diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0b245f1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# The same lockfile bytes identify production artifacts on every build host. +Cargo.lock text eol=lf diff --git a/.github/workflows/platform-delivery.yml b/.github/workflows/platform-delivery.yml new file mode 100644 index 0000000..31e79c5 --- /dev/null +++ b/.github/workflows/platform-delivery.yml @@ -0,0 +1,78 @@ +name: Release builds + +on: + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + production: + name: ${{ matrix.label }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - name: apple + label: Apple + platform: apple + runner: macos-15 + targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios,aarch64-apple-darwin,x86_64-apple-darwin + manifest: dist/apple/vcore-delivery.json + - name: android + label: Android + platform: android + runner: ubuntu-24.04 + targets: aarch64-linux-android,x86_64-linux-android + manifest: dist/android/vcore-delivery.json + - name: windows-x64 + label: Windows x64 + platform: windows + runner: windows-2025 + targets: x86_64-pc-windows-msvc + manifest: dist/windows/x64/vcore-delivery.json + - name: windows-arm64 + label: Windows ARM64 + platform: windows + runner: windows-11-arm + targets: aarch64-pc-windows-msvc + manifest: dist/windows/arm64/vcore-delivery.json + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.targets }} + components: rustfmt + - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 + with: + version: "0.12.15" + python-version: "3.13" + - name: Platform artifact contracts + run: uv run --project scripts --locked python -m unittest discover -s scripts/tests -p test_platform_delivery.py + - name: Locked source + run: cargo fetch --locked + - name: Android toolchain + if: matrix.platform == 'android' + shell: bash + run: | + "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" "ndk;28.2.13676358" + echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/28.2.13676358" >> "$GITHUB_ENV" + - name: Build production artifacts and record identity + run: uv run --project scripts --locked vcore-scripts build ${{ matrix.platform }} --delivery + - name: Independently verify packaged files + run: uv run --project scripts --locked vcore-scripts check platform-artifacts --manifest ${{ matrix.manifest }} + - name: Native C ABI and Windows snapshot/startup contracts + if: matrix.platform != 'android' + run: uv run --project scripts --locked vcore-scripts check platform-abi --manifest ${{ matrix.manifest }} + - name: Preserve unsigned artifacts and evidence + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: vcore-${{ matrix.name }}-${{ github.sha }} + path: | + dist/${{ matrix.platform }}/ + target/platform-delivery/abi/**/result.json + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4e451c0..0cbc86e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,25 +9,20 @@ permissions: contents: read jobs: - test: - runs-on: windows-2025 - timeout-minutes: 30 - steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - - uses: ilammy/msvc-dev-cmd@v1 - with: - arch: x64 - - name: Build default features - run: cargo build --locked --all-targets - - name: Debug tests - run: cargo test --locked --all-features --all-targets - - name: Release tests - run: cargo test --locked --release --all-features --all-targets - client-contracts: + name: ${{ matrix.name }} runs-on: macos-15 - timeout-minutes: 45 + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - name: Core tests (Debug) + profile: debug + - name: Core tests (Release) + profile: release + - name: Quality and features + profile: features steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable @@ -38,6 +33,7 @@ jobs: version: "0.12.15" python-version: "3.13" - name: Formatting, scripts, and dependency contract + if: matrix.profile == 'features' run: | cargo fmt --all -- --check uv run --project scripts --locked python -m unittest discover -s scripts/tests @@ -45,96 +41,20 @@ jobs: uv run --project scripts --locked ruff format --check scripts uv run --project scripts --locked vcore-scripts check c-header uv run --project scripts --locked vcore-scripts check tls-dependencies - - name: Minimal and individual protocol features - run: | - cargo check --locked --no-default-features --lib - for features in inbound-http inbound-socks5 outbound-anytls outbound-socks5 outbound-shadowsocks outbound-vless inbound-http,inbound-socks5 ffi; do - cargo check --locked --no-default-features --features "$features" --lib - done - - name: Debug and Release runtime tests - run: | - cargo test --locked --all-features --all-targets - cargo test --locked --release --all-features --all-targets - cargo test --locked --manifest-path crates/vcore-netstack/Cargo.toml --all-targets - - name: Production and netstack clippy + - name: Production, instrumented harness, and netstack clippy + if: matrix.profile == 'features' run: | + cargo clippy --locked --features ffi --lib --bins -- -D warnings cargo clippy --locked --all-features --lib --bins -- -D warnings - cargo clippy --locked --features ffi --test mihomo_interop -- -D warnings + cargo clippy --locked --features ffi,interop-test --test mihomo_interop -- -D warnings cargo clippy --locked --manifest-path crates/vcore-netstack/Cargo.toml --all-targets -- -D warnings + - name: Run ${{ matrix.name }} + run: uv run --project scripts --locked vcore-scripts check core --profile ${{ matrix.profile }} + - name: Memory-only netstack tests + if: matrix.profile == 'features' + run: | + cargo test --locked --manifest-path crates/vcore-netstack/Cargo.toml --all-targets platform-build: - name: Release ${{ matrix.name }} - runs-on: ${{ matrix.runner }} - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - include: - - name: apple - platform: apple - runner: macos-15 - targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios,aarch64-apple-darwin,x86_64-apple-darwin - - name: android - platform: android - runner: ubuntu-24.04 - targets: aarch64-linux-android,x86_64-linux-android - - name: windows-x64 - platform: windows - runner: windows-2025 - targets: x86_64-pc-windows-msvc - - name: windows-arm64 - platform: windows - runner: windows-11-arm - targets: aarch64-pc-windows-msvc - steps: - - uses: actions/checkout@v7 - - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.targets }} - components: rustfmt - - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 - with: - version: "0.12.15" - python-version: "3.13" - - name: Record locked source and toolchain - run: | - git rev-parse HEAD - rustc --version --verbose - cargo fetch --locked - - name: Apple SDK versions - if: matrix.platform == 'apple' - run: | - xcodebuild -version - xcodebuild -showsdks - - name: Build Android with the pinned NDK - if: matrix.platform == 'android' - shell: bash - run: | - sdkmanager "ndk;28.2.13676358" - export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/28.2.13676358" - "$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/clang" --version - uv run --project scripts --locked vcore-scripts build android - - name: Build native platform artifacts - if: matrix.platform != 'android' - run: uv run --project scripts --locked vcore-scripts build ${{ matrix.platform }} - - name: Record Unix artifact hashes - if: runner.os != 'Windows' - shell: bash - run: | - shasum -a 256 Cargo.lock - find dist -type f -exec shasum -a 256 {} + - - name: Record Windows artifact hashes - if: runner.os == 'Windows' - shell: pwsh - run: | - Get-FileHash Cargo.lock -Algorithm SHA256 - Get-ChildItem dist -Recurse -File | Get-FileHash -Algorithm SHA256 - - name: Preserve unsigned build artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: vcore-${{ matrix.name }}-${{ github.sha }} - path: | - dist/${{ matrix.platform }}/ - Cargo.lock - if-no-files-found: error - retention-days: 7 + name: Release builds + uses: ./.github/workflows/platform-delivery.yml diff --git a/AGENTS.md b/AGENTS.md index 2637745..2bd8ab5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Project Overview -VCore is a standalone Rust proxy core. The current public contract is Invoke API v5 with internal schema revision 14. Runtime configuration uses the strict schema documented in `docs/config.yaml` and is passed inline as `configYaml` / `configYamls`; YAML contains neither `configVersion` nor `default-proxy`. Public lifecycle state is runtime-local and single-instance. +VCore is a standalone Rust proxy core. The current public contract is Invoke API v5 with internal schema revision 27. Runtime configuration uses the strict schema documented in `docs/config.yaml` and is passed inline as `configYaml` / `configYamls`; YAML contains neither `configVersion` nor `default-proxy`. Public lifecycle state is runtime-local and single-instance. Apple and Android use host-owned TUN fds through the Unix `rust-tun` adapter. Windows uses `windows-rs` / `Windows.Networking.Vpn`; the packaged ARM64 foreground, AppContainer provider, per-session full-trust runtime, lifecycle, pressure, and packet-channel gates pass on Windows 11. Windows 10, native x64, physical IPv6, WACK, and Store publishing remain release gates. Linux remains unsupported. @@ -12,15 +12,17 @@ Read the relevant document completely before changing that area: - FFI, lifecycle, Android protect, or config delivery: `docs/invoke-api.md` and `src/ffi/`. - YAML, proxy graph, proxy groups, DNS, rules, or sniffer: `docs/config.yaml`, `docs/tun-icmp-dns.md`, and `src/config/`. -- HTTP/SOCKS5 inbound, authentication, or client listener policy: `docs/http-proxy.md`, `docs/socks5-proxy.md`, and `src/inbound/`. -- AnyTLS: `docs/anytls.md`. -- Shadowsocks 2022 and its narrowly scoped AWS-LC dependency exception: `docs/shadowsocks.md`. +- HTTP/SOCKS5 inbound, authentication, or listeners: `docs/inbounds.md` and `src/inbound/`. +- SOCKS5, AnyTLS, Trojan, VMess, Hysteria2 or SS 2022: `docs/outbounds.md`. +- VLESS, Vision, Encryption, JLS or static ECH: `docs/vless.md`. +- XHTTP request fields, download legs, H1/H2/H3 or sing-mux: `docs/xhttp.md`. - Runtime Controller, proxy-group selection, or TUN traffic metrics: `docs/controller-api.md` and `src/controller.rs`. - GeoData: `docs/geodata.md`. -- REALITY or the GitHub rustls fork: `docs/reality-wire-protocol.md` and `docs/rustls-reality-release.md`. +- TLS profiles, certificate policy or TLS dependencies: `docs/tls-client-fingerprint.md` and `docs/tls-dependencies.md`; REALITY also requires `docs/reality-wire-protocol.md`. - Unix TUN fd ownership or packet I/O: `docs/tun-platform.md`. -- Windows VPN/TUN, outbound binding, AppContainer packet buffers, or package lifecycle: `docs/windows-vpn.md`, `docs/windows-session-runtime.md`, and `docs/tun-platform.md`. -- Build, validation, or interoperability tooling: `scripts/README.md` and the unified `vcore-scripts` interface. +- Windows VPN/TUN, outbound binding, AppContainer packet buffers, or package lifecycle: `docs/windows-vpn.md` and `docs/tun-platform.md`. +- Build, validation, or interoperability tooling: `scripts/README.md`, `tests/README.md`, and the unified `vcore-scripts` interface. +- Server-side tests: `docs/testing-isolation.md`. All new server peers and network origins must run in isolated containers; never fall back to native host servers. - Claims that something passed: `docs/acceptance.md`. Record only commands and environments actually executed; host tests and cross-builds do not prove physical-device data paths. # Architecture Boundaries @@ -46,6 +48,7 @@ Read the relevant document completely before changing that area: 8. When copying or modifying third-party source, record the upstream project and preserve all applicable license terms. Audit linked dependencies against the resolved release graph. Do not describe independent rewrites, protocol interoperability, or architectural references as derived source without evidence; Credits provide context and attribution, not a substitute for release license review. 9. Keep every public surface—code, documentation, tests, examples, commits, issues, pull requests, reviews, CI output, and releases—limited to VCore and public dependencies. Keep private downstream repository or product identities, links, implementation details, status, artifacts, and roadmaps outside this repository and its GitHub surfaces. Before publishing, search the staged diff and proposed GitHub text for downstream identifiers. 10. Make changes on a separate branch, never directly on `main`. +11. Use the official latest stable third-party dependencies, including test-only experiments. Check current non-prerelease, non-yanked releases when selecting or upgrading them; reference projects' old pins are not a version policy. Keep lockfiles for reproducible validation, refresh affected dependency audits, and rerun applicable tests. If freshness conflicts with the approved fork/provider or a platform's compatible dependency set, report the conflict and obtain an explicit exception or scope decision instead of silently retaining an old version or changing the security architecture. # Validation @@ -53,7 +56,9 @@ Choose the smallest relevant set, then expand for shared contracts: ```shell cargo fmt --all -- --check -cargo test --all-features --all-targets +uv run --project scripts --locked vcore-scripts check core --profile debug +# All-target compilation only; network peers must be containerized. +cargo test --locked --all-features --all-targets --no-run cargo clippy --locked --all-features --lib --bins -- -D warnings cargo test --manifest-path crates/vcore-netstack/Cargo.toml --all-targets cargo clippy --manifest-path crates/vcore-netstack/Cargo.toml --all-targets -- -D warnings @@ -62,8 +67,6 @@ uv run --project scripts --locked vcore-scripts check tls-dependencies uv run --project scripts --locked python -m unittest discover -s scripts/tests uv run --project scripts --locked ruff check scripts uv run --project scripts --locked ruff format --check scripts -sh -n tests/run_xray_interop.sh tests/run_anytls_interop.sh -sh -n tests/run_mihomo_interop.sh git diff --check ``` diff --git a/Cargo.lock b/Cargo.lock index 844140d..dbd7cb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,13 +2,19 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "crypto-common 0.2.2", + "crypto-common", "inout", ] @@ -20,7 +26,8 @@ checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher", "cpubits", - "cpufeatures 0.3.1", + "cpufeatures", + "zeroize", ] [[package]] @@ -37,6 +44,30 @@ dependencies = [ "ghash", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "arc-swap" version = "1.9.2" @@ -76,8 +107,8 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", - "synstructure", + "syn 2.0.119", + "synstructure 0.13.2", ] [[package]] @@ -88,7 +119,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -111,13 +142,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -156,18 +187,30 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "base64" version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + [[package]] name = "bit-vec" version = "0.9.1" @@ -185,9 +228,18 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +dependencies = [ + "digest", +] [[package]] name = "blake3" @@ -199,16 +251,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures 0.3.1", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", + "cpufeatures", ] [[package]] @@ -222,9 +265,9 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel", "async-task", @@ -233,6 +276,52 @@ dependencies = [ "piper", ] +[[package]] +name = "boring" +version = "5.2.0" +source = "git+https://github.com/OneXray/boring?branch=release#d5a5d41850886aef5b269015130ee6d6eb2129ba" +dependencies = [ + "bitflags 2.13.2", + "boring-sys", + "brotli", + "flate2", + "foreign-types", + "libc", + "openssl-macros", +] + +[[package]] +name = "boring-sys" +version = "5.2.0" +source = "git+https://github.com/OneXray/boring?branch=release#d5a5d41850886aef5b269015130ee6d6eb2129ba" +dependencies = [ + "bindgen", + "cmake", + "fs_extra", + "fslock", +] + +[[package]] +name = "brotli" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -259,21 +348,30 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.2.67" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", ] [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -289,8 +387,8 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cipher", - "cpufeatures 0.3.1", - "rand_core 0.10.1", + "cpufeatures", + "rand_core", ] [[package]] @@ -311,11 +409,22 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer 0.12.1", - "crypto-common 0.2.2", + "block-buffer", + "crypto-common", "inout", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + [[package]] name = "cmake" version = "0.1.58" @@ -340,6 +449,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -352,15 +467,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "cpufeatures" version = "0.3.1" @@ -371,20 +477,19 @@ dependencies = [ ] [[package]] -name = "crossbeam-utils" -version = "0.8.22" +name = "crc32fast" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] [[package]] -name = "crypto-common" -version = "0.1.7" +name = "crossbeam-utils" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crypto-common" @@ -394,7 +499,7 @@ checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "getrandom 0.4.3", "hybrid-array", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -417,12 +522,12 @@ dependencies = [ [[package]] name = "curve25519-dalek" -version = "4.1.3" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "curve25519-dalek-derive", "fiat-crypto", "rustc_version", @@ -438,7 +543,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -447,15 +552,6 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" -[[package]] -name = "defmt" -version = "0.3.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" -dependencies = [ - "defmt 1.1.1", -] - [[package]] name = "defmt" version = "1.1.1" @@ -475,7 +571,7 @@ dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -509,23 +605,25 @@ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "digest" -version = "0.10.7" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", + "block-buffer", + "const-oid", + "crypto-common", + "ctutils", ] [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -551,9 +649,15 @@ checksum = "5ac0893f103ae7bf50181323d748d505f27b6255777c28b502030bdaf8749f4c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + [[package]] name = "equivalent" version = "1.0.2" @@ -572,11 +676,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -593,21 +696,31 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fiat-crypto" -version = "0.2.9" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] [[package]] name = "fnv" @@ -615,6 +728,33 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -631,10 +771,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] -name = "futures" -version = "0.3.33" +name = "fslock" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -647,9 +797,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -657,15 +807,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -674,9 +824,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -690,32 +840,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -728,16 +878,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -745,20 +885,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", + "wasm-bindgen", ] [[package]] @@ -768,9 +898,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", + "r-efi", + "rand_core", + "wasm-bindgen", ] [[package]] @@ -783,10 +915,16 @@ dependencies = [ ] [[package]] -name = "h2" -version = "0.4.15" +name = "glob" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -801,6 +939,34 @@ dependencies = [ "tracing", ] +[[package]] +name = "h3" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be" +dependencies = [ + "bytes", + "fastrand", + "futures-util", + "http", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "h3-quinn" +version = "0.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4" +dependencies = [ + "bytes", + "futures", + "h3", + "quinn", + "tokio", + "tokio-util", +] + [[package]] name = "hash32" version = "0.3.1" @@ -827,15 +993,67 @@ dependencies = [ ] [[package]] -name = "http" -version = "1.4.2" +name = "hkdf" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[package]] +name = "hpke" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5324110b02044183df000f0bd7d2d7e61f1000631508627e77f63983b6fdffb" +dependencies = [ + "aead", + "aes-gcm", + "chacha20poly1305", + "hkdf", + "hybrid-array", + "rand_core", + "sha2", + "subtle", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", ] +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "hybrid-array" version = "0.4.15" @@ -846,10 +1064,44 @@ dependencies = [ ] [[package]] -name = "icu_collections" -version = "2.2.0" +name = "hyper" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -861,9 +1113,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -874,9 +1126,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -888,16 +1140,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -908,15 +1161,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -950,9 +1203,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown", @@ -969,9 +1222,18 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] [[package]] name = "itoa" @@ -991,15 +1253,25 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1012,6 +1284,16 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libloading" version = "0.9.0" @@ -1030,9 +1312,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -1045,9 +1327,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "lru_time_cache" @@ -1063,9 +1351,9 @@ checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", "digest", @@ -1084,10 +1372,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] -name = "mio" -version = "1.2.1" +name = "miniz_oxide" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", @@ -1100,12 +1398,18 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -1165,6 +1469,17 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "oslog" version = "0.2.0" @@ -1226,7 +1541,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1258,7 +1573,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" dependencies = [ - "cpufeatures 0.3.1", + "cpufeatures", "universal-hash", ] @@ -1269,15 +1584,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", - "cpufeatures 0.3.1", + "cpufeatures", "universal-hash", ] [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -1288,38 +1603,80 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] -name = "quote" -version = "1.0.46" +name = "quinn" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ - "proc-macro2", + "bytes", + "cfg_aliases", + "futures-io", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "quinn-proto" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] [[package]] name = "r-efi" @@ -1329,48 +1686,13 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.5" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", + "rand_core", ] [[package]] @@ -1380,10 +1702,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] -name = "rcgen" -version = "0.14.8" +name = "rand_pcg" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rcgen" +version = "0.14.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" dependencies = [ "ring", "rustls-pki-types", @@ -1398,15 +1729,29 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.4.15" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ + "aho-corasick", + "memchr", "regex-syntax", ] @@ -1430,6 +1775,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1450,11 +1801,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -1463,32 +1814,33 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" -source = "git+https://github.com/OneVCore/rustls?branch=vcore%2Freality-0.23#df261c84cbac4f708e63ac8644ce70daa90d771c" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "ring", "rustls-pki-types", "rustls-webpki", "subtle", - "x25519-dalek", "zeroize", ] [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -1521,7 +1873,7 @@ checksum = "9b68e2ea526d9fb32f23ca8894fb5da9e743f34c2f41701f0501dc8a25c4b343" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -1542,9 +1894,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1552,22 +1904,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] @@ -1609,23 +1961,35 @@ dependencies = [ ] [[package]] -name = "sha2" -version = "0.10.9" +name = "sha1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", "digest", ] [[package]] name = "shadowsocks" version = "1.25.0" -source = "git+https://github.com/shadowsocks/shadowsocks-rust.git?rev=ab388c7466d21f979430e33cc9ef10e22fb05955#ab388c7466d21f979430e33cc9ef10e22fb05955" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2065b026dbe4f47048eca384adf07f693bf901050d80abd3adb7b2422709b2d" dependencies = [ "aes", - "base64 0.23.1", + "base64", "blake3", "byte_string", "bytes", @@ -1637,7 +2001,7 @@ dependencies = [ "lru_time_cache", "percent-encoding", "pin-project", - "rand 0.10.2", + "rand", "sealed", "sendfd", "serde", @@ -1667,9 +2031,26 @@ dependencies = [ "bytes", "cfg-if", "chacha20poly1305", - "rand 0.10.2", + "rand", ] +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -1686,6 +2067,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -1694,29 +2081,29 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "smoltcp" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f73d40463bba65efc9adc6370b56df76d563cc46e2482bba58351b4afb7535e" +checksum = "b6f8b28ad56c6e35524a37dd492af5d1a47e31e1a4d175cd12f89c075f01980f" dependencies = [ "bitflags 1.3.2", "byteorder", "cfg-if", - "defmt 0.3.100", + "defmt", "heapless", "managed", ] [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -1731,12 +2118,24 @@ dependencies = [ "lock_api", ] +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "subtle" version = "2.6.1" @@ -1745,9 +2144,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -1756,9 +2155,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -1773,7 +2172,18 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", ] [[package]] @@ -1791,22 +2201,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -1841,19 +2251,25 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", ] [[package]] -name = "tokio" -version = "1.52.3" +name = "tinyvec" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -1866,22 +2282,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-boring" +version = "5.2.0" +source = "git+https://github.com/OneXray/boring?branch=release#d5a5d41850886aef5b269015130ee6d6eb2129ba" +dependencies = [ + "boring", + "boring-sys", + "tokio", +] + [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -1905,15 +2331,29 @@ dependencies = [ ] [[package]] -name = "tokio-util" -version = "0.7.18" +name = "tokio-tungstenite" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "futures-util", + "libc", "pin-project-lite", "tokio", ] @@ -1937,7 +2377,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1957,9 +2397,15 @@ checksum = "b19a4867a870f6edc4c283f2b455804b1879c0baf0e642f26b03ed8ee262d9d3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "tun" version = "0.8.14" @@ -1978,6 +2424,22 @@ dependencies = [ "wintun-bindings", ] +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", +] + [[package]] name = "typenum" version = "1.20.1" @@ -1986,9 +2448,9 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "universal-hash" @@ -1996,7 +2458,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common 0.2.2", + "crypto-common", "ctutils", ] @@ -2038,9 +2500,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.4" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "js-sys", "serde_core", @@ -2054,41 +2516,70 @@ dependencies = [ "aes", "arc-swap", "async-trait", - "base64 0.22.1", + "base64", + "blake2", "blake3", + "boring", + "boring-sys", "bytes", + "crc32fast", + "foreign-types", "futures-util", "h2", + "h3", + "h3-quinn", + "hpke", "http", + "httparse", + "hyper", + "hyper-util", "idna", "libc", "log", "md-5", "oslog", - "rand 0.9.5", + "quinn", + "quinn-proto", + "rand", "rcgen", "regex-automata", + "ring", "rustls", "serde", "serde_json", "serde_yaml_ng", "sha2", "shadowsocks", + "shake", "socket2", "tempfile", "thiserror", "tokio", + "tokio-boring", "tokio-rustls", + "tokio-tungstenite", "tokio-util", "tracing", "tun", "url", "uuid", + "vcore-blake3-raw", "vcore-netstack", "webpki-roots", "windows", "windows-collections", "windows-core", + "yamux", + "zeroize", +] + +[[package]] +name = "vcore-blake3-raw" +version = "0.1.0" +dependencies = [ + "blake3", + "cc", + "serde_json", ] [[package]] @@ -2104,10 +2595,13 @@ dependencies = [ ] [[package]] -name = "version_check" -version = "0.9.5" +name = "want" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] [[package]] name = "wasi" @@ -2115,20 +2609,11 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -2139,9 +2624,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2149,35 +2634,67 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] [[package]] -name = "webpki-roots" -version = "1.0.8" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" @@ -2231,7 +2748,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2242,7 +2759,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2446,38 +2963,32 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "wintun-bindings" -version = "0.7.39" +version = "0.7.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc4494d02357537af05cf526be7b817a51752b688a78926af57379abd840d911" +checksum = "e4316764300a7eb4aecf4770c81ae629ff1161b54905d48721a6832fd296ce5f" dependencies = [ "blocking", "futures", - "libloading", + "libloading 0.9.0", "log", "thiserror", "windows-sys 0.61.2", ] -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "x25519-dalek" -version = "2.0.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" dependencies = [ "curve25519-dalek", - "rand_core 0.6.4", + "rand_core", "zeroize", ] @@ -2499,6 +3010,21 @@ dependencies = [ "time", ] +[[package]] +name = "yamux" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4e661d6ba6f09a768e31c6f3d88f9fac596ad5d376515d598a37b62d220b34" +dependencies = [ + "futures", + "log", + "nohash-hasher", + "parking_lot", + "pin-project", + "static_assertions", + "web-time", +] + [[package]] name = "yasna" version = "0.6.0" @@ -2522,34 +3048,14 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] @@ -2563,14 +3069,14 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", - "synstructure", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] @@ -2590,14 +3096,14 @@ checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -2606,9 +3112,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -2617,17 +3123,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.6", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 1289610..1b95f67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/vcore-netstack"] +members = ["crates/vcore-blake3-raw", "crates/vcore-netstack"] resolver = "3" [package] @@ -23,6 +23,11 @@ name = "vcore-windows-session-host" path = "src/bin/windows_session_host.rs" required-features = ["ffi"] +[[example]] +name = "protocol-stream-probe" +path = "tests/protocols/stream_probe.rs" +required-features = ["interop-test", "stream-transport"] + [features] default = [ "inbound-http", @@ -30,14 +35,23 @@ default = [ "outbound-anytls", "outbound-socks5", "outbound-shadowsocks", + "outbound-trojan", + "outbound-vmess", "outbound-vless", + "outbound-hysteria2", ] inbound-http = [] inbound-socks5 = [] -outbound-anytls = ["dep:md-5"] +outbound-anytls = ["dep:md-5", "tls-fingerprint"] outbound-socks5 = [] outbound-shadowsocks = ["dep:shadowsocks", "dep:log"] -outbound-vless = ["rustls/reality"] +outbound-vless = ["tls-fingerprint", "boring/reality", "boring/jls", "boring/mlkem", "dep:blake3", "dep:hpke", "dep:zeroize", "dep:vcore-blake3-raw", "stream-transport", "quic-transport", "dep:hyper", "dep:hyper-util", "dep:h3", "dep:h3-quinn", "dep:yamux", "tokio-util/compat"] +outbound-trojan = ["stream-transport", "tls-fingerprint"] +outbound-vmess = ["stream-transport", "tls-fingerprint", "dep:aes", "dep:crc32fast", "dep:md-5", "dep:ring", "dep:shake"] +tls-fingerprint = ["dep:boring", "dep:tokio-boring"] +outbound-hysteria2 = ["quic-transport", "dep:quinn-proto", "dep:h3", "dep:h3-quinn", "dep:blake2"] +stream-transport = ["dep:tokio-tungstenite", "dep:httparse", "dep:log"] +quic-transport = ["dep:quinn"] # Enables local interoperability-only trust-root injection. Release build # scripts must not enable this feature. interop-test = ["outbound-anytls", "outbound-vless"] @@ -50,41 +64,64 @@ tun = [ "outbound-anytls", "outbound-socks5", "outbound-shadowsocks", + "outbound-trojan", + "outbound-vmess", "outbound-vless", + "outbound-hysteria2", ] [dependencies] +aes = { version = "0.9.3", optional = true, features = ["zeroize"] } arc-swap = "1" async-trait = "0.1" -base64 = "0.22" +base64 = "0.23" +blake2 = { version = "=0.11.0", optional = true } +blake3 = { version = "=1.8.7", optional = true } +boring = { git = "https://github.com/OneXray/boring", branch = "release", version = "=5.2.0", features = ["client-fingerprint"], optional = true } bytes = "1" -futures-util = "0.3" -h2 = "0.4" +crc32fast = { version = "1.5.2", optional = true } +futures-util = { version = "0.3", features = ["sink"] } +h2 = "0.4.19" +h3 = { version = "=0.0.8", default-features = false, optional = true } +h3-quinn = { version = "=0.0.10", default-features = false, optional = true } +hpke = { version = "=0.14.1", default-features = false, features = ["alloc", "aes", "chacha", "x25519"], optional = true } http = "1" +hyper = { version = "=1.11.1", default-features = false, features = ["client", "http1"], optional = true } +hyper-util = { version = "=0.1.20", default-features = false, features = ["tokio"], optional = true } +httparse = { version = "1.10", optional = true } idna = "1.1" libc = "0.2" # Upstream SS trace/debug logs include key material and traffic destinations. # Keep the log facade compiled out when SS is linked; VCore uses tracing. log = { version = "0.4", optional = true, features = ["max_level_off", "release_max_level_off"] } -md-5 = { version = "0.10", optional = true } -rand = "0.9" +md-5 = { version = "0.11", optional = true } +quinn = { version = "=0.11.12", default-features = false, features = ["runtime-tokio", "rustls-ring"], optional = true } +quinn-proto = { version = "=0.11.18", default-features = false, features = ["rustls-ring"], optional = true } +rand = "0.10" +ring = { version = "0.17.14", optional = true } regex-automata = { version = "0.4", default-features = false, features = ["dfa-build", "dfa-search", "syntax", "unicode"] } -rustls = { version = "=0.23.43", default-features = false, features = ["ring", "std", "tls12"] } +rustls = { version = "=0.23.45", default-features = false, features = ["ring", "std", "tls12"] } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml_ng = "0.10" -sha2 = "0.10" -shadowsocks = { git = "https://github.com/shadowsocks/shadowsocks-rust.git", rev = "ab388c7466d21f979430e33cc9ef10e22fb05955", optional = true, default-features = false, features = ["aead-cipher-2022"] } +sha2 = "0.11" +shake = { version = "0.1.0", optional = true } +shadowsocks = { version = "=1.25.0", optional = true, default-features = false, features = ["aead-cipher-2022"] } socket2 = { version = "0.6", features = ["all"] } thiserror = "2" tokio = { version = "1", features = ["io-util", "macros", "net", "rt", "sync", "time"] } -tokio-rustls = { version = "=0.26.4", default-features = false, features = ["ring", "tls12"] } +tokio-boring = { git = "https://github.com/OneXray/boring", branch = "release", version = "=5.2.0", optional = true } +tokio-rustls = { version = "=0.26.5", default-features = false, features = ["ring", "tls12"] } +tokio-tungstenite = { version = "=0.30.0", default-features = false, features = ["handshake"], optional = true } tokio-util = { version = "0.7", features = ["rt"] } tracing = "0.1" url = "2" uuid = { version = "1", features = ["serde"] } vcore-netstack = { path = "crates/vcore-netstack", optional = true } +vcore-blake3-raw = { path = "crates/vcore-blake3-raw", optional = true } webpki-roots = "1" +yamux = { version = "=0.14.1", optional = true } +zeroize = { version = "=1.9.0", optional = true } [target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies] oslog = { version = "0.2", default-features = false } @@ -118,19 +155,22 @@ windows = { version = "=0.62.2", optional = true, features = [ "Win32_System_WinRT", "Win32_UI_Shell", ] } +# Keep the companion types required by the latest windows SDK. The separately +# released 0.100.x crates are not type-compatible with windows 0.62.2. windows-collections = { version = "=0.3.2", optional = true } windows-core = { version = "=0.62.2", optional = true } [dev-dependencies] +# Native peer controls for bounded in-memory ALPS tests; same published fork. +boring-sys = { git = "https://github.com/OneXray/boring", branch = "release", version = "=5.2.0" } +foreign-types = "=0.5.0" +tokio = { version = "1", features = ["test-util"] } # Independent EIH relay fixture only; production crypto remains the official SS crate. aes = "0.9" blake3 = "1" -rcgen = { version = "=0.14.8", default-features = false, features = ["crypto", "ring"] } +rcgen = { version = "=0.14.10", default-features = false, features = ["crypto", "ring"] } tempfile = "3" -[patch.crates-io] -rustls = { git = "https://github.com/OneVCore/rustls", branch = "vcore/reality-0.23" } - [profile.release] codegen-units = 1 lto = "thin" diff --git a/README.md b/README.md index daa0b39..343b8ee 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ English · 简体中文 · Русский

-VCore is a standalone, host-agnostic Rust client proxy core. It provides proxy graphs, static `select` proxy groups, DNS, routing rules, GeoData, HTTP/SOCKS5 listeners, a TUN data plane, and a loopback Controller through strict YAML configuration and Invoke API v5. The internal configuration schema revision is 14; the revision appears only in the `version` response and `buildIdentity`, not in YAML. +VCore is a standalone, host-agnostic Rust client proxy core. It provides proxy graphs, static `select` proxy groups, DNS, routing rules, GeoData, HTTP/SOCKS5 listeners, a TUN data plane, and a loopback Controller through strict YAML configuration and Invoke API v5. The internal configuration schema revision is 27; the revision appears only in the `version` response and `buildIdentity`, not in YAML. ## Features -- Outbounds: VLESS + XHTTP + TLS/REALITY, SOCKS5 CONNECT/UDP ASSOCIATE, AnyTLS TCP/UoT, Shadowsocks 2022, and DIRECT. SS supports three standard 2022 algorithms and AES identity chains; see its [upstream risks and acceptance boundaries](docs/shadowsocks.md). +- Outbounds: [VLESS TCP/WS/gRPC/HTTP/H2/XHTTP, TLS/REALITY and Vision](docs/vless.md), SOCKS5 CONNECT/UDP ASSOCIATE, AnyTLS TCP/UoT, Shadowsocks 2022, [Trojan TCP/UDP over TLS/WS/gRPC](docs/outbounds.md#trojan), [VMess AEAD over TCP/WS/gRPC/HTTP/H2](docs/outbounds.md#vmess-aead), [Hysteria2 TCP/UDP, bandwidth, Salamander and port hopping](docs/outbounds.md#hysteria2), and DIRECT. SS supports three standard 2022 algorithms and AES identity chains; see its [upstream risks and acceptance boundaries](docs/outbounds.md#shadowsocks-2022). - Proxy chains: `dialer-proxy` forms a directed acyclic graph of arbitrary length. If node A points to B, the physical path is `client -> B -> A -> target`. - Proxy groups: static `select` groups keep ordered members, including concrete nodes, nested groups, `DIRECT`, and `REJECT`; their current-session selection can be changed live through the Controller. `dialer-proxy` may reference nodes or groups; DIRECT in an upstream group connects the current node's prepared server. - Routing: ordered `DOMAIN`, `DOMAIN-SUFFIX`, `DOMAIN-KEYWORD`, `GEOSITE`, `GEOIP`, `IP-CIDR`, `IP-CIDR6`, `DST-PORT`, `NETWORK`, and final `MATCH` rules. @@ -17,6 +17,7 @@ VCore is a standalone, host-agnostic Rust client proxy core. It provides proxy g - Listeners: HTTP CONNECT/forward with per-request authentication/routing, streaming bodies, Keep-Alive and Upgrade; SOCKS5 CONNECT and TCP-authorized UDP ASSOCIATE. Local access is unauthenticated by default; opt-in LAN sharing requires one shared username/password. - GeoData: VCore manages `geosite.dat` and `geoip.dat` under `dataDir/geodata`, loads them on demand, and can update them through a proxy chain. - Delay measurement: `measureDelay` accepts 1–5 node-only configurations per call, uses up to five private workers, and preserves input order in its results. +- TLS: independent certificate pins and four opt-in ClientHello templates (Chrome120/133, Firefox120, Safari16.0) over TCP TLS/REALITY/JLS; see [public names, scope and limitations](docs/tls-client-fingerprint.md). [VLESS JLS](docs/vless.md#jls) uses separate shared credentials with complete native TLS authentication. [Static ECH](docs/vless.md#静态-ech) uses explicit VLESS main/download-leg configuration with no dynamic DNS lookup or fallback. ## Configuration @@ -72,6 +73,8 @@ Authorization: Bearer `GET /traffic` is a one-time `up/down/upTotal/downTotal` TUN snapshot. The group endpoints expose and change the selected direct member of static `select` groups; a successful change affects only new physical TCP, UDP, and DNS transports in the current session. It does not migrate existing connections, UDP associations, DNS state, or pooled TCP transports, and it never performs automatic failover. A Controller that manages groups requires one Bearer secret for all routes and may run without TUN. See [`docs/controller-api.md`](docs/controller-api.md). +An authenticated Hysteria2 session keeps its upstream selection during port hopping. A replacement socket for that same QUIC session does not resample the group; only a new authenticated session does. + ## Platforms | Platform | Data plane | Status | @@ -85,43 +88,15 @@ Windows does not use an fd emulation layer. The Provider owns only `VpnChannel`, ## Resource Bounds -The current TUN profile keeps local structural bounds rather than a fixed admission limit on the total number of business flows: - -```text -raw packet / MTU 1,500 bytes -packet queue 256 -ordinary event / UDP response 128 -DNS ingress / DNS response 128 / 128 -TCP buffer 32 KiB per direction -TLS / XHTTP buffer 64 KiB -DNS typed cache 256 entries -DNS opaque cache 64 entries / 256 KiB -GeoData allocation capacity 8 MiB -``` - -Windows advertises a 1,400-byte L3 MTU as required by `StartWithMainTransport` and caps TUN UDP responses at 1,352 bytes; 1,500 bytes remain the cross-platform parser ceiling. - -TCP sessions, ordinary UDP associations, half-open connections, outbound handshakes, and active DNS transports are created on demand. Bounded queues, per-flow buffers, wire/parser limits, timeouts, idle cleanup, and caches provide structural safety. The iOS 35/45 MiB targets are best-effort observations and do not change lifecycle results. +Safety is enforced through per-object queues, buffers, parser limits, deadlines and owned cancellation, not a global business-flow quota. See the [resource policy](docs/runtime-resource-policy.md) for shared limits and protocol-specific contracts for local budgets. Memory telemetry does not change lifecycle results. ## Documentation - [Documentation index](docs/README.md) -- [Configuration contract](docs/config.yaml) +- [Configuration](docs/config.yaml) - [Invoke API](docs/invoke-api.md) -- [HTTP proxy inbound](docs/http-proxy.md) -- [SOCKS5 proxy inbound](docs/socks5-proxy.md) -- [AnyTLS outbound](docs/anytls.md) -- [Shadowsocks 2022 outbound and limitations](docs/shadowsocks.md) -- [REALITY V1 client protocol](docs/reality-wire-protocol.md) -- [rustls REALITY dependency and release requirements](docs/rustls-reality-release.md) -- [Runtime Controller](docs/controller-api.md) -- [TUN ICMP and DNS](docs/tun-icmp-dns.md) -- [GeoData rules and assets](docs/geodata.md) -- [TUN platform layer](docs/tun-platform.md) -- [Windows VPN platform boundary](docs/windows-vpn.md) -- [Windows session runtime](docs/windows-session-runtime.md) -- [Runtime resource policy](docs/runtime-resource-policy.md) -- [Acceptance matrix](docs/acceptance.md) +- [Build and test](scripts/README.md) +- [Acceptance boundaries](docs/acceptance.md) ## Example @@ -131,7 +106,7 @@ TCP sessions, ordinary UDP associations, half-open connections, outbound handsha ```bash cargo fmt --all -- --check -cargo test --all-features --all-targets +uv run --project scripts --locked vcore-scripts check core --profile debug cargo clippy --locked --all-features --lib --bins -- -D warnings cargo test --manifest-path crates/vcore-netstack/Cargo.toml --all-targets cargo clippy --manifest-path crates/vcore-netstack/Cargo.toml --all-targets -- -D warnings @@ -159,7 +134,8 @@ VCore's dependencies, maintained forks, public API/protocol references, architec - [smoltcp](https://github.com/smoltcp-rs/smoltcp), [clash-rs](https://github.com/Watfaq/clash-rs), and [netstack-smoltcp](https://github.com/automesh-network/netstack-smoltcp): userspace IP stacks and TUN netstacks. - [windows-rs](https://github.com/microsoft/windows-rs), [UWP VPN Plugin Sample](https://github.com/microsoft/UwpVpnPluginSample), [wireguard-uwp-rs](https://github.com/luqmana/wireguard-uwp-rs), [Maple](https://github.com/YtFlow/Maple), and [YtFlowCore](https://github.com/YtFlow/YtFlowCore): Windows VPN, WinRT activation, and packet flow. - [Xray-core](https://github.com/XTLS/Xray-core), [Mihomo](https://github.com/MetaCubeX/mihomo), and [Leaf](https://github.com/eycorsican/leaf): proxy protocols, routing, TUN architecture, and interoperability references. -- [rustls](https://github.com/rustls/rustls): the TLS dependency and upstream of the maintained VCore REALITY fork. +- [rustls](https://github.com/rustls/rustls): unprofiled TLS/QUIC and shared WebPKI certificate policy. +- [boring](https://github.com/cloudflare/boring) and [BoringSSL](https://boringssl.googlesource.com/boringssl/): upstreams of the maintained fork for named ClientHello profiles and classic / opt-in hybrid REALITY. - [shadowsocks-rust](https://github.com/shadowsocks/shadowsocks-rust): the unmodified SS 2022 protocol dependency and source of the derived UDP replay window; see the [MIT notices in the source header](src/outbound/shadowsocks/packet_window.rs). ## License diff --git a/crates/vcore-blake3-raw/Cargo.toml b/crates/vcore-blake3-raw/Cargo.toml new file mode 100644 index 0000000..2a22cd6 --- /dev/null +++ b/crates/vcore-blake3-raw/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "vcore-blake3-raw" +version = "0.1.0" +edition = "2024" +license = "MIT" +publish = false + +[build-dependencies] +cc = "=1.5.1" + +[dev-dependencies] +blake3 = "=1.8.7" +serde_json = "1" diff --git a/crates/vcore-blake3-raw/README.md b/crates/vcore-blake3-raw/README.md new file mode 100644 index 0000000..0d69497 --- /dev/null +++ b/crates/vcore-blake3-raw/README.md @@ -0,0 +1,60 @@ +# Binary-context BLAKE3 DeriveKey + +Private workspace crate for the byte-string DeriveKey used on the VLESS +Encryption wire. One public function accepts two byte slices and returns a +32-byte key. It performs no allocation, IO, runtime loading, or TLS operation. +Callers own input/output secrets and their erasure policy. The C adapter clears +its hasher; it cannot guarantee erasure of upstream internal temporaries. + +The official Rust API takes `&str`; arbitrary binary protocol contexts cannot be +converted to UTF-8, hex, or base64 without changing the derivation. The official +C `blake3_hasher_init_derive_key_raw` is used directly. Rust does not mirror the +C hasher layout or implement a compression/KDF algorithm. + +This reproduces an existing protocol, not a new cryptographic recommendation: +BLAKE3's authors recommend a fixed, globally unique application-specific context, +and caution against dynamic contexts. Compatibility does not establish the +security of VLESS Encryption's protocol design. + +## Source and build + +The five `vendor/blake3*` files are byte-for-byte copies from official +[BLAKE3 1.8.7](https://github.com/BLAKE3-team/BLAKE3/tree/1.8.7/c), commit +`f3149ec5bb5449af877ba20377a11008ff499fa2` (latest stable checked 2026-09-26). +Archive SHA-256: `c6782a28842b1c0478524ac06a4f2ede784038ee298d6e2162c0b089c4306a3c`. +Upstream offers CC0-1.0, Apache-2.0, or Apache-2.0 WITH LLVM-exception; all three +license texts are preserved in `vendor/`. Vendored source is used under +Apache-2.0 WITH LLVM-exception. VCore-owned adapter/build/Rust code is MIT. + +| File | SHA-256 | +| --- | --- | +| blake3.c | b118ddf7cf9e6e5ef3fded72dcb1acf9dfdc4ea923cbe4605900ad6ee9afe1af | +| blake3.h | 81bbb11ad1909341565dce4914cc0e5c29718eca27e6950b28f9226a91aaf8c5 | +| blake3_impl.h | d388dca3574602c8849805ea3c8c0a12d082ac0e428756f305e111942f099af4 | +| blake3_dispatch.c | 134f21550138c0af6312925c988aeee35df287e4119e8ad1d206fccdb2238fe3 | +| blake3_portable.c | 2bc25b0dad67b4329d0b49cfa075ab2b0d04e424addbddc4e9c389c52a192524 | + +`build.rs` compiles these local files with official portable-only switches and +compile-time symbol prefixes. It never accesses research checkouts, Cargo source +caches, or the network. No bindgen, SIMD implementation, or fork is introduced. +Latest stable `cc` 1.5.1 is selected explicitly. Upgrades require rechecking the +official release, replacing unchanged files, updating provenance and hashes, +and rerunning vectors and target builds. + +## Check + +```sh +cargo test --locked -p vcore-blake3-raw +``` + +This checks a primitive, not the Encryption handshake, ticket authentication, +runtime ownership, platform integration, or end-to-end Encryption acceptance. + +The binary-context fixture has 52 values generated with the independent +`github.com/metacubex/blake3 v0.1.0` implementation used by Mihomo, using Go 1.27.1. +Its SHA-256 is `16fa3198a0a6adad7de4df74010f1625b72a445991e8554842c0b12bd070b08d`. +The generator and Go module lock are in `tests/generator/`; run `go run .` there +to reproduce the JSON on stdout (not part of the Rust build). Inputs contain +invalid UTF-8 and NUL and cover 64/1024-byte boundaries and protocol contexts up +to 17005 bytes. UTF-8 cases also link and compare the official Rust crate in the +same test executable. diff --git a/crates/vcore-blake3-raw/build.rs b/crates/vcore-blake3-raw/build.rs new file mode 100644 index 0000000..7646d0a --- /dev/null +++ b/crates/vcore-blake3-raw/build.rs @@ -0,0 +1,46 @@ +fn main() { + let mut build = cc::Build::new(); + build + .include("vendor") + .file("src/derive.c") + .file("vendor/blake3.c") + .file("vendor/blake3_dispatch.c") + .file("vendor/blake3_portable.c") + .std("c11") + .define("BLAKE3_NO_SSE2", None) + .define("BLAKE3_NO_SSE41", None) + .define("BLAKE3_NO_AVX2", None) + .define("BLAKE3_NO_AVX512", None) + .define("BLAKE3_USE_NEON", "0") + .define("BLAKE3_API", "") + .define("BLAKE3_PRIVATE", "") + .flag_if_supported("-fvisibility=hidden"); + + // Prefix at compilation, never by modifying the vendored official source. + // The regular Rust blake3 crate can link into the same final executable. + for symbol in [ + "blake3_version", + "blake3_hasher_init", + "blake3_hasher_init_keyed", + "blake3_hasher_init_derive_key", + "blake3_hasher_init_derive_key_raw", + "blake3_hasher_update", + "blake3_hasher_finalize", + "blake3_hasher_finalize_seek", + "blake3_hasher_reset", + "blake3_compress_subtree_wide", + "blake3_compress_in_place", + "blake3_compress_xof", + "blake3_xof_many", + "blake3_hash_many", + "blake3_simd_degree", + "blake3_compress_in_place_portable", + "blake3_compress_xof_portable", + "blake3_hash_many_portable", + ] { + build.define(symbol, format!("vcore_raw_{symbol}").as_str()); + } + build.compile("vcore_blake3_raw"); + println!("cargo:rerun-if-changed=src/derive.c"); + println!("cargo:rerun-if-changed=vendor"); +} diff --git a/crates/vcore-blake3-raw/src/derive.c b/crates/vcore-blake3-raw/src/derive.c new file mode 100644 index 0000000..36293b0 --- /dev/null +++ b/crates/vcore-blake3-raw/src/derive.c @@ -0,0 +1,18 @@ +/* VCore-owned one-shot adapter. The BLAKE3 implementation is unmodified. */ +#include "blake3.h" + +void vcore_blake3_raw_derive(const unsigned char *context, size_t context_len, + const unsigned char *material, size_t material_len, + unsigned char output[32]) { + blake3_hasher hasher; + blake3_hasher_init_derive_key_raw(&hasher, context, context_len); + blake3_hasher_update(&hasher, material, material_len); + blake3_hasher_finalize(&hasher, output, 32); + + /* Erase this adapter's state without optimizing the stores away. This does + not promise erasure of temporaries inside the unmodified upstream code. */ + volatile unsigned char *state = (volatile unsigned char *)&hasher; + for (size_t i = 0; i < sizeof(hasher); ++i) { + state[i] = 0; + } +} diff --git a/crates/vcore-blake3-raw/src/lib.rs b/crates/vcore-blake3-raw/src/lib.rs new file mode 100644 index 0000000..71eb498 --- /dev/null +++ b/crates/vcore-blake3-raw/src/lib.rs @@ -0,0 +1,31 @@ +//! One-shot BLAKE3 key derivation with a byte-string context. + +/// Derive a 32-byte key, preserving every byte of the context and key material. +/// +/// The caller owns the returned secret and is responsible for erasing it. +pub fn derive_key(context: &[u8], key_material: &[u8]) -> [u8; 32] { + let mut output = [0; 32]; + // SAFETY: both input pointers are valid for their supplied slice lengths, + // including empty slices. The disjoint output holds exactly 32 bytes. The + // synchronous C wrapper retains no pointers and exposes no private layout. + unsafe { + vcore_blake3_raw_derive( + context.as_ptr(), + context.len(), + key_material.as_ptr(), + key_material.len(), + output.as_mut_ptr(), + ); + } + output +} + +unsafe extern "C" { + fn vcore_blake3_raw_derive( + context: *const u8, + context_len: usize, + material: *const u8, + material_len: usize, + output: *mut u8, + ); +} diff --git a/crates/vcore-blake3-raw/tests/derive.rs b/crates/vcore-blake3-raw/tests/derive.rs new file mode 100644 index 0000000..1e897ae --- /dev/null +++ b/crates/vcore-blake3-raw/tests/derive.rs @@ -0,0 +1,52 @@ +use vcore_blake3_raw::derive_key; + +#[test] +fn derives_binary_context_without_utf8_conversion() { + // Independent github.com/metacubex/blake3 v0.1.0 DeriveKey result. + let context: Vec = (0..16).map(|i| (i * 37 + 255) as u8).collect(); + let material: Vec = (0..32).map(|i| (i * 13 + 7) as u8).collect(); + let actual: String = derive_key(&context, &material) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + assert_eq!( + actual, + "853b1d12dc08c5f34a282e668cc51fa4e34a5213ecd9d036773696493d6a19ba" + ); +} + +#[test] +fn matches_independent_go_vectors_across_block_chunk_and_wire_context_lengths() { + let fixtures: serde_json::Value = + serde_json::from_str(include_str!("fixtures/blake3-binary-context-vectors.json")).unwrap(); + let vectors = fixtures["vectors"].as_array().unwrap(); + assert_eq!(vectors.len(), 52); + for vector in vectors { + let context_len = vector["context_length"].as_u64().unwrap() as usize; + let key_len = vector["key_length"].as_u64().unwrap() as usize; + let context: Vec = (0..context_len).map(|i| (i * 37 + 255) as u8).collect(); + let material: Vec = (0..key_len).map(|i| (i * 13 + 7) as u8).collect(); + let actual: String = derive_key(&context, &material) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + assert_eq!( + actual, + vector["output"].as_str().unwrap(), + "context={context_len}, material={key_len}" + ); + } +} + +#[test] +fn coexists_with_official_rust_blake3_and_matches_its_utf8_interface() { + for context in ["", "VLESS", "vcore tests 2026-09-26 固定 context\0with NUL"] { + for length in [0, 1, 32, 64, 1023, 1024, 1025, 17005] { + let material: Vec = (0..length).map(|i| (i * 13 + 7) as u8).collect(); + assert_eq!( + derive_key(context.as_bytes(), &material), + blake3::derive_key(context, &material) + ); + } + } +} diff --git a/crates/vcore-blake3-raw/tests/fixtures/blake3-binary-context-vectors.json b/crates/vcore-blake3-raw/tests/fixtures/blake3-binary-context-vectors.json new file mode 100644 index 0000000..499eb2a --- /dev/null +++ b/crates/vcore-blake3-raw/tests/fixtures/blake3-binary-context-vectors.json @@ -0,0 +1,268 @@ +{ + "context_pattern": "byte(i*37 + 255)", + "generator": "github.com/metacubex/blake3@v0.1.0", + "go_version": "go1.27.1", + "key_pattern": "byte(i*13 + 7)", + "vectors": [ + { + "context_length": 0, + "key_length": 0, + "output": "741011989511e0d6b52532320d9edb6c0def0ab7e832b99bcc1259591ce2d75b" + }, + { + "context_length": 0, + "key_length": 32, + "output": "5c3d866c66aa5509a24a982595396810c0f8d2eb8cfd50fd376f49ddc80d74cd" + }, + { + "context_length": 0, + "key_length": 64, + "output": "28e7a122e4ae1fd45d84bde76961352d9fe0946fb3190405df7f8e0902a9da04" + }, + { + "context_length": 0, + "key_length": 96, + "output": "282dbfb0b9b7ecee8f86740fc47cb43bfc9e7b19673646b5eeaa6706866d0e89" + }, + { + "context_length": 1, + "key_length": 0, + "output": "d53da9fb267add567423b045a78090195b8d2d8384a413d7a40b2af02b081b3c" + }, + { + "context_length": 1, + "key_length": 32, + "output": "a1697a1da3f18b4fe54a92c8c9209bc619b991a2c8da37d544d2698df8672498" + }, + { + "context_length": 1, + "key_length": 64, + "output": "83dbd99441ad83e40c503cf0350d7445b259afc9cb081fc4f51a623fd19f177f" + }, + { + "context_length": 1, + "key_length": 96, + "output": "a7621a490de87ec7d68b1a272d46b65a449cb031af76d534b0213b1780af0135" + }, + { + "context_length": 16, + "key_length": 0, + "output": "8ec56afd22a625be532f37efd05aa12fa45b4f35ad07a5662eb6915781e7972a" + }, + { + "context_length": 16, + "key_length": 32, + "output": "853b1d12dc08c5f34a282e668cc51fa4e34a5213ecd9d036773696493d6a19ba" + }, + { + "context_length": 16, + "key_length": 64, + "output": "b974c88487ab05f48b43edbb6dad8688a0422d96bd2a88d72b5fb55e17fc3ae3" + }, + { + "context_length": 16, + "key_length": 96, + "output": "1bb66bda20f8e51ca3ded31fe6d027aa14d2aff6810545eca7a65356cf095dbe" + }, + { + "context_length": 32, + "key_length": 0, + "output": "ac50ff84429785daf1388fdc74d85eec7ca1c5a4759f1e729138856394139d87" + }, + { + "context_length": 32, + "key_length": 32, + "output": "868cad5faa4b6a36f8d93655679392d72e4bfb5cae341574ac2a8620c6a3fc68" + }, + { + "context_length": 32, + "key_length": 64, + "output": "d908d23c6a10f8c5b75decd181728bc55d758d0b2c9faa4c58178752736cc4ba" + }, + { + "context_length": 32, + "key_length": 96, + "output": "b22e9062a43df3713be845ac69322df62b1b44c0013e016a96065f4890d92e1a" + }, + { + "context_length": 63, + "key_length": 0, + "output": "e5171a92930fcf36afdbf7d6361d998103434eb5cf5eab80ac775f2dff90d9f0" + }, + { + "context_length": 63, + "key_length": 32, + "output": "2ab26669ae07bb11a23da55b635ef48bcf000509cacb19e1a2d474f9e37af167" + }, + { + "context_length": 63, + "key_length": 64, + "output": "79e7e69f2926ac044fe860f119cef33d6395204b6f743ca49a1eecdb2da30829" + }, + { + "context_length": 63, + "key_length": 96, + "output": "de7eeeaa212a6bcbe3cb623120500ad4ded3d6d1888637ea46922259e8d9b969" + }, + { + "context_length": 64, + "key_length": 0, + "output": "a018e9df76b09153906cb5f66a0111c08c6ac6691f12071a21f4243ae37a4b1b" + }, + { + "context_length": 64, + "key_length": 32, + "output": "f88c72bc16d746fa394241112415ccd035f2475d888e8c9208d7bf9c20272956" + }, + { + "context_length": 64, + "key_length": 64, + "output": "9154d9ef3d611d28d7d35c9c387c87db8db9bf024c595f1d63749f6a434f9bf7" + }, + { + "context_length": 64, + "key_length": 96, + "output": "7b937eed3f5c5b5f01c1990b14ddacca052151b759d8da864beaa9521e74bc9b" + }, + { + "context_length": 65, + "key_length": 0, + "output": "c1b475b71a2fdf3d334108dbf8ea8e40f2a0d18216cbb1a24da9e8551acfb74e" + }, + { + "context_length": 65, + "key_length": 32, + "output": "c5effde6b388307a3f5c58c9cb4fe7883c1293b8ef5e238a05828ae7ffb14002" + }, + { + "context_length": 65, + "key_length": 64, + "output": "f7e06704e58d53ea0738a2fb4021f6c5ba4b5506c132c8d50b7cf73439361717" + }, + { + "context_length": 65, + "key_length": 96, + "output": "48f7b450e72b23540fe485581793762439f22d328fe7b52eb2911391477b5513" + }, + { + "context_length": 1023, + "key_length": 0, + "output": "ae51881daa67fff0ca9caaed5a4ab4c5664ffbe95fc9195047e78c3344c799e0" + }, + { + "context_length": 1023, + "key_length": 32, + "output": "1493d5c97eaac5df219395f5e59b1c66d163a88ed975d21757a57057a623d5e2" + }, + { + "context_length": 1023, + "key_length": 64, + "output": "3ae30f3112bca1a4c839098c331518576589161212e17a517575a1aedfd052f2" + }, + { + "context_length": 1023, + "key_length": 96, + "output": "3b37a481bb95dc116c870338d8702ea03203ad7b7be125063854cd33ffbfec06" + }, + { + "context_length": 1024, + "key_length": 0, + "output": "d55f97eceb7dc4ce43fccbbd56977b851e68fb34f2c9968141ef1e4479d1c021" + }, + { + "context_length": 1024, + "key_length": 32, + "output": "bba6b1461919e559cfca385931d8e6e395abad848951345abed2d72ac3ea6b9d" + }, + { + "context_length": 1024, + "key_length": 64, + "output": "362eef17ded94eb6a8b92ae23c358a55d090369e61265ccc84a087614923d211" + }, + { + "context_length": 1024, + "key_length": 96, + "output": "04eb7f933497554b7539091ab3c157ea3f8dac1c683a1a800a69527dc33d80a4" + }, + { + "context_length": 1025, + "key_length": 0, + "output": "bf8a4c338519e453f673aa3069df1c600ebb02d8965e98d59c38734d0cbb52fe" + }, + { + "context_length": 1025, + "key_length": 32, + "output": "c7328a6379382cd5cd6b124595226a49e80562098b394c846d8286ebc42b4316" + }, + { + "context_length": 1025, + "key_length": 64, + "output": "c2c3d7c40d077227bef58629709223ae0871aee18d28292b1484b9ae55ef9f18" + }, + { + "context_length": 1025, + "key_length": 96, + "output": "0b426d2a0cdc93d867a3c31649db4e5464c25a7ec15d7fdb47c743f377310626" + }, + { + "context_length": 1120, + "key_length": 0, + "output": "09c90c6e5021e06227b22bcd9cff06c78c143e215d209c347f05071322d9d206" + }, + { + "context_length": 1120, + "key_length": 32, + "output": "cfd9bf5c7d60830d6ed7a122c0b0c93e98c5fe902c06cdea604a256d62b3741d" + }, + { + "context_length": 1120, + "key_length": 64, + "output": "8283905b141b0000e38a46b0b550402cd8f134a815729a85a5d1f568daa07cbc" + }, + { + "context_length": 1120, + "key_length": 96, + "output": "e991d2062bd9ee1940b40e4a48078ab9a4aee7bd732eb36ea8890f48b567a873" + }, + { + "context_length": 1216, + "key_length": 0, + "output": "5cc36e83e6b8b78f623e3058aa18590d16ac8b9f7a0967da26ba2ba7bc1a0c39" + }, + { + "context_length": 1216, + "key_length": 32, + "output": "4b7ca04d45003d06a1930c5a564c0445337a0a615a656842c5614fdf9105d70f" + }, + { + "context_length": 1216, + "key_length": 64, + "output": "19fb3137b53173539090def86175e5ac2770a80e7adf761bde3d0ad4c81ec0f1" + }, + { + "context_length": 1216, + "key_length": 96, + "output": "78229c88df99a2af3399a2451a2354da3ef0e424ee769867fe9d8f3b48d4ebb5" + }, + { + "context_length": 17005, + "key_length": 0, + "output": "4625bc3910fe2dcb10416934b654cfcc6ea1e36aa7237aa778e28a9fc202e222" + }, + { + "context_length": 17005, + "key_length": 32, + "output": "a5ed129744ad5b6fe17ea7483894ff6c33222a31137598b670eab451e3ad3c08" + }, + { + "context_length": 17005, + "key_length": 64, + "output": "6df4a7125f4546428469b09aae90733ec9d2bfc0330b7d9b18375bdf4f5683e5" + }, + { + "context_length": 17005, + "key_length": 96, + "output": "25b9781600c2f8040c4f55515fb9a40d1c473f613df2df5e8379233ed4804685" + } + ] +} diff --git a/crates/vcore-blake3-raw/tests/generator/go.mod b/crates/vcore-blake3-raw/tests/generator/go.mod new file mode 100644 index 0000000..3b4fb16 --- /dev/null +++ b/crates/vcore-blake3-raw/tests/generator/go.mod @@ -0,0 +1,7 @@ +module vcore-blake3-vectors + +go 1.27.0 + +require github.com/metacubex/blake3 v0.1.0 + +require golang.org/x/sys v0.48.0 // indirect diff --git a/crates/vcore-blake3-raw/tests/generator/go.sum b/crates/vcore-blake3-raw/tests/generator/go.sum new file mode 100644 index 0000000..e636975 --- /dev/null +++ b/crates/vcore-blake3-raw/tests/generator/go.sum @@ -0,0 +1,6 @@ +github.com/metacubex/blake3 v0.1.0 h1:KGnjh/56REO7U+cgZA8dnBhxdP7jByrG7hTP+bu6cqY= +github.com/metacubex/blake3 v0.1.0/go.mod h1:CCkLdzFrqf7xmxCdhQFvJsRRV2mwOLDoSPg6vUTB9Uk= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/crates/vcore-blake3-raw/tests/generator/main.go b/crates/vcore-blake3-raw/tests/generator/main.go new file mode 100644 index 0000000..4d47e3d --- /dev/null +++ b/crates/vcore-blake3-raw/tests/generator/main.go @@ -0,0 +1,46 @@ +// Independent wire-compatibility vectors; no network listeners or protocol engine. +package main + +import ( + "encoding/hex" + "encoding/json" + "os" + "runtime" + + "github.com/metacubex/blake3" +) + +func pattern(length, step, start int) []byte { + out := make([]byte, length) + for i := range out { + out[i] = byte(i*step + start) + } + return out +} + +func main() { + type vector struct { + ContextLength int `json:"context_length"` + KeyLength int `json:"key_length"` + Output string `json:"output"` + } + var vectors []vector + for _, contextLength := range []int{0, 1, 16, 32, 63, 64, 65, 1023, 1024, 1025, 1120, 1216, 17005} { + for _, keyLength := range []int{0, 32, 64, 96} { + out := make([]byte, 32) + blake3.DeriveKey(out, string(pattern(contextLength, 37, 255)), pattern(keyLength, 13, 7)) + vectors = append(vectors, vector{contextLength, keyLength, hex.EncodeToString(out)}) + } + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(map[string]any{ + "generator": "github.com/metacubex/blake3@v0.1.0", + "go_version": runtime.Version(), + "context_pattern": "byte(i*37 + 255)", + "key_pattern": "byte(i*13 + 7)", + "vectors": vectors, + }); err != nil { + panic(err) + } +} diff --git a/crates/vcore-blake3-raw/vendor/.gitattributes b/crates/vcore-blake3-raw/vendor/.gitattributes new file mode 100644 index 0000000..9852d38 --- /dev/null +++ b/crates/vcore-blake3-raw/vendor/.gitattributes @@ -0,0 +1,3 @@ +# Preserve the official release bytes, including upstream whitespace. +blake3_impl.h whitespace=-blank-at-eol +LICENSE_A2LLVM whitespace=-blank-at-eof diff --git a/crates/vcore-blake3-raw/vendor/LICENSE_A2 b/crates/vcore-blake3-raw/vendor/LICENSE_A2 new file mode 100644 index 0000000..d512ca9 --- /dev/null +++ b/crates/vcore-blake3-raw/vendor/LICENSE_A2 @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Jack O'Connor and Samuel Neves + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/vcore-blake3-raw/vendor/LICENSE_A2LLVM b/crates/vcore-blake3-raw/vendor/LICENSE_A2LLVM new file mode 100644 index 0000000..8d11bec --- /dev/null +++ b/crates/vcore-blake3-raw/vendor/LICENSE_A2LLVM @@ -0,0 +1,219 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Jack O'Connor and Samuel Neves + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +---- LLVM Exceptions to the Apache 2.0 License ---- + +As an exception, if, as a result of your compiling your source code, portions +of this Software are embedded into an Object form of such source code, you +may redistribute such embedded portions in such Object form without complying +with the conditions of Sections 4(a), 4(b) and 4(d) of the License. + +In addition, if you combine or link compiled forms of this Software with +software that is licensed under the GPLv2 ("Combined Software") and if a +court of competent jurisdiction determines that the patent provision (Section +3), the indemnity provision (Section 9) or other Section of the License +conflicts with the conditions of the GPLv2, you may retroactively and +prospectively choose to deem waived or otherwise exclude such Section(s) of +the License, but only in their entirety and only with respect to the Combined +Software. + diff --git a/crates/vcore-blake3-raw/vendor/LICENSE_CC0 b/crates/vcore-blake3-raw/vendor/LICENSE_CC0 new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/crates/vcore-blake3-raw/vendor/LICENSE_CC0 @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/crates/vcore-blake3-raw/vendor/blake3.c b/crates/vcore-blake3-raw/vendor/blake3.c new file mode 100644 index 0000000..00f91f4 --- /dev/null +++ b/crates/vcore-blake3-raw/vendor/blake3.c @@ -0,0 +1,651 @@ +#include +#include +#include +#include + +#include "blake3.h" +#include "blake3_impl.h" + +const char *blake3_version(void) { return BLAKE3_VERSION_STRING; } + +INLINE void chunk_state_init(blake3_chunk_state *self, const uint32_t key[8], + uint8_t flags) { + memcpy(self->cv, key, BLAKE3_KEY_LEN); + self->chunk_counter = 0; + memset(self->buf, 0, BLAKE3_BLOCK_LEN); + self->buf_len = 0; + self->blocks_compressed = 0; + self->flags = flags; +} + +INLINE void chunk_state_reset(blake3_chunk_state *self, const uint32_t key[8], + uint64_t chunk_counter) { + memcpy(self->cv, key, BLAKE3_KEY_LEN); + self->chunk_counter = chunk_counter; + self->blocks_compressed = 0; + memset(self->buf, 0, BLAKE3_BLOCK_LEN); + self->buf_len = 0; +} + +INLINE size_t chunk_state_len(const blake3_chunk_state *self) { + return (BLAKE3_BLOCK_LEN * (size_t)self->blocks_compressed) + + ((size_t)self->buf_len); +} + +INLINE size_t chunk_state_fill_buf(blake3_chunk_state *self, + const uint8_t *input, size_t input_len) { + size_t take = BLAKE3_BLOCK_LEN - ((size_t)self->buf_len); + if (take > input_len) { + take = input_len; + } + uint8_t *dest = self->buf + ((size_t)self->buf_len); + memcpy(dest, input, take); + self->buf_len += (uint8_t)take; + return take; +} + +INLINE uint8_t chunk_state_maybe_start_flag(const blake3_chunk_state *self) { + if (self->blocks_compressed == 0) { + return CHUNK_START; + } else { + return 0; + } +} + +typedef struct { + uint32_t input_cv[8]; + uint64_t counter; + uint8_t block[BLAKE3_BLOCK_LEN]; + uint8_t block_len; + uint8_t flags; +} output_t; + +INLINE output_t make_output(const uint32_t input_cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags) { + output_t ret; + memcpy(ret.input_cv, input_cv, 32); + memcpy(ret.block, block, BLAKE3_BLOCK_LEN); + ret.block_len = block_len; + ret.counter = counter; + ret.flags = flags; + return ret; +} + +// Chaining values within a given chunk (specifically the compress_in_place +// interface) are represented as words. This avoids unnecessary bytes<->words +// conversion overhead in the portable implementation. However, the hash_many +// interface handles both user input and parent node blocks, so it accepts +// bytes. For that reason, chaining values in the CV stack are represented as +// bytes. +INLINE void output_chaining_value(const output_t *self, uint8_t cv[32]) { + uint32_t cv_words[8]; + memcpy(cv_words, self->input_cv, 32); + blake3_compress_in_place(cv_words, self->block, self->block_len, + self->counter, self->flags); + store_cv_words(cv, cv_words); +} + +INLINE void output_root_bytes(const output_t *self, uint64_t seek, uint8_t *out, + size_t out_len) { + if (out_len == 0) { + return; + } + uint64_t output_block_counter = seek / 64; + size_t offset_within_block = seek % 64; + uint8_t wide_buf[64]; + if(offset_within_block) { + blake3_compress_xof(self->input_cv, self->block, self->block_len, output_block_counter, self->flags | ROOT, wide_buf); + const size_t available_bytes = 64 - offset_within_block; + const size_t bytes = out_len > available_bytes ? available_bytes : out_len; + memcpy(out, wide_buf + offset_within_block, bytes); + out += bytes; + out_len -= bytes; + output_block_counter += 1; + } + if(out_len / 64) { + blake3_xof_many(self->input_cv, self->block, self->block_len, output_block_counter, self->flags | ROOT, out, out_len / 64); + } + output_block_counter += out_len / 64; + out += out_len & -64; + out_len -= out_len & -64; + if(out_len) { + blake3_compress_xof(self->input_cv, self->block, self->block_len, output_block_counter, self->flags | ROOT, wide_buf); + memcpy(out, wide_buf, out_len); + } +} + +INLINE void chunk_state_update(blake3_chunk_state *self, const uint8_t *input, + size_t input_len) { + if (self->buf_len > 0) { + size_t take = chunk_state_fill_buf(self, input, input_len); + input += take; + input_len -= take; + if (input_len > 0) { + blake3_compress_in_place( + self->cv, self->buf, BLAKE3_BLOCK_LEN, self->chunk_counter, + self->flags | chunk_state_maybe_start_flag(self)); + self->blocks_compressed += 1; + self->buf_len = 0; + memset(self->buf, 0, BLAKE3_BLOCK_LEN); + } + } + + while (input_len > BLAKE3_BLOCK_LEN) { + blake3_compress_in_place(self->cv, input, BLAKE3_BLOCK_LEN, + self->chunk_counter, + self->flags | chunk_state_maybe_start_flag(self)); + self->blocks_compressed += 1; + input += BLAKE3_BLOCK_LEN; + input_len -= BLAKE3_BLOCK_LEN; + } + + chunk_state_fill_buf(self, input, input_len); +} + +INLINE output_t chunk_state_output(const blake3_chunk_state *self) { + uint8_t block_flags = + self->flags | chunk_state_maybe_start_flag(self) | CHUNK_END; + return make_output(self->cv, self->buf, self->buf_len, self->chunk_counter, + block_flags); +} + +INLINE output_t parent_output(const uint8_t block[BLAKE3_BLOCK_LEN], + const uint32_t key[8], uint8_t flags) { + return make_output(key, block, BLAKE3_BLOCK_LEN, 0, flags | PARENT); +} + +// Given some input larger than one chunk, return the number of bytes that +// should go in the left subtree. This is the largest power-of-2 number of +// chunks that leaves at least 1 byte for the right subtree. +INLINE size_t left_subtree_len(size_t input_len) { + // Subtract 1 to reserve at least one byte for the right side. input_len + // should always be greater than BLAKE3_CHUNK_LEN. + size_t full_chunks = (input_len - 1) / BLAKE3_CHUNK_LEN; + return round_down_to_power_of_2(full_chunks) * BLAKE3_CHUNK_LEN; +} + +// Use SIMD parallelism to hash up to MAX_SIMD_DEGREE chunks at the same time +// on a single thread. Write out the chunk chaining values and return the +// number of chunks hashed. These chunks are never the root and never empty; +// those cases use a different codepath. +INLINE size_t compress_chunks_parallel(const uint8_t *input, size_t input_len, + const uint32_t key[8], + uint64_t chunk_counter, uint8_t flags, + uint8_t *out) { +#if defined(BLAKE3_TESTING) + assert(0 < input_len); + assert(input_len <= MAX_SIMD_DEGREE * BLAKE3_CHUNK_LEN); +#endif + + const uint8_t *chunks_array[MAX_SIMD_DEGREE]; + size_t input_position = 0; + size_t chunks_array_len = 0; + while (input_len - input_position >= BLAKE3_CHUNK_LEN) { + chunks_array[chunks_array_len] = &input[input_position]; + input_position += BLAKE3_CHUNK_LEN; + chunks_array_len += 1; + } + + blake3_hash_many(chunks_array, chunks_array_len, + BLAKE3_CHUNK_LEN / BLAKE3_BLOCK_LEN, key, chunk_counter, + true, flags, CHUNK_START, CHUNK_END, out); + + // Hash the remaining partial chunk, if there is one. Note that the empty + // chunk (meaning the empty message) is a different codepath. + if (input_len > input_position) { + uint64_t counter = chunk_counter + (uint64_t)chunks_array_len; + blake3_chunk_state chunk_state; + chunk_state_init(&chunk_state, key, flags); + chunk_state.chunk_counter = counter; + chunk_state_update(&chunk_state, &input[input_position], + input_len - input_position); + output_t output = chunk_state_output(&chunk_state); + output_chaining_value(&output, &out[chunks_array_len * BLAKE3_OUT_LEN]); + return chunks_array_len + 1; + } else { + return chunks_array_len; + } +} + +// Use SIMD parallelism to hash up to MAX_SIMD_DEGREE parents at the same time +// on a single thread. Write out the parent chaining values and return the +// number of parents hashed. (If there's an odd input chaining value left over, +// return it as an additional output.) These parents are never the root and +// never empty; those cases use a different codepath. +INLINE size_t compress_parents_parallel(const uint8_t *child_chaining_values, + size_t num_chaining_values, + const uint32_t key[8], uint8_t flags, + uint8_t *out) { +#if defined(BLAKE3_TESTING) + assert(2 <= num_chaining_values); + assert(num_chaining_values <= 2 * MAX_SIMD_DEGREE_OR_2); +#endif + + const uint8_t *parents_array[MAX_SIMD_DEGREE_OR_2]; + size_t parents_array_len = 0; + while (num_chaining_values - (2 * parents_array_len) >= 2) { + parents_array[parents_array_len] = + &child_chaining_values[2 * parents_array_len * BLAKE3_OUT_LEN]; + parents_array_len += 1; + } + + blake3_hash_many(parents_array, parents_array_len, 1, key, + 0, // Parents always use counter 0. + false, flags | PARENT, + 0, // Parents have no start flags. + 0, // Parents have no end flags. + out); + + // If there's an odd child left over, it becomes an output. + if (num_chaining_values > 2 * parents_array_len) { + memcpy(&out[parents_array_len * BLAKE3_OUT_LEN], + &child_chaining_values[2 * parents_array_len * BLAKE3_OUT_LEN], + BLAKE3_OUT_LEN); + return parents_array_len + 1; + } else { + return parents_array_len; + } +} + +// The wide helper function returns (writes out) an array of chaining values +// and returns the length of that array. The number of chaining values returned +// is the dynamically detected SIMD degree, at most MAX_SIMD_DEGREE. Or fewer, +// if the input is shorter than that many chunks. The reason for maintaining a +// wide array of chaining values going back up the tree, is to allow the +// implementation to hash as many parents in parallel as possible. +// +// As a special case when the SIMD degree is 1, this function will still return +// at least 2 outputs. This guarantees that this function doesn't perform the +// root compression. (If it did, it would use the wrong flags, and also we +// wouldn't be able to implement extendable output.) Note that this function is +// not used when the whole input is only 1 chunk long; that's a different +// codepath. +// +// Why not just have the caller split the input on the first update(), instead +// of implementing this special rule? Because we don't want to limit SIMD or +// multi-threading parallelism for that update(). +size_t blake3_compress_subtree_wide(const uint8_t *input, size_t input_len, + const uint32_t key[8], + uint64_t chunk_counter, uint8_t flags, + uint8_t *out, bool use_tbb) { + // Note that the single chunk case does *not* bump the SIMD degree up to 2 + // when it is 1. If this implementation adds multi-threading in the future, + // this gives us the option of multi-threading even the 2-chunk case, which + // can help performance on smaller platforms. + if (input_len <= blake3_simd_degree() * BLAKE3_CHUNK_LEN) { + return compress_chunks_parallel(input, input_len, key, chunk_counter, flags, + out); + } + + // With more than simd_degree chunks, we need to recurse. Start by dividing + // the input into left and right subtrees. (Note that this is only optimal + // as long as the SIMD degree is a power of 2. If we ever get a SIMD degree + // of 3 or something, we'll need a more complicated strategy.) + size_t left_input_len = left_subtree_len(input_len); + size_t right_input_len = input_len - left_input_len; + const uint8_t *right_input = &input[left_input_len]; + uint64_t right_chunk_counter = + chunk_counter + (uint64_t)(left_input_len / BLAKE3_CHUNK_LEN); + + // Make space for the child outputs. Here we use MAX_SIMD_DEGREE_OR_2 to + // account for the special case of returning 2 outputs when the SIMD degree + // is 1. + uint8_t cv_array[2 * MAX_SIMD_DEGREE_OR_2 * BLAKE3_OUT_LEN]; + size_t degree = blake3_simd_degree(); + if (left_input_len > BLAKE3_CHUNK_LEN && degree == 1) { + // The special case: We always use a degree of at least two, to make + // sure there are two outputs. Except, as noted above, at the chunk + // level, where we allow degree=1. (Note that the 1-chunk-input case is + // a different codepath.) + degree = 2; + } + uint8_t *right_cvs = &cv_array[degree * BLAKE3_OUT_LEN]; + + // Recurse! + size_t left_n = SIZE_MAX; + size_t right_n = SIZE_MAX; + +#if defined(BLAKE3_USE_TBB) + blake3_compress_subtree_wide_join_tbb( + key, flags, use_tbb, + // left-hand side + input, left_input_len, chunk_counter, cv_array, &left_n, + // right-hand side + right_input, right_input_len, right_chunk_counter, right_cvs, &right_n); +#else + left_n = blake3_compress_subtree_wide( + input, left_input_len, key, chunk_counter, flags, cv_array, use_tbb); + right_n = blake3_compress_subtree_wide(right_input, right_input_len, key, + right_chunk_counter, flags, right_cvs, + use_tbb); +#endif // BLAKE3_USE_TBB + + // The special case again. If simd_degree=1, then we'll have left_n=1 and + // right_n=1. Rather than compressing them into a single output, return + // them directly, to make sure we always have at least two outputs. + if (left_n == 1) { + memcpy(out, cv_array, 2 * BLAKE3_OUT_LEN); + return 2; + } + + // Otherwise, do one layer of parent node compression. + size_t num_chaining_values = left_n + right_n; + return compress_parents_parallel(cv_array, num_chaining_values, key, flags, + out); +} + +// Hash a subtree with compress_subtree_wide(), and then condense the resulting +// list of chaining values down to a single parent node. Don't compress that +// last parent node, however. Instead, return its message bytes (the +// concatenated chaining values of its children). This is necessary when the +// first call to update() supplies a complete subtree, because the topmost +// parent node of that subtree could end up being the root. It's also necessary +// for extended output in the general case. +// +// As with compress_subtree_wide(), this function is not used on inputs of 1 +// chunk or less. That's a different codepath. +INLINE void +compress_subtree_to_parent_node(const uint8_t *input, size_t input_len, + const uint32_t key[8], uint64_t chunk_counter, + uint8_t flags, uint8_t out[2 * BLAKE3_OUT_LEN], + bool use_tbb) { +#if defined(BLAKE3_TESTING) + assert(input_len > BLAKE3_CHUNK_LEN); +#endif + + uint8_t cv_array[MAX_SIMD_DEGREE_OR_2 * BLAKE3_OUT_LEN]; + size_t num_cvs = blake3_compress_subtree_wide(input, input_len, key, + chunk_counter, flags, cv_array, use_tbb); + assert(num_cvs <= MAX_SIMD_DEGREE_OR_2); + // The following loop never executes when MAX_SIMD_DEGREE_OR_2 is 2, because + // as we just asserted, num_cvs will always be <=2 in that case. But GCC + // (particularly GCC 8.5) can't tell that it never executes, and if NDEBUG is + // set then it emits incorrect warnings here. We tried a few different + // hacks to silence these, but in the end our hacks just produced different + // warnings (see https://github.com/BLAKE3-team/BLAKE3/pull/380). Out of + // desperation, we ifdef out this entire loop when we know it's not needed. +#if MAX_SIMD_DEGREE_OR_2 > 2 + // If MAX_SIMD_DEGREE_OR_2 is greater than 2 and there's enough input, + // compress_subtree_wide() returns more than 2 chaining values. Condense + // them into 2 by forming parent nodes repeatedly. + uint8_t out_array[MAX_SIMD_DEGREE_OR_2 * BLAKE3_OUT_LEN / 2]; + while (num_cvs > 2) { + num_cvs = + compress_parents_parallel(cv_array, num_cvs, key, flags, out_array); + memcpy(cv_array, out_array, num_cvs * BLAKE3_OUT_LEN); + } +#endif + memcpy(out, cv_array, 2 * BLAKE3_OUT_LEN); +} + +INLINE void hasher_init_base(blake3_hasher *self, const uint32_t key[8], + uint8_t flags) { + memcpy(self->key, key, BLAKE3_KEY_LEN); + chunk_state_init(&self->chunk, key, flags); + self->cv_stack_len = 0; +} + +void blake3_hasher_init(blake3_hasher *self) { hasher_init_base(self, IV, 0); } + +void blake3_hasher_init_keyed(blake3_hasher *self, + const uint8_t key[BLAKE3_KEY_LEN]) { + uint32_t key_words[8]; + load_key_words(key, key_words); + hasher_init_base(self, key_words, KEYED_HASH); +} + +void blake3_hasher_init_derive_key_raw(blake3_hasher *self, const void *context, + size_t context_len) { + blake3_hasher context_hasher; + hasher_init_base(&context_hasher, IV, DERIVE_KEY_CONTEXT); + blake3_hasher_update(&context_hasher, context, context_len); + uint8_t context_key[BLAKE3_KEY_LEN]; + blake3_hasher_finalize(&context_hasher, context_key, BLAKE3_KEY_LEN); + uint32_t context_key_words[8]; + load_key_words(context_key, context_key_words); + hasher_init_base(self, context_key_words, DERIVE_KEY_MATERIAL); +} + +void blake3_hasher_init_derive_key(blake3_hasher *self, const char *context) { + blake3_hasher_init_derive_key_raw(self, context, strlen(context)); +} + +// As described in hasher_push_cv() below, we do "lazy merging", delaying +// merges until right before the next CV is about to be added. This is +// different from the reference implementation. Another difference is that we +// aren't always merging 1 chunk at a time. Instead, each CV might represent +// any power-of-two number of chunks, as long as the smaller-above-larger stack +// order is maintained. Instead of the "count the trailing 0-bits" algorithm +// described in the spec, we use a "count the total number of 1-bits" variant +// that doesn't require us to retain the subtree size of the CV on top of the +// stack. The principle is the same: each CV that should remain in the stack is +// represented by a 1-bit in the total number of chunks (or bytes) so far. +INLINE void hasher_merge_cv_stack(blake3_hasher *self, uint64_t total_len) { + size_t post_merge_stack_len = (size_t)popcnt(total_len); + while (self->cv_stack_len > post_merge_stack_len) { + uint8_t *parent_node = + &self->cv_stack[(self->cv_stack_len - 2) * BLAKE3_OUT_LEN]; + output_t output = parent_output(parent_node, self->key, self->chunk.flags); + output_chaining_value(&output, parent_node); + self->cv_stack_len -= 1; + } +} + +// In reference_impl.rs, we merge the new CV with existing CVs from the stack +// before pushing it. We can do that because we know more input is coming, so +// we know none of the merges are root. +// +// This setting is different. We want to feed as much input as possible to +// compress_subtree_wide(), without setting aside anything for the chunk_state. +// If the user gives us 64 KiB, we want to parallelize over all 64 KiB at once +// as a single subtree, if at all possible. +// +// This leads to two problems: +// 1) This 64 KiB input might be the only call that ever gets made to update. +// In this case, the root node of the 64 KiB subtree would be the root node +// of the whole tree, and it would need to be ROOT finalized. We can't +// compress it until we know. +// 2) This 64 KiB input might complete a larger tree, whose root node is +// similarly going to be the root of the whole tree. For example, maybe +// we have 196 KiB (that is, 128 + 64) hashed so far. We can't compress the +// node at the root of the 256 KiB subtree until we know how to finalize it. +// +// The second problem is solved with "lazy merging". That is, when we're about +// to add a CV to the stack, we don't merge it with anything first, as the +// reference impl does. Instead we do merges using the *previous* CV that was +// added, which is sitting on top of the stack, and we put the new CV +// (unmerged) on top of the stack afterwards. This guarantees that we never +// merge the root node until finalize(). +// +// Solving the first problem requires an additional tool, +// compress_subtree_to_parent_node(). That function always returns the top +// *two* chaining values of the subtree it's compressing. We then do lazy +// merging with each of them separately, so that the second CV will always +// remain unmerged. (That also helps us support extendable output when we're +// hashing an input all-at-once.) +INLINE void hasher_push_cv(blake3_hasher *self, uint8_t new_cv[BLAKE3_OUT_LEN], + uint64_t chunk_counter) { + hasher_merge_cv_stack(self, chunk_counter); + memcpy(&self->cv_stack[self->cv_stack_len * BLAKE3_OUT_LEN], new_cv, + BLAKE3_OUT_LEN); + self->cv_stack_len += 1; +} + +INLINE void blake3_hasher_update_base(blake3_hasher *self, const void *input, + size_t input_len, bool use_tbb) { + // Explicitly checking for zero avoids causing UB by passing a null pointer + // to memcpy. This comes up in practice with things like: + // std::vector v; + // blake3_hasher_update(&hasher, v.data(), v.size()); + if (input_len == 0) { + return; + } + + const uint8_t *input_bytes = (const uint8_t *)input; + + // If we have some partial chunk bytes in the internal chunk_state, we need + // to finish that chunk first. + if (chunk_state_len(&self->chunk) > 0) { + size_t take = BLAKE3_CHUNK_LEN - chunk_state_len(&self->chunk); + if (take > input_len) { + take = input_len; + } + chunk_state_update(&self->chunk, input_bytes, take); + input_bytes += take; + input_len -= take; + // If we've filled the current chunk and there's more coming, finalize this + // chunk and proceed. In this case we know it's not the root. + if (input_len > 0) { + output_t output = chunk_state_output(&self->chunk); + uint8_t chunk_cv[32]; + output_chaining_value(&output, chunk_cv); + hasher_push_cv(self, chunk_cv, self->chunk.chunk_counter); + chunk_state_reset(&self->chunk, self->key, self->chunk.chunk_counter + 1); + } else { + return; + } + } + + // Now the chunk_state is clear, and we have more input. If there's more than + // a single chunk (so, definitely not the root chunk), hash the largest whole + // subtree we can, with the full benefits of SIMD (and maybe in the future, + // multi-threading) parallelism. Two restrictions: + // - The subtree has to be a power-of-2 number of chunks. Only subtrees along + // the right edge can be incomplete, and we don't know where the right edge + // is going to be until we get to finalize(). + // - The subtree must evenly divide the total number of chunks up until this + // point (if total is not 0). If the current incomplete subtree is only + // waiting for 1 more chunk, we can't hash a subtree of 4 chunks. We have + // to complete the current subtree first. + // Because we might need to break up the input to form powers of 2, or to + // evenly divide what we already have, this part runs in a loop. + while (input_len > BLAKE3_CHUNK_LEN) { + size_t subtree_len = round_down_to_power_of_2(input_len); + uint64_t count_so_far = self->chunk.chunk_counter * BLAKE3_CHUNK_LEN; + // Shrink the subtree_len until it evenly divides the count so far. We know + // that subtree_len itself is a power of 2, so we can use a bitmasking + // trick instead of an actual remainder operation. (Note that if the caller + // consistently passes power-of-2 inputs of the same size, as is hopefully + // typical, this loop condition will always fail, and subtree_len will + // always be the full length of the input.) + // + // An aside: We don't have to shrink subtree_len quite this much. For + // example, if count_so_far is 1, we could pass 2 chunks to + // compress_subtree_to_parent_node. Since we'll get 2 CVs back, we'll still + // get the right answer in the end, and we might get to use 2-way SIMD + // parallelism. The problem with this optimization, is that it gets us + // stuck always hashing 2 chunks. The total number of chunks will remain + // odd, and we'll never graduate to higher degrees of parallelism. See + // https://github.com/BLAKE3-team/BLAKE3/issues/69. + while ((((uint64_t)(subtree_len - 1)) & count_so_far) != 0) { + subtree_len /= 2; + } + // The shrunken subtree_len might now be 1 chunk long. If so, hash that one + // chunk by itself. Otherwise, compress the subtree into a pair of CVs. + uint64_t subtree_chunks = subtree_len / BLAKE3_CHUNK_LEN; + if (subtree_len <= BLAKE3_CHUNK_LEN) { + blake3_chunk_state chunk_state; + chunk_state_init(&chunk_state, self->key, self->chunk.flags); + chunk_state.chunk_counter = self->chunk.chunk_counter; + chunk_state_update(&chunk_state, input_bytes, subtree_len); + output_t output = chunk_state_output(&chunk_state); + uint8_t cv[BLAKE3_OUT_LEN]; + output_chaining_value(&output, cv); + hasher_push_cv(self, cv, chunk_state.chunk_counter); + } else { + // This is the high-performance happy path, though getting here depends + // on the caller giving us a long enough input. + uint8_t cv_pair[2 * BLAKE3_OUT_LEN]; + compress_subtree_to_parent_node(input_bytes, subtree_len, self->key, + self->chunk.chunk_counter, + self->chunk.flags, cv_pair, use_tbb); + hasher_push_cv(self, cv_pair, self->chunk.chunk_counter); + hasher_push_cv(self, &cv_pair[BLAKE3_OUT_LEN], + self->chunk.chunk_counter + (subtree_chunks / 2)); + } + self->chunk.chunk_counter += subtree_chunks; + input_bytes += subtree_len; + input_len -= subtree_len; + } + + // If there's any remaining input less than a full chunk, add it to the chunk + // state. In that case, also do a final merge loop to make sure the subtree + // stack doesn't contain any unmerged pairs. The remaining input means we + // know these merges are non-root. This merge loop isn't strictly necessary + // here, because hasher_push_chunk_cv already does its own merge loop, but it + // simplifies blake3_hasher_finalize below. + if (input_len > 0) { + chunk_state_update(&self->chunk, input_bytes, input_len); + hasher_merge_cv_stack(self, self->chunk.chunk_counter); + } +} + +void blake3_hasher_update(blake3_hasher *self, const void *input, + size_t input_len) { + bool use_tbb = false; + blake3_hasher_update_base(self, input, input_len, use_tbb); +} + +#if defined(BLAKE3_USE_TBB) +void blake3_hasher_update_tbb(blake3_hasher *self, const void *input, + size_t input_len) { + bool use_tbb = true; + blake3_hasher_update_base(self, input, input_len, use_tbb); +} +#endif // BLAKE3_USE_TBB + +void blake3_hasher_finalize(const blake3_hasher *self, uint8_t *out, + size_t out_len) { + blake3_hasher_finalize_seek(self, 0, out, out_len); +} + +void blake3_hasher_finalize_seek(const blake3_hasher *self, uint64_t seek, + uint8_t *out, size_t out_len) { + // Explicitly checking for zero avoids causing UB by passing a null pointer + // to memcpy. This comes up in practice with things like: + // std::vector v; + // blake3_hasher_finalize(&hasher, v.data(), v.size()); + if (out_len == 0) { + return; + } + + // If the subtree stack is empty, then the current chunk is the root. + if (self->cv_stack_len == 0) { + output_t output = chunk_state_output(&self->chunk); + output_root_bytes(&output, seek, out, out_len); + return; + } + // If there are any bytes in the chunk state, finalize that chunk and do a + // roll-up merge between that chunk hash and every subtree in the stack. In + // this case, the extra merge loop at the end of blake3_hasher_update + // guarantees that none of the subtrees in the stack need to be merged with + // each other first. Otherwise, if there are no bytes in the chunk state, + // then the top of the stack is a chunk hash, and we start the merge from + // that. + output_t output; + size_t cvs_remaining; + if (chunk_state_len(&self->chunk) > 0) { + cvs_remaining = self->cv_stack_len; + output = chunk_state_output(&self->chunk); + } else { + // There are always at least 2 CVs in the stack in this case. + cvs_remaining = self->cv_stack_len - 2; + output = parent_output(&self->cv_stack[cvs_remaining * 32], self->key, + self->chunk.flags); + } + while (cvs_remaining > 0) { + cvs_remaining -= 1; + uint8_t parent_block[BLAKE3_BLOCK_LEN]; + memcpy(parent_block, &self->cv_stack[cvs_remaining * 32], 32); + output_chaining_value(&output, &parent_block[32]); + output = parent_output(parent_block, self->key, self->chunk.flags); + } + output_root_bytes(&output, seek, out, out_len); +} + +void blake3_hasher_reset(blake3_hasher *self) { + chunk_state_reset(&self->chunk, self->key, 0); + self->cv_stack_len = 0; +} diff --git a/crates/vcore-blake3-raw/vendor/blake3.h b/crates/vcore-blake3-raw/vendor/blake3.h new file mode 100644 index 0000000..c5b7998 --- /dev/null +++ b/crates/vcore-blake3-raw/vendor/blake3.h @@ -0,0 +1,86 @@ +#ifndef BLAKE3_H +#define BLAKE3_H + +#include +#include + +#if !defined(BLAKE3_API) +# if defined(_WIN32) || defined(__CYGWIN__) +# if defined(BLAKE3_DLL) +# if defined(BLAKE3_DLL_EXPORTS) +# define BLAKE3_API __declspec(dllexport) +# else +# define BLAKE3_API __declspec(dllimport) +# endif +# define BLAKE3_PRIVATE +# else +# define BLAKE3_API +# define BLAKE3_PRIVATE +# endif +# elif __GNUC__ >= 4 +# define BLAKE3_API __attribute__((visibility("default"))) +# define BLAKE3_PRIVATE __attribute__((visibility("hidden"))) +# else +# define BLAKE3_API +# define BLAKE3_PRIVATE +# endif +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +#define BLAKE3_VERSION_STRING "1.8.7" +#define BLAKE3_KEY_LEN 32 +#define BLAKE3_OUT_LEN 32 +#define BLAKE3_BLOCK_LEN 64 +#define BLAKE3_CHUNK_LEN 1024 +#define BLAKE3_MAX_DEPTH 54 + +// This struct is a private implementation detail. It has to be here because +// it's part of the blake3_hasher structure defined below. +typedef struct { + uint32_t cv[8]; + uint64_t chunk_counter; + uint8_t buf[BLAKE3_BLOCK_LEN]; + uint8_t buf_len; + uint8_t blocks_compressed; + uint8_t flags; +} blake3_chunk_state; + +typedef struct { + uint32_t key[8]; + blake3_chunk_state chunk; + uint8_t cv_stack_len; + // The stack size is MAX_DEPTH + 1 because we do lazy merging. For example, + // with 7 chunks, we have 3 entries in the stack. Adding an 8th chunk + // requires a 4th entry, rather than merging everything down to 1, because we + // don't know whether more input is coming. This is different from how the + // reference implementation does things. + uint8_t cv_stack[(BLAKE3_MAX_DEPTH + 1) * BLAKE3_OUT_LEN]; +} blake3_hasher; + +BLAKE3_API const char *blake3_version(void); +BLAKE3_API void blake3_hasher_init(blake3_hasher *self); +BLAKE3_API void blake3_hasher_init_keyed(blake3_hasher *self, + const uint8_t key[BLAKE3_KEY_LEN]); +BLAKE3_API void blake3_hasher_init_derive_key(blake3_hasher *self, const char *context); +BLAKE3_API void blake3_hasher_init_derive_key_raw(blake3_hasher *self, const void *context, + size_t context_len); +BLAKE3_API void blake3_hasher_update(blake3_hasher *self, const void *input, + size_t input_len); +#if defined(BLAKE3_USE_TBB) +BLAKE3_API void blake3_hasher_update_tbb(blake3_hasher *self, const void *input, + size_t input_len); +#endif // BLAKE3_USE_TBB +BLAKE3_API void blake3_hasher_finalize(const blake3_hasher *self, uint8_t *out, + size_t out_len); +BLAKE3_API void blake3_hasher_finalize_seek(const blake3_hasher *self, uint64_t seek, + uint8_t *out, size_t out_len); +BLAKE3_API void blake3_hasher_reset(blake3_hasher *self); + +#ifdef __cplusplus +} +#endif + +#endif /* BLAKE3_H */ diff --git a/crates/vcore-blake3-raw/vendor/blake3_dispatch.c b/crates/vcore-blake3-raw/vendor/blake3_dispatch.c new file mode 100644 index 0000000..14dfbbe --- /dev/null +++ b/crates/vcore-blake3-raw/vendor/blake3_dispatch.c @@ -0,0 +1,332 @@ +#include +#include +#include + +#include "blake3_impl.h" + +#if defined(_MSC_VER) +#include +#endif + +#if defined(IS_X86) +#if defined(_MSC_VER) +#include +#elif defined(__GNUC__) +#include +#else +#undef IS_X86 /* Unimplemented! */ +#endif +#endif + +#if !defined(BLAKE3_ATOMICS) +#if defined(__has_include) +#if __has_include() && !defined(_MSC_VER) +#define BLAKE3_ATOMICS 1 +#else +#define BLAKE3_ATOMICS 0 +#endif /* __has_include() && !defined(_MSC_VER) */ +#else +#define BLAKE3_ATOMICS 0 +#endif /* defined(__has_include) */ +#endif /* BLAKE3_ATOMICS */ + +#if BLAKE3_ATOMICS +#define ATOMIC_INT _Atomic int +#define ATOMIC_LOAD(x) x +#define ATOMIC_STORE(x, y) x = y +#elif defined(_MSC_VER) +#define ATOMIC_INT LONG +#define ATOMIC_LOAD(x) InterlockedOr(&x, 0) +#define ATOMIC_STORE(x, y) InterlockedExchange(&x, y) +#else +#define ATOMIC_INT int +#define ATOMIC_LOAD(x) x +#define ATOMIC_STORE(x, y) x = y +#endif + +#define MAYBE_UNUSED(x) (void)((x)) + +#if defined(IS_X86) +static uint64_t xgetbv(void) { +#if defined(_MSC_VER) + return _xgetbv(0); +#else + uint32_t eax = 0, edx = 0; + __asm__ __volatile__("xgetbv\n" : "=a"(eax), "=d"(edx) : "c"(0)); + return ((uint64_t)edx << 32) | eax; +#endif +} + +static void cpuid(uint32_t out[4], uint32_t id) { +#if defined(_MSC_VER) + __cpuid((int *)out, id); +#elif defined(__i386__) || defined(_M_IX86) + __asm__ __volatile__("movl %%ebx, %1\n" + "cpuid\n" + "xchgl %1, %%ebx\n" + : "=a"(out[0]), "=r"(out[1]), "=c"(out[2]), "=d"(out[3]) + : "a"(id)); +#else + __asm__ __volatile__("cpuid\n" + : "=a"(out[0]), "=b"(out[1]), "=c"(out[2]), "=d"(out[3]) + : "a"(id)); +#endif +} + +static void cpuidex(uint32_t out[4], uint32_t id, uint32_t sid) { +#if defined(_MSC_VER) + __cpuidex((int *)out, id, sid); +#elif defined(__i386__) || defined(_M_IX86) + __asm__ __volatile__("movl %%ebx, %1\n" + "cpuid\n" + "xchgl %1, %%ebx\n" + : "=a"(out[0]), "=r"(out[1]), "=c"(out[2]), "=d"(out[3]) + : "a"(id), "c"(sid)); +#else + __asm__ __volatile__("cpuid\n" + : "=a"(out[0]), "=b"(out[1]), "=c"(out[2]), "=d"(out[3]) + : "a"(id), "c"(sid)); +#endif +} + + +enum cpu_feature { + SSE2 = 1 << 0, + SSSE3 = 1 << 1, + SSE41 = 1 << 2, + AVX = 1 << 3, + AVX2 = 1 << 4, + AVX512F = 1 << 5, + AVX512VL = 1 << 6, + /* ... */ + UNDEFINED = 1 << 30 +}; + +#if !defined(BLAKE3_TESTING) +static /* Allow the variable to be controlled manually for testing */ +#endif + ATOMIC_INT g_cpu_features = UNDEFINED; + +#if !defined(BLAKE3_TESTING) +static +#endif + enum cpu_feature + get_cpu_features(void) { + + /* If TSAN detects a data race here, try compiling with -DBLAKE3_ATOMICS=1 */ + enum cpu_feature features = ATOMIC_LOAD(g_cpu_features); + if (features != UNDEFINED) { + return features; + } else { +#if defined(IS_X86) + uint32_t regs[4] = {0}; + uint32_t *eax = ®s[0], *ebx = ®s[1], *ecx = ®s[2], *edx = ®s[3]; + (void)edx; + features = 0; + cpuid(regs, 0); + const int max_id = *eax; + cpuid(regs, 1); +#if defined(__amd64__) || defined(_M_X64) + features |= SSE2; +#else + if (*edx & (1UL << 26)) + features |= SSE2; +#endif + if (*ecx & (1UL << 9)) + features |= SSSE3; + if (*ecx & (1UL << 19)) + features |= SSE41; + + if (*ecx & (1UL << 27)) { // OSXSAVE + const uint64_t mask = xgetbv(); + if ((mask & 6) == 6) { // SSE and AVX states + if (*ecx & (1UL << 28)) + features |= AVX; + if (max_id >= 7) { + cpuidex(regs, 7, 0); + if (*ebx & (1UL << 5)) + features |= AVX2; + if ((mask & 224) == 224) { // Opmask, ZMM_Hi256, Hi16_Zmm + if (*ebx & (1UL << 31)) + features |= AVX512VL; + if (*ebx & (1UL << 16)) + features |= AVX512F; + } + } + } + } + ATOMIC_STORE(g_cpu_features, features); + return features; +#else + /* How to detect NEON? */ + return 0; +#endif + } +} +#endif + +void blake3_compress_in_place(uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags) { +#if defined(IS_X86) + const enum cpu_feature features = get_cpu_features(); + MAYBE_UNUSED(features); +#if !defined(BLAKE3_NO_AVX512) + if (features & AVX512VL) { + blake3_compress_in_place_avx512(cv, block, block_len, counter, flags); + return; + } +#endif +#if !defined(BLAKE3_NO_SSE41) + if (features & SSE41) { + blake3_compress_in_place_sse41(cv, block, block_len, counter, flags); + return; + } +#endif +#if !defined(BLAKE3_NO_SSE2) + if (features & SSE2) { + blake3_compress_in_place_sse2(cv, block, block_len, counter, flags); + return; + } +#endif +#endif + blake3_compress_in_place_portable(cv, block, block_len, counter, flags); +} + +void blake3_compress_xof(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, uint8_t flags, + uint8_t out[64]) { +#if defined(IS_X86) + const enum cpu_feature features = get_cpu_features(); + MAYBE_UNUSED(features); +#if !defined(BLAKE3_NO_AVX512) + if (features & AVX512VL) { + blake3_compress_xof_avx512(cv, block, block_len, counter, flags, out); + return; + } +#endif +#if !defined(BLAKE3_NO_SSE41) + if (features & SSE41) { + blake3_compress_xof_sse41(cv, block, block_len, counter, flags, out); + return; + } +#endif +#if !defined(BLAKE3_NO_SSE2) + if (features & SSE2) { + blake3_compress_xof_sse2(cv, block, block_len, counter, flags, out); + return; + } +#endif +#endif + blake3_compress_xof_portable(cv, block, block_len, counter, flags, out); +} + + +void blake3_xof_many(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, uint8_t flags, + uint8_t out[64], size_t outblocks) { + if (outblocks == 0) { + // The current assembly implementation always outputs at least 1 block. + return; + } +#if defined(IS_X86) + const enum cpu_feature features = get_cpu_features(); + MAYBE_UNUSED(features); +#if !defined(_WIN32) && !defined(__CYGWIN__) && !defined(BLAKE3_NO_AVX512) + if (features & AVX512VL) { + blake3_xof_many_avx512(cv, block, block_len, counter, flags, out, outblocks); + return; + } +#endif +#endif + for(size_t i = 0; i < outblocks; ++i) { + blake3_compress_xof(cv, block, block_len, counter + i, flags, out + 64*i); + } +} + +void blake3_hash_many(const uint8_t *const *inputs, size_t num_inputs, + size_t blocks, const uint32_t key[8], uint64_t counter, + bool increment_counter, uint8_t flags, + uint8_t flags_start, uint8_t flags_end, uint8_t *out) { +#if defined(IS_X86) + const enum cpu_feature features = get_cpu_features(); + MAYBE_UNUSED(features); +#if !defined(BLAKE3_NO_AVX512) + if ((features & (AVX512F|AVX512VL)) == (AVX512F|AVX512VL)) { + blake3_hash_many_avx512(inputs, num_inputs, blocks, key, counter, + increment_counter, flags, flags_start, flags_end, + out); + return; + } +#endif +#if !defined(BLAKE3_NO_AVX2) + if (features & AVX2) { + blake3_hash_many_avx2(inputs, num_inputs, blocks, key, counter, + increment_counter, flags, flags_start, flags_end, + out); + return; + } +#endif +#if !defined(BLAKE3_NO_SSE41) + if (features & SSE41) { + blake3_hash_many_sse41(inputs, num_inputs, blocks, key, counter, + increment_counter, flags, flags_start, flags_end, + out); + return; + } +#endif +#if !defined(BLAKE3_NO_SSE2) + if (features & SSE2) { + blake3_hash_many_sse2(inputs, num_inputs, blocks, key, counter, + increment_counter, flags, flags_start, flags_end, + out); + return; + } +#endif +#endif + +#if BLAKE3_USE_NEON == 1 + blake3_hash_many_neon(inputs, num_inputs, blocks, key, counter, + increment_counter, flags, flags_start, flags_end, out); + return; +#endif + + blake3_hash_many_portable(inputs, num_inputs, blocks, key, counter, + increment_counter, flags, flags_start, flags_end, + out); +} + +// The dynamically detected SIMD degree of the current platform. +size_t blake3_simd_degree(void) { +#if defined(IS_X86) + const enum cpu_feature features = get_cpu_features(); + MAYBE_UNUSED(features); +#if !defined(BLAKE3_NO_AVX512) + if ((features & (AVX512F|AVX512VL)) == (AVX512F|AVX512VL)) { + return 16; + } +#endif +#if !defined(BLAKE3_NO_AVX2) + if (features & AVX2) { + return 8; + } +#endif +#if !defined(BLAKE3_NO_SSE41) + if (features & SSE41) { + return 4; + } +#endif +#if !defined(BLAKE3_NO_SSE2) + if (features & SSE2) { + return 4; + } +#endif +#endif +#if BLAKE3_USE_NEON == 1 + return 4; +#endif + return 1; +} diff --git a/crates/vcore-blake3-raw/vendor/blake3_impl.h b/crates/vcore-blake3-raw/vendor/blake3_impl.h new file mode 100644 index 0000000..88e71e4 --- /dev/null +++ b/crates/vcore-blake3-raw/vendor/blake3_impl.h @@ -0,0 +1,333 @@ +#ifndef BLAKE3_IMPL_H +#define BLAKE3_IMPL_H + +#include +#include +#include +#include +#include + +#include "blake3.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// internal flags +enum blake3_flags { + CHUNK_START = 1 << 0, + CHUNK_END = 1 << 1, + PARENT = 1 << 2, + ROOT = 1 << 3, + KEYED_HASH = 1 << 4, + DERIVE_KEY_CONTEXT = 1 << 5, + DERIVE_KEY_MATERIAL = 1 << 6, +}; + +// This C implementation tries to support recent versions of GCC, Clang, and +// MSVC. +#if defined(_MSC_VER) +#define INLINE static __forceinline +#else +#define INLINE static inline __attribute__((always_inline)) +#endif + +#ifdef __cplusplus +#define NOEXCEPT noexcept +#else +#define NOEXCEPT +#endif + +#if (defined(__x86_64__) || defined(_M_X64)) && !defined(_M_ARM64EC) +#define IS_X86 +#define IS_X86_64 +#endif + +#if defined(__i386__) || defined(_M_IX86) +#define IS_X86 +#define IS_X86_32 +#endif + +#if defined(__aarch64__) || defined(_M_ARM64) || defined(_M_ARM64EC) +#define IS_AARCH64 +#endif + +#if defined(IS_X86) +#if defined(_MSC_VER) +#include +#endif +#endif + +#if !defined(BLAKE3_USE_NEON) + // If BLAKE3_USE_NEON not manually set, autodetect based on AArch64ness + #if defined(IS_AARCH64) + #if defined(__ARM_BIG_ENDIAN) + #define BLAKE3_USE_NEON 0 + #else + #define BLAKE3_USE_NEON 1 + #endif + #else + #define BLAKE3_USE_NEON 0 + #endif +#endif + +#if defined(IS_X86) +#define MAX_SIMD_DEGREE 16 +#elif BLAKE3_USE_NEON == 1 +#define MAX_SIMD_DEGREE 4 +#else +#define MAX_SIMD_DEGREE 1 +#endif + +// There are some places where we want a static size that's equal to the +// MAX_SIMD_DEGREE, but also at least 2. +#define MAX_SIMD_DEGREE_OR_2 (MAX_SIMD_DEGREE > 2 ? MAX_SIMD_DEGREE : 2) + +static const uint32_t IV[8] = {0x6A09E667UL, 0xBB67AE85UL, 0x3C6EF372UL, + 0xA54FF53AUL, 0x510E527FUL, 0x9B05688CUL, + 0x1F83D9ABUL, 0x5BE0CD19UL}; + +static const uint8_t MSG_SCHEDULE[7][16] = { + {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, + {2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8}, + {3, 4, 10, 12, 13, 2, 7, 14, 6, 5, 9, 0, 11, 15, 8, 1}, + {10, 7, 12, 9, 14, 3, 13, 15, 4, 0, 11, 2, 5, 8, 1, 6}, + {12, 13, 9, 11, 15, 10, 14, 8, 7, 2, 5, 3, 0, 1, 6, 4}, + {9, 14, 11, 5, 8, 12, 15, 1, 13, 3, 0, 10, 2, 6, 4, 7}, + {11, 15, 5, 0, 1, 9, 8, 6, 14, 10, 2, 12, 3, 4, 7, 13}, +}; + +/* Find index of the highest set bit */ +/* x is assumed to be nonzero. */ +static unsigned int highest_one(uint64_t x) { +#if defined(__GNUC__) || defined(__clang__) + return 63 ^ (unsigned int)__builtin_clzll(x); +#elif defined(_MSC_VER) && defined(IS_X86_64) + unsigned long index; + _BitScanReverse64(&index, x); + return index; +#elif defined(_MSC_VER) && defined(IS_X86_32) + if(x >> 32) { + unsigned long index; + _BitScanReverse(&index, (unsigned long)(x >> 32)); + return 32 + index; + } else { + unsigned long index; + _BitScanReverse(&index, (unsigned long)x); + return index; + } +#else + unsigned int c = 0; + if(x & 0xffffffff00000000ULL) { x >>= 32; c += 32; } + if(x & 0x00000000ffff0000ULL) { x >>= 16; c += 16; } + if(x & 0x000000000000ff00ULL) { x >>= 8; c += 8; } + if(x & 0x00000000000000f0ULL) { x >>= 4; c += 4; } + if(x & 0x000000000000000cULL) { x >>= 2; c += 2; } + if(x & 0x0000000000000002ULL) { c += 1; } + return c; +#endif +} + +// Count the number of 1 bits. +INLINE unsigned int popcnt(uint64_t x) { +#if defined(__GNUC__) || defined(__clang__) + return (unsigned int)__builtin_popcountll(x); +#else + unsigned int count = 0; + while (x != 0) { + count += 1; + x &= x - 1; + } + return count; +#endif +} + +// Largest power of two less than or equal to x. As a special case, returns 1 +// when x is 0. +INLINE uint64_t round_down_to_power_of_2(uint64_t x) { + return 1ULL << highest_one(x | 1); +} + +INLINE uint32_t counter_low(uint64_t counter) { return (uint32_t)counter; } + +INLINE uint32_t counter_high(uint64_t counter) { + return (uint32_t)(counter >> 32); +} + +INLINE uint32_t load32(const void *src) { + const uint8_t *p = (const uint8_t *)src; + return ((uint32_t)(p[0]) << 0) | ((uint32_t)(p[1]) << 8) | + ((uint32_t)(p[2]) << 16) | ((uint32_t)(p[3]) << 24); +} + +INLINE void load_key_words(const uint8_t key[BLAKE3_KEY_LEN], + uint32_t key_words[8]) { + key_words[0] = load32(&key[0 * 4]); + key_words[1] = load32(&key[1 * 4]); + key_words[2] = load32(&key[2 * 4]); + key_words[3] = load32(&key[3 * 4]); + key_words[4] = load32(&key[4 * 4]); + key_words[5] = load32(&key[5 * 4]); + key_words[6] = load32(&key[6 * 4]); + key_words[7] = load32(&key[7 * 4]); +} + +INLINE void load_block_words(const uint8_t block[BLAKE3_BLOCK_LEN], + uint32_t block_words[16]) { + for (size_t i = 0; i < 16; i++) { + block_words[i] = load32(&block[i * 4]); + } +} + +INLINE void store32(void *dst, uint32_t w) { + uint8_t *p = (uint8_t *)dst; + p[0] = (uint8_t)(w >> 0); + p[1] = (uint8_t)(w >> 8); + p[2] = (uint8_t)(w >> 16); + p[3] = (uint8_t)(w >> 24); +} + +INLINE void store_cv_words(uint8_t bytes_out[32], uint32_t cv_words[8]) { + store32(&bytes_out[0 * 4], cv_words[0]); + store32(&bytes_out[1 * 4], cv_words[1]); + store32(&bytes_out[2 * 4], cv_words[2]); + store32(&bytes_out[3 * 4], cv_words[3]); + store32(&bytes_out[4 * 4], cv_words[4]); + store32(&bytes_out[5 * 4], cv_words[5]); + store32(&bytes_out[6 * 4], cv_words[6]); + store32(&bytes_out[7 * 4], cv_words[7]); +} + +void blake3_compress_in_place(uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags); + +void blake3_compress_xof(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, uint8_t flags, + uint8_t out[64]); + +void blake3_xof_many(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, uint8_t flags, + uint8_t out[64], size_t outblocks); + +void blake3_hash_many(const uint8_t *const *inputs, size_t num_inputs, + size_t blocks, const uint32_t key[8], uint64_t counter, + bool increment_counter, uint8_t flags, + uint8_t flags_start, uint8_t flags_end, uint8_t *out); + +size_t blake3_simd_degree(void); + +BLAKE3_PRIVATE size_t blake3_compress_subtree_wide(const uint8_t *input, size_t input_len, + const uint32_t key[8], + uint64_t chunk_counter, uint8_t flags, + uint8_t *out, bool use_tbb); + +#if defined(BLAKE3_USE_TBB) +BLAKE3_PRIVATE void blake3_compress_subtree_wide_join_tbb( + // shared params + const uint32_t key[8], uint8_t flags, bool use_tbb, + // left-hand side params + const uint8_t *l_input, size_t l_input_len, uint64_t l_chunk_counter, + uint8_t *l_cvs, size_t *l_n, + // right-hand side params + const uint8_t *r_input, size_t r_input_len, uint64_t r_chunk_counter, + uint8_t *r_cvs, size_t *r_n) NOEXCEPT; +#endif + +// Declarations for implementation-specific functions. +void blake3_compress_in_place_portable(uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags); + +void blake3_compress_xof_portable(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags, uint8_t out[64]); + +void blake3_hash_many_portable(const uint8_t *const *inputs, size_t num_inputs, + size_t blocks, const uint32_t key[8], + uint64_t counter, bool increment_counter, + uint8_t flags, uint8_t flags_start, + uint8_t flags_end, uint8_t *out); + +#if defined(IS_X86) +#if !defined(BLAKE3_NO_SSE2) +void blake3_compress_in_place_sse2(uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags); +void blake3_compress_xof_sse2(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags, uint8_t out[64]); +void blake3_hash_many_sse2(const uint8_t *const *inputs, size_t num_inputs, + size_t blocks, const uint32_t key[8], + uint64_t counter, bool increment_counter, + uint8_t flags, uint8_t flags_start, + uint8_t flags_end, uint8_t *out); +#endif +#if !defined(BLAKE3_NO_SSE41) +void blake3_compress_in_place_sse41(uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags); +void blake3_compress_xof_sse41(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags, uint8_t out[64]); +void blake3_hash_many_sse41(const uint8_t *const *inputs, size_t num_inputs, + size_t blocks, const uint32_t key[8], + uint64_t counter, bool increment_counter, + uint8_t flags, uint8_t flags_start, + uint8_t flags_end, uint8_t *out); +#endif +#if !defined(BLAKE3_NO_AVX2) +void blake3_hash_many_avx2(const uint8_t *const *inputs, size_t num_inputs, + size_t blocks, const uint32_t key[8], + uint64_t counter, bool increment_counter, + uint8_t flags, uint8_t flags_start, + uint8_t flags_end, uint8_t *out); +#endif +#if !defined(BLAKE3_NO_AVX512) +void blake3_compress_in_place_avx512(uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags); + +void blake3_compress_xof_avx512(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags, uint8_t out[64]); + +void blake3_hash_many_avx512(const uint8_t *const *inputs, size_t num_inputs, + size_t blocks, const uint32_t key[8], + uint64_t counter, bool increment_counter, + uint8_t flags, uint8_t flags_start, + uint8_t flags_end, uint8_t *out); + +#if !defined(_WIN32) && !defined(__CYGWIN__) +void blake3_xof_many_avx512(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, uint8_t flags, + uint8_t* out, size_t outblocks); +#endif +#endif +#endif + +#if BLAKE3_USE_NEON == 1 +void blake3_hash_many_neon(const uint8_t *const *inputs, size_t num_inputs, + size_t blocks, const uint32_t key[8], + uint64_t counter, bool increment_counter, + uint8_t flags, uint8_t flags_start, + uint8_t flags_end, uint8_t *out); +#endif + +#ifdef __cplusplus +} +#endif + +#endif /* BLAKE3_IMPL_H */ diff --git a/crates/vcore-blake3-raw/vendor/blake3_portable.c b/crates/vcore-blake3-raw/vendor/blake3_portable.c new file mode 100644 index 0000000..062dd1b --- /dev/null +++ b/crates/vcore-blake3-raw/vendor/blake3_portable.c @@ -0,0 +1,160 @@ +#include "blake3_impl.h" +#include + +INLINE uint32_t rotr32(uint32_t w, uint32_t c) { + return (w >> c) | (w << (32 - c)); +} + +INLINE void g(uint32_t *state, size_t a, size_t b, size_t c, size_t d, + uint32_t x, uint32_t y) { + state[a] = state[a] + state[b] + x; + state[d] = rotr32(state[d] ^ state[a], 16); + state[c] = state[c] + state[d]; + state[b] = rotr32(state[b] ^ state[c], 12); + state[a] = state[a] + state[b] + y; + state[d] = rotr32(state[d] ^ state[a], 8); + state[c] = state[c] + state[d]; + state[b] = rotr32(state[b] ^ state[c], 7); +} + +INLINE void round_fn(uint32_t state[16], const uint32_t *msg, size_t round) { + // Select the message schedule based on the round. + const uint8_t *schedule = MSG_SCHEDULE[round]; + + // Mix the columns. + g(state, 0, 4, 8, 12, msg[schedule[0]], msg[schedule[1]]); + g(state, 1, 5, 9, 13, msg[schedule[2]], msg[schedule[3]]); + g(state, 2, 6, 10, 14, msg[schedule[4]], msg[schedule[5]]); + g(state, 3, 7, 11, 15, msg[schedule[6]], msg[schedule[7]]); + + // Mix the rows. + g(state, 0, 5, 10, 15, msg[schedule[8]], msg[schedule[9]]); + g(state, 1, 6, 11, 12, msg[schedule[10]], msg[schedule[11]]); + g(state, 2, 7, 8, 13, msg[schedule[12]], msg[schedule[13]]); + g(state, 3, 4, 9, 14, msg[schedule[14]], msg[schedule[15]]); +} + +INLINE void compress_pre(uint32_t state[16], const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, uint8_t flags) { + uint32_t block_words[16]; + block_words[0] = load32(block + 4 * 0); + block_words[1] = load32(block + 4 * 1); + block_words[2] = load32(block + 4 * 2); + block_words[3] = load32(block + 4 * 3); + block_words[4] = load32(block + 4 * 4); + block_words[5] = load32(block + 4 * 5); + block_words[6] = load32(block + 4 * 6); + block_words[7] = load32(block + 4 * 7); + block_words[8] = load32(block + 4 * 8); + block_words[9] = load32(block + 4 * 9); + block_words[10] = load32(block + 4 * 10); + block_words[11] = load32(block + 4 * 11); + block_words[12] = load32(block + 4 * 12); + block_words[13] = load32(block + 4 * 13); + block_words[14] = load32(block + 4 * 14); + block_words[15] = load32(block + 4 * 15); + + state[0] = cv[0]; + state[1] = cv[1]; + state[2] = cv[2]; + state[3] = cv[3]; + state[4] = cv[4]; + state[5] = cv[5]; + state[6] = cv[6]; + state[7] = cv[7]; + state[8] = IV[0]; + state[9] = IV[1]; + state[10] = IV[2]; + state[11] = IV[3]; + state[12] = counter_low(counter); + state[13] = counter_high(counter); + state[14] = (uint32_t)block_len; + state[15] = (uint32_t)flags; + + round_fn(state, &block_words[0], 0); + round_fn(state, &block_words[0], 1); + round_fn(state, &block_words[0], 2); + round_fn(state, &block_words[0], 3); + round_fn(state, &block_words[0], 4); + round_fn(state, &block_words[0], 5); + round_fn(state, &block_words[0], 6); +} + +void blake3_compress_in_place_portable(uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags) { + uint32_t state[16]; + compress_pre(state, cv, block, block_len, counter, flags); + cv[0] = state[0] ^ state[8]; + cv[1] = state[1] ^ state[9]; + cv[2] = state[2] ^ state[10]; + cv[3] = state[3] ^ state[11]; + cv[4] = state[4] ^ state[12]; + cv[5] = state[5] ^ state[13]; + cv[6] = state[6] ^ state[14]; + cv[7] = state[7] ^ state[15]; +} + +void blake3_compress_xof_portable(const uint32_t cv[8], + const uint8_t block[BLAKE3_BLOCK_LEN], + uint8_t block_len, uint64_t counter, + uint8_t flags, uint8_t out[64]) { + uint32_t state[16]; + compress_pre(state, cv, block, block_len, counter, flags); + + store32(&out[0 * 4], state[0] ^ state[8]); + store32(&out[1 * 4], state[1] ^ state[9]); + store32(&out[2 * 4], state[2] ^ state[10]); + store32(&out[3 * 4], state[3] ^ state[11]); + store32(&out[4 * 4], state[4] ^ state[12]); + store32(&out[5 * 4], state[5] ^ state[13]); + store32(&out[6 * 4], state[6] ^ state[14]); + store32(&out[7 * 4], state[7] ^ state[15]); + store32(&out[8 * 4], state[8] ^ cv[0]); + store32(&out[9 * 4], state[9] ^ cv[1]); + store32(&out[10 * 4], state[10] ^ cv[2]); + store32(&out[11 * 4], state[11] ^ cv[3]); + store32(&out[12 * 4], state[12] ^ cv[4]); + store32(&out[13 * 4], state[13] ^ cv[5]); + store32(&out[14 * 4], state[14] ^ cv[6]); + store32(&out[15 * 4], state[15] ^ cv[7]); +} + +INLINE void hash_one_portable(const uint8_t *input, size_t blocks, + const uint32_t key[8], uint64_t counter, + uint8_t flags, uint8_t flags_start, + uint8_t flags_end, uint8_t out[BLAKE3_OUT_LEN]) { + uint32_t cv[8]; + memcpy(cv, key, BLAKE3_KEY_LEN); + uint8_t block_flags = flags | flags_start; + while (blocks > 0) { + if (blocks == 1) { + block_flags |= flags_end; + } + blake3_compress_in_place_portable(cv, input, BLAKE3_BLOCK_LEN, counter, + block_flags); + input = &input[BLAKE3_BLOCK_LEN]; + blocks -= 1; + block_flags = flags; + } + store_cv_words(out, cv); +} + +void blake3_hash_many_portable(const uint8_t *const *inputs, size_t num_inputs, + size_t blocks, const uint32_t key[8], + uint64_t counter, bool increment_counter, + uint8_t flags, uint8_t flags_start, + uint8_t flags_end, uint8_t *out) { + while (num_inputs > 0) { + hash_one_portable(inputs[0], blocks, key, counter, flags, flags_start, + flags_end, out); + if (increment_counter) { + counter += 1; + } + inputs += 1; + num_inputs -= 1; + out = &out[BLAKE3_OUT_LEN]; + } +} diff --git a/crates/vcore-netstack/Cargo.toml b/crates/vcore-netstack/Cargo.toml index 7bd6537..4717418 100644 --- a/crates/vcore-netstack/Cargo.toml +++ b/crates/vcore-netstack/Cargo.toml @@ -9,7 +9,7 @@ description = "Bounded userspace IPv4/IPv6 TCP and UDP netstack for VCore" [dependencies] bytes = "1" futures-util = "0.3" -smoltcp = { version = "0.13", default-features = false, features = [ +smoltcp = { version = "0.14", default-features = false, features = [ "std", "medium-ip", "auto-icmp-echo-reply", diff --git a/docs/README.md b/docs/README.md index f09665f..5dc4c1c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,48 +1,34 @@ # VCore 文档 -本目录只描述当前实现和仍然有效的开发约束。历史方案、阶段计划、一次性实验记录、旧包哈希和第三方实现对比不属于公共文档;源代码、测试与本文档不一致时视为缺陷。 +本文档只维护当前契约、有效约束和发布边界。历史方案、阶段报告与已取消能力留在 Git 历史,不另建归档副本。行为以当前源码、测试和契约为准,不一致时一起修正。 -## 公共契约 +## 配置与接口 -1. [配置协议](config.yaml):配置修订版 14 的完整 YAML 示例、静态 `select` 代理组及组上游和严格字段边界。 -2. [Invoke API](invoke-api.md):API v5 的请求格式、生命周期和平台回调。 -3. [AnyTLS 出站](anytls.md):TLS、会话复用、填充、TCP/UoT 和清理语义。 -4. [REALITY V1 协议](reality-wire-protocol.md):握手、认证、连接状态和失败边界。 -5. [运行时 Controller](controller-api.md):Bearer 鉴权、实时代理组选择和四字段 TUN 流量快照。 -6. [DNS 与 ICMP](tun-icmp-dns.md):TUN DNS、代理组出口、缓存、故障转移和本地 Echo Reply。 -7. [GeoData](geodata.md):规则、资产更新、匹配器和资源上限。 -8. [TUN 平台层](tun-platform.md):文件描述符、原始 IP 包和平台所有权。 -9. [HTTP 代理入站](http-proxy.md):本机 / 共享监听、逐请求认证、消息定界、CONNECT / Upgrade 和同步停止。 -10. [SOCKS5 代理入站](socks5-proxy.md):CONNECT、授权 UDP 关联、双栈与同端口监听、来源隔离和回收。 -11. [Shadowsocks 2022 出站](shadowsocks.md):官方依赖、配置/IO 适配、AWS-LC 局部例外、已知风险与未完成互通门槛。 +- [配置协议](config.yaml):严格 YAML、节点、静态 select 组、DNS 与规则。 +- [Invoke API](invoke-api.md):请求格式、生命周期、平台回调与内联配置。 +- [Controller](controller-api.md):认证、运行中组选择和 TUN 流量。 +- [入站](inbounds.md):HTTP 转发/隧道、SOCKS5 TCP/授权 UDP、监听及认证。 +- [出站](outbounds.md):SOCKS5、AnyTLS、Trojan、VMess AEAD、Hysteria2、SS 2022。 +- [VLESS](vless.md):传输、Vision、Encryption、REALITY、JLS、静态 ECH。 +- [XHTTP 与 sing-mux](xhttp.md):双腿、H1/H2/H3、连接池及复用。 +- [TLS 证书与指纹](tls-client-fingerprint.md):证书 pin、ClientHello 模板、身份与缓存。 +- [REALITY 线协议](reality-wire-protocol.md):认证、密钥与失败边界。 -## 平台与运行架构 +## 运行与平台 -1. [Windows VPN 平台边界](windows-vpn.md):官方 VPN API、包缓冲区、路由、物理网络绑定和安装包约束。 -2. [Windows 会话运行时](windows-session-runtime.md):Provider、Session Host、包通道和生命周期。 -3. [运行时资源策略](runtime-resource-policy.md):局部容量、取消、回收和遥测原则。 -4. [rustls REALITY 依赖](rustls-reality-release.md):自有 fork 的边界和发布要求。 -5. [Windows UWP VPN 最小集成](../example/windows-uwp/README.md):同包 manifest、完全信任宿主和可运行 demo。 +- [DNS 与 ICMP](tun-icmp-dns.md)、[GeoData](geodata.md)。 +- [资源策略](runtime-resource-policy.md):局部预算、所有权、取消和观测。 +- [TUN 平台](tun-platform.md)、[Windows VPN](windows-vpn.md)。 +- [TLS 依赖与发布](tls-dependencies.md):来源、provider、fork 与升级门禁。 +- [Windows 最小集成示例](../example/windows-uwp/README.md)。 -## 证据与维护 +## 开发与验证 -- [验收矩阵](acceptance.md):自动化覆盖、实机覆盖和未完成的发布门禁。 -- [架构决策](adr/):仍然有效的系统级决策。 -- [开发上下文](../CONTEXT.md):领域词汇和模块边界。 -- [构建与检查](../scripts/README.md):统一脚本、产物和环境变量。 +- [构建与检查](../scripts/README.md)、[测试入口](../tests/README.md)。 +- [测试隔离](testing-isolation.md):所有网络服务端必须容器化。 +- [验收边界](acceptance.md):本地、CI、设备和发布证据分别记录。 +- [领域上下文](../CONTEXT.md)、[架构决策](adr/)。 +- 工程 skills:[Issue 跟踪](agents/issue-tracker.md)、[分类标签](agents/triage-labels.md)、[领域流程](agents/domain.md)。 -## 工程 skills - -- [Issue 跟踪](agents/issue-tracker.md):本仓库 GitHub Issues、显式仓库选择和工作项协作。 -- [分类标签](agents/triage-labels.md):五个默认 triage 角色与 GitHub 标签映射。 -- [领域文档流程](agents/domain.md):单上下文词汇、ADR 阅读与冲突处理。 - -`AGENTS.md` 保留工程约束和按任务读取的入口;skills 工作流维护在 `docs/agents/`。 - -## 文档规则 - -- 协议文档只描述当前版本;被删除的字段和历史兼容结构不重复收录。 -- 技术标识符、配置键、API 名称和线上协议值保留原文,其余说明使用中文。 -- 验收文档保留可复现矩阵,不累积候选包版本、临时进程号或一次性性能结果。 -- 主机测试、交叉编译、模拟器和虚拟网络不能替代对应物理平台证据。 -- 公共行为变化必须同时更新实现、测试和相关契约。 +配置字段由 `config.yaml` 维护;协议文档补充语义与限制,不复制阶段计划。 +技术标识符保留原文,说明使用中文。AGENTS.md 只放跨任务约束与读取入口。 diff --git a/docs/acceptance.md b/docs/acceptance.md index d6d0cd7..237b652 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -1,146 +1,62 @@ -# VCore 验收矩阵 +# 验证范围与发布边界 -本文记录验证范围与发布门禁,不把历史开发包结果当作当前 release 保证。 -源码 revision、lockfile/产物 hash、签名、环境、命令和结果保存在当次 PR 或发布记录中。 +测试入口见 [tests](../tests/README.md),命令见 [scripts](../scripts/README.md)。 +本文定义证据边界,不是随源码自动续期的“全部通过”证明。 -## 证据规则 +## 必须保留的验证 -- 主机测试、交叉编译、模拟器和虚拟网络不能替代对应物理 TUN 或安装包证据。 -- x64 模拟不能替代原生 x64;VPN 双栈接口不能替代真实物理 IPv6。 -- 外网吞吐与进程间通信基准各自独立,不能相互替代。 -- 未执行或缺少可复现记录的项目保持未验证;不从旧产物继承通过结论。 -- 记录不得包含 secret、UUID、密钥、完整用户配置或私有目标信息。 - -## 自动化覆盖 - -当前 source/tests 覆盖: - -- Invoke API v5、单实例生命周期、Debug/Release 运行时线程重入拒绝、panic 与同步清理; -- schema revision 14、IPv6、严格 YAML、节点 / `select` 组上游混合 DAG 和 node-only 测速; -- 组上游与路由的共享选择、SOCKS5 UDP 建链快照、潜在 DIRECT 首跳准备、独立下载端点和深图回收; -- HTTP 本机 / 认证共享、双栈监听回滚、逐请求认证与分发、Keep-Alive / 正文定界、CONNECT / Upgrade、10 MiB 双向摘要与活动连接 Stop; -- SOCKS5 入站认证、三类目标、半关闭、TCP 授权 UDP、源端口学习/隔离、IPv6 作用域固定端口/学习端口匹配与跨接口隔离、过期/满队列/慢上游取消及纯 SOCKS5 Controller(作用域匹配为合成地址测试,不代表物理 LAN 验证); -- VLESS/XHTTP/TLS/REALITY、SOCKS5、AnyTLS、代理链、DNS、规则、GeoData 和 HTTP/TLS/QUIC 嗅探; -- AnyTLS 有序 ALPN、WebPKI / 跳过 / 叶与非叶 pin、TLS 1.2/1.3 伪造签名拒绝、节点间策略隔离和精确恢复票据预算; -- SS 2022 三算法白名单、同库 TCP/UDP 回环、有界 TCP 首写、首次传输前半关闭及 Pending 空握手续写、响应时间/认证/请求盐、UDP Pending/取消、重放/乱序/会话轮换、封装上限与来源检查,socket protect 失败关闭; -- ICMPv4/v6、校验和、分片、MTU、队列与 Apple/Android 帧/fd/protect 所有权; -- Windows 单 Application、token 绑定、Snapshot/profile、控制/数据协议、会合记录、 - 物理绑定、on-link prefix 去重、显式排除优先的路由和 MTU 派生的 UDP/DNS 上限; -- Windows Job Object 进程监督,以及 Controller 鉴权、流量、实时组选择和有界请求。 - -常规命令见 [README Validation](../README.md#validation),平台构建与脚本检查见 -[scripts](../scripts/README.md)。运行时线程重入还需 Release 配置: - -```bash -cargo test --locked --release --all-features --all-targets -``` - -后续外部互操作以 mihomo 为对端,不从单元测试推断: - -```bash -bash tests/run_mihomo_interop.sh -``` - -该入口当前覆盖 8 个 HTTP 场景、8 个 SOCKS5 TCP/UDP 双向 IPv4/IPv6 场景及 17 个 AnyTLS 检查(12 个 TCP/UoT 数据场景、3 个独立测速、2 个证书拒绝)。版本、二进制 hash、超时及清理见 [scripts](../scripts/README.md#mihomo-协议互通)。既有 Xray / anytls-go 脚本保留为历史专用入口,未迁移的协议场景仍需补充 mihomo 证据,不能自动继承旧对端结果。 - -## 协议与数据面 - -| 能力 | 自动化 | 外部进程互操作 | 物理 TUN / 安装包 | -| --- | --- | --- | --- | -| VLESS + XHTTP + TLS/REALITY | 已覆盖 | mihomo stream-one/REALITY 有受控组合证据;其他模式仍待迁移;历史 Xray harness 保留 | Windows ARM64 开发包已覆盖 | -| SOCKS5 CONNECT / UDP ASSOCIATE 出站 | 已覆盖 | mihomo TCP/UDP、IPv4/IPv6 | Windows ARM64 历史开发包已覆盖 | -| SOCKS5 CONNECT / TCP 授权 UDP 入站 | 已覆盖 | mihomo 双向 TCP/UDP、IPv4/IPv6 | 真实 LAN / 物理 IPv6 未验证 | -| AnyTLS TCP / UoT v2、ALPN / 证书策略 | 已覆盖 | mihomo 公开 YAML,TCP/UoT IPv4/IPv6、测速及证书拒绝 | 旧能力有 Windows ARM64 历史开发包记录;新 TLS 字段未做设备验证 | -| SS 2022 | 三算法配置与 I/O/安全边界;活动 Stop、绑定失败回滚、独立测速和进程 FD 回收 | mihomo TCP/UDP × IPv4/IPv6/域名及服务器先发;具体/嵌套组/DIRECT 链;AES 1/2 层受控 EIH 中继 → mihomo;错误密钥/算法/身份拒绝 | 未验证 | -| HTTP 本机 / 认证共享、消息定界与隧道 | 已覆盖 | mihomo 双向 harness(8 个场景) | 真实 LAN / 物理 IPv6 未验证 | -| DIRECT 与代理链 | 已覆盖 | 本地 fixture | Windows ARM64 开发包已覆盖 | -| DNS / rules / GeoData / sniffer | 已覆盖 | 本地 DNS 与代理 fixture | Windows ARM64 开发包已覆盖 | -| ICMPv4 / ICMPv6 Echo | 已覆盖 | 不适用 | Windows ARM64 开发包已覆盖 | -| Controller 四字段流量 | 已覆盖 | HTTP fixture | Windows ARM64 开发包已覆盖 | -| Controller `select` 代理组控制 | 已覆盖 | HTTP fixture | 物理设备未验证 | -| `dialer-proxy` 引用 `select` 组 | 配置、建链、切换与回收 fixture | 本地 SOCKS5 fixture | 物理设备未验证 | - -Windows 开发包的适用环境与证据入口见下节。本地互操作只证明受控配置, -不代表所有公网服务、所有协议组合或当前发布包。 - -2026-09-17 的 SS 候选在 macOS arm64 / Rust 1.98.1 上完成 12 项 SS 专项、Debug/Release 各 563 项库测试与 4 项兼容测试、17 项 netstack 测试及 clippy/依赖审计。默认构建、精简 feature 组合、Rust 1.91 检查和无 `interop-test` 的原生 macOS Release `ffi` 构建通过。mihomo 对端为 `ab405bad5beeeac8b003bb01f60f134f6df54471`,二进制 SHA-256 `99462761f951b08df9911cf82f96e2f0cf34cda959b1bc391584b1b1bb2de5b3`;当次三算法 Stop/回滚/测速后测试进程 FD 回到 6 的基线。 - -SS 原样上游库的 padding 未初始化风险仍存在,未将相关失败证据改记为 PASS;完整说明见 [Shadowsocks](shadowsocks.md)。EIH 中继只处理身份头,最终 SS 业务解密由 mihomo 完成,不是同库自测或 mihomo 原生 EIH 服务端。 - -跨协议候选增加四类出站的两跳 TCP/UDP、真实嵌套组切换、HTTP-only Controller / 测速快照、合成 utun 和 100 次生命周期检查。首次 1800 秒持续测试约 8 分半后因 UDP 来源异常失败;macOS 双栈 UDP 本地端口串扰已独立复现,但当时未保存完整 socket 映射,不把所有历史超时归为同一根因。随后仅在自有测试中增加双地址族端口保护与跨进程互斥,不修改 VCore 生产 socket 行为或第三方源码。修复后 Debug/Release 各 564 项库测试、4 项兼容测试和 2 项端口保护测试通过,lib/bins 及 mihomo fixture clippy 通过。 - -修复后的单次 1800 秒长测通过:16 TCP + 16 UDP、1,754 次切组、29 次断连重建、470,248,320 字节校验;FD 为 6 → 195 → 6(含 32 个测试 guard),活动起始/峰值/结束/Stop 后 RSS 分别为 17,616 / 18,688 / 17,264 / 17,120 KiB,32-flow 建立耗时起始 165 ms、末次 166 ms。此结果不等于完整阶段签收:随后新增的 100 次快速重建检查两次失败,一次是收到 origin 请求后的回包超时,另一次在第 11 轮捕获 mihomo 的 `reject loopback connection`,AnyTLS 来源端口与该进程另一 UDP socket 端口同号。后一次有日志与 socket 映射佐证,前一次不强行归因。没有关闭 mihomo 保护或修改第三方;这些失败保留,不因独立网络重跑而改为 PASS。命令及限制见 [mihomo harness](../scripts/README.md#mihomo-协议互通)。 - -随后配置 Apple Container 1.4.1 的专用 host-only 网络:首跳和末跳使用两个 Linux ARM64 mihomo,仍为相同源码 revision,Linux 程序 SHA-256 `eb35562be501dd6cd9e1f8bdf3ba43162bf6abb6a3946cd1f1ec0511462feab2`。反向 HTTP/SOCKS5 入站仍为原生对端,VCore 不开放 LAN。基础 IPv4/IPv6 互通、全部跨协议与宿主组合、100 次生命周期,以及 100 次快速 32-flow 重建通过;快速重建后 FD 回到 6,在用堆 64,176 B。最初适配中的固定回环断言和 SLAAC 地址未就绪失败分别保留并修正于自有测试。候选 Debug/Release 各 564 lib + 4 compatibility + 2 fixture、netstack 17、Python 18、clippy、feature 组合与依赖审计通过。 - -该容器候选完成新的 1800 秒长测,完整测试总时长 1840.10 秒:16 TCP + 16 UDP、1,734 次切组、29 次断连重建、420,476,672 字节校验;FD 6 → 195 → 6。RSS 活动初始 / 峰值 / 结束 / Stop 后分别为 19,248 / 21,984 / 19,184 / 18,896 KiB;四次仅统计、不读取内容的堆采样约 4.90–4.91 MB,未出现持续活对象增长,后段 RSS 回落。初次 / 末次 32-flow 建立耗时为 204 / 153 ms;诊断采样扰动使最大 wave 达 441 ms,不能拿本轮的节流速率或最大延迟当无扰动性能基准。本机受控组合与资源门槛签收,全部自建对端与临时配置清理;这不修复或取消旧 macOS 同内核失败,不代表物理 TUN、LAN、IPv6 或平台发布门禁通过。 - -## Apple 与 Android - -主机自动化覆盖 utun/rawIp、nonblocking、借用 fd 复制/关闭、EOF/非法包/部分写入、 -Android protect 失败关闭和重复 prepare/start/stop。构建脚本可生成 Apple -XCFramework 与 Android arm64-v8a/x86_64 库。 - -2026-09-17 在 `926e15b5070316763c82ed8d749580d811e61ddb` 的干净检出、无相邻 rustls 工作目录下,Apple 五目标(iOS arm64、simulator arm64/x86_64、macOS arm64/x86_64)和 Android 两 ABI 的标准 Release 构建通过。环境为 macOS 27 / arm64、Rust 1.98.1、Xcode 27.0(27A266a)及 SDK 27、Android NDK 28.2.13676358 / API 24。所有产物身份为 Invoke API v5 / schema 14,不含 `interop-test`;lockfile SHA-256 为 `64866bfff397559e3b5e9cb03094cfb229ccaf28ae2c8fae0a549d3a9106f8e2`。使用完整显式协议 feature 列表重建后,产物哈希与原 `ffi` / `tun` 传递启用方式一致。 - -macOS ARM64 原生 C 宿主链接该 XCFramework,1,000 次 `version` Invoke / VCoreFree 和身份检查通过;这是 C ABI 冒烟,不是物理 TUN、完整应用或 x64 原生运行验证。新增 CI 构建矩阵仅通过本地静态检查,本轮未触发远端 CI,也未获得 Windows 新能力构建或设备数据面证据。 - -同一干净检出的 locked fetch、C header、TLS/AWS-LC 来源审计及 Debug/Release 全测试通过(各 564 lib + 4 compatibility + 2 fixture,ignored 不计通过)。本机复用 Cargo cache,不声称验证了空缓存或离线下载。 - -仍需独立验证: - -- Release iOS 无 debugger 的完整 TUN 生命周期与整进程内存轨迹; -- Android 真机的 TUN、protect、DNS、TCP/UDP 和重复启停; -- macOS system extension 的产品安装与生命周期。 - -## Windows VPN - -### 已记录的开发环境 - -Windows 11 ARM64 开发签名包曾覆盖数据面、生命周期、压力与有界 batching; -单 Application loose-package 和后续 clean-install policy 门禁在 build 26200.9278 -执行。不同记录不能拼接成一份完整正式发布结论。 - -| 记录 | 已观察范围 | 限制 | +| 层次 | 验证内容 | 不能证明 | | --- | --- | --- | -| 单 Application Gate 0 | AppContainer Provider、无参数激活同包 medium-integrity Session Host、connect/stop、会合清理 | loose-package spike 的基线含未提交目标改动,单独基线 SHA 不能复现;不证明签名包或 Store | -| 全局 policy / IPv4-only clean gate(2026-09-01) | IPv4-only/dual-stack lifecycle、LAN allow/block、IPv4 exclusion/control、Always On、cold profile、失败关闭和 Stop 后清理 | 只验证一个局域网 peer,没有逐个探测全部 on-link prefix | -| PR 修复后同机开发包回归 | TCP、UDP、ICMPv4/v6,DNS 开关两种情况下的清缓存 hostname 请求、link-local route inventory、零残留 | 没有物理 IPv6/default gateway;UDP probe 仅验证正常大小报文,不证明超限传输 | +| 离线 / 纯内存 | 严格配置与 feature、DAG/组快照、协议向量、TLS 身份/签名/pin、取消与局部上限、FFI 所有权 | 网络互通、设备 | +| 容器互通 | 公开配置和消费者、真实认证及负例、传输关闭、UDP 来源与边界、受控 DNS/上游 | 任意字段组合或公网服务 | +| 集成 / 压力 | 七出站的有序两跳、运行时切组、Stop/回滚/测速、重建、持续流量与静默窗口 | 无扰动吞吐基准或整机内存保证 | +| 平台构建 / ABI | 同一锁文件、产物架构/身份/hash、原生 C/Swift 消费者与打包依赖 | 物理 TUN、签名安装 | +| 设备 / 发布 | 真机网络、protect/物理绑定、正式宿主生命周期、签名安装及商店门禁 | 其他平台或后续 revision | -门禁结束时检查 Session Host、VPN route/interface 和测试进程清理;已停止 Provider -的 AppContainer 外壳可能暂留,原 case 之间显式清理它。Windows 的两条 `/1`、 -null IPv6 地址参数、物理 transport 绑定等实现约束见 -[Windows VPN](windows-vpn.md),不在验收文档另维护一份。 +保留独立 ClientHello golden、Encryption 密码向量、H2 完整响应后 RST、SS 背压/读先于写的刷新、 +HY2 已完成分片 ID 重用等确定性回归。单纯复用实现生成期望值、声明字段数量或找到 PASS +文本,不能替代行为验证。ignored、未运行、基础设施失败和清理失败均不得计为通过。 -### 不可变证据入口 +## 对端与已知限制 -以下记录保留环境、命令、结果和适用范围,替代滚动追加候选包叙述: +- 所有协议端、原站、DNS 和提供入口的对照客户端遵守[隔离规则](testing-isolation.md)。 + 优先 Mihomo listener;缺少的能力由官方 Xray、V2Ray、Hysteria 或 Shadowsocks 服务端补验。 +- 关闭行为按 Mihomo 的实际传输包装链验证;不要求所有传输在上传 EOF 后仍收到尾包。 +- WS + REALITY 的数据/认证对 Mihomo listener 验证;关闭使用同种 WS 的标准 TLS 分层参照。 + 无指纹 JLS/gRPC、Safari ECH/gRPC 的官方对照缺口使用明确标注的 Chrome 关闭参照。 + 这些不是完全同配置的客户端差分。 +- XHTTP H3/mTLS 使用获准的 Caddy 网关;packetaddr/sing-mux 等分层拓扑明确标注 + 网关、会话处理器和解码端,不能宣称单个原生服务端直接支持全部能力。 +- 官方 Hysteria 回包缓冲包含协议头;V2Ray 部分 VMess 返回路径也有更小缓冲。 + 原生夹具上限不改变 VCore 的协议预算。 +- SS 原样上游 padding 未初始化风险及空首包随机零 padding 被严格服务端拒绝的限制 + 仍未修补。codec 级刷新回归不证明 server-first 互通。官方 ssserver 单层 EIH 终结与自有 + 1/2 层身份中继是不同证据,不能据此宣称任意多层原生 EIH。详见[出站](outbounds.md#shadowsocks-2022)。 +- 静态 ECH、JLS、Encryption 与混合 REALITY 是选定能力集;动态 ECH、Restls、 + ShadowTLS、WireGuard 不在当前范围。JLS 内部使用 fork hook 不构成 ShadowTLS 支持。 -- [Session Runtime lifecycle、失败关闭、重连与 pressure](https://github.com/OneVCore/VCore/blob/f41610c/docs/acceptance.md#windows-session-runtime-phase-6-2026-08-24) -- [protocol-v1 有界 batching](https://github.com/OneVCore/VCore/blob/7856bef/docs/acceptance.md#windows-session-runtime-phase-6-2026-08-24) -- [外部 TUN/DNS 地址与安装包边界](https://github.com/OneVCore/VCore/blob/1011955/docs/acceptance.md#6-windows-vpn) -- [external Xray SOCKS tun2socks demo](https://github.com/OneVCore/VCore/blob/6636bd7/docs/acceptance.md#67-external-xray-socks-tun2socks-demo) -- [UWP VPN 最小示例 lifecycle](https://github.com/OneVCore/VCore/blob/26e1095/docs/acceptance.md#windows-vpn) -- [单 Application spike 与 2026-09-01 policy/data-plane gate 完整记录](https://github.com/OneVCore/VCore/blob/9205e2e83dc84905f24a7dda5570582d71d075b5/docs/acceptance.md#windows-vpn) +## 当前证据如何使用 -### 未完成发布门禁 +精简前的[冻结验收索引](https://github.com/OneXray/VCore/blob/b7c0100602e188bf28b9fa5370e11120069b54f7/docs/acceptance.md) +保留各次运行、原始失败、环境和适用 revision;[完整阶段记录](https://github.com/OneXray/VCore/tree/b7c0100602e188bf28b9fa5370e11120069b54f7/docs/acceptance) +可在 Git 历史查阅。它们不再复制到当前文档。 -- production-signed MSIX、WACK、Partner Center identity/publisher 与受限能力审批、 - ARM64/x64 Store bundle、提交与安装路径; -- Windows 10 20H2、原生 x64、真实物理 IPv6、新鲜物理网卡禁用场景; -- 多用户和远程会话; -- 带公开可复现命令的 `sessionBackend` package-boundary argv、进程退出和 Job - 清理矩阵,以及 native x64/正式宿主 UI 路径回归。 +该基线记录过本地协议集成、持续压力与 Apple/Android 构建通过;随后 boring release / +Shadowsocks registry 接入只做了定向 JLS、SS 和离线回归,没有重签全部组合及长测。 +平台交付仍未完整签收。这些是历史记录范围,不代表当前 checkout、远端 CI 或发布候选 +已经重新执行。后续结果放当次 PR、CI artifact 或发布记录,绑定源码、锁文件、对端、 +命令、结果及清理证据,不在本文累计包哈希和阶段流水账。 -## rustls REALITY 发布 +## 尚需独立签收 -REALITY 线上向量、普通 TLS、错误 key/short ID、HRR、并发和取消有自动化覆盖。 -正式发布仍需: +- iOS 无 debugger 的 Release TUN 生命周期与整进程内存;Android 真机 TUN/protect、 + DNS/TCP/UDP、重复启停;macOS system extension 正式宿主安装和生命周期。 +- Windows 10 20H2、原生 x64、真实物理 IPv6、物理网卡禁用、多用户/远程会话; + session backend 包路径/argv/退出与 Job 清理、正式宿主 UI。 +- production-signed MSIX、WACK、Partner Center identity/publisher 与受限能力、 + ARM64/x64 Store bundle、提交及安装。 +- 干净 checkout、同一 lockfile 的平台产物、原生运行库和完整许可证审查。 -- 无相邻 rustls 目录的干净检出执行 locked tests; -- 使用同一 lockfile 完成 Apple、Android、Windows 构建; -- 保存 VCore/rustls revision、toolchain、目标架构、lockfile/产物 SHA-256、 - 签名安装与当次物理设备/网络/失败关闭矩阵。 - -详细要求见 [rustls 发布契约](rustls-reality-release.md)。 -没有当次证据的项目不得写成发布保证。 +Windows 11 ARM64 历史开发包曾覆盖数据面、policy 和生命周期;loose-package、 +单个 LAN peer、合成 utun 或交叉编译均不能抵扣上述门禁。记录遵守隐私规则,不含 +真实凭据、UUID、密钥、完整用户配置或私有目标。 diff --git a/docs/adr/0003-run-windows-runtime-in-a-full-trust-session-host.md b/docs/adr/0003-run-windows-runtime-in-a-full-trust-session-host.md index cebc736..26fe64d 100644 --- a/docs/adr/0003-run-windows-runtime-in-a-full-trust-session-host.md +++ b/docs/adr/0003-run-windows-runtime-in-a-full-trust-session-host.md @@ -8,4 +8,4 @@ AppContainer Provider 只拥有 `VpnChannel`、包缓冲区、路由、物理网 Session Host 通过 `IApplicationActivationManager` 激活,并使用 Provider 发布的 AppContainer 限定对象路径连接控制管道和数据管道。会合记录只包含协议版本、快照令牌、相对对象路径和固定管道名称。 -该拆分使 Controller 和本机 SOCKS5 服务可由完全信任进程正常访问,同时不引入 Wintun、`CheckNetIsolation` 豁免、宽泛 DACL 或按协议转发层。完整契约见 [Windows 会话运行时](../windows-session-runtime.md)。 +该拆分使 Controller 和本机 SOCKS5 服务可由完全信任进程正常访问,同时不引入 Wintun、`CheckNetIsolation` 豁免、宽泛 DACL 或按协议转发层。完整契约见 [Windows 会话运行时](../windows-vpn.md)。 diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md index ef9529a..b09550f 100644 --- a/docs/agents/issue-tracker.md +++ b/docs/agents/issue-tracker.md @@ -1,19 +1,19 @@ # Issue tracker: GitHub -Issues and specs live in GitHub Issues for `OneVCore/VCore`. +Issues and specs live in GitHub Issues for `OneXray/VCore`. -Use `gh`. Every issue or PR command must specify `--repo OneVCore/VCore` because -the SSH remote uses an alias. For `gh api`, use an explicit `repos/OneVCore/VCore/...` -endpoint. +Use `gh`. Every issue or PR command must specify `--repo OneXray/VCore`. +For `gh api`, use an explicit `repos/OneXray/VCore/...` endpoint. Before publishing, +verify that `origin` and the resolved GitHub repository still identify this project. ## Operations -- Publish a request/spec: `gh issue create --repo OneVCore/VCore --title "..." --body-file `. -- Read a ticket: `gh issue view --repo OneVCore/VCore --comments`. -- List tickets: `gh issue list --repo OneVCore/VCore --state open --json number,title,body,labels,comments`. -- Comment: `gh issue comment --repo OneVCore/VCore --body-file `. -- Apply/remove labels: `gh issue edit --repo OneVCore/VCore --add-label "..."` / `--remove-label "..."`. -- Close: `gh issue close --repo OneVCore/VCore --comment "..."`. +- Publish a request/spec: `gh issue create --repo OneXray/VCore --title "..." --body-file `. +- Read a ticket: `gh issue view --repo OneXray/VCore --comments`. +- List tickets: `gh issue list --repo OneXray/VCore --state open --json number,title,body,labels,comments`. +- Comment: `gh issue comment --repo OneXray/VCore --body-file `. +- Apply/remove labels: `gh issue edit --repo OneXray/VCore --add-label "..."` / `--remove-label "..."`. +- Close: `gh issue close --repo OneXray/VCore --comment "..."`. Read a referenced ticket's body, comments, and labels before acting on it. Use the [label mappings](triage-labels.md) for canonical triage roles. @@ -36,6 +36,6 @@ Issues hold requests and specs; PRs hold implementation changes. `Blocked by: #` in the child and inspect those issues' states. - A child is unblocked only when every blocker is closed. - Select the first open, unassigned, unblocked child in map order. -- Claim it with `gh issue edit --repo OneVCore/VCore --add-assignee @me`. +- Claim it with `gh issue edit --repo OneXray/VCore --add-assignee @me`. - Resolve by commenting the result, closing the ticket, and appending a concise result and evidence link to the map's Decisions-so-far. diff --git a/docs/anytls.md b/docs/anytls.md deleted file mode 100644 index ce2cd2a..0000000 --- a/docs/anytls.md +++ /dev/null @@ -1,111 +0,0 @@ -# AnyTLS 出站 - -本文描述当前 AnyTLS 客户端出站契约。VCore 支持 AnyTLS v2、兼容 v1 的会话、内置填充和 UoT v2。 - -## 配置 - -```yaml -proxies: - - name: anytls-edge - type: anytls - server: anytls.example.com - port: 443 - password: "private-password" - sni: origin.example.com - # alpn: [h2, http/1.1] - skip-cert-verify: false - # fingerprint: "" - udp: true - # dialer-proxy: socks-hop -rules: - - MATCH,anytls-edge -``` - -- `name`、`type`、`server`、`port` 和 `password` 必填,`type` 固定为 `anytls`。 -- `password` 长度为 1–1024 个 UTF-8 字节,不得出现在调试输出、日志或错误正文中。 -- `sni` 可省略,默认使用 `server`;两者都必须是合法 IP 字面量或域名。 -- `alpn` 可省略或为 `[]`,均不发送 ALPN;非空列表按配置顺序发送。每项为 1–255 个 UTF-8 字节,带长度字节的列表总长最多 65,533 字节。 -- `skip-cert-verify` 默认为 `false`;`fingerprint` 为证书 DER 的 SHA-256,接受大小写十六进制及冒号分隔形式,去除冒号后必须为 64 个十六进制字符。 -- `udp` 默认为 `false`;设为 `true` 后启用 UoT v2。 -- `dialer-proxy` 使用与其他出站相同的无环代理图。 -- 未列出的字段一律拒绝。TLS 版本、客户端伪造指纹、ECH、mTLS、填充和会话参数不可配置。 - -## TLS 与认证 - -AnyTLS 使用 VCore 的标准 TLS 客户端: - -- 支持 TLS 1.3 和 TLS 1.2; -- 默认使用 WebPKI 根证书、规范化 SNI 和完整证书校验; -- `skip-cert-verify: true` 且没有 pin 时跳过常规证书链、名称及有效期验证; -- `fingerprint` 匹配叶证书时,以该证书作为信任依据,不再要求公共根、名称或有效期匹配;匹配非叶证书时,以它为信任锚,继续验证叶证书链、名称及有效期; -- 配置了 pin 却不匹配时必须失败,`skip-cert-verify` 不能覆盖 pin; -- 所有策略始终验证 TLS 握手签名,跳过常规校验并不等于接受伪造签名; -- ALPN 按配置发送,不强制服务端选中某一协议; -- 不支持 REALITY、客户端伪造指纹、ECH 或客户端证书。 - -校验策略、SNI、ALPN 和恢复缓存属于各节点不可变的 TLS 客户端,不跨节点共享。恢复票据按节点分配固定额度,运行时标准 TLS 总预算仍为 4;TLS 1.3 票据一次性消费,TLS 1.2 会话也占用同一额度。VLESS 的 TLS 1.3 / h2 / WebPKI 要求和 REALITY 的禁用恢复策略不受 AnyTLS 配置影响。 - -连接建立后,客户端依次写入 SHA-256 password hash、认证填充和首个会话批次。认证材料不得进入日志或错误信息。 - -## 版本协商 - -客户端首个 `Settings` 固定声明 `v=2`,并携带当前填充方案的 MD5,不附带产品或客户端版本标识。服务端返回合法的 `ServerSettings v>=2` 时按 v2 工作;未返回时按兼容 v1 的方式继续。 - -首个流与认证批次一同发送。复用空闲会话时: - -- v2 会话写出 `SYN` 和目标后立即返回可用流,并启动最长 3 秒的 `SYNACK` 监视任务; -- 兼容 v1 的会话不等待 `SYNACK`; -- 非空 `SYNACK` 负载表示拒绝,整个物理会话随即失败;已经返回的流通过后续 I/O 暴露错误; -- `SYNACK` 超时、读写器关闭或协议错误都会销毁物理会话; -- 只有在 `open` 返回前发现空闲会话已经失效时,当前请求才允许新建一次物理连接。返回后的失败不会静默重拨。 - -未知流、错误控制流 ID、方向错误的帧、越界负载和非法 `ServerSettings` 都是会话级错误。远端诊断最多保留 256 字节。 - -## TCP 会话复用 - -- 一个物理会话同时只承载一个活动流。 -- 流完整结束后,物理会话才进入空闲列表。 -- 新请求优先复用序号最大的空闲会话。 -- 空闲检查周期和超时均为 30 秒,不预热连接。 -- 空闲清理任务在节点首次使用时按需启动;只构建未使用的节点不创建后台任务,建图或监听绑定失败不会遗留空闲 worker。 -- 活动物理会话不设固定业务数量上限。 -- 流 ID、会话序号耗尽或状态不一致时立即关闭对应会话。 - -写命令通道容量为 2。每个流的接收通道由运行时缓冲区容量推导且至少为 1。帧、填充、队列和诊断字符串都受固定上限约束,这些上限不开放为 YAML 参数。 - -## UDP over TCP - -`udp: true` 时使用 UoT v2 目标: - -```text -sp.v2.udp-over-tcp.arpa:0 -``` - -UoT 固定采用数据报模式。首包携带 UoT 请求和第一份数据报,后续数据报各自编码 IPv4、IPv6 或域名目标,因此同一关联可以访问不同目标。协议帧负载最多 65,535 字节,同时继续受调用方给出的最终响应负载上限约束。 - -响应通道容量为 1。关闭、取消或丢弃 UoT 传输时,读取任务和底层流必须一起结束。 - -## 填充 - -客户端内置固定填充方案,不提供 YAML 配置。每个新物理会话取得一份不可变快照;服务端的合法 `UpdatePaddingScheme` 只影响之后创建的会话,不改变当前帧边界。非法更新只产生有界警告。 - -填充文本、每包条目数、停止值、逻辑填充量和帧负载均有固定上限。认证填充和后续数据填充使用同一份会话快照。 - -## 生命周期 - -每个运行时代理图为 AnyTLS 节点创建一个共享客户端和显式生命周期句柄: - -```text -准备代理图 - -> 启动 - -> 按需打开 TCP/UoT 流 - -> begin_shutdown - -> shutdown().await - -> 释放代理图 -``` - -`begin_shutdown` 是同步第一阶段屏障:禁止新流、清空空闲列表、取消客户端令牌并通知所有会话关闭。`shutdown` 随后等待空闲清理、会话读写器和 UoT 任务全部结束。运行时停止、准备回滚、测速工作器释放和失败清理必须使用同一路径。 - -AnyTLS 状态只属于创建它的运行时或测速工作器,不跨运行时共享。`measureDelay` 返回前必须完成私有代理图的关闭。 - -自动化和外部互操作的实际覆盖范围见 [验收矩阵](acceptance.md)。 diff --git a/docs/config.yaml b/docs/config.yaml index 89c64b9..f0f8e6a 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -1,6 +1,6 @@ -# VCore 配置修订版 14 的完整示例。 +# VCore 配置修订版 27 的完整示例。 # -# `version.configVersion` 返回的 14 是二进制报告值,不是 YAML 字段。配置只接受 +# `version.configVersion` 返回的 27 是二进制报告值,不是 YAML 字段。配置只接受 # 本文列出的结构;未知字段、anchor、alias 和自定义 tag 都会失败。 # # 宿主先通过 Invoke API v5 调用 initialize({dataDir}),再通过 configYaml 内联 @@ -29,10 +29,10 @@ geo-update-interval: 24 # 按首个冒号分隔,用户名 / 密码各 1–255 UTF-8 字节,HTTP/SOCKS5 共用。 # 共享或非空认证要求 port 或 socks-port 启用。 # HTTP 逐请求认证、选路,支持流式正文 / 分块、Keep-Alive、CONNECT 与 Upgrade。 -# 共享只适合可信网络,Basic 不加密链路;详细边界见 docs/http-proxy.md。 +# 共享只适合可信网络,Basic 不加密链路;详细边界见 docs/inbounds.md#http。 port: 1080 # SOCKS5 CONNECT / UDP ASSOCIATE;省略或 0 关闭,非零时 TCP/UDP 同端口。 -# UDP 必须属于有效的 TCP 控制连接,源 IP/端口校验和资源上限见 socks5-proxy.md。 +# UDP 必须属于有效的 TCP 控制连接,源 IP/端口校验和资源上限见 inbounds.md#socks5。 socks-port: 1081 allow-lan: false authentication: @@ -80,30 +80,146 @@ sniffer: # 范围内的 IPv4 字面量或 ::1;域名解析到回环地址会失败关闭。 # # VLESS 子集: -# - type 固定 vless,network 固定 xhttp,tls 必须为 true; -# - encryption 省略或 none,flow 省略或空字符串; -# - servername 可省略(缺省为 server),alpn 省略或严格 [h2]; +# - type 固定 vless,network 默认 tcp,支持 tcp/ws/grpc/http/h2/xhttp,tls 默认 false; +# TCP 支持明文或 TLS 1.2/1.3;XHTTP H1/H2 支持明文、TLS1.3 或 REALITY。 +# - encryption 省略/空/none,或 mlkem768x25519plus.{native,xorpub,random}.{1rtt,0rtt}.…; +# key/padding 链、上限和恢复规则见 vless.md;该层独立于外层 TLS/传输。 +# flow 省略/空/xtls-rprx-vision;Vision 仅 TCP + TLS1.3/REALITY 或 Encryption, +# packet-encoding 必须 xudp(即使 udp=false); +# - servername 可省略(WS Host 去端口后优先于 server);TCP alpn 默认空, +# WS 默认 [http/1.1],gRPC/H2 默认 [h2];XHTTP [http/1.1] 选择 H1, +# 空或 H1/H2 列表选择 H2,仅 [h3] 选择 H3。H3 必须标准 TLS1.3 与受控 UDP, +# 不回退 H2。明文 XHTTP 的 alpn 仅选择版本,其余 TLS 专属字段仍拒绝。 +# 详细语义见 docs/vless.md 和 docs/xhttp.md。 +# - packet-encoding 默认 xudp;none 是显式 raw,packetaddr/packet 为 IP-only +# 地址帧。raw 固定首包目标;packetaddr 域名通过受控 DNS 解析。 # - TLS 省略 reality-opts;REALITY 必须提供 reality-opts.public-key,可选 short-id; -# - xhttp-opts 可包含 host/path/mode/download-settings,mode 只接受 +# support-x25519mlkem768 默认 false;true 强制真实混合协商、不回退经典组。 +# true 只允许 client-fingerprint chrome 或未命名(省略/none/空串);不改写模板。 +# - xhttp-opts 请求字段与值域详见 docs/xhttp.md;mode 只接受 # auto/packet-up/stream-up/stream-one;stream-one 不能与 download-settings 同用; # - download-settings 一旦出现(包括 {}),就为下行 GET 创建独立的 -# server -> TLS/REALITY -> HTTP/2 连接;上行 POST 仍走主连接,两者共用 +# 独立传输/安全连接;上行 POST 仍走主连接,两者共用 # XHTTP session ID,不进行第二次 VLESS 握手。两条请求最终必须由 CDN 或 -# 反向代理汇聚到同一个服务端 XHTTP 处理器和会话存储。只支持 -# server/port/tls/servername/alpn/reality-opts/host/path; -# - download-settings 中缺省的字段继承主连接。reality-opts 缺省时继承 -# 主连接的 TLS/REALITY,即外层 REALITY 下单独写 tls: true 也不会清除 -# REALITY;reality-opts 存在时选择/覆盖 REALITY,tls: false 始终失败。 -# 当前结构没有“清除已继承 REALITY”的标记,因此无法表达“主连接 REALITY、 -# 下行连接标准 TLS”;解析器不会猜测该语义。 +# 反向代理汇聚到同一个服务端 XHTTP 处理器和会话存储。 +# - download-settings 缺省叶字段继承;headers/reuse-settings/reality-opts/jls-opts/ech-opts 整对象 +# 替换,{} 分别清空请求头、重置复用默认、清除 REALITY/JLS/ECH。false/空串不等同缺省; +# certificate/private-key 成对替换或同时空串清除。改变安全类型须显式清除冲突 +# 策略。两个独立 listener 不能组成共享会话。 # auto 为 TLS -> packet-up、REALITY + # download-settings -> stream-up、REALITY 无 download-settings -> stream-one; -# - 不接受 packet-encoding、headers/reuse-settings、fingerprint、client-fingerprint、 -# skip-cert-verify、ECH、mTLS、shadow-tls-opts、restls-opts 或 jls-opts。 +# - XHTTP 关闭语义与 Mihomo 一致:应用上传 EOF / shutdown 结束整条逻辑连接, +# 同时关闭下载腿,不提供 TCP 半关闭后继续读取尾包的保证;packet-up 的待完成 +# 上传最多等待 1 秒。需要完整响应的应用须在关闭前读完响应。 +# - 标准 TLS 支持 skip-cert-verify/fingerprint/name-cert-verify,以及配对的 +# 内联 PEM certificate/private-key;不能混用 REALITY。完整策略见 vless.md。 +# - WS/gRPC/HTTP/H2 复用共享传输。WS 可 v2ray-http-upgrade 及其 fast-open; +# gRPC 可 UA、空闲 PING、max-connections/min-streams/max-streams 池阈值。 +# - smux 支持 h2mux/smux/yamux、padding/only-tcp 与池阈值;与 Vision 互斥。 +# - client-fingerprint 接受 none/chrome/chrome120/firefox/firefox120/safari/safari16; +# chrome=Chrome133,firefox=Firefox120,safari=Safari16.0;节点缺省关闭。 +# 适用于标准 TCP TLS/REALITY,不改变证书 fingerprint;下载腿缺省继承、 +# 显式名称覆盖、none/空串清除,转 H3/明文必须清除指纹。 +# 非空 ALPS 服务端设置暂拒绝;边界见 docs/tls-client-fingerprint.md。 +# - jls-opts.username/password 必须成对非空,各 1–65535 UTF-8 字节,要求 tls: true; +# 与同腿 REALITY/证书策略/mTLS、Vision/H3 互斥。下载对象缺省继承、整体替换、 +# {} 清除,主腿空对象拒绝。保持完整 TLS1.3 认证,无恢复;详见 vless.md#jls。 +# - 静态 ech-opts 只用于标准 TLS,详见下方示意与 docs/vless.md#静态-ech。 +# 不接受动态 query-server-name、shadow-tls-opts 或已移除的 restls-opts。 # # 下方 vless-edge 通过 upstream-select 当前选择的 socks-hop 建链: # 客户端 -> socks-hop -> vless-edge -> 目标。 proxies: + # Hysteria2:固定 QUIC/TLS1.3,外层总是需要 UDP;业务 UDP 默认关闭。 + # 完整字段/预算及开发验收边界见 outbounds.md#hysteria2。标准 TLS 证书策略与其他协议共用。 + - name: hysteria2-edge + type: hysteria2 + server: hysteria2.example.com + port: 443 + password: "synthetic-hysteria2-password" + sni: hysteria2.example.com + alpn: [h3] # 缺省或 [] 都归一为 h3 + skip-cert-verify: false + # fingerprint: "" + # certificate/private-key: 成对内联 PEM 客户端身份 + udp: true + up: 0 # 裸数单位 Mbps;0 自动,也可 "125 KBps",b=bit/B=byte + down: 0 + udp-mtu: 1197 # 单个协议分片预算;0 同默认,范围 64–65535 + # obfs: salamander + # obfs-password: "independent-synthetic-password" + # ports: "443,8443-8445" # 非空集合控制初始及后续入口;出现时 port 可省略 + # hop-interval: "5-7" # 仅与 ports 配套,默认 30 秒,最小 5 秒 + # dialer-proxy: upstream-select + + # VMess AEAD:alterId 仅 0;cipher 默认 auto(实际 AES 硬件检测),还支持 + # aes-128-gcm/chacha20-poly1305/none,zero 归一为 none。 + # global-padding/authenticated-length 默认 false;none/zero 不接受 true。 + # network 默认 tcp,支持 tcp/ws/grpc/http/h2。tls 默认 false;关闭时 + # 禁止 servername/alpn/fingerprint/skip-cert-verify 等 TLS 专属字段。 + # servername 优先显式值,其次 WS Host 去端口,否则 server。 + # ws-opts/grpc-opts 与下面 Trojan 的结构相同;HTTP/H2 选项见示例注释。 + # UDP 默认 false;packet-encoding 默认为空(raw),或 xudp/packetaddr。 + # raw transport 固定首包目标;packetaddr 域名由受控 VCore DNS 解析为 IP。 + # TCP/WS 保留下载侧;gRPC/HTTP/H2 上传 EOF 关闭逻辑流,详见 outbounds.md#vmess-aead。 + - name: vmess-edge + type: vmess + server: vmess.example.com + port: 443 + uuid: 00000000-0000-4000-8000-000000000002 + alterId: 0 + cipher: auto + network: tcp + tls: true + servername: vmess.example.com + udp: true + packet-encoding: xudp + global-padding: false + authenticated-length: false + # network: http + # http-opts: + # method: GET + # path: [/cover, /other] # 按 URL.Path 编码;缺省/空列表为 [/] + # headers: {Host: [cover.example:443], X-Custom: [one, two]} + # 每次握手各选一个路径与每个头的一个值;禁止 Content-Length/Transfer-Encoding。 + # network: h2 + # h2-opts: + # host: [cover.example:443] # 必填、非空;每流选一个,不改变 TLS 认证名 + # path: /cover # 默认 /;普通 PUT body,不使用 gRPC framing + + # Trojan(outbound-trojan feature):固定 TLS,不接受 tls 开关。 + # password 必须非空,按原始 UTF-8 字节认证,不 trim。 + # network 接受 tcp(默认)、ws、grpc。sni 默认 server,不回退 WS Host; + # alpn 默认 tcp=[]、ws=[http/1.1]、grpc=[h2]。显式列表保留顺序,WS/gRPC + # 必须包含适用协议并校验实际协商结果。上传 EOF 保留下载侧,直到对端结束; + # Stop / 取消仍关闭全部会话及自有 driver,不更改 XHTTP 的全关闭约定。 + # skip-cert-verify 默认 false;fingerprint 沿用 AnyTLS 的证书 SHA-256 + # pin 语义,pin 与 skip 同时存在仍必须匹配 pin。udp 默认 false。 + # UDP 在 TLS 流内传输;单包负载最多 8192 字节并与入口预算取较小值, + # 不拆包或截断。域名地址保持域名交由服务端处理;Mihomo v1.19.31 的 + # Trojan listener 不接收域名 UDP 帧,使用该对端时需要 IP 业务目标。 + - name: trojan-edge + type: trojan + server: trojan.example.com + port: 443 + password: example-only-password + network: tcp + sni: trojan.example.com + udp: true + # network: ws + # ws-opts: + # path: /edge?query=1 # 默认 /,必须绝对路径 + # headers: {Host: cover.example:443, X-Custom: value} + # max-early-data: 2048 # 0–2048,默认 0 + # early-data-header-name: Sec-WebSocket-Protocol + # W04>0 时头名缺省为 Sec-WebSocket-Protocol;可显式自定义头,或空字符串 + # 使用路径后缀(此时 path 不可含 query)。禁用 ED 时不接受无效的头名选项。 + # 不接受保留握手头、重复大小写头、CR/LF、userinfo 或超限 HTTP 头。 + # network: grpc + # grpc-opts: + # grpc-service-name: edge # 必填;普通名映射 /edge/Tun;/ 开头使用完整路径 + # ws-opts / grpc-opts 仅可用于对应 network;不提供 gRPC 连接池。 + - name: vless-edge type: vless server: edge.example.com @@ -119,6 +235,18 @@ proxies: host: edge.example.com path: /onev mode: auto + headers: {X-Transport: edge} + # 以下范围均为字符串 N 或 N-M。所有请求字段与合法配套见 docs/xhttp.md。 + x-padding-bytes: "100-1000" + sc-max-each-post-bytes: "1000000" # packet-up,最多16MiB,非预分配大小 + sc-min-posts-interval-ms: "30" # packet-up,0–60000且最大值为正 + reuse-settings: # 省略无额外池;{} 启用默认池,阈值不是全节点数量上限 + max-concurrency: "0" + max-connections: "0" + c-max-reuse-times: "0" + h-max-request-times: "0" # 按领取 transport,不按每 POST + h-max-reusable-secs: "0" + h-keep-alive-period: 0 # H2默认45秒/H3默认10秒;负数禁用;H1仅0 # 完整覆盖示例;改成 download-settings: {} 则所有字段继承主连接。 download-settings: server: download.edge.example.com @@ -127,7 +255,25 @@ proxies: servername: download.edge.example.com alpn: [h2] host: download.edge.example.com - path: /onev-download + path: /onev # 与主腿汇入同一原生 handler + headers: {} # 清除主腿自定义请求头 + reuse-settings: {} # 整体重置,不按叶继承 + skip-cert-verify: false + fingerprint: "" # 清除继承的 pin + client-fingerprint: "" # 清除继承的 ClientHello profile + name-cert-verify: "" # 恢复下载认证名 + # certificate: # 与 private-key 成对;禁止文件路径 + # private-key: + # reality-opts: {} # 清除继承的 REALITY;非空对象必须提供 public-key + # jls-opts: {} # 清除继承的 JLS;非空对象必须提供 username/password + smux: + enabled: false + protocol: h2mux # h2mux/smux/yamux + max-connections: 1 # 0–i32::MAX;正值与正 max-streams 互斥 + min-streams: 0 + max-streams: 0 + padding: false + only-tcp: false # true 保留 packet-encoding;false 使用 sing-mux UDP dialer-proxy: upstream-select # REALITY 写法示意:在 VLESS 节点中保留 tls: true,并增加以下 mapping;不要与 @@ -135,6 +281,15 @@ proxies: # reality-opts: # public-key: BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc # short-id: "0123" + # support-x25519mlkem768: false # 主腿/下载腿均可配置,必须为非 null 布尔值 + + # 标准 TLS 的可选静态 ECH;主腿与 download-settings 使用相同字段。 + # 启用时 config 必填标准 Base64 ECHConfigList,不能只填写 enable。 + # 实际只协商 TLS1.3,servername 必须是 DNS 名;不查询 DNS 或自动明文回落。 + # {} 清除;不能与同腿 REALITY/JLS、Vision 混用,详细契约见 docs/vless.md#静态-ech。 + # ech-opts: + # enable: true + # config: "" # SOCKS5 出站支持 CONNECT 和 UDP ASSOCIATE。username/password 必须同时省略 # 或同时出现;tls 只能省略或为 false。udp 控制 UDP ASSOCIATE。 @@ -150,7 +305,7 @@ proxies: # sni 省略时使用 server。TLS 1.3/1.2 默认 WebPKI,alpn 省略/[] 时不发送。 # skip-cert-verify 不能覆盖 fingerprint;pin 是证书 SHA-256,不是客户端伪造指纹。 # 叶 pin 直接决定信任;非叶 pin 作为根继续验证链和名称。所有策略均验证握手签名。 - # 不支持 REALITY、fp、ECH、mTLS 或 session/padding 调参字段,详见 anytls.md。 + # 不支持 REALITY、fp、ECH、mTLS 或 session/padding 调参字段,详见 outbounds.md#anytls。 # TCP 使用 AnyTLS session;udp: true 使用 UoT v2 datagram mode。 - name: anytls-edge type: anytls @@ -161,6 +316,7 @@ proxies: # alpn: [h2, http/1.1] skip-cert-verify: false # fingerprint: "" + # client-fingerprint: chrome # 可选;与上方证书 pin 独立 udp: true # SS 2022:cipher 精确接受下列三种;AES-128 密钥解码后 16 字节,其余为 32。 @@ -168,7 +324,7 @@ proxies: # 2022-blake3-chacha20-poly1305。 # AES password 可用 iPSK1:...:iPSKn:uPSK 身份链,其他算法仅单一 Base64 PSK。 # 以下仅为公开测试 key,不可用于真实服务。UDP 默认 false。 - # 上游 padding 风险和验收边界见 shadowsocks.md; + # 上游 padding 风险和验收边界见 outbounds.md#shadowsocks-2022; # 完整互通尚未签收。dialer-proxy 可引用具体节点或 select 组。 - name: ss-edge type: ss @@ -196,7 +352,8 @@ proxies: # 当前节点的服务器而不是业务目标,REJECT 在建链时失败且不回退。单次建链对每个组 # 只读一次选择,SOCKS5 UDP 控制流与 relay 路径共用该快照和期限;不同组没有原子 # 快照保证。复用旧 AnyTLS 会话的新业务流仍沿用该会话路径,只有新建底层 transport -# 才读取新选择。业务节点的 udp 开关不阻止上游通过 TCP 承载封装后的 UDP。 +# 才读取新选择。已认证 Hysteria2 会话的新逻辑流和跳端口 socket 同样沿用初始上游 +# 快照,只有新建认证会话读取新选择。业务节点的 udp 开关不阻止上游通过 TCP 承载封装后的 UDP。 proxy-groups: - name: upstream-select type: select diff --git a/docs/controller-api.md b/docs/controller-api.md index 8ce7cb4..ec1f9f3 100644 --- a/docs/controller-api.md +++ b/docs/controller-api.md @@ -129,6 +129,8 @@ Content-Length: 26 `dialer-proxy` 与规则、DNS 共享同一组选择,但经共享连接器直接建链,不重新执行业务规则。上游组的 `DIRECT` 连接当前节点预解析的服务器,`REJECT` 使建链失败。单次建链对同一组只读取一次选择,SOCKS5 UDP 的控制连接和 relay 路径共享该快照;不同组之间不提供原子快照。已经读取旧选择的在途握手继续原路径。复用旧 AnyTLS 会话的新业务流也保留原路径,只有真正新建底层 transport 才读取新选择。 +Hysteria2 已认证 QUIC 会话也保留其 `dialer-proxy` 上游组快照,包括复用该会话的新业务流和端口跳跃所需的新物理 socket;这些不是新的认证会话,不能因切组而迁移原路径。只有重新建立的认证会话才读取新的上游组选择。节点本身作为路由组成员时,后续业务的路由选节点仍按当前组选择执行。 + ## HTTP 状态与协议边界 | 状态 | 含义 | diff --git a/docs/http-proxy.md b/docs/http-proxy.md deleted file mode 100644 index 7d33af1..0000000 --- a/docs/http-proxy.md +++ /dev/null @@ -1,59 +0,0 @@ -# HTTP 代理入站 - -HTTP 入站通过当前 Session 的 Dispatcher 使用规则、节点和静态 `select` 代理组。实现位于 `src/inbound/http/`;公共字段见[配置协议](config.yaml)。 - -## 监听与认证 - -- `port` 省略或为 0 时不启用,1–65535 时启用;整个配置须启用 HTTP、SOCKS5 或 TUN。 -- `allow-lan` 默认为 false,绑定 `127.0.0.1`;开启时绑定 `0.0.0.0`。当全局 `ipv6: true` 时,同时绑定同端口的 `::1` 或 `::`,IPv6 socket 固定为 v6-only。 -- 操作系统明确报告地址族 / 协议不支持时可省略 IPv6 socket;地址占用、权限和其他绑定错误使整个启动失败。`ipv6: false` 不创建 IPv6 listener。 -- `authentication` 省略或为 `[]` 时,本机模式不要求认证;提供时必须恰好一项 `user:password`。按第一个冒号分隔,用户名与密码各为 1–255 UTF-8 字节,密码中的后续冒号保留,不 trim。显式 `null` 不是省略或空列表。 -- `allow-lan: true` 强制要求有效凭据;配置共享或非空凭据时必须有启用的 HTTP 或 SOCKS5 入站,二者共用这一组凭据。HTTP 共享通过 Basic 认证,不提供链路加密,只适合可信网络。 -- 配置了凭据就对每个请求独立认证,不继承同一连接前次请求的授权。缺失、错误、重复或格式不合法的 `Proxy-Authorization` 返回 `407` 并关闭连接,不建立上游。 -- Controller 保留独立的回环地址、Bearer secret 和路由,不随共享开关开放。 - -配置片段(与节点、规则等共同使用): - -```yaml -port: 1080 -allow-lan: false -ipv6: true -authentication: - - "proxy-user:proxy-password" -``` - -## 普通转发 - -支持 HTTP/1.0 和 HTTP/1.1 的绝对形式 `http://host[:port]/path?query`。每个请求分别认证、确定目标、选路并建立上游;上游连接不跨请求复用。目标来自绝对 URI,转发成 origin-form 并重建 Host。 - -客户端连接可依次发送多个请求,也可顺序预发送。固定读取缓冲区保存下一请求的预读字节,正文严格按照当前消息边界消费。只有请求允许 Keep-Alive、上传完成、响应有明确边界时才继续下一请求;不会把旧目标连接用作下一请求的上游。 - -正文按流处理: - -- `Content-Length` 精确转发对应字节数,未带正文长度的普通请求视为空正文。 -- `Transfer-Encoding: chunked` 逐块解码边界并重编码,支持合法扩展语法和 trailer;扩展不向下一解析器透传,合法 trailer 保留。 -- HEAD、1xx、204、304 按无正文处理;1xx / 204 携带正文定界字段时失败关闭,HEAD / 304 可保留描述所选表示的长度。 -- 转发 HTTP/1.1 的 `100 Continue` 等临时响应。上传和读取响应并行,目标提前给出最终响应时终止上传、转发响应并关闭客户端连接,不把剩余正文作为新请求。 -- 普通响应无长度字段时由上游 EOF 定界,响应结束后关闭客户端连接。向 HTTP/1.0 客户端交付分块响应时去块并以关闭连接定界。 - -歧义长度、重复 Content-Length、CL/TE 并存、非法分块、正文截断、非法控制字符和超限解析均失败关闭,不尝试恢复到下一请求。内部诊断字段、代理认证字段、Connection 指名字段及其他逐跳字段不会泄漏给目标;正文 trailer 不能携带认证、路由、消息定界或内部诊断字段。 - -消息定界与连接管理依据 [RFC 9112 §6.3](https://www.rfc-editor.org/rfc/rfc9112.html#section-6.3),逐跳字段依据 [RFC 9110 §7.6.1](https://www.rfc-editor.org/rfc/rfc9110.html#section-7.6.1)。VCore 对歧义输入采用严格拒绝策略。 - -## CONNECT 与 Upgrade - -CONNECT 只在 Dispatcher 成功建立上游后返回 `200 Connection Established`。请求头后已经读到的数据保留,随后与新数据一起进入双向转发。建链失败返回对应错误,不提前确认隧道。 - -HTTP/1.1 Upgrade 只接受无正文的请求。收到合法 `101`、Connection / Upgrade 字段一致且目标选择的协议在请求列表中时,才进入双向隧道。双方预读字节及 TCP 半关闭保留;非 `101` 响应按普通 HTTP 消息处理,非法切换返回 `502`。 - -内部测速诊断只在**认证通过、监听地址为回环、客户端为回环、CONNECT 显式请求诊断**时返回。无认证的本机入口和共享入口均不返回内部错误详情,即使连接来自本机。 - -## 生命周期与局部资源边界 - -- 先取得 Controller 和全部业务地址族的监听 socket,再启动接收任务;任何一步失败释放已绑定 socket。业务连接按需创建,不设置全局连接数量准入上限。 -- 单个请求 / 响应头最多 32 KiB、100 个字段,读头总期限 10 秒;出站连接期限 10 秒。 -- 每个方向的预读 / 正文复制缓冲区为 8 KiB,正文长度不决定分配大小。chunk-size 行最多 1 KiB,trailer 合计最多 8 KiB / 100 个字段;每个最终响应前最多 16 个临时响应。 -- 正文每次读取 / 写入的空闲期限为 30 秒。上传期间并行等候响应,响应开始后使用独立读头总期限;上传结束后等待响应也有 10 秒期限。 -- Stop 覆盖读头、认证响应、建链、正文、CONNECT 和 Upgrade,取消后等待全部连接任务退出;端口在 Stop 返回后可重新绑定。 - -自动化与 mihomo 双向进程互通的入口见[验收矩阵](acceptance.md)与[脚本说明](../scripts/README.md)。回环双栈测试不等于真实局域网或物理 IPv6 验收。 diff --git a/docs/inbounds.md b/docs/inbounds.md new file mode 100644 index 0000000..8c31285 --- /dev/null +++ b/docs/inbounds.md @@ -0,0 +1,87 @@ +# HTTP 与 SOCKS5 入站 + +两个客户端入口和 TUN 共用 Dispatcher、规则、DNS、静态 select 组及出站图。 +字段全集见[配置协议](config.yaml),Controller 另见[接口](controller-api.md)。 + +## 监听与认证 + +- `port` 控制 HTTP;`socks-port` 控制同端口 SOCKS5 TCP/UDP。省略或 0 关闭, + 启用时为 1–65535;HTTP、SOCKS5、TUN 至少启用一个。 +- `allow-lan: false` 绑定 IPv4 回环,true 绑定通配地址。`ipv6: true` 增加独立 + IPv6-only 回环/通配 socket。仅系统明确不支持地址族时可省略 IPv6,其余绑定错误 + 整体回滚。取得 Controller 和全部业务 socket 后才启动接收任务。 +- `authentication` 省略或空列表时仅本机免认证;非空必须恰好一项 `user:password`, + 按第一个冒号拆分,两项各 1–255 UTF-8 字节,不 trim。null 不视为省略。 + 共享强制认证;共享或非空凭据须有启用的 HTTP/SOCKS5 入口消费。 +- Basic / SOCKS 用户密码没有链路加密,只适合可信网络。Controller 仍独立回环监听, + 使用自己的 Bearer 认证,不随共享开关开放。 +- 不设置全局业务连接/关联准入数。Stop 取消并等待全部连接、relay 和接收任务, + 返回后端口可重新绑定。纯 SOCKS5 的独立 feature 不要求启用业务 HTTP。 + +## HTTP + +每个请求独立认证,缺失、错误、重复或非法 Proxy-Authorization 返回 407 并关闭, +不建立上游;前一个请求的授权不沿用到下一请求。 + +### 普通转发 + +HTTP/1.0 和 HTTP/1.1 使用绝对 `http://host[:port]/path?query` URI。每个请求重新 +确定目标、选路和建链,转发为 origin-form 并重建 Host,上游不跨请求复用。 +客户端可以 Keep-Alive 或顺序预发送;固定缓冲保存预读,只有上传完成且响应有明确 +边界才处理下一请求。不会把旧目标连接用于下一请求。 + +- Content-Length 精确转发;没有正文长度的普通请求视为空正文。 +- chunked 按块解码并重编码;合法扩展不透传,合法 trailer 保留。 +- HEAD/1xx/204/304 无正文;1xx/204 携带正文定界字段失败关闭,HEAD/304 可保留表示长度。 +- 转发 100 Continue 等临时响应;上传和响应读取并行。提前最终响应会终止上传、 + 转发响应并关闭客户端,不把余下正文误作新请求。 +- 无长度响应由 EOF 定界并关闭;HTTP/1.0 客户端接收 chunked 响应时去块后关闭。 +- 重复 Content-Length、CL/TE 并存、歧义长度、非法控制字符/分块、截断或超限均失败关闭。 + 认证、内部诊断、Connection 指名及逐跳字段不泄漏给目标;trailer 不能携带认证、 + 路由、定界或内部字段。 + +定界遵循 RFC 9112 §6.3,逐跳字段遵循 RFC 9110 §7.6.1;歧义输入严格拒绝。 + +### 隧道 + +CONNECT 在上游成功后才返回 200,保留头后的预读数据。Upgrade 只接受无正文请求; +合法 101、Connection/Upgrade 一致且选中请求协议时才切隧道,保留双方预读和 TCP +半关闭。非 101 按普通响应处理,非法切换返回 502。 + +内部测速诊断只在认证通过、监听和客户端均为回环且 CONNECT 显式请求时返回。 +无认证本机入口和共享入口不返回内部详情。 + +### 局部上限 + +头 32 KiB/100 字段、读头/建链 10 秒;每方向预读/复制 8 KiB。chunk 行 1 KiB, +trailer 8 KiB/100 字段,每个最终响应前最多 16 个临时响应。正文逐次读写空闲期限 +30 秒;响应开始后独立计算读头总期限,上传结束后等待响应最多 10 秒。正文长度不 +决定分配大小,Stop 覆盖认证、建链、正文及隧道。 + +## SOCKS5 + +仅支持 RFC 1928/1929 的 SOCKS5 CONNECT、UDP ASSOCIATE 和用户名密码协商。 +不降级绕过已配置认证。精确读取握手并保留 CONNECT 后业务字节;上游成功才回复成功。 +目标保留 IPv4、IPv6 或域名交给 Dispatcher,入站不提前解析。TCP 每方向复制 4 KiB, +保留半关闭。 + +### UDP 授权 + +- 一个有效 TCP 控制连接拥有一个关联。未授权 UDP 不创建状态或上游。 +- 请求源 IP 必须为未指定地址或 TCP peer IP;回复使用 TCP 连接实际本地地址和配置端口, + 不返回通配 relay 地址。 +- 授权键包含 TCP peer IP、从 TCP peer 取得的 IPv6 scope 和 UDP 源端口。 + 端口 0 在首个合法且成功入队的数据报学习;同 IP/scope 最多一个待学习关联。 + 明确端口可并存但不能重复授权,不同 scope 的链路本地地址互不串包。 +- 一关联可访问多目标,分别选路;回包保留出站提供的实际远端地址。 +- 非零保留位、分片、非法地址、零目标端口和超长包丢弃,不学习端口或续期。 +- TCP EOF/错误/Stop/过期撤销授权;旧任务不能删除新代次关联或发送旧回包。 + 这比 Mihomo 任意 UDP 接收创建状态的策略严格。 + +协商 10 秒;UDP 建链/单次发送/回包和 TCP 建链 15 秒。每关联队列 16 包,满时丢当前包; +全局接收不等待慢建链。空闲 30 秒、每 10 秒检查,仅成功入队/发送的合法流量续期。 +wire 最大 65,507 字节,额外 sentinel 识别截断,自有 UDP socket 请求 65,508 字节 +发送缓冲;回包负载保守扣最大 262 字节头为 65,245 字节,嵌套出站继续扣预算。 +TUN 使用独立 MTU;SOCKS5 数据不计入 Controller 的 TUN 流量。 + +[验收边界](acceptance.md)区分容器双栈与真实 LAN/物理 IPv6,不能相互替代。 diff --git a/docs/invoke-api.md b/docs/invoke-api.md index 4afacce..a4e5295 100644 --- a/docs/invoke-api.md +++ b/docs/invoke-api.md @@ -1,6 +1,6 @@ # VCore Invoke API -业务接口版本为 5,配置结构修订版为 14。配置只通过内联的 `configYaml` 或 `configYamls` 传入;每份已加载的 VCore 运行时最多拥有一个公共实例。代理组实时选择沿用 Controller,不增加 Invoke method 或版本协商。 +业务接口版本为 5,配置结构修订版为 27。配置只通过内联的 `configYaml` 或 `configYamls` 传入;每份已加载的 VCore 运行时最多拥有一个公共实例。代理组实时选择沿用 Controller,不增加 Invoke method 或版本协商。静态 ECH 只使用节点内联配置,不新增 bootstrap DNS 入参。 ## C ABI @@ -108,8 +108,8 @@ stopped -> preparing -> prepared -> starting -> running ```json { "apiVersion": 5, - "buildIdentity": "VCore;engine=rust;coreVersion=0.1.0;invokeApiVersion=5;configVersion=14", - "configVersion": 14, + "buildIdentity": "VCore;engine=rust;coreVersion=0.1.0;invokeApiVersion=5;configVersion=27", + "configVersion": 27, "engine": "rust", "version": "0.1.0" } @@ -316,7 +316,7 @@ ProtectFd(fd) -> bool 配置 `external-controller` 后,运行时可提供回环 `GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}`。代理组 Controller 可以在非 TUN 的 HTTP 或 SOCKS5 配置中运行;此时 `/traffic` 不存在。只要 Controller 管理代理组,`secret` 就必填并保护全部路由。 -组成员列表是配置期固定的,选择只存于当前 Running Session。成功切换只影响之后新建的物理 TCP、UDP 和 DNS transport,不迁移既有连接、UDP association、DNS 状态或 TCP pool,也不触发 failover。Controller 查询不携带 `instanceId`,不进入 Invoke 命令锁;完整语义见 [Controller API](controller-api.md)。 +组成员列表是配置期固定的,选择只存于当前 Running Session。成功切换只影响之后新建的物理 TCP、UDP 和 DNS transport,不迁移既有连接、UDP association、DNS 状态或 TCP pool,也不触发 failover。已认证 Hysteria2 会话的后续逻辑流和跳端口 socket 保留初始上游选择;新的认证会话才读取新选择。Controller 查询不携带 `instanceId`,不进入 Invoke 命令锁;完整语义见 [Controller API](controller-api.md)。 ## Windows 安装包桥接 diff --git a/docs/outbounds.md b/docs/outbounds.md new file mode 100644 index 0000000..c8dd581 --- /dev/null +++ b/docs/outbounds.md @@ -0,0 +1,191 @@ +# 客户端出站 + +字段、类型与示例由 [config.yaml](config.yaml) 维护;这里只定义行为。 +VLESS/Vision/Encryption/JLS/ECH 见 [VLESS](vless.md),XHTTP/sing-mux 见[专用契约](xhttp.md)。 + +## 公共规则 + +- 物理 TCP/UDP 只能由共享 Dialer 创建,保留 Android protect、Windows 物理绑定和受控解析。 + 传输包装器只消费 IO,不另建 socket 或系统 resolver。 +- `dialer-proxy` 可引用节点或静态 select 组;与业务路由共享选择,完整 DAG 在配置期验证。 + 一次建链使用组快照和同一绝对期限;已有连接不随切组迁移,无自动 failover 或 DIRECT 回落。 +- gRPC/legacy H2 首次读取延迟响应头时先检查原建链期限,即使响应已就绪也不能绕过 + 超时;响应头已确认后的业务读取不再受该期限限制。 +- HTTP、SOCKS5、TUN、DNS 选路及 node-only `measureDelay` 使用同一连接器。 + UDP 默认为 false;收发预算分别逐层扣封装开销,超限不截断交付。 +- TLS 使用共享 WebPKI、叶证书 pin/非叶信任锚与 skip 策略。pin 不匹配不能被 skip 绕过, + TLS 握手签名始终验证。节点策略与恢复缓存不可变、互相隔离,标准 TLS 总票据预算为 4。 + 字段及模板详见 [TLS 证书与指纹](tls-client-fingerprint.md)。 +- 未知字段、null、错型、非法枚举或不适用的传输选项在网络 IO 前拒绝。 + 凭据、UUID、业务目标和完整请求不进入 Debug/错误;资源与 Stop 见[公共策略](runtime-resource-policy.md)。 + +## SOCKS5 + +仅 SOCKS5 CONNECT / UDP ASSOCIATE;用户名密码成对配置,不支持 SOCKS4。 +TCP 保留域名交由上游解析;UDP 由控制连接拥有,校验 relay 来源并保留实际回包目标。 +上游为 select 组时,控制连接和后续 UDP 数据报使用同次建链快照。 +SOCKS5 不提供 TLS、证书 pin 或 ClientHello 模板;它的预算独立于 TUN MTU。 + +## AnyTLS + +支持 v2、兼容 v1 的会话、内置填充与 UoT v2。password 为 1–1024 UTF-8 字节; +SNI 默认 server;ALPN 省略/空列表不发送,非空保持顺序,不强制选中某协议。 +不开放 TLS 版本、ECH、mTLS、REALITY、填充和会话参数;命名指纹沿用共享 TLS。 + +连接先发送 SHA-256 密码摘要、认证填充和首个会话批次。Settings 固定声明 v=2 和 +填充方案 MD5,不附产品/版本标识;合法 ServerSettings v>=2 启用 v2,否则兼容 v1。 +首个流与认证批次一起发送。复用空闲会话时,v2 写 SYN 和目标后即返回流,再监视 +最长 3 秒 SYNACK;v1 不等待。非空 SYNACK、超时、协议/读写器错误销毁整个会话, +已返回流在后续 IO 报错。仅 open 返回前发现空闲会话失效时可新建一次;不重放业务。 + +未知流、控制 ID/方向错误、越界负载或非法设置是会话级错误;Alert/非空 SYNACK +仅返回固定诊断,不向日志或调用方回显远端内容。 +一个物理会话同时仅一个活动流,完整结束后入空闲列表;优先复用序号最大者。 +检查/空闲超时均 30 秒,不预热;首次使用才启动清理 worker。ID/序号耗尽关闭会话。 + +写命令队列 2 项,流接收容量按运行时预算推导且至少 1;活动会话不设全局数量上限。 +UoT v2 使用 `sp.v2.udp-over-tcp.arpa:0` 数据报模式,首包含请求,之后每包携带 +IP/域名目标,协议负载最多 65,535 字节并取调用方预算交集;响应队列 1 项。 +关闭/取消/丢弃 UoT 同时结束读取任务和底层流。 + +填充为内置方案;新物理会话持有不可变快照,合法 UpdatePaddingScheme 仅影响后续 +会话,非法更新只产生有界警告。文本、条目、逻辑填充量和帧负载都有界。 +begin_shutdown 同步禁止新流、清空空闲并取消所有会话;shutdown 等待清理 worker、 +读写器和 UoT 任务。准备回滚、运行时 Stop 和测速返回前均完成此屏障,不跨运行时共享。 + +## Trojan + +固定 TLS,network 为 tcp/标准 ws/grpc,默认 tcp。密码为非空原始 UTF-8,不 trim, +认证为 SHA-224 小写十六进制。无认证 ACK:本地建链成功不代表密码通过,负例须检查 +原站零业务字节。 + +SNI 默认 server;WS Host 只覆盖 HTTP authority,不作为 SNI 回退。ALPN 默认 TCP 空、 +WS http/1.1、gRPC h2,显式列表及实际协商均须满足传输,不能静默降级。 + +WS path 默认 /,可带 query;头字段不得覆盖握手保留头或大小写重复。early-data +0–2048 字节,启用时默认 Sec-WebSocket-Protocol,可自定义;空头名表示路径后缀, +此时禁止 query。禁用时不能附 ED 头名。首请求头与剩余数据只按序发送一次。 +gRPC service-name 必填,普通名映射 /name/Tun,以 / 开头则为完整路径;不开放池参数。 + +TCP 保留半关闭;gRPC duplex 发送 END_STREAM,不用 RST_STREAM 提前结束读侧。 +UDP 使用同一已认证流上的地址/长度/CRLF 帧,无额外 socket/读取泵/池。 +payload 最多 8192 字节,待解析最多 8455 字节,并与调用方收发预算分别取交集。 +非法/截断帧关闭,合法超接收预算整包丢弃;取消读取保留进度,部分发送取消使关联失效。 +运行时先取消并 join 入站所有者,再等待节点 gRPC 驱动。 + +## VMess AEAD + +标准 UUID,alterId 仅省略/0,请求和响应头均 AEAD,无旧认证回退。cipher 为 auto、 +aes-128-gcm、chacha20-poly1305、none;zero 归一 none。auto 按实际 AES 硬件能力选择。 +none 只关闭 body 加密,仍验证响应身份;不能启用 global-padding/authenticated-length, +两开关默认 false。 + +network 默认 tcp,另支持 ws/grpc/http/h2;均可明文或标准 TLS。tls 默认 false, +关闭时禁止 TLS 字段,即便空串、空列表或 false。认证名依次为 servername、WS Host +去端口、server;HTTP 伪装 Host/H2 authority 不替代认证名。WS 需 http/1.1,gRPC/H2 需 h2。 + +- WS ED/路径/头约束同 Trojan。 +- gRPC service-name 必填,普通名/完整路径规则同 Trojan,无额外池调度器。 +- http 是 TCP 首包伪装:method 默认 GET,path 列表省略/空为 /,按 URL.Path 转义; + 头为字符串列表,每次握手独立选择路径/头值,Host 默认物理 server authority。 + 仅首包封装一次,之后恢复原流;禁止 Content-Length/Transfer-Encoding。 +- h2 需要非空 authority 列表,path 默认 /,每流选一个 authority,通过 HTTP/2 PUT body + 双向传输;明文 prior knowledge、TLS h2,无 gRPC 帧。 + +关闭对齐 Mihomo:TCP/WS 可保留上传 EOF 后的读取;gRPC/HTTP/H2 关闭整条逻辑流, +完整响应应在关闭前读取,不统一要求半关闭尾包。 + +UDP packet-encoding 默认空串(raw),外层仍 TCP: + +| 编码 | 目标与预算 | +| --- | --- | +| raw | 首目标写入认证头;同关联换目标失败,等待首包不重置组快照/建链期限 | +| xudp | 每帧携带目标,零 global ID 不发可选扩展;内部 mux 标记不解析 DNS | +| packetaddr | 每包仅 IP+端口,业务域名用运行时受控 DNS;没有 resolver 时不回退系统 DNS | + +UDP body 最多 15,000 字节;packetaddr 另扣 IPv4 7 / IPv6 19 字节,未解析域名按 19 +预检;超限一字节即写前失败,不拆业务包。对端可能更小,V2Ray 部分返回路径总缓冲 +仅 2048 字节,不能当作 VCore 上限。body wire 解析最多 16 KiB,响应头/XUDP 元数据有界。 +TCP 写块最多 4 KiB 以适配 Mihomo 拷贝边界;UDP 不按此切块。16 位帧计数耗尽前关闭, +不重复 nonce。半帧发送取消关闭,接收取消保留进度;认证/地址/标签错误使关联失效。 +gRPC/H2 driver 由节点跟踪并同步退出,无全局会话额度或历史表。 + +## Hysteria2 + +始终 QUIC/TLS 1.3,使用官方 rustls;不接受 tls/network/client-fingerprint/REALITY。 +port 为 1–65535,仅提供 ports 时可省略;显式非法 port 仍拒绝。password 默认空、 +保留原值,须为最长 8192 字节的合法 HTTP 头值。SNI 默认 server;ALPN 省略/空为 h3。 +支持共享证书 pin/skip 和成对内联 PEM mTLS;udp 只控制业务 UDP,不关闭 QUIC 载体。 + +- up/down 独立非负整数或十进制整数字符串,裸值 Mbit/s;单位 [KMGT]?[bB]ps 使用 + 十进制倍率,bit 向下取整为 byte/s。省略/归一零为自动;拒绝小数、符号、溢出。 +- udp-mtu 缺省/0 为 1197,其他值 64–65535,限制编码后单分片并取实际 DATAGRAM + 预算交集,不是 TUN MTU。 +- obfs 只允许 salamander,须配非空独立 obfs-password。 +- ports 是归一去重的非空端口/闭区间集合,优先于合法 port。hop-interval 仅随 ports: + 整数秒 N、字符串 N 或 N-M,默认 30,5 ≤ N ≤ M ≤ u32::MAX;过短严格拒绝。 + +每节点一个当前认证 QUIC 会话,逻辑流/关联复用且互不取消。HTTP/3 POST hysteria/auth +在业务前要求 233;TCP 类型 0x401,响应可延迟到首次读取,允许先写业务。 +认证/上游/请求共用绝对 10 秒预算。旧会话失败后新流可新建会话,已有业务不重放。 +写 EOF 关闭当前逻辑 TCP 双向通道,与 Mihomo 一致,不影响兄弟流。 + +每流接收窗口 256 KiB、连接收发各 1 MiB、DATAGRAM 收发各 256 KiB;最多 8 条单向 +控制流,拒绝主动双向流。PMTUD 关闭,最大 QUIC payload 1400 并取上游预算交集; +空闲 30 秒/保活 10 秒。认证头 16 KiB,TCP 响应消息 2048/padding 4096 字节。 +业务 UDP 原生 DATAGRAM,payload 最多 4096,最多 255 分片;未知关联不创建状态。 +重组按关联/包/地址隔离,最多 64 个待完成包/256 KiB、TTL 5 秒,关联交付队列 32 包。 +重复未完成分片忽略;完成后立即释放 16 位 Packet ID,允许后续重用,**不做业务去重**。 + +down 写入 Hysteria-CC-RX。up=0 或服务端 auto 使用 BBR,否则取 up 与服务端正上限 +较小值(服务端 0 无限),使用固定带宽控制与实际 wire-byte pacer。Mihomo down=0 +可能返回 RxAuto,优先自动模式。丢包补偿五秒窗口、最小采样量、ACK 比例下限 0.8。 +Salamander 采用公开 salt + XOR/BLAKE2b-256,每包加 8 字节,先扣 QUIC 预算但计入 +pacer;这是混淆不是认证,错误密钥由 QUIC 拒绝,短包有界丢弃。 + +端口集合须映射同一服务端 QUIC 状态;初始/后续随机选端口。跳跃创建新受控 socket, +保持认证时组快照,独立期限不延长初始认证、不重认证。发送只走新路径;一秒内公平 +接收新旧路径,最多两条,之后回收旧路径。Stop 同步 join 路径/重组/HTTP3/QUIC, +关闭交换最多一秒。 + +官方 Hysteria 已验证版本的 4096 字节回包缓冲包含头,更大回包可能在分片前丢弃。 +原生测试记录实际版本和限制,VCore 完整 4096 字节另对 Mihomo 验证,不修改第三方。 + +## Shadowsocks 2022 + +原样复用官方 shadowsocks-rust,仅三算法: + +| cipher | 密钥 | +| --- | --- | +| 2022-blake3-aes-128-gcm | 16 字节 Base64 PSK,或有序 iPSK1:…:iPSKn:uPSK | +| 2022-blake3-aes-256-gcm | 32 字节,同上 | +| 2022-blake3-chacha20-poly1305 | 单一 32 字节 Base64 PSK | + +标准 Base64 可省末尾 padding;空段/非法编码/长度错误拒绝,凭据不 trim。 +官方库只包装已有 TCP 流,UDP codec 单包有界交接,不调用其 socket 工厂。 + +- TCP 每次最多交付 16 KiB;读或关闭先于首次写时,完成并刷新官方空首写以支持 + server-first/半关闭。Pending 继续同次握手,已有首写不重复目标头。 +- 背压适配最多暂存一个 16 KiB 原文块并立即报告接受;后续保持同缓冲完成写入, + 避免官方重试长度误计。flush/关闭排空,读取也推进待写但不被写背压阻塞。 +- UDP 取消前预留 packet ID,底层实际发送后才成功;Pending 不重编码,无后台发送队列。 + 接收验证来源、认证、关联与完整负载,外层 wire 最大 65,507。 +- 每关联随机 client session ID、递增 packet ID,最多两个 server session 的 8128 包 + 重放窗口;旧 session 一分钟仍有有效包时不替换,ID 将溢出时换 client ID 并清空窗口。 + close 清空并关闭底层,不新增后台任务。 + +依赖仅 crates.io 版本,不使用 Git/path/源码补丁。AWS-LC 仅允许沿 shadowsocks → +shadowsocks-crypto 链使用,TLS/REALITY 不消费它,详见[TLS 依赖](tls-dependencies.md)。 +启用 SS 时通过 log 的 max_level_off/release_max_level_off 关闭整个依赖图该 facade, +防止上游日志泄密;VCore tracing 不受影响。上游 Debug 不暴露。 +重放窗口派生代码的来源/完整许可保留在源码头,发布仍审计实际链接图。 + +**已知风险:**原样上游空 TCP 首写/空 UDP padding 路径扩展长度而未显式初始化, +存在把残留缓冲传给可解密对端的风险;普通 echo 或自有背压修复不代表此风险已解决。 +官方空 TCP 首写还可能随机生成零 padding,被其严格服务端拒绝;VCore 不修改或重试 +该上游行为。内存回归分别验证适配器刷新与确定性的零 padding 拒绝,不将 codec +级回复算作 server-first 互通通过。 +首次固定头不够单次读取时,上游按探测防护拒绝,不改成 read_exact。 +Mihomo 仅提供单 PSK;原样 ssserver 验证 AES 单层 EIH 多用户 TCP/UDP 终结及错误身份。 +MTU1500 大包夹具显式启用官方双向 IP 分片。自有 1/2 层身份中继不是原生任意多层证明。 +所有结果与平台/发布签收分开,见[验收边界](acceptance.md)。 diff --git a/docs/reality-wire-protocol.md b/docs/reality-wire-protocol.md index 3775961..21b729a 100644 --- a/docs/reality-wire-protocol.md +++ b/docs/reality-wire-protocol.md @@ -1,34 +1,47 @@ # REALITY V1 客户端协议 -本文定义 VCore 自有 rustls fork 当前实现的 classic REALITY V1 客户端线上行为。它不是通用 REALITY 规范,也不承诺浏览器 ClientHello 模拟。 +本文定义 VCore 的 REALITY V1 客户端线上行为。它不是通用 REALITY 规范。经典模式为默认,配置修订版 22 可显式要求混合密钥交换。可选的四套 ClientHello 模板及混合兼容边界见 [TLS 指纹](tls-client-fingerprint.md)。 ## 版本边界 -当前实现基于 rustls 0.23 系列。线上行为由本文和 fork 内的确定性测试向量共同约束。升级 rustls、加密提供方或任一握手字节时,必须重新验证普通 TLS、REALITY、并发、取消和目标平台构建。 +当前 REALITY 实现基于自有 boring 5.2.0 fork 的原生 BoringSSL 扩展。线上行为由本文和 fork 内的确定性测试向量共同约束。升级 TLS 后端、加密提供方或握手字节时,必须重新验证普通 TLS、REALITY、并发、取消和目标平台构建。 V1 只支持: - 客户端模式; - TLS 1.3; -- X25519; +- 经典 X25519,或显式要求 X25519MLKEM768 的 TLS 密钥交换; - classic Ed25519 临时证书认证; - 0–8 字节 short ID,在线上右侧补零到 8 字节; -- `ring` 加密提供方。 +- BoringSSL 原生握手和加密实现。 -不支持 REALITY 服务端、TLS 1.2、QUIC 传输、ECH、HelloRetryRequest、会话恢复、0-RTT、混合后量子密钥交换、浏览器指纹或 `fp`。收到 HRR 或 REALITY 认证失败时立即终止,不降级为普通 WebPKI。 +不支持 REALITY 服务端、TLS 1.2 协商、QUIC 传输、实际 ECH、HelloRetryRequest、会话恢复、0-RTT 或 `fp`。ECH GREASE 与实际 ECH 不同。收到 HRR 或 REALITY 认证失败时立即终止,不降级为普通 WebPKI。 ## ClientHello 认证 -每个 `ClientConnection` 独占一份 X25519 临时私钥,同时用于: +每次 REALITY 认证绑定本次原生 SSL 握手中的一份真实 X25519 临时私钥,同时用于: -1. TLS 1.3 ClientHello 的 X25519 key share; +1. TLS 1.3 ClientHello 的实际 X25519 share,或混合 share 中的 X25519 分量; 2. 与配置中的服务端静态 X25519 公钥执行 ECDH。 VCore 不能读取该私钥,也不能为两个用途生成不同密钥。 +原生封装按实际 GroupID 找到唯一 X25519 share,不依赖 share 下标。Firefox 可保留 +额外 P-256 share,但认证始终绑定同一 X25519 临时私钥。Chrome133 的普通 TLS +ML-KEM group/share 在 classic REALITY 中裁剪。显式混合模式保留模板中的真实混合 share; +无命名指纹时只提供混合 share。若同时存在独立 X25519,优先用它派生认证,否则使用 +实际混合 share 内的 X25519 分量,与 Mihomo 的认证取值一致。 +重复/缺失认证 share、低阶点均失败。显式混合模式还在 ServerHello 处理时要求 +实际选择 group 4588,经典组或 HRR 立即失败,不重新发起经典握手。REALITY 身份 +认证仍是经典 X25519/Ed25519,不能将混合 TLS 密钥交换称为后量子身份认证。 + ClientHello 的 legacy session ID 固定为 32 字节。生成密文前先把该字段清零,再编码完整 TLS Handshake `ClientHello`,将其作为 AES-GCM 的 AAD。 -`signature_algorithms` 必须公布当前加密提供方完整支持的 ECDSA、RSA 和 Ed25519 算法。REALITY 使用 Ed25519 临时证书不意味着 ClientHello 只能声明 Ed25519;完整列表用于让采用 ECDSA/RSA 证书的伪装站点正常进入握手,不会放宽后续 REALITY 身份校验。 +`signature_algorithms` 保留所选模板的列表;REALITY 不强行加入 Ed25519。原生实现仅在临时证书先通过 REALITY HMAC 认证后,允许它的 Ed25519 CertificateVerify,并仍验证签名。该局部例外不放宽普通 TLS 的签名算法检查。 + +命名指纹保留 TLS1.2 的版本/cipher/扩展声明(Safari 的 TLS1.0/1.1 仍裁剪), +而非先用 TLS1.3-only 的通用编码过滤模板。原生 REALITY 在 ServerHello 阶段独立拒绝 +TLS1.2,且仍禁止恢复、0-RTT 和普通证书兜底;声明并不代表允许降级协商。 session ID 明文前 16 字节为: @@ -72,13 +85,13 @@ session_id = AES-256-GCM-Seal(auth_key, nonce, ## 状态与资源 -共享 `Arc` 只保存不可变的服务端公钥、short ID 和客户端版本。临时私钥、ECDH 结果、`auth_key` 和已认证公钥只存在于单个连接中,不使用全局表、跨连接锁或共享认证槽位。 +共享连接器只保存不可变的服务端公钥、short ID、客户端版本和模板策略。临时私钥、ECDH 结果、`auth_key` 和已认证公钥只存在于单个连接中,不使用全局表、跨连接锁或共享认证槽位。 连接取消、失败或释放时清零临时私钥、共享密钥和 `auth_key`。证书解析深度和输入大小固定,不依据对端长度创建无界容器。fork 不创建线程、异步任务、连接池或队列。 ## 固定向量 -rustls fork 的测试固定: +boring fork 的测试固定: - RFC 7748 X25519 私钥、公钥和共享密钥; - 清零 session ID 的 ClientHello AAD、HKDF 结果和 32 字节 REALITY session ID; @@ -86,4 +99,4 @@ rustls fork 的测试固定: - 截断或非规范 DER、错误 HMAC、错误签名、缺失状态、低阶公钥和 HRR 负例; - 多配置并发和连接状态隔离。 -VCore 的互操作测试还覆盖 XHTTP 模式、取消重连、错误 key/short ID 和普通 TLS 回归。实际执行范围见 [验收矩阵](acceptance.md),依赖发布要求见 [rustls REALITY 依赖](rustls-reality-release.md)。 +VCore 的互操作测试还覆盖 XHTTP 模式、取消重连、错误 key/short ID 和普通 TLS 回归。实际执行范围见 [验收矩阵](acceptance.md),依赖发布要求见 [TLS 依赖](tls-dependencies.md)。 diff --git a/docs/runtime-resource-policy.md b/docs/runtime-resource-policy.md index bb16ad2..1c39586 100644 --- a/docs/runtime-resource-policy.md +++ b/docs/runtime-resource-policy.md @@ -37,19 +37,26 @@ Windows L3 接口及其 Session Host netstack 使用 1400 MTU,因此按 IPv6 U ## TCP 与握手 - TCP 流按需创建任务,每方向缓冲区固定为 32 KiB。 -- TLS/XHTTP 内部缓冲区固定为 64 KiB。 +- rustls/XHTTP 的配置缓冲区为 64 KiB;boring 每次应用写入最多 min(节点缓冲预算, 16 KiB),原生 TLS 记录有界,命名模板证书消息/解压输出另限 128 KiB。 - 标准 TLS 恢复会话总预算为 4,按节点(含独立下载端点)分配;每个缓存绑定不可变的 SNI、ALPN 和证书策略,绝不跨节点复用。TLS 1.3 票据有界、一次性消费,TLS 1.2 会话占用同一额度;额度为 0 则禁用恢复。REALITY 不参与该缓存。 +- 共享 TLS 客户端的显式验证名、协议版本、客户端证书身份和 ClientHello profile 也属于不可变策略,单独构造缓存,不跨身份共享。boring 只保存当前节点/SSL 上下文产生的票据,应用层 ALPN 接受前不发布,失败或取消丢弃临时票据。标准 TLS 和 REALITY CloseWrite 只发送 close_notify,刷新最多5秒,保持读方向和底层传输;Drop 释放整个流。XHTTP 仍以逻辑连接整体关闭为准。 - 半开连接、出站握手和活动会话只记录当前值和峰值,不触发资源错误。 - 超时、取消、EOF 和协议错误负责回收;停止必须等待全部已跟踪任务结束。 - 引导解析器最多使用四个工作线程;全部忙时在调用方既有期限内等待,不返回人为容量错误。 -## HTTP 代理入站 +## 共享流传输基础 -HTTP 代理入站不是 TUN 转发缓冲区的使用者:请求 / 响应各使用 8 KiB 预读与复制缓冲区,头部 32 KiB / 100 字段、chunk 行 1 KiB、trailer 8 KiB / 100 字段。正文不整体缓存,不设全局业务连接准入数;先绑定后启动,Stop 取消并等待所有入站连接任务。读头、正文空闲和临时响应数量边界见 [HTTP 代理入站](http-proxy.md)。 +`stream-transport` 仅包装传入 IO,不创建 socket 或 DNS。WS/HTTP响应首部最多16 KiB、100字段;WS用户请求头最多100字段,额外固定升级头和early-data仍计入16 KiB总预算。WS early-data最多2,048原始字节,头名称/路径后缀由类型化选项区分。WS消息和单帧最多64 KiB,写入切块16 KiB;HTTP首包正文直接写入,不整体复制或持续按HTTP正文定界。 -## SOCKS5 代理入站 +共享的无池gRPC/legacy H2适配器每个实例仅拥有一条底层连接与一个逻辑流,不是全局并发许可;VLESS另有下文描述的节点级gRPC池。流窗口64 KiB、连接窗口128 KiB、最大HTTP/2帧和发送缓冲各16 KiB、解码负载64 KiB。读侧按实际消费量释放窗口;每poll最多处理32个片段/控制消息。原有gRPC和legacy H2的shutdown关闭整个逻辑连接;Trojan使用独立duplex模式,仅发送END_STREAM并保留读取。关闭前已接受的数据须到达所提供IO的flush边界,不能仅排入h2队列后立即reset/abort;每个逻辑写端最多一个待确认数据块,Stop可取消等待。两种模式的owner.stop都等待驱动任务退出;Drop只做取消兜底,不作为同步停止通过证据。WS/HTTP适配器按底层CloseWrite语义工作,协议包装层可以根据Mihomo契约结束整个逻辑流。所有握手使用调用方同一个绝对deadline。 -TCP 每方向复制缓冲区 4 KiB,握手 10 秒。UDP 控制连接拥有授权、单关联 16 包队列及 relay;全局接收不等待建链或发送。空闲 30 秒、每 10 秒清理,满队列与非法包不刷新时间。UDP wire 上限 65,507 字节,回包负载保守预留最大头部为 65,245 字节,出站继续逐层核算预算;不复用 TUN MTU。Stop 等待全部任务退出,无全局关联数准入。完整语义见 [SOCKS5 入站](socks5-proxy.md)。 +XUDP现在只拥有已认证流上的帧编码;VLESS响应头由VLESS包装层处理。元数据仍最多512字节,单payload仍受调用方预算和u16 wire上限约束,不新增全局会话额度。 + +## 协议局部预算 + +协议专用上限与关闭语义只维护在[入站](inbounds.md)、[出站](outbounds.md)、 +[VLESS](vless.md)和[XHTTP/sing-mux](xhttp.md)。`tests/protocols/limits.json` +绑定实际常量与越界用例,不是第二套运行时配置或可配置业务准入数。 ## UDP @@ -65,6 +72,16 @@ TCP 每方向复制缓冲区 4 KiB,握手 10 秒。UDP 控制连接拥有授 嵌套代理协议可以增加有界帧头,但最终解封装负载仍不得超过调用方按有效 MTU 给出的上限;其他 TUN 平台为 1,452 字节,Windows 为 1,352 字节。 +### 定向数据报预算与受控 QUIC + +`DatagramBudget` 分别表达当前层的发送和接收 payload 上限。嵌套协议先为下层申请有界 envelope 空间,再按实际协议/地址族开销扣除下层能力,与调用方预算取交集;不能把接收上限直接当作发送能力。DIRECT、SOCKS5、SS 2022、AnyTLS UoT 和 VLESS XUDP 保留原来对端/地址校验。超出发送预算在写入前失败,超出接收预算丢当前包并有界让出执行权,close 后不能恢复收发。 + +`quic-transport` 只把已有 `DatagramTransport` 适配成 Quinn 的受控 UDP 接口,没有内部 bind、DNS 或 DIRECT 回落。每个连接双向各最多32个排队数据报,另允许一个正在发送的包;TX满返回WouldBlock,RX满暂停读取。接收等待不会持有发送队列锁;Pending发送不会重复提交。单逻辑peer/物理peer映射和来源校验独立保留,不接受未请求的目标、GSO或源地址覆盖。物理 socket 仍只能来自 Dialer。 + +QUIC双向可用payload至少1,200字节;endpoint必须显式把QUIC MTU限制在有效预算内。IPv4/IPv6路径MTU分别先扣除28/48字节IP+UDP头,边界和单字节不足均有定向测试。 + +QUIC owner.stop先取消并等待驱动,再关闭并释放上游;上游close最多1秒。Driver Drop仅为取消/abort兜底,不能作为同步Stop验收。队列容量是单连接局部界限,不是全局QUIC连接准入数。 + ## DNS - 不设置固定的活动请求或活动传输总许可数。 @@ -78,11 +95,13 @@ TCP 每方向复制缓冲区 4 KiB,握手 10 秒。UDP 控制连接拥有授 完整语义见 [TUN ICMP 与 DNS](tun-icmp-dns.md)。 +IP-only协议接点持有`ResolutionContext`,runtime DNS通过Weak绑定,不形成DNS→dispatcher→connector强引用环。解析继承同次建链绝对期限和runtime取消;同名递归/超过32层解析依赖立即失败,32是单调用依赖深度而非并发请求额度。独立测速的受控bootstrap上下文不创建Running Session或RuntimeDns。 + ## 代理组与 Controller - 静态 `select` 组只拥有不可变的有序成员和每组一个可原子替换的当前索引,不拥有协议连接、后台任务、队列、缓存或独立 worker。 - 组数、每组成员数、重复成员数和嵌套深度没有独立固定上限,统一受 256 KiB YAML 上限约束;配置期使用 O(V+E) 的迭代 DAG 校验,运行时也以迭代方式解析当前叶节点。 -- 节点上游和全部组成员在同一 DAG 中校验;组连接器只持有声明依赖,按逆依赖顺序释放。建链快照只按需记录本次遇到的组,随建链结束释放,不成为全局缓存或长生命周期状态。 +- 节点上游和全部组成员在同一 DAG 中校验;组连接器只持有声明依赖,按逆依赖顺序释放。建链快照只按需记录本次遇到的组,不成为全局缓存。Hysteria2 的已认证连接为后续端口跳跃保留该快照,直到此物理会话退出;它不再读取实时选择,也不延长原建链期限。 - 同组成功选择是线性化的,不同组独立;选择失败不改变状态,也不创建重试或自动 failover 任务。 - 切换不扫描或迁移现有 TCP、UDP、DNS transport,不刷新 DNS cache/singleflight 或 TCP pool;资源仍由原所有者按既有 timeout、EOF、取消和 stop 语义回收。 - Controller 同时最多跟踪 8 个连接任务。请求 header 和 PUT body 各有 5 秒读取期限,PUT body 最大 1 KiB;超时、超限和解析失败只终止当前请求。 @@ -115,6 +134,10 @@ Apple 目标通过 `TASK_VM_INFO` 尽力记录当前 physical footprint、进程 日志必须有界且脱敏,不记录目标、DNS question、UUID、凭据、密钥、负载或完整配置。 +仅`cfg(test)`/`interop-test`启用的`ResourceProbe`按测试作用域记录RAII当前值/峰值;子任务显式继承作用域,不使用进程全局reset或更换生产allocator。当前接入物理TCP/UDP、共享流/QUIC驱动与session、数据报association、DNS池/等待者及既有运行时活动guard。Hysteria2 的待完成分片通过 `Reassembly` 登记,交付后释放 Packet ID 和重组资源;零计数必须与实际执行过的重组路径一起解释。 + +生产`ObservedIo`中的观测guard为空类型,不分配共享计数器。同步Stop、5秒静默窗口和真实对端由断言及结构化事件证明,不根据日志中的PASS文字判断。`tests/protocols/limits.json`登记公共限额及继承的`ResourceLimits`,`limit_foundations`直接与Rust常量核对;它不是第二套运行时配置。 + ## 变更要求 新增全局 admission、可配置容量或长期缓存前,必须: diff --git a/docs/rustls-reality-release.md b/docs/rustls-reality-release.md deleted file mode 100644 index 23e3307..0000000 --- a/docs/rustls-reality-release.md +++ /dev/null @@ -1,71 +0,0 @@ -# rustls REALITY 依赖与发布要求 - -VCore 的 REALITY 客户端依赖自有 rustls 0.23 fork。仓库通过 GitHub 分支 `vcore/reality-0.23` 引用该 fork,并由 `Cargo.lock` 固定实际解析的提交。 - -## 实现边界 - -fork 只增加连接级 REALITY 能力: - -- 配置构建器接收不可变的服务端公钥、short ID 和客户端版本; -- ClientHello 使用同一 X25519 临时密钥完成 key share、ECDH 和 session ID 封装; -- 证书验证器消费当前连接的认证状态,验证临时证书和 TLS 1.3 `CertificateVerify`; -- 所有失败路径清零秘密并终止连接。 - -fork 不创建线程、异步任务、连接池、全局映射或跨连接锁。VCore 只负责配置解析并选择普通 TLS 或 REALITY;握手字节和认证状态属于 rustls。 - -普通 TLS 与 REALITY 必须使用不同的不可变 `ClientConfig`,不能在同一对象上热切换身份。线上协议见 [REALITY V1 客户端协议](reality-wire-protocol.md)。 - -## GitHub 分支依赖 - -```toml -[patch.crates-io] -rustls = { git = "https://github.com/OneVCore/rustls", branch = "vcore/reality-0.23" } -``` - -要求: - -1. 只使用上述 GitHub 分支,不使用本机路径或第二个 rustls source; -2. `Cargo.toml` 和 `Cargo.lock` 在同一提交中更新,lockfile 必须包含实际解析的完整 Git revision; -3. 没有相邻 rustls 目录时,`cargo fetch --locked` 和后续构建仍能成功; -4. 只启用预期的 `ring` 提供方和 REALITY feature; -5. 发布记录保存 VCore revision、lockfile 中的 rustls revision、lockfile hash 和产物 SHA-256。 - -分支前移不会自动改变 locked 构建。升级必须显式更新 `Cargo.lock`、审查解析 revision,并在同一变更中重新执行验证门禁。 - -## 验证门禁 - -在全新目录中执行: - -```bash -cargo fetch --locked -cargo fmt --all -- --check -cargo test --locked --all-features --all-targets -cargo clippy --locked --all-features --all-targets -- -D warnings -uv run --project scripts --locked vcore-scripts check c-header -uv run --project scripts --locked vcore-scripts check tls-dependencies -uv run --project scripts --locked vcore-scripts build apple -uv run --project scripts --locked vcore-scripts build android -uv run --project scripts --locked vcore-scripts build windows -``` - -同时验证: - -- 普通 TLS 与 REALITY 的真实进程数据面; -- 错误 public key、short ID、伪装站证书和 HRR; -- 共享连接器并发、取消重连和运行时停止; -- AnyTLS 继续使用标准 TLS,不继承 REALITY 身份; -- Apple、Android 和 Windows 产物使用同一锁定依赖。 - -具体执行结果只记录在 [验收矩阵](acceptance.md)。 - -## 升级与回退 - -升级 rustls 基线或加密依赖时,单独完成以下变更: - -1. 同步上游并先解决普通 TLS 回归; -2. 重新运行全部确定性线上向量; -3. 审查 ClientHello、key share、证书验证器和 provider API 的变化; -4. 重新执行平台构建和产品数据面; -5. 推送分支提交并更新、审查 lockfile 中的解析 revision。 - -回退通过新的 VCore 提交恢复上一个已验证 lockfile revision。VCore 不保留双 REALITY 实现或运行时降级开关。 diff --git a/docs/shadowsocks.md b/docs/shadowsocks.md deleted file mode 100644 index 238bec5..0000000 --- a/docs/shadowsocks.md +++ /dev/null @@ -1,41 +0,0 @@ -# Shadowsocks 2022 出站 - -`outbound-shadowsocks` feature 使用原样官方 `shadowsocks-rust` 协议库,仅开放下列三种算法;配置纳入修订版 14。字段与示例见 [config.yaml](config.yaml)。本机协议与适配器验收见 [acceptance.md](acceptance.md),不等同于完整组合或目标设备发布验收。 - -## 配置与密钥 - -`type: ss` 接受公共节点字段 `name`、`server`、`port`、`udp`、`dialer-proxy`,及必填 `cipher`、`password`。`udp` 默认 false;上游可引用具体节点或静态 select 组。 - -| cipher | 解码后密钥长度 | password | -| --- | --- | --- | -| `2022-blake3-aes-128-gcm` | 16 字节 | Base64 PSK 或 `iPSK1:…:iPSKn:uPSK` | -| `2022-blake3-aes-256-gcm` | 32 字节 | Base64 PSK 或 `iPSK1:…:iPSKn:uPSK` | -| `2022-blake3-chacha20-poly1305` | 32 字节 | 单一 Base64 PSK | - -算法名称精确匹配。标准 Base64 可带或不带末尾 padding;空段、非法编码与密钥长度错误在配置验证时失败。AES 身份链保持声明顺序,末段是用户密钥;不 trim 凭据,不在错误或 Debug 中展示密钥。 - -## I/O 与关联状态 - -- 官方库仅包装 VCore 已有 TCP 流,UDP codec 使用单包有界交接;不调用官方库的物理 socket 创建接口。物理首跳继续经过共享 dialer、预解析端点、Android protect 和 Windows 绑定。 -- TCP 按 16 KiB 部分写入交付 codec,确保首次目标头与负载落在协议帧上限内;读取或关闭写端先于首次写入时调用官方空首写,允许服务器先发或在客户端半关闭后响应。空首写遇到 Pending 时继续完成同一次握手,再关闭底层写端;已有负载首写不重复目标头。错误只保留错误种类与固定消息。 -- UDP 在可取消发送前预留 packet ID,真实底层发送结束才报告成功;Pending 不触发重复编码,无后台发送队列。接收只交付完整数据报,并检查来源、认证、响应 payload 上限和关联 ID。 -- 每个 UDP transport 保留随机 client session ID、递增 packet ID,以及最多两个 server session 的 8,128 包滑动重放窗口。旧 server session 最近一分钟仍有有效包时不替换;packet ID 即将溢出时重新生成 client session ID 并清空旧接收窗口。 -- TUN 与 SOCKS5 的响应预算分别传入封装层,外层 wire 上限为 65,507 字节;超限不截断交付。关闭清空窗口并关闭底层 transport,不新增协议后台任务。 -- 上游组选择使用同一次建链快照;DIRECT 使用已准备的代理服务器地址,代理上游保留逻辑服务器地址,不重新执行业务路由。 - -## 依赖与日志边界 - -官方 Git revision 固定为 `ab388c7466d21f979430e33cc9ef10e22fb05955`,关闭默认 features,仅启用 `aead-cipher-2022`;registry `shadowsocks-crypto` 为 `0.8.0`。没有协议源码补丁或研究目录 path 依赖。 - -仅允许 `shadowsocks → shadowsocks-crypto → aws-lc-rs → aws-lc-sys` 链使用 AWS-LC,来源、features 与反向依赖边由 `check tls-dependencies` 校验。TLS / REALITY 保持既有 rustls + ring;额外 AWS-LC 消费者、FIPS 和 2022-extra features 被拒绝。 - -官方库的 `log` 调用可能包含密钥或流量。启用 SS feature 时,通过 `log/max_level_off` 和 `release_max_level_off` 编译关闭该 facade 的全部日志;Cargo feature 合并意味着这也关闭同一依赖图中其他 `log` 调用,并非仅按 SS target 过滤。VCore 自身使用的 `tracing` 不受影响。上游 Debug 类型不通过公开适配器暴露。 - -UDP 重放窗口代码派生自官方项目中的 MIT 实现;来源、版权及完整许可文本见 [源码文件头](../src/outbound/shadowsocks/packet_window.rs)。发布时仍须审计目标平台的实际链接依赖图。 - -## 已知限制与验收边界 - -1. **上游 padding 风险未修补。** 锁定官方源码在空 TCP 首写、空 UDP payload 等路径扩展 padding 长度但没有显式初始化字节,存在把残留缓冲内容传给可解密对端的风险。原样复用并不等于该风险已修复;普通 echo 测试不能代替安全结论。 -2. SS 2022 首次固定头不足单次读取长度时,官方库按协议的探测防护要求拒绝;不改为无限等待或 `read_exact`。 -3. 当前 mihomo 服务端只暴露单 PSK。AES EIH 数据面通过独立测试中继逐层校验并剥离 1 / 2 层身份头,再把未解密的业务流量交给 mihomo;TCP/UDP 和错误身份拒绝均已执行。这不是 mihomo 原生 EIH 服务端支持,也不证明任意服务商的身份链部署。 -4. 三算法活动 TCP/UDP Stop、绑定失败回滚、独立测速返回后的 socket 回收已在 macOS 验证。全协议组合压力与目标设备仍有独立门槛,见 [验收矩阵](acceptance.md)。 diff --git a/docs/socks5-proxy.md b/docs/socks5-proxy.md deleted file mode 100644 index a8f7065..0000000 --- a/docs/socks5-proxy.md +++ /dev/null @@ -1,44 +0,0 @@ -# SOCKS5 代理入站 - -SOCKS5 与 HTTP、TUN 共用规则、DNS、代理组和出站图;入口不提前解析业务域名。协议遵循 [RFC 1928](https://www.rfc-editor.org/rfc/rfc1928) 与 [RFC 1929](https://www.rfc-editor.org/rfc/rfc1929) 的 CONNECT、UDP ASSOCIATE 和用户名密码协商。 - -## 配置与监听 - -- 顶层 `socks-port` 省略或为 0 时关闭,1–65535 时绑定同端口 TCP 与 UDP。 -- HTTP、SOCKS5、TUN 至少启用一个;纯 SOCKS5 可以独立运行,并使用代理组 Controller、DNS 和 GeoData。 -- `authentication`、`allow-lan` 与 HTTP 共用。默认仅回环且免认证;非空认证必须恰好一项 `user:password`,用户名和密码分别为 1–255 UTF-8 字节,按首个冒号分隔。共享必须配置认证,凭据和共享开关必须有启用的 HTTP 或 SOCKS5 入口消费。 -- `ipv6: false` 只绑定 IPv4;true 时增加独立的 IPv6-only socket。仅系统明确不支持该地址族时可省略 IPv6,其余错误使启动失败。 -- 所有入站和 Controller 完成绑定后才开始服务。HTTP/SOCKS5 TCP 端口冲突、必要的 UDP 端口冲突均整体回滚。 -- 用户名密码协商没有传输加密;共享只适合受信任网络。Controller 的回环和 Bearer 认证不随共享开关改变。 - -## TCP 与授权 - -仅 SOCKS5;按当前配置选择免认证或用户名密码方法,不降级绕过已配置认证。握手使用精确读取,保留紧随 CONNECT 的业务字节。认证失败不建立上游。 - -CONNECT 将 IPv4、IPv6 或域名目标交给共享 Dispatcher;只有上游建立成功才返回成功,拒绝、不可达、超时等使用 SOCKS5 回复码。双向转发支持半关闭,每方向复制缓冲区 4 KiB。 - -## UDP 关联 - -- 一个成功协商的 TCP 控制连接拥有一个关联;未授权 UDP 不创建状态或上游。 -- 请求的源 IP 必须为未指定地址或 TCP peer IP,不能把代理用于其他来源。回复使用接受到的 TCP 连接的具体本地 IP 和配置端口,即使监听器绑定通配地址,也不返回不可达的通配 relay。 -- 用 TCP peer IP、IPv6 接口作用域(scope)与客户端 UDP 源端口识别关联;作用域取自 TCP peer,不从客户端请求推断。请求源端口为 0 时,首个合法且成功入队的同作用域 UDP 包学习端口;同 IP、同作用域至多存在一个尚未学习端口的关联,新的歧义请求被拒绝。同 IP、同作用域的不同明确端口可并存,同端口不能重复授权;不同作用域的相同链路本地地址互不串包。 -- 每个关联可转发多个目标,分别经过共享路由;回包保留出站提供的实际远端地址。 -- 非零保留位、分片、非法地址、零目标端口和超长报文被丢弃;无效包不学习端口、不延长空闲时间。 -- TCP EOF、错误、停止或关联过期撤销授权。源端口可以重新授权,但旧任务不能删除新关联或发送旧代次回包。 - -这比 mihomo 的共享 UDP 接收策略更严格:UDP 必须归属于仍然有效的 TCP 控制连接,不因收到任意 UDP 包隐式创建关联。 - -## 边界与回收 - -| 项目 | 固定上限 / 策略 | -| --- | --- | -| 协商 | 10 秒;方法和用户名/密码各最多 255 字节 | -| TCP 建链、UDP 建链/单次发送/回包写入 | 15 秒,整个操作随所属连接取消 | -| 单关联待发队列 | 16 包;满队列丢弃当前包,不阻塞其他客户端 | -| UDP 入站 wire 大小 | 65,507 字节;接收额外一个 sentinel 字节识别截断/超限 | -| UDP 回包业务负载 | 保守预留最大 262 字节 SOCKS5 头,最多 65,245 字节;嵌套出站继续逐层计算封装预算 | -| 关联空闲 / 检查间隔 | 30 秒 / 10 秒;只有成功入队和成功发送的合法回包刷新时间 | - -没有全局业务连接或关联数量准入限制。全局 UDP 接收只做有界解析和非阻塞入队,慢建链和写入留在各关联任务。控制连接拥有该任务,Stop 取消并等待全部 TCP 控制、relay 和 UDP 接收任务,返回后监听端口可以重新绑定。TUN 仍使用其独立 MTU 预算;Controller 流量统计不包含 SOCKS5 数据。 - -独立 `inbound-socks5` feature 可在不启用业务 HTTP 的构建中使用;默认和 TUN 构建包含两个客户端代理入口。验证范围见 [验收矩阵](acceptance.md)。 diff --git a/docs/testing-isolation.md b/docs/testing-isolation.md new file mode 100644 index 0000000..45c07e5 --- /dev/null +++ b/docs/testing-isolation.md @@ -0,0 +1,23 @@ +# 服务端测试隔离规则 + +自 2026-09-23 起,**后续所有服务端测试均在隔离容器中执行**。本文件是服务端测试部署方式的权威规则,优先于历史脚本和验收记录中的宿主运行说明。 + +## 范围 + +- 协议服务端(Mihomo、Xray、V2Ray、Hysteria、Shadowsocks 等)、UDP echo / TCP / HTTP / DNS 测试原站,以及故障注入中的服务端角色,全部容器化。 +- 对照客户端若提供 SOCKS/HTTP 等测试入口,该入口也放在容器中。宿主只运行待测客户端、测试驱动和只读观察工具。 +- 纯内存、编解码、离线配置或 mock 单元测试不属于外部服务端互通测试。不能把宿主网络服务改称 mock 来规避隔离。 +- 尚未完成容器化、容器不可用或平台不支持的服务端用例记为 `BLOCKED` / `NOT RUN`,不得回退到宿主启动。Linux 测试对端不代表 VCore 新增 Linux 产品支持。 + +## 执行要求 + +1. 使用本次任务独占、可识别所有权的容器和私有网络。macOS 使用 Apple Container 的 host-only 网络;不发布宿主端口,不改宿主 VPN、路由、DNS 或防火墙。 +2. 协议对端使用官方最新稳定发行二进制,通过官方 `latest` 下载链接获取;不查版本 API、不本地编译、不修改第三方源码、不静默复用旧缓存。镜像使用官方稳定标签,在当次运行记录解析后的 digest。记录容器内实际版本、二进制和下载包校验值。 +3. 保留 Mihomo 回环保护和第三方默认 socket 行为;不能通过关闭保护、放宽数据正确性/超时或重试业务包掩盖失败。 +4. 原站和协议对端的地址必须来自本次容器网络。配置、证书与测试凭据仅放临时目录,fixture 只读挂载,日志保持有界且脱敏。 +5. 注册清理后再启动资源。成功、失败、超时、SIGINT 和部分启动都必须停止并删除本次拥有的容器,检查剩余资源;不得停止或清理其他任务的容器。 +6. 报告分别记录隔离拓扑、真实流量结果、源码身份和清理结果。基础设施/清理失败不能记为互通通过;定向诊断通过不等于阶段签收。 + +已批准的局部例外:H3/mTLS 测试网关可用官方最新稳定版 `xcaddy` 编译官方最新稳定版 Caddy,再导入隔离容器。保留 go.mod/go.sum、工具版本、构建日志、二进制 hash 和容器内版本;不修改第三方源码、不新增协议 decoder。Caddy 只终结 H3/TLS 并转发 HTTP,XHTTP 会话与 VLESS 解码仍由同一个原生 Xray handler 负责。此例外不允许编译 Mihomo/Xray 等对端,也不把本地构建称为官方预编译产物。 + +历史宿主测试保留为历史证据,不追改为容器测试。旧脚本即使有 `--container`,若原站或反向服务仍在宿主,也不满足本规则;迁移前不得用于新的服务端验收。 diff --git a/docs/tls-client-fingerprint.md b/docs/tls-client-fingerprint.md new file mode 100644 index 0000000..b1abb7d --- /dev/null +++ b/docs/tls-client-fingerprint.md @@ -0,0 +1,113 @@ +# TLS 证书与客户端指纹 + +两个字段相互独立: + +| 字段 | 含义 | 接受值 | +| --- | --- | --- | +| `fingerprint` | 服务端证书 DER 的 SHA-256 pin | 64 个十六进制字符,可带冒号 | +| `client-fingerprint` | 客户端 ClientHello 的命名模板 | 下列七值;空串也关闭 | + +| 公开值 | 实际模板 | +| --- | --- | +| `none` | 关闭命名指纹,不关闭 TLS 或 REALITY 认证 | +| `chrome` | Chrome133 | +| `chrome120` | Chrome120(固定) | +| `firefox` / `firefox120` | Firefox120 | +| `safari` / `safari16` | Safari16.0 | + +节点省略时关闭,显式启用推荐 `chrome`;这是节点级选项,无全局默认。 +大小写敏感,未知名称、`null`、错型全部拒绝;不接受 `chrome133`、`safari16.0`、 +移动端、随机或 PSK/PQ 专用名称。短名只随显式依赖升级和验收变更,不随 OS 自动选择。 + +## 适用范围 + +AnyTLS、Trojan、VMess + TLS、VLESS + TLS/REALITY/JLS 使用同一共享连接器。 +TCP、WS、gRPC、HTTP 首包伪装、legacy H2、Vision、XHTTP H1/H2 仍遵守各自的协议约束。 +关闭 TLS 时不能携带该字段。SOCKS5、SS 2022 不接入 TLS 指纹。 + +XHTTP `download-settings.client-fingerprint` 缺省继承主腿,显式名称覆盖,`none` / 空串清除。 +下载腿即使与主腿使用同一服务器,也拥有独立的 TLS 策略、身份和缓存。 +切换到明文或 H3 前必须显式清除继承的非空 profile;H3 不支持此模板,不回退 H2。 + +```yaml +type: vless +tls: true +client-fingerprint: chrome +# fingerprint: # 与 ClientHello 模板独立 +``` + +## 后端与认证 + +- 默认验证公开信任链、名称及有效期。叶证书 pin 匹配时以叶证书为显式信任依据, + 不再要求公共根、名称或有效期;匹配非叶证书时将其作为信任锚,仍验证叶链、名称和有效期。 + skip 只跳过常规验证,不能覆盖不匹配的 pin;所有策略仍验证握手签名。 +- 未启用指纹的标准 TLS 和 QUIC 使用 crates.io 官方 rustls + ring,不依赖 rustls fork。 +- 命名指纹的标准 TLS,以及所有 REALITY/JLS,使用 release 分支并由 lockfile 锁定的自有 boring fork。 +- 普通 TLS 两条路径共用同一个 WebPKI 证书验证器和发布信任根;叶 pin、非叶信任锚、 + 独立验证名、skip 优先级不变。TLS 握手签名仍由各自后端强制验证。 +- VLESS mTLS 继续使用配对的内联 PEM,密钥匹配在使用 IO 前检查;profile 不修改身份。 +- REALITY 仅允许自身临时证书认证,不能混用 pin/skip/mTLS 或降级到普通站点证书。 +- [JLS](vless.md#jls) 使用共享凭据身份和完整原生 TLS1.3,不启用恢复或 PKI 回退;profile 不改变身份。 +- 连接器只包装 Dialer/上游交付的 IO,不解析 DNS、不创建 socket、不绕过平台 protect。 + +## 四模板边界 + +模板由 fork 固定密码套件、组/share、签名算法、扩展排列及受限扩展编码。 + +| 模板 | 主要差异 | +| --- | --- | +| Chrome120 | 经典 X25519 share,GREASE/乱序、ECH GREASE、Brotli、条件 padding、旧 ALPS 17513 | +| Chrome133 | 普通 TLS 与显式混合 REALITY 使用原生 X25519MLKEM768 和 X25519 双 share;新 ALPS 17613,无 padding | +| Firefox120 | 自有 cipher 顺序、固定扩展顺序、X25519/P-256 双 share,无 GREASE/ALPS/证书压缩 | +| Safari16.0 | 固定扩展和签名顺序、GREASE、条件 padding、真实 Zlib 解压,模板本身不加 ECH/ALPS | + +TLS 版本范围、SNI、ALPN、证书策略、身份和恢复预算仍由节点及传输决定。 +因此名称表示版本固定的 TLS 模板,不承诺完整浏览器行为或所有上下文的逐字节相同。 +最低 TLS1.2;Safari 的 TLS1.0/1.1 声明裁剪,Vision/XHTTP/REALITY 继续强制 TLS1.3。 +普通 Vision/XHTTP 的版本约束同时省略 TLS1.2 cipher 及仅用于旧握手的 +EC point formats、extended master secret、session ticket、renegotiation info 扩展。 +命名 REALITY 则保留模板的 TLS1.2 声明与字段,由原生 REALITY 在 ServerHello 阶段 +拒绝非 TLS1.3 协商;这与关闭命名指纹时的 TLS1.3-only 默认 offer 分开。 +ALPS 仅在实际提供 h2 时发送,不复制 Mihomo 某些 WS 调用中的额外 h2 ALPS。 +classic REALITY 移除 Chrome133 的 ML-KEM group/share,并绑定实际 X25519 私钥; +Firefox 的额外经典 share 不产生第二份 REALITY 身份。REALITY 仍拒绝 HRR、恢复和 0-RTT。 + +`reality-opts.support-x25519mlkem768`:默认 false,true 要求实际 +发送且协商 X25519MLKEM768(4588),经典选组即失败,不重试或降级。只兼容 `chrome` +或关闭命名指纹;后者使用原生混合-only offer,不自动切到 chrome。Chrome120、Firefox120、 +Safari16.0 在配置期拒绝该组合。下载腿继承/整体替换 REALITY 后,再独立验证最终 profile。 +ML-KEM 增强 TLS 密钥交换,REALITY 身份认证仍基于 X25519,不宣称后量子身份认证。 + +Firefox 的 FFDHE、delegated credentials、record size limit 及 Safari 的模板专用 +cipher 声明不开放新的配置能力;不能真实完成的对端选择明确失败,不静默换模板。 + +模板不模拟浏览器 HTTP/2 SETTINGS、QUIC 参数,也不会自动启用实际 ECH。 +VLESS 可另行显式配置[静态 ECH](vless.md#静态-ech):四模板均可使用,届时强制 TLS1.3、 +加入真实 ECH 并关闭恢复;不再声称与未启用 ECH 的历史浏览器模板逐字节相同。 +当前 HTTP 驱动不导入 TLS ALPS 中的应用设置,因此**非空 ALPS 响应明确失败**; +未协商或协商空设置可用。Brotli/Zlib 解压输出和原生证书消息上限均为 128 KiB; +超限、截断、错误算法和损坏压缩流失败,不能绕过证书认证。 + +## 所有权与关闭 + +节点的 SNI、ALPN、验证策略、mTLS 身份、版本范围、profile 和 SSL 上下文不可变。 +每次独立构造建立独立缓存,clone 只共享同一节点。标准 TLS 的运行时票据总预算为 4; +TLS 1.3 票据只消费一次,TLS 1.2 会话也计入预算,过期票据清除,零预算关闭恢复。 +非零 TLS1.2 ticket lifetime hint 与原生 session timeout 取较短者;零提示不扩大原生期限。 +Safari16 不声明 TLS1.2 session-ticket 扩展,该版本的恢复使用会话 ID,不能把不存在的 +票据提示当作它的过期时间。TLS1.3 的票据期限已由原生握手收敛。 +握手及应用层 ALPN 策略通过前,暂存票据不会进入节点缓存。REALITY 始终不恢复。 +别名只复用模板,不扩大节点/下载腿缓存归属。冷连接和恢复连接分开验收;VCore 的 +票据策略不同于 Mihomo,恢复 hello 不宣称逐字段相同,也不伪造 PSK/binder。 + +CloseWrite 刷新 close_notify 最多 5 秒,保留底层连接和读方向;重复关闭幂等, +关闭后拒绝写入。Vision direct 切换保留同一 TLS 记录边界,先排空明文/刷新密文, +不向裸流插入外层 TLS alert。XHTTP 仍按其整条逻辑连接关闭契约工作。 + +## 验证与发布 + +验证使用真实公开配置、共享连接接口和独占容器内的原生协议对端; +编解码及纯内存 TLS 测试不启动宿主监听器。命令见 [构建与检查](../scripts/README.md), +指纹分层与 golden 比对见 [指纹验证](../tests/fingerprints/README.md), +结果的证明范围见 [验收边界](acceptance.md)。设备、性能、体积和发布许可证审查各自独立, +本地互通或交叉编译不替代这些门禁。 diff --git a/docs/tls-dependencies.md b/docs/tls-dependencies.md new file mode 100644 index 0000000..b4abacd --- /dev/null +++ b/docs/tls-dependencies.md @@ -0,0 +1,67 @@ +# TLS 依赖与发布 + +版本、完整 Git revision 和 registry 校验值以 Cargo.toml/Cargo.lock 为准, +由 `check tls-dependencies` 审核,不在文档复制一份易失效的包清单。 + +## 来源与后端 + +| 用途 | 来源 / 约束 | +| --- | --- | +| 普通无指纹 TLS、QUIC、共享 WebPKI | crates.io 官方 rustls/tokio-rustls,仅 ring | +| 命名 ClientHello、REALITY、JLS | 自有 OneXray/boring 的 release 分支,由 lockfile 固定完整已发布 revision | +| SS 2022 | crates.io shadowsocks 版本依赖,原样官方库,仅 aead-cipher-2022 | +| Encryption 原语 | 同一 boring 的公共 X25519、ML-KEM、AEAD、AES-CTR;官方 blake3 | +| 静态 ECH | 官方 hpke,经 rustls 公开 HPKE trait;命名模板使用 boring 既有 ECH 接口 | + +boring/tokio-boring/测试用 boring-sys 必须同源、同分支、同 revision;审计拒绝混合提交、 +未批准 revision、其他分支及本机 path/references/rustls Git patch。 +旧 rustls fork 已退役,不是回退或重建来源。分支前移不会自动改变 locked 构建。 + +AWS-LC 只允许 shadowsocks → shadowsocks-crypto → aws-lc-rs → aws-lc-sys 链; +不能供 TLS/REALITY 使用。禁止额外消费者、FIPS 和 2022-extra。 +SS 日志抑制与未修补风险见[出站](outbounds.md#shadowsocks-2022)。 + +## 身份与原生接口 + +VCore 负责策略和受控 IO,BoringSSL 拥有握手字节、认证状态和临时秘密。 +REALITY 使用同一 X25519 临时密钥生成 share、ECDH 和 session ID;连接级验证器 +检查临时证书和 CertificateVerify,失败清零并终止。普通 TLS 与 REALITY 使用不同 +不可变连接器,不热换身份、不保留第二个 REALITY 后端或降级开关。 +模板与 ALPS 限制见[TLS 指纹](tls-client-fingerprint.md),线格式见[REALITY](reality-wire-protocol.md)。 + +JLS hook 认证真正的 hello,保留 TLS 签名/Finished/记录保护;不可重试失败前清零, +非阻塞重试保留必要材料。其内部 ShadowTLS hook 依赖不等于支持 ShadowTLS。 +Restls 不支持。所有 fork 补丁由 feature 控制,原始 BoringSSL 子模块不直接改写。 + +Encryption 的固定文本 context 使用官方 Rust blake3;二进制 context 使用 +[私有薄 FFI](../crates/vcore-blake3-raw/README.md),包装未修改的官方 C portable 源, +保留逐文件 hash、许可证及符号隔离,不在构建时下载。自有临时密钥使用 zeroize, +不承诺擦除配置 String 或第三方全部内部副本。强制 ChaCha 测试入口只在 interop-test。 + +ECH 不引入第三套 TLS/provider;升级时审查 rustls HPKE/wire enum 接点。 +Brotli/Zlib 解压采用 fork 的受限实现和纯 Rust 依赖,不新增系统 zlib。 +普通 TLS 票据采用原生超时与非零 ticket hint 的较短期限;身份、票据所有权及失败策略 +见[TLS 契约](tls-client-fingerprint.md)。 + +## 构建与发布 + +原生构建需要 C/C++、CMake、Perl、libclang,分别验证目标工具链。 +Apple 最终链接需要 libc++(module map 已声明,直接 C 链接需 -lc++)。 +Android 必须随库打包同 ABI/同 NDK 的 libc++_shared.so;只生成 libvcore.so 不证明可加载。 +测试用 boring-sys/foreign-types 仅服务纯内存 peer,不新增生产后端。 + +发布记录绑定 VCore/boring revision、lockfile hash、registry 校验值、BoringSSL 子模块/ +补丁 hash、toolchain、架构、产物 hash 和签名。许可证审核覆盖实际 release graph、 +boring MIT/Apache-2.0、BoringSSL 随源通知及 Android C++ runtime,不能只看 crate license。 + +## 升级与回退 + +1. 选择官方最新稳定依赖;fork 同步上游并先完成普通 TLS 回归。 +2. fork 变更先发布至 release;同次修改 manifest、lockfile 和依赖审计的批准 revision。 +3. 重跑确定性向量、ClientHello/share/证书/签名、恢复/取消/期限及受影响容器数据面; + 分别验证 AnyTLS 标准 TLS 和 REALITY/JLS,不能互相抵扣。 +4. 在没有相邻 fork 目录的干净 checkout 执行 locked fetch、离线测试、相关平台构建和 + 原生消费者。全目标 --no-run 只是编译,网络 peer 仍遵守[隔离规则](testing-isolation.md)。 +5. 按[验收边界](acceptance.md)完成对应设备/安装门禁并保存当次证据。 + +回退使用新提交恢复仍可获取的已验证 lockfile,不恢复已退役 fork。 diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index 5855a52..0e9c8b2 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -157,6 +157,14 @@ DNS 和普通 UDP 响应使用不同队列,但共享 netstack UDP 入站接收 普通非 DNS UDP 关联不设固定总数,采用代次感知所有权、30 秒空闲超时和 10 秒清理周期。只有成功入队的请求或响应刷新活动时间。 +## IP-only协议与独立测速接点 + +公共`ResolutionContext`仅在协议必须取得IP地址的边界解析业务目标或逻辑上游;原始`Destination`保持不变,HTTP Host、TLS SNI和路由仍使用逻辑名称。代理endpoint的prepare bootstrap与该运行期解析分离,不递归使用尚未建立的同一出口。当前静态ECH不查询DNS;动态ECH及其可选宿主bootstrap入口尚未实现,不属于当前Invoke v5。 + +Running Session上下文仅弱引用本session的RuntimeDns,使用上文nameserver/policy与出口;未绑定、DNS关闭、上游不可达或session已停止时明确失败,不调用系统resolver兜底。IP字面量仍执行端口/地址族政策。解析共享同一次建链期限,Stop取消当前及后续查询;在进入可能等待自身的singleflight之前拒绝同名递归依赖,嵌套依赖深度最多32层。不会因为DNS经代理出口就无条件禁止整个图。 + +独立`measureDelay`使用测量生命周期的受控bootstrap resolver,仅在IP-only边界解析域名;不创建Running Session、RuntimeDns或DNS监听服务,也不改变现有endpoint准备流程。通用 IP-only connector 覆盖最终节点和 `dialer-proxy` 上游的受控解析、期限与取消。该公共接点继续供 packetaddr 等必须 IP 的消费者使用;通用机制测试不替代各协议的实际数据闭环。 + ## 不支持 - 真实 ICMP 转发、ICMP error、Traceroute、ICMP 选路规则或 ICMP 测速; diff --git a/docs/tun-platform.md b/docs/tun-platform.md index e413d47..5e147f0 100644 --- a/docs/tun-platform.md +++ b/docs/tun-platform.md @@ -52,7 +52,7 @@ Windows 使用 `Windows.Networking.Vpn` 回调,不使用文件描述符或适 - 数据帧为 `u16` 长度加 `1..=1500` 字节数据。写端最多合并 8 个已经就绪的帧,读端使用 64 KiB 缓冲区并逐帧校验; - EOF、截断、超限、任务异常和进程退出都会停止当前会话。 -完整契约见 [Windows VPN 平台边界](windows-vpn.md) 和 [Windows 会话运行时](windows-session-runtime.md)。 +完整契约见 [Windows VPN 平台边界](windows-vpn.md) 和 [Windows 会话运行时](windows-vpn.md)。 ## MTU 与结构上限 diff --git a/docs/vless.md b/docs/vless.md new file mode 100644 index 0000000..58fbcc7 --- /dev/null +++ b/docs/vless.md @@ -0,0 +1,184 @@ +# VLESS 出站 + +`outbound-vless` 使用共享 Dialer、上游图及 TLS/REALITY/JLS,不创建独立 socket 或系统 DNS。 +本文定义选定能力集,不承诺上游所有扩展;字段与版本以 [config.yaml](config.yaml) 为准。 + +## 配置与传输 + +`type: vless`,`server`、非零 `port` 和标准格式 `uuid` 必填。`udp` 默认 false;`network` 默认 `tcp`,`tls` 默认 false。`encryption` 省略、空或 `none` 关闭该层;其他值见下文。`flow` 省略、空或 `xtls-rprx-vision`。未知字段、null、错型或不匹配的传输选项失败,不隐式迁移配置。 + +- TCP/WS/gRPC/HTTP 首包伪装/legacy H2 可明文或标准 TLS。TLS 支持 1.2/1.3,TCP 默认不发送 ALPN,WS 默认 `[http/1.1]`,gRPC/H2 默认 `[h2]`。有序 ALPN 保留且必须包含传输要求值;标准 TLS 检查协商结果。`servername` 优先显式值、WS Host 去端口、server;不会改变拨号地址。TLS 关闭时禁止安全选项。 +- 标准 TLS 支持 `skip-cert-verify`、SHA-256 `fingerprint`、独立 `name-cert-verify`;沿用共享证书验证器的叶 pin / 非叶信任锚优先级。`certificate` / `private-key` 是配对的内联 PEM,解析或密钥不匹配在 IO 前失败,禁止文件路径。REALITY 不能混用这些标准证书策略。 +- `client-fingerprint` 使用 [TLS 指纹](tls-client-fingerprint.md)的七值/四模板,适用于 TCP 上的标准 TLS/REALITY(含 Vision 和 XHTTP H1/H2),与证书 pin 独立;`none` / 空串关闭,H3 拒绝已启用的 profile。 +- WS 的 ED 支持默认头、自定义头或路径(上限 2048 字节)。`v2ray-http-upgrade` 开启升级后裸流;`v2ray-http-upgrade-fast-open` 必须与前者一起启用,只允许默认 ED 头。fast-open 提前发协议首包但仍等待合法 101,拒绝不返回连接成功。 +- gRPC 接入 `grpc-user-agent`(默认 `grpc-go/1.36.0`)、非负秒数 `ping-interval`(0 不主动 PING),以及三项池阈值。空闲连接总是复用;max-connections>0 时按连接上限/min-streams 扩容,否则按 max-streams 复用阈值扩容。三项全零归一 max-connections=1;显式正 max-connections 与正 max-streams 互斥。阈值不是硬流数上限。物理连接归节点所有,关闭逻辑流不会杀死兄弟流;复用不重选上游组。 +- gRPC 不新增业务流/建链数量额度;每个物理连接一个自有驱动,每个等待调用只保留自身 future、取消与原期限,不派生队列任务。每节点最多保留 4 条空闲连接,多余空闲连接退役且不影响活动流。空闲 PING 等待 ACK 最多 15 秒,节点 Stop 取消并 join 所有驱动。 +- `network: xhttp` 支持 H1/H2 明文、TLS1.3 或 REALITY,H3 仅标准 TLS1.3。请求字段、独立下载腿、池与版本选择见 [XHTTP](xhttp.md)。H3 的物理 UDP 仍由原 Dialer/上游图创建。 +- `smux` 以独立的 h2mux/smux v1/yamux 承载逻辑 TCP/UDP;不是 XUDP。only-tcp、调度及关闭边界见 [XHTTP 与 sing-mux](xhttp.md)。 +- Vision 仅允许 `network: tcp`,要求 TLS 1.3/REALITY 或 Encryption;UDP 仅 XUDP。启用 Encryption 后外层 TLS 可省略,存在时仍保持独立认证。配置阶段拒绝 WS/gRPC/HTTP/H2/XHTTP、raw/packetaddr、sing-mux 与 Vision 的组合,包括未启用业务 UDP 但显式选错编码。ECH 和附加安全封装仍拒绝。 +- Vision 请求不自动降级;对端用户不支持 Vision 时业务失败。Mihomo 服务端允许为 Vision 用户显式配置空 flow,这属于普通 VLESS 模式,不是错误 flow 的拒绝用例。 + +## REALITY + +`reality-opts.support-x25519mlkem768` 为非 null 布尔值,默认 false。true 强制真实混合 +TLS 密钥交换,服务端选择经典组时失败;可用于主连接和 XHTTP 独立下载连接。 +仅 `chrome` 或未命名 profile 兼容,模板及下载继承约束见 [TLS 指纹](tls-client-fingerprint.md)。 + +## JLS + +共享凭据身份认证仍使用完整原生 TLS 1.3,不是 skip 或失败后裸流回退。 + +### 配置 + +- 对象出现即启用,两项必须都是非空字符串,各 1–65,535 个 UTF-8 字节,不 trim。 + 主连接不接受空对象;未知字段、null、错型、缺项及超限在 IO 前拒绝。 +- 必须 `tls: true`。同一腿不能混用 REALITY、标准证书策略或客户端证书; + ECH、其他安全封装、Vision 和 HTTP/3 也不属于此组合。 +- `servername`、有序 ALPN、传输必需 ALPN 与已支持的七值/四模板 + `client-fingerprint` 沿用共享配置,不增加全局默认或别名。 +- `download-settings.jls-opts` 缺省继承整个身份;非空对象必须完整替换两项, + `{}` 清除,不能按叶继承另一个身份的密码。其他下载字段独立覆盖。 +- 切换下载安全模式须显式清除旧身份及冲突证书策略;设置新对象不会自动删除旧对象。 + 两腿仍必须汇入同一个原生 XHTTP handler 的会话表。 + +### 认证与所有权 + +锁定的自有 boring JLS hook 认证实际 ClientHello/ServerHello,由原生 TLS +完成 transcript、CertificateVerify、Finished 和记录保护。共享凭据替代 WebPKI +身份,但不会跳过握手签名或 Finished。普通 TLS 证书、错误凭据、篡改握手、 +TLS 1.2 和 HRR 都不能满足 JLS;失败不发伪装 HTTP 探测、不重发业务、不回退 DIRECT。 +模板保留 TLS 1.2 的声明下限,实际成功协商始终必须 TLS 1.3。 + +每条腿的连接器身份不可变;不启用 TLS 恢复、PSK 或 early data。自有凭据副本 +使用清零容器,原生临时材料按握手/失败清理;不宣称擦除所有配置 String 副本。 +连接器只包装既有 Dialer/上游交付的受控 IO,不创建 resolver、socket、后台任务 +或额外连接池。建链期限、取消、ALPN 检查和同步 Stop 继承现有运行时。 + +JLS 继续承载 TLS 记录,CloseWrite 沿用共享 TLS 有界关闭;XHTTP 保持整流关闭。 +Encryption 位于外层传输内,身份彼此独立,不能由单层成功推断组合验收。 + +## 静态 ECH + +仅显式静态 ECHConfigList;保护内层 ClientHello 名称,不隐藏 IP、外层名称或流量形态。 +无动态 HTTPS RR/bootstrap、公共 DNS 后备或自动更新。 + +### 配置 + +```yaml +ech-opts: + enable: true + config: "<服务端提供的标准 Base64 ECHConfigList>" +``` + +- 仅标准 TLS,内层 `servername` 必须是 DNS 名,实际只协商 TLS 1.3。 + 同腿不能与 REALITY、JLS 或 Vision 混用;可与 VLESS Encryption、证书策略、 + mTLS 和既有传输组合。命名指纹仍只用于 TCP TLS,H3 仍拒绝命名指纹。 +- `enable` 默认 false。启用必须同时提供非空 `config`;不查询 HTTPS RR, + 不接收 `query-server-name`,没有公共 DNS 后备或自动更新配置。 +- `config` 是标准带填充 Base64,不是 URL、文件路径或 PEM。解码后的完整 + ECHConfigList 最多 65,537 字节,仍受整份配置 256 KiB 上限限制。 +- 支持 ECH 版本 `0xfe0d`、X25519/HKDF-SHA256,以及 AES-128-GCM、 + AES-256-GCM、ChaCha20-Poly1305。预检选取第一个双方后端均支持的条目, + 保留其原始编码;未知版本或不兼容条目可跳过,无兼容条目、畸形长度、非法 + public_name 或不支持的必需扩展在 IO 前失败。不能依赖 BoringSSL 静默忽略。 +- `{}` / `{enable: false}` 清除 ECH。关闭时不允许携带非空 config,以免 + 用户认为给出的隐私配置已经生效。null、未知字段、错型均拒绝;诊断不回显配置。 + +`xhttp-opts.download-settings.ech-opts` 缺省继承整个主腿对象;出现时整对象替换, +不逐字段合并,替换密钥须同时写 `enable: true`。显式 `{}` 可仅清除下载腿 ECH。 +切换到 REALITY/JLS/明文必须显式清除继承的 ECH,以及其他冲突的证书身份。 +两腿仍须汇聚到同一原生 XHTTP handler;各自独立建立和认证外层安全连接。 + +### 后端与失败边界 + +无指纹 TCP TLS 与 H3 使用官方 rustls + ring,通过公开 HPKE trait 接入官方 +`hpke`;命名指纹使用锁定的自有 boring fork 既有 ECH 接口。本次不修改 +第三方 TLS 实现,不恢复 rustls fork,也不扩展 Shadowsocks 的 AWS-LC 例外。 + +两后端都必须真正接受 ECH 才交出业务连接。rustls QUIC 的 ECH 拒绝作为原生 +握手错误传播,H3 在握手完成前不发送 HTTP 请求;TCP 另检查 accepted 状态。 +不把 GREASE 当真实 ECH,不自动采用 retry config,不重拨、不回放业务,也不 +降级为未加密 ClientHello 的成功连接。错误或轮换后的旧密钥需要宿主更新配置。 + +静态 ECH 连接禁用 TLS 会话恢复和 0-RTT;XHTTP/gRPC 的已认证物理连接仍可在 +原节点内复用。不同节点、主/下载腿、配置替换和后续运行实例不会共享 TLS 票据。 +本层只包装调用方提供的 IO,不创建 resolver、socket、后台任务或全局缓存。 + +正常 ECH 接受后沿用内层证书名称、pin 和 mTLS 策略。拒绝分支不返回业务成功, +也不能发送客户端证书。boring 使用原生 outer-name override,按外层名称和默认 +WebPKI 验证拒绝连接,不沿用内层 pin/skip。rustls 的公共 verifier 没有独立的 +ECH-rejection 回调:沿用配置的证书策略后仍由原生状态机强制失败;不获取或使用 +retry config。该拒绝证书路径不宣称与 Mihomo 完全相同,但不放宽业务成功门槛。 + +## Encryption + +`mlkem768x25519plus.{native,xorpub,random}.{1rtt,0rtt}.[.…][.…]`。 +key 为无填充 URL-safe Base64,解码后 32 字节 X25519 或 1184 字节 ML-KEM-768 +公开密钥,可组成有序链;非法 ML-KEM 编码在 IO 前拒绝,X25519 低阶共享密钥 +在握手失败关闭。最多 16 个 key、128 个 padding 三元组和 65,536 字节配置串。 + +padding 采用 Mihomo 的 `概率-下界-上界` 三元组,交替表示字节长度、毫秒间隔。 +首组概率至少 100、两端至少 35;长度上界累计不超过 65,553。 +概率按 Mihomo 的包含边界比较,区间上界排除;相等/倒序端点允许。 +缺省为 `100-111-1111.75-0-111.50-0-3333`,等待受原建链期限及取消控制。 + +所有密钥交换、AEAD 和 CTR 使用既有官方原语,AES-256-GCM / ChaCha20-Poly1305 +根据 CPU 选择,不增加用户算法选项。每节点一个恢复票据;`1rtt` 不复用, +`0rtt` 在票据有效时恢复,过期改为新握手。错误票据/认证失败不会自动重发业务。 +节点缓存互相隔离,旧连接失败不清除新一代票据,Stop 清空缓存且禁止再次插入。 + +该层位于外层传输内、VLESS 请求头外;WS/HTTPUpgrade/HTTP 的 initial data 是 +Encryption 握手字节,不能提前发送明文 VLESS 请求头。XHTTP 主/下载腿汇聚为 +一条逻辑流后只执行一次 Encryption。普通 Encryption 关闭与 Mihomo CommonConn +一致,flush 后关闭整条逻辑流,不承诺半关闭尾包;不关闭复用池中的兄弟流。 + +Vision 在独立的 Encryption 记录边界切换:先排空已认证明文,再绕过 AEAD; +已有外层 TLS 保留,random 模式保留记录头 CTR,不重置计数器。 +进入 direct 后,native/xorpub 继承底层 CloseWrite 并保留读取方向;random 对齐 +Mihomo 不可替换的 XorConn,仍整流关闭。未进入 direct 时三种外观均整流关闭。 +验收只覆盖实际列举的组合,不由单层成功推断整个组合或设备通过。 + +## Vision + +Vision padding、TLS 记录过滤和读写切换状态独立管理。内层非 TLS / TLS 1.2 只结束 padding,保持外层加密;识别支持的内层 TLS 1.3 协商后,分别发送/接收 direct 标记才切到裸流。每次发送最多暂存 8 KiB 一帧,接收逐段处理 u16 长度,不按声明长度无限分配。非法 UUID/命令或截断关闭 IO;取消读取保留帧进度。 + +外层 TLS 使用公开 rustls 或 boring 接口及同一记录边界适配器;每次最多读一个 TLS 记录,不在识别切换标记前吞入后续裸流。切换读取前排空已解密明文,切换写入前 flush 外层密文;VCore 不访问 TLS 私有内存布局。direct 关闭不向裸流插入外层 close-notify;未切换时保留共享 TLS 的有界关闭语义。 + +## UDP + +`packet-encoding` 默认 **xudp**,与既有 VCore 行为一致: + +| 配置 | 线上语义 | 目标 | +| --- | --- | --- | +| 省略 / `xudp` | VLESS CommandMux + 共享 XUDP 帧 | 每包携带 IP / 域名 | +| `none` | VLESS CommandUDP + 两字节长度 | 固定首包目标,后续换目标失败 | +| `packetaddr` / `packet` | CommandUDP 到原生约定地址,长度帧内再带地址 | IP-only,域名经受控 ResolutionContext 解析 | + +`none` 是 VCore 的显式 raw 拼写,不是对 Mihomo 同名字段的兼容承诺。原生约定地址不做本地 DNS。UDP 443 不额外过滤。 + +raw/packetaddr 每关联只拥有一个已建流,首次发送前已固定物理上游选择和建链期限;等待首包不会重新选择组或重启期限。packetaddr 不调用系统 resolver:运行期使用本 Running Session DNS,独立测速使用受控 bootstrap。 + +收发预算独立与调用方预算取交集。raw/XUDP payload 受 u16 上限约束,packetaddr 另扣 IPv4 7 / IPv6 19 字节地址头(未解析域名按较大开销预检)。raw/packetaddr 接收 wire 最多 65,537 字节,逐段读取,合法超接收预算的整包丢弃。对端可能有更小容量,原生夹具预算不能冒充生产协议上限。 + +部分发送取消立即关闭 IO,不允许接着发送半帧;接收取消保留帧进度。非法帧或响应头关闭关联,不切换编码、不回退 DIRECT。显式 close 后不能恢复收发。 + +## 关闭和期限 + +VLESS 响应头允许延迟到业务响应前;TCP 不等待它才允许发送请求。读取、首次响应和传输握手共用原建链期限,错误或超时释放 IO。UUID 和业务目标不进入 Debug/诊断。 + +普通 TCP/TLS 按底层 CloseWrite 语义工作;HTTP 首包伪装没有 Mihomo CloseWrite,shutdown 释放整个逻辑流并唤醒读侧。gRPC/H2 结束当前逻辑流,gRPC 的兄弟流与节点池不受影响。XHTTP 上传 EOF/shutdown 关闭整条逻辑连接,包括下载腿;需要完整响应时在关闭前读完。验收分别检查正常完整性和对齐 Mihomo 的关闭行为,不要求所有传输半关闭后都收到尾包。 + +## 验证边界 + +所有服务端、原站、DNS 和对照入口遵守[隔离规则](testing-isolation.md)。 +ignored 未执行不计通过;精确组合与源/对端身份在当次报告记录,不拼接旧成绩。 +HTTPUpgrade/fast-open 与 sing-mux 的未列举组合不从单层结果推导。 + +静态 ECH 的 H3 使用 Xray,packetaddr/sing-mux 使用明确标注的 XHTTP → Mihomo 解码。 +HTTP/H2/扩展 WS 可用 Xray TLS 网关 → V2Ray transport;需 UDP/Encryption 时另由 +Mihomo 解码。网关使用官方 XRAY_BUF_SPLICE=disable,避免下行 raw splice 绕过 TLS; +不代表 V2Ray 直接支持 ECH。无指纹 JLS/gRPC 及 Safari ECH/gRPC 的官方对照缺口采用 +明确标注的 Chrome 关闭参照;VCore 原配置的数据面单独验证,不声称同配置差分。 +动态 ECH、ShadowTLS、Restls 不支持,配置严格拒绝。 + +WS + REALITY(普通 WS、HTTPUpgrade、fast-open)的数据及认证使用真实 Mihomo listener;关闭验证采用明确标注的分层参照。当前 Mihomo WS 客户端分支未接入 REALITY,不能作为同组合对照,因此使用标准 TLS 的同种传输客户端关闭基线,并独立验证 REALITY。不得将该结果写成 Mihomo WS + REALITY 客户端互通或同组合差分通过。 diff --git a/docs/windows-session-runtime.md b/docs/windows-session-runtime.md deleted file mode 100644 index e6c5085..0000000 --- a/docs/windows-session-runtime.md +++ /dev/null @@ -1,245 +0,0 @@ -# Windows 会话运行时 - -Windows package 只有一个主 Application,但每个 VPN 会话仍由独立的完全信任 Session Host 独占完整 VCore 运行时。AppContainer Provider 只负责 Windows VPN 平台资源、原始包转发和失败关闭;系统中不存在 Provider 内嵌运行时的备用路径。 - -## 参与者与所有权 - -```text -前台宿主(完全信任) - ├─ VCoreInvoke - └─ VCoreWindowsVpnInvoke - ├─ Session Snapshot / profile - └─ ConnectProfileAsync - │ Windows 激活 - ▼ -vcore-windows-vpn-host.exe + vcore.dll(AppContainer) - ├─ VpnChannel / 路由 / DNS / 物理网络 - ├─ VpnPacketBuffer / 有界回调队列 / 失败关闭 - └─ FullTrustProcessLauncher - │ 无参数激活 - ▼ -vcore-windows-session-host.exe - ├─ 校验不可变 Session Snapshot - ├─ 可选 Windows session backend / Job Object - ├─ PreparedCore / RunningCore - ├─ DNS / 规则 / GeoData / 嗅探器 / select 代理组 - ├─ VLESS / SOCKS5 / AnyTLS / DIRECT - └─ 运行时 Controller(TUN 流量 / 代理组选择) - ▲ - └─ 同包控制管道 + 数据管道 -``` - -| 参与者 | 拥有 | 不拥有 | -| --- | --- | --- | -| 前台宿主 | 用户命令、会话记录、UI 状态 | TUN 运行时、包通道、Provider 状态 | -| Windows 桥接 | Session Snapshot、profile、连接/断开命令 | 数据包、代理流、Session Host 进程 | -| Session Host | 单次 VCore 运行时、可选 session backend、Controller、GeoData、包客户端 | `VpnChannel`、路由、进程业务配置 | -| Provider | `VpnChannel`、WinRT 缓冲区、路由、物理绑定、管道服务端、网络监控、Session Host 激活 | YAML、代理图、Controller、GeoData、backend 描述 | -| SOCKS 服务 | 自身监听器、外层 socket 和绕过策略 | VCore 代理图和 Windows profile | - -Session Host 每次连接新建一个进程,不常驻、不复用运行时,也不处理 URI 或 StartupTask。 - -## 快照与 profile - -- Windows 只维护一个同包 `VCore` profile。 -- 桥接先用当前解析器验证 TUN 配置、四个网络地址、全局 VPN policy 和可选 backend,再发布单文件内容寻址 Session Snapshot: - - ```text - vcore-session-v2:<64 lowercase sha256> - LocalState/vcore/windows/sessions/.json - ``` - -- Snapshot revision 2 保存完整 VCore YAML(包括代理组及其 `default-selected`),以及可选的有序 `sessionBackend.processes`;每项只有规范 package-relative executable path 和 argv 数组。 -- token 覆盖 YAML、进程顺序、路径和参数。参数引用的文件由调用方保持存在且不可变,VCore 不读取或摘要其内容。 -- profile custom configuration 是最大 4 KiB 的严格 JSON,包含修订版 4、规范 Session token、顶层 IPv6 开关、TUN/DNS 的 IPv4/IPv6 地址和完整 policy。 -- custom configuration 不包含 YAML、backend 描述、Controller secret、PID 或管道路径。 -- `startVpn` 的四个地址和 `alwaysOn`、`allowLocalNetwork`、`excludedCidrs` 始终严格必填并经过验证;顶层 `ipv6: false` 时,Provider 忽略两个 IPv6 地址,只安装 IPv4 地址、路由和 DNS,并拒绝 IPv6 exclusion。 -- 活动 profile 只有在 token、IPv6 开关、四个地址和 policy 完全相同时才幂等;任何变化都必须先 Stop。 -- 读取 Snapshot 时校验大小、普通文件、reparse point、内容摘要、规范 JSON及每个 executable。 -- TUN 地址、Controller 端口/secret 和 Ping 目标属于运行时字段,不写入用户 RAW YAML。 - -## 启动顺序 - -1. 前台宿主调用 `startVpn(configYaml, networkSettings, policy, sessionBackend?)`。 -2. 桥接验证配置、四个地址、policy 和进程描述,发布不可变 Session Snapshot,并把解析后的顶层 IPv6 开关和 policy 写入 profile configuration。 -3. 桥接写入单一 VPN profile 并调用 `ConnectProfileAsync`;它不启动或持有 Session Host。 -4. Windows 激活 AppContainer Provider。 -5. Provider 从 profile configuration 取得权威 token,选择物理网络绑定并准备基础资源。 -6. Provider 清理陈旧会合记录,通过无参数 `FullTrustProcessLauncher` 激活 Session Host。 -7. Session Host 不读取动态命令行参数,等待 Provider 会合记录。 -8. Provider 创建控制/数据管道服务端并原子发布会合记录。 -9. Session Host 严格解析会合记录,把其中的 token 作为候选值,构造限定对象路径并连接两条管道。 -10. Session Host 发送 `SessionHello`;Provider 把候选 token 与 profile token 精确比较后返回 `ProviderHello` 和不可变物理绑定。 -11. Session Host 验证 `ProviderHello` 回传同一 token,之后才读取 Snapshot;若存在 backend,则用一个 kill-on-close Job Object 按顺序启动全部进程。 -12. Session Host 准备并启动完整 VCore 运行时、静态代理组状态和 Controller。 -13. Session Host 确认受管进程尚未退出后返回 `RuntimeReady`。 -14. Provider 调用 `StartWithMainTransport` 并启动失败关闭监视器。 -15. 连接成功后,桥接向前台宿主返回当前系统 VPN 状态。 - -任一步失败都必须关闭包通道并收敛为 Disconnected,只返回有界脱敏错误;未完成握手的 Session Host 最多等待 15 秒后退出。 - -Session Host 的路径读取和启动失败日志必须在同一次 WinRT 初始化周期内完成;`RoUninitialize` 后不能再次查询缓存的 `ApplicationData` factory。没有 package identity 的裸启动快速、安全退出,不进入握手、不启动 backend,也不回退到普通用户数据目录。 - -## 会合记录 - -`LocalState/vcore/windows/rendezvous.json` 最大 4 KiB: - -```json -{ - "protocolVersion": 1, - "snapshotToken": "vcore-session-v2:...", - "objectPath": "AppContainerNamedObjects\\S-1-15-2-...", - "controlLeaf": "VCore.Vpn.Control.v1", - "dataLeaf": "VCore.Vpn.Data.v1" -} -``` - -- Provider 是唯一发布者和清理者; -- 使用同目录暂存文件和原子重命名; -- 只接受规范 token、AppContainer 相对路径和固定 leaf; -- 非普通文件、reparse point、超限或未知字段都会失败关闭;候选 token 与 profile token 的绑定只在双向握手中完成; -- 握手完成后删除,新连接前清理断开状态下的陈旧记录。 - -## 控制协议 - -```text -u32 大端序 JSON 长度 -严格 UTF-8 JSON -``` - -- 单帧最大 16 KiB; -- DTO 拒绝未知字段、错误版本和错误顺序; -- 错误码最大 128 字节,脱敏信息最大 4 KiB; -- 不传 YAML、Controller secret、SOCKS 凭据、日志或 Invoke 请求。 - -版本 1 消息: - -```text -SessionHello { version, snapshotToken } -ProviderHello { version, snapshotToken, physicalBinding } -RuntimeReady { version } -RuntimeFailed { version, code, redactedMessage } -Stop { version, packetCounters } -Stopped { version, packetCounters } -``` - -启动超时 15 秒,有序停止确认超时 10 秒。活动包流没有空闲超时或 heartbeat。 - -## 数据协议 - -每个方向只有一个写端: - -```text -u16 大端序包长 -1..=1500 字节原始 IP 包 -``` - -- EOF、截断、零长度、超限和帧格式错误会停止当前会话; -- 合法帧中的非法 IP 包由原始包解析器局部丢弃; -- wire v1 不包含 batch envelope、方向字节、校验和或压缩; -- 写端只等待首包,然后最多排空 7 个已经位于现有有界队列中的包; -- 不等待计时器或未来数据,低流量单包立即发送; -- 两端读端使用 64 KiB 缓冲区并逐帧校验; -- 编码缓冲区在任务内复用,单次最多写入 `8 × 1502` 字节。 - -## 回调队列 - -Provider 两侧容量均为 256: - -```text -Encapsulate - -> 复制包 - -> try_send 入站队列 - -> 包写端 - -数据读端 - -> 有界出站队列 - -> 空到非空唤醒 - -> Decapsulate 排空 -``` - -回调不等待管道 I/O。队列满时只丢当前包并计数。Session Host 的 `WindowsTunIo` 使用同样的有界通道并接入 `TunRuntime`;流量统计继续按原始 IP TUN 边界计算。 - -## 物理网络绑定 - -Provider 选择并持有: - -- 适配器 GUID; -- profile 和 network identity; -- 可用地址族从非 link-local 地址中选出的源 IP 和非零接口索引; -- 独立于源地址选择保留的全部去重 on-link prefixes,包括 link-local。 - -Session Host 只消费这份不可变绑定。每个非回环 socket 必须同时设置源地址和接口选项。只有配置中显式使用 `127.0.0.0/8` 范围内的 IPv4 字面量或 `::1` 的本地出站可以跳过;物理代理服务器的域名在准备阶段解析到任何回环地址都会失败关闭。地址族缺失、bind 失败或 setsockopt 失败时,当前连接失败关闭。 - -Provider 订阅网络变化,等待 2 秒消抖后复验适配器、选定源地址、全部 on-link prefixes 和 identity。任一变化就停止会话,不迁移 socket、不重选网卡、不自动回退。 - -## Windows session backend - -`sessionBackend` 可以省略;存在时包含 `1..=8` 个进程。每个进程只声明: - -```json -{ - "executableRelativePath": "bin\\proxy-core.exe", - "arguments": ["run", "--mode", "vpn"] -} -``` - -- executable 必须是 package installed location 内不经过 reparse point 的规范 `.exe` 相对路径; -- argv 项数、单项大小、总大小和最终 UTF-16 command line 均有界; -- 不经过 shell,不展开环境变量,工作目录固定为 package installed location; -- Session Host 使用 `CreateProcessW(CREATE_SUSPENDED)`,先加入设置了 `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` 的 Job,再恢复主线程; -- 所有进程都是关键进程;任一退出都会停止 VCore、终止同 Job 中的其余进程并使 Provider 失败关闭; -- Stop 先停止 VCore,再终止 Job,确认活动进程归零后才返回 `Stopped`; -- 第一版不提供 port、UDP、readiness、heartbeat、restart、environment、working directory 或单进程控制; -- `RuntimeReady` 只表示进程仍存活且 VCore 已启动,不表示进程内部协议已就绪。 - -## Controller 与本地 SOCKS - -- Controller 由 Session Host 监听完全信任的回环地址; -- `RuntimeReady` 前必须完成绑定,失败则连接失败; -- 前台宿主持有配置中的端口和 secret,并调用 `GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 或 `PUT /proxies/{name}`; -- 配置代理组与 Controller 时 secret 必填并保护全部路由;仅有 TUN 流量 Controller 时 secret 仍可省略; -- 代理组选择属于 Session Host 中实际 Running Session 的内存状态,不经过 Windows bridge、Provider 控制管道或 Session Snapshot 写回; -- 成功切换只影响之后新建的物理 TCP、UDP 和 DNS transport,不迁移既有连接、UDP association、DNS 状态或 TCP pool,也不执行自动 failover; -- 宿主如需跨 VPN session 保留选择,必须自行持久化,并在下次 `startVpn` 的 YAML 中注入对应 `default-selected`; -- 前台宿主退出后 Controller 和运行时继续,重新启动后恢复查询; -- Stop 关闭 Controller,销毁本次代理组选择;下一会话重新采用 YAML 初始选择并从零计数; -- 回环 SOCKS5 是普通 VCore 出站;其服务可以由外部宿主管理,也可以恰好运行在 session backend 中,但 VCore 不从 backend 描述推断端口或 readiness; -- 单个 SOCKS 流失败不停止 VPN。 - -## 生命周期 - -| 事件 | 结果 | -| --- | --- | -| 前台宿主退出 | Provider、Session Host 和运行时继续 | -| 前台宿主重启 | 以系统 profile 为权威恢复状态和 Controller 查询 | -| Provider 退出 | Windows 清理 VPN,Session Host 因 EOF 退出 | -| Session Host 退出 | Job 清理受管进程,Provider 失败关闭并停止 VPN | -| 任一受管进程退出 | 停止 VCore和其余受管进程,Provider 失败关闭 | -| 物理网络变化 | 消抖后停止 VPN | -| 管道非法或 EOF | 停止当前会话 | -| 显式 Stop | Disconnect -> Stop -> 有界确认 -> channel Stop | -| 本地 SOCKS 流失败且服务进程仍存活 | 只失败当前流 | -| Controller 绑定失败 | 启动失败,profile 不进入 Connected | -| Controller 切换代理组 | 当前 Session Host 原子更新选择;既有 transport 保持原路径 | - -桥接不持有或终止 Session Host 进程;启动失败由 Provider 的 Connect 清理路径收敛,显式 Stop 通过系统 profile 断开。 - -## 安装包契约 - -```text -HostApplication.exe -vcore.dll -vcore-windows-vpn-host.exe -vcore-windows-session-host.exe -[optional package-local managed executables] -``` - -- 产物架构与安装包一致,Rust 产物使用静态 CRT; -- Session Host 不显示在应用列表,不注册 StartupTask、URI 或 VPN background task; -- Provider activation 来自 `vcore.dll`; -- Manifest 包含 `networkingVpnProvider` 和 `runFullTrust`; -- 不使用产品级 loopback exemption; -- 安装或升级前必须断开活动 VPN,版本必须递增。 - -当前实测范围和未完成发布门禁见 [验收矩阵](acceptance.md)。 diff --git a/docs/windows-vpn.md b/docs/windows-vpn.md index 1fb8d76..42d7827 100644 --- a/docs/windows-vpn.md +++ b/docs/windows-vpn.md @@ -147,14 +147,6 @@ Provider 是物理网络状态的唯一权威,并订阅 `NetworkStatusChanged` 会合记录只包含协议版本、Session token、相对对象路径和固定管道名称,不包含 YAML、backend 描述、参数、secret、PID、物理绑定或任意文件路径。Provider 是唯一发布者和清理者。 -## 可选 session backend - -`startVpn` 可以携带一个只描述进程的 `sessionBackend`。它包含 `1..=8` 个有序项目,每项只有 package-relative `.exe` 路径和 argv 数组。Bridge 把该描述与 VCore YAML 一同发布为 `vcore-session-v2:` Session Snapshot;路径或参数变化会改变 token,活动会话不能热换。 - -Session Host 为全部进程创建一个设置了 kill-on-close 的 Job Object,使用 suspended create 在进程执行前完成 Job assignment。任一进程退出都会停止 VCore、清理同 Job 进程树并让 Provider 失败关闭;显式 Stop 在 `Stopped` 前完成同样清理。前台宿主退出不影响它们。 - -第一版只监督进程存活,不包含 port、UDP、协议 readiness、heartbeat、restart、environment 或 working directory。进程仍存活时,本地 SOCKS 或其他业务流失败只影响当前流。 - ## 安装包与 profile 安装包必须包含: @@ -166,13 +158,13 @@ vcore-windows-vpn-host.exe vcore-windows-session-host.exe ``` -- manifest 只有一个主 Application,三个可执行参与者相互独立; +- manifest 只有一个主 Application,三个可执行参与者相互独立;产物架构一致,Rust 使用静态 CRT; - Session Host 是 `windows.fullTrustProcess` extension,不显示在应用列表,也不注册 StartupTask 或 URI; - Provider 的 `windows.backgroundTasks` extension 显式使用 `windowsApp + appContainer`; - Provider activation class 来自 `vcore.dll`; - 同一 package 只维护一个 `VCore` VPN profile; - custom configuration 是最大 4 KiB 的严格 JSON,只含修订版 4、Session token、顶层 IPv6 开关、四个网络地址和完整 policy; -- Session Snapshot 是 `LocalState/vcore/windows/sessions/.json`,覆盖 YAML、可选进程顺序、路径和参数; +- Session Snapshot 是 `LocalState/vcore/windows/sessions/.json`,revision 2 覆盖完整 YAML、可选进程顺序、路径和参数;读取验证大小、普通文件、reparse point、规范 JSON、摘要和每个 executable。参数引用的文件由宿主保持存在且不可变,VCore 不读取其内容; - 活动 Session token、IPv6 开关、网络地址或 policy 不同时必须先显式 Stop,不能热切换; - 安装包更新只能在 VPN 已断开时进行,并要求版本递增。 @@ -192,6 +184,150 @@ vcore-windows-session-host.exe 当前实测范围和未完成平台门禁见 [验收矩阵](acceptance.md)。 +## 参与者与所有权 + +```text +前台宿主(完全信任) + ├─ VCoreInvoke + └─ VCoreWindowsVpnInvoke + ├─ Session Snapshot / profile + └─ ConnectProfileAsync + │ Windows 激活 + ▼ +vcore-windows-vpn-host.exe + vcore.dll(AppContainer) + ├─ VpnChannel / 路由 / DNS / 物理网络 + ├─ VpnPacketBuffer / 有界回调队列 / 失败关闭 + └─ FullTrustProcessLauncher + │ 无参数激活 + ▼ +vcore-windows-session-host.exe + ├─ 校验不可变 Session Snapshot + ├─ 可选 Windows session backend / Job Object + ├─ PreparedCore / RunningCore + ├─ DNS / 规则 / GeoData / 嗅探器 / select 代理组 + ├─ 统一出站图 + └─ 运行时 Controller(TUN 流量 / 代理组选择) + ▲ + └─ 同包控制管道 + 数据管道 +``` + +| 参与者 | 拥有 | 不拥有 | +| --- | --- | --- | +| 前台宿主 | 用户命令、会话记录、UI 状态 | TUN 运行时、包通道、Provider 状态 | +| Windows 桥接 | Session Snapshot、profile、连接/断开命令 | 数据包、代理流、Session Host 进程 | +| Session Host | 单次 VCore 运行时、可选 session backend、Controller、GeoData、包客户端 | `VpnChannel`、路由、进程业务配置 | +| Provider | `VpnChannel`、WinRT 缓冲区、路由、物理绑定、管道服务端、网络监控、Session Host 激活 | YAML、代理图、Controller、GeoData、backend 描述 | +| SOCKS 服务 | 自身监听器、外层 socket 和绕过策略 | VCore 代理图和 Windows profile | + +Session Host 每次连接新建一个进程,不常驻、不复用运行时,也不处理 URI 或 StartupTask。 + + +## 启动顺序 + +1. 前台宿主调用 `startVpn(configYaml, networkSettings, policy, sessionBackend?)`。 +2. 桥接验证配置、四个地址、policy 和进程描述,发布不可变 Session Snapshot,并把解析后的顶层 IPv6 开关和 policy 写入 profile configuration。 +3. 桥接写入单一 VPN profile 并调用 `ConnectProfileAsync`;它不启动或持有 Session Host。 +4. Windows 激活 AppContainer Provider。 +5. Provider 从 profile configuration 取得权威 token,选择物理网络绑定并准备基础资源。 +6. Provider 清理陈旧会合记录,通过无参数 `FullTrustProcessLauncher` 激活 Session Host。 +7. Session Host 不读取动态命令行参数,等待 Provider 会合记录。 +8. Provider 创建控制/数据管道服务端并原子发布会合记录。 +9. Session Host 严格解析会合记录,把其中的 token 作为候选值,构造限定对象路径并连接两条管道。 +10. Session Host 发送 `SessionHello`;Provider 把候选 token 与 profile token 精确比较后返回 `ProviderHello` 和不可变物理绑定。 +11. Session Host 验证 `ProviderHello` 回传同一 token,之后才读取 Snapshot;若存在 backend,则用一个 kill-on-close Job Object 按顺序启动全部进程。 +12. Session Host 准备并启动完整 VCore 运行时、静态代理组状态和 Controller。 +13. Session Host 确认受管进程尚未退出后返回 `RuntimeReady`。 +14. Provider 调用 `StartWithMainTransport` 并启动失败关闭监视器。 +15. 连接成功后,桥接向前台宿主返回当前系统 VPN 状态。 + +任一步失败都必须关闭包通道并收敛为 Disconnected,只返回有界脱敏错误;未完成握手的 Session Host 最多等待 15 秒后退出。 + +Session Host 的路径读取和启动失败日志必须在同一次 WinRT 初始化周期内完成;`RoUninitialize` 后不能再次查询缓存的 `ApplicationData` factory。没有 package identity 的裸启动快速、安全退出,不进入握手、不启动 backend,也不回退到普通用户数据目录。 + + +## 会合记录 + +`LocalState/vcore/windows/rendezvous.json` 最大 4 KiB: + +```json +{ + "protocolVersion": 1, + "snapshotToken": "vcore-session-v2:...", + "objectPath": "AppContainerNamedObjects\\S-1-15-2-...", + "controlLeaf": "VCore.Vpn.Control.v1", + "dataLeaf": "VCore.Vpn.Data.v1" +} +``` + +- Provider 是唯一发布者和清理者; +- 使用同目录暂存文件和原子重命名; +- 只接受规范 token、AppContainer 相对路径和固定 leaf; +- 非普通文件、reparse point、超限或未知字段都会失败关闭;候选 token 与 profile token 的绑定只在双向握手中完成; +- 握手完成后删除,新连接前清理断开状态下的陈旧记录。 + + +## 控制协议 + +```text +u32 大端序 JSON 长度 +严格 UTF-8 JSON +``` + +- 单帧最大 16 KiB; +- DTO 拒绝未知字段、错误版本和错误顺序; +- 错误码最大 128 字节,脱敏信息最大 4 KiB; +- 不传 YAML、Controller secret、SOCKS 凭据、日志或 Invoke 请求。 + +版本 1 消息: + +```text +SessionHello { version, snapshotToken } +ProviderHello { version, snapshotToken, physicalBinding } +RuntimeReady { version } +RuntimeFailed { version, code, redactedMessage } +Stop { version, packetCounters } +Stopped { version, packetCounters } +``` + +启动超时 15 秒,有序停止确认超时 10 秒。活动包流没有空闲超时或 heartbeat。 + + +## Windows session backend + +`sessionBackend` 可以省略;存在时包含 `1..=8` 个进程。每个进程只声明: + +```json +{ + "executableRelativePath": "bin\\proxy-core.exe", + "arguments": ["run", "--mode", "vpn"] +} +``` + +- executable 必须是 package installed location 内不经过 reparse point 的规范 `.exe` 相对路径; +- argv 项数、单项大小、总大小和最终 UTF-16 command line 均有界; +- 不经过 shell,不展开环境变量,工作目录固定为 package installed location; +- Session Host 使用 `CreateProcessW(CREATE_SUSPENDED)`,先加入设置了 `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` 的 Job,再恢复主线程; +- 所有进程都是关键进程;任一退出都会停止 VCore、终止同 Job 中的其余进程并使 Provider 失败关闭; +- Stop 先停止 VCore,再终止 Job,确认活动进程归零后才返回 `Stopped`; +- 第一版不提供 port、UDP、readiness、heartbeat、restart、environment、working directory 或单进程控制; +- `RuntimeReady` 只表示进程仍存活且 VCore 已启动,不表示进程内部协议已就绪。 + + +## Controller 与本地 SOCKS + +- Controller 由 Session Host 监听完全信任的回环地址; +- `RuntimeReady` 前必须完成绑定,失败则连接失败; +- 前台宿主持有配置中的端口和 secret,并调用 `GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 或 `PUT /proxies/{name}`; +- 配置代理组与 Controller 时 secret 必填并保护全部路由;仅有 TUN 流量 Controller 时 secret 仍可省略; +- 代理组选择属于 Session Host 中实际 Running Session 的内存状态,不经过 Windows bridge、Provider 控制管道或 Session Snapshot 写回; +- 成功切换只影响之后新建的物理 TCP、UDP 和 DNS transport,不迁移既有连接、UDP association、DNS 状态或 TCP pool,也不执行自动 failover; +- 宿主如需跨 VPN session 保留选择,必须自行持久化,并在下次 `startVpn` 的 YAML 中注入对应 `default-selected`; +- 前台宿主退出后 Controller 和运行时继续,重新启动后恢复查询; +- Stop 关闭 Controller,销毁本次代理组选择;下一会话重新采用 YAML 初始选择并从零计数; +- 回环 SOCKS5 是普通 VCore 出站;其服务可以由外部宿主管理,也可以恰好运行在 session backend 中,但 VCore 不从 backend 描述推断端口或 readiness; +- 单个 SOCKS 流失败不停止 VPN。 + + ## 官方 API - [`IVpnPlugIn`](https://learn.microsoft.com/uwp/api/windows.networking.vpn.ivpnplugin) diff --git a/docs/xhttp.md b/docs/xhttp.md new file mode 100644 index 0000000..c641ec0 --- /dev/null +++ b/docs/xhttp.md @@ -0,0 +1,105 @@ +# XHTTP 与 VLESS sing-mux + +XHTTP/sing-mux 使用严格字段与类型;未知、null、无效或被忽略的组合在 IO 前拒绝。 +REALITY、JLS、静态 ECH 的身份规则见 [VLESS](vless.md),实际验证边界见[验收说明](acceptance.md)。 + +## HTTP 版本和连接模式 + +`network: xhttp` 使用现有 Dialer、上游图、保护接口与 SecurityClient,不另建系统解析器或裸 socket。`alpn: [http/1.1]` 选择 H1;空列表、`[h2]` 或包含 H1/H2 的列表选择 H2;只有独占 `[h3]` 选择 H3。不会静默降级 HTTP 版本。 + +H1/H2 可使用明文、标准 TLS 1.3、REALITY(经典默认、显式混合模式)或 JLS。H3 必须使用标准 TLS 1.3,不允许同腿明文、REALITY 或 JLS。QUIC 收发走受控数据报,上游有效预算小于 1200 字节即拒绝,不绕开代理、protect 或接口绑定;路径 MTU 探测关闭。 + +`xhttp-opts.mode` 接受 `auto`(默认)、`packet-up`、`stream-up`、`stream-one`。auto 在 REALITY 无下载配置时选择 stream-one,有下载配置时选择 stream-up,其余选择 packet-up。stream-one 是一条双工 POST,不允许 `download-settings`;其余模式以同一会话 ID 关联上传与下载。`path` 默认 `/`,`host` 显式非空优先,否则使用该腿 servername/server;IPv6 authority 使用方括号。主腿与下载腿的 host 均禁止 userinfo(`@`)。 + +## 请求字段 + +区间字段均为字符串 `"N"` 或 `"N-M"`,不接受数字、负数、倒置区间、空串或溢出。每次采样保持在闭区间内。 + +| 字段 | 默认及约束 | +| --- | --- | +| `headers` | 字符串 map;最多 100 项 / 8 KiB。禁止 Host、定界/逐跳头、CRLF、大小写重复及与生成字段冲突。值不进入 Debug | +| `no-grpc-header` | false;true 关闭流式请求的 application/grpc,packet-up 不接受 true | +| `x-padding-bytes` | `"100-1000"`,1–4096 | +| `x-padding-obfs-mode` | false:固定 Referer 中的 `x_padding`、queryInHeader、repeat-x | +| `x-padding-placement` | 混淆开启时必填:queryInHeader/header/query/cookie | +| `x-padding-key` | 混淆 queryInHeader/query/cookie 时必填;header 不使用此字段 | +| `x-padding-header` | 混淆 header/queryInHeader 时必填合法头名;其他位置不使用 | +| `x-padding-method` | repeat-x;开启混淆后也可 tokenish,按 HPACK 编码后的长度生成 | +| `uplink-http-method` | POST,也接受 PUT/PATCH/DELETE;不将 GET 或 OPTIONS 用于上传 | +| `session-placement` / `session-key` | path(不填写 key);header 默认 X-Session,query/cookie 默认 x_session | +| `session-table` | 空:32 字符随机 hex;uuid:标准 UUID v4;或预定义/自定义去重 ASCII 字符表 | +| `session-length` | 字符表模式默认 `"16-32"`,1–128;全部长度构成的命名空间至少 2^31。空表/uuid 不接受显式长度 | +| `seq-placement` / `seq-key` | 仅 packet-up;path,或 header 默认 X-Seq、query/cookie 默认 x_seq | +| `uplink-data-placement` | body,auto 归一 body;header/cookie 仅用于 packet-up | +| `uplink-data-key` | header/cookie payload 必填,body 不接受 | +| `uplink-chunk-size` | header/cookie 默认 `"0"` 自适应;显式区间 64–8192。自适应 header 3072–4096、cookie 2048–3072 | +| `sc-max-each-post-bytes` | packet-up,`"1000000"`,1–16 MiB;这是 POST 上限,不是预分配缓冲大小 | +| `sc-min-posts-interval-ms` | packet-up,`"30"`,0–60000 且最大值须为正;允许 `"0-N"`,不允许纯零 | + +session/sequence 均支持 path/query/header/cookie。键使用至多 64 字节的 HTTP 安全 token,禁止互相冲突以及覆盖 padding、payload 或既有 query/header。字符表预定义值包括 `ALPHABET`、`Alphabet`、`alphabet`、`BASE36`、`base36`、`Base62`、`HEX`、`hex`、`number`。 + +路径遵循 Mihomo:session 或 sequence 任一放在 path 时补尾斜杠,否则保留显式路径。原生 Xray 的 handler 始终补尾斜杠;因此与 Xray 互通且两项均不在 path 时,须显式配置带尾斜杠的路径(例如 `/proxy/`)。这是对端路径配置差异,不通过修改 VCore 的默认规范化规则解决。 + +header/cookie payload 使用无填充 Base64URL,并按实际请求预算拆块;完整请求(URI、生成字段和自定义头)不超过 16 KiB / 128 项。业务数据按有界块处理,不把整个 POST 限额、对端声明或业务流读入无界 Vec。 + +packet-up 在发送间隔内聚合小块写入,而不是把每个 write 变成一个 POST。最多保留一个待发送批次和一个在途批次,每批最多 64 KiB,并与本连接采样的 POST 上限取较小值;头部 payload 仍按请求预算拆分。write 完成只表示数据已进入有界缓冲,flush 等待对应 POST 确认;计时器无需调用方再次读写即可发送。shutdown 立即尝试 flush 未发送批次,卡住的 POST 仍受一秒上限控制。计时器/上传任务归节点所有,Stop 同步等待。 + +## 独立下载腿 + +`download-settings` 缺省和 `{}` 不等价:存在即启用独立下载连接/池,但仍共用一次 VLESS 握手、会话 ID 和建链上下文。两腿必须汇聚到**同一个原生 XHTTP handler 的会话表**;两个独立 listener 不能构成共享会话。 + +可覆盖 `server/port/tls/servername/alpn/host/path/headers`、`client-fingerprint`、普通 TLS 的 `skip-cert-verify/name-cert-verify/fingerprint/certificate/private-key/ech-opts`、`reality-opts`、`jls-opts` 和 `reuse-settings`。 + +- 缺省叶字段继承主腿。headers 是整体替换,`{}` 清空;显式空 host 恢复下载认证名/server 推导。 +- `skip-cert-verify: false` 必须覆盖主腿 true;空 name-cert-verify/fingerprint 清除对应 override/pin。 +- `client-fingerprint` 缺省继承,显式名称覆盖,`none` / 空串清除。下载腿切到明文或 H3 前必须清除继承的已启用 profile;它不属于证书策略,不因切换 TLS/REALITY 自动清除。七值/四模板见 [TLS 指纹](tls-client-fingerprint.md)。 +- certificate/private-key 必须配对替换或同时空串清除;PEM 与密钥匹配在 IO 前检查。 +- reality-opts 缺省继承整个对象,`{}` 清除;非空对象必须提供 public-key,short-id 缺省空、support-x25519mlkem768 缺省 false,不按叶合并旧对象。仅提供混合开关不等同空对象,仍须提供 public-key。null 拒绝。 +- jls-opts 缺省继承整个身份,非空对象必须完整替换 username/password,`{}` 清除;同腿与 REALITY 和证书策略互斥,切换时须显式清除旧对象。JLS 仅 H1/H2,实际 TLS1.3,无恢复;完整约束见 [JLS](vless.md#jls)。 +- ech-opts 缺省继承、非空对象整体替换,启用必须同时提供 enable=true 和静态 config;`{}` 清除。仅标准 TLS,可用于 H1/H2/H3。没有动态查询或自动重试;切换到 REALITY/JLS/明文前必须显式清除继承的 ECH。 +- 切换到明文或 REALITY 不会悄悄丢弃继承的证书策略;须显式清除冲突字段。每条腿独立重新校验最终安全配置。 +- 独立地址/端口在 prepare 时分别准备;两腿共享原绝对期限和每个上游组的选择快照,下载失败不会另起超时预算或绕过原图。 + +## HTTP transport 复用 + +`reuse-settings` 缺省禁用节点池;`{}` 启用默认池。下载配置缺省继承整个主腿 reuse 对象,显式对象整体替换,未填写项回到零,不逐字段继承。 + +| 字段 | 语义 | +| --- | --- | +| `max-concurrency` | 候选 transport 的并发挑选阈值,默认 `"0"` | +| `max-connections` | 优先扩到的候选数,默认 `"0"`;全部忙时仍可扩容,不是全节点硬上限 | +| `c-max-reuse-times` | 复用次数预算,默认 `"0"`;耗尽后不再分配新流 | +| `h-max-request-times` | 按领取 transport 扣减,默认 `"0"`;不按每个 packet-up POST 扣减 | +| `h-max-reusable-secs` | 按物理 transport 年龄退役,默认 `"0"`;活动逻辑流继续 | +| `h-keep-alive-period` | 整数秒;0 为 H2 45 秒 / H3 10 秒,负数禁用,正数指定周期;H1 仅接受 0 | + +前五项接受 0–i32::MAX 的字符串区间。h-max-request-times 和 h-max-reusable-secs 只有最大值为零才禁用;例如 `"0-1"` 采样到零仍按零预算或立即到期处理。c-max-reuse-times 则与 Mihomo 一样,采样值为零即不启用该项限制。两腿池与计数独立。已建物理连接保持原上游选择;创建新连接时才使用当前选择。H1 仅复用完整结束的上传请求连接,不复用取消的下载 GET。节点空闲缓存有界;它不构成业务流数量额度。 + +## VLESS sing-mux + +节点级 `smux` 接受 enabled、protocol、max-connections、min-streams、max-streams、padding、only-tcp。enabled 默认 false;protocol 默认 h2mux,也支持 smux v1/yamux。计数是 0–i32::MAX 整数;正 max-connections 与正 max-streams 互斥。padding、only-tcp 默认 false。Vision 与启用的 sing-mux 互斥。 + +空闲物理会话优先复用;max-connections>0 分支按连接数量与 min-streams 扩容,否则按 max-streams 阈值复用。max-connections=max-streams=0 时 min-streams 归一为 8,但不会将其解释为所有调度分支的硬额度。单流关闭不关闭兄弟流,节点 Stop 才取消和等待所有自有驱动。 + +每条逻辑流的状态字节沿用该次建链的绝对期限;读取前先检查超时,状态已缓冲也不例外。 +物理连接复用不重置期限;状态已确认后的业务读取不受原建链期限限制。 + +smux 单流 shutdown 先等待已接受帧写入并 flush,再安排 FIN;h2mux 同样等待数据 +到达上游 IO 的 flush 边界再 reset。节点 Stop 或 Drop 可取消待写,不承诺异常终止交付。 + +yamux 的内部资源边界是单物理连接累计分配 64 个流后退役;旧流继续,新请求另建连接,空闲即回收。该边界避免逻辑取消与库内 reset 回收不同步时误关兄弟流,不是全节点额度,详见 [资源策略](runtime-resource-policy.md)。 + +sing-mux 的外层是普通 VLESS TCP 到约定目标,**不是 VLESS CommandMux/XUDP**。only-tcp=false 时 UDP 使用 mux 的逐包地址格式,经受控解析处理域名;true 时保留所选 raw/XUDP/packetaddr 编码。`udp: false` 始终禁止业务 UDP。padding 覆盖握手及最初 16 个物理写入帧;接收增量解析和丢弃 padding,不按声明长度无限分配。 + +## 关闭、安全与验收边界 + +XHTTP 按 Mihomo 整条逻辑连接关闭:shutdown 先尝试送完已接受的上传,再取消下载, +所有模式的待决上传最多等待一秒;超时或节点 Stop 可以取消,异常终止不承诺交付。 +H1/H2 flush 等待已接受的 payload 到达所供应 IO 的 flush 边界;H3 flush 等待上传任务 +写入 QUIC,shutdown 进一步等待该请求上传的 QUIC 确认,再释放物理连接租约。 +这些等待不依赖下载响应。需要完整回复的应用必须在关闭前读完;不承诺 TCP 半关闭 +后继续收到尾包。复用只延长物理 transport 生命周期,不改变单个逻辑连接的关闭范围。 + +H3 的 QUIC 驱动、数据报适配器和后台任务归节点所有。Stop 先执行有界关闭交换,再取消/join 内部任务和上游 IO,不把 Drop 或“等一会儿资源消失”当作完成屏障。 + +H1/H2 原生验证优先使用官方 latest Mihomo;H3 使用官方 latest Xray。不被 Xray 直接解码的 packetaddr/sing-mux 使用明确标注的 Xray XHTTP → Mihomo VLESS 分层拓扑;mTLS 使用已批准的 xcaddy/Caddy H3 网关,后端仍是同一个 Xray handler。分层成功不改写直接对端失败。所有服务端与原站在隔离容器中,配置和私钥不入 Git;原生数据、公共入口、资源、跨平台构建和阶段签收分别记录。 diff --git a/example/windows-uwp/README.md b/example/windows-uwp/README.md index 8d73b8b..c3b7dab 100644 --- a/example/windows-uwp/README.md +++ b/example/windows-uwp/README.md @@ -181,7 +181,7 @@ if (response != nullptr) { VCore 会校验 YAML、发布 `vcore-session-v2:` 内容寻址 Session Snapshot,并把 token、解析后的顶层 IPv6 开关、四个地址和 policy 写入最大 4 KiB 的 profile custom configuration。调用方不要自行创建另一个 `VpnPlugInProfile` 或维护第二份 Snapshot。 -需要让 Session Host 同会话监督 package-local 进程时,可以额外提交 `sessionBackend.processes`;每项只有 `executableRelativePath` 和 `arguments`。第一版不管理端口、UDP、readiness 或进程业务配置,完整契约见 [Windows 会话运行时](../../docs/windows-session-runtime.md)。本 demo 不携带 backend。 +需要让 Session Host 同会话监督 package-local 进程时,可以额外提交 `sessionBackend.processes`;每项只有 `executableRelativePath` 和 `arguments`。第一版不管理端口、UDP、readiness 或进程业务配置,完整契约见 [Windows 会话运行时](../../docs/windows-vpn.md)。本 demo 不携带 backend。 ## Manifest 契约 @@ -253,6 +253,6 @@ broker 负责 JSON bridge、命令串行化和结果回传。UWP UI 不接触 YA 详细内部契约见: - [`../../docs/windows-vpn.md`](../../docs/windows-vpn.md) -- [`../../docs/windows-session-runtime.md`](../../docs/windows-session-runtime.md) +- [`../../docs/windows-vpn.md`](../../docs/windows-vpn.md) - [`../../docs/invoke-api.md`](../../docs/invoke-api.md) - [`../../docs/acceptance.md`](../../docs/acceptance.md) diff --git a/include/module.modulemap b/include/module.modulemap index 0d6b929..ae1b7cf 100644 --- a/include/module.modulemap +++ b/include/module.modulemap @@ -1,4 +1,5 @@ module LibVCore { header "vcore.h" + link "c++" export * } diff --git a/readme/README.ru.md b/readme/README.ru.md index da22cfc..619942f 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -4,11 +4,11 @@ English · 简体中文 · Русский

-VCore — независимое клиентское прокси-ядро на Rust, не привязанное к конкретному хост-приложению. Через строгую YAML-конфигурацию и Invoke API v5 оно предоставляет граф прокси, статические группы `select`, DNS, правила маршрутизации, GeoData, HTTP/SOCKS5 listeners, плоскость данных TUN и loopback Controller. Внутренняя ревизия схемы конфигурации — 14; она присутствует только в ответе `version` и `buildIdentity`, но не записывается в YAML. +VCore — независимое клиентское прокси-ядро на Rust, не привязанное к конкретному хост-приложению. Через строгую YAML-конфигурацию и Invoke API v5 оно предоставляет граф прокси, статические группы `select`, DNS, правила маршрутизации, GeoData, HTTP/SOCKS5 listeners, плоскость данных TUN и loopback Controller. Внутренняя ревизия схемы конфигурации — 27; она присутствует только в ответе `version` и `buildIdentity`, но не записывается в YAML. ## Возможности -- Исходящие подключения: VLESS + XHTTP + TLS/REALITY, SOCKS5 CONNECT/UDP ASSOCIATE, AnyTLS TCP/UoT и DIRECT. +- Исходящие подключения: [VLESS TCP/WS/gRPC/HTTP/H2/XHTTP, TLS/REALITY и Vision](../docs/vless.md), SOCKS5 CONNECT/UDP ASSOCIATE, AnyTLS TCP/UoT, Shadowsocks 2022, [Trojan TCP/UDP через TLS/WS/gRPC](../docs/outbounds.md#trojan), [VMess AEAD через TCP/WS/gRPC/HTTP/H2](../docs/outbounds.md#vmess-aead), [Hysteria2 TCP/UDP, управление скоростью, Salamander и смена портов](../docs/outbounds.md#hysteria2) и DIRECT. - Цепочки прокси: `dialer-proxy` образует ориентированный ациклический граф произвольной длины. Если узел A указывает на B, физический путь имеет вид `client -> B -> A -> target`. - Группы прокси: статические группы `select` сохраняют порядок участников; участниками могут быть конкретные узлы, вложенные группы, `DIRECT` и `REJECT`. Выбор текущей session можно менять через Controller. `dialer-proxy` принимает узел или группу; DIRECT в группе верхнего уровня подключается к заранее разрешённому серверу текущего узла. - Маршрутизация: последовательно применяются `DOMAIN`, `DOMAIN-SUFFIX`, `DOMAIN-KEYWORD`, `GEOSITE`, `GEOIP`, `IP-CIDR`, `IP-CIDR6`, `DST-PORT`, `NETWORK` и завершающее правило `MATCH`. @@ -17,6 +17,7 @@ VCore — независимое клиентское прокси-ядро на - Listeners: HTTP CONNECT/forward с аутентификацией и маршрутизацией каждого запроса, потоковыми телами, Keep-Alive и Upgrade; SOCKS5 CONNECT и UDP ASSOCIATE с авторизацией через TCP. По умолчанию доступ локальный без аутентификации; для LAN требуется общая пара имени пользователя и пароля. - GeoData: VCore управляет `geosite.dat` и `geoip.dat` в `dataDir/geodata`, загружает их по запросу и может обновлять через цепочку прокси. - Измерение задержки: `measureDelay` принимает за вызов 1–5 конфигураций node-only, использует до пяти частных worker и сохраняет порядок входных данных в результатах. +- TLS: независимый pin сертификата и четыре необязательных [шаблона ClientHello](../docs/tls-client-fingerprint.md); [VLESS JLS](../docs/vless.md#jls) сохраняет полную аутентификацию TLS. [Статический ECH](../docs/vless.md#静态-ech) использует явную конфигурацию основного и download-соединения VLESS, без динамических DNS-запросов или отката при ошибке. ## Конфигурация @@ -72,6 +73,8 @@ Authorization: Bearer `GET /traffic` возвращает одноразовый TUN snapshot `up/down/upTotal/downTotal`. Конечные точки групп читают и изменяют выбранного прямого участника статической группы `select`; успешное изменение влияет только на новые физические TCP-, UDP- и DNS-transports текущей session. Оно не переносит существующие соединения, UDP associations, состояние DNS или pooled TCP transports и не выполняет автоматический failover. Controller, управляющий группами, требует один Bearer secret для всех маршрутов и может работать без TUN. Подробности — в [`docs/controller-api.md`](../docs/controller-api.md). +Аутентифицированная сессия Hysteria2 сохраняет выбранный upstream при смене портов. Замена socket в той же QUIC-сессии не считывает выбор группы заново; новый выбор применяется только к новой аутентифицированной сессии. + ## Платформы | Платформа | Плоскость данных | Статус | @@ -93,7 +96,7 @@ packet queue 256 ordinary event / UDP response 128 DNS ingress / DNS response 128 / 128 TCP buffer 32 KiB per direction -TLS / XHTTP buffer 64 KiB +rustls / XHTTP buffer 64 KiB DNS typed cache 256 entries DNS opaque cache 64 entries / 256 KiB GeoData allocation capacity 8 MiB @@ -105,22 +108,11 @@ TCP sessions, обычные UDP associations, half-open connections, outbound h ## Документация -- [Оглавление документации](../docs/README.md) -- [Контракт конфигурации](../docs/config.yaml) +- [Documentation index](../docs/README.md) +- [Configuration](../docs/config.yaml) - [Invoke API](../docs/invoke-api.md) -- [HTTP proxy inbound](../docs/http-proxy.md) -- [SOCKS5 proxy inbound](../docs/socks5-proxy.md) -- [AnyTLS outbound](../docs/anytls.md) -- [Клиентский протокол REALITY V1](../docs/reality-wire-protocol.md) -- [Зависимость rustls REALITY и требования к выпуску](../docs/rustls-reality-release.md) -- [Runtime Controller](../docs/controller-api.md) -- [ICMP и DNS в TUN](../docs/tun-icmp-dns.md) -- [Правила и assets GeoData](../docs/geodata.md) -- [Платформенный слой TUN](../docs/tun-platform.md) -- [Граница платформы Windows VPN](../docs/windows-vpn.md) -- [Runtime сессии Windows](../docs/windows-session-runtime.md) -- [Политика ресурсов runtime](../docs/runtime-resource-policy.md) -- [Матрица приёмки](../docs/acceptance.md) +- [Build and test](../scripts/README.md) +- [Acceptance boundaries](../docs/acceptance.md) ## Пример @@ -130,7 +122,7 @@ TCP sessions, обычные UDP associations, half-open connections, outbound h ```bash cargo fmt --all -- --check -cargo test --all-features --all-targets +uv run --project scripts --locked vcore-scripts check core --profile debug cargo clippy --locked --all-features --lib --bins -- -D warnings cargo test --manifest-path crates/vcore-netstack/Cargo.toml --all-targets cargo clippy --manifest-path crates/vcore-netstack/Cargo.toml --all-targets -- -D warnings @@ -158,7 +150,8 @@ uv run --project scripts --locked vcore-scripts build windows - [smoltcp](https://github.com/smoltcp-rs/smoltcp), [clash-rs](https://github.com/Watfaq/clash-rs) и [netstack-smoltcp](https://github.com/automesh-network/netstack-smoltcp): userspace IP stacks и TUN netstacks. - [windows-rs](https://github.com/microsoft/windows-rs), [UWP VPN Plugin Sample](https://github.com/microsoft/UwpVpnPluginSample), [wireguard-uwp-rs](https://github.com/luqmana/wireguard-uwp-rs), [Maple](https://github.com/YtFlow/Maple) и [YtFlowCore](https://github.com/YtFlow/YtFlowCore): Windows VPN, активация WinRT и packet flow. - [Xray-core](https://github.com/XTLS/Xray-core), [Mihomo](https://github.com/MetaCubeX/mihomo) и [Leaf](https://github.com/eycorsican/leaf): прокси-протоколы, маршрутизация, архитектура TUN и interoperability references. -- [rustls](https://github.com/rustls/rustls): TLS-зависимость и upstream сопровождаемого VCore REALITY fork. +- [rustls](https://github.com/rustls/rustls): TLS без fingerprint, QUIC и общая проверка сертификатов WebPKI. +- [boring](https://github.com/cloudflare/boring) / [BoringSSL](https://boringssl.googlesource.com/boringssl/): именованные профили TLS ClientHello и расширение classic REALITY в собственном fork VCore. ## Лицензия diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 37b7188..334dab0 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -4,11 +4,11 @@ English · 简体中文 · Русский

-VCore 是独立且不绑定特定宿主应用的 Rust 客户端代理 core。它通过严格 YAML 配置和 Invoke API v5 提供代理图、静态 `select` 代理组、DNS、规则、GeoData、HTTP/SOCKS5 listener、TUN 数据面与回环 Controller。内部配置 schema revision 为 14;revision 只出现在 `version` 响应和 `buildIdentity` 中,不写入 YAML。 +VCore 是独立且不绑定特定宿主应用的 Rust 客户端代理 core。它通过严格 YAML 配置和 Invoke API v5 提供代理图、静态 `select` 代理组、DNS、规则、GeoData、HTTP/SOCKS5 listener、TUN 数据面与回环 Controller。内部配置 schema revision 为 27;revision 只出现在 `version` 响应和 `buildIdentity` 中,不写入 YAML。 ## 能力 -- Outbound:VLESS + XHTTP + TLS/REALITY、SOCKS5 CONNECT/UDP ASSOCIATE、AnyTLS TCP/UoT、DIRECT。 +- Outbound:[VLESS TCP/WS/gRPC/HTTP/H2/XHTTP、TLS/REALITY 与 Vision](../docs/vless.md)、SOCKS5 CONNECT/UDP ASSOCIATE、AnyTLS TCP/UoT、Shadowsocks 2022、[Trojan TCP/UDP(TLS/WS/gRPC)](../docs/outbounds.md#trojan)、[VMess AEAD(TCP/WS/gRPC/HTTP/H2)](../docs/outbounds.md#vmess-aead)、[Hysteria2 TCP/UDP、带宽、Salamander 与端口跳跃](../docs/outbounds.md#hysteria2)、DIRECT。 - 代理链:`dialer-proxy` 组成任意长度的有向无环图;节点 A 指向 B 时,物理路径为 `client -> B -> A -> target`。 - 代理组:静态 `select` 组保留有序成员,可包含具体节点、嵌套组、`DIRECT` 与 `REJECT`;当前 session 的选择可通过 Controller 实时修改。`dialer-proxy` 可引用节点或组;上游组的 DIRECT 连接当前节点预解析的服务器。 - 路由:顺序执行 `DOMAIN`、`DOMAIN-SUFFIX`、`DOMAIN-KEYWORD`、`GEOSITE`、`GEOIP`、`IP-CIDR`、`IP-CIDR6`、`DST-PORT`、`NETWORK` 和最终 `MATCH`。 @@ -17,6 +17,7 @@ VCore 是独立且不绑定特定宿主应用的 Rust 客户端代理 core。它 - Listener:HTTP CONNECT/forward,逐请求认证与选路,支持流式正文、Keep-Alive 和 Upgrade;SOCKS5 CONNECT 与 TCP 授权的 UDP ASSOCIATE。默认本机免认证,局域网共享强制使用一组共用账号密码。 - GeoData:VCore 管理 `dataDir/geodata` 下的 `geosite.dat` 和 `geoip.dat`,按需求加载并可通过代理链后台更新。 - 测速:`measureDelay` 单次接收 1–5 份 node-only 配置,使用最多五个私有 worker,结果保持输入顺序。 +- TLS:独立证书 pin 与四种可选 [ClientHello 模板](../docs/tls-client-fingerprint.md);[VLESS JLS](../docs/vless.md#jls)保留完整原生 TLS 认证。[静态 ECH](../docs/vless.md#静态-ech)接受显式的 VLESS 主/下载腿配置,不执行动态 DNS 查询或失败回落。 ## 配置 @@ -72,6 +73,8 @@ Authorization: Bearer `GET /traffic` 返回一次 TUN `up/down/upTotal/downTotal` snapshot。代理组端点读取或修改静态 `select` 组的当前直接成员;成功切换只影响当前 session 后续新建的物理 TCP、UDP 与 DNS transport,不迁移既有连接、UDP association、DNS 状态或 TCP 连接池,也不自动故障转移。Controller 管理代理组时,全部路由必须共用一个 Bearer secret,并且可以不启用 TUN。详见 [`docs/controller-api.md`](../docs/controller-api.md)。 +已认证的 Hysteria2 会话在端口跳跃期间保留上游选择;同一 QUIC 会话替换物理 socket 不重新读取组选择,只有新建认证会话才使用新选择。 + ## 平台 | 平台 | 数据面 | 状态 | @@ -85,42 +88,16 @@ Windows 不使用 fd 模拟层。Provider 只拥有 `VpnChannel`、buffer、rout ## 资源边界 -当前 TUN profile 保留局部结构边界,不按业务 flow 总数做固定 admission: - -```text -raw packet / MTU 1,500 bytes -packet queue 256 -ordinary event / UDP response 128 -DNS ingress / DNS response 128 / 128 -TCP buffer 32 KiB per direction -TLS / XHTTP buffer 64 KiB -DNS typed cache 256 entries -DNS opaque cache 64 entries / 256 KiB -GeoData allocation capacity 8 MiB -``` - -Windows 按 `StartWithMainTransport` 要求宣告 1,400 字节 L3 MTU;1,500 字节仍是跨平台解析上限。 - -TCP session、普通 UDP association、half-open、outbound handshake 和 active DNS transport 按需创建;bounded queue、每流 buffer、wire/parser size、timeout、idle cleanup 和 cache 继续提供结构安全。iOS 35/45 MiB 仅为 best-effort 优化观测,不改变生命周期结果。 +使用局部队列、缓冲、解析上限、期限与所有者取消,不设置全局业务流准入数量。 +共享上限见[资源策略](../docs/runtime-resource-policy.md),专用预算见协议契约;内存遥测不改变生命周期结果。 ## 文档 -- [文档索引](../docs/README.md) -- [配置协议](../docs/config.yaml) +- [Documentation index](../docs/README.md) +- [Configuration](../docs/config.yaml) - [Invoke API](../docs/invoke-api.md) -- [HTTP 代理入站](../docs/http-proxy.md) -- [SOCKS5 代理入站](../docs/socks5-proxy.md) -- [AnyTLS 出站](../docs/anytls.md) -- [REALITY V1 客户端协议](../docs/reality-wire-protocol.md) -- [rustls REALITY 依赖与发布要求](../docs/rustls-reality-release.md) -- [运行时 Controller](../docs/controller-api.md) -- [TUN ICMP 与 DNS](../docs/tun-icmp-dns.md) -- [GeoData 规则与资产](../docs/geodata.md) -- [TUN 平台层](../docs/tun-platform.md) -- [Windows VPN 平台边界](../docs/windows-vpn.md) -- [Windows 会话运行时](../docs/windows-session-runtime.md) -- [运行时资源策略](../docs/runtime-resource-policy.md) -- [验收矩阵](../docs/acceptance.md) +- [Build and test](../scripts/README.md) +- [Acceptance boundaries](../docs/acceptance.md) ## 示例 @@ -130,7 +107,7 @@ TCP session、普通 UDP association、half-open、outbound handshake 和 active ```bash cargo fmt --all -- --check -cargo test --all-features --all-targets +uv run --project scripts --locked vcore-scripts check core --profile debug cargo clippy --locked --all-features --lib --bins -- -D warnings cargo test --manifest-path crates/vcore-netstack/Cargo.toml --all-targets cargo clippy --manifest-path crates/vcore-netstack/Cargo.toml --all-targets -- -D warnings @@ -158,7 +135,8 @@ VCore 的依赖、维护中的 fork、公开 API/协议参考、架构参考与 - [smoltcp](https://github.com/smoltcp-rs/smoltcp)、[clash-rs](https://github.com/Watfaq/clash-rs) 与 [netstack-smoltcp](https://github.com/automesh-network/netstack-smoltcp):用户态 IP stack 与 TUN netstack。 - [windows-rs](https://github.com/microsoft/windows-rs)、[UWP VPN Plugin Sample](https://github.com/microsoft/UwpVpnPluginSample)、[wireguard-uwp-rs](https://github.com/luqmana/wireguard-uwp-rs)、[Maple](https://github.com/YtFlow/Maple) 与 [YtFlowCore](https://github.com/YtFlow/YtFlowCore):Windows VPN、WinRT activation 与 packet flow。 - [Xray-core](https://github.com/XTLS/Xray-core)、[Mihomo](https://github.com/MetaCubeX/mihomo) 与 [Leaf](https://github.com/eycorsican/leaf):代理协议、路由、TUN 架构与互操作参考。 -- [rustls](https://github.com/rustls/rustls):TLS 依赖与 VCore 维护的 REALITY fork 上游。 +- [rustls](https://github.com/rustls/rustls):未启用指纹的 TLS、QUIC 与共享 WebPKI 证书验证。 +- [boring](https://github.com/cloudflare/boring) / [BoringSSL](https://boringssl.googlesource.com/boringssl/):命名 TLS ClientHello profile 与 VCore 自有 fork 中的 classic REALITY 扩展。 ## License diff --git a/scripts/README.md b/scripts/README.md index c17276a..35c00bc 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,144 +1,141 @@ -# VCore Scripts +# 构建与验证 -本目录是由 `uv` 管理的 Python 工程,统一提供 VCore 平台构建、静态检查和可选互操作 demo。所有命令都从 VCore 仓库根目录执行: +所有命令从 VCore 根目录运行,或为 `--project` 显式指定本仓库 scripts。 +Python 工程由 uv/uv.lock 管理,运行时只用标准库;不推断相邻研究仓库。 -```bash -uv sync --project scripts --locked -uv run --project scripts --locked vcore-scripts --help +## 日常检查 + +```sh +uv run --project scripts --locked vcore-scripts check core --profile debug +uv run --project scripts --locked vcore-scripts check core --profile release +uv run --project scripts --locked vcore-scripts check core --profile features +uv run --project scripts --locked python -m unittest discover -s scripts/tests +cargo fmt --all -- --check +uv run --project scripts --locked ruff check scripts +uv run --project scripts --locked ruff format --check scripts +uv run --project scripts --locked vcore-scripts check c-header +uv run --project scripts --locked vcore-scripts check tls-dependencies ``` -运行时只使用 Python 标准库;`ruff` 是由 `uv.lock` 固定的开发依赖。 +`check core --list` 只显示命令。Debug/Release 只执行明确的纯内存/配置/安全回归; +features 检查精简构建、准入和完整生产构建,全目标只编译 `--no-run`。 +每条真正执行的 Cargo 测试须有非空成功结果,失败、超时或未 join 均非零退出。 +**不要执行无过滤的 cargo test --all-features --all-targets**:部分历史 fixture 含宿主监听器。 -## 平台构建 +额外生产/测试代码静态检查与 netstack: -```bash +```sh +cargo clippy --locked --features ffi --lib --bins -- -D warnings +cargo clippy --locked --all-features --all-targets -- -D warnings +cargo test --locked --manifest-path crates/vcore-netstack/Cargo.toml --all-targets +cargo clippy --locked --manifest-path crates/vcore-netstack/Cargo.toml --all-targets -- -D warnings +``` + +C header 检查只编译 C/C++;TLS 检查审查 resolved graph、provider、来源及批准 revision, +不是网络互通。边界见 [TLS 依赖](../docs/tls-dependencies.md)。 + +## 容器互通 + +```sh +uv run --project scripts --locked vcore-scripts check protocol-interop --suite integration --list +uv run --project scripts --locked vcore-scripts check protocol-interop --suite integration --preflight +uv run --project scripts --locked vcore-scripts check protocol-interop --suite integration +uv run --project scripts --locked vcore-scripts check protocol-coverage --suite integration --run-dir target/interop/runs/ +uv run --project scripts --locked vcore-scripts check protocol-coverage --catalog-only +``` + +可执行容器 suite:vmess、vless、xhttp、hysteria2、security、integration。 +`--case` 可重复、`--protocol` 可筛选;子集只证明实际执行的项目,不能签收整套。 +foundations/trojan 保留基础用例和断言,其旧服务端编排尚未全容器化,不能执行; +需要 Trojan 互通时用 integration 中的真实容器路径。 + +文件、用例、事件与报告按协议或验证用途命名,例如 INTEGRATION-PAIR-SS-SS、 +SECURITY-ECH-CHROME;不再提供阶段编号或 --stage 别名。旧报告保持原样,使用 +其记录的 Git revision 复核,不重写旧证据,也不将旧名称解释为新运行结果。 + +执行清单从 Python 的协议定义生成;不再提交重复 cases.json 或规划字段/组合表。 +`--catalog-only` 只检查可执行清单与上限引用,输出 VALID / NOT RUN,不冒充通过。 +`limits.json` 仍与 Rust 常量和边界断言校验。原始事件、命令/退出码、源/锁文件、 +对端身份、artifact hash、资源与清理证据独立复核;缺失/重复/部分结果不能 PASS。 + +每轮新建 target/interop/runs 子目录;不得运行中修改源码。合成凭据/私有日志放临时 +目录并清理,不进入报告。Stop 时和后续静默窗口分别采样;短 tracer 不代替完整长测。 +容器不可用即 BLOCKED,不能退回宿主。完整规则见[测试隔离](../docs/testing-isolation.md)。 + +### 对端下载 + +```sh +uv run --project scripts --locked vcore-scripts download mihomo +uv run --project scripts --locked vcore-scripts download mihomo --target linux-arm64 +``` + +使用官方 latest 下载链接;Mihomo 从 latest/download/version.txt 取得资产名所需版本, +再通过二进制 -v 确认实际版本。不查 API、不固定旧版、不本地编译或静默复用旧缓存。 +同轮对端使用同一 release;下载/解压大小与超时有界,失败终止。产物在 target/interop, +版本、URL、压缩包/二进制 SHA-256 和容器镜像 digest 随结果记录;本地摘要不是官方签名。 + +所有协议端、原站、DNS、提供入口的对照客户端在本轮独占容器中;仅清理本轮所有者, +不全局 stop/prune。默认 Mihomo,明确缺口由官方 Xray/V2Ray/Hysteria/ssserver 补验。 +唯一源码构建例外是获准的 xcaddy/Caddy H3/mTLS 网关,仍导入容器、不修改第三方。 + +### 定向安全验证 + +```sh +uv run --project scripts --locked python -m vcore_scripts.protocol_fingerprint_shape target/interop/runs/ +uv run --project scripts --locked python -m vcore_scripts.protocol_fingerprint target/interop/runs/ --client-fingerprint chrome +uv run --project scripts --locked python -m vcore_scripts.protocol_fingerprint_reference --list +uv run --project scripts --locked python -m vcore_scripts.protocol_fingerprint_reference --run-dir target/interop/runs/ +uv run --project scripts --locked python -m vcore_scripts.protocol_fingerprint_reference --check-run target/interop/runs/ +uv run --project scripts --locked python -m vcore_scripts.protocol_jls target/interop/runs/ +uv run --project scripts --locked python -m vcore_scripts.protocol_encryption target/interop/runs/ +uv run --project scripts --locked vcore-scripts check reality-hybrid --run-dir target/interop/runs/ +``` + +指纹验证的 reference、wire、interop 三层证据及 golden 比对规则见 +[指纹验证](../tests/fingerprints/README.md)。模板、主/下载身份、恢复/过期与容器数据面 +各有独立断言;不从别名、另一个模板或 fork 接口探针推导通过。更多参数读对应 --help。 + +## 平台构建与产物 + +```sh uv run --project scripts --locked vcore-scripts build apple uv run --project scripts --locked vcore-scripts build android uv run --project scripts --locked vcore-scripts build windows +# 正式候选要求干净、已提交的 checkout: +uv run --project scripts --locked vcore-scripts build apple --delivery +uv run --project scripts --locked vcore-scripts check platform-artifacts --manifest dist/apple/vcore-delivery.json +uv run --project scripts --locked vcore-scripts check platform-abi --manifest dist/apple/vcore-delivery.json ``` -- Apple 命令只能在 macOS 运行,输出 `dist/apple/LibVCore.xcframework`。 -- Android 命令在 macOS/Linux 运行,默认输出 `dist/android/{arm64-v8a,x86_64}/libvcore.so`。 -- Windows 命令只能在已安装 Visual Studio C++ 工具的 Windows 运行;命令从系统注册表读取原生 ARM64/x64 处理器架构,通过 `vswhere` 加载对应的 MSVC 环境,验证三项 PE 的 machine type 后输出 `dist/windows/` 下的 DLL、Provider Host、Session Host 和记录 package integration revision、架构及三项 SHA-256 的 `vcore-windows-artifacts.json`。 -- 所有构建都使用 `Cargo.lock`,并检查产物内的 Invoke API v5/config revision 14 身份。 -- 标准 Apple、Android、Windows 构建显式包含两种客户端入站和四种出站,不依赖 `ffi` / `tun` 的传递 feature 来隐式补齐;不包含 `interop-test`。Apple/Android 的自定义 `VCORE_FEATURES` 不得将测试信任注入用于交付。 +- Apple 在 macOS 构建,输出 dist/apple/LibVCore.xcframework,包含 iOS、模拟器、macOS。 + 最终链接 libc++;module map 已声明,直接 C 链接需 -lc++。 +- Android 在 macOS/Linux 构建,输出 dist/android 下两 ABI 的 libvcore.so 及配套 + libc++_shared.so,宿主一起打包。NDK 优先 ANDROID_NDK_HOME,否则 ANDROID_HOME/ndk。 +- Windows 在原生 ARM64/x64、Visual Studio C++ 环境构建,输出配套 DLL/Provider Host/ + Session Host 及架构/摘要。ARM64 需要 clang-cl/clang、Ninja;保持 BoringSSL 汇编启用。 + 契约见 [Windows VPN](../docs/windows-vpn.md)。 -Apple/Android 继续接受现有环境变量: +Apple/Android 可设置 VCORE_BUILD_PROFILE、VCORE_FEATURES、VCORE_APPLE_DIST_DIR、 +VCORE_IOS_DEPLOYMENT_TARGET、VCORE_MACOS_DEPLOYMENT_TARGET、VCORE_ANDROID_NDK_VERSION、 +VCORE_ANDROID_API、VCORE_ANDROID_TARGETS、VCORE_ANDROID_OUTPUT_DIR。默认值查看 builds.py; +交付禁用隐藏覆盖、测试 feature、非 Release profile,标准 feature 集合显式包含所有支持协议。 -| 变量 | 默认值 | -| --- | --- | -| `VCORE_BUILD_PROFILE` | `release`,也可为 `debug` | -| `VCORE_FEATURES` | `ffi,tun,inbound-http,inbound-socks5,outbound-anytls,outbound-socks5,outbound-shadowsocks,outbound-vless` | -| `VCORE_APPLE_DIST_DIR` | `dist/apple` | -| `VCORE_IOS_DEPLOYMENT_TARGET` | `13.0` | -| `VCORE_MACOS_DEPLOYMENT_TARGET` | `10.15` | -| `VCORE_ANDROID_NDK_VERSION` | `28.2.13676358` | -| `VCORE_ANDROID_API` | `24` | -| `VCORE_ANDROID_TARGETS` | `aarch64-linux-android x86_64-linux-android` | -| `VCORE_ANDROID_OUTPUT_DIR` | `dist/android` | +--delivery 绑定 commit/tree、lockfile、API/schema、features、toolchain/SDK/NDK、架构及 +全部文件大小/hash;所有平台在清旧记录或构建前拒绝输出路径及其仓库内祖先目录的 +符号链接和 Windows reparse point(包括 junction),不沿链接删除或写入外部产物。 +开始前清旧记录,失败不签收。Android 交付还会清空标准 dist/android +构建输出,避免纳入开发构建遗留的额外 ABI。platform-artifacts 拒绝缺失/额外文件、 +错架构/身份及缺 C++ runtime;可重复 --manifest,--complete 要求 Apple、Android、 +原生 Windows ARM64/x64,仍只证明构建。--source-dir 须显式指定同一候选 checkout。 +platform-abi 在原生 macOS/Windows 链接/加载并执行 C ABI;Windows 另验 snapshot/ +未打包失败关闭,不创建业务运行时,不证明设备 VPN 或正式安装。 -Android NDK 优先读取 `ANDROID_NDK_HOME`,否则使用 `$ANDROID_HOME/ndk/`。 +## CI 与证据 -`.github/workflows/test.yml` 配置 macOS 的完整/精简协议 feature 检查,以及 Apple 五目标、Android 两 ABI、原生 Windows ARM64/x64 的 Release 构建和短期未签名产物归档。Android job 显式使用 NDK `28.2.13676358`,避免继承 runner 的另一默认版本;Linux runner 只作 Android 交叉构建,不意味着 VCore 支持 Linux 运行。runner 标签依据 [GitHub 官方清单](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)。工作流配置不等于已执行的 CI 或设备验收,通过记录仍见 [验收矩阵](../docs/acceptance.md)。 +Tests 工作流只跑 core Debug/Release、quality/features 和 memory-only netstack; +通用 Python、格式检查只在 quality 执行一次。Release builds 复用 Apple、Android、 +Windows ARM64/x64 构建,每个平台保留产物路径/架构/ABI 检查和未签名 artifacts。 +CI 配置存在不等于已运行或通过;原始 run URL、commit、artifact 有效期随当次记录。 -## 检查 - -```bash -uv run --project scripts --locked vcore-scripts check c-header -uv run --project scripts --locked vcore-scripts check tls-dependencies -uv run --project scripts --locked python -m unittest discover -s scripts/tests -uv run --project scripts --locked ruff check scripts -uv run --project scripts --locked ruff format --check scripts -``` - -`c-header` 在 macOS 使用 `xcrun clang/clang++`,其他平台使用 `PATH` 中的 `clang/clang++`。`tls-dependencies` 直接读取 `cargo metadata`,验证唯一的 OneVCore rustls 0.23.43 来自 GitHub `vcore/reality-0.23` 分支、官方 tokio-rustls 0.26.4、registry ring,并禁止 Watfaq 来源和 TLS 的 AWS-LC/FIPS provider。AWS-LC 仅允许出现在锁定官方 Shadowsocks 1.25.0 → registry shadowsocks-crypto 0.8.0 → aws-lc-rs → aws-lc-sys 链;额外使用方、非官方来源、重复版本和 FIPS 包均失败。该局部例外不更换 TLS/REALITY 的 ring provider。 - -## Windows tun2socks demo - -后续协议互通统一使用下节的 mihomo harness。已有 Xray / anytls-go 脚本及本节的 Windows demo 保留为历史、显式专用入口,不作为新一轮协议互通的默认对端。 - -```powershell -uv run --project scripts --locked vcore-scripts demo windows-tun2socks -uv run --project scripts --locked vcore-scripts demo windows-tun2socks C:\path\to\xray-config.json -``` - -该命令仍是显式互操作验收:它临时构建 `references/Xray-core`,使用已安装的 `VCore.UwpDemo.Dev` 示例 package,并在结束时停止测试 VPN 和删除临时文件。源配置不会被修改。真实配置、凭据和临时访问日志不得提交。 - -## mihomo 协议互通 - -使用独立 mihomo 进程验证公开 YAML → Invoke prepare/start → 实际数据路径。HTTP forward、分块上传、CONNECT 预读和 WebSocket Upgrade 各自验证以下两条路径,共 8 个场景: - -1. VCore HTTP 入站 → VCore SOCKS5 出站 → mihomo → 本机 origin。 -2. mihomo HTTP 入站 → mihomo HTTP 出站 → VCore CONNECT → 本机 origin。 - -SOCKS5 另覆盖 CONNECT / UDP ASSOCIATE × IPv4 / IPv6 × 两个方向,共 8 个场景:VCore SOCKS5 入站 → VCore SOCKS5 出站 → mihomo,以及 mihomo SOCKS5 入站 → mihomo SOCKS5 出站 → VCore SOCKS5 入站。UDP 控制连接、端口学习与真实远端回包一并验证。 - -AnyTLS 使用同一 mihomo 的独立 TLS listener:跳过常规验证、叶 pin、叶 pin + skip + 有序 ALPN 三种公开配置各覆盖 TCP/UoT × IPv4/IPv6 和独立 `measureDelay`,另检查默认不可信证书与错误 pin 的拒绝,共 17 项。证书由 PATH 中的 OpenSSL/LibreSSL 临时生成(RSA 2048,仅测试),因此需要可用的 `openssl` 命令;密钥和证书放在临时 mihomo dataDir,结束即清理。VCore 不开启测试信任注入或 `listeners` 配置。 - -SS 使用三个独立 2022 listener 和合成 PSK,检查 TCP/UDP × IPv4/IPv6/域名及服务器先发,随后检查具体 SOCKS5 上游和嵌套组 / DIRECT 路径。域名仅使用 mihomo 测试 hosts 映射,不访问外网。SS 对端与首跳在不同进程,保留 mihomo 回环保护。 - -该 mihomo 服务端不暴露 EIH 用户配置;`tests/mihomo/eih.rs` 的受控中继独立校验并剥离 AES 的 1/2 层身份头,业务密文仍交给 mihomo,明确区别于原生 EIH 服务端。错误身份/密钥/算法有 TCP/UDP 负例。另通过公开 Invoke 验证各算法的活动 TCP/UDP Stop、绑定失败回滚和独立测速,macOS 记录清理前后的 `/dev/fd` 数量;这些不替代完整压力与物理设备验收。 - -```bash -uv run --project scripts --locked vcore-scripts check mihomo-interop -# 或传入现有独立对端二进制: -bash tests/run_mihomo_interop.sh --binary /absolute/path/to/mihomo -# 跨协议组合、Controller 切组、合成 utun 和重复生命周期: -uv run --project scripts --locked vcore-scripts check mihomo-interop --extended -# 30 分钟持续测试(单次通过不等于完整阶段签收,见下方限制): -uv run --project scripts --locked vcore-scripts check mihomo-interop --extended --soak-seconds 1800 -# macOS 推荐:上游和末端对端使用 Apple Container 独立 Linux VM: -uv run --project scripts --locked vcore-scripts check mihomo-interop \ - --container-binary references/mihomo-linux-arm64 --extended --soak-seconds 1800 -``` - -默认读取 `VCORE_MIHOMO_BIN` 或 `references/mihomo-test`。缺少二进制会明确失败并提示 NOT RUN,不进行下载或空跑。对已有参考 checkout,可自行构建: - -```bash -git -C references/mihomo rev-parse HEAD -go -C references/mihomo build -trimpath -o ../mihomo-test . -``` - -验收时记录源码完整 revision、Go 版本、构建 features 和 harness 输出的二进制版本 / SHA-256,不能只依赖自定义版本字符串。参考代码和二进制是独立研究对端,不加入 VCore 的生产依赖。 - -默认模式四个对端仅开放本机回环,使用临时测试凭据、配置和 dataDir;origin 使用 IPv4/IPv6 回环,不启用系统 TUN,不改变系统代理或现有 mihomo 服务。容器模式的边界见下节。就绪期限 10 秒、socket I/O 与 Invoke 清理看门狗 5 秒、测试进程总期限 `300 + soak-seconds` 秒;退出或测试失败时停止并等待全部对端,必要时在 5 秒后强制结束,再移除本次临时目录。Invoke Stop 返回后检查 VCore TCP/UDP 端口可重绑。 - -`--extended` 增加 VLESS/XHTTP stream-one/REALITY 对端,需要 OpenSSL 3 的 TLS 1.3/X25519 服务端能力;可用 `OPENSSL_BIN` 指定路径,或从常见 Homebrew 路径及 PATH 查找。伪装站点绑定回环(容器模式绑定专用 host-only bridge),证书和密钥由夹具生成,不启用测试信任注入。SOCKS5、AnyTLS、SS(AES-128 代表算法)、VLESS 的两跳 TCP/UDP 组合重复 8 轮,另检查真实 Controller 切组、HTTP-only 宿主生命周期、具体链 / DIRECT 测速快照与批量结果顺序,以及 100 次活动 Stop/失败回滚/独立测速。Apple 主机使用 UnixDatagram 对模拟 utun 帧入口,不创建系统接口,不能计为物理 TUN 验收。自建 mihomo 的带认证 Controller 只负责用例间清理自身连接,不接触已有服务。 - -macOS 扩展入口另在同一 Running Session 中执行 100 次 32-flow 断连重建(120 秒看门狗),读取系统公开 `malloc_zone_statistics` 的在用堆字节数,并记录 RSS / FD 与 Stop 后状态。此采样不读取或输出分配内容、不改分配器;用于区分活对象增长和驻留内存现象,仍需人工审查趋势,不能仅凭进程退出成功判定没有泄漏。 - -`--soak-seconds` 必须与 `--extended` 一起使用,范围 0–7200;非零时持续保持 16 TCP + 16 UDP flow,每秒切组、每分钟断开自建对端的连接并显式建立新客户端,逐包校验来源与负载,记录 RSS/FD/延迟。短于 1800 秒的运行只验证夹具,不能替代长测。当前资源采样仅在 macOS 主机执行;测试内含节流,输出的负载速率不是最大吞吐基准。 - -测试隔离:入口使用跨进程非阻塞锁,另一套 harness 在启动服务前失败;Python 同时预留回环 TCP/UDP 的 IPv4、IPv6 端口,IPv4-only 对端启动后保留 IPv6 guard。Rust 测试客户端、origin 和 EIH 中继也持有另一地址族的 UDP guard,直到对应 socket 释放。所有 IPv6 guard 为 v6-only,不启用地址复用,不关闭 IPv6 测试;仅在流量启动前有界重选占用端口,不重试业务包。32 个活动 flow 的进程 FD 比原夹具增加 32,来自 UDP 客户端/origin 的测试 guard,结束后仍必须回到空闲基线。第三方源码及 VCore 生产 socket 创建策略不因该隔离改动而改变。 - -历史失败与当前门槛:2026-09-17 的首次 1800 秒长测约 8 分半后出现 UDP 来源异常。该 macOS 环境独立复现了 IPv6 双栈 UDP 自动端口与 IPv4 socket 同号、回包进入无关 IPv4 socket 的现象;并行短测也出现 UDP 超时,但未保留完整 socket 映射,不能把两次失败都断言为已逐跳证明的同一原因。端口隔离后一次完整 30 分钟通过,FD 回到 6;随后加入的 100 次快速重建检查两次 FAIL,第二次在第 11 轮同时捕获 mihomo 的回环拒绝日志和自建进程 UDP 映射:AnyTLS 的来源端口与同一 mihomo 的另一个 UDP 出口端口同号,触发按来源端口匹配的保护。保护测试端点不能约束所有内部自动端口,清理用例间连接也不能排除同轮活动流之间的同号端口。首次快速重建的回包超时未获得同等根因证据,单独保留。 - -UDP 失败时,macOS 夹具在退出前用有界 `lsof` 采样,仅检查本测试进程和 Python 入口提供的自建本机对端 PID,不读取负载、不检查无关进程;容器进程不伪装成 macOS PID,失败时另读取本次容器日志。来源校验、mihomo 回环保护及所有原始失败记录保留,不重试业务包来规避失败。 - -默认 Cargo 测试会将外部互通标记 ignored,必须显式执行 harness 才算有外部对端证据。2026-09-17 的 Apple Container 组合/资源验收结果见下节及 [验收矩阵](../docs/acceptance.md);VLESS 的其他模式、设备和安装包结果不能从当前夹具推导,也不把旧对端或同库回环结果改写为 mihomo 结果。 - -### Apple Container 对端 - -需要已安装并运行的 Apple `container`(当前实际使用 1.4.1,macOS 27 / arm64)。先准备一次专用 host-only 网络、固定摘要的基础镜像及未修改的 mihomo Linux ARM64 程序;若同名网络已存在,先检查其 `mode=hostOnly` 和 `purpose=vcore-mihomo-interop` 标签,不覆盖其他网络: - -```bash -container network inspect vcore-mihomo-interop -# 仅在该网络不存在时创建: -container network create --internal --label purpose=vcore-mihomo-interop vcore-mihomo-interop -container image pull --arch arm64 docker.io/library/alpine@sha256:fd791d74b68913cbb027c6546007b3f0d3bc45125f797758156952bc2d6daf40 -git -C references/mihomo rev-parse HEAD -CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go -C references/mihomo build -trimpath \ - -ldflags '-X github.com/metacubex/mihomo/constant.Version=vcore-interop-ab405bad' \ - -o ../mihomo-linux-arm64 . -``` - -本次源码完整 revision 为 `ab405bad5beeeac8b003bb01f60f134f6df54471`,Go 1.27.1、未添加 build tags,Linux 程序 SHA-256 为 `eb35562be501dd6cd9e1f8bdf3ba43162bf6abb6a3946cd1f1ec0511462feab2`。`--binary` / `VCORE_MIHOMO_BIN` 仍指定原生 macOS 程序,`--container-binary` 单独指定 Linux 程序。缺少基础镜像、网络或二进制会失败,不降级到本机、不自行拉取不同版本。 - -- 首跳与末跳分别在两个 Linux VM 中,每个 1 CPU / 256 MiB;只读根和夹具挂载,临时可写 dataDir。直接访问隔离网络 IP,不发布宿主端口,不配置系统 DNS/PF、代理或 TUN,不关闭 mihomo 回环保护。 -- origin 和 REALITY 伪装站点只绑定该 host-only bridge 的地址,保留 IPv4 / IPv6 数据校验;启动时发现实际地址,不硬编码 DHCP 或 SLAAC 地址。排除尚处于 DAD、重复或失效状态的 IPv6 地址,再验证可绑定及两个容器可达,全部发生在业务测试前。域名 origin 和末端节点分别映射,避免把容器回环当成宿主。 -- 反向 HTTP/SOCKS5 入站测试仍使用两个原生 mihomo 进程,VCore 保持仅回环监听和原有认证。这不是四个对端全容器化,也不是 LAN 共享或物理 IPv6 验收。 -- 每次分配唯一容器名;成功、断言失败、超时或启动失败均只清理本次已创建且带标签的容器及临时目录。保留专用网络、缓存镜像和研究二进制供下次使用,不执行全局 stop/prune。 - -网络行为依据 [Apple Container 1.4.1 networking](https://github.com/apple/container/blob/1.4.1/docs/networking.md)。虚拟网络通过只证明该受控环境,不等于已经修复 macOS 同内核碰撞或第三方实现。 - -本次完整入口通过全部基础/扩展检查、100 次快速 32-flow 重建和 1800 秒长测:1,734 次切组、29 次断连重建、420,476,672 字节校验,FD 为 6 → 195 → 6。RSS 活动起始 / 峰值 / 结束 / Stop 后为 19,248 / 21,984 / 19,184 / 18,896 KiB;多次 `heap --noContent` 采样的存活分配约 2,109–2,127 个、4.90–4.91 MB,未随 RSS 同步累积。堆诊断会扰动延迟(最大 wave 441 ms),节流负载速率不是吞吐基准;32-flow 初次 / 末次建连为 204 / 153 ms。容器、原生对端与临时配置清理完成,专用网络及镜像缓存保留。最初适配中的固定回环断言、IPv6 地址未就绪,以及此前所有本机失败均保留,不用最终通过覆盖历史。 +真实设备、正式宿主、签名安装和商店发布单独签收,见[验收边界](../docs/acceptance.md)。 +旧 Windows tun2socks demo 是显式平台人工验收工具,不是允许在宿主运行协议服务端的例外。 diff --git a/scripts/cmake/android.toolchain.cmake b/scripts/cmake/android.toolchain.cmake new file mode 100644 index 0000000..af9ca86 --- /dev/null +++ b/scripts/cmake/android.toolchain.cmake @@ -0,0 +1,7 @@ +# Let the NDK select C/C++/ASM compilers consistently across repeated configure +# calls. Passing a target clang wrapper as CMAKE_C_COMPILER makes the NDK replace +# it, which triggers CMake's cache reset on boring-sys's second configure. +set(ANDROID_ABI "$ENV{VCORE_CMAKE_ANDROID_ABI}") +set(ANDROID_PLATFORM "android-$ENV{VCORE_CMAKE_ANDROID_API}") +set(ANDROID_STL c++_shared) +include("$ENV{ANDROID_NDK_HOME}/build/cmake/android.toolchain.cmake") diff --git a/scripts/cmake/windows-arm64.toolchain.cmake b/scripts/cmake/windows-arm64.toolchain.cmake new file mode 100644 index 0000000..14a748c --- /dev/null +++ b/scripts/cmake/windows-arm64.toolchain.cmake @@ -0,0 +1,14 @@ +# Native Windows ARM64, MSVC ABI. Clang's integrated assembler handles BoringSSL +# preprocessed .S files; Ninja preserves their directory-qualified object names. +# Never disable assembly or change the bundled third-party source. +set(CMAKE_SYSTEM_NAME Windows) +set(CMAKE_SYSTEM_PROCESSOR ARM64) +set(CMAKE_C_COMPILER "$ENV{CC_aarch64_pc_windows_msvc}") +set(CMAKE_CXX_COMPILER "$ENV{CXX_aarch64_pc_windows_msvc}") +# cmake-rs supplies MSVC-style ASM flags as well as C/C++ flags. Use the CL +# driver for all three languages so the integrated assembler accepts them. +set(CMAKE_ASM_COMPILER "$ENV{CC_aarch64_pc_windows_msvc}") +set(CMAKE_C_COMPILER_TARGET aarch64-pc-windows-msvc) +set(CMAKE_CXX_COMPILER_TARGET aarch64-pc-windows-msvc) +set(CMAKE_ASM_COMPILER_TARGET aarch64-pc-windows-msvc) +set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded) diff --git a/scripts/fixtures/platform_abi.c b/scripts/fixtures/platform_abi.c new file mode 100644 index 0000000..e2c05b8 --- /dev/null +++ b/scripts/fixtures/platform_abi.c @@ -0,0 +1,47 @@ +/* Production artifact consumer. No runtime instance, sockets, or test features. */ +#include +#include +#include "vcore.h" +#ifdef _WIN32 +#include +#endif + +int main(int argc, char **argv) { +#ifdef _WIN32 + if (argc != 2) return 10; + HMODULE library = LoadLibraryExA(argv[1], NULL, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS); + if (!library) return 11; + /* memcpy avoids a nonportable data-pointer/function-pointer cast. */ + FARPROC invoke_address = GetProcAddress(library, "VCoreInvoke"); + FARPROC free_address = GetProcAddress(library, "VCoreFree"); + char *(*invoke)(const char *) = NULL; + void (*release)(char *) = NULL; + if (!invoke_address || !free_address) return 12; + memcpy(&invoke, &invoke_address, sizeof(invoke)); + memcpy(&release, &free_address, sizeof(release)); +#else + (void)argc; + (void)argv; + char *(*invoke)(const char *) = VCoreInvoke; + void (*release)(char *) = VCoreFree; +#endif + for (unsigned i = 0; i < 1000; i++) { + char *response = invoke("{\"apiVersion\":5,\"method\":\"version\",\"payload\":{}}"); + int valid = response && strstr(response, "\"success\":true") + && strstr(response, "\"apiVersion\":5") + && strstr(response, "\"configVersion\":27") + && strstr(response, "VCore;engine=rust;coreVersion=0.1.0;invokeApiVersion=5;configVersion=27"); + release(response); + if (!valid) return 20; + } + char *invalid = invoke("{\"apiVersion\":4,\"method\":\"version\",\"payload\":{}}"); + int rejected = invalid && strstr(invalid, "\"success\":false"); + release(invalid); + if (!rejected) return 21; + puts("PASS production ABI: 1000 version/free calls; incompatible API rejected"); +#ifdef _WIN32 + FreeLibrary(library); +#endif + return 0; +} diff --git a/scripts/src/vcore_scripts/builds.py b/scripts/src/vcore_scripts/builds.py index c0e3615..b75c758 100644 --- a/scripts/src/vcore_scripts/builds.py +++ b/scripts/src/vcore_scripts/builds.py @@ -13,11 +13,12 @@ from pathlib import Path CORE_DIR = Path(__file__).resolve().parents[3] EXPECTED_IDENTITY = ( - b"VCore;engine=rust;coreVersion=0.1.0;invokeApiVersion=5;configVersion=14" + b"VCore;engine=rust;coreVersion=0.1.0;invokeApiVersion=5;configVersion=27" ) DEFAULT_FEATURES = ( "ffi,tun,inbound-http,inbound-socks5,outbound-anytls," - "outbound-socks5,outbound-shadowsocks,outbound-vless" + "outbound-socks5,outbound-shadowsocks,outbound-trojan,outbound-vmess,outbound-vless," + "outbound-hysteria2" ) @@ -196,17 +197,34 @@ def build_android() -> None: for target in targets: abi, clang, cargo_name = _android_target(target, android_api) linker = toolchain / "bin" / clang + cpp = toolchain / "bin" / (clang + "++") archive = toolchain / "bin" / "llvm-ar" if not linker.is_file(): raise RuntimeError(f"Android linker not found: {linker}") if not archive.is_file(): raise RuntimeError(f"Android archiver not found: {archive}") + if not cpp.is_file(): + raise RuntimeError(f"Android C++ compiler not found: {cpp}") + runtime_target = ( + "arm-linux-androideabi" if target == "armv7-linux-androideabi" else target + ) + cpp_runtime = ( + toolchain / "sysroot/usr/lib" / runtime_target / "libc++_shared.so" + ) + if not cpp_runtime.is_file(): + raise RuntimeError(f"Android C++ runtime not found: {cpp_runtime}") target_env = target.replace("-", "_") env = base_env | { f"CC_{target_env}": str(linker), + f"CXX_{target_env}": str(cpp), f"AR_{target_env}": str(archive), f"CARGO_TARGET_{cargo_name}_LINKER": str(linker), f"CARGO_TARGET_{cargo_name}_AR": str(archive), + f"CMAKE_TOOLCHAIN_FILE_{target_env}": str( + CORE_DIR / "scripts/cmake/android.toolchain.cmake" + ), + "VCORE_CMAKE_ANDROID_ABI": abi, + "VCORE_CMAKE_ANDROID_API": android_api, } _cargo_build(target, profile_flags, features, env) artifact = CORE_DIR / "target" / target / profile_name / "libvcore.so" @@ -214,6 +232,9 @@ def build_android() -> None: destination = output / abi / "libvcore.so" destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(artifact, destination) + # BoringSSL links the NDK shared C++ runtime. It is not supplied by + # Android itself; distribute the matching ABI/runtime alongside VCore. + shutil.copy2(cpp_runtime, destination.parent / cpp_runtime.name) print(output) @@ -368,6 +389,30 @@ def _windows_msvc_environment(architecture: str) -> dict[str, str]: key, separator, value = line.partition("=") if separator and key: env[key] = value + if architecture == "arm64": + search_path = next(value for key, value in env.items() if key.upper() == "PATH") + compilers = { + name: shutil.which(name, path=search_path) + for name in ("clang-cl", "clang", "ninja") + } + if not all(compilers.values()): + raise RuntimeError( + "native Windows ARM64 requires LLVM clang-cl/clang and Ninja" + ) + # Visual Studio's generator does not assemble BoringSSL's preprocessed + # .S inputs. Keep assembly enabled using LLVM + Ninja, still MSVC ABI, + # the selected Windows SDK, and the same static CRT. No source patch. + env.update( + { + "CC_aarch64_pc_windows_msvc": compilers["clang-cl"], + "CXX_aarch64_pc_windows_msvc": compilers["clang-cl"], + "VCORE_WINDOWS_ARM64_CLANG": compilers["clang"], + "CMAKE_GENERATOR_aarch64_pc_windows_msvc": "Ninja", + "CMAKE_TOOLCHAIN_FILE_aarch64_pc_windows_msvc": str( + CORE_DIR / "scripts/cmake/windows-arm64.toolchain.cmake" + ), + } + ) return env diff --git a/scripts/src/vcore_scripts/caddy_build.py b/scripts/src/vcore_scripts/caddy_build.py new file mode 100644 index 0000000..48580d6 --- /dev/null +++ b/scripts/src/vcore_scripts/caddy_build.py @@ -0,0 +1,128 @@ +"""Explicit xcaddy-only exception for the isolated H3/mTLS test gateway.""" + +from __future__ import annotations + +import os +import re +import shutil +import urllib.parse +import urllib.request +from pathlib import Path + +from .native_release import PeerArtifact +from .protocol_inputs import redact, sha256 +from .protocol_peers import run_command + + +def latest_tag(project): + """Official latest redirect, not the GitHub API or a pinned old tag.""" + if project not in ("caddy", "xcaddy"): + raise ValueError("unsupported gateway project") + url = f"https://github.com/caddyserver/{project}/releases/latest" + request = urllib.request.Request( + url, method="HEAD", headers={"User-Agent": "VCore-interop-scripts"} + ) + with urllib.request.urlopen(request, timeout=30) as response: + final = urllib.parse.urlsplit(response.geturl()) + prefix = f"/caddyserver/{project}/releases/tag/" + tag = final.path.removeprefix(prefix) + if ( + final.scheme != "https" + or final.netloc != "github.com" + or final.query + or final.fragment + or not final.path.startswith(prefix) + or not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", tag) + ): + raise RuntimeError("cannot resolve official stable gateway release") + return tag + + +def build_caddy(directory: Path): + directory.mkdir(parents=True, exist_ok=False) + xcaddy, go = shutil.which("xcaddy"), shutil.which("go") + if not xcaddy or not go: + raise RuntimeError("xcaddy and Go are required for the approved gateway build") + versions = {project: latest_tag(project) for project in ("xcaddy", "caddy")} + tool = run_command([xcaddy, "version"], timeout=10, limit=4096) + if ( + tool.returncode + or not tool.cleanup + or tool.stdout.decode().split()[0] != versions["xcaddy"] + ): + raise RuntimeError( + "installed xcaddy must match the official latest stable release" + ) + compiler = run_command([go, "version"], timeout=10, limit=4096) + if compiler.returncode or not compiler.cleanup: + raise RuntimeError("cannot read Go build identity") + temporary = directory / "build-work" + temporary.mkdir() + binary = directory / "caddy" + environment = os.environ.copy() + # A fresh standard-module build, never a local source replacement or plugin. + for key in tuple(environment): + if key.startswith("XCADDY_") or key in ("CADDY_VERSION", "GOFLAGS", "GOWORK"): + environment.pop(key) + environment.update( + GOOS="linux", + GOARCH="arm64", + CGO_ENABLED="0", + GOWORK="off", + XCADDY_SKIP_CLEANUP="1", + XCADDY_PRINT_VERSION="0", + TMPDIR=str(temporary), + ) + print("Building official latest Caddy with xcaddy for linux/arm64", flush=True) + built = run_command( + [xcaddy, "build", "latest", "--output", str(binary)], + env=environment, + cwd=directory, + timeout=600, + limit=4 * 1024 * 1024, + ) + (directory / "build.log").write_text(redact(built.stdout.decode(errors="replace"))) + if built.returncode or not built.cleanup or not binary.is_file(): + raise RuntimeError("xcaddy gateway build failed") + metadata = run_command([go, "version", "-m", str(binary)], timeout=10, limit=65536) + info = metadata.stdout.decode(errors="replace") + expected = f"\tdep\tgithub.com/caddyserver/caddy/v2\t{versions['caddy']}\t" + if ( + metadata.returncode + or not metadata.cleanup + or expected not in info + or "=>" in info + ): + raise RuntimeError("gateway build is not the unmodified official latest Caddy") + # xcaddy deliberately uses cwd on macOS, TMPDIR on other hosts. + manifests = [ + *directory.glob("buildenv_*/go.mod"), + *temporary.glob("buildenv_*/go.mod"), + ] + if len(manifests) != 1: + raise RuntimeError("xcaddy build manifest is missing or ambiguous") + inputs = {} + for name in ("go.mod", "go.sum", "main.go"): + source = manifests[0].with_name(name) + shutil.copyfile(source, directory / name) + inputs[name] = sha256(source) + # Omit the host executable path from the retained module metadata. + (directory / "modules.txt").write_text("\n".join(info.splitlines()[1:]) + "\n") + return PeerArtifact( + binary, + dict( + kind="Caddy-xcaddy", + target="linux-arm64", + source_built=True, + source_url="https://github.com/caddyserver/caddy/releases/latest", + release=versions["caddy"], + xcaddy_version=tool.stdout.decode().strip(), + go_version=compiler.stdout.decode().strip(), + binary_sha256=sha256(binary), + build_seconds=built.seconds, + build_cleanup=built.cleanup, + inputs=inputs, + extra_plugins=[], + version=None, + ), + ) diff --git a/scripts/src/vcore_scripts/checks.py b/scripts/src/vcore_scripts/checks.py index 9122251..fc451c6 100644 --- a/scripts/src/vcore_scripts/checks.py +++ b/scripts/src/vcore_scripts/checks.py @@ -13,13 +13,9 @@ CRATES_IO_SOURCES = { "registry+https://github.com/rust-lang/crates.io-index", "registry+https://index.crates.io/", } -RUSTLS_GIT_SOURCE_PREFIX = ( - "git+https://github.com/OneVCore/rustls?branch=vcore/reality-0.23#" -) -SHADOWSOCKS_REVISION = "ab388c7466d21f979430e33cc9ef10e22fb05955" -SHADOWSOCKS_GIT_SOURCE = ( - "git+https://github.com/shadowsocks/shadowsocks-rust.git" - f"?rev={SHADOWSOCKS_REVISION}#{SHADOWSOCKS_REVISION}" +BORING_REVISION = "d5a5d41850886aef5b269015130ee6d6eb2129ba" +BORING_GIT_SOURCE = ( + f"git+https://github.com/OneXray/boring?branch=release#{BORING_REVISION}" ) _C_SOURCE = r"""#include "vcore.h" @@ -100,7 +96,7 @@ def _shadowsocks_aws_lc_errors(metadata: dict[str, Any]) -> list[str]: errors: list[str] = [] allowed: dict[str, dict[str, Any]] = {} expected = { - "shadowsocks": ("1.25.0", {SHADOWSOCKS_GIT_SOURCE}), + "shadowsocks": ("1.25.0", CRATES_IO_SOURCES), "shadowsocks-crypto": ("0.8.0", CRATES_IO_SOURCES), "aws-lc-rs": (None, CRATES_IO_SOURCES), "aws-lc-sys": (None, CRATES_IO_SOURCES), @@ -178,8 +174,8 @@ def _tls_dependency_errors(metadata: dict[str, Any]) -> list[str]: return None return matches[0] - rustls = require_single("rustls", "0.23.43") - tokio_rustls = require_single("tokio-rustls", "0.26.4") + rustls = require_single("rustls", "0.23.45") + tokio_rustls = require_single("tokio-rustls", "0.26.5") ring = named("ring") if len(ring) != 1: @@ -195,12 +191,9 @@ def _tls_dependency_errors(metadata: dict[str, Any]) -> list[str]: if rustls is not None: rustls_source = rustls.get("source") or "" - revision = rustls_source.removeprefix(RUSTLS_GIT_SOURCE_PREFIX) - if len(revision) != 40 or any( - character not in "0123456789abcdef" for character in revision - ): + if rustls_source not in CRATES_IO_SOURCES: errors.append( - "rustls must come from the vcore/reality-0.23 GitHub branch; " + "rustls must come from crates.io; " f"resolved source: {rustls_source or 'path'}" ) @@ -226,8 +219,8 @@ def _tls_dependency_errors(metadata: dict[str, Any]) -> list[str]: errors.append("rustls is missing from the resolved dependency graph") else: features = set(rustls_node["features"]) - missing = {"reality", "ring"} - features - forbidden = {"aws_lc_rs", "fips"} & features + missing = {"ring"} - features + forbidden = {"reality", "aws_lc_rs", "fips"} & features if missing: errors.append( f"rustls is missing required features: {', '.join(sorted(missing))}" @@ -238,6 +231,63 @@ def _tls_dependency_errors(metadata: dict[str, Any]) -> list[str]: f"{', '.join(sorted(forbidden))}" ) + native = {} + node_by_id = {node["id"]: node for node in nodes} + hpke = require_single("hpke", "0.14.1") + if hpke is not None: + if hpke.get("source") not in CRATES_IO_SOURCES: + errors.append("hpke must be the unmodified crates.io release") + hpke_node = node_by_id.get(hpke["id"]) + if hpke_node is None or set(hpke_node.get("features", [])) != { + "alloc", + "aes", + "chacha", + "x25519", + "hkdfsha2", + }: + errors.append("hpke must use only the approved ECH algorithms") + for name, required in ( + ("boring", {"reality", "client-fingerprint", "shadow-tls-v3", "jls"}), + ("boring-sys", {"reality", "shadow-tls-v3", "jls"}), + ("tokio-boring", set()), + ): + package = require_single(name, "5.2.0") + if package is None: + continue + native[name] = package + if package.get("source") != BORING_GIT_SOURCE: + errors.append( + f"{name} must use the approved boring release branch " + "and locked revision" + ) + node = node_by_id.get(package["id"]) + if node is None: + errors.append(f"{name} is missing from the resolved graph") + continue + features = set(node["features"]) + if not required <= features: + errors.append(f"{name} is missing required TLS features") + if features & { + "fips", + "fips-precompiled", + "rpk", + "pq-experimental", + "restls", + }: + errors.append(f"{name} enables an unapproved native TLS mode") + for parent, children in ( + ("boring", {"boring-sys"}), + ("tokio-boring", {"boring", "boring-sys"}), + ): + if parent in native: + deps = { + dep["pkg"] + for dep in node_by_id.get(native[parent]["id"], {}).get("deps", []) + } + for child in children: + if child not in native or native[child]["id"] not in deps: + errors.append(f"{parent} must directly use the locked {child}") + errors.extend(_shadowsocks_aws_lc_errors(metadata)) return errors @@ -273,12 +323,19 @@ def check_tls_dependencies() -> None: rustls = next( package for package in metadata["packages"] if package["name"] == "rustls" ) - revision = rustls["source"].rsplit("#", 1)[1] print("TLS dependency check passed:") - print(f"- one OneVCore rustls 0.23.43 vcore/reality-0.23 @ {revision[:12]}") - print("- one official tokio-rustls 0.26.4") + print(f"- one official crates.io rustls {rustls['version']}") + print("- one official tokio-rustls 0.26.5") + print("- one official hpke 0.14.1 with the approved static ECH algorithms") + print( + "- one boring/boring-sys/tokio-boring 5.2.0 release branch " + f"@ {BORING_REVISION[:12]}" + ) print(f"- one registry ring {ring['version']} provider") - print("- no Watfaq or second rustls version; TLS uses ring only") + print( + "- unprofiled TLS/QUIC use rustls + ring; REALITY/named profiles use BoringSSL" + ) + print("- no Watfaq, second rustls source or rustls REALITY backend") if any(p["name"] == "aws-lc-rs" for p in metadata["packages"]): print("- AWS-LC is restricted to the pinned official Shadowsocks 2022 chain") else: diff --git a/scripts/src/vcore_scripts/cli.py b/scripts/src/vcore_scripts/cli.py index a9dfa75..e1f0deb 100644 --- a/scripts/src/vcore_scripts/cli.py +++ b/scripts/src/vcore_scripts/cli.py @@ -8,7 +8,10 @@ from pathlib import Path from .builds import build_android, build_apple, build_windows from .checks import check_c_header, check_tls_dependencies -from .mihomo import run_mihomo_interop +from .mihomo_release import SUPPORTED_TARGETS, download_mihomo +from .protocol_catalogs import CATALOG_DIR, SUITES, check_protocol_catalogs +from .protocol_evidence import check_run +from .protocol_harness import run_protocol_interop from .tun2socks import run_demo @@ -21,41 +24,124 @@ def _parser() -> argparse.ArgumentParser: build = commands.add_parser("build", help="build platform artifacts") platforms = build.add_subparsers(dest="platform", required=True) - platforms.add_parser("apple", help="build LibVCore.xcframework on macOS") - platforms.add_parser("android", help="build Android libvcore.so artifacts") - platforms.add_parser("windows", help="build packaged Windows artifacts") + for name, description in ( + ("apple", "build LibVCore.xcframework on macOS"), + ("android", "build Android libvcore.so artifacts"), + ("windows", "build packaged Windows artifacts"), + ): + command = platforms.add_parser(name, help=description) + command.add_argument( + "--delivery", + action="store_true", + help="record production artifact identity", + ) + + download = commands.add_parser("download", help="download official test peers") + downloads = download.add_subparsers(dest="download", required=True) + peer = downloads.add_parser("mihomo", help="download the latest stable mihomo") + peer.add_argument( + "--target", choices=SUPPORTED_TARGETS, help="default: host platform" + ) check = commands.add_parser("check", help="run repository checks") checks = check.add_subparsers(dest="check", required=True) + core = checks.add_parser( + "core", help="run memory-only tests or feature/build checks" + ) + core.add_argument( + "--profile", choices=("debug", "release", "features"), default="debug" + ) + core.add_argument("--list", dest="list_only", action="store_true") checks.add_parser("c-header", help="compile vcore.h as C and C++") checks.add_parser("tls-dependencies", help="validate the locked TLS graph") - mihomo = checks.add_parser( - "mihomo-interop", help="run local protocol interoperability against mihomo" + delivery = checks.add_parser( + "platform-artifacts", help="verify production artifact evidence, not devices" ) - mihomo.add_argument("--binary", type=Path) - mihomo.add_argument( - "--container-binary", - type=Path, - help="run upstream/terminal peers in Apple Container (Linux ARM64 binary)", - ) - mihomo.add_argument( - "--extended", + delivery.add_argument("--manifest", type=Path, action="append", required=True) + delivery.add_argument("--source-dir", type=Path) + delivery.add_argument( + "--complete", action="store_true", - help="include repeated cross-protocol two-hop gates", + help="require every production platform group", ) - mihomo.add_argument( - "--soak-seconds", - type=int, - default=0, - help="requires --extended; full soak acceptance needs 1800 wall-clock seconds", + abi = checks.add_parser("platform-abi", help="link/load native production artifact") + abi.add_argument("--manifest", type=Path, required=True) + reality = checks.add_parser( + "reality-hybrid", + help="verify isolated hybrid REALITY, not the complete security suite", + ) + reality.add_argument("--run-dir", type=Path, required=True) + reality.add_argument("--case", dest="identifiers", action="append") + xhttp = checks.add_parser( + "xhttp-peers", + help="probe isolated native XHTTP peer capabilities, not suite acceptance", + ) + xhttp.add_argument("--run-dir", type=Path, required=True) + xhttp.add_argument( + "--identities-only", + action="store_true", + help="probe real download-leg client-identity enforcement only", + ) + gateway = checks.add_parser( + "xhttp-gateway", + help="build xcaddy and test isolated H3/mTLS native topology", + ) + gateway.add_argument("--run-dir", type=Path, required=True) + gateway.add_argument( + "--identities-only", + action="store_true", + help="small identity probes with native QUIC certificate-error observation", + ) + coverage = checks.add_parser( + "protocol-coverage", help="validate executable catalogs or persisted evidence" + ) + coverage_modes = coverage.add_mutually_exclusive_group(required=True) + coverage_modes.add_argument( + "--catalog-only", + action="store_true", + help="check declarations only, not implementation or behavior acceptance", + ) + coverage_modes.add_argument( + "--run-dir", type=Path, help="validate a complete persisted suite run" + ) + suites = [name.lower() for name in SUITES] + coverage.add_argument("--suite", choices=suites, default="foundations") + coverage.add_argument( + "--manifest", + type=Path, + help="explicit frozen executable manifest for evidence checks", + ) + protocol = checks.add_parser( + "protocol-interop", help="run a protocol or integration capability suite" + ) + protocol.add_argument( + "--suite", choices=suites, required=True, help="capability suite to run" + ) + protocol.add_argument("--case", dest="identifiers", action="append") + protocol.add_argument( + "--protocol", + choices=[ + "foundation", + "legacy", + "trojan", + "vmess", + "vless", + "hysteria2", + ], + ) + modes = protocol.add_mutually_exclusive_group() + modes.add_argument("--list", dest="list_only", action="store_true") + modes.add_argument("--preflight", dest="preflight_only", action="store_true") + protocol.add_argument( + "--run-dir", type=Path, help="fresh child directory of target/interop/runs" ) - demo = commands.add_parser("demo", help="run opt-in interoperability demos") demos = demo.add_subparsers(dest="demo", required=True) tun2socks = demos.add_parser( "windows-tun2socks", help="run VCore TUN through an external Xray SOCKS inbound" ) - tun2socks.add_argument("config", nargs="?", type=Path) + tun2socks.add_argument("config", type=Path) + tun2socks.add_argument("--xray-source", type=Path, required=True) return parser @@ -63,29 +149,69 @@ def main(argv: Sequence[str] | None = None) -> int: args = _parser().parse_args(argv) try: if args.command == "build": - if args.platform == "apple": + if args.delivery: + from .platform_delivery import build_delivery + + build_delivery(args.platform) + elif args.platform == "apple": build_apple() elif args.platform == "android": build_android() else: build_windows() + elif args.command == "download": + download_mihomo(args.target) elif args.command == "check": - if args.check == "c-header": + if args.check == "core": + from .core_checks import run + + run(args.profile, list_only=args.list_only) + elif args.check == "c-header": check_c_header() - elif args.check == "mihomo-interop": - if args.extended or args.soak_seconds or args.container_binary: - run_mihomo_interop( - args.binary, - extended=args.extended, - soak_seconds=args.soak_seconds, - container_binary=args.container_binary, - ) + elif args.check == "platform-artifacts": + from .platform_delivery import check_delivery + + check_delivery( + args.manifest, source_dir=args.source_dir, complete=args.complete + ) + elif args.check == "platform-abi": + from .platform_delivery import check_abi + + check_abi(args.manifest.resolve()) + elif args.check == "xhttp-peers": + from .protocol_xhttp_peers import main as xhttp_peers + + return xhttp_peers(args.run_dir, identities_only=args.identities_only) + elif args.check == "xhttp-gateway": + from .protocol_xhttp_gateway import main as xhttp_gateway + + return xhttp_gateway(args.run_dir, identities_only=args.identities_only) + elif args.check == "protocol-coverage": + if args.catalog_only: + check_protocol_catalogs(CATALOG_DIR) else: - run_mihomo_interop(args.binary) + check_run( + args.run_dir.resolve(), + args.suite.upper(), + args.manifest, + ) + elif args.check == "protocol-interop": + run_protocol_interop( + stage=args.suite.upper(), + identifiers=args.identifiers, + protocol=args.protocol, + list_only=args.list_only, + preflight_only=args.preflight_only, + run_dir=args.run_dir, + ) + elif args.check == "reality-hybrid": + from .protocol_reality_hybrid import main as run_reality_hybrid + + return run_reality_hybrid(args.run_dir, args.identifiers) else: check_tls_dependencies() else: - run_demo(args.config) + run_demo(args.config, xray_source=args.xray_source) except (OSError, RuntimeError, ValueError, subprocess.SubprocessError) as error: print(f"vcore-scripts: {error}", file=sys.stderr) return 1 diff --git a/scripts/src/vcore_scripts/container_bandwidth_origin.py b/scripts/src/vcore_scripts/container_bandwidth_origin.py new file mode 100644 index 0000000..d4adb2f --- /dev/null +++ b/scripts/src/vcore_scripts/container_bandwidth_origin.py @@ -0,0 +1,105 @@ +"""Bounded one-way bandwidth origin, only inside the owned HYSTERIA2 container. + +64-KiB records carry a sequence and deterministic payload. Upload progress is +timestamped at the actual origin, not inferred from the client's send buffer. +""" + +import hashlib +import os +import select +import socket +import struct +import sys +import threading +import time + +SIZE = 65536 +END = (1 << 64) - 1 +SLOTS = threading.BoundedSemaphore(4) + + +def exact(stream, size): + value = bytearray() + while len(value) < size: + part = stream.recv(size - len(value)) + if not part: + raise ValueError("truncated bandwidth record") + value.extend(part) + return bytes(value) + + +def record(sequence): + return struct.pack("!Q", sequence) + bytes([sequence % 251]) * (SIZE - 8) + + +def serve(control): + try: + with control, socket.socket() as listener: + control.settimeout(90) + direction = exact(control, 1) + if direction not in (b"U", b"D"): + raise ValueError("invalid bandwidth direction") + listener.bind(("0.0.0.0", 0)) + listener.listen(1) + listener.settimeout(15) + control.sendall(struct.pack("!H", listener.getsockname()[1])) + stream, _ = listener.accept() + with stream: + stream.settimeout(30) + stream.sendall(b"ready") + digest, sequence = hashlib.sha256(), 0 + started = time.monotonic_ns() + if direction == b"U": + while True: + data = exact(stream, SIZE) + number = struct.unpack("!Q", data[:8])[0] + if number == END: + break + if data != record(sequence): + raise ValueError("upload sequence or payload mismatch") + digest.update(data) + sequence += 1 + control.sendall( + b"P" + + struct.pack( + "!QQ", sequence, time.monotonic_ns() - started + ) + ) + stream.sendall(b"done!") + else: + if exact(stream, 1) != b"S": + raise ValueError("missing start") + while True: + ready, _, _ = select.select([stream], [], [], 0) + if ready: + if exact(stream, 1) != b"X": + raise ValueError("invalid stop") + break + data = record(sequence) + stream.sendall(data) + digest.update(data) + sequence += 1 + stream.sendall(record(END)) + control.sendall(b"D" + struct.pack("!Q", sequence) + digest.digest()) + except (OSError, ValueError) as error: + print(type(error).__name__, flush=True) + finally: + SLOTS.release() + + +def main(): + if sys.platform != "linux" or os.environ.get("VCORE_ISOLATED_ORIGIN") != "1": + raise SystemExit("isolated container required") + with socket.socket() as listener: + listener.bind(("0.0.0.0", 24002)) + listener.listen(4) + while True: + control, _ = listener.accept() + if SLOTS.acquire(blocking=False): + threading.Thread(target=serve, args=(control,), daemon=True).start() + else: + control.close() + + +if __name__ == "__main__": + main() diff --git a/scripts/src/vcore_scripts/container_close_client.py b/scripts/src/vcore_scripts/container_close_client.py new file mode 100644 index 0000000..1e4fe2e --- /dev/null +++ b/scripts/src/vcore_scripts/container_close_client.py @@ -0,0 +1,74 @@ +"""Read a real official client's close behavior; execute only inside its VM lab.""" + +import json +import os +import socket +import ssl +import struct +import sys +from pathlib import Path + + +def exact(io, length): + data = bytearray() + while len(data) < length: + part = io.recv(length - len(data)) + if not part: + raise ValueError("truncated close control") + data.extend(part) + return bytes(data) + + +def main(proxy, origin, variant="plain"): + if sys.platform != "linux" or os.environ.get("VCORE_ISOLATED_ORIGIN") != "1": + raise RuntimeError("isolated close consumer required") + with socket.create_connection((origin, 24000), timeout=10) as control: + vision = variant == "vision-direct" + if variant not in ("plain", "vision-direct"): + raise ValueError("unknown close variant") + control.sendall(b"\x15" if vision else b"\x0b") + port = struct.unpack("!H", exact(control, 2))[0] + with socket.create_connection((proxy, 23002), timeout=10) as client: + client.sendall(b"\x05\x01\x00") + assert exact(client, 2) == b"\x05\x00" + client.sendall( + b"\x05\x01\x00\x01" + socket.inet_aton(origin) + struct.pack("!H", port) + ) + header = exact(client, 4) + assert header[:3] == b"\x05\x00\x00" + exact(client, 6 if header[3] == 1 else 18) + io = client + if vision: + from tls_bio import TlsBio + + context = ssl.create_default_context( + cafile=str(Path(__file__).with_name("cert.pem")) + ) + context.minimum_version = ssl.TLSVersion.TLSv1_3 + context.maximum_version = ssl.TLSVersion.TLSv1_3 + io = TlsBio(client, context, server=False) + assert io.tls.version() == "TLSv1.3" + assert exact(control, 2) == b"A\x13" + assert exact(io, 5) == b"hello" + payload = b"Z" * 65536 if vision else b"ping" + io.sendall(payload) + assert exact(io, len(payload)) == payload + client.shutdown(socket.SHUT_WR) + tail = bytearray() + try: + while chunk := io.recv(1024): + tail.extend(chunk) + if len(tail) > 1024: + raise ValueError("close tail limit") + except ConnectionResetError: + pass + assert exact(control, 1 if vision else 2) == (b"D" if vision else b"AD") + print( + json.dumps( + {"tail_hex": tail.hex(), "terminated": True, "variant": variant} + ) + ) + + +if __name__ == "__main__": + main(*sys.argv[1:]) diff --git a/scripts/src/vcore_scripts/container_quic_observer.py b/scripts/src/vcore_scripts/container_quic_observer.py new file mode 100644 index 0000000..babdc75 --- /dev/null +++ b/scripts/src/vcore_scripts/container_quic_observer.py @@ -0,0 +1,56 @@ +"""Read-only, bounded native qlog observer for the isolated identity diagnostic. + +Return certificate failure categories only, never frames, keys, connection IDs, +request headers or peer addresses. Raw qlogs stay in the disposable container. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + + +def summarize_trace(text): + failures = [] + for line in text.splitlines(keepends=True): + if not line.endswith("\n"): + continue # The native writer can still be appending the last record. + row = json.loads(line.lstrip("\x1e")) + if row.get("name") != "transport:connection_closed": + continue + data = row.get("data", {}) + code = data.get("connection_error", "") + if data.get("initiator") != "local" or not code.startswith("crypto_error_"): + continue + # RFC 9001 CRYPTO_ERROR = 0x100 + the RFC 8446 TLS alert number. + # quic-go's local crypto error deliberately leaves qlog.reason empty. + categories = { + "crypto_error_0x174": "certificate_required", + "crypto_error_0x12d": "certificate_expired", + "crypto_error_0x130": "unknown_ca", + } + category = categories.get(code) + if category: + failures.append({"tls_alert": code, "category": category}) + return failures + + +def main(): + files = sorted(Path("/data/qlog").glob("*.sqlog")) + if len(files) > 128 or sum(path.stat().st_size for path in files) > 16 * 1024**2: + raise ValueError("native identity trace exceeded diagnostic bound") + print( + json.dumps( + { + hashlib.sha256(path.name.encode()).hexdigest(): summarize_trace( + path.read_text() + ) + for path in files + } + ) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/src/vcore_scripts/container_reality_hybrid.py b/scripts/src/vcore_scripts/container_reality_hybrid.py new file mode 100644 index 0000000..0393963 --- /dev/null +++ b/scripts/src/vcore_scripts/container_reality_hybrid.py @@ -0,0 +1,263 @@ +"""Container-only OpenSSL cover sites, TCP origin, and transparent observation.""" + +import json +import os +import select +import socket +import ssl +import sys +import threading +import time +from pathlib import Path + +ROOT = Path("/data/fixture") +EVENTS = Path("/data/events.jsonl") +LOCK = threading.Lock() +SLOTS = threading.BoundedSemaphore(32) + + +def emit(**event): + with LOCK, EVENTS.open("a") as output: + if EVENTS.stat().st_size > 8 * 1024 * 1024: + raise ValueError("observer event limit") + output.write(json.dumps(event) + "\n") + + +def exact(stream, size): + data = bytearray() + while len(data) < size: + part = stream.recv(size - len(data)) + if not part: + raise EOFError() + data.extend(part) + return bytes(data) + + +def word(data, at): + return int.from_bytes(data[at : at + 2], "big") + + +def client_hello(stream): + wire, message = bytearray(), bytearray() + for _ in range(8): + head = exact(stream, 5) + size = word(head, 3) + if head[0] != 22 or not 1 <= size <= 16384: + raise ValueError("TLS record") + body = exact(stream, size) + wire.extend(head + body) + message.extend(body) + if len(message) > 65536: + raise ValueError("hello limit") + if len(message) >= 4: + expected = 4 + int.from_bytes(message[1:4], "big") + if message[0] != 1 or len(message) > expected: + raise ValueError("ClientHello length") + if len(message) == expected: + return bytes(wire), bytes(message) + raise ValueError("record count") + + +def extensions(message, client): + at = 39 + message[38] + if client: + at += 2 + word(message, at) + at += 1 + message[at] + else: + at += 3 + end = at + 2 + word(message, at) + at += 2 + if end != len(message): + raise ValueError("extension length") + while at < end: + kind, size = word(message, at), word(message, at + 2) + data = message[at + 4 : at + 4 + size] + if len(data) != size: + raise ValueError("truncated extension") + yield kind, data + at += 4 + size + + +def shares(message): + result = [] + for kind, data in extensions(message, True): + if kind == 51: + at = 2 + while at < len(data): + group, size = word(data, at), word(data, at + 2) + if group & 0x0F0F != 0x0A0A: + result.append([group, size]) + at += 4 + size + if at != len(data): + raise ValueError("key share length") + return result + + +def selected_group(wire): + if len(wire) < 5 or len(wire) < 5 + word(wire, 3): + return None + if wire[0] != 22 or wire[5] != 2: + return -1 + message = wire[5 : 5 + word(wire, 3)] + size = 4 + int.from_bytes(message[1:4], "big") + for kind, data in extensions(message[:size], False): + if kind == 51: + return word(data, 0) + return -1 + + +def handle(stream, port, context): + try: + with stream: + stream.settimeout(5) + if port == 9000: + # Readiness opens a connection without application data. + received = 0 + while part := stream.recv(16384): + if not received: + emit(port=port, event="origin-data") + received += len(part) + if received > 10 * 1024 * 1024: + raise ValueError("origin byte limit") + stream.sendall(part) + if received: + emit(port=port, event="origin-complete", bytes=received) + return + try: + raw, hello = client_hello(stream) + except EOFError: + return + emit( + port=port, + event="client-hello", + shares=shares(hello), + groups=[ + word(data, at) + for kind, data in extensions(hello, True) + if kind == 10 + for at in range(2, len(data), 2) + if word(data, at) & 0x0F0F != 0x0A0A + ], + ) + if context is None: + address = json.loads((ROOT / "routes.json").read_text())[str(port)] + with socket.create_connection(tuple(address), timeout=5) as upstream: + upstream.sendall(raw) + deadline, total = time.monotonic() + 240, len(raw) + flight, observed = bytearray(), False + while time.monotonic() < deadline: + ready, _, _ = select.select([stream, upstream], [], [], 0.2) + for source in ready: + data = source.recv(16384) + if not data: + if source is upstream: + emit( + port=port, + event="server-close", + bytes=len(flight), + ) + return + total += len(data) + if total > 128 * 1024 * 1024: + raise ValueError("relay byte limit") + if source is upstream and not observed: + flight.extend(data) + if len(flight) > 65536: + raise ValueError("server flight limit") + group = selected_group(flight) + if group is not None: + emit( + port=port, + event="server-hello" + if group >= 0 + else "tls-response", + group=group, + hrr=flight[11:43] + == bytes.fromhex( + "cf21ad74e59a6111be1d8c021e65b891" + "c2a211167abb8c5e079e09e2c8a8339c" + ), + ) + observed = True + (upstream if source is stream else stream).sendall(data) + raise TimeoutError("relay duration") + incoming, outgoing = ssl.MemoryBIO(), ssl.MemoryBIO() + tls = context.wrap_bio(incoming, outgoing, server_side=True) + incoming.write(raw) + for _ in range(32): + try: + tls.do_handshake() + stream.sendall(outgoing.read()) + emit(port=port, event="cover-complete", version=tls.version()) + return + except ssl.SSLWantReadError: + data = outgoing.read() + if data: + stream.sendall(data) + data = stream.recv(16384) + if not data: + return + incoming.write(data) + raise ValueError("cover iteration limit") + except ssl.SSLError as error: + emit( + port=port, + event="no-shared-group" + if error.reason == "NO_SUITABLE_KEY_SHARE" + else "closed", + ) + except (OSError, ValueError, EOFError): + emit(port=port, event="closed") + finally: + SLOTS.release() + + +def serve(port, context): + with socket.socket(socket.AF_INET6) as listener: + listener.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) + listener.bind(("::", port)) + listener.listen(32) + while True: + stream, _ = listener.accept() + SLOTS.acquire() + threading.Thread( + target=handle, args=(stream, port, context), daemon=True + ).start() + + +if __name__ == "__main__": + if sys.platform != "linux" or os.environ.get("VCORE_ISOLATED_ORIGIN") != "1": + raise SystemExit("isolated REALITY observer required") + EVENTS.touch() + contexts = {} + for port, group, version in [ + (24431, "X25519MLKEM768", ssl.TLSVersion.TLSv1_3), + (24432, "X25519", ssl.TLSVersion.TLSv1_3), + (24433, "secp384r1", ssl.TLSVersion.TLSv1_3), + (24434, "X25519", ssl.TLSVersion.TLSv1_2), + ]: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(ROOT / "cert.pem", ROOT / "key.pem") + # Python's EC-NID setter rejects hybrid group names even with OpenSSL + # 3.5. Use the native default list for the hybrid cover, then verify the + # actual ServerHello/finished connection selected 4588 in the harness. + if group != "X25519MLKEM768": + context.set_ecdh_curve(group) + context.minimum_version = context.maximum_version = version + context.set_alpn_protocols(["h2", "http/1.1"]) + contexts[port] = context + emit(event="native-tls", version=ssl.OPENSSL_VERSION) + threads = [ + threading.Thread(target=serve, args=(port, context)) + for port, context in [ + *contexts.items(), + *( + (int(port), None) + for port in json.loads((ROOT / "routes.json").read_text()) + ), + ] + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() diff --git a/scripts/src/vcore_scripts/container_tls_bio.py b/scripts/src/vcore_scripts/container_tls_bio.py new file mode 100644 index 0000000..466ad16 --- /dev/null +++ b/scripts/src/vcore_scripts/container_tls_bio.py @@ -0,0 +1,48 @@ +"""Fixture-only public OpenSSL MemoryBIO driver; never a TLS implementation.""" + +import ssl + + +class TlsBio: + def __init__(self, stream, context, *, server): + self.stream = stream + self.incoming = ssl.MemoryBIO() + self.outgoing = ssl.MemoryBIO() + self.tls = context.wrap_bio( + self.incoming, + self.outgoing, + server_side=server, + server_hostname=None if server else "localhost", + ) + self.perform(self.tls.do_handshake) + + def flush(self): + while self.outgoing.pending: + self.stream.sendall(self.outgoing.read()) + + def perform(self, operation, *args): + while True: + try: + result = operation(*args) + self.flush() + return result + except ssl.SSLWantWriteError: + self.flush() + except ssl.SSLWantReadError: + self.flush() + chunk = self.stream.recv(65536) + if chunk: + self.incoming.write(chunk) + else: + self.incoming.write_eof() + + def recv(self, length): + try: + return self.perform(self.tls.read, length) + except (ssl.SSLEOFError, ssl.SSLZeroReturnError): + return b"" + + def sendall(self, data): + offset = 0 + while offset < len(data): + offset += self.perform(self.tls.write, data[offset:]) diff --git a/scripts/src/vcore_scripts/container_tls_observer.py b/scripts/src/vcore_scripts/container_tls_observer.py new file mode 100644 index 0000000..8f20fc1 --- /dev/null +++ b/scripts/src/vcore_scripts/container_tls_observer.py @@ -0,0 +1,179 @@ +"""Container-only TLS observer; never decodes or serves a proxy protocol.""" + +from __future__ import annotations + +import base64 +import contextlib +import json +import os +import socket +import ssl +import threading +import time +from pathlib import Path + + +def capture_client_hello(receive): + """Read at most eight records and 64 KiB of ClientHello from injected IO.""" + + def exact(count): + result = bytearray() + while len(result) < count: + part = receive(count - len(result)) + if not part: + raise EOFError("incomplete TLS record") + result.extend(part) + return bytes(result) + + records, handshake, layout = bytearray(), bytearray(), [] + for _ in range(8): + header = exact(5) + size = int.from_bytes(header[3:], "big") + if header[0] != 22 or not 1 <= size <= 18432: + raise ValueError("expected bounded handshake record") + payload = exact(size) + records.extend(header + payload) + handshake.extend(payload) + layout.append( + dict(type=header[0], version=int.from_bytes(header[1:3], "big"), bytes=size) + ) + if len(handshake) >= 4: + hello_size = int.from_bytes(handshake[1:4], "big") + 4 + if handshake[0] != 1 or hello_size > 65536: + raise ValueError("expected bounded ClientHello") + if len(handshake) >= hello_size: + return bytes(records), bytes(handshake[:hello_size]), layout + raise ValueError("ClientHello record limit") + + +def main(): + if os.environ.get("VCORE_ISOLATED_TLS_OBSERVER") != "1": + raise RuntimeError("TLS observer requires an owned isolated container") + log = Path("/data/events.jsonl") + lock, stop = threading.Lock(), threading.Event() + deadline = time.monotonic() + 900 + log.touch() + + def emit(event): + with lock: + if log.stat().st_size > 8 * 1024 * 1024: + stop.set() + raise RuntimeError("observer output limit") + with log.open("a") as output: + output.write(json.dumps(event, separators=(",", ":")) + "\n") + + contexts = {24430: None} + for port, version in ( + (24431, ssl.TLSVersion.TLSv1_3), + (24432, ssl.TLSVersion.TLSv1_2), + ): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain("/data/fixture/cert.pem", "/data/fixture/key.pem") + context.minimum_version = context.maximum_version = version + context.set_alpn_protocols(["h2", "http/1.1"]) + contexts[port] = context + + def serve(port, context): + try: + with socket.socket() as listener: + listener.settimeout(0.5) + listener.bind(("0.0.0.0", port)) + listener.listen(8) + sequence = 0 + while not stop.is_set() and time.monotonic() < deadline: + try: + stream, _ = listener.accept() + except TimeoutError: + continue + sequence += 1 + if sequence > 512: + raise RuntimeError("observer connection limit") + with stream: + stream.settimeout(5) + event = dict(port=port, sequence=sequence) + try: + raw, hello, layout = capture_client_hello(stream.recv) + except EOFError: + continue # The readiness connect carries no TLS bytes. + except (OSError, ValueError) as error: + emit( + event + | dict( + status="capture-error", reason=type(error).__name__ + ) + ) + continue + event.update( + client_hello_b64=base64.b64encode(hello).decode(), + records_b64=base64.b64encode(raw).decode(), + records=layout, + ) + if context is None: + emit(event | dict(status="captured-only")) + with contextlib.suppress(OSError): + stream.sendall(bytes.fromhex("15030300020228")) + continue + incoming, outgoing = ssl.MemoryBIO(), ssl.MemoryBIO() + tls = context.wrap_bio(incoming, outgoing, server_side=True) + incoming.write(raw) + try: + consumed = len(raw) + for _ in range(32): + try: + tls.do_handshake() + stream.sendall(outgoing.read()) + emit( + event + | dict( + status="tls-handshake-complete", + version=tls.version(), + cipher=tls.cipher()[0], + alpn=tls.selected_alpn_protocol(), + resumed=tls.session_reused, + ) + ) + break + except ssl.SSLWantReadError: + stream.sendall(outgoing.read()) + chunk = stream.recv(16384) + if not chunk: + raise EOFError() from None + consumed += len(chunk) + if consumed > 131072: + raise ValueError( + "TLS handshake input limit" + ) from None + incoming.write(chunk) + else: + raise ValueError("TLS handshake iteration limit") + except (OSError, EOFError, ValueError) as error: + with contextlib.suppress(OSError): + stream.sendall(outgoing.read()) + emit( + event + | dict( + status="tls-handshake-rejected", + reason=getattr( + error, "reason", type(error).__name__ + ), + ) + ) + except BaseException as error: + emit(dict(status="observer-error", reason=type(error).__name__)) + stop.set() + + workers = [ + threading.Thread(target=serve, args=(port, ctx)) + for port, ctx in contexts.items() + ] + print( + json.dumps(dict(openssl=ssl.OPENSSL_VERSION, ports=list(contexts))), flush=True + ) + for worker in workers: + worker.start() + for worker in workers: + worker.join() + + +if __name__ == "__main__": + main() diff --git a/scripts/src/vcore_scripts/container_udp_origin.py b/scripts/src/vcore_scripts/container_udp_origin.py new file mode 100644 index 0000000..27d4a85 --- /dev/null +++ b/scripts/src/vcore_scripts/container_udp_origin.py @@ -0,0 +1,286 @@ +"""Bounded UDP echo origin, run only inside the owned fixture container. + +A TCP observer allocates one UDP association with a one-byte address family. +The response starts with its two-byte UDP port. Each real datagram is reported +as length/source-port (two network-order u16s), then the exact payload. Echoes +are sent by this container, never by the host test driver. +""" + +from __future__ import annotations + +import contextlib +import os +import select +import socket +import ssl +import struct +import sys +import threading +import time + +CAPACITY = 20000 +INTEGRATION_PROFILE = os.environ.get("VCORE_ORIGIN_PROFILE") == "INTEGRATION" +# Explicit, still bounded pressure profile. Historical protocol fixtures keep +# their original limits; no production queue or socket policy is changed. +SLOTS = threading.BoundedSemaphore(96 if INTEGRATION_PROFILE else 8) +COVER_SLOTS = threading.BoundedSemaphore(8) + + +def receive_exact(stream, size): + data = bytearray() + while len(data) < size: + chunk = stream.recv(min(65536, size - len(data))) + if not chunk: + raise ValueError("truncated fixture request") + data.extend(chunk) + return bytes(data) + + +def serve_tcp(control, mode, ipv6=False): + """10=bulk/server-first, 11=close differential, 12=identity probe. + + Observer A proves an actual accept; D proves exact request bytes and normal + completion. Rejected identities must produce neither. No host-side server. + """ + with socket.socket( + socket.AF_INET6 if ipv6 else socket.AF_INET, socket.SOCK_STREAM + ) as listener: + if ipv6: + listener.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + listener.bind(("::" if ipv6 else "0.0.0.0", 0)) + listener.listen(8) + control.sendall(struct.pack("!H", listener.getsockname()[1])) + for _ in range(32): + ready, _, _ = select.select([control, listener], [], [], 30) + if not ready or control in ready: + return + stream, _ = listener.accept() + control.sendall(b"A") + if mode == 21: + # Generate the withheld tail with the real TLS engine before + # transport EOF. This tests FIN semantics without depending on + # OpenSSL accepting an application write after a ragged EOF. + from tls_bio import TlsBio + + with stream: + stream.settimeout(15) + tls = TlsBio( + stream, tls_context(ssl.TLSVersion.TLSv1_3), server=True + ) + assert tls.tls.version() == "TLSv1.3" + control.sendall(b"\x13") + tls.sendall(b"hello") + data = receive_exact(tls, 65536) + if data != b"Z" * 65536: + raise ValueError("TLS direct close data") + tls.sendall(data) + tail = b"native-after-upload-eof" + assert tls.tls.write(tail) == len(tail) + if stream.recv(1): + raise ValueError("unexpected data after TLS direct request") + with contextlib.suppress(OSError): + tls.flush() + control.sendall(b"D") + continue + if mode in (16, 18): + context = tls_context( + ssl.TLSVersion.TLSv1_3 if mode == 16 else ssl.TLSVersion.TLSv1_2 + ) + stream.settimeout(15) + stream = context.wrap_socket(stream, server_side=True) + control.sendall(b"\x13" if stream.version() == "TLSv1.3" else b"\x12") + with stream: + stream.settimeout(15) + if mode == 13: # bounded-lifetime echo; client-first and cancellation + while data := stream.recv(65536): + stream.sendall(data) + elif mode == 14: # measureDelay/HTTP entrypoint, not a proxy decoder + request = bytearray() + while not request.endswith(b"\r\n\r\n"): + request.extend(receive_exact(stream, 1)) + if len(request) > 16384: + raise ValueError("HTTP origin header limit") + stream.sendall( + b"HTTP/1.1 200 OK\r\nConnection: close\r\n" + b"Content-Length: 0\r\n\r\n" + ) + elif mode == 15: # deliberately withhold all handshake replies + while stream.recv(65536): + pass + elif mode == 12: + if receive_exact(stream, 15) != b"synthetic-probe": + raise ValueError("identity probe data") + stream.sendall(b"ok") + else: + stream.sendall(b"hello") + if mode in (10, 16, 18): + data = receive_exact(stream, 10 * 1024 * 1024) + if data != b"\x5a" * len(data): + raise ValueError("bulk data") + stream.sendall(data + b"trailer") + else: + data = receive_exact(stream, 4) + if data != b"ping": + raise ValueError("close probe data") + stream.sendall(data) + if stream.recv(1): + raise ValueError("unexpected data after request") + if mode == 11: + # Whole-close peers need not accept a tail. + with contextlib.suppress(OSError): + stream.sendall(b"native-after-upload-eof") + control.sendall(b"D") + + +def serve(control): + try: + with control: + control.settimeout(15) + control.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + family = control.recv(1) + if family and (family[0] & 0x7F) in (*range(10, 17), 18, 21): + serve_tcp(control, family[0] & 0x7F, bool(family[0] & 0x80)) + return + if family not in (b"\x04", b"\x06", b"\x11", b"\x13", b"\x14"): + return + ipv6 = family == b"\x06" + with socket.socket( + socket.AF_INET6 if ipv6 else socket.AF_INET, socket.SOCK_DGRAM + ) as udp: + if ipv6: + udp.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) + udp.bind(("::" if ipv6 else "0.0.0.0", 0)) + control.sendall(struct.pack("!H", udp.getsockname()[1])) + peer_ipv4 = ( + socket.inet_ntop(socket.AF_INET, receive_exact(control, 4)) + if family == b"\x13" + else None + ) + seen = False + dns = family in (b"\x11", b"\x13") + dns_deadline = time.monotonic() + 240 + for _ in range(100000 if INTEGRATION_PROFILE else 20000): + wait = ( + min(30, max(0, dns_deadline - time.monotonic())) if dns else 30 + ) + if not wait: + return + ready, _, _ = select.select([control, udp], [], [], wait) + if control in ready: + return # EOF/cancel closes this association's UDP socket. + if not ready: + if dns: + # Runtime-owned DNS can be quiet during TCP or an + # IP-only UDP phase. Keep it until control closes, + # within the same bounded 240-second fixture lifetime. + continue + return + packet, source = udp.recvfrom(CAPACITY + 1) + if len(packet) > CAPACITY: + return + if family == b"\x14": + # A native origin blackhole, not a protocol peer. Report + # only first receipt; all handshake bytes are discarded. + if not seen: + control.sendall(b"A") + seen = True + continue + control.sendall(struct.pack("!HH", len(packet), source[1]) + packet) + response = ( + dns_response(packet, control.getsockname()[0], peer_ipv4) + if family in (b"\x11", b"\x13") + else packet + ) + if udp.sendto(response, source) != len(response): + return + except (OSError, ValueError): + # Client cancellation/failed association is recorded by the test driver. + pass + finally: + SLOTS.release() + + +def dns_response(packet, ipv4, peer_ipv4=None): + """Only the synthetic business name is answerable; never system DNS.""" + question = b"\x0dvcore-fixture\x04test\x00" + peer_question = b"\x04peer\x07fixture\x04test\x00" + if peer_ipv4 and packet[12 : 12 + len(peer_question)] == peer_question: + question, ipv4 = peer_question, peer_ipv4 + if ( + len(packet) < 12 + len(question) + 4 + or packet[12 : 12 + len(question)] != question + ): + raise ValueError("unexpected DNS question (including magic names)") + qtype = struct.unpack("!H", packet[-4:-2])[0] + answer = socket.inet_pton(socket.AF_INET, ipv4) if qtype == 1 else b"" + response = bytearray(packet) + response[2:4] = b"\x81\x80" + response[6:8] = struct.pack("!H", int(bool(answer))) + if answer: + response.extend(b"\xc0\x0c\x00\x01\x00\x01\x00\x00\x00\x00\x00\x04" + answer) + return bytes(response) + + +def tls_context(version): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = version + context.maximum_version = version + context.set_ecdh_curve("X25519") + context.load_cert_chain( + os.environ["VCORE_ORIGIN_CERT"], os.environ["VCORE_ORIGIN_KEY"] + ) + context.set_alpn_protocols(["h2", "http/1.1"]) + context.num_tickets = 0 + return context + + +def serve_camouflage(stream, context): + try: + with stream: + stream.settimeout(5) + with context.wrap_socket(stream, server_side=True) as secured: + secured.recv(1) + except OSError: + pass + finally: + COVER_SLOTS.release() + + +def camouflage(): + """Container-only TLS 1.3 target for REALITY authentication.""" + context = tls_context(ssl.TLSVersion.TLSv1_3) + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("0.0.0.0", 24001)) + listener.listen(8) + while True: + stream, _ = listener.accept() + # Independent XHTTP legs need concurrent cover handshakes. A quiet + # first leg must not hold the accept loop for its five-second bound. + if not COVER_SLOTS.acquire(blocking=False): + stream.close() + continue + threading.Thread( + target=serve_camouflage, args=(stream, context), daemon=True + ).start() + + +def main(): + if sys.platform != "linux" or os.environ.get("VCORE_ISOLATED_ORIGIN") != "1": + raise SystemExit( + "BLOCKED: this origin must be launched by the container harness" + ) + if os.environ.get("VCORE_ORIGIN_CERT"): + threading.Thread(target=camouflage, daemon=True).start() + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("0.0.0.0", 24000)) + listener.listen(8) + while True: + control, _ = listener.accept() + if not SLOTS.acquire(blocking=False): + control.close() + continue + threading.Thread(target=serve, args=(control,), daemon=True).start() + + +if __name__ == "__main__": + main() diff --git a/scripts/src/vcore_scripts/core_checks.py b/scripts/src/vcore_scripts/core_checks.py new file mode 100644 index 0000000..199fbcd --- /dev/null +++ b/scripts/src/vcore_scripts/core_checks.py @@ -0,0 +1,168 @@ +"""Memory-only checks shared by local development and CI. + +Network consumers remain explicit container suites. Keep this allowlist independent +of historical milestone runners: compiling all targets is safe; executing them is not. +""" + +from __future__ import annotations + +import re + +from .builds import CORE_DIR, DEFAULT_FEATURES +from .protocol_peers import run_command + +FEATURE_TEST = ( + "runtime::tests::integration_feature_admission_is_explicit_without_opening_sockets" +) +PROTOCOLS = ("socks5", "anytls", "shadowsocks", "trojan", "vmess", "vless", "hysteria2") +TARGETS = ( + "ech_config", + "ech_tls", + "encryption_config", + "reality_config", + "jls_config", + "security_capabilities", + "retired_security", + "feature_foundations", + "limit_foundations", + "trojan", + "trojan_config", + "vmess_config", + "vless_config", + "vless_lifecycle", + "xhttp_config", + "xhttp_budget", + "h2_stream_regression", + "stream_foundations", + "stream_shutdown", + "xhttp_h3_shutdown", + "hysteria2_paths", + "hysteria2_config", + "shadowsocks_backpressure", + "hysteria2_packet_ids", +) +LIB_FILTERS = ( + "security::", + "outbound::vless::encryption", + "config::", + "outbound::hysteria2::", + "outbound::shadowsocks::tests::official_tcp_", + "outbound::anytls::", + "transport::h2_write::tests::", + "transport::sing_mux::smux_driver::tests::", + "transport::sing_mux::tests::", +) +EXACT_LIB_TESTS = ( + "outbound::connector::tests::authenticated_continuation_keeps_group_choice_but_has_a_new_io_deadline", + FEATURE_TEST, + "ffi::tests::runtime_thread_cannot_reenter_invoke", + "ffi::tests::null_invalid_utf8_and_oversized_input_return_json_failures", + "ffi::tests::same_instance_command_is_fail_fast", + "ffi::tests::android_protector_is_required_only_for_tun", +) + + +def commands(profile: str) -> list[list[str]]: + cargo = ["cargo", "test", "--locked", "--all-features"] + if profile in {"debug", "release"}: + if profile == "release": + cargo += ["--release"] + return [ + cargo + [arg for target in TARGETS for arg in ("--test", target)], + *(cargo + ["--lib", name] for name in LIB_FILTERS), + *(cargo + ["--lib", name, "--", "--exact"] for name in EXACT_LIB_TESTS), + cargo + + [ + "--test", + "quic_datagram_foundations", + "pending_send_is_not_restarted_and_stop_cancels_without_waiting_for_writable", + "--", + "--exact", + ], + ] + if profile == "features": + features = ["outbound-" + protocol for protocol in PROTOCOLS] + return ( + [ + ["cargo", "check", "--locked", "--no-default-features", "--lib"] + + (["--features", name] if name else []) + for name in [ + "", + *features, + "stream-transport", + "quic-transport", + "tun", + "ffi", + ] + ] + + [ + [ + "cargo", + "test", + "--locked", + "--no-default-features", + "--features", + name, + "--lib", + FEATURE_TEST, + "--", + "--exact", + ] + for name in features + ] + + [ + [ + "cargo", + "test", + "--locked", + "--no-default-features", + "--test", + "feature_foundations", + "--test", + "hysteria2_config", + ], + ["cargo", "check", "--locked", "--lib"], + cargo + ["--all-targets", "--no-run"], + [ + "cargo", + "build", + "--locked", + "--release", + "--no-default-features", + "--features", + DEFAULT_FEATURES, + "--lib", + ], + ] + ) + raise ValueError("unknown core check profile") + + +def validate_execution( + command: list[str], output: str, returncode: int, cleanup: bool +) -> None: + if returncode != 0 or not cleanup: + raise RuntimeError("core check failed, timed out, or did not join its process") + if command[:2] != ["cargo", "test"] or "--no-run" in command: + return + summaries = re.findall( + r"test result: ok\. (\d+) passed; (\d+) failed; \d+ ignored;", output + ) + expected = max(1, command.count("--test")) + if len(summaries) != expected or any( + int(passed) == 0 or int(failed) for passed, failed in summaries + ): + raise RuntimeError( + "core check selected no tests or returned incomplete test results" + ) + + +def run(profile: str, *, list_only: bool = False) -> None: + for command in commands(profile): + print(" ".join(command), flush=True) + if list_only: + continue + result = run_command(command, cwd=CORE_DIR, timeout=3600, limit=4 * 1024 * 1024) + output = result.stdout.decode("utf-8", errors="replace") + print(output, end="", flush=True) + validate_execution(command, output, result.returncode, result.cleanup) diff --git a/scripts/src/vcore_scripts/mihomo.py b/scripts/src/vcore_scripts/mihomo.py index 41097f2..96ce408 100644 --- a/scripts/src/vcore_scripts/mihomo.py +++ b/scripts/src/vcore_scripts/mihomo.py @@ -15,7 +15,7 @@ from contextlib import ExitStack from pathlib import Path from .builds import CORE_DIR -from .mihomo_isolation import exclusive_run, reserve_port +from .mihomo_isolation import reserve_port def _stop_peer(peer: subprocess.Popen) -> None: @@ -41,32 +41,6 @@ def _wait_ready(peer, port: int, host: str = "127.0.0.1") -> None: raise RuntimeError("mihomo did not become ready within 10 seconds") -def run_mihomo_interop( - binary: Path | None = None, - *, - extended: bool = False, - soak_seconds: int = 0, - container_binary: Path | None = None, -) -> None: - if not 0 <= soak_seconds <= 7200 or (soak_seconds and not extended): - raise ValueError("soak seconds must be 0..7200 and require --extended") - binary = ( - binary - or Path(os.environ.get("VCORE_MIHOMO_BIN", CORE_DIR / "references/mihomo-test")) - ).resolve() - if not binary.is_file(): - raise RuntimeError( - "mihomo binary is missing; build the reference or pass --binary (NOT RUN)" - ) - with exclusive_run(): - _run_mihomo_interop( - binary, - extended=extended, - soak_seconds=soak_seconds, - container_binary=container_binary, - ) - - def _run_mihomo_interop( binary: Path, *, @@ -320,25 +294,44 @@ def _run_mihomo_interop( if containers: environment.update(containers.environment()) try: - subprocess.run( - [ - "cargo", - "test", - "--locked", - "--features", - "ffi", - "--test", - "mihomo_interop", - "--", - "--ignored", - "--nocapture", - "--test-threads=1", - ], - cwd=CORE_DIR, - env=environment, - check=True, - timeout=300 + soak_seconds, - ) + command = [ + "cargo", + "test", + "--locked", + "--features", + "ffi,interop-test" if os.environ.get("VCORE_CASE_EVENTS") else "ffi", + "--test", + "mihomo_interop", + "public_client_inbounds_interoperate_with_mihomo_in_both_directions", + "--", + "--exact", + "--ignored", + "--nocapture", + "--test-threads=1", + ] + if os.environ.get("VCORE_CASE_EVENTS"): + from .protocol_peers import run_command + + completed = run_command( + command, + cwd=CORE_DIR, + env=environment, + timeout=300 + soak_seconds, + limit=4 * 1024 * 1024, + ) + print(completed.stdout.decode("utf-8", errors="replace"), flush=True) + if completed.returncode != 0 or not completed.cleanup: + raise subprocess.CalledProcessError( + completed.returncode or 1, command + ) + else: + subprocess.run( + command, + cwd=CORE_DIR, + env=environment, + check=True, + timeout=300 + soak_seconds, + ) except (subprocess.CalledProcessError, subprocess.TimeoutExpired): for index in range(len(configs)): print(f"Generated fixture peer {index} diagnostics (last 4096 bytes):") diff --git a/scripts/src/vcore_scripts/mihomo_isolation.py b/scripts/src/vcore_scripts/mihomo_isolation.py index 084d4a1..91dba04 100644 --- a/scripts/src/vcore_scripts/mihomo_isolation.py +++ b/scripts/src/vcore_scripts/mihomo_isolation.py @@ -19,6 +19,9 @@ class PortReservation: # IPv4-only peers take these ports; IPv6 guards remain owned until exit. self.ipv4.close() + def release_ipv6(self) -> None: + self.ipv6.close() + def close(self) -> None: self.ipv4.close() self.ipv6.close() diff --git a/scripts/src/vcore_scripts/mihomo_release.py b/scripts/src/vcore_scripts/mihomo_release.py new file mode 100644 index 0000000..92ea78b --- /dev/null +++ b/scripts/src/vcore_scripts/mihomo_release.py @@ -0,0 +1,231 @@ +"""Download the latest official Mihomo release for interop tests without an API.""" + +from __future__ import annotations + +import gzip +import hashlib +import http.client +import io +import os +import platform +import re +import stat +import tempfile +import time +import urllib.parse +import urllib.request +import zipfile +import zlib +from contextlib import contextmanager +from pathlib import Path, PurePosixPath + +from .builds import CORE_DIR + +LATEST_VERSION_URL = ( + "https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt" +) +RELEASE_URL = "https://github.com/MetaCubeX/mihomo/releases/download" +SUPPORTED_TARGETS = ( + "darwin-arm64", + "darwin-amd64", + "linux-arm64", + "linux-amd64", + "windows-arm64", + "windows-amd64", +) +MAX_VERSION_BYTES = 128 +MAX_ARCHIVE_BYTES = 128 * 1024 * 1024 +MAX_BINARY_BYTES = 256 * 1024 * 1024 +CHUNK_BYTES = 64 * 1024 +TRANSFER_SECONDS = 180 + + +def _target(target: str | None) -> str: + if target is None: + system = platform.system().lower() + machine = platform.machine().lower() + architecture = { + "arm64": "arm64", + "aarch64": "arm64", + "amd64": "amd64", + "x86_64": "amd64", + }.get(machine, machine) + target = f"{system}-{architecture}" + if target not in SUPPORTED_TARGETS: + raise RuntimeError(f"unsupported official mihomo target: {target}") + return target + + +def _release_tag(tag: str) -> str: + if ( + not isinstance(tag, str) + or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", tag) is None + ): + raise RuntimeError("invalid official mihomo download tag") + return tag + + +def _copy_and_hash(source, output, limit: int) -> str: + digest = hashlib.sha256() + total = 0 + deadline = time.monotonic() + TRANSFER_SECONDS + # read1 avoids waiting for a complete chunk before checking the deadline. + read = getattr(source, "read1", source.read) + while True: + if time.monotonic() > deadline: + raise RuntimeError("official mihomo transfer exceeded its time limit") + chunk = read(min(CHUNK_BYTES, limit - total + 1)) + if not chunk: + break + total += len(chunk) + if total > limit: + raise RuntimeError("official mihomo payload exceeded its size limit") + digest.update(chunk) + output.write(chunk) + if not total: + raise RuntimeError("official mihomo payload is empty") + return digest.hexdigest() + + +def _https_response(response) -> None: + if urllib.parse.urlsplit(response.geturl()).scheme != "https": + raise RuntimeError("official mihomo download redirected outside HTTPS") + + +def latest_release() -> str: + """Read the official latest/version.txt asset only to form download filenames.""" + request = urllib.request.Request( + LATEST_VERSION_URL, headers={"User-Agent": "VCore-interop-scripts"} + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + _https_response(response) + contents = io.BytesIO() + _copy_and_hash(response, contents, MAX_VERSION_BYTES) + tag = contents.getvalue().decode("utf-8").strip() + except (OSError, ValueError, http.client.HTTPException) as error: + raise RuntimeError( + f"cannot read official mihomo latest version.txt: {error}" + ) from error + return _release_tag(tag) + + +def _asset(tag: str, target: str) -> tuple[str, str]: + variant = "-v1" if target in {"linux-amd64", "windows-amd64"} else "" + suffix = "zip" if target.startswith("windows-") else "gz" + name = f"mihomo-{target}{variant}-{tag}.{suffix}" + return name, f"{RELEASE_URL}/{tag}/{name}" + + +@contextmanager +def _payload(archive: Path): + if archive.suffix == ".gz": + with gzip.open(archive, "rb") as payload: + yield payload + return + with zipfile.ZipFile(archive) as bundle: + files = [] + for member in bundle.infolist(): + name = member.filename + if ( + PurePosixPath(name).is_absolute() + or ".." in PurePosixPath(name).parts + or "\\" in name + or ":" in name + ): + raise RuntimeError("unsafe path in official mihomo ZIP") + if member.is_dir(): + continue + mode = stat.S_IFMT(member.external_attr >> 16) + if ( + mode not in {0, stat.S_IFREG} + or member.flag_bits & 1 + or len(PurePosixPath(name).parts) != 1 + or not name.endswith(".exe") + ): + raise RuntimeError("unsafe executable entry in official mihomo ZIP") + files.append(member) + if len(files) != 1: + raise RuntimeError( + "official mihomo ZIP must contain exactly one executable" + ) + if not 0 < files[0].file_size <= MAX_BINARY_BYTES: + raise RuntimeError("official mihomo executable exceeds its size limit") + with bundle.open(files[0]) as payload: + yield payload + + +def _report( + tag: str, asset: str, url: str, archive_digest: str, digest: str, binary: Path +): + print(f"Mihomo download release tag: {tag}", flush=True) + print(f"Official mihomo asset: {asset}", flush=True) + print(f"Official mihomo download URL: {url}", flush=True) + print(f"Mihomo archive SHA-256 (downloaded content): {archive_digest}", flush=True) + print(f"Mihomo binary SHA-256 (downloaded content): {digest}", flush=True) + print(f"Official mihomo binary: {binary}", flush=True) + + +def download_mihomo( + target: str | None = None, + *, + release: str | None = None, + directory: Path | None = None, + identity: dict | None = None, +) -> Path: + """Download a fresh official archive and atomically publish its binary. + + Callers can share a freshly read version.txt tag for multiple architectures. + Every call downloads again; failures neither replace nor return an old binary. + Hashes identify downloaded content, without claiming an upstream digest check. + The harness records the actual version by running the binary with -v. + """ + target = _target(target) + if release is None: + release = latest_release() + tag = _release_tag(release) + name, url = _asset(tag, target) + directory = ( + directory + if directory is not None + else CORE_DIR / "target/interop/mihomo" / tag / target + ) + binary = directory / ("mihomo.exe" if target.startswith("windows-") else "mihomo") + request = urllib.request.Request( + url, headers={"User-Agent": "VCore-interop-scripts"} + ) + try: + directory.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".download-", dir=directory) as staging: + staged_archive = Path(staging) / name + staged_binary = Path(staging) / binary.name + with ( + urllib.request.urlopen(request, timeout=30) as response, + staged_archive.open("wb") as output, + ): + _https_response(response) + archive_digest = _copy_and_hash(response, output, MAX_ARCHIVE_BYTES) + with _payload(staged_archive) as source, staged_binary.open("wb") as output: + digest = _copy_and_hash(source, output, MAX_BINARY_BYTES) + staged_binary.chmod(0o755) + os.replace(staged_binary, binary) + except ( + OSError, + EOFError, + http.client.HTTPException, + zipfile.BadZipFile, + zlib.error, + ) as error: + raise RuntimeError(f"cannot download official mihomo {tag}: {error}") from error + _report(tag, name, url, archive_digest, digest, binary) + if identity is not None: + identity.update( + kind="M", + target=target, + release=tag, + asset=name, + source_url=url, + archive_sha256=archive_digest, + binary_sha256=digest, + ) + return binary diff --git a/scripts/src/vcore_scripts/native_release.py b/scripts/src/vcore_scripts/native_release.py new file mode 100644 index 0000000..a2b5852 --- /dev/null +++ b/scripts/src/vcore_scripts/native_release.py @@ -0,0 +1,229 @@ +"""Fresh official native peers. No API lookup, source build or stale fallback.""" + +from __future__ import annotations + +import http.client +import os +import platform +import re +import stat +import subprocess +import tarfile +import tempfile +import urllib.request +import zipfile +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +from .mihomo_release import ( + MAX_ARCHIVE_BYTES, + MAX_BINARY_BYTES, + _copy_and_hash, + _https_response, +) +from .protocol_peers import run_command + + +@dataclass(frozen=True) +class PeerArtifact: + binary: Path + identity: dict + + +def download_native( + kind: str, + directory: Path, + target: str | None = None, + *, + defer_version: bool = False, +) -> PeerArtifact: + try: + return _download_native(kind, directory, target, defer_version) + except ( + OSError, + ValueError, + zipfile.BadZipFile, + tarfile.TarError, + http.client.HTTPException, + subprocess.SubprocessError, + ): + raise RuntimeError( + "official native peer download or version check failed" + ) from None + + +def _download_native( + kind: str, directory: Path, target: str | None, defer_version: bool +) -> PeerArtifact: + """Install only a named executable into this run's owned directory.""" + if target is None: + architecture = {"aarch64": "arm64", "x86_64": "amd64"}.get( + platform.machine().lower(), platform.machine().lower() + ) + target = f"{platform.system().lower()}-{architecture}" + assets = { + "SS": ( + "shadowsocks/shadowsocks-rust", + "ssserver", + {"linux-arm64": "aarch64-unknown-linux-musl"}, + ), + "V2": ( + "v2fly/v2ray-core", + "v2ray", + { + "darwin-arm64": "v2ray-macos-arm64-v8a.zip", + "darwin-amd64": "v2ray-macos-64.zip", + "linux-arm64": "v2ray-linux-arm64-v8a.zip", + "linux-amd64": "v2ray-linux-64.zip", + }, + ), + "XR": ( + "XTLS/Xray-core", + "xray", + { + "darwin-arm64": "Xray-macos-arm64-v8a.zip", + "darwin-amd64": "Xray-macos-64.zip", + "linux-arm64": "Xray-linux-arm64-v8a.zip", + "linux-amd64": "Xray-linux-64.zip", + }, + ), + "H": ( + "HyNetworks/hysteria", + "hysteria", + { + target: f"hysteria-{target}" + for target in [ + "darwin-arm64", + "darwin-amd64", + "linux-arm64", + "linux-amd64", + ] + }, + ), + } + if kind not in assets or target not in assets[kind][2]: + raise RuntimeError("unsupported official native peer target") + project, name, targets = assets[kind] + asset = targets[target] + if kind == "SS": + # SS assets include their tag; follow latest's HTTP redirect, not an API + # or a pinned version. The executable reports its own actual version. + request = urllib.request.Request( + f"https://github.com/{project}/releases/latest", + method="HEAD", + headers={"User-Agent": "VCore-interop-scripts"}, + ) + with urllib.request.urlopen(request, timeout=30) as response: + prefix = f"https://github.com/{project}/releases/tag/" + resolved = response.geturl() + if not resolved.startswith(prefix) or not re.fullmatch( + r"v\d+\.\d+\.\d+", resolved[len(prefix) :] + ): + raise RuntimeError("invalid official SS latest stable redirect") + tag = resolved[len(prefix) :] + asset = f"shadowsocks-{tag}.{asset}.tar.xz" + url = f"https://github.com/{project}/releases/latest/download/{asset}" + directory.mkdir(parents=True, exist_ok=True) + binary = directory / name + with tempfile.TemporaryDirectory(prefix=".download-", dir=directory) as temporary: + archive, executable = Path(temporary) / "archive.zip", Path(temporary) / name + request = urllib.request.Request( + url, headers={"User-Agent": "VCore-interop-scripts"} + ) + with ( + urllib.request.urlopen(request, timeout=30) as response, + archive.open("wb") as output, + ): + _https_response(response) + archive_hash = _copy_and_hash(response, output, MAX_ARCHIVE_BYTES) + if kind == "H": + with archive.open("rb") as source, executable.open("wb") as output: + binary_hash = _copy_and_hash(source, output, MAX_BINARY_BYTES) + elif kind == "SS": + binary_hash = _extract_tar(archive, executable, name) + else: + binary_hash = _extract_zip(archive, executable, name) + executable.chmod(0o755) + # Foreign executables are verified inside the owned VM before traffic; + # a deferred artifact is not yet a ready/verified peer. + version = None + if not defer_version: + result = run_command( + [str(executable), "--version" if kind == "SS" else "version"], + timeout=10, + limit=4096, + ) + if result.returncode != 0 or not result.cleanup: + raise RuntimeError("official native peer version check failed") + version = result.stdout.decode("utf-8", errors="replace").strip() + if not version: + raise RuntimeError("invalid official native peer version output") + os.replace(executable, binary) + return PeerArtifact( + binary, + { + "kind": kind, + "target": target, + "source_url": url, + "archive_sha256": archive_hash, + "binary_sha256": binary_hash, + "version": version, + }, + ) + + +def _extract_zip(archive: Path, executable: Path, name: str) -> str: + with zipfile.ZipFile(archive) as bundle: + matches = [] + for member in bundle.infolist(): + path = PurePosixPath(member.filename) + mode = stat.S_IFMT(member.external_attr >> 16) + if ( + path.is_absolute() + or ".." in path.parts + or "\\" in member.filename + or ":" in member.filename + or mode not in {0, stat.S_IFREG, stat.S_IFDIR} + or member.flag_bits & 1 + ): + raise RuntimeError("unsafe official native peer archive") + if member.filename == name: + matches.append(member) + if len(matches) != 1 or not 0 < matches[0].file_size <= MAX_BINARY_BYTES: + raise RuntimeError("invalid official native peer executable") + with bundle.open(matches[0]) as source, executable.open("wb") as output: + binary_hash = _copy_and_hash(source, output, MAX_BINARY_BYTES) + return binary_hash + + +def _extract_tar(archive: Path, executable: Path, name: str) -> str: + """Stream a bounded official bundle; never extract archive paths or links.""" + found, total, digest = 0, 0, None + with tarfile.open(archive, "r|xz") as bundle: + for index, entry in enumerate(bundle): + path = PurePosixPath(entry.name) + total += entry.size + if ( + index >= 32 + or total > MAX_BINARY_BYTES + or path.is_absolute() + or ".." in path.parts + or "\\" in entry.name + or ":" in entry.name + or not (entry.isfile() or entry.isdir()) + ): + raise RuntimeError("unsafe official native peer archive") + if entry.name != name: + continue + found += 1 + if ( + found != 1 + or not entry.isfile() + or not 0 < entry.size <= MAX_BINARY_BYTES + ): + raise RuntimeError("invalid official native peer executable") + with bundle.extractfile(entry) as source, executable.open("wb") as output: + digest = _copy_and_hash(source, output, MAX_BINARY_BYTES) + if found != 1: + raise RuntimeError("missing official native peer executable") + return digest diff --git a/scripts/src/vcore_scripts/platform_delivery.py b/scripts/src/vcore_scripts/platform_delivery.py new file mode 100644 index 0000000..5df4fc0 --- /dev/null +++ b/scripts/src/vcore_scripts/platform_delivery.py @@ -0,0 +1,495 @@ +"""Production build evidence. This is not device or signed-release acceptance.""" + +from __future__ import annotations + +import hashlib +import json +import os +import platform +import plistlib +import shutil +import stat +import subprocess +from datetime import UTC, datetime +from pathlib import Path, PurePosixPath + +from . import builds + +GROUPS = {"apple", "android", "windows-arm64", "windows-x64"} +APPLE_LIBRARIES = { + "ios-arm64": ("ios", None, {"arm64"}), + "ios-arm64_x86_64-simulator": ("ios", "simulator", {"arm64", "x86_64"}), + "macos-arm64_x86_64": ("macos", None, {"arm64", "x86_64"}), +} + + +def _sha(path: Path) -> str: + with path.open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def _output(argv: list[str], root: Path) -> str: + return subprocess.check_output(argv, cwd=root, text=True, timeout=60).strip() + + +def _source(root: Path) -> dict: + if _output(["git", "status", "--porcelain", "--untracked-files=normal"], root): + raise ValueError("delivery requires a clean committed source checkout") + return { + "commit": _output(["git", "rev-parse", "HEAD"], root), + "tree": _output(["git", "rev-parse", "HEAD^{tree}"], root), + "lockSha256": _sha(root / "Cargo.lock"), + } + + +def check_delivery( + manifests: list[Path], *, source_dir: Path | None = None, complete: bool = False +) -> None: + source = _source(source_dir or builds.CORE_DIR) + groups = set() + if not manifests: + raise ValueError("empty manifest set cannot pass") + for manifest in manifests: + record = json.loads(manifest.read_text(encoding="utf-8")) + if ( + record.get("formatVersion") != 1 + or record.get("profile") != "release" + or record.get("features") != builds.DEFAULT_FEATURES.split(",") + or record.get("buildIdentity") != builds.EXPECTED_IDENTITY.decode() + ): + raise ValueError("incompatible production artifact metadata") + if record.get("source") != source: + raise ValueError("artifact source/lock identity does not match checkout") + group = record.get("group") + if group not in GROUPS or group in groups: + raise ValueError("duplicate delivery group") + groups.add(group) + toolchain = record.get("toolchain", {}) + required = {"rustc", "cargo"} | ( + {"ndk", "clang", "androidApi"} + if group == "android" + else {"xcode", "iphoneos", "iphonesimulator", "macosx"} + if group == "apple" + else {"msvc", "windowsSdk"} + ) + if not all( + isinstance(toolchain.get(key), str) and toolchain[key] for key in required + ): + raise ValueError("missing toolchain evidence") + rows = record.get("artifacts", []) + if not rows: + raise ValueError("empty artifact set cannot pass") + names = set() + for row in rows: + name = row["path"] + relative = PurePosixPath(name) + if ( + relative.is_absolute() + or ".." in relative.parts + or "\\" in name + or str(relative) != name + or name in names + ): + raise ValueError("invalid or duplicate artifact path") + names.add(name) + base = manifest.parent.resolve() + path = base / name + if not path.is_file() or any( + base.joinpath(*relative.parts[:index]).is_symlink() + for index in range(1, len(relative.parts) + 1) + ): + raise ValueError(f"missing or non-regular artifact: {name}") + if path.stat().st_size != row["size"] or _sha(path) != row["sha256"]: + raise ValueError(f"artifact hash/size mismatch: {name}") + actual = { + p.relative_to(manifest.parent).as_posix() + for p in manifest.parent.rglob("*") + if p.is_file() and p != manifest + } + if actual != names: + raise ValueError("unrecorded or missing artifact files") + if record.get("group") == "android": + expected = { + f"{abi}/{name}" + for abi in ("arm64-v8a", "x86_64") + for name in ("libvcore.so", "libc++_shared.so") + } + if names != expected: + raise ValueError("Android delivery requires both ABIs and C++ runtimes") + for abi, machine in (("arm64-v8a", 183), ("x86_64", 62)): + for name in ("libvcore.so", "libc++_shared.so"): + path = manifest.parent / abi / name + with path.open("rb") as stream: + header = stream.read(20) + if ( + header[:7] != b"\x7fELF\x02\x01\x01" + or len(header) != 20 + or int.from_bytes(header[16:18], "little") != 3 + or int.from_bytes(header[18:20], "little") != machine + ): + raise ValueError( + f"wrong Android ELF architecture: {abi}/{name}" + ) + if name == "libvcore.so": + builds._require_identity(path, "Android") + elif group == "apple": + expected = {"LibVCore.xcframework/Info.plist"} | { + f"LibVCore.xcframework/{identifier}/{name}" + for identifier in APPLE_LIBRARIES + for name in ( + "libvcore.a", + "Headers/vcore.h", + "Headers/module.modulemap", + ) + } + if names != expected: + raise ValueError("incomplete Apple XCFramework") + with (manifest.parent / "LibVCore.xcframework/Info.plist").open( + "rb" + ) as stream: + libraries = plistlib.load(stream).get("AvailableLibraries", []) + if len(libraries) != 3: + raise ValueError("incomplete Apple platform slices") + identifiers = set() + for library in libraries: + identifier = library.get("LibraryIdentifier") + if identifier not in APPLE_LIBRARIES or identifier in identifiers: + raise ValueError("invalid Apple library identifier") + identifiers.add(identifier) + target_os, variant, architectures = APPLE_LIBRARIES[identifier] + if ( + library.get("SupportedPlatform") != target_os + or library.get("SupportedPlatformVariant") != variant + or set(library.get("SupportedArchitectures", [])) != architectures + or library.get("LibraryPath") != "libvcore.a" + or library.get("HeadersPath") != "Headers" + ): + raise ValueError("invalid Apple slice metadata") + path = ( + manifest.parent / "LibVCore.xcframework" / identifier / "libvcore.a" + ) + actual_archs = set( + _output( + ["xcrun", "lipo", "-archs", str(path)], builds.CORE_DIR + ).split() + ) + if actual_archs != architectures: + raise ValueError("wrong Apple binary architecture") + builds._require_identity(path, "Apple") + else: + arch = group.removeprefix("windows-") + expected = { + "vcore.dll", + "vcore-windows-vpn-host.exe", + "vcore-windows-session-host.exe", + } + if names != expected | {"vcore-windows-artifacts.json"}: + raise ValueError("incomplete Windows artifact set") + if ( + record.get("host", {}).get("os") != "Windows" + or record["host"].get("architecture") != arch + ): + raise ValueError( + "Windows delivery requires native OS architecture evidence" + ) + for name in expected: + builds._require_windows_architecture(manifest.parent / name, arch) + builds._require_identity(manifest.parent / "vcore.dll", "Windows") + package = json.loads( + (manifest.parent / "vcore-windows-artifacts.json").read_text() + ) + if ( + package.get("formatVersion") != 1 + or package.get("architecture") != arch + or package.get("windowsPackageIntegrationRevision") != 3 + or package.get("buildIdentity") != record["buildIdentity"] + or package.get("artifacts") + != { + row["path"]: row["sha256"] + for row in rows + if row["path"] in expected + } + ): + raise ValueError("Windows package integration identity mismatch") + print( + f"PASS {record['group']} artifact integrity (not device/release acceptance)" + ) + if complete and groups != GROUPS: + raise ValueError( + "complete platform artifacts require Apple, Android, " + "native Windows ARM64 and x64" + ) + if complete: + print( + "PASS production platform artifacts; " + "ABI, devices and release require separate evidence" + ) + + +def build_delivery(platform_name: str) -> None: + if os.environ.get("VCORE_BUILD_PROFILE", "release") != "release": + raise ValueError("delivery requires the release profile") + if ( + os.environ.get("VCORE_FEATURES", builds.DEFAULT_FEATURES) + != builds.DEFAULT_FEATURES + ): + raise ValueError("delivery requires the complete production feature set") + # Keep the recorded build command honest: existing development builds still + # allow customization, but acceptance must not inherit hidden overrides. + overrides = [ + name + for name in os.environ + if ( + name + in { + "RUSTFLAGS", + "CARGO_ENCODED_RUSTFLAGS", + "CARGO_TARGET_DIR", + "VCORE_ANDROID_OUTPUT_DIR", + "VCORE_APPLE_DIST_DIR", + "VCORE_ANDROID_TARGETS", + } + or name.startswith("CARGO_PROFILE_") + ) + and os.environ[name] + ] + if overrides: + raise ValueError( + "unsupported delivery build overrides: " + ", ".join(sorted(overrides)) + ) + source = _source(builds.CORE_DIR) + group = platform_name + architecture = platform.machine().lower() + toolchain = { + "rustc": _output(["rustc", "-Vv"], builds.CORE_DIR), + "cargo": _output(["cargo", "--version"], builds.CORE_DIR), + } + if platform_name == "windows": + if os.name != "nt": + raise ValueError("Windows delivery must run on native Windows") + architecture = builds._windows_architecture() + group += "-" + architecture + environment = builds._windows_msvc_environment(architecture) + # Windows env names are case insensitive, even when a subprocess returns + # a plain Python dict. Never persist the full environment (credentials). + normalized = {key.upper(): value for key, value in environment.items()} + toolchain["msvc"] = normalized.get("VCTOOLSVERSION", "").strip() + toolchain["windowsSdk"] = normalized.get("WINDOWSSDKVERSION", "").strip("\\ ") + toolchain["cmake"] = _output(["cmake", "--version"], builds.CORE_DIR) + toolchain["generator"] = "Ninja" if architecture == "arm64" else "Visual Studio" + if architecture == "arm64": + toolchain["clang"] = _output( + [normalized["VCORE_WINDOWS_ARM64_CLANG"], "--version"], builds.CORE_DIR + ) + toolchain["assembly"] = "enabled" + elif platform_name == "apple": + toolchain["xcode"] = _output(["xcodebuild", "-version"], builds.CORE_DIR) + for sdk in ("iphoneos", "iphonesimulator", "macosx"): + toolchain[sdk] = _output( + ["xcrun", "--sdk", sdk, "--show-sdk-version"], builds.CORE_DIR + ) + toolchain["iosDeploymentTarget"] = os.environ.get( + "VCORE_IOS_DEPLOYMENT_TARGET", "13.0" + ) + toolchain["macosDeploymentTarget"] = os.environ.get( + "VCORE_MACOS_DEPLOYMENT_TARGET", "10.15" + ) + else: + android_home = Path( + os.environ.get("ANDROID_HOME", Path.home() / "Library/Android/sdk") + ) + ndk = Path( + os.environ.get( + "ANDROID_NDK_HOME", + android_home + / "ndk" + / os.environ.get("VCORE_ANDROID_NDK_VERSION", "28.2.13676358"), + ) + ) + toolchain["ndk"] = (ndk / "source.properties").read_text().strip() + toolchain["clang"] = _output( + [str(builds._android_toolchain(ndk) / "bin/clang"), "--version"], + builds.CORE_DIR, + ) + toolchain["androidApi"] = os.environ.get("VCORE_ANDROID_API", "24") + output = builds.CORE_DIR / "dist" / platform_name + if platform_name == "windows": + output /= architecture + # Check the lexical path before resolving, unlinking a manifest, or letting + # a builder clean output. CORE_DIR is the canonical, trusted checkout root; + # Windows junctions and other reparse points can redirect descendants too. + current = builds.CORE_DIR + for part in output.relative_to(builds.CORE_DIR).parts: + current /= part + try: + metadata = current.lstat() + except FileNotFoundError: + break + if stat.S_ISLNK(metadata.st_mode) or ( + getattr(metadata, "st_file_attributes", 0) + & stat.FILE_ATTRIBUTE_REPARSE_POINT + ): + raise ValueError( + "delivery output must not contain symlinks or reparse points" + ) + manifest = output / "vcore-delivery.json" + if platform_name == "android": + # Development builds can leave additional ABIs in the same ignored + # directory. Never mix those artifacts into a fresh delivery manifest. + if output.exists(): + shutil.rmtree(output) + else: + manifest.unlink(missing_ok=True) + started = datetime.now(UTC).isoformat() + { + "apple": builds.build_apple, + "android": builds.build_android, + "windows": builds.build_windows, + }[platform_name]() + if _source(builds.CORE_DIR) != source: + raise ValueError("source changed during delivery build") + record = { + "formatVersion": 1, + "group": group, + "profile": "release", + "source": source, + "features": builds.DEFAULT_FEATURES.split(","), + "buildIdentity": builds.EXPECTED_IDENTITY.decode(), + "host": { + "os": platform.system(), + "architecture": architecture, + "osVersion": platform.release(), + }, + "toolchain": toolchain, + "startedUtc": started, + "finishedUtc": datetime.now(UTC).isoformat(), + "artifacts": [ + { + "path": path.relative_to(output).as_posix(), + "size": path.stat().st_size, + "sha256": _sha(path), + } + for path in sorted(output.rglob("*")) + if path.is_file() + ], + } + manifest.write_text( + json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + try: + check_delivery([manifest]) + except Exception: + manifest.unlink(missing_ok=True) + raise + print(manifest) + + +def check_abi(manifest: Path) -> None: + """Compile a real C consumer; no replacement library and no protocol servers.""" + # Commands below run from an isolated output directory, not the caller cwd. + manifest = manifest.resolve() + check_delivery([manifest]) + record = json.loads(manifest.read_text(encoding="utf-8")) + group = record["group"] + root = builds.CORE_DIR + work = root / "target/platform-delivery/abi" / group + work.mkdir(parents=True, exist_ok=True) + (work / "result.json").unlink(missing_ok=True) + source = root / "scripts/fixtures/platform_abi.c" + binary = work / ("abi.exe" if os.name == "nt" else "abi") + environment = os.environ.copy() + if group == "apple" and platform.system() == "Darwin": + library = manifest.parent / "LibVCore.xcframework/macos-arm64_x86_64/libvcore.a" + command = [ + "xcrun", + "clang", + "-Wall", + "-Wextra", + "-Werror", + "-I", + str(root / "include"), + str(source), + str(library), + "-lc++", + "-lresolv", + "-framework", + "Security", + "-framework", + "SystemConfiguration", + "-framework", + "CoreFoundation", + "-o", + str(binary), + ] + execute = [str(binary)] + architecture = platform.machine() + elif group.startswith("windows-") and os.name == "nt": + architecture = builds._windows_architecture() + if group != "windows-" + architecture: + raise ValueError("ABI check cannot substitute emulation for native Windows") + environment = builds._windows_msvc_environment(architecture) + search_path = next( + value for key, value in environment.items() if key.upper() == "PATH" + ) + # CreateProcess does not use env[PATH] to resolve the executable. Resolve + # cl from vcvars explicitly rather than requiring it in the parent PATH. + compiler = shutil.which("cl", path=search_path) + if not compiler: + raise ValueError( + "MSVC C compiler is unavailable in the selected environment" + ) + command = [ + compiler, + "/nologo", + "/std:c11", + "/W4", + "/WX", + "/MT", + "/I" + str(root / "include"), + str(source), + "/Fe:" + str(binary), + "/Fo:" + str(work / "abi.obj"), + ] + execute = [str(binary), str(manifest.parent / "vcore.dll")] + else: + raise ValueError( + "native ABI runner requires a matching macOS or Windows artifact" + ) + subprocess.run(command, cwd=work, env=environment, check=True, timeout=120) + subprocess.run(execute, cwd=work, env=environment, check=True, timeout=60) + if group.startswith("windows-"): + target = {"arm64": "aarch64-pc-windows-msvc", "x64": "x86_64-pc-windows-msvc"}[ + architecture + ] + base = [ + "cargo", + "test", + "--locked", + "--release", + "--target", + target, + "--features", + "ffi", + ] + for suffix in ( + ["--lib", "windows::snapshot::tests"], + ["--test", "windows_session_startup"], + ): + subprocess.run( + base + suffix, cwd=root, env=environment, check=True, timeout=1200 + ) + if _source(root) != record["source"]: + raise ValueError("source changed during ABI check") + evidence = { + "kind": "native-production-abi", + "group": group, + "source": record["source"], + "manifestSha256": _sha(manifest), + "architecture": architecture, + "iterations": 1000, + "invalidApiRejected": True, + "finishedUtc": datetime.now(UTC).isoformat(), + } + (work / "result.json").write_text( + json.dumps(evidence, indent=2) + "\n", encoding="utf-8" + ) diff --git a/scripts/src/vcore_scripts/protocol_catalogs.py b/scripts/src/vcore_scripts/protocol_catalogs.py new file mode 100644 index 0000000..fa73c08 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_catalogs.py @@ -0,0 +1,84 @@ +"""Executable catalog integrity; production fields are documented in config.yaml.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +CATALOG_DIR = Path(__file__).resolve().parents[3] / "tests" / "protocols" +PROTOCOLS = {"trojan", "vmess", "vless", "hysteria2"} +PEERS = {"M", "H", "XR", "V2"} +SUITES = { + "FOUNDATIONS": ( + "schema", + "security", + "streams", + "datagrams", + "features", + "regression", + ), + "TROJAN": ("codec", "configuration", "cancellation", "acceptance"), + "VMESS": ("identity", "codec", "udp", "close", "acceptance"), + "VLESS": ("tcp", "transports", "vision", "acceptance"), + "XHTTP": ("requests", "download", "mux", "http3", "acceptance"), + "HYSTERIA2": ("tcp", "udp", "bandwidth", "salamander", "hopping", "acceptance"), + "SECURITY": ("encryption", "reality", "ech", "jls", "acceptance"), + "INTEGRATION": ("routing", "lifecycle", "pressure", "regression"), +} +CATEGORIES = { + f"{suite}.{category}" + for suite, categories in SUITES.items() + for category in categories +} +# Stable evidence tags; field semantics live in docs/config.yaml, not a planning mirror. +FIELD_IDS = { + f"{prefix}{index:02}" + for prefix, count in { + "C": 6, + "T": 8, + "W": 7, + "G": 6, + "H": 6, + "TR": 2, + "VM": 8, + "VL": 6, + "X": 29, + "D": 32, + "S": 13, + "M": 7, + "HY": 8, + }.items() + for index in range(1, count + 1) + if not ( + (prefix == "D" and 19 <= index <= 24) or (prefix == "S" and 6 <= index <= 11) + ) +} + + +def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError("protocol catalog: duplicate JSON key") + result[key] = value + return result + + +def check_protocol_catalogs(directory: Path = CATALOG_DIR) -> None: + from .protocol_evidence import check_limit_references, load_manifest + + cases = load_manifest() + check_limit_references(cases, directory / "limits.json") + print( + json.dumps( + { + "kind": "executable-catalog-check", + "status": "VALID", + "behavior_status": "NOT RUN", + "cases": len(cases), + "fields": len(set().union(*(set(case["row_ids"]) for case in cases))), + }, + sort_keys=True, + ) + ) diff --git a/scripts/src/vcore_scripts/protocol_containers.py b/scripts/src/vcore_scripts/protocol_containers.py new file mode 100644 index 0000000..97d21e6 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_containers.py @@ -0,0 +1,234 @@ +"""Owned Apple Container test peers; no native-server fallback or host publishing.""" + +from __future__ import annotations + +import hashlib +import ipaddress +import json +import re +import socket +import time +import uuid +from contextlib import contextmanager +from contextvars import ContextVar +from pathlib import Path + +from .protocol_inputs import redact +from .protocol_peers import OwnedProcess, run_command + +NETWORK = "vcore-mihomo-interop" +IMAGE = "docker.io/library/python:3-alpine" +_FROZEN_IMAGE = ContextVar("vcore_frozen_container_image", default=None) + + +def _pull_image(log=None): + argv = ["container", "image", "pull", IMAGE] + result = run_command(argv, timeout=180) + if log is not None: + log.write_text(redact(result.stdout.decode(errors="replace"))) + if result.returncode != 0 or not result.cleanup: + raise RuntimeError("isolated container image pull failed") + inspection = json.loads(command("image", "inspect", IMAGE))[0] + digest = inspection["configuration"]["descriptor"]["digest"] + if not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", digest): + raise RuntimeError("invalid isolated container image identity") + return dict( + tag=IMAGE, + digest=digest, + command=argv, + exit_code=0, + cleanup=True, + log=log.name if log is not None else None, + ) + + +@contextmanager +def frozen_image(log): + """Refresh once per owned run, never a persistent/offline fallback cache.""" + if _FROZEN_IMAGE.get() is not None: + raise RuntimeError("container image scope is already frozen") + snapshot = _pull_image(log) + token = _FROZEN_IMAGE.set(snapshot["digest"]) + try: + yield snapshot + finally: + _FROZEN_IMAGE.reset(token) + + +def command(*args, timeout=30): + result = run_command(["container", *args], timeout=timeout) + if result.returncode != 0 or not result.cleanup: + # CLI output may contain fixture paths/config; retain a bounded operation only. + raise RuntimeError(f"isolated container operation failed: {args[0]}") + return result.stdout.decode(errors="replace") + + +def listing(): + return json.loads(command("list", "--all", "--format", "json")) + + +class ContainerLab: + def __init__(self, record, *, mtu=1280): + if type(mtu) is not int or mtu not in (1280, 1500): + raise ValueError("unsupported isolated guest MTU") + self.mtu = mtu + self.record = record + self.run_id = uuid.uuid4().hex[:12] + network = json.loads(command("network", "inspect", NETWORK))[0] + config = network["configuration"] + if ( + config["mode"] != "hostOnly" + or config.get("labels", {}).get("purpose") != NETWORK + ): + raise RuntimeError("owned host-only container network required") + self.v4 = ipaddress.ip_network(network["status"]["ipv4Subnet"]) + self.v6 = ipaddress.ip_network(network["status"]["ipv6Subnet"]) + digest = _FROZEN_IMAGE.get() + if digest is None: + digest = _pull_image()["digest"] + self.image = IMAGE.split(":")[0] + "@" + digest + record.update( + backend="Apple Container", + network_mode="hostOnly", + image_tag=IMAGE, + image_digest=digest, + run_id=self.run_id, + peers=[], + host_servers=False, + guest_mtu=mtu, + ) + + def start(self, stack, root: Path, role, argv, *, net_admin=False): + peer = ContainerPeer(self, root, role) + self.record["peers"].append(peer.record) + # Register before launching: CLI interruption/timeout can leave a live VM. + stack.callback(peer.stop) + peer.start(argv, net_admin=net_admin) + return peer + + +class ContainerPeer: + def __init__(self, lab, root, role): + self.lab, self.root = lab, root + self.name = f"vcore-interop-{lab.run_id}-{role}" + if len(self.name) > 63: + digest = hashlib.sha256(self.name.encode()).hexdigest()[:8] + self.name = self.name[:54] + "-" + digest + self.record = dict(role=role, name=self.name, joined=False, started=False) + self.capture = None + self.log = root / "peer.log" + + def start(self, argv, *, net_admin=False): + if type(net_admin) is not bool: + raise ValueError("isolated capability choice must be boolean") + self.record["net_admin"] = net_admin + command( + "run", + "--detach", + "--name", + self.name, + "--label", + f"purpose={NETWORK}", + "--label", + f"vcore-run={self.lab.run_id}", + "--network", + f"{NETWORK},mtu={self.lab.mtu}", + "--cpus", + "1", + "--memory", + "256M", + "--arch", + "arm64", + *(["--cap-add", "CAP_NET_ADMIN"] if net_admin else ["--read-only"]), + "--no-dns", + "--tmpfs", + "/data", + "--mount", + f"type=bind,source={self.root},target=/data/fixture,readonly", + "--entrypoint", + "/bin/sh", + self.lab.image, + "-ec", + "for i in $(seq 1 600); do " + 'if [ -f /data/fixture/ready ]; then exec "$@"; fi; ' + "sleep 0.1; done; exit 1", + "fixture", + *argv, + timeout=45, + ) + self.record["started"] = True + self.capture = OwnedProcess( + ["container", "logs", "--follow", self.name], + self.log, + {}, + limit=1024 * 1024, + ).__enter__() + state = json.loads(command("inspect", self.name))[0] + self.record["guest_mtu"] = state["status"]["networks"][0]["mtu"] + if self.record["guest_mtu"] != self.lab.mtu: + raise RuntimeError("isolated guest MTU differs from requested value") + self.ipv4 = state["status"]["networks"][0]["ipv4Address"].split("/")[0] + if ipaddress.ip_address(self.ipv4) not in self.lab.v4: + raise RuntimeError("container address outside owned network") + # Wait for SLAAC/DAD before any business traffic. No host IPv6 binds needed. + query = ( + "import pathlib,json; print(json.dumps([" + "[x[0],int(x[4],16)] for line in " + "pathlib.Path('/proc/net/if_inet6').read_text().splitlines() " + "if (x:=line.split())[5]=='eth0']))" + ) + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + rows = json.loads(command("exec", self.name, "python", "-c", query)) + for address, flags in rows: + ip = ipaddress.IPv6Address(int(address, 16)) + if ip in self.lab.v6 and not flags & (0x40 | 0x08): + self.ipv6 = str(ip) + return + time.sleep(0.1) + raise RuntimeError("container IPv6 readiness failed") + + def release(self): + (self.root / "ready").touch() + + def ensure_alive(self): + state = json.loads(command("inspect", self.name))[0]["status"]["state"] + if state != "running" or (self.capture and self.capture.overflow.is_set()): + raise RuntimeError("owned container peer exited or exceeded output bound") + + def wait_tcp(self, port): + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + self.ensure_alive() + try: + with socket.create_connection((self.ipv4, port), timeout=0.2): + self.record["ready"] = True + return + except OSError: + time.sleep(0.05) + raise TimeoutError("container service readiness failed") + + def stop(self): + try: + owned = next((item for item in listing() if item["id"] == self.name), None) + if owned is not None: + labels = owned["configuration"].get("labels", {}) + if ( + labels.get("purpose") != NETWORK + or labels.get("vcore-run") != self.lab.run_id + ): + raise RuntimeError("container ownership mismatch; refusing cleanup") + try: + if owned["status"]["state"] == "running": + command("stop", "--time", "5", self.name, timeout=15) + finally: + command("delete", "--force", self.name, timeout=15) + finally: + if self.capture is not None: + self.capture.__exit__(None, None, None) + self.record["log_cleanup"] = self.capture.record.get("joined", False) + self.record["joined"] = not any( + item["id"] == self.name for item in listing() + ) and self.record.get("log_cleanup", True) + if not self.record["joined"]: + raise RuntimeError("owned container cleanup incomplete") diff --git a/scripts/src/vcore_scripts/protocol_ech.py b/scripts/src/vcore_scripts/protocol_ech.py new file mode 100644 index 0000000..a984b92 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_ech.py @@ -0,0 +1,226 @@ +"""SECURITY static ECH consumers against official latest isolated peers.""" + +from __future__ import annotations + +import base64 +import os +import shutil +import struct +import textwrap +from pathlib import Path + +from .protocol_peers import run_command + + +def catalog(): + cases = {} + + def add(mode, suffix, test): + cases[f"SECURITY-ECH-{mode.upper()}-{suffix}"] = ("M", mode, True, test) + + modes = ( + "tcp-tls", + "ws-tls", + "upgrade-tls", + "upgrade-fast-tls", + "grpc-tls", + ) + tuple( + f"xhttp-{mode}{version}" + for mode in ( + "stream-one", + "stream-up", + "packet-up", + "stream-up-download", + "packet-up-download", + ) + for version in ("", "-h1") + ) + for mode in modes: + add(mode, "BASE", "public_base") + add(mode, "CLOSE", "native_mihomo_close_alignment") + for mode in ( + "tcp-tls", + "ws-tls", + "grpc-tls", + "xhttp-stream-up-download", + "xhttp-stream-up-download-h1", + ): + add(mode, "REJECT", "native_ech_fail_closed") + for mode in modes: + if "download" in mode: + add(mode, "DOWNLOAD", "public_ech_download_identity") + for mode in ("tcp-mtls", "ws-mtls", "grpc-mtls"): + add(mode, "IDENTITY", "public_tls_identity_and_verification_name") + for suffix, test in ( + ("GRAPH", "runtime::public_graph"), + ("ENTRYPOINTS", "runtime::public_entrypoints"), + ("IPV6", "runtime::public_ipv6_and_gates"), + ("UDP-ISOLATION", "runtime::public_udp_isolation"), + ): + add("tcp-tls", suffix, test) + for mode in ("grpc-tls", "xhttp-stream-up-download"): + add(mode, "LIFE", "runtime::public_lifecycle") + add(mode, "OWNED", "runtime::owned_resources") + return cases + + +def material(directory: Path, key_id: int): + """Generate synthetic keys with official OpenSSL, never implement crypto here.""" + openssl = next( + ( + path + for path in ( + os.environ.get("OPENSSL_BIN"), + shutil.which("openssl3"), + "/opt/homebrew/opt/openssl@3/bin/openssl", + "/usr/local/opt/openssl@3/bin/openssl", + shutil.which("openssl"), + ) + if path and Path(path).is_file() + ), + None, + ) + if openssl is None: + raise RuntimeError("OpenSSL with X25519 support is required") + private = directory / f"ech-{key_id}.der" + generated = run_command( + [ + openssl, + "genpkey", + "-algorithm", + "X25519", + "-outform", + "DER", + "-out", + str(private), + ], + timeout=15, + ) + public = run_command( + [ + openssl, + "pkey", + "-inform", + "DER", + "-in", + str(private), + "-pubout", + "-outform", + "DER", + ], + timeout=15, + ) + if any(item.returncode or not item.cleanup for item in (generated, public)): + raise RuntimeError("synthetic ECH material generation failed") + secret = private.read_bytes() + if len(secret) != 48 or len(public.stdout) != 44: + raise RuntimeError("unexpected synthetic X25519 DER encoding") + name = b"public.invalid" + contents = bytes([key_id]) + struct.pack("!HH", 32, 32) + public.stdout[-32:] + contents += struct.pack("!HHHBB", 4, 1, 1, 0, len(name)) + name + b"\0\0" + config = struct.pack("!HH", 0xFE0D, len(contents)) + contents + key_set = ( + struct.pack("!H", 32) + secret[-32:] + struct.pack("!H", len(config)) + config + ) + return base64.b64encode(struct.pack("!H", len(config)) + config).decode(), key_set + + +def configuration(node, peer, directory, mode, *, kind="M"): + primary, first_key = material(directory, 7) + download, second_key = material(directory, 9) + encoded = base64.b64encode(first_key + second_key).decode() + pem = ( + "-----BEGIN ECH KEYS-----\n" + + "\n".join(textwrap.wrap(encoded, 64)) + + "\n-----END ECH KEYS-----\n" + ) + (directory / "ech-keys.pem").write_text(pem) + node["ech-opts"] = dict(enable=True, config=primary) + if mode.endswith("-h1"): + node["alpn"] = ["http/1.1"] + if kind == "M": + for listener in peer["listeners"]: + listener["ech-key"] = "/data/fixture/ech-keys.pem" + elif kind == "XR": + for inbound in peer["inbounds"]: + inbound["streamSettings"]["tlsSettings"]["echServerKeys"] = encoded + else: + raise ValueError("unsupported static ECH peer") + return dict(ech_download_config=download) + + +def wrong_key(options): + wire = bytearray(base64.b64decode(options["config"], validate=True)) + wire[11:43] = bytes([7]) * 32 + return dict(enable=True, config=base64.b64encode(wire).decode()) + + +def legacy_gateway_command(): + # Xray's dokodemo-door marks its inbound splice-capable even with TLS. + # Its default freedom response path then bypasses the TLS writer. Use + # the upstream runtime switch only for this synthetic TLS gateway. + return [ + "env", + "XRAY_BUF_SPLICE=disable", + "/data/fixture/peer", + "run", + "-c", + "/data/fixture/config.json", + ] + + +def legacy_gateway_config(upstream, tls_options): + """Official Xray TLS ingress, no custom protocol server or V2Ray patches.""" + return dict( + log={"loglevel": "warning"}, + inbounds=[ + dict( + listen="::", + port=23000, + protocol="dokodemo-door", + settings=dict(address=upstream, port=23000, network="tcp"), + streamSettings=dict( + network="tcp", security="tls", tlsSettings=tls_options + ), + ) + ], + outbounds=[dict(protocol="freedom")], + ) + + +def field_variants(): + cases = {} + for version in ("h1", "h2", "h3"): + for action in ("reject-main", "reject-download", "replace", "clear"): + cases[f"{version}-ech-{action}"] = ( + { + "mode": "stream-up", + "download-settings": {}, + "_ech_action": action, + "_reject": action.startswith("reject-"), + }, + version, + ) + return cases + + +if __name__ == "__main__": + import argparse + + from .mihomo_isolation import exclusive_run + from .protocol_vless_container import CLIENT_FINGERPRINTS, run + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("output", type=Path) + parser.add_argument("cases", nargs="*") + parser.add_argument("--client-fingerprint", choices=CLIENT_FINGERPRINTS) + args = parser.parse_args() + with exclusive_run(): + raise SystemExit( + run( + args.output.resolve(), + args.cases or None, + ech=True, + client_fingerprint=args.client_fingerprint, + ) + ) diff --git a/scripts/src/vcore_scripts/protocol_encryption.py b/scripts/src/vcore_scripts/protocol_encryption.py new file mode 100644 index 0000000..6be795f --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_encryption.py @@ -0,0 +1,347 @@ +"""Encryption wire checks; not public YAML or complete security acceptance.""" + +from __future__ import annotations + +import base64 +import contextlib +import json +import os +import shutil +import tempfile +from pathlib import Path + +from .builds import CORE_DIR +from .mihomo_isolation import exclusive_run +from .mihomo_release import download_mihomo +from .protocol_containers import ContainerLab, command +from .protocol_inputs import redact, same_source, source_identity +from .protocol_peers import run_command + + +def cases(): + return { + f"{style}-{rtt}-{key}": (style, rtt, key) + for style in ("native", "xorpub", "random") + for rtt in ("1rtt", "0rtt") + for key in ("x25519", "mlkem", "mixed") + } + + +def encode(value): + return base64.urlsafe_b64encode(bytes.fromhex(value)).decode().rstrip("=") + + +def run( + output: Path, + selected=None, + *, + consumer=False, + chacha=False, + expiry=False, + padding="minimal", + supplied=None, +): + output = output.resolve() + required = cases() + if expiry: + required = { + name: value for name, value in required.items() if value[1] == "0rtt" + } + paddings = { + "minimal": ".100-35-35", + "default": "", + "fragmented": ".100-35-35.100-1-1.100-47-47", + "maximum": ".100-65553-65553", + } + selected = list(required) if selected is None else selected + if ( + not output.is_relative_to(CORE_DIR / "target/interop/runs") + or not selected + or len(set(selected)) != len(selected) + or not set(selected) <= required.keys() + or padding not in paddings + or (consumer and (chacha or expiry)) + ): + raise ValueError("fresh run directory and known unique cases required") + output.mkdir(parents=True, exist_ok=False) + if expiry: + test = "native_ticket_expiry" + marker = "SECURITY-ENCRYPTION-EXPIRY-PASS full-resumed-expired-full-resumed" + elif consumer: + test = "public_tcp_roundtrip" + marker = ( + "SECURITY-ENCRYPTION-CONSUMER-PASS rounds=4 bytes_per_direction=10485760" + ) + else: + test = "native_encryption_roundtrip" + marker = "SECURITY-ENCRYPTION-WIRE-PASS rounds=4 bytes_per_direction=10485760" + report = dict( + stage="SECURITY.encryption-wire", + scope="public-config-and-outbound" if consumer else "native-wire-only", + cipher="chacha20-poly1305" if chacha else "cpu-selected", + ticket_expiry=expiry, + padding=padding, + complete_selection=set(selected) == set(required), + source=source_identity(), + required=list(required), + cases=[], + isolation={}, + status="NOT RUN", + ) + try: + if supplied is None: + identity = {} + binary = download_mihomo( + "linux-arm64", directory=output / "binaries", identity=identity + ) + else: + binary, identity = supplied["M"] + report["peer"] = identity + built = run_command( + [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "encryption_wire", + "--no-run", + ], + timeout=240, + ) + (output / "build.log").write_text(redact(built.stdout.decode(errors="replace"))) + if built.returncode or not built.cleanup: + raise RuntimeError("Encryption wire consumer build failed") + vectors = json.loads( + (CORE_DIR / "tests/protocols/encryption-crypto.json").read_text() + ) + pairs = dict( + x25519=( + encode(vectors["x25519_public"]), + encode(vectors["x25519_private"]), + ), + mlkem=(encode(vectors["mlkem_public"]), encode(vectors["mlkem_seed"])), + ) + pairs["mixed"] = tuple( + ".".join((pairs["x25519"][i], pairs["mlkem"][i], pairs["x25519"][i])) + for i in (0, 1) + ) + lab = ContainerLab(report["isolation"], mtu=1500) + with ( + tempfile.TemporaryDirectory(prefix="private-", dir=output) as temporary, + contextlib.ExitStack() as stack, + ): + root = Path(temporary) + server_dir, origin_dir = root / "server", root / "origin" + server_dir.mkdir() + origin_dir.mkdir() + shutil.copy2(binary, server_dir / "peer") + shutil.copy2( + Path(__file__).with_name("container_udp_origin.py"), + origin_dir / "origin.py", + ) + origin = lab.start( + stack, + origin_dir, + "security-e-origin", + [ + "env", + "VCORE_ISOLATED_ORIGIN=1", + "python", + "-B", + "/data/fixture/origin.py", + ], + ) + server = lab.start( + stack, + server_dir, + "security-e-mihomo", + [ + "/data/fixture/peer", + "-d", + "/data/mihomo", + "-f", + "/data/fixture/config.json", + ], + ) + listeners = [] + for index, name in enumerate(selected): + style, _, key = required[name] + lifetime = "2-3s" if expiry else "600s" + listeners.append( + dict( + name=f"encryption-{index}", + type="vless", + listen="::", + port=23000 + index, + users=[ + dict( + username="fixture", + uuid="07070707-0707-0707-0707-070707070707", + ) + ], + decryption=( + f"mlkem768x25519plus.{style}.{lifetime}." + f"{pairs[key][1]}{paddings[padding]}" + ), + ) + ) + # A distinct native handler has no tickets from the primary. + # It exercises genuine unknown-ticket rejection without + # patching the peer, restarting it, or replaying business data. + listeners.append( + { + **listeners[-1], + "name": f"encryption-fresh-{index}", + "port": 23100 + index, + } + ) + (server_dir / "config.json").write_text( + json.dumps( + dict( + ipv6=True, + **{"log-level": "silent"}, + listeners=listeners, + rules=["MATCH,DIRECT"], + hosts={"vcore-fixture.test": origin.ipv4}, + ) + ) + ) + origin.release() + origin.wait_tcp(24000) + server.release() + for index in range(len(selected)): + server.wait_tcp(23000 + index) + server.wait_tcp(23100 + index) + identity["version"] = command( + "exec", server.name, "/data/fixture/peer", "-v" + ).strip() + if ( + command("exec", server.name, "sha256sum", "/data/fixture/peer").split()[ + 0 + ] + != identity["binary_sha256"] + ): + raise RuntimeError("Mihomo binary identity mismatch") + for index, name in enumerate(selected): + style, rtt, key = required[name] + encryption = ( + f"mlkem768x25519plus.{style}.{rtt}." + f"{pairs[key][0]}{paddings[padding]}" + ) + fixture = root / "input.json" + fixture.write_text( + json.dumps( + dict( + isolation="containers", + encryption=encryption, + force_chacha=chacha, + ticket_expiry=expiry, + node=dict( + name="edge", + type="vless", + server=server.ipv4, + port=23000 + index, + uuid="07070707-0707-0707-0707-070707070707", + udp=True, + encryption=encryption, + ), + origin_control=f"{origin.ipv4}:24000", + origin_ipv4=origin.ipv4, + origin_ipv6=origin.ipv6, + server_ipv4=server.ipv4, + server_ipv6=server.ipv6, + port=23000 + index, + reject_port=23100 + index, + ) + ) + ) + argv = [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "encryption_wire", + test, + "--", + "--ignored", + "--exact", + "--nocapture", + ] + result = run_command( + argv, + env={**os.environ, "VCORE_ENCRYPTION_FIXTURE": str(fixture)}, + timeout=150, + ) + text = result.stdout.decode(errors="replace") + (output / f"{name}.log").write_text(redact(text)) + passed = ( + result.returncode == 0 + and result.cleanup + and marker in text + and "1 passed; 0 failed; 0 ignored" in text + ) + report["cases"].append( + dict( + case_id=name, + command=argv, + returncode=result.returncode, + cleanup=result.cleanup, + status="PASS" if passed else "FAIL", + ) + ) + print(f"{name}: {'PASS' if passed else 'FAIL'}", flush=True) + if not passed: + raise RuntimeError("Encryption wire gate failed") + origin.ensure_alive() + server.ensure_alive() + report["status"] = "PASS" + except KeyboardInterrupt: + report["status"] = "INTERRUPTED" + except Exception as error: + report["status"] = "FAIL" + report["error"] = redact(str(error)) + finally: + report["source_after"] = source_identity() + report["source_unchanged"] = same_source( + report["source"], report["source_after"] + ) + report["cleanup"] = all( + peer.get("joined", False) for peer in report["isolation"].get("peers", []) + ) + if not report["source_unchanged"] or not report["cleanup"]: + report["status"] = "FAIL" + (output / "encryption-results.json").write_text( + json.dumps(report, indent=2) + "\n" + ) + return 0 if report["status"] == "PASS" else 1 + + +if __name__ == "__main__": + import argparse + import sys + + parser = argparse.ArgumentParser() + parser.add_argument("output", type=Path) + parser.add_argument("cases", nargs="*") + parser.add_argument("--consumer", action="store_true") + parser.add_argument("--chacha", action="store_true") + parser.add_argument("--expiry", action="store_true") + parser.add_argument( + "--padding", + choices=("minimal", "default", "fragmented", "maximum"), + default="minimal", + ) + args = parser.parse_args() + with exclusive_run(): + sys.exit( + run( + args.output, + args.cases or None, + consumer=args.consumer, + chacha=args.chacha, + expiry=args.expiry, + padding=args.padding, + ) + ) diff --git a/scripts/src/vcore_scripts/protocol_encryption_public.py b/scripts/src/vcore_scripts/protocol_encryption_public.py new file mode 100644 index 0000000..76a5b5e --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_encryption_public.py @@ -0,0 +1,103 @@ +"""Public Encryption consumers using the existing isolated VLESS lab.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from .builds import CORE_DIR +from .protocol_encryption import cases, encode + + +def configuration(profile, node, peer): + style, rtt, key = cases()[profile] + vectors = json.loads( + (CORE_DIR / "tests/protocols/encryption-crypto.json").read_text() + ) + pairs = { + "x25519": (encode(vectors["x25519_public"]), encode(vectors["x25519_private"])), + "mlkem": (encode(vectors["mlkem_public"]), encode(vectors["mlkem_seed"])), + } + pairs["mixed"] = tuple( + ".".join((pairs["x25519"][i], pairs["mlkem"][i], pairs["x25519"][i])) + for i in (0, 1) + ) + # Nontrivial fragments/gaps exercise early-data continuation, not merely + # the smallest one-flight padding used by the wire/replay lab. + padding = "100-35-35.100-1-1.100-47-47" + node["encryption"] = f"mlkem768x25519plus.{style}.{rtt}.{pairs[key][0]}.{padding}" + for listener in peer["listeners"]: + listener["decryption"] = ( + f"mlkem768x25519plus.{style}.600s.{pairs[key][1]}.{padding}" + ) + + +def catalog(): + required = {} + modes = ( + "tcp", + "tcp-tls", + "tcp-reality", + "ws", + "ws-tls", + "ws-ed-1", + "ws-ed-2048", + "upgrade", + "upgrade-fast", + "grpc", + "grpc-tls", + "xhttp-stream-one", + "xhttp-stream-up", + "xhttp-packet-up", + "xhttp-stream-up-download", + "xhttp-packet-up-download", + ) + for mode in modes: + tls = mode.endswith(("-tls", "-reality")) or mode.startswith("xhttp-") + for suffix, test in ( + ("BASE", "public_base"), + ("CLOSE", "native_mihomo_close_alignment"), + ): + required[f"SECURITY-E-{mode.upper()}-{suffix}"] = ("M", mode, tls, test) + for mode in ("tcp-tls", "grpc-tls"): + for suffix, test in ( + ("GRAPH", "runtime::public_graph"), + ("IPV6", "runtime::public_ipv6_and_gates"), + ("ENTRYPOINTS", "runtime::public_entrypoints"), + ("LIFE", "runtime::public_lifecycle"), + ("OWNED", "runtime::owned_resources"), + ("UDP-ISOLATION", "runtime::public_udp_isolation"), + ): + required[f"SECURITY-E-{mode.upper()}-{suffix}"] = ("M", mode, True, test) + for mode in ("vision-encryption", "vision-tls", "vision-reality"): + for suffix, test in ( + ("BASE", "public_base"), + ("INNER-TLS", "native_vision_inner_tls"), + ("UDP", "native_vision_xudp"), + ("CLOSE", "native_mihomo_close_alignment"), + ("DIRECT-CLOSE", "native_vision_direct_close_alignment"), + ): + required[f"SECURITY-E-{mode.upper()}-{suffix}"] = ( + "M", + mode, + mode != "vision-encryption", + test, + ) + return required + + +if __name__ == "__main__": + import argparse + + from .mihomo_isolation import exclusive_run + from .protocol_vless_container import run + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("output", type=Path) + parser.add_argument("profile", choices=cases()) + parser.add_argument("cases", nargs="*") + args = parser.parse_args() + with exclusive_run(): + raise SystemExit( + run(args.output.resolve(), args.cases or None, encryption=args.profile) + ) diff --git a/scripts/src/vcore_scripts/protocol_evidence.py b/scripts/src/vcore_scripts/protocol_evidence.py new file mode 100644 index 0000000..b93d29a --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_evidence.py @@ -0,0 +1,600 @@ +"""Executable requirements and fail-closed structured result checks.""" + +from __future__ import annotations + +import hashlib +import json +import re +from pathlib import Path + +from .protocol_catalogs import ( + CATALOG_DIR, + CATEGORIES, + FIELD_IDS, + SUITES, + _unique_object, +) + +FOUNDATIONS_REQUIRED_IDS = { + "FOUNDATIONS-SCHEMA", + "FOUNDATIONS-LIMITS", + "FOUNDATIONS-STREAM", + "FOUNDATIONS-DATAGRAM", + "FOUNDATIONS-QUIC", + "FOUNDATIONS-RESOLUTION", + "FOUNDATIONS-RESOURCES", + "FOUNDATIONS-SECURITY", + "FOUNDATIONS-XUDP", + "FOUNDATIONS-REGRESSION", + "FOUNDATIONS-M-WS", + "FOUNDATIONS-M-WSS", + "FOUNDATIONS-M-GRPC", + "FOUNDATIONS-M-GRPC-TLS", + "FOUNDATIONS-M-WS-ED", + "FOUNDATIONS-V2-HTTP", + "FOUNDATIONS-V2-H2", + "FOUNDATIONS-V2-WS-HEADER", + "FOUNDATIONS-V2-WS-PATH", + "FOUNDATIONS-FEATURES", + "FOUNDATIONS-SCRIPTS", +} +HEX = re.compile(r"[0-9a-f]{64}") +RESOURCE_KINDS = { + "task", + "socket", + "session", + "association", + "reassembly", + "pool", + "waiter", + "handshake", +} + + +def idle_resources(snapshot) -> bool: + if not isinstance(snapshot, dict) or not isinstance(snapshot.get("counts"), list): + return False + counts = snapshot["counts"] + return ( + len(counts) == len(RESOURCE_KINDS) + and all(isinstance(count, dict) for count in counts) + and {count.get("kind") for count in counts} == RESOURCE_KINDS + and all( + type(count.get("current")) is int + and count["current"] == 0 + and type(count.get("peak")) is int + and count["peak"] >= 0 + for count in counts + ) + ) + + +def read_events(path: Path) -> list[dict]: + if path.stat().st_size > 4 * 1024 * 1024: + raise ValueError("case events exceeded bound") + events = [ + json.loads(line, object_pairs_hook=_unique_object) + for line in path.read_text().splitlines() + ] + if not all(isinstance(event, dict) for event in events): + raise ValueError("malformed structured case event") + return events + + +def read_json(path: Path): + if path.stat().st_size > 4 * 1024 * 1024: + raise ValueError("protocol evidence exceeds bounded document size") + return json.loads( + path.read_text(encoding="utf-8"), object_pairs_hook=_unique_object + ) + + +def executable_cases() -> list[dict]: + """Single source: retained foundation fixtures plus current protocol catalogs.""" + from .protocol_hysteria2_catalog import definitions as hysteria2 + from .protocol_integration_catalog import definitions as integration + from .protocol_security_catalog import definitions as security + from .protocol_vless_acceptance import definitions as vless + from .protocol_vmess_acceptance import definitions as vmess + from .protocol_xhttp_acceptance import definitions as xhttp + + foundation = read_json(CATALOG_DIR / "foundation-cases.json")["cases"] + return foundation + [ + case + for define in (vmess, vless, xhttp, hysteria2, security, integration) + for case in define() + ] + + +def load_manifest(path: Path | None = None) -> list[dict]: + expected = executable_cases() + data = ( + read_json(path) + if path is not None + else { + "schema_version": 1, + "kind": "executable-case-manifest", + "cases": expected, + } + ) + if ( + not isinstance(data, dict) + or type(data.get("schema_version")) is not int + or data.get("schema_version") != 1 + or data.get("kind") != "executable-case-manifest" + ): + raise ValueError("unsupported executable case manifest") + cases = data.get("cases") + if not isinstance(cases, list) or not cases: + raise ValueError("empty executable case manifest") + seen = set() + for case in cases: + required = { + "case_id", + "stage", + "substage", + "required", + "row_ids", + "protocol", + "network", + "security", + "udp_codec", + "field_values", + "outer_family", + "inner_family", + "target_type", + "upstream_graph", + "peer_kind", + "peer_config", + "gap_source", + "expected_observation", + "required_evidence", + "prerequisites", + "runner", + "timeout_seconds", + } + if not isinstance(case, dict) or not required <= case.keys(): + raise ValueError("incomplete executable case metadata") + identifier = case["case_id"] + if ( + not isinstance(identifier, str) + or not re.fullmatch(r"[A-Z0-9-]+", identifier) + or identifier in seen + ): + raise ValueError("invalid or duplicate executable case ID") + seen.add(identifier) + if ( + case["stage"] not in SUITES + or case["substage"] not in CATEGORIES + or not case["substage"].startswith(case["stage"] + ".") + ): + raise ValueError("invalid case ownership") + if ( + type(case["required"]) is not bool + or not isinstance(case["row_ids"], list) + or len(set(case["row_ids"])) != len(case["row_ids"]) + or not set(case["row_ids"]) <= FIELD_IDS + ): + raise ValueError("invalid required flag or field references") + if case["peer_kind"] not in {"M", "H", "XR", "V2", "SS", "unit"}: + raise ValueError("unknown peer kind") + for key in ["expected_observation", "required_evidence", "prerequisites"]: + items = case[key] + if ( + not isinstance(items, list) + or not items + or not all(isinstance(item, str) and item for item in items) + or len(items) != len(set(items)) + ): + raise ValueError("empty or duplicate executable observations") + if ( + not isinstance(case["field_values"], dict) + or not isinstance(case["peer_config"], dict) + or type(case["timeout_seconds"]) is not int + or not 1 <= case["timeout_seconds"] <= 3600 + ): + raise ValueError("invalid case bounds or configuration reference") + if { + case["case_id"] + for case in cases + if case["stage"] == "FOUNDATIONS" and case["required"] + } != FOUNDATIONS_REQUIRED_IDS: + raise ValueError("FOUNDATIONS required set was removed, renamed or downgraded") + from .protocol_trojan_acceptance import REQUIRED_IDS + + if { + case["case_id"] + for case in cases + if case["stage"] == "TROJAN" and case["required"] + } != REQUIRED_IDS: + raise ValueError("TROJAN required set was removed, renamed or downgraded") + if cases != expected: + raise ValueError("manifest differs from the current executable catalog") + return cases + + +def check_limit_references( + cases: list[dict], path: Path = CATALOG_DIR / "limits.json" +) -> None: + limits = read_json(path) + identifiers = {case["case_id"] for case in cases} + if ( + limits.get("schema_version") != 1 + or limits.get("kind") != "runtime-limit-registry" + ): + raise ValueError("unsupported runtime limit registry") + entries = limits.get("limits", []) + names = [entry.get("id") for entry in entries] + if not entries or len(names) != len(set(names)): + raise ValueError("empty or duplicate runtime limits") + for entry in entries: + if ( + not entry.get("boundary_cases") + or not set(entry["boundary_cases"]) <= identifiers + ): + raise ValueError("runtime limit has no executable boundary case") + + +def select_cases( + cases: list[dict], + *, + stage: str, + identifiers: list[str] | None = None, + protocol: str | None = None, +) -> list[dict]: + if identifiers and ( + len(identifiers) != len(set(identifiers)) + or not set(identifiers) <= {case["case_id"] for case in cases} + ): + raise ValueError("duplicate or unknown requested case") + selected = [ + case + for case in cases + if case["stage"] == stage + and case["required"] + and (not identifiers or case["case_id"] in identifiers) + and (protocol is None or case["protocol"] == protocol) + ] + if not selected: + raise ValueError("selection has no executable required cases; stage is NOT RUN") + if identifiers and {case["case_id"] for case in selected} != set(identifiers): + raise ValueError("case selection conflicts with stage or protocol") + return selected + + +def validate_results(required: list[dict], results: list[dict]) -> None: + if not required: + raise ValueError("empty required result set") + expected = {case["case_id"]: case for case in required} + identifiers = [result.get("case_id") for result in results] + if len(identifiers) != len(set(identifiers)) or set(identifiers) != set(expected): + raise ValueError("missing, duplicate or unknown case result") + for result in results: + case = expected[result["case_id"]] + if ( + result.get("status") != "PASS" + or result.get("cleanup") is not True + or type(result.get("command_exit_code")) is not int + or result["command_exit_code"] != 0 + ): + raise ValueError( + "required case failed, was blocked/not run, or did not clean up" + ) + observations = result.get("assertions", {}) + if ( + not isinstance(observations, dict) + or set(observations) != set(case["expected_observation"]) + or not all(value is True for value in observations.values()) + ): + raise ValueError("missing or false required behavioral assertion") + evidence = result.get("evidence", []) + if ( + not isinstance(evidence, list) + or len(evidence) != len(set(evidence)) + or set(evidence) != set(case["required_evidence"]) + ): + raise ValueError( + "incomplete behavior evidence; CFG alone is not acceptance" + ) + if ( + result.get("row_ids") != case["row_ids"] + or result.get("peer_kind") != case["peer_kind"] + or result.get("scope") + != ( + "protocol-consumer" + if case["stage"] + in { + "TROJAN", + "VMESS", + "VLESS", + "XHTTP", + "HYSTERIA2", + "SECURITY", + "INTEGRATION", + } + else "foundation-only" + ) + ): + raise ValueError("case mapping or evidence scope differs from manifest") + + +def rust_results(case: dict, events: list[dict], returncode: int) -> dict: + observed = [ + event + for event in events + if event.get("suite") == case.get("event_suite", case["case_id"]) + ] + names = case["expected_observation"] + assertions = {name: False for name in names} + unknown = any( + event.get("assertion") not in assertions or event.get("schema_version") != 1 + for event in observed + ) + for name in names: + entries = [event for event in observed if event.get("assertion") == name] + assertions[name] = len(entries) == 2 and [ + entry.get("status") for entry in entries + ] == ["BEGIN", "PASS"] + if assertions[name] and "resources" in case["required_evidence"]: + assertions[name] = idle_resources(entries[-1].get("resources")) + result = new_result(case) + result.update(assertions=assertions, command_exit_code=returncode, cleanup=True) + result["status"] = ( + "PASS" + if not unknown and returncode == 0 and all(assertions.values()) + else "FAIL" + ) + if result["status"] == "PASS": + result["evidence"] = [ + item for item in case["required_evidence"] if item != "peer-identity" + ] + return result + + +def new_result(case: dict) -> dict: + return { + "case_id": case["case_id"], + "row_ids": case["row_ids"], + "peer_kind": case["peer_kind"], + "scope": "protocol-consumer" + if case["stage"] + in {"TROJAN", "VMESS", "VLESS", "XHTTP", "HYSTERIA2", "SECURITY", "INTEGRATION"} + else "foundation-only", + "status": "NOT RUN", + "assertions": {}, + "evidence": [], + "cleanup": False, + "command_exit_code": None, + } + + +def artifact(run_dir: Path, relative: str, digest: str) -> Path: + path = run_dir / relative + if ( + Path(relative).is_absolute() + or ".." in Path(relative).parts + or path.is_symlink() + or not path.resolve().is_relative_to(run_dir.resolve()) + or not HEX.fullmatch(digest) + ): + raise ValueError("unsafe evidence path or invalid hash") + with path.open("rb") as stream: + actual = hashlib.file_digest(stream, "sha256").hexdigest() + if actual != digest: + raise ValueError("evidence content hash mismatch") + return path + + +def check_run(run_dir: Path, stage: str, manifest: Path | None = None) -> None: + if manifest is None and (run_dir / "manifest.json").is_file(): + manifest = run_dir / "manifest.json" + cases = load_manifest(manifest) + check_limit_references(cases) + required = select_cases(cases, stage=stage) + run = read_json(run_dir / "run.json") + result = read_json(run_dir / "cases.json") + peers = read_json(run_dir / "peers.json") + if ( + run.get("stage") != stage + or run.get("mode") != "execute" + or run.get("cleanup") is not True + or run.get("source_unchanged") is not True + or run.get("selected_cases") != [case["case_id"] for case in required] + ): + raise ValueError( + "partial/preflight run or changing source cannot sign off the stage" + ) + for key in ["source_tree_sha256", "lock_sha256", "dirty_patch_sha256"]: + if not isinstance(run.get(key), str) or not HEX.fullmatch(run[key]): + raise ValueError("missing immutable input identity") + validate_results(required, result) + artifacts = run.get("artifacts", []) + paths = [entry["path"] for entry in artifacts] + if len(paths) != len(set(paths)): + raise ValueError("duplicate evidence artifact") + if stage in { + "TROJAN", + "VMESS", + "VLESS", + "XHTTP", + "HYSTERIA2", + "SECURITY", + "INTEGRATION", + }: + if stage == "TROJAN": + from .protocol_trojan_acceptance import check + elif stage == "VMESS": + from .protocol_vmess_acceptance import check + elif stage == "VLESS": + from .protocol_vless_acceptance import check + elif stage == "XHTTP": + from .protocol_xhttp_acceptance import check + elif stage == "HYSTERIA2": + from .protocol_hysteria2_acceptance import check + elif stage == "SECURITY": + from .protocol_security_acceptance import check + else: + from .protocol_integration_acceptance import check + + for evidence in artifacts: + artifact(run_dir, evidence["path"], evidence["sha256"]) + check(run_dir, required, run, result, peers, paths) + return + necessary = { + "cases.json", + "peers.json", + "resources.jsonl", + "summary.md", + "rust-events.jsonl", + "legacy-events.jsonl", + "native/stream-cases.json", + "script-tests.json", + } + if not necessary <= set(paths): + raise ValueError("missing persisted behavior artifacts") + for evidence in artifacts: + artifact(run_dir, evidence["path"], evidence["sha256"]) + commands = run.get("commands", []) + command_names = [command.get("name") for command in commands] + required_commands = { + "rust-foundations", + "native-probe-build", + "legacy-mihomo", + "offline-scripts", + "feature-default", + "feature-minimal", + "feature-outbound-trojan", + "feature-outbound-vmess", + "feature-outbound-hysteria2", + "feature-stream-transport", + "feature-quic-transport", + } + if ( + len(command_names) != len(set(command_names)) + or set(command_names) != required_commands + or any( + type(command.get("exit_code")) is not int + or command["exit_code"] != 0 + or command.get("cleanup") is not True + for command in commands + ) + ): + raise ValueError("missing, failed or unjoined acceptance command") + for command in commands: + if command.get("log") and command["log"] not in paths: + raise ValueError("command log has no content identity") + rust_events = read_events(run_dir / "rust-events.jsonl") + legacy_events = read_events(run_dir / "legacy-events.jsonl") + native = read_json(run_dir / "native/stream-cases.json") + native_cases = [case for case in required if case["runner"] == "native-stream"] + if [item.get("case_id") for item in native.get("cases", [])] != [ + case["case_id"] for case in native_cases + ]: + raise ValueError("missing, duplicate or unknown native case evidence") + # Use exactly the same structured evaluators as execution, never console text. + from .protocol_harness import SCRIPT_OBSERVATIONS, _native_results + + recalculated = _native_results(native_cases, native) + for case in required: + if case["runner"] in {"rust", "mihomo-legacy"}: + calculated = rust_results( + case, + legacy_events if case["runner"] == "mihomo-legacy" else rust_events, + 0, + ) + if case["runner"] == "mihomo-legacy" and calculated["status"] == "PASS": + calculated["evidence"] = case["required_evidence"] + recalculated.append(calculated) + actual = {item["case_id"]: item for item in result} + for calculated in recalculated: + for key in ["status", "assertions", "evidence", "cleanup", "command_exit_code"]: + if actual[calculated["case_id"]].get(key) != calculated[key]: + raise ValueError( + "case result disagrees with original structured evidence" + ) + script = read_json(run_dir / "script-tests.json") + scripts = script.get("cases", []) + tests = {entry["test"]: entry["status"] for entry in scripts} + if ( + not scripts + or len(tests) != len(scripts) + or len(tests) != script.get("tests_run") + or any(status != "PASS" for status in tests.values()) + or any( + tests.get(identifier) != "PASS" + for identifier in SCRIPT_OBSERVATIONS.values() + ) + ): + raise ValueError("missing or failed offline harness failure-path proof") + resources = read_events(run_dir / "resources.jsonl") + if not resources or not all( + idle_resources(event.get("resources")) for event in resources + ): + raise ValueError("missing, nonidle or malformed owned resource evidence") + resource_passes = [ + event + for event in rust_events + if event.get("suite") == "FOUNDATIONS-RESOURCES" + and event.get("status") == "PASS" + ] + for event in resource_passes: + checkpoints = event.get("checkpoints", []) + by_phase = {point["phase"]: point["resources"] for point in checkpoints} + if ( + len(checkpoints) != len(by_phase) + or not {"baseline", "after-stop"} <= by_phase.keys() + or not idle_resources(by_phase["baseline"]) + or not idle_resources(by_phase["after-stop"]) + ): + raise ValueError("resource ownership has no baseline/Stop evidence") + if "remain_quiet" in event["assertion"] and ( + by_phase.get("quiet") != by_phase["after-stop"] + or event.get("seconds", 0) < 5 + ): + raise ValueError("resource quiet-window evidence missing") + for case in required: + if "peer-identity" in case["required_evidence"]: + matches = [ + peer + for peer in peers + if peer.get("kind") == case["peer_kind"] + and peer.get("status") == "READY" + ] + if ( + len(matches) != 1 + or not matches[0].get("version") + or not HEX.fullmatch(matches[0].get("binary_sha256", "")) + or not HEX.fullmatch(matches[0].get("archive_sha256", "")) + or not matches[0] + .get("source_url", "") + .startswith("https://github.com/") + ): + raise ValueError("missing official peer identity") + if case["runner"] == "native-stream": + native_peers = [ + peer + for peer in native.get("peers", []) + if peer.get("kind") == case["peer_kind"] + ] + if ( + len(native_peers) != 1 + or native_peers[0].get("binary_sha256") + != matches[0]["binary_sha256"] + ): + raise ValueError("case used a different peer from preflight") + if case["runner"] == "mihomo-legacy": + peer = matches[0] + if ( + peer.get("container_status") != "READY" + or not HEX.fullmatch( + peer.get("container_artifact", {}).get("binary_sha256", "") + ) + or peer["container_artifact"].get("release") != peer.get("release") + ): + raise ValueError( + "legacy peer did not use the same official release snapshot" + ) + print( + f"{stage}: PASS ({len(required)} required foundation cases); " + "production field consumers remain NOT RUN" + ) diff --git a/scripts/src/vcore_scripts/protocol_fingerprint.py b/scripts/src/vcore_scripts/protocol_fingerprint.py new file mode 100644 index 0000000..1ca5339 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_fingerprint.py @@ -0,0 +1,177 @@ +"""Named-profile VCore integration, reusing the owned container wire harness.""" + +import argparse +import hashlib +import json +import sys +import time +from datetime import UTC, datetime +from pathlib import Path + +from .mihomo_isolation import exclusive_run +from .protocol_inputs import same_source, source_identity +from .protocol_vless_container import CLIENT_FINGERPRINTS, run + +SELECTED_TIMEOUT_SECONDS = 3600 + +CASES = [ + "FINGERPRINT-ANYTLS", + *[ + f"VLESS-REGRESSION-{protocol}-{network}" + for protocol in ("TROJAN", "VMESS") + for network in ("TCP", "WS", "GRPC") + ], + *[ + f"VLESS-{mode}-BASE" + for mode in ( + "TCP-TLS", + "WS-TLS", + "WS-ED-1-TLS", + "WS-ED-2048-TLS", + "WS-HEADER-TLS", + "WS-PATH-TLS", + "UPGRADE-TLS", + "UPGRADE-FAST-TLS", + "HTTP-TLS", + "H2-TLS", + "GRPC-TLS", + "TCP-REALITY", + "WS-REALITY", + "UPGRADE-REALITY", + "UPGRADE-FAST-REALITY", + "GRPC-REALITY", + "XHTTP-STREAM-ONE", + "XHTTP-STREAM-UP", + "XHTTP-PACKET-UP", + "XHTTP-STREAM-ONE-REALITY", + "XHTTP-STREAM-UP-DOWNLOAD-REALITY", + "VISION-TLS", + "VISION-REALITY", + ) + ], + "VLESS-VISION-TLS-INNER-TLS", + "FINGERPRINT-VMESS-HTTP-TLS", + "FINGERPRINT-VMESS-H2-TLS", + "VLESS-VISION-REALITY-INNER-TLS", + "VLESS-TCP-REALITY-NEG", + "VLESS-TCP-TLS-NEG", + "VLESS-GRPC-MTLS-IDENTITY", + "VLESS-WS-ALPN-TLS-NEG", + "VLESS-TCP-TLS-CLOSE", + "VLESS-TCP-REALITY-CLOSE", + "VLESS-GRPC-REALITY-CLOSE", + "VLESS-VISION-REALITY-CLOSE", + "VLESS-GRPC-TLS-LIFE", + "VLESS-GRPC-TLS-OWNED", + "VLESS-VISION-REALITY-LIFE", + "VLESS-VISION-REALITY-OWNED", +] + + +def run_selected(output, profile): + from .builds import CORE_DIR + from .protocol_xhttp_fields import run as run_xhttp + from .protocol_xhttp_fields import selected_profile_variants + + if ( + profile not in CLIENT_FINGERPRINTS + or output.parent != CORE_DIR / "target/interop/runs" + ): + raise ValueError("use a supported profile and fresh target/interop/runs child") + output.mkdir(parents=True, exist_ok=False) + started = time.monotonic() + report = dict( + stage="FINGERPRINT-INTEROP", + scope="selected-transports", + profile=profile, + source=source_identity(), + status="NOT RUN", + parts=[], + started_utc=datetime.now(UTC).isoformat(), + timeout_seconds=SELECTED_TIMEOUT_SECONDS, + ) + try: + for phase in ("transports", "xhttp"): + if phase == "transports": + result = run(output / phase, CASES, client_fingerprint=profile) + path = output / phase / "vless-results.json" + else: + selected = [ + f"{version}-{mode}-headers" + for version in ("h1", "h2") + for mode in ("stream-one", "stream-up", "packet-up") + ] + selected.extend(selected_profile_variants(profile)) + jobs = { + name: [ + dict( + case_id=f"FINGERPRINT-{profile}-{name}", + test="security::native_xhttp_security" + if name.endswith("reject-pin") + else "public_base", + ) + ] + for name in selected + } + result = run_xhttp( + output / phase, selected, jobs=jobs, client_fingerprint=profile + ) + path = output / phase / "xhttp-fields-results.json" + part = json.loads(path.read_text()) + report["parts"].append( + dict( + phase=phase, + report=str(path.relative_to(output)), + sha256=hashlib.sha256(path.read_bytes()).hexdigest(), + status=part["status"], + cleanup=part["cleanup"], + source_unchanged=part["source_unchanged"], + cases=len(part["cases"]), + ) + ) + if result: + raise RuntimeError(f"{phase} gate failed") + report["status"] = "PASS" + except (OSError, ValueError, RuntimeError) as error: + report.update(status="FAIL", reason=str(error)) + finally: + report["finished_utc"] = datetime.now(UTC).isoformat() + report["seconds"] = round(time.monotonic() - started, 3) + report["source_after"] = source_identity() + # Staging an already captured untracked file changes the diagnostic + # Git patch, not the exhaustive path/content source-tree digest. + report["source_unchanged"] = same_source( + report["source"], report["source_after"] + ) + if not report["source_unchanged"]: + report["status"] = "FAIL" + if report["seconds"] > SELECTED_TIMEOUT_SECONDS: + report.update( + status="FAIL", reason="selected transport suite deadline exceeded" + ) + (output / "fingerprint-results.json").write_text( + json.dumps(report, indent=2) + "\n" + ) + return 0 if report["status"] == "PASS" else 1 + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("output", type=Path) + parser.add_argument("cases", nargs="*") + parser.add_argument( + "--client-fingerprint", choices=CLIENT_FINGERPRINTS, default="chrome120" + ) + args = parser.parse_args(argv) + with exclusive_run(): + if not args.cases: + return run_selected(args.output.resolve(), args.client_fingerprint) + return run( + args.output.resolve(), + args.cases or CASES, + client_fingerprint=args.client_fingerprint, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/src/vcore_scripts/protocol_fingerprint_reference.py b/scripts/src/vcore_scripts/protocol_fingerprint_reference.py new file mode 100644 index 0000000..37f73f2 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_fingerprint_reference.py @@ -0,0 +1,551 @@ +"""Selected-v1 reference ClientHello acquisition; not VCore interoperability.""" + +from __future__ import annotations + +import argparse +import base64 +import contextlib +import copy +import json +import secrets +import shutil +import signal +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +from .builds import CORE_DIR +from .mihomo_isolation import exclusive_run +from .mihomo_release import download_mihomo +from .protocol_containers import ContainerLab, command, listing +from .protocol_fixtures import certificates +from .protocol_inputs import redact, source_identity +from .protocol_peers import run_command +from .tls_client_hello import is_grease, parse_client_hello, validate_capture + +PROFILES = ("chrome", "chrome120", "firefox", "firefox120", "safari", "safari16") +TEMPLATES = { + "chrome": "Chrome133", + "chrome120": "Chrome120", + "firefox": "Firefox120", + "firefox120": "Firefox120", + "safari": "Safari16.0", + "safari16": "Safari16.0", +} +SNIS = ("fingerprint.test", "f" * 63 + ".fingerprint.test") +PORTS = { + "tcp": 24430, + "ws": 24430, + "grpc": 24430, + "reality": 24430, + "tls13": 24431, + "tls12": 24432, +} + + +def reference_cases() -> list[dict]: + """The public reference-case catalog; a listing does not start a peer.""" + cases = [] + for profile in (*PROFILES, "none"): + contexts = ("tcp",) if profile == "none" else ("tcp", "ws", "grpc", "reality") + for context in contexts: + for sni in SNIS: + for attempt in range(2): + cases.append( + dict( + id=f"FINGERPRINT-REFERENCE-{profile}-{context}-" + f"{len(sni)}-{attempt}", + profile=profile, + template=TEMPLATES.get(profile), + context=context, + sni=sni, + sni_length=len(sni), + attempt=attempt, + ) + ) + for profile in ("chrome", "chrome120", "firefox", "safari"): + for context in ("tls12", "tls13"): + for attempt in range(2): + cases.append( + dict( + id=f"FINGERPRINT-REFERENCE-{profile}-{context}-16-{attempt}", + profile=profile, + template=TEMPLATES[profile], + context=context, + sni=SNIS[0], + sni_length=16, + attempt=attempt, + ) + ) + return cases + + +def _reference_shape(hello, case, *, expected_alpn=None, allow_psk=False): + """Apply only source-defined reference variations, checking before masking.""" + shape = copy.deepcopy(hello) + shape.pop("raw_sha256") + size = shape.pop("bytes") + padding = next((e for e in shape["extensions"] if e["type"] == 21), None) + psk = next((e for e in shape["extensions"] if e["type"] == 41), None) + if psk and (not allow_psk or shape["extensions"][-1] != psk): + raise ValueError("unexpected PSK or invalid last-extension position") + unpadded = size - (padding["bytes"] + 4 if padding else 0) + needs_padding = ( + case["template"] in {"Chrome120", "Safari16.0"} and 255 < unpadded < 512 + ) + if bool(padding) != needs_padding: + raise ValueError("reference padding presence differs from template") + if padding: + expected = max(1, 512 - unpadded - 4) + if ( + padding["bytes"] != expected + or padding["payload_hex"] != "00" * expected + or shape["extensions"][-2 if psk else -1] != padding + ): + raise ValueError("reference padding contents, position or size mismatch") + shape["extensions"].remove(padding) + groups = next(e["values"] for e in shape["extensions"] if e["type"] == 10) + shares = next(e["shares"] for e in shape["extensions"] if e["type"] == 51) + if any(s["group"] not in groups for s in shares): + raise ValueError("reference key share/group correlation mismatch") + for row in shape["extensions"]: + if row["type"] == 0: + if row["names"] != [dict(type=0, name=case["sni"])]: + raise ValueError("reference SNI mismatch") + row["names"], row["bytes"] = "synthetic-sni", "sni-dependent" + elif row["type"] == 16: + expected = expected_alpn or ( + ["http/1.1"] if case["context"] == "ws" else ["h2", "http/1.1"] + ) + if row["protocols"] != expected: + raise ValueError("reference transport ALPN mismatch") + row["protocols"], row["bytes"] = "transport-alpn", "alpn-dependent" + elif row["type"] == 65037: + firefox = case["template"] == "Firefox120" + if ( + (row["hello_type"], row["kdf"], row["enc_bytes"]) != (0, 1, 32) + or row["aead"] not in ({1, 3} if firefox else {1}) + or row["payload_bytes"] + not in ({239} if firefox else {144, 176, 208, 240}) + or row["bytes"] != 42 + row["payload_bytes"] + ): + raise ValueError( + "reference ECH GREASE differs from source-defined variants" + ) + row["aead"], row["payload_bytes"], row["bytes"] = ( + "checked-variant", + "checked-variant", + "checked-variant", + ) + for key in ("values",): + if key in row: + row[key] = ["GREASE" if is_grease(v) else v for v in row[key]] + if row["type"] == 51: + for share in row["shares"]: + if is_grease(share["group"]): + share["group"] = "GREASE" + if is_grease(row["type"]): + row["type"] = "GREASE" + shape["ciphers"] = ["GREASE" if is_grease(v) else v for v in shape["ciphers"]] + if case["template"].startswith("Chrome"): + rows = shape["extensions"] + if rows[0] != dict(type="GREASE", bytes=0, payload_hex="") or rows[ + -2 if psk else -1 + ] != dict(type="GREASE", bytes=1, payload_hex="00"): + raise ValueError("reference Chrome GREASE positions changed") + shape["extensions"] = [ + rows[0], + *sorted(rows[1 : -2 if psk else -1], key=lambda e: e["type"]), + *rows[-2 if psk else -1 :], + ] + return shape + + +def check_reference_report(report: dict) -> dict: + """Verify reference scope, raw observations and source-defined variations.""" + if ( + report.get("scope") != "selected-v1" + or report.get("status") != "CAPTURED" + or report.get("selection") != "full" + or report.get("cleanup") is not True + or report.get("source_unchanged") is not True + ): + raise ValueError("incomplete or unclean selected-profile reference run") + if report.get("source") != report.get("source_after"): + raise ValueError("reference source changed during capture") + catalog = {case["id"]: case for case in reference_cases()} + cases = report["cases"] + if len(cases) != len(catalog) or {c["id"] for c in cases} != catalog.keys(): + raise ValueError("missing or duplicate reference cases") + fixtures = json.loads( + (CORE_DIR / "tests/fingerprints/mihomo-selected-v1.json").read_text() + ) + baselines = {} + for sample in fixtures["samples"]: + case = catalog[sample["case_id"]] + baselines[(sample["profile"], sample["context"])] = _reference_shape( + validate_capture(sample), case + ) + observed, tls_handshakes = 0, 0 + for row in cases: + case = catalog[row["id"]] + if ( + any(row.get(key) != value for key, value in case.items()) + or row["status"] != "CAPTURED" + or not 1 <= len(row["observations"]) <= 4 + ): + raise ValueError("reference case metadata or observations changed") + for event in row["observations"]: + hello = validate_capture(event) + expected = ( + "tls-handshake-complete" + if case["context"] in {"tls12", "tls13"} + else "captured-only" + ) + if event["status"] != expected or event["port"] != PORTS[case["context"]]: + raise ValueError("reference observation has wrong outcome") + if expected == "tls-handshake-complete": + version = "TLSv1.2" if case["context"] == "tls12" else "TLSv1.3" + if event["version"] != version: + raise ValueError("reference negotiated wrong TLS version") + tls_handshakes += 1 + if case["profile"] != "none": + profile = {"firefox120": "firefox", "safari16": "safari"}.get( + case["profile"], case["profile"] + ) + context = "reality" if case["context"] == "reality" else "tcp" + if _reference_shape(hello, case) != baselines[(profile, context)]: + raise ValueError(f"reference structure changed: {case['id']}") + observed += 1 + return dict( + scope="selected-v1", + stage="FINGERPRINT-REFERENCE", + status="BASELINE VERIFIED", + cases=len(cases), + observations=observed, + tls_only_handshakes=tls_handshakes, + business_interoperability="NOT TESTED", + production_support="UNCHANGED", + ) + + +def _name(case): + return f"{case['profile']}-{case['context']}-{case['sni_length']}" + + +def _node(case, server): + context = case["context"] + node = dict( + name=_name(case), + type="vless", + server=server, + port=PORTS[context], + uuid="07070707-0707-0707-0707-070707070707", + tls=True, + servername=case["sni"], + network=context if context in {"ws", "grpc"} else "tcp", + alpn=["h2", "http/1.1"], + **{"client-fingerprint": case["profile"]}, + ) + if context == "reality": + # Public RFC 7748 test vector; observer never authenticates REALITY. + public = bytes.fromhex( + "de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f" + ) + node["reality-opts"] = { + "public-key": base64.urlsafe_b64encode(public).decode().rstrip("="), + "short-id": "01020304", + } + else: + node["skip-cert-verify"] = True + if context == "ws": + node["ws-opts"] = {"path": "/capture"} + elif context == "grpc": + node["grpc-opts"] = {"grpc-service-name": "capture"} + return node + + +def _probe(client, case, target, secret): + query = urllib.parse.urlencode(dict(url=f"http://{target}:80/", timeout=3000)) + request = urllib.request.Request( + f"http://{client}:25000/proxies/{_name(case)}/delay?{query}", + headers={"Authorization": "Bearer " + secret}, + ) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + try: + response = opener.open(request, timeout=5) + except urllib.error.HTTPError as error: + response = error + with response: + body = response.read(4097) + if len(body) > 4096: + raise ValueError("reference controller response limit") + # This observer cannot serve VLESS or a URL: delay failure is expected. + return dict(delay_http_status=response.status, business_result="NOT TESTED") + + +def _events(observer, after): + query = ( + "import json,pathlib,sys; p=pathlib.Path('/data/events.jsonl'); " + "assert p.stat().st_size <= 8*1024*1024; " + "print(json.dumps(p.read_text().splitlines()[int(sys.argv[1]):]))" + ) + lines = json.loads( + command("exec", observer.name, "python", "-c", query, str(after)) + ) + return [json.loads(line) for line in lines] + + +def run_reference(output: Path, selected=None) -> int: + catalog = {case["id"]: case for case in reference_cases()} + ids = list(catalog) if selected is None else selected + if not ids or len(set(ids)) != len(ids) or not set(ids) <= catalog.keys(): + raise ValueError("invalid reference capture selection") + output = output.resolve() + if output.parent != CORE_DIR / "target/interop/runs" or output.exists(): + raise ValueError("fresh in-repository reference run directory required") + output.mkdir(parents=True) + report = dict( + scope="selected-v1", + stage="FINGERPRINT-REFERENCE", + kind="reference-clienthello-only", + status="NOT RUN", + production_support="UNCHANGED", + business_interoperability="NOT TESTED", + selection="full" if set(ids) == catalog.keys() else "partial", + source=source_identity(), + cases=[], + cleanup=False, + source_unchanged=False, + ) + lab = None + try: + with exclusive_run(), contextlib.ExitStack() as stack: + identity = {} + binary = download_mihomo( + "linux-arm64", directory=output / "official-mihomo", identity=identity + ) + report["mihomo"] = identity + build = run_command(["go", "version", "-m", str(binary)], timeout=10) + if build.returncode or not build.cleanup: + raise RuntimeError("official binary build identity query failed") + (output / "mihomo-build-info.txt").write_bytes(build.stdout) + report["utls_build_info"] = [ + line.strip() + for line in build.stdout.decode().splitlines() + if "utls" in line or "vcs.revision" in line + ] + if not any( + "github.com/metacubex/utls\tv1.8.7\t" in line + for line in report["utls_build_info"] + ): + raise RuntimeError( + "reference uTLS revision changed; recheck selected template mapping" + ) + root = Path( + stack.enter_context( + tempfile.TemporaryDirectory(prefix="private-", dir=output) + ) + ) + observer_root, client_root = root / "observer", root / "client" + observer_root.mkdir() + client_root.mkdir() + certificates(observer_root) + shutil.copy2( + Path(__file__).with_name("container_tls_observer.py"), + observer_root / "observer.py", + ) + shutil.copy2(binary, client_root / "mihomo") + lab = ContainerLab(report.setdefault("isolation", {}), mtu=1500) + observer = lab.start( + stack, + observer_root, + "fingerprint-reference-observer", + [ + "env", + "VCORE_ISOLATED_TLS_OBSERVER=1", + "python", + "-B", + "-u", + "/data/fixture/observer.py", + ], + ) + observer.release() + for port in sorted(set(PORTS.values())): + observer.wait_tcp(port) + secret = secrets.token_hex(24) + nodes = { + _name(catalog[case]): _node(catalog[case], observer.ipv4) + for case in ids + } + config = dict( + proxies=list(nodes.values()), + rules=["MATCH,REJECT"], + secret=secret, + **{"log-level": "warning", "external-controller": "0.0.0.0:25000"}, + ) + (client_root / "config.json").write_text(json.dumps(config)) + client = lab.start( + stack, + client_root, + "fingerprint-reference-mihomo", + [ + "/data/fixture/mihomo", + "-d", + "/data/mihomo", + "-f", + "/data/fixture/config.json", + ], + ) + + def retain_logs(): + for role, peer in (("observer", observer), ("client", client)): + if peer.log.exists(): + text = peer.log.read_text(errors="replace")[-16384:] + for value in (secret, observer.ipv4, client.ipv4): + text = text.replace(value, "") + (output / f"{role}.log").write_text(redact(text)) + + stack.callback(retain_logs) + identity["version"] = command( + "exec", client.name, "/data/fixture/mihomo", "-v" + ).strip() + digest = command( + "exec", client.name, "sha256sum", "/data/fixture/mihomo" + ).split()[0] + if digest != identity["binary_sha256"]: + raise RuntimeError("container official binary identity mismatch") + report["observer_tls"] = command( + "exec", + observer.name, + "python", + "-c", + "import ssl; print(ssl.OPENSSL_VERSION)", + ).strip() + client.release() + client.wait_tcp(25000) + observed_count = 0 + for case_id in ids: + case = catalog[case_id] + row = dict(case, status="NOT RUN", observations=[]) + report["cases"].append(row) + row["controller"] = _probe(client.ipv4, case, observer.ipv4, secret) + deadline = time.monotonic() + 6 + while True: + events = _events(observer, observed_count) + if events or time.monotonic() >= deadline: + break + time.sleep(0.05) + if not 1 <= len(events) <= 4: + raise RuntimeError( + f"reference observation count mismatch: {case_id}" + ) + observed_count += len(events) + expected = ( + "tls-handshake-complete" + if case["context"] in {"tls12", "tls13"} + else "captured-only" + ) + for event in events: + row["observations"].append(event) + if "client_hello_b64" not in event: + raise RuntimeError( + f"reference observer did not capture a hello: {case_id}" + ) + hello = parse_client_hello( + base64.b64decode(event["client_hello_b64"], validate=True) + ) + event["parsed"] = hello + sni = next( + (e["names"] for e in hello["extensions"] if e["type"] == 0), [] + ) + if ( + event["status"] != expected + or event["port"] != PORTS[case["context"]] + or sni != [dict(type=0, name=case["sni"])] + ): + raise RuntimeError( + f"reference TLS observation mismatch: {case_id}" + ) + row["status"] = "CAPTURED" + print( + json.dumps( + dict( + case_id=case_id, + status=row["status"], + observations=len(events), + ) + ), + flush=True, + ) + client.ensure_alive() + observer.ensure_alive() + report["status"] = "CAPTURED" + except BaseException as error: + report["status"] = "FAIL" + report["error"] = redact(f"{type(error).__name__}: {error}") + raise + finally: + report["source_after"] = source_identity() + report["source_unchanged"] = report["source_after"] == report["source"] + if lab is not None: + report["cleanup"] = not any( + item["configuration"].get("labels", {}).get("vcore-run") == lab.run_id + for item in listing() + ) and all(peer.get("joined") for peer in report["isolation"]["peers"]) + if report["status"] == "CAPTURED" and not ( + report["source_unchanged"] and report["cleanup"] + ): + report["status"] = "FAIL" + (output / "reference-results.json").write_text( + json.dumps(report, indent=2) + "\n" + ) + print( + json.dumps( + {key: report[key] for key in ("status", "cleanup", "source_unchanged")} + ), + flush=True, + ) + return 0 if report["status"] == "CAPTURED" else 1 + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--list", action="store_true") + mode.add_argument("--run-dir", type=Path) + mode.add_argument("--check-run", type=Path) + parser.add_argument("--case", action="append") + args = parser.parse_args(argv) + if args.check_run: + if args.case: + parser.error("--case requires --run-dir") + report = args.check_run / "reference-results.json" + if report.stat().st_size > 8 * 1024 * 1024: + raise ValueError("reference report size limit") + print( + json.dumps(check_reference_report(json.loads(report.read_text())), indent=2) + ) + return 0 + if args.list: + if args.case: + parser.error("--case requires --run-dir") + print(json.dumps(reference_cases(), indent=2)) + return 0 + + def stop_signal(_signum, _frame): + raise KeyboardInterrupt() + + previous = signal.signal(signal.SIGTERM, stop_signal) + try: + return run_reference(args.run_dir, args.case) + finally: + signal.signal(signal.SIGTERM, previous) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/src/vcore_scripts/protocol_fingerprint_shape.py b/scripts/src/vcore_scripts/protocol_fingerprint_shape.py new file mode 100644 index 0000000..af053a1 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_fingerprint_shape.py @@ -0,0 +1,259 @@ +"""Public-config TLS wire checks over memory IO; no network observer.""" + +from __future__ import annotations + +import argparse +import base64 +import copy +import io +import json +import os +from pathlib import Path + +from .builds import CORE_DIR +from .container_tls_observer import capture_client_hello +from .protocol_fingerprint_reference import PROFILES, SNIS, TEMPLATES, _reference_shape +from .protocol_inputs import redact, same_source, source_identity +from .protocol_peers import run_command +from .tls_client_hello import parse_client_hello, validate_capture + +CONTEXTS = ("tcp", "ws", "grpc", "reality", "vision", "xhttp-h1", "xhttp-h2") + + +def expected_shape(sample, context): + """Transform an independent baseline only for documented caller policies.""" + case = dict(template=sample["template"], sni=SNIS[0], context=sample["context"]) + shape = _reference_shape(validate_capture(sample), case) + tls13 = context in {"vision", "xhttp-h1", "xhttp-h2"} + if tls13: + # Standard Vision/XHTTP require TLS1.3 before authentication. These + # TLS1.2-only fields are omitted by native version policy, not hidden + # from actual captures. REALITY instead enforces TLS1.3 natively while + # retaining the browser offer, exactly as the reference does. + shape["ciphers"] = [ + c for c in shape["ciphers"] if c in {"GREASE", 4865, 4866, 4867} + ] + shape["extensions"] = [ + e for e in shape["extensions"] if e["type"] not in {11, 23, 35, 65281} + ] + if context in {"ws", "xhttp-h1"}: + shape["extensions"] = [ + e for e in shape["extensions"] if e["type"] not in {17513, 17613} + ] + for extension in shape["extensions"]: + if extension["type"] == 43: + extension["values"] = [ + v + for v in extension["values"] + if v == "GREASE" or v >= (772 if tls13 else 771) + ] + extension["bytes"] = 1 + 2 * len(extension["values"]) + return shape + + +def check_captures(rows): + wanted = { + (p, c, s, a) for p in PROFILES for c in CONTEXTS for s in SNIS for a in range(2) + } + actual = [(r["profile"], r["context"], r["sni"], r["attempt"]) for r in rows] + if len(actual) != len(wanted) or set(actual) != wanted: + raise ValueError("incomplete or duplicate public-config wire matrix") + samples = json.loads( + (CORE_DIR / "tests/fingerprints/mihomo-selected-v1.json").read_text() + )["samples"] + baselines = {(s["profile"], s["context"]): s for s in samples} + results = [] + for row in rows: + profile, context = row["profile"], row["context"] + canonical = {"firefox120": "firefox", "safari16": "safari"}.get( + profile, profile + ) + reference = baselines[(canonical, "reality" if context == "reality" else "tcp")] + encoded = row["records_b64"] + if len(encoded) > 128 * 1024: + raise ValueError("wire capture size limit") + wire = base64.b64decode(encoded, validate=True) + records, raw, layout = capture_client_hello(io.BytesIO(wire).read) + if records != wire or any( + r["type"] != 22 or r["version"] != 769 for r in layout + ): + raise ValueError("unexpected initial TLS record envelope") + hello = parse_client_hello(raw) + alpn = ( + ["http/1.1"] + if context in {"ws", "xhttp-h1"} + else ["h2"] + if context == "xhttp-h2" + else ["h2", "http/1.1"] + ) + shape = _reference_shape( + hello, dict(row, template=TEMPLATES[profile]), expected_alpn=alpn + ) + expected = expected_shape(reference, context) + if shape != expected: + differences = { + k: dict(expected=expected.get(k), observed=shape.get(k)) + for k in expected.keys() | shape.keys() + if expected.get(k) != shape.get(k) + } + raise ValueError( + f"{profile}/{context}/{len(row['sni'])}/{row['attempt']}: " + f"{json.dumps(differences)}" + ) + results.append( + dict( + copy.deepcopy(row), + client_hello_b64=base64.b64encode(raw).decode(), + records=layout, + parsed=hello, + status="SHAPE VERIFIED", + ) + ) + return results + + +def check_warm_captures(rows): + wanted = { + (p, v, phase) + for p in ("chrome", "chrome120", "firefox", "safari") + for v in ("TLSv1_2", "TLSv1_3") + for phase in ("cold", "warm", "after-hint") + } + actual = [(r["profile"], r["version"], r["phase"]) for r in rows] + if len(actual) != len(wanted) or set(actual) != wanted: + raise ValueError("incomplete or duplicate warm/expiry matrix") + samples = json.loads( + (CORE_DIR / "tests/fingerprints/mihomo-selected-v1.json").read_text() + )["samples"] + baselines = {s["profile"]: s for s in samples if s["context"] == "tcp"} + results = [] + for row in rows: + profile, version, phase = row["profile"], row["version"], row["phase"] + stateful_id = profile == "safari" and version == "TLSv1_2" + resumed = phase == "warm" or (phase == "after-hint" and stateful_id) + if row["resumed"] != resumed: + raise ValueError("unexpected real peer resumption/expiry outcome") + wire = base64.b64decode(row["records_b64"], validate=True) + records, raw, layout = capture_client_hello(io.BytesIO(wire).read) + if wire[len(records) :] not in (b"", b"\x14\x03\x03\x00\x01\x01"): + raise ValueError("unexpected warm first flight beyond ClientHello/CCS") + hello = parse_client_hello(raw) + shape = _reference_shape( + hello, + dict(template=TEMPLATES[profile], sni="fixture.invalid", context="tcp"), + allow_psk=True, + ) + expected = expected_shape(baselines[profile], "tcp") + if resumed and not stateful_id: + length = row["prior_ticket_bytes"] + if not isinstance(length, int) or not 1 <= length <= 4096: + raise ValueError("independent peer did not issue a bounded ticket") + if version == "TLSv1_3": + expected["extensions"].append( + dict(type=41, bytes=length + 43, identities=[length], binders=[32]) + ) + else: + ticket = next(e for e in expected["extensions"] if e["type"] == 35) + ticket.update(bytes=length, ticket_bytes=length) + if shape != expected: + differences = { + k: dict(expected=expected.get(k), observed=shape.get(k)) + for k in expected.keys() | shape.keys() + if expected.get(k) != shape.get(k) + } + raise ValueError( + f"warm {profile}/{version}/{phase}: {json.dumps(differences)}" + ) + results.append( + dict( + row, + records_b64=base64.b64encode(records).decode(), + client_hello_b64=base64.b64encode(raw).decode(), + records=layout, + parsed=hello, + status="SHAPE VERIFIED", + ) + ) + return results + + +def run(output): + if output.parent != CORE_DIR / "target/interop/runs": + raise ValueError("use a fresh directory directly under target/interop/runs") + output.mkdir(exist_ok=False) + report = dict( + stage="FINGERPRINT-WIRE", + scope="memory-clienthello-structure", + source=source_identity(), + status="NOT RUN", + ) + try: + result = run_command( + [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "fingerprint_capture", + "--", + "--nocapture", + ], + timeout=180, + env=dict( + os.environ, VCORE_FINGERPRINT_CAPTURE=str(output / "captures.json") + ), + ) + (output / "command.log").write_text( + redact(result.stdout.decode(errors="replace")) + ) + report["cleanup"] = result.cleanup + if result.returncode or not result.cleanup: + raise RuntimeError("memory capture command failed") + report["cases"] = check_captures( + json.loads((output / "captures.json").read_text()) + ) + warm = run_command( + [ + "cargo", + "test", + "--locked", + "--all-features", + "--lib", + "security::tls::tests::named_wire", + "--", + "--nocapture", + ], + timeout=180, + env=dict( + os.environ, VCORE_FINGERPRINT_WARM_CAPTURE=str(output / "warm.json") + ), + ) + (output / "warm-command.log").write_text( + redact(warm.stdout.decode(errors="replace")) + ) + report["cleanup"] = report["cleanup"] and warm.cleanup + if warm.returncode or not warm.cleanup: + raise RuntimeError("warm/expiry/version gate failed") + report["warm_cases"] = check_warm_captures( + json.loads((output / "warm.json").read_text()) + ) + report["status"] = "PASS" + except Exception as error: + report.update(status="FAIL", error=redact(str(error))) + finally: + report["source_after"] = source_identity() + report["source_unchanged"] = same_source( + report["source"], report["source_after"] + ) + if not report["source_unchanged"]: + report["status"] = "FAIL" + (output / "shape-results.json").write_text(json.dumps(report, indent=2) + "\n") + print(report["status"], report.get("error", "")) + return 0 if report["status"] == "PASS" else 1 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("output", type=Path) + raise SystemExit(run(parser.parse_args().output.resolve())) diff --git a/scripts/src/vcore_scripts/protocol_fixtures.py b/scripts/src/vcore_scripts/protocol_fixtures.py new file mode 100644 index 0000000..d280ba7 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_fixtures.py @@ -0,0 +1,181 @@ +"""Shared synthetic certificates and peer configuration; never starts a listener.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +from .protocol_peers import run_command + + +def certificates(directory: Path): + cert, key = directory / "cert.pem", directory / "key.pem" + generated = run_command( + [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "2", + "-subj", + "/CN=localhost", + "-addext", + "subjectAltName=DNS:localhost", + "-keyout", + str(key), + "-out", + str(cert), + ], + timeout=20, + limit=65536, + ) + der = run_command( + ["openssl", "x509", "-in", str(cert), "-outform", "DER"], + timeout=10, + limit=65536, + ) + if ( + generated.returncode != 0 + or der.returncode != 0 + or not generated.cleanup + or not der.cleanup + ): + raise RuntimeError("synthetic certificate generation failed") + return cert, key, hashlib.sha256(der.stdout).hexdigest() + + +def certificate_chain(directory): + """Owned synthetic CA + leaf; root pin still verifies the leaf name.""" + root = directory / "root.pem" + root_key = directory / "root-key.pem" + key = directory / "key.pem" + csr = directory / "leaf.csr" + cert = directory / "cert.pem" + extensions = directory / "extensions.cnf" + extensions.write_text( + "basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=DNS:localhost\n" + ) + commands = [ + [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "2", + "-subj", + "/CN=trojan-root", + "-addext", + "basicConstraints=critical,CA:TRUE", + "-keyout", + str(root_key), + "-out", + str(root), + ], + [ + "openssl", + "req", + "-new", + "-newkey", + "rsa:2048", + "-nodes", + "-subj", + "/CN=localhost", + "-keyout", + str(key), + "-out", + str(csr), + ], + [ + "openssl", + "x509", + "-req", + "-in", + str(csr), + "-CA", + str(root), + "-CAkey", + str(root_key), + "-set_serial", + "2", + "-days", + "2", + "-extfile", + str(extensions), + "-out", + str(cert), + ], + ["openssl", "x509", "-in", str(root), "-outform", "DER"], + ] + for command in commands: + result = run_command(command, timeout=20, limit=65536) + if result.returncode or not result.cleanup: + raise RuntimeError("synthetic chain generation failed") + cert.write_bytes(cert.read_bytes() + root.read_bytes()) + return cert, key, hashlib.sha256(result.stdout).hexdigest() + + +def trojan_peer_config(kind, mode, port, password, cert, key): + host = "::1" if mode.endswith("-ipv6") else "127.0.0.1" + mode = mode.removesuffix("-ipv6").removesuffix("-ca") + if kind == "M": + listener = { + "name": "trojan", + "type": "trojan", + "listen": host, + "port": port, + "users": [{"username": "fixture", "password": password}], + "certificate": str(cert), + "private-key": str(key), + } + if mode.startswith("ws"): + listener["ws-path"] = "cover.example/trojan-ws" + if mode.startswith("grpc"): + listener["grpc-service-name"] = "trojan-grpc" + if mode == "ws-alpn": + listener["grpc-service-name"] = "unrelated-service" + return { + "mode": "rule", + "log-level": "silent", + "ipv6": True, + "hosts": {"vcore-fixture.test": "127.0.0.1"}, + "listeners": [listener], + "rules": ["MATCH,DIRECT"], + } + stream = { + "network": "tcp", + "security": "tls", + "tlsSettings": { + "certificates": [{"certificateFile": str(cert), "keyFile": str(key)}] + }, + } + if mode.startswith("ws"): + stream.update(network="ws", wsSettings={"path": "/trojan-ws/"}) + stream["tlsSettings"]["alpn"] = ["http/1.1"] + if kind == "V2": + stream["wsSettings"].update( + maxEarlyData=2048, + earlyDataHeaderName="x-vcore-ed" if mode == "ws-header" else "", + ) + if mode == "grpc": + stream.update(network="grpc", grpcSettings={"serviceName": "trojan-grpc"}) + stream["tlsSettings"]["alpn"] = ["h2"] + return { + "log": {"loglevel": "none"}, + "dns": {"hosts": {"vcore-fixture.test": "127.0.0.1"}}, + "inbounds": [ + { + "listen": "127.0.0.1", + "port": port, + "protocol": "trojan", + "settings": {"clients": [{"password": password}]}, + "streamSettings": stream, + } + ], + "outbounds": [{"protocol": "freedom", "settings": {"domainStrategy": "UseIP"}}], + } diff --git a/scripts/src/vcore_scripts/protocol_harness.py b/scripts/src/vcore_scripts/protocol_harness.py new file mode 100644 index 0000000..31eb25a --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_harness.py @@ -0,0 +1,649 @@ +"""Unified foundation and implemented protocol-consumer acceptance runner.""" + +from __future__ import annotations + +import contextlib +import json +import os +import signal +import sys +import tempfile +import time +from datetime import UTC, datetime +from pathlib import Path + +from .builds import CORE_DIR +from .mihomo import _run_mihomo_interop +from .mihomo_isolation import exclusive_run +from .protocol_catalogs import CATALOG_DIR, check_protocol_catalogs +from .protocol_evidence import ( + check_limit_references, + check_run, + idle_resources, + load_manifest, + new_result, + read_events, + read_json, + rust_results, + select_cases, + validate_results, +) +from .protocol_inputs import redact, run_identity, sha256, source_identity +from .protocol_peers import run_command +from .protocol_preflight import preflight +from .protocol_streams import run_streams + + +@contextlib.contextmanager +def deadline(seconds: float): + if not hasattr(signal, "SIGALRM"): + raise RuntimeError("whole-suite watchdog requires a supported Unix test host") + previous = signal.getsignal(signal.SIGALRM) + + def expired(*_): + raise TimeoutError("protocol suite deadline expired") + + signal.signal(signal.SIGALRM, expired) + previous_timer = signal.getitimer(signal.ITIMER_REAL) + started = time.monotonic() + signal.setitimer( + signal.ITIMER_REAL, + min(seconds, previous_timer[0]) if previous_timer[0] > 0 else seconds, + ) + try: + yield + finally: + remaining = ( + max(0.001, previous_timer[0] - (time.monotonic() - started)) + if previous_timer[0] > 0 + else 0 + ) + signal.setitimer(signal.ITIMER_REAL, remaining, previous_timer[1]) + signal.signal(signal.SIGALRM, previous) + + +def _events(path: Path) -> list[dict]: + if not path.exists(): + return [] + return read_events(path) + + +def _command( + run, + output: Path, + name: str, + command: list[str], + seconds: int, + *, + events: Path | None = None, +): + env = dict(os.environ) + env.pop("VCORE_CASE_EVENTS", None) + if events: + env["VCORE_CASE_EVENTS"] = str(events) + print(f"{run.get('stage', 'FOUNDATIONS')}: {name}", flush=True) + result = run_command( + command, cwd=CORE_DIR, env=env, timeout=seconds, limit=4 * 1024 * 1024 + ) + log = output / f"{name}.log" + log.write_text(redact(result.stdout.decode("utf-8", errors="replace"))) + run["commands"].append( + { + "name": name, + "command": [redact(str(part)) for part in command], + "exit_code": result.returncode, + "seconds": result.seconds, + "cleanup": result.cleanup, + "reason": result.reason, + "log": log.name, + } + ) + return result + + +def _features(case, run, output): + started = time.monotonic() + results = [] + names = [ + None, + "outbound-trojan", + "outbound-vmess", + "outbound-hysteria2", + "stream-transport", + "quic-transport", + ] + for name in names: + command = ["cargo", "check", "--locked", "--no-default-features", "--lib"] + if name: + command.extend(["--features", name]) + results.append( + _command( + run, + output, + "feature-" + (name or "minimal"), + command, + max(1, int(case["timeout_seconds"] - (time.monotonic() - started))), + ) + ) + results.append( + _command( + run, + output, + "feature-default", + ["cargo", "test", "--locked", "--test", "feature_foundations"], + max(1, int(case["timeout_seconds"] - (time.monotonic() - started))), + ) + ) + cargo = __import__("tomllib").loads((CORE_DIR / "Cargo.toml").read_text()) + for name in names[1:]: + pending = [name] + enabled = set() + while pending: + item = pending.pop() + if item not in enabled: + enabled.add(item) + pending.extend(cargo["features"].get(item, [])) + if enabled & {"outbound-vless", "tun"}: + raise RuntimeError( + "foundation feature silently enables an old protocol or TUN" + ) + passed = all(item.returncode == 0 and item.cleanup for item in results) + result = new_result(case) + result.update( + status="PASS" if passed else "FAIL", + assertions=dict.fromkeys(case["expected_observation"], passed), + evidence=case["required_evidence"] if passed else [], + cleanup=all(item.cleanup for item in results), + command_exit_code=0 if passed else 1, + ) + return result + + +SCRIPT_OBSERVATIONS = { + "offline-success": ( + "test_protocol_peers.OwnedPeerTest." + "test_success_is_bounded_and_records_exit_and_cleanup" + ), + "missing-duplicate-unknown-case": ( + "test_protocol_evidence.ProtocolEvidenceTest." + "test_missing_duplicate_unknown_and_empty_required_cases_fail" + ), + "download-failure": ( + "test_native_release.NativeReleaseTest." + "test_failed_download_never_uses_old_binary_or_leaks_network_details" + ), + "timeout": ( + "test_protocol_peers.OwnedPeerTest." + "test_timeout_and_output_overflow_fail_and_join" + ), + "sigint": ( + "test_protocol_peers.OwnedPeerTest." + "test_real_sigint_joins_nested_peer_without_stopping_unrelated_peer" + ), + "partial-start": ( + "test_protocol_peers.OwnedPeerTest." + "test_partial_start_and_permission_failure_do_not_leave_a_process" + ), + "permission": ( + "test_protocol_peers.OwnedPeerTest." + "test_partial_start_and_permission_failure_do_not_leave_a_process" + ), + "peer-exit": ("test_protocol_peers.OwnedPeerTest.test_peer_exit_is_not_readiness"), + "cleanup-failure": ( + "test_protocol_peers.OwnedPeerTest.test_cleanup_failure_is_explicit" + ), + "redaction": ( + "test_native_release.NativeReleaseTest." + "test_failed_download_never_uses_old_binary_or_leaks_network_details" + ), +} + + +def _scripts(case, run, output): + path = output / "script-tests.json" + command = _command( + run, + output, + "offline-scripts", + [sys.executable, "-m", "vcore_scripts.protocol_script_tests", str(path)], + case["timeout_seconds"], + ) + data = read_json(path) + tests = {entry["test"]: entry["status"] for entry in data["cases"]} + assertions = { + name: tests.get(identifier) == "PASS" + for name, identifier in SCRIPT_OBSERVATIONS.items() + } + passed = ( + command.returncode == 0 + and all(assertions.values()) + and len(tests) == len(data["cases"]) == data["tests_run"] + ) + result = new_result(case) + result.update( + status="PASS" if passed else "FAIL", + assertions=assertions, + evidence=case["required_evidence"] if passed else [], + cleanup=command.cleanup, + command_exit_code=command.returncode, + ) + return result + + +def _legacy(case, run, output, artifacts): + result = new_result(case) + if "M" not in artifacts or "M-container" not in artifacts: + result.update( + status="BLOCKED", + reason="M native/container prerequisites unavailable", + cleanup=True, + ) + return result + path = output / "legacy-events.jsonl" + previous = os.environ.get("VCORE_CASE_EVENTS") + os.environ["VCORE_CASE_EVENTS"] = str(path) + started = time.monotonic() + code = 1 + cleaned = False + try: + # The existing orchestrator owns exact peers/containers through ExitStack. + # SIGINT/timeout unwinds it in this process, not by orphaning a child CLI. + with ( + (output / "legacy.log").open("w") as log, + contextlib.redirect_stdout(log), + deadline(case["timeout_seconds"]), + ): + _run_mihomo_interop( + artifacts["M"].binary, + extended=True, + soak_seconds=0, + container_binary=artifacts["M-container"].binary, + ) + code = 0 + cleaned = True + except Exception as error: + result.update(status="FAIL", reason=type(error).__name__) + finally: + if previous is None: + os.environ.pop("VCORE_CASE_EVENTS", None) + else: + os.environ["VCORE_CASE_EVENTS"] = previous + run["commands"].append( + { + "name": "legacy-mihomo", + "command": ["check", "mihomo-interop", "--container", "--extended"], + "exit_code": code, + "seconds": round(time.monotonic() - started, 3), + "cleanup": cleaned, + } + ) + if (output / "legacy.log").exists(): + log = (output / "legacy.log").read_text() + (output / "legacy.log").write_text(redact(log)) + result = rust_results(case, _events(path), code) + result["cleanup"] = cleaned + if result["status"] == "PASS": + result["evidence"] = case["required_evidence"] + return result + + +def _native_results(cases, report): + actual = {case["case_id"]: case for case in report.get("cases", [])} + results = [] + for case in cases: + result = new_result(case) + source = actual.get(case["case_id"], {}) + event = source.get("rust_event", {}) + assertions = { + "server_first": event.get("server_first") is True, + "payload_65536": event.get("payload_bytes") == 65536, + "trailer_exact": event.get("tail_bytes") == 14, + "origin_complete": source.get("origin") + == {"finished": True, "accepted": 1, "received": 65536, "failed": False}, + "protect_once": event.get("protect_calls") == 1, + "driver_joined": event.get("driver_joined") is True, + "resources_idle": event.get("resources_idle") is True + and idle_resources(event.get("resources")), + } + passed = ( + source.get("status") == "PASS" + and all(assertions.values()) + and source.get("cleanup", {}).get("joined") is True + and report.get("cleanup") is True + ) + if case["peer_kind"] == "M": + cleanup = report.get("abnormal_cleanup", {}) + passed = passed and cleanup == { + "passed": True, + "kind": "M", + "failure_injected": True, + "joined": True, + "port_rebound": True, + } + result.update( + status="PASS" if passed else source.get("status", "NOT RUN"), + assertions=assertions, + evidence=case["required_evidence"] if passed else [], + cleanup=source.get("cleanup", {}).get("joined") is True + and report.get("cleanup") is True, + command_exit_code=source.get("command_exit_code"), + seconds=source.get("seconds"), + ) + if not passed and result["status"] == "PASS": + result["status"] = "FAIL" + results.append(result) + return results + + +def _execute(selected, run, output, records): + rust = [case for case in selected if case["runner"] == "rust"] + if rust: + path = output / "rust-events.jsonl" + result = _command( + run, + output, + "rust-foundations", + ["cargo", "test", "--locked", "--all-features", "--all-targets"], + max(case["timeout_seconds"] for case in rust), + events=path, + ) + events = _events(path) + for case in rust: + records[case["case_id"]] = rust_results(case, events, result.returncode) + records[case["case_id"]]["cleanup"] = result.cleanup + kinds = {case["peer_kind"] for case in selected if case["peer_kind"] != "unit"} + artifacts, peers = preflight( + output / "binaries", + kinds, + container=any(case["runner"] == "mihomo-legacy" for case in selected), + ) + (output / "peers.json").write_text(json.dumps(peers, indent=2) + "\n") + native = [case for case in selected if case["runner"] == "native-stream"] + if native: + build = _command( + run, + output, + "native-probe-build", + [ + "cargo", + "build", + "--locked", + "--all-features", + "--example", + "protocol-stream-probe", + ], + 300, + ) + if build.returncode == 0: + report = {} + try: + report = run_streams( + output / "native", + [case["case_id"] for case in native], + artifacts=artifacts, + ) + except (OSError, ValueError, RuntimeError): + if (output / "native/stream-cases.json").exists(): + report = read_json(output / "native/stream-cases.json") + for result in _native_results(native, report): + records[result["case_id"]] = result + for case in selected: + if case["runner"] == "features": + records[case["case_id"]] = _features(case, run, output) + elif case["runner"] == "scripts": + records[case["case_id"]] = _scripts(case, run, output) + elif case["runner"] == "mihomo-legacy": + records[case["case_id"]] = _legacy(case, run, output, artifacts) + + +def run_protocol_interop( + *, + stage: str, + identifiers=None, + protocol=None, + list_only=False, + preflight_only=False, + run_dir: Path | None = None, +) -> Path | None: + selected = select_cases( + load_manifest(), stage=stage, identifiers=identifiers, protocol=protocol + ) + check_limit_references(load_manifest()) + if list_only: + for case in selected: + print( + f"{case['case_id']}\t{case['substage']}\t{case['peer_kind']}\t{case['protocol']}" + ) + return None + if stage in {"FOUNDATIONS", "TROJAN"}: + raise RuntimeError( + "legacy server orchestration is not fully containerized; " + "use check core or a current container suite" + ) + check_protocol_catalogs(CATALOG_DIR) + root = CORE_DIR / "target/interop/runs" + root.mkdir(parents=True, exist_ok=True) + if run_dir is None: + output = Path(tempfile.mkdtemp(prefix=f"{stage.lower()}-", dir=root)) + else: + output = run_dir.resolve() + if not output.is_relative_to(root.resolve()): + raise ValueError( + "run directory must be a fresh child of target/interop/runs" + ) + output.mkdir(parents=True, exist_ok=False) + print(f"Protocol evidence: {output.relative_to(CORE_DIR.resolve())}", flush=True) + (output / "manifest.json").write_text( + json.dumps( + { + "schema_version": 1, + "kind": "executable-case-manifest", + "cases": load_manifest(), + }, + indent=2, + ) + + "\n" + ) + run = { + "schema_version": 1, + "stage": stage, + "mode": "preflight" if preflight_only else "execute", + "selected_cases": [case["case_id"] for case in selected], + "commands": [], + "cleanup": False, + "source_unchanged": False, + } + records = {case["case_id"]: new_result(case) for case in selected} + (output / "peers.json").write_text("[]\n") + error = None + try: + run.update(run_identity(stage, selected, preflight_only)) + with exclusive_run(), deadline(run["suite_timeout_seconds"]): + if preflight_only and stage == "INTEGRATION": + from .protocol_integration_acceptance import ( + preflight as integration_preflight, + ) + + integration_preflight(output) + elif preflight_only and stage == "SECURITY": + _, peers = preflight( + output / "binaries", {"M", "XR", "V2"}, container=True + ) + (output / "peers.json").write_text(json.dumps(peers, indent=2) + "\n") + if any( + peer["status"] != "READY" or peer.get("container_status") != "READY" + for peer in peers + ): + raise RuntimeError("SECURITY native prerequisites are BLOCKED") + elif preflight_only and stage == "HYSTERIA2": + from .protocol_hysteria2_acceptance import preflight as preflight_hy2 + + preflight_hy2(output) + elif preflight_only and stage == "XHTTP": + from .protocol_xhttp_acceptance import preflight as preflight_xhttp + + preflight_xhttp(output) + elif preflight_only and stage in {"VMESS", "VLESS"}: + if stage == "VMESS": + from .protocol_vmess_container import run as preflight_vmess + else: + from .protocol_vless_container import run as preflight_vmess + protocol_name = "vmess" if stage == "VMESS" else "vless" + + native = [ + case["case_id"] + for case in selected + if case["runner"] == "native-" + protocol_name + ] + if preflight_vmess(output / "native", native, preflight_only=True): + raise RuntimeError("container protocol preflight failed") + peers = read_json(output / f"native/{protocol_name}-results.json")[ + "peers" + ] + (output / "peers.json").write_text(json.dumps(peers, indent=2) + "\n") + elif preflight_only: + _, peers = preflight( + output / "binaries", {"M", "V2", "H", "XR"}, container=True + ) + (output / "peers.json").write_text(json.dumps(peers, indent=2) + "\n") + if any( + peer["status"] != "READY" + or peer.get("container_status", "READY") != "READY" + for peer in peers + ): + raise RuntimeError( + "some independent peer prerequisites are BLOCKED" + ) + else: + if stage == "TROJAN": + from .protocol_trojan_acceptance import execute + + execute(selected, run, output, records) + elif stage == "VMESS": + from .protocol_vmess_acceptance import execute + + execute(selected, run, output, records) + elif stage == "VLESS": + from .protocol_vless_acceptance import execute + + execute(selected, run, output, records) + elif stage == "XHTTP": + from .protocol_xhttp_acceptance import execute + + execute(selected, run, output, records) + elif stage == "HYSTERIA2": + from .protocol_hysteria2_acceptance import execute + + execute(selected, run, output, records) + elif stage == "SECURITY": + from .protocol_security_acceptance import execute + + execute(selected, run, output, records) + elif stage == "INTEGRATION": + from .protocol_integration_acceptance import execute + + execute(selected, run, output, records) + else: + _execute(selected, run, output, records) + except BaseException as caught: + error = caught + run["failure_kind"] = type(caught).__name__ + finally: + results = list(records.values()) + run["finished_utc"] = datetime.now(UTC).isoformat() + try: + current = source_identity() + run["source_unchanged"] = current == {key: run.get(key) for key in current} + except (OSError, RuntimeError, ValueError): + run["source_unchanged"] = False + run["identity_failure"] = True + run["cleanup"] = error is None and ( + preflight_only or all(result["cleanup"] for result in results) + ) + (output / "cases.json").write_text(json.dumps(results, indent=2) + "\n") + resources = [] + try: + for path in output.rglob("*-events.jsonl"): + for event in _events(path): + if event.get("resources") is not None: + resources.append(event) + if (output / "native/stream-cases.json").exists(): + for case in read_json(output / "native/stream-cases.json")["cases"]: + if case.get("rust_event", {}).get("resources") is not None: + resources.append( + { + "case_id": case["case_id"], + "phase": "after-stop", + "resources": case["rust_event"]["resources"], + } + ) + except (OSError, ValueError, KeyError, TypeError) as caught: + error = error or caught + run.update(cleanup=False, failure_kind=type(error).__name__) + (output / "resources.jsonl").write_text( + "".join(json.dumps(item) + "\n" for item in resources) + ) + status = ( + "PREFLIGHT" + if preflight_only + else "PASS" + if error is None + and run["source_unchanged"] + and all(result["status"] == "PASS" for result in results) + else "FAIL" + ) + (output / "summary.md").write_text( + f"# {stage}: {status}\n\n" + + ( + "Trojan protocol consumer evidence; other protocols remain NOT RUN.\n\n" + if stage == "TROJAN" + else "VMess protocol consumer evidence with " + "container-only servers and origins.\n\n" + if stage == "VMESS" + else "VLESS consumer evidence with container-only peers; " + "XHTTP and security remain separate suites.\n\n" + if stage == "VLESS" + else "VLESS XHTTP/sing-mux consumer evidence; " + "container-only native peers, security remains a separate suite.\n\n" + if stage == "XHTTP" + else "Hysteria2 consumer evidence with container-only peers; " + "native H server limits and platform/device gates remain explicit.\n\n" + if stage == "HYSTERIA2" + else "Selected VLESS security consumers; static ECH only, " + "container-only peers; integration, platforms and devices " + "remain separate.\n\n" + if stage == "SECURITY" + else "Seven-protocol integration; container-only peers. " + "Partial selections do not sign off lifecycle or soak gates.\n\n" + if stage == "INTEGRATION" + else "Foundation-only evidence; " + "production protocol field consumers remain NOT RUN.\n\n" + ) + + "\n".join( + f"- {result['case_id']}: {result['status']}" for result in results + ) + + "\n" + ) + run["artifacts"] = [ + {"path": str(path.relative_to(output)), "sha256": sha256(path)} + for path in sorted(output.rglob("*")) + if path.is_file() + and path.suffix in {".json", ".jsonl", ".log", ".md"} + and "binaries" not in path.parts + and path.name != "run.json" + ] + (output / "run.json").write_text(json.dumps(run, indent=2) + "\n") + if error is not None: + raise RuntimeError( + f"protocol run incomplete ({type(error).__name__}); " + f"see {output.relative_to(CORE_DIR.resolve())}" + ) from None + if not preflight_only: + if not run["source_unchanged"]: + raise RuntimeError( + "source changed during the run; evidence cannot sign off this input" + ) + validate_results(selected, list(records.values())) + if not identifiers and protocol is None: + check_run(output, stage) + return output diff --git a/scripts/src/vcore_scripts/protocol_hysteria2.py b/scripts/src/vcore_scripts/protocol_hysteria2.py new file mode 100644 index 0000000..b1a885a --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_hysteria2.py @@ -0,0 +1,480 @@ +"""HYSTERIA2 incremental native gates; all servers/origins stay in owned containers.""" + +from __future__ import annotations + +import contextlib +import copy +import hashlib +import json +import os +import shutil +import ssl +import tempfile +from pathlib import Path + +from .builds import CORE_DIR +from .mihomo_release import download_mihomo +from .protocol_containers import ContainerLab, command +from .protocol_evidence import read_events +from .protocol_fixtures import certificate_chain, certificates +from .protocol_hysteria2_catalog import events_pass +from .protocol_inputs import redact, source_identity +from .protocol_peers import run_command +from .protocol_vless_security import client_identities + + +def security_cases(base, identities, leaf_pin): + cases = [] + + def case(name, changes=None, *, absent=(), success=True): + node = copy.deepcopy(base) + node.update(changes or {}) + for field in absent: + node.pop(field, None) + cases.append(dict(id=name, node=node, success=success)) + + case("root-pin") + case("leaf-pin", {"fingerprint": leaf_pin}) + case("root-pin-wrong-name", {"sni": "wrong.fixture.test"}, success=False) + case("leaf-pin-is-trust", {"fingerprint": leaf_pin, "sni": "wrong.fixture.test"}) + case("unknown-ca", absent=("fingerprint",), success=False) + case("explicit-skip", {"skip-cert-verify": True}, absent=("fingerprint",)) + case("wrong-pin", {"fingerprint": "00" * 32}, success=False) + case( + "skip-does-not-override-pin", + {"fingerprint": "00" * 32, "skip-cert-verify": True}, + success=False, + ) + case("wrong-password", {"password": "not-the-password"}, success=False) + case("empty-password", {"port": 23005}, absent=("password",)) + case("raw-password", {"port": 23006, "password": " untrimmed credential "}) + case("empty-alpn-default", {"alpn": []}) + case("custom-alpn", {"port": 23007, "alpn": ["h3-fixture"]}) + case("wrong-alpn", {"alpn": ["wrong-fixture"]}, success=False) + case("mtls-valid", {"port": 23008, **identities["valid"]}) + case("mtls-absent", {"port": 23008}, success=False) + for identity in ("expired", "wrong-ca"): + case("mtls-" + identity, {"port": 23008, **identities[identity]}, success=False) + obfs = {"obfs": "salamander", "obfs-password": "independent-obfs-fixture"} + case("salamander", {"port": 23009, **obfs}) + case("salamander-client-only", obfs, success=False) + case("salamander-server-only", {"port": 23009}, success=False) + case( + "salamander-wrong-key", + {"port": 23009, **obfs, "obfs-password": "wrong-obfs-key"}, + success=False, + ) + case( + "salamander-wrong-auth", + {"port": 23009, **obfs, "password": "wrong-auth"}, + success=False, + ) + return cases + + +def run(output: Path, test="hysteria2_tcp_base", *, obfs=False, supplied=None): + if test not in { + "hysteria2_tcp_base", + "hysteria2_client_first", + "hysteria2_udp_base", + "native_bandwidth_matrix", + "native_mihomo_close_alignment", + "native_security_matrix", + "native_owned_lifecycle", + "native_deadline_and_udp_budget", + "runtime::public_lifecycle", + "runtime::public_ipv6_and_gates", + "runtime::public_entrypoints", + "hysteria2::public_udp_boundaries", + "hysteria2::public_concrete_upstream", + "hysteria2::public_graph_and_hop_snapshot", + }: + raise ValueError("unknown HYSTERIA2 test") + output = output.resolve() + output.mkdir(parents=True, exist_ok=False) + report = dict( + stage="HYSTERIA2", + scope="incremental-native", + source=source_identity(), + status="NOT RUN", + isolation={}, + peers={}, + cases=[], + obfs=obfs, + ) + try: + if supplied is None: + identity = {} + binary = download_mihomo( + "linux-arm64", directory=output / "binaries", identity=identity + ) + else: + binary, identity = supplied + if ( + hashlib.sha256(binary.read_bytes()).hexdigest() + != identity["binary_sha256"] + ): + raise RuntimeError("supplied native peer identity mismatch") + report["peers"]["M"] = identity + lab = ContainerLab(report["isolation"], mtu=1500) + with tempfile.TemporaryDirectory(prefix="private-", dir=output) as temporary: + root = Path(temporary) + origin_dir, server_dir = root / "origin", root / "server" + origin_dir.mkdir() + server_dir.mkdir() + shutil.copyfile( + Path(__file__).with_name("container_udp_origin.py"), + origin_dir / "origin.py", + ) + shutil.copyfile( + Path(__file__).with_name("container_close_client.py"), + origin_dir / "close.py", + ) + shutil.copy2(binary, server_dir / "peer") + cert, key, pin = ( + certificate_chain if test == "native_security_matrix" else certificates + )(server_dir) + with contextlib.ExitStack() as stack: + origin = lab.start( + stack, + origin_dir, + "hysteria2-origin", + [ + "env", + "VCORE_ISOLATED_ORIGIN=1", + "python", + "-B", + "/data/fixture/origin.py", + ], + ) + server = lab.start( + stack, + server_dir, + "hysteria2-mihomo", + [ + "/data/fixture/peer", + "-d", + "/data", + "-f", + "/data/fixture/config.json", + ], + ) + identity["version"] = command( + "exec", server.name, "/data/fixture/peer", "-v" + ).strip() + digest = command( + "exec", server.name, "sha256sum", "/data/fixture/peer" + ).split()[0] + if digest != identity["binary_sha256"]: + raise RuntimeError("native peer identity mismatch") + upstream = server + if test in { + "hysteria2::public_concrete_upstream", + "hysteria2::public_graph_and_hop_snapshot", + }: + # A separate native instance prevents its own outer UDP + # source tuple from tripping Mihomo's loopback detector. + hop_dir = root / "hop" + hop_dir.mkdir() + shutil.copy2(binary, hop_dir / "peer") + (hop_dir / "config.json").write_text( + json.dumps( + { + "socks-port": 23001, + "allow-lan": True, + "bind-address": "*", + "ipv6": True, + "log-level": "silent", + "rules": ["MATCH,DIRECT"], + } + ) + ) + upstream = lab.start( + stack, + hop_dir, + "hysteria2-hop", + [ + "/data/fixture/peer", + "-d", + "/data", + "-f", + "/data/fixture/config.json", + ], + ) + upstream.release() + upstream.wait_tcp(23001) + password = "synthetic-hysteria2-fixture" + (server_dir / "config.json").write_text( + json.dumps( + { + "socks-port": 23001, + "allow-lan": True, + "bind-address": "*", + "ipv6": True, + "log-level": "silent", + "hosts": {"vcore-fixture.test": origin.ipv4}, + "listeners": [ + { + "name": "hysteria2", + "type": "hysteria2", + "listen": "::", + "port": 23000, + "users": {"fixture": password}, + "certificate": f"/data/fixture/{cert.name}", + "private-key": f"/data/fixture/{key.name}", + } + ], + "rules": ["MATCH,DIRECT"], + } + ) + ) + fixture = root / "input.json" + bandwidth_peer = None + if test == "native_bandwidth_matrix": + bandwidth_dir = root / "bandwidth" + bandwidth_dir.mkdir() + shutil.copyfile( + Path(__file__).with_name("container_bandwidth_origin.py"), + bandwidth_dir / "origin.py", + ) + bandwidth_peer = lab.start( + stack, + bandwidth_dir, + "hysteria2-bandwidth", + [ + "env", + "VCORE_ISOLATED_ORIGIN=1", + "python", + "-B", + "/data/fixture/origin.py", + ], + ) + bandwidth_peer.release() + bandwidth_peer.wait_tcp(24002) + peer_config = json.loads((server_dir / "config.json").read_text()) + baseline = peer_config["listeners"][0] + for port, options in [ + (23002, {"down": "1 Mbps"}), + (23003, {"up": "1 Mbps"}), + (23004, {"ignore-client-bandwidth": True}), + ]: + peer_config["listeners"].append( + dict( + baseline, name=f"hysteria2-{port}", port=port, **options + ) + ) + (server_dir / "config.json").write_text(json.dumps(peer_config)) + fixture.write_text( + json.dumps( + dict( + isolation="containers", + origin_control=f"{origin.ipv4}:24000", + origin_ipv4=origin.ipv4, + origin_ipv6=origin.ipv6, + server_ipv6=server.ipv6, + data_dir=str(root / "data"), + hop=dict( + name="hop", + type="socks5", + server=upstream.ipv4, + port=23001, + udp=True, + ), + bandwidth_control=f"{bandwidth_peer.ipv4}:24002" + if bandwidth_peer + else None, + bandwidth_ipv4=bandwidth_peer.ipv4 + if bandwidth_peer + else None, + node=dict( + name="peer", + type="hysteria2", + server=server.ipv4, + port=23000, + password=password, + udp=True, + sni="localhost", + fingerprint=pin, + alpn=["h3"], + ), + ) + ) + ) + if test == "native_security_matrix": + identities = client_identities(server_dir) + peer_config = json.loads((server_dir / "config.json").read_text()) + baseline = peer_config["listeners"][0] + for port, options in [ + (23005, {"users": {"fixture": ""}}), + (23006, {"users": {"fixture": " untrimmed credential "}}), + (23007, {"alpn": ["h3-fixture"]}), + ( + 23008, + { + "client-auth-type": "require-and-verify", + "client-auth-cert": "/data/fixture/root.pem", + }, + ), + ( + 23009, + { + "obfs": "salamander", + "obfs-password": "independent-obfs-fixture", + }, + ), + ]: + peer_config["listeners"].append( + dict( + baseline, name=f"hysteria2-{port}", port=port, **options + ) + ) + (server_dir / "config.json").write_text(json.dumps(peer_config)) + value = json.loads(fixture.read_text()) + leaf = ( + cert.read_text().split("-----END CERTIFICATE-----", 1)[0] + + "-----END CERTIFICATE-----\n" + ) + leaf_pin = hashlib.sha256( + ssl.PEM_cert_to_DER_cert(leaf) + ).hexdigest() + value["security"] = security_cases( + value["node"], identities, leaf_pin + ) + value["invalid_identities"] = [ + { + "certificate": identities["valid"]["certificate"], + "private-key": identities["expired"]["private-key"], + }, + { + "certificate": "bad PEM", + "private-key": identities["valid"]["private-key"], + }, + ] + fixture.write_text(json.dumps(value)) + if obfs: + peer_config = json.loads((server_dir / "config.json").read_text()) + for listener in peer_config["listeners"]: + listener.update( + obfs="salamander", + **{"obfs-password": "independent-obfs-fixture"}, + ) + (server_dir / "config.json").write_text(json.dumps(peer_config)) + value = json.loads(fixture.read_text()) + value["node"].update( + obfs="salamander", + **{"obfs-password": "independent-obfs-fixture"}, + ) + fixture.write_text(json.dumps(value)) + origin.release() + server.release() + origin.wait_tcp(24000) + server.wait_tcp(23001) + if test == "native_mihomo_close_alignment": + from .protocol_xhttp_fields import close_reference + + value = json.loads(fixture.read_text()) + value["close_reference"] = close_reference( + lab, stack, root, "hy2", value["node"], origin, binary, output + ) + fixture.write_text(json.dumps(value)) + events = output / f"{test}-events.jsonl" + result = run_command( + [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "hysteria2_native" + if test.startswith("native_") + else "vless_public", + test, + "--", + "--ignored", + "--exact", + "--nocapture", + ], + cwd=CORE_DIR, + timeout=600 if test == "native_bandwidth_matrix" else 240, + limit=4 * 1024 * 1024, + env=dict( + os.environ, + VCORE_VLESS_INPUT=str(fixture), + VCORE_CASE_EVENTS=str(events), + VCORE_PROTOCOL_STAGE="HYSTERIA2", + VCORE_BANDWIDTH_OBSERVATIONS=str(output / "bandwidth.jsonl"), + ), + ) + (output / f"{test}.log").write_text( + redact(result.stdout.decode(errors="replace")) + ) + observed = read_events(events) if events.exists() else [] + passed = ( + result.returncode == 0 + and result.cleanup + and events_pass(observed, test) + ) + report["cases"].append( + dict( + case_id=test, + status="PASS" if passed else "FAIL", + exit_code=result.returncode, + seconds=result.seconds, + command_cleanup=result.cleanup, + command=test_command(test), + ) + ) + report["status"] = "PASS" if passed else "FAIL" + for role, peer in (("origin", origin), ("server", server)): + peer.ensure_alive() + if peer.log.exists(): + (output / f"{role}.log").write_text( + redact(peer.log.read_text(errors="replace")[-65536:]) + ) + except (OSError, RuntimeError, ValueError) as error: + report.update( + status="FAIL", + reason=str(error) + if isinstance(error, RuntimeError) + else type(error).__name__, + ) + finally: + report["cleanup"] = all( + peer["joined"] for peer in report["isolation"].get("peers", []) + ) + report["source_unchanged"] = ( + source_identity()["source_tree_sha256"] + == report["source"]["source_tree_sha256"] + ) + if not report["source_unchanged"] or not report["cleanup"]: + report["status"] = "FAIL" + (output / "report.json").write_text(json.dumps(report, indent=2) + "\n") + return report + + +def test_command(test): + return [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "hysteria2_native" if test.startswith("native_") else "vless_public", + test, + "--", + "--ignored", + "--exact", + "--nocapture", + ] + + +if __name__ == "__main__": + import sys + + result = run( + Path(sys.argv[1]), + sys.argv[2] if len(sys.argv) > 2 else "hysteria2_tcp_base", + obfs="--salamander" in sys.argv[3:], + ) + print(json.dumps({key: result[key] for key in ("status", "cleanup", "cases")})) + raise SystemExit(0 if result["status"] == "PASS" and result["cleanup"] else 1) diff --git a/scripts/src/vcore_scripts/protocol_hysteria2_acceptance.py b/scripts/src/vcore_scripts/protocol_hysteria2_acceptance.py new file mode 100644 index 0000000..b3c1fa1 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_hysteria2_acceptance.py @@ -0,0 +1,670 @@ +"""Hysteria2 execution and fail-closed reconstruction of persisted evidence.""" + +from __future__ import annotations + +import contextlib +import json +import sys + +from .builds import DEFAULT_FEATURES +from .protocol_evidence import HEX, new_result, read_events, read_json +from .protocol_hysteria2_catalog import ( + CFG, + FIELDS, + GATES, + H3, + UNIT, + H, + M, + assertions_pass, + definitions, + events_pass, +) +from .protocol_inputs import redact + + +def commands(identifier): + cargo = ["cargo", "test", "--locked", "--all-features"] + if identifier in {"HYSTERIA2-CFG", "HYSTERIA2-UNIT", "HYSTERIA2-RELEASE"}: + if identifier == "HYSTERIA2-RELEASE": + cargo += ["--release"] + result = [] + if identifier != "HYSTERIA2-UNIT": + result.append(cargo + ["--test", "hysteria2_config"]) + if identifier != "HYSTERIA2-CFG": + result += [ + cargo + ["--lib", "outbound::hysteria2::"], + cargo + + [ + "--lib", + "outbound::connector::tests::authenticated_continuation_keeps_group_choice_but_has_a_new_io_deadline", + "--", + "--exact", + ], + cargo + ["--test", "hysteria2_paths"], + ] + return result + if identifier == "HYSTERIA2-REGRESSION": + # Every chosen test uses memory IO/config, never a host server. + targets = [ + "feature_foundations", + "limit_foundations", + "trojan", + "trojan_config", + "vmess_config", + "vless_config", + "vless_lifecycle", + "xhttp_config", + "xhttp_budget", + "h2_stream_regression", + "stream_foundations", + ] + return [ + cargo + sum((["--test", name] for name in targets), []), + cargo + + [ + "--test", + "quic_datagram_foundations", + "pending_send_is_not_restarted_and_stop_cancels_without_waiting_for_writable", + "--", + "--exact", + ], + ] + if identifier == "HYSTERIA2-QUALITY": + from .protocol_vmess_acceptance import commands as previous + + return previous("VMESS-QUALITY")[:7] + [ + [ + "cargo", + "clippy", + "--locked", + "--all-features", + "--all-targets", + "--", + "-D", + "warnings", + ], + [ + "cargo", + "test", + "--locked", + "--manifest-path", + "crates/vcore-netstack/Cargo.toml", + "--all-targets", + ], + [ + "cargo", + "clippy", + "--locked", + "--manifest-path", + "crates/vcore-netstack/Cargo.toml", + "--all-targets", + "--", + "-D", + "warnings", + ], + ] + if identifier == "HYSTERIA2-PLATFORMS": + return [ + ["vcore-scripts", "build", "apple"], + ["vcore-scripts", "build", "android"], + ] + if identifier == "HYSTERIA2-FEATURES": + checks = [ + ["cargo", "check", "--locked", "--no-default-features", "--lib"] + + (["--features", feature] if feature else []) + for feature in ( + "", + "outbound-hysteria2", + "quic-transport", + "outbound-vless", + "tun", + ) + ] + return checks + [ + [ + "cargo", + "test", + "--locked", + "--test", + "feature_foundations", + "--test", + "hysteria2_config", + ], + [ + "cargo", + "test", + "--locked", + "--no-default-features", + "--test", + "hysteria2_config", + ], + [ + "cargo", + "test", + "--locked", + "--no-default-features", + "--features", + "outbound-hysteria2", + "--test", + "hysteria2_config", + ], + [ + "cargo", + "build", + "--locked", + "--release", + "--no-default-features", + "--features", + DEFAULT_FEATURES, + "--lib", + ], + ] + if identifier == "HYSTERIA2-SCRIPTS": + return [ + [ + sys.executable, + "-m", + "vcore_scripts.protocol_script_tests", + "{output}/script-tests.json", + ] + ] + return [] + + +def gate_result(case, records, events, script=None): + identifier = case["case_id"] + good = bool(records) and all( + r.get("exit_code") == 0 and r.get("cleanup") is True for r in records + ) + if identifier in {"HYSTERIA2-CFG", "HYSTERIA2-UNIT", "HYSTERIA2-RELEASE"}: + expected = {} + if identifier != "HYSTERIA2-UNIT": + expected.update({("HYSTERIA2-CFG", name): 1 for name in CFG}) + if identifier != "HYSTERIA2-CFG": + expected.update({("HYSTERIA2-UNIT", name): 1 for name in UNIT}) + good &= assertions_pass(events, expected) + if identifier == "HYSTERIA2-SCRIPTS": + required = { + "test_protocol_hysteria2.Hysteria2AcceptanceTest." + name + for name in ( + "test_catalog_covers_all_fields_and_eight_hop_combinations", + "test_partial_unknown_duplicate_and_wrong_suite_events_fail", + "test_native_identity_cleanup_or_missing_results_fail", + "test_bandwidth_and_hop_counters_cannot_be_forged_as_empty_success", + ) + } + samples = (script or {}).get("cases", []) + names = [s.get("test") for s in samples] + good &= ( + bool(samples) + and len(names) == len(set(names)) == (script or {}).get("tests_run") + and required <= set(names) + and all(s.get("status") == "PASS" for s in samples) + ) + result = new_result(case) + result.update( + status="PASS" if good else "FAIL", + assertions=dict.fromkeys(case["expected_observation"], bool(good)), + evidence=case["required_evidence"] if good else [], + command_exit_code=0 if good else 1, + cleanup=bool(records) and all(r.get("cleanup") is True for r in records), + ) + return result + + +def bandwidth_pass(samples): + expected = [ + (d, u, down, port, controller) + for d in ("U", "D") + for u, down, port, controller in ( + (0, 0, 23000, 0), + *( + (rate, 100, 23000, rate * 125000) if d == "U" else (0, rate, 23000, 0) + for rate in (1, 2) + ), + (2, 100, 23002, 125000) if d == "U" else (0, 2, 23003, 0), + ) + ] + [("U", 2, 0, 23000, 0), ("U", 2, 2, 23004, 0)] + if len(samples) != len(expected): + return False + for sample, key in zip(samples, expected, strict=True): + if ( + tuple( + sample.get(k) + for k in ( + "direction", + "up_mbps", + "down_mbps", + "listener_port", + "controller_bps", + ) + ) + != key + ): + return False + buckets = sample.get("buckets", []) + if ( + len(buckets) != 30 + or any(type(v) is not int or v < 0 for v in buckets) + or sample.get("payload_bps", 0) <= 0 + or sample["payload_bps"] != sum(buckets) / 30 + or sample.get("udp_tx_bytes", 0) <= 0 + or sample.get("udp_rx_bytes", 0) <= 0 + ): + return False + for offset in (0, 4): + rates = [samples[offset + i]["payload_bps"] for i in range(4)] + if ( + rates[0] < 750000 + or rates[1] > 125000 * 1.1 + or rates[2] > 250000 * 1.1 + or rates[2] < rates[1] * 1.5 + or rates[3] > 125000 * 1.1 + ): + return False + return True + + +def hopping_pass(value): + return ( + value.get("seconds", 0) >= 60 + and value.get("protected_sockets", 0) >= 9 + and value.get("tcp_bytes_per_direction", 0) >= 10 * 1024 * 1024 + and HEX.fullmatch(value.get("tcp_sha256", "")) is not None + and value.get("udp_packets") == 1500 + and value.get("udp_target_kinds") == 3 + and len(value.get("udp_maximums", [])) == 3 + and all(1200 < n < 4096 for n in value["udp_maximums"]) + and value.get("socket_peak") == 2 + ) + + +def native_result(case, report, directory): + from .protocol_hysteria2 import test_command + + identifier = case["case_id"] + test = M[identifier][0] if identifier in M else H[identifier]["test"] + records = report.get("cases", []) + events = directory / ( + f"{test}-events.jsonl" if identifier in M else "hysteria2-events.jsonl" + ) + observed = read_events(events) if events.exists() else [] + isolation = report.get("isolation", {}) + peers = isolation.get("peers", []) + clean = ( + report.get("cleanup") is True + and bool(peers) + and all(p.get("joined") is True and p.get("started") is True for p in peers) + ) + good = ( + clean + and report.get("status") == "PASS" + and report.get("source_unchanged") is True + and isolation.get("network_mode") == "hostOnly" + and isolation.get("host_servers") is False + and isolation.get("guest_mtu") == 1500 + and bool(isolation.get("image_digest")) + ) + good &= ( + len(records) == 1 + and all( + r.get("case_id") == test + and r.get("status") == "PASS" + and r.get("exit_code") == 0 + and r.get("command_cleanup") is True + and r.get("command") == test_command(test) + for r in records + ) + and events_pass(observed, test) + ) + if identifier in M: + good &= report.get("obfs") is M[identifier][1] + identity = report.get("peers", {}).get("M", {}) + project = "MetaCubeX/mihomo" + else: + identity = report.get("peer", {}) + project = "HyNetworks/hysteria" + good &= ( + all( + report.get(k) is H[identifier][k] + for k in ("ipv6", "obfs", "random_interval") + ) + and report.get("auth_connections") == 1 + ) + counts = report.get("entry_port_packets", {}) + good &= ( + set(counts) == {"p23010", "p23011", "p23012"} + and all(type(v) is int and v >= 0 for v in counts.values()) + and sum(v > 0 for v in counts.values()) + >= (2 if test == "native_hopping" else 1) + ) + if test == "native_hopping": + good &= (directory / "hopping.json").exists() and hopping_pass( + read_json(directory / "hopping.json") + ) + good &= ( + bool(identity.get("version")) + and HEX.fullmatch(identity.get("binary_sha256", "")) is not None + and identity.get("source_url", "").startswith( + f"https://github.com/{project}/releases/" + ) + ) + if test == "native_bandwidth_matrix": + path = directory / "bandwidth.jsonl" + good &= path.exists() and bandwidth_pass(read_events(path)) + if test == "native_mihomo_close_alignment": + path = directory / "hy2-close-reference.json" + reference = read_json(path) if path.exists() else {} + good &= reference == {"tail_hex": "", "terminated": True, "scope": "same-mode"} + result = new_result(case) + result.update( + status="PASS" if good else "FAIL", + assertions={test: bool(good)}, + evidence=case["required_evidence"] if good else [], + command_exit_code=0 if good else 1, + cleanup=clean, + ) + return result + + +def fields_report(cases, results): + status = {r["case_id"]: r["status"] for r in results} + rows = [] + for field in sorted(FIELDS): + native = [ + c["case_id"] + for c in cases + if field in c["row_ids"] and c["case_id"] in M | H + ] + good = ( + status.get("HYSTERIA2-CFG") == "PASS" + and bool(native) + and all(status.get(i) == "PASS" for i in native) + ) + rows.append( + dict( + row_id=field, + configuration="HYSTERIA2-CFG", + behavior_cases=native, + status="PASS" if good else "NOT RUN", + ) + ) + return dict(stage="HYSTERIA2", scope="hysteria2-consumer", fields=rows) + + +def prepare(output, kinds): + from .protocol_containers import ContainerLab + from .protocol_hysteria2_hop import native_binary, packages + from .protocol_xhttp_acceptance import prepare_peers + + supplied = prepare_peers(output, kinds - {"H"}) + if "H" in kinds: + root = output / "binaries/H" + root.mkdir(parents=True) + binary, identity = native_binary(root) + record = {} + try: + lab = ContainerLab(record, mtu=1500) + with contextlib.ExitStack() as stack: + apks = packages(lab, stack, root / "apks") + supplied["H"] = binary, identity, apks, record["firewall_packages"] + finally: + (output / "package-preparation.json").write_text( + json.dumps(record, indent=2) + "\n" + ) + return supplied + + +def h3_jobs(): + return { + name: [dict(case_id=name, test="public_base")] + for name in ( + "h3-stream-one-headers", + "h3-stream-up-headers", + "h3-packet-up-headers", + ) + } + + +def h3_result(case, report, directory): + from .protocol_xhttp_fields import public_events_pass + + jobs = h3_jobs() + records = report.get("cases", []) + isolation = report.get("isolation", {}) + owned = isolation.get("peers", []) + good = ( + report.get("status") == "PASS" + and report.get("cleanup") is True + and report.get("source_unchanged") is True + and {r.get("case_id") for r in records} == set(jobs) + and len(records) == len(jobs) + and isolation.get("network_mode") == "hostOnly" + and isolation.get("host_servers") is False + and isolation.get("guest_mtu") == 1500 + and bool(isolation.get("image_digest")) + and bool(owned) + and all(p.get("started") is True and p.get("joined") is True for p in owned) + and set(report.get("peers", {})) == {"M", "XR"} + ) + for record in records: + path = directory / (record["case_id"] + "-events.jsonl") + good &= ( + record.get("exit_code") == 0 + and record.get("command_cleanup") is True + and record.get("status") == "PASS" + and record.get("assertion") == "public_base" + and record.get("command") + == [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "vless_public", + "public_base", + "--", + "--exact", + "--ignored", + "--nocapture", + ] + and path.exists() + and public_events_pass(read_events(path), "public_base") + ) + result = new_result(case) + result.update( + status="PASS" if good else "FAIL", + assertions={case["expected_observation"][0]: bool(good)}, + evidence=case["required_evidence"] if good else [], + command_exit_code=0 if good else 1, + cleanup=report.get("cleanup") is True, + ) + return result + + +def execute(cases, run, output, results): + from .protocol_harness import _command + from .protocol_hysteria2 import run as run_m + from .protocol_hysteria2_hop import run as run_h + + supplied = {} + try: + for case in cases: + identifier = case["case_id"] + if identifier not in GATES: + continue + records, events = [], [] + for index, argv in enumerate(commands(identifier)): + argv = [v.replace("{output}", str(output)) for v in argv] + name = f"{identifier}-{index}" + path = output / f"{name}-events.jsonl" + _command(run, output, name, argv, case["timeout_seconds"], events=path) + records.append(run["commands"][-1]) + if path.exists(): + events += read_events(path) + script = ( + read_json(output / "script-tests.json") + if identifier == "HYSTERIA2-SCRIPTS" + else None + ) + results[identifier] = gate_result(case, records, events, script) + if results[identifier]["status"] != "PASS": + raise RuntimeError(f"HYSTERIA2 local gate failed: {identifier}") + kinds = {"M" for c in cases if c["case_id"] in M} | { + "H" for c in cases if c["case_id"] in H + } + if any(c["case_id"] == H3 for c in cases): + kinds |= {"M", "XR"} + supplied = prepare(output, kinds) + for case in cases: + identifier = case["case_id"] + directory = output / identifier + if identifier in M: + test, obfs, _ = M[identifier] + report = run_m(directory, test, obfs=obfs, supplied=supplied["M"]) + result = native_result(case, report, directory) + elif identifier in H: + report = run_h(directory, **H[identifier], supplied=supplied["H"]) + result = native_result(case, report, directory) + elif identifier == H3: + from .protocol_xhttp_fields import run as run_h3 + + jobs = h3_jobs() + run_h3( + directory, + list(jobs), + jobs=jobs, + supplied={k: supplied[k] for k in ("M", "XR")}, + ) + report = read_json(directory / "xhttp-fields-results.json") + result = h3_result(case, report, directory) + else: + continue + results[identifier] = result + print(f"{identifier}: {result['status']}", flush=True) + if result["status"] != "PASS": + raise RuntimeError(f"HYSTERIA2 native gate failed: {identifier}") + finally: + (output / "peers.json").write_text( + json.dumps( + {kind: artifact[1] for kind, artifact in supplied.items()}, indent=2 + ) + + "\n" + ) + (output / "fields.json").write_text( + json.dumps(fields_report(cases, list(results.values())), indent=2) + "\n" + ) + + +def check(run_dir, cases, run, results, peers, paths): + if ( + cases != definitions() + or set(peers) != {"M", "H", "XR"} + or not { + "fields.json", + "script-tests.json", + "package-preparation.json", + } + <= set(paths) + ): + raise ValueError("incomplete HYSTERIA2 stage artifacts or altered manifest") + actual = {r["case_id"]: r for r in results} + records = run.get("commands", []) + expected_names = { + f"{c['case_id']}-{i}" + for c in cases + for i, _ in enumerate(commands(c["case_id"])) + } + if ( + len(records) != len(expected_names) + or {r.get("name") for r in records} != expected_names + ): + raise ValueError("missing, duplicate or extra HYSTERIA2 validation command") + source = { + k: run[k] + for k in ( + "parent_commit", + "source_tree_sha256", + "dirty_patch_sha256", + "lock_sha256", + ) + } + for case in cases: + identifier = case["case_id"] + if identifier in GATES: + selected, events = [], [] + for i, argv in enumerate(commands(identifier)): + record = next(r for r in records if r["name"] == f"{identifier}-{i}") + if ( + record["command"] + != [redact(v.replace("{output}", str(run_dir))) for v in argv] + or record["log"] not in paths + ): + raise ValueError("HYSTERIA2 validation command/log changed") + selected.append(record) + path = run_dir / f"{identifier}-{i}-events.jsonl" + if path.exists(): + events += read_events(path) + recalculated = gate_result( + case, + selected, + events, + read_json(run_dir / "script-tests.json") + if identifier == "HYSTERIA2-SCRIPTS" + else None, + ) + else: + directory = run_dir / identifier + report = read_json( + directory + / ("xhttp-fields-results.json" if identifier == H3 else "report.json") + ) + if report.get("source") != source: + raise ValueError("HYSTERIA2 native source differs from stage input") + recalculated = ( + h3_result(case, report, directory) + if identifier == H3 + else native_result(case, report, directory) + ) + if identifier in H: + if report.get("peer") != peers.get("H"): + raise ValueError("H native identity changed") + preparation = read_json(run_dir / "package-preparation.json") + if report.get("isolation", {}).get( + "firewall_packages" + ) != preparation.get("firewall_packages"): + raise ValueError("H native firewall package identity changed") + elif set(report.get("peers", {})) != ( + {"M", "XR"} if identifier == H3 else {"M"} + ): + raise ValueError("missing or extra native peer identity") + elif any( + identity != peers.get(kind) + for kind, identity in report.get("peers", {}).items() + ): + raise ValueError("native identity changed between reports") + if recalculated != actual[identifier] or recalculated["status"] != "PASS": + raise ValueError("HYSTERIA2 results differ from structured evidence") + preparation = read_json(run_dir / "package-preparation.json") + if ( + not preparation.get("firewall_packages") + or not preparation.get("peers") + or any(p.get("joined") is not True for p in preparation["peers"]) + ): + raise ValueError("native firewall preparation identity/cleanup missing") + fields = fields_report(cases, results) + if read_json(run_dir / "fields.json") != fields or any( + f["status"] != "PASS" for f in fields["fields"] + ): + raise ValueError("missing HYSTERIA2 field behavior coverage") + print(f"HYSTERIA2: PASS ({len(cases)} required cases; {len(FIELDS)} field rows)") + + +def preflight(output): + # The normal native gates prove readiness as well as protocol behavior; + # this command deliberately never fabricates a protocol PASS. + supplied = prepare(output, {"M", "H", "XR"}) + (output / "peers.json").write_text( + json.dumps({k: v[1] for k, v in supplied.items()}, indent=2) + "\n" + ) diff --git a/scripts/src/vcore_scripts/protocol_hysteria2_catalog.py b/scripts/src/vcore_scripts/protocol_hysteria2_catalog.py new file mode 100644 index 0000000..b43a7ee --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_hysteria2_catalog.py @@ -0,0 +1,329 @@ +"""Frozen HYSTERIA2 requirements and exact structured assertion identities.""" + +from __future__ import annotations + +from collections import Counter + +from .protocol_evidence import idle_resources + +FIELDS = { + *(f"C{i:02}" for i in range(1, 7)), + "T01", + "T03", + "T04", + "T05", + "T07", + "T08", + *(f"HY{i:02}" for i in range(1, 9)), +} +CFG = [ + "hysteria2_configuration_follows_its_protocol_feature", + "hysteria2_accepts_the_approved_tls_bandwidth_obfs_and_hopping_configuration", + "hysteria2_fields_are_strict_normalized_and_credentials_are_not_trimmed", +] +UNIT = [ + "tcp_wire_matches_the_protocol_and_preserves_server_first_bytes", + "udp_wire_round_trips_at_budget_and_bounds_malformed_fragments", + "udp_reassembly_isolates_sources_duplicates_counts_bytes_and_expiry", + "salamander_matches_independent_blake2b256_vector_and_rejects_short_packets", + "pacer_limits_actual_bytes_after_one_bounded_burst", + "bandwidth_negotiation_obeys_auto_and_the_lower_positive_limit", + "application_eof_closes_both_directions_without_consuming_a_tail", + "shutdown_wakes_an_already_pending_logical_reader", + "a_client_first_write_does_not_steal_a_pending_read_wakeup", + "a_new_path_packet_does_not_end_the_old_receive_window", + "authenticated_continuation_keeps_group_choice_but_has_a_new_io_deadline", + "controlled_path_maps_only_its_authorized_source_and_stops_without_replay", +] +SECURITY = ( + "root-pin", + "leaf-pin", + "root-pin-wrong-name", + "leaf-pin-is-trust", + "unknown-ca", + "explicit-skip", + "wrong-pin", + "skip-does-not-override-pin", + "wrong-password", + "empty-password", + "raw-password", + "empty-alpn-default", + "custom-alpn", + "wrong-alpn", + "mtls-valid", + "mtls-absent", + "mtls-expired", + "mtls-wrong-ca", + "salamander", + "salamander-client-only", + "salamander-server-only", + "salamander-wrong-key", + "salamander-wrong-auth", +) +M = { + "HYSTERIA2-TCP": ( + "hysteria2_tcp_base", + False, + ["C01", "C02", "C03", "C04", "T01", "T03", "HY01"], + ), + "HYSTERIA2-UDP": ("hysteria2_udp_base", False, ["C05", "HY06"]), + "HYSTERIA2-TCP-SALAMANDER": ("hysteria2_tcp_base", True, ["HY02", "HY03"]), + "HYSTERIA2-UDP-SALAMANDER": ("hysteria2_udp_base", True, ["HY02", "HY03", "HY06"]), + "HYSTERIA2-SECURITY": ( + "native_security_matrix", + False, + ["T01", "T03", "T04", "T05", "T07", "T08", "HY01", "HY02", "HY03"], + ), + "HYSTERIA2-BANDWIDTH": ("native_bandwidth_matrix", False, ["HY04", "HY05"]), + "HYSTERIA2-CLOSE": ("native_mihomo_close_alignment", False, []), + "HYSTERIA2-CLOSE-SALAMANDER": ( + "native_mihomo_close_alignment", + True, + ["HY02", "HY03"], + ), + "HYSTERIA2-OWNED": ("native_owned_lifecycle", False, []), + "HYSTERIA2-DEADLINE": ("native_deadline_and_udp_budget", False, ["HY06"]), + "HYSTERIA2-LIFE": ("runtime::public_lifecycle", False, []), + "HYSTERIA2-ENTRYPOINTS": ("runtime::public_entrypoints", False, ["C05"]), + "HYSTERIA2-IPV6-GATES": ("runtime::public_ipv6_and_gates", False, ["C03", "C05"]), + "HYSTERIA2-UDP-BOUNDARIES": ("hysteria2::public_udp_boundaries", False, ["HY06"]), + "HYSTERIA2-UPSTREAM": ("hysteria2::public_concrete_upstream", False, ["C06"]), + "HYSTERIA2-GRAPH": ( + "hysteria2::public_graph_and_hop_snapshot", + False, + ["C06", "HY07", "HY08"], + ), +} +H = { + "-".join( + ( + "HYSTERIA2-HOP", + "V6" if v6 else "V4", + "OBFS" if obfs else "PLAIN", + "RANDOM" if random else "FIXED", + ) + ): dict(test="native_hopping", ipv6=v6, obfs=obfs, random_interval=random) + for v6 in (False, True) + for obfs in (False, True) + for random in (False, True) +} +H.update( + { + "HYSTERIA2-UDP-DISABLED": dict( + test="native_udp_disabled", ipv6=False, obfs=False, random_interval=False + ), + "HYSTERIA2-HOP-PROTECT": dict( + test="native_hop_protect_rejection", + ipv6=False, + obfs=False, + random_interval=False, + ), + "HYSTERIA2-HOP-STOP": dict( + test="native_stop_during_hop", ipv6=True, obfs=True, random_interval=False + ), + } +) +GATES = { + "HYSTERIA2-CFG", + "HYSTERIA2-UNIT", + "HYSTERIA2-RELEASE", + "HYSTERIA2-REGRESSION", + "HYSTERIA2-QUALITY", + "HYSTERIA2-FEATURES", + "HYSTERIA2-SCRIPTS", + "HYSTERIA2-PLATFORMS", +} +H3 = "HYSTERIA2-H3-REGRESSION" + + +def expected_events(test): + public = { + "hysteria2_tcp_base": ("HYSTERIA2-BASE", "tcp_ipv4_ipv6_domain_and_measure"), + "hysteria2_udp_base": ("HYSTERIA2-BASE", "udp_ipv4_ipv6_domain_fragmentation"), + "hysteria2_client_first": ("HYSTERIA2-BASE", "client_first"), + } + if test in public: + expected = {public[test]: 1} + if test == "hysteria2_tcp_base": + expected["HYSTERIA2-BASE", "tcp_10mib_both_directions"] = 3 + return expected + if test.startswith("runtime::"): + result = {("HYSTERIA2-PUBLIC", test): 1} + if test == "runtime::public_lifecycle": + result["HYSTERIA2-LIFE", "stop_and_remain_quiet"] = 20 + return result + if test.startswith("hysteria2::"): + return {("HYSTERIA2-PUBLIC", test.removeprefix("hysteria2::")): 1} + if test == "native_security_matrix": + return { + ("HYSTERIA2-SECURITY", test): 1, + **{("HYSTERIA2-SECURITY-CASE", name): 1 for name in SECURITY}, + } + return { + ( + { + "native_bandwidth_matrix": "HYSTERIA2-BANDWIDTH", + "native_owned_lifecycle": "HYSTERIA2-LIFE", + "native_hopping": "HYSTERIA2-HOP", + "native_udp_disabled": "HYSTERIA2-UDP-DISABLED", + "native_hop_protect_rejection": "HYSTERIA2-HOP", + "native_stop_during_hop": "HYSTERIA2-HOP", + "native_deadline_and_udp_budget": "HYSTERIA2-NATIVE", + "native_mihomo_close_alignment": "HYSTERIA2-NATIVE", + }[test], + test, + ): 20 if test == "native_owned_lifecycle" else 1 + } + + +def assertions_pass(events, expected): + if not events or any( + e.get("schema_version") != 1 or e.get("status") not in {"BEGIN", "PASS"} + for e in events + ): + return False + actual = Counter((e.get("suite"), e.get("assertion")) for e in events) + if actual != Counter({key: count * 2 for key, count in expected.items()}): + return False + for key, count in expected.items(): + selected = [e for e in events if (e.get("suite"), e.get("assertion")) == key] + if [e["status"] for e in selected] != ["BEGIN", "PASS"] * count: + return False + for event in selected[1::2]: + if event.get("resources") is not None and not idle_resources( + event["resources"] + ): + return False + return True + + +def events_pass(events, test): + if not assertions_pass(events, expected_events(test)): + return False + passed = [e for e in events if e["status"] == "PASS"] + if test in { + "native_hopping", + "native_hop_protect_rejection", + "native_stop_during_hop", + "native_owned_lifecycle", + "native_deadline_and_udp_budget", + "native_security_matrix", + }: + owned = [e for e in passed if e["assertion"] == test] + if any(not idle_resources(e.get("resources")) for e in owned): + return False + if test == "native_owned_lifecycle" and any( + [p["phase"] for p in e["checkpoints"]] + != ["baseline", "after-stop", "quiet"] + or any(not idle_resources(p.get("resources")) for p in e["checkpoints"]) + or e["seconds"] < 5 + for e in owned + ): + return False + return True + + +def definitions(): + output = [] + for identifier in sorted(GATES | set(M) | set(H) | {H3}): + kind, test, rows, options = "unit", identifier, [], {} + if identifier in M: + test, obfs, rows = M[identifier] + kind, options = "M", dict(obfs=obfs) + elif identifier in H: + options = H[identifier] + kind, test = "H", options["test"] + rows = ["C04", "HY07", "HY08"] if test != "native_udp_disabled" else ["C05"] + elif identifier == H3: + kind, test = "XR", "shared-quic-h3-native-regression" + elif identifier == "HYSTERIA2-CFG": + rows = sorted(FIELDS) + native = kind != "unit" + substage = ( + "HYSTERIA2.hopping" + if kind == "H" and test != "native_udp_disabled" + else "HYSTERIA2.bandwidth" + if identifier == "HYSTERIA2-BANDWIDTH" + else "HYSTERIA2.udp" + if "UDP" in identifier + else "HYSTERIA2.tcp" + if identifier in M + else "HYSTERIA2.acceptance" + ) + expected = ( + CFG + if identifier == "HYSTERIA2-CFG" + else UNIT + if identifier == "HYSTERIA2-UNIT" + else CFG + UNIT + if identifier == "HYSTERIA2-RELEASE" + else [test] + ) + output.append( + dict( + case_id=identifier, + stage="HYSTERIA2", + substage=substage, + required=True, + row_ids=sorted(rows), + protocol="hysteria2" if identifier != H3 else "vless", + network="quic", + security="tls13", + udp_codec="hysteria2-datagram" if identifier != H3 else "vless", + field_values=options, + outer_family="IPv6" + if options.get("ipv6") + else "IPv4/IPv6" + if identifier == "HYSTERIA2-IPV6-GATES" + else "IPv4", + inner_family="IPv4/IPv6/domain", + target_type="isolated synthetic origin" + if native + else "memory-only/build", + upstream_graph="direct/concrete/nested-select" + if identifier in {"HYSTERIA2-GRAPH", "HYSTERIA2-UPSTREAM"} + else "direct", + peer_kind=kind, + peer_config=dict( + test=test, **{k: v for k, v in options.items() if k != "test"} + ), + gap_source=dict( + reason=( + "Mihomo HY2 listener cannot redirect one QUIC state " + "across a port set or disable authenticated business " + "UDP; official Hysteria provides these gates" + ), + source="https://github.com/HyNetworks/hysteria/blob/e1366b173ccf5706e1e4630fe8aa654a4b574085/core/server/server.go", + ) + if kind == "H" + else dict( + reason=( + "XHTTP H3 shared-adapter regression uses the XHTTP " + "official Xray handler" + ), + source="https://github.com/XTLS/Xray-core", + ) + if kind == "XR" + else None, + expected_observation=expected, + required_evidence=["structured-assertions", "command", "cleanup"] + + (["peer-identity", "isolated-origins"] if native else []), + prerequisites=[ + "owned-host-only-container-network", + "official-latest-peer", + ] + if native + else ["rust-toolchain"], + runner="native-hysteria2" if kind in {"M", "H"} else "hysteria2-gate", + timeout_seconds=3600 + if identifier == "HYSTERIA2-PLATFORMS" + else 900 + if identifier == H3 + else 1200 + if not native + else 600 + if identifier == "HYSTERIA2-BANDWIDTH" + else 300, + ) + ) + return output diff --git a/scripts/src/vcore_scripts/protocol_hysteria2_hop.py b/scripts/src/vcore_scripts/protocol_hysteria2_hop.py new file mode 100644 index 0000000..4a5252c --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_hysteria2_hop.py @@ -0,0 +1,440 @@ +"""Official Hysteria, with redirection confined to an owned Linux container.""" + +from __future__ import annotations + +import contextlib +import hashlib +import json +import os +import shutil +import tempfile +import time +import urllib.request +from pathlib import Path + +from .builds import CORE_DIR +from .protocol_containers import NETWORK, ContainerLab, ContainerPeer, command +from .protocol_evidence import read_events +from .protocol_fixtures import certificates +from .protocol_hysteria2_catalog import events_pass +from .protocol_inputs import redact, source_identity +from .protocol_peers import run_command + + +def guest(peer, *argv, timeout=30): + # Container CLI can exit zero even when its guest command fails. + result = run_command( + [ + "container", + "exec", + peer.name, + "/bin/sh", + "-c", + '"$@" 2>&1\ncode=$?\nprintf "\\nVCORE_STATUS=%s\\n" "$code"', + "fixture", + *argv, + ], + timeout=timeout, + ) + output = result.stdout.decode(errors="replace") + if result.returncode != 0 or not result.cleanup: + raise RuntimeError( + f"isolated guest {argv[0]} failed after {result.seconds:.1f}s " + f"(exit={result.returncode}, cleanup={result.cleanup}): " + f"{redact(output[-2048:])}" + ) + body, marker, code = output.rpartition("\nVCORE_STATUS=") + if not marker or code.strip() != "0": + raise RuntimeError( + f"isolated guest operation failed: {argv[0]}: {redact(body[-2048:])}" + ) + return body + + +def native_binary(output): + url = "https://github.com/HyNetworks/hysteria/releases/latest/download/hysteria-linux-arm64" + path = output / "hysteria" + digest, total, deadline = hashlib.sha256(), 0, time.monotonic() + 90 + request = urllib.request.Request(url, headers={"User-Agent": "VCore-interop"}) + with ( + urllib.request.urlopen(request, timeout=30) as response, + path.open("xb") as target, + ): + if not response.geturl().startswith("https://"): + raise RuntimeError("native download redirected outside HTTPS") + while chunk := response.read(1024 * 1024): + total += len(chunk) + if total > 128 * 1024 * 1024 or time.monotonic() > deadline: + raise RuntimeError("native binary download budget exceeded") + target.write(chunk) + digest.update(chunk) + path.chmod(0o755) + return path, {"source_url": url, "binary_sha256": digest.hexdigest()} + + +def packages(lab, stack, root): + """Only package preparation has external egress; no server runs there.""" + root.mkdir() + peer = ContainerPeer(lab, root, "hysteria2-packages") + lab.record["peers"].append(peer.record) + stack.callback(peer.stop) + command( + "run", + "--detach", + "--name", + peer.name, + "--label", + f"purpose={NETWORK}", + "--label", + f"vcore-run={lab.run_id}", + "--network", + "default", + "--arch", + "arm64", + "--cpus", + "1", + "--memory", + "256M", + "--mount", + f"type=bind,source={root},target=/packages", + "--entrypoint", + "/bin/sleep", + lab.image, + "360", + timeout=45, + ) + peer.record.update( + started=True, role="package-preparation-only", network_mode="default" + ) + guest(peer, "apk", "update", timeout=60) + guest( + peer, + "apk", + "fetch", + "--recursive", + "--output", + "/packages", + "nftables", + timeout=240, + ) + found = sorted(root.glob("*.apk")) + if not found: + raise RuntimeError("missing native firewall packages") + lab.record["firewall_packages"] = { + path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in found + } + peer.stop() + return found + + +def run( + output: Path, + *, + ipv6=False, + obfs=False, + random_interval=False, + test="native_hopping", + supplied=None, +): + if test not in { + "native_hopping", + "native_udp_disabled", + "native_hop_protect_rejection", + "native_stop_during_hop", + }: + raise ValueError("unknown native H gate") + output = output.resolve() + output.mkdir(parents=True, exist_ok=False) + report = dict( + stage="HYSTERIA2", + scope="incremental-native-H", + source=source_identity(), + status="NOT RUN", + isolation={}, + cases=[], + ipv6=ipv6, + obfs=obfs, + random_interval=random_interval, + ) + try: + if supplied is None: + binary, report["peer"] = native_binary(output) + else: + binary, report["peer"], _, _ = supplied + if ( + hashlib.sha256(binary.read_bytes()).hexdigest() + != report["peer"]["binary_sha256"] + ): + raise RuntimeError("supplied H artifact identity mismatch") + lab = ContainerLab(report["isolation"], mtu=1500) + with ( + tempfile.TemporaryDirectory(prefix="private-", dir=output) as temporary, + contextlib.ExitStack() as stack, + ): + root = Path(temporary) + root.chmod(0o700) + if supplied is None: + apks = packages(lab, stack, root / "apks") + else: + _, _, apks, digests = supplied + if { + p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in apks + } != digests: + raise RuntimeError("supplied firewall package identity mismatch") + report["isolation"]["firewall_packages"] = digests + origin_dir, server_dir = root / "origin", root / "server" + origin_dir.mkdir() + server_dir.mkdir() + shutil.copyfile( + Path(__file__).with_name("container_udp_origin.py"), + origin_dir / "origin.py", + ) + shutil.copy2(binary, server_dir / "hysteria") + (server_dir / "apks").mkdir() + for path in apks: + shutil.copy2(path, server_dir / "apks" / path.name) + cert, key, pin = certificates(server_dir) + origin = lab.start( + stack, + origin_dir, + "hysteria2-h-origin", + [ + "env", + "VCORE_ISOLATED_ORIGIN=1", + "python", + "-B", + "/data/fixture/origin.py", + ], + ) + server = lab.start( + stack, + server_dir, + "hysteria2-h-peer", + [ + "env", + "HYSTERIA_FIREWALL_BACKEND=nftables", + "/data/fixture/hysteria", + "server", + "--disable-update-check", + "--log-format", + "json", + "-c", + "/data/fixture/config.json", + ], + net_admin=True, + ) + guest( + server, + "apk", + "add", + "--no-network", + *[f"/data/fixture/apks/{path.name}" for path in apks], + timeout=45, + ) + report["peer"]["version"] = guest( + server, "/data/fixture/hysteria", "version" + ).strip() + if ( + guest(server, "sha256sum", "/data/fixture/hysteria").split()[0] + != report["peer"]["binary_sha256"] + ): + raise RuntimeError("native binary identity mismatch") + report["isolation"]["nftables"] = guest(server, "nft", "--version").strip() + (server_dir / "hosts").write_text( + "127.0.0.1 localhost\n::1 localhost\n" + f"{origin.ipv4} vcore-fixture.test\n" + ) + guest(server, "cp", "/data/fixture/hosts", "/etc/hosts") + # Pre-DNAT observations: native Hysteria owns its separate redirect table. + rules = ( + "table inet vcore_hysteria2 { chain observe { " + "type filter hook prerouting priority -150; policy accept; " + ) + for port in range(23010, 23013): + rules += f'udp dport {port} counter comment "p{port}"; ' + rules += "}\n}\n" + (server_dir / "observe.nft").write_text(rules) + guest(server, "nft", "-f", "/data/fixture/observe.nft") + password = "synthetic-hysteria2-fixture" + config = dict( + # Native Hysteria deliberately treats [::] as IPv6-only. + listen=f"[{server.ipv6}]:23010-23012" + if ipv6 + else f"{server.ipv4}:23010-23012", + tls=dict( + cert=f"/data/fixture/{cert.name}", key=f"/data/fixture/{key.name}" + ), + auth=dict(type="password", password=password), + trafficStats=dict(listen="0.0.0.0:24003"), + ) + node = dict( + name="peer", + type="hysteria2", + server=server.ipv6 if ipv6 else server.ipv4, + ports="23010-23012", + **{"hop-interval": "5-7" if random_interval else 5}, + password=password, + udp=True, + sni="localhost", + fingerprint=pin, + ) + if obfs: + config["obfs"] = dict( + type="salamander", + salamander=dict(password="independent-obfs-fixture"), + ) + node.update( + obfs="salamander", **{"obfs-password": "independent-obfs-fixture"} + ) + if test == "native_udp_disabled": + config["disableUDP"] = True + (server_dir / "config.json").write_text(json.dumps(config)) + fixture = root / "input.json" + fixture.write_text( + json.dumps( + dict( + isolation="containers", + origin_control=f"{origin.ipv4}:24000", + origin_ipv4=origin.ipv4, + origin_ipv6=origin.ipv6, + node=node, + ) + ) + ) + origin.release() + server.release() + origin.wait_tcp(24000) + server.wait_tcp(24003) + result = run_command( + [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "hysteria2_native", + test, + "--", + "--ignored", + "--exact", + "--nocapture", + ], + cwd=CORE_DIR, + timeout=180, + limit=4 * 1024 * 1024, + env=dict( + os.environ, + VCORE_VLESS_INPUT=str(fixture), + VCORE_CASE_EVENTS=str(output / "hysteria2-events.jsonl"), + VCORE_HOP_OBSERVATIONS=str(output / "hopping.json"), + ), + ) + (output / "test.log").write_text( + redact(result.stdout.decode(errors="replace")) + ) + raw_log = command("logs", server.name) + connected = sum( + '"msg":"client connected"' in line for line in raw_log.splitlines() + ) + report["auth_connections"] = connected + (output / "server.log").write_text( + redact(raw_log.replace(password, "")) + ) + rules = json.loads( + guest(server, "nft", "-j", "list", "table", "inet", "vcore_hysteria2") + ) + counts = {} + for entry in rules["nftables"]: + if "rule" in entry: + rule = entry["rule"] + counts[rule["comment"]] = next( + item["counter"]["packets"] + for item in rule["expr"] + if "counter" in item + ) + report["entry_port_packets"] = counts + passed = ( + result.returncode == 0 + and result.cleanup + and (output / "hysteria2-events.jsonl").exists() + and events_pass(read_events(output / "hysteria2-events.jsonl"), test) + and connected == 1 + and sum(value > 0 for value in counts.values()) + >= (2 if test == "native_hopping" else 1) + ) + report["cases"].append( + dict( + case_id=test, + exit_code=result.returncode, + seconds=result.seconds, + command_cleanup=result.cleanup, + command=[ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "hysteria2_native", + test, + "--", + "--ignored", + "--exact", + "--nocapture", + ], + status="PASS" if passed else "FAIL", + ) + ) + report["status"] = "PASS" if passed else "FAIL" + except (OSError, RuntimeError, ValueError) as error: + report.update( + status="FAIL", + reason=str(error) + if isinstance(error, RuntimeError) + else type(error).__name__, + ) + finally: + report["cleanup"] = all( + peer["joined"] for peer in report["isolation"].get("peers", []) + ) + report["source_unchanged"] = ( + source_identity()["source_tree_sha256"] + == report["source"]["source_tree_sha256"] + ) + if not report["source_unchanged"] or not report["cleanup"]: + report["status"] = "FAIL" + (output / "report.json").write_text(json.dumps(report, indent=2) + "\n") + return report + + +if __name__ == "__main__": + import sys + + result = run( + Path(sys.argv[1]), + ipv6="--ipv6" in sys.argv, + obfs="--salamander" in sys.argv, + random_interval="--random" in sys.argv, + test=( + sys.argv[sys.argv.index("--test") + 1] + if "--test" in sys.argv + else "native_udp_disabled" + if "--udp-disabled" in sys.argv + else "native_hopping" + ), + ) + print( + json.dumps( + { + key: result.get(key) + for key in ( + "status", + "reason", + "cleanup", + "cases", + "auth_connections", + "entry_port_packets", + ) + } + ) + ) + raise SystemExit(0 if result["status"] == "PASS" and result["cleanup"] else 1) diff --git a/scripts/src/vcore_scripts/protocol_inputs.py b/scripts/src/vcore_scripts/protocol_inputs.py new file mode 100644 index 0000000..a313f05 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_inputs.py @@ -0,0 +1,133 @@ +"""Reproducible, nonsensitive input identity for one local protocol run.""" + +from __future__ import annotations + +import hashlib +import platform +import re +import sys +import tomllib +from datetime import UTC, datetime +from pathlib import Path + +from .builds import CORE_DIR +from .protocol_peers import run_command + +CODE_PATHS = [ + "Cargo.toml", + "Cargo.lock", + "src", + "crates", + "tests", + "scripts", + "include", +] + + +def sha256(path: Path) -> str: + with path.open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def command_output(command: list[str]) -> bytes: + result = run_command(command, cwd=CORE_DIR, timeout=20, limit=8 * 1024 * 1024) + if result.returncode != 0: + raise RuntimeError("cannot record protocol input identity") + return result.stdout + + +def source_identity() -> dict: + paths = ( + command_output( + ["git", "ls-files", "-co", "--exclude-standard", "-z", "--", *CODE_PATHS] + ) + .decode() + .split("\0") + ) + digest = hashlib.sha256() + for name in sorted(set(paths) - {""}): + path = CORE_DIR / name + if not path.exists(): + continue + if path.is_symlink() or not path.is_file(): + raise ValueError("non-file source input") + digest.update(name.encode() + b"\0" + bytes.fromhex(sha256(path))) + patch = command_output(["git", "diff", "--binary", "HEAD", "--", *CODE_PATHS]) + return { + "parent_commit": command_output(["git", "rev-parse", "HEAD"]).decode().strip(), + "source_tree_sha256": digest.hexdigest(), + "dirty_patch_sha256": hashlib.sha256(patch).hexdigest(), + "lock_sha256": sha256(CORE_DIR / "Cargo.lock"), + } + + +def same_source(before: dict, after: dict) -> bool: + """Compare actual inputs, not a patch changed merely by staging new files.""" + return all( + before[key] == after[key] + for key in ("parent_commit", "source_tree_sha256", "lock_sha256") + ) + + +def run_identity(stage: str, selected: list[dict], preflight: bool) -> dict: + record = source_identity() + lock = tomllib.loads((CORE_DIR / "Cargo.lock").read_text()) + tls = [ + {key: package[key] for key in ["name", "version", "source"]} + for package in lock["package"] + if package["name"] in {"rustls", "tokio-rustls", "ring"} + ] + text = (CORE_DIR / "src/lib.rs").read_text() + record.update( + schema_version=1, + stage=stage, + mode="preflight" if preflight else "execute", + started_utc=datetime.now(UTC).isoformat(), + os=platform.system(), + os_version=platform.release(), + architecture=platform.machine(), + python=platform.python_version(), + rustls=tls, + invoke_api_version=int(re.search(r"INVOKE_API_VERSION: u32 = (\d+)", text)[1]), + config_version=int(re.search(r"CONFIG_VERSION: u8 = (\d+)", text)[1]), + features=["all-features", "independent-feature-smokes"], + selected_cases=[case["case_id"] for case in selected], + commands=[], + cleanup=False, + source_unchanged=False, + artifacts=[], + suite_timeout_seconds=sum(case["timeout_seconds"] for case in selected) + 1800, + ) + record["rustc"] = command_output(["rustc", "-Vv"]).decode().strip() + record["cargo"] = command_output(["cargo", "--version"]).decode().strip() + if sys.platform == "darwin": + record["xcode"] = command_output(["xcodebuild", "-version"]).decode().strip() + record["sdk"] = command_output(["xcrun", "--show-sdk-version"]).decode().strip() + return record + + +def redact(text: str) -> str: + text = text.replace(str(CORE_DIR), "") + text = re.sub( + r"(?i)(authorization\s*[:=]\s*)(?:Bearer|Basic)\s+[^\s,}]+", + r"\1", + text, + ) + text = re.sub( + r"(?i)-----BEGIN .*?PRIVATE KEY-----.*?-----END .*?PRIVATE KEY-----", + "", + text, + flags=re.S, + ) + text = re.sub( + r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", "", text + ) + text = re.sub( + r"(?i)((?:password|authorization|private[-_]key|token)\s*[:=]\s*)([^\s,}]+)", + r"\1", + text, + ) + text = re.sub( + r"/(?:Users|home|private/var|var/folders)/[^\s\"']+", "", text + ) + return text diff --git a/scripts/src/vcore_scripts/protocol_integration_acceptance.py b/scripts/src/vcore_scripts/protocol_integration_acceptance.py new file mode 100644 index 0000000..7f48640 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_integration_acceptance.py @@ -0,0 +1,357 @@ +"""INTEGRATION execution and independent reconstruction of raw integration evidence.""" + +from __future__ import annotations + +import json + +from .protocol_evidence import read_events, read_json +from .protocol_hysteria2_catalog import assertions_pass +from .protocol_integration_catalog import LOCAL, PAIRS, definitions +from .protocol_integration_checks import envelope, runtime_pass +from .protocol_integration_local import commands, passed +from .protocol_integration_shared import hops_pass, shared_pass +from .protocol_security_acceptance import result + + +def pair_pass(identifier, record, directory): + from .protocol_integration_native import rust_command + + first, last = PAIRS[identifier] + targets = 2 if last == "trojan" else 3 + events = read_events(directory / (identifier + "-events.jsonl")) + observation = read_json(directory / (identifier + "-observations.json")) + expected = [ + dict( + outer_ipv6=v6, + nested_select=group, + tcp_target_kinds=3, + tcp_bytes_each_direction=31457280, + udp_target_kinds=targets, + udp_packets=targets * 400, + udp_sizes=[1, 64, 512, 1200], + server_first=True, + client_first=True, + ports_rebound=True, + ) + for v6 in (False, True) + for group in (False, True) + ] + return ( + record.get("case_id") == identifier + and record.get("command") == rust_command() + and type(record.get("exit_code")) is int + and record["exit_code"] == 0 + and record.get("command_cleanup") is True + and observation + == dict( + first=first, + last=last, + paths=expected, + domain_native=[ + dict( + outer_ipv6=v6, + nested_select=group, + peer="XR", + udp_packets=400, + target="domain", + ) + for v6 in (False, True) + for group in (False, True) + ] + if last == "trojan" + else [], + budgets=[ + dict( + transmit=tx, + receive=rx, + roundtrip_size=min(tx, rx), + packets=100, + oversize_rejected_before_origin=True, + ) + for tx, rx in ((128, 128), (512, 256)) + ], + routed_udp=dict( + upstream_business_udp_disabled=True, + carrier_tcp=True, + carrier_udp=True, + leaf_business_udp_rejected=True, + ), + carrier_capability=dict( + upstream_datagrams="rejected", + tcp_allowed=last != "hysteria2", + udp_allowed=last not in {"ss", "socks5", "hysteria2"}, + no_bypass=True, + ), + ) + and assertions_pass( + events, + { + ("INTEGRATION-PAIR", "ordered_pair"): 1, + ("INTEGRATION-BASE", "tcp_10mib_both_directions"): 12, + ("INTEGRATION-PAIR", "directional_budget"): 1, + ("INTEGRATION-PAIR", "routed_udp_permission"): 1, + ("INTEGRATION-PAIR", "carrier_capability"): 1, + **( + {("INTEGRATION-PAIR", "native_domain_terminal"): 1} + if last == "trojan" + else {} + ), + }, + ) + ) + + +def execute(cases, run, output, results): + from .protocol_containers import frozen_image + from .protocol_harness import _command + from .protocol_hysteria2_acceptance import prepare + from .protocol_integration_native import run as run_pairs + from .protocol_integration_shared import hops_run, shared_run + from .protocol_integration_suite import CONSUMERS + from .protocol_integration_suite import run as run_suite + + by_id = {c["case_id"]: c for c in cases} + supplied, peers = {}, {} + source = { + k: run[k] + for k in ( + "parent_commit", + "source_tree_sha256", + "dirty_patch_sha256", + "lock_sha256", + ) + } + + def save(identifier, good, cleanup=True): + results[identifier] = result(by_id[identifier], good, cleanup) + print(identifier + ": " + results[identifier]["status"], flush=True) + if not good: + raise RuntimeError("INTEGRATION gate failed: " + identifier) + + try: + for identifier in sorted(by_id.keys() & LOCAL): + records, events = [], [] + for i, argv in enumerate(commands(identifier)): + name = f"{identifier}-{i}" + path = output / (name + "-events.jsonl") + _command( + run, + output, + name, + [v.replace("{output}", str(output)) for v in argv], + 3600, + events=path, + ) + records.append(run["commands"][-1]) + if path.exists(): + events += read_events(path) + if records[-1]["exit_code"] != 0 or not records[-1]["cleanup"]: + save(identifier, False, records[-1]["cleanup"]) + script = ( + read_json(output / "script-tests.json") + if identifier == "INTEGRATION-SCRIPTS" + else None + ) + save(identifier, passed(identifier, records, events, script)) + if set(by_id) <= LOCAL: + return + with frozen_image(output / "container-image-pull.log") as snapshot: + run["container_image"] = snapshot + kinds = {"M"} + if "INTEGRATION-SHARED" in by_id: + kinds |= {"XR", "V2"} + if any(PAIRS[i][1] == "trojan" for i in by_id.keys() & PAIRS.keys()): + kinds.add("XR") + if "INTEGRATION-HY2-HOP" in by_id: + kinds.add("H") + supplied = prepare(output, kinds) + peers = {k: v[1] for k, v in supplied.items()} + digest = snapshot["digest"] + # Each group is rerun from this source/image/peer scope. Historical + # reports are never imported into results. + selected = sorted(by_id.keys() & PAIRS.keys()) + if selected: + directory = output / "pairs" + report = run_pairs( + directory, + selected, + supplied=supplied["M"], + domain_peer=supplied.get("XR"), + ) + if [r["case_id"] for r in report["cases"]] != selected: + raise RuntimeError("INTEGRATION ordered-pair selection differs") + for record in report["cases"]: + identifier = record["case_id"] + save( + identifier, + envelope(report, peers, source, digest) + and pair_pass(identifier, record, directory), + report["cleanup"], + ) + selected = sorted( + by_id.keys() & CONSUMERS.keys(), + key=lambda k: (k == "INTEGRATION-SOAK", k), + ) + if selected: + directory = output / "runtime" + report = run_suite(directory, selected, supplied=supplied["M"]) + peers.update(report["peers"]) + if [r["case_id"] for r in report["cases"]] != selected: + raise RuntimeError("INTEGRATION runtime selection differs") + for record in report["cases"]: + identifier = record["case_id"] + save( + identifier, + envelope(report, peers, source, digest) + and runtime_pass(identifier, record, directory), + report["cleanup"], + ) + if "INTEGRATION-HY2-HOP" in by_id: + hops_run(output / "hop", supplied["H"]) + save( + "INTEGRATION-HY2-HOP", + hops_pass(output / "hop", peers, source, digest), + ) + if "INTEGRATION-SHARED" in by_id: + shared_run( + output / "shared", + {k: v for k, v in supplied.items() if k in {"M", "XR", "V2"}}, + ) + save( + "INTEGRATION-SHARED", + shared_pass(output / "shared", peers, source, digest), + ) + finally: + (output / "peers.json").write_text(json.dumps(peers, indent=2) + "\n") + + +def check(run_dir, cases, run, results, peers, paths): + from .protocol_containers import IMAGE + from .protocol_evidence import HEX + from .protocol_inputs import redact + from .protocol_integration_suite import CONSUMERS + + observed_paths = { + str(p.relative_to(run_dir)) + for p in run_dir.rglob("*") + if p.is_file() + and p.suffix in {".json", ".jsonl", ".log", ".md"} + and "binaries" not in p.parts + and p.name != "run.json" + } + if set(paths) != observed_paths: + raise ValueError("unhashed or missing INTEGRATION evidence artifact") + snapshot = run.get("container_image", {}) + digest = snapshot.get("digest", "") + if not ( + digest.startswith("sha256:") + and HEX.fullmatch(digest[7:]) + and snapshot + == dict( + tag=IMAGE, + digest=digest, + command=["container", "image", "pull", IMAGE], + exit_code=0, + cleanup=True, + log="container-image-pull.log", + ) + and snapshot["log"] in paths + and cases == definitions() + and set(peers) == {"M", "XR", "V2", "H", "SS"} + and {"script-tests.json", "package-preparation.json"} <= set(paths) + ): + raise ValueError("incomplete INTEGRATION scope, image or peer identities") + source = { + k: run[k] + for k in ( + "parent_commit", + "source_tree_sha256", + "dirty_patch_sha256", + "lock_sha256", + ) + } + records = run.get("commands", []) + names = { + f"{identifier}-{i}" + for identifier in LOCAL + for i, _ in enumerate(commands(identifier)) + } + if len(records) != len(names) or {r.get("name") for r in records} != names: + raise ValueError("missing, duplicate or extra INTEGRATION local command") + pair_report = read_json(run_dir / "pairs/integration-native.json") + runtime_report = read_json(run_dir / "runtime/integration-suite.json") + expected_runtime = sorted( + {c["case_id"] for c in cases} & CONSUMERS.keys(), + key=lambda k: (k == "INTEGRATION-SOAK", k), + ) + if ( + [r.get("case_id") for r in pair_report.get("cases", [])] != sorted(PAIRS) + or [r.get("case_id") for r in runtime_report.get("cases", [])] + != expected_runtime + or set(pair_report.get("peers", {})) != {"M", "XR"} + or set(runtime_report.get("peers", {})) != {"M", "SS"} + or runtime_report.get("ssserver_policy") + != dict( + outbound_udp_allow_fragmentation=True, + inbound_udp_allow_fragmentation=True, + guest_mtu=1500, + ) + ): + raise ValueError( + "missing INTEGRATION ordered pairs, runtime gates or native SS policy" + ) + actual = {r["case_id"]: r for r in results} + for case in cases: + identifier = case["case_id"] + if identifier in LOCAL: + selected, events = [], [] + for i, argv in enumerate(commands(identifier)): + record = next(r for r in records if r["name"] == f"{identifier}-{i}") + if ( + record.get("command") + != [redact(v.replace("{output}", str(run_dir))) for v in argv] + or record.get("log") not in paths + ): + raise ValueError("INTEGRATION command or log changed") + selected.append(record) + path = run_dir / f"{identifier}-{i}-events.jsonl" + if path.exists(): + events += read_events(path) + good = passed( + identifier, + selected, + events, + read_json(run_dir / "script-tests.json") + if identifier == "INTEGRATION-SCRIPTS" + else None, + ) + elif identifier in PAIRS or identifier in CONSUMERS: + pair = identifier in PAIRS + report = pair_report if pair else runtime_report + record = next(r for r in report["cases"] if r["case_id"] == identifier) + directory = run_dir / ("pairs" if pair else "runtime") + good = envelope(report, peers, source, digest) and ( + pair_pass if pair else runtime_pass + )(identifier, record, directory) + elif identifier == "INTEGRATION-HY2-HOP": + good = hops_pass(run_dir / "hop", peers, source, digest) + else: + good = shared_pass(run_dir / "shared", peers, source, digest) + if not good or result(case, good, True) != actual[identifier]: + raise ValueError("INTEGRATION raw evidence does not pass: " + identifier) + print( + f"INTEGRATION: PASS ({len(cases)} required gates; 49 pairs, " + "100 lifetimes, 100 rebuilds, 1800-second soak)" + ) + + +def preflight(output): + from .protocol_containers import frozen_image + from .protocol_hysteria2_acceptance import prepare + + # Downloads and container package setup only; never reports behavior PASS. + with frozen_image(output / "container-image-pull.log"): + supplied = prepare(output, {"M", "XR", "V2", "H"}) + (output / "peers.json").write_text( + json.dumps({k: v[1] for k, v in supplied.items()}, indent=2) + "\n" + ) diff --git a/scripts/src/vcore_scripts/protocol_integration_catalog.py b/scripts/src/vcore_scripts/protocol_integration_catalog.py new file mode 100644 index 0000000..e62b336 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_integration_catalog.py @@ -0,0 +1,78 @@ +"""Cross-protocol integration requirements and descriptive coverage categories.""" + +from __future__ import annotations + +PROTOCOLS = ("socks5", "anytls", "ss", "trojan", "vmess", "vless", "hysteria2") +PAIRS = { + f"INTEGRATION-PAIR-{first.upper()}-{last.upper()}": (first, last) + for first in PROTOCOLS + for last in PROTOCOLS +} +GATES = { + "INTEGRATION-ENTRYPOINTS": "routing", + "INTEGRATION-GRAPH": "routing", + "INTEGRATION-DNS-MEASURE": "routing", + "INTEGRATION-SS-ALGORITHMS": "routing", + "INTEGRATION-SS-EIH": "routing", + "INTEGRATION-LIFECYCLE": "lifecycle", + "INTEGRATION-REBUILD": "lifecycle", + "INTEGRATION-FAILURES": "lifecycle", + "INTEGRATION-SOAK": "pressure", + "INTEGRATION-HY2-HOP": "pressure", + "INTEGRATION-DEBUG": "regression", + "INTEGRATION-RELEASE": "regression", + "INTEGRATION-QUALITY": "regression", + "INTEGRATION-FEATURES": "regression", + "INTEGRATION-SCRIPTS": "regression", + "INTEGRATION-SHARED": "regression", +} +LOCAL = { + "INTEGRATION-DEBUG", + "INTEGRATION-RELEASE", + "INTEGRATION-QUALITY", + "INTEGRATION-FEATURES", + "INTEGRATION-SCRIPTS", +} + + +def definitions(): + result = [] + for identifier in sorted(PAIRS.keys() | GATES.keys()): + pair = PAIRS.get(identifier) + local = identifier in LOCAL + result.append( + dict( + case_id=identifier, + stage="INTEGRATION", + substage=f"INTEGRATION.{GATES.get(identifier, 'routing')}", + required=True, + row_ids=["C06"] if pair else [], + protocol="integration", + network="seven-protocol-graph", + security="per-frozen-consumer", + udp_codec="per-protocol", + field_values={"first": pair[0], "last": pair[1]} if pair else {}, + outer_family="IPv4/IPv6", + inner_family="IPv4/IPv6/domain", + target_type="memory-only/build" if local else "isolated origin", + upstream_graph="concrete/nested-select" if pair else "per-gate", + peer_kind="unit" + if local + else "H" + if identifier == "INTEGRATION-HY2-HOP" + else "SS" + if identifier == "INTEGRATION-SS-EIH" + else "M", + peer_config={"group": identifier}, + gap_source=None, + expected_observation=[identifier], + required_evidence=["command", "structured-assertions", "cleanup"] + + ([] if local else ["peer-identity", "isolated-origins"]), + prerequisites=["rust-toolchain"] + if local + else ["owned-host-only-container-network", "official-latest-peer"], + runner="integration-pair" if pair else "integration-gate", + timeout_seconds=3600 if not pair else 300, + ) + ) + return result diff --git a/scripts/src/vcore_scripts/protocol_integration_checks.py b/scripts/src/vcore_scripts/protocol_integration_checks.py new file mode 100644 index 0000000..f53e801 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_integration_checks.py @@ -0,0 +1,189 @@ +"""Raw runtime observations and native isolation, independent of run status.""" + +from __future__ import annotations + +from .protocol_evidence import HEX, idle_resources, read_events, read_json +from .protocol_hysteria2_catalog import assertions_pass +from .protocol_integration_catalog import PROTOCOLS +from .protocol_integration_metrics import lifetimes, rebuild, soak +from .protocol_integration_native import rust_command +from .protocol_integration_suite import CONSUMERS + + +def envelope(report, peers, source, digest, *, single=None): + isolation = report.get("isolation", {}) + identities = {single: report.get("peer", {})} if single else report.get("peers", {}) + owned = isolation.get("peers", []) + urls = { + "M": "MetaCubeX/mihomo", + "XR": "XTLS/Xray-core", + "V2": "v2fly/v2ray-core", + "SS": "shadowsocks/shadowsocks-rust", + "H": "HyNetworks/hysteria", + } + return bool( + report.get("status") == "PASS" + and report.get("cleanup") is True + and report.get("source_unchanged") is True + and report.get("source") == source + and isolation.get("network_mode") == "hostOnly" + and isolation.get("host_servers") is False + and isolation.get("guest_mtu") == 1500 + and isolation.get("image_digest") == digest + and digest.startswith("sha256:") + and HEX.fullmatch(digest[7:]) + and owned + and all(p.get("started") is True and p.get("joined") is True for p in owned) + and identities + and all( + kind in urls + and identity == peers.get(kind) + and identity.get("version") + and HEX.fullmatch(identity.get("binary_sha256", "")) + and (kind == "H" or HEX.fullmatch(identity.get("archive_sha256", ""))) + and identity.get("source_url", "").startswith( + f"https://github.com/{urls[kind]}/releases/" + ) + for kind, identity in identities.items() + ) + ) + + +def runtime_pass(identifier, record, directory): + if not ( + record.get("case_id") == identifier + and record.get("command") == rust_command(CONSUMERS[identifier]) + and type(record.get("exit_code")) is int + and record["exit_code"] == 0 + and record.get("command_cleanup") is True + ): + return False + value = read_json(directory / (identifier + "-observations.json")) + events = read_events(directory / (identifier + "-events.jsonl")) + protocols = list(PROTOCOLS) + expected = {(identifier, p): 1 for p in protocols} + if identifier == "INTEGRATION-ENTRYPOINTS": + good = value == dict( + entrypoints=[ + dict( + protocol=p, + http_forward=True, + http_connect=True, + tun_tcp=True, + tun_udp=True, + tun_dns=True, + ) + for p in protocols + ] + ) + elif identifier == "INTEGRATION-GRAPH": + good = value == dict( + protocols=protocols, + nested_select=True, + old_transport_snapshot=True, + cold_reject=True, + direct=True, + unselected_cycle=True, + no_fallback=True, + ) + elif identifier == "INTEGRATION-DNS-MEASURE": + good = value == dict( + protocols=protocols, + controlled_dns_via_proxy=True, + ip_rule_consumer=True, + node_measure=True, + group_measure_rejected=True, + ) + elif identifier == "INTEGRATION-FAILURES": + expected = { + (suite, p): 1 + for suite in ( + identifier, + "INTEGRATION-FAILURES-AUTH", + "INTEGRATION-FAILURES-SOURCE", + ) + for p in protocols + } + good = value == dict( + protect_rejected=protocols, + auth_rejected=protocols, + certificate_pin_rejected=[ + "anytls", + "trojan", + "vmess", + "vless", + "hysteria2", + ], + source_and_oversize_isolated=protocols, + cancel_preserves_sibling=protocols, + no_origin_bytes=True, + stop_idle=True, + ) + good &= all( + idle_resources(e.get("resources")) + for e in events + if e.get("status") == "PASS" + and e.get("suite") != "INTEGRATION-FAILURES-AUTH" + ) + elif identifier in {"INTEGRATION-SS-ALGORITHMS", "INTEGRATION-SS-EIH"}: + eih = identifier == "INTEGRATION-SS-EIH" + ciphers = ["2022-blake3-aes-128-gcm", "2022-blake3-aes-256-gcm"] + ( + [] if eih else ["2022-blake3-chacha20-poly1305"] + ) + expected = {(identifier, c): 1 for c in ciphers} | { + ("INTEGRATION-BASE", "tcp_10mib_both_directions"): 8 if eih else 9 + } + good = value == ( + dict( + native_terminal_eih=[ + dict( + cipher=c, + identity_depth=1, + outer_families=2, + tcp_targets=2, + tcp_bytes_each_direction=41943040, + udp_targets=2, + udp_packets=2000, + wrong_identity_rejected=True, + wrong_user_rejected=True, + ) + for c in ciphers + ], + arbitrary_relay_claim=False, + ) + if eih + else dict( + algorithms=[ + dict( + cipher=c, + tcp_targets=3, + tcp_bytes_each_direction=31457280, + udp_targets=3, + udp_packets=1500, + wrong_key_rejected=True, + ) + for c in ciphers + ] + ) + ) + elif identifier == "INTEGRATION-LIFECYCLE": + expected = {(identifier, "stop_and_remain_quiet"): 100} + good = lifetimes(value, events) + else: + name = ( + "forty_flows_same_session" + if identifier == "INTEGRATION-REBUILD" + else "mixed_forty_flows" + ) + expected = {(identifier, name): 1} + passes = [e for e in events if e.get("status") == "PASS"] + good = len(passes) == 1 and ( + rebuild(value, passes[0]) + if identifier == "INTEGRATION-REBUILD" + else soak( + value, + passes[0], + read_events(directory / (identifier + "-observations.json.jsonl")), + ) + ) + return bool(good and assertions_pass(events, expected)) diff --git a/scripts/src/vcore_scripts/protocol_integration_local.py b/scripts/src/vcore_scripts/protocol_integration_local.py new file mode 100644 index 0000000..3d91d8f --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_integration_local.py @@ -0,0 +1,98 @@ +"""Explicit memory-only/build commands; never run historical host-server suites.""" + +from __future__ import annotations + +import sys + +from .core_checks import commands as core_commands +from .protocol_hysteria2_acceptance import commands as hysteria2_commands + + +def commands(identifier): + if identifier in { + "INTEGRATION-DEBUG", + "INTEGRATION-RELEASE", + "INTEGRATION-FEATURES", + }: + return core_commands(identifier.removeprefix("INTEGRATION-").lower()) + if identifier == "INTEGRATION-QUALITY": + return hysteria2_commands("HYSTERIA2-QUALITY") + [ + ["vcore-scripts", "check", "protocol-coverage", "--catalog-only"], + ["vcore-scripts", "build", "apple"], + ["vcore-scripts", "build", "android"], + ] + if identifier == "INTEGRATION-SCRIPTS": + return [ + [ + sys.executable, + "-m", + "vcore_scripts.protocol_script_tests", + "{output}/script-tests.json", + ] + ] + return [] + + +def passed(identifier, records, events, script=None): + from .protocol_hysteria2_catalog import CFG, UNIT, assertions_pass + from .protocol_integration_catalog import PROTOCOLS + from .protocol_security_units import TESTS + + good = bool(records) and all( + type(r.get("exit_code")) is int + and r["exit_code"] == 0 + and r.get("cleanup") is True + for r in records + ) + if identifier in {"INTEGRATION-DEBUG", "INTEGRATION-RELEASE"}: + expected = { + ("SECURITY-CONFIG-TLS", name): 1 + for names in TESTS.values() + for name in names + } + expected.update({("HYSTERIA2-CFG", name): 1 for name in CFG}) + expected.update({("HYSTERIA2-UNIT", name): 1 for name in UNIT}) + expected.update({("INTEGRATION-FEATURE", name): 1 for name in PROTOCOLS}) + expected[("INTEGRATION-ADAPTER", "growing_caller_buffer")] = 1 + expected[("INTEGRATION-ADAPTER", "server_first_buffered_upstream")] = 1 + expected[("INTEGRATION-ADAPTER", "hysteria2_fragment_id_reuse")] = 1 + selected = [ + e + for e in events + if e.get("suite") + in { + "SECURITY-CONFIG-TLS", + "HYSTERIA2-CFG", + "HYSTERIA2-UNIT", + "INTEGRATION-FEATURE", + "INTEGRATION-ADAPTER", + } + ] + good &= assertions_pass(selected, expected) and all( + e.get("status") in {"BEGIN", "PASS"} for e in events + ) + if identifier == "INTEGRATION-FEATURES": + good &= assertions_pass( + [e for e in events if e.get("suite") == "INTEGRATION-FEATURE"], + {("INTEGRATION-FEATURE", p): 7 for p in PROTOCOLS}, + ) + if identifier == "INTEGRATION-SCRIPTS": + rows = (script or {}).get("cases", []) + names = [r.get("test") for r in rows] + required = { + "test_protocol_integration.IntegrationAcceptanceTest." + name + for name in ( + "test_integration_consumer_results_keep_the_manifest_scope", + "test_pair_evidence_requires_each_real_path_and_original_assertions", + "test_list_contains_all_ordered_pairs_and_remaining_gates", + "test_pressure_evidence_is_recomputed_not_inferred", + "test_native_source_identity_and_cleanup_are_required", + ) + } + good &= ( + bool(rows) + and len(names) == len(set(names)) == (script or {}).get("tests_run") + and required <= set(names) + and all(r.get("status") == "PASS" for r in rows) + ) + return bool(good) diff --git a/scripts/src/vcore_scripts/protocol_integration_metrics.py b/scripts/src/vcore_scripts/protocol_integration_metrics.py new file mode 100644 index 0000000..c0b5e26 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_integration_metrics.py @@ -0,0 +1,220 @@ +"""Recompute lifecycle/pressure gates from bounded, persisted observations.""" + +from __future__ import annotations + +from math import isfinite +from statistics import median + +from .protocol_evidence import RESOURCE_KINDS, idle_resources + +NEW = ["trojan", "vmess", "vless", "hysteria2"] +QUEUES = { + "socks_udp": 16, + "hysteria2_udp": 32, + "quic_incoming": 32, + "quic_outgoing": 32, +} + + +def integer(value, minimum=0): + return type(value) is int and value >= minimum + + +def resources(value, *, active=False): + rows = value.get("counts", []) + if len(rows) != 8 or {r.get("kind") for r in rows} != RESOURCE_KINDS: + return False + if not all( + integer(r.get("current")) + and integer(r.get("peak")) + and r["current"] <= r["peak"] + for r in rows + ): + return False + counts = {r["kind"]: r["current"] for r in rows} + return not active or all( + counts[k] > 0 for k in ("task", "socket", "session", "association") + ) + + +def sample(value, *, active=True): + queues = value.get("queues", []) + return ( + integer(value.get("heap_in_use"), 1) + and integer(value.get("rss_kib"), 1) + and integer(value.get("fd"), 1) + and resources(value.get("resources", {}), active=active) + and len(queues) == 4 + and {q.get("kind"): q.get("capacity") for q in queues} == QUEUES + and all( + integer(q.get("peak"), 1) and q["peak"] <= q["capacity"] for q in queues + ) + ) + + +def lifetimes(value, events): + cycles = value.get("cycles", []) + passes = [e for e in events if e.get("status") == "PASS"] + if value.get("count") != 100 or len(cycles) != 100 or len(passes) != 100: + return False + for index, (row, event) in enumerate(zip(cycles, passes, strict=True)): + stop = row.get("after_stop", {}) + checkpoints = event.get("checkpoints", []) + mode = index % 5 + retained = {0: 0, 1: 10, 2: 1, 3: 0, 4: 8}[mode] + if not ( + row.get("cycle") == index + and row.get("protocol") == NEW[(index // 5) % 4] + and row.get("mode") == mode + and integer(row.get("baseline_fd"), 1) + and row.get("retained_fixture_fd") == retained + and integer(row.get("after_stop_fd"), 1) + and row["after_stop_fd"] <= row["baseline_fd"] + retained + and integer(row.get("final_fd"), 1) + and row["final_fd"] <= row["baseline_fd"] + and integer(row.get("stop_ms")) + and row["stop_ms"] < 5000 + and isfinite(row.get("quiet_seconds", 0)) + and row.get("quiet_seconds", 0) >= 5 + and row.get("ports_rebound") is True + and idle_resources(stop) + and row.get("quiet") == stop == event.get("resources") + and len(checkpoints) == 4 + and [p.get("phase") for p in checkpoints] + == ["baseline", "active", "after-stop", "quiet"] + and idle_resources(checkpoints[0].get("resources")) + and checkpoints[1].get("resources") == row.get("active") + and resources(row.get("active", {})) + and checkpoints[2].get("resources") + == stop + == checkpoints[3].get("resources") + ): + return False + if mode in (1, 4) and not resources(row["active"], active=True): + return False + return True + + +def stopped(value, event): + cold, baseline = value.get("cold_fd"), value.get("baseline_fd") + row = value.get("stopped", {}) + after, quiet = row.get("after_stop", {}), row.get("quiet", {}) + return ( + integer(cold, 1) + and integer(baseline, 1) + and 0 <= baseline - cold <= 2 + and integer(row.get("stop_ms")) + and row["stop_ms"] < 5000 + and isfinite(row.get("quiet_seconds", 0)) + and row.get("quiet_seconds", 0) >= 5 + and row.get("ports_rebound") is True + and sample(after, active=False) + and sample(quiet, active=False) + and idle_resources(after.get("resources")) + and quiet.get("resources") == after.get("resources") == event.get("resources") + and after["fd"] <= baseline + and quiet["fd"] <= baseline + ) + + +def setup(values): + return ( + isinstance(values, list) + and len(values) == 40 + and all(integer(v, 1) and v < 10_000_000 for v in values) + ) + + +def rebuild(value, event): + rows = value.get("cycles", []) + return ( + value.get("count") == 100 + and value.get("same_running_session") is True + and value.get("protocols") == NEW + and len(rows) == 100 + and stopped(value, event) + and all( + row.get("generation") == i + and row.get("tcp") == row.get("udp") == 20 + and row.get("per_protocol_tcp") == row.get("per_protocol_udp") == 5 + and sample(row.get("active", {})) + and sample(row.get("after_clients", {}), active=False) + and setup(row.get("setup_us")) + and row.get("fault") == "owned-peer-connections-closed" + and row.get("new_clients") is True + for i, row in enumerate(rows) + ) + ) + + +def soak(value, event, curve): + points, faults, switches = ( + value.get("samples", []), + value.get("faults", []), + value.get("switches", []), + ) + if not ( + value.get("requested_seconds") == 1800 + and 1800 <= value.get("seconds", 0) < 1860 + and value.get("protocols") == NEW + and value.get("tcp") == value.get("udp") == 20 + and value.get("per_protocol_tcp") == value.get("per_protocol_udp") == 5 + and integer(value.get("waves"), 1) + and value.get("normal_verified_bytes") == value["waves"] * 20 * 2 * 2 * 257 + and all( + value.get(k) == 0 + for k in ( + "normal_corruption", + "normal_misdirection", + "normal_unexpected_loss", + ) + ) + and len(points) >= 25 + and points == curve + and 29 <= len(faults) <= 30 + and len(switches) >= 1799 + and all(i <= t < i + 5 for i, t in enumerate(switches, 1)) + and all( + 60 * i <= row.get("start", 0) < 60 * i + 5 + and row["start"] <= row.get("end", 0) < row["start"] + 10 + and row.get("old_tcp_closed") + == row.get("new_tcp") + == row.get("new_udp") + == 20 + and row.get("replay") is False + for i, row in enumerate(faults, 1) + ) + and all( + sample(p) and 300 + 60 * i <= p.get("seconds", 0) < 310 + 60 * i + for i, p in enumerate(points) + ) + and stopped(value, event) + and sample(value.get("active_end", {})) + and setup(value.get("first_setup_us")) + and setup(value.get("last_setup_us")) + ): + return False + first_setup, last_setup = ( + int(median(value[k])) for k in ("first_setup_us", "last_setup_us") + ) + early, late = points[:10], points[-10:] + first, last = ( + int(median(p["heap_in_use"] for p in rows)) for rows in (early, late) + ) + allowed = max(1024 * 1024, first // 20) + + def counts(rows, kind): + return max( + next(r["current"] for r in p["resources"]["counts"] if r["kind"] == kind) + for p in rows + ) + + return ( + first_setup == value.get("first_setup_median_us") + and last_setup == value.get("last_setup_median_us") <= 2 * first_setup + and value.get("heap") + == dict(early_median=first, late_median=last, allowed_growth=allowed) + and last <= first + allowed + and all(counts(late, kind) <= counts(early, kind) for kind in RESOURCE_KINDS) + and max(p["fd"] for p in late) <= max(p["fd"] for p in early) + ) diff --git a/scripts/src/vcore_scripts/protocol_integration_native.py b/scripts/src/vcore_scripts/protocol_integration_native.py new file mode 100644 index 0000000..e74f5d0 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_integration_native.py @@ -0,0 +1,305 @@ +"""Seven-protocol integration with owned official peers and container origins.""" + +from __future__ import annotations + +import base64 +import contextlib +import json +import os +import shutil +import sys +import tempfile +from pathlib import Path + +from .builds import CORE_DIR +from .mihomo_isolation import exclusive_run +from .mihomo_release import download_mihomo +from .native_release import download_native +from .protocol_containers import ContainerLab, command, frozen_image +from .protocol_fixtures import certificates +from .protocol_inputs import redact, sha256, source_identity +from .protocol_integration_catalog import PAIRS +from .protocol_peers import run_command + + +def peer_configuration(protocol, server, origin, directory, *, certificate=None): + cert, key, pin = certificate or certificates(directory) + listener = dict(name="integration", type=protocol, listen="::", port=23000) + node = dict(name="peer", type=protocol, server=server, port=23000, udp=True) + password = "synthetic-integration-fixture" + identity = "07070707-0707-0707-0707-070707070707" + if protocol == "socks5": + listener.update( + type="socks", udp=True, users=[dict(username="fixture", password=password)] + ) + node.update(username="fixture", password=password) + elif protocol == "ss": + password = base64.b64encode(bytes([7]) * 16).decode() + cipher = "2022-blake3-aes-128-gcm" + listener.update(type="shadowsocks", udp=True, cipher=cipher, password=password) + node.update(cipher=cipher, password=password) + elif protocol in {"trojan", "anytls", "hysteria2"}: + node.update(password=password, sni="localhost", fingerprint=pin) + listener["users"] = ( + [dict(username="fixture", password=password)] + if protocol == "trojan" + else {"fixture": password} + ) + else: + node.update(uuid=identity, tls=True, servername="localhost", fingerprint=pin) + listener["users"] = [dict(username="fixture", uuid=identity)] + if protocol == "vmess": + node.update(cipher="auto", alterId=0) + listener["users"][0]["alterId"] = 0 + node["packet-encoding"] = "xudp" + if protocol not in {"socks5", "ss"}: + listener.update( + certificate=f"/data/fixture/{cert.name}", + **{"private-key": f"/data/fixture/{key.name}"}, + ) + return node, { + "socks-port": 23999, + "allow-lan": True, + "bind-address": "*", + "ipv6": True, + "log-level": "silent", + "hosts": {"vcore-fixture.test": origin}, + "listeners": [listener], + "rules": ["MATCH,DIRECT"], + } + + +def rust_command(consumer="ordered_pair"): + return [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "vless_public", + f"integration::{consumer}", + "--", + "--exact", + "--ignored", + "--nocapture", + ] + + +def run( + output: Path, selected, *, supplied=None, consumer="ordered_pair", domain_peer=None +): + if ( + not selected + or len(selected) != len(set(selected)) + or not set(selected) <= PAIRS.keys() + ): + raise ValueError("invalid INTEGRATION pair selection") + output = output.resolve() + if not output.is_relative_to((CORE_DIR / "target/interop/runs").resolve()): + raise ValueError( + "INTEGRATION evidence must stay inside the owned run directory" + ) + output.mkdir(parents=True, exist_ok=False) + report = dict( + stage="INTEGRATION", + scope="ordered-pair-subset" if consumer == "ordered_pair" else "diagnostic", + source=source_identity(), + status="NOT RUN", + cases=[], + peers={}, + isolation={}, + ) + try: + if supplied is None: + identity = {} + binary = download_mihomo( + "linux-arm64", directory=output / "binaries", identity=identity + ) + else: + binary, identity = supplied + if sha256(binary) != identity["binary_sha256"]: + raise RuntimeError("INTEGRATION native peer identity mismatch") + report["peers"]["M"] = identity + if any(PAIRS[key][1] == "trojan" for key in selected): + if domain_peer is None: + artifact = download_native( + "XR", output / "binary-xray", "linux-arm64", defer_version=True + ) + domain_peer = (artifact.binary, artifact.identity) + if sha256(domain_peer[0]) != domain_peer[1]["binary_sha256"]: + raise RuntimeError("INTEGRATION domain terminal identity mismatch") + report["peers"]["XR"] = domain_peer[1] + lab = ContainerLab(report["isolation"], mtu=1500) + for index, identifier in enumerate(selected): + first_kind, last_kind = PAIRS[identifier] + print(f"INTEGRATION: {identifier}", flush=True) + with tempfile.TemporaryDirectory( + prefix="private-", dir=output + ) as temporary: + root = Path(temporary) + with contextlib.ExitStack() as stack: + origin_dir = root / "origin" + origin_dir.mkdir() + shutil.copyfile( + Path(__file__).with_name("container_udp_origin.py"), + origin_dir / "origin.py", + ) + origin = lab.start( + stack, + origin_dir, + f"{index}-origin", + [ + "env", + "VCORE_ISOLATED_ORIGIN=1", + "python", + "-B", + "/data/fixture/origin.py", + ], + ) + origin.release() + origin.wait_tcp(24000) + fixture = dict( + isolation="containers", + origin_control=f"{origin.ipv4}:24000", + origin_ipv4=origin.ipv4, + origin_ipv6=origin.ipv6, + data_dir=str(root / "core"), + ) + for role, protocol in (("first", first_kind), ("last", last_kind)): + directory = root / role + directory.mkdir() + shutil.copy2(binary, directory / "peer") + peer = lab.start( + stack, + directory, + f"{index}-{role}", + [ + "/data/fixture/peer", + "-d", + "/data", + "-f", + "/data/fixture/config.json", + ], + ) + identity["version"] = command( + "exec", peer.name, "/data/fixture/peer", "-v" + ).strip() + if ( + command( + "exec", peer.name, "sha256sum", "/data/fixture/peer" + ).split()[0] + != identity["binary_sha256"] + ): + raise RuntimeError( + "INTEGRATION container binary differs from download" + ) + node, config = peer_configuration( + protocol, peer.ipv4, origin.ipv4, directory + ) + (directory / "config.json").write_text(json.dumps(config)) + fixture[role], fixture[role + "_ipv6"] = node, peer.ipv6 + peer.release() + peer.wait_tcp(23999) + if last_kind == "trojan": + from .protocol_fixtures import trojan_peer_config as peer_config + + directory = root / "domain" + directory.mkdir() + shutil.copy2(domain_peer[0], directory / "peer") + peer = lab.start( + stack, + directory, + f"{index}-domain", + [ + "/data/fixture/peer", + "run", + "-c", + "/data/fixture/config.json", + ], + ) + domain_peer[1]["version"] = command( + "exec", peer.name, "/data/fixture/peer", "version" + ).strip() + if ( + command( + "exec", peer.name, "sha256sum", "/data/fixture/peer" + ).split()[0] + != domain_peer[1]["binary_sha256"] + ): + raise RuntimeError( + "INTEGRATION domain container binary identity mismatch" + ) + certificate = certificates(directory) + node, _ = peer_configuration( + "trojan", + peer.ipv4, + origin.ipv4, + directory, + certificate=certificate, + ) + cert, key, _ = certificate + config = peer_config( + "XR", + "tcp", + 23000, + node["password"], + f"/data/fixture/{cert.name}", + f"/data/fixture/{key.name}", + ) + config["inbounds"][0]["listen"] = "::" + config["dns"]["hosts"] = {"vcore-fixture.test": origin.ipv4} + (directory / "config.json").write_text(json.dumps(config)) + fixture["domain_last"], fixture["domain_last_ipv6"] = ( + node, + peer.ipv6, + ) + peer.release() + peer.wait_tcp(23000) + path = root / "fixture.json" + path.write_text(json.dumps(fixture)) + events = output / (identifier + "-events.jsonl") + observations = output / (identifier + "-observations.json") + env = dict( + os.environ, + VCORE_VLESS_INPUT=str(path), + VCORE_PROTOCOL_STAGE="INTEGRATION", + VCORE_CASE_EVENTS=str(events), + VCORE_INTEGRATION_OBSERVATIONS=str(observations), + ) + argv = rust_command(consumer) + result = run_command(argv, cwd=CORE_DIR, env=env, timeout=300) + (output / (identifier + ".log")).write_text( + redact(result.stdout.decode(errors="replace")) + ) + report["cases"].append( + dict( + case_id=identifier, + command=argv, + exit_code=result.returncode, + command_cleanup=result.cleanup, + seconds=result.seconds, + ) + ) + if result.returncode != 0 or not result.cleanup: + raise RuntimeError("INTEGRATION ordered-pair consumer failed") + report["status"] = "PASS" + except BaseException as error: + report.update(status="FAIL", failure_kind=type(error).__name__) + raise + finally: + report["source_unchanged"] = source_identity() == report["source"] + report["cleanup"] = all( + peer.get("joined") is True for peer in report["isolation"].get("peers", []) + ) + if not report["cleanup"] or not report["source_unchanged"]: + report["status"] = "FAIL" + (output / "integration-native.json").write_text( + json.dumps(report, indent=2) + "\n" + ) + return report + + +if __name__ == "__main__": + output = Path(sys.argv[1]).resolve() + with exclusive_run(), frozen_image(None): + run(output, sys.argv[2:] or list(PAIRS)) diff --git a/scripts/src/vcore_scripts/protocol_integration_shared.py b/scripts/src/vcore_scripts/protocol_integration_shared.py new file mode 100644 index 0000000..03f7174 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_integration_shared.py @@ -0,0 +1,91 @@ +"""Frozen strongly-coupled TLS/transport and uninterrupted hopping regressions.""" + +from __future__ import annotations + +from .protocol_evidence import read_json +from .protocol_integration_checks import envelope +from .protocol_security_acceptance import CHECKERS, REPORTS +from .protocol_security_catalog import groups + +SHARED = ( + "SECURITY-SHARED-NONE", + "SECURITY-SHARED-CHROME", + "SECURITY-SHARED-XHTTP", + "SECURITY-HYBRID-RETAINED", + "SECURITY-JLS-RETAINED", + "SECURITY-ECH-CHROME", + "SECURITY-ECH-ENCRYPTION-NATIVE-0RTT-MIXED", + "SECURITY-JLS-ENCRYPTION-NATIVE-0RTT-MIXED", + "SECURITY-HYBRID-ENCRYPTION-NATIVE-0RTT-MIXED", +) +HOPS = ( + "HYSTERIA2-HOP-V4-PLAIN-FIXED", + "HYSTERIA2-HOP-V4-OBFS-RANDOM", + "HYSTERIA2-HOP-V6-PLAIN-RANDOM", + "HYSTERIA2-HOP-V6-OBFS-FIXED", +) + + +def shared_run(directory, supplied): + from .protocol_reality_hybrid import run as hybrid + from .protocol_vless_container import run as vless + from .protocol_xhttp_fields import run as xhttp + + catalog = groups() + for name in SHARED: + print("INTEGRATION shared: " + name, flush=True) + group = catalog[name] + options = {k: v for k, v in group.items() if k not in {"rows", "runner"}} + if group["runner"] == "xhttp": + options["selected"] = list(options["jobs"]) + {"vless": vless, "xhttp": xhttp, "hybrid": hybrid}[group["runner"]]( + directory / name, supplied=supplied, **options + ) + + +def shared_pass(directory, peers, source, digest): + catalog = groups() + for name in SHARED: + group = catalog[name] + root = directory / name + report = read_json(root / REPORTS[group["runner"]]) + if not envelope(report, peers, source, digest) or not CHECKERS[group["runner"]]( + group, report, root + ): + return False + return True + + +def hops_run(directory, supplied): + from .protocol_hysteria2_catalog import H + from .protocol_hysteria2_hop import run + + for name in HOPS: + print("INTEGRATION uninterrupted: " + name, flush=True) + run(directory / name, supplied=supplied, **H[name]) + + +def hops_pass(directory, peers, source, digest): + from .protocol_hysteria2_acceptance import native_result + from .protocol_hysteria2_catalog import definitions + + catalog = {c["case_id"]: c for c in definitions()} + prep = read_json(directory.parent / "package-preparation.json") + if not ( + prep.get("firewall_packages") + and prep.get("peers") + and all(p.get("joined") is True for p in prep["peers"]) + and prep.get("image_digest") == digest + ): + return False + for name in HOPS: + root = directory / name + report = read_json(root / "report.json") + if not ( + envelope(report, peers, source, digest, single="H") + and native_result(catalog[name], report, root)["status"] == "PASS" + and report["isolation"].get("firewall_packages") + == prep["firewall_packages"] + ): + return False + return True diff --git a/scripts/src/vcore_scripts/protocol_integration_suite.py b/scripts/src/vcore_scripts/protocol_integration_suite.py new file mode 100644 index 0000000..c7e99ea --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_integration_suite.py @@ -0,0 +1,351 @@ +"""INTEGRATION public-runtime gates against seven listeners in an owned native peer.""" + +from __future__ import annotations + +import base64 +import contextlib +import json +import os +import re +import shutil +import sys +import tempfile +from pathlib import Path + +from .builds import CORE_DIR +from .mihomo_isolation import exclusive_run +from .mihomo_release import download_mihomo +from .native_release import download_native +from .protocol_containers import ContainerLab, command, frozen_image +from .protocol_fixtures import certificates +from .protocol_inputs import redact, sha256, source_identity +from .protocol_integration_catalog import PROTOCOLS +from .protocol_integration_native import peer_configuration, rust_command +from .protocol_peers import run_command + +CONSUMERS = { + "INTEGRATION-ENTRYPOINTS": "entrypoints", + "INTEGRATION-GRAPH": "graph", + "INTEGRATION-DNS-MEASURE": "dns_measure", + "INTEGRATION-RESOURCE-TRACER": "resource_tracer", + "INTEGRATION-LIFECYCLE": "lifecycle::public_lifetimes", + "INTEGRATION-LIFECYCLE-TRACER": "lifecycle::lifecycle_tracer", + "INTEGRATION-REBUILD": "pressure::rebuild", + "INTEGRATION-REBUILD-TRACER": "pressure::rebuild_tracer", + "INTEGRATION-SOAK": "pressure::soak", + "INTEGRATION-SOAK-TRACER": "pressure::soak_tracer", + "INTEGRATION-SS-ALGORITHMS": "shadowsocks::algorithms", + "INTEGRATION-SS-EIH": "shadowsocks::eih", + "INTEGRATION-FAILURES": "connector::protect_failure", +} + + +def run(output: Path, selected, *, supplied=None): + if ( + not selected + or len(selected) != len(set(selected)) + or not set(selected) <= CONSUMERS.keys() + ): + raise ValueError("invalid INTEGRATION public gate selection") + output = output.resolve() + if not output.is_relative_to((CORE_DIR / "target/interop/runs").resolve()): + raise ValueError( + "INTEGRATION evidence must stay inside the owned run directory" + ) + output.mkdir(parents=True, exist_ok=False) + report = dict( + stage="INTEGRATION", + scope="public-runtime-subset", + source=source_identity(), + status="NOT RUN", + cases=[], + peers={}, + isolation={}, + ) + try: + if supplied is None: + identity = {} + binary = download_mihomo( + "linux-arm64", directory=output / "binaries", identity=identity + ) + else: + binary, identity = supplied + if sha256(binary) != identity["binary_sha256"]: + raise RuntimeError("INTEGRATION native peer identity mismatch") + report["peers"]["M"] = identity + lab = ContainerLab(report["isolation"], mtu=1500) + with tempfile.TemporaryDirectory(prefix="private-", dir=output) as temporary: + root = Path(temporary) + with contextlib.ExitStack() as stack: + origin_dir = root / "origin" + origin_dir.mkdir() + shutil.copyfile( + Path(__file__).with_name("container_udp_origin.py"), + origin_dir / "origin.py", + ) + origin = lab.start( + stack, + origin_dir, + "origin", + [ + "env", + "VCORE_ISOLATED_ORIGIN=1", + "VCORE_ORIGIN_PROFILE=INTEGRATION", + "python", + "-B", + "/data/fixture/origin.py", + ], + ) + origin.release() + origin.wait_tcp(24000) + upstream_dir = root / "upstream" + upstream_dir.mkdir() + shutil.copy2(binary, upstream_dir / "peer") + upstream = lab.start( + stack, + upstream_dir, + "upstream", + [ + "/data/fixture/peer", + "-d", + "/data", + "-f", + "/data/fixture/config.json", + ], + ) + hop, upstream_config = peer_configuration( + "socks5", upstream.ipv4, origin.ipv4, upstream_dir + ) + hop["name"] = "hop" + (upstream_dir / "config.json").write_text(json.dumps(upstream_config)) + upstream.release() + upstream.wait_tcp(23999) + if ( + command( + "exec", upstream.name, "sha256sum", "/data/fixture/peer" + ).split()[0] + != identity["binary_sha256"] + ): + raise RuntimeError( + "INTEGRATION upstream binary differs from download" + ) + directory = root / "native" + directory.mkdir() + shutil.copy2(binary, directory / "peer") + peer = lab.start( + stack, + directory, + "native", + [ + "/data/fixture/peer", + "-d", + "/data", + "-f", + "/data/fixture/config.json", + ], + ) + identity["version"] = command( + "exec", peer.name, "/data/fixture/peer", "-v" + ).strip() + if ( + command( + "exec", peer.name, "sha256sum", "/data/fixture/peer" + ).split()[0] + != identity["binary_sha256"] + ): + raise RuntimeError( + "INTEGRATION container binary differs from download" + ) + certificate = certificates(directory) + nodes, listeners = {}, [] + config = None + for index, protocol in enumerate(PROTOCOLS): + node, current = peer_configuration( + protocol, + peer.ipv4, + origin.ipv4, + directory, + certificate=certificate, + ) + port = 23000 + index + node["port"] = port + current["listeners"][0].update(name=protocol, port=port) + nodes[protocol] = node + listeners.extend(current["listeners"]) + config = current + ss_nodes = [nodes["ss"].copy()] + for index, cipher in enumerate( + ("2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305") + ): + node = nodes["ss"].copy() + node.update( + port=23010 + index, + cipher=cipher, + password=base64.b64encode(bytes([7]) * 32).decode(), + ) + listeners.append( + dict( + name=cipher, + type="shadowsocks", + listen="::", + port=node["port"], + cipher=cipher, + password=node["password"], + udp=True, + ) + ) + ss_nodes.append(node) + config.update( + listeners=listeners, + **{ + "external-controller": "0.0.0.0:23998", + "secret": "synthetic-integration-control", + }, + ) + (directory / "config.json").write_text(json.dumps(config)) + peer.release() + peer.wait_tcp(23999) + fixture = dict( + isolation="containers", + origin_control=f"{origin.ipv4}:24000", + origin_ipv4=origin.ipv4, + origin_ipv6=origin.ipv6, + nodes=nodes, + ss_nodes=ss_nodes, + hop=hop, + server_ipv6=peer.ipv6, + peer_controller=f"{peer.ipv4}:23998", + data_dir=str(root / "core"), + ) + if "INTEGRATION-SS-EIH" in selected: + artifact = download_native( + "SS", output / "binaries-ss", "linux-arm64", defer_version=True + ) + ss_dir = root / "ssserver" + ss_dir.mkdir() + shutil.copy2(artifact.binary, ss_dir / "ssserver") + ss = lab.start( + stack, + ss_dir, + "ssserver", + ["/data/fixture/ssserver", "-c", "/data/fixture/config.json"], + ) + artifact.identity["version"] = command( + "exec", ss.name, "/data/fixture/ssserver", "--version" + ).strip() + if ( + command( + "exec", ss.name, "sha256sum", "/data/fixture/ssserver" + ).split()[0] + != artifact.identity["binary_sha256"] + ): + raise RuntimeError("INTEGRATION SS binary identity mismatch") + report["peers"]["SS"] = artifact.identity + servers, eih_nodes = [], [] + for index, length in enumerate((16, 32)): + cipher = f"2022-blake3-aes-{length * 8}-gcm" + identity_key, user_key = [ + base64.b64encode(bytes([v]) * length).decode() + for v in (9, 8) + ] + port = 23020 + index + servers.append( + dict( + server="::", + server_port=port, + method=cipher, + password=identity_key, + users=[dict(name="fixture", password=user_key)], + mode="tcp_and_udp", + ) + ) + eih_nodes.append( + dict( + name="peer", + type="ss", + server=ss.ipv4, + port=port, + cipher=cipher, + password=f"{identity_key}:{user_key}", + udp=True, + ) + ) + (ss_dir / "config.json").write_text( + json.dumps( + dict( + servers=servers, + outbound_udp_allow_fragmentation=True, + inbound_udp_allow_fragmentation=True, + ) + ) + ) + report["ssserver_policy"] = dict( + outbound_udp_allow_fragmentation=True, + inbound_udp_allow_fragmentation=True, + guest_mtu=1500, + ) + ss.release() + ss.wait_tcp(23020) + fixture["eih_nodes"] = eih_nodes + fixture["eih_ipv6"] = ss.ipv6 + path = root / "fixture.json" + path.write_text(json.dumps(fixture)) + for identifier in selected: + print(f"INTEGRATION: {identifier}", flush=True) + env = dict( + os.environ, + VCORE_VLESS_INPUT=str(path), + VCORE_PROTOCOL_STAGE="INTEGRATION", + VCORE_CASE_EVENTS=str(output / (identifier + "-events.jsonl")), + VCORE_INTEGRATION_OBSERVATIONS=str( + output / (identifier + "-observations.json") + ), + ) + argv = rust_command(CONSUMERS[identifier]) + result = run_command(argv, cwd=CORE_DIR, env=env, timeout=3600) + (output / (identifier + ".log")).write_text( + redact(result.stdout.decode(errors="replace")) + ) + report["cases"].append( + dict( + case_id=identifier, + command=argv, + exit_code=result.returncode, + command_cleanup=result.cleanup, + seconds=result.seconds, + ) + ) + if "INTEGRATION-SS-EIH" in selected: + # The follow-log owner buffers while live; snapshot via + # the CLI so a short failed run retains real diagnostics. + text = command("logs", ss.name) + for server in servers: + for key in ( + server["password"], + server["users"][0]["password"], + ): + text = text.replace(key, "") + text = re.sub(r"(?:\d{1,3}\.){3}\d{1,3}", "", text) + (output / "ssserver.log").write_text(redact(text)) + if result.returncode != 0 or not result.cleanup: + raise RuntimeError("INTEGRATION public-runtime consumer failed") + report["status"] = "PASS" + except BaseException as error: + report.update(status="FAIL", failure_kind=type(error).__name__) + raise + finally: + report["source_unchanged"] = source_identity() == report["source"] + report["cleanup"] = all( + peer.get("joined") is True for peer in report["isolation"].get("peers", []) + ) + if not report["cleanup"] or not report["source_unchanged"]: + report["status"] = "FAIL" + (output / "integration-suite.json").write_text( + json.dumps(report, indent=2) + "\n" + ) + return report + + +if __name__ == "__main__": + with exclusive_run(), frozen_image(None): + run(Path(sys.argv[1]), sys.argv[2:] or list(CONSUMERS)) diff --git a/scripts/src/vcore_scripts/protocol_jls.py b/scripts/src/vcore_scripts/protocol_jls.py new file mode 100644 index 0000000..0a160aa --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_jls.py @@ -0,0 +1,104 @@ +"""JLS public consumers against official latest Mihomo in isolated containers.""" + +from __future__ import annotations + +from pathlib import Path + + +def catalog(): + cases = {} + + def add(mode, suffix, test): + cases[f"SECURITY-JLS-{mode.removesuffix('-tls').upper()}-{suffix}"] = ( + "M", + mode, + True, + test, + ) + + modes = ( + "tcp-tls", + "ws-tls", + "upgrade-tls", + "upgrade-fast-tls", + "grpc-tls", + ) + tuple( + f"xhttp-{mode}{version}" + for mode in ( + "stream-one", + "stream-up", + "packet-up", + "stream-up-download", + "packet-up-download", + ) + for version in ("", "-h1") + ) + for mode in modes: + add(mode, "BASE", "public_base") + add(mode, "CLOSE", "native_mihomo_close_alignment") + for mode in ( + "tcp-tls", + "ws-tls", + "grpc-tls", + "xhttp-stream-up-download", + "xhttp-stream-up-download-h1", + ): + add(mode, "AUTH", "native_jls_fail_closed") + for mode in modes: + if "download" in mode: + add(mode, "IDENTITY", "public_jls_download_identity") + for mode in ("tcp-tls", "grpc-tls"): + for suffix, test in ( + ("GRAPH", "runtime::public_graph"), + ("IPV6", "runtime::public_ipv6_and_gates"), + ("ENTRYPOINTS", "runtime::public_entrypoints"), + ("UDP-ISOLATION", "runtime::public_udp_isolation"), + ): + add(mode, suffix, test) + for mode in ("grpc-tls", "xhttp-stream-up-download"): + add(mode, "LIFE", "runtime::public_lifecycle") + add(mode, "OWNED", "runtime::owned_resources") + return cases + + +def configuration(node, peer, origin, mode): + credentials = dict(username="synthetic-jls-user", password="synthetic-jls-password") + download = dict( + username="synthetic-download-user", password="synthetic-download-password" + ) + node.pop("fingerprint", None) + node["jls-opts"] = credentials + if mode.endswith("-h1"): + node["alpn"] = ["http/1.1"] + for listener in peer["listeners"]: + listener.pop("certificate", None) + listener.pop("private-key", None) + listener["jls-config"] = dict( + enable=True, + sni="localhost", + dest=f"{origin}:24001", + users=[credentials, download], + ) + return dict(jls_download_credentials=download) + + +if __name__ == "__main__": + import argparse + + from .mihomo_isolation import exclusive_run + from .protocol_vless_container import CLIENT_FINGERPRINTS, run + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("output", type=Path) + parser.add_argument("cases", nargs="*") + parser.add_argument("--client-fingerprint", choices=CLIENT_FINGERPRINTS) + args = parser.parse_args() + with exclusive_run(): + raise SystemExit( + run( + args.output.resolve(), + args.cases or None, + jls=True, + client_fingerprint=args.client_fingerprint, + ) + ) diff --git a/scripts/src/vcore_scripts/protocol_peers.py b/scripts/src/vcore_scripts/protocol_peers.py new file mode 100644 index 0000000..b385880 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_peers.py @@ -0,0 +1,170 @@ +"""Owned native-peer processes. Stop is a wait barrier, never a global prune.""" + +from __future__ import annotations + +import os +import signal +import socket +import subprocess +import tempfile +import threading +import time +from dataclasses import dataclass +from pathlib import Path + +from .builds import CORE_DIR + + +class OwnedProcess: + def __init__( + self, + command: list[str], + log: Path, + record: dict, + *, + env=None, + cwd=None, + limit=1024 * 1024, + ): + self.command, self.log_path, self.record = command, log, record + self.process = None + self.log = None + self.env, self.cwd, self.limit = env, cwd, limit + self.reader = None + self.overflow = threading.Event() + + def __enter__(self): + self.record.update(started=False, joined=False) + self.log = self.log_path.open("wb") + try: + self.process = subprocess.Popen( + self.command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + start_new_session=os.name != "nt", + stdin=subprocess.DEVNULL, + env=self.env, + cwd=self.cwd, + ) + except BaseException: + self.log.close() + self.record["joined"] = True + raise + self.record["started"] = True + self.reader = threading.Thread(target=self._read, name="vcore-owned-output") + self.reader.start() + return self + + def _read(self): + stored = 0 + try: + while chunk := self.process.stdout.read(8192): + remaining = self.limit - stored + self.log.write(chunk[:remaining]) + stored += min(len(chunk), remaining) + if len(chunk) > remaining: + self.overflow.set() + self._signal(signal.SIGKILL) + except OSError: + self.overflow.set() + finally: + self.process.stdout.close() + + def _signal(self, value): + try: + if os.name == "nt": + if value == signal.SIGKILL: + self.process.kill() + else: + self.process.terminate() + else: + os.killpg(self.process.pid, value) + except ProcessLookupError: + pass + + def _terminate(self): + if self.process.poll() is None: + self._signal(signal.SIGTERM) + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self._signal(signal.SIGKILL) + self.process.wait(timeout=5) + + def _finish_reader(self): + if self.reader is not None: + self.reader.join(timeout=5) + if self.reader.is_alive(): + self._signal(signal.SIGKILL) + self.reader.join(timeout=5) + if self.reader.is_alive(): + raise RuntimeError("owned output reader did not stop") + if self.log is not None and not self.log.closed: + self.log.close() + + def ensure_alive(self): + if self.overflow.is_set(): + raise RuntimeError("native peer output exceeded its bound") + if self.process.poll() is not None: + self.record["unexpected_exit"] = self.process.returncode + raise RuntimeError("native peer exited before case completion") + + def wait_tcp(self, port: int, seconds: float = 10, *, host: str = "127.0.0.1"): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + self.ensure_alive() + try: + with socket.create_connection((host, port), 0.1): + self.record["ready"] = True + return + except OSError: + time.sleep(0.02) + raise TimeoutError("native peer readiness timeout") + + def __exit__(self, *_): + try: + self._terminate() + self._finish_reader() + self.record.update(joined=True, exit_code=self.process.returncode) + finally: + if self.reader is None or not self.reader.is_alive(): + self.log.close() + + +@dataclass(frozen=True) +class CommandResult: + returncode: int + stdout: bytes + cleanup: bool + seconds: float + reason: str | None + + +def run_command( + command: list[str], *, timeout: float, env=None, cwd=None, limit=1024 * 1024 +) -> CommandResult: + """Bounded capture with process-group cleanup even on timeout or SIGINT.""" + started = time.monotonic() + record = {} + reason = None + root = CORE_DIR / "target/interop/commands" + root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="command-", dir=root) as directory: + log = Path(directory) / "private.log" + with OwnedProcess(command, log, record, env=env, cwd=cwd, limit=limit) as owner: + try: + code = owner.process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + code = 124 + reason = "timeout" + if owner.overflow.is_set(): + code = 125 + reason = "output-limit" + output = log.read_bytes() + return CommandResult( + code, + output, + record.get("joined") is True, + round(time.monotonic() - started, 3), + reason, + ) diff --git a/scripts/src/vcore_scripts/protocol_preflight.py b/scripts/src/vcore_scripts/protocol_preflight.py new file mode 100644 index 0000000..9d66692 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_preflight.py @@ -0,0 +1,67 @@ +"""Independent native prerequisites. Artifact readiness is not wire acceptance.""" + +from __future__ import annotations + +from pathlib import Path + +from .mihomo_release import download_mihomo, latest_release +from .native_release import PeerArtifact, download_native +from .protocol_catalogs import PEERS +from .protocol_peers import run_command + + +def preflight(directory: Path, kinds: set[str], *, container: bool = False): + if not kinds <= PEERS: + raise ValueError("unsupported native peer kind") + directory.mkdir(parents=True, exist_ok=True) + artifacts, records = {}, [] + for kind in sorted(kinds): + record = { + "kind": kind, + "status": "BLOCKED", + "scope": "preflight-only", + "wire_acceptance": "NOT RUN", + } + records.append(record) + try: + if kind == "M": + identity = {} + release = latest_release() + binary = download_mihomo( + release=release, directory=directory / "M", identity=identity + ) + version = run_command([str(binary), "-v"], timeout=10, limit=4096) + if version.returncode != 0 or not version.stdout.strip(): + raise RuntimeError("official peer version query failed") + identity["version"] = version.stdout.decode( + "utf-8", errors="replace" + ).strip() + artifact = PeerArtifact(binary, identity) + artifacts[kind] = artifact + record.update(identity, status="READY") + if container: + record["container_status"] = "BLOCKED" + status = run_command( + ["container", "system", "status"], timeout=10, limit=4096 + ) + if status.returncode != 0: + raise RuntimeError("Apple Container is not running") + container_identity = {} + binary = download_mihomo( + "linux-arm64", + release=release, + directory=directory / "M-container", + identity=container_identity, + ) + artifacts["M-container"] = PeerArtifact(binary, container_identity) + record.update( + container_status="READY", container_artifact=container_identity + ) + else: + artifact = download_native(kind, directory / kind) + artifacts[kind] = artifact + record.update(artifact.identity, status="READY") + except (OSError, RuntimeError, ValueError) as error: + # Never expose URLs with userinfo, paths, config bodies or child output. + record["reason"] = type(error).__name__ + return artifacts, records diff --git a/scripts/src/vcore_scripts/protocol_reality_hybrid.py b/scripts/src/vcore_scripts/protocol_reality_hybrid.py new file mode 100644 index 0000000..b4cacce --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_reality_hybrid.py @@ -0,0 +1,536 @@ +"""Hybrid REALITY checks; not complete security suite acceptance.""" + +from __future__ import annotations + +import contextlib +import copy +import hashlib +import json +import os +import shutil +import tempfile +from pathlib import Path + +from .builds import CORE_DIR +from .mihomo_isolation import exclusive_run +from .mihomo_release import download_mihomo +from .protocol_containers import ContainerLab, command +from .protocol_evidence import read_events +from .protocol_fixtures import certificates +from .protocol_inputs import redact, same_source, source_identity +from .protocol_peers import run_command +from .protocol_vless_peers import configuration +from .protocol_vless_public import events_pass + +MODES = { + "tcp": ("tcp-reality", None), + "ws": ("ws-reality", None), + "upgrade": ("upgrade-reality", None), + "upgrade-fast": ("upgrade-fast-reality", None), + "grpc": ("grpc-reality", None), + "vision": ("vision-reality", None), + **{ + f"{version}-{mode}": ( + f"xhttp-{mode}{'-download' if mode != 'stream-one' else ''}-reality", + alpn, + ) + for version, alpn in (("h1", "http/1.1"), ("h2", "h2")) + for mode in ("stream-one", "stream-up", "packet-up") + }, +} + + +def cases(): + result = {} + for profile in ("none", "chrome"): + for mode in MODES: + result[f"{profile}-{mode}-base"] = (profile, mode, "public_base", "hybrid") + for variant in ("main-classic", "download-classic"): + for version in ("h1", "h2"): + result[f"{profile}-{version}-{variant}"] = ( + profile, + f"{version}-stream-up", + "public_base", + variant, + ) + for mode in ("tcp", "h1-stream-up", "h2-stream-up"): + result[f"{profile}-{mode}-security"] = ( + profile, + mode, + "native_hybrid_fail_closed", + "hybrid", + ) + result[f"{profile}-vision-inner-tls"] = ( + profile, + "vision", + "native_vision_inner_tls", + "hybrid", + ) + for test in ( + "runtime::public_graph", + "runtime::public_ipv6_and_gates", + "runtime::public_entrypoints", + ): + result[f"{profile}-tcp-{test.split('::')[-1]}"] = ( + profile, + "tcp", + test, + "hybrid", + ) + for mode in ("tcp", "grpc", "vision", "h2-stream-up"): + for suffix, test in ( + ("life", "runtime::public_lifecycle"), + ("owned", "runtime::owned_resources"), + ): + result[f"chrome-{mode}-{suffix}"] = ("chrome", mode, test, "hybrid") + return result + + +def observed_events(observer): + raw = command( + "exec", + observer.name, + "python", + "-c", + "from pathlib import Path; p=Path('/data/events.jsonl'); " + "assert p.stat().st_size <= 8388608; print(p.read_text(),end='')", + ) + return [json.loads(line) for line in raw.splitlines()] + + +def wire_pass(events, expectations): + for port, (hybrid, profile) in expectations.items(): + shares = ( + [[4588, 1216], [29, 32]] + if hybrid and profile == "chrome" + else [[4588, 1216]] + if hybrid + else [[29, 32]] + ) + hellos = [ + e + for e in events + if e.get("port") == port and e.get("event") == "client-hello" + ] + selections = [ + e + for e in events + if e.get("port") == port and e.get("event") == "server-hello" + ] + if ( + not hellos + or not selections + or not all(e["shares"] == shares for e in hellos) + ): + return False + if not all(e["group"] == (4588 if hybrid else 29) for e in selections): + return False + return True + + +def negative_wire_pass(events, profile, front=25000, legs=1): + for offset, cover, group in ((2, 24432, 29), (3, 24433, 24)): + observed = [e for e in events if e.get("port") == front + offset] + hellos = [e for e in observed if e.get("event") == "client-hello"] + replies = [e for e in observed if e.get("event") == "server-hello"] + if len(hellos) != legs: + return False + if profile == "chrome": + if ( + len(replies) != legs + or not all( + e.get("group") == group and e.get("hrr") is (offset == 3) + for e in replies + ) + or not all( + e.get("shares") == [[4588, 1216], [29, 32]] + and e.get("groups") == [4588, 29, 23, 24] + for e in hellos + ) + ): + return False + else: + # A hybrid-only offer has no common group with these peers. A + # missing ServerHello alone is not proof: require the cover's + # native no-shared-group error and an empty, closed server flight. + rejected = [ + e + for e in events + if e.get("port") == cover and e.get("event") == "no-shared-group" + ] + closed = [ + e + for e in observed + if e.get("event") == "server-close" and e.get("bytes") == 0 + ] + if ( + replies + or len(rejected) != legs + or len(closed) != legs + or not all( + e.get("shares") == [[4588, 1216]] and e.get("groups") == [4588] + for e in hellos + ) + ): + return False + return True + + +def run(output: Path, selected=None, *, encryption=None, supplied=None): + output = output.resolve() + if not output.is_relative_to(CORE_DIR / "target/interop/runs"): + raise ValueError("use a fresh child of target/interop/runs") + required = cases() + selected = list(required) if selected is None else selected + if ( + not selected + or len(set(selected)) != len(selected) + or not set(selected) <= required.keys() + ): + raise ValueError("invalid SECURITY.reality selection") + output.mkdir(parents=True, exist_ok=False) + report = dict( + stage="SECURITY.reality", + scope="S03/D16-production", + encryption_profile=encryption, + complete_selection=set(selected) == set(required), + required=list(required), + source=source_identity(), + cases=[], + isolation={}, + peers={}, + status="NOT RUN", + ) + try: + if supplied is None: + identity = {} + binary = download_mihomo( + "linux-arm64", directory=output / "binaries", identity=identity + ) + else: + binary, identity = supplied["M"] + report["peers"]["M"] = identity + built = run_command( + [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "vless_public", + "--test", + "vless_native", + "--no-run", + ], + timeout=240, + ) + (output / "build.log").write_text(redact(built.stdout.decode(errors="replace"))) + if built.returncode or not built.cleanup: + raise RuntimeError("SECURITY.reality consumer build failed") + lab = ContainerLab(report["isolation"], mtu=1500) + with ( + tempfile.TemporaryDirectory(prefix="private-", dir=output) as temporary, + contextlib.ExitStack() as stack, + ): + root = Path(temporary) + origin_dir, server_dir, observer_dir, hop_dir = [ + root / p for p in ("origin", "server", "observer", "hop") + ] + for directory in (origin_dir, server_dir, observer_dir, hop_dir): + directory.mkdir() + shutil.copy2( + Path(__file__).with_name("container_udp_origin.py"), + origin_dir / "origin.py", + ) + shutil.copy2( + Path(__file__).with_name("container_reality_hybrid.py"), + observer_dir / "observer.py", + ) + shutil.copy2(binary, server_dir / "peer") + shutil.copy2(binary, hop_dir / "peer") + cert, key, pin = certificates(origin_dir) + certificates(observer_dir) + origin = lab.start( + stack, + origin_dir, + "security-origin", + [ + "env", + "VCORE_ISOLATED_ORIGIN=1", + f"VCORE_ORIGIN_CERT=/data/fixture/{cert.name}", + f"VCORE_ORIGIN_KEY=/data/fixture/{key.name}", + "python", + "-B", + "/data/fixture/origin.py", + ], + ) + observer = lab.start( + stack, + observer_dir, + "security-observer", + [ + "env", + "VCORE_ISOLATED_ORIGIN=1", + "python", + "-B", + "/data/fixture/observer.py", + ], + ) + server = lab.start( + stack, + server_dir, + "security-mihomo", + [ + "/data/fixture/peer", + "-d", + "/data/mihomo", + "-f", + "/data/fixture/config.json", + ], + ) + hop = lab.start( + stack, + hop_dir, + "security-upstream", + [ + "/data/fixture/peer", + "-d", + "/data/mihomo", + "-f", + "/data/fixture/config.json", + ], + ) + listeners, routes, nodes, ports = [], {}, {}, {} + for index, (name, (mode, alpn)) in enumerate(MODES.items()): + node, config = configuration( + mode, + observer.ipv4, + origin.ipv4, + Path("/data/fixture/cert.pem"), + Path("/data/fixture/key.pem"), + ) + backend, front = 23010 + index * 10, 25000 + index * 10 + listener = config["listeners"][0] + if encryption: + from .protocol_encryption_public import configuration as encrypt + + encrypt(encryption, node, config) + listener.update(name=name, port=backend) + listener["reality-config"]["dest"] = f"{observer.ipv4}:24431" + listeners.append(listener) + routes[str(front)] = routes[str(front + 1)] = [server.ipv4, backend] + if name == "tcp": + for offset, cover in ((2, 24432), (3, 24433)): + negative = copy.deepcopy(listener) + negative.update(name=f"reject-{cover}", port=backend + offset) + negative["reality-config"]["dest"] = f"{observer.ipv4}:{cover}" + listeners.append(negative) + routes[str(front + offset)] = [server.ipv4, backend + offset] + node.update(name="peer", port=front) + node["reality-opts"]["support-x25519mlkem768"] = True + if alpn: + node["alpn"] = [alpn] + if "download-settings" in node.get("xhttp-opts", {}): + node["xhttp-opts"]["download-settings"] = { + "server": observer.ipv4, + "port": front + 1, + } + nodes[name], ports[name] = node, front + (observer_dir / "routes.json").write_text(json.dumps(routes)) + (server_dir / "config.json").write_text( + json.dumps( + { + "ipv6": True, + "log-level": "silent", + "listeners": listeners, + "hosts": {"vcore-fixture.test": origin.ipv4}, + "rules": ["MATCH,DIRECT"], + } + ) + ) + (hop_dir / "config.json").write_text( + json.dumps( + { + "socks-port": 23001, + "allow-lan": True, + "bind-address": "*", + "log-level": "silent", + "ipv6": True, + "hosts": {"peer.fixture.test": observer.ipv4}, + "rules": ["MATCH,DIRECT"], + } + ) + ) + for peer, port in ( + (origin, 24000), + (observer, 24431), + (server, 23010), + (hop, 23001), + ): + peer.release() + peer.wait_tcp(port) + identity["version"] = command( + "exec", server.name, "/data/fixture/peer", "-v" + ).strip() + if ( + command("exec", server.name, "sha256sum", "/data/fixture/peer").split()[ + 0 + ] + != identity["binary_sha256"] + ): + raise RuntimeError("native peer identity mismatch") + report["openssl"] = command( + "exec", + observer.name, + "python", + "-c", + "import ssl; print(ssl.OPENSSL_VERSION)", + ).strip() + for case in selected: + profile, mode, test, variant = required[case] + node = copy.deepcopy(nodes[mode]) + node["client-fingerprint"] = profile + expectations = {ports[mode]: (True, profile)} + if node.get("xhttp-opts", {}).get("download-settings") is not None: + expectations[ports[mode] + 1] = (True, profile) + if variant != "hybrid": + down = node["xhttp-opts"]["download-settings"] + down["reality-opts"] = copy.deepcopy(node["reality-opts"]) + if variant == "main-classic": + node["reality-opts"]["support-x25519mlkem768"] = False + expectations[ports[mode]] = (False, profile) + else: + down["reality-opts"]["support-x25519mlkem768"] = False + expectations[ports[mode] + 1] = (False, profile) + f = dict( + isolation="containers", + node=node, + origin_control=f"{origin.ipv4}:24000", + origin_ipv4=origin.ipv4, + origin_ipv6=origin.ipv6, + server_ipv6=observer.ipv6, + origin_pin=pin, + data_dir=str(root / "data"), + peer_kind="M", + vision_probe=mode == "vision", + hop=dict( + name="hop", type="socks5", server=hop.ipv4, port=23001, udp=True + ), + downgrade_port=ports["tcp"] + 2, + hrr_port=ports["tcp"] + 3, + ordinary_tls_port=24431, + tls12_port=24434, + ) + fixture = root / "input.json" + fixture.write_text(json.dumps(f)) + before = len(observed_events(observer)) + events_path = output / f"{case}-events.jsonl" + test_command = [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "vless_native" if test.startswith("native_") else "vless_public", + test, + "--", + "--ignored", + "--exact", + "--nocapture", + ] + print(case, flush=True) + result = run_command( + test_command, + timeout=240, + env=dict( + os.environ, + VCORE_VLESS_INPUT=str(fixture), + VCORE_CASE_EVENTS=str(events_path), + VCORE_PROTOCOL_STAGE="SECURITY", + ), + ) + (output / f"{case}.log").write_text( + redact(result.stdout.decode(errors="replace")) + ) + observed = observed_events(observer)[before:] + wire_path = output / f"{case}-wire.json" + wire_path.write_text(json.dumps(observed, indent=2) + "\n") + events = read_events(events_path) if events_path.exists() else [] + remapped = [ + dict(e, suite=e.get("suite", "").replace("SECURITY-", "VLESS-", 1)) + for e in events + ] + assertions = ( + remapped + == [ + dict( + schema_version=1, + suite="VLESS-WIRE", + assertion=test, + status=status, + ) + for status in ("BEGIN", "PASS") + ] + if test.startswith("native_") + else events_pass(remapped, test, MODES[mode][0]) + ) + # Security matrices deliberately contain rejected peers; their + # strict per-attempt assertions require errors, never timeouts. + wire_valid = wire_pass(observed, expectations) + if test == "native_hybrid_fail_closed": + wire_valid &= negative_wire_pass( + observed, profile, ports["tcp"], len(expectations) + ) + passed = ( + result.returncode == 0 + and result.cleanup + and assertions + and wire_valid + ) + report["cases"].append( + dict( + id=case, + test=test, + variant=variant, + profile=profile, + status="PASS" if passed else "FAIL", + exit_code=result.returncode, + command_cleanup=result.cleanup, + seconds=result.seconds, + command=test_command, + wire_sha256=hashlib.sha256(wire_path.read_bytes()).hexdigest(), + assertions=assertions, + wire_valid=wire_valid, + ) + ) + print(f"{case}: {report['cases'][-1]['status']}", flush=True) + for peer in (origin, observer, server, hop): + peer.ensure_alive() + if not passed: + raise RuntimeError(f"SECURITY.reality case failed: {case}") + report["status"] = "PASS" + except KeyboardInterrupt: + report.update(status="INTERRUPTED", reason="user interruption") + except (OSError, RuntimeError, ValueError, KeyError) as error: + report.update( + status="FAIL", + reason=str(error) + if isinstance(error, RuntimeError) + else type(error).__name__, + ) + finally: + report["cleanup"] = all( + p["joined"] for p in report["isolation"].get("peers", []) + ) + report["source_unchanged"] = same_source(report["source"], source_identity()) + if not report["cleanup"] or not report["source_unchanged"]: + report["status"] = "FAIL" + (output / "reality-results.json").write_text( + json.dumps(report, indent=2) + "\n" + ) + return 0 if report["status"] == "PASS" else 1 + + +def main(output, selected=None): + with exclusive_run(): + return run(output, selected) diff --git a/scripts/src/vcore_scripts/protocol_script_tests.py b/scripts/src/vcore_scripts/protocol_script_tests.py new file mode 100644 index 0000000..c0fa748 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_script_tests.py @@ -0,0 +1,68 @@ +"""Structured unittest results for the protocol harness; no stdout PASS parsing.""" + +from __future__ import annotations + +import contextlib +import io +import json +import sys +import unittest +from pathlib import Path + +from .builds import CORE_DIR + + +class Result(unittest.TestResult): + def __init__(self): + super().__init__() + self.cases = [] + + def addSuccess(self, test): + super().addSuccess(test) + self.cases.append({"test": test.id(), "status": "PASS"}) + + def addFailure(self, test, error): + super().addFailure(test, error) + self.cases.append({"test": test.id(), "status": "FAIL", "reason": "assertion"}) + + def addError(self, test, error): + super().addError(test, error) + self.cases.append( + {"test": test.id(), "status": "FAIL", "reason": error[0].__name__} + ) + + def addSkip(self, test, reason): + super().addSkip(test, reason) + self.cases.append({"test": test.id(), "status": "NOT RUN"}) + + +class BoundedOutput(io.TextIOBase): + def __init__(self): + self.size = 0 + + def write(self, text): + self.size += len(text) + if self.size > 128 * 1024: + raise RuntimeError("script test output exceeded bound") + return len(text) + + +def execute(path: Path) -> bool: + result = Result() + try: + suite = unittest.defaultTestLoader.discover(str(CORE_DIR / "scripts/tests")) + with ( + contextlib.redirect_stdout(BoundedOutput()), + contextlib.redirect_stderr(BoundedOutput()), + ): + suite.run(result) + finally: + path.write_text( + json.dumps({"tests_run": result.testsRun, "cases": result.cases}, indent=2) + + "\n" + ) + return result.testsRun > 0 and result.wasSuccessful() and not result.skipped + + +if __name__ == "__main__": + raise SystemExit(0 if execute(Path(sys.argv[1])) else 1) diff --git a/scripts/src/vcore_scripts/protocol_security_acceptance.py b/scripts/src/vcore_scripts/protocol_security_acceptance.py new file mode 100644 index 0000000..5e264fc --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_security_acceptance.py @@ -0,0 +1,662 @@ +"""Execute security cases and reconstruct results from commands and native events.""" + +from __future__ import annotations + +import json +import sys +from contextlib import ExitStack + +from .protocol_evidence import HEX, new_result, read_events, read_json +from .protocol_inputs import redact +from .protocol_security_catalog import FIELDS, GATES, definitions, groups + + +def commands(identifier): + from .protocol_hysteria2_acceptance import commands as previous + + cargo = ["cargo", "test", "--locked", "--all-features"] + targets = [ + "ech_config", + "ech_tls", + "encryption_config", + "reality_config", + "jls_config", + ] + if identifier in {"SECURITY-CFG", "SECURITY-RELEASE"}: + if identifier == "SECURITY-RELEASE": + cargo += ["--release"] + return [ + cargo + sum((["--test", name] for name in targets), []), + cargo + ["--lib", "security::"], + cargo + ["--lib", "outbound::vless::encryption"], + cargo + ["--lib", "config::"], + ] + if identifier == "SECURITY-FEATURES": + return previous("HYSTERIA2-FEATURES") + [ + [ + "cargo", + "test", + "--locked", + "--no-default-features", + "--features", + "outbound-vless", + "--test", + "ech_config", + "--test", + "ech_tls", + ], + [ + "cargo", + "test", + "--locked", + "--all-features", + "--all-targets", + "--no-run", + ], + ] + if identifier == "SECURITY-SCRIPTS": + return [ + [ + sys.executable, + "-m", + "vcore_scripts.protocol_script_tests", + "{output}/script-tests.json", + ] + ] + return previous(identifier.replace("SECURITY-", "HYSTERIA2-")) + + +def gate_result(case, records, events, script=None): + from .protocol_hysteria2_catalog import assertions_pass + from .protocol_security_units import TESTS + + identifier = case["case_id"] + good = bool(records) and all( + r.get("exit_code") == 0 and r.get("cleanup") is True for r in records + ) + if identifier in {"SECURITY-CFG", "SECURITY-RELEASE"}: + # Config and TLS tests each have an exact BEGIN/PASS pair. Library + # regressions run separately without these assertion identities. + selected = [e for e in events if e.get("suite") == "SECURITY-CONFIG-TLS"] + good &= assertions_pass( + selected, + { + ("SECURITY-CONFIG-TLS", name): 1 + for names in TESTS.values() + for name in names + }, + ) + good &= all(e.get("status") in {"BEGIN", "PASS"} for e in events) + if identifier == "SECURITY-SCRIPTS": + samples = (script or {}).get("cases", []) + names = [s.get("test") for s in samples] + required = { + "test_protocol_security.SecurityAcceptanceTest." + name + for name in ( + "test_frozen_scope_and_compositions", + "test_native_missing_duplicate_wrong_events_fail", + "test_source_cleanup_and_identity_are_not_inferred", + "test_empty_gate_and_missing_script_proof_fail", + "test_legacy_ech_gateway_keeps_response_inside_tls", + "test_close_reference_cannot_hide_a_different_official_profile", + ) + } + required |= { + "test_protocol_containers.ContainerTests." + name + for name in ( + "test_frozen_latest_image_is_single_run_owned", + "test_image_refresh_failure_never_falls_back_to_a_cached_image", + ) + } + good &= ( + bool(samples) + and len(names) == len(set(names)) == (script or {}).get("tests_run") + and required <= set(names) + and all(s.get("status") == "PASS" for s in samples) + ) + return result( + case, good, bool(records) and all(r.get("cleanup") is True for r in records) + ) + + +def result(case, good, cleanup): + record = new_result(case) + record.update( + status="PASS" if good else "FAIL", + assertions={case["case_id"]: bool(good)}, + evidence=case["required_evidence"] if good else [], + command_exit_code=0 if good else 1, + cleanup=cleanup, + ) + return record + + +def native_envelope(report, peers=None, source=None, *, image_digest=None): + isolation = report.get("isolation", {}) + owned = isolation.get("peers", []) + identities = report.get("peers", {}) + if "peer" in report: + identities = {"M": report["peer"]} + good = ( + report.get("status") == "PASS" + and report.get("cleanup") is True + and report.get("source_unchanged") is True + and isolation.get("network_mode") == "hostOnly" + and isolation.get("host_servers") is False + and isolation.get("guest_mtu") == 1500 + and isolation.get("image_digest", "").startswith("sha256:") + and bool(HEX.fullmatch(isolation.get("image_digest", "")[7:])) + and bool(owned) + and all(p.get("started") is True and p.get("joined") is True for p in owned) + and bool(identities) + ) + for kind, identity in identities.items(): + good &= ( + bool(identity.get("version")) + and bool(HEX.fullmatch(identity.get("binary_sha256", ""))) + and bool(HEX.fullmatch(identity.get("archive_sha256", ""))) + and identity.get("source_url", "").startswith( + { + "M": "https://github.com/MetaCubeX/mihomo/releases/", + "XR": "https://github.com/XTLS/Xray-core/releases/", + "V2": "https://github.com/v2fly/v2ray-core/releases/", + }.get(kind, "invalid-source") + ) + ) + if peers is not None: + good &= identity == peers.get(kind) + if source is not None: + good &= report.get("source") == source + if image_digest is not None: + good &= isolation.get("image_digest") == image_digest + return bool(good) + + +def rust_command(target, test, *, fields=False): + return [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + target, + test, + "--", + *(["--exact", "--ignored"] if fields else ["--ignored", "--exact"]), + "--nocapture", + ] + + +def vless_catalog(group): + if group.get("ech"): + from .protocol_ech import catalog + + return catalog() + if group.get("jls"): + from .protocol_jls import catalog + + return catalog() + from .protocol_vless_container import ALL_CASES + + return ALL_CASES | { + "FINGERPRINT-ANYTLS": ("M", "anytls", True, "public_legacy_regression") + } + + +def close_reference_pass(group, mode, directory): + profile = group.get("client_fingerprint") + expected_profile, scope = profile, "same-mode" + if mode == "grpc-tls": + if group.get("jls") and profile in (None, "", "none"): + expected_profile, scope = "chrome", "jls-grpc-chrome-baseline" + elif group.get("ech") and profile in ("safari", "safari16"): + expected_profile, scope = "chrome", "ech-safari-chrome-baseline" + try: + reference = read_json(directory / f"{mode}-tls-close-reference.json") + except (OSError, ValueError): + return False + return ( + isinstance(reference, dict) + and reference.get("scope") == scope + and reference.get("client_fingerprint") == expected_profile + and reference.get("dut_client_fingerprint") == profile + and reference.get("terminated") is True + ) + + +def vless_pass(group, report, directory): + from .protocol_vless_public import events_pass + + catalog = vless_catalog(group) + expected = group["selected"] + records = report.get("cases", []) + if len(records) != len(expected) or {r.get("case_id") for r in records} != set( + expected + ): + return False + if any(report.get(k) != group.get(k, False) for k in ("jls", "ech")): + return False + if report.get("encryption_profile") != group.get("encryption") or report.get( + "client_fingerprint" + ) != group.get("client_fingerprint"): + return False + stage = ( + "SECURITY" + if group.get("ech") or group.get("jls") or group.get("encryption") + else "VLESS" + ) + for record in records: + name = record["case_id"] + kind, mode, _, test = catalog[name] + native = test.startswith("native_") + events = read_events(directory / (name + "-events.jsonl")) + if not ( + record.get("status") == "PASS" + and record.get("exit_code") == 0 + and record.get("command_cleanup") is True + and record.get("cleanup") is True + and record.get("peer_kind") == kind + and record.get("command") + == rust_command("vless_native" if native else "vless_public", test) + ): + return False + if native: + if test == "native_mihomo_close_alignment" and not close_reference_pass( + group, mode, directory + ): + return False + if events != [ + dict( + schema_version=1, + suite=stage + "-WIRE", + assertion=test, + status=status, + ) + for status in ("BEGIN", "PASS") + ]: + return False + elif not events_pass(events, test, mode, stage=stage): + return False + return True + + +def xhttp_pass(group, report, directory): + from .protocol_xhttp_fields import PUBLIC_TESTS, events_pass, public_events_pass + + jobs = group["jobs"] + expected = { + entry["case_id"]: (variant, entry["test"]) + for variant, entries in jobs.items() + for entry in entries + } + records = report.get("cases", []) + if len(records) != len(expected) or {r.get("case_id") for r in records} != set( + expected + ): + return False + if ( + report.get("ech") != group.get("ech", False) + or report.get("jls") != group.get("jls", False) + or report.get("encryption_profile") != group.get("encryption") + ): + return False + for record in records: + variant, test = expected[record["case_id"]] + events = read_events(directory / (record["case_id"] + "-events.jsonl")) + if not ( + record.get("variant") == variant + and record.get("assertion") == test + and record.get("status") == "PASS" + and record.get("exit_code") == 0 + and record.get("command_cleanup") is True + and record.get("command") + == rust_command( + "vless_public" if test in PUBLIC_TESTS else "xhttp_native", + test, + fields=True, + ) + ): + return False + if not ( + public_events_pass(events, test) + if test in PUBLIC_TESTS + else events_pass(events, test, owned=test.startswith("lifecycle::")) + ): + return False + return True + + +def hybrid_pass(group, report, directory): + from .protocol_reality_hybrid import MODES, cases, negative_wire_pass, wire_pass + from .protocol_vless_public import events_pass + + catalog = cases() + records = report.get("cases", []) + selected = group["selected"] + if ( + len(records) != len(selected) + or {r.get("id") for r in records} != set(selected) + or report.get("encryption_profile") != group.get("encryption") + ): + return False + ports = {mode: 25000 + i * 10 for i, mode in enumerate(MODES)} + for record in records: + name = record["id"] + profile, mode, test, variant = catalog[name] + expected = {ports[mode]: (True, profile)} + if mode.startswith(("h1-", "h2-")) and not mode.endswith("stream-one"): + expected[ports[mode] + 1] = (True, profile) + if variant != "hybrid": + expected[ports[mode] + (variant == "download-classic")] = (False, profile) + wire = read_json(directory / (name + "-wire.json")) + events = read_events(directory / (name + "-events.jsonl")) + native = test.startswith("native_") + good = ( + record.get("status") == "PASS" + and record.get("exit_code") == 0 + and record.get("command_cleanup") is True + and (record.get("test"), record.get("variant"), record.get("profile")) + == (test, variant, profile) + and record.get("command") + == rust_command("vless_native" if native else "vless_public", test) + and wire_pass(wire, expected) + ) + if test == "native_hybrid_fail_closed": + good &= negative_wire_pass(wire, profile, ports["tcp"], len(expected)) + good &= ( + events + == [ + dict( + schema_version=1, + suite="SECURITY-WIRE", + assertion=test, + status=status, + ) + for status in ("BEGIN", "PASS") + ] + if native + else events_pass(events, test, MODES[mode][0], stage="SECURITY") + ) + if not good: + return False + return True + + +def encryption_pass(group, report, directory): + from .protocol_encryption import cases + + expected = [ + key + for key, (_, rtt, _) in cases().items() + if not group.get("expiry") or rtt == "0rtt" + ] + records = report.get("cases", []) + test = ( + "native_ticket_expiry" if group.get("expiry") else "native_encryption_roundtrip" + ) + marker = ( + "SECURITY-ENCRYPTION-EXPIRY-PASS full-resumed-expired-full-resumed" + if group.get("expiry") + else "SECURITY-ENCRYPTION-WIRE-PASS rounds=4 bytes_per_direction=10485760" + ) + if ( + len(records) != len(expected) + or {r.get("case_id") for r in records} != set(expected) + or report.get("ticket_expiry") != group.get("expiry", False) + or report.get("cipher") + != ("chacha20-poly1305" if group.get("chacha") else "cpu-selected") + or report.get("padding") != "minimal" + ): + return False + for record in records: + log = (directory / (record["case_id"] + ".log")).read_text() + if not ( + record.get("status") == "PASS" + and record.get("returncode") == 0 + and record.get("cleanup") is True + and marker in log + and "1 passed; 0 failed; 0 ignored" in log + and record.get("command") == rust_command("encryption_wire", test) + ): + return False + return True + + +REPORTS = { + "vless": "vless-results.json", + "xhttp": "xhttp-fields-results.json", + "hybrid": "reality-results.json", + "encryption": "encryption-results.json", +} +CHECKERS = { + "vless": vless_pass, + "xhttp": xhttp_pass, + "hybrid": hybrid_pass, + "encryption": encryption_pass, +} + + +def native_result( + case, group, report, directory, *, peers=None, source=None, image_digest=None +): + good = native_envelope(report, peers, source, image_digest=image_digest) + good &= CHECKERS[group["runner"]](group, report, directory) + return result(case, good, report.get("cleanup") is True) + + +def fields_report(cases, results): + by_id = {r["case_id"]: r for r in results} + rows = [] + for field in sorted(FIELDS): + owners = [c["case_id"] for c in cases if field in c["row_ids"]] + cfg = [c for c in owners if c in {"SECURITY-CFG", "SECURITY-RELEASE"}] + wire = [c for c in owners if c not in GATES] + good = ( + bool(cfg) + and bool(wire) + and all(by_id.get(c, {}).get("status") == "PASS" for c in owners) + ) + rows.append( + dict( + id=field, + cfg_cases=cfg, + wire_cases=wire, + status="PASS" if good else "NOT RUN", + ) + ) + return dict( + stage="SECURITY", scope="selected-vless-security-consumers", fields=rows + ) + + +def execute(cases, run, output, results): + from .protocol_containers import frozen_image + from .protocol_encryption import run as run_encryption + from .protocol_harness import _command + from .protocol_reality_hybrid import run as run_hybrid + from .protocol_vless_container import run as run_vless + from .protocol_xhttp_acceptance import prepare_peers + from .protocol_xhttp_fields import run as run_xhttp + + supplied = {} + image_scope = ExitStack() + catalog = groups() + source = { + k: run[k] + for k in ( + "parent_commit", + "source_tree_sha256", + "dirty_patch_sha256", + "lock_sha256", + ) + } + try: + for case in cases: + identifier = case["case_id"] + if identifier not in GATES: + continue + records, events = [], [] + for i, argv in enumerate(commands(identifier)): + name = f"{identifier}-{i}" + path = output / (name + "-events.jsonl") + _command( + run, + output, + name, + [v.replace("{output}", str(output)) for v in argv], + case["timeout_seconds"], + events=path, + ) + records.append(run["commands"][-1]) + if path.exists(): + events += read_events(path) + script = ( + read_json(output / "script-tests.json") + if identifier == "SECURITY-SCRIPTS" + else None + ) + results[identifier] = gate_result(case, records, events, script) + print(identifier + ": " + results[identifier]["status"], flush=True) + if results[identifier]["status"] != "PASS": + raise RuntimeError("SECURITY local gate failed: " + identifier) + if any(c["case_id"] in catalog for c in cases): + supplied = prepare_peers(output, {"M", "XR", "V2"}) + run["container_image"] = image_scope.enter_context( + frozen_image(output / "container-image-pull.log") + ) + runners = { + "vless": run_vless, + "xhttp": run_xhttp, + "hybrid": run_hybrid, + "encryption": run_encryption, + } + # Retained combinations first, followed by the longer new ECH matrix. + # Every required group still runs in this same frozen source/image scope. + for case in sorted( + cases, + key=lambda c: (c["case_id"].startswith("SECURITY-ECH-"), c["case_id"]), + ): + identifier = case["case_id"] + if identifier in GATES: + continue + group = catalog[identifier] + kwargs = {k: v for k, v in group.items() if k not in {"rows", "runner"}} + if group["runner"] == "xhttp": + kwargs["selected"] = list(kwargs["jobs"]) + directory = output / identifier + runners[group["runner"]](directory, supplied=supplied, **kwargs) + report = read_json(directory / REPORTS[group["runner"]]) + results[identifier] = native_result( + case, + group, + report, + directory, + peers={k: v[1] for k, v in supplied.items()}, + source=source, + image_digest=run["container_image"]["digest"], + ) + print(identifier + ": " + results[identifier]["status"], flush=True) + if results[identifier]["status"] != "PASS": + raise RuntimeError("SECURITY native gate failed: " + identifier) + finally: + image_scope.close() + (output / "peers.json").write_text( + json.dumps({k: v[1] for k, v in supplied.items()}, indent=2) + "\n" + ) + (output / "fields.json").write_text( + json.dumps(fields_report(cases, list(results.values())), indent=2) + "\n" + ) + + +def check(run_dir, cases, run, results, peers, paths): + from .protocol_containers import IMAGE + + snapshot = run.get("container_image", {}) + digest = snapshot.get("digest", "") + if ( + not isinstance(digest, str) + or not digest.startswith("sha256:") + or not HEX.fullmatch(digest[7:]) + or snapshot + != dict( + tag=IMAGE, + digest=digest, + command=["container", "image", "pull", IMAGE], + exit_code=0, + cleanup=True, + log="container-image-pull.log", + ) + or snapshot["log"] not in paths + ): + raise ValueError("missing or altered SECURITY frozen container image") + if ( + cases != definitions() + or set(peers) != {"M", "XR", "V2"} + or not {"fields.json", "script-tests.json"} <= set(paths) + ): + raise ValueError("incomplete SECURITY stage artifacts or altered manifest") + records = run.get("commands", []) + names = { + f"{c['case_id']}-{i}" + for c in cases + for i, _ in enumerate(commands(c["case_id"])) + if c["case_id"] in GATES + } + if len(records) != len(names) or {r.get("name") for r in records} != names: + raise ValueError("missing, duplicate or extra SECURITY gate command") + actual = {r["case_id"]: r for r in results} + source = { + k: run[k] + for k in ( + "parent_commit", + "source_tree_sha256", + "dirty_patch_sha256", + "lock_sha256", + ) + } + catalog = groups() + for case in cases: + identifier = case["case_id"] + if identifier in GATES: + selected, events = [], [] + for i, argv in enumerate(commands(identifier)): + record = next(r for r in records if r["name"] == f"{identifier}-{i}") + if ( + record["command"] + != [redact(v.replace("{output}", str(run_dir))) for v in argv] + or record["log"] not in paths + ): + raise ValueError("SECURITY gate command or log changed") + selected.append(record) + path = run_dir / f"{identifier}-{i}-events.jsonl" + if path.exists(): + events += read_events(path) + recalculated = gate_result( + case, + selected, + events, + read_json(run_dir / "script-tests.json") + if identifier == "SECURITY-SCRIPTS" + else None, + ) + else: + group = catalog[identifier] + directory = run_dir / identifier + report = read_json(directory / REPORTS[group["runner"]]) + recalculated = native_result( + case, + group, + report, + directory, + peers=peers, + source=source, + image_digest=digest, + ) + if recalculated != actual[identifier] or recalculated["status"] != "PASS": + raise ValueError("SECURITY results differ from independent evidence") + fields = fields_report(cases, results) + if read_json(run_dir / "fields.json") != fields or any( + f["status"] != "PASS" for f in fields["fields"] + ): + raise ValueError("missing SECURITY field behavior coverage") + print(f"SECURITY: PASS ({len(cases)} required groups; {len(FIELDS)} field rows)") diff --git a/scripts/src/vcore_scripts/protocol_security_catalog.py b/scripts/src/vcore_scripts/protocol_security_catalog.py new file mode 100644 index 0000000..e2cbb9a --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_security_catalog.py @@ -0,0 +1,331 @@ +"""Selected security requirements, independent of runtime results. + +This is an explicit covering set, not a Cartesian-product support claim. Encryption, +hybrid REALITY and JLS have dedicated reports; this suite exercises their critical +consumers together with static ECH and composition checks. +""" + +from __future__ import annotations + +FIELDS = {"VL06", "S03", "S04", "S05", "S12", "S13", "D16", "D17", "D18", "D25", "D26"} +ECH_FIELDS = ["S04", "S05", "D17", "D18"] +JLS_FIELDS = ["S12", "S13", "D25", "D26"] +HYBRID_FIELDS = ["S03", "D16"] +GATES = { + "SECURITY-CFG", + "SECURITY-RELEASE", + "SECURITY-REGRESSION", + "SECURITY-QUALITY", + "SECURITY-FEATURES", + "SECURITY-SCRIPTS", + "SECURITY-PLATFORMS", +} +PROFILES = ("chrome", "chrome120", "firefox", "safari") +ENCRYPTION = tuple( + f"{style}-{rtt}-mixed" + for style in ("native", "xorpub", "random") + for rtt in ("1rtt", "0rtt") +) + + +def groups(): + from .protocol_ech import catalog as ech_catalog + from .protocol_jls import catalog as jls_catalog + + result = {} + + def add(name, runner, rows, **options): + result[name] = dict(runner=runner, rows=rows, **options) + + add( + "SECURITY-ECH-STANDARD", + "vless", + ECH_FIELDS, + selected=list(ech_catalog()), + ech=True, + ) + named = [ + "SECURITY-ECH-TCP-TLS-BASE", + "SECURITY-ECH-TCP-TLS-REJECT", + "SECURITY-ECH-WS-TLS-BASE", + "SECURITY-ECH-GRPC-TLS-BASE", + "SECURITY-ECH-GRPC-TLS-CLOSE", + "SECURITY-ECH-TCP-MTLS-IDENTITY", + "SECURITY-ECH-XHTTP-STREAM-UP-DOWNLOAD-BASE", + "SECURITY-ECH-XHTTP-STREAM-UP-DOWNLOAD-REJECT", + "SECURITY-ECH-XHTTP-STREAM-UP-DOWNLOAD-DOWNLOAD", + "SECURITY-ECH-XHTTP-STREAM-UP-DOWNLOAD-H1-BASE", + "SECURITY-ECH-XHTTP-STREAM-UP-DOWNLOAD-H1-DOWNLOAD", + ] + for profile in PROFILES: + add( + "SECURITY-ECH-" + profile.upper(), + "vless", + ECH_FIELDS, + selected=named, + ech=True, + client_fingerprint=profile, + ) + + add( + "SECURITY-JLS-RETAINED", + "vless", + JLS_FIELDS, + jls=True, + selected=[ + key + for key, (_, mode, _, test) in jls_catalog().items() + if mode + in { + "tcp-tls", + "grpc-tls", + "xhttp-stream-up-download", + "xhttp-stream-up-download-h1", + } + and test + in { + "public_base", + "native_jls_fail_closed", + "public_jls_download_identity", + "runtime::owned_resources", + "runtime::public_lifecycle", + "native_mihomo_close_alignment", + } + ], + ) + add( + "SECURITY-HYBRID-RETAINED", + "hybrid", + HYBRID_FIELDS, + selected=[ + f"{profile}-{mode}-{test}" + for profile in ("none", "chrome") + for mode in ("tcp", "h1-stream-up", "h2-stream-up") + for test in ("base", "security") + ] + + [ + "chrome-vision-inner-tls", + "chrome-h2-stream-up-owned", + "chrome-h2-stream-up-life", + "chrome-h1-main-classic", + "chrome-h2-download-classic", + ], + ) + + for profile in ENCRYPTION: + suffix = profile.upper() + add( + "SECURITY-ECH-ENCRYPTION-" + suffix, + "vless", + ["VL06", *ECH_FIELDS], + ech=True, + encryption=profile, + client_fingerprint="chrome", + selected=[ + "SECURITY-ECH-TCP-TLS-BASE", + "SECURITY-ECH-XHTTP-STREAM-UP-DOWNLOAD-BASE", + "SECURITY-ECH-XHTTP-STREAM-UP-DOWNLOAD-H1-BASE", + ], + ) + add( + "SECURITY-JLS-ENCRYPTION-" + suffix, + "vless", + ["VL06", *JLS_FIELDS], + jls=True, + encryption=profile, + selected=[ + "SECURITY-JLS-TCP-BASE", + "SECURITY-JLS-XHTTP-STREAM-UP-DOWNLOAD-BASE", + ], + ) + add( + "SECURITY-HYBRID-ENCRYPTION-" + suffix, + "hybrid", + ["VL06", *HYBRID_FIELDS], + encryption=profile, + selected=[ + "chrome-tcp-base", + "chrome-vision-base", + "chrome-h2-stream-up-base", + ], + ) + + def fields(name, rows, specs, **options): + jobs = {} + for variant, tests in specs: + jobs[variant] = [ + dict(case_id=variant + "-" + str(i), test=test) + for i, test in enumerate(tests) + ] + add(name, "xhttp", rows, jobs=jobs, **options) + + specs = [ + (f"h3-{mode}-headers", ["public_base"]) + for mode in ("stream-one", "stream-up", "packet-up") + ] + specs += [ + ( + f"h3-ech-{action}", + ["security::native_xhttp_security"] + if action.startswith("reject") + else ["public_base"], + ) + for action in ("reject-main", "reject-download", "replace", "clear") + ] + specs += [ + ( + "h3-stream-up-reuse", + [ + "runtime::public_graph", + "runtime::public_entrypoints", + "runtime::public_ipv6_and_gates", + "runtime::public_udp_isolation", + "runtime::public_lifecycle", + "lifecycle::native_xhttp_owned_resources", + ], + ), + ("h3-keepalive", ["keepalive::native_h3_keepalive_observes_each_leg_and_stop"]), + ] + fields("SECURITY-ECH-H3", ECH_FIELDS, specs, ech=True) + for security in ("ech", "jls"): + versions = ("h1", "h2", "h3") if security == "ech" else ("h1", "h2") + specs = [ + (f"{version}-mux-{mux}-padded{only}", ["public_base"]) + for version in versions + for mux in ("h2mux", "smux", "yamux") + for only in ("", "-only-tcp") + ] + fields( + "SECURITY-" + security.upper() + "-MUX", + ECH_FIELDS if security == "ech" else JLS_FIELDS, + specs, + **{security: True}, + encryption="native-0rtt-mixed", + ) + fields( + "SECURITY-ECH-NATIVE-TRANSPORT", + ECH_FIELDS, + [ + ("outer-" + transport + "-tls", ["public_base"]) + for transport in ("http", "h2", "ws-header", "ws-path") + ], + ech=True, + encryption="native-0rtt-mixed", + ) + + fields( + "SECURITY-ECH-H3-ENCRYPTION", + ["VL06", *ECH_FIELDS], + [ + (f"h3-{mode}-headers", ["public_base"]) + for mode in ("stream-one", "stream-up", "packet-up") + ] + + [("h3-ech-replace", ["public_base"])], + ech=True, + encryption="native-0rtt-mixed", + ) + + add("SECURITY-ENCRYPTION-WIRE", "encryption", ["VL06"]) + add("SECURITY-ENCRYPTION-CHACHA", "encryption", ["VL06"], chacha=True) + add("SECURITY-ENCRYPTION-EXPIRY", "encryption", ["VL06"], expiry=True) + for profile in ("none", "chrome"): + add( + "SECURITY-SHARED-" + profile.upper(), + "vless", + [], + client_fingerprint=profile, + selected=[ + "VLESS-TCP-TLS-BASE", + "VLESS-TCP-TLS-NEG", + "VLESS-TCP-REALITY-BASE", + "VLESS-TCP-REALITY-NEG", + "VLESS-VISION-REALITY-BASE", + "VLESS-GRPC-TLS-BASE", + "VLESS-HTTP-TLS-BASE", + "VLESS-H2-TLS-BASE", + "VLESS-REGRESSION-TROJAN-TCP", + "VLESS-REGRESSION-VMESS-GRPC", + "FINGERPRINT-ANYTLS", + ], + ) + fields( + "SECURITY-SHARED-XHTTP", + [], + [ + ("h1-stream-up-split-reuse", ["public_base"]), + ("h2-stream-up-split-reuse", ["public_base"]), + ("h3-stream-up-split-reuse", ["public_base"]), + ("h2r-stream-up-split-reuse", ["public_base"]), + ("h3-mux-yamux-padded", ["public_base"]), + ], + ) + return result + + +def definitions(): + native = groups() + result = [] + for identifier in sorted(GATES | native.keys()): + group = native.get(identifier, {}) + runner = group.get("runner", "gate") + rows = sorted( + FIELDS + if identifier in {"SECURITY-CFG", "SECURITY-RELEASE"} + else group.get("rows", []) + ) + options = {k: v for k, v in group.items() if k not in {"rows", "runner"}} + kind = ( + "unit" + if not group + else ( + "XR" if "H3" in identifier or "NATIVE-TRANSPORT" in identifier else "M" + ) + ) + result.append( + dict( + case_id=identifier, + stage="SECURITY", + substage="SECURITY.acceptance", + required=True, + row_ids=rows, + protocol="vless", + network="explicit-selected-compositions", + security="static-ech/encryption/hybrid-reality/jls", + udp_codec="raw/xudp/packetaddr/sing-mux", + field_values=options, + outer_family="IPv4/IPv6", + inner_family="IPv4/IPv6/domain", + target_type=( + "memory-only/build" + if kind == "unit" + else "isolated synthetic origin" + ), + upstream_graph="direct/concrete/nested-select", + peer_kind=kind, + peer_config=dict(runner=runner, **options), + gap_source=dict( + reason="Mihomo v1.19.31 Safari ECH fails before business data. " + "Only the gRPC close reference uses Chrome with the same ECH " + "listener; VCore remains Safari, not a same-profile comparison.", + source="https://github.com/MetaCubeX/utls/blob/v1.8.7/u_handshake_client.go", + ) + if identifier == "SECURITY-ECH-SAFARI" + else None + if kind != "XR" + else dict( + reason="Mihomo has no H3 or legacy HTTP/H2/extended WS listener; " + "official Xray/V2Ray terminate that transport, with " + "Mihomo VLESS for packetaddr, Encryption and sing-mux.", + source="https://github.com/XTLS/Xray-core", + ), + expected_observation=[identifier], + required_evidence=["command", "cleanup", "structured-assertions"] + + ([] if kind == "unit" else ["peer-identity", "isolated-origins"]), + prerequisites=["rust-toolchain"] + if kind == "unit" + else ["owned-host-only-container-network", "official-latest-peer"], + runner="vless-security-" + runner, + timeout_seconds=3600, + ) + ) + return result diff --git a/scripts/src/vcore_scripts/protocol_security_units.py b/scripts/src/vcore_scripts/protocol_security_units.py new file mode 100644 index 0000000..b49398c --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_security_units.py @@ -0,0 +1,42 @@ +# Exact configuration and in-memory TLS assertion identities for SECURITY. +TESTS = { + "ech_config": [ + "static_ech_builds_both_tls_backends_without_dns_or_io", + "static_ech_is_strict_and_never_enables_dynamic_lookup", + "unsupported_ech_configs_fail_at_parse_before_a_client_can_emit_inner_sni", + "ech_selection_skips_unknown_or_mandatory_configs_and_keeps_exact_supported_bytes", + "download_ech_inherits_replaces_or_clears_as_a_whole", + "ech_is_standard_tls_only_with_explicit_download_clear_and_dns_inner_name", + ], + "ech_tls": [ + "rejected_or_wrong_key_ech_never_reaches_application_data_even_with_a_matching_pin", + "client_identity_is_sent_only_after_ech_acceptance", + "cancelled_ech_emits_only_public_sni_and_releases_io_for_every_backend", + "each_backend_and_hpke_suite_requires_real_ech_acceptance_before_data", + ], + "encryption_config": [ + "public_encryption_accepts_all_six_modes_without_exposing_key_material", + "vision_encryption_is_independent_of_outer_tls_but_keeps_transport_and_udp_limits", + ], + "reality_config": [ + "public_reality_accepts_explicit_hybrid_without_replacing_the_fingerprint", + "public_reality_rejects_profiles_without_the_required_hybrid_share", + "download_inherits_or_replaces_the_entire_reality_object_without_leaf_merging", + "hybrid_flag_is_a_strict_nonnullable_boolean_on_both_legs", + "classic_default_and_explicit_false_preserve_all_existing_profiles", + "inherited_profile_is_validated_after_download_reality_replacement", + "hybrid_reality_cannot_be_combined_with_h3_or_plaintext_on_either_leg", + ], + "jls_config": [ + "public_jls_builds_an_authenticated_security_client_without_exposing_credentials", + "independent_download_inherits_jls_identity_and_applies_its_own_tls_fields", + "download_jls_credentials_are_replaced_as_a_whole_or_explicitly_cleared", + "malformed_or_partial_credentials_never_inherit_a_missing_secret", + "invalid_jls_objects_do_not_echo_credentials_in_public_configuration_errors", + "jls_rejects_incompatible_security_at_configuration_time", + "download_security_switch_requires_explicit_clearing_of_inherited_identity", + "selected_profiles_build_jls_without_changing_its_identity", + "cancelled_jls_handshakes_release_supplied_io_and_never_reuse_randoms", + "jls_security_client_rejects_ordinary_tls_before_application_data", + ], +} diff --git a/scripts/src/vcore_scripts/protocol_streams.py b/scripts/src/vcore_scripts/protocol_streams.py new file mode 100644 index 0000000..30e6202 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_streams.py @@ -0,0 +1,310 @@ +"""Transport-only native cases; synthetic VLESS is not public YAML support.""" + +from __future__ import annotations + +import contextlib +import json +import socket +import socketserver +import subprocess +import tempfile +import threading +import time +from pathlib import Path + +from .builds import CORE_DIR +from .mihomo_isolation import reserve_port +from .protocol_fixtures import certificates +from .protocol_peers import OwnedProcess, run_command +from .protocol_preflight import preflight + +CLIENT_ID = "b831381d-6324-4d53-ad4f-8cda48b30811" # Public synthetic identity. +GREETING, TRAILER = b"FOUNDATIONS-server-first\n", b"FOUNDATIONS-half-close\n" +STREAM_CASES = { + "FOUNDATIONS-M-WS": ("M", "ws"), + "FOUNDATIONS-M-WSS": ("M", "wss"), + "FOUNDATIONS-M-GRPC": ("M", "grpc"), + "FOUNDATIONS-M-GRPC-TLS": ("M", "grpc-tls"), + "FOUNDATIONS-M-WS-ED": ("M", "ws-header"), + "FOUNDATIONS-V2-HTTP": ("V2", "http"), + "FOUNDATIONS-V2-H2": ("V2", "h2"), + "FOUNDATIONS-V2-WS-HEADER": ("V2", "ws-header"), + "FOUNDATIONS-V2-WS-PATH": ("V2", "ws-path"), +} + + +def abnormal_cleanup(binary: Path, directory: Path, cert: Path, key: Path) -> dict: + """A real native peer must be joined even when its calling case unwinds.""" + + class InjectedFailure(Exception): + pass + + record = {} + with contextlib.ExitStack() as stack: + port, reservation = reserve_port(stack) + config = directory / "cleanup-peer.json" + config.write_text(json.dumps(peer_config("M", "ws", port, cert, key))) + reservation.release_ipv4() + owner = OwnedProcess( + [str(binary), "-d", str(directory), "-f", str(config)], + directory / "cleanup-peer.log", + record, + ) + try: + with owner: + owner.wait_tcp(port) + raise InjectedFailure + except InjectedFailure: + pass + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as rebind: + rebind.bind(("127.0.0.1", port)) + if not record.get("joined") or owner.process.poll() is None: + raise RuntimeError("native abnormal cleanup failed") + return { + "passed": True, + "kind": "M", + "failure_injected": True, + "joined": True, + "port_rebound": True, + } + + +class Echo(socketserver.BaseRequestHandler): + def handle(self): + self.server.accepted += 1 + self.request.settimeout(15) + try: + self.request.sendall(GREETING) + while data := self.request.recv(8192): + self.server.received += len(data) + self.request.sendall(data) + if self.server.received >= 65536: + break + self.request.sendall(TRAILER) + except OSError: + self.server.failed = True + finally: + self.server.finished.set() + + +@contextlib.contextmanager +def origin(): + with socketserver.TCPServer(("127.0.0.1", 0), Echo) as server: + server.accepted, server.received, server.failed = 0, 0, False + server.finished = threading.Event() + thread = threading.Thread(target=server.serve_forever) + thread.start() + try: + yield server + finally: + server.shutdown() + thread.join(timeout=20) + if thread.is_alive(): + raise RuntimeError("origin cleanup failed") + + +def peer_config(kind, mode, port, cert, key): + encrypted = mode in {"wss", "grpc-tls", "h2"} + if kind == "M": + listener = { + "name": "foundations-stream", + "type": "vless", + "listen": "127.0.0.1", + "port": port, + "users": [{"uuid": CLIENT_ID}], + } + if encrypted: + listener.update(certificate=str(cert), **{"private-key": str(key)}) + else: + listener["allow-insecure"] = True # Explicit loopback-only plaintext. + if mode.startswith("ws"): + listener["ws-path"] = "/foundations-ws" + if mode.startswith("grpc"): + listener["grpc-service-name"] = "foundations-grpc" + return { + "mode": "rule", + "log-level": "silent", + "allow-lan": False, + "bind-address": "127.0.0.1", + "listeners": [listener], + "rules": ["MATCH,DIRECT"], + } + stream = {"network": "tcp", "security": "tls" if encrypted else "none"} + if mode == "http": + stream["tcpSettings"] = { + "header": {"type": "http", "request": {"path": ["/foundations-http"]}} + } + elif mode == "h2": + stream.update( + network="http", + httpSettings={"host": ["localhost"], "path": "/foundations-h2"}, + ) + else: + stream.update( + network="ws", + wsSettings={ + "path": "/foundations-ws", + "maxEarlyData": 2048, + "earlyDataHeaderName": "x-vcore-ed" if mode == "ws-header" else "", + }, + ) + if encrypted: + stream["tlsSettings"] = { + "alpn": ["h2"], + "certificates": [{"certificateFile": str(cert), "keyFile": str(key)}], + } + return { + "log": {"loglevel": "none"}, + "inbounds": [ + { + "listen": "127.0.0.1", + "port": port, + "protocol": "vless", + "settings": {"clients": [{"id": CLIENT_ID}], "decryption": "none"}, + "streamSettings": stream, + } + ], + "outbounds": [{"protocol": "freedom", "settings": {}}], + } + + +def run_streams(output: Path, selected: list[str] | None = None, *, artifacts=None): + selected = list(STREAM_CASES) if selected is None else selected + if ( + not selected + or len(set(selected)) != len(selected) + or not set(selected) <= STREAM_CASES.keys() + ): + raise RuntimeError("invalid native stream case selection") + output.mkdir(parents=True, exist_ok=False) + report = { + "scope": "FOUNDATIONS-shared-transport-only", + "cases": [], + "peers": [], + "cleanup": False, + } + try: + kinds = {STREAM_CASES[case][0] for case in selected} + if artifacts is None: + artifacts, report["preflight"] = preflight(output / "binaries", kinds) + peers = {kind: item.binary for kind, item in artifacts.items() if kind in kinds} + report["peers"] = [ + item.identity for kind, item in artifacts.items() if kind in kinds + ] + with tempfile.TemporaryDirectory(prefix="private-", dir=output) as temporary: + temporary = Path(temporary) + cert, key, pin = certificates(temporary) + if "M" in peers: + report["abnormal_cleanup"] = abnormal_cleanup( + peers["M"], temporary, cert, key + ) + for case_id in selected: + kind, mode = STREAM_CASES[case_id] + record = { + "case_id": case_id, + "peer_kind": kind, + "mode": mode, + "status": "NOT RUN", + "cleanup": {}, + } + report["cases"].append(record) + if kind not in peers: + record.update( + status="BLOCKED", + reason="required peer unavailable", + cleanup={"joined": True, "started": False}, + ) + continue + started = time.monotonic() + try: + with contextlib.ExitStack() as stack: + port, reservation = reserve_port(stack) + config = temporary / "peer.json" + config.write_text( + json.dumps(peer_config(kind, mode, port, cert, key)) + ) + command = ( + [str(peers[kind]), "-d", str(temporary), "-f", str(config)] + if kind == "M" + else [str(peers[kind]), "run", "-c", str(config)] + ) + reservation.release_ipv4() + peer = stack.enter_context( + OwnedProcess( + command, temporary / "peer.log", record["cleanup"] + ) + ) + peer.wait_tcp(port) + with origin() as echo: + fixture = { + "mode": mode, + "server": f"127.0.0.1:{port}", + "target": f"127.0.0.1:{echo.server_address[1]}", + "pin": pin, + "ed_header": "sec-websocket-protocol" + if kind == "M" + else "x-vcore-ed", + } + probe_config = temporary / "probe.json" + probe_config.write_text(json.dumps(fixture)) + completed = run_command( + [ + str( + CORE_DIR + / "target/debug/examples/protocol-stream-probe" + ), + str(probe_config), + ], + timeout=20, + limit=65536, + ) + result = json.loads(completed.stdout) + record["rust_event"] = result + record["command_exit_code"] = completed.returncode + record["origin"] = { + "finished": echo.finished.wait(3), + "accepted": echo.accepted, + "received": echo.received, + "failed": echo.failed, + } + peer.ensure_alive() + record["status"] = ( + "PASS" + if completed.returncode == 0 + and result["outcome"] == "pass" + and result["driver_joined"] + and result["protect_calls"] == 1 + and result.get("resources_idle") is True + and result.get("server_first") is True + and result.get("payload_bytes") == 65536 + and result.get("tail_bytes") == len(TRAILER) + and completed.cleanup + and record["origin"] + == { + "finished": True, + "accepted": 1, + "received": 65536, + "failed": False, + } + else "FAIL" + ) + except ( + OSError, + ValueError, + RuntimeError, + subprocess.SubprocessError, + ) as error: + record.update(status="FAIL", reason=type(error).__name__) + finally: + record["seconds"] = round(time.monotonic() - started, 3) + if not record["cleanup"].get("joined"): + record["status"] = "FAIL" + print(json.dumps(record), flush=True) + report["cleanup"] = True + finally: + (output / "stream-cases.json").write_text(json.dumps(report, indent=2) + "\n") + if not report["cleanup"] or any( + case["status"] != "PASS" for case in report["cases"] + ): + raise RuntimeError("native stream cases did not pass; see structured report") + return report diff --git a/scripts/src/vcore_scripts/protocol_trojan.py b/scripts/src/vcore_scripts/protocol_trojan.py new file mode 100644 index 0000000..f44ae82 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_trojan.py @@ -0,0 +1,363 @@ +"""TROJAN public Trojan consumer acceptance against owned official native peers.""" + +from __future__ import annotations + +import contextlib +import json +import os +import tempfile +from pathlib import Path + +from .builds import CORE_DIR +from .mihomo_isolation import reserve_port +from .protocol_evidence import idle_resources, read_events +from .protocol_fixtures import certificate_chain, certificates +from .protocol_fixtures import trojan_peer_config as peer_config +from .protocol_inputs import redact, source_identity +from .protocol_peers import OwnedProcess, run_command +from .protocol_preflight import preflight + +CASES = { + "TROJAN-M-TCP": ("M", "tcp", "public_trojan_native_base"), + "TROJAN-M-WS": ("M", "ws", "public_trojan_native_base"), + "TROJAN-M-GRPC": ("M", "grpc", "public_trojan_native_base"), + "TROJAN-M-WS-ED-1": ("M", "ws-ed-1", "public_trojan_native_base"), + "TROJAN-M-WS-ED-2048": ("M", "ws-ed-2048", "public_trojan_native_base"), + "TROJAN-M-WS-POLICY": ( + "M", + "ws", + "public_trojan_native_policy_and_group_snapshots", + ), + "TROJAN-M-GRPC-POLICY": ( + "M", + "grpc", + "public_trojan_native_policy_and_group_snapshots", + ), + "TROJAN-M-WS-NEGATIVE": ("M", "ws", "public_trojan_native_transport_negative"), + "TROJAN-M-GRPC-NEGATIVE": ("M", "grpc", "public_trojan_native_transport_negative"), + "TROJAN-M-ALPN-NEGATIVE": ( + "M", + "ws-alpn", + "public_trojan_native_transport_negative", + ), + "TROJAN-V2-WS-HEADER": ( + "V2", + "ws-header", + "public_trojan_native_extended_early_data", + ), + "TROJAN-V2-WS-PATH": ("V2", "ws-path", "public_trojan_native_extended_early_data"), + "TROJAN-M-TCP-POLICY": ( + "M", + "tcp", + "public_trojan_native_policy_and_group_snapshots", + ), + "TROJAN-XR-UDP-DOMAIN": ("XR", "tcp", "public_trojan_native_udp_domain"), + "TROJAN-XR-WS-UDP-DOMAIN": ("XR", "ws", "public_trojan_native_udp_domain"), + "TROJAN-XR-GRPC-UDP-DOMAIN": ("XR", "grpc", "public_trojan_native_udp_domain"), +} +for _mode in ("tcp", "ws", "grpc"): + for _suffix, _test in ( + ("LIFE", "runtime::public_trojan_native_lifecycle"), + ("ENTRYPOINTS", "runtime::public_trojan_native_entrypoints"), + ("UDP-ISOLATION", "runtime::public_trojan_native_udp_isolation_and_limit"), + ): + CASES[f"TROJAN-M-{_mode.upper()}-{_suffix}"] = ("M", _mode, _test) + CASES[f"TROJAN-M-{_mode.upper()}-IPV6"] = ( + "M", + _mode + "-ipv6", + "public_trojan_native_base", + ) + CASES[f"TROJAN-M-{_mode.upper()}-OWNED"] = ( + "M", + _mode, + "trojan_native_owned_resources", + ) + CASES[f"TROJAN-M-{_mode.upper()}-CERTIFICATE"] = ( + "M", + _mode + "-ca", + "runtime::public_trojan_native_certificate_names", + ) +CASES["TROJAN-M-GRPC-CUSTOM"] = ("M", "grpc-custom", "public_trojan_native_base") + + +def native_events_pass(events, test): + suites = {"TROJAN-NATIVE"} + if test == "runtime::public_trojan_native_lifecycle": + suites.add("TROJAN-LIFE-CYCLE") + elif test == "trojan_native_owned_resources": + suites.add("TROJAN-OWNED-CYCLE") + main = [event for event in events if event.get("suite") == "TROJAN-NATIVE"] + if ( + len(main) != 2 + or any( + event.get("assertion") != test or event.get("schema_version") != 1 + for event in main + ) + or [event.get("status") for event in main] != ["BEGIN", "PASS"] + ): + return False + if test in { + "runtime::public_trojan_native_lifecycle", + "trojan_native_owned_resources", + }: + suite = ( + "TROJAN-OWNED-CYCLE" + if test == "trojan_native_owned_resources" + else "TROJAN-LIFE-CYCLE" + ) + cycles = [event for event in events if event.get("suite") == suite] + if len(cycles) != 40: + return False + for begin, end in zip(cycles[::2], cycles[1::2], strict=True): + if ( + begin.get("status") != "BEGIN" + or end.get("status") != "PASS" + or end.get("seconds", 0) < 5 + or any( + event.get("assertion") != "stop_and_remain_quiet" + or event.get("schema_version") != 1 + for event in (begin, end) + ) + ): + return False + if suite == "TROJAN-OWNED-CYCLE": + points = end.get("checkpoints", []) + phases = {point["phase"]: point["resources"] for point in points} + if ( + len(points) != 3 + or set(phases) != {"baseline", "after-stop", "quiet"} + or not all(idle_resources(value) for value in phases.values()) + or phases["after-stop"] != phases["quiet"] + or not idle_resources(end.get("resources")) + ): + return False + return all( + event.get("status") in {"BEGIN", "PASS"} + and event.get("schema_version") == 1 + and event.get("suite") in suites + for event in events + ) + + +def run(output: Path, selected=None, *, artifacts=None): + selected = list(CASES) if selected is None else selected + if ( + not selected + or len(selected) != len(set(selected)) + or not set(selected) <= CASES.keys() + ): + raise ValueError("invalid TROJAN case selection") + output.mkdir(parents=True, exist_ok=False) + report = { + "stage": "TROJAN", + "scope": "protocol-consumer", + "source": source_identity(), + "selected_cases": selected, + "cases": [], + "cleanup": False, + } + try: + if artifacts is None: + artifacts, report["preflight"] = preflight( + output / "binaries", {CASES[case][0] for case in selected} + ) + for case_id in selected: + kind, mode, test = CASES[case_id] + record = { + "case_id": case_id, + "mode": mode, + "cleanup": {}, + "status": "NOT RUN", + } + report["cases"].append(record) + if kind not in artifacts: + record.update( + status="BLOCKED", reason="required official native peer unavailable" + ) + continue + record["peer"] = artifacts[kind].identity + print(f"TROJAN: {case_id}", flush=True) + with ( + tempfile.TemporaryDirectory(prefix="private-", dir=output) as directory, + contextlib.ExitStack() as stack, + ): + directory = Path(directory) + cert, key, pin = ( + certificate_chain if mode.endswith("-ca") else certificates + )(directory) + port, reservation = reserve_port(stack) + password = " synthetic TROJAN 密码 " + config = peer_config(kind, mode, port, password, cert, key) + path = directory / "peer.json" + path.write_text(json.dumps(config)) + node = { + "name": "peer", + "type": "trojan", + "server": "127.0.0.1", + "port": port, + "password": password, + "udp": True, + "sni": "localhost", + "fingerprint": pin, + } + if mode.startswith("ws"): + node.update( + network="ws", + **{ + "ws-opts": { + "path": "/trojan-ws?q=1", + "headers": { + "Host": "cover.example:443", + "X-TROJAN": "fixture", + }, + } + }, + ) + if mode.startswith("grpc"): + node.update( + network="grpc", + **{"grpc-opts": {"grpc-service-name": "trojan-grpc"}}, + ) + if mode.startswith("ws") and kind != "M": + node["ws-opts"]["path"] = "/trojan-ws/" + if mode in {"ws-ed-1", "ws-ed-2048", "ws-header", "ws-path"}: + node["ws-opts"]["max-early-data"] = 1 if mode == "ws-ed-1" else 2048 + if mode in {"ws-header", "ws-path"}: + node["ws-opts"]["early-data-header-name"] = ( + "x-vcore-ed" if mode == "ws-header" else "" + ) + if mode == "ws-alpn": + node["alpn"] = ["h2", "http/1.1"] + if mode == "grpc-custom": + node["grpc-opts"]["grpc-service-name"] = "/trojan-grpc/Tun" + if mode.endswith("-ipv6"): + node["server"] = "::1" + command = ( + [str(artifacts[kind].binary), "-d", str(directory), "-f", str(path)] + if kind == "M" + else [str(artifacts[kind].binary), "run", "-c", str(path)] + ) + if mode.endswith("-ipv6"): + reservation.release_ipv6() + else: + reservation.release_ipv4() + with OwnedProcess( + command, directory / "peer.log", record["cleanup"] + ) as peer: + peer.wait_tcp(port, host=node["server"]) + hop = None + if test == "public_trojan_native_policy_and_group_snapshots": + # Two independently owned peers, not one process proxying + # back into its own loopback detector. + hop_dir = directory / "hop" + hop_dir.mkdir() + hop_cert, hop_key, hop_pin = certificates(hop_dir) + hop_port, hop_reservation = reserve_port(stack) + hop_path = hop_dir / "peer.json" + hop_path.write_text( + json.dumps( + peer_config( + kind, mode, hop_port, password, hop_cert, hop_key + ) + ) + ) + record["upstream_cleanup"] = {} + hop_reservation.release_ipv4() + hop_peer = stack.enter_context( + OwnedProcess( + [ + str(artifacts[kind].binary), + "-d", + str(hop_dir), + "-f", + str(hop_path), + ], + hop_dir / "peer.log", + record["upstream_cleanup"], + ) + ) + hop_peer.wait_tcp(hop_port) + hop = dict(node, name="hop", port=hop_port, fingerprint=hop_pin) + env = dict( + os.environ, + VCORE_CASE_EVENTS=str(output / f"{case_id}-events.jsonl"), + VCORE_TROJAN_FIXTURE=json.dumps( + { + "node": node, + "hop": hop, + "mode": mode, + "udp_payload_max": 2048 + if kind == "V2" + else 8166 + if kind == "XR" + else 8192, + "data_dir": str(directory / "core"), + } + ), + ) + target = ( + "trojan_lifecycle" + if test == "trojan_native_owned_resources" + else "mihomo_interop" + ) + test_name = ( + test if target == "trojan_lifecycle" else f"trojan::{test}" + ) + command = [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + target, + test_name, + "--", + "--ignored", + "--exact", + "--nocapture", + ] + result = run_command( + command, + cwd=CORE_DIR, + env=env, + timeout=180, + limit=4 * 1024 * 1024, + ) + (output / f"{case_id}.log").write_text( + redact(result.stdout.decode(errors="replace")) + ) + record.update( + status="PASS" + if result.returncode == 0 + and result.cleanup + and native_events_pass( + read_events(output / f"{case_id}-events.jsonl") + if (output / f"{case_id}-events.jsonl").exists() + else [], + test, + ) + else "FAIL", + exit_code=result.returncode, + command_cleanup=result.cleanup, + seconds=result.seconds, + command=command, + ) + peer.ensure_alive() + report["cleanup"] = all( + case["cleanup"].get("joined") + and case.get("upstream_cleanup", {"joined": True}).get("joined") + for case in report["cases"] + ) + report["source_unchanged"] = ( + source_identity()["source_tree_sha256"] + == report["source"]["source_tree_sha256"] + ) + return ( + 0 + if report["cleanup"] + and report["source_unchanged"] + and all(case["status"] == "PASS" for case in report["cases"]) + else 1 + ) + finally: + (output / "trojan-results.json").write_text(json.dumps(report, indent=2) + "\n") diff --git a/scripts/src/vcore_scripts/protocol_trojan_acceptance.py b/scripts/src/vcore_scripts/protocol_trojan_acceptance.py new file mode 100644 index 0000000..c3120b8 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_trojan_acceptance.py @@ -0,0 +1,329 @@ +"""Trojan stage execution and independent, fail-closed persisted evidence checks.""" + +from __future__ import annotations + +import json + +from .protocol_evidence import HEX, new_result, read_events, read_json, rust_results +from .protocol_preflight import preflight +from .protocol_trojan import native_events_pass +from .protocol_trojan import run as run_native + +# Frozen independently of cases.json: removing or downgrading a case is not signoff. +REQUIRED_IDS = { + "TROJAN-CODEC", + "TROJAN-CFG", + "TROJAN-CANCEL", + "TROJAN-M-TCP", + "TROJAN-M-WS", + "TROJAN-M-GRPC", + "TROJAN-M-WS-ED-1", + "TROJAN-M-WS-ED-2048", + "TROJAN-M-WS-POLICY", + "TROJAN-M-GRPC-POLICY", + "TROJAN-M-WS-NEGATIVE", + "TROJAN-M-GRPC-NEGATIVE", + "TROJAN-M-ALPN-NEGATIVE", + "TROJAN-V2-WS-HEADER", + "TROJAN-V2-WS-PATH", + "TROJAN-M-TCP-POLICY", + "TROJAN-XR-UDP-DOMAIN", + "TROJAN-XR-WS-UDP-DOMAIN", + "TROJAN-XR-GRPC-UDP-DOMAIN", + "TROJAN-M-TCP-LIFE", + "TROJAN-M-TCP-ENTRYPOINTS", + "TROJAN-M-TCP-UDP-ISOLATION", + "TROJAN-M-TCP-IPV6", + "TROJAN-M-TCP-OWNED", + "TROJAN-M-TCP-CERTIFICATE", + "TROJAN-M-WS-LIFE", + "TROJAN-M-WS-ENTRYPOINTS", + "TROJAN-M-WS-UDP-ISOLATION", + "TROJAN-M-WS-IPV6", + "TROJAN-M-WS-OWNED", + "TROJAN-M-WS-CERTIFICATE", + "TROJAN-M-GRPC-LIFE", + "TROJAN-M-GRPC-ENTRYPOINTS", + "TROJAN-M-GRPC-UDP-ISOLATION", + "TROJAN-M-GRPC-IPV6", + "TROJAN-M-GRPC-OWNED", + "TROJAN-M-GRPC-CERTIFICATE", + "TROJAN-M-GRPC-CUSTOM", + "TROJAN-FEATURES", + "TROJAN-SCRIPTS", + "TROJAN-REGRESSION", +} +FIELD_IDS = { + "C01", + "C02", + "C03", + "C04", + "C05", + "C06", + "T01", + "T03", + "T04", + "T05", + "W01", + "W02", + "W03", + "W04", + "W05", + "G01", + "TR01", + "TR02", +} + + +def native_results(cases, report, directory): + records = report.get("cases", []) + if [entry.get("case_id") for entry in records] != [ + case["case_id"] for case in cases + ]: + raise ValueError("missing, duplicate or reordered Trojan native evidence") + results = [] + for case, record in zip(cases, records, strict=True): + path = directory / (case["case_id"] + "-events.jsonl") + events = read_events(path) if path.exists() else [] + observed = native_events_pass(events, case["peer_config"]["test"]) + clean = ( + record.get("command_cleanup") is True + and record.get("cleanup", {}).get("joined") is True + and record.get("upstream_cleanup", {"joined": True}).get("joined") is True + ) + passed = ( + observed + and clean + and record.get("exit_code") == 0 + and record.get("status") == "PASS" + and record.get("mode") == case["peer_config"]["mode"] + and report.get("source_unchanged") is True + ) + result = new_result(case) + result.update( + status="PASS" if passed else "FAIL", + assertions={name: observed for name in case["expected_observation"]}, + evidence=case["required_evidence"] if passed else [], + command_exit_code=record.get("exit_code"), + cleanup=clean, + ) + results.append(result) + return results + + +def fields_report(cases, results): + statuses = {result["case_id"]: result["status"] for result in results} + complete = {case["case_id"] for case in cases} == REQUIRED_IDS + return { + "schema_version": 1, + "stage": "TROJAN", + "protocol": "trojan", + "scope": "protocol-consumer; other protocols remain NOT RUN", + "fields": [ + { + "row_id": row, + "status": "PASS" + if complete + and owners + and all(statuses.get(case_id) == "PASS" for case_id in owners) + else "NOT RUN", + "required_cases": owners, + } + for row in sorted(FIELD_IDS) + for owners in [ + [case["case_id"] for case in cases if row in case["row_ids"]] + ] + ], + "peer_limitations": [ + "Mihomo v1.19.31 domain UDP FAIL is retained; " + "Xray TCP/WS/gRPC supplies separate domain evidence.", + "V2Ray ED supplement UDP path is bounded by its 2048-byte return buffer; " + "8192-byte truncation is retained as peer failure.", + "Xray domain UDP return frame has 8192 total bytes, " + "leaving 8166 payload for the controlled domain; " + "larger peer failure retained.", + ], + } + + +def execute(cases, run, output, records): + from .protocol_harness import _command, _events, _features, _legacy, _scripts + + rust = [case for case in cases if case["runner"] == "rust"] + if rust: + command = _command( + run, + output, + "rust-consumer", + ["cargo", "test", "--locked", "--all-features", "--all-targets"], + 600, + events=output / "rust-events.jsonl", + ) + events = _events(output / "rust-events.jsonl") + for case in rust: + result = rust_results(case, events, command.returncode) + result["cleanup"] = command.cleanup + records[case["case_id"]] = result + kinds = {case["peer_kind"] for case in cases if case["peer_kind"] != "unit"} + artifacts, peers = preflight( + output / "binaries", + kinds, + container=any(case["runner"] == "mihomo-legacy" for case in cases), + ) + (output / "peers.json").write_text(json.dumps(peers, indent=2) + "\n") + native = [case for case in cases if case["runner"] == "native-trojan"] + if native: + directory = output / "native" + run_native(directory, [case["case_id"] for case in native], artifacts=artifacts) + report = read_json(directory / "trojan-results.json") + for result in native_results(native, report, directory): + records[result["case_id"]] = result + for case in cases: + if case["runner"] == "features": + records[case["case_id"]] = _features(case, run, output) + elif case["runner"] == "scripts": + records[case["case_id"]] = _scripts(case, run, output) + elif case["runner"] == "mihomo-legacy": + records[case["case_id"]] = _legacy(case, run, output, artifacts) + (output / "fields.json").write_text( + json.dumps(fields_report(cases, list(records.values())), indent=2) + "\n" + ) + + +def check(run_dir, required, run, results, peers, paths): + from .protocol_harness import SCRIPT_OBSERVATIONS + + necessary = { + "cases.json", + "peers.json", + "resources.jsonl", + "summary.md", + "rust-events.jsonl", + "legacy-events.jsonl", + "script-tests.json", + "native/trojan-results.json", + "fields.json", + } + if not necessary <= set(paths): + raise ValueError("missing Trojan acceptance artifacts") + expected_commands = { + "rust-consumer", + "legacy-mihomo", + "offline-scripts", + "feature-default", + "feature-minimal", + "feature-outbound-trojan", + "feature-outbound-vmess", + "feature-outbound-hysteria2", + "feature-stream-transport", + "feature-quic-transport", + } + commands = run.get("commands", []) + if ( + len(commands) != len(expected_commands) + or {command.get("name") for command in commands} != expected_commands + or any( + type(command.get("exit_code")) is not int + or command["exit_code"] != 0 + or command.get("cleanup") is not True + or (command.get("log") and command["log"] not in paths) + for command in commands + ) + ): + raise ValueError("missing, failed or unjoined Trojan acceptance command") + native = [case for case in required if case["runner"] == "native-trojan"] + report = read_json(run_dir / "native/trojan-results.json") + if ( + report.get("source_unchanged") is not True + or report.get("cleanup") is not True + or report.get("source", {}).get("source_tree_sha256") + != run["source_tree_sha256"] + or report.get("source", {}).get("lock_sha256") != run["lock_sha256"] + ): + raise ValueError("Trojan peer tests used another source input") + for record, case in zip(report.get("cases", []), native, strict=True): + for suffix in (".log", "-events.jsonl"): + if "native/" + case["case_id"] + suffix not in paths: + raise ValueError("native log/events missing immutable identity") + identity = [peer for peer in peers if peer.get("kind") == case["peer_kind"]] + if ( + len(identity) != 1 + or identity[0].get("status") != "READY" + or not identity[0].get("version") + or not HEX.fullmatch(identity[0].get("binary_sha256", "")) + or not HEX.fullmatch(identity[0].get("archive_sha256", "")) + or not identity[0].get("source_url", "").startswith("https://github.com/") + or any( + record.get("peer", {}).get(key) != identity[0].get(key) + for key in ("version", "binary_sha256", "archive_sha256", "source_url") + ) + ): + raise ValueError("Trojan used a peer different from official preflight") + test = case["peer_config"]["test"] + target = ( + "trojan_lifecycle" + if test == "trojan_native_owned_resources" + else "mihomo_interop" + ) + name = test if target == "trojan_lifecycle" else "trojan::" + test + if record.get("command") != [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + target, + name, + "--", + "--ignored", + "--exact", + "--nocapture", + ]: + raise ValueError("Trojan case did not execute its declared public consumer") + calculated = native_results(native, report, run_dir / "native") + rust = read_events(run_dir / "rust-events.jsonl") + legacy = read_events(run_dir / "legacy-events.jsonl") + for case in required: + if case["runner"] in {"rust", "mihomo-legacy"}: + entry = rust_results( + case, legacy if case["runner"] == "mihomo-legacy" else rust, 0 + ) + if case["runner"] == "mihomo-legacy" and entry["status"] == "PASS": + entry["evidence"] = case["required_evidence"] + calculated.append(entry) + actual = {entry["case_id"]: entry for entry in results} + for entry in calculated: + if actual[entry["case_id"]] != entry: + raise ValueError( + "Trojan result disagrees with original structured assertions" + ) + script = read_json(run_dir / "script-tests.json") + tests = {entry["test"]: entry["status"] for entry in script.get("cases", [])} + if ( + not tests + or len(tests) != len(script.get("cases", [])) + or len(tests) != script.get("tests_run") + or any(status != "PASS" for status in tests.values()) + or any(tests.get(name) != "PASS" for name in SCRIPT_OBSERVATIONS.values()) + or not any("test_protocol_trojan." in name for name in tests) + ): + raise ValueError("Trojan harness failure paths were not exercised") + mihomo = next(peer for peer in peers if peer["kind"] == "M") + if ( + mihomo.get("container_status") != "READY" + or not HEX.fullmatch( + mihomo.get("container_artifact", {}).get("binary_sha256", "") + ) + or mihomo["container_artifact"].get("release") != mihomo.get("release") + ): + raise ValueError("old regression did not use the same official Mihomo snapshot") + field_report = fields_report(required, results) + if read_json(run_dir / "fields.json") != field_report or any( + row["status"] != "PASS" for row in field_report["fields"] + ): + raise ValueError("incomplete or inconsistent Trojan field report") + print( + f"TROJAN: PASS ({len(required)} required cases, " + f"{len(FIELD_IDS)} Trojan fields); " + "other protocols remain NOT RUN" + ) diff --git a/scripts/src/vcore_scripts/protocol_vless.py b/scripts/src/vcore_scripts/protocol_vless.py new file mode 100644 index 0000000..363f1a3 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_vless.py @@ -0,0 +1,27 @@ +"""VLESS development mode selection, using the same container fixtures as acceptance. + +This entry does not execute local/build gates or sign off the complete stage. +""" + +from pathlib import Path + +from .protocol_vless_container import run as run_cases +from .protocol_vless_peers import MODES +from .protocol_vless_public import CASES + + +def run(output: Path, modes=None): + modes = MODES if modes is None else modes + if not modes or not set(modes) <= set(MODES) or len(set(modes)) != len(modes): + raise ValueError("invalid VLESS development mode") + selected = [case for case, (_, mode, _, _) in CASES.items() if mode in modes] + return run_cases(output, selected) + + +if __name__ == "__main__": + import sys + + from .mihomo_isolation import exclusive_run + + with exclusive_run(): + sys.exit(run(Path(sys.argv[1]).resolve(), sys.argv[2:] or None)) diff --git a/scripts/src/vcore_scripts/protocol_vless_acceptance.py b/scripts/src/vcore_scripts/protocol_vless_acceptance.py new file mode 100644 index 0000000..8b80e85 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_vless_acceptance.py @@ -0,0 +1,652 @@ +"""VLESS container-only public consumer acceptance and independent coverage.""" + +from __future__ import annotations + +import json +import sys + +from .builds import DEFAULT_FEATURES +from .protocol_evidence import HEX, idle_resources, new_result, read_events, read_json +from .protocol_inputs import redact +from .protocol_vless_container import run as native_run +from .protocol_vless_public import PUBLIC, WIRE, events_pass +from .protocol_vmess_acceptance import OBSERVATIONS as PREVIOUS_OBSERVATIONS +from .protocol_vmess_acceptance import SCRIPT_ASSERTIONS + +NATIVE = WIRE | PUBLIC +FIELD_IDS = { + *(f"C{i:02}" for i in range(1, 7)), + *(f"T{i:02}" for i in range(2, 9)), + *(f"W{i:02}" for i in range(1, 8)), + *(f"G{i:02}" for i in range(1, 7)), + *(f"H{i:02}" for i in range(1, 7)), + *(f"VL{i:02}" for i in range(1, 6)), + "S01", + "S02", +} +GATES = { + "VLESS-CFG", + "VLESS-CODEC", + "VLESS-TRANSPORT", + "VLESS-VISION", + "VLESS-REGRESSION", + "VLESS-RELEASE", + "VLESS-FEATURES", + "VLESS-SCRIPTS", + "VLESS-QUALITY", +} +REQUIRED_IDS = set(NATIVE) | GATES +OBSERVATIONS = { + "VLESS-CFG": [ + "inline_client_identity_is_validated_and_redacted_before_io", + "vision_requires_tcp_tls13_and_xudp_before_any_io", + "default_tcp_and_three_udp_encodings_are_accepted_without_io", + "tcp_and_existing_xhttp_reject_mismatched_and_future_options", + "stream_transports_and_explicit_tls_policy_have_strict_public_fields", + "extended_ws_and_grpc_fields_are_scoped_and_bounded", + ], + "VLESS-CODEC": [ + "stopping_before_first_udp_send_wakes_receive_and_releases_io", + "raw_and_packetaddr_consume_wire_and_keep_cancelled_receive_progress", + "udp_cancelled_send_and_bad_response_header_close_owned_io", + "tcp_bad_response_header_poisoning_and_absolute_response_deadline", + "vless_all_handshakes_keep_the_original_deadline_and_join_cancelled_io", + "vless_expired_deadline_and_protect_failure_never_fall_back", + ], + "VLESS-TRANSPORT": [ + "http_camouflage_shutdown_releases_both_directions_without_waiting_for_peer_eof", + "grpc_server_first_flushes_vless_request_without_an_application_write", + "http_upgrade_consumes_early_prefix_and_requires_valid_101_even_fast_open", + "grpc_idle_ping_is_observable_disabled_by_zero_and_joined_at_stop", + "grpc_pool_matches_both_threshold_policies_and_keeps_other_streams_alive", + ], + "VLESS-VISION": [ + "vision_fragmented_headers_content_padding_and_cancelled_reads_keep_raw_tail", + "vision_invalid_uuid_command_and_truncation_poison_the_stream", + "vision_bounded_partial_writes_flush_and_close_preserve_all_plaintext", + "fragmented_server_hello_distinguishes_tls12_tls13_and_non_tls", + "record_boundary_preserves_buffered_plaintext_and_coalesced_raw_tail_and_flush_order", + ], +} +OBSERVATIONS["VLESS-REGRESSION"] = ( + PREVIOUS_OBSERVATIONS["VMESS-REGRESSION"] + + PREVIOUS_OBSERVATIONS["VMESS-CFG"] + + PREVIOUS_OBSERVATIONS["VMESS-CODEC"] + + PREVIOUS_OBSERVATIONS["VMESS-CANCEL"] + + [ + "all_two_hop_protocol_combinations_build_as_connector_graphs", + "direct_download_constructor_reuses_or_requires_the_precise_prepared_endpoint", + "split_node_over_a_proxy_automatically_reuses_its_parent_for_both_legs", + ] +) +OBSERVATIONS["VLESS-RELEASE"] = sum( + ( + OBSERVATIONS[key] + for key in ("VLESS-CFG", "VLESS-CODEC", "VLESS-TRANSPORT", "VLESS-VISION") + ), + [], +) + + +def rows(identifier): + if identifier == "VLESS-CFG": + return sorted(FIELD_IDS) + if identifier == "VLESS-CODEC": + return ["VL01", "VL02", "VL04"] + if identifier == "VLESS-TRANSPORT": + return [*(f"G{i:02}" for i in range(1, 7)), "W06", "W07"] + if identifier == "VLESS-VISION": + return ["VL03", "VL04", "VL05", "S01", "S02"] + if identifier in GATES or identifier.startswith("VLESS-REGRESSION"): + return [] + _, mode, tls, test = NATIVE[identifier] + result = {"VL01", "VL02", "VL04", "VL05"} + if identifier in PUBLIC: + result |= {*(f"C{i:02}" for i in range(1, 6))} + if "graph" in test or "selection" in test: + result.add("C06") + if mode.endswith("-reality"): + result |= {"S01", "S02", "T02"} + elif tls: + result |= {"T02", "T03", "T04", "T05"} + if mode.endswith("-mtls"): + result |= {"T06", "T07", "T08"} + if mode.startswith("vision-"): + result.add("VL03") + if mode.startswith(("ws", "upgrade")): + result |= {*(f"W{i:02}" for i in range(1, 6))} + if mode.startswith("upgrade"): + result |= {"W06", "W07"} + if mode.startswith("grpc"): + result |= {*(f"G{i:02}" for i in range(1, 7))} + if mode.startswith("http"): + result |= {"H01", "H02", "H03", "H04"} + if mode.startswith("h2"): + result |= {"H05", "H06"} + return sorted(result) + + +def definitions(): + cases = [] + for identifier in sorted(REQUIRED_IDS): + kind, mode, tls, test = NATIVE.get( + identifier, ("unit", "memory-or-build", False, identifier) + ) + native = identifier in NATIVE + substage = ( + "VLESS.vision" + if mode.startswith("vision-") or identifier == "VLESS-VISION" + else "VLESS.tcp" + if mode in {"tcp", "tcp-tls"} or identifier == "VLESS-CODEC" + else "VLESS.transports" + if mode.startswith(("ws", "grpc", "http", "h2", "upgrade")) + or identifier == "VLESS-TRANSPORT" + else "VLESS.acceptance" + ) + cases.append( + dict( + case_id=identifier, + stage="VLESS", + substage=substage, + required=True, + row_ids=rows(identifier), + protocol="vless", + network=mode, + security="tls" if tls else "plain", + udp_codec="raw/xudp/packetaddr" + if "udp" in test or test == "public_base" + else "per-assertion", + field_values={ + "network": mode, + "tls": tls, + "scope": "protocol-consumer", + "close_reference": "ws-standard-tls-baseline" + if test == "native_ws_reality_close_boundary" + else "same-mode" + if test == "native_mihomo_close_alignment" + else "not-applicable", + }, + outer_family="IPv4/IPv6" if "ipv6" in test else "IPv4", + inner_family="IPv4/IPv6/domain" + if "base" in test or "udp_boundaries" in test + else "per-assertion", + target_type="isolated synthetic origin" if native else "memory-only", + upstream_graph="direct/concrete/nested-select" + if "graph" in test + else "per-assertion", + peer_kind=kind, + peer_config=dict(mode=mode, tls=tls, test=test), + gap_source={ + "reason": "Mihomo WS client ignores RealityOpts; " + "REALITY listener data " + "is native-tested, but close uses the separate standard-TLS WS " + "adapter reference plus independent REALITY close cases, not a " + "same-combination client differential. Original failures retained.", + "source": "https://github.com/MetaCubeX/mihomo/blob/ab405bad5beeeac8b003bb01f60f134f6df54471/adapter/outbound/vless.go", + } + if test == "native_ws_reality_close_boundary" + else { + "reason": "Mihomo VLESS listener has no HTTP header/legacy H2 " + "or custom ED field; official V2Ray supplements only these paths", + "source": "https://github.com/MetaCubeX/mihomo/blob/v1.19.31/listener/inbound/vless.go", + } + if kind == "V2" + else None, + expected_observation=[test] + if native + else SCRIPT_ASSERTIONS + if identifier == "VLESS-SCRIPTS" + else OBSERVATIONS.get(identifier, [identifier]), + required_evidence=["structured-assertions", "command", "cleanup"] + + (["peer-identity", "isolated-origins"] if native else []), + prerequisites=[ + "owned-host-only-container-network", + "official-latest-peer", + ] + if native + else ["rust-toolchain"], + runner="native-vless" if native else "vless-gate", + timeout_seconds=240 if native else 1200, + ) + ) + return cases + + +def commands(identifier): + cargo = ["cargo", "test", "--locked", "--all-features"] + if identifier == "VLESS-CFG": + return [cargo + ["--test", "vless_config"], cargo + ["--lib", "config::"]] + if identifier == "VLESS-CODEC": + return [cargo + ["--test", "vless_codec", "--test", "vless_lifecycle"]] + if identifier == "VLESS-TRANSPORT": + return [cargo + ["--test", "vless_transports", "--test", "grpc_pool"]] + if identifier == "VLESS-VISION": + return [cargo + ["--lib", "vision"]] + if identifier == "VLESS-RELEASE": + return [ + cargo + + [ + "--release", + "--test", + "vless_config", + "--test", + "vless_codec", + "--test", + "vless_lifecycle", + "--test", + "vless_transports", + "--test", + "grpc_pool", + ], + cargo + ["--release", "--lib", "vision"], + ] + if identifier == "VLESS-REGRESSION": + from .protocol_vmess_acceptance import commands as previous + + return ( + previous("VMESS-REGRESSION") + + previous("VMESS-CFG") + + previous("VMESS-CODEC") + + previous("VMESS-CANCEL") + + [ + cargo + + ["--lib", "outbound::vless::outbound::connector_composition_tests::"] + ] + + [ + ["cargo", "test", "--locked", "--no-default-features"] + + features + + [ + "--test", + "feature_foundations", + "vless_yaml_follows_its_own_feature", + "--", + "--exact", + ] + for features in ([], ["--features", "outbound-trojan"]) + ] + ) + if identifier == "VLESS-QUALITY": + from .protocol_vmess_acceptance import commands as previous + + return previous("VMESS-QUALITY") + return [] + + +def gate_result(case, records, events): + result = new_result(case) + good = bool(records) and all( + r.get("exit_code") == 0 and r.get("cleanup") is True for r in records + ) + identifier = case["case_id"] + assertions = {} + if identifier in OBSERVATIONS: + for name in case["expected_observation"]: + observed = [e for e in events if e.get("assertion") == name] + assertions[name] = len(observed) == 2 and [ + e.get("status") for e in observed + ] == ["BEGIN", "PASS"] + good &= ( + bool(assertions) + and all(assertions.values()) + and all( + e.get("schema_version") == 1 and e.get("status") in {"BEGIN", "PASS"} + for e in events + ) + ) + else: + assertions = dict.fromkeys(case["expected_observation"], good) + result.update( + status="PASS" if good else "FAIL", + assertions=assertions, + evidence=case["required_evidence"] if good else [], + command_exit_code=0 if good else 1, + cleanup=all(r.get("cleanup") is True for r in records), + ) + return result + + +def native_results(cases, report, directory): + records = report.get("cases", []) + expected = {case["case_id"] for case in cases} + if len(records) != len(expected) or {r.get("case_id") for r in records} != expected: + raise ValueError("missing, duplicate or unknown VLESS native case") + by_id = {record["case_id"]: record for record in records} + output = [] + for case in cases: + record = by_id[case["case_id"]] + path = directory / (case["case_id"] + "-events.jsonl") + observed = read_events(path) if path.exists() else [] + test = NATIVE[case["case_id"]][3] + observed_ok = ( + events_pass(observed, test, NATIVE[case["case_id"]][1]) + if case["case_id"] in PUBLIC + else observed + == [ + dict( + schema_version=1, suite="VLESS-WIRE", assertion=test, status=status + ) + for status in ("BEGIN", "PASS") + ] + ) + clean = ( + record.get("command_cleanup") is True + and record.get("cleanup") is True + and report.get("cleanup") is True + ) + expected_command = [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "vless_public" if case["case_id"] in PUBLIC else "vless_native", + test, + "--", + "--ignored", + "--exact", + "--nocapture", + ] + good = ( + observed_ok + and clean + and report.get("source_unchanged") is True + and record.get("exit_code") == 0 + and record.get("status") == "PASS" + and record.get("command") == expected_command + and record.get("peer_kind") == case["peer_kind"] + ) + result = new_result(case) + result.update( + status="PASS" if good else "FAIL", + assertions={test: observed_ok}, + evidence=case["required_evidence"] if good else [], + command_exit_code=record.get("exit_code"), + cleanup=clean, + ) + output.append(result) + return output + + +def fields_report(cases, results): + statuses = {result["case_id"]: result["status"] for result in results} + complete = set(statuses) == REQUIRED_IDS and all( + value == "PASS" for value in statuses.values() + ) + fields = [] + for row in sorted(FIELD_IDS): + owners = [c["case_id"] for c in cases if row in c["row_ids"]] + fields.append( + dict( + row_id=row, + required_cases=owners, + status="PASS" if complete and len(owners) > 1 else "NOT RUN", + ) + ) + return dict(schema_version=1, stage="VLESS", protocol="vless", fields=fields) + + +def execute(cases, run, output, results): + from .protocol_harness import _command, _features, _scripts + + for case in cases: + identifier = case["case_id"] + if identifier in NATIVE: + continue + if identifier == "VLESS-FEATURES": + result = _features(case, run, output) + independent = _command( + run, + output, + "feature-outbound-vless", + [ + "cargo", + "check", + "--locked", + "--no-default-features", + "--lib", + "--features", + "outbound-vless", + ], + 1200, + ) + if independent.returncode or not independent.cleanup: + result.update( + status="FAIL", + command_exit_code=independent.returncode, + cleanup=independent.cleanup, + ) + build = _command( + run, + output, + "production-build", + [ + "cargo", + "build", + "--locked", + "--release", + "--no-default-features", + "--features", + DEFAULT_FEATURES, + "--lib", + ], + 1200, + ) + if build.returncode or not build.cleanup: + result.update( + status="FAIL", + command_exit_code=build.returncode, + cleanup=build.cleanup, + ) + results[identifier] = result + elif identifier == "VLESS-SCRIPTS": + results[identifier] = _scripts(case, run, output) + else: + events = [] + start = len(run["commands"]) + for index, argv in enumerate(commands(identifier)): + path = output / f"{identifier}-{index}-events.jsonl" + _command(run, output, f"{identifier}-{index}", argv, 1200, events=path) + if path.exists(): + events.extend(read_events(path)) + results[identifier] = gate_result(case, run["commands"][start:], events) + native = [case for case in cases if case["case_id"] in NATIVE] + if native: + directory = output / "native" + native_run(directory, [case["case_id"] for case in native]) + report = read_json(directory / "vless-results.json") + (output / "peers.json").write_text(json.dumps(report["peers"], indent=2) + "\n") + for result in native_results(native, report, directory): + results[result["case_id"]] = result + (output / "fields.json").write_text( + json.dumps(fields_report(cases, list(results.values())), indent=2) + "\n" + ) + + +def required_command_names(): + # The shared feature smoke includes VMess; VLESS separately adds VLESS. + return { + f"{identifier}-{i}" + for identifier in GATES + for i in range(len(commands(identifier))) + } | { + "production-build", + "offline-scripts", + "feature-default", + "feature-minimal", + "feature-outbound-vless", + *( + "feature-" + name + for name in ( + "outbound-trojan", + "outbound-vmess", + "outbound-hysteria2", + "stream-transport", + "quic-transport", + ) + ), + } + + +def check(run_dir, cases, run, results, peers, paths): + from .protocol_harness import SCRIPT_OBSERVATIONS + + needed = { + "native/vless-results.json", + "fields.json", + "script-tests.json", + "resources.jsonl", + "peers.json", + "cases.json", + "summary.md", + } + if not needed <= set(paths): + raise ValueError("missing VLESS acceptance artifacts") + expected = {c["case_id"]: c for c in definitions()} + if len(cases) != len(expected) or any( + c != expected.get(c["case_id"]) for c in cases + ): + raise ValueError("VLESS frozen case metadata changed") + report = read_json(run_dir / "native/vless-results.json") + source_keys = ( + "parent_commit", + "source_tree_sha256", + "dirty_patch_sha256", + "lock_sha256", + ) + if ( + report.get("source") != {key: run.get(key) for key in source_keys} + or report.get("status") != "PASS" + ): + raise ValueError("VLESS peer evidence input mismatch") + isolation = report.get("isolation", {}) + if ( + isolation.get("host_servers") is not False + or isolation.get("network_mode") != "hostOnly" + or not isolation.get("peers") + or not all( + p.get("joined") is True and p.get("started") is True + for p in isolation["peers"] + ) + ): + raise ValueError("VLESS peers not isolated or cleaned") + if peers != report.get("peers") or set(peers) != {"M", "V2"}: + raise ValueError("missing VLESS official peers") + for kind, peer in peers.items(): + project = "MetaCubeX/mihomo" if kind == "M" else "v2fly/v2ray-core" + if ( + peer.get("kind") != kind + or peer.get("target") != "linux-arm64" + or not peer.get("version") + or not peer.get("source_url", "").startswith( + f"https://github.com/{project}/releases/" + ) + or not all( + HEX.fullmatch(peer.get(key, "")) + for key in ("archive_sha256", "binary_sha256") + ) + ): + raise ValueError("invalid official VLESS peer identity") + actual = {r["case_id"]: r for r in results} + recalculated = native_results( + [c for c in cases if c["case_id"] in NATIVE], report, run_dir / "native" + ) + for case in cases: + identifier = case["case_id"] + argv = commands(identifier) + if not argv: + continue + records = [] + events = [] + for i, command in enumerate(argv): + matching = [r for r in run["commands"] if r["name"] == f"{identifier}-{i}"] + if len(matching) != 1 or matching[0]["command"] != [ + redact(part) for part in command + ]: + raise ValueError("missing VLESS gate command") + records.extend(matching) + path = run_dir / f"{identifier}-{i}-events.jsonl" + if path.exists(): + events.extend(read_events(path)) + recalculated.append(gate_result(case, records, events)) + for item in recalculated: + if item != actual.get(item["case_id"]): + raise ValueError("VLESS results disagree with structured observations") + names = [c["name"] for c in run["commands"]] + needed_commands = required_command_names() + if ( + len(names) != len(set(names)) + or set(names) != needed_commands + or any( + c.get("exit_code") != 0 + or c.get("cleanup") is not True + or c.get("log") not in paths + for c in run["commands"] + ) + ): + raise ValueError("missing or failed VLESS validation command") + for record in run["commands"]: + name = record["name"] + if name == "feature-default": + expected_command = [ + "cargo", + "test", + "--locked", + "--test", + "feature_foundations", + ] + elif name.startswith("feature-"): + expected_command = [ + "cargo", + "check", + "--locked", + "--no-default-features", + "--lib", + ] + if name != "feature-minimal": + expected_command += ["--features", name.removeprefix("feature-")] + elif name == "production-build": + expected_command = [ + "cargo", + "build", + "--locked", + "--release", + "--no-default-features", + "--features", + DEFAULT_FEATURES, + "--lib", + ] + elif name == "offline-scripts": + expected_command = [ + sys.executable, + "-m", + "vcore_scripts.protocol_script_tests", + str(run_dir / "script-tests.json"), + ] + else: + continue + if record["command"] != [redact(part) for part in expected_command]: + raise ValueError("VLESS feature or script command changed") + script = read_json(run_dir / "script-tests.json") + tests = {t["test"]: t["status"] for t in script["cases"]} + if ( + len(tests) != script["tests_run"] + or any(tests.get(name) != "PASS" for name in SCRIPT_OBSERVATIONS.values()) + or any(status != "PASS" for status in tests.values()) + ): + raise ValueError("missing harness failure-path tests") + fields = fields_report(cases, results) + if read_json(run_dir / "fields.json") != fields or any( + f["status"] != "PASS" for f in fields["fields"] + ): + raise ValueError("incomplete VLESS field coverage") + resources = read_events(run_dir / "resources.jsonl") + owned = [ + e + for e in resources + if e.get("suite") == "VLESS-OWNED" and e.get("status") == "PASS" + ] + if len(owned) != 80 or not all(idle_resources(e.get("resources")) for e in owned): + raise ValueError("missing 20-round owned resource evidence per selected mode") + print( + f"VLESS: PASS ({len(cases)} required cases; {len(FIELD_IDS)} VLESS field rows)" + ) diff --git a/scripts/src/vcore_scripts/protocol_vless_container.py b/scripts/src/vcore_scripts/protocol_vless_container.py new file mode 100644 index 0000000..d91120c --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_vless_container.py @@ -0,0 +1,759 @@ +"""VLESS wire cases against official latest peers and entirely isolated origins.""" + +from __future__ import annotations + +import contextlib +import json +import os +import shutil +import ssl +import tempfile +from pathlib import Path + +from .builds import CORE_DIR +from .mihomo_release import download_mihomo +from .native_release import PeerArtifact, download_native +from .protocol_containers import ContainerLab, command +from .protocol_evidence import read_events +from .protocol_fixtures import certificates +from .protocol_inputs import redact, source_identity +from .protocol_peers import run_command +from .protocol_vless_peers import configuration +from .protocol_vless_public import PUBLIC as PUBLIC_CASES +from .protocol_vless_public import WIRE as CASES +from .protocol_vless_public import events_pass +from .protocol_vless_security import client_identities +from .protocol_vmess import peer_config as legacy_peer +from .protocol_vmess_public import node_config as legacy_node + +ALL_CASES = CASES | PUBLIC_CASES +CLOSE_TESTS = { + "native_mihomo_close_alignment", + "native_ws_reality_close_boundary", + "native_vision_direct_close_alignment", +} +CLIENT_FINGERPRINTS = ( + "none", + "chrome", + "chrome120", + "firefox", + "firefox120", + "safari", + "safari16", +) + + +def close_reference(mode, node, config, certificate, private_key, pin): + reference = dict(node, name="reference") + if reference.get("network") == "ws": + options = dict(reference.get("ws-opts", {})) + if options.get("max-early-data", 0) > 0: + # VCore defaults ED to this header; Mihomo's raw option defaults + # to path ED. Explicit empty/custom values must stay unchanged. + options.setdefault("early-data-header-name", "Sec-WebSocket-Protocol") + reference["ws-opts"] = options + scope = "same-mode" + if mode.endswith("-reality") and reference.get("network") == "ws": + # Mihomo's WS client never consumes RealityOpts. Its REALITY listener + # does support WS, so preserve VCore's real peer and compare the WS + # close layer with a separate TLS entrance. Never call this same-mode. + baseline = json.loads(json.dumps(config["listeners"][0])) + baseline.pop("reality-config") + baseline.update( + name="vless-ws-close-baseline", + port=23003, + certificate=str(certificate), + **{"private-key": str(private_key)}, + ) + config["listeners"].append(baseline) + reference.pop("reality-opts") + reference.update(port=23003, fingerprint=pin) + scope = "ws-standard-tls-baseline" + elif mode.endswith("-reality") and reference.get("client-fingerprint") in ( + None, + "", + "none", + ): + reference["client-fingerprint"] = "chrome" + elif ( + reference.get("jls-opts") + and reference.get("network") == "grpc" + and reference.get("client-fingerprint") in (None, "", "none") + ): + # Official Mihomo v1.19.31's gRPC ALPN reader does not recognize the + # ordinary jls-tls ConnectionState type. uTLS JLS does work. Keep the + # VCore node unchanged and label this different-profile close baseline. + reference["client-fingerprint"] = "chrome" + scope = "jls-grpc-chrome-baseline" + elif ( + reference.get("network") == "grpc" + and reference.get("ech-opts", {}).get("enable") is True + and reference.get("client-fingerprint") in ("safari", "safari16") + ): + # Official Mihomo v1.19.31/uTLS rejects Safari ECH before business + # data with "malformed outer client hello". Compare the same ECH + # listener's transport close using Chrome; the VCore node stays Safari. + reference["client-fingerprint"] = "chrome" + scope = "ech-safari-chrome-baseline" + return reference, scope + + +def run( + output: Path, + selected=None, + *, + preflight_only=False, + client_fingerprint=None, + encryption=None, + jls=False, + ech=False, + supplied=None, +): + if client_fingerprint is not None and client_fingerprint not in CLIENT_FINGERPRINTS: + raise ValueError("unsupported named client profile") + public_cases = PUBLIC_CASES | ( + { + "FINGERPRINT-ANYTLS": ("M", "anytls", True, "public_legacy_regression"), + **{ + f"FINGERPRINT-VMESS-{network.upper()}-TLS": ( + "V2", + f"vmess-{network}", + True, + "public_legacy_tcp", + ) + for network in ("http", "h2") + }, + } + if client_fingerprint + else {} + ) + all_cases = CASES | public_cases + if encryption is not None: + from .protocol_encryption import cases as encryption_profiles + from .protocol_encryption_public import catalog + + if encryption not in encryption_profiles(): + raise ValueError("unsupported Encryption fixture") + all_cases = catalog() + public_cases = { + key: value + for key, value in all_cases.items() + if not value[3].startswith("native_") + } + if jls: + from .protocol_jls import catalog + + all_cases = catalog() + public_cases = { + key: value + for key, value in all_cases.items() + if not value[3].startswith("native_") + } + if ech: + if jls: + raise ValueError("ECH and JLS are mutually exclusive") + from .protocol_ech import catalog + + all_cases = catalog() + public_cases = { + key: value + for key, value in all_cases.items() + if not value[3].startswith("native_") + } + selected = list(all_cases) if selected is None else selected + if ( + not selected + or len(set(selected)) != len(selected) + or not set(selected) <= all_cases.keys() + ): + raise ValueError("invalid VLESS native selection") + if client_fingerprint and any(not all_cases[case][2] for case in selected): + raise ValueError("named client profiles require TLS cases") + output.mkdir(parents=True, exist_ok=False) + if preflight_only: + # One listener/origin group per official implementation, not a traffic run. + kinds = {all_cases[case][0] for case in selected} + selected = [ + next(case for case in selected if all_cases[case][0] == kind) + for kind in sorted(kinds) + ] + report = dict( + stage="SECURITY.ech" + if ech + else "SECURITY.jls" + if jls + else "SECURITY.encryption" + if encryption + else "FINGERPRINT-INTEROP" + if client_fingerprint + else "VLESS", + jls=jls, + ech=ech, + encryption_profile=encryption, + client_fingerprint=client_fingerprint, + scope="container-wire-and-public-consumer", + source=source_identity(), + status="NOT RUN", + phase="preflight", + cases=[], + peers={}, + isolation={}, + ) + try: + artifacts = {} + for kind in sorted({all_cases[case][0] for case in selected} | {"M"}): + directory = output / "binaries" / kind + if supplied is not None: + artifacts[kind] = PeerArtifact(*supplied[kind]) + elif kind == "M": + identity = {} + binary = download_mihomo( + "linux-arm64", directory=directory, identity=identity + ) + artifacts[kind] = PeerArtifact(binary, identity) + else: + artifacts[kind] = download_native( + kind, directory, "linux-arm64", defer_version=True + ) + report["peers"][kind] = artifacts[kind].identity + lab = ContainerLab(report["isolation"], mtu=1500) + report["phase"] = "build" + built = run_command( + [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "vless_native", + "--test", + "vless_public", + "--no-run", + ], + timeout=180, + ) + (output / "build.log").write_text(redact(built.stdout.decode(errors="replace"))) + if built.returncode != 0 or not built.cleanup: + raise RuntimeError("container wire build failed") + # Reuse a peer only within one transport; every case gets fresh origin + # controls/ports and sessions. One downloaded release snapshot per run. + groups = {} + for case in selected: + kind, mode, encrypted, test = all_cases[case] + groups.setdefault((kind, mode, encrypted), []).append((case, test)) + for (kind, mode, encrypted), cases in groups.items(): + report["phase"] = "peer-start" + with tempfile.TemporaryDirectory( + prefix="private-", dir=output + ) as temporary: + root = Path(temporary) + with contextlib.ExitStack() as stack: + origin_dir, server_dir = root / "origin", root / "server" + origin_dir.mkdir() + server_dir.mkdir() + shutil.copyfile( + Path(__file__).with_name("container_udp_origin.py"), + origin_dir / "origin.py", + ) + shutil.copyfile( + Path(__file__).with_name("container_close_client.py"), + origin_dir / "close.py", + ) + shutil.copyfile( + Path(__file__).with_name("container_tls_bio.py"), + origin_dir / "tls_bio.py", + ) + artifact = artifacts[kind] + shutil.copy2(artifact.binary, server_dir / "peer") + tag = f"{mode}-{'tls' if encrypted else 'plain'}" + origin_cert, origin_key, origin_pin = certificates(origin_dir) + origin = lab.start( + stack, + origin_dir, + tag + "-origin", + [ + "env", + "VCORE_ISOLATED_ORIGIN=1", + f"VCORE_ORIGIN_CERT=/data/fixture/{origin_cert.name}", + f"VCORE_ORIGIN_KEY=/data/fixture/{origin_key.name}", + "python", + "-B", + "/data/fixture/origin.py", + ], + ) + argv = ( + [ + "/data/fixture/peer", + "-d", + "/data", + "-f", + "/data/fixture/config.json", + ] + if kind == "M" + else [ + "/data/fixture/peer", + "run", + "-c", + "/data/fixture/config.json", + ] + ) + server = lab.start(stack, server_dir, tag + "-server", argv) + + def retain_logs(origin=origin, server=server, tag=tag): + for role, peer in (("origin", origin), ("server", server)): + if peer.log.exists(): + (output / f"{tag}-{role}.log").write_text( + redact( + # Capture before cleanup: live log + # following may buffer short messages. + command("logs", peer.name)[-65536:] + ) + ) + + stack.callback(retain_logs) + version = command( + "exec", + server.name, + "/data/fixture/peer", + "-v" if kind == "M" else "version", + ).strip() + digest = command( + "exec", server.name, "sha256sum", "/data/fixture/peer" + ).split()[0] + if not version or digest != artifact.identity["binary_sha256"]: + raise RuntimeError("container peer identity mismatch") + artifact.identity["version"] = version + # Non-leaf pin still exercises actual certificate name checks. + from .protocol_fixtures import certificate_chain + + cert, key, pin = ( + certificate_chain(server_dir) + if encrypted + else certificates(server_dir) + ) + identities = {} + if mode == "anytls": + node = dict( + name="peer", + type="anytls", + server=server.ipv4, + port=23000, + password="fixture", + sni="localhost", + udp=True, + fingerprint=pin, + ) + config = { + "listeners": [ + { + "name": "anytls", + "type": "anytls", + "listen": "::", + "port": 23000, + "users": {"fixture": "fixture"}, + "certificate": f"/data/fixture/{cert.name}", + "private-key": f"/data/fixture/{key.name}", + } + ], + "ipv6": True, + "log-level": "silent", + "hosts": {"vcore-fixture.test": origin.ipv4}, + "rules": ["MATCH,DIRECT"], + } + elif mode.startswith(("vmess-", "trojan-")): + legacy_mode = mode.removeprefix("vmess-") + node = legacy_node(legacy_mode, True, server.ipv4, pin) + config = legacy_peer( + kind, + legacy_mode, + True, + 23000, + Path("/data/fixture") / cert.name, + Path("/data/fixture") / key.name, + ) + if kind == "M": + config["hosts"] = {"vcore-fixture.test": origin.ipv4} + config["listeners"][0]["listen"] = "::" + else: + config["dns"]["hosts"] = {"vcore-fixture.test": origin.ipv4} + config["inbounds"][0]["listen"] = "::" + else: + node, config = configuration( + mode.removesuffix("-h1") if jls or ech else mode, + server.ipv4, + origin.ipv4, + Path("/data/fixture") / cert.name, + Path("/data/fixture") / key.name, + ) + node["name"] = "peer" + if encrypted and not mode.endswith("-reality"): + node["fingerprint"] = pin + if mode.endswith("-mtls"): + identities = client_identities(server_dir) + node.update(identities["valid"]) + config["listeners"][0].update( + { + "client-auth-type": "require-and-verify", + "client-auth-cert": "/data/fixture/root.pem", + } + ) + if client_fingerprint: + node["client-fingerprint"] = client_fingerprint + extra_fixture = {} + if ech: + from .protocol_ech import configuration as ech_config + + extra_fixture = ech_config(node, config, server_dir, mode) + if jls: + from .protocol_jls import configuration as jls_config + + extra_fixture = jls_config(node, config, origin.ipv4, mode) + if encryption: + from .protocol_encryption_public import ( + configuration as encrypted_config, + ) + + encrypted_config(encryption, node, config) + if mode == "vision-encryption": + node.pop("tls", None) + node.pop("servername", None) + for listener in config["listeners"]: + listener.pop("certificate", None) + listener.pop("private-key", None) + listener["allow-insecure"] = True + reference_node = None + reference_scope = None + if any(test in CLOSE_TESTS for _, test in cases): + reference_node, reference_scope = close_reference( + mode, + node, + config, + Path("/data/fixture") / cert.name, + Path("/data/fixture") / key.name, + pin, + ) + (server_dir / "config.json").write_text(json.dumps(config)) + upstream = None + hop = None + if any(case in public_cases for case, _ in cases): + upstream_dir = root / "upstream" + upstream_dir.mkdir() + shutil.copy2(artifacts["M"].binary, upstream_dir / "peer") + upstream = lab.start( + stack, + upstream_dir, + tag + "-upstream", + [ + "/data/fixture/peer", + "-d", + "/data", + "-f", + "/data/fixture/config.json", + ], + ) + (upstream_dir / "config.json").write_text( + json.dumps( + { + "socks-port": 23001, + "allow-lan": True, + "bind-address": "*", + "log-level": "silent", + "ipv6": True, + "hosts": {"peer.fixture.test": server.ipv4}, + "rules": ["MATCH,DIRECT"], + } + ) + ) + hop = dict( + name="hop", + type="socks5", + server=upstream.ipv4, + port=23001, + udp=True, + ) + upstream.release() + upstream.wait_tcp(23001) + fixture = root / "input.json" + fixture.write_text( + json.dumps( + dict( + isolation="containers", + origin_control=f"{origin.ipv4}:24000", + origin_ipv4=origin.ipv4, + origin_ipv6=origin.ipv6, + server_ipv6=server.ipv6, + data_dir=str(root / "data"), + node=node, + vision_probe=mode.startswith("vision-"), + flow_reject_port=23004 + if mode.startswith("vision-") + else None, + origin_pin=origin_pin, + origin_root_der=list( + ssl.PEM_cert_to_DER_cert(origin_cert.read_text()) + ), + identities=identities, + root_der=list( + ssl.PEM_cert_to_DER_cert( + ( + server_dir / "root.pem" + if encrypted + else cert + ).read_text() + ) + ), + hop=hop, + peer_kind=kind, + **extra_fixture, + ) + ) + ) + origin.release() + server.release() + origin.wait_tcp(24000) + if mode.endswith("-reality") or jls: + origin.wait_tcp(24001) + server.wait_tcp(23000) + comparison = None + if reference_node is not None: + comparison_dir = root / "comparison" + comparison_dir.mkdir() + shutil.copy2(artifacts["M"].binary, comparison_dir / "peer") + (comparison_dir / "config.json").write_text( + json.dumps( + { + "socks-port": 23002, + "allow-lan": True, + "bind-address": "*", + "log-level": "warning", + "ipv6": True, + "proxies": [reference_node], + "rules": ["MATCH,reference"], + } + ) + ) + comparison = lab.start( + stack, + comparison_dir, + tag + "-comparison", + [ + "/data/fixture/peer", + "-d", + "/data", + "-f", + "/data/fixture/config.json", + ], + ) + comparison.release() + comparison.wait_tcp(23002) + stack.callback( + lambda peer=comparison, tag=tag: ( + output / f"{tag}-comparison.log" + ).write_text(redact(command("logs", peer.name)[-65536:])) + ) + observed_close = run_command( + [ + "container", + "exec", + origin.name, + "env", + "VCORE_ISOLATED_ORIGIN=1", + "python", + "-B", + "/data/fixture/close.py", + comparison.ipv4, + origin.ipv4, + ], + timeout=30, + ) + (output / f"{tag}-close-command.log").write_text( + redact(observed_close.stdout.decode(errors="replace")) + ) + if observed_close.returncode or not observed_close.cleanup: + raise RuntimeError("official close comparison failed") + reference = json.loads(observed_close.stdout) + reference["scope"] = reference_scope + reference["client_fingerprint"] = reference_node.get( + "client-fingerprint" + ) + reference["dut_client_fingerprint"] = node.get( + "client-fingerprint" + ) + (output / f"{tag}-close-reference.json").write_text( + json.dumps(reference) + "\n" + ) + data = json.loads(fixture.read_text()) + data["close_reference"] = reference + if any( + test == "native_vision_direct_close_alignment" + for _, test in cases + ): + direct = run_command( + [ + "container", + "exec", + origin.name, + "env", + "VCORE_ISOLATED_ORIGIN=1", + "python", + "-B", + "/data/fixture/close.py", + comparison.ipv4, + origin.ipv4, + "vision-direct", + ], + timeout=30, + ) + (output / f"{tag}-direct-close-command.log").write_text( + redact(direct.stdout.decode(errors="replace")) + ) + if direct.returncode or not direct.cleanup: + raise RuntimeError( + "official direct close comparison failed" + ) + direct_reference = json.loads(direct.stdout) + direct_reference["scope"] = reference_scope + (output / f"{tag}-direct-close-reference.json").write_text( + json.dumps(direct_reference) + "\n" + ) + data["direct_close_reference"] = direct_reference + fixture.write_text(json.dumps(data)) + report["phase"] = "execute" + for case, test in cases: + if preflight_only: + continue + print(case, flush=True) + record = dict(case_id=case, status="NOT RUN", peer_kind=kind) + report["cases"].append(record) + events = output / f"{case}-events.jsonl" + test_command = [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "vless_public" if case in public_cases else "vless_native", + test, + "--", + "--ignored", + "--exact", + "--nocapture", + ] + result = run_command( + test_command, + cwd=CORE_DIR, + timeout=240, + limit=4 * 1024 * 1024, + env=dict( + os.environ, + VCORE_VLESS_PEER=f"{server.ipv4}:23000", + VCORE_VLESS_TRANSPORT=json.dumps( + dict( + mode=mode, + tls=encrypted, + pin=pin, + peer_kind=kind, + udp_path_limit=15000, + ) + ), + VCORE_VLESS_ORIGIN_V4=origin.ipv4, + VCORE_VLESS_INPUT=str(fixture), + VCORE_CASE_EVENTS=str(events), + VCORE_PROTOCOL_STAGE="SECURITY" + if encryption or jls or ech + else "VLESS", + ), + ) + (output / f"{case}.log").write_text( + redact(result.stdout.decode(errors="replace")) + ) + observed = read_events(events) if events.exists() else [] + record.update( + command=test_command, + exit_code=result.returncode, + seconds=result.seconds, + command_cleanup=result.cleanup, + status="PASS" + if result.returncode == 0 + and result.cleanup + and ( + events_pass( + observed, + test, + mode, + stage="SECURITY" + if encryption or jls or ech + else "VLESS", + ) + if case in public_cases + else observed + == [ + dict( + schema_version=1, + suite="SECURITY-WIRE" + if encryption or jls or ech + else "VLESS-WIRE", + assertion=test, + status=status, + ) + for status in ("BEGIN", "PASS") + ] + ) + else "FAIL", + ) + server.ensure_alive() + origin.ensure_alive() + print(f"{case}: {record['status']}", flush=True) + for case, _ in cases: + if preflight_only: + continue + next(item for item in report["cases"] if item["case_id"] == case)[ + "cleanup" + ] = all( + peer.record["joined"] + for peer in [server, origin] + ([upstream] if upstream else []) + ) + report["status"] = ( + "READY" + if preflight_only + else "PASS" + if (preflight_only or len(report["cases"]) == len(selected)) + and all(case["status"] == "PASS" for case in report["cases"]) + else "FAIL" + ) + except KeyboardInterrupt: + report.update(status="INTERRUPTED", reason="user interruption") + except (OSError, RuntimeError, ValueError, KeyError) as error: + report.update( + status="BLOCKED" if report["phase"] == "preflight" else "FAIL", + reason=str(error) + if isinstance(error, RuntimeError) + else type(error).__name__, + ) + finally: + report["cleanup"] = all( + peer["joined"] for peer in report["isolation"].get("peers", []) + ) + report["source_unchanged"] = ( + source_identity()["source_tree_sha256"] + == report["source"]["source_tree_sha256"] + ) + if report["status"] in {"PASS", "READY"} and not ( + report["cleanup"] and report["source_unchanged"] + ): + report["status"] = "FAIL" + (output / "vless-results.json").write_text(json.dumps(report, indent=2) + "\n") + return ( + 0 + if report["status"] in {"PASS", "READY"} + else (130 if report["status"] == "INTERRUPTED" else 1) + ) + + +if __name__ == "__main__": + import sys + + from .mihomo_isolation import exclusive_run + + with exclusive_run(): + sys.exit(run(Path(sys.argv[1]).resolve(), sys.argv[2:] or None)) diff --git a/scripts/src/vcore_scripts/protocol_vless_peers.py b/scripts/src/vcore_scripts/protocol_vless_peers.py new file mode 100644 index 0000000..236aee9 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_vless_peers.py @@ -0,0 +1,219 @@ +"""VLESS isolated native fixtures. No host service or reference checkout is used.""" + +from pathlib import Path + +MODES = [ + mode + suffix + for mode in ( + "tcp", + "ws", + "ws-ed-1", + "ws-ed-2048", + "upgrade", + "upgrade-fast", + "grpc", + "http", + "h2", + "ws-header", + "ws-path", + ) + for suffix in ("", "-tls") +] + [ + "xhttp-stream-one", + "xhttp-stream-up", + "xhttp-packet-up", + "vision-tls", + "vision-reality", + "tcp-reality", + "ws-reality", + "grpc-reality", + "upgrade-reality", + "upgrade-fast-reality", + "tcp-mtls", + "ws-mtls", + "grpc-mtls", + "xhttp-stream-one-reality", + "xhttp-stream-up-reality", + "xhttp-packet-up-reality", + "xhttp-stream-up-download", + "xhttp-packet-up-download", + "xhttp-stream-up-download-reality", + "xhttp-packet-up-download-reality", + "grpc-path-tls", +] + + +def peer_kind(mode): + base = mode.removesuffix("-tls").removesuffix("-reality").removesuffix("-mtls") + return "V2" if base in {"http", "h2", "ws-header", "ws-path"} else "M" + + +def configuration(mode, server, origin, cert: Path, key: Path): + encrypted = mode.endswith(("-tls", "-reality", "-mtls")) or mode.startswith( + ("xhttp-", "vision-") + ) + base = mode.removesuffix("-tls").removesuffix("-reality").removesuffix("-mtls") + separate_download = base.endswith("-download") + base = base.removesuffix("-download") + kind = peer_kind(mode) + identity = "07070707-0707-0707-0707-070707070707" + node = dict( + name="edge", type="vless", server=server, port=23000, uuid=identity, udp=True + ) + if encrypted: + node.update(tls=True, servername="localhost") + if base.startswith("xhttp-"): + node.update( + network="xhttp", + **{ + "xhttp-opts": dict( + path="/vless", host="localhost", mode=base.removeprefix("xhttp-") + ) + }, + ) + if separate_download: + node["xhttp-opts"]["download-settings"] = {} + elif base.startswith(("ws", "upgrade")): + ws = dict(path="/vless-ws", headers={"Host": "localhost"}) + if base in {"ws-header", "ws-path"}: + ws.update( + path="/vless-ws/", + **{ + "max-early-data": 2048, + "early-data-header-name": "X-Vcore-Ed" + if base == "ws-header" + else "", + }, + ) + elif base in {"ws-ed-1", "ws-ed-2048"}: + ws["max-early-data"] = int(base.removeprefix("ws-ed-")) + elif base.startswith("upgrade"): + ws.update( + **{ + "v2ray-http-upgrade": True, + "v2ray-http-upgrade-fast-open": base == "upgrade-fast", + "max-early-data": 1, + } + ) + node.update(network="ws", **{"ws-opts": ws}) + elif base in {"grpc", "grpc-path"}: + node.update( + network="grpc", + **{ + "grpc-opts": dict( + **{ + "grpc-service-name": "/vless-grpc/Tun" + if base == "grpc-path" + else "vless-grpc", + "grpc-user-agent": "VCore-VLESS", + "ping-interval": 1, + } + ) + }, + ) + elif base == "http": + node.update( + network="http", + **{ + "http-opts": dict( + method="POST", path=["/vless-http"], headers={"Host": ["localhost"]} + ) + }, + ) + elif base == "h2": + node.update( + network="h2", **{"h2-opts": dict(host=["localhost"], path="/vless-h2")} + ) + if kind == "M": + listener = dict( + name="vless", + type="vless", + listen="::", + port=23000, + users=[dict(username="fixture", uuid=identity)], + ) + if mode.startswith("vision-"): + listener["users"][0]["flow"] = "xtls-rprx-vision" + node["flow"] = "xtls-rprx-vision" + if encrypted: + listener.update(certificate=str(cert), **{"private-key": str(key)}) + else: + listener["allow-insecure"] = True + if mode.endswith("-reality"): + from .mihomo_extended import PRIVATE_KEY, PUBLIC_KEY, SHORT_ID + + listener.pop("certificate", None) + listener.pop("private-key", None) + listener["reality-config"] = { + "dest": f"{origin}:24001", + "private-key": PRIVATE_KEY, + "short-id": [SHORT_ID], + "server-names": ["localhost"], + } + node["reality-opts"] = {"public-key": PUBLIC_KEY, "short-id": SHORT_ID} + if node.get("network") == "ws": + listener["ws-path"] = "/vless-ws" + if base in {"grpc", "grpc-path"}: + listener["grpc-service-name"] = "vless-grpc" + if base == "ws-alpn": + node["alpn"] = ["h2", "http/1.1"] + listener["grpc-service-name"] = "vless-alpn" + if base.startswith("xhttp-"): + listener["xhttp-config"] = dict(path="/vless", mode="auto") + listeners = [listener] + if mode.startswith("vision-"): + # Mihomo permits an explicit empty flow even for a Vision user. + # Rejection requires a Vision request against an ordinary user. + listeners.append( + dict( + listener, + name="vless-flow-rejection", + port=23004, + users=[dict(user, flow="") for user in listener["users"]], + ) + ) + return node, dict( + ipv6=True, + mode="rule", + **{"log-level": "silent"}, + hosts={"vcore-fixture.test": origin}, + listeners=listeners, + rules=["MATCH,DIRECT"], + ) + stream = dict(network="tcp", security="tls" if encrypted else "none") + if base == "http": + stream["tcpSettings"] = dict( + header=dict(type="http", request=dict(path=["/vless-http"])) + ) + elif base == "h2": + stream.update( + network="http", httpSettings=dict(host=["localhost"], path="/vless-h2") + ) + else: + stream.update( + network="ws", + wsSettings=dict( + path="/vless-ws/", + maxEarlyData=2048, + earlyDataHeaderName="X-Vcore-Ed" if base == "ws-header" else "", + ), + ) + if encrypted: + stream["tlsSettings"] = dict( + alpn=["h2"] if base == "h2" else ["http/1.1"], + certificates=[dict(certificateFile=str(cert), keyFile=str(key))], + ) + return node, dict( + log={"loglevel": "none"}, + dns={"hosts": {"vcore-fixture.test": origin}}, + inbounds=[ + dict( + listen="::", + port=23000, + protocol="vless", + settings=dict(clients=[dict(id=identity)], decryption="none"), + streamSettings=stream, + ) + ], + outbounds=[dict(protocol="freedom", settings={"domainStrategy": "UseIP"})], + ) diff --git a/scripts/src/vcore_scripts/protocol_vless_public.py b/scripts/src/vcore_scripts/protocol_vless_public.py new file mode 100644 index 0000000..6ee59ee --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_vless_public.py @@ -0,0 +1,154 @@ +"""Frozen VLESS native/public consumer requirements; no result-derived catalog.""" + +from .protocol_evidence import idle_resources +from .protocol_vless_peers import MODES, peer_kind +from .protocol_vmess_public import pairs + +PUBLIC = {} +WIRE = {} + + +def add(mode, suffix, test, *, wire=False): + kind = peer_kind(mode) + encrypted = mode.endswith(("-tls", "-reality", "-mtls")) or mode.startswith( + ("xhttp-", "vision-") + ) + (WIRE if wire else PUBLIC)[f"VLESS-{mode.upper()}-{suffix}"] = ( + kind, + mode, + encrypted, + test, + ) + + +for mode in MODES: + add(mode, "BASE", "public_base") +add("ws-alpn-tls", "NEG", "public_alpn_rejection") +for mode in ("tcp", "ws", "grpc", "http", "h2"): + for suffix in ("", "-tls"): + add(mode + suffix, "NEG", "public_negative") + for suffix, test in ( + ("GRAPH", "runtime::public_graph"), + ("IPV6", "runtime::public_ipv6_and_gates"), + ("ENTRYPOINTS", "runtime::public_entrypoints"), + ): + add(mode + "-tls", suffix, test) +for mode in ( + "upgrade-tls", + "upgrade-fast-tls", + "upgrade-reality", + "upgrade-fast-reality", + "tcp-reality", + "ws-reality", + "grpc-reality", + "vision-tls", + "vision-reality", + "xhttp-stream-one", + "xhttp-stream-up", + "xhttp-packet-up", +): + add(mode, "NEG", "public_negative") +for mode in ("tcp-tls", "grpc-tls", "vision-tls", "vision-reality"): + add(mode, "LIFE", "runtime::public_lifecycle") + add(mode, "OWNED", "runtime::owned_resources") +for mode in ("tcp-tls", "grpc-tls", "vision-tls"): + add(mode, "UDP-ISOLATION", "runtime::public_udp_isolation") +for mode in ("vision-tls", "vision-reality"): + add(mode, "INNER-TLS", "native_vision_inner_tls", wire=True) + add(mode, "GRAPH", "runtime::public_graph") + add(mode, "ENTRYPOINTS", "runtime::public_entrypoints") + add(mode, "IPV6", "runtime::public_ipv6_and_gates") +for mode in ("tcp", "grpc-tls", "http", "h2-tls"): + add(mode, "UDP-BOUNDARIES", "native_three_udp_encodings", wire=True) +for mode in ("tcp-mtls", "ws-mtls", "grpc-mtls"): + add(mode, "IDENTITY", "public_tls_identity_and_verification_name") +add( + "grpc-tls", + "POOL-SELECTION", + "runtime::grpc_pool_keeps_physical_selection_until_new_transport", +) +for protocol in ("vmess", "trojan"): + for network in ("tcp", "ws", "grpc"): + PUBLIC[f"VLESS-REGRESSION-{protocol.upper()}-{network.upper()}"] = ( + "M", + f"{protocol}-{network}", + True, + "public_legacy_regression", + ) +for mode in ( + "tcp", + "tcp-tls", + "tcp-reality", + "ws-reality", + "grpc-reality", + "ws", + "ws-tls", + "grpc", + "grpc-tls", + "http", + "http-tls", + "h2", + "h2-tls", + "upgrade-fast-tls", + "upgrade-reality", + "upgrade-fast-reality", + "vision-tls", + "vision-reality", + "xhttp-stream-one", + "xhttp-stream-up", + "xhttp-packet-up", +): + add( + mode, + "CLOSE", + "native_ws_reality_close_boundary" + if mode in {"ws-reality", "upgrade-reality", "upgrade-fast-reality"} + else "native_mihomo_close_alignment", + wire=True, + ) +add("grpc-tls", "POOL-COUNTS", "native_grpc_pool_thresholds", wire=True) +CASES = WIRE | PUBLIC + + +def events_pass(events, test, mode, *, stage="VLESS"): + main = [event for event in events if event.get("suite") == f"{stage}-PUBLIC"] + if len(main) != 2 or not pairs(main, f"{stage}-PUBLIC", test, 1): + return False + if any( + event.get("schema_version") != 1 or event.get("status") not in {"BEGIN", "PASS"} + for event in events + ): + return False + if test == "public_base" and not ( + pairs(events, f"{stage}-BASE", "tcp_10mib_both_directions", 3) + and pairs( + events, + f"{stage}-BASE", + "udp_each_codec_and_family", + 1 if mode.startswith("vision-") else 3, + ) + ): + return False + if test in {"runtime::public_lifecycle", "runtime::owned_resources"}: + suite = ( + f"{stage}-LIFE" if test.endswith("public_lifecycle") else f"{stage}-OWNED" + ) + if not pairs(events, suite, "stop_and_remain_quiet", 20): + return False + for event in [ + e for e in events if e.get("suite") == suite and e["status"] == "PASS" + ]: + if event.get("seconds", 0) < 5: + return False + if suite == f"{stage}-OWNED": + points = event.get("checkpoints", []) + phases = {p["phase"]: p["resources"] for p in points} + if ( + len(points) != 3 + or set(phases) != {"baseline", "after-stop", "quiet"} + or not all(idle_resources(r) for r in phases.values()) + or phases["after-stop"] != phases["quiet"] + or not idle_resources(event.get("resources")) + ): + return False + return True diff --git a/scripts/src/vcore_scripts/protocol_vless_security.py b/scripts/src/vcore_scripts/protocol_vless_security.py new file mode 100644 index 0000000..acc6bd7 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_vless_security.py @@ -0,0 +1,94 @@ +"""Ephemeral public-test identities. Keys never leave the private fixture.""" + +from pathlib import Path + +from .protocol_fixtures import certificates +from .protocol_peers import run_command + + +def client_identities(directory: Path): + result = {} + extensions = directory / "client-ext.cnf" + extensions.write_text( + "basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=clientAuth\n" + ) + (directory / "index.txt").write_text("") + (directory / "serial.txt").write_text("10\n") + ca = directory / "client-ca.cnf" + ca.write_text( + "[ca]\ndefault_ca=fixture\n[fixture]\n" + f"database={directory / 'index.txt'}\nserial={directory / 'serial.txt'}\n" + f"new_certs_dir={directory}\ncertificate={directory / 'root.pem'}\n" + f"private_key={directory / 'root-key.pem'}\n" + "default_md=sha256\npolicy=names\n[names]\ncommonName=supplied\n" + ) + for name, days in (("valid", "2"), ("expired", "2")): + key, csr, cert = ( + directory / f"client-{name}.{suffix}" for suffix in ("key", "csr", "pem") + ) + for argv in ( + [ + "openssl", + "req", + "-new", + "-newkey", + "rsa:2048", + "-nodes", + "-subj", + "/CN=synthetic-client", + "-keyout", + str(key), + "-out", + str(csr), + ], + [ + "openssl", + "x509", + "-req", + "-in", + str(csr), + "-CA", + str(directory / "root.pem"), + "-CAkey", + str(directory / "root-key.pem"), + "-set_serial", + "3" if name == "valid" else "4", + "-days", + days, + "-extfile", + str(extensions), + "-out", + str(cert), + ], + ): + if name == "expired" and argv[1] == "x509": + argv = [ + "openssl", + "ca", + "-batch", + "-notext", + "-config", + str(ca), + "-in", + str(csr), + "-out", + str(cert), + "-extfile", + str(extensions), + "-startdate", + "20000101000000Z", + "-enddate", + "20010101000000Z", + ] + command = run_command(argv, timeout=20, limit=65536) + if command.returncode or not command.cleanup: + raise RuntimeError("synthetic client identity generation failed") + result[name] = {"certificate": cert.read_text(), "private-key": key.read_text()} + wrong = directory / "wrong-ca" + wrong.mkdir() + cert, key, _ = certificates(wrong) + result["wrong-ca"] = { + "certificate": cert.read_text(), + "private-key": key.read_text(), + } + return result diff --git a/scripts/src/vcore_scripts/protocol_vmess.py b/scripts/src/vcore_scripts/protocol_vmess.py new file mode 100644 index 0000000..8723aea --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_vmess.py @@ -0,0 +1,132 @@ +"""VMESS wire gate; all peers and origins run in isolated containers.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from .mihomo_isolation import exclusive_run + +CASES = { + "VMESS-M-IDENTITY": ("M", "tcp", False, "native_identity_time_replay_rejection") +} +CASES["VMESS-M-RAW-UDP"] = ("M", "tcp", False, "native_raw_udp_boundaries") +CASES["VMESS-M-ENCODED-UDP"] = ("M", "tcp", False, "native_encoded_udp_boundaries") +for _mode in ("tcp", "ws", "grpc", "http", "h2"): + for _tls in (False, True): + _kind = "V2" if _mode in {"http", "h2"} else "M" + CASES[f"VMESS-{_kind}-{_mode.upper()}-{'TLS' if _tls else 'PLAIN'}"] = ( + _kind, + _mode, + _tls, + "native_cipher_matrix", + ) + CASES[f"VMESS-{_kind}-{_mode.upper()}-{'TLS' if _tls else 'PLAIN'}-CLOSE"] = ( + _kind, + _mode, + _tls, + "native_mihomo_close_alignment", + ) + if _mode != "tcp" or _tls: + for _encoding in ("raw", "encoded"): + CASES[ + f"VMESS-{_kind}-{_mode.upper()}-{'TLS' if _tls else 'PLAIN'}" + f"-{_encoding.upper()}-UDP" + ] = ( + _kind, + _mode, + _tls, + f"native_{_encoding}_udp_boundaries", + ) + + +def udp_path_limit(): + # Both packet sender and origin are now Linux VM services. + return 15000 + + +def peer_config(kind, mode, encrypted, port, cert, key): + if mode.startswith("trojan-"): + config = peer_config( + kind, mode.removeprefix("trojan-"), encrypted, port, cert, key + ) + config["listeners"][0].update( + type="trojan", + users=[dict(username="fixture", password="synthetic-regression-only")], + ) + return config + variant = mode + mode = mode.split("-")[0] + identity = "07070707-0707-0707-0707-070707070707" + if kind == "M": + listener = dict( + name="vmess", + type="vmess", + listen="127.0.0.1", + port=port, + users=[dict(username="fixture", uuid=identity, alterId=0)], + ) + if encrypted: + listener.update(certificate=str(cert), **{"private-key": str(key)}) + if mode == "ws": + listener["ws-path"] = "/vmess-ws" + if mode == "grpc": + listener["grpc-service-name"] = "vmess-grpc" + if variant == "ws-alpn": + listener["grpc-service-name"] = "vmess-alpn" + return { + "mode": "rule", + "log-level": "silent", + "ipv6": True, + "hosts": {"vcore-fixture.test": "127.0.0.1"}, + "listeners": [listener], + "rules": ["MATCH,DIRECT"], + } + stream = dict(network="tcp", security="tls" if encrypted else "none") + if mode == "http": + stream["tcpSettings"] = { + "header": {"type": "http", "request": {"path": ["/vmess-http"]}} + } + elif mode == "h2": + stream.update( + network="http", httpSettings=dict(host=["localhost"], path="/vmess-h2") + ) + elif mode == "ws": + stream.update( + network="ws", + wsSettings={ + "path": "/vmess-ws/", + "maxEarlyData": 2048, + "earlyDataHeaderName": "X-Vcore-Ed" if variant == "ws-header" else "", + }, + ) + if encrypted: + stream["tlsSettings"] = { + "alpn": ["h2"] if mode == "h2" else ["http/1.1"], + "certificates": [{"certificateFile": str(cert), "keyFile": str(key)}], + } + return { + "log": {"loglevel": "none"}, + "dns": {"hosts": {"vcore-fixture.test": "127.0.0.1"}}, + "inbounds": [ + { + "listen": "127.0.0.1", + "port": port, + "protocol": "vmess", + "settings": {"clients": [{"id": identity, "alterId": 0}]}, + "streamSettings": stream, + } + ], + "outbounds": [{"protocol": "freedom", "settings": {"domainStrategy": "UseIP"}}], + } + + +def run(output: Path, selected=None): + from .protocol_vmess_container import run as run_container + + return run_container(output, selected) + + +if __name__ == "__main__": + with exclusive_run(): + sys.exit(run(Path(sys.argv[1]).resolve(), sys.argv[2:] or None)) diff --git a/scripts/src/vcore_scripts/protocol_vmess_acceptance.py b/scripts/src/vcore_scripts/protocol_vmess_acceptance.py new file mode 100644 index 0000000..d3bfdec --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_vmess_acceptance.py @@ -0,0 +1,691 @@ +"""VMESS container-only execution and independently recomputed stage acceptance.""" + +from __future__ import annotations + +import json +import sys + +from .builds import DEFAULT_FEATURES +from .protocol_evidence import HEX, idle_resources, new_result, read_events, read_json +from .protocol_inputs import redact +from .protocol_vmess import CASES as WIRE +from .protocol_vmess_container import run as native_run +from .protocol_vmess_public import CASES as PUBLIC +from .protocol_vmess_public import events_pass + +NATIVE = WIRE | PUBLIC +FIELD_IDS = { + *(f"C{i:02}" for i in range(1, 7)), + *(f"T{i:02}" for i in range(2, 6)), + *(f"W{i:02}" for i in range(1, 6)), + "G01", + *(f"H{i:02}" for i in range(1, 7)), + *(f"VM{i:02}" for i in range(1, 9)), +} +GATES = { + "VMESS-CFG", + "VMESS-CODEC", + "VMESS-CANCEL", + "VMESS-REGRESSION", + "VMESS-RELEASE", + "VMESS-FEATURES", + "VMESS-SCRIPTS", + "VMESS-QUALITY", +} +SCRIPT_ASSERTIONS = [ + "offline-success", + "missing-duplicate-unknown-case", + "download-failure", + "timeout", + "sigint", + "partial-start", + "permission", + "peer-exit", + "cleanup-failure", + "redaction", +] +REQUIRED_IDS = set(NATIVE) | GATES +# Frozen names, not a regex over test output and not read from the manifest. +OBSERVATIONS = { + "VMESS-CFG": [ + "vmess_default_node_and_explicit_cipher_aliases_are_accepted_without_io", + "vmess_transport_and_security_combinations_are_strict", + "vmess_field_boundaries_and_normalized_transport_values", + "vmess_normalized_websocket_fields_preserve_headers_and_early_data_order", + ], + "VMESS-CODEC": [ + "xudp_cancelled_send_and_bad_frame_release_io_and_cannot_resume", + "xudp_zero_global_id_omits_the_optional_extension_like_mihomo", + "packetaddr_has_ip_only_address_first_wire_and_rejects_truncation", + "vmess_aead_requests_are_fresh_bounded_and_redacted", + "vmess_response_authentication_cannot_be_skipped_by_none_cipher", + "vmess_whole_close_wakes_reader_and_releases_io_before_response", + "xudp_explicit_budget_rejects_max_plus_one_before_writing", + "independently_published_nested_hmac_vectors", + "chunks_preserve_boundaries_masks_padding_and_authenticated_eof", + "tags_lengths_and_nonce_exhaustion_fail_closed", + "auto_uses_detected_hardware_not_architecture_name", + ], + "VMESS-CANCEL": [ + "vmess_all_handshakes_keep_the_original_deadline_and_join_cancelled_io", + "vmess_expired_deadline_and_protect_failure_never_fall_back", + ], + "VMESS-REGRESSION": [ + "shared_xudp_receives_frames_without_a_vless_response_header", + "first_frame_matches_xray_mux_wire_format", + "followup_frame_carries_each_datagrams_destination", + "destination_codec_round_trips_all_address_families", + "normal_and_error_end_frames_are_distinguishable", + "oversized_response_is_rejected_before_payload_read", + "fragmented_receive_survives_cancellation_and_resumes", + "keepalive_data_is_consumed_without_becoming_an_udp_response", + "full_wire_payload_remains_available_to_proxy_inbounds", + "request_matches_the_official_sha224_and_socks_address_wire_format", + "authentication_and_requests_are_strict_and_never_debug_credentials", + "invalid_truncated_and_oversized_frames_fail_closed_without_payload_leaks", + "datagram_limits_preserve_messages_and_drain_over_budget_responses", + "datagram_sends_one_frame_and_receives_fragmented_consecutive_frames", + "cancelled_receive_preserves_partial_frames_and_does_not_block_send", + "cancelled_partial_send_poison_closes_without_replaying_or_waiting", + "trojan_tcp_configuration_and_node_graph_accept_the_approved_fields", + "trojan_tcp_defaults_preserve_credentials_and_address_policy", + "trojan_invalid_configuration_is_rejected_without_exposing_credentials", + "trojan_ws_and_grpc_configuration_applies_transport_specific_defaults", + "trojan_transport_boundaries_fail_before_runtime_io", + "transport_options_reject_ambiguous_headers_before_io", + "http_first_header_preserves_prefix_raw_continuation_and_half_close_tail", + "legacy_h2_keeps_unframed_bytes_server_first_and_owned_whole_close", + "websocket_rejects_invalid_upgrade_responses_and_bounded_header_overflows", + "websocket_early_data_and_remaining_frames_keep_the_original_byte_order", + "setup_deadline_releases_supplied_io_in_stream_adapters", + "cancelled_setup_releases_supplied_io_without_a_detached_task", + "ws_rejects_oversized_and_truncated_frames_instead_of_reporting_eof", + "ws_accepts_mihomo_clean_underlay_eof_only_at_complete_message_boundaries", + "dropping_an_established_ws_releases_its_only_io_owner", + "grpc_rejects_oversized_truncated_and_invalid_records", + "grpc_large_writes_obey_small_http2_windows_and_keep_byte_integrity", + "ws_partial_writes_empty_frames_ping_and_half_close_preserve_every_byte", + "grpc_handles_response_after_upload_fragmented_records_and_owned_stop", + "websocket_supplied_io_preserves_server_first_and_partial_writes", + "xhttp_keeps_complete_response_when_peer_resets_after_end_stream", + "xhttp_does_not_hide_reset_before_end_stream", + "feature_skeletons_do_not_open_unimplemented_yaml_or_measurement_protocols", + "future_fields_and_over_limit_documents_remain_rejected", + "registry_matches_live_production_constants_and_has_owned_boundary_cases", + "webpki_rejects_untrusted_wrong_name_and_expired_unless_explicitly_skipped", + "leaf_pin_is_trust_but_nonleaf_pin_checks_chain_name_and_expiry", + "ticket_storage_obeys_exact_node_budget_and_consumes_each_ticket_once", + "every_certificate_policy_verifies_tls12_and_tls13_handshake_signatures", + "vless_keeps_webpki_tls13_and_required_h2_after_anytls_connections", + "alpn_and_tls_resumption_are_isolated_between_node_policies", + "explicit_verification_name_does_not_change_sni_or_allow_skip_to_override_it", + "mutual_tls_identity_is_required_verified_and_not_shared_between_clients", + "tls_options_reject_invalid_alpn_identity_and_budget_before_using_a_stream", + "certificate_rejection_delivers_no_business_bytes_and_diagnostics_are_redacted", + "tls_close_write_sends_notify_without_closing_the_supplied_transport", + "tls_close_notify_flush_has_a_five_second_bound", + "cancelling_tls_handshake_releases_the_caller_supplied_stream", + ], + "VMESS-RELEASE": [ + "vmess_default_node_and_explicit_cipher_aliases_are_accepted_without_io", + "vmess_transport_and_security_combinations_are_strict", + "vmess_field_boundaries_and_normalized_transport_values", + "vmess_normalized_websocket_fields_preserve_headers_and_early_data_order", + "xudp_cancelled_send_and_bad_frame_release_io_and_cannot_resume", + "xudp_zero_global_id_omits_the_optional_extension_like_mihomo", + "packetaddr_has_ip_only_address_first_wire_and_rejects_truncation", + "vmess_aead_requests_are_fresh_bounded_and_redacted", + "vmess_response_authentication_cannot_be_skipped_by_none_cipher", + "vmess_whole_close_wakes_reader_and_releases_io_before_response", + "xudp_explicit_budget_rejects_max_plus_one_before_writing", + "independently_published_nested_hmac_vectors", + "chunks_preserve_boundaries_masks_padding_and_authenticated_eof", + "tags_lengths_and_nonce_exhaustion_fail_closed", + "auto_uses_detected_hardware_not_architecture_name", + "vmess_all_handshakes_keep_the_original_deadline_and_join_cancelled_io", + "vmess_expired_deadline_and_protect_failure_never_fall_back", + ], +} + + +def rows(identifier): + if identifier == "VMESS-CFG": + return sorted(FIELD_IDS) + if identifier == "VMESS-CODEC": + return ["VM01", "VM03", "VM06", "VM07", "VM08"] + if identifier in GATES or identifier.startswith("VMESS-REGRESSION"): + return [] + _, mode, tls, test = NATIVE[identifier] + result = {"VM04", "VM05"} + if identifier in WIRE: + if test == "native_identity_time_replay_rejection": + result |= {"VM01", "VM02"} + elif test == "native_cipher_matrix": + result |= {"VM03", "VM07", "VM08"} + elif test.endswith("udp_boundaries"): + result.add("VM06") + else: + result |= {"C01", "C02", "C03", "C04", "VM01", "VM02"} + if test == "public_body_options": + result |= {"VM03", "VM07", "VM08"} + if "graph" in test: + result.add("C06") + if "base" in test or "udp" in test or "gates" in test: + result |= {"C05", "VM06"} + if tls: + result |= {"T02", "T03", "T05"} + if test == "public_negative": + result.add("T04") + if mode.startswith("ws"): + result |= {"W01", "W02", "W03"} + if mode != "ws": + result |= {"W04", "W05"} + if mode.startswith("grpc"): + result.add("G01") + if mode == "http": + result |= {"H01", "H02", "H03", "H04"} + if mode == "h2": + result |= {"H05", "H06"} + return sorted(result) + + +def definitions(): + cases = [] + for identifier in sorted(REQUIRED_IDS): + kind, mode, tls, test = NATIVE.get( + identifier, ("unit", "memory-or-build", False, identifier) + ) + native = identifier in NATIVE + substage = "VMESS.acceptance" + if identifier in WIRE: + substage = ( + "VMESS.identity" + if "identity" in test + else "VMESS.codec" + if "cipher" in test + else "VMESS.udp" + if "udp" in test + else "VMESS.close" + ) + elif identifier == "VMESS-CODEC": + substage = "VMESS.codec" + cases.append( + dict( + case_id=identifier, + stage="VMESS", + substage=substage, + required=True, + row_ids=rows(identifier), + protocol="vmess", + network=mode, + security="tls" if tls else "plain", + udp_codec="raw/xudp/packetaddr" + if "udp" in test or test == "public_base" + else "per-assertion", + field_values={ + "network": mode, + "tls": tls, + "scope": "protocol-consumer", + }, + outer_family="IPv4/IPv6" if "ipv6" in test else "IPv4", + inner_family="IPv4/IPv6/domain" + if "base" in test or "udp_boundaries" in test + else "per-assertion", + target_type="isolated synthetic origin" if native else "memory-only", + upstream_graph="direct/concrete/nested-select" + if "graph" in test + else "per-assertion", + peer_kind=kind, + peer_config=dict(mode=mode, tls=tls, test=test), + gap_source={ + "reason": "Mihomo VMess listener has no HTTP header/legacy H2 " + "or custom ED field; official V2Ray supplements only these paths", + "source": "https://github.com/MetaCubeX/mihomo/blob/v1.19.31/listener/inbound/vmess.go", + } + if kind == "V2" + else None, + expected_observation=[test] + if native + else SCRIPT_ASSERTIONS + if identifier == "VMESS-SCRIPTS" + else OBSERVATIONS.get(identifier, [identifier]), + required_evidence=["structured-assertions", "command", "cleanup"] + + (["peer-identity", "isolated-origins"] if native else []), + prerequisites=[ + "owned-host-only-container-network", + "official-latest-peer", + ] + if native + else ["rust-toolchain"], + runner="native-vmess" if native else "vmess-gate", + timeout_seconds=240 if native else 1200, + ) + ) + return cases + + +def commands(identifier): + cargo = ["cargo", "test", "--locked", "--all-features"] + if identifier == "VMESS-CFG": + return [cargo + ["--test", "vmess_config"], cargo + ["--lib", "config::"]] + if identifier == "VMESS-CODEC": + return [cargo + ["--test", "vmess_codec"], cargo + ["--lib", "outbound::vmess"]] + if identifier == "VMESS-CANCEL": + return [cargo + ["--test", "vmess_lifecycle"]] + if identifier == "VMESS-RELEASE": + return [ + cargo + + [ + "--release", + "--test", + "vmess_config", + "--test", + "vmess_codec", + "--test", + "vmess_lifecycle", + ], + cargo + ["--release", "--lib", "outbound::vmess"], + ] + if identifier == "VMESS-REGRESSION": + return [ + cargo + ["--lib", "xudp::"], + cargo + ["--lib", "security::tls::tests::"], + cargo + + [ + "--test", + "trojan", + "--test", + "trojan_config", + "--test", + "stream_foundations", + "--test", + "h2_stream_regression", + "--test", + "feature_foundations", + "--test", + "limit_foundations", + ], + cargo + + [ + "--lib", + "inbound::listen::tests::socks_udp_send_buffer_covers_the_declared_wire_limit", + "--", + "--exact", + ], + ] + if identifier == "VMESS-QUALITY": + return [ + ["cargo", "fmt", "--all", "--", "--check"], + [ + "cargo", + "clippy", + "--locked", + "--all-features", + "--lib", + "--bins", + "--", + "-D", + "warnings", + ], + ["vcore-scripts", "check", "c-header"], + ["vcore-scripts", "check", "tls-dependencies"], + ["ruff", "check", "scripts"], + ["ruff", "format", "--check", "scripts"], + ["git", "diff", "--check"], + ["vcore-scripts", "build", "apple"], + ["vcore-scripts", "build", "android"], + ] + return [] + + +def gate_result(case, records, events): + result = new_result(case) + good = bool(records) and all( + r.get("exit_code") == 0 and r.get("cleanup") is True for r in records + ) + identifier = case["case_id"] + assertions = {} + if identifier in OBSERVATIONS: + for name in case["expected_observation"]: + observed = [e for e in events if e.get("assertion") == name] + assertions[name] = len(observed) == 2 and [ + e.get("status") for e in observed + ] == ["BEGIN", "PASS"] + good &= ( + bool(assertions) + and all(assertions.values()) + and all( + e.get("schema_version") == 1 and e.get("status") in {"BEGIN", "PASS"} + for e in events + ) + ) + else: + assertions = dict.fromkeys(case["expected_observation"], good) + result.update( + status="PASS" if good else "FAIL", + assertions=assertions, + evidence=case["required_evidence"] if good else [], + command_exit_code=0 if good else 1, + cleanup=all(r.get("cleanup") is True for r in records), + ) + return result + + +def native_results(cases, report, directory): + records = report.get("cases", []) + expected = {case["case_id"] for case in cases} + if len(records) != len(expected) or {r.get("case_id") for r in records} != expected: + raise ValueError("missing, duplicate or unknown VMess native case") + by_id = {record["case_id"]: record for record in records} + output = [] + for case in cases: + record = by_id[case["case_id"]] + path = directory / (case["case_id"] + "-events.jsonl") + observed = read_events(path) if path.exists() else [] + test = NATIVE[case["case_id"]][3] + observed_ok = ( + events_pass(observed, test) + if case["case_id"] in PUBLIC + else observed + == [ + dict( + schema_version=1, suite="VMESS-WIRE", assertion=test, status=status + ) + for status in ("BEGIN", "PASS") + ] + ) + clean = ( + record.get("command_cleanup") is True + and record.get("cleanup") is True + and report.get("cleanup") is True + ) + expected_command = [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "vmess_public" if case["case_id"] in PUBLIC else "vmess_native", + test, + "--", + "--ignored", + "--exact", + "--nocapture", + ] + good = ( + observed_ok + and clean + and report.get("source_unchanged") is True + and record.get("exit_code") == 0 + and record.get("status") == "PASS" + and record.get("command") == expected_command + and record.get("peer_kind") == case["peer_kind"] + ) + result = new_result(case) + result.update( + status="PASS" if good else "FAIL", + assertions={test: observed_ok}, + evidence=case["required_evidence"] if good else [], + command_exit_code=record.get("exit_code"), + cleanup=clean, + ) + output.append(result) + return output + + +def fields_report(cases, results): + statuses = {result["case_id"]: result["status"] for result in results} + complete = set(statuses) == REQUIRED_IDS and all( + value == "PASS" for value in statuses.values() + ) + fields = [] + for row in sorted(FIELD_IDS): + owners = [c["case_id"] for c in cases if row in c["row_ids"]] + fields.append( + dict( + row_id=row, + required_cases=owners, + status="PASS" if complete and len(owners) > 1 else "NOT RUN", + ) + ) + return dict(schema_version=1, stage="VMESS", protocol="vmess", fields=fields) + + +def execute(cases, run, output, results): + from .protocol_harness import _command, _features, _scripts + + for case in cases: + identifier = case["case_id"] + if identifier in NATIVE: + continue + if identifier == "VMESS-FEATURES": + result = _features(case, run, output) + build = _command( + run, + output, + "production-build", + [ + "cargo", + "build", + "--locked", + "--release", + "--no-default-features", + "--features", + DEFAULT_FEATURES, + "--lib", + ], + 1200, + ) + if build.returncode or not build.cleanup: + result.update( + status="FAIL", + command_exit_code=build.returncode, + cleanup=build.cleanup, + ) + results[identifier] = result + elif identifier == "VMESS-SCRIPTS": + results[identifier] = _scripts(case, run, output) + else: + events = [] + start = len(run["commands"]) + for index, argv in enumerate(commands(identifier)): + path = output / f"{identifier}-{index}-events.jsonl" + _command(run, output, f"{identifier}-{index}", argv, 1200, events=path) + if path.exists(): + events.extend(read_events(path)) + results[identifier] = gate_result(case, run["commands"][start:], events) + native = [case for case in cases if case["case_id"] in NATIVE] + if native: + directory = output / "native" + native_run(directory, [case["case_id"] for case in native]) + report = read_json(directory / "vmess-results.json") + (output / "peers.json").write_text(json.dumps(report["peers"], indent=2) + "\n") + for result in native_results(native, report, directory): + results[result["case_id"]] = result + (output / "fields.json").write_text( + json.dumps(fields_report(cases, list(results.values())), indent=2) + "\n" + ) + + +def check(run_dir, cases, run, results, peers, paths): + from .protocol_harness import SCRIPT_OBSERVATIONS + + needed = { + "native/vmess-results.json", + "fields.json", + "script-tests.json", + "resources.jsonl", + "peers.json", + "cases.json", + "summary.md", + } + if not needed <= set(paths): + raise ValueError("missing VMESS acceptance artifacts") + expected = {c["case_id"]: c for c in definitions()} + if len(cases) != len(expected) or any( + c != expected.get(c["case_id"]) for c in cases + ): + raise ValueError("VMess frozen case metadata changed") + report = read_json(run_dir / "native/vmess-results.json") + source_keys = ( + "parent_commit", + "source_tree_sha256", + "dirty_patch_sha256", + "lock_sha256", + ) + if ( + report.get("source") != {key: run.get(key) for key in source_keys} + or report.get("status") != "PASS" + ): + raise ValueError("VMess peer evidence input mismatch") + isolation = report.get("isolation", {}) + if ( + isolation.get("host_servers") is not False + or isolation.get("network_mode") != "hostOnly" + or not isolation.get("peers") + or not all( + p.get("joined") is True and p.get("started") is True + for p in isolation["peers"] + ) + ): + raise ValueError("VMess peers not isolated or cleaned") + if peers != report.get("peers") or set(peers) != {"M", "V2"}: + raise ValueError("missing VMess official peers") + for kind, peer in peers.items(): + project = "MetaCubeX/mihomo" if kind == "M" else "v2fly/v2ray-core" + if ( + peer.get("kind") != kind + or peer.get("target") != "linux-arm64" + or not peer.get("version") + or not peer.get("source_url", "").startswith( + f"https://github.com/{project}/releases/" + ) + or not all( + HEX.fullmatch(peer.get(key, "")) + for key in ("archive_sha256", "binary_sha256") + ) + ): + raise ValueError("invalid official VMess peer identity") + actual = {r["case_id"]: r for r in results} + recalculated = native_results( + [c for c in cases if c["case_id"] in NATIVE], report, run_dir / "native" + ) + for case in cases: + identifier = case["case_id"] + argv = commands(identifier) + if not argv: + continue + records = [] + events = [] + for i, command in enumerate(argv): + matching = [r for r in run["commands"] if r["name"] == f"{identifier}-{i}"] + if len(matching) != 1 or matching[0]["command"] != [ + redact(part) for part in command + ]: + raise ValueError("missing VMess gate command") + records.extend(matching) + path = run_dir / f"{identifier}-{i}-events.jsonl" + if path.exists(): + events.extend(read_events(path)) + recalculated.append(gate_result(case, records, events)) + for item in recalculated: + if item != actual.get(item["case_id"]): + raise ValueError("VMess results disagree with structured observations") + names = [c["name"] for c in run["commands"]] + needed_commands = { + f"{identifier}-{i}" + for identifier in GATES + for i in range(len(commands(identifier))) + } | { + "production-build", + "offline-scripts", + "feature-default", + "feature-minimal", + *( + "feature-" + name + for name in ( + "outbound-trojan", + "outbound-vmess", + "outbound-hysteria2", + "stream-transport", + "quic-transport", + ) + ), + } + if ( + len(names) != len(set(names)) + or set(names) != needed_commands + or any( + c.get("exit_code") != 0 + or c.get("cleanup") is not True + or c.get("log") not in paths + for c in run["commands"] + ) + ): + raise ValueError("missing or failed VMess validation command") + for record in run["commands"]: + name = record["name"] + if name == "feature-default": + expected_command = [ + "cargo", + "test", + "--locked", + "--test", + "feature_foundations", + ] + elif name.startswith("feature-"): + expected_command = [ + "cargo", + "check", + "--locked", + "--no-default-features", + "--lib", + ] + if name != "feature-minimal": + expected_command += ["--features", name.removeprefix("feature-")] + elif name == "production-build": + expected_command = [ + "cargo", + "build", + "--locked", + "--release", + "--no-default-features", + "--features", + DEFAULT_FEATURES, + "--lib", + ] + elif name == "offline-scripts": + expected_command = [ + sys.executable, + "-m", + "vcore_scripts.protocol_script_tests", + str(run_dir / "script-tests.json"), + ] + else: + continue + if record["command"] != [redact(part) for part in expected_command]: + raise ValueError("VMess feature or script command changed") + script = read_json(run_dir / "script-tests.json") + tests = {t["test"]: t["status"] for t in script["cases"]} + if ( + len(tests) != script["tests_run"] + or any(tests.get(name) != "PASS" for name in SCRIPT_OBSERVATIONS.values()) + or any(status != "PASS" for status in tests.values()) + ): + raise ValueError("missing harness failure-path tests") + fields = fields_report(cases, results) + if read_json(run_dir / "fields.json") != fields or any( + f["status"] != "PASS" for f in fields["fields"] + ): + raise ValueError("incomplete VMess field coverage") + resources = read_events(run_dir / "resources.jsonl") + owned = [ + e + for e in resources + if e.get("suite") == "VMESS-OWNED" and e.get("status") == "PASS" + ] + if len(owned) != 40 or not all(idle_resources(e.get("resources")) for e in owned): + raise ValueError("missing 20-round owned resource evidence per selected mode") + print( + f"VMESS: PASS ({len(cases)} required cases; {len(FIELD_IDS)} VMess field rows)" + ) diff --git a/scripts/src/vcore_scripts/protocol_vmess_container.py b/scripts/src/vcore_scripts/protocol_vmess_container.py new file mode 100644 index 0000000..dd3203d --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_vmess_container.py @@ -0,0 +1,352 @@ +"""VMESS wire cases against official latest peers and entirely isolated origins.""" + +from __future__ import annotations + +import contextlib +import json +import os +import shutil +import tempfile +from pathlib import Path + +from .builds import CORE_DIR +from .mihomo_release import download_mihomo +from .native_release import PeerArtifact, download_native +from .protocol_containers import ContainerLab, command +from .protocol_evidence import read_events +from .protocol_fixtures import certificates +from .protocol_inputs import redact, source_identity +from .protocol_peers import run_command +from .protocol_vmess import CASES, peer_config +from .protocol_vmess_public import CASES as PUBLIC_CASES +from .protocol_vmess_public import events_pass, node_config + +ALL_CASES = CASES | PUBLIC_CASES + + +def run(output: Path, selected=None, *, preflight_only=False): + selected = list(ALL_CASES) if selected is None else selected + if ( + not selected + or len(set(selected)) != len(selected) + or not set(selected) <= ALL_CASES.keys() + ): + raise ValueError("invalid VMESS native selection") + output.mkdir(parents=True, exist_ok=False) + if preflight_only: + # One listener/origin group per official implementation, not a traffic run. + kinds = {ALL_CASES[case][0] for case in selected} + selected = [ + next(case for case in selected if ALL_CASES[case][0] == kind) + for kind in sorted(kinds) + ] + report = dict( + stage="VMESS", + scope="container-wire-and-public-consumer", + source=source_identity(), + status="NOT RUN", + phase="preflight", + cases=[], + peers={}, + isolation={}, + ) + try: + artifacts = {} + for kind in sorted({ALL_CASES[case][0] for case in selected} | {"M"}): + directory = output / "binaries" / kind + if kind == "M": + identity = {} + binary = download_mihomo( + "linux-arm64", directory=directory, identity=identity + ) + artifacts[kind] = PeerArtifact(binary, identity) + else: + artifacts[kind] = download_native( + kind, directory, "linux-arm64", defer_version=True + ) + report["peers"][kind] = artifacts[kind].identity + lab = ContainerLab(report["isolation"]) + report["phase"] = "build" + built = run_command( + [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "vmess_native", + "--test", + "vmess_public", + "--no-run", + ], + timeout=180, + ) + (output / "build.log").write_text(redact(built.stdout.decode(errors="replace"))) + if built.returncode != 0 or not built.cleanup: + raise RuntimeError("container wire build failed") + # Reuse a peer only within one transport; every case gets fresh origin + # controls/ports and sessions. One downloaded release snapshot per run. + groups = {} + for case in selected: + kind, mode, encrypted, test = ALL_CASES[case] + groups.setdefault((kind, mode, encrypted), []).append((case, test)) + for (kind, mode, encrypted), cases in groups.items(): + report["phase"] = "peer-start" + with tempfile.TemporaryDirectory( + prefix="private-", dir=output + ) as temporary: + root = Path(temporary) + with contextlib.ExitStack() as stack: + origin_dir, server_dir = root / "origin", root / "server" + origin_dir.mkdir() + server_dir.mkdir() + shutil.copyfile( + Path(__file__).with_name("container_udp_origin.py"), + origin_dir / "origin.py", + ) + artifact = artifacts[kind] + shutil.copy2(artifact.binary, server_dir / "peer") + tag = f"{mode}-{'tls' if encrypted else 'plain'}" + origin = lab.start( + stack, + origin_dir, + tag + "-origin", + [ + "env", + "VCORE_ISOLATED_ORIGIN=1", + "python", + "-B", + "/data/fixture/origin.py", + ], + ) + argv = ( + [ + "/data/fixture/peer", + "-d", + "/data", + "-f", + "/data/fixture/config.json", + ] + if kind == "M" + else [ + "/data/fixture/peer", + "run", + "-c", + "/data/fixture/config.json", + ] + ) + server = lab.start(stack, server_dir, tag + "-server", argv) + version = command( + "exec", + server.name, + "/data/fixture/peer", + "-v" if kind == "M" else "version", + ).strip() + digest = command( + "exec", server.name, "sha256sum", "/data/fixture/peer" + ).split()[0] + if not version or digest != artifact.identity["binary_sha256"]: + raise RuntimeError("container peer identity mismatch") + artifact.identity["version"] = version + # Non-leaf pin still exercises actual certificate name checks. + from .protocol_fixtures import certificate_chain + + cert, key, pin = ( + certificate_chain(server_dir) + if encrypted + else certificates(server_dir) + ) + config = peer_config( + kind, + mode, + encrypted, + 23000, + Path("/data/fixture") / cert.name, + Path("/data/fixture") / key.name, + ) + if kind == "M": + config["hosts"] = {"vcore-fixture.test": origin.ipv4} + config["listeners"][0]["listen"] = "::" + else: + config["dns"]["hosts"] = {"vcore-fixture.test": origin.ipv4} + config["inbounds"][0]["listen"] = "::" + (server_dir / "config.json").write_text(json.dumps(config)) + upstream = None + node = node_config(mode, encrypted, server.ipv4, pin) + hop = None + if any(case in PUBLIC_CASES for case, _ in cases): + upstream_dir = root / "upstream" + upstream_dir.mkdir() + shutil.copy2(artifacts["M"].binary, upstream_dir / "peer") + upstream = lab.start( + stack, + upstream_dir, + tag + "-upstream", + [ + "/data/fixture/peer", + "-d", + "/data", + "-f", + "/data/fixture/config.json", + ], + ) + (upstream_dir / "config.json").write_text( + json.dumps( + { + "socks-port": 23001, + "allow-lan": True, + "bind-address": "*", + "log-level": "silent", + "ipv6": True, + "hosts": {"peer.fixture.test": server.ipv4}, + "rules": ["MATCH,DIRECT"], + } + ) + ) + hop = dict( + name="hop", + type="socks5", + server=upstream.ipv4, + port=23001, + udp=True, + ) + upstream.release() + upstream.wait_tcp(23001) + fixture = root / "input.json" + fixture.write_text( + json.dumps( + dict( + isolation="containers", + origin_control=f"{origin.ipv4}:24000", + origin_ipv4=origin.ipv4, + origin_ipv6=origin.ipv6, + server_ipv6=server.ipv6, + data_dir=str(root / "data"), + node=node, + hop=hop, + peer_kind=kind, + ) + ) + ) + origin.release() + server.release() + origin.wait_tcp(24000) + server.wait_tcp(23000) + report["phase"] = "execute" + for case, test in cases: + if preflight_only: + continue + print(case, flush=True) + record = dict(case_id=case, status="NOT RUN", peer_kind=kind) + report["cases"].append(record) + events = output / f"{case}-events.jsonl" + test_command = [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "vmess_public" if case in PUBLIC_CASES else "vmess_native", + test, + "--", + "--ignored", + "--exact", + "--nocapture", + ] + result = run_command( + test_command, + cwd=CORE_DIR, + timeout=240, + limit=4 * 1024 * 1024, + env=dict( + os.environ, + VCORE_VMESS_PEER=f"{server.ipv4}:23000", + VCORE_VMESS_TRANSPORT=json.dumps( + dict( + mode=mode, + tls=encrypted, + pin=pin, + peer_kind=kind, + udp_path_limit=15000, + ) + ), + VCORE_VMESS_ORIGIN_V4=origin.ipv4, + VCORE_VMESS_AB_INPUT=str(fixture), + VCORE_CASE_EVENTS=str(events), + ), + ) + (output / f"{case}.log").write_text( + redact(result.stdout.decode(errors="replace")) + ) + observed = read_events(events) if events.exists() else [] + record.update( + command=test_command, + exit_code=result.returncode, + seconds=result.seconds, + command_cleanup=result.cleanup, + status="PASS" + if result.returncode == 0 + and result.cleanup + and ( + events_pass(observed, test) + if case in PUBLIC_CASES + else observed + == [ + dict( + schema_version=1, + suite="VMESS-WIRE", + assertion=test, + status=status, + ) + for status in ("BEGIN", "PASS") + ] + ) + else "FAIL", + ) + server.ensure_alive() + origin.ensure_alive() + print(f"{case}: {record['status']}", flush=True) + for case, _ in cases: + if preflight_only: + continue + next(item for item in report["cases"] if item["case_id"] == case)[ + "cleanup" + ] = all( + peer.record["joined"] + for peer in [server, origin] + ([upstream] if upstream else []) + ) + report["status"] = ( + "READY" + if preflight_only + else "PASS" + if (preflight_only or len(report["cases"]) == len(selected)) + and all(case["status"] == "PASS" for case in report["cases"]) + else "FAIL" + ) + except KeyboardInterrupt: + report.update(status="INTERRUPTED", reason="user interruption") + except (OSError, RuntimeError, ValueError, KeyError) as error: + report.update( + status="BLOCKED" if report["phase"] == "preflight" else "FAIL", + reason=str(error) + if isinstance(error, RuntimeError) + else type(error).__name__, + ) + finally: + report["cleanup"] = all( + peer["joined"] for peer in report["isolation"].get("peers", []) + ) + report["source_unchanged"] = ( + source_identity()["source_tree_sha256"] + == report["source"]["source_tree_sha256"] + ) + if report["status"] in {"PASS", "READY"} and not ( + report["cleanup"] and report["source_unchanged"] + ): + report["status"] = "FAIL" + (output / "vmess-results.json").write_text(json.dumps(report, indent=2) + "\n") + return ( + 0 + if report["status"] in {"PASS", "READY"} + else (130 if report["status"] == "INTERRUPTED" else 1) + ) diff --git a/scripts/src/vcore_scripts/protocol_vmess_public.py b/scripts/src/vcore_scripts/protocol_vmess_public.py new file mode 100644 index 0000000..580cf4f --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_vmess_public.py @@ -0,0 +1,153 @@ +"""Required public-consumer cases, independent of the persisted manifest.""" + +from .protocol_evidence import idle_resources + +CASES = { + "VMESS-PUBLIC-UDP-FIRST": ("M", "tcp", False, "public_udp_first_response"), + "VMESS-PUBLIC-BODY-OPTIONS": ("M", "tcp", False, "public_body_options"), +} +CASES["VMESS-PUBLIC-WS-ALPN"] = ("M", "ws-alpn", True, "public_alpn_rejection") +for mode in ("tcp", "ws", "grpc", "http", "h2"): + kind = "V2" if mode in {"http", "h2"} else "M" + for tls in (False, True): + label = f"VMESS-PUBLIC-{mode.upper()}-{'TLS' if tls else 'PLAIN'}" + for suffix, test in ( + ("BASE", "public_base"), + ("NEG", "public_negative"), + ("GRAPH", "runtime::public_graph"), + ("IPV6", "runtime::public_ipv6_and_gates"), + ("ENTRYPOINTS", "runtime::public_entrypoints"), + ): + CASES[label + "-" + suffix] = (kind, mode, tls, test) +for mode in ("tcp", "grpc"): + for suffix, test in ( + ("LIFE", "runtime::public_lifecycle"), + ("OWNED", "runtime::owned_resources"), + ): + CASES[f"VMESS-PUBLIC-{mode.upper()}-{suffix}"] = ("M", mode, True, test) +for mode in ("tcp", "ws", "grpc"): + CASES[f"VMESS-REGRESSION-TROJAN-{mode.upper()}"] = ( + "M", + "trojan-" + mode, + True, + "public_legacy_regression", + ) + CASES[f"VMESS-PUBLIC-{mode.upper()}-UDP-ISOLATION"] = ( + "M", + mode, + True, + "runtime::public_udp_isolation", + ) +for mode in ("ws-ed-1", "ws-ed-2048", "ws-header", "ws-path", "grpc-custom"): + kind = "V2" if mode in {"ws-header", "ws-path"} else "M" + CASES[f"VMESS-PUBLIC-{mode.upper()}"] = (kind, mode, True, "public_base") + + +def node_config(mode, tls, host, pin): + if mode.startswith("trojan-"): + node = node_config(mode.removeprefix("trojan-"), tls, host, pin) + node.update( + type="trojan", + password="synthetic-regression-only", + sni=node.pop("servername"), + ) + for key in ("uuid", "tls"): + node.pop(key) + return node + network = mode.split("-")[0] + node = dict( + name="peer", + type="vmess", + server=host, + port=23000, + uuid="07070707-0707-0707-0707-070707070707", + udp=True, + network=network, + tls=tls, + ) + if tls: + node.update(servername="localhost", fingerprint=pin) + node["alpn"] = ["h2" if network in {"grpc", "h2"} else "http/1.1"] + if network == "ws": + node["ws-opts"] = { + "path": "/vmess-ws", + "headers": {"Host": "localhost", "X-Fixture": "exact-value"}, + } + if mode == "ws-alpn": + node["alpn"] = ["h2", "http/1.1"] + if mode.startswith("ws-ed"): + node["ws-opts"]["max-early-data"] = int(mode.split("-")[-1]) + elif mode in {"ws-header", "ws-path"}: + node["ws-opts"].update( + { + "path": "/vmess-ws/", + "max-early-data": 2048, + "early-data-header-name": "X-Vcore-Ed" + if mode == "ws-header" + else "", + } + ) + elif network == "grpc": + node["grpc-opts"] = { + "grpc-service-name": "/vmess-grpc/Tun" + if mode == "grpc-custom" + else "vmess-grpc" + } + elif network == "http": + node["http-opts"] = { + "method": "POST", + "path": ["/vmess-http"], + "headers": {"Host": ["localhost"], "X-Fixture": ["exact-value"]}, + } + elif network == "h2": + node["h2-opts"] = {"host": ["localhost"], "path": "/vmess-h2"} + return node + + +def pairs(events, suite, assertion, count): + selected = [ + e for e in events if e.get("suite") == suite and e.get("assertion") == assertion + ] + return len(selected) == count * 2 and all( + e.get("status") == ("BEGIN" if i % 2 == 0 else "PASS") + for i, e in enumerate(selected) + ) + + +def events_pass(events, test): + main = [event for event in events if event.get("suite") == "VMESS-PUBLIC"] + if len(main) != 2 or not pairs(main, "VMESS-PUBLIC", test, 1): + return False + if any( + event.get("schema_version") != 1 or event.get("status") not in {"BEGIN", "PASS"} + for event in events + ): + return False + if test == "public_base" and not ( + pairs(events, "VMESS-BASE", "tcp_10mib_both_directions", 3) + and pairs(events, "VMESS-BASE", "udp_each_codec_and_family", 3) + ): + return False + if test in {"runtime::public_lifecycle", "runtime::owned_resources"}: + suite = "VMESS-LIFE" if test.endswith("public_lifecycle") else "VMESS-OWNED" + if not pairs(events, suite, "stop_and_remain_quiet", 20): + return False + for end in [ + e for e in events if e.get("suite") == suite and e["status"] == "PASS" + ]: + if end.get("seconds", 0) < 5: + return False + if suite == "VMESS-OWNED": + checkpoints = end.get("checkpoints", []) + points = {p["phase"]: p["resources"] for p in checkpoints} + if ( + len(checkpoints) != 3 + or set(points) != {"baseline", "after-stop", "quiet"} + or not all(idle_resources(p) for p in points.values()) + or points["after-stop"] != points["quiet"] + or not idle_resources(end.get("resources")) + ): + return False + return test != "public_body_options" or pairs( + events, "VMESS-BODY", "config_controls_aead_body", 14 + ) diff --git a/scripts/src/vcore_scripts/protocol_vmess_udp_ab.py b/scripts/src/vcore_scripts/protocol_vmess_udp_ab.py new file mode 100644 index 0000000..484954c --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_vmess_udp_ab.py @@ -0,0 +1,88 @@ +"""Container-only matched UDP diagnostic, not VMESS stage acceptance.""" + +from __future__ import annotations + +import argparse +import itertools +import re +import sys +from pathlib import Path + +from .mihomo_isolation import exclusive_run + + +def options(codecs): + return [ + dict(codec=codec, cipher=cipher, padding=padding, length=length) + for codec, cipher, padding, length in itertools.product( + codecs, + ["none", "auto", "aes-128-gcm", "chacha20-poly1305"], + [False, True], + [False, True], + ) + if cipher != "none" or not (padding or length) + ] + + +def warning_summary(path): + lines = path.read_text(errors="replace").splitlines() + loopbacks = [] + for line in lines: + if "reject loopback connection" in line: + endpoints = re.findall( + r"(?:\[[0-9a-fA-F:]+\]|(?:[a-zA-Z0-9_-]+\.)+[a-zA-Z0-9_-]+):(\d+)\b", + line, + ) + loopbacks.append(dict(ports=[int(port) for port in endpoints])) + return dict(lines=len(lines), loopback_rejections=loopbacks) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("output", type=Path) + parser.add_argument( + "--modes", + nargs="+", + choices=["tcp", "ws", "grpc"], + default=["tcp", "ws", "grpc"], + ) + parser.add_argument( + "--tls", nargs="+", choices=["plain", "tls"], default=["plain", "tls"] + ) + parser.add_argument( + "--codecs", + nargs="+", + choices=["raw", "xudp", "packetaddr"], + default=["raw", "xudp", "packetaddr"], + ) + parser.add_argument( + "--families", + nargs="+", + choices=["ipv4", "ipv6", "domain"], + default=["ipv4", "ipv6", "domain"], + ) + parser.add_argument("--rounds", type=int, choices=range(1, 21), default=2) + parser.add_argument("--packets", type=int, choices=range(1, 101), default=100) + parser.add_argument("--sizes", nargs="+", type=int) + parser.add_argument("--include-boundary", action="store_true") + parser.add_argument("--collision-probe", action="store_true") + parser.add_argument("--socket-probe", action="store_true") + parser.add_argument("--nat-reuse-probe", action="store_true") + args = parser.parse_args() + if args.collision_probe or args.socket_probe: + parser.error("BLOCKED: archived host-server probes require container migration") + if args.sizes and any(size < 1 or size > 15000 for size in args.sizes): + parser.error("sizes must be between 1 and the 15000-byte fixture budget") + args.tls = [value == "tls" for value in args.tls] + if args.nat_reuse_probe: + args.modes, args.tls, args.codecs = ["tcp"], [False], ["raw", "xudp"] + args.rounds, args.packets, args.sizes, args.families = 1, 1, [1], ["ipv4"] + args.include_boundary = False + from .protocol_vmess_udp_container import run as container_run + + with exclusive_run(): + return container_run(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/src/vcore_scripts/protocol_vmess_udp_container.py b/scripts/src/vcore_scripts/protocol_vmess_udp_container.py new file mode 100644 index 0000000..bab7145 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_vmess_udp_container.py @@ -0,0 +1,382 @@ +"""Container-only VMESS UDP differential; not public-runtime or stage acceptance.""" + +from __future__ import annotations + +import contextlib +import itertools +import json +import os +import shutil +import tempfile +from pathlib import Path + +from .builds import CORE_DIR +from .mihomo_release import download_mihomo +from .protocol_containers import ContainerLab, command +from .protocol_fixtures import certificates +from .protocol_inputs import redact, source_identity +from .protocol_peers import run_command +from .protocol_vmess import peer_config +from .protocol_vmess_udp_ab import options, warning_summary + + +def client_config(matrix, mode, encrypted, server, origin, client, pin): + listeners, nodes, selected = [], [], [] + for index, option in enumerate(matrix): + port = 25000 + index + selected.append(dict(option, socks_port=port, socks_host=client)) + node = dict( + name=f"edge-{index}", + type="vmess", + server=server, + port=23000, + uuid="07070707-0707-0707-0707-070707070707", + alterId=0, + cipher=option["cipher"], + network=mode, + tls=encrypted, + udp=True, + ) + node.update( + { + "global-padding": option["padding"], + "authenticated-length": option["length"], + } + ) + if option["codec"] != "raw": + node["packet-encoding"] = option["codec"] + if encrypted: + node.update(servername="localhost", fingerprint=pin) + if mode == "ws": + node["ws-opts"] = dict(path="/vmess-ws", headers={"Host": "localhost"}) + if mode == "grpc": + node["grpc-opts"] = {"grpc-service-name": "vmess-grpc"} + nodes.append(node) + listeners.append( + dict( + name=f"fixture-{index}", + type="socks", + listen=client, + port=port, + udp=True, + proxy=node["name"], + ) + ) + return { + "mode": "rule", + "log-level": "warning", + "ipv6": True, + "hosts": {"vcore-fixture.test": origin}, + "listeners": listeners, + "proxies": nodes, + "rules": ["MATCH,REJECT"], + }, selected + + +def run(args): + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=False) + matrix = options(args.codecs) + sizes = args.sizes or [1, 64, 512, 1200] + boundary = args.include_boundary or args.sizes is None + report = dict( + scope="container-matched-mihomo-vcore-udp-diagnostic", + status="NOT RUN", + phase="preflight", + source=source_identity(), + cases=[], + isolation={}, + peer={}, + cleanup=False, + rounds=args.rounds, + packets_per_size=args.packets, + sizes=sizes, + include_boundary=boundary, + boundary_policy="raw/XUDP: 15000; packetaddr: 15000 minus 7/19 address bytes", + families=args.families, + options=matrix, + nat_reuse_probe=args.nat_reuse_probe, + fixture_source_policy="distinct SOCKS UDP sockets retained until transport end", + notes=[ + "Separate owned VMs: protocol server, official client ingress, UDP origin.", + "Host runs VCore and test driver only; no published host/server ports.", + "Same body flags, families, sizes, packet count; order alternates.", + "Send, origin observation and reply each retain their 1s deadline.", + "Origin now echoes autonomously and reports exact received bytes via TCP.", + "Failed associations stop without retries; loopback protection unchanged.", + "Virtual IPv6 and wire diagnostic do not prove device or VMESS acceptance.", + ], + ) + try: + binary = download_mihomo( + "linux-arm64", directory=output / "binary", identity=report["peer"] + ) + lab = ContainerLab(report["isolation"]) + report["phase"] = "build" + build = run_command( + [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "vmess_native", + "--no-run", + ], + cwd=CORE_DIR, + timeout=180, + ) + (output / "build.log").write_text(redact(build.stdout.decode(errors="replace"))) + if build.returncode != 0 or not build.cleanup: + raise RuntimeError("diagnostic build failed") + for mode, encrypted in itertools.product(args.modes, args.tls): + name = f"{mode}-{'tls' if encrypted else 'plain'}" + record = dict(name=name, status="NOT RUN") + report["cases"].append(record) + report["phase"] = "peer-start" + print(f"UDP-CONTAINERS: {name}", flush=True) + with tempfile.TemporaryDirectory( + prefix="private-", dir=output + ) as temporary: + root = Path(temporary) + with contextlib.ExitStack() as stack: + directories = {} + peers = {} + for role in ["origin", "server", "client"]: + directory = root / role + directory.mkdir() + directories[role] = directory + if role == "origin": + shutil.copyfile( + Path(__file__).with_name("container_udp_origin.py"), + directory / "origin.py", + ) + argv = [ + "env", + "VCORE_ISOLATED_ORIGIN=1", + "python", + "-B", + "/data/fixture/origin.py", + ] + else: + shutil.copy2(binary, directory / "mihomo") + argv = [ + "/data/fixture/mihomo", + "-d", + "/data", + "-f", + "/data/fixture/config.json", + ] + peers[role] = lab.start( + stack, directory, f"{name}-{role}", argv + ) + origin, server, client = ( + peers[key] for key in ["origin", "server", "client"] + ) + version = command( + "exec", server.name, "/data/fixture/mihomo", "-v" + ).strip() + digest = command( + "exec", server.name, "sha256sum", "/data/fixture/mihomo" + ).split()[0] + if digest != report["peer"]["binary_sha256"]: + raise RuntimeError( + "container peer identity differs from download" + ) + report["peer"]["version"] = version + record["peer_version"] = version + record["origin_python"] = command( + "exec", origin.name, "python", "--version" + ).strip() + cert, key, pin = certificates(directories["server"]) + config = peer_config( + "M", + mode, + encrypted, + 23000, + Path("/data/fixture") / cert.name, + Path("/data/fixture") / key.name, + ) + config["log-level"] = "warning" + config["hosts"] = {"vcore-fixture.test": origin.ipv4} + config["listeners"][0]["listen"] = server.ipv4 + (directories["server"] / "config.json").write_text( + json.dumps(config) + ) + config, selected = client_config( + matrix, + mode, + encrypted, + server.ipv4, + origin.ipv4, + client.ipv4, + pin, + ) + (directories["client"] / "config.json").write_text( + json.dumps(config) + ) + for peer in peers.values(): + peer.release() + origin.wait_tcp(24000) + server.wait_tcp(23000) + for option in selected: + client.wait_tcp(option["socks_port"]) + report["phase"] = "execute" + fixture = root / "input.json" + fixture.write_text( + json.dumps( + dict( + isolation="containers", + origin_control=f"{origin.ipv4}:24000", + origin_ipv4=origin.ipv4, + origin_ipv6=origin.ipv6, + options=selected, + rounds=args.rounds, + packets_per_size=args.packets, + sizes=sizes, + include_boundary=boundary, + families=args.families, + ) + ) + ) + events = output / f"{name}.jsonl" + test_command = [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "vmess_native", + "udp_ab::socks_nat_source_reuse" + if args.nat_reuse_probe + else "udp_ab::matched_clients", + "--", + "--ignored", + "--exact", + "--nocapture", + ] + execution = run_command( + test_command, + cwd=CORE_DIR, + timeout=600, + limit=4 * 1024 * 1024, + env=dict( + os.environ, + VCORE_VMESS_PEER=f"{server.ipv4}:23000", + VCORE_VMESS_TRANSPORT=json.dumps( + dict(mode=mode, tls=encrypted, pin=pin) + ), + VCORE_VMESS_ORIGIN_V4=origin.ipv4, + VCORE_VMESS_AB_INPUT=str(fixture), + VCORE_VMESS_AB_EVENTS=str(events), + VCORE_VMESS_AB_PRIVATE=str(root), + ), + ) + (output / f"{name}.log").write_text( + redact(execution.stdout.decode(errors="replace")) + ) + observations = ( + [json.loads(line) for line in events.read_text().splitlines()] + if events.exists() + else [] + ) + expected = ( + 3 + if args.nat_reuse_probe + else args.rounds * len(matrix) * len(args.families) * 2 + ) + record.update( + command=test_command, + exit_code=execution.returncode, + seconds=execution.seconds, + command_cleanup=execution.cleanup, + expected_associations=expected, + observed_associations=len(observations), + failures=[ + item for item in observations if item["status"] != "PASS" + ], + clients={ + kind: dict( + associations=sum( + item["client"] == kind for item in observations + ), + failed=sum( + item["client"] == kind and item["status"] != "PASS" + for item in observations + ), + completed_packets=sum( + item["completed"] + for item in observations + if item["client"] == kind + ), + ) + for kind in ["vcore", "mihomo"] + }, + ) + for peer in peers.values(): + peer.ensure_alive() + # Stop/delete VMs and join bounded log readers before inspection. + for role in ["server", "client"]: + record[f"{role}_warnings"] = warning_summary( + directories[role] / "peer.log" + ) + record["cleanup"] = all( + peer.record["joined"] for peer in peers.values() + ) + expected_outcome = ( + [item["status"] for item in observations] + == ["PASS", "REPRODUCED", "PASS"] + if args.nat_reuse_probe + else not record["failures"] + ) + record["status"] = ( + ("REPRODUCED" if args.nat_reuse_probe else "PASS") + if ( + execution.returncode == 0 + and execution.cleanup + and record["cleanup"] + and len(observations) == expected + and expected_outcome + and all( + item.get("driver_joined", True) for item in observations + ) + ) + else "FAIL" + ) + print( + json.dumps( + {key: record[key] for key in ["name", "status", "clients"]} + ), + flush=True, + ) + expected_status = "REPRODUCED" if args.nat_reuse_probe else "PASS" + report["status"] = ( + expected_status + if report["cases"] + and all(case["status"] == expected_status for case in report["cases"]) + else "FAIL" + ) + except KeyboardInterrupt: + report.update(status="INTERRUPTED", reason="user interruption") + except (OSError, RuntimeError, ValueError, KeyError) as error: + status = "BLOCKED" if report["phase"] == "preflight" else "FAIL" + report.update(status=status, reason=type(error).__name__) + print(f"UDP-CONTAINERS {status}: {type(error).__name__}", flush=True) + finally: + report["cleanup"] = all( + peer["joined"] for peer in report["isolation"].get("peers", []) + ) + report["source_unchanged"] = ( + source_identity()["source_tree_sha256"] + == report["source"]["source_tree_sha256"] + ) + if report["status"] in {"PASS", "REPRODUCED"} and not ( + report["cleanup"] and report["source_unchanged"] + ): + report["status"] = "FAIL" + (output / "udp-ab.json").write_text(json.dumps(report, indent=2) + "\n") + return ( + 0 + if report["status"] == "PASS" + else (130 if report["status"] == "INTERRUPTED" else 1) + ) diff --git a/scripts/src/vcore_scripts/protocol_xhttp_acceptance.py b/scripts/src/vcore_scripts/protocol_xhttp_acceptance.py new file mode 100644 index 0000000..da32975 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_xhttp_acceptance.py @@ -0,0 +1,660 @@ +"""XHTTP execution and independently recomputed, fail-closed stage evidence.""" + +from __future__ import annotations + +import json +import sys + +from .builds import DEFAULT_FEATURES +from .protocol_evidence import HEX, idle_resources, new_result, read_events, read_json +from .protocol_inputs import redact +from .protocol_vless_acceptance import commands as previous_commands +from .protocol_xhttp_catalog import ( + FIELD_IDS, + GATES, + NATIVE, + OBSERVATIONS, + REPRESENTATIVES, + SECURITY, + UNIT_TESTS, + definitions, +) +from .protocol_xhttp_fields import PUBLIC_TESTS, events_pass, public_events_pass + + +def test_command(test): + return [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "vless_public" if test in PUBLIC_TESTS else "xhttp_native", + test, + "--", + "--exact", + "--ignored", + "--nocapture", + ] + + +def commands(identifier): + cargo = ["cargo", "test", "--locked", "--all-features"] + if identifier in {"XHTTP-CFG", "XHTTP-UNIT", "XHTTP-RELEASE"}: + names = list(UNIT_TESTS) + if identifier == "XHTTP-CFG": + names = names[:2] + elif identifier == "XHTTP-UNIT": + names = names[2:] + flags = ["--release"] if identifier == "XHTTP-RELEASE" else [] + selected = sum((["--test", name] for name in names), []) + result = [cargo + flags + selected] + if identifier != "XHTTP-CFG": + result += [ + cargo + flags + ["--lib", "transport::xhttp::tests::"], + cargo + flags + ["--lib", "transport::sing_mux::"], + cargo + flags + ["--test", "vless_lifecycle"], + ] + return result + if identifier == "XHTTP-REGRESSION": + return sum( + ( + previous_commands("VLESS-" + part) + for part in ("REGRESSION", "CODEC", "TRANSPORT", "VISION") + ), + [], + ) + if identifier == "XHTTP-QUALITY": + return previous_commands("VLESS-QUALITY") + [ + [ + "cargo", + "test", + "--locked", + "--manifest-path", + "crates/vcore-netstack/Cargo.toml", + "--all-targets", + ], + [ + "cargo", + "clippy", + "--locked", + "--manifest-path", + "crates/vcore-netstack/Cargo.toml", + "--all-targets", + "--", + "-D", + "warnings", + ], + ] + return [] + + +def gate_result(case, records, events): + result = new_result(case) + good = bool(records) and all( + r.get("exit_code") == 0 and r.get("cleanup") is True for r in records + ) + assertions = {} + for name in case["expected_observation"]: + observed = [e for e in events if e.get("assertion") == name] + assertions[name] = len(observed) == 2 and [ + e.get("status") for e in observed + ] == ["BEGIN", "PASS"] + if case["case_id"] not in OBSERVATIONS: + assertions = dict.fromkeys(case["expected_observation"], good) + else: + good &= all(assertions.values()) and all( + e.get("schema_version") == 1 and e.get("status") in {"BEGIN", "PASS"} + for e in events + ) + if case["case_id"] != "XHTTP-REGRESSION": + unit = [e for e in events if e.get("suite") == "XHTTP-UNIT"] + good &= len(unit) == 2 * len(assertions) and all( + e["assertion"] in assertions for e in unit + ) + result.update( + status="PASS" if good else "FAIL", + assertions=assertions, + evidence=case["required_evidence"] if good else [], + command_exit_code=0 if good else 1, + cleanup=bool(records) and all(r.get("cleanup") is True for r in records), + ) + return result + + +def native_results(cases, report, directory, *, allow_partial=False): + records = report.get("cases", []) + expected = {case["case_id"] for case in cases} + actual = {r.get("case_id") for r in records} + if ( + len(records) != len(actual) + or not actual <= expected + or (not allow_partial and actual != expected) + ): + raise ValueError("missing, duplicate or unknown XHTTP native case") + by_id = {record["case_id"]: record for record in records} + output = [] + for case in cases: + identifier = case["case_id"] + if identifier not in by_id: + continue + record = by_id[identifier] + variant, test = NATIVE[identifier] + path = directory / f"{identifier}-events.jsonl" + observed = read_events(path) if path.exists() else [] + observed_ok = ( + public_events_pass(observed, test) + if test in PUBLIC_TESTS + else events_pass(observed, test, owned=test.startswith("lifecycle::")) + ) + clean = record.get("command_cleanup") is True and report.get("cleanup") is True + good = ( + observed_ok + and clean + and report.get("source_unchanged") is True + and record.get("status") == "PASS" + and record.get("exit_code") == 0 + and record.get("command") == test_command(test) + and record.get("assertion") == test + and record.get("variant") == variant + ) + if test.startswith("close::"): + path = directory / f"{variant}-close-reference.json" + reference = read_json(path) if path.exists() else {} + good &= ( + reference.get("scope") == "same-mode" + and reference.get("terminated") is True + and reference.get("tail_hex") == "" + ) + result = new_result(case) + result.update( + status="PASS" if good else "FAIL", + assertions={test: observed_ok}, + evidence=case["required_evidence"] if good else [], + command_exit_code=record.get("exit_code"), + cleanup=clean, + ) + output.append(result) + return output + + +def security_results(cases, report, directory, *, allow_partial=False): + from .protocol_xhttp_security import security_cases + + expected = {} + identity = { + k: {"certificate": "fixture", "private-key": "fixture"} + for k in ("valid", "absent", "expired", "wrong-ca") + } + for case in cases: + version = SECURITY[case["case_id"]] + base = dict( + alpn=["http/1.1" if version == "h1" else version], + certificate="fixture", + **{"private-key": "fixture", "xhttp-opts": {}}, + ) + expected[version] = { + f"{version}-{name}": failure + for name, _, _, failure in security_cases( + base, identity, identity["valid"], "2001:db8::1" + ) + } + records = report.get("cases", []) + ids = set().union(*(set(e) for e in expected.values())) + actual = {r.get("case_id") for r in records} + if ( + len(records) != len(actual) + or not actual <= ids + or (not allow_partial and actual != ids) + ): + raise ValueError("missing, duplicate or unknown XHTTP security case") + results = [] + by_id = {r["case_id"]: r for r in records} + for case in cases: + version = SECURITY[case["case_id"]] + if allow_partial and not actual.intersection(expected[version]): + continue + clean = report.get("cleanup") is True and report.get("owned_remaining") == [] + good = clean and report.get("source_unchanged") is True + for name, failure in expected[version].items(): + record = by_id.get(name, {}) + path = directory / f"{name}-events.jsonl" + events = read_events(path) if path.exists() else [] + good &= ( + events_pass(events, "security::native_xhttp_security") + and record.get("exit_code") == 0 + and record.get("command_cleanup") is True + and record.get("command") + == test_command("security::native_xhttp_security") + and record.get("status") == "PASS" + ) + if version == "h3" and failure: + category = { + "absent": "certificate_required", + "expired": "certificate_expired", + "wrong-ca": "unknown_ca", + }[failure] + good &= record.get("identity_enforced") is True and any( + e.get("category") == category + for e in record.get("certificate_errors", []) + ) + result = new_result(case) + result.update( + status="PASS" if good else "FAIL", + assertions={case["case_id"]: good}, + evidence=case["required_evidence"] if good else [], + command_exit_code=0 if good else 1, + cleanup=clean, + ) + results.append(result) + return results + + +def fields_report(cases, results): + statuses = {r["case_id"]: r["status"] for r in results} + complete = set(statuses) == {c["case_id"] for c in definitions()} and all( + s == "PASS" for s in statuses.values() + ) + fields = [] + for row in sorted(FIELD_IDS): + owners = [c["case_id"] for c in cases if row in c["row_ids"]] + behavior = [c for c in owners if c in NATIVE or c in SECURITY] + fields.append( + dict( + row_id=row, + required_cases=owners, + status="PASS" + if complete and behavior and "XHTTP-CFG" in owners + else "NOT RUN", + ) + ) + return dict(schema_version=1, stage="XHTTP", protocol="vless", fields=fields) + + +def prepare_peers(output, kinds): + from .caddy_build import build_caddy + from .mihomo_release import download_mihomo + from .native_release import download_native + + supplied = {} + for kind in sorted(kinds): + directory = output / "binaries" / kind + if kind == "M": + identity = {} + binary = download_mihomo( + "linux-arm64", directory=directory, identity=identity + ) + supplied[kind] = binary, identity + else: + artifact = ( + build_caddy(directory) + if kind == "Caddy" + else download_native(kind, directory, "linux-arm64", defer_version=True) + ) + supplied[kind] = artifact.binary, artifact.identity + return supplied + + +def preflight(output): + """Only verify the XHTTP native artifacts inside an owned isolated guest.""" + import shutil + import tempfile + from contextlib import ExitStack + + from .protocol_containers import ContainerLab, command + from .protocol_inputs import CORE_DIR + + supplied = prepare_peers(output, {"M", "XR", "V2", "Caddy"}) + isolation = {} + lab = ContainerLab(isolation, mtu=1500) + try: + with tempfile.TemporaryDirectory( + prefix="xhttp-preflight-", dir=CORE_DIR / "target" + ) as tmp: + from pathlib import Path + + root = Path(tmp) + for kind, (binary, _) in supplied.items(): + shutil.copy2(binary, root / kind) + with ExitStack() as stack: + peer = lab.start( + stack, + root, + "preflight", + ["python", "-c", "import time; time.sleep(600)"], + ) + peer.release() + for kind, (_, identity) in supplied.items(): + binary = "/data/fixture/" + kind + version = command( + "exec", peer.name, binary, "-v" if kind == "M" else "version" + ).strip() + digest = command("exec", peer.name, "sha256sum", binary).split()[0] + if not version or digest != identity["binary_sha256"]: + raise RuntimeError("isolated XHTTP artifact identity mismatch") + identity["version"] = version + finally: + (output / "peers.json").write_text( + json.dumps({k: i for k, (_, i) in supplied.items()}, indent=2) + "\n" + ) + (output / "preflight.json").write_text( + json.dumps( + dict( + scope="preflight-only", + wire_acceptance="NOT RUN", + isolation=isolation, + ), + indent=2, + ) + + "\n" + ) + + +def execute(cases, run, output, results): + from .protocol_harness import _command, _features, _scripts + from .protocol_xhttp_fields import run as fields_run + from .protocol_xhttp_security import run as security_run + + for case in cases: + identifier = case["case_id"] + if identifier not in GATES: + continue + if identifier == "XHTTP-FEATURES": + result = _features(case, run, output) + for name, command in ( + ( + "feature-outbound-vless", + [ + "cargo", + "check", + "--locked", + "--no-default-features", + "--lib", + "--features", + "outbound-vless", + ], + ), + ( + "production-build", + [ + "cargo", + "build", + "--locked", + "--release", + "--no-default-features", + "--features", + DEFAULT_FEATURES, + "--lib", + ], + ), + ): + observed = _command(run, output, name, command, 1200) + if observed.returncode or not observed.cleanup: + result.update( + status="FAIL", + command_exit_code=observed.returncode, + cleanup=observed.cleanup, + ) + results[identifier] = result + elif identifier == "XHTTP-SCRIPTS": + results[identifier] = _scripts(case, run, output) + else: + start = len(run["commands"]) + events = [] + for i, command in enumerate(commands(identifier)): + path = output / f"{identifier}-{i}-events.jsonl" + observed = _command( + run, output, f"{identifier}-{i}", command, 1200, events=path + ) + if path.exists(): + events.extend(read_events(path)) + if observed.returncode or not observed.cleanup: + break + results[identifier] = gate_result(case, run["commands"][start:], events) + if results[identifier]["status"] != "PASS": + raise RuntimeError(f"{identifier} failed; later cases remain NOT RUN") + native = [c for c in cases if c["case_id"] in NATIVE] + security = [c for c in cases if c["case_id"] in SECURITY] + kinds = {c["peer_kind"] for c in native + security} + if native or security: + kinds.add("M") + if any(c["peer_kind"] == "XR" for c in security): + kinds.add("Caddy") + supplied = prepare_peers(output, kinds) + try: + if native: + jobs = {} + for case in native: + identifier = case["case_id"] + variant, test = NATIVE[identifier] + jobs.setdefault(variant, []).append(dict(case_id=identifier, test=test)) + directory = output / "native" + # Exercise native-gap transports before the long Mihomo matrix. + # Ordering changes neither the frozen case set nor its evidence. + ordered = sorted( + jobs, key=lambda name: (not name.startswith(("h3-", "outer-")), name) + ) + fields_run(directory, ordered, jobs=jobs, supplied=supplied) + report = read_json(directory / "xhttp-fields-results.json") + for result in native_results(native, report, directory, allow_partial=True): + results[result["case_id"]] = result + if report["status"] != "PASS": + raise RuntimeError("native XHTTP cases failed") + if security: + directory = output / "security" + security_run( + directory, [SECURITY[c["case_id"]] for c in security], supplied=supplied + ) + report = read_json(directory / "xhttp-security-results.json") + for result in security_results( + security, report, directory, allow_partial=True + ): + results[result["case_id"]] = result + if report["status"] != "PASS": + raise RuntimeError("native XHTTP security failed") + finally: + (output / "peers.json").write_text( + json.dumps({k: identity for k, (_, identity) in supplied.items()}, indent=2) + + "\n" + ) + (output / "fields.json").write_text( + json.dumps(fields_report(cases, list(results.values())), indent=2) + "\n" + ) + + +def required_command_names(): + return { + f"{identifier}-{i}" + for identifier in GATES + for i in range(len(commands(identifier))) + } | { + "production-build", + "offline-scripts", + "feature-default", + "feature-minimal", + "feature-outbound-vless", + *( + "feature-" + name + for name in ( + "outbound-trojan", + "outbound-vmess", + "outbound-hysteria2", + "stream-transport", + "quic-transport", + ) + ), + } + + +def check(run_dir, cases, run, results, peers, paths): + from .protocol_harness import SCRIPT_OBSERVATIONS + + needed = { + "native/xhttp-fields-results.json", + "security/xhttp-security-results.json", + "fields.json", + "script-tests.json", + "resources.jsonl", + "peers.json", + "cases.json", + "summary.md", + } + if not needed <= set(paths) or cases != definitions(): + raise ValueError("missing XHTTP artifacts or altered frozen metadata") + recalculated = [] + source_keys = ( + "parent_commit", + "source_tree_sha256", + "dirty_patch_sha256", + "lock_sha256", + ) + for name, file, evaluator, wanted in ( + ("native", "xhttp-fields-results.json", native_results, NATIVE), + ("security", "xhttp-security-results.json", security_results, SECURITY), + ): + report = read_json(run_dir / name / file) + if ( + report.get("source") != {key: run.get(key) for key in source_keys} + or report.get("status") != "PASS" + ): + raise ValueError("XHTTP native source identity mismatch") + isolation = report.get("isolation", {}) + if ( + isolation.get("host_servers") is not False + or isolation.get("network_mode") != "hostOnly" + or not isolation.get("peers") + or any( + p.get("joined") is not True or p.get("started") is not True + for p in isolation["peers"] + ) + ): + raise ValueError("XHTTP native isolation or cleanup missing") + if any( + identity != peers.get(kind) + for kind, identity in report.get("peers", {}).items() + ): + raise ValueError("XHTTP peer identity changed between reports") + recalculated += evaluator( + [c for c in cases if c["case_id"] in wanted], report, run_dir / name + ) + if set(peers) != {"M", "XR", "V2", "Caddy"}: + raise ValueError("XHTTP required official peers missing") + for kind, peer in peers.items(): + project = { + "M": "MetaCubeX/mihomo", + "XR": "XTLS/Xray-core", + "V2": "v2fly/v2ray-core", + "Caddy": "caddyserver/caddy", + }[kind] + if ( + peer.get("target") != "linux-arm64" + or not peer.get("version") + or not peer.get("source_url", "").startswith( + f"https://github.com/{project}/releases/" + ) + or not HEX.fullmatch(peer.get("binary_sha256", "")) + ): + raise ValueError("invalid official XHTTP peer identity") + if kind != "Caddy" and not HEX.fullmatch(peer.get("archive_sha256", "")): + raise ValueError("missing native download digest") + if kind == "Caddy" and ( + peer.get("source_built") is not True + or peer.get("build_cleanup") is not True + or peer.get("extra_plugins") != [] + or set(peer.get("inputs", {})) != {"go.mod", "go.sum", "main.go"} + or any(not HEX.fullmatch(value) for value in peer["inputs"].values()) + ): + raise ValueError("gateway is outside the approved xcaddy build exception") + records = run.get("commands", []) + if ( + len(records) != len(required_command_names()) + or {r.get("name") for r in records} != required_command_names() + or any( + r.get("exit_code") != 0 + or r.get("cleanup") is not True + or r.get("log") not in paths + for r in records + ) + ): + raise ValueError("missing, duplicate or failed XHTTP command") + for case in cases: + identifier = case["case_id"] + expected_commands = commands(identifier) + if not expected_commands: + continue + selected, events = [], [] + for i, argv in enumerate(expected_commands): + record = next(r for r in records if r["name"] == f"{identifier}-{i}") + if record["command"] != [redact(part) for part in argv]: + raise ValueError("XHTTP validation command changed") + selected.append(record) + path = run_dir / f"{identifier}-{i}-events.jsonl" + if path.exists(): + events.extend(read_events(path)) + recalculated.append(gate_result(case, selected, events)) + actual = {r["case_id"]: r for r in results} + if any(r != actual.get(r["case_id"]) for r in recalculated): + raise ValueError("XHTTP results disagree with structured evidence") + for record in records: + name = record["name"] + if name == "feature-default": + argv = ["cargo", "test", "--locked", "--test", "feature_foundations"] + elif name.startswith("feature-"): + argv = ["cargo", "check", "--locked", "--no-default-features", "--lib"] + if name != "feature-minimal": + argv += ["--features", name.removeprefix("feature-")] + elif name == "production-build": + argv = [ + "cargo", + "build", + "--locked", + "--release", + "--no-default-features", + "--features", + DEFAULT_FEATURES, + "--lib", + ] + elif name == "offline-scripts": + argv = [ + sys.executable, + "-m", + "vcore_scripts.protocol_script_tests", + str(run_dir / "script-tests.json"), + ] + else: + continue + if record["command"] != [redact(part) for part in argv]: + raise ValueError("XHTTP feature or script command changed") + script = read_json(run_dir / "script-tests.json") + tests = {t["test"]: t["status"] for t in script["cases"]} + required_tests = set(SCRIPT_OBSERVATIONS.values()) | { + "test_protocol_xhttp.XhttpAcceptanceTest." + name + for name in ( + "test_frozen_catalog_has_all_rows_modes_and_mux_wires", + "test_unknown_duplicate_wrong_suite_and_partial_events_fail", + "test_unit_gate_requires_every_frozen_assertion_and_successful_command", + "test_native_missing_and_duplicate_records_are_not_success", + ) + } + if ( + len(tests) != script["tests_run"] + or not required_tests <= set(tests) + or any(v != "PASS" for v in tests.values()) + ): + raise ValueError("XHTTP offline failure-path proof missing") + fields = fields_report(cases, results) + if read_json(run_dir / "fields.json") != fields or any( + f["status"] != "PASS" for f in fields["fields"] + ): + raise ValueError("incomplete XHTTP field behavior coverage") + resources = read_events(run_dir / "resources.jsonl") + owned = [ + e + for e in resources + if e.get("suite") == "XHTTP-OWNED" and e.get("status") == "PASS" + ] + if len(owned) != 20 * len(REPRESENTATIVES) or any( + not idle_resources(e.get("resources")) for e in owned + ): + raise ValueError("missing XHTTP twenty-round owned resource proof") + print(f"XHTTP: PASS ({len(cases)} required cases; {len(FIELD_IDS)} field rows)") diff --git a/scripts/src/vcore_scripts/protocol_xhttp_catalog.py b/scripts/src/vcore_scripts/protocol_xhttp_catalog.py new file mode 100644 index 0000000..a618fa1 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_xhttp_catalog.py @@ -0,0 +1,394 @@ +"""Frozen XHTTP cases. Independent tuning is pairwise; coupled modes remain explicit.""" + +from __future__ import annotations + +import hashlib + +from .protocol_vless_acceptance import OBSERVATIONS as PREVIOUS +from .protocol_vmess_acceptance import SCRIPT_ASSERTIONS +from .protocol_xhttp_fields import native_kind, variants + +UNIT_TESTS = { + "xhttp_config": [ + "xhttp_fields_reject_wrong_types_null_and_every_range_boundary_before_io", + "empty_http_authority_falls_back_to_each_legs_authentication_name", + "download_security_changes_do_not_silently_discard_inherited_certificate_policy", + "h3_requires_exclusive_alpn_and_standard_tls_on_each_leg", + "custom_headers_inherit_replace_clear_and_do_not_leak_values", + "conflicting_request_fields_are_rejected_on_both_legs_before_io", + "http_version_selects_h1_only_for_its_single_alpn_and_supports_plaintext", + "download_certificate_policy_inherits_and_explicit_false_or_empty_clears", + "download_mtls_identity_is_inherited_or_replaced_and_cleared_as_a_pair", + "download_reality_object_replaces_inherits_or_clears_without_merging_keys", + "reuse_object_presence_and_download_whole_object_replacement_are_preserved", + ], + "sing_mux_config": [ + "sing_mux_accepts_three_protocols_and_rejects_invalid_or_ignored_options" + ], + "xhttp_requests": [ + "packet_up_coalesces_small_writes_and_flushes_without_more_caller_io", + "packet_up_backpressures_bounded_batches_and_stop_cancels_the_timer", + "first_vless_read_never_acknowledges_or_replays_a_concurrent_packet_write", + "response_codes_follow_mihomo_streaming_and_packet_rules", + "streaming_content_type_can_be_disabled_without_disabling_padding", + "custom_semantic_headers_are_allowed_when_no_generated_field_overwrites_them", + "padding_uses_the_configured_http_location_and_encoding", + "packet_metadata_and_upload_methods_are_sent_in_each_supported_location", + "packet_upload_can_move_payload_into_bounded_header_or_cookie_chunks", + "http1_stream_one_is_chunked_duplex_and_shutdown_closes_both_directions", + "http1_packet_up_shares_a_session_across_two_connections", + "http1_response_body_can_arrive_after_the_response_headers", + "custom_request_headers_reach_the_http_peer_without_changing_body", + ], + "xhttp_reuse": [ + "enabled_reuse_shares_physical_h2_without_one_close_killing_its_sibling", + "reuse_thresholds_expand_and_retire_without_closing_active_sessions", + "expired_transport_is_not_assigned_again_while_old_session_stays_live", + "pooled_packet_posts_do_not_each_consume_a_transport_lease", + "h2_keepalive_uses_default_explicit_and_disabled_idle_periods", + "download_uses_its_own_reuse_counters_and_keeps_shared_sessions_alive", + "h1_packet_upload_reuses_its_idle_connection_but_reopens_the_cancelled_get", + "stopping_during_handshake_prevents_late_driver_admission", + "stop_wakes_a_handshake_even_when_the_peer_never_responds", + ], + "sing_mux": [ + "yamux_replacing_dropped_streams_at_capacity_keeps_the_sibling_alive", + "sing_mux_scheduling_uses_the_selected_branch_not_a_global_stream_quota", + "h2mux_sends_idle_ping_and_retires_a_peer_that_never_acknowledges", + "only_tcp_preserves_all_three_vless_udp_wire_commands", + "h2mux_reuses_physical_vless_and_cancels_only_one_logical_stream", + "yamux_reuses_physical_vless_and_cancels_only_one_logical_stream", + "smux_reuses_physical_vless_and_cancels_only_one_logical_stream", + "sing_mux_udp_preserves_addresses_and_cancellation_safe_partial_frames", + ], + "xhttp_budget": [ + "h3_rejects_incapable_or_small_budget_upstreams_before_any_datagram" + ], +} + +DRIVER_TESTS = ["malformed_smux_headers_close_the_owned_driver_before_reading_a_body"] + +FIELD_IDS = { + *(f"X{i:02}" for i in range(1, 30)), + *(f"D{i:02}" for i in range(1, 16)), + *(f"D{i:02}" for i in range(27, 33)), + *(f"M{i:02}" for i in range(1, 8)), +} +OBSERVATIONS = { + "XHTTP-CFG": UNIT_TESTS["xhttp_config"] + UNIT_TESTS["sing_mux_config"], + "XHTTP-UNIT": sum( + ( + UNIT_TESTS[n] + for n in ("xhttp_requests", "xhttp_reuse", "sing_mux", "xhttp_budget") + ), + [], + ) + + DRIVER_TESTS, + "XHTTP-REGRESSION": PREVIOUS["VLESS-REGRESSION"] + + PREVIOUS["VLESS-CODEC"] + + PREVIOUS["VLESS-TRANSPORT"] + + PREVIOUS["VLESS-VISION"], +} +OBSERVATIONS["XHTTP-RELEASE"] = OBSERVATIONS["XHTTP-CFG"] + OBSERVATIONS["XHTTP-UNIT"] +GATES = { + "XHTTP-CFG", + "XHTTP-UNIT", + "XHTTP-REGRESSION", + "XHTTP-RELEASE", + "XHTTP-QUALITY", + "XHTTP-FEATURES", + "XHTTP-SCRIPTS", +} +SECURITY = {f"XHTTP-SECURITY-{v.upper()}": v for v in ("h1", "h2", "h3")} +NATIVE = {} +VERSIONS = ("h1", "h2", "h1c", "h2c", "h1r", "h2r", "h3") +ALL_VARIANTS = variants() + + +def add(variant, suffix, test): + if variant not in ALL_VARIANTS: + raise ValueError("missing frozen XHTTP variant") + # Case-sensitive table names need distinct stable identifiers. + digest = hashlib.sha256(variant.encode()).hexdigest()[:8].upper() + identifier = f"XHTTP-{variant.upper()}-{suffix}-{digest}" + if identifier in NATIVE: + raise ValueError("duplicate XHTTP requirement") + NATIVE[identifier] = (variant, test) + + +for version in VERSIONS: + for mode in ("stream-one", "stream-up", "packet-up"): + variant = f"{version}-{mode}-headers" + add(variant, "BASE", "public_base") + add(variant, "CLOSE", "close::native_mihomo_xhttp_close") + for mode in ("stream-up", "packet-up"): + variant = f"{version}-{mode}-split-reuse" + add(variant, "BASE", "public_base") + add(variant, "CLOSE", "close::native_mihomo_xhttp_close") + for suffix in ("auto", "auto-download"): + add(f"{version}-{suffix}", "AUTO", "native_xhttp_request_fields") + if version.endswith("r"): + for suffix in ( + "download-reality-replacement", + "reject-download-short-id", + "reject-download-public-key", + ): + add( + f"{version}-{suffix}", + "SECURITY", + "security::native_xhttp_security" + if suffix.startswith("reject") + else "native_xhttp_request_fields", + ) + +# Every finite request enum is consumed on H1, H2 and H3. Security is orthogonal +# here; every plain/TLS/REALITY + mode + download branch has a public BASE above. +for version in ("h1", "h2", "h3"): + for name, (options, v) in ALL_VARIANTS.items(): + if v != version or "_mux" in options or "_keepalive" in options: + continue + suffix = name.removeprefix(version + "-") + if suffix.startswith( + ("padding-", "metadata-", "payload-", "session-", "post-", "reject-") + ) or suffix in ( + "stream-up-no-grpc", + "stream-one-no-grpc", + "body-auto", + ): + add( + name, + "FIELDS", + "security::native_xhttp_security" + if options.get("_reject") + else "native_xhttp_request_fields", + ) +add( + "h3-keepalive", + "KEEPALIVE", + "keepalive::native_h3_keepalive_observes_each_leg_and_stop", +) + +# All three wire protocols and both only-tcp branches for every outer transport. +# Padding parity alternates between branches; H2 additionally expands all padding +# values. Padding and the outer framing are independent, tested separately above. +for outer in ( + *VERSIONS, + *( + f"outer-{m}" + for m in ( + "tcp", + "tcp-tls", + "tcp-reality", + "ws", + "ws-tls", + "ws-reality", + "grpc", + "grpc-tls", + "grpc-reality", + "http", + "http-tls", + "h2", + "h2-tls", + "ws-header", + "ws-header-tls", + "ws-path", + "ws-path-tls", + ) + ), +): + for index, protocol in enumerate(("h2mux", "smux", "yamux")): + for only in (False, True): + paddings = ( + (False, True) if outer == "h2" else ((index + int(only)) % 2 == 0,) + ) + for padding in paddings: + variant = ( + f"{outer}-mux-{protocol}-{'padded' if padding else 'plain'}" + + ("-only-tcp" if only else "") + ) + add(variant, "BASE", "public_base") + +for version in ("h1", "h2", "h3"): + for protocol in ("h2mux", "smux", "yamux"): + add( + f"{version}-mux-{protocol}-padding-required", + "REJECT", + "security::native_xhttp_security", + ) + +REPRESENTATIVES = ( + "h1-packet-up-split-reuse", + "h2-stream-up-split-reuse", + "h3-packet-up-split-reuse", + "h2-mux-h2mux-padded", + "h2-mux-smux-padded", + "h2-mux-yamux-padded", +) +for variant in REPRESENTATIVES: + for suffix, test in ( + ("GRAPH", "runtime::public_graph"), + ("IPV6", "runtime::public_ipv6_and_gates"), + ("ENTRYPOINTS", "runtime::public_entrypoints"), + ("LIFE", "runtime::public_lifecycle"), + ("ISOLATION", "runtime::public_udp_isolation"), + ("OWNED", "lifecycle::native_xhttp_owned_resources"), + ("NEG", "public_negative"), + ): + add(variant, suffix, test) +for variant in ( + "h1-packet-up-reuse", + "h2-stream-up-reuse", + "h3-packet-up-reuse", + "h2-mux-h2mux-padded", + "h2-mux-smux-padded", + "h2-mux-yamux-padded", +): + add( + variant, + "POOL-SELECTION", + "runtime::grpc_pool_keeps_physical_selection_until_new_transport", + ) + +X_NAMES = ( + "path", + "host", + "mode", + "headers", + "no-grpc-header", + "x-padding-bytes", + "x-padding-obfs-mode", + "x-padding-key", + "x-padding-header", + "x-padding-placement", + "x-padding-method", + "uplink-http-method", + "session-placement", + "session-key", + "session-table", + "session-length", + "seq-placement", + "seq-key", + "uplink-data-placement", + "uplink-data-key", + "uplink-chunk-size", + "sc-max-each-post-bytes", + "sc-min-posts-interval-ms", +) +REUSE_ROWS = { + *(f"X{i:02}" for i in range(24, 30)), + *(f"D{i:02}" for i in range(27, 33)), +} + + +def rows(identifier): + if identifier in {"XHTTP-CFG", "XHTTP-UNIT"}: + return sorted(FIELD_IDS) + if identifier in GATES: + return [] + if identifier in SECURITY: + return [f"D{i:02}" for i in range(1, 14)] + variant, test = NATIVE[identifier] + options, version = ALL_VARIANTS[variant] + if version.startswith("outer-"): + return [f"M{i:02}" for i in range(1, 8)] + result = {"X01", "X02", "X03", "X06"} + result |= {f"X{i:02}" for i, name in enumerate(X_NAMES, 1) if name in options} + if "download-settings" in options: + result |= {"D01", "D02", "D03", "D04", "D05", "D06", "D07", "D08"} + if version.endswith("r"): + result |= {"D14", "D15"} + if "reuse-settings" in options or "reuse" in variant: + result |= REUSE_ROWS + if "_mux" in options: + result |= {f"M{i:02}" for i in range(1, 8)} + if options.get("_keepalive"): + result |= {"X29", "D32"} + return sorted(result) + + +def definitions(): + result = [] + for identifier in sorted(GATES | set(SECURITY) | set(NATIVE)): + variant, test = NATIVE.get( + identifier, (SECURITY.get(identifier, "memory-or-build"), identifier) + ) + options, version = ALL_VARIANTS.get(variant, ({}, variant)) + native = identifier not in GATES + kind = native_kind(version) if native else "unit" + if identifier in SECURITY: + kind = "XR" if version == "h3" else "M" + encrypted = not version.endswith("c") and ( + not version.startswith("outer-") or version.endswith(("-tls", "-reality")) + ) + gap = ( + { + "reason": "Xray H3 forwards to the official Mihomo VLESS decoder " + "for packetaddr/sing-mux. mTLS uses the approved unmodified " + "xcaddy/Caddy gateway to one Xray handler.", + "source": "https://github.com/XTLS/Xray-core/tree/v26.3.27/transport/internet/splithttp", + } + if kind == "XR" + else { + "reason": "Official V2Ray supplies HTTP/H2/custom-ED framing; " + "sing-mux is decoded by official Mihomo, not V2Ray CommandMux.", + "source": "https://github.com/MetaCubeX/mihomo/blob/v1.19.31/listener/inbound/vless.go", + } + if kind == "V2" + else None + ) + result.append( + dict( + case_id=identifier, + stage="XHTTP", + substage="XHTTP.http3" + if version == "h3" + else "XHTTP.mux" + if "_mux" in options + else "XHTTP.download" + if identifier in SECURITY or "download-settings" in options + else "XHTTP.requests" + if native + else "XHTTP.acceptance", + required=True, + row_ids=rows(identifier), + protocol="vless", + network=variant, + security="tls-or-reality" if encrypted else "plain", + udp_codec="raw/xudp/packetaddr/sing-mux", + field_values=dict( + scope="protocol-consumer", variant=variant, **options + ), + outer_family="IPv4/IPv6", + inner_family="IPv4/IPv6/domain", + target_type="isolated synthetic origin" + if native + else "memory-only/build", + upstream_graph="direct/concrete/nested-select" + if "graph" in test + else "per-assertion", + peer_kind=kind, + peer_config=dict(variant=variant, test=test), + gap_source=gap, + expected_observation=OBSERVATIONS.get( + identifier, + SCRIPT_ASSERTIONS if identifier == "XHTTP-SCRIPTS" else [test], + ), + required_evidence=["structured-assertions", "command", "cleanup"] + + (["official-peer-identity", "isolated-origins"] if native else []), + prerequisites=[ + "owned-host-only-container-network", + "official-latest-peer", + ] + if native + else ["rust-toolchain"], + runner="native-xhttp" + if identifier in NATIVE + else "xhttp-security" + if identifier in SECURITY + else "xhttp-gate", + timeout_seconds=3600 + if identifier in GATES or identifier in SECURITY + else 300, + ) + ) + return result diff --git a/scripts/src/vcore_scripts/protocol_xhttp_fields.py b/scripts/src/vcore_scripts/protocol_xhttp_fields.py new file mode 100644 index 0000000..a9fb4d2 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_xhttp_fields.py @@ -0,0 +1,1334 @@ +"""XHTTP request-field development checks, not the complete XHTTP stage gate.""" + +from __future__ import annotations + +import contextlib +import copy +import json +import os +import shutil +import ssl +import tempfile +from pathlib import Path + +from .builds import CORE_DIR +from .caddy_build import build_caddy +from .mihomo_isolation import exclusive_run +from .mihomo_release import download_mihomo +from .native_release import download_native +from .protocol_containers import ContainerLab, command +from .protocol_evidence import idle_resources, read_events +from .protocol_fixtures import certificate_chain, certificates +from .protocol_inputs import redact, source_identity +from .protocol_peers import run_command +from .protocol_vless_container import CLIENT_FINGERPRINTS +from .protocol_vless_peers import ( + configuration as vless_configuration, +) +from .protocol_vless_peers import ( + peer_kind as vless_peer_kind, +) +from .protocol_vless_public import events_pass as vless_events_pass +from .protocol_vless_security import client_identities +from .protocol_xhttp_gateway import gateway_config, sanitized_gateway_log +from .protocol_xhttp_peers import CLIENT_ID, peer_config + +PUBLIC_TESTS = ( + "public_base", + "public_negative", + "runtime::public_entrypoints", + "runtime::public_graph", + "runtime::public_ipv6_and_gates", + "runtime::public_lifecycle", + "runtime::public_udp_isolation", + "runtime::grpc_pool_keeps_physical_selection_until_new_transport", +) + + +def native_kind(version): + return ( + vless_peer_kind(version.removeprefix("outer-")) + if version.startswith("outer-") + else "XR" + if version == "h3" + else "M" + ) + + +def public_events_pass(events, assertion): + if assertion not in PUBLIC_TESTS or any( + e.get("suite") not in {"XHTTP-PUBLIC", "XHTTP-BASE", "XHTTP-LIFE"} + for e in events + ): + return False + expected = { + ("XHTTP-PUBLIC", assertion): 1, + } + if assertion == "public_base": + expected.update( + { + ("XHTTP-BASE", "tcp_10mib_both_directions"): 3, + ("XHTTP-BASE", "udp_each_codec_and_family"): 3, + } + ) + if assertion == "runtime::public_lifecycle": + expected[("XHTTP-LIFE", "stop_and_remain_quiet")] = 20 + if len(events) != 2 * sum(expected.values()): + return False + for key, count in expected.items(): + matched = [e for e in events if (e.get("suite"), e.get("assertion")) == key] + if [e.get("status") for e in matched] != ["BEGIN", "PASS"] * count: + return False + remapped = [ + dict(e, suite=e["suite"].replace("XHTTP-", "VLESS-", 1)) for e in events + ] + return vless_events_pass(remapped, assertion, "xhttp") + + +def variants(): + result = {} + result["auto"] = {"mode": "auto"} + result["auto-download"] = {"mode": "auto", "download-settings": {}} + for fault, peer_overrides in { + "session": {"session-placement": "header", "session-key": "X-Required-Session"}, + "sequence": {"seq-placement": "header", "seq-key": "X-Required-Sequence"}, + "payload": { + "uplink-data-placement": "header", + "uplink-data-key": "required-data", + }, + "post-size": {"sc-max-each-post-bytes": "1"}, + }.items(): + result[f"reject-{fault}"] = { + "mode": "packet-up", + "_reject": True, + "_server": peer_overrides, + } + result["post-minimum"] = { + "mode": "packet-up", + "sc-max-each-post-bytes": "1", + "sc-min-posts-interval-ms": "1", + "_tiny": True, + } + result["post-maximum"] = { + "mode": "packet-up", + "sc-max-each-post-bytes": "16777216", + "sc-min-posts-interval-ms": "0-1", + } + for table in ( + "uuid", + "ALPHABET", + "Alphabet", + "BASE36", + "Base62", + "HEX", + "alphabet", + "base36", + "hex", + "number", + "abAB0123", + ): + result[f"session-{table}"] = { + "mode": "packet-up", + "session-table": table, + "sc-min-posts-interval-ms": "1", + "_tiny": True, + } + for mode in ("stream-one", "stream-up", "packet-up"): + result[f"{mode}-headers"] = dict(mode=mode, headers={"X-Probe": "fixture"}) + result["stream-up-no-grpc"] = {"mode": "stream-up", "no-grpc-header": True} + result["stream-one-no-grpc"] = {"mode": "stream-one", "no-grpc-header": True} + result["body-auto"] = { + "mode": "packet-up", + "uplink-data-placement": "auto", + "sc-min-posts-interval-ms": "1", + } + for protocol in ("h2mux", "smux", "yamux"): + for padding in (False, True): + result[f"mux-{protocol}-{'padded' if padding else 'plain'}"] = { + "mode": "packet-up", + "sc-min-posts-interval-ms": "1", + "_mux": { + "enabled": True, + "protocol": protocol, + "padding": padding, + "max-connections": 1, + }, + } + for mode in ("stream-one", "stream-up", "packet-up"): + result[f"{mode}-reuse"] = {"mode": mode, "reuse-settings": {}} + if mode == "packet-up": + result[f"{mode}-reuse"]["sc-min-posts-interval-ms"] = "1" + for mode in ("stream-up", "packet-up"): + result[f"{mode}-split-reuse"] = { + "mode": mode, + "reuse-settings": {"h-max-request-times": "1"}, + "download-settings": { + "alpn": ["http/1.1"], + "headers": {"X-Down": "fixture"}, + "reuse-settings": {}, + }, + } + if mode == "packet-up": + result[f"{mode}-split-reuse"]["sc-min-posts-interval-ms"] = "1" + for placement in ("queryInHeader", "header", "query", "cookie"): + for method in ("repeat-x", "tokenish"): + options = { + "mode": "packet-up", + "x-padding-bytes": "100", + "x-padding-obfs-mode": True, + "x-padding-placement": placement, + "x-padding-method": method, + "sc-min-posts-interval-ms": "1", + } + if placement != "header": + options["x-padding-key"] = "pad" + if placement in ("queryInHeader", "header"): + options["x-padding-header"] = "X-Pad" + result[f"padding-{placement}-{method}"] = options + for placement, method in zip( + ("path", "query", "header", "cookie"), + ("POST", "PUT", "PATCH", "DELETE"), + strict=True, + ): + options = { + "mode": "packet-up", + "session-placement": placement, + "seq-placement": placement, + "uplink-http-method": method, + "session-table": "number", + "session-length": "10", + "sc-min-posts-interval-ms": "1", + } + if placement != "path": + options.update({"session-key": "sid", "seq-key": "seq"}) + result[f"metadata-{placement}-{method}"] = options + for placement in ("header", "cookie"): + result[f"payload-{placement}"] = { + "mode": "packet-up", + "uplink-data-placement": placement, + "uplink-data-key": "data", + "uplink-chunk-size": "64-128", + "sc-min-posts-interval-ms": "1", + } + result[f"payload-{placement}-auto"] = dict( + result[f"payload-{placement}"], **{"uplink-chunk-size": "0"} + ) + for protocol in ("h2mux", "smux", "yamux"): + result[f"mux-{protocol}-padding-required"] = { + "mode": "stream-one", + "_mux": {"enabled": True, "protocol": protocol, "padding": False}, + "_require-padding": True, + "_reject": True, + } + expanded = {} + for version in ("h2", "h1", "h2c", "h1c", "h3", "h2r", "h1r"): + for name, fields in result.items(): + options = copy.deepcopy(fields) + if version == "h3" and "download-settings" in options: + options["download-settings"]["alpn"] = ["h3"] + expanded[f"{version}-{name}"] = (options, version) + if "_mux" in options: + tcp_only = copy.deepcopy(options) + tcp_only["_mux"]["only-tcp"] = True + expanded[f"{version}-{name}-only-tcp"] = (tcp_only, version) + if version == "h3": + expanded["h3-keepalive"] = ( + {"mode": "packet-up", "_keepalive": True}, + version, + ) + if version.endswith("r"): + from .mihomo_extended import PUBLIC_KEY + + replacement = {"public-key": PUBLIC_KEY} + expanded[f"{version}-download-reality-replacement"] = ( + { + "mode": "stream-up", + "download-settings": {"reality-opts": replacement}, + }, + version, + ) + expanded[f"{version}-reject-download-short-id"] = ( + { + "mode": "stream-up", + "download-settings": { + "reality-opts": dict(replacement, **{"short-id": "ff"}) + }, + "_reject": True, + }, + version, + ) + expanded[f"{version}-reject-download-public-key"] = ( + { + "mode": "stream-up", + "download-settings": { + "reality-opts": { + # Valid X25519 encoding, not this server's public key. + "public-key": "CQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + } + }, + "_reject": True, + }, + version, + ) + for mode in ( + "tcp", + "tcp-tls", + "tcp-reality", + "ws", + "ws-tls", + "ws-reality", + "grpc", + "grpc-tls", + "grpc-reality", + "http", + "http-tls", + "h2", + "h2-tls", + "ws-header", + "ws-header-tls", + "ws-path", + "ws-path-tls", + ): + for name, options in result.items(): + if "_mux" in options: + expanded[f"outer-{mode}-{name}"] = ( + copy.deepcopy(options), + "outer-" + mode, + ) + tcp_only = copy.deepcopy(options) + tcp_only["_mux"]["only-tcp"] = True + expanded[f"outer-{mode}-{name}-only-tcp"] = (tcp_only, "outer-" + mode) + return expanded + + +def server_fields(fields, kind): + if kind == "M": + return fields + names = { + "path": "path", + "mode": "mode", + "host": "host", + "no-grpc-header": "noGRPCHeader", + "x-padding-bytes": "xPaddingBytes", + "x-padding-obfs-mode": "xPaddingObfsMode", + "x-padding-key": "xPaddingKey", + "x-padding-header": "xPaddingHeader", + "x-padding-placement": "xPaddingPlacement", + "x-padding-method": "xPaddingMethod", + "uplink-http-method": "uplinkHTTPMethod", + "session-placement": "sessionPlacement", + "session-key": "sessionKey", + "seq-placement": "seqPlacement", + "seq-key": "seqKey", + "uplink-data-placement": "uplinkDataPlacement", + "uplink-data-key": "uplinkDataKey", + "uplink-chunk-size": "uplinkChunkSize", + "sc-max-each-post-bytes": "scMaxEachPostBytes", + } + if not fields.keys() <= names.keys(): + raise ValueError("missing explicit Xray field translation") + return {names[key]: value for key, value in fields.items()} + + +def events_pass(events, assertion, *, owned=False): + if any(e.get("schema_version") != 1 for e in events): + return False + main = [event for event in events if event.get("suite") == "XHTTP-XHTTP"] + if ( + len(main) != 2 + or [e.get("status") for e in main] != ["BEGIN", "PASS"] + or any(e.get("assertion") != assertion for e in main) + ): + return False + if not owned: + return len(events) == 2 + cycles = [e for e in events if e.get("suite") == "XHTTP-OWNED"] + if len(events) != 42 or len(cycles) != 40: + return False + for start, finish in zip(cycles[::2], cycles[1::2], strict=True): + if ( + start.get("status") != "BEGIN" + or start.get("assertion") != "stop_and_remain_quiet" + or finish.get("status") != "PASS" + or finish.get("assertion") != "stop_and_remain_quiet" + or finish.get("seconds", 0) < 5 + or not idle_resources(finish.get("resources")) + ): + return False + points = finish.get("checkpoints", []) + if len(points) != 3 or [p.get("phase") for p in points] != [ + "baseline", + "after-stop", + "quiet", + ]: + return False + if not all(idle_resources(p.get("resources")) for p in points): + return False + if points[1]["resources"] != points[2]["resources"]: + return False + return True + + +def close_reference(lab, stack, root, name, node, origin, binary, output): + comparison_dir = root / "comparison" + comparison_dir.mkdir() + shutil.copy2(binary, comparison_dir / "peer") + reference = copy.deepcopy(node) + reference["name"] = "reference" + reference.pop("smux", None) + if reference.get("reality-opts"): + # Required by the official Mihomo client's uTLS REALITY backend. This + # belongs only to the reference fixture, not VCore's public config. + reference["client-fingerprint"] = "chrome" + config = { + "socks-port": 23002, + "allow-lan": True, + "bind-address": "*", + "log-level": "warning", + "ipv6": True, + "proxies": [reference], + "rules": ["MATCH,reference"], + } + (comparison_dir / "config.json").write_text(json.dumps(config)) + comparison = lab.start( + stack, + comparison_dir, + name + "-comparison", + ["/data/fixture/peer", "-d", "/data", "-f", "/data/fixture/config.json"], + ) + comparison.release() + comparison.wait_tcp(23002) + result = run_command( + [ + "container", + "exec", + origin.name, + "env", + "VCORE_ISOLATED_ORIGIN=1", + "python", + "-B", + "/data/fixture/close.py", + comparison.ipv4, + origin.ipv4, + ], + timeout=30, + ) + (output / f"{name}-close-command.log").write_text( + redact(result.stdout.decode(errors="replace")) + ) + (output / f"{name}-comparison.log").write_text( + redact(command("logs", comparison.name)[-65536:]) + ) + if result.returncode or not result.cleanup: + raise RuntimeError("official XHTTP close comparison failed") + observed = json.loads(result.stdout) + observed["scope"] = "same-mode" + (output / f"{name}-close-reference.json").write_text(json.dumps(observed) + "\n") + comparison.ensure_alive() + return observed + + +def selected_profile_variants(profile): + """Named-profile download-leg crossings; ordinary XHTTP variants are unchanged.""" + other = "firefox" if profile in {"chrome", "chrome120"} else "chrome" + result = {} + for label, override in ( + ("inherit", {}), + ("different", {"client-fingerprint": other}), + ("clear", {"client-fingerprint": "none"}), + ("reject-pin", {"client-fingerprint": other, "fingerprint": "11" * 32}), + ): + options = { + "mode": "stream-up", + "reuse-settings": {}, + "download-settings": { + "alpn": ["http/1.1"], + "reuse-settings": {}, + **override, + }, + } + if label == "reject-pin": + options["_reject"] = True + result[f"h2-selected-download-{label}"] = (options, "h2") + result["h3-selected-download-clear"] = ( + { + "mode": "stream-up", + "_main_alpn": "h2", + "download-settings": {"alpn": ["h3"], "client-fingerprint": "none"}, + }, + "h3", + ) + return result + + +def selected_gateway_config(upstream): + """Both HTTP versions reach one native XHTTP session table, with real mTLS.""" + config = gateway_config(upstream) + config["apps"]["http"]["servers"]["fixture"]["protocols"] = ["h2", "h3"] + return config + + +def run( + output: Path, + selected: list[str], + *, + udp: bool = False, + owned: bool = False, + public: str | None = None, + jobs: dict | None = None, + supplied: dict | None = None, + client_fingerprint: str | None = None, + encryption: str | None = None, + ech: bool = False, + jls: bool = False, +): + if ech and jls: + raise ValueError("ECH and JLS are mutually exclusive") + if client_fingerprint is not None and client_fingerprint not in CLIENT_FINGERPRINTS: + raise ValueError("unsupported named client profile") + if public is not None and (public not in PUBLIC_TESTS or udp or owned): + raise ValueError("invalid public consumer selection") + cases = variants() + if ech: + from .protocol_ech import field_variants + + cases.update(field_variants()) + if encryption is not None: + from .protocol_encryption import cases as encryption_profiles + + if encryption not in encryption_profiles(): + raise ValueError("unsupported Encryption fixture") + if encryption is not None or ech: + # Official V2Ray terminates only the legacy transport. A separate + # official Mihomo handler authenticates Encryption and decodes VLESS. + for mode in ("http", "h2", "ws-header", "ws-path"): + for suffix in ("", "-tls"): + name = f"outer-{mode}{suffix}" + cases[name] = ({}, name) + if client_fingerprint: + cases.update(selected_profile_variants(client_fingerprint)) + if jobs is not None: + if set(jobs) != set(selected) or any(not entries for entries in jobs.values()): + raise ValueError("invalid frozen native jobs") + allowed = set(PUBLIC_TESTS) | { + "native_xhttp_request_fields", + "native_xhttp_udp_encodings", + "lifecycle::native_xhttp_owned_resources", + "keepalive::native_h3_keepalive_observes_each_leg_and_stop", + "security::native_xhttp_security", + "close::native_mihomo_xhttp_close", + } + ids = [entry["case_id"] for entries in jobs.values() for entry in entries] + if len(ids) != len(set(ids)) or any( + entry["test"] not in allowed + for entries in jobs.values() + for entry in entries + ): + raise ValueError("unknown or duplicate frozen native job") + if not selected: + selected = list(cases) + if len(selected) != len(set(selected)) or not set(selected) <= cases.keys(): + raise ValueError("invalid XHTTP field case selection") + if ech and any( + ( + cases[name][1] not in {"h1", "h2", "h3"} + and not ( + cases[name][1].startswith("outer-") and cases[name][1].endswith("-tls") + ) + ) + or "_main_alpn" in cases[name][0] + for name in selected + ): + raise ValueError("static ECH requires a native standard TLS peer") + if jls and any(cases[name][1] not in {"h1", "h2"} for name in selected): + raise ValueError("JLS field gate requires a Mihomo stream TLS peer") + if client_fingerprint is not None: + for name in selected: + options, version = cases[name] + if version.startswith("outer-") or version.endswith("c"): + raise ValueError("profile field requires a TLS XHTTP node") + if ( + client_fingerprint != "none" + and version == "h3" + and "_main_alpn" not in options + ): + raise ValueError("named client profiles require stream TLS") + if ( + not output.is_relative_to(CORE_DIR / "target/interop/runs") + or output == CORE_DIR / "target/interop/runs" + ): + raise ValueError("new run directory must be directly under target/interop/runs") + output.mkdir(exist_ok=False) + report = dict( + stage="SECURITY.ech" + if ech + else "SECURITY.encryption" + if encryption + else "XHTTP", + ech=ech, + jls=jls, + encryption_profile=encryption, + client_fingerprint=client_fingerprint, + scope="protocol-consumer" + if jobs is not None + else "request-fields-development-only", + traffic=public + or ("owned-resources" if owned else "udp-three-encodings" if udp else "tcp"), + source=source_identity(), + status="NOT RUN", + cases=[], + peers={}, + isolation={}, + ) + try: + artifacts = {} + if any("_main_alpn" in cases[name][0] for name in selected): + gateway_artifact = build_caddy(output / "Caddy") + artifacts["Caddy"] = gateway_artifact.binary + report["peers"]["Caddy"] = gateway_artifact.identity + kinds = {native_kind(cases[name][1]) for name in selected} + if ech and any(cases[name][1].startswith("outer-") for name in selected): + kinds.add("XR") + if ( + encryption + or jobs is not None + or any( + native_kind(cases[name][1]) != "M" + and (udp or owned or public or "_mux" in cases[name][0]) + for name in selected + ) + ): + kinds.add("M") + for kind in sorted(kinds): + if supplied is not None: + binary, identity = supplied[kind] + elif kind == "M": + identity = {} + binary = download_mihomo( + "linux-arm64", directory=output / "binary", identity=identity + ) + else: + artifact = download_native( + kind, output / kind, "linux-arm64", defer_version=True + ) + binary, identity = artifact.binary, artifact.identity + artifacts[kind] = binary + report["peers"][kind] = identity + lab = ContainerLab(report["isolation"], mtu=1500) + build = run_command( + [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "xhttp_native", + *(["--test", "vless_public"] if public or jobs is not None else []), + "--no-run", + ], + timeout=180, + ) + (output / "build.log").write_text(redact(build.stdout.decode(errors="replace"))) + if build.returncode or not build.cleanup: + raise RuntimeError("XHTTP field test build failed") + for name in selected: + print(name, flush=True) + fields, version = cases[name] + kind = native_kind(version) + entries = jobs[name] if jobs is not None else None + needs_public = bool(public) or bool( + entries and any(e["test"] in PUBLIC_TESTS for e in entries) + ) + hop_tests = { + "runtime::public_graph", + "runtime::grpc_pool_keeps_physical_selection_until_new_transport", + } + needs_hop = public in hop_tests or bool( + entries and any(e["test"] in hop_tests for e in entries) + ) + needs_decoder = ( + bool(encryption) + or udp + or owned + or needs_public + or bool( + entries + and any( + e["test"] == "native_xhttp_udp_encodings" + or e["test"].startswith("lifecycle::") + for e in entries + ) + ) + ) + needs_close = bool( + entries and any(e["test"].startswith("close::") for e in entries) + ) + outer_mode = ( + version.removeprefix("outer-") if version.startswith("outer-") else None + ) + identity = report["peers"][kind] + with ( + tempfile.TemporaryDirectory(prefix="private-", dir=output) as temporary, + contextlib.ExitStack() as stack, + ): + root = Path(temporary) + server_dir, origin_dir = root / "server", root / "origin" + server_dir.mkdir() + origin_dir.mkdir() + shutil.copy2(artifacts[kind], server_dir / "peer") + shutil.copyfile( + Path(__file__).with_name("container_udp_origin.py"), + origin_dir / "origin.py", + ) + if needs_close: + shutil.copyfile( + Path(__file__).with_name("container_close_client.py"), + origin_dir / "close.py", + ) + origin_security = [] + if version.endswith(("r", "-reality")) or jls: + origin_cert, origin_key, _ = certificates(origin_dir) + origin_security = [ + f"VCORE_ORIGIN_CERT=/data/fixture/{origin_cert.name}", + f"VCORE_ORIGIN_KEY=/data/fixture/{origin_key.name}", + ] + origin = lab.start( + stack, + origin_dir, + name + "-origin", + [ + "env", + "VCORE_ISOLATED_ORIGIN=1", + *origin_security, + "python", + "-B", + "/data/fixture/origin.py", + ], + ) + server = lab.start( + stack, + server_dir, + name + "-server", + [ + "/data/fixture/peer", + "-d", + "/data", + "-f", + "/data/fixture/config.json", + ] + if kind == "M" + else [ + "/data/fixture/peer", + "run", + "-c", + "/data/fixture/config.json", + ], + ) + version_text = command( + "exec", + server.name, + "/data/fixture/peer", + "-v" if kind == "M" else "version", + ).strip() + digest = command( + "exec", server.name, "sha256sum", "/data/fixture/peer" + ).split()[0] + if not version_text or digest != identity["binary_sha256"]: + raise RuntimeError("XHTTP peer identity mismatch") + identity["version"] = version_text + cert, key, pin = certificate_chain(server_dir) + # Xray always normalizes its handler path with a trailing slash, + # unlike Mihomo when both metadata fields are outside the path. + # Configure an explicit compatible path, without changing VCore. + options = dict(fields, path="/xhttp/" if kind == "XR" else "/xhttp") + plain = ( + not outer_mode.endswith(("-tls", "-reality")) + if outer_mode + else version.endswith("c") + ) + reality = version.endswith(("r", "-reality")) + tiny = options.pop("_tiny", False) + keepalive = options.pop("_keepalive", False) + reject = options.pop("_reject", False) + peer_overrides = options.pop("_server", {}) + main_alpn = options.pop("_main_alpn", None) + mux = options.pop("_mux", None) + require_padding = options.pop("_require-padding", False) + ech_action = options.pop("_ech_action", None) + decoder = None + encryption_node = {} + if kind != "M" and (needs_decoder or mux is not None): + decoder_dir = root / "decoder" + decoder_dir.mkdir() + shutil.copy2(artifacts["M"], decoder_dir / "peer") + listener = dict( + name="decoder", + type="vless", + listen="::", + port=23001, + users=[dict(uuid=CLIENT_ID)], + **{"allow-insecure": True}, + ) + if mux is not None: + listener["mux-option"] = { + "padding": require_padding or mux["padding"] + } + if encryption: + from .protocol_encryption_public import ( + configuration as encryption_config, + ) + + encryption_config( + encryption, encryption_node, {"listeners": [listener]} + ) + (decoder_dir / "config.json").write_text( + json.dumps( + dict( + ipv6=True, + hosts={ + "vcore-fixture.test": origin.ipv4, + "peer.fixture.test": server.ipv4, + }, + listeners=[listener], + rules=["MATCH,DIRECT"], + **{"log-level": "warning"}, + ) + ) + ) + decoder = lab.start( + stack, + decoder_dir, + name + "-decoder", + [ + "/data/fixture/peer", + "-d", + "/data", + "-f", + "/data/fixture/config.json", + ], + ) + decoded_version = command( + "exec", decoder.name, "/data/fixture/peer", "-v" + ).strip() + decoded_digest = command( + "exec", decoder.name, "sha256sum", "/data/fixture/peer" + ).split()[0] + if ( + not decoded_version + or decoded_digest != report["peers"]["M"]["binary_sha256"] + ): + raise RuntimeError("layered decoder identity mismatch") + report["peers"]["M"]["version"] = decoded_version + decoder.release() + decoder.wait_tcp(23001) + node = dict( + name="peer", + type="vless", + server=server.ipv4, + port=23000, + uuid=CLIENT_ID, + tls=not plain, + alpn=[ + "http/1.1" + if version.startswith("h1") + else "h3" + if version == "h3" + else "h2" + ], + **{"xhttp-opts": options}, + network="xhttp", + udp=True, + ) + if mux is not None: + node["smux"] = mux + if not plain: + node.update(servername="localhost", fingerprint=pin) + if client_fingerprint: + node["client-fingerprint"] = client_fingerprint + if main_alpn: + node["alpn"] = [main_alpn] + config = peer_config( + kind, + f"/data/fixture/{cert.name}", + f"/data/fixture/{key.name}", + plain=plain, + decoder=decoder.ipv4 if decoder else None, + ) + gateway = None + edge = server + if main_alpn: + # Xray's native listener is either TCP or QUIC, not both. + # The approved gateway terminates both TLS legs and feeds + # ONE unmodified Xray h2c handler/session table. + stream = config["inbounds"][0]["streamSettings"] + stream["security"] = "none" + del stream["tlsSettings"] + gateway_dir = root / "gateway" + gateway_dir.mkdir() + shutil.copy2(artifacts["Caddy"], gateway_dir / "peer") + for filename in ("cert.pem", "key.pem", "root.pem", "root-key.pem"): + shutil.copyfile(server_dir / filename, gateway_dir / filename) + node.update(client_identities(gateway_dir)["valid"]) + options["host"] = "localhost" + (gateway_dir / "config.json").write_text( + json.dumps(selected_gateway_config(server.ipv4)) + ) + gateway = lab.start( + stack, + gateway_dir, + name + "-gateway", + [ + "env", + "XDG_CONFIG_HOME=/data/config", + "XDG_DATA_HOME=/data/share", + "/data/fixture/peer", + "run", + "--config", + "/data/fixture/config.json", + ], + ) + gateway_version = command( + "exec", gateway.name, "/data/fixture/peer", "version" + ).strip() + gateway_digest = command( + "exec", gateway.name, "sha256sum", "/data/fixture/peer" + ).split()[0] + if ( + not gateway_version + or gateway_digest != report["peers"]["Caddy"]["binary_sha256"] + ): + raise RuntimeError("selected gateway identity mismatch") + report["peers"]["Caddy"]["version"] = gateway_version + + def preserve_gateway(peer=gateway, variant=name): + (output / f"{variant}-gateway.log").write_text( + sanitized_gateway_log(command("logs", peer.name)[-65536:]) + ) + + stack.callback(preserve_gateway) + edge = gateway + node["server"] = edge.ipv4 + if outer_mode: + node, config = vless_configuration( + outer_mode, + server.ipv4, + origin.ipv4, + Path("/data/fixture") / cert.name, + Path("/data/fixture") / key.name, + ) + node["name"] = "peer" + if mux is not None: + node["smux"] = mux + if not plain and not reality: + node["fingerprint"] = pin + if decoder: + config["inbounds"][0].update( + protocol="dokodemo-door", + settings=dict( + address=decoder.ipv4, port=23001, network="tcp" + ), + ) + if kind == "M": + config["hosts"] = { + "vcore-fixture.test": origin.ipv4, + "peer.fixture.test": server.ipv4, + } + if mux is not None: + config["listeners"][0]["mux-option"] = { + "padding": require_padding or mux["padding"] + } + if reality: + from .mihomo_extended import PRIVATE_KEY, PUBLIC_KEY, SHORT_ID + + listener = config["listeners"][0] + listener.pop("certificate", None) + listener.pop("private-key", None) + listener["reality-config"] = { + "dest": f"{origin.ipv4}:24001", + "private-key": PRIVATE_KEY, + "short-id": [SHORT_ID, ""], + "server-names": ["localhost"], + } + node.pop("fingerprint", None) + node["reality-opts"] = { + "public-key": PUBLIC_KEY, + "short-id": SHORT_ID, + } + else: + config["dns"] = {"hosts": {"vcore-fixture.test": origin.ipv4}} + config["outbounds"][0]["settings"] = {"domainStrategy": "UseIP"} + if encryption: + if decoder: + node.update(encryption_node) + else: + from .protocol_encryption_public import ( + configuration as encryption_config, + ) + + encryption_config(encryption, node, config) + if jls: + from .protocol_jls import configuration as jls_config + + jls_config(node, config, origin.ipv4, name) + if ech: + from .protocol_ech import configuration as ech_config + from .protocol_ech import ( + legacy_gateway_command, + legacy_gateway_config, + wrong_key, + ) + + ech_peer, ech_dir, ech_kind = config, server_dir, kind + if outer_mode: + # ECH is terminated by official Xray, not a custom TLS + # server. V2Ray remains the native legacy transport; + # the existing Mihomo decoder owns VLESS/Encryption. + stream = config["inbounds"][0]["streamSettings"] + tls_options = stream.pop("tlsSettings") + stream["security"] = "none" + gateway_dir = root / "gateway" + gateway_dir.mkdir() + shutil.copy2(artifacts["XR"], gateway_dir / "peer") + for filename in (cert.name, key.name): + shutil.copyfile( + server_dir / filename, gateway_dir / filename + ) + ech_peer = legacy_gateway_config(server.ipv4, tls_options) + ech_dir, ech_kind = gateway_dir, "XR" + ech_material = ech_config( + node, ech_peer, ech_dir, name, kind=ech_kind + ) + if outer_mode: + (gateway_dir / "config.json").write_text(json.dumps(ech_peer)) + gateway = lab.start( + stack, + gateway_dir, + name + "-ech-gateway", + legacy_gateway_command(), + ) + version_text = command( + "exec", gateway.name, "/data/fixture/peer", "version" + ).strip() + digest = command( + "exec", gateway.name, "sha256sum", "/data/fixture/peer" + ).split()[0] + if ( + not version_text + or digest != report["peers"]["XR"]["binary_sha256"] + ): + raise RuntimeError("ECH gateway identity mismatch") + report["peers"]["XR"]["version"] = version_text + edge = gateway + node["server"] = edge.ipv4 + + def preserve_ech_gateway(peer=gateway, variant=name): + (output / f"{variant}-gateway.log").write_text( + redact(command("logs", peer.name)[-65536:]) + ) + + stack.callback(preserve_ech_gateway) + if ech_action in {"replace", "clear", "reject-download"}: + replacement = ( + {} + if ech_action == "clear" + else dict( + enable=True, + config=ech_material["ech_download_config"], + ) + ) + if ech_action == "reject-download": + replacement = wrong_key(replacement) + options["download-settings"]["ech-opts"] = replacement + elif ech_action == "reject-main": + node["ech-opts"] = wrong_key(node["ech-opts"]) + # Client-only session generator and pacing have no server counterpart. + peer_options = { + k: v + for k, v in options.items() + if k + not in { + "session-table", + "session-length", + "sc-min-posts-interval-ms", + "headers", + "reuse-settings", + "download-settings", + } + } + peer_options.update(peer_overrides) + if outer_mode: + pass # Native outer transport options were supplied above. + elif kind == "M": + config["listeners"][0]["xhttp-config"].update( + server_fields(peer_options, kind) + ) + else: + config["inbounds"][0]["streamSettings"]["xhttpSettings"].update( + server_fields(peer_options, kind) + ) + (server_dir / "config.json").write_text(json.dumps(config)) + hop = None + if needs_hop: + # An upstream must not dial a VLESS listener in its own + # Mihomo process: that correctly triggers loopback protection. + upstream_dir = root / "upstream" + upstream_dir.mkdir() + shutil.copy2(artifacts["M"], upstream_dir / "peer") + (upstream_dir / "config.json").write_text( + json.dumps( + dict( + ipv6=True, + hosts={ + "peer.fixture.test": server.ipv4, + "vcore-fixture.test": origin.ipv4, + }, + listeners=[ + dict( + name="hop", + type="socks", + listen="::", + port=23004, + udp=True, + ) + ], + rules=["MATCH,DIRECT"], + **{"log-level": "warning"}, + ) + ) + ) + upstream = lab.start( + stack, + upstream_dir, + name + "-upstream", + [ + "/data/fixture/peer", + "-d", + "/data", + "-f", + "/data/fixture/config.json", + ], + ) + upstream.release() + upstream.wait_tcp(23004) + hop = dict( + name="hop", + type="socks5", + server=upstream.ipv4, + port=23004, + udp=True, + ) + fixture = root / "input.json" + fixture.write_text( + json.dumps( + dict( + isolation="containers", + node=node, + tiny=tiny, + reject=reject, + peer_kind=f"{kind}-to-M" + if encryption and decoder + else kind, + data_dir=str(root / "data"), + server_ipv4=edge.ipv4, + server_ipv6=edge.ipv6, + hop=hop, + origin_control=f"{origin.ipv4}:24000", + origin_ipv4=origin.ipv4, + origin_ipv6=origin.ipv6, + root_der=list( + ssl.PEM_cert_to_DER_cert( + (server_dir / "root.pem").read_text() + ) + ), + ) + ) + ) + origin.release() + server.release() + if gateway: + gateway.release() + origin.wait_tcp(24000) + if kind == "M" or gateway: + server.wait_tcp(23000) + if gateway: + gateway.wait_tcp(23000) + default_assertion = ( + public + or ( + "keepalive::native_h3_keepalive_observes_each_leg_and_stop" + if keepalive + else None + ) + or ("security::native_xhttp_security" if reject else None) + or ( + "lifecycle::native_xhttp_owned_resources" + if owned + else "native_xhttp_udp_encodings" + if udp + else "native_xhttp_request_fields" + ) + ) + if needs_close: + try: + reference = close_reference( + lab, stack, root, name, node, origin, artifacts["M"], output + ) + finally: + # A failed reference setup precedes the VCore cases; + # preserve its peer evidence before ExitStack cleanup. + for role, peer in (("server", server), ("origin", origin)): + (output / f"{name}-{role}.log").write_text( + redact(command("logs", peer.name)[-65536:]) + ) + data = json.loads(fixture.read_text()) + data["close_reference"] = reference + fixture.write_text(json.dumps(data)) + for entry in entries or [dict(case_id=name, test=default_assertion)]: + case_id, assertion = entry["case_id"], entry["test"] + events = output / f"{case_id}-events.jsonl" + command_line = [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "vless_public" if assertion in PUBLIC_TESTS else "xhttp_native", + assertion, + "--", + "--exact", + "--ignored", + "--nocapture", + ] + result = run_command( + command_line, + timeout=240 + if assertion.startswith("lifecycle::") + or assertion in PUBLIC_TESTS + else 150, + env=dict( + os.environ, + VCORE_XHTTP_INPUT=str(fixture), + VCORE_VLESS_INPUT=str(fixture), + VCORE_PROTOCOL_STAGE="XHTTP", + VCORE_CASE_EVENTS=str(events), + ), + ) + (output / f"{case_id}.log").write_text( + redact(result.stdout.decode(errors="replace")) + ) + observed = read_events(events) if events.exists() else [] + passed = ( + result.returncode == 0 + and result.cleanup + and ( + public_events_pass(observed, assertion) + if assertion in PUBLIC_TESTS + else events_pass( + observed, + assertion, + owned=assertion.startswith("lifecycle::"), + ) + ) + ) + report["cases"].append( + dict( + case_id=case_id, + variant=name, + assertion=assertion, + status="PASS" if passed else "FAIL", + command=command_line, + exit_code=result.returncode, + seconds=result.seconds, + command_cleanup=result.cleanup, + decoder_path=( + ( + "Xray ECH -> " + if ech and outer_mode + else "Caddy H2/H3 -> " + if gateway + else "" + ) + + f"{kind} {outer_mode or 'XHTTP'} -> Mihomo VLESS" + ) + if decoder + else kind, + ) + ) + print(f"{case_id}: {'PASS' if passed else 'FAIL'}", flush=True) + if not passed: + break + for role, peer in (("server", server), ("origin", origin)): + peer.ensure_alive() + (output / f"{name}-{role}.log").write_text( + redact(command("logs", peer.name)[-65536:]) + ) + if decoder: + decoder.ensure_alive() + if gateway: + gateway.ensure_alive() + if needs_hop: + upstream.ensure_alive() + (output / f"{name}-upstream.log").write_text( + redact(command("logs", upstream.name)[-65536:]) + ) + if not passed: + break + report["status"] = ( + "PASS" + if len(report["cases"]) + == (sum(map(len, jobs.values())) if jobs is not None else len(selected)) + and all(c["status"] == "PASS" for c in report["cases"]) + else "FAIL" + ) + except (OSError, ValueError, RuntimeError) as error: + report.update( + status="FAIL", + reason=str(error) + if isinstance(error, RuntimeError) + else type(error).__name__, + ) + except KeyboardInterrupt: + report.update(status="INTERRUPTED") + finally: + report["cleanup"] = all( + p["joined"] for p in report["isolation"].get("peers", []) + ) + report["source_unchanged"] = ( + source_identity()["source_tree_sha256"] + == report["source"]["source_tree_sha256"] + ) + if not report["cleanup"] or not report["source_unchanged"]: + report["status"] = "FAIL" + (output / "xhttp-fields-results.json").write_text( + json.dumps(report, indent=2) + "\n" + ) + return 0 if report["status"] == "PASS" else 1 + + +if __name__ == "__main__": + import argparse + import sys + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("output", type=Path) + parser.add_argument("cases", nargs="*") + parser.add_argument("--udp", action="store_true") + parser.add_argument("--owned", action="store_true") + parser.add_argument("--public", choices=PUBLIC_TESTS) + parser.add_argument("--encryption") + parser.add_argument("--ech", action="store_true") + args = parser.parse_args() + if sum((args.udp, args.owned, args.public is not None)) > 1: + parser.error("select UDP or owned-resource checks") + with exclusive_run(): + sys.exit( + run( + args.output.resolve(), + args.cases, + udp=args.udp, + owned=args.owned, + public=args.public, + encryption=args.encryption, + ech=args.ech, + ) + ) diff --git a/scripts/src/vcore_scripts/protocol_xhttp_gateway.py b/scripts/src/vcore_scripts/protocol_xhttp_gateway.py new file mode 100644 index 0000000..4068eaf --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_xhttp_gateway.py @@ -0,0 +1,564 @@ +"""Isolated xcaddy H3/mTLS -> one native Xray XHTTP handler probe. + +Native comparator evidence only: this never claims VCore XHTTP acceptance. +""" + +from __future__ import annotations + +import contextlib +import hashlib +import ipaddress +import json +import select +import shutil +import socket +import struct +import tempfile +import time +from pathlib import Path + +from .builds import CORE_DIR +from .caddy_build import build_caddy +from .container_close_client import exact +from .mihomo_isolation import exclusive_run +from .mihomo_release import download_mihomo +from .native_release import PeerArtifact, download_native +from .protocol_containers import ContainerLab, command, listing +from .protocol_fixtures import certificate_chain +from .protocol_inputs import redact, source_identity +from .protocol_peers import run_command +from .protocol_vless_security import client_identities +from .protocol_xhttp_peers import client_config, exercise, peer_config, socks + + +def sanitized_gateway_log(text): + """Keep native protocol evidence, not HTTP headers, identities or destinations.""" + entries = [] + events = { + "aborting with incomplete response": "response_canceled", + "strict SNI-Host enforcement": "strict_sni_host", + "admin endpoint disabled": "admin_disabled", + "unable to create folder for config autosave": "autosave_failed", + "unable to get instance ID": "storage_id_failed", + } + for line in text.splitlines(): + try: + row = json.loads(line) + except ValueError: + entries.append({"event": "non_json_log", "omitted": True}) + continue + message = row.get("msg", "") + entry = { + "level": row.get("level"), + "event": next( + (name for pattern, name in events.items() if pattern in message), + "other_native_event", + ), + } + request = row.get("request", {}) + if request: + entry.update( + http_version=request.get("proto"), + alpn=request.get("tls", {}).get("proto"), + client_certificate_present=bool( + request.get("tls", {}).get("client_common_name") + ), + ) + entries.append(entry) + return "".join(json.dumps(entry) + "\n" for entry in entries) + + +def gateway_config(upstream): + address = ipaddress.ip_address(upstream) + authority = f"[{address}]" if address.version == 6 else str(address) + return { + "admin": {"disabled": True}, + "storage": {"module": "file_system", "root": "/data/caddy"}, + "logging": {"logs": {"default": {"level": "WARN"}}}, + "apps": { + "tls": { + "certificates": { + "load_files": [ + { + "certificate": "/data/fixture/cert.pem", + "key": "/data/fixture/key.pem", + } + ] + } + }, + "http": { + "servers": { + "fixture": { + "listen": [":23000"], + "protocols": ["h3"], + "automatic_https": {"disable": True}, + "tls_connection_policies": [ + { + "client_authentication": { + "mode": "require_and_verify", + "ca": { + "provider": "file", + "pem_files": ["/data/fixture/root.pem"], + }, + } + } + ], + "routes": [ + { + "handle": [ + { + "handler": "reverse_proxy", + "upstreams": [{"dial": f"{authority}:23000"}], + "transport": { + "protocol": "http", + "versions": ["h2c"], + "compression": False, + }, + } + ] + } + ], + } + } + }, + }, + } + + +def cases(*, identities_only=False): + for mode in ("packet-up", "stream-up", "stream-one"): + for split in (False, True) if mode != "stream-one" else (False,): + prefix = mode + ("-download" if split else "") + checks = ( + ("echo",) + if identities_only + else ("echo", "bulk-server-first", "close", "udp") + ) + for check in checks: + yield dict(id=f"{prefix}-{check}", mode=mode, split=split, check=check) + if split or identities_only: + for leg in ("upload", "download") if split else ("shared",): + for identity in ("absent", "expired", "wrong-ca"): + yield dict( + id=f"{prefix}-{leg}-{identity}", + mode=mode, + split=split, + check="reject", + leg=leg, + identity=identity, + ) + + +def native_config(): + config = peer_config("XR", "unused", "unused") + stream = config["inbounds"][0]["streamSettings"] + stream["security"] = "none" + del stream["tlsSettings"] + # This ONE handler owns the upload and download session table. Caddy does + # not decode XHTTP or VLESS; h2c avoids H1 full-duplex/buffering differences. + return config + + +def stream_check(proxy, port, origin, *, bulk): + with socket.create_connection((origin, 24000), timeout=15) as control: + control.sendall(b"\x0a" if bulk else b"\x0b") + target = struct.unpack("!H", exact(control, 2))[0] + stream, _ = socks(proxy, port, 1, origin, target) + with stream: + stream.settimeout(15) + if exact(stream, 5) != b"hello": + raise ValueError("missing native server-first data") + size = 10 * 1024 * 1024 if bulk else 4 + payload = b"\x5a" * size if bulk else b"ping" + stream.sendall(payload) + observed = hashlib.sha256() + remaining = size + while remaining: + data = exact(stream, min(65536, remaining)) + observed.update(data) + remaining -= len(data) + expected = hashlib.sha256(payload).hexdigest() + if observed.hexdigest() != expected: + raise ValueError("gateway data mismatch") + if bulk and exact(stream, 7) != b"trailer": + raise ValueError("missing complete native reply") + stream.settimeout(5) + started = time.monotonic() + stream.shutdown(socket.SHUT_WR) + tail = bytearray() + try: + while data := stream.recv(1024): + tail.extend(data) + if len(tail) > 1024: + raise ValueError("gateway close exceeded response bound") + except ConnectionResetError: + pass + control.settimeout(5) + if exact(control, 2) != b"AD": + raise ValueError("native origin did not finish") + return dict( + bytes_each_way=size, + sha256=expected, + server_first=True, + closed=True, + close_seconds=round(time.monotonic() - started, 3), + tail_after_upload_eof=len(tail), + origin_finished=True, + ) + + +def rejected_identity(proxy, port, origin): + with socket.create_connection((origin, 24000), timeout=5) as control: + control.sendall(b"\x0c") + target = struct.unpack("!H", exact(control, 2))[0] + connected = False + outcome = "closed" + try: + stream, _ = socks(proxy, port, 1, origin, target) + with stream: + stream.sendall(b"synthetic-probe") + connected = exact(stream, 2) == b"ok" + except TimeoutError: + outcome = "timeout" # Never infer rejection from a blackhole. + except (OSError, ValueError): + pass + ready, _, _ = select.select([control], [], [], 0.3) + return dict( + rejected=not connected and not ready and outcome == "closed", + data_exchanged=connected, + origin_accepted=bool(ready), + client_outcome=outcome, + quiet_seconds=0.3, + ) + + +def run(output: Path, *, identities_only=False): + output = output.resolve() + if output.parent != (CORE_DIR / "target/interop/runs").resolve(): + raise ValueError("gateway output must be a fresh target/interop/runs child") + output.mkdir(parents=True, exist_ok=False) + report = dict( + stage="XHTTP-PEERS", + purpose="XHTTP-xcaddy-H3-mTLS-prerequisite", + vcore_acceptance=False, + identities_only=identities_only, + source=source_identity(), + status="NOT RUN", + phase="build", + cases=[], + peers={}, + isolation={}, + topology=( + "Mihomo-H3-client -> Caddy-H3-mTLS -> one-Xray-h2c-XHTTP-handler -> origin" + ), + ) + try: + built = build_caddy(output / "binaries/Caddy") + report["phase"] = "download" + identity = {} + mihomo = PeerArtifact( + download_mihomo( + "linux-arm64", + directory=output / "binaries/M", + identity=identity, + ), + identity, + ) + xray = download_native( + "XR", output / "binaries/XR", "linux-arm64", defer_version=True + ) + artifacts = {"Caddy": built, "M": mihomo, "XR": xray} + report["peers"] = { + kind: artifact.identity for kind, artifact in artifacts.items() + } + lab = ContainerLab(report["isolation"], mtu=1500) + report["phase"] = "start" + with tempfile.TemporaryDirectory(prefix="private-", dir=output) as temporary: + root = Path(temporary) + directories = { + role: root / role for role in ("origin", "gateway", "server", "client") + } + for directory in directories.values(): + directory.mkdir() + origin_dir, gateway_dir, server_dir, client_dir = directories.values() + shutil.copyfile( + Path(__file__).with_name("container_udp_origin.py"), + origin_dir / "origin.py", + ) + shutil.copyfile( + Path(__file__).with_name("container_quic_observer.py"), + gateway_dir / "observe.py", + ) + for role, artifact in ( + ("gateway", built), + ("server", xray), + ("client", mihomo), + ): + shutil.copyfile(artifact.binary, directories[role] / "peer") + (directories[role] / "peer").chmod(0o755) + _, _, pin = certificate_chain(gateway_dir) + identities = client_identities(gateway_dir) + identities["absent"] = {"certificate": "", "private-key": ""} + second = root / "second-identity" + second.mkdir() + for name in ("root.pem", "root-key.pem"): + shutil.copyfile(gateway_dir / name, second / name) + download_identity = client_identities(second)["valid"] + (server_dir / "config.json").write_text(json.dumps(native_config())) + with contextlib.ExitStack() as stack: + origin = lab.start( + stack, + origin_dir, + "xhttp-gateway-origin", + [ + "env", + "VCORE_ISOLATED_ORIGIN=1", + "python", + "-B", + "/data/fixture/origin.py", + ], + ) + server = lab.start( + stack, + server_dir, + "xhttp-gateway-xray", + [ + "/data/fixture/peer", + "run", + "-c", + "/data/fixture/config.json", + ], + ) + (gateway_dir / "config.json").write_text( + json.dumps(gateway_config(server.ipv4)) + ) + gateway = lab.start( + stack, + gateway_dir, + "xhttp-gateway-caddy", + [ + "env", + "XDG_CONFIG_HOME=/data/config", + "XDG_DATA_HOME=/data/share", + *(["QLOGDIR=/data/qlog"] if identities_only else []), + "/data/fixture/peer", + "run", + "--config", + "/data/fixture/config.json", + ], + ) + selected = list(cases(identities_only=identities_only)) + config = dict( + ipv6=True, + **{"log-level": "warning"}, + proxies=[], + listeners=[], + rules=["MATCH,REJECT"], + ) + for index, case in enumerate(selected): + candidate = client_config( + gateway.ipv4, pin, "h3", case["mode"], download=case["split"] + ) + node, listener = candidate["proxies"][0], candidate["listeners"][0] + node.update(identities["valid"]) + node["xhttp-opts"]["host"] = "localhost" + if case["split"]: + node["xhttp-opts"]["download-settings"].update( + download_identity + ) + if case["check"] == "reject": + target = ( + node + if case["leg"] in ("upload", "shared") + else node["xhttp-opts"]["download-settings"] + ) + target.update(identities[case["identity"]]) + node["name"] = listener["name"] = listener["proxy"] = case["id"] + listener["port"] = 23100 + index + config["proxies"].append(node) + config["listeners"].append(listener) + (client_dir / "config.json").write_text(json.dumps(config)) + client = lab.start( + stack, + client_dir, + "xhttp-gateway-mihomo", + [ + "/data/fixture/peer", + "-d", + "/data", + "-f", + "/data/fixture/config.json", + ], + ) + + def preserve_logs(): + # Snapshot before deletion, independent of log-follow buffering. + for role, peer in ( + ("gateway", gateway), + ("server", server), + ("client", client), + ): + (output / f"{role}.log").write_text( + sanitized_gateway_log(command("logs", peer.name)) + if role == "gateway" + else redact(command("logs", peer.name)) + ) + + stack.callback(preserve_logs) + for kind, peer in (("Caddy", gateway), ("XR", server), ("M", client)): + version = command( + "exec", + peer.name, + "/data/fixture/peer", + "-v" if kind == "M" else "version", + ).strip() + digest = command( + "exec", peer.name, "sha256sum", "/data/fixture/peer" + ).split()[0] + if not version or digest != report["peers"][kind]["binary_sha256"]: + raise RuntimeError("container binary identity mismatch") + if kind == "Caddy" and not version.startswith( + report["peers"][kind]["release"] + " " + ): + raise RuntimeError( + "container Caddy version differs from latest source build" + ) + report["peers"][kind]["version"] = version + validation = run_command( + [ + "container", + "exec", + gateway.name, + "env", + "XDG_CONFIG_HOME=/data/config", + "XDG_DATA_HOME=/data/share", + "/data/fixture/peer", + "validate", + "--config", + "/data/fixture/config.json", + ], + timeout=30, + limit=65536, + ) + (output / "gateway-validation.log").write_text( + redact(validation.stdout.decode(errors="replace")) + ) + if validation.returncode or not validation.cleanup: + raise RuntimeError("native Caddy configuration validation failed") + for peer in (origin, server, gateway, client): + peer.release() + origin.wait_tcp(24000) + server.wait_tcp(23000) + client.wait_tcp(23100) + report["phase"] = "traffic" + + def trace_snapshot(): + return json.loads( + command( + "exec", + gateway.name, + "python", + "-B", + "/data/fixture/observe.py", + ) + ) + + for index, case in enumerate(selected): + result = dict(case, status="FAIL") + before = trace_snapshot() if identities_only else {} + started = time.monotonic() + try: + check = case["check"] + args = (client.ipv4, 23100 + index, origin.ipv4) + observation = ( + rejected_identity(*args) + if check == "reject" + else stream_check(*args, bulk=check == "bulk-server-first") + if check in ("bulk-server-first", "close") + else exercise(*args, udp=check == "udp") + ) + result.update(observation, status="PASS") + if check == "reject" and not observation["rejected"]: + result.update( + status="FAIL", + failure_kind=( + "TimeoutError" + if observation["client_outcome"] == "timeout" + else "OriginAccepted" + ), + ) + except (OSError, ValueError) as error: + result["failure_kind"] = type(error).__name__ + if identities_only: + after = trace_snapshot() + result["gateway_trace_files"] = len(after) + result["gateway_new_trace_files"] = len( + after.keys() - before.keys() + ) + evidence = [ + event + for key, events in after.items() + if key not in before + for event in events + ] + result["gateway_certificate_errors"] = evidence + if case["check"] == "reject": + expected = { + "absent": "certificate_required", + "expired": "certificate_expired", + "wrong-ca": "unknown_ca", + }[case["identity"]] + result["identity_enforced"] = any( + event["category"] == expected for event in evidence + ) and ( + result.get("origin_accepted") is False + and result.get("data_exchanged") is False + ) + if not result["identity_enforced"]: + result.update(status="FAIL", evidence_missing=True) + result["seconds"] = round(time.monotonic() - started, 3) + report["cases"].append(result) + print(case["id"], result["status"], flush=True) + (output / "result.json").write_text( + json.dumps(report, indent=2) + "\n" + ) + for peer in (origin, server, gateway, client): + peer.ensure_alive() + report["status"] = ( + "PASS" + if all(case["status"] == "PASS" for case in report["cases"]) + else "FAIL" + ) + report["phase"] = "complete" + except BaseException as error: + report.update(status="FAIL", failure_kind=type(error).__name__) + raise + finally: + report["source_unchanged"] = report["source"] == source_identity() + report["cleanup"] = all( + peer.get("joined") for peer in report["isolation"].get("peers", []) + ) + run_id = report["isolation"].get("run_id") + report["owned_remaining"] = ( + [ + item["id"] + for item in listing() + if item["configuration"].get("labels", {}).get("vcore-run") == run_id + ] + if run_id + else [] + ) + (output / "result.json").write_text(json.dumps(report, indent=2) + "\n") + return ( + 0 + if report["status"] == "PASS" + and report["source_unchanged"] + and report["cleanup"] + and not report["owned_remaining"] + else 1 + ) + + +def main(output, *, identities_only=False): + with exclusive_run(): + return run(output, identities_only=identities_only) diff --git a/scripts/src/vcore_scripts/protocol_xhttp_peers.py b/scripts/src/vcore_scripts/protocol_xhttp_peers.py new file mode 100644 index 0000000..8a79382 --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_xhttp_peers.py @@ -0,0 +1,524 @@ +"""XHTTP-PEERS native XHTTP/mux capability probes, not VCore XHTTP acceptance. + +Official clients, servers and origins run in owned containers. The host only +drives SOCKS clients and observes the origin. Direct and layered native decoder +topologies have separate results; a layered pass never overwrites a direct fail. +""" + +from __future__ import annotations + +import contextlib +import json +import shutil +import socket +import struct +import tempfile +import time +from pathlib import Path + +from .builds import CORE_DIR +from .container_close_client import exact +from .mihomo_isolation import exclusive_run +from .mihomo_release import download_mihomo +from .native_release import PeerArtifact, download_native +from .protocol_containers import ContainerLab, command, listing +from .protocol_fixtures import certificate_chain, certificates +from .protocol_inputs import redact, source_identity +from .protocol_vless_security import client_identities + +CLIENT_ID = "07070707-0707-0707-0707-070707070707" + + +def peer_config(kind, cert, key, *, decoder=None, plain=False): + if kind == "M": + listener = dict( + name="xhttp", + type="vless", + listen="::", + port=23000, + users=[dict(uuid=CLIENT_ID)], + **{"xhttp-config": dict(path="/xhttp", mode="auto")}, + ) + if plain: + listener["allow-insecure"] = True + else: + listener.update(certificate=cert, **{"private-key": key}) + return dict( + ipv6=True, + **{"log-level": "warning"}, + listeners=[listener], + rules=["MATCH,DIRECT"], + ) + inbound = dict( + listen="::", + port=23000, + protocol="vless", + settings=dict(clients=[dict(id=CLIENT_ID)], decryption="none"), + streamSettings=dict( + network="xhttp", + security="tls", + tlsSettings=dict( + alpn=["h3"], certificates=[dict(certificateFile=cert, keyFile=key)] + ), + xhttpSettings=dict(path="/xhttp", mode="auto"), + ), + ) + if decoder: + # The same Xray XHTTP handler owns both legs. It forwards the resulting + # byte stream to a real Mihomo VLESS decoder, without parsing its bytes. + inbound.update( + protocol="dokodemo-door", + settings=dict(address=decoder, port=23001, network="tcp"), + ) + return dict( + log=dict(loglevel="warning"), + inbounds=[inbound], + outbounds=[dict(protocol="freedom")], + ) + + +def client_config( + server, pin, version, mode, *, download=False, mux=None, codec="xudp", plain=False +): + node = dict( + name="probe", + type="vless", + server=server, + port=23000, + uuid=CLIENT_ID, + tls=not plain, + udp=True, + network="xhttp", + servername="localhost", + alpn=["http/1.1" if version == "h1" else version], + **{"packet-encoding": codec, "xhttp-opts": dict(path="/xhttp", mode=mode)}, + ) + if not plain: + node["fingerprint"] = pin + if download: + node["xhttp-opts"]["download-settings"] = {} + if mux: + node["smux"] = dict(enabled=True, protocol=mux) + return dict( + ipv6=True, + **{"log-level": "warning"}, + proxies=[node], + listeners=[ + dict( + name="consumer", + type="socks", + listen="::", + port=23002, + udp=True, + proxy="probe", + ) + ], + rules=["MATCH,REJECT"], + ) + + +def identity_peer_config(kind, cert, key, ca): + config = peer_config(kind, cert, key) + if kind == "M": + config["listeners"][0].update( + {"client-auth-type": "require-and-verify", "client-auth-cert": ca} + ) + else: + # Xray's published verify usage adds RootCAs; it is NOT client auth. + # This probe checks whether a client identity is actually enforced, + # without inventing an unsupported client-auth option for that peer. + tls = config["inbounds"][0]["streamSettings"]["tlsSettings"] + tls["certificates"].append(dict(certificateFile=ca, usage="verify")) + return config + + +def variants(): + for mode in ("packet-up", "stream-up", "stream-one"): + yield dict(name=mode, mode=mode) + if mode != "stream-one": + yield dict(name=mode + "-download", mode=mode, download=True) + yield dict(name="packetaddr", mode="packet-up", codec="packetaddr") + for mux in ("h2mux", "smux", "yamux"): + yield dict(name=mux, mode="packet-up", mux=mux) + + +def socks(proxy, port, command_id, target, target_port): + connection = socket.create_connection((proxy, port), timeout=5) + try: + connection.sendall(b"\x05\x01\x00") + if exact(connection, 2) != b"\x05\x00": + raise ValueError("SOCKS greeting rejected") + connection.sendall( + bytes([5, command_id, 0, 1]) + + socket.inet_aton(target) + + struct.pack("!H", target_port) + ) + reply = exact(connection, 4) + if reply[:3] != b"\x05\x00\x00" or reply[3] != 1: + raise ValueError("SOCKS request rejected") + address = socket.inet_ntoa(exact(connection, 4)) + relay_port = struct.unpack("!H", exact(connection, 2))[0] + return connection, (proxy if address == "0.0.0.0" else address, relay_port) + except BaseException: + connection.close() + raise + + +def exercise(proxy, port, origin, *, udp=False): + """One small positive data probe; no retry, no full stage coverage claim.""" + payload = b"xhttp-native-decoder-probe" * 3 + with socket.create_connection((origin, 24000), timeout=5) as control: + control.sendall(b"\x04" if udp else b"\x0d") + target_port = struct.unpack("!H", exact(control, 2))[0] + connection, relay = socks( + proxy, + port, + 3 if udp else 1, + "0.0.0.0" if udp else origin, + 0 if udp else target_port, + ) + with connection: + if not udp: + connection.sendall(payload) + if ( + exact(connection, len(payload)) != payload + or exact(control, 1) != b"A" + ): + raise ValueError("native TCP mismatch") + else: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as datagram: + datagram.settimeout(5) + datagram.bind(("0.0.0.0", 0)) + address = ( + b"\x01" + + socket.inet_aton(origin) + + struct.pack("!H", target_port) + ) + datagram.sendto(b"\x00\x00\x00" + address + payload, relay) + reply, source = datagram.recvfrom(2048) + if source != relay or reply != b"\x00\x00\x00" + address + payload: + raise ValueError("native UDP mismatch") + observed = exact(control, 4) + length = struct.unpack("!H", observed[:2])[0] + if length != len(payload) or exact(control, length) != payload: + raise ValueError("native origin mismatch") + return dict(bytes=len(payload), exact_echo=True, origin_observed=True) + + +def run(output: Path, *, identities_only=False): + output = output.resolve() + if output.parent != (CORE_DIR / "target/interop/runs").resolve(): + raise ValueError( + "native probe output must be a fresh target/interop/runs child" + ) + output.mkdir(parents=True, exist_ok=False) + report = dict( + stage="XHTTP-PEERS", + purpose="XHTTP-H3-client-identity-prerequisite" + if identities_only + else "XHTTP-native-prerequisites", + vcore_acceptance=False, + source=source_identity(), + status="NOT RUN", + cases=[], + peers={}, + isolation={}, + ) + try: + artifacts = {} + for kind in ("M", "XR"): + directory = output / "binaries" / kind + if kind == "M": + identity = {} + artifacts[kind] = PeerArtifact( + download_mihomo( + "linux-arm64", directory=directory, identity=identity + ), + identity, + ) + else: + artifacts[kind] = download_native( + kind, directory, "linux-arm64", defer_version=True + ) + report["peers"][kind] = artifacts[kind].identity + # Go QUIC peers send 1280-byte Initial UDP payloads. A 1280-byte guest + # L3 interface cannot carry those plus IP/UDP headers. This changes only + # these disposable guests, never the shared network or host interface. + lab = ContainerLab(report["isolation"], mtu=1500) + with ( + tempfile.TemporaryDirectory(prefix="private-", dir=output) as temporary, + contextlib.ExitStack() as stack, + ): + root = Path(temporary) + origin_dir = root / "origin" + origin_dir.mkdir() + shutil.copyfile( + Path(__file__).with_name("container_udp_origin.py"), + origin_dir / "origin.py", + ) + origin = lab.start( + stack, + origin_dir, + "xhttp-origin", + [ + "env", + "VCORE_ISOLATED_ORIGIN=1", + "python", + "-B", + "/data/fixture/origin.py", + ], + ) + origin.release() + origin.wait_tcp(24000) + decoder_dir = root / "decoder" + decoder_dir.mkdir() + shutil.copy2(artifacts["M"].binary, decoder_dir / "peer") + (decoder_dir / "config.json").write_text( + json.dumps( + dict( + ipv6=True, + **{"log-level": "warning"}, + listeners=[ + dict( + name="decoder", + type="vless", + listen="::", + port=23001, + users=[dict(uuid=CLIENT_ID)], + **{"allow-insecure": True}, + ) + ], + rules=["MATCH,DIRECT"], + ) + ) + ) + decoder = lab.start( + stack, + decoder_dir, + "xhttp-decoder", + [ + "/data/fixture/peer", + "-d", + "/data", + "-f", + "/data/fixture/config.json", + ], + ) + decoder.release() + decoder.wait_tcp(23001) + groups = ( + ("M", "h1", True, False), + ("M", "h2", True, False), + ("M", "h1", False, False), + ("M", "h2", False, False), + ("XR", "h3", False, False), + ("XR", "h3", False, True), + ) + if identities_only: + groups = (("M", "h2", False, False), ("XR", "h3", False, False)) + for kind, version, plain, layered in groups: + security = "plain" if plain else "tls" + topology = "layered" if layered else "direct" + label = f"{kind}-{version}-{security}-{topology}" + with contextlib.ExitStack() as group: + server_dir, client_dir = ( + root / (label + "-server"), + root / (label + "-client"), + ) + server_dir.mkdir() + client_dir.mkdir() + + def preserve_logs( + server_dir=server_dir, client_dir=client_dir, label=label + ): + for role, directory in ( + ("server", server_dir), + ("client", client_dir), + ): + log = directory / "peer.log" + if log.exists(): + (output / f"{label}-{role}.log").write_text( + redact(log.read_text(errors="replace")) + ) + + # Runs after peer stop/join, before temporary keys/configs + # are removed. No credentials or configs are copied out. + group.callback(preserve_logs) + shutil.copy2(artifacts[kind].binary, server_dir / "peer") + shutil.copy2(artifacts["M"].binary, client_dir / "peer") + cert, key, pin = ( + certificate_chain(server_dir) + if identities_only + else certificates(server_dir) + ) + config = peer_config( + kind, + "/data/fixture/" + cert.name, + "/data/fixture/" + key.name, + decoder=decoder.ipv4 if layered else None, + plain=plain, + ) + identities = {} + if identities_only: + identities = client_identities(server_dir) + identities["absent"] = {"certificate": "", "private-key": ""} + config = identity_peer_config( + kind, + "/data/fixture/" + cert.name, + "/data/fixture/" + key.name, + "/data/fixture/root.pem", + ) + (server_dir / "config.json").write_text(json.dumps(config)) + argv = ( + [ + "/data/fixture/peer", + "-d", + "/data", + "-f", + "/data/fixture/config.json", + ] + if kind == "M" + else [ + "/data/fixture/peer", + "run", + "-c", + "/data/fixture/config.json", + ] + ) + server = lab.start(group, server_dir, label + "-server", argv) + version_text = command( + "exec", + server.name, + "/data/fixture/peer", + "-v" if kind == "M" else "version", + ).strip() + report["peers"][kind]["version"] = version_text + # One immutable client config gives every variant its own + # listener, node and pool; no API mutation or NAT-key reuse. + clients = dict( + ipv6=True, + **{"log-level": "warning"}, + proxies=[], + listeners=[], + rules=["MATCH,REJECT"], + ) + selected = list(variants()) + if identities_only: + selected = [ + dict(name=name, mode="packet-up", download=True) + for name in ("valid", "absent", "expired", "wrong-ca") + ] + for index, variant in enumerate(selected): + kwargs = {k: v for k, v in variant.items() if k != "name"} + client = client_config( + server.ipv4, pin, version, plain=plain, **kwargs + ) + node, listener = client["proxies"][0], client["listeners"][0] + if identities_only: + node.update(identities["valid"]) + node["xhttp-opts"]["download-settings"].update( + identities[variant["name"]] + ) + node["name"] = listener["name"] = listener["proxy"] = variant[ + "name" + ] + listener["port"] = 23100 + index + clients["proxies"].append(node) + clients["listeners"].append(listener) + (client_dir / "config.json").write_text(json.dumps(clients)) + client = lab.start( + group, + client_dir, + label + "-client", + [ + "/data/fixture/peer", + "-d", + "/data", + "-f", + "/data/fixture/config.json", + ], + ) + server.release() + client.release() + if kind == "M": + server.wait_tcp(23000) + client.wait_tcp(23100) + for index, variant in enumerate(selected): + for udp in (False,) if identities_only else (False, True): + business = "udp" if udp else "tcp" + case = dict( + id=f"{label}-{variant['name']}-{business}", + topology="Xray-XHTTP-to-Mihomo-VLESS" + if layered + else "single-native-handler", + status="FAIL", + ) + started = time.monotonic() + should_connect = ( + not identities_only or variant["name"] == "valid" + ) + case["expected_data_connected"] = should_connect + try: + case.update( + exercise( + client.ipv4, 23100 + index, origin.ipv4, udp=udp + ), + data_connected=True, + status="PASS" if should_connect else "FAIL", + ) + if not should_connect: + case["failure_kind"] = "ClientIdentityNotEnforced" + except (OSError, ValueError) as error: + case["failure_kind"] = type(error).__name__ + case["data_connected"] = False + # A rejected exchange alone is only a peer + # prerequisite observation, not an XHTTP negative + # gate proving zero business bytes/resources. + if not should_connect: + case["status"] = "PASS" + case["seconds"] = round(time.monotonic() - started, 3) + report["cases"].append(case) + print(case["id"], case["status"], flush=True) + (output / "result.json").write_text( + json.dumps(report, indent=2) + "\n" + ) + server.ensure_alive() + client.ensure_alive() + report["status"] = ( + "PASS" + if all(case["status"] == "PASS" for case in report["cases"]) + else "FAIL" + ) + except BaseException as error: + report["status"] = "FAIL" + report["failure_kind"] = type(error).__name__ + raise + finally: + report["source_unchanged"] = report["source"] == source_identity() + report["cleanup"] = all( + peer.get("joined") for peer in report["isolation"].get("peers", []) + ) + run_id = report["isolation"].get("run_id") + report["owned_remaining"] = ( + [ + item["id"] + for item in listing() + if item["configuration"].get("labels", {}).get("vcore-run") == run_id + ] + if run_id + else [] + ) + (output / "result.json").write_text(json.dumps(report, indent=2) + "\n") + return ( + 0 + if report["status"] == "PASS" + and report["source_unchanged"] + and report["cleanup"] + and not report["owned_remaining"] + else 1 + ) + + +def main(output, *, identities_only=False): + with exclusive_run(): + return run(output, identities_only=identities_only) diff --git a/scripts/src/vcore_scripts/protocol_xhttp_security.py b/scripts/src/vcore_scripts/protocol_xhttp_security.py new file mode 100644 index 0000000..2c201ee --- /dev/null +++ b/scripts/src/vcore_scripts/protocol_xhttp_security.py @@ -0,0 +1,494 @@ +"""XHTTP VCore upload/download security against owned native handlers. + +H1/H2 use one Mihomo listener with two ports. H3 uses the approved xcaddy +gateway (two bindings, one Xray h2c handler). Neither topology invents a decoder. +""" + +from __future__ import annotations + +import contextlib +import copy +import json +import os +import shutil +import ssl +import tempfile +from pathlib import Path + +from .builds import CORE_DIR +from .caddy_build import build_caddy +from .mihomo_isolation import exclusive_run +from .mihomo_release import download_mihomo +from .native_release import download_native +from .protocol_containers import ContainerLab, command, listing +from .protocol_evidence import read_events +from .protocol_fixtures import certificate_chain +from .protocol_inputs import redact, source_identity +from .protocol_peers import run_command +from .protocol_vless_security import client_identities +from .protocol_xhttp_fields import events_pass +from .protocol_xhttp_gateway import gateway_config, sanitized_gateway_log +from .protocol_xhttp_peers import CLIENT_ID, peer_config + + +def security_cases(base, identities, download_identity, server_ipv6): + """Frozen cases, selected before observing results. No retry or skip-on-error.""" + for mode in ("packet-up", "stream-up", "stream-one"): + node = copy.deepcopy(base) + options = node["xhttp-opts"] + options["mode"] = mode + if mode == "packet-up": + options["sc-min-posts-interval-ms"] = "1" + yield f"{mode}-shared", node, False, None + for identity in ("absent", "expired", "wrong-ca"): + invalid = copy.deepcopy(node) + invalid.update(identities[identity]) + if identity == "absent": + del invalid["certificate"], invalid["private-key"] + yield f"{mode}-shared-{identity}", invalid, True, identity + if mode == "stream-one": + continue + options["download-settings"] = {} + yield f"{mode}-inherit", copy.deepcopy(node), False, None + download = options["download-settings"] + download.update(download_identity) + download.update( + server=server_ipv6, + port=23002, + tls=True, + servername="localhost", + host="localhost", + path="/xhttp/", + headers={"X-Download": "fixture"}, + **{"reuse-settings": {}, "fingerprint": "", "name-cert-verify": ""}, + ) + yield f"{mode}-replace-dualstack-port", copy.deepcopy(node), False, None + download["server"] = "peer.fixture.test" + yield f"{mode}-download-domain", copy.deepcopy(node), False, None + node["servername"] = "sni.fixture.test" + node["name-cert-verify"] = "localhost" + if base["alpn"] != ["h3"]: + # One native handler accepts both ALPNs; the download has its own policy. + download["alpn"] = ["http/1.1"] if base["alpn"] == ["h2"] else ["h2"] + yield f"{mode}-name-clear-alpn", copy.deepcopy(node), False, None + node["servername"] = "localhost" + del node["name-cert-verify"] + for leg in ("upload", "download"): + for identity in ("absent", "expired", "wrong-ca"): + invalid = copy.deepcopy(node) + target = ( + invalid + if leg == "upload" + else invalid["xhttp-opts"]["download-settings"] + ) + target.update(identities[identity]) + if identity == "absent" and leg == "upload": + del target["certificate"], target["private-key"] + yield f"{mode}-{leg}-{identity}", invalid, True, identity + for key, value in ( + ("fingerprint", "00" * 32), + ("name-cert-verify", "wrong.fixture.test"), + ): + invalid = copy.deepcopy(node) + invalid["xhttp-opts"]["download-settings"][key] = value + yield f"{mode}-download-wrong-{key}", invalid, True, None + invalid = copy.deepcopy(node) + invalid["skip-cert-verify"] = True + invalid["xhttp-opts"]["download-settings"].update( + {"skip-cert-verify": False, "servername": "wrong.fixture.test"} + ) + yield f"{mode}-download-false-restores-verify", invalid, True, None + + +def run(output: Path, versions: list[str], *, supplied: dict | None = None): + if ( + not versions + or len(versions) != len(set(versions)) + or not set(versions) <= {"h1", "h2", "h3"} + ): + raise ValueError("select unique H1/H2/H3 security versions") + if ( + not output.is_relative_to(CORE_DIR / "target/interop/runs") + or output == CORE_DIR / "target/interop/runs" + ): + raise ValueError("security output must be a fresh target/interop/runs child") + output.mkdir(exist_ok=False) + report = dict( + stage="XHTTP", + scope="security-development-only", + source=source_identity(), + status="NOT RUN", + cases=[], + peers={}, + isolation={}, + ) + try: + artifacts = {} + if supplied is not None: + for kind in ({"M"} if any(v != "h3" for v in versions) else set()) | ( + {"XR", "Caddy"} if "h3" in versions else set() + ): + artifacts[kind], report["peers"][kind] = supplied[kind] + elif any(v != "h3" for v in versions): + identity = {} + artifacts["M"] = download_mihomo( + "linux-arm64", directory=output / "binaries/M", identity=identity + ) + report["peers"]["M"] = identity + if "h3" in versions and supplied is None: + for kind, artifact in ( + ("Caddy", build_caddy(output / "binaries/Caddy")), + ( + "XR", + download_native( + "XR", output / "binaries/XR", "linux-arm64", defer_version=True + ), + ), + ): + artifacts[kind] = artifact.binary + report["peers"][kind] = artifact.identity + lab = ContainerLab(report["isolation"], mtu=1500) + build = run_command( + [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "xhttp_native", + "--no-run", + ], + timeout=180, + ) + (output / "build.log").write_text(redact(build.stdout.decode(errors="replace"))) + if build.returncode or not build.cleanup: + raise RuntimeError("native security driver build failed") + groups = [ + (version, negative) + for version in versions + for negative in ((False, True) if version == "h3" else (None,)) + ] + for version, negative in groups: + with ( + tempfile.TemporaryDirectory(prefix="private-", dir=output) as temporary, + contextlib.ExitStack() as stack, + ): + root = Path(temporary) + directories = { + role: root / role for role in ("origin", "server", "gateway") + } + for directory in directories.values(): + directory.mkdir() + origin_dir, server_dir, gateway_dir = directories.values() + shutil.copyfile( + Path(__file__).with_name("container_udp_origin.py"), + origin_dir / "origin.py", + ) + origin = lab.start( + stack, + origin_dir, + version + "-origin", + [ + "env", + "VCORE_ISOLATED_ORIGIN=1", + "python", + "-B", + "/data/fixture/origin.py", + ], + ) + cert_dir = gateway_dir if version == "h3" else server_dir + cert, key, pin = certificate_chain(cert_dir) + identities = client_identities(cert_dir) + identities["absent"] = {"certificate": "", "private-key": ""} + second = root / "second" + second.mkdir() + for filename in ("root.pem", "root-key.pem"): + shutil.copyfile(cert_dir / filename, second / filename) + download_identity = client_identities(second)["valid"] + kind = "XR" if version == "h3" else "M" + shutil.copy2(artifacts[kind], server_dir / "peer") + config = peer_config( + kind, f"/data/fixture/{cert.name}", f"/data/fixture/{key.name}" + ) + if kind == "M": + config["hosts"] = {"vcore-fixture.test": origin.ipv4} + config["listeners"][0].update( + { + "port": "23000,23002", + "client-auth-type": "require-and-verify", + "client-auth-cert": "/data/fixture/root.pem", + } + ) + else: + stream = config["inbounds"][0]["streamSettings"] + stream["security"] = "none" + del stream["tlsSettings"] + config["dns"] = {"hosts": {"vcore-fixture.test": origin.ipv4}} + (server_dir / "config.json").write_text(json.dumps(config)) + server = lab.start( + stack, + server_dir, + version + "-server", + [ + "/data/fixture/peer", + "-d", + "/data", + "-f", + "/data/fixture/config.json", + ] + if kind == "M" + else [ + "/data/fixture/peer", + "run", + "-c", + "/data/fixture/config.json", + ], + ) + peers = [(kind, server)] + gateway = None + edge = server + if version == "h3": + shutil.copy2(artifacts["Caddy"], gateway_dir / "peer") + shutil.copyfile( + Path(__file__).with_name("container_quic_observer.py"), + gateway_dir / "observe.py", + ) + gateway = lab.start( + stack, + gateway_dir, + version + "-gateway", + [ + "env", + "XDG_CONFIG_HOME=/data/config", + "XDG_DATA_HOME=/data/share", + *(["QLOGDIR=/data/qlog"] if negative else []), + "/data/fixture/peer", + "run", + "--config", + "/data/fixture/config.json", + ], + ) + conf = gateway_config(server.ipv4) + conf["apps"]["http"]["servers"]["fixture"]["listen"].append( + ":23002" + ) + (gateway_dir / "config.json").write_text(json.dumps(conf)) + peers.append(("Caddy", gateway)) + edge = gateway + for implementation, peer in peers: + identity = report["peers"][implementation] + actual = command( + "exec", + peer.name, + "/data/fixture/peer", + "-v" if implementation == "M" else "version", + ).strip() + digest = command( + "exec", peer.name, "sha256sum", "/data/fixture/peer" + ).split()[0] + if not actual or digest != identity["binary_sha256"]: + raise RuntimeError("native security binary identity mismatch") + identity["version"] = actual + + def preserve_logs(peers=peers, version=version, negative=negative): + for implementation, peer in peers: + text = command("logs", peer.name)[-65536:] + ( + output / f"{version}-{negative}-{implementation}.log" + ).write_text( + sanitized_gateway_log(text) + if implementation == "Caddy" + else redact(text) + ) + + stack.callback(preserve_logs) + origin.release() + server.release() + if gateway: + gateway.release() + origin.wait_tcp(24000) + server.wait_tcp(23000) + base = dict( + name="peer", + type="vless", + server=edge.ipv4, + port=23000, + uuid=CLIENT_ID, + udp=True, + tls=True, + servername="localhost", + fingerprint=pin, + alpn=["http/1.1" if version == "h1" else version], + network="xhttp", + **identities["valid"], + **{ + "xhttp-opts": { + "path": "/xhttp", + "host": "localhost", + "headers": {"X-Upload": "fixture"}, + } + }, + ) + selected = [ + case + for case in security_cases( + base, identities, download_identity, edge.ipv6 + ) + if negative is None or case[2] == negative + ] + + def trace_snapshot(gateway=gateway, negative=negative): + return ( + json.loads( + command( + "exec", + gateway.name, + "python", + "-B", + "/data/fixture/observe.py", + ) + ) + if gateway and negative + else {} + ) + + for name, node, reject, expected_identity in selected: + case_id = f"{version}-{name}" + print(case_id, flush=True) + fixture = root / "input.json" + fixture.write_text( + json.dumps( + dict( + isolation="containers", + node=node, + reject=reject, + server_ipv4=edge.ipv4, + origin_control=f"{origin.ipv4}:24000", + origin_ipv4=origin.ipv4, + origin_ipv6=origin.ipv6, + root_der=list( + ssl.PEM_cert_to_DER_cert( + (cert_dir / "root.pem").read_text() + ) + ), + ) + ) + ) + events = output / f"{case_id}-events.jsonl" + assertion = "security::native_xhttp_security" + cmd = [ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "xhttp_native", + assertion, + "--", + "--exact", + "--ignored", + "--nocapture", + ] + before = trace_snapshot() + result = run_command( + cmd, + timeout=140, + env=dict( + os.environ, + VCORE_XHTTP_INPUT=str(fixture), + VCORE_CASE_EVENTS=str(events), + ), + ) + (output / f"{case_id}.log").write_text( + redact(result.stdout.decode(errors="replace")) + ) + observation = read_events(events) if events.exists() else [] + passed = ( + result.returncode == 0 + and result.cleanup + and events_pass(observation, assertion) + ) + row = dict( + case_id=case_id, + status="PASS" if passed else "FAIL", + command=cmd, + exit_code=result.returncode, + seconds=result.seconds, + command_cleanup=result.cleanup, + topology="Caddy H3 -> one Xray h2c handler" + if gateway + else "one Mihomo handler, two ports", + ) + if gateway and negative: + after = trace_snapshot() + # QUIC trace files contain only lifecycle records at this + # level; retain categories, never identities or packets. + evidence = [ + event + for key, records in after.items() + if key not in before + for event in records + ] + row["certificate_errors"] = evidence + if expected_identity: + expected = { + "absent": "certificate_required", + "expired": "certificate_expired", + "wrong-ca": "unknown_ca", + }[expected_identity] + row["identity_enforced"] = any( + e["category"] == expected for e in evidence + ) + if not row["identity_enforced"]: + row["status"] = "FAIL" + report["cases"].append(row) + print(case_id, row["status"], flush=True) + if row["status"] != "PASS": + raise RuntimeError("native security case failed") + for _, peer in peers: + peer.ensure_alive() + report["status"] = "PASS" + except (OSError, ValueError, RuntimeError) as error: + report.update( + status="FAIL", + reason=str(error) + if isinstance(error, RuntimeError) + else type(error).__name__, + ) + finally: + report["cleanup"] = all( + p.get("joined") for p in report["isolation"].get("peers", []) + ) + report["source_unchanged"] = report["source"] == source_identity() + run_id = report["isolation"].get("run_id") + report["owned_remaining"] = ( + [ + p["id"] + for p in listing() + if p["configuration"].get("labels", {}).get("vcore-run") == run_id + ] + if run_id + else [] + ) + if ( + not report["cleanup"] + or not report["source_unchanged"] + or report["owned_remaining"] + ): + report["status"] = "FAIL" + (output / "xhttp-security-results.json").write_text( + json.dumps(report, indent=2) + "\n" + ) + return 0 if report["status"] == "PASS" else 1 + + +if __name__ == "__main__": + import argparse + import sys + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("output", type=Path) + parser.add_argument("versions", nargs="+", choices=("h1", "h2", "h3")) + args = parser.parse_args() + with exclusive_run(): + sys.exit(run(args.output.resolve(), args.versions)) diff --git a/scripts/src/vcore_scripts/tls_client_hello.py b/scripts/src/vcore_scripts/tls_client_hello.py new file mode 100644 index 0000000..ae03977 --- /dev/null +++ b/scripts/src/vcore_scripts/tls_client_hello.py @@ -0,0 +1,186 @@ +"""Bounded, offline TLS ClientHello inspection, not a TLS implementation.""" + +from __future__ import annotations + +import base64 +import hashlib +import io + +from .container_tls_observer import capture_client_hello + + +class Cursor: + def __init__(self, data: bytes): + self.data, self.at = data, 0 + + def take(self, size: int) -> bytes: + if size < 0 or self.at + size > len(self.data): + raise ValueError("truncated ClientHello") + result = self.data[self.at : self.at + size] + self.at += size + return result + + def number(self, size: int) -> int: + return int.from_bytes(self.take(size), "big") + + def vector(self, size: int) -> bytes: + return self.take(self.number(size)) + + def done(self): + if self.at != len(self.data): + raise ValueError("unparsed ClientHello data") + + +def _codes(data: bytes) -> list[int]: + if len(data) % 2: + raise ValueError("odd TLS uint16 vector") + return [int.from_bytes(data[i : i + 2], "big") for i in range(0, len(data), 2)] + + +def is_grease(value: int) -> bool: + return value & 0x0F0F == 0x0A0A and value >> 8 == value & 0xFF + + +def _shape(hello: dict, shuffle_extensions: bool) -> dict: + def normalize(value, key=""): + if isinstance(value, dict): + return {k: normalize(v, k) for k, v in value.items() if k != "raw_sha256"} + if isinstance(value, list): + return [normalize(v, key) for v in value] + if key in {"ciphers", "values", "type", "group"} and isinstance(value, int): + return "GREASE" if is_grease(value) else value + return value + + result = normalize(hello) + groups = next((e["values"] for e in hello["extensions"] if e["type"] == 10), []) + shares = next((e["shares"] for e in hello["extensions"] if e["type"] == 51), []) + result["grease_share_group_links"] = [ + [i for i, group in enumerate(groups) if group == share["group"]] + for share in shares + if is_grease(share["group"]) + ] + if shuffle_extensions: + # Preserve GREASE/padding/PSK positions, sort ONLY the shuffled slots. + rows = result["extensions"] + slots = [ + i for i, row in enumerate(rows) if row["type"] not in {"GREASE", 21, 41} + ] + ordered = sorted((rows[i] for i in slots), key=lambda row: row["type"]) + for index, row in zip(slots, ordered, strict=True): + rows[index] = row + return result + + +def compare_client_hellos( + expected: bytes, observed: bytes, *, shuffle_extensions: bool = False +) -> list[str]: + """Return differing structural fields; no blanket JA3/ECH/PSK exemption. + + Random values are normalized, not their widths or positions. A caller may + separately classify an observed ECH/padding length variant using its pinned + template; it is intentionally still reported here. + """ + left = _shape(parse_client_hello(expected), shuffle_extensions) + right = _shape(parse_client_hello(observed), shuffle_extensions) + return [ + key + for key in sorted(left.keys() | right.keys()) + if left.get(key) != right.get(key) + ] + + +def validate_capture(record: dict) -> dict: + """Recompute the retained observation from raw records, not PASS text.""" + if any( + len(record[key]) > 256 * 1024 for key in ("client_hello_b64", "records_b64") + ): + raise ValueError("capture size limit") + wire = base64.b64decode(record["records_b64"], validate=True) + raw = base64.b64decode(record["client_hello_b64"], validate=True) + records, handshake, layout = capture_client_hello(io.BytesIO(wire).read) + parsed = parse_client_hello(raw) + if records != wire or handshake != raw or layout != record["records"]: + raise ValueError("TLS records differ from retained ClientHello") + if parsed != record["parsed"]: + raise ValueError("ClientHello evidence differs from raw capture") + return parsed + + +def parse_client_hello(data: bytes) -> dict: + """Observe an entire handshake message, including its four-byte envelope.""" + if len(data) > 65536: + raise ValueError("ClientHello size limit") + cursor = Cursor(data) + if cursor.number(1) != 1 or cursor.number(3) != len(data) - 4: + raise ValueError("invalid ClientHello envelope") + result = {"bytes": len(data), "legacy_version": cursor.number(2)} + cursor.take(32) + result["session_id_bytes"] = len(cursor.vector(1)) + result["ciphers"] = _codes(cursor.vector(2)) + result["compression_methods"] = list(cursor.vector(1)) + extensions = Cursor(cursor.vector(2)) + cursor.done() + result["extensions"] = [] + seen = set() + while extensions.at < len(extensions.data): + kind, payload = extensions.number(2), extensions.vector(2) + if kind in seen or len(seen) >= 128: + raise ValueError("duplicate or excessive ClientHello extensions") + seen.add(kind) + row = {"type": kind, "bytes": len(payload)} + value = Cursor(payload) + if kind in (10, 13, 34, 50): + row["values"] = _codes(value.vector(2)) + elif kind in (27, 43): + row["values"] = _codes(value.vector(1)) + elif kind == 0: + names = Cursor(value.vector(2)) + row["names"] = [] + while names.at < len(names.data): + row["names"].append( + {"type": names.number(1), "name": names.vector(2).decode("ascii")} + ) + elif kind in (16, 17513, 17613): + protocols = Cursor(value.vector(2)) + row["protocols"] = [] + while protocols.at < len(protocols.data): + row["protocols"].append(protocols.vector(1).decode("ascii")) + elif kind == 51: + shares = Cursor(value.vector(2)) + row["shares"] = [] + while shares.at < len(shares.data): + group, key = shares.number(2), shares.vector(2) + share = {"group": group, "bytes": len(key)} + if is_grease(group): + share["grease_data"] = key.hex() + elif group in (23, 24, 25) and key: + share["point_format"] = key[0] + row["shares"].append(share) + elif kind == 65037: + row["hello_type"] = value.number(1) + if row["hello_type"] != 0: + raise ValueError("expected outer ECH structure") + row["kdf"], row["aead"] = value.number(2), value.number(2) + value.take(1) # Random GREASE config_id remains in the raw evidence. + row["enc_bytes"] = len(value.vector(2)) + row["payload_bytes"] = len(value.vector(2)) + elif kind == 35: + row["ticket_bytes"] = len(value.take(len(payload))) + elif kind == 41: + identities = Cursor(value.vector(2)) + row["identities"] = [] + while identities.at < len(identities.data): + row["identities"].append(len(identities.vector(2))) + identities.take(4) # Obfuscated age is time-dependent. + binders = Cursor(value.vector(2)) + row["binders"] = [] + while binders.at < len(binders.data): + row["binders"].append(len(binders.vector(1))) + if not row["identities"] or len(row["identities"]) != len(row["binders"]): + raise ValueError("invalid PSK identity/binder counts") + else: + row["payload_hex"] = value.take(len(payload)).hex() + value.done() + result["extensions"].append(row) + result["raw_sha256"] = hashlib.sha256(data).hexdigest() + return result diff --git a/scripts/src/vcore_scripts/tun2socks.py b/scripts/src/vcore_scripts/tun2socks.py index 461820c..6adf350 100644 --- a/scripts/src/vcore_scripts/tun2socks.py +++ b/scripts/src/vcore_scripts/tun2socks.py @@ -20,8 +20,6 @@ import time from pathlib import Path from urllib.parse import urlparse -ROOT = Path(__file__).resolve().parents[3] -XRAY_SOURCE = ROOT / "references" / "Xray-core" PACKAGE_NAME = "VCore.UwpDemo.Dev" SOCKS_PORT = 19080 TUN_IPV4 = "192.168.8.1" @@ -328,11 +326,12 @@ rules: """ -def run_demo(source_config: Path | None = None) -> None: +def run_demo(source_config: Path, *, xray_source: Path) -> None: if os.name != "nt": raise RuntimeError("This demo requires Windows") - source_config = source_config or Path.home() / "lib" / "xray" / "config.json" - if not source_config.is_file() or not (XRAY_SOURCE / "go.mod").is_file(): + source_config = source_config.resolve() + xray_source = xray_source.resolve() + if not source_config.is_file() or not (xray_source / "go.mod").is_file(): raise RuntimeError("Xray source checkout or source config is missing") package = package_info() @@ -356,13 +355,13 @@ def run_demo(source_config: Path | None = None) -> None: str(xray_exe), "./main", ], - cwd=XRAY_SOURCE, + cwd=xray_source, env=env, check=True, ) revision = subprocess.run( ["git", "rev-parse", "--short", "HEAD"], - cwd=XRAY_SOURCE, + cwd=xray_source, check=True, capture_output=True, text=True, diff --git a/scripts/tests/test_core_checks.py b/scripts/tests/test_core_checks.py new file mode 100644 index 0000000..e65b634 --- /dev/null +++ b/scripts/tests/test_core_checks.py @@ -0,0 +1,87 @@ +"""Offline checks of the supported validation entrypoint, not milestone counts.""" + +import contextlib +import io +import unittest +from unittest.mock import patch + +from vcore_scripts import cli +from vcore_scripts.core_checks import commands, validate_execution +from vcore_scripts.protocol_catalogs import check_protocol_catalogs +from vcore_scripts.protocol_evidence import check_limit_references, load_manifest + + +class CoreChecksTest(unittest.TestCase): + def test_legacy_server_suites_fail_before_acquiring_resources(self): + for suite in ("foundations", "trojan"): + with ( + self.subTest(suite=suite), + patch("vcore_scripts.protocol_harness.tempfile.mkdtemp") as create_run, + patch("subprocess.Popen") as spawn, + contextlib.redirect_stderr(io.StringIO()) as error, + ): + self.assertEqual( + cli.main(["check", "protocol-interop", "--suite", suite]), 1 + ) + self.assertIn("not fully containerized", error.getvalue()) + create_run.assert_not_called() + spawn.assert_not_called() + + def test_catalog_validity_does_not_claim_executed_behavior(self): + with contextlib.redirect_stdout(io.StringIO()) as output: + check_protocol_catalogs() + self.assertIn('"behavior_status": "NOT RUN"', output.getvalue()) + check_limit_references(load_manifest()) + + def test_profiles_select_tests_without_running_all_target_servers(self): + for profile in ("debug", "release", "features"): + selected = commands(profile) + self.assertTrue(selected) + for command in selected: + if command[:2] == ["cargo", "test"]: + self.assertTrue( + "--all-targets" not in command or "--no-run" in command + ) + self.assertNotIn("--ignored", command) + if profile != "features": + self.assertTrue( + all( + ("--release" in command) == (profile == "release") + for command in selected + ) + ) + with self.assertRaises(ValueError): + commands("missing") + + def test_empty_partial_failed_or_unjoined_execution_fails(self): + command = ["cargo", "test", "--test", "one", "--test", "two"] + ok = "test result: ok. 2 passed; 0 failed; 0 ignored;" + validate_execution(command, ok + "\n" + ok, 0, True) + for output, code, cleanup in ( + ("", 0, True), + (ok, 0, True), + (ok + "\ntest result: ok. 0 passed; 0 failed; 3 ignored;", 0, True), + (ok + "\n" + ok, 1, True), + (ok + "\n" + ok, 0, False), + ): + with ( + self.subTest(output=output, code=code, cleanup=cleanup), + self.assertRaises(RuntimeError), + ): + validate_execution(command, output, code, cleanup) + + def test_list_mode_is_read_only_and_capability_names_resolve(self): + with contextlib.redirect_stdout(io.StringIO()) as output: + self.assertEqual(cli.main(["check", "core", "--list"]), 0) + self.assertEqual( + cli.main( + ["check", "protocol-interop", "--suite", "integration", "--list"] + ), + 0, + ) + self.assertIn("shadowsocks_backpressure", output.getvalue()) + self.assertIn("INTEGRATION-PAIR-SS-SS", output.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_fingerprint_cli.py b/scripts/tests/test_fingerprint_cli.py new file mode 100644 index 0000000..5b18af5 --- /dev/null +++ b/scripts/tests/test_fingerprint_cli.py @@ -0,0 +1,56 @@ +"""Profile CLI help/listing never acquires a network or starts a test peer.""" + +import contextlib +import io +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from vcore_scripts.mihomo_isolation import exclusive_run +from vcore_scripts.protocol_fingerprint import main + + +class FingerprintCliTest(unittest.TestCase): + def test_help_exposes_explicit_profile_selection_without_running(self): + output = io.StringIO() + with contextlib.redirect_stdout(output), self.assertRaises(SystemExit) as error: + main(["--help"]) + self.assertEqual(error.exception.code, 0) + self.assertIn("--client-fingerprint", output.getvalue()) + for name in ( + "none", + "chrome", + "chrome120", + "firefox", + "firefox120", + "safari", + "safari16", + ): + self.assertIn(name, output.getvalue()) + + def test_option_between_output_and_cases_reaches_pre_io_selection_check(self): + for name in ( + "none", + "chrome", + "chrome120", + "firefox", + "firefox120", + "safari", + "safari16", + ): + # This argument-only test can run inside a locked native suite. + # Exercise the real lock on its own temporary filesystem boundary. + with ( + tempfile.TemporaryDirectory() as temporary, + patch( + "vcore_scripts.protocol_fingerprint.exclusive_run", + lambda: exclusive_run(Path(temporary) / "cli.lock"), + ), + self.assertRaisesRegex(ValueError, "invalid VLESS native selection"), + ): + main(["unused-output", "--client-fingerprint", name, "missing"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_fingerprint_reference.py b/scripts/tests/test_fingerprint_reference.py new file mode 100644 index 0000000..3e2c6ef --- /dev/null +++ b/scripts/tests/test_fingerprint_reference.py @@ -0,0 +1,82 @@ +"""Offline checks of the selected-profile reference driver interface.""" + +import json +import tempfile +import unittest +from pathlib import Path + +from vcore_scripts.builds import CORE_DIR +from vcore_scripts.protocol_fingerprint_reference import ( + check_reference_report, + reference_cases, + run_reference, +) + + +class FingerprintReferenceTest(unittest.TestCase): + def test_partial_or_unclean_reference_is_not_a_completed_baseline(self): + for changed in ( + {"selection": "partial"}, + {"cleanup": False}, + {"source_unchanged": False}, + ): + report = dict( + scope="selected-v1", + status="CAPTURED", + selection="full", + cleanup=True, + source_unchanged=True, + cases=[], + ) + report.update(changed) + with self.assertRaises(ValueError): + check_reference_report(report) + + def test_invalid_selection_has_no_filesystem_or_network_effects(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "not-created" + for cases in ( + [], + ["missing"], + ["FINGERPRINT-REFERENCE-chrome-tcp-16-0"] * 2, + ): + with self.assertRaises(ValueError): + run_reference(output, cases) + self.assertFalse(output.exists()) + + def test_catalog_covers_selected_names_contexts_and_successive_connections(self): + cases = reference_cases() + self.assertEqual(len({case["id"] for case in cases}), len(cases)) + self.assertEqual( + {case["profile"] for case in cases}, + { + "none", + "chrome", + "chrome120", + "firefox", + "firefox120", + "safari", + "safari16", + }, + ) + for profile in ("chrome", "chrome120", "firefox", "safari"): + selected = [c for c in cases if c["profile"] == profile] + self.assertEqual( + {c["context"] for c in selected}, + {"tcp", "ws", "grpc", "reality", "tls12", "tls13"}, + ) + self.assertEqual({c["attempt"] for c in selected}, {0, 1}) + self.assertEqual({c["sni_length"] for c in cases}, {16, 80}) + catalog = {case["id"]: case for case in cases} + samples = json.loads( + (CORE_DIR / "tests/fingerprints/mihomo-selected-v1.json").read_text() + )["samples"] + for sample in samples: + with self.subTest(sample=sample["case_id"]): + case = catalog[sample["case_id"]] + for field in ("profile", "context", "template"): + self.assertEqual(sample[field], case[field]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_mihomo_release.py b/scripts/tests/test_mihomo_release.py new file mode 100644 index 0000000..b16d835 --- /dev/null +++ b/scripts/tests/test_mihomo_release.py @@ -0,0 +1,416 @@ +from __future__ import annotations + +import gzip +import hashlib +import http.client +import io +import stat +import tempfile +import unittest +import urllib.error +import zipfile +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import patch + +from vcore_scripts import mihomo_release + +LATEST_VERSION_URL = ( + "https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt" +) +RELEASE_BASE = "https://github.com/MetaCubeX/mihomo/releases/download" +TARGET_ASSET_PREFIXES = { + "darwin-arm64": "mihomo-darwin-arm64", + "darwin-amd64": "mihomo-darwin-amd64", + "linux-arm64": "mihomo-linux-arm64", + "linux-amd64": "mihomo-linux-amd64-v1", + "windows-arm64": "mihomo-windows-arm64", + "windows-amd64": "mihomo-windows-amd64-v1", +} + + +class Response(io.BytesIO): + def __init__(self, contents: bytes, url: str): + super().__init__(contents) + self.url = url + + def geturl(self): + return self.url + + def read(self, size=-1): + if size <= 0: + raise AssertionError("Network responses must be read in bounded chunks") + return super().read(size) + + def read1(self, size=-1): + return self.read(size) + + +def zip_contents(entries: list[tuple[str | zipfile.ZipInfo, bytes]]) -> bytes: + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name, contents in entries: + archive.writestr(name, contents) + return output.getvalue() + + +def archive_fixture(target="darwin-arm64", contents=b"fixture executable\n"): + if target.startswith("windows-"): + return zip_contents([("mihomo.exe", contents)]) + return gzip.compress(contents, mtime=0) + + +def asset_name(target, tag): + suffix = "zip" if target.startswith("windows-") else "gz" + return f"{TARGET_ASSET_PREFIXES[target]}-{tag}.{suffix}" + + +class MihomoReleaseTest(unittest.TestCase): + def setUp(self): + directory = self.enterContext(tempfile.TemporaryDirectory()) + self.root = Path(directory) / "core" + self.enterContext(patch.object(mihomo_release, "CORE_DIR", self.root)) + self.urlopen = self.enterContext( + patch( + "urllib.request.urlopen", + side_effect=AssertionError("Unexpected network request"), + ) + ) + self.output = self.enterContext(redirect_stdout(io.StringIO())) + + def binary_path(self, target="darwin-arm64", tag="v1.19.31"): + return ( + self.root + / "target" + / "interop" + / "mihomo" + / tag + / target + / ("mihomo.exe" if target.startswith("windows-") else "mihomo") + ) + + def archive_url(self, target="darwin-arm64", tag="v1.19.31"): + return f"{RELEASE_BASE}/{tag}/{asset_name(target, tag)}" + + def asset_response(self, archive, target="darwin-arm64", tag="v1.19.31"): + return Response(archive, self.archive_url(target, tag)) + + def version_response(self, tag="v1.19.31"): + return Response(f"{tag}\n".encode(), LATEST_VERSION_URL) + + def provide_archive(self, archive, target="darwin-arm64", tag="v1.19.31"): + self.urlopen.side_effect = None + self.urlopen.return_value = self.asset_response(archive, target, tag) + + def assert_download_rejected(self, archive, target="darwin-arm64"): + self.provide_archive(archive, target) + with self.assertRaises(RuntimeError): + mihomo_release.download_mihomo(target, release="v1.19.31") + binary = self.binary_path(target) + self.assertFalse(binary.exists()) + self.assertFalse(any(binary.parent.glob(".download-*"))) + + def test_target_mapping_is_pure_and_supports_host_aliases(self): + self.assertEqual( + set(mihomo_release.SUPPORTED_TARGETS), set(TARGET_ASSET_PREFIXES) + ) + for system in ("Darwin", "Linux", "Windows"): + for machine, architecture in ( + ("arm64", "arm64"), + ("aarch64", "arm64"), + ("amd64", "amd64"), + ("x86_64", "amd64"), + ("AMD64", "amd64"), + ): + with ( + self.subTest(system=system, machine=machine), + patch.object( + mihomo_release.platform, "system", return_value=system + ), + patch.object( + mihomo_release.platform, "machine", return_value=machine + ), + ): + self.assertEqual( + mihomo_release._target(None), f"{system.lower()}-{architecture}" + ) + for target in TARGET_ASSET_PREFIXES: + self.assertEqual(mihomo_release._target(target), target) + self.urlopen.assert_not_called() + self.assertFalse(self.root.exists()) + + def test_unsupported_target_fails_before_network_or_filesystem_changes(self): + for target in ("freebsd-amd64", "linux-386", "../../outside"): + with self.subTest(target=target), self.assertRaises(RuntimeError): + mihomo_release.download_mihomo(target) + for system, machine in (("FreeBSD", "amd64"), ("Linux", "riscv64")): + with ( + self.subTest(system=system, machine=machine), + patch.object(mihomo_release.platform, "system", return_value=system), + patch.object(mihomo_release.platform, "machine", return_value=machine), + self.assertRaises(RuntimeError), + ): + mihomo_release.download_mihomo() + self.urlopen.assert_not_called() + self.assertFalse(self.root.exists()) + + def test_downloads_expected_gzip_and_zip_asset_for_each_target(self): + payload = b"offline executable fixture\x00\xff" + for target in TARGET_ASSET_PREFIXES: + with self.subTest(target=target): + archive = archive_fixture(target, payload) + self.provide_archive(archive, target) + binary = mihomo_release.download_mihomo(target, release="v1.19.31") + self.assertEqual(binary, self.binary_path(target)) + self.assertEqual(binary.read_bytes(), payload) + self.assertEqual(list(binary.parent.iterdir()), [binary]) + request = self.urlopen.call_args.args[0] + self.assertEqual(request.full_url, self.archive_url(target)) + self.assertEqual(request.get_method(), "GET") + self.assertEqual(self.urlopen.call_args.kwargs["timeout"], 30) + if not target.startswith("windows-"): + self.assertTrue(binary.stat().st_mode & stat.S_IXUSR) + report = self.output.getvalue() + for value in ( + "v1.19.31", + asset_name(target, "v1.19.31"), + self.archive_url(target), + hashlib.sha256(archive).hexdigest(), + hashlib.sha256(payload).hexdigest(), + str(binary), + ): + self.assertIn(value, report) + + def test_latest_release_gets_version_text_on_every_call(self): + self.urlopen.side_effect = [ + Response(b" v1.19.31\r\n", LATEST_VERSION_URL), + self.version_response("v1.19.32"), + ] + self.assertEqual(mihomo_release.latest_release(), "v1.19.31") + self.assertEqual(mihomo_release.latest_release(), "v1.19.32") + self.assertEqual(self.urlopen.call_count, 2) + for call in self.urlopen.call_args_list: + self.assertEqual(call.args[0].full_url, LATEST_VERSION_URL) + self.assertEqual(call.args[0].get_method(), "GET") + self.assertEqual(call.kwargs["timeout"], 30) + self.assertFalse(self.root.exists()) + + def test_latest_version_change_downloads_into_new_version_directory(self): + old_archive = archive_fixture(contents=b"old") + new_archive = archive_fixture(contents=b"new") + self.urlopen.side_effect = [ + self.version_response("v1.19.31"), + self.asset_response(old_archive, tag="v1.19.31"), + self.version_response("v1.19.32"), + self.asset_response(new_archive, tag="v1.19.32"), + ] + old_binary = mihomo_release.download_mihomo("darwin-arm64") + new_binary = mihomo_release.download_mihomo("darwin-arm64") + self.assertNotEqual(old_binary, new_binary) + self.assertEqual(old_binary.read_bytes(), b"old") + self.assertEqual(new_binary.read_bytes(), b"new") + self.assertEqual(new_binary, self.binary_path(tag="v1.19.32")) + self.assertEqual( + [call.args[0].full_url for call in self.urlopen.call_args_list], + [ + LATEST_VERSION_URL, + self.archive_url(tag="v1.19.31"), + LATEST_VERSION_URL, + self.archive_url(tag="v1.19.32"), + ], + ) + + def test_same_release_is_downloaded_again_even_when_binary_exists(self): + self.urlopen.side_effect = [ + self.asset_response(archive_fixture(contents=b"first download")), + self.asset_response(archive_fixture(contents=b"second download")), + ] + first = mihomo_release.download_mihomo("darwin-arm64", release="v1.19.31") + self.assertEqual(first.read_bytes(), b"first download") + second = mihomo_release.download_mihomo("darwin-arm64", release="v1.19.31") + self.assertEqual(first, second) + self.assertEqual(second.read_bytes(), b"second download") + self.assertEqual(self.urlopen.call_count, 2) + + def test_version_lookup_failure_does_not_return_existing_binary(self): + binary = self.binary_path() + binary.parent.mkdir(parents=True) + binary.write_bytes(b"previous valid binary") + self.urlopen.side_effect = urllib.error.URLError("offline fixture") + with self.assertRaises(RuntimeError): + mihomo_release.download_mihomo("darwin-arm64") + self.urlopen.assert_called_once() + self.assertEqual(self.urlopen.call_args.args[0].full_url, LATEST_VERSION_URL) + self.assertEqual(binary.read_bytes(), b"previous valid binary") + + def test_latest_release_rejects_invalid_utf8_unsafe_or_oversized_versions(self): + for contents in ( + b"\xff", + b"", + b" \n", + b"../outside", + b"v1/other", + b"v1\\other", + b".", + b"v1\nv2", + b'{"tag_name":"v1.19.31"}', + b"v" * 129, + ): + self.urlopen.side_effect = None + self.urlopen.return_value = Response(contents, LATEST_VERSION_URL) + with self.subTest(contents=contents), self.assertRaises(RuntimeError): + mihomo_release.latest_release() + self.urlopen.return_value = self.version_response() + with ( + patch.object(mihomo_release, "MAX_VERSION_BYTES", 4), + self.assertRaises(RuntimeError), + ): + mihomo_release.latest_release() + self.assertFalse(self.root.exists()) + + def test_explicit_unsafe_release_fails_before_network_or_filesystem_changes(self): + for tag in ("../outside", "v1/other", "v1\\other", ".", "", "v" * 129): + with self.subTest(tag=tag), self.assertRaises(RuntimeError): + mihomo_release.download_mihomo("darwin-arm64", release=tag) + self.urlopen.assert_not_called() + self.assertFalse(self.root.exists()) + + def test_network_or_archive_failure_preserves_existing_binary(self): + binary = self.binary_path() + binary.parent.mkdir(parents=True) + binary.write_bytes(b"previous valid binary") + for failure in ("network", "archive"): + if failure == "network": + self.urlopen.side_effect = urllib.error.URLError("offline fixture") + else: + self.provide_archive(b"invalid gzip") + with self.subTest(failure=failure), self.assertRaises(RuntimeError): + mihomo_release.download_mihomo("darwin-arm64", release="v1.19.31") + self.assertEqual(binary.read_bytes(), b"previous valid binary") + self.assertEqual(list(binary.parent.iterdir()), [binary]) + + def test_corrupt_gzip_or_zip_never_installs_binary(self): + for target in ("darwin-arm64", "windows-amd64"): + with self.subTest(target=target): + self.assert_download_rejected(b"not an archive", target) + + def test_gzip_crc_failure_after_payload_read_preserves_existing_binary(self): + binary = self.binary_path() + binary.parent.mkdir(parents=True) + binary.write_bytes(b"previous valid binary") + archive = bytearray(archive_fixture(contents=b"new binary" * 10000)) + archive[-8] ^= 0xFF + self.provide_archive(bytes(archive)) + decoded_sizes = [] + read1 = gzip.GzipFile.read1 + + def record_decoded_chunk(source, size=-1): + chunk = read1(source, size) + decoded_sizes.append(len(chunk)) + return chunk + + with ( + patch.object(gzip.GzipFile, "read1", record_decoded_chunk), + self.assertRaises(RuntimeError), + ): + mihomo_release.download_mihomo("darwin-arm64", release="v1.19.31") + self.assertGreater(sum(decoded_sizes), 0) + self.assertEqual(binary.read_bytes(), b"previous valid binary") + self.assertEqual(list(binary.parent.iterdir()), [binary]) + + def test_archive_download_is_bounded(self): + archive = archive_fixture() + with patch.object(mihomo_release, "MAX_ARCHIVE_BYTES", len(archive) - 1): + self.assert_download_rejected(archive) + + def test_gzip_and_zip_decompressed_binary_sizes_are_bounded(self): + for target in ("darwin-arm64", "windows-amd64"): + archive = archive_fixture(target, b"A" * 4096) + with ( + self.subTest(target=target), + patch.object(mihomo_release, "MAX_BINARY_BYTES", 128), + ): + self.assert_download_rejected(archive, target) + + def test_interrupted_http_stream_is_reported_without_installing_binary(self): + for version in (True, False): + response = ( + self.version_response() + if version + else self.asset_response(archive_fixture()) + ) + self.urlopen.side_effect = None + self.urlopen.return_value = response + with ( + self.subTest(version=version), + patch.object( + response, + "read1", + side_effect=http.client.IncompleteRead(b"partial", 100), + ), + self.assertRaises(RuntimeError), + ): + if version: + mihomo_release.latest_release() + else: + mihomo_release.download_mihomo("darwin-arm64", release="v1.19.31") + binary = self.binary_path() + self.assertFalse(binary.exists()) + self.assertFalse(any(binary.parent.glob(".download-*"))) + + def test_http_redirect_downgrade_is_rejected(self): + for version in (True, False): + self.urlopen.side_effect = None + self.urlopen.return_value = Response( + b"v1.19.31" if version else archive_fixture(), + "http://example.com/download", + ) + with self.subTest(version=version), self.assertRaises(RuntimeError): + if version: + mihomo_release.latest_release() + else: + mihomo_release.download_mihomo("darwin-arm64", release="v1.19.31") + self.assertFalse(self.binary_path().exists()) + + def test_interrupted_binary_publication_preserves_previous_binary(self): + binary = self.binary_path() + binary.parent.mkdir(parents=True) + binary.write_bytes(b"previous valid binary") + self.provide_archive(archive_fixture(contents=b"new binary")) + with ( + patch.object( + mihomo_release.os, "replace", side_effect=OSError("publication failure") + ), + self.assertRaises(RuntimeError), + ): + mihomo_release.download_mihomo("darwin-arm64", release="v1.19.31") + self.assertEqual(binary.read_bytes(), b"previous valid binary") + self.assertEqual(list(binary.parent.iterdir()), [binary]) + self.provide_archive(archive_fixture(contents=b"new binary")) + self.assertEqual( + mihomo_release.download_mihomo("darwin-arm64", release="v1.19.31"), binary + ) + self.assertEqual(binary.read_bytes(), b"new binary") + + def test_zip_rejects_traversal_and_non_regular_executables(self): + symlink = zipfile.ZipInfo("mihomo.exe") + symlink.create_system = 3 + symlink.external_attr = (stat.S_IFLNK | 0o777) << 16 + for name in ("../mihomo.exe", "/mihomo.exe", "..\\mihomo.exe", symlink): + archive = zip_contents([(name, b"outside")]) + with self.subTest(name=str(name)): + self.assert_download_rejected(archive, "windows-amd64") + self.assertFalse((self.root.parent / "mihomo.exe").exists()) + + def test_zip_rejects_missing_or_multiple_executables(self): + for entries in ( + [("readme.txt", b"no executable")], + [("mihomo.exe", b"first"), ("other.exe", b"second")], + ): + archive = zip_contents(entries) + with self.subTest(entries=entries): + self.assert_download_rejected(archive, "windows-amd64") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_native_release.py b/scripts/tests/test_native_release.py new file mode 100644 index 0000000..af7c1a7 --- /dev/null +++ b/scripts/tests/test_native_release.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import hashlib +import io +import tarfile +import tempfile +import unittest +import urllib.error +import zipfile +from pathlib import Path +from unittest.mock import patch + +from vcore_scripts import native_release + + +class Response(io.BytesIO): + def geturl(self): + return "https://release-assets.githubusercontent.com/fixture" + + +class NativeReleaseTest(unittest.TestCase): + def test_ss_tar_is_bounded_named_only_and_rejects_links(self): + def archive(unsafe=False): + data = io.BytesIO() + with tarfile.open(fileobj=data, mode="w:xz") as bundle: + entry = tarfile.TarInfo("ssserver") + entry.size = 7 + bundle.addfile(entry, io.BytesIO(b"fixture")) + entry = tarfile.TarInfo("sslocal") + if unsafe: + entry.type, entry.linkname = tarfile.SYMTYPE, "/tmp/forbidden" + bundle.addfile(entry) + return data.getvalue() + + with tempfile.TemporaryDirectory() as directory: + path, binary = Path(directory) / "peer.tar.xz", Path(directory) / "ssserver" + path.write_bytes(archive()) + self.assertEqual( + native_release._extract_tar(path, binary, "ssserver"), + hashlib.sha256(b"fixture").hexdigest(), + ) + self.assertEqual(binary.read_bytes(), b"fixture") + path.write_bytes(archive(True)) + with self.assertRaises(RuntimeError): + native_release._extract_tar(path, binary, "ssserver") + + def test_foreign_binary_version_is_deferred_to_its_container(self): + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w") as bundle: + bundle.writestr("v2ray", b"linux-fixture") + with ( + tempfile.TemporaryDirectory() as directory, + patch("urllib.request.urlopen", return_value=Response(archive.getvalue())), + patch("vcore_scripts.native_release.run_command") as run, + ): + peer = native_release.download_native( + "V2", Path(directory), "linux-arm64", defer_version=True + ) + run.assert_not_called() + self.assertIsNone(peer.identity["version"]) + self.assertEqual(peer.binary.read_bytes(), b"linux-fixture") + + def test_failed_download_never_uses_old_binary_or_leaks_network_details(self): + with ( + tempfile.TemporaryDirectory() as directory, + patch( + "urllib.request.urlopen", + side_effect=urllib.error.URLError("synthetic-private-marker"), + ), + ): + binary = Path(directory) / "v2ray" + binary.write_bytes(b"older executable") + with self.assertRaises(RuntimeError) as caught: + native_release.download_native("V2", Path(directory), "darwin-arm64") + self.assertNotIn("synthetic-private-marker", str(caught.exception)) + self.assertEqual(binary.read_bytes(), b"older executable") + self.assertEqual(list(Path(directory).iterdir()), [binary]) + + def test_v2ray_uses_latest_binary_and_records_content_identity(self): + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w") as bundle: + bundle.writestr("v2ray", b"fixture-executable") + bundle.writestr("geoip.dat", b"unused") + with ( + tempfile.TemporaryDirectory() as directory, + patch( + "urllib.request.urlopen", return_value=Response(archive.getvalue()) + ) as request, + patch("vcore_scripts.native_release.run_command") as run, + ): + run.return_value.stdout = b"V2Ray 5.53.0 fixture\n" + run.return_value.returncode = 0 + run.return_value.cleanup = True + peer = native_release.download_native("V2", Path(directory), "darwin-arm64") + self.assertEqual( + request.call_args.args[0].full_url, + "https://github.com/v2fly/v2ray-core/releases/latest/download/v2ray-macos-arm64-v8a.zip", + ) + self.assertEqual(peer.binary.read_bytes(), b"fixture-executable") + self.assertEqual( + peer.identity["binary_sha256"], + hashlib.sha256(b"fixture-executable").hexdigest(), + ) + self.assertEqual( + peer.identity["archive_sha256"], + hashlib.sha256(archive.getvalue()).hexdigest(), + ) + self.assertEqual(peer.identity["version"], "V2Ray 5.53.0 fixture") + self.assertFalse((Path(directory) / "geoip.dat").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_platform_delivery.py b/scripts/tests/test_platform_delivery.py new file mode 100644 index 0000000..f5c1c6f --- /dev/null +++ b/scripts/tests/test_platform_delivery.py @@ -0,0 +1,305 @@ +"""Offline delivery validation with real temporary output trees.""" + +from __future__ import annotations + +import copy +import hashlib +import json +import os +import subprocess +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from vcore_scripts import builds, platform_delivery +from vcore_scripts.cli import main + + +class PlatformDeliveryTest(unittest.TestCase): + def test_all_delivery_platforms_reject_linked_output_before_mutation(self): + for platform_name in ("apple", "windows", "android"): + relative = Path("dist") / platform_name + if platform_name == "windows": + relative /= "x64" + for depth in range(1, len(relative.parts) + 1): + with ( + self.subTest(platform=platform_name, depth=depth), + tempfile.TemporaryDirectory() as directory, + ): + fixture = Path(directory) + root = fixture / "checkout" + root.mkdir() + external = fixture / "external" + external.mkdir() + link = root.joinpath(*relative.parts[:depth]) + link.parent.mkdir(parents=True, exist_ok=True) + if os.name == "nt": + # Junctions need no symlink privilege on Windows CI. + subprocess.run( + ["cmd", "/c", "mklink", "/J", str(link), str(external)], + check=True, + capture_output=True, + ) + else: + link.symlink_to(external, target_is_directory=True) + output = external.joinpath(*relative.parts[depth:]) + output.mkdir(parents=True, exist_ok=True) + manifest = output / "vcore-delivery.json" + manifest.write_text("original manifest") + sentinel = output / "keep" + sentinel.write_bytes(b"original artifact") + ndk = fixture / "ndk" + ndk.mkdir() + (ndk / "source.properties").write_text("fixture") + with ( + patch.dict( + os.environ, {"ANDROID_NDK_HOME": str(ndk)}, clear=True + ), + patch.object(Path, "home", return_value=fixture), + patch.object( + platform_delivery, + "os", + SimpleNamespace( + name="nt" if platform_name == "windows" else "posix", + environ=os.environ, + ), + ), + patch.object(builds, "CORE_DIR", root), + patch.object( + builds, "_windows_architecture", return_value="x64" + ), + patch.object( + builds, "_windows_msvc_environment", return_value={} + ), + patch.object(builds, "_android_toolchain", return_value=ndk), + patch.object( + platform_delivery, "_source", return_value={"fixture": True} + ), + patch.object( + platform_delivery, "_output", return_value="fixture" + ), + patch.object( + builds, + f"build_{platform_name}", + side_effect=AssertionError("unsafe path reached build"), + ) as build, + ): + try: + with self.assertRaisesRegex(ValueError, "symlink|reparse"): + platform_delivery.build_delivery(platform_name) + finally: + build.assert_not_called() + self.assertEqual(manifest.read_text(), "original manifest") + self.assertEqual( + sentinel.read_bytes(), b"original artifact" + ) + + def test_android_delivery_replaces_stale_abis_without_touching_other_outputs(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + ndk = root / "ndk" + ndk.mkdir() + (ndk / "source.properties").write_text("Pkg.Revision = fixture") + output = root / "dist/android" + stale = output / "armeabi-v7a/libvcore.so" + stale.parent.mkdir(parents=True) + stale.write_bytes(b"stale") + (output / "vcore-delivery.json").write_text("old manifest") + unrelated = root / "dist/apple/keep" + unrelated.parent.mkdir(parents=True) + unrelated.write_bytes(b"keep") + + def build(): + self.assertFalse(stale.exists()) + self.assertFalse((output / "vcore-delivery.json").exists()) + for abi, machine in [("arm64-v8a", 183), ("x86_64", 62)]: + for name in ("libvcore.so", "libc++_shared.so"): + path = output / abi / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes( + b"\x7fELF\x02\x01\x01" + + bytes(9) + + b"\x03\x00" + + machine.to_bytes(2, "little") + + builds.EXPECTED_IDENTITY + ) + + with ( + patch.dict(os.environ, {"ANDROID_NDK_HOME": str(ndk)}, clear=True), + # Windows resolves home from environment variables that this + # hermetic fixture clears; do not depend on a runner profile. + patch.object(Path, "home", return_value=root), + patch.object(builds, "CORE_DIR", root), + patch.object(builds, "build_android", side_effect=build), + patch.object(builds, "_android_toolchain", return_value=ndk), + patch.object( + platform_delivery, "_source", return_value={"fixture": True} + ), + patch.object(platform_delivery, "_output", return_value="fixture"), + ): + platform_delivery.build_delivery("android") + platform_delivery.check_delivery([output / "vcore-delivery.json"]) + self.assertEqual(unrelated.read_bytes(), b"keep") + + def test_delivery_rejects_debug_before_starting_a_build(self): + with patch.dict(os.environ, {"VCORE_BUILD_PROFILE": "debug"}): + self.assertEqual(main(["build", "android", "--delivery"]), 1) + + def test_android_evidence_checks_real_files_not_claimed_success(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "source" + source.mkdir() + (source / "Cargo.lock").write_bytes(b"fixture lock\n") + subprocess.run(["git", "init", "-q", str(source)], check=True) + subprocess.run(["git", "-C", str(source), "add", "."], check=True) + subprocess.run( + [ + "git", + "-C", + str(source), + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "fixture", + ], + check=True, + ) + + def git(ref): + return subprocess.check_output( + ["git", "-C", str(source), "rev-parse", ref], text=True + ).strip() + + output = root / "android" + artifacts = [] + identity = ( + "VCore;engine=rust;coreVersion=0.1.0;" + "invokeApiVersion=5;configVersion=27" + ) + for abi, machine in [("arm64-v8a", 183), ("x86_64", 62)]: + for name in ("libvcore.so", "libc++_shared.so"): + # Minimal ELF header, independent of the production reader. + contents = ( + b"\x7fELF\x02\x01\x01" + + bytes(9) + + b"\x03\x00" + + machine.to_bytes(2, "little") + + bytes(44) + + identity.encode() + ) + path = output / abi / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(contents) + artifacts.append( + { + "path": f"{abi}/{name}", + "size": len(contents), + "sha256": hashlib.sha256(contents).hexdigest(), + } + ) + manifest = output / "vcore-delivery.json" + record = { + "formatVersion": 1, + "group": "android", + "profile": "release", + "source": { + "commit": git("HEAD"), + "tree": git("HEAD^{tree}"), + "lockSha256": hashlib.sha256(b"fixture lock\n").hexdigest(), + }, + "buildIdentity": identity, + "features": [ + "ffi", + "tun", + "inbound-http", + "inbound-socks5", + "outbound-anytls", + "outbound-socks5", + "outbound-shadowsocks", + "outbound-trojan", + "outbound-vmess", + "outbound-vless", + "outbound-hysteria2", + ], + "host": {"os": "Linux", "architecture": "x86_64"}, + "toolchain": { + "rustc": "fixture", + "cargo": "fixture", + "ndk": "fixture", + "clang": "fixture", + "androidApi": "24", + }, + "artifacts": artifacts, + } + manifest.write_text(json.dumps(record)) + command = [ + "check", + "platform-artifacts", + "--manifest", + str(manifest), + "--source-dir", + str(source), + ] + self.assertEqual(main(command), 0) + for mutation in ( + "profile", + "features", + "identity", + "source", + "toolchain", + "empty", + "missing", + "duplicate", + "traversal", + "architecture", + ): + with self.subTest(mutation=mutation): + bad = copy.deepcopy(record) + if mutation == "profile": + bad["profile"] = "debug" + elif mutation == "features": + bad["features"].append("interop-test") + elif mutation == "identity": + bad["buildIdentity"] = "schema14" + elif mutation == "source": + bad["source"]["commit"] = "0" * 40 + elif mutation == "toolchain": + del bad["toolchain"]["ndk"] + elif mutation == "empty": + bad["artifacts"] = [] + elif mutation == "missing": + bad["artifacts"].pop() + elif mutation == "duplicate": + bad["artifacts"].append(bad["artifacts"][0]) + elif mutation == "traversal": + bad["artifacts"][0]["path"] = "../android/arm64-v8a/libvcore.so" + elif mutation == "architecture": + file = output / "arm64-v8a/libvcore.so" + original = file.read_bytes() + wrong = original[:18] + b"\x3e\x00" + original[20:] + file.write_bytes(wrong) + bad["artifacts"][0]["sha256"] = hashlib.sha256( + wrong + ).hexdigest() + manifest.write_text(json.dumps(bad)) + try: + self.assertEqual(main(command), 1) + finally: + if mutation == "architecture": + file.write_bytes(original) + manifest.write_text(json.dumps(record)) + self.assertEqual(main(command + ["--complete"]), 1) + (output / "arm64-v8a/libc++_shared.so").unlink() + self.assertEqual(main(command), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_protocol_containers.py b/scripts/tests/test_protocol_containers.py new file mode 100644 index 0000000..c0bb78b --- /dev/null +++ b/scripts/tests/test_protocol_containers.py @@ -0,0 +1,281 @@ +"""Offline ownership/config checks; no server is launched on the host.""" + +import contextlib +import io +import json +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from vcore_scripts.container_udp_origin import main as origin_main +from vcore_scripts.container_udp_origin import serve as origin_serve +from vcore_scripts.protocol_containers import NETWORK, ContainerLab, ContainerPeer +from vcore_scripts.protocol_vmess_udp_ab import main, options +from vcore_scripts.protocol_vmess_udp_container import client_config + + +class ContainerTests(unittest.TestCase): + def test_frozen_latest_image_is_single_run_owned(self): + from vcore_scripts.protocol_containers import IMAGE, frozen_image + + network = dict( + configuration=dict(mode="hostOnly", labels={"purpose": NETWORK}), + status=dict(ipv4Subnet="192.0.2.0/24", ipv6Subnet="fd00::/64"), + ) + digest = "sha256:" + "a" * 64 + inspection = dict(configuration=dict(descriptor=dict(digest=digest))) + + def command(*args, **_): + return json.dumps([network if args[0] == "network" else inspection]) + + with ( + tempfile.TemporaryDirectory() as root, + patch("vcore_scripts.protocol_containers.command", side_effect=command), + patch("vcore_scripts.protocol_containers.run_command") as pull, + ): + pull.return_value = SimpleNamespace( + returncode=0, cleanup=True, stdout=b"ok" + ) + with frozen_image(Path(root) / "image.log") as snapshot: + first, second = ContainerLab({}), ContainerLab({}) + self.assertEqual(first.image, second.image) + self.assertTrue(first.image.endswith("@" + digest)) + self.assertEqual(snapshot["digest"], digest) + self.assertEqual(snapshot["tag"], IMAGE) + self.assertEqual(pull.call_count, 1) + ContainerLab({}) + self.assertEqual( + pull.call_count, 2, "no image cache across independent runs" + ) + + def test_image_refresh_failure_never_falls_back_to_a_cached_image(self): + from vcore_scripts.protocol_containers import frozen_image + + with ( + tempfile.TemporaryDirectory() as root, + patch("vcore_scripts.protocol_containers.command") as inspect, + patch("vcore_scripts.protocol_containers.run_command") as pull, + ): + pull.return_value = SimpleNamespace( + returncode=1, + cleanup=True, + stdout=b"registry unavailable token=synthetic-secret", + ) + log = Path(root) / "image.log" + with self.assertRaisesRegex(RuntimeError, "image pull"), frozen_image(log): + self.fail("failed refresh must not establish a frozen image scope") + inspect.assert_not_called() + self.assertIn("registry unavailable", log.read_text()) + self.assertNotIn("synthetic-secret", log.read_text()) + + def test_dns_fixture_remains_owned_across_idle_business_phases(self): + control, udp = MagicMock(), MagicMock() + control.recv.return_value = b"\x11" + control.getsockname.return_value = ("192.0.2.55", 24000) + udp.getsockname.return_value = ("0.0.0.0", 53001) + query = b"\x00" * 12 + b"\x0dvcore-fixture\x04test\x00\x00\x01\x00\x01" + udp.recvfrom.return_value = (query, ("192.0.2.2", 40001)) + udp.sendto.side_effect = lambda data, _peer: len(data) + with ( + patch("vcore_scripts.container_udp_origin.socket.socket") as socket, + patch("vcore_scripts.container_udp_origin.SLOTS"), + patch( + "vcore_scripts.container_udp_origin.select.select", + side_effect=[ + ([], [], []), + ([udp], [], []), + ([control], [], []), + ], + ), + ): + socket.return_value.__enter__.return_value = udp + origin_serve(control) + udp.sendto.assert_called_once() + self.assertEqual(udp.sendto.call_args.args[1], ("192.0.2.2", 40001)) + with ( + patch("vcore_scripts.container_udp_origin.socket.socket") as socket, + patch("vcore_scripts.container_udp_origin.SLOTS"), + patch( + "vcore_scripts.container_udp_origin.time.monotonic", + side_effect=[0, 241], + ), + patch("vcore_scripts.container_udp_origin.select.select") as wait, + ): + socket.return_value.__enter__.return_value = udp + origin_serve(control) + wait.assert_not_called() + + def test_quic_mtu_applies_only_to_the_owned_guest_network(self): + lab = SimpleNamespace( + run_id="fixture", mtu=1500, image="fixture@sha256:synthetic" + ) + peer = ContainerPeer(lab, Path("fixture"), "quic") + with ( + patch( + "vcore_scripts.protocol_containers.command", + side_effect=RuntimeError("captured launch"), + ) as launch, + self.assertRaisesRegex(RuntimeError, "captured launch"), + ): + peer.start(["peer"]) + arguments = launch.call_args.args + self.assertEqual( + arguments[arguments.index("--network") + 1], NETWORK + ",mtu=1500" + ) + self.assertNotIn("--cap-add", arguments) + self.assertNotIn("--publish", arguments) + + def test_long_roles_keep_bounded_distinct_owned_container_names(self): + lab = SimpleNamespace(run_id="0123456789ab") + roles = [ + "xhttp-stream-up-download-reality-tls-origin", + "xhttp-stream-up-download-reality-tls-server", + "xhttp-stream-up-download-reality-tls-upstream", + ] + peers = [ContainerPeer(lab, Path("fixture"), role) for role in roles] + self.assertEqual(len({peer.name for peer in peers}), len(roles)) + for peer, role in zip(peers, roles, strict=True): + self.assertLessEqual(len(peer.name), 63) + self.assertIn(lab.run_id, peer.name) + self.assertEqual(peer.record["role"], role) + + def test_non_private_network_fails_before_image_or_launch(self): + network = dict(configuration=dict(mode="shared", labels={"purpose": NETWORK})) + with ( + patch( + "vcore_scripts.protocol_containers.command", + return_value=json.dumps([network]), + ) as command, + self.assertRaisesRegex(RuntimeError, "host-only"), + ): + ContainerLab({}) + command.assert_called_once_with("network", "inspect", NETWORK) + + def test_ownership_mismatch_never_deletes(self): + lab = SimpleNamespace(run_id="fixture") + with tempfile.TemporaryDirectory() as root: + peer = ContainerPeer(lab, Path(root), "server") + unrelated = dict( + id=peer.name, configuration=dict(labels={"purpose": "other"}) + ) + with ( + patch( + "vcore_scripts.protocol_containers.listing", + return_value=[unrelated], + ), + patch("vcore_scripts.protocol_containers.command") as command, + self.assertRaisesRegex(RuntimeError, "ownership"), + ): + peer.stop() + command.assert_not_called() + + def test_inventory_failure_still_closes_log_capture(self): + for error in [ + RuntimeError("isolated container operation failed: list"), + TimeoutError("inventory query timed out"), + json.JSONDecodeError("invalid inventory", "not-json", 0), + KeyboardInterrupt(), + ]: + with self.subTest(error=type(error).__name__): + peer = ContainerPeer( + SimpleNamespace(run_id="fixture"), Path("fixture"), "server" + ) + peer.capture = MagicMock(record={"joined": True}) + with ( + patch( + "vcore_scripts.protocol_containers.listing", side_effect=error + ) as listing, + patch("vcore_scripts.protocol_containers.command") as command, + self.assertRaises(type(error)) as raised, + ): + peer.stop() + self.assertIs(raised.exception, error) + peer.capture.__exit__.assert_called_once_with(None, None, None) + self.assertTrue(peer.record["log_cleanup"]) + self.assertFalse(peer.record["joined"]) + listing.assert_called_once_with() + command.assert_not_called() + + def test_cleanup_only_owned_vm_and_verifies_absence(self): + lab = SimpleNamespace(run_id="fixture") + with tempfile.TemporaryDirectory() as root: + peer = ContainerPeer(lab, Path(root), "server") + owned = dict( + id=peer.name, + configuration=dict(labels={"purpose": NETWORK, "vcore-run": "fixture"}), + status=dict(state="running"), + ) + other = dict(id="another-task") + with ( + patch( + "vcore_scripts.protocol_containers.listing", + side_effect=[[owned, other], [other]], + ), + patch("vcore_scripts.protocol_containers.command") as command, + ): + peer.stop() + self.assertEqual( + [call.args for call in command.call_args_list], + [ + ("stop", "--time", "5", peer.name), + ("delete", "--force", peer.name), + ], + ) + self.assertTrue(peer.record["joined"]) + + def test_failed_launch_still_registers_cleanup(self): + lab = ContainerLab.__new__(ContainerLab) + lab.run_id, lab.record = "fixture", {"peers": []} + with ( + patch.object( + ContainerPeer, "start", side_effect=RuntimeError("partial launch") + ), + patch.object(ContainerPeer, "stop") as stop, + ): + with ( + self.assertRaisesRegex(RuntimeError, "partial launch"), + contextlib.ExitStack() as stack, + ): + lab.start(stack, Path("fixture"), "server", []) + stop.assert_called_once() + + def test_no_native_origin_entry_or_archived_host_probe(self): + with ( + patch.dict("os.environ", {}, clear=True), + self.assertRaisesRegex(SystemExit, "container harness"), + ): + origin_main() + for option in ["--collision-probe", "--socket-probe"]: + with ( + patch("sys.argv", ["diagnostic", "unused", option]), + contextlib.redirect_stderr(io.StringIO()), + ): + with self.assertRaises(SystemExit) as error: + main() + self.assertEqual(error.exception.code, 2) + + def test_client_server_and_origins_use_distinct_container_addresses(self): + config, selected = client_config( + options(["raw", "xudp", "packetaddr"]), + "ws", + True, + "192.0.2.2", + "192.0.2.3", + "192.0.2.4", + "fixture-pin", + ) + self.assertEqual(len(selected), 39) + self.assertEqual({item["socks_host"] for item in selected}, {"192.0.2.4"}) + self.assertEqual( + {item["listen"] for item in config["listeners"]}, {"192.0.2.4"} + ) + self.assertEqual({item["server"] for item in config["proxies"]}, {"192.0.2.2"}) + self.assertEqual(config["hosts"]["vcore-fixture.test"], "192.0.2.3") + self.assertEqual(config["rules"], ["MATCH,REJECT"]) + self.assertNotIn("packet-encoding", config["proxies"][0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_protocol_evidence.py b/scripts/tests/test_protocol_evidence.py new file mode 100644 index 0000000..4291ff9 --- /dev/null +++ b/scripts/tests/test_protocol_evidence.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import copy +import json +import tempfile +import unittest +from pathlib import Path + +from vcore_scripts.protocol_evidence import ( + load_manifest, + rust_results, + select_cases, + validate_results, +) + + +class ProtocolEvidenceTest(unittest.TestCase): + def setUp(self): + self.cases = select_cases(load_manifest(), stage="FOUNDATIONS") + self.results = [ + { + "case_id": case["case_id"], + "status": "PASS", + "scope": "foundation-only", + "assertions": dict.fromkeys(case["expected_observation"], True), + "evidence": list(case["required_evidence"]), + "cleanup": True, + "command_exit_code": 0, + "row_ids": case["row_ids"], + "peer_kind": case["peer_kind"], + } + for case in self.cases + ] + + def test_complete_results_are_accepted_as_foundation_not_protocol_field_signoff( + self, + ): + validate_results(self.cases, self.results) + + def test_missing_duplicate_unknown_and_empty_required_cases_fail(self): + for results in [ + self.results[:-1], + self.results + [self.results[0]], + self.results + [{"case_id": "unknown"}], + ]: + with self.subTest(results=len(results)), self.assertRaises(ValueError): + validate_results(self.cases, results) + with self.assertRaises(ValueError): + validate_results([], []) + with self.assertRaises(ValueError): + select_cases(load_manifest(), stage="WIREGUARD") + + def test_failure_blocked_not_run_timeout_and_cleanup_never_become_pass(self): + for update in [ + {"status": status} for status in ["FAIL", "BLOCKED", "NOT RUN"] + ] + [{"cleanup": False}, {"command_exit_code": 124}]: + results = copy.deepcopy(self.results) + results[0].update(update) + with self.subTest(update=update), self.assertRaises(ValueError): + validate_results(self.cases, results) + + def test_cfg_only_incomplete_or_false_assertions_are_not_behavior_evidence(self): + for update in [ + {"evidence": ["CFG"]}, + {"assertions": {}}, + {"assertions": {key: False for key in self.results[0]["assertions"]}}, + {"row_ids": ["T08"]}, + ]: + results = copy.deepcopy(self.results) + results[0].update(update) + with self.subTest(update=update), self.assertRaises(ValueError): + validate_results(self.cases, results) + + def test_manifest_rejects_duplicate_ids_and_unknown_rows(self): + manifest = load_manifest() + for mutation in ["duplicate", "unknown-row"]: + data = { + "schema_version": 1, + "kind": "executable-case-manifest", + "cases": copy.deepcopy(manifest), + } + if mutation == "duplicate": + data["cases"].append(data["cases"][0]) + else: + data["cases"][0]["row_ids"] = ["not-a-field"] + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "cases.json" + path.write_text(json.dumps(data)) + with self.assertRaises(ValueError): + load_manifest(path) + + def test_resource_claims_require_idle_structured_snapshots(self): + case = next( + case for case in self.cases if case["case_id"] == "FOUNDATIONS-RESOURCES" + ) + events = [ + { + "schema_version": 1, + "suite": case["case_id"], + "assertion": name, + "status": status, + } + for name in case["expected_observation"] + for status in ["BEGIN", "PASS"] + ] + self.assertEqual(rust_results(case, events, 0)["status"], "FAIL") + + def test_malformed_or_unknown_assertions_never_become_pass(self): + case = self.cases[0] + events = [ + { + "schema_version": 1, + "suite": case["case_id"], + "assertion": name, + "status": status, + } + for name in case["expected_observation"] + for status in ["BEGIN", "PASS"] + ] + self.assertEqual(rust_results(case, events, 0)["status"], "PASS") + events[0]["schema_version"] = 99 + self.assertEqual(rust_results(case, events, 0)["status"], "FAIL") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_protocol_harness.py b/scripts/tests/test_protocol_harness.py new file mode 100644 index 0000000..437459b --- /dev/null +++ b/scripts/tests/test_protocol_harness.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import contextlib +import io +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from vcore_scripts import cli, protocol_harness, protocol_preflight +from vcore_scripts.native_release import PeerArtifact +from vcore_scripts.protocol_inputs import redact + + +class ProtocolHarnessTest(unittest.TestCase): + def test_cli_freezes_list_case_protocol_and_preflight_selection(self): + with patch("vcore_scripts.cli.run_protocol_interop") as run: + self.assertEqual( + cli.main( + [ + "check", + "protocol-interop", + "--suite", + "foundations", + "--case", + "FOUNDATIONS-QUIC", + "--protocol", + "foundation", + "--list", + ] + ), + 0, + ) + self.assertEqual(run.call_args.kwargs["identifiers"], ["FOUNDATIONS-QUIC"]) + self.assertTrue(run.call_args.kwargs["list_only"]) + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + cli.main( + [ + "check", + "protocol-interop", + "--suite", + "foundations", + "--list", + "--preflight", + ] + ) + + def test_coverage_requires_explicit_catalog_or_result_mode(self): + with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit): + cli.main(["check", "protocol-coverage"]) + with patch("vcore_scripts.cli.check_run") as check: + self.assertEqual( + cli.main( + [ + "check", + "protocol-coverage", + "--suite", + "foundations", + "--run-dir", + "target/interop/runs/fixture", + ] + ), + 0, + ) + self.assertEqual(check.call_args.args[1], "FOUNDATIONS") + self.assertEqual( + check.call_args.args[0], + Path("target/interop/runs/fixture").resolve(), + ) + + def test_redaction_removes_paths_uuid_authorization_and_private_key(self): + output = redact( + "/Users/example/private/input password=secret " + "Authorization: Bearer secret-token\n" + "12345678-1234-1234-1234-123456789abc " + "-----BEGIN PRIVATE KEY-----secret-material-----END PRIVATE KEY-----" + ) + for secret in [ + "secret", + "Bearer-token", + "secret-token", + "12345678", + "/Users/example", + "secret-material", + ]: + self.assertNotIn(secret, output) + + def test_peer_preflight_failure_does_not_prevent_other_peer_capabilities(self): + with tempfile.TemporaryDirectory() as temporary: + + def download(kind, directory): + if kind == "H": + raise RuntimeError("private-marker") + return PeerArtifact( + Path(temporary) / kind, {"version": "fixture", "kind": kind} + ) + + with patch.object( + protocol_preflight, "download_native", side_effect=download + ): + artifacts, records = protocol_preflight.preflight( + Path(temporary), {"H", "XR", "V2"} + ) + self.assertEqual(set(artifacts), {"XR", "V2"}) + self.assertEqual( + {record["kind"]: record["status"] for record in records}, + {"H": "BLOCKED", "V2": "READY", "XR": "READY"}, + ) + self.assertNotIn("private-marker", json.dumps(records)) + + def test_sigint_still_writes_incomplete_report_without_promoting_not_run(self): + identity = { + "source_tree_sha256": "0" * 64, + "lock_sha256": "1" * 64, + "dirty_patch_sha256": "2" * 64, + "parent_commit": "3" * 40, + } + with ( + tempfile.TemporaryDirectory() as temporary, + contextlib.ExitStack() as stack, + ): + root = Path(temporary) + stack.enter_context(patch.object(protocol_harness, "CORE_DIR", root)) + stack.enter_context( + patch.object( + protocol_harness, + "run_identity", + return_value=identity + | { + "stage": "VMESS", + "mode": "execute", + "suite_timeout_seconds": 30, + "commands": [], + }, + ) + ) + stack.enter_context( + patch.object(protocol_harness, "source_identity", return_value=identity) + ) + stack.enter_context( + patch.object( + protocol_harness, + "exclusive_run", + side_effect=contextlib.nullcontext, + ) + ) + stack.enter_context( + patch.object(protocol_harness, "check_protocol_catalogs") + ) + stack.enter_context( + patch( + "vcore_scripts.protocol_vmess_acceptance.execute", + side_effect=KeyboardInterrupt, + ) + ) + output = root / "target/interop/runs/fixture" + with ( + contextlib.redirect_stdout(io.StringIO()), + self.assertRaises(RuntimeError), + ): + protocol_harness.run_protocol_interop( + stage="VMESS", identifiers=["VMESS-CFG"], run_dir=output + ) + self.assertEqual( + json.loads((output / "cases.json").read_text())[0]["status"], "NOT RUN" + ) + self.assertFalse(json.loads((output / "run.json").read_text())["cleanup"]) + self.assertTrue((output / "resources.jsonl").is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_protocol_hysteria2.py b/scripts/tests/test_protocol_hysteria2.py new file mode 100644 index 0000000..0f55c04 --- /dev/null +++ b/scripts/tests/test_protocol_hysteria2.py @@ -0,0 +1,157 @@ +"""Offline checks of HYSTERIA2's evidence boundary; no sockets or protocol peers.""" + +import copy +import json +import tempfile +import unittest +from pathlib import Path + +from vcore_scripts.protocol_hysteria2 import test_command +from vcore_scripts.protocol_hysteria2_acceptance import ( + bandwidth_pass, + hopping_pass, + native_result, +) +from vcore_scripts.protocol_hysteria2_catalog import ( + FIELDS, + H, + definitions, + events_pass, +) + + +def pair(suite, assertion): + return [ + dict(schema_version=1, suite=suite, assertion=assertion, status=s) + for s in ("BEGIN", "PASS") + ] + + +def tcp_events(): + outer = pair("HYSTERIA2-BASE", "tcp_ipv4_ipv6_domain_and_measure") + return ( + outer[:1] + pair("HYSTERIA2-BASE", "tcp_10mib_both_directions") * 3 + outer[1:] + ) + + +class Hysteria2AcceptanceTest(unittest.TestCase): + def test_catalog_covers_all_fields_and_eight_hop_combinations(self): + cases = definitions() + self.assertEqual(len(cases), 36) + self.assertEqual(len(cases), len({c["case_id"] for c in cases})) + self.assertEqual(len(FIELDS), 20) + self.assertEqual(FIELDS, set().union(*(set(c["row_ids"]) for c in cases))) + self.assertEqual( + { + (v["ipv6"], v["obfs"], v["random_interval"]) + for v in H.values() + if v["test"] == "native_hopping" + }, + { + (a, b, c) + for a in (False, True) + for b in (False, True) + for c in (False, True) + }, + ) + + def test_partial_unknown_duplicate_and_wrong_suite_events_fail(self): + # The public caller records one outer case plus three nested bulk + # transfers. Mirror that real trace, not only its outer success event. + observed = tcp_events() + self.assertTrue(events_pass(observed, "hysteria2_tcp_base")) + wrong = copy.deepcopy(observed) + wrong[1]["suite"] = "XHTTP-BASE" + for bad in ( + [], + observed[:1], + observed * 2, + wrong, + observed + pair("HYSTERIA2-BASE", "unknown"), + pair("HYSTERIA2-BASE", "tcp_ipv4_ipv6_domain_and_measure"), + observed[:1] + observed[3:], + observed + pair("HYSTERIA2-BASE", "tcp_10mib_both_directions"), + ): + self.assertFalse(events_pass(bad, "hysteria2_tcp_base")) + + def test_native_identity_cleanup_or_missing_results_fail(self): + case = next(c for c in definitions() if c["case_id"] == "HYSTERIA2-TCP") + test = "hysteria2_tcp_base" + report = dict( + status="PASS", + cleanup=True, + source_unchanged=True, + obfs=False, + cases=[ + dict( + case_id=test, + status="PASS", + exit_code=0, + command_cleanup=True, + command=test_command(test), + ) + ], + isolation=dict( + network_mode="hostOnly", + host_servers=False, + guest_mtu=1500, + image_digest="sha256:" + "a" * 64, + peers=[dict(started=True, joined=True)], + ), + peers={ + "M": dict( + version="test", + binary_sha256="b" * 64, + source_url="https://github.com/MetaCubeX/mihomo/" + "releases/download/test/fixture", + ) + }, + ) + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + path = directory / f"{test}-events.jsonl" + path.write_text("".join(json.dumps(e) + "\n" for e in tcp_events())) + self.assertEqual(native_result(case, report, directory)["status"], "PASS") + for key, bad in ( + ("peers", {}), + ("cleanup", False), + ("cases", []), + ("source_unchanged", False), + ): + altered = copy.deepcopy(report) + altered[key] = bad + self.assertEqual( + native_result(case, altered, directory)["status"], "FAIL" + ) + path.unlink() + self.assertEqual(native_result(case, report, directory)["status"], "FAIL") + + def test_bandwidth_and_hop_counters_cannot_be_forged_as_empty_success(self): + self.assertFalse(bandwidth_pass([])) + self.assertFalse(bandwidth_pass([{}] * 10)) + self.assertFalse(hopping_pass({})) + hop = dict( + seconds=60, + protected_sockets=9, + tcp_bytes_per_direction=10485760, + tcp_sha256="c" * 64, + udp_packets=1500, + udp_target_kinds=3, + udp_maximums=[4071, 4052, 4060], + socket_peak=2, + ) + self.assertTrue(hopping_pass(hop)) + for key, value in ( + ("seconds", 59), + ("protected_sockets", 8), + ("udp_packets", 0), + ("tcp_sha256", ""), + ("socket_peak", 3), + ("udp_maximums", []), + ): + altered = dict(hop, **{key: value}) + self.assertFalse(hopping_pass(altered)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_protocol_integration.py b/scripts/tests/test_protocol_integration.py new file mode 100644 index 0000000..2edc642 --- /dev/null +++ b/scripts/tests/test_protocol_integration.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +import copy +import io +import json +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path + +from vcore_scripts import cli + + +class IntegrationAcceptanceTest(unittest.TestCase): + def test_native_source_identity_and_cleanup_are_required(self): + from vcore_scripts.protocol_integration_checks import envelope + + identity = dict( + version="official-test", + binary_sha256="a" * 64, + archive_sha256="b" * 64, + source_url="https://github.com/MetaCubeX/mihomo/releases/latest/download/test", + ) + source = { + k: "c" * 64 + for k in ( + "parent_commit", + "source_tree_sha256", + "dirty_patch_sha256", + "lock_sha256", + ) + } + digest = "sha256:" + "d" * 64 + report = dict( + status="PASS", + cleanup=True, + source_unchanged=True, + source=source, + peers={"M": identity}, + isolation=dict( + network_mode="hostOnly", + host_servers=False, + guest_mtu=1500, + image_digest=digest, + peers=[dict(started=True, joined=True)], + ), + ) + self.assertTrue(envelope(report, {"M": identity}, source, digest)) + for mutate in ( + lambda d: d.update(cleanup=False), + lambda d: d.update(source_unchanged=False), + lambda d: d["source"].update(source_tree_sha256="e" * 64), + lambda d: d["peers"]["M"].update(binary_sha256="e" * 64), + lambda d: d["isolation"].update(host_servers=True), + lambda d: d["isolation"].update(guest_mtu=1200), + lambda d: d["isolation"]["peers"][0].update(joined=False), + lambda d: d["isolation"].update(peers=[]), + ): + broken = copy.deepcopy(report) + mutate(broken) + self.assertFalse(envelope(broken, {"M": identity}, source, digest)) + + def test_pressure_evidence_is_recomputed_not_inferred(self): + from vcore_scripts.protocol_evidence import RESOURCE_KINDS + from vcore_scripts.protocol_integration_metrics import ( + NEW, + QUEUES, + lifetimes, + rebuild, + soak, + ) + + def snapshot(current): + return dict( + counts=[ + dict(kind=k, current=current, peak=1) + for k in sorted(RESOURCE_KINDS) + ] + ) + + def sample(active=True): + return dict( + heap_in_use=2_000_000, + rss_kib=50_000, + fd=100 if active else 6, + resources=snapshot(int(active)), + queues=[dict(kind=k, capacity=v, peak=1) for k, v in QUEUES.items()], + ) + + event = dict(resources=snapshot(0)) + stop = dict( + stop_ms=4, + after_stop=sample(False), + quiet_seconds=5.1, + quiet=sample(False), + ports_rebound=True, + ) + rows = [ + dict( + generation=i, + tcp=20, + udp=20, + per_protocol_tcp=5, + per_protocol_udp=5, + active=sample(), + setup_us=[1000] * 40, + fault="owned-peer-connections-closed", + new_clients=True, + after_clients=sample(), + ) + for i in range(100) + ] + value = dict( + count=100, + same_running_session=True, + protocols=NEW, + cold_fd=4, + baseline_fd=6, + cycles=rows, + stopped=stop, + ) + self.assertTrue(rebuild(value, event)) + for mutate in ( + lambda d: d["cycles"].pop(), + lambda d: d.update(same_running_session=False), + lambda d: d["cycles"][0].update(tcp=0), + lambda d: d["cycles"][0].update(setup_us=[]), + lambda d: d["stopped"].update(stop_ms=5001), + lambda d: d["stopped"].update(quiet_seconds=0), + lambda d: d["stopped"]["after_stop"].update(fd=7), + ): + broken = copy.deepcopy(value) + mutate(broken) + self.assertFalse(rebuild(broken, event)) + self.assertFalse(lifetimes(dict(count=99, cycles=[]), [])) + points = [ + dict(sample(), seconds=300 + i * 60, waves=i + 1, faults=5 + i) + for i in range(25) + ] + value = dict( + requested_seconds=1800, + seconds=1800.2, + protocols=NEW, + tcp=20, + udp=20, + per_protocol_tcp=5, + per_protocol_udp=5, + waves=1000, + normal_verified_bytes=1000 * 20 * 2 * 2 * 257, + normal_corruption=0, + normal_misdirection=0, + normal_unexpected_loss=0, + switches=list(range(1, 1800)), + faults=[ + dict( + start=60 * i, + end=60 * i + 0.5, + old_tcp_closed=20, + new_tcp=20, + new_udp=20, + replay=False, + ) + for i in range(1, 30) + ], + samples=points, + cold_fd=4, + baseline_fd=6, + stopped=stop, + active_end=sample(), + first_setup_us=[1000] * 40, + last_setup_us=[1100] * 40, + first_setup_median_us=1000, + last_setup_median_us=1100, + heap=dict( + early_median=2_000_000, late_median=2_000_000, allowed_growth=1048576 + ), + ) + self.assertTrue(soak(value, event, points)) + for mutate in ( + lambda d: d.update(requested_seconds=65), + lambda d: d.update(seconds=1799), + lambda d: d["faults"].pop(), + lambda d: d.update(switches=[]), + lambda d: d["samples"][0].update(heap_in_use=0), + lambda d: d["samples"][-1]["queues"][0].update(peak=10000), + lambda d: d["heap"].update(late_median=4_000_000), + lambda d: d.update(last_setup_us=[3000] * 40, last_setup_median_us=3000), + lambda d: d.update(normal_unexpected_loss=1), + ): + broken = copy.deepcopy(value) + mutate(broken) + self.assertFalse(soak(broken, event, broken["samples"])) + self.assertFalse(soak(value, event, points[:-1])) + + def test_integration_consumer_results_keep_the_manifest_scope(self): + from vcore_scripts.protocol_evidence import load_manifest, validate_results + from vcore_scripts.protocol_security_acceptance import result + + case = next( + c for c in load_manifest() if c["case_id"] == "INTEGRATION-PAIR-SS-SS" + ) + validate_results([case], [result(case, True, True)]) + + def test_pair_evidence_requires_each_real_path_and_original_assertions(self): + from vcore_scripts.protocol_integration_acceptance import pair_pass + from vcore_scripts.protocol_integration_native import rust_command + + identifier = "INTEGRATION-PAIR-SOCKS5-VLESS" + record = dict( + case_id=identifier, + command=rust_command(), + exit_code=0, + command_cleanup=True, + ) + paths = [ + dict( + outer_ipv6=v6, + nested_select=group, + tcp_target_kinds=3, + tcp_bytes_each_direction=31457280, + udp_target_kinds=3, + udp_packets=1200, + udp_sizes=[1, 64, 512, 1200], + server_first=True, + client_first=True, + ports_rebound=True, + ) + for v6 in (False, True) + for group in (False, True) + ] + observation = dict( + first="socks5", + last="vless", + paths=paths, + domain_native=[], + budgets=[ + dict( + transmit=tx, + receive=rx, + roundtrip_size=min(tx, rx), + packets=100, + oversize_rejected_before_origin=True, + ) + for tx, rx in ((128, 128), (512, 256)) + ], + routed_udp=dict( + upstream_business_udp_disabled=True, + carrier_tcp=True, + carrier_udp=True, + leaf_business_udp_rejected=True, + ), + carrier_capability=dict( + upstream_datagrams="rejected", + tcp_allowed=True, + udp_allowed=True, + no_bypass=True, + ), + ) + events = [ + dict(schema_version=1, suite=suite, assertion=assertion, status=status) + for suite, assertion, count in ( + ("INTEGRATION-PAIR", "ordered_pair", 1), + ("INTEGRATION-BASE", "tcp_10mib_both_directions", 12), + ("INTEGRATION-PAIR", "directional_budget", 1), + ("INTEGRATION-PAIR", "routed_udp_permission", 1), + ("INTEGRATION-PAIR", "carrier_capability", 1), + ) + for _ in range(count) + for status in ("BEGIN", "PASS") + ] + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + event_path = root / (identifier + "-events.jsonl") + data_path = root / (identifier + "-observations.json") + event_path.write_text("".join(json.dumps(e) + "\n" for e in events)) + data_path.write_text(json.dumps(observation)) + self.assertTrue(pair_pass(identifier, record, root)) + for mutate in ( + lambda d: d["paths"].pop(), + lambda d: d["paths"].append(d["paths"][0]), + lambda d: d["paths"][0].update(udp_packets=799), + lambda d: d["paths"][0].update(ports_rebound=False), + lambda d: d.update(last="vmess"), + lambda d: d["budgets"][0].update(packets=99), + lambda d: d["carrier_capability"].update(no_bypass=False), + ): + broken = copy.deepcopy(observation) + mutate(broken) + data_path.write_text(json.dumps(broken)) + self.assertFalse(pair_pass(identifier, record, root)) + data_path.write_text(json.dumps(observation)) + event_path.write_text("".join(json.dumps(e) + "\n" for e in events[:-1])) + self.assertFalse(pair_pass(identifier, record, root)) + + def test_list_contains_all_ordered_pairs_and_remaining_gates(self): + output = io.StringIO() + with redirect_stdout(output): + self.assertEqual( + cli.main( + ["check", "protocol-interop", "--suite", "integration", "--list"] + ), + 0, + ) + identifiers = [line.split("\t")[0] for line in output.getvalue().splitlines()] + protocols = {"SOCKS5", "ANYTLS", "SS", "TROJAN", "VMESS", "VLESS", "HYSTERIA2"} + expected = { + f"INTEGRATION-PAIR-{first}-{last}" + for first in protocols + for last in protocols + } + self.assertEqual(len(expected), 49) + self.assertEqual({key for key in identifiers if "-PAIR-" in key}, expected) + self.assertEqual(len(identifiers), len(set(identifiers))) + self.assertTrue( + { + "INTEGRATION-ENTRYPOINTS", + "INTEGRATION-GRAPH", + "INTEGRATION-DNS-MEASURE", + "INTEGRATION-SS-ALGORITHMS", + "INTEGRATION-SS-EIH", + "INTEGRATION-LIFECYCLE", + "INTEGRATION-REBUILD", + "INTEGRATION-FAILURES", + "INTEGRATION-SOAK", + "INTEGRATION-HY2-HOP", + "INTEGRATION-DEBUG", + "INTEGRATION-RELEASE", + "INTEGRATION-QUALITY", + "INTEGRATION-FEATURES", + "INTEGRATION-SCRIPTS", + "INTEGRATION-SHARED", + } + <= set(identifiers) + ) diff --git a/scripts/tests/test_protocol_peers.py b/scripts/tests/test_protocol_peers.py new file mode 100644 index 0000000..597531b --- /dev/null +++ b/scripts/tests/test_protocol_peers.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import os +import select +import signal +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from vcore_scripts.protocol_peers import OwnedProcess, run_command + + +class OwnedPeerTest(unittest.TestCase): + def test_success_is_bounded_and_records_exit_and_cleanup(self): + result = run_command([sys.executable, "-c", "print('fixture')"], timeout=2) + self.assertEqual(result.stdout, b"fixture\n") + self.assertEqual(result.returncode, 0) + self.assertTrue(result.cleanup) + + def test_timeout_and_output_overflow_fail_and_join(self): + for source, limit in [ + ("import time; time.sleep(60)", 128), + ("print('x'*10000)", 64), + ]: + result = run_command( + [sys.executable, "-c", source], timeout=0.2, limit=limit + ) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(result.cleanup) + self.assertLessEqual(len(result.stdout), limit) + + def test_partial_start_and_permission_failure_do_not_leave_a_process(self): + from unittest.mock import patch + + for error in [FileNotFoundError(), PermissionError()]: + with ( + tempfile.TemporaryDirectory() as directory, + patch("subprocess.Popen", side_effect=error), + ): + record = {} + with ( + self.assertRaises(type(error)), + OwnedProcess(["fixture"], Path(directory) / "private.log", record), + ): + pass + self.assertTrue(record["joined"]) + self.assertFalse(record["started"]) + + def test_peer_exit_is_not_readiness(self): + with tempfile.TemporaryDirectory() as directory: + record = {} + with OwnedProcess( + [sys.executable, "-c", "raise SystemExit(3)"], + Path(directory) / "private.log", + record, + ) as peer: + peer.process.wait(timeout=2) + with self.assertRaises(RuntimeError): + peer.ensure_alive() + self.assertEqual(record["unexpected_exit"], 3) + self.assertTrue(record["joined"]) + + def test_cleanup_failure_is_explicit(self): + from unittest.mock import patch + + with tempfile.TemporaryDirectory() as directory: + record = {} + peer = OwnedProcess( + [sys.executable, "-c", "import time; time.sleep(60)"], + Path(directory) / "private.log", + record, + ) + peer.__enter__() + try: + with ( + patch.object( + peer, + "_terminate", + side_effect=OSError("synthetic-private-marker"), + ), + self.assertRaises(OSError), + ): + peer.__exit__(None, None, None) + self.assertFalse(record["joined"]) + finally: + peer._terminate() + peer._finish_reader() + + def test_cancellation_joins_only_the_owned_peer(self): + with tempfile.TemporaryDirectory() as directory: + record = {} + with ( + self.assertRaises(KeyboardInterrupt), + OwnedProcess( + [sys.executable, "-c", "import time; time.sleep(60)"], + Path(directory) / "peer.log", + record, + ) as peer, + ): + pid = peer.process.pid + raise KeyboardInterrupt + self.assertTrue(record["joined"]) + self.assertIsNotNone(peer.process.poll()) + with self.assertRaises(ProcessLookupError): + os.kill(pid, 0) + + @unittest.skipIf(os.name == "nt", "Unix process-group signal contract") + def test_real_sigint_joins_nested_peer_without_stopping_unrelated_peer(self): + with tempfile.TemporaryDirectory() as directory: + source = ( + "import signal, sys\nfrom pathlib import Path\n" + "from vcore_scripts.protocol_peers import OwnedProcess\n" + "with OwnedProcess([sys.executable, '-c', " + "'import time; time.sleep(60)'], " + "Path(sys.argv[1]), {}) as peer:\n" + " print(peer.process.pid, flush=True)\n signal.pause()\n" + ) + unrelated = {} + with OwnedProcess( + [sys.executable, "-c", "import time; time.sleep(60)"], + Path(directory) / "unrelated.log", + unrelated, + ) as sentinel: + worker = subprocess.Popen( + [sys.executable, "-c", source, str(Path(directory) / "nested.log")], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + try: + ready, _, _ = select.select([worker.stdout], [], [], 5) + self.assertTrue(ready, "signal fixture did not start") + pid = int(worker.stdout.readline()) + worker.send_signal(signal.SIGINT) + self.assertNotEqual(worker.wait(timeout=10), 0) + with self.assertRaises(ProcessLookupError): + os.kill(pid, 0) + sentinel.ensure_alive() + finally: + if worker.poll() is None: + worker.kill() + worker.wait(timeout=5) + worker.stdout.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_protocol_reality_hybrid.py b/scripts/tests/test_protocol_reality_hybrid.py new file mode 100644 index 0000000..ef13861 --- /dev/null +++ b/scripts/tests/test_protocol_reality_hybrid.py @@ -0,0 +1,125 @@ +import unittest + +from vcore_scripts.cli import _parser +from vcore_scripts.protocol_reality_hybrid import ( + MODES, + cases, + negative_wire_pass, + wire_pass, +) + + +class HybridRealityGateTests(unittest.TestCase): + def test_browser_downgrade_and_retry_require_each_legs_actual_server_hello(self): + events = [] + for port, group, hrr in ((25002, 29, False), (25003, 24, True)): + events.extend( + [ + { + "port": port, + "event": "client-hello", + "shares": [[4588, 1216], [29, 32]], + "groups": [4588, 29, 23, 24], + }, + {"port": port, "event": "server-hello", "group": group, "hrr": hrr}, + ] + ) + self.assertTrue(negative_wire_pass(events, "chrome")) + self.assertTrue(negative_wire_pass(events * 2, "chrome", legs=2)) + self.assertFalse(negative_wire_pass(events, "chrome", legs=2)) + self.assertFalse(negative_wire_pass(events[:-1], "chrome")) + self.assertFalse( + negative_wire_pass(events[:-1] + [dict(events[-1], hrr=False)], "chrome") + ) + self.assertFalse( + negative_wire_pass( + [dict(events[0], shares=[[29, 32]])] + events[1:], "chrome" + ) + ) + + def test_hybrid_only_peer_rejection_needs_positive_no_shared_group_evidence(self): + events = [] + for front, cover in ((25002, 24432), (25003, 24433)): + events.extend( + [ + { + "port": front, + "event": "client-hello", + "shares": [[4588, 1216]], + "groups": [4588], + }, + {"port": cover, "event": "no-shared-group"}, + {"port": front, "event": "server-close", "bytes": 0}, + ] + ) + self.assertTrue(negative_wire_pass(events, "none")) + self.assertFalse(negative_wire_pass(events[:-1], "none")) + self.assertFalse(negative_wire_pass(events, "none", legs=2)) + self.assertFalse(negative_wire_pass(events, "chrome")) + self.assertFalse( + negative_wire_pass( + events + [{"port": 25002, "event": "server-hello", "group": 29}], + "none", + ) + ) + + def test_required_scope_includes_profiles_download_and_lifecycle(self): + required = cases() + self.assertEqual(len(required), 54) + self.assertEqual(len(MODES), 12) + for profile in ("none", "chrome"): + for version in ("h1", "h2"): + for mode in ("stream-up", "packet-up"): + self.assertIn(f"{profile}-{version}-{mode}-base", required) + for variant in ("main-classic", "download-classic"): + self.assertIn(f"{profile}-{version}-{variant}", required) + self.assertIn(f"{profile}-{version}-stream-up-security", required) + for mode in ("tcp", "grpc", "vision", "h2-stream-up"): + for test in ("life", "owned"): + self.assertIn(f"chrome-{mode}-{test}", required) + + def test_observation_requires_both_real_shares_and_actual_negotiation(self): + events = [ + { + "port": 25000, + "event": "client-hello", + "shares": [[4588, 1216], [29, 32]], + }, + {"port": 25000, "event": "server-hello", "group": 4588}, + ] + expected = {25000: (True, "chrome")} + self.assertTrue(wire_pass(events, expected)) + self.assertFalse(wire_pass(events[:1], expected)) + self.assertFalse(wire_pass(events[1:], expected)) + self.assertFalse(wire_pass(events, {**expected, 25001: (True, "chrome")})) + self.assertFalse(wire_pass(events + [dict(events[1], group=29)], expected)) + self.assertFalse( + wire_pass([dict(events[0], shares=[[29, 32]]), events[1]], expected) + ) + + def test_classic_and_hybrid_legs_have_independent_requirements(self): + events = [ + {"port": 25000, "event": "client-hello", "shares": [[4588, 1216]]}, + {"port": 25000, "event": "server-hello", "group": 4588}, + {"port": 25001, "event": "client-hello", "shares": [[29, 32]]}, + {"port": 25001, "event": "server-hello", "group": 29}, + ] + self.assertTrue( + wire_pass(events, {25000: (True, "none"), 25001: (False, "chrome")}) + ) + self.assertFalse( + wire_pass(events, {25000: (True, "none"), 25001: (True, "chrome")}) + ) + + def test_cli_exposes_a_subpackage_not_full_stage_acceptance(self): + args = _parser().parse_args( + [ + "check", + "reality-hybrid", + "--run-dir", + "target/interop/runs/new", + "--case", + "chrome-tcp-base", + ] + ) + self.assertEqual(args.identifiers, ["chrome-tcp-base"]) diff --git a/scripts/tests/test_protocol_retirement.py b/scripts/tests/test_protocol_retirement.py new file mode 100644 index 0000000..f40e653 --- /dev/null +++ b/scripts/tests/test_protocol_retirement.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import io +import tempfile +import tomllib +import unittest +from contextlib import redirect_stderr +from pathlib import Path +from unittest.mock import patch + +from vcore_scripts import cli +from vcore_scripts.protocol_catalogs import FIELD_IDS, PEERS, PROTOCOLS, SUITES +from vcore_scripts.protocol_preflight import preflight + + +class ProtocolRetirementTest(unittest.TestCase): + def test_wireguard_is_not_a_feature_protocol_peer_or_field_target(self): + root = Path(__file__).resolve().parents[2] + features = tomllib.loads((root / "Cargo.toml").read_text())["features"] + self.assertNotIn("outbound-wireguard", features) + self.assertNotIn("wireguard", PROTOCOLS) + self.assertNotIn("W", PEERS) + self.assertNotIn("WIREGUARD", SUITES) + self.assertFalse(any(identifier.startswith("WG") for identifier in FIELD_IDS)) + + def test_retired_cli_selections_fail_before_any_harness_work(self): + for arguments in ( + ["protocol-interop", "--suite", "wireguard", "--list"], + ["protocol-coverage", "--suite", "wireguard", "--catalog-only"], + [ + "protocol-interop", + "--suite", + "security", + "--protocol", + "wireguard", + "--list", + ], + ): + with self.subTest(arguments=arguments), redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit) as error: + cli.main(["check", *arguments]) + self.assertEqual(error.exception.code, 2) + + def test_retired_peer_preflight_cannot_download_or_create_a_container(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) / "not-created" + with ( + patch("vcore_scripts.protocol_preflight.run_command") as command, + patch("vcore_scripts.protocol_preflight.download_native") as download, + ): + with self.assertRaisesRegex(ValueError, "unsupported native peer"): + preflight(directory, {"M", "W"}, container=True) + command.assert_not_called() + download.assert_not_called() + self.assertFalse(directory.exists()) diff --git a/scripts/tests/test_protocol_run_evidence.py b/scripts/tests/test_protocol_run_evidence.py new file mode 100644 index 0000000..0370ee6 --- /dev/null +++ b/scripts/tests/test_protocol_run_evidence.py @@ -0,0 +1,235 @@ +"""Offline evidence fixtures; these synthetic passes are never stage reports.""" + +from __future__ import annotations + +import contextlib +import copy +import hashlib +import io +import json +import tempfile +import unittest +from pathlib import Path + +from vcore_scripts.protocol_evidence import RESOURCE_KINDS, check_run, load_manifest +from vcore_scripts.protocol_harness import SCRIPT_OBSERVATIONS + + +class RunEvidenceTest(unittest.TestCase): + def fixture(self): + cases = [case for case in load_manifest() if case["stage"] == "FOUNDATIONS"] + idle = { + "counts": [ + {"kind": name, "current": 0, "peak": 1} + for name in sorted(RESOURCE_KINDS) + ] + } + events = [] + legacy = [] + native = [] + results = [] + for case in cases: + results.append( + { + "case_id": case["case_id"], + "row_ids": case["row_ids"], + "peer_kind": case["peer_kind"], + "scope": "foundation-only", + "status": "PASS", + "cleanup": True, + "command_exit_code": 0, + "assertions": dict.fromkeys(case["expected_observation"], True), + "evidence": case["required_evidence"], + } + ) + if case["runner"] in {"rust", "mihomo-legacy"}: + target = legacy if case["runner"] == "mihomo-legacy" else events + for assertion in case["expected_observation"]: + for status in ["BEGIN", "PASS"]: + target.append( + { + "schema_version": 1, + "suite": case["case_id"], + "assertion": assertion, + "status": status, + "seconds": 5, + "resources": idle + if case["case_id"] == "FOUNDATIONS-RESOURCES" + and status == "PASS" + else None, + "checkpoints": [ + {"phase": phase, "resources": idle} + for phase in ["baseline", "after-stop", "quiet"] + ], + } + ) + if case["runner"] == "native-stream": + native.append( + { + "case_id": case["case_id"], + "status": "PASS", + "cleanup": {"joined": True}, + "command_exit_code": 0, + "rust_event": { + "server_first": True, + "payload_bytes": 65536, + "tail_bytes": 14, + "protect_calls": 1, + "driver_joined": True, + "resources_idle": True, + "resources": idle, + }, + "origin": { + "finished": True, + "accepted": 1, + "received": 65536, + "failed": False, + }, + } + ) + peers = [ + { + "kind": kind, + "status": "READY", + "version": "offline-fixture", + "source_url": "https://github.com/fixture/project/releases/latest/download/fixture", + "archive_sha256": "0" * 64, + "binary_sha256": "1" * 64, + "release": "fixture", + "container_status": "READY", + "container_artifact": {"binary_sha256": "2" * 64, "release": "fixture"}, + } + for kind in ["M", "V2"] + ] + commands = [ + "rust-foundations", + "native-probe-build", + "legacy-mihomo", + "offline-scripts", + "feature-default", + "feature-minimal", + "feature-outbound-trojan", + "feature-outbound-vmess", + "feature-outbound-hysteria2", + "feature-stream-transport", + "feature-quic-transport", + ] + return { + "run.json": { + "stage": "FOUNDATIONS", + "mode": "execute", + "source_unchanged": True, + "cleanup": True, + "selected_cases": [case["case_id"] for case in cases], + "source_tree_sha256": "0" * 64, + "lock_sha256": "1" * 64, + "dirty_patch_sha256": "2" * 64, + "commands": [ + {"name": name, "exit_code": 0, "cleanup": True} for name in commands + ], + }, + "cases.json": results, + "peers.json": peers, + "rust-events.jsonl": events, + "legacy-events.jsonl": legacy, + "resources.jsonl": [{"resources": idle}], + "summary.md": "Offline fixture only", + "native/stream-cases.json": { + "cases": native, + "cleanup": True, + "peers": peers, + "abnormal_cleanup": { + "passed": True, + "kind": "M", + "failure_injected": True, + "joined": True, + "port_rebound": True, + }, + }, + "script-tests.json": { + "tests_run": len(set(SCRIPT_OBSERVATIONS.values())), + "cases": [ + {"test": name, "status": "PASS"} + for name in sorted(set(SCRIPT_OBSERVATIONS.values())) + ], + }, + } + + def write_fixture(self, directory, data): + artifacts = [] + for name, value in data.items(): + if name == "run.json": + continue + path = directory / name + path.parent.mkdir(parents=True, exist_ok=True) + text = ( + "".join(json.dumps(item) + "\n" for item in value) + if name.endswith(".jsonl") + else value + if name.endswith(".md") + else json.dumps(value) + ) + path.write_text(text) + artifacts.append( + {"path": name, "sha256": hashlib.sha256(path.read_bytes()).hexdigest()} + ) + (directory / "run.json").write_text( + json.dumps(data["run.json"] | {"artifacts": artifacts}) + ) + + def test_complete_original_evidence_is_required_even_when_summary_claims_pass(self): + fixture = self.fixture() + with ( + tempfile.TemporaryDirectory() as temporary, + contextlib.redirect_stdout(io.StringIO()), + ): + directory = Path(temporary) + self.write_fixture(directory, fixture) + check_run(directory, "FOUNDATIONS") + for mutation in [ + "raw-event", + "nonidle", + "command", + "peer", + "artifact", + "quiet", + "script", + ]: + data = copy.deepcopy(fixture) + if mutation == "raw-event": + data["rust-events.jsonl"][1]["status"] = "FAIL" + elif mutation == "nonidle": + data["resources.jsonl"][0]["resources"]["counts"][0]["current"] = 1 + elif mutation == "command": + data["run.json"]["commands"][0]["exit_code"] = 124 + elif mutation == "peer": + data["peers.json"][0]["status"] = "BLOCKED" + elif mutation == "artifact": + del data["rust-events.jsonl"] + elif mutation == "quiet": + for event in data["rust-events.jsonl"]: + if event["suite"] == "FOUNDATIONS-RESOURCES": + event["checkpoints"] = [] + else: + data["script-tests.json"]["cases"][0]["status"] = "NOT RUN" + self.write_fixture(directory, data) + with self.subTest(mutation=mutation), self.assertRaises(ValueError): + check_run(directory, "FOUNDATIONS") + + def test_content_tampering_partial_runs_and_preflight_cannot_sign_off(self): + with tempfile.TemporaryDirectory() as temporary: + directory = Path(temporary) + data = self.fixture() + self.write_fixture(directory, data) + (directory / "summary.md").write_text("tampered") + with self.assertRaises(ValueError): + check_run(directory, "FOUNDATIONS") + for change in [ + {"selected_cases": []}, + {"mode": "preflight"}, + {"source_unchanged": False}, + ]: + data["run.json"].update(change) + self.write_fixture(directory, data) + with self.assertRaises(ValueError): + check_run(directory, "FOUNDATIONS") diff --git a/scripts/tests/test_protocol_security.py b/scripts/tests/test_protocol_security.py new file mode 100644 index 0000000..3f845da --- /dev/null +++ b/scripts/tests/test_protocol_security.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import copy +import json +import tempfile +import unittest +from pathlib import Path + +from vcore_scripts.protocol_evidence import load_manifest +from vcore_scripts.protocol_security_acceptance import ( + gate_result, + native_envelope, + rust_command, + vless_pass, +) +from vcore_scripts.protocol_security_catalog import FIELDS, definitions, groups + + +class SecurityAcceptanceTest(unittest.TestCase): + def test_legacy_ech_gateway_keeps_response_inside_tls(self): + from vcore_scripts.protocol_ech import legacy_gateway_command + + self.assertEqual( + legacy_gateway_command(), + [ + "env", + "XRAY_BUF_SPLICE=disable", + "/data/fixture/peer", + "run", + "-c", + "/data/fixture/config.json", + ], + ) + + def test_frozen_scope_and_compositions(self): + cases = definitions() + self.assertEqual( + [c for c in load_manifest() if c["stage"] == "SECURITY"], cases + ) + self.assertEqual(len(cases), 43) + self.assertEqual(set().union(*(set(c["row_ids"]) for c in cases)), FIELDS) + self.assertEqual(len(FIELDS), 11) + catalog = groups() + self.assertEqual(len(catalog["SECURITY-ECH-STANDARD"]["selected"]), 50) + for profile in ("chrome", "chrome120", "firefox", "safari"): + self.assertEqual( + catalog["SECURITY-ECH-" + profile.upper()]["client_fingerprint"], + profile, + ) + for security in ("ECH", "JLS", "HYBRID"): + self.assertEqual( + sum(k.startswith(f"SECURITY-{security}-ENCRYPTION-") for k in catalog), + 6, + ) + self.assertFalse( + any("shadow" in str(c).lower() or "restls" in str(c).lower() for c in cases) + ) + + def fixture(self, directory): + name, test = "SECURITY-ECH-TCP-TLS-REJECT", "native_ech_fail_closed" + group = dict(runner="vless", selected=[name], ech=True) + report = dict( + status="PASS", + cleanup=True, + source_unchanged=True, + source=dict( + parent_commit="7" * 40, + source_tree_sha256="a" * 64, + dirty_patch_sha256="b" * 64, + lock_sha256="c" * 64, + ), + ech=True, + jls=False, + encryption_profile=None, + client_fingerprint=None, + cases=[ + dict( + case_id=name, + command=rust_command("vless_native", test), + exit_code=0, + cleanup=True, + command_cleanup=True, + status="PASS", + peer_kind="M", + ) + ], + isolation=dict( + network_mode="hostOnly", + host_servers=False, + guest_mtu=1500, + image_digest="sha256:" + "d" * 64, + peers=[dict(started=True, joined=True)], + ), + peers={ + "M": dict( + version="synthetic test metadata", + binary_sha256="a" * 64, + archive_sha256="b" * 64, + source_url="https://github.com/MetaCubeX/mihomo/releases/latest/download/fixture", + ) + }, + ) + path = directory / (name + "-events.jsonl") + events = [ + dict(schema_version=1, suite="SECURITY-WIRE", assertion=test, status=s) + for s in ("BEGIN", "PASS") + ] + path.write_text("".join(json.dumps(e) + "\n" for e in events)) + return group, report, path, events + + def test_native_missing_duplicate_wrong_events_fail(self): + with tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + group, report, path, events = self.fixture(directory) + self.assertTrue(vless_pass(group, report, directory)) + for samples in ([], report["cases"] * 2): + invalid = dict(report, cases=samples) + self.assertFalse(vless_pass(group, invalid, directory)) + for samples in ( + events[:1], + events * 2, + [dict(e, suite="VLESS-WIRE") for e in events], + [dict(e, status="PASS") for e in events], + ): + path.write_text("".join(json.dumps(e) + "\n" for e in samples)) + self.assertFalse(vless_pass(group, report, directory)) + + def test_source_cleanup_and_identity_are_not_inferred(self): + with tempfile.TemporaryDirectory() as temp: + _, report, _, _ = self.fixture(Path(temp)) + self.assertTrue(native_envelope(report, report["peers"], report["source"])) + self.assertTrue(native_envelope(report, image_digest="sha256:" + "d" * 64)) + self.assertFalse(native_envelope(report, image_digest="sha256:" + "e" * 64)) + for field in ("cleanup", "source_unchanged"): + self.assertFalse(native_envelope(dict(report, **{field: False}))) + self.assertFalse(native_envelope(report, {}, report["source"])) + self.assertFalse(native_envelope(report, report["peers"], {})) + for mutate in ( + lambda r: r["isolation"].update(host_servers=True), + lambda r: r["isolation"].update(peers=[]), + lambda r: r["isolation"]["peers"][0].update(joined=False), + lambda r: r["peers"]["M"].update(binary_sha256=""), + ): + invalid = copy.deepcopy(report) + mutate(invalid) + self.assertFalse(native_envelope(invalid)) + + def test_close_reference_cannot_hide_a_different_official_profile(self): + with tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + group, report, _, _ = self.fixture(directory) + name, test = "SECURITY-ECH-GRPC-TLS-CLOSE", "native_mihomo_close_alignment" + group.update(selected=[name], client_fingerprint="safari") + report.update(client_fingerprint="safari") + report["cases"][0].update( + case_id=name, command=rust_command("vless_native", test) + ) + (directory / (name + "-events.jsonl")).write_text( + "".join( + json.dumps( + dict( + schema_version=1, + suite="SECURITY-WIRE", + assertion=test, + status=s, + ) + ) + + "\n" + for s in ("BEGIN", "PASS") + ) + ) + path = directory / "grpc-tls-tls-close-reference.json" + self.assertFalse(vless_pass(group, report, directory)) + valid = dict( + scope="ech-safari-chrome-baseline", + client_fingerprint="chrome", + dut_client_fingerprint="safari", + terminated=True, + tail_hex="", + ) + path.write_text(json.dumps(valid)) + self.assertTrue(vless_pass(group, report, directory)) + for field, value in ( + ("scope", "same-mode"), + ("client_fingerprint", "safari"), + ("dut_client_fingerprint", "chrome"), + ("terminated", False), + ): + path.write_text(json.dumps(dict(valid, **{field: value}))) + self.assertFalse(vless_pass(group, report, directory)) + + def test_empty_gate_and_missing_script_proof_fail(self): + cases = {c["case_id"]: c for c in definitions()} + for name in ("SECURITY-CFG", "SECURITY-RELEASE", "SECURITY-SCRIPTS"): + self.assertEqual(gate_result(cases[name], [], [])["status"], "FAIL") + record = [dict(exit_code=0, cleanup=True)] + self.assertEqual( + gate_result(cases["SECURITY-CFG"], record, [])["status"], "FAIL" + ) + self.assertEqual( + gate_result(cases["SECURITY-SCRIPTS"], record, [], {})["status"], "FAIL" + ) diff --git a/scripts/tests/test_protocol_trojan.py b/scripts/tests/test_protocol_trojan.py new file mode 100644 index 0000000..bc76938 --- /dev/null +++ b/scripts/tests/test_protocol_trojan.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import copy +import json +import tempfile +import tomllib +import unittest +from pathlib import Path + +from vcore_scripts.builds import CORE_DIR, DEFAULT_FEATURES +from vcore_scripts.protocol_evidence import load_manifest, select_cases +from vcore_scripts.protocol_trojan import CASES, native_events_pass +from vcore_scripts.protocol_trojan_acceptance import fields_report, native_results + + +class TrojanEvidenceTest(unittest.TestCase): + def test_removed_or_downgraded_required_case_is_rejected(self): + original = load_manifest() + index = next(i for i, case in enumerate(original) if case["stage"] == "TROJAN") + for downgrade in (True, False): + cases = copy.deepcopy(original) + if downgrade: + cases[index]["required"] = False + else: + cases.pop(index) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "cases.json" + path.write_text( + json.dumps( + { + "schema_version": 1, + "kind": "executable-case-manifest", + "cases": cases, + } + ) + ) + with self.assertRaises(ValueError): + load_manifest(path) + + def test_native_summary_cannot_hide_missing_events_or_unjoined_processes(self): + case = next( + case for case in load_manifest() if case["case_id"] == "TROJAN-M-TCP" + ) + record = { + "case_id": case["case_id"], + "status": "PASS", + "mode": "tcp", + "exit_code": 0, + "command_cleanup": True, + "cleanup": {"joined": True}, + } + report = {"source_unchanged": True, "cases": [record]} + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + self.assertEqual(native_results([case], report, root)[0]["status"], "FAIL") + events = [ + { + "suite": "TROJAN-NATIVE", + "assertion": "public_trojan_native_base", + "schema_version": 1, + "status": status, + } + for status in ("BEGIN", "PASS") + ] + (root / (case["case_id"] + "-events.jsonl")).write_text( + "".join(json.dumps(event) + "\n" for event in events) + ) + self.assertEqual(native_results([case], report, root)[0]["status"], "PASS") + for update in [ + {"exit_code": 124}, + {"command_cleanup": False}, + {"cleanup": {"joined": False}}, + {"status": "BLOCKED"}, + ]: + invalid = copy.deepcopy(report) + invalid["cases"][0].update(update) + self.assertEqual( + native_results([case], invalid, root)[0]["status"], "FAIL" + ) + report["source_unchanged"] = False + self.assertEqual(native_results([case], report, root)[0]["status"], "FAIL") + report["cases"].append(record) + with self.assertRaises(ValueError): + native_results([case], report, root) + + def test_partial_case_selection_never_signs_off_fields(self): + case = next(case for case in load_manifest() if case["case_id"] == "TROJAN-CFG") + result = fields_report([case], [{"case_id": case["case_id"], "status": "PASS"}]) + self.assertEqual(len(result["fields"]), 18) + self.assertTrue(all(field["status"] == "NOT RUN" for field in result["fields"])) + + def test_default_and_packaged_builds_enable_trojan(self): + features = tomllib.loads((CORE_DIR / "Cargo.toml").read_text())["features"] + self.assertIn("outbound-trojan", features["default"]) + self.assertIn("outbound-trojan", features["tun"]) + self.assertIn("outbound-trojan", DEFAULT_FEATURES.split(",")) + + def test_every_native_case_is_required_and_mapped_to_fields(self): + cases = select_cases(load_manifest(), stage="TROJAN") + native = { + case["case_id"]: case for case in cases if case["runner"] == "native-trojan" + } + self.assertEqual(set(native), set(CASES)) + for identifier, (kind, mode, test) in CASES.items(): + self.assertEqual(native[identifier]["peer_kind"], kind) + self.assertEqual( + native[identifier]["peer_config"], {"mode": mode, "test": test} + ) + self.assertTrue(native[identifier]["row_ids"]) + if kind != "M": + self.assertTrue(native[identifier]["gap_source"]) + + def test_zero_tests_partial_duplicate_wrong_or_failed_events_fail(self): + test = "public_trojan_native_base" + events = [ + { + "suite": "TROJAN-NATIVE", + "assertion": test, + "schema_version": 1, + "status": status, + } + for status in ("BEGIN", "PASS") + ] + self.assertTrue(native_events_pass(events, test)) + for invalid in [[], events[:1], events + events, events[::-1]]: + self.assertFalse(native_events_pass(invalid, test)) + for update in [ + {"status": "FAIL"}, + {"assertion": "other"}, + {"schema_version": 2}, + ]: + invalid = copy.deepcopy(events) + invalid[1].update(update) + self.assertFalse(native_events_pass(invalid, test)) + + def test_lifecycle_requires_twenty_full_quiet_cycles(self): + test = "runtime::public_trojan_native_lifecycle" + events = [ + { + "suite": "TROJAN-NATIVE", + "assertion": test, + "schema_version": 1, + "status": status, + } + for status in ("BEGIN", "PASS") + ] + cycles = [ + { + "suite": "TROJAN-LIFE-CYCLE", + "assertion": "stop_and_remain_quiet", + "schema_version": 1, + "status": status, + "seconds": 5.1 if status == "PASS" else 0, + } + for _ in range(20) + for status in ("BEGIN", "PASS") + ] + self.assertTrue(native_events_pass(events + cycles, test)) + self.assertFalse(native_events_pass(events + cycles[:-2], test)) + cycles[-1]["seconds"] = 4.9 + self.assertFalse(native_events_pass(events + cycles, test)) diff --git a/scripts/tests/test_protocol_vless.py b/scripts/tests/test_protocol_vless.py new file mode 100644 index 0000000..bace8de --- /dev/null +++ b/scripts/tests/test_protocol_vless.py @@ -0,0 +1,389 @@ +"""Offline evidence validation: no protocol listener or origin is launched.""" + +from __future__ import annotations + +import copy +import json +import tempfile +import tomllib +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from vcore_scripts.builds import CORE_DIR, DEFAULT_FEATURES +from vcore_scripts.protocol_evidence import RESOURCE_KINDS, load_manifest +from vcore_scripts.protocol_vless_acceptance import ( + FIELD_IDS, + NATIVE, + OBSERVATIONS, + REQUIRED_IDS, + definitions, + execute, + fields_report, + gate_result, + native_results, + required_command_names, +) +from vcore_scripts.protocol_vless_container import close_reference, run +from vcore_scripts.protocol_vless_peers import configuration +from vcore_scripts.protocol_vless_public import events_pass + + +def pair(suite, name): + return [ + dict(schema_version=1, suite=suite, assertion=name, status=status) + for status in ("BEGIN", "PASS") + ] + + +class VlessEvidenceTest(unittest.TestCase): + def test_fingerprint_runner_rejects_unknown_profile_or_plaintext_before_io(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "must-not-exist" + for profile, cases in [ + ("chrome133", ["VLESS-TCP-TLS-BASE"]), + ("chrome120", ["VLESS-TCP-BASE"]), + ]: + with self.assertRaises(ValueError): + run(output, cases, client_fingerprint=profile) + self.assertFalse(output.exists()) + + def test_official_ws_reference_preserves_vcore_default_and_explicit_ed_locations( + self, + ): + for specified in (None, "", "X-Synthetic-ED"): + options = {"max-early-data": 1, "v2ray-http-upgrade-fast-open": True} + if specified is not None: + options["early-data-header-name"] = specified + node = {"network": "ws", "ws-opts": options} + reference, scope = close_reference( + "upgrade-fast-tls", node, {}, Path("cert"), Path("key"), "pin" + ) + self.assertEqual(scope, "same-mode") + self.assertEqual( + reference["ws-opts"]["early-data-header-name"], + "Sec-WebSocket-Protocol" if specified is None else specified, + ) + self.assertEqual( + "early-data-header-name" in node["ws-opts"], specified is not None + ) + + def test_vision_flow_rejection_uses_plain_user_without_mutating_valid_peer(self): + for mode in ("vision-tls", "vision-reality"): + node, config = configuration( + mode, "server", "origin", Path("cert"), Path("key") + ) + self.assertEqual(node["flow"], "xtls-rprx-vision") + valid, plain = config["listeners"] + self.assertEqual(valid["users"][0]["flow"], "xtls-rprx-vision") + self.assertEqual(plain["users"][0]["flow"], "") + self.assertEqual((valid["port"], plain["port"]), (23000, 23004)) + self.assertEqual(valid.get("reality-config"), plain.get("reality-config")) + self.assertEqual(valid.get("certificate"), plain.get("certificate")) + + def test_ws_reality_close_reference_is_explicitly_layered_not_same_mode(self): + node = { + "network": "ws", + "tls": True, + "port": 23000, + "reality-opts": {"public-key": "synthetic"}, + } + config = { + "listeners": [{"port": 23000, "reality-config": {"dest": "synthetic"}}] + } + reference, scope = close_reference( + "ws-reality", node, config, Path("cert"), Path("key"), "pin" + ) + self.assertEqual(scope, "ws-standard-tls-baseline") + self.assertIn("reality-opts", node) + self.assertNotIn("reality-opts", reference) + self.assertEqual(reference["port"], 23003) + self.assertIn("reality-config", config["listeners"][0]) + self.assertNotIn("reality-config", config["listeners"][1]) + case = next( + c for c in definitions() if c["case_id"] == "VLESS-WS-REALITY-CLOSE" + ) + self.assertEqual(case["field_values"]["close_reference"], scope) + self.assertIn("not a same-combination", case["gap_source"]["reason"]) + + def test_jls_grpc_close_reference_labels_the_official_alpn_type_gap(self): + for profile in (None, "", "none", "chrome", "chrome120", "firefox", "safari"): + node = { + "network": "grpc", + "tls": True, + "jls-opts": {"username": "synthetic", "password": "synthetic"}, + } + if profile is not None: + node["client-fingerprint"] = profile + original = copy.deepcopy(node) + reference, scope = close_reference( + "grpc-tls", node, {}, Path("cert"), Path("key"), "pin" + ) + baseline = profile in (None, "", "none") + self.assertEqual( + scope, "jls-grpc-chrome-baseline" if baseline else "same-mode" + ) + self.assertEqual( + reference["client-fingerprint"], "chrome" if baseline else profile + ) + self.assertEqual(reference["jls-opts"], node["jls-opts"]) + self.assertEqual(node, original) + + for node in ({"network": "grpc"}, {"network": "tcp", "jls-opts": {}}): + reference, scope = close_reference( + "tcp-tls", node, {}, Path("cert"), Path("key"), "pin" + ) + self.assertEqual(scope, "same-mode") + self.assertNotIn("client-fingerprint", reference) + + def test_reality_close_reference_keeps_explicit_browser_profiles(self): + for profile in (None, "none", "chrome120", "firefox", "safari"): + node = {"network": "grpc"} + if profile is not None: + node["client-fingerprint"] = profile + reference, scope = close_reference( + "grpc-reality", node, {}, Path("cert"), Path("key"), "pin" + ) + self.assertEqual(scope, "same-mode") + self.assertEqual( + reference["client-fingerprint"], + "chrome" if profile in (None, "none") else profile, + ) + + def test_safari_ech_close_reference_labels_the_official_client_gap(self): + for profile in ("safari", "safari16", "chrome", "firefox", "none"): + for enabled in (False, True): + node = { + "network": "grpc", + "tls": True, + "client-fingerprint": profile, + "ech-opts": {"enable": enabled, "config": "synthetic"}, + } + original = copy.deepcopy(node) + reference, scope = close_reference( + "grpc-tls", node, {}, Path("cert"), Path("key"), "pin" + ) + baseline = enabled and profile in ("safari", "safari16") + self.assertEqual( + scope, "ech-safari-chrome-baseline" if baseline else "same-mode" + ) + self.assertEqual( + reference["client-fingerprint"], "chrome" if baseline else profile + ) + self.assertEqual(reference["ech-opts"], node["ech-opts"]) + self.assertEqual(node, original) + + def test_independent_features_match_the_executed_command_set(self): + case = next(c for c in definitions() if c["case_id"] == "VLESS-FEATURES") + with ( + tempfile.TemporaryDirectory() as root, + patch( + "vcore_scripts.protocol_harness._command", + return_value=SimpleNamespace(returncode=0, cleanup=True), + ) as command, + ): + execute([case], {"commands": []}, Path(root), {}) + actual = {call.args[2] for call in command.call_args_list} + expected = { + name + for name in required_command_names() + if name.startswith("feature-") or name == "production-build" + } + self.assertEqual(actual, expected) + + def test_required_manifest_cannot_drop_or_downgrade_behavior(self): + original = load_manifest() + index = next(i for i, case in enumerate(original) if case["stage"] == "VLESS") + for mutation in ("drop", "required", "observation", "row"): + cases = copy.deepcopy(original) + if mutation == "drop": + cases.pop(index) + elif mutation == "required": + cases[index]["required"] = False + elif mutation == "observation": + cases[index]["expected_observation"] = ["always-green"] + else: + cases[index]["row_ids"] = ["VL01"] + with tempfile.TemporaryDirectory() as root: + path = Path(root) / "cases.json" + path.write_text( + json.dumps( + dict( + schema_version=1, + kind="executable-case-manifest", + cases=cases, + ) + ) + ) + with self.assertRaises(ValueError): + load_manifest(path) + + def test_fields_require_complete_native_and_public_evidence(self): + cases = definitions() + self.assertEqual({c["case_id"] for c in cases}, REQUIRED_IDS) + self.assertEqual( + {c["case_id"] for c in cases if c["runner"] == "native-vless"}, set(NATIVE) + ) + results = [dict(case_id=c["case_id"], status="PASS") for c in cases] + fields = fields_report(cases, results)["fields"] + self.assertEqual({f["row_id"] for f in fields}, FIELD_IDS) + self.assertTrue(all(f["status"] == "PASS" for f in fields)) + for bad in ( + results[:-1], + results[:-1] + [dict(case_id=results[-1]["case_id"], status="NOT RUN")], + ): + self.assertTrue( + all( + f["status"] == "NOT RUN" + for f in fields_report(cases, bad)["fields"] + ) + ) + features = tomllib.loads((CORE_DIR / "Cargo.toml").read_text())["features"] + for enabled in ( + features["default"], + features["tun"], + DEFAULT_FEATURES.split(","), + ): + self.assertIn("outbound-vless", enabled) + + def test_zero_partial_duplicate_failed_or_unjoined_rust_gate_is_not_pass(self): + cases = {c["case_id"]: c for c in definitions()} + for identifier, names in OBSERVATIONS.items(): + case = cases[identifier] + events = [e for name in names for e in pair(identifier, name)] + commands = [dict(exit_code=0, cleanup=True)] + self.assertEqual(gate_result(case, commands, events)["status"], "PASS") + for bad in ( + [], + events[:-1], + events + events[:1], + events[:-1] + [events[-1] | {"status": "FAIL"}], + ): + self.assertEqual(gate_result(case, commands, bad)["status"], "FAIL") + for record in ( + dict(exit_code=124, cleanup=True), + dict(exit_code=0, cleanup=False), + ): + self.assertEqual(gate_result(case, [record], events)["status"], "FAIL") + + def test_native_summary_cannot_hide_absent_events_changed_source_or_cleanup(self): + case = next( + c for c in definitions() if c["case_id"] == "VLESS-VISION-TLS-INNER-TLS" + ) + test = case["peer_config"]["test"] + record = dict( + case_id=case["case_id"], + status="PASS", + exit_code=0, + command_cleanup=True, + cleanup=True, + peer_kind="M", + command=[ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "vless_native", + test, + "--", + "--ignored", + "--exact", + "--nocapture", + ], + ) + report = dict(cases=[record], source_unchanged=True, cleanup=True) + with tempfile.TemporaryDirectory() as root: + path = Path(root) + self.assertEqual(native_results([case], report, path)[0]["status"], "FAIL") + (path / (case["case_id"] + "-events.jsonl")).write_text( + "".join(json.dumps(e) + "\n" for e in pair("VLESS-WIRE", test)) + ) + self.assertEqual(native_results([case], report, path)[0]["status"], "PASS") + for key in ("cleanup", "source_unchanged"): + self.assertEqual( + native_results([case], report | {key: False}, path)[0]["status"], + "FAIL", + ) + for change in ( + dict(exit_code=124), + dict(command_cleanup=False), + dict(cleanup=False), + dict(command=[]), + dict(peer_kind="V2"), + ): + self.assertEqual( + native_results([case], report | {"cases": [record | change]}, path)[ + 0 + ]["status"], + "FAIL", + ) + with self.assertRaises(ValueError): + native_results([case], report | {"cases": [record, record]}, path) + + def test_public_base_requires_every_family_codec_and_body_option(self): + test = "public_base" + events = pair("VLESS-PUBLIC", test) + self.assertFalse(events_pass(events, test, "tcp")) + for name in ("tcp_10mib_both_directions", "udp_each_codec_and_family"): + events += [e for _ in range(3) for e in pair("VLESS-BASE", name)] + self.assertTrue(events_pass(events, test, "tcp")) + self.assertFalse(events_pass(events[:-2], test, "tcp")) + self.assertFalse(events_pass(events + events[-2:], test, "tcp")) + security = [ + dict(event, suite=event["suite"].replace("VLESS-", "SECURITY-")) + for event in events + ] + self.assertTrue(events_pass(security, test, "tcp", stage="SECURITY")) + self.assertFalse(events_pass(security[:-2], test, "tcp", stage="SECURITY")) + self.assertFalse(events_pass(security, test, "tcp")) + vision = ( + pair("VLESS-PUBLIC", test) + + [ + e + for _ in range(3) + for e in pair("VLESS-BASE", "tcp_10mib_both_directions") + ] + + pair("VLESS-BASE", "udp_each_codec_and_family") + ) + self.assertTrue(events_pass(vision, test, "vision-tls")) + self.assertFalse(events_pass(vision, test, "tcp")) + + def test_owned_resources_need_twenty_idle_stop_and_quiet_cycles(self): + snapshot = dict( + counts=[ + dict(kind=kind, current=0, peak=1) for kind in sorted(RESOURCE_KINDS) + ] + ) + test = "runtime::owned_resources" + events = pair("VLESS-PUBLIC", test) + for _ in range(20): + cycle = pair("VLESS-OWNED", "stop_and_remain_quiet") + cycle[-1].update( + seconds=5, + resources=snapshot, + checkpoints=[ + dict(phase=phase, resources=snapshot) + for phase in ("baseline", "after-stop", "quiet") + ], + ) + events += cycle + self.assertTrue(events_pass(events, test, "tcp")) + for change in ( + {"seconds": 4.9}, + {"checkpoints": []}, + {"resources": {}}, + {"status": "FAIL"}, + ): + self.assertFalse( + events_pass(events[:-1] + [events[-1] | change], test, "tcp") + ) + self.assertFalse(events_pass(events[:-2], test, "tcp")) + busy = copy.deepcopy(events) + busy[-1]["resources"]["counts"][0]["current"] = 1 + self.assertFalse(events_pass(busy, test, "tcp")) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_protocol_vmess.py b/scripts/tests/test_protocol_vmess.py new file mode 100644 index 0000000..c6a0c88 --- /dev/null +++ b/scripts/tests/test_protocol_vmess.py @@ -0,0 +1,214 @@ +"""Offline evidence validation: no protocol listener or origin is launched.""" + +from __future__ import annotations + +import copy +import json +import tempfile +import tomllib +import unittest +from pathlib import Path + +from vcore_scripts.builds import CORE_DIR, DEFAULT_FEATURES +from vcore_scripts.protocol_evidence import RESOURCE_KINDS, load_manifest +from vcore_scripts.protocol_vmess_acceptance import ( + FIELD_IDS, + NATIVE, + OBSERVATIONS, + REQUIRED_IDS, + definitions, + fields_report, + gate_result, + native_results, +) +from vcore_scripts.protocol_vmess_public import events_pass + + +def pair(suite, name): + return [ + dict(schema_version=1, suite=suite, assertion=name, status=status) + for status in ("BEGIN", "PASS") + ] + + +class VmessEvidenceTest(unittest.TestCase): + def test_required_manifest_cannot_drop_or_downgrade_behavior(self): + original = load_manifest() + index = next(i for i, case in enumerate(original) if case["stage"] == "VMESS") + for mutation in ("drop", "required", "observation", "row"): + cases = copy.deepcopy(original) + if mutation == "drop": + cases.pop(index) + elif mutation == "required": + cases[index]["required"] = False + elif mutation == "observation": + cases[index]["expected_observation"] = ["always-green"] + else: + cases[index]["row_ids"] = ["VM01"] + with tempfile.TemporaryDirectory() as root: + path = Path(root) / "cases.json" + path.write_text( + json.dumps( + dict( + schema_version=1, + kind="executable-case-manifest", + cases=cases, + ) + ) + ) + with self.assertRaises(ValueError): + load_manifest(path) + + def test_fields_require_complete_native_and_public_evidence(self): + cases = definitions() + self.assertEqual({c["case_id"] for c in cases}, REQUIRED_IDS) + self.assertEqual( + {c["case_id"] for c in cases if c["runner"] == "native-vmess"}, set(NATIVE) + ) + results = [dict(case_id=c["case_id"], status="PASS") for c in cases] + fields = fields_report(cases, results)["fields"] + self.assertEqual({f["row_id"] for f in fields}, FIELD_IDS) + self.assertTrue(all(f["status"] == "PASS" for f in fields)) + for bad in ( + results[:-1], + results[:-1] + [dict(case_id=results[-1]["case_id"], status="NOT RUN")], + ): + self.assertTrue( + all( + f["status"] == "NOT RUN" + for f in fields_report(cases, bad)["fields"] + ) + ) + features = tomllib.loads((CORE_DIR / "Cargo.toml").read_text())["features"] + for enabled in ( + features["default"], + features["tun"], + DEFAULT_FEATURES.split(","), + ): + self.assertIn("outbound-vmess", enabled) + + def test_zero_partial_duplicate_failed_or_unjoined_rust_gate_is_not_pass(self): + cases = {c["case_id"]: c for c in definitions()} + for identifier, names in OBSERVATIONS.items(): + case = cases[identifier] + events = [e for name in names for e in pair(identifier, name)] + commands = [dict(exit_code=0, cleanup=True)] + self.assertEqual(gate_result(case, commands, events)["status"], "PASS") + for bad in ( + [], + events[:-1], + events + events[:1], + events[:-1] + [events[-1] | {"status": "FAIL"}], + ): + self.assertEqual(gate_result(case, commands, bad)["status"], "FAIL") + for record in ( + dict(exit_code=124, cleanup=True), + dict(exit_code=0, cleanup=False), + ): + self.assertEqual(gate_result(case, [record], events)["status"], "FAIL") + + def test_native_summary_cannot_hide_absent_events_changed_source_or_cleanup(self): + case = next(c for c in definitions() if c["case_id"] == "VMESS-M-IDENTITY") + test = case["peer_config"]["test"] + record = dict( + case_id=case["case_id"], + status="PASS", + exit_code=0, + command_cleanup=True, + cleanup=True, + peer_kind="M", + command=[ + "cargo", + "test", + "--locked", + "--all-features", + "--test", + "vmess_native", + test, + "--", + "--ignored", + "--exact", + "--nocapture", + ], + ) + report = dict(cases=[record], source_unchanged=True, cleanup=True) + with tempfile.TemporaryDirectory() as root: + path = Path(root) + self.assertEqual(native_results([case], report, path)[0]["status"], "FAIL") + (path / (case["case_id"] + "-events.jsonl")).write_text( + "".join(json.dumps(e) + "\n" for e in pair("VMESS-WIRE", test)) + ) + self.assertEqual(native_results([case], report, path)[0]["status"], "PASS") + for key in ("cleanup", "source_unchanged"): + self.assertEqual( + native_results([case], report | {key: False}, path)[0]["status"], + "FAIL", + ) + for change in ( + dict(exit_code=124), + dict(command_cleanup=False), + dict(cleanup=False), + dict(command=[]), + dict(peer_kind="V2"), + ): + self.assertEqual( + native_results([case], report | {"cases": [record | change]}, path)[ + 0 + ]["status"], + "FAIL", + ) + with self.assertRaises(ValueError): + native_results([case], report | {"cases": [record, record]}, path) + + def test_public_base_requires_every_family_codec_and_body_option(self): + test = "public_base" + events = pair("VMESS-PUBLIC", test) + self.assertFalse(events_pass(events, test)) + for name in ("tcp_10mib_both_directions", "udp_each_codec_and_family"): + events += [e for _ in range(3) for e in pair("VMESS-BASE", name)] + self.assertTrue(events_pass(events, test)) + self.assertFalse(events_pass(events[:-2], test)) + self.assertFalse(events_pass(events + events[-2:], test)) + body = pair("VMESS-PUBLIC", "public_body_options") + [ + e + for _ in range(14) + for e in pair("VMESS-BODY", "config_controls_aead_body") + ] + self.assertTrue(events_pass(body, "public_body_options")) + self.assertFalse(events_pass(body[:-2], "public_body_options")) + + def test_owned_resources_need_twenty_idle_stop_and_quiet_cycles(self): + snapshot = dict( + counts=[ + dict(kind=kind, current=0, peak=1) for kind in sorted(RESOURCE_KINDS) + ] + ) + test = "runtime::owned_resources" + events = pair("VMESS-PUBLIC", test) + for _ in range(20): + cycle = pair("VMESS-OWNED", "stop_and_remain_quiet") + cycle[-1].update( + seconds=5, + resources=snapshot, + checkpoints=[ + dict(phase=phase, resources=snapshot) + for phase in ("baseline", "after-stop", "quiet") + ], + ) + events += cycle + self.assertTrue(events_pass(events, test)) + for change in ( + {"seconds": 4.9}, + {"checkpoints": []}, + {"resources": {}}, + {"status": "FAIL"}, + ): + self.assertFalse(events_pass(events[:-1] + [events[-1] | change], test)) + self.assertFalse(events_pass(events[:-2], test)) + busy = copy.deepcopy(events) + busy[-1]["resources"]["counts"][0]["current"] = 1 + self.assertFalse(events_pass(busy, test)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_protocol_vmess_udp_ab.py b/scripts/tests/test_protocol_vmess_udp_ab.py new file mode 100644 index 0000000..acb1a12 --- /dev/null +++ b/scripts/tests/test_protocol_vmess_udp_ab.py @@ -0,0 +1,30 @@ +"""Small offline checks for diagnostic evidence extraction, not interop.""" + +import tempfile +import unittest +from pathlib import Path + +from vcore_scripts.protocol_vmess_udp_ab import options, warning_summary + + +class UdpDiagnosticTests(unittest.TestCase): + def test_matched_body_options(self): + matrix = options(["raw", "xudp", "packetaddr"]) + self.assertEqual(len(matrix), 39) + self.assertEqual(sum(item["cipher"] == "none" for item in matrix), 3) + + def test_loopback_ports_include_domain_and_exclude_timestamp(self): + with tempfile.TemporaryDirectory() as root: + log = Path(root) / "peer.log" + log.write_text( + 'time="2026-09-23T10:27:13+08:00" level=warning ' + 'msg="[UDP] 127.0.0.1:51243 --> vcore-fixture.test:63254 ' + 'error: reject loopback connection to: vcore-fixture.test:63254"\n' + 'time="2026-09-23T10:27:14+08:00" level=warning ' + 'msg="[UDP] 127.0.0.1:51386 --> [::1]:64969 ' + 'error: reject loopback connection to: [::1]:64969"\n' + ) + self.assertEqual( + warning_summary(log)["loopback_rejections"], + [{"ports": [51243, 63254, 63254]}, {"ports": [51386, 64969, 64969]}], + ) diff --git a/scripts/tests/test_protocol_xhttp.py b/scripts/tests/test_protocol_xhttp.py new file mode 100644 index 0000000..473e805 --- /dev/null +++ b/scripts/tests/test_protocol_xhttp.py @@ -0,0 +1,105 @@ +"""Fail-closed XHTTP coverage and fixture checks; no host listeners.""" + +import copy +import unittest + +from vcore_scripts.protocol_xhttp_acceptance import ( + FIELD_IDS, + NATIVE, + OBSERVATIONS, + definitions, + gate_result, + native_results, +) +from vcore_scripts.protocol_xhttp_fields import events_pass, public_events_pass + + +def pair(suite, assertion): + return [ + dict(schema_version=1, suite=suite, assertion=assertion, status=s) + for s in ("BEGIN", "PASS") + ] + + +class XhttpAcceptanceTest(unittest.TestCase): + def test_frozen_catalog_has_all_rows_modes_and_mux_wires(self): + cases = definitions() + self.assertEqual(len(FIELD_IDS), 57) + self.assertEqual(len(cases), len({c["case_id"] for c in cases})) + self.assertEqual(FIELD_IDS, set().union(*(set(c["row_ids"]) for c in cases))) + for version in ("h1", "h2", "h1c", "h2c", "h1r", "h2r", "h3"): + for mode in ("stream-one", "stream-up", "packet-up"): + self.assertTrue( + any( + v == f"{version}-{mode}-headers" and test == "public_base" + for v, test in NATIVE.values() + ) + ) + for protocol in ("h2mux", "smux", "yamux"): + self.assertTrue( + any( + v.startswith(f"{version}-mux-{protocol}-") + and test == "public_base" + for v, test in NATIVE.values() + ) + ) + for version in ("h1r", "h2r"): + for field in ("public-key", "short-id"): + self.assertIn( + ( + f"{version}-reject-download-{field}", + "security::native_xhttp_security", + ), + NATIVE.values(), + ) + + def test_unknown_duplicate_wrong_suite_and_partial_events_fail(self): + observed = pair("XHTTP-XHTTP", "native_xhttp_request_fields") + self.assertTrue(events_pass(observed, "native_xhttp_request_fields")) + for bad in ( + [], + observed[:1], + observed * 2, + observed + pair("extra", "unknown"), + ): + self.assertFalse(events_pass(bad, "native_xhttp_request_fields")) + public = pair("XHTTP-PUBLIC", "runtime::public_graph") + self.assertTrue(public_events_pass(public, "runtime::public_graph")) + self.assertFalse( + public_events_pass( + public + pair("XHTTP-BASE", "unknown"), "runtime::public_graph" + ) + ) + bad = copy.deepcopy(public) + bad[1]["schema_version"] = 2 + self.assertFalse(public_events_pass(bad, "runtime::public_graph")) + + def test_unit_gate_requires_every_frozen_assertion_and_successful_command(self): + case = next(c for c in definitions() if c["case_id"] == "XHTTP-CFG") + events = sum( + (pair("XHTTP-UNIT", name) for name in OBSERVATIONS["XHTTP-CFG"]), [] + ) + records = [dict(exit_code=0, cleanup=True)] + self.assertEqual(gate_result(case, records, events)["status"], "PASS") + for bad in ([], events[:-1], events + events[:2]): + self.assertEqual(gate_result(case, records, bad)["status"], "FAIL") + self.assertEqual( + gate_result(case, [dict(exit_code=1, cleanup=True)], events)["status"], + "FAIL", + ) + + def test_native_missing_and_duplicate_records_are_not_success(self): + case = next(c for c in definitions() if c["case_id"] in NATIVE) + for records in ([], [dict(case_id=case["case_id"])] * 2): + with self.assertRaises(ValueError): + native_results([case], dict(cases=records), None) + self.assertEqual( + native_results([case], dict(cases=[]), None, allow_partial=True), [] + ) + for records in ([dict(case_id="unknown")], [dict(case_id=case["case_id"])] * 2): + with self.assertRaises(ValueError): + native_results([case], dict(cases=records), None, allow_partial=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_scripts.py b/scripts/tests/test_scripts.py index d959761..8c99ef4 100644 --- a/scripts/tests/test_scripts.py +++ b/scripts/tests/test_scripts.py @@ -1,6 +1,7 @@ from __future__ import annotations import copy +import hashlib import json import subprocess import sys @@ -11,14 +12,14 @@ import xml.etree.ElementTree as ET from contextlib import ExitStack from pathlib import Path from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch from vcore_scripts import builds, cli, mihomo from vcore_scripts.builds import EXPECTED_IDENTITY, _android_target, _require_identity from vcore_scripts.checks import ( + BORING_GIT_SOURCE, + BORING_REVISION, CRATES_IO_SOURCES, - RUSTLS_GIT_SOURCE_PREFIX, - SHADOWSOCKS_GIT_SOURCE, _shadowsocks_aws_lc_errors, _tls_dependency_errors, ) @@ -51,51 +52,30 @@ class ScriptTest(unittest.TestCase): ) self.assertNotIn("interop-test", features) - def test_mihomo_check_dispatches_and_missing_peer_is_not_success(self): - with patch("vcore_scripts.cli.run_mihomo_interop") as run: + def test_download_cli_dispatches_host_and_container_target(self): + with patch("vcore_scripts.cli.download_mihomo") as fetch: + self.assertEqual(cli.main(["download", "mihomo"]), 0) self.assertEqual( - cli.main(["check", "mihomo-interop", "--binary", "fixture-mihomo"]), 0 + cli.main(["download", "mihomo", "--target", "linux-arm64"]), 0 ) - run.assert_called_once_with(Path("fixture-mihomo")) - with ( - tempfile.TemporaryDirectory() as directory, - self.assertRaisesRegex(RuntimeError, "NOT RUN"), - ): - mihomo.run_mihomo_interop(Path(directory) / "missing") + self.assertEqual(fetch.call_args_list, [call(None), call("linux-arm64")]) - def test_extended_mihomo_dispatch_and_soak_bounds(self): - with patch("vcore_scripts.cli.run_mihomo_interop") as run: - self.assertEqual( - cli.main( - ["check", "mihomo-interop", "--extended", "--soak-seconds", "90"] - ), - 0, - ) - run.assert_called_once_with( - None, extended=True, soak_seconds=90, container_binary=None - ) - for extended, seconds in [(False, 1), (True, -1), (True, 7201)]: - with ( - self.subTest(extended=extended, seconds=seconds), - self.assertRaises(ValueError), - ): - mihomo.run_mihomo_interop(extended=extended, soak_seconds=seconds) - - def test_container_cli_dispatch(self): - with patch("vcore_scripts.cli.run_mihomo_interop") as run: + def test_legacy_demo_requires_explicit_config_and_source(self): + with patch("vcore_scripts.cli.run_demo") as run: self.assertEqual( cli.main( [ - "check", - "mihomo-interop", - "--container-binary", - "linux-mihomo", + "demo", + "windows-tun2socks", + "fixture.json", + "--xray-source", + "fixture-xray", ] ), 0, ) run.assert_called_once_with( - None, extended=False, soak_seconds=0, container_binary=Path("linux-mihomo") + Path("fixture.json"), xray_source=Path("fixture-xray") ) def test_container_bridge_ipv6_does_not_select_lan_or_utun(self): @@ -304,6 +284,57 @@ except RuntimeError as error: with self.assertRaisesRegex(RuntimeError, "unsupported Android Rust target"): _android_target("mips-linux-android", "24") + def test_android_build_packages_the_matching_ndk_cpp_runtime(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + toolchain = root / "ndk/toolchain" + (toolchain / "bin").mkdir(parents=True) + (toolchain / "bin/llvm-ar").touch() + targets = ("aarch64-linux-android", "x86_64-linux-android") + for target in targets: + abi, clang, _ = _android_target(target, "24") + (toolchain / "bin" / clang).touch() + (toolchain / "bin" / (clang + "++")).touch() + runtime = toolchain / "sysroot/usr/lib" / target / "libc++_shared.so" + runtime.parent.mkdir(parents=True) + runtime.write_bytes(abi.encode()) + artifact = root / "target" / target / "release/libvcore.so" + artifact.parent.mkdir(parents=True) + artifact.write_bytes(EXPECTED_IDENTITY) + with ( + patch.dict( + builds.os.environ, + {"ANDROID_NDK_HOME": str(root / "ndk")}, + clear=True, + ), + patch.object(builds, "CORE_DIR", root), + patch.object(builds, "_android_toolchain", return_value=toolchain), + patch.object(builds, "_require_targets"), + patch.object(builds, "_cargo_build") as cargo, + ): + builds.build_android() + self.assertEqual(cargo.call_count, 2) + for invocation, target in zip(cargo.call_args_list, targets, strict=True): + abi, clang, _ = _android_target(target, "24") + env = invocation.args[3] + self.assertEqual(env["VCORE_CMAKE_ANDROID_ABI"], abi) + self.assertEqual(env["VCORE_CMAKE_ANDROID_API"], "24") + self.assertEqual( + env[f"CMAKE_TOOLCHAIN_FILE_{target.replace('-', '_')}"], + str(root / "scripts/cmake/android.toolchain.cmake"), + ) + self.assertEqual( + env[f"CXX_{target.replace('-', '_')}"], + str(toolchain / "bin" / (clang + "++")), + ) + output = root / "dist/android" / abi + self.assertEqual( + (output / "libvcore.so").read_bytes(), EXPECTED_IDENTITY + ) + self.assertEqual( + (output / "libc++_shared.so").read_bytes(), abi.encode() + ) + def test_artifact_identity_check_reads_binary_directly(self): with tempfile.TemporaryDirectory() as directory: artifact = Path(directory) / "libvcore.a" @@ -355,9 +386,7 @@ except RuntimeError as error: root / "dist/windows/arm64/vcore-windows-artifacts.json" ).read_text() ) - expected_digest = ( - "bfb78d72918e79702c425f161b4889064a68981ce9f5e69da78166f4ad56e3e8" - ) + expected_digest = hashlib.sha256(_windows_pe(0xAA64)).hexdigest() self.assertEqual( manifest, { @@ -397,13 +426,13 @@ except RuntimeError as error: { "id": "rustls-id", "name": "rustls", - "version": "0.23.43", - "source": RUSTLS_GIT_SOURCE_PREFIX + "a" * 40, + "version": "0.23.45", + "source": registry, }, { "id": "tokio-rustls-id", "name": "tokio-rustls", - "version": "0.26.4", + "version": "0.26.5", "source": registry, }, { @@ -412,18 +441,138 @@ except RuntimeError as error: "version": "0.17.14", "source": registry, }, + *[ + dict(id=name, name=name, version="5.2.0", source=BORING_GIT_SOURCE) + for name in ("boring", "boring-sys", "tokio-boring") + ], + dict(id="hpke", name="hpke", version="0.14.1", source=registry), ], "resolve": { "nodes": [ { "id": "rustls-id", - "features": ["reality", "ring", "std", "tls12"], - } + "features": ["ring", "std", "tls12"], + "deps": [], + }, + { + "id": "boring", + "features": [ + "reality", + "client-fingerprint", + "shadow-tls-v3", + "jls", + ], + "deps": [{"pkg": "boring-sys"}], + }, + { + "id": "boring-sys", + "features": ["reality", "shadow-tls-v3", "jls"], + }, + { + "id": "tokio-boring", + "features": [], + "deps": [{"pkg": "boring"}, {"pkg": "boring-sys"}], + }, + { + "id": "hpke", + "features": ["alloc", "aes", "chacha", "x25519", "hkdfsha2"], + }, ] }, } self.assertEqual(_tls_dependency_errors(metadata), []) + for invalid_source in (None, BORING_GIT_SOURCE): + invalid = copy.deepcopy(metadata) + invalid["packages"][-1]["source"] = invalid_source + self.assertTrue(_tls_dependency_errors(invalid)) + for feature in ("alloc", "aes", "chacha", "x25519"): + invalid = copy.deepcopy(metadata) + invalid["resolve"]["nodes"][-1]["features"].remove(feature) + self.assertTrue(_tls_dependency_errors(invalid)) + + for index, old_version in [(0, "0.23.43"), (1, "0.26.4"), (3, "5.1.0")]: + with self.subTest(outdated_version=old_version): + outdated = copy.deepcopy(metadata) + outdated["packages"][index]["version"] = old_version + self.assertTrue(_tls_dependency_errors(outdated)) + + for source in [ + None, + registry, + BORING_GIT_SOURCE.rsplit("#", 1)[0] + "#" + "f" * 40, + BORING_GIT_SOURCE.replace("?branch=release", "?branch=main"), + BORING_GIT_SOURCE.replace("?branch=release", f"?rev={BORING_REVISION}"), + BORING_GIT_SOURCE.replace("OneXray/boring", "example/boring"), + ]: + for index in (3, 4, 5): + with self.subTest(boring_source=source, package=index): + invalid = copy.deepcopy(metadata) + invalid["packages"][index]["source"] = source + self.assertTrue(_tls_dependency_errors(invalid)) + + for required in ["reality", "client-fingerprint", "shadow-tls-v3", "jls"]: + with self.subTest(boring_feature=required): + invalid = copy.deepcopy(metadata) + invalid["resolve"]["nodes"][1]["features"].remove(required) + self.assertTrue(_tls_dependency_errors(invalid)) + + for missing in ["edge", "node", "package"]: + with self.subTest(boring_missing=missing): + invalid = copy.deepcopy(metadata) + if missing == "edge": + invalid["resolve"]["nodes"][1]["deps"] = [] + elif missing == "node": + invalid["resolve"]["nodes"].pop() + else: + invalid["packages"].pop() + self.assertTrue(_tls_dependency_errors(invalid)) + + for forbidden in ["reality", "aws_lc_rs", "fips"]: + invalid = copy.deepcopy(metadata) + invalid["resolve"]["nodes"][0]["features"].append(forbidden) + self.assertTrue(_tls_dependency_errors(invalid)) + + for forbidden in ("fips", "restls"): + for index in (1, 2, 3): + with self.subTest(native_feature=forbidden, node=index): + invalid = copy.deepcopy(metadata) + invalid["resolve"]["nodes"][index]["features"].append(forbidden) + self.assertTrue(_tls_dependency_errors(invalid)) + + for source in CRATES_IO_SOURCES: + with self.subTest(rustls_registry=source): + official = copy.deepcopy(metadata) + official["packages"][0]["source"] = source + self.assertEqual(_tls_dependency_errors(official), []) + + for source in ( + None, + "git+https://example.invalid/rustls?branch=custom#" + "a" * 40, + "git+https://github.com/rustls/rustls#" + "a" * 40, + "registry+https://example.invalid/index", + ): + with self.subTest(rustls_source=source): + invalid = copy.deepcopy(metadata) + invalid["packages"][0]["source"] = source + self.assertTrue( + any( + "rustls must come from crates.io" in error + for error in _tls_dependency_errors(invalid) + ) + ) + + duplicate = copy.deepcopy(metadata) + duplicate["packages"].append( + dict( + id="second-rustls", + name="rustls", + version="0.23.45", + source="git+https://github.com/rustls/rustls#" + "a" * 40, + ) + ) + self.assertTrue(_tls_dependency_errors(duplicate)) + metadata["packages"].append( { "id": "aws-id", @@ -442,7 +591,7 @@ except RuntimeError as error: metadata["packages"][0]["source"] = None self.assertTrue( any( - "vcore/reality-0.23 GitHub branch" in error + "rustls must come from crates.io" in error for error in _tls_dependency_errors(metadata) ) ) @@ -456,7 +605,7 @@ except RuntimeError as error: for name, version, source in zip( names, ["1.25.0", "0.8.0", "1.18.1", "0.45.0"], - [SHADOWSOCKS_GIT_SOURCE, registry, registry, registry], + [registry] * 4, strict=True, ) ], @@ -478,6 +627,21 @@ except RuntimeError as error: }, } self.assertEqual(_shadowsocks_aws_lc_errors(metadata), []) + for source in CRATES_IO_SOURCES: + with self.subTest(shadowsocks_registry=source): + official = copy.deepcopy(metadata) + official["packages"][0]["source"] = source + self.assertEqual(_shadowsocks_aws_lc_errors(official), []) + for source in ( + "git+https://github.com/shadowsocks/shadowsocks-rust.git?rev=" + "ab388c7466d21f979430e33cc9ef10e22fb05955#" + "ab388c7466d21f979430e33cc9ef10e22fb05955", + "registry+https://example.invalid/index", + ): + with self.subTest(shadowsocks_source=source): + invalid = copy.deepcopy(metadata) + invalid["packages"][0]["source"] = source + self.assertTrue(_shadowsocks_aws_lc_errors(invalid)) for index, feature in [(0, "aead-cipher-2022-extra"), (1, "v2-extra")]: invalid = copy.deepcopy(metadata) invalid["resolve"]["nodes"][index]["features"].append(feature) diff --git a/scripts/tests/test_selected_fingerprint_gates.py b/scripts/tests/test_selected_fingerprint_gates.py new file mode 100644 index 0000000..ab7c777 --- /dev/null +++ b/scripts/tests/test_selected_fingerprint_gates.py @@ -0,0 +1,119 @@ +"""Offline guardrails for selected-profile coverage and explicit differences.""" + +import copy +import json +import tempfile +import unittest +from pathlib import Path + +from vcore_scripts.builds import CORE_DIR +from vcore_scripts.protocol_fingerprint import CASES +from vcore_scripts.protocol_fingerprint_reference import _reference_shape +from vcore_scripts.protocol_fingerprint_shape import ( + check_captures, + check_warm_captures, + expected_shape, +) +from vcore_scripts.protocol_inputs import same_source +from vcore_scripts.protocol_xhttp_fields import ( + run, + selected_gateway_config, + selected_profile_variants, +) +from vcore_scripts.tls_client_hello import validate_capture + + +class SelectedFingerprintGatesTest(unittest.TestCase): + def test_staging_metadata_is_not_a_source_change(self): + source = dict( + parent_commit="parent", + source_tree_sha256="tree", + lock_sha256="lock", + dirty_patch_sha256="unstaged", + ) + self.assertTrue(same_source(source, dict(source, dirty_patch_sha256="staged"))) + for field in ("parent_commit", "source_tree_sha256", "lock_sha256"): + self.assertFalse(same_source(source, dict(source, **{field: "changed"}))) + + def test_partial_wire_and_warm_matrices_never_pass(self): + for check in (check_captures, check_warm_captures): + with self.assertRaises(ValueError): + check([]) + + def test_reality_does_not_inherit_standard_tls13_template_pruning(self): + samples = json.loads( + (CORE_DIR / "tests/fingerprints/mihomo-selected-v1.json").read_text() + )["samples"] + sample = next( + s + for s in samples + if s["profile"] == "firefox" and s["context"] == "reality" + ) + shape = expected_shape(sample, "reality") + self.assertEqual(shape["ciphers"], validate_capture(sample)["ciphers"]) + self.assertEqual(len(shape["ciphers"]), 17) + hello = copy.deepcopy(validate_capture(sample)) + psk = dict(type=41, bytes=50, identities=[7], binders=[32]) + hello["extensions"].append(psk) + hello["bytes"] += 54 + case = dict(template="Firefox120", sni="fingerprint.test", context="reality") + with self.assertRaises(ValueError): + _reference_shape(hello, case) + self.assertEqual( + _reference_shape(hello, case, allow_psk=True)["extensions"][-1], psk + ) + hello["extensions"].insert(0, hello["extensions"].pop()) + with self.assertRaises(ValueError): + _reference_shape(hello, case, allow_psk=True) + + def test_transport_catalog_includes_existing_native_and_upgrade_paths(self): + for name in ( + "VLESS-UPGRADE-TLS-BASE", + "VLESS-UPGRADE-FAST-TLS-BASE", + "VLESS-H2-TLS-BASE", + "VLESS-HTTP-TLS-BASE", + "VLESS-WS-ED-2048-TLS-BASE", + "FINGERPRINT-VMESS-H2-TLS", + "FINGERPRINT-VMESS-HTTP-TLS", + ): + self.assertIn(name, CASES) + self.assertEqual(len(CASES), len(set(CASES))) + + def test_download_crossings_are_explicit_and_h3_requires_clear(self): + cases = selected_profile_variants("chrome") + + def get(suffix): + return cases[f"h2-selected-download-{suffix}"][0]["download-settings"] + + self.assertNotIn("client-fingerprint", get("inherit")) + self.assertEqual(get("different")["client-fingerprint"], "firefox") + self.assertEqual(get("clear")["client-fingerprint"], "none") + h3 = cases["h3-selected-download-clear"][0] + self.assertEqual(h3["_main_alpn"], "h2") + self.assertEqual(h3["download-settings"]["client-fingerprint"], "none") + parent = CORE_DIR / "target/interop/runs" + parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=parent) as directory: + output = Path(directory) / "not-created" + for profile, selection in [ + ("chrome133", ["h2-auto"]), + ("chrome", ["h3-auto"]), + ("chrome", ["h2c-auto"]), + ("none", ["h2c-auto"]), + ]: + with self.assertRaises(ValueError): + run(output, selection, client_fingerprint=profile) + self.assertFalse(output.exists()) + + def test_split_h2_h3_uses_a_gateway_with_one_native_handler(self): + fixture = selected_gateway_config("192.0.2.1")["apps"]["http"]["servers"][ + "fixture" + ] + self.assertEqual(fixture["protocols"], ["h2", "h3"]) + handler = fixture["routes"][0]["handle"][0] + self.assertEqual(handler["upstreams"], [{"dial": "192.0.2.1:23000"}]) + self.assertEqual(handler["transport"]["versions"], ["h2c"]) + self.assertEqual( + fixture["tls_connection_policies"][0]["client_authentication"]["mode"], + "require_and_verify", + ) diff --git a/scripts/tests/test_tls_client_hello.py b/scripts/tests/test_tls_client_hello.py new file mode 100644 index 0000000..5fc8c88 --- /dev/null +++ b/scripts/tests/test_tls_client_hello.py @@ -0,0 +1,146 @@ +"""RFC-shaped, in-memory ClientHello observations; no sockets or peers.""" + +import copy +import json +import unittest + +from vcore_scripts.builds import CORE_DIR +from vcore_scripts.tls_client_hello import ( + compare_client_hellos, + parse_client_hello, + validate_capture, +) + +# RFC 8446 vectors with synthetic random bytes, in their encoded order. +HELLO = bytes.fromhex( + "010000590303" + + "00" * 32 + + "0000041301c02f0100002c" + + "000a00060004001d0017" + + "000d0006000404030804" + + "002b00050403040303" + + "0010000b000908687474702f312e31" +) + + +def extensions_hello(extensions): + body = HELLO[4:47] + len(extensions).to_bytes(2, "big") + extensions + return b"\x01" + len(body).to_bytes(3, "big") + body + + +class ClientHelloTest(unittest.TestCase): + def test_raw_official_fixture_detects_tampered_parsed_evidence(self): + samples = json.loads( + (CORE_DIR / "tests/fingerprints/mihomo-selected-v1.json").read_text() + )["samples"] + for sample in samples: + self.assertEqual(validate_capture(sample), sample["parsed"]) + corrupted = copy.deepcopy(samples[0]) + corrupted["parsed"]["ciphers"].reverse() + with self.assertRaises(ValueError): + validate_capture(corrupted) + + def test_preserves_ordered_tls_vectors(self): + hello = parse_client_hello(HELLO) + self.assertEqual(hello["ciphers"], [0x1301, 0xC02F]) + self.assertEqual( + hello["extensions"], + [ + {"type": 10, "bytes": 6, "values": [29, 23]}, + {"type": 13, "bytes": 6, "values": [0x0403, 0x0804]}, + {"type": 43, "bytes": 5, "values": [0x0304, 0x0303]}, + {"type": 16, "bytes": 11, "protocols": ["http/1.1"]}, + ], + ) + + def test_retains_key_share_and_ech_layout_without_random_key_bytes(self): + data = extensions_hello( + bytes.fromhex("0033002b00290a0a000100001d0020" + "11" * 32) + + bytes.fromhex("fe0d000f0000010001120004abababab0001ac") + ) + + hello = parse_client_hello(data) + self.assertEqual( + hello["extensions"], + [ + { + "type": 51, + "bytes": 43, + "shares": [ + {"group": 0x0A0A, "bytes": 1, "grease_data": "00"}, + {"group": 29, "bytes": 32}, + ], + }, + { + "type": 65037, + "bytes": 15, + "hello_type": 0, + "kdf": 1, + "aead": 1, + "enc_bytes": 4, + "payload_bytes": 1, + }, + ], + ) + + def test_comparison_ignores_random_bytes_but_not_order_or_lengths(self): + random_changed = bytearray(HELLO) + random_changed[6:38] = b"\x12" * 32 + self.assertEqual(compare_client_hellos(HELLO, bytes(random_changed)), []) + cipher_changed = bytearray(HELLO) + cipher_changed[41:45] = bytes.fromhex("c02f1301") + self.assertIn("ciphers", compare_client_hellos(HELLO, bytes(cipher_changed))) + reordered = extensions_hello(HELLO[59:69] + HELLO[49:59] + HELLO[69:]) + self.assertIn("extensions", compare_client_hellos(HELLO, reordered)) + self.assertEqual( + compare_client_hellos(HELLO, reordered, shuffle_extensions=True), [] + ) + + def test_reports_ticket_binder_sni_and_compression_structure(self): + hello = parse_client_hello( + extensions_hello( + bytes.fromhex("00000009000700000474657374") + + bytes.fromhex("001b00050400010002") + + bytes.fromhex("00290030000b0005616263646500000000002120" + "55" * 32) + ) + ) + self.assertEqual( + hello["extensions"], + [ + {"type": 0, "bytes": 9, "names": [{"type": 0, "name": "test"}]}, + {"type": 27, "bytes": 5, "values": [1, 2]}, + {"type": 41, "bytes": 48, "identities": [5], "binders": [32]}, + ], + ) + + def test_shuffle_keeps_grease_slots_and_share_group_relationships(self): + grease = bytes.fromhex("0a0a0000") + alpn = HELLO[78:] + first = extensions_hello(grease + alpn) + moved = extensions_hello(alpn + grease) + self.assertIn( + "extensions", + compare_client_hellos(first, moved, shuffle_extensions=True), + ) + group = bytes.fromhex("000a000400020a0a") + share = bytes.fromhex("0033000700050a0a000100") + linked = extensions_hello(group + share) + unlinked = extensions_hello(group + share.replace(b"\x0a\x0a", b"\x1a\x1a")) + self.assertIn( + "grease_share_group_links", compare_client_hellos(linked, unlinked) + ) + + def test_rejects_truncated_duplicate_and_oversized_envelopes(self): + malformed = [HELLO[:size] for size in range(len(HELLO))] + malformed += [ + HELLO + b"\x00", + b"\x00" * 65537, + extensions_hello(bytes.fromhex("0012000000120000")), + ] + for data in malformed: + with self.subTest(size=len(data)), self.assertRaises(ValueError): + parse_client_hello(data) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_tls_observer.py b/scripts/tests/test_tls_observer.py new file mode 100644 index 0000000..56dc340 --- /dev/null +++ b/scripts/tests/test_tls_observer.py @@ -0,0 +1,31 @@ +"""The observer is tested over injected byte streams, never host listeners.""" + +import io +import os +import unittest +from unittest.mock import patch + +from vcore_scripts.container_tls_observer import capture_client_hello, main + + +class TlsObserverTest(unittest.TestCase): + def test_observer_refuses_host_execution_before_any_listener(self): + with patch.dict(os.environ, {}, clear=True), self.assertRaises(RuntimeError): + main() + + def test_captures_fragmented_hello_and_retains_record_layout(self): + raw = bytes.fromhex("160301000301000016030300020100") + records, hello, layout = capture_client_hello(io.BytesIO(raw).read) + self.assertEqual(records, raw) + self.assertEqual(hello, bytes.fromhex("0100000100")) + self.assertEqual( + layout, + [ + {"type": 22, "version": 0x0301, "bytes": 3}, + {"type": 22, "version": 0x0303, "bytes": 2}, + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_xhttp_gateway.py b/scripts/tests/test_xhttp_gateway.py new file mode 100644 index 0000000..1529d2c --- /dev/null +++ b/scripts/tests/test_xhttp_gateway.py @@ -0,0 +1,79 @@ +"""Offline external fixture contracts; real peers are container-only.""" + +import json +import unittest + +from vcore_scripts.container_quic_observer import summarize_trace +from vcore_scripts.protocol_xhttp_gateway import gateway_config, sanitized_gateway_log + + +class XhttpGatewayTest(unittest.TestCase): + def test_gateway_requires_client_auth_and_only_forwards_to_one_native_handler(self): + config = gateway_config("192.0.2.10") + self.assertTrue(config["admin"]["disabled"]) + server = config["apps"]["http"]["servers"]["fixture"] + self.assertEqual(server["protocols"], ["h3"]) + self.assertEqual(server["listen"], [":23000"]) + self.assertTrue(server["automatic_https"]["disable"]) + auth = server["tls_connection_policies"][0]["client_authentication"] + self.assertEqual(auth["mode"], "require_and_verify") + self.assertEqual( + auth["ca"], + { + "provider": "file", + "pem_files": ["/data/fixture/root.pem"], + }, + ) + handler = server["routes"][0]["handle"][0] + self.assertEqual(handler["handler"], "reverse_proxy") + self.assertEqual(handler["upstreams"], [{"dial": "192.0.2.10:23000"}]) + self.assertEqual(handler["transport"]["versions"], ["h2c"]) + # Negative flush_interval disables upstream cancellation on disconnect. + self.assertNotIn("flush_interval", handler) + + def test_retained_gateway_log_has_protocol_evidence_without_request_identity(self): + retained = sanitized_gateway_log( + json.dumps( + { + "level": "warn", + "msg": "aborting with incomplete response", + "upstream": "192.0.2.10:23000", + "error": "context canceled", + "request": { + "remote_ip": "192.0.2.20", + "uri": "/private-session?token=secret", + "headers": {"Authorization": ["private-value"]}, + "proto": "HTTP/3.0", + "method": "GET", + "tls": {"proto": "h3", "client_common_name": "private-name"}, + }, + } + ) + ) + evidence = json.loads(retained) + self.assertEqual(evidence["http_version"], "HTTP/3.0") + self.assertEqual(evidence["alpn"], "h3") + self.assertTrue(evidence["client_certificate_present"]) + for forbidden in ("192.0.2", "private-", "secret", "Authorization"): + self.assertNotIn(forbidden, retained) + + def test_native_trace_evidence_requires_local_certificate_rejection(self): + trace = { + "name": "transport:connection_closed", + "data": { + "initiator": "local", + "connection_error": "crypto_error_0x12d", + # Native quic-go records local TLS alerts with an empty reason. + "reason": "", + }, + } + observed = summarize_trace("\x1e" + json.dumps(trace) + "\n") + self.assertEqual( + observed, + [{"tls_alert": "crypto_error_0x12d", "category": "certificate_expired"}], + ) + trace["data"]["initiator"] = "remote" + self.assertEqual(summarize_trace(json.dumps(trace) + "\n"), []) + trace["data"]["initiator"] = "local" + trace["data"]["connection_error"] = "idle_timeout" + self.assertEqual(summarize_trace(json.dumps(trace) + "\n"), []) diff --git a/scripts/tests/test_xhttp_peers.py b/scripts/tests/test_xhttp_peers.py new file mode 100644 index 0000000..0fd5399 --- /dev/null +++ b/scripts/tests/test_xhttp_peers.py @@ -0,0 +1,61 @@ +"""Offline fixture contracts; no host listener is used.""" + +import unittest + +from vcore_scripts.protocol_xhttp_peers import ( + client_config, + identity_peer_config, + peer_config, +) + + +class XhttpPeerTest(unittest.TestCase): + def test_identity_probe_distinguishes_client_auth_from_root_trust(self): + mihomo = identity_peer_config("M", "cert", "key", "ca") + listener = mihomo["listeners"][0] + self.assertEqual(listener["client-auth-type"], "require-and-verify") + self.assertEqual(listener["client-auth-cert"], "ca") + xray = identity_peer_config("XR", "cert", "key", "ca") + tls = xray["inbounds"][0]["streamSettings"]["tlsSettings"] + self.assertEqual( + tls["certificates"][-1], + { + "certificateFile": "ca", + "usage": "verify", + }, + ) + self.assertEqual(tls["alpn"], ["h3"]) + + def test_http_versions_are_explicit_and_download_uses_the_same_handler(self): + for version in ("h1", "h2", "h3"): + config = client_config( + "192.0.2.1", "pin", version, "packet-up", download=True + ) + node = config["proxies"][0] + self.assertEqual(node["alpn"], ["http/1.1" if version == "h1" else version]) + self.assertEqual(node["xhttp-opts"]["download-settings"], {}) + self.assertNotIn("skip-cert-verify", node) + self.assertEqual(config["listeners"][0]["proxy"], node["name"]) + + def test_mux_is_not_substituted_for_packet_encoding(self): + for protocol in ("h2mux", "smux", "yamux"): + config = client_config("192.0.2.1", "pin", "h2", "packet-up", mux=protocol) + node = config["proxies"][0] + self.assertEqual(node["smux"], {"enabled": True, "protocol": protocol}) + self.assertEqual(node["packet-encoding"], "xudp") + + def test_h3_bridge_does_not_replace_the_native_vless_decoder(self): + direct = peer_config("XR", "cert", "key") + bridge = peer_config("XR", "cert", "key", decoder="192.0.2.2") + self.assertEqual(direct["inbounds"][0]["protocol"], "vless") + self.assertEqual(bridge["inbounds"][0]["protocol"], "dokodemo-door") + self.assertEqual(bridge["inbounds"][0]["settings"]["port"], 23001) + self.assertEqual( + direct["inbounds"][0]["streamSettings"], + bridge["inbounds"][0]["streamSettings"], + ) + for config in (direct, bridge): + self.assertEqual(len(config["inbounds"]), 1) + stream = config["inbounds"][0]["streamSettings"] + self.assertEqual(stream["tlsSettings"]["alpn"], ["h3"]) + self.assertEqual(stream["xhttpSettings"]["mode"], "auto") diff --git a/src/config/ech.rs b/src/config/ech.rs new file mode 100644 index 0000000..7c93ab1 --- /dev/null +++ b/src/config/ech.rs @@ -0,0 +1,170 @@ +//! Static ECH configuration admission shared by both TLS backends. In +//! particular, BoringSSL otherwise silently skips unsupported configurations. +use super::*; + +#[derive(Clone, PartialEq, Eq)] +pub struct StaticEchConfig(Vec); + +impl std::fmt::Debug for StaticEchConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StaticEchConfig").finish_non_exhaustive() + } +} + +impl StaticEchConfig { + /// Keeps the first mutually supported config's exact wire encoding. No + /// backend can skip an earlier unsupported entry and expose the inner SNI. + pub fn from_config_list(bytes: &[u8]) -> Result { + if bytes.len() > 65_537 { + return invalid("ECH config list exceeds the wire bound"); + } + let mut reader = Reader(bytes); + let list = reader.vector16()?; + reader.end()?; + let mut configs = Reader(list); + let mut selected = None; + while !configs.0.is_empty() { + let start = configs.0; + let version = configs.u16()?; + let contents = configs.vector16()?; + if version == 0xfe0d && supported(contents)? && selected.is_none() { + selected = Some(&start[..4 + contents.len()]); + } + } + let selected = selected.ok_or_else(|| { + VCoreError::InvalidConfig("ECH has no compatible configuration".into()) + })?; + let mut wire = (selected.len() as u16).to_be_bytes().to_vec(); + wire.extend_from_slice(selected); + Ok(Self(wire)) + } + + #[cfg(feature = "tls-fingerprint")] + pub(crate) fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + +fn supported(bytes: &[u8]) -> Result { + let mut r = Reader(bytes); + r.take(1)?; // config_id + let kem = r.u16()?; + let key = r.vector16()?; + let suites = r.vector16()?; + if suites.is_empty() || !suites.len().is_multiple_of(4) { + return invalid("invalid ECH cipher suites"); + } + let compatible_suite = suites.as_chunks::<4>().0.iter().any(|suite| { + suite[..2] == [0, 1] && matches!(u16::from_be_bytes([suite[2], suite[3]]), 1..=3) + }); + r.take(1)?; // maximum_name_length + let name_len = usize::from(r.take(1)?[0]); + let name = r.take(name_len)?; + let mut extensions = Reader(r.vector16()?); + r.end()?; + let mut seen = std::collections::BTreeSet::new(); + let mut compatible_extensions = true; + while !extensions.0.is_empty() { + let kind = extensions.u16()?; + extensions.vector16()?; + compatible_extensions &= kind & 0x8000 == 0 && seen.insert(kind); + } + Ok(kem == 0x0020 + && key.len() == 32 + && compatible_suite + && compatible_extensions + && valid_public_name(name)) +} + +fn valid_public_name(name: &[u8]) -> bool { + // Match the stricter fixed BoringSSL LDH/IP-lookalike admission rules. + if name.is_empty() || name.len() > 253 { + return false; + } + if name.split(|b| *b == b'.').any(|label| { + label.is_empty() + || label.len() > 63 + || label.first() == Some(&b'-') + || label.last() == Some(&b'-') + || !label + .iter() + .all(|b| b.is_ascii_alphanumeric() || *b == b'-') + }) { + return false; + } + let last = name.rsplit(|b| *b == b'.').next().unwrap_or_default(); + !last.iter().all(u8::is_ascii_digit) + && !(last.len() >= 2 + && last[0] == b'0' + && matches!(last[1], b'x' | b'X') + && last[2..].iter().all(u8::is_ascii_hexdigit)) +} + +struct Reader<'a>(&'a [u8]); +impl<'a> Reader<'a> { + fn take(&mut self, n: usize) -> Result<&'a [u8]> { + let Some((head, tail)) = self.0.split_at_checked(n) else { + return invalid("invalid ECH config list"); + }; + self.0 = tail; + Ok(head) + } + fn u16(&mut self) -> Result { + let bytes = self.take(2)?; + Ok(u16::from_be_bytes([bytes[0], bytes[1]])) + } + fn vector16(&mut self) -> Result<&'a [u8]> { + let len = usize::from(self.u16()?); + self.take(len) + } + fn end(self) -> Result<()> { + if !self.0.is_empty() { + return invalid("invalid ECH config list"); + } + Ok(()) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawEch { + #[serde(default)] + enable: bool, + #[serde(default, deserialize_with = "deserialize_present_option")] + config: Option, +} + +impl std::fmt::Debug for RawEch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RawEch").finish_non_exhaustive() + } +} + +impl RawEch { + pub(super) fn normalize(self) -> Result> { + if !self.enable { + if self.config.as_ref().is_some_and(|value| !value.is_empty()) { + return invalid("ECH config requires enable=true"); + } + return Ok(None); + } + let encoded = self.config.ok_or_else(|| { + VCoreError::InvalidConfig("static ECH requires an explicit config".into()) + })?; + if encoded.len() > 87_384 { + return invalid("ECH config exceeds the wire bound"); + } + let wire = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|_| VCoreError::InvalidConfig("invalid ECH Base64 config".into()))?; + StaticEchConfig::from_config_list(&wire).map(Some) + } +} + +pub(super) fn deserialize<'de, D>(deserializer: D) -> std::result::Result, D::Error> +where + D: Deserializer<'de>, +{ + deserialize_present_map(deserializer) + .map_err(|_| serde::de::Error::custom("invalid static ECH options")) +} diff --git a/src/config/fingerprint_tests.rs b/src/config/fingerprint_tests.rs new file mode 100644 index 0000000..c916994 --- /dev/null +++ b/src/config/fingerprint_tests.rs @@ -0,0 +1,236 @@ +use super::*; + +fn yaml(protocol: &str, fields: &str) -> String { + let auth = match protocol { + "anytls" | "trojan" => "password: fixture", + "vmess" => { + "uuid: 00000000-0000-4000-8000-000000000001\n alterId: 0\n cipher: auto\n tls: true" + } + "vless" => "uuid: 00000000-0000-4000-8000-000000000001\n tls: true", + _ => unreachable!(), + }; + format!("port: 1080\nrules: [MATCH,p]\nproxies:\n - name: p\n type: {protocol}\n server: example.com\n port: 443\n {auth}\n {fields}\n").replace("rules: [MATCH,p]", "rules: ['MATCH,p']") +} + +fn profile(config: &Config) -> Option { + match &config.proxies[0].protocol { + ProxyProtocol::AnyTls(p) => p.tls.client_fingerprint, + ProxyProtocol::Trojan(p) => p.tls.client_fingerprint, + ProxyProtocol::Vmess(p) => p.tls.as_ref().unwrap().client_fingerprint, + ProxyProtocol::Vless(p) => match &p.security { + SecurityConfig::Tls(tls) => tls.client_fingerprint, + SecurityConfig::Reality(reality) => reality.client_fingerprint, + _ => panic!("expected TLS"), + }, + _ => unreachable!(), + } +} + +#[test] +fn named_fingerprint_is_strict_and_independent_of_certificate_pins() { + for protocol in ["anytls", "trojan", "vmess", "vless"] { + for (value, expected) in [ + ("chrome120", Some(ClientFingerprint::Chrome120)), + ("none", None), + ("\"\"", None), + ] { + let config = Config::parse_yaml( + yaml( + protocol, + &format!( + "client-fingerprint: {value}\n fingerprint: {}", + "aa".repeat(32) + ), + ) + .as_bytes(), + ) + .unwrap(); + assert_eq!(profile(&config), expected); + } + assert_eq!( + profile(&Config::parse_yaml(yaml(protocol, "").as_bytes()).unwrap()), + None + ); + for value in [ + "Chrome120", + "Chrome", + "NONE", + "chrome133", + "safari16.0", + "ios", + "android", + "edge", + "random", + "randomized", + "chrome_psk", + "chrome_pq", + "null", + "1", + "{}", + "[]", + "true", + "secret-unknown-profile", + ] { + let error = Config::parse_yaml( + yaml(protocol, &format!("client-fingerprint: {value}")).as_bytes(), + ) + .unwrap_err(); + assert!(!error.to_string().contains("secret-unknown-profile")); + } + } + for protocol in ["vless", "vmess"] { + for value in ["chrome", "firefox", "safari", "none", "\"\""] { + assert!( + Config::parse_yaml( + yaml(protocol, &format!("client-fingerprint: {value}")) + .replace("tls: true", "tls: false") + .as_bytes() + ) + .is_err() + ); + } + } +} + +#[test] +fn selected_names_resolve_to_four_distinct_templates_and_only_documented_aliases() { + for protocol in ["anytls", "trojan", "vmess", "vless"] { + let mut templates = Vec::new(); + for name in ["chrome", "chrome120", "firefox", "safari"] { + let config = Config::parse_yaml( + yaml(protocol, &format!("client-fingerprint: {name}")).as_bytes(), + ) + .unwrap(); + let selected = profile(&config).expect("enabled profile"); + assert!( + !templates.contains(&selected), + "{name} cannot reuse another template" + ); + templates.push(selected); + } + for (alias, template) in [("firefox120", templates[2]), ("safari16", templates[3])] { + let config = Config::parse_yaml( + yaml(protocol, &format!("client-fingerprint: {alias}")).as_bytes(), + ) + .unwrap(); + assert_eq!(profile(&config), Some(template)); + } + } +} + +#[test] +fn download_fingerprint_inherits_and_can_be_cleared_independently() { + for (name, template) in [ + ("chrome120", ClientFingerprint::Chrome120), + ("chrome", ClientFingerprint::Chrome133), + ("firefox", ClientFingerprint::Firefox120), + ("safari", ClientFingerprint::Safari16), + ] { + let base = yaml( + "vless", + &format!( + "network: xhttp\n client-fingerprint: {name}\n xhttp-opts:\n path: /fixture\n mode: stream-up\n download-settings:\n server: download.example.com" + ), + ); + for (extra, expected) in [ + ("", Some(template)), + ("\n client-fingerprint: \"\"", None), + ("\n client-fingerprint: none", None), + ( + "\n client-fingerprint: chrome", + Some(ClientFingerprint::Chrome133), + ), + ( + "\n client-fingerprint: chrome120", + Some(ClientFingerprint::Chrome120), + ), + ( + "\n client-fingerprint: firefox120", + Some(ClientFingerprint::Firefox120), + ), + ( + "\n client-fingerprint: safari16", + Some(ClientFingerprint::Safari16), + ), + ] { + let text = format!("{}{extra}\n", base.trim_end()); + let config = Config::parse_yaml(text.as_bytes()).unwrap(); + assert_eq!(profile(&config), Some(template)); + let ProxyProtocol::Vless(vless) = &config.proxies[0].protocol else { + unreachable!() + }; + let VlessTransport::Xhttp(xhttp) = &vless.transport else { + unreachable!() + }; + let SecurityConfig::Tls(tls) = &xhttp.download.as_ref().unwrap().security else { + unreachable!() + }; + assert_eq!(tls.client_fingerprint, expected); + } + assert!( + Config::parse_yaml( + yaml( + "vless", + &format!("network: xhttp\n alpn: [h3]\n client-fingerprint: {name}") + ) + .as_bytes() + ) + .is_err() + ); + assert!(Config::parse_yaml(format!("{} alpn: [h3]\n", base).as_bytes()).is_err()); + for clear in ["none", "\"\""] { + for security in ["alpn: [h3]", "tls: false"] { + assert!( + Config::parse_yaml( + format!("{base} {security}\n client-fingerprint: {clear}\n") + .as_bytes() + ) + .is_ok() + ); + } + } + for value in [ + "null", + "true", + "1", + "[]", + "{}", + "Chrome", + "chrome133", + "safari16.0", + ] { + assert!( + Config::parse_yaml( + format!("{base} client-fingerprint: {value}\n").as_bytes() + ) + .is_err() + ); + } + assert!(Config::parse_yaml(format!("{} tls: false\n", base).as_bytes()).is_err()); + } +} + +#[test] +fn reality_accepts_named_profile_without_admitting_certificate_policy() { + for (name, expected) in [ + ("chrome120", Some(ClientFingerprint::Chrome120)), + ("chrome", Some(ClientFingerprint::Chrome133)), + ("firefox", Some(ClientFingerprint::Firefox120)), + ("safari", Some(ClientFingerprint::Safari16)), + ("none", None), + ("\"\"", None), + ] { + let fields = format!( + "client-fingerprint: {name}\n reality-opts:\n public-key: {}", + URL_SAFE_NO_PAD.encode([7; 32]) + ); + let config = Config::parse_yaml(yaml("vless", &fields).as_bytes()).unwrap(); + assert_eq!(profile(&config), expected); + assert!( + Config::parse_yaml( + yaml("vless", &format!("skip-cert-verify: true\n {fields}")).as_bytes() + ) + .is_err() + ); + } +} diff --git a/src/config/hysteria2.rs b/src/config/hysteria2.rs new file mode 100644 index 0000000..b2bb6be --- /dev/null +++ b/src/config/hysteria2.rs @@ -0,0 +1,294 @@ +use serde::Deserialize; + +use super::{ + ProxyProtocol, TlsCertificatePolicy, TlsConfig, TlsIdentityPem, deserialize_present_option, + invalid, validate_host, validate_port, +}; +use crate::Result; + +#[derive(Clone, PartialEq, Eq)] +pub struct Hysteria2OutboundConfig { + pub address: String, + pub port: u16, + pub password: String, + pub tls: TlsConfig, + pub up: u64, + pub down: u64, + pub udp_mtu: u16, + pub obfs_password: Option, + pub hopping: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Hysteria2Hopping { + pub ports: Vec, + pub min_seconds: u32, + pub max_seconds: u32, +} + +impl std::fmt::Debug for Hysteria2OutboundConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Hysteria2OutboundConfig") + .finish_non_exhaustive() + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawHysteria2 { + name: String, + server: String, + #[serde(default, deserialize_with = "deserialize_present_option")] + port: Option, + #[serde(default)] + password: String, + #[serde(default)] + udp: bool, + #[serde(default, deserialize_with = "deserialize_present_option")] + sni: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + alpn: Option>, + #[serde(rename = "skip-cert-verify", default)] + skip_cert_verify: bool, + #[serde(default, deserialize_with = "deserialize_present_option")] + fingerprint: Option, + #[serde( + rename = "certificate", + default, + deserialize_with = "deserialize_present_option" + )] + certificate: Option, + #[serde( + rename = "private-key", + default, + deserialize_with = "deserialize_present_option" + )] + private_key: Option, + #[serde( + rename = "dialer-proxy", + default, + deserialize_with = "deserialize_present_option" + )] + dialer_proxy: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + up: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + down: Option, + #[serde(rename = "udp-mtu", default)] + udp_mtu: u16, + #[serde(default, deserialize_with = "deserialize_present_option")] + obfs: Option, + #[serde( + rename = "obfs-password", + default, + deserialize_with = "deserialize_present_option" + )] + obfs_password: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + ports: Option, + #[serde( + rename = "hop-interval", + default, + deserialize_with = "deserialize_present_option" + )] + hop_interval: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum NumberOrText { + Number(u64), + Text(String), +} + +impl std::fmt::Debug for RawHysteria2 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RawHysteria2").finish_non_exhaustive() + } +} + +impl RawHysteria2 { + pub(super) fn normalize(self) -> Result<(String, Option, bool, ProxyProtocol)> { + if !cfg!(feature = "outbound-hysteria2") { + return invalid("Hysteria2 outbound support is disabled at build time"); + } + validate_host(&self.server, "Hysteria2 server")?; + if let Some(port) = self.port { + validate_port(port, "Hysteria2")?; + } + let hopping = self + .ports + .map(|ports| { + let ports = parse_ports(&ports)?; + let (min_seconds, max_seconds) = parse_interval(self.hop_interval.as_ref())?; + Ok::<_, crate::VCoreError>(Hysteria2Hopping { + ports, + min_seconds, + max_seconds, + }) + }) + .transpose()?; + if hopping.is_none() && self.hop_interval.is_some() { + return invalid("Hysteria2 hop-interval requires ports"); + } + let port = match &hopping { + Some(hopping) => hopping.ports[0], + None => self.port.ok_or_else(|| { + crate::VCoreError::InvalidConfig("Hysteria2 requires port or ports".into()) + })?, + }; + // The complete auth header section is bounded to 16 KiB. Validate the + // raw value, without trimming credentials or printing them on failure. + if self.password.len() > 8192 + || http::HeaderValue::from_bytes(self.password.as_bytes()).is_err() + { + return invalid("invalid or over-limit Hysteria2 authentication header"); + } + let obfs_password = match (self.obfs.as_deref(), self.obfs_password) { + (None, None) => None, + (Some("salamander"), Some(password)) if !password.is_empty() => Some(password), + _ => return invalid("Hysteria2 Salamander requires obfs and a nonempty obfs-password"), + }; + let server_name = self.sni.unwrap_or_else(|| self.server.clone()); + validate_host(&server_name, "Hysteria2 sni")?; + let mut alpn = self.alpn.unwrap_or_default(); + if alpn.is_empty() { + alpn.push("h3".into()); + } + if alpn.iter().any(|item| item.is_empty() || item.len() > 255) + || alpn.iter().map(|item| item.len() + 1).sum::() > 65533 + { + return invalid("invalid Hysteria2 ALPN list"); + } + let identity = match (self.certificate, self.private_key) { + (None, None) => None, + (Some(certificate), Some(private_key)) => { + #[cfg(feature = "outbound-hysteria2")] + crate::security::TlsClientIdentity::from_pem(&certificate, &private_key).map_err( + |_| { + crate::VCoreError::InvalidConfig("invalid Hysteria2 client identity".into()) + }, + )?; + Some(TlsIdentityPem { + certificate, + private_key, + }) + } + _ => return invalid("Hysteria2 certificate and private-key must be paired"), + }; + let fingerprint = self + .fingerprint + .map(|pin| { + super::vless::parse_pin(&pin).map_err(|_| { + crate::VCoreError::InvalidConfig( + "invalid Hysteria2 certificate fingerprint".into(), + ) + }) + }) + .transpose()?; + let udp_mtu = if self.udp_mtu == 0 { + 1197 + } else { + self.udp_mtu + }; + if udp_mtu < 64 { + return invalid("Hysteria2 udp-mtu must be 0 or 64..65535"); + } + Ok(( + self.name, + self.dialer_proxy, + self.udp, + ProxyProtocol::Hysteria2(Hysteria2OutboundConfig { + address: self.server, + port, + password: self.password, + tls: TlsConfig { + ech: None, + client_fingerprint: None, + server_name, + alpn: alpn.into_iter().map(String::into_bytes).collect(), + tls13_only: true, + required_alpn: None, + certificate: TlsCertificatePolicy { + skip_cert_verify: self.skip_cert_verify, + fingerprint, + verification_name: None, + }, + identity, + }, + up: parse_rate(self.up)?, + down: parse_rate(self.down)?, + udp_mtu, + obfs_password, + hopping, + }), + )) + } +} + +fn parse_rate(raw: Option) -> Result { + let text = match raw { + None => return Ok(0), + Some(NumberOrText::Number(value)) => format!("{value}"), + Some(NumberOrText::Text(value)) => value.trim().to_owned(), + }; + let end = text.bytes().take_while(u8::is_ascii_digit).count(); + let value = text[..end] + .parse::() + .map_err(|_| crate::VCoreError::InvalidConfig("invalid Hysteria2 bandwidth".into()))?; + let unit = text[end..].trim(); + let (factor, divisor) = match unit { + "" | "Mbps" => (1_000_000_u64, 8), + "bps" => (1, 8), + "Kbps" => (1000, 8), + "Gbps" => (1_000_000_000, 8), + "Tbps" => (1_000_000_000_000, 8), + "Bps" => (1, 1), + "KBps" => (1000, 1), + "MBps" => (1_000_000, 1), + "GBps" => (1_000_000_000, 1), + "TBps" => (1_000_000_000_000, 1), + _ => return invalid("invalid Hysteria2 bandwidth unit"), + }; + value + .checked_mul(factor) + .map(|bytes| bytes / divisor) + .ok_or_else(|| crate::VCoreError::InvalidConfig("Hysteria2 bandwidth overflows".into())) +} + +fn decimal(value: &str) -> Result { + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return invalid("invalid Hysteria2 integer range"); + } + value + .parse() + .map_err(|_| crate::VCoreError::InvalidConfig("Hysteria2 integer range overflows".into())) +} + +fn parse_ports(value: &str) -> Result> { + let mut ports = std::collections::BTreeSet::new(); + for item in value.split(',') { + let item = item.trim(); + let (start, end) = item.split_once('-').unwrap_or((item, item)); + let (start, end) = (decimal(start)?, decimal(end)?); + if start == 0 || start > end || end > 65535 { + return invalid("invalid Hysteria2 ports"); + } + ports.extend((start..=end).map(|port| port as u16)); + } + Ok(ports.into_iter().collect()) +} + +fn parse_interval(value: Option<&NumberOrText>) -> Result<(u32, u32)> { + let text = match value { + None => return Ok((30, 30)), + Some(NumberOrText::Number(value)) => value.to_string(), + Some(NumberOrText::Text(value)) => value.clone(), + }; + let (start, end) = text.split_once('-').unwrap_or((&text, &text)); + let (start, end) = (decimal(start)?, decimal(end)?); + if start < 5 || start > end { + return invalid("Hysteria2 hop-interval requires 5 <= min <= max <= 4294967295 seconds"); + } + Ok((start, end)) +} diff --git a/src/config/jls.rs b/src/config/jls.rs new file mode 100644 index 0000000..a1867bd --- /dev/null +++ b/src/config/jls.rs @@ -0,0 +1,80 @@ +use super::*; + +/// JLS identity is independent of WebPKI; the native TLS handshake still verifies +/// CertificateVerify and Finished. Each transport leg owns its credentials. +#[derive(Clone, PartialEq, Eq)] +pub struct JlsConfig { + pub tls: TlsConfig, + pub username: String, + pub password: String, +} + +impl std::fmt::Debug for JlsConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("JlsConfig") + .field("tls", &self.tls) + .finish_non_exhaustive() + } +} + +impl JlsConfig { + pub(crate) fn validate(&self) -> Result<()> { + for value in [&self.username, &self.password] { + if value.is_empty() || value.len() > 65_535 { + return invalid("JLS credentials must each contain 1..65535 bytes"); + } + } + if self.tls.certificate != TlsCertificatePolicy::default() + || self.tls.identity.is_some() + || self.tls.ech.is_some() + { + return invalid("JLS cannot use standard certificate policy or client identity"); + } + Ok(()) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawJls { + #[serde(default, deserialize_with = "deserialize_present_option")] + username: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + password: Option, +} + +impl std::fmt::Debug for RawJls { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RawJls").finish_non_exhaustive() + } +} + +impl RawJls { + pub(super) fn is_empty(&self) -> bool { + self.username.is_none() && self.password.is_none() + } + + pub(super) fn normalize(self, tls: TlsConfig) -> Result { + let (Some(username), Some(password)) = (self.username, self.password) else { + return invalid("JLS requires both username and password"); + }; + let config = JlsConfig { + tls, + username, + password, + }; + config.validate()?; + Ok(config) + } +} + +pub(super) fn deserialize<'de, D>(deserializer: D) -> std::result::Result, D::Error> +where + D: Deserializer<'de>, +{ + // Serde's type/unknown-field errors can echo a supplied numeric secret or + // an entire malformed credential object. Keep the strict map decoder but + // do not expose its input-bearing error through Config or Invoke. + deserialize_present_map(deserializer) + .map_err(|_| serde::de::Error::custom("invalid JLS credential object")) +} diff --git a/src/config/measure.rs b/src/config/measure.rs index c90616a..e7bc54a 100644 --- a/src/config/measure.rs +++ b/src/config/measure.rs @@ -101,6 +101,29 @@ impl RawMeasureConfig { #[cfg(test)] mod tests { + #[test] + fn measurement_rejects_future_protocols_and_oversized_documents() { + let _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-SCHEMA", + "measurement_rejects_future_protocols_and_oversized_documents", + ); + let yaml = + b"proxies:\n - name: node\n type: wireguard\n server: example.com\n port: 443\n"; + assert!(super::MeasureConfig::parse_yaml(yaml).is_err()); + assert!( + super::MeasureConfig::parse_yaml(&vec![b'x'; super::MAX_CONFIG_BYTES + 1]).is_err() + ); + } + + #[test] + fn measurement_hysteria2_admission_matches_the_protocol_feature() { + let yaml = + b"proxies:\n - name: node\n type: hysteria2\n server: example.com\n port: 443\n"; + assert_eq!( + super::MeasureConfig::parse_yaml(yaml).is_ok(), + cfg!(feature = "outbound-hysteria2") + ); + } use super::*; const NODE: &str = r#" diff --git a/src/config/mod.rs b/src/config/mod.rs index 4239b85..bde6130 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -19,8 +19,23 @@ use crate::{Result, VCoreError}; mod credentials; pub use credentials::{ProxyAccess, ProxyCredentials}; +mod ech; +pub use ech::StaticEchConfig; +mod hysteria2; +pub use hysteria2::{Hysteria2Hopping, Hysteria2OutboundConfig}; +mod jls; +pub use jls::JlsConfig; mod shadowsocks; pub use shadowsocks::{ShadowsocksCipher, ShadowsocksOutboundConfig}; +mod trojan; +pub use trojan::{TrojanOutboundConfig, TrojanTransport}; +mod vless; +pub use vless::{GrpcOptions, SingMuxConfig, SingMuxProtocol, VlessStreamOptions}; +pub(crate) mod xhttp; +pub use xhttp::{XHttpHeaders, XHttpRequestOptions, XHttpReuseConfig, XHttpVersion}; +mod vmess; +pub use vmess::StreamTransport as VmessTransport; +pub use vmess::{StreamTransport, VmessCipher, VmessOutboundConfig, VmessPacketEncoding}; #[cfg(feature = "ffi")] mod measure; @@ -286,6 +301,9 @@ pub struct ProxyConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProxyProtocol { + Hysteria2(Hysteria2OutboundConfig), + Vmess(VmessOutboundConfig), + Trojan(TrojanOutboundConfig), Vless(VlessOutboundConfig), Socks5(Socks5OutboundConfig), AnyTls(AnyTlsOutboundConfig), @@ -296,6 +314,9 @@ impl ProxyConfig { #[must_use] pub fn address(&self) -> &str { match &self.protocol { + ProxyProtocol::Hysteria2(config) => &config.address, + ProxyProtocol::Vmess(config) => &config.address, + ProxyProtocol::Trojan(config) => &config.address, ProxyProtocol::Vless(config) => &config.address, ProxyProtocol::Socks5(config) => &config.address, ProxyProtocol::AnyTls(config) => &config.address, @@ -306,6 +327,9 @@ impl ProxyConfig { #[must_use] pub const fn port(&self) -> u16 { match &self.protocol { + ProxyProtocol::Hysteria2(config) => config.port, + ProxyProtocol::Vmess(config) => config.port, + ProxyProtocol::Trojan(config) => config.port, ProxyProtocol::Vless(config) => config.port, ProxyProtocol::Socks5(config) => config.port, ProxyProtocol::AnyTls(config) => config.port, @@ -314,7 +338,7 @@ impl ProxyConfig { } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct VlessOutboundConfig { pub address: String, pub port: u16, @@ -322,7 +346,45 @@ pub struct VlessOutboundConfig { pub encryption: VlessEncryption, pub flow: String, pub security: SecurityConfig, - pub xhttp: XHttpConfig, + pub transport: VlessTransport, + pub packet_encoding: VlessPacketEncoding, + pub stream_options: VlessStreamOptions, +} + +impl std::fmt::Debug for VlessOutboundConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VlessOutboundConfig") + .finish_non_exhaustive() + } +} + +impl VlessOutboundConfig { + pub fn xhttp(&self) -> Option<&XHttpConfig> { + match &self.transport { + VlessTransport::Xhttp(config) => Some(config), + VlessTransport::Stream(_) => None, + } + } + pub fn download(&self) -> Option<&XHttpDownloadConfig> { + self.xhttp().and_then(|config| config.download.as_deref()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum VlessTransport { + Stream(StreamTransport), + Xhttp(Box), +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] +pub enum VlessPacketEncoding { + #[default] + #[serde(rename = "xudp")] + Xudp, + #[serde(rename = "none")] + Raw, + #[serde(rename = "packetaddr", alias = "packet")] + PacketAddr, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -344,11 +406,44 @@ pub struct AnyTlsOutboundConfig { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AnyTlsCertificatePolicy { + pub client_fingerprint: Option, pub alpn: Vec>, pub skip_cert_verify: bool, pub fingerprint: Option<[u8; 32]>, } +/// A version-pinned ClientHello profile, independent of certificate pins. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientFingerprint { + Chrome120, + Chrome133, + Firefox120, + Safari16, +} + +fn parse_client_fingerprint(value: Option<&str>) -> Result> { + match value { + None | Some("" | "none") => Ok(None), + Some(_) if !cfg!(feature = "tls-fingerprint") => { + invalid("client-fingerprint is not compiled in") + } + Some("chrome") => Ok(Some(ClientFingerprint::Chrome133)), + Some("chrome120") => Ok(Some(ClientFingerprint::Chrome120)), + Some("firefox" | "firefox120") => Ok(Some(ClientFingerprint::Firefox120)), + Some("safari" | "safari16") => Ok(Some(ClientFingerprint::Safari16)), + Some(_) => invalid("unsupported client-fingerprint"), + } +} + +#[cfg(all( + test, + feature = "outbound-anytls", + feature = "outbound-trojan", + feature = "outbound-vmess", + feature = "outbound-vless" +))] +mod fingerprint_tests; + impl std::fmt::Debug for AnyTlsOutboundConfig { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter @@ -360,37 +455,130 @@ impl std::fmt::Debug for AnyTlsOutboundConfig { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub enum VlessEncryption { None, + MlKem768X25519Plus(String), +} +impl std::fmt::Debug for VlessEncryption { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::None => "None", + Self::MlKem768X25519Plus(_) => "MlKem768X25519Plus { .. }", + }) + } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum SecurityConfig { + None, Tls(TlsConfig), Reality(RealityConfig), + Jls(JlsConfig), } impl SecurityConfig { + fn client_fingerprint(&self) -> Option { + match self { + Self::None => None, + Self::Tls(config) => config.client_fingerprint, + Self::Reality(config) => config.client_fingerprint, + Self::Jls(config) => config.tls.client_fingerprint, + } + } #[must_use] pub fn server_name(&self) -> &str { match self { + Self::None => "", Self::Tls(config) => &config.server_name, Self::Reality(config) => &config.server_name, + Self::Jls(config) => &config.tls.server_name, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct TlsConfig { + pub ech: Option, + pub client_fingerprint: Option, pub server_name: String, + pub alpn: Vec>, + pub tls13_only: bool, + pub required_alpn: Option>, + pub certificate: TlsCertificatePolicy, + pub identity: Option, +} + +/// Data-only certificate policy, also available in builds without TLS IO. +/// A matching leaf pin is the trust decision; a non-leaf pin still verifies +/// the chain and name. Explicit pin/name checks precede `skip_cert_verify`. +#[derive(Clone, Default, PartialEq, Eq)] +pub struct TlsCertificatePolicy { + pub verification_name: Option, + pub skip_cert_verify: bool, + pub fingerprint: Option<[u8; 32]>, +} + +impl std::fmt::Debug for TlsCertificatePolicy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TlsCertificatePolicy") + .field("explicit_name", &self.verification_name.is_some()) + .field("skip_cert_verify", &self.skip_cert_verify) + .field("pinned", &self.fingerprint.is_some()) + .finish() + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct TlsIdentityPem { + pub certificate: String, + pub private_key: String, +} +impl std::fmt::Debug for TlsIdentityPem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TlsIdentityPem").finish_non_exhaustive() + } +} + +impl TlsConfig { + pub fn xhttp(server_name: String) -> Self { + Self { + ech: None, + client_fingerprint: None, + server_name, + alpn: vec![b"h2".to_vec()], + tls13_only: true, + required_alpn: Some(b"h2".to_vec()), + certificate: Default::default(), + identity: None, + } + } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RealityConfig { + pub client_fingerprint: Option, + pub support_x25519mlkem768: bool, pub server_name: String, pub public_key: [u8; 32], pub short_id: Vec, + pub alpn: Vec>, +} + +impl RealityConfig { + pub(crate) fn validate_fingerprint(&self) -> Result<()> { + if self.support_x25519mlkem768 + && !matches!( + self.client_fingerprint, + None | Some(ClientFingerprint::Chrome133) + ) + { + return invalid( + "hybrid REALITY requires client-fingerprint chrome or no named fingerprint", + ); + } + Ok(()) + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -398,6 +586,10 @@ pub struct XHttpConfig { pub path: String, pub host: String, pub mode: XHttpMode, + pub http_version: XHttpVersion, + pub headers: XHttpHeaders, + pub request: std::sync::Arc, + pub reuse: Option>, pub download: Option>, } @@ -406,8 +598,12 @@ pub struct XHttpDownloadConfig { pub address: String, pub port: u16, pub security: SecurityConfig, + pub http_version: XHttpVersion, pub path: String, pub host: String, + pub headers: XHttpHeaders, + pub request: std::sync::Arc, + pub reuse: Option>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -667,6 +863,12 @@ where // protocol fields inline avoids extra heap allocations in the startup path. #[allow(clippy::large_enum_variant)] enum RawOutbound { + #[serde(rename = "hysteria2")] + Hysteria2(hysteria2::RawHysteria2), + #[serde(rename = "vmess")] + Vmess(vmess::RawVmess), + #[serde(rename = "trojan")] + Trojan(trojan::RawTrojan), #[serde(rename = "ss")] Shadowsocks { name: String, @@ -684,42 +886,7 @@ enum RawOutbound { dialer_proxy: Option, }, #[serde(rename = "vless")] - Vless { - name: String, - server: String, - port: u16, - uuid: String, - #[serde(default)] - udp: bool, - tls: bool, - network: String, - #[serde(default)] - encryption: String, - #[serde(default)] - flow: String, - #[serde(default, deserialize_with = "deserialize_present_option")] - servername: Option, - #[serde(default, deserialize_with = "deserialize_present_option")] - alpn: Option>, - #[serde( - rename = "dialer-proxy", - default, - deserialize_with = "deserialize_present_option" - )] - dialer_proxy: Option, - #[serde( - rename = "reality-opts", - default, - deserialize_with = "deserialize_present_option" - )] - reality_opts: Option, - #[serde( - rename = "xhttp-opts", - default, - deserialize_with = "deserialize_present_option" - )] - xhttp_opts: Option, - }, + Vless(vless::RawVless), #[serde(rename = "socks5")] Socks5 { name: String, @@ -756,6 +923,12 @@ enum RawOutbound { skip_cert_verify: bool, #[serde(default, deserialize_with = "deserialize_present_option")] fingerprint: Option, + #[serde( + rename = "client-fingerprint", + default, + deserialize_with = "deserialize_present_option" + )] + client_fingerprint: Option, #[serde( rename = "dialer-proxy", default, @@ -768,16 +941,130 @@ enum RawOutbound { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawXHttpSettings { + #[serde( + rename = "reuse-settings", + default, + deserialize_with = "deserialize_present_map" + )] + reuse_settings: Option, #[serde(default, deserialize_with = "deserialize_present_option")] host: Option, #[serde(default = "default_path")] path: String, #[serde(default = "default_xhttp_mode")] mode: String, + #[serde(default)] + headers: std::collections::BTreeMap, + #[serde(rename = "no-grpc-header", default)] + no_grpc_header: bool, + #[serde( + rename = "x-padding-bytes", + default, + deserialize_with = "deserialize_present_option" + )] + x_padding_bytes: Option, + #[serde(rename = "x-padding-obfs-mode", default)] + x_padding_obfs_mode: bool, + #[serde( + rename = "x-padding-key", + default, + deserialize_with = "deserialize_present_option" + )] + x_padding_key: Option, + #[serde( + rename = "x-padding-header", + default, + deserialize_with = "deserialize_present_option" + )] + x_padding_header: Option, + #[serde( + rename = "x-padding-placement", + default, + deserialize_with = "deserialize_present_option" + )] + x_padding_placement: Option, + #[serde( + rename = "x-padding-method", + default, + deserialize_with = "deserialize_present_option" + )] + x_padding_method: Option, + #[serde( + rename = "uplink-http-method", + default, + deserialize_with = "deserialize_present_option" + )] + uplink_http_method: Option, + #[serde( + rename = "session-placement", + default, + deserialize_with = "deserialize_present_option" + )] + session_placement: Option, + #[serde( + rename = "session-key", + default, + deserialize_with = "deserialize_present_option" + )] + session_key: Option, + #[serde( + rename = "session-table", + default, + deserialize_with = "deserialize_present_option" + )] + session_table: Option, + #[serde( + rename = "session-length", + default, + deserialize_with = "deserialize_present_option" + )] + session_length: Option, + #[serde( + rename = "seq-placement", + default, + deserialize_with = "deserialize_present_option" + )] + seq_placement: Option, + #[serde( + rename = "seq-key", + default, + deserialize_with = "deserialize_present_option" + )] + seq_key: Option, + #[serde( + rename = "uplink-data-placement", + default, + deserialize_with = "deserialize_present_option" + )] + uplink_data_placement: Option, + #[serde( + rename = "uplink-data-key", + default, + deserialize_with = "deserialize_present_option" + )] + uplink_data_key: Option, + #[serde( + rename = "uplink-chunk-size", + default, + deserialize_with = "deserialize_present_option" + )] + uplink_chunk_size: Option, + #[serde( + rename = "sc-max-each-post-bytes", + default, + deserialize_with = "deserialize_present_option" + )] + sc_max_each_post_bytes: Option, + #[serde( + rename = "sc-min-posts-interval-ms", + default, + deserialize_with = "deserialize_present_option" + )] + sc_min_posts_interval_ms: Option, #[serde( rename = "download-settings", default, - deserialize_with = "deserialize_present_option" + deserialize_with = "deserialize_present_map" )] download_settings: Option, } @@ -785,9 +1072,30 @@ struct RawXHttpSettings { impl Default for RawXHttpSettings { fn default() -> Self { Self { + reuse_settings: None, host: None, path: default_path(), mode: default_xhttp_mode(), + headers: Default::default(), + no_grpc_header: false, + x_padding_bytes: None, + x_padding_obfs_mode: false, + x_padding_key: None, + x_padding_header: None, + x_padding_placement: None, + x_padding_method: None, + uplink_http_method: None, + session_placement: None, + session_key: None, + session_table: None, + session_length: None, + seq_placement: None, + seq_key: None, + uplink_data_placement: None, + uplink_data_key: None, + uplink_chunk_size: None, + sc_max_each_post_bytes: None, + sc_min_posts_interval_ms: None, download_settings: None, } } @@ -796,6 +1104,18 @@ impl Default for RawXHttpSettings { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawXHttpDownloadSettings { + #[serde( + rename = "client-fingerprint", + default, + deserialize_with = "deserialize_present_option" + )] + client_fingerprint: Option, + #[serde( + rename = "reuse-settings", + default, + deserialize_with = "deserialize_present_map" + )] + reuse_settings: Option, #[serde(default, deserialize_with = "deserialize_present_option")] server: Option, #[serde(default, deserialize_with = "deserialize_present_option")] @@ -807,15 +1127,66 @@ struct RawXHttpDownloadSettings { #[serde(default, deserialize_with = "deserialize_present_option")] alpn: Option>, #[serde( - rename = "reality-opts", + rename = "skip-cert-verify", default, deserialize_with = "deserialize_present_option" )] - reality_opts: Option, + skip_cert_verify: Option, + #[serde( + rename = "name-cert-verify", + default, + deserialize_with = "deserialize_present_option" + )] + name_cert_verify: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + fingerprint: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + certificate: Option, + #[serde( + rename = "private-key", + default, + deserialize_with = "deserialize_present_option" + )] + private_key: Option, + #[serde( + rename = "reality-opts", + default, + deserialize_with = "deserialize_present_map" + )] + reality_opts: Option, + #[serde(rename = "jls-opts", default, deserialize_with = "jls::deserialize")] + jls_opts: Option, + #[serde(rename = "ech-opts", default, deserialize_with = "ech::deserialize")] + ech_opts: Option, #[serde(default, deserialize_with = "deserialize_present_option")] path: Option, #[serde(default, deserialize_with = "deserialize_present_option")] host: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + headers: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawXHttpDownloadReality { + #[serde( + rename = "public-key", + default, + deserialize_with = "deserialize_present_option" + )] + public_key: Option, + #[serde( + rename = "short-id", + default, + deserialize_with = "deserialize_present_option" + )] + short_id: Option, + #[serde( + rename = "support-x25519mlkem768", + default, + deserialize_with = "deserialize_present_option" + )] + support_x25519mlkem768: Option, } #[derive(Debug, Deserialize)] @@ -825,6 +1196,8 @@ struct RawRealitySettings { public_key: String, #[serde(rename = "short-id", default)] short_id: String, + #[serde(rename = "support-x25519mlkem768", default)] + support_x25519mlkem768: bool, } #[derive(Debug)] @@ -861,6 +1234,30 @@ where T::deserialize(deserializer).map(Some) } +// Derived structs also accept positional sequences. Configuration objects must +// remain named maps: in particular, [] must not silently select all defaults. +fn deserialize_present_map<'de, D, T>(deserializer: D) -> std::result::Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + struct NamedMap(std::marker::PhantomData); + impl<'de, T: Deserialize<'de>> Visitor<'de> for NamedMap { + type Value = T; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a named configuration map") + } + + fn visit_map>(self, map: A) -> std::result::Result { + T::deserialize(serde::de::value::MapAccessDeserializer::new(map)) + } + } + deserializer + .deserialize_map(NamedMap(std::marker::PhantomData)) + .map(Some) +} + fn deserialize_non_null_vec<'de, D, T>(deserializer: D) -> std::result::Result, D::Error> where D: Deserializer<'de>, @@ -1538,6 +1935,9 @@ pub(crate) fn proxy_graph_order( impl RawOutbound { fn normalize(self) -> Result { let (tag, dialer_proxy, udp, protocol) = match self { + Self::Hysteria2(raw) => raw.normalize()?, + Self::Vmess(raw) => raw.normalize()?, + Self::Trojan(raw) => raw.normalize()?, Self::Shadowsocks { name, server, @@ -1552,39 +1952,7 @@ impl RawOutbound { udp, ProxyProtocol::Shadowsocks(shadowsocks::normalize(server, port, cipher, password)?), ), - Self::Vless { - name, - server, - port, - uuid, - udp, - tls, - network, - encryption, - flow, - servername, - alpn, - dialer_proxy, - reality_opts, - xhttp_opts, - } => ( - name, - dialer_proxy, - udp, - ProxyProtocol::Vless(normalize_vless( - server, - port, - uuid, - tls, - network, - encryption, - flow, - servername, - alpn, - reality_opts, - xhttp_opts, - )?), - ), + Self::Vless(raw) => raw.normalize()?, Self::Socks5 { name, server, @@ -1610,12 +1978,10 @@ impl RawOutbound { alpn, skip_cert_verify, fingerprint, + client_fingerprint, dialer_proxy, - } => ( - name, - dialer_proxy, - udp, - ProxyProtocol::AnyTls(normalize_anytls( + } => { + let mut config = normalize_anytls( server, port, password, @@ -1623,8 +1989,11 @@ impl RawOutbound { alpn, skip_cert_verify, fingerprint, - )?), - ), + )?; + config.tls.client_fingerprint = + parse_client_fingerprint(client_fingerprint.as_deref())?; + (name, dialer_proxy, udp, ProxyProtocol::AnyTls(config)) + } }; validate_route_target_definition_name(&tag, "proxy name")?; if let Some(dialer_proxy) = &dialer_proxy { @@ -1639,65 +2008,6 @@ impl RawOutbound { } } -#[allow(clippy::too_many_arguments)] -fn normalize_vless( - server: String, - port: u16, - uuid: String, - tls: bool, - network: String, - encryption: String, - flow: String, - servername: Option, - alpn: Option>, - reality_opts: Option, - xhttp_opts: Option, -) -> Result { - validate_host(&server, "VLESS server")?; - validate_port(port, "VLESS")?; - let id = parse_standard_uuid(&uuid)?; - if !matches!(encryption.as_str(), "" | "none") { - return invalid("VLESS encryption must be empty or `none`"); - } - if !flow.is_empty() { - return invalid("VLESS flow must be empty"); - } - if !tls { - return invalid("VLESS tls must be true"); - } - if network != "xhttp" { - return invalid("VLESS network must be `xhttp`"); - } - if let Some(alpn) = alpn - && alpn.as_slice() != ["h2"] - { - return invalid("VLESS alpn must be [h2] when configured"); - } - let server_name_is_explicit = servername.is_some(); - let server_name = servername.unwrap_or_else(|| server.clone()); - validate_host(&server_name, "VLESS servername")?; - let security = match reality_opts { - Some(reality) => SecurityConfig::Reality(reality.normalize(server_name)?), - None => SecurityConfig::Tls(TlsConfig { server_name }), - }; - let xhttp = xhttp_opts.unwrap_or_default().normalize( - &server, - port, - server_name_is_explicit.then_some(security.server_name()), - security.server_name(), - &security, - )?; - Ok(VlessOutboundConfig { - address: server, - port, - id, - encryption: VlessEncryption::None, - flow, - security, - xhttp, - }) -} - impl RawRealitySettings { fn normalize(self, server_name: String) -> Result { let decoded = URL_SAFE_NO_PAD @@ -1722,9 +2032,12 @@ impl RawRealitySettings { } Ok(RealityConfig { + client_fingerprint: None, + support_x25519mlkem768: self.support_x25519mlkem768, server_name, public_key, short_id, + alpn: vec![b"h2".to_vec()], }) } } @@ -1817,6 +2130,7 @@ fn normalize_anytls( password, server_name, tls: AnyTlsCertificatePolicy { + client_fingerprint: None, alpn, skip_cert_verify, fingerprint, @@ -1832,13 +2146,39 @@ impl RawXHttpSettings { default_explicit_server_name: Option<&str>, default_host: &str, security: &SecurityConfig, + http_version: XHttpVersion, ) -> Result { + if http_version == XHttpVersion::Http3 && !matches!(security, SecurityConfig::Tls(_)) { + return invalid("XHTTP HTTP/3 requires standard TLS"); + } + let headers = XHttpHeaders::normalize(self.headers.clone())?; + let has_download = self.download_settings.is_some(); + let mode = match self.mode.as_str() { + "auto" if matches!(security, SecurityConfig::Reality(_)) && has_download => { + XHttpMode::StreamUp + } + "auto" if matches!(security, SecurityConfig::Reality(_)) => XHttpMode::StreamOne, + "auto" | "packet-up" => XHttpMode::PacketUp, + "stream-one" if !has_download => XHttpMode::StreamOne, + "stream-one" => { + return invalid("xhttp mode `stream-one` cannot be used with download-settings"); + } + "stream-up" => XHttpMode::StreamUp, + _ => return invalid("XHTTP mode must be auto, packet-up, stream-up, or stream-one"), + }; + let request = std::sync::Arc::new(XHttpRequestOptions::normalize(&self, &headers, mode)?); + let reuse = self + .reuse_settings + .map(|raw| raw.normalize(http_version)) + .transpose()? + .map(std::sync::Arc::new); let Self { host, path, - mode, download_settings, + .. } = self; + let host = host.filter(|value| !value.is_empty()); if path.is_empty() || path.len() > 2_048 || !path.starts_with('/') @@ -1848,16 +2188,19 @@ impl RawXHttpSettings { "xhttp-opts.path must be a valid path/query starting with `/` and at most 2048 bytes", ); } - let download = download_settings + let mut download = download_settings .map(|settings| { - settings.normalize( - default_address, - default_port, - default_explicit_server_name, + settings.normalize(XHttpDownloadDefaults { + address: default_address, + port: default_port, + explicit_server_name: default_explicit_server_name, security, - &path, - host.as_deref(), - ) + http_version, + path: &path, + explicit_host: host.as_deref(), + headers: &headers, + reuse: &reuse, + }) }) .transpose()? .map(Box::new); @@ -1867,60 +2210,106 @@ impl RawXHttpSettings { |address| format!("[{address}]"), ) }); - if host.is_empty() || host.len() > 253 || host.parse::().is_err() { + if host.is_empty() + || host.len() > 253 + || host.contains('@') + || host.parse::().is_err() + { return invalid("xhttp-opts.host must be a valid HTTP authority"); } - let mode = match mode.as_str() { - "auto" if matches!(security, SecurityConfig::Reality(_)) && download.is_some() => { - XHttpMode::StreamUp - } - "auto" if matches!(security, SecurityConfig::Reality(_)) => XHttpMode::StreamOne, - "auto" | "packet-up" => XHttpMode::PacketUp, - "stream-one" if download.is_none() => XHttpMode::StreamOne, - "stream-one" => { - return invalid("xhttp mode `stream-one` cannot be used with download-settings"); - } - "stream-up" => XHttpMode::StreamUp, - _ => return invalid("XHTTP mode must be auto, packet-up, stream-up, or stream-one"), - }; + if let Some(download) = &mut download { + request.validate_headers(&download.headers, &download.path)?; + download.request = request.clone(); + } Ok(XHttpConfig { path, host, mode, + http_version, + headers, + request, download, + reuse, }) } } +struct XHttpDownloadDefaults<'a> { + reuse: &'a Option>, + address: &'a str, + port: u16, + explicit_server_name: Option<&'a str>, + security: &'a SecurityConfig, + http_version: XHttpVersion, + path: &'a str, + explicit_host: Option<&'a str>, + headers: &'a XHttpHeaders, +} + impl RawXHttpDownloadSettings { - fn normalize( - self, - default_address: &str, - default_port: u16, - default_explicit_server_name: Option<&str>, - default_security: &SecurityConfig, - default_path: &str, - default_explicit_host: Option<&str>, - ) -> Result { + fn normalize(self, defaults: XHttpDownloadDefaults<'_>) -> Result { + let XHttpDownloadDefaults { + reuse: default_reuse, + address: default_address, + port: default_port, + explicit_server_name: default_explicit_server_name, + security: default_security, + http_version: default_http_version, + path: default_path, + explicit_host: default_explicit_host, + headers: default_headers, + } = defaults; let Self { server, port, tls, servername, alpn, + skip_cert_verify, + name_cert_verify, + fingerprint, + client_fingerprint, + certificate, + private_key, reality_opts, + jls_opts, + ech_opts, path, host, + headers, + reuse_settings, } = self; + let headers = headers + .map(XHttpHeaders::normalize) + .transpose()? + .unwrap_or_else(|| default_headers.clone()); - if tls == Some(false) { - return invalid("xhttp-opts.download-settings.tls must be true when configured"); + let http_version = alpn + .as_deref() + .map(XHttpVersion::from_alpn) + .transpose()? + .unwrap_or(default_http_version); + let tls = tls.unwrap_or(!matches!(default_security, SecurityConfig::None)); + let client_fingerprint = match client_fingerprint { + Some(value) => parse_client_fingerprint(Some(&value))?, + None => default_security.client_fingerprint(), + }; + if client_fingerprint.is_some() && (!tls || http_version == XHttpVersion::Http3) { + return invalid( + "XHTTP download client-fingerprint requires TCP TLS; clear the inherited profile before selecting plaintext or HTTP/3", + ); } - if let Some(alpn) = &alpn - && alpn.as_slice() != ["h2"] + let reuse = match reuse_settings { + Some(raw) => Some(std::sync::Arc::new(raw.normalize(http_version)?)), + None => default_reuse.clone(), + }; + if http_version == XHttpVersion::Http1 + && reuse + .as_ref() + .is_some_and(|value| value.keep_alive_seconds != 0) { - return invalid("xhttp-opts.download-settings.alpn must be [h2] when configured"); + return invalid("XHTTP HTTP/1 download cannot inherit a keepalive period override"); } let address = server.unwrap_or_else(|| default_address.to_owned()); @@ -1934,25 +2323,150 @@ impl RawXHttpDownloadSettings { validate_host(&server_name, "XHTTP download servername")?; let host = host .or_else(|| default_explicit_host.map(str::to_owned)) + .filter(|value| !value.is_empty()) .unwrap_or_else(|| { server_name .parse::() .map_or_else(|_| server_name.clone(), |address| format!("[{address}]")) }); - if host.is_empty() || host.len() > 253 || host.parse::().is_err() { + if host.is_empty() + || host.len() > 253 + || host.contains('@') + || host.parse::().is_err() + { return invalid("xhttp-opts.download-settings.host must be a valid HTTP authority"); } - let security = match reality_opts { - Some(reality) => SecurityConfig::Reality(reality.normalize(server_name)?), - None => { - let mut security = default_security.clone(); - match &mut security { - SecurityConfig::Tls(config) => config.server_name = server_name, - SecurityConfig::Reality(config) => config.server_name = server_name, + let reality = match reality_opts { + None => match default_security { + SecurityConfig::Reality(config) => { + let mut config = config.clone(); + config.server_name = server_name.clone(); + Some(config) } - security - } + _ => None, + }, + Some(RawXHttpDownloadReality { + public_key: None, + short_id: None, + support_x25519mlkem768: None, + }) => None, + Some(RawXHttpDownloadReality { + public_key: Some(public_key), + short_id, + support_x25519mlkem768, + }) => Some( + RawRealitySettings { + public_key, + short_id: short_id.unwrap_or_default(), + support_x25519mlkem768: support_x25519mlkem768.unwrap_or(false), + } + .normalize(server_name.clone())?, + ), + Some(_) => return invalid("XHTTP download REALITY replacement requires public-key"), }; + // Resolve inherited leaf policies before choosing the final security + // mode. A mode switch must not silently erase a pin or client identity. + let mut config = match default_security { + SecurityConfig::Tls(config) => config.clone(), + SecurityConfig::Jls(config) => config.tls.clone(), + _ => TlsConfig::xhttp(server_name.clone()), + }; + config.server_name = server_name; + config.client_fingerprint = client_fingerprint; + config.alpn = vec![http_version.alpn().to_vec()]; + config.required_alpn = Some(http_version.alpn().to_vec()); + if let Some(raw) = ech_opts { + config.ech = raw.normalize()?; + } + if config.ech.is_some() && config.server_name.parse::().is_ok() { + return invalid("XHTTP download ECH requires a DNS servername"); + } + if let Some(skip) = skip_cert_verify { + config.certificate.skip_cert_verify = skip; + } + if let Some(name) = name_cert_verify { + config.certificate.verification_name = if name.is_empty() { + None + } else { + validate_host(&name, "XHTTP download certificate verification name")?; + Some(name) + }; + } + if let Some(pin) = fingerprint { + config.certificate.fingerprint = if pin.is_empty() { + None + } else { + Some(vless::parse_pin(&pin)?) + }; + } + match (certificate, private_key) { + (None, None) => {} + (Some(certificate), Some(private_key)) + if certificate.is_empty() && private_key.is_empty() => + { + config.identity = None + } + (Some(certificate), Some(private_key)) => { + vless::validate_client_identity(&certificate, &private_key)?; + config.identity = Some(TlsIdentityPem { + certificate, + private_key, + }); + } + _ => { + return invalid( + "XHTTP download certificate and private-key must be replaced or cleared together", + ); + } + } + let certificate_policy = config.certificate.skip_cert_verify + || config.certificate.verification_name.is_some() + || config.certificate.fingerprint.is_some() + || config.identity.is_some(); + let jls = match jls_opts { + Some(raw) if raw.is_empty() => None, + Some(raw) => Some(raw.normalize(config.clone())?), + None => match default_security { + SecurityConfig::Jls(config) => Some(config.clone()), + _ => None, + }, + }; + if reality.is_some() && jls.is_some() { + return invalid("XHTTP download JLS and REALITY are mutually exclusive"); + } + if config.ech.is_some() && (reality.is_some() || jls.is_some() || !tls) { + return invalid( + "XHTTP download ECH requires standard TLS; clear inherited ECH before changing security mode", + ); + } + if (reality.is_some() || jls.is_some() || !tls) && certificate_policy { + return invalid( + "XHTTP download must explicitly clear inherited certificate policy before changing security mode", + ); + } + let security = if let Some(mut reality) = reality { + if !tls { + return invalid("XHTTP download REALITY requires TLS"); + } + reality.alpn = vec![http_version.alpn().to_vec()]; + reality.client_fingerprint = client_fingerprint; + reality.validate_fingerprint()?; + SecurityConfig::Reality(reality) + } else if let Some(mut jls) = jls { + if !tls { + return invalid("XHTTP download JLS requires TLS"); + } + jls.tls = config; + jls.validate()?; + SecurityConfig::Jls(jls) + } else if tls { + SecurityConfig::Tls(config) + } else { + SecurityConfig::None + }; + if http_version == XHttpVersion::Http3 && !matches!(security, SecurityConfig::Tls(_)) { + return invalid("XHTTP HTTP/3 download requires standard TLS"); + } let path = path.unwrap_or_else(|| default_path.to_owned()); if path.is_empty() @@ -1968,8 +2482,12 @@ impl RawXHttpDownloadSettings { address, port, security, + http_version, path, host, + headers, + request: Default::default(), + reuse, }) } } @@ -2796,9 +3314,7 @@ proxies: fn first_xhttp_download(config: &Config) -> &XHttpDownloadConfig { first_vless(config) - .xhttp - .download - .as_deref() + .download() .expect("expected XHTTP download settings") } @@ -2848,10 +3364,13 @@ proxies: assert_eq!(first_vless(&config).address, "203.0.113.1"); assert!(matches!( &first_vless(&config).security, - SecurityConfig::Tls(TlsConfig { server_name }) if server_name == "example.com" + SecurityConfig::Tls(TlsConfig { server_name, .. }) if server_name == "example.com" )); - assert_eq!(first_vless(&config).xhttp.mode, XHttpMode::PacketUp); - assert!(first_vless(&config).xhttp.download.is_none()); + assert_eq!( + first_vless(&config).xhttp().unwrap().mode, + XHttpMode::PacketUp + ); + assert!(first_vless(&config).xhttp().unwrap().download.is_none()); } #[test] @@ -3031,9 +3550,12 @@ geo-update-interval: 24"#, ); let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); assert_eq!(first_vless(&config).flow, ""); - assert_eq!(first_vless(&config).xhttp.path, "/"); - assert_eq!(first_vless(&config).xhttp.host, "example.com"); - assert_eq!(first_vless(&config).xhttp.mode, XHttpMode::PacketUp); + assert_eq!(first_vless(&config).xhttp().unwrap().path, "/"); + assert_eq!(first_vless(&config).xhttp().unwrap().host, "example.com"); + assert_eq!( + first_vless(&config).xhttp().unwrap().mode, + XHttpMode::PacketUp + ); assert!(matches!( config.inbounds[1], InboundConfig::Tun(TunInboundConfig { mtu: 1_500, .. }) @@ -3195,7 +3717,7 @@ geo-update-interval: 24"#, .replace("servername: example.com", "servername: '::1'") .replace(" host: example.com\n", ""); let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); - assert_eq!(first_vless(&config).xhttp.host, "[::1]"); + assert_eq!(first_vless(&config).xhttp().unwrap().host, "[::1]"); } #[test] @@ -3207,19 +3729,25 @@ geo-update-interval: 24"#, assert_eq!(reality.server_name, "example.com"); assert_eq!(reality.public_key, [7_u8; 32]); assert_eq!(reality.short_id, [1, 0xa2]); - assert_eq!(first_vless(&config).xhttp.mode, XHttpMode::StreamOne); + assert_eq!( + first_vless(&config).xhttp().unwrap().mode, + XHttpMode::StreamOne + ); } #[test] fn auto_mode_tracks_the_security_transport() { let tls = Config::parse_yaml(CURRENT_TLS.as_bytes()).unwrap(); - assert_eq!(first_vless(&tls).xhttp.mode, XHttpMode::PacketUp); + assert_eq!(first_vless(&tls).xhttp().unwrap().mode, XHttpMode::PacketUp); let reality = Config::parse_yaml(reality_yaml().as_bytes()).unwrap(); - assert_eq!(first_vless(&reality).xhttp.mode, XHttpMode::StreamOne); + assert_eq!( + first_vless(&reality).xhttp().unwrap().mode, + XHttpMode::StreamOne + ); let reality_with_download = with_xhttp_download(&reality_yaml(), ""); let reality_with_download = Config::parse_yaml(reality_with_download.as_bytes()).unwrap(); assert_eq!( - first_vless(&reality_with_download).xhttp.mode, + first_vless(&reality_with_download).xhttp().unwrap().mode, XHttpMode::StreamUp ); } @@ -3228,13 +3756,22 @@ geo-update-interval: 24"#, fn accepts_explicit_supported_xhttp_modes() { let packet_up = CURRENT_TLS.replace("mode: auto", "mode: packet-up"); let packet_up = Config::parse_yaml(packet_up.as_bytes()).unwrap(); - assert_eq!(first_vless(&packet_up).xhttp.mode, XHttpMode::PacketUp); + assert_eq!( + first_vless(&packet_up).xhttp().unwrap().mode, + XHttpMode::PacketUp + ); let stream_one = CURRENT_TLS.replace("mode: auto", "mode: stream-one"); let stream_one = Config::parse_yaml(stream_one.as_bytes()).unwrap(); - assert_eq!(first_vless(&stream_one).xhttp.mode, XHttpMode::StreamOne); + assert_eq!( + first_vless(&stream_one).xhttp().unwrap().mode, + XHttpMode::StreamOne + ); let stream_up = CURRENT_TLS.replace("mode: auto", "mode: stream-up"); let stream_up = Config::parse_yaml(stream_up.as_bytes()).unwrap(); - assert_eq!(first_vless(&stream_up).xhttp.mode, XHttpMode::StreamUp); + assert_eq!( + first_vless(&stream_up).xhttp().unwrap().mode, + XHttpMode::StreamUp + ); for (mode, expected) in [ ("packet-up", XHttpMode::PacketUp), @@ -3243,7 +3780,7 @@ geo-update-interval: 24"#, let yaml = with_xhttp_download(CURRENT_TLS, "") .replace("mode: auto", &format!("mode: {mode}")); let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); - assert_eq!(first_vless(&config).xhttp.mode, expected); + assert_eq!(first_vless(&config).xhttp().unwrap().mode, expected); } } @@ -3258,9 +3795,12 @@ geo-update-interval: 24"#, assert_eq!(download.host, "example.com"); assert!(matches!( &download.security, - SecurityConfig::Tls(TlsConfig { server_name }) if server_name == "example.com" + SecurityConfig::Tls(TlsConfig { server_name, .. }) if server_name == "example.com" )); - assert_eq!(first_vless(&tls_inherited).xhttp.mode, XHttpMode::PacketUp); + assert_eq!( + first_vless(&tls_inherited).xhttp().unwrap().mode, + XHttpMode::PacketUp + ); let download_reality_key = URL_SAFE_NO_PAD.encode([9_u8; 32]); let tls_to_reality = with_xhttp_download( @@ -3288,7 +3828,10 @@ geo-update-interval: 24"#, assert_eq!(download_reality.server_name, "reality-download.example.com"); assert_eq!(download_reality.public_key, [9_u8; 32]); assert_eq!(download_reality.short_id, [2, 0xa3]); - assert_eq!(first_vless(&tls_to_reality).xhttp.mode, XHttpMode::PacketUp); + assert_eq!( + first_vless(&tls_to_reality).xhttp().unwrap().mode, + XHttpMode::PacketUp + ); let reality_inherited = with_xhttp_download(&reality_yaml(), ""); let reality_inherited = Config::parse_yaml(reality_inherited.as_bytes()).unwrap(); @@ -3297,7 +3840,7 @@ geo-update-interval: 24"#, &first_vless(&reality_inherited).security ); assert_eq!( - first_vless(&reality_inherited).xhttp.mode, + first_vless(&reality_inherited).xhttp().unwrap().mode, XHttpMode::StreamUp ); @@ -3314,7 +3857,10 @@ geo-update-interval: 24"#, }; assert_eq!(download_reality.server_name, "tls-download.example.com"); assert_eq!( - first_vless(&reality_with_explicit_tls).xhttp.mode, + first_vless(&reality_with_explicit_tls) + .xhttp() + .unwrap() + .mode, XHttpMode::StreamUp ); } @@ -3394,9 +3940,9 @@ geo-update-interval: 24"#, } for fields in [ - "tls: false", - "alpn: []", - "alpn: [h3]", + "tls: 'false'", + "alpn: [unknown]", + "alpn: [h3, h2]", "server: 'bad host'", "port: 0", "servername: 'bad host'", @@ -3413,16 +3959,6 @@ geo-update-interval: 24"#, for fields in [ "shadow-tls-opts: {}", "restls-opts: {}", - "jls-opts: {}", - "ech-opts: {}", - "headers: {}", - "reuse-settings: {}", - "skip-cert-verify: false", - "name-cert-verify: example.com", - "fingerprint: pinned", - "certificate: cert.pem", - "private-key: key.pem", - "client-fingerprint: chrome", "mode: stream-up", "typo: true", ] { @@ -3434,11 +3970,7 @@ geo-update-interval: 24"#, ); } - for unsupported in [ - " shadow-tls-opts: {}\n", - " restls-opts: {}\n", - " jls-opts: null\n", - ] { + for unsupported in [" shadow-tls-opts: {}\n", " restls-opts: {}\n"] { let yaml = CURRENT_TLS.replace( " xhttp-opts:\n", &format!("{unsupported} xhttp-opts:\n"), @@ -3478,13 +4010,9 @@ geo-update-interval: 24"#, CURRENT_TLS.replace(" server: ", " vnext: []\n server: "), CURRENT_TLS.replace(" network: xhttp", " typo: true\n network: xhttp"), CURRENT_TLS.replace(" path: /x", " typo: true\n path: /x"), - CURRENT_TLS.replace( - " servername: example.com", - " client-fingerprint: chrome\n servername: example.com", - ), reality_yaml().replace( " public-key:", - " support-x25519mlkem768: true\n public-key:", + " unknown-reality-option: true\n public-key:", ), ] { let error = Config::parse_yaml(yaml.as_bytes()).unwrap_err(); @@ -3943,7 +4471,6 @@ authentication: fn rejects_anytls_fields_outside_the_locked_subset() { for field in [ "tls: true", - "client-fingerprint: chrome", "ech-opts: {}", "certificate: client.crt", "private-key: client.key", @@ -4078,10 +4605,10 @@ authentication: #[test] fn validates_tls_and_xhttp_whitelists() { for yaml in [ - CURRENT_TLS.replace("alpn: [h2]", "alpn: [http/1.1]"), + CURRENT_TLS.replace("alpn: [h2]", "alpn: [unknown]"), CURRENT_TLS.replace( " alpn: [h2]", - " alpn: [h2]\n skip-cert-verify: false", + " alpn: [h2]\n skip-cert-verify: null", ), CURRENT_TLS.replace(" alpn: [h2]", " alpn: [h2]\n allowInsecure: true"), CURRENT_TLS.replace("path: /x", "path: '/bad path'"), diff --git a/src/config/trojan.rs b/src/config/trojan.rs new file mode 100644 index 0000000..7a16115 --- /dev/null +++ b/src/config/trojan.rs @@ -0,0 +1,326 @@ +use serde::Deserialize; +use std::collections::BTreeMap; + +use super::{ + AnyTlsCertificatePolicy, ProxyProtocol, deserialize_present_option, invalid, validate_host, + validate_port, +}; +use crate::Result; + +#[derive(Clone, PartialEq, Eq)] +pub struct TrojanOutboundConfig { + pub address: String, + pub port: u16, + pub password: String, + pub server_name: String, + pub tls: AnyTlsCertificatePolicy, + pub transport: TrojanTransport, +} + +#[derive(Clone, PartialEq, Eq)] +pub enum TrojanTransport { + Tcp, + WebSocket { + uri: String, + headers: BTreeMap, + max_early_data: u16, + early_data_header_name: String, + }, + Grpc { + uri: String, + }, +} + +impl std::fmt::Debug for TrojanTransport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Tcp => "Tcp", + Self::WebSocket { .. } => "WebSocket", + Self::Grpc { .. } => "Grpc", + }) + } +} + +impl TrojanTransport { + pub fn required_alpn(&self) -> Option<&'static [u8]> { + match self { + Self::Tcp => None, + Self::WebSocket { .. } => Some(b"http/1.1"), + Self::Grpc { .. } => Some(b"h2"), + } + } + + #[cfg(feature = "stream-transport")] + pub fn websocket_options(&self) -> std::io::Result> { + let Self::WebSocket { + uri, + headers, + max_early_data, + early_data_header_name, + } = self + else { + return Ok(None); + }; + websocket_options(uri, headers, *max_early_data, early_data_header_name).map(Some) + } +} + +#[cfg(feature = "stream-transport")] +pub(super) fn websocket_options( + uri: &str, + headers: &BTreeMap, + max_early_data: u16, + early_data_header_name: &str, +) -> std::io::Result { + use crate::transport::{WebSocketEarlyData, WebSocketOptions}; + let invalid = || { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid WebSocket options", + ) + }; + let mut map = http::HeaderMap::new(); + for (name, value) in headers { + let name = http::HeaderName::from_bytes(name.as_bytes()).map_err(|_| invalid())?; + if map + .insert(name, value.parse().map_err(|_| invalid())?) + .is_some() + { + return Err(invalid()); + } + } + let early = if max_early_data == 0 { + None + } else if early_data_header_name.is_empty() { + Some(WebSocketEarlyData::Path { + max_bytes: usize::from(max_early_data), + }) + } else { + Some(WebSocketEarlyData::Header { + name: early_data_header_name.parse().map_err(|_| invalid())?, + max_bytes: usize::from(max_early_data), + }) + }; + WebSocketOptions::new(uri, map, early) +} +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawWebSocket { + #[serde(default, deserialize_with = "deserialize_present_option")] + path: Option, + #[serde(default)] + headers: BTreeMap, + #[serde(rename = "max-early-data", default)] + max_early_data: u16, + #[serde( + rename = "early-data-header-name", + default, + deserialize_with = "deserialize_present_option" + )] + early_data_header_name: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawGrpc { + #[serde(rename = "grpc-service-name")] + service: String, +} + +fn uri(scheme: &str, server: &str, port: u16, path: &str) -> Result { + if !path.starts_with('/') || path.contains(['\r', '\n', '#']) { + return invalid("invalid Trojan transport path"); + } + let authority = if server.parse::().is_ok() { + format!("[{server}]:{port}") + } else { + format!("{server}:{port}") + }; + let uri = http::Uri::builder() + .scheme(scheme) + .authority(authority) + .path_and_query(path) + .build() + .map_err(|_| crate::VCoreError::InvalidConfig("invalid Trojan transport URI".into()))?; + // Same bounded HTTP head envelope as the shared stream adapters. + if uri.to_string().len() > 16 * 1024 - 256 { + return invalid("Trojan transport URI exceeds HTTP head limit"); + } + Ok(uri.to_string()) +} + +impl std::fmt::Debug for TrojanOutboundConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TrojanOutboundConfig") + .finish_non_exhaustive() + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawTrojan { + name: String, + server: String, + port: u16, + password: String, + #[serde(default)] + udp: bool, + #[serde(default, deserialize_with = "deserialize_present_option")] + network: Option, + #[serde( + rename = "ws-opts", + default, + deserialize_with = "deserialize_present_option" + )] + ws: Option, + #[serde( + rename = "grpc-opts", + default, + deserialize_with = "deserialize_present_option" + )] + grpc: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + sni: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + alpn: Option>, + #[serde(rename = "skip-cert-verify", default)] + skip_cert_verify: bool, + #[serde(default, deserialize_with = "deserialize_present_option")] + fingerprint: Option, + #[serde( + rename = "client-fingerprint", + default, + deserialize_with = "deserialize_present_option" + )] + client_fingerprint: Option, + #[serde( + rename = "dialer-proxy", + default, + deserialize_with = "deserialize_present_option" + )] + dialer_proxy: Option, +} + +impl std::fmt::Debug for RawTrojan { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RawTrojan").finish_non_exhaustive() + } +} + +impl RawTrojan { + pub(super) fn normalize(self) -> Result<(String, Option, bool, ProxyProtocol)> { + if !cfg!(feature = "outbound-trojan") { + return invalid("Trojan outbound support is disabled at build time"); + } + validate_host(&self.server, "Trojan server")?; + validate_port(self.port, "Trojan")?; + if self.password.is_empty() { + return invalid("Trojan password must not be empty"); + } + let transport = match (self.network.as_deref().unwrap_or("tcp"), self.ws, self.grpc) { + ("tcp", None, None) => TrojanTransport::Tcp, + ("ws", ws, None) => { + let ws = ws.unwrap_or_default(); + if ws.max_early_data > 2048 + || (ws.max_early_data == 0 && ws.early_data_header_name.is_some()) + { + return invalid("invalid Trojan WebSocket early-data options"); + } + let transport = TrojanTransport::WebSocket { + uri: uri( + "wss", + &self.server, + self.port, + ws.path.as_deref().unwrap_or("/"), + )?, + headers: ws.headers, + max_early_data: ws.max_early_data, + early_data_header_name: ws + .early_data_header_name + .unwrap_or_else(|| "Sec-WebSocket-Protocol".into()), + }; + #[cfg(feature = "stream-transport")] + if transport.websocket_options().is_err() { + return invalid("invalid Trojan WebSocket options"); + } + transport + } + ("grpc", None, Some(grpc)) if !grpc.service.is_empty() => { + let path = if grpc.service.starts_with('/') { + grpc.service + } else { + format!("/{}/Tun", grpc.service) + }; + if path.contains('?') { + return invalid("invalid Trojan gRPC service path"); + } + TrojanTransport::Grpc { + uri: uri("https", &self.server, self.port, &path)?, + } + } + _ => return invalid("Trojan requires tcp, ws with ws-opts, or grpc with grpc-opts"), + }; + let server_name = self.sni.unwrap_or_else(|| self.server.clone()); + validate_host(&server_name, "Trojan sni")?; + let alpn: Vec> = self + .alpn + .unwrap_or_else(|| { + transport + .required_alpn() + .map(|value| vec![String::from_utf8(value.to_vec()).unwrap()]) + .unwrap_or_default() + }) + .into_iter() + .map(String::into_bytes) + .collect(); + if alpn + .iter() + .any(|value| value.is_empty() || value.len() > 255) + || alpn.iter().map(|value| value.len() + 1).sum::() > 65_533 + { + return invalid("invalid Trojan ALPN list"); + } + if transport + .required_alpn() + .is_some_and(|required| !alpn.iter().any(|value| value == required)) + { + return invalid("Trojan ALPN does not include the selected transport"); + } + let fingerprint = self + .fingerprint + .map(|value| { + let hex: Vec = value.trim().bytes().filter(|byte| *byte != b':').collect(); + if hex.len() != 64 || !hex.iter().all(u8::is_ascii_hexdigit) { + return invalid("Trojan fingerprint must be a SHA-256 certificate digest"); + } + let mut pin = [0; 32]; + for (byte, pair) in pin.iter_mut().zip(hex.as_chunks::<2>().0) { + *byte = ((pair[0] as char).to_digit(16).unwrap() * 16 + + (pair[1] as char).to_digit(16).unwrap()) + as u8; + } + Ok(pin) + }) + .transpose()?; + Ok(( + self.name, + self.dialer_proxy, + self.udp, + ProxyProtocol::Trojan(TrojanOutboundConfig { + address: self.server, + port: self.port, + password: self.password, + server_name, + transport, + tls: AnyTlsCertificatePolicy { + client_fingerprint: super::parse_client_fingerprint( + self.client_fingerprint.as_deref(), + )?, + alpn, + skip_cert_verify: self.skip_cert_verify, + fingerprint, + }, + }), + )) + } +} diff --git a/src/config/vless.rs b/src/config/vless.rs new file mode 100644 index 0000000..30796a9 --- /dev/null +++ b/src/config/vless.rs @@ -0,0 +1,521 @@ +//! VLESS public normalization. Transport options share the common stream adapters; +//! security and VLESS-specific behavior remain independent of VMess. +use super::vmess::{RawGrpc, RawH2, RawHttp, RawWs}; +use super::*; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct VlessStreamOptions { + pub http_upgrade: bool, + pub fast_open: bool, + pub grpc: GrpcOptions, + pub sing_mux: Option, +} +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SingMuxProtocol { + #[default] + H2Mux, + Smux, + Yamux, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct SingMuxConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub protocol: SingMuxProtocol, + #[serde(default)] + pub max_connections: u32, + #[serde(default)] + pub min_streams: u32, + #[serde(default)] + pub max_streams: u32, + #[serde(default)] + pub padding: bool, + #[serde(default)] + pub only_tcp: bool, +} +#[derive(Clone, PartialEq, Eq)] +pub struct GrpcOptions { + pub user_agent: String, + pub ping_interval: u64, + pub max_connections: usize, + pub min_streams: usize, + pub max_streams: usize, +} +impl std::fmt::Debug for GrpcOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GrpcOptions").finish_non_exhaustive() + } +} +impl Default for GrpcOptions { + fn default() -> Self { + Self { + user_agent: "grpc-go/1.36.0".into(), + ping_interval: 0, + max_connections: 1, + min_streams: 0, + max_streams: 0, + } + } +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawVlessWs { + #[serde(default, deserialize_with = "deserialize_present_option")] + path: Option, + #[serde(default)] + headers: std::collections::BTreeMap, + #[serde(rename = "max-early-data", default)] + max_early_data: u16, + #[serde( + rename = "early-data-header-name", + default, + deserialize_with = "deserialize_present_option" + )] + early_data_header_name: Option, + #[serde(rename = "v2ray-http-upgrade", default)] + http_upgrade: bool, + #[serde(rename = "v2ray-http-upgrade-fast-open", default)] + fast_open: bool, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawVlessGrpc { + #[serde(rename = "grpc-service-name")] + service: String, + #[serde( + rename = "grpc-user-agent", + default, + deserialize_with = "deserialize_present_option" + )] + user_agent: Option, + #[serde(rename = "ping-interval", default)] + ping_interval: u64, + #[serde(rename = "max-connections", default)] + max_connections: usize, + #[serde(rename = "min-streams", default)] + min_streams: usize, + #[serde(rename = "max-streams", default)] + max_streams: usize, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawVless { + name: String, + server: String, + port: u16, + uuid: String, + #[serde(default)] + udp: bool, + #[serde(default)] + tls: bool, + #[serde(default, deserialize_with = "deserialize_present_option")] + network: Option, + #[serde(rename = "packet-encoding", default)] + packet_encoding: VlessPacketEncoding, + #[serde(default)] + encryption: String, + #[serde(default)] + flow: String, + #[serde(default, deserialize_with = "deserialize_present_option")] + servername: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + alpn: Option>, + #[serde( + rename = "dialer-proxy", + default, + deserialize_with = "deserialize_present_option" + )] + dialer_proxy: Option, + #[serde( + rename = "reality-opts", + default, + deserialize_with = "deserialize_present_option" + )] + reality_opts: Option, + #[serde( + rename = "jls-opts", + default, + deserialize_with = "super::jls::deserialize" + )] + jls_opts: Option, + #[serde( + rename = "ech-opts", + default, + deserialize_with = "super::ech::deserialize" + )] + ech_opts: Option, + #[serde( + rename = "xhttp-opts", + default, + deserialize_with = "deserialize_present_map" + )] + xhttp_opts: Option, + + #[serde(default, deserialize_with = "deserialize_present_map")] + smux: Option, + + #[serde( + rename = "ws-opts", + default, + deserialize_with = "deserialize_present_option" + )] + ws: Option, + #[serde( + rename = "grpc-opts", + default, + deserialize_with = "deserialize_present_option" + )] + grpc: Option, + #[serde( + rename = "http-opts", + default, + deserialize_with = "deserialize_present_option" + )] + http: Option, + #[serde( + rename = "h2-opts", + default, + deserialize_with = "deserialize_present_option" + )] + h2: Option, + #[serde( + rename = "skip-cert-verify", + default, + deserialize_with = "deserialize_present_option" + )] + skip_cert_verify: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + fingerprint: Option, + #[serde( + rename = "client-fingerprint", + default, + deserialize_with = "deserialize_present_option" + )] + client_fingerprint: Option, + #[serde( + rename = "name-cert-verify", + default, + deserialize_with = "deserialize_present_option" + )] + name_cert_verify: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + certificate: Option, + #[serde( + rename = "private-key", + default, + deserialize_with = "deserialize_present_option" + )] + private_key: Option, +} + +impl std::fmt::Debug for RawVless { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RawVless").finish_non_exhaustive() + } +} + +impl RawVless { + pub(super) fn normalize(self) -> Result<(String, Option, bool, ProxyProtocol)> { + if !cfg!(feature = "outbound-vless") { + return invalid("VLESS support is disabled in this build"); + } + validate_host(&self.server, "VLESS server")?; + validate_port(self.port, "VLESS")?; + let id = parse_standard_uuid(&self.uuid)?; + let encryption = if matches!(self.encryption.as_str(), "" | "none") { + VlessEncryption::None + } else { + #[cfg(feature = "outbound-vless")] + crate::outbound::validate_vless_encryption(&self.encryption).map_err(|_| { + VCoreError::InvalidConfig("invalid VLESS Encryption configuration".into()) + })?; + VlessEncryption::MlKem768X25519Plus(self.encryption) + }; + let network = self.network.as_deref().unwrap_or("tcp"); + let vision = self.flow == "xtls-rprx-vision"; + if self.jls_opts.is_some() && (vision || self.reality_opts.is_some()) { + return invalid("JLS cannot be combined with REALITY or Vision"); + } + if (!self.flow.is_empty() && !vision) + || (vision + && (network != "tcp" + || (!self.tls && matches!(&encryption, VlessEncryption::None)) + || self.packet_encoding != VlessPacketEncoding::Xudp)) + { + return invalid("VLESS Vision requires TCP, TLS or Encryption, and XUDP"); + } + let mut stream_options = VlessStreamOptions::default(); + if let Some(mut mux) = self.smux { + if (mux.max_connections > 0 && mux.max_streams > 0) + || [mux.max_connections, mux.min_streams, mux.max_streams] + .iter() + .any(|value| *value > i32::MAX as u32) + || (mux.enabled && vision) + { + return invalid("invalid VLESS sing-mux options"); + } + if mux.max_connections == 0 && mux.max_streams == 0 { + mux.min_streams = 8; + } + stream_options.sing_mux = mux.enabled.then_some(mux); + } + let ws = self + .ws + .map(|ws| { + if (ws.fast_open && !ws.http_upgrade) + || (ws.http_upgrade + && ws.early_data_header_name.as_ref().is_some_and(|name| { + !name.eq_ignore_ascii_case("Sec-WebSocket-Protocol") + })) + { + return invalid("invalid VLESS HTTPUpgrade options"); + } + stream_options.http_upgrade = ws.http_upgrade; + stream_options.fast_open = ws.fast_open; + Ok(RawWs { + path: ws.path, + headers: ws.headers, + max_early_data: ws.max_early_data, + early_data_header_name: ws.early_data_header_name, + }) + }) + .transpose()?; + let grpc = self + .grpc + .map(|grpc| { + let user_agent = grpc.user_agent.unwrap_or_else(|| "grpc-go/1.36.0".into()); + if user_agent.len() > 8192 + || user_agent.parse::().is_err() + || (grpc.max_connections > 0 && grpc.max_streams > 0) + || std::time::Instant::now() + .checked_add(std::time::Duration::from_secs(grpc.ping_interval)) + .is_none() + { + return invalid("invalid VLESS gRPC options"); + } + stream_options.grpc = GrpcOptions { + user_agent, + ping_interval: grpc.ping_interval, + max_connections: if grpc.max_connections == 0 + && grpc.min_streams == 0 + && grpc.max_streams == 0 + { + 1 + } else { + grpc.max_connections + }, + min_streams: grpc.min_streams, + max_streams: grpc.max_streams, + }; + Ok(RawGrpc { + service: grpc.service, + }) + }) + .transpose()?; + if (network != "xhttp" && self.xhttp_opts.is_some()) + || (network == "xhttp" + && (ws.is_some() || grpc.is_some() || self.http.is_some() || self.h2.is_some())) + { + return invalid("VLESS transport options do not match network"); + } + let (stream, fallback_name) = if network == "xhttp" { + (StreamTransport::Tcp, self.server.clone()) + } else { + vmess::normalize_transport( + &self.server, + self.port, + self.tls, + network, + ws, + grpc, + self.http, + self.h2, + )? + }; + let standard_options = self.skip_cert_verify.is_some() + || self.fingerprint.is_some() + || self.name_cert_verify.is_some() + || self.certificate.is_some() + || self.private_key.is_some(); + if !self.tls + && (self.servername.is_some() + || (network != "xhttp" && self.alpn.is_some()) + || self.reality_opts.is_some() + || self.jls_opts.is_some() + || self.ech_opts.is_some() + || self.client_fingerprint.is_some() + || standard_options) + { + return invalid("VLESS TLS options require tls=true"); + } + if self.reality_opts.is_some() && standard_options { + return invalid("standard certificate options cannot be used with REALITY"); + } + if self.jls_opts.is_some() && standard_options { + return invalid("standard certificate options cannot be used with JLS"); + } + let ech = self + .ech_opts + .map(super::ech::RawEch::normalize) + .transpose()? + .flatten(); + if ech.is_some() && (self.reality_opts.is_some() || self.jls_opts.is_some() || vision) { + return invalid("ECH requires standard TLS without REALITY, JLS or Vision"); + } + let xhttp_version = if network == "xhttp" { + super::XHttpVersion::from_alpn(self.alpn.as_deref().unwrap_or_default())? + } else { + super::XHttpVersion::default() + }; + let default_alpn = if network == "xhttp" { + Some(xhttp_version.alpn()) + } else { + stream.required_alpn() + }; + let client_fingerprint = parse_client_fingerprint(self.client_fingerprint.as_deref())?; + if network == "xhttp" + && xhttp_version == super::XHttpVersion::Http3 + && client_fingerprint.is_some() + { + return invalid("client-fingerprint is not supported on HTTP/3"); + } + let alpn: Vec> = if network == "xhttp" { + vec![xhttp_version.alpn().to_vec()] + } else { + self.alpn + .map(|values| values.into_iter().map(String::into_bytes).collect()) + .unwrap_or_else(|| default_alpn.into_iter().map(<[u8]>::to_vec).collect()) + }; + if alpn.iter().any(|p| p.is_empty() || p.len() > 255) + || alpn.iter().map(|p| 1 + p.len()).sum::() > 65533 + || default_alpn.is_some_and(|p| !alpn.iter().any(|v| v == p)) + { + return invalid("invalid VLESS ALPN"); + } + let explicit_name = self.servername.is_some(); + let server_name = self.servername.unwrap_or(fallback_name); + validate_host(&server_name, "VLESS servername")?; + if ech.is_some() && server_name.parse::().is_ok() { + return invalid("ECH requires a DNS servername"); + } + if let Some(name) = &self.name_cert_verify { + validate_host(name, "VLESS certificate verification name")?; + } + let identity = match (self.certificate, self.private_key) { + (None, None) => None, + (Some(certificate), Some(private_key)) => { + validate_client_identity(&certificate, &private_key)?; + Some(TlsIdentityPem { + certificate, + private_key, + }) + } + _ => return invalid("VLESS certificate and private-key must be provided together"), + }; + let security = match (self.reality_opts, self.jls_opts) { + (Some(raw), None) => { + let mut config = raw.normalize(server_name)?; + config.alpn = alpn; + config.client_fingerprint = client_fingerprint; + config.validate_fingerprint()?; + SecurityConfig::Reality(config) + } + (None, Some(raw)) => SecurityConfig::Jls(raw.normalize(TlsConfig { + ech: None, + client_fingerprint, + server_name, + alpn, + tls13_only: true, + required_alpn: default_alpn.map(<[u8]>::to_vec), + certificate: Default::default(), + identity: None, + })?), + (None, None) if self.tls => SecurityConfig::Tls(TlsConfig { + tls13_only: ech.is_some() + || network == "xhttp" + || (vision && matches!(&encryption, VlessEncryption::None)), + ech, + client_fingerprint, + server_name, + alpn, + required_alpn: default_alpn.map(<[u8]>::to_vec), + certificate: TlsCertificatePolicy { + verification_name: self.name_cert_verify, + skip_cert_verify: self.skip_cert_verify.unwrap_or(false), + fingerprint: self + .fingerprint + .map(|value| parse_pin(&value)) + .transpose()?, + }, + identity, + }), + (None, None) => SecurityConfig::None, + (Some(_), Some(_)) => return invalid("JLS and REALITY are mutually exclusive"), + }; + let transport = if network == "xhttp" { + VlessTransport::Xhttp(Box::new(self.xhttp_opts.unwrap_or_default().normalize( + &self.server, + self.port, + explicit_name.then_some(security.server_name()), + if matches!(security, SecurityConfig::None) { + &self.server + } else { + security.server_name() + }, + &security, + xhttp_version, + )?)) + } else { + VlessTransport::Stream(stream) + }; + Ok(( + self.name, + self.dialer_proxy, + self.udp, + ProxyProtocol::Vless(VlessOutboundConfig { + address: self.server, + port: self.port, + id, + encryption, + flow: self.flow, + security, + transport, + packet_encoding: self.packet_encoding, + stream_options, + }), + )) + } +} +#[cfg(feature = "outbound-vless")] +pub(super) fn validate_client_identity(certificate: &str, private_key: &str) -> Result<()> { + crate::security::TlsClientIdentity::from_pem(certificate, private_key) + .map(|_| ()) + .map_err(|_| VCoreError::InvalidConfig("invalid VLESS client identity".into())) +} + +#[cfg(not(feature = "outbound-vless"))] +pub(super) fn validate_client_identity(_certificate: &str, _private_key: &str) -> Result<()> { + invalid("VLESS support is disabled in this build") +} + +pub(super) fn parse_pin(value: &str) -> Result<[u8; 32]> { + let hex = value + .trim() + .bytes() + .filter(|byte| *byte != b':') + .collect::>(); + if hex.len() != 64 || hex.iter().any(|byte| !byte.is_ascii_hexdigit()) { + return invalid("VLESS fingerprint must be a SHA-256 certificate digest"); + } + let mut pin = [0; 32]; + for (index, pair) in hex.as_chunks::<2>().0.iter().enumerate() { + pin[index] = ((pair[0] as char).to_digit(16).unwrap() * 16 + + (pair[1] as char).to_digit(16).unwrap()) as u8; + } + Ok(pin) +} diff --git a/src/config/vmess.rs b/src/config/vmess.rs new file mode 100644 index 0000000..8fd517d --- /dev/null +++ b/src/config/vmess.rs @@ -0,0 +1,527 @@ +use super::{ + ProxyProtocol, deserialize_present_option, invalid, parse_standard_uuid, validate_host, + validate_port, +}; +use crate::Result; +use serde::Deserialize; +use std::collections::BTreeMap; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Default)] +pub enum VmessPacketEncoding { + #[default] + #[serde(rename = "")] + Raw, + #[serde(rename = "xudp")] + Xudp, + #[serde(rename = "packetaddr")] + PacketAddr, +} + +#[derive(Clone, PartialEq, Eq)] +pub enum StreamTransport { + Tcp, + WebSocket { + uri: String, + headers: BTreeMap, + max_early_data: u16, + early_data_header_name: String, + }, + Grpc { + uri: String, + }, + Http { + method: String, + uris: Vec, + headers: BTreeMap>, + }, + H2 { + uris: Vec, + }, +} +impl std::fmt::Debug for StreamTransport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Tcp => "Tcp", + Self::WebSocket { .. } => "WebSocket", + Self::Grpc { .. } => "Grpc", + Self::Http { .. } => "Http", + Self::H2 { .. } => "H2", + }) + } +} +impl StreamTransport { + pub fn required_alpn(&self) -> Option<&'static [u8]> { + match self { + Self::WebSocket { .. } => Some(b"http/1.1"), + Self::Grpc { .. } | Self::H2 { .. } => Some(b"h2"), + _ => None, + } + } + #[cfg(feature = "stream-transport")] + pub fn websocket_options(&self) -> std::io::Result> { + let Self::WebSocket { + uri, + headers, + max_early_data, + early_data_header_name, + } = self + else { + return Ok(None); + }; + super::trojan::websocket_options(uri, headers, *max_early_data, early_data_header_name) + .map(Some) + } + #[cfg(feature = "stream-transport")] + pub fn http_options(&self) -> std::io::Result> { + let Self::Http { + method, + uris, + headers, + } = self + else { + return Ok(None); + }; + let choose = |values: &[String]| values[rand::random_range(0..values.len())].clone(); + http_options( + method, + &choose(uris), + headers.iter().map(|(key, values)| (key, choose(values))), + ) + .map(Some) + } +} + +#[cfg(feature = "stream-transport")] +fn http_options<'a>( + method: &str, + uri: &str, + headers: impl Iterator, +) -> std::io::Result { + let error = || std::io::Error::from(std::io::ErrorKind::InvalidInput); + let mut map = http::HeaderMap::new(); + for (name, value) in headers { + let name = http::HeaderName::from_bytes(name.as_bytes()).map_err(|_| error())?; + if map + .insert(name, value.parse().map_err(|_| error())?) + .is_some() + { + return Err(error()); + } + } + crate::transport::HttpObfsOptions::new(method.parse().map_err(|_| error())?, uri, map) +} + +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawWs { + #[serde(default, deserialize_with = "deserialize_present_option")] + pub(super) path: Option, + #[serde(default)] + pub(super) headers: BTreeMap, + #[serde(rename = "max-early-data", default)] + pub(super) max_early_data: u16, + #[serde( + rename = "early-data-header-name", + default, + deserialize_with = "deserialize_present_option" + )] + pub(super) early_data_header_name: Option, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawGrpc { + #[serde(rename = "grpc-service-name")] + pub(super) service: String, +} +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawHttp { + #[serde(default, deserialize_with = "deserialize_present_option")] + method: Option, + #[serde(default)] + path: Vec, + #[serde(default)] + headers: BTreeMap>, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawH2 { + host: Vec, + #[serde(default, deserialize_with = "deserialize_present_option")] + path: Option, +} + +fn uri(scheme: &str, authority: &str, path: &str) -> Result { + if !path.starts_with('/') || path.contains(['\r', '\n', '#']) { + return invalid("invalid stream transport path"); + } + let uri = http::Uri::builder() + .scheme(scheme) + .authority(authority) + .path_and_query(path) + .build() + .map_err(|_| crate::VCoreError::InvalidConfig("invalid stream transport URI".into()))?; + if uri.to_string().len() > 16 * 1024 - 256 { + return invalid("VMess transport URI exceeds limit"); + } + Ok(uri.to_string()) +} +fn authority_host(value: &str) -> Result { + let authority: http::uri::Authority = value + .parse() + .map_err(|_| crate::VCoreError::InvalidConfig("invalid VMess Host".into()))?; + let has_port = value.len() > authority.host().len(); + if value.contains('@') || (has_port && !authority.port_u16().is_some_and(|port| port > 0)) { + return invalid("invalid VMess Host"); + } + let host = authority + .host() + .trim_start_matches('[') + .trim_end_matches(']'); + validate_host(host, "VMess Host")?; + Ok(host.to_owned()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Default)] +pub enum VmessCipher { + #[default] + #[serde(rename = "auto")] + Auto, + #[serde(rename = "aes-128-gcm")] + Aes128Gcm, + #[serde(rename = "chacha20-poly1305")] + Chacha20Poly1305, + #[serde(rename = "none", alias = "zero")] + None, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct VmessOutboundConfig { + pub address: String, + pub port: u16, + pub id: uuid::Uuid, + pub cipher: VmessCipher, + pub global_padding: bool, + pub authenticated_length: bool, + pub packet_encoding: VmessPacketEncoding, + pub server_name: String, + pub tls: Option, + pub transport: StreamTransport, +} +impl std::fmt::Debug for VmessOutboundConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VmessOutboundConfig") + .finish_non_exhaustive() + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawVmess { + name: String, + server: String, + port: u16, + uuid: String, + #[serde(rename = "alterId", default)] + alter_id: u16, + #[serde(default)] + cipher: VmessCipher, + #[serde(default)] + udp: bool, + #[serde(default, deserialize_with = "deserialize_present_option")] + network: Option, + #[serde(default)] + tls: bool, + #[serde(default, deserialize_with = "deserialize_present_option")] + servername: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + alpn: Option>, + #[serde( + rename = "skip-cert-verify", + default, + deserialize_with = "deserialize_present_option" + )] + skip_cert_verify: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + fingerprint: Option, + #[serde( + rename = "client-fingerprint", + default, + deserialize_with = "deserialize_present_option" + )] + client_fingerprint: Option, + #[serde(rename = "global-padding", default)] + global_padding: bool, + #[serde(rename = "authenticated-length", default)] + authenticated_length: bool, + #[serde(rename = "packet-encoding", default)] + packet_encoding: VmessPacketEncoding, + #[serde( + rename = "ws-opts", + default, + deserialize_with = "deserialize_present_option" + )] + ws: Option, + #[serde( + rename = "grpc-opts", + default, + deserialize_with = "deserialize_present_option" + )] + grpc: Option, + #[serde( + rename = "http-opts", + default, + deserialize_with = "deserialize_present_option" + )] + http: Option, + #[serde( + rename = "h2-opts", + default, + deserialize_with = "deserialize_present_option" + )] + h2: Option, + #[serde( + rename = "dialer-proxy", + default, + deserialize_with = "deserialize_present_option" + )] + dialer_proxy: Option, +} +impl std::fmt::Debug for RawVmess { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RawVmess").finish_non_exhaustive() + } +} +impl RawVmess { + pub(super) fn normalize(self) -> Result<(String, Option, bool, ProxyProtocol)> { + if !cfg!(feature = "outbound-vmess") { + return invalid("VMess outbound support is disabled at build time"); + } + validate_host(&self.server, "VMess server")?; + validate_port(self.port, "VMess")?; + if self.alter_id != 0 { + return invalid("VMess requires AEAD alterId=0"); + } + if self.cipher == VmessCipher::None && (self.global_padding || self.authenticated_length) { + return invalid("VMess none cannot enable padding or authenticated length"); + } + if !self.tls + && (self.servername.is_some() + || self.alpn.is_some() + || self.skip_cert_verify.is_some() + || self.fingerprint.is_some() + || self.client_fingerprint.is_some()) + { + return invalid("VMess TLS options require tls=true"); + } + let (transport, fallback_name) = normalize_transport( + &self.server, + self.port, + self.tls, + self.network.as_deref().unwrap_or("tcp"), + self.ws, + self.grpc, + self.http, + self.h2, + )?; + let server_name = self.servername.unwrap_or(fallback_name); + validate_host(&server_name, "VMess servername")?; + let tls = if self.tls { + let alpn: Vec> = self + .alpn + .unwrap_or_else(|| { + transport + .required_alpn() + .map(|p| vec![String::from_utf8(p.to_vec()).unwrap()]) + .unwrap_or_default() + }) + .into_iter() + .map(String::into_bytes) + .collect(); + if alpn.iter().any(|p| p.is_empty() || p.len() > 255) + || alpn.iter().map(|p| p.len() + 1).sum::() > 65533 + || transport + .required_alpn() + .is_some_and(|required| !alpn.iter().any(|p| p == required)) + { + return invalid("invalid VMess ALPN"); + } + let fingerprint = self + .fingerprint + .map(|value| { + let hex: Vec<_> = value.trim().bytes().filter(|b| *b != b':').collect(); + if hex.len() != 64 || !hex.iter().all(u8::is_ascii_hexdigit) { + return invalid("invalid VMess certificate fingerprint"); + } + let mut pin = [0; 32]; + for (byte, pair) in pin.iter_mut().zip(hex.as_chunks::<2>().0) { + *byte = ((pair[0] as char).to_digit(16).unwrap() * 16 + + (pair[1] as char).to_digit(16).unwrap()) + as u8; + } + Ok(pin) + }) + .transpose()?; + Some(super::AnyTlsCertificatePolicy { + client_fingerprint: super::parse_client_fingerprint( + self.client_fingerprint.as_deref(), + )?, + alpn, + fingerprint, + skip_cert_verify: self.skip_cert_verify.unwrap_or(false), + }) + } else { + None + }; + Ok(( + self.name, + self.dialer_proxy, + self.udp, + ProxyProtocol::Vmess(VmessOutboundConfig { + address: self.server, + port: self.port, + id: parse_standard_uuid(&self.uuid)?, + cipher: self.cipher, + global_padding: self.global_padding, + authenticated_length: self.authenticated_length, + packet_encoding: self.packet_encoding, + server_name, + tls, + transport, + }), + )) + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn normalize_transport( + server: &str, + port: u16, + tls: bool, + network: &str, + ws: Option, + grpc: Option, + http: Option, + h2: Option, +) -> Result<(StreamTransport, String)> { + let authority = if server.parse::().is_ok() { + format!("[{}]:{}", server, port) + } else { + format!("{}:{}", server, port) + }; + let scheme = if tls { "https" } else { "http" }; + let mut fallback_name = server.to_owned(); + let transport = match (network, ws, grpc, http, h2) { + ("tcp", None, None, None, None) => StreamTransport::Tcp, + ("ws", ws, None, None, None) => { + let ws = ws.unwrap_or_default(); + if ws.max_early_data > 2048 + || (ws.max_early_data == 0 && ws.early_data_header_name.is_some()) + { + return invalid("invalid VMess early-data options"); + } + for (key, value) in &ws.headers { + if key.eq_ignore_ascii_case("host") { + fallback_name = authority_host(value)?; + } + } + let transport = StreamTransport::WebSocket { + uri: uri( + if tls { "wss" } else { "ws" }, + &authority, + ws.path.as_deref().unwrap_or("/"), + )?, + headers: ws.headers, + max_early_data: ws.max_early_data, + early_data_header_name: ws + .early_data_header_name + .unwrap_or_else(|| "Sec-WebSocket-Protocol".into()), + }; + #[cfg(feature = "stream-transport")] + if transport.websocket_options().is_err() { + return invalid("invalid VMess WebSocket options"); + } + transport + } + ("grpc", None, Some(grpc), None, None) if !grpc.service.is_empty() => { + let path = if grpc.service.starts_with('/') { + grpc.service + } else { + format!("/{}/Tun", grpc.service) + }; + if path.contains('?') { + return invalid("invalid VMess gRPC path"); + } + StreamTransport::Grpc { + uri: uri(scheme, &authority, &path)?, + } + } + ("http", None, None, http, None) => { + let mut http = http.unwrap_or_default(); + if http.path.is_empty() { + http.path.push("/".into()); + } + let method = http.method.unwrap_or_else(|| "GET".into()); + if method.parse::().is_err() || http.headers.values().any(Vec::is_empty) { + return invalid("invalid HTTP transport options"); + } + for (key, values) in &http.headers { + let name: http::HeaderName = key.parse().map_err(|_| { + crate::VCoreError::InvalidConfig("invalid HTTP transport header".into()) + })?; + for value in values { + if value.parse::().is_err() { + return invalid("invalid HTTP transport value"); + } + if name == "host" { + authority_host(value)?; + } + } + } + let uris = http + .path + .iter() + .map(|path| { + if !path.starts_with('/') || path.contains(['\r', '\n']) { + return invalid("invalid HTTP transport path"); + } + let mut url = + url::Url::parse(&format!("{scheme}://{authority}/")).map_err(|_| { + crate::VCoreError::InvalidConfig("invalid HTTP transport URI".into()) + })?; + url.set_path(path); + Ok(url.to_string()) + }) + .collect::>>()?; + #[cfg(feature = "stream-transport")] + for uri in &uris { + if http_options( + &method, + uri, + http.headers.iter().map(|(key, values)| { + (key, values.iter().max_by_key(|v| v.len()).unwrap().clone()) + }), + ) + .is_err() + { + return invalid("invalid HTTP transport options"); + } + } + StreamTransport::Http { + method, + uris, + headers: http.headers, + } + } + ("h2", None, None, None, Some(h2)) if !h2.host.is_empty() => { + let mut uris = Vec::new(); + for host in h2.host { + authority_host(&host)?; + uris.push(uri(scheme, &host, h2.path.as_deref().unwrap_or("/"))?); + } + StreamTransport::H2 { uris } + } + _ => return invalid("stream transport options do not match network"), + }; + Ok((transport, fallback_name)) +} diff --git a/src/config/xhttp.rs b/src/config/xhttp.rs new file mode 100644 index 0000000..244f387 --- /dev/null +++ b/src/config/xhttp.rs @@ -0,0 +1,668 @@ +//! Validated, bounded XHTTP request options shared by configuration and transport. +use std::collections::BTreeMap; + +use http::{HeaderMap, HeaderName, HeaderValue}; + +use super::{Result, invalid}; + +pub const MAX_CUSTOM_HEADER_BYTES: usize = crate::limits::XHTTP_CUSTOM_HEADER_BYTES; +pub const MAX_CUSTOM_HEADERS: usize = crate::limits::XHTTP_CUSTOM_HEADERS; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum XHttpVersion { + Http1, + #[default] + Http2, + Http3, +} + +impl XHttpVersion { + pub(crate) fn from_alpn(protocols: &[String]) -> Result { + if protocols == ["h3"] { + return Ok(Self::Http3); + } + if protocols + .iter() + .any(|p| !matches!(p.as_str(), "http/1.1" | "h2")) + { + return invalid("XHTTP ALPN must select a supported HTTP version"); + } + Ok(if protocols == ["http/1.1"] { + Self::Http1 + } else { + Self::Http2 + }) + } + pub(crate) fn alpn(self) -> &'static [u8] { + match self { + Self::Http1 => b"http/1.1", + Self::Http2 => b"h2", + Self::Http3 => b"h3", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct XHttpRequestOptions { + pub no_grpc_header: bool, + pub(crate) padding: XHttpPadding, + pub(crate) uplink_method: http::Method, + pub(crate) session: MetaPlacement, + pub(crate) sequence: MetaPlacement, + pub(crate) session_id: SessionId, + pub(crate) data: DataPlacement, + pub(crate) post_bytes: IntegerRange, + pub(crate) post_interval_ms: IntegerRange, +} + +impl Default for XHttpRequestOptions { + fn default() -> Self { + Self { + no_grpc_header: false, + padding: Default::default(), + uplink_method: http::Method::POST, + session: MetaPlacement::Path, + sequence: MetaPlacement::Path, + session_id: SessionId::Hex, + data: DataPlacement::Body, + post_bytes: IntegerRange { + min: 1_000_000, + max: 1_000_000, + }, + post_interval_ms: IntegerRange { min: 30, max: 30 }, + } + } +} + +impl XHttpRequestOptions { + pub(super) fn normalize( + raw: &super::RawXHttpSettings, + headers: &XHttpHeaders, + mode: super::XHttpMode, + ) -> Result { + if mode == super::XHttpMode::PacketUp && raw.no_grpc_header { + return invalid("xhttp-opts.no-grpc-header is only effective for streaming modes"); + } + if mode != super::XHttpMode::PacketUp + && !raw.no_grpc_header + && headers.as_map().contains_key(http::header::CONTENT_TYPE) + { + return invalid("XHTTP generated content type conflicts with custom headers"); + } + if mode != super::XHttpMode::PacketUp + && (raw.seq_key.is_some() || raw.seq_placement.is_some()) + { + return invalid("XHTTP sequence overrides require packet-up"); + } + let data = DataPlacement::normalize(raw)?; + if mode != super::XHttpMode::PacketUp + && (data != DataPlacement::Body + || raw.sc_max_each_post_bytes.is_some() + || raw.sc_min_posts_interval_ms.is_some()) + { + return invalid("XHTTP packet upload fields require packet-up"); + } + let post_bytes = IntegerRange::parse( + raw.sc_max_each_post_bytes.as_deref().unwrap_or("1000000"), + 1, + crate::limits::XHTTP_POST_BYTES, + )?; + let post_interval_ms = IntegerRange::parse( + raw.sc_min_posts_interval_ms.as_deref().unwrap_or("30"), + 0, + 60_000, + )?; + if post_interval_ms.max == 0 { + return invalid("XHTTP post interval must have a positive maximum"); + } + let uplink_method = match raw.uplink_http_method.as_deref().unwrap_or("POST") { + "POST" => http::Method::POST, + "PUT" => http::Method::PUT, + "PATCH" => http::Method::PATCH, + "DELETE" => http::Method::DELETE, + _ => return invalid("unsupported XHTTP uplink method"), + }; + let options = Self { + no_grpc_header: raw.no_grpc_header, + padding: XHttpPadding::normalize(raw, headers)?, + uplink_method, + session: MetaPlacement::normalize( + raw.session_placement.as_deref(), + raw.session_key.as_deref(), + "X-Session", + "x_session", + )?, + sequence: MetaPlacement::normalize( + raw.seq_placement.as_deref(), + raw.seq_key.as_deref(), + "X-Seq", + "x_seq", + )?, + session_id: SessionId::normalize( + raw.session_table.as_deref().unwrap_or(""), + raw.session_length.as_deref(), + )?, + data, + post_bytes, + post_interval_ms, + }; + options.validate_headers(headers, &raw.path)?; + Ok(options) + } + + pub(super) fn validate_headers(&self, headers: &XHttpHeaders, path: &str) -> Result<()> { + let padding_conflict = match self.padding.placement { + PaddingPlacement::Header | PaddingPlacement::QueryInHeader => { + headers.as_map().contains_key(&self.padding.header) + } + PaddingPlacement::Cookie => headers.as_map().contains_key(http::header::COOKIE), + PaddingPlacement::Query => path.split_once('?').is_some_and(|(_, q)| { + url::form_urlencoded::parse(q.as_bytes()).any(|(key, _)| key == self.padding.key) + }), + }; + if padding_conflict { + return invalid("XHTTP padding conflicts with configured headers or query"); + } + if self.session != MetaPlacement::Path && self.session == self.sequence { + return invalid("XHTTP session and sequence keys must not collide"); + } + for meta in [&self.session, &self.sequence] { + let padding_conflict = match meta { + MetaPlacement::Header(name) => { + if headers.as_map().contains_key(name) { + return invalid("XHTTP metadata conflicts with custom headers"); + } + matches!( + self.padding.placement, + PaddingPlacement::Header | PaddingPlacement::QueryInHeader + ) && self.padding.header == *name + } + MetaPlacement::Query(key) => { + if path.split_once('?').is_some_and(|(_, q)| { + url::form_urlencoded::parse(q.as_bytes()).any(|(k, _)| k == key.as_str()) + }) { + return invalid("XHTTP metadata conflicts with the configured query"); + } + self.padding.placement == PaddingPlacement::Query && self.padding.key == *key + } + MetaPlacement::Cookie(key) => { + if headers.as_map().contains_key(http::header::COOKIE) { + return invalid( + "XHTTP cookie metadata conflicts with a custom Cookie header", + ); + } + self.padding.placement == PaddingPlacement::Cookie && self.padding.key == *key + } + MetaPlacement::Path => false, + }; + if padding_conflict { + return invalid("XHTTP metadata conflicts with padding"); + } + } + if let DataPlacement::Chunks { key, cookie, .. } = &self.data { + let prefix = format!("{key}{}", if *cookie { '_' } else { '-' }); + for meta in [&self.session, &self.sequence] { + let collides = match meta { + MetaPlacement::Header(name) if !cookie => { + name.as_str().starts_with(&prefix.to_ascii_lowercase()) + } + MetaPlacement::Cookie(name) if *cookie => name.starts_with(&prefix), + _ => false, + }; + if collides { + return invalid("XHTTP payload key conflicts with metadata"); + } + } + if *cookie && headers.as_map().contains_key(http::header::COOKIE) + || !cookie + && headers + .as_map() + .keys() + .any(|name| name.as_str().starts_with(&prefix.to_ascii_lowercase())) + || *cookie + && self.padding.placement == PaddingPlacement::Cookie + && self.padding.key.starts_with(&prefix) + || !cookie + && matches!( + self.padding.placement, + PaddingPlacement::Header | PaddingPlacement::QueryInHeader + ) + && self + .padding + .header + .as_str() + .starts_with(&prefix.to_ascii_lowercase()) + { + return invalid("XHTTP payload key conflicts with request headers or padding"); + } + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum DataPlacement { + Body, + Chunks { + key: String, + cookie: bool, + size: IntegerRange, + }, +} + +impl DataPlacement { + fn normalize(raw: &super::RawXHttpSettings) -> Result { + let placement = raw.uplink_data_placement.as_deref().unwrap_or("body"); + if matches!(placement, "body" | "auto") { + if raw.uplink_data_key.is_some() || raw.uplink_chunk_size.is_some() { + return invalid( + "XHTTP body upload does not use payload keys or header chunk sizes", + ); + } + return Ok(Self::Body); + } + if !matches!(placement, "header" | "cookie") { + return invalid("unsupported XHTTP payload placement"); + } + let Some(key) = &raw.uplink_data_key else { + return invalid("XHTTP header/cookie upload requires a payload key"); + }; + validate_key(key)?; + let mut size = + IntegerRange::parse(raw.uplink_chunk_size.as_deref().unwrap_or("0"), 0, 8192)?; + if size.max == 0 { + size = if placement == "cookie" { + IntegerRange { + min: 2048, + max: 3072, + } + } else { + IntegerRange { + min: 3072, + max: 4096, + } + }; + } + if size.min < 64 { + return invalid("XHTTP explicit header/cookie chunk size must be at least 64"); + } + Ok(Self::Chunks { + key: key.clone(), + cookie: placement == "cookie", + size, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum MetaPlacement { + Path, + Query(String), + Header(HeaderName), + Cookie(String), +} + +impl MetaPlacement { + fn normalize( + placement: Option<&str>, + key: Option<&str>, + header_default: &str, + other_default: &str, + ) -> Result { + match placement.unwrap_or("path") { + "path" if key.is_none() => Ok(Self::Path), + "query" | "cookie" => { + let key = key.unwrap_or(other_default); + validate_key(key)?; + Ok(if placement == Some("query") { + Self::Query(key.into()) + } else { + Self::Cookie(key.into()) + }) + } + "header" => { + let key = key.unwrap_or(header_default); + validate_key(key)?; + let Ok(key) = key.parse::() else { + return invalid("invalid XHTTP metadata header"); + }; + if reserved_header(&key) || key == http::header::COOKIE { + return invalid("reserved XHTTP metadata header"); + } + Ok(Self::Header(key)) + } + _ => invalid("invalid XHTTP metadata placement or unused key"), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SessionId { + Hex, + Uuid, + Alphabet { + bytes: Vec, + length: IntegerRange, + }, +} + +impl SessionId { + fn normalize(table: &str, length: Option<&str>) -> Result { + if table.is_empty() || table == "uuid" { + if length.is_some() { + return invalid("XHTTP session-length requires a character table"); + } + return Ok(if table.is_empty() { + Self::Hex + } else { + Self::Uuid + }); + } + let table = match table { + "ALPHABET" => "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "Alphabet" => "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", + "BASE36" => "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "Base62" => "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", + "HEX" => "0123456789ABCDEF", + "alphabet" => "abcdefghijklmnopqrstuvwxyz", + "base36" => "0123456789abcdefghijklmnopqrstuvwxyz", + "hex" => "0123456789abcdef", + "number" => "0123456789", + other => other, + }; + validate_key(table)?; + let mut bytes = Vec::new(); + for byte in table.bytes() { + if !bytes.contains(&byte) { + bytes.push(byte); + } + } + let length = IntegerRange::parse(length.unwrap_or("16-32"), 1, 128)?; + let mut possibilities = 0u64; + for n in length.min..=length.max { + possibilities = + possibilities.saturating_add((bytes.len() as u64).saturating_pow(n as u32)); + } + if possibilities < 1 << 31 { + return invalid("XHTTP session ID namespace must contain at least 2^31 values"); + } + Ok(Self::Alphabet { bytes, length }) + } + + pub(crate) fn generate(&self) -> String { + use rand::RngExt as _; + match self { + Self::Hex => uuid::Uuid::from_bytes(rand::random()).simple().to_string(), + Self::Uuid => { + let mut bytes: [u8; 16] = rand::random(); + bytes[6] = (bytes[6] & 15) | 64; + bytes[8] = (bytes[8] & 63) | 128; + uuid::Uuid::from_bytes(bytes).hyphenated().to_string() + } + Self::Alphabet { bytes, length } => { + let mut rng = rand::rng(); + (0..length.sample()) + .map(|_| char::from(bytes[rng.random_range(0..bytes.len())])) + .collect() + } + } + } +} + +pub const MAX_PADDING_BYTES: usize = crate::limits::XHTTP_PADDING_BYTES; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct IntegerRange { + pub min: usize, + pub max: usize, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct XHttpReuseConfig { + pub(crate) concurrency: IntegerRange, + pub(crate) connections: IntegerRange, + pub(crate) reuse_times: IntegerRange, + pub(crate) requests: IntegerRange, + pub(crate) age_seconds: IntegerRange, + pub(crate) keep_alive_seconds: i32, +} + +#[derive(Debug, Default, serde::Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub(super) struct RawReuseConfig { + #[serde(default, deserialize_with = "super::deserialize_present_option")] + max_concurrency: Option, + #[serde(default, deserialize_with = "super::deserialize_present_option")] + max_connections: Option, + #[serde(default, deserialize_with = "super::deserialize_present_option")] + c_max_reuse_times: Option, + #[serde(default, deserialize_with = "super::deserialize_present_option")] + h_max_request_times: Option, + #[serde(default, deserialize_with = "super::deserialize_present_option")] + h_max_reusable_secs: Option, + #[serde(default)] + h_keep_alive_period: i32, +} + +impl RawReuseConfig { + pub(super) fn normalize(self, version: XHttpVersion) -> Result { + let range = |value: Option| { + IntegerRange::parse(value.as_deref().unwrap_or("0"), 0, i32::MAX as usize) + }; + if version == XHttpVersion::Http1 && self.h_keep_alive_period != 0 { + return invalid("XHTTP HTTP/1 does not support a keepalive period override"); + } + Ok(XHttpReuseConfig { + concurrency: range(self.max_concurrency)?, + connections: range(self.max_connections)?, + reuse_times: range(self.c_max_reuse_times)?, + requests: range(self.h_max_request_times)?, + age_seconds: range(self.h_max_reusable_secs)?, + keep_alive_seconds: self.h_keep_alive_period, + }) + } +} + +impl IntegerRange { + pub(super) fn parse(value: &str, minimum: usize, maximum: usize) -> Result { + let (left, right) = value.split_once('-').unwrap_or((value, value)); + if [left, right] + .iter() + .any(|part| part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit())) + { + return invalid("XHTTP range must be decimal N or N-M"); + } + let (Ok(min), Ok(max)) = (left.parse::(), right.parse::()) else { + return invalid("XHTTP range exceeds its numeric limit"); + }; + if min < minimum || max < min || max > maximum { + return invalid("XHTTP range is outside its field bounds"); + } + Ok(Self { min, max }) + } + pub(crate) fn sample(self) -> usize { + use rand::RngExt as _; + rand::rng().random_range(self.min..=self.max) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PaddingPlacement { + QueryInHeader, + Header, + Query, + Cookie, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct XHttpPadding { + pub bytes: IntegerRange, + pub placement: PaddingPlacement, + pub key: String, + pub header: HeaderName, + pub tokenish: bool, +} + +impl Default for XHttpPadding { + fn default() -> Self { + Self { + bytes: IntegerRange { + min: 100, + max: 1000, + }, + placement: PaddingPlacement::QueryInHeader, + key: "x_padding".into(), + header: http::header::REFERER, + tokenish: false, + } + } +} + +impl XHttpPadding { + pub(super) fn normalize(raw: &super::RawXHttpSettings, headers: &XHttpHeaders) -> Result { + let mut padding = Self::default(); + if let Some(bytes) = &raw.x_padding_bytes { + padding.bytes = IntegerRange::parse(bytes, 1, MAX_PADDING_BYTES)?; + } + if !raw.x_padding_obfs_mode { + if raw + .x_padding_key + .as_deref() + .is_some_and(|value| value != "x_padding") + || raw + .x_padding_header + .as_deref() + .is_some_and(|value| !value.eq_ignore_ascii_case("referer")) + || raw + .x_padding_placement + .as_deref() + .is_some_and(|value| value != "queryInHeader") + || raw + .x_padding_method + .as_deref() + .is_some_and(|value| value != "repeat-x") + { + return invalid( + "XHTTP non-obfuscated padding requires its default placement and method", + ); + } + return Ok(padding); + } + padding.placement = match raw.x_padding_placement.as_deref() { + Some("queryInHeader") => PaddingPlacement::QueryInHeader, + Some("header") => PaddingPlacement::Header, + Some("query") => PaddingPlacement::Query, + Some("cookie") => PaddingPlacement::Cookie, + _ => return invalid("XHTTP obfuscated padding requires a supported placement"), + }; + padding.tokenish = match raw.x_padding_method.as_deref().unwrap_or("repeat-x") { + "repeat-x" => false, + "tokenish" => true, + _ => return invalid("unsupported XHTTP padding method"), + }; + if padding.placement == PaddingPlacement::Header { + if raw.x_padding_key.is_some() { + return invalid("XHTTP header padding does not use a key"); + } + } else { + let Some(key) = &raw.x_padding_key else { + return invalid("XHTTP padding requires a key"); + }; + validate_key(key)?; + padding.key = key.clone(); + } + if matches!( + padding.placement, + PaddingPlacement::Header | PaddingPlacement::QueryInHeader + ) { + let Some(header) = &raw.x_padding_header else { + return invalid("XHTTP padding requires a header name"); + }; + let Ok(header) = header.parse::() else { + return invalid("invalid XHTTP padding header name"); + }; + if reserved_header(&header) && header != http::header::REFERER + || header == http::header::COOKIE + || headers.as_map().contains_key(&header) + { + return invalid("XHTTP padding header conflicts with another request field"); + } + padding.header = header; + } else if raw.x_padding_header.is_some() { + return invalid("XHTTP padding placement does not use a header name"); + } + Ok(padding) + } +} + +pub(super) fn validate_key(key: &str) -> Result<()> { + if key.is_empty() + || key.len() > 64 + || !key + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~')) + { + return invalid("XHTTP placement key must be a bounded HTTP-safe token"); + } + Ok(()) +} + +#[derive(Clone, Default, PartialEq, Eq)] +pub struct XHttpHeaders(HeaderMap); + +impl std::fmt::Debug for XHttpHeaders { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("XHttpHeaders") + .field("count", &self.0.len()) + .finish() + } +} + +impl XHttpHeaders { + pub fn as_map(&self) -> &HeaderMap { + &self.0 + } + + pub(super) fn normalize(raw: BTreeMap) -> Result { + let mut headers = HeaderMap::new(); + let mut bytes = 0usize; + for (name, value) in raw { + let (Ok(name), Ok(mut value)) = + (name.parse::(), value.parse::()) + else { + return invalid("invalid XHTTP request header"); + }; + if headers.contains_key(&name) + || reserved_header(&name) + && name != http::header::CONTENT_TYPE + && name != http::header::REFERER + { + return invalid("duplicate or reserved XHTTP request header"); + } + bytes += name.as_str().len() + value.len() + 4; + if bytes > MAX_CUSTOM_HEADER_BYTES || headers.len() >= MAX_CUSTOM_HEADERS { + return invalid("XHTTP custom headers exceed their byte or field limit"); + } + value.set_sensitive(true); + headers.insert(name, value); + } + Ok(Self(headers)) + } +} + +pub(super) fn reserved_header(name: &HeaderName) -> bool { + matches!( + name.as_str(), + "host" + | "connection" + | "content-length" + | "transfer-encoding" + | "upgrade" + | "trailer" + | "te" + | "keep-alive" + | "proxy-connection" + | "content-type" + | "referer" + ) +} diff --git a/src/controller.rs b/src/controller.rs index 47f8600..b3070ef 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -78,7 +78,7 @@ impl RuntimeController { let traffic = self.traffic.clone(); let proxy_groups = self.proxy_groups.clone(); let child = cancellation.clone(); - tasks.spawn(async move { + tasks.spawn(crate::resources::observation::task(async move { if let Err(error) = handle_connection( stream, peer, @@ -94,7 +94,7 @@ impl RuntimeController { "controller request failed" ); } - }); + })); } } } diff --git a/src/dialer.rs b/src/dialer.rs index 5034e38..796e01e 100644 --- a/src/dialer.rs +++ b/src/dialer.rs @@ -33,6 +33,10 @@ use windows::Win32::Networking::WinSock::{ }; use crate::limits::{DNS_WORKER_STACK_BYTES, MAX_DNS_WORKERS}; +use crate::resources::observation::{ObservedIo, ResourceKind, track}; + +pub type PhysicalStream = ObservedIo; +pub type PhysicalDatagram = ObservedIo; const MAX_RESOLVED_ADDRESSES: usize = 8; @@ -343,7 +347,7 @@ impl Dialer { self } - pub async fn connect(&self, endpoint: &ResolvedEndpoint) -> io::Result { + pub async fn connect(&self, endpoint: &ResolvedEndpoint) -> io::Result { let mut last_error = None; for address in &endpoint.addresses { if !self.ipv6 && address.is_ipv6() { @@ -368,13 +372,13 @@ impl Dialer { /// Connects a direct TCP socket while applying the same platform protect /// hook and timeout used for the proxy-server dial path. - pub async fn connect_address(&self, address: SocketAddr) -> io::Result { + pub async fn connect_address(&self, address: SocketAddr) -> io::Result { self.connect_one(address).await } /// Creates a direct UDP socket for one destination and applies the /// platform protect or binding policy before exposing it to the caller. - pub async fn bind_udp_for(&self, destination: SocketAddr) -> io::Result { + pub async fn bind_udp_for(&self, destination: SocketAddr) -> io::Result { self.require_permitted(destination)?; let ipv6 = destination.is_ipv6(); let bind_address = self @@ -405,7 +409,7 @@ impl Dialer { "socket protection is only supported on Unix platforms", )); } - Ok(socket) + Ok(ObservedIo::new(socket, ResourceKind::Socket)) } fn source_address_for(&self, destination: SocketAddr) -> io::Result> { @@ -493,7 +497,7 @@ impl Dialer { Ok(()) } - async fn connect_one(&self, address: SocketAddr) -> io::Result { + async fn connect_one(&self, address: SocketAddr) -> io::Result { self.require_permitted(address)?; let ipv6 = address.is_ipv6(); let socket = if ipv6 { @@ -501,6 +505,7 @@ impl Dialer { } else { TcpSocket::new_v4()? }; + let guard = track(ResourceKind::Socket); #[cfg(unix)] if let Some(protector) = &self.protector { protector.protect(socket.as_raw_fd())?; @@ -521,7 +526,7 @@ impl Dialer { .await .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "connect timed out"))??; stream.set_nodelay(true)?; - Ok(stream) + Ok(ObservedIo::with_guard(stream, guard)) } fn require_permitted(&self, address: SocketAddr) -> io::Result<()> { diff --git a/src/dispatch.rs b/src/dispatch.rs index 49f1e7b..86d1d7c 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -72,6 +72,14 @@ impl From for DispatchError { #[async_trait] pub trait DatagramTransport: Send { + /// Payload budget for packets sent to and received from this logical peer. + /// Encapsulating transports subtract their headers from the upstream cap. + /// The default is the u16 wire ceiling; narrower paths must override it. + /// This never resolves a domain or opens IO. + fn payload_budget(&self, _peer: &crate::session::Destination) -> DatagramBudget { + DatagramBudget::new(u16::MAX, u16::MAX) + } + async fn send(&mut self, datagram: Datagram) -> Result<(), DispatchError>; /// Receives one complete datagram. @@ -229,6 +237,10 @@ struct ObservedDatagramTransport { #[async_trait] impl DatagramTransport for ObservedDatagramTransport { + fn payload_budget(&self, peer: &crate::session::Destination) -> DatagramBudget { + self.inner.payload_budget(peer) + } + async fn send(&mut self, datagram: Datagram) -> Result<(), DispatchError> { self.inner.send(datagram).await } @@ -413,3 +425,6 @@ mod tests { assert_eq!(stats.snapshot().handshake_current, 0); } } +mod datagram; +pub(crate) use datagram::bounded as bound_datagram; +pub use datagram::{DatagramBudget, QUIC_MIN_PAYLOAD_BYTES}; diff --git a/src/dispatch/datagram.rs b/src/dispatch/datagram.rs new file mode 100644 index 0000000..760e309 --- /dev/null +++ b/src/dispatch/datagram.rs @@ -0,0 +1,134 @@ +use std::io; + +use super::{DatagramTransport, DispatchError}; +use crate::packet::IpVersion; +use crate::session::{Datagram, Destination}; + +pub const QUIC_MIN_PAYLOAD_BYTES: u16 = 1200; + +/// Directional payload ceilings at one datagram seam, excluding its envelope. +/// Zero means no nonempty payload fits, not permission to remove a bound. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DatagramBudget { + transmit: u16, + receive: u16, +} + +impl DatagramBudget { + pub const fn new(transmit: u16, receive: u16) -> Self { + Self { transmit, receive } + } + pub const fn transmit(self) -> u16 { + self.transmit + } + pub const fn receive(self) -> u16 { + self.receive + } + + pub fn from_path_mtu(mtu: u16, version: IpVersion) -> io::Result { + let overhead = match version { + IpVersion::V4 => 28, + IpVersion::V6 => 48, + }; + let payload = mtu + .checked_sub(overhead) + .filter(|value| *value > 0) + .ok_or_else(invalid)?; + Ok(Self::new(payload, payload)) + } + + pub fn intersect(self, other: Self) -> Self { + Self::new( + self.transmit.min(other.transmit), + self.receive.min(other.receive), + ) + } + + pub fn subtract_overhead(self, transmit: usize, receive: usize) -> Self { + Self::new( + usize::from(self.transmit).saturating_sub(transmit) as u16, + usize::from(self.receive).saturating_sub(receive) as u16, + ) + } + + pub fn quic_payload_limit(self) -> io::Result { + let payload = self.transmit.min(self.receive); + if payload < QUIC_MIN_PAYLOAD_BYTES { + return Err(invalid()); + } + Ok(payload) + } +} + +fn invalid() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + "datagram path budget is insufficient", + ) +} + +/// Enforce the caller's limit outside a codec, and retain all lower-layer caps. +pub(crate) fn bounded( + inner: Box, + budget: DatagramBudget, +) -> Box { + Box::new(Bounded { + inner, + budget, + closing: false, + closed: false, + observation: Some(crate::resources::observation::track( + crate::resources::observation::ResourceKind::Association, + )), + }) +} + +struct Bounded { + inner: Box, + budget: DatagramBudget, + closing: bool, + closed: bool, + observation: Option, +} + +#[async_trait::async_trait] +impl DatagramTransport for Bounded { + fn payload_budget(&self, peer: &Destination) -> DatagramBudget { + self.budget.intersect(self.inner.payload_budget(peer)) + } + async fn send(&mut self, datagram: Datagram) -> Result<(), DispatchError> { + if self.closing { + return Err(DispatchError::NotAllowed); + } + if datagram.payload.len() > usize::from(self.payload_budget(&datagram.remote).transmit()) { + return Err(invalid().into()); + } + self.inner.send(datagram).await + } + async fn receive(&mut self) -> Result { + if self.closing { + return Err(DispatchError::NotAllowed); + } + loop { + for _ in 0..crate::limits::IO_POLL_BUDGET { + let datagram = self.inner.receive().await?; + if datagram.payload.len() + <= usize::from(self.payload_budget(&datagram.remote).receive()) + { + return Ok(datagram); + } + } + tokio::task::yield_now().await; + } + } + async fn close(&mut self) -> Result<(), DispatchError> { + if self.closed { + return Ok(()); + } + self.closing = true; + self.inner.close().await?; + self.closed = true; + self.observation.take(); + Ok(()) + } +} diff --git a/src/dns/mod.rs b/src/dns/mod.rs index d2c05d3..132b039 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -5,6 +5,7 @@ //! redir-host table is bounded by the constants below, while concurrent query //! activity is observed rather than rejected by a fixed local admission cap. +pub mod resolution; pub mod runtime; use std::{ diff --git a/src/dns/resolution.rs b/src/dns/resolution.rs new file mode 100644 index 0000000..a76e502 --- /dev/null +++ b/src/dns/resolution.rs @@ -0,0 +1,192 @@ +//! IP-only protocol boundaries use a runtime-owned resolver, never an implicit +//! system lookup. Endpoint/ECH bootstrap remains a separate prepare operation. + +use super::{canonicalize_name, runtime::RuntimeDns}; +use crate::{dialer::Resolver, dispatch::DispatchError, session::Destination}; +use std::{ + io, + net::SocketAddr, + sync::{Arc, OnceLock, Weak}, +}; +use tokio::time::{Instant, timeout_at}; +use tokio_util::sync::CancellationToken; + +/// Per-lookup dependency depth, not a concurrent connection admission limit. +pub const MAX_RESOLUTION_DEPTH: usize = 32; + +enum Source { + Runtime(Option>), + Measurement(Arc), +} + +struct State { + source: OnceLock, + ipv6: bool, + cancel: CancellationToken, +} + +/// Clones share only one runtime's policy and cancellation. The runtime DNS +/// link is weak, so DNS -> dispatcher -> connector cannot retain its owner. +/// Default contexts permit literal IPs but have no domain resolver. +#[derive(Clone, Default)] +pub struct ResolutionContext { + inner: Option>, +} + +impl std::fmt::Debug for ResolutionContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResolutionContext").finish_non_exhaustive() + } +} + +#[derive(Clone)] +struct ActiveResolution { + deadline: Instant, + names: Vec<(usize, String)>, +} + +tokio::task_local! { + static ACTIVE: ActiveResolution; +} + +pub(crate) fn inherited_deadline(deadline: Instant) -> Instant { + ACTIVE + .try_with(|active| active.deadline.min(deadline)) + .unwrap_or(deadline) +} + +impl ResolutionContext { + pub fn runtime(ipv6: bool) -> Self { + Self { + inner: Some(Arc::new(State { + source: OnceLock::new(), + ipv6, + cancel: CancellationToken::new(), + })), + } + } + + /// Called once, after graph construction has made the DNS egress available. + pub fn bind_runtime(&self, dns: Option<&Arc>) -> io::Result<()> { + self.inner + .as_ref() + .ok_or(io::ErrorKind::InvalidInput)? + .source + .set(Source::Runtime(dns.map(Arc::downgrade))) + .map_err(|_| io::ErrorKind::AlreadyExists.into()) + } + + /// Private measurement lifetime: no Running Session, RuntimeDns, listener, + /// or system-fallback path is constructed by this context. + pub fn measurement(bootstrap: Arc, ipv6: bool) -> Self { + let context = Self::runtime(ipv6); + let _ = context + .inner + .as_ref() + .unwrap() + .source + .set(Source::Measurement(bootstrap)); + context + } + + pub fn close(&self) { + if let Some(inner) = &self.inner { + inner.cancel.cancel(); + } + } + + /// Resolve only when the consuming protocol requires an IP. This does not + /// mutate the logical destination used for HTTP Host, TLS SNI or routing. + pub async fn resolve_ip( + &self, + target: &Destination, + deadline: Instant, + ) -> Result { + let deadline = inherited_deadline(deadline); + if deadline <= Instant::now() { + return Err(DispatchError::TimedOut); + } + if self + .inner + .as_ref() + .is_some_and(|inner| inner.cancel.is_cancelled()) + { + return Err(DispatchError::NotAllowed); + } + if let Destination::Ip(address) = target { + if address.port() == 0 + || (address.is_ipv6() && self.inner.as_ref().is_some_and(|inner| !inner.ipv6)) + { + return Err(DispatchError::HostUnreachable); + } + return Ok(*address); + } + let Destination::Domain { host, port } = target else { + unreachable!() + }; + let host = canonicalize_name(host).map_err(|_| DispatchError::HostUnreachable)?; + let inner = self.inner.as_ref().ok_or_else(unavailable)?; + let source = inner.source.get().ok_or_else(unavailable)?; + let key = (Arc::as_ptr(inner) as usize, host.clone()); + let mut active = ACTIVE.try_with(Clone::clone).unwrap_or(ActiveResolution { + deadline, + names: Vec::new(), + }); + if active.names.contains(&key) { + return Err(DispatchError::Other("recursive DNS dependency".into())); + } + if active.names.len() >= MAX_RESOLUTION_DEPTH { + return Err(DispatchError::Other("DNS dependency depth exceeded".into())); + } + active.deadline = deadline; + active.names.push(key); + let _waiter = crate::resources::observation::track( + crate::resources::observation::ResourceKind::Waiter, + ); + let lookup = async { + let addresses = match source { + Source::Runtime(dns) => { + let dns = dns + .as_ref() + .and_then(Weak::upgrade) + .ok_or_else(unavailable)?; + dns.resolve(&host) + .await + .map_err(|_| DispatchError::HostUnreachable)? + .into_iter() + .map(|ip| SocketAddr::new(ip, *port)) + .collect::>() + } + Source::Measurement(bootstrap) => { + let resolved = bootstrap + .resolve(&host, *port) + .await + .map_err(|_| DispatchError::HostUnreachable)?; + if resolved.logical_host != host + || resolved.port != *port + || resolved + .addresses + .iter() + .any(|address| address.port() != *port) + { + return Err(DispatchError::HostUnreachable); + } + resolved.addresses + } + }; + addresses + .into_iter() + .find(|address| inner.ipv6 || address.is_ipv4()) + .ok_or(DispatchError::HostUnreachable) + }; + tokio::select! { + biased; + _ = inner.cancel.cancelled() => Err(DispatchError::NotAllowed), + result = timeout_at(deadline, ACTIVE.scope(active, lookup)) => result.unwrap_or(Err(DispatchError::TimedOut)), + } + } +} + +fn unavailable() -> DispatchError { + DispatchError::Other("runtime DNS is unavailable".into()) +} diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index 6d542b8..a7ca9bb 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -136,6 +136,7 @@ struct IdleTcpConnection { struct PooledTcpConnection { stream: BoxStream, _slot: TcpPhysicalSlot, + _observation: crate::resources::observation::Guard, } struct TcpPhysicalSlot { @@ -277,6 +278,9 @@ impl TcpConnectionPool { Ok(PooledTcpConnection { stream, _slot: slot, + _observation: crate::resources::observation::track( + crate::resources::observation::ResourceKind::Pool, + ), }) } @@ -380,7 +384,7 @@ impl TcpConnectionPool { { return; } - tokio::spawn(Self::reap_idle( + crate::resources::observation::spawn(Self::reap_idle( Arc::downgrade(self), self.reaper_cancel.clone(), )); @@ -1908,7 +1912,6 @@ mod tests { use async_trait::async_trait; use tokio::{ - io::{AsyncReadExt as _, AsyncWriteExt as _}, net::{TcpListener, UdpSocket}, sync::{Notify, Semaphore}, }; diff --git a/src/ffi/measure_delay.rs b/src/ffi/measure_delay.rs index 48ed167..f82a3ca 100644 --- a/src/ffi/measure_delay.rs +++ b/src/ffi/measure_delay.rs @@ -478,7 +478,6 @@ mod tests { }; use async_trait::async_trait; - use tokio::io::AsyncReadExt as _; use crate::{ dispatch::{DatagramTransport, DispatchError}, diff --git a/src/ffi/mod.rs b/src/ffi/mod.rs index e3bd36c..d9ed36f 100644 --- a/src/ffi/mod.rs +++ b/src/ffi/mod.rs @@ -981,10 +981,10 @@ impl CoreController { let spawned = thread::Builder::new() .name(format!("vcore-runtime-{instance_id}")) .stack_size(1024 * 1024) - .spawn(move || { + .spawn(crate::resources::observation::inherit_thread(move || { let _runtime_thread = RuntimeThreadGuard::enter(); run_engine(context, prepared.core, tun, dialer, stop_rx, startup_tx) - }); + })); let runtime_thread = match spawned { Ok(thread) => thread, Err(error) => { diff --git a/src/geodata/manager.rs b/src/geodata/manager.rs index b243402..6657aec 100644 --- a/src/geodata/manager.rs +++ b/src/geodata/manager.rs @@ -1323,7 +1323,7 @@ fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { #[cfg(test)] mod tests { - use std::{io::Write as _, net::Ipv4Addr}; + use std::net::Ipv4Addr; use tempfile::tempdir; diff --git a/src/inbound/http/server.rs b/src/inbound/http/server.rs index 59bb197..64046ab 100644 --- a/src/inbound/http/server.rs +++ b/src/inbound/http/server.rs @@ -165,9 +165,9 @@ impl HttpServer { let dispatcher = self.dispatcher.clone(); let config = self.config.clone(); let child = cancellation.clone(); - tasks.spawn(async move { + tasks.spawn(crate::resources::observation::task(async move { let _ = handle_connection(stream, peer, dispatcher, config, child).await; - }); + })); } } }; diff --git a/src/inbound/http/tests.rs b/src/inbound/http/tests.rs index a7546ae..d3de1c0 100644 --- a/src/inbound/http/tests.rs +++ b/src/inbound/http/tests.rs @@ -711,7 +711,7 @@ async fn fixed_and_chunked_ten_mib_bodies_stream_in_both_directions_with_matchin for _ in 0..COUNT { expected.update(BLOCK); } - let expected = format!("{:x}", expected.finalize()); + let expected = finish_sha256(expected); for chunked in [false, true] { let (fixture, mut remotes) = Fixture::start(1, |_| {}).await; let mut remote = remotes.pop().unwrap(); @@ -828,5 +828,15 @@ async fn receive_large( } } assert_eq!(remaining, 0); - format!("{:x}", hash.finalize()) + finish_sha256(hash) +} + +fn finish_sha256(hash: Sha256) -> String { + use std::fmt::Write as _; + + let mut hex = String::with_capacity(64); + for byte in hash.finalize() { + write!(hex, "{byte:02x}").unwrap(); + } + hex } diff --git a/src/inbound/listen.rs b/src/inbound/listen.rs index 172d789..0e27812 100644 --- a/src/inbound/listen.rs +++ b/src/inbound/listen.rs @@ -64,6 +64,9 @@ pub(crate) fn bind_udp(address: SocketAddr) -> io::Result { if address.is_ipv6() { socket.set_only_v6(true)?; } + // Darwin's default UDP send high-water mark is smaller than a legal SOCKS + // reply. Configure this owned socket only; do not alter global sysctls. + socket.set_send_buffer_size(crate::session::SOCKS5_UDP_PACKET_LIMIT + 1)?; socket.set_nonblocking(true)?; socket.bind(&address.into())?; UdpSocket::from_std(socket.into()) @@ -92,6 +95,16 @@ pub(crate) fn address_family_unavailable(error: &io::Error) -> bool { mod tests { use super::*; + #[cfg(feature = "inbound-socks5")] + #[tokio::test] + async fn socks_udp_send_buffer_covers_the_declared_wire_limit() { + let socket = bind_udp("127.0.0.1:0".parse().unwrap()).unwrap(); + assert!( + socket2::SockRef::from(&socket).send_buffer_size().unwrap() + >= crate::session::SOCKS5_UDP_PACKET_LIMIT + ); + } + #[tokio::test] async fn policy_binds_loopback_or_wildcard_and_respects_ipv6() { for allow_lan in [false, true] { diff --git a/src/inbound/socks5/association.rs b/src/inbound/socks5/association.rs index 35a821e..cac1bd8 100644 --- a/src/inbound/socks5/association.rs +++ b/src/inbound/socks5/association.rs @@ -131,6 +131,11 @@ impl Associations { let Ok(permit) = entry.sender.try_reserve() else { return; }; + crate::resources::observation::observe_queue( + crate::resources::observation::QueueKind::SocksUdp, + QUEUE_CAPACITY - entry.sender.capacity(), + QUEUE_CAPACITY, + ); *entry.source.lock().unwrap() = source; permit.send(Datagram { remote, diff --git a/src/inbound/socks5/server.rs b/src/inbound/socks5/server.rs index da14ca1..2e53ea3 100644 --- a/src/inbound/socks5/server.rs +++ b/src/inbound/socks5/server.rs @@ -80,7 +80,7 @@ impl Socks5Server { let socket = socket.clone(); let registry = registry.clone(); let child = cancellation.clone(); - receivers.spawn(async move { + receivers.spawn(crate::resources::observation::task(async move { // A sentinel byte rejects oversized packets, including OS // truncation, without allocating from a client length field. let mut buffer = vec![0; SOCKS5_UDP_PACKET_LIMIT + 1]; @@ -94,7 +94,7 @@ impl Socks5Server { } } } - }); + })); } let mut connections = JoinSet::new(); let mut cleanup = interval_at(Instant::now() + CLEANUP_INTERVAL, CLEANUP_INTERVAL); @@ -116,13 +116,13 @@ impl Socks5Server { let config = self.config.clone(); let registry = registry.clone(); let child = cancellation.clone(); - connections.spawn(async move { + connections.spawn(crate::resources::observation::task(async move { tokio::select! { biased; () = child.cancelled() => {}, _ = handle_connection(stream, peer, socket, config, dispatcher, registry, child.clone()) => {}, } - }); + })); } } }; diff --git a/src/lib.rs b/src/lib.rs index a31988f..e9ac738 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,18 +30,28 @@ pub mod routing; feature = "outbound-anytls", feature = "outbound-socks5", feature = "outbound-shadowsocks", - feature = "outbound-vless" + feature = "outbound-vless", + feature = "outbound-trojan", + feature = "outbound-vmess", + feature = "outbound-hysteria2" ), any(feature = "ffi", test) ))] mod runtime; -#[cfg(any(feature = "outbound-anytls", feature = "outbound-vless"))] +#[cfg(any( + feature = "outbound-anytls", + feature = "outbound-vless", + feature = "stream-transport", + feature = "outbound-hysteria2" +))] pub mod security; pub mod session; #[cfg(any( feature = "inbound-socks5", feature = "outbound-anytls", - feature = "outbound-socks5" + feature = "outbound-socks5", + feature = "outbound-trojan", + feature = "outbound-vless" ))] mod socks5; #[cfg(any(feature = "tun", test))] @@ -53,14 +63,18 @@ mod tcp_sniffer; test ))] pub(crate) mod traffic; -#[cfg(feature = "outbound-vless")] +#[cfg(any( + feature = "outbound-vless", + feature = "stream-transport", + feature = "quic-transport" +))] pub mod transport; #[cfg(all(feature = "tun", any(unix, windows)))] mod tun_runtime; #[cfg(all(windows, feature = "ffi"))] #[doc(hidden)] pub mod windows; -#[cfg(feature = "outbound-vless")] +#[cfg(any(feature = "outbound-vless", feature = "outbound-vmess"))] pub mod xudp; pub use error::{Result, VCoreError}; @@ -74,7 +88,7 @@ pub const INVOKE_API_VERSION: u32 = 5; /// Internal configuration schema revision reported through Invoke. /// /// The strict Mihomo YAML subset deliberately carries no version field. -pub const CONFIG_VERSION: u8 = 14; +pub const CONFIG_VERSION: u8 = 27; /// Stable implementation identifier returned by the version Invoke method. pub const ENGINE: &str = "rust"; @@ -86,5 +100,5 @@ pub const ENGINE: &str = "rust"; pub const BUILD_IDENTITY: &str = concat!( "VCore;engine=rust;coreVersion=", env!("CARGO_PKG_VERSION"), - ";invokeApiVersion=5;configVersion=14" + ";invokeApiVersion=5;configVersion=27" ); diff --git a/src/limits.rs b/src/limits.rs index 1891466..bdc3482 100644 --- a/src/limits.rs +++ b/src/limits.rs @@ -1,5 +1,60 @@ use crate::{Result, VCoreError}; +/// Fairness bound for control/discard work in one poll or receive iteration. +pub const IO_POLL_BUDGET: usize = 32; + +/// One retained plaintext chunk while the official SS codec is backpressured. +pub const SHADOWSOCKS_WRITE_CHUNK: usize = 16 * 1024; + +/// Per-object VLESS bounds, never business-flow admission limits. +pub const VLESS_UDP_FRAME_BYTES: usize = 65537; +pub const VISION_CONTENT_BYTES: usize = 8192 - 21; +pub const VISION_TLS_RECORD_BYTES: usize = 18 * 1024; +pub const VISION_HELLO_BYTES: usize = 65536; +pub const GRPC_IDLE_CONNECTIONS: usize = 4; +pub const GRPC_PING_TIMEOUT_SECONDS: usize = 15; + +/// XHTTP and sing-mux per-object parser, queue and retained-pool bounds. +pub const XHTTP_CUSTOM_HEADER_BYTES: usize = 8 * 1024; +pub const XHTTP_CUSTOM_HEADERS: usize = 100; +pub const XHTTP_PADDING_BYTES: usize = 4096; +pub const XHTTP_POST_BYTES: usize = 16 * 1024 * 1024; +pub const XHTTP_PACKET_BATCH_BYTES: usize = 64 * 1024; +pub const XHTTP_REQUEST_BYTES: usize = 16 * 1024; +pub const XHTTP_REQUEST_HEADERS: usize = 128; +pub const XHTTP_IDLE_ENTRIES: usize = 64; +pub const XHTTP_IDLE_H1_CONNECTIONS: usize = 4; +pub const XHTTP_BODY_QUEUE: usize = 2; +pub const XHTTP_BODY_CHUNK: usize = 16 * 1024; +pub const XHTTP_H3_UNI_STREAMS: usize = 8; +pub const XHTTP_H3_STREAM_WINDOW: usize = 64 * 1024; +pub const XHTTP_H3_CONNECTION_WINDOW: usize = 128 * 1024; +pub const XHTTP_H3_SEND_WINDOW: usize = 64 * 1024; +pub const SING_MUX_IDLE_CONNECTIONS: usize = 16; +pub const SING_MUX_COMMAND_QUEUE: usize = 16; +pub const SING_MUX_CHUNK: usize = 16 * 1024; +pub const SMUX_RECEIVE_QUEUE: usize = 4; +/// Retire the physical connection after this many admissions, without closing +/// existing streams. Never pass a saturated connection to yamux's open method. +pub const YAMUX_CONNECTION_ADMISSIONS: usize = 64; +pub const YAMUX_CONNECTION_WINDOW: usize = YAMUX_CONNECTION_ADMISSIONS * 256 * 1024; + +/// Hysteria2 per-packet, per-association and per-connection retained bounds. +pub const HY2_UDP_PAYLOAD: usize = 4096; +pub const HY2_UDP_QUEUE: usize = 32; +pub const HY2_PENDING_PACKETS: usize = 64; +pub const HY2_PENDING_BYTES: usize = 256 * 1024; +pub const HY2_FRAGMENT_TTL_SECONDS: usize = 5; +pub const HY2_PATH_RETIRE_SECONDS: usize = 1; +pub const HY2_QUIC_PAYLOAD: usize = 1400; +pub const HY2_UNI_STREAMS: usize = 8; +pub const HY2_STREAM_WINDOW: usize = 256 * 1024; +pub const HY2_CONNECTION_WINDOW: usize = 1024 * 1024; +pub const HY2_DATAGRAM_BUFFER: usize = 256 * 1024; +pub const HY2_AUTH_HEADERS: usize = 16384; +pub const HY2_RESPONSE_MESSAGE: usize = 2048; +pub const HY2_RESPONSE_PADDING: usize = 4096; + /// Historical iOS TUN footprint target retained for best-effort telemetry. /// /// Crossing this value never changes a runtime lifecycle result. diff --git a/src/outbound/address.rs b/src/outbound/address.rs new file mode 100644 index 0000000..c3ceb17 --- /dev/null +++ b/src/outbound/address.rs @@ -0,0 +1,156 @@ +//! Shared port-first address encoding used by VLESS, VMess and XUDP. +//! Address kinds are IPv4=1, domain=2, IPv6=3; SOCKS uses a different layout. +use crate::session::Destination; +use bytes::{BufMut as _, BytesMut}; +use std::io; + +/// Packetaddr uses address-first IPv4=1 / IPv6=2, with no domain wire form. +/// The caller must resolve business domains through its controlled context. +pub fn encode_packet_addr(destination: &Destination, output: &mut BytesMut) -> io::Result<()> { + let Destination::Ip(address) = destination else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "packetaddr requires an IP", + )); + }; + if address.port() == 0 { + return Err(io::ErrorKind::InvalidInput.into()); + } + match address.ip() { + std::net::IpAddr::V4(ip) => { + output.put_u8(1); + output.extend_from_slice(&ip.octets()); + } + std::net::IpAddr::V6(ip) => { + output.put_u8(2); + output.extend_from_slice(&ip.octets()); + } + } + output.put_u16(address.port()); + Ok(()) +} + +pub fn decode_packet_addr(input: &[u8]) -> io::Result<(Destination, usize)> { + let invalid = || io::Error::new(io::ErrorKind::InvalidData, "invalid packetaddr address"); + let (ip, length) = match input.first() { + Some(1) => ( + std::net::IpAddr::V4(std::net::Ipv4Addr::from( + <[u8; 4]>::try_from(input.get(1..5).ok_or_else(invalid)?).map_err(|_| invalid())?, + )), + 5, + ), + Some(2) => ( + std::net::IpAddr::V6(std::net::Ipv6Addr::from( + <[u8; 16]>::try_from(input.get(1..17).ok_or_else(invalid)?) + .map_err(|_| invalid())?, + )), + 17, + ), + _ => return Err(invalid()), + }; + let port = u16::from_be_bytes( + input + .get(length..length + 2) + .ok_or_else(invalid)? + .try_into() + .map_err(|_| invalid())?, + ); + if port == 0 { + return Err(invalid()); + } + Ok((std::net::SocketAddr::new(ip, port).into(), length + 2)) +} + +pub fn encode_port_first(destination: &Destination, output: &mut BytesMut) -> io::Result<()> { + output.put_u16(destination.port()); + match destination { + Destination::Ip(address) if address.is_ipv4() => { + output.put_u8(1); + let std::net::IpAddr::V4(ip) = address.ip() else { + unreachable!("is_ipv4 checked") + }; + output.extend_from_slice(&ip.octets()); + } + Destination::Domain { host, .. } => { + let length = u8::try_from(host.len()).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "proxy domain is too long") + })?; + output.put_u8(2); + output.put_u8(length); + output.extend_from_slice(host.as_bytes()); + } + Destination::Ip(address) => { + output.put_u8(3); + let std::net::IpAddr::V6(ip) = address.ip() else { + unreachable!("non-IPv4 address is IPv6") + }; + output.extend_from_slice(&ip.octets()); + } + } + Ok(()) +} + +pub fn decode_port_first(input: &[u8]) -> io::Result<(Destination, usize)> { + if input.len() < 3 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "truncated XUDP destination", + )); + } + let port = u16::from_be_bytes([input[0], input[1]]); + if port == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "proxy destination port is zero", + )); + } + match input[2] { + 1 => { + let octets: [u8; 4] = input + .get(3..7) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "truncated IPv4 destination") + })? + .try_into() + .expect("slice length checked"); + Ok(( + Destination::from(std::net::SocketAddr::new( + std::net::IpAddr::V4(octets.into()), + port, + )), + 7, + )) + } + 2 => { + let length = usize::from(*input.get(3).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "truncated domain destination") + })?); + let host = input.get(4..4 + length).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "truncated domain destination") + })?; + let host = std::str::from_utf8(host) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "non-UTF-8 domain"))?; + Ok((Destination::domain(host, port)?, 4 + length)) + } + 3 => { + let octets: [u8; 16] = input + .get(3..19) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "truncated IPv6 destination") + })? + .try_into() + .expect("slice length checked"); + Ok(( + Destination::from(std::net::SocketAddr::new( + std::net::IpAddr::V6(octets.into()), + port, + )), + 19, + )) + } + _ => Err(io::Error::new( + io::ErrorKind::InvalidData, + "unknown XUDP address type", + )), + } +} diff --git a/src/outbound/anytls/client.rs b/src/outbound/anytls/client.rs index e4f12a6..3f2cf61 100644 --- a/src/outbound/anytls/client.rs +++ b/src/outbound/anytls/client.rs @@ -295,10 +295,11 @@ impl AnyTlsClient { // constructor/bind failure. Start only when traffic first uses // this node, under the same lock as the shutdown admission gate. state.cleanup_started = true; - self.tasks.spawn(cleanup_idle( - Arc::downgrade(self), - self.cancellation.clone(), - )); + self.tasks + .spawn(crate::resources::observation::task(cleanup_idle( + Arc::downgrade(self), + self.cancellation.clone(), + ))); } Ok(()) } diff --git a/src/outbound/anytls/mod.rs b/src/outbound/anytls/mod.rs index 6a2ff89..b28e6d3 100644 --- a/src/outbound/anytls/mod.rs +++ b/src/outbound/anytls/mod.rs @@ -167,9 +167,12 @@ impl OutboundConnector for AnyTlsOutbound { .client .open_stream(stream_session, &magic_destination(), context) .await?; - Ok(Box::new( - self.client - .start_uot(stream, request.max_response_payload_size())?, + Ok(crate::dispatch::bound_datagram( + Box::new( + self.client + .start_uot(stream, request.max_response_payload_size())?, + ), + request.budget(), )) } diff --git a/src/outbound/anytls/padding.rs b/src/outbound/anytls/padding.rs index fbc73a9..a240a2b 100644 --- a/src/outbound/anytls/padding.rs +++ b/src/outbound/anytls/padding.rs @@ -2,7 +2,7 @@ use std::{collections::BTreeMap, io}; use bytes::{Bytes, BytesMut}; use md5::{Digest as _, Md5}; -use rand::Rng as _; +use rand::RngExt as _; use tokio::io::{AsyncWrite, AsyncWriteExt as _}; use super::frame::{Command, Frame, HEADER_LENGTH, MAX_FRAME_PAYLOAD}; diff --git a/src/outbound/anytls/session.rs b/src/outbound/anytls/session.rs index b844aa5..842aebb 100644 --- a/src/outbound/anytls/session.rs +++ b/src/outbound/anytls/session.rs @@ -142,8 +142,15 @@ impl PreparedSession { writer_receiver, } = self; let (reader, writer) = tokio::io::split(transport); - tracker.spawn(reader_loop(session.clone(), reader)); - tracker.spawn(writer_loop(session, writer, writer_receiver)); + tracker.spawn(crate::resources::observation::task(reader_loop( + session.clone(), + reader, + ))); + tracker.spawn(crate::resources::observation::task(writer_loop( + session, + writer, + writer_receiver, + ))); stream } } @@ -300,7 +307,7 @@ impl Session { if self.peer_version.load(Ordering::Acquire) >= 2 { let session = self.clone(); - tracker.spawn(async move { + tracker.spawn(crate::resources::observation::task(async move { let result = tokio::select! { biased; () = session.cancellation.cancelled() => return, @@ -318,7 +325,7 @@ impl Session { if let Err(error) = result { session.fail(error); } - }); + })); } open_guard.commit(); @@ -639,11 +646,10 @@ async fn handle_server_frame(session: &Arc, frame: Frame) -> io::Result if frame.stream_id != 0 { return Err(protocol_error("AnyTLS Alert uses a non-zero stream ID")); } - let message = bounded_message(&String::from_utf8_lossy(&frame.payload)); - tracing::warn!(message = %message, "AnyTLS server alert"); + tracing::warn!("AnyTLS server alert"); Err(io::Error::new( io::ErrorKind::ConnectionAborted, - format!("AnyTLS server alert: {message}"), + "AnyTLS server alert", )) } Command::UpdatePaddingScheme => { @@ -730,23 +736,17 @@ fn handle_syn_ack(session: &Arc, frame: Frame) -> io::Result<()> { return Ok(()); } - let message = bounded_message(&String::from_utf8_lossy(&frame.payload)); - let failure = StreamFailure::new( - io::ErrorKind::ConnectionRefused, - Arc::::from(format!("AnyTLS remote: {message}")), - ); + let message = "AnyTLS stream rejected by server"; + let failure = StreamFailure::new(io::ErrorKind::ConnectionRefused, Arc::::from(message)); active.shared.fail(failure.clone()); let _ = active.incoming.try_send(StreamEvent::Failed(failure)); if let Some(waiter) = active.syn_ack.take() { let _ = waiter.send(Err(io::Error::new( io::ErrorKind::ConnectionRefused, - format!("AnyTLS remote: {message}"), + message, ))); } - Err(io::Error::new( - io::ErrorKind::ConnectionRefused, - format!("AnyTLS remote: {message}"), - )) + Err(io::Error::new(io::ErrorKind::ConnectionRefused, message)) } fn active_target( @@ -912,6 +912,56 @@ fn closed_pipe(message: &'static str) -> io::Error { mod tests { use super::*; + #[tokio::test] + async fn remote_alerts_and_synack_errors_never_echo_peer_payloads() { + use tokio::io::AsyncReadExt; + for (command, stream_id, expected, kind) in [ + ( + Command::Alert, + 0, + "AnyTLS server alert", + io::ErrorKind::ConnectionAborted, + ), + ( + Command::SynAck, + 1, + "AnyTLS stream rejected by server", + io::ErrorKind::ConnectionRefused, + ), + ] { + let padding = Arc::new(PaddingScheme::default_scheme()); + let (raw, _peer) = tokio::io::duplex(65536); + let mut prepared = Session::prepare_first( + 1, + Weak::new(), + Box::new(raw), + [0; 32], + padding.clone(), + Arc::new(ArcSwap::new(padding)), + &Destination::domain("target.example", 443).unwrap(), + 1024, + 1, + &CancellationToken::new(), + ) + .await + .unwrap(); + let session = prepared.session(); + let payload = + Bytes::from_static(b"synthetic-secret target.private.invalid\r\nforged-log"); + let error = handle_server_frame( + &session, + Frame::with_payload(command, stream_id, payload).unwrap(), + ) + .await + .unwrap_err(); + assert_eq!(error.kind(), kind); + assert_eq!(error.to_string(), expected); + session.fail(error); + let propagated = prepared.stream.read(&mut [0]).await.unwrap_err(); + assert_eq!(propagated.to_string(), expected); + } + } + #[test] fn server_settings_version_is_strict_and_bounded_to_supported_v2() { assert_eq!(parse_server_version(b"v=1").unwrap(), 1); diff --git a/src/outbound/anytls/uot.rs b/src/outbound/anytls/uot.rs index a6afe5d..518965d 100644 --- a/src/outbound/anytls/uot.rs +++ b/src/outbound/anytls/uot.rs @@ -104,12 +104,12 @@ impl UotTransport { let cancellation = parent_cancellation.child_token(); let (responses, receiver) = mpsc::channel(RESPONSE_QUEUE_CAPACITY); let reader_cancellation = cancellation.clone(); - let reader_task = tracker.spawn(read_loop( + let reader_task = tracker.spawn(crate::resources::observation::task(read_loop( reader, responses, max_response_payload_size, reader_cancellation, - )); + ))); Self { writer, responses: receiver, @@ -398,8 +398,6 @@ mod tests { time::Duration, }; - use tokio::io::AsyncWriteExt as _; - use super::*; fn datagram(remote: Destination, payload: &'static [u8]) -> Datagram { diff --git a/src/outbound/connector.rs b/src/outbound/connector.rs index bb08f57..f87b0f6 100644 --- a/src/outbound/connector.rs +++ b/src/outbound/connector.rs @@ -21,8 +21,9 @@ use crate::{ }; use super::DirectOutbound; +use crate::dns::resolution::{ResolutionContext, inherited_deadline}; -const DEFAULT_ESTABLISH_TIMEOUT: Duration = Duration::from_secs(10); +pub const DEFAULT_ESTABLISH_TIMEOUT: Duration = Duration::from_secs(10); pub(crate) const MAX_OUTBOUND_DIAGNOSTIC_MESSAGE_BYTES: usize = 256; tokio::task_local! { @@ -125,6 +126,10 @@ fn diagnostic_stage(operation: &'static str) -> &'static str { "AnyTLS TLS handshake" => "anytls-tls", "AnyTLS authentication and session preface" => "anytls-session", "AnyTLS session open" => "anytls-stream", + "Trojan TLS handshake" => "trojan-tls", + "Trojan request header" => "trojan-request", + "Trojan WebSocket upgrade" => "trojan-websocket", + "Trojan gRPC handshake" => "trojan-grpc", _ => "outbound", } } @@ -200,18 +205,27 @@ impl fmt::Write for BoundedMessage { /// A router builds this context once and passes it through the complete /// configured chain so nested connectors share one timeout instead of restarting it at /// every hop. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct EstablishContext { deadline: Instant, - selections: Mutex, usize)>>, + resolution: ResolutionContext, + selections: Arc>, } +type SelectionSnapshot = HashMap, usize)>; + impl EstablishContext { #[must_use] pub fn with_timeout(duration: Duration) -> Self { + Self::with_resolution(duration, ResolutionContext::default()) + } + + #[must_use] + pub fn with_resolution(duration: Duration, resolution: ResolutionContext) -> Self { Self { - deadline: Instant::now() + duration, - selections: Mutex::new(HashMap::new()), + deadline: inherited_deadline(Instant::now() + duration), + resolution, + selections: Arc::new(Mutex::new(HashMap::new())), } } @@ -220,6 +234,34 @@ impl EstablishContext { self.deadline } + pub async fn resolve_ip( + &self, + target: &Destination, + ) -> Result { + self.resolution.resolve_ip(target, self.deadline).await + } + + pub fn resolution(&self) -> ResolutionContext { + self.resolution.clone() + } + + /// Only for a new physical path of an already authenticated session, never + /// for extending an in-progress setup. Freeze its group choices and give + /// this socket's complete upstream establishment a new absolute deadline. + #[cfg(feature = "outbound-hysteria2")] + pub(crate) fn authenticated_continuation(&self) -> Self { + Self { + deadline: Instant::now() + DEFAULT_ESTABLISH_TIMEOUT, + resolution: self.resolution.clone(), + selections: Arc::new(Mutex::new( + self.selections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(), + )), + } + } + /// One read per group for the entire setup, including both legs of a /// SOCKS5 UDP association. Retaining the atomic prevents address reuse; /// unrelated groups are not an atomic cross-group snapshot. @@ -309,7 +351,7 @@ impl std::fmt::Debug for ConnectedStream { #[derive(Debug, Clone)] pub struct DatagramRequest { pub session: DatagramSession, - max_response_payload_size: u16, + budget: crate::dispatch::DatagramBudget, } impl DatagramRequest { @@ -318,22 +360,48 @@ impl DatagramRequest { let max_response_payload_size = session.max_response_payload_size(); Self { session, - max_response_payload_size, + budget: crate::dispatch::DatagramBudget::new(u16::MAX, max_response_payload_size), } } #[must_use] pub const fn max_response_payload_size(&self) -> u16 { - self.max_response_payload_size + self.budget.receive() } #[must_use] pub fn with_max_response_payload_size(&self, maximum: u16) -> Self { Self { session: self.session.clone(), - max_response_payload_size: maximum, + budget: crate::dispatch::DatagramBudget::new(self.budget.transmit(), maximum), } } + + pub const fn budget(&self) -> crate::dispatch::DatagramBudget { + self.budget + } + + #[must_use] + pub fn with_budget(&self, budget: crate::dispatch::DatagramBudget) -> Self { + Self { + session: self.session.clone(), + budget, + } + } + + /// Budget the wire envelope independently in each direction. A larger + /// requested envelope can never increase a lower transport's real cap. + pub fn with_envelope(&self, transmit: usize, receive: usize) -> Self { + let widen = |value: u16, overhead: usize| { + usize::from(value) + .saturating_add(overhead) + .min(usize::from(u16::MAX)) as u16 + }; + self.with_budget(crate::dispatch::DatagramBudget::new( + widen(self.budget.transmit(), transmit), + widen(self.budget.receive(), receive), + )) + } } /// Internal composable outbound boundary. @@ -527,7 +595,11 @@ impl UpstreamPath { /// Pins a native UDP proxy server without performing DNS after prepare. /// Resolving here and opening IO below share the context's group snapshot. - #[cfg(feature = "outbound-shadowsocks")] + #[cfg(any( + feature = "outbound-shadowsocks", + feature = "outbound-vless", + feature = "outbound-hysteria2" + ))] pub(crate) fn datagram_server( &self, server: &Destination, @@ -591,6 +663,7 @@ fn validate_prepared_endpoint( pub struct ConnectorDispatcher { inner: Arc, allow_udp: bool, + resolution: ResolutionContext, } impl ConnectorDispatcher { @@ -599,6 +672,7 @@ impl ConnectorDispatcher { Self { inner, allow_udp: true, + resolution: ResolutionContext::default(), } } @@ -609,7 +683,21 @@ impl ConnectorDispatcher { /// carrying a child TCP stream is not rejected by the parent's UDP flag. #[must_use] pub fn with_udp_capability(inner: Arc, allow_udp: bool) -> Self { - Self { inner, allow_udp } + Self { + inner, + allow_udp, + resolution: ResolutionContext::default(), + } + } + + #[must_use] + pub fn with_resolution(mut self, resolution: ResolutionContext) -> Self { + self.resolution = resolution; + self + } + + fn context(&self) -> EstablishContext { + EstablishContext::with_resolution(DEFAULT_ESTABLISH_TIMEOUT, self.resolution.clone()) } } @@ -617,7 +705,7 @@ impl ConnectorDispatcher { impl Dispatcher for ConnectorDispatcher { async fn connect_tcp(&self, session: StreamSession) -> Result { self.inner - .connect_stream(session, &EstablishContext::default()) + .connect_stream(session, &self.context()) .await .map(|connected| connected.io) } @@ -630,7 +718,7 @@ impl Dispatcher for ConnectorDispatcher { return Err(DispatchError::NotAllowed); } self.inner - .open_datagram(DatagramRequest::new(session), &EstablishContext::default()) + .open_datagram(DatagramRequest::new(session), &self.context()) .await } } @@ -647,6 +735,52 @@ mod tests { use super::*; + #[cfg(feature = "outbound-hysteria2")] + #[tokio::test] + async fn authenticated_continuation_keeps_group_choice_but_has_a_new_io_deadline() { + #[cfg(feature = "interop-test")] + let _case = crate::resources::case_events::Case::new( + "HYSTERIA2-UNIT", + "authenticated_continuation_keeps_group_choice_but_has_a_new_io_deadline", + ); + let selection = Arc::new(AtomicUsize::new(0)); + let path = selected_path( + selection.clone(), + vec![ + SelectUpstreamMember::Proxy(Arc::new(MarkedConnector { + marker: 1, + ..Default::default() + })), + SelectUpstreamMember::Proxy(Arc::new(MarkedConnector { + marker: 2, + ..Default::default() + })), + ], + ); + let context = EstablishContext::with_timeout(Duration::from_millis(1)); + let request = DatagramRequest::new(DatagramSession::new( + InboundKind::InternalMeasure, + test_session().source, + )); + let mut initial = path.open_datagram(request.clone(), &context).await.unwrap(); + assert_eq!(initial.receive().await.unwrap().payload[0], 1); + tokio::time::sleep(Duration::from_millis(2)).await; + selection.store(1, Ordering::Release); + let continuation = context.authenticated_continuation(); + assert!(context.deadline() < Instant::now()); + assert!(continuation.deadline() > Instant::now()); + let mut old = path + .open_datagram(request.clone(), &continuation) + .await + .unwrap(); + assert_eq!(old.receive().await.unwrap().payload[0], 1); + let mut new = path + .open_datagram(request, &EstablishContext::default()) + .await + .unwrap(); + assert_eq!(new.receive().await.unwrap().payload[0], 2); + } + struct UnreachableConnector; #[derive(Default)] diff --git a/src/outbound/direct.rs b/src/outbound/direct.rs index 4d1852b..5bdb9c4 100644 --- a/src/outbound/direct.rs +++ b/src/outbound/direct.rs @@ -1,9 +1,9 @@ use std::{future::Future, io, net::SocketAddr, pin::Pin}; +use crate::dialer::PhysicalDatagram as UdpSocket; use async_trait::async_trait; use bytes::Bytes; use futures_util::future::select_all; -use tokio::net::UdpSocket; use crate::{ dialer::Dialer, @@ -73,10 +73,13 @@ impl OutboundConnector for DirectOutbound { request: DatagramRequest, _context: &EstablishContext, ) -> Result, DispatchError> { - Ok(Box::new(DirectDatagramTransport::new( - self.dialer.clone(), - usize::from(request.max_response_payload_size()), - ))) + Ok(crate::dispatch::bound_datagram( + Box::new(DirectDatagramTransport::new( + self.dialer.clone(), + usize::from(request.max_response_payload_size()), + )), + request.budget(), + )) } } @@ -171,6 +174,18 @@ impl DirectDatagramTransport { #[async_trait] impl DatagramTransport for DirectDatagramTransport { + fn payload_budget(&self, peer: &Destination) -> crate::dispatch::DatagramBudget { + let wire_limit = if matches!(peer, Destination::Ip(address) if address.is_ipv6()) { + 65_527 + } else { + 65_507 + }; + crate::dispatch::DatagramBudget::new( + wire_limit, + (self.max_response_payload_size as u16).min(wire_limit), + ) + } + async fn send(&mut self, datagram: Datagram) -> Result<(), DispatchError> { let Destination::Ip(address) = datagram.remote else { return Err(DispatchError::HostUnreachable); diff --git a/src/outbound/hysteria2/bandwidth.rs b/src/outbound/hysteria2/bandwidth.rs new file mode 100644 index 0000000..eee9887 --- /dev/null +++ b/src/outbound/hysteria2/bandwidth.rs @@ -0,0 +1,320 @@ +use crate::{ + dispatch::{DatagramBudget, DatagramTransport, DispatchError}, + session::{Datagram, Destination}, +}; +use async_trait::async_trait; +use quinn_proto::{ + RttEstimator, + congestion::{BbrConfig, Controller, ControllerFactory}, +}; +use std::{ + io, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, + time::{Duration, Instant}, +}; + +#[derive(Default)] +struct Bucket { + last: Option, + credit: u128, + rate: u64, +} +impl Bucket { + fn claim(&mut self, now: Instant, bytes: usize, rate: u64, mtu: u16) -> Duration { + if rate == 0 { + self.last = None; + return Duration::ZERO; + } + // Credits are byte-nanoseconds: fractional tokens survive repeated + // polls, with no floating-point rounding or truncating low rates. + let capacity = u128::from((u64::from(mtu) * 10).max(rate / 250)) * 1_000_000_000; + self.credit = match self.last { + None => capacity, + Some(last) => self + .credit + .saturating_add( + now.saturating_duration_since(last) + .as_nanos() + .saturating_mul(u128::from(self.rate)), + ) + .min(capacity), + }; + self.last = Some(now); + self.rate = rate; + let cost = (bytes as u128) * 1_000_000_000; + if self.credit >= cost { + self.credit -= cost; + return Duration::ZERO; + } + Duration::from_nanos( + (cost - self.credit) + .div_ceil(u128::from(rate)) + .min(u128::from(u64::MAX)) as u64, + ) + } +} + +/// Rate zero uses Quinn BBR. A positive negotiated rate installs Brutal's +/// fixed-rate window policy and a separate wire-byte pacer. Quinn's own cwnd +/// pacer is retained, but is not relied on as a configured bandwidth limit. +pub(super) struct Bandwidth { + pub rate: AtomicU64, + ratio: AtomicU64, + bucket: Mutex, +} +impl Default for Bandwidth { + fn default() -> Self { + Self { + rate: AtomicU64::new(0), + ratio: AtomicU64::new(1_000_000), + bucket: Mutex::new(Bucket::default()), + } + } +} +impl Bandwidth { + pub fn wrap( + self: &Arc, + inner: Box, + mtu: u16, + ) -> Box { + Box::new(Paced { + inner, + bandwidth: self.clone(), + mtu, + }) + } + fn compensated(&self) -> u64 { + let rate = self.rate.load(Ordering::Relaxed); + ((u128::from(rate) * 1_000_000) / u128::from(self.ratio.load(Ordering::Relaxed))) + .min(u128::from(u64::MAX)) as u64 + } + pub async fn pace(&self, bytes: usize, mtu: u16) { + loop { + let delay = self.bucket.lock().unwrap().claim( + tokio::time::Instant::now().into_std(), + bytes, + self.compensated(), + mtu, + ); + if delay.is_zero() { + return; + } + tokio::time::sleep(delay).await; + } + } +} + +struct Paced { + inner: Box, + bandwidth: Arc, + mtu: u16, +} +#[async_trait] +impl DatagramTransport for Paced { + fn payload_budget(&self, peer: &Destination) -> DatagramBudget { + self.inner.payload_budget(peer) + } + async fn send(&mut self, packet: Datagram) -> Result<(), DispatchError> { + self.bandwidth.pace(packet.payload.len(), self.mtu).await; + self.inner.send(packet).await + } + async fn receive(&mut self) -> Result { + self.inner.receive().await + } + async fn close(&mut self) -> Result<(), DispatchError> { + self.inner.close().await + } +} +impl ControllerFactory for Bandwidth { + fn build(self: Arc, now: Instant, mtu: u16) -> Box { + Box::new(Switchable { + bbr: Arc::new(BbrConfig::default()).build(now, mtu), + shared: self, + start: now, + slots: [Slot::default(); 5], + mtu, + rtt: Duration::ZERO, + }) + } +} +#[derive(Clone, Copy, Default)] +struct Slot { + second: u64, + acknowledged: u64, + lost: u64, +} +struct Switchable { + bbr: Box, + shared: Arc, + start: Instant, + slots: [Slot; 5], + mtu: u16, + rtt: Duration, +} +impl Switchable { + fn sample(&mut self, now: Instant, acknowledged: u64, lost: u64) { + let second = now.saturating_duration_since(self.start).as_secs(); + let slot = &mut self.slots[(second % 5) as usize]; + if slot.second != second { + *slot = Slot { + second, + ..Default::default() + }; + } + slot.acknowledged = slot.acknowledged.saturating_add(acknowledged); + slot.lost = slot.lost.saturating_add(lost); + let (ack, loss) = self + .slots + .iter() + .filter(|slot| second.saturating_sub(slot.second) < 5) + .fold((0_u64, 0_u64), |(a, l), slot| { + ( + a.saturating_add(slot.acknowledged), + l.saturating_add(slot.lost), + ) + }); + let total = ack.saturating_add(loss); + let ratio = if total < 50 { + 1_000_000 + } else { + ((u128::from(ack) * 1_000_000 / u128::from(total)) as u64).max(800_000) + }; + self.shared.ratio.store(ratio, Ordering::Relaxed); + } +} +impl Controller for Switchable { + fn on_sent(&mut self, now: Instant, bytes: u64, packet: u64) { + self.bbr.on_sent(now, bytes, packet); + } + fn on_ack( + &mut self, + now: Instant, + sent: Instant, + bytes: u64, + limited: bool, + rtt: &RttEstimator, + ) { + self.bbr.on_ack(now, sent, bytes, limited, rtt); + self.rtt = rtt.get(); + self.sample(now, u64::from(bytes > 0), 0); + } + fn on_end_acks(&mut self, now: Instant, flight: u64, limited: bool, largest: Option) { + self.bbr.on_end_acks(now, flight, limited, largest); + } + fn on_congestion_event(&mut self, now: Instant, sent: Instant, persistent: bool, lost: u64) { + self.bbr.on_congestion_event(now, sent, persistent, lost); + self.sample(now, 0, lost.div_ceil(u64::from(self.mtu))); + } + fn on_mtu_update(&mut self, mtu: u16) { + self.mtu = mtu; + self.bbr.on_mtu_update(mtu); + } + fn window(&self) -> u64 { + if self.shared.rate.load(Ordering::Relaxed) == 0 { + return self.bbr.window(); + } + // Keep enough flight space for Quinn's delayed ACKs at very low RTT. + // The independent wire pacer, not this floor, enforces the exact rate. + ((self.shared.compensated() as f64 * self.rtt.as_secs_f64() * 2.0) as u64) + .max(10240) + .max(u64::from(self.mtu) * 2) + } + fn initial_window(&self) -> u64 { + self.bbr.initial_window() + } + fn clone_box(&self) -> Box { + Box::new(Self { + bbr: self.bbr.clone_box(), + shared: self.shared.clone(), + start: self.start, + slots: self.slots, + mtu: self.mtu, + rtt: self.rtt, + }) + } + fn into_any(self: Box) -> Box { + self + } +} + +pub(super) fn negotiate(up: u64, server: &[u8]) -> io::Result { + if server == b"auto" { + return Ok(0); + } + if server.is_empty() || !server.iter().all(u8::is_ascii_digit) { + return Err(io::ErrorKind::InvalidData.into()); + } + let server: u64 = std::str::from_utf8(server) + .map_err(|_| io::ErrorKind::InvalidData)? + .parse() + .map_err(|_| io::ErrorKind::InvalidData)?; + Ok(if up == 0 { + 0 + } else if server == 0 { + up + } else { + up.min(server) + }) +} + +#[cfg(test)] +mod tests { + #[test] + fn pacer_limits_actual_bytes_after_one_bounded_burst() { + #[cfg(feature = "interop-test")] + let _case = crate::resources::case_events::Case::new( + "HYSTERIA2-UNIT", + "pacer_limits_actual_bytes_after_one_bounded_burst", + ); + use std::time::{Duration, Instant}; + let now = Instant::now(); + let mut bucket = super::Bucket::default(); + for _ in 0..10 { + assert_eq!(bucket.claim(now, 1400, 125000, 1400), Duration::ZERO); + } + assert_eq!( + bucket.claim(now, 1400, 125000, 1400), + Duration::from_micros(11200) + ); + assert_eq!( + bucket.claim(now + Duration::from_micros(11200), 1400, 125000, 1400), + Duration::ZERO + ); + // A change in loss compensation must not grant another initial burst. + assert!( + bucket.claim(now + Duration::from_micros(11200), 1400, 156250, 1400) > Duration::ZERO + ); + } + #[test] + fn bandwidth_negotiation_obeys_auto_and_the_lower_positive_limit() { + #[cfg(feature = "interop-test")] + let _case = crate::resources::case_events::Case::new( + "HYSTERIA2-UNIT", + "bandwidth_negotiation_obeys_auto_and_the_lower_positive_limit", + ); + for (up, response, expected) in [ + (0, "0", 0), + (0, "100000", 0), + (250000, "auto", 0), + (250000, "0", 250000), + (250000, "125000", 125000), + (125000, "250000", 125000), + ] { + assert_eq!(super::negotiate(up, response.as_bytes()).unwrap(), expected); + } + for invalid in [ + "", + "-1", + "+1", + "1Mbps", + "Auto", + " 0", + "18446744073709551616", + ] { + assert!(super::negotiate(0, invalid.as_bytes()).is_err()); + } + } +} diff --git a/src/outbound/hysteria2/datagram.rs b/src/outbound/hysteria2/datagram.rs new file mode 100644 index 0000000..e8dd090 --- /dev/null +++ b/src/outbound/hysteria2/datagram.rs @@ -0,0 +1,570 @@ +//! Raw QUIC DATAGRAM sessions; authenticated connection ownership stays in the node. +use crate::{ + dispatch::{DatagramBudget, DatagramTransport, DispatchError}, + resources::observation::{self, ResourceKind}, + session::{Datagram, Destination}, +}; +use async_trait::async_trait; +use bytes::{Buf, Bytes}; +use std::{ + collections::HashMap, + io, + sync::{ + Arc, Mutex, + atomic::{AtomicU32, Ordering}, + }, + time::Duration, +}; +use tokio::{sync::mpsc, time::Instant}; +use tokio_util::sync::CancellationToken; + +// Both the official native implementation and Mihomo bound relay payloads to +// 4096 bytes. udp-mtu is the *fragment message* budget, not this payload limit. +pub(super) const MAX_PAYLOAD: usize = crate::limits::HY2_UDP_PAYLOAD; +const QUEUE: usize = crate::limits::HY2_UDP_QUEUE; +const PENDING_PACKETS: usize = crate::limits::HY2_PENDING_PACKETS; +const PENDING_BYTES: usize = crate::limits::HY2_PENDING_BYTES; +const TTL: Duration = Duration::from_secs(crate::limits::HY2_FRAGMENT_TTL_SECONDS as u64); + +#[derive(Default)] +pub(super) struct Registry { + next: AtomicU32, + entries: Mutex>, +} +struct Registration { + sender: mpsc::Sender, + budget: u16, + _observation: observation::Guard, +} + +impl Registry { + pub fn open( + self: &Arc, + connection: quinn::Connection, + cancel: CancellationToken, + mtu: u16, + budget: DatagramBudget, + ) -> Result, DispatchError> { + let id = self + .next + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| { + value.checked_add(1) + }) + .map_err(|_| DispatchError::NotAllowed)?; + let (sender, receiver) = mpsc::channel(QUEUE); + let mut entries = self.entries.lock().unwrap(); + if cancel.is_cancelled() { + return Err(DispatchError::NotAllowed); + } + entries.insert( + id, + Registration { + sender, + budget: budget.receive(), + _observation: observation::track(ResourceKind::Association), + }, + ); + Ok(Box::new(Transport { + id, + packet: 0, + connection, + cancel, + registry: self.clone(), + receiver, + mtu, + budget, + closed: false, + })) + } + pub fn close(&self) { + self.entries.lock().unwrap().clear(); + } +} + +struct Transport { + id: u32, + packet: u16, + connection: quinn::Connection, + cancel: CancellationToken, + registry: Arc, + receiver: mpsc::Receiver, + mtu: u16, + budget: DatagramBudget, + closed: bool, +} +impl Transport { + fn close_local(&mut self) { + self.closed = true; + self.registry.entries.lock().unwrap().remove(&self.id); + self.receiver.close(); + while self.receiver.try_recv().is_ok() {} + } + fn message_budget(&self) -> usize { + self.connection + .max_datagram_size() + .unwrap_or(0) + .min(usize::from(self.mtu)) + } +} +impl Drop for Transport { + fn drop(&mut self) { + self.close_local(); + } +} + +#[async_trait] +impl DatagramTransport for Transport { + fn payload_budget(&self, peer: &Destination) -> DatagramBudget { + let space = self.message_budget().saturating_sub(header(peer).len()); + DatagramBudget::new((space * 255).min(MAX_PAYLOAD) as u16, MAX_PAYLOAD as u16) + .intersect(self.budget) + } + async fn send(&mut self, datagram: Datagram) -> Result<(), DispatchError> { + if self.closed || self.cancel.is_cancelled() { + return Err(DispatchError::NotAllowed); + } + if datagram.payload.len() > usize::from(self.payload_budget(&datagram.remote).transmit()) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Hysteria2 datagram exceeds payload budget", + ) + .into()); + } + let packets = encode( + self.id, + self.packet, + &datagram.remote, + &datagram.payload, + self.message_budget(), + )?; + self.packet = self.packet.wrapping_add(1); + for (index, packet) in packets.into_iter().enumerate() { + tokio::select! {biased; + () = self.cancel.cancelled() => return Err(DispatchError::NotAllowed), + result = self.connection.send_datagram_wait(packet) => result.map_err(|_| DispatchError::ConnectionRefused)?, + } + if (index + 1) % crate::limits::IO_POLL_BUDGET == 0 { + tokio::task::yield_now().await; + } + } + Ok(()) + } + async fn receive(&mut self) -> Result { + if self.closed { + return Err(DispatchError::NotAllowed); + } + tokio::select! {biased; + () = self.cancel.cancelled() => Err(DispatchError::NotAllowed), + received = self.receiver.recv() => received.ok_or(DispatchError::ConnectionRefused), + } + } + async fn close(&mut self) -> Result<(), DispatchError> { + self.close_local(); + Ok(()) + } +} + +fn header(peer: &Destination) -> Vec { + let address = peer.authority(); + let mut header = vec![0; 8]; + super::wire::encode_varint(address.len() as u64, &mut header); + header.extend_from_slice(address.as_bytes()); + header +} + +fn encode( + session: u32, + packet: u16, + peer: &Destination, + payload: &[u8], + mtu: usize, +) -> io::Result> { + if payload.len() > MAX_PAYLOAD || peer.port() == 0 { + return Err(io::ErrorKind::InvalidInput.into()); + } + let mut header = header(peer); + let space = mtu + .checked_sub(header.len()) + .filter(|space| *space > 0) + .ok_or(io::ErrorKind::InvalidInput)?; + let count = payload.len().max(1).div_ceil(space); + if count > 255 { + return Err(io::ErrorKind::InvalidInput.into()); + } + header[..4].copy_from_slice(&session.to_be_bytes()); + header[4..6].copy_from_slice(&packet.to_be_bytes()); + header[7] = count as u8; + let mut messages = Vec::with_capacity(count); + for index in 0..count { + let mut bytes = header.clone(); + bytes[6] = index as u8; + bytes.extend_from_slice( + &payload[(index * space).min(payload.len())..((index + 1) * space).min(payload.len())], + ); + messages.push(bytes.into()); + } + Ok(messages) +} + +struct Fragment { + session: u32, + packet: u16, + index: u8, + count: u8, + peer: Destination, + payload: Bytes, +} +fn decode(mut bytes: Bytes) -> io::Result { + if bytes.len() < 9 { + return Err(io::ErrorKind::InvalidData.into()); + } + let session = bytes.get_u32(); + let packet = bytes.get_u16(); + let index = bytes.get_u8(); + let count = bytes.get_u8(); + // In an unfragmented packet, the protocol says fragment and packet IDs + // are irrelevant. Do not reject peers which use a nonzero fragment ID. + if count == 0 || (count > 1 && index >= count) { + return Err(io::ErrorKind::InvalidData.into()); + } + let first = bytes.get_u8(); + let size = 1_usize << (first >> 6); + if bytes.len() < size - 1 { + return Err(io::ErrorKind::InvalidData.into()); + } + let mut length = u64::from(first & 0x3f); + for _ in 1..size { + length = (length << 8) | u64::from(bytes.get_u8()); + } + if length == 0 || length > 512 || bytes.len() < length as usize { + return Err(io::ErrorKind::InvalidData.into()); + } + let address = bytes.split_to(length as usize); + let address = std::str::from_utf8(&address).map_err(|_| io::ErrorKind::InvalidData)?; + let peer = Destination::from_authority(address).map_err(|_| io::ErrorKind::InvalidData)?; + if bytes.len() > MAX_PAYLOAD { + return Err(io::ErrorKind::InvalidData.into()); + } + Ok(Fragment { + session, + packet, + index, + count, + peer, + payload: bytes, + }) +} + +struct Pending { + peer: Destination, + expires: Instant, + pieces: Vec>, + bytes: usize, + received: usize, + _observation: observation::Guard, +} +#[derive(Default)] +struct Reassembly { + packets: HashMap<(u32, u16), Pending>, + bytes: usize, +} +impl Reassembly { + fn remove(&mut self, key: &(u32, u16)) -> Option { + let packet = self.packets.remove(key)?; + self.bytes -= packet.bytes; + Some(packet) + } + fn expire(&mut self, now: Instant, live: impl Fn(u32) -> bool) { + self.packets.retain(|&(session, _), packet| { + let keep = packet.expires > now && live(session); + if !keep { + self.bytes -= packet.bytes; + } + keep + }); + } + fn push(&mut self, fragment: Fragment, now: Instant, budget: usize) -> Option { + if fragment.payload.len() > budget { + return None; + } + if fragment.count == 1 { + return Some(Datagram { + remote: fragment.peer, + payload: fragment.payload, + sniffed_domain: None, + }); + } + let key = (fragment.session, fragment.packet); + if self + .packets + .get(&key) + .is_some_and(|packet| packet.expires <= now) + { + self.remove(&key); + } + if !self.packets.contains_key(&key) { + if self.packets.len() >= PENDING_PACKETS { + return None; + } + self.packets.insert( + key, + Pending { + peer: fragment.peer.clone(), + expires: now + TTL, + pieces: vec![None; usize::from(fragment.count)], + bytes: 0, + received: 0, + _observation: observation::track(ResourceKind::Reassembly), + }, + ); + } + let packet = self.packets.get_mut(&key).unwrap(); + if packet.peer != fragment.peer || packet.pieces.len() != usize::from(fragment.count) { + self.remove(&key); + return None; + } + if let Some(prior) = &packet.pieces[usize::from(fragment.index)] { + if prior != &fragment.payload { + self.remove(&key); + } + return None; + } + if packet.bytes + fragment.payload.len() > budget.min(MAX_PAYLOAD) + || self.bytes + fragment.payload.len() > PENDING_BYTES + { + self.remove(&key); + return None; + } + self.bytes += fragment.payload.len(); + packet.bytes += fragment.payload.len(); + packet.received += 1; + packet.pieces[usize::from(fragment.index)] = Some(fragment.payload); + if packet.received != packet.pieces.len() { + return None; + } + let packet = self.remove(&key).unwrap(); + let mut payload = Vec::with_capacity(packet.bytes); + for piece in packet.pieces { + payload.extend_from_slice(&piece.unwrap()); + } + // A Packet ID only identifies an unfinished assembly, not a replay + // nonce. Native Hysteria uses random u16 IDs; like Mihomo, release the + // completed ID immediately so a later legal reply is not suppressed. + Some(Datagram { + remote: packet.peer, + payload: payload.into(), + sniffed_domain: None, + }) + } +} + +pub(super) async fn receive( + connection: &quinn::Connection, + registry: &Registry, +) -> Result<(), DispatchError> { + let mut pending = Reassembly::default(); + let mut timer = tokio::time::interval(Duration::from_secs(1)); + timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut processed = 0; + loop { + let raw = tokio::select! {biased; + _ = timer.tick() => { + let entries = registry.entries.lock().unwrap(); + pending.expire(Instant::now(), |id| entries.contains_key(&id)); + continue; + }, + data = connection.read_datagram() => data.map_err(|_| DispatchError::ConnectionRefused)?, + }; + // Malformed/unknown/full packets are dropped, never queued without a + // live local association and never allowed to fail sibling sessions. + { + if let Ok(fragment) = decode(raw) { + let entries = registry.entries.lock().unwrap(); + if let Some(entry) = entries.get(&fragment.session) + && let Some(datagram) = + pending.push(fragment, Instant::now(), usize::from(entry.budget)) + && let Ok(permit) = entry.sender.try_reserve() + { + crate::resources::observation::observe_queue( + crate::resources::observation::QueueKind::Hysteria2Udp, + crate::limits::HY2_UDP_QUEUE - entry.sender.capacity(), + crate::limits::HY2_UDP_QUEUE, + ); + permit.send(datagram); + } + } + } + processed += 1; + if processed == crate::limits::IO_POLL_BUDGET { + processed = 0; + tokio::task::yield_now().await; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn udp_wire_round_trips_at_budget_and_bounds_malformed_fragments() { + #[cfg(feature = "interop-test")] + let _case = crate::resources::case_events::Case::new( + "HYSTERIA2-UNIT", + "udp_wire_round_trips_at_budget_and_bounds_malformed_fragments", + ); + let peer = Destination::domain("x", 1).unwrap(); + let literal = Bytes::from_static(b"\0\0\0\x01\0\x02\0\x01\x03x:1payload"); + assert_eq!( + encode(1, 2, &peer, b"payload", 1197).unwrap(), + vec![literal.clone()] + ); + let parsed = decode(literal).unwrap(); + assert_eq!((parsed.session, parsed.packet, parsed.count), (1, 2, 1)); + assert_eq!(parsed.payload, b"payload"[..]); + assert_eq!(parsed.peer, peer); + for size in [0, 1, 64, 512, 1200, 4096] { + for mtu in [64, 1197, 1400] { + let payload = vec![7; size]; + let frames = encode(1, 3, &peer, &payload, mtu).unwrap(); + assert!(frames.iter().all(|frame| frame.len() <= mtu)); + let mut state = Reassembly::default(); + let mut result = None; + for frame in frames.into_iter().rev() { + result = state + .push(decode(frame).unwrap(), Instant::now(), 4096) + .or(result); + } + assert_eq!(result.unwrap().payload, payload); + assert_eq!(state.bytes, 0); + } + } + assert!(encode(1, 3, &peer, &vec![0; 4097], 1400).is_err()); + assert!(encode(1, 3, &peer, b"x", 12).is_err()); + for bad in [ + b"".as_slice(), + b"\0\0\0\0\0\0\0\0\x01x", + b"\0\0\0\0\0\0\x02\x02\x03x:1", + b"\0\0\0\0\0\0\0\x01\xff", + b"\0\0\0\0\0\0\0\x01\x40\x00", + b"\0\0\0\0\0\0\0\x01\x01x", + ] { + assert!(decode(Bytes::copy_from_slice(bad)).is_err()); + } + } + + #[test] + fn udp_reassembly_isolates_sources_duplicates_counts_bytes_and_expiry() { + #[cfg(feature = "interop-test")] + let _case = crate::resources::case_events::Case::new( + "HYSTERIA2-UNIT", + "udp_reassembly_isolates_sources_duplicates_counts_bytes_and_expiry", + ); + let now = Instant::now(); + let peer = Destination::domain("x", 1).unwrap(); + let frames = encode(1, 2, &peer, &[5; 100], 64).unwrap(); + let mut state = Reassembly::default(); + assert!( + state + .push(decode(frames[1].clone()).unwrap(), now, 100) + .is_none() + ); + assert!( + state + .push(decode(frames[1].clone()).unwrap(), now, 100) + .is_none() + ); + assert_eq!( + state + .push(decode(frames[0].clone()).unwrap(), now, 100) + .unwrap() + .payload, + [5; 100][..] + ); + // Duplicate pieces of an unfinished packet are ignored above; once + // delivered, the ID may immediately identify another full datagram. + assert!( + state + .push(decode(frames[0].clone()).unwrap(), now, 100) + .is_none() + ); + assert_eq!( + state + .push(decode(frames[1].clone()).unwrap(), now, 100) + .unwrap() + .payload, + [5; 100][..] + ); + state.expire(now + TTL, |_| true); + assert!( + state + .push(decode(frames[0].clone()).unwrap(), now + TTL, 99) + .is_none() + ); + assert!( + state + .push(decode(frames[1].clone()).unwrap(), now + TTL, 99) + .is_none() + ); + assert!(state.packets.is_empty()); + for mutation in [0, 1, 2] { + assert!( + state + .push(decode(frames[0].clone()).unwrap(), now, 4096) + .is_none() + ); + let mut bad = decode(frames[0].clone()).unwrap(); + match mutation { + 0 => bad.peer = Destination::domain("other", 1).unwrap(), + 1 => bad.count += 1, + _ => bad.payload = Bytes::from_static(b"different"), + } + assert!(state.push(bad, now, 4096).is_none()); + assert!(state.packets.is_empty()); + assert_eq!(state.bytes, 0); + } + for session in 0..64 { + assert!( + state + .push( + Fragment { + session, + packet: 9, + index: 0, + count: 2, + peer: peer.clone(), + payload: vec![0; 4096].into() + }, + now, + 4096 + ) + .is_none() + ); + } + assert_eq!(state.packets.len(), 64); + assert_eq!(state.bytes, 256 * 1024); + assert!( + state + .push( + Fragment { + session: 65, + packet: 9, + index: 0, + count: 2, + peer: peer.clone(), + payload: Bytes::from_static(b"x") + }, + now, + 4096 + ) + .is_none() + ); + assert_eq!(state.packets.len(), 64); + state.expire(now + Duration::from_secs(4), |session| session != 0); + assert_eq!(state.packets.len(), 63); + assert_eq!(state.bytes, 63 * 4096); + state.expire(now + TTL, |_| true); + assert!(state.packets.is_empty()); + assert_eq!(state.bytes, 0); + } +} diff --git a/src/outbound/hysteria2/mod.rs b/src/outbound/hysteria2/mod.rs new file mode 100644 index 0000000..ceb9f69 --- /dev/null +++ b/src/outbound/hysteria2/mod.rs @@ -0,0 +1,10 @@ +//! Hysteria2 over the shared, protected QUIC transport. +mod bandwidth; +mod datagram; +mod outbound; +mod paths; +mod salamander; +mod socket; +mod stream; +mod wire; +pub use outbound::Hysteria2Outbound; diff --git a/src/outbound/hysteria2/outbound.rs b/src/outbound/hysteria2/outbound.rs new file mode 100644 index 0000000..e7d612a --- /dev/null +++ b/src/outbound/hysteria2/outbound.rs @@ -0,0 +1,386 @@ +use crate::{ + config::Hysteria2OutboundConfig, + dispatch::{DatagramTransport, DispatchError}, + outbound::{ + ConnectedStream, DatagramRequest, EstablishContext, OutboundConnector, UpstreamPath, + owned_stream::OwnedStream, server_destination, + }, + resources::observation::{self, ResourceKind}, + security::{ + SecurityContext, StandardTlsClient, TLS_RESUMPTION_SESSION_BUDGET, TlsClientIdentity, + TlsClientOptions, TlsVersions, + }, + session::{DatagramSession, Destination, StreamSession}, + transport::quic::{self, OwnedRuntime}, +}; +use async_trait::async_trait; +use bytes::Bytes; +use std::{ + io, + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::{ + io::AsyncWriteExt, + sync::{Mutex as AsyncMutex, oneshot}, +}; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; + +/// A node owns its authenticated connection and every driver used by that +/// connection. Individual streams never own the shared cancellation token. +pub struct Hysteria2Outbound { + config: Arc, + server: Destination, + upstream: UpstreamPath, + tls: Arc, + current: AsyncMutex>>, + cancel: CancellationToken, + admission: Mutex<()>, + tasks: TaskTracker, +} + +struct Session { + connection: quinn::Connection, + cancel: CancellationToken, + udp_enabled: bool, + datagrams: Arc, + #[cfg(feature = "interop-test")] + bandwidth: Arc, +} + +impl std::fmt::Debug for Hysteria2Outbound { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Hysteria2Outbound").finish_non_exhaustive() + } +} + +impl Hysteria2Outbound { + /// Test-only controller/path evidence; no configuration or traffic addresses. + #[cfg(feature = "interop-test")] + pub async fn congestion_observation(&self) -> Option<(u64, quinn::ConnectionStats)> { + self.current.lock().await.as_ref().map(|session| { + ( + session + .bandwidth + .rate + .load(std::sync::atomic::Ordering::Relaxed), + session.connection.stats(), + ) + }) + } + pub fn new_with_path( + config: &Hysteria2OutboundConfig, + upstream: UpstreamPath, + ) -> io::Result { + Self::with_shared_security( + config, + upstream, + &SecurityContext::new(), + TLS_RESUMPTION_SESSION_BUDGET, + 64 * 1024, + ) + } + + pub(crate) fn with_shared_security( + config: &Hysteria2OutboundConfig, + upstream: UpstreamPath, + security: &SecurityContext, + resumption_sessions: usize, + buffer_limit: usize, + ) -> io::Result { + let tls = StandardTlsClient::with_options( + security, + &config.tls.server_name, + TlsClientOptions { + versions: TlsVersions::Tls13, + alpn: config.tls.alpn.clone(), + certificate: config.tls.certificate.clone(), + identity: config + .tls + .identity + .as_ref() + .map(|identity| { + TlsClientIdentity::from_pem(&identity.certificate, &identity.private_key) + }) + .transpose()?, + ..Default::default() + }, + resumption_sessions, + buffer_limit, + )? + .quic_config()? + .0; + Ok(Self { + config: Arc::new(config.clone()), + server: server_destination(&config.address, config.port)?, + upstream, + tls, + current: AsyncMutex::new(None), + cancel: CancellationToken::new(), + admission: Mutex::new(()), + tasks: TaskTracker::new(), + }) + } + + async fn session( + &self, + datagram: DatagramSession, + context: &EstablishContext, + ) -> Result, DispatchError> { + if tokio::time::Instant::now() >= context.deadline() { + return Err(DispatchError::TimedOut); + } + let _waiter = observation::track(ResourceKind::Waiter); + tokio::select! {biased; + () = self.cancel.cancelled() => Err(DispatchError::NotAllowed), + result = context.run("Hysteria2 session", async { + let mut current = self.current.lock().await; + if let Some(session) = current.as_ref().filter(|session| + !session.cancel.is_cancelled() && session.connection.close_reason().is_none()) { + return Ok(session.clone()); + } + current.take(); + let (ready, reply) = oneshot::channel(); + let cancel = self.cancel.child_token(); + let guard = cancel.clone().drop_guard(); + { + let _admission = self.admission.lock().unwrap(); + if self.cancel.is_cancelled() { return Err(DispatchError::NotAllowed); } + let config = self.config.clone(); + let upstream = self.upstream.clone(); + let server = self.server.clone(); + let tls = self.tls.clone(); + let context = context.clone(); + let task = observation::track(ResourceKind::Task); + self.tasks.spawn(observation::bind(async move { + let _task = task; + drive(config, upstream, server, tls, datagram, context, cancel, ready).await; + })); + } + let session = reply.await.map_err(|_| DispatchError::ConnectionRefused)??; + guard.disarm(); + *current = Some(session.clone()); + Ok(session) + }) => result, + } + } +} + +#[async_trait] +impl OutboundConnector for Hysteria2Outbound { + async fn connect_stream( + &self, + session: StreamSession, + context: &EstablishContext, + ) -> Result { + let request = super::wire::tcp_request(&session.destination)?; + let shared = self + .session( + DatagramSession::new(session.inbound, session.source), + context, + ) + .await?; + tokio::select! {biased; + () = shared.cancel.cancelled() => Err(DispatchError::ConnectionRefused), + result = context.run_io("Hysteria2 TCP request", async { + let (mut send, recv) = shared.connection.open_bi().await.map_err(failure)?; + tokio::io::AsyncWriteExt::write_all(&mut send, &request).await?; + send.flush().await?; + let stream = super::stream::TcpStream::new(recv, send, context.deadline()); + Ok(ConnectedStream { + io: Box::new(OwnedStream::new(Box::new(stream), shared.cancel.child_token())), + effective_peer: session.destination, + }) + }) => result, + } + } + + async fn open_datagram( + &self, + request: DatagramRequest, + context: &EstablishContext, + ) -> Result, DispatchError> { + let session = self.session(request.session.clone(), context).await?; + if !session.udp_enabled || session.connection.max_datagram_size().is_none() { + return Err(DispatchError::NotAllowed); + } + session.datagrams.open( + session.connection.clone(), + session.cancel.clone(), + self.config.udp_mtu, + request.budget(), + ) + } + + fn begin_shutdown(&self) { + let _admission = self.admission.lock().unwrap(); + self.cancel.cancel(); + self.tasks.close(); + } + + async fn shutdown(&self) { + self.begin_shutdown(); + self.tasks.wait().await; + self.current.lock().await.take(); + } +} + +impl Drop for Hysteria2Outbound { + fn drop(&mut self) { + self.cancel.cancel(); + } +} + +#[allow(clippy::too_many_arguments)] +async fn drive( + config: Arc, + upstream: UpstreamPath, + server: Destination, + tls: Arc, + datagram: DatagramSession, + context: EstablishContext, + cancel: CancellationToken, + ready: oneshot::Sender, DispatchError>>, +) { + let runtime = Arc::new(OwnedRuntime::new(CancellationToken::new())); + let mut ready = Some(ready); + let mut endpoint = None; + let mut paths = None; + let datagrams = Arc::new(super::datagram::Registry::default()); + let bandwidth = Arc::new(super::bandwidth::Bandwidth::default()); + let _pool = observation::track(ResourceKind::Pool); + let operation = async { + let _handshake = observation::track(ResourceKind::Handshake); + let (path, socket) = super::paths::Paths::open( + config.clone(), + upstream, + &server, + datagram, + context, + bandwidth.clone(), + ) + .await?; + let peer = path.peer; + let mtu = path.mtu; + paths = Some(path); + let mut limits = quinn::TransportConfig::default(); + limits + .initial_mtu(mtu) + .min_mtu(mtu) + .mtu_discovery_config(None) + .max_concurrent_bidi_streams(0_u8.into()) + .max_concurrent_uni_streams((crate::limits::HY2_UNI_STREAMS as u32).into()) + .stream_receive_window((crate::limits::HY2_STREAM_WINDOW as u32).into()) + .receive_window((crate::limits::HY2_CONNECTION_WINDOW as u32).into()) + .send_window(crate::limits::HY2_CONNECTION_WINDOW as u64) + .datagram_receive_buffer_size(Some(crate::limits::HY2_DATAGRAM_BUFFER)) + .datagram_send_buffer_size(crate::limits::HY2_DATAGRAM_BUFFER) + .max_idle_timeout(Some(Duration::from_secs(30).try_into().map_err(failure)?)) + .keep_alive_interval(Some(Duration::from_secs(10))) + .congestion_controller_factory(bandwidth.clone()); + let crypto = quinn::crypto::rustls::QuicClientConfig::try_from(tls).map_err(failure)?; + let mut client = quinn::ClientConfig::new(Arc::new(crypto)); + client.transport_config(Arc::new(limits)); + let mut options = quinn::EndpointConfig::default(); + options.max_udp_payload_size(mtu).map_err(failure)?; + let mut ep = + quinn::Endpoint::new_with_abstract_socket(options, None, socket, runtime.clone())?; + ep.set_default_client_config(client); + endpoint = Some(ep); + let connection = endpoint + .as_ref() + .unwrap() + .connect(peer, &config.tls.server_name) + .map_err(failure)? + .await + .map_err(failure)?; + let (mut control, mut requests) = h3::client::builder() + .max_field_section_size(crate::limits::HY2_AUTH_HEADERS as u64) + .build::<_, _, Bytes>(h3_quinn::Connection::new(connection.clone())) + .await + .map_err(failure)?; + let request = http::Request::builder() + .method("POST") + .uri("https://hysteria/auth") + .header( + "Hysteria-Auth", + http::HeaderValue::from_bytes(config.password.as_bytes()).map_err(failure)?, + ) + .header("Hysteria-CC-RX", config.down.to_string()) + .header("Hysteria-Padding", "padding") + .body(()) + .map_err(failure)?; + // The driver must be polled during auth and retained afterward. Do not + // enable H3 DATAGRAM: Hysteria consumes raw QUIC DATAGRAM frames. + let auth = async { + let mut stream = requests.send_request(request).await.map_err(failure)?; + stream.finish().await.map_err(failure)?; + let response = stream.recv_response().await.map_err(failure)?; + if response.status().as_u16() != 233 { + return Err(io::ErrorKind::PermissionDenied.into()); + } + let udp_enabled = response + .headers() + .get("Hysteria-UDP") + .is_some_and(|value| value == "true"); + let server_rx = response + .headers() + .get("Hysteria-CC-RX") + .ok_or(io::ErrorKind::InvalidData)?; + let rate = super::bandwidth::negotiate(config.up, server_rx.as_bytes())?; + bandwidth + .rate + .store(rate, std::sync::atomic::Ordering::Relaxed); + Ok::<_, io::Error>(udp_enabled) + }; + let udp_enabled = tokio::select! { + _ = control.wait_idle() => return Err(DispatchError::ConnectionRefused), + result = auth => result?, + }; + drop(_handshake); + let session = Arc::new(Session { + connection: connection.clone(), + cancel: cancel.clone(), + udp_enabled, + datagrams: datagrams.clone(), + #[cfg(feature = "interop-test")] + bandwidth: bandwidth.clone(), + }); + ready + .take() + .unwrap() + .send(Ok(session)) + .map_err(|_| DispatchError::ConnectionRefused)?; + tokio::select! { + _ = connection.closed() => {}, + _ = control.wait_idle() => {}, + _ = super::datagram::receive(&connection, &datagrams) => {}, + _ = paths.as_mut().unwrap().hopping(endpoint.as_ref().unwrap()) => {}, + } + drop(requests); + Ok::<_, DispatchError>(()) + }; + let result = tokio::select! {biased; + () = cancel.cancelled() => Err(DispatchError::NotAllowed), + result = operation => result, + }; + cancel.cancel(); + datagrams.close(); + if let Some(ready) = ready { + let _ = ready.send(Err(result + .err() + .unwrap_or(DispatchError::ConnectionRefused))); + } + if let Some(endpoint) = endpoint { + endpoint.close(0_u8.into(), b""); + let _ = tokio::time::timeout(quic::CLOSE_TIMEOUT, endpoint.wait_idle()).await; + } + runtime.stop().await; + if let Some(mut paths) = paths { + paths.stop().await; + } +} + +fn failure(_: impl std::fmt::Display) -> io::Error { + io::Error::other("Hysteria2 QUIC exchange failed") +} diff --git a/src/outbound/hysteria2/paths.rs b/src/outbound/hysteria2/paths.rs new file mode 100644 index 0000000..dd95189 --- /dev/null +++ b/src/outbound/hysteria2/paths.rs @@ -0,0 +1,149 @@ +//! Bounded physical paths for one authenticated QUIC connection. +use super::{bandwidth::Bandwidth, salamander, socket::WindowSocket}; +use crate::{ + config::Hysteria2OutboundConfig, + dispatch::{DatagramBudget, DispatchError}, + outbound::{DatagramRequest, EstablishContext, UpstreamPath}, + session::{DatagramSession, Destination}, + transport::quic::{self, DatagramDriver, DatagramSocket}, +}; +use std::{io, net::SocketAddr, sync::Arc, time::Duration}; +use tokio::time::Instant; + +const QUIC_PAYLOAD: u16 = crate::limits::HY2_QUIC_PAYLOAD as u16; +const RETIRE_AFTER: Duration = Duration::from_secs(crate::limits::HY2_PATH_RETIRE_SECONDS as u64); + +pub(super) struct Paths { + config: Arc, + upstream: UpstreamPath, + datagram: DatagramSession, + context: EstablishContext, + bandwidth: Arc, + pub peer: SocketAddr, + pub mtu: u16, + current: Option, + retiring: Option, + current_socket: Option>, +} +impl Paths { + pub async fn open( + config: Arc, + upstream: UpstreamPath, + server: &Destination, + datagram: DatagramSession, + context: EstablishContext, + bandwidth: Arc, + ) -> Result<(Self, Arc), DispatchError> { + let destination = upstream.datagram_server(server, &context)?; + let peer = context.resolve_ip(&destination).await?; + let mut paths = Self { + config, + upstream, + datagram, + context, + bandwidth, + peer, + mtu: 0, + current: None, + retiring: None, + current_socket: None, + }; + let (socket, driver) = paths.attach(&paths.context).await?; + paths.current = Some(driver); + paths.mtu = socket.budget().quic_payload_limit()?; + paths.current_socket = Some(socket.clone()); + Ok((paths, WindowSocket::new(socket, None))) + } + fn physical_peer(&self) -> SocketAddr { + let mut peer = self.peer; + if let Some(hopping) = &self.config.hopping { + peer.set_port(hopping.ports[rand::random_range(0..hopping.ports.len())]); + } + peer + } + fn interval(&self) -> Duration { + let hopping = self.config.hopping.as_ref().unwrap(); + Duration::from_secs(u64::from(rand::random_range( + hopping.min_seconds..=hopping.max_seconds, + ))) + } + async fn attach( + &self, + context: &EstablishContext, + ) -> Result<(Arc, DatagramDriver), DispatchError> { + let wire_mtu = QUIC_PAYLOAD + + if self.config.obfs_password.is_some() { + salamander::OVERHEAD + } else { + 0 + }; + let request = DatagramRequest::new(self.datagram.clone()) + .with_budget(DatagramBudget::new(wire_mtu, wire_mtu)); + let raw = self.upstream.open_datagram(request, context).await?; + // Pace the encrypted wire packet, including the optional salt. + let raw = self.bandwidth.wrap(raw, wire_mtu); + let raw = if let Some(password) = &self.config.obfs_password { + salamander::wrap(raw, password) + } else { + raw + }; + Ok(quic::attach_mapped( + raw, + self.peer, + self.physical_peer(), + DatagramBudget::new(QUIC_PAYLOAD, QUIC_PAYLOAD), + )?) + } + pub async fn hopping(&mut self, endpoint: &quinn::Endpoint) -> Result<(), DispatchError> { + if self.config.hopping.is_none() { + return std::future::pending().await; + } + // The first socket has now authenticated: all lazy upstream choices + // are known. Never sample the live group again for this connection. + self.context = self.context.authenticated_continuation(); + let mut next = Instant::now() + self.interval(); + loop { + tokio::time::sleep_until(next).await; + let context = self.context.authenticated_continuation(); + let (socket, driver) = context + .run("Hysteria2 hop path", self.attach(&context)) + .await?; + self.retiring = Some(driver); + if socket.budget().quic_payload_limit()? < self.mtu { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Hysteria2 hop path budget decreased", + ) + .into()); + } + let old = self.current_socket.replace(socket.clone()).unwrap(); + let window = WindowSocket::new(socket, Some(old)); + endpoint.rebind_abstract(window.clone())?; + std::mem::swap(&mut self.current, &mut self.retiring); + // Measure the next interval from the switch, not from the end of + // retirement, so the grace period never stretches configured hops. + next = Instant::now() + self.interval(); + tokio::time::sleep(RETIRE_AFTER).await; + window.retire_previous(); + // Keep ownership while joining. If node Stop cancels this future, + // stop() resumes the same join rather than losing an aborted task. + self.retiring.as_mut().unwrap().join().await?; + self.retiring.take(); + } + } + pub async fn stop(&mut self) { + let current = async { + if let Some(driver) = self.current.as_mut() { + let _ = driver.join().await; + } + }; + let retiring = async { + if let Some(driver) = self.retiring.as_mut() { + let _ = driver.join().await; + } + }; + tokio::join!(current, retiring); + self.current.take(); + self.retiring.take(); + } +} diff --git a/src/outbound/hysteria2/salamander.rs b/src/outbound/hysteria2/salamander.rs new file mode 100644 index 0000000..78ab08e --- /dev/null +++ b/src/outbound/hysteria2/salamander.rs @@ -0,0 +1,116 @@ +//! Independent implementation of the public Salamander wire specification. +//! This is obfuscation, not authentication; QUIC authenticates decoded packets. +use crate::{ + dispatch::{DatagramBudget, DatagramTransport, DispatchError}, + session::{Datagram, Destination}, +}; +use async_trait::async_trait; +use blake2::{Blake2b256, Digest}; +use bytes::Bytes; +use std::{io, sync::Arc}; + +pub(super) const OVERHEAD: u16 = 8; + +pub(super) fn wrap( + inner: Box, + password: &str, +) -> Box { + Box::new(Salamander { + inner, + password: Arc::from(password.as_bytes()), + }) +} +struct Salamander { + inner: Box, + password: Arc<[u8]>, +} + +fn mask(password: &[u8], salt: &[u8], payload: &mut [u8]) { + let mut hash = Blake2b256::new(); + hash.update(password); + hash.update(salt); + let key = hash.finalize(); + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= key[index % 32]; + } +} +fn encode(password: &[u8], salt: [u8; 8], payload: &[u8]) -> Bytes { + let mut wire = Vec::with_capacity(payload.len() + usize::from(OVERHEAD)); + wire.extend_from_slice(&salt); + wire.extend_from_slice(payload); + mask(password, &salt, &mut wire[8..]); + wire.into() +} +fn decode(password: &[u8], wire: Bytes) -> Option { + if wire.len() <= usize::from(OVERHEAD) { + return None; + } + let mut payload = wire[8..].to_vec(); + mask(password, &wire[..8], &mut payload); + Some(payload.into()) +} + +#[async_trait] +impl DatagramTransport for Salamander { + fn payload_budget(&self, peer: &Destination) -> DatagramBudget { + self.inner.payload_budget(peer).subtract_overhead(8, 8) + } + async fn send(&mut self, mut packet: Datagram) -> Result<(), DispatchError> { + if packet.payload.is_empty() + || packet.payload.len() > usize::from(self.payload_budget(&packet.remote).transmit()) + { + return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); + } + packet.payload = encode(&self.password, rand::random(), &packet.payload); + self.inner.send(packet).await + } + async fn receive(&mut self) -> Result { + loop { + for _ in 0..crate::limits::IO_POLL_BUDGET { + let mut packet = self.inner.receive().await?; + let maximum = self.inner.payload_budget(&packet.remote).receive(); + if packet.payload.len() > usize::from(maximum) { + continue; + } + if let Some(payload) = decode(&self.password, packet.payload) { + packet.payload = payload; + return Ok(packet); + } + } + tokio::task::yield_now().await; + } + } + async fn close(&mut self) -> Result<(), DispatchError> { + self.inner.close().await + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn salamander_matches_independent_blake2b256_vector_and_rejects_short_packets() { + #[cfg(feature = "interop-test")] + let _case = crate::resources::case_events::Case::new( + "HYSTERIA2-UNIT", + "salamander_matches_independent_blake2b256_vector_and_rejects_short_packets", + ); + // Python hashlib.blake2b(digest_size=32), password || salt; not a + // truncated BLAKE2b-512 and not a keyed BLAKE2 MAC. + let literal = "0001020304050607c4df23f89a88b3e3c7615199d4aaa6d88c9d92aa39a1914b372f674df11d4745e4ff03d8baa893c3"; + let wire: Vec = (0..literal.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&literal[i..i + 2], 16).unwrap()) + .collect(); + let plain: Vec = (0..40).collect(); + assert_eq!( + encode(b"fixture-obfs", [0, 1, 2, 3, 4, 5, 6, 7], &plain), + wire + ); + assert_eq!(decode(b"fixture-obfs", wire.clone().into()).unwrap(), plain); + assert_ne!(decode(b"wrong", wire.into()).unwrap(), plain); + for size in 0..=8 { + assert!(decode(b"fixture-obfs", vec![0; size].into()).is_none()); + } + } +} diff --git a/src/outbound/hysteria2/socket.rs b/src/outbound/hysteria2/socket.rs new file mode 100644 index 0000000..3d7b5a2 --- /dev/null +++ b/src/outbound/hysteria2/socket.rs @@ -0,0 +1,186 @@ +//! Receive ownership across a bounded physical-path handover. +use quinn::{AsyncUdpSocket, UdpPoller, udp::RecvMeta}; +use std::{ + io::{self, IoSliceMut}, + net::SocketAddr, + pin::Pin, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + task::{Context, Poll}, +}; + +#[derive(Debug)] +pub(super) struct WindowSocket { + current: Arc, + previous: Mutex>>, + previous_first: AtomicBool, +} +impl WindowSocket { + pub fn new( + current: Arc, + previous: Option>, + ) -> Arc { + Arc::new(Self { + current, + previous: Mutex::new(previous), + previous_first: AtomicBool::new(false), + }) + } + pub fn retire_previous(&self) { + self.previous.lock().unwrap().take(); + } +} +impl AsyncUdpSocket for WindowSocket { + fn create_io_poller(self: Arc) -> Pin> { + self.current.clone().create_io_poller() + } + fn try_send(&self, transmit: &quinn::udp::Transmit<'_>) -> io::Result<()> { + self.current.try_send(transmit) + } + fn poll_recv( + &self, + cx: &mut Context<'_>, + buffers: &mut [IoSliceMut<'_>], + meta: &mut [RecvMeta], + ) -> Poll> { + // Quinn retires its previous socket after the first packet on the new + // one. Keep the whole grace window behind its *current* socket so late + // old-path datagrams are still read, not merely buffered until Drop. + let previous = self.previous.lock().unwrap().clone(); + let first = self.previous_first.fetch_xor(true, Ordering::Relaxed); + for old in [first, !first] { + let socket = if old { + let Some(socket) = previous.as_deref() else { + continue; + }; + socket + } else { + &*self.current + }; + match socket.poll_recv(cx, buffers, meta) { + Poll::Pending => {} + Poll::Ready(Err(_)) if old => self.retire_previous(), + ready => return ready, + } + } + Poll::Pending + } + fn local_addr(&self) -> io::Result { + self.current.local_addr() + } + fn may_fragment(&self) -> bool { + self.current.may_fragment() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use tokio::sync::mpsc; + + // External datagram IO is in memory; no host socket or TLS server exists. + #[derive(Debug)] + struct MemorySocket(Mutex>); + #[derive(Debug)] + struct ReadyPoller; + impl UdpPoller for ReadyPoller { + fn poll_writable(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + impl AsyncUdpSocket for MemorySocket { + fn create_io_poller(self: Arc) -> Pin> { + Box::pin(ReadyPoller) + } + fn try_send(&self, _: &quinn::udp::Transmit<'_>) -> io::Result<()> { + Ok(()) + } + fn poll_recv( + &self, + cx: &mut Context<'_>, + buffers: &mut [IoSliceMut<'_>], + meta: &mut [RecvMeta], + ) -> Poll> { + let Some(packet) = std::task::ready!(self.0.lock().unwrap().poll_recv(cx)) else { + return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into())); + }; + buffers[0][..packet.len()].copy_from_slice(&packet); + meta[0] = RecvMeta { + addr: "192.0.2.1:443".parse().unwrap(), + len: packet.len(), + stride: packet.len(), + ecn: None, + dst_ip: None, + }; + Poll::Ready(Ok(1)) + } + fn local_addr(&self) -> io::Result { + Ok("0.0.0.0:0".parse().unwrap()) + } + } + fn memory_socket() -> (mpsc::Sender, Arc) { + let (tx, rx) = mpsc::channel(4); + (tx, Arc::new(MemorySocket(Mutex::new(rx)))) + } + async fn receive(socket: &dyn AsyncUdpSocket) -> Bytes { + let mut bytes = [0; 64]; + let mut meta = [RecvMeta::default()]; + let count = futures_util::future::poll_fn(|cx| { + socket.poll_recv(cx, &mut [IoSliceMut::new(&mut bytes)], &mut meta) + }) + .await + .unwrap(); + assert_eq!(count, 1); + Bytes::copy_from_slice(&bytes[..meta[0].len]) + } + #[tokio::test] + async fn a_new_path_packet_does_not_end_the_old_receive_window() { + #[cfg(feature = "interop-test")] + let _case = crate::resources::case_events::Case::new( + "HYSTERIA2-UNIT", + "a_new_path_packet_does_not_end_the_old_receive_window", + ); + let (old_tx, old) = memory_socket(); + let (new_tx, new) = memory_socket(); + let socket = WindowSocket::new(new, Some(old)); + new_tx.send(Bytes::from_static(b"new")).await.unwrap(); + assert_eq!(receive(&*socket).await, b"new"[..]); + // A delayed old-path datagram can arrive after the first new-path ACK. + old_tx.send(Bytes::from_static(b"old-late")).await.unwrap(); + assert_eq!( + tokio::time::timeout(std::time::Duration::from_millis(50), receive(&*socket)) + .await + .expect("old receive path ended before its grace window"), + b"old-late"[..] + ); + // A continuously ready path cannot starve the other during handover. + for _ in 0..2 { + old_tx.send(Bytes::from_static(b"old")).await.unwrap(); + new_tx.send(Bytes::from_static(b"new")).await.unwrap(); + } + for expected in [b"new", b"old", b"new", b"old"] { + assert_eq!(receive(&*socket).await, expected[..]); + } + // Exercise a pending reader, rather than only pre-buffered datagrams. + for (sender, expected) in [(&old_tx, b"old"), (&new_tx, b"new")] { + let reading = socket.clone(); + let reader = tokio::spawn(async move { receive(&*reading).await }); + tokio::task::yield_now().await; + sender.send(Bytes::from_static(expected)).await.unwrap(); + assert_eq!( + tokio::time::timeout(std::time::Duration::from_millis(50), reader) + .await + .expect("path did not wake the pending reader") + .unwrap(), + expected[..] + ); + } + socket.retire_previous(); + assert!(old_tx.send(Bytes::from_static(b"retired")).await.is_err()); + new_tx.send(Bytes::from_static(b"new-again")).await.unwrap(); + assert_eq!(receive(&*socket).await, b"new-again"[..]); + } +} diff --git a/src/outbound/hysteria2/stream.rs b/src/outbound/hysteria2/stream.rs new file mode 100644 index 0000000..fd98a3c --- /dev/null +++ b/src/outbound/hysteria2/stream.rs @@ -0,0 +1,232 @@ +//! Consume the response on the first read without blocking client-first writes. +//! Mihomo can combine the successful response header with the origin's first +//! bytes. Waiting for that header in connect_stream deadlocks request/response IO. +use std::{ + future::Future, + io, + pin::Pin, + sync::Arc, + task::{Context, Poll, Wake, Waker}, +}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + +type Response = Pin> + Send>>; + +#[derive(Default)] +struct ResponseWakeups { + reader: futures_util::task::AtomicWaker, + writer: futures_util::task::AtomicWaker, +} +impl Wake for ResponseWakeups { + fn wake(self: Arc) { + self.wake_by_ref(); + } + fn wake_by_ref(self: &Arc) { + self.reader.wake(); + self.writer.wake(); + } +} + +pub(super) struct TcpStream { + response: Option>, + reader: Option, + writer: Option, + closing: bool, + wakeups: Arc, +} + +impl TcpStream { + pub fn new(mut reader: R, writer: W, deadline: tokio::time::Instant) -> Self { + Self { + response: Some(Box::pin(async move { + tokio::time::timeout_at(deadline, super::wire::tcp_response(&mut reader)) + .await + .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))??; + Ok(reader) + })), + reader: None, + writer: Some(writer), + closing: false, + wakeups: Arc::default(), + } + } +} + +impl TcpStream { + fn poll_response(&mut self) -> Poll> { + if self.closing || self.writer.is_none() { + return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into())); + } + let Some(response) = self.response.as_mut() else { + return Poll::Ready(Ok(())); + }; + // Reads and opportunistic writes poll the same response future. Its + // underlying reader has one wake slot; fan it out to both callers. + let waker = Waker::from(self.wakeups.clone()); + match response.as_mut().poll(&mut Context::from_waker(&waker)) { + Poll::Ready(Ok(reader)) => { + self.reader = Some(reader); + self.response = None; + self.wakeups.reader.wake(); + Poll::Ready(Ok(())) + } + Poll::Ready(Err(error)) => { + self.response = None; + self.writer = None; + self.wakeups.wake_by_ref(); + Poll::Ready(Err(error)) + } + Poll::Pending => Poll::Pending, + } + } +} + +impl AsyncRead for TcpStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if buf.remaining() == 0 { + return Poll::Ready(Ok(())); + } + self.wakeups.reader.register(cx.waker()); + std::task::ready!(self.poll_response())?; + Pin::new(self.reader.as_mut().unwrap()).poll_read(cx, buf) + } +} + +impl AsyncWrite for TcpStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &[u8], + ) -> Poll> { + // Opportunistically consume an already available response/failure and + // enforce the original deadline, but never wait for target-first data. + self.wakeups.writer.register(cx.waker()); + if let Poll::Ready(Err(error)) = self.poll_response() { + return Poll::Ready(Err(error)); + } + Pin::new(self.writer.as_mut().unwrap()).poll_write(cx, bytes) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.wakeups.writer.register(cx.waker()); + if let Poll::Ready(Err(error)) = self.poll_response() { + return Poll::Ready(Err(error)); + } + Pin::new(self.writer.as_mut().unwrap()).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + // Mihomo cancels QUIC reads and closes its write side on application + // EOF. This closes the logical stream, never the shared connection. + self.closing = true; + self.response.take(); + self.reader.take(); + self.wakeups.wake_by_ref(); + let Some(writer) = self.writer.as_mut() else { + return Poll::Ready(Ok(())); + }; + let result = Pin::new(writer).poll_shutdown(cx); + if result.is_ready() { + self.writer.take(); + } + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[tokio::test] + async fn a_client_first_write_does_not_steal_a_pending_read_wakeup() { + #[cfg(feature = "interop-test")] + let _case = crate::resources::case_events::Case::new( + "HYSTERIA2-UNIT", + "a_client_first_write_does_not_steal_a_pending_read_wakeup", + ); + let (client, mut peer) = tokio::io::duplex(128); + let (reader, writer) = tokio::io::split(client); + let stream = TcpStream::new( + reader, + writer, + tokio::time::Instant::now() + std::time::Duration::from_secs(1), + ); + let (mut reader, mut writer) = tokio::io::split(stream); + let reading = tokio::spawn(async move { reader.read_u8().await }); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + writer.write_all(b"q").await.unwrap(); + assert_eq!(peer.read_u8().await.unwrap(), b'q'); + peer.write_all(b"\0\0\0r").await.unwrap(); + assert_eq!( + tokio::time::timeout(std::time::Duration::from_millis(100), reading) + .await + .unwrap() + .unwrap() + .unwrap(), + b'r' + ); + } + + #[tokio::test] + async fn application_eof_closes_both_directions_without_consuming_a_tail() { + #[cfg(feature = "interop-test")] + let _case = crate::resources::case_events::Case::new( + "HYSTERIA2-UNIT", + "application_eof_closes_both_directions_without_consuming_a_tail", + ); + let (client, mut peer) = tokio::io::duplex(128); + let (reader, writer) = tokio::io::split(client); + let mut stream = TcpStream::new( + reader, + writer, + tokio::time::Instant::now() + std::time::Duration::from_secs(1), + ); + peer.write_all(b"\0\0\0hello").await.unwrap(); + let mut hello = [0; 5]; + stream.read_exact(&mut hello).await.unwrap(); + assert_eq!(&hello, b"hello"); + stream.shutdown().await.unwrap(); + assert_eq!(peer.read(&mut [0; 1]).await.unwrap(), 0); + // Even without any peer response, upload EOF must wake a pending read. + assert!( + tokio::time::timeout(std::time::Duration::from_millis(50), stream.read_u8()) + .await + .unwrap() + .is_err() + ); + stream.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn shutdown_wakes_an_already_pending_logical_reader() { + #[cfg(feature = "interop-test")] + let _case = crate::resources::case_events::Case::new( + "HYSTERIA2-UNIT", + "shutdown_wakes_an_already_pending_logical_reader", + ); + let (client, mut peer) = tokio::io::duplex(128); + let (reader, writer) = tokio::io::split(client); + let mut stream = TcpStream::new( + reader, + writer, + tokio::time::Instant::now() + std::time::Duration::from_secs(1), + ); + peer.write_all(b"\0\0\0a").await.unwrap(); + assert_eq!(stream.read_u8().await.unwrap(), b'a'); + let (mut reader, mut writer) = tokio::io::split(stream); + let reading = tokio::spawn(async move { reader.read_u8().await }); + tokio::task::yield_now().await; + assert!(!reading.is_finished()); + writer.shutdown().await.unwrap(); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(50), reading) + .await + .unwrap() + .unwrap() + .is_err() + ); + } +} diff --git a/src/outbound/hysteria2/wire.rs b/src/outbound/hysteria2/wire.rs new file mode 100644 index 0000000..98bf9da --- /dev/null +++ b/src/outbound/hysteria2/wire.rs @@ -0,0 +1,86 @@ +use crate::session::Destination; +use std::io; +use tokio::io::{AsyncRead, AsyncReadExt}; + +pub(super) fn tcp_request(destination: &Destination) -> io::Result> { + let address = destination.authority(); + if destination.port() == 0 || address.len() > 2048 { + return Err(io::ErrorKind::InvalidInput.into()); + } + let mut bytes = Vec::with_capacity(address.len() + 12); + encode_varint(0x401, &mut bytes); + encode_varint(address.len() as u64, &mut bytes); + bytes.extend_from_slice(address.as_bytes()); + encode_varint(0, &mut bytes); + Ok(bytes) +} + +pub(super) async fn tcp_response(reader: &mut (impl AsyncRead + Unpin)) -> io::Result<()> { + let status = reader.read_u8().await?; + let mut scratch = [0; crate::limits::HY2_RESPONSE_PADDING]; + for limit in [ + crate::limits::HY2_RESPONSE_MESSAGE, + crate::limits::HY2_RESPONSE_PADDING, + ] { + let length = read_varint(reader).await?; + if length > limit as u64 { + return Err(io::ErrorKind::InvalidData.into()); + } + reader.read_exact(&mut scratch[..length as usize]).await?; + } + if status != 0 { + return Err(io::ErrorKind::ConnectionRefused.into()); + } + Ok(()) +} + +pub(super) fn encode_varint(value: u64, output: &mut Vec) { + if value < 64 { + output.push(value as u8); + } else if value < 16384 { + output.extend_from_slice(&((value as u16) | 0x4000).to_be_bytes()); + } else if value < (1 << 30) { + output.extend_from_slice(&((value as u32) | 0x8000_0000).to_be_bytes()); + } else { + output.extend_from_slice(&(value | 0xc000_0000_0000_0000).to_be_bytes()); + } +} + +async fn read_varint(reader: &mut (impl AsyncRead + Unpin)) -> io::Result { + let first = reader.read_u8().await?; + let mut value = u64::from(first & 0x3f); + for _ in 1..(1 << (first >> 6)) { + value = (value << 8) | u64::from(reader.read_u8().await?); + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn tcp_wire_matches_the_protocol_and_preserves_server_first_bytes() { + #[cfg(feature = "interop-test")] + let _case = crate::resources::case_events::Case::new( + "HYSTERIA2-UNIT", + "tcp_wire_matches_the_protocol_and_preserves_server_first_bytes", + ); + let destination = Destination::domain("x", 1).unwrap(); + assert_eq!(tcp_request(&destination).unwrap(), b"\x44\x01\x03x:1\x00"); + let mut response = &b"\0\x02ok\x03padserver-first"[..]; + tcp_response(&mut response).await.unwrap(); + assert_eq!(response, b"server-first"); + for (bytes, kind) in [ + (&b"\x01\0\0"[..], io::ErrorKind::ConnectionRefused), + (&b"\0\x40"[..], io::ErrorKind::UnexpectedEof), + (&b"\0\x48\x01"[..], io::ErrorKind::InvalidData), + (&b"\0\0\x50\x01"[..], io::ErrorKind::InvalidData), + ] { + assert_eq!( + tcp_response(&mut &bytes[..]).await.unwrap_err().kind(), + kind + ); + } + } +} diff --git a/src/outbound/mod.rs b/src/outbound/mod.rs index bb93620..b43f560 100644 --- a/src/outbound/mod.rs +++ b/src/outbound/mod.rs @@ -1,5 +1,11 @@ //! Composable proxy connectors and the built-in DIRECT dispatcher. +pub mod address; +#[cfg(feature = "outbound-trojan")] +pub mod trojan; +#[cfg(feature = "outbound-vmess")] +pub mod vmess; + #[cfg(feature = "outbound-anytls")] mod anytls; mod connector; @@ -13,58 +19,13 @@ mod socks5; #[cfg(feature = "outbound-vless")] mod vless; -#[cfg(feature = "outbound-vless")] -use std::{io, sync::Arc}; - -#[cfg(feature = "outbound-vless")] -use async_trait::async_trait; -#[cfg(feature = "outbound-vless")] -use tokio::io::AsyncWriteExt as _; - -#[cfg(feature = "outbound-vless")] -use crate::{ - config::{VlessOutboundConfig, XHttpMode as ConfigXHttpMode}, - dialer::{Dialer, ResolvedEndpoint}, - dispatch::{BoxStream, DatagramTransport, DispatchError, Dispatcher}, - security::{SecurityClient, SecurityContext, TLS_RESUMPTION_SESSION_BUDGET}, - session::{DatagramSession, StreamSession}, - transport::xhttp::{XHttpClient, XHttpConfig, XHttpMode}, - xudp::XudpTransport, -}; - -#[cfg(feature = "outbound-vless")] -const DEFAULT_VLESS_TLS_BUFFER_LIMIT: usize = 64 * 1024; - -#[cfg(feature = "outbound-vless")] -#[derive(Clone, Copy, Debug)] -pub(crate) struct VlessResourceLimits { - tls_buffer_limit: usize, - xhttp_send_buffer_size: usize, - xhttp_upload_chunk_size: usize, -} - -#[cfg(feature = "outbound-vless")] -impl VlessResourceLimits { - pub(crate) const fn new( - tls_buffer_limit: usize, - xhttp_send_buffer_size: usize, - xhttp_upload_chunk_size: usize, - ) -> Self { - Self { - tls_buffer_limit, - xhttp_send_buffer_size, - xhttp_upload_chunk_size, - } - } -} - #[cfg(feature = "outbound-anytls")] pub use anytls::{AnyTlsLifecycle, AnyTlsOutbound, AnyTlsStream, AnyTlsTlsConnector}; #[cfg(any(feature = "ffi", test))] pub(crate) use connector::SelectUpstreamMember; pub use connector::{ - ConnectedStream, ConnectorDispatcher, DatagramRequest, EstablishContext, OutboundConnector, - SelectUpstream, UpstreamPath, server_destination, + ConnectedStream, ConnectorDispatcher, DEFAULT_ESTABLISH_TIMEOUT, DatagramRequest, + EstablishContext, OutboundConnector, SelectUpstream, UpstreamPath, server_destination, }; pub(crate) use connector::{ MAX_OUTBOUND_DIAGNOSTIC_MESSAGE_BYTES, OutboundDiagnostic, capture_outbound_diagnostic, @@ -73,742 +34,23 @@ pub use direct::DirectOutbound; #[cfg(feature = "outbound-socks5")] pub use socks5::{Socks5Auth, Socks5Outbound}; #[cfg(feature = "outbound-vless")] -pub use vless::{VlessCommand, VlessStream, encode_request_header, read_response_header}; - +pub(crate) use vless::VlessResourceLimits; +#[cfg(all(feature = "outbound-vless", feature = "interop-test"))] +#[doc(hidden)] +pub use vless::encryption::Client as VlessEncryptionClient; #[cfg(feature = "outbound-vless")] -#[derive(Clone)] -struct VlessTransportLeg { - server: crate::session::Destination, - upstream: UpstreamPath, - security: SecurityClient, -} - +pub(crate) use vless::encryption::validate_config as validate_vless_encryption; #[cfg(feature = "outbound-vless")] -impl std::fmt::Debug for VlessTransportLeg { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("VlessTransportLeg") - .field("server", &self.server) - .field("upstream", &self.upstream) - .field("security", &self.security) - .finish() - } -} +pub use vless::{ + VlessCommand, VlessOutbound, VlessStream, encode_request_header, read_response_header, +}; -#[cfg(feature = "outbound-vless")] -#[derive(Clone)] -struct VlessDownloadLeg { - transport: VlessTransportLeg, - xhttp: XHttpClient, -} - -#[cfg(feature = "outbound-vless")] -impl std::fmt::Debug for VlessDownloadLeg { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("VlessDownloadLeg") - .field("transport", &self.transport) - .field("xhttp", &self.xhttp) - .finish() - } -} - -#[cfg(feature = "outbound-vless")] -#[derive(Clone)] -pub struct VlessOutbound { - uuid: uuid::Uuid, - upload: VlessTransportLeg, - xhttp: XHttpClient, - download: Option, -} - -#[cfg(feature = "outbound-vless")] -impl std::fmt::Debug for VlessOutbound { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("VlessOutbound") - .field("uuid", &self.uuid) - .field("upload", &self.upload) - .field("xhttp", &self.xhttp) - .field("download", &self.download) - .finish() - } -} - -#[cfg(feature = "outbound-vless")] -impl VlessOutbound { - /// Builds an outbound from configuration and an endpoint resolved during - /// the host's prepare phase, before the VPN becomes active. - pub fn new( - config: &VlessOutboundConfig, - endpoint: ResolvedEndpoint, - dialer: Dialer, - ) -> io::Result { - Self::new_with_endpoints(config, endpoint, None, dialer) - } - - /// Builds a directly connected node from endpoints resolved during the - /// prepare phase. A distinct download server requires its own prepared - /// endpoint; an identical server reuses the primary resolution. - pub fn new_with_endpoints( - config: &VlessOutboundConfig, - endpoint: ResolvedEndpoint, - download_endpoint: Option, - dialer: Dialer, - ) -> io::Result { - let (upload_path, download_path) = - Self::direct_paths(config, endpoint, download_endpoint, dialer)?; - Self::new_with_paths(config, upload_path, download_path) - } - - /// Builds a VLESS node on top of another configured outbound connector. - /// Its server name remains a logical destination and is resolved by the - /// upstream proxy. - pub fn new_with_upstream( - config: &VlessOutboundConfig, - upstream: Arc, - ) -> io::Result { - let upload_path = UpstreamPath::proxy(upstream.clone()); - let download_path = config - .xhttp - .download - .as_ref() - .map(|_| UpstreamPath::proxy(upstream)); - Self::new_with_paths(config, upload_path, download_path) - } - - pub fn new_with_path(config: &VlessOutboundConfig, upstream: UpstreamPath) -> io::Result { - let download_path = Self::download_path_from_primary(config, &upstream)?; - Self::new_with_paths(config, upstream, download_path) - } - - pub fn new_with_paths( - config: &VlessOutboundConfig, - upload_path: UpstreamPath, - download_path: Option, - ) -> io::Result { - let security_context = SecurityContext::new(); - let standard_tls_count = usize::from(matches!( - &config.security, - crate::config::SecurityConfig::Tls(_) - )) + config.xhttp.download.as_ref().map_or(0, |download| { - usize::from(matches!( - &download.security, - crate::config::SecurityConfig::Tls(_) - )) - }); - let resumption_sessions = - if standard_tls_count == 0 || standard_tls_count > TLS_RESUMPTION_SESSION_BUDGET { - 0 - } else { - TLS_RESUMPTION_SESSION_BUDGET / standard_tls_count - }; - let upload_security = SecurityClient::from_proxy_with_context( - config, - &security_context, - resumption_sessions, - DEFAULT_VLESS_TLS_BUFFER_LIMIT, - )?; - let download_security = config - .xhttp - .download - .as_ref() - .map(|download| { - SecurityClient::from_security_with_context( - &download.security, - &security_context, - resumption_sessions, - DEFAULT_VLESS_TLS_BUFFER_LIMIT, - ) - }) - .transpose()?; - Self::assemble( - config, - upload_path, - download_path, - upload_security, - download_security, - ) - } - - /// Runtime graph constructor using instance-shared TLS material and an - /// explicitly partitioned resumption-cache budget. - pub(crate) fn new_with_shared_security( - config: &VlessOutboundConfig, - upload_path: UpstreamPath, - download_path: Option, - security_context: &SecurityContext, - resumption_sessions: usize, - limits: VlessResourceLimits, - ) -> io::Result { - let upload_security = SecurityClient::from_security_with_context( - &config.security, - security_context, - resumption_sessions, - limits.tls_buffer_limit, - )?; - let download_security = config - .xhttp - .download - .as_ref() - .map(|download| { - SecurityClient::from_security_with_context( - &download.security, - security_context, - resumption_sessions, - limits.tls_buffer_limit, - ) - }) - .transpose()?; - Self::assemble_with_xhttp_limits( - config, - upload_path, - download_path, - upload_security, - download_security, - limits.xhttp_send_buffer_size, - limits.xhttp_upload_chunk_size, - ) - } - - /// Builds a standard-TLS outbound with local interoperability-test roots. - /// - /// This constructor is absent from normal and release builds. It must only - /// be enabled by the opt-in local Xray interoperability harness. - #[cfg(feature = "interop-test")] - #[doc(hidden)] - pub fn new_with_test_tls_roots( - config: &VlessOutboundConfig, - endpoint: ResolvedEndpoint, - dialer: Dialer, - roots_der: impl IntoIterator>, - ) -> io::Result { - let (upload_path, download_path) = Self::direct_paths(config, endpoint, None, dialer)?; - let roots = roots_der.into_iter().collect::>(); - let upload_security = - SecurityClient::from_proxy_with_test_tls_roots(config, roots.clone())?; - let download_security = config - .xhttp - .download - .as_ref() - .map(|download| match &download.security { - crate::config::SecurityConfig::Tls(_) => { - SecurityClient::from_security_with_test_tls_roots( - &download.security, - roots.clone(), - ) - } - crate::config::SecurityConfig::Reality(_) => { - SecurityClient::from_security(&download.security) - } - }) - .transpose()?; - Self::assemble( - config, - upload_path, - download_path, - upload_security, - download_security, - ) - } - - fn validate_endpoint( - address: &str, - port: u16, - endpoint: &ResolvedEndpoint, - leg: &str, - ) -> io::Result<()> { - if endpoint.logical_host != address || endpoint.port != port { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("resolved endpoint does not match the configured VLESS {leg} server"), - )); - } - if endpoint.addresses.is_empty() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("resolved VLESS {leg} endpoint has no addresses"), - )); - } - Ok(()) - } - - fn direct_paths( - config: &VlessOutboundConfig, - endpoint: ResolvedEndpoint, - download_endpoint: Option, - dialer: Dialer, - ) -> io::Result<(UpstreamPath, Option)> { - Self::validate_endpoint(&config.address, config.port, &endpoint, "upload")?; - let download_path = match (&config.xhttp.download, download_endpoint) { - (None, None) => None, - (None, Some(_)) => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "a download endpoint was provided without VLESS download-settings", - )); - } - (Some(download), supplied) => { - let resolved = match supplied { - Some(resolved) => resolved, - None if download.address == config.address && download.port == config.port => { - endpoint.clone() - } - None => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "distinct VLESS download server requires a prepared download endpoint", - )); - } - }; - Self::validate_endpoint(&download.address, download.port, &resolved, "download")?; - Some(UpstreamPath::direct(resolved, dialer.clone())) - } - }; - Ok((UpstreamPath::direct(endpoint, dialer), download_path)) - } - - fn download_path_from_primary( - config: &VlessOutboundConfig, - primary: &UpstreamPath, - ) -> io::Result> { - let Some(download) = config.xhttp.download.as_ref() else { - return Ok(None); - }; - match primary { - UpstreamPath::Proxy(_) => Ok(Some(primary.clone())), - UpstreamPath::Direct { endpoint, .. } - | UpstreamPath::Group { - endpoint: Some(endpoint), - .. - } if endpoint.logical_host == download.address && endpoint.port == download.port => { - Ok(Some(primary.clone())) - } - UpstreamPath::Group { endpoint: None, .. } => Ok(Some(primary.clone())), - UpstreamPath::Direct { .. } | UpstreamPath::Group { .. } => Err(io::Error::new( - io::ErrorKind::InvalidInput, - "distinct VLESS download server requires an explicit download upstream path", - )), - } - } - - fn assemble( - config: &VlessOutboundConfig, - upload_path: UpstreamPath, - download_path: Option, - upload_security: SecurityClient, - download_security: Option, - ) -> io::Result { - Self::assemble_with_xhttp( - config, - upload_path, - download_path, - upload_security, - download_security, - |config| Ok(XHttpClient::new(config)), - ) - } - - fn assemble_with_xhttp_limits( - config: &VlessOutboundConfig, - upload_path: UpstreamPath, - download_path: Option, - upload_security: SecurityClient, - download_security: Option, - send_buffer_size: usize, - upload_chunk_size: usize, - ) -> io::Result { - Self::assemble_with_xhttp( - config, - upload_path, - download_path, - upload_security, - download_security, - |config| XHttpClient::new_with_limits(config, send_buffer_size, upload_chunk_size), - ) - } - - fn assemble_with_xhttp( - config: &VlessOutboundConfig, - upload_path: UpstreamPath, - download_path: Option, - upload_security: SecurityClient, - download_security: Option, - mut build_xhttp: impl FnMut(XHttpConfig) -> io::Result, - ) -> io::Result { - let mode = match config.xhttp.mode { - ConfigXHttpMode::PacketUp => XHttpMode::PacketUp, - ConfigXHttpMode::StreamOne => XHttpMode::StreamOne, - ConfigXHttpMode::StreamUp => XHttpMode::StreamUp, - }; - if mode == XHttpMode::StreamOne && config.xhttp.download.is_some() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "XHTTP stream-one cannot use download settings", - )); - } - let xhttp = build_xhttp(XHttpConfig::new( - config.xhttp.host.clone(), - config.xhttp.path.clone(), - mode, - )?)?; - let download = match ( - config.xhttp.download.as_ref(), - download_path, - download_security, - ) { - (None, None, None) => None, - (Some(config), Some(upstream), Some(security)) => Some(VlessDownloadLeg { - transport: VlessTransportLeg { - server: server_destination(&config.address, config.port)?, - upstream, - security, - }, - xhttp: build_xhttp(XHttpConfig::new( - config.host.clone(), - config.path.clone(), - mode, - )?)?, - }), - _ => { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "VLESS download configuration, path, and security must be provided together", - )); - } - }; - Ok(Self { - uuid: config.id, - upload: VlessTransportLeg { - server: server_destination(&config.address, config.port)?, - upstream: upload_path, - security: upload_security, - }, - xhttp, - download, - }) - } - - async fn connect_transport( - &self, - session: StreamSession, - context: &EstablishContext, - ) -> Result { - let upload = Self::connect_transport_leg(&self.upload, session.clone(), context); - let Some(download) = &self.download else { - let secured = upload.await?; - let connected = context - .run_io("VLESS XHTTP handshake", self.xhttp.connect(secured)) - .await?; - tracing::debug!(stage = "xhttp", "VLESS transport stage completed"); - return Ok(connected); - }; - - let download_stream = Self::connect_transport_leg(&download.transport, session, context); - let (upload_stream, download_stream) = tokio::try_join!(upload, download_stream)?; - let connected = context - .run_io( - "VLESS XHTTP handshake", - self.xhttp - .connect_with_download(upload_stream, &download.xhttp, download_stream), - ) - .await?; - tracing::debug!(stage = "xhttp-split", "VLESS transport stage completed"); - Ok(connected) - } - - async fn connect_transport_leg( - leg: &VlessTransportLeg, - session: StreamSession, - context: &EstablishContext, - ) -> Result { - let raw = leg - .upstream - .connect_server(session, &leg.server, context) - .await?; - tracing::debug!(stage = "upstream", "VLESS transport stage completed"); - let secured = context - .run_io("VLESS TLS/REALITY handshake", leg.security.connect(raw.io)) - .await?; - tracing::debug!(stage = "security", "VLESS transport stage completed"); - Ok(secured) - } - - async fn connect_vless_tcp( - &self, - session: StreamSession, - context: &EstablishContext, - ) -> Result { - let effective_peer = session.destination.clone(); - let stream = self.connect_transport(session.clone(), context).await?; - let header = - encode_request_header(self.uuid, VlessCommand::Tcp, Some(&session.destination)) - .map_err(DispatchError::from)?; - Ok(ConnectedStream { - io: Box::new(VlessStream::new(stream, header)), - effective_peer, - }) - } - - async fn connect_vless_xudp( - &self, - request: &DatagramRequest, - context: &EstablishContext, - ) -> Result { - let mut stream = self - .connect_transport( - StreamSession { - inbound: request.session.inbound, - source: request.session.source, - destination: self.upload.server.clone(), - sniffed_domain: None, - }, - context, - ) - .await?; - let header = encode_request_header(self.uuid, VlessCommand::Mux, None) - .map_err(DispatchError::from)?; - context - .run_io("VLESS XUDP request header", async { - stream.write_all(&header).await?; - stream.flush().await - }) - .await?; - // VCore does not implement Xray's cone/reconnect association - // reuse. Xray uses the all-zero Global ID to explicitly disable that - // behavior; a per-association random value would claim reuse semantics - // that this runtime cannot honor. - Ok(XudpTransport::new( - stream, - [0_u8; 8], - request.max_response_payload_size(), - )) - } -} - -#[cfg(feature = "outbound-vless")] -#[async_trait] -impl OutboundConnector for VlessOutbound { - async fn connect_stream( - &self, - session: StreamSession, - context: &EstablishContext, - ) -> Result { - self.connect_vless_tcp(session, context).await - } - - async fn open_datagram( - &self, - request: DatagramRequest, - context: &EstablishContext, - ) -> Result, DispatchError> { - self.connect_vless_xudp(&request, context) - .await - .map(|transport| Box::new(transport) as Box) - } -} - -#[cfg(feature = "outbound-vless")] -#[async_trait] -impl Dispatcher for VlessOutbound { - async fn connect_tcp(&self, session: StreamSession) -> Result { - OutboundConnector::connect_stream(self, session, &EstablishContext::default()) - .await - .map(|connected| connected.io) - } - - async fn open_datagram( - &self, - session: DatagramSession, - ) -> Result, DispatchError> { - OutboundConnector::open_datagram( - self, - DatagramRequest::new(session), - &EstablishContext::default(), - ) - .await - } -} - -#[cfg(all(test, feature = "outbound-socks5", feature = "outbound-vless"))] -mod connector_composition_tests { - use super::*; - use crate::{ - config::{ - SecurityConfig, Socks5OutboundConfig, TlsConfig, VlessEncryption, VlessOutboundConfig, - XHttpConfig as ConfigXHttpConfig, XHttpDownloadConfig, XHttpMode as ConfigXHttpMode, - }, - dispatch::{DatagramTransport, DispatchError}, - session::StreamSession, - }; - - struct NeverConnector; - - #[async_trait] - impl OutboundConnector for NeverConnector { - async fn connect_stream( - &self, - _session: StreamSession, - _context: &EstablishContext, - ) -> Result { - Err(DispatchError::NetworkUnreachable) - } - - async fn open_datagram( - &self, - _request: DatagramRequest, - _context: &EstablishContext, - ) -> Result, DispatchError> { - Err(DispatchError::NetworkUnreachable) - } - } - - fn vless_config(address: &str) -> VlessOutboundConfig { - VlessOutboundConfig { - address: address.to_owned(), - port: 443, - id: uuid::Uuid::parse_str("b831381d-6324-4d53-ad4f-8cda48b30811").unwrap(), - encryption: VlessEncryption::None, - flow: String::new(), - security: SecurityConfig::Tls(TlsConfig { - server_name: address.to_owned(), - }), - xhttp: ConfigXHttpConfig { - path: "/xhttp".to_owned(), - host: address.to_owned(), - mode: ConfigXHttpMode::PacketUp, - download: None, - }, - } - } - - fn socks_config(address: &str) -> Socks5OutboundConfig { - Socks5OutboundConfig { - address: address.to_owned(), - port: 1080, - username: None, - password: None, - } - } - - fn split_vless_config(upload: &str, download: &str, download_port: u16) -> VlessOutboundConfig { - let mut config = vless_config(upload); - config.xhttp.download = Some(Box::new(XHttpDownloadConfig { - address: download.to_owned(), - port: download_port, - security: SecurityConfig::Tls(TlsConfig { - server_name: download.to_owned(), - }), - path: "/download".to_owned(), - host: download.to_owned(), - })); - config - } - - fn endpoint(address: &str, port: u16) -> ResolvedEndpoint { - ResolvedEndpoint { - logical_host: address.to_owned(), - port, - addresses: vec![std::net::SocketAddr::from(([127, 0, 0, 1], port))], - } - } - - #[test] - fn all_two_hop_protocol_combinations_build_as_connector_graphs() { - let physical: Arc = Arc::new(NeverConnector); - let vless_leaf: Arc = Arc::new( - VlessOutbound::new_with_upstream(&vless_config("vless-leaf.example"), physical.clone()) - .unwrap(), - ); - let socks_leaf: Arc = Arc::new( - Socks5Outbound::new_with_upstream(&socks_config("socks-leaf.example"), physical) - .unwrap(), - ); - - let combinations: [Arc; 4] = [ - Arc::new( - VlessOutbound::new_with_upstream( - &vless_config("vless-over-vless.example"), - vless_leaf.clone(), - ) - .unwrap(), - ), - Arc::new( - VlessOutbound::new_with_upstream( - &vless_config("vless-over-socks.example"), - socks_leaf.clone(), - ) - .unwrap(), - ), - Arc::new( - Socks5Outbound::new_with_upstream( - &socks_config("socks-over-vless.example"), - vless_leaf, - ) - .unwrap(), - ), - Arc::new( - Socks5Outbound::new_with_upstream( - &socks_config("socks-over-socks.example"), - socks_leaf, - ) - .unwrap(), - ), - ]; - assert_eq!(combinations.len(), 4); - } - - #[test] - fn direct_download_constructor_reuses_or_requires_the_precise_prepared_endpoint() { - let same = split_vless_config("same.example", "same.example", 443); - let outbound = - VlessOutbound::new(&same, endpoint("same.example", 443), Dialer::default()).unwrap(); - assert!(outbound.download.is_some()); - - let mut invalid_mode = same.clone(); - invalid_mode.xhttp.mode = ConfigXHttpMode::StreamOne; - let error = VlessOutbound::new( - &invalid_mode, - endpoint("same.example", 443), - Dialer::default(), - ) - .unwrap_err(); - assert!(error.to_string().contains("stream-one")); - - let distinct = split_vless_config("upload.example", "download.example", 8443); - let error = VlessOutbound::new( - &distinct, - endpoint("upload.example", 443), - Dialer::default(), - ) - .unwrap_err(); - assert!(error.to_string().contains("prepared download endpoint")); - - let outbound = VlessOutbound::new_with_endpoints( - &distinct, - endpoint("upload.example", 443), - Some(endpoint("download.example", 8443)), - Dialer::default(), - ) - .unwrap(); - let download = outbound.download.as_ref().unwrap(); - assert_eq!(outbound.upload.server.port(), 443); - assert_eq!(download.transport.server.port(), 8443); - } - - #[test] - fn split_node_over_a_proxy_automatically_reuses_its_parent_for_both_legs() { - let parent: Arc = Arc::new(NeverConnector); - let config = split_vless_config("upload.example", "download.example", 8443); - let outbound = VlessOutbound::new_with_upstream(&config, parent).unwrap(); - let UpstreamPath::Proxy(upload_parent) = &outbound.upload.upstream else { - panic!("upload leg must use the configured parent") - }; - let UpstreamPath::Proxy(download_parent) = - &outbound.download.as_ref().unwrap().transport.upstream - else { - panic!("download leg must use the configured parent") - }; - assert!(Arc::ptr_eq(upload_parent, download_parent)); - } -} +#[cfg(feature = "outbound-hysteria2")] +pub mod hysteria2; +#[cfg(any( + feature = "outbound-trojan", + feature = "outbound-vmess", + feature = "outbound-vless", + feature = "outbound-hysteria2" +))] +mod owned_stream; diff --git a/src/outbound/owned_stream.rs b/src/outbound/owned_stream.rs new file mode 100644 index 0000000..01f3da7 --- /dev/null +++ b/src/outbound/owned_stream.rs @@ -0,0 +1,69 @@ +use crate::{ + dispatch::BoxStream, + resources::observation::{self, ResourceKind}, +}; +use std::{ + future::Future, + io, + pin::Pin, + task::{Context, Poll}, +}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio_util::sync::CancellationToken; + +pub(crate) struct OwnedStream { + stream: Option, + cancellation: CancellationToken, + cancelled: Pin + Send>>, + _observation: observation::Guard, +} + +impl OwnedStream { + pub(crate) fn new(stream: BoxStream, cancellation: CancellationToken) -> Self { + Self { + stream: Some(stream), + cancelled: Box::pin(cancellation.clone().cancelled_owned()), + cancellation, + _observation: observation::track(ResourceKind::Session), + } + } + fn open(&mut self, cx: &mut Context<'_>) -> io::Result<&mut BoxStream> { + if self.cancelled.as_mut().poll(cx).is_ready() { + self.stream.take(); + } + self.stream.as_mut().ok_or_else(|| { + io::Error::new(io::ErrorKind::ConnectionAborted, "outbound session stopped") + }) + } +} + +impl Drop for OwnedStream { + fn drop(&mut self) { + self.cancellation.cancel(); + } +} + +impl AsyncRead for OwnedStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(self.open(cx)?).poll_read(cx, buf) + } +} +impl AsyncWrite for OwnedStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(self.open(cx)?).poll_write(cx, buf) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(self.open(cx)?).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(self.open(cx)?).poll_shutdown(cx) + } +} diff --git a/src/outbound/shadowsocks/datagram.rs b/src/outbound/shadowsocks/datagram.rs index 8722cb9..db0d763 100644 --- a/src/outbound/shadowsocks/datagram.rs +++ b/src/outbound/shadowsocks/datagram.rs @@ -19,6 +19,19 @@ use tokio::time::Instant; // Largest response: XChaCha nonce, IDs/type/time/length, optional padding, // maximum SOCKS address and AEAD tag. The payload is separately bounded. pub(super) const MAX_RESPONSE_HEADER: u16 = 24 + 16 + 1 + 8 + 8 + 2 + 900 + 259 + 16; +// The official library pads empty UDP only. For nonempty payloads, directional +// headers differ by the response's client-session ID; AES EIH is request-only. +pub(super) fn request_header(config: &ServerConfig) -> usize { + nonce_size(config) + 43 + config.identity_keys().len().saturating_mul(16) +} + +fn nonce_size(config: &ServerConfig) -> usize { + if config.method() == shadowsocks::crypto::CipherKind::AEAD2022_BLAKE3_CHACHA20_POLY1305 { + 24 + } else { + 0 + } +} const SESSION_RETENTION: Duration = Duration::from_secs(60); struct ServerSession { @@ -38,6 +51,8 @@ pub(super) struct SsDatagram { wire_maximum: usize, buffer: Vec, closed: bool, + request_header: usize, + response_header: usize, } impl SsDatagram { @@ -63,6 +78,8 @@ impl SsDatagram { wire_maximum: usize::from(wire_maximum).min(MAX_WIRE_PACKET), buffer: vec![0; usize::from(wire_maximum).min(MAX_WIRE_PACKET)], closed: false, + request_header: request_header(config), + response_header: nonce_size(config) + 51, } } @@ -115,6 +132,29 @@ impl SsDatagram { #[async_trait] impl DatagramTransport for SsDatagram { + fn payload_budget(&self, peer: &Destination) -> crate::dispatch::DatagramBudget { + let address = super::address(peer).serialized_len(); + let response_address = if matches!(peer, Destination::Domain { .. }) { + 259 + } else { + address + }; + self.inner + .payload_budget(&self.server) + .intersect(crate::dispatch::DatagramBudget::new( + MAX_WIRE_PACKET as u16, + self.wire_maximum as u16, + )) + .subtract_overhead( + self.request_header.saturating_add(address), + self.response_header + response_address, + ) + .intersect(crate::dispatch::DatagramBudget::new( + u16::MAX, + self.maximum as u16, + )) + } + async fn send(&mut self, datagram: Datagram) -> Result<(), DispatchError> { if self.closed { return Err(DispatchError::NotAllowed); diff --git a/src/outbound/shadowsocks/mod.rs b/src/outbound/shadowsocks/mod.rs index 3b10309..c2969aa 100644 --- a/src/outbound/shadowsocks/mod.rs +++ b/src/outbound/shadowsocks/mod.rs @@ -105,18 +105,24 @@ impl OutboundConnector for ShadowsocksOutbound { let inner = self .upstream .open_datagram( - request.with_max_response_payload_size(wire_maximum), + request.with_envelope( + datagram::request_header(&self.config).saturating_add(259 + 900), + usize::from(datagram::MAX_RESPONSE_HEADER), + ), context, ) .await?; - Ok(Box::new(datagram::SsDatagram::new( - inner, - server, - self.context.clone(), - &self.config, - maximum, - wire_maximum, - ))) + Ok(crate::dispatch::bound_datagram( + Box::new(datagram::SsDatagram::new( + inner, + server, + self.context.clone(), + &self.config, + maximum, + wire_maximum, + )), + request.budget(), + )) } } diff --git a/src/outbound/shadowsocks/stream.rs b/src/outbound/shadowsocks/stream.rs index cced305..267fe27 100644 --- a/src/outbound/shadowsocks/stream.rs +++ b/src/outbound/shadowsocks/stream.rs @@ -10,17 +10,19 @@ use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; // Keep each codec call bounded, including the target header on the first // write. The upstream first-write wrapper expects that entire frame to fit // within the SS 2022 u16 payload limit. Larger caller buffers use partial writes. -const WRITE_CHUNK: usize = 16 * 1024; +use crate::limits::SHADOWSOCKS_WRITE_CHUNK as WRITE_CHUNK; enum Start { Fresh, EmptyPending, + EmptyFlushing, Started, } pub(super) struct SsStream { inner: ProxyClientStream, start: Start, + pending: Option>, } impl SsStream { @@ -28,15 +30,40 @@ impl SsStream { Self { inner, start: Start::Fresh, + pending: None, } } fn finish_empty(&mut self, cx: &mut Context<'_>) -> Poll> { if matches!(self.start, Start::EmptyPending) { ready!(Pin::new(&mut self.inner).poll_write(cx, &[])).map_err(super::safe_io)?; + self.start = Start::EmptyFlushing; + } + if matches!(self.start, Start::EmptyFlushing) { + // A buffered carrier may accept the target header without sending + // it. The internally generated read-first handshake owns its flush; + // no application write is available to make it reach the server. + ready!(Pin::new(&mut self.inner).poll_flush(cx)).map_err(super::safe_io)?; self.start = Start::Started; } Poll::Ready(Ok(())) } + + fn finish_write(&mut self, cx: &mut Context<'_>) -> Poll> { + if let Some(bytes) = &self.pending { + // The official codec retains its encrypted frame on Pending but + // reports the retry buffer's length. Keep that input stable even + // when a caller (including Tokio copy) tops up its own buffer. + let written = + ready!(Pin::new(&mut self.inner).poll_write(cx, bytes)).map_err(super::safe_io)?; + if written != bytes.len() { + return Poll::Ready(Err(io::Error::other( + "Shadowsocks buffered write was incomplete", + ))); + } + self.pending = None; + } + Poll::Ready(Ok(())) + } } impl AsyncRead for SsStream { @@ -54,6 +81,11 @@ impl AsyncRead for SsStream { self.start = Start::EmptyPending; } ready!(self.finish_empty(cx))?; + // Progress buffered output while waiting for a response, but do not + // block reads on a full write side: both directions may be backpressured. + if let Poll::Ready(result) = self.finish_write(cx) { + result?; + } Pin::new(&mut self.inner) .poll_read(cx, buf) .map_err(super::safe_io) @@ -67,16 +99,24 @@ impl AsyncWrite for SsStream { buf: &[u8], ) -> Poll> { ready!(self.finish_empty(cx))?; + ready!(self.finish_write(cx))?; // Mark before polling: a concurrent read must not replace an in-flight // first payload with an empty write while upstream reports Pending. self.start = Start::Started; let buf = &buf[..buf.len().min(WRITE_CHUNK)]; - Pin::new(&mut self.inner) - .poll_write(cx, buf) - .map_err(super::safe_io) + match Pin::new(&mut self.inner).poll_write(cx, buf) { + Poll::Pending => { + // Accept exactly this bounded chunk into owned storage now; + // subsequent writes/flush/shutdown resume the same codec call. + self.pending = Some(buf.to_vec()); + Poll::Ready(Ok(buf.len())) + } + Poll::Ready(result) => Poll::Ready(result.map_err(super::safe_io)), + } } fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { ready!(self.finish_empty(cx))?; + ready!(self.finish_write(cx))?; Pin::new(&mut self.inner) .poll_flush(cx) .map_err(super::safe_io) @@ -88,6 +128,7 @@ impl AsyncWrite for SsStream { self.start = Start::EmptyPending; } ready!(self.finish_empty(cx))?; + ready!(self.finish_write(cx))?; Pin::new(&mut self.inner) .poll_shutdown(cx) .map_err(super::safe_io) diff --git a/src/outbound/shadowsocks/tests.rs b/src/outbound/shadowsocks/tests.rs index a8b1120..3c22765 100644 --- a/src/outbound/shadowsocks/tests.rs +++ b/src/outbound/shadowsocks/tests.rs @@ -5,9 +5,82 @@ use crate::{ session::InboundKind, }; use base64::{Engine as _, engine::general_purpose::STANDARD}; -use shadowsocks::relay::tcprelay::proxy_stream::ProxyServerStream; +use shadowsocks::relay::tcprelay::proxy_stream::{ProxyServerStream, protocol::TcpRequestHeader}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; +// A lower-level codec peer, deliberately not a conforming SS server. It keeps +// adapter scheduling/half-close checks independent of upstream's randomized +// zero-padding rejection. Strict acceptance and rejection have separate, +// deterministic fixtures in tests/shadowsocks_backpressure.rs. +struct CodecPeer { + stream: shadowsocks::relay::tcprelay::crypto_io::CryptoStream, + context: Arc, +} +impl CodecPeer { + fn new( + raw: tokio::io::DuplexStream, + method: shadowsocks::crypto::CipherKind, + key: &[u8], + ) -> Self { + let context = Context::new_shared(ServerType::Server); + Self { + stream: shadowsocks::relay::tcprelay::crypto_io::CryptoStream::from_stream( + &context, + raw, + shadowsocks::relay::tcprelay::crypto_io::StreamType::Server, + method, + key, + ), + context, + } + } + async fn header(&mut self) -> shadowsocks::relay::socks5::Address { + let header = TcpRequestHeader::read_from(self.stream.method(), self) + .await + .unwrap(); + let nonce = self.stream.received_nonce().unwrap().to_vec(); + self.stream.set_request_nonce(&nonce); + header.addr() + } +} +impl tokio::io::AsyncRead for CodecPeer { + fn poll_read( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + out: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + use shadowsocks::relay::tcprelay::crypto_io::CryptoRead; + let this = self.get_mut(); + std::pin::Pin::new(&mut this.stream) + .poll_read_decrypted(cx, &this.context, out) + .map_err(Into::into) + } +} +impl tokio::io::AsyncWrite for CodecPeer { + fn poll_write( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + data: &[u8], + ) -> std::task::Poll> { + use shadowsocks::relay::tcprelay::crypto_io::CryptoWrite; + std::pin::Pin::new(&mut self.stream) + .poll_write_encrypted(cx, data) + .map_err(Into::into) + } + fn poll_flush( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.stream.poll_flush(cx).map_err(Into::into) + } + fn poll_shutdown( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.stream.poll_shutdown(cx).map_err(Into::into) + } +} + fn config(cipher: ShadowsocksCipher) -> ShadowsocksOutboundConfig { ShadowsocksOutboundConfig { address: "127.0.0.1".into(), @@ -18,7 +91,7 @@ fn config(cipher: ShadowsocksCipher) -> ShadowsocksOutboundConfig { } #[tokio::test] -async fn official_tcp_all_ciphers_first_payload_server_first_and_backpressure() { +async fn official_tcp_codecs_all_ciphers_first_payload_read_first_and_backpressure() { for cipher in [ ShadowsocksCipher::Aes128Gcm, ShadowsocksCipher::Aes256Gcm, @@ -39,12 +112,7 @@ async fn official_tcp_all_ciphers_first_payload_server_first_and_backpressure() address(&target), ); let mut client = stream::SsStream::new(inner); - let mut server = ProxyServerStream::from_stream( - Context::new_shared(ServerType::Server), - server, - method, - &key, - ); + let mut server = CodecPeer::new(server, method, &key); tokio::time::timeout(std::time::Duration::from_secs(5), async { tokio::join!( async { @@ -62,7 +130,7 @@ async fn official_tcp_all_ciphers_first_payload_server_first_and_backpressure() assert_eq!(result, b"response"); }, async { - assert_eq!(server.handshake().await.unwrap(), address(&target)); + assert_eq!(server.header().await, address(&target)); if server_first { server.write_all(b"hello").await.unwrap(); } @@ -81,7 +149,7 @@ async fn official_tcp_all_ciphers_first_payload_server_first_and_backpressure() } #[tokio::test] -async fn official_tcp_all_ciphers_empty_half_close_keeps_server_response() { +async fn official_tcp_codecs_all_ciphers_empty_half_close_keeps_response() { use std::{future::poll_fn, pin::Pin, task::Poll}; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; @@ -129,12 +197,7 @@ async fn official_tcp_all_ciphers_empty_half_close_keeps_server_response() { assert_eq!(prefill, [0x5a; 4095]); } client.shutdown().await.unwrap(); - let mut server = ProxyServerStream::from_stream( - Context::new_shared(ServerType::Server), - peer, - method, - &vec![7; cipher.key_len()], - ); + let mut server = CodecPeer::new(peer, method, &vec![7; cipher.key_len()]); tokio::join!( async { let mut response = Vec::new(); @@ -142,7 +205,7 @@ async fn official_tcp_all_ciphers_empty_half_close_keeps_server_response() { assert_eq!(response, b"hello"); }, async { - assert_eq!(server.handshake().await.unwrap(), address(&target)); + assert_eq!(server.header().await, address(&target)); let mut payload = Vec::new(); server.read_to_end(&mut payload).await.unwrap(); assert!(payload.is_empty()); diff --git a/src/outbound/socks5.rs b/src/outbound/socks5.rs index 28dbe58..e284e96 100644 --- a/src/outbound/socks5.rs +++ b/src/outbound/socks5.rs @@ -175,18 +175,26 @@ impl Socks5Outbound { let upstream_limit = request_parameters .max_response_payload_size() .saturating_add(MAX_UDP_HEADER_SIZE); - let upstream_request = request_parameters.with_max_response_payload_size(upstream_limit); + let upstream_request = request_parameters.with_envelope( + usize::from(MAX_UDP_HEADER_SIZE), + usize::from(MAX_UDP_HEADER_SIZE), + ); let inner = self .upstream .open_datagram(upstream_request, context) .await?; - Ok(Box::new(Socks5DatagramTransport { - control: control.io, - inner, - relay, - max_response_payload_size: usize::from(request_parameters.max_response_payload_size()), - max_wire_response_size: usize::from(upstream_limit), - })) + Ok(crate::dispatch::bound_datagram( + Box::new(Socks5DatagramTransport { + control: control.io, + inner, + relay, + max_response_payload_size: usize::from( + request_parameters.max_response_payload_size(), + ), + max_wire_response_size: usize::from(upstream_limit), + }), + request_parameters.budget(), + )) } } @@ -385,6 +393,25 @@ struct Socks5DatagramTransport { #[async_trait] impl DatagramTransport for Socks5DatagramTransport { + fn payload_budget(&self, peer: &Destination) -> crate::dispatch::DatagramBudget { + let Ok(address) = crate::socks5::encoded_address_size(peer) else { + return crate::dispatch::DatagramBudget::new(0, 0); + }; + let transmit = address + 3; + let receive = if matches!(peer, Destination::Domain { .. }) { + usize::from(MAX_UDP_HEADER_SIZE) + } else { + transmit + }; + self.inner + .payload_budget(&self.relay) + .subtract_overhead(transmit, receive) + .intersect(crate::dispatch::DatagramBudget::new( + u16::MAX, + self.max_response_payload_size as u16, + )) + } + async fn send(&mut self, datagram: Datagram) -> Result<(), DispatchError> { let packet = encode_udp_packet(&datagram.remote, &datagram.payload, usize::from(u16::MAX)) .map_err(|error| protocol_error(format!("invalid SOCKS5 UDP request: {error}")))?; @@ -468,8 +495,6 @@ mod tests { time::Duration, }; - use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; - use super::*; #[derive(Clone)] diff --git a/src/outbound/trojan/datagram.rs b/src/outbound/trojan/datagram.rs new file mode 100644 index 0000000..53ec749 --- /dev/null +++ b/src/outbound/trojan/datagram.rs @@ -0,0 +1,181 @@ +use std::io; + +use async_trait::async_trait; +use bytes::{Buf, BytesMut}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::{ + dispatch::{BoxStream, DatagramBudget, DatagramTransport, DispatchError}, + session::{Datagram, Destination}, + socks5::{Socks5CodecError, decode_address, encode_address}, +}; + +// Mihomo's Trojan decoder accepts at most 8192 bytes per datagram. Do not +// emulate its sender's splitting: splitting would change UDP message boundaries. +pub const MAX_DATAGRAM_PAYLOAD: u16 = 8192; +const MAX_FRAME_BYTES: usize = 259 + 4 + MAX_DATAGRAM_PAYLOAD as usize; + +/// One UDP association over an already authenticated Trojan stream. Partial +/// receive state survives cancellation; a cancelled write poisons the stream. +/// No reader task or lock can prevent the caller from sending while read-idle. +pub struct TrojanDatagram { + stream: Option, + wire: BytesMut, + budget: DatagramBudget, + send_incomplete: bool, +} + +impl std::fmt::Debug for TrojanDatagram { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TrojanDatagram") + .field("closed", &self.stream.is_none()) + .field("budget", &self.budget) + .finish_non_exhaustive() + } +} + +impl TrojanDatagram { + pub fn new(stream: BoxStream, budget: DatagramBudget) -> Self { + Self { + stream: Some(stream), + wire: BytesMut::new(), + budget: budget.intersect(DatagramBudget::new( + MAX_DATAGRAM_PAYLOAD, + MAX_DATAGRAM_PAYLOAD, + )), + send_incomplete: false, + } + } + + fn check_open(&mut self) -> io::Result<()> { + if self.send_incomplete { + self.stream.take(); + } + if self.stream.is_none() { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "Trojan association is closed", + )); + } + Ok(()) + } + + fn packet(&mut self) -> io::Result> { + let (remote, address_size) = match decode_address(&self.wire) { + Ok(address) => address, + Err(Socks5CodecError::Truncated) => return Ok(None), + Err(_) => return Err(invalid_frame()), + }; + let Some(header) = self.wire.get(address_size..address_size + 4) else { + return Ok(None); + }; + let length = usize::from(u16::from_be_bytes([header[0], header[1]])); + if length > usize::from(MAX_DATAGRAM_PAYLOAD) || &header[2..] != b"\r\n" { + return Err(invalid_frame()); + } + let header_size = address_size + 4; + if self.wire.len() < header_size + length { + return Ok(None); + } + self.wire.advance(header_size); + let payload = self.wire.split_to(length).freeze(); + Ok(Some(Datagram { + remote, + payload, + sniffed_domain: None, + })) + } +} + +fn invalid_frame() -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, "invalid Trojan datagram frame") +} + +#[async_trait] +impl DatagramTransport for TrojanDatagram { + fn payload_budget(&self, _: &Destination) -> DatagramBudget { + self.budget + } + + async fn send(&mut self, datagram: Datagram) -> Result<(), DispatchError> { + self.check_open()?; + if datagram.payload.len() > usize::from(self.budget.transmit()) + || datagram.remote.port() == 0 + { + return Err( + io::Error::new(io::ErrorKind::InvalidInput, "invalid Trojan datagram").into(), + ); + } + let mut packet = Vec::with_capacity(263 + datagram.payload.len()); + encode_address(&datagram.remote, &mut packet)?; + packet.extend_from_slice(&(datagram.payload.len() as u16).to_be_bytes()); + packet.extend_from_slice(b"\r\n"); + packet.extend_from_slice(&datagram.payload); + self.send_incomplete = true; + let stream = self.stream.as_mut().expect("checked open"); + stream.write_all(&packet).await?; + stream.flush().await?; + self.send_incomplete = false; + Ok(()) + } + + async fn receive(&mut self) -> Result { + self.check_open()?; + let mut discarded = 0; + loop { + match self.packet() { + Ok(Some(packet)) if packet.payload.len() <= usize::from(self.budget.receive()) => { + return Ok(packet); + } + Ok(Some(_)) => { + discarded += 1; + if discarded == crate::limits::IO_POLL_BUDGET { + tokio::task::yield_now().await; + discarded = 0; + } + continue; + } + Ok(None) => {} + Err(error) => { + self.stream.take(); + return Err(error.into()); + } + } + let mut buffer = [0; 2048]; + let remaining = (MAX_FRAME_BYTES - self.wire.len()).min(buffer.len()); + if remaining == 0 { + self.stream.take(); + return Err(invalid_frame().into()); + } + match self + .stream + .as_mut() + .expect("checked open") + .read(&mut buffer[..remaining]) + .await + { + Ok(0) => { + self.stream.take(); + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "Trojan datagram stream ended", + ) + .into()); + } + Ok(length) => self.wire.extend_from_slice(&buffer[..length]), + Err(error) => { + self.stream.take(); + return Err(error.into()); + } + } + } + } + + async fn close(&mut self) -> Result<(), DispatchError> { + // Dropping the complete owned stream closes both directions even if a + // protocol shutdown would wait for an unresponsive peer. + self.stream.take(); + self.wire.clear(); + Ok(()) + } +} diff --git a/src/outbound/trojan/mod.rs b/src/outbound/trojan/mod.rs new file mode 100644 index 0000000..b5705b7 --- /dev/null +++ b/src/outbound/trojan/mod.rs @@ -0,0 +1,69 @@ +//! Trojan client wire primitives. The TLS/transport and physical socket remain +//! caller-owned; this module never resolves or opens a network endpoint. +//! Wire reference: https://trojan-gfw.github.io/trojan/protocol + +use std::io; + +use bytes::Bytes; +use sha2::{Digest, Sha224}; + +use crate::{session::Destination, socks5::encode_address}; + +mod datagram; +pub use datagram::{MAX_DATAGRAM_PAYLOAD, TrojanDatagram}; +mod outbound; +pub use outbound::TrojanOutbound; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrojanCommand { + Tcp, + Udp, +} + +/// A node's precomputed authentication token. Debug never exposes it. +#[derive(Clone)] +pub struct TrojanAuth([u8; 56]); + +impl std::fmt::Debug for TrojanAuth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TrojanAuth").finish_non_exhaustive() + } +} + +impl TrojanAuth { + pub fn new(password: &str) -> io::Result { + if password.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "empty Trojan password", + )); + } + let digest = Sha224::digest(password.as_bytes()); + let mut token = [0; 56]; + const HEX: &[u8; 16] = b"0123456789abcdef"; + for (pair, byte) in token.as_chunks_mut::<2>().0.iter_mut().zip(digest) { + pair[0] = HEX[usize::from(byte >> 4)]; + pair[1] = HEX[usize::from(byte & 15)]; + } + Ok(Self(token)) + } + + pub fn request(&self, command: TrojanCommand, peer: &Destination) -> io::Result { + if peer.port() == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "zero Trojan destination port", + )); + } + let mut output = Vec::with_capacity(61 + 259); + output.extend_from_slice(&self.0); + output.extend_from_slice(b"\r\n"); + output.push(match command { + TrojanCommand::Tcp => 1, + TrojanCommand::Udp => 3, + }); + encode_address(peer, &mut output)?; + output.extend_from_slice(b"\r\n"); + Ok(output.into()) + } +} diff --git a/src/outbound/trojan/outbound.rs b/src/outbound/trojan/outbound.rs new file mode 100644 index 0000000..6b9bf40 --- /dev/null +++ b/src/outbound/trojan/outbound.rs @@ -0,0 +1,201 @@ +use std::io; + +use crate::outbound::owned_stream::OwnedStream; +use async_trait::async_trait; +use tokio::io::AsyncWriteExt; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; + +use crate::{ + config::{TrojanOutboundConfig, TrojanTransport}, + dispatch::{BoxStream, DatagramTransport, DispatchError}, + outbound::{ + ConnectedStream, DatagramRequest, EstablishContext, OutboundConnector, UpstreamPath, + server_destination, + }, + resources::observation::{self, ResourceKind}, + security::{ + SecurityContext, StandardTlsClient, TLS_RESUMPTION_SESSION_BUDGET, TlsCertificatePolicy, + TlsClientOptions, + }, + session::{Destination, StreamSession}, + transport::{WebSocketOptions, connect_websocket, grpc_duplex}, +}; + +use super::{TrojanAuth, TrojanCommand, TrojanDatagram}; + +pub struct TrojanOutbound { + server: Destination, + upstream: UpstreamPath, + auth: TrojanAuth, + tls: StandardTlsClient, + cancellation: CancellationToken, + transport: Transport, + tasks: TaskTracker, +} + +enum Transport { + Tcp, + WebSocket(Box), + Grpc(String), +} + +impl std::fmt::Debug for TrojanOutbound { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TrojanOutbound").finish_non_exhaustive() + } +} + +impl TrojanOutbound { + pub fn new_with_path( + config: &TrojanOutboundConfig, + upstream: UpstreamPath, + ) -> io::Result { + Self::with_shared_security( + config, + upstream, + &SecurityContext::new(), + TLS_RESUMPTION_SESSION_BUDGET, + 64 * 1024, + ) + } + + pub(crate) fn with_shared_security( + config: &TrojanOutboundConfig, + upstream: UpstreamPath, + security: &SecurityContext, + resumption_sessions: usize, + buffer_limit: usize, + ) -> io::Result { + Ok(Self { + transport: match &config.transport { + TrojanTransport::Tcp => Transport::Tcp, + TrojanTransport::WebSocket { .. } => Transport::WebSocket(Box::new( + config + .transport + .websocket_options()? + .expect("WebSocket config"), + )), + TrojanTransport::Grpc { uri } => Transport::Grpc(uri.clone()), + }, + tasks: TaskTracker::new(), + server: server_destination(&config.address, config.port)?, + upstream, + auth: TrojanAuth::new(&config.password)?, + tls: StandardTlsClient::with_options( + security, + &config.server_name, + TlsClientOptions { + client_fingerprint: config.tls.client_fingerprint, + alpn: config.tls.alpn.clone(), + required_alpn: config.transport.required_alpn().map(<[u8]>::to_vec), + certificate: TlsCertificatePolicy { + skip_cert_verify: config.tls.skip_cert_verify, + fingerprint: config.tls.fingerprint, + ..Default::default() + }, + ..Default::default() + }, + resumption_sessions, + buffer_limit, + )?, + cancellation: CancellationToken::new(), + }) + } + + async fn connect( + &self, + session: StreamSession, + command: TrojanCommand, + context: &EstablishContext, + ) -> Result { + // timeout_at may poll an immediately-ready future before its timer. + // A previously expired chain deadline must not even create a socket. + if tokio::time::Instant::now() >= context.deadline() { + return Err(DispatchError::TimedOut); + } + let request = self.auth.request(command, &session.destination)?; + tokio::select! { + biased; + () = self.cancellation.cancelled() => Err(DispatchError::NotAllowed), + connected = async { + let raw = self.upstream.connect_server(session, &self.server, context).await?; + let stream = context.run_io("Trojan TLS handshake", self.tls.connect(raw.io)).await?; + let token = self.cancellation.child_token(); + let setup_guard = token.clone().drop_guard(); + let stream = match &self.transport { + Transport::Tcp => stream, + Transport::WebSocket(options) => context.run_io("Trojan WebSocket upgrade",connect_websocket(stream,options,&request,context.deadline())).await?, + Transport::Grpc(uri) => { + let (stream, driver) = context.run_io("Trojan gRPC handshake",grpc_duplex(stream,uri,context.deadline())).await?; + let token = token.clone(); + let observation = observation::track(ResourceKind::Task); + self.tasks.spawn(observation::bind(async move { + let _observation = observation; + token.cancelled().await; + let _ = driver.stop().await; + })); + stream + }, + }; + let mut stream = OwnedStream::new(stream, token); + setup_guard.disarm(); + if !matches!(self.transport,Transport::WebSocket(_)) { + context.run_io("Trojan request header", async { + stream.write_all(&request).await?; + stream.flush().await + }).await?; + } + Ok(Box::new(stream) as BoxStream) + } => connected, + } + } +} + +#[async_trait] +impl OutboundConnector for TrojanOutbound { + async fn connect_stream( + &self, + session: StreamSession, + context: &EstablishContext, + ) -> Result { + let effective_peer = session.destination.clone(); + Ok(ConnectedStream { + io: self.connect(session, TrojanCommand::Tcp, context).await?, + effective_peer, + }) + } + + async fn open_datagram( + &self, + request: DatagramRequest, + context: &EstablishContext, + ) -> Result, DispatchError> { + // UDP destinations live in individual frames, not this request header. + let session = StreamSession { + inbound: request.session.inbound, + source: request.session.source, + destination: Destination::Ip(std::net::SocketAddr::from(([0, 0, 0, 0], 1))), + sniffed_domain: None, + }; + let stream = self.connect(session, TrojanCommand::Udp, context).await?; + Ok(crate::dispatch::bound_datagram( + Box::new(TrojanDatagram::new(stream, request.budget())), + request.budget(), + )) + } + + fn begin_shutdown(&self) { + self.cancellation.cancel(); + } + async fn shutdown(&self) { + self.begin_shutdown(); + self.tasks.close(); + self.tasks.wait().await; + } +} + +impl Drop for TrojanOutbound { + fn drop(&mut self) { + self.cancellation.cancel(); + } +} diff --git a/src/outbound/vless.rs b/src/outbound/vless/codec.rs similarity index 61% rename from src/outbound/vless.rs rename to src/outbound/vless/codec.rs index 87e89e8..face1a7 100644 --- a/src/outbound/vless.rs +++ b/src/outbound/vless/codec.rs @@ -1,4 +1,5 @@ use std::{ + future::Future, io, pin::Pin, task::{Context, Poll}, @@ -8,6 +9,7 @@ use bytes::{BufMut as _, Bytes, BytesMut}; use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, ReadBuf}; use uuid::Uuid; +use crate::outbound::address::encode_port_first as encode_destination; use crate::{dispatch::BoxStream, session::Destination}; const VLESS_VERSION: u8 = 0; @@ -15,6 +17,7 @@ const VLESS_VERSION: u8 = 0; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VlessCommand { Tcp, + Udp, Mux, } @@ -22,6 +25,7 @@ impl VlessCommand { const fn wire_value(self) -> u8 { match self { Self::Tcp => 1, + Self::Udp => 2, Self::Mux => 3, } } @@ -32,17 +36,31 @@ pub fn encode_request_header( command: VlessCommand, destination: Option<&Destination>, ) -> io::Result { - if (command == VlessCommand::Tcp) != destination.is_some() { + encode_header(uuid, command, destination, false) +} + +pub(super) fn encode_header( + uuid: Uuid, + command: VlessCommand, + destination: Option<&Destination>, + vision: bool, +) -> io::Result { + if (command != VlessCommand::Mux) != destination.is_some() { return Err(io::Error::new( io::ErrorKind::InvalidInput, - "VLESS TCP requires a destination and mux must omit it", + "VLESS TCP/UDP requires a destination and mux must omit it", )); } let mut output = BytesMut::with_capacity(64); output.put_u8(VLESS_VERSION); output.extend_from_slice(uuid.as_bytes()); - output.put_u8(0); // protobuf addons length; Vision is outside the current XHTTP scope + if vision { + output.put_u8(18); + output.extend_from_slice(b"\x0a\x10xtls-rprx-vision"); + } else { + output.put_u8(0); + } output.put_u8(command.wire_value()); if let Some(destination) = destination { encode_destination(destination, &mut output)?; @@ -78,30 +96,77 @@ where /// is available. Keeping both handshakes lazy avoids waiting for that response /// before the caller has had a chance to send its first request payload. pub struct VlessStream { - inner: BoxStream, + inner: Option, request_header: Bytes, request_header_written: usize, + request_header_flushed: bool, response_header: [u8; 2], response_header_read: usize, response_header_done: bool, + whole_close: bool, + closed: bool, + deadline: Option>>, } impl VlessStream { #[must_use] pub const fn new(inner: BoxStream, request_header: Bytes) -> Self { Self { - inner, + inner: Some(inner), request_header, request_header_written: 0, + request_header_flushed: false, response_header: [0; 2], response_header_read: 0, response_header_done: false, + whole_close: false, + closed: false, + deadline: None, } } + pub fn with_deadline( + inner: BoxStream, + request_header: Bytes, + deadline: tokio::time::Instant, + ) -> Self { + let mut stream = Self::new(inner, request_header); + stream.deadline = Some(Box::pin(tokio::time::sleep_until(deadline))); + stream + } + + pub(super) fn with_whole_close(mut self) -> Self { + self.whole_close = true; + self + } + + fn check_setup(&mut self, cx: &mut Context<'_>) -> io::Result<()> { + if self.inner.is_none() { + return Err(io::ErrorKind::BrokenPipe.into()); + } + if !self.response_header_done + && self + .deadline + .as_mut() + .is_some_and(|deadline| deadline.as_mut().poll(cx).is_ready()) + { + self.inner.take(); + return Err(io::ErrorKind::TimedOut.into()); + } + Ok(()) + } + + fn finish(&mut self, result: Poll>) -> Poll> { + if matches!(result, Poll::Ready(Err(_))) { + self.inner.take(); + } + result + } + fn poll_request_header(&mut self, cx: &mut Context<'_>) -> Poll> { + self.check_setup(cx)?; while self.request_header_written < self.request_header.len() { - let written = match Pin::new(&mut *self.inner) + let written = match Pin::new(self.inner.as_mut().expect("checked open")) .poll_write(cx, &self.request_header[self.request_header_written..]) { Poll::Pending => return Poll::Pending, @@ -113,6 +178,14 @@ impl VlessStream { } self.request_header_written += written; } + // Complete the handshake's write ownership before handing the supplied + // transport to payload writes or reads. A read-side flush must never + // consume the completion of a concurrently pending packet-up write: + // its writer would then replay bytes whose acknowledgement was lost. + if !self.request_header_flushed { + std::task::ready!(Pin::new(self.inner.as_mut().expect("checked open")).poll_flush(cx))?; + self.request_header_flushed = true; + } Poll::Ready(Ok(())) } @@ -123,7 +196,7 @@ impl VlessStream { while self.response_header_read < self.response_header.len() { let mut buffer = ReadBuf::new(&mut self.response_header[self.response_header_read..]); - match Pin::new(&mut *self.inner).poll_read(cx, &mut buffer) { + match Pin::new(self.inner.as_mut().expect("checked open")).poll_read(cx, &mut buffer) { Poll::Pending => return Poll::Pending, Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), Poll::Ready(Ok(())) if buffer.filled().is_empty() => { @@ -157,17 +230,23 @@ impl AsyncRead for VlessStream { cx: &mut Context<'_>, buffer: &mut ReadBuf<'_>, ) -> Poll> { - match self.poll_request_header(cx) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), - Poll::Ready(Ok(())) => {} + if buffer.remaining() == 0 || self.closed { + return Poll::Ready(Ok(())); } - match self.poll_response_header(cx) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), - Poll::Ready(Ok(())) => {} - } - Pin::new(&mut *self.inner).poll_read(cx, buffer) + let result = (|| { + match self.poll_request_header(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(())) => {} + } + match self.poll_response_header(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(())) => {} + } + Pin::new(self.inner.as_mut().expect("checked open")).poll_read(cx, buffer) + })(); + self.finish(result) } } @@ -177,70 +256,62 @@ impl AsyncWrite for VlessStream { cx: &mut Context<'_>, buffer: &[u8], ) -> Poll> { - match self.poll_request_header(cx) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), - Poll::Ready(Ok(())) => {} - } - Pin::new(&mut *self.inner).poll_write(cx, buffer) + let result = (|| { + match self.poll_request_header(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(())) => {} + } + Pin::new(self.inner.as_mut().expect("checked open")).poll_write(cx, buffer) + })(); + self.finish(result) } fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match self.poll_request_header(cx) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), - Poll::Ready(Ok(())) => {} - } - Pin::new(&mut *self.inner).poll_flush(cx) + let result = (|| { + match self.poll_request_header(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(())) => {} + } + Pin::new(self.inner.as_mut().expect("checked open")).poll_flush(cx) + })(); + self.finish(result) } fn poll_shutdown( mut self: Pin<&mut Self>, cx: &mut Context<'_>, ) -> Poll> { - match self.poll_request_header(cx) { - Poll::Pending => return Poll::Pending, - Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), - Poll::Ready(Ok(())) => {} + if self.closed { + return Poll::Ready(Ok(())); } - Pin::new(&mut *self.inner).poll_shutdown(cx) + if self.whole_close { + std::task::ready!(self.as_mut().poll_flush(cx))?; + self.closed = true; + // HTTP camouflage has no Mihomo CloseWrite. Drop the supplied + // OwnedStream, which also wakes reads via its shared session token. + self.inner.take(); + self.deadline.take(); + return Poll::Ready(Ok(())); + } + let result = (|| { + match self.poll_request_header(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(())) => {} + } + Pin::new(self.inner.as_mut().expect("checked open")).poll_shutdown(cx) + })(); + self.finish(result) } } -fn encode_destination(destination: &Destination, output: &mut BytesMut) -> io::Result<()> { - output.put_u16(destination.port()); - match destination { - Destination::Ip(address) if address.is_ipv4() => { - output.put_u8(1); - let std::net::IpAddr::V4(ip) = address.ip() else { - unreachable!("is_ipv4 checked") - }; - output.extend_from_slice(&ip.octets()); - } - Destination::Domain { host, .. } => { - let length = u8::try_from(host.len()).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidInput, "VLESS domain is too long") - })?; - output.put_u8(2); - output.put_u8(length); - output.extend_from_slice(host.as_bytes()); - } - Destination::Ip(address) => { - output.put_u8(3); - let std::net::IpAddr::V6(ip) = address.ip() else { - unreachable!("non-IPv4 address is IPv6") - }; - output.extend_from_slice(&ip.octets()); - } - } - Ok(()) -} - #[cfg(test)] mod tests { use std::time::Duration; - use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + use tokio::io::AsyncWriteExt as _; use super::*; diff --git a/src/outbound/vless/datagram.rs b/src/outbound/vless/datagram.rs new file mode 100644 index 0000000..6e93a13 --- /dev/null +++ b/src/outbound/vless/datagram.rs @@ -0,0 +1,223 @@ +//! Length-delimited VLESS UDP and packetaddr. XUDP uses the shared mux codec. +use super::{VlessCommand, outbound::PreparedStream}; +use crate::{ + dispatch::{BoxStream, DatagramBudget, DatagramTransport, DispatchError}, + dns::resolution::ResolutionContext, + outbound::{ + DEFAULT_ESTABLISH_TIMEOUT, + address::{decode_packet_addr, encode_packet_addr}, + }, + session::{Datagram, Destination}, +}; +use async_trait::async_trait; +use bytes::{BufMut, Bytes, BytesMut}; +use std::io; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::time::Instant; + +pub(super) struct VlessDatagram { + io: Option, + pending: Option, + packet_addr: bool, + peer: Option, + initialized: bool, + deadline: Instant, + resolution: ResolutionContext, + budget: DatagramBudget, + input: BytesMut, + cancellation: tokio_util::sync::CancellationToken, +} + +impl VlessDatagram { + pub(super) fn new( + prepared: PreparedStream, + packet_addr: bool, + budget: DatagramBudget, + resolution: ResolutionContext, + ) -> Self { + let deadline = prepared.deadline; + let cancellation = prepared.token.clone(); + Self { + io: None, + pending: Some(prepared), + packet_addr, + peer: None, + initialized: false, + deadline, + resolution, + budget, + input: BytesMut::new(), + cancellation, + } + } + async fn fill(&mut self, required: usize) -> io::Result<()> { + if required > crate::limits::VLESS_UDP_FRAME_BYTES { + return Err(io::ErrorKind::InvalidData.into()); + } + let mut scratch = [0; 1024]; + while self.input.len() < required { + let size = (required - self.input.len()).min(scratch.len()); + let count = self + .io + .as_mut() + .ok_or(io::ErrorKind::BrokenPipe)? + .read(&mut scratch[..size]) + .await?; + if count == 0 { + return Err(io::ErrorKind::UnexpectedEof.into()); + } + self.input.extend_from_slice(&scratch[..count]); + } + Ok(()) + } + async fn next(&mut self) -> io::Result<(Destination, Bytes)> { + self.fill(2).await?; + let length = u16::from_be_bytes(self.input[..2].try_into().unwrap()) as usize; + if length == 0 { + return Err(io::ErrorKind::InvalidData.into()); + } + self.fill(length + 2).await?; + let packet = self.input.split().freeze().slice(2..); + let (peer, start) = if self.packet_addr { + decode_packet_addr(&packet)? + } else { + (self.peer.clone().ok_or(io::ErrorKind::InvalidData)?, 0) + }; + if start == packet.len() { + return Err(io::ErrorKind::InvalidData.into()); + } + Ok((peer, packet.slice(start..))) + } +} + +#[async_trait] +impl DatagramTransport for VlessDatagram { + fn payload_budget(&self, peer: &Destination) -> DatagramBudget { + let overhead = if !self.packet_addr { + 0 + } else if matches!(peer,Destination::Ip(addr) if addr.is_ipv4()) { + 7 + } else { + 19 + }; + let max = u16::MAX - overhead; + self.budget.intersect(DatagramBudget::new(max, max)) + } + async fn send(&mut self, packet: Datagram) -> Result<(), DispatchError> { + if self.cancellation.is_cancelled() { + self.close().await?; + return Err(DispatchError::NotAllowed); + } + if self.io.is_none() && self.pending.is_none() { + return Err(io::Error::from(io::ErrorKind::BrokenPipe).into()); + } + if packet.payload.is_empty() + || packet.remote.port() == 0 + || packet.payload.len() > self.payload_budget(&packet.remote).transmit() as usize + { + return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); + } + if !self.packet_addr + && self + .peer + .as_ref() + .is_some_and(|peer| *peer != packet.remote) + { + return Err(DispatchError::NotAllowed); + } + let first = !self.initialized; + if first && Instant::now() >= self.deadline { + self.io.take(); + self.pending.take(); + return Err(DispatchError::TimedOut); + } + let mut wire = BytesMut::with_capacity(packet.payload.len() + 21); + wire.put_u16(0); + if self.packet_addr { + let deadline = if first { + self.deadline + } else { + Instant::now() + DEFAULT_ESTABLISH_TIMEOUT + }; + let peer = tokio::select! { + biased; + () = self.cancellation.cancelled() => { + self.close().await?; + return Err(DispatchError::NotAllowed); + } + result = self.resolution.resolve_ip(&packet.remote, deadline) => result?, + }; + encode_packet_addr(&peer.into(), &mut wire)?; + } + wire.extend_from_slice(&packet.payload); + let length = (wire.len() - 2) as u16; + wire[..2].copy_from_slice(&length.to_be_bytes()); + let mut stream = if let Some(prepared) = self.pending.take() { + let destination = if self.packet_addr { + Destination::domain("sp.packet-addr.v2fly.arpa", 443)? + } else { + packet.remote.clone() + }; + prepared + .finish(VlessCommand::Udp, Some(&destination)) + .await? + } else { + self.io + .take() + .ok_or_else(|| io::Error::from(io::ErrorKind::BrokenPipe))? + }; + let write = async { + stream.write_all(&wire).await?; + stream.flush().await + }; + if first { + tokio::time::timeout_at(self.deadline, write) + .await + .map_err(|_| DispatchError::TimedOut)??; + } else { + write.await?; + } + self.peer.get_or_insert(packet.remote); + self.initialized = true; + self.io = Some(stream); + Ok(()) + } + async fn receive(&mut self) -> Result { + if self.io.is_none() && self.pending.is_none() { + return Err(io::Error::from(io::ErrorKind::BrokenPipe).into()); + } + if !self.initialized { + self.cancellation.cancelled().await; + self.close().await?; + return Err(DispatchError::NotAllowed); + } + let mut count = 0; + loop { + let (remote, payload) = match self.next().await { + Ok(packet) => packet, + Err(error) => { + self.io.take(); + return Err(error.into()); + } + }; + if payload.len() <= self.payload_budget(&remote).receive() as usize { + return Ok(Datagram { + remote, + payload, + sniffed_domain: None, + }); + } + count += 1; + if count == crate::limits::IO_POLL_BUDGET { + tokio::task::yield_now().await; + count = 0; + } + } + } + async fn close(&mut self) -> Result<(), DispatchError> { + self.io.take(); + self.pending.take(); + self.input.clear(); + Ok(()) + } +} diff --git a/src/outbound/vless/encryption/cache.rs b/src/outbound/vless/encryption/cache.rs new file mode 100644 index 0000000..e3b5d93 --- /dev/null +++ b/src/outbound/vless/encryption/cache.rs @@ -0,0 +1,94 @@ +use std::{ + io, + sync::{Arc, Mutex}, +}; +use tokio::time::Instant; +use tokio_util::sync::CancellationToken; +use zeroize::Zeroizing; + +pub(super) struct Ticket { + pub pfs: Zeroizing<[u8; 64]>, + pub bytes: Zeroizing<[u8; 16]>, + pub expires: Instant, +} + +#[derive(Default)] +pub(super) struct Cache { + ticket: Mutex>>, + pub cancellation: CancellationToken, +} + +impl Cache { + pub fn lookup(&self) -> io::Result>> { + let mut slot = self.ticket.lock().unwrap_or_else(|e| e.into_inner()); + if self.cancellation.is_cancelled() { + return Err(closed()); + } + if slot.as_ref().is_some_and(|t| t.expires <= Instant::now()) { + slot.take(); + } + Ok(slot.clone()) + } + + pub fn publish(&self, ticket: Arc) -> io::Result<()> { + let mut slot = self.ticket.lock().unwrap_or_else(|e| e.into_inner()); + if self.cancellation.is_cancelled() { + return Err(closed()); + } + *slot = Some(ticket); + Ok(()) + } + + pub fn invalidate(&self, ticket: &Arc) { + let mut slot = self.ticket.lock().unwrap_or_else(|e| e.into_inner()); + if slot + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, ticket)) + { + slot.take(); + } + } + + pub fn close(&self) { + let mut slot = self.ticket.lock().unwrap_or_else(|e| e.into_inner()); + self.cancellation.cancel(); + slot.take(); + } +} + +pub(super) fn closed() -> io::Error { + io::Error::new( + io::ErrorKind::ConnectionAborted, + "VLESS Encryption node stopped", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + fn ticket(expires: Instant) -> Arc { + Arc::new(Ticket { + pfs: Zeroizing::new([0; 64]), + bytes: Zeroizing::new([0; 16]), + expires, + }) + } + #[test] + fn expires_and_invalidates_only_the_matching_ticket_generation() { + let cache = Cache::default(); + cache.publish(ticket(Instant::now())).unwrap(); + assert!(cache.lookup().unwrap().is_none()); + let expires = Instant::now() + std::time::Duration::from_secs(60); + let old = ticket(expires); + let new = ticket(expires); + cache.publish(old.clone()).unwrap(); + cache.publish(new.clone()).unwrap(); + cache.invalidate(&old); + assert!(Arc::ptr_eq(&cache.lookup().unwrap().unwrap(), &new)); + cache.invalidate(&new); + assert!(cache.lookup().unwrap().is_none()); + cache.close(); + assert!(cache.publish(old).is_err()); + assert!(cache.lookup().is_err()); + } +} diff --git a/src/outbound/vless/encryption/config.rs b/src/outbound/vless/encryption/config.rs new file mode 100644 index 0000000..3086048 --- /dev/null +++ b/src/outbound/vless/encryption/config.rs @@ -0,0 +1,118 @@ +use super::{crypto, invalid_config}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use boring::mlkem::{Algorithm, MlKemPublicKey}; +use std::{io, time::Duration}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Appearance { + Native, + XorPublic, + Random, +} + +pub(super) struct Settings { + pub keys: Vec>, + pub appearance: Appearance, + pub resume: bool, + padding: Vec<[u32; 3]>, +} + +impl Settings { + pub(super) fn parse(value: &str) -> io::Result { + if value.len() > 65_536 { + return Err(invalid_config()); + } + let mut parts = value.split('.'); + if parts.next() != Some("mlkem768x25519plus") { + return Err(invalid_config()); + } + let appearance = match parts.next() { + Some("native") => Appearance::Native, + Some("xorpub") => Appearance::XorPublic, + Some("random") => Appearance::Random, + _ => return Err(invalid_config()), + }; + let resume = match parts.next() { + Some("1rtt") => false, + Some("0rtt") => true, + _ => return Err(invalid_config()), + }; + let (mut keys, mut padding) = (Vec::new(), Vec::new()); + let mut max_padding = 0u64; + for part in parts { + if part.len() >= 20 { + if keys.len() == 16 { + return Err(invalid_config()); + } + let key = URL_SAFE_NO_PAD.decode(part).map_err(|_| invalid_config())?; + match key.len() { + 32 => { + crypto::x_public(&key).map_err(|_| invalid_config())?; + } + 1184 => { + MlKemPublicKey::from_slice(Algorithm::MlKem768, &key) + .map_err(|_| invalid_config())?; + } + _ => return Err(invalid_config()), + } + keys.push(key); + } else { + if padding.len() == 128 { + return Err(invalid_config()); + } + let numbers = part + .split('-') + .map(str::parse::) + .collect::, _>>() + .map_err(|_| invalid_config())?; + let row: [u32; 3] = numbers.try_into().map_err(|_| invalid_config())?; + if padding.is_empty() && (row[0] < 100 || row[1] < 35 || row[2] < 35) { + return Err(invalid_config()); + } + if padding.len() % 2 == 0 { + max_padding += u64::from(row[1].max(row[2])); + if max_padding > 65_553 { + return Err(invalid_config()); + } + } + padding.push(row); + } + } + if keys.is_empty() { + return Err(invalid_config()); + } + if padding.is_empty() { + padding = vec![[100, 111, 1111], [75, 0, 111], [50, 0, 3333]]; + } + Ok(Self { + keys, + appearance, + resume, + padding, + }) + } + + pub(super) fn padding(&self) -> (Vec, Vec) { + let mut lengths = Vec::new(); + let mut gaps = Vec::new(); + for (index, [probability, left, right]) in self.padding.iter().copied().enumerate() { + // Match Mihomo's inclusive probability comparison and exclusive + // upper range, including reversed/equal endpoints. + let value = if probability >= rand::random_range(0..100) { + if left == right { + left + } else { + rand::random_range(left.min(right)..left.max(right)) + } + } else { + 0 + }; + if index % 2 == 0 { + lengths.push(value as usize); + } else { + gaps.push(Duration::from_millis(u64::from(value))); + } + } + (lengths, gaps) + } +} diff --git a/src/outbound/vless/encryption/crypto.rs b/src/outbound/vless/encryption/crypto.rs new file mode 100644 index 0000000..1994659 --- /dev/null +++ b/src/outbound/vless/encryption/crypto.rs @@ -0,0 +1,332 @@ +use boring::{ + aead::{AeadCtx, Algorithm}, + derive::Deriver, + pkey::{Id, PKey, Private, Public}, + symm::{Cipher, Crypter, Mode}, +}; +use std::io; +use zeroize::Zeroizing; + +#[derive(Clone, Copy)] +pub(super) enum Suite { + Aes, + ChaCha, +} + +impl Suite { + pub(super) fn for_cpu() -> Self { + #[cfg(target_arch = "aarch64")] + if std::arch::is_aarch64_feature_detected!("aes") { + return Self::Aes; + } + #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] + if std::arch::is_x86_feature_detected!("aes") + && std::arch::is_x86_feature_detected!("pclmulqdq") + && std::arch::is_x86_feature_detected!("sse4.1") + && std::arch::is_x86_feature_detected!("ssse3") + { + return Self::Aes; + } + Self::ChaCha + } +} + +pub(super) fn failure() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + "VLESS Encryption authentication failed", + ) +} + +pub(super) struct Aead { + context: AeadCtx, + nonce: [u8; 12], +} +impl Aead { + pub(super) fn new(context: &[u8], material: &[u8], suite: Suite) -> io::Result { + let key = Zeroizing::new(vcore_blake3_raw::derive_key(context, material)); + let algorithm = match suite { + Suite::Aes => Algorithm::aes_256_gcm(), + Suite::ChaCha => Algorithm::chacha20_poly1305(), + }; + Ok(Self { + context: AeadCtx::new_default_tag(&algorithm, key.as_ref()).map_err(|_| failure())?, + nonce: [0; 12], + }) + } + pub(super) fn exhausted(&self) -> bool { + self.nonce == [255; 12] + } + #[cfg(test)] + pub(super) fn set_test_nonce(&mut self, nonce: [u8; 12]) { + self.nonce = nonce; + } + fn next_nonce(&mut self, explicit: Option<[u8; 12]>) -> [u8; 12] { + if let Some(nonce) = explicit { + return nonce; + } + for byte in self.nonce.iter_mut().rev() { + let (next, carry) = byte.overflowing_add(1); + *byte = next; + if !carry { + break; + } + } + self.nonce + } + pub(super) fn seal( + &mut self, + plain: &[u8], + aad: &[u8], + nonce: Option<[u8; 12]>, + ) -> io::Result> { + let nonce = self.next_nonce(nonce); + let mut sealed = plain.to_vec(); + let mut tag = [0; 16]; + self.context + .seal_in_place_mut(&nonce, &mut sealed, &mut tag, aad) + .map_err(|_| failure())?; + sealed.extend_from_slice(&tag); + Ok(sealed) + } + pub(super) fn open( + &mut self, + sealed: &[u8], + aad: &[u8], + nonce: Option<[u8; 12]>, + ) -> io::Result> { + let size = sealed.len().checked_sub(16).ok_or_else(failure)?; + let nonce = self.next_nonce(nonce); + let mut plain = Zeroizing::new(sealed[..size].to_vec()); + self.context + .open_in_place_mut(&nonce, &mut plain, &sealed[size..], aad) + .map_err(|_| failure())?; + Ok(std::mem::take(&mut *plain)) + } +} + +pub(super) struct Ctr(Crypter); +impl Ctr { + pub(super) fn new(material: &[u8], iv: &[u8; 16]) -> io::Result { + let key = Zeroizing::new(blake3::derive_key("VLESS", material)); + Ok(Self( + Crypter::new(Cipher::aes_256_ctr(), Mode::Encrypt, key.as_ref(), Some(iv)) + .map_err(|_| failure())?, + )) + } + pub(super) fn apply(&mut self, data: &mut [u8]) -> io::Result<()> { + let mut output = Zeroizing::new(vec![0; data.len() + 16]); + let count = self.0.update(data, &mut output).map_err(|_| failure())?; + if count != data.len() { + return Err(failure()); + } + data.copy_from_slice(&output[..count]); + Ok(()) + } +} + +pub(super) fn x_public(raw: &[u8]) -> io::Result> { + if raw.len() != 32 { + return Err(failure()); + } + // RFC 8410 SubjectPublicKeyInfo, id-X25519 with absent parameters. + let mut der = [0; 44]; + der[..12].copy_from_slice(&[0x30, 0x2a, 0x30, 5, 6, 3, 0x2b, 0x65, 0x6e, 3, 0x21, 0]); + der[12..].copy_from_slice(raw); + PKey::public_key_from_der(&der).map_err(|_| failure()) +} + +pub(super) fn x_generate() -> io::Result<(PKey, [u8; 32])> { + let private = PKey::generate(Id::X25519).map_err(|_| failure())?; + let mut public = [0; 32]; + if private + .raw_public_key(&mut public) + .map_err(|_| failure())? + .len() + != 32 + { + return Err(failure()); + } + Ok((private, public)) +} + +pub(super) fn x_shared(private: &PKey, raw: &[u8]) -> io::Result> { + let peer = x_public(raw)?; + let mut derive = Deriver::new(private).map_err(|_| failure())?; + derive.set_peer(&peer).map_err(|_| failure())?; + let mut shared = Zeroizing::new([0; 32]); + // Native EVP derives reject low-order/all-zero shared secrets. + if derive.derive(shared.as_mut()).map_err(|_| failure())? != 32 { + return Err(failure()); + } + Ok(shared) +} + +#[cfg(test)] +mod tests { + use super::*; + fn bytes(value: &serde_json::Value) -> Vec { + let s = value.as_str().unwrap().as_bytes(); + s.as_chunks::<2>() + .0 + .iter() + .map(|b| u8::from_str_radix(std::str::from_utf8(b).unwrap(), 16).unwrap()) + .collect() + } + fn vectors() -> serde_json::Value { + serde_json::from_str(include_str!( + "../../../../tests/protocols/encryption-crypto.json" + )) + .unwrap() + } + #[test] + fn derived_aead_matches_independent_go_ciphertexts() { + for v in vectors()["records"].as_array().unwrap() { + let suite = if v["cipher"] == "aes256gcm" { + Suite::Aes + } else { + Suite::ChaCha + }; + let mut aead = Aead::new(&bytes(&v["context"]), &bytes(&v["key"]), suite).unwrap(); + assert_eq!( + aead.seal( + &bytes(&v["plaintext"]), + &bytes(&v["aad"]), + Some(bytes(&v["nonce"]).try_into().unwrap()) + ) + .unwrap(), + bytes(&v["ciphertext"]) + ); + } + } + #[test] + fn opens_go_records_and_rejects_changes_to_aad_nonce_or_tag() { + for v in vectors()["records"].as_array().unwrap() { + let suite = if v["cipher"] == "aes256gcm" { + Suite::Aes + } else { + Suite::ChaCha + }; + let nonce: [u8; 12] = bytes(&v["nonce"]).try_into().unwrap(); + let make = || Aead::new(&bytes(&v["context"]), &bytes(&v["key"]), suite).unwrap(); + assert_eq!( + make() + .open(&bytes(&v["ciphertext"]), &bytes(&v["aad"]), Some(nonce)) + .unwrap(), + bytes(&v["plaintext"]) + ); + let mut aad = bytes(&v["aad"]); + aad[0] ^= 1; + assert!( + make() + .open(&bytes(&v["ciphertext"]), &aad, Some(nonce)) + .is_err() + ); + let mut wrong_nonce = nonce; + wrong_nonce[0] ^= 1; + assert!( + make() + .open( + &bytes(&v["ciphertext"]), + &bytes(&v["aad"]), + Some(wrong_nonce) + ) + .is_err() + ); + let mut sealed = bytes(&v["ciphertext"]); + *sealed.last_mut().unwrap() ^= 1; + assert!( + make() + .open(&sealed, &bytes(&v["aad"]), Some(nonce)) + .is_err() + ); + } + } + #[test] + fn automatic_nonce_increments_before_encryption() { + let fixtures = vectors(); + for records in fixtures["records"].as_array().unwrap().as_chunks::<3>().0 { + let first = &records[0]; + let suite = if first["cipher"] == "aes256gcm" { + Suite::Aes + } else { + Suite::ChaCha + }; + let mut aead = + Aead::new(&bytes(&first["context"]), &bytes(&first["key"]), suite).unwrap(); + for v in &records[..2] { + assert_eq!( + aead.seal(&bytes(&v["plaintext"]), &bytes(&v["aad"]), None) + .unwrap(), + bytes(&v["ciphertext"]) + ); + } + } + } + #[test] + fn ctr_matches_go_across_arbitrary_fragment_boundaries() { + let v = vectors(); + for chunk in [1, 3, 16, 37] { + let mut ctr = Ctr::new( + &bytes(&v["ctr_key"]), + &bytes(&v["ctr_context"]).try_into().unwrap(), + ) + .unwrap(); + let mut input = bytes(&v["ctr_plaintext"]); + for piece in input.chunks_mut(chunk) { + ctr.apply(piece).unwrap(); + } + assert_eq!(input, bytes(&v["ctr_ciphertext"])); + } + } + #[test] + fn x25519_matches_go_and_rejects_low_order_peers() { + let v = vectors(); + let mut der = Zeroizing::new(vec![ + 0x30, 0x2e, 2, 1, 0, 0x30, 5, 6, 3, 0x2b, 0x65, 0x6e, 4, 0x22, 4, 0x20, + ]); + der.extend_from_slice(&bytes(&v["x25519_private"])); + let private = PKey::private_key_from_pkcs8(&der).unwrap(); + assert_eq!( + x_shared(&private, &bytes(&v["x25519_peer"])) + .unwrap() + .as_ref(), + bytes(&v["x25519_shared"]) + ); + let mut public = [0; 32]; + private.raw_public_key(&mut public).unwrap(); + assert_eq!(public.as_slice(), bytes(&v["x25519_public"])); + for low in [vec![0; 32], { + let mut k = vec![0; 32]; + k[0] = 1; + k + }] { + assert!(x_shared(&private, &low).is_err()); + } + } + #[test] + fn native_mlkem_decapsulates_go_and_rejects_invalid_encodings() { + use boring::mlkem::{Algorithm as Kem, MlKemPrivateKey, MlKemPublicKey}; + let v = vectors(); + let key = + MlKemPrivateKey::from_seed(Kem::MlKem768, &bytes(&v["mlkem_seed"]).try_into().unwrap()) + .unwrap(); + assert_eq!( + key.decapsulate(&bytes(&v["mlkem_ciphertext"])) + .unwrap() + .as_slice(), + bytes(&v["mlkem_shared"]) + ); + assert!(key.decapsulate(&[0; 1087]).is_err()); + assert!(MlKemPublicKey::from_slice(Kem::MlKem768, &[255; 1184]).is_err()); + let peer = MlKemPublicKey::from_slice(Kem::MlKem768, &bytes(&v["mlkem_public"])).unwrap(); + let (ciphertext, shared) = peer.encapsulate().unwrap(); + assert_eq!(key.decapsulate(&ciphertext).unwrap(), shared); + let mut tampered = bytes(&v["mlkem_ciphertext"]); + tampered[0] ^= 1; + assert_ne!( + key.decapsulate(&tampered).unwrap().as_slice(), + bytes(&v["mlkem_shared"]) + ); + } +} diff --git a/src/outbound/vless/encryption/handshake.rs b/src/outbound/vless/encryption/handshake.rs new file mode 100644 index 0000000..04a27c5 --- /dev/null +++ b/src/outbound/vless/encryption/handshake.rs @@ -0,0 +1,292 @@ +use super::{ + cache::{Cache, Ticket}, + config::{Appearance, Settings}, + crypto::{self, Aead, Ctr, Suite, failure}, + records::Records, +}; +use crate::dispatch::BoxStream; +use crate::security::vision::{SpliceControl, SpliceStats}; +use boring::{ + mlkem::{Algorithm, MlKemPrivateKey, MlKemPublicKey}, + pkey::{PKey, Private}, +}; +use std::{io, sync::Arc, time::Duration}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + time::{Instant, sleep, timeout_at}, +}; +use zeroize::Zeroizing; + +/// One owned flight. The initial bytes can be consumed exactly once by an +/// outer transport's early-data interface; finish sends only the remainder. +pub(crate) struct Handshake { + hello: Vec, + fragments: Vec, + gaps: Vec, + iv: [u8; 16], + nfs_key: Zeroizing<[u8; 32]>, + exchange: Exchange, + suite: Suite, + random_headers: bool, + cache: Arc, + cache_enabled: bool, + vision: Option<(SpliceControl, Arc)>, +} + +enum Exchange { + Full { + nfs: Aead, + public: Vec, + kem: MlKemPrivateKey, + x: PKey, + }, + Resume { + ticket: Arc, + context: Vec, + }, +} + +impl Handshake { + pub(super) fn start(settings: &Settings, cache: Arc, suite: Suite) -> io::Result { + let ticket = cache.lookup()?.filter(|_| settings.resume); + let iv = rand::random::<[u8; 16]>(); + let mut hello = iv.to_vec(); + let mut last_ctr: Option = None; + let mut nfs_key = Zeroizing::new([0; 32]); + for (index, key) in settings.keys.iter().enumerate() { + let mut relay = if key.len() == 32 { + let (private, public) = crypto::x_generate()?; + nfs_key = crypto::x_shared(&private, key)?; + public.to_vec() + } else { + let peer = + MlKemPublicKey::from_slice(Algorithm::MlKem768, key).map_err(|_| failure())?; + let (ciphertext, shared) = peer.encapsulate().map_err(|_| failure())?; + nfs_key = Zeroizing::new(shared); + ciphertext + }; + if settings.appearance != Appearance::Native { + Ctr::new(key, &iv)?.apply(&mut relay)?; + } + if let Some(previous) = last_ctr.as_mut() { + previous.apply(&mut relay[..32])?; + } + hello.extend_from_slice(&relay); + if let Some(next) = settings.keys.get(index + 1) { + let mut next_hash = *blake3::hash(next).as_bytes(); + let mut ctr = Ctr::new(nfs_key.as_ref(), &iv)?; + ctr.apply(&mut next_hash)?; + hello.extend_from_slice(&next_hash); + last_ctr = Some(ctr); + } + } + let mut nfs = Aead::new(&iv, nfs_key.as_ref(), suite)?; + if let Some(ticket) = ticket { + hello.extend_from_slice(&nfs.seal(&32u16.to_be_bytes(), &[], None)?); + let context = nfs.seal(ticket.bytes.as_ref(), &[], None)?; + hello.extend_from_slice(&context); + return Ok(Self { + fragments: vec![hello.len()], + hello, + gaps: Vec::new(), + iv, + nfs_key, + exchange: Exchange::Resume { ticket, context }, + suite, + random_headers: settings.appearance == Appearance::Random, + cache, + cache_enabled: true, + vision: None, + }); + } + let (kem_public, kem) = + MlKemPrivateKey::generate(Algorithm::MlKem768).map_err(|_| failure())?; + let (x, x_public) = crypto::x_generate()?; + let mut public = kem_public.as_bytes().to_vec(); + public.extend_from_slice(&x_public); + hello.extend_from_slice(&nfs.seal(&1232u16.to_be_bytes(), &[], None)?); + hello.extend_from_slice(&nfs.seal(&public, &[], None)?); + let (mut fragments, gaps) = settings.padding(); + let padding_size: usize = fragments.iter().sum(); + fragments[0] += hello.len(); + hello.extend_from_slice(&nfs.seal( + &((padding_size - 18) as u16).to_be_bytes(), + &[], + None, + )?); + hello.extend_from_slice(&nfs.seal(&vec![0; padding_size - 34], &[], None)?); + Ok(Self { + hello, + fragments, + gaps, + iv, + nfs_key, + exchange: Exchange::Full { + nfs, + public, + kem, + x, + }, + suite, + random_headers: settings.appearance == Appearance::Random, + cache, + cache_enabled: settings.resume, + vision: None, + }) + } + + pub(crate) fn with_vision(mut self, vision: Option<(SpliceControl, Arc)>) -> Self { + self.vision = vision; + self + } + + pub(crate) fn prefix(&self) -> &[u8] { + &self.hello[..self.fragments[0]] + } + + pub(crate) async fn finish( + self, + raw: BoxStream, + prefix_sent: bool, + deadline: Instant, + ) -> io::Result { + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "VLESS Encryption establishment timed out", + )); + } + let cancellation = self.cache.cancellation.clone(); + let mut raw: BoxStream = Box::new(crate::outbound::owned_stream::OwnedStream::new( + raw, + cancellation.child_token(), + )); + let operation = async move { + if let Exchange::Resume { ticket, context } = &self.exchange { + let mut united = Zeroizing::new([0; 96]); + united[..64].copy_from_slice(ticket.pfs.as_ref()); + united[64..].copy_from_slice(self.nfs_key.as_ref()); + let tx = Aead::new(context, united.as_ref(), self.suite)?; + let tx_ctr = self + .random_headers + .then(|| Ctr::new(united.as_ref(), &self.iv)) + .transpose()?; + let prefix = if prefix_sent { + Vec::new() + } else { + self.prefix().to_vec() + }; + return Ok(Box::new( + Records::resumed( + raw, + united, + self.suite, + tx, + tx_ctr, + self.random_headers, + prefix, + self.cache.clone(), + ticket.clone(), + ) + .with_vision(self.vision), + ) as BoxStream); + } + let mut offset = 0; + for (index, length) in self.fragments.iter().copied().enumerate() { + if length != 0 && !(index == 0 && prefix_sent) { + raw.write_all(&self.hello[offset..offset + length]).await?; + raw.flush().await?; + } + offset += length; + if let Some(gap) = self.gaps.get(index).filter(|gap| !gap.is_zero()) { + sleep(*gap).await; + } + } + let mut server = [0; 1136]; + raw.read_exact(&mut server).await?; + let Exchange::Full { + mut nfs, + public: client_public, + kem: private_kem, + x: private_x, + } = self.exchange + else { + unreachable!() + }; + let public = nfs.open(&server, &[], Some([255; 12]))?; + let kem = Zeroizing::new( + private_kem + .decapsulate(&public[..1088]) + .map_err(|_| failure())?, + ); + let x = crypto::x_shared(&private_x, &public[1088..])?; + let mut united = Zeroizing::new([0; 96]); + united[..32].copy_from_slice(kem.as_ref()); + united[32..64].copy_from_slice(x.as_ref()); + united[64..].copy_from_slice(self.nfs_key.as_ref()); + let tx = Aead::new(&client_public, united.as_ref(), self.suite)?; + let mut rx = Aead::new(&public, united.as_ref(), self.suite)?; + let mut ticket = [0; 32]; + raw.read_exact(&mut ticket).await?; + let ticket = Zeroizing::new(rx.open(&ticket, &[], None)?); + let mut length = [0; 18]; + raw.read_exact(&mut length).await?; + let size = rx.open(&length, &[], None)?; + let padding_size = usize::from(u16::from_be_bytes([size[0], size[1]])); + if padding_size < 16 { + return Err(failure()); + } + let seconds = u16::from_be_bytes([ticket[0], ticket[1]]); + let cached = if self.cache_enabled && seconds != 0 { + let mut pfs = Zeroizing::new([0; 64]); + pfs.copy_from_slice(&united[..64]); + let cached = Arc::new(Ticket { + pfs, + bytes: Zeroizing::new(ticket.as_slice().try_into().map_err(|_| failure())?), + expires: Instant::now() + Duration::from_secs(u64::from(seconds)), + }); + self.cache.publish(cached.clone())?; + Some(cached) + } else { + None + }; + let (tx_ctr, rx_ctr) = if self.random_headers { + let server_iv: &[u8; 16] = ticket.as_slice().try_into().map_err(|_| failure())?; + ( + Some(Ctr::new(united.as_ref(), &self.iv)?), + Some(Ctr::new(united.as_ref(), server_iv)?), + ) + } else { + (None, None) + }; + Ok(Box::new( + Records::new( + raw, + united, + self.suite, + tx, + rx, + tx_ctr, + rx_ctr, + padding_size, + self.cache, + cached, + ) + .with_vision(self.vision), + ) as BoxStream) + }; + timeout_at(deadline, async { + tokio::select! { biased; + _ = cancellation.cancelled() => Err(super::cache::closed()), + result = operation => result, + } + }) + .await + .map_err(|_| { + io::Error::new( + io::ErrorKind::TimedOut, + "VLESS Encryption establishment timed out", + ) + })? + } +} diff --git a/src/outbound/vless/encryption/header_xor.rs b/src/outbound/vless/encryption/header_xor.rs new file mode 100644 index 0000000..7c38488 --- /dev/null +++ b/src/outbound/vless/encryption/header_xor.rs @@ -0,0 +1,51 @@ +//! The random appearance retains header CTR even after Vision bypasses AEAD. +//! Preserve header/body progress across arbitrary read/write fragment sizes. +use super::crypto::Ctr; +use std::io; + +#[derive(Default)] +pub(super) struct HeaderXor { + header: [u8; 5], + filled: usize, + remaining: usize, +} + +impl HeaderXor { + pub(super) fn apply( + &mut self, + ctr: &mut Ctr, + mut bytes: &mut [u8], + decrypt: bool, + ) -> io::Result<()> { + while !bytes.is_empty() { + if self.remaining != 0 { + let count = self.remaining.min(bytes.len()); + self.remaining -= count; + bytes = &mut bytes[count..]; + continue; + } + let count = (5 - self.filled).min(bytes.len()); + let (part, rest) = bytes.split_at_mut(count); + if decrypt { + ctr.apply(part)?; + } + self.header[self.filled..self.filled + count].copy_from_slice(part); + if !decrypt { + ctr.apply(part)?; + } + self.filled += count; + bytes = rest; + if self.filled == 5 { + // Mihomo's XorConn ignores DecodeHeader's error, including + // non-application TLS headers; these consume no body bytes. + self.remaining = if self.header[..3] == [23, 3, 3] { + usize::from(u16::from_be_bytes([self.header[3], self.header[4]])) + } else { + 0 + }; + self.filled = 0; + } + } + Ok(()) + } +} diff --git a/src/outbound/vless/encryption/mod.rs b/src/outbound/vless/encryption/mod.rs new file mode 100644 index 0000000..963472e --- /dev/null +++ b/src/outbound/vless/encryption/mod.rs @@ -0,0 +1,198 @@ +//! VLESS Encryption over caller-owned IO. No sockets or background tasks. +mod cache; +mod config; +mod crypto; +mod handshake; +mod header_xor; +mod records; +pub(crate) use handshake::Handshake; +use std::{io, sync::Arc}; + +pub struct Client { + settings: config::Settings, + cache: Arc, + suite: crypto::Suite, +} + +impl std::fmt::Debug for Client { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EncryptionClient") + .field("key_count", &self.settings.keys.len()) + .finish_non_exhaustive() + } +} + +impl Client { + pub fn parse(value: &str) -> io::Result { + Ok(Self { + settings: config::Settings::parse(value)?, + cache: Arc::new(cache::Cache::default()), + suite: crypto::Suite::for_cpu(), + }) + } + + #[cfg(any(test, feature = "interop-test"))] + pub async fn connect( + &self, + raw: crate::dispatch::BoxStream, + deadline: tokio::time::Instant, + ) -> io::Result { + let flight = self.start()?; + flight.finish(raw, false, deadline).await + } + + pub(crate) fn start(&self) -> io::Result { + Handshake::start(&self.settings, self.cache.clone(), self.suite) + } + + /// Exercise the non-AES hardware branch against an independent peer. + /// Never compiled into production, and never a public YAML cipher option. + #[cfg(feature = "interop-test")] + #[doc(hidden)] + pub fn with_chacha20_poly1305_for_interop(mut self) -> Self { + self.suite = crypto::Suite::ChaCha; + self + } + + /// Clears node-local tickets and cancels owned connections. Never reopens. + pub fn close(&self) { + self.cache.close(); + } +} + +fn invalid_config() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + "invalid VLESS Encryption configuration", + ) +} + +pub(crate) fn validate_config(value: &str) -> io::Result<()> { + config::Settings::parse(value).map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine as _; + + #[test] + fn accepts_the_native_one_rtt_key_configuration() { + let key = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([9; 32]); + assert!(Client::parse(&format!("mlkem768x25519plus.native.1rtt.{key}")).is_ok()); + } + #[test] + fn accepts_all_six_modes_and_ordered_keys_with_fragment_padding() { + let key = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([9; 32]); + for style in ["native", "xorpub", "random"] { + for mode in ["1rtt", "0rtt"] { + let config = format!( + "mlkem768x25519plus.{style}.{mode}.{key}.100-35-35.100-0-1.50-1-5.{key}" + ); + assert!(Client::parse(&config).is_ok(), "{style}/{mode}"); + } + } + } + + #[test] + fn rejects_malformed_or_unbounded_settings_without_reflecting_input() { + let key = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([9; 32]); + let prefix = format!("mlkem768x25519plus.native.1rtt.{key}"); + for value in [ + "secret-invalid-profile".to_owned(), + format!("{prefix}.100-34-35"), + format!("{prefix}.99-35-35"), + format!("{prefix}.100-65554-65554"), + format!("{prefix}.100-32777-32777.100-0-0.100-32777-32777"), + format!("{prefix}.100-35-35.100--1-0"), + format!("{prefix}.100-35-35-1"), + format!("{prefix}.100-35-35.100-0-4294967296"), + format!( + "mlkem768x25519plus.native.1rtt.{}", + vec![key.as_str(); 17].join(".") + ), + format!("{prefix}.{}", vec!["100-35-35"; 129].join(".")), + "x".repeat(65537), + "mlkem768x25519plus.native.1rtt.100-35-35".to_owned(), + ] { + let error = Client::parse(&value).unwrap_err(); + assert_eq!(error.to_string(), "invalid VLESS Encryption configuration"); + } + assert!(Client::parse(&format!("{prefix}.100-65553-65553")).is_ok()); + assert!( + Client::parse(&format!( + "mlkem768x25519plus.native.1rtt.{}", + vec![key.as_str(); 16].join(".") + )) + .is_ok() + ); + assert!(!format!("{:?}", Client::parse(&prefix).unwrap()).contains(&key)); + } + + #[tokio::test] + async fn close_cancels_an_inflight_handshake_without_waiting_for_the_peer() { + use std::{ + pin::Pin, + sync::atomic::{AtomicBool, AtomicUsize, Ordering}, + task::{Context, Poll}, + }; + use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + struct Blackhole(Arc, Arc); + impl Drop for Blackhole { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + impl AsyncRead for Blackhole { + fn poll_read( + self: Pin<&mut Self>, + _: &mut Context<'_>, + _: &mut ReadBuf<'_>, + ) -> Poll> { + Poll::Pending + } + } + impl AsyncWrite for Blackhole { + fn poll_write( + self: Pin<&mut Self>, + _: &mut Context<'_>, + input: &[u8], + ) -> Poll> { + self.1.fetch_add(input.len(), Ordering::SeqCst); + Poll::Ready(Ok(input.len())) + } + fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + fn poll_shutdown(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + let key = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([9; 32]); + let client = + Client::parse(&format!("mlkem768x25519plus.native.0rtt.{key}.100-35-35")).unwrap(); + let dropped = Arc::new(AtomicBool::new(false)); + let bytes = Arc::new(AtomicUsize::new(0)); + let raw = Box::new(Blackhole(dropped.clone(), bytes.clone())); + let mut connect = std::pin::pin!(client.connect( + raw, + tokio::time::Instant::now() + std::time::Duration::from_secs(30) + )); + assert!( + connect + .as_mut() + .poll(&mut Context::from_waker(std::task::Waker::noop())) + .is_pending() + ); + assert_eq!(bytes.load(Ordering::SeqCst), 1333); + client.close(); + let error = tokio::time::timeout(std::time::Duration::from_millis(100), connect) + .await + .unwrap() + .err() + .unwrap(); + assert_eq!(error.kind(), io::ErrorKind::ConnectionAborted); + assert!(dropped.load(Ordering::SeqCst)); + assert_eq!(bytes.load(Ordering::SeqCst), 1333); + } +} diff --git a/src/outbound/vless/encryption/records.rs b/src/outbound/vless/encryption/records.rs new file mode 100644 index 0000000..ea168fa --- /dev/null +++ b/src/outbound/vless/encryption/records.rs @@ -0,0 +1,807 @@ +use super::cache::{Cache, Ticket}; +use super::crypto::{Aead, Ctr, Suite, failure}; +use super::header_xor::HeaderXor; +use crate::dispatch::BoxStream; +use crate::security::vision::{SpliceControl, SpliceStats}; +use std::sync::Arc; +use std::{ + io, + pin::Pin, + task::{Context, Poll, ready}, +}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use zeroize::{Zeroize, Zeroizing}; + +pub(super) struct Records { + raw: Option, + key: Zeroizing<[u8; 96]>, + suite: Suite, + tx: Aead, + rx: Option, + tx_ctr: Option, + rx_ctr: Option, + pending: Vec, + sent: usize, + input: Vec, + filled: usize, + reading: Reading, + plain: Zeroizing>, + consumed: usize, + write_closed: bool, + cache: Arc, + ticket: Option>, + random_headers: bool, + vision: Option<(SpliceControl, Arc)>, + direct_write: bool, + pending_direct: bool, + direct_tx: HeaderXor, + direct_rx: HeaderXor, +} + +enum Reading { + Random, + Padding, + Header, + Body([u8; 5]), + Eof, +} + +impl Records { + #[allow(clippy::too_many_arguments)] + pub(super) fn new( + raw: BoxStream, + key: Zeroizing<[u8; 96]>, + suite: Suite, + tx: Aead, + rx: Aead, + tx_ctr: Option, + rx_ctr: Option, + padding: usize, + cache: Arc, + ticket: Option>, + ) -> Self { + Self { + raw: Some(raw), + key, + suite, + tx, + rx: Some(rx), + tx_ctr, + rx_ctr, + pending: Vec::new(), + sent: 0, + input: vec![0; padding], + filled: 0, + reading: Reading::Padding, + plain: Zeroizing::new(Vec::new()), + consumed: 0, + write_closed: false, + cache, + ticket, + random_headers: false, + vision: None, + direct_write: false, + pending_direct: false, + direct_tx: HeaderXor::default(), + direct_rx: HeaderXor::default(), + } + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn resumed( + raw: BoxStream, + key: Zeroizing<[u8; 96]>, + suite: Suite, + tx: Aead, + tx_ctr: Option, + random_headers: bool, + prefix: Vec, + cache: Arc, + ticket: Arc, + ) -> Self { + Self { + raw: Some(raw), + key, + suite, + tx, + rx: None, + tx_ctr, + rx_ctr: None, + pending: prefix, + sent: 0, + input: vec![0; 16], + filled: 0, + reading: Reading::Random, + plain: Zeroizing::new(Vec::new()), + consumed: 0, + write_closed: false, + cache, + ticket: Some(ticket), + random_headers, + vision: None, + direct_write: false, + pending_direct: false, + direct_tx: HeaderXor::default(), + direct_rx: HeaderXor::default(), + } + } + + pub(super) fn with_vision(mut self, vision: Option<(SpliceControl, Arc)>) -> Self { + self.vision = vision; + self + } + + fn open(&self) -> io::Result<()> { + if self.cache.cancellation.is_cancelled() { + return Err(super::cache::closed()); + } + if self.raw.is_none() { + Err(io::Error::new( + io::ErrorKind::ConnectionAborted, + "VLESS Encryption stream closed", + )) + } else { + Ok(()) + } + } + + fn fail(&mut self, error: io::Error) -> io::Error { + if let Some(ticket) = self.ticket.take() { + self.cache.invalidate(&ticket); + } + self.raw.take(); + self.key.zeroize(); + self.plain.zeroize(); + self.pending.clear(); + self.input.clear(); + error + } + + fn drain(&mut self, cx: &mut Context<'_>) -> Poll> { + if let Err(error) = self.open() { + return Poll::Ready(Err(self.fail(error))); + } + while self.sent < self.pending.len() { + let result = ready!( + Pin::new(self.raw.as_mut().unwrap()).poll_write(cx, &self.pending[self.sent..]) + ); + let count = match result { + Ok(0) => { + return Poll::Ready(Err(self.fail(io::Error::new( + io::ErrorKind::WriteZero, + "VLESS Encryption transport closed", + )))); + } + Ok(count) => count, + Err(error) => return Poll::Ready(Err(self.fail(error))), + }; + self.sent += count; + if self.pending_direct { + self.vision.as_ref().unwrap().1.record_written(count); + } + } + self.pending.clear(); + self.sent = 0; + Poll::Ready(Ok(())) + } + + fn receive(&mut self, cx: &mut Context<'_>, output: &mut ReadBuf<'_>) -> Poll> { + self.open()?; + if output.remaining() == 0 { + return Poll::Ready(Ok(())); + } + ready!(self.drain(cx))?; + loop { + if self.consumed < self.plain.len() { + let count = output.remaining().min(self.plain.len() - self.consumed); + output.put_slice(&self.plain[self.consumed..self.consumed + count]); + self.consumed += count; + return Poll::Ready(Ok(())); + } + self.plain.zeroize(); + self.plain.clear(); + self.consumed = 0; + if let Some((control, stats)) = &self.vision + && control.reading_direct() + { + if !matches!(self.reading, Reading::Header) || self.filled != 0 { + return Poll::Ready(Err(failure())); + } + let before = output.filled().len(); + ready!(Pin::new(self.raw.as_mut().unwrap()).poll_read(cx, output))?; + if let Some(ctr) = &mut self.rx_ctr { + self.direct_rx + .apply(ctr, &mut output.filled_mut()[before..], true)?; + } + stats.record_read(output.filled().len() - before); + return Poll::Ready(Ok(())); + } + if matches!(self.reading, Reading::Eof) { + return Poll::Ready(Ok(())); + } + while self.filled < self.input.len() { + let mut buf = ReadBuf::new(&mut self.input[self.filled..]); + ready!(Pin::new(self.raw.as_mut().unwrap()).poll_read(cx, &mut buf))?; + let count = buf.filled().len(); + if count == 0 { + if self.filled == 0 && matches!(self.reading, Reading::Header) { + self.reading = Reading::Eof; + return Poll::Ready(Ok(())); + } + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "truncated VLESS Encryption record", + ))); + } + self.filled += count; + } + match self.reading { + Reading::Random => { + self.rx = Some(Aead::new(&self.input, self.key.as_ref(), self.suite)?); + if self.random_headers { + self.rx_ctr = Some(Ctr::new( + self.key.as_ref(), + self.input.as_slice().try_into().map_err(|_| failure())?, + )?); + } + self.reading = Reading::Header; + self.input.resize(5, 0); + } + Reading::Padding => { + let _padding = + Zeroizing::new(self.rx.as_mut().unwrap().open(&self.input, &[], None)?); + self.reading = Reading::Header; + self.input.resize(5, 0); + } + Reading::Header => { + if let Some(ctr) = self.rx_ctr.as_mut() { + ctr.apply(&mut self.input)?; + } + let header: [u8; 5] = + self.input.as_slice().try_into().map_err(|_| failure())?; + let length = usize::from(u16::from_be_bytes([header[3], header[4]])); + if header[..3] != [23, 3, 3] || !(17..=17000).contains(&length) { + return Poll::Ready(Err(failure())); + } + self.reading = Reading::Body(header); + self.input.resize(length, 0); + } + Reading::Body(header) => { + let rx = self.rx.as_mut().unwrap(); + let rekey = rx.exhausted(); + self.plain = Zeroizing::new(rx.open(&self.input, &header, None)?); + if rekey { + let mut context = header.to_vec(); + context.extend_from_slice(&self.input); + self.rx = Some(Aead::new(&context, self.key.as_ref(), self.suite)?); + } + self.ticket.take(); + self.reading = Reading::Header; + self.input.resize(5, 0); + } + Reading::Eof => unreachable!(), + } + self.filled = 0; + } + } +} + +impl AsyncRead for Records { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + output: &mut ReadBuf<'_>, + ) -> Poll> { + match self.receive(cx, output) { + Poll::Ready(Err(error)) => Poll::Ready(Err(self.fail(error))), + result => result, + } + } +} + +impl AsyncWrite for Records { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + input: &[u8], + ) -> Poll> { + ready!(self.drain(cx))?; + if self.write_closed { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "VLESS Encryption write side closed", + ))); + } + if input.is_empty() { + return Poll::Ready(Ok(0)); + } + let count = input.len().min(8192); + if self + .vision + .as_ref() + .is_some_and(|(control, _)| control.writing_direct()) + { + if !self.direct_write { + let result = ready!(Pin::new(self.raw.as_mut().unwrap()).poll_flush(cx)); + if let Err(error) = result { + return Poll::Ready(Err(self.fail(error))); + } + self.direct_write = true; + } + self.pending.extend_from_slice(&input[..count]); + let this = &mut *self; + if let Some(ctr) = &mut this.tx_ctr + && let Err(error) = this.direct_tx.apply(ctr, &mut this.pending, false) + { + return Poll::Ready(Err(this.fail(error))); + } + self.pending_direct = true; + return Poll::Ready(Ok(count)); + } + let length = (count + 16) as u16; + let mut header = [23, 3, 3, (length >> 8) as u8, length as u8]; + let rekey = self.tx.exhausted(); + let result = (|| { + let sealed = self.tx.seal(&input[..count], &header, None)?; + if rekey { + let mut context = header.to_vec(); + context.extend_from_slice(&sealed); + self.tx = Aead::new(&context, self.key.as_ref(), self.suite)?; + } + if let Some(ctr) = self.tx_ctr.as_mut() { + ctr.apply(&mut header)?; + } + self.pending.extend_from_slice(&header); + self.pending.extend_from_slice(&sealed); + Ok(count) + })(); + Poll::Ready(result.map_err(|error| self.fail(error))) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + ready!(self.drain(cx))?; + match Pin::new(self.raw.as_mut().unwrap()).poll_flush(cx) { + Poll::Ready(Err(error)) => Poll::Ready(Err(self.fail(error))), + result => result, + } + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.write_closed { + return Poll::Ready(Ok(())); + } + ready!(self.drain(cx))?; + if self.tx_ctr.is_none() + && self + .vision + .as_ref() + .is_some_and(|(control, _)| control.writing_direct() || control.reading_direct()) + { + // Vision exposes the underlying transport after direct switching. + // Native/xorpub therefore inherit its CloseWrite (TCP FIN or TLS + // close_notify). Random's XorConn is not replaceable in Mihomo and + // still has whole-connection close semantics. + return match Pin::new(self.raw.as_mut().unwrap()).poll_shutdown(cx) { + Poll::Ready(Ok(())) => { + self.write_closed = true; + Poll::Ready(Ok(())) + } + Poll::Ready(Err(error)) => Poll::Ready(Err(self.fail(error))), + result => result, + }; + } + // Mihomo CommonConn does not expose CloseWrite/replaceable upstream. + // Flush pending records then release this whole logical transport. + // A normal close must not invalidate a reusable node-local ticket. + match Pin::new(self.raw.as_mut().unwrap()).poll_flush(cx) { + Poll::Ready(Ok(())) => { + self.write_closed = true; + self.raw.take(); + self.key.zeroize(); + self.plain.zeroize(); + Poll::Ready(Ok(())) + } + Poll::Ready(Err(error)) => Poll::Ready(Err(self.fail(error))), + result => result, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, + }; + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + struct Fragmented { + input: Vec, + read: usize, + output: Arc>>, + pending: bool, + dropped: Arc, + } + impl Drop for Fragmented { + fn drop(&mut self) { + self.dropped.store(true, Ordering::SeqCst); + } + } + impl Fragmented { + fn yield_once(&mut self, cx: &mut Context<'_>) -> bool { + self.pending = !self.pending; + if self.pending { + cx.waker().wake_by_ref(); + } + self.pending + } + } + impl AsyncRead for Fragmented { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + b: &mut ReadBuf<'_>, + ) -> Poll> { + if self.yield_once(cx) { + return Poll::Pending; + } + if self.read < self.input.len() && b.remaining() != 0 { + b.put_slice(&self.input[self.read..self.read + 1]); + self.read += 1; + } + Poll::Ready(Ok(())) + } + } + impl AsyncWrite for Fragmented { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + b: &[u8], + ) -> Poll> { + if self.yield_once(cx) { + return Poll::Pending; + } + let count = b.len().min(1); + self.output.lock().unwrap().extend_from_slice(&b[..count]); + Poll::Ready(Ok(count)) + } + fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + fn poll_shutdown(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + fn bytes(v: &serde_json::Value) -> Vec { + v.as_str() + .unwrap() + .as_bytes() + .as_chunks::<2>() + .0 + .iter() + .map(|b| u8::from_str_radix(std::str::from_utf8(b).unwrap(), 16).unwrap()) + .collect() + } + fn vectors() -> serde_json::Value { + serde_json::from_str(include_str!( + "../../../../tests/protocols/encryption-crypto.json" + )) + .unwrap() + } + fn wire(v: &serde_json::Value) -> Vec { + [bytes(&v["aad"]), bytes(&v["ciphertext"])].concat() + } + fn fixture( + v: &serde_json::Value, + input: Vec, + output: Arc>>, + dropped: Arc, + ) -> Records { + let key: [u8; 96] = bytes(&v["key"]).try_into().unwrap(); + let suite = if v["cipher"] == "aes256gcm" { + Suite::Aes + } else { + Suite::ChaCha + }; + Records::resumed( + Box::new(Fragmented { + input, + read: 0, + output, + pending: false, + dropped, + }), + Zeroizing::new(key), + suite, + Aead::new(&bytes(&v["context"]), &key, suite).unwrap(), + None, + false, + Vec::new(), + Arc::new(Cache::default()), + Arc::new(Ticket { + pfs: Zeroizing::new([0; 64]), + bytes: Zeroizing::new([0; 16]), + expires: tokio::time::Instant::now() + std::time::Duration::from_secs(60), + }), + ) + } + + #[tokio::test] + async fn nonce_wrap_rekeys_both_directions_using_independent_go_records() { + for v in vectors()["wrap_records"].as_array().unwrap() { + let output = Arc::new(Mutex::new(Vec::new())); + let mut stream = fixture(v, bytes(&v["wire"]), output.clone(), Arc::default()); + let suite = if v["cipher"] == "aes256gcm" { + Suite::Aes + } else { + Suite::ChaCha + }; + let mut rx = Aead::new(&bytes(&v["context"]), &bytes(&v["key"]), suite).unwrap(); + let mut nonce = [255; 12]; + nonce[11] = 254; + rx.set_test_nonce(nonce); + stream.tx.set_test_nonce(nonce); + stream.rx = Some(rx); + stream.reading = Reading::Header; + stream.input = vec![0; 5]; + let plain = bytes(&v["plaintext"]); + for record in plain.chunks(37) { + stream.write_all(record).await.unwrap(); + } + stream.flush().await.unwrap(); + assert_eq!(*output.lock().unwrap(), bytes(&v["wire"])); + let mut received = Vec::new(); + stream.read_to_end(&mut received).await.unwrap(); + assert_eq!(received, plain); + } + } + + #[tokio::test] + async fn vision_preserves_authenticated_buffer_then_switches_without_prefetching() { + for v in vectors()["records"].as_array().unwrap().iter().step_by(3) { + let payload = bytes(&v["plaintext"]); + let tail = b"independent-direct-tail"; + let input = [bytes(&v["context"]), wire(v), tail.to_vec()].concat(); + let output = Arc::new(Mutex::new(Vec::new())); + let stats = Arc::new(SpliceStats::default()); + let control = SpliceControl::default(); + let mut stream = fixture(v, input, output.clone(), Arc::default()) + .with_vision(Some((control.clone(), stats.clone()))); + let mut first = [0; 7]; + stream.read_exact(&mut first).await.unwrap(); + assert_eq!(first, payload[..7]); + control.read_direct(); + let mut received = Vec::new(); + stream.read_to_end(&mut received).await.unwrap(); + assert_eq!(received, [payload[7..].to_vec(), tail.to_vec()].concat()); + stream.write_all(&payload).await.unwrap(); + control.write_direct(); + stream.write_all(tail).await.unwrap(); + stream.flush().await.unwrap(); + assert_eq!(*output.lock().unwrap(), [wire(v), tail.to_vec()].concat()); + assert_eq!(stats.bytes(), (tail.len() as u64, tail.len() as u64)); + } + } + + #[test] + fn random_direct_headers_match_go_with_every_fragment_boundary() { + let v = vectors(); + let key = bytes(&v["ctr_key"]); + let iv: [u8; 16] = bytes(&v["ctr_context"]).try_into().unwrap(); + for decrypt in [false, true] { + let (input, expected) = if decrypt { + ( + bytes(&v["headers_ciphertext"]), + bytes(&v["headers_plaintext"]), + ) + } else { + ( + bytes(&v["headers_plaintext"]), + bytes(&v["headers_ciphertext"]), + ) + }; + for cut in 0..=input.len() { + let mut actual = input.clone(); + let mut state = HeaderXor::default(); + let mut ctr = Ctr::new(&key, &iv).unwrap(); + state.apply(&mut ctr, &mut actual[..cut], decrypt).unwrap(); + for byte in &mut actual[cut..] { + state + .apply(&mut ctr, std::slice::from_mut(byte), decrypt) + .unwrap(); + } + assert_eq!(actual, expected); + } + } + } + + #[tokio::test] + async fn close_preserves_read_direction_only_for_replaceable_vision_transport() { + let vectors = vectors(); + let v = &vectors["records"][0]; + for direct in [0, 1, 2] { + for random in [false, true] { + let dropped = Arc::new(AtomicBool::new(false)); + let control = SpliceControl::default(); + let mut stream = fixture(v, b"tail".to_vec(), Arc::default(), dropped.clone()) + .with_vision(Some((control.clone(), Arc::default()))); + stream.reading = Reading::Header; + stream.input = vec![0; 5]; + if random { + stream.tx_ctr = Some(Ctr::new(&[1; 96], &[2; 16]).unwrap()); + } + if direct == 1 { + control.write_direct(); + } else if direct == 2 { + control.read_direct(); + } + stream.shutdown().await.unwrap(); + stream.shutdown().await.unwrap(); + let retain = direct != 0 && !random; + assert_eq!(!dropped.load(Ordering::SeqCst), retain); + if retain { + control.read_direct(); + let mut tail = Vec::new(); + stream.read_to_end(&mut tail).await.unwrap(); + assert_eq!(tail, b"tail"); + } + assert!(stream.write_all(b"forbidden").await.is_err()); + drop(stream); + assert!(dropped.load(Ordering::SeqCst)); + } + } + } + + #[tokio::test] + async fn fragmented_io_and_cancelled_reads_preserve_independent_go_records() { + for records in vectors()["records"].as_array().unwrap().as_chunks::<3>().0 { + let v = &records[0]; + let expected = [wire(v), wire(&records[1])].concat(); + let input = [bytes(&v["context"]), expected.clone()].concat(); + let output = Arc::new(Mutex::new(Vec::new())); + let mut stream = fixture(v, input, output.clone(), Arc::default()); + let payload = bytes(&v["plaintext"]); + for _ in 0..2 { + stream.write_all(&payload).await.unwrap(); + } + stream.flush().await.unwrap(); + assert_eq!( + *output.lock().unwrap(), + expected, + "partial writes must not reseal or reuse a nonce" + ); + let mut buffer = [0; 1]; + let mut cx = Context::from_waker(std::task::Waker::noop()); + for _ in 0..23 { + let mut read = ReadBuf::new(&mut buffer); + assert!( + Pin::new(&mut stream) + .poll_read(&mut cx, &mut read) + .is_pending() + ); + assert!( + read.filled().is_empty(), + "unauthenticated fragments must stay private" + ); + } + let mut actual = Vec::new(); + stream.read_to_end(&mut actual).await.unwrap(); + assert_eq!(actual, payload.repeat(2)); + } + } + + #[tokio::test] + async fn corrupt_truncated_or_replayed_records_fail_closed_without_plaintext() { + for records in vectors()["records"].as_array().unwrap().as_chunks::<3>().0 { + let v = &records[0]; + let good = wire(v); + let variants = [ + { + let mut data = good.clone(); + data[0] = 22; + data + }, + { + let mut data = good.clone(); + data[3..5].copy_from_slice(&17001u16.to_be_bytes()); + data + }, + { + let mut data = good.clone(); + *data.last_mut().unwrap() ^= 1; + data + }, + good[..4].to_vec(), + good[..good.len() - 1].to_vec(), + wire(&records[1]), + ]; + for bad in variants { + let dropped = Arc::new(AtomicBool::new(false)); + let mut stream = fixture( + v, + [bytes(&v["context"]), bad].concat(), + Arc::default(), + dropped.clone(), + ); + let mut actual = Vec::new(); + assert!(stream.read_to_end(&mut actual).await.is_err()); + assert!(actual.is_empty()); + assert!(dropped.load(Ordering::SeqCst)); + assert!(stream.write_all(b"must not escape").await.is_err()); + } + let mut stream = fixture( + v, + [bytes(&v["context"]), good.clone(), good].concat(), + Arc::default(), + Arc::default(), + ); + let mut actual = Vec::new(); + assert!(stream.read_to_end(&mut actual).await.is_err()); + assert_eq!( + actual, + bytes(&v["plaintext"]), + "only the first authenticated record is delivered" + ); + } + } + + struct DropProbe(Arc); + impl Drop for DropProbe { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + impl AsyncRead for DropProbe { + fn poll_read( + self: Pin<&mut Self>, + _: &mut Context<'_>, + _: &mut ReadBuf<'_>, + ) -> Poll> { + panic!("stopped node must not read IO"); + } + } + impl AsyncWrite for DropProbe { + fn poll_write( + self: Pin<&mut Self>, + _: &mut Context<'_>, + _: &[u8], + ) -> Poll> { + panic!("stopped node must not write IO"); + } + fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + panic!("stopped node must not flush IO"); + } + fn poll_shutdown(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + panic!("stopped node must not shutdown IO"); + } + } + + #[tokio::test] + async fn stopped_node_fails_writes_and_releases_owned_io_immediately() { + let dropped = Arc::new(AtomicBool::new(false)); + let cache = Arc::new(Cache::default()); + let key = Zeroizing::new([0; 96]); + let mut stream = Records::new( + Box::new(DropProbe(dropped.clone())), + key, + Suite::Aes, + Aead::new(&[0; 16], &[0; 96], Suite::Aes).unwrap(), + Aead::new(&[0; 16], &[0; 96], Suite::Aes).unwrap(), + None, + None, + 17, + cache.clone(), + None, + ); + cache.close(); + assert_eq!( + stream.write(b"business").await.unwrap_err().kind(), + io::ErrorKind::ConnectionAborted + ); + assert!( + dropped.load(Ordering::SeqCst), + "Stop error must release the underlying IO before returning" + ); + } +} diff --git a/src/outbound/vless/fingerprint_leg_tests.rs b/src/outbound/vless/fingerprint_leg_tests.rs new file mode 100644 index 0000000..487dfed --- /dev/null +++ b/src/outbound/vless/fingerprint_leg_tests.rs @@ -0,0 +1,220 @@ +//! Real TLS over memory IO through both production XHTTP node constructors. +use super::*; +use crate::config::{Config, ProxyProtocol}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use rcgen::{ + BasicConstraints, CertificateParams, CertifiedIssuer, ExtendedKeyUsagePurpose, IsCa, KeyPair, +}; +use rustls::{RootCertStore, ServerConfig, pki_types::PrivateKeyDer}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + sync::atomic::{AtomicUsize, Ordering}, + time::Duration, +}; +use tokio::io::AsyncReadExt; + +struct Identity { + root: rustls::pki_types::CertificateDer<'static>, + certificate: String, + key: String, +} + +fn pem(label: &str, bytes: &[u8]) -> String { + format!( + "-----BEGIN {label}-----\n{}\n-----END {label}-----\n", + STANDARD.encode(bytes) + ) +} + +fn identity() -> Identity { + let mut ca = CertificateParams::default(); + ca.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + let root = CertifiedIssuer::self_signed(ca, KeyPair::generate().unwrap()).unwrap(); + let mut leaf = CertificateParams::new(vec!["client.fixture.invalid".into()]).unwrap(); + leaf.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth]; + let key = KeyPair::generate().unwrap(); + let certificate = leaf.signed_by(&key, &root).unwrap(); + Identity { + root: root.der().clone(), + certificate: pem("CERTIFICATE", certificate.der()), + key: pem("PRIVATE KEY", &key.serialize_der()), + } +} + +fn server(certificate: &rcgen::CertifiedKey, identity: &Identity) -> Arc { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let mut roots = RootCertStore::empty(); + roots.add(identity.root.clone()).unwrap(); + let verifier = rustls::server::WebPkiClientVerifier::builder_with_provider( + Arc::new(roots), + provider.clone(), + ) + .build() + .unwrap(); + let mut server = ServerConfig::builder_with_provider(provider) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .with_client_cert_verifier(verifier) + .with_single_cert( + vec![certificate.cert.der().clone()], + PrivateKeyDer::Pkcs8(certificate.signing_key.serialize_der().into()), + ) + .unwrap(); + server.alpn_protocols = vec![b"h2".to_vec()]; + server.send_tls13_tickets = 2; + Arc::new(server) +} + +fn outbound( + profile: &str, + pin: &str, + upload: &Identity, + download: Value, + runtime: bool, +) -> VlessOutbound { + let config = Config::parse_yaml(json!({ + "port": 1080, + "proxies": [{ + "name": "p", "type": "vless", "server": "192.0.2.1", "port": 443, + "uuid": "07070707-0707-0707-0707-070707070707", "tls": true, + "servername": "fixture.invalid", "fingerprint": pin, + "client-fingerprint": profile, "alpn": ["h2"], "network": "xhttp", + "certificate": upload.certificate, "private-key": upload.key, + "xhttp-opts": {"mode": "stream-up", "path": "/fixture", "download-settings": download} + }], "rules": ["MATCH,p"] + }).to_string().as_bytes()).unwrap(); + let ProxyProtocol::Vless(config) = &config.proxies[0].protocol else { + unreachable!() + }; + // These paths are never dialed. Both real adapters receive memory streams. + let path = || { + UpstreamPath::direct( + ResolvedEndpoint { + logical_host: "192.0.2.1".into(), + port: 443, + addresses: vec!["192.0.2.1:443".parse().unwrap()], + }, + Dialer::default(), + ) + }; + if runtime { + VlessOutbound::new_with_shared_security( + config, + path(), + Some(path()), + &SecurityContext::new(), + 2, + VlessResourceLimits::new(65536, 65536, 65536), + ) + } else { + VlessOutbound::new_with_paths(config, path(), Some(path())) + } + .unwrap() +} + +async fn exchange( + client: &SecurityClient, + peer: Arc, +) -> (io::Result<()>, io::Result, usize) { + let (io, peer_io) = tokio::io::duplex(65536); + let received = AtomicUsize::new(0); + let (client, server) = tokio::time::timeout(Duration::from_secs(3), async { + tokio::join!( + async { + let mut stream = client.connect(Box::new(io)).await?; + stream.write_all(b"ping").await?; + let mut response = Vec::new(); + stream.read_to_end(&mut response).await?; + if response != b"pong" { + return Err(io::Error::other("memory peer response mismatch")); + } + Ok(()) + }, + async { + let mut stream = tokio_rustls::TlsAcceptor::from(peer) + .accept(peer_io) + .await?; + let resumed = + stream.get_ref().1.handshake_kind() == Some(rustls::HandshakeKind::Resumed); + let mut request = [0; 4]; + stream.read_exact(&mut request).await?; + received.store(request.len(), Ordering::Relaxed); + assert_eq!(&request, b"ping"); + stream.write_all(b"pong").await?; + stream.shutdown().await?; + Ok::<_, io::Error>(resumed) + } + ) + }) + .await + .unwrap(); + (client, server, received.load(Ordering::Relaxed)) +} + +async fn accepted(client: &SecurityClient, peer: Arc, resumed: bool) { + let (client, server, received) = exchange(client, peer).await; + client.unwrap(); + assert_eq!(server.unwrap(), resumed); + assert_eq!(received, 4); +} + +#[tokio::test] +async fn selected_xhttp_legs_isolate_tickets_and_client_identities() { + let certificate = rcgen::generate_simple_self_signed(vec!["fixture.invalid".into()]).unwrap(); + let pin = Sha256::digest(certificate.cert.der()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let upload_identity = identity(); + let download_identity = identity(); + for runtime in [false, true] { + for profile in ["chrome", "chrome120", "firefox", "safari"] { + for variant in ["inherit", "different", "clear"] { + let other = if profile.starts_with("chrome") { + "firefox" + } else { + "chrome" + }; + let override_identity = variant != "inherit"; + let download = if override_identity { + json!({ + "client-fingerprint": if variant == "clear" { "none" } else { other }, + "certificate": download_identity.certificate, "private-key": download_identity.key, + }) + } else { + json!({}) + }; + let outbound = outbound(profile, &pin, &upload_identity, download, runtime); + let upload = &outbound.upload.security; + let download = &outbound.download.as_ref().unwrap().transport.security; + let peer = server(&certificate, &upload_identity); + let download_peer = if override_identity { + server(&certificate, &download_identity) + } else { + peer.clone() + }; + accepted(upload, peer.clone(), false).await; + accepted(upload, peer.clone(), true).await; + // Even inherited policy, certificate, name and template may + // not reuse an upload ticket in a freshly constructed leg. + accepted(download, download_peer.clone(), false).await; + accepted(download, download_peer.clone(), true).await; + if override_identity { + for (client, wrong_peer) in + [(upload, download_peer.clone()), (download, peer.clone())] + { + let (client, server, bytes) = exchange(client, wrong_peer).await; + assert!(client.is_err() && server.is_err()); + assert_eq!(bytes, 0, "opposite leg identity admitted application bytes"); + } + // A failed cross-identity attempt cannot replace the + // immutable client identity; fresh handshakes still work. + assert!(exchange(upload, peer).await.0.is_ok()); + assert!(exchange(download, download_peer).await.0.is_ok()); + } + outbound.shutdown().await; + } + } + } +} diff --git a/src/outbound/vless/mod.rs b/src/outbound/vless/mod.rs new file mode 100644 index 0000000..88fb37f --- /dev/null +++ b/src/outbound/vless/mod.rs @@ -0,0 +1,11 @@ +//! VLESS framing and connector assembly over shared stream transports. +mod codec; +mod datagram; +#[doc(hidden)] +pub mod encryption; +mod outbound; +mod vision; +mod vision_filter; +pub use codec::{VlessCommand, VlessStream, encode_request_header, read_response_header}; +pub use outbound::VlessOutbound; +pub(crate) use outbound::VlessResourceLimits; diff --git a/src/outbound/vless/outbound.rs b/src/outbound/vless/outbound.rs new file mode 100644 index 0000000..7e604e8 --- /dev/null +++ b/src/outbound/vless/outbound.rs @@ -0,0 +1,1169 @@ +use std::{io, sync::Arc}; + +use async_trait::async_trait; +use tokio::io::AsyncWriteExt as _; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; + +use crate::{ + config::{ + StreamTransport, VlessOutboundConfig, VlessPacketEncoding, VlessTransport, + XHttpMode as ConfigXHttpMode, + }, + dialer::{Dialer, ResolvedEndpoint}, + dispatch::{BoxStream, DatagramTransport, DispatchError, Dispatcher}, + security::{SecurityClient, SecurityContext, TLS_RESUMPTION_SESSION_BUDGET}, + session::{DatagramSession, StreamSession}, + transport::xhttp::{XHttpClient, XHttpConfig, XHttpMode}, + xudp::XudpTransport, +}; + +const DEFAULT_VLESS_TLS_BUFFER_LIMIT: usize = 64 * 1024; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct VlessResourceLimits { + tls_buffer_limit: usize, + xhttp_send_buffer_size: usize, + xhttp_upload_chunk_size: usize, +} + +impl VlessResourceLimits { + pub(crate) const fn new( + tls_buffer_limit: usize, + xhttp_send_buffer_size: usize, + xhttp_upload_chunk_size: usize, + ) -> Self { + Self { + tls_buffer_limit, + xhttp_send_buffer_size, + xhttp_upload_chunk_size, + } + } +} + +use super::{VlessCommand, VlessStream}; +#[cfg(all(test, feature = "tls-fingerprint"))] +#[path = "fingerprint_leg_tests.rs"] +mod fingerprint_leg_tests; +#[cfg(all(test, feature = "outbound-socks5"))] +use crate::outbound::Socks5Outbound; +use crate::outbound::{ + ConnectedStream, DatagramRequest, EstablishContext, OutboundConnector, UpstreamPath, + server_destination, +}; + +#[derive(Clone)] +struct VlessTransportLeg { + server: crate::session::Destination, + upstream: UpstreamPath, + security: SecurityClient, +} + +impl std::fmt::Debug for VlessTransportLeg { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("VlessTransportLeg") + .field("server", &self.server) + .field("upstream", &self.upstream) + .field("security", &self.security) + .finish() + } +} + +#[derive(Clone)] +struct VlessDownloadLeg { + transport: VlessTransportLeg, + xhttp: XHttpClient, +} + +impl std::fmt::Debug for VlessDownloadLeg { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("VlessDownloadLeg") + .field("transport", &self.transport) + .field("xhttp", &self.xhttp) + .finish() + } +} + +pub struct VlessOutbound { + uuid: uuid::Uuid, + encryption: Option, + upload: VlessTransportLeg, + xhttp: Option, + download: Option, + encoding: VlessPacketEncoding, + transport: StreamTransport, + stream_options: crate::config::VlessStreamOptions, + cancellation: CancellationToken, + tasks: TaskTracker, + grpc_pool: crate::transport::GrpcPool, + sing_mux: Option, + vision: bool, + vision_stats: Arc, +} + +impl std::fmt::Debug for VlessOutbound { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("VlessOutbound") + .finish_non_exhaustive() + } +} + +impl VlessOutbound { + /// Builds an outbound from configuration and an endpoint resolved during + /// the host's prepare phase, before the VPN becomes active. + pub fn new( + config: &VlessOutboundConfig, + endpoint: ResolvedEndpoint, + dialer: Dialer, + ) -> io::Result { + Self::new_with_endpoints(config, endpoint, None, dialer) + } + + /// Builds a directly connected node from endpoints resolved during the + /// prepare phase. A distinct download server requires its own prepared + /// endpoint; an identical server reuses the primary resolution. + pub fn new_with_endpoints( + config: &VlessOutboundConfig, + endpoint: ResolvedEndpoint, + download_endpoint: Option, + dialer: Dialer, + ) -> io::Result { + let (upload_path, download_path) = + Self::direct_paths(config, endpoint, download_endpoint, dialer)?; + Self::new_with_paths(config, upload_path, download_path) + } + + /// Builds a VLESS node on top of another configured outbound connector. + /// Its server name remains a logical destination and is resolved by the + /// upstream proxy. + pub fn new_with_upstream( + config: &VlessOutboundConfig, + upstream: Arc, + ) -> io::Result { + let upload_path = UpstreamPath::proxy(upstream.clone()); + let download_path = config.download().map(|_| UpstreamPath::proxy(upstream)); + Self::new_with_paths(config, upload_path, download_path) + } + + pub fn new_with_path(config: &VlessOutboundConfig, upstream: UpstreamPath) -> io::Result { + let download_path = Self::download_path_from_primary(config, &upstream)?; + Self::new_with_paths(config, upstream, download_path) + } + + pub fn new_with_paths( + config: &VlessOutboundConfig, + upload_path: UpstreamPath, + download_path: Option, + ) -> io::Result { + let security_context = SecurityContext::new(); + let standard_tls_count = usize::from(matches!( + &config.security, + crate::config::SecurityConfig::Tls(_) + )) + config.download().map_or(0, |download| { + usize::from(matches!( + &download.security, + crate::config::SecurityConfig::Tls(_) + )) + }); + let resumption_sessions = + if standard_tls_count == 0 || standard_tls_count > TLS_RESUMPTION_SESSION_BUDGET { + 0 + } else { + TLS_RESUMPTION_SESSION_BUDGET / standard_tls_count + }; + let upload_security = SecurityClient::from_proxy_with_context( + config, + &security_context, + resumption_sessions, + DEFAULT_VLESS_TLS_BUFFER_LIMIT, + )?; + let download_security = config + .download() + .map(|download| { + SecurityClient::from_security_with_context( + &download.security, + &security_context, + resumption_sessions, + DEFAULT_VLESS_TLS_BUFFER_LIMIT, + ) + }) + .transpose()?; + Self::assemble( + config, + upload_path, + download_path, + upload_security, + download_security, + ) + } + + /// Runtime graph constructor using instance-shared TLS material and an + /// explicitly partitioned resumption-cache budget. + pub(crate) fn new_with_shared_security( + config: &VlessOutboundConfig, + upload_path: UpstreamPath, + download_path: Option, + security_context: &SecurityContext, + resumption_sessions: usize, + limits: VlessResourceLimits, + ) -> io::Result { + let upload_security = SecurityClient::from_security_with_context( + &config.security, + security_context, + resumption_sessions, + limits.tls_buffer_limit, + )?; + let download_security = config + .download() + .map(|download| { + SecurityClient::from_security_with_context( + &download.security, + security_context, + resumption_sessions, + limits.tls_buffer_limit, + ) + }) + .transpose()?; + Self::assemble_with_xhttp_limits( + config, + upload_path, + download_path, + upload_security, + download_security, + limits.xhttp_send_buffer_size, + limits.xhttp_upload_chunk_size, + ) + } + + /// Builds a standard-TLS outbound with local interoperability-test roots. + /// + /// This constructor is absent from normal and release builds. It must only + /// be enabled by the opt-in local Xray interoperability harness. + #[cfg(feature = "interop-test")] + #[doc(hidden)] + pub fn new_with_test_tls_roots( + config: &VlessOutboundConfig, + endpoint: ResolvedEndpoint, + dialer: Dialer, + roots_der: impl IntoIterator>, + ) -> io::Result { + Self::new_with_test_tls_roots_and_endpoints(config, endpoint, None, dialer, roots_der) + } + + /// Test-only roots with independently prepared upload/download endpoints. + #[cfg(feature = "interop-test")] + #[doc(hidden)] + pub fn new_with_test_tls_roots_and_endpoints( + config: &VlessOutboundConfig, + endpoint: ResolvedEndpoint, + download_endpoint: Option, + dialer: Dialer, + roots_der: impl IntoIterator>, + ) -> io::Result { + let (upload_path, download_path) = + Self::direct_paths(config, endpoint, download_endpoint, dialer)?; + let roots = roots_der.into_iter().collect::>(); + let upload_security = match &config.security { + crate::config::SecurityConfig::Tls(_) => { + SecurityClient::from_proxy_with_test_tls_roots(config, roots.clone())? + } + _ => SecurityClient::from_security(&config.security)?, + }; + let download_security = config + .download() + .map(|download| match &download.security { + crate::config::SecurityConfig::Tls(_) => { + SecurityClient::from_security_with_test_tls_roots( + &download.security, + roots.clone(), + ) + } + crate::config::SecurityConfig::Reality(_) + | crate::config::SecurityConfig::Jls(_) + | crate::config::SecurityConfig::None => { + SecurityClient::from_security(&download.security) + } + }) + .transpose()?; + Self::assemble( + config, + upload_path, + download_path, + upload_security, + download_security, + ) + } + + fn validate_endpoint( + address: &str, + port: u16, + endpoint: &ResolvedEndpoint, + leg: &str, + ) -> io::Result<()> { + if endpoint.logical_host != address || endpoint.port != port { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("resolved endpoint does not match the configured VLESS {leg} server"), + )); + } + if endpoint.addresses.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("resolved VLESS {leg} endpoint has no addresses"), + )); + } + Ok(()) + } + + fn direct_paths( + config: &VlessOutboundConfig, + endpoint: ResolvedEndpoint, + download_endpoint: Option, + dialer: Dialer, + ) -> io::Result<(UpstreamPath, Option)> { + Self::validate_endpoint(&config.address, config.port, &endpoint, "upload")?; + let download_path = match (config.download(), download_endpoint) { + (None, None) => None, + (None, Some(_)) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "a download endpoint was provided without VLESS download-settings", + )); + } + (Some(download), supplied) => { + let resolved = match supplied { + Some(resolved) => resolved, + None if download.address == config.address && download.port == config.port => { + endpoint.clone() + } + None => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "distinct VLESS download server requires a prepared download endpoint", + )); + } + }; + Self::validate_endpoint(&download.address, download.port, &resolved, "download")?; + Some(UpstreamPath::direct(resolved, dialer.clone())) + } + }; + Ok((UpstreamPath::direct(endpoint, dialer), download_path)) + } + + fn download_path_from_primary( + config: &VlessOutboundConfig, + primary: &UpstreamPath, + ) -> io::Result> { + let Some(download) = config.download() else { + return Ok(None); + }; + match primary { + UpstreamPath::Proxy(_) => Ok(Some(primary.clone())), + UpstreamPath::Direct { endpoint, .. } + | UpstreamPath::Group { + endpoint: Some(endpoint), + .. + } if endpoint.logical_host == download.address && endpoint.port == download.port => { + Ok(Some(primary.clone())) + } + UpstreamPath::Group { endpoint: None, .. } => Ok(Some(primary.clone())), + UpstreamPath::Direct { .. } | UpstreamPath::Group { .. } => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "distinct VLESS download server requires an explicit download upstream path", + )), + } + } + + fn assemble( + config: &VlessOutboundConfig, + upload_path: UpstreamPath, + download_path: Option, + upload_security: SecurityClient, + download_security: Option, + ) -> io::Result { + Self::assemble_with_xhttp( + config, + upload_path, + download_path, + upload_security, + download_security, + |config| Ok(XHttpClient::new(config)), + ) + } + + fn assemble_with_xhttp_limits( + config: &VlessOutboundConfig, + upload_path: UpstreamPath, + download_path: Option, + upload_security: SecurityClient, + download_security: Option, + send_buffer_size: usize, + upload_chunk_size: usize, + ) -> io::Result { + Self::assemble_with_xhttp( + config, + upload_path, + download_path, + upload_security, + download_security, + |config| XHttpClient::new_with_limits(config, send_buffer_size, upload_chunk_size), + ) + } + + fn assemble_with_xhttp( + config: &VlessOutboundConfig, + upload_path: UpstreamPath, + download_path: Option, + upload_security: SecurityClient, + download_security: Option, + mut build_xhttp: impl FnMut(XHttpConfig) -> io::Result, + ) -> io::Result { + let encryption = match &config.encryption { + crate::config::VlessEncryption::None => None, + crate::config::VlessEncryption::MlKem768X25519Plus(value) => { + Some(super::encryption::Client::parse(value)?) + } + }; + let mode = match config + .xhttp() + .map(|config| config.mode) + .unwrap_or(ConfigXHttpMode::PacketUp) + { + ConfigXHttpMode::PacketUp => XHttpMode::PacketUp, + ConfigXHttpMode::StreamOne => XHttpMode::StreamOne, + ConfigXHttpMode::StreamUp => XHttpMode::StreamUp, + }; + if mode == XHttpMode::StreamOne && config.download().is_some() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "XHTTP stream-one cannot use download settings", + )); + } + let xhttp = config + .xhttp() + .map(|config| { + let mut request = XHttpConfig::new(config.host.clone(), config.path.clone(), mode)?; + request.headers = config.headers.clone(); + request.request = config.request.clone(); + request.http_version = config.http_version; + request.reuse = config.reuse.clone(); + build_xhttp(request) + }) + .transpose()?; + let download = match (config.download(), download_path, download_security) { + (None, None, None) => None, + (Some(config), Some(upstream), Some(security)) => Some(VlessDownloadLeg { + transport: VlessTransportLeg { + server: server_destination(&config.address, config.port)?, + upstream, + security, + }, + xhttp: { + let mut request = + XHttpConfig::new(config.host.clone(), config.path.clone(), mode)?; + request.headers = config.headers.clone(); + request.request = config.request.clone(); + request.http_version = config.http_version; + request.reuse = config.reuse.clone(); + build_xhttp(request)? + }, + }), + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "VLESS download configuration, path, and security must be provided together", + )); + } + }; + Ok(Self { + uuid: config.id, + encryption, + upload: VlessTransportLeg { + server: server_destination(&config.address, config.port)?, + upstream: upload_path, + security: upload_security, + }, + xhttp, + download, + encoding: config.packet_encoding, + transport: match &config.transport { + VlessTransport::Stream(transport) => transport.clone(), + VlessTransport::Xhttp(_) => StreamTransport::Tcp, + }, + cancellation: CancellationToken::new(), + stream_options: config.stream_options.clone(), + tasks: TaskTracker::new(), + grpc_pool: crate::transport::GrpcPool::new(config.stream_options.grpc.clone()), + sing_mux: config + .stream_options + .sing_mux + .clone() + .map(crate::transport::sing_mux::Pool::new), + vision: config.flow == "xtls-rprx-vision", + vision_stats: Arc::default(), + }) + } + + fn connect_transport<'a>( + &'a self, + session: StreamSession, + context: &'a EstablishContext, + ) -> futures_util::future::BoxFuture<'a, Result> { + Box::pin(async move { + let Some(xhttp) = &self.xhttp else { + return Self::connect_transport_leg(&self.upload, session, context).await; + }; + let upload = || { + let session = session.clone(); + async move { + Self::connect_xhttp_leg(&self.upload, xhttp.http_version(), session, context) + .await + .map_err(io::Error::other) + } + }; + let Some(download) = &self.download else { + let connected = context + .run_io( + "VLESS XHTTP handshake", + xhttp.open_transport(context.deadline(), upload), + ) + .await?; + tracing::debug!(stage = "xhttp", "VLESS transport stage completed"); + return Ok(connected); + }; + + let download_stream = || { + let session = session.clone(); + async move { + Self::connect_xhttp_leg( + &download.transport, + download.xhttp.http_version(), + session, + context, + ) + .await + .map_err(io::Error::other) + } + }; + let connected = context + .run_io( + "VLESS XHTTP handshake", + xhttp.open_with_download_transports( + context.deadline(), + &download.xhttp, + upload, + download_stream, + ), + ) + .await?; + tracing::debug!(stage = "xhttp-split", "VLESS transport stage completed"); + Ok(connected) + }) + } + + async fn prepare( + &self, + session: StreamSession, + context: &EstablishContext, + ) -> Result { + if tokio::time::Instant::now() >= context.deadline() { + return Err(DispatchError::TimedOut); + } + tokio::select! {biased; + ()=self.cancellation.cancelled()=>Err(DispatchError::NotAllowed), + result=async { + let mut vision_control=None; + let (raw,transport)=if self.vision && self.encryption.is_none() { + let raw=self.upload.upstream.connect_server(session,&self.upload.server,context).await?.io; + let (raw,control)=context.run_io("Vision TLS handshake",self.upload.security.connect_vision(raw,self.vision_stats.clone())).await?; + vision_control=Some(control); + (raw,StreamTransport::Tcp) + } else if let StreamTransport::Grpc{uri}=&self.transport { + let io=self.grpc_pool.open(uri,context.deadline(),||async { + self.connect_transport(session,context).await.map_err(io::Error::other) + }).await?; + (io,StreamTransport::Tcp) + } else {(self.connect_transport(session,context).await?,self.transport.clone())}; + if self.vision && self.encryption.is_some() { + vision_control=Some(crate::security::vision::SpliceControl::default()); + } + let encryption=self.encryption.as_ref().map(|client| { + client.start().map(|flight| flight.with_vision( + vision_control.as_ref().map(|control| (control.clone(),self.vision_stats.clone())) + )) + }).transpose()?; + let token=self.cancellation.child_token(); + Ok(PreparedStream { raw:Box::new(crate::outbound::owned_stream::OwnedStream::new(raw,token.clone())), + id:self.uuid, encryption, transport, options:self.stream_options.clone(), vision_control, deadline:context.deadline(), tasks:self.tasks.clone(), token }) + }=>result, + } + } + + #[cfg(feature = "interop-test")] + #[doc(hidden)] + pub fn vision_raw_bytes(&self) -> (u64, u64) { + self.vision_stats.bytes() + } + + /// Read-only native frame counters, absent from production builds. + #[cfg(feature = "interop-test")] + #[doc(hidden)] + pub fn xhttp_quic_ping_counts(&self) -> (Vec, Vec) { + ( + self.xhttp + .as_ref() + .map_or_else(Vec::new, XHttpClient::quic_pings), + self.download + .as_ref() + .map_or_else(Vec::new, |leg| leg.xhttp.quic_pings()), + ) + } + + async fn connect_transport_leg( + leg: &VlessTransportLeg, + session: StreamSession, + context: &EstablishContext, + ) -> Result { + let raw = leg + .upstream + .connect_server(session, &leg.server, context) + .await?; + tracing::debug!(stage = "upstream", "VLESS transport stage completed"); + let secured = context + .run_io("VLESS TLS/REALITY handshake", leg.security.connect(raw.io)) + .await?; + tracing::debug!(stage = "security", "VLESS transport stage completed"); + Ok(secured) + } + + fn connect_xhttp_leg<'a>( + leg: &'a VlessTransportLeg, + version: crate::config::XHttpVersion, + session: StreamSession, + context: &'a EstablishContext, + ) -> futures_util::future::BoxFuture< + 'a, + Result, + > { + // Separate the per-leg handshake state from both parent futures. A + // split XHTTP setup otherwise duplicates the largest TLS/QUIC branch + // throughout try_join/acquire on the runtime's bounded thread stack. + Box::pin(async move { + use crate::{ + dispatch::DatagramBudget, + transport::xhttp::{QuicTransport, TransportIo}, + }; + if version != crate::config::XHttpVersion::Http3 { + return Self::connect_transport_leg(leg, session, context) + .await + .map(TransportIo::Stream); + } + let (tls, server_name) = leg.security.quic_config()?; + let server = leg.upstream.datagram_server(&leg.server, context)?; + let peer = context.resolve_ip(&server).await?; + let budget = DatagramBudget::new(1400, 1400); + let request = + DatagramRequest::new(DatagramSession::new(session.inbound, session.source)) + .with_budget(budget); + let transport = leg.upstream.open_datagram(request, context).await?; + Ok(TransportIo::Quic(Box::new(QuicTransport { + transport, + peer, + budget, + tls, + server_name, + }))) + }) + } +} +pub(super) struct PreparedStream { + raw: BoxStream, + encryption: Option, + id: uuid::Uuid, + transport: StreamTransport, + options: crate::config::VlessStreamOptions, + vision_control: Option, + pub(super) deadline: tokio::time::Instant, + tasks: TaskTracker, + pub(super) token: CancellationToken, +} +impl PreparedStream { + pub(super) async fn finish( + self, + command: VlessCommand, + target: Option<&crate::session::Destination>, + ) -> Result { + let deadline = self.deadline; + if tokio::time::Instant::now() >= deadline { + return Err(DispatchError::TimedOut); + } + let mut header = + super::codec::encode_header(self.id, command, target, self.vision_control.is_some())?; + let initial = self.encryption.as_ref().map_or_else( + || header.clone(), + |flight| bytes::Bytes::copy_from_slice(flight.prefix()), + ); + let token = self.token.clone(); + let guard = token.clone().drop_guard(); + let stream = tokio::time::timeout_at(deadline, async { + let mut raw = self.raw; + let mut driver = None; + let mut prefix_sent = false; + match &self.transport { + StreamTransport::Tcp => {} + StreamTransport::WebSocket { .. } => { + if self.options.http_upgrade { + raw = crate::transport::http_upgrade( + raw, + &self.transport.websocket_options()?.expect("validated WS"), + &initial, + self.options.fast_open, + deadline, + ) + .await?; + } else { + raw = crate::transport::connect_websocket( + raw, + &self.transport.websocket_options()?.expect("validated WS"), + &initial, + deadline, + ) + .await?; + } + prefix_sent = true; + if self.encryption.is_none() { + header = bytes::Bytes::new(); + } + } + StreamTransport::Http { .. } => { + raw = crate::transport::http_obfs( + raw, + &self.transport.http_options()?.expect("validated HTTP"), + &initial, + deadline, + ) + .await?; + prefix_sent = true; + if self.encryption.is_none() { + header = bytes::Bytes::new(); + } + } + StreamTransport::Grpc { uri } => { + let connected = crate::transport::grpc(raw, uri, deadline).await?; + raw = connected.0; + driver = Some(connected.1); + } + StreamTransport::H2 { uris } => { + let connected = crate::transport::legacy_h2( + raw, + &uris[rand::random_range(0..uris.len())], + deadline, + ) + .await?; + raw = connected.0; + driver = Some(connected.1); + } + } + if let Some(driver) = driver { + let token = token.clone(); + let observation = crate::resources::observation::track( + crate::resources::observation::ResourceKind::Task, + ); + self.tasks + .spawn(crate::resources::observation::bind(async move { + let _observation = observation; + token.cancelled().await; + let _ = driver.stop().await; + })); + } + if let Some(flight) = self.encryption { + raw = flight.finish(raw, prefix_sent, deadline).await?; + } + let stream = VlessStream::with_deadline(raw, header, deadline); + let stream: BoxStream = + Box::new(if matches!(self.transport, StreamTransport::Http { .. }) { + stream.with_whole_close() + } else { + stream + }); + let stream = if let Some(control) = self.vision_control { + Box::new(super::vision::VisionStream::new(stream, self.id, control)) as BoxStream + } else { + stream + }; + Ok::(Box::new(crate::outbound::owned_stream::OwnedStream::new( + stream, token, + ))) + }) + .await + .map_err(|_| DispatchError::TimedOut)??; + guard.disarm(); + Ok(stream) + } +} +#[async_trait] +impl OutboundConnector for VlessOutbound { + async fn connect_stream( + &self, + session: StreamSession, + context: &EstablishContext, + ) -> Result { + let effective_peer = session.destination.clone(); + if let Some(mux) = &self.sing_mux { + let io = mux + .open(&effective_peer, false, context.deadline(), || async { + let target = crate::session::Destination::domain("sp.mux.sing-box.arpa", 444)?; + let prepared = self + .prepare(session, context) + .await + .map_err(io::Error::other)?; + prepared + .finish(VlessCommand::Tcp, Some(&target)) + .await + .map_err(io::Error::other) + }) + .await?; + return Ok(ConnectedStream { io, effective_peer }); + } + let prepared = self.prepare(session, context).await?; + let io = prepared + .finish(VlessCommand::Tcp, Some(&effective_peer)) + .await?; + Ok(ConnectedStream { io, effective_peer }) + } + async fn open_datagram( + &self, + request: DatagramRequest, + context: &EstablishContext, + ) -> Result, DispatchError> { + if let Some(mux) = &self.sing_mux + && !mux.only_tcp() + { + let target = crate::session::Destination::domain("sp.mux.sing-box.arpa", 444)?; + let session = StreamSession { + inbound: request.session.inbound, + source: request.session.source, + destination: target.clone(), + sniffed_domain: None, + }; + let io = mux + .open(&target, true, context.deadline(), || async { + let prepared = self + .prepare(session, context) + .await + .map_err(io::Error::other)?; + prepared + .finish(VlessCommand::Tcp, Some(&target)) + .await + .map_err(io::Error::other) + }) + .await?; + return Ok(crate::dispatch::bound_datagram( + Box::new(crate::transport::sing_mux::datagram::DatagramIo::new( + io, + request.budget(), + context.resolution(), + context.deadline(), + self.cancellation.clone(), + )), + request.budget(), + )); + } + let prepared = self + .prepare( + StreamSession { + inbound: request.session.inbound, + source: request.session.source, + destination: self.upload.server.clone(), + sniffed_domain: None, + }, + context, + ) + .await?; + let transport: Box = if self.encoding == VlessPacketEncoding::Xudp { + let mut io = prepared.finish(VlessCommand::Mux, None).await?; + context.run_io("VLESS XUDP request", io.flush()).await?; + Box::new(XudpTransport::with_budget(io, [0; 8], request.budget())) + } else { + Box::new(super::datagram::VlessDatagram::new( + prepared, + self.encoding == VlessPacketEncoding::PacketAddr, + request.budget(), + context.resolution(), + )) + }; + Ok(crate::dispatch::bound_datagram(transport, request.budget())) + } + fn begin_shutdown(&self) { + self.cancellation.cancel(); + if let Some(encryption) = &self.encryption { + encryption.close(); + } + self.grpc_pool.begin_shutdown(); + if let Some(mux) = &self.sing_mux { + mux.begin_stop(); + } + if let Some(xhttp) = &self.xhttp { + xhttp.begin_stop(); + } + if let Some(download) = &self.download { + download.xhttp.begin_stop(); + } + } + async fn shutdown(&self) { + self.begin_shutdown(); + self.tasks.close(); + self.tasks.wait().await; + self.grpc_pool.shutdown().await; + if let Some(mux) = &self.sing_mux { + mux.stop().await; + } + if let Some(xhttp) = &self.xhttp { + xhttp.stop().await; + } + if let Some(download) = &self.download { + download.xhttp.stop().await; + } + } +} +impl Drop for VlessOutbound { + fn drop(&mut self) { + self.cancellation.cancel(); + if let Some(encryption) = &self.encryption { + encryption.close(); + } + } +} + +#[async_trait] +impl Dispatcher for VlessOutbound { + async fn connect_tcp(&self, session: StreamSession) -> Result { + OutboundConnector::connect_stream(self, session, &EstablishContext::default()) + .await + .map(|connected| connected.io) + } + + async fn open_datagram( + &self, + session: DatagramSession, + ) -> Result, DispatchError> { + OutboundConnector::open_datagram( + self, + DatagramRequest::new(session), + &EstablishContext::default(), + ) + .await + } +} + +#[cfg(all(test, feature = "outbound-socks5", feature = "outbound-vless"))] +mod connector_composition_tests { + use super::*; + use crate::{ + config::{ + SecurityConfig, Socks5OutboundConfig, TlsConfig, VlessEncryption, VlessOutboundConfig, + XHttpConfig as ConfigXHttpConfig, XHttpDownloadConfig, XHttpMode as ConfigXHttpMode, + }, + dispatch::{DatagramTransport, DispatchError}, + session::StreamSession, + }; + + struct NeverConnector; + + #[async_trait] + impl OutboundConnector for NeverConnector { + async fn connect_stream( + &self, + _session: StreamSession, + _context: &EstablishContext, + ) -> Result { + Err(DispatchError::NetworkUnreachable) + } + + async fn open_datagram( + &self, + _request: DatagramRequest, + _context: &EstablishContext, + ) -> Result, DispatchError> { + Err(DispatchError::NetworkUnreachable) + } + } + + fn vless_config(address: &str) -> VlessOutboundConfig { + VlessOutboundConfig { + address: address.to_owned(), + port: 443, + id: uuid::Uuid::parse_str("b831381d-6324-4d53-ad4f-8cda48b30811").unwrap(), + encryption: VlessEncryption::None, + flow: String::new(), + security: SecurityConfig::Tls(TlsConfig::xhttp(address.to_owned())), + transport: crate::config::VlessTransport::Xhttp(Box::new(ConfigXHttpConfig { + path: "/xhttp".to_owned(), + host: address.to_owned(), + mode: ConfigXHttpMode::PacketUp, + http_version: Default::default(), + reuse: None, + headers: Default::default(), + request: Default::default(), + download: None, + })), + packet_encoding: crate::config::VlessPacketEncoding::Xudp, + stream_options: Default::default(), + } + } + + fn socks_config(address: &str) -> Socks5OutboundConfig { + Socks5OutboundConfig { + address: address.to_owned(), + port: 1080, + username: None, + password: None, + } + } + + fn split_vless_config(upload: &str, download: &str, download_port: u16) -> VlessOutboundConfig { + let mut config = vless_config(upload); + let crate::config::VlessTransport::Xhttp(xhttp) = &mut config.transport else { + unreachable!() + }; + xhttp.download = Some(Box::new(XHttpDownloadConfig { + address: download.to_owned(), + port: download_port, + security: SecurityConfig::Tls(TlsConfig::xhttp(download.to_owned())), + http_version: Default::default(), + reuse: None, + path: "/download".to_owned(), + host: download.to_owned(), + headers: Default::default(), + request: Default::default(), + })); + config + } + + fn endpoint(address: &str, port: u16) -> ResolvedEndpoint { + ResolvedEndpoint { + logical_host: address.to_owned(), + port, + addresses: vec![std::net::SocketAddr::from(([127, 0, 0, 1], port))], + } + } + + #[test] + fn all_two_hop_protocol_combinations_build_as_connector_graphs() { + #[cfg(feature = "interop-test")] + let _case = crate::resources::case_events::Case::new( + "VLESS-REGRESSION", + "all_two_hop_protocol_combinations_build_as_connector_graphs", + ); + let physical: Arc = Arc::new(NeverConnector); + let vless_leaf: Arc = Arc::new( + VlessOutbound::new_with_upstream(&vless_config("vless-leaf.example"), physical.clone()) + .unwrap(), + ); + let socks_leaf: Arc = Arc::new( + Socks5Outbound::new_with_upstream(&socks_config("socks-leaf.example"), physical) + .unwrap(), + ); + + let combinations: [Arc; 4] = [ + Arc::new( + VlessOutbound::new_with_upstream( + &vless_config("vless-over-vless.example"), + vless_leaf.clone(), + ) + .unwrap(), + ), + Arc::new( + VlessOutbound::new_with_upstream( + &vless_config("vless-over-socks.example"), + socks_leaf.clone(), + ) + .unwrap(), + ), + Arc::new( + Socks5Outbound::new_with_upstream( + &socks_config("socks-over-vless.example"), + vless_leaf, + ) + .unwrap(), + ), + Arc::new( + Socks5Outbound::new_with_upstream( + &socks_config("socks-over-socks.example"), + socks_leaf, + ) + .unwrap(), + ), + ]; + assert_eq!(combinations.len(), 4); + } + + #[test] + fn direct_download_constructor_reuses_or_requires_the_precise_prepared_endpoint() { + #[cfg(feature = "interop-test")] + let _case = crate::resources::case_events::Case::new( + "VLESS-REGRESSION", + "direct_download_constructor_reuses_or_requires_the_precise_prepared_endpoint", + ); + let same = split_vless_config("same.example", "same.example", 443); + let outbound = + VlessOutbound::new(&same, endpoint("same.example", 443), Dialer::default()).unwrap(); + assert!(outbound.download.is_some()); + + let mut invalid_mode = same.clone(); + let crate::config::VlessTransport::Xhttp(xhttp) = &mut invalid_mode.transport else { + unreachable!() + }; + xhttp.mode = ConfigXHttpMode::StreamOne; + let error = VlessOutbound::new( + &invalid_mode, + endpoint("same.example", 443), + Dialer::default(), + ) + .unwrap_err(); + assert!(error.to_string().contains("stream-one")); + + let distinct = split_vless_config("upload.example", "download.example", 8443); + let error = VlessOutbound::new( + &distinct, + endpoint("upload.example", 443), + Dialer::default(), + ) + .unwrap_err(); + assert!(error.to_string().contains("prepared download endpoint")); + + let outbound = VlessOutbound::new_with_endpoints( + &distinct, + endpoint("upload.example", 443), + Some(endpoint("download.example", 8443)), + Dialer::default(), + ) + .unwrap(); + let download = outbound.download.as_ref().unwrap(); + assert_eq!(outbound.upload.server.port(), 443); + assert_eq!(download.transport.server.port(), 8443); + } + + #[test] + fn split_node_over_a_proxy_automatically_reuses_its_parent_for_both_legs() { + #[cfg(feature = "interop-test")] + let _case = crate::resources::case_events::Case::new( + "VLESS-REGRESSION", + "split_node_over_a_proxy_automatically_reuses_its_parent_for_both_legs", + ); + let parent: Arc = Arc::new(NeverConnector); + let config = split_vless_config("upload.example", "download.example", 8443); + let outbound = VlessOutbound::new_with_upstream(&config, parent).unwrap(); + let UpstreamPath::Proxy(upload_parent) = &outbound.upload.upstream else { + panic!("upload leg must use the configured parent") + }; + let UpstreamPath::Proxy(download_parent) = + &outbound.download.as_ref().unwrap().transport.upstream + else { + panic!("download leg must use the configured parent") + }; + assert!(Arc::ptr_eq(upload_parent, download_parent)); + } +} diff --git a/src/outbound/vless/vision.rs b/src/outbound/vless/vision.rs new file mode 100644 index 0000000..4426d22 --- /dev/null +++ b/src/outbound/vless/vision.rs @@ -0,0 +1,373 @@ +//! Vision framing over authenticated VLESS. Wire constants follow the public +//! XTLS/Mihomo protocol; state, bounds and async ownership are VCore-owned. +use super::vision_filter::Filter; +use crate::{dispatch::BoxStream, security::vision::SpliceControl}; +use bytes::{Buf, BufMut, Bytes, BytesMut}; +use std::{ + io, + pin::Pin, + task::{Context, Poll, ready}, +}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + +const CONTENT_LIMIT: usize = crate::limits::VISION_CONTENT_BYTES; +pub(super) struct VisionStream { + io: Option, + control: SpliceControl, + uuid: [u8; 16], + filter: Filter, + header: [u8; 21], + header_read: usize, + first_read: bool, + need_header: bool, + content_left: usize, + padding_left: usize, + command: u8, + read_done: bool, + first_write: bool, + write_done: bool, + queued: Bytes, + queued_command: u8, +} +impl VisionStream { + pub(super) fn new(io: BoxStream, id: uuid::Uuid, control: SpliceControl) -> Self { + Self { + io: Some(io), + control, + uuid: id.into_bytes(), + filter: Filter::default(), + header: [0; 21], + header_read: 0, + first_read: true, + need_header: true, + content_left: 0, + padding_left: 0, + command: 0, + read_done: false, + first_write: true, + write_done: false, + queued: Bytes::new(), + queued_command: 0, + } + } + fn open(&mut self) -> io::Result<&mut BoxStream> { + self.io + .as_mut() + .ok_or_else(|| io::ErrorKind::BrokenPipe.into()) + } + fn finish(&mut self, result: Poll>) -> Poll> { + if matches!(result, Poll::Ready(Err(_))) { + self.io.take(); + self.queued = Bytes::new(); + } + result + } + fn drain(&mut self, cx: &mut Context<'_>) -> Poll> { + for _ in 0..crate::limits::IO_POLL_BUDGET { + if self.queued.is_empty() { + if self.queued_command != 0 { + self.write_done = true; + if self.queued_command == 2 { + self.control.write_direct(); + } + self.queued_command = 0; + } + return Poll::Ready(Ok(())); + } + let io = self + .io + .as_mut() + .ok_or_else(|| io::Error::from(io::ErrorKind::BrokenPipe))?; + let count = ready!(Pin::new(io).poll_write(cx, &self.queued))?; + if count == 0 { + return Poll::Ready(Err(io::ErrorKind::WriteZero.into())); + } + self.queued.advance(count); + } + cx.waker().wake_by_ref(); + Poll::Pending + } + fn read(&mut self, cx: &mut Context<'_>, out: &mut ReadBuf<'_>) -> Poll> { + if !self.queued.is_empty() || self.queued_command != 0 { + ready!(self.drain(cx))?; + ready!(Pin::new(self.open()?).poll_flush(cx))?; + } + for _ in 0..crate::limits::IO_POLL_BUDGET { + if self.read_done { + return Pin::new(self.open()?).poll_read(cx, out); + } + if self.need_header { + let size = if self.first_read { 21 } else { 5 }; + if self.header_read < size { + let mut part = ReadBuf::new(&mut self.header[self.header_read..size]); + ready!( + Pin::new(self.io.as_mut().ok_or(io::ErrorKind::BrokenPipe)?) + .poll_read(cx, &mut part) + )?; + let count = part.filled().len(); + if count == 0 { + if self.header_read == 0 && !self.first_read { + return Poll::Ready(Ok(())); + } + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + self.header_read += count; + if self.header_read < size { + continue; + } + } + let start = if self.first_read { + if self.header[..16] != self.uuid { + return Poll::Ready(Err(invalid())); + } + self.first_read = false; + 16 + } else { + 0 + }; + let frame = &self.header[start..]; + self.command = frame[0]; + if self.command > 2 { + return Poll::Ready(Err(invalid())); + } + self.content_left = u16::from_be_bytes([frame[1], frame[2]]) as usize; + self.padding_left = u16::from_be_bytes([frame[3], frame[4]]) as usize; + self.need_header = false; + self.header_read = 0; + } + if self.content_left > 0 { + let count = out.remaining().min(self.content_left); + let mut part = ReadBuf::new(&mut out.initialize_unfilled()[..count]); + ready!( + Pin::new(self.io.as_mut().ok_or(io::ErrorKind::BrokenPipe)?) + .poll_read(cx, &mut part) + )?; + let count = part.filled().len(); + if count == 0 { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + self.filter.received(part.filled()); + self.content_left -= count; + out.advance(count); + return Poll::Ready(Ok(())); + } + if self.padding_left > 0 { + let mut discard = [0; 1024]; + let count = self.padding_left.min(discard.len()); + let mut part = ReadBuf::new(&mut discard[..count]); + ready!(Pin::new(self.open()?).poll_read(cx, &mut part))?; + let count = part.filled().len(); + if count == 0 { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + self.padding_left -= count; + if self.padding_left > 0 { + continue; + } + } + match self.command { + 0 => self.need_header = true, + 1 => self.read_done = true, + 2 => { + self.control.read_direct(); + self.read_done = true; + } + _ => unreachable!(), + } + } + cx.waker().wake_by_ref(); + Poll::Pending + } + fn write(&mut self, cx: &mut Context<'_>, input: &[u8]) -> Poll> { + self.open()?; + ready!(self.drain(cx))?; + if self.write_done { + return Pin::new(self.open()?).poll_write(cx, input); + } + if input.is_empty() { + return Poll::Ready(Ok(0)); + } + let count = input.len().min(CONTENT_LIMIT); + let command = self.filter.command(&input[..count]); + let padding = if count < 900 { + if self.filter.is_tls() { + 900 - count + rand::random_range(0..500) + } else { + rand::random_range(0..256) + } + } else { + 0 + }; + let mut frame = BytesMut::with_capacity(count + padding + 21); + if self.first_write { + frame.extend_from_slice(&self.uuid); + self.first_write = false; + } + frame.put_u8(command); + frame.put_u16(count as u16); + frame.put_u16(padding as u16); + frame.extend_from_slice(&input[..count]); + for _ in 0..padding { + frame.put_u8(rand::random()); + } + self.queued = frame.freeze(); + self.queued_command = command; + Poll::Ready(Ok(count)) + } +} +fn invalid() -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, "invalid Vision frame") +} +impl AsyncRead for VisionStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + out: &mut ReadBuf<'_>, + ) -> Poll> { + if out.remaining() == 0 { + return Poll::Ready(Ok(())); + } + let result = self.read(cx, out); + self.finish(result) + } +} +impl AsyncWrite for VisionStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let result = self.write(cx, buf); + self.finish(result) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let result = (|| { + ready!(self.drain(cx))?; + Pin::new(self.open()?).poll_flush(cx) + })(); + self.finish(result) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let result = (|| { + ready!(self.drain(cx))?; + ready!(Pin::new(self.open()?).poll_flush(cx))?; + Pin::new(self.open()?).poll_shutdown(cx) + })(); + self.finish(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + fn frame(first: bool, command: u8, content: &[u8], padding: usize) -> Vec { + let mut wire = if first { vec![7; 16] } else { Vec::new() }; + wire.push(command); + wire.extend((content.len() as u16).to_be_bytes()); + wire.extend((padding as u16).to_be_bytes()); + wire.extend(content); + wire.extend(vec![0; padding]); + wire + } + + #[tokio::test] + async fn vision_fragmented_headers_content_padding_and_cancelled_reads_keep_raw_tail() { + let _case = crate::resources::case_events::Case::new( + "VLESS-UNIT", + "vision_fragmented_headers_content_padding_and_cancelled_reads_keep_raw_tail", + ); + let mut wire = frame(true, 0, b"abc", 7); + wire.extend(frame(false, 2, b"def", 3)); + wire.extend(b"raw-tail"); + for cut in 0..wire.len() { + let (io, mut peer) = tokio::io::duplex(4096); + let mut stream = VisionStream::new( + Box::new(io), + uuid::Uuid::from_bytes([7; 16]), + SpliceControl::default(), + ); + peer.write_all(&wire[..cut]).await.unwrap(); + let mut received = Vec::new(); + assert!( + tokio::time::timeout(Duration::from_millis(1), stream.read_to_end(&mut received)) + .await + .is_err() + ); + peer.write_all(&wire[cut..]).await.unwrap(); + peer.shutdown().await.unwrap(); + stream.read_to_end(&mut received).await.unwrap(); + assert_eq!(received, b"abcdefraw-tail"); + } + } + + #[tokio::test] + async fn vision_invalid_uuid_command_and_truncation_poison_the_stream() { + let _case = crate::resources::case_events::Case::new( + "VLESS-UNIT", + "vision_invalid_uuid_command_and_truncation_poison_the_stream", + ); + let good = frame(true, 0, b"content", 4); + let mut invalid_uuid = good.clone(); + invalid_uuid[0] ^= 1; + let mut invalid_command = good.clone(); + invalid_command[16] = 3; + for wire in (0..good.len()) + .map(|n| good[..n].to_vec()) + .chain([invalid_uuid, invalid_command]) + { + let (io, mut peer) = tokio::io::duplex(4096); + let mut stream = VisionStream::new( + Box::new(io), + uuid::Uuid::from_bytes([7; 16]), + SpliceControl::default(), + ); + peer.write_all(&wire).await.unwrap(); + peer.shutdown().await.unwrap(); + assert!(stream.read_to_end(&mut Vec::new()).await.is_err()); + assert!(stream.write_all(b"not-replayed").await.is_err()); + assert_eq!(peer.read(&mut [0; 1]).await.unwrap(), 0); + } + } + + #[tokio::test] + async fn vision_bounded_partial_writes_flush_and_close_preserve_all_plaintext() { + let _case = crate::resources::case_events::Case::new( + "VLESS-UNIT", + "vision_bounded_partial_writes_flush_and_close_preserve_all_plaintext", + ); + let (io, mut peer) = tokio::io::duplex(31); + let mut stream = VisionStream::new( + Box::new(io), + uuid::Uuid::from_bytes([7; 16]), + SpliceControl::default(), + ); + let reader = tokio::spawn(async move { + let mut uuid = [0; 16]; + peer.read_exact(&mut uuid).await.unwrap(); + assert_eq!(uuid, [7; 16]); + let mut data = Vec::new(); + loop { + let command = peer.read_u8().await.unwrap(); + let content = peer.read_u16().await.unwrap() as usize; + let padding = peer.read_u16().await.unwrap() as usize; + assert!(content <= CONTENT_LIMIT && padding <= 1399); + let mut part = vec![0; content + padding]; + peer.read_exact(&mut part).await.unwrap(); + data.extend(&part[..content]); + if command == 1 { + break; + } + assert_eq!(command, 0); + } + peer.read_to_end(&mut data).await.unwrap(); + data + }); + let payload = vec![0x5a; 128 * 1024]; + stream.write_all(&payload).await.unwrap(); + stream.shutdown().await.unwrap(); + assert_eq!(reader.await.unwrap(), payload); + } +} diff --git a/src/outbound/vless/vision_filter.rs b/src/outbound/vless/vision_filter.rs new file mode 100644 index 0000000..8af79f0 --- /dev/null +++ b/src/outbound/vless/vision_filter.rs @@ -0,0 +1,187 @@ +//! Bounded recognition of inner TLS records. It is a traffic classifier, not +//! a TLS authenticator; the inner endpoint still verifies its own handshake. +#[derive(Default)] +struct Records { + header: [u8; 5], + used: usize, + remaining: usize, + invalid: bool, + hello: Vec, + hello_done: bool, +} +#[derive(Default)] +pub(super) struct Filter { + upload: Records, + download: Records, + tls: bool, + direct: Option, + frames: usize, +} +impl Filter { + pub(super) fn received(&mut self, data: &[u8]) { + self.download.feed(data, &mut self.tls, &mut self.direct); + } + pub(super) fn command(&mut self, data: &[u8]) -> u8 { + self.frames = self.frames.saturating_add(1); + let application = self.upload.feed(data, &mut self.tls, &mut self.direct); + if self.tls && application { + if self.direct == Some(true) { 2 } else { 1 } + } else if !self.tls && self.frames >= 7 { + 1 + } else { + 0 + } + } + pub(super) fn is_tls(&self) -> bool { + self.tls + } +} +impl Records { + fn feed(&mut self, mut data: &[u8], tls: &mut bool, direct: &mut Option) -> bool { + let mut application = false; + while !data.is_empty() && !self.invalid { + if self.used < 5 { + let count = (5 - self.used).min(data.len()); + self.header[self.used..self.used + count].copy_from_slice(&data[..count]); + self.used += count; + data = &data[count..]; + if self.used < 5 { + break; + } + self.remaining = u16::from_be_bytes([self.header[3], self.header[4]]) as usize; + if !matches!(self.header[0], 20..=23) + || self.header[1] != 3 + || self.header[2] > 3 + || self.remaining > crate::limits::VISION_TLS_RECORD_BYTES + { + self.invalid = true; + self.hello.clear(); + break; + } + application |= self.header[0] == 23; + if self.remaining == 0 { + self.used = 0; + continue; + } + } + let count = self.remaining.min(data.len()); + application |= self.header[0] == 23 && count > 0; + if count > 0 && self.header[0] == 22 && !self.hello_done { + // Only the first hello is relevant. Excess handshake content + // disables classification rather than allocating without bound. + if self.hello.len() + count > crate::limits::VISION_HELLO_BYTES { + self.hello_done = true; + self.hello.clear(); + } else { + self.hello.extend_from_slice(&data[..count]); + if self.hello.first() == Some(&1) { + *tls = true; + self.hello_done = true; + self.hello.clear(); + } else if self.hello.first() != Some(&2) { + self.hello_done = true; + self.hello.clear(); + } else if self.hello.len() >= 4 { + let need = 4 + + ((self.hello[1] as usize) << 16) + + ((self.hello[2] as usize) << 8) + + self.hello[3] as usize; + if need > crate::limits::VISION_HELLO_BYTES { + self.hello_done = true; + self.hello.clear(); + } else if self.hello.len() >= need { + if let Some(version) = server_hello(&self.hello[..need]) { + *tls = true; + *direct = Some(version); + } + self.hello_done = true; + self.hello.clear(); + } + } + } + } + self.remaining -= count; + data = &data[count..]; + if self.remaining == 0 { + self.used = 0; + } + } + application + } +} +fn server_hello(hello: &[u8]) -> Option { + if hello.len() < 42 || hello[4..6] != [3, 3] { + return None; + } + let session = hello[38] as usize; + if session > 32 { + return None; + } + let mut pos = 39 + session; + let cipher = u16::from_be_bytes(hello.get(pos..pos + 2)?.try_into().ok()?); + pos += 3; // cipher + compression + if pos == hello.len() { + return Some(false); + } + let length = u16::from_be_bytes(hello.get(pos..pos + 2)?.try_into().ok()?) as usize; + pos += 2; + if pos + length != hello.len() { + return None; + } + let mut tls13 = false; + while pos < hello.len() { + let kind = u16::from_be_bytes(hello.get(pos..pos + 2)?.try_into().ok()?); + let length = u16::from_be_bytes(hello.get(pos + 2..pos + 4)?.try_into().ok()?) as usize; + let content = hello.get(pos + 4..pos + 4 + length)?; + if kind == 43 { + tls13 = content == [3, 4]; + } + pos += 4 + length; + } + Some(tls13 && matches!(cipher, 0x1301..=0x1304)) +} + +#[cfg(test)] +mod tests { + use super::*; + fn hello(tls13: bool) -> Vec { + let mut body = vec![2, 0, 0, 0, 3, 3]; + body.extend_from_slice(&[0; 32]); + body.extend_from_slice(&[0, 0x13, 1, 0, 0, if tls13 { 6 } else { 0 }]); + if tls13 { + body.extend_from_slice(&[0, 43, 0, 2, 3, 4]); + } + let length = body.len() - 4; + body[3] = length as u8; + let mut record = vec![22, 3, 3, 0, body.len() as u8]; + record.extend_from_slice(&body); + record + } + #[test] + fn fragmented_server_hello_distinguishes_tls12_tls13_and_non_tls() { + let _case = crate::resources::case_events::Case::new( + "VLESS-UNIT", + "fragmented_server_hello_distinguishes_tls12_tls13_and_non_tls", + ); + for tls13 in [false, true] { + for chunk in [1, 3, 8192] { + let mut filter = Filter::default(); + for part in hello(tls13).chunks(chunk) { + filter.received(part); + } + assert_eq!( + filter.command(&[23, 3, 3, 0, 1, 42]), + if tls13 { 2 } else { 1 } + ); + } + } + let mut filter = Filter::default(); + for _ in 0..6 { + assert_eq!(filter.command(b"not TLS"), 0); + } + assert_eq!(filter.command(b"not TLS"), 1); + let mut filter = Filter::default(); + filter.received(&[22, 3, 3, 255, 255]); + assert_ne!(filter.command(&[23, 3, 3, 0, 1, 42]), 2); + } +} diff --git a/src/outbound/vmess/crypto.rs b/src/outbound/vmess/crypto.rs new file mode 100644 index 0000000..4292b78 --- /dev/null +++ b/src/outbound/vmess/crypto.rs @@ -0,0 +1,124 @@ +//! VMess's nested-HMAC KDF composes SHA-256; this is not HKDF or a sequence +//! of ordinary HMAC digests. The path entries select nested hash functions. +use std::io; + +use md5::Md5; +use ring::{aead, rand::SecureRandom}; +use sha2::{Digest, Sha256}; + +pub(super) fn error() -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, "invalid VMess authentication") +} + +pub(super) fn random(output: &mut [u8]) -> io::Result<()> { + ring::rand::SystemRandom::new() + .fill(output) + .map_err(|_| io::Error::other("VMess entropy unavailable")) +} + +pub(super) fn kdf(key: &[u8], path: &[&[u8]]) -> [u8; 32] { + fn hash(keys: &[&[u8]], input: &[u8]) -> [u8; 32] { + let Some((key, parents)) = keys.split_last() else { + return Sha256::digest(input).into(); + }; + let mut pad = [0; 64]; + if key.len() > 64 { + pad[..32].copy_from_slice(&hash(parents, key)); + } else { + pad[..key.len()].copy_from_slice(key); + } + let mut inner = Vec::with_capacity(64 + input.len()); + inner.extend(pad.iter().map(|byte| byte ^ 0x36)); + inner.extend_from_slice(input); + let inner = hash(parents, &inner); + let mut outer = [0; 96]; + for (out, byte) in outer[..64].iter_mut().zip(pad) { + *out = byte ^ 0x5c; + } + outer[64..].copy_from_slice(&inner); + hash(parents, &outer) + } + let mut keys = Vec::with_capacity(path.len() + 1); + keys.push(b"VMess AEAD KDF".as_slice()); + keys.extend_from_slice(path); + hash(&keys, key) +} + +pub(super) fn aead_key(cipher: super::BodyCipher, key: &[u8; 16]) -> aead::LessSafeKey { + let (algorithm, material) = match cipher { + super::BodyCipher::Chacha20Poly1305 => { + let first = Md5::digest(key); + let second = Md5::digest(first); + ( + &aead::CHACHA20_POLY1305, + [first.as_slice(), second.as_slice()].concat(), + ) + } + _ => (&aead::AES_128_GCM, key.to_vec()), + }; + aead::LessSafeKey::new( + aead::UnboundKey::new(algorithm, &material).expect("fixed AEAD key length"), + ) +} + +pub(super) fn seal( + key: &aead::LessSafeKey, + nonce: [u8; 12], + aad: &[u8], + data: &mut Vec, +) -> io::Result<()> { + key.seal_in_place_append_tag( + aead::Nonce::assume_unique_for_key(nonce), + aead::Aad::from(aad), + data, + ) + .map_err(|_| error()) +} + +pub(super) fn open<'a>( + key: &aead::LessSafeKey, + nonce: [u8; 12], + aad: &[u8], + data: &'a mut [u8], +) -> io::Result<&'a mut [u8]> { + key.open_in_place( + aead::Nonce::assume_unique_for_key(nonce), + aead::Aad::from(aad), + data, + ) + .map_err(|_| error()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn independently_published_nested_hmac_vectors() { + #[cfg(feature = "interop-test")] + let _evidence = crate::resources::case_events::Case::new( + "VMESS-CODEC", + "independently_published_nested_hmac_vectors", + ); + assert_eq!( + kdf(b"test", &[b"AES Auth ID Encryption"]), + [ + 149, 109, 253, 20, 158, 39, 112, 199, 28, 74, 3, 106, 99, 8, 234, 59, 64, 172, 126, + 5, 155, 28, 59, 21, 220, 196, 241, 54, 138, 5, 71, 107 + ] + ); + assert_eq!( + kdf( + b"test", + &[ + b"AEAD Resp Header Len Key", + b"AEAD Resp Header Len IV", + b"AEAD Resp Header Key" + ] + ), + [ + 243, 80, 193, 249, 151, 10, 93, 168, 117, 239, 214, 89, 161, 130, 122, 81, 238, + 177, 51, 113, 21, 74, 73, 212, 199, 41, 75, 155, 49, 55, 217, 226 + ] + ); + } +} diff --git a/src/outbound/vmess/datagram.rs b/src/outbound/vmess/datagram.rs new file mode 100644 index 0000000..ad21a3c --- /dev/null +++ b/src/outbound/vmess/datagram.rs @@ -0,0 +1,153 @@ +use std::io; + +use super::{MAX_PACKET_BYTES, VmessStream}; +use crate::{ + dispatch::{DatagramBudget, DatagramTransport, DispatchError}, + dns::resolution::ResolutionContext, + outbound::{ + DEFAULT_ESTABLISH_TIMEOUT, + address::{decode_packet_addr, encode_packet_addr}, + }, + session::{Datagram, Destination}, +}; +use async_trait::async_trait; +use bytes::BytesMut; + +enum Encoding { + Raw(Destination), + PacketAddr(ResolutionContext), +} + +/// One VMess UDP command, with intact datagram boundaries. Raw commands are +/// bound to their authenticated request destination; packetaddr is IP-only. +/// Dropping a partially sent packet poisons the association, while incremental +/// reads retain their state in VmessStream. No task, socket or system DNS here. +pub struct VmessDatagram { + stream: Option, + encoding: Encoding, + budget: DatagramBudget, +} + +impl VmessDatagram { + pub fn raw(stream: VmessStream, peer: Destination, budget: DatagramBudget) -> Self { + Self { + stream: Some(stream), + encoding: Encoding::Raw(peer), + budget, + } + } + pub fn packet_addr( + stream: VmessStream, + budget: DatagramBudget, + resolution: ResolutionContext, + ) -> Self { + Self { + stream: Some(stream), + encoding: Encoding::PacketAddr(resolution), + budget, + } + } + fn check(&self) -> io::Result<()> { + if self.stream.is_none() { + return Err(io::ErrorKind::BrokenPipe.into()); + } + Ok(()) + } +} + +#[async_trait] +impl DatagramTransport for VmessDatagram { + fn payload_budget(&self, peer: &Destination) -> DatagramBudget { + let overhead = match (&self.encoding, peer) { + (Encoding::Raw(_), _) => 0, + (_, Destination::Ip(address)) if address.is_ipv4() => 7, + _ => 19, + }; + let maximum = (MAX_PACKET_BYTES - overhead) as u16; + self.budget.intersect(DatagramBudget::new(maximum, maximum)) + } + + async fn send(&mut self, datagram: Datagram) -> Result<(), DispatchError> { + self.check()?; + if datagram.payload.is_empty() + || datagram.payload.len() + > usize::from(self.payload_budget(&datagram.remote).transmit()) + || datagram.remote.port() == 0 + { + return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); + } + let mut packet = BytesMut::with_capacity(19 + datagram.payload.len()); + match &self.encoding { + Encoding::Raw(peer) => { + if peer != &datagram.remote { + return Err(DispatchError::NotAllowed); + } + } + Encoding::PacketAddr(resolution) => { + let peer = resolution + .resolve_ip( + &datagram.remote, + tokio::time::Instant::now() + DEFAULT_ESTABLISH_TIMEOUT, + ) + .await?; + encode_packet_addr(&peer.into(), &mut packet)?; + } + } + packet.extend_from_slice(&datagram.payload); + // Taking ownership across the non-cancellation-safe write makes a + // cancelled/failed partial frame close IO immediately, even if the + // caller retains the datagram transport without polling it again. + let mut stream = self.stream.take().expect("checked open"); + stream.send_packet(&packet).await?; + self.stream = Some(stream); + Ok(()) + } + + async fn receive(&mut self) -> Result { + self.check()?; + let mut discarded = 0; + loop { + let received = self + .stream + .as_mut() + .expect("checked open") + .receive_packet() + .await; + let packet = match received { + Ok(packet) => packet, + Err(error) => { + self.stream.take(); + return Err(error.into()); + } + }; + let (remote, consumed) = match &self.encoding { + Encoding::Raw(peer) => (peer.clone(), 0), + Encoding::PacketAddr(_) => match decode_packet_addr(&packet) { + Ok(address) => address, + Err(error) => { + self.stream.take(); + return Err(error.into()); + } + }, + }; + let payload = packet.slice(consumed..); + if payload.len() <= usize::from(self.payload_budget(&remote).receive()) { + return Ok(Datagram { + remote, + payload, + sniffed_domain: None, + }); + } + discarded += 1; + if discarded == crate::limits::IO_POLL_BUDGET { + tokio::task::yield_now().await; + discarded = 0; + } + } + } + + async fn close(&mut self) -> Result<(), DispatchError> { + self.stream.take(); + Ok(()) + } +} diff --git a/src/outbound/vmess/header.rs b/src/outbound/vmess/header.rs new file mode 100644 index 0000000..5f2cd4b --- /dev/null +++ b/src/outbound/vmess/header.rs @@ -0,0 +1,217 @@ +use super::{ + BodyCipher, BodyOptions, + crypto::{aead_key, error, kdf, open, random, seal}, +}; +use crate::{outbound::address::encode_port_first, session::Destination}; +use aes::cipher::{BlockCipherEncrypt, KeyInit}; +use bytes::BytesMut; +use md5::Md5; +use sha2::{Digest, Sha256}; +use std::{ + io, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[derive(Clone)] +pub struct VmessIdentity([u8; 16]); +impl std::fmt::Debug for VmessIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VmessIdentity").finish_non_exhaustive() + } +} +impl VmessIdentity { + pub fn new(uuid: uuid::Uuid) -> Self { + let mut digest = Md5::new(); + digest.update(uuid.as_bytes()); + digest.update(b"c48619fe-8f02-49e0-b9e9-edf763e17e21"); + Self(digest.finalize().into()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Command { + Tcp, + Udp, + Mux, +} + +pub struct ClientHandshake { + request: Vec, + pub(super) key: [u8; 16], + pub(super) iv: [u8; 16], + pub(super) response_key: [u8; 16], + pub(super) response_iv: [u8; 16], + response_tag: u8, + pub(super) options: BodyOptions, + pub(super) command: Command, +} +impl std::fmt::Debug for ClientHandshake { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ClientHandshake").finish_non_exhaustive() + } +} + +impl ClientHandshake { + pub fn new( + identity: &VmessIdentity, + command: Command, + peer: &Destination, + options: BodyOptions, + ) -> io::Result { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| io::Error::other("VMess clock before epoch"))? + .as_secs(); + let mut entropy = [0; 61]; + random(&mut entropy)?; + Self::with_entropy(identity, command, peer, options, timestamp, entropy) + } + + /// Synthetic-clock hook for owned native-peer rejection tests only. + #[cfg(feature = "interop-test")] + pub fn with_test_timestamp( + identity: &VmessIdentity, + command: Command, + peer: &Destination, + options: BodyOptions, + timestamp: u64, + ) -> io::Result { + let mut entropy = [0; 61]; + random(&mut entropy)?; + Self::with_entropy(identity, command, peer, options, timestamp, entropy) + } + + fn with_entropy( + identity: &VmessIdentity, + command: Command, + peer: &Destination, + options: BodyOptions, + timestamp: u64, + entropy: [u8; 61], + ) -> io::Result { + if peer.port() == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "zero VMess destination port", + )); + } + let key: [u8; 16] = entropy[..16].try_into().unwrap(); + let iv: [u8; 16] = entropy[16..32].try_into().unwrap(); + let response_tag = entropy[32]; + let padding_length = entropy[33] as usize & 15; + let mut plain = BytesMut::with_capacity(320); + plain.extend_from_slice(&[1]); + plain.extend_from_slice(&iv); + plain.extend_from_slice(&key); + plain.extend_from_slice(&[ + response_tag, + options.wire(command), + (padding_length as u8) << 4 | options.cipher.wire(), + 0, + match command { + Command::Tcp => 1, + Command::Udp => 2, + Command::Mux => 3, + }, + ]); + if command != Command::Mux { + encode_port_first(peer, &mut plain)?; + } + plain.extend_from_slice(&entropy[34..34 + padding_length]); + let checksum = plain.iter().fold(0x811c9dc5u32, |hash, byte| { + (hash ^ u32::from(*byte)).wrapping_mul(0x01000193) + }); + plain.extend_from_slice(&checksum.to_be_bytes()); + + let mut auth_id = [0; 16]; + auth_id[..8].copy_from_slice(×tamp.to_be_bytes()); + auth_id[8..12].copy_from_slice(&entropy[49..53]); + let checksum = crc32fast::hash(&auth_id[..12]); + auth_id[12..].copy_from_slice(&checksum.to_be_bytes()); + let auth_key = kdf(&identity.0, &[b"AES Auth ID Encryption"]); + let block = aes::Aes128::new_from_slice(&auth_key[..16]).expect("AES key size"); + block.encrypt_block((&mut auth_id).into()); + let nonce = &entropy[53..61]; + let mut length = (plain.len() as u16).to_be_bytes().to_vec(); + let mut payload = plain.to_vec(); + for (data, key_salt, iv_salt) in [ + ( + &mut length, + b"VMess Header AEAD Key_Length".as_slice(), + b"VMess Header AEAD Nonce_Length".as_slice(), + ), + ( + &mut payload, + b"VMess Header AEAD Key".as_slice(), + b"VMess Header AEAD Nonce".as_slice(), + ), + ] { + let key = kdf(&identity.0, &[key_salt, &auth_id, nonce]); + let iv = kdf(&identity.0, &[iv_salt, &auth_id, nonce]); + seal( + &aead_key(BodyCipher::Aes128Gcm, key[..16].try_into().unwrap()), + iv[..12].try_into().unwrap(), + &auth_id, + data, + )?; + } + let request = [&auth_id[..], &length, nonce, &payload].concat(); + Ok(Self { + request, + key, + iv, + response_key: Sha256::digest(key)[..16].try_into().unwrap(), + response_iv: Sha256::digest(iv)[..16].try_into().unwrap(), + response_tag, + options, + command, + }) + } + + pub fn request(&self) -> &[u8] { + &self.request + } + + pub fn response_length(&self, wire: &[u8; 18]) -> io::Result { + let mut data = *wire; + let plain = self.open_response( + b"AEAD Resp Header Len Key", + b"AEAD Resp Header Len IV", + &mut data, + )?; + let length = usize::from(u16::from_be_bytes(plain.try_into().map_err(|_| error())?)); + if !(4..=259).contains(&length) { + return Err(error()); + } + Ok(length + 16) + } + + pub fn authenticate_response(&self, wire: &mut [u8]) -> io::Result<()> { + if !(20..=275).contains(&wire.len()) { + return Err(error()); + } + let plain = self.open_response(b"AEAD Resp Header Key", b"AEAD Resp Header IV", wire)?; + if plain[0] != self.response_tag || plain.len() != 4 + usize::from(plain[3]) { + return Err(error()); + } + // Authenticated server commands never change the configured cipher, + // identity, upstream or destination. Legacy account changes are ignored. + Ok(()) + } + + fn open_response<'a>( + &self, + key_salt: &[u8], + iv_salt: &[u8], + data: &'a mut [u8], + ) -> io::Result<&'a mut [u8]> { + let key = kdf(&self.response_key, &[key_salt]); + let iv = kdf(&self.response_iv, &[iv_salt]); + open( + &aead_key(BodyCipher::Aes128Gcm, key[..16].try_into().unwrap()), + iv[..12].try_into().unwrap(), + &[], + data, + ) + } +} diff --git a/src/outbound/vmess/mod.rs b/src/outbound/vmess/mod.rs new file mode 100644 index 0000000..bda5159 --- /dev/null +++ b/src/outbound/vmess/mod.rs @@ -0,0 +1,93 @@ +//! VMess AEAD client over caller-owned IO. No socket creation, resolver, legacy +//! authentication, or peer-controlled cipher negotiation lives in this module. +//! Wire references: v2fly/v2ray-core proxy/vmess/encoding and Clash-RS vmess_impl. +mod crypto; +mod datagram; +mod outbound; +pub use outbound::VmessOutbound; +mod header; +mod stream; +pub use datagram::VmessDatagram; +pub use header::{ClientHandshake, Command, VmessIdentity}; +pub use stream::{MAX_BODY_WIRE, MAX_PACKET_BYTES, VmessStream, WRITE_CHUNK}; + +use std::io; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BodyCipher { + Auto, + Aes128Gcm, + Chacha20Poly1305, + None, +} + +impl BodyCipher { + pub fn resolved(self) -> Self { + self.with_aes_hardware(aes::hardware_accelerated()) + } + fn with_aes_hardware(self, accelerated: bool) -> Self { + match self { + Self::Auto if accelerated => Self::Aes128Gcm, + Self::Auto => Self::Chacha20Poly1305, + other => other, + } + } + fn wire(self) -> u8 { + match self.resolved() { + Self::Aes128Gcm => 3, + Self::Chacha20Poly1305 => 4, + Self::None => 5, + Self::Auto => unreachable!("auto resolved"), + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct BodyOptions { + cipher: BodyCipher, + padding: bool, + authenticated_length: bool, +} + +impl BodyOptions { + pub fn new(cipher: BodyCipher, padding: bool, authenticated_length: bool) -> io::Result { + if cipher == BodyCipher::None && (padding || authenticated_length) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "VMess none cannot enable padding or authenticated length", + )); + } + Ok(Self { + cipher: cipher.resolved(), + padding, + authenticated_length, + }) + } + fn wire(self, command: Command) -> u8 { + if self.cipher == BodyCipher::None { + return u8::from(command == Command::Udp); + } + 1 | 4 | (u8::from(self.padding) << 3) | (u8::from(self.authenticated_length) << 4) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn auto_uses_detected_hardware_not_architecture_name() { + #[cfg(feature = "interop-test")] + let _evidence = crate::resources::case_events::Case::new( + "VMESS-CODEC", + "auto_uses_detected_hardware_not_architecture_name", + ); + assert_eq!( + BodyCipher::Auto.with_aes_hardware(true), + BodyCipher::Aes128Gcm + ); + assert_eq!( + BodyCipher::Auto.with_aes_hardware(false), + BodyCipher::Chacha20Poly1305 + ); + } +} diff --git a/src/outbound/vmess/outbound.rs b/src/outbound/vmess/outbound.rs new file mode 100644 index 0000000..a124833 --- /dev/null +++ b/src/outbound/vmess/outbound.rs @@ -0,0 +1,365 @@ +use super::{ + BodyCipher, BodyOptions, ClientHandshake, Command, MAX_PACKET_BYTES, VmessDatagram, + VmessIdentity, VmessStream, +}; +use crate::{ + config::{VmessCipher, VmessOutboundConfig, VmessPacketEncoding, VmessTransport}, + dispatch::{BoxStream, DatagramBudget, DatagramTransport, DispatchError}, + outbound::{ + ConnectedStream, DatagramRequest, EstablishContext, OutboundConnector, UpstreamPath, + owned_stream::OwnedStream, server_destination, + }, + resources::observation::{self, ResourceKind}, + security::{ + SecurityContext, StandardTlsClient, TLS_RESUMPTION_SESSION_BUDGET, TlsCertificatePolicy, + TlsClientOptions, + }, + session::{Datagram, Destination, StreamSession}, + transport::{connect_websocket, grpc, http_obfs, legacy_h2}, +}; +use async_trait::async_trait; +use std::{io, sync::Arc}; +use tokio::{io::AsyncWriteExt, time::Instant}; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; + +struct SessionOptions { + identity: VmessIdentity, + body: BodyOptions, + transport: VmessTransport, +} + +pub struct VmessOutbound { + server: Destination, + upstream: UpstreamPath, + tls: Option, + options: Arc, + encoding: VmessPacketEncoding, + cancellation: CancellationToken, + tasks: TaskTracker, +} + +impl std::fmt::Debug for VmessOutbound { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("VmessOutbound").finish_non_exhaustive() + } +} + +impl VmessOutbound { + pub fn new_with_path(config: &VmessOutboundConfig, upstream: UpstreamPath) -> io::Result { + Self::with_shared_security( + config, + upstream, + &SecurityContext::new(), + TLS_RESUMPTION_SESSION_BUDGET, + 64 * 1024, + ) + } + pub(crate) fn with_shared_security( + config: &VmessOutboundConfig, + upstream: UpstreamPath, + security: &SecurityContext, + sessions: usize, + buffer: usize, + ) -> io::Result { + let cipher = match config.cipher { + VmessCipher::Auto => BodyCipher::Auto, + VmessCipher::Aes128Gcm => BodyCipher::Aes128Gcm, + VmessCipher::Chacha20Poly1305 => BodyCipher::Chacha20Poly1305, + VmessCipher::None => BodyCipher::None, + }; + let tls = config + .tls + .as_ref() + .map(|tls| { + StandardTlsClient::with_options( + security, + &config.server_name, + TlsClientOptions { + client_fingerprint: tls.client_fingerprint, + alpn: tls.alpn.clone(), + required_alpn: config.transport.required_alpn().map(<[u8]>::to_vec), + certificate: TlsCertificatePolicy { + skip_cert_verify: tls.skip_cert_verify, + fingerprint: tls.fingerprint, + ..Default::default() + }, + ..Default::default() + }, + sessions, + buffer, + ) + }) + .transpose()?; + Ok(Self { + server: server_destination(&config.address, config.port)?, + upstream, + tls, + options: Arc::new(SessionOptions { + identity: VmessIdentity::new(config.id), + body: BodyOptions::new(cipher, config.global_padding, config.authenticated_length)?, + transport: config.transport.clone(), + }), + encoding: config.packet_encoding, + cancellation: CancellationToken::new(), + tasks: TaskTracker::new(), + }) + } + async fn prepare( + &self, + session: StreamSession, + context: &EstablishContext, + ) -> Result { + if Instant::now() >= context.deadline() { + return Err(DispatchError::TimedOut); + } + tokio::select! { biased; + ()=self.cancellation.cancelled()=>Err(DispatchError::NotAllowed), + result=async { + let raw=self.upstream.connect_server(session,&self.server,context).await?.io; + let raw=if let Some(tls)=&self.tls { context.run_io("VMess TLS handshake",tls.connect(raw)).await? } else {raw}; + let token=self.cancellation.child_token(); + Ok(PreparedStream { raw:Box::new(OwnedStream::new(raw,token.clone())),options:self.options.clone(),deadline:context.deadline(),tasks:self.tasks.clone(),token }) + }=>result, + } + } +} + +struct PreparedStream { + raw: BoxStream, + options: Arc, + deadline: Instant, + tasks: TaskTracker, + token: CancellationToken, +} +impl PreparedStream { + async fn finish( + self, + command: Command, + target: &Destination, + ) -> Result { + if Instant::now() >= self.deadline { + return Err(DispatchError::TimedOut); + } + let handshake = + ClientHandshake::new(&self.options.identity, command, target, self.options.body)?; + let whole_close = matches!( + self.options.transport, + VmessTransport::Grpc { .. } | VmessTransport::Http { .. } | VmessTransport::H2 { .. } + ); + let deadline = self.deadline; + let result = tokio::time::timeout_at(deadline, async { + let mut raw = self.raw; + let mut sent = false; + let mut driver = None; + match &self.options.transport { + VmessTransport::Tcp => {} + VmessTransport::WebSocket { .. } => { + raw = connect_websocket( + raw, + &self + .options + .transport + .websocket_options()? + .expect("validated websocket"), + handshake.request(), + deadline, + ) + .await?; + sent = true; + } + VmessTransport::Http { .. } => { + raw = http_obfs( + raw, + &self + .options + .transport + .http_options()? + .expect("validated http"), + handshake.request(), + deadline, + ) + .await?; + sent = true; + } + VmessTransport::Grpc { uri } => { + let connected = grpc(raw, uri, deadline).await?; + raw = connected.0; + driver = Some(connected.1); + } + VmessTransport::H2 { uris } => { + let uri = &uris[rand::random_range(0..uris.len())]; + let connected = legacy_h2(raw, uri, deadline).await?; + raw = connected.0; + driver = Some(connected.1); + } + } + if let Some(driver) = driver { + let token = self.token.clone(); + let observation = observation::track(ResourceKind::Task); + self.tasks.spawn(observation::bind(async move { + let _observation = observation; + token.cancelled().await; + let _ = driver.stop().await; + })); + } + // Dropping setup after a partial header must also stop an H2 driver. + let guard = self.token.clone().drop_guard(); + if !sent { + raw.write_all(handshake.request()).await?; + raw.flush().await?; + } + let stream = VmessStream::new(raw, handshake, deadline); + guard.disarm(); + Ok::<_, io::Error>(if whole_close { + stream.with_whole_close() + } else { + stream + }) + }) + .await + .map_err(|_| DispatchError::TimedOut)??; + Ok(result) + } +} + +#[async_trait] +impl OutboundConnector for VmessOutbound { + async fn connect_stream( + &self, + session: StreamSession, + context: &EstablishContext, + ) -> Result { + let effective_peer = session.destination.clone(); + let prepared = self.prepare(session, context).await?; + let token = prepared.token.clone(); + let stream = prepared.finish(Command::Tcp, &effective_peer).await?; + Ok(ConnectedStream { + io: Box::new(OwnedStream::new(Box::new(stream), token)), + effective_peer, + }) + } + async fn open_datagram( + &self, + request: DatagramRequest, + context: &EstablishContext, + ) -> Result, DispatchError> { + let session = StreamSession { + inbound: request.session.inbound, + source: request.session.source, + destination: self.server.clone(), + sniffed_domain: None, + }; + // Select the physical upstream once now. Raw UDP learns its authenticated + // target from the first send, under this same absolute setup deadline. + let prepared = self.prepare(session, context).await?; + let token = prepared.token.clone(); + let budget = request.budget().intersect(DatagramBudget::new( + MAX_PACKET_BYTES as u16, + MAX_PACKET_BYTES as u16, + )); + let (pending, inner) = if self.encoding == VmessPacketEncoding::Raw { + (Some(prepared), None) + } else { + let (command, magic) = if self.encoding == VmessPacketEncoding::Xudp { + (Command::Mux, "v1.mux.cool") + } else { + (Command::Udp, "sp.packet-addr.v2fly.arpa") + }; + let stream = prepared + .finish(command, &Destination::domain(magic, 443)?) + .await?; + let inner: Box = if self.encoding == VmessPacketEncoding::Xudp { + Box::new(crate::xudp::XudpTransport::with_budget( + Box::new(stream), + [0; 8], + budget, + )) + } else { + Box::new(VmessDatagram::packet_addr( + stream, + budget, + context.resolution(), + )) + }; + (None, Some(inner)) + }; + Ok(crate::dispatch::bound_datagram( + Box::new(SessionDatagram { + pending, + inner, + budget, + token, + }), + request.budget(), + )) + } + fn begin_shutdown(&self) { + self.cancellation.cancel(); + } + async fn shutdown(&self) { + self.begin_shutdown(); + self.tasks.close(); + self.tasks.wait().await; + } +} +impl Drop for VmessOutbound { + fn drop(&mut self) { + self.cancellation.cancel(); + } +} + +struct SessionDatagram { + pending: Option, + inner: Option>, + budget: DatagramBudget, + token: CancellationToken, +} +impl Drop for SessionDatagram { + fn drop(&mut self) { + self.token.cancel(); + } +} +#[async_trait] +impl DatagramTransport for SessionDatagram { + fn payload_budget(&self, peer: &Destination) -> DatagramBudget { + self.inner.as_ref().map_or(self.budget, |inner| { + self.budget.intersect(inner.payload_budget(peer)) + }) + } + async fn send(&mut self, datagram: Datagram) -> Result<(), DispatchError> { + if datagram.payload.is_empty() + || datagram.remote.port() == 0 + || datagram.payload.len() + > usize::from(self.payload_budget(&datagram.remote).transmit()) + { + return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); + } + tokio::select! {biased; + ()=self.token.cancelled()=>Err(DispatchError::NotAllowed), + result=async { + if let Some(prepared)=self.pending.take() { + let stream=prepared.finish(Command::Udp,&datagram.remote).await?; + self.inner=Some(Box::new(VmessDatagram::raw(stream,datagram.remote.clone(),self.budget))); + } + self.inner.as_mut().ok_or(DispatchError::NotAllowed)?.send(datagram).await + }=>result, + } + } + async fn receive(&mut self) -> Result { + let pending = self.pending.is_some(); + let inner = &mut self.inner; + tokio::select! {biased; + ()=self.token.cancelled()=>Err(DispatchError::NotAllowed), + result=async { + if pending { return std::future::pending().await; } + inner.as_mut().ok_or(DispatchError::NotAllowed)?.receive().await + }=>result, + } + } + async fn close(&mut self) -> Result<(), DispatchError> { + self.token.cancel(); + self.pending.take(); + self.inner.take(); + Ok(()) + } +} diff --git a/src/outbound/vmess/stream.rs b/src/outbound/vmess/stream.rs new file mode 100644 index 0000000..6240568 --- /dev/null +++ b/src/outbound/vmess/stream.rs @@ -0,0 +1,529 @@ +use super::{ + BodyCipher, BodyOptions, ClientHandshake, Command, + crypto::{aead_key, error, kdf, open, random, seal}, +}; +use crate::dispatch::BoxStream; +use bytes::{Buf, Bytes, BytesMut}; +use ring::aead; +use shake::{ExtendableOutput, Shake128, Update, XofReader}; +use std::{ + future::Future, + io, + pin::Pin, + task::{Context, Poll, ready}, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite, ReadBuf}, + time::{Instant, Sleep}, +}; + +pub const MAX_BODY_WIRE: usize = 16 * 1024; +// Keep TCP chunks within the native Mihomo listener's initial copy buffer. +// Larger first chunks leave a cache tail which sing's switch to an MTU-sized +// ReadBuffer can skip. This shapes only our wire, never third-party source. +pub const WRITE_CHUNK: usize = 4 * 1024; +/// One intact UDP body; native Mihomo's writer chunks at 15,000 bytes. +/// TCP uses smaller pieces, but splitting a UDP body would change its meaning. +pub const MAX_PACKET_BYTES: usize = 15_000; + +struct BodyCodec { + options: BodyOptions, + iv: [u8; 16], + length_iv: [u8; 16], + body: aead::LessSafeKey, + length: aead::LessSafeKey, + mask: shake::Shake128Reader, + counter: u32, + framed: bool, + write_limit: usize, +} + +impl BodyCodec { + fn new( + options: BodyOptions, + command: Command, + key: &[u8; 16], + iv: [u8; 16], + request_key: &[u8; 16], + request_iv: [u8; 16], + ) -> Self { + let mut mask = Shake128::default(); + mask.update(&iv); + let length_key = kdf(request_key, &[b"auth_len"]); + Self { + options, + iv, + length_iv: request_iv, + body: aead_key(options.cipher, key), + length: aead_key(options.cipher, length_key[..16].try_into().unwrap()), + mask: mask.finalize_xof(), + counter: 0, + framed: options.cipher != BodyCipher::None || command == Command::Udp, + write_limit: if command == Command::Udp { + MAX_PACKET_BYTES + } else { + WRITE_CHUNK + }, + } + } + fn overhead(&self) -> usize { + if self.options.cipher == BodyCipher::None { + 0 + } else { + 16 + } + } + fn length_size(&self) -> usize { + if self.options.authenticated_length { + 18 + } else { + 2 + } + } + fn nonce(&self, iv: &[u8; 16]) -> io::Result<[u8; 12]> { + // The wire counter is only 16 bits. Never reuse a nonce under the same + // key: an exhausted encrypted session must be re-established by caller. + let counter = u16::try_from(self.counter) + .map_err(|_| io::Error::other("VMess nonce sequence exhausted"))?; + let mut nonce: [u8; 12] = iv[..12].try_into().unwrap(); + nonce[..2].copy_from_slice(&counter.to_be_bytes()); + Ok(nonce) + } + fn next_mask(&mut self) -> u16 { + let mut bytes = [0; 2]; + self.mask.read(&mut bytes); + u16::from_be_bytes(bytes) + } + fn padding(&mut self) -> usize { + if self.options.padding { + usize::from(self.next_mask() % 64) + } else { + 0 + } + } + fn encode(&mut self, plain: &[u8]) -> io::Result { + if plain.len() > self.write_limit { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "VMess chunk exceeds limit", + )); + } + if !self.framed { + return Ok(Bytes::copy_from_slice(plain)); + } + let padding = self.padding(); + let mut body = plain.to_vec(); + if self.overhead() != 0 { + seal(&self.body, self.nonce(&self.iv)?, &[], &mut body)?; + } + let mut length = (body.len() + padding) as u16; + let mut wire = if self.options.authenticated_length { + let mut encoded = (length - 16).to_be_bytes().to_vec(); + seal( + &self.length, + self.nonce(&self.length_iv)?, + &[], + &mut encoded, + )?; + encoded + } else { + if self.options.cipher != BodyCipher::None { + length ^= self.next_mask(); + } + length.to_be_bytes().to_vec() + }; + wire.extend_from_slice(&body); + let start = wire.len(); + wire.resize(start + padding, 0); + random(&mut wire[start..])?; + self.counter += 1; + Ok(wire.into()) + } + fn decode_length(&mut self, wire: &mut [u8]) -> io::Result<(usize, usize)> { + let padding = self.padding(); + let length = if self.options.authenticated_length { + let plain = open(&self.length, self.nonce(&self.length_iv)?, &[], wire)?; + usize::from(u16::from_be_bytes(plain.try_into().map_err(|_| error())?)) + 16 + } else { + let mut length = u16::from_be_bytes(wire.try_into().map_err(|_| error())?); + if self.options.cipher != BodyCipher::None { + length ^= self.next_mask(); + } + usize::from(length) + }; + if length < padding + self.overhead() || length > MAX_BODY_WIRE { + return Err(error()); + } + Ok((length, padding)) + } + fn decode_body(&mut self, mut wire: BytesMut, padding: usize) -> io::Result { + wire.truncate(wire.len().checked_sub(padding).ok_or_else(error)?); + if self.overhead() != 0 { + let length = open(&self.body, self.nonce(&self.iv)?, &[], &mut wire)?.len(); + wire.truncate(length); + } + self.counter += 1; + Ok(wire.freeze()) + } +} + +enum ReadStage { + ResponseLength, + Response(usize), + Length, + Body { length: usize, padding: usize }, + Eof, +} + +/// Bounded, cancellation-safe framed IO. The caller writes `handshake.request()` +/// before wrapping (including WS early data). Response authentication is lazy so +/// both server-first and client-first protocols work; it shares setup's deadline. +pub struct VmessStream { + io: Option, + handshake: Option, + deadline: Pin>, + read: BodyCodec, + write: BodyCodec, + stage: ReadStage, + input: BytesMut, + plain: Bytes, + output: Bytes, + shutdown: bool, + whole_close: bool, + closed: bool, + reader: futures_util::task::AtomicWaker, +} + +impl VmessStream { + pub fn new(io: BoxStream, handshake: ClientHandshake, deadline: Instant) -> Self { + Self { + io: Some(io), + read: BodyCodec::new( + handshake.options, + handshake.command, + &handshake.response_key, + handshake.response_iv, + &handshake.key, + handshake.iv, + ), + write: BodyCodec::new( + handshake.options, + handshake.command, + &handshake.key, + handshake.iv, + &handshake.key, + handshake.iv, + ), + handshake: Some(handshake), + deadline: Box::pin(tokio::time::sleep_until(deadline)), + stage: ReadStage::ResponseLength, + input: BytesMut::new(), + plain: Bytes::new(), + output: Bytes::new(), + shutdown: false, + whole_close: false, + closed: false, + reader: futures_util::task::AtomicWaker::new(), + } + } + /// Mihomo falls back to whole-connection Close for gRPC, HTTP camouflage + /// and legacy H2. TCP/ordinary WS keep the underlying CloseWrite behavior. + pub fn with_whole_close(mut self) -> Self { + self.whole_close = true; + self + } + fn check(&mut self, cx: &mut Context<'_>) -> io::Result<()> { + if self.io.is_none() { + return Err(io::ErrorKind::BrokenPipe.into()); + } + if self.handshake.is_some() && self.deadline.as_mut().poll(cx).is_ready() { + self.io.take(); + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "VMess response deadline", + )); + } + Ok(()) + } + fn fail(&mut self, result: io::Result) -> io::Result { + if result.is_err() { + self.io.take(); + } + result + } + fn fill( + &mut self, + cx: &mut Context<'_>, + length: usize, + eof_allowed: bool, + ) -> Poll> { + while self.input.len() < length { + let mut storage = [0; 2048]; + let amount = (length - self.input.len()).min(storage.len()); + let mut buf = ReadBuf::new(&mut storage[..amount]); + ready!( + Pin::new(self.io.as_mut().ok_or(io::ErrorKind::BrokenPipe)?) + .poll_read(cx, &mut buf) + )?; + if buf.filled().is_empty() { + return Poll::Ready(if eof_allowed && self.input.is_empty() { + Ok(false) + } else { + Err(io::ErrorKind::UnexpectedEof.into()) + }); + } + self.input.extend_from_slice(buf.filled()); + } + Poll::Ready(Ok(true)) + } + fn poll_packet_inner(&mut self, cx: &mut Context<'_>) -> Poll>> { + self.reader.register(cx.waker()); + if self.closed { + return Poll::Ready(Ok(None)); + } + self.check(cx)?; + loop { + match self.stage { + ReadStage::ResponseLength => { + ready!(self.fill(cx, 18, false))?; + let length = self + .handshake + .as_ref() + .unwrap() + .response_length(self.input[..].try_into().unwrap())?; + self.input.clear(); + self.stage = ReadStage::Response(length); + } + ReadStage::Response(length) => { + ready!(self.fill(cx, length, false))?; + self.handshake + .as_ref() + .unwrap() + .authenticate_response(&mut self.input)?; + self.handshake.take(); + self.input.clear(); + self.stage = ReadStage::Length; + } + ReadStage::Length if !self.read.framed => { + let mut data = [0; WRITE_CHUNK]; + let mut buf = ReadBuf::new(&mut data); + ready!(Pin::new(self.io.as_mut().unwrap()).poll_read(cx, &mut buf))?; + if buf.filled().is_empty() { + self.stage = ReadStage::Eof; + return Poll::Ready(Ok(None)); + } + return Poll::Ready(Ok(Some(Bytes::copy_from_slice(buf.filled())))); + } + ReadStage::Length => { + let length = self.read.length_size(); + // Native peers may close at a frame boundary without an EOF + // frame; partial lengths/bodies still fail as truncation. + if !ready!(self.fill(cx, length, true))? { + self.stage = ReadStage::Eof; + return Poll::Ready(Ok(None)); + } + let (length, padding) = self.read.decode_length(&mut self.input)?; + self.input.clear(); + self.stage = ReadStage::Body { length, padding }; + } + ReadStage::Body { length, padding } => { + ready!(self.fill(cx, length, false))?; + let data = self.read.decode_body(self.input.split(), padding)?; + self.stage = if data.is_empty() { + ReadStage::Eof + } else { + ReadStage::Length + }; + return Poll::Ready(Ok(if data.is_empty() { None } else { Some(data) })); + } + ReadStage::Eof => return Poll::Ready(Ok(None)), + } + } + } + pub async fn receive_packet(&mut self) -> io::Result { + std::future::poll_fn(|cx| self.poll_packet(cx)) + .await? + .ok_or(io::ErrorKind::UnexpectedEof.into()) + } + fn poll_packet(&mut self, cx: &mut Context<'_>) -> Poll>> { + let result = ready!(self.poll_packet_inner(cx)); + Poll::Ready(self.fail(result)) + } + fn drain(&mut self, cx: &mut Context<'_>) -> Poll> { + self.check(cx)?; + while !self.output.is_empty() { + let n = ready!(Pin::new(self.io.as_mut().unwrap()).poll_write(cx, &self.output))?; + if n == 0 { + return Poll::Ready(Err(io::ErrorKind::WriteZero.into())); + } + self.output.advance(n); + } + Poll::Ready(Ok(())) + } + pub async fn send_packet(&mut self, payload: &[u8]) -> io::Result<()> { + if self.shutdown { + return Err(io::ErrorKind::BrokenPipe.into()); + } + if payload.is_empty() || payload.len() > self.write.write_limit { + return Err(io::ErrorKind::InvalidInput.into()); + } + use tokio::io::AsyncWriteExt; + // A datagram owner poisons its association when this future is cancelled. + self.flush().await?; + self.output = self.write.encode(payload)?; + self.flush().await + } +} + +impl AsyncRead for VmessStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if buf.remaining() == 0 { + return Poll::Ready(Ok(())); + } + if self.plain.is_empty() { + self.plain = match ready!(self.poll_packet(cx))? { + Some(data) => data, + None => return Poll::Ready(Ok(())), + }; + } + let length = self.plain.len().min(buf.remaining()); + buf.put_slice(&self.plain.split_to(length)); + Poll::Ready(Ok(())) + } +} + +impl AsyncWrite for VmessStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + data: &[u8], + ) -> Poll> { + let result = ready!(self.drain(cx)); + self.fail(result)?; + if self.shutdown { + return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into())); + } + if data.is_empty() { + return Poll::Ready(Ok(0)); + } + let length = data.len().min(WRITE_CHUNK); + let encoded = self.write.encode(&data[..length]); + self.output = self.fail(encoded)?; + Poll::Ready(Ok(length)) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let result = ready!(self.drain(cx)); + self.fail(result)?; + let result = ready!(Pin::new(self.io.as_mut().unwrap()).poll_flush(cx)); + Poll::Ready(self.fail(result)) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.closed { + return Poll::Ready(Ok(())); + } + if self.whole_close { + ready!(self.as_mut().poll_flush(cx))?; + self.shutdown = true; + self.closed = true; + self.io.take(); + self.handshake.take(); + self.plain = Bytes::new(); + self.input.clear(); + self.reader.wake(); + return Poll::Ready(Ok(())); + } + let result = ready!(self.drain(cx)); + self.fail(result)?; + if !self.shutdown { + self.shutdown = true; + let encoded = self.write.encode(&[]); + self.output = self.fail(encoded)?; + } + ready!(self.as_mut().poll_flush(cx))?; + let result = ready!(Pin::new(self.io.as_mut().unwrap()).poll_shutdown(cx)); + Poll::Ready(self.fail(result)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn codec(cipher: BodyCipher, padding: bool, length: bool) -> BodyCodec { + BodyCodec::new( + BodyOptions::new(cipher, padding, length).unwrap(), + Command::Tcp, + &[7; 16], + [9; 16], + &[7; 16], + [9; 16], + ) + } + + #[test] + fn chunks_preserve_boundaries_masks_padding_and_authenticated_eof() { + #[cfg(feature = "interop-test")] + let _evidence = crate::resources::case_events::Case::new( + "VMESS-CODEC", + "chunks_preserve_boundaries_masks_padding_and_authenticated_eof", + ); + for cipher in [BodyCipher::Aes128Gcm, BodyCipher::Chacha20Poly1305] { + for padding in [false, true] { + for length in [false, true] { + let mut tx = codec(cipher, padding, length); + let mut rx = codec(cipher, padding, length); + for size in (1..256).chain([WRITE_CHUNK, 0]) { + let data = vec![0x5a; size]; + let mut wire = BytesMut::from(tx.encode(&data).unwrap().as_ref()); + let mut header = wire.split_to(rx.length_size()); + let (size, padding) = rx.decode_length(&mut header).unwrap(); + assert_eq!(wire.len(), size); + assert_eq!(rx.decode_body(wire, padding).unwrap(), data); + } + } + } + } + } + + #[test] + fn tags_lengths_and_nonce_exhaustion_fail_closed() { + #[cfg(feature = "interop-test")] + let _evidence = crate::resources::case_events::Case::new( + "VMESS-CODEC", + "tags_lengths_and_nonce_exhaustion_fail_closed", + ); + for cipher in [BodyCipher::Aes128Gcm, BodyCipher::Chacha20Poly1305] { + let mut tx = codec(cipher, false, true); + let wire = tx.encode(b"synthetic-payload").unwrap(); + for index in [0, 17, 18, wire.len() - 1] { + let mut rx = codec(cipher, false, true); + let mut tampered = BytesMut::from(wire.as_ref()); + tampered[index] ^= 1; + let mut header = tampered.split_to(18); + if index < 18 { + assert!(rx.decode_length(&mut header).is_err()); + } else { + let (_, padding) = rx.decode_length(&mut header).unwrap(); + assert!(rx.decode_body(tampered, padding).is_err()); + } + } + let mut tx = codec(cipher, false, false); + tx.counter = u32::from(u16::MAX); + assert!(tx.encode(b"last-nonce").is_ok()); + assert!(tx.encode(b"must-not-repeat").is_err()); + let mut rx = codec(cipher, false, false); + let mask = rx.next_mask(); + let mut rx = codec(cipher, false, false); + assert!(rx.decode_length(&mut (mask ^ 65535).to_be_bytes()).is_err()); + assert!( + codec(cipher, true, true) + .encode(&vec![0; WRITE_CHUNK + 1]) + .is_err() + ); + } + } +} diff --git a/src/quic_sniffer.rs b/src/quic_sniffer.rs index 513c948..f33a7b3 100644 --- a/src/quic_sniffer.rs +++ b/src/quic_sniffer.rs @@ -700,7 +700,9 @@ mod tests { fn decode_hex(input: &str) -> Vec { input .as_bytes() - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|pair| { let digits = std::str::from_utf8(pair).unwrap(); u8::from_str_radix(digits, 16).unwrap() diff --git a/src/resources.rs b/src/resources.rs index 0e84afa..cc37625 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -1,3 +1,7 @@ +#[cfg(any(test, feature = "interop-test"))] +pub mod case_events; +pub mod observation; + use std::sync::{ Arc, atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, @@ -71,6 +75,7 @@ pub(crate) enum ResourceQueue { pub(crate) struct ResourceActivityGuard { stats: RuntimeResourceStats, activity: ResourceActivity, + _observation: observation::Guard, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -127,6 +132,14 @@ impl RuntimeResourceStats { ResourceActivityGuard { stats: self.clone(), activity, + _observation: observation::track(match activity { + ResourceActivity::DnsRequest | ResourceActivity::Singleflight => { + observation::ResourceKind::Waiter + } + ResourceActivity::TcpSession => observation::ResourceKind::Session, + ResourceActivity::UdpAssociation => observation::ResourceKind::Association, + ResourceActivity::Handshake => observation::ResourceKind::Handshake, + }), } } diff --git a/src/resources/case_events.rs b/src/resources/case_events.rs new file mode 100644 index 0000000..7b96c26 --- /dev/null +++ b/src/resources/case_events.rs @@ -0,0 +1,73 @@ +//! Structured opt-in evidence from assertion-bearing Rust tests. Human test +//! output is not a coverage source. Missing/partial events cannot become PASS. +use super::observation::ResourceSnapshot; +use std::{fs::OpenOptions, io::Write, path::PathBuf, sync::Mutex, time::Instant}; + +static WRITE_LOCK: Mutex<()> = Mutex::new(()); + +pub struct Case { + suite: &'static str, + assertion: &'static str, + started: Instant, + path: Option, + resources: Option, + checkpoints: Vec<(&'static str, ResourceSnapshot)>, +} +impl Case { + pub fn new(suite: &'static str, assertion: &'static str) -> Self { + let case = Self { + suite, + assertion, + started: Instant::now(), + path: std::env::var_os("VCORE_CASE_EVENTS").map(PathBuf::from), + resources: None, + checkpoints: Vec::new(), + }; + case.emit("BEGIN"); + case + } + pub fn resources(&mut self, snapshot: ResourceSnapshot) { + self.resources = Some(snapshot); + } + pub fn checkpoint(&mut self, phase: &'static str, snapshot: ResourceSnapshot) { + self.checkpoints.push((phase, snapshot)); + } + fn emit(&self, status: &str) { + let Some(path) = &self.path else { + return; + }; + let checkpoints: Vec<_> = self + .checkpoints + .iter() + .map(|(phase, snapshot)| serde_json::json!({"phase":phase,"resources":snapshot})) + .collect(); + let event = serde_json::json!({"schema_version":1,"suite":self.suite,"assertion":self.assertion,"status":status,"seconds":self.started.elapsed().as_secs_f64(),"resources":self.resources,"checkpoints":checkpoints}); + let _lock = WRITE_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let result = (|| -> std::io::Result<()> { + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + writeln!(file, "{event}")?; + file.flush() + })(); + if result.is_err() && !std::thread::panicking() { + panic!("cannot persist structured case evidence"); + } + } +} +impl Drop for Case { + fn drop(&mut self) { + self.emit( + if std::thread::panicking() + || self + .resources + .as_ref() + .is_some_and(|snapshot| !snapshot.is_idle()) + { + "FAIL" + } else { + "PASS" + }, + ); + } +} diff --git a/src/resources/observation.rs b/src/resources/observation.rs new file mode 100644 index 0000000..f3138c0 --- /dev/null +++ b/src/resources/observation.rs @@ -0,0 +1,309 @@ +//! Test-only per-run RAII counters. No global allocator, admission limit or +//! process-global reset; concurrent tests use independent scopes. +use std::{ + future::Future, + io, + ops::Deref, + pin::Pin, + task::{Context, Poll}, +}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ResourceKind { + Task, + Socket, + Session, + Association, + Reassembly, + Pool, + Waiter, + Handshake, +} +impl ResourceKind { + pub const ALL: [Self; 8] = [ + Self::Task, + Self::Socket, + Self::Session, + Self::Association, + Self::Reassembly, + Self::Pool, + Self::Waiter, + Self::Handshake, + ]; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum QueueKind { + SocksUdp, + Hysteria2Udp, + QuicIncoming, + QuicOutgoing, +} +impl QueueKind { + pub const ALL: [Self; 4] = [ + Self::SocksUdp, + Self::Hysteria2Udp, + Self::QuicIncoming, + Self::QuicOutgoing, + ]; +} + +#[cfg(any(test, feature = "interop-test"))] +mod enabled { + use super::*; + use crate::resources::ActivityStats; + use std::{ + cell::RefCell, + sync::{Arc, atomic::Ordering}, + }; + + tokio::task_local! { static ACTIVE: ResourceProbe; } + thread_local! { static SYNCHRONOUS: RefCell> = const { RefCell::new(None) }; } + + fn current() -> Option { + ACTIVE + .try_with(Clone::clone) + .ok() + .or_else(|| SYNCHRONOUS.with(|slot| slot.borrow().clone())) + } + + #[cfg(feature = "ffi")] + pub fn inherit_thread T, T>(operation: F) -> impl FnOnce() -> T { + let probe = current(); + move || match probe { + Some(probe) => probe.scope_sync(operation), + None => operation(), + } + } + + #[derive(Debug, Clone)] + pub struct ResourceProbe { + counters: Arc<[ActivityStats; 8]>, + queues: Arc<[ActivityStats; 4]>, + } + impl Default for ResourceProbe { + fn default() -> Self { + Self { + counters: Arc::new(std::array::from_fn(|_| ActivityStats::default())), + queues: Arc::new(std::array::from_fn(|_| ActivityStats::default())), + } + } + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] + pub struct ResourceCount { + pub kind: ResourceKind, + pub current: usize, + pub peak: usize, + } + #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] + pub struct ResourceSnapshot { + pub counts: [ResourceCount; 8], + } + #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] + pub struct QueueCount { + pub kind: QueueKind, + pub capacity: usize, + pub peak: usize, + } + impl ResourceSnapshot { + pub fn current(&self, kind: ResourceKind) -> usize { + self.counts[kind as usize].current + } + pub fn peak(&self, kind: ResourceKind) -> usize { + self.counts[kind as usize].peak + } + pub fn is_idle(&self) -> bool { + self.counts.iter().all(|count| count.current == 0) + } + } + impl ResourceProbe { + /// Observe a synchronous Invoke entry and its explicitly inherited + /// engine thread. Async task-local scopes take precedence. Restores the + /// caller's previous scope on both success and panic; no global reset. + pub fn scope_sync T, T>(&self, operation: F) -> T { + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + SYNCHRONOUS.with(|slot| { + slot.replace(self.0.take()); + }); + } + } + let _restore = Restore(SYNCHRONOUS.with(|slot| slot.replace(Some(self.clone())))); + operation() + } + pub async fn scope(&self, future: F) -> F::Output { + ACTIVE.scope(self.clone(), future).await + } + pub fn snapshot(&self) -> ResourceSnapshot { + ResourceSnapshot { + counts: std::array::from_fn(|i| ResourceCount { + kind: ResourceKind::ALL[i], + current: self.counters[i].current.load(Ordering::Acquire), + peak: self.counters[i].peak.load(Ordering::Relaxed), + }), + } + } + /// Maximum occupancy of an individual queue in each category, not the + /// sum across queues. Reserved send permits count as occupied slots. + pub fn queues(&self) -> [QueueCount; 4] { + std::array::from_fn(|i| QueueCount { + kind: QueueKind::ALL[i], + capacity: self.queues[i].current.load(Ordering::Relaxed), + peak: self.queues[i].peak.load(Ordering::Relaxed), + }) + } + } + pub fn observe_queue(kind: QueueKind, occupied: usize, capacity: usize) { + if let Some(probe) = current() { + let queue = &probe.queues[kind as usize]; + queue.current.fetch_max(capacity, Ordering::Relaxed); + queue.peak.fetch_max(occupied, Ordering::Relaxed); + } + } + #[derive(Debug)] + pub struct Guard { + probe: Option, + kind: ResourceKind, + } + pub fn track(kind: ResourceKind) -> Guard { + let probe = current(); + if let Some(probe) = &probe { + let count = &probe.counters[kind as usize]; + let current = count.current.fetch_add(1, Ordering::AcqRel) + 1; + count.peak.fetch_max(current, Ordering::Relaxed); + } + Guard { probe, kind } + } + impl Drop for Guard { + fn drop(&mut self) { + if let Some(probe) = &self.probe { + probe.counters[self.kind as usize] + .current + .fetch_sub(1, Ordering::AcqRel); + } + } + } + pub fn bind(future: F) -> impl Future { + let probe = current(); + async move { + if let Some(probe) = probe { + probe.scope(future).await + } else { + future.await + } + } + } +} + +#[cfg(all(feature = "ffi", any(test, feature = "interop-test")))] +pub(crate) use enabled::inherit_thread; +#[cfg(any(test, feature = "interop-test"))] +pub(crate) use enabled::{Guard, bind, observe_queue, track}; +#[cfg(any(test, feature = "interop-test"))] +pub use enabled::{QueueCount, ResourceCount, ResourceProbe, ResourceSnapshot}; + +#[cfg(not(any(test, feature = "interop-test")))] +pub(crate) fn observe_queue(_: QueueKind, _: usize, _: usize) {} + +#[cfg(not(any(test, feature = "interop-test")))] +#[derive(Debug)] +pub(crate) struct Guard; +// Callers may end a tracked lifetime explicitly. Keep that RAII contract when +// observation is disabled, without adding state, counters, or allocations. +#[cfg(not(any(test, feature = "interop-test")))] +impl Drop for Guard { + fn drop(&mut self) {} +} +#[cfg(not(any(test, feature = "interop-test")))] +pub(crate) fn track(_: ResourceKind) -> Guard { + Guard +} +#[cfg(not(any(test, feature = "interop-test")))] +pub(crate) fn bind(future: F) -> F { + future +} + +#[cfg(all(feature = "ffi", not(any(test, feature = "interop-test"))))] +pub(crate) fn inherit_thread T, T>(operation: F) -> F { + operation +} + +pub(crate) fn spawn(future: F) -> tokio::task::JoinHandle +where + F: Future + Send + 'static, + F::Output: Send + 'static, +{ + tokio::spawn(task(future)) +} + +pub(crate) fn task(future: F) -> impl Future { + let guard = track(ResourceKind::Task); + bind(async move { + let _guard = guard; + future.await + }) +} + +/// IO plus a test-only lifetime guard. In release builds the guard is a ZST; +/// it adds no allocation, shared counter or admission behavior. +#[derive(Debug)] +pub struct ObservedIo { + inner: T, + _guard: Guard, +} +impl ObservedIo { + pub(crate) fn with_guard(inner: T, guard: Guard) -> Self { + Self { + inner, + _guard: guard, + } + } + pub(crate) fn new(inner: T, kind: ResourceKind) -> Self { + Self::with_guard(inner, track(kind)) + } +} +impl Deref for ObservedIo { + type Target = T; + fn deref(&self) -> &T { + &self.inner + } +} +impl AsyncRead for ObservedIo { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffer: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buffer) + } +} +impl AsyncWrite for ObservedIo { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffer: &[u8], + ) -> Poll> { + Pin::new(&mut self.inner).poll_write(cx, buffer) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } + fn is_write_vectored(&self) -> bool { + self.inner.is_write_vectored() + } + fn poll_write_vectored( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffers: &[io::IoSlice<'_>], + ) -> Poll> { + Pin::new(&mut self.inner).poll_write_vectored(cx, buffers) + } +} diff --git a/src/runtime.rs b/src/runtime.rs index 5ee4398..c158bbd 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -42,7 +42,7 @@ use crate::{ }, dialer::{Dialer, ResolvedEndpoint, Resolver}, dispatch::{Dispatcher, observe_handshakes_with_stats, observe_sessions_with_stats}, - dns::runtime::RuntimeDns, + dns::{resolution::ResolutionContext, runtime::RuntimeDns}, geodata::{ GeoDataManager, GeoDataRegistration, GeoRequirements, service::GeoDataUpdateService, }, @@ -59,13 +59,21 @@ use crate::{ use crate::outbound::ShadowsocksOutbound; #[cfg(feature = "outbound-socks5")] use crate::outbound::Socks5Outbound; +#[cfg(feature = "outbound-trojan")] +use crate::outbound::trojan::TrojanOutbound; #[cfg(feature = "outbound-anytls")] use crate::outbound::{AnyTlsOutbound, server_destination}; #[cfg(feature = "outbound-vless")] use crate::outbound::{VlessOutbound, VlessResourceLimits}; #[cfg(feature = "outbound-anytls")] use crate::security::StandardTlsClient; -#[cfg(any(feature = "outbound-anytls", feature = "outbound-vless"))] +#[cfg(any( + feature = "outbound-anytls", + feature = "outbound-vless", + feature = "outbound-trojan", + feature = "outbound-vmess", + feature = "outbound-hysteria2" +))] use crate::security::{SecurityContext, TLS_RESUMPTION_SESSION_BUDGET}; /// Parsed configuration plus the bootstrap-resolved physical proxy roots. @@ -139,6 +147,7 @@ struct BuiltRuntimeParts { } struct BuiltProxyGraph { + resolution: ResolutionContext, nodes: Vec>, lifecycle_order: Vec, selections: Vec>, @@ -164,6 +173,7 @@ impl BuiltProxyGraph { } fn begin_shutdown(&self) { + self.resolution.close(); for target in self.lifecycle_order.iter().rev() { if let BuiltRouteTarget::Proxy(connector) = target { connector.begin_shutdown(); @@ -302,8 +312,11 @@ impl PreparedCore { let dialer = dialer.with_ipv6(self.config.ipv6); let handshake_stats = RuntimeResourceStats::new("runtime_handshake_observation"); let proxy_graph = self.build_proxy_graph(dialer.clone())?; - let proxy_dispatchers = - self.wrap_proxy_dispatchers(proxy_graph.connectors(), &handshake_stats)?; + let proxy_dispatchers = self.wrap_proxy_dispatchers( + proxy_graph.connectors(), + &handshake_stats, + &proxy_graph.resolution, + )?; let direct_raw: Arc = Arc::new(DirectOutbound::new(dialer)); let direct = observe_handshakes_with_stats(direct_raw, handshake_stats.clone()); let proxy_groups = ProxyGroups::with_selections( @@ -330,6 +343,7 @@ impl PreparedCore { redir_host_entries, )) }); + proxy_graph.resolution.bind_runtime(dns.as_ref())?; let geodata_rules = RuleSet::compile(vec![crate::config::RuleSpec { kind: crate::config::RuleKind::Match, action: crate::config::RuleAction::Route(self.config.default_route_target), @@ -384,6 +398,7 @@ impl PreparedCore { &self.endpoints, self.limits, dialer, + ResolutionContext::runtime(self.config.ipv6), ) } @@ -391,11 +406,13 @@ impl PreparedCore { &self, connectors: &[Arc], handshake_stats: &RuntimeResourceStats, + resolution: &ResolutionContext, ) -> io::Result>> { let mut dispatchers = Vec::with_capacity(connectors.len()); for (proxy, connector) in self.config.proxies.iter().zip(connectors) { let mut dispatcher: Arc = Arc::new( - ConnectorDispatcher::with_udp_capability(connector.clone(), proxy.udp), + ConnectorDispatcher::with_udp_capability(connector.clone(), proxy.udp) + .with_resolution(resolution.clone()), ); dispatcher = observe_handshakes_with_stats(dispatcher, handshake_stats.clone()); dispatchers.push(dispatcher); @@ -524,12 +541,21 @@ impl PreparedMeasurement { } pub(crate) fn into_runtime(self, dialer: Dialer) -> io::Result { + self.into_runtime_with_resolver(dialer, Arc::new(crate::dialer::SystemResolver)) + } + + pub(crate) fn into_runtime_with_resolver( + self, + dialer: Dialer, + resolver: Arc, + ) -> io::Result { let proxy_graph = build_proxy_graph( &self.config.proxies, &[], &self.endpoints, self.limits, dialer, + ResolutionContext::measurement(resolver, true), )?; let connector = proxy_graph .get(self.config.default_proxy.index()) @@ -540,7 +566,9 @@ impl PreparedMeasurement { "default proxy is missing from the measurement graph", ) })?; - let dispatcher: Arc = Arc::new(ConnectorDispatcher::new(connector)); + let dispatcher: Arc = Arc::new( + ConnectorDispatcher::new(connector).with_resolution(proxy_graph.resolution.clone()), + ); let handshake_stats = RuntimeResourceStats::new("measurement_handshake_observation"); let dispatcher = observe_handshakes_with_stats(dispatcher, handshake_stats); let session_stats = RuntimeResourceStats::new("measurement_session_observation"); @@ -557,6 +585,7 @@ fn build_proxy_graph( endpoints: &[PreparedProxyEndpoints], limits: ResourceLimits, dialer: Dialer, + resolution: ResolutionContext, ) -> io::Result { if endpoints.len() != proxies.len() { return Err(io::Error::new( @@ -566,17 +595,36 @@ fn build_proxy_graph( } let order = proxy_graph_order(proxies, groups) .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; - #[cfg(any(feature = "outbound-anytls", feature = "outbound-vless"))] + #[cfg(any( + feature = "outbound-anytls", + feature = "outbound-vless", + feature = "outbound-trojan", + feature = "outbound-vmess", + feature = "outbound-hysteria2" + ))] let (security_client_count, standard_tls_count) = security_counts(proxies); - #[cfg(any(feature = "outbound-anytls", feature = "outbound-vless"))] + #[cfg(any( + feature = "outbound-anytls", + feature = "outbound-vless", + feature = "outbound-trojan", + feature = "outbound-vmess", + feature = "outbound-hysteria2" + ))] let security_context = (security_client_count != 0).then(SecurityContext::new); - #[cfg(any(feature = "outbound-anytls", feature = "outbound-vless"))] + #[cfg(any( + feature = "outbound-anytls", + feature = "outbound-vless", + feature = "outbound-trojan", + feature = "outbound-vmess", + feature = "outbound-hysteria2" + ))] let resumption_sessions = standard_tls_resumption_sessions(standard_tls_count); // Declared before the temporary registries: even on partial construction // failure they drop first, then this guard releases the DAG in reverse // dependency order without recursive destruction of configuration-sized chains. let mut graph = BuiltProxyGraph { + resolution, nodes: Vec::new(), lifecycle_order: Vec::with_capacity(order.len()), selections: groups @@ -633,6 +681,70 @@ fn build_proxy_graph( &dialer, )?; let connector: Arc = match &proxy.protocol { + ProxyProtocol::Hysteria2(config) => { + #[cfg(feature = "outbound-hysteria2")] + { + Arc::new( + crate::outbound::hysteria2::Hysteria2Outbound::with_shared_security( + config, + upstream, + security_context.as_ref().expect("TLS context"), + resumption_sessions, + limits.tls_buffer_limit, + )?, + ) + } + #[cfg(not(feature = "outbound-hysteria2"))] + { + let _ = config; + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "Hysteria2 support is disabled in this build", + )); + } + } + ProxyProtocol::Vmess(config) => { + #[cfg(feature = "outbound-vmess")] + { + Arc::new(crate::outbound::vmess::VmessOutbound::with_shared_security( + config, + upstream, + security_context.as_ref().expect("VMess security context"), + resumption_sessions, + limits.tls_buffer_limit, + )?) + } + #[cfg(not(feature = "outbound-vmess"))] + { + let _ = (config, upstream); + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "VMess outbound support is disabled at build time", + )); + } + } + ProxyProtocol::Trojan(config) => { + #[cfg(feature = "outbound-trojan")] + { + Arc::new(TrojanOutbound::with_shared_security( + config, + upstream, + security_context + .as_ref() + .expect("Trojan graph has security material"), + resumption_sessions, + limits.tls_buffer_limit, + )?) + } + #[cfg(not(feature = "outbound-trojan"))] + { + let _ = (config, upstream); + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "Trojan outbound support is disabled at build time", + )); + } + } ProxyProtocol::Shadowsocks(config) => { #[cfg(feature = "outbound-shadowsocks")] { @@ -651,9 +763,7 @@ fn build_proxy_graph( #[cfg(feature = "outbound-vless")] { let download_upstream = config - .xhttp - .download - .as_ref() + .download() .map(|_| { build_upstream_path( proxy.dialer_proxy, @@ -814,10 +924,13 @@ async fn prepare_proxy_endpoints( let upload_address = proxy.address(); let upload_port = proxy.port(); let download = match &proxy.protocol { - ProxyProtocol::Vless(config) => config.xhttp.download.as_deref(), + ProxyProtocol::Vless(config) => config.download(), ProxyProtocol::Socks5(_) + | ProxyProtocol::Trojan(_) + | ProxyProtocol::Vmess(_) | ProxyProtocol::AnyTls(_) - | ProxyProtocol::Shadowsocks(_) => None, + | ProxyProtocol::Shadowsocks(_) + | ProxyProtocol::Hysteria2(_) => None, }; let (upload, download) = match download { @@ -876,13 +989,19 @@ fn restrict_endpoint_addresses( Ok(endpoint) } -#[cfg(any(feature = "outbound-anytls", feature = "outbound-vless"))] +#[cfg(any( + feature = "outbound-anytls", + feature = "outbound-vless", + feature = "outbound-trojan", + feature = "outbound-vmess", + feature = "outbound-hysteria2" +))] fn security_counts(proxies: &[ProxyConfig]) -> (usize, usize) { proxies.iter().fold( (0, 0), |(client_count, standard_count), proxy| match &proxy.protocol { ProxyProtocol::Vless(config) => { - let download = config.xhttp.download.as_deref(); + let download = config.download(); ( client_count + 1 + usize::from(download.is_some()), standard_count @@ -898,7 +1017,13 @@ fn security_counts(proxies: &[ProxyConfig]) -> (usize, usize) { }), ) } - ProxyProtocol::AnyTls(_) => (client_count + 1, standard_count + 1), + ProxyProtocol::AnyTls(_) | ProxyProtocol::Trojan(_) | ProxyProtocol::Hysteria2(_) => { + (client_count + 1, standard_count + 1) + } + ProxyProtocol::Vmess(config) => ( + client_count + 1, + standard_count + usize::from(config.tls.is_some()), + ), ProxyProtocol::Socks5(_) | ProxyProtocol::Shadowsocks(_) => { (client_count, standard_count) } @@ -906,7 +1031,13 @@ fn security_counts(proxies: &[ProxyConfig]) -> (usize, usize) { ) } -#[cfg(any(feature = "outbound-anytls", feature = "outbound-vless"))] +#[cfg(any( + feature = "outbound-anytls", + feature = "outbound-vless", + feature = "outbound-trojan", + feature = "outbound-vmess", + feature = "outbound-hysteria2" +))] fn standard_tls_resumption_sessions(standard_tls_count: usize) -> usize { if standard_tls_count == 0 || standard_tls_count > TLS_RESUMPTION_SESSION_BUDGET { 0 @@ -1037,24 +1168,26 @@ impl RunningCore { #[cfg(feature = "inbound-http")] for server in http_servers { let child = cancellation.clone(); - tasks.push(tokio::spawn(server.serve(child))); + tasks.push(crate::resources::observation::spawn(server.serve(child))); } #[cfg(feature = "inbound-socks5")] for server in socks_servers { let child = cancellation.clone(); - tasks.push(tokio::spawn(server.serve(child))); + tasks.push(crate::resources::observation::spawn(server.serve(child))); } #[cfg(all(feature = "tun", any(unix, windows)))] if let Some(tun_runtime) = tun_runtime { let child = cancellation.clone(); - tasks.push(tokio::spawn(tun_runtime.run(child))); + tasks.push(crate::resources::observation::spawn(tun_runtime.run(child))); } #[cfg(any(feature = "inbound-http", feature = "inbound-socks5"))] if let Some(controller) = controller { let child = cancellation.clone(); - tasks.push(tokio::spawn(controller.serve(child))); + tasks.push(crate::resources::observation::spawn( + controller.serve(child), + )); } #[cfg(not(any(feature = "inbound-http", feature = "inbound-socks5")))] @@ -1062,7 +1195,9 @@ impl RunningCore { if let Some(geodata_updater) = geodata_updater { let child = cancellation.clone(); - tasks.push(tokio::spawn(geodata_updater.run(child))); + tasks.push(crate::resources::observation::spawn( + geodata_updater.run(child), + )); } Ok(Self { @@ -1187,6 +1322,71 @@ mod tests { struct FixedResolver; + #[tokio::test] + async fn integration_feature_admission_is_explicit_without_opening_sockets() { + let variants = [ + ("socks5", "", cfg!(feature = "outbound-socks5")), + ( + "anytls", + ", password: fixture", + cfg!(feature = "outbound-anytls"), + ), + ( + "ss", + ", cipher: 2022-blake3-aes-128-gcm, password: BwcHBwcHBwcHBwcHBwcHBw==", + cfg!(feature = "outbound-shadowsocks"), + ), + ( + "trojan", + ", password: fixture", + cfg!(feature = "outbound-trojan"), + ), + ( + "vmess", + ", uuid: 08080808-0808-0808-0808-080808080808", + cfg!(feature = "outbound-vmess"), + ), + ( + "vless", + ", uuid: 08080808-0808-0808-0808-080808080808", + cfg!(feature = "outbound-vless"), + ), + ( + "hysteria2", + ", password: fixture", + cfg!(feature = "outbound-hysteria2"), + ), + ]; + for (protocol, fields, enabled) in variants { + let _case = crate::resources::case_events::Case::new("INTEGRATION-FEATURE", protocol); + let yaml = format!( + "socks-port: 1080\nproxies:\n - {{name: peer, type: {protocol}, server: 127.0.0.1, port: 443{fields}}}\nrules: ['MATCH,peer']\n" + ); + let result = match PreparedCore::prepare( + yaml.as_bytes(), + &FixedResolver, + ResourceLimits::default(), + ) + .await + { + Ok(core) => core + .build_proxy_graph(Dialer::default()) + .map(|_| ()) + .map_err(|e| e.to_string()), + Err(error) => Err(error.to_string()), + }; + if enabled { + assert!(result.is_ok(), "{protocol}: {result:?}"); + } else { + let error = result.unwrap_err(); + assert!( + error.contains("disabled") || error.contains("not enabled"), + "{protocol}: {error}" + ); + } + } + } + #[async_trait] impl Resolver for FixedResolver { async fn resolve(&self, host: &str, port: u16) -> io::Result { @@ -1785,9 +1985,12 @@ rules: }; config.security = crate::config::SecurityConfig::Reality(crate::config::RealityConfig { + support_x25519mlkem768: false, + client_fingerprint: None, server_name: "example.com".to_owned(), public_key: [7; 32], short_id: vec![1, 2, 3, 4], + alpn: vec![b"h2".to_vec()], }); mixed_security.push(reality); } @@ -2121,11 +2324,11 @@ rules: #[cfg(all(feature = "tun", any(unix, windows)))] fn test_checksum(bytes: &[u8]) -> u16 { let mut sum = 0_u32; - let mut chunks = bytes.chunks_exact(2); - for chunk in &mut chunks { - sum += u32::from(u16::from_be_bytes([chunk[0], chunk[1]])); + let (chunks, remainder) = bytes.as_chunks::<2>(); + for chunk in chunks { + sum += u32::from(u16::from_be_bytes(*chunk)); } - if let Some(byte) = chunks.remainder().first() { + if let Some(byte) = remainder.first() { sum += u32::from(*byte) << 8; } while sum >> 16 != 0 { diff --git a/src/security/boring.rs b/src/security/boring.rs new file mode 100644 index 0000000..9f768ed --- /dev/null +++ b/src/security/boring.rs @@ -0,0 +1,365 @@ +//! Named TLS profiles over caller-supplied IO. Certificate policy is shared with +//! rustls, not a second OpenSSL trust policy. This module never resolves or dials. +use super::boring_stream::{BoringStream, KeepOpen}; +use super::{SecurityContext, TlsClientOptions, TlsVersions}; +use crate::dispatch::BoxStream; +use boring::{ + pkey::PKey, + ssl::{ + ClientFingerprint, ConnectConfiguration, FingerprintConnector, SslAlert, SslConnector, + SslMethod, SslVerifyError, SslVerifyMode, SslVersion, + }, + x509::X509, +}; +use rustls::{ + client::danger::ServerCertVerifier, + pki_types::{CertificateDer, ServerName, UnixTime}, +}; +use std::{io, sync::Arc}; +use tokio::io::{AsyncRead, AsyncWrite}; + +#[derive(Clone)] +pub(super) struct BoringTlsClient { + connector: Connector, + server_name: String, + alpn: Vec, + required_alpn: Option>, + sessions: super::boring_resumption::Sessions, + ech: Option, + #[cfg(feature = "outbound-vless")] + reality: Option, + #[cfg(feature = "outbound-vless")] + jls: Option>, +} + +#[cfg(feature = "outbound-vless")] +struct JlsCredentials { + username: zeroize::Zeroizing>, + password: zeroize::Zeroizing>, +} + +#[derive(Clone)] +enum Connector { + #[cfg(feature = "outbound-vless")] + Default(SslConnector), + Named(FingerprintConnector), +} +impl Connector { + fn configure(&self, alpn: &[u8]) -> Result { + match self { + #[cfg(feature = "outbound-vless")] + Self::Default(connector) => { + let mut config = connector.configure()?; + config.set_alpn_protos(alpn)?; + Ok(config) + } + Self::Named(connector) => connector.configure(alpn), + } + } +} + +impl BoringTlsClient { + pub(super) fn standard( + context: &SecurityContext, + server_name: &str, + options: &TlsClientOptions, + capacity: usize, + ) -> io::Result { + let name = ServerName::try_from(server_name.to_owned()).map_err(|_| invalid())?; + let verifier = Arc::new(super::verifier::CertificateVerifier::new( + context, + options.certificate.clone(), + )?); + let mut builder = SslConnector::builder(SslMethod::tls()).map_err(|_| invalid())?; + builder + .set_min_proto_version(Some( + match if options.ech.is_some() { + TlsVersions::Tls13 + } else { + options.versions + } { + TlsVersions::Tls13 => SslVersion::TLS1_3, + TlsVersions::Tls12And13 => SslVersion::TLS1_2, + }, + )) + .map_err(|_| invalid())?; + builder + .set_max_proto_version(Some(SslVersion::TLS1_3)) + .map_err(|_| invalid())?; + let sessions = super::boring_resumption::Sessions::new( + &mut builder, + if options.ech.is_some() { 0 } else { capacity }, + )?; + let rejection_verifier = + super::verifier::CertificateVerifier::new(context, Default::default())?; + builder.set_custom_verify_callback(SslVerifyMode::PEER, move |ssl| { + let reject = || SslVerifyError::Invalid(SslAlert::BAD_CERTIFICATE); + let chain = ssl.peer_cert_chain().ok_or_else(reject)?; + let certificates = chain + .iter() + .map(|cert| cert.to_der().map(CertificateDer::from)) + .collect::, _>>() + .map_err(|_| reject())?; + let (leaf, rest) = certificates.split_first().ok_or_else(reject)?; + let outer_name = ssl.get_ech_name_override().map(|bytes| { + std::str::from_utf8(bytes) + .ok() + .and_then(|name| ServerName::try_from(name.to_owned()).ok()) + }); + let (verifier, name): (&dyn ServerCertVerifier, _) = match outer_name { + Some(Some(outer)) => (&rejection_verifier, outer), + Some(None) => return Err(reject()), + None => (verifier.as_ref(), name.clone()), + }; + verifier + .verify_server_cert( + leaf, + rest, + &name, + ssl.ocsp_status().unwrap_or_default(), + UnixTime::now(), + ) + .map(|_| ()) + .map_err(|_| reject()) + }); + if let Some(identity) = &options.identity { + let size = identity + .certificates + .iter() + .try_fold(identity.key.secret_der().len(), |size, cert| { + size.checked_add(cert.len()) + }); + if size.is_none_or(|n| n > crate::config::MAX_CONFIG_BYTES) { + return Err(invalid()); + } + let (leaf, rest) = identity.certificates.split_first().ok_or_else(invalid)?; + let leaf = X509::from_der(leaf).map_err(|_| invalid())?; + builder.set_certificate(&leaf).map_err(|_| invalid())?; + for cert in rest { + builder + .add_extra_chain_cert(X509::from_der(cert).map_err(|_| invalid())?) + .map_err(|_| invalid())?; + } + let key = + PKey::private_key_from_der(identity.key.secret_der()).map_err(|_| invalid())?; + builder.set_private_key(&key).map_err(|_| invalid())?; + builder.check_private_key().map_err(|_| invalid())?; + } + let profile = profile(options.client_fingerprint.ok_or_else(invalid)?); + let connector = FingerprintConnector::new(builder, profile).map_err(|_| invalid())?; + let alpn = wire_alpn(&options.alpn)?; + Ok(Self { + connector: Connector::Named(connector), + server_name: server_name.to_owned(), + alpn, + required_alpn: options.required_alpn.clone(), + sessions, + ech: options.ech.clone(), + #[cfg(feature = "outbound-vless")] + reality: None, + #[cfg(feature = "outbound-vless")] + jls: None, + }) + } + + #[cfg(feature = "outbound-vless")] + pub(super) fn reality(config: &crate::config::RealityConfig) -> io::Result { + config.validate_fingerprint().map_err(|_| invalid())?; + ServerName::try_from(config.server_name.to_owned()).map_err(|_| invalid())?; + let mut builder = SslConnector::builder(SslMethod::tls()).map_err(|_| invalid())?; + builder + // A named REALITY hello preserves the template's TLS1.2 fields, + // as Mihomo does. Native REALITY independently rejects every + // pre-TLS1.3 ServerHello before certificate/application processing. + .set_min_proto_version(Some(if config.client_fingerprint.is_some() { + SslVersion::TLS1_2 + } else { + SslVersion::TLS1_3 + })) + .map_err(|_| invalid())?; + builder + .set_max_proto_version(Some(SslVersion::TLS1_3)) + .map_err(|_| invalid())?; + builder + .set_curves_list(if config.support_x25519mlkem768 { + "X25519MLKEM768" + } else { + "X25519" + }) + .map_err(|_| invalid())?; + let sessions = super::boring_resumption::Sessions::new(&mut builder, 0)?; + let connector = match config.client_fingerprint { + Some(value) => Connector::Named( + FingerprintConnector::new(builder, profile(value)).map_err(|_| invalid())?, + ), + None => Connector::Default(builder.build()), + }; + let reality = boring::ssl::RealityClientConfig::new( + config.public_key, + &config.short_id, + super::REALITY_CLIENT_VERSION, + ) + .map_err(|_| invalid())?; + let reality = if config.support_x25519mlkem768 { + reality.require_x25519mlkem768() + } else { + reality + }; + Ok(Self { + connector, + server_name: config.server_name.clone(), + alpn: wire_alpn(&config.alpn)?, + // REALITY peers can omit negotiated ALPN. XHTTP retains the + // configured HTTP version; no ordinary PKI fallback is permitted. + required_alpn: None, + sessions, + ech: None, + reality: Some(reality), + jls: None, + }) + } + + #[cfg(feature = "outbound-vless")] + pub(super) fn jls(config: &crate::config::JlsConfig) -> io::Result { + config.validate().map_err(|_| invalid())?; + ServerName::try_from(config.tls.server_name.to_owned()).map_err(|_| invalid())?; + let mut builder = SslConnector::builder(SslMethod::tls()).map_err(|_| invalid())?; + // Retain the advertised template floor. Native JLS independently + // requires an authenticated TLS 1.3 result, including CV and Finished. + builder + .set_min_proto_version(Some(SslVersion::TLS1_2)) + .map_err(|_| invalid())?; + builder + .set_max_proto_version(Some(SslVersion::TLS1_3)) + .map_err(|_| invalid())?; + let sessions = super::boring_resumption::Sessions::new(&mut builder, 0)?; + let connector = match config.tls.client_fingerprint { + Some(value) => Connector::Named( + FingerprintConnector::new(builder, profile(value)).map_err(|_| invalid())?, + ), + None => Connector::Default(builder.build()), + }; + Ok(Self { + connector, + server_name: config.tls.server_name.clone(), + alpn: wire_alpn(&config.tls.alpn)?, + required_alpn: config.tls.required_alpn.clone(), + sessions, + ech: None, + reality: None, + jls: Some(Arc::new(JlsCredentials { + username: config.username.as_bytes().to_vec().into(), + password: config.password.as_bytes().to_vec().into(), + })), + }) + } + + pub(super) async fn connect( + &self, + stream: BoxStream, + buffer_limit: usize, + ) -> io::Result { + Ok(Box::new(BoringStream::new( + self.handshake(stream).await?, + buffer_limit, + ))) + } + + #[cfg(feature = "outbound-vless")] + pub(super) async fn connect_vision( + &self, + stream: BoxStream, + stats: Arc, + buffer_limit: usize, + ) -> io::Result<(BoxStream, super::vision::SpliceControl)> { + let tls = self.handshake(super::vision::RecordIo::new(stream)).await?; + super::vision::BoringSplice::wrap(tls, stats, buffer_limit) + } + + async fn handshake( + &self, + stream: S, + ) -> io::Result>> { + let mut config = self + .connector + .configure(&self.alpn) + .map_err(|_| invalid())?; + // The shared WebPKI verifier owns name checking, including independent + // verification names and pin precedence; wire SNI still uses server_name. + config.set_verify_hostname(false); + let sink = self.sessions.configure(&mut config)?; + if let Some(ech) = &self.ech { + config + .set_ech_config_list(ech.as_bytes()) + .map_err(|_| invalid())?; + } + #[cfg(feature = "outbound-vless")] + if let Some(reality) = &self.reality { + config.set_reality_client(reality).map_err(|_| invalid())?; + } + #[cfg(feature = "outbound-vless")] + if let Some(jls) = &self.jls { + config + .set_jls_client(&jls.username, &jls.password) + .map_err(|_| invalid())?; + } + let tls = tokio_boring::connect(config, &self.server_name, KeepOpen(stream)) + .await + .map_err(|_| io::Error::other("TLS handshake failed"))?; + if self.ech.is_some() && !tls.ssl().ech_accepted() { + return Err(io::Error::other("TLS ECH was not accepted")); + } + if self + .required_alpn + .as_deref() + .is_some_and(|required| tls.ssl().selected_alpn_protocol() != Some(required)) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "TLS server did not negotiate the required ALPN", + )); + } + // The HTTP drivers use on-wire SETTINGS and do not import ALPS into + // their initial state. Never silently accept nonempty peer settings. + if tls + .ssl() + .peer_application_settings() + .is_some_and(|settings| !settings.is_empty()) + { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "nonempty TLS ALPS settings are not supported", + )); + } + sink.accept()?; + Ok(tls) + } +} + +fn profile(value: crate::config::ClientFingerprint) -> ClientFingerprint { + match value { + crate::config::ClientFingerprint::Chrome120 => ClientFingerprint::Chrome120, + crate::config::ClientFingerprint::Chrome133 => ClientFingerprint::Chrome133, + crate::config::ClientFingerprint::Firefox120 => ClientFingerprint::Firefox120, + crate::config::ClientFingerprint::Safari16 => ClientFingerprint::Safari16, + } +} + +fn wire_alpn(protocols: &[Vec]) -> io::Result> { + let mut wire = Vec::new(); + for protocol in protocols { + if protocol.is_empty() || protocol.len() > 255 || wire.len() + 1 + protocol.len() > 65_533 { + return Err(invalid()); + } + wire.push(protocol.len() as u8); + wire.extend_from_slice(protocol); + } + Ok(wire) +} + +fn invalid() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + "invalid fingerprinted TLS policy", + ) +} diff --git a/src/security/boring_resumption.rs b/src/security/boring_resumption.rs new file mode 100644 index 0000000..096ba2c --- /dev/null +++ b/src/security/boring_resumption.rs @@ -0,0 +1,146 @@ +//! Node-owned tickets. No native/global cache or cross-policy ticket import. +use boring::{ + ex_data::Index, + ssl::{ + ConnectConfiguration, Ssl, SslConnectorBuilder, SslSession, SslSessionCacheMode, SslVersion, + }, +}; +use rustls::pki_types::UnixTime; +use std::{ + collections::VecDeque, + io, + sync::{Arc, LazyLock, Mutex}, +}; + +type SessionIndex = Index>; +// Only the slot number is global, never the tickets or a verification policy. +static SESSION_INDEX: LazyLock> = + LazyLock::new(Ssl::new_ex_index); + +#[derive(Clone)] +pub(super) struct Sessions { + queue: Arc>>, + capacity: usize, + index: SessionIndex, +} + +impl Sessions { + pub(super) fn new(builder: &mut SslConnectorBuilder, capacity: usize) -> io::Result { + if capacity > super::TLS_RESUMPTION_SESSION_BUDGET { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "TLS ticket capacity exceeds the runtime budget", + )); + } + let index = *SESSION_INDEX.as_ref().map_err(|_| cache_error())?; + if capacity == 0 { + builder.set_session_cache_mode(SslSessionCacheMode::OFF); + } else { + builder.set_session_cache_mode( + SslSessionCacheMode::CLIENT | SslSessionCacheMode::NO_INTERNAL, + ); + builder.set_new_session_callback(move |ssl, session| { + if let Some(sink) = ssl.ex_data(index) { + sink.store(session); + } + }); + } + Ok(Self { + queue: Arc::default(), + capacity, + index, + }) + } + + pub(super) fn configure( + &self, + config: &mut ConnectConfiguration, + ) -> io::Result> { + let sink = Arc::new(SessionSink { + cache: self.clone(), + state: Mutex::new(State { + accepted: false, + pending: VecDeque::new(), + }), + }); + config.set_ex_data(self.index, sink.clone()); + let session = { + let now = UnixTime::now().as_secs(); + let mut queue = self.queue.lock().map_err(|_| cache_error())?; + queue.retain(|session| { + let hint = session.ticket_lifetime_hint(); + let lifetime = if hint == 0 { + session.timeout() + } else { + session.timeout().min(hint) + }; + now < session.time().saturating_add(u64::from(lifetime)) + }); + if queue + .back() + .is_some_and(|s| s.protocol_version() == SslVersion::TLS1_2) + { + // TLS 1.2 abbreviated handshakes need not issue another ticket. + queue.back().cloned() + } else { + // A TLS 1.3 ticket is consumed once. + queue.pop_back() + } + }; + if let Some(session) = session { + // SAFETY: Only this immutable client's SSL_CTX can populate its + // cache. A cloned client shares that exact context and policy. + unsafe { config.set_session(&session) }.map_err(|_| cache_error())?; + } + Ok(sink) + } +} + +struct State { + accepted: bool, + pending: VecDeque, +} + +pub(super) struct SessionSink { + cache: Sessions, + state: Mutex, +} + +impl SessionSink { + fn store(&self, session: SslSession) { + // Native callbacks must not panic, including after a poisoned lock. + let Ok(mut state) = self.state.lock() else { + return; + }; + if state.accepted { + if let Ok(mut queue) = self.cache.queue.lock() { + bounded_push(&mut queue, session, self.cache.capacity); + } + } else { + bounded_push(&mut state.pending, session, self.cache.capacity); + } + } + + pub(super) fn accept(&self) -> io::Result<()> { + let mut state = self.state.lock().map_err(|_| cache_error())?; + let mut queue = self.cache.queue.lock().map_err(|_| cache_error())?; + state.accepted = true; + for session in state.pending.drain(..) { + bounded_push(&mut queue, session, self.cache.capacity); + } + Ok(()) + } +} + +fn bounded_push(queue: &mut VecDeque, session: SslSession, capacity: usize) { + if capacity != 0 { + while queue.len() >= capacity { + queue.pop_front(); + } + queue.push_back(session); + } +} + +fn cache_error() -> io::Error { + io::Error::other("TLS session cache unavailable") +} diff --git a/src/security/boring_stream.rs b/src/security/boring_stream.rs new file mode 100644 index 0000000..008c709 --- /dev/null +++ b/src/security/boring_stream.rs @@ -0,0 +1,110 @@ +//! BoringSSL CloseWrite over caller-owned IO. Native shutdown must never +//! propagate FIN to the underlay or wait for the peer's close_notify. +use std::{ + future::Future, + io, + pin::Pin, + task::{Context, Poll}, +}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + +pub(super) struct KeepOpen(pub(super) S); +impl AsyncRead for KeepOpen { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.0).poll_read(cx, buf) + } +} +impl AsyncWrite for KeepOpen { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.0).poll_write(cx, buf) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_flush(cx) + } + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + // Flush separately, so a pending flush cannot cause SSL_shutdown to be + // called twice (the second call could wait for the peer's alert). + Poll::Ready(Ok(())) + } +} + +pub(super) struct BoringStream { + pub(super) inner: tokio_boring::SslStream>, + closing: Option>>, + alert_sent: bool, + closed: bool, + write_limit: usize, +} +impl BoringStream { + pub(super) fn new(inner: tokio_boring::SslStream>, buffer_limit: usize) -> Self { + Self { + inner, + closing: None, + alert_sent: false, + closed: false, + write_limit: buffer_limit.min(16 * 1024), + } + } +} +impl AsyncRead for BoringStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} +impl AsyncWrite for BoringStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + if self.closing.is_some() { + return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into())); + } + let n = buf.len().min(self.write_limit); + Pin::new(&mut self.inner).poll_write(cx, &buf[..n]) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.closed { + return Poll::Ready(Ok(())); + } + if self.closing.is_none() { + self.closing = Some(Box::pin(tokio::time::sleep(super::CLOSE_NOTIFY_TIMEOUT))); + } + if !self.alert_sent { + match Pin::new(&mut self.inner).poll_shutdown(cx) { + Poll::Ready(Ok(())) => self.alert_sent = true, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Pending => {} + } + } + if self.alert_sent { + match Pin::new(&mut self.inner).poll_flush(cx) { + Poll::Ready(Ok(())) => { + self.closed = true; + return Poll::Ready(Ok(())); + } + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Pending => {} + } + } + if self.closing.as_mut().unwrap().as_mut().poll(cx).is_ready() { + return Poll::Ready(Err(io::ErrorKind::TimedOut.into())); + } + Poll::Pending + } +} diff --git a/src/security/client.rs b/src/security/client.rs index 485d6c7..5036f92 100644 --- a/src/security/client.rs +++ b/src/security/client.rs @@ -1,14 +1,8 @@ use std::{io, sync::Arc}; +use rustls::ClientConfig; #[cfg(feature = "interop-test")] use rustls::RootCertStore; -use rustls::{ - ClientConfig, - client::{RealityClientConfig, Resumption}, - pki_types::ServerName, - version::TLS13, -}; -use tokio_rustls::TlsConnector; use crate::{ config::{SecurityConfig, VlessOutboundConfig}, @@ -18,8 +12,8 @@ use crate::{ use super::{ SecurityContext, tls::{ - DEFAULT_TLS_BUFFER_LIMIT, StandardTlsClient, StandardTlsProfile, - TLS_RESUMPTION_SESSION_BUDGET, + DEFAULT_TLS_BUFFER_LIMIT, StandardTlsClient, TLS_RESUMPTION_SESSION_BUDGET, + TlsClientOptions, TlsVersions, }, }; @@ -31,10 +25,14 @@ pub const REALITY_CLIENT_VERSION: [u8; 3] = [26, 7, 11]; #[derive(Clone)] enum SecurityBackend { + Plain, Standard(StandardTlsClient), Reality { - connector: TlsConnector, - server_name: String, + client: super::boring::BoringTlsClient, + buffer_limit: usize, + }, + Jls { + client: super::boring::BoringTlsClient, buffer_limit: usize, }, } @@ -47,17 +45,17 @@ pub struct SecurityClient { impl std::fmt::Debug for SecurityClient { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.backend { + SecurityBackend::Plain => formatter.write_str("SecurityClient::Plain"), SecurityBackend::Standard(client) => formatter .debug_tuple("SecurityClient::Standard") .field(client) .finish(), - SecurityBackend::Reality { - server_name, - buffer_limit, - .. - } => formatter + SecurityBackend::Reality { buffer_limit, .. } => formatter .debug_struct("SecurityClient::Reality") - .field("server_name", server_name) + .field("buffer_limit", buffer_limit) + .finish_non_exhaustive(), + SecurityBackend::Jls { buffer_limit, .. } => formatter + .debug_struct("SecurityClient::Jls") .field("buffer_limit", buffer_limit) .finish_non_exhaustive(), } @@ -65,7 +63,17 @@ impl std::fmt::Debug for SecurityClient { } impl SecurityClient { - /// Builds one TLS or REALITY transport leg from its normalized security + pub(crate) fn quic_config(&self) -> io::Result<(Arc, String)> { + match &self.backend { + SecurityBackend::Standard(client) => client.quic_config(), + _ => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "HTTP/3 requires standard TLS", + )), + } + } + + /// Builds one TLS, REALITY or JLS transport leg from its normalized security /// configuration. A VLESS XHTTP download leg may use security settings /// distinct from the enclosing proxy's primary leg. pub fn from_security(config: &SecurityConfig) -> io::Result { @@ -85,8 +93,8 @@ impl SecurityClient { /// Builds one node from instance-shared cryptographic material. The /// caller allocates the aggregate TLS resumption budget across standard-TLS /// nodes; zero disables resumption when the fixed four-session runtime - /// budget cannot provide a slot for every node. REALITY always ignores the - /// budget because its resumption policy is disabled. + /// budget cannot provide a slot for every node. REALITY and JLS ignore the + /// budget because their resumption policies are disabled. pub(crate) fn from_proxy_with_context( config: &VlessOutboundConfig, context: &SecurityContext, @@ -171,37 +179,43 @@ impl SecurityClient { )); } let backend = match config { - SecurityConfig::Tls(tls) => SecurityBackend::Standard(StandardTlsClient::new( + SecurityConfig::None => SecurityBackend::Plain, + SecurityConfig::Tls(tls) => SecurityBackend::Standard(StandardTlsClient::with_options( context, &tls.server_name, - StandardTlsProfile::VlessXhttp, + TlsClientOptions { + ech: tls.ech.clone(), + client_fingerprint: tls.client_fingerprint, + versions: if tls.tls13_only { + TlsVersions::Tls13 + } else { + TlsVersions::Tls12And13 + }, + alpn: tls.alpn.clone(), + required_alpn: tls.required_alpn.clone(), + certificate: tls.certificate.clone(), + identity: tls + .identity + .as_ref() + .map(|identity| { + super::TlsClientIdentity::from_pem( + &identity.certificate, + &identity.private_key, + ) + }) + .transpose()?, + }, resumption_sessions, buffer_limit, )?), - SecurityConfig::Reality(reality) => { - let reality = RealityClientConfig::new( - reality.public_key, - &reality.short_id, - REALITY_CLIENT_VERSION, - ) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; - let mut tls_config = ClientConfig::builder_with_provider(context.provider.clone()) - .with_protocol_versions(&[&TLS13]) - .map_err(io_other)? - .with_reality(reality) - .map_err(io_other)? - .with_no_client_auth(); - tls_config.resumption = Resumption::disabled(); - tls_config.enable_early_data = false; - // Xray REALITY does not echo ALPN, so XHTTP deliberately starts - // h2 even when negotiated ALPN is nil. - tls_config.alpn_protocols = vec![b"h2".to_vec()]; - SecurityBackend::Reality { - connector: TlsConnector::from(Arc::new(tls_config)), - server_name: config.server_name().to_owned(), - buffer_limit, - } - } + SecurityConfig::Reality(reality) => SecurityBackend::Reality { + client: super::boring::BoringTlsClient::reality(reality)?, + buffer_limit, + }, + SecurityConfig::Jls(jls) => SecurityBackend::Jls { + client: super::boring::BoringTlsClient::jls(jls)?, + buffer_limit, + }, }; Ok(Self { backend }) @@ -209,38 +223,52 @@ impl SecurityClient { pub async fn connect(&self, stream: BoxStream) -> io::Result { match &self.backend { + SecurityBackend::Plain => Ok(stream), SecurityBackend::Standard(client) => client.connect(stream).await, SecurityBackend::Reality { - connector, - server_name, + client, buffer_limit, - } => { - let server_name = ServerName::try_from(server_name.clone()) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; - let tls = connector - .connect_with(server_name, stream, |connection| { - connection.set_buffer_limit(Some(*buffer_limit)); - }) - .await - .map_err(io_other)?; - Ok(Box::new(tls)) } + | SecurityBackend::Jls { + client, + buffer_limit, + } => client.connect(stream, *buffer_limit).await, + } + } + + pub(crate) async fn connect_vision( + &self, + stream: BoxStream, + stats: Arc, + ) -> io::Result<(BoxStream, super::vision::SpliceControl)> { + match &self.backend { + SecurityBackend::Plain => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Vision requires TLS", + )), + SecurityBackend::Jls { .. } => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Vision cannot use JLS", + )), + SecurityBackend::Standard(client) => client.connect_vision(stream, stats).await, + SecurityBackend::Reality { + client, + buffer_limit, + } => client.connect_vision(stream, stats, *buffer_limit).await, } } #[cfg(test)] const fn buffer_limit(&self) -> usize { match &self.backend { + SecurityBackend::Plain => 0, SecurityBackend::Standard(client) => client.buffer_limit(), - SecurityBackend::Reality { buffer_limit, .. } => *buffer_limit, + SecurityBackend::Reality { buffer_limit, .. } + | SecurityBackend::Jls { buffer_limit, .. } => *buffer_limit, } } } -fn io_other(error: impl std::error::Error + Send + Sync + 'static) -> io::Error { - io::Error::other(error) -} - #[cfg(test)] mod tests { use super::*; @@ -253,15 +281,19 @@ mod tests { id: uuid::Uuid::parse_str("b831381d-6324-4d53-ad4f-8cda48b30811").unwrap(), encryption: VlessEncryption::None, flow: String::new(), - security: SecurityConfig::Tls(TlsConfig { - server_name: "example.com".to_owned(), - }), - xhttp: XHttpConfig { + security: SecurityConfig::Tls(TlsConfig::xhttp("example.com".to_owned())), + transport: crate::config::VlessTransport::Xhttp(Box::new(XHttpConfig { path: "/xhttp".to_owned(), host: "example.com".to_owned(), mode: XHttpMode::StreamOne, + http_version: Default::default(), + reuse: None, + headers: Default::default(), + request: Default::default(), download: None, - }, + })), + packet_encoding: crate::config::VlessPacketEncoding::Xudp, + stream_options: Default::default(), } } @@ -298,9 +330,12 @@ mod tests { #[test] fn security_level_constructor_keeps_an_independent_reality_identity() { let security = SecurityConfig::Reality(crate::config::RealityConfig { + support_x25519mlkem768: false, + client_fingerprint: None, server_name: "download.example.com".to_owned(), public_key: [7; 32], short_id: vec![1, 2, 3, 4], + alpn: vec![b"h2".to_vec()], }); let client = SecurityClient::from_security_with_context( &security, @@ -309,15 +344,10 @@ mod tests { 12 * 1024, ) .unwrap(); - let SecurityBackend::Reality { - server_name, - buffer_limit, - .. - } = &client.backend - else { + let SecurityBackend::Reality { buffer_limit, .. } = &client.backend else { panic!("REALITY security must build the REALITY backend") }; - assert_eq!(server_name, "download.example.com"); assert_eq!(*buffer_limit, 12 * 1024); + assert!(!format!("{client:?}").contains("download.example.com")); } } diff --git a/src/security/ech.rs b/src/security/ech.rs new file mode 100644 index 0000000..bb926d9 --- /dev/null +++ b/src/security/ech.rs @@ -0,0 +1,127 @@ +//! Adapter for official Rust HPKE through rustls's public provider seam. The +//! wire enum paths are publicly exported but internal to rustls: keep them here. +use hpke::{Deserializable, Kem as _, Serializable, aead::Aead}; +use rand::SeedableRng; +use rustls::internal::msgs::{ + enums::{HpkeAead, HpkeKdf, HpkeKem}, + handshake::HpkeSymmetricCipherSuite, +}; +use rustls::{ + Error, + crypto::hpke::{ + EncapsulatedSecret, Hpke, HpkeOpener, HpkePrivateKey, HpkePublicKey, HpkeSealer, HpkeSuite, + }, +}; +use std::marker::PhantomData; + +type Kem = hpke::kem::X25519HkdfSha256; +type Kdf = hpke::kdf::HkdfSha256; + +static AES128: Adapter = Adapter(PhantomData); +static AES256: Adapter = Adapter(PhantomData); +static CHACHA: Adapter = Adapter(PhantomData); +pub(super) static SUITES: &[&dyn Hpke] = &[&AES128, &AES256, &CHACHA]; + +struct Adapter(PhantomData); +struct Sender(hpke::aead::AeadCtxS); +struct Receiver(hpke::aead::AeadCtxR); +impl std::fmt::Debug for Adapter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("HPKE adapter") + } +} +impl std::fmt::Debug for Sender { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("HPKE sender") + } +} +impl std::fmt::Debug for Receiver { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("HPKE receiver") + } +} +fn error(_: hpke::HpkeError) -> Error { + Error::General("HPKE operation failed".into()) +} +fn rng() -> Result { + rand::rngs::StdRng::try_from_rng(&mut rand::rngs::SysRng) + .map_err(|_| Error::General("HPKE randomness unavailable".into())) +} +impl HpkeSealer for Sender { + fn seal(&mut self, aad: &[u8], plaintext: &[u8]) -> Result, Error> { + self.0.seal(plaintext, aad).map_err(error) + } +} +impl HpkeOpener for Receiver { + fn open(&mut self, aad: &[u8], ciphertext: &[u8]) -> Result, Error> { + self.0.open(ciphertext, aad).map_err(error) + } +} +impl Hpke for Adapter { + fn suite(&self) -> HpkeSuite { + HpkeSuite { + kem: HpkeKem::DHKEM_X25519_HKDF_SHA256, + sym: HpkeSymmetricCipherSuite { + kdf_id: HpkeKdf::HKDF_SHA256, + aead_id: HpkeAead::from(A::AEAD_ID), + }, + } + } + fn generate_key_pair(&self) -> Result<(HpkePublicKey, HpkePrivateKey), Error> { + let (sk, pk) = Kem::gen_keypair_with_rng(&mut rng()?); + Ok(( + HpkePublicKey(pk.to_bytes().to_vec()), + sk.to_bytes().to_vec().into(), + )) + } + fn setup_sealer( + &self, + info: &[u8], + key: &HpkePublicKey, + ) -> Result<(EncapsulatedSecret, Box), Error> { + let pk = ::PublicKey::from_bytes(&key.0).map_err(error)?; + let (enc, ctx) = hpke::setup_sender_with_rng::( + &hpke::OpModeS::Base, + &pk, + info, + &mut rng()?, + ) + .map_err(error)?; + Ok(( + EncapsulatedSecret(enc.to_bytes().to_vec()), + Box::new(Sender(ctx)), + )) + } + fn setup_opener( + &self, + enc: &EncapsulatedSecret, + info: &[u8], + key: &HpkePrivateKey, + ) -> Result, Error> { + let sk = ::PrivateKey::from_bytes(key.secret_bytes()).map_err(error)?; + let enc = ::EncappedKey::from_bytes(&enc.0).map_err(error)?; + let ctx = hpke::setup_receiver::(&hpke::OpModeR::Base, &sk, &enc, info) + .map_err(error)?; + Ok(Box::new(Receiver(ctx))) + } + fn seal( + &self, + info: &[u8], + aad: &[u8], + plaintext: &[u8], + key: &HpkePublicKey, + ) -> Result<(EncapsulatedSecret, Vec), Error> { + let (enc, mut ctx) = self.setup_sealer(info, key)?; + Ok((enc, ctx.seal(aad, plaintext)?)) + } + fn open( + &self, + enc: &EncapsulatedSecret, + info: &[u8], + aad: &[u8], + ciphertext: &[u8], + key: &HpkePrivateKey, + ) -> Result, Error> { + self.setup_opener(enc, info, key)?.open(aad, ciphertext) + } +} diff --git a/src/security/mod.rs b/src/security/mod.rs index 68b9834..47abdaa 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -1,15 +1,47 @@ //! TLS and REALITY security layers used by outbound transports. -#[cfg(feature = "outbound-anytls")] -mod anytls_verifier; +#[cfg(feature = "tls-fingerprint")] +mod boring; +#[cfg(feature = "tls-fingerprint")] +mod boring_resumption; +#[cfg(feature = "tls-fingerprint")] +mod boring_stream; #[cfg(feature = "outbound-vless")] mod client; mod context; +#[cfg(feature = "outbound-vless")] +mod ech; mod resumption; +mod stream; mod tls; +mod verifier; +#[cfg(feature = "outbound-vless")] +pub(crate) mod vision; #[cfg(feature = "outbound-vless")] pub use client::{REALITY_CLIENT_VERSION, SecurityClient}; pub use context::SecurityContext; -pub(crate) use tls::StandardTlsClient; -pub use tls::TLS_RESUMPTION_SESSION_BUDGET; +pub use stream::CLOSE_NOTIFY_TIMEOUT; +pub use tls::{ + StandardTlsClient, TLS_RESUMPTION_SESSION_BUDGET, TlsCertificatePolicy, TlsClientIdentity, + TlsClientOptions, TlsVersions, +}; + +#[cfg(test)] +fn test_profiles() -> &'static [Option] { + #[cfg(feature = "tls-fingerprint")] + { + use crate::config::ClientFingerprint::*; + &[ + None, + Some(Chrome120), + Some(Chrome133), + Some(Firefox120), + Some(Safari16), + ] + } + #[cfg(not(feature = "tls-fingerprint"))] + { + &[None] + } +} diff --git a/src/security/stream.rs b/src/security/stream.rs new file mode 100644 index 0000000..df8cf32 --- /dev/null +++ b/src/security/stream.rs @@ -0,0 +1,71 @@ +//! TLS CloseWrite follows Mihomo: flush close_notify, keep the supplied +//! transport/read direction alive. Drop still owns the entire connection. +use crate::dispatch::BoxStream; +use std::{ + future::Future, + io, + pin::Pin, + task::{Context, Poll}, + time::Duration, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite, ReadBuf}, + time::Sleep, +}; + +pub const CLOSE_NOTIFY_TIMEOUT: Duration = Duration::from_secs(5); + +pub(super) struct TlsStream { + inner: tokio_rustls::client::TlsStream, + closing: Option>>, +} + +impl TlsStream { + pub(super) fn new(inner: tokio_rustls::client::TlsStream) -> Self { + Self { + inner, + closing: None, + } + } +} + +impl AsyncRead for TlsStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for TlsStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + if self.closing.is_some() { + return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into())); + } + Pin::new(&mut self.inner).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.closing.is_none() { + self.inner.get_mut().1.send_close_notify(); + self.closing = Some(Box::pin(tokio::time::sleep(CLOSE_NOTIFY_TIMEOUT))); + } + if let Poll::Ready(result) = Pin::new(&mut self.inner).poll_flush(cx) { + return Poll::Ready(result); + } + if self.closing.as_mut().unwrap().as_mut().poll(cx).is_ready() { + return Poll::Ready(Err(io::ErrorKind::TimedOut.into())); + } + Poll::Pending + } +} diff --git a/src/security/tls.rs b/src/security/tls.rs index 89a802d..7b49b1c 100644 --- a/src/security/tls.rs +++ b/src/security/tls.rs @@ -3,7 +3,7 @@ use std::{io, sync::Arc}; use rustls::{ ClientConfig, client::Resumption, - pki_types::ServerName, + pki_types::{CertificateDer, PrivateKeyDer, ServerName}, version::{TLS12, TLS13}, }; use tokio_rustls::TlsConnector; @@ -18,39 +18,150 @@ pub(crate) const DEFAULT_TLS_BUFFER_LIMIT: usize = 64 * 1024; /// REALITY disables resumption and does not consume this budget. pub const TLS_RESUMPTION_SESSION_BUDGET: usize = 4; +pub use crate::config::TlsCertificatePolicy; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum TlsVersions { + #[default] + Tls12And13, + Tls13, +} + +/// Immutable per-node, per-transport TLS options. Constructing another client +/// always creates an independent resumption store, even for the same SNI. +#[derive(Default)] +pub struct TlsClientOptions { + pub ech: Option, + pub client_fingerprint: Option, + pub versions: TlsVersions, + pub alpn: Vec>, + pub required_alpn: Option>, + pub certificate: TlsCertificatePolicy, + pub identity: Option, +} + +impl std::fmt::Debug for TlsClientOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TlsClientOptions") + .field("client_fingerprint", &self.client_fingerprint) + .field("versions", &self.versions) + .field("alpn_count", &self.alpn.len()) + .field("requires_alpn", &self.required_alpn.is_some()) + .field("certificate", &self.certificate) + .field("client_identity", &self.identity.is_some()) + .field("ech", &self.ech.is_some()) + .finish() + } +} + +/// A node-owned certificate chain and matching key. The provider validates the +/// key pair while constructing the client, before any supplied stream is used. +/// No file loading, environment key logging or dynamic identity reload occurs. +pub struct TlsClientIdentity { + pub(super) certificates: Vec>, + pub(super) key: PrivateKeyDer<'static>, +} + +impl TlsClientIdentity { + pub fn from_pem(certificate: &str, private_key: &str) -> io::Result { + use rustls::pki_types::pem::PemObject; + let invalid = || io::Error::new(io::ErrorKind::InvalidInput, "invalid TLS client identity"); + if certificate.len().saturating_add(private_key.len()) > crate::config::MAX_CONFIG_BYTES { + return Err(invalid()); + } + let certificates = CertificateDer::pem_slice_iter(certificate.as_bytes()) + .collect::, _>>() + .map_err(|_| invalid())?; + let mut keys = PrivateKeyDer::pem_slice_iter(private_key.as_bytes()); + let key = keys.next().ok_or_else(invalid)?.map_err(|_| invalid())?; + if keys.next().is_some() || certificates.is_empty() { + return Err(invalid()); + } + rustls::sign::CertifiedKey::from_der( + certificates.clone(), + key.clone_key(), + &rustls::crypto::ring::default_provider(), + ) + .map_err(|_| invalid())?; + Ok(Self { certificates, key }) + } + + pub fn from_der( + certificates: Vec>, + key: PrivateKeyDer<'static>, + ) -> Self { + Self { certificates, key } + } +} + +impl std::fmt::Debug for TlsClientIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TlsClientIdentity").finish_non_exhaustive() + } +} + /// TLS protocol policy for a standard WebPKI client. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg(test)] pub(crate) enum StandardTlsProfile { /// VLESS XHTTP is TLS 1.3 and HTTP/2 only. VlessXhttp, - /// AnyTLS uses TLS 1.2 or 1.3 with an optional protocol-specific policy. - AnyTls, } /// Reusable standard TLS client shared by protocol-specific outbound code. /// -/// REALITY remains in [`super::SecurityClient`] because it uses the local -/// rustls fork's distinct verifier and session policy. +/// REALITY remains in [`super::SecurityClient`] with a distinct authentication +/// and session policy. Ordinary unprofiled TLS and QUIC continue to use rustls. #[derive(Clone)] -pub(crate) struct StandardTlsClient { - connector: TlsConnector, +pub struct StandardTlsClient { + connector: StandardConnector, server_name: String, - required_alpn: Option<&'static [u8]>, + required_alpn: Option>, buffer_limit: usize, + require_ech: bool, +} + +#[derive(Clone)] +enum StandardConnector { + Rustls(TlsConnector), + #[cfg(feature = "tls-fingerprint")] + Boring(super::boring::BoringTlsClient), } impl std::fmt::Debug for StandardTlsClient { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("StandardTlsClient") - .field("server_name", &self.server_name) - .field("required_alpn", &self.required_alpn) + .field("requires_alpn", &self.required_alpn.is_some()) .field("buffer_limit", &self.buffer_limit) .finish_non_exhaustive() } } impl StandardTlsClient { + #[cfg(any(feature = "outbound-vless", feature = "outbound-hysteria2"))] + pub(crate) fn quic_config(&self) -> io::Result<(Arc, String)> { + match &self.connector { + StandardConnector::Rustls(connector) => { + // XHTTP validates its exclusive h3 policy before construction. + // Hysteria2 also uses H3 but permits a custom negotiated ALPN. + if connector.config().alpn_protocols.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "QUIC requires ALPN", + )); + } + Ok((connector.config().clone(), self.server_name.clone())) + } + #[cfg(feature = "tls-fingerprint")] + StandardConnector::Boring(_) => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "client-fingerprint is not supported on QUIC", + )), + } + } + + #[cfg(test)] pub(crate) fn new( context: &SecurityContext, server_name: impl Into, @@ -58,13 +169,20 @@ impl StandardTlsClient { resumption_sessions: usize, buffer_limit: usize, ) -> io::Result { - Self::build( + let options = match profile { + StandardTlsProfile::VlessXhttp => TlsClientOptions { + versions: TlsVersions::Tls13, + alpn: vec![b"h2".to_vec()], + required_alpn: Some(b"h2".to_vec()), + ..Default::default() + }, + }; + Self::with_options( context, - server_name.into(), - profile, + server_name, + options, resumption_sessions, buffer_limit, - None, ) } @@ -76,87 +194,195 @@ impl StandardTlsClient { resumption_sessions: usize, buffer_limit: usize, ) -> io::Result { - Self::build( + Self::with_options( context, - server_name.into(), - StandardTlsProfile::AnyTls, + server_name, + TlsClientOptions { + alpn: policy.alpn.clone(), + client_fingerprint: policy.client_fingerprint, + certificate: TlsCertificatePolicy { + verification_name: None, + skip_cert_verify: policy.skip_cert_verify, + fingerprint: policy.fingerprint, + }, + ..Default::default() + }, resumption_sessions, buffer_limit, - Some(policy), ) } - fn build( + /// Wraps caller-supplied streams only: no socket, resolver, background task + /// or unbounded shared cache is created here. The graph allocates ticket + /// capacity from its existing aggregate budget; zero disables resumption. + pub fn with_options( context: &SecurityContext, - server_name: String, - profile: StandardTlsProfile, + server_name: impl Into, + options: TlsClientOptions, resumption_sessions: usize, buffer_limit: usize, - policy: Option<&crate::config::AnyTlsCertificatePolicy>, ) -> io::Result { + let server_name = server_name.into(); + let name = ServerName::try_from(server_name.clone()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid TLS server name"))?; + let require_ech = options.ech.is_some(); + if require_ech && !matches!(name, ServerName::DnsName(_)) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "ECH requires a DNS server name", + )); + } + #[cfg(not(feature = "outbound-vless"))] + if require_ech { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "ECH is not compiled in", + )); + } if buffer_limit == 0 { return Err(io::Error::new( io::ErrorKind::InvalidInput, "TLS buffer limit must be greater than zero", )); } + if resumption_sessions > TLS_RESUMPTION_SESSION_BUDGET { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "TLS ticket capacity exceeds the runtime budget", + )); + } + let alpn_size = options.alpn.iter().try_fold(0_usize, |size, protocol| { + if !(1..=255).contains(&protocol.len()) { + return None; + } + size.checked_add(1 + protocol.len()) + .filter(|total| *total <= 65_533) + }); + if alpn_size.is_none() + || options + .required_alpn + .as_ref() + .is_some_and(|required| !options.alpn.contains(required)) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid TLS ALPN policy", + )); + } - let protocol_versions: &[&'static rustls::SupportedProtocolVersion] = match profile { - StandardTlsProfile::VlessXhttp => &[&TLS13], - StandardTlsProfile::AnyTls => &[&TLS13, &TLS12], + if options.client_fingerprint.is_some() { + #[cfg(not(feature = "tls-fingerprint"))] + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "client-fingerprint is not compiled in", + )); + #[cfg(feature = "tls-fingerprint")] + { + let client = super::boring::BoringTlsClient::standard( + context, + &server_name, + &options, + resumption_sessions, + )?; + return Ok(Self { + connector: StandardConnector::Boring(client), + server_name, + required_alpn: options.required_alpn, + buffer_limit, + require_ech, + }); + } + } + + let protocol_versions: &[&'static rustls::SupportedProtocolVersion] = match options.versions + { + TlsVersions::Tls13 => &[&TLS13], + TlsVersions::Tls12And13 => &[&TLS13, &TLS12], }; - let mut config = ClientConfig::builder_with_provider(context.provider.clone()) + let builder = ClientConfig::builder_with_provider(context.provider.clone()); + #[cfg(feature = "outbound-vless")] + let builder = if let Some(ech) = &options.ech { + let ech = rustls::client::EchConfig::new(ech.as_bytes().into(), super::ech::SUITES) + .map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "invalid static ECH config") + })?; + builder.with_ech(ech.into()).map_err(io_other)? + } else { + builder + .with_protocol_versions(protocol_versions) + .map_err(io_other)? + }; + #[cfg(not(feature = "outbound-vless"))] + let builder = builder .with_protocol_versions(protocol_versions) - .map_err(io_other)? - .with_root_certificates(context.tls_roots.clone()) - .with_no_client_auth(); - config.resumption = if resumption_sessions == 0 { + .map_err(io_other)?; + let builder = builder.with_root_certificates(context.tls_roots.clone()); + let mut config = if let Some(identity) = options.identity { + let identity_size = identity + .certificates + .iter() + .try_fold(identity.key.secret_der().len(), |size, cert| { + size.checked_add(cert.len()) + }); + if identity_size.is_none_or(|size| size > crate::config::MAX_CONFIG_BYTES) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "TLS client identity exceeds the configuration bound", + )); + } + builder + .with_client_auth_cert(identity.certificates, identity.key) + .map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "invalid TLS client identity") + })? + } else { + builder.with_no_client_auth() + }; + config.resumption = if resumption_sessions == 0 || require_ech { Resumption::disabled() } else { Resumption::store(Arc::new(super::resumption::NodeSessionStore::new( - ServerName::try_from(server_name.clone()).map_err(io_other)?, + name, resumption_sessions, ))) }; - let required_alpn = match profile { - StandardTlsProfile::VlessXhttp => { - config.alpn_protocols = vec![b"h2".to_vec()]; - Some(b"h2".as_slice()) - } - StandardTlsProfile::AnyTls => None, - }; - - if let Some(policy) = policy { - config.alpn_protocols.clone_from(&policy.alpn); - #[cfg(feature = "outbound-anytls")] - if policy.skip_cert_verify || policy.fingerprint.is_some() { - config.dangerous().set_certificate_verifier(Arc::new( - super::anytls_verifier::AnyTlsVerifier::new(context, policy.clone())?, - )); - } - } + config.alpn_protocols = options.alpn; + config.dangerous().set_certificate_verifier(Arc::new( + super::verifier::CertificateVerifier::new(context, options.certificate)?, + )); Ok(Self { - connector: TlsConnector::from(Arc::new(config)), + connector: StandardConnector::Rustls(TlsConnector::from(Arc::new(config))), server_name, - required_alpn, + required_alpn: options.required_alpn, buffer_limit, + require_ech, }) } - pub(crate) async fn connect(&self, stream: BoxStream) -> io::Result { + pub async fn connect(&self, stream: BoxStream) -> io::Result { + let connector = match &self.connector { + StandardConnector::Rustls(connector) => connector, + #[cfg(feature = "tls-fingerprint")] + StandardConnector::Boring(client) => { + return client.connect(stream, self.buffer_limit).await; + } + }; let server_name = ServerName::try_from(self.server_name.clone()) .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; - let tls = self - .connector + let tls = connector .connect_with(server_name, stream, |connection| { connection.set_buffer_limit(Some(self.buffer_limit)); }) .await - .map_err(io_other)?; + .map_err(|error| io::Error::new(error.kind(), "TLS handshake failed"))?; - if let Some(required_alpn) = self.required_alpn + if self.require_ech && tls.get_ref().1.ech_status() != rustls::client::EchStatus::Accepted { + return Err(io::Error::other("TLS ECH was not accepted")); + } + + if let Some(required_alpn) = self.required_alpn.as_deref() && tls.get_ref().1.alpn_protocol() != Some(required_alpn) { return Err(io::Error::new( @@ -165,7 +391,48 @@ impl StandardTlsClient { )); } - Ok(Box::new(tls)) + Ok(Box::new(super::stream::TlsStream::new(tls))) + } + + #[cfg(feature = "outbound-vless")] + pub(crate) async fn connect_vision( + &self, + stream: BoxStream, + stats: Arc, + ) -> io::Result<(BoxStream, super::vision::SpliceControl)> { + if self.require_ech { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Vision with ECH is not supported", + )); + } + let connector = match &self.connector { + StandardConnector::Rustls(connector) => connector, + StandardConnector::Boring(client) => { + return client + .connect_vision(stream, stats, self.buffer_limit) + .await; + } + }; + let name = ServerName::try_from(self.server_name.clone()) + .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + let tls = connector + .connect_with(name, super::vision::RecordIo::new(stream), |connection| { + connection.set_buffer_limit(Some(self.buffer_limit)) + }) + .await + .map_err(|error| io::Error::new(error.kind(), "Vision TLS handshake failed"))?; + if self + .required_alpn + .as_deref() + .is_some_and(|required| tls.get_ref().1.alpn_protocol() != Some(required)) + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "TLS server did not negotiate the required ALPN", + )); + } + super::vision::SpliceTls::wrap(tls, stats) } #[cfg(test)] @@ -178,6 +445,6 @@ fn io_other(error: impl std::error::Error + Send + Sync + 'static) -> io::Error io::Error::other(error) } -#[cfg(all(test, feature = "outbound-anytls"))] +#[cfg(test)] #[path = "tls_tests.rs"] mod tests; diff --git a/src/security/tls_named_tests.rs b/src/security/tls_named_tests.rs new file mode 100644 index 0000000..07082a9 --- /dev/null +++ b/src/security/tls_named_tests.rs @@ -0,0 +1,215 @@ +//! Additional shared-adapter policy checks over memory IO, never host servers. +use super::*; +use boring::{ + pkey::PKey, + ssl::{AlpnError, Ssl, SslAcceptor, SslMethod, SslVersion, select_next_proto}, + x509::X509, +}; +use foreign_types::ForeignTypeRef; + +fn native_peer(chain: &Chain) -> SslAcceptor { + let mut builder = SslAcceptor::mozilla_intermediate_v5(SslMethod::tls()).unwrap(); + builder + .set_min_proto_version(Some(SslVersion::TLS1_3)) + .unwrap(); + builder + .set_max_proto_version(Some(SslVersion::TLS1_3)) + .unwrap(); + builder + .set_certificate(&X509::from_der(&chain.certificates[0]).unwrap()) + .unwrap(); + for certificate in &chain.certificates[1..] { + builder + .add_extra_chain_cert(X509::from_der(certificate).unwrap()) + .unwrap(); + } + builder + .set_private_key(&PKey::private_key_from_der(chain.key.secret_der()).unwrap()) + .unwrap(); + builder.set_alpn_select_callback(|_, offered| { + select_next_proto(b"\x02h2", offered).ok_or(AlpnError::ALERT_FATAL) + }); + builder.build() +} + +async fn alps_exchange( + client: &StandardTlsClient, + acceptor: &SslAcceptor, + new_codepoint: bool, + settings: Option<&[u8]>, +) -> (io::Result<()>, bool, usize, bool) { + let ssl = Ssl::new(acceptor.context()).unwrap(); + if let Some(settings) = settings { + // SAFETY: SSL is live and exclusively owned. The native setters copy + // the bounded application protocol/settings slices before returning. + unsafe { + boring_sys::SSL_set_alps_use_new_codepoint(ssl.as_ptr(), i32::from(new_codepoint)); + assert_eq!( + boring_sys::SSL_add_application_settings( + ssl.as_ptr(), + b"h2".as_ptr(), + 2, + settings.as_ptr(), + settings.len() + ), + 1 + ); + } + } + let (io, peer) = tokio::io::duplex(16384); + tokio::time::timeout(Duration::from_secs(3), async { + let server = async { + let Ok(mut tls) = tokio_boring::SslStreamBuilder::new(ssl, peer) + .accept() + .await + else { + return (false, 0, false); + }; + let negotiated = tls.ssl().peer_application_settings().is_some(); + let resumed = tls.ssl().session_reused(); + let mut request = [0; 4]; + let count = tls.read(&mut request).await.unwrap_or(0); + if count != 0 { + assert_eq!(&request[..count], &b"ping"[..count]); + tls.write_all(b"pong").await.unwrap(); + } + (negotiated, count, resumed) + }; + let consumer = async { + let mut tls = client.connect(Box::new(io)).await?; + tls.write_all(b"ping").await?; + let mut response = [0; 4]; + tls.read_exact(&mut response).await?; + assert_eq!(&response, b"pong"); + Ok(()) + }; + let (result, (negotiated, bytes, resumed)) = tokio::join!(consumer, server); + (result, negotiated, bytes, resumed) + }) + .await + .unwrap() +} + +#[tokio::test] +async fn selected_profiles_enforce_alps_before_business_and_ticket_publication() { + use crate::config::ClientFingerprint; + let chain = chain(false); + for profile in crate::security::test_profiles().iter().copied().flatten() { + let chrome = matches!( + profile, + ClientFingerprint::Chrome120 | ClientFingerprint::Chrome133 + ); + let new_codepoint = profile == ClientFingerprint::Chrome133; + for settings in [ + None, + Some(b"".as_slice()), + Some(b"\0\x03\0\0\0\x64".as_slice()), + ] { + let peer = native_peer(&chain); + let client = StandardTlsClient::with_options( + &trusted(&chain), + "fixture.invalid", + TlsClientOptions { + client_fingerprint: Some(profile), + alpn: vec![b"h2".to_vec()], + required_alpn: Some(b"h2".to_vec()), + ..Default::default() + }, + 4, + 65536, + ) + .unwrap(); + let rejected = chrome && settings.is_some_and(|bytes| !bytes.is_empty()); + let (result, negotiated, bytes, _) = + alps_exchange(&client, &peer, new_codepoint, settings).await; + assert_eq!(negotiated, chrome && settings.is_some()); + if rejected { + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::Unsupported); + assert_eq!(bytes, 0, "nonempty ALPS cannot admit application data"); + let (result, _, bytes, resumed) = + alps_exchange(&client, &peer, new_codepoint, Some(b"")).await; + result.unwrap(); + assert_eq!(bytes, 4); + assert!(!resumed, "rejected ALPS cannot publish tickets"); + } else { + result.unwrap(); + assert_eq!(bytes, 4); + } + } + } +} + +#[tokio::test] +async fn selected_profiles_consume_tickets_once_under_concurrent_handshakes() { + for profile in crate::security::test_profiles().iter().copied().flatten() { + for capacity in [0, 1, 4] { + let chain = chain(false); + let mut peer = server(&chain, &TLS13, false); + Arc::get_mut(&mut peer).unwrap().send_tls13_tickets = 8; + let client = StandardTlsClient::with_options( + &trusted(&chain), + "fixture.invalid", + TlsClientOptions { + client_fingerprint: Some(profile), + ..Default::default() + }, + capacity, + 65536, + ) + .unwrap(); + assert!(handshake(&client, peer.clone()).await.0); + let mut quiet_peer = peer.as_ref().clone(); + quiet_peer.send_tls13_tickets = 0; + let quiet_peer = Arc::new(quiet_peer); + let results = futures_util::future::join_all( + (0..8).map(|_| handshake(&client, quiet_peer.clone())), + ) + .await; + assert!(results.iter().all(|(ok, _)| *ok)); + assert_eq!( + results.iter().filter(|(_, seen)| seen.resumed).count(), + capacity + ); + let (ok, seen) = handshake(&client, quiet_peer).await; + assert!(ok); + assert!(!seen.resumed, "concurrent consumers cannot replay a ticket"); + } + } +} + +#[tokio::test] +async fn selected_profiles_fall_back_to_authenticated_full_handshake_on_ticket_refusal() { + for profile in crate::security::test_profiles().iter().copied().flatten() { + for version in [&TLS12, &TLS13] { + let chain = chain(false); + let peer = server(&chain, version, false); + let client = StandardTlsClient::with_options( + &trusted(&chain), + "fixture.invalid", + TlsClientOptions { + client_fingerprint: Some(profile), + ..Default::default() + }, + 4, + 65536, + ) + .unwrap(); + assert!(handshake(&client, peer.clone()).await.0); + let (ok, seen) = handshake(&client, peer).await; + assert!(ok && seen.resumed); + let (ok, seen) = handshake(&client, server(&chain, version, false)).await; + assert!(ok); + assert!( + !seen.resumed, + "a new server session identity refuses the old ticket" + ); + let unrelated = super::chain(false); + assert!( + !handshake(&client, server(&unrelated, version, false)) + .await + .0, + "ticket refusal cannot bypass renewed certificate authentication" + ); + } + } +} diff --git a/src/security/tls_named_wire_tests.rs b/src/security/tls_named_wire_tests.rs new file mode 100644 index 0000000..b4918db --- /dev/null +++ b/src/security/tls_named_wire_tests.rs @@ -0,0 +1,219 @@ +//! Independent rustls memory peers for warm-wire/expiry and version rejection. +use super::*; +use base64::{Engine, engine::general_purpose::STANDARD}; +use std::{ + pin::Pin, + task::{Context, Poll}, +}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + +struct FirstFlight { + io: tokio::io::DuplexStream, + bytes: Arc>>, + reading: bool, +} +impl AsyncRead for FirstFlight { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + self.reading = true; + Pin::new(&mut self.io).poll_read(cx, buf) + } +} +impl AsyncWrite for FirstFlight { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let result = Pin::new(&mut self.io).poll_write(cx, buf); + if !self.reading + && let Poll::Ready(Ok(count)) = &result + { + let mut bytes = self.bytes.lock().unwrap(); + assert!(bytes.len() + count <= 65536); + bytes.extend_from_slice(&buf[..*count]); + } + result + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.io).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.io).poll_shutdown(cx) + } +} + +async fn observed_handshake( + client: &StandardTlsClient, + server: Arc, +) -> (bool, Vec) { + let (io, peer) = tokio::io::duplex(65536); + let bytes = Arc::new(Mutex::new(Vec::new())); + let tap = FirstFlight { + io, + bytes: bytes.clone(), + reading: false, + }; + let (resumed, ()) = tokio::time::timeout(Duration::from_secs(3), async { + tokio::join!( + async { + let mut stream = tokio_rustls::TlsAcceptor::from(server) + .accept(peer) + .await + .unwrap(); + let resumed = + stream.get_ref().1.handshake_kind() == Some(rustls::HandshakeKind::Resumed); + stream.write_all(&[42]).await.unwrap(); + stream.shutdown().await.unwrap(); + resumed + }, + async { + let mut stream = client.connect(Box::new(tap)).await.unwrap(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).await.unwrap(); + assert_eq!(response, [42]); + } + ) + }) + .await + .unwrap(); + let bytes = Arc::try_unwrap(bytes).unwrap().into_inner().unwrap(); + (resumed, bytes) +} + +#[derive(Debug)] +struct ShortTickets { + inner: Arc, + issued: Arc>>, +} +impl rustls::server::ProducesTickets for ShortTickets { + fn enabled(&self) -> bool { + true + } + fn lifetime(&self) -> u32 { + 2 + } + fn encrypt(&self, plain: &[u8]) -> Option> { + let ticket = self.inner.encrypt(plain)?; + self.issued.lock().unwrap().push(ticket.len()); + Some(ticket) + } + fn decrypt(&self, cipher: &[u8]) -> Option> { + self.inner.decrypt(cipher) + } +} + +#[tokio::test] +async fn selected_profiles_warm_wire_and_expired_tickets_use_real_handshakes() { + let mut observations = Vec::new(); + let mut clients = Vec::new(); + for (name, profile) in [ + ("chrome120", crate::config::ClientFingerprint::Chrome120), + ("chrome", crate::config::ClientFingerprint::Chrome133), + ("firefox", crate::config::ClientFingerprint::Firefox120), + ("safari", crate::config::ClientFingerprint::Safari16), + ] { + for version in [&TLS12, &TLS13] { + let chain = chain(false); + let mut peer = server(&chain, version, false); + // Only the advertised lifetime is short. The real ticket key stays + // valid so full renegotiation proves the client's own expiry rule. + let issued = Arc::new(Mutex::new(Vec::new())); + Arc::get_mut(&mut peer).unwrap().ticketer = Arc::new(ShortTickets { + inner: rustls::crypto::ring::Ticketer::new().unwrap(), + issued: issued.clone(), + }); + let client = StandardTlsClient::with_options( + &trusted(&chain), + "fixture.invalid", + TlsClientOptions { + client_fingerprint: Some(profile), + alpn: vec![b"h2".to_vec(), b"http/1.1".to_vec()], + ..Default::default() + }, + 4, + 65536, + ) + .unwrap(); + for (phase, expected) in [("cold", false), ("warm", true)] { + let prior_ticket_bytes = issued.lock().unwrap().last().copied(); + let (resumed, bytes) = observed_handshake(&client, peer.clone()).await; + assert_eq!(resumed, expected, "{name}/{version:?}/{phase}"); + observations.push(serde_json::json!({"profile": name, "version": format!("{:?}", version.version), "phase": phase, "resumed": resumed, "prior_ticket_bytes": prior_ticket_bytes, "records_b64": STANDARD.encode(bytes)})); + } + clients.push((name, version, client, peer, issued)); + } + } + // Native ticket timestamps use real UNIX time, not Tokio's paused clock. + tokio::time::sleep(Duration::from_secs(3)).await; + for (name, version, client, peer, issued) in clients { + let prior_ticket_bytes = issued.lock().unwrap().last().copied(); + let (resumed, bytes) = observed_handshake(&client, peer).await; + // Safari16 does not offer the TLS1.2 session-ticket extension. Its + // stateful session ID has no server ticket-lifetime hint to expire. + let stateful_id = name == "safari" && version.version == rustls::ProtocolVersion::TLSv1_2; + assert_eq!( + resumed, stateful_id, + "{name}/{version:?}: ticket hint expiry" + ); + observations.push(serde_json::json!({"profile": name, "version": format!("{:?}", version.version), "phase": "after-hint", "resumed": resumed, "prior_ticket_bytes": prior_ticket_bytes, "records_b64": STANDARD.encode(bytes)})); + } + if let Ok(path) = std::env::var("VCORE_FINGERPRINT_WARM_CAPTURE") { + std::fs::write(path, serde_json::to_vec_pretty(&observations).unwrap()).unwrap(); + } +} + +#[cfg(feature = "outbound-vless")] +#[tokio::test] +async fn named_reality_retains_browser_offer_but_rejects_tls12_server_hello() { + for &profile in crate::security::test_profiles() { + let chain = chain(false); + let peer = server(&chain, &TLS12, false); + let ordinary = client( + &trusted(&chain), + "fixture.invalid", + AnyTlsCertificatePolicy { + client_fingerprint: profile, + ..Default::default() + }, + ); + assert!(handshake(&ordinary, peer.clone()).await.0); + let reality = crate::security::SecurityClient::from_security( + &crate::config::SecurityConfig::Reality(crate::config::RealityConfig { + support_x25519mlkem768: false, + client_fingerprint: profile, + server_name: "fixture.invalid".into(), + public_key: [7; 32], + short_id: vec![], + alpn: vec![], + }), + ) + .unwrap(); + let (io, peer_io) = tokio::io::duplex(65536); + let (result, peer_result) = tokio::time::timeout(Duration::from_secs(3), async { + tokio::join!( + reality.connect(Box::new(io)), + tokio_rustls::TlsAcceptor::from(peer).accept(peer_io) + ) + }) + .await + .unwrap(); + assert!(result.is_err()); + let error = peer_result.unwrap_err(); + assert!( + profile.is_none() + || matches!( + error + .get_ref() + .and_then(|e| e.downcast_ref::()), + Some(rustls::Error::AlertReceived( + rustls::AlertDescription::ProtocolVersion + )) + ), + "{error:?}" + ); + } +} diff --git a/src/security/tls_tests.rs b/src/security/tls_tests.rs index 90e0c11..2032769 100644 --- a/src/security/tls_tests.rs +++ b/src/security/tls_tests.rs @@ -14,6 +14,14 @@ use std::{sync::Mutex, time::Duration}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio_rustls::LazyConfigAcceptor; +#[cfg(feature = "tls-fingerprint")] +#[path = "tls_named_tests.rs"] +mod named; + +#[cfg(feature = "tls-fingerprint")] +#[path = "tls_named_wire_tests.rs"] +mod named_wire; + struct Chain { certificates: Vec>, key: PrivateKeyDer<'static>, @@ -32,6 +40,10 @@ fn ca(name: &str) -> CertificateParams { } fn chain(expired: bool) -> Chain { + chain_with_usage(expired, ExtendedKeyUsagePurpose::ServerAuth) +} + +fn chain_with_usage(expired: bool, usage: ExtendedKeyUsagePurpose) -> Chain { let root = CertifiedIssuer::self_signed(ca("fixture root"), KeyPair::generate().unwrap()).unwrap(); let intermediate = CertifiedIssuer::signed_by( @@ -44,7 +56,7 @@ fn chain(expired: bool) -> Chain { let mut params = CertificateParams::new(vec!["fixture.invalid".into()]).unwrap(); params.not_before = date_time_ymd(2020, 1, 1); params.not_after = date_time_ymd(if expired { 2021 } else { 2090 }, 1, 1); - params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + params.extended_key_usages = vec![usage]; let leaf = params.signed_by(&key, &intermediate).unwrap(); Chain { certificates: vec![ @@ -74,7 +86,23 @@ fn client( name: &str, policy: AnyTlsCertificatePolicy, ) -> StandardTlsClient { - StandardTlsClient::for_anytls(context, name, &policy, 4, DEFAULT_TLS_BUFFER_LIMIT).unwrap() + StandardTlsClient::with_options( + context, + name, + TlsClientOptions { + client_fingerprint: policy.client_fingerprint, + certificate: TlsCertificatePolicy { + verification_name: None, + skip_cert_verify: policy.skip_cert_verify, + fingerprint: policy.fingerprint, + }, + alpn: policy.alpn, + ..Default::default() + }, + 4, + DEFAULT_TLS_BUFFER_LIMIT, + ) + .unwrap() } #[derive(Debug)] @@ -171,96 +199,318 @@ async fn handshake(client: &StandardTlsClient, server: Arc) -> (bo ) } +#[cfg(feature = "tls-fingerprint")] +#[tokio::test] +async fn named_fingerprint_uses_the_shared_webpki_policy_and_transport_alpn() { + for profile in crate::security::test_profiles().iter().copied().flatten() { + let chain = chain(false); + let mut server = server(&chain, &TLS13, false); + Arc::get_mut(&mut server).unwrap().alpn_protocols = vec![b"h2".to_vec()]; + for (context, expected) in [(trusted(&chain), true), (SecurityContext::new(), false)] { + let client = StandardTlsClient::with_options( + &context, + "fixture.invalid", + TlsClientOptions { + client_fingerprint: Some(profile), + alpn: vec![b"h2".to_vec()], + required_alpn: Some(b"h2".to_vec()), + ..Default::default() + }, + 0, + DEFAULT_TLS_BUFFER_LIMIT, + ) + .unwrap(); + let (accepted, seen) = handshake(&client, server.clone()).await; + assert_eq!(accepted, expected); + assert_eq!(seen.alpn, [b"h2".to_vec()]); + assert_eq!(seen.name.as_deref(), Some("fixture.invalid")); + } + } +} + +#[cfg(feature = "tls-fingerprint")] +#[tokio::test] +async fn named_fingerprint_resumption_is_node_owned_and_disabled_at_zero_budget() { + for profile in crate::security::test_profiles().iter().copied().flatten() { + let chain = chain(false); + for version in [&TLS12, &TLS13] { + let peer = server(&chain, version, false); + for capacity in [0, 1] { + let make_client = || { + StandardTlsClient::with_options( + &trusted(&chain), + "fixture.invalid", + TlsClientOptions { + client_fingerprint: Some(profile), + ..Default::default() + }, + capacity, + DEFAULT_TLS_BUFFER_LIMIT, + ) + .unwrap() + }; + let client = make_client(); + let first = handshake(&client, peer.clone()).await; + assert!(first.0); + assert!(!first.1.resumed); + for _ in 0..2 { + let next = handshake(&client.clone(), peer.clone()).await; + assert!(next.0); + assert_eq!(next.1.resumed, capacity != 0); + } + let different_node = handshake(&make_client(), peer.clone()).await; + assert!(different_node.0); + assert!(!different_node.1.resumed); + } + } + } +} + +#[cfg(feature = "tls-fingerprint")] +#[tokio::test] +async fn named_fingerprint_tickets_are_bounded_single_use_and_not_published_on_alpn_rejection() { + for profile in crate::security::test_profiles().iter().copied().flatten() { + let chain = chain(false); + let context = trusted(&chain); + for capacity in [0, 1, 4] { + let mut endpoint = server(&chain, &TLS13, false); + Arc::get_mut(&mut endpoint).unwrap().send_tls13_tickets = 8; + let client = StandardTlsClient::with_options( + &context, + "fixture.invalid", + TlsClientOptions { + client_fingerprint: Some(profile), + ..Default::default() + }, + capacity, + 4096, + ) + .unwrap(); + assert!(handshake(&client, endpoint.clone()).await.0); + // Keep the same peer session storage, but issue no replacement tickets. + let mut no_tickets = endpoint.as_ref().clone(); + no_tickets.send_tls13_tickets = 0; + let no_tickets = Arc::new(no_tickets); + let mut resumed = 0; + for _ in 0..6 { + let (ok, seen) = handshake(&client, no_tickets.clone()).await; + assert!(ok); + resumed += usize::from(seen.resumed); + } + assert_eq!(resumed, capacity); + } + for version in [&TLS12, &TLS13] { + let endpoint = server(&chain, version, false); + let client = StandardTlsClient::with_options( + &context, + "fixture.invalid", + TlsClientOptions { + client_fingerprint: Some(profile), + alpn: vec![b"h2".to_vec()], + required_alpn: Some(b"h2".to_vec()), + ..Default::default() + }, + 4, + 4096, + ) + .unwrap(); + assert!(!handshake(&client, endpoint.clone()).await.0); + let mut accepted = endpoint.as_ref().clone(); + accepted.alpn_protocols = vec![b"h2".to_vec()]; + let (ok, seen) = handshake(&client, Arc::new(accepted)).await; + assert!(ok); + assert!( + !seen.resumed, + "failed application policy cannot publish tickets" + ); + } + } +} + +#[cfg(feature = "tls-fingerprint")] +#[tokio::test(start_paused = true)] +async fn named_fingerprint_close_notify_has_a_five_second_bound() { + use std::{ + pin::Pin, + sync::atomic::{AtomicBool, Ordering}, + task::{Context, Poll}, + }; + use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + struct Gate(tokio::io::DuplexStream, Arc); + impl AsyncRead for Gate { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + b: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.0).poll_read(cx, b) + } + } + impl AsyncWrite for Gate { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + b: &[u8], + ) -> Poll> { + if self.1.load(Ordering::Relaxed) { + Poll::Pending + } else { + Pin::new(&mut self.0).poll_write(cx, b) + } + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_flush(cx) + } + fn poll_shutdown(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + panic!("TLS cannot close caller IO") + } + } + for profile in crate::security::test_profiles().iter().copied().flatten() { + let chain = chain(false); + let client = client( + &trusted(&chain), + "fixture.invalid", + AnyTlsCertificatePolicy { + client_fingerprint: Some(profile), + ..Default::default() + }, + ); + let (io, peer) = tokio::io::duplex(65536); + let gate = Arc::new(AtomicBool::new(false)); + let (connected, accepted) = tokio::join!( + client.connect(Box::new(Gate(io, gate.clone()))), + tokio_rustls::TlsAcceptor::from(server(&chain, &TLS13, false)).accept(peer) + ); + let mut tls = connected.unwrap(); + let _peer = accepted.unwrap(); + gate.store(true, Ordering::Relaxed); + let started = tokio::time::Instant::now(); + assert_eq!( + tls.shutdown().await.unwrap_err().kind(), + io::ErrorKind::TimedOut + ); + assert_eq!(started.elapsed(), Duration::from_secs(5)); + } +} + #[tokio::test] async fn webpki_rejects_untrusted_wrong_name_and_expired_unless_explicitly_skipped() { - for expired in [false, true] { - let chain = chain(expired); - let server = server(&chain, &TLS13, false); - for (context, name, expected) in [ - (trusted(&chain), "fixture.invalid", !expired), - (trusted(&chain), "wrong.invalid", false), - (SecurityContext::new(), "fixture.invalid", false), - ] { - assert_eq!( - handshake(&client(&context, name, Default::default()), server.clone()) + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-SECURITY", + "webpki_rejects_untrusted_wrong_name_and_expired_unless_explicitly_skipped", + ); + for &profile in crate::security::test_profiles() { + for expired in [false, true] { + let chain = chain(expired); + let server = server(&chain, &TLS13, false); + for (context, name, expected) in [ + (trusted(&chain), "fixture.invalid", !expired), + (trusted(&chain), "wrong.invalid", false), + (SecurityContext::new(), "fixture.invalid", false), + ] { + assert_eq!( + handshake( + &client( + &context, + name, + AnyTlsCertificatePolicy { + client_fingerprint: profile, + ..Default::default() + } + ), + server.clone() + ) .await .0, - expected - ); - let insecure = AnyTlsCertificatePolicy { - skip_cert_verify: true, - ..Default::default() - }; - assert!( - handshake(&client(&context, name, insecure), server.clone()) - .await - .0 - ); + expected + ); + let insecure = AnyTlsCertificatePolicy { + client_fingerprint: profile, + skip_cert_verify: true, + ..Default::default() + }; + assert!( + handshake(&client(&context, name, insecure), server.clone()) + .await + .0 + ); + } } } } #[tokio::test] async fn leaf_pin_is_trust_but_nonleaf_pin_checks_chain_name_and_expiry() { - for expired in [false, true] { - let chain = chain(expired); - let server = server(&chain, &TLS13, false); - for index in 0..3 { - for name in ["fixture.invalid", "wrong.invalid"] { - for skip in [false, true] { - let policy = AnyTlsCertificatePolicy { - fingerprint: Some(pin(&chain, index)), - skip_cert_verify: skip, - ..Default::default() - }; - let expected = index == 0 || (!expired && name == "fixture.invalid"); - assert_eq!( - handshake( - &client(&SecurityContext::new(), name, policy), - server.clone() - ) - .await - .0, - expected, - "index={index}, skip={skip}, expired={expired}" - ); + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-SECURITY", + "leaf_pin_is_trust_but_nonleaf_pin_checks_chain_name_and_expiry", + ); + for &profile in crate::security::test_profiles() { + for expired in [false, true] { + let chain = chain(expired); + let server = server(&chain, &TLS13, false); + for index in 0..3 { + for name in ["fixture.invalid", "wrong.invalid"] { + for skip in [false, true] { + let policy = AnyTlsCertificatePolicy { + client_fingerprint: profile, + fingerprint: Some(pin(&chain, index)), + skip_cert_verify: skip, + ..Default::default() + }; + let expected = index == 0 || (!expired && name == "fixture.invalid"); + assert_eq!( + handshake( + &client(&SecurityContext::new(), name, policy), + server.clone() + ) + .await + .0, + expected, + "index={index}, skip={skip}, expired={expired}" + ); + } } } + let policy = AnyTlsCertificatePolicy { + client_fingerprint: profile, + fingerprint: Some([0; 32]), + skip_cert_verify: true, + ..Default::default() + }; + assert!( + !handshake( + &client(&SecurityContext::new(), "fixture.invalid", policy), + server.clone() + ) + .await + .0 + ); + // A pinned unrelated certificate appended to the chain cannot bless + // a leaf it did not issue, even with skip-cert-verify also enabled. + let other = chain_for_unrelated(); + let mut unrelated = Chain { + certificates: chain.certificates.clone(), + key: chain.key.clone_key(), + }; + unrelated.certificates.push(other.certificates[2].clone()); + let policy = AnyTlsCertificatePolicy { + client_fingerprint: profile, + fingerprint: Some(pin(&other, 2)), + skip_cert_verify: true, + ..Default::default() + }; + assert!( + !handshake( + &client(&SecurityContext::new(), "fixture.invalid", policy), + super_server(&unrelated) + ) + .await + .0 + ); } - let policy = AnyTlsCertificatePolicy { - fingerprint: Some([0; 32]), - skip_cert_verify: true, - ..Default::default() - }; - assert!( - !handshake( - &client(&SecurityContext::new(), "fixture.invalid", policy), - server.clone() - ) - .await - .0 - ); - // A pinned unrelated certificate appended to the chain cannot bless - // a leaf it did not issue, even with skip-cert-verify also enabled. - let other = chain_for_unrelated(); - let mut unrelated = Chain { - certificates: chain.certificates.clone(), - key: chain.key.clone_key(), - }; - unrelated.certificates.push(other.certificates[2].clone()); - let policy = AnyTlsCertificatePolicy { - fingerprint: Some(pin(&other, 2)), - skip_cert_verify: true, - ..Default::default() - }; - assert!( - !handshake( - &client(&SecurityContext::new(), "fixture.invalid", policy), - super_server(&unrelated) - ) - .await - .0 - ); } } @@ -274,6 +524,11 @@ fn super_server(chain: &Chain) -> Arc { #[tokio::test] async fn ticket_storage_obeys_exact_node_budget_and_consumes_each_ticket_once() { + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-SECURITY", + "ticket_storage_obeys_exact_node_budget_and_consumes_each_ticket_once", + ); use crate::security::resumption::NodeSessionStore; use rustls::client::ClientSessionStore; let chain = chain(false); @@ -283,9 +538,12 @@ async fn ticket_storage_obeys_exact_node_budget_and_consumes_each_ticket_once() for capacity in [0, 1, 4] { let store = Arc::new(NodeSessionStore::new(name.clone(), capacity)); let mut client = client(&context, "fixture.invalid", Default::default()); - let mut config = client.connector.config().as_ref().clone(); + let StandardConnector::Rustls(connector) = &client.connector else { + panic!("unprofiled TLS uses rustls") + }; + let mut config = connector.config().as_ref().clone(); config.resumption = Resumption::store(store.clone()); - client.connector = TlsConnector::from(Arc::new(config)); + client.connector = StandardConnector::Rustls(TlsConnector::from(Arc::new(config))); let mut server = server(&chain, version, false); Arc::get_mut(&mut server).unwrap().send_tls13_tickets = 8; assert!(handshake(&client, server.clone()).await.0); @@ -317,23 +575,36 @@ async fn ticket_storage_obeys_exact_node_budget_and_consumes_each_ticket_once() #[tokio::test] async fn every_certificate_policy_verifies_tls12_and_tls13_handshake_signatures() { - let chain = chain(false); - for version in [&TLS12, &TLS13] { - for fingerprint in [None, Some(pin(&chain, 0)), Some(pin(&chain, 1))] { - let policy = AnyTlsCertificatePolicy { - skip_cert_verify: true, - fingerprint, - ..Default::default() - }; - let client = client(&SecurityContext::new(), "fixture.invalid", policy); - assert!(handshake(&client, server(&chain, version, false)).await.0); - assert!(!handshake(&client, server(&chain, version, true)).await.0); + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-SECURITY", + "every_certificate_policy_verifies_tls12_and_tls13_handshake_signatures", + ); + for &profile in crate::security::test_profiles() { + let chain = chain(false); + for version in [&TLS12, &TLS13] { + for fingerprint in [None, Some(pin(&chain, 0)), Some(pin(&chain, 1))] { + let policy = AnyTlsCertificatePolicy { + client_fingerprint: profile, + skip_cert_verify: true, + fingerprint, + ..Default::default() + }; + let client = client(&SecurityContext::new(), "fixture.invalid", policy); + assert!(handshake(&client, server(&chain, version, false)).await.0); + assert!(!handshake(&client, server(&chain, version, true)).await.0); + } } } } #[tokio::test] async fn vless_keeps_webpki_tls13_and_required_h2_after_anytls_connections() { + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-SECURITY", + "vless_keeps_webpki_tls13_and_required_h2_after_anytls_connections", + ); let chain = chain(false); let mut endpoint = server(&chain, &TLS13, false); Arc::get_mut(&mut endpoint).unwrap().alpn_protocols = vec![b"h2".to_vec()]; @@ -372,6 +643,11 @@ async fn vless_keeps_webpki_tls13_and_required_h2_after_anytls_connections() { #[tokio::test] async fn alpn_and_tls_resumption_are_isolated_between_node_policies() { + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-SECURITY", + "alpn_and_tls_resumption_are_isolated_between_node_policies", + ); let chain = chain(false); let server = server(&chain, &TLS13, false); let context = trusted(&chain); @@ -429,3 +705,350 @@ async fn alpn_and_tls_resumption_are_isolated_between_node_policies() { .0 ); } + +#[tokio::test] +async fn explicit_verification_name_does_not_change_sni_or_allow_skip_to_override_it() { + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-SECURITY", + "explicit_verification_name_does_not_change_sni_or_allow_skip_to_override_it", + ); + for &profile in crate::security::test_profiles() { + let chain = chain(false); + let context = trusted(&chain); + for (name, skip, fingerprint, expected) in [ + ("fixture.invalid", false, None, true), + ("fixture.invalid", true, None, true), + ("wrong.invalid", true, None, false), + ("wrong.invalid", true, Some(pin(&chain, 0)), true), + ("wrong.invalid", true, Some(pin(&chain, 1)), false), + ("fixture.invalid", true, Some([0; 32]), false), + ] { + let options = TlsClientOptions { + client_fingerprint: profile, + certificate: TlsCertificatePolicy { + verification_name: Some(name.into()), + skip_cert_verify: skip, + fingerprint, + }, + ..Default::default() + }; + let client = StandardTlsClient::with_options( + &context, + "sni.invalid", + options, + 1, + DEFAULT_TLS_BUFFER_LIMIT, + ) + .unwrap(); + let (ok, seen) = handshake(&client, server(&chain, &TLS13, false)).await; + assert_eq!(ok, expected); + assert_eq!(seen.name.as_deref(), Some("sni.invalid")); + } + } +} + +#[tokio::test] +async fn mutual_tls_identity_is_required_verified_and_not_shared_between_clients() { + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-SECURITY", + "mutual_tls_identity_is_required_verified_and_not_shared_between_clients", + ); + for &profile in crate::security::test_profiles() { + let chain = chain(false); + let identity = chain_with_usage(false, ExtendedKeyUsagePurpose::ClientAuth); + let unknown = chain_with_usage(false, ExtendedKeyUsagePurpose::ClientAuth); + for version in [&TLS12, &TLS13] { + let mut roots = RootCertStore::empty(); + roots.add(identity.certificates[2].clone()).unwrap(); + let verifier = rustls::server::WebPkiClientVerifier::builder_with_provider( + Arc::new(roots), + trusted(&chain).provider, + ) + .build() + .unwrap(); + let server = Arc::new( + ServerConfig::builder_with_provider(trusted(&chain).provider) + .with_protocol_versions(&[version]) + .unwrap() + .with_client_cert_verifier(verifier) + .with_single_cert(chain.certificates.clone(), chain.key.clone_key()) + .unwrap(), + ); + for (cert, expected) in [ + (Some(&identity), true), + (None, false), + (Some(&unknown), false), + (Some(&identity), true), + ] { + let options = TlsClientOptions { + client_fingerprint: profile, + identity: cert.map(|cert| { + TlsClientIdentity::from_der(cert.certificates.clone(), cert.key.clone_key()) + }), + ..Default::default() + }; + let client = StandardTlsClient::with_options( + &trusted(&chain), + "fixture.invalid", + options, + 1, + DEFAULT_TLS_BUFFER_LIMIT, + ) + .unwrap(); + let (ok, seen) = handshake(&client, server.clone()).await; + assert_eq!(ok, expected); + assert!( + !seen.resumed, + "new identity must not inherit a node's tickets" + ); + if expected { + assert!(handshake(&client, server.clone()).await.1.resumed); + } + } + } + } +} + +#[test] +fn tls_options_reject_invalid_alpn_identity_and_budget_before_using_a_stream() { + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-SECURITY", + "tls_options_reject_invalid_alpn_identity_and_budget_before_using_a_stream", + ); + let context = SecurityContext::new(); + for options in [ + TlsClientOptions { + alpn: vec![vec![]], + ..Default::default() + }, + TlsClientOptions { + alpn: vec![vec![b'a'; 256]], + ..Default::default() + }, + TlsClientOptions { + alpn: vec![vec![b'a'; 255]; 256], + ..Default::default() + }, + TlsClientOptions { + required_alpn: Some(b"h2".to_vec()), + ..Default::default() + }, + ] { + assert!( + StandardTlsClient::with_options(&context, "example.com", options, 1, 4096).is_err() + ); + } + let first = chain(false); + let second = chain(false); + let options = TlsClientOptions { + identity: Some(TlsClientIdentity::from_der(first.certificates, second.key)), + ..Default::default() + }; + let error = + StandardTlsClient::with_options(&context, "example.com", options, 1, 4096).unwrap_err(); + assert_eq!(error.to_string(), "invalid TLS client identity"); + assert!( + StandardTlsClient::with_options(&context, "example.com", Default::default(), 5, 4096) + .is_err() + ); + assert!( + StandardTlsClient::with_options( + &context, + "secret-invalid name", + Default::default(), + 1, + 4096 + ) + .is_err() + ); +} + +#[tokio::test] +async fn certificate_rejection_delivers_no_business_bytes_and_diagnostics_are_redacted() { + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-SECURITY", + "certificate_rejection_delivers_no_business_bytes_and_diagnostics_are_redacted", + ); + for fingerprint in [None, Some([0; 32])] { + let chain = chain(false); + let client = StandardTlsClient::with_options( + &trusted(&chain), + "secret-wrong.invalid", + TlsClientOptions { + certificate: TlsCertificatePolicy { + fingerprint, + ..Default::default() + }, + ..Default::default() + }, + 1, + 4096, + ) + .unwrap(); + assert!(!format!("{client:?}").contains("secret-wrong")); + let (client_io, server_io) = tokio::io::duplex(4096); + let client_side = async { + match client.connect(Box::new(client_io)).await { + Ok(mut stream) => { + stream.write_all(b"business bytes").await.unwrap(); + panic!("invalid certificate accepted") + } + Err(error) => { + assert!(!error.to_string().contains("secret-wrong")); + assert!(!error.to_string().contains("fixture.invalid")); + } + } + }; + let server_side = async { + let Ok(mut stream) = tokio_rustls::TlsAcceptor::from(server(&chain, &TLS13, false)) + .accept(server_io) + .await + else { + return 0; + }; + let mut data = [0; 64]; + stream.read(&mut data).await.unwrap_or(0) + }; + let (_, bytes) = tokio::time::timeout(Duration::from_secs(3), async { + tokio::join!(client_side, server_side) + }) + .await + .unwrap(); + assert_eq!(bytes, 0); + } +} + +#[tokio::test] +async fn tls_close_write_sends_notify_without_closing_the_supplied_transport() { + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-SECURITY", + "tls_close_write_sends_notify_without_closing_the_supplied_transport", + ); + for &profile in crate::security::test_profiles() { + let chain = chain(false); + let client = StandardTlsClient::with_options( + &trusted(&chain), + "fixture.invalid", + TlsClientOptions { + client_fingerprint: profile, + ..Default::default() + }, + 0, + 4096, + ) + .unwrap(); + let (client_io, server_io) = tokio::io::duplex(4096); + let server = tokio::spawn(async move { + let mut stream = tokio_rustls::TlsAcceptor::from(server(&chain, &TLS13, false)) + .accept(server_io) + .await + .unwrap(); + let mut body = Vec::new(); + stream.read_to_end(&mut body).await.unwrap(); + assert_eq!(body, b"request"); + let mut probe = [0; 1]; + assert!( + tokio::time::timeout( + Duration::from_millis(30), + stream.get_mut().0.read(&mut probe) + ) + .await + .is_err(), + "TLS CloseWrite must not shut down the underlay" + ); + stream.write_all(b"tail").await.unwrap(); + stream.shutdown().await.unwrap(); + }); + let mut stream = client.connect(Box::new(client_io)).await.unwrap(); + stream.write_all(b"request").await.unwrap(); + stream.shutdown().await.unwrap(); + stream.shutdown().await.unwrap(); + assert_eq!( + stream.write(b"late").await.unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + let mut body = Vec::new(); + tokio::time::timeout(Duration::from_secs(3), stream.read_to_end(&mut body)) + .await + .unwrap() + .unwrap(); + assert_eq!(body, b"tail"); + server.await.unwrap(); + } +} + +#[tokio::test] +async fn tls_close_notify_flush_has_a_five_second_bound() { + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-SECURITY", + "tls_close_notify_flush_has_a_five_second_bound", + ); + let chain = chain(false); + let client = client(&trusted(&chain), "fixture.invalid", Default::default()); + let (client_io, server_io) = tokio::io::duplex(128); + let server = tokio::spawn(async move { + let _stream = tokio_rustls::TlsAcceptor::from(server(&chain, &TLS13, false)) + .accept(server_io) + .await + .unwrap(); + std::future::pending::<()>().await; + }); + let mut stream = client.connect(Box::new(client_io)).await.unwrap(); + assert!(stream.write(&[0; 32768]).await.unwrap() > 0); + let started = tokio::time::Instant::now(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(6), stream.shutdown()) + .await + .unwrap() + .unwrap_err() + .kind(), + io::ErrorKind::TimedOut + ); + assert!(started.elapsed() >= Duration::from_secs(5)); + drop(stream); + server.abort(); + assert!(server.await.unwrap_err().is_cancelled()); +} + +#[tokio::test] +async fn cancelling_tls_handshake_releases_the_caller_supplied_stream() { + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-SECURITY", + "cancelling_tls_handshake_releases_the_caller_supplied_stream", + ); + for &profile in crate::security::test_profiles() { + let (client_io, mut peer) = tokio::io::duplex(4096); + let chain = chain(false); + let client = client( + &trusted(&chain), + "fixture.invalid", + AnyTlsCertificatePolicy { + client_fingerprint: profile, + ..Default::default() + }, + ); + let reconnect = client.clone(); + let connect = tokio::spawn(async move { client.connect(Box::new(client_io)).await }); + let mut hello = [0; 4096]; + assert!(peer.read(&mut hello).await.unwrap() > 0); + connect.abort(); + assert!(matches!(connect.await, Err(error) if error.is_cancelled())); + let mut rest = Vec::new(); + tokio::time::timeout(Duration::from_secs(1), peer.read_to_end(&mut rest)) + .await + .unwrap() + .unwrap(); + let (ok, seen) = handshake(&reconnect, server(&chain, &TLS13, false)).await; + assert!( + ok && !seen.resumed, + "cancelled handshake cannot poison or seed the next connection" + ); + } +} diff --git a/src/security/anytls_verifier.rs b/src/security/verifier.rs similarity index 72% rename from src/security/anytls_verifier.rs rename to src/security/verifier.rs index 4149477..829a30f 100644 --- a/src/security/anytls_verifier.rs +++ b/src/security/verifier.rs @@ -1,4 +1,4 @@ -//! Certificate policy is per AnyTLS node. Handshake signatures are mandatory +//! Certificate policy is per immutable TLS client. Handshake signatures are mandatory //! even when a pin replaces WebPKI trust or chain verification is disabled. use std::{io, sync::Arc}; @@ -13,36 +13,49 @@ use rustls::{ }; use sha2::{Digest, Sha256}; -use super::SecurityContext; -use crate::config::AnyTlsCertificatePolicy; +use super::{SecurityContext, tls::TlsCertificatePolicy}; -#[derive(Debug)] -pub(super) struct AnyTlsVerifier { - policy: AnyTlsCertificatePolicy, +pub(super) struct CertificateVerifier { + policy: TlsCertificatePolicy, + verification_name: Option>, provider: Arc, default: Arc, } -impl AnyTlsVerifier { - pub(super) fn new( - context: &SecurityContext, - policy: AnyTlsCertificatePolicy, - ) -> io::Result { +impl std::fmt::Debug for CertificateVerifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CertificateVerifier") + .field("policy", &self.policy) + .finish_non_exhaustive() + } +} + +impl CertificateVerifier { + pub(super) fn new(context: &SecurityContext, policy: TlsCertificatePolicy) -> io::Result { + let verification_name = policy + .verification_name + .clone() + .map(ServerName::try_from) + .transpose() + .map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "invalid TLS verification name") + })?; let default = WebPkiServerVerifier::builder_with_provider( context.tls_roots.clone(), context.provider.clone(), ) .build() - .map_err(|_| io::Error::other("failed to construct AnyTLS certificate verifier"))?; + .map_err(|_| io::Error::other("failed to construct certificate verifier"))?; Ok(Self { policy, + verification_name, provider: context.provider.clone(), default, }) } } -impl ServerCertVerifier for AnyTlsVerifier { +impl ServerCertVerifier for CertificateVerifier { fn verify_server_cert( &self, end_entity: &CertificateDer<'_>, @@ -51,6 +64,7 @@ impl ServerCertVerifier for AnyTlsVerifier { ocsp: &[u8], now: UnixTime, ) -> Result { + let server_name = self.verification_name.as_ref().unwrap_or(server_name); if let Some(pin) = self.policy.fingerprint { if Sha256::digest(end_entity.as_ref()).as_slice() == pin { // Like mihomo, an exact leaf pin is the trust decision. The @@ -84,7 +98,9 @@ impl ServerCertVerifier for AnyTlsVerifier { CertificateError::ApplicationVerificationFailure, )); } - if self.policy.skip_cert_verify { + // Mihomo's explicit name verifier, like pin verification, takes + // precedence over skip-cert-verify. It never changes the SNI. + if self.policy.skip_cert_verify && self.verification_name.is_none() { return Ok(ServerCertVerified::assertion()); } self.default diff --git a/src/security/vision.rs b/src/security/vision.rs new file mode 100644 index 0000000..fe2173a --- /dev/null +++ b/src/security/vision.rs @@ -0,0 +1,422 @@ +//! Record-bounded TLS IO for Vision, using only public TLS interfaces. +//! Never feed a second TLS record to the deframer in one read, and never +//! concatenate plaintext chunks before the framing layer sees its direct marker. + +#[path = "vision_boring.rs"] +mod boring; +pub(super) use boring::BoringSplice; + +#[cfg(test)] +mod tests { + use super::*; + use crate::security::{SecurityContext, StandardTlsClient, TlsClientOptions, TlsVersions}; + use rustls::{ + RootCertStore, ServerConfig, + pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}, + }; + use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio_rustls::TlsAcceptor; + fn pair(profile: Option) -> (StandardTlsClient, TlsAcceptor) { + let identity = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let mut roots = RootCertStore::empty(); + roots.add(identity.cert.der().clone()).unwrap(); + let context = SecurityContext { + provider: provider.clone(), + tls_roots: Arc::new(roots), + }; + let client = StandardTlsClient::with_options( + &context, + "localhost", + TlsClientOptions { + client_fingerprint: profile, + versions: TlsVersions::Tls13, + ..Default::default() + }, + 0, + 65536, + ) + .unwrap(); + let server = ServerConfig::builder_with_provider(provider) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .with_no_client_auth() + .with_single_cert( + vec![identity.cert.der().clone()], + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from( + identity.signing_key.serialize_der(), + )), + ) + .unwrap(); + (client, TlsAcceptor::from(Arc::new(server))) + } + struct FragmentIo { + raw: BoxStream, + limit: usize, + } + impl AsyncRead for FragmentIo { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let size = buf.remaining().min(self.limit); + let mut part = ReadBuf::new(&mut buf.initialize_unfilled()[..size]); + ready!(Pin::new(&mut self.raw).poll_read(cx, &mut part))?; + let count = part.filled().len(); + buf.advance(count); + Poll::Ready(Ok(())) + } + } + impl AsyncWrite for FragmentIo { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let count = buf.len().min(self.limit); + Pin::new(&mut self.raw).poll_write(cx, &buf[..count]) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.raw).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.raw).poll_shutdown(cx) + } + } + #[tokio::test] + async fn record_boundary_preserves_buffered_plaintext_and_coalesced_raw_tail_and_flush_order() { + let _case = crate::resources::case_events::Case::new( + "VLESS-UNIT", + "record_boundary_preserves_buffered_plaintext_and_coalesced_raw_tail_and_flush_order", + ); + for &profile in crate::security::test_profiles() { + for limit in [1, 3, 65536] { + tokio::time::timeout(Duration::from_secs(3), async { + let (client, acceptor) = pair(profile); + let (io, peer) = tokio::io::duplex(65536); + let server = tokio::spawn(async move { + let mut tls = acceptor + .accept(RecordIo::new(Box::new(peer))) + .await + .unwrap(); + tls.write_all(b"marker-buffered").await.unwrap(); + tls.flush().await.unwrap(); + // Raw tail is immediately coalesced after the encrypted + // marker; a greedy TLS read loses or tries to decrypt it. + tls.get_mut() + .0 + .raw + .write_all(b"raw-after-record") + .await + .unwrap(); + let mut marker = [0; 16]; + tls.read_exact(&mut marker).await.unwrap(); + assert_eq!(&marker, b"encrypted-marker"); + let mut raw = tls.into_inner().0.raw; + let mut written = [0; 9]; + raw.read_exact(&mut written).await.unwrap(); + assert_eq!(&written, b"raw-write"); + }); + let stats = Arc::new(SpliceStats::default()); + let (mut tls, control) = client + .connect_vision( + Box::new(FragmentIo { + raw: Box::new(io), + limit, + }), + stats.clone(), + ) + .await + .unwrap(); + let mut marker = [0; 7]; + tls.read_exact(&mut marker).await.unwrap(); + assert_eq!(&marker, b"marker-"); + control.read_direct(); + let mut tail = [0; 24]; + tls.read_exact(&mut tail).await.unwrap(); + assert_eq!(&tail, b"bufferedraw-after-record"); + tls.write_all(b"encrypted-marker").await.unwrap(); + // No explicit flush; switching must flush the outer record. + control.write_direct(); + tls.write_all(b"raw-write").await.unwrap(); + tls.flush().await.unwrap(); + server.await.unwrap(); + assert_eq!(stats.bytes(), (16, 9)); + }) + .await + .unwrap(); + } + } + } +} +use crate::dispatch::BoxStream; +use std::{ + future::Future, + io::{self, Read}, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + task::{Context, Poll, ready}, +}; +use tokio::io::{AsyncBufRead, AsyncRead, AsyncWrite, ReadBuf}; + +#[derive(Default)] +pub(crate) struct SpliceStats { + read: AtomicU64, + written: AtomicU64, +} +impl SpliceStats { + pub(crate) fn record_read(&self, count: usize) { + self.read.fetch_add(count as u64, Ordering::Relaxed); + } + pub(crate) fn record_written(&self, count: usize) { + self.written.fetch_add(count as u64, Ordering::Relaxed); + } + #[cfg(any(test, feature = "interop-test"))] + pub(crate) fn bytes(&self) -> (u64, u64) { + ( + self.read.load(Ordering::Relaxed), + self.written.load(Ordering::Relaxed), + ) + } +} +#[derive(Clone, Default)] +pub(crate) struct SpliceControl { + read: Arc, + write: Arc, +} +impl SpliceControl { + pub(crate) fn reading_direct(&self) -> bool { + self.read.load(Ordering::Acquire) + } + pub(crate) fn writing_direct(&self) -> bool { + self.write.load(Ordering::Acquire) + } + pub(crate) fn read_direct(&self) { + self.read.store(true, Ordering::Release); + } + pub(crate) fn write_direct(&self) { + self.write.store(true, Ordering::Release); + } +} + +pub(crate) struct RecordIo { + raw: BoxStream, + header: [u8; 5], + header_read: usize, + body_left: usize, +} +impl RecordIo { + pub(crate) fn new(raw: BoxStream) -> Self { + Self { + raw, + header: [0; 5], + header_read: 0, + body_left: 0, + } + } + fn boundary(&self) -> bool { + self.header_read == 0 && self.body_left == 0 + } +} +impl AsyncRead for RecordIo { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + out: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + if out.remaining() == 0 { + return Poll::Ready(Ok(())); + } + let header = this.header_read < 5; + let count = out.remaining().min(if header { + 5 - this.header_read + } else { + this.body_left + }); + let mut part = ReadBuf::new(&mut out.initialize_unfilled()[..count]); + ready!(Pin::new(&mut this.raw).poll_read(cx, &mut part))?; + let bytes = part.filled(); + let length = bytes.len(); + if header { + this.header[this.header_read..this.header_read + length].copy_from_slice(bytes); + this.header_read += length; + if this.header_read == 5 { + this.body_left = u16::from_be_bytes([this.header[3], this.header[4]]) as usize; + // TLS ciphertext limit (TLS 1.2 worst case); rustls still owns + // version/content-type/authentication checks. + if this.body_left > crate::limits::VISION_TLS_RECORD_BYTES { + return Poll::Ready(Err(io::ErrorKind::InvalidData.into())); + } + if this.body_left == 0 { + this.header_read = 0; + } + } + } else { + this.body_left -= length; + if this.body_left == 0 { + this.header_read = 0; + } + } + out.advance(length); + Poll::Ready(Ok(())) + } +} +impl AsyncWrite for RecordIo { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.raw).poll_write(cx, buf) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.raw).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.raw).poll_shutdown(cx) + } +} + +pub(crate) struct SpliceTls { + tls: tokio_rustls::client::TlsStream, + control: SpliceControl, + stats: Arc, + write_switched: bool, + closing: Option>>, +} +impl SpliceTls { + pub(crate) fn wrap( + tls: tokio_rustls::client::TlsStream, + stats: Arc, + ) -> io::Result<(BoxStream, SpliceControl)> { + if tls.get_ref().1.protocol_version() != Some(rustls::ProtocolVersion::TLSv1_3) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Vision requires TLS 1.3", + )); + } + let control = SpliceControl::default(); + Ok(( + Box::new(Self { + tls, + control: control.clone(), + stats, + write_switched: false, + closing: None, + }), + control, + )) + } + fn prepare_write(&mut self, cx: &mut Context<'_>) -> Poll> { + if self.control.write.load(Ordering::Acquire) && !self.write_switched { + // The marker's final encrypted bytes must precede every raw byte. + ready!(Pin::new(&mut self.tls).poll_flush(cx))?; + self.write_switched = true; + } + Poll::Ready(Ok(())) + } +} +impl AsyncRead for SpliceTls { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + out: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + if out.remaining() == 0 { + return Poll::Ready(Ok(())); + } + if this.control.read.load(Ordering::Acquire) { + let (io, connection) = this.tls.get_mut(); + // Public reader drains plaintext already present after the marker. + match connection.reader().read(out.initialize_unfilled()) { + Ok(n) if n > 0 => { + out.advance(n); + return Poll::Ready(Ok(())); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::WouldBlock => {} + Err(error) => return Poll::Ready(Err(error)), + } + if !io.boundary() { + return Poll::Ready(Err(io::ErrorKind::InvalidData.into())); + } + let before = out.filled().len(); + let result = Pin::new(&mut io.raw).poll_read(cx, out); + this.stats + .read + .fetch_add((out.filled().len() - before) as u64, Ordering::Relaxed); + return result; + } + // AsyncRead on tokio-rustls loops across chunks. Use one public + // AsyncBufRead chunk so the caller can inspect a marker first. + let mut tls = Pin::new(&mut this.tls); + let bytes = ready!(tls.as_mut().poll_fill_buf(cx))?; + let count = bytes.len().min(out.remaining()); + out.put_slice(&bytes[..count]); + tls.consume(count); + Poll::Ready(Ok(())) + } +} +impl AsyncWrite for SpliceTls { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + if self.closing.is_some() { + return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into())); + } + ready!(self.prepare_write(cx))?; + if self.write_switched { + let result = Pin::new(&mut self.tls.get_mut().0.raw).poll_write(cx, buf); + if let Poll::Ready(Ok(n)) = result { + self.stats.written.fetch_add(n as u64, Ordering::Relaxed); + } + result + } else { + Pin::new(&mut self.tls).poll_write(cx, buf) + } + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + ready!(self.prepare_write(cx))?; + if self.write_switched { + Pin::new(&mut self.tls.get_mut().0.raw).poll_flush(cx) + } else { + Pin::new(&mut self.tls).poll_flush(cx) + } + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + ready!(self.prepare_write(cx))?; + if self.write_switched || self.control.read.load(Ordering::Acquire) { + if self.closing.is_none() { + self.closing = Some(Box::pin(tokio::time::sleep(super::CLOSE_NOTIFY_TIMEOUT))); + } + if self.closing.as_mut().unwrap().as_mut().poll(cx).is_ready() { + return Poll::Ready(Err(io::ErrorKind::TimedOut.into())); + } + if !self.write_switched { + ready!(Pin::new(&mut self.tls).poll_flush(cx))?; + } + // Do not inject an outer TLS alert into the direct stream. + return Pin::new(&mut self.tls.get_mut().0.raw).poll_shutdown(cx); + } + if self.closing.is_none() { + self.tls.get_mut().1.send_close_notify(); + self.closing = Some(Box::pin(tokio::time::sleep(super::CLOSE_NOTIFY_TIMEOUT))); + } + if let Poll::Ready(result) = Pin::new(&mut self.tls).poll_flush(cx) { + return Poll::Ready(result); + } + if self.closing.as_mut().unwrap().as_mut().poll(cx).is_ready() { + return Poll::Ready(Err(io::ErrorKind::TimedOut.into())); + } + Poll::Pending + } +} diff --git a/src/security/vision_boring.rs b/src/security/vision_boring.rs new file mode 100644 index 0000000..6f410cb --- /dev/null +++ b/src/security/vision_boring.rs @@ -0,0 +1,136 @@ +//! The same record boundary and switch controls as rustls, with no access to +//! native TLS internals. SSL_pending drains only already-decrypted plaintext. +use super::*; +use crate::security::boring_stream::{BoringStream, KeepOpen}; + +pub(in crate::security) struct BoringSplice { + tls: BoringStream, + control: SpliceControl, + stats: Arc, + write_switched: bool, + closing: Option>>, + closed: bool, +} +impl BoringSplice { + pub(in crate::security) fn wrap( + tls: tokio_boring::SslStream>, + stats: Arc, + buffer_limit: usize, + ) -> io::Result<(BoxStream, SpliceControl)> { + if tls.ssl().version2() != Some(::boring::ssl::SslVersion::TLS1_3) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Vision requires TLS 1.3", + )); + } + let control = SpliceControl::default(); + Ok(( + Box::new(Self { + tls: BoringStream::new(tls, buffer_limit), + control: control.clone(), + stats, + write_switched: false, + closing: None, + closed: false, + }), + control, + )) + } + fn prepare_write(&mut self, cx: &mut Context<'_>) -> Poll> { + if self.control.write.load(Ordering::Acquire) && !self.write_switched { + ready!(Pin::new(&mut self.tls).poll_flush(cx))?; + self.write_switched = true; + } + Poll::Ready(Ok(())) + } +} +impl AsyncRead for BoringSplice { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + out: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + if out.remaining() == 0 { + return Poll::Ready(Ok(())); + } + if this.control.read.load(Ordering::Acquire) { + let pending = this.tls.inner.ssl().pending(); + if pending != 0 { + let count = pending.min(out.remaining()); + let mut part = ReadBuf::new(&mut out.initialize_unfilled()[..count]); + ready!(Pin::new(&mut this.tls).poll_read(cx, &mut part))?; + let n = part.filled().len(); + out.advance(n); + return Poll::Ready(Ok(())); + } + let io = &mut this.tls.inner.get_mut().0; + if !io.boundary() { + return Poll::Ready(Err(io::ErrorKind::InvalidData.into())); + } + let before = out.filled().len(); + let result = Pin::new(&mut io.raw).poll_read(cx, out); + this.stats + .read + .fetch_add((out.filled().len() - before) as u64, Ordering::Relaxed); + return result; + } + // SSL_read returns one application record, unlike a greedy deframer. + Pin::new(&mut this.tls).poll_read(cx, out) + } +} +impl AsyncWrite for BoringSplice { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + if self.closing.is_some() { + return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into())); + } + ready!(self.prepare_write(cx))?; + if self.write_switched { + let result = Pin::new(&mut self.tls.inner.get_mut().0.raw).poll_write(cx, buf); + if let Poll::Ready(Ok(n)) = result { + self.stats.written.fetch_add(n as u64, Ordering::Relaxed); + } + result + } else { + Pin::new(&mut self.tls).poll_write(cx, buf) + } + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + ready!(self.prepare_write(cx))?; + if self.write_switched { + Pin::new(&mut self.tls.inner.get_mut().0.raw).poll_flush(cx) + } else { + Pin::new(&mut self.tls).poll_flush(cx) + } + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.closed { + return Poll::Ready(Ok(())); + } + if self.closing.is_none() { + self.closing = Some(Box::pin(tokio::time::sleep( + crate::security::CLOSE_NOTIFY_TIMEOUT, + ))); + } + // Start the deadline before flushing a pending direct marker. + if self.closing.as_mut().unwrap().as_mut().poll(cx).is_ready() { + return Poll::Ready(Err(io::ErrorKind::TimedOut.into())); + } + ready!(self.prepare_write(cx))?; + let result = if self.write_switched || self.control.read.load(Ordering::Acquire) { + ready!(Pin::new(&mut self.tls).poll_flush(cx))?; + // A raw stream must not receive an outer TLS alert. + Pin::new(&mut self.tls.inner.get_mut().0.raw).poll_shutdown(cx) + } else { + Pin::new(&mut self.tls).poll_shutdown(cx) + }; + if matches!(result, Poll::Ready(Ok(()))) { + self.closed = true; + } + result + } +} diff --git a/src/socks5.rs b/src/socks5.rs index bf65cc8..128070d 100644 --- a/src/socks5.rs +++ b/src/socks5.rs @@ -137,7 +137,7 @@ pub fn encode_udp_packet( Ok(packet) } -fn encoded_address_size(destination: &Destination) -> Result { +pub(crate) fn encoded_address_size(destination: &Destination) -> Result { match destination { Destination::Ip(SocketAddr::V4(_)) => Ok(1 + 4 + 2), Destination::Ip(SocketAddr::V6(_)) => Ok(1 + 16 + 2), @@ -178,7 +178,7 @@ pub fn encode_address(destination: &Destination, output: &mut Vec) -> io::Re Ok(()) } -fn decode_address(packet: &[u8]) -> Result<(Destination, usize), Socks5CodecError> { +pub(crate) fn decode_address(packet: &[u8]) -> Result<(Destination, usize), Socks5CodecError> { let address_type = *packet.first().ok_or(Socks5CodecError::Truncated)?; match address_type { ADDRESS_IPV4 => { diff --git a/src/transport/grpc.rs b/src/transport/grpc.rs new file mode 100644 index 0000000..6e37f70 --- /dev/null +++ b/src/transport/grpc.rs @@ -0,0 +1,591 @@ +use std::{ + future::Future, + io, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + task::{Context, Poll, ready}, +}; + +use bytes::{Buf, Bytes, BytesMut}; +use futures_util::task::AtomicWaker; +use h2::{RecvStream, SendStream, client::ResponseFuture}; +use tokio::{ + io::{AsyncRead, AsyncWrite, ReadBuf}, + task::{AbortHandle, JoinHandle}, + time::{Instant, timeout_at}, +}; + +use super::h2_write::{Payload, Receipt, Sender, Writes}; +use super::{STREAM_BUFFER_BYTES as LIMIT, STREAM_CHUNK_BYTES as CHUNK}; +use crate::dispatch::BoxStream; + +#[derive(Default)] +struct Close { + stopped: AtomicBool, + reader: AtomicWaker, +} + +impl Close { + fn stop(&self) { + self.stopped.store(true, Ordering::SeqCst); + self.reader.wake(); + } +} + +/// Owns one supplied HTTP/2 connection (not a multiplexing pool). Stop +/// owns the join barrier; Drop is cancellation fallback, never a PASS barrier. +pub struct Driver { + task: Option>>, + close: Arc, +} + +impl Driver { + pub async fn stop(mut self) -> io::Result<()> { + self.close.stop(); + if let Some(task) = self.task.take() { + task.abort(); + if task.await.is_err_and(|error| !error.is_cancelled()) { + return Err(io::ErrorKind::Other.into()); + } + } + Ok(()) + } +} + +impl Drop for Driver { + fn drop(&mut self) { + self.close.stop(); + if let Some(task) = &self.task { + task.abort(); + } + } +} + +pub async fn grpc( + stream: BoxStream, + uri: &str, + deadline: Instant, +) -> io::Result<(BoxStream, Driver)> { + connect(stream, uri, deadline, Framing::Gun, false).await +} + +/// A protocol carrying a bidirectional TCP stream needs upload END_STREAM, +/// not RST_STREAM, when its application half-closes. The read half and driver +/// remain owned until the session is dropped or its owner explicitly stops. +/// This does not change the whole-close contract of `grpc` or legacy H2. +pub async fn grpc_duplex( + stream: BoxStream, + uri: &str, + deadline: Instant, +) -> io::Result<(BoxStream, Driver)> { + connect(stream, uri, deadline, Framing::Gun, true).await +} + +/// Legacy VMess/VLESS H2: PUT with an unframed byte stream, not gRPC. +pub async fn legacy_h2( + stream: BoxStream, + uri: &str, + deadline: Instant, +) -> io::Result<(BoxStream, Driver)> { + connect(stream, uri, deadline, Framing::Plain, false).await +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Framing { + Gun, + Plain, +} + +async fn connect( + stream: BoxStream, + uri: &str, + deadline: Instant, + framing: Framing, + half_close: bool, +) -> io::Result<(BoxStream, Driver)> { + let uri: http::Uri = uri + .parse() + .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + if !matches!(uri.scheme_str(), Some("http" | "https")) + || uri + .authority() + .is_none_or(|value| value.as_str().contains('@')) + || uri.to_string().len() > super::HTTP_HEAD_BYTES - 256 + { + return Err(io::ErrorKind::InvalidInput.into()); + } + let request = http::Request::builder() + .method(if framing == Framing::Gun { + "POST" + } else { + "PUT" + }) + .uri(uri); + let request = match framing { + Framing::Gun => request + .header("content-type", "application/grpc") + .header("te", "trailers"), + Framing::Plain => request.header("accept-encoding", "identity"), + }; + let request = request + .body(()) + .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + let (stream, writes) = Writes::wrap(stream); + let (sender, connection) = timeout_at( + deadline, + h2::client::Builder::new() + .enable_push(false) + .initial_window_size(LIMIT as u32) + .initial_connection_window_size((2 * LIMIT) as u32) + .max_frame_size(CHUNK as u32) + .max_header_list_size(CHUNK as u32) + .max_concurrent_streams(1) + .max_send_buffer_size(CHUNK) + .handshake(stream), + ) + .await + .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))? + .map_err(|_| io::Error::from(io::ErrorKind::ConnectionAborted))?; + let close = Arc::new(Close::default()); + let task = crate::resources::observation::spawn(connection); + let abort = task.abort_handle(); + // Immediately own the task, including cancellation during ready(). + let owner = Driver { + task: Some(task), + close: close.clone(), + }; + let sender = timeout_at(deadline, sender.ready()).await; + let mut sender = match sender { + Ok(Ok(sender)) => sender, + result => { + owner.stop().await?; + return Err(if result.is_err() { + io::ErrorKind::TimedOut + } else { + io::ErrorKind::ConnectionAborted + } + .into()); + } + }; + let (response, send) = match sender.send_request(request, false) { + Ok(stream) => stream, + Err(_) => { + owner.stop().await?; + return Err(io::ErrorKind::ConnectionAborted.into()); + } + }; + // Do not await response headers here: Mihomo may only flush them after the + // first VLESS bytes. Request writing and response reading must be independent. + Ok(( + Box::new(Grpc::new( + response, + send, + framing, + half_close, + deadline, + close, + Some(abort), + None, + writes, + )), + owner, + )) +} + +struct Grpc { + _observation: crate::resources::observation::Guard, + framing: Framing, + half_close: bool, + upload_closed: bool, + response: Option, + receive: Option, + send: SendStream, + writes: Writes, + pending: Option, + deadline: Pin>, + frame: Bytes, + wire: BytesMut, + payload: Bytes, + queued: Bytes, + eof: bool, + data_eof: bool, + close: Arc, + abort: Option, + _lease: Option, +} + +pub(super) async fn pooled_stream( + sender: Sender, + uri: &str, + user_agent: &str, + deadline: Instant, + lease: super::grpc_pool::Lease, +) -> io::Result { + let request = http::Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/grpc") + .header("te", "trailers") + .header("user-agent", user_agent) + .body(()) + .map_err(|_| invalid())?; + let writes = sender.writes; + let mut sender = timeout_at(deadline, sender.request.ready()) + .await + .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))? + .map_err(|_| io::Error::from(io::ErrorKind::ConnectionAborted))?; + let (response, send) = sender + .send_request(request, false) + .map_err(|_| io::Error::from(io::ErrorKind::ConnectionAborted))?; + Ok(Box::new(Grpc::new( + response, + send, + Framing::Gun, + false, + deadline, + Arc::default(), + None, + Some(lease), + writes, + ))) +} + +impl Drop for Grpc { + fn drop(&mut self) { + self.close.stop(); + self.send.send_reset(h2::Reason::CANCEL); + if let Some(abort) = &self.abort { + abort.abort(); + } + } +} + +/// sing-mux uses plain HTTP/2 CONNECT streams, not gRPC records. +#[cfg(feature = "outbound-vless")] +pub(super) async fn mux_stream(sender: Sender, deadline: Instant) -> io::Result { + let writes = sender.writes; + let mut sender = sender.request.ready().await.map_err(|_| invalid())?; + let request = http::Request::builder() + .method("CONNECT") + .uri("https://localhost") + .body(()) + .map_err(|_| invalid())?; + let (response, send) = sender.send_request(request, false).map_err(|_| invalid())?; + Ok(Box::new(Grpc::new( + response, + send, + Framing::Plain, + false, + deadline, + Arc::default(), + None, + None, + writes, + ))) +} + +fn invalid() -> io::Error { + io::ErrorKind::InvalidData.into() +} + +impl Grpc { + fn stopped(&self) -> bool { + self.close.stopped.load(Ordering::SeqCst) + || self._lease.as_ref().is_some_and(|lease| lease.stopped()) + } + #[allow(clippy::too_many_arguments)] + fn new( + response: ResponseFuture, + send: SendStream, + framing: Framing, + half_close: bool, + deadline: Instant, + close: Arc, + abort: Option, + lease: Option, + writes: Writes, + ) -> Self { + Self { + _observation: crate::resources::observation::track( + crate::resources::observation::ResourceKind::Session, + ), + framing, + half_close, + upload_closed: false, + response: Some(response), + receive: None, + send, + deadline: Box::pin(tokio::time::sleep_until(deadline)), + writes, + pending: None, + frame: Bytes::new(), + wire: BytesMut::new(), + payload: Bytes::new(), + queued: Bytes::new(), + eof: false, + data_eof: false, + close, + abort, + _lease: lease, + } + } + fn drain(&mut self, cx: &mut Context<'_>) -> Poll> { + if self.stopped() { + return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into())); + } + // Register for connection failure even while waiting on a write receipt: + // queued DATA can remain owned by a SendStream after its driver stops. + if self.send.poll_reset(cx).is_ready() { + return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into())); + } + for _ in 0..crate::limits::IO_POLL_BUDGET { + if let Some(pending) = &mut self.pending { + ready!(Pin::new(pending).poll(cx)).map_err(|_| io::ErrorKind::BrokenPipe)??; + self.pending = None; + } + if self.queued.is_empty() { + self.send.reserve_capacity(0); + return Poll::Ready(Ok(())); + } + self.send.reserve_capacity(self.queued.len()); + let count = ready!(self.send.poll_capacity(cx)) + .ok_or_else(|| io::Error::from(io::ErrorKind::BrokenPipe))? + .map_err(|_| io::Error::from(io::ErrorKind::BrokenPipe))?; + if count == 0 { + continue; + } + let count = count.min(self.queued.len()); + let (payload, receipt) = self.writes.payload(self.queued.split_to(count)); + self.send + .send_data(payload, false) + .map_err(|_| io::Error::from(io::ErrorKind::BrokenPipe))?; + self.pending = Some(receipt); + } + cx.waker().wake_by_ref(); + Poll::Pending + } + + fn record(&mut self) -> io::Result { + if self.wire.len() < 5 { + return Ok(false); + } + let length = u32::from_be_bytes(self.wire[1..5].try_into().unwrap()) as usize; + if self.wire[0] != 0 || !(2..=LIMIT + 4).contains(&length) { + return Err(invalid()); + } + if self.wire.len() < length + 5 { + return Ok(false); + } + if self.wire[5] != 0x0a { + return Err(invalid()); + } + let mut value = 0; + let mut start = None; + for index in 0..3 { + let byte = *self.wire.get(6 + index).ok_or_else(invalid)?; + value |= usize::from(byte & 0x7f) << (7 * index); + if byte & 0x80 == 0 { + start = Some(7 + index); + break; + } + } + let start = start.ok_or_else(invalid)?; + if value > LIMIT || start + value != length + 5 { + return Err(invalid()); + } + self.payload = self.wire.split().freeze().slice(start..); + Ok(true) + } +} + +impl AsyncRead for Grpc { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + output: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + if output.remaining() == 0 { + return Poll::Ready(Ok(())); + } + this.close.reader.register(cx.waker()); + if this.stopped() || this.eof { + return Poll::Ready(Ok(())); + } + if let Some(response) = &mut this.response { + // Lazy setup still uses the original deadline, even when headers + // are already queued. Established reads are not setup operations. + if this.deadline.as_mut().poll(cx).is_ready() { + return Poll::Ready(Err(io::ErrorKind::TimedOut.into())); + } + match Pin::new(response).poll(cx) { + Poll::Ready(result) => { + let response = result.map_err(|_| invalid())?; + if response.status() != 200 + || (this.framing == Framing::Gun + && !response.headers().get("content-type").is_some_and(|value| { + matches!( + value + .as_bytes() + .split(|byte| matches!(byte, b';' | b'+')) + .next(), + Some(b"application/grpc") + ) + })) + { + return Poll::Ready(Err(invalid())); + } + this.response = None; + this.receive = Some(response.into_body()); + } + Poll::Pending => return Poll::Pending, + } + } + if this.framing == Framing::Plain { + let receive = this.receive.as_mut().unwrap(); + for _ in 0..crate::limits::IO_POLL_BUDGET { + if !this.frame.is_empty() { + let count = this.frame.len().min(output.remaining()); + output.put_slice(&this.frame[..count]); + this.frame.advance(count); + receive + .flow_control() + .release_capacity(count) + .map_err(|_| invalid())?; + return Poll::Ready(Ok(())); + } + match ready!(receive.poll_data(cx)) { + Some(Ok(frame)) => this.frame = frame, + Some(Err(_)) => return Poll::Ready(Err(invalid())), + None => { + this.eof = true; + return Poll::Ready(Ok(())); + } + } + } + cx.waker().wake_by_ref(); + return Poll::Pending; + } + for _ in 0..crate::limits::IO_POLL_BUDGET { + if !this.payload.is_empty() { + let count = this.payload.len().min(output.remaining()); + output.put_slice(&this.payload[..count]); + this.payload.advance(count); + return Poll::Ready(Ok(())); + } + if this.record()? { + continue; + } + let receive = this.receive.as_mut().unwrap(); + if !this.frame.is_empty() { + let needed = if this.wire.len() < 5 { + 5 + } else { + 5 + u32::from_be_bytes(this.wire[1..5].try_into().unwrap()) as usize + }; + let count = (needed - this.wire.len()).min(this.frame.len()); + this.wire.extend_from_slice(&this.frame[..count]); + this.frame.advance(count); + // Only consumed bytes replenish the peer's window. + receive + .flow_control() + .release_capacity(count) + .map_err(|_| invalid())?; + continue; + } + if !this.data_eof { + match ready!(receive.poll_data(cx)) { + Some(Ok(frame)) => { + this.frame = frame; + continue; + } + Some(Err(_)) => return Poll::Ready(Err(invalid())), + None => this.data_eof = true, + } + } + if !this.wire.is_empty() { + return Poll::Ready(Err(invalid())); + } + let trailers = ready!(receive.poll_trailers(cx)).map_err(|_| invalid())?; + if trailers + .as_ref() + .and_then(|headers| headers.get("grpc-status")) + .is_some_and(|status| status != "0") + { + return Poll::Ready(Err(invalid())); + } + this.eof = true; + return Poll::Ready(Ok(())); + } + cx.waker().wake_by_ref(); + Poll::Pending + } +} + +impl AsyncWrite for Grpc { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + input: &[u8], + ) -> Poll> { + if self.stopped() || self.upload_closed { + return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into())); + } + ready!(self.drain(cx))?; + if input.is_empty() { + return Poll::Ready(Ok(0)); + } + let count = input.len().min(CHUNK); + if self.framing == Framing::Plain { + self.queued = Bytes::copy_from_slice(&input[..count]); + return Poll::Ready(Ok(count)); + } + let mut wire = Vec::with_capacity(count + 9); + wire.extend_from_slice(&[0; 5]); + wire.push(0x0a); + let mut length = count; + while length > 127 { + wire.push((length as u8 & 0x7f) | 0x80); + length >>= 7; + } + wire.push(length as u8); + wire.extend_from_slice(&input[..count]); + let total = (wire.len() - 5) as u32; + wire[1..5].copy_from_slice(&total.to_be_bytes()); + self.queued = wire.into(); + Poll::Ready(Ok(count)) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.drain(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.upload_closed { + return Poll::Ready(Ok(())); + } + if self.half_close { + ready!(self.drain(cx))?; + self.send + .send_data(Payload::empty(), true) + .map_err(|_| invalid())?; + self.upload_closed = true; + return Poll::Ready(Ok(())); + } + // Close the logical byte stream. A pooled physical connection belongs + // to the node and must survive another stream's RST_STREAM. + ready!(self.drain(cx))?; + self.upload_closed = true; + self.close.stop(); + self.send.send_reset(h2::Reason::CANCEL); + self.response = None; + self.receive = None; + self.queued = Bytes::new(); + self._lease.take(); + if let Some(abort) = &self.abort { + abort.abort(); + } + Poll::Ready(Ok(())) + } +} diff --git a/src/transport/grpc_pool.rs b/src/transport/grpc_pool.rs new file mode 100644 index 0000000..c824439 --- /dev/null +++ b/src/transport/grpc_pool.rs @@ -0,0 +1,277 @@ +//! Node-owned gRPC physical connections. Policy matches Mihomo's two threshold +//! branches; bounded idle retention does not impose a business-flow quota. +use super::h2_write::{Sender, Writes}; +use crate::{config::GrpcOptions, dispatch::BoxStream}; +use std::{ + future::Future, + io, + pin::Pin, + sync::{ + Arc, Mutex as SyncMutex, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + task::{Context, Poll}, + time::Duration, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite, ReadBuf}, + sync::Mutex, + time::{Instant, timeout_at}, +}; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; + +const MAX_IDLE_CONNECTIONS: usize = crate::limits::GRPC_IDLE_CONNECTIONS; +const PING_TIMEOUT: Duration = Duration::from_secs(crate::limits::GRPC_PING_TIMEOUT_SECONDS as u64); + +pub struct GrpcPool { + options: GrpcOptions, + connections: Arc>>, + opening: Mutex<()>, + cancellation: CancellationToken, + tasks: TaskTracker, +} +impl GrpcPool { + pub fn new(options: GrpcOptions) -> Self { + Self { + options, + connections: Arc::default(), + opening: Mutex::default(), + cancellation: CancellationToken::new(), + tasks: TaskTracker::new(), + } + } + /// The factory is invoked only for a new physical connection. A reused + /// transport keeps its original upstream selection and TLS identity. + pub async fn open( + &self, + uri: &str, + deadline: Instant, + connect: F, + ) -> io::Result + where + F: FnOnce() -> Fut, + Fut: Future>, + { + let operation = async { + // Only physical creation is serialized; waiting callers own their + // deadline/cancellation and no detached queue or worker is created. + let (sender, lease) = { + let _opening = self.opening.lock().await; + let existing = { + let mut connections = self.connections.lock().unwrap(); + connections.retain(|connection| !connection.closed.load(Ordering::Acquire)); + connections + .iter() + .min_by_key(|connection| connection.active.load(Ordering::Acquire)) + .filter(|connection| { + let active = connection.active.load(Ordering::Acquire); + active == 0 + || if self.options.max_connections > 0 { + connections.len() >= self.options.max_connections + || active < self.options.min_streams + } else { + self.options.max_streams > 0 + && active < self.options.max_streams + } + }) + .map(|connection| connection.reserve(&self.connections)) + .transpose()? + }; + match existing { + Some(pair) => pair, + None => { + let raw = connect().await?; + let connection = self.establish(raw).await?; + let pair = connection.reserve(&self.connections)?; + self.connections.lock().unwrap().push(connection); + pair + } + } + }; + super::grpc::pooled_stream(sender, uri, &self.options.user_agent, deadline, lease).await + }; + tokio::select! {biased; + ()=self.cancellation.cancelled()=>Err(io::ErrorKind::ConnectionAborted.into()), + result=timeout_at(deadline,operation)=>result.map_err(|_|io::Error::from(io::ErrorKind::TimedOut))?, + } + } + async fn establish(&self, raw: BoxStream) -> io::Result { + let last_read = Arc::new(SyncMutex::new(Instant::now())); + let (raw, writes) = Writes::wrap(raw); + let (sender, mut connection) = h2::client::Builder::new() + .enable_push(false) + .initial_window_size(super::STREAM_BUFFER_BYTES as u32) + .initial_connection_window_size((2 * super::STREAM_BUFFER_BYTES) as u32) + .max_frame_size(super::STREAM_CHUNK_BYTES as u32) + .max_header_list_size(super::STREAM_CHUNK_BYTES as u32) + .max_concurrent_streams(0) + .max_send_buffer_size(super::STREAM_CHUNK_BYTES) + .handshake(ActivityIo { + raw: Box::new(raw), + last_read: last_read.clone(), + }) + .await + .map_err(|_| io::Error::from(io::ErrorKind::ConnectionAborted))?; + let mut ping = connection.ping_pong().expect("single PING owner"); + let closed = Arc::new(AtomicBool::new(false)); + let lifetime = Lifetime(closed.clone()); + let token = self.cancellation.child_token(); + let cancellation = token.clone(); + let interval = self.options.ping_interval; + let observation = + crate::resources::observation::track(crate::resources::observation::ResourceKind::Task); + self.tasks.spawn(crate::resources::observation::bind(async move { + let _observation=observation; + let _lifetime=lifetime; + if interval==0 { + tokio::select! {biased; + ()=cancellation.cancelled()=>{}, + _=&mut connection=>{}, + } + return; + } + loop { + let idle_at=*last_read.lock().unwrap()+Duration::from_secs(interval); + tokio::select! {biased; + ()=cancellation.cancelled()=>break, + _=&mut connection=>break, + ()=tokio::time::sleep_until(idle_at)=>{ + if Instant::now()<*last_read.lock().unwrap()+Duration::from_secs(interval) {continue;} + tokio::select! {biased; + ()=cancellation.cancelled()=>break, + _=&mut connection=>break, + result=tokio::time::timeout(PING_TIMEOUT,ping.ping(h2::Ping::opaque()))=>{ + if !matches!(result,Ok(Ok(_))) {break;} + } + } + } + } + } + })); + Ok(Connection { + sender: Sender { + request: sender, + writes, + }, + closed, + active: Arc::default(), + token, + _observation: crate::resources::observation::track( + crate::resources::observation::ResourceKind::Pool, + ), + }) + } + pub fn begin_shutdown(&self) { + self.cancellation.cancel(); + } + pub async fn shutdown(&self) { + self.begin_shutdown(); + self.tasks.close(); + self.tasks.wait().await; + self.connections.lock().unwrap().clear(); + } +} +impl Drop for GrpcPool { + fn drop(&mut self) { + self.cancellation.cancel(); + } +} +struct Connection { + sender: Sender, + active: Arc, + closed: Arc, + token: CancellationToken, + _observation: crate::resources::observation::Guard, +} +impl Connection { + fn reserve(&self, pool: &Arc>>) -> io::Result<(Sender, Lease)> { + self.active + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| { + count.checked_add(1) + }) + .map_err(|_| io::Error::from(io::ErrorKind::OutOfMemory))?; + Ok(( + self.sender.clone(), + Lease { + active: self.active.clone(), + closed: self.closed.clone(), + pool: Arc::downgrade(pool), + }, + )) + } +} +impl Drop for Connection { + fn drop(&mut self) { + self.token.cancel(); + } +} +struct Lifetime(Arc); +impl Drop for Lifetime { + fn drop(&mut self) { + self.0.store(true, Ordering::Release); + } +} + +pub(super) struct Lease { + active: Arc, + closed: Arc, + pool: std::sync::Weak>>, +} +impl Lease { + pub(super) fn stopped(&self) -> bool { + self.closed.load(Ordering::Acquire) + } +} +impl Drop for Lease { + fn drop(&mut self) { + if self.active.fetch_sub(1, Ordering::AcqRel) == 1 + && let Some(pool) = self.pool.upgrade() + { + let mut idle = 0; + pool.lock().unwrap().retain(|connection| { + if connection.closed.load(Ordering::Acquire) { + return false; + } + if connection.active.load(Ordering::Acquire) > 0 { + return true; + } + idle += 1; + idle <= MAX_IDLE_CONNECTIONS + }); + } + } +} + +struct ActivityIo { + raw: BoxStream, + last_read: Arc>, +} +impl AsyncRead for ActivityIo { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let before = buf.filled().len(); + let result = Pin::new(&mut self.raw).poll_read(cx, buf); + if buf.filled().len() > before { + *self.last_read.lock().unwrap() = Instant::now(); + } + result + } +} +impl AsyncWrite for ActivityIo { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.raw).poll_write(cx, buf) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.raw).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.raw).poll_shutdown(cx) + } +} diff --git a/src/transport/h2_write.rs b/src/transport/h2_write.rs new file mode 100644 index 0000000..921f013 --- /dev/null +++ b/src/transport/h2_write.rs @@ -0,0 +1,240 @@ +//! Per-write completion at the supplied IO's flush boundary, not H1/H2 enqueue. +//! Each logical writer keeps at most one outstanding payload/receipt. No extra +//! driver, protocol decoder, socket, or connection-wide admission limit. +use crate::dispatch::BoxStream; +use bytes::{Buf, Bytes}; +use std::{ + io, + pin::Pin, + sync::{Arc, Mutex}, + task::{Context, Poll}, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite, ReadBuf}, + sync::oneshot, +}; + +pub(super) type Receipt = oneshot::Receiver>; +type Completion = oneshot::Sender>; + +#[derive(Clone, Debug)] +pub(super) struct Sender { + pub request: h2::client::SendRequest, + pub writes: Writes, +} + +#[derive(Clone, Default, Debug)] +pub(super) struct Writes(Arc>); + +#[derive(Default, Debug)] +struct State { + closed: bool, + encoded: Vec, +} + +impl Writes { + pub fn wrap(raw: BoxStream) -> (impl AsyncRead + AsyncWrite + Unpin + Send, Self) { + let writes = Self::default(); + ( + FlushIo { + raw, + writes: writes.clone(), + }, + writes, + ) + } + + pub fn payload(&self, bytes: Bytes) -> (Payload, Receipt) { + assert!(!bytes.is_empty()); + let (done, receipt) = oneshot::channel(); + ( + Payload { + bytes, + done: Some(done), + writes: self.clone(), + }, + receipt, + ) + } +} + +pub(super) struct Payload { + bytes: Bytes, + done: Option, + writes: Writes, +} + +impl Payload { + pub fn empty() -> Self { + Self { + bytes: Bytes::new(), + done: None, + writes: Writes::default(), + } + } +} + +impl std::fmt::Debug for Payload { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("H2Payload") + .field("remaining", &self.bytes.len()) + .finish() + } +} + +impl Buf for Payload { + fn remaining(&self) -> usize { + self.bytes.remaining() + } + fn chunk(&self) -> &[u8] { + self.bytes.chunk() + } + fn advance(&mut self, count: usize) { + self.bytes.advance(count); + if self.bytes.is_empty() + && let Some(done) = self.done.take() + { + let mut state = self.writes.0.lock().unwrap(); + if !state.closed { + // Encoding may only have copied into h2's own buffer. The IO + // wrapper acknowledges this after that buffer reaches raw.flush. + state.encoded.push(done); + } + } + } +} + +struct FlushIo { + raw: BoxStream, + writes: Writes, +} + +impl Drop for FlushIo { + fn drop(&mut self) { + let mut state = self.writes.0.lock().unwrap(); + state.closed = true; + state.encoded.clear(); + } +} + +impl AsyncRead for FlushIo { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + out: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.raw).poll_read(cx, out) + } +} + +impl AsyncWrite for FlushIo { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + input: &[u8], + ) -> Poll> { + Pin::new(&mut self.raw).poll_write(cx, input) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let result = std::task::ready!(Pin::new(&mut self.raw).poll_flush(cx)); + let encoded = std::mem::take(&mut self.writes.0.lock().unwrap().encoded); + for done in encoded { + let _ = done.send( + result + .as_ref() + .map(|_| ()) + .map_err(|e| io::Error::from(e.kind())), + ); + } + Poll::Ready(result) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.raw).poll_shutdown(cx) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + future::poll_fn, + sync::atomic::{AtomicU8, Ordering}, + }; + use tokio::io::AsyncWriteExt; + + struct Gate(Arc); + impl AsyncRead for Gate { + fn poll_read( + self: Pin<&mut Self>, + _: &mut Context<'_>, + _: &mut ReadBuf<'_>, + ) -> Poll> { + Poll::Pending + } + } + impl AsyncWrite for Gate { + fn poll_write( + self: Pin<&mut Self>, + _: &mut Context<'_>, + data: &[u8], + ) -> Poll> { + Poll::Ready(Ok(data.len())) + } + fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + match self.0.load(Ordering::SeqCst) { + 0 => Poll::Pending, + 1 => Poll::Ready(Ok(())), + _ => Poll::Ready(Err(io::ErrorKind::ConnectionReset.into())), + } + } + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.poll_flush(cx) + } + } + + #[tokio::test] + async fn receipt_waits_for_every_byte_and_the_supplied_io_flush() { + let gate = Arc::new(AtomicU8::new(1)); + let (mut raw, writes) = Writes::wrap(Box::new(Gate(gate.clone()))); + let (mut data, mut receipt) = writes.payload(Bytes::from_static(b"data")); + data.advance(2); + raw.flush().await.unwrap(); + assert!(matches!( + receipt.try_recv(), + Err(oneshot::error::TryRecvError::Empty) + )); + gate.store(0, Ordering::SeqCst); + data.advance(2); + assert!( + poll_fn(|cx| Poll::Ready(Pin::new(&mut raw).poll_flush(cx))) + .await + .is_pending() + ); + assert!(matches!( + receipt.try_recv(), + Err(oneshot::error::TryRecvError::Empty) + )); + gate.store(1, Ordering::SeqCst); + raw.flush().await.unwrap(); + receipt.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn failed_flush_and_dropped_payload_or_driver_never_report_success() { + let (mut raw, writes) = Writes::wrap(Box::new(Gate(Arc::new(AtomicU8::new(2))))); + let (mut data, receipt) = writes.payload(Bytes::from_static(b"data")); + data.advance(4); + assert!(raw.flush().await.is_err()); + assert_eq!( + receipt.await.unwrap().unwrap_err().kind(), + io::ErrorKind::ConnectionReset + ); + let (data, receipt) = writes.payload(Bytes::from_static(b"reset before encoding")); + drop(data); + assert!(receipt.await.is_err()); + let (mut data, receipt) = writes.payload(Bytes::from_static(b"data")); + data.advance(4); + drop(raw); + assert!(receipt.await.is_err()); + } +} diff --git a/src/transport/http_head.rs b/src/transport/http_head.rs new file mode 100644 index 0000000..e62f293 --- /dev/null +++ b/src/transport/http_head.rs @@ -0,0 +1,60 @@ +//! Bounded response headers shared by stream handshakes; leftover bytes are +//! preserved verbatim for the framing layer. No body buffering or diagnostics. +use std::io; +use tokio::io::{AsyncRead, AsyncReadExt}; + +pub const HTTP_HEAD_BYTES: usize = 16 * 1024; +pub const HTTP_HEADER_COUNT: usize = 100; + +pub(super) async fn read_response( + stream: &mut (impl AsyncRead + Unpin), +) -> io::Result<(http::Response<()>, Vec)> { + let mut wire = Vec::new(); + loop { + if let Some(end) = wire + .windows(4) + .position(|part| part == b"\r\n\r\n") + .map(|start| start + 4) + { + if end > HTTP_HEAD_BYTES { + return Err(invalid()); + } + let mut headers = [httparse::EMPTY_HEADER; HTTP_HEADER_COUNT]; + let mut parsed = httparse::Response::new(&mut headers); + match parsed.parse(&wire[..end]).map_err(|_| invalid())? { + httparse::Status::Complete(length) if length == end => {} + _ => return Err(invalid()), + } + let version = match parsed.version { + Some(0) => http::Version::HTTP_10, + Some(1) => http::Version::HTTP_11, + _ => return Err(invalid()), + }; + let mut response = http::Response::builder() + .version(version) + .status(parsed.code.ok_or_else(invalid)?); + for header in parsed.headers.iter() { + response = response.header(header.name, header.value); + } + let response = response.body(()).map_err(|_| invalid())?; + return Ok((response, wire[end..].to_vec())); + } + if wire.len() >= HTTP_HEAD_BYTES { + return Err(invalid()); + } + let mut chunk = [0; 1024]; + let remaining = (HTTP_HEAD_BYTES - wire.len()).min(chunk.len()); + let count = stream.read(&mut chunk[..remaining]).await?; + if count == 0 { + return Err(io::ErrorKind::UnexpectedEof.into()); + } + wire.extend_from_slice(&chunk[..count]); + } +} + +fn invalid() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + "invalid transport HTTP response", + ) +} diff --git a/src/transport/http_obfs.rs b/src/transport/http_obfs.rs new file mode 100644 index 0000000..1cf41b9 --- /dev/null +++ b/src/transport/http_obfs.rs @@ -0,0 +1,193 @@ +//! One HTTP/1 request/response header followed by unframed bytes. The supplied +//! protocol prefix is the first request body; later writes are never re-framed. +use std::{ + io, + pin::Pin, + task::{Context, Poll, ready}, +}; + +use bytes::{Buf, Bytes}; +use futures_util::future::BoxFuture; +use tokio::{ + io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf, ReadHalf, WriteHalf}, + time::{Instant, timeout_at}, +}; + +use super::http_head::{HTTP_HEAD_BYTES, HTTP_HEADER_COUNT, read_response}; +use crate::dispatch::BoxStream; + +#[derive(Clone)] +pub struct HttpObfsOptions { + head: Vec, +} + +impl std::fmt::Debug for HttpObfsOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HttpObfsOptions").finish_non_exhaustive() + } +} + +impl HttpObfsOptions { + /// A caller selects one configured path/Host before constructing options. + /// Framing headers are reserved, so configuration cannot inject a second body. + pub fn new(method: http::Method, uri: &str, headers: http::HeaderMap) -> io::Result { + let uri: http::Uri = uri.parse().map_err(|_| invalid())?; + if !matches!(uri.scheme_str(), Some("http" | "https")) + || headers.len() > HTTP_HEADER_COUNT - 2 + { + return Err(invalid()); + } + let authority = uri + .authority() + .filter(|value| !value.as_str().contains('@')) + .ok_or_else(invalid)?; + let host = match headers.get("host") { + Some(value) => { + let value: http::uri::Authority = value + .to_str() + .map_err(|_| invalid())? + .parse() + .map_err(|_| invalid())?; + if value.as_str().contains('@') { + return Err(invalid()); + } + value.to_string() + } + None => authority.to_string(), + }; + let path = uri.path_and_query().map_or("/", |path| path.as_str()); + if !path.starts_with('/') { + return Err(invalid()); + } + let mut head = format!("{method} {path} HTTP/1.1\r\nhost: {host}\r\n").into_bytes(); + for (name, value) in &headers { + if matches!(name.as_str(), "content-length" | "transfer-encoding") + || headers.get_all(name).iter().count() != 1 + { + return Err(invalid()); + } + if name == "host" { + continue; + } + head.extend_from_slice(name.as_str().as_bytes()); + head.extend_from_slice(b": "); + head.extend_from_slice(value.as_bytes()); + head.extend_from_slice(b"\r\n"); + } + // Reserve the maximum usize decimal length and final framing headers. + if head.len() + 42 > HTTP_HEAD_BYTES { + return Err(invalid()); + } + Ok(Self { head }) + } +} + +fn invalid() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + "invalid HTTP transport options", + ) +} + +pub async fn http_obfs( + mut stream: BoxStream, + options: &HttpObfsOptions, + prefix: &[u8], + deadline: Instant, +) -> io::Result { + timeout_at(deadline, async { + stream.write_all(&options.head).await?; + stream + .write_all(format!("content-length: {}\r\n\r\n", prefix.len()).as_bytes()) + .await?; + stream.write_all(prefix).await?; + stream.flush().await + }) + .await + .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))??; + let (mut reader, writer) = tokio::io::split(stream); + // No task or socket: the caller's first read drives the response header. + let response = Box::pin(async move { + let (_, tail) = timeout_at(deadline, read_response(&mut reader)) + .await + .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))??; + Ok((reader, Bytes::from(tail))) + }); + Ok(Box::new(HttpStream { + _observation: crate::resources::observation::track( + crate::resources::observation::ResourceKind::Session, + ), + response: Some(response), + reader: None, + writer, + tail: Bytes::new(), + failed: false, + })) +} + +type Response = BoxFuture<'static, io::Result<(ReadHalf, Bytes)>>; + +struct HttpStream { + _observation: crate::resources::observation::Guard, + response: Option, + reader: Option>, + writer: WriteHalf, + tail: Bytes, + failed: bool, +} + +impl AsyncRead for HttpStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + output: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + if output.remaining() == 0 { + return Poll::Ready(Ok(())); + } + if this.failed { + return Poll::Ready(Err(io::ErrorKind::ConnectionAborted.into())); + } + if let Some(response) = &mut this.response { + let result = ready!(response.as_mut().poll(cx)); + this.response = None; + match result { + Ok((reader, tail)) => { + this.reader = Some(reader); + this.tail = tail; + } + Err(error) => { + this.failed = true; + return Poll::Ready(Err(error)); + } + } + } + if !this.tail.is_empty() { + let count = output.remaining().min(this.tail.len()); + output.put_slice(&this.tail[..count]); + this.tail.advance(count); + return Poll::Ready(Ok(())); + } + Pin::new(this.reader.as_mut().unwrap()).poll_read(cx, output) + } +} + +impl AsyncWrite for HttpStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + input: &[u8], + ) -> Poll> { + if self.failed { + return Poll::Ready(Err(io::ErrorKind::ConnectionAborted.into())); + } + Pin::new(&mut self.writer).poll_write(cx, input) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.writer).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.writer).poll_shutdown(cx) + } +} diff --git a/src/transport/mod.rs b/src/transport/mod.rs index a08716b..703ae48 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -1,3 +1,40 @@ //! Outbound wire transports. +#[cfg(feature = "outbound-vless")] +pub(crate) mod sing_mux; +#[cfg(feature = "outbound-vless")] pub mod xhttp; + +pub const STREAM_CHUNK_BYTES: usize = 16 * 1024; +pub const STREAM_BUFFER_BYTES: usize = 64 * 1024; + +#[cfg(feature = "stream-transport")] +mod ws; +#[cfg(feature = "stream-transport")] +pub use ws::{ + MAX_EARLY_DATA_BYTES, WebSocketEarlyData, WebSocketOptions, connect_websocket, http_upgrade, + websocket, +}; + +#[cfg(feature = "stream-transport")] +mod http_head; +#[cfg(feature = "stream-transport")] +pub use http_head::{HTTP_HEAD_BYTES, HTTP_HEADER_COUNT}; + +#[cfg(feature = "stream-transport")] +mod grpc; +#[cfg(feature = "stream-transport")] +mod grpc_pool; +#[cfg(feature = "stream-transport")] +mod h2_write; +#[cfg(feature = "stream-transport")] +pub use grpc::{Driver as StreamDriver, grpc, grpc_duplex, legacy_h2}; +#[cfg(feature = "stream-transport")] +pub use grpc_pool::GrpcPool; + +#[cfg(feature = "stream-transport")] +mod http_obfs; +#[cfg(feature = "stream-transport")] +pub use http_obfs::{HttpObfsOptions, http_obfs}; +#[cfg(feature = "quic-transport")] +pub mod quic; diff --git a/src/transport/quic.rs b/src/transport/quic.rs new file mode 100644 index 0000000..041f88b --- /dev/null +++ b/src/transport/quic.rs @@ -0,0 +1,355 @@ +//! Single-connection, bounded public DatagramTransport to AsyncUdpSocket adapter. + +mod runtime; +pub(crate) use runtime::OwnedRuntime; + +use std::{ + collections::VecDeque, + io::{self, IoSliceMut}, + net::SocketAddr, + pin::Pin, + sync::{Arc, Mutex}, + task::{Context, Poll}, +}; + +use crate::{ + dispatch::{DatagramBudget, DatagramTransport, DispatchError}, + session::{Datagram, Destination}, +}; +use bytes::Bytes; +use futures_util::task::AtomicWaker; +use quinn::{AsyncUdpSocket, UdpPoller}; +use tokio::{sync::Notify, task::JoinHandle}; +use tokio_util::sync::CancellationToken; + +pub const QUEUE_LIMIT: usize = 32; +pub const CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); + +#[derive(Clone, Copy, Debug, Default)] +pub struct Stats { + pub sent: usize, + pub received: usize, + pub rejected_source: usize, + pub rejected_oversize: usize, + pub dropped_full: usize, + pub peak_outgoing: usize, + pub peak_incoming: usize, +} + +#[derive(Default)] +struct State { + incoming: VecDeque, + outgoing: VecDeque, + closed: bool, + error: Option, + stats: Stats, +} + +#[derive(Default)] +struct Shared { + state: Mutex, + changed: Notify, + reader: AtomicWaker, + writer: AtomicWaker, +} + +impl Shared { + fn finish(&self, error: Option) { + let mut state = self.state.lock().unwrap(); + state.closed = true; + state.error = state.error.or(error); + state.incoming.clear(); + state.outgoing.clear(); + drop(state); + self.reader.wake(); + self.writer.wake(); + } +} + +/// One QUIC connection to one logical peer; no implicit socket creation or DNS. +pub struct DatagramSocket { + peer: SocketAddr, + budget: DatagramBudget, + shared: Arc, +} + +impl std::fmt::Debug for DatagramSocket { + fn fmt(&self, output: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + output + .debug_struct("ControlledDatagramSocket") + .finish_non_exhaustive() + } +} + +impl DatagramSocket { + /// The endpoint must configure its QUIC MTU no larger than this budget. + pub fn budget(&self) -> DatagramBudget { + self.budget + } + + pub fn stats(&self) -> Stats { + self.shared.state.lock().unwrap().stats + } +} + +pub struct DatagramDriver { + shared: Arc, + cancel: CancellationToken, + task: Option>>, +} + +impl DatagramDriver { + pub async fn stop(mut self) -> io::Result<()> { + self.join().await + } + + /// Cancellation-safe join for an owner which retains this driver during + /// a retiring-path grace period. A cancelled join can be awaited again. + pub(crate) async fn join(&mut self) -> io::Result<()> { + self.cancel.cancel(); + let Some(task) = self.task.as_mut() else { + return Ok(()); + }; + let result = task.await; + self.task.take(); + result.map_err(|_| io::Error::from(io::ErrorKind::Other))? + } +} + +impl Drop for DatagramDriver { + fn drop(&mut self) { + self.cancel.cancel(); + self.shared.finish(None); + if let Some(task) = self.task.take() { + task.abort(); + } + } +} + +pub fn attach( + transport: Box, + peer: SocketAddr, + budget: DatagramBudget, +) -> io::Result<(Arc, DatagramDriver)> { + attach_mapped(transport, peer, peer, budget) +} + +/// Keep one logical QUIC peer while this transport owns exactly one physical +/// endpoint. Each hop uses a fresh VCore transport; no direct socket creation. +pub fn attach_mapped( + mut transport: Box, + logical_peer: SocketAddr, + physical_peer: SocketAddr, + budget: DatagramBudget, +) -> io::Result<(Arc, DatagramDriver)> { + if [logical_peer, physical_peer] + .iter() + .any(|peer| peer.port() == 0 || peer.ip().is_unspecified() || peer.ip().is_multicast()) + { + return Err(io::ErrorKind::InvalidInput.into()); + } + let budget = budget.intersect(transport.payload_budget(&Destination::Ip(physical_peer))); + budget.quic_payload_limit()?; + let shared = Arc::new(Shared::default()); + let socket = Arc::new(DatagramSocket { + peer: logical_peer, + budget, + shared: shared.clone(), + }); + let cancel = CancellationToken::new(); + let task_shared = shared.clone(); + let task_cancel = cancel.clone(); + let task = crate::resources::observation::spawn(async move { + let result = drive( + &mut *transport, + physical_peer, + budget, + &task_shared, + &task_cancel, + ) + .await; + task_shared.finish(result.as_ref().err().map(io::Error::kind)); + // The owned upstream must not hold synchronous Stop indefinitely. + let closed = tokio::time::timeout(CLOSE_TIMEOUT, transport.close()).await; + drop(transport); + result?; + closed + .map_err(|_| io::ErrorKind::TimedOut)? + .map_err(dispatch_error) + }); + Ok(( + socket, + DatagramDriver { + shared, + cancel, + task: Some(task), + }, + )) +} + +async fn drive( + transport: &mut dyn DatagramTransport, + peer: SocketAddr, + budget: DatagramBudget, + shared: &Shared, + cancel: &CancellationToken, +) -> io::Result<()> { + let mut has_sent = false; + loop { + let can_receive = shared.state.lock().unwrap().incoming.len() < QUEUE_LIMIT; + tokio::select! { + _ = cancel.cancelled() => return Ok(()), + _ = shared.changed.notified() => {}, + received = transport.receive(), if has_sent && can_receive => { + let datagram = received.map_err(dispatch_error)?; + let mut state = shared.state.lock().unwrap(); + if datagram.remote != Destination::Ip(peer) { + state.stats.rejected_source += 1; + } else if datagram.payload.len() > usize::from(budget.receive()) { + state.stats.rejected_oversize += 1; + } else if state.incoming.len() == QUEUE_LIMIT { + state.stats.dropped_full += 1; + } else { + state.incoming.push_back(datagram.payload); + state.stats.received += 1; + state.stats.peak_incoming = state.stats.peak_incoming.max(state.incoming.len()); + crate::resources::observation::observe_queue( + crate::resources::observation::QueueKind::QuicIncoming, + state.incoming.len(), + QUEUE_LIMIT, + ); + drop(state); + shared.reader.wake(); + } + } + } + let payload = shared.state.lock().unwrap().outgoing.pop_front(); + if let Some(payload) = payload { + shared.writer.wake(); + tokio::select! { + _ = cancel.cancelled() => return Ok(()), + sent = transport.send(Datagram { remote: Destination::Ip(peer), payload, sniffed_domain: None }) => sent.map_err(dispatch_error)?, + } + has_sent = true; + let mut state = shared.state.lock().unwrap(); + state.stats.sent += 1; + if !state.outgoing.is_empty() { + shared.changed.notify_one(); + } + } + } +} + +fn dispatch_error(error: DispatchError) -> io::Error { + match error { + DispatchError::NotAllowed => io::ErrorKind::PermissionDenied, + DispatchError::TimedOut => io::ErrorKind::TimedOut, + _ => io::ErrorKind::ConnectionAborted, + } + .into() +} + +struct PacketPoller(Arc); + +impl std::fmt::Debug for PacketPoller { + fn fmt(&self, output: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + output.write_str("PacketPoller") + } +} + +impl UdpPoller for PacketPoller { + fn poll_writable(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.0.writer.register(cx.waker()); + let state = self.0.state.lock().unwrap(); + if state.closed { + return Poll::Ready(Err(state.error.unwrap_or(io::ErrorKind::BrokenPipe).into())); + } + if state.outgoing.len() < QUEUE_LIMIT { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } +} + +impl AsyncUdpSocket for DatagramSocket { + fn create_io_poller(self: Arc) -> Pin> { + Box::pin(PacketPoller(self.shared.clone())) + } + + fn try_send(&self, transmit: &quinn::udp::Transmit<'_>) -> io::Result<()> { + if transmit.destination != self.peer + || transmit.contents.len() > usize::from(self.budget.transmit()) + || transmit.segment_size.is_some() + || transmit.src_ip.is_some() + { + return Err(io::ErrorKind::InvalidInput.into()); + } + let mut state = self.shared.state.lock().unwrap(); + if state.closed { + return Err(state.error.unwrap_or(io::ErrorKind::BrokenPipe).into()); + } + if state.outgoing.len() == QUEUE_LIMIT { + return Err(io::ErrorKind::WouldBlock.into()); + } + state + .outgoing + .push_back(Bytes::copy_from_slice(transmit.contents)); + state.stats.peak_outgoing = state.stats.peak_outgoing.max(state.outgoing.len()); + crate::resources::observation::observe_queue( + crate::resources::observation::QueueKind::QuicOutgoing, + state.outgoing.len(), + QUEUE_LIMIT, + ); + drop(state); + self.shared.changed.notify_one(); + Ok(()) + } + + fn poll_recv( + &self, + cx: &mut Context<'_>, + buffers: &mut [IoSliceMut<'_>], + meta: &mut [quinn::udp::RecvMeta], + ) -> Poll> { + self.shared.reader.register(cx.waker()); + let mut state = self.shared.state.lock().unwrap(); + if state.closed { + return Poll::Ready(Err(state.error.unwrap_or(io::ErrorKind::BrokenPipe).into())); + } + let Some(payload) = state.incoming.front() else { + return Poll::Pending; + }; + if buffers.is_empty() || meta.is_empty() || buffers[0].len() < payload.len() { + return Poll::Ready(Err(io::ErrorKind::InvalidInput.into())); + } + buffers[0][..payload.len()].copy_from_slice(payload); + meta[0] = quinn::udp::RecvMeta { + addr: self.peer, + len: payload.len(), + stride: payload.len(), + ecn: None, + dst_ip: None, + }; + state.incoming.pop_front(); + // Resume the single transport owner when the consumer frees capacity. + self.shared.changed.notify_one(); + Poll::Ready(Ok(1)) + } + + fn local_addr(&self) -> io::Result { + // Logical socket metadata only. VCore's public datagram transport owns + // physical sockets and intentionally does not expose their bound ports. + Ok(if self.peer.is_ipv4() { + "0.0.0.0:0" + } else { + "[::]:0" + } + .parse() + .unwrap()) + } + + fn may_fragment(&self) -> bool { + false + } +} diff --git a/src/transport/quic/runtime.rs b/src/transport/quic/runtime.rs new file mode 100644 index 0000000..3ac7b20 --- /dev/null +++ b/src/transport/quic/runtime.rs @@ -0,0 +1,67 @@ +//! Shared ownership of Quinn background work; accepts protected IO only. +use crate::resources::observation::{self, ResourceKind}; +use std::{ + future::Future, + io, + pin::Pin, + sync::{Arc, Mutex}, +}; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; + +/// Quinn's runtime hook makes its internal tasks part of the same stop barrier. +#[derive(Debug)] +pub(crate) struct OwnedRuntime { + admission: Mutex<()>, + pub cancel: CancellationToken, + tasks: TaskTracker, +} +impl OwnedRuntime { + pub(crate) fn new(cancel: CancellationToken) -> Self { + Self { + admission: Mutex::new(()), + cancel, + tasks: TaskTracker::new(), + } + } + pub fn spawn_owned(&self, future: impl Future + Send + 'static) -> io::Result<()> { + let _admission = self.admission.lock().unwrap(); + if self.cancel.is_cancelled() { + return Err(io::ErrorKind::ConnectionAborted.into()); + } + let cancel = self.cancel.clone(); + let observation = observation::track(ResourceKind::Task); + self.tasks.spawn(observation::bind(async move { + let _observation = observation; + tokio::select! {biased; ()=cancel.cancelled()=>{}, ()=future=>{}} + })); + Ok(()) + } + pub(crate) async fn stop(&self) { + { + let _admission = self.admission.lock().unwrap(); + self.cancel.cancel(); + self.tasks.close(); + } + self.tasks.wait().await; + } +} +impl quinn::Runtime for OwnedRuntime { + fn new_timer(&self, instant: std::time::Instant) -> Pin> { + quinn::Runtime::new_timer(&quinn::TokioRuntime, instant) + } + fn spawn(&self, future: Pin + Send>>) { + let _ = self.spawn_owned(future); + } + fn wrap_udp_socket( + &self, + _: std::net::UdpSocket, + ) -> io::Result> { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "QUIC accepts controlled datagram IO only", + )) + } + fn now(&self) -> std::time::Instant { + tokio::time::Instant::now().into_std() + } +} diff --git a/src/transport/sing_mux.rs b/src/transport/sing_mux.rs new file mode 100644 index 0000000..fa9ae2b --- /dev/null +++ b/src/transport/sing_mux.rs @@ -0,0 +1,372 @@ +//! Node-owned sing-mux sessions over supplied authenticated VLESS byte streams. +//! No sockets, resolver, global pool, or detached protocol tasks live here. +use crate::{ + config::{SingMuxConfig, SingMuxProtocol}, + dispatch::BoxStream, + resources::observation::{self, ResourceKind}, + session::Destination, +}; +use bytes::Bytes; +use std::{ + future::Future, + io, + pin::Pin, + sync::{ + Arc, Mutex, Weak, + atomic::{AtomicUsize, Ordering}, + }, + task::{Context, Poll, ready}, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf}, + time::{Instant, timeout_at}, +}; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; + +const IDLE_RETAINED: usize = crate::limits::SING_MUX_IDLE_CONNECTIONS; +pub(crate) mod datagram; +mod h2_driver; +mod padding; +mod smux_driver; +mod yamux_driver; +pub(crate) struct Pool { + options: SingMuxConfig, + state: Arc>, + opening: tokio::sync::Mutex<()>, + cancel: CancellationToken, + tasks: TaskTracker, +} +#[derive(Default)] +struct State { + closed: bool, + connections: Vec, +} +struct Connection { + sender: Sender, + active: Arc, + issued: AtomicUsize, + cancel: CancellationToken, + _observation: observation::Guard, +} +#[derive(Clone)] +enum Sender { + H2(super::h2_write::Sender), + Yamux(yamux_driver::Client), + Smux(smux_driver::Client), +} +impl Drop for Connection { + fn drop(&mut self) { + self.cancel.cancel(); + } +} +impl Pool { + pub(crate) fn new(options: SingMuxConfig) -> Self { + Self { + options, + state: Arc::default(), + opening: Default::default(), + cancel: CancellationToken::new(), + tasks: TaskTracker::new(), + } + } + pub(crate) fn only_tcp(&self) -> bool { + self.options.only_tcp + } + pub(crate) async fn open( + &self, + target: &Destination, + udp: bool, + deadline: Instant, + connect: F, + ) -> io::Result + where + F: FnOnce() -> Fut, + Fut: Future>, + { + let operation = async { + let (sender, lease) = { + let _opening = self.opening.lock().await; + let existing = { + let mut state = self.state.lock().unwrap(); + if state.closed { + return Err(io::ErrorKind::ConnectionAborted.into()); + } + state.connections.retain(|c| !c.cancel.is_cancelled()); + state + .connections + .iter() + .filter(|c| { + !matches!(c.sender, Sender::Yamux(_)) + || c.issued.load(Ordering::Acquire) < yamux_driver::STREAMS + }) + .min_by_key(|c| c.active.load(Ordering::Acquire)) + .filter(|c| { + let active = c.active.load(Ordering::Acquire); + active == 0 + || if self.options.max_connections > 0 { + state.connections.len() >= self.options.max_connections as usize + || active < self.options.min_streams as usize + } else { + self.options.max_streams > 0 + && active < self.options.max_streams as usize + } + }) + .map(|c| c.reserve(&self.state)) + }; + if let Some(pair) = existing { + pair + } else { + let mut raw = connect().await?; + let protocol = match self.options.protocol { + SingMuxProtocol::Smux => 0, + SingMuxProtocol::Yamux => 1, + SingMuxProtocol::H2Mux => 2, + }; + if self.options.padding { + let size = rand::random_range(256_usize..768); + let mut wire = vec![1, protocol, 1]; + wire.extend_from_slice(&(size as u16).to_be_bytes()); + wire.resize(5 + size, 0); + raw.write_all(&wire).await?; + } else { + raw.write_all(&[0, protocol]).await?; + } + raw.flush().await?; + if self.options.padding { + raw = Box::new(padding::Padding::new(raw)); + } + let connection = self.establish(raw).await?; + let mut state = self.state.lock().unwrap(); + if state.closed { + return Err(io::ErrorKind::ConnectionAborted.into()); + } + let pair = connection.reserve(&self.state); + state.connections.push(connection); + pair + } + }; + let mut raw = match sender { + Sender::H2(sender) => super::grpc::mux_stream(sender, deadline).await?, + Sender::Yamux(sender) => sender.open().await?, + Sender::Smux(sender) => sender.open().await?, + }; + let mut request = Vec::with_capacity(262); + request.extend_from_slice(&(if udp { 3_u16 } else { 0 }).to_be_bytes()); + crate::socks5::encode_address(target, &mut request)?; + raw.write_all(&request).await?; + raw.flush().await?; + Ok(Box::new(ResponseStream { + raw: Some(raw), + lease: Some(lease), + response: false, + deadline: Box::pin(tokio::time::sleep_until(deadline)), + }) as BoxStream) + }; + tokio::select! { biased; + () = self.cancel.cancelled() => Err(io::ErrorKind::ConnectionAborted.into()), + result = timeout_at(deadline, operation) => result.map_err(|_|io::Error::from(io::ErrorKind::TimedOut))?, + } + } + async fn establish(&self, raw: BoxStream) -> io::Result { + if self.options.protocol == SingMuxProtocol::Smux { + let (sender, driver) = smux_driver::new(raw); + return self.own(Sender::Smux(sender), driver); + } + if self.options.protocol == SingMuxProtocol::Yamux { + let (sender, driver) = yamux_driver::new(raw); + return self.own(Sender::Yamux(sender), driver); + } + let (sender, driver) = h2_driver::new(raw).await?; + self.own(Sender::H2(sender), driver) + } + fn own( + &self, + sender: Sender, + driver: impl Future + Send + 'static, + ) -> io::Result { + let cancel = self.cancel.child_token(); + let token = cancel.clone(); + let state = self.state.lock().unwrap(); + if state.closed { + return Err(io::ErrorKind::ConnectionAborted.into()); + } + let guard = observation::track(ResourceKind::Task); + self.tasks.spawn(observation::bind(async move { + let _guard = guard; + tokio::select! { biased; ()=token.cancelled()=>{}, _=driver=>{} } + token.cancel(); + })); + Ok(Connection { + sender, + active: Arc::default(), + issued: AtomicUsize::new(0), + cancel, + _observation: observation::track(ResourceKind::Pool), + }) + } + pub(crate) fn begin_stop(&self) { + let mut state = self.state.lock().unwrap(); + state.closed = true; + self.cancel.cancel(); + state.connections.clear(); + self.tasks.close(); + } + pub(crate) async fn stop(&self) { + self.begin_stop(); + self.tasks.wait().await; + } +} +impl Drop for Pool { + fn drop(&mut self) { + self.begin_stop(); + } +} +impl Connection { + fn reserve(&self, pool: &Arc>) -> (Sender, Lease) { + self.active.fetch_add(1, Ordering::AcqRel); + // A dropped logical lease is not proof that yamux has processed its + // reset. Its public poll_new_outbound closes the entire connection on + // TooManyStreams, including siblings. Retire admission after a bounded + // number of issued streams; let existing ones drain on the old driver. + // This is a per-transport retirement budget, never a node-wide quota. + self.issued.fetch_add(1, Ordering::AcqRel); + ( + self.sender.clone(), + Lease { + active: self.active.clone(), + pool: Arc::downgrade(pool), + }, + ) + } +} +struct Lease { + active: Arc, + pool: Weak>, +} +impl Drop for Lease { + fn drop(&mut self) { + if self.active.fetch_sub(1, Ordering::AcqRel) == 1 + && let Some(pool) = self.pool.upgrade() + { + let mut idle = 0; + pool.lock().unwrap().connections.retain(|c| { + if c.cancel.is_cancelled() { + return false; + } + if c.active.load(Ordering::Acquire) > 0 { + return true; + } + if matches!(c.sender, Sender::Yamux(_)) + && c.issued.load(Ordering::Acquire) >= yamux_driver::STREAMS + { + return false; + } + idle += 1; + idle <= IDLE_RETAINED + }); + } + } +} +struct ResponseStream { + raw: Option, + lease: Option, + response: bool, + deadline: Pin>, +} +impl AsyncRead for ResponseStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + out: &mut ReadBuf<'_>, + ) -> Poll> { + if out.remaining() == 0 || self.raw.is_none() { + return Poll::Ready(Ok(())); + } + if !self.response { + // A pooled physical connection does not extend this logical + // stream's setup deadline, including an already-buffered status. + if self.deadline.as_mut().poll(cx).is_ready() { + return Poll::Ready(Err(io::ErrorKind::TimedOut.into())); + } + let mut status = [0]; + let mut buf = ReadBuf::new(&mut status); + ready!(Pin::new(self.raw.as_mut().unwrap()).poll_read(cx, &mut buf))?; + if buf.filled() != [0] { + self.raw.take(); + self.lease.take(); + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::ConnectionRefused, + "sing-mux stream rejected", + ))); + } + self.response = true; + } + Pin::new(self.raw.as_mut().unwrap()).poll_read(cx, out) + } +} +impl AsyncWrite for ResponseStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(self.raw.as_mut().ok_or(io::ErrorKind::BrokenPipe)?).poll_write(cx, buf) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(self.raw.as_mut().ok_or(io::ErrorKind::BrokenPipe)?).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if let Some(raw) = &mut self.raw { + ready!(Pin::new(raw).poll_shutdown(cx))?; + } + self.raw.take(); + self.lease.take(); + Poll::Ready(Ok(())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::AsyncReadExt; + + #[tokio::test(start_paused = true)] + async fn lazy_status_obeys_setup_deadline_but_established_reads_do_not() { + for timing in ["late-first-poll", "late-after-pending", "established"] { + let (raw, mut peer) = tokio::io::duplex(16); + let mut stream = ResponseStream { + raw: Some(Box::new(raw)), + lease: None, + response: false, + deadline: Box::pin(tokio::time::sleep(std::time::Duration::from_secs(1))), + }; + if timing == "late-after-pending" { + assert!(futures_util::poll!(std::pin::pin!(stream.read_u8())).is_pending()); + tokio::time::advance(std::time::Duration::from_secs(2)).await; + } + peer.write_all(b"\x00a").await.unwrap(); + if timing == "established" { + assert_eq!(stream.read_u8().await.unwrap(), b'a'); + } + if timing != "late-after-pending" { + tokio::time::advance(std::time::Duration::from_secs(2)).await; + } + assert_eq!(stream.read(&mut []).await.unwrap(), 0); + peer.write_all(b"b").await.unwrap(); + if timing == "established" { + assert_eq!(stream.read_u8().await.unwrap(), b'b'); + } else { + assert_eq!( + stream.read_u8().await.unwrap_err().kind(), + io::ErrorKind::TimedOut, + "{timing}" + ); + // A late response cannot revive a stream after a timeout. + assert_eq!( + stream.read_u8().await.unwrap_err().kind(), + io::ErrorKind::TimedOut + ); + } + } + } +} diff --git a/src/transport/sing_mux/datagram.rs b/src/transport/sing_mux/datagram.rs new file mode 100644 index 0000000..aa456de --- /dev/null +++ b/src/transport/sing_mux/datagram.rs @@ -0,0 +1,158 @@ +//! Address-per-packet sing-mux UDP. Partial reads survive cancellation; a +//! partially sent frame poisons this logical stream, never its siblings. +use crate::{ + dispatch::{BoxStream, DatagramBudget, DatagramTransport, DispatchError}, + dns::resolution::ResolutionContext, + session::{Datagram, Destination}, +}; +use async_trait::async_trait; +use bytes::BytesMut; +use std::io; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + time::Instant, +}; +use tokio_util::sync::CancellationToken; + +pub(crate) struct DatagramIo { + io: Option, + input: BytesMut, + budget: DatagramBudget, + resolution: ResolutionContext, + deadline: Instant, + first: bool, + cancel: CancellationToken, +} +impl DatagramIo { + pub(crate) fn new( + io: BoxStream, + budget: DatagramBudget, + resolution: ResolutionContext, + deadline: Instant, + cancel: CancellationToken, + ) -> Self { + Self { + io: Some(io), + input: BytesMut::new(), + budget, + resolution, + deadline, + first: true, + cancel, + } + } + async fn fill(&mut self, length: usize) -> io::Result<()> { + if length > u16::MAX as usize + 261 { + return Err(io::ErrorKind::InvalidData.into()); + } + let mut scratch = [0; 1024]; + while self.input.len() < length { + let count = (length - self.input.len()).min(scratch.len()); + let n = self + .io + .as_mut() + .ok_or(io::ErrorKind::BrokenPipe)? + .read(&mut scratch[..count]) + .await?; + if n == 0 { + return Err(io::ErrorKind::UnexpectedEof.into()); + } + self.input.extend_from_slice(&scratch[..n]); + } + Ok(()) + } + async fn next(&mut self) -> io::Result { + self.fill(2).await?; + let address_len = match self.input[0] { + 1 => 7, + 4 => 19, + 3 => self.input[1] as usize + 4, + _ => return Err(io::ErrorKind::InvalidData.into()), + }; + self.fill(address_len + 2).await?; + let (remote, _) = crate::socks5::decode_address(&self.input[..address_len]) + .map_err(|_| io::ErrorKind::InvalidData)?; + let length = + u16::from_be_bytes(self.input[address_len..address_len + 2].try_into().unwrap()) + as usize; + if length == 0 { + return Err(io::ErrorKind::InvalidData.into()); + } + self.fill(address_len + 2 + length).await?; + let payload = self.input.split().freeze().slice(address_len + 2..); + Ok(Datagram { + remote, + payload, + sniffed_domain: None, + }) + } +} +#[async_trait] +impl DatagramTransport for DatagramIo { + fn payload_budget(&self, _: &Destination) -> DatagramBudget { + self.budget + } + async fn send(&mut self, packet: Datagram) -> Result<(), DispatchError> { + if packet.remote.port() == 0 + || packet.payload.is_empty() + || packet.payload.len() > self.budget.transmit() as usize + { + return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); + } + let deadline = if self.first { + self.deadline + } else { + Instant::now() + crate::outbound::DEFAULT_ESTABLISH_TIMEOUT + }; + let peer = tokio::select! { biased; + ()=self.cancel.cancelled()=>return Err(DispatchError::NotAllowed), + peer=self.resolution.resolve_ip(&packet.remote,deadline)=>peer?, + }; + let mut wire = Vec::with_capacity(packet.payload.len() + 21); + crate::socks5::encode_address(&peer.into(), &mut wire)?; + wire.extend_from_slice(&(packet.payload.len() as u16).to_be_bytes()); + wire.extend_from_slice(&packet.payload); + let mut io = self + .io + .take() + .ok_or_else(|| io::Error::from(io::ErrorKind::BrokenPipe))?; + tokio::select! { biased; + ()=self.cancel.cancelled()=>return Err(DispatchError::NotAllowed), + result=async { + let write=async {io.write_all(&wire).await?;io.flush().await}; + if self.first {tokio::time::timeout_at(deadline,write).await.map_err(|_|io::ErrorKind::TimedOut)?} else {write.await} + }=>result?, + } + self.first = false; + self.io = Some(io); + Ok(()) + } + async fn receive(&mut self) -> Result { + let cancel = self.cancel.clone(); + for count in 0_usize.. { + let result = tokio::select! {biased; + ()=cancel.cancelled()=>Err(io::ErrorKind::ConnectionAborted.into()), + result=self.next()=>result, + }; + match result { + Ok(packet) if packet.payload.len() <= self.budget.receive() as usize => { + return Ok(packet); + } + Ok(_) => {} + Err(error) => { + self.close().await?; + return Err(error.into()); + } + } + if count % crate::limits::IO_POLL_BUDGET == crate::limits::IO_POLL_BUDGET - 1 { + tokio::task::yield_now().await; + } + } + unreachable!() + } + async fn close(&mut self) -> Result<(), DispatchError> { + self.io.take(); + self.input.clear(); + Ok(()) + } +} diff --git a/src/transport/sing_mux/h2_driver.rs b/src/transport/sing_mux/h2_driver.rs new file mode 100644 index 0000000..5cd126c --- /dev/null +++ b/src/transport/sing_mux/h2_driver.rs @@ -0,0 +1,82 @@ +//! Caller-owned h2mux with Mihomo's 30-second read-idle PING policy. +use super::*; +use crate::transport::h2_write::{Sender, Writes}; +use std::time::Duration; + +pub(super) async fn new(raw: BoxStream) -> io::Result<(Sender, impl Future + Send)> { + let last_read = Arc::new(Mutex::new(Instant::now())); + let (raw, writes) = Writes::wrap(raw); + let (sender, mut connection) = h2::client::Builder::new() + .enable_push(false) + .initial_window_size(65536) + .initial_connection_window_size(131072) + .max_frame_size(16384) + .max_header_list_size(16384) + .max_concurrent_streams(0) + .max_send_buffer_size(16384) + .handshake(ActivityIo { + raw: Box::new(raw), + last_read: last_read.clone(), + }) + .await + .map_err(|_| io::ErrorKind::ConnectionAborted)?; + let mut ping = connection.ping_pong().expect("single sing-mux PING owner"); + Ok(( + Sender { + request: sender, + writes, + }, + async move { + let interval = Duration::from_secs(30); + loop { + let at = *last_read.lock().unwrap() + interval; + tokio::select! { + _ = &mut connection => break, + () = tokio::time::sleep_until(at) => { + if Instant::now() < *last_read.lock().unwrap() + interval {continue;} + tokio::select! { + _ = &mut connection => break, + result = tokio::time::timeout(Duration::from_secs(15), ping.ping(h2::Ping::opaque())) => { + if !matches!(result, Ok(Ok(_))) {break;} + } + } + } + } + } + }, + )) +} + +struct ActivityIo { + raw: BoxStream, + last_read: Arc>, +} +impl AsyncRead for ActivityIo { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + out: &mut ReadBuf<'_>, + ) -> Poll> { + let before = out.filled().len(); + let result = Pin::new(&mut self.raw).poll_read(cx, out); + if out.filled().len() > before { + *self.last_read.lock().unwrap() = Instant::now(); + } + result + } +} +impl AsyncWrite for ActivityIo { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + input: &[u8], + ) -> Poll> { + Pin::new(&mut self.raw).poll_write(cx, input) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.raw).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.raw).poll_shutdown(cx) + } +} diff --git a/src/transport/sing_mux/padding.rs b/src/transport/sing_mux/padding.rs new file mode 100644 index 0000000..0afb07c --- /dev/null +++ b/src/transport/sing_mux/padding.rs @@ -0,0 +1,136 @@ +//! sing-mux first-16-write padding. Incremental parsing retains at most a +//! four-byte header; padding is discarded through a fixed scratch buffer. +use super::*; +use bytes::Buf; + +pub(super) struct Padding { + raw: BoxStream, + read_frames: u8, + header: [u8; 4], + header_used: usize, + data_left: usize, + padding_left: usize, + write_frames: u8, + queued: Bytes, +} +impl Padding { + pub(super) fn new(raw: BoxStream) -> Self { + Self { + raw, + read_frames: 0, + header: [0; 4], + header_used: 0, + data_left: 0, + padding_left: 0, + write_frames: 0, + queued: Bytes::new(), + } + } + fn drain(&mut self, cx: &mut Context<'_>) -> Poll> { + for _ in 0..crate::limits::IO_POLL_BUDGET { + if self.queued.is_empty() { + return Poll::Ready(Ok(())); + } + let count = ready!(Pin::new(&mut self.raw).poll_write(cx, &self.queued))?; + if count == 0 { + return Poll::Ready(Err(io::ErrorKind::WriteZero.into())); + } + self.queued.advance(count); + } + cx.waker().wake_by_ref(); + Poll::Pending + } +} +impl AsyncRead for Padding { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + out: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + if out.remaining() == 0 { + return Poll::Ready(Ok(())); + } + for _ in 0..crate::limits::IO_POLL_BUDGET { + if this.data_left > 0 { + let mut scratch = [0; 4096]; + let count = this.data_left.min(out.remaining()).min(scratch.len()); + let mut part = ReadBuf::new(&mut scratch[..count]); + ready!(Pin::new(&mut this.raw).poll_read(cx, &mut part))?; + let count = part.filled().len(); + if count == 0 { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + out.put_slice(part.filled()); + this.data_left -= count; + return Poll::Ready(Ok(())); + } + if this.padding_left > 0 { + let mut scratch = [0; 1024]; + let size = this.padding_left.min(scratch.len()); + let mut buf = ReadBuf::new(&mut scratch[..size]); + ready!(Pin::new(&mut this.raw).poll_read(cx, &mut buf))?; + if buf.filled().is_empty() { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + this.padding_left -= buf.filled().len(); + continue; + } + if this.read_frames == 16 { + return Pin::new(&mut this.raw).poll_read(cx, out); + } + let mut buf = ReadBuf::new(&mut this.header[this.header_used..]); + ready!(Pin::new(&mut this.raw).poll_read(cx, &mut buf))?; + let count = buf.filled().len(); + if count == 0 { + return Poll::Ready(if this.header_used == 0 { + Ok(()) + } else { + Err(io::ErrorKind::UnexpectedEof.into()) + }); + } + this.header_used += count; + if this.header_used == 4 { + this.data_left = u16::from_be_bytes([this.header[0], this.header[1]]) as usize; + this.padding_left = u16::from_be_bytes([this.header[2], this.header[3]]) as usize; + this.header_used = 0; + this.read_frames += 1; + } + } + cx.waker().wake_by_ref(); + Poll::Pending + } +} +impl AsyncWrite for Padding { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + ready!(self.drain(cx))?; + if self.write_frames == 16 { + return Pin::new(&mut self.raw).poll_write(cx, buf); + } + if buf.is_empty() { + return Poll::Ready(Ok(0)); + } + let size = buf.len().min(16384); + let padding = rand::random_range(256_usize..768); + let mut wire = Vec::with_capacity(4 + size + padding); + wire.extend_from_slice(&(size as u16).to_be_bytes()); + wire.extend_from_slice(&(padding as u16).to_be_bytes()); + wire.extend_from_slice(&buf[..size]); + wire.resize(4 + size + padding, 0); + self.queued = wire.into(); + self.write_frames += 1; + Poll::Ready(Ok(size)) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + ready!(self.drain(cx))?; + Pin::new(&mut self.raw).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + ready!(self.drain(cx))?; + Pin::new(&mut self.raw).poll_shutdown(cx) + } +} diff --git a/src/transport/sing_mux/smux_driver.rs b/src/transport/sing_mux/smux_driver.rs new file mode 100644 index 0000000..ac9a6a0 --- /dev/null +++ b/src/transport/sing_mux/smux_driver.rs @@ -0,0 +1,395 @@ +//! Independent smux v1 client framing. One owned reader/writer pair, bounded +//! physical write queue and per-stream receive queues; no detached workers. +use super::*; +use bytes::Buf; +use std::collections::HashMap; +use std::sync::atomic::AtomicBool; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + sync::{Notify, mpsc, oneshot}, +}; +use tokio_util::sync::PollSender; + +const CHUNK: usize = crate::limits::SING_MUX_CHUNK; +struct Frame { + cmd: u8, + id: u32, + data: Bytes, + done: oneshot::Sender>, +} +struct Entry { + receive: mpsc::Sender, + close: CancellationToken, + remote_fin: Arc, +} +struct State { + closed: bool, + next: u32, + streams: HashMap, +} +struct Shared { + state: Mutex, + changed: Notify, +} +#[derive(Clone)] +pub(super) struct Client { + shared: Arc, + write: mpsc::Sender, +} +impl Client { + pub(super) async fn open(&self) -> io::Result { + let permit = self + .write + .clone() + .reserve_owned() + .await + .map_err(|_| io::ErrorKind::ConnectionAborted)?; + let (tx, receive) = mpsc::channel(crate::limits::SMUX_RECEIVE_QUEUE); + let close = CancellationToken::new(); + let remote_fin = Arc::new(AtomicBool::new(false)); + let (id, done) = { + let mut state = self.shared.state.lock().unwrap(); + if state.closed { + return Err(io::ErrorKind::ConnectionAborted.into()); + } + state.next = state + .next + .checked_add(2) + .ok_or(io::ErrorKind::ConnectionAborted)?; + let id = state.next; + state.streams.insert( + id, + Entry { + receive: tx, + close: close.clone(), + remote_fin: remote_fin.clone(), + }, + ); + let (tx, rx) = oneshot::channel(); + permit.send(Frame { + cmd: 0, + id, + data: Bytes::new(), + done: tx, + }); + (id, rx) + }; + let stream = Stream { + id, + shared: self.shared.clone(), + write: PollSender::new(self.write.clone()), + receive, + close: close.clone(), + cancelled: Box::pin(close.cancelled_owned()), + remote_fin, + pending: None, + data: Bytes::new(), + }; + // Cancellation drops the newly registered logical stream and queues FIN. + done.await.map_err(|_| io::ErrorKind::ConnectionAborted)??; + Ok(Box::new(stream)) + } +} +struct Lifetime(Arc); +impl Drop for Lifetime { + fn drop(&mut self) { + let mut state = self.0.state.lock().unwrap(); + state.closed = true; + for (_, entry) in state.streams.drain() { + entry.close.cancel(); + } + } +} +pub(super) fn new(raw: BoxStream) -> (Client, impl Future + Send) { + let shared = Arc::new(Shared { + state: Mutex::new(State { + closed: false, + next: 1, + streams: HashMap::new(), + }), + changed: Notify::new(), + }); + let lifetime = Lifetime(shared.clone()); + let (write, mut frames) = mpsc::channel::(crate::limits::SING_MUX_COMMAND_QUEUE); + let client = Client { + shared: shared.clone(), + write, + }; + let (mut reader, mut writer) = tokio::io::split(raw); + let read_shared = shared.clone(); + let read = async move { + loop { + let mut header = [0; 8]; + reader.read_exact(&mut header).await?; + let cmd = header[1]; + let length = u16::from_le_bytes(header[2..4].try_into().unwrap()) as usize; + let id = u32::from_le_bytes(header[4..8].try_into().unwrap()); + if header[0] != 1 || !matches!(cmd, 1..=3) || (cmd != 2 && length != 0) { + return Err::<(), _>(io::Error::from(io::ErrorKind::InvalidData)); + } + if cmd == 1 { + if let Some(entry) = read_shared.state.lock().unwrap().streams.remove(&id) { + entry.remote_fin.store(true, Ordering::Release); + } + continue; + } + if cmd == 3 || length == 0 { + tokio::task::yield_now().await; + continue; + } + let mut data = vec![0; length]; + reader.read_exact(&mut data).await?; + let target = read_shared + .state + .lock() + .unwrap() + .streams + .get(&id) + .map(|e| (e.receive.clone(), e.close.clone())); + if let Some((receive, close)) = target { + tokio::select! {biased;()=close.cancelled()=>{},_=receive.send(data.into())=>{}} + } + } + }; + let write = async move { + let mut processed = 0; + loop { + // A canceled stream always has a FIN path even if the data queue is + // full. No unbounded "control queue" and no try_send/drop fallback. + let closing = { + let mut state = shared.state.lock().unwrap(); + let id = state + .streams + .iter() + .find(|(_, e)| e.close.is_cancelled()) + .map(|(id, _)| *id); + id.and_then(|id| state.streams.remove(&id).map(|_| id)) + }; + if let Some(id) = closing { + send(&mut writer, 1, id, &[]).await?; + } else { + let frame = tokio::select! { + ()=shared.changed.notified()=>continue, + frame=frames.recv()=>{let Some(frame)=frame else {return Ok::<(),io::Error>(());};frame} + }; + let alive = shared + .state + .lock() + .unwrap() + .streams + .get(&frame.id) + .is_some_and(|e| !e.close.is_cancelled()); + if !alive { + let _ = frame.done.send(Err(io::ErrorKind::BrokenPipe.into())); + continue; + } + let result = send(&mut writer, frame.cmd, frame.id, &frame.data).await; + let failed = result.is_err(); + let _ = frame.done.send(result); + if failed { + return Err(io::ErrorKind::ConnectionAborted.into()); + } + } + processed += 1; + if processed == crate::limits::IO_POLL_BUDGET { + processed = 0; + tokio::task::yield_now().await; + } + } + }; + let driver = async move { + let _lifetime = lifetime; + tokio::select! { + _=read=>{}, + _=write=>{} + } + }; + (client, driver) +} +async fn send( + writer: &mut W, + cmd: u8, + id: u32, + data: &[u8], +) -> io::Result<()> { + let mut header = [0; 8]; + header[0] = 1; + header[1] = cmd; + header[2..4].copy_from_slice(&(data.len() as u16).to_le_bytes()); + header[4..8].copy_from_slice(&id.to_le_bytes()); + writer.write_all(&header).await?; + writer.write_all(data).await?; + writer.flush().await +} +struct Stream { + id: u32, + shared: Arc, + write: PollSender, + receive: mpsc::Receiver, + close: CancellationToken, + cancelled: Pin + Send>>, + remote_fin: Arc, + pending: Option>>, + data: Bytes, +} +impl Stream { + fn stop(&mut self) { + self.close.cancel(); + self.shared.changed.notify_one(); + self.pending.take(); + self.write.abort_send(); + self.receive.close(); + self.data = Bytes::new(); + } + fn flush_pending(&mut self, cx: &mut Context<'_>) -> Poll> { + if self.cancelled.as_mut().poll(cx).is_ready() { + return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into())); + } + if let Some(pending) = &mut self.pending { + ready!(Pin::new(pending).poll(cx)).map_err(|_| io::ErrorKind::BrokenPipe)??; + self.pending = None; + } + Poll::Ready(Ok(())) + } +} +impl Drop for Stream { + fn drop(&mut self) { + self.stop(); + } +} +impl AsyncRead for Stream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + out: &mut ReadBuf<'_>, + ) -> Poll> { + if out.remaining() == 0 || self.cancelled.as_mut().poll(cx).is_ready() { + return Poll::Ready(Ok(())); + } + if self.data.is_empty() { + match ready!(self.receive.poll_recv(cx)) { + Some(data) => self.data = data, + None => return Poll::Ready(Ok(())), + } + } + let n = out.remaining().min(self.data.len()); + out.put_slice(&self.data[..n]); + self.data.advance(n); + Poll::Ready(Ok(())) + } +} +impl AsyncWrite for Stream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + ready!(self.flush_pending(cx))?; + if self.remote_fin.load(Ordering::Acquire) { + return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into())); + } + if buf.is_empty() { + return Poll::Ready(Ok(0)); + } + ready!(self.write.poll_reserve(cx)).map_err(|_| io::ErrorKind::BrokenPipe)?; + let size = buf.len().min(CHUNK); + let (tx, rx) = oneshot::channel(); + let id = self.id; + self.write + .send_item(Frame { + cmd: 2, + id, + data: Bytes::copy_from_slice(&buf[..size]), + done: tx, + }) + .map_err(|_| io::ErrorKind::BrokenPipe)?; + self.pending = Some(rx); + Poll::Ready(Ok(size)) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.flush_pending(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.close.is_cancelled() { + return Poll::Ready(Ok(())); + } + ready!(self.flush_pending(cx))?; + self.stop(); + Poll::Ready(Ok(())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[tokio::test] + async fn shutdown_flushes_the_pending_frame_before_fin() { + tokio::time::timeout(Duration::from_secs(2), async { + let (raw, mut peer) = tokio::io::duplex(16); + let (client, driver) = new(Box::new(raw)); + let task = tokio::spawn(driver); + let mut stream = client.open().await.unwrap(); + let mut syn = [0; 8]; + peer.read_exact(&mut syn).await.unwrap(); + let receive = tokio::spawn(async move { + let mut received = Vec::new(); + loop { + let mut header = [0; 8]; + peer.read_exact(&mut header).await.unwrap(); + assert_eq!(&header[4..], &syn[4..]); + if header[1] == 1 { + break; + } + assert_eq!(header[1], 2); + let size = u16::from_le_bytes([header[2], header[3]]) as usize; + let mut data = vec![0; size]; + peer.read_exact(&mut data).await.unwrap(); + received.extend(data); + } + received + }); + let payload = vec![b'x'; 1024]; + stream.write_all(&payload).await.unwrap(); + stream.shutdown().await.unwrap(); + let received = receive.await.unwrap(); + task.await.unwrap(); + assert_eq!(received.len(), payload.len()); + assert_eq!(received, payload); + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn malformed_smux_headers_close_the_owned_driver_before_reading_a_body() { + #[cfg(feature = "interop-test")] + let _case = crate::resources::case_events::Case::new( + "XHTTP-UNIT", + "malformed_smux_headers_close_the_owned_driver_before_reading_a_body", + ); + for (version, command, length) in [(2, 2, 0), (1, 0, 0), (1, 4, 0), (1, 1, 1), (1, 3, 1)] { + tokio::time::timeout(Duration::from_secs(1), async { + let (raw, mut peer) = tokio::io::duplex(64); + let (client, driver) = new(Box::new(raw)); + let task = tokio::spawn(driver); + let mut logical = client.open().await.unwrap(); + let mut syn = [0; 8]; + peer.read_exact(&mut syn).await.unwrap(); + assert_eq!(syn, [1, 0, 0, 0, 3, 0, 0, 0]); + // No body is supplied: malformed control framing must terminate + // immediately rather than waiting for attacker-controlled data. + peer.write_all(&[version, command, length, 0, 3, 0, 0, 0]) + .await + .unwrap(); + task.await.unwrap(); + assert_eq!(logical.read(&mut [0]).await.unwrap(), 0); + assert!(logical.write_all(b"closed").await.is_err()); + assert!(client.open().await.is_err()); + }) + .await + .unwrap(); + } + } +} diff --git a/src/transport/sing_mux/yamux_driver.rs b/src/transport/sing_mux/yamux_driver.rs new file mode 100644 index 0000000..0b76ed0 --- /dev/null +++ b/src/transport/sing_mux/yamux_driver.rs @@ -0,0 +1,74 @@ +//! The upstream library is caller-driven. VCore owns its one driver and the +//! bounded open-command queue; it never delegates detached task ownership. +use super::*; +use tokio::sync::{mpsc, oneshot}; +use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; + +pub(super) const STREAMS: usize = crate::limits::YAMUX_CONNECTION_ADMISSIONS; +type Reply = oneshot::Sender>; +#[derive(Clone)] +pub(super) struct Client { + open: mpsc::Sender, +} +impl Client { + pub(super) async fn open(&self) -> io::Result { + let (tx, rx) = oneshot::channel(); + self.open + .send(tx) + .await + .map_err(|_| io::ErrorKind::ConnectionAborted)?; + rx.await.map_err(|_| io::ErrorKind::ConnectionAborted)? + } +} +pub(super) fn new(raw: BoxStream) -> (Client, impl Future + Send) { + let (open, mut commands) = mpsc::channel::(crate::limits::SING_MUX_COMMAND_QUEUE); + let mut config = yamux::Config::default(); + config + .set_max_num_streams(STREAMS) + .set_max_connection_receive_window(Some(crate::limits::YAMUX_CONNECTION_WINDOW)) + .set_read_after_close(false) + .set_split_send_size(crate::limits::SING_MUX_CHUNK); + let mut connection = yamux::Connection::new(raw.compat(), config, yamux::Mode::Client); + let driver = async move { + let mut pending: Option = None; + futures_util::future::poll_fn(|cx| { + for _ in 0..crate::limits::IO_POLL_BUDGET { + if pending.is_none() + && let Poll::Ready(command) = commands.poll_recv(cx) + { + let Some(command) = command else { + return Poll::Ready(()); + }; + pending = Some(command); + } + if pending.as_ref().is_some_and(Reply::is_closed) { + pending = None; + continue; + } + if pending.is_some() + && let Poll::Ready(result) = connection.poll_new_outbound(cx) + { + let result = result + .map(|stream| Box::new(stream.compat()) as BoxStream) + .map_err(|_| io::Error::from(io::ErrorKind::ConnectionAborted)); + let _ = pending.take().unwrap().send(result); + continue; + } + // Poll even with a pending open; this is also the write driver + // for all existing streams, so open backpressure cannot stall it. + match connection.poll_next_inbound(cx) { + Poll::Ready(Some(Ok(stream))) => { + drop(stream); + continue; + } + Poll::Ready(_) => return Poll::Ready(()), + Poll::Pending => return Poll::Pending, + } + } + cx.waker().wake_by_ref(); + Poll::Pending + }) + .await; + }; + (Client { open }, driver) +} diff --git a/src/transport/ws.rs b/src/transport/ws.rs new file mode 100644 index 0000000..cf9bc3a --- /dev/null +++ b/src/transport/ws.rs @@ -0,0 +1,614 @@ +use std::{ + io, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + task::{Context, Poll, ready}, +}; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use bytes::{Buf, Bytes}; +use futures_util::{Sink, Stream}; +use tokio::{ + io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf}, + time::{Instant, timeout_at}, +}; +use tokio_tungstenite::{ + WebSocketStream, + tungstenite::{ + Error, Message, + client::IntoClientRequest, + error::ProtocolError, + protocol::{Role, WebSocketConfig}, + }, +}; + +use super::{STREAM_BUFFER_BYTES as LIMIT, STREAM_CHUNK_BYTES as CHUNK}; +use crate::dispatch::BoxStream; + +pub const MAX_EARLY_DATA_BYTES: usize = 2048; + +#[derive(Clone)] +pub enum WebSocketEarlyData { + Header { + name: http::HeaderName, + max_bytes: usize, + }, + Path { + max_bytes: usize, + }, +} + +#[derive(Clone)] +pub struct WebSocketOptions { + uri: http::Uri, + headers: http::HeaderMap, + early_data: Option, +} + +impl std::fmt::Debug for WebSocketOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WebSocketOptions") + .field("early_data", &self.early_data.is_some()) + .finish_non_exhaustive() + } +} + +impl WebSocketOptions { + pub fn new( + uri: &str, + headers: http::HeaderMap, + early_data: Option, + ) -> io::Result { + use super::http_head::{HTTP_HEAD_BYTES, HTTP_HEADER_COUNT}; + let uri: http::Uri = uri.parse().map_err(|_| invalid_options())?; + if !matches!(uri.scheme_str(), Some("ws" | "wss")) + || uri + .authority() + .is_none_or(|value| value.as_str().contains('@')) + || !uri.path().starts_with('/') + || headers.len() > HTTP_HEADER_COUNT + { + return Err(invalid_options()); + } + let mut size = uri.to_string().len(); + for (name, value) in &headers { + if reserved(name) + || name == "sec-websocket-protocol" + || headers.get_all(name).iter().count() != 1 + { + return Err(invalid_options()); + } + if name == "host" { + let host = value.to_str().map_err(|_| invalid_options())?; + let authority: http::uri::Authority = + host.parse().map_err(|_| invalid_options())?; + if authority.as_str().contains('@') { + return Err(invalid_options()); + } + } + size = size + .checked_add(name.as_str().len()) + .and_then(|size| size.checked_add(value.len() + 4)) + .ok_or_else(invalid_options)?; + } + if size > HTTP_HEAD_BYTES { + return Err(invalid_options()); + } + if let Some(early) = &early_data { + let maximum = match early { + WebSocketEarlyData::Header { name, max_bytes } => { + if reserved(name) || name == "host" || headers.contains_key(name) { + return Err(invalid_options()); + } + *max_bytes + } + WebSocketEarlyData::Path { max_bytes } => { + if uri.query().is_some() { + return Err(invalid_options()); + } + *max_bytes + } + }; + if !(1..=MAX_EARLY_DATA_BYTES).contains(&maximum) { + return Err(invalid_options()); + } + } + Ok(Self { + uri, + headers, + early_data, + }) + } +} + +fn reserved(name: &http::HeaderName) -> bool { + matches!( + name.as_str(), + "upgrade" + | "connection" + | "sec-websocket-key" + | "sec-websocket-version" + | "sec-websocket-accept" + | "sec-websocket-extensions" + | "content-length" + | "transfer-encoding" + ) +} + +fn invalid_options() -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, "invalid WebSocket options") +} + +/// Complete one upgrade on supplied IO under the caller's absolute deadline. +/// The protocol supplies its pre-read prefix explicitly; ED and frame remainders +/// are committed exactly once before returning, without a deferred write task. +pub async fn connect_websocket( + stream: BoxStream, + options: &WebSocketOptions, + initial_data: &[u8], + deadline: Instant, +) -> io::Result { + timeout_at(deadline, connect_inner(stream, options, initial_data)) + .await + .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))? +} + +pub async fn websocket(stream: BoxStream, uri: &str, deadline: Instant) -> io::Result { + connect_websocket( + stream, + &WebSocketOptions::new(uri, Default::default(), None)?, + &[], + deadline, + ) + .await +} + +/// V2Ray HTTPUpgrade uses a validated HTTP/1 upgrade followed by raw bytes, +/// without WebSocket masking or frames. Fast-open advances only the supplied +/// prefix; setup never succeeds until the peer's 101 has been validated. +pub async fn http_upgrade( + mut stream: BoxStream, + options: &WebSocketOptions, + initial_data: &[u8], + fast_open: bool, + deadline: Instant, +) -> io::Result { + timeout_at(deadline, async move { + let mut headers = options.headers.clone(); + let mut early = 0; + if let Some(ed) = &options.early_data { + let WebSocketEarlyData::Header { name, max_bytes } = ed else { + return Err(invalid_options()); + }; + if name != "sec-websocket-protocol" { + return Err(invalid_options()); + } + early = (*max_bytes).min(initial_data.len()); + if early > 0 { + headers.insert( + name.clone(), + URL_SAFE_NO_PAD + .encode(&initial_data[..early]) + .parse() + .map_err(|_| invalid_options())?, + ); + } + } + if !headers.contains_key("host") { + headers.insert( + "host", + options + .uri + .authority() + .ok_or_else(invalid_options)? + .as_str() + .parse() + .map_err(|_| invalid_options())?, + ); + } + let mut head = format!( + "GET {} HTTP/1.1\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n", + options.uri.path_and_query().map_or("/", |p| p.as_str()) + ) + .into_bytes(); + for (name, value) in &headers { + head.extend_from_slice(name.as_str().as_bytes()); + head.extend_from_slice(b": "); + head.extend_from_slice(value.as_bytes()); + head.extend_from_slice(b"\r\n"); + } + head.extend_from_slice(b"\r\n"); + if head.len() > super::HTTP_HEAD_BYTES { + return Err(invalid_options()); + } + stream.write_all(&head).await?; + if fast_open { + stream.write_all(&initial_data[early..]).await?; + } + stream.flush().await?; + let (response, tail) = super::http_head::read_response(&mut stream).await?; + let single = |name| { + let mut values = response.headers().get_all(name).iter(); + let value = values.next(); + if values.next().is_some() { None } else { value } + }; + if response.status() != 101 + || response.version() != http::Version::HTTP_11 + || !single("upgrade").is_some_and(|v| v.as_bytes().eq_ignore_ascii_case(b"websocket")) + || !single("connection").is_some_and(|v| { + v.to_str().is_ok_and(|v| { + v.split(',') + .any(|token| token.trim().eq_ignore_ascii_case("upgrade")) + }) + }) + || response.headers().contains_key("content-length") + || response.headers().contains_key("transfer-encoding") + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid HTTPUpgrade response", + )); + } + if !fast_open { + stream.write_all(&initial_data[early..]).await?; + stream.flush().await?; + } + Ok(Box::new(UpgradeStream { + stream, + tail: tail.into(), + }) as BoxStream) + }) + .await + .map_err(|_| io::Error::from(io::ErrorKind::TimedOut))? +} + +struct UpgradeStream { + stream: BoxStream, + tail: Bytes, +} +impl AsyncRead for UpgradeStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + output: &mut ReadBuf<'_>, + ) -> Poll> { + if !self.tail.is_empty() { + let count = output.remaining().min(self.tail.len()); + output.put_slice(&self.tail[..count]); + self.tail.advance(count); + return Poll::Ready(Ok(())); + } + Pin::new(&mut self.stream).poll_read(cx, output) + } +} +impl AsyncWrite for UpgradeStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.stream).poll_write(cx, buf) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream).poll_shutdown(cx) + } +} + +async fn connect_inner( + stream: BoxStream, + options: &WebSocketOptions, + initial_data: &[u8], +) -> io::Result { + let mut uri = options.uri.clone(); + let mut headers = options.headers.clone(); + let mut sent_early = 0; + let mut subprotocol = None; + if let Some(early) = &options.early_data { + let maximum = match early { + WebSocketEarlyData::Header { max_bytes, .. } + | WebSocketEarlyData::Path { max_bytes } => *max_bytes, + }; + sent_early = maximum.min(initial_data.len()); + if sent_early > 0 { + let encoded = URL_SAFE_NO_PAD.encode(&initial_data[..sent_early]); + match early { + WebSocketEarlyData::Header { name, .. } => { + let value: http::HeaderValue = + encoded.parse().map_err(|_| invalid_options())?; + if name == "sec-websocket-protocol" { + subprotocol = Some(value.clone()); + } + headers.insert(name, value); + } + WebSocketEarlyData::Path { .. } => { + let mut parts = uri.into_parts(); + let path = format!( + "{}{}", + parts.path_and_query.as_ref().unwrap().path(), + encoded + ); + parts.path_and_query = Some(path.parse().map_err(|_| invalid_options())?); + uri = http::Uri::from_parts(parts).map_err(|_| invalid_options())?; + } + } + } + } + let mut request = uri.into_client_request().map_err(|_| invalid_options())?; + for (name, value) in &headers { + request.headers_mut().insert(name.clone(), value.clone()); + } + let (request, key) = + tokio_tungstenite::tungstenite::handshake::client::generate_request(request) + .map_err(|_| invalid_options())?; + if request.len() > super::HTTP_HEAD_BYTES { + return Err(invalid_options()); + } + let clean_eof = Arc::new(AtomicBool::new(false)); + let mut stream = BoundaryIo { + stream, + clean_eof: clean_eof.clone(), + boundary: Boundary::default(), + }; + let config = WebSocketConfig::default() + .read_buffer_size(CHUNK) + .write_buffer_size(0) + .max_write_buffer_size(LIMIT) + .max_message_size(Some(LIMIT)) + .max_frame_size(Some(LIMIT)); + stream.write_all(&request).await?; + stream.flush().await?; + let (response, tail) = super::http_head::read_response(&mut stream).await?; + let invalid = || io::Error::new(io::ErrorKind::InvalidData, "invalid WebSocket upgrade"); + let single = |name| { + let mut values = response.headers().get_all(name).iter(); + let value = values.next().ok_or_else(invalid)?; + if values.next().is_some() { + return Err(invalid()); + } + Ok(value) + }; + if response.status() != 101 + || response.version() != http::Version::HTTP_11 + || !single("upgrade")? + .as_bytes() + .eq_ignore_ascii_case(b"websocket") + || !single("connection")? + .to_str() + .map_err(|_| invalid())? + .split(',') + .any(|token| token.trim().eq_ignore_ascii_case("upgrade")) + || single("sec-websocket-accept")?.as_bytes() + != tokio_tungstenite::tungstenite::handshake::derive_accept_key(key.as_bytes()) + .as_bytes() + || response.headers().contains_key("sec-websocket-extensions") + { + return Err(invalid()); + } + // ED in Sec-WebSocket-Protocol is not subprotocol negotiation. An absent + // echo is valid, but an unsolicited or different echo is never accepted. + if response.headers().contains_key("sec-websocket-protocol") + && subprotocol.as_ref() != Some(single("sec-websocket-protocol")?) + { + return Err(invalid()); + } + let stream = + WebSocketStream::from_partially_read(stream, tail, Role::Client, Some(config)).await; + let mut stream = WebSocket { + stream, + buffered: Bytes::new(), + closing: false, + eof: false, + clean_eof, + }; + stream.write_all(&initial_data[sent_early..]).await?; + stream.flush().await?; + Ok(Box::new(crate::resources::observation::ObservedIo::new( + stream, + crate::resources::observation::ResourceKind::Session, + ))) +} + +struct WebSocket { + stream: WebSocketStream, + buffered: Bytes, + closing: bool, + eof: bool, + clean_eof: Arc, +} + +impl AsyncRead for WebSocket { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if buf.remaining() == 0 { + return Poll::Ready(Ok(())); + } + // Bound work per poll, including a malicious stream of empty/control frames. + for _ in 0..crate::limits::IO_POLL_BUDGET { + if !self.buffered.is_empty() { + let count = buf.remaining().min(self.buffered.len()); + buf.put_slice(&self.buffered[..count]); + self.buffered.advance(count); + return Poll::Ready(Ok(())); + } + if self.eof { + return Poll::Ready(Ok(())); + } + match ready!(Pin::new(&mut self.stream).poll_next(cx)) { + Some(Ok(Message::Binary(data))) => self.buffered = data, + Some(Ok(Message::Text(data))) => self.buffered = data.into(), + Some(Ok(Message::Close(_))) | None => self.eof = true, + Some(Ok(Message::Ping(_) | Message::Pong(_))) => {} + Some(Err(Error::Protocol(ProtocolError::ResetWithoutClosingHandshake))) + if self.clean_eof.load(Ordering::SeqCst) => + { + self.eof = true + } + // Never turn an arbitrary protocol/truncation error into EOF. + Some(Err(_)) | Some(Ok(Message::Frame(_))) => { + return Poll::Ready(Err(io::ErrorKind::InvalidData.into())); + } + } + } + cx.waker().wake_by_ref(); + Poll::Pending + } +} + +// Tungstenite intentionally reports the same ResetWithoutClosingHandshake for +// EOF between frames and EOF in a partial frame. Mihomo permits the former for +// proxy CloseWrite. Observe only wire boundaries (14 header bytes, no payload +// copies) so that the compatibility mapping cannot conceal truncation. The +// The official library remains the WS frame validator and message decoder; +// the bounded HTTP upgrade above handles proxy ED's non-negotiated header. +#[derive(Default)] +struct Boundary { + upgrade: usize, + header: [u8; 14], + used: usize, + remaining: u64, + fragmented: bool, +} + +impl Boundary { + fn accept(&mut self, mut data: &[u8]) { + while !data.is_empty() { + if self.upgrade < 4 { + let byte = data[0]; + data = &data[1..]; + self.upgrade = if byte == b"\r\n\r\n"[self.upgrade] { + self.upgrade + 1 + } else { + usize::from(byte == b'\r') + }; + continue; + } + if self.remaining > 0 { + let count = self.remaining.min(data.len() as u64) as usize; + self.remaining -= count as u64; + data = &data[count..]; + continue; + } + self.header[self.used] = data[0]; + self.used += 1; + data = &data[1..]; + if self.used < 2 { + continue; + } + let length = self.header[1] & 0x7f; + let extended = match length { + 126 => 2, + 127 => 8, + _ => 0, + }; + let mask = if self.header[1] & 0x80 != 0 { 4 } else { 0 }; + if self.used < 2 + extended + mask { + continue; + } + self.remaining = match length { + 126 => u16::from_be_bytes(self.header[2..4].try_into().unwrap()) as u64, + 127 => u64::from_be_bytes(self.header[2..10].try_into().unwrap()), + value => u64::from(value), + }; + let opcode = self.header[0] & 0x0f; + if matches!(opcode, 0..=2) { + self.fragmented = self.header[0] & 0x80 == 0; + } + self.used = 0; + } + } + + fn complete(&self) -> bool { + self.upgrade == 4 && self.used == 0 && self.remaining == 0 && !self.fragmented + } +} + +struct BoundaryIo { + stream: BoxStream, + boundary: Boundary, + clean_eof: Arc, +} + +impl AsyncRead for BoundaryIo { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if buf.remaining() == 0 { + return Poll::Ready(Ok(())); + } + let start = buf.filled().len(); + ready!(Pin::new(&mut self.stream).poll_read(cx, buf))?; + let data = &buf.filled()[start..]; + if data.is_empty() { + if !self.boundary.complete() { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + self.clean_eof.store(true, Ordering::SeqCst); + } else { + self.boundary.accept(data); + } + Poll::Ready(Ok(())) + } +} + +impl AsyncWrite for BoundaryIo { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.stream).poll_write(cx, buf) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream).poll_shutdown(cx) + } +} + +impl AsyncWrite for WebSocket { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + if self.closing { + return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into())); + } + if buf.is_empty() { + return Poll::Ready(Ok(0)); + } + ready!(Pin::new(&mut self.stream).poll_ready(cx)) + .map_err(|_| io::Error::from(io::ErrorKind::ConnectionAborted))?; + let count = CHUNK.min(buf.len()); + Pin::new(&mut self.stream) + .start_send(Message::Binary(Bytes::copy_from_slice(&buf[..count]))) + .map_err(|_| io::Error::from(io::ErrorKind::ConnectionAborted))?; + // A queued frame is accepted exactly once; a Pending flush must not + // make AsyncWrite callers resubmit the same application bytes. + Poll::Ready(Ok(count)) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.stream) + .poll_flush(cx) + .map_err(|_| io::ErrorKind::ConnectionAborted.into()) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.closing = true; + ready!(self.as_mut().poll_flush(cx))?; + // Ordinary Mihomo WS Relay bypasses WS Close via Upstream(). TLS (when + // present) applies its close_notify contract; plain TCP sends FIN. + Pin::new(self.stream.get_mut()).poll_shutdown(cx) + } +} diff --git a/src/transport/xhttp.rs b/src/transport/xhttp.rs index a3309d1..1acea97 100644 --- a/src/transport/xhttp.rs +++ b/src/transport/xhttp.rs @@ -3,30 +3,42 @@ use std::{ io, pin::Pin, sync::Arc, - task::{Context, Poll}, + task::{Context, Poll, Waker}, + time::Duration, }; use bytes::{Buf, Bytes}; -use h2::{ - RecvStream, SendStream, - client::{ResponseFuture, SendRequest}, -}; use http::{Method, Request, StatusCode, Uri}; -use rand::Rng as _; +use http_io::{ + Download as RecvStream, ResponseFuture, Sender as SendRequest, Upload as SendStream, +}; use tokio::{ io::{AsyncRead, AsyncWrite, ReadBuf}, task::JoinHandle, + time::Sleep, }; use crate::dispatch::BoxStream; +mod h2_driver; +mod h3_driver; +mod h3_upload; +pub(crate) use h3_driver::QuicTransport; +mod http_io; +mod packet_up; +mod pool; +mod request; + +pub(crate) enum TransportIo { + Stream(BoxStream), + Quic(Box), +} -const MIN_X_PADDING: usize = 100; -const MAX_X_PADDING: usize = 1_000; const DEFAULT_UPLOAD_CHUNK_SIZE: usize = 64 * 1024; const MAX_H2_HEADER_LIST_SIZE: u32 = 16 * 1024; const DEFAULT_H2_SEND_BUFFER_SIZE: usize = 64 * 1024; const MAX_UPLOAD_RESPONSE_FRAMES_PER_POLL: usize = 16; const MAX_UPLOAD_RESPONSE_BYTES_PER_POLL: usize = 32 * 1024; +const CLOSE_UPLOAD_TIMEOUT: Duration = Duration::from_secs(1); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum XHttpMode { @@ -40,6 +52,10 @@ pub struct XHttpConfig { pub host: String, pub path: String, pub mode: XHttpMode, + pub headers: crate::config::XHttpHeaders, + pub request: Arc, + pub http_version: crate::config::XHttpVersion, + pub reuse: Option>, } impl XHttpConfig { @@ -50,7 +66,11 @@ impl XHttpConfig { ) -> io::Result { let host = host.into(); let path = path.into(); - if host.is_empty() || host.len() > 253 || host.bytes().any(|byte| byte.is_ascii_control()) { + if host.is_empty() + || host.len() > 253 + || host.contains('@') + || host.parse::().is_err() + { return Err(io::Error::new( io::ErrorKind::InvalidInput, "invalid XHTTP host", @@ -62,7 +82,15 @@ impl XHttpConfig { "invalid XHTTP path", )); } - Ok(Self { host, path, mode }) + Ok(Self { + host, + path, + mode, + headers: Default::default(), + request: Default::default(), + http_version: Default::default(), + reuse: None, + }) } } @@ -72,20 +100,53 @@ pub struct XHttpClient { request: RequestTemplate, send_buffer_size: usize, upload_chunk_size: usize, + http_version: crate::config::XHttpVersion, + owner: Arc, + reuse: Option>, + pool: Arc, +} + +#[derive(Debug)] +struct ConnectionLease { + _driver: Arc, + _usage: Option, +} + +impl From> for ConnectionLease { + fn from(driver: Arc) -> Self { + Self { + _driver: driver, + _usage: None, + } + } } impl XHttpClient { #[must_use] pub fn new(config: XHttpConfig) -> Self { - let XHttpConfig { host, path, mode } = config; + let XHttpConfig { + host, + path, + mode, + headers, + request, + http_version, + reuse, + } = config; Self { mode, request: RequestTemplate { host: Arc::from(host), path: Arc::from(path), + headers, + options: request, }, send_buffer_size: DEFAULT_H2_SEND_BUFFER_SIZE, upload_chunk_size: DEFAULT_UPLOAD_CHUNK_SIZE, + http_version, + owner: Arc::default(), + pool: Arc::new(pool::Pool::new(reuse.clone())), + reuse, } } @@ -112,8 +173,29 @@ impl XHttpClient { Ok(client) } + /// Returns an XHTTP logical connection. Like Mihomo's XHTTP connection, + /// `shutdown()` closes both directions; it is not a TCP half-close. pub async fn connect(&self, stream: BoxStream) -> io::Result { + if self.owner.cancellation.is_cancelled() { + return Err(connection_closed()); + } + if self.http_version == crate::config::XHttpVersion::Http1 + && self.mode != XHttpMode::StreamOne + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "XHTTP HTTP/1 split mode requires two supplied connections", + )); + } let (sender, connection) = self.handshake(stream).await?; + self.connect_http(sender, connection.into()).await + } + + async fn connect_http( + &self, + sender: SendRequest, + connection: ConnectionLease, + ) -> io::Result { match self.mode { XHttpMode::StreamOne => self.connect_stream_one(sender, connection).await, XHttpMode::StreamUp => { @@ -139,16 +221,152 @@ impl XHttpClient { } } + /// Opens a logical session, dialing only when no reusable physical transport + /// is available. The caller's absolute setup deadline is never reset. + pub async fn open( + &self, + deadline: tokio::time::Instant, + mut connect: F, + ) -> io::Result + where + F: FnMut() -> Fut, + Fut: Future>, + { + self.open_transport(deadline, || { + let next = connect(); + async { next.await.map(TransportIo::Stream) } + }) + .await + } + + pub(crate) async fn open_transport( + &self, + deadline: tokio::time::Instant, + mut connect: F, + ) -> io::Result + where + F: FnMut() -> Fut, + Fut: Future>, + { + let operation = async { + let (sender, driver) = self.acquire(false, &mut connect).await?; + if self.needs_separate_download() { + let (download, download_driver) = + self.handshake_transport(connect().await?).await?; + return self + .connect_split( + sender, + self, + download, + ConnectionGuards::split(driver, download_driver.into()), + ) + .await; + } + self.connect_http(sender, driver).await + }; + tokio::select! {biased; + () = self.owner.cancellation.cancelled() => Err(connection_closed()), + result = tokio::time::timeout_at(deadline, operation) => result.map_err(|_|io::Error::from(io::ErrorKind::TimedOut))?, + } + } + + pub async fn open_with_download( + &self, + deadline: tokio::time::Instant, + download_client: &Self, + upload: U, + download: D, + ) -> io::Result + where + U: FnOnce() -> UF, + D: FnOnce() -> DF, + UF: Future>, + DF: Future>, + { + self.open_with_download_transports( + deadline, + download_client, + || async { upload().await.map(TransportIo::Stream) }, + || async { download().await.map(TransportIo::Stream) }, + ) + .await + } + + pub(crate) async fn open_with_download_transports( + &self, + deadline: tokio::time::Instant, + download_client: &Self, + upload: U, + download: D, + ) -> io::Result + where + U: FnOnce() -> UF, + D: FnOnce() -> DF, + UF: Future>, + DF: Future>, + { + if self.mode == XHttpMode::StreamOne { + return Err(request::invalid_request()); + } + let operation = async { + let up = self.acquire(false, upload); + let down = download_client.acquire(true, download); + let ((upload_sender, upload_connection), (download_sender, download_connection)) = + tokio::try_join!(up, down)?; + self.connect_split( + upload_sender, + download_client, + download_sender, + ConnectionGuards::split(upload_connection, download_connection), + ) + .await + }; + tokio::select! {biased; + () = self.owner.cancellation.cancelled() => Err(connection_closed()), + () = download_client.owner.cancellation.cancelled() => Err(connection_closed()), + result = tokio::time::timeout_at(deadline,operation) => result.map_err(|_|io::Error::from(io::ErrorKind::TimedOut))?, + } + } + + async fn acquire( + &self, + download: bool, + connect: F, + ) -> io::Result<(SendRequest, ConnectionLease)> + where + F: FnOnce() -> Fut, + Fut: Future>, + { + let policy = if self.http_version != crate::config::XHttpVersion::Http1 { + pool::Policy::Multiplexed + } else if !download && self.mode == XHttpMode::PacketUp { + pool::Policy::Recyclable + } else { + pool::Policy::Exclusive + }; + self.pool + .acquire(policy, || async { + self.handshake_transport(connect().await?).await + }) + .await + } + /// Connects XHTTP upload and download over independent secured HTTP/2 streams. /// /// `self` and `upload_stream` always carry upload requests. The download client and /// stream are used only for the stream-down GET. Both legs use the same session ID. + /// Shutting down the returned stream closes both legs. pub async fn connect_with_download( &self, upload_stream: BoxStream, download_client: &XHttpClient, download_stream: BoxStream, ) -> io::Result { + if self.owner.cancellation.is_cancelled() + || download_client.owner.cancellation.is_cancelled() + { + return Err(connection_closed()); + } if self.mode == XHttpMode::StreamOne { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -159,8 +377,19 @@ impl XHttpClient { let (upload_sender, upload_connection) = self.handshake(upload_stream).await?; let (download_sender, download_connection) = download_client.handshake(download_stream).await?; - let connections = ConnectionGuards::split(upload_connection, download_connection); + let connections = + ConnectionGuards::split(upload_connection.into(), download_connection.into()); + self.connect_split(upload_sender, download_client, download_sender, connections) + .await + } + async fn connect_split( + &self, + upload_sender: SendRequest, + download_client: &Self, + download_sender: SendRequest, + connections: ConnectionGuards, + ) -> io::Result { match self.mode { XHttpMode::StreamUp => { self.connect_stream_up_with_download( @@ -187,91 +416,143 @@ impl XHttpClient { async fn handshake( &self, stream: BoxStream, - ) -> io::Result<(SendRequest, ConnectionGuard)> { - let mut builder = h2::client::Builder::new(); - builder - .max_header_list_size(MAX_H2_HEADER_LIST_SIZE) - .max_send_buffer_size(self.send_buffer_size) - .enable_push(false); - let (sender, connection) = builder.handshake(stream).await.map_err(io_other)?; - let connection = ConnectionGuard::spawn(connection); - Ok((sender, connection)) + ) -> io::Result<(SendRequest, Arc)> { + self.handshake_transport(TransportIo::Stream(stream)).await + } + + pub(crate) fn http_version(&self) -> crate::config::XHttpVersion { + self.http_version + } + + #[cfg(feature = "interop-test")] + pub(crate) fn quic_pings(&self) -> Vec { + self.pool.quic_pings() + } + + fn handshake_transport( + &self, + transport: TransportIo, + ) -> futures_util::future::BoxFuture<'_, io::Result<(SendRequest, Arc)>> { + Box::pin(async move { + tokio::select! {biased; + () = self.owner.cancellation.cancelled() => Err(connection_closed()), + result = async { + match transport { + TransportIo::Stream(stream) if self.http_version != crate::config::XHttpVersion::Http3 => self.handshake_active(stream).await, + TransportIo::Quic(transport) if self.http_version == crate::config::XHttpVersion::Http3 => h3_driver::connect(*transport, self.reuse.as_ref().map_or(0, |r| r.keep_alive_seconds), &self.owner).await, + _ => Err(request::invalid_request()), + } + } => result, + } + }) + } + + async fn handshake_active( + &self, + stream: BoxStream, + ) -> io::Result<(SendRequest, Arc)> { + if self.http_version == crate::config::XHttpVersion::Http1 { + return http_io::http1(stream, &self.owner) + .await + .map(|(sender, guard)| (sender, Arc::new(guard))); + } + h2_driver::connect( + stream, + self.send_buffer_size, + self.reuse + .as_ref() + .map_or(0, |options| options.keep_alive_seconds), + &self.owner, + ) + .await + } + + pub async fn stop(&self) { + self.begin_stop(); + self.pool.clear(); + self.owner.tasks.close(); + self.owner.tasks.wait().await; + } + + pub(crate) fn begin_stop(&self) { + let _admission = self.owner.admission.lock().unwrap(); + self.owner.cancellation.cancel(); + self.owner.tasks.close(); + } + + pub(crate) fn needs_separate_download(&self) -> bool { + self.http_version == crate::config::XHttpVersion::Http1 && self.mode != XHttpMode::StreamOne } async fn connect_stream_one( &self, - sender: SendRequest, - connection: ConnectionGuard, + sender: SendRequest, + connection: ConnectionLease, ) -> io::Result { let mut sender = sender; let request = self.stream_request(Method::POST, None, None, true)?; - let (response, upload) = sender.send_request(request, false).map_err(io_other)?; - Ok(Box::new(StreamOne { + let (response, upload) = sender.send_request(request, false).await?; + Ok(Box::new(XHttpStream::new(StreamOne { upload, - download: Downlink::pending(response, "stream-one"), + download: Downlink::pending(response, "stream-one", true), upload_chunk_size: self.upload_chunk_size, send_closed: false, _connection: connection, - })) + }))) } async fn connect_packet_up_with_download( &self, - upload_sender: SendRequest, + upload_sender: SendRequest, download_client: &XHttpClient, - download_sender: SendRequest, + download_sender: SendRequest, connections: ConnectionGuards, ) -> io::Result { - let session_id: Arc = Arc::from(random_session_id()); + let session_id: Arc = Arc::from(self.request.options.session_id.generate()); let mut download_sender = download_sender; let request = download_client.stream_request(Method::GET, Some(session_id.as_ref()), None, false)?; - let (response, _) = download_sender - .send_request(request, true) - .map_err(io_other)?; + let (response, _) = download_sender.send_request(request, true).await?; - Ok(Box::new(PacketUp { - sender: upload_sender, - request: self.request.clone(), - session_id, - sequence: 0, - upload_chunk_size: self.upload_chunk_size, - pending_write: None, - download: Downlink::pending(response, "packet-up download"), + Ok(Box::new(XHttpStream::new(PacketUp { + upload: packet_up::Writer::new( + upload_sender, + self.request.clone(), + session_id, + self.upload_chunk_size, + &self.owner, + )?, + download: Downlink::pending(response, "packet-up download", false), closed: false, _connections: connections, - })) + }))) } async fn connect_stream_up_with_download( &self, - upload_sender: SendRequest, + upload_sender: SendRequest, download_client: &XHttpClient, - download_sender: SendRequest, + download_sender: SendRequest, connections: ConnectionGuards, ) -> io::Result { - let session_id: Arc = Arc::from(random_session_id()); + let session_id: Arc = Arc::from(self.request.options.session_id.generate()); let mut download_sender = download_sender; let request = download_client.stream_request(Method::GET, Some(session_id.as_ref()), None, false)?; - let (download_response, _) = download_sender - .send_request(request, true) - .map_err(io_other)?; + let (download_response, _) = download_sender.send_request(request, true).await?; let mut upload_sender = upload_sender; let request = self.stream_request(Method::POST, Some(session_id.as_ref()), None, true)?; - let (upload_response, upload) = upload_sender - .send_request(request, false) - .map_err(io_other)?; + let (upload_response, upload) = upload_sender.send_request(request, false).await?; - Ok(Box::new(StreamUp { + Ok(Box::new(XHttpStream::new(StreamUp { upload, upload_response: UploadResponse::pending(upload_response), - download: Downlink::pending(download_response, "stream-up download"), + download: Downlink::pending(download_response, "stream-up download", false), upload_chunk_size: self.upload_chunk_size, send_closed: false, _connections: connections, - })) + }))) } fn stream_request( @@ -286,10 +567,103 @@ impl XHttpClient { } } +// Mihomo's Relay falls back to Conn.Close because XHTTP has no CloseWrite. +// Keep that contract local to XHTTP: callers and other protocols retain their +// normal AsyncWrite shutdown behavior. Dropping the inner connection releases +// both HTTP legs, buffered responses and the owned connection drivers. +struct XHttpStream { + inner: Option, + reader_waker: Option, + close_deadline: Option>>, +} + +impl XHttpStream { + fn new(inner: T) -> Self { + Self { + inner: Some(inner), + reader_waker: None, + close_deadline: None, + } + } + + fn close(&mut self) { + self.inner = None; + self.close_deadline = None; + if let Some(waker) = self.reader_waker.take() { + waker.wake(); + } + } +} + +fn connection_closed() -> io::Error { + io::Error::new(io::ErrorKind::BrokenPipe, "XHTTP connection is closed") +} + +impl AsyncRead for XHttpStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + output: &mut ReadBuf<'_>, + ) -> Poll> { + let Some(inner) = self.inner.as_mut() else { + return Poll::Ready(Err(connection_closed())); + }; + let result = Pin::new(inner).poll_read(cx, output); + self.reader_waker = result.is_pending().then(|| cx.waker().clone()); + result + } +} + +impl AsyncWrite for XHttpStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + input: &[u8], + ) -> Poll> { + if self.close_deadline.is_some() { + return Poll::Ready(Err(connection_closed())); + } + match self.inner.as_mut() { + Some(inner) => Pin::new(inner).poll_write(cx, input), + None => Poll::Ready(Err(connection_closed())), + } + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.inner.as_mut() { + Some(inner) => Pin::new(inner).poll_flush(cx), + None => Poll::Ready(Err(connection_closed())), + } + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let Some(inner) = self.inner.as_mut() else { + return Poll::Ready(Ok(())); + }; + if let Poll::Ready(result) = Pin::new(inner).poll_shutdown(cx) { + // Close the reader even if ending/flushing the upload failed. + self.close(); + return Poll::Ready(result); + } + // Mihomo's packet-up Close waits at most one second for its pending + // upload flush, then cancels it. A stalled POST must not pin the reader. + let deadline = self + .close_deadline + .get_or_insert_with(|| Box::pin(tokio::time::sleep(CLOSE_UPLOAD_TIMEOUT))); + if deadline.as_mut().poll(cx).is_ready() { + self.close(); + return Poll::Ready(Ok(())); + } + Poll::Pending + } +} + #[derive(Debug, Clone)] struct RequestTemplate { host: Arc, path: Arc, + headers: crate::config::XHttpHeaders, + options: Arc, } impl RequestTemplate { @@ -300,28 +674,104 @@ impl RequestTemplate { sequence: Option, grpc_content_type: bool, ) -> io::Result> { - let uri = build_uri(self.host.as_ref(), self.path.as_ref(), session_id, sequence)?; - let padding_len = rand::rng().random_range(MIN_X_PADDING..=MAX_X_PADDING); - let referer = format!( - "https://{}{}?x_padding={}", - self.host, - normalized_base_path(self.path.as_ref()), - "X".repeat(padding_len) - ); - - let mut builder = Request::builder() - .method(method) - .uri(uri) - .header("referer", referer); - if grpc_content_type { + use crate::config::xhttp::MetaPlacement; + let base = if self.options.session == MetaPlacement::Path + || self.options.sequence == MetaPlacement::Path + { + build_uri(self.host.as_ref(), self.path.as_ref(), None, None)? + } else { + Uri::builder() + .scheme("https") + .authority(self.host.as_ref()) + .path_and_query(self.path.as_ref()) + .build() + .map_err(|_| request::invalid_request())? + }; + let method = if method == Method::GET { + method + } else { + self.options.uplink_method.clone() + }; + let mut builder = Request::builder().method(method).uri(base.clone()); + *builder.headers_mut().expect("valid request") = self.headers.as_map().clone(); + if grpc_content_type && !self.options.no_grpc_header { builder = builder.header("content-type", "application/grpc"); } - builder.body(()).map_err(io_other) + let mut request = builder.body(()).map_err(io_other)?; + request::apply_padding(&mut request, &base, &self.options.padding)?; + if let Some(session_id) = session_id { + request::apply_metadata(&mut request, &self.options.session, session_id)?; + } + if let Some(sequence) = sequence { + request::apply_metadata(&mut request, &self.options.sequence, &sequence.to_string())?; + } + request::check_size(&request)?; + Ok(request) } } struct ConnectionGuard { task: JoinHandle<()>, + cancel: Option, +} + +#[derive(Debug, Default)] +struct DriverOwner { + admission: std::sync::Mutex<()>, + cancellation: tokio_util::sync::CancellationToken, + tasks: tokio_util::task::TaskTracker, +} + +impl DriverOwner { + fn spawn( + &self, + future: impl Future + Send + 'static, + ) -> io::Result { + let _admission = self.admission.lock().unwrap(); + if self.cancellation.is_cancelled() { + return Err(connection_closed()); + } + let token = self.cancellation.clone(); + let observation = + crate::resources::observation::track(crate::resources::observation::ResourceKind::Task); + let task = self + .tasks + .spawn(crate::resources::observation::bind(async move { + let _observation = observation; + tokio::select! {biased; ()=token.cancelled()=>{}, ()=future=>{} } + })); + Ok(ConnectionGuard { task, cancel: None }) + } + + fn spawn_managed( + &self, + future: impl Future + Send + 'static, + cancel: tokio_util::sync::CancellationToken, + ) -> io::Result { + let _admission = self.admission.lock().unwrap(); + if self.cancellation.is_cancelled() { + return Err(connection_closed()); + } + let observation = + crate::resources::observation::track(crate::resources::observation::ResourceKind::Task); + let task = self + .tasks + .spawn(crate::resources::observation::bind(async move { + let _observation = observation; + future.await; + })); + Ok(ConnectionGuard { + task, + cancel: Some(cancel), + }) + } +} + +impl Drop for DriverOwner { + fn drop(&mut self) { + self.cancellation.cancel(); + self.tasks.close(); + } } impl std::fmt::Debug for ConnectionGuard { @@ -332,48 +782,36 @@ impl std::fmt::Debug for ConnectionGuard { } } -impl ConnectionGuard { - fn spawn(connection: h2::client::Connection) -> Self - where - T: AsyncRead + AsyncWrite + Unpin + Send + 'static, - { - let task = tokio::spawn(async move { - if let Err(error) = connection.await { - tracing::warn!( - reason = ?error.reason(), - is_io = error.is_io(), - is_go_away = error.is_go_away(), - is_reset = error.is_reset(), - is_remote = error.is_remote(), - "XHTTP HTTP/2 connection failed" - ); - } - }); - Self { task } - } -} - impl Drop for ConnectionGuard { fn drop(&mut self) { - self.task.abort(); + if let Some(cancel) = &self.cancel { + cancel.cancel(); + } else { + self.task.abort(); + } } } #[derive(Debug)] struct ConnectionGuards { - _upload: ConnectionGuard, - _download: Option, + _upload: ConnectionLease, + _download: Option, } impl ConnectionGuards { - fn single(connection: ConnectionGuard) -> Self { + fn permit_upload_recycle(&mut self) { + if let Some(usage) = &mut self._upload._usage { + usage.permit_recycle(); + } + } + fn single(connection: ConnectionLease) -> Self { Self { _upload: connection, _download: None, } } - fn split(upload: ConnectionGuard, download: ConnectionGuard) -> Self { + fn split(upload: ConnectionLease, download: ConnectionLease) -> Self { Self { _upload: upload, _download: Some(download), @@ -385,6 +823,7 @@ enum DownlinkState { Pending { response: Pin>, operation: &'static str, + allow_other_success: bool, }, Active(RecvStream), Eof, @@ -396,11 +835,16 @@ struct Downlink { } impl Downlink { - fn pending(response: ResponseFuture, operation: &'static str) -> Self { + fn pending( + response: ResponseFuture, + operation: &'static str, + allow_other_success: bool, + ) -> Self { Self { state: DownlinkState::Pending { response: Box::pin(response), operation, + allow_other_success, }, current: Bytes::new(), } @@ -415,7 +859,7 @@ impl Downlink { return Poll::Ready(Ok(())); } - loop { + for _ in 0..MAX_UPLOAD_RESPONSE_FRAMES_PER_POLL { if self.current.has_remaining() { let count = self.current.remaining().min(output.remaining()); output.put_slice(&self.current[..count]); @@ -423,10 +867,7 @@ impl Downlink { let DownlinkState::Active(stream) = &mut self.state else { unreachable!("buffered data only exists for an active response") }; - stream - .flow_control() - .release_capacity(count) - .map_err(io_other)?; + stream.release_capacity(count)?; return Poll::Ready(Ok(())); } @@ -434,9 +875,12 @@ impl Downlink { DownlinkState::Pending { response, operation, + allow_other_success, } => match response.as_mut().poll(cx) { Poll::Ready(Ok(response)) => { - if let Err(error) = ensure_success(response.status(), operation) { + if let Err(error) = + ensure_success(response.status(), operation, *allow_other_success) + { self.state = DownlinkState::Eof; return Poll::Ready(Err(error)); } @@ -444,13 +888,13 @@ impl Downlink { } Poll::Ready(Err(error)) => { self.state = DownlinkState::Eof; - return Poll::Ready(Err(io_other(error))); + return Poll::Ready(Err(error)); } Poll::Pending => return Poll::Pending, }, DownlinkState::Active(stream) => match stream.poll_data(cx) { Poll::Ready(Some(Ok(data))) => self.current = data, - Poll::Ready(Some(Err(error))) => return Poll::Ready(Err(io_other(error))), + Poll::Ready(Some(Err(error))) => return Poll::Ready(Err(error)), Poll::Ready(None) => { self.state = DownlinkState::Eof; return Poll::Ready(Ok(())); @@ -460,15 +904,17 @@ impl Downlink { DownlinkState::Eof => return Poll::Ready(Ok(())), } } + cx.waker().wake_by_ref(); + Poll::Pending } } struct StreamOne { - upload: SendStream, + upload: SendStream, download: Downlink, upload_chunk_size: usize, send_closed: bool, - _connection: ConnectionGuard, + _connection: ConnectionLease, } impl AsyncRead for StreamOne { @@ -498,12 +944,13 @@ impl AsyncWrite for StreamOne { } let requested = input.len().min(self.upload_chunk_size); + std::task::ready!(self.upload.poll_flush(cx))?; self.upload.reserve_capacity(requested); let capacity = self.upload.capacity(); let capacity = if capacity == 0 { match self.upload.poll_capacity(cx) { Poll::Ready(Some(Ok(capacity))) => capacity, - Poll::Ready(Some(Err(error))) => return Poll::Ready(Err(io_other(error))), + Poll::Ready(Some(Err(error))) => return Poll::Ready(Err(error)), Poll::Ready(None) => { return Poll::Ready(Err(io::Error::new( io::ErrorKind::BrokenPipe, @@ -517,23 +964,20 @@ impl AsyncWrite for StreamOne { }; let count = requested.min(capacity); self.upload - .send_data(Bytes::copy_from_slice(&input[..count]), false) - .map_err(io_other)?; + .send_data(Bytes::copy_from_slice(&input[..count]), false)?; Poll::Ready(Ok(count)) } - fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.upload.poll_flush(cx) } - fn poll_shutdown(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { if !self.send_closed { - self.upload - .send_data(Bytes::new(), true) - .map_err(io_other)?; + self.upload.send_data(Bytes::new(), true)?; self.send_closed = true; } - Poll::Ready(Ok(())) + self.upload.poll_finish(cx) } } @@ -561,7 +1005,9 @@ impl UploadResponse { match &mut self.state { UploadResponseState::Pending(response) => match response.as_mut().poll(cx) { Poll::Ready(Ok(response)) => { - if let Err(error) = ensure_success(response.status(), "stream-up upload") { + if let Err(error) = + ensure_success(response.status(), "stream-up upload", true) + { self.state = UploadResponseState::Done; return Poll::Ready(Err(error)); } @@ -569,7 +1015,7 @@ impl UploadResponse { } Poll::Ready(Err(error)) => { self.state = UploadResponseState::Done; - return Poll::Ready(Err(io_other(error))); + return Poll::Ready(Err(error)); } Poll::Pending => return Poll::Pending, }, @@ -577,10 +1023,7 @@ impl UploadResponse { Poll::Ready(Some(Ok(data))) => { drained_frames = drained_frames.saturating_add(1); drained_bytes = drained_bytes.saturating_add(data.len()); - stream - .flow_control() - .release_capacity(data.len()) - .map_err(io_other)?; + stream.release_capacity(data.len())?; if upload_response_drain_budget_exhausted(drained_frames, drained_bytes) { cx.waker().wake_by_ref(); return Poll::Pending; @@ -588,7 +1031,7 @@ impl UploadResponse { } Poll::Ready(Some(Err(error))) => { self.state = UploadResponseState::Done; - return Poll::Ready(Err(io_other(error))); + return Poll::Ready(Err(error)); } Poll::Ready(None) => { self.state = UploadResponseState::Done; @@ -614,7 +1057,7 @@ fn upload_response_drain_budget_exhausted(frames: usize, bytes: usize) -> bool { } struct StreamUp { - upload: SendStream, + upload: SendStream, upload_response: UploadResponse, download: Downlink, upload_chunk_size: usize, @@ -655,12 +1098,13 @@ impl AsyncWrite for StreamUp { } let requested = input.len().min(self.upload_chunk_size); + std::task::ready!(self.upload.poll_flush(cx))?; self.upload.reserve_capacity(requested); let capacity = self.upload.capacity(); let capacity = if capacity == 0 { match self.upload.poll_capacity(cx) { Poll::Ready(Some(Ok(capacity))) => capacity, - Poll::Ready(Some(Err(error))) => return Poll::Ready(Err(io_other(error))), + Poll::Ready(Some(Err(error))) => return Poll::Ready(Err(error)), Poll::Ready(None) => { return Poll::Ready(Err(io::Error::new( io::ErrorKind::BrokenPipe, @@ -674,8 +1118,7 @@ impl AsyncWrite for StreamUp { }; let count = requested.min(capacity); self.upload - .send_data(Bytes::copy_from_slice(&input[..count]), false) - .map_err(io_other)?; + .send_data(Bytes::copy_from_slice(&input[..count]), false)?; Poll::Ready(Ok(count)) } @@ -683,7 +1126,7 @@ impl AsyncWrite for StreamUp { if let Err(error) = self.upload_response.poll_error(cx) { return Poll::Ready(Err(error)); } - Poll::Ready(Ok(())) + self.upload.poll_flush(cx) } fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { @@ -691,24 +1134,15 @@ impl AsyncWrite for StreamUp { return Poll::Ready(Err(error)); } if !self.send_closed { - self.upload - .send_data(Bytes::new(), true) - .map_err(io_other)?; + self.upload.send_data(Bytes::new(), true)?; self.send_closed = true; } - Poll::Ready(Ok(())) + self.upload.poll_finish(cx) } } -type PendingWrite = Pin> + Send + 'static>>; - struct PacketUp { - sender: SendRequest, - request: RequestTemplate, - session_id: Arc, - sequence: u64, - upload_chunk_size: usize, - pending_write: Option, + upload: packet_up::Writer, download: Downlink, closed: bool, _connections: ConnectionGuards, @@ -720,6 +1154,7 @@ impl AsyncRead for PacketUp { cx: &mut Context<'_>, output: &mut ReadBuf<'_>, ) -> Poll> { + self.upload.check_read(cx)?; self.download.poll_read(cx, output) } } @@ -736,56 +1171,18 @@ impl AsyncWrite for PacketUp { "XHTTP packet-up upload is closed", ))); } - if input.is_empty() { - return Poll::Ready(Ok(0)); - } - if self.pending_write.is_none() { - let count = input.len().min(self.upload_chunk_size); - let data = Bytes::copy_from_slice(&input[..count]); - let sender = self.sender.clone(); - let request = self.request.clone(); - let session_id = self.session_id.clone(); - let sequence = self.sequence; - self.sequence = self.sequence.checked_add(1).ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "XHTTP sequence overflow") - })?; - self.pending_write = Some(Box::pin(async move { - post_packet(sender, request, session_id, sequence, data).await?; - Ok(count) - })); - } - - let future = self.pending_write.as_mut().expect("pending write exists"); - match future.as_mut().poll(cx) { - Poll::Ready(result) => { - self.pending_write = None; - Poll::Ready(result) - } - Poll::Pending => Poll::Pending, - } + Pin::new(&mut self.upload).poll_write(cx, input) } fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let Some(future) = self.pending_write.as_mut() else { - return Poll::Ready(Ok(())); - }; - match future.as_mut().poll(cx) { - Poll::Ready(Ok(_)) => { - self.pending_write = None; - Poll::Ready(Ok(())) - } - Poll::Ready(Err(error)) => { - self.pending_write = None; - Poll::Ready(Err(error)) - } - Poll::Pending => Poll::Pending, - } + Pin::new(&mut self.upload).poll_flush(cx) } fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - match self.as_mut().poll_flush(cx) { + match Pin::new(&mut self.upload).poll_shutdown(cx) { Poll::Ready(Ok(())) => { self.closed = true; + self._connections.permit_upload_recycle(); Poll::Ready(Ok(())) } other => other, @@ -793,24 +1190,29 @@ impl AsyncWrite for PacketUp { } } -async fn post_packet( - sender: SendRequest, - request: RequestTemplate, - session_id: Arc, - sequence: u64, - data: Bytes, -) -> io::Result<()> { +async fn post_packet(sender: SendRequest, request: Request<()>, data: Bytes) -> io::Result<()> { let mut sender = sender; - let request = request.build( - Method::POST, - Some(session_id.as_ref()), - Some(sequence), - false, - )?; - let (response, mut upload) = sender.send_request(request, false).map_err(io_other)?; - upload.send_data(data, true).map_err(io_other)?; - let response = response.await.map_err(io_other)?; - ensure_success(response.status(), "packet-up upload")?; + let empty = data.is_empty(); + let (response, mut upload) = sender.send_request(request, empty).await?; + if !empty { + upload.send_data(data, true)?; + } + let response = response.await?; + ensure_success(response.status(), "packet-up upload", false)?; + let mut body = response.into_body(); + let mut drained = 0usize; + while let Some(data) = std::future::poll_fn(|cx| body.poll_data(cx)).await { + let data = data?; + drained = drained.saturating_add(data.len()); + if drained > request::MAX_REQUEST_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "XHTTP upload response exceeds its drain budget", + )); + } + body.release_capacity(data.len())?; + tokio::task::yield_now().await; + } Ok(()) } @@ -856,19 +1258,12 @@ fn normalized_base_path(raw_path: &str) -> String { path } -fn random_session_id() -> String { - let bytes: [u8; 16] = rand::random(); - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut output = String::with_capacity(32); - for byte in bytes { - output.push(char::from(HEX[usize::from(byte >> 4)])); - output.push(char::from(HEX[usize::from(byte & 0x0f)])); - } - output -} - -fn ensure_success(status: StatusCode, operation: &str) -> io::Result<()> { - if status.is_success() { +fn ensure_success( + status: StatusCode, + operation: &str, + allow_other_success: bool, +) -> io::Result<()> { + if status == StatusCode::OK || (allow_other_success && status.is_success()) { Ok(()) } else { Err(io::Error::other(format!( @@ -959,6 +1354,194 @@ mod tests { )); } + fn open_response_peer(server_io: tokio::io::DuplexStream, mode: XHttpMode) -> JoinHandle<()> { + tokio::spawn(async move { + let mut connection = h2::server::handshake(server_io).await.unwrap(); + let mut requests = Vec::new(); + let mut responses = Vec::new(); + while let Some(Ok((request, mut respond))) = connection.accept().await { + let is_download = request.method() == Method::GET || mode == XHttpMode::StreamOne; + let mut response = respond + .send_response(http::Response::new(()), !is_download) + .unwrap(); + if is_download { + response + .send_data(Bytes::from_static(b"ready"), false) + .unwrap(); + } + requests.push(request); + responses.push(response); + } + }) + } + + async fn assert_full_shutdown(mode: XHttpMode, independent_download: bool) { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let mut servers = vec![open_response_peer(server_io, mode)]; + let client = XHttpClient::new(XHttpConfig::new("example.com", "/x", mode).unwrap()); + let mut stream = if independent_download { + let (download_io, download_server) = tokio::io::duplex(64 * 1024); + servers.push(open_response_peer(download_server, mode)); + client + .connect_with_download(Box::new(client_io), &client, Box::new(download_io)) + .await + .unwrap() + } else { + client.connect(Box::new(client_io)).await.unwrap() + }; + let mut greeting = [0; 5]; + stream.read_exact(&mut greeting).await.unwrap(); + assert_eq!(&greeting, b"ready"); + + let (mut reader, mut writer) = tokio::io::split(stream); + let (pending, observed_pending) = tokio::sync::oneshot::channel(); + let read = tokio::spawn(async move { + let mut pending = Some(pending); + let mut byte = [0]; + let mut output = ReadBuf::new(&mut byte); + std::future::poll_fn(|cx| { + let result = Pin::new(&mut reader).poll_read(cx, &mut output); + if result.is_pending() + && let Some(pending) = pending.take() + { + let _ = pending.send(()); + } + result + }) + .await + }); + observed_pending.await.unwrap(); + writer.shutdown().await.unwrap(); + let result = tokio::time::timeout(std::time::Duration::from_secs(2), read) + .await + .expect("XHTTP shutdown left a blocked downstream reader") + .unwrap(); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::BrokenPipe); + assert_eq!( + writer.write_all(b"late").await.unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + writer.shutdown().await.unwrap(); + for server in servers { + tokio::time::timeout(Duration::from_secs(2), server) + .await + .expect("XHTTP connection survived shutdown") + .unwrap(); + } + } + + #[tokio::test] + async fn stream_one_shutdown_closes_both_directions_without_dropping_the_stream() { + assert_full_shutdown(XHttpMode::StreamOne, false).await; + } + + #[tokio::test] + async fn stream_up_shutdown_closes_both_directions_and_download_connections() { + assert_full_shutdown(XHttpMode::StreamUp, false).await; + assert_full_shutdown(XHttpMode::StreamUp, true).await; + } + + #[tokio::test] + async fn packet_up_shutdown_closes_both_directions_and_download_connections() { + assert_full_shutdown(XHttpMode::PacketUp, false).await; + assert_full_shutdown(XHttpMode::PacketUp, true).await; + } + + #[tokio::test] + async fn shutdown_discards_buffered_downlink_bytes() { + for mode in [ + XHttpMode::StreamOne, + XHttpMode::StreamUp, + XHttpMode::PacketUp, + ] { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let server = open_response_peer(server_io, mode); + let client = XHttpClient::new(XHttpConfig::new("example.com", "/x", mode).unwrap()); + let mut stream = client.connect(Box::new(client_io)).await.unwrap(); + assert_eq!(stream.read_u8().await.unwrap(), b'r'); + stream.shutdown().await.unwrap(); + assert_eq!( + stream.read_u8().await.unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + tokio::time::timeout(Duration::from_secs(2), server) + .await + .unwrap() + .unwrap(); + } + } + + #[tokio::test] + async fn shutdown_before_response_headers_releases_the_connection() { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let (accepted, request_accepted) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let mut connection = h2::server::handshake(server_io).await.unwrap(); + let (_request, _respond) = connection.accept().await.unwrap().unwrap(); + accepted.send(()).unwrap(); + assert!(!matches!(connection.accept().await, Some(Ok(_)))); + }); + let client = + XHttpClient::new(XHttpConfig::new("example.com", "/x", XHttpMode::StreamOne).unwrap()); + let mut stream = client.connect(Box::new(client_io)).await.unwrap(); + request_accepted.await.unwrap(); + tokio::time::timeout(Duration::from_secs(2), stream.shutdown()) + .await + .unwrap() + .unwrap(); + assert_eq!( + stream.read_u8().await.unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + tokio::time::timeout(Duration::from_secs(2), server) + .await + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn packet_up_shutdown_bounds_a_pending_post_and_rejects_further_io() { + let (client_io, server_io) = tokio::io::duplex(64 * 1024); + let (accepted, post_accepted) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let mut connection = h2::server::handshake(server_io).await.unwrap(); + let (_download, mut respond) = connection.accept().await.unwrap().unwrap(); + let _response = respond + .send_response(http::Response::new(()), false) + .unwrap(); + let (_post, _post_response) = connection.accept().await.unwrap().unwrap(); + accepted.send(()).unwrap(); + // Never acknowledge the POST. This is a real HTTP response wait, + // not a mock of the internal upload future. + assert!(!matches!(connection.accept().await, Some(Ok(_)))); + }); + let client = + XHttpClient::new(XHttpConfig::new("example.com", "/x", XHttpMode::PacketUp).unwrap()); + let mut stream = client.connect(Box::new(client_io)).await.unwrap(); + stream.write_all(b"pending").await.unwrap(); + // Buffered write completion is not a POST acknowledgement. The owned + // timer sends without further caller IO; the peer never acknowledges. + post_accepted.await.unwrap(); + let started = std::time::Instant::now(); + tokio::time::timeout(Duration::from_secs(2), stream.shutdown()) + .await + .expect("pending packet-up POST blocked connection close") + .unwrap(); + assert!(started.elapsed() >= Duration::from_secs(1)); + assert_eq!( + stream.read_u8().await.unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + assert_eq!( + stream.write_all(b"late").await.unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + tokio::time::timeout(Duration::from_secs(2), server) + .await + .unwrap() + .unwrap(); + } + #[tokio::test] async fn stream_one_is_a_single_full_duplex_post() { let (client_io, server_io) = tokio::io::duplex(64 * 1024); @@ -981,10 +1564,19 @@ mod tests { .send_response(http::Response::new(()), false) .unwrap(); let mut body = request.into_body(); + let mut received = 0; while let Some(data) = body.data().await { - response.send_data(data.unwrap(), false).unwrap(); + let data = data.unwrap(); + received += data.len(); + response.send_data(data, false).unwrap(); + if received == 5 { + break; + } } response.send_data(Bytes::new(), true).unwrap(); + // Retain the request body until the client closes; dropping it + // now would reset a still-open upload before the reply drains. + body }); let mut keep_server_alive = keep_server_alive; tokio::select! { @@ -993,7 +1585,7 @@ mod tests { assert!(incoming.is_none(), "unexpected second stream-one request"); } } - handler.await.unwrap(); + let _body = handler.await.unwrap(); }); let client = @@ -1012,10 +1604,6 @@ mod tests { .await .expect("stream-one write timed out") .unwrap(); - tokio::time::timeout(std::time::Duration::from_secs(2), stream.shutdown()) - .await - .expect("stream-one shutdown timed out") - .unwrap(); let mut response = Vec::new(); tokio::time::timeout( std::time::Duration::from_secs(2), @@ -1025,6 +1613,7 @@ mod tests { .expect("stream-one read timed out") .unwrap(); assert_eq!(response, b"hello"); + stream.shutdown().await.unwrap(); let _ = close_server.send(()); server.await.unwrap(); } @@ -1125,18 +1714,22 @@ mod tests { let mut body = upload.into_body(); while let Some(data) = body.data().await { payload.extend_from_slice(&data.unwrap()); + if payload.len() == b"continuous".len() { + break; + } } download_stream .send_data(Bytes::from(payload), true) .unwrap(); + body }; tokio::pin!(handler); - tokio::select! { - () = &mut handler => {} + let _body = tokio::select! { + body = &mut handler => body, incoming = connection.accept() => { panic!("stream-up connection ended before upload completed: {incoming:?}") } - } + }; let mut keep_server_alive = keep_server_alive; tokio::select! { _ = &mut keep_server_alive => {} @@ -1162,10 +1755,6 @@ mod tests { .await .expect("stream-up write timed out") .unwrap(); - tokio::time::timeout(std::time::Duration::from_secs(2), stream.shutdown()) - .await - .expect("stream-up shutdown timed out") - .unwrap(); let mut response = Vec::new(); tokio::time::timeout( std::time::Duration::from_secs(2), @@ -1175,6 +1764,7 @@ mod tests { .expect("stream-up read timed out") .unwrap(); assert_eq!(response, b"continuous"); + stream.shutdown().await.unwrap(); let _ = close_server.send(()); server.await.unwrap(); } @@ -1216,16 +1806,20 @@ mod tests { let mut body = request.into_body(); while let Some(data) = body.data().await { payload.extend_from_slice(&data.unwrap()); + if mode == XHttpMode::StreamUp && payload.len() == b"uplink".len() { + break; + } } payload_sender.send((session_id, payload)).unwrap(); + body }; tokio::pin!(handler); - tokio::select! { - () = &mut handler => {} + let _body = tokio::select! { + body = &mut handler => body, incoming = connection.accept() => { panic!("upload connection ended before its request body: {incoming:?}") } - } + }; match connection.accept().await { None | Some(Err(_)) => {} Some(Ok(_)) => panic!("unexpected request on upload connection"), @@ -1293,11 +1887,6 @@ mod tests { .await .expect("split XHTTP write timed out") .unwrap(); - tokio::time::timeout(std::time::Duration::from_secs(2), stream.shutdown()) - .await - .expect("split XHTTP shutdown timed out") - .unwrap(); - let mut response = Vec::new(); tokio::time::timeout( std::time::Duration::from_secs(2), @@ -1307,8 +1896,7 @@ mod tests { .expect("split XHTTP read timed out") .unwrap(); assert_eq!(response, b"downlink"); - - drop(stream); + stream.shutdown().await.unwrap(); tokio::time::timeout(std::time::Duration::from_secs(2), upload_server) .await .expect("upload connection task survived the returned stream") diff --git a/src/transport/xhttp/h2_driver.rs b/src/transport/xhttp/h2_driver.rs new file mode 100644 index 0000000..cbaf8f9 --- /dev/null +++ b/src/transport/xhttp/h2_driver.rs @@ -0,0 +1,95 @@ +//! HTTP/2 idle PING ownership over an already protected byte stream. +use super::{BoxStream, ConnectionGuard, DriverOwner, SendRequest, io_other}; +use crate::transport::h2_write::{Sender, Writes}; +use std::{ + io, + pin::Pin, + sync::{Arc, Mutex}, + task::{Context, Poll}, + time::Duration, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite, ReadBuf}, + time::Instant, +}; + +pub(super) async fn connect( + raw: BoxStream, + send_buffer: usize, + period: i32, + owner: &DriverOwner, +) -> io::Result<(SendRequest, Arc)> { + let last_read = Arc::new(Mutex::new(Instant::now())); + let (raw, writes) = Writes::wrap(raw); + let (sender, mut connection) = h2::client::Builder::new() + .max_header_list_size(super::MAX_H2_HEADER_LIST_SIZE) + .max_send_buffer_size(send_buffer) + .enable_push(false) + .handshake(ActivityIo { + raw: Box::new(raw), + last_read: last_read.clone(), + }) + .await + .map_err(io_other)?; + let mut ping = connection.ping_pong().expect("single XHTTP PING owner"); + let driver = owner.spawn(async move { + if period < 0 {let _ = connection.await; return;} + let interval = Duration::from_secs(if period == 0 {45} else {period as u64}); + loop { + let idle_at = *last_read.lock().unwrap() + interval; + tokio::select! { + _ = &mut connection => break, + () = tokio::time::sleep_until(idle_at) => { + if Instant::now() < *last_read.lock().unwrap() + interval {continue;} + tokio::select! { + _ = &mut connection => break, + result = tokio::time::timeout(Duration::from_secs(15), ping.ping(h2::Ping::opaque())) => { + if !matches!(result, Ok(Ok(_))) {break;} + } + } + } + } + } + })?; + Ok(( + SendRequest::H2(Sender { + request: sender, + writes, + }), + Arc::new(driver), + )) +} + +struct ActivityIo { + raw: BoxStream, + last_read: Arc>, +} +impl AsyncRead for ActivityIo { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let before = buf.filled().len(); + let result = Pin::new(&mut self.raw).poll_read(cx, buf); + if buf.filled().len() > before { + *self.last_read.lock().unwrap() = Instant::now(); + } + result + } +} +impl AsyncWrite for ActivityIo { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + data: &[u8], + ) -> Poll> { + Pin::new(&mut self.raw).poll_write(cx, data) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.raw).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.raw).poll_shutdown(cx) + } +} diff --git a/src/transport/xhttp/h3_driver.rs b/src/transport/xhttp/h3_driver.rs new file mode 100644 index 0000000..376596d --- /dev/null +++ b/src/transport/xhttp/h3_driver.rs @@ -0,0 +1,139 @@ +//! HTTP/3 over the shared controlled datagram seam. No socket or DNS fallback. +use super::{ConnectionGuard, DriverOwner, SendRequest, connection_closed}; +use crate::{ + dispatch::{DatagramBudget, DatagramTransport}, + transport::quic, +}; +use bytes::Bytes; +use std::{io, net::SocketAddr, sync::Arc, time::Duration}; +use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; + +pub(crate) struct QuicTransport { + pub transport: Box, + pub peer: SocketAddr, + pub budget: DatagramBudget, + pub tls: Arc, + pub server_name: String, +} + +#[derive(Clone)] +pub(super) struct Sender { + pub requests: h3::client::SendRequest, + pub runtime: Arc, + #[cfg(feature = "interop-test")] + pub observation: quinn::Connection, +} +impl std::fmt::Debug for Sender { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Http3Sender").finish_non_exhaustive() + } +} + +use crate::transport::quic::OwnedRuntime; + +pub(super) async fn connect( + raw: QuicTransport, + keepalive: i32, + owner: &DriverOwner, +) -> io::Result<(SendRequest, Arc)> { + let cancel = owner.cancellation.child_token(); + let stop = cancel.clone(); + let (ready, response) = oneshot::channel(); + let guard = owner.spawn_managed( + async move { + // Keep QUIC alive for the bounded close exchange after logical + // cancellation. Cancelling its runtime first loses CONNECTION_CLOSE. + let runtime = Arc::new(OwnedRuntime::new(CancellationToken::new())); + let mut ready = Some(ready); + let mut driver = None; + let mut endpoint = None; + let connection = async { + let crypto = + quinn::crypto::rustls::QuicClientConfig::try_from(raw.tls).map_err(failure)?; + let (socket, datagram) = quic::attach(raw.transport, raw.peer, raw.budget)?; + driver = Some(datagram); + let mtu = socket.budget().quic_payload_limit()?; + let mut limits = quinn::TransportConfig::default(); + limits + .initial_mtu(mtu) + .min_mtu(mtu) + .mtu_discovery_config(None) + .max_concurrent_bidi_streams(0_u8.into()) + .max_concurrent_uni_streams((crate::limits::XHTTP_H3_UNI_STREAMS as u32).into()) + .stream_receive_window((crate::limits::XHTTP_H3_STREAM_WINDOW as u32).into()) + .receive_window((crate::limits::XHTTP_H3_CONNECTION_WINDOW as u32).into()) + .send_window(crate::limits::XHTTP_H3_SEND_WINDOW as u64) + .datagram_receive_buffer_size(None) + .datagram_send_buffer_size(0) + .max_idle_timeout(Some(Duration::from_secs(300).try_into().map_err(failure)?)) + .keep_alive_interval((keepalive >= 0).then(|| { + Duration::from_secs(if keepalive == 0 { 10 } else { keepalive as u64 }) + })); + let mut client = quinn::ClientConfig::new(Arc::new(crypto)); + client.transport_config(Arc::new(limits)); + let mut config = quinn::EndpointConfig::default(); + config.max_udp_payload_size(mtu).map_err(failure)?; + let mut peer = quinn::Endpoint::new_with_abstract_socket( + config, + None, + socket, + runtime.clone(), + )?; + peer.set_default_client_config(client); + endpoint = Some(peer); + let connected = endpoint + .as_ref() + .unwrap() + .connect(raw.peer, &raw.server_name) + .map_err(failure)? + .await + .map_err(failure)?; + let (mut control, requests) = h3::client::builder() + .max_field_section_size(u64::from(super::MAX_H2_HEADER_LIST_SIZE)) + .build(super::h3_upload::Connection::new(connected.clone())) + .await + .map_err(failure)?; + // h3 closes the connection when its last request sender is dropped, + // even while response/upload streams still exist. Keep one sender + // with the physical driver, whose lease owns the connection lifetime. + let keep_requests = requests.clone(); + ready + .take() + .unwrap() + .send(Ok(Sender { + requests, + runtime: runtime.clone(), + #[cfg(feature = "interop-test")] + observation: connected, + })) + .map_err(|_| connection_closed())?; + let _ = control.wait_idle().await; + drop(keep_requests); + Ok::<_, io::Error>(()) + }; + let result = tokio::select! {biased; + () = stop.cancelled() => Err(connection_closed()), + result = connection => result, + }; + if let Some(ready) = ready { + let _ = ready.send(Err(result.err().unwrap_or_else(connection_closed))); + } + if let Some(endpoint) = endpoint { + endpoint.close(0_u8.into(), b""); + let _ = tokio::time::timeout(quic::CLOSE_TIMEOUT, endpoint.wait_idle()).await; + } + runtime.stop().await; + if let Some(driver) = driver { + let _ = driver.stop().await; + } + }, + cancel, + )?; + let sender = response.await.map_err(|_| connection_closed())??; + Ok((SendRequest::H3(sender), Arc::new(guard))) +} + +pub(super) fn failure(_: impl std::fmt::Display) -> io::Error { + io::Error::other("XHTTP HTTP/3 exchange failed") +} diff --git a/src/transport/xhttp/h3_upload.rs b/src/transport/xhttp/h3_upload.rs new file mode 100644 index 0000000..8831384 --- /dev/null +++ b/src/transport/xhttp/h3_upload.rs @@ -0,0 +1,274 @@ +//! Request-stream adapter for acknowledged QUIC upload completion. h3-quinn +//! still owns the HTTP/3 control streams; h3 owns all HTTP encoding. Quinn's +//! finish only queues FIN, so a logical close must also wait for stopped(None) +//! before its last lease can close the physical connection. The XHTTP wrapper +//! retains its one-second deadline and node Stop can cancel every wait. +use bytes::{Buf, Bytes}; +use h3::quic::{self, ConnectionErrorIncoming, StreamErrorIncoming, StreamId, WriteBuf}; +use std::{ + future::Future, + pin::Pin, + sync::Arc, + task::{Context, Poll, ready}, +}; +use tokio::io::{AsyncRead, ReadBuf}; + +type Operation = Pin> + Send>>; + +pub(super) struct Connection { + control: h3_quinn::Connection, + open: OpenStreams, +} + +impl Connection { + pub fn new(connection: quinn::Connection) -> Self { + let control = h3_quinn::Connection::new(connection.clone()); + let open = OpenStreams { + control: >::opener(&control), + connection, + pending: None, + }; + Self { control, open } + } +} + +impl quic::Connection for Connection { + type RecvStream = h3_quinn::RecvStream; + type OpenStreams = OpenStreams; + + fn poll_accept_recv( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { + >::poll_accept_recv(&mut self.control, cx) + } + fn poll_accept_bidi( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { + // Clients advertise a zero incoming-bidirectional-stream limit. Keep + // upstream's connection-error handling if the peer violates it. + let _unexpected = ready!( + >::poll_accept_bidi( + &mut self.control, + cx + ) + )?; + Poll::Ready(Err(ConnectionErrorIncoming::InternalError( + "unexpected HTTP/3 request stream".into(), + ))) + } + fn opener(&self) -> OpenStreams { + self.open.clone() + } +} + +pub(super) struct OpenStreams { + control: h3_quinn::OpenStreams, + connection: quinn::Connection, + pending: Option>, +} + +impl Clone for OpenStreams { + fn clone(&self) -> Self { + Self { + control: self.control.clone(), + connection: self.connection.clone(), + pending: None, + } + } +} + +impl quic::OpenStreams for OpenStreams { + type BidiStream = BidiStream; + type SendStream = h3_quinn::SendStream; + + fn poll_open_bidi( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { + let pending = self.pending.get_or_insert_with(|| { + let connection = self.connection.clone(); + Box::pin(async move { + connection.open_bi().await.map_err(|error| { + StreamErrorIncoming::ConnectionErrorIncoming { + connection_error: ConnectionErrorIncoming::Undefined(Arc::new(error)), + } + }) + }) + }); + let result = ready!(pending.as_mut().poll(cx)); + self.pending = None; + let (send, recv) = result?; + Poll::Ready(Ok(BidiStream { + send: SendStream { + raw: send, + queued: None, + completion: None, + finished: false, + }, + recv: RecvStream(recv), + })) + } + fn poll_open_send( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { + self.control.poll_open_send(cx) + } + fn close(&mut self, code: h3::error::Code, reason: &[u8]) { + >::close(&mut self.control, code, reason); + } +} + +impl quic::OpenStreams for Connection { + type BidiStream = BidiStream; + type SendStream = h3_quinn::SendStream; + fn poll_open_bidi( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { + self.open.poll_open_bidi(cx) + } + fn poll_open_send( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { + self.open.poll_open_send(cx) + } + fn close(&mut self, code: h3::error::Code, reason: &[u8]) { + self.open.close(code, reason); + } +} + +pub(super) struct BidiStream { + send: SendStream, + recv: RecvStream, +} +pub(super) struct SendStream { + raw: quinn::SendStream, + queued: Option>, + completion: Option>, + finished: bool, +} +pub(super) struct RecvStream(quinn::RecvStream); + +fn stream_error(error: impl std::error::Error + Send + Sync + 'static) -> StreamErrorIncoming { + StreamErrorIncoming::Unknown(Box::new(error)) +} + +impl quic::SendStream for SendStream { + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + if let Some(data) = &mut self.queued { + while data.has_remaining() { + let count = ready!(Pin::new(&mut self.raw).poll_write(cx, data.chunk())) + .map_err(stream_error)?; + data.advance(count); + } + } + self.queued = None; + Poll::Ready(Ok(())) + } + fn poll_finish(&mut self, cx: &mut Context<'_>) -> Poll> { + if self.finished { + return Poll::Ready(Ok(())); + } + ready!(self.poll_ready(cx))?; + if self.completion.is_none() { + self.raw.finish().map_err(stream_error)?; + let stopped = self.raw.stopped(); + self.completion = Some(Box::pin(async move { + match stopped.await.map_err(stream_error)? { + None => Ok(()), + Some(code) => Err(StreamErrorIncoming::StreamTerminated { + error_code: code.into_inner(), + }), + } + })); + } + let result = ready!(self.completion.as_mut().unwrap().as_mut().poll(cx)); + self.completion = None; + self.finished = result.is_ok(); + Poll::Ready(result) + } + fn send_data>>(&mut self, data: D) -> Result<(), StreamErrorIncoming> { + if self.queued.is_some() || self.completion.is_some() || self.finished { + return Err(stream_error(std::io::Error::other( + "HTTP/3 upload is not writable", + ))); + } + self.queued = Some(data.into()); + Ok(()) + } + fn reset(&mut self, code: u64) { + let _ = self + .raw + .reset(quinn::VarInt::from_u64(code).unwrap_or(quinn::VarInt::MAX)); + } + fn send_id(&self) -> StreamId { + u64::from(self.raw.id()).try_into().expect("QUIC stream ID") + } +} + +impl quic::RecvStream for RecvStream { + type Buf = Bytes; + fn poll_data( + &mut self, + cx: &mut Context<'_>, + ) -> Poll, StreamErrorIncoming>> { + let mut bytes = [0; 16384]; + let mut output = ReadBuf::new(&mut bytes); + ready!(Pin::new(&mut self.0).poll_read(cx, &mut output)).map_err(stream_error)?; + Poll::Ready(Ok( + (!output.filled().is_empty()).then(|| Bytes::copy_from_slice(output.filled())) + )) + } + fn stop_sending(&mut self, code: u64) { + let _ = self + .0 + .stop(quinn::VarInt::from_u64(code).unwrap_or(quinn::VarInt::MAX)); + } + fn recv_id(&self) -> StreamId { + u64::from(self.0.id()).try_into().expect("QUIC stream ID") + } +} + +impl quic::SendStream for BidiStream { + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.send.poll_ready(cx) + } + fn poll_finish(&mut self, cx: &mut Context<'_>) -> Poll> { + self.send.poll_finish(cx) + } + fn send_data>>(&mut self, data: D) -> Result<(), StreamErrorIncoming> { + self.send.send_data(data) + } + fn reset(&mut self, code: u64) { + self.send.reset(code); + } + fn send_id(&self) -> StreamId { + self.send.send_id() + } +} +impl quic::RecvStream for BidiStream { + type Buf = Bytes; + fn poll_data( + &mut self, + cx: &mut Context<'_>, + ) -> Poll, StreamErrorIncoming>> { + self.recv.poll_data(cx) + } + fn stop_sending(&mut self, code: u64) { + self.recv.stop_sending(code); + } + fn recv_id(&self) -> StreamId { + self.recv.recv_id() + } +} +impl quic::BidiStream for BidiStream { + type SendStream = SendStream; + type RecvStream = RecvStream; + fn split(self) -> (SendStream, RecvStream) { + (self.send, self.recv) + } +} diff --git a/src/transport/xhttp/http_io.rs b/src/transport/xhttp/http_io.rs new file mode 100644 index 0000000..9ac813d --- /dev/null +++ b/src/transport/xhttp/http_io.rs @@ -0,0 +1,467 @@ +//! Bounded HTTP exchanges over caller-owned IO; no socket or resolver ownership. +use std::{ + future::Future, + io, + pin::Pin, + task::{Context, Poll}, +}; + +use bytes::{Buf, Bytes}; +use http::{Request, Response}; +use hyper::body::{Body, Frame, Incoming, SizeHint}; +use tokio::sync::{mpsc, oneshot}; +use tokio_util::sync::PollSender; + +use super::{BoxStream, ConnectionGuard, DriverOwner, connection_closed}; +use crate::transport::h2_write::{self, Payload, Receipt, Writes}; + +const BODY_QUEUE: usize = crate::limits::XHTTP_BODY_QUEUE; +const BODY_CHUNK: usize = crate::limits::XHTTP_BODY_CHUNK; +type H1Request = ( + Request, + oneshot::Sender>>, +); +pub(super) type ResponseFuture = + Pin>> + Send>>; + +#[derive(Clone, Debug)] +pub(super) enum Sender { + H2(h2_write::Sender), + H1 { + requests: mpsc::Sender, + writes: Writes, + }, + H3(super::h3_driver::Sender), +} + +impl Sender { + pub(super) async fn send_request( + &mut self, + request: Request<()>, + end: bool, + ) -> io::Result<(ResponseFuture, Upload)> { + match self { + Self::H2(sender) => { + let (response, upload) = sender + .request + .send_request(request, end) + .map_err(super::io_other)?; + Ok(( + Box::pin(async move { + response + .await + .map(|r| r.map(Download::H2)) + .map_err(super::io_other) + }), + Upload { + inner: UploadKind::H2(upload, sender.writes.clone()), + pending: None, + }, + )) + } + Self::H1 { requests, writes } => { + let request = http1_request(request)?; + let (send, receive) = mpsc::channel(BODY_QUEUE); + let (reply, response) = oneshot::channel(); + let upload = if end { + drop(send); + H1Upload::closed() + } else { + H1Upload::new(send) + }; + requests + .send(( + request.map(|_| UploadBody { + receive, + empty: end, + }), + reply, + )) + .await + .map_err(|_| connection_closed())?; + Ok(( + Box::pin(async move { + response + .await + .map_err(|_| connection_closed())? + .map(|r| r.map(Download::H1)) + }), + Upload { + inner: UploadKind::H1(upload, writes.clone()), + pending: None, + }, + )) + } + Self::H3(sender) => { + let request = sender + .requests + .send_request(request) + .await + .map_err(super::h3_driver::failure)?; + let (mut send, mut receive) = request.split(); + let cancel = sender.runtime.cancel.child_token(); + let (upload, mut data) = mpsc::channel::(BODY_QUEUE); + let (finish, finished) = oneshot::channel(); + if end { + send.finish().await.map_err(super::h3_driver::failure)?; + let _ = finish.send(Ok(())); + } else { + let cancellation = cancel.clone(); + sender.runtime.spawn_owned(async move { + let transmit = async { + while let Some(data) = data.recv().await { + send.send_data(data.bytes).await?; + let _ = data.done.send(Ok(())); + } + send.finish().await + }; + let completed = tokio::select! {biased; + () = cancellation.cancelled() => false, + result = transmit => result.is_ok(), + }; + if !completed { + send.stop_stream(h3::error::Code::H3_REQUEST_CANCELLED); + } + let _ = finish.send(if completed { + Ok(()) + } else { + Err(connection_closed()) + }); + })?; + } + Ok(( + Box::pin(async move { + let response = receive + .recv_response() + .await + .map_err(super::h3_driver::failure)?; + Ok(response.map(|_| Download::H3(H3Download(receive)))) + }), + Upload { + inner: UploadKind::H3(H3Upload { + channel: H1Upload::new(upload), + cancel, + finished: Some(finished), + }), + pending: None, + }, + )) + } + } + } +} + +pub(super) async fn http1( + io: BoxStream, + owner: &DriverOwner, +) -> io::Result<(Sender, ConnectionGuard)> { + let (io, writes) = Writes::wrap(io); + let (mut sender, connection) = hyper::client::conn::http1::Builder::new() + .max_headers(super::request::MAX_REQUEST_HEADERS) + .max_buf_size(super::request::MAX_REQUEST_BYTES) + .writev(false) + .handshake(hyper_util::rt::TokioIo::new(io)) + .await + .map_err(h1_failure)?; + let (commands, mut requests) = mpsc::channel::(BODY_QUEUE); + // Dispatch eagerly, so an upload is not dependent on the caller polling reads. + // Both futures belong to the same cancellation/join barrier as the physical IO. + let guard = owner.spawn(async move { + let dispatch = async move { + while let Some((request, reply)) = requests.recv().await { + if reply.is_closed() { + continue; + } + let result = match sender.ready().await { + Ok(()) => sender.send_request(request).await, + Err(error) => { + let _ = reply.send(Err(h1_failure(error))); + break; + } + }; + let _ = reply.send(result.map_err(h1_failure)); + } + }; + tokio::pin!(connection); + tokio::select! { + _ = &mut connection => {}, + () = dispatch => { + // Dropping the last request sender does not end a response body. + // The logical stream/owner still controls the connection lifetime. + let _ = connection.await; + } + } + })?; + Ok(( + Sender::H1 { + requests: commands, + writes, + }, + guard, + )) +} + +fn h1_failure(_: hyper::Error) -> io::Error { + io::Error::other("XHTTP HTTP/1 exchange failed") +} + +fn http1_request(mut request: Request<()>) -> io::Result> { + let host = request + .uri() + .authority() + .ok_or_else(super::request::invalid_request)? + .as_str() + .to_owned(); + request + .headers_mut() + .insert(http::header::HOST, super::request::header_value(&host)?); + *request.uri_mut() = request + .uri() + .path_and_query() + .ok_or_else(super::request::invalid_request)? + .as_str() + .parse() + .map_err(|_| super::request::invalid_request())?; + *request.version_mut() = http::Version::HTTP_11; + Ok(request) +} + +pub(super) struct UploadBody { + receive: mpsc::Receiver, + empty: bool, +} +impl Body for UploadBody { + type Data = Payload; + type Error = io::Error; + fn poll_frame( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, io::Error>>> { + self.receive + .poll_recv(cx) + .map(|bytes| bytes.map(|data| Ok(Frame::data(data)))) + } + fn is_end_stream(&self) -> bool { + self.receive.is_closed() && self.receive.is_empty() + } + fn size_hint(&self) -> SizeHint { + if self.empty { + SizeHint::with_exact(0) + } else { + SizeHint::default() + } + } +} + +pub(super) struct Upload { + inner: UploadKind, + pending: Option, +} +enum UploadKind { + H2(h2::SendStream, Writes), + H1(H1Upload, Writes), + H3(H3Upload), +} +struct H3Chunk { + bytes: Bytes, + done: oneshot::Sender>, +} +struct H3Upload { + channel: H1Upload, + cancel: tokio_util::sync::CancellationToken, + finished: Option, +} +impl Drop for H3Upload { + fn drop(&mut self) { + self.cancel.cancel(); + } +} +struct H1Upload { + poll: PollSender, + direct: Option>, + reserved: bool, +} +impl H1Upload { + fn new(sender: mpsc::Sender) -> Self { + Self { + poll: PollSender::new(sender.clone()), + direct: Some(sender), + reserved: false, + } + } + fn closed() -> Self { + let (sender, _) = mpsc::channel(1); + let mut value = Self::new(sender); + value.close(); + value + } + fn close(&mut self) { + self.poll.abort_send(); + self.poll.close(); + self.direct = None; + self.reserved = false; + } + fn capacity(&self) -> usize { + if self.reserved { BODY_CHUNK } else { 0 } + } + fn poll_capacity(&mut self, cx: &mut Context<'_>) -> Poll>> { + if self.direct.is_none() { + return Poll::Ready(None); + } + match std::task::ready!(self.poll.poll_reserve(cx)) { + Ok(()) => { + self.reserved = true; + Poll::Ready(Some(Ok(BODY_CHUNK))) + } + Err(_) => Poll::Ready(Some(Err(connection_closed()))), + } + } + fn send(&mut self, data: T) -> io::Result<()> { + if self.reserved { + self.reserved = false; + self.poll.send_item(data).map_err(|_| connection_closed()) + } else { + self.direct + .as_ref() + .ok_or_else(connection_closed)? + .try_send(data) + .map_err(|_| connection_closed()) + } + } +} +impl Upload { + pub(super) fn reserve_capacity(&mut self, count: usize) { + if let UploadKind::H2(stream, _) = &mut self.inner { + stream.reserve_capacity(count); + } + } + pub(super) fn capacity(&self) -> usize { + match &self.inner { + UploadKind::H2(stream, _) => stream.capacity(), + UploadKind::H1(stream, _) => stream.capacity(), + UploadKind::H3(stream) => stream.channel.capacity(), + } + } + pub(super) fn poll_capacity( + &mut self, + cx: &mut Context<'_>, + ) -> Poll>> { + match &mut self.inner { + UploadKind::H2(stream, _) => stream + .poll_capacity(cx) + .map(|r| r.map(|r| r.map_err(super::io_other))), + UploadKind::H1(stream, _) => stream.poll_capacity(cx), + UploadKind::H3(stream) => stream.channel.poll_capacity(cx), + } + } + pub(super) fn send_data(&mut self, data: Bytes, end: bool) -> io::Result<()> { + if data.len() > crate::limits::XHTTP_PACKET_BATCH_BYTES { + return Err(super::request::invalid_request()); + } + // Writers drain each receipt before accepting another chunk. Packet-up + // sends exactly one bounded batch and then waits for its POST response. + if !data.is_empty() && self.pending.is_some() { + return Err(connection_closed()); + } + match &mut self.inner { + UploadKind::H2(stream, writes) => { + let payload = if data.is_empty() { + Payload::empty() + } else { + let (data, receipt) = writes.payload(data); + self.pending = Some(receipt); + data + }; + stream.send_data(payload, end).map_err(super::io_other)?; + } + UploadKind::H1(stream, writes) => { + if !data.is_empty() { + let (data, receipt) = writes.payload(data); + self.pending = Some(receipt); + stream.send(data)?; + } + if end { + stream.close(); + } + } + UploadKind::H3(stream) => { + if !data.is_empty() { + let (done, receipt) = oneshot::channel(); + self.pending = Some(receipt); + stream.channel.send(H3Chunk { bytes: data, done })?; + } + if end { + stream.channel.close(); + } + } + } + Ok(()) + } + pub(super) fn poll_flush(&mut self, cx: &mut Context<'_>) -> Poll> { + if let UploadKind::H2(stream, _) = &mut self.inner + && stream.poll_reset(cx).is_ready() + { + return Poll::Ready(Err(connection_closed())); + } + if let Some(pending) = &mut self.pending { + std::task::ready!(Pin::new(pending).poll(cx)).map_err(|_| connection_closed())??; + self.pending = None; + } + Poll::Ready(Ok(())) + } + pub(super) fn poll_finish(&mut self, cx: &mut Context<'_>) -> Poll> { + std::task::ready!(self.poll_flush(cx))?; + if let UploadKind::H3(stream) = &mut self.inner + && let Some(finished) = &mut stream.finished + { + std::task::ready!(Pin::new(finished).poll(cx)).map_err(|_| connection_closed())??; + stream.finished = None; + } + Poll::Ready(Ok(())) + } +} + +pub(super) enum Download { + H2(h2::RecvStream), + H1(Incoming), + H3(H3Download), +} +pub(super) struct H3Download(h3::client::RequestStream); +impl Download { + pub(super) fn poll_data(&mut self, cx: &mut Context<'_>) -> Poll>> { + match self { + Self::H3(stream) => stream.0.poll_recv_data(cx).map(|result| match result { + Ok(Some(mut data)) => Some(Ok(data.copy_to_bytes(data.remaining()))), + Ok(None) => None, + Err(error) => Some(Err(super::h3_driver::failure(error))), + }), + Self::H2(stream) => stream + .poll_data(cx) + .map(|r| r.map(|r| r.map_err(super::io_other))), + Self::H1(stream) => { + for _ in 0..16 { + match std::task::ready!(Pin::new(&mut *stream).poll_frame(cx)) { + Some(Ok(frame)) => { + if let Ok(data) = frame.into_data() { + return Poll::Ready(Some(Ok(data))); + } + } + Some(Err(error)) => return Poll::Ready(Some(Err(h1_failure(error)))), + None => return Poll::Ready(None), + } + } + cx.waker().wake_by_ref(); + Poll::Pending + } + } + } + pub(super) fn release_capacity(&mut self, bytes: usize) -> io::Result<()> { + match self { + Self::H2(stream) => stream + .flow_control() + .release_capacity(bytes) + .map_err(super::io_other), + Self::H1(_) | Self::H3(_) => Ok(()), + } + } +} diff --git a/src/transport/xhttp/packet_up.rs b/src/transport/xhttp/packet_up.rs new file mode 100644 index 0000000..2a73c4d --- /dev/null +++ b/src/transport/xhttp/packet_up.rs @@ -0,0 +1,214 @@ +//! Timer-driven bounded aggregation. Writes acknowledge admitted bytes; flush +//! waits for their POST responses. The node owns and joins the timer/IO driver. +use super::{ConnectionGuard, DriverOwner, RequestTemplate, SendRequest, connection_closed}; +use bytes::{Buf, BytesMut}; +use futures_util::task::AtomicWaker; +use std::{ + io, + pin::Pin, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + task::{Context, Poll}, + time::Duration, +}; +use tokio::{io::AsyncWrite, sync::Notify}; + +struct State { + buffer: BytesMut, + accepted: u64, + committed: u64, + error: Option>, + closed: bool, +} +struct Shared { + state: Mutex, + changed: Notify, + finishing: AtomicBool, + finish: Notify, + writer: AtomicWaker, + flush: AtomicWaker, + reader: AtomicWaker, +} +impl Shared { + fn wake(&self) { + self.writer.wake(); + self.flush.wake(); + self.reader.wake(); + } +} +impl State { + fn check(&self) -> io::Result<()> { + if let Some(error) = &self.error { + return Err(io::Error::new(error.kind(), error.clone())); + } + if self.closed { + return Err(connection_closed()); + } + Ok(()) + } +} +struct Lifetime(Arc); +impl Drop for Lifetime { + fn drop(&mut self) { + self.0.state.lock().unwrap().closed = true; + self.0.wake(); + } +} + +pub(super) struct Writer { + shared: Arc, + capacity: usize, + chunk: usize, + _task: ConnectionGuard, +} +impl Writer { + pub(super) fn new( + sender: SendRequest, + request: RequestTemplate, + session: Arc, + chunk: usize, + owner: &DriverOwner, + ) -> io::Result { + // Mihomo samples this maximum once per logical writer. Never allocate + // the configurable (up to 16 MiB) maximum per connection. + let capacity = request + .options + .post_bytes + .sample() + .min(crate::limits::XHTTP_PACKET_BATCH_BYTES); + let shared = Arc::new(Shared { + state: Mutex::new(State { + buffer: BytesMut::new(), + accepted: 0, + committed: 0, + error: None, + closed: false, + }), + changed: Notify::new(), + finishing: AtomicBool::new(false), + finish: Notify::new(), + writer: AtomicWaker::new(), + flush: AtomicWaker::new(), + reader: AtomicWaker::new(), + }); + let lifetime = Lifetime(shared.clone()); + let worker = shared.clone(); + let task = owner.spawn(async move { + let _lifetime = lifetime; + if let Err(error) = transmit(&worker, sender, request, session).await { + worker.state.lock().unwrap().error = Some(Arc::new(error)); + } + })?; + Ok(Self { + shared, + capacity, + chunk, + _task: task, + }) + } + pub(super) fn check_read(&self, cx: &mut Context<'_>) -> io::Result<()> { + self.shared.reader.register(cx.waker()); + self.shared.state.lock().unwrap().check() + } +} +impl AsyncWrite for Writer { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + input: &[u8], + ) -> Poll> { + self.shared.writer.register(cx.waker()); + if self.shared.finishing.load(Ordering::Acquire) { + return Poll::Ready(Err(connection_closed())); + } + let mut state = self.shared.state.lock().unwrap(); + state.check()?; + if input.is_empty() { + return Poll::Ready(Ok(0)); + } + let count = input + .len() + .min(self.chunk) + .min(self.capacity - state.buffer.len()); + if count == 0 { + return Poll::Pending; + } + let accepted = state + .accepted + .checked_add(count as u64) + .ok_or_else(connection_closed)?; + state.buffer.extend_from_slice(&input[..count]); + state.accepted = accepted; + drop(state); + self.shared.changed.notify_one(); + Poll::Ready(Ok(count)) + } + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.shared.flush.register(cx.waker()); + let state = self.shared.state.lock().unwrap(); + state.check()?; + if state.committed == state.accepted { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.shared.finishing.store(true, Ordering::Release); + self.shared.finish.notify_one(); + self.poll_flush(cx) + } +} + +async fn transmit( + shared: &Shared, + sender: SendRequest, + request: RequestTemplate, + session: Arc, +) -> io::Result<()> { + let mut sequence = 0_u64; + loop { + loop { + let changed = shared.changed.notified(); + if !shared.state.lock().unwrap().buffer.is_empty() { + break; + } + changed.await; + } + let delay = || Duration::from_millis(request.options.post_interval_ms.sample() as u64); + wait_interval(shared, delay()).await; + // One bounded batch in flight and one filling batch. Only this driver + // owns POST completion; read cannot acknowledge a pending caller write. + let mut batch = { + let mut state = shared.state.lock().unwrap(); + std::mem::take(&mut state.buffer).freeze() + }; + shared.writer.wake(); + while !batch.is_empty() { + let headers = + request.build(http::Method::POST, Some(&session), Some(sequence), false)?; + let (headers, payload, count) = + super::request::packet_payload(headers, &batch, &request.options.data)?; + sequence = sequence.checked_add(1).ok_or_else(connection_closed)?; + batch.advance(count); + super::post_packet(sender.clone(), headers, payload).await?; + shared.state.lock().unwrap().committed += count as u64; + shared.flush.wake(); + if !batch.is_empty() { + wait_interval(shared, delay()).await; + } + } + } +} + +async fn wait_interval(shared: &Shared, delay: Duration) { + let finish = shared.finish.notified(); + if !shared.finishing.load(Ordering::Acquire) { + tokio::select! { biased; + () = finish => {}, + () = tokio::time::sleep(delay) => {}, + } + } +} diff --git a/src/transport/xhttp/pool.rs b/src/transport/xhttp/pool.rs new file mode 100644 index 0000000..c094c85 --- /dev/null +++ b/src/transport/xhttp/pool.rs @@ -0,0 +1,269 @@ +//! Node-owned transport leases. Thresholds choose candidates, not admission. +use super::{ConnectionGuard, ConnectionLease, SendRequest, connection_closed}; +use crate::{ + config::XHttpReuseConfig, + resources::observation::{Guard, ResourceKind, track}, +}; +use std::{ + future::Future, + io, + sync::{Arc, Mutex, Weak}, + time::Duration, +}; +use tokio::time::Instant; + +const MAX_IDLE_ENTRIES: usize = crate::limits::XHTTP_IDLE_ENTRIES; +const MAX_IDLE_HTTP1_CONNECTIONS: usize = crate::limits::XHTTP_IDLE_H1_CONNECTIONS; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Policy { + Multiplexed, + Exclusive, + Recyclable, +} + +#[derive(Debug)] +pub(super) struct Pool { + options: Option>, + concurrency: usize, + connections: usize, + state: Arc>, + opening: tokio::sync::Mutex<()>, +} +#[derive(Debug, Default)] +struct State { + entries: Vec, + next_id: u64, + closed: bool, +} +#[derive(Clone, Debug)] +struct Physical { + sender: SendRequest, + driver: Arc, +} +#[derive(Debug)] +enum Transport { + Multiplexed(Physical), + Http1(Vec), +} +#[derive(Debug)] +struct Entry { + id: u64, + transport: Transport, + active: usize, + reuses: usize, + max_reuses: usize, + remaining: Option, + expires: Option, + _observation: Guard, +} +impl Entry { + fn retired(&self) -> bool { + matches!(&self.transport, Transport::Multiplexed(physical) if physical.driver.task.is_finished()) + || self.remaining == Some(0) + || self.max_reuses > 0 && self.reuses >= self.max_reuses + || self.expires.is_some_and(|at| Instant::now() >= at) + } + fn reserve(&mut self, state: &Arc>, reused: bool) -> io::Result { + self.active = self + .active + .checked_add(1) + .ok_or_else(|| io::Error::from(io::ErrorKind::OutOfMemory))?; + if reused { + self.reuses = self.reuses.saturating_add(1); + } + if let Some(left) = &mut self.remaining { + *left = left.saturating_sub(1); + } + Ok(Usage { + state: Arc::downgrade(state), + id: self.id, + recycle: None, + reusable: false, + }) + } + fn take(&mut self, policy: Policy) -> Option { + match &mut self.transport { + Transport::Multiplexed(physical) => Some(physical.clone()), + Transport::Http1(idle) if policy == Policy::Recyclable => { + while let Some(physical) = idle.pop() { + if !physical.driver.task.is_finished() { + return Some(physical); + } + } + None + } + Transport::Http1(_) => None, + } + } +} +impl State { + fn cleanup(&mut self) { + let mut idle = 0; + self.entries.retain(|entry| { + // Logical streams own driver leases independently of candidate retention. + if entry.retired() { + return false; + } + if entry.active != 0 { + return true; + } + idle += 1; + idle <= MAX_IDLE_ENTRIES + }); + } +} +impl Pool { + pub(super) fn new(options: Option>) -> Self { + Self { + concurrency: options + .as_ref() + .map_or(0, |options| options.concurrency.sample()), + connections: options + .as_ref() + .map_or(0, |options| options.connections.sample()), + options, + state: Arc::default(), + opening: tokio::sync::Mutex::default(), + } + } + pub(super) async fn acquire( + &self, + policy: Policy, + make: F, + ) -> io::Result<(SendRequest, ConnectionLease)> + where + F: FnOnce() -> Fut, + Fut: Future)>>, + { + let Some(options) = &self.options else { + let (sender, driver) = make().await?; + return Ok((sender, driver.into())); + }; + let _opening = self.opening.lock().await; + let existing = { + let mut state = self.state.lock().unwrap(); + if state.closed { + return Err(connection_closed()); + } + state.cleanup(); + if self.connections == 0 || state.entries.len() >= self.connections { + state + .entries + .iter_mut() + .filter(|entry| self.concurrency == 0 || entry.active < self.concurrency) + .min_by_key(|entry| entry.active) + .map(|entry| -> io::Result<_> { + Ok((entry.take(policy), entry.reserve(&self.state, true)?)) + }) + .transpose()? + } else { + None + } + }; + let (physical, mut usage) = if let Some((physical, usage)) = existing { + let physical = if let Some(physical) = physical { + physical + } else { + let (sender, driver) = make().await?; + Physical { sender, driver } + }; + (physical, usage) + } else { + let (sender, driver) = make().await?; + let physical = Physical { sender, driver }; + let mut state = self.state.lock().unwrap(); + if state.closed { + return Err(connection_closed()); + } + let id = state.next_id; + state.next_id = id.checked_add(1).ok_or_else(connection_closed)?; + let mut entry = Entry { + id, + transport: if policy == Policy::Multiplexed { + Transport::Multiplexed(physical.clone()) + } else { + Transport::Http1(Vec::new()) + }, + active: 0, + reuses: 0, + max_reuses: options.reuse_times.sample(), + remaining: (options.requests.max > 0).then(|| options.requests.sample()), + expires: (options.age_seconds.max > 0).then(|| { + Instant::now() + Duration::from_secs(options.age_seconds.sample() as u64) + }), + _observation: track(ResourceKind::Pool), + }; + let usage = entry.reserve(&self.state, false)?; + state.entries.push(entry); + (physical, usage) + }; + if policy == Policy::Recyclable { + usage.recycle = Some(physical.clone()); + } + Ok(( + physical.sender, + ConnectionLease { + _driver: physical.driver, + _usage: Some(usage), + }, + )) + } + pub(super) fn clear(&self) { + let mut state = self.state.lock().unwrap(); + state.closed = true; + state.entries.clear(); + } + + #[cfg(feature = "interop-test")] + pub(super) fn quic_pings(&self) -> Vec { + self.state + .lock() + .unwrap() + .entries + .iter() + .filter_map(|entry| { + let Transport::Multiplexed(Physical { + sender: SendRequest::H3(sender), + .. + }) = &entry.transport + else { + return None; + }; + Some(sender.observation.stats().frame_tx.ping) + }) + .collect() + } +} +#[derive(Debug)] +pub(super) struct Usage { + state: Weak>, + id: u64, + recycle: Option, + reusable: bool, +} +impl Usage { + pub(super) fn permit_recycle(&mut self) { + self.reusable = true; + } +} +impl Drop for Usage { + fn drop(&mut self) { + if let Some(state) = self.state.upgrade() { + let mut state = state.lock().unwrap(); + if let Some(entry) = state.entries.iter_mut().find(|entry| entry.id == self.id) { + entry.active = entry.active.saturating_sub(1); + if self.reusable + && !entry.retired() + && let Some(physical) = self.recycle.take() + && !physical.driver.task.is_finished() + && let Transport::Http1(idle) = &mut entry.transport + && idle.len() < MAX_IDLE_HTTP1_CONNECTIONS + { + idle.push(physical); + } + } + state.cleanup(); + } + } +} diff --git a/src/transport/xhttp/request.rs b/src/transport/xhttp/request.rs new file mode 100644 index 0000000..4e713cd --- /dev/null +++ b/src/transport/xhttp/request.rs @@ -0,0 +1,229 @@ +use std::io; + +use http::{HeaderValue, Request, Uri}; +use rand::RngExt as _; + +use crate::config::xhttp::{DataPlacement, MetaPlacement, PaddingPlacement, XHttpPadding}; + +pub(super) const MAX_REQUEST_BYTES: usize = crate::limits::XHTTP_REQUEST_BYTES; +pub(super) const MAX_REQUEST_HEADERS: usize = crate::limits::XHTTP_REQUEST_HEADERS; + +pub(super) fn request_size(request: &Request<()>) -> usize { + request + .headers() + .iter() + .map(|(name, value)| name.as_str().len() + value.len() + 32) + .sum::() + + request.uri().to_string().len() + + 128 +} + +pub(super) fn check_size(request: &Request<()>) -> io::Result<()> { + if request_size(request) > MAX_REQUEST_BYTES || request.headers().len() > MAX_REQUEST_HEADERS { + Err(invalid_request()) + } else { + Ok(()) + } +} + +pub(super) fn packet_payload( + mut request: Request<()>, + data: &[u8], + placement: &DataPlacement, +) -> io::Result<(Request<()>, bytes::Bytes, usize)> { + use base64::Engine as _; + let DataPlacement::Chunks { key, cookie, size } = placement else { + return Ok((request, bytes::Bytes::copy_from_slice(data), data.len())); + }; + let base = request_size(&request); + let fits = |raw: usize| { + let encoded = (raw * 4).div_ceil(3); + let chunks = encoded.div_ceil(size.min); + let extra = encoded + chunks * (key.len() + 10 + if *cookie { 0 } else { 32 }) + 32; + let fields = if *cookie { 1 } else { chunks }; + base + extra <= MAX_REQUEST_BYTES && request.headers().len() + fields <= MAX_REQUEST_HEADERS + }; + let (mut low, mut high) = (0, data.len()); + while low < high { + let mid = low + (high - low).div_ceil(2); + if fits(mid) { + low = mid; + } else { + high = mid - 1; + } + } + if low == 0 { + return Err(invalid_request()); + } + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&data[..low]); + let mut remaining = encoded.as_str(); + let mut index = 0; + while !remaining.is_empty() { + let count = size.sample().min(remaining.len()); + let (chunk, rest) = remaining.split_at(count); + remaining = rest; + if *cookie { + append_cookie(&mut request, &format!("{key}_{index}"), chunk)?; + } else { + let name = format!("{key}-{index}") + .parse::() + .map_err(|_| invalid_request())?; + request.headers_mut().insert(name, header_value(chunk)?); + } + index += 1; + } + check_size(&request)?; + Ok((request, bytes::Bytes::new(), low)) +} + +pub(super) fn apply_metadata( + request: &mut Request<()>, + placement: &MetaPlacement, + value: &str, +) -> io::Result<()> { + match placement { + MetaPlacement::Path => { + let path = request.uri().path(); + let path = format!( + "{}{value}{}", + if path.ends_with('/') { + path.to_owned() + } else { + format!("{path}/") + }, + request + .uri() + .query() + .map_or(String::new(), |query| format!("?{query}")) + ); + let mut parts = request.uri().clone().into_parts(); + parts.path_and_query = Some(path.parse().map_err(|_| invalid_request())?); + *request.uri_mut() = Uri::from_parts(parts).map_err(|_| invalid_request())?; + } + MetaPlacement::Query(key) => append_query(request, key, value)?, + MetaPlacement::Header(key) => { + request + .headers_mut() + .insert(key.clone(), header_value(value)?); + } + MetaPlacement::Cookie(key) => append_cookie(request, key, value)?, + } + Ok(()) +} + +fn append_query(request: &mut Request<()>, key: &str, value: &str) -> io::Result<()> { + let mut parts = request.uri().clone().into_parts(); + let path = request.uri().path_and_query().unwrap().as_str(); + let separator = if request.uri().query().is_some() { + '&' + } else { + '?' + }; + parts.path_and_query = Some( + format!("{path}{separator}{key}={value}") + .parse() + .map_err(|_| invalid_request())?, + ); + *request.uri_mut() = Uri::from_parts(parts).map_err(|_| invalid_request())?; + Ok(()) +} + +pub(super) fn apply_padding( + request: &mut Request<()>, + base: &Uri, + padding: &XHttpPadding, +) -> io::Result<()> { + let length = padding.bytes.sample(); + let value = if padding.tokenish { + tokenish(length) + } else { + "X".repeat(length) + }; + match padding.placement { + PaddingPlacement::Header => { + request + .headers_mut() + .insert(padding.header.clone(), header_value(&value)?); + } + PaddingPlacement::QueryInHeader => { + let url = format!( + "{}://{}{}?{}={}", + base.scheme_str().unwrap_or("https"), + base.authority().unwrap(), + base.path(), + padding.key, + value + ); + request + .headers_mut() + .insert(padding.header.clone(), header_value(&url)?); + } + PaddingPlacement::Query => { + append_query(request, &padding.key, &value)?; + } + PaddingPlacement::Cookie => append_cookie(request, &padding.key, &value)?, + } + Ok(()) +} + +pub(super) fn append_cookie(request: &mut Request<()>, key: &str, value: &str) -> io::Result<()> { + let prefix = request + .headers() + .get(http::header::COOKIE) + .map(|v| v.to_str()) + .transpose() + .map_err(|_| invalid_request())?; + let cookie = prefix.map_or_else( + || format!("{key}={value}"), + |prefix| format!("{prefix}; {key}={value}"), + ); + request + .headers_mut() + .insert(http::header::COOKIE, header_value(&cookie)?); + Ok(()) +} + +pub(super) fn header_value(value: &str) -> io::Result { + let mut value = HeaderValue::from_str(value).map_err(|_| invalid_request())?; + value.set_sensitive(true); + Ok(value) +} + +pub(super) fn invalid_request() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + "invalid or oversized XHTTP request", + ) +} + +fn tokenish(target: usize) -> String { + const ALPHABET: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + let mut rng = rand::rng(); + let mut result = String::with_capacity(target * 8 / 5 + 1); + let mut bits: usize = 0; + while bits.div_ceil(8) < target { + let ch = ALPHABET[rng.random_range(0..ALPHABET.len())]; + // Code widths for ASCII alphanumerics from RFC 7541 Appendix B. + // Incrementally target the encoded byte count; no HTTP encoder fork. + bits += match ch { + b'0'..=b'2' | b'a' | b'c' | b'e' | b'i' | b'o' | b's' | b't' => 5, + b'3'..=b'9' + | b'A' + | b'b' + | b'd' + | b'f' + | b'g' + | b'h' + | b'l' + | b'm' + | b'n' + | b'p' + | b'r' + | b'u' => 6, + b'X' | b'Z' => 8, + _ => 7, + }; + result.push(char::from(ch)); + } + result +} diff --git a/src/tun_runtime.rs b/src/tun_runtime.rs index b35539e..622e820 100644 --- a/src/tun_runtime.rs +++ b/src/tun_runtime.rs @@ -36,7 +36,9 @@ use crate::{ QuicConnectionKey, QuicSniffOutcome, QuicSniffer, quic_connection_key, quic_has_unsupported_version, }, - resources::{ResourceActivity, ResourceActivityGuard, ResourceQueue, RuntimeResourceStats}, + resources::{ + ResourceActivity, ResourceActivityGuard, ResourceQueue, RuntimeResourceStats, observation, + }, session::{Datagram, DatagramSession, Destination, InboundKind, StreamSession}, tcp_sniffer::{SniffOutcome, SniffProtocol, TcpSniffer}, traffic::TunTrafficStats, @@ -179,16 +181,16 @@ impl TunRuntime { let netstack_stats = parts.stats.clone(); let mut tasks = JoinSet::new(); - tasks.spawn(netstack_stats_loop( + tasks.spawn(observation::task(netstack_stats_loop( netstack_stats.clone(), cancellation.clone(), - )); - tasks.spawn( + ))); + tasks.spawn(observation::task( self.traffic_stats .clone() .run_rate_clock(cancellation.clone()), - ); - tasks.spawn(tun_read_loop( + )); + tasks.spawn(observation::task(tun_read_loop( self.tun.clone(), parts.packet_sink, self.ipv6, @@ -196,22 +198,22 @@ impl TunRuntime { resource_stats.clone(), self.traffic_stats.clone(), cancellation.clone(), - )); - tasks.spawn(tun_write_loop( + ))); + tasks.spawn(observation::task(tun_write_loop( self.tun, parts.packet_stream, self.traffic_stats, cancellation.clone(), - )); + ))); let sniffer = self.sniffer; - tasks.spawn(tcp_loop( + tasks.spawn(observation::task(tcp_loop( parts.tcp_listener, self.dispatcher.clone(), sniffer.clone(), resource_stats.clone(), cancellation.clone(), - )); - tasks.spawn(udp_loop( + ))); + tasks.spawn(observation::task(udp_loop( parts.udp_socket, self.dispatcher, self.dns, @@ -219,7 +221,7 @@ impl TunRuntime { self.limits, resource_stats.clone(), cancellation.clone(), - )); + ))); let mut first_error = tokio::select! { biased; @@ -445,14 +447,14 @@ async fn tcp_loop( "TUN TCP session accepted" ); let activity = resource_stats.begin(ResourceActivity::TcpSession); - sessions.spawn(relay_tcp( + sessions.spawn(observation::task(relay_tcp( session_id, stream, dispatcher.clone(), sniffer.clone(), activity, cancellation.clone(), - )); + ))); } } } @@ -845,7 +847,7 @@ async fn udp_loop( } } let permit = dns.begin_query(); - dns_tasks.spawn(run_tun_dns_query( + dns_tasks.spawn(observation::task(run_tun_dns_query( dns.clone(), permit, datagram, @@ -853,7 +855,7 @@ async fn udp_loop( tun_mtu, resource_stats.clone(), cancellation.clone(), - )); + ))); continue; } let source = datagram.source; @@ -871,7 +873,7 @@ async fn udp_loop( }); let activity = resource_stats.begin(ResourceActivity::UdpAssociation); - tasks.spawn(run_udp_association( + tasks.spawn(observation::task(run_udp_association( receiver, activity, UdpAssociationTaskContext { @@ -886,7 +888,7 @@ async fn udp_loop( sniffer: sniffer.clone(), cancellation: child_cancellation, }, - )); + ))); } let Some(association) = associations.get(&source) else { continue; @@ -1824,7 +1826,7 @@ mod tests { use async_trait::async_trait; use tokio::{ - io::{AsyncReadExt as _, AsyncWriteExt as _}, + io::AsyncReadExt as _, sync::{Notify, mpsc}, }; @@ -4322,11 +4324,11 @@ mod tests { } fn add_bytes(sum: &mut u32, bytes: &[u8]) { - let mut chunks = bytes.chunks_exact(2); - for chunk in &mut chunks { - *sum += u32::from(u16::from_be_bytes([chunk[0], chunk[1]])); + let (chunks, remainder) = bytes.as_chunks::<2>(); + for chunk in chunks { + *sum += u32::from(u16::from_be_bytes(*chunk)); } - if let Some(byte) = chunks.remainder().first() { + if let Some(byte) = remainder.first() { *sum += u32::from(*byte) << 8; } } diff --git a/src/windows/snapshot.rs b/src/windows/snapshot.rs index bd1b249..2af1cac 100644 --- a/src/windows/snapshot.rs +++ b/src/windows/snapshot.rs @@ -247,7 +247,13 @@ fn is_session_file_name(name: &str) -> bool { } fn snapshot_digest(bytes: &[u8]) -> String { - format!("{:x}", Sha256::digest(bytes)) + use std::fmt::Write as _; + + let mut output = String::with_capacity(64); + for byte in Sha256::digest(bytes) { + write!(&mut output, "{byte:02x}").expect("writing to a String cannot fail"); + } + output } fn invalid_snapshot() -> Error { diff --git a/src/xudp.rs b/src/xudp.rs index 9db14f2..1912e79 100644 --- a/src/xudp.rs +++ b/src/xudp.rs @@ -1,4 +1,4 @@ -//! Minimal XUDP framing for one UDP association over a VLESS mux command. +//! XUDP frames over an authenticated mux stream; protocol response headers belong to the caller. use std::io; @@ -7,6 +7,9 @@ use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; use crate::{ dispatch::{BoxStream, DatagramTransport, DispatchError}, + outbound::address::{ + decode_port_first as decode_destination, encode_port_first as encode_destination, + }, session::{Datagram, Destination}, }; @@ -17,14 +20,14 @@ const STATUS_KEEP_ALIVE: u8 = 4; const OPTION_DATA: u8 = 1; const OPTION_ERROR: u8 = 2; const NETWORK_UDP: u8 = 2; -const MAX_METADATA_LENGTH: usize = 512; +pub const MAX_METADATA_LENGTH: usize = 512; pub struct XudpTransport { - stream: BoxStream, + stream: Option, global_id: [u8; 8], max_response_payload_size: u16, + max_request_payload_size: u16, first_write: bool, - response_pending: bool, last_remote: Option, receive_buffer: BytesMut, max_receive_buffer_size: usize, @@ -37,8 +40,6 @@ impl std::fmt::Debug for XudpTransport { .debug_struct("XudpTransport") .field("max_response_payload_size", &self.max_response_payload_size) .field("first_write", &self.first_write) - .field("response_pending", &self.response_pending) - .field("last_remote", &self.last_remote) .field("receive_buffered", &self.receive_buffer.len()) .field("max_receive_buffer_size", &self.max_receive_buffer_size) .field("closed", &self.closed) @@ -49,21 +50,33 @@ impl std::fmt::Debug for XudpTransport { impl XudpTransport { #[must_use] pub fn new(stream: BoxStream, global_id: [u8; 8], max_response_payload_size: u16) -> Self { - // One VLESS response header plus one maximum-size XUDP frame. The + Self::with_budget( + stream, + global_id, + crate::dispatch::DatagramBudget::new(u16::MAX, max_response_payload_size), + ) + } + + pub fn with_budget( + stream: BoxStream, + global_id: [u8; 8], + budget: crate::dispatch::DatagramBudget, + ) -> Self { + let max_response_payload_size = budget.receive(); + // One maximum-size XUDP frame; the caller consumes its protocol header. The // checked construction keeps the bound explicit if any component is // widened in the future. let max_receive_buffer_size = 2_usize - .checked_add(2) - .and_then(|size| size.checked_add(MAX_METADATA_LENGTH)) + .checked_add(MAX_METADATA_LENGTH) .and_then(|size| size.checked_add(2)) .and_then(|size| size.checked_add(usize::from(max_response_payload_size))) .expect("XUDP receive-buffer ceiling fits usize"); Self { - stream, + stream: Some(stream), global_id, max_response_payload_size, + max_request_payload_size: budget.transmit(), first_write: true, - response_pending: true, last_remote: None, receive_buffer: BytesMut::with_capacity(max_receive_buffer_size), max_receive_buffer_size, @@ -87,7 +100,12 @@ impl XudpTransport { while self.receive_buffer.len() < required { let missing = required - self.receive_buffer.len(); let chunk = missing.min(scratch.len()); - let read = self.stream.read(&mut scratch[..chunk]).await?; + let read = self + .stream + .as_mut() + .expect("open XUDP stream") + .read(&mut scratch[..chunk]) + .await?; if read == 0 { return Err(io::Error::new( io::ErrorKind::UnexpectedEof, @@ -99,30 +117,6 @@ impl XudpTransport { Ok(()) } - async fn read_vless_response(&mut self) -> io::Result<()> { - if !self.response_pending { - return Ok(()); - } - self.fill_receive_buffer(2).await?; - let header = &self.receive_buffer[..2]; - if header[0] != 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "unexpected VLESS response version", - )); - } - if header[1] != 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "VLESS response addons are unsupported when flow is empty", - )); - } - // This reader never reads beyond the requested parser boundary. - self.receive_buffer.clear(); - self.response_pending = false; - Ok(()) - } - async fn read_frame(&mut self) -> io::Result { loop { self.fill_receive_buffer(2).await?; @@ -257,27 +251,44 @@ impl XudpTransport { #[async_trait::async_trait] impl DatagramTransport for XudpTransport { + fn payload_budget(&self, _peer: &Destination) -> crate::dispatch::DatagramBudget { + crate::dispatch::DatagramBudget::new( + self.max_request_payload_size, + self.max_response_payload_size, + ) + } + async fn send(&mut self, datagram: Datagram) -> Result<(), DispatchError> { if self.closed { return Err(DispatchError::Other( "XUDP association is closed".to_owned(), )); } + if datagram.payload.len() > usize::from(self.max_request_payload_size) { + return Err(io::Error::from(io::ErrorKind::InvalidInput).into()); + } let frame = encode_data_frame( &datagram, self.first_write, - if self.first_write { + if self.first_write && self.global_id != [0; 8] { Some(self.global_id) } else { None }, ) .map_err(DispatchError::from)?; - self.stream + // Own the stream across the non-cancellation-safe write. Dropping this + // future after a partial frame closes the wire rather than resuming at + // a new frame boundary on the next send. + self.closed = true; + let mut stream = self.stream.take().expect("open XUDP stream"); + stream .write_all(&frame) .await .map_err(DispatchError::from)?; - self.stream.flush().await.map_err(DispatchError::from)?; + stream.flush().await.map_err(DispatchError::from)?; + self.stream = Some(stream); + self.closed = false; self.first_write = false; self.last_remote = Some(datagram.remote); Ok(()) @@ -289,22 +300,25 @@ impl DatagramTransport for XudpTransport { "XUDP association is closed".to_owned(), )); } - self.read_vless_response() - .await - .map_err(DispatchError::from)?; - self.read_frame().await.map_err(DispatchError::from) + let result = self.read_frame().await; + if result.is_err() { + self.closed = true; + self.stream.take(); + } + result.map_err(DispatchError::from) } async fn close(&mut self) -> Result<(), DispatchError> { if self.closed { return Ok(()); } - self.stream + self.closed = true; + let mut stream = self.stream.take().expect("open XUDP stream"); + stream .write_all(&[0, 4, 0, 0, STATUS_END, 0]) .await .map_err(DispatchError::from)?; - self.stream.shutdown().await.map_err(DispatchError::from)?; - self.closed = true; + stream.shutdown().await.map_err(DispatchError::from)?; Ok(()) } } @@ -336,7 +350,7 @@ pub(crate) fn encode_data_frame( let mut output = BytesMut::with_capacity(2 + usize::from(metadata_length) + 2 + datagram.payload.len()); output.put_u16(metadata_length); - output.put_u16(0); // session id; XUDP uses the VLESS mux session zero + output.put_u16(0); // one XUDP association uses mux session zero output.put_u8(if first { STATUS_NEW } else { STATUS_KEEP }); output.put_u8(OPTION_DATA); output.put_u8(NETWORK_UDP); @@ -349,108 +363,36 @@ pub(crate) fn encode_data_frame( Ok(output.freeze()) } -fn encode_destination(destination: &Destination, output: &mut BytesMut) -> io::Result<()> { - output.put_u16(destination.port()); - match destination { - Destination::Ip(address) if address.is_ipv4() => { - output.put_u8(1); - let std::net::IpAddr::V4(ip) = address.ip() else { - unreachable!("is_ipv4 checked") - }; - output.extend_from_slice(&ip.octets()); - } - Destination::Domain { host, .. } => { - let length = u8::try_from(host.len()).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidInput, "XUDP domain is too long") - })?; - output.put_u8(2); - output.put_u8(length); - output.extend_from_slice(host.as_bytes()); - } - Destination::Ip(address) => { - output.put_u8(3); - let std::net::IpAddr::V6(ip) = address.ip() else { - unreachable!("non-IPv4 address is IPv6") - }; - output.extend_from_slice(&ip.octets()); - } - } - Ok(()) -} - -fn decode_destination(input: &[u8]) -> io::Result<(Destination, usize)> { - if input.len() < 3 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "truncated XUDP destination", - )); - } - let port = u16::from_be_bytes([input[0], input[1]]); - if port == 0 { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "XUDP destination port is zero", - )); - } - match input[2] { - 1 => { - let octets: [u8; 4] = input - .get(3..7) - .ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "truncated IPv4 destination") - })? - .try_into() - .expect("slice length checked"); - Ok(( - Destination::from(std::net::SocketAddr::new( - std::net::IpAddr::V4(octets.into()), - port, - )), - 7, - )) - } - 2 => { - let length = usize::from(*input.get(3).ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "truncated domain destination") - })?); - let host = input.get(4..4 + length).ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "truncated domain destination") - })?; - let host = std::str::from_utf8(host) - .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "non-UTF-8 domain"))?; - Ok((Destination::domain(host, port)?, 4 + length)) - } - 3 => { - let octets: [u8; 16] = input - .get(3..19) - .ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidData, "truncated IPv6 destination") - })? - .try_into() - .expect("slice length checked"); - Ok(( - Destination::from(std::net::SocketAddr::new( - std::net::IpAddr::V6(octets.into()), - port, - )), - 19, - )) - } - _ => Err(io::Error::new( - io::ErrorKind::InvalidData, - "unknown XUDP address type", - )), - } -} - #[cfg(test)] mod tests { - use tokio::io::AsyncWriteExt as _; - use super::*; + #[tokio::test] + async fn shared_xudp_receives_frames_without_a_vless_response_header() { + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-XUDP", + "shared_xudp_receives_frames_without_a_vless_response_header", + ); + let (client, mut peer) = tokio::io::duplex(64); + peer.write_all(&[ + 0, 12, 0, 0, 2, 1, 2, 0, 53, 1, 1, 2, 3, 4, 0, 3, b'a', b'b', b'c', + ]) + .await + .unwrap(); + let mut transport = XudpTransport::new(Box::new(client), [0; 8], 1452); + let datagram = transport.receive().await.unwrap(); + assert_eq!(datagram.remote.to_string(), "1.2.3.4:53"); + assert_eq!(datagram.payload.as_ref(), b"abc"); + } + #[test] fn first_frame_matches_xray_mux_wire_format() { + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-XUDP", + "first_frame_matches_xray_mux_wire_format", + ); let datagram = Datagram { remote: "1.2.3.4:53".parse::().unwrap().into(), payload: Bytes::from_static(b"abc"), @@ -472,6 +414,11 @@ mod tests { #[test] fn followup_frame_carries_each_datagrams_destination() { + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-XUDP", + "followup_frame_carries_each_datagrams_destination", + ); let datagram = Datagram { remote: Destination::domain("dns.example", 53).unwrap(), payload: Bytes::from_static(b"q"), @@ -485,6 +432,11 @@ mod tests { #[test] fn destination_codec_round_trips_all_address_families() { + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-XUDP", + "destination_codec_round_trips_all_address_families", + ); for destination in [ Destination::from("192.0.2.1:80".parse::().unwrap()), Destination::from("[2001:db8::1]:443".parse::().unwrap()), @@ -500,6 +452,11 @@ mod tests { #[tokio::test] async fn normal_and_error_end_frames_are_distinguishable() { + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-XUDP", + "normal_and_error_end_frames_are_distinguishable", + ); let normal = receive_frame_error(&[0, 4, 0, 0, STATUS_END, 0]).await; let failed = receive_frame_error(&[0, 4, 0, 0, STATUS_END, OPTION_ERROR]).await; @@ -517,17 +474,21 @@ mod tests { #[tokio::test] async fn oversized_response_is_rejected_before_payload_read() { + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-XUDP", + "oversized_response_is_rejected_before_payload_read", + ); let (client, mut server) = tokio::io::duplex(64); server .write_all(&response_data_prefix(u16::MAX)) .await .unwrap(); let mut transport = XudpTransport::new(Box::new(client), [0; 8], 1_452); - let bounded_capacity = 2 + 2 + MAX_METADATA_LENGTH + 2 + 1_452; + let bounded_capacity = 2 + MAX_METADATA_LENGTH + 2 + 1_452; assert_eq!(transport.max_receive_buffer_size, bounded_capacity); assert_eq!(transport.receive_buffer.capacity(), bounded_capacity); - transport.read_vless_response().await.unwrap(); let error = tokio::time::timeout( std::time::Duration::from_millis(100), transport.read_frame(), @@ -553,23 +514,27 @@ mod tests { #[tokio::test] async fn fragmented_receive_survives_cancellation_and_resumes() { + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-XUDP", + "fragmented_receive_survives_cancellation_and_resumes", + ); let (client, mut server) = tokio::io::duplex(64); let mut frame = response_data_prefix(3); frame.extend_from_slice(b"abc"); // Stop in the middle of the two-byte metadata length. The first byte // has already crossed the stream boundary when receive is cancelled. - server.write_all(&frame[..3]).await.unwrap(); + server.write_all(&frame[..1]).await.unwrap(); let mut transport = XudpTransport::new(Box::new(client), [0; 8], 1_452); assert!( tokio::time::timeout(std::time::Duration::from_millis(20), transport.receive()) .await .is_err() ); - assert!(!transport.response_pending); assert_eq!(transport.receive_buffer.as_ref(), &[0]); - let remaining = frame[3..].to_vec(); + let remaining = frame[1..].to_vec(); let writer = tokio::spawn(async move { for byte in remaining { server.write_all(&[byte]).await.unwrap(); @@ -590,10 +555,13 @@ mod tests { #[tokio::test] async fn keepalive_data_is_consumed_without_becoming_an_udp_response() { + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-XUDP", + "keepalive_data_is_consumed_without_becoming_an_udp_response", + ); let (client, mut server) = tokio::io::duplex(128); let mut wire = vec![ - 0, - 0, // VLESS response header 0, 4, // metadata length 0, @@ -604,7 +572,7 @@ mod tests { 4, // discarded payload length ]; wire.extend_from_slice(b"ping"); - wire.extend_from_slice(&response_data_prefix(3)[2..]); + wire.extend_from_slice(&response_data_prefix(3)); wire.extend_from_slice(b"abc"); server.write_all(&wire).await.unwrap(); @@ -618,6 +586,11 @@ mod tests { #[tokio::test] async fn full_wire_payload_remains_available_to_proxy_inbounds() { + #[cfg(any(test, feature = "interop-test"))] + let mut _case = crate::resources::case_events::Case::new( + "FOUNDATIONS-XUDP", + "full_wire_payload_remains_available_to_proxy_inbounds", + ); let (client, mut server) = tokio::io::duplex(66_000); let mut frame = response_data_prefix(u16::MAX); frame.resize(frame.len() + usize::from(u16::MAX), 0x5a); @@ -633,8 +606,6 @@ mod tests { fn response_data_prefix(payload_length: u16) -> Vec { let mut frame = vec![ - 0, - 0, // VLESS response header 0, 12, // metadata length 0, @@ -656,7 +627,6 @@ mod tests { async fn receive_frame_error(frame: &[u8]) -> DispatchError { let (client, mut server) = tokio::io::duplex(64); - server.write_all(&[0, 0]).await.unwrap(); server.write_all(frame).await.unwrap(); let mut transport = XudpTransport::new(Box::new(client), [0; 8], u16::MAX); transport.receive().await.unwrap_err() diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..e1d0e18 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,51 @@ +# 测试入口 + +测试按验证目的维护,不按开发阶段累积副本。当前命令见 [scripts](../scripts/README.md)。 + +文件名与用例 ID 使用协议或验证用途:ech_config、encryption_wire、 +protocol_security、protocol_integration;事件标签与报告采用同一套名称。 + +| 层次 | 保留内容 | 入口 | +| --- | --- | --- | +| 日常 | 严格配置、feature、协议/TLS 内存 IO、局部上限、取消、FFI 边界和确定性回归 | check core --profile debug/release | +| 构建 | 精简 feature、生产 feature、全目标编译 | check core --profile features | +| 工具 | 子进程/容器清理、下载、原始证据完整性、平台产物 | scripts/tests | +| 网络 | 公开消费者、真实原生认证/传输/UDP/上游和资源 | protocol-interop --suite … | +| 发布候选 | 有序两跳、故障/重建/长测、平台产物/ABI | integration suite 与平台构建 | + +所有服务端遵守[隔离规则](../docs/testing-isolation.md),包括原站和对照客户端入口。 +默认测试命令不执行历史宿主 listener。全目标仅 --no-run;ignored 不算通过。 +物理设备和正式安装不由本地测试推导。 + +## 必要回归与独立输入 + +- h2_stream_regression:完整 END_STREAM 后 RST 不丢响应,未完成响应仍报错。 +- stream_foundations 与 sing-mux 单元回归:延迟响应已就绪仍遵守原建链期限, + 先前 Pending 或首次延迟读取均不能绕过;确认建立后允许继续读取。 +- stream_shutdown:gRPC/legacy H2/池化 gRPC 和 XHTTP H1/H2 在有界、带缓冲 IO 上先送完 + 再关闭;Stop 可取消待写,XHTTP 关闭有一秒上限。 +- xhttp_h3_shutdown:纯内存 QUIC 背压下的上传完成屏障,不用下载尾包证明关闭。 + 覆盖延迟握手;测试对端显式取消并 join 后台任务,不把随 RTT 变化的 QUIC 关闭保留期 + 当作客户端关闭期限。上传完整性、一秒关闭上限和 Stop 验证保持独立。 +- shadowsocks_backpressure:三算法 Pending 重试长度、读先于写时的 codec 刷新,以及 + 官方服务端对空首包零 padding 的确定性拒绝;不修改官方库,不把 codec 夹具当作互通。 +- hysteria2_packet_ids:完成后重用 16 位分片 ID,不误丢后续业务包。 +- security_capabilities:公开配置经真实 SecurityClient 在主/下载腿产生实际混合 share; + 不再重复测试 fork 的纯 API 准入。 +- fingerprints/mihomo-selected-v1.json:官方独立 ClientHello golden,不从待测实现重新生成期望; + [指纹验证](fingerprints/README.md)分别维护独立基线、内存报文与容器互通。 +- protocols/encryption-crypto.json 及 Go 生成器:独立密码向量;许可证和来源必须保留。 +- protocols/limits.json:实际常量、边界与越界行为;不另建一套产品配置。 + +## 保留而未自动执行的 fixture + +anytls_interop、xray_interop、mihomo_interop 仍含会话复用计数、双向 HTTP/SOCKS5、 +CONNECT 预读/Upgrade 等独有断言。保留这些 Rust 消费者用于迁移,不能把“其他 echo 通过” +当作已覆盖而删除。旧宿主启动入口不再开放,迁移前只编译,不计为当前容器验收。 +协议容器消费者已覆盖其共享数据、安全、组快照和 Stop 路径。 + +其他 tests 中含宿主 listener 的旧 fixture 同样不在 core 白名单;新增网络断言放容器 +suite,或在能真实覆盖行为时改为纯内存 IO。不要把宿主服务端改称 mock 绕过规则。 + +阶段实验、重复规划表和只测试 BLOCKED 文案的诊断已删除;原始记录在 Git 历史。 +必要负例、资源归零/静默检查和尚未替代的独有断言不按行数裁剪。 diff --git a/tests/anytls_interop.rs b/tests/anytls_interop.rs index 1da6fde..7da095a 100644 --- a/tests/anytls_interop.rs +++ b/tests/anytls_interop.rs @@ -1,8 +1,6 @@ -//! Opt-in interoperability coverage against the local `anytls-go` reference -//! server. -//! -//! Run with `bash tests/run_anytls_interop.sh`. The test is ignored by default -//! and only compiles with `interop-test`. Its certificate verifier intentionally +//! Legacy AnyTLS session-count assertions retained for container migration. +//! Compile only until all server roles are isolated; see tests/README.md. +//! This fixture only compiles with `interop-test`. Its verifier intentionally //! trusts the reference server's dynamically generated self-signed certificate, //! but still verifies the TLS handshake signature. This verifier lives only in //! this integration-test binary and cannot enter the VCore release library. @@ -323,7 +321,7 @@ async fn run_interop() -> io::Result<()> { .map_err(|_| { io::Error::new( io::ErrorKind::InvalidInput, - "ANYTLS_INTEROP_ADDRESS is missing; run tests/run_anytls_interop.sh", + "ANYTLS_INTEROP_ADDRESS is missing; legacy fixture; see tests/README.md", ) })? .parse::() @@ -331,7 +329,7 @@ async fn run_interop() -> io::Result<()> { let password = std::env::var("ANYTLS_INTEROP_PASSWORD").map_err(|_| { io::Error::new( io::ErrorKind::InvalidInput, - "ANYTLS_INTEROP_PASSWORD is missing; run tests/run_anytls_interop.sh", + "ANYTLS_INTEROP_PASSWORD is missing; legacy fixture; see tests/README.md", ) })?; @@ -389,7 +387,7 @@ async fn run_interop() -> io::Result<()> { } #[tokio::test] -#[ignore = "requires the local anytls-go reference server; run tests/run_anytls_interop.sh"] +#[ignore = "requires the local anytls-go reference server; legacy fixture; see tests/README.md"] async fn anytls_go_tcp_uot_v2_reuse_and_shutdown() { tokio::time::timeout(Duration::from_secs(45), run_interop()) .await diff --git a/tests/datagram_foundations.rs b/tests/datagram_foundations.rs new file mode 100644 index 0000000..d956792 --- /dev/null +++ b/tests/datagram_foundations.rs @@ -0,0 +1,257 @@ +use vcore::{dispatch::DatagramBudget, packet::IpVersion}; + +#[cfg(feature = "outbound-shadowsocks")] +#[tokio::test] +async fn shadowsocks_budget_accounts_for_cipher_headers_and_identity_chain() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-DATAGRAM", + "shadowsocks_budget_accounts_for_cipher_headers_and_identity_chain", + ); + use base64::{Engine as _, engine::general_purpose::STANDARD}; + use bytes::Bytes; + use vcore::{ + config::{ShadowsocksCipher, ShadowsocksOutboundConfig}, + dialer::{Dialer, ResolvedEndpoint}, + outbound::{ + DatagramRequest, EstablishContext, OutboundConnector, ShadowsocksOutbound, UpstreamPath, + }, + session::{Datagram, DatagramSession, Destination, InboundKind}, + }; + for (cipher, chain, tx, rx, wire_bytes) in [ + (ShadowsocksCipher::Aes128Gcm, false, 65457, 65449, 67), + (ShadowsocksCipher::Aes256Gcm, false, 65457, 65449, 67), + (ShadowsocksCipher::Chacha20Poly1305, false, 65433, 65425, 91), + (ShadowsocksCipher::Aes128Gcm, true, 65441, 65449, 83), + ] { + let server = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let address = server.local_addr().unwrap(); + let mut password = STANDARD.encode(vec![7; cipher.key_len()]); + if chain { + password = format!("{}:{password}", STANDARD.encode(vec![8; cipher.key_len()])); + } + let outbound = ShadowsocksOutbound::new_with_path( + &ShadowsocksOutboundConfig { + address: "127.0.0.1".into(), + port: address.port(), + cipher, + password, + }, + UpstreamPath::direct( + ResolvedEndpoint { + logical_host: "127.0.0.1".into(), + port: address.port(), + addresses: vec![address], + }, + Dialer::default(), + ), + ) + .unwrap(); + let request = DatagramRequest::new(DatagramSession::new( + InboundKind::InternalMeasure, + "127.0.0.1:0".parse().unwrap(), + )); + let mut io = outbound + .open_datagram(request, &EstablishContext::default()) + .await + .unwrap(); + let destination = Destination::Ip("1.2.3.4:53".parse().unwrap()); + assert_eq!(io.payload_budget(&destination), DatagramBudget::new(tx, rx)); + io.send(Datagram { + remote: destination, + payload: Bytes::from_static(b"12345678901234567"), + sniffed_domain: None, + }) + .await + .unwrap(); + let mut packet = [0; 128]; + assert_eq!( + tokio::time::timeout( + std::time::Duration::from_secs(1), + server.recv_from(&mut packet) + ) + .await + .unwrap() + .unwrap() + .0, + wire_bytes + ); + io.close().await.unwrap(); + } +} + +#[cfg(feature = "outbound-socks5")] +#[tokio::test] +async fn socks5_budget_retains_distinct_payload_and_envelope_limits() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-DATAGRAM", + "socks5_budget_retains_distinct_payload_and_envelope_limits", + ); + use bytes::Bytes; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use vcore::{ + config::Socks5OutboundConfig, + dialer::{Dialer, ResolvedEndpoint}, + outbound::{DatagramRequest, EstablishContext, OutboundConnector, Socks5Outbound}, + session::{Datagram, DatagramSession, Destination, InboundKind}, + }; + tokio::time::timeout(std::time::Duration::from_secs(2), async { + let server = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = server.local_addr().unwrap(); + let udp = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let relay = udp.local_addr().unwrap(); + let peer = tokio::spawn(async move { + let (mut control, _) = server.accept().await.unwrap(); + let mut greeting = [0; 3]; + control.read_exact(&mut greeting).await.unwrap(); + assert_eq!(greeting, [5, 1, 0]); + control.write_all(&[5, 0]).await.unwrap(); + let mut request = [0; 10]; + control.read_exact(&mut request).await.unwrap(); + assert_eq!(&request[..4], &[5, 3, 0, 1]); + let mut response = vec![5, 0, 0, 1, 127, 0, 0, 1]; + response.extend_from_slice(&relay.port().to_be_bytes()); + control.write_all(&response).await.unwrap(); + let mut packet = [0; 64]; + let (length, source) = udp.recv_from(&mut packet).await.unwrap(); + assert_eq!( + &packet[..length], + &[0, 0, 0, 1, 1, 2, 3, 4, 0, 53, b'a', b'b', b'c'] + ); + udp.send_to(&packet[..length], source).await.unwrap(); + assert_eq!(control.read(&mut [0]).await.unwrap(), 0); + }); + let outbound = Socks5Outbound::new( + &Socks5OutboundConfig { + address: "127.0.0.1".into(), + port: endpoint.port(), + username: None, + password: None, + }, + ResolvedEndpoint { + logical_host: "127.0.0.1".into(), + port: endpoint.port(), + addresses: vec![endpoint], + }, + Dialer::default(), + ) + .unwrap(); + let request = DatagramRequest::new(DatagramSession::new( + InboundKind::Socks5, + "127.0.0.1:0".parse().unwrap(), + )) + .with_budget(DatagramBudget::new(3, 3)); + let mut io = outbound + .open_datagram(request, &EstablishContext::default()) + .await + .unwrap(); + let destination = Destination::Ip("1.2.3.4:53".parse().unwrap()); + assert_eq!(io.payload_budget(&destination), DatagramBudget::new(3, 3)); + assert!( + io.send(Datagram { + remote: destination.clone(), + payload: Bytes::from_static(b"abcd"), + sniffed_domain: None + }) + .await + .is_err() + ); + io.send(Datagram { + remote: destination, + payload: Bytes::from_static(b"abc"), + sniffed_domain: None, + }) + .await + .unwrap(); + assert_eq!(io.receive().await.unwrap().payload, "abc"); + io.close().await.unwrap(); + drop(io); + peer.await.unwrap(); + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn direct_budget_rejects_large_sends_and_drops_oversize_responses() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-DATAGRAM", + "direct_budget_rejects_large_sends_and_drops_oversize_responses", + ); + use bytes::Bytes; + use vcore::{ + dialer::Dialer, + outbound::{DatagramRequest, DirectOutbound, EstablishContext, OutboundConnector}, + session::{Datagram, DatagramSession, Destination, InboundKind}, + }; + tokio::time::timeout(std::time::Duration::from_secs(2), async { + let server = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let peer = Destination::Ip(server.local_addr().unwrap()); + let request = DatagramRequest::new(DatagramSession::new( + InboundKind::Socks5, + "127.0.0.1:0".parse().unwrap(), + )) + .with_budget(DatagramBudget::new(3, 2)); + let mut io = DirectOutbound::new(Dialer::default()) + .open_datagram(request, &EstablishContext::default()) + .await + .unwrap(); + assert_eq!(io.payload_budget(&peer), DatagramBudget::new(3, 2)); + let packet = |payload: &'static [u8]| Datagram { + remote: peer.clone(), + payload: Bytes::from_static(payload), + sniffed_domain: None, + }; + assert!(io.send(packet(b"1234")).await.is_err()); + io.send(packet(b"123")).await.unwrap(); + let mut bytes = [0; 16]; + let (length, source) = server.recv_from(&mut bytes).await.unwrap(); + assert_eq!(&bytes[..length], b"123"); + server.send_to(b"bad", source).await.unwrap(); + server.send_to(b"ok", source).await.unwrap(); + assert_eq!(io.receive().await.unwrap().payload, "ok"); + io.close().await.unwrap(); + io.close().await.unwrap(); + assert!(io.send(packet(b"1")).await.is_err()); + }) + .await + .unwrap(); +} + +#[test] +fn layered_directional_budgets_account_for_ip_headers_and_exact_minima() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-DATAGRAM", + "layered_directional_budgets_account_for_ip_headers_and_exact_minima", + ); + let ipv4 = DatagramBudget::from_path_mtu(1500, IpVersion::V4).unwrap(); + let ipv6 = DatagramBudget::from_path_mtu(1500, IpVersion::V6).unwrap(); + assert_eq!((ipv4.transmit(), ipv4.receive()), (1472, 1472)); + assert_eq!((ipv6.transmit(), ipv6.receive()), (1452, 1452)); + assert!(DatagramBudget::from_path_mtu(47, IpVersion::V6).is_err()); + let layered = ipv6 + .intersect(DatagramBudget::new(1420, 1400)) + .subtract_overhead(20, 200); + assert_eq!((layered.transmit(), layered.receive()), (1400, 1200)); + assert_eq!(layered.quic_payload_limit().unwrap(), 1200); + assert!( + layered + .subtract_overhead(0, 1) + .quic_payload_limit() + .is_err() + ); + assert!( + DatagramBudget::new(1199, 1400) + .quic_payload_limit() + .is_err() + ); + assert_eq!( + DatagramBudget::new(12, 10) + .subtract_overhead(13, usize::MAX) + .receive(), + 0 + ); +} diff --git a/tests/ech_config.rs b/tests/ech_config.rs new file mode 100644 index 0000000..34800f2 --- /dev/null +++ b/tests/ech_config.rs @@ -0,0 +1,256 @@ +#![cfg(feature = "outbound-vless")] + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde_json::json; +use vcore::{ + config::{Config, ProxyProtocol, SecurityConfig, VlessOutboundConfig}, + security::SecurityClient, +}; + +fn ech_list() -> Vec { + let mut content = vec![7, 0, 32, 0, 32]; + content.extend_from_slice(&[7; 32]); + content.extend_from_slice(&[0, 4, 0, 1, 0, 1, 0, 14]); + content.extend_from_slice(b"public.invalid"); + content.extend_from_slice(&[0, 0]); + let mut list = ((content.len() + 4) as u16).to_be_bytes().to_vec(); + list.extend_from_slice(&[0xfe, 0x0d]); + list.extend_from_slice(&(content.len() as u16).to_be_bytes()); + list.extend_from_slice(&content); + list +} + +fn node() -> serde_json::Value { + json!({"name":"edge", "type":"vless", "server":"example.invalid", "port":443, + "uuid":"07070707-0707-0707-0707-070707070707", "tls":true, + "ech-opts":{"enable":true, "config":STANDARD.encode(ech_list())}}) +} + +fn parse(node: serde_json::Value) -> vcore::Result { + let config = Config::parse_yaml( + &serde_json::to_vec(&json!({ + "socks-port":1080, "proxies":[node], "rules":["MATCH,edge"] + })) + .unwrap(), + )?; + let ProxyProtocol::Vless(node) = config.proxies.into_iter().next().unwrap().protocol else { + panic!() + }; + Ok(node) +} + +#[test] +fn static_ech_builds_both_tls_backends_without_dns_or_io() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "static_ech_builds_both_tls_backends_without_dns_or_io", + ); + for profile in ["none", "chrome", "chrome120", "firefox", "safari"] { + let mut input = node(); + input["client-fingerprint"] = json!(profile); + let config = parse(input).expect("static ECH must normalize without a DNS entry point"); + SecurityClient::from_proxy(&config).expect("static ECH must build a real TLS client"); + assert!(!format!("{config:?}").contains(&STANDARD.encode(ech_list()))); + } +} + +#[test] +fn static_ech_is_strict_and_never_enables_dynamic_lookup() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "static_ech_is_strict_and_never_enables_dynamic_lookup", + ); + for ech in [ + json!(null), + json!([]), + json!("private-config"), + json!({"enable":null}), + json!({"enable":"true"}), + json!({"enable":true}), + json!({"enable":true,"config":""}), + json!({"enable":true,"config":42}), + json!({"enable":true,"config":"private-config"}), + json!({"enable":true,"config":STANDARD.encode(ech_list()),"query-server-name":"private.invalid"}), + json!({"enable":false,"config":STANDARD.encode(ech_list())}), + ] { + let mut input = node(); + input["ech-opts"] = ech; + let error = parse(input).unwrap_err().to_string(); + assert!(!error.contains("private-config")); + assert!(!error.contains("private.invalid")); + let mut input = node(); + input["network"] = json!("xhttp"); + input["xhttp-opts"] = json!({"mode":"stream-up", "download-settings":{ + "ech-opts":{"enable":true,"query-server-name":"private.invalid"} + }}); + assert!(parse(input).is_err()); + } + for ech in [json!({}), json!({"enable":false}), json!({"config":""})] { + let mut input = node(); + input["ech-opts"] = ech; + let SecurityConfig::Tls(tls) = parse(input).unwrap().security else { + panic!() + }; + assert!(tls.ech.is_none()); + } +} + +#[test] +fn unsupported_ech_configs_fail_at_parse_before_a_client_can_emit_inner_sni() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "unsupported_ech_configs_fail_at_parse_before_a_client_can_emit_inner_sni", + ); + let valid = ech_list(); + let mut invalid = vec![vec![], vec![0, 0], vec![0, 1, 0], vec![0; 65_538]]; + for length in 0..valid.len() { + invalid.push(valid[..length].to_vec()); + } + let mut trailing = valid.clone(); + trailing.push(0); + invalid.push(trailing); + for (index, value) in [ + (2, 0), + (8, 0x21), + (46, 2), + (48, 4), + (47, 0xff), + (50, b'.'), + (64, b'.'), + ] { + let mut bytes = valid.clone(); + bytes[index] = value; + invalid.push(bytes); + } + for bytes in invalid { + let mut input = node(); + input["ech-opts"]["config"] = json!(STANDARD.encode(bytes)); + assert!( + parse(input).is_err(), + "unsupported or malformed config must fail before IO" + ); + } +} + +#[test] +fn ech_selection_skips_unknown_or_mandatory_configs_and_keeps_exact_supported_bytes() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "ech_selection_skips_unknown_or_mandatory_configs_and_keeps_exact_supported_bytes", + ); + use vcore::config::StaticEchConfig; + let valid = ech_list(); + let expected = StaticEchConfig::from_config_list(&valid).unwrap(); + let mut unknown_version = valid[2..].to_vec(); + unknown_version[..2].copy_from_slice(&[0xfe, 0x0c]); + let mut mandatory = valid[2..].to_vec(); + let length = mandatory.len(); + mandatory[length - 2..].copy_from_slice(&4_u16.to_be_bytes()); + mandatory.extend_from_slice(&[0x80, 0, 0, 0]); + let content_len = (mandatory.len() - 4) as u16; + mandatory[2..4].copy_from_slice(&content_len.to_be_bytes()); + let mut duplicate = mandatory.clone(); + duplicate[length..length + 2].copy_from_slice(&[0, 1]); + duplicate.extend_from_slice(&[0, 1, 0, 0]); + duplicate[length - 2..length].copy_from_slice(&8_u16.to_be_bytes()); + let content_len = (duplicate.len() - 4) as u16; + duplicate[2..4].copy_from_slice(&content_len.to_be_bytes()); + for unsupported in [unknown_version, mandatory, duplicate] { + let mut alone = (unsupported.len() as u16).to_be_bytes().to_vec(); + alone.extend_from_slice(&unsupported); + assert!(StaticEchConfig::from_config_list(&alone).is_err()); + let mut list = ((unsupported.len() + valid.len() - 2) as u16) + .to_be_bytes() + .to_vec(); + list.extend_from_slice(&unsupported); + list.extend_from_slice(&valid[2..]); + assert_eq!(StaticEchConfig::from_config_list(&list).unwrap(), expected); + for profile in ["none", "chrome", "firefox", "safari"] { + let mut input = node(); + input["client-fingerprint"] = json!(profile); + input["ech-opts"]["config"] = json!(STANDARD.encode(&list)); + SecurityClient::from_proxy(&parse(input).unwrap()).unwrap(); + } + } +} + +#[test] +fn download_ech_inherits_replaces_or_clears_as_a_whole() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "download_ech_inherits_replaces_or_clears_as_a_whole", + ); + let mut input = node(); + input["network"] = json!("xhttp"); + input["xhttp-opts"] = json!({"mode":"stream-up", "download-settings":{}}); + let node = parse(input.clone()).unwrap(); + assert_eq!(node.security, node.download().unwrap().security); + let mut different = ech_list(); + different[6] = 9; // config ID + input["xhttp-opts"]["download-settings"]["ech-opts"] = + json!({"enable":true, "config":STANDARD.encode(different)}); + let node = parse(input.clone()).unwrap(); + assert_ne!(node.security, node.download().unwrap().security); + SecurityClient::from_security(&node.download().unwrap().security).unwrap(); + input["xhttp-opts"]["download-settings"]["ech-opts"] = json!({"enable":true}); + assert!( + parse(input.clone()).is_err(), + "replacement must not inherit the main key" + ); + for clear in [json!({}), json!({"enable":false})] { + input["xhttp-opts"]["download-settings"]["ech-opts"] = clear; + let node = parse(input.clone()).unwrap(); + let SecurityConfig::Tls(tls) = &node.download().unwrap().security else { + panic!() + }; + assert!(tls.ech.is_none()); + } +} + +#[test] +fn ech_is_standard_tls_only_with_explicit_download_clear_and_dns_inner_name() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "ech_is_standard_tls_only_with_explicit_download_clear_and_dns_inner_name", + ); + for extra in [ + json!({"tls":false}), + json!({"server":"127.0.0.1"}), + json!({"flow":"xtls-rprx-vision"}), + json!({"reality-opts":{"public-key":"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc"}}), + json!({"jls-opts":{"username":"user","password":"password"}}), + ] { + let mut input = node(); + input + .as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + assert!(parse(input).is_err()); + } + for download in [ + json!({"tls":false}), + json!({"servername":"127.0.0.1"}), + json!({"reality-opts":{"public-key":"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc"}}), + json!({"jls-opts":{"username":"user","password":"password"}}), + ] { + let mut input = node(); + input["network"] = json!("xhttp"); + input["xhttp-opts"] = json!({"mode":"stream-up","download-settings":download}); + assert!(parse(input.clone()).is_err()); + input["xhttp-opts"]["download-settings"]["ech-opts"] = json!({}); + parse(input).unwrap(); + } + let mut input = node(); + input["network"] = json!("xhttp"); + input["alpn"] = json!(["h3"]); + input["xhttp-opts"] = json!({"mode":"stream-up","download-settings":{}}); + let node = parse(input).unwrap(); + SecurityClient::from_proxy(&node).unwrap(); + SecurityClient::from_security(&node.download().unwrap().security).unwrap(); +} diff --git a/tests/ech_tls.rs b/tests/ech_tls.rs new file mode 100644 index 0000000..dc464ff --- /dev/null +++ b/tests/ech_tls.rs @@ -0,0 +1,392 @@ +#![cfg(feature = "outbound-vless")] + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use boring::{ + hpke::HpkeKey, + pkey::PKey, + ssl::{SslAcceptor, SslEchKeys, SslMethod, SslVersion}, + x509::X509, +}; +use foreign_types::ForeignType; +use hpke::{Kem as _, Serializable}; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use vcore::{ + config::{Config, ProxyProtocol}, + security::SecurityClient, +}; + +fn material(aead: u16) -> (Vec, HpkeKey) { + use rand::SeedableRng; + let mut rng = rand::rngs::StdRng::try_from_rng(&mut rand::rngs::SysRng).unwrap(); + let (private, public) = hpke::kem::X25519HkdfSha256::gen_keypair_with_rng(&mut rng); + let mut content = vec![7, 0, 32, 0, 32]; + content.extend_from_slice(&public.to_bytes()); + content.extend_from_slice(&[0, 4, 0, 1]); + content.extend_from_slice(&aead.to_be_bytes()); + content.extend_from_slice(&[0, 14]); + content.extend_from_slice(b"public.invalid"); + content.extend_from_slice(&[0, 0]); + let mut config = vec![0xfe, 0x0d]; + config.extend_from_slice(&(content.len() as u16).to_be_bytes()); + config.extend_from_slice(&content); + // Only the test peer needs an X25519 ECH server key; the stable safe wrapper + // exposes a P-256 constructor. Use existing official native FFI, not a server + // implementation or a production dependency on private library structures. + let key = unsafe { + let raw = boring_sys::EVP_HPKE_KEY_new(); + assert!(!raw.is_null()); + let key = HpkeKey::from_ptr(raw); + let bytes = private.to_bytes(); + assert_eq!( + boring_sys::EVP_HPKE_KEY_init( + raw, + boring_sys::EVP_hpke_x25519_hkdf_sha256(), + bytes.as_ptr(), + bytes.len() + ), + 1 + ); + key + }; + (config, key) +} + +fn client(profile: &str, list: &[u8], pin: &str) -> SecurityClient { + client_with(profile, list, pin, json!({})) +} + +fn client_with(profile: &str, list: &[u8], pin: &str, extra: serde_json::Value) -> SecurityClient { + let mut node = json!({"name":"edge", "type":"vless", "server":"inner.invalid", "port":443, + "uuid":"07070707-0707-0707-0707-070707070707", "tls":true, + "client-fingerprint":profile, "fingerprint":pin, + "ech-opts":{"enable":true, "config":STANDARD.encode(list)}}); + node.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + let config = Config::parse_yaml( + &serde_json::to_vec(&json!({ + "socks-port":1080, + "proxies":[node], + "rules":["MATCH,edge"] + })) + .unwrap(), + ) + .unwrap(); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + panic!() + }; + SecurityClient::from_proxy(node).unwrap() +} + +#[tokio::test] +async fn rejected_or_wrong_key_ech_never_reaches_application_data_even_with_a_matching_pin() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "rejected_or_wrong_key_ech_never_reaches_application_data_even_with_a_matching_pin", + ); + let rcgen::CertifiedKey { cert, signing_key } = + rcgen::generate_simple_self_signed(vec!["inner.invalid".into(), "public.invalid".into()]) + .unwrap(); + let pin: String = Sha256::digest(cert.der()) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + let (config, _) = material(1); + let mut list = (config.len() as u16).to_be_bytes().to_vec(); + list.extend_from_slice(&config); + for has_wrong_key in [false, true] { + let mut server = SslAcceptor::mozilla_intermediate_v5(SslMethod::tls()).unwrap(); + server + .set_certificate(&X509::from_der(cert.der()).unwrap()) + .unwrap(); + server + .set_private_key(&PKey::private_key_from_der(&signing_key.serialize_der()).unwrap()) + .unwrap(); + if has_wrong_key { + let (wrong, key) = material(1); + let mut keys = SslEchKeys::builder().unwrap(); + keys.add_key(true, &wrong, key).unwrap(); + server.set_ech_keys(&keys.build()).unwrap(); + } + let server = server.build(); + for profile in ["none", "chrome", "chrome120", "firefox", "safari"] { + let client = client(profile, &list, &pin); + let (io, peer) = tokio::io::duplex(65_536); + tokio::time::timeout(Duration::from_secs(3), async { + let native = async { + if let Ok(mut tls) = tokio_boring::accept(&server, peer).await { + let mut byte = [0]; + assert!(!matches!(tls.read(&mut byte).await, Ok(n) if n != 0)); + } + }; + let consumer = async { + assert!( + client.connect(Box::new(io)).await.is_err(), + "{profile}: rejected ECH must fail closed" + ); + }; + tokio::join!(native, consumer); + }) + .await + .unwrap(); + } + } +} + +fn outer_sni(hello: &[u8]) -> &[u8] { + assert_eq!(hello[0], 1); // handshake ClientHello, record header removed + let mut offset = 38; + offset += 1 + usize::from(hello[offset]); // session ID + offset += 2 + usize::from(u16::from_be_bytes([hello[offset], hello[offset + 1]])); + offset += 1 + usize::from(hello[offset]); // compression + let len = usize::from(u16::from_be_bytes([hello[offset], hello[offset + 1]])); + offset += 2; + assert_eq!(offset + len, hello.len()); + let mut sni = None; + let mut encrypted = false; + while offset < hello.len() { + let kind = u16::from_be_bytes([hello[offset], hello[offset + 1]]); + let len = usize::from(u16::from_be_bytes([hello[offset + 2], hello[offset + 3]])); + let data = &hello[offset + 4..offset + 4 + len]; + if kind == 0 { + assert_eq!(data[2], 0); + assert_eq!( + usize::from(u16::from_be_bytes([data[3], data[4]])), + data.len() - 5 + ); + sni = Some(&data[5..]); + } + if kind == 0xfe0d { + encrypted = true; + } + if kind == 43 { + assert_eq!(usize::from(data[0]), data.len() - 1); + let versions: Vec<_> = data[1..] + .as_chunks::<2>() + .0 + .iter() + .filter(|v| { + !(v[0] == v[1] && v[0] & 0x0f == 0x0a) // GREASE is not a TLS version. + }) + .collect(); + assert_eq!(versions, [b"\x03\x04"], "ECH must offer TLS 1.3 only"); + } + assert_ne!(kind, 41, "static ECH must not offer a cached PSK"); + offset += 4 + len; + } + assert!(encrypted); + sni.unwrap() +} + +#[tokio::test] +async fn client_identity_is_sent_only_after_ech_acceptance() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "client_identity_is_sent_only_after_ech_acceptance", + ); + use boring::ssl::SslVerifyMode; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + let rcgen::CertifiedKey { cert, signing_key } = + rcgen::generate_simple_self_signed(vec!["inner.invalid".into(), "public.invalid".into()]) + .unwrap(); + let identity = rcgen::generate_simple_self_signed(vec!["client.invalid".into()]).unwrap(); + let pin: String = Sha256::digest(cert.der()) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + for accepted in [true, false] { + for profile in ["none", "chrome", "chrome120", "firefox", "safari"] { + let (config, key) = material(1); + let mut list = (config.len() as u16).to_be_bytes().to_vec(); + list.extend_from_slice(&config); + let mut keys = SslEchKeys::builder().unwrap(); + keys.add_key(true, &config, key).unwrap(); + let observed = Arc::new(AtomicUsize::new(0)); + let verifier = observed.clone(); + let mut server = SslAcceptor::mozilla_intermediate_v5(SslMethod::tls()).unwrap(); + server + .set_certificate(&X509::from_der(cert.der()).unwrap()) + .unwrap(); + server + .set_private_key(&PKey::private_key_from_der(&signing_key.serialize_der()).unwrap()) + .unwrap(); + server.set_verify_callback(SslVerifyMode::PEER, move |_, chain| { + if chain.current_cert().is_some() { + verifier.fetch_add(1, Ordering::Relaxed); + } + true + }); + if accepted { + server.set_ech_keys(&keys.build()).unwrap(); + } + let server = server.build(); + let client = client_with( + profile, + &list, + &pin, + json!({ + "certificate":String::from_utf8(X509::from_der(identity.cert.der()).unwrap().to_pem().unwrap()).unwrap(), + "private-key":String::from_utf8(PKey::private_key_from_der(&identity.signing_key.serialize_der()).unwrap().private_key_to_pem_pkcs8().unwrap()).unwrap() + }), + ); + let (io, peer) = tokio::io::duplex(65_536); + tokio::time::timeout(Duration::from_secs(3), async { + let native = async { + let outcome = tokio_boring::accept(&server, peer).await; + if accepted { + let mut tls = outcome.unwrap(); + assert!(tls.ssl().ech_accepted()); + assert!(tls.ssl().peer_certificate().is_some()); + tls.write_all(b"ok").await.unwrap(); + tls.flush().await.unwrap(); + } else if let Ok(tls) = outcome { + assert!(tls.ssl().peer_certificate().is_none()); + } + }; + let consumer = async { + let outcome = client.connect(Box::new(io)).await; + if accepted { + let mut data = [0; 2]; + outcome.unwrap().read_exact(&mut data).await.unwrap(); + assert_eq!(&data, b"ok"); + } else { + assert!(outcome.is_err()); + } + }; + tokio::join!(native, consumer); + }) + .await + .unwrap(); + assert_eq!(observed.load(Ordering::Relaxed) > 0, accepted, "{profile}"); + } + } +} + +#[tokio::test] +async fn cancelled_ech_emits_only_public_sni_and_releases_io_for_every_backend() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "cancelled_ech_emits_only_public_sni_and_releases_io_for_every_backend", + ); + let (config, _) = material(1); + let mut list = (config.len() as u16).to_be_bytes().to_vec(); + list.extend_from_slice(&config); + for profile in ["none", "chrome", "chrome120", "firefox", "safari"] { + let client = client(profile, &list, &"07".repeat(32)); + let mut previous = None; + for _ in 0..20 { + let (io, mut peer) = tokio::io::duplex(65_536); + let cloned = client.clone(); + let task = tokio::spawn(async move { cloned.connect(Box::new(io)).await }); + let mut header = [0; 5]; + tokio::time::timeout(Duration::from_secs(2), peer.read_exact(&mut header)) + .await + .unwrap() + .unwrap(); + assert_eq!(header[0], 22); + let mut hello = vec![0; usize::from(u16::from_be_bytes([header[3], header[4]]))]; + peer.read_exact(&mut hello).await.unwrap(); + assert_eq!(outer_sni(&hello), b"public.invalid"); + let random: [u8; 32] = hello[6..38].try_into().unwrap(); + assert_ne!(previous, Some(random)); + previous = Some(random); + task.abort(); + assert!(matches!(task.await, Err(error) if error.is_cancelled())); + let mut tail = Vec::new(); + tokio::time::timeout(Duration::from_secs(1), peer.read_to_end(&mut tail)) + .await + .unwrap() + .unwrap(); + assert!( + tail.len() <= 6, + "only an optional compatibility CCS may follow ClientHello" + ); + } + } +} + +#[tokio::test] +async fn each_backend_and_hpke_suite_requires_real_ech_acceptance_before_data() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "each_backend_and_hpke_suite_requires_real_ech_acceptance_before_data", + ); + let rcgen::CertifiedKey { cert, signing_key } = + rcgen::generate_simple_self_signed(vec!["inner.invalid".into(), "public.invalid".into()]) + .unwrap(); + let pin: String = Sha256::digest(cert.der()) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + for aead in [1, 2, 3] { + let (config, key) = material(aead); + let mut list = (config.len() as u16).to_be_bytes().to_vec(); + list.extend_from_slice(&config); + let mut keys = SslEchKeys::builder().unwrap(); + keys.add_key(true, &config, key).unwrap(); + let mut server = SslAcceptor::mozilla_intermediate_v5(SslMethod::tls()).unwrap(); + server + .set_certificate(&X509::from_der(cert.der()).unwrap()) + .unwrap(); + server + .set_private_key(&PKey::private_key_from_der(&signing_key.serialize_der()).unwrap()) + .unwrap(); + server + .set_min_proto_version(Some(SslVersion::TLS1_3)) + .unwrap(); + server.set_ech_keys(&keys.build()).unwrap(); + let server = server.build(); + for profile in ["none", "chrome", "chrome120", "firefox", "safari"] { + let client = client(profile, &list, &pin); + for _round in 0..3 { + let (io, peer) = tokio::io::duplex(65_536); + tokio::time::timeout(Duration::from_secs(3), async { + let native = async { + let mut tls = tokio_boring::accept(&server, peer).await.unwrap(); + assert!( + !tls.ssl().session_reused(), + "static ECH must never resume TLS" + ); + assert!( + tls.ssl().ech_accepted(), + "{profile}/{aead}: peer did not accept ECH" + ); + assert_eq!( + tls.ssl().servername(boring::ssl::NameType::HOST_NAME), + Some("inner.invalid") + ); + let mut data = [0; 7]; + tls.read_exact(&mut data).await.unwrap(); + assert_eq!(&data, b"payload"); + tls.write_all(b"reply").await.unwrap(); + tls.flush().await.unwrap(); + }; + let consumer = async { + let mut tls = client + .connect(Box::new(io)) + .await + .unwrap_or_else(|e| panic!("{profile}/{aead}: {e}")); + tls.write_all(b"payload").await.unwrap(); + tls.flush().await.unwrap(); + let mut data = [0; 5]; + tls.read_exact(&mut data).await.unwrap(); + assert_eq!(&data, b"reply"); + }; + tokio::join!(native, consumer); + }) + .await + .unwrap(); + } + } + } +} diff --git a/tests/encryption_config.rs b/tests/encryption_config.rs new file mode 100644 index 0000000..35ed48e --- /dev/null +++ b/tests/encryption_config.rs @@ -0,0 +1,71 @@ +#![cfg(feature = "outbound-vless")] +use base64::Engine as _; +use serde_json::json; +use vcore::config::{Config, ProxyProtocol, VlessEncryption}; + +#[test] +fn public_encryption_accepts_all_six_modes_without_exposing_key_material() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "public_encryption_accepts_all_six_modes_without_exposing_key_material", + ); + let key = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([9; 32]); + for appearance in ["native", "xorpub", "random"] { + for rtt in ["1rtt", "0rtt"] { + let config=Config::parse_yaml(&serde_json::to_vec(&json!({ + "socks-port":1080, + "proxies":[{"name":"edge", "type":"vless", "server":"example.invalid", "port":443, + "uuid":"07070707-0707-0707-0707-070707070707", + "encryption":format!("mlkem768x25519plus.{appearance}.{rtt}.{key}.100-35-35")}], + "rules":["MATCH,edge"] + })).unwrap()).expect("valid Encryption configuration must normalize before IO"); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + panic!() + }; + assert!(!matches!(&node.encryption, VlessEncryption::None)); + assert!(!format!("{:?}", node.encryption).contains(&key)); + assert!(!format!("{config:?}").contains(&key)); + } + } +} + +#[test] +fn vision_encryption_is_independent_of_outer_tls_but_keeps_transport_and_udp_limits() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "vision_encryption_is_independent_of_outer_tls_but_keeps_transport_and_udp_limits", + ); + let key = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([9; 32]); + for tls in [false, true] { + for appearance in ["native", "xorpub", "random"] { + let node = json!({"name":"edge", "type":"vless", "server":"example.invalid", "port":443, + "uuid":"07070707-0707-0707-0707-070707070707", "tls":tls, + "flow":"xtls-rprx-vision", + "encryption":format!("mlkem768x25519plus.{appearance}.0rtt.{key}")}); + let parse = |node| { + Config::parse_yaml( + &serde_json::to_vec(&json!({ + "socks-port":1080,"proxies":[node],"rules":["MATCH,edge"] + })) + .unwrap(), + ) + }; + assert!( + parse(node.clone()).is_ok(), + "Encryption must supply the Vision splice boundary" + ); + for (field, value) in [ + ("network", json!("ws")), + ("packet-encoding", json!("none")), + ("packet-encoding", json!("packetaddr")), + ("smux", json!({"enabled":true})), + ] { + let mut invalid = node.clone(); + invalid[field] = value; + assert!(parse(invalid).is_err()); + } + } + } +} diff --git a/tests/encryption_wire.rs b/tests/encryption_wire.rs new file mode 100644 index 0000000..04e9886 --- /dev/null +++ b/tests/encryption_wire.rs @@ -0,0 +1,464 @@ +#![cfg(all(feature = "outbound-vless", feature = "interop-test"))] + +use std::{ + io, + net::{IpAddr, SocketAddr}, + pin::Pin, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + task::{Context, Poll}, + time::Duration, +}; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}, + time::{Instant, timeout, timeout_at}, +}; +use vcore::{ + dialer::Dialer, + dispatch::BoxStream, + outbound::{VlessCommand, VlessEncryptionClient as Client, VlessStream, encode_request_header}, + session::Destination, +}; + +struct CountWrites(BoxStream, Arc, Arc>>); +fn encryption_client(fixture: &serde_json::Value, settings: &str) -> Client { + let client = Client::parse(settings).unwrap(); + if fixture["force_chacha"] == true { + client.with_chacha20_poly1305_for_interop() + } else { + client + } +} +impl AsyncRead for CountWrites { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + b: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.0).poll_read(cx, b) + } +} + +#[tokio::test] +#[ignore = "requires the owned SECURITY Encryption container harness"] +async fn public_tcp_roundtrip() { + use vcore::{ + config::{Config, ProxyProtocol}, + dialer::ResolvedEndpoint, + outbound::{EstablishContext, OutboundConnector, VlessOutbound}, + session::{InboundKind, StreamSession}, + }; + let fixture: serde_json::Value = serde_json::from_slice( + &std::fs::read(std::env::var("VCORE_ENCRYPTION_FIXTURE").unwrap()).unwrap(), + ) + .unwrap(); + assert_eq!(fixture["isolation"], "containers"); + let config=Config::parse_yaml(&serde_json::to_vec(&serde_json::json!({"socks-port":1080,"proxies":[fixture["node"]],"rules":["MATCH,edge"]})).unwrap()).unwrap(); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + panic!() + }; + let outbound = VlessOutbound::new( + node, + ResolvedEndpoint { + logical_host: node.address.clone(), + port: node.port, + addresses: vec![SocketAddr::new(node.address.parse().unwrap(), node.port)], + }, + Dialer::default(), + ) + .unwrap(); + for round in 0..4 { + timeout(Duration::from_secs(25), async { + let family = if round % 2 == 0 { "ipv4" } else { "ipv6" }; + let mut control = + tokio::net::TcpStream::connect(fixture["origin_control"].as_str().unwrap()) + .await + .unwrap(); + control + .write_u8(if round % 2 == 0 { 10 } else { 138 }) + .await + .unwrap(); + let port = control.read_u16().await.unwrap(); + let destination = Destination::Ip(SocketAddr::new( + fixture[format!("origin_{family}")] + .as_str() + .unwrap() + .parse() + .unwrap(), + port, + )); + let mut stream = outbound + .connect_stream( + StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:1".parse().unwrap(), + destination, + sniffed_domain: None, + }, + &EstablishContext::default(), + ) + .await + .unwrap() + .io; + let mut hello = [0; 5]; + stream.read_exact(&mut hello).await.unwrap(); + assert_eq!(hello, *b"hello"); + assert_eq!(control.read_u8().await.unwrap(), b'A'); + let payload = vec![0x5a; 10 * 1024 * 1024]; + stream.write_all(&payload).await.unwrap(); + stream.flush().await.unwrap(); + let mut received = vec![0; payload.len() + 7]; + stream.read_exact(&mut received).await.unwrap(); + assert_eq!(&received[..payload.len()], &payload); + assert_eq!(&received[payload.len()..], b"trailer"); + stream.shutdown().await.unwrap(); + drop(stream); + assert_eq!(control.read_u8().await.unwrap(), b'D'); + }) + .await + .unwrap(); + } + outbound.shutdown().await; + println!("SECURITY-ENCRYPTION-CONSUMER-PASS rounds=4 bytes_per_direction=10485760"); +} +impl AsyncWrite for CountWrites { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + b: &[u8], + ) -> Poll> { + let result = Pin::new(&mut self.0).poll_write(cx, b); + if let Poll::Ready(Ok(n)) = result { + self.1.fetch_add(n, Ordering::SeqCst); + let mut prefix = self.2.lock().unwrap(); + let count = n.min(4096 - prefix.len()); + prefix.extend_from_slice(&b[..count]); + } + result + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_shutdown(cx) + } +} + +/// Fault injection at caller-owned IO, not a custom peer or decoder. +struct CorruptRead(BoxStream, usize, usize); +impl AsyncRead for CorruptRead { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + b: &mut ReadBuf<'_>, + ) -> Poll> { + let start = b.filled().len(); + let result = Pin::new(&mut self.0).poll_read(cx, b); + if let Poll::Ready(Ok(())) = result { + let count = b.filled().len() - start; + if self.1 >= self.2 && self.1 < self.2 + count { + b.filled_mut()[start + self.1 - self.2] ^= 1; + } + self.2 += count; + } + result + } +} +impl AsyncWrite for CorruptRead { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + b: &[u8], + ) -> Poll> { + Pin::new(&mut self.0).poll_write(cx, b) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_shutdown(cx) + } +} + +#[tokio::test] +#[ignore = "requires the owned SECURITY Encryption container harness"] +async fn native_encryption_roundtrip() { + let fixture: serde_json::Value = serde_json::from_slice( + &std::fs::read(std::env::var("VCORE_ENCRYPTION_FIXTURE").unwrap()).unwrap(), + ) + .unwrap(); + assert_eq!(fixture["isolation"], "containers"); + let client = encryption_client(&fixture, fixture["encryption"].as_str().unwrap()); + let mut full_flight = 0; + for round in 0..4 { + let family = if round % 2 == 0 { "ipv4" } else { "ipv6" }; + let deadline = Instant::now() + Duration::from_secs(25); + timeout_at(deadline, async { + let mut control = + tokio::net::TcpStream::connect(fixture["origin_control"].as_str().unwrap()) + .await + .unwrap(); + control + .write_u8(if family == "ipv4" { 10 } else { 10 | 0x80 }) + .await + .unwrap(); + let port = control.read_u16().await.unwrap(); + let origin: IpAddr = fixture[format!("origin_{family}")] + .as_str() + .unwrap() + .parse() + .unwrap(); + let peer: IpAddr = fixture[format!("server_{family}")] + .as_str() + .unwrap() + .parse() + .unwrap(); + let raw = Dialer::default() + .connect_address(SocketAddr::new( + peer, + fixture["port"].as_u64().unwrap() as u16, + )) + .await + .unwrap(); + let sent = Arc::new(AtomicUsize::new(0)); + let capture = Arc::new(Mutex::new(Vec::new())); + let encrypted = client + .connect( + Box::new(CountWrites(Box::new(raw), sent.clone(), capture.clone())), + deadline, + ) + .await + .unwrap(); + let handshake_bytes = sent.load(Ordering::SeqCst); + let resumed = round != 0 && fixture["encryption"].as_str().unwrap().contains(".0rtt."); + if resumed { + assert_eq!( + handshake_bytes, 0, + "0-RTT must return without waiting for or sending a full handshake" + ); + } else { + assert!( + handshake_bytes >= 1333, + "a fresh client must perform the complete authenticated handshake" + ); + full_flight = handshake_bytes; + } + let destination = Destination::Ip(SocketAddr::new(origin, port)); + let header = encode_request_header( + uuid::Uuid::from_bytes([7; 16]), + VlessCommand::Tcp, + Some(&destination), + ) + .unwrap(); + let mut stream = VlessStream::with_deadline(encrypted, header, deadline); + let mut hello = [0; 5]; + stream.read_exact(&mut hello).await.unwrap(); + assert_eq!(hello, *b"hello"); + if resumed { + assert!( + sent.load(Ordering::SeqCst) + 1000 < full_flight, + "resumed wire prefix must not contain a full PFS exchange" + ); + } + assert_eq!(control.read_u8().await.unwrap(), b'A'); + let replay = if resumed && round == 1 { + Some(capture.lock().unwrap().clone()) + } else { + None + }; + let payload = vec![0x5a; 10 * 1024 * 1024]; + stream.write_all(&payload).await.unwrap(); + stream.flush().await.unwrap(); + let mut received = vec![0; payload.len() + 7]; + stream.read_exact(&mut received).await.unwrap(); + assert_eq!(&received[..payload.len()], &payload); + assert_eq!(&received[payload.len()..], b"trailer"); + stream.shutdown().await.unwrap(); + drop(stream); + assert_eq!(control.read_u8().await.unwrap(), b'D'); + if let Some(replay) = replay { + let mut raw = Dialer::default() + .connect_address(SocketAddr::new( + peer, + fixture["port"].as_u64().unwrap() as u16, + )) + .await + .unwrap(); + raw.write_all(&replay).await.unwrap(); + raw.flush().await.unwrap(); + let mut byte = [0; 1]; + let result = timeout(Duration::from_secs(3), raw.read(&mut byte)) + .await + .expect("replayed NFS flight must be rejected promptly"); + assert!( + matches!(result, Ok(0)) + || result.as_ref().is_err_and(|e| matches!( + e.kind(), + io::ErrorKind::ConnectionReset | io::ErrorKind::BrokenPipe + )) + ); + assert!( + timeout(Duration::from_millis(150), control.read_u8()) + .await + .is_err(), + "replay must not reach the original live origin listener" + ); + } + }) + .await + .unwrap(); + } + let encryption = fixture["encryption"].as_str().unwrap(); + // A separately constructed node must never inherit this node's ticket. + identity_probe( + &encryption_client(&fixture, encryption), + &fixture, + false, + true, + false, + None, + ) + .await; + if encryption.contains(".0rtt.") { + identity_probe(&client, &fixture, true, false, true, None).await; + identity_probe(&client, &fixture, false, true, false, None).await; + identity_probe(&client, &fixture, false, true, true, None).await; + } + use base64::Engine as _; + let mut parts: Vec = encryption.split('.').map(str::to_owned).collect(); + let key = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(&parts[3]) + .unwrap(); + parts[3] = if key.len() == 32 { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([9; 32]) + } else { + let (public, _) = + boring::mlkem::MlKemPrivateKey::generate(boring::mlkem::Algorithm::MlKem768).unwrap(); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public.as_bytes()) + }; + identity_probe( + &encryption_client(&fixture, &parts.join(".")), + &fixture, + false, + false, + false, + None, + ) + .await; + for offset in [0, 1136, 1168] { + identity_probe( + &encryption_client(&fixture, encryption), + &fixture, + false, + false, + false, + Some(offset), + ) + .await; + } + client.close(); + println!("SECURITY-ENCRYPTION-WIRE-PASS rounds=4 bytes_per_direction=10485760"); +} + +#[tokio::test] +#[ignore = "requires the owned SECURITY Encryption container harness with short-lived tickets"] +async fn native_ticket_expiry() { + let fixture: serde_json::Value = serde_json::from_slice( + &std::fs::read(std::env::var("VCORE_ENCRYPTION_FIXTURE").unwrap()).unwrap(), + ) + .unwrap(); + assert_eq!(fixture["isolation"], "containers"); + assert_eq!(fixture["ticket_expiry"], true); + let encryption = fixture["encryption"].as_str().unwrap(); + assert!(encryption.contains(".0rtt.")); + let client = encryption_client(&fixture, encryption); + identity_probe(&client, &fixture, false, true, false, None).await; + identity_probe(&client, &fixture, false, true, true, None).await; + // The independent peer advertises two seconds. Real monotonic time, no + // mutation of the cache or server clock, and no automatic business retry. + tokio::time::sleep(Duration::from_secs(3)).await; + identity_probe(&client, &fixture, false, true, false, None).await; + identity_probe(&client, &fixture, false, true, true, None).await; + client.close(); + println!("SECURITY-ENCRYPTION-EXPIRY-PASS full-resumed-expired-full-resumed"); +} + +async fn identity_probe( + client: &Client, + fixture: &serde_json::Value, + other_handler: bool, + should_succeed: bool, + resumed: bool, + corrupt: Option, +) { + let deadline = Instant::now() + Duration::from_secs(5); + let mut control = tokio::net::TcpStream::connect(fixture["origin_control"].as_str().unwrap()) + .await + .unwrap(); + control.write_u8(12).await.unwrap(); + let port = control.read_u16().await.unwrap(); + let origin: IpAddr = fixture["origin_ipv4"].as_str().unwrap().parse().unwrap(); + let server: IpAddr = fixture["server_ipv4"].as_str().unwrap().parse().unwrap(); + let peer_port = fixture[if other_handler { "reject_port" } else { "port" }] + .as_u64() + .unwrap() as u16; + let bytes = Arc::new(AtomicUsize::new(0)); + let result = timeout_at(deadline, async { + let raw = Dialer::default() + .connect_address(SocketAddr::new(server, peer_port)) + .await?; + let raw: BoxStream = if let Some(offset) = corrupt { + Box::new(CorruptRead(Box::new(raw), offset, 0)) + } else { + Box::new(raw) + }; + let raw = client + .connect( + Box::new(CountWrites(raw, bytes.clone(), Arc::default())), + deadline, + ) + .await?; + assert_eq!( + bytes.load(Ordering::SeqCst) == 0, + resumed, + "cache must be node-local and rejected tickets must be replaced by a full handshake" + ); + let header = encode_request_header( + uuid::Uuid::from_bytes([7; 16]), + VlessCommand::Tcp, + Some(&Destination::Ip(SocketAddr::new(origin, port))), + )?; + let mut stream = VlessStream::with_deadline(raw, header, deadline); + stream.write_all(b"synthetic-probe").await?; + stream.flush().await?; + let mut response = [0; 2]; + stream.read_exact(&mut response).await?; + assert_eq!(response, *b"ok"); + stream.shutdown().await?; + Ok::<_, io::Error>(()) + }) + .await + .expect("authentication outcome must be explicit, not timeout"); + if should_succeed { + result.unwrap(); + assert_eq!(control.read_u8().await.unwrap(), b'A'); + assert_eq!(control.read_u8().await.unwrap(), b'D'); + } else { + assert!(result.is_err(), "invalid authentication must not succeed"); + if corrupt.is_some() { + assert_eq!( + result.unwrap_err().kind(), + io::ErrorKind::InvalidData, + "modified handshake must fail authentication" + ); + } + assert!( + timeout(Duration::from_millis(150), control.read_u8()) + .await + .is_err(), + "failed authentication must produce zero origin accepts" + ); + } +} diff --git a/tests/feature_foundations.rs b/tests/feature_foundations.rs new file mode 100644 index 0000000..98f2a53 --- /dev/null +++ b/tests/feature_foundations.rs @@ -0,0 +1,106 @@ +use vcore::config::Config; + +#[test] +fn feature_skeletons_do_not_open_unimplemented_yaml_or_measurement_protocols() { + // Keep the stable FOUNDATIONS assertion ID as a regression for the retired protocol. + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-SCHEMA", + "feature_skeletons_do_not_open_unimplemented_yaml_or_measurement_protocols", + ); + let yaml = "proxies:\n - name: node\n type: wireguard\n server: example.com\n port: 443\nrules:\n - MATCH,node\n"; + assert!(Config::parse_yaml(yaml.as_bytes()).is_err()); + assert_eq!(vcore::INVOKE_API_VERSION, 5); + assert_eq!(vcore::CONFIG_VERSION, 27); + assert!(vcore::BUILD_IDENTITY.ends_with("invokeApiVersion=5;configVersion=27")); +} + +#[test] +fn selected_fingerprints_follow_protocol_feature_admission() { + for (protocol, authentication, enabled) in [ + ( + "anytls", + "password: fixture", + cfg!(feature = "outbound-anytls"), + ), + ( + "trojan", + "password: fixture", + cfg!(feature = "outbound-trojan"), + ), + ( + "vmess", + "uuid: 00000000-0000-4000-8000-000000000001, tls: true", + cfg!(feature = "outbound-vmess"), + ), + ( + "vless", + "uuid: 00000000-0000-4000-8000-000000000001, tls: true", + cfg!(feature = "outbound-vless"), + ), + ] { + for name in [ + "none", + "chrome", + "chrome120", + "firefox", + "firefox120", + "safari", + "safari16", + ] { + let yaml = format!( + "socks-port: 1080\nproxies: [{{name: node, type: {protocol}, server: localhost, port: 443, {authentication}, client-fingerprint: {name}}}]\nrules: ['MATCH,node']\n" + ); + assert_eq!( + Config::parse_yaml(yaml.as_bytes()).is_ok(), + // Legacy AnyTLS without a profile defers protocol admission to + // graph preparation. Named profiles require their backend here. + if protocol == "anytls" { + name == "none" || cfg!(feature = "tls-fingerprint") + } else { + enabled + }, + "{protocol}/{name}" + ); + } + } +} + +#[test] +fn vless_yaml_follows_its_own_feature() { + let yaml=b"socks-port: 1080\nproxies: [{name: node, type: vless, server: localhost, port: 443, uuid: 07070707-0707-0707-0707-070707070707}]\nrules: ['MATCH,node']\n"; + assert_eq!( + Config::parse_yaml(yaml).is_ok(), + cfg!(feature = "outbound-vless") + ); +} + +#[test] +fn vmess_yaml_follows_its_own_feature() { + let yaml=b"socks-port: 1080\nproxies: [{name: node, type: vmess, server: localhost, port: 443, uuid: 07070707-0707-0707-0707-070707070707}]\nrules: ['MATCH,node']\n"; + assert_eq!( + Config::parse_yaml(yaml).is_ok(), + cfg!(feature = "outbound-vmess") + ); +} + +#[test] +fn trojan_yaml_follows_its_own_feature() { + let yaml = b"socks-port: 1080\nproxies: [{name: node, type: trojan, server: localhost, port: 443, password: fixture}]\nrules: ['MATCH,node']\n"; + assert_eq!( + Config::parse_yaml(yaml).is_ok(), + cfg!(feature = "outbound-trojan") + ); +} + +#[test] +fn future_fields_and_over_limit_documents_remain_rejected() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-SCHEMA", + "future_fields_and_over_limit_documents_remain_rejected", + ); + assert!(Config::parse_yaml(&vec![b'x'; vcore::config::MAX_CONFIG_BYTES + 1]).is_err()); + assert!(Config::parse_yaml(b"listeners: []\n").is_err()); + assert!(Config::parse_yaml(b"proxies:\n - name: s\n type: socks4\n server: example.com\n port: 1080\nrules: [MATCH,s]\n").is_err()); +} diff --git a/tests/fingerprint_capture.rs b/tests/fingerprint_capture.rs new file mode 100644 index 0000000..7f674c2 --- /dev/null +++ b/tests/fingerprint_capture.rs @@ -0,0 +1,131 @@ +//! Capture the public configuration's real TLS adapter over bounded memory IO. +//! The observer aborts before authentication; proxy interoperability is separate. +#![cfg(all(feature = "tls-fingerprint", feature = "outbound-vless"))] + +use base64::{Engine, engine::general_purpose::STANDARD}; +use serde_json::{Value, json}; +use std::time::Duration; +use tokio::io::AsyncReadExt; +use vcore::{ + config::{Config, ProxyProtocol}, + security::SecurityClient, +}; + +fn configuration(profile: &str, context: &str, sni: &str) -> SecurityClient { + let mut node = json!({ + "name": "p", "type": "vless", "server": "192.0.2.1", "port": 443, + "uuid": "07070707-0707-0707-0707-070707070707", "tls": true, + "servername": sni, "client-fingerprint": profile, + "alpn": ["h2", "http/1.1"] + }); + match context { + "tcp" => {} + "ws" => { + node["network"] = json!("ws"); + node["alpn"] = json!(["http/1.1"]); + node["ws-opts"] = json!({"path": "/capture"}); + } + "grpc" => { + node["network"] = json!("grpc"); + node["grpc-opts"] = json!({"grpc-service-name": "capture"}); + } + "vision" => node["flow"] = json!("xtls-rprx-vision"), + "xhttp-h1" | "xhttp-h2" => { + node["network"] = json!("xhttp"); + node["alpn"] = json!([if context == "xhttp-h1" { + "http/1.1" + } else { + "h2" + }]); + node["xhttp-opts"] = json!({"path": "/capture", "mode": "stream-one"}); + } + "reality" => { + node["reality-opts"] = json!({ + // Public RFC 7748 vector, never an account/server secret. + "public-key": "3p7bfXt9wbTTW2HC7OQ1Nz-DQ8hbeGdNrfx-FG-IK08", + "short-id": "01020304" + }) + } + _ => panic!("unknown capture context"), + } + let config = Config::parse_yaml( + json!({"port": 1080, "proxies": [node], "rules": ["MATCH,p"]}) + .to_string() + .as_bytes(), + ) + .unwrap(); + let ProxyProtocol::Vless(proxy) = &config.proxies[0].protocol else { + unreachable!() + }; + SecurityClient::from_proxy(proxy).unwrap() +} + +async fn capture(client: &SecurityClient) -> Vec { + let (io, mut observer) = tokio::io::duplex(65536); + let observed = async { + let mut records = Vec::new(); + let mut hello = Vec::new(); + for _ in 0..8 { + let mut header = [0; 5]; + observer.read_exact(&mut header).await.unwrap(); + assert_eq!(header[0], 22); + let length = u16::from_be_bytes([header[3], header[4]]) as usize; + assert!((1..=16384).contains(&length)); + let mut body = vec![0; length]; + observer.read_exact(&mut body).await.unwrap(); + records.extend(header); + records.extend(&body); + hello.extend(body); + assert!(hello.len() <= 65536); + if hello.len() >= 4 { + assert_eq!(hello[0], 1); + let expected = u32::from_be_bytes([0, hello[1], hello[2], hello[3]]) as usize + 4; + assert!(hello.len() <= expected); + if hello.len() == expected { + drop(observer); + return records; + } + } + } + panic!("ClientHello record bound exceeded") + }; + let (result, records) = tokio::time::timeout(Duration::from_secs(3), async { + tokio::join!(client.connect(Box::new(io)), observed) + }) + .await + .unwrap(); + assert!(result.is_err(), "capture-only observer cannot authenticate"); + records +} + +#[tokio::test] +async fn selected_public_profiles_emit_bounded_client_hellos() { + let mut cases: Vec = Vec::new(); + for profile in [ + "chrome", + "chrome120", + "firefox", + "firefox120", + "safari", + "safari16", + ] { + for context in [ + "tcp", "ws", "grpc", "reality", "vision", "xhttp-h1", "xhttp-h2", + ] { + for sni in [ + "fingerprint.test".to_owned(), + format!("{}.fingerprint.test", "f".repeat(63)), + ] { + let client = configuration(profile, context, &sni); + for attempt in 0..2 { + cases.push(json!({"profile": profile, "context": context, "sni": sni, + "attempt": attempt, "records_b64": STANDARD.encode(capture(&client).await)})); + } + } + } + } + assert_eq!(cases.len(), 168); + if let Ok(path) = std::env::var("VCORE_FINGERPRINT_CAPTURE") { + std::fs::write(path, serde_json::to_vec_pretty(&cases).unwrap()).unwrap(); + } +} diff --git a/tests/fingerprints/README.md b/tests/fingerprints/README.md new file mode 100644 index 0000000..946f653 --- /dev/null +++ b/tests/fingerprints/README.md @@ -0,0 +1,67 @@ +# ClientHello 独立参考 + +保留官方 Mihomo/uTLS 原始 ClientHello golden;开发阶段计划不再作为测试输入。 + +## 验证分层 + +| 报告标签 | 脚本入口 | 证明范围 | +| --- | --- | --- | +| FINGERPRINT-REFERENCE | protocol_fingerprint_reference | 官方客户端的独立基线,不是 VCore 互通 | +| FINGERPRINT-WIRE | protocol_fingerprint_shape | 公开配置的内存 TLS 报文、恢复与过期 | +| FINGERPRINT-INTEROP | protocol_fingerprint | 命名指纹在真实容器协议链路中的行为 | + +命令统一见 [定向安全验证](../../scripts/README.md#定向安全验证)。用例、容器与报告 +按用途命名,不使用开发阶段编号。golden 中的 `case_id` 是当前用例标签;原始 TLS +字节、解析结果、来源版本及原报告 hash 保持独立采集时的值,不表示重新抓包。 +历史运行报告不改写,按其记录的 Git revision 复核;各层结果不能互相代替。 + +## Independent wire baseline + +`mihomo-selected-v1.json` retains eight small synthetic, raw TLS observations: +four templates, ordinary TCP TLS and classic REALITY. The official latest Mihomo +binary was downloaded, not compiled from the reference source. Binary identity, +uTLS build dependency, raw record layout, handshake bytes and parsed fields are +retained. `validate_capture` re-parses raw bytes and detects altered derived data. +These are public ephemeral key shares and synthetic names, not private keys, +proxy credentials, application traffic or user destinations. + +The full 116-case run stays in ignored `target/interop/runs/`: six enabled names +across TCP/WS/gRPC/REALITY, two SNI lengths and two successive connections; four +`none` controls; four actual templates with TLS1.2/1.3 handshakes on a native +OpenSSL observer. All server roles and the official client run in owned isolated +containers. Success means **reference captured / baseline verified**, not VCore +support, REALITY authentication or proxy interoperability. A capture-only observer +intentionally aborts; OpenSSL handshake cases do not implement VLESS or HTTP. + +## Comparison rules + +The bounded parser preserves ordered cipher/signature/group/share vectors, +extension payloads, ALPN/ALPS, versions, compression, ECH outer shape, padding, +session IDs' widths, ticket lengths and PSK identity/binder widths. Crypto bytes +and time-dependent ages remain in raw evidence but are not compared for equality. +GREASE values are normalized while position, width and group/share association +remain checked. Chrome shuffles only eligible slots; fixed GREASE/padding/PSK +positions are not sorted away. Firefox/Safari use fixed extension order. + +The reference checker additionally validates source-defined variants before +normalizing them: exact SNI and caller-context ALPN; Chrome's ECH payload sizes +144/176/208/240; Firefox's 239-byte payload and AES128-GCM/ChaCha20 choice; and +Chrome120/Safari's conditional padding to 512 bytes. Chrome133 has no padding. +Changes outside those allowances fail comparison. Goldens are not regenerated +from the implementation under test to silence mismatches. + +Sources: [Mihomo TLS connection path](https://github.com/MetaCubeX/mihomo/blob/ab405bad5beeeac8b003bb01f60f134f6df54471/component/tls/utls.go), +[pinned uTLS templates](https://github.com/MetaCubeX/utls/blob/f7d52c22f3a8d2f510ad1470f75cb6c3fe26aa37/u_parrots.go), +[ECH GREASE](https://github.com/MetaCubeX/utls/blob/f7d52c22f3a8d2f510ad1470f75cb6c3fe26aa37/u_ech.go), +[padding](https://github.com/MetaCubeX/utls/blob/f7d52c22f3a8d2f510ad1470f75cb6c3fe26aa37/u_tls_extensions.go). + +## Intentional VCore differences + +VCore retains a TLS1.2 floor, transport-owned ALPN, h2-only ALPS advertisement, +nonempty ALPS rejection and its bounded ordinary-TLS session cache. Safari's +TLS1.0/1.1 declarations are trimmed; Mihomo's WS Chrome still advertises h2 ALPS +when ALPN is only HTTP/1.1, which VCore deliberately does not do. Warm/resumed +hellos have separate expectations; PSK is not ignored by the comparator. These +policies do not authorize dropping unrelated cipher suites, shares or extensions. + +当前身份、后端和恢复边界见 [TLS 契约](../../docs/tls-client-fingerprint.md);实际运行结果按 [验收边界](../../docs/acceptance.md)记录。 diff --git a/tests/fingerprints/mihomo-selected-v1.json b/tests/fingerprints/mihomo-selected-v1.json new file mode 100644 index 0000000..683d825 --- /dev/null +++ b/tests/fingerprints/mihomo-selected-v1.json @@ -0,0 +1,1447 @@ +{ + "scope": "selected-v1", + "kind": "synthetic-official-clienthello-fixtures", + "proves": "Reference shapes only, not VCore support, proxy data or REALITY authentication", + "source": { + "release": "Mihomo Meta v1.19.31 linux arm64 with go1.26.8 Mon Sep 14 13:20:46 UTC 2026\nUse tags: with_gvisor", + "binary_sha256": "1b315bc038d05f84ee86d232f3c3d2b020b5044e9b971bb8fe215b6e6a2148f3", + "utls": [ + "dep\tgithub.com/metacubex/utls\tv1.8.7\th1:Cp+yWkNTFkSihETgGWq34hlVFds5HpYWVOR1xovUVTs=", + "build\tvcs.revision=ab405bad5beeeac8b003bb01f60f134f6df54471" + ], + "report_sha256": "c22b544d09407447f6beb32aa8718c8ae16055512bf49690ed4e93e49564edc8", + "source_tree_sha256": "1e52f8fcdd1f58d55869f70c6d259a04d18b753df4902becd32911249245762d" + }, + "samples": [ + { + "case_id": "FINGERPRINT-REFERENCE-chrome-tcp-16-0", + "profile": "chrome", + "context": "tcp", + "template": "Chrome133", + "port": 24430, + "sequence": 2, + "client_hello_b64": "AQAG1QMDZKGJWCZE3S2TOjf3YEtdTjAJxY0q8+uSVtAcSUUTpHUgRLR2ecQ1xVFRudQ0IqfGwOMwyBSQnmqESg3TJ9O4fUsAICoqEwETAhMDwCvAL8AswDDMqcyowBPAFACcAJ0ALwA1AQAGbGpqAAAAEgAAAAUABQEAAAAA/wEAAQBEzQAFAAMCaDIAFwAAABsAAwIAAgANABIAEAQDCAQEAQUDCAUFAQgGBgEAMwTvBO0aGgABABHsBMB+Ygay+rH+cs3Oy3QT6Xf7JiiPgsrz5R1GPHvSalA5a4d8KS3roS9w2JdtyVIC1zeAwxiUMM4CygrthaxN8m4fMw15IyxtgU55QbIKfJgTSDHYxVAoS5VdIIYQ0lv8Q0TrQ5Y/MkLoUbBXW4vPc4co2l4GSULPaKvmNbNkpTljgzTHUx9BeIKgMkpCZc/QyFqFrIp4Iihma5dvyX4jMGK2qk4BeMHyNQZ4PHZL9k1nZ2RZS2qF9hGqCCFGxhxoQx+fhR1A1MBIVjc8creFbCUmYisomJufBiiItHtQg1POwoOe04VIx85zghW78sLxWR6namJXe3KjqbpKeoa3W3T6wsj8IryIilQqO7XImoAOiqPg2g/zJ4WZ8oeenD3KtwX6Oj5NIXx+UmQsWL+kW5VefIDN13DOmaqTgyRHYJB2iUdpgFEfqjwMRogbDM94Y7N5a535OA6oZA8NqxK12RVM6sXsSDDrlC4ghmm+CmcqPEa8HIcLAIqZCr0m8MKtfBJXUE8Wo0xO97xhUEZTkgy/24fQZpms4phI6RSE2ZACQjuTmBcuwpPP+EnIRof6ASQQYEZVRD8apSxOliLM4c9agou7mHC6xJs0JCgMIpwnW7piZgyEeXdn3DKLMG4sgJ8KlmRxFJjwpySiSFp2smDwRh3lopup13NztHcAx2t2C6YBNGsKaG8hd3rWE0I5YjoUVkE8dAb5DCTCCFhvY7xx4BDDYG4eGXz2Qhn3u0todWo9oFg/MImYhVIl/MK70rZodD2SOEbqC5atd5+zJ0GqRiJHsj0e5F0FiEdtYpyGVw7fUwzPNUkQR3a7oQ8xdXL2LMmLg3+iqhLZWgpj5Qvqe4+6Z30mq1gpFyDN9oEsmwJpYLj82E8wyGB8JWeO2jFmKEqFCbXaNFkt4oZ7dCaJ4HurSF1LgqljlE3X68J2ApFr7BSJhayG9CewqZ40sYGcqAqpPD/c12FZvIFlF0pSo8wE5rPGWRQqqlS2pZzmGQycJxXc/MyjmWI/zHkQ4MHDmz61ZjeO12nb6CpH+RZ10yyPom+r0KRHCyM+nJCAWXCEWgtCaWaJGQXuJjcHKQQQg8Ac0sx/y5gM9Fh5SDtQ20NIRJs7sxgxKIVKSExZcIGR6FrWcEW19QOkE2kGt5qn0Wbu0aqt00bS8l+9sySEcC4Kyiql3Id/kZfRa1VJOknXasheTD0w9iFN58GaScXjVyj3EGxEQ0kSYyMx5LfbO8dtg8/xc5NQo2sQ9MCdTMAbQrmCpKBNMLsJ5LiKlFIQmxZQ1nxZQ4lUQDr8MlY9YW2IUCuEhsweaFadLEZTkGNOGJToiB3WvMgi8IW888xdBDDDZDqydW3vAWbyt0uMeKQgYS3QIFt9C5r9qU3A2zAnAUm/JlPmqRuks4RyyDWPaCKvxDRq8I4868jmzGGSMr81io/Si5wxlg7A5yDdlgfUQ7bB4iRcUWbfN3618Bfxmczol1//SX4BtpUDAa8ntFORpDr1Cq+/+g+aCrnwK77I4JJmhipwkQFGdaNEqzLEr5nw96wXC1dx+H5Q29Ivo61XSPH+Klae0WXdJ6wabTNF9HyQWAmxyJQ5LtojPZsIBHfaujsKQP2F/Yw12AR+AB0AICnvqIFUFpCwpRbegzSqzjQoQhyCaIGezTgA1Pyl5GF6ACMAAAAQAA4ADAJoMghodHRwLzEuMQALAAIBAAAtAAIBAQArAAcG+voDBAMDAAAAFQATAAAQZmluZ2VycHJpbnQudGVzdP4NANoAAAEAAdsAIKMfewRKqQsv04sEaIoACbIt1k3IqVzcMybPuXc5l98kALDbiswTxS7Zz98wVdggvtJt5/0nR68qrfcMAjjKJtQVz/XEGiDeSn0WE59PDdPsywKoDi08eiT7psU+wRH07k/6zmoP7hwZ6Yz9S8hJ3m6Mel14YXWbIxJtplY9i107rgK21ju7McIeiakaE5YjyNPuFhJ91r3DMpYz3SpppViBLaKt0y6e1wZtHI2rIanmdVcGUmXMpYWmhpt/E/JXTVkYF9gj1HrtGSNUlkAUBb3AYgAKAAwAChoaEewAHQAXABjKygABAA==", + "records_b64": "FgMBBtkBAAbVAwNkoYlYJkTdLZM6N/dgS11OMAnFjSrz65JW0BxJRROkdSBEtHZ5xDXFUVG51DQip8bA4zDIFJCeaoRKDdMn07h9SwAgKioTARMCEwPAK8AvwCzAMMypzKjAE8AUAJwAnQAvADUBAAZsamoAAAASAAAABQAFAQAAAAD/AQABAETNAAUAAwJoMgAXAAAAGwADAgACAA0AEgAQBAMIBAQBBQMIBQUBCAYGAQAzBO8E7RoaAAEAEewEwH5iBrL6sf5yzc7LdBPpd/smKI+CyvPlHUY8e9JqUDlrh3wpLeuhL3DYl23JUgLXN4DDGJQwzgLKCu2FrE3ybh8zDXkjLG2BTnlBsgp8mBNIMdjFUChLlV0ghhDSW/xDROtDlj8yQuhRsFdbi89zhyjaXgZJQs9oq+Y1s2SlOWODNMdTH0F4gqAySkJlz9DIWoWsingiKGZrl2/JfiMwYraqTgF4wfI1Bng8dkv2TWdnZFlLaoX2EaoIIUbGHGhDH5+FHUDUwEhWNzxyt4VsJSZiKyiYm58GKIi0e1CDU87Cg57ThUjHznOCFbvywvFZHqdqYld7cqOpukp6hrdbdPrCyPwivIiKVCo7tciagA6Ko+DaD/MnhZnyh56cPcq3Bfo6Pk0hfH5SZCxYv6RblV58gM3XcM6ZqpODJEdgkHaJR2mAUR+qPAxGiBsMz3hjs3lrnfk4DqhkDw2rErXZFUzqxexIMOuULiCGab4KZyo8RrwchwsAipkKvSbwwq18EldQTxajTE73vGFQRlOSDL/bh9BmmazimEjpFITZkAJCO5OYFy7Ck8/4SchGh/oBJBBgRlVEPxqlLE6WIszhz1qCi7uYcLrEmzQkKAwinCdbumJmDIR5d2fcMoswbiyAnwqWZHEUmPCnJKJIWnayYPBGHeWim6nXc3O0dwDHa3YLpgE0awpobyF3etYTQjliOhRWQTx0BvkMJMIIWG9jvHHgEMNgbh4ZfPZCGfe7S2h1aj2gWD8wiZiFUiX8wrvStmh0PZI4RuoLlq13n7MnQapGIkeyPR7kXQWIR21inIZXDt9TDM81SRBHdruhDzF1cvYsyYuDf6KqEtlaCmPlC+p7j7pnfSarWCkXIM32gSybAmlguPzYTzDIYHwlZ47aMWYoSoUJtdo0WS3ihnt0Jonge6tIXUuCqWOUTdfrwnYCkWvsFImFrIb0J7CpnjSxgZyoCqk8P9zXYVm8gWUXSlKjzATms8ZZFCqqVLalnOYZDJwnFdz8zKOZYj/MeRDgwcObPrVmN47XadvoKkf5FnXTLI+ib6vQpEcLIz6ckIBZcIRaC0JpZokZBe4mNwcpBBCDwBzSzH/LmAz0WHlIO1DbQ0hEmzuzGDEohUpITFlwgZHoWtZwRbX1A6QTaQa3mqfRZu7Rqq3TRtLyX72zJIRwLgrKKqXch3+Rl9FrVUk6SddqyF5MPTD2IU3nwZpJxeNXKPcQbERDSRJjIzHkt9s7x22Dz/Fzk1CjaxD0wJ1MwBtCuYKkoE0wuwnkuIqUUhCbFlDWfFlDiVRAOvwyVj1hbYhQK4SGzB5oVp0sRlOQY04YlOiIHda8yCLwhbzzzF0EMMNkOrJ1be8BZvK3S4x4pCBhLdAgW30Lmv2pTcDbMCcBSb8mU+apG6SzhHLINY9oIq/ENGrwjjzryObMYZIyvzWKj9KLnDGWDsDnIN2WB9RDtsHiJFxRZt83frXwF/GZzOiXX/9JfgG2lQMBrye0U5GkOvUKr7/6D5oKufArvsjgkmaGKnCRAUZ1o0SrMsSvmfD3rBcLV3H4flDb0i+jrVdI8f4qVp7RZd0nrBptM0X0fJBYCbHIlDku2iM9mwgEd9q6OwpA/YX9jDXYBH4AHQAgKe+ogVQWkLClFt6DNKrONChCHIJogZ7NOADU/KXkYXoAIwAAABAADgAMAmgyCGh0dHAvMS4xAAsAAgEAAC0AAgEBACsABwb6+gMEAwMAAAAVABMAABBmaW5nZXJwcmludC50ZXN0/g0A2gAAAQAB2wAgox97BEqpCy/TiwRoigAJsi3WTcipXNwzJs+5dzmX3yQAsNuKzBPFLtnP3zBV2CC+0m3n/SdHryqt9wwCOMom1BXP9cQaIN5KfRYTn08N0+zLAqgOLTx6JPumxT7BEfTuT/rOag/uHBnpjP1LyEnebox6XXhhdZsjEm2mVj2LXTuuArbWO7sxwh6JqRoTliPI0+4WEn3WvcMyljPdKmmlWIEtoq3TLp7XBm0cjashqeZ1VwZSZcylhaaGm38T8ldNWRgX2CPUeu0ZI1SWQBQFvcBiAAoADAAKGhoR7AAdABcAGMrKAAEA", + "records": [ + { + "type": 22, + "version": 769, + "bytes": 1753 + } + ], + "status": "captured-only", + "parsed": { + "bytes": 1753, + "legacy_version": 771, + "session_id_bytes": 32, + "ciphers": [ + 10794, + 4865, + 4866, + 4867, + 49195, + 49199, + 49196, + 49200, + 52393, + 52392, + 49171, + 49172, + 156, + 157, + 47, + 53 + ], + "compression_methods": [ + 0 + ], + "extensions": [ + { + "type": 27242, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 18, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 5, + "bytes": 5, + "payload_hex": "0100000000" + }, + { + "type": 65281, + "bytes": 1, + "payload_hex": "00" + }, + { + "type": 17613, + "bytes": 5, + "protocols": [ + "h2" + ] + }, + { + "type": 23, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 27, + "bytes": 3, + "values": [ + 2 + ] + }, + { + "type": 13, + "bytes": 18, + "values": [ + 1027, + 2052, + 1025, + 1283, + 2053, + 1281, + 2054, + 1537 + ] + }, + { + "type": 51, + "bytes": 1263, + "shares": [ + { + "group": 6682, + "bytes": 1, + "grease_data": "00" + }, + { + "group": 4588, + "bytes": 1216 + }, + { + "group": 29, + "bytes": 32 + } + ] + }, + { + "type": 35, + "bytes": 0, + "ticket_bytes": 0 + }, + { + "type": 16, + "bytes": 14, + "protocols": [ + "h2", + "http/1.1" + ] + }, + { + "type": 11, + "bytes": 2, + "payload_hex": "0100" + }, + { + "type": 45, + "bytes": 2, + "payload_hex": "0101" + }, + { + "type": 43, + "bytes": 7, + "values": [ + 64250, + 772, + 771 + ] + }, + { + "type": 0, + "bytes": 21, + "names": [ + { + "type": 0, + "name": "fingerprint.test" + } + ] + }, + { + "type": 65037, + "bytes": 218, + "hello_type": 0, + "kdf": 1, + "aead": 1, + "enc_bytes": 32, + "payload_bytes": 176 + }, + { + "type": 10, + "bytes": 12, + "values": [ + 6682, + 4588, + 29, + 23, + 24 + ] + }, + { + "type": 51914, + "bytes": 1, + "payload_hex": "00" + } + ], + "raw_sha256": "e16b70905070c0d3cb15b23ba90f2cfd3e76c4439e49ab3b0ec0f0a07fd1fd1e" + } + }, + { + "case_id": "FINGERPRINT-REFERENCE-chrome-reality-16-0", + "profile": "chrome", + "context": "reality", + "template": "Chrome133", + "port": 24430, + "sequence": 14, + "client_hello_b64": "AQACDwMDZF9lYYSiyHG+2zkOuT7VzaJRzAC5o60meo+MwOXllskga9OJK/zRWgmLFyUKmD2Oib/Qw2Cok8fC4Dt0LbKjWm0AIJqaEwETAhMDwCvAL8AswDDMqcyowBPAFACcAJ0ALwA1AQABpnp6AAAAKwAHBkpKAwQDAwAtAAIBAQANABIAEAQDCAQEAQUDCAUFAQgGBgEABQAFAQAAAAAAAAAVABMAABBmaW5nZXJwcmludC50ZXN0AAsAAgEAABAADgAMAmgyCGh0dHAvMS4xABsAAwIAAgAzACsAKdraAAEAAB0AIJilMBOaPZnMHa69RhzSpaCVLoJWVHRt5lOpawnKTj8t/g0A2gAAAQAB1AAgHrtrg7GFa1Pb9IK2r2BsjZFYTOZpQYOrveiQb6tS2kEAsNcoOrb5XVxgKcE+f0BevYNAA3RO6C/fq89LOlMxMXAm0KBHNI+TRvVvhoBsjNDCWxOtdx/Grl5gKGjfCxJiOA2Fcuf28TOWozMKYj+horGSKMTne5EMqWJT+9VToYUtKkCWg166d31ez9BHFvTD/MTAdVSjIauurU0ys+Hw8RrGKEcqPowR6ZaSL+3DwN93e+mIjQh1BC262wSIQ0HeF19n2X89OuAUQIMwBFW3gwJ8ABIAAAAKAAoACNraAB0AFwAYABcAAETNAAUAAwJoMv8BAAEAACMAALq6AAEA", + "records_b64": "FgMBAhMBAAIPAwNkX2VhhKLIcb7bOQ65PtXNolHMALmjrSZ6j4zA5eWWySBr04kr/NFaCYsXJQqYPY6Jv9DDYKiTx8LgO3QtsqNabQAgmpoTARMCEwPAK8AvwCzAMMypzKjAE8AUAJwAnQAvADUBAAGmenoAAAArAAcGSkoDBAMDAC0AAgEBAA0AEgAQBAMIBAQBBQMIBQUBCAYGAQAFAAUBAAAAAAAAABUAEwAAEGZpbmdlcnByaW50LnRlc3QACwACAQAAEAAOAAwCaDIIaHR0cC8xLjEAGwADAgACADMAKwAp2toAAQAAHQAgmKUwE5o9mcwdrr1GHNKloJUuglZUdG3mU6lrCcpOPy3+DQDaAAABAAHUACAeu2uDsYVrU9v0gravYGyNkVhM5mlBg6u96JBvq1LaQQCw1yg6tvldXGApwT5/QF69g0ADdE7oL9+rz0s6UzExcCbQoEc0j5NG9W+GgGyM0MJbE613H8auXmAoaN8LEmI4DYVy5/bxM5ajMwpiP6GisZIoxOd7kQypYlP71VOhhS0qQJaDXrp3fV7P0EcW9MP8xMB1VKMhq66tTTKz4fDxGsYoRyo+jBHplpIv7cPA33d76YiNCHUELbrbBIhDQd4XX2fZfz064BRAgzAEVbeDAnwAEgAAAAoACgAI2toAHQAXABgAFwAARM0ABQADAmgy/wEAAQAAIwAAuroAAQA=", + "records": [ + { + "type": 22, + "version": 769, + "bytes": 531 + } + ], + "status": "captured-only", + "parsed": { + "bytes": 531, + "legacy_version": 771, + "session_id_bytes": 32, + "ciphers": [ + 39578, + 4865, + 4866, + 4867, + 49195, + 49199, + 49196, + 49200, + 52393, + 52392, + 49171, + 49172, + 156, + 157, + 47, + 53 + ], + "compression_methods": [ + 0 + ], + "extensions": [ + { + "type": 31354, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 43, + "bytes": 7, + "values": [ + 19018, + 772, + 771 + ] + }, + { + "type": 45, + "bytes": 2, + "payload_hex": "0101" + }, + { + "type": 13, + "bytes": 18, + "values": [ + 1027, + 2052, + 1025, + 1283, + 2053, + 1281, + 2054, + 1537 + ] + }, + { + "type": 5, + "bytes": 5, + "payload_hex": "0100000000" + }, + { + "type": 0, + "bytes": 21, + "names": [ + { + "type": 0, + "name": "fingerprint.test" + } + ] + }, + { + "type": 11, + "bytes": 2, + "payload_hex": "0100" + }, + { + "type": 16, + "bytes": 14, + "protocols": [ + "h2", + "http/1.1" + ] + }, + { + "type": 27, + "bytes": 3, + "values": [ + 2 + ] + }, + { + "type": 51, + "bytes": 43, + "shares": [ + { + "group": 56026, + "bytes": 1, + "grease_data": "00" + }, + { + "group": 29, + "bytes": 32 + } + ] + }, + { + "type": 65037, + "bytes": 218, + "hello_type": 0, + "kdf": 1, + "aead": 1, + "enc_bytes": 32, + "payload_bytes": 176 + }, + { + "type": 18, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 10, + "bytes": 10, + "values": [ + 56026, + 29, + 23, + 24 + ] + }, + { + "type": 23, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 17613, + "bytes": 5, + "protocols": [ + "h2" + ] + }, + { + "type": 65281, + "bytes": 1, + "payload_hex": "00" + }, + { + "type": 35, + "bytes": 0, + "ticket_bytes": 0 + }, + { + "type": 47802, + "bytes": 1, + "payload_hex": "00" + } + ], + "raw_sha256": "fb36c6c6054a1fb6f783dae79747b2b754c811ac693f29e3b23cc791c35347c1" + } + }, + { + "case_id": "FINGERPRINT-REFERENCE-chrome120-tcp-16-0", + "profile": "chrome120", + "context": "tcp", + "template": "Chrome120", + "port": 24430, + "sequence": 18, + "client_hello_b64": "AQACTwMDUlU8Bf1nyA2jrL4h3WofYeXIdt/TF4rosjsmUwGqfiwgzkR4V4T8M0jZiGdQJtz4mXNqH+sR6LDJZb4O+9Va/x0AICoqEwETAhMDwCvAL8AswDDMqcyowBPAFACcAJ0ALwA1AQAB5qqqAAAAIwAA/wEAAQAAEAAOAAwCaDIIaHR0cC8xLjEACgAKAAg6OgAdABcAGERpAAUAAwJoMgAAABUAEwAAEGZpbmdlcnByaW50LnRlc3QAFwAA/g0BGgAAAQABrwAglote65tn11bs2WmSWmgpXTu/G9Fra10HDCiGGQRtLHsA8LXncFujJFZhWVGlYt/n9UabBg5Skr8auhSuqyZPIfx/10D17qANClhoSDrZxJ/j1DVv11bPJdJtit3S8w9D8kAYDJrHnHUyVnxyaoKSeHEXEd8ZyMjxIgJVyIzdByKBWlyoYJIlV0g+VimGPU0ToZaxwSDTVSYTWKyFwdTTU5kPfAubncapVkyKQTqCWiiUHwJT+Hks+lRs3ciR2hkyD0cqTWBgKUqq+InT7IzaB9utGGh7hSXVMQIUbmQyjefX8me4OSO5caKKmhMPO746Dweg2DX8vY/9VIXj6RY1AEAURwafXdasPqy1JSR57vC02wArAAcG6uoDBAMDAAUABQEAAAAAADMAKwApOjoAAQAAHQAgJLMEAunnFt6VK2SwiSR9m2bupreV9ihJUihdooh3xnYALQACAQEAEgAAAA0AEgAQBAMIBAQBBQMIBQUBCAYGAQAbAAMCAAIACwACAQDa2gABAA==", + "records_b64": "FgMBAlMBAAJPAwNSVTwF/WfIDaOsviHdah9h5ch239MXiuiyOyZTAap+LCDORHhXhPwzSNmIZ1Am3PiZc2of6xHosMllvg771Vr/HQAgKioTARMCEwPAK8AvwCzAMMypzKjAE8AUAJwAnQAvADUBAAHmqqoAAAAjAAD/AQABAAAQAA4ADAJoMghodHRwLzEuMQAKAAoACDo6AB0AFwAYRGkABQADAmgyAAAAFQATAAAQZmluZ2VycHJpbnQudGVzdAAXAAD+DQEaAAABAAGvACCWi17rm2fXVuzZaZJaaCldO78b0WtrXQcMKIYZBG0sewDwtedwW6MkVmFZUaVi3+f1RpsGDlKSvxq6FK6rJk8h/H/XQPXuoA0KWGhIOtnEn+PUNW/XVs8l0m2K3dLzD0PyQBgMmsecdTJWfHJqgpJ4cRcR3xnIyPEiAlXIjN0HIoFaXKhgkiVXSD5WKYY9TROhlrHBINNVJhNYrIXB1NNTmQ98C5udxqlWTIpBOoJaKJQfAlP4eSz6VGzdyJHaGTIPRypNYGApSqr4idPsjNoH260YaHuFJdUxAhRuZDKN59fyZ7g5I7lxooqaEw87vjoPB6DYNfy9j/1UhePpFjUAQBRHBp9d1qw+rLUlJHnu8LTbACsABwbq6gMEAwMABQAFAQAAAAAAMwArACk6OgABAAAdACAkswQC6ecW3pUrZLCJJH2bZu6mt5X2KElSKF2iiHfGdgAtAAIBAQASAAAADQASABAEAwgEBAEFAwgFBQEIBgYBABsAAwIAAgALAAIBANraAAEA", + "records": [ + { + "type": 22, + "version": 769, + "bytes": 595 + } + ], + "status": "captured-only", + "parsed": { + "bytes": 595, + "legacy_version": 771, + "session_id_bytes": 32, + "ciphers": [ + 10794, + 4865, + 4866, + 4867, + 49195, + 49199, + 49196, + 49200, + 52393, + 52392, + 49171, + 49172, + 156, + 157, + 47, + 53 + ], + "compression_methods": [ + 0 + ], + "extensions": [ + { + "type": 43690, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 35, + "bytes": 0, + "ticket_bytes": 0 + }, + { + "type": 65281, + "bytes": 1, + "payload_hex": "00" + }, + { + "type": 16, + "bytes": 14, + "protocols": [ + "h2", + "http/1.1" + ] + }, + { + "type": 10, + "bytes": 10, + "values": [ + 14906, + 29, + 23, + 24 + ] + }, + { + "type": 17513, + "bytes": 5, + "protocols": [ + "h2" + ] + }, + { + "type": 0, + "bytes": 21, + "names": [ + { + "type": 0, + "name": "fingerprint.test" + } + ] + }, + { + "type": 23, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 65037, + "bytes": 282, + "hello_type": 0, + "kdf": 1, + "aead": 1, + "enc_bytes": 32, + "payload_bytes": 240 + }, + { + "type": 43, + "bytes": 7, + "values": [ + 60138, + 772, + 771 + ] + }, + { + "type": 5, + "bytes": 5, + "payload_hex": "0100000000" + }, + { + "type": 51, + "bytes": 43, + "shares": [ + { + "group": 14906, + "bytes": 1, + "grease_data": "00" + }, + { + "group": 29, + "bytes": 32 + } + ] + }, + { + "type": 45, + "bytes": 2, + "payload_hex": "0101" + }, + { + "type": 18, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 13, + "bytes": 18, + "values": [ + 1027, + 2052, + 1025, + 1283, + 2053, + 1281, + 2054, + 1537 + ] + }, + { + "type": 27, + "bytes": 3, + "values": [ + 2 + ] + }, + { + "type": 11, + "bytes": 2, + "payload_hex": "0100" + }, + { + "type": 56026, + "bytes": 1, + "payload_hex": "00" + } + ], + "raw_sha256": "7b9565078a74ee0255fab5a23669410073e451130cd1e9cb197614ab7dd656ce" + } + }, + { + "case_id": "FINGERPRINT-REFERENCE-chrome120-reality-16-0", + "profile": "chrome120", + "context": "reality", + "template": "Chrome120", + "port": 24430, + "sequence": 30, + "client_hello_b64": "AQAB/AMD0SRbHCGo52XVQ8Ae4v1BJwYFXb8m9K5qIi8SniuTVfUgvsuIIb3xroMdVcwexw4XWSQHN5J8N+1gNXTAd9jJWVgAINraEwETAhMDwCvAL8AswDDMqcyowBPAFACcAJ0ALwA1AQABkzo6AAD+DQC6AAABAAEWACArvsoe2K12Qx/gNS6L2t05K/VeCeRNDrHCb9fQLvpwZACQu1DP7dGdccbHmbtW63DsRu+LzgqKIZJtw6bISNWcfkUOGknvfbVcNLOLkqkD53Ko/5/cuYtjcOQYTIJXBjurZ2lMtrlp/tF1s5+gTJh9VHL/r5a4/YdYoRg39P/mls8E6IKj/Ku6tk7Joyndt58mPZ4RMKXdzhxDqcrNxZjYCHt2edtmh4rhhAKcEGhDCUUnABIAAAAFAAUBAAAAAAAjAAAAAAAVABMAABBmaW5nZXJwcmludC50ZXN0RGkABQADAmgyAAsAAgEAADMAKwApenoAAQAAHQAg4+MR0GAjRKqOxbE4HMhUHbiQHKnraRaXX8aMc3gPFCMALQACAQEAFwAA/wEAAQAAKwAHBrq6AwQDAwANABIAEAQDCAQEAQUDCAUFAQgGBgEACgAKAAh6egAdABcAGAAbAAMCAAIAEAAOAAwCaDIIaHR0cC8xLjFKSgABAAAVAAkAAAAAAAAAAAA=", + "records_b64": "FgMBAgABAAH8AwPRJFscIajnZdVDwB7i/UEnBgVdvyb0rmoiLxKeK5NV9SC+y4ghvfGugx1VzB7HDhdZJAc3knw37WA1dMB32MlZWAAg2toTARMCEwPAK8AvwCzAMMypzKjAE8AUAJwAnQAvADUBAAGTOjoAAP4NALoAAAEAARYAICu+yh7YrXZDH+A1Lova3Tkr9V4J5E0OscJv19Au+nBkAJC7UM/t0Z1xxseZu1brcOxG74vOCoohkm3DpshI1Zx+RQ4aSe99tVw0s4uSqQPncqj/n9y5i2Nw5BhMglcGO6tnaUy2uWn+0XWzn6BMmH1Ucv+vlrj9h1ihGDf0/+aWzwTogqP8q7q2TsmjKd23nyY9nhEwpd3OHEOpys3FmNgIe3Z522aHiuGEApwQaEMJRScAEgAAAAUABQEAAAAAACMAAAAAABUAEwAAEGZpbmdlcnByaW50LnRlc3REaQAFAAMCaDIACwACAQAAMwArACl6egABAAAdACDj4xHQYCNEqo7FsTgcyFQduJAcqetpFpdfxoxzeA8UIwAtAAIBAQAXAAD/AQABAAArAAcGuroDBAMDAA0AEgAQBAMIBAQBBQMIBQUBCAYGAQAKAAoACHp6AB0AFwAYABsAAwIAAgAQAA4ADAJoMghodHRwLzEuMUpKAAEAABUACQAAAAAAAAAAAA==", + "records": [ + { + "type": 22, + "version": 769, + "bytes": 512 + } + ], + "status": "captured-only", + "parsed": { + "bytes": 512, + "legacy_version": 771, + "session_id_bytes": 32, + "ciphers": [ + 56026, + 4865, + 4866, + 4867, + 49195, + 49199, + 49196, + 49200, + 52393, + 52392, + 49171, + 49172, + 156, + 157, + 47, + 53 + ], + "compression_methods": [ + 0 + ], + "extensions": [ + { + "type": 14906, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 65037, + "bytes": 186, + "hello_type": 0, + "kdf": 1, + "aead": 1, + "enc_bytes": 32, + "payload_bytes": 144 + }, + { + "type": 18, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 5, + "bytes": 5, + "payload_hex": "0100000000" + }, + { + "type": 35, + "bytes": 0, + "ticket_bytes": 0 + }, + { + "type": 0, + "bytes": 21, + "names": [ + { + "type": 0, + "name": "fingerprint.test" + } + ] + }, + { + "type": 17513, + "bytes": 5, + "protocols": [ + "h2" + ] + }, + { + "type": 11, + "bytes": 2, + "payload_hex": "0100" + }, + { + "type": 51, + "bytes": 43, + "shares": [ + { + "group": 31354, + "bytes": 1, + "grease_data": "00" + }, + { + "group": 29, + "bytes": 32 + } + ] + }, + { + "type": 45, + "bytes": 2, + "payload_hex": "0101" + }, + { + "type": 23, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 65281, + "bytes": 1, + "payload_hex": "00" + }, + { + "type": 43, + "bytes": 7, + "values": [ + 47802, + 772, + 771 + ] + }, + { + "type": 13, + "bytes": 18, + "values": [ + 1027, + 2052, + 1025, + 1283, + 2053, + 1281, + 2054, + 1537 + ] + }, + { + "type": 10, + "bytes": 10, + "values": [ + 31354, + 29, + 23, + 24 + ] + }, + { + "type": 27, + "bytes": 3, + "values": [ + 2 + ] + }, + { + "type": 16, + "bytes": 14, + "protocols": [ + "h2", + "http/1.1" + ] + }, + { + "type": 19018, + "bytes": 1, + "payload_hex": "00" + }, + { + "type": 21, + "bytes": 9, + "payload_hex": "000000000000000000" + } + ], + "raw_sha256": "6526b59ea7744aaaacaa9e6801ad58832d5959a5e0456ef7e82b79b35bbd014f" + } + }, + { + "case_id": "FINGERPRINT-REFERENCE-firefox-tcp-16-0", + "profile": "firefox", + "context": "tcp", + "template": "Firefox120", + "port": 24430, + "sequence": 34, + "client_hello_b64": "AQACjwMDqiFr9aeAnExTS/gilD64WFfgaCHQWhA6GKQxARkf9bggerEcuasmap/BA4RVj46mxmiZstMNhkcEH5bHv8cfznEAIhMBEwMTAsArwC/MqcyowCzAMMAKwAnAE8AUAJwAnQAvADUBAAIkAAAAFQATAAAQZmluZ2VycHJpbnQudGVzdAAXAAD/AQABAAAKAA4ADAAdABcAGAAZAQABAQALAAIBAAAjAAAAEAAOAAwCaDIIaHR0cC8xLjEABQAFAQAAAAAAIgAKAAgEAwUDBgMCAwAzAGsAaQAdACB2FTvB7f3bX4lKH6d6pEs7yMEa/TZDVIPthXNY4+HHfgAXAEEEalzCp+89drb5mDCkWiHDBqBKmW0gxmf6KSadVR33vRWaXwIX8G/MBmp+vHFBePH0DB0xxZxnJeUgZcazrG74hwArAAUEAwQDAwANABgAFgQDBQMGAwgECAUIBgQBBQEGAQIDAgEALQACAQEAHAACQAH+DQEZAAABAAMMACAEF1PdDkpDyn5QNL7Lj/i11jgPRitWJCCR10MwdmFKUADvyCe/GLgbdtlXDGcvbuNW9zrYUxT0zMRxx+F2glxSwOaq/BG46kxDTLqe26GVDhsnsQiv5uE4UIds2HnabD3RHnyAB7zmg2fOt/1x5POf+gpgadrqnxra5/gYyo29dy9ONzEhheFhtGu5HnzbmzNas3XUB/PiAIG1YWLIECA7WaugneZ92jZrHdoa8Wjtz8vU34Mt05fGR8p7goro3HHidj3CxdGoOInWdT9NaUTH7+BYN0ruJbi/gs/ifLDkQ6OXJbnqJCVCvrKnb+ZNL7dwpr1fpR1tJ9u14kz5eMQ1A711a724/Mj4xFvKLsnpX2M=", + "records_b64": "FgMBApMBAAKPAwOqIWv1p4CcTFNL+CKUPrhYV+BoIdBaEDoYpDEBGR/1uCB6sRy5qyZqn8EDhFWPjqbGaJmy0w2GRwQflse/xx/OcQAiEwETAxMCwCvAL8ypzKjALMAwwArACcATwBQAnACdAC8ANQEAAiQAAAAVABMAABBmaW5nZXJwcmludC50ZXN0ABcAAP8BAAEAAAoADgAMAB0AFwAYABkBAAEBAAsAAgEAACMAAAAQAA4ADAJoMghodHRwLzEuMQAFAAUBAAAAAAAiAAoACAQDBQMGAwIDADMAawBpAB0AIHYVO8Ht/dtfiUofp3qkSzvIwRr9NkNUg+2Fc1jj4cd+ABcAQQRqXMKn7z12tvmYMKRaIcMGoEqZbSDGZ/opJp1VHfe9FZpfAhfwb8wGan68cUF48fQMHTHFnGcl5SBlxrOsbviHACsABQQDBAMDAA0AGAAWBAMFAwYDCAQIBQgGBAEFAQYBAgMCAQAtAAIBAQAcAAJAAf4NARkAAAEAAwwAIAQXU90OSkPKflA0vsuP+LXWOA9GK1YkIJHXQzB2YUpQAO/IJ78YuBt22VcMZy9u41b3OthTFPTMxHHH4XaCXFLA5qr8EbjqTENMup7boZUOGyexCK/m4ThQh2zYedpsPdEefIAHvOaDZ863/XHk85/6CmBp2uqfGtrn+BjKjb13L043MSGF4WG0a7kefNubM1qzddQH8+IAgbVhYsgQIDtZq6Cd5n3aNmsd2hrxaO3Py9Tfgy3Tl8ZHynuCiujcceJ2PcLF0ag4idZ1P01pRMfv4Fg3Su4luL+Cz+J8sORDo5clueokJUK+sqdv5k0vt3CmvV+lHW0n27XiTPl4xDUDvXVrvbj8yPjEW8ouyelfYw==", + "records": [ + { + "type": 22, + "version": 769, + "bytes": 659 + } + ], + "status": "captured-only", + "parsed": { + "bytes": 659, + "legacy_version": 771, + "session_id_bytes": 32, + "ciphers": [ + 4865, + 4867, + 4866, + 49195, + 49199, + 52393, + 52392, + 49196, + 49200, + 49162, + 49161, + 49171, + 49172, + 156, + 157, + 47, + 53 + ], + "compression_methods": [ + 0 + ], + "extensions": [ + { + "type": 0, + "bytes": 21, + "names": [ + { + "type": 0, + "name": "fingerprint.test" + } + ] + }, + { + "type": 23, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 65281, + "bytes": 1, + "payload_hex": "00" + }, + { + "type": 10, + "bytes": 14, + "values": [ + 29, + 23, + 24, + 25, + 256, + 257 + ] + }, + { + "type": 11, + "bytes": 2, + "payload_hex": "0100" + }, + { + "type": 35, + "bytes": 0, + "ticket_bytes": 0 + }, + { + "type": 16, + "bytes": 14, + "protocols": [ + "h2", + "http/1.1" + ] + }, + { + "type": 5, + "bytes": 5, + "payload_hex": "0100000000" + }, + { + "type": 34, + "bytes": 10, + "values": [ + 1027, + 1283, + 1539, + 515 + ] + }, + { + "type": 51, + "bytes": 107, + "shares": [ + { + "group": 29, + "bytes": 32 + }, + { + "group": 23, + "bytes": 65, + "point_format": 4 + } + ] + }, + { + "type": 43, + "bytes": 5, + "values": [ + 772, + 771 + ] + }, + { + "type": 13, + "bytes": 24, + "values": [ + 1027, + 1283, + 1539, + 2052, + 2053, + 2054, + 1025, + 1281, + 1537, + 515, + 513 + ] + }, + { + "type": 45, + "bytes": 2, + "payload_hex": "0101" + }, + { + "type": 28, + "bytes": 2, + "payload_hex": "4001" + }, + { + "type": 65037, + "bytes": 281, + "hello_type": 0, + "kdf": 1, + "aead": 3, + "enc_bytes": 32, + "payload_bytes": 239 + } + ], + "raw_sha256": "6780a02d14dadda983299c6cfdb13b451dcd3af2f80f22f396aded6cd53e6220" + } + }, + { + "case_id": "FINGERPRINT-REFERENCE-firefox-reality-16-0", + "profile": "firefox", + "context": "reality", + "template": "Firefox120", + "port": 24430, + "sequence": 46, + "client_hello_b64": "AQACjwMD8nDP+fDwCjbjT15dbwGqrLItMf7FMzYnARyX3CCFuUwgIL9PGveZNjcUNE/c9Rbs6EFf8e3nAqeasvj3mMHWQQsAIhMBEwMTAsArwC/MqcyowCzAMMAKwAnAE8AUAJwAnQAvADUBAAIkAAAAFQATAAAQZmluZ2VycHJpbnQudGVzdAAXAAD/AQABAAAKAA4ADAAdABcAGAAZAQABAQALAAIBAAAjAAAAEAAOAAwCaDIIaHR0cC8xLjEABQAFAQAAAAAAIgAKAAgEAwUDBgMCAwAzAGsAaQAdACCwAQy4OYINaLECLRrHNCw0ShI3268BCI3tCuFuqlN/KwAXAEEEegE2eHH/D1588UY8aTsUI/V3U0147baH3rCQgS8E6ANjbhFjs8QjTW/ISjd8V0JfAnpLyfU1mnSwVVDSdUDFtQArAAUEAwQDAwANABgAFgQDBQMGAwgECAUIBgQBBQEGAQIDAgEALQACAQEAHAACQAH+DQEZAAABAAMNACB2lbAhwsalR1fjWyvzc5hRkzQFRcsj0wIl9wUp/a0yKADvf73hX9wKXRBZTjJ0hHTOfTisZqopgIYCAUr3r41qM3AhYRA/kX3vAFCB5Z5EChnt3//iNS1C5UeDCRcFTlAfyrpcML9uRQK/+920Yzsg395dBy6ze+lrl6+06s2CWbwJJh2tGamh3LmjVLZ+616OVB8KaydDmsEvOih+nqtyhz6+HFrFB7u+yt3KQ1zDb/5bv55n2FWb7cDPHqxE6iHQYSzshaa1fM08NzbgDeYNGaSmGeiTxByv8LPeDhvpls4o097E7V0Iuqg5cjX+EyzL4PuTPJbmqeQHVDeGrHRZ6+k4eR5w0E1dqaaJQICklz4=", + "records_b64": "FgMBApMBAAKPAwPycM/58PAKNuNPXl1vAaqssi0x/sUzNicBHJfcIIW5TCAgv08a95k2NxQ0T9z1FuzoQV/x7ecCp5qy+PeYwdZBCwAiEwETAxMCwCvAL8ypzKjALMAwwArACcATwBQAnACdAC8ANQEAAiQAAAAVABMAABBmaW5nZXJwcmludC50ZXN0ABcAAP8BAAEAAAoADgAMAB0AFwAYABkBAAEBAAsAAgEAACMAAAAQAA4ADAJoMghodHRwLzEuMQAFAAUBAAAAAAAiAAoACAQDBQMGAwIDADMAawBpAB0AILABDLg5gg1osQItGsc0LDRKEjfbrwEIje0K4W6qU38rABcAQQR6ATZ4cf8PXnzxRjxpOxQj9XdTTXjttofesJCBLwToA2NuEWOzxCNNb8hKN3xXQl8CekvJ9TWadLBVUNJ1QMW1ACsABQQDBAMDAA0AGAAWBAMFAwYDCAQIBQgGBAEFAQYBAgMCAQAtAAIBAQAcAAJAAf4NARkAAAEAAw0AIHaVsCHCxqVHV+NbK/NzmFGTNAVFyyPTAiX3BSn9rTIoAO9/veFf3ApdEFlOMnSEdM59OKxmqimAhgIBSvevjWozcCFhED+Rfe8AUIHlnkQKGe3f/+I1LULlR4MJFwVOUB/Kulwwv25FAr/73bRjOyDf3l0HLrN76WuXr7TqzYJZvAkmHa0ZqaHcuaNUtn7rXo5UHwprJ0OawS86KH6eq3KHPr4cWsUHu77K3cpDXMNv/lu/nmfYVZvtwM8erETqIdBhLOyFprV8zTw3NuAN5g0ZpKYZ6JPEHK/ws94OG+mWzijT3sTtXQi6qDlyNf4TLMvg+5M8luap5AdUN4asdFnr6Th5HnDQTV2ppolAgKSXPg==", + "records": [ + { + "type": 22, + "version": 769, + "bytes": 659 + } + ], + "status": "captured-only", + "parsed": { + "bytes": 659, + "legacy_version": 771, + "session_id_bytes": 32, + "ciphers": [ + 4865, + 4867, + 4866, + 49195, + 49199, + 52393, + 52392, + 49196, + 49200, + 49162, + 49161, + 49171, + 49172, + 156, + 157, + 47, + 53 + ], + "compression_methods": [ + 0 + ], + "extensions": [ + { + "type": 0, + "bytes": 21, + "names": [ + { + "type": 0, + "name": "fingerprint.test" + } + ] + }, + { + "type": 23, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 65281, + "bytes": 1, + "payload_hex": "00" + }, + { + "type": 10, + "bytes": 14, + "values": [ + 29, + 23, + 24, + 25, + 256, + 257 + ] + }, + { + "type": 11, + "bytes": 2, + "payload_hex": "0100" + }, + { + "type": 35, + "bytes": 0, + "ticket_bytes": 0 + }, + { + "type": 16, + "bytes": 14, + "protocols": [ + "h2", + "http/1.1" + ] + }, + { + "type": 5, + "bytes": 5, + "payload_hex": "0100000000" + }, + { + "type": 34, + "bytes": 10, + "values": [ + 1027, + 1283, + 1539, + 515 + ] + }, + { + "type": 51, + "bytes": 107, + "shares": [ + { + "group": 29, + "bytes": 32 + }, + { + "group": 23, + "bytes": 65, + "point_format": 4 + } + ] + }, + { + "type": 43, + "bytes": 5, + "values": [ + 772, + 771 + ] + }, + { + "type": 13, + "bytes": 24, + "values": [ + 1027, + 1283, + 1539, + 2052, + 2053, + 2054, + 1025, + 1281, + 1537, + 515, + 513 + ] + }, + { + "type": 45, + "bytes": 2, + "payload_hex": "0101" + }, + { + "type": 28, + "bytes": 2, + "payload_hex": "4001" + }, + { + "type": 65037, + "bytes": 281, + "hello_type": 0, + "kdf": 1, + "aead": 3, + "enc_bytes": 32, + "payload_bytes": 239 + } + ], + "raw_sha256": "3f7582e1e36b41682cf9954f32240a678dd8dcd216defa9120e4cda8a77e5d61" + } + }, + { + "case_id": "FINGERPRINT-REFERENCE-safari-tcp-16-0", + "profile": "safari", + "context": "tcp", + "template": "Safari16.0", + "port": 24430, + "sequence": 66, + "client_hello_b64": "AQAB/AMDtDp3Ct99nf+jPE1w1eAvrrxYaJwxc4R2g8KwDFp5I+sglE6F3C9Pb90EsEcO8n2dFfSAl1tHVzGHxxx8RjU78n0AKkpKEwETAhMDwCzAK8ypwDDAL8yowArACcAUwBMAnQCcADUAL8AIwBIACgEAAYm6ugAAAAAAFQATAAAQZmluZ2VycHJpbnQudGVzdAAXAAD/AQABAAAKAAwACqqqAB0AFwAYABkACwACAQAAEAAOAAwCaDIIaHR0cC8xLjEABQAFAQAAAAAADQAYABYEAwgEBAEFAwIDCAUIBQUBCAYGAQIBABIAAAAzACsAKaqqAAEAAB0AIEQYHgAPHKuKdVs5Cw0DfypzXmNpyUqyH256her3TbsIAC0AAgEBACsACwqKigMEAwMDAgMBABsAAwIAATo6AAEAABUAvgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "records_b64": "FgMBAgABAAH8AwO0OncK332d/6M8TXDV4C+uvFhonDFzhHaDwrAMWnkj6yCUToXcL09v3QSwRw7yfZ0V9ICXW0dXMYfHHHxGNTvyfQAqSkoTARMCEwPALMArzKnAMMAvzKjACsAJwBTAEwCdAJwANQAvwAjAEgAKAQABibq6AAAAAAAVABMAABBmaW5nZXJwcmludC50ZXN0ABcAAP8BAAEAAAoADAAKqqoAHQAXABgAGQALAAIBAAAQAA4ADAJoMghodHRwLzEuMQAFAAUBAAAAAAANABgAFgQDCAQEAQUDAgMIBQgFBQEIBgYBAgEAEgAAADMAKwApqqoAAQAAHQAgRBgeAA8cq4p1WzkLDQN/KnNeY2nJSrIfbnqF6vdNuwgALQACAQEAKwALCoqKAwQDAwMCAwEAGwADAgABOjoAAQAAFQC+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "records": [ + { + "type": 22, + "version": 769, + "bytes": 512 + } + ], + "status": "captured-only", + "parsed": { + "bytes": 512, + "legacy_version": 771, + "session_id_bytes": 32, + "ciphers": [ + 19018, + 4865, + 4866, + 4867, + 49196, + 49195, + 52393, + 49200, + 49199, + 52392, + 49162, + 49161, + 49172, + 49171, + 157, + 156, + 53, + 47, + 49160, + 49170, + 10 + ], + "compression_methods": [ + 0 + ], + "extensions": [ + { + "type": 47802, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 0, + "bytes": 21, + "names": [ + { + "type": 0, + "name": "fingerprint.test" + } + ] + }, + { + "type": 23, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 65281, + "bytes": 1, + "payload_hex": "00" + }, + { + "type": 10, + "bytes": 12, + "values": [ + 43690, + 29, + 23, + 24, + 25 + ] + }, + { + "type": 11, + "bytes": 2, + "payload_hex": "0100" + }, + { + "type": 16, + "bytes": 14, + "protocols": [ + "h2", + "http/1.1" + ] + }, + { + "type": 5, + "bytes": 5, + "payload_hex": "0100000000" + }, + { + "type": 13, + "bytes": 24, + "values": [ + 1027, + 2052, + 1025, + 1283, + 515, + 2053, + 2053, + 1281, + 2054, + 1537, + 513 + ] + }, + { + "type": 18, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 51, + "bytes": 43, + "shares": [ + { + "group": 43690, + "bytes": 1, + "grease_data": "00" + }, + { + "group": 29, + "bytes": 32 + } + ] + }, + { + "type": 45, + "bytes": 2, + "payload_hex": "0101" + }, + { + "type": 43, + "bytes": 11, + "values": [ + 35466, + 772, + 771, + 770, + 769 + ] + }, + { + "type": 27, + "bytes": 3, + "values": [ + 1 + ] + }, + { + "type": 14906, + "bytes": 1, + "payload_hex": "00" + }, + { + "type": 21, + "bytes": 190, + "payload_hex": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + ], + "raw_sha256": "f4a6a7f8fad25f919e4d2a8eb96befcde3d0df23bc4ff941e6250fbd7f8b539f" + } + }, + { + "case_id": "FINGERPRINT-REFERENCE-safari-reality-16-0", + "profile": "safari", + "context": "reality", + "template": "Safari16.0", + "port": 24430, + "sequence": 78, + "client_hello_b64": "AQAB/AMD/2PySojaNsOovsiizPyXIkays1xNhJVhWa7OzryT2lMg76NSvKOG/1kpntp7k+oGmyvzpJDN/gSaj/GNL0kjwrYAKsrKEwETAhMDwCzAK8ypwDDAL8yowArACcAUwBMAnQCcADUAL8AIwBIACgEAAYkqKgAAAAAAFQATAAAQZmluZ2VycHJpbnQudGVzdAAXAAD/AQABAAAKAAwACjo6AB0AFwAYABkACwACAQAAEAAOAAwCaDIIaHR0cC8xLjEABQAFAQAAAAAADQAYABYEAwgEBAEFAwIDCAUIBQUBCAYGAQIBABIAAAAzACsAKTo6AAEAAB0AIA0+wC1iWKdriCkbnhofrlNzxZG0bVCd5deuogXIboYBAC0AAgEBACsACwoqKgMEAwMDAgMBABsAAwIAAYqKAAEAABUAvgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "records_b64": "FgMBAgABAAH8AwP/Y/JKiNo2w6i+yKLM/JciRrKzXE2ElWFZrs7OvJPaUyDvo1K8o4b/WSme2nuT6gabK/OkkM3+BJqP8Y0vSSPCtgAqysoTARMCEwPALMArzKnAMMAvzKjACsAJwBTAEwCdAJwANQAvwAjAEgAKAQABiSoqAAAAAAAVABMAABBmaW5nZXJwcmludC50ZXN0ABcAAP8BAAEAAAoADAAKOjoAHQAXABgAGQALAAIBAAAQAA4ADAJoMghodHRwLzEuMQAFAAUBAAAAAAANABgAFgQDCAQEAQUDAgMIBQgFBQEIBgYBAgEAEgAAADMAKwApOjoAAQAAHQAgDT7ALWJYp2uIKRueGh+uU3PFkbRtUJ3l166iBchuhgEALQACAQEAKwALCioqAwQDAwMCAwEAGwADAgABiooAAQAAFQC+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", + "records": [ + { + "type": 22, + "version": 769, + "bytes": 512 + } + ], + "status": "captured-only", + "parsed": { + "bytes": 512, + "legacy_version": 771, + "session_id_bytes": 32, + "ciphers": [ + 51914, + 4865, + 4866, + 4867, + 49196, + 49195, + 52393, + 49200, + 49199, + 52392, + 49162, + 49161, + 49172, + 49171, + 157, + 156, + 53, + 47, + 49160, + 49170, + 10 + ], + "compression_methods": [ + 0 + ], + "extensions": [ + { + "type": 10794, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 0, + "bytes": 21, + "names": [ + { + "type": 0, + "name": "fingerprint.test" + } + ] + }, + { + "type": 23, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 65281, + "bytes": 1, + "payload_hex": "00" + }, + { + "type": 10, + "bytes": 12, + "values": [ + 14906, + 29, + 23, + 24, + 25 + ] + }, + { + "type": 11, + "bytes": 2, + "payload_hex": "0100" + }, + { + "type": 16, + "bytes": 14, + "protocols": [ + "h2", + "http/1.1" + ] + }, + { + "type": 5, + "bytes": 5, + "payload_hex": "0100000000" + }, + { + "type": 13, + "bytes": 24, + "values": [ + 1027, + 2052, + 1025, + 1283, + 515, + 2053, + 2053, + 1281, + 2054, + 1537, + 513 + ] + }, + { + "type": 18, + "bytes": 0, + "payload_hex": "" + }, + { + "type": 51, + "bytes": 43, + "shares": [ + { + "group": 14906, + "bytes": 1, + "grease_data": "00" + }, + { + "group": 29, + "bytes": 32 + } + ] + }, + { + "type": 45, + "bytes": 2, + "payload_hex": "0101" + }, + { + "type": 43, + "bytes": 11, + "values": [ + 10794, + 772, + 771, + 770, + 769 + ] + }, + { + "type": 27, + "bytes": 3, + "values": [ + 1 + ] + }, + { + "type": 35466, + "bytes": 1, + "payload_hex": "00" + }, + { + "type": 21, + "bytes": 190, + "payload_hex": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + ], + "raw_sha256": "ca80ca85d45a87187c0afba988e5dd5336772409338f7140e52bb0955290d98c" + } + } + ] +} diff --git a/tests/grpc_pool.rs b/tests/grpc_pool.rs new file mode 100644 index 0000000..619f764 --- /dev/null +++ b/tests/grpc_pool.rs @@ -0,0 +1,235 @@ +#![cfg(feature = "stream-transport")] +use std::{ + io, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_util::task::TaskTracker; +use vcore::{config::GrpcOptions, dispatch::BoxStream, transport::GrpcPool}; + +struct ObservedIo { + io: tokio::io::DuplexStream, + read: Arc>>, +} +impl tokio::io::AsyncRead for ObservedIo { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + let start = buf.filled().len(); + let result = std::pin::Pin::new(&mut self.io).poll_read(cx, buf); + let mut read = self.read.lock().unwrap(); + read.extend_from_slice(&buf.filled()[start..]); + assert!(read.len() < 65536); + result + } +} +impl tokio::io::AsyncWrite for ObservedIo { + fn poll_write( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.io).poll_write(cx, buf) + } + fn poll_flush( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.io).poll_flush(cx) + } + fn poll_shutdown( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(&mut self.io).poll_shutdown(cx) + } +} +fn ping_count(wire: &[u8]) -> usize { + assert!(wire.starts_with(b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n")); + let mut pos = 24; + let mut pings = 0; + while pos + 9 <= wire.len() { + let len = + ((wire[pos] as usize) << 16) | ((wire[pos + 1] as usize) << 8) | wire[pos + 2] as usize; + assert!(pos + 9 + len <= wire.len(), "partial observed frame"); + if wire[pos + 3] == 6 && wire[pos + 4] & 1 == 0 { + pings += 1; + } + pos += 9 + len; + } + pings +} +#[tokio::test] +async fn grpc_idle_ping_is_observable_disabled_by_zero_and_joined_at_stop() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "VLESS-UNIT", + "grpc_idle_ping_is_observable_disabled_by_zero_and_joined_at_stop", + ); + for interval in [0, 1] { + let (io, peer) = tokio::io::duplex(65536); + let observed = Arc::new(std::sync::Mutex::new(Vec::new())); + let peer = ObservedIo { + io: peer, + read: observed.clone(), + }; + let server = tokio::spawn(async move { + let mut connection = h2::server::handshake(peer).await.unwrap(); + while let Some(Ok((_request, mut respond))) = connection.accept().await { + let _ = respond.send_response( + http::Response::builder() + .status(200) + .header("content-type", "application/grpc") + .body(()) + .unwrap(), + true, + ); + } + }); + let pool = GrpcPool::new(GrpcOptions { + ping_interval: interval, + ..Default::default() + }); + let stream = pool + .open( + "http://example.com/service/Tun", + tokio::time::Instant::now() + Duration::from_secs(2), + || async { Ok(Box::new(io) as BoxStream) }, + ) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(1250)).await; + let pings = ping_count(&observed.lock().unwrap()); + assert_eq!(pings > 0, interval > 0); + pool.shutdown().await; + drop(stream); + server.await.unwrap(); + let length = observed.lock().unwrap().len(); + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!(observed.lock().unwrap().len(), length); + } +} + +async fn memory_peer(count: Arc, tasks: TaskTracker) -> io::Result { + count.fetch_add(1, Ordering::SeqCst); + let (io, peer) = tokio::io::duplex(64 * 1024); + let sessions = tasks.clone(); + tasks.spawn(async move { + let mut connection = h2::server::handshake(peer).await.unwrap(); + while let Some(Ok((request, mut response))) = connection.accept().await { + assert_eq!(request.method(), "POST"); + assert_eq!(request.uri().path(), "/service/Tun"); + assert_eq!(request.headers()["user-agent"], "vless-observer"); + let mut send = response + .send_response( + http::Response::builder() + .status(200) + .header("content-type", "application/grpc") + .body(()) + .unwrap(), + false, + ) + .unwrap(); + let mut receive = request.into_body(); + sessions.spawn(async move { + while let Some(Ok(data)) = receive.data().await { + receive.flow_control().release_capacity(data.len()).unwrap(); + if send.send_data(data, false).is_err() { + break; + } + } + }); + } + }); + Ok(Box::new(io)) +} +#[tokio::test] +async fn grpc_pool_matches_both_threshold_policies_and_keeps_other_streams_alive() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "VLESS-UNIT", + "grpc_pool_matches_both_threshold_policies_and_keeps_other_streams_alive", + ); + tokio::time::timeout(Duration::from_secs(8), async { + for (max, min, streams, expected) in [ + (1, 0, 0, vec![1, 1, 1, 1]), + (2, 2, 0, vec![1, 1, 2, 2]), + (2, 0, 0, vec![1, 2, 2, 2]), + (0, 0, 2, vec![1, 1, 2, 2, 3]), + (0, 2, 0, vec![1, 2, 3, 4]), + ] { + let tasks = TaskTracker::new(); + let count = Arc::new(AtomicUsize::new(0)); + let pool = GrpcPool::new(GrpcOptions { + user_agent: "vless-observer".into(), + max_connections: max, + min_streams: min, + max_streams: streams, + ..Default::default() + }); + let mut open = Vec::new(); + for expected in expected { + let mut stream = pool + .open( + "http://example.com/service/Tun", + tokio::time::Instant::now() + Duration::from_secs(2), + || memory_peer(count.clone(), tasks.clone()), + ) + .await + .unwrap(); + stream.write_all(b"roundtrip").await.unwrap(); + stream.flush().await.unwrap(); + let mut data = [0; 9]; + stream.read_exact(&mut data).await.unwrap(); + assert_eq!(&data, b"roundtrip"); + assert_eq!(count.load(Ordering::SeqCst), expected); + open.push(stream); + } + open[0].shutdown().await.unwrap(); + open.last_mut() + .unwrap() + .write_all(b"still-open") + .await + .unwrap(); + open.last_mut().unwrap().flush().await.unwrap(); + let mut data = [0; 10]; + open.last_mut() + .unwrap() + .read_exact(&mut data) + .await + .unwrap(); + assert_eq!(&data, b"still-open"); + open.clear(); + let before = count.load(Ordering::SeqCst); + let mut stream = pool + .open( + "http://example.com/service/Tun", + tokio::time::Instant::now() + Duration::from_secs(2), + || memory_peer(count.clone(), tasks.clone()), + ) + .await + .unwrap(); + assert_eq!( + before, + count.load(Ordering::SeqCst), + "idle connection must always be reused" + ); + pool.shutdown().await; + assert!( + stream.write_all(b"after-stop").await.is_err(), + "stopped pool accepted a write" + ); + drop(stream); + tasks.close(); + tasks.wait().await; + } + }) + .await + .unwrap(); +} diff --git a/tests/h2_stream_regression.rs b/tests/h2_stream_regression.rs new file mode 100644 index 0000000..ec94e11 --- /dev/null +++ b/tests/h2_stream_regression.rs @@ -0,0 +1,99 @@ +//! Public XHTTP stream regressions for the locked, unmodified h2 dependency. +//! The minimal wire peer is an adversarial fixture, not native interoperability. + +#![cfg(feature = "outbound-vless")] + +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use vcore::transport::xhttp::{XHttpClient, XHttpConfig, XHttpMode}; + +fn frame(kind: u8, flags: u8, stream: u32, payload: &[u8]) -> Vec { + let mut output = (payload.len() as u32).to_be_bytes()[1..].to_vec(); + output.extend_from_slice(&[kind, flags]); + output.extend_from_slice(&stream.to_be_bytes()); + output.extend_from_slice(payload); + output +} + +async fn response_followed_by_reset(end_stream: bool) -> std::io::Result> { + let (client_io, mut peer_io) = tokio::io::duplex(4096); + let peer = tokio::spawn(async move { + let mut preface = [0; 24]; + peer_io.read_exact(&mut preface).await.unwrap(); + assert_eq!(&preface, b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"); + peer_io.write_all(&frame(4, 0, 0, &[])).await.unwrap(); + let stream_id = loop { + let mut header = [0; 9]; + peer_io.read_exact(&mut header).await.unwrap(); + let length = u32::from_be_bytes([0, header[0], header[1], header[2]]) as usize; + assert!(length <= 16384); + let mut payload = vec![0; length]; + peer_io.read_exact(&mut payload).await.unwrap(); + if header[3] == 4 && header[4] & 1 == 0 { + peer_io.write_all(&frame(4, 1, 0, &[])).await.unwrap(); + } + if header[3] == 1 { + break u32::from_be_bytes(header[5..9].try_into().unwrap()); + } + }; + // HPACK indexed :status 200, then a complete response, then CANCEL. + // Put them in one write so the client driver receives the entire order + // before the application drains the DATA. No third-party internals. + let mut response = frame(1, 4, stream_id, &[0x88]); + response.extend(frame( + 0, + u8::from(end_stream), + stream_id, + b"complete-before-reset", + )); + response.extend(frame(3, 0, stream_id, &8_u32.to_be_bytes())); + peer_io.write_all(&response).await.unwrap(); + let mut remaining = Vec::new(); + peer_io.read_to_end(&mut remaining).await.unwrap(); + }); + let client = XHttpClient::new( + XHttpConfig::new("fixture.invalid", "/foundations", XHttpMode::StreamOne).unwrap(), + ); + let mut stream = client.connect(Box::new(client_io)).await.unwrap(); + let mut response = Vec::new(); + let result = + tokio::time::timeout(Duration::from_secs(2), stream.read_to_end(&mut response)).await; + drop(stream); + tokio::time::timeout(Duration::from_secs(2), peer) + .await + .unwrap() + .unwrap(); + result.expect("response read stalled")?; + Ok(response) +} + +#[tokio::test] +async fn xhttp_keeps_complete_response_when_peer_resets_after_end_stream() { + #[cfg(feature = "interop-test")] + let _evidence = vcore::resources::case_events::Case::new( + "VMESS-REGRESSION", + "xhttp_keeps_complete_response_when_peer_resets_after_end_stream", + ); + let response = response_followed_by_reset(true) + .await + .expect("END_STREAM must retain complete DATA despite a subsequent reset"); + assert_eq!(response, b"complete-before-reset"); +} + +#[tokio::test] +async fn xhttp_does_not_hide_reset_before_end_stream() { + #[cfg(feature = "interop-test")] + let _evidence = vcore::resources::case_events::Case::new( + "VMESS-REGRESSION", + "xhttp_does_not_hide_reset_before_end_stream", + ); + let error = response_followed_by_reset(false) + .await + .expect_err("a reset before END_STREAM must not become successful EOF"); + let reset = error + .get_ref() + .and_then(|source| source.downcast_ref::()) + .expect("the HTTP/2 reset must be preserved"); + assert_eq!(reset.reason(), Some(h2::Reason::CANCEL)); +} diff --git a/tests/hysteria2_config.rs b/tests/hysteria2_config.rs new file mode 100644 index 0000000..721474b --- /dev/null +++ b/tests/hysteria2_config.rs @@ -0,0 +1,232 @@ +use vcore::config::Config; + +#[test] +fn hysteria2_configuration_follows_its_protocol_feature() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "HYSTERIA2-CFG", + "hysteria2_configuration_follows_its_protocol_feature", + ); + let yaml = b"socks-port: 1080\nproxies: [{name: edge, type: hysteria2, server: localhost, port: 443}]\nrules: ['MATCH,edge']\n"; + let result = Config::parse_yaml(yaml); + assert_eq!(result.is_ok(), cfg!(feature = "outbound-hysteria2")); + if let Ok(config) = result { + assert_eq!(config.proxies[0].address(), "localhost"); + assert_eq!(config.proxies[0].port(), 443); + assert!(!config.proxies[0].udp); + } +} + +#[cfg(feature = "outbound-hysteria2")] +#[test] +fn hysteria2_accepts_the_approved_tls_bandwidth_obfs_and_hopping_configuration() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "HYSTERIA2-CFG", + "hysteria2_accepts_the_approved_tls_bandwidth_obfs_and_hopping_configuration", + ); + let yaml = b"socks-port: 1080\nproxies:\n - name: edge\n type: hysteria2\n server: localhost\n ports: 443,8443-8445,8443\n password: ' credential '\n udp: true\n up: 2 Mbps\n down: 125 KBps\n udp-mtu: 1200\n hop-interval: 5-7\n obfs: salamander\n obfs-password: ' independent secret '\n sni: tls.example.com\n alpn: [custom-h3]\n skip-cert-verify: true\n dialer-proxy: upstream\nproxy-groups: [{name: upstream, type: select, proxies: [DIRECT, REJECT]}]\nrules: ['MATCH,edge']\n"; + let config = Config::parse_yaml(yaml).expect("approved Hysteria2 fields"); + assert_eq!(config.proxies[0].port(), 443); + assert!(config.proxies[0].udp); + assert!(config.proxies[0].dialer_proxy.is_some()); + assert!(!format!("{config:?}").contains("credential")); + assert!(!format!("{config:?}").contains("independent secret")); +} + +#[cfg(feature = "outbound-hysteria2")] +#[test] +fn hysteria2_fields_are_strict_normalized_and_credentials_are_not_trimmed() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "HYSTERIA2-CFG", + "hysteria2_fields_are_strict_normalized_and_credentials_are_not_trimmed", + ); + use serde_json::{Value, json}; + use vcore::config::ProxyProtocol; + let base = json!({"name":"edge", "type":"hysteria2", "server":"localhost", "port":443}); + let parse = |raw: Value| { + Config::parse_yaml( + json!({"socks-port":1080, "proxies":[raw], "rules":["MATCH,edge"]}) + .to_string() + .as_bytes(), + ) + }; + let normalized = |raw: Value| { + let config = parse(raw).unwrap(); + let ProxyProtocol::Hysteria2(node) = config.proxies.into_iter().next().unwrap().protocol + else { + panic!() + }; + node + }; + let defaults = normalized(base.clone()); + for field in [ + "name", + "server", + "port", + "password", + "udp", + "sni", + "alpn", + "skip-cert-verify", + "fingerprint", + "certificate", + "private-key", + "up", + "down", + "udp-mtu", + "obfs", + "obfs-password", + "ports", + "hop-interval", + "dialer-proxy", + ] { + for wrong in [Value::Null, json!({"invalid":"type"})] { + let mut raw = base.clone(); + raw[field] = wrong; + assert!( + parse(raw).is_err(), + "{field} must reject null and object types" + ); + } + } + assert_eq!((defaults.up, defaults.down, defaults.udp_mtu), (0, 0, 1197)); + assert_eq!(defaults.password, ""); + assert_eq!(defaults.tls.server_name, "localhost"); + assert_eq!(defaults.tls.alpn, vec![b"h3".to_vec()]); + assert!(defaults.tls.tls13_only); + for (value, expected) in [ + (json!(2), 250000), + (json!("2"), 250000), + (json!("2 Mbps"), 250000), + (json!("125 KBps"), 125000), + (json!("1Gbps"), 125000000), + (json!("1 TBps"), 1000000000000), + (json!("8 bps"), 1), + (json!("1 bps"), 0), + (json!(0), 0), + ] { + for field in ["up", "down"] { + let mut raw = base.clone(); + raw[field] = value.clone(); + let node = normalized(raw); + assert_eq!(if field == "up" { node.up } else { node.down }, expected); + } + } + let mut raw = base.clone(); + raw["ports"] = json!("8443-8445,443,8444"); + raw["hop-interval"] = json!("5-7"); + raw["password"] = json!(" raw credential "); + raw["udp-mtu"] = json!(0); + raw["alpn"] = json!([]); + let node = normalized(raw.clone()); + assert_eq!(node.password, " raw credential "); + assert_eq!(node.port, 443); + assert_eq!(node.udp_mtu, 1197); + let hopping = node.hopping.unwrap(); + assert_eq!(hopping.ports, vec![443, 8443, 8444, 8445]); + assert_eq!((hopping.min_seconds, hopping.max_seconds), (5, 7)); + raw.as_object_mut().unwrap().remove("port"); + assert!(parse(raw.clone()).is_ok()); + for interval in [json!(5), json!("5"), json!("4294967295")] { + raw["hop-interval"] = interval; + assert!(parse(raw.clone()).is_ok()); + } + for mtu in [64, 65535] { + let mut raw = base.clone(); + raw["udp-mtu"] = json!(mtu); + assert!(parse(raw).is_ok()); + } + for (field, values) in [ + ( + "up", + vec![ + json!(-1), + json!(1.5), + json!("1.5 Mbps"), + json!("+1 Mbps"), + json!("1 MiBps"), + json!("18446744073709551615 TBps"), + Value::Null, + ], + ), + ( + "udp-mtu", + vec![json!(1), json!(63), json!(65536), json!(-1), Value::Null], + ), + ( + "ports", + vec![ + json!(""), + json!("1,,2"), + json!("2-1"), + json!("0"), + json!("65536"), + json!("*"), + json!([443]), + Value::Null, + ], + ), + ( + "alpn", + vec![ + json!([""]), + json!(["a".repeat(256)]), + json!("h3"), + Value::Null, + ], + ), + ( + "password", + vec![ + json!("bad\r\nheader"), + json!("a".repeat(8193)), + json!(12), + Value::Null, + ], + ), + ("port", vec![json!(0), json!(65536), Value::Null]), + ("fingerprint", vec![json!("not-a-hash"), Value::Null]), + ] { + for value in values { + let mut raw = base.clone(); + raw[field] = value; + assert!(parse(raw).is_err(), "{field}"); + } + } + for (field, value) in [ + ("tls", json!(false)), + ("network", json!("tcp")), + ("client-fingerprint", json!("chrome")), + ("name-cert-verify", json!("localhost")), + ("certificate", json!("bad")), + ("private-key", json!("bad")), + ("obfs", json!("salamander")), + ("obfs-password", json!("secret")), + ("hop-interval", json!(5)), + ] { + let mut raw = base.clone(); + raw[field] = value; + assert!(parse(raw).is_err(), "{field}"); + } + for value in [ + json!(0), + json!(4), + json!("7-5"), + json!("5s"), + json!("4294967296"), + Value::Null, + ] { + let mut raw = base.clone(); + raw["ports"] = json!("443"); + raw["hop-interval"] = value; + assert!(parse(raw).is_err()); + } + for value in [json!(""), json!("gecko")] { + let mut raw = base.clone(); + raw["obfs"] = value; + raw["obfs-password"] = json!("secret"); + assert!(parse(raw).is_err()); + } +} diff --git a/tests/hysteria2_native.rs b/tests/hysteria2_native.rs new file mode 100644 index 0000000..bb46756 --- /dev/null +++ b/tests/hysteria2_native.rs @@ -0,0 +1,816 @@ +#![cfg(all(feature = "outbound-hysteria2", feature = "interop-test"))] +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{net::SocketAddr, time::Duration}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use vcore::{ + config::{Config, ProxyProtocol}, + dialer::{Dialer, ResolvedEndpoint}, + outbound::{EstablishContext, OutboundConnector, UpstreamPath, hysteria2::Hysteria2Outbound}, + session::{InboundKind, StreamSession}, +}; + +fn fixture() -> Value { + let value: Value = serde_json::from_slice( + &std::fs::read(std::env::var("VCORE_VLESS_INPUT").unwrap()).unwrap(), + ) + .unwrap(); + assert_eq!(value["isolation"], "containers"); + value +} +fn node(raw: Value) -> Hysteria2Outbound { + node_with_dialer(raw, Dialer::default()) +} +fn node_with_dialer(raw: Value, dialer: Dialer) -> Hysteria2Outbound { + let config = Config::parse_yaml( + json!({"socks-port":1080, "proxies":[raw], "rules":["MATCH,peer"]}) + .to_string() + .as_bytes(), + ) + .unwrap(); + let ProxyProtocol::Hysteria2(node) = &config.proxies[0].protocol else { + panic!("wrong protocol") + }; + Hysteria2Outbound::new_with_path( + node, + UpstreamPath::direct( + ResolvedEndpoint { + logical_host: node.address.clone(), + port: node.port, + addresses: vec![SocketAddr::new(node.address.parse().unwrap(), node.port)], + }, + dialer, + ), + ) + .unwrap() +} + +struct Protector { + count: std::sync::atomic::AtomicUsize, + reject_after: usize, +} +impl vcore::dialer::SocketProtector for Protector { + fn protect(&self, _: i32) -> std::io::Result<()> { + let index = self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if index >= self.reject_after { + return Err(std::io::ErrorKind::PermissionDenied.into()); + } + Ok(()) + } +} +async fn origin(f: &Value, mode: u8, ipv6: bool) -> (tokio::net::TcpStream, SocketAddr) { + let mut control = tokio::net::TcpStream::connect(f["origin_control"].as_str().unwrap()) + .await + .unwrap(); + control + .write_u8(if mode >= 10 && ipv6 { mode | 128 } else { mode }) + .await + .unwrap(); + let port = control.read_u16().await.unwrap(); + ( + control, + SocketAddr::new( + f[if ipv6 { "origin_ipv6" } else { "origin_ipv4" }] + .as_str() + .unwrap() + .parse() + .unwrap(), + port, + ), + ) +} +fn stream_request(target: SocketAddr) -> StreamSession { + StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:1".parse().unwrap(), + destination: target.into(), + sniffed_domain: None, + } +} +fn datagram_request() -> vcore::outbound::DatagramRequest { + vcore::outbound::DatagramRequest::new(vcore::session::DatagramSession::new( + InboundKind::InternalMeasure, + "127.0.0.1:1".parse().unwrap(), + )) +} + +#[tokio::test] +#[ignore = "official Hysteria in an owned NET_ADMIN container required"] +async fn native_hopping() { + use std::sync::{Arc, atomic::Ordering}; + use vcore::resources::observation::{ResourceKind, ResourceProbe}; + let mut event = vcore::resources::case_events::Case::new("HYSTERIA2-HOP", "native_hopping"); + let f = fixture(); + let probe = ResourceProbe::default(); + probe + .scope(async { + let protector = Arc::new(Protector { + count: 0.into(), + reject_after: usize::MAX, + }); + let outbound = node_with_dialer( + f["node"].clone(), + Dialer::default().with_protector(protector.clone()), + ); + let (mut tcp_control, target) = origin(&f, 13, false).await; + let mut stream = outbound + .connect_stream(stream_request(target), &EstablishContext::default()) + .await + .unwrap() + .io; + assert_eq!(tcp_control.read_u8().await.unwrap(), b'A'); + let mut associations = Vec::new(); + for (ipv6, domain) in [(false, false), (true, false), (false, true)] { + let (control, target) = origin(&f, if ipv6 { 6 } else { 4 }, ipv6).await; + let remote = if domain { + vcore::session::Destination::domain("vcore-fixture.test", target.port()) + .unwrap() + } else { + target.into() + }; + let udp = outbound + .open_datagram(datagram_request(), &EstablishContext::default()) + .await + .unwrap(); + // Native Hysteria's response buffer includes the protocol header. + // Mihomo separately passes the full 4096-byte application payload. + let peer_maximum = 4096 - 9 - target.to_string().len(); + associations.push((control, target, remote, peer_maximum, udp)); + } + let started = tokio::time::Instant::now(); + let mut sent_hash = Sha256::new(); + let mut recv_hash = Sha256::new(); + let mut bytes = vec![0; SIZE]; + for sequence in 0..1500 { + let payload = record(sequence); + tokio::time::timeout(Duration::from_secs(5), async { + stream.write_all(&payload).await.unwrap(); + stream.read_exact(&mut bytes).await.unwrap(); + }) + .await + .unwrap(); + sent_hash.update(&payload); + recv_hash.update(&bytes); + let (udp_control, udp_target, remote, peer_maximum, udp) = + &mut associations[sequence as usize % 3]; + let size = [1, 64, 512, 1200, *peer_maximum][sequence as usize / 3 % 5]; + let payload = bytes::Bytes::from(vec![(sequence % 251) as u8; size]); + udp.send(vcore::session::Datagram { + remote: remote.clone(), + payload: payload.clone(), + sniffed_domain: None, + }) + .await + .unwrap(); + let reply = tokio::time::timeout(Duration::from_secs(5), udp.receive()) + .await + .unwrap_or_else(|_| { + panic!("UDP response deadline: sequence={sequence}, size={size}") + }) + .unwrap(); + assert_eq!(reply.remote, (*udp_target).into()); + assert_eq!(reply.payload, payload); + assert_eq!(udp_control.read_u16().await.unwrap() as usize, size); + assert_ne!(udp_control.read_u16().await.unwrap(), 0); + let mut observed = vec![0; size]; + udp_control.read_exact(&mut observed).await.unwrap(); + assert_eq!(observed, payload); + if started.elapsed() > Duration::from_secs(9) { + assert!( + protector.count.load(Ordering::SeqCst) > 1, + "port hopping has not created a protected new socket" + ); + } + tokio::time::sleep_until(started + Duration::from_millis((sequence + 1) * 40)) + .await; + } + assert!(started.elapsed() >= Duration::from_secs(60)); + let digest = sent_hash.finalize(); + assert_eq!(digest, recv_hash.finalize()); + assert!(protector.count.load(Ordering::SeqCst) >= 9); + if let Ok(path) = std::env::var("VCORE_HOP_OBSERVATIONS") { + let hash: String = digest.iter().map(|byte| format!("{byte:02x}")).collect(); + let data = json!({"seconds":started.elapsed().as_secs_f64(),"protected_sockets":protector.count.load(Ordering::SeqCst),"tcp_bytes_per_direction":1500*SIZE,"tcp_sha256":hash,"udp_packets":1500,"udp_target_kinds":3,"udp_maximums":associations.iter().map(|v| v.3).collect::>(),"socket_peak":probe.snapshot().peak(ResourceKind::Socket)}); + std::fs::write(path, data.to_string()).unwrap(); + } + assert!( + probe.snapshot().peak(ResourceKind::Socket) <= 2, + "old socket not bounded" + ); + for (mut observer, _, remote, maximum, mut udp) in associations { + // Preserve the native peer limitation as an explicit observation: + // max+1 reaches the origin but its reply is silently discarded. + let payload = bytes::Bytes::from(vec![9; maximum + 1]); + udp.send(vcore::session::Datagram { + remote, + payload: payload.clone(), + sniffed_domain: None, + }) + .await + .unwrap(); + assert_eq!(observer.read_u16().await.unwrap() as usize, maximum + 1); + assert_ne!(observer.read_u16().await.unwrap(), 0); + let mut observed = vec![0; maximum + 1]; + observer.read_exact(&mut observed).await.unwrap(); + assert_eq!(observed, payload); + assert!( + tokio::time::timeout(Duration::from_millis(150), udp.receive()) + .await + .is_err() + ); + udp.close().await.unwrap(); + } + drop(stream); + tokio::time::timeout(Duration::from_secs(5), outbound.shutdown()) + .await + .unwrap(); + drop(outbound); + assert!(probe.snapshot().is_idle(), "{:?}", probe.snapshot()); + tokio::time::sleep(Duration::from_secs(5)).await; + assert!(probe.snapshot().is_idle()); + }) + .await; + event.resources(probe.snapshot()); +} +const SIZE: usize = 65536; +fn record(sequence: u64) -> Vec { + let mut bytes = vec![(sequence % 251) as u8; SIZE]; + bytes[..8].copy_from_slice(&sequence.to_be_bytes()); + bytes +} + +async fn bandwidth(f: &Value, direction: u8, up: u64, down: u64, port: u16, expected: u64) -> f64 { + let mut raw = f["node"].clone(); + raw["up"] = json!(up); + raw["down"] = json!(down); + raw["port"] = json!(port); + let outbound = node(raw); + let mut control = tokio::net::TcpStream::connect(f["bandwidth_control"].as_str().unwrap()) + .await + .unwrap(); + control.write_u8(direction).await.unwrap(); + let target_port = control.read_u16().await.unwrap(); + let mut stream = outbound + .connect_stream( + StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:1".parse().unwrap(), + destination: SocketAddr::new( + f["bandwidth_ipv4"].as_str().unwrap().parse().unwrap(), + target_port, + ) + .into(), + sniffed_domain: None, + }, + &EstablishContext::default(), + ) + .await + .unwrap() + .io; + let mut hello = [0; 5]; + stream.read_exact(&mut hello).await.unwrap(); + assert_eq!(&hello, b"ready"); + assert_eq!(outbound.congestion_observation().await.unwrap().0, expected); + let before = outbound.congestion_observation().await.unwrap().1; + let observation = tokio::spawn(async move { + let mut buckets = [0_u64; 30]; + let mut last = 0; + loop { + match control.read_u8().await.unwrap() { + b'P' => { + let sequence = control.read_u64().await.unwrap(); + assert_eq!(sequence, last + 1); + last = sequence; + let second = control.read_u64().await.unwrap() / 1_000_000_000; + if (10..40).contains(&second) { + buckets[(second - 10) as usize] += SIZE as u64; + } + } + b'D' => { + let sequence = control.read_u64().await.unwrap(); + let mut digest = [0; 32]; + control.read_exact(&mut digest).await.unwrap(); + return (buckets, sequence, digest); + } + _ => panic!("invalid origin observation"), + } + } + }); + let started = tokio::time::Instant::now(); + let mut digest = Sha256::new(); + let mut sequence = 0; + let mut download_buckets = [0_u64; 30]; + if direction == b'U' { + while started.elapsed() < Duration::from_secs(40) { + let bytes = record(sequence); + stream.write_all(&bytes).await.unwrap(); + digest.update(&bytes); + sequence += 1; + } + stream.write_all(&record(u64::MAX)).await.unwrap(); + let mut done = [0; 5]; + stream.read_exact(&mut done).await.unwrap(); + assert_eq!(&done, b"done!"); + } else { + stream.write_u8(b'S').await.unwrap(); + let mut stopped = false; + let mut bytes = vec![0; SIZE]; + loop { + stream.read_exact(&mut bytes).await.unwrap(); + let number = u64::from_be_bytes(bytes[..8].try_into().unwrap()); + if number == u64::MAX { + break; + } + assert_eq!(bytes, record(sequence)); + digest.update(&bytes); + sequence += 1; + let second = started.elapsed().as_secs(); + if (10..40).contains(&second) { + download_buckets[(second - 10) as usize] += SIZE as u64; + } + if second >= 40 && !stopped { + stream.write_u8(b'X').await.unwrap(); + stopped = true; + } + } + } + let (upload_buckets, received, remote_digest) = observation.await.unwrap(); + assert_eq!(received, sequence); + assert_eq!(digest.finalize().as_slice(), remote_digest); + let after = outbound.congestion_observation().await.unwrap().1; + let buckets = if direction == b'U' { + upload_buckets + } else { + download_buckets + }; + let throughput = buckets.iter().sum::() as f64 / 30.0; + let evidence = json!({"direction":char::from(direction), "up_mbps":up, "down_mbps":down, + "listener_port":port,"controller_bps":expected,"payload_bps":throughput,"buckets":buckets, + "udp_tx_bytes":after.udp_tx.bytes - before.udp_tx.bytes,"udp_rx_bytes":after.udp_rx.bytes - before.udp_rx.bytes, + "rtt_ms":after.path.rtt.as_secs_f64()*1000.0,"cwnd":after.path.cwnd,"lost_packets":after.path.lost_packets}); + println!("HYSTERIA2-BANDWIDTH {evidence}"); + if let Ok(path) = std::env::var("VCORE_BANDWIDTH_OBSERVATIONS") { + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + writeln!(file, "{evidence}").unwrap(); + file.flush().unwrap(); + } + drop(stream); + tokio::time::timeout(Duration::from_secs(5), outbound.shutdown()) + .await + .unwrap(); + throughput +} + +#[tokio::test] +#[ignore = "owned isolated HYSTERIA2 peers required"] +async fn native_bandwidth_matrix() { + let _case = + vcore::resources::case_events::Case::new("HYSTERIA2-BANDWIDTH", "native_bandwidth_matrix"); + let f = fixture(); + for direction in *b"UD" { + let baseline = bandwidth(&f, direction, 0, 0, 23000, 0).await; + assert!( + baseline >= 750000.0, + "environment cannot measure: baseline below 6 Mbit/s" + ); + let mut measured = Vec::new(); + for rate in [1, 2] { + let (up, down, expected) = if direction == b'U' { + (rate, 100, rate * 125000) + } else { + (0, rate, 0) + }; + let value = bandwidth(&f, direction, up, down, 23000, expected).await; + assert!( + value <= rate as f64 * 125000.0 * 1.1, + "configured bandwidth is not enforced" + ); + measured.push(value); + } + assert!( + measured[1] >= measured[0] * 1.5, + "two configured rates do not scale" + ); + let (up, down, port, expected) = if direction == b'U' { + (2, 100, 23002, 125000) + } else { + (0, 2, 23003, 0) + }; + let lower = bandwidth(&f, direction, up, down, port, expected).await; + assert!( + lower <= 125000.0 * 1.1, + "lower server bandwidth is not enforced" + ); + } + // Mihomo returns RxAuto when client down=0; a configured up must not override it. + assert!(bandwidth(&f, b'U', 2, 0, 23000, 0).await > 0.0); + assert!(bandwidth(&f, b'U', 2, 2, 23004, 0).await > 0.0); +} + +#[tokio::test] +#[ignore = "owned isolated Mihomo client and server required"] +async fn native_mihomo_close_alignment() { + let _case = vcore::resources::case_events::Case::new( + "HYSTERIA2-NATIVE", + "native_mihomo_close_alignment", + ); + let f = fixture(); + assert_eq!(f["close_reference"]["scope"], "same-mode"); + assert_eq!(f["close_reference"]["terminated"], true); + let outbound = node(f["node"].clone()); + let (mut observer, target) = origin(&f, 11, false).await; + let mut io = outbound + .connect_stream(stream_request(target), &EstablishContext::default()) + .await + .unwrap() + .io; + let mut hello = [0; 5]; + io.read_exact(&mut hello).await.unwrap(); + assert_eq!(&hello, b"hello"); + io.write_all(b"ping").await.unwrap(); + io.flush().await.unwrap(); + let mut ping = [0; 4]; + io.read_exact(&mut ping).await.unwrap(); + assert_eq!(&ping, b"ping"); + io.shutdown().await.unwrap(); + let mut tail = Vec::new(); + let _terminal = tokio::time::timeout(Duration::from_secs(5), io.read_to_end(&mut tail)) + .await + .expect("close must end pending reads"); + let hex: String = tail.iter().map(|byte| format!("{byte:02x}")).collect(); + assert_eq!(hex, f["close_reference"]["tail_hex"].as_str().unwrap()); + assert_eq!(observer.read_u8().await.unwrap(), b'A'); + assert_eq!(observer.read_u8().await.unwrap(), b'D'); + drop(io); + tokio::time::timeout(Duration::from_secs(5), outbound.shutdown()) + .await + .unwrap(); +} + +#[tokio::test] +#[ignore = "owned isolated HYSTERIA2 peers required"] +async fn native_security_matrix() { + use vcore::resources::{case_events::Case, observation::ResourceProbe}; + let f = fixture(); + let mut case = Case::new("HYSTERIA2-SECURITY", "native_security_matrix"); + let probe = ResourceProbe::default(); + const IDS: &[&str] = &[ + "root-pin", + "leaf-pin", + "root-pin-wrong-name", + "leaf-pin-is-trust", + "unknown-ca", + "explicit-skip", + "wrong-pin", + "skip-does-not-override-pin", + "wrong-password", + "empty-password", + "raw-password", + "empty-alpn-default", + "custom-alpn", + "wrong-alpn", + "mtls-valid", + "mtls-absent", + "mtls-expired", + "mtls-wrong-ca", + "salamander", + "salamander-client-only", + "salamander-server-only", + "salamander-wrong-key", + "salamander-wrong-auth", + ]; + assert_eq!(f["security"].as_array().unwrap().len(), IDS.len()); + for sample in f["security"].as_array().unwrap() { + let id = sample["id"].as_str().unwrap(); + let _sample = Case::new( + "HYSTERIA2-SECURITY-CASE", + IDS.iter().copied().find(|name| *name == id).unwrap(), + ); + let success = sample["success"].as_bool().unwrap(); + probe + .scope(async { + let outbound = node(sample["node"].clone()); + let (mut control, target) = origin(&f, 12, false).await; + let outcome = tokio::time::timeout(Duration::from_secs(5), async { + let mut io = outbound + .connect_stream( + stream_request(target), + &EstablishContext::with_timeout(Duration::from_secs(4)), + ) + .await + .map_err(std::io::Error::other)? + .io; + io.write_all(b"synthetic-probe").await?; + let mut reply = [0; 2]; + io.read_exact(&mut reply).await?; + assert_eq!(&reply, b"ok"); + Ok::<_, std::io::Error>(()) + }) + .await + .expect("bounded security operation"); + assert_eq!(outcome.is_ok(), success, "{id}"); + if success { + assert_eq!(control.read_u8().await.unwrap(), b'A'); + assert_eq!(control.read_u8().await.unwrap(), b'D'); + } else { + assert!( + tokio::time::timeout(Duration::from_millis(100), control.read_u8()) + .await + .is_err(), + "rejected security reached origin: {id}" + ); + } + tokio::time::timeout(Duration::from_secs(5), outbound.shutdown()) + .await + .unwrap(); + }) + .await; + assert!(probe.snapshot().is_idle(), "{id}: {:?}", probe.snapshot()); + println!("HYSTERIA2-SECURITY {id} PASS"); + } + for identity in f["invalid_identities"].as_array().unwrap() { + let mut raw = f["node"].clone(); + raw["certificate"] = identity["certificate"].clone(); + raw["private-key"] = identity["private-key"].clone(); + assert!( + Config::parse_yaml( + json!({"socks-port":1080,"proxies":[raw],"rules":["MATCH,peer"]}) + .to_string() + .as_bytes() + ) + .is_err() + ); + } + case.resources(probe.snapshot()); +} + +#[tokio::test] +#[ignore = "official Hysteria with disableUDP required"] +async fn native_udp_disabled() { + let _case = + vcore::resources::case_events::Case::new("HYSTERIA2-UDP-DISABLED", "native_udp_disabled"); + let f = fixture(); + let outbound = node(f["node"].clone()); + let (mut control, target) = origin(&f, 10, false).await; + let mut io = outbound + .connect_stream(stream_request(target), &EstablishContext::default()) + .await + .unwrap() + .io; + let mut hello = [0; 5]; + io.read_exact(&mut hello).await.unwrap(); + assert_eq!(&hello, b"hello"); + assert_eq!(control.read_u8().await.unwrap(), b'A'); + let sent = vec![0x5a; 10 * 1024 * 1024]; + io.write_all(&sent).await.unwrap(); + let mut received = vec![0; sent.len()]; + io.read_exact(&mut received).await.unwrap(); + assert_eq!(Sha256::digest(&sent), Sha256::digest(&received)); + let mut tail = [0; 7]; + io.read_exact(&mut tail).await.unwrap(); + assert_eq!(&tail, b"trailer"); + assert!(matches!( + outbound + .open_datagram(datagram_request(), &EstablishContext::default()) + .await, + Err(vcore::dispatch::DispatchError::NotAllowed) + )); + drop(io); + tokio::time::timeout(Duration::from_secs(5), outbound.shutdown()) + .await + .unwrap(); +} + +async fn hop_interrupt(reject: bool) { + use std::sync::{Arc, atomic::Ordering}; + use vcore::resources::{ + case_events::Case, + observation::{ResourceKind, ResourceProbe}, + }; + let mut event = Case::new( + "HYSTERIA2-HOP", + if reject { + "native_hop_protect_rejection" + } else { + "native_stop_during_hop" + }, + ); + let f = fixture(); + let probe = ResourceProbe::default(); + event.checkpoint("baseline", probe.snapshot()); + probe + .scope(async { + let protector = Arc::new(Protector { + count: 0.into(), + reject_after: if reject { 1 } else { usize::MAX }, + }); + let outbound = node_with_dialer( + f["node"].clone(), + Dialer::default().with_protector(protector.clone()), + ); + let (mut control, target) = origin(&f, 13, false).await; + let mut io = outbound + .connect_stream(stream_request(target), &EstablishContext::default()) + .await + .unwrap() + .io; + io.write_all(b"before-hop").await.unwrap(); + let mut bytes = [0; 10]; + io.read_exact(&mut bytes).await.unwrap(); + assert_eq!(&bytes, b"before-hop"); + assert_eq!(control.read_u8().await.unwrap(), b'A'); + tokio::time::timeout(Duration::from_secs(9), async { + while protector.count.load(Ordering::SeqCst) < 2 { + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await + .unwrap(); + if reject { + assert!( + tokio::time::timeout(Duration::from_secs(2), io.read_u8()) + .await + .unwrap() + .is_err() + ); + } else { + // The second protected socket exists, inside the one-second old + // socket grace window. Stop must join both rather than abort/leak. + assert!(probe.snapshot().current(ResourceKind::Socket) >= 2); + outbound.begin_shutdown(); + assert!(io.read_u8().await.is_err()); + } + drop(io); + let count = protector.count.load(Ordering::SeqCst); + tokio::time::timeout(Duration::from_secs(5), outbound.shutdown()) + .await + .unwrap(); + assert!(probe.snapshot().is_idle(), "{:?}", probe.snapshot()); + event.checkpoint("after-stop", probe.snapshot()); + tokio::time::sleep(Duration::from_secs(5)).await; + assert_eq!( + protector.count.load(Ordering::SeqCst), + count, + "late unrequested reconnect" + ); + assert!(probe.snapshot().is_idle()); + event.checkpoint("quiet", probe.snapshot()); + }) + .await; + event.resources(probe.snapshot()); +} + +#[tokio::test] +#[ignore = "official Hysteria in an owned NET_ADMIN container required"] +async fn native_hop_protect_rejection() { + hop_interrupt(true).await; +} + +#[tokio::test] +#[ignore = "official Hysteria in an owned NET_ADMIN container required"] +async fn native_stop_during_hop() { + hop_interrupt(false).await; +} + +#[tokio::test] +#[ignore = "owned isolated HYSTERIA2 peers required"] +async fn native_deadline_and_udp_budget() { + use vcore::resources::{case_events::Case, observation::ResourceProbe}; + let mut event = Case::new("HYSTERIA2-NATIVE", "native_deadline_and_udp_budget"); + let f = fixture(); + let probe = ResourceProbe::default(); + probe + .scope(async { + let (mut observer, blackhole) = origin(&f, 20, false).await; + let mut raw = f["node"].clone(); + raw["server"] = json!(blackhole.ip().to_string()); + raw["port"] = json!(blackhole.port()); + let outbound = node(raw); + let context = EstablishContext::default(); + let started = tokio::time::Instant::now(); + // Consume part of the same ten-second budget before the outer handshake. + tokio::time::sleep(Duration::from_secs(2)).await; + let result = tokio::time::timeout( + Duration::from_secs(9), + outbound.connect_stream(stream_request(blackhole), &context), + ) + .await + .unwrap(); + assert!(matches!( + result, + Err(vcore::dispatch::DispatchError::TimedOut) + )); + assert!( + (Duration::from_millis(9900)..Duration::from_secs(11)).contains(&started.elapsed()) + ); + assert_eq!(observer.read_u8().await.unwrap(), b'A'); + tokio::time::timeout(Duration::from_secs(5), outbound.shutdown()) + .await + .unwrap(); + assert!(probe.snapshot().is_idle()); + let outbound = node(f["node"].clone()); + let (mut observer, target) = origin(&f, 4, false).await; + let mut udp = outbound + .open_datagram( + datagram_request().with_budget(vcore::dispatch::DatagramBudget::new(128, 128)), + &EstablishContext::default(), + ) + .await + .unwrap(); + let packet = |size| vcore::session::Datagram { + remote: target.into(), + payload: bytes::Bytes::from(vec![7; size]), + sniffed_domain: None, + }; + assert_eq!(udp.payload_budget(&target.into()).transmit(), 128); + assert!(udp.send(packet(129)).await.is_err()); + assert!( + tokio::time::timeout(Duration::from_millis(100), observer.read_u8()) + .await + .is_err() + ); + udp.send(packet(128)).await.unwrap(); + assert_eq!(udp.receive().await.unwrap().payload, vec![7; 128]); + assert_eq!(observer.read_u16().await.unwrap(), 128); + assert_ne!(observer.read_u16().await.unwrap(), 0); + let mut observed = [0; 128]; + observer.read_exact(&mut observed).await.unwrap(); + assert_eq!(observed, [7; 128]); + udp.close().await.unwrap(); + drop(udp); + tokio::time::timeout(Duration::from_secs(5), outbound.shutdown()) + .await + .unwrap(); + }) + .await; + assert!(probe.snapshot().is_idle()); + event.resources(probe.snapshot()); +} + +#[tokio::test] +#[ignore = "owned isolated HYSTERIA2 peers required"] +async fn native_owned_lifecycle() { + use std::sync::{Arc, atomic::Ordering}; + use vcore::resources::{ + case_events::Case, + observation::{ResourceKind, ResourceProbe}, + }; + let f = fixture(); + for cycle in 0..20 { + let mut case = Case::new("HYSTERIA2-LIFE", "native_owned_lifecycle"); + let probe = ResourceProbe::default(); + case.checkpoint("baseline", probe.snapshot()); + probe.scope(async { + let protector=Arc::new(Protector {count:0.into(),reject_after:usize::MAX}); + if cycle%5==2 { + let (mut control,blackhole)=origin(&f,20,false).await; + let mut raw=f["node"].clone();raw["server"]=json!(blackhole.ip().to_string());raw["port"]=json!(blackhole.port()); + let outbound=node_with_dialer(raw,Dialer::default().with_protector(protector.clone())); + // Dropping the user's future cancels only the pending auth owner. + let context = EstablishContext::default(); + tokio::select! { + result=outbound.connect_stream(stream_request(blackhole),&context) => panic!("unexpected blackhole outcome: {:?}",result.err()), + marker=control.read_u8()=>assert_eq!(marker.unwrap(),b'A'), + } + tokio::time::timeout(Duration::from_secs(5),outbound.shutdown()).await.unwrap(); + assert!(tokio::time::timeout(Duration::from_millis(100),control.read_u8()).await.is_err()); + } else { + let outbound=node_with_dialer(f["node"].clone(),Dialer::default().with_protector(protector.clone())); + let (mut a,target)=origin(&f,13,false).await; + if cycle%5==3 { + assert!(matches!(outbound.connect_stream(stream_request(target),&EstablishContext::with_timeout(Duration::ZERO)).await,Err(vcore::dispatch::DispatchError::TimedOut))); + assert_eq!(protector.count.load(Ordering::SeqCst),0); + } else { + let mut first=outbound.connect_stream(stream_request(target),&EstablishContext::default()).await.unwrap().io; + first.write_all(b"a").await.unwrap();assert_eq!(first.read_u8().await.unwrap(),b'a');assert_eq!(a.read_u8().await.unwrap(),b'A'); + let (mut b,target)=origin(&f,13,false).await; + let mut second=outbound.connect_stream(stream_request(target),&EstablishContext::default()).await.unwrap().io; + second.write_all(b"b").await.unwrap();assert_eq!(second.read_u8().await.unwrap(),b'b');assert_eq!(b.read_u8().await.unwrap(),b'A'); + drop(first);assert_eq!(a.read_u8().await.unwrap(),b'D'); + second.write_all(b"sibling").await.unwrap();let mut reply=[0;7];second.read_exact(&mut reply).await.unwrap();assert_eq!(&reply,b"sibling"); + let (mut u,target)=origin(&f,4,false).await; + let mut udp=outbound.open_datagram(datagram_request(),&EstablishContext::default()).await.unwrap(); + udp.send(vcore::session::Datagram {remote:target.into(),payload:bytes::Bytes::from_static(b"udp"),sniffed_domain:None}).await.unwrap(); + assert_eq!(udp.receive().await.unwrap().payload,b"udp"[..]); + assert_eq!(u.read_u16().await.unwrap(),3);assert_ne!(u.read_u16().await.unwrap(),0); + let mut observed=[0;3];u.read_exact(&mut observed).await.unwrap();assert_eq!(&observed,b"udp"); + for kind in [ResourceKind::Socket,ResourceKind::Session,ResourceKind::Task,ResourceKind::Pool,ResourceKind::Handshake,ResourceKind::Association] {assert!(probe.snapshot().peak(kind)>0);} + outbound.begin_shutdown();assert!(second.read_u8().await.is_err());drop(second); + assert!(udp.receive().await.is_err());udp.close().await.unwrap();drop(udp); + } + tokio::time::timeout(Duration::from_secs(5),outbound.shutdown()).await.unwrap(); + } + assert!(probe.snapshot().is_idle(),"cycle {cycle}: {:?}",probe.snapshot()); + case.checkpoint("after-stop",probe.snapshot()); + let snapshot=probe.snapshot();let until=tokio::time::Instant::now()+Duration::from_secs(5); + while tokio::time::Instant::now()(h3_quinn::Connection::new(connection.clone())) + .await + .unwrap(); + let (request, mut response) = h3 + .accept() + .await + .unwrap() + .unwrap() + .resolve_request() + .await + .unwrap(); + assert_eq!(request.uri().path(), "/auth"); + response + .send_response( + http::Response::builder() + .status(233) + .header("Hysteria-UDP", "true") + .header("Hysteria-CC-RX", "0") + .body(()) + .unwrap(), + ) + .await + .unwrap(); + response.finish().await.unwrap(); + // Native Hysteria randomizes its 16-bit Packet ID. A completed ID can + // recur, including immediately; Mihomo releases the completed pieces. + for (sequence, id) in [17_u16, 18, 17, 17].into_iter().enumerate() { + let Ok(request) = connection.read_datagram().await else { + break; + }; + assert_eq!(&request[6..], &[0, 1, 3, b'x', b':', b'1', sequence as u8]); + // Literal protocol fixtures, independent of VCore's wire encoder. + // Out of order plus a duplicate within the unfinished assembly. + for index in [1, 1, 0] { + let mut frame = request[..4].to_vec(); + frame.extend_from_slice(&id.to_be_bytes()); + frame.extend_from_slice(&[index, 2, 3, b'x', b':', b'1']); + frame.extend_from_slice(&[sequence as u8; 50]); + connection.send_datagram_wait(frame.into()).await.unwrap(); + } + } + connection.closed().await; + drop(h3); + endpoint.close(0_u32.into(), b""); + endpoint.wait_idle().await; + }); + let config = Config::parse_yaml(br#"{"socks-port":1080,"proxies":[{"name":"peer","type":"hysteria2","server":"192.0.2.1","port":443,"udp":true,"skip-cert-verify":true,"sni":"fixture.invalid"}],"rules":["MATCH,peer"]}"#).unwrap(); + let ProxyProtocol::Hysteria2(config) = &config.proxies[0].protocol else { + panic!("protocol") + }; + let outbound = Hysteria2Outbound::new_with_path( + config, + UpstreamPath::proxy(Arc::new(MemoryUpstream(Mutex::new(Some(client_io))))), + ) + .unwrap(); + let mut udp = tokio::time::timeout( + Duration::from_secs(2), + outbound.open_datagram( + DatagramRequest::new(DatagramSession::new( + InboundKind::InternalMeasure, + client_address, + )), + &EstablishContext::default(), + ), + ) + .await + .expect("memory peer must authenticate") + .unwrap(); + let mut delivered = Vec::new(); + let outcome = tokio::time::timeout(Duration::from_secs(2), async { + let target = Destination::domain("x", 1).unwrap(); + for sequence in 0..4 { + udp.send(Datagram { + remote: target.clone(), + payload: vec![sequence].into(), + sniffed_domain: None, + }) + .await + .unwrap(); + let reply = udp.receive().await.unwrap(); + assert_eq!(reply.remote, target); + assert_eq!(reply.payload, vec![sequence; 100]); + delivered.push(sequence); + } + udp.close().await.unwrap(); + }) + .await; + outbound.shutdown().await; + tokio::time::timeout(Duration::from_secs(2), peer) + .await + .unwrap() + .unwrap(); + assert_eq!( + delivered, + [0, 1, 2, 3], + "completed Packet IDs are not replay nonces" + ); + outcome.expect("all legal replies, including completed ID reuse, must be delivered"); +} diff --git a/tests/hysteria2_paths.rs b/tests/hysteria2_paths.rs new file mode 100644 index 0000000..2386623 --- /dev/null +++ b/tests/hysteria2_paths.rs @@ -0,0 +1,114 @@ +#![cfg(feature = "outbound-hysteria2")] +use async_trait::async_trait; +use bytes::Bytes; +use quinn::AsyncUdpSocket; +use std::{ + collections::VecDeque, + future::poll_fn, + io::IoSliceMut, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; +use vcore::{ + dispatch::{DatagramBudget, DatagramTransport, DispatchError}, + session::{Datagram, Destination}, +}; + +// In-memory controlled datagrams, not a host network or a HY2 server substitute. +struct Packets { + incoming: VecDeque, + sent: tokio::sync::mpsc::Sender, + closed: Arc, +} +#[async_trait] +impl DatagramTransport for Packets { + fn payload_budget(&self, _: &Destination) -> DatagramBudget { + DatagramBudget::new(1400, 1400) + } + async fn send(&mut self, packet: Datagram) -> Result<(), DispatchError> { + self.sent + .send(packet) + .await + .map_err(|_| DispatchError::NotAllowed) + } + async fn receive(&mut self) -> Result { + match self.incoming.pop_front() { + Some(packet) => Ok(packet), + None => std::future::pending().await, + } + } + async fn close(&mut self) -> Result<(), DispatchError> { + self.closed.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +#[tokio::test] +async fn controlled_path_maps_only_its_authorized_source_and_stops_without_replay() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "HYSTERIA2-UNIT", + "controlled_path_maps_only_its_authorized_source_and_stops_without_replay", + ); + let logical = "192.0.2.1:443".parse().unwrap(); + let physical = "192.0.2.1:8443".parse().unwrap(); + let packet = |remote, payload| Datagram { + remote: Destination::Ip(remote), + payload, + sniffed_domain: None, + }; + let (sent, mut observed) = tokio::sync::mpsc::channel(4); + let closed = Arc::new(AtomicUsize::new(0)); + let raw = Packets { + incoming: [ + packet(logical, Bytes::from_static(b"foreign-port")), + packet(physical, vec![0; 1401].into()), + packet(physical, Bytes::from_static(b"authorized")), + ] + .into(), + sent, + closed: closed.clone(), + }; + let (socket, driver) = vcore::transport::quic::attach_mapped( + Box::new(raw), + logical, + physical, + DatagramBudget::new(1400, 1400), + ) + .unwrap(); + let request = quinn::udp::Transmit { + destination: logical, + ecn: None, + contents: b"once", + segment_size: None, + src_ip: None, + }; + socket.try_send(&request).unwrap(); + let outbound = observed.recv().await.unwrap(); + assert_eq!(outbound.remote, Destination::Ip(physical)); + assert_eq!(outbound.payload, b"once"[..]); + let mut bytes = [0; 1400]; + let mut buffers = [IoSliceMut::new(&mut bytes)]; + let mut metadata = [quinn::udp::RecvMeta::default()]; + assert_eq!( + tokio::time::timeout( + Duration::from_secs(1), + poll_fn(|cx| socket.poll_recv(cx, &mut buffers, &mut metadata)) + ) + .await + .unwrap() + .unwrap(), + 1 + ); + assert_eq!(metadata[0].addr, logical); + assert_eq!(&bytes[..metadata[0].len], b"authorized"); + assert_eq!(socket.stats().rejected_source, 1); + assert_eq!(socket.stats().rejected_oversize, 1); + driver.stop().await.unwrap(); + assert_eq!(closed.load(Ordering::SeqCst), 1); + assert!(socket.try_send(&request).is_err()); + assert!(observed.recv().await.is_none()); +} diff --git a/tests/jls_config.rs b/tests/jls_config.rs new file mode 100644 index 0000000..d95a896 --- /dev/null +++ b/tests/jls_config.rs @@ -0,0 +1,423 @@ +#![cfg(feature = "outbound-vless")] + +use serde_json::json; +use vcore::{ + config::{Config, ProxyProtocol, SecurityConfig, VlessOutboundConfig}, + security::SecurityClient, +}; + +#[test] +fn public_jls_builds_an_authenticated_security_client_without_exposing_credentials() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "public_jls_builds_an_authenticated_security_client_without_exposing_credentials", + ); + let config = Config::parse_yaml( + &serde_json::to_vec(&json!({ + "socks-port":1080, + "proxies":[{"name":"edge", "type":"vless", "server":"example.invalid", "port":443, + "uuid":"07070707-0707-0707-0707-070707070707", "tls":true, + "jls-opts":{"username":"synthetic-jls-user", "password":"synthetic-jls-password"}}], + "rules":["MATCH,edge"] + })) + .unwrap(), + ) + .expect("valid JLS must normalize before any IO"); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + panic!() + }; + let client = SecurityClient::from_proxy(node).expect("JLS must have a real native client"); + for text in [format!("{config:?}"), format!("{client:?}")] { + assert!(!text.contains("synthetic-jls-user")); + assert!(!text.contains("synthetic-jls-password")); + } +} + +fn node() -> serde_json::Value { + json!({"name":"edge", "type":"vless", "server":"example.invalid", "port":443, + "uuid":"07070707-0707-0707-0707-070707070707", "tls":true, + "jls-opts":{"username":"synthetic-jls-user", "password":"synthetic-jls-password"}}) +} + +fn parse(node: serde_json::Value) -> vcore::Result { + let config = Config::parse_yaml( + &serde_json::to_vec(&json!({ + "socks-port":1080, "proxies":[node], "rules":["MATCH,edge"] + })) + .unwrap(), + )?; + let ProxyProtocol::Vless(node) = config.proxies.into_iter().next().unwrap().protocol else { + panic!() + }; + Ok(node) +} + +#[test] +fn independent_download_inherits_jls_identity_and_applies_its_own_tls_fields() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "independent_download_inherits_jls_identity_and_applies_its_own_tls_fields", + ); + let mut input = node(); + input["network"] = json!("xhttp"); + input["xhttp-opts"] = json!({"mode":"stream-up", "download-settings":{ + "servername":"download.invalid", "alpn":["http/1.1"] + }}); + let config = parse(input).unwrap(); + let SecurityConfig::Jls(download) = &config.download().unwrap().security else { + panic!("download must not silently lose inherited JLS authentication") + }; + assert_eq!(download.username, "synthetic-jls-user"); + assert_eq!(download.password, "synthetic-jls-password"); + assert_eq!(download.tls.server_name, "download.invalid"); + assert_eq!(download.tls.alpn, [b"http/1.1"]); + SecurityClient::from_security(&config.download().unwrap().security).unwrap(); +} + +#[test] +fn download_jls_credentials_are_replaced_as_a_whole_or_explicitly_cleared() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "download_jls_credentials_are_replaced_as_a_whole_or_explicitly_cleared", + ); + let mut input = node(); + input["network"] = json!("xhttp"); + input["xhttp-opts"] = json!({"mode":"stream-up", "download-settings":{ + "jls-opts":{"username":"download-user", "password":"download-password"} + }}); + let config = parse(input.clone()).unwrap(); + let SecurityConfig::Jls(download) = &config.download().unwrap().security else { + panic!("explicit download JLS must be retained") + }; + assert_eq!(download.username, "download-user"); + assert_eq!(download.password, "download-password"); + input["xhttp-opts"]["download-settings"]["jls-opts"] = json!({}); + assert!(matches!( + parse(input.clone()).unwrap().download().unwrap().security, + SecurityConfig::Tls(_) + )); + input["xhttp-opts"]["download-settings"]["tls"] = json!(false); + assert!(matches!( + parse(input).unwrap().download().unwrap().security, + SecurityConfig::None + )); +} + +#[test] +fn malformed_or_partial_credentials_never_inherit_a_missing_secret() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "malformed_or_partial_credentials_never_inherit_a_missing_secret", + ); + for replacement in [ + json!(null), + json!([]), + json!("secret"), + json!({}), + json!({"username":"synthetic-jls-user"}), + json!({"password":"synthetic-jls-password"}), + json!({"username":"", "password":"synthetic-jls-password"}), + json!({"username":"synthetic-jls-user", "password":""}), + json!({"username":null, "password":"synthetic-jls-password"}), + json!({"username":"synthetic-jls-user", "password":null}), + json!({"username":123, "password":"synthetic-jls-password"}), + json!({"username":"synthetic-jls-user", "password":"synthetic-jls-password", "extra":true}), + json!({"username":"u".repeat(65_536), "password":"synthetic-jls-password"}), + json!({"username":"synthetic-jls-user", "password":"密".repeat(21_846)}), + ] { + let mut input = node(); + input["jls-opts"] = replacement.clone(); + let error = parse(input) + .expect_err("invalid primary JLS must fail before IO") + .to_string(); + assert!(!error.contains("synthetic-jls-user")); + assert!(!error.contains("synthetic-jls-password")); + if replacement == json!({}) { + continue; // Only an independent download leg accepts an empty clear object. + } + let mut input = node(); + input["network"] = json!("xhttp"); + input["xhttp-opts"] = + json!({"mode":"stream-up", "download-settings":{"jls-opts":replacement}}); + assert!( + parse(input).is_err(), + "download must not leaf-merge credentials" + ); + } + let mut input = node(); + input["jls-opts"] = json!({"username":"u".repeat(65_535), "password":"p".repeat(65_535)}); + SecurityClient::from_proxy(&parse(input).unwrap()).unwrap(); +} + +#[test] +fn invalid_jls_objects_do_not_echo_credentials_in_public_configuration_errors() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "invalid_jls_objects_do_not_echo_credentials_in_public_configuration_errors", + ); + for credential in ["username", "password"] { + let mut input = node(); + input["jls-opts"][credential] = json!(98765432198765_u64); + let error = parse(input).unwrap_err().to_string(); + assert!( + !error.contains("98765432198765"), + "numeric secret was echoed" + ); + } + for replacement in [ + json!("synthetic-object-secret"), + json!({"synthetic-object-secret":true}), + json!({"username":"synthetic-jls-user", "password":98765432198765_u64}), + ] { + for download in [false, true] { + let mut input = node(); + if download { + input["network"] = json!("xhttp"); + input["xhttp-opts"] = + json!({"mode":"stream-up", "download-settings":{"jls-opts":replacement}}); + } else { + input["jls-opts"] = replacement.clone(); + } + let error = parse(input).unwrap_err().to_string(); + assert!(!error.contains("synthetic-object-secret")); + assert!(!error.contains("synthetic-jls-user")); + assert!(!error.contains("98765432198765")); + } + } +} + +#[test] +fn jls_rejects_incompatible_security_at_configuration_time() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "jls_rejects_incompatible_security_at_configuration_time", + ); + for overrides in [ + json!({"tls":false}), + json!({"flow":"xtls-rprx-vision"}), + json!({"reality-opts":{"public-key":"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc"}}), + json!({"skip-cert-verify":false}), + json!({"skip-cert-verify":true}), + json!({"fingerprint":"07".repeat(32)}), + json!({"name-cert-verify":"example.invalid"}), + json!({"certificate":"certificate", "private-key":"key"}), + json!({"network":"xhttp", "alpn":["h3"], "xhttp-opts":{"mode":"stream-up"}}), + ] { + let mut input = node(); + input + .as_object_mut() + .unwrap() + .extend(overrides.as_object().unwrap().clone()); + assert!(parse(input).is_err()); + } + for download in [ + json!({"tls":false}), + json!({"alpn":["h3"]}), + json!({"skip-cert-verify":true}), + json!({"name-cert-verify":"other.invalid"}), + json!({"fingerprint":"07".repeat(32)}), + ] { + let mut input = node(); + input["network"] = json!("xhttp"); + input["xhttp-opts"] = json!({"mode":"stream-up", "download-settings":download}); + assert!(parse(input).is_err()); + } +} + +#[test] +fn download_security_switch_requires_explicit_clearing_of_inherited_identity() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "download_security_switch_requires_explicit_clearing_of_inherited_identity", + ); + let reality = json!({"public-key":"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc"}); + let mut input = node(); + input["network"] = json!("xhttp"); + input["xhttp-opts"] = json!({"mode":"stream-up", "download-settings":{"reality-opts":reality}}); + assert!(parse(input.clone()).is_err()); + input["xhttp-opts"]["download-settings"]["jls-opts"] = json!({}); + assert!(matches!( + parse(input).unwrap().download().unwrap().security, + SecurityConfig::Reality(_) + )); + + let mut input = node(); + let credentials = input.as_object_mut().unwrap().remove("jls-opts").unwrap(); + input["reality-opts"] = reality; + input["network"] = json!("xhttp"); + input["xhttp-opts"] = json!({"mode":"stream-up", "download-settings":{"jls-opts":credentials}}); + assert!(parse(input.clone()).is_err()); + input["xhttp-opts"]["download-settings"]["reality-opts"] = json!({}); + assert!(matches!( + parse(input.clone()).unwrap().download().unwrap().security, + SecurityConfig::Jls(_) + )); + input.as_object_mut().unwrap().remove("reality-opts"); + input["skip-cert-verify"] = json!(true); + assert!(parse(input.clone()).is_err()); + input["xhttp-opts"]["download-settings"]["skip-cert-verify"] = json!(false); + assert!(matches!( + parse(input).unwrap().download().unwrap().security, + SecurityConfig::Jls(_) + )); +} + +#[cfg(feature = "tls-fingerprint")] +#[test] +fn selected_profiles_build_jls_without_changing_its_identity() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "selected_profiles_build_jls_without_changing_its_identity", + ); + for profile in [ + "none", + "", + "chrome", + "chrome120", + "firefox", + "firefox120", + "safari", + "safari16", + ] { + let mut input = node(); + input["client-fingerprint"] = json!(profile); + input["network"] = json!("xhttp"); + input["xhttp-opts"] = json!({"mode":"stream-up", "download-settings":{}}); + let config = parse(input.clone()).unwrap(); + let SecurityConfig::Jls(main) = &config.security else { + panic!() + }; + let SecurityConfig::Jls(download) = &config.download().unwrap().security else { + panic!() + }; + assert_eq!(main, download); + SecurityClient::from_proxy(&config).unwrap(); + input["xhttp-opts"]["download-settings"]["client-fingerprint"] = json!("none"); + let config = parse(input).unwrap(); + let SecurityConfig::Jls(download) = &config.download().unwrap().security else { + panic!() + }; + assert!(download.tls.client_fingerprint.is_none()); + SecurityClient::from_security(&config.download().unwrap().security).unwrap(); + } +} + +#[tokio::test] +async fn cancelled_jls_handshakes_release_supplied_io_and_never_reuse_randoms() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "cancelled_jls_handshakes_release_supplied_io_and_never_reuse_randoms", + ); + use std::time::Duration; + use tokio::io::AsyncReadExt; + let client = SecurityClient::from_proxy(&parse(node()).unwrap()).unwrap(); + let mut previous = None; + for _ in 0..20 { + let (io, mut peer) = tokio::io::duplex(32_768); + let cloned = client.clone(); + let task = tokio::spawn(async move { cloned.connect(Box::new(io)).await }); + let mut header = [0; 5]; + tokio::time::timeout(Duration::from_secs(2), peer.read_exact(&mut header)) + .await + .unwrap() + .unwrap(); + assert_eq!(header[0], 22); + let length = usize::from(u16::from_be_bytes([header[3], header[4]])); + assert!((38..=16_384).contains(&length)); + let mut hello = vec![0; length]; + peer.read_exact(&mut hello).await.unwrap(); + let random: [u8; 32] = hello[6..38].try_into().unwrap(); + assert_ne!(previous, Some(random)); + previous = Some(random); + task.abort(); + assert!(matches!(task.await, Err(error) if error.is_cancelled())); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), peer.read(&mut header)) + .await + .unwrap() + .unwrap(), + 0 + ); + } +} + +#[tokio::test] +async fn jls_security_client_rejects_ordinary_tls_before_application_data() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "jls_security_client_rejects_ordinary_tls_before_application_data", + ); + use boring::{ + pkey::PKey, + ssl::{SslAcceptor, SslMethod, SslVersion}, + x509::X509, + }; + use sha2::{Digest, Sha256}; + use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let rcgen::CertifiedKey { cert, signing_key } = + rcgen::generate_simple_self_signed(vec!["example.invalid".to_owned()]).unwrap(); + for version in [SslVersion::TLS1_2, SslVersion::TLS1_3] { + let mut server = SslAcceptor::mozilla_intermediate_v5(SslMethod::tls()).unwrap(); + server + .set_certificate(&X509::from_der(cert.der()).unwrap()) + .unwrap(); + server + .set_private_key(&PKey::private_key_from_der(&signing_key.serialize_der()).unwrap()) + .unwrap(); + server.set_min_proto_version(Some(version)).unwrap(); + server.set_max_proto_version(Some(version)).unwrap(); + let server = server.build(); + for jls in [false, true] { + let config = parse(node()).unwrap(); + let SecurityConfig::Jls(mut identity) = config.security else { + panic!() + }; + let security = if jls { + SecurityConfig::Jls(identity) + } else { + identity.tls.tls13_only = false; + identity.tls.certificate.fingerprint = Some(Sha256::digest(cert.der()).into()); + SecurityConfig::Tls(identity.tls) + }; + let client = SecurityClient::from_security(&security).unwrap(); + let (io, peer) = tokio::io::duplex(512); + tokio::time::timeout(Duration::from_secs(3), async { + let native = async { + let Ok(mut tls) = tokio_boring::accept(&server, peer).await else { + return false; + }; + tls.write_all(b"hello").await.unwrap(); + tls.flush().await.unwrap(); + true + }; + let consumer = async { + let result = client.connect(Box::new(io)).await; + if jls { + assert!(result.is_err(), "ordinary TLS must not authenticate JLS"); + } else { + let mut tls = result.unwrap(); + let mut hello = [0; 5]; + tls.read_exact(&mut hello).await.unwrap(); + assert_eq!(&hello, b"hello"); + } + }; + let (_, completed) = tokio::join!(consumer, native); + assert_eq!(completed, !jls); + }) + .await + .unwrap(); + } + } +} diff --git a/tests/limit_foundations.rs b/tests/limit_foundations.rs new file mode 100644 index 0000000..33ce104 --- /dev/null +++ b/tests/limit_foundations.rs @@ -0,0 +1,163 @@ +#![cfg(all( + feature = "stream-transport", + feature = "quic-transport", + feature = "outbound-vless", + feature = "outbound-trojan", + feature = "outbound-vmess" +))] +use std::collections::BTreeMap; + +#[test] +fn registry_matches_live_production_constants_and_has_owned_boundary_cases() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-LIMITS", + "registry_matches_live_production_constants_and_has_owned_boundary_cases", + ); + use vcore::{ResourceLimits, dispatch, transport}; + let defaults = ResourceLimits::default(); + let expected = BTreeMap::from([ + ( + "shadowsocks-write-chunk", + vcore::limits::SHADOWSOCKS_WRITE_CHUNK, + ), + ("hy2-udp-payload", vcore::limits::HY2_UDP_PAYLOAD), + ("hy2-udp-queue", vcore::limits::HY2_UDP_QUEUE), + ("hy2-pending-packets", vcore::limits::HY2_PENDING_PACKETS), + ("hy2-pending-bytes", vcore::limits::HY2_PENDING_BYTES), + ("hy2-fragment-ttl", vcore::limits::HY2_FRAGMENT_TTL_SECONDS), + ("hy2-path-retire", vcore::limits::HY2_PATH_RETIRE_SECONDS), + ("hy2-quic-payload", vcore::limits::HY2_QUIC_PAYLOAD), + ("hy2-uni-streams", vcore::limits::HY2_UNI_STREAMS), + ("hy2-stream-window", vcore::limits::HY2_STREAM_WINDOW), + ( + "hy2-connection-window", + vcore::limits::HY2_CONNECTION_WINDOW, + ), + ("hy2-datagram-buffer", vcore::limits::HY2_DATAGRAM_BUFFER), + ("hy2-auth-headers", vcore::limits::HY2_AUTH_HEADERS), + ("hy2-response-message", vcore::limits::HY2_RESPONSE_MESSAGE), + ("hy2-response-padding", vcore::limits::HY2_RESPONSE_PADDING), + ("vless-udp-frame", vcore::limits::VLESS_UDP_FRAME_BYTES), + ("vision-content", vcore::limits::VISION_CONTENT_BYTES), + ("vision-tls-record", vcore::limits::VISION_TLS_RECORD_BYTES), + ("vision-hello", vcore::limits::VISION_HELLO_BYTES), + ("grpc-idle", vcore::limits::GRPC_IDLE_CONNECTIONS), + ( + "grpc-ping-timeout", + vcore::limits::GRPC_PING_TIMEOUT_SECONDS, + ), + ("config-bytes", vcore::config::MAX_CONFIG_BYTES), + ( + "establish-timeout", + vcore::outbound::DEFAULT_ESTABLISH_TIMEOUT.as_millis() as usize, + ), + ("tls-buffer", defaults.tls_buffer_limit), + ( + "tls-close", + vcore::security::CLOSE_NOTIFY_TIMEOUT.as_millis() as usize, + ), + ( + "tls-resumption", + vcore::security::TLS_RESUMPTION_SESSION_BUDGET, + ), + ("stream-chunk", transport::STREAM_CHUNK_BYTES), + ("stream-buffer", transport::STREAM_BUFFER_BYTES), + ("http-head", transport::HTTP_HEAD_BYTES), + ("http-headers", transport::HTTP_HEADER_COUNT), + ("ws-early-data", transport::MAX_EARLY_DATA_BYTES), + ( + "trojan-udp-payload", + usize::from(vcore::outbound::trojan::MAX_DATAGRAM_PAYLOAD), + ), + ("xudp-metadata", vcore::xudp::MAX_METADATA_LENGTH), + ("vmess-body-wire", vcore::outbound::vmess::MAX_BODY_WIRE), + ("vmess-write-chunk", vcore::outbound::vmess::WRITE_CHUNK), + ("vmess-datagram", vcore::outbound::vmess::MAX_PACKET_BYTES), + ("io-poll", vcore::limits::IO_POLL_BUDGET), + ("quic-queue", transport::quic::QUEUE_LIMIT), + ( + "quic-close", + transport::quic::CLOSE_TIMEOUT.as_millis() as usize, + ), + ( + "quic-minimum", + usize::from(dispatch::QUIC_MIN_PAYLOAD_BYTES), + ), + ( + "resolution-depth", + vcore::dns::resolution::MAX_RESOLUTION_DEPTH, + ), + ("packet-queue", defaults.packet_queue_capacity), + ("event-queue", defaults.event_queue_capacity), + ("udp-payload", defaults.max_datagram_size), + ("tun-packet", defaults.tun_max_datagram_size), + ("tcp-buffer", defaults.tcp_buffer_per_direction), + ("dns-cache", defaults.dns_address_cache_entries), + ("dns-hints", defaults.dns_redir_host_entries), + ("tun-dns-ingress", defaults.tun_dns_ingress_queue_capacity), + ("tun-dns-response", defaults.tun_dns_response_queue_capacity), + ("xhttp-send", defaults.xhttp_send_buffer_size), + ("xhttp-upload", defaults.xhttp_upload_chunk_size), + ( + "xhttp-custom-header-bytes", + vcore::limits::XHTTP_CUSTOM_HEADER_BYTES, + ), + ("xhttp-custom-headers", vcore::limits::XHTTP_CUSTOM_HEADERS), + ("xhttp-padding", vcore::limits::XHTTP_PADDING_BYTES), + ("xhttp-post", vcore::limits::XHTTP_POST_BYTES), + ( + "xhttp-packet-batch", + vcore::limits::XHTTP_PACKET_BATCH_BYTES, + ), + ("xhttp-request-bytes", vcore::limits::XHTTP_REQUEST_BYTES), + ( + "xhttp-request-headers", + vcore::limits::XHTTP_REQUEST_HEADERS, + ), + ("xhttp-idle-entries", vcore::limits::XHTTP_IDLE_ENTRIES), + ("xhttp-idle-h1", vcore::limits::XHTTP_IDLE_H1_CONNECTIONS), + ("xhttp-body-queue", vcore::limits::XHTTP_BODY_QUEUE), + ("xhttp-body-chunk", vcore::limits::XHTTP_BODY_CHUNK), + ("xhttp-h3-uni-streams", vcore::limits::XHTTP_H3_UNI_STREAMS), + ( + "xhttp-h3-stream-window", + vcore::limits::XHTTP_H3_STREAM_WINDOW, + ), + ( + "xhttp-h3-connection-window", + vcore::limits::XHTTP_H3_CONNECTION_WINDOW, + ), + ("xhttp-h3-send-window", vcore::limits::XHTTP_H3_SEND_WINDOW), + ("sing-mux-idle", vcore::limits::SING_MUX_IDLE_CONNECTIONS), + ( + "sing-mux-command-queue", + vcore::limits::SING_MUX_COMMAND_QUEUE, + ), + ("sing-mux-chunk", vcore::limits::SING_MUX_CHUNK), + ("smux-receive-queue", vcore::limits::SMUX_RECEIVE_QUEUE), + ( + "yamux-admissions", + vcore::limits::YAMUX_CONNECTION_ADMISSIONS, + ), + ("yamux-window", vcore::limits::YAMUX_CONNECTION_WINDOW), + ]); + let registry: serde_json::Value = + serde_json::from_str(include_str!("protocols/limits.json")).unwrap(); + let rows = registry["limits"].as_array().unwrap(); + assert_eq!(rows.len(), expected.len()); + let mut actual = BTreeMap::new(); + for row in rows { + let id = row["id"].as_str().unwrap(); + assert!( + actual + .insert(id, row["value"].as_u64().unwrap() as usize) + .is_none() + ); + for key in ["unit", "owner", "saturation", "cancellation"] { + assert!(!row[key].as_str().unwrap().is_empty()); + } + assert!(!row["boundary_cases"].as_array().unwrap().is_empty()); + } + assert_eq!(actual, expected); +} diff --git a/tests/mihomo/trojan.rs b/tests/mihomo/trojan.rs new file mode 100644 index 0000000..fce3ae3 --- /dev/null +++ b/tests/mihomo/trojan.rs @@ -0,0 +1,402 @@ +//! Trojan consumer checks through public YAML and Invoke, owned by the TROJAN runner. +use super::*; +use sha2::{Digest, Sha256}; +use vcore::resources::case_events::Case; + +#[path = "trojan_runtime.rs"] +mod runtime; + +fn fixture() -> Value { + serde_json::from_str(&env::var("VCORE_TROJAN_FIXTURE").expect("use the TROJAN runner")).unwrap() +} + +fn free_port() -> u16 { + for _ in 0..32 { + let tcp = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = tcp.local_addr().unwrap().port(); + match reserve_runtime_families(tcp) { + Ok(_reservation) => return port, + Err(error) if error.kind() == io::ErrorKind::AddrInUse => {} + Err(error) => panic!("runtime fixture port reservation: {error}"), + } + // Only pre-traffic reservation retries, never failed runtime starts. + } + panic!("could not reserve a dual-family TCP/UDP runtime fixture port"); +} + +fn reserve_runtime_families(tcp: TcpListener) -> io::Result<(TcpListener, TcpListener, UdpSocket)> { + let address = tcp.local_addr()?; + let tcp_v6 = TcpListener::bind((Ipv6Addr::LOCALHOST, address.port()))?; + let udp = UdpSocket::bind(address)?; + Ok((tcp, tcp_v6, udp)) +} + +#[test] +fn fixture_runtime_port_rejects_occupied_udp_and_ipv6_tcp() { + for udp_busy in [true, false] { + let tcp = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = tcp.local_addr().unwrap(); + if udp_busy { + let _occupied = UdpSocket::bind(address).unwrap(); + assert!(reserve_runtime_families(tcp).is_err()); + } else { + let _occupied = TcpListener::bind((Ipv6Addr::LOCALHOST, address.port())).unwrap(); + assert!(reserve_runtime_families(tcp).is_err()); + } + } +} + +fn config(node: Value, port: u16) -> Value { + json!({"socks-port":port,"ipv6":true,"proxies":[node],"rules":["MATCH,peer"]}) +} + +fn tcp_base(proxy: SocketAddr, ipv6: bool, domain: bool) { + let listener = TcpListener::bind(origin_address(ipv6)).unwrap(); + listener.set_nonblocking(true).unwrap(); + let target = listener.local_addr().unwrap(); + let mut client = socks_login(proxy, false); + let mut request = vec![5, 1, 0]; + request.extend_from_slice(&fixture_address(target, domain)); + client.write_all(&request).unwrap(); + socks_reply(&mut client); + let mut remote = accept_until(&listener); + remote.write_all(b"server-first").unwrap(); + let mut greeting = [0; 12]; + client.read_exact(&mut greeting).unwrap(); + assert_eq!(&greeting, b"server-first"); + let payload: Vec = (0..10 * 1024 * 1024).map(|n| (n % 251) as u8).collect(); + let expected = Sha256::digest(&payload); + let origin = thread::spawn(move || { + let mut hash = Sha256::new(); + let mut received = 0; + let mut buffer = [0; 8191]; + while received < 10 * 1024 * 1024 { + let n = remote.read(&mut buffer).unwrap(); + assert_ne!(n, 0, "truncated upload"); + received += n; + hash.update(&buffer[..n]); + remote.write_all(&buffer[..n]).unwrap(); + } + assert_eq!(hash.finalize(), expected); + assert_eq!(remote.read(&mut [0; 1]).unwrap(), 0, "upload EOF"); + remote.write_all(b"half-close-tail").unwrap(); + remote.shutdown(std::net::Shutdown::Write).unwrap(); + }); + let mut upload = client.try_clone().unwrap(); + let writer = thread::spawn(move || { + for fragment in payload.chunks(4093) { + upload.write_all(fragment).unwrap(); + } + upload.shutdown(std::net::Shutdown::Write).unwrap(); + }); + let mut hash = Sha256::new(); + let mut remaining = 10 * 1024 * 1024; + let mut buffer = [0; 6151]; + while remaining > 0 { + let size = buffer.len().min(remaining); + let n = client.read(&mut buffer[..size]).unwrap(); + assert_ne!(n, 0, "truncated download"); + remaining -= n; + hash.update(&buffer[..n]); + } + assert_eq!(hash.finalize(), expected); + let mut tail = Vec::new(); + client.read_to_end(&mut tail).unwrap(); + assert_eq!(tail, b"half-close-tail"); + writer.join().unwrap(); + origin.join().unwrap(); +} + +fn udp_base(proxy: SocketAddr, ipv6: bool, domain: bool) { + let remote = UdpSocket::bind(origin_address(ipv6)).unwrap(); + remote.set_read_timeout(Some(IO_TIMEOUT)).unwrap(); + let target = remote.local_addr().unwrap(); + let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + socket.set_read_timeout(Some(IO_TIMEOUT)).unwrap(); + let mut control = socks_login(proxy, false); + control.write_all(&[5, 3, 0, 1, 0, 0, 0, 0, 0, 0]).unwrap(); + let relay = socks_reply(&mut control); + let mut request_header = vec![0, 0, 0]; + request_header.extend_from_slice(&fixture_address(target, domain)); + let mut response_header = vec![0, 0, 0]; + // Native Xray preserves the domain-form source; Mihomo literal cases + // preserve the literal source. Neither may be silently rewritten by VCore. + response_header.extend_from_slice(&fixture_address(target, domain)); + let mut buffer = [0; 9000]; + // Xray's Trojan writer uses an 8192-byte total-frame buffer (address + + // length + CRLF + payload). Mihomo's literal path permits 8192 payload. + // Exercise each real path's maximum; VCore's 8192/8193 codec boundary is + // separately tested, never inferred from a peer's smaller buffer. + let maximum = fixture()["udp_payload_max"].as_u64().unwrap() as usize; + for size in [1, 64, 512, 1200, maximum] { + for sequence in 0..100u8 { + let payload: Vec = (0..size) + .map(|n| (n as u8).wrapping_add(sequence)) + .collect(); + let mut request = request_header.clone(); + request.extend_from_slice(&payload); + socket.send_to(&request, relay).unwrap(); + let (n, peer) = remote.recv_from(&mut buffer).unwrap_or_else(|error| panic!("Trojan UDP upload ipv6={ipv6} domain={domain} size={size} sequence={sequence}: {error}")); + assert_eq!(&buffer[..n], payload); + remote.send_to(&buffer[..n], peer).unwrap(); + let (n, from) = socket.recv_from(&mut buffer).unwrap_or_else(|error| panic!("Trojan UDP download ipv6={ipv6} domain={domain} size={size} sequence={sequence}: {error}")); + assert_eq!(from, relay); + assert_eq!(&buffer[..response_header.len()], response_header); + assert_eq!( + n - response_header.len(), + payload.len(), + "UDP response length size={size} sequence={sequence}" + ); + assert_eq!( + Sha256::digest(&buffer[response_header.len()..n]), + Sha256::digest(&payload), + "UDP response content size={size} sequence={sequence}" + ); + } + } +} + +fn measurement(node: &Value) { + let listener = TcpListener::bind(origin_address(false)).unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let origin = thread::spawn(move || { + let mut peer = buffered(accept_until(&listener)); + assert!(head(&mut peer).starts_with("HEAD /")); + peer.get_mut() + .write_all(b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n") + .unwrap(); + }); + let response = invoke( + "measureDelay", + None, + json!({"configYamls":[json!({"proxies":[node]}).to_string()],"timeout":5,"url":format!("http://{address}/")}), + ); + origin.join().unwrap(); + assert_eq!(response["results"][0]["success"], true); +} + +#[test] +#[ignore = "requires the owned TROJAN native-peer runner"] +fn public_trojan_native_base() { + let _case = Case::new("TROJAN-NATIVE", "public_trojan_native_base"); + let fixture = fixture(); + invoke("initialize", None, json!({"dataDir":fixture["data_dir"]})); + let port = free_port(); + let node = &fixture["node"]; + let core = Core::start(&config(node.clone(), port).to_string()); + let proxy = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + for (ipv6, domain) in [(false, false), (true, false), (false, true)] { + tcp_base(proxy, ipv6, domain); + if !domain { + udp_base(proxy, ipv6, domain); + } + } + core.stop(); + drop(TcpListener::bind(proxy).unwrap()); + drop(UdpSocket::bind(proxy).unwrap()); + measurement(node); +} + +#[test] +#[ignore = "requires the owned TROJAN native-peer runner"] +fn public_trojan_native_udp_domain() { + let _case = Case::new("TROJAN-NATIVE", "public_trojan_native_udp_domain"); + let fixture = fixture(); + invoke("initialize", None, json!({"dataDir":fixture["data_dir"]})); + let port = free_port(); + let core = Core::start(&config(fixture["node"].clone(), port).to_string()); + udp_base(SocketAddr::from((Ipv4Addr::LOCALHOST, port)), false, true); + core.stop(); + drop(TcpListener::bind((Ipv4Addr::LOCALHOST, port)).unwrap()); + drop(UdpSocket::bind((Ipv4Addr::LOCALHOST, port)).unwrap()); +} + +#[test] +#[ignore = "requires the owned TROJAN native-peer runner"] +fn public_trojan_native_extended_early_data() { + let _case = Case::new("TROJAN-NATIVE", "public_trojan_native_extended_early_data"); + let fixture = fixture(); + invoke("initialize", None, json!({"dataDir":fixture["data_dir"]})); + let port = free_port(); + let core = Core::start(&config(fixture["node"].clone(), port).to_string()); + let proxy = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + for (ipv6, domain) in [(false, false), (true, false), (false, true)] { + for server_first in [false, true] { + probe_socks_tcp_target(proxy, false, ipv6, domain, server_first); + } + udp_base(proxy, ipv6, domain); + } + core.stop(); + measurement(&fixture["node"]); +} + +#[test] +#[ignore = "requires the owned TROJAN native-peer runner"] +fn public_trojan_native_transport_negative() { + let _case = Case::new("TROJAN-NATIVE", "public_trojan_native_transport_negative"); + let fixture = fixture(); + invoke("initialize", None, json!({"dataDir":fixture["data_dir"]})); + let mut node = fixture["node"].clone(); + match fixture["mode"].as_str().unwrap() { + "ws" => { + node["ws-opts"]["path"] = json!("/wrong"); + assert_no_origin_bytes(node.clone()); + node = fixture["node"].clone(); + node["ws-opts"]["headers"]["Host"] = json!("wrong.example"); + } + "grpc" => { + node["grpc-opts"]["grpc-service-name"] = json!("wrong"); + } + "ws-alpn" => {} + _ => panic!("invalid negative fixture"), + } + assert_no_origin_bytes(node); +} + +fn assert_no_origin_bytes(node: Value) { + let listener = TcpListener::bind(origin_address(false)).unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = free_port(); + let core = Core::start(&config(node, port).to_string()); + let mut client = socks_login(SocketAddr::from((Ipv4Addr::LOCALHOST, port)), false); + let mut request = vec![5, 1, 0]; + request.extend_from_slice(&socks_address(listener.local_addr().unwrap())); + request.extend_from_slice(b"must-not-reach-origin"); + client.write_all(&request).unwrap(); + let mut response = [0; 10]; + client.read_exact(&mut response).unwrap(); + // Trojan has no authentication ACK; a successful local CONNECT is not an + // authenticated-peer claim. Both a rejected CONNECT and subsequent EOF + // must fail closed without delivering business bytes to the origin. + if response[1] == 0 { + match client.read(&mut [0; 1]) { + Ok(0) => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::ConnectionReset | io::ErrorKind::ConnectionAborted + ) => {} + other => panic!("authentication failure remained usable: {other:?}"), + } + } + assert_eq!( + listener.accept().unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + core.stop(); +} + +#[test] +#[ignore = "requires the owned TROJAN native-peer runner"] +fn public_trojan_native_policy_and_group_snapshots() { + let _case = Case::new( + "TROJAN-NATIVE", + "public_trojan_native_policy_and_group_snapshots", + ); + let fixture = fixture(); + invoke("initialize", None, json!({"dataDir":fixture["data_dir"]})); + let original = fixture["node"].clone(); + let mut wrong_password = original.clone(); + wrong_password["password"] = json!("wrong-credential"); + assert_no_origin_bytes(wrong_password); + let mut untrusted = original.clone(); + untrusted.as_object_mut().unwrap().remove("fingerprint"); + assert_no_origin_bytes(untrusted.clone()); + let mut wrong_pin = original.clone(); + wrong_pin["fingerprint"] = json!("00".repeat(32)); + wrong_pin["skip-cert-verify"] = json!(true); + assert_no_origin_bytes(wrong_pin); + // skip only bypasses PKI; a supplied pin continues to be mandatory. + for mut node in [original.clone(), untrusted] { + node["skip-cert-verify"] = json!(true); + let port = free_port(); + let core = Core::start(&config(node, port).to_string()); + probe_socks_tcp(SocketAddr::from((Ipv4Addr::LOCALHOST, port)), false, false); + core.stop(); + } + println!( + "TROJAN policy: wrong password, untrusted certificate, wrong pin, skip and pin passed" + ); + // A concrete Trojan upstream is also usable as the physical first hop. + let port = free_port(); + let controller = SocketAddr::from((Ipv4Addr::LOCALHOST, free_port())); + let hop = fixture["hop"].clone(); + let mut leaf = original.clone(); + leaf["dialer-proxy"] = json!("hop"); + // Only the concrete upstream resolves this synthetic server name. VCore + // must not turn it into a local direct socket or invoke system DNS. + leaf["server"] = json!("vcore-fixture.test"); + let mut document = config(leaf.clone(), port); + document["proxies"] + .as_array_mut() + .unwrap() + .push(hop.clone()); + let core = Core::start(&document.to_string()); + println!("TROJAN policy: checking concrete upstream"); + let proxy = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + probe_socks_tcp(proxy, false, false); + probe_socks_udp(proxy, false, false); + core.stop(); + leaf["server"] = original["server"].clone(); + leaf["dialer-proxy"] = json!("outer"); + let document = json!({"socks-port":port,"ipv6":true,"external-controller":controller.to_string(),"secret":"fixture-controller-only","proxies":[leaf,hop],"proxy-groups":[{"name":"outer","type":"select","proxies":["inner"]},{"name":"inner","type":"select","proxies":["hop","DIRECT","REJECT"]}],"rules":["MATCH,peer"]}); + let core = Core::start(&document.to_string()); + println!("TROJAN policy: checking nested group snapshots"); + let mut tcp = groups::TcpFlow::open(proxy); + let mut udp = groups::UdpFlow::open(proxy); + udp.exchange(1); + groups::select(controller, "REJECT"); + tcp.exchange(2); + udp.exchange(3); + let listener = TcpListener::bind(origin_address(false)).unwrap(); + listener.set_nonblocking(true).unwrap(); + let mut denied = socks_login(proxy, false); + let mut request = vec![5, 1, 0]; + request.extend_from_slice(&socks_address(listener.local_addr().unwrap())); + denied.write_all(&request).unwrap(); + let mut response = [0; 10]; + denied.read_exact(&mut response).unwrap(); + assert_ne!(response[1], 0); + assert_eq!( + listener.accept().unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + groups::select(controller, "DIRECT"); + probe_socks_tcp(proxy, false, false); + probe_socks_udp(proxy, false, false); + tcp.exchange(4); + udp.exchange(5); + core.stop(); + tcp.assert_closed(); + drop((tcp, udp)); + drop(TcpListener::bind(proxy).unwrap()); + drop(UdpSocket::bind(proxy).unwrap()); + // Public protocol gates do not become dependent on the selected transport. + let mut disabled = original; + disabled["udp"] = json!(false); + let mut document = config(disabled, port); + document["ipv6"] = json!(false); + let core = Core::start(&document.to_string()); + probe_socks_tcp(proxy, false, false); + let origin = UdpSocket::bind("127.0.0.1:0").unwrap(); + origin + .set_read_timeout(Some(Duration::from_millis(150))) + .unwrap(); + let client = UdpSocket::bind("127.0.0.1:0").unwrap(); + let mut control = socks_login(proxy, false); + control.write_all(&[5, 3, 0, 1, 0, 0, 0, 0, 0, 0]).unwrap(); + let relay = socks_reply(&mut control); + let mut packet = vec![0, 0, 0]; + packet.extend_from_slice(&socks_address(origin.local_addr().unwrap())); + packet.push(1); + client.send_to(&packet, relay).unwrap(); + assert!(origin.recv_from(&mut [0; 32]).is_err()); + let mut denied = socks_login(proxy, false); + let mut request = vec![5, 1, 0]; + request.extend_from_slice(&socks_address(SocketAddr::from((Ipv6Addr::LOCALHOST, 9)))); + denied.write_all(&request).unwrap(); + denied.read_exact(&mut response).unwrap(); + assert_ne!(response[1], 0); + core.stop(); +} diff --git a/tests/mihomo/trojan_runtime.rs b/tests/mihomo/trojan_runtime.rs new file mode 100644 index 0000000..a74d24a --- /dev/null +++ b/tests/mihomo/trojan_runtime.rs @@ -0,0 +1,337 @@ +//! Consumer entrypoints and lifecycle against a real owned Mihomo listener. +use super::*; +#[cfg(any(target_os = "macos", target_os = "ios"))] +use std::os::{fd::AsRawFd, unix::net::UnixDatagram}; + +struct Association { + control: TcpStream, + socket: UdpSocket, + relay: SocketAddr, + origin: UdpSocket, + peer: Option, +} + +impl Association { + fn open(proxy: SocketAddr) -> Self { + let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); + socket.set_read_timeout(Some(IO_TIMEOUT)).unwrap(); + let mut control = socks_login(proxy, false); + let mut request = vec![5, 3, 0]; + request.extend_from_slice(&socks_address(socket.local_addr().unwrap())); + control.write_all(&request).unwrap(); + let relay = socks_reply(&mut control); + let origin = UdpSocket::bind("127.0.0.1:0").unwrap(); + origin.set_read_timeout(Some(IO_TIMEOUT)).unwrap(); + Self { + control, + socket, + relay, + origin, + peer: None, + } + } + + fn request(&self, payload: &[u8]) -> Vec { + let mut packet = vec![0, 0, 0]; + packet.extend_from_slice(&socks_address(self.origin.local_addr().unwrap())); + packet.extend_from_slice(payload); + packet + } + + fn exchange(&mut self, payload: &[u8]) { + let packet = self.request(payload); + self.socket.send_to(&packet, self.relay).unwrap(); + let mut buffer = [0; 9000]; + let (n, peer) = self.origin.recv_from(&mut buffer).unwrap(); + assert_eq!(&buffer[..n], payload); + self.peer = Some(peer); + self.origin.send_to(&buffer[..n], peer).unwrap(); + let (n, source) = self.socket.recv_from(&mut buffer).unwrap(); + assert_eq!(source, self.relay); + assert_eq!(&buffer[..n], packet); + } + + fn assert_quiet(&self) { + let mut bytes = [0; 9000]; + self.socket.set_nonblocking(true).unwrap(); + self.origin.set_nonblocking(true).unwrap(); + assert_eq!( + self.socket.recv_from(&mut bytes).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + assert_eq!( + self.origin.recv_from(&mut bytes).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + } +} + +#[test] +#[ignore = "requires the owned TROJAN native-peer runner"] +fn public_trojan_native_lifecycle() { + let _case = Case::new("TROJAN-NATIVE", "runtime::public_trojan_native_lifecycle"); + let fixture = fixture(); + invoke("initialize", None, json!({"dataDir":fixture["data_dir"]})); + // The process-wide Invoke worker is lazily created by the first instance; + // it is not owned by a Running Session. Establish that baseline once. + Core::start(&config(fixture["node"].clone(), free_port()).to_string()).stop(); + for cycle in 0..20 { + let cycle_case = Case::new("TROJAN-LIFE-CYCLE", "stop_and_remain_quiet"); + let baseline = ss_lifecycle::open_fd_count(); + let port = free_port(); + let proxy = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + let yaml = config(fixture["node"].clone(), port).to_string(); + let mut associations = Vec::new(); + let mut stalled = None; + if cycle % 5 == 0 { + let core = Core::start(&yaml); + probe_socks_tcp(proxy, false, false); + probe_socks_udp(proxy, false, false); + core.stop(); + } else if cycle % 5 == 3 { + let occupied = UdpSocket::bind(proxy).unwrap(); + let id = invoke("createInstance", None, json!({}))["instanceId"] + .as_str() + .unwrap() + .to_owned(); + let core = Core(Some(id)); + invoke("prepare", core.0.as_deref(), json!({"configYaml":yaml})); + assert_eq!( + invoke_response("start", core.0.as_deref(), json!({}))["success"], + false + ); + assert_ne!( + invoke("getState", core.0.as_deref(), json!({}))["state"], + "running" + ); + core.stop(); + drop(occupied); + } else if cycle % 5 == 2 { + let blackhole = TcpListener::bind("127.0.0.1:0").unwrap(); + blackhole.set_nonblocking(true).unwrap(); + let mut node = fixture["node"].clone(); + node["port"] = json!(blackhole.local_addr().unwrap().port()); + let core = Core::start(&config(node, port).to_string()); + let mut client = socks_login(proxy, false); + client.write_all(&[5, 1, 0, 1, 127, 0, 0, 1, 0, 9]).unwrap(); + let mut remote = accept_until(&blackhole); + assert!(remote.read(&mut [0; 8192]).unwrap() > 0); + core.stop(); + let mut bytes = Vec::new(); + remote.read_to_end(&mut bytes).unwrap(); + drop((remote, client)); + // Keep the blackhole listening: any resumed/retried handshake is + // detected during the post-Stop window, not merely dropped. + stalled = Some(blackhole); + } else { + let core = Core::start(&yaml); + let mut first = groups::TcpFlow::open(proxy); + let mut second = groups::TcpFlow::open(proxy); + first.exchange(1); + second.exchange(2); + let mut udp1 = Association::open(proxy); + let mut udp2 = Association::open(proxy); + udp1.exchange(b"association-one"); + udp2.exchange(b"association-two"); + if cycle % 5 == 4 { + drop(first); + second.exchange(3); + udp1.exchange(b"after-one-tcp-cancelled"); + udp2.exchange(b"other-association-still-live"); + } else { + first.exchange(4); + drop(first); + } + core.stop(); + second.assert_closed(); + for udp in [&mut udp1, &mut udp2] { + assert_eq!(udp.control.read(&mut [0; 1]).unwrap(), 0); + udp.origin + .send_to(b"late-after-stop", udp.peer.unwrap()) + .unwrap(); + } + drop(second); + associations.extend([udp1, udp2]); + } + // Only caller-owned fixture fds remain: each association has two + // dual-family UDP sockets (four fds) and its TCP control (one fd). + let retained_fixture_fds = associations.len() * 5 + usize::from(stalled.is_some()); + ss_lifecycle::assert_fd_returned(baseline.map(|count| count + retained_fixture_fds)); + // Port rebind occurs immediately at Stop return, before any quiet wait. + let tcp_guard = TcpListener::bind(proxy).unwrap(); + let udp_guard = UdpSocket::bind(proxy).unwrap(); + let quiet = Instant::now(); + while quiet.elapsed() < Duration::from_secs(5) { + ss_lifecycle::assert_fd_returned( + baseline.map(|count| count + retained_fixture_fds + 3), + ); + for association in &associations { + association.assert_quiet(); + } + if let Some(listener) = &stalled { + assert_eq!( + listener.accept().unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + } + thread::sleep(Duration::from_millis(25)); + } + drop((tcp_guard, udp_guard, associations, stalled)); + ss_lifecycle::assert_fd_returned(baseline); + drop(cycle_case); + } +} + +#[test] +#[ignore = "requires the owned TROJAN native-peer runner"] +fn public_trojan_native_certificate_names() { + let _case = Case::new( + "TROJAN-NATIVE", + "runtime::public_trojan_native_certificate_names", + ); + let fixture = fixture(); + invoke("initialize", None, json!({"dataDir":fixture["data_dir"]})); + let node = fixture["node"].clone(); + let port = free_port(); + let core = Core::start(&config(node.clone(), port).to_string()); + probe_socks_tcp(SocketAddr::from((Ipv4Addr::LOCALHOST, port)), false, false); + core.stop(); + let mut wrong_name = node.clone(); + wrong_name["sni"] = json!("wrong.example"); + assert_no_origin_bytes(wrong_name.clone()); + wrong_name["skip-cert-verify"] = json!(true); + // A non-leaf pin keeps chain/name validation even when skip is enabled. + assert_no_origin_bytes(wrong_name.clone()); + wrong_name.as_object_mut().unwrap().remove("fingerprint"); + let core = Core::start(&config(wrong_name, port).to_string()); + probe_socks_tcp(SocketAddr::from((Ipv4Addr::LOCALHOST, port)), false, false); + core.stop(); + let mut wrong_pin = node; + wrong_pin["fingerprint"] = json!("11".repeat(32)); + assert_no_origin_bytes(wrong_pin); +} + +#[test] +#[ignore = "requires the owned TROJAN native-peer runner"] +fn public_trojan_native_udp_isolation_and_limit() { + let _case = Case::new( + "TROJAN-NATIVE", + "runtime::public_trojan_native_udp_isolation_and_limit", + ); + let fixture = fixture(); + invoke("initialize", None, json!({"dataDir":fixture["data_dir"]})); + let port = free_port(); + let core = Core::start(&config(fixture["node"].clone(), port).to_string()); + let proxy = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); + let mut first = Association::open(proxy); + let mut second = Association::open(proxy); + for n in 0..100u8 { + first.exchange(&[1, n]); + second.exchange(&[2, n]); + } + // Unauthorised source must not enter either association. + let rogue = UdpSocket::bind("127.0.0.1:0").unwrap(); + rogue + .send_to(&first.request(b"rogue"), first.relay) + .unwrap(); + first + .origin + .set_read_timeout(Some(Duration::from_millis(100))) + .unwrap(); + assert!(first.origin.recv_from(&mut [0; 9000]).is_err()); + first + .socket + .send_to(&first.request(&[7; 8193]), first.relay) + .unwrap(); + assert!(first.origin.recv_from(&mut [0; 9000]).is_err()); + second.exchange(b"oversize-on-other-association-did-not-leak"); + core.stop(); +} + +#[test] +#[cfg(any(target_os = "macos", target_os = "ios"))] +#[ignore = "requires the owned TROJAN native-peer runner"] +fn public_trojan_native_entrypoints() { + let _case = Case::new("TROJAN-NATIVE", "runtime::public_trojan_native_entrypoints"); + let fixture = fixture(); + invoke("initialize", None, json!({"dataDir":fixture["data_dir"]})); + let port = free_port(); + let yaml = json!({"port":port,"proxies":[fixture["node"]],"rules":["MATCH,peer"]}); + let core = Core::start(&yaml.to_string()); + for mode in [Mode::Forward, Mode::Chunked, Mode::Connect, Mode::Upgrade] { + probe(SocketAddr::from((Ipv4Addr::LOCALHOST, port)), false, mode); + } + core.stop(); + let controller = SocketAddr::from((Ipv4Addr::LOCALHOST, free_port())); + let (host, peer) = UnixDatagram::pair().unwrap(); + host.set_nonblocking(true).unwrap(); + peer.set_read_timeout(Some(IO_TIMEOUT)).unwrap(); + let dns = UdpSocket::bind("127.0.0.1:0").unwrap(); + dns.set_read_timeout(Some(IO_TIMEOUT)).unwrap(); + let yaml = json!({"tun":{"enable":true},"external-controller":controller.to_string(),"secret":"fixture-controller-only","proxies":[fixture["node"]],"rules":["MATCH,peer"],"dns":{"enable":true,"ipv6":false,"nameserver":[format!("udp://{}#peer",dns.local_addr().unwrap())]}}); + let core = Core::start_with( + &yaml.to_string(), + json!({"tunFd":host.as_raw_fd(),"tunFraming":"utun"}), + ); + let origin = UdpSocket::bind("127.0.0.1:0").unwrap(); + origin.set_read_timeout(Some(IO_TIMEOUT)).unwrap(); + peer.send(&tun::packet( + origin.local_addr().unwrap(), + 17, + (0, 0, 0), + b"tun-udp", + )) + .unwrap(); + let mut bytes = [0; 1500]; + let (n, source) = origin.recv_from(&mut bytes).unwrap(); + assert_eq!(&bytes[..n], b"tun-udp"); + origin.send_to(b"tun-reply", source).unwrap(); + assert_eq!(&tun::receive(&peer, 17)[28..], b"tun-reply"); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let target = listener.local_addr().unwrap(); + peer.send(&tun::packet(target, 6, (1, 0, 2), &[])).unwrap(); + let syn_ack = tun::receive(&peer, 6); + assert_eq!(syn_ack[33] & 0x12, 0x12); + let seq = u32::from_be_bytes(syn_ack[24..28].try_into().unwrap()).wrapping_add(1); + peer.send(&tun::packet(target, 6, (2, seq, 0x18), b"tun-tcp")) + .unwrap(); + let mut remote = accept_until(&listener); + remote.read_exact(&mut bytes[..7]).unwrap(); + assert_eq!(&bytes[..7], b"tun-tcp"); + remote.write_all(b"tun-tcp-reply").unwrap(); + let mut received = Vec::new(); + while received.len() < 13 { + let packet = tun::receive(&peer, 6); + let offset = 20 + usize::from(packet[32] >> 4) * 4; + received.extend_from_slice(&packet[offset..]); + } + assert_eq!(received, b"tun-tcp-reply"); + // Synthetic DNS A question through the TUN DNS interception path. No host + // resolver, route, firewall or network service is modified. + let query = b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x07fixture\x07invalid\x00\x00\x01\x00\x01"; + peer.send(&tun::packet( + "198.18.0.1:53".parse().unwrap(), + 17, + (0, 0, 0), + query, + )) + .unwrap(); + let (n, source) = dns.recv_from(&mut bytes).unwrap(); + assert_eq!(&bytes[12..n], &query[12..]); + let mut response = bytes[..n].to_vec(); + response[2..4].copy_from_slice(&[0x81, 0x80]); + response[6..8].copy_from_slice(&[0, 1]); + response.extend_from_slice(b"\xc0\x0c\x00\x01\x00\x01\x00\x00\x00\x3c\x00\x04\xc0\x00\x02\x01"); + dns.send_to(&response, source).unwrap(); + let response = tun::receive(&peer, 17); + assert_eq!(&response[28..30], &query[..2]); + assert!(response.ends_with(&[192, 0, 2, 1])); + let traffic = tun::traffic(controller); + assert!(traffic["upTotal"].as_u64().unwrap() > 0); + assert!(traffic["downTotal"].as_u64().unwrap() > 0); + core.stop(); + assert_eq!(remote.read(&mut [0; 1]).unwrap(), 0); + assert!(unsafe { libc::fcntl(host.as_raw_fd(), libc::F_GETFL) } & libc::O_NONBLOCK != 0); + drop(TcpListener::bind(controller).unwrap()); +} diff --git a/tests/mihomo/tun.rs b/tests/mihomo/tun.rs index 719d563..abb5992 100644 --- a/tests/mihomo/tun.rs +++ b/tests/mihomo/tun.rs @@ -13,7 +13,7 @@ fn checksum(bytes: &[u8]) -> u16 { !(sum as u16) } -fn packet( +pub(super) fn packet( target: SocketAddr, protocol: u8, seq_ack_flags: (u32, u32, u8), @@ -60,7 +60,7 @@ fn packet( framed } -fn receive(peer: &UnixDatagram, protocol: u8) -> Vec { +pub(super) fn receive(peer: &UnixDatagram, protocol: u8) -> Vec { let deadline = Instant::now() + IO_TIMEOUT; loop { assert!(Instant::now() < deadline, "TUN packet deadline"); @@ -76,7 +76,7 @@ fn receive(peer: &UnixDatagram, protocol: u8) -> Vec { } } -fn traffic(controller: SocketAddr) -> Value { +pub(super) fn traffic(controller: SocketAddr) -> Value { let mut stream = buffered(TcpStream::connect_timeout(&controller, IO_TIMEOUT).unwrap()); stream.get_mut().write_all(b"GET /traffic HTTP/1.1\r\nHost: fixture\r\nAuthorization: Bearer fixture-controller-only\r\nConnection: close\r\n\r\n").unwrap(); assert!(head(&mut stream).starts_with("HTTP/1.1 200")); diff --git a/tests/mihomo_interop.rs b/tests/mihomo_interop.rs index 1f833bc..ea526d3 100644 --- a/tests/mihomo_interop.rs +++ b/tests/mihomo_interop.rs @@ -1,5 +1,5 @@ //! Actual process interop through public YAML + Invoke, not a test-only dispatcher. -//! Run `bash tests/run_mihomo_interop.sh`; use loopback or an owned host-only network. +//! Compile-only legacy fixture pending full container migration; see tests/README.md. #![cfg(all( feature = "ffi", feature = "inbound-http", @@ -58,6 +58,9 @@ mod soak; mod sockets; #[path = "mihomo/ss_lifecycle.rs"] mod ss_lifecycle; +#[cfg(feature = "outbound-trojan")] +#[path = "mihomo/trojan.rs"] +mod trojan; use sockets::GuardedUdpSocket as UdpSocket; #[cfg(any(target_os = "macos", target_os = "ios"))] #[path = "mihomo/tun.rs"] @@ -329,14 +332,19 @@ fn probe(proxy: SocketAddr, authenticated: bool, mode: Mode) { fn port(variable: &str) -> u16 { env::var(variable) - .expect("run tests/run_mihomo_interop.sh") + .expect("legacy fixture; see tests/README.md") .parse() .unwrap() } #[test] -#[ignore = "requires managed mihomo processes; run tests/run_mihomo_interop.sh"] +#[ignore = "requires managed mihomo processes; legacy fixture; see tests/README.md"] fn public_client_inbounds_interoperate_with_mihomo_in_both_directions() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-REGRESSION", + "public_client_inbounds_interoperate_with_mihomo_in_both_directions", + ); let upstream = port("VCORE_MIHOMO_UPSTREAM"); let upstream_host = peer_ip(0); let downstream = port("VCORE_MIHOMO_DOWNSTREAM"); diff --git a/tests/protocols/README.md b/tests/protocols/README.md new file mode 100644 index 0000000..5a61943 --- /dev/null +++ b/tests/protocols/README.md @@ -0,0 +1,34 @@ +# 协议验证输入 + +生产字段只以 [config.yaml](../../docs/config.yaml) 和协议契约为准。 +本目录保留真实执行需要的输入,不维护开发阶段规划、候选库实验或重复 JSON 清单。 + +- foundation-cases.json:尚未替代的基础/Trojan 稳定证据标识与断言。旧服务端编排 + 未全容器化,不作为可执行宿主入口;本地基础测试使用 check core。 +- 其他可执行用例由 scripts/src/vcore_scripts 下各协议 catalog/definitions 生成, + protocol_evidence.load_manifest 统一校验。用例 ID 以协议或验证用途为前缀, + 分组使用 tcp、udp、transports、lifecycle 等能力名称,不绑定开发阶段。 +- limits.json:局部资源上限及边界 case;Rust 测试对照真实常量。 +- encryption-crypto.json 与 encryption-vectors:独立官方 Go 原语生成的密码向量, + 被生产模块的确定性测试直接使用。 +- stream_probe.rs:旧基础消费者仍有引用,保留编译;不是四个已删除的独立 spike。 + +```sh +uv run --project scripts --locked vcore-scripts check protocol-coverage --catalog-only +uv run --project scripts --locked vcore-scripts check protocol-interop --suite vless --list +uv run --project scripts --locked vcore-scripts check protocol-interop --suite vless +uv run --project scripts --locked vcore-scripts check protocol-coverage --suite vless --run-dir target/interop/runs/ +``` + +catalog-only 仅 VALID / NOT RUN。运行记录的原始事件、命令/退出、源/锁文件/对端身份、 +hash、所有者清理和资源快照共同决定结果;空、部分、重复、改源、超时、CFG-only 或 +未清理都不能 PASS。保留完整通过与原始失败,不拼接旧报告。case 子集不签收整套。 + +Mihomo 是默认 listener;缺口由官方 Xray、V2Ray、Hysteria、ssserver 补验。 +共享字段按实际整个模式选择对端,XHTTP 两腿须到同一原生会话表;分层网关不是单端 +原生支持。对端最新下载策略与命令见 [scripts](../../scripts/README.md); +所有服务端遵守[隔离规则](../../docs/testing-isolation.md)。 + +integration 保留七协议有序两跳、100 次生命周期、100 轮 40-flow 重建、1800 秒持续 +流量和实际 HY2 跳跃。短测不代替长测,编译不代替物理设备;[验收边界](../../docs/acceptance.md) +单独列出对端限制、参照差异和未完成发布条件。 diff --git a/tests/protocols/encryption-crypto.json b/tests/protocols/encryption-crypto.json new file mode 100644 index 0000000..6723e06 --- /dev/null +++ b/tests/protocols/encryption-crypto.json @@ -0,0 +1,90 @@ +{ + "ctr_ciphertext": "7dc05548c12a9614dc66ecda6d93a3c022471ba2bbc8b3eb994f23af2ca1e614a254245894", + "ctr_context": "ff24496e93b8dd02274c7196bbe0052a", + "ctr_key": "0714212e3b4855626f7c8996a3b0bdcad7e4f1fe0b1825323f4c596673808d9aa7b4c1cedbe8f5020f1c293643505d6a7784919eabb8c5d2dfecf90613202d3a4754616e7b8895a2afbcc9d6e3f0fd0a1724313e4b5865727f8c99a6b3c0cdda", + "ctr_plaintext": "031425364758697a8b9cadbecfe0f102132435465768798a9bacbdcedff001122334455667", + "generator": "Go official crypto + metacubex/blake3; synthetic keys only", + "go_version": "go1.27.1", + "headers_ciphertext": "69d7737eb3838e18df5b197620b336e1dca15c6847ce54c8b9a8b49390838b834fe7afe15c94612242a589e4149e2df1f6d484da4e68f19670af65fc6d57cf838e18df5b197620b336e1dca15c6847ce54c8b9a8b49390838b834fe7afe15c94612242a589e4149e2df1f6d484da4e68f19670af5667a17367838e18df5b197620b336e1dca15c6847ce54c8b9a8b49390838b834fe7afe15c94612242a589e4149e2df1f6d484da4e68f19670af", + "headers_plaintext": "1703030035838e18df5b197620b336e1dca15c6847ce54c8b9a8b49390838b834fe7afe15c94612242a589e4149e2df1f6d484da4e68f19670af1703030035838e18df5b197620b336e1dca15c6847ce54c8b9a8b49390838b834fe7afe15c94612242a589e4149e2df1f6d484da4e68f19670af1703030035838e18df5b197620b336e1dca15c6847ce54c8b9a8b49390838b834fe7afe15c94612242a589e4149e2df1f6d484da4e68f19670af", + "mlkem_ciphertext": "7dea6f96b530a19468a274284c11f6336723d113d06d802736bbd7eca534ec884d3f9ea32439b8bf983306db8725730c294f2561c6990ed78e8f723b7dcda6fa712a086eda115d5b8ab769f905ef7bd514f6b0412d40f2243fb218df674c294524dce4857e6e3a72c3f33c1b2eef7a6bfa9bed4779db93396d0fa8fe98a44c2c759644a6e5871a84e5c8bd8b9a8bb64bd78241fe1b3ca6d3fb54bfd6a11988d8f79fdd057e161bdb1f15d222ab06fb9f9543877209154c0553f38eb5b2a8113d3361c4ffbe52edf0d8fe3e3c8fdeba1720c579e652f0127247773451aa4b29bff20f89ae4db998227656b0c0f7221e61408a48d2da6edaba078a13e7a2d50cfc1687b349e3ea96309b6087b4661e8dd606a9f77cce7517e0c18dbb0421a8ddef276917b13de8b8f7b7fa223b9fda1f9c1f667048ded20b4ab0958b2f1bb43922ed5867025206b583b909413eefa5781c23cc1eecf4531e4f2333ccca1671731ed28b698bf4ad46c701fa3ff05a274f042201ea9f766fb99e33194c5f77607818d20ec5148d28bb1830e76ae10f844e06228a869c60225b54435af1c9790cdd18a85e17be6295570978a851ade1db04d96ba55e36d94ea67126582902a3b46cec56d85b3d9d885f76b2e2de7d5968e02681d5a3b55620068d0807ed657179732d0cf5050d85366d2345f99f1f6335dc26328a9cba8ada75c41028f34f1e7931ab78a62f7ebe8d3c5b0a00409d71f797b073398457ae0d39030153a1a8dd3667046a2027be0fda7641f83c60453e4e5ba83c3b77d0c28e3ec51c8c4253b66df5739d71862c82f1728d974362df86f29c2f7c0a2c4a07eea210f7f1898d4062685c1002774f16f1a1adb25f199a76970e54c89594ca2a4cd063ebca4213bb78705ade3170b8e30c20762529a55c0de97c0da742c2cd05e71889170cf118d08baf529bec3ec34a8b6dd89aa1e641dba4f6e98eecab3be2f7783f6ebbc6c109b30e55443efe4fe0d5bf7c966998056fed5d55414f809fa757b5b0cd5d4c88ec548acf3335b406e580c44af6b5e0024a0f70e1635dde2f1f8c764ef50790cafd27e325e76a13f8e6c0b8673498d1d64406b4b7cad4af651999cd504a7e1b2e24a173359f1b6ef270ea39d9c45904139e37d32ac85b87069647e52104e58cb2fa922aa346a8ec10addf26d482bf27387857d3621729d4ca90d1936373af03bc9c7277728116401648f3ad43aa3861898cfaf796ff9328300f6be12509953a1cf36da75f05f0e3031a406745548d0753a019e010e9ec73771f86dee11cae66692fcfe7f71db3c733c1fa49c8c106d4be4acb24b50a48e92daaee17f319b924e3e01c0b59dd2e85d680e9b8d56462e6140fd12f23d398b4d44671fd8880e4c7adb8be06cc91b7f925ca8dfddb902f377097a3fa78c3bb15bb80ee1425304f4af039521593ff1b1360350756572e6c83198667bdb02cdad23bcca2a7232d25b128d83518e9894eb25e0645f6d0375a25be3188dcad64c985867487a21b8a790039757b945c", + "mlkem_public": "7b018573944261f27d4382087e115c4a2aa38e483c96e45a3bd7846e6c35f43967ea165b7b524019582d48b4ab45daa1173c6d03501b50168a1a421e453139c9870a36dba9cbe2cb8ec5475544a71cc7682971a018c08306d602318c3c80d060e1a69043b3b1d2ac98e081b7235a7b8a59574de07b82e2bb630a8c100870dad2794144c22e94b341125baad2674064b165972fadb74025447c5d638d40220b1b0847803103eae0c59a091df2d1c6c8d3216adc2c98192d140753bf4169a7e37eca94b46d61cd88d0b5d2d068e1c0c93151bdc4520dd42a106254375e966345d24704c4b47f8ace8957ab93217793b521f36035467a6e263255d1e1802c739dbdcb330433c47d44803c3b9c61390449d385dc2a8a9913a57a1207a7357c64578da507cf1b8c4bafd0c3b0bb2650d4c0ff667df8b081fe824e48bbc70fab4b18399e5d402b54f16d24dcc58f39a53aa94dfc406579ccb374b5a4918cc5e6f99ec2a24f5d6805ad61066e05208e238c6935cb4bf37fb527a0245c0498770c62438226d15a7f95c244a89916b0a12ae00deb669134750ea2a486c830288f0b2ec46280ae920fef0bc28033030da901c17565aa9b575585788d08a048d32d184ccc22f4537aa00cb8258d17277369c72ae6809dd91650acf0b685cb09f4abb7e7b52ba34cc193b507083c03c5051d69a357cf8bb8e5e9611ceab5adbb8fb281c63d9969941c8bca9523e4a65c065741925b198834bc7a735c8a8107464cbff8fa237ac11a7f6a07735406d9b7a994e58b2652b8ab62737b88657822a96a391ee9d96e212b94e1041b33f7c0982c380e646bcbd3750ef5a056d49c40c5c5a7854987a734b524cb73755e64cc6c502b77a134a3a7500d80c4501c5a33cbf093b1a1a1ce7c9ddf028de18728115cbf70d50bf6090e643c150c953affd1211b744893641544e5b510c01c715622e49a54943cad84a99e8bd062fe8b824aa0728dd76e81ab3e0e84bc503082f48c6065725fb43b463bdb7514784cadc54d86c867ab5c2d8677015701858ebb9b017971e6383756f69cf6d93fc9042c5df0cfb52cbbf1dc6b7fb954948bc08333c2c5444ad25694a6430059471b2d662e2359b138d16c59945567cb5b79d702e9a21bc018721c2907315c33db80a3eb2a80a034480971b0008740dc868cae282476f05a2a6424710146e72aa7ef9116c3e66eb62c90aa613fe019a2578b055053482dcb80c32ba6cb8468bc34b50e762369869bb9dbbbc5574dea6b447762308d700cff9a121590aa4f10ac80eb8198160ed9da8838003bea2a78821acbd7db133ba48b28486e9861830c7a550ae10ec34671155ab027d706d11308a45aaca39977eae689af42164bfb295b1c8d90a104fb5bb660270e521a1d3b38aa8745821cf8cabd95b6090410272ac63c717429f69c3423875b312f6fccaa596342fa632fe29b9fd21a902d78611c572b182173a53577a154c33ca72c6cf1240b47c7897b8081ebb117f09f1dcbbf46599ce534c296f823705a9acf5658a02635dadc016b641357682d2569093e216ee9683a850a5d9315253bb2664b272a484c79d464ccc0d39c80c44d2e145ff0c411e917aaffbc8de1e884346a33d7a17c5a2ddc28e09d214e3203e952a70f81cbd1e4fd2c14a5b7650c24be2f29", + "mlkem_seed": "01080f161d242b323940474e555c636a71787f868d949ba2a9b0b7bec5ccd3dae1e8eff6fd040b121920272e353c434a51585f666d747b828990979ea5acb3ba", + "mlkem_shared": "fefbcb48009080a4839117bf57db4842738d2f4ab091d92963557c1ae2e92766", + "records": [ + { + "aad": "1703030035", + "cipher": "aes256gcm", + "ciphertext": "e751d051dad55b2c30cb0bf7797899d338cd04dc6046d399c3a51cf0f62afe2edc2c8e224e52b00afb6bd4b97212603180034ea373", + "context": "ff24496e93b8dd02274c7196bbe0052a", + "key": "0714212e3b4855626f7c8996a3b0bdcad7e4f1fe0b1825323f4c596673808d9aa7b4c1cedbe8f5020f1c293643505d6a7784919eabb8c5d2dfecf90613202d3a4754616e7b8895a2afbcc9d6e3f0fd0a1724313e4b5865727f8c99a6b3c0cdda", + "nonce": "000000000000000000000001", + "plaintext": "031425364758697a8b9cadbecfe0f102132435465768798a9bacbdcedff001122334455667" + }, + { + "aad": "1703030035", + "cipher": "aes256gcm", + "ciphertext": "5647cdefb6e0fd7525307961463273e9826c6b6aae83817c8bf3378fdb307176f90a3b40bf9d75ecb74c85fbea59aa4193df8a1b4a", + "context": "ff24496e93b8dd02274c7196bbe0052a", + "key": "0714212e3b4855626f7c8996a3b0bdcad7e4f1fe0b1825323f4c596673808d9aa7b4c1cedbe8f5020f1c293643505d6a7784919eabb8c5d2dfecf90613202d3a4754616e7b8895a2afbcc9d6e3f0fd0a1724313e4b5865727f8c99a6b3c0cdda", + "nonce": "000000000000000000000002", + "plaintext": "031425364758697a8b9cadbecfe0f102132435465768798a9bacbdcedff001122334455667" + }, + { + "aad": "1703030035", + "cipher": "aes256gcm", + "ciphertext": "eaa475b39adad2e77941fc984c26a512a861bd69b7dd332f313045aaf31dca80f4dfeb84eacbf31c4c8e08a6a7796ba111bdb3ed65", + "context": "ff24496e93b8dd02274c7196bbe0052a", + "key": "0714212e3b4855626f7c8996a3b0bdcad7e4f1fe0b1825323f4c596673808d9aa7b4c1cedbe8f5020f1c293643505d6a7784919eabb8c5d2dfecf90613202d3a4754616e7b8895a2afbcc9d6e3f0fd0a1724313e4b5865727f8c99a6b3c0cdda", + "nonce": "ffffffffffffffffffffffff", + "plaintext": "031425364758697a8b9cadbecfe0f102132435465768798a9bacbdcedff001122334455667" + }, + { + "aad": "1703030035", + "cipher": "chacha20poly1305", + "ciphertext": "30180d0c77bb9828856d6e7bad0f6c29282acfa6188d038e4dd19e1ffba31e50ec2f58f414c3e80926f3dbc015003947dcce7bd8ff", + "context": "ff24496e93b8dd02274c7196bbe0052a", + "key": "0714212e3b4855626f7c8996a3b0bdcad7e4f1fe0b1825323f4c596673808d9aa7b4c1cedbe8f5020f1c293643505d6a7784919eabb8c5d2dfecf90613202d3a4754616e7b8895a2afbcc9d6e3f0fd0a1724313e4b5865727f8c99a6b3c0cdda", + "nonce": "000000000000000000000001", + "plaintext": "031425364758697a8b9cadbecfe0f102132435465768798a9bacbdcedff001122334455667" + }, + { + "aad": "1703030035", + "cipher": "chacha20poly1305", + "ciphertext": "2d8c016127a0f5b5d070091c856f4c861f603144efc5bd2deb7a7f303f3d43b30dd8170489e7781d745bf1c1d973ffa870b8a551a7", + "context": "ff24496e93b8dd02274c7196bbe0052a", + "key": "0714212e3b4855626f7c8996a3b0bdcad7e4f1fe0b1825323f4c596673808d9aa7b4c1cedbe8f5020f1c293643505d6a7784919eabb8c5d2dfecf90613202d3a4754616e7b8895a2afbcc9d6e3f0fd0a1724313e4b5865727f8c99a6b3c0cdda", + "nonce": "000000000000000000000002", + "plaintext": "031425364758697a8b9cadbecfe0f102132435465768798a9bacbdcedff001122334455667" + }, + { + "aad": "1703030035", + "cipher": "chacha20poly1305", + "ciphertext": "53683b271eaa5ade23c16017a06831a90b21083b73fead408249791d0f149fd7a5be3e5f50731ba6594787326723d68f73063446d5", + "context": "ff24496e93b8dd02274c7196bbe0052a", + "key": "0714212e3b4855626f7c8996a3b0bdcad7e4f1fe0b1825323f4c596673808d9aa7b4c1cedbe8f5020f1c293643505d6a7784919eabb8c5d2dfecf90613202d3a4754616e7b8895a2afbcc9d6e3f0fd0a1724313e4b5865727f8c99a6b3c0cdda", + "nonce": "ffffffffffffffffffffffff", + "plaintext": "031425364758697a8b9cadbecfe0f102132435465768798a9bacbdcedff001122334455667" + } + ], + "wrap_records": [ + { + "cipher": "aes256gcm", + "context": "ff24496e93b8dd02274c7196bbe0052a", + "key": "0714212e3b4855626f7c8996a3b0bdcad7e4f1fe0b1825323f4c596673808d9aa7b4c1cedbe8f5020f1c293643505d6a7784919eabb8c5d2dfecf90613202d3a4754616e7b8895a2afbcc9d6e3f0fd0a1724313e4b5865727f8c99a6b3c0cdda", + "plaintext": "031425364758697a8b9cadbecfe0f102132435465768798a9bacbdcedff001122334455667031425364758697a8b9cadbecfe0f102132435465768798a9bacbdcedff001122334455667031425364758697a8b9cadbecfe0f102132435465768798a9bacbdcedff001122334455667", + "wire": "1703030035eaa475b39adad2e77941fc984c26a512a861bd69b7dd332f313045aaf31dca80f4dfeb84eacbf31c4c8e08a6a7796ba111bdb3ed651703030035838e18df5b197620b336e1dca15c6847ce54c8b9a8b49390838b834fe7afe15c94612242a589e4149e2df1f6d484da4e68f19670af17030300353473dc2375b0b0e20da851b6ca9390575246185b5650ee3681dd00cf7075256a5fdb18cea30a273e2fb517f847acbabd4ecbab37e5" + }, + { + "cipher": "chacha20poly1305", + "context": "ff24496e93b8dd02274c7196bbe0052a", + "key": "0714212e3b4855626f7c8996a3b0bdcad7e4f1fe0b1825323f4c596673808d9aa7b4c1cedbe8f5020f1c293643505d6a7784919eabb8c5d2dfecf90613202d3a4754616e7b8895a2afbcc9d6e3f0fd0a1724313e4b5865727f8c99a6b3c0cdda", + "plaintext": "031425364758697a8b9cadbecfe0f102132435465768798a9bacbdcedff001122334455667031425364758697a8b9cadbecfe0f102132435465768798a9bacbdcedff001122334455667031425364758697a8b9cadbecfe0f102132435465768798a9bacbdcedff001122334455667", + "wire": "170303003553683b271eaa5ade23c16017a06831a90b21083b73fead408249791d0f149fd7a5be3e5f50731ba6594787326723d68f73063446d5170303003571eb3511cbf047707094f80318c1e0f4475ea0fe9785a1d5a5c21888567fdeea487c261553009e69af814a6661348741ea6527cd40170303003530a2d61c09e2de6770a6bec7fb488d75b1fe69691154c6322d871595c6ef7f3da409bd89ed1276631233ab164d7ef27c7fafde344d" + } + ], + "x25519_peer": "393270183ac86b5789960dca5641f63226d41be53f8e16c3055e6a4840bae11e", + "x25519_private": "05080b0e1114171a1d202326292c2f3235383b3e4144474a4d505356595c5f62", + "x25519_public": "61809d5fdf3553fb134e6563ad0aa24df0a8b737722126ed331d85425cf98e6e", + "x25519_shared": "75c90e78f4158f5f4756d42feffa289957288a5cec54673bb424427fb8885b0b" +} diff --git a/tests/protocols/encryption-vectors/go.mod b/tests/protocols/encryption-vectors/go.mod new file mode 100644 index 0000000..691dad5 --- /dev/null +++ b/tests/protocols/encryption-vectors/go.mod @@ -0,0 +1,10 @@ +module vcore-encryption-vectors + +go 1.27.0 + +require ( + github.com/metacubex/blake3 v0.1.0 + golang.org/x/crypto v0.57.0 +) + +require golang.org/x/sys v0.48.0 // indirect diff --git a/tests/protocols/encryption-vectors/go.sum b/tests/protocols/encryption-vectors/go.sum new file mode 100644 index 0000000..e1009d3 --- /dev/null +++ b/tests/protocols/encryption-vectors/go.sum @@ -0,0 +1,6 @@ +github.com/metacubex/blake3 v0.1.0 h1:KGnjh/56REO7U+cgZA8dnBhxdP7jByrG7hTP+bu6cqY= +github.com/metacubex/blake3 v0.1.0/go.mod h1:CCkLdzFrqf7xmxCdhQFvJsRRV2mwOLDoSPg6vUTB9Uk= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/tests/protocols/encryption-vectors/main.go b/tests/protocols/encryption-vectors/main.go new file mode 100644 index 0000000..a2d7250 --- /dev/null +++ b/tests/protocols/encryption-vectors/main.go @@ -0,0 +1,158 @@ +// Independent primitive fixtures. Every key is synthetic test material. +// No network service, protocol decoder, or third-party source modification. +package main + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/mlkem" + "encoding/hex" + "encoding/json" + "os" + "runtime" + + "github.com/metacubex/blake3" + "golang.org/x/crypto/chacha20poly1305" +) + +func pattern(n, step, start int) []byte { + b := make([]byte, n) + for i := range b { + b[i] = byte(i*step + start) + } + return b +} + +func check(err error) { + if err != nil { + panic(err) + } +} + +func aeadFor(name string, ctx, material []byte) cipher.AEAD { + key := make([]byte, 32) + blake3.DeriveKey(key, string(ctx), material) + if name == "aes256gcm" { + block, err := aes.NewCipher(key) + check(err) + aead, err := cipher.NewGCM(block) + check(err) + return aead + } + aead, err := chacha20poly1305.New(key) + check(err) + return aead +} + +func main() { + seed := pattern(64, 7, 1) + kem, err := mlkem.NewDecapsulationKey768(seed) + check(err) + shared, ciphertext := kem.EncapsulationKey().Encapsulate() + // Passing a previous JSON reproduces its random encapsulation exactly. + if len(os.Args) == 2 { + data, err := os.ReadFile(os.Args[1]) + check(err) + var old map[string]json.RawMessage + check(json.Unmarshal(data, &old)) + var value string + check(json.Unmarshal(old["mlkem_ciphertext"], &value)) + ciphertext, err = hex.DecodeString(value) + check(err) + shared, err = kem.Decapsulate(ciphertext) + check(err) + } + secret := pattern(32, 3, 5) + x, err := ecdh.X25519().NewPrivateKey(secret) + check(err) + peer, err := ecdh.X25519().NewPrivateKey(pattern(32, 11, 9)) + check(err) + xShared, err := x.ECDH(peer.PublicKey()) + check(err) + context, material := pattern(16, 37, 255), pattern(96, 13, 7) + derived := make([]byte, 32) + blake3.DeriveKey(derived, string(context), material) + block, err := aes.NewCipher(derived) + check(err) + gcm, err := cipher.NewGCM(block) + check(err) + chacha, err := chacha20poly1305.New(derived) + check(err) + plain := pattern(37, 17, 3) + aad := []byte{23, 3, 3, 0, byte(len(plain) + 16)} + var records []map[string]string + for _, item := range []struct { + name string + aead cipher.AEAD + }{{"aes256gcm", gcm}, {"chacha20poly1305", chacha}} { + for _, last := range []byte{1, 2, 255} { + nonce := make([]byte, 12) + if last == 255 { + for i := range nonce { + nonce[i] = 255 + } + } else { + nonce[11] = last + } + records = append(records, map[string]string{ + "cipher": item.name, "context": hex.EncodeToString(context), "key": hex.EncodeToString(material), + "nonce": hex.EncodeToString(nonce), "aad": hex.EncodeToString(aad), "plaintext": hex.EncodeToString(plain), + "ciphertext": hex.EncodeToString(item.aead.Seal(nil, nonce, plain, aad)), + }) + } + } + ctrKey := make([]byte, 32) + blake3.DeriveKey(ctrKey, "VLESS", material) + ctrBlock, err := aes.NewCipher(ctrKey) + check(err) + ctrOut := make([]byte, len(plain)) + cipher.NewCTR(ctrBlock, context).XORKeyStream(ctrOut, plain) + var wrapped []map[string]string + for _, name := range []string{"aes256gcm", "chacha20poly1305"} { + aead := aeadFor(name, context, material) + var wire []byte + for index := range 3 { + nonce := make([]byte, 12) + if index == 0 { + for i := range nonce { + nonce[i] = 255 + } + } else if index == 2 { + nonce[11] = 1 + } + frame := append(append([]byte{}, aad...), aead.Seal(nil, nonce, plain, aad)...) + wire = append(wire, frame...) + if index == 1 { + aead = aeadFor(name, frame, material) + } + } + wrapped = append(wrapped, map[string]string{ + "cipher": name, "context": hex.EncodeToString(context), "key": hex.EncodeToString(material), + "plaintext": hex.EncodeToString(append(append(append([]byte{}, plain...), plain...), plain...)), + "wire": hex.EncodeToString(wire), + }) + } + var headersPlain, headersCipher []byte + headerCTR := cipher.NewCTR(ctrBlock, context) + for range 3 { + // An application-data record with an independently generated AEAD body. + body := gcm.Seal(nil, make([]byte, 12), plain, aad) + header := append([]byte{}, aad...) + headersPlain = append(append(headersPlain, header...), body...) + headerCTR.XORKeyStream(header, header) + headersCipher = append(append(headersCipher, header...), body...) + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + check(encoder.Encode(map[string]any{ + "generator": "Go official crypto + metacubex/blake3; synthetic keys only", "go_version": runtime.Version(), + "mlkem_seed": hex.EncodeToString(seed), "mlkem_public": hex.EncodeToString(kem.EncapsulationKey().Bytes()), + "mlkem_ciphertext": hex.EncodeToString(ciphertext), "mlkem_shared": hex.EncodeToString(shared), + "x25519_private": hex.EncodeToString(secret), "x25519_public": hex.EncodeToString(x.PublicKey().Bytes()), + "x25519_peer": hex.EncodeToString(peer.PublicKey().Bytes()), "x25519_shared": hex.EncodeToString(xShared), + "ctr_context": hex.EncodeToString(context), "ctr_key": hex.EncodeToString(material), "ctr_plaintext": hex.EncodeToString(plain), "ctr_ciphertext": hex.EncodeToString(ctrOut), + "records": records, "wrap_records": wrapped, + "headers_plaintext": hex.EncodeToString(headersPlain), "headers_ciphertext": hex.EncodeToString(headersCipher), + })) +} diff --git a/tests/protocols/foundation-cases.json b/tests/protocols/foundation-cases.json new file mode 100644 index 0000000..1733fec --- /dev/null +++ b/tests/protocols/foundation-cases.json @@ -0,0 +1,3210 @@ +{ + "schema_version": 1, + "kind": "executable-case-manifest", + "scope": "Frozen FOUNDATIONS foundations, TROJAN-SECURITY consumer requirements and INTEGRATION integration. Declarations contain no run results; each stage requires its own complete evidence.", + "cases": [ + { + "case_id": "FOUNDATIONS-SCHEMA", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.schema", + "required": true, + "row_ids": [ + "C01" + ], + "protocol": "foundation", + "network": "supplied-io-or-loopback", + "security": "per-assertion", + "udp_codec": "per-assertion", + "field_values": { + "scope": "foundation-only" + }, + "outer_family": "per-assertion", + "inner_family": "per-assertion", + "target_type": "controlled-fixture", + "upstream_graph": "per-assertion", + "peer_kind": "unit", + "peer_config": { + "selector": "feature_foundations" + }, + "gap_source": null, + "expected_observation": [ + "feature_skeletons_do_not_open_unimplemented_yaml_or_measurement_protocols", + "future_fields_and_over_limit_documents_remain_rejected", + "measurement_rejects_future_protocols_and_oversized_documents" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup" + ], + "prerequisites": [ + "rust-toolchain" + ], + "runner": "rust", + "timeout_seconds": 300 + }, + { + "case_id": "FOUNDATIONS-LIMITS", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.schema", + "required": true, + "row_ids": [], + "protocol": "foundation", + "network": "supplied-io-or-loopback", + "security": "per-assertion", + "udp_codec": "per-assertion", + "field_values": { + "scope": "foundation-only" + }, + "outer_family": "per-assertion", + "inner_family": "per-assertion", + "target_type": "controlled-fixture", + "upstream_graph": "per-assertion", + "peer_kind": "unit", + "peer_config": { + "selector": "limit_foundations" + }, + "gap_source": null, + "expected_observation": [ + "registry_matches_live_production_constants_and_has_owned_boundary_cases" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup" + ], + "prerequisites": [ + "rust-toolchain" + ], + "runner": "rust", + "timeout_seconds": 300 + }, + { + "case_id": "FOUNDATIONS-STREAM", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.streams", + "required": true, + "row_ids": [ + "W01", + "W02", + "W03", + "W04", + "W05", + "G01", + "H01", + "H02", + "H03", + "H04", + "H05", + "H06" + ], + "protocol": "foundation", + "network": "supplied-io-or-loopback", + "security": "per-assertion", + "udp_codec": "per-assertion", + "field_values": { + "scope": "foundation-only" + }, + "outer_family": "per-assertion", + "inner_family": "per-assertion", + "target_type": "controlled-fixture", + "upstream_graph": "per-assertion", + "peer_kind": "unit", + "peer_config": { + "selector": "stream_foundations" + }, + "gap_source": null, + "expected_observation": [ + "transport_options_reject_ambiguous_headers_before_io", + "http_first_header_preserves_prefix_raw_continuation_and_half_close_tail", + "legacy_h2_keeps_unframed_bytes_server_first_and_owned_whole_close", + "websocket_rejects_invalid_upgrade_responses_and_bounded_header_overflows", + "setup_deadline_releases_supplied_io_in_stream_adapters", + "cancelled_setup_releases_supplied_io_without_a_detached_task", + "ws_rejects_oversized_and_truncated_frames_instead_of_reporting_eof", + "ws_accepts_mihomo_clean_underlay_eof_only_at_complete_message_boundaries", + "dropping_an_established_ws_releases_its_only_io_owner", + "grpc_rejects_oversized_truncated_and_invalid_records", + "grpc_large_writes_obey_small_http2_windows_and_keep_byte_integrity", + "ws_partial_writes_empty_frames_ping_and_half_close_preserve_every_byte", + "grpc_handles_response_after_upload_fragmented_records_and_owned_stop", + "websocket_supplied_io_preserves_server_first_and_partial_writes", + "websocket_early_data_and_remaining_frames_keep_the_original_byte_order" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup" + ], + "prerequisites": [ + "rust-toolchain" + ], + "runner": "rust", + "timeout_seconds": 300 + }, + { + "case_id": "FOUNDATIONS-DATAGRAM", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.datagrams", + "required": true, + "row_ids": [ + "C05", + "C06", + "HY06" + ], + "protocol": "foundation", + "network": "supplied-io-or-loopback", + "security": "per-assertion", + "udp_codec": "per-assertion", + "field_values": { + "scope": "foundation-only" + }, + "outer_family": "per-assertion", + "inner_family": "per-assertion", + "target_type": "controlled-fixture", + "upstream_graph": "per-assertion", + "peer_kind": "unit", + "peer_config": { + "selector": "datagram_foundations" + }, + "gap_source": null, + "expected_observation": [ + "shadowsocks_budget_accounts_for_cipher_headers_and_identity_chain", + "socks5_budget_retains_distinct_payload_and_envelope_limits", + "direct_budget_rejects_large_sends_and_drops_oversize_responses", + "layered_directional_budgets_account_for_ip_headers_and_exact_minima" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup" + ], + "prerequisites": [ + "rust-toolchain" + ], + "runner": "rust", + "timeout_seconds": 300 + }, + { + "case_id": "FOUNDATIONS-QUIC", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.datagrams", + "required": true, + "row_ids": [ + "C06", + "HY06" + ], + "protocol": "foundation", + "network": "supplied-io-or-loopback", + "security": "per-assertion", + "udp_codec": "per-assertion", + "field_values": { + "scope": "foundation-only" + }, + "outer_family": "per-assertion", + "inner_family": "per-assertion", + "target_type": "controlled-fixture", + "upstream_graph": "per-assertion", + "peer_kind": "unit", + "peer_config": { + "selector": "quic_datagram_foundations" + }, + "gap_source": null, + "expected_observation": [ + "quic_minimum_budget_is_checked_before_io_and_exact_minimum_connects", + "pending_send_is_not_restarted_and_stop_cancels_without_waiting_for_writable", + "controlled_quic_datagram_seam_roundtrips_and_stop_closes_owned_io", + "mapped_peer_sends_to_one_physical_port_and_reports_the_logical_peer", + "incoming_queue_backpressures_without_discarding_a_controlled_burst", + "controlled_direct_datagrams_roundtrip_and_stop" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup" + ], + "prerequisites": [ + "rust-toolchain" + ], + "runner": "rust", + "timeout_seconds": 300 + }, + { + "case_id": "FOUNDATIONS-RESOLUTION", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.datagrams", + "required": true, + "row_ids": [ + "C03", + "C06" + ], + "protocol": "foundation", + "network": "supplied-io-or-loopback", + "security": "per-assertion", + "udp_codec": "per-assertion", + "field_values": { + "scope": "foundation-only" + }, + "outer_family": "per-assertion", + "inner_family": "per-assertion", + "target_type": "controlled-fixture", + "upstream_graph": "per-assertion", + "peer_kind": "unit", + "peer_config": { + "selector": "resolution_foundations" + }, + "gap_source": null, + "expected_observation": [ + "resolver_dependency_depth_is_bounded_independently_of_concurrent_lookups", + "measurement_domain_resolution_reaches_both_final_and_upstream_ip_boundaries", + "runtime_uses_configured_dns_and_weak_binding_releases_owner", + "recursive_dns_dependency_fails_before_connecting_or_joining_its_own_flight", + "lookup_deadline_and_stop_cancel_pending_resolver_without_retaining_waiters", + "standalone_measurement_resolves_only_at_ip_boundary_with_address_policy_and_stop", + "unbound_and_disabled_runtime_resolution_never_fall_back_to_system_dns" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup" + ], + "prerequisites": [ + "rust-toolchain" + ], + "runner": "rust", + "timeout_seconds": 300 + }, + { + "case_id": "FOUNDATIONS-RESOURCES", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.schema", + "required": true, + "row_ids": [], + "protocol": "foundation", + "network": "supplied-io-or-loopback", + "security": "per-assertion", + "udp_codec": "per-assertion", + "field_values": { + "scope": "foundation-only" + }, + "outer_family": "per-assertion", + "inner_family": "per-assertion", + "target_type": "controlled-fixture", + "upstream_graph": "per-assertion", + "peer_kind": "unit", + "peer_config": { + "selector": "resource_foundations" + }, + "gap_source": null, + "expected_observation": [ + "twenty_quic_lifetimes_return_owned_resources_to_zero_then_remain_quiet", + "twenty_stream_lifetimes_and_cancelled_setups_remain_quiet", + "physical_tcp_guard_survives_connect_and_releases_on_stream_drop" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "resources" + ], + "prerequisites": [ + "rust-toolchain" + ], + "runner": "rust", + "timeout_seconds": 300 + }, + { + "case_id": "FOUNDATIONS-SECURITY", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.security", + "required": true, + "row_ids": [ + "T01", + "T02", + "T03", + "T04", + "T05" + ], + "protocol": "foundation", + "network": "supplied-io-or-loopback", + "security": "per-assertion", + "udp_codec": "per-assertion", + "field_values": { + "scope": "foundation-only" + }, + "outer_family": "per-assertion", + "inner_family": "per-assertion", + "target_type": "controlled-fixture", + "upstream_graph": "per-assertion", + "peer_kind": "unit", + "peer_config": { + "selector": "security::tls::tests" + }, + "gap_source": null, + "expected_observation": [ + "webpki_rejects_untrusted_wrong_name_and_expired_unless_explicitly_skipped", + "leaf_pin_is_trust_but_nonleaf_pin_checks_chain_name_and_expiry", + "ticket_storage_obeys_exact_node_budget_and_consumes_each_ticket_once", + "every_certificate_policy_verifies_tls12_and_tls13_handshake_signatures", + "vless_keeps_webpki_tls13_and_required_h2_after_anytls_connections", + "alpn_and_tls_resumption_are_isolated_between_node_policies", + "explicit_verification_name_does_not_change_sni_or_allow_skip_to_override_it", + "mutual_tls_identity_is_required_verified_and_not_shared_between_clients", + "tls_options_reject_invalid_alpn_identity_and_budget_before_using_a_stream", + "certificate_rejection_delivers_no_business_bytes_and_diagnostics_are_redacted", + "tls_close_write_sends_notify_without_closing_the_supplied_transport", + "tls_close_notify_flush_has_a_five_second_bound", + "cancelling_tls_handshake_releases_the_caller_supplied_stream" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup" + ], + "prerequisites": [ + "rust-toolchain" + ], + "runner": "rust", + "timeout_seconds": 300 + }, + { + "case_id": "FOUNDATIONS-XUDP", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.streams", + "required": true, + "row_ids": [ + "VM06", + "VL04" + ], + "protocol": "foundation", + "network": "supplied-io-or-loopback", + "security": "per-assertion", + "udp_codec": "per-assertion", + "field_values": { + "scope": "foundation-only" + }, + "outer_family": "per-assertion", + "inner_family": "per-assertion", + "target_type": "controlled-fixture", + "upstream_graph": "per-assertion", + "peer_kind": "unit", + "peer_config": { + "selector": "xudp::tests" + }, + "gap_source": null, + "expected_observation": [ + "shared_xudp_receives_frames_without_a_vless_response_header", + "first_frame_matches_xray_mux_wire_format", + "followup_frame_carries_each_datagrams_destination", + "destination_codec_round_trips_all_address_families", + "normal_and_error_end_frames_are_distinguishable", + "oversized_response_is_rejected_before_payload_read", + "fragmented_receive_survives_cancellation_and_resumes", + "keepalive_data_is_consumed_without_becoming_an_udp_response", + "full_wire_payload_remains_available_to_proxy_inbounds" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup" + ], + "prerequisites": [ + "rust-toolchain" + ], + "runner": "rust", + "timeout_seconds": 300 + }, + { + "case_id": "FOUNDATIONS-REGRESSION", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.regression", + "required": true, + "row_ids": [], + "protocol": "legacy", + "network": "supplied-io-or-loopback", + "security": "per-assertion", + "udp_codec": "per-assertion", + "field_values": { + "scope": "foundation-only" + }, + "outer_family": "per-assertion", + "inner_family": "per-assertion", + "target_type": "controlled-fixture", + "upstream_graph": "per-assertion", + "peer_kind": "M", + "peer_config": { + "selector": "mihomo_interop" + }, + "gap_source": null, + "expected_observation": [ + "public_client_inbounds_interoperate_with_mihomo_in_both_directions" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "rust-toolchain", + "M", + "apple-container" + ], + "runner": "mihomo-legacy", + "timeout_seconds": 900 + }, + { + "case_id": "FOUNDATIONS-M-WS", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.streams", + "required": true, + "row_ids": [ + "W01", + "W02", + "W03" + ], + "protocol": "foundation", + "network": "ws", + "security": "plain", + "udp_codec": "none", + "field_values": { + "scope": "foundation-only", + "mode": "ws" + }, + "outer_family": "ipv4-loopback", + "inner_family": "ipv4-loopback", + "target_type": "controlled-echo", + "upstream_graph": "direct", + "peer_kind": "M", + "peer_config": { + "factory": "scripts/src/vcore_scripts/protocol_streams.py", + "mode": "ws" + }, + "gap_source": null, + "expected_observation": [ + "server_first", + "payload_65536", + "trailer_exact", + "origin_complete", + "protect_once", + "driver_joined", + "resources_idle" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity", + "resources" + ], + "prerequisites": [ + "M", + "openssl", + "rust-toolchain" + ], + "runner": "native-stream", + "timeout_seconds": 60 + }, + { + "case_id": "FOUNDATIONS-M-WSS", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.streams", + "required": true, + "row_ids": [ + "W01", + "W02", + "W03" + ], + "protocol": "foundation", + "network": "wss", + "security": "tls-pin", + "udp_codec": "none", + "field_values": { + "scope": "foundation-only", + "mode": "wss" + }, + "outer_family": "ipv4-loopback", + "inner_family": "ipv4-loopback", + "target_type": "controlled-echo", + "upstream_graph": "direct", + "peer_kind": "M", + "peer_config": { + "factory": "scripts/src/vcore_scripts/protocol_streams.py", + "mode": "wss" + }, + "gap_source": null, + "expected_observation": [ + "server_first", + "payload_65536", + "trailer_exact", + "origin_complete", + "protect_once", + "driver_joined", + "resources_idle" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity", + "resources" + ], + "prerequisites": [ + "M", + "openssl", + "rust-toolchain" + ], + "runner": "native-stream", + "timeout_seconds": 60 + }, + { + "case_id": "FOUNDATIONS-M-GRPC", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.streams", + "required": true, + "row_ids": [ + "G01" + ], + "protocol": "foundation", + "network": "grpc", + "security": "plain", + "udp_codec": "none", + "field_values": { + "scope": "foundation-only", + "mode": "grpc" + }, + "outer_family": "ipv4-loopback", + "inner_family": "ipv4-loopback", + "target_type": "controlled-echo", + "upstream_graph": "direct", + "peer_kind": "M", + "peer_config": { + "factory": "scripts/src/vcore_scripts/protocol_streams.py", + "mode": "grpc" + }, + "gap_source": null, + "expected_observation": [ + "server_first", + "payload_65536", + "trailer_exact", + "origin_complete", + "protect_once", + "driver_joined", + "resources_idle" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity", + "resources" + ], + "prerequisites": [ + "M", + "openssl", + "rust-toolchain" + ], + "runner": "native-stream", + "timeout_seconds": 60 + }, + { + "case_id": "FOUNDATIONS-M-GRPC-TLS", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.streams", + "required": true, + "row_ids": [ + "G01" + ], + "protocol": "foundation", + "network": "grpc-tls", + "security": "tls-pin", + "udp_codec": "none", + "field_values": { + "scope": "foundation-only", + "mode": "grpc-tls" + }, + "outer_family": "ipv4-loopback", + "inner_family": "ipv4-loopback", + "target_type": "controlled-echo", + "upstream_graph": "direct", + "peer_kind": "M", + "peer_config": { + "factory": "scripts/src/vcore_scripts/protocol_streams.py", + "mode": "grpc-tls" + }, + "gap_source": null, + "expected_observation": [ + "server_first", + "payload_65536", + "trailer_exact", + "origin_complete", + "protect_once", + "driver_joined", + "resources_idle" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity", + "resources" + ], + "prerequisites": [ + "M", + "openssl", + "rust-toolchain" + ], + "runner": "native-stream", + "timeout_seconds": 60 + }, + { + "case_id": "FOUNDATIONS-M-WS-ED", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.streams", + "required": true, + "row_ids": [ + "W01", + "W02", + "W03", + "W04", + "W05" + ], + "protocol": "foundation", + "network": "ws-header", + "security": "plain", + "udp_codec": "none", + "field_values": { + "scope": "foundation-only", + "mode": "ws-header" + }, + "outer_family": "ipv4-loopback", + "inner_family": "ipv4-loopback", + "target_type": "controlled-echo", + "upstream_graph": "direct", + "peer_kind": "M", + "peer_config": { + "factory": "scripts/src/vcore_scripts/protocol_streams.py", + "mode": "ws-header" + }, + "gap_source": null, + "expected_observation": [ + "server_first", + "payload_65536", + "trailer_exact", + "origin_complete", + "protect_once", + "driver_joined", + "resources_idle" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity", + "resources" + ], + "prerequisites": [ + "M", + "openssl", + "rust-toolchain" + ], + "runner": "native-stream", + "timeout_seconds": 60 + }, + { + "case_id": "FOUNDATIONS-V2-HTTP", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.streams", + "required": true, + "row_ids": [ + "H01", + "H02", + "H03", + "H04" + ], + "protocol": "foundation", + "network": "http", + "security": "plain", + "udp_codec": "none", + "field_values": { + "scope": "foundation-only", + "mode": "http" + }, + "outer_family": "ipv4-loopback", + "inner_family": "ipv4-loopback", + "target_type": "controlled-echo", + "upstream_graph": "direct", + "peer_kind": "V2", + "peer_config": { + "factory": "scripts/src/vcore_scripts/protocol_streams.py", + "mode": "http" + }, + "gap_source": "tests/protocols/combinations.json", + "expected_observation": [ + "server_first", + "payload_65536", + "trailer_exact", + "origin_complete", + "protect_once", + "driver_joined", + "resources_idle" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity", + "resources" + ], + "prerequisites": [ + "V2", + "openssl", + "rust-toolchain" + ], + "runner": "native-stream", + "timeout_seconds": 60 + }, + { + "case_id": "FOUNDATIONS-V2-H2", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.streams", + "required": true, + "row_ids": [ + "H05", + "H06" + ], + "protocol": "foundation", + "network": "h2", + "security": "tls-pin", + "udp_codec": "none", + "field_values": { + "scope": "foundation-only", + "mode": "h2" + }, + "outer_family": "ipv4-loopback", + "inner_family": "ipv4-loopback", + "target_type": "controlled-echo", + "upstream_graph": "direct", + "peer_kind": "V2", + "peer_config": { + "factory": "scripts/src/vcore_scripts/protocol_streams.py", + "mode": "h2" + }, + "gap_source": "tests/protocols/combinations.json", + "expected_observation": [ + "server_first", + "payload_65536", + "trailer_exact", + "origin_complete", + "protect_once", + "driver_joined", + "resources_idle" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity", + "resources" + ], + "prerequisites": [ + "V2", + "openssl", + "rust-toolchain" + ], + "runner": "native-stream", + "timeout_seconds": 60 + }, + { + "case_id": "FOUNDATIONS-V2-WS-HEADER", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.streams", + "required": true, + "row_ids": [ + "W01", + "W02", + "W03", + "W04", + "W05" + ], + "protocol": "foundation", + "network": "ws-header", + "security": "plain", + "udp_codec": "none", + "field_values": { + "scope": "foundation-only", + "mode": "ws-header" + }, + "outer_family": "ipv4-loopback", + "inner_family": "ipv4-loopback", + "target_type": "controlled-echo", + "upstream_graph": "direct", + "peer_kind": "V2", + "peer_config": { + "factory": "scripts/src/vcore_scripts/protocol_streams.py", + "mode": "ws-header" + }, + "gap_source": "tests/protocols/combinations.json", + "expected_observation": [ + "server_first", + "payload_65536", + "trailer_exact", + "origin_complete", + "protect_once", + "driver_joined", + "resources_idle" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity", + "resources" + ], + "prerequisites": [ + "V2", + "openssl", + "rust-toolchain" + ], + "runner": "native-stream", + "timeout_seconds": 60 + }, + { + "case_id": "FOUNDATIONS-V2-WS-PATH", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.streams", + "required": true, + "row_ids": [ + "W01", + "W02", + "W03", + "W04", + "W05" + ], + "protocol": "foundation", + "network": "ws-path", + "security": "plain", + "udp_codec": "none", + "field_values": { + "scope": "foundation-only", + "mode": "ws-path" + }, + "outer_family": "ipv4-loopback", + "inner_family": "ipv4-loopback", + "target_type": "controlled-echo", + "upstream_graph": "direct", + "peer_kind": "V2", + "peer_config": { + "factory": "scripts/src/vcore_scripts/protocol_streams.py", + "mode": "ws-path" + }, + "gap_source": "tests/protocols/combinations.json", + "expected_observation": [ + "server_first", + "payload_65536", + "trailer_exact", + "origin_complete", + "protect_once", + "driver_joined", + "resources_idle" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity", + "resources" + ], + "prerequisites": [ + "V2", + "openssl", + "rust-toolchain" + ], + "runner": "native-stream", + "timeout_seconds": 60 + }, + { + "case_id": "FOUNDATIONS-FEATURES", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.features", + "required": true, + "row_ids": [], + "protocol": "foundation", + "network": "none", + "security": "none", + "udp_codec": "none", + "field_values": { + "scope": "foundation-only" + }, + "outer_family": "none", + "inner_family": "none", + "target_type": "none", + "upstream_graph": "none", + "peer_kind": "unit", + "peer_config": { + "runner": "features" + }, + "gap_source": null, + "expected_observation": [ + "independent-features", + "default-feature", + "no-default-feature", + "build-identity" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup" + ], + "prerequisites": [ + "rust-toolchain" + ], + "runner": "features", + "timeout_seconds": 1200 + }, + { + "case_id": "FOUNDATIONS-SCRIPTS", + "stage": "FOUNDATIONS", + "substage": "FOUNDATIONS.regression", + "required": true, + "row_ids": [], + "protocol": "foundation", + "network": "none", + "security": "none", + "udp_codec": "none", + "field_values": { + "scope": "foundation-only" + }, + "outer_family": "none", + "inner_family": "none", + "target_type": "none", + "upstream_graph": "none", + "peer_kind": "unit", + "peer_config": { + "runner": "scripts" + }, + "gap_source": null, + "expected_observation": [ + "offline-success", + "missing-duplicate-unknown-case", + "download-failure", + "timeout", + "sigint", + "partial-start", + "permission", + "peer-exit", + "cleanup-failure", + "redaction" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup" + ], + "prerequisites": [ + "python-toolchain" + ], + "runner": "scripts", + "timeout_seconds": 300 + }, + { + "case_id": "TROJAN-CODEC", + "stage": "TROJAN", + "substage": "TROJAN.codec", + "required": true, + "row_ids": [ + "TR01", + "TR02", + "C03", + "C04" + ], + "protocol": "trojan", + "network": "per-assertion", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "scope": "protocol-consumer" + }, + "outer_family": "per-assertion", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "unit", + "peer_config": { + "selector": "trojan" + }, + "gap_source": null, + "expected_observation": [ + "request_matches_the_official_sha224_and_socks_address_wire_format", + "authentication_and_requests_are_strict_and_never_debug_credentials", + "invalid_truncated_and_oversized_frames_fail_closed_without_payload_leaks", + "datagram_limits_preserve_messages_and_drain_over_budget_responses", + "datagram_sends_one_frame_and_receives_fragmented_consecutive_frames", + "cancelled_receive_preserves_partial_frames_and_does_not_block_send", + "cancelled_partial_send_poison_closes_without_replaying_or_waiting" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "rust", + "timeout_seconds": 600 + }, + { + "case_id": "TROJAN-CFG", + "stage": "TROJAN", + "substage": "TROJAN.configuration", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "C06", + "T01", + "T03", + "T04", + "T05", + "W01", + "W02", + "W03", + "W04", + "W05", + "G01", + "TR01", + "TR02" + ], + "protocol": "trojan", + "network": "per-assertion", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "scope": "protocol-consumer" + }, + "outer_family": "per-assertion", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "unit", + "peer_config": { + "selector": "trojan_config" + }, + "gap_source": null, + "expected_observation": [ + "trojan_tcp_configuration_and_node_graph_accept_the_approved_fields", + "trojan_tcp_defaults_preserve_credentials_and_address_policy", + "trojan_invalid_configuration_is_rejected_without_exposing_credentials", + "trojan_ws_and_grpc_configuration_applies_transport_specific_defaults", + "trojan_transport_boundaries_fail_before_runtime_io" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "rust", + "timeout_seconds": 600 + }, + { + "case_id": "TROJAN-CANCEL", + "stage": "TROJAN", + "substage": "TROJAN.cancellation", + "required": true, + "row_ids": [ + "C06", + "T01", + "T03", + "W01", + "W02", + "W03", + "G01", + "TR02" + ], + "protocol": "trojan", + "network": "per-assertion", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "scope": "protocol-consumer" + }, + "outer_family": "per-assertion", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "unit", + "peer_config": { + "selector": "trojan_lifecycle" + }, + "gap_source": null, + "expected_observation": [ + "trojan_handshake_deadlines_cancel_tls_ws_and_grpc_without_retaining_io", + "trojan_protect_rejection_and_expired_deadline_send_no_network_bytes" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "resources" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "rust", + "timeout_seconds": 600 + }, + { + "case_id": "TROJAN-M-TCP", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02" + ], + "protocol": "trojan", + "network": "tcp", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "tcp", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "tcp", + "test": "public_trojan_native_base" + }, + "gap_source": null, + "expected_observation": [ + "public_trojan_native_base" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-WS", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "W01", + "W02", + "W03" + ], + "protocol": "trojan", + "network": "ws", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "ws", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "ws", + "test": "public_trojan_native_base" + }, + "gap_source": null, + "expected_observation": [ + "public_trojan_native_base" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-GRPC", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "G01" + ], + "protocol": "trojan", + "network": "grpc", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "grpc", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "grpc", + "test": "public_trojan_native_base" + }, + "gap_source": null, + "expected_observation": [ + "public_trojan_native_base" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-WS-ED-1", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "W01", + "W02", + "W03", + "W04", + "W05" + ], + "protocol": "trojan", + "network": "ws", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "ws-ed-1", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 1, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "ws-ed-1", + "test": "public_trojan_native_base" + }, + "gap_source": null, + "expected_observation": [ + "public_trojan_native_base" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-WS-ED-2048", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "W01", + "W02", + "W03", + "W04", + "W05" + ], + "protocol": "trojan", + "network": "ws", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "ws-ed-2048", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 2048, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "ws-ed-2048", + "test": "public_trojan_native_base" + }, + "gap_source": null, + "expected_observation": [ + "public_trojan_native_base" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-WS-POLICY", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "C06", + "T01", + "T04", + "T05", + "T03", + "W01", + "W02", + "W03" + ], + "protocol": "trojan", + "network": "ws", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "ws", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "ws", + "test": "public_trojan_native_policy_and_group_snapshots" + }, + "gap_source": null, + "expected_observation": [ + "public_trojan_native_policy_and_group_snapshots" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-GRPC-POLICY", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "C06", + "T01", + "T04", + "T05", + "T03", + "G01" + ], + "protocol": "trojan", + "network": "grpc", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "grpc", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "grpc", + "test": "public_trojan_native_policy_and_group_snapshots" + }, + "gap_source": null, + "expected_observation": [ + "public_trojan_native_policy_and_group_snapshots" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-WS-NEGATIVE", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "W01", + "W02", + "W03" + ], + "protocol": "trojan", + "network": "ws", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "ws", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "ws", + "test": "public_trojan_native_transport_negative" + }, + "gap_source": null, + "expected_observation": [ + "public_trojan_native_transport_negative" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-GRPC-NEGATIVE", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "G01" + ], + "protocol": "trojan", + "network": "grpc", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "grpc", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "grpc", + "test": "public_trojan_native_transport_negative" + }, + "gap_source": null, + "expected_observation": [ + "public_trojan_native_transport_negative" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-ALPN-NEGATIVE", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "W01", + "W02", + "W03" + ], + "protocol": "trojan", + "network": "ws", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "ws-alpn", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "ws-alpn", + "test": "public_trojan_native_transport_negative" + }, + "gap_source": null, + "expected_observation": [ + "public_trojan_native_transport_negative" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-V2-WS-HEADER", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "W01", + "W02", + "W03", + "W04", + "W05" + ], + "protocol": "trojan", + "network": "ws", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "ws-header", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 2048, + "udp_payload_max": 2048 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "V2", + "peer_config": { + "mode": "ws-header", + "test": "public_trojan_native_extended_early_data" + }, + "gap_source": { + "url": "https://github.com/v2fly/v2ray-core/blob/master/transport/internet/websocket/config.proto", + "limitation": "M listener only decodes Sec-WebSocket-Protocol ED; V2Ray maxEarlyData/earlyDataHeaderName covers custom-header and path suffix." + }, + "expected_observation": [ + "public_trojan_native_extended_early_data" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-V2-WS-PATH", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "W01", + "W02", + "W03", + "W04", + "W05" + ], + "protocol": "trojan", + "network": "ws", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "ws-path", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 2048, + "udp_payload_max": 2048 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "V2", + "peer_config": { + "mode": "ws-path", + "test": "public_trojan_native_extended_early_data" + }, + "gap_source": { + "url": "https://github.com/v2fly/v2ray-core/blob/master/transport/internet/websocket/config.proto", + "limitation": "M listener only decodes Sec-WebSocket-Protocol ED; V2Ray maxEarlyData/earlyDataHeaderName covers custom-header and path suffix." + }, + "expected_observation": [ + "public_trojan_native_extended_early_data" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-TCP-POLICY", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "C06", + "T01", + "T04", + "T05" + ], + "protocol": "trojan", + "network": "tcp", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "tcp", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "tcp", + "test": "public_trojan_native_policy_and_group_snapshots" + }, + "gap_source": null, + "expected_observation": [ + "public_trojan_native_policy_and_group_snapshots" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-XR-UDP-DOMAIN", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02" + ], + "protocol": "trojan", + "network": "tcp", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "tcp", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8166 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "XR", + "peer_config": { + "mode": "tcp", + "test": "public_trojan_native_udp_domain" + }, + "gap_source": { + "url": "https://github.com/MetaCubeX/mihomo/blob/Meta/transport/trojan/trojan.go", + "limitation": "WaitReadFrom calls socks5.Addr.UDPAddr(), which returns nil for a domain; actual M failure retained in TROJAN-tcp.md; Xray owns domain UDP supplement." + }, + "expected_observation": [ + "public_trojan_native_udp_domain" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-XR-WS-UDP-DOMAIN", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "W01", + "W02", + "W03" + ], + "protocol": "trojan", + "network": "ws", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "ws", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8166 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "XR", + "peer_config": { + "mode": "ws", + "test": "public_trojan_native_udp_domain" + }, + "gap_source": { + "url": "https://github.com/MetaCubeX/mihomo/blob/Meta/transport/trojan/trojan.go", + "limitation": "WaitReadFrom calls socks5.Addr.UDPAddr(), which returns nil for a domain; actual M failure retained in TROJAN-tcp.md; Xray owns domain UDP supplement." + }, + "expected_observation": [ + "public_trojan_native_udp_domain" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-XR-GRPC-UDP-DOMAIN", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "G01" + ], + "protocol": "trojan", + "network": "grpc", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "grpc", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8166 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "XR", + "peer_config": { + "mode": "grpc", + "test": "public_trojan_native_udp_domain" + }, + "gap_source": { + "url": "https://github.com/MetaCubeX/mihomo/blob/Meta/transport/trojan/trojan.go", + "limitation": "WaitReadFrom calls socks5.Addr.UDPAddr(), which returns nil for a domain; actual M failure retained in TROJAN-tcp.md; Xray owns domain UDP supplement." + }, + "expected_observation": [ + "public_trojan_native_udp_domain" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-TCP-LIFE", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02" + ], + "protocol": "trojan", + "network": "tcp", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "tcp", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "tcp", + "test": "runtime::public_trojan_native_lifecycle" + }, + "gap_source": null, + "expected_observation": [ + "runtime::public_trojan_native_lifecycle" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity", + "twenty-quiet-cycles" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-TCP-ENTRYPOINTS", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02" + ], + "protocol": "trojan", + "network": "tcp", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "tcp", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "tcp", + "test": "runtime::public_trojan_native_entrypoints" + }, + "gap_source": null, + "expected_observation": [ + "runtime::public_trojan_native_entrypoints" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-TCP-UDP-ISOLATION", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02" + ], + "protocol": "trojan", + "network": "tcp", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "tcp", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "tcp", + "test": "runtime::public_trojan_native_udp_isolation_and_limit" + }, + "gap_source": null, + "expected_observation": [ + "runtime::public_trojan_native_udp_isolation_and_limit" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-TCP-IPV6", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02" + ], + "protocol": "trojan", + "network": "tcp", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "tcp-ipv6", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv6", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "tcp-ipv6", + "test": "public_trojan_native_base" + }, + "gap_source": null, + "expected_observation": [ + "public_trojan_native_base" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-TCP-OWNED", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02" + ], + "protocol": "trojan", + "network": "tcp", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "tcp", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "tcp", + "test": "trojan_native_owned_resources" + }, + "gap_source": null, + "expected_observation": [ + "trojan_native_owned_resources" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity", + "resources", + "twenty-quiet-cycles" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-TCP-CERTIFICATE", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T01", + "T04", + "T05" + ], + "protocol": "trojan", + "network": "tcp", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "tcp-ca", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "tcp-ca", + "test": "runtime::public_trojan_native_certificate_names" + }, + "gap_source": null, + "expected_observation": [ + "runtime::public_trojan_native_certificate_names" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-WS-LIFE", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "W01", + "W02", + "W03" + ], + "protocol": "trojan", + "network": "ws", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "ws", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "ws", + "test": "runtime::public_trojan_native_lifecycle" + }, + "gap_source": null, + "expected_observation": [ + "runtime::public_trojan_native_lifecycle" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity", + "twenty-quiet-cycles" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-WS-ENTRYPOINTS", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "W01", + "W02", + "W03" + ], + "protocol": "trojan", + "network": "ws", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "ws", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "ws", + "test": "runtime::public_trojan_native_entrypoints" + }, + "gap_source": null, + "expected_observation": [ + "runtime::public_trojan_native_entrypoints" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-WS-UDP-ISOLATION", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "W01", + "W02", + "W03" + ], + "protocol": "trojan", + "network": "ws", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "ws", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "ws", + "test": "runtime::public_trojan_native_udp_isolation_and_limit" + }, + "gap_source": null, + "expected_observation": [ + "runtime::public_trojan_native_udp_isolation_and_limit" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-WS-IPV6", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "W01", + "W02", + "W03" + ], + "protocol": "trojan", + "network": "ws", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "ws-ipv6", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv6", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "ws-ipv6", + "test": "public_trojan_native_base" + }, + "gap_source": null, + "expected_observation": [ + "public_trojan_native_base" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-WS-OWNED", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "W01", + "W02", + "W03" + ], + "protocol": "trojan", + "network": "ws", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "ws", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "ws", + "test": "trojan_native_owned_resources" + }, + "gap_source": null, + "expected_observation": [ + "trojan_native_owned_resources" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity", + "resources", + "twenty-quiet-cycles" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-WS-CERTIFICATE", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T01", + "T04", + "T05", + "T03", + "W01", + "W02", + "W03" + ], + "protocol": "trojan", + "network": "ws", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "ws-ca", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "ws-ca", + "test": "runtime::public_trojan_native_certificate_names" + }, + "gap_source": null, + "expected_observation": [ + "runtime::public_trojan_native_certificate_names" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-GRPC-LIFE", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "G01" + ], + "protocol": "trojan", + "network": "grpc", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "grpc", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "grpc", + "test": "runtime::public_trojan_native_lifecycle" + }, + "gap_source": null, + "expected_observation": [ + "runtime::public_trojan_native_lifecycle" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity", + "twenty-quiet-cycles" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-GRPC-ENTRYPOINTS", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "G01" + ], + "protocol": "trojan", + "network": "grpc", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "grpc", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "grpc", + "test": "runtime::public_trojan_native_entrypoints" + }, + "gap_source": null, + "expected_observation": [ + "runtime::public_trojan_native_entrypoints" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-GRPC-UDP-ISOLATION", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "G01" + ], + "protocol": "trojan", + "network": "grpc", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "grpc", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "grpc", + "test": "runtime::public_trojan_native_udp_isolation_and_limit" + }, + "gap_source": null, + "expected_observation": [ + "runtime::public_trojan_native_udp_isolation_and_limit" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-GRPC-IPV6", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "G01" + ], + "protocol": "trojan", + "network": "grpc", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "grpc-ipv6", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv6", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "grpc-ipv6", + "test": "public_trojan_native_base" + }, + "gap_source": null, + "expected_observation": [ + "public_trojan_native_base" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-GRPC-OWNED", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "G01" + ], + "protocol": "trojan", + "network": "grpc", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "grpc", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "grpc", + "test": "trojan_native_owned_resources" + }, + "gap_source": null, + "expected_observation": [ + "trojan_native_owned_resources" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity", + "resources", + "twenty-quiet-cycles" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-GRPC-CERTIFICATE", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T01", + "T04", + "T05", + "T03", + "G01" + ], + "protocol": "trojan", + "network": "grpc", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "grpc-ca", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "grpc-ca", + "test": "runtime::public_trojan_native_certificate_names" + }, + "gap_source": null, + "expected_observation": [ + "runtime::public_trojan_native_certificate_names" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-M-GRPC-CUSTOM", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [ + "C01", + "C02", + "C03", + "C04", + "C05", + "TR01", + "TR02", + "T03", + "G01" + ], + "protocol": "trojan", + "network": "grpc", + "security": "standard-tls", + "udp_codec": "trojan-native", + "field_values": { + "network": "grpc-custom", + "udp": true, + "password": "raw-UTF8-with-surrounding-whitespace", + "early_data": 0, + "udp_payload_max": 8192 + }, + "outer_family": "IPv4", + "inner_family": "IPv4/IPv6/domain", + "target_type": "controlled-fixture", + "upstream_graph": "direct/concrete/select/DIRECT/REJECT", + "peer_kind": "M", + "peer_config": { + "mode": "grpc-custom", + "test": "public_trojan_native_base" + }, + "gap_source": null, + "expected_observation": [ + "public_trojan_native_base" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "FOUNDATIONS-shared-foundations" + ], + "runner": "native-trojan", + "timeout_seconds": 240, + "event_suite": "TROJAN-NATIVE" + }, + { + "case_id": "TROJAN-FEATURES", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [], + "protocol": "trojan", + "network": "none", + "security": "none", + "udp_codec": "none", + "field_values": { + "scope": "protocol-consumer" + }, + "outer_family": "none", + "inner_family": "none", + "target_type": "none", + "upstream_graph": "none", + "peer_kind": "unit", + "peer_config": { + "runner": "features" + }, + "gap_source": null, + "expected_observation": [ + "independent-features", + "default-feature", + "no-default-feature", + "build-identity" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup" + ], + "prerequisites": [ + "rust-toolchain" + ], + "runner": "features", + "timeout_seconds": 1200, + "event_suite": "FOUNDATIONS-FEATURES" + }, + { + "case_id": "TROJAN-SCRIPTS", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [], + "protocol": "trojan", + "network": "none", + "security": "none", + "udp_codec": "none", + "field_values": { + "scope": "protocol-consumer" + }, + "outer_family": "none", + "inner_family": "none", + "target_type": "none", + "upstream_graph": "none", + "peer_kind": "unit", + "peer_config": { + "runner": "scripts" + }, + "gap_source": null, + "expected_observation": [ + "offline-success", + "missing-duplicate-unknown-case", + "download-failure", + "timeout", + "sigint", + "partial-start", + "permission", + "peer-exit", + "cleanup-failure", + "redaction" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup" + ], + "prerequisites": [ + "python-toolchain" + ], + "runner": "scripts", + "timeout_seconds": 300, + "event_suite": "FOUNDATIONS-SCRIPTS" + }, + { + "case_id": "TROJAN-REGRESSION", + "stage": "TROJAN", + "substage": "TROJAN.acceptance", + "required": true, + "row_ids": [], + "protocol": "trojan", + "network": "supplied-io-or-loopback", + "security": "per-assertion", + "udp_codec": "per-assertion", + "field_values": { + "scope": "protocol-consumer" + }, + "outer_family": "per-assertion", + "inner_family": "per-assertion", + "target_type": "controlled-fixture", + "upstream_graph": "per-assertion", + "peer_kind": "M", + "peer_config": { + "selector": "mihomo_interop" + }, + "gap_source": null, + "expected_observation": [ + "public_client_inbounds_interoperate_with_mihomo_in_both_directions" + ], + "required_evidence": [ + "structured-assertions", + "command", + "cleanup", + "peer-identity" + ], + "prerequisites": [ + "rust-toolchain", + "M", + "apple-container" + ], + "runner": "mihomo-legacy", + "timeout_seconds": 900, + "event_suite": "FOUNDATIONS-REGRESSION" + } + ] +} diff --git a/tests/protocols/limits.json b/tests/protocols/limits.json new file mode 100644 index 0000000..ce61d9d --- /dev/null +++ b/tests/protocols/limits.json @@ -0,0 +1,831 @@ +{ + "schema_version": 1, + "kind": "runtime-limit-registry", + "scope": "FOUNDATIONS shared foundations through INTEGRATION integration; per-object bounds, not global admission caps", + "limits": [ + { + "id": "shadowsocks-write-chunk", + "value": 16384, + "unit": "retained-plaintext-bytes-per-stream", + "owner": "limits::SHADOWSOCKS_WRITE_CHUNK", + "saturation": "partial write then backpressure while the bounded owned chunk drains", + "cancellation": "stream drop releases plaintext and codec; flush and shutdown drain the exact accepted chunk", + "boundary_cases": ["INTEGRATION-DEBUG", "INTEGRATION-RELEASE"] + }, + { + "id": "xhttp-custom-header-bytes", + "value": 8192, + "unit": "bytes", + "owner": "limits::XHTTP_CUSTOM_HEADER_BYTES", + "saturation": "reject aggregate custom header overflow before IO", + "cancellation": "reject before creating runtime IO or tasks", + "boundary_cases": [ + "XHTTP-CFG" + ] + }, + { + "id": "xhttp-custom-headers", + "value": 100, + "unit": "headers", + "owner": "limits::XHTTP_CUSTOM_HEADERS", + "saturation": "reject custom header count overflow before IO", + "cancellation": "reject before creating runtime IO or tasks", + "boundary_cases": [ + "XHTTP-CFG" + ] + }, + { + "id": "xhttp-padding", + "value": 4096, + "unit": "bytes", + "owner": "limits::XHTTP_PADDING_BYTES", + "saturation": "reject configured padding overflow before IO", + "cancellation": "reject before creating runtime IO or tasks", + "boundary_cases": [ + "XHTTP-CFG" + ] + }, + { + "id": "xhttp-post", + "value": 16777216, + "unit": "bytes", + "owner": "limits::XHTTP_POST_BYTES", + "saturation": "reject post size overflow before IO; bound retained upload", + "cancellation": "reject before creating runtime IO or tasks", + "boundary_cases": [ + "XHTTP-CFG" + ] + }, + { + "id": "xhttp-packet-batch", + "value": 65536, + "unit": "bytes-per-batch", + "owner": "limits::XHTTP_PACKET_BATCH_BYTES", + "saturation": "backpressure one filling batch and one in-flight batch; never allocate the configured POST maximum", + "cancellation": "logical drop cancels the timer/upload; node Stop joins the owned driver", + "boundary_cases": [ + "XHTTP-UNIT" + ] + }, + { + "id": "xhttp-request-bytes", + "value": 16384, + "unit": "bytes", + "owner": "limits::XHTTP_REQUEST_BYTES", + "saturation": "reject oversized constructed request including generated metadata", + "cancellation": "runtime Stop cancels and joins node-owned drivers; drops queues and retained IO", + "boundary_cases": [ + "XHTTP-UNIT" + ] + }, + { + "id": "xhttp-request-headers", + "value": 128, + "unit": "headers", + "owner": "limits::XHTTP_REQUEST_HEADERS", + "saturation": "reject excess constructed headers including payload chunks", + "cancellation": "runtime Stop cancels and joins node-owned drivers; drops queues and retained IO", + "boundary_cases": [ + "XHTTP-UNIT" + ] + }, + { + "id": "xhttp-idle-entries", + "value": 64, + "unit": "entries", + "owner": "limits::XHTTP_IDLE_ENTRIES", + "saturation": "retire excess idle transports without imposing active business quota", + "cancellation": "runtime Stop cancels and joins node-owned drivers; drops queues and retained IO", + "boundary_cases": [ + "XHTTP-UNIT" + ] + }, + { + "id": "xhttp-idle-h1", + "value": 4, + "unit": "connections-per-entry", + "owner": "limits::XHTTP_IDLE_H1_CONNECTIONS", + "saturation": "retire excess idle H1 connections; active requests keep ownership", + "cancellation": "runtime Stop cancels and joins node-owned drivers; drops queues and retained IO", + "boundary_cases": [ + "XHTTP-UNIT" + ] + }, + { + "id": "xhttp-body-queue", + "value": 2, + "unit": "chunks", + "owner": "limits::XHTTP_BODY_QUEUE", + "saturation": "backpressure owned H1 commands and body chunks", + "cancellation": "runtime Stop cancels and joins node-owned drivers; drops queues and retained IO", + "boundary_cases": [ + "XHTTP-UNIT" + ] + }, + { + "id": "xhttp-body-chunk", + "value": 16384, + "unit": "bytes", + "owner": "limits::XHTTP_BODY_CHUNK", + "saturation": "split streaming writes; a packet-up body uses the separately bounded xhttp-packet-batch limit", + "cancellation": "runtime Stop cancels and joins node-owned drivers; drops queues and retained IO", + "boundary_cases": [ + "XHTTP-UNIT" + ] + }, + { + "id": "xhttp-h3-uni-streams", + "value": 8, + "unit": "streams-per-connection", + "owner": "limits::XHTTP_H3_UNI_STREAMS", + "saturation": "QUIC flow control bounds remote control streams", + "cancellation": "runtime Stop cancels and joins node-owned drivers; drops queues and retained IO", + "boundary_cases": [ + "XHTTP-UNIT" + ] + }, + { + "id": "xhttp-h3-stream-window", + "value": 65536, + "unit": "bytes", + "owner": "limits::XHTTP_H3_STREAM_WINDOW", + "saturation": "QUIC receive flow control backpressures peer per stream", + "cancellation": "runtime Stop cancels and joins node-owned drivers; drops queues and retained IO", + "boundary_cases": [ + "XHTTP-UNIT" + ] + }, + { + "id": "xhttp-h3-connection-window", + "value": 131072, + "unit": "bytes", + "owner": "limits::XHTTP_H3_CONNECTION_WINDOW", + "saturation": "QUIC aggregate receive flow control", + "cancellation": "runtime Stop cancels and joins node-owned drivers; drops queues and retained IO", + "boundary_cases": [ + "XHTTP-UNIT" + ] + }, + { + "id": "xhttp-h3-send-window", + "value": 65536, + "unit": "bytes", + "owner": "limits::XHTTP_H3_SEND_WINDOW", + "saturation": "QUIC send window backpressures pending writes", + "cancellation": "runtime Stop cancels and joins node-owned drivers; drops queues and retained IO", + "boundary_cases": [ + "XHTTP-UNIT" + ] + }, + { + "id": "sing-mux-idle", + "value": 16, + "unit": "connections", + "owner": "limits::SING_MUX_IDLE_CONNECTIONS", + "saturation": "retire excess idle connections; no active business quota", + "cancellation": "runtime Stop cancels and joins node-owned drivers; drops queues and retained IO", + "boundary_cases": [ + "XHTTP-UNIT" + ] + }, + { + "id": "sing-mux-command-queue", + "value": 16, + "unit": "commands", + "owner": "limits::SING_MUX_COMMAND_QUEUE", + "saturation": "backpressure smux frames and yamux opens", + "cancellation": "runtime Stop cancels and joins node-owned drivers; drops queues and retained IO", + "boundary_cases": [ + "XHTTP-UNIT" + ] + }, + { + "id": "sing-mux-chunk", + "value": 16384, + "unit": "bytes", + "owner": "limits::SING_MUX_CHUNK", + "saturation": "split frame writes at retained chunk boundary", + "cancellation": "runtime Stop cancels and joins node-owned drivers; drops queues and retained IO", + "boundary_cases": [ + "XHTTP-UNIT" + ] + }, + { + "id": "smux-receive-queue", + "value": 4, + "unit": "frames-per-stream", + "owner": "limits::SMUX_RECEIVE_QUEUE", + "saturation": "backpressure reader; each wire frame is bounded by its u16 length", + "cancellation": "runtime Stop cancels and joins node-owned drivers; drops queues and retained IO", + "boundary_cases": [ + "XHTTP-UNIT" + ] + }, + { + "id": "yamux-admissions", + "value": 64, + "unit": "lifetime-streams-per-physical-connection", + "owner": "limits::YAMUX_CONNECTION_ADMISSIONS", + "saturation": "retire before library stream saturation; new transport serves new streams while old streams remain live", + "cancellation": "runtime Stop cancels and joins node-owned drivers; drops queues and retained IO", + "boundary_cases": [ + "XHTTP-UNIT" + ] + }, + { + "id": "yamux-window", + "value": 16777216, + "unit": "bytes", + "owner": "limits::YAMUX_CONNECTION_WINDOW", + "saturation": "library receive credit window bounds physical connection retention", + "cancellation": "runtime Stop cancels and joins node-owned drivers; drops queues and retained IO", + "boundary_cases": [ + "XHTTP-UNIT" + ] + }, + { + "id": "trojan-udp-payload", + "value": 8192, + "unit": "bytes", + "owner": "outbound::trojan::MAX_DATAGRAM_PAYLOAD", + "saturation": "reject oversize send before write; invalid receive closes the stream; bounded caller-budget drops never truncate", + "cancellation": "drop partial receive buffer and poison any partially written frame on cancelled send", + "boundary_cases": [ + "TROJAN-CODEC", + "TROJAN-M-TCP-UDP-ISOLATION", + "TROJAN-M-WS-UDP-ISOLATION", + "TROJAN-M-GRPC-UDP-ISOLATION" + ] + }, + { + "id": "config-bytes", + "value": 262144, + "unit": "bytes", + "owner": "config::MAX_CONFIG_BYTES", + "saturation": "reject oversized YAML", + "cancellation": "parse owns no background work", + "boundary_cases": [ + "FOUNDATIONS-SCHEMA" + ] + }, + { + "id": "establish-timeout", + "value": 10000, + "unit": "milliseconds", + "owner": "outbound::DEFAULT_ESTABLISH_TIMEOUT", + "saturation": "fail entire setup at absolute deadline", + "cancellation": "drop pending setup at cancellation", + "boundary_cases": [ + "FOUNDATIONS-RESOLUTION", + "FOUNDATIONS-STREAM" + ] + }, + { + "id": "tls-buffer", + "value": 65536, + "unit": "bytes", + "owner": "ResourceLimits::tls_buffer_limit", + "saturation": "backpressure TLS records", + "cancellation": "drop owned IO on Stop", + "boundary_cases": [ + "FOUNDATIONS-SECURITY" + ] + }, + { + "id": "tls-close", + "value": 5000, + "unit": "milliseconds", + "owner": "security::CLOSE_NOTIFY_TIMEOUT", + "saturation": "fail bounded close_notify flush", + "cancellation": "cancel flush and release IO", + "boundary_cases": [ + "FOUNDATIONS-SECURITY" + ] + }, + { + "id": "tls-resumption", + "value": 4, + "unit": "sessions", + "owner": "security::TLS_RESUMPTION_SESSION_BUDGET", + "saturation": "evict retained sessions within isolated identity cache", + "cancellation": "drop cache owner", + "boundary_cases": [ + "FOUNDATIONS-SECURITY" + ] + }, + { + "id": "stream-chunk", + "value": 16384, + "unit": "bytes", + "owner": "transport::STREAM_CHUNK_BYTES", + "saturation": "split writes at chunk boundary", + "cancellation": "drop queued chunk on cancellation", + "boundary_cases": [ + "FOUNDATIONS-STREAM" + ] + }, + { + "id": "stream-buffer", + "value": 65536, + "unit": "bytes", + "owner": "transport::STREAM_BUFFER_BYTES", + "saturation": "backpressure writes; reject oversized WS/gRPC message", + "cancellation": "Stop owns driver join", + "boundary_cases": [ + "FOUNDATIONS-STREAM" + ] + }, + { + "id": "http-head", + "value": 16384, + "unit": "bytes", + "owner": "transport::HTTP_HEAD_BYTES", + "saturation": "reject oversized HTTP header", + "cancellation": "drop parser and supplied IO", + "boundary_cases": [ + "FOUNDATIONS-STREAM" + ] + }, + { + "id": "http-headers", + "value": 100, + "unit": "headers", + "owner": "transport::HTTP_HEADER_COUNT", + "saturation": "reject excess fields", + "cancellation": "drop parser and supplied IO", + "boundary_cases": [ + "FOUNDATIONS-STREAM" + ] + }, + { + "id": "ws-early-data", + "value": 2048, + "unit": "bytes", + "owner": "transport::MAX_EARLY_DATA_BYTES", + "saturation": "reject invalid option, put excess initial bytes in data frames", + "cancellation": "no separate task", + "boundary_cases": [ + "FOUNDATIONS-STREAM" + ] + }, + { + "id": "xudp-metadata", + "value": 512, + "unit": "bytes", + "owner": "xudp::MAX_METADATA_LENGTH", + "saturation": "reject oversized metadata before body allocation", + "cancellation": "drop persistent partial frame on association close", + "boundary_cases": [ + "FOUNDATIONS-XUDP" + ] + }, + { + "id": "io-poll", + "value": 32, + "unit": "iterations", + "owner": "limits::IO_POLL_BUDGET", + "saturation": "yield before processing another control/discard burst", + "cancellation": "cooperative cancellation between polls", + "boundary_cases": [ + "FOUNDATIONS-STREAM", + "FOUNDATIONS-DATAGRAM" + ] + }, + { + "id": "quic-queue", + "value": 32, + "unit": "datagrams-per-direction", + "owner": "transport::quic::QUEUE_LIMIT", + "saturation": "WouldBlock TX; pause RX, plus one in-flight send", + "cancellation": "cancel and join owner; clear queues", + "boundary_cases": [ + "FOUNDATIONS-QUIC" + ] + }, + { + "id": "quic-close", + "value": 1000, + "unit": "milliseconds", + "owner": "transport::quic::CLOSE_TIMEOUT", + "saturation": "time out upstream close", + "cancellation": "drop transport after bounded close and join task", + "boundary_cases": [ + "FOUNDATIONS-QUIC" + ] + }, + { + "id": "quic-minimum", + "value": 1200, + "unit": "bytes", + "owner": "dispatch::QUIC_MIN_PAYLOAD_BYTES", + "saturation": "reject insufficient directional path budget before QUIC", + "cancellation": "no QUIC task created on rejection", + "boundary_cases": [ + "FOUNDATIONS-QUIC", + "FOUNDATIONS-DATAGRAM" + ] + }, + { + "id": "resolution-depth", + "value": 32, + "unit": "nested-lookups", + "owner": "dns::resolution::MAX_RESOLUTION_DEPTH", + "saturation": "reject dependency recursion/depth, no system fallback", + "cancellation": "shared deadline and runtime cancellation", + "boundary_cases": [ + "FOUNDATIONS-RESOLUTION" + ] + }, + { + "id": "packet-queue", + "value": 256, + "unit": "items", + "owner": "ResourceLimits::packet_queue_capacity", + "saturation": "retain existing per-object queue/cache/buffer policy in docs/runtime-resource-policy.md", + "cancellation": "existing runtime-owned cancellation and Stop barrier", + "boundary_cases": [ + "FOUNDATIONS-REGRESSION" + ] + }, + { + "id": "event-queue", + "value": 128, + "unit": "items", + "owner": "ResourceLimits::event_queue_capacity", + "saturation": "retain existing per-object queue/cache/buffer policy in docs/runtime-resource-policy.md", + "cancellation": "existing runtime-owned cancellation and Stop barrier", + "boundary_cases": [ + "FOUNDATIONS-REGRESSION" + ] + }, + { + "id": "udp-payload", + "value": 65535, + "unit": "bytes", + "owner": "ResourceLimits::max_datagram_size", + "saturation": "retain existing per-object queue/cache/buffer policy in docs/runtime-resource-policy.md", + "cancellation": "existing runtime-owned cancellation and Stop barrier", + "boundary_cases": [ + "FOUNDATIONS-REGRESSION" + ] + }, + { + "id": "tun-packet", + "value": 1500, + "unit": "bytes", + "owner": "ResourceLimits::tun_max_datagram_size", + "saturation": "retain existing per-object queue/cache/buffer policy in docs/runtime-resource-policy.md", + "cancellation": "existing runtime-owned cancellation and Stop barrier", + "boundary_cases": [ + "FOUNDATIONS-REGRESSION" + ] + }, + { + "id": "tcp-buffer", + "value": 32768, + "unit": "bytes", + "owner": "ResourceLimits::tcp_buffer_per_direction", + "saturation": "retain existing per-object queue/cache/buffer policy in docs/runtime-resource-policy.md", + "cancellation": "existing runtime-owned cancellation and Stop barrier", + "boundary_cases": [ + "FOUNDATIONS-REGRESSION" + ] + }, + { + "id": "dns-cache", + "value": 256, + "unit": "entries", + "owner": "ResourceLimits::dns_address_cache_entries", + "saturation": "retain existing per-object queue/cache/buffer policy in docs/runtime-resource-policy.md", + "cancellation": "existing runtime-owned cancellation and Stop barrier", + "boundary_cases": [ + "FOUNDATIONS-REGRESSION" + ] + }, + { + "id": "dns-hints", + "value": 256, + "unit": "entries", + "owner": "ResourceLimits::dns_redir_host_entries", + "saturation": "retain existing per-object queue/cache/buffer policy in docs/runtime-resource-policy.md", + "cancellation": "existing runtime-owned cancellation and Stop barrier", + "boundary_cases": [ + "FOUNDATIONS-REGRESSION" + ] + }, + { + "id": "tun-dns-ingress", + "value": 128, + "unit": "items", + "owner": "ResourceLimits::tun_dns_ingress_queue_capacity", + "saturation": "retain existing per-object queue/cache/buffer policy in docs/runtime-resource-policy.md", + "cancellation": "existing runtime-owned cancellation and Stop barrier", + "boundary_cases": [ + "FOUNDATIONS-REGRESSION" + ] + }, + { + "id": "tun-dns-response", + "value": 128, + "unit": "items", + "owner": "ResourceLimits::tun_dns_response_queue_capacity", + "saturation": "retain existing per-object queue/cache/buffer policy in docs/runtime-resource-policy.md", + "cancellation": "existing runtime-owned cancellation and Stop barrier", + "boundary_cases": [ + "FOUNDATIONS-REGRESSION" + ] + }, + { + "id": "xhttp-send", + "value": 65536, + "unit": "bytes", + "owner": "ResourceLimits::xhttp_send_buffer_size", + "saturation": "retain existing per-object queue/cache/buffer policy in docs/runtime-resource-policy.md", + "cancellation": "existing runtime-owned cancellation and Stop barrier", + "boundary_cases": [ + "FOUNDATIONS-REGRESSION" + ] + }, + { + "id": "xhttp-upload", + "value": 65536, + "unit": "bytes", + "owner": "ResourceLimits::xhttp_upload_chunk_size", + "saturation": "retain existing per-object queue/cache/buffer policy in docs/runtime-resource-policy.md", + "cancellation": "existing runtime-owned cancellation and Stop barrier", + "boundary_cases": [ + "FOUNDATIONS-REGRESSION" + ] + }, + { + "id": "vmess-body-wire", + "value": 16384, + "unit": "bytes", + "owner": "outbound::vmess::MAX_BODY_WIRE", + "saturation": "reject malformed or oversized chunk before payload allocation", + "cancellation": "drop owned stream; never reuse nonce after a partial send", + "boundary_cases": [ + "VMESS-CODEC", + "VMESS-RELEASE" + ] + }, + { + "id": "vmess-write-chunk", + "value": 4096, + "unit": "bytes", + "owner": "outbound::vmess::WRITE_CHUNK", + "saturation": "bounded TCP segmentation with backpressure", + "cancellation": "cancel owned stream and join driver", + "boundary_cases": [ + "VMESS-CODEC", + "VMESS-PUBLIC-TCP-PLAIN-BASE" + ] + }, + { + "id": "vmess-datagram", + "value": 15000, + "unit": "bytes", + "owner": "outbound::vmess::MAX_PACKET_BYTES", + "saturation": "reject max+1 without partial delivery; intersect the caller budget", + "cancellation": "close association and release upstream IO", + "boundary_cases": [ + "VMESS-CODEC", + "VMESS-PUBLIC-TCP-UDP-ISOLATION" + ] + }, + { + "id": "vless-udp-frame", + "value": 65537, + "unit": "bytes", + "owner": "limits::VLESS_UDP_FRAME_BYTES", + "saturation": "reject length beyond u16 payload and prefix", + "cancellation": "drop owned buffers and join cancelled node drivers", + "boundary_cases": [ + "VLESS-CODEC" + ] + }, + { + "id": "vision-content", + "value": 8171, + "unit": "bytes", + "owner": "limits::VISION_CONTENT_BYTES", + "saturation": "split writes; retain one bounded frame", + "cancellation": "drop owned buffers and join cancelled node drivers", + "boundary_cases": [ + "VLESS-VISION" + ] + }, + { + "id": "vision-tls-record", + "value": 18432, + "unit": "bytes", + "owner": "limits::VISION_TLS_RECORD_BYTES", + "saturation": "fail malformed outer TLS; stop inner classification", + "cancellation": "drop owned buffers and join cancelled node drivers", + "boundary_cases": [ + "VLESS-VISION" + ] + }, + { + "id": "vision-hello", + "value": 65536, + "unit": "bytes", + "owner": "limits::VISION_HELLO_BYTES", + "saturation": "disable inner hello classification at cap", + "cancellation": "drop owned buffers and join cancelled node drivers", + "boundary_cases": [ + "VLESS-VISION" + ] + }, + { + "id": "grpc-idle", + "value": 4, + "unit": "connections", + "owner": "limits::GRPC_IDLE_CONNECTIONS", + "saturation": "retire excess idle; no active business cap", + "cancellation": "drop owned buffers and join cancelled node drivers", + "boundary_cases": [ + "VLESS-TRANSPORT" + ] + }, + { + "id": "grpc-ping-timeout", + "value": 15, + "unit": "seconds", + "owner": "limits::GRPC_PING_TIMEOUT_SECONDS", + "saturation": "close a physical transport if its PING is unanswered", + "cancellation": "drop owned buffers and join cancelled node drivers", + "boundary_cases": [ + "VLESS-TRANSPORT" + ] + }, + { + "id": "hy2-udp-payload", + "value": 4096, + "unit": "bytes", + "owner": "limits::HY2_UDP_PAYLOAD", + "saturation": "reject max+1 before sending a business fragment", + "cancellation": "owned logical close or physical session Stop releases retained state and joins drivers", + "boundary_cases": [ + "HYSTERIA2-UNIT", + "HYSTERIA2-OWNED" + ] + }, + { + "id": "hy2-udp-queue", + "value": 32, + "unit": "packets-per-association", + "owner": "limits::HY2_UDP_QUEUE", + "saturation": "drop full inbound queue without blocking sibling associations", + "cancellation": "owned logical close or physical session Stop releases retained state and joins drivers", + "boundary_cases": [ + "HYSTERIA2-UNIT", + "HYSTERIA2-OWNED" + ] + }, + { + "id": "hy2-pending-packets", + "value": 64, + "unit": "partial-packets-per-connection", + "owner": "limits::HY2_PENDING_PACKETS", + "saturation": "drop excess partial packets; retain bounded completion tombstones", + "cancellation": "owned logical close or physical session Stop releases retained state and joins drivers", + "boundary_cases": [ + "HYSTERIA2-UNIT", + "HYSTERIA2-OWNED" + ] + }, + { + "id": "hy2-pending-bytes", + "value": 262144, + "unit": "bytes-per-connection", + "owner": "limits::HY2_PENDING_BYTES", + "saturation": "reject fragment retaining overflow", + "cancellation": "owned logical close or physical session Stop releases retained state and joins drivers", + "boundary_cases": [ + "HYSTERIA2-UNIT", + "HYSTERIA2-OWNED" + ] + }, + { + "id": "hy2-fragment-ttl", + "value": 5, + "unit": "seconds", + "owner": "limits::HY2_FRAGMENT_TTL_SECONDS", + "saturation": "expire missing fragments on the owned cleanup timer", + "cancellation": "owned logical close or physical session Stop releases retained state and joins drivers", + "boundary_cases": [ + "HYSTERIA2-UNIT", + "HYSTERIA2-OWNED" + ] + }, + { + "id": "hy2-path-retire", + "value": 1, + "unit": "seconds", + "owner": "limits::HY2_PATH_RETIRE_SECONDS", + "saturation": "cancel and join the old path before another hop", + "cancellation": "owned logical close or physical session Stop releases retained state and joins drivers", + "boundary_cases": [ + "HYSTERIA2-UNIT", + "HYSTERIA2-OWNED" + ] + }, + { + "id": "hy2-quic-payload", + "value": 1400, + "unit": "bytes", + "owner": "limits::HY2_QUIC_PAYLOAD", + "saturation": "intersect controlled path payload budget including obfs overhead", + "cancellation": "owned logical close or physical session Stop releases retained state and joins drivers", + "boundary_cases": [ + "HYSTERIA2-UNIT", + "HYSTERIA2-OWNED" + ] + }, + { + "id": "hy2-uni-streams", + "value": 8, + "unit": "streams-per-connection", + "owner": "limits::HY2_UNI_STREAMS", + "saturation": "QUIC flow control bounds remote control streams", + "cancellation": "owned logical close or physical session Stop releases retained state and joins drivers", + "boundary_cases": [ + "HYSTERIA2-UNIT", + "HYSTERIA2-OWNED" + ] + }, + { + "id": "hy2-stream-window", + "value": 262144, + "unit": "bytes", + "owner": "limits::HY2_STREAM_WINDOW", + "saturation": "QUIC receive flow control", + "cancellation": "owned logical close or physical session Stop releases retained state and joins drivers", + "boundary_cases": [ + "HYSTERIA2-UNIT", + "HYSTERIA2-OWNED" + ] + }, + { + "id": "hy2-connection-window", + "value": 1048576, + "unit": "bytes", + "owner": "limits::HY2_CONNECTION_WINDOW", + "saturation": "QUIC aggregate send and receive flow control", + "cancellation": "owned logical close or physical session Stop releases retained state and joins drivers", + "boundary_cases": [ + "HYSTERIA2-UNIT", + "HYSTERIA2-OWNED" + ] + }, + { + "id": "hy2-datagram-buffer", + "value": 262144, + "unit": "bytes-per-direction", + "owner": "limits::HY2_DATAGRAM_BUFFER", + "saturation": "QUIC bounds retained datagram queues", + "cancellation": "owned logical close or physical session Stop releases retained state and joins drivers", + "boundary_cases": [ + "HYSTERIA2-UNIT", + "HYSTERIA2-OWNED" + ] + }, + { + "id": "hy2-auth-headers", + "value": 16384, + "unit": "bytes", + "owner": "limits::HY2_AUTH_HEADERS", + "saturation": "reject excess HTTP3 response headers", + "cancellation": "owned logical close or physical session Stop releases retained state and joins drivers", + "boundary_cases": [ + "HYSTERIA2-UNIT", + "HYSTERIA2-OWNED" + ] + }, + { + "id": "hy2-response-message", + "value": 2048, + "unit": "bytes", + "owner": "limits::HY2_RESPONSE_MESSAGE", + "saturation": "reject oversized TCP response message before allocation", + "cancellation": "owned logical close or physical session Stop releases retained state and joins drivers", + "boundary_cases": [ + "HYSTERIA2-UNIT", + "HYSTERIA2-OWNED" + ] + }, + { + "id": "hy2-response-padding", + "value": 4096, + "unit": "bytes", + "owner": "limits::HY2_RESPONSE_PADDING", + "saturation": "reject oversized TCP response padding before allocation", + "cancellation": "owned logical close or physical session Stop releases retained state and joins drivers", + "boundary_cases": [ + "HYSTERIA2-UNIT", + "HYSTERIA2-OWNED" + ] + } + ] +} diff --git a/tests/protocols/stream_probe.rs b/tests/protocols/stream_probe.rs new file mode 100644 index 0000000..55b4f73 --- /dev/null +++ b/tests/protocols/stream_probe.rs @@ -0,0 +1,316 @@ +//! FOUNDATIONS shared transport probe. Synthetic VLESS framing is test-only; this is +//! not a production protocol/configuration registration. + +use std::{ + io, + net::SocketAddr, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use serde_json::{Value, json}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + time::timeout, +}; +use vcore::{ + config::Socks5OutboundConfig, + dialer::{Dialer, ResolvedEndpoint, SocketProtector}, + outbound::{DirectOutbound, EstablishContext, OutboundConnector, Socks5Outbound}, + session::{InboundKind, StreamSession}, +}; +use vcore::{ + dispatch::BoxStream, + security::{SecurityContext, StandardTlsClient, TlsCertificatePolicy, TlsClientOptions}, + transport::{ + HttpObfsOptions, WebSocketEarlyData, WebSocketOptions, connect_websocket, grpc, http_obfs, + legacy_h2, + }, +}; + +const GREETING: &[u8] = b"FOUNDATIONS-server-first\n"; +const TRAILER: &[u8] = b"FOUNDATIONS-half-close\n"; +const UUID: [u8; 16] = [ + 0xb8, 0x31, 0x38, 0x1d, 0x63, 0x24, 0x4d, 0x53, 0xad, 0x4f, 0x8c, 0xda, 0x48, 0xb3, 0x08, 0x11, +]; + +struct Protector { + calls: AtomicUsize, + reject: bool, +} +impl SocketProtector for Protector { + fn protect(&self, _socket: i32) -> io::Result<()> { + self.calls.fetch_add(1, Ordering::SeqCst); + if self.reject { + Err(io::ErrorKind::PermissionDenied.into()) + } else { + Ok(()) + } + } +} + +fn field<'a>(config: &'a Value, name: &str) -> Result<&'a str, &'static str> { + config[name].as_str().ok_or("invalid_fixture") +} + +fn tls_client(config: &Value, mode: &str) -> Result { + let pin = field(config, "pin")?; + if pin.len() != 64 { + return Err("invalid_fixture"); + } + let mut fingerprint = [0; 32]; + for (index, byte) in fingerprint.iter_mut().enumerate() { + *byte = u8::from_str_radix(&pin[index * 2..index * 2 + 2], 16) + .map_err(|_| "invalid_fixture")?; + } + let alpn = if matches!(mode, "grpc-tls" | "h2") { + b"h2".to_vec() + } else { + b"http/1.1".to_vec() + }; + StandardTlsClient::with_options( + &SecurityContext::new(), + "localhost", + TlsClientOptions { + alpn: vec![alpn.clone()], + required_alpn: Some(alpn), + certificate: TlsCertificatePolicy { + fingerprint: Some(fingerprint), + ..Default::default() + }, + ..Default::default() + }, + 0, + 65536, + ) + .map_err(|_| "tls_options") +} + +fn preface(config: &Value) -> Result, &'static str> { + let target: SocketAddr = field(config, "target")? + .parse() + .map_err(|_| "invalid_fixture")?; + let mut preface = vec![0]; + preface.extend_from_slice(&UUID); + preface.extend_from_slice(&[0, 1]); + preface.extend_from_slice(&target.port().to_be_bytes()); + preface.push(1); + preface.extend_from_slice(&[127, 0, 0, 1]); + Ok(preface) +} + +async fn exchange( + stream: &mut BoxStream, + config: &Value, + mode: &str, +) -> Result { + if !matches!(mode, "ws" | "wss" | "ws-header" | "ws-path" | "http") { + stream + .write_all(&preface(config)?) + .await + .map_err(|_| "preface_write")?; + stream.flush().await.map_err(|_| "preface_flush")?; + } + let mut response = [0; 2]; + stream + .read_exact(&mut response) + .await + .map_err(|_| "response")?; + if response != [0, 0] { + return Err("response"); + } + let mut greeting = vec![0; GREETING.len()]; + stream + .read_exact(&mut greeting) + .await + .map_err(|_| "greeting")?; + if greeting != GREETING { + return Err("greeting"); + } + let payload: Vec<_> = (0..65536).map(|n| (n % 251) as u8).collect(); + for fragment in payload.chunks(4096) { + stream.write_all(fragment).await.map_err(|_| "upload")?; + stream.flush().await.map_err(|_| "flush")?; + let mut echo = vec![0; fragment.len()]; + stream.read_exact(&mut echo).await.map_err(|_| "download")?; + if echo != fragment { + return Err("integrity"); + } + } + let close = config["close"].as_bool().unwrap_or(false); + let mut tail = Vec::new(); + if close { + stream.shutdown().await.map_err(|_| "shutdown")?; + timeout( + Duration::from_secs(3), + stream.take(256).read_to_end(&mut tail), + ) + .await + .map_err(|_| "close_timeout")? + .map_err(|_| "close_read")?; + if mode.starts_with("grpc") || mode == "h2" { + if !tail.is_empty() { + return Err("unexpected_tail"); + } + } else if tail != TRAILER { + return Err("tail"); + } + } else { + tail.resize(TRAILER.len(), 0); + stream + .read_exact(&mut tail) + .await + .map_err(|_| "tail_read")?; + if tail != TRAILER { + return Err("tail"); + } + } + Ok( + json!({"outcome":"pass", "payload_bytes":payload.len(), "server_first":true, "tail_bytes":tail.len(), "close":close}), + ) +} + +async fn run(config: Value) -> Value { + let protector = Arc::new(Protector { + calls: AtomicUsize::new(0), + reject: config["reject"].as_bool().unwrap_or(false), + }); + let mut driver = None; + let result = timeout(Duration::from_secs(12), async { + let mode = field(&config, "mode")?; + let server: SocketAddr = field(&config, "server")? + .parse() + .map_err(|_| "invalid_fixture")?; + let dialer = Dialer::default().with_protector(protector.clone()); + let connector: Box = + if let Some(upstream) = config["socks5"].as_str() { + let address: SocketAddr = upstream.parse().map_err(|_| "invalid_fixture")?; + Box::new( + Socks5Outbound::new( + &Socks5OutboundConfig { + address: address.ip().to_string(), + port: address.port(), + username: None, + password: None, + }, + ResolvedEndpoint { + logical_host: address.ip().to_string(), + port: address.port(), + addresses: vec![address], + }, + dialer, + ) + .map_err(|_| "socks_config")?, + ) + } else { + Box::new(DirectOutbound::new(dialer)) + }; + let context = EstablishContext::with_timeout(Duration::from_secs(4)); + let mut stream = connector + .connect_stream( + StreamSession { + inbound: InboundKind::Socks5, + source: "127.0.0.1:0".parse().unwrap(), + destination: server.into(), + sniffed_domain: None, + }, + &context, + ) + .await + .map_err(|_| "connect")? + .io; + if matches!(mode, "tls" | "wss" | "grpc-tls" | "h2") { + stream = context + .run_io("TLS", tls_client(&config, mode)?.connect(stream)) + .await + .map_err(|_| "tls")?; + } + if matches!(mode, "ws" | "wss" | "ws-header" | "ws-path") { + let path = config["path"].as_str().unwrap_or("/foundations-ws"); + let early = match mode { + "ws-header" => Some(WebSocketEarlyData::Header { + name: config["ed_header"] + .as_str() + .unwrap_or("x-vcore-ed") + .parse() + .map_err(|_| "invalid_fixture")?, + max_bytes: config["ed_max"].as_u64().unwrap_or(2048) as usize, + }), + "ws-path" => Some(WebSocketEarlyData::Path { + max_bytes: config["ed_max"].as_u64().unwrap_or(2048) as usize, + }), + _ => None, + }; + let options = + WebSocketOptions::new(&format!("ws://localhost{path}"), Default::default(), early) + .map_err(|_| "ws_options")?; + stream = connect_websocket(stream, &options, &preface(&config)?, context.deadline()) + .await + .map_err(|_| "ws")?; + } + if mode == "http" { + let options = HttpObfsOptions::new( + http::Method::GET, + "http://localhost/foundations-http", + Default::default(), + ) + .map_err(|_| "http_options")?; + stream = http_obfs(stream, &options, &preface(&config)?, context.deadline()) + .await + .map_err(|_| "http")?; + } + if mode.starts_with("grpc") || mode == "h2" { + let uri = if mode == "h2" { + "https://localhost/foundations-h2" + } else { + "https://localhost/foundations-grpc/Tun" + }; + let (io, owner) = if mode == "h2" { + legacy_h2(stream, uri, context.deadline()).await + } else { + grpc(stream, uri, context.deadline()).await + } + .map_err(|_| "h2")?; + stream = io; + driver = Some(owner); + } + exchange(&mut stream, &config, mode).await + }) + .await + .unwrap_or(Err("case_timeout")); + let stopped = if let Some(owner) = driver { + owner.stop().await.is_ok() + } else { + true + }; + let mut report = result.unwrap_or_else(|stage| json!({"outcome":stage})); + report["driver_joined"] = json!(stopped); + report["protect_calls"] = json!(protector.calls.load(Ordering::SeqCst)); + report +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let config = std::env::args() + .nth(1) + .and_then(|path| std::fs::read(path).ok()) + .and_then(|bytes| serde_json::from_slice(&bytes).ok()); + let resources = vcore::resources::observation::ResourceProbe::default(); + let mut report = match config { + Some(config) => resources.scope(run(config)).await, + None => json!({"outcome":"invalid_fixture"}), + }; + let stopped = resources.snapshot(); + report["resources_idle"] = json!(stopped.is_idle()); + report["resources"] = serde_json::to_value(stopped).unwrap(); + println!("{report}"); + if report["outcome"] != "pass" + || report["driver_joined"] != true + || report["resources_idle"] != true + { + std::process::exit(1); + } +} diff --git a/tests/quic_datagram_foundations.rs b/tests/quic_datagram_foundations.rs new file mode 100644 index 0000000..87598d7 --- /dev/null +++ b/tests/quic_datagram_foundations.rs @@ -0,0 +1,475 @@ +#![cfg(feature = "quic-transport")] + +use quinn::AsyncUdpSocket; +use std::{future::poll_fn, io::IoSliceMut, time::Duration}; +use vcore::{ + dialer::Dialer, + dispatch::DatagramBudget, + outbound::{DatagramRequest, DirectOutbound, EstablishContext, OutboundConnector}, + session::{DatagramSession, InboundKind}, + transport::quic::attach, +}; + +#[tokio::test] +async fn quic_minimum_budget_is_checked_before_io_and_exact_minimum_connects() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-QUIC", + "quic_minimum_budget_is_checked_before_io_and_exact_minimum_connects", + ); + use std::sync::Arc; + tokio::time::timeout(Duration::from_secs(10), async { + let certificate = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + let cert = certificate.cert.der().clone(); + let key = + rustls::pki_types::PrivatePkcs8KeyDer::from(certificate.signing_key.serialize_der()); + let server_config = + quinn::ServerConfig::with_single_cert(vec![cert.clone()], key.into()).unwrap(); + let server = + quinn::Endpoint::server(server_config, "127.0.0.1:0".parse().unwrap()).unwrap(); + let address = server.local_addr().unwrap(); + let server_task = tokio::spawn(async move { + let connection = server.accept().await.unwrap().await.unwrap(); + let (mut send, mut receive) = connection.accept_bi().await.unwrap(); + assert_eq!(receive.read_to_end(64).await.unwrap(), b"request"); + send.write_all(b"response-after-fin").await.unwrap(); + send.finish().unwrap(); + let _ = send.stopped().await; + connection.closed().await; + server.close(0u32.into(), b""); + server.wait_idle().await; + }); + let open = || async { + DirectOutbound::new(Dialer::default()) + .open_datagram( + DatagramRequest::new(DatagramSession::new( + InboundKind::InternalMeasure, + address, + )), + &EstablishContext::default(), + ) + .await + .unwrap() + }; + assert!(attach(open().await, address, DatagramBudget::new(1199, 1200)).is_err()); + assert!(attach(open().await, address, DatagramBudget::new(1200, 1199)).is_err()); + let (socket, driver) = + attach(open().await, address, DatagramBudget::new(1200, 1200)).unwrap(); + assert_eq!(socket.budget().quic_payload_limit().unwrap(), 1200); + let mut endpoint_config = quinn::EndpointConfig::default(); + endpoint_config.max_udp_payload_size(1200).unwrap(); + let mut client = quinn::Endpoint::new_with_abstract_socket( + endpoint_config, + None, + socket.clone(), + Arc::new(quinn::TokioRuntime), + ) + .unwrap(); + let mut roots = rustls::RootCertStore::empty(); + roots.add(cert).unwrap(); + let mut client_config = + quinn::ClientConfig::with_root_certificates(Arc::new(roots)).unwrap(); + let mut transport = quinn::TransportConfig::default(); + transport + .initial_mtu(1200) + .min_mtu(1200) + .mtu_discovery_config(None) + .max_idle_timeout(Some(Duration::from_secs(3).try_into().unwrap())); + client_config.transport_config(Arc::new(transport)); + client.set_default_client_config(client_config); + let connection = client.connect(address, "localhost").unwrap().await.unwrap(); + let (mut send, mut receive) = connection.open_bi().await.unwrap(); + send.write_all(b"request").await.unwrap(); + send.finish().unwrap(); + assert_eq!( + receive.read_to_end(64).await.unwrap(), + b"response-after-fin" + ); + assert!(socket.stats().sent > 0 && socket.stats().received > 0); + connection.close(0u32.into(), b""); + client.close(0u32.into(), b""); + client.wait_idle().await; + driver.stop().await.unwrap(); + server_task.await.unwrap(); + }) + .await + .unwrap(); +} + +struct PendingSend { + starts: std::sync::Arc, + closed: std::sync::Arc, + entered: std::sync::Arc, +} +#[async_trait::async_trait] +impl vcore::dispatch::DatagramTransport for PendingSend { + async fn send( + &mut self, + _: vcore::session::Datagram, + ) -> Result<(), vcore::dispatch::DispatchError> { + self.starts + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.entered.notify_one(); + std::future::pending().await + } + async fn receive( + &mut self, + ) -> Result { + std::future::pending().await + } + async fn close(&mut self) -> Result<(), vcore::dispatch::DispatchError> { + self.closed + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Ok(()) + } +} + +#[tokio::test] +async fn pending_send_is_not_restarted_and_stop_cancels_without_waiting_for_writable() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-QUIC", + "pending_send_is_not_restarted_and_stop_cancels_without_waiting_for_writable", + ); + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + let starts = Arc::new(AtomicUsize::new(0)); + let closed = Arc::new(AtomicUsize::new(0)); + let entered = Arc::new(tokio::sync::Notify::new()); + let peer = "127.0.0.1:443".parse().unwrap(); + let (socket, driver) = attach( + Box::new(PendingSend { + starts: starts.clone(), + closed: closed.clone(), + entered: entered.clone(), + }), + peer, + DatagramBudget::new(1400, 1400), + ) + .unwrap(); + let packet = quinn::udp::Transmit { + destination: peer, + ecn: None, + contents: b"pending", + segment_size: None, + src_ip: None, + }; + socket.try_send(&packet).unwrap(); + entered.notified().await; + for _ in 0..32 { + socket.try_send(&packet).unwrap(); + } + assert_eq!( + socket.try_send(&packet).unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + tokio::task::yield_now().await; + assert_eq!(starts.load(Ordering::Relaxed), 1); + tokio::time::timeout(Duration::from_secs(2), driver.stop()) + .await + .unwrap() + .unwrap(); + assert_eq!(starts.load(Ordering::Relaxed), 1); + assert_eq!(closed.load(Ordering::Relaxed), 1); + assert!(socket.try_send(&packet).is_err()); +} + +#[tokio::test] +async fn controlled_quic_datagram_seam_roundtrips_and_stop_closes_owned_io() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-QUIC", + "controlled_quic_datagram_seam_roundtrips_and_stop_closes_owned_io", + ); + tokio::time::timeout(Duration::from_secs(3), async { + let peer = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let address = peer.local_addr().unwrap(); + let request = DatagramRequest::new(DatagramSession::new(InboundKind::Socks5, address)) + .with_budget(DatagramBudget::new(1400, 1400)); + let transport = DirectOutbound::new(Dialer::default()) + .open_datagram(request, &EstablishContext::default()) + .await + .unwrap(); + let (socket, owner) = attach(transport, address, DatagramBudget::new(1400, 1400)).unwrap(); + socket + .try_send(&quinn::udp::Transmit { + destination: address, + ecn: None, + contents: b"first", + segment_size: None, + src_ip: None, + }) + .unwrap(); + let mut bytes = [0; 1401]; + let (length, source) = peer.recv_from(&mut bytes).await.unwrap(); + assert_eq!(&bytes[..length], b"first"); + peer.send_to(b"reply", source).await.unwrap(); + let mut buffers = [IoSliceMut::new(&mut bytes)]; + let mut meta = [quinn::udp::RecvMeta::default()]; + assert_eq!( + poll_fn(|cx| socket.poll_recv(cx, &mut buffers, &mut meta)) + .await + .unwrap(), + 1 + ); + assert_eq!(&buffers[0][..meta[0].len], b"reply"); + assert_eq!(meta[0].addr, address); + owner.stop().await.unwrap(); + assert!( + socket + .try_send(&quinn::udp::Transmit { + destination: address, + ecn: None, + contents: b"after-stop", + segment_size: None, + src_ip: None + }) + .is_err() + ); + assert!( + poll_fn(|cx| socket.poll_recv(cx, &mut buffers, &mut meta)) + .await + .is_err() + ); + }) + .await + .unwrap(); +} +#[tokio::test] +async fn mapped_peer_sends_to_one_physical_port_and_reports_the_logical_peer() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-QUIC", + "mapped_peer_sends_to_one_physical_port_and_reports_the_logical_peer", + ); + tokio::time::timeout(Duration::from_secs(5), async { + let logical = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let physical = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let logical_peer = logical.local_addr().unwrap(); + let physical_peer = physical.local_addr().unwrap(); + let transport = DirectOutbound::new(Dialer::default()) + .open_datagram( + DatagramRequest::new(DatagramSession::new(InboundKind::Socks5, logical_peer)) + .with_max_response_payload_size(1400), + &EstablishContext::default(), + ) + .await + .unwrap(); + let (socket, driver) = vcore::transport::quic::attach_mapped( + transport, + logical_peer, + physical_peer, + DatagramBudget::new(1400, 1400), + ) + .unwrap(); + socket + .try_send(&quinn::udp::Transmit { + destination: logical_peer, + ecn: None, + contents: b"mapped", + segment_size: None, + src_ip: None, + }) + .unwrap(); + let mut payload = [0; 1400]; + let (len, source) = physical.recv_from(&mut payload).await.unwrap(); + assert_eq!(&payload[..len], b"mapped"); + logical + .send_to(b"not-the-physical-peer", source) + .await + .unwrap(); + while socket.stats().rejected_source == 0 { + tokio::task::yield_now().await; + } + physical.send_to(b"mapped-reply", source).await.unwrap(); + let mut buffers = [IoSliceMut::new(&mut payload)]; + let mut meta = [quinn::udp::RecvMeta::default()]; + poll_fn(|cx| socket.poll_recv(cx, &mut buffers, &mut meta)) + .await + .unwrap(); + assert_eq!(meta[0].addr, logical_peer); + assert_eq!(&buffers[0][..meta[0].len], b"mapped-reply"); + driver.stop().await.unwrap(); + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn incoming_queue_backpressures_without_discarding_a_controlled_burst() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-QUIC", + "incoming_queue_backpressures_without_discarding_a_controlled_burst", + ); + tokio::time::timeout(Duration::from_secs(5), async { + let echo = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let peer = echo.local_addr().unwrap(); + let transport = DirectOutbound::new(Dialer::default()) + .open_datagram( + DatagramRequest::new(DatagramSession::new(InboundKind::Socks5, peer)) + .with_max_response_payload_size(1400), + &EstablishContext::default(), + ) + .await + .unwrap(); + let (socket, driver) = attach(transport, peer, DatagramBudget::new(1400, 1400)).unwrap(); + socket + .try_send(&quinn::udp::Transmit { + destination: peer, + ecn: None, + contents: b"ready", + segment_size: None, + src_ip: None, + }) + .unwrap(); + let mut payload = [0; 1400]; + let (_, source) = echo.recv_from(&mut payload).await.unwrap(); + for sequence in 0..40_u8 { + echo.send_to(&[sequence], source).await.unwrap(); + } + // Deliberately pause the reader until the bounded queue saturates. + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!( + socket.stats().peak_incoming, + vcore::transport::quic::QUEUE_LIMIT + ); + assert_eq!(socket.stats().dropped_full, 0); + for expected in 0..40_u8 { + let mut buffers = [IoSliceMut::new(&mut payload)]; + let mut meta = [quinn::udp::RecvMeta::default()]; + poll_fn(|cx| socket.poll_recv(cx, &mut buffers, &mut meta)) + .await + .unwrap(); + assert_eq!(&buffers[0][..meta[0].len], &[expected]); + } + driver.stop().await.unwrap(); + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn controlled_direct_datagrams_roundtrip_and_stop() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-QUIC", + "controlled_direct_datagrams_roundtrip_and_stop", + ); + tokio::time::timeout(Duration::from_secs(5), async { + let echo = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let peer = echo.local_addr().unwrap(); + let transport = DirectOutbound::new(Dialer::default()) + .open_datagram( + DatagramRequest::new(DatagramSession::new( + InboundKind::Socks5, + "127.0.0.1:0".parse().unwrap(), + )) + .with_max_response_payload_size(1400), + &EstablishContext::default(), + ) + .await + .unwrap(); + let (socket, driver) = attach(transport, peer, DatagramBudget::new(1400, 1400)).unwrap(); + socket + .try_send(&quinn::udp::Transmit { + destination: peer, + ecn: None, + contents: b"controlled-datagram", + segment_size: None, + src_ip: None, + }) + .unwrap(); + let mut payload = [0; 1400]; + let (len, source) = echo.recv_from(&mut payload).await.unwrap(); + assert_eq!(&payload[..len], b"controlled-datagram"); + let foreign = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + foreign.send_to(b"wrong-source", source).await.unwrap(); + while socket.stats().rejected_source == 0 { + tokio::task::yield_now().await; + } + assert_eq!( + socket + .try_send(&quinn::udp::Transmit { + destination: foreign.local_addr().unwrap(), + ecn: None, + contents: b"wrong-target", + segment_size: None, + src_ip: None, + }) + .unwrap_err() + .kind(), + std::io::ErrorKind::InvalidInput + ); + assert_eq!( + socket + .try_send(&quinn::udp::Transmit { + destination: peer, + ecn: None, + contents: &[0; 1401], + segment_size: None, + src_ip: None, + }) + .unwrap_err() + .kind(), + std::io::ErrorKind::InvalidInput + ); + echo.send_to(b"reply", source).await.unwrap(); + let mut buffers = [IoSliceMut::new(&mut payload)]; + let mut meta = [quinn::udp::RecvMeta::default()]; + assert_eq!( + poll_fn(|cx| socket.poll_recv(cx, &mut buffers, &mut meta)) + .await + .unwrap(), + 1 + ); + assert_eq!(meta[0].addr, peer); + assert_eq!(&buffers[0][..meta[0].len], b"reply"); + // The current-thread driver cannot consume these until we yield. + let transmit = quinn::udp::Transmit { + destination: peer, + ecn: None, + contents: b"bounded", + segment_size: None, + src_ip: None, + }; + for _ in 0..vcore::transport::quic::QUEUE_LIMIT { + socket.try_send(&transmit).unwrap(); + } + assert_eq!( + socket.try_send(&transmit).unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + let mut writable = socket.clone().create_io_poller(); + let waker = futures_util::task::noop_waker(); + assert!( + writable + .as_mut() + .poll_writable(&mut std::task::Context::from_waker(&waker)) + .is_pending() + ); + poll_fn(|cx| writable.as_mut().poll_writable(cx)) + .await + .unwrap(); + assert_eq!( + socket.stats().peak_outgoing, + vcore::transport::quic::QUEUE_LIMIT + ); + driver.stop().await.unwrap(); + assert!( + socket + .try_send(&quinn::udp::Transmit { + destination: peer, + ecn: None, + contents: b"after-stop", + segment_size: None, + src_ip: None + }) + .is_err() + ); + }) + .await + .unwrap(); +} diff --git a/tests/reality_config.rs b/tests/reality_config.rs new file mode 100644 index 0000000..2580575 --- /dev/null +++ b/tests/reality_config.rs @@ -0,0 +1,226 @@ +#![cfg(feature = "outbound-vless")] + +use serde_json::{Value, json}; +use vcore::config::{ClientFingerprint, Config, ProxyProtocol, SecurityConfig}; + +fn proxy() -> Value { + json!({ + "name": "edge", "type": "vless", "server": "example.invalid", "port": 443, + "uuid": "07070707-0707-0707-0707-070707070707", "tls": true, + "reality-opts": { + "public-key": "3p7bfXt9wbTTW2HC7OQ1Nz-DQ8hbeGdNrfx-FG-IK08", + "support-x25519mlkem768": true + } + }) +} + +fn parse(proxy: Value) -> vcore::Result { + Config::parse_yaml( + &serde_json::to_vec(&json!({ + "socks-port": 1080, "proxies": [proxy], "rules": ["MATCH,edge"] + })) + .unwrap(), + ) +} + +#[test] +fn public_reality_accepts_explicit_hybrid_without_replacing_the_fingerprint() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "public_reality_accepts_explicit_hybrid_without_replacing_the_fingerprint", + ); + for profile in [None, Some("none"), Some(""), Some("chrome")] { + let mut node = proxy(); + if let Some(profile) = profile { + node["client-fingerprint"] = json!(profile); + } + assert!( + parse(node).is_ok(), + "hybrid REALITY rejected for {profile:?}" + ); + } +} + +#[test] +fn public_reality_rejects_profiles_without_the_required_hybrid_share() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "public_reality_rejects_profiles_without_the_required_hybrid_share", + ); + for profile in ["chrome120", "firefox", "firefox120", "safari", "safari16"] { + let mut node = proxy(); + node["client-fingerprint"] = json!(profile); + assert!( + parse(node).is_err(), + "accepted incompatible profile {profile}" + ); + } +} + +fn securities(node: Value) -> (SecurityConfig, SecurityConfig) { + let config = parse(node).unwrap(); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + panic!() + }; + ( + node.security.clone(), + node.download().unwrap().security.clone(), + ) +} + +fn split_proxy() -> Value { + let mut node = proxy(); + node["network"] = json!("xhttp"); + node["xhttp-opts"] = json!({"mode":"stream-up", "download-settings":{}}); + node +} + +#[test] +fn download_inherits_or_replaces_the_entire_reality_object_without_leaf_merging() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "download_inherits_or_replaces_the_entire_reality_object_without_leaf_merging", + ); + let node = split_proxy(); + let (main, download) = securities(node.clone()); + assert_eq!(main, download); + let SecurityConfig::Reality(inherited) = download else { + panic!() + }; + assert!(inherited.support_x25519mlkem768); + assert_eq!(inherited.client_fingerprint, None); + + let mut replacement = node.clone(); + replacement["client-fingerprint"] = json!("chrome"); + replacement["reality-opts"]["short-id"] = json!("1234"); + replacement["xhttp-opts"]["download-settings"]["reality-opts"] = + json!({"public-key":node["reality-opts"]["public-key"]}); + let (_, download) = securities(replacement); + let SecurityConfig::Reality(download) = download else { + panic!() + }; + assert!(!download.support_x25519mlkem768); + assert!(download.short_id.is_empty()); + assert_eq!( + download.client_fingerprint, + Some(ClientFingerprint::Chrome133) + ); + + let mut cleared = node; + cleared["xhttp-opts"]["download-settings"]["reality-opts"] = json!({}); + assert!(matches!(securities(cleared).1, SecurityConfig::Tls(_))); +} + +#[test] +fn hybrid_flag_is_a_strict_nonnullable_boolean_on_both_legs() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "hybrid_flag_is_a_strict_nonnullable_boolean_on_both_legs", + ); + for value in [Value::Null, json!("true"), json!(1), json!([]), json!({})] { + let mut main = proxy(); + main["reality-opts"]["support-x25519mlkem768"] = value.clone(); + assert!(parse(main).is_err()); + let mut download = split_proxy(); + download["xhttp-opts"]["download-settings"]["reality-opts"] = + proxy()["reality-opts"].clone(); + download["xhttp-opts"]["download-settings"]["reality-opts"]["support-x25519mlkem768"] = + value; + assert!(parse(download).is_err()); + } + for value in [false, true] { + let mut node = split_proxy(); + node["xhttp-opts"]["download-settings"]["reality-opts"] = + json!({"support-x25519mlkem768":value}); + assert!( + parse(node).is_err(), + "nonempty replacement requires public-key" + ); + } +} + +#[test] +fn classic_default_and_explicit_false_preserve_all_existing_profiles() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "classic_default_and_explicit_false_preserve_all_existing_profiles", + ); + for profile in [ + "none", + "chrome", + "chrome120", + "firefox", + "firefox120", + "safari", + "safari16", + ] { + for explicit in [false, true] { + let mut node = split_proxy(); + node["client-fingerprint"] = json!(profile); + if explicit { + node["reality-opts"]["support-x25519mlkem768"] = json!(false); + } else { + node["reality-opts"] + .as_object_mut() + .unwrap() + .remove("support-x25519mlkem768"); + } + let (main, download) = securities(node); + for security in [main, download] { + let SecurityConfig::Reality(reality) = security else { + panic!() + }; + assert!(!reality.support_x25519mlkem768); + } + } + } +} + +#[test] +fn inherited_profile_is_validated_after_download_reality_replacement() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "inherited_profile_is_validated_after_download_reality_replacement", + ); + let mut node = split_proxy(); + node["client-fingerprint"] = json!("firefox"); + node["reality-opts"]["support-x25519mlkem768"] = json!(false); + node["xhttp-opts"]["download-settings"]["reality-opts"] = proxy()["reality-opts"].clone(); + assert!(parse(node.clone()).is_err()); + node["xhttp-opts"]["download-settings"]["client-fingerprint"] = json!("chrome"); + assert!(parse(node).is_ok()); + + let mut inherited = split_proxy(); + inherited["xhttp-opts"]["download-settings"]["client-fingerprint"] = json!("safari"); + assert!(parse(inherited).is_err()); +} + +#[test] +fn hybrid_reality_cannot_be_combined_with_h3_or_plaintext_on_either_leg() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "SECURITY-CONFIG-TLS", + "hybrid_reality_cannot_be_combined_with_h3_or_plaintext_on_either_leg", + ); + for download in [false, true] { + for patch in [json!({"tls":false}), json!({"alpn":["h3"]})] { + let mut node = split_proxy(); + let target = if download { + &mut node["xhttp-opts"]["download-settings"] + } else { + &mut node + }; + target + .as_object_mut() + .unwrap() + .extend(patch.as_object().unwrap().clone()); + assert!(parse(node).is_err()); + } + } +} diff --git a/tests/resolution_foundations.rs b/tests/resolution_foundations.rs new file mode 100644 index 0000000..5989534 --- /dev/null +++ b/tests/resolution_foundations.rs @@ -0,0 +1,393 @@ +use async_trait::async_trait; +use std::{ + io, + net::SocketAddr, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; +use vcore::{ + dialer::{ResolvedEndpoint, Resolver}, + dns::resolution::ResolutionContext, + outbound::EstablishContext, + session::Destination, +}; + +struct Bootstrap(AtomicUsize); + +struct IpBoundary { + seen: Arc>>, +} +#[async_trait] +impl vcore::outbound::OutboundConnector for IpBoundary { + async fn connect_stream( + &self, + session: vcore::session::StreamSession, + context: &EstablishContext, + ) -> Result { + let effective = context.resolve_ip(&session.destination).await?; + self.seen + .lock() + .unwrap() + .push((session.destination, effective)); + let (stream, _) = tokio::io::duplex(1); + Ok(vcore::outbound::ConnectedStream { + io: Box::new(stream), + effective_peer: Destination::Ip(effective), + }) + } + async fn open_datagram( + &self, + _: vcore::outbound::DatagramRequest, + _: &EstablishContext, + ) -> Result, vcore::dispatch::DispatchError> { + unreachable!() + } +} + +#[tokio::test] +async fn measurement_domain_resolution_reaches_both_final_and_upstream_ip_boundaries() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-RESOLUTION", + "measurement_domain_resolution_reaches_both_final_and_upstream_ip_boundaries", + ); + use vcore::{ + dispatch::Dispatcher, + outbound::{ConnectorDispatcher, UpstreamPath}, + session::{InboundKind, StreamSession}, + }; + let bootstrap = Arc::new(Bootstrap(AtomicUsize::new(0))); + let resolution = ResolutionContext::measurement(bootstrap.clone(), false); + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let connector = Arc::new(IpBoundary { seen: seen.clone() }); + let dispatcher = + ConnectorDispatcher::new(connector.clone()).with_resolution(resolution.clone()); + let target = Destination::domain("controlled.example", 443).unwrap(); + let session = StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:0".parse().unwrap(), + destination: target.clone(), + sniffed_domain: None, + }; + dispatcher.connect_tcp(session.clone()).await.unwrap(); + let upstream_target = Destination::domain("controlled.example", 8443).unwrap(); + UpstreamPath::proxy(connector) + .connect_server( + session, + &upstream_target, + &EstablishContext::with_resolution(Duration::from_secs(10), resolution.clone()), + ) + .await + .unwrap(); + assert_eq!( + *seen.lock().unwrap(), + vec![ + (target, "192.0.2.1:443".parse().unwrap()), + (upstream_target, "192.0.2.1:8443".parse().unwrap()) + ] + ); + assert_eq!(bootstrap.0.load(Ordering::Relaxed), 2); + resolution.close(); +} + +struct ChainedBootstrap(Arc); +#[async_trait] +impl Resolver for ChainedBootstrap { + async fn resolve(&self, _: &str, port: u16) -> io::Result { + let depth = self.0.fetch_add(1, Ordering::Relaxed) + 1; + let resolution = ResolutionContext::measurement(Arc::new(Self(self.0.clone())), true); + resolution + .resolve_ip( + &Destination::domain(format!("level-{depth}.example"), port).unwrap(), + tokio::time::Instant::now() + Duration::from_secs(10), + ) + .await + .map_err(|_| io::Error::other("bounded dependency rejected"))?; + unreachable!() + } +} + +#[tokio::test] +async fn resolver_dependency_depth_is_bounded_independently_of_concurrent_lookups() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-RESOLUTION", + "resolver_dependency_depth_is_bounded_independently_of_concurrent_lookups", + ); + let calls = Arc::new(AtomicUsize::new(0)); + let resolution = + ResolutionContext::measurement(Arc::new(ChainedBootstrap(calls.clone())), true); + let context = EstablishContext::with_resolution(Duration::from_secs(1), resolution); + assert!( + context + .resolve_ip(&Destination::domain("level-0.example", 443).unwrap()) + .await + .is_err() + ); + assert_eq!(calls.load(Ordering::Relaxed), 32); +} +#[async_trait] +impl Resolver for Bootstrap { + async fn resolve(&self, host: &str, port: u16) -> io::Result { + self.0.fetch_add(1, Ordering::Relaxed); + assert_eq!(host, "controlled.example"); + Ok(ResolvedEndpoint { + logical_host: host.into(), + port, + addresses: vec![ + SocketAddr::new("2001:db8::1".parse().unwrap(), port), + SocketAddr::new("192.0.2.1".parse().unwrap(), port), + ], + }) + } +} + +#[tokio::test] +async fn runtime_uses_configured_dns_and_weak_binding_releases_owner() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-RESOLUTION", + "runtime_uses_configured_dns_and_weak_binding_releases_owner", + ); + use vcore::{ + config::{DnsConfig, DnsNameserver, DnsRoute, DnsTransport}, + dialer::Dialer, + dns::runtime::RuntimeDns, + outbound::DirectOutbound, + }; + let peer = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let address = peer.local_addr().unwrap(); + let responder = tokio::spawn(async move { + let mut packet = [0; 4096]; + let (length, source) = peer.recv_from(&mut packet).await.unwrap(); + assert_eq!( + vcore::dns::parse_query(&packet[..length]) + .unwrap() + .question + .name, + "controlled.example" + ); + let mut response = packet[..length].to_vec(); + response[2..4].copy_from_slice(&0x8180_u16.to_be_bytes()); + response[6..8].copy_from_slice(&1_u16.to_be_bytes()); + response.extend_from_slice(&[0xc0, 12, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 203, 0, 113, 11]); + peer.send_to(&response, source).await.unwrap(); + }); + let config = DnsConfig { + enable: true, + ipv6: false, + nameservers: vec![DnsNameserver { + transport: DnsTransport::Udp, + address: address.ip(), + port: address.port(), + route: DnsRoute::Direct, + }], + nameserver_policies: vec![], + }; + let dns = Arc::new(RuntimeDns::new( + &config, + Arc::new(DirectOutbound::new(Dialer::default())), + )); + let weak = Arc::downgrade(&dns); + let resolution = ResolutionContext::runtime(true); + resolution.bind_runtime(Some(&dns)).unwrap(); + assert_eq!(Arc::strong_count(&dns), 1); + let context = EstablishContext::with_resolution(Duration::from_secs(1), resolution); + assert_eq!( + context + .resolve_ip(&Destination::domain("controlled.example", 8443).unwrap()) + .await + .unwrap(), + "203.0.113.11:8443".parse().unwrap() + ); + responder.await.unwrap(); + drop(dns); + assert!(weak.upgrade().is_none()); + assert!( + context + .resolve_ip(&Destination::domain("controlled.example", 8443).unwrap()) + .await + .is_err() + ); +} + +struct RecursiveEgress { + resolution: ResolutionContext, + rejected: Arc, +} +#[async_trait] +impl vcore::dispatch::Dispatcher for RecursiveEgress { + async fn connect_tcp( + &self, + _: vcore::session::StreamSession, + ) -> Result { + unreachable!() + } + async fn open_datagram( + &self, + _: vcore::session::DatagramSession, + ) -> Result, vcore::dispatch::DispatchError> { + let context = + EstablishContext::with_resolution(Duration::from_secs(10), self.resolution.clone()); + let error = context + .resolve_ip(&Destination::domain("controlled.example", 53).unwrap()) + .await + .unwrap_err(); + assert!(error.to_string().contains("recursive DNS dependency")); + self.rejected.fetch_add(1, Ordering::Relaxed); + Err(error) + } +} + +#[tokio::test] +async fn recursive_dns_dependency_fails_before_connecting_or_joining_its_own_flight() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-RESOLUTION", + "recursive_dns_dependency_fails_before_connecting_or_joining_its_own_flight", + ); + use vcore::{ + config::{DnsConfig, DnsNameserver, DnsRoute, DnsTransport}, + dns::runtime::RuntimeDns, + }; + let resolution = ResolutionContext::runtime(true); + let rejected = Arc::new(AtomicUsize::new(0)); + let egress = Arc::new(RecursiveEgress { + resolution: resolution.clone(), + rejected: rejected.clone(), + }); + let config = DnsConfig { + enable: true, + ipv6: false, + nameservers: vec![DnsNameserver { + transport: DnsTransport::Udp, + address: "192.0.2.1".parse().unwrap(), + port: 53, + route: DnsRoute::Direct, + }], + nameserver_policies: vec![], + }; + let dns = Arc::new(RuntimeDns::new(&config, egress)); + resolution.bind_runtime(Some(&dns)).unwrap(); + let context = EstablishContext::with_resolution(Duration::from_secs(10), resolution); + let result = tokio::time::timeout( + Duration::from_millis(200), + context.resolve_ip(&Destination::domain("controlled.example", 443).unwrap()), + ) + .await + .unwrap(); + assert!(result.is_err()); + assert_eq!(rejected.load(Ordering::Relaxed), 1); +} + +struct PendingBootstrap { + active: Arc, + entered: Arc, +} +struct LookupGuard(Arc); +impl Drop for LookupGuard { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::Relaxed); + } +} +#[async_trait] +impl Resolver for PendingBootstrap { + async fn resolve(&self, _: &str, _: u16) -> io::Result { + self.active.fetch_add(1, Ordering::Relaxed); + let _guard = LookupGuard(self.active.clone()); + self.entered.notify_one(); + std::future::pending().await + } +} + +#[tokio::test] +async fn lookup_deadline_and_stop_cancel_pending_resolver_without_retaining_waiters() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-RESOLUTION", + "lookup_deadline_and_stop_cancel_pending_resolver_without_retaining_waiters", + ); + let active = Arc::new(AtomicUsize::new(0)); + let entered = Arc::new(tokio::sync::Notify::new()); + let resolution = ResolutionContext::measurement( + Arc::new(PendingBootstrap { + active: active.clone(), + entered: entered.clone(), + }), + true, + ); + let target = Destination::domain("controlled.example", 443).unwrap(); + let context = EstablishContext::with_resolution(Duration::from_millis(10), resolution.clone()); + assert!(matches!( + context.resolve_ip(&target).await, + Err(vcore::dispatch::DispatchError::TimedOut) + )); + assert_eq!(active.load(Ordering::Relaxed), 0); + // Consume the first lookup's notification before observing the second. + entered.notified().await; + let context = EstablishContext::with_resolution(Duration::from_secs(10), resolution.clone()); + let task = tokio::spawn(async move { context.resolve_ip(&target).await }); + entered.notified().await; + assert_eq!(active.load(Ordering::Relaxed), 1); + resolution.close(); + assert!(task.await.unwrap().is_err()); + assert_eq!(active.load(Ordering::Relaxed), 0); +} + +#[tokio::test] +async fn standalone_measurement_resolves_only_at_ip_boundary_with_address_policy_and_stop() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-RESOLUTION", + "standalone_measurement_resolves_only_at_ip_boundary_with_address_policy_and_stop", + ); + let bootstrap = Arc::new(Bootstrap(AtomicUsize::new(0))); + let resolution = ResolutionContext::measurement(bootstrap.clone(), false); + let context = EstablishContext::with_resolution(Duration::from_secs(10), resolution.clone()); + let target = Destination::domain("controlled.example", 443).unwrap(); + assert_eq!(bootstrap.0.load(Ordering::Relaxed), 0); + assert_eq!( + context.resolve_ip(&target).await.unwrap(), + "192.0.2.1:443".parse().unwrap() + ); + assert_eq!( + target, + Destination::domain("controlled.example", 443).unwrap() + ); + assert_eq!(bootstrap.0.load(Ordering::Relaxed), 1); + resolution.close(); + assert!(context.resolve_ip(&target).await.is_err()); + assert_eq!(bootstrap.0.load(Ordering::Relaxed), 1); +} + +#[tokio::test] +async fn unbound_and_disabled_runtime_resolution_never_fall_back_to_system_dns() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-RESOLUTION", + "unbound_and_disabled_runtime_resolution_never_fall_back_to_system_dns", + ); + let resolution = ResolutionContext::runtime(false); + let context = EstablishContext::with_resolution(Duration::from_secs(10), resolution.clone()); + let target = Destination::domain("localhost", 443).unwrap(); + assert!(context.resolve_ip(&target).await.is_err()); + resolution.bind_runtime(None).unwrap(); + assert!(context.resolve_ip(&target).await.is_err()); + assert!(resolution.bind_runtime(None).is_err()); + assert_eq!( + context + .resolve_ip(&Destination::Ip("192.0.2.5:443".parse().unwrap())) + .await + .unwrap(), + "192.0.2.5:443".parse().unwrap() + ); + assert!( + context + .resolve_ip(&Destination::Ip("[2001:db8::1]:443".parse().unwrap())) + .await + .is_err() + ); +} diff --git a/tests/resource_foundations.rs b/tests/resource_foundations.rs new file mode 100644 index 0000000..905045e --- /dev/null +++ b/tests/resource_foundations.rs @@ -0,0 +1,187 @@ +#![cfg(all(feature = "interop-test", feature = "quic-transport"))] +use quinn::AsyncUdpSocket; +use std::{sync::Arc, time::Duration}; +use vcore::{ + dialer::Dialer, + dispatch::DatagramBudget, + outbound::{DatagramRequest, DirectOutbound, EstablishContext, OutboundConnector}, + resources::observation::{ResourceKind, ResourceProbe}, + session::{DatagramSession, InboundKind}, + transport::quic::attach, +}; + +#[tokio::test] +async fn twenty_quic_lifetimes_return_owned_resources_to_zero_then_remain_quiet() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-RESOURCES", + "twenty_quic_lifetimes_return_owned_resources_to_zero_then_remain_quiet", + ); + let probe = ResourceProbe::default(); + _case.checkpoint("baseline", probe.snapshot()); + for _ in 0..20 { + probe + .scope(async { + let peer = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let address = peer.local_addr().unwrap(); + let transport = DirectOutbound::new(Dialer::default()) + .open_datagram( + DatagramRequest::new(DatagramSession::new( + InboundKind::InternalMeasure, + address, + )), + &EstablishContext::default(), + ) + .await + .unwrap(); + let (socket, driver) = + attach(transport, address, DatagramBudget::new(1400, 1400)).unwrap(); + socket + .try_send(&quinn::udp::Transmit { + destination: address, + ecn: None, + contents: b"lifetime", + segment_size: None, + src_ip: None, + }) + .unwrap(); + let mut bytes = [0; 16]; + peer.recv_from(&mut bytes).await.unwrap(); + assert_eq!(probe.snapshot().current(ResourceKind::Task), 1); + assert_eq!(probe.snapshot().current(ResourceKind::Socket), 1); + driver.stop().await.unwrap(); + drop(socket); + }) + .await; + assert!(probe.snapshot().is_idle()); + } + let stopped = probe.snapshot(); + assert_eq!(stopped.peak(ResourceKind::Task), 1); + assert_eq!(stopped.peak(ResourceKind::Socket), 1); + _case.checkpoint("after-stop", stopped.clone()); + tokio::time::sleep(Duration::from_secs(5)).await; + assert_eq!(probe.snapshot(), stopped); + _case.checkpoint("quiet", probe.snapshot()); + _case.resources(stopped); +} + +#[tokio::test] +async fn physical_tcp_guard_survives_connect_and_releases_on_stream_drop() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-RESOURCES", + "physical_tcp_guard_survives_connect_and_releases_on_stream_drop", + ); + let probe = Arc::new(ResourceProbe::default()); + _case.checkpoint("baseline", probe.snapshot()); + probe + .scope(async { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let dialer = Dialer::default(); + let (client, peer) = tokio::join!( + dialer.connect_address(listener.local_addr().unwrap()), + listener.accept() + ); + let client = client.unwrap(); + let _peer = peer.unwrap(); + assert_eq!(probe.snapshot().current(ResourceKind::Socket), 1); + drop(client); + assert!(probe.snapshot().is_idle()); + }) + .await; + _case.checkpoint("after-stop", probe.snapshot()); + _case.resources(probe.snapshot()); +} + +#[cfg(feature = "stream-transport")] +#[tokio::test] +async fn twenty_stream_lifetimes_and_cancelled_setups_remain_quiet() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-RESOURCES", + "twenty_stream_lifetimes_and_cancelled_setups_remain_quiet", + ); + let probe = ResourceProbe::default(); + case.checkpoint("baseline", probe.snapshot()); + for _ in 0..20 { + for is_grpc in [true, false] { + tokio::time::timeout( + Duration::from_secs(5), + probe.scope(async { + let (client, peer) = tokio::io::duplex(1024); + let peer = tokio::spawn(async move { + let mut connection = h2::server::handshake(peer).await.unwrap(); + let (_, mut respond) = connection.accept().await.unwrap().unwrap(); + let _response = respond + .send_response(http::Response::new(()), false) + .unwrap(); + while let Some(result) = connection.accept().await { + if result.is_err() { + break; + } + } + }); + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + let (mut stream, owner) = if is_grpc { + vcore::transport::grpc( + Box::new(client), + "https://fixture.invalid/s/Tun", + deadline, + ) + .await + } else { + vcore::transport::legacy_h2( + Box::new(client), + "https://fixture.invalid/h2", + deadline, + ) + .await + } + .unwrap(); + stream.write_all(b"active stream").await.unwrap(); + stream.flush().await.unwrap(); + assert_eq!(probe.snapshot().current(ResourceKind::Task), 1); + assert_eq!(probe.snapshot().current(ResourceKind::Session), 1); + drop(stream); + owner.stop().await.unwrap(); + peer.await.unwrap(); + }), + ) + .await + .unwrap(); + assert!(probe.snapshot().is_idle()); + } + // Cancellation before an Upgrade reply drops the supplied IO and creates + // no detached task; the peer observes EOF without a process exit. + probe + .scope(async { + let (client, mut peer) = tokio::io::duplex(1024); + let result = tokio::time::timeout( + Duration::from_millis(2), + vcore::transport::websocket( + Box::new(client), + "ws://fixture.invalid/", + tokio::time::Instant::now() + Duration::from_secs(2), + ), + ) + .await; + assert!(result.is_err()); + let mut input = Vec::new(); + tokio::time::timeout(Duration::from_secs(1), peer.read_to_end(&mut input)) + .await + .unwrap() + .unwrap(); + assert!(input.starts_with(b"GET / HTTP/1.1")); + }) + .await; + assert!(probe.snapshot().is_idle()); + } + let stopped = probe.snapshot(); + assert_eq!(stopped.peak(ResourceKind::Task), 1); + assert_eq!(stopped.peak(ResourceKind::Session), 1); + case.checkpoint("after-stop", stopped.clone()); + tokio::time::sleep(Duration::from_secs(5)).await; + assert_eq!(probe.snapshot(), stopped); + case.checkpoint("quiet", probe.snapshot()); + case.resources(stopped); +} diff --git a/tests/retired_security.rs b/tests/retired_security.rs new file mode 100644 index 0000000..db0a2b9 --- /dev/null +++ b/tests/retired_security.rs @@ -0,0 +1,62 @@ +#![cfg(feature = "outbound-vless")] + +use serde_json::{Value, json}; +use vcore::config::Config; + +fn node() -> Value { + json!({"name":"edge", "type":"vless", "server":"example.invalid", "port":443, + "uuid":"07070707-0707-0707-0707-070707070707", "tls":true}) +} + +fn parse(node: Value) -> vcore::Result { + Config::parse_yaml( + &serde_json::to_vec(&json!({ + "socks-port":1080, "proxies":[node], "rules":["MATCH,edge"] + })) + .unwrap(), + ) +} + +fn retired_values() -> [Value; 4] { + [ + json!({"password":"retired-secret-marker", "version-hint":"tls12"}), + json!({}), + Value::Null, + json!("retired-secret-marker"), + ] +} + +#[test] +fn restls_is_rejected_but_jls_remains_valid_on_the_primary_leg() { + parse(node()).expect("ordinary TLS remains valid"); + let mut baseline = node(); + baseline["jls-opts"] = json!({"username":"retained-user", "password":"retained-password"}); + parse(baseline.clone()).expect("JLS remains supported"); + for original in [node(), baseline] { + for value in retired_values() { + let mut input = original.clone(); + input["restls-opts"] = value; + let error = parse(input).expect_err("Restls must not be silently ignored"); + assert!(!error.to_string().contains("retired-secret-marker")); + } + } +} + +#[test] +fn restls_is_rejected_but_jls_remains_valid_on_the_download_leg() { + let mut ordinary = node(); + ordinary["network"] = json!("xhttp"); + ordinary["xhttp-opts"] = json!({"mode":"stream-up", "download-settings":{}}); + parse(ordinary.clone()).expect("independent ordinary TLS remains valid"); + let mut jls = ordinary.clone(); + jls["jls-opts"] = json!({"username":"retained-user", "password":"retained-password"}); + parse(jls.clone()).expect("inherited JLS remains supported"); + for baseline in [ordinary, jls] { + for value in retired_values() { + let mut input = baseline.clone(); + input["xhttp-opts"]["download-settings"]["restls-opts"] = value; + let error = parse(input).expect_err("retired download option must be rejected"); + assert!(!error.to_string().contains("retired-secret-marker")); + } + } +} diff --git a/tests/run_anytls_interop.sh b/tests/run_anytls_interop.sh deleted file mode 100755 index c781844..0000000 --- a/tests/run_anytls_interop.sh +++ /dev/null @@ -1,103 +0,0 @@ -#!/bin/sh -set -eu - -SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -CRATE_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) -REFERENCE_DIR=${ANYTLS_GO_DIR:-"$CRATE_DIR/../references/anytls-go"} -PASSWORD=${ANYTLS_INTEROP_PASSWORD:-vcore-anytls-interop} -TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/vcore-anytls.XXXXXX") -SERVER_PID= - -cleanup() { - if [ -n "$SERVER_PID" ]; then - kill "$SERVER_PID" 2>/dev/null || true - wait "$SERVER_PID" 2>/dev/null || true - fi - rm -rf "$TMP_DIR" -} -trap cleanup EXIT INT TERM - -if ! command -v go >/dev/null 2>&1; then - echo "Go is required to build the local anytls-go reference server" >&2 - exit 2 -fi -if ! command -v python3 >/dev/null 2>&1; then - echo "Python 3 is required to reserve a local interoperability port" >&2 - exit 2 -fi -if ! command -v git >/dev/null 2>&1; then - echo "Git is required to identify the anytls-go reference revision" >&2 - exit 2 -fi -if [ ! -f "$REFERENCE_DIR/go.mod" ] || [ ! -d "$REFERENCE_DIR/cmd/server" ]; then - echo "anytls-go reference checkout was not found at $REFERENCE_DIR" >&2 - echo "Set ANYTLS_GO_DIR to its local checkout" >&2 - exit 2 -fi -REFERENCE_PREFIX=$(git -C "$REFERENCE_DIR" rev-parse --show-prefix 2>/dev/null) || { - echo "anytls-go reference is not a Git checkout: $REFERENCE_DIR" >&2 - exit 2 -} -if [ -n "$REFERENCE_PREFIX" ]; then - echo "anytls-go reference must be the root of its Git checkout: $REFERENCE_DIR" >&2 - exit 2 -fi -REFERENCE_REVISION=$(git -C "$REFERENCE_DIR" rev-parse --verify HEAD 2>/dev/null) || { - echo "anytls-go reference revision is unavailable: $REFERENCE_DIR" >&2 - exit 2 -} - -if [ -n "${ANYTLS_INTEROP_PORT:-}" ]; then - PORT=$ANYTLS_INTEROP_PORT -else - PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()') -fi -ADDRESS="127.0.0.1:$PORT" - -printf 'anytls-go revision: %s\n' "$REFERENCE_REVISION" -go version - -( - cd "$REFERENCE_DIR" - go build -trimpath -o "$TMP_DIR/anytls-server" ./cmd/server -) - -LOG_LEVEL=debug "$TMP_DIR/anytls-server" \ - -l "$ADDRESS" \ - -p "$PASSWORD" \ - >"$TMP_DIR/anytls-server.log" 2>&1 & -SERVER_PID=$! - -ready=false -attempt=0 -while [ "$attempt" -lt 50 ]; do - if ! kill -0 "$SERVER_PID" 2>/dev/null; then - cat "$TMP_DIR/anytls-server.log" - exit 1 - fi - if python3 -c 'import socket,sys; s=socket.create_connection((sys.argv[1], int(sys.argv[2])), 0.2); s.close()' \ - 127.0.0.1 "$PORT" 2>/dev/null - then - ready=true - break - fi - attempt=$((attempt + 1)) - sleep 0.1 -done -if [ "$ready" != true ]; then - echo "anytls-go server did not become ready at $ADDRESS" >&2 - cat "$TMP_DIR/anytls-server.log" - exit 1 -fi - -if ! ( - cd "$CRATE_DIR" - ANYTLS_INTEROP_ADDRESS="$ADDRESS" \ - ANYTLS_INTEROP_PASSWORD="$PASSWORD" \ - cargo test --features interop-test --test anytls_interop -- --ignored --nocapture -); then - cat "$TMP_DIR/anytls-server.log" - exit 1 -fi - -printf 'AnyTLS interop passed: TCP echo, UoT v2 UDP echo, one-session reuse, shutdown\n' diff --git a/tests/run_mihomo_interop.sh b/tests/run_mihomo_interop.sh deleted file mode 100644 index b69f089..0000000 --- a/tests/run_mihomo_interop.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh -set -eu - -MIHOMO_TEST_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) -cd "$MIHOMO_TEST_ROOT" -exec uv run --project scripts --locked vcore-scripts check mihomo-interop "$@" diff --git a/tests/run_xray_interop.sh b/tests/run_xray_interop.sh deleted file mode 100755 index b8d0821..0000000 --- a/tests/run_xray_interop.sh +++ /dev/null @@ -1,176 +0,0 @@ -#!/bin/sh -set -eu - -SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -CRATE_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd) -XRAY_BIN=${XRAY_BIN:-xray} -PORT=${XRAY_INTEROP_PORT:-24443} -CAMOUFLAGE_PORT=${XRAY_INTEROP_CAMOUFLAGE_PORT:-24444} -TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/vcore-xray.XXXXXX") -XRAY_PID= -CAMOUFLAGE_PID= - -if [ -n "${OPENSSL_BIN:-}" ]; then - OPENSSL=$OPENSSL_BIN -else - OPENSSL=openssl - for candidate in \ - openssl3 \ - /opt/homebrew/opt/openssl@3/bin/openssl \ - /usr/local/opt/openssl@3/bin/openssl - do - if command -v "$candidate" >/dev/null 2>&1 && \ - "$candidate" s_client -help 2>&1 | grep -Fq -- '-sigalgs' - then - OPENSSL=$candidate - break - fi - done -fi - -if ! command -v "$OPENSSL" >/dev/null 2>&1 || \ - ! "$OPENSSL" s_client -help 2>&1 | grep -Fq -- '-sigalgs' -then - echo "OpenSSL with s_client -sigalgs support is required; set OPENSSL_BIN" >&2 - exit 2 -fi - -cleanup() { - if [ -n "$XRAY_PID" ]; then - kill "$XRAY_PID" 2>/dev/null || true - wait "$XRAY_PID" 2>/dev/null || true - fi - if [ -n "$CAMOUFLAGE_PID" ]; then - kill "$CAMOUFLAGE_PID" 2>/dev/null || true - wait "$CAMOUFLAGE_PID" 2>/dev/null || true - fi - rm -rf "$TMP_DIR" -} -trap cleanup EXIT INT TERM - -if ! command -v "$XRAY_BIN" >/dev/null 2>&1; then - echo "Xray is required; set XRAY_BIN to its executable" >&2 - exit 2 -fi -"$XRAY_BIN" version >"$TMP_DIR/xray-version.txt" -sed -n '1,2p' "$TMP_DIR/xray-version.txt" -"$OPENSSL" version - -"$OPENSSL" req -x509 -newkey rsa:2048 -sha256 -nodes -days 1 \ - -subj '/CN=VCore Interop CA' \ - -addext 'basicConstraints=critical,CA:TRUE' \ - -addext 'keyUsage=critical,keyCertSign,cRLSign' \ - -keyout "$TMP_DIR/ca.key" -out "$TMP_DIR/ca.pem" >/dev/null 2>&1 -"$OPENSSL" req -newkey rsa:2048 -sha256 -nodes \ - -subj '/CN=vcore.test' \ - -keyout "$TMP_DIR/server.key" -out "$TMP_DIR/server.csr" >/dev/null 2>&1 -printf '%s\n' \ - 'basicConstraints=CA:FALSE' \ - 'keyUsage=digitalSignature,keyEncipherment' \ - 'extendedKeyUsage=serverAuth' \ - 'subjectAltName=DNS:vcore.test' >"$TMP_DIR/server.ext" -"$OPENSSL" x509 -req -in "$TMP_DIR/server.csr" \ - -CA "$TMP_DIR/ca.pem" -CAkey "$TMP_DIR/ca.key" -CAcreateserial \ - -days 1 -sha256 -extfile "$TMP_DIR/server.ext" \ - -out "$TMP_DIR/server.pem" >/dev/null 2>&1 -"$OPENSSL" x509 -in "$TMP_DIR/ca.pem" -outform DER -out "$TMP_DIR/ca.der" - -# REALITY forwards the client's original ClientHello to its camouflage target. -# Use an ECDSA-only target so an ED25519-only signature_algorithms extension is -# rejected before ServerHello, while a browser-compatible list can proceed. -"$OPENSSL" ecparam -name prime256v1 -genkey -noout \ - -out "$TMP_DIR/camouflage.key" -"$OPENSSL" req -x509 -new -key "$TMP_DIR/camouflage.key" -sha256 -days 1 \ - -subj '/CN=www.cloudflare.com' \ - -addext 'subjectAltName=DNS:www.cloudflare.com' \ - -addext 'extendedKeyUsage=serverAuth' \ - -out "$TMP_DIR/camouflage.pem" >/dev/null 2>&1 -"$OPENSSL" s_server \ - -accept "127.0.0.1:$CAMOUFLAGE_PORT" \ - -cert "$TMP_DIR/camouflage.pem" \ - -key "$TMP_DIR/camouflage.key" \ - -tls1_3 \ - -alpn h2,http/1.1 \ - -quiet \ - >"$TMP_DIR/camouflage.log" 2>&1 & -CAMOUFLAGE_PID=$! -sleep 1 -if ! kill -0 "$CAMOUFLAGE_PID" 2>/dev/null; then - cat "$TMP_DIR/camouflage.log" - exit 1 -fi - -if "$OPENSSL" s_client \ - -connect "127.0.0.1:$CAMOUFLAGE_PORT" \ - -servername www.cloudflare.com \ - -tls1_3 \ - -sigalgs ed25519 \ - -alpn h2 \ - -msg \ - "$TMP_DIR/ed25519-only-client.log" 2>&1 -then - echo "ECDSA camouflage unexpectedly accepted an ED25519-only ClientHello" >&2 - exit 1 -fi -if ! grep -Fq 'ClientHello' "$TMP_DIR/ed25519-only-client.log" || \ - grep -Fq 'ServerHello' "$TMP_DIR/ed25519-only-client.log" || \ - ! grep -Eq 'fatal handshake_failure|alert handshake failure' \ - "$TMP_DIR/ed25519-only-client.log" -then - echo "ECDSA camouflage preflight did not fail before ServerHello as expected" >&2 - cat "$TMP_DIR/ed25519-only-client.log" - exit 1 -fi -printf 'ECDSA camouflage preflight: ED25519-only ClientHello rejected before ServerHello\n' - -for SECURITY in reality tls; do - for CASE in packet-up stream-one split; do - CLIENT_MODE=$CASE - SERVER_MODE=$CASE - SPLIT=0 - if [ "$CASE" = split ]; then - CLIENT_MODE=auto - SPLIT=1 - if [ "$SECURITY" = reality ]; then - SERVER_MODE=stream-up - else - SERVER_MODE=packet-up - fi - fi - printf 'Xray interop: %s + %s (server %s, split %s)\n' \ - "$SECURITY" "$CLIENT_MODE" "$SERVER_MODE" "$SPLIT" - sed \ - -e "s|{{PORT}}|$PORT|g" \ - -e "s|{{CERT_FILE}}|$TMP_DIR/server.pem|g" \ - -e "s|{{KEY_FILE}}|$TMP_DIR/server.key|g" \ - -e "s|www.cloudflare.com:443|127.0.0.1:$CAMOUFLAGE_PORT|g" \ - -e "s/\"mode\": \"packet-up\"/\"mode\": \"$SERVER_MODE\"/g" \ - "$SCRIPT_DIR/fixtures/xray-$SECURITY-xhttp.json" >"$TMP_DIR/xray.json" - - "$XRAY_BIN" run -c "$TMP_DIR/xray.json" \ - >"$TMP_DIR/xray-$SECURITY-$CASE.log" 2>&1 & - XRAY_PID=$! - sleep 6 - if ! kill -0 "$XRAY_PID" 2>/dev/null; then - cat "$TMP_DIR/xray-$SECURITY-$CASE.log" - exit 1 - fi - - if ! ( - cd "$CRATE_DIR" - XRAY_INTEROP_ADDRESS="127.0.0.1:$PORT" \ - XRAY_INTEROP_MODE="$CLIENT_MODE" \ - XRAY_INTEROP_SPLIT="$SPLIT" \ - XRAY_INTEROP_SECURITY="$SECURITY" \ - XRAY_INTEROP_CA_DER="$TMP_DIR/ca.der" \ - cargo test --features interop-test,ffi --test xray_interop -- --ignored --nocapture - ); then - cat "$TMP_DIR/xray-$SECURITY-$CASE.log" - exit 1 - fi - - kill "$XRAY_PID" 2>/dev/null || true - wait "$XRAY_PID" 2>/dev/null || true - XRAY_PID= - done -done diff --git a/tests/security_capabilities.rs b/tests/security_capabilities.rs new file mode 100644 index 0000000..42a8bf5 --- /dev/null +++ b/tests/security_capabilities.rs @@ -0,0 +1,126 @@ +//! Public configuration must produce the required REALITY shares on both legs. +//! Pure memory IO; no protocol peer or host listener. +#![cfg(all(feature = "tls-fingerprint", feature = "outbound-vless"))] + +use tokio::io::AsyncReadExt; + +const X25519: u16 = 29; +const X25519_MLKEM768: u16 = 4588; +const HELLO_LIMIT: usize = 65_536; + +fn word(bytes: &[u8], at: usize) -> usize { + u16::from_be_bytes(bytes[at..at + 2].try_into().unwrap()).into() +} + +fn key_shares(records: &[u8]) -> Vec<(u16, usize)> { + let mut hello = Vec::new(); + let mut at = 0; + while at < records.len() { + assert_eq!(records[at], 22, "expected handshake record"); + let size = word(records, at + 3); + assert!(size <= 16_384); + hello.extend_from_slice(&records[at + 5..at + 5 + size]); + at += 5 + size; + } + assert_eq!(hello[0], 1, "expected ClientHello"); + assert_eq!( + hello.len(), + 4 + u32::from_be_bytes([0, hello[1], hello[2], hello[3]]) as usize + ); + let mut at = 39 + usize::from(hello[38]); + at += 2 + word(&hello, at); + at += 1 + usize::from(hello[at]); + let end = at + 2 + word(&hello, at); + assert_eq!(end, hello.len()); + at += 2; + let mut shares = None; + while at < end { + let kind = word(&hello, at); + let next = at + 4 + word(&hello, at + 2); + assert!(next <= end); + if kind == 51 { + assert!(shares.is_none(), "duplicate key_share extension"); + assert_eq!(at + 6 + word(&hello, at + 4), next); + let mut item = at + 6; + let mut found = Vec::new(); + while item < next { + let group = word(&hello, item) as u16; + let size = word(&hello, item + 2); + item += 4 + size; + assert!(item <= next); + // GREASE is not a negotiated cryptographic group. + if group & 0x0f0f != 0x0a0a { + found.push((group, size)); + } + } + shares = Some(found); + } + at = next; + } + shares.expect("missing key_share extension") +} + +#[tokio::test] +async fn public_hybrid_reality_emits_required_shares_on_both_transport_legs() { + use serde_json::json; + use vcore::{ + config::{Config, ProxyProtocol}, + security::SecurityClient, + }; + + for profile in ["none", "chrome"] { + let yaml = serde_json::to_vec(&json!({ + "socks-port": 1080, + "proxies": [{ + "name": "edge", "type": "vless", "server": "example.invalid", "port": 443, + "uuid": "07070707-0707-0707-0707-070707070707", "tls": true, + "client-fingerprint": profile, + "reality-opts": { + "public-key": "3p7bfXt9wbTTW2HC7OQ1Nz-DQ8hbeGdNrfx-FG-IK08", + "support-x25519mlkem768": true + }, + "network": "xhttp", + "xhttp-opts": {"mode": "stream-up", "download-settings": {}} + }], + "rules": ["MATCH,edge"] + })) + .unwrap(); + let config = Config::parse_yaml(&yaml).unwrap(); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + panic!() + }; + for security in [&node.security, &node.download().unwrap().security] { + let client = SecurityClient::from_security(security).unwrap(); + let (io, mut peer) = tokio::io::duplex(HELLO_LIMIT); + let handshake = tokio::spawn(async move { client.connect(Box::new(io)).await }); + let mut header = [0u8; 5]; + tokio::time::timeout( + std::time::Duration::from_secs(2), + peer.read_exact(&mut header), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(header[0], 22); + let size = word(&header, 3); + assert!(size <= 16_384); + let mut wire = header.to_vec(); + wire.resize(5 + size, 0); + peer.read_exact(&mut wire[5..]).await.unwrap(); + let shares = key_shares(&wire); + handshake.abort(); + assert!(matches!(handshake.await, Err(error) if error.is_cancelled())); + let expected = if profile == "chrome" { + vec![(X25519_MLKEM768, 1216), (X25519, 32)] + } else { + vec![(X25519_MLKEM768, 1216)] + }; + assert_eq!(shares, expected, "public {profile} REALITY"); + assert_eq!( + peer.read(&mut header).await.unwrap(), + 0, + "cancel releases supplied IO" + ); + } + } +} diff --git a/tests/shadowsocks_backpressure.rs b/tests/shadowsocks_backpressure.rs new file mode 100644 index 0000000..476074a --- /dev/null +++ b/tests/shadowsocks_backpressure.rs @@ -0,0 +1,273 @@ +#![cfg(feature = "outbound-shadowsocks")] +//! Connector contract with bounded in-memory IO, not a host protocol server. +use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use shadowsocks::{ + config::ServerType, + context::Context, + relay::tcprelay::{ + crypto_io::{CryptoRead, CryptoStream, CryptoWrite, StreamType}, + proxy_stream::{ProxyServerStream, protocol::TcpRequestHeader}, + }, +}; +use std::{ + future::poll_fn, + pin::Pin, + sync::{Arc, Mutex}, + task::Poll, + time::Duration, +}; +use tokio::io::{AsyncReadExt, AsyncWrite, AsyncWriteExt, BufWriter}; +use vcore::{ + config::{ShadowsocksCipher, ShadowsocksOutboundConfig}, + dispatch::{BoxStream, DatagramTransport, DispatchError}, + outbound::{ + ConnectedStream, DatagramRequest, EstablishContext, OutboundConnector, ShadowsocksOutbound, + UpstreamPath, + }, + session::{Destination, InboundKind, StreamSession}, +}; + +struct MemoryUpstream(Mutex>); +#[async_trait] +impl OutboundConnector for MemoryUpstream { + async fn connect_stream( + &self, + session: StreamSession, + _: &EstablishContext, + ) -> Result { + Ok(ConnectedStream { + io: self.0.lock().unwrap().take().unwrap(), + effective_peer: session.destination, + }) + } + async fn open_datagram( + &self, + _: DatagramRequest, + _: &EstablishContext, + ) -> Result, DispatchError> { + Err(DispatchError::NotAllowed) + } +} + +#[tokio::test] +async fn growing_caller_buffer_after_backpressure_never_loses_plaintext() { + #[cfg(feature = "interop-test")] + let _case = + vcore::resources::case_events::Case::new("INTEGRATION-ADAPTER", "growing_caller_buffer"); + for (cipher, initial) in [ + ShadowsocksCipher::Aes128Gcm, + ShadowsocksCipher::Aes256Gcm, + ShadowsocksCipher::Chacha20Poly1305, + ] + .into_iter() + .flat_map(|cipher| [4096, 32768].map(|size| (cipher, size))) + { + let key = vec![7; cipher.key_len()]; + let config = ShadowsocksOutboundConfig { + address: "fixture.invalid".into(), + port: 443, + cipher, + password: STANDARD.encode(&key), + }; + let (client, peer) = tokio::io::duplex(4096); + let outbound = ShadowsocksOutbound::new_with_path( + &config, + UpstreamPath::proxy(Arc::new(MemoryUpstream(Mutex::new(Some(Box::new(client)))))), + ) + .unwrap(); + let target = Destination::domain("target.invalid", 80).unwrap(); + let session = StreamSession { + inbound: InboundKind::Socks5, + source: "127.0.0.1:1234".parse().unwrap(), + destination: target, + sniffed_domain: None, + }; + let mut stream = outbound + .connect_stream(session, &EstablishContext::default()) + .await + .unwrap() + .io; + let mut payload = vec![0x5a; initial * 2]; + payload[initial..].fill(0xa5); + // Tokio copy tops up its caller buffer after a pending write. Exercise + // that public AsyncWrite pattern rather than assuming a fixed retry length. + let first = + poll_fn(|cx| Poll::Ready(Pin::new(&mut stream).poll_write(cx, &payload[..initial]))) + .await; + let accepted = match first { + Poll::Ready(result) => result.unwrap(), + Poll::Pending => 0, + }; + assert_eq!( + accepted, + initial.min(vcore::limits::SHADOWSOCKS_WRITE_CHUNK) + ); + let mut decoder = ProxyServerStream::from_stream( + Context::new_shared(ServerType::Server), + peer, + cipher.as_str().parse().unwrap(), + &key, + ); + let received = tokio::time::timeout(Duration::from_secs(2), async { + let ((), decoded) = tokio::join!( + async { + stream.write_all(&payload[accepted..]).await.unwrap(); + stream.shutdown().await.unwrap(); + }, + async { + decoder.handshake().await.unwrap(); + let mut decoded = Vec::new(); + decoder.read_to_end(&mut decoded).await.unwrap(); + decoded + } + ); + decoded + }) + .await + .unwrap(); + assert_eq!( + received.len(), + payload.len(), + "{cipher:?}: acknowledged bytes were lost" + ); + assert_eq!(received, payload); + } +} + +#[tokio::test] +async fn read_first_flushes_the_official_header_over_a_buffered_upstream() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "INTEGRATION-ADAPTER", + "server_first_buffered_upstream", + ); + for cipher in [ + ShadowsocksCipher::Aes128Gcm, + ShadowsocksCipher::Aes256Gcm, + ShadowsocksCipher::Chacha20Poly1305, + ] { + let key = vec![7; cipher.key_len()]; + let config = ShadowsocksOutboundConfig { + address: "fixture.invalid".into(), + port: 443, + cipher, + password: STANDARD.encode(&key), + }; + let (client, peer) = tokio::io::duplex(4096); + // Buffered carriers may acknowledge the complete SS header before + // delivering it. A read-first application has no payload to flush it. + let outbound = ShadowsocksOutbound::new_with_path( + &config, + UpstreamPath::proxy(Arc::new(MemoryUpstream(Mutex::new(Some(Box::new( + BufWriter::new(client), + )))))), + ) + .unwrap(); + let mut stream = outbound + .connect_stream( + StreamSession { + inbound: InboundKind::Socks5, + source: "127.0.0.1:1234".parse().unwrap(), + destination: Destination::domain("target.invalid", 80).unwrap(), + sniffed_domain: None, + }, + &EstablishContext::default(), + ) + .await + .unwrap() + .io; + let context = Context::new_shared(ServerType::Server); + let mut decoder = CryptoStream::from_stream( + &context, + peer, + StreamType::Server, + cipher.as_str().parse().unwrap(), + &key, + ); + tokio::time::timeout(Duration::from_secs(2), async { + tokio::join!( + async { + let mut greeting = [0; 5]; + stream.read_exact(&mut greeting).await.unwrap(); + assert_eq!(&greeting, b"hello"); + }, + async { + // Exercise only the adapter's flush contract. The official + // client randomly chooses zero padding for an empty request, + // which its strict server rejects. Parse with its codec here; + // the deterministic test below separately locks that rejection. + let mut bytes = [0; 1024]; + let mut buffer = tokio::io::ReadBuf::new(&mut bytes); + poll_fn(|cx| { + Pin::new(&mut decoder).poll_read_decrypted(cx, &context, &mut buffer) + }) + .await + .unwrap(); + let mut plaintext = buffer.filled(); + let header = TcpRequestHeader::read_from( + cipher.as_str().parse().unwrap(), + &mut plaintext, + ) + .await + .unwrap(); + assert_eq!(header.addr(), ("target.invalid".to_owned(), 80).into()); + assert!(plaintext.is_empty()); + let request_nonce = decoder.received_nonce().unwrap().to_vec(); + decoder.set_request_nonce(&request_nonce); + poll_fn(|cx| Pin::new(&mut decoder).poll_write_encrypted(cx, b"hello")) + .await + .unwrap(); + poll_fn(|cx| decoder.poll_flush(cx)).await.unwrap(); + } + ); + }) + .await + .expect("server-first greeting must arrive without application writes"); + } +} + +#[tokio::test] +async fn official_server_rejects_zero_padding_without_payload_deterministically() { + for cipher in [ + ShadowsocksCipher::Aes128Gcm, + ShadowsocksCipher::Aes256Gcm, + ShadowsocksCipher::Chacha20Poly1305, + ] { + for padding_size in [0, 1] { + let method = cipher.as_str().parse().unwrap(); + let key = vec![7; cipher.key_len()]; + let context = Context::new_shared(ServerType::Local); + let (client, peer) = tokio::io::duplex(4096); + let mut writer = + CryptoStream::from_stream(&context, client, StreamType::Client, method, &key); + let target: shadowsocks::relay::socks5::Address = + ("target.invalid".to_owned(), 80).into(); + let mut header = Vec::new(); + target.write_to_buf(&mut header); + header.extend_from_slice(&u16::to_be_bytes(padding_size)); + header.resize(header.len() + usize::from(padding_size), 0); + poll_fn(|cx| Pin::new(&mut writer).poll_write_encrypted(cx, &header)) + .await + .unwrap(); + poll_fn(|cx| writer.poll_flush(cx)).await.unwrap(); + let mut decoder = ProxyServerStream::from_stream( + Context::new_shared(ServerType::Server), + peer, + method, + &key, + ); + let result = tokio::time::timeout(Duration::from_secs(2), decoder.handshake()) + .await + .unwrap(); + if padding_size == 0 { + assert_eq!( + result.unwrap_err().to_string(), + "no payload in first data chunk, and padding is 0" + ); + } else { + assert_eq!(result.unwrap(), ("target.invalid".to_owned(), 80).into()); + } + } + } +} diff --git a/tests/sing_mux.rs b/tests/sing_mux.rs new file mode 100644 index 0000000..eb56f4e --- /dev/null +++ b/tests/sing_mux.rs @@ -0,0 +1,538 @@ +#![cfg(feature = "outbound-vless")] +use async_trait::async_trait; +use bytes::Bytes; +use std::{ + io, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use vcore::{ + config::{Config, ProxyProtocol}, + dispatch::{BoxStream, DatagramTransport, DispatchError}, + outbound::{ + ConnectedStream, DatagramRequest, EstablishContext, OutboundConnector, VlessOutbound, + }, + session::{Destination, InboundKind, StreamSession}, +}; + +struct MemoryPeer { + count: AtomicUsize, + tasks: Mutex>>, + udp: bool, + protocol: u8, +} +#[async_trait] +impl OutboundConnector for MemoryPeer { + async fn connect_stream( + &self, + session: StreamSession, + _: &EstablishContext, + ) -> Result { + self.count.fetch_add(1, Ordering::SeqCst); + let (client, mut server) = tokio::io::duplex(65536); + let udp = self.udp; + let protocol = self.protocol; + self.tasks.lock().unwrap().push(tokio::spawn(async move { + let mut header = [0; 23]; + server.read_exact(&mut header).await.unwrap(); + assert_eq!( + header[18], 1, + "sing-mux is a normal VLESS TCP destination, not XUDP" + ); + assert_eq!(&header[19..23], &[1, 188, 2, 20]); + let mut target = [0; 20]; + server.read_exact(&mut target).await.unwrap(); + assert_eq!(&target, b"sp.mux.sing-box.arpa"); + let mut mux = [0; 2]; + server.read_exact(&mut mux).await.unwrap(); + assert_eq!(mux, [0, protocol]); + server.write_all(&[0, 0]).await.unwrap(); + if protocol == 0 { + let mut opened = std::collections::HashSet::new(); + loop { + let mut head = [0; 8]; + if server.read_exact(&mut head).await.is_err() { + break; + } + assert_eq!(head[0], 1); + let len = u16::from_le_bytes(head[2..4].try_into().unwrap()) as usize; + let id = u32::from_le_bytes(head[4..].try_into().unwrap()); + assert!(id >= 3 && id % 2 == 1); + let mut data = vec![0; len]; + server.read_exact(&mut data).await.unwrap(); + match head[1] { + 0 => { + assert_eq!(len, 0); + } + 1 => { + assert_eq!(len, 0); + server.write_all(&head).await.unwrap(); + } + 2 => { + if opened.insert(id) { + assert_eq!(data, [0, 0, 1, 192, 0, 2, 1, 1, 187]); + data = b"\x00hello".to_vec(); + } + head[2..4].copy_from_slice(&(data.len() as u16).to_le_bytes()); + server.write_all(&head).await.unwrap(); + server.write_all(&data).await.unwrap(); + } + _ => panic!("unexpected smux control frame"), + } + server.flush().await.unwrap(); + } + return; + } + if protocol == 1 { + use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; + let mut connection = yamux::Connection::new( + server.compat(), + yamux::Config::default(), + yamux::Mode::Server, + ); + let mut tasks = tokio::task::JoinSet::new(); + while let Some(Ok(stream)) = + futures_util::future::poll_fn(|cx| connection.poll_next_inbound(cx)).await + { + tasks.spawn(async move { + let mut io = stream.compat(); + let mut request = [0; 9]; + io.read_exact(&mut request).await.unwrap(); + assert_eq!(request, [0, 0, 1, 192, 0, 2, 1, 1, 187]); + io.write_all(b"\x00hello").await.unwrap(); + io.flush().await.unwrap(); + let mut scratch = [0; 1024]; + while let Ok(n) = io.read(&mut scratch).await { + if n == 0 { + break; + } + io.write_all(&scratch[..n]).await.unwrap(); + io.flush().await.unwrap(); + } + }); + } + while let Some(result) = tasks.join_next().await { + result.unwrap(); + } + return; + } + let mut connection = h2::server::handshake(server).await.unwrap(); + let mut streams = tokio::task::JoinSet::new(); + while let Some(Ok((request, mut response))) = connection.accept().await { + assert_eq!(request.method(), "CONNECT"); + streams.spawn(async move { + let mut input = request.into_body(); + let first = input.data().await.unwrap().unwrap(); + if udp { + assert_eq!(&first[..2], &[0, 3]); + } else { + assert_eq!(first.as_ref(), &[0, 0, 1, 192, 0, 2, 1, 1, 187]); + } + input.flow_control().release_capacity(first.len()).unwrap(); + let mut output = response + .send_response(http::Response::new(()), false) + .unwrap(); + output + .send_data( + Bytes::from_static(if udp { b"\x00" } else { b"\x00hello" }), + false, + ) + .unwrap(); + while let Some(Ok(data)) = input.data().await { + input.flow_control().release_capacity(data.len()).unwrap(); + output.send_data(data, false).unwrap(); + } + }); + } + while let Some(result) = streams.join_next().await { + result.unwrap(); + } + })); + Ok(ConnectedStream { + io: Box::new(client), + effective_peer: session.destination, + }) + } + async fn open_datagram( + &self, + _: DatagramRequest, + _: &EstablishContext, + ) -> Result, DispatchError> { + Err(DispatchError::NotAllowed) + } +} +fn outbound(peer: Arc, options: &str) -> VlessOutbound { + let raw = format!( + "socks-port: 1080\nproxies:\n- name: edge\n type: vless\n server: example.com\n port: 443\n uuid: 07070707-0707-0707-0707-070707070707\n smux: {{enabled: true, {options}}}\nrules: [MATCH,edge]\n" + ); + let raw = raw.replace("rules: [MATCH,edge]", "rules: ['MATCH,edge']"); + let config = Config::parse_yaml(raw.as_bytes()).unwrap(); + let ProxyProtocol::Vless(config) = &config.proxies[0].protocol else { + unreachable!() + }; + VlessOutbound::new_with_upstream(config, peer).unwrap() +} +async fn open(node: &VlessOutbound) -> BoxStream { + node.connect_stream( + StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:1".parse().unwrap(), + destination: Destination::Ip("192.0.2.1:443".parse().unwrap()), + sniffed_domain: None, + }, + &EstablishContext::default(), + ) + .await + .unwrap() + .io +} +async fn hello(io: &mut BoxStream) { + let mut greeting = [0; 5]; + io.read_exact(&mut greeting).await.unwrap(); + assert_eq!(&greeting, b"hello"); +} +#[tokio::test] +async fn yamux_replacing_dropped_streams_at_capacity_keeps_the_sibling_alive() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "yamux_replacing_dropped_streams_at_capacity_keeps_the_sibling_alive", + ); + tokio::time::timeout(Duration::from_secs(10), async { + let peer = Arc::new(MemoryPeer { + count: AtomicUsize::new(0), + tasks: Mutex::new(vec![]), + udp: false, + protocol: 1, + }); + let node = outbound(peer.clone(), "protocol: yamux, max-connections: 1"); + let mut streams = Vec::new(); + for _ in 0..64 { + let mut stream = open(&node).await; + hello(&mut stream).await; + streams.push(stream); + } + let mut sibling = streams.pop().unwrap(); + drop(streams); + let mut replacement = open(&node).await; + hello(&mut replacement).await; + sibling.write_all(b"live").await.unwrap(); + sibling.flush().await.unwrap(); + let mut response = [0; 4]; + sibling.read_exact(&mut response).await.unwrap(); + assert_eq!(&response, b"live"); + drop((sibling, replacement)); + node.shutdown().await; + let tasks = std::mem::take(&mut *peer.tasks.lock().unwrap()); + for task in tasks { + task.await.unwrap(); + } + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn sing_mux_scheduling_uses_the_selected_branch_not_a_global_stream_quota() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "sing_mux_scheduling_uses_the_selected_branch_not_a_global_stream_quota", + ); + tokio::time::timeout(Duration::from_secs(5), async { + for (protocol, wire) in [("h2mux", 2), ("smux", 0), ("yamux", 1)] { + for (settings, counts) in [ + ("min-streams: 8", [1, 2, 3, 4]), + ("max-connections: 1", [1, 1, 1, 1]), + ("max-connections: 2, min-streams: 2", [1, 1, 2, 2]), + ("max-streams: 2", [1, 1, 2, 2]), + ] { + let peer = Arc::new(MemoryPeer { + count: AtomicUsize::new(0), + tasks: Mutex::new(vec![]), + udp: false, + protocol: wire, + }); + let node = outbound(peer.clone(), &format!("protocol: {protocol}, {settings}")); + let mut streams = Vec::new(); + for count in counts { + let mut stream = open(&node).await; + hello(&mut stream).await; + assert_eq!( + peer.count.load(Ordering::SeqCst), + count, + "{protocol}, {settings}" + ); + streams.push(stream); + } + for mut stream in streams { + stream.write_all(b"still alive").await.unwrap(); + stream.flush().await.unwrap(); + let mut response = [0; 11]; + stream.read_exact(&mut response).await.unwrap(); + assert_eq!(&response, b"still alive"); + stream.shutdown().await.unwrap(); + } + node.shutdown().await; + let tasks = std::mem::take(&mut *peer.tasks.lock().unwrap()); + for task in tasks { + task.await.unwrap(); + } + } + } + }) + .await + .unwrap(); +} + +struct CapturePeer(Mutex>); +#[async_trait] +impl OutboundConnector for CapturePeer { + async fn connect_stream( + &self, + session: StreamSession, + _: &EstablishContext, + ) -> Result { + Ok(ConnectedStream { + io: Box::new(self.0.lock().unwrap().take().unwrap()), + effective_peer: session.destination, + }) + } + async fn open_datagram( + &self, + _: DatagramRequest, + _: &EstablishContext, + ) -> Result, DispatchError> { + Err(DispatchError::NotAllowed) + } +} + +#[tokio::test] +async fn h2mux_sends_idle_ping_and_retires_a_peer_that_never_acknowledges() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "h2mux_sends_idle_ping_and_retires_a_peer_that_never_acknowledges", + ); + let raw = serde_json::json!({"socks-port":1080,"proxies":[{"name":"edge","type":"vless","server":"example.com","port":443,"uuid":"07070707-0707-0707-0707-070707070707","smux":{"enabled":true,"max-connections":1}}],"rules":["MATCH,edge"]}); + let config = Config::parse_yaml(raw.to_string().as_bytes()).unwrap(); + let ProxyProtocol::Vless(config) = &config.proxies[0].protocol else { + unreachable!() + }; + let (io, mut peer) = tokio::io::duplex(4096); + let node = + VlessOutbound::new_with_upstream(config, Arc::new(CapturePeer(Mutex::new(Some(io))))) + .unwrap(); + let (ping, mut received) = tokio::sync::oneshot::channel(); + let peer = tokio::spawn(async move { + let mut preface = [0; 45]; + peer.read_exact(&mut preface).await.unwrap(); + assert_eq!(&preface[43..], &[0, 2]); + peer.write_all(b"\0\0\0\0\0\x04\0\0\0\0\0").await.unwrap(); + let mut preface = [0; 24]; + peer.read_exact(&mut preface).await.unwrap(); + assert_eq!(&preface, b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"); + let mut ping = Some(ping); + loop { + let mut header = [0; 9]; + if peer.read_exact(&mut header).await.is_err() { + break; + } + let length = u32::from_be_bytes([0, header[0], header[1], header[2]]) as usize; + assert!(length <= 16384); + let mut payload = vec![0; length]; + peer.read_exact(&mut payload).await.unwrap(); + if header[3] == 6 && header[4] == 0 { + assert_eq!(length, 8); + ping.take().unwrap().send(()).unwrap(); + // Deliberately never ACK. The owned driver must fail closed. + } + } + }); + let held = open(&node).await; + tokio::time::pause(); + tokio::time::advance(Duration::from_secs(29)).await; + for _ in 0..32 { + tokio::task::yield_now().await; + } + assert!(matches!( + received.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + tokio::time::advance(Duration::from_secs(2)).await; + tokio::time::timeout(Duration::from_secs(1), received) + .await + .expect("missing sing-mux idle PING") + .unwrap(); + tokio::time::advance(Duration::from_secs(16)).await; + tokio::time::timeout(Duration::from_secs(1), peer) + .await + .expect("unacknowledged PING retained physical IO") + .unwrap(); + drop(held); + node.shutdown().await; +} + +#[tokio::test] +async fn only_tcp_preserves_all_three_vless_udp_wire_commands() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "only_tcp_preserves_all_three_vless_udp_wire_commands", + ); + use vcore::session::{Datagram, DatagramSession}; + tokio::time::timeout(Duration::from_secs(3), async { + for protocol in ["h2mux", "smux", "yamux"] { + for (codec, command) in [("xudp", 3), ("none", 2), ("packetaddr", 2)] { + let raw = serde_json::json!({"socks-port":1080,"proxies":[{"name":"edge","type":"vless","server":"example.com","port":443,"uuid":"07070707-0707-0707-0707-070707070707","udp":true,"packet-encoding":codec,"smux":{"enabled":true,"protocol":protocol,"padding":true,"only-tcp":true}}],"rules":["MATCH,edge"]}); + let config = Config::parse_yaml(raw.to_string().as_bytes()).unwrap(); + let ProxyProtocol::Vless(config) = &config.proxies[0].protocol else { unreachable!() }; + let (io, mut peer) = tokio::io::duplex(4096); + let node = VlessOutbound::new_with_upstream(config, Arc::new(CapturePeer(Mutex::new(Some(io))))).unwrap(); + let mut association = node.open_datagram( + DatagramRequest::new(DatagramSession::new(InboundKind::InternalMeasure, "127.0.0.1:1".parse().unwrap())), + &EstablishContext::default(), + ).await.unwrap(); + association.send(Datagram { remote: Destination::Ip("192.0.2.1:443".parse().unwrap()), payload: Bytes::from_static(b"udp"), sniffed_domain: None }).await.unwrap(); + let mut header = [0; 19]; + peer.read_exact(&mut header).await.unwrap(); + assert_eq!(header[18], command, "{protocol} / {codec} must not become sing-mux TCP"); + association.close().await.unwrap(); + drop(association); + node.shutdown().await; + let mut body = Vec::new(); + peer.read_to_end(&mut body).await.unwrap(); + assert!(!body.windows(20).any(|part| part == b"sp.mux.sing-box.arpa")); + assert!(body.windows(3).any(|part| part == b"udp")); + } + } + }).await.unwrap(); +} +#[tokio::test] +async fn h2mux_reuses_physical_vless_and_cancels_only_one_logical_stream() -> io::Result<()> { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "h2mux_reuses_physical_vless_and_cancels_only_one_logical_stream", + ); + siblings("h2mux", 2).await +} +#[tokio::test] +async fn yamux_reuses_physical_vless_and_cancels_only_one_logical_stream() -> io::Result<()> { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "yamux_reuses_physical_vless_and_cancels_only_one_logical_stream", + ); + siblings("yamux", 1).await +} +#[tokio::test] +async fn smux_reuses_physical_vless_and_cancels_only_one_logical_stream() -> io::Result<()> { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "smux_reuses_physical_vless_and_cancels_only_one_logical_stream", + ); + siblings("smux", 0).await +} +async fn siblings(protocol: &str, wire: u8) -> io::Result<()> { + tokio::time::timeout(Duration::from_secs(5), async { + let peer = Arc::new(MemoryPeer { + count: AtomicUsize::new(0), + tasks: Mutex::new(vec![]), + udp: false, + protocol: wire, + }); + let node = outbound( + peer.clone(), + &format!("max-connections: 1, protocol: {protocol}"), + ); + let mut first = open(&node).await; + hello(&mut first).await; + let mut second = open(&node).await; + hello(&mut second).await; + assert_eq!(peer.count.load(Ordering::SeqCst), 1); + first.shutdown().await.unwrap(); + drop(first); + second.write_all(b"sibling alive").await.unwrap(); + second.flush().await.unwrap(); + let mut response = [0; 13]; + second.read_exact(&mut response).await.unwrap(); + assert_eq!(&response, b"sibling alive"); + drop(second); + node.shutdown().await; + let tasks = std::mem::take(&mut *peer.tasks.lock().unwrap()); + for task in tasks { + task.await.unwrap(); + } + }) + .await + .map_err(io::Error::other) +} + +#[tokio::test] +async fn sing_mux_udp_preserves_addresses_and_cancellation_safe_partial_frames() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "sing_mux_udp_preserves_addresses_and_cancellation_safe_partial_frames", + ); + use vcore::{ + dispatch::DatagramBudget, + session::{Datagram, DatagramSession}, + }; + tokio::time::timeout(Duration::from_secs(5), async { + let peer = Arc::new(MemoryPeer { + count: AtomicUsize::new(0), + tasks: Mutex::new(vec![]), + udp: true, + protocol: 2, + }); + let node = outbound(peer.clone(), "max-connections: 1"); + let mut io = node + .open_datagram( + DatagramRequest::new(DatagramSession::new( + InboundKind::InternalMeasure, + "127.0.0.1:1".parse().unwrap(), + )) + .with_budget(DatagramBudget::new(15000, 15000)), + &EstablishContext::default(), + ) + .await + .unwrap(); + for remote in [ + "192.0.2.1:1234".parse().map(Destination::Ip).unwrap(), + "[2001:db8::1]:1234".parse().map(Destination::Ip).unwrap(), + ] { + assert!( + tokio::time::timeout(Duration::from_millis(1), io.receive()) + .await + .is_err() + ); + let payload = Bytes::from(vec![0x5a; 15000]); + io.send(Datagram { + remote: remote.clone(), + payload: payload.clone(), + sniffed_domain: None, + }) + .await + .unwrap(); + let received = io.receive().await.unwrap(); + assert_eq!(received.remote, remote); + assert_eq!(received.payload, payload); + } + io.close().await.unwrap(); + drop(io); + node.shutdown().await; + let tasks = std::mem::take(&mut *peer.tasks.lock().unwrap()); + for task in tasks { + task.await.unwrap(); + } + }) + .await + .unwrap(); +} diff --git a/tests/sing_mux_config.rs b/tests/sing_mux_config.rs new file mode 100644 index 0000000..4a798a3 --- /dev/null +++ b/tests/sing_mux_config.rs @@ -0,0 +1,72 @@ +#![cfg(feature = "outbound-vless")] +use serde_json::json; +use vcore::config::Config; + +fn parse(options: serde_json::Value, vision: bool) -> bool { + Config::parse_yaml( + &serde_json::to_vec(&json!({ + "socks-port":1080, + "proxies":[{"name":"edge","type":"vless","server":"example.com","port":443, + "uuid":"07070707-0707-0707-0707-070707070707","tls":true, + "flow":if vision {"xtls-rprx-vision"} else {""},"smux":options}], + "rules":["MATCH,edge"] + })) + .unwrap(), + ) + .is_ok() +} + +#[test] +fn sing_mux_accepts_three_protocols_and_rejects_invalid_or_ignored_options() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "sing_mux_accepts_three_protocols_and_rejects_invalid_or_ignored_options", + ); + for protocol in ["h2mux", "smux", "yamux"] { + for enabled in [false, true] { + assert!(parse( + json!({"enabled":enabled,"protocol":protocol,"padding":true, + "only-tcp":true,"max-connections":2,"min-streams":3}), + false + )); + } + } + assert!(parse(json!({"enabled":true,"max-streams":3}), false)); + assert!(parse(json!({}), false)); + assert!(!parse(json!([]), false)); + for field in [ + "enabled", + "protocol", + "padding", + "only-tcp", + "max-connections", + "min-streams", + "max-streams", + ] { + for value in [json!(null), json!([]), json!(0.5)] { + assert!(!parse(json!({field:value}), false), "{field}"); + } + } + for field in ["max-connections", "min-streams", "max-streams"] { + for value in [0, 1, 2147483647_u64] { + assert!(parse(json!({field:value}), false)); + } + assert!(!parse(json!({field:2147483648_u64}), false)); + } + for options in [ + json!(null), + json!({"enabled":null}), + json!({"protocol":"mux"}), + json!({"protocol":""}), + json!({"padding":1}), + json!({"only-tcp":null}), + json!({"enabled":true,"max-connections":2,"max-streams":3}), + json!({"max-streams":-1}), + json!({"min-streams":2147483648_u64}), + json!({"enabled":true,"brutal":true}), + ] { + assert!(!parse(options, false)); + } + assert!(!parse(json!({"enabled":true}), true)); +} diff --git a/tests/stream_foundations.rs b/tests/stream_foundations.rs new file mode 100644 index 0000000..ef786c3 --- /dev/null +++ b/tests/stream_foundations.rs @@ -0,0 +1,1032 @@ +#![cfg(feature = "stream-transport")] + +#[test] +fn transport_options_reject_ambiguous_headers_before_io() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-STREAM", + "transport_options_reject_ambiguous_headers_before_io", + ); + use vcore::transport::{HttpObfsOptions, WebSocketEarlyData, WebSocketOptions}; + for name in ["host", "x-custom"] { + let mut headers = http::HeaderMap::new(); + headers.append(name, "example.com".parse().unwrap()); + headers.append(name, "other.example".parse().unwrap()); + assert!(WebSocketOptions::new("ws://example.com/", headers.clone(), None).is_err()); + assert!(HttpObfsOptions::new(http::Method::GET, "http://example.com/", headers).is_err()); + } + for maximum in [0, 2049] { + assert!( + WebSocketOptions::new( + "ws://example.com/", + Default::default(), + Some(WebSocketEarlyData::Path { max_bytes: maximum }) + ) + .is_err() + ); + } + for uri in [ + "ws://user:password@example.com/", + "http://example.com/", + "ws://example.com/path?query=1", + ] { + assert!( + WebSocketOptions::new( + uri, + Default::default(), + Some(WebSocketEarlyData::Path { max_bytes: 1 }) + ) + .is_err() + ); + } + for name in ["content-length", "transfer-encoding"] { + let mut headers = http::HeaderMap::new(); + headers.insert(name, "1".parse().unwrap()); + assert!(WebSocketOptions::new("ws://example.com/", headers.clone(), None).is_err()); + assert!(HttpObfsOptions::new(http::Method::GET, "http://example.com/", headers).is_err()); + } +} + +#[tokio::test] +async fn http_first_header_preserves_prefix_raw_continuation_and_half_close_tail() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-STREAM", + "http_first_header_preserves_prefix_raw_continuation_and_half_close_tail", + ); + timeout(Duration::from_secs(3), async { + let (client, mut peer) = tokio::io::duplex(128); + let peer = tokio::spawn(async move { + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + request.push(peer.read_u8().await.unwrap()); + } + let request = String::from_utf8(request).unwrap(); + assert!(request.starts_with("POST /cover?q=1 HTTP/1.1\r\n")); + assert!(request.contains("host: cover.example:8080\r\n")); + assert!(request.contains("content-length: 6\r\n")); + assert!(request.contains("x-custom: value\r\n")); + let mut prefix = [0; 6]; + peer.read_exact(&mut prefix).await.unwrap(); + assert_eq!(&prefix, b"prefix"); + peer.write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n\r\nhello", + ) + .await + .unwrap(); + let mut data = Vec::new(); + peer.read_to_end(&mut data).await.unwrap(); + assert_eq!(data, vec![42; 65536]); + peer.write_all(b"tail").await.unwrap(); + }); + let mut headers = http::HeaderMap::new(); + headers.insert("x-custom", "value".parse().unwrap()); + let options = vcore::transport::HttpObfsOptions::new( + http::Method::POST, + "http://cover.example:8080/cover?q=1", + headers, + ) + .unwrap(); + let mut stream = vcore::transport::http_obfs( + Box::new(client), + &options, + b"prefix", + Instant::now() + Duration::from_secs(2), + ) + .await + .unwrap(); + let mut hello = [0; 5]; + stream.read_exact(&mut hello).await.unwrap(); + assert_eq!(&hello, b"hello"); + stream.write_all(&vec![42; 65536]).await.unwrap(); + stream.shutdown().await.unwrap(); + let mut tail = Vec::new(); + stream.read_to_end(&mut tail).await.unwrap(); + assert_eq!(&tail, b"tail"); + peer.await.unwrap(); + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn legacy_h2_keeps_unframed_bytes_server_first_and_owned_whole_close() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-STREAM", + "legacy_h2_keeps_unframed_bytes_server_first_and_owned_whole_close", + ); + timeout(Duration::from_secs(3), async { + let (client, peer) = tokio::io::duplex(128); + let peer = tokio::spawn(async move { + let mut connection = h2::server::Builder::new() + .initial_window_size(128) + .handshake::<_, bytes::Bytes>(peer) + .await + .unwrap(); + let (request, mut reply) = connection.accept().await.unwrap().unwrap(); + assert_eq!(request.method(), "PUT"); + assert_eq!(request.uri().authority().unwrap().as_str(), "cover.example"); + assert_eq!(request.uri().path(), "/h2"); + assert_eq!(request.headers()["accept-encoding"], "identity"); + let mut receive = request.into_body(); + let mut send = reply.send_response(http::Response::new(()), false).unwrap(); + send.send_data(bytes::Bytes::from_static(b"hello"), false) + .unwrap(); + let work = tokio::spawn(async move { + let mut input = Vec::new(); + while input.len() < 65536 { + let data = receive.data().await.unwrap().unwrap(); + receive.flow_control().release_capacity(data.len()).unwrap(); + input.extend_from_slice(&data); + } + assert_eq!(input, vec![42; 65536]); + send.send_data(bytes::Bytes::from_static(b"done"), false) + .unwrap(); + assert!(receive.data().await.unwrap().is_err()); + }); + while connection.accept().await.is_some() {} + work.await.unwrap(); + }); + let (mut stream, owner) = vcore::transport::legacy_h2( + Box::new(client), + "https://cover.example/h2", + Instant::now() + Duration::from_secs(2), + ) + .await + .unwrap(); + let mut hello = [0; 5]; + stream.read_exact(&mut hello).await.unwrap(); + assert_eq!(&hello, b"hello"); + stream.write_all(&vec![42; 65536]).await.unwrap(); + stream.flush().await.unwrap(); + let mut done = [0; 4]; + stream.read_exact(&mut done).await.unwrap(); + assert_eq!(&done, b"done"); + stream.shutdown().await.unwrap(); + assert_eq!(stream.read(&mut [0]).await.unwrap(), 0); + assert_eq!( + stream.write(b"x").await.unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + owner.stop().await.unwrap(); + peer.await.unwrap(); + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn websocket_rejects_invalid_upgrade_responses_and_bounded_header_overflows() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-STREAM", + "websocket_rejects_invalid_upgrade_responses_and_bounded_header_overflows", + ); + for change in [ + "version", + "accept", + "duplicate", + "extension", + "subprotocol", + "size", + "count", + "truncated", + ] { + let (client, mut peer) = tokio::io::duplex(256); + let peer = tokio::spawn(async move { + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + request.push(peer.read_u8().await.unwrap()); + } + let request = String::from_utf8(request).unwrap(); + let key = request + .lines() + .find_map(|line| { + line.split_once(':') + .filter(|(name, _)| name.eq_ignore_ascii_case("sec-websocket-key")) + }) + .unwrap() + .1 + .trim(); + let accept = + tokio_tungstenite::tungstenite::handshake::derive_accept_key(key.as_bytes()); + let version = if change == "version" { + "HTTP/1.0" + } else { + "HTTP/1.1" + }; + let accept = if change == "accept" { + "invalid" + } else { + &accept + }; + let mut response = format!( + "{version} 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n" + ); + match change { + "duplicate" => response.push_str(&format!("Sec-WebSocket-Accept: {accept}\r\n")), + "extension" => { + response.push_str("Sec-WebSocket-Extensions: permessage-deflate\r\n") + } + "subprotocol" => response.push_str("Sec-WebSocket-Protocol: unsolicited\r\n"), + "size" => response.push_str(&format!("X-Padding: {}\r\n", "x".repeat(16384))), + "count" => response.push_str(&"X-Many: x\r\n".repeat(101)), + _ => {} + } + if change != "truncated" { + response.push_str("\r\n"); + } + let _ = peer.write_all(response.as_bytes()).await; + }); + let result = websocket( + Box::new(client), + "ws://example.com/", + Instant::now() + Duration::from_secs(1), + ) + .await; + assert!(result.is_err(), "accepted invalid {change}"); + peer.await.unwrap(); + } +} +#[tokio::test] +// The official handshake callback fixes its error type to Response>. +#[allow(clippy::result_large_err)] +async fn websocket_early_data_and_remaining_frames_keep_the_original_byte_order() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-STREAM", + "websocket_early_data_and_remaining_frames_keep_the_original_byte_order", + ); + use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; + use vcore::transport::{WebSocketEarlyData, WebSocketOptions, connect_websocket}; + for maximum in [1, 2048] { + timeout(Duration::from_secs(3), async { + let (client, peer) = tokio::io::duplex(128); + let input: Vec = (0..3000).map(|value| (value % 251) as u8).collect(); + let expected = input.clone(); + let peer = tokio::spawn(async move { + let observed = Arc::new(std::sync::Mutex::new(Vec::new())); + let header_bytes = observed.clone(); + let mut ws = tokio_tungstenite::accept_hdr_async( + peer, + move |request: &http::Request<()>, response: http::Response<()>| { + assert_eq!( + request.uri().path_and_query().unwrap().as_str(), + "/hello?mode=1" + ); + assert_eq!(request.headers()["host"], "cover.example:8443"); + *header_bytes.lock().unwrap() = URL_SAFE_NO_PAD + .decode(request.headers()["sec-websocket-protocol"].as_bytes()) + .unwrap(); + Ok(response) // Mihomo's ED is not an echoed WS subprotocol. + }, + ) + .await + .unwrap(); + let mut received = observed.lock().unwrap().clone(); + assert_eq!(received.len(), maximum); + while received.len() < 3000 { + if let Message::Binary(data) = ws.next().await.unwrap().unwrap() { + received.extend_from_slice(&data); + } + } + assert_eq!(received, expected); + ws.send(Message::Binary("ok".into())).await.unwrap(); + }); + let mut headers = http::HeaderMap::new(); + headers.insert("host", "cover.example:8443".parse().unwrap()); + let options = WebSocketOptions::new( + "ws://example.com/hello?mode=1", + headers, + Some(WebSocketEarlyData::Header { + name: http::HeaderName::from_static("sec-websocket-protocol"), + max_bytes: maximum, + }), + ) + .unwrap(); + let mut stream = connect_websocket( + Box::new(client), + &options, + &input, + Instant::now() + Duration::from_secs(2), + ) + .await + .unwrap(); + assert_eq!(stream.read_u16().await.unwrap(), 0x6f6b); + peer.await.unwrap(); + }) + .await + .unwrap(); + } +} +async fn stalled_handshake(kind: &str, raw: Observed, deadline: Instant) -> io::Result<()> { + match kind { + "ws" => { + websocket(Box::new(raw), "ws://fixture.invalid/stream", deadline).await?; + } + "grpc" => { + let (mut stream, owner) = grpc( + Box::new(raw), + "https://fixture.invalid/stream/Tun", + deadline, + ) + .await?; + let result = stream.read_u8().await; + drop(stream); + owner.stop().await?; + result?; + } + "h2" => { + let (mut stream, owner) = + vcore::transport::legacy_h2(Box::new(raw), "https://fixture.invalid/h2", deadline) + .await?; + let result = stream.read_u8().await; + drop(stream); + owner.stop().await?; + result?; + } + "http" => { + let options = vcore::transport::HttpObfsOptions::new( + http::Method::GET, + "http://fixture.invalid/", + Default::default(), + )?; + let mut stream = + vcore::transport::http_obfs(Box::new(raw), &options, &[], deadline).await?; + stream.read_u8().await?; + } + _ => unreachable!(), + } + Ok(()) +} + +#[tokio::test] +async fn setup_deadline_releases_supplied_io_in_stream_adapters() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-STREAM", + "setup_deadline_releases_supplied_io_in_stream_adapters", + ); + for kind in ["ws", "grpc", "h2", "http"] { + let (outgoing, mut incoming) = tokio::io::duplex(128); + let dropped = Arc::new(AtomicBool::new(false)); + let raw = Observed { + io: outgoing, + shutdown: Arc::new(AtomicBool::new(false)), + dropped: dropped.clone(), + }; + let peer = tokio::spawn(async move { + let mut sink = Vec::new(); + incoming.read_to_end(&mut sink).await.unwrap(); + assert!(sink.len() < 4096); + }); + let result = timeout( + Duration::from_secs(2), + stalled_handshake(kind, raw, Instant::now() + Duration::from_millis(30)), + ) + .await + .unwrap(); + assert_eq!( + result.unwrap_err().kind(), + io::ErrorKind::TimedOut, + "{kind}" + ); + timeout(Duration::from_secs(1), peer) + .await + .unwrap() + .unwrap(); + assert!(dropped.load(Ordering::SeqCst), "{kind}"); + } +} + +#[tokio::test(start_paused = true)] +async fn http2_lazy_response_obeys_setup_deadline_but_established_reads_do_not() { + for kind in ["grpc", "duplex", "h2", "pooled"] { + for timing in ["late-first-poll", "late-after-pending", "established"] { + let (client, peer) = tokio::io::duplex(65536); + let (release, released) = tokio::sync::oneshot::channel(); + let (ready, observed) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let mut connection = h2::server::handshake(peer).await.unwrap(); + let (_request, mut reply) = connection.accept().await.unwrap().unwrap(); + released.await.unwrap(); + let mut send = reply + .send_response( + http::Response::builder() + .header("content-type", "application/grpc") + .body(()) + .unwrap(), + false, + ) + .unwrap(); + send.send_data( + bytes::Bytes::from_static(if kind == "h2" { + b"ab" + } else { + b"\x00\x00\x00\x00\x04\x0a\x02ab" + }), + false, + ) + .unwrap(); + // Flush HEADERS/DATA before queuing PING. Its ACK proves that + // the client driver parsed the response, without polling the + // lazy application reader or depending on a scheduling sleep. + futures_util::future::poll_fn(|cx| { + assert!(connection.poll_closed(cx).is_pending()); + Poll::Ready(()) + }) + .await; + let mut ping = connection.ping_pong().unwrap(); + let barrier = async move { + ping.ping(h2::Ping::opaque()).await.unwrap(); + ready.send(()).unwrap(); + std::future::pending::<()>().await; + }; + tokio::select! { + () = barrier => unreachable!(), + _ = futures_util::future::poll_fn(|cx| connection.poll_closed(cx)) => {}, + } + drop(send); + }); + let deadline = Instant::now() + Duration::from_secs(1); + let uri = "https://fixture.invalid/stream/Tun"; + let pool = vcore::transport::GrpcPool::new(Default::default()); + let (mut stream, owner) = if kind == "pooled" { + ( + pool.open(uri, deadline, || async { Ok(Box::new(client) as _) }) + .await + .unwrap(), + None, + ) + } else { + let pair = match kind { + "grpc" => grpc(Box::new(client), uri, deadline).await, + "duplex" => { + vcore::transport::grpc_duplex(Box::new(client), uri, deadline).await + } + "h2" => vcore::transport::legacy_h2(Box::new(client), uri, deadline).await, + _ => unreachable!(), + } + .unwrap(); + (pair.0, Some(pair.1)) + }; + if timing == "late-after-pending" { + assert!(futures_util::poll!(std::pin::pin!(stream.read_u8())).is_pending()); + tokio::time::advance(Duration::from_secs(2)).await; + } + release.send(()).unwrap(); + timeout(Duration::from_secs(1), observed) + .await + .unwrap() + .unwrap(); + if timing == "established" { + assert_eq!(stream.read_u8().await.unwrap(), b'a'); + } + if timing != "late-after-pending" { + tokio::time::advance(Duration::from_secs(2)).await; + } + let result = stream.read_u8().await; + drop(stream); + if let Some(owner) = owner { + owner.stop().await.unwrap(); + } + pool.shutdown().await; + timeout(Duration::from_secs(1), server) + .await + .unwrap() + .unwrap(); + if timing == "established" { + assert_eq!(result.unwrap(), b'b', "{kind}"); + } else { + assert_eq!( + result.unwrap_err().kind(), + io::ErrorKind::TimedOut, + "{kind}: {timing}" + ); + } + } + } +} + +#[tokio::test] +async fn cancelled_setup_releases_supplied_io_without_a_detached_task() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-STREAM", + "cancelled_setup_releases_supplied_io_without_a_detached_task", + ); + for kind in ["ws", "grpc", "h2", "http"] { + let (outgoing, mut incoming) = tokio::io::duplex(128); + let dropped = Arc::new(AtomicBool::new(false)); + let raw = Observed { + io: outgoing, + shutdown: Arc::new(AtomicBool::new(false)), + dropped: dropped.clone(), + }; + let peer = tokio::spawn(async move { + let mut sink = Vec::new(); + incoming.read_to_end(&mut sink).await.unwrap(); + }); + assert!( + timeout( + Duration::from_millis(20), + stalled_handshake(kind, raw, Instant::now() + Duration::from_secs(10)) + ) + .await + .is_err() + ); + timeout(Duration::from_secs(1), peer) + .await + .unwrap() + .unwrap(); + assert!(dropped.load(Ordering::SeqCst), "{kind}"); + } +} + +#[tokio::test] +async fn ws_rejects_oversized_and_truncated_frames_instead_of_reporting_eof() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-STREAM", + "ws_rejects_oversized_and_truncated_frames_instead_of_reporting_eof", + ); + for frame in [ + vec![0x82, 0x7f, 0, 0, 0, 0, 0, 1, 0, 1], + vec![0x82, 3, b'x'], + vec![0x02, 1, b'x'], + vec![0x82], + ] { + let (outgoing, incoming) = tokio::io::duplex(128); + let peer = tokio::spawn(async move { + let mut ws = tokio_tungstenite::accept_async(incoming).await.unwrap(); + ws.get_mut().write_all(&frame).await.unwrap(); + ws.get_mut().shutdown().await.unwrap(); + }); + let mut stream = websocket( + Box::new(outgoing), + "ws://fixture.invalid/stream", + Instant::now() + Duration::from_secs(2), + ) + .await + .unwrap(); + assert_eq!( + timeout(Duration::from_secs(1), stream.read_u8()) + .await + .unwrap() + .unwrap_err() + .kind(), + io::ErrorKind::InvalidData + ); + drop(stream); + peer.await.unwrap(); + } +} + +#[tokio::test] +async fn ws_accepts_mihomo_clean_underlay_eof_only_at_complete_message_boundaries() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-STREAM", + "ws_accepts_mihomo_clean_underlay_eof_only_at_complete_message_boundaries", + ); + for frame in [ + b"\x82\x04tail".as_slice(), + b"\x02\x02ta\x80\x02il".as_slice(), + ] { + let (outgoing, incoming) = tokio::io::duplex(4096); + let peer = tokio::spawn(async move { + let mut ws = tokio_tungstenite::accept_async(incoming).await.unwrap(); + ws.get_mut().write_all(frame).await.unwrap(); + ws.get_mut().shutdown().await.unwrap(); + }); + let mut stream = websocket( + Box::new(outgoing), + "ws://fixture.invalid/stream", + Instant::now() + Duration::from_secs(2), + ) + .await + .unwrap(); + let mut tail = Vec::new(); + timeout(Duration::from_secs(1), stream.read_to_end(&mut tail)) + .await + .unwrap() + .unwrap(); + assert_eq!(tail, b"tail"); + peer.await.unwrap(); + } +} + +#[tokio::test] +async fn dropping_an_established_ws_releases_its_only_io_owner() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-STREAM", + "dropping_an_established_ws_releases_its_only_io_owner", + ); + let (outgoing, incoming) = tokio::io::duplex(128); + let dropped = Arc::new(AtomicBool::new(false)); + let raw = Observed { + io: outgoing, + shutdown: Arc::new(AtomicBool::new(false)), + dropped: dropped.clone(), + }; + let peer = tokio::spawn(async move { + let mut ws = tokio_tungstenite::accept_async(incoming).await.unwrap(); + assert_eq!(ws.get_mut().read(&mut [0; 1]).await.unwrap(), 0); + }); + let mut stream = websocket( + Box::new(raw), + "ws://fixture.invalid/stream", + Instant::now() + Duration::from_secs(2), + ) + .await + .unwrap(); + assert!( + timeout(Duration::from_millis(20), stream.read_u8()) + .await + .is_err() + ); + drop(stream); + timeout(Duration::from_secs(1), peer) + .await + .unwrap() + .unwrap(); + assert!(dropped.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn grpc_rejects_oversized_truncated_and_invalid_records() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-STREAM", + "grpc_rejects_oversized_truncated_and_invalid_records", + ); + for wire in [ + b"\x00\x00\x10\x00\x00".to_vec(), + b"\x00\x00\x00\x00\x06\x0a\x04x".to_vec(), + b"\x01\x00\x00\x00\x02\x0a\x00".to_vec(), + b"\x00\x00\x00\x00\x02\x0b\x00".to_vec(), + ] { + let (outgoing, incoming) = tokio::io::duplex(128); + let peer = tokio::spawn(async move { + let mut connection = h2::server::handshake(incoming).await.unwrap(); + let (_, mut respond) = connection.accept().await.unwrap().unwrap(); + let response = http::Response::builder() + .header("content-type", "application/grpc") + .body(()) + .unwrap(); + let mut send = respond.send_response(response, false).unwrap(); + send.send_data(wire.into(), true).unwrap(); + while let Some(result) = connection.accept().await { + if result.is_err() { + break; + } + } + }); + let (mut stream, owner) = grpc( + Box::new(outgoing), + "https://fixture.invalid/stream/Tun", + Instant::now() + Duration::from_secs(2), + ) + .await + .unwrap(); + assert_eq!( + timeout(Duration::from_secs(1), stream.read_u8()) + .await + .unwrap() + .unwrap_err() + .kind(), + io::ErrorKind::InvalidData + ); + drop(stream); + owner.stop().await.unwrap(); + timeout(Duration::from_secs(1), peer) + .await + .unwrap() + .unwrap(); + } +} + +#[tokio::test] +async fn grpc_large_writes_obey_small_http2_windows_and_keep_byte_integrity() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-STREAM", + "grpc_large_writes_obey_small_http2_windows_and_keep_byte_integrity", + ); + let (outgoing, incoming) = tokio::io::duplex(128); + let expected: Vec<_> = (0..65536).map(|n| (n % 251) as u8).collect(); + let peer = tokio::spawn(async move { + let mut connection = h2::server::Builder::new() + .initial_window_size(128) + .max_send_buffer_size(128) + .handshake(incoming) + .await + .unwrap(); + let (request, mut respond) = connection.accept().await.unwrap().unwrap(); + let exchange = async move { + let response = http::Response::builder() + .header("content-type", "application/grpc") + .body(()) + .unwrap(); + let mut send = respond.send_response(response, false).unwrap(); + let mut receive = request.into_body(); + while let Some(data) = receive.data().await { + let mut data = data.unwrap(); + let consumed = data.len(); + while !data.is_empty() { + send.reserve_capacity(data.len()); + let capacity = std::future::poll_fn(|cx| send.poll_capacity(cx)) + .await + .unwrap() + .unwrap(); + let count = capacity.min(data.len()); + if count > 0 { + send.send_data(data.split_to(count), false).unwrap(); + } + } + receive.flow_control().release_capacity(consumed).unwrap(); + } + }; + tokio::pin!(exchange); + loop { + tokio::select! { + _ = &mut exchange => break, + result = connection.accept() => if result.is_none() || result.is_some_and(|r| r.is_err()) { break; }, + } + } + }); + let (stream, owner) = grpc( + Box::new(outgoing), + "https://fixture.invalid/stream/Tun", + Instant::now() + Duration::from_secs(2), + ) + .await + .unwrap(); + let (mut read, mut write) = tokio::io::split(stream); + let upload = async { + write.write_all(&expected).await.unwrap(); + write.flush().await.unwrap(); + }; + let download = async { + let mut received = vec![0; expected.len()]; + read.read_exact(&mut received).await.unwrap(); + assert_eq!(received, expected); + }; + timeout(Duration::from_secs(3), async { + tokio::join!(upload, download); + }) + .await + .unwrap(); + drop(read); + drop(write); + owner.stop().await.unwrap(); + timeout(Duration::from_secs(1), peer) + .await + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn ws_partial_writes_empty_frames_ping_and_half_close_preserve_every_byte() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-STREAM", + "ws_partial_writes_empty_frames_ping_and_half_close_preserve_every_byte", + ); + let (outgoing, incoming) = tokio::io::duplex(128); + let expected: Vec<_> = (0..65536).map(|n| (n % 251) as u8).collect(); + let request = expected.clone(); + let peer = tokio::spawn(async move { + let mut ws = tokio_tungstenite::accept_async(incoming).await.unwrap(); + ws.send(Message::Binary(bytes::Bytes::new())).await.unwrap(); + ws.send(Message::Ping(b"ping".as_slice().into())) + .await + .unwrap(); + ws.send(Message::Text("ready".into())).await.unwrap(); + let mut received = Vec::new(); + while received.len() < request.len() { + match ws.next().await.unwrap().unwrap() { + Message::Binary(data) => received.extend_from_slice(&data), + Message::Pong(_) => {} + message => panic!("unexpected fixture message type: {}", message.is_close()), + } + } + assert_eq!(received, request); + let mut byte = [0]; + // Read raw EOF, not a WS Close frame. The public server library marks + // itself terminated on that EOF, so leave its WS state intact to reply. + assert_eq!(ws.get_mut().read(&mut byte).await.unwrap(), 0); + ws.send(Message::Binary(received.into())).await.unwrap(); + ws.send(Message::Close(None)).await.unwrap(); + }); + let mut stream = websocket( + Box::new(outgoing), + "ws://fixture.invalid/stream", + Instant::now() + Duration::from_secs(2), + ) + .await + .unwrap(); + let mut greeting = [0; 5]; + stream.read_exact(&mut greeting).await.unwrap(); + assert_eq!(&greeting, b"ready"); + stream.write_all(&expected).await.unwrap(); + stream.shutdown().await.unwrap(); + assert!(stream.write_all(b"forbidden").await.is_err()); + let mut reply = Vec::new(); + timeout(Duration::from_secs(2), stream.read_to_end(&mut reply)) + .await + .unwrap() + .unwrap(); + assert_eq!(reply, expected); + drop(stream); + peer.await.unwrap(); +} +use std::{ + io, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + task::{Context, Poll}, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite, DuplexStream, ReadBuf}, + time::{Instant, timeout}, +}; +use vcore::transport::{grpc, websocket}; + +struct Observed { + io: DuplexStream, + shutdown: Arc, + dropped: Arc, +} + +impl Drop for Observed { + fn drop(&mut self) { + self.dropped.store(true, Ordering::SeqCst); + } +} + +impl AsyncRead for Observed { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.io).poll_read(cx, buf) + } +} + +impl AsyncWrite for Observed { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.io).poll_write(cx, buf) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.io).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.shutdown.store(true, Ordering::SeqCst); + Pin::new(&mut self.io).poll_shutdown(cx) + } +} + +#[tokio::test] +async fn grpc_handles_response_after_upload_fragmented_records_and_owned_stop() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-STREAM", + "grpc_handles_response_after_upload_fragmented_records_and_owned_stop", + ); + let (outgoing, incoming) = tokio::io::duplex(128); + let dropped = Arc::new(AtomicBool::new(false)); + let raw = Observed { + io: outgoing, + shutdown: Arc::new(AtomicBool::new(false)), + dropped: dropped.clone(), + }; + let peer = tokio::spawn(async move { + let mut connection = h2::server::Builder::new() + .initial_window_size(128) + .handshake(incoming) + .await + .unwrap(); + let (request, mut respond) = connection.accept().await.unwrap().unwrap(); + assert_eq!(request.uri().path(), "/stream/Tun"); + let session = async move { + let mut body = request.into_body(); + let mut wire = Vec::new(); + // Sending response headers only after request bytes is deliberate: + // eagerly awaiting the response in the connector would deadlock. + while wire.len() < 11 { + let data = body.data().await.unwrap().unwrap(); + body.flow_control().release_capacity(data.len()).unwrap(); + wire.extend_from_slice(&data); + } + assert_eq!(&wire, b"\x00\x00\x00\x00\x06\x0a\x04ping"); + let response = http::Response::builder() + .status(200) + .header("content-type", "application/grpc") + .body(()) + .unwrap(); + let mut tx = respond.send_response(response, false).unwrap(); + for byte in b"\x00\x00\x00\x00\x06\x0a\x04pong" { + tx.send_data(bytes::Bytes::copy_from_slice(&[*byte]), false) + .unwrap(); + } + // Keep the response open: local shutdown must wake a pending read. + std::future::pending::<()>().await; + }; + tokio::pin!(session); + loop { + tokio::select! { + _ = &mut session => break, + result = connection.accept() => if result.is_none() || result.is_some_and(|r| r.is_err()) { break; }, + } + } + }); + let (mut stream, owner) = grpc( + Box::new(raw), + "https://fixture.invalid/stream/Tun", + Instant::now() + Duration::from_secs(2), + ) + .await + .unwrap(); + stream.write_all(b"ping").await.unwrap(); + stream.flush().await.unwrap(); + let mut reply = [0; 4]; + timeout(Duration::from_secs(2), stream.read_exact(&mut reply)) + .await + .unwrap() + .unwrap(); + assert_eq!(&reply, b"pong"); + let (mut read, mut write) = tokio::io::split(stream); + let waiting = tokio::spawn(async move { read.read_u8().await }); + tokio::task::yield_now().await; + write.shutdown().await.unwrap(); + assert!( + timeout(Duration::from_secs(1), waiting) + .await + .unwrap() + .unwrap() + .is_err() + ); + owner.stop().await.unwrap(); + assert!( + dropped.load(Ordering::SeqCst), + "Stop must join the task which owns IO" + ); + timeout(Duration::from_secs(1), peer) + .await + .unwrap() + .unwrap(); +} +use futures_util::{SinkExt, StreamExt}; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_tungstenite::tungstenite::Message; + +#[tokio::test] +async fn websocket_supplied_io_preserves_server_first_and_partial_writes() { + #[cfg(feature = "interop-test")] + let mut _case = vcore::resources::case_events::Case::new( + "FOUNDATIONS-STREAM", + "websocket_supplied_io_preserves_server_first_and_partial_writes", + ); + let (client, peer) = tokio::io::duplex(128); + let task = tokio::spawn(async move { + let mut ws = tokio_tungstenite::accept_async(peer).await.unwrap(); + ws.send(Message::Binary("hello".into())).await.unwrap(); + let mut received = Vec::new(); + while received.len() < 65_536 { + if let Message::Binary(data) = ws.next().await.unwrap().unwrap() { + received.extend_from_slice(&data); + } + } + assert_eq!(received, vec![42; 65_536]); + ws.send(Message::Binary("done".into())).await.unwrap(); + }); + let mut ws = vcore::transport::websocket( + Box::new(client), + "ws://example.com/tunnel", + tokio::time::Instant::now() + Duration::from_secs(3), + ) + .await + .unwrap(); + let mut hello = [0; 5]; + ws.read_exact(&mut hello).await.unwrap(); + assert_eq!(&hello, b"hello"); + ws.write_all(&vec![42; 65_536]).await.unwrap(); + ws.flush().await.unwrap(); + let mut done = [0; 4]; + ws.read_exact(&mut done).await.unwrap(); + assert_eq!(&done, b"done"); + drop(ws); + task.await.unwrap(); +} diff --git a/tests/stream_shutdown.rs b/tests/stream_shutdown.rs new file mode 100644 index 0000000..553474c --- /dev/null +++ b/tests/stream_shutdown.rs @@ -0,0 +1,322 @@ +//! Shutdown must flush accepted writes, without requiring a half-close response. +#![cfg(feature = "stream-transport")] + +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWrite, AsyncWriteExt, BufWriter}; +use vcore::{ + config::GrpcOptions, + dispatch::BoxStream, + transport::{GrpcPool, grpc, legacy_h2}, +}; + +#[tokio::test] +async fn xhttp_shutdown_flushes_the_last_upload_before_dropping_its_driver() { + use vcore::transport::xhttp::{XHttpClient, XHttpConfig, XHttpMode}; + + for mode in [XHttpMode::StreamOne, XHttpMode::StreamUp] { + for buffered in [false, true] { + for explicit_flush in [false, true] { + tokio::time::timeout(Duration::from_secs(3), async { + let (client, mut peer) = tokio::io::duplex(64); + let reader = tokio::spawn(async move { + let mut preface = [0; 24]; + peer.read_exact(&mut preface).await.unwrap(); + peer.write_all(&[0, 0, 0, 4, 0, 0, 0, 0, 0]).await.unwrap(); + let mut received = Vec::new(); + loop { + let mut header = [0; 9]; + if peer.read_exact(&mut header).await.is_err() { + break; + } + let size = + u32::from_be_bytes([0, header[0], header[1], header[2]]) as usize; + assert!(size <= 16384); + let mut data = vec![0; size]; + if peer.read_exact(&mut data).await.is_err() { + break; + } + if header[3] == 0 { + received.extend_from_slice(&data); + } + } + received + }); + let raw: BoxStream = if buffered { + Box::new(BufWriter::new(client)) + } else { + Box::new(client) + }; + let owner = XHttpClient::new( + XHttpConfig::new("fixture.invalid", "/stream", mode).unwrap(), + ); + let mut stream = owner.connect(raw).await.unwrap(); + stream.write_all(&[b'x'; 1024]).await.unwrap(); + if explicit_flush { + stream.flush().await.unwrap(); + } + stream.shutdown().await.unwrap(); + stream.shutdown().await.unwrap(); + assert!(stream.read(&mut [0]).await.is_err()); + let received = reader.await.unwrap(); + owner.stop().await; + assert_eq!( + received.len(), + 1024, + "mode={mode:?}, buffered={buffered}, explicit_flush={explicit_flush}" + ); + assert!(received.iter().all(|byte| *byte == b'x')); + }) + .await + .unwrap(); + } + } + } +} + +#[tokio::test] +async fn xhttp_http1_shutdown_flushes_the_last_chunk() { + use tokio::io::{AsyncBufReadExt, BufReader}; + use vcore::{ + config::XHttpVersion, + transport::xhttp::{XHttpClient, XHttpConfig, XHttpMode}, + }; + + for buffered in [false, true] { + tokio::time::timeout(Duration::from_secs(3), async { + let (raw, peer) = tokio::io::duplex(64); + let reader = tokio::spawn(async move { + let mut peer = BufReader::new(peer); + loop { + let mut line = String::new(); + if peer.read_line(&mut line).await.unwrap() == 0 { + return Vec::new(); + } + if line == "\r\n" { + break; + } + } + let mut received = Vec::new(); + loop { + let mut line = String::new(); + if peer.read_line(&mut line).await.unwrap() == 0 { + break; + } + let len = usize::from_str_radix(line.trim(), 16).unwrap(); + if len == 0 { + break; + } + assert!(len <= 16384); + let mut chunk = vec![0; len]; + if peer.read_exact(&mut chunk).await.is_err() { + break; + } + received.extend_from_slice(&chunk); + peer.read_exact(&mut [0; 2]).await.unwrap(); + } + received + }); + let mut config = + XHttpConfig::new("fixture.invalid", "/stream", XHttpMode::StreamOne).unwrap(); + config.http_version = XHttpVersion::Http1; + let owner = XHttpClient::new(config); + let raw: BoxStream = if buffered { + Box::new(BufWriter::new(raw)) + } else { + Box::new(raw) + }; + let mut stream = owner.connect(raw).await.unwrap(); + stream.write_all(&[b'x'; 1024]).await.unwrap(); + stream.flush().await.unwrap(); + stream.shutdown().await.unwrap(); + let received = reader.await.unwrap(); + owner.stop().await; + assert_eq!(received.len(), 1024, "buffered={buffered}"); + assert!(received.iter().all(|byte| *byte == b'x')); + }) + .await + .unwrap(); + } +} + +#[tokio::test(start_paused = true)] +async fn xhttp_stalled_upload_has_a_close_deadline_and_stop_cancels_it() { + use std::{future::poll_fn, pin::Pin, task::Poll}; + use vcore::{ + config::XHttpVersion, + transport::xhttp::{XHttpClient, XHttpConfig, XHttpMode}, + }; + for version in [XHttpVersion::Http1, XHttpVersion::Http2] { + for stop in [false, true] { + let (raw, _unread_peer) = tokio::io::duplex(64); + let mut config = + XHttpConfig::new("fixture.invalid", "/stream", XHttpMode::StreamOne).unwrap(); + config.http_version = version; + let owner = XHttpClient::new(config); + let mut stream = owner.connect(Box::new(raw)).await.unwrap(); + stream.write_all(&[b'x'; 1024]).await.unwrap(); + assert!( + poll_fn(|cx| Poll::Ready(Pin::new(&mut stream).poll_shutdown(cx))) + .await + .is_pending() + ); + if stop { + owner.stop().await; + } + tokio::time::timeout(Duration::from_millis(1100), stream.shutdown()) + .await + .expect("shutdown exceeded its bound") + .ok(); + assert!(stream.read(&mut [0]).await.is_err()); + owner.stop().await; + } + } +} + +#[tokio::test] +async fn whole_stream_shutdown_sends_the_last_write_before_closing() { + for mode in ["grpc", "legacy-h2", "pooled-grpc"] { + for buffered in [false, true] { + for explicit_flush in [false, true] { + tokio::time::timeout(Duration::from_secs(3), async { + let (client, mut peer) = tokio::io::duplex(64); + // A raw in-memory HTTP/2 peer observes actual DATA before reset + // or physical EOF, independently of h2's receive-side buffering. + let (ready, started) = tokio::sync::oneshot::channel(); + let reader = tokio::spawn(async move { + let mut preface = [0; 24]; + peer.read_exact(&mut preface).await.unwrap(); + assert_eq!(&preface, b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"); + peer.write_all(&[0, 0, 0, 4, 0, 0, 0, 0, 0]).await.unwrap(); + let mut ready = Some(ready); + let mut received = Vec::new(); + loop { + let mut header = [0; 9]; + if peer.read_exact(&mut header).await.is_err() { + break; + } + let size = + u32::from_be_bytes([0, header[0], header[1], header[2]]) as usize; + assert!(size <= 16384); + let mut data = vec![0; size]; + peer.read_exact(&mut data).await.unwrap(); + match header[3] { + 0 => received.extend_from_slice(&data), + 1 => { + if let Some(ready) = ready.take() { + ready.send(()).unwrap(); + } + } + 3 => break, + 4 if header[4] & 1 == 0 => { + peer.write_all(&[0, 0, 0, 4, 1, 0, 0, 0, 0]).await.unwrap(); + } + _ => {} + } + } + received + }); + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + let raw: BoxStream = if buffered { + Box::new(BufWriter::new(client)) + } else { + Box::new(client) + }; + let pool = GrpcPool::new(GrpcOptions::default()); + let (mut stream, owner) = if mode == "pooled-grpc" { + ( + pool.open("https://fixture.invalid/stream/Tun", deadline, || async { + Ok(raw) + }) + .await + .unwrap(), + None, + ) + } else { + let (stream, owner) = if mode == "legacy-h2" { + legacy_h2(raw, "https://fixture.invalid/stream", deadline).await + } else { + grpc(raw, "https://fixture.invalid/stream/Tun", deadline).await + } + .unwrap(); + (stream, Some(owner)) + }; + started.await.unwrap(); + let payload = [b'x'; 1024]; + stream.write_all(&payload).await.unwrap(); + if explicit_flush { + stream.flush().await.unwrap(); + } + stream.shutdown().await.unwrap(); + stream.shutdown().await.unwrap(); + assert_eq!(stream.read(&mut [0]).await.unwrap(), 0); + let received = reader.await.unwrap(); + if let Some(owner) = owner { + owner.stop().await.unwrap(); + } + pool.shutdown().await; + let expected = if mode == "legacy-h2" { + payload.to_vec() + } else { + let mut frame = vec![0, 0, 0, 4, 3, 0x0a, 0x80, 0x08]; + frame.extend_from_slice(&payload); + frame + }; + assert_eq!( + received.len(), + expected.len(), + "mode={mode}, buffered={buffered}, explicit_flush={explicit_flush}" + ); + assert_eq!(received, expected); + }) + .await + .unwrap(); + } + } + } +} + +#[tokio::test] +async fn owner_stop_cancels_a_shutdown_blocked_on_the_physical_writer() { + use std::{future::poll_fn, pin::Pin, task::Poll}; + for pooled in [false, true] { + tokio::time::timeout(Duration::from_secs(3), async { + let (raw, _unread_peer) = tokio::io::duplex(4096); + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + let pool = GrpcPool::new(GrpcOptions::default()); + let (mut stream, owner) = if pooled { + ( + pool.open("https://fixture.invalid/stream/Tun", deadline, || async { + Ok(Box::new(raw) as BoxStream) + }) + .await + .unwrap(), + None, + ) + } else { + let (stream, owner) = grpc( + Box::new(raw), + "https://fixture.invalid/stream/Tun", + deadline, + ) + .await + .unwrap(); + (stream, Some(owner)) + }; + stream.write_all(&[b'x'; 16384]).await.unwrap(); + assert!( + poll_fn(|cx| Poll::Ready(Pin::new(&mut stream).poll_shutdown(cx))) + .await + .is_pending() + ); + let shutdown = tokio::spawn(async move { stream.shutdown().await }); + tokio::task::yield_now().await; + if let Some(owner) = owner { + owner.stop().await.unwrap(); + } + pool.shutdown().await; + assert!(shutdown.await.unwrap().is_err()); + }) + .await + .expect("stop must wake a pending flush/shutdown"); + } +} diff --git a/tests/support/memory_quic.rs b/tests/support/memory_quic.rs new file mode 100644 index 0000000..9889fc4 --- /dev/null +++ b/tests/support/memory_quic.rs @@ -0,0 +1,124 @@ +//! Memory-only UDP peer shared by protocol regressions; never opens a socket. +use async_trait::async_trait; +use bytes::Bytes; +use std::{ + io::{self, IoSliceMut}, + net::SocketAddr, + pin::Pin, + sync::{Arc, Mutex}, + task::{Context, Poll, ready}, +}; +use tokio::sync::mpsc; +use vcore::{ + dispatch::{DatagramBudget, DatagramTransport, DispatchError}, + outbound::{ConnectedStream, DatagramRequest, EstablishContext, OutboundConnector}, + session::{Datagram, Destination, StreamSession}, +}; + +pub struct MemoryPackets { + pub send: mpsc::Sender, + pub receive: mpsc::Receiver, + pub remote: SocketAddr, +} +#[async_trait] +impl DatagramTransport for MemoryPackets { + fn payload_budget(&self, _: &Destination) -> DatagramBudget { + DatagramBudget::new(1400, 1400) + } + async fn send(&mut self, packet: Datagram) -> Result<(), DispatchError> { + assert_eq!(packet.remote, self.remote.into()); + self.send + .send(packet.payload) + .await + .map_err(|_| DispatchError::NotAllowed) + } + async fn receive(&mut self) -> Result { + Ok(Datagram { + remote: self.remote.into(), + payload: self.receive.recv().await.ok_or(DispatchError::NotAllowed)?, + sniffed_domain: None, + }) + } + async fn close(&mut self) -> Result<(), DispatchError> { + self.receive.close(); + Ok(()) + } +} +pub struct MemoryUpstream(pub Mutex>); +#[async_trait] +impl OutboundConnector for MemoryUpstream { + async fn connect_stream( + &self, + _: StreamSession, + _: &EstablishContext, + ) -> Result { + Err(DispatchError::NotAllowed) + } + async fn open_datagram( + &self, + _: DatagramRequest, + _: &EstablishContext, + ) -> Result, DispatchError> { + Ok(Box::new(self.0.lock().unwrap().take().unwrap())) + } +} + +// The passive fixture uses Quinn's socket seam, not VCore's client-only +// adapter (which intentionally waits for its first authorized outbound send). +#[derive(Debug)] +pub struct PeerSocket { + pub send: mpsc::Sender, + pub receive: Mutex>, + pub local: SocketAddr, + pub remote: SocketAddr, +} +#[derive(Debug)] +struct Writable(Mutex>); +impl quinn::UdpPoller for Writable { + fn poll_writable(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut sender = self.0.lock().unwrap(); + if ready!(sender.poll_reserve(cx)).is_ok() { + sender.abort_send(); + } + Poll::Ready(Ok(())) + } +} +impl quinn::AsyncUdpSocket for PeerSocket { + fn create_io_poller(self: Arc) -> Pin> { + Box::pin(Writable(Mutex::new(tokio_util::sync::PollSender::new( + self.send.clone(), + )))) + } + fn try_send(&self, packet: &quinn::udp::Transmit<'_>) -> io::Result<()> { + assert_eq!(packet.destination, self.remote); + match self.send.try_send(Bytes::copy_from_slice(packet.contents)) { + Ok(()) | Err(mpsc::error::TrySendError::Closed(_)) => Ok(()), + Err(mpsc::error::TrySendError::Full(_)) => Err(io::ErrorKind::WouldBlock.into()), + } + } + fn poll_recv( + &self, + cx: &mut Context<'_>, + bufs: &mut [IoSliceMut<'_>], + meta: &mut [quinn::udp::RecvMeta], + ) -> Poll> { + // An unconnected UDP socket has no peer EOF. A closed memory link + // becomes silent, not a local socket failure: otherwise Quinn's + // endpoint driver can exit before notifying an existing wait_idle. + let Some(packet) = ready!(self.receive.lock().unwrap().poll_recv(cx)) else { + return Poll::Pending; + }; + bufs[0][..packet.len()].copy_from_slice(&packet); + meta[0] = quinn::udp::RecvMeta { + addr: self.remote, + len: packet.len(), + stride: packet.len(), + ecn: None, + dst_ip: Some(self.local.ip()), + }; + Poll::Ready(Ok(1)) + } + fn local_addr(&self) -> io::Result { + Ok(self.local) + } +} diff --git a/tests/trojan.rs b/tests/trojan.rs new file mode 100644 index 0000000..a4ff30a --- /dev/null +++ b/tests/trojan.rs @@ -0,0 +1,265 @@ +#![cfg(feature = "outbound-trojan")] + +use vcore::{ + outbound::trojan::{TrojanAuth, TrojanCommand}, + session::Destination, +}; + +#[test] +fn request_matches_the_official_sha224_and_socks_address_wire_format() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "TROJAN-CODEC", + "request_matches_the_official_sha224_and_socks_address_wire_format", + ); + // SHA-224 independently checked with OpenSSL, not the production codec. + let auth = TrojanAuth::new("password").unwrap(); + let destination = Destination::domain("example.com", 443).unwrap(); + let request = auth.request(TrojanCommand::Tcp, &destination).unwrap(); + assert_eq!( + request.as_ref(), + b"d63dc919e201d7bc4c825630d2cf25fdc93d4b2f0d46706d29038d01\r\n\x01\x03\x0bexample.com\x01\xbb\r\n" + ); +} + +#[test] +fn authentication_and_requests_are_strict_and_never_debug_credentials() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "TROJAN-CODEC", + "authentication_and_requests_are_strict_and_never_debug_credentials", + ); + assert!(TrojanAuth::new("").is_err()); + let auth = TrojanAuth::new(" password ").unwrap(); + let plain = TrojanAuth::new("password").unwrap(); + let destination = Destination::Ip("[::1]:53".parse().unwrap()); + let packet = auth.request(TrojanCommand::Udp, &destination).unwrap(); + assert_ne!( + &packet[..56], + &plain.request(TrojanCommand::Udp, &destination).unwrap()[..56] + ); + assert_eq!( + &packet[56..], + b"\r\n\x03\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x35\r\n" + ); + assert!(!format!("{auth:?}").contains("password")); + assert!(!format!("{auth:?}").contains(std::str::from_utf8(&packet[..56]).unwrap())); + assert!( + auth.request( + TrojanCommand::Tcp, + &Destination::Ip("127.0.0.1:0".parse().unwrap()) + ) + .is_err() + ); +} + +#[tokio::test] +async fn invalid_truncated_and_oversized_frames_fail_closed_without_payload_leaks() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "TROJAN-CODEC", + "invalid_truncated_and_oversized_frames_fail_closed_without_payload_leaks", + ); + use tokio::io::AsyncWriteExt; + use vcore::{ + dispatch::{DatagramBudget, DatagramTransport}, + outbound::trojan::TrojanDatagram, + }; + for frame in [ + &b"\x09"[..], + &b"\x01\x01\x02"[..], + &b"\x01\x01\x02\x03\x04\x00\x00\x00\x01\r\nq"[..], + &b"\x01\x01\x02\x03\x04\x00\x35\x00\x01XXq"[..], + &b"\x01\x01\x02\x03\x04\x00\x35\x20\x01\r\n"[..], + &b"\x03\x00"[..], + &b"\x03\x01\xff\x00\x35\x00\x01\r\nq"[..], + ] { + let (client, mut peer) = tokio::io::duplex(4096); + let mut transport = TrojanDatagram::new(Box::new(client), DatagramBudget::new(8192, 8192)); + peer.write_all(frame).await.unwrap(); + peer.shutdown().await.unwrap(); + let error = transport.receive().await.unwrap_err().to_string(); + assert!(!error.contains("1.2.3.4")); + assert!(transport.receive().await.is_err()); + transport.close().await.unwrap(); + } +} + +#[tokio::test] +async fn datagram_limits_preserve_messages_and_drain_over_budget_responses() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "TROJAN-CODEC", + "datagram_limits_preserve_messages_and_drain_over_budget_responses", + ); + use bytes::Bytes; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use vcore::{ + dispatch::{DatagramBudget, DatagramTransport}, + outbound::trojan::{MAX_DATAGRAM_PAYLOAD, TrojanDatagram}, + session::Datagram, + }; + let (client, mut peer) = tokio::io::duplex(16384); + let mut transport = TrojanDatagram::new(Box::new(client), DatagramBudget::new(u16::MAX, 3)); + let remote = Destination::Ip("1.2.3.4:53".parse().unwrap()); + assert_eq!( + transport.payload_budget(&remote), + DatagramBudget::new(8192, 3) + ); + transport + .send(Datagram { + remote: remote.clone(), + payload: Bytes::from(vec![7; usize::from(MAX_DATAGRAM_PAYLOAD)]), + sniffed_domain: None, + }) + .await + .unwrap(); + let mut request = vec![0; 8192 + 11]; + peer.read_exact(&mut request).await.unwrap(); + assert_eq!(&request[..11], b"\x01\x01\x02\x03\x04\x00\x35\x20\x00\r\n"); + assert!(request[11..].iter().all(|byte| *byte == 7)); + assert!( + transport + .send(Datagram { + remote, + payload: Bytes::from(vec![7; 8193]), + sniffed_domain: None + }) + .await + .is_err() + ); + peer.write_all( + b"\x01\x01\x02\x03\x04\x00\x35\x00\x04\r\nskip\x03\x08dns.test\x00\x35\x00\x03\r\nyes", + ) + .await + .unwrap(); + let packet = transport.receive().await.unwrap(); + assert_eq!(packet.remote, Destination::domain("dns.test", 53).unwrap()); + assert_eq!(packet.payload.as_ref(), b"yes"); +} + +#[tokio::test] +async fn datagram_sends_one_frame_and_receives_fragmented_consecutive_frames() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "TROJAN-CODEC", + "datagram_sends_one_frame_and_receives_fragmented_consecutive_frames", + ); + use bytes::Bytes; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use vcore::{ + dispatch::{DatagramBudget, DatagramTransport}, + outbound::trojan::TrojanDatagram, + session::Datagram, + }; + let (client, mut peer) = tokio::io::duplex(1); + let remote = Destination::Ip("1.2.3.4:53".parse().unwrap()); + let mut transport = TrojanDatagram::new(Box::new(client), DatagramBudget::new(8192, 8192)); + let server = tokio::spawn(async move { + let mut frame = [0; 14]; + peer.read_exact(&mut frame).await.unwrap(); + assert_eq!(&frame, b"\x01\x01\x02\x03\x04\x00\x35\x00\x03\r\nabc"); + for payload in [b"one", b"two"] { + peer.write_all(b"\x01\x01\x02\x03\x04\x00\x35\x00\x03\r\n") + .await + .unwrap(); + peer.write_all(payload).await.unwrap(); + } + }); + transport + .send(Datagram { + remote: remote.clone(), + payload: Bytes::from_static(b"abc"), + sniffed_domain: None, + }) + .await + .unwrap(); + for expected in [b"one", b"two"] { + let packet = transport.receive().await.unwrap(); + assert_eq!(packet.remote, remote); + assert_eq!(packet.payload.as_ref(), expected); + } + server.await.unwrap(); + transport.close().await.unwrap(); +} + +#[tokio::test] +async fn cancelled_receive_preserves_partial_frames_and_does_not_block_send() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "TROJAN-CODEC", + "cancelled_receive_preserves_partial_frames_and_does_not_block_send", + ); + use bytes::Bytes; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use vcore::{ + dispatch::{DatagramBudget, DatagramTransport}, + outbound::trojan::TrojanDatagram, + session::Datagram, + }; + let (client, mut peer) = tokio::io::duplex(4096); + let mut transport = TrojanDatagram::new(Box::new(client), DatagramBudget::new(8192, 8192)); + peer.write_all(b"\x01\x01\x02").await.unwrap(); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(10), transport.receive()) + .await + .is_err() + ); + transport + .send(Datagram { + remote: Destination::domain("example.com", 53).unwrap(), + payload: Bytes::from_static(b"q"), + sniffed_domain: None, + }) + .await + .unwrap(); + let mut sent = [0; 20]; + peer.read_exact(&mut sent).await.unwrap(); + assert_eq!(&sent, b"\x03\x0bexample.com\x00\x35\x00\x01\r\nq"); + peer.write_all(b"\x03\x04\x00\x35\x00\x03\r\nend") + .await + .unwrap(); + let packet = transport.receive().await.unwrap(); + assert_eq!( + packet.remote, + Destination::Ip("1.2.3.4:53".parse().unwrap()) + ); + assert_eq!(packet.payload.as_ref(), b"end"); +} + +#[tokio::test] +async fn cancelled_partial_send_poison_closes_without_replaying_or_waiting() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "TROJAN-CODEC", + "cancelled_partial_send_poison_closes_without_replaying_or_waiting", + ); + use bytes::Bytes; + use tokio::io::AsyncReadExt; + use vcore::{ + dispatch::{DatagramBudget, DatagramTransport}, + outbound::trojan::TrojanDatagram, + session::Datagram, + }; + let (client, mut peer) = tokio::io::duplex(1); + let mut transport = TrojanDatagram::new(Box::new(client), DatagramBudget::new(8192, 8192)); + let packet = || Datagram { + remote: Destination::Ip("1.2.3.4:53".parse().unwrap()), + payload: Bytes::from_static(b"q"), + sniffed_domain: None, + }; + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(10), + transport.send(packet()) + ) + .await + .is_err() + ); + assert!(transport.send(packet()).await.is_err()); + let mut remaining = Vec::new(); + peer.read_to_end(&mut remaining).await.unwrap(); + assert_eq!(remaining, [1]); + transport.close().await.unwrap(); + transport.close().await.unwrap(); +} diff --git a/tests/trojan_config.rs b/tests/trojan_config.rs new file mode 100644 index 0000000..8620b57 --- /dev/null +++ b/tests/trojan_config.rs @@ -0,0 +1,247 @@ +#![cfg(feature = "outbound-trojan")] + +use serde_json::{Value, json}; +use vcore::config::{Config, ProxyProtocol}; + +fn document(extra: Value) -> Vec { + let mut node = json!({"name":"edge", "type":"trojan", "server":"localhost", "port":443, "password":" 密码 "}); + node.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + serde_json::to_vec(&json!({"socks-port":1080,"proxies":[node],"rules":["MATCH,edge"]})).unwrap() +} + +#[test] +fn trojan_tcp_configuration_and_node_graph_accept_the_approved_fields() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "TROJAN-CFG", + "trojan_tcp_configuration_and_node_graph_accept_the_approved_fields", + ); + let yaml = b"socks-port: 1080\nproxies:\n - name: edge\n type: trojan\n server: example.com\n port: 443\n password: ' password '\n udp: true\n sni: tls.example.com\n alpn: [h2, http/1.1]\n skip-cert-verify: false\n fingerprint: '0000000000000000000000000000000000000000000000000000000000000000'\n dialer-proxy: upstream\nproxy-groups:\n - name: upstream\n type: select\n proxies: [DIRECT, REJECT]\nrules: ['MATCH,edge']\n"; + let config = Config::parse_yaml(yaml).expect("TROJAN Trojan TCP configuration"); + assert_eq!(config.proxies[0].address(), "example.com"); + assert_eq!(config.proxies[0].port(), 443); + assert!(config.proxies[0].udp); + assert!(!format!("{config:?}").contains(" password ")); +} + +#[test] +fn trojan_tcp_defaults_preserve_credentials_and_address_policy() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "TROJAN-CFG", + "trojan_tcp_defaults_preserve_credentials_and_address_policy", + ); + let parsed = Config::parse_yaml(&document(json!({}))).unwrap(); + let ProxyProtocol::Trojan(node) = &parsed.proxies[0].protocol else { + panic!("wrong protocol") + }; + assert_eq!(node.password, " 密码 "); + assert_eq!(node.server_name, "localhost"); + assert!(!parsed.proxies[0].udp); + assert!(node.tls.alpn.is_empty()); + assert!(!node.tls.skip_cert_verify); + assert_eq!(node.tls.fingerprint, None); + for server in ["127.0.0.1", "::1", "example.com"] { + assert!(Config::parse_yaml(&document(json!({"server":server, "network":"tcp"}))).is_ok()); + } + let config = Config::parse_yaml(&document(json!({"alpn":["second","first"],"skip-cert-verify":true,"fingerprint":"AB:".repeat(31)+"AB"}))).unwrap(); + let ProxyProtocol::Trojan(node) = &config.proxies[0].protocol else { + unreachable!() + }; + assert_eq!(node.tls.alpn, [b"second".to_vec(), b"first".to_vec()]); + assert!(node.tls.skip_cert_verify); + assert_eq!(node.tls.fingerprint, Some([0xab; 32])); +} + +#[test] +fn trojan_invalid_configuration_is_rejected_without_exposing_credentials() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "TROJAN-CFG", + "trojan_invalid_configuration_is_rejected_without_exposing_credentials", + ); + for extra in [ + json!({"name":null}), + json!({"name":3}), + json!({"name":""}), + json!({"password":""}), + json!({"password":null}), + json!({"password":123}), + json!({"udp":null}), + json!({"udp":"true"}), + json!({"tls":true}), + json!({"port":0}), + json!({"port":65536}), + json!({"port":null}), + json!({"port":"443"}), + json!({"server":"https://localhost"}), + json!({"server":"localhost:443"}), + json!({"server":null}), + json!({"network":"invalid"}), + json!({"network":null}), + json!({"sni":""}), + json!({"sni":"https://localhost"}), + json!({"sni":null}), + json!({"alpn":null}), + json!({"alpn":12}), + json!({"alpn":[3]}), + json!({"alpn":[""]}), + json!({"alpn":["x".repeat(256)]}), + json!({"skip-cert-verify":null}), + json!({"skip-cert-verify":"true"}), + json!({"fingerprint":"ab"}), + json!({"fingerprint":null}), + json!({"fingerprint":12}), + json!({"dialer-proxy":null}), + json!({"dialer-proxy":12}), + json!({"dialer-proxy":"missing"}), + json!({"dialer-proxy":"edge"}), + json!({"ws-opts":{}}), + json!({"grpc-opts":{}}), + json!({"extra-option":true}), + ] { + let error = + Config::parse_yaml(&document(extra.clone())).expect_err(&format!("accepted {extra}")); + assert!( + !error.to_string().contains("密码"), + "credential in parser diagnostic" + ); + } + for field in ["name", "type", "server", "port", "password"] { + let mut config: Value = serde_json::from_slice(&document(json!({}))).unwrap(); + config["proxies"][0].as_object_mut().unwrap().remove(field); + assert!( + Config::parse_yaml(config.to_string().as_bytes()).is_err(), + "missing {field}" + ); + } + let mut config: Value = + serde_json::from_slice(&document(json!({"dialer-proxy":"unselected"}))).unwrap(); + config["proxy-groups"] = + json!([{"name":"unselected","type":"select","proxies":["DIRECT","edge"]}]); + assert!( + Config::parse_yaml(config.to_string().as_bytes()).is_err(), + "unselected graph edge forms a cycle" + ); +} + +#[test] +fn trojan_ws_and_grpc_configuration_applies_transport_specific_defaults() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "TROJAN-CFG", + "trojan_ws_and_grpc_configuration_applies_transport_specific_defaults", + ); + let config = Config::parse_yaml(&document(json!({"network":"ws"}))).unwrap(); + let ProxyProtocol::Trojan(node) = &config.proxies[0].protocol else { + unreachable!() + }; + assert_eq!(node.tls.alpn, [b"http/1.1".to_vec()]); + assert_eq!(node.server_name, "localhost"); + let config = Config::parse_yaml(&document(json!({"network":"ws","ws-opts":{"path":"/edge?q=1","headers":{"Host":"cover.example:443","X-Fixture":"yes"},"max-early-data":2048}}))).unwrap(); + let ProxyProtocol::Trojan(node) = &config.proxies[0].protocol else { + unreachable!() + }; + assert_eq!( + node.server_name, "localhost", + "Trojan SNI must not fall back to WS Host" + ); + let config = Config::parse_yaml(&document( + json!({"network":"grpc","grpc-opts":{"grpc-service-name":"edge"}}), + )) + .unwrap(); + let ProxyProtocol::Trojan(node) = &config.proxies[0].protocol else { + unreachable!() + }; + assert_eq!(node.tls.alpn, [b"h2".to_vec()]); + for service in ["Edge", "/custom/Tun"] { + assert!( + Config::parse_yaml(&document( + json!({"network":"grpc","grpc-opts":{"grpc-service-name":service}}) + )) + .is_ok() + ); + } +} + +#[test] +fn trojan_transport_boundaries_fail_before_runtime_io() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "TROJAN-CFG", + "trojan_transport_boundaries_fail_before_runtime_io", + ); + for fields in [ + json!({"ws-opts":null}), + json!({"grpc-opts":null}), + json!({"network":"grpc"}), + json!({"network":"grpc","grpc-opts":{"grpc-service-name":""}}), + json!({"network":"grpc","grpc-opts":{"grpc-service-name":"edge?query=1"}}), + json!({"network":"grpc","grpc-opts":{"grpc-service-name":null}}), + json!({"network":"grpc","grpc-opts":{"grpc-service-name":"edge","max-connections":1}}), + json!({"network":"ws","grpc-opts":{"grpc-service-name":"edge"}}), + json!({"network":"grpc","ws-opts":{},"grpc-opts":{"grpc-service-name":"edge"}}), + json!({"network":"ws","alpn":[]}), + json!({"network":"ws","alpn":["h2"]}), + json!({"network":"grpc","alpn":["http/1.1"],"grpc-opts":{"grpc-service-name":"edge"}}), + ] { + assert!( + Config::parse_yaml(&document(fields.clone())).is_err(), + "accepted {fields}" + ); + } + for opts in [ + json!({"path":"relative"}), + json!({"path":null}), + json!({"path":"/bad\r\nheader"}), + json!({"path":"/bad#fragment"}), + json!({"path":"/".repeat(16385)}), + json!({"headers":null}), + json!({"headers":{"Host":"user:secret@example.com"}}), + json!({"headers":{"Host":"a.example", "host":"b.example"}}), + json!({"headers":{"X-Foo":null}}), + json!({"headers":{"X-Foo":"bad\r\nheader"}}), + json!({"headers":{"Connection":"keep-alive"}}), + json!({"headers":{"Upgrade":"websocket"}}), + json!({"headers":{"Sec-WebSocket-Key":"override"}}), + json!({"headers":{"Sec-WebSocket-Protocol":"override"}}), + json!({"headers":{"Content-Length":"1"}}), + json!({"max-early-data":2049}), + json!({"max-early-data":-1}), + json!({"max-early-data":null}), + json!({"early-data-header-name":"x-ed"}), + json!({"max-early-data":1,"early-data-header-name":"Host"}), + json!({"max-early-data":1,"early-data-header-name":"Connection"}), + json!({"max-early-data":1,"early-data-header-name":null}), + json!({"max-early-data":1,"early-data-header-name":"x-ed","headers":{"X-ED":"duplicate"}}), + json!({"max-early-data":1,"early-data-header-name":"","path":"/edge?q=1"}), + json!({"v2ray-http-upgrade":true}), + ] { + assert!( + Config::parse_yaml(&document(json!({"network":"ws","ws-opts":opts.clone()}))).is_err(), + "accepted WS options {opts}" + ); + } + for max in [0, 1, 2048] { + for header in [None, Some("x-vcore-ed"), Some("")] { + if max == 0 && header.is_some() { + continue; + } + let mut opts = json!({"max-early-data":max}); + if let Some(name) = header { + opts["early-data-header-name"] = json!(name); + } + assert!(Config::parse_yaml(&document(json!({"network":"ws","ws-opts":opts}))).is_ok()); + } + } + for host in ["cover.example:443", "[::1]:443"] { + assert!( + Config::parse_yaml(&document( + json!({"network":"ws","ws-opts":{"headers":{"Host":host}}}) + )) + .is_ok() + ); + } +} diff --git a/tests/trojan_lifecycle.rs b/tests/trojan_lifecycle.rs new file mode 100644 index 0000000..9f4c542 --- /dev/null +++ b/tests/trojan_lifecycle.rs @@ -0,0 +1,290 @@ +// These probes exercise the Unix fd protector. Windows uses its separate +// physical-interface binding contract, not this callback. +#![cfg(all(unix, feature = "outbound-trojan", feature = "interop-test"))] + +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::{ + io, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use vcore::{ + config::{Config, ProxyProtocol}, + dialer::{Dialer, ResolvedEndpoint, SocketProtector}, + outbound::{EstablishContext, OutboundConnector, UpstreamPath, trojan::TrojanOutbound}, + resources::{ + case_events::Case, + observation::{ResourceKind, ResourceProbe}, + }, + session::{Destination, InboundKind, StreamSession}, +}; + +#[derive(Default)] +struct Protect { + calls: AtomicUsize, + reject: bool, +} + +#[tokio::test] +#[ignore = "requires the owned TROJAN native-peer runner"] +async fn trojan_native_owned_resources() { + use bytes::Bytes; + use vcore::{ + outbound::DatagramRequest, + session::{Datagram, DatagramSession}, + }; + let _case = Case::new("TROJAN-NATIVE", "trojan_native_owned_resources"); + let fixture: serde_json::Value = + serde_json::from_str(&std::env::var("VCORE_TROJAN_FIXTURE").unwrap()).unwrap(); + let node = &fixture["node"]; + let config = Config::parse_yaml( + json!({"socks-port":1080,"proxies":[node],"rules":["MATCH,peer"]}) + .to_string() + .as_bytes(), + ) + .unwrap(); + let ProxyProtocol::Trojan(config) = &config.proxies[0].protocol else { + unreachable!() + }; + for _ in 0..20 { + let mut cycle = Case::new("TROJAN-OWNED-CYCLE", "stop_and_remain_quiet"); + let probe = ResourceProbe::default(); + cycle.checkpoint("baseline", probe.snapshot()); + probe + .scope(async { + let address = std::net::SocketAddr::new( + node["server"].as_str().unwrap().parse().unwrap(), + node["port"].as_u64().unwrap() as u16, + ); + let endpoint = ResolvedEndpoint { + logical_host: node["server"].as_str().unwrap().into(), + port: address.port(), + addresses: vec![address], + }; + let protect = Arc::new(Protect::default()); + let outbound = TrojanOutbound::new_with_path( + config, + UpstreamPath::direct( + endpoint, + Dialer::default().with_protector(protect.clone()), + ), + ) + .unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut request = session(); + request.destination = Destination::Ip(listener.local_addr().unwrap()); + let mut first = outbound + .connect_stream(request.clone(), &EstablishContext::default()) + .await + .unwrap() + .io; + first.write_all(b"one").await.unwrap(); + first.flush().await.unwrap(); + let (mut remote1, _) = listener.accept().await.unwrap(); + assert_eq!(&remote1.read_u8().await.unwrap(), &b'o'); + let mut second = outbound + .connect_stream(request, &EstablishContext::default()) + .await + .unwrap() + .io; + second.write_all(b"two").await.unwrap(); + second.flush().await.unwrap(); + let (mut remote2, _) = listener.accept().await.unwrap(); + let mut bytes = [0; 3]; + remote2.read_exact(&mut bytes).await.unwrap(); + assert_eq!(&bytes, b"two"); + drop(first); + remote2.write_all(b"live").await.unwrap(); + let mut reply = [0; 4]; + second.read_exact(&mut reply).await.unwrap(); + assert_eq!(&reply, b"live"); + let origin = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let datagram = DatagramRequest::new(DatagramSession::new( + InboundKind::InternalMeasure, + "127.0.0.1:1".parse().unwrap(), + )); + let mut udp = outbound + .open_datagram(datagram, &EstablishContext::default()) + .await + .unwrap(); + udp.send(Datagram { + remote: Destination::Ip(origin.local_addr().unwrap()), + payload: Bytes::from_static(b"udp"), + sniffed_domain: None, + }) + .await + .unwrap(); + let (n, source) = origin.recv_from(&mut bytes).await.unwrap(); + assert_eq!(&bytes[..n], b"udp"); + origin.send_to(b"udp", source).await.unwrap(); + assert_eq!(udp.receive().await.unwrap().payload.as_ref(), b"udp"); + assert!(probe.snapshot().peak(ResourceKind::Association) > 0); + assert!(probe.snapshot().peak(ResourceKind::Session) > 0); + assert!(probe.snapshot().peak(ResourceKind::Socket) > 0); + if node["network"] == "grpc" { + assert!(probe.snapshot().peak(ResourceKind::Task) > 0); + } + // RunningCore first cancels and joins inbound owners, dropping + // their streams/associations, then joins protocol-owned drivers. + outbound.begin_shutdown(); + assert!(second.read_u8().await.is_err()); + drop(second); + udp.close().await.unwrap(); + drop(udp); + tokio::time::timeout(Duration::from_secs(5), outbound.shutdown()) + .await + .unwrap(); + assert!( + probe.snapshot().is_idle(), + "Stop retained owned resources: {:?}", + probe.snapshot() + ); + cycle.checkpoint("after-stop", probe.snapshot()); + let calls = protect.calls.load(Ordering::SeqCst); + assert_eq!(calls, 3); + let stopped = probe.snapshot(); + let quiet = tokio::time::Instant::now(); + while quiet.elapsed() < Duration::from_secs(5) { + assert_eq!(probe.snapshot(), stopped); + assert_eq!(protect.calls.load(Ordering::SeqCst), calls); + tokio::time::sleep(Duration::from_millis(25)).await; + } + cycle.checkpoint("quiet", probe.snapshot()); + drop(outbound); + assert_eq!(Arc::strong_count(&protect), 1); + }) + .await; + cycle.resources(probe.snapshot()); + } +} +impl SocketProtector for Protect { + fn protect(&self, _: i32) -> io::Result<()> { + self.calls.fetch_add(1, Ordering::SeqCst); + if self.reject { + Err(io::ErrorKind::PermissionDenied.into()) + } else { + Ok(()) + } + } +} + +fn session() -> StreamSession { + StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:1".parse().unwrap(), + destination: Destination::Ip("127.0.0.1:9".parse().unwrap()), + sniffed_domain: None, + } +} + +#[tokio::test] +async fn trojan_handshake_deadlines_cancel_tls_ws_and_grpc_without_retaining_io() { + let mut case = Case::new( + "TROJAN-CANCEL", + "trojan_handshake_deadlines_cancel_tls_ws_and_grpc_without_retaining_io", + ); + for mode in ["tls", "ws", "grpc"] { + let probe = ResourceProbe::default(); + probe.scope(async { + let key = rcgen::KeyPair::generate().unwrap(); + let cert = rcgen::CertificateParams::new(vec!["fixture.invalid".into()]).unwrap().self_signed(&key).unwrap(); + let pin: String = Sha256::digest(cert.der().as_ref()).iter().map(|byte|format!("{byte:02x}")).collect(); + let mut tls = rustls::ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions().unwrap().with_no_client_auth() + .with_single_cert(vec![cert.der().clone()],rustls::pki_types::PrivateKeyDer::Pkcs8(key.serialize_der().into())).unwrap(); + tls.alpn_protocols = vec![if mode == "grpc" {b"h2".to_vec()} else {b"http/1.1".to_vec()}]; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let peer = tokio::spawn(async move { + let (mut raw,_) = listener.accept().await.unwrap(); + if mode == "tls" { + let mut bytes = [0;4096]; + assert!(raw.read(&mut bytes).await.unwrap() > 0); + assert_eq!(raw.read(&mut bytes).await.unwrap(),0); + return; + } + let mut stream = tokio_rustls::TlsAcceptor::from(Arc::new(tls)).accept(raw).await.unwrap(); + if mode == "ws" { + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { request.push(stream.read_u8().await.unwrap()); } + let request = String::from_utf8(request).unwrap().to_ascii_lowercase(); + assert!(request.starts_with("get /edge?q=1 http/1.1")); + assert!(request.contains("host: cover.example:443\r\n")); + assert!(request.contains("x-fixture: exact-value\r\n")); + // Withhold the Upgrade, exercising cancellation after the + // actual protocol consumer wrote its configured request. + let mut bytes = Vec::new(); + let _ = stream.read_to_end(&mut bytes).await; + } else { + let mut h2 = h2::server::handshake(stream).await.unwrap(); + let (request,_response) = h2.accept().await.unwrap().unwrap(); + assert_eq!(request.uri().path(),"/custom/Tun"); + // No response headers: the gRPC response deadline must + // still be the original setup deadline, not a fresh one. + while let Some(result) = h2.accept().await { if result.is_err() { break; } } + } + }); + let mut node = json!({"name":"peer","type":"trojan","server":"127.0.0.1","port":address.port(),"password":"fixture","sni":"fixture.invalid","fingerprint":pin}); + match mode { + "ws" => { node["network"] = json!("ws"); node["ws-opts"] = json!({"path":"/edge?q=1","headers":{"Host":"cover.example:443","X-Fixture":"exact-value"}}); }, + "grpc" => { node["network"] = json!("grpc"); node["grpc-opts"] = json!({"grpc-service-name":"/custom/Tun"}); }, + _ => {}, + } + let parsed = Config::parse_yaml(json!({"socks-port":1080,"proxies":[node],"rules":["MATCH,peer"]}).to_string().as_bytes()).unwrap(); + let ProxyProtocol::Trojan(config) = &parsed.proxies[0].protocol else { unreachable!() }; + let protect = Arc::new(Protect::default()); + let endpoint = ResolvedEndpoint { logical_host:"127.0.0.1".into(),port:address.port(),addresses:vec![address] }; + let outbound = TrojanOutbound::new_with_path(config,UpstreamPath::direct(endpoint,Dialer::default().with_protector(protect.clone()))).unwrap(); + let started = tokio::time::Instant::now(); + let context = EstablishContext::with_timeout(Duration::from_millis(150)); + let connected = outbound.connect_stream(session(),&context).await; + if mode == "grpc" { + let mut stream = connected.unwrap().io; + assert!(tokio::time::timeout(Duration::from_secs(1),stream.read_u8()).await.unwrap().is_err()); + drop(stream); + } else { assert!(connected.is_err()); } + assert!(started.elapsed() < Duration::from_secs(1)); + tokio::time::timeout(Duration::from_secs(5),outbound.shutdown()).await.unwrap(); + drop(outbound); + tokio::time::timeout(Duration::from_secs(1),peer).await.unwrap().unwrap(); + assert_eq!(protect.calls.load(Ordering::SeqCst),1); + assert_eq!(Arc::strong_count(&protect),1); + assert!(probe.snapshot().is_idle(),"mode={mode}: {:?}",probe.snapshot()); + assert_eq!(probe.snapshot().peak(ResourceKind::Socket),1); + }).await; + case.checkpoint("after-stop", probe.snapshot()); + case.resources(probe.snapshot()); + } +} + +#[tokio::test] +async fn trojan_protect_rejection_and_expired_deadline_send_no_network_bytes() { + let mut case = Case::new( + "TROJAN-CANCEL", + "trojan_protect_rejection_and_expired_deadline_send_no_network_bytes", + ); + let probe = ResourceProbe::default(); + probe.scope(async { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let parsed = Config::parse_yaml(json!({"socks-port":1080,"proxies":[{"name":"peer","type":"trojan","server":"127.0.0.1","port":address.port(),"password":"fixture"}],"rules":["MATCH,peer"]}).to_string().as_bytes()).unwrap(); + let ProxyProtocol::Trojan(config) = &parsed.proxies[0].protocol else { unreachable!() }; + let protect = Arc::new(Protect {reject:true,..Default::default()}); + let endpoint = ResolvedEndpoint {logical_host:"127.0.0.1".into(),port:address.port(),addresses:vec![address]}; + let outbound = TrojanOutbound::new_with_path(config,UpstreamPath::direct(endpoint,Dialer::default().with_protector(protect.clone()))).unwrap(); + assert!(outbound.connect_stream(session(),&EstablishContext::default()).await.is_err()); + assert_eq!(protect.calls.load(Ordering::SeqCst),1); + assert!(outbound.connect_stream(session(),&EstablishContext::with_timeout(Duration::ZERO)).await.is_err()); + assert_eq!(protect.calls.load(Ordering::SeqCst),1); + assert!(tokio::time::timeout(Duration::from_millis(20),listener.accept()).await.is_err()); + outbound.shutdown().await; + drop(outbound); + }).await; + assert!(probe.snapshot().is_idle()); + case.resources(probe.snapshot()); +} diff --git a/tests/vless_codec.rs b/tests/vless_codec.rs new file mode 100644 index 0000000..459d849 --- /dev/null +++ b/tests/vless_codec.rs @@ -0,0 +1,254 @@ +#![cfg(feature = "outbound-vless")] +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use vcore::{ + config::{Config, ProxyProtocol}, + dispatch::{BoxStream, DatagramTransport, DispatchError}, + outbound::{ + ConnectedStream, DatagramRequest, EstablishContext, OutboundConnector, UpstreamPath, + VlessOutbound, + }, + session::{Datagram, DatagramSession, Destination, InboundKind, StreamSession}, +}; + +struct Supplied(Mutex>); +#[async_trait::async_trait] +impl OutboundConnector for Supplied { + async fn connect_stream( + &self, + session: StreamSession, + _: &EstablishContext, + ) -> Result { + Ok(ConnectedStream { + io: self.0.lock().unwrap().take().unwrap(), + effective_peer: session.destination, + }) + } + async fn open_datagram( + &self, + _: DatagramRequest, + _: &EstablishContext, + ) -> Result, DispatchError> { + Err(DispatchError::NotAllowed) + } +} +fn node(io: BoxStream, encoding: &str) -> VlessOutbound { + let config=Config::parse_yaml(format!("socks-port: 1080\nproxies: [{{name: edge, type: vless, server: example.com, port: 443, uuid: 07070707-0707-0707-0707-070707070707, udp: true, packet-encoding: {encoding}}}]\nrules: ['MATCH,edge']").as_bytes()).unwrap(); + let ProxyProtocol::Vless(config) = &config.proxies[0].protocol else { + unreachable!() + }; + VlessOutbound::new_with_path( + config, + UpstreamPath::proxy(Arc::new(Supplied(Mutex::new(Some(io))))), + ) + .unwrap() +} +fn session() -> DatagramSession { + DatagramSession::new(InboundKind::InternalMeasure, "127.0.0.1:1".parse().unwrap()) +} +fn packet() -> Datagram { + Datagram { + remote: Destination::Ip("192.0.2.1:53".parse().unwrap()), + payload: bytes::Bytes::from_static(b"payload"), + sniffed_domain: None, + } +} + +#[tokio::test] +async fn stopping_before_first_udp_send_wakes_receive_and_releases_io() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "VLESS-UNIT", + "stopping_before_first_udp_send_wakes_receive_and_releases_io", + ); + for encoding in ["none", "packetaddr"] { + let (io, mut peer) = tokio::io::duplex(1024); + let outbound = node(Box::new(io), encoding); + let mut client = outbound + .open_datagram( + DatagramRequest::new(session()), + &EstablishContext::default(), + ) + .await + .unwrap(); + outbound.shutdown().await; + assert!( + tokio::time::timeout(Duration::from_millis(100), client.receive()) + .await + .expect("stopped receive remained pending") + .is_err() + ); + assert!(client.send(packet()).await.is_err()); + assert_eq!(peer.read(&mut [0; 1]).await.unwrap(), 0); + } +} + +#[tokio::test] +async fn raw_and_packetaddr_consume_wire_and_keep_cancelled_receive_progress() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "VLESS-UNIT", + "raw_and_packetaddr_consume_wire_and_keep_cancelled_receive_progress", + ); + for encoding in ["none", "packetaddr", "packet"] { + let (io, mut peer) = tokio::io::duplex(1024); + let outbound = node(Box::new(io), encoding); + let mut client = outbound + .open_datagram( + DatagramRequest::new(session()), + &EstablishContext::default(), + ) + .await + .unwrap(); + let datagram = packet(); + client.send(datagram.clone()).await.unwrap(); + let target = if encoding == "none" { + datagram.remote.clone() + } else { + Destination::domain("sp.packet-addr.v2fly.arpa", 443).unwrap() + }; + let header = vcore::outbound::encode_request_header( + uuid::Uuid::from_bytes([7; 16]), + vcore::outbound::VlessCommand::Udp, + Some(&target), + ) + .unwrap(); + let mut received = vec![0; header.len()]; + peer.read_exact(&mut received).await.unwrap(); + assert_eq!(received, header); + let length = peer.read_u16().await.unwrap() as usize; + let mut frame = vec![0; length]; + peer.read_exact(&mut frame).await.unwrap(); + let mut expected = bytes::BytesMut::new(); + if encoding != "none" { + vcore::outbound::address::encode_packet_addr(&datagram.remote, &mut expected).unwrap(); + } + expected.extend_from_slice(&datagram.payload); + assert_eq!(frame, expected); + peer.write_all(&[0, 0, (length >> 8) as u8]).await.unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(10), client.receive()) + .await + .is_err() + ); + peer.write_all(&[length as u8]).await.unwrap(); + peer.write_all(&frame[..2]).await.unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(10), client.receive()) + .await + .is_err() + ); + peer.write_all(&frame[2..]).await.unwrap(); + let response = client.receive().await.unwrap(); + assert_eq!(response.remote, datagram.remote); + assert_eq!(response.payload, datagram.payload); + if encoding == "none" { + let mut other = datagram.clone(); + other.remote = Destination::Ip("192.0.2.2:53".parse().unwrap()); + assert!(client.send(other).await.is_err()); + } + client.close().await.unwrap(); + assert_eq!(peer.read(&mut [0; 1]).await.unwrap(), 0); + } +} + +#[tokio::test] +async fn udp_cancelled_send_and_bad_response_header_close_owned_io() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "VLESS-UNIT", + "udp_cancelled_send_and_bad_response_header_close_owned_io", + ); + for encoding in ["none", "packetaddr", "xudp"] { + let (io, mut peer) = tokio::io::duplex(if encoding == "xudp" { 20 } else { 1 }); + let outbound = node(Box::new(io), encoding); + let mut client = outbound + .open_datagram( + DatagramRequest::new(session()), + &EstablishContext::default(), + ) + .await + .unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(10), client.send(packet())) + .await + .is_err() + ); + tokio::time::timeout( + Duration::from_millis(100), + peer.read_to_end(&mut Vec::new()), + ) + .await + .expect("send cancellation retained IO") + .unwrap(); + assert!(client.send(packet()).await.is_err()); + assert!(client.receive().await.is_err()); + + let (io, mut peer) = tokio::io::duplex(1024); + let outbound = node(Box::new(io), encoding); + let mut client = outbound + .open_datagram( + DatagramRequest::new(session()), + &EstablishContext::default(), + ) + .await + .unwrap(); + client.send(packet()).await.unwrap(); + peer.write_all(&[1, 0]).await.unwrap(); + assert!(client.receive().await.is_err()); + assert!(client.send(packet()).await.is_err()); + tokio::time::timeout( + Duration::from_millis(100), + peer.read_to_end(&mut Vec::new()), + ) + .await + .unwrap() + .unwrap(); + } +} + +#[tokio::test] +async fn tcp_bad_response_header_poisoning_and_absolute_response_deadline() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "VLESS-UNIT", + "tcp_bad_response_header_poisoning_and_absolute_response_deadline", + ); + for invalid_header in [true, false] { + let (io, mut peer) = tokio::io::duplex(1024); + let outbound = node(Box::new(io), "xudp"); + let context = EstablishContext::with_timeout(Duration::from_millis(30)); + let mut connected = outbound + .connect_stream( + StreamSession { + inbound: session().inbound, + source: session().source, + destination: packet().remote, + sniffed_domain: None, + }, + &context, + ) + .await + .unwrap(); + connected.io.write_all(b"request").await.unwrap(); + if invalid_header { + peer.write_all(&[1, 0]).await.unwrap(); + } + let result = + tokio::time::timeout(Duration::from_millis(100), connected.io.read(&mut [0; 1])) + .await + .expect("response did not retain setup deadline"); + assert!(result.is_err()); + assert!(connected.io.write_all(b"after failure").await.is_err()); + tokio::time::timeout( + Duration::from_millis(100), + peer.read_to_end(&mut Vec::new()), + ) + .await + .expect("failed response retained IO") + .unwrap(); + } +} diff --git a/tests/vless_config.rs b/tests/vless_config.rs new file mode 100644 index 0000000..0170154 --- /dev/null +++ b/tests/vless_config.rs @@ -0,0 +1,213 @@ +#![cfg(feature = "outbound-vless")] +use serde_json::{Value, json}; +use vcore::config::Config; + +#[test] +fn inline_client_identity_is_validated_and_redacted_before_io() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "VLESS-UNIT", + "inline_client_identity_is_validated_and_redacted_before_io", + ); + use base64::Engine as _; + let pem = |kind: &str, der: &[u8]| { + format!( + "-----BEGIN {kind}-----\n{}\n-----END {kind}-----\n", + base64::engine::general_purpose::STANDARD.encode(der) + ) + }; + let identity = rcgen::generate_simple_self_signed(vec!["fixture.invalid".into()]).unwrap(); + let certificate = pem("CERTIFICATE", identity.cert.der()); + let private_key = pem("PRIVATE KEY", &identity.signing_key.serialize_der()); + let config = Config::parse_yaml(&document( + json!({"tls":true,"certificate":certificate,"private-key":private_key}), + )) + .unwrap(); + assert!(!format!("{config:?}").contains("BEGIN")); + let other = rcgen::generate_simple_self_signed(vec!["other.invalid".into()]).unwrap(); + assert!(Config::parse_yaml(&document(json!({"tls":true,"certificate":certificate,"private-key":pem("PRIVATE KEY",&other.signing_key.serialize_der())}))).is_err()); + assert!(Config::parse_yaml(&document(json!({"tls":true,"certificate":certificate,"private-key":format!("{private_key}{private_key}")}))).is_err()); +} + +fn document(extra: Value) -> Vec { + let mut node = json!({"name":"edge","type":"vless","server":"example.com","port":443,"uuid":"07070707-0707-0707-0707-070707070707"}); + node.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + serde_json::to_vec(&json!({"socks-port":1080,"proxies":[node],"rules":["MATCH,edge"]})).unwrap() +} + +#[test] +fn vision_requires_tcp_tls13_and_xudp_before_any_io() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "VLESS-UNIT", + "vision_requires_tcp_tls13_and_xudp_before_any_io", + ); + use vcore::config::{ProxyProtocol, SecurityConfig}; + let config = + Config::parse_yaml(&document(json!({"flow":"xtls-rprx-vision","tls":true}))).unwrap(); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + unreachable!() + }; + let SecurityConfig::Tls(tls) = &node.security else { + unreachable!() + }; + assert!(tls.tls13_only); + for fields in [ + json!({"flow":"xtls-rprx-vision"}), + json!({"flow":"xtls-rprx-vision","tls":true,"packet-encoding":"none"}), + json!({"flow":"xtls-rprx-vision","tls":true,"packet-encoding":"packetaddr"}), + json!({"flow":"xtls-rprx-vision","tls":true,"network":"ws"}), + json!({"flow":"xtls-rprx-vision","tls":true,"network":"grpc","grpc-opts":{"grpc-service-name":"test"}}), + json!({"flow":"xtls-rprx-vision","tls":true,"network":"xhttp"}), + json!({"flow":"xtls-rprx-vision-udp443","tls":true}), + ] { + assert!(Config::parse_yaml(&document(fields)).is_err()); + } +} + +#[test] +fn default_tcp_and_three_udp_encodings_are_accepted_without_io() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "VLESS-UNIT", + "default_tcp_and_three_udp_encodings_are_accepted_without_io", + ); + let config = Config::parse_yaml(&document(json!({}))).expect("default plaintext TCP"); + assert!(!config.proxies[0].udp); + assert!(!format!("{config:?}").contains("07070707")); + for encoding in ["xudp", "none", "packetaddr", "packet"] { + for tls in [false, true] { + Config::parse_yaml(&document( + json!({"network":"tcp","tls":tls,"udp":true,"packet-encoding":encoding}), + )) + .expect("VLESS TCP UDP encoding"); + } + } +} + +#[test] +fn tcp_and_existing_xhttp_reject_mismatched_and_future_options() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "VLESS-UNIT", + "tcp_and_existing_xhttp_reject_mismatched_and_future_options", + ); + for fields in [ + json!({"network":"tcp","xhttp-opts":{}}), + json!({"network":"xhttp","tls":false,"fingerprint":"ab".repeat(32)}), + json!({"tls":false,"servername":"secret.invalid"}), + json!({"tls":false,"alpn":[]}), + json!({"packet-encoding":""}), + json!({"packet-encoding":"raw"}), + json!({"flow":"invalid-flow"}), + json!({"encryption":"auto"}), + json!({"future":true}), + ] { + assert!(Config::parse_yaml(&document(fields)).is_err()); + } + for key in [ + "name", + "server", + "port", + "uuid", + "network", + "tls", + "udp", + "flow", + "encryption", + "packet-encoding", + "servername", + "alpn", + "dialer-proxy", + "xhttp-opts", + "reality-opts", + ] { + assert!( + Config::parse_yaml(&document(json!({key:null}))).is_err(), + "null {key}" + ); + } + Config::parse_yaml(&document( + json!({"network":"xhttp","tls":true,"alpn":["h2"],"xhttp-opts":{"mode":"auto"}}), + )) + .expect("existing XHTTP"); +} + +#[test] +fn stream_transports_and_explicit_tls_policy_have_strict_public_fields() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "VLESS-UNIT", + "stream_transports_and_explicit_tls_policy_have_strict_public_fields", + ); + for transport in [ + json!({"network":"ws","ws-opts":{"path":"/vless","headers":{"Host":"localhost:443"},"max-early-data":64}}), + json!({"network":"grpc","grpc-opts":{"grpc-service-name":"vless"}}), + json!({"network":"http","http-opts":{"method":"POST","path":["/one","/two"]}}), + json!({"network":"h2","h2-opts":{"host":["localhost"],"path":"/vless"}}), + ] { + for tls in [false, true] { + let mut fields = transport.clone(); + fields["tls"] = json!(tls); + if tls { + fields["skip-cert-verify"] = json!(true); + fields["name-cert-verify"] = json!("verified.invalid"); + fields["fingerprint"] = json!("ab".repeat(32)); + } + Config::parse_yaml(&document(fields)).expect("shared transport and TLS policy"); + } + } + for field in [ + "skip-cert-verify", + "fingerprint", + "name-cert-verify", + "certificate", + "private-key", + "ws-opts", + "grpc-opts", + "http-opts", + "h2-opts", + ] { + assert!(Config::parse_yaml(&document(json!({"tls":true,field:null}))).is_err()); + } + for fields in [ + json!({"skip-cert-verify":false}), + json!({"tls":true,"fingerprint":"invalid"}), + json!({"tls":true,"name-cert-verify":""}), + json!({"tls":true,"certificate":"bad"}), + json!({"tls":true,"certificate":"bad","private-key":"bad"}), + json!({"network":"tcp","ws-opts":{}}), + json!({"network":"h2","h2-opts":{"host":[]}}), + json!({"network":"grpc","grpc-opts":{"grpc-service-name":"vless","future":1}}), + ] { + assert!(Config::parse_yaml(&document(fields)).is_err()); + } +} + +#[test] +fn extended_ws_and_grpc_fields_are_scoped_and_bounded() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "VLESS-UNIT", + "extended_ws_and_grpc_fields_are_scoped_and_bounded", + ); + for fields in [ + json!({"network":"ws","ws-opts":{"v2ray-http-upgrade":true,"v2ray-http-upgrade-fast-open":true,"max-early-data":1}}), + json!({"network":"grpc","grpc-opts":{"grpc-service-name":"test","grpc-user-agent":"probe","ping-interval":1,"max-connections":2,"min-streams":3}}), + json!({"network":"grpc","grpc-opts":{"grpc-service-name":"test","max-streams":2}}), + ] { + Config::parse_yaml(&document(fields)).expect("VLESS transport extension"); + } + for fields in [ + json!({"network":"ws","ws-opts":{"v2ray-http-upgrade-fast-open":true}}), + json!({"network":"ws","ws-opts":{"v2ray-http-upgrade":true,"max-early-data":1,"early-data-header-name":"x-ed"}}), + json!({"network":"ws","ws-opts":{"v2ray-http-upgrade":null}}), + json!({"network":"grpc","grpc-opts":{"grpc-service-name":"test","max-connections":1,"max-streams":2}}), + json!({"network":"grpc","grpc-opts":{"grpc-service-name":"test","ping-interval":-1}}), + json!({"network":"grpc","grpc-opts":{"grpc-service-name":"test","grpc-user-agent":"bad\r\nvalue"}}), + ] { + assert!(Config::parse_yaml(&document(fields)).is_err()); + } +} diff --git a/tests/vless_lifecycle.rs b/tests/vless_lifecycle.rs new file mode 100644 index 0000000..7082046 --- /dev/null +++ b/tests/vless_lifecycle.rs @@ -0,0 +1,201 @@ +#![cfg(all(unix, feature = "outbound-vless"))] +use std::{ + io, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; +use vcore::{ + config::{Config, ProxyProtocol}, + dialer::{Dialer, ResolvedEndpoint, SocketProtector}, + outbound::{EstablishContext, OutboundConnector, UpstreamPath, VlessOutbound}, + session::{InboundKind, StreamSession}, +}; + +struct SuppliedStream { + io: std::sync::Mutex>, +} +#[async_trait::async_trait] +impl OutboundConnector for SuppliedStream { + async fn connect_stream( + &self, + session: StreamSession, + _context: &EstablishContext, + ) -> Result { + tokio::time::sleep(Duration::from_millis(150)).await; + Ok(vcore::outbound::ConnectedStream { + io: Box::new(self.io.lock().unwrap().take().unwrap()), + effective_peer: session.destination, + }) + } + async fn open_datagram( + &self, + _: vcore::outbound::DatagramRequest, + _: &EstablishContext, + ) -> Result, vcore::dispatch::DispatchError> { + Err(vcore::dispatch::DispatchError::NotAllowed) + } +} + +#[tokio::test] +async fn vless_all_handshakes_keep_the_original_deadline_and_join_cancelled_io() { + #[cfg(feature = "interop-test")] + let _evidence = vcore::resources::case_events::Case::new( + "VLESS-CANCEL", + "vless_all_handshakes_keep_the_original_deadline_and_join_cancelled_io", + ); + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + for mode in ["tcp", "tls", "ws", "grpc", "http", "h2", "vision", "xhttp"] { + let mut node = serde_json::json!({"name":"edge","type":"vless","server":"fixture.invalid","port":443,"uuid":"07070707-0707-0707-0707-070707070707","network":if mode=="tls" || mode=="vision" {"tcp"}else{mode}}); + match mode { + "tls" | "xhttp" | "vision" => { + if mode == "vision" { + node["flow"] = serde_json::json!("xtls-rprx-vision"); + } + node["tls"] = serde_json::json!(true); + } + "grpc" => node["grpc-opts"] = serde_json::json!({"grpc-service-name":"edge"}), + "h2" => node["h2-opts"] = serde_json::json!({"host":["fixture.invalid"]}), + _ => {} + } + let parsed = Config::parse_yaml( + serde_json::json!({"socks-port":1080,"proxies":[node],"rules":["MATCH,edge"]}) + .to_string() + .as_bytes(), + ) + .unwrap(); + let ProxyProtocol::Vless(node) = &parsed.proxies[0].protocol else { + unreachable!() + }; + let (io, mut peer) = tokio::io::duplex(65536); + let outbound = VlessOutbound::new_with_path( + node, + UpstreamPath::proxy(Arc::new(SuppliedStream { + io: std::sync::Mutex::new(Some(io)), + })), + ) + .unwrap(); + let session = StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:1".parse().unwrap(), + destination: "192.0.2.2:80" + .parse::() + .unwrap() + .into(), + sniffed_domain: None, + }; + let start = tokio::time::Instant::now(); + let context = EstablishContext::with_timeout(Duration::from_millis(250)); + let task = async { + if let Ok(mut connected) = outbound.connect_stream(session, &context).await { + let _ = connected.io.write_all(b"first").await; + let _ = connected.io.flush().await; + assert!(connected.io.read(&mut [0; 1]).await.is_err()); + } + }; + tokio::time::timeout(Duration::from_millis(325), task) + .await + .expect("consumer reset the original deadline"); + assert!(start.elapsed() >= Duration::from_millis(240)); + tokio::time::timeout(Duration::from_secs(1), outbound.shutdown()) + .await + .unwrap(); + let mut observed = Vec::new(); + tokio::time::timeout(Duration::from_secs(1), peer.read_to_end(&mut observed)) + .await + .unwrap() + .unwrap(); + assert!(!observed.is_empty()); + assert!(observed.len() <= 65536); + } +} + +struct RejectProtect(AtomicUsize); +impl SocketProtector for RejectProtect { + fn protect(&self, _: i32) -> io::Result<()> { + self.0.fetch_add(1, Ordering::SeqCst); + Err(io::ErrorKind::PermissionDenied.into()) + } +} + +#[test] +fn split_xhttp_construction_fits_the_runtime_stack_and_protect_failure_has_no_fallback() { + // Same stack budget as the Invoke runtime. No server or outbound packet: + // the first socket is rejected by the host-owned protection boundary. + std::thread::Builder::new().stack_size(1024 * 1024).spawn(|| { + tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + for alpn in ["h3", "h2", "http/1.1"] { + let raw=serde_json::json!({"socks-port":1080,"proxies":[{"name":"edge","type":"vless","server":"192.0.2.1","port":443,"uuid":"07070707-0707-0707-0707-070707070707","network":"xhttp","tls":true,"alpn":[alpn],"xhttp-opts":{"mode":"packet-up","reuse-settings":{},"download-settings":{"reuse-settings":{}}}}],"rules":["MATCH,edge"]}); + let parsed=Config::parse_yaml(raw.to_string().as_bytes()).unwrap(); + let ProxyProtocol::Vless(config)=&parsed.proxies[0].protocol else {unreachable!()}; + let protect=Arc::new(RejectProtect(AtomicUsize::new(0))); + let endpoint=ResolvedEndpoint {logical_host:"192.0.2.1".into(),port:443,addresses:vec!["192.0.2.1:443".parse().unwrap()]}; + let outbound=VlessOutbound::new(config, endpoint, Dialer::default().with_protector(protect.clone())).unwrap(); + let session=StreamSession {inbound:InboundKind::InternalMeasure,source:"127.0.0.1:1".parse().unwrap(),destination:"192.0.2.2:80".parse::().unwrap().into(),sniffed_domain:None}; + assert!(outbound.connect_stream(session, &EstablishContext::default()).await.is_err()); + assert!((1..=2).contains(&protect.0.load(Ordering::SeqCst))); + outbound.shutdown().await; + } + }); + }).unwrap().join().unwrap(); +} + +#[tokio::test] +async fn vless_expired_deadline_and_protect_failure_never_fall_back() { + #[cfg(feature = "interop-test")] + let _evidence = vcore::resources::case_events::Case::new( + "VLESS-CANCEL", + "vless_expired_deadline_and_protect_failure_never_fall_back", + ); + let parsed = Config::parse_yaml(b"socks-port: 1080\nproxies: [{name: edge, type: vless, server: 192.0.2.1, port: 443, uuid: 07070707-0707-0707-0707-070707070707}]\nrules: ['MATCH,edge']").unwrap(); + let ProxyProtocol::Vless(config) = &parsed.proxies[0].protocol else { + unreachable!() + }; + let protect = Arc::new(RejectProtect(AtomicUsize::new(0))); + let endpoint = ResolvedEndpoint { + logical_host: "192.0.2.1".into(), + port: 443, + addresses: vec!["192.0.2.1:443".parse().unwrap()], + }; + let outbound = VlessOutbound::new_with_path( + config, + UpstreamPath::direct(endpoint, Dialer::default().with_protector(protect.clone())), + ) + .unwrap(); + let session = StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:1".parse().unwrap(), + destination: "192.0.2.2:80" + .parse::() + .unwrap() + .into(), + sniffed_domain: None, + }; + assert!( + outbound + .connect_stream( + session.clone(), + &EstablishContext::with_timeout(Duration::ZERO) + ) + .await + .is_err() + ); + assert_eq!(protect.0.load(Ordering::SeqCst), 0); + assert!( + outbound + .connect_stream(session.clone(), &EstablishContext::default()) + .await + .is_err() + ); + assert_eq!(protect.0.load(Ordering::SeqCst), 1); + outbound.shutdown().await; + assert!( + outbound + .connect_stream(session, &EstablishContext::default()) + .await + .is_err() + ); + assert_eq!(protect.0.load(Ordering::SeqCst), 1); +} diff --git a/tests/vless_native.rs b/tests/vless_native.rs new file mode 100644 index 0000000..fb8d263 --- /dev/null +++ b/tests/vless_native.rs @@ -0,0 +1,822 @@ +#![cfg(all(feature = "outbound-vless", feature = "interop-test"))] +use serde_json::{Value, json}; + +#[tokio::test] +#[ignore = "requires the owned container harness"] +async fn native_vision_inner_tls() { + use vcore::security::{ + SecurityContext, StandardTlsClient, TlsCertificatePolicy, TlsClientOptions, + }; + let name = "native_vision_inner_tls"; + event(name, "BEGIN"); + let fixture = fixture(); + assert_eq!(fixture["vision_probe"], true); + let mut fingerprint = [0; 32]; + let pin = fixture["origin_pin"].as_str().unwrap(); + for (index, byte) in fingerprint.iter_mut().enumerate() { + *byte = u8::from_str_radix(&pin[index * 2..index * 2 + 2], 16).unwrap(); + } + tokio::time::timeout(Duration::from_secs(120), async { + for (mode, version) in [(16, 0x13), (18, 0x12)] { + // Keep one real node across destination families, so Encryption's + // 0-RTT mode also exercises Vision on reused native tickets. + let outbound = node(&fixture, None); + for family in ["ipv4", "ipv6", "domain"] { + let (mut control, target) = origin(&fixture, mode, family).await; + let before = outbound.vision_raw_bytes(); + let stream = outbound + .connect_stream(session(target), &EstablishContext::default()) + .await + .unwrap() + .io; + let inner = StandardTlsClient::with_options( + &SecurityContext::new(), + "localhost", + TlsClientOptions { + certificate: TlsCertificatePolicy { + fingerprint: Some(fingerprint), + ..Default::default() + }, + ..Default::default() + }, + 0, + 65536, + ) + .unwrap(); + let mut stream = + tokio::time::timeout(Duration::from_secs(10), inner.connect(stream)) + .await + .unwrap() + .unwrap(); + assert_eq!(control.read_u8().await.unwrap(), b'A'); + assert_eq!(control.read_u8().await.unwrap(), version); + let mut hello = [0; 5]; + stream.read_exact(&mut hello).await.unwrap(); + assert_eq!(&hello, b"hello"); + let payload = vec![0x5a; 10 * 1024 * 1024]; + stream.write_all(&payload).await.unwrap(); + stream.flush().await.unwrap(); + let mut response = vec![0; payload.len() + 7]; + stream.read_exact(&mut response).await.unwrap(); + assert_eq!(&response[..payload.len()], payload.as_slice()); + assert_eq!(&response[payload.len()..], b"trailer"); + let (read, written) = outbound.vision_raw_bytes(); + let (read, written) = (read - before.0, written - before.1); + if version == 0x13 { + assert!( + read > 9 * 1024 * 1024 && written > 9 * 1024 * 1024, + "no actual bidirectional direct-mode IO" + ); + } else { + assert_eq!( + (read, written), + (0, 0), + "TLS 1.2 must remain inside outer TLS" + ); + } + stream.shutdown().await.unwrap(); + assert_eq!(control.read_u8().await.unwrap(), b'D'); + drop(stream); + } + outbound.shutdown().await; + } + }) + .await + .unwrap(); + event(name, "PASS"); +} + +#[tokio::test] +#[ignore = "official isolated container peer required"] +async fn native_vision_direct_close_alignment() { + use rustls::pki_types::{CertificateDer, ServerName}; + let name = "native_vision_direct_close_alignment"; + event(name, "BEGIN"); + let fixture = fixture(); + assert_eq!(fixture["vision_probe"], true); + let reference = &fixture["direct_close_reference"]; + assert_eq!(reference["scope"], "same-mode"); + assert_eq!(reference["variant"], "vision-direct"); + assert_eq!(reference["terminated"], true); + let cert: Vec = serde_json::from_value(fixture["origin_root_der"].clone()).unwrap(); + let mut roots = rustls::RootCertStore::empty(); + roots.add(CertificateDer::from(cert)).unwrap(); + let config = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .with_root_certificates(roots) + .with_no_client_auth(); + let connector = tokio_rustls::TlsConnector::from(Arc::new(config)); + tokio::time::timeout(Duration::from_secs(40), async { + let outbound = node(&fixture, None); + for family in ["ipv4", "ipv6"] { + let (mut control, target) = origin(&fixture, 21, family).await; + let before = outbound.vision_raw_bytes(); + let io = outbound + .connect_stream(session(target), &EstablishContext::default()) + .await + .unwrap() + .io; + let mut tls = connector + .connect(ServerName::try_from("localhost").unwrap(), io) + .await + .unwrap(); + assert_eq!(control.read_u8().await.unwrap(), b'A'); + assert_eq!(control.read_u8().await.unwrap(), 0x13); + let mut greeting = [0; 5]; + tls.read_exact(&mut greeting).await.unwrap(); + assert_eq!(&greeting, b"hello"); + let payload = vec![b'Z'; 65536]; + tls.write_all(&payload).await.unwrap(); + tls.flush().await.unwrap(); + let mut echo = vec![0; payload.len()]; + tls.read_exact(&mut echo).await.unwrap(); + assert_eq!(echo, payload); + let after = outbound.vision_raw_bytes(); + assert!(after.0 - before.0 > 32768 && after.1 - before.1 > 32768); + // Upload EOF below the real inner TLS session, without adding a + // TLS close_notify. The independent client does exactly the same. + tls.get_mut().0.shutdown().await.unwrap(); + let mut tail = Vec::new(); + let _terminal = tls.read_to_end(&mut tail).await; + let hex: String = tail.iter().map(|byte| format!("{byte:02x}")).collect(); + assert_eq!(hex, reference["tail_hex"].as_str().unwrap()); + assert_eq!(control.read_u8().await.unwrap(), b'D'); + drop(tls); + } + outbound.shutdown().await; + }) + .await + .expect("direct close did not terminate"); + event(name, "PASS"); +} + +#[tokio::test] +#[ignore = "official isolated container peer required"] +async fn native_mihomo_close_alignment() { + let fixture = fixture(); + let node = &fixture["node"]; + let jls_grpc_without_profile = node["network"] == "grpc" + && node["jls-opts"].is_object() + && matches!( + node["client-fingerprint"].as_str(), + None | Some("" | "none") + ); + let ech_safari_grpc = node["network"] == "grpc" + && node["ech-opts"]["enable"] == true + && matches!( + node["client-fingerprint"].as_str(), + Some("safari" | "safari16") + ); + let scope = if jls_grpc_without_profile || ech_safari_grpc { + assert_eq!(fixture["close_reference"]["client_fingerprint"], "chrome"); + assert_eq!( + fixture["close_reference"]["dut_client_fingerprint"], + node["client-fingerprint"] + ); + if jls_grpc_without_profile { + "jls-grpc-chrome-baseline" + } else { + "ech-safari-chrome-baseline" + } + } else { + "same-mode" + }; + compare_native_close("native_mihomo_close_alignment", scope).await; +} + +#[tokio::test] +#[ignore = "official isolated peers required; layered reference, not same-mode"] +async fn native_ws_reality_close_boundary() { + compare_native_close( + "native_ws_reality_close_boundary", + "ws-standard-tls-baseline", + ) + .await; +} + +async fn compare_native_close(name: &str, scope: &str) { + event(name, "BEGIN"); + let fixture = fixture(); + assert_eq!(fixture["close_reference"]["scope"], scope); + tokio::time::timeout(Duration::from_secs(15), async { + let (mut control, target) = origin(&fixture, 11, "ipv4").await; + let outbound = node(&fixture, None); + let mut io = outbound + .connect_stream(session(target), &EstablishContext::default()) + .await + .unwrap() + .io; + let mut hello = [0; 5]; + io.read_exact(&mut hello).await.unwrap(); + assert_eq!(&hello, b"hello"); + io.write_all(b"ping").await.unwrap(); + io.flush().await.unwrap(); + let mut echo = [0; 4]; + io.read_exact(&mut echo).await.unwrap(); + assert_eq!(&echo, b"ping"); + io.shutdown().await.unwrap(); + let mut tail = Vec::new(); + let _terminal = io.read_to_end(&mut tail).await; + let hex: String = tail.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(fixture["close_reference"]["terminated"], true); + assert_eq!( + hex, + fixture["close_reference"]["tail_hex"].as_str().unwrap(), + "explicitly scoped close reference" + ); + assert_eq!(control.read_u8().await.unwrap(), b'A'); + assert_eq!(control.read_u8().await.unwrap(), b'D'); + drop(io); + outbound.shutdown().await; + }) + .await + .expect("close did not terminate"); + event(name, "PASS"); +} + +#[tokio::test] +#[ignore = "official isolated container peer required"] +async fn native_grpc_pool_thresholds() { + use vcore::resources::observation::{ResourceKind, ResourceProbe}; + let name = "native_grpc_pool_thresholds"; + event(name, "BEGIN"); + let original = fixture(); + for (max, min, streams, expected) in [ + (1, 0, 0, [1, 1, 1, 1]), + (2, 2, 0, [1, 1, 2, 2]), + (2, 0, 0, [1, 2, 2, 2]), + (0, 0, 2, [1, 1, 2, 2]), + (0, 2, 0, [1, 2, 3, 4]), + ] { + let probe = ResourceProbe::default(); + probe + .scope(async { + let mut fixture = original.clone(); + fixture["node"]["grpc-opts"]["max-connections"] = json!(max); + fixture["node"]["grpc-opts"]["min-streams"] = json!(min); + fixture["node"]["grpc-opts"]["max-streams"] = json!(streams); + let outbound = node(&fixture, None); + let mut held = Vec::new(); + for connections in expected { + let (mut observer, target) = origin(&fixture, 13, "ipv4").await; + let mut io = outbound + .connect_stream(session(target), &EstablishContext::default()) + .await + .unwrap() + .io; + io.write_all(b"a").await.unwrap(); + io.flush().await.unwrap(); + assert_eq!(io.read_u8().await.unwrap(), b'a'); + assert_eq!(observer.read_u8().await.unwrap(), b'A'); + assert_eq!( + probe.snapshot().current(ResourceKind::Socket), + connections, + "actual native physical socket count" + ); + held.push((io, observer)); + } + for (mut io, mut observer) in held { + io.shutdown().await.unwrap(); + drop(io); + assert_eq!(observer.read_u8().await.unwrap(), b'D'); + } + outbound.shutdown().await; + assert!(probe.snapshot().is_idle(), "{:?}", probe.snapshot()); + }) + .await; + } + event(name, "PASS"); +} +use std::{ + io, + net::{IpAddr, SocketAddr}, + sync::Arc, + time::Duration, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use vcore::{ + config::{Config, ProxyProtocol}, + dialer::{Dialer, ResolvedEndpoint, Resolver}, + dispatch::DatagramBudget, + dns::resolution::ResolutionContext, + outbound::{DatagramRequest, EstablishContext, OutboundConnector, VlessOutbound}, + session::{Datagram, DatagramSession, Destination, InboundKind, StreamSession}, +}; + +fn fixture() -> Value { + let value: Value = serde_json::from_slice( + &std::fs::read(std::env::var("VCORE_VLESS_INPUT").expect("container harness required")) + .unwrap(), + ) + .unwrap(); + assert_eq!(value["isolation"], "containers"); + value +} +fn event(name: &str, status: &str) { + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .append(true) + .create(true) + .open(std::env::var("VCORE_CASE_EVENTS").unwrap()) + .unwrap(); + writeln!( + file, + "{}", + json!({"schema_version":1,"suite":if std::env::var("VCORE_PROTOCOL_STAGE").as_deref() == Ok("SECURITY") {"SECURITY-WIRE"} else {"VLESS-WIRE"},"assertion":name,"status":status}) + ) + .unwrap(); +} +fn node(fixture: &Value, encoding: Option<&str>) -> VlessOutbound { + let mut node = fixture["node"].clone(); + if let Some(encoding) = encoding { + node["packet-encoding"] = json!(encoding); + } + let config = Config::parse_yaml( + json!({"socks-port":1080,"proxies":[node],"rules":[format!("MATCH,{}",node["name"].as_str().unwrap())]}) + .to_string() + .as_bytes(), + ) + .unwrap(); + let ProxyProtocol::Vless(config) = &config.proxies[0].protocol else { + unreachable!() + }; + let config = config.clone(); + let endpoint = ResolvedEndpoint { + logical_host: config.address.clone(), + port: config.port, + addresses: vec![SocketAddr::new( + config.address.parse().unwrap(), + config.port, + )], + }; + let download_endpoint = config.download().map(|download| ResolvedEndpoint { + logical_host: download.address.clone(), + port: download.port, + addresses: vec![SocketAddr::new( + download.address.parse().unwrap(), + download.port, + )], + }); + if matches!(config.security, vcore::config::SecurityConfig::Tls(_)) { + let root: Vec = serde_json::from_value(fixture["root_der"].clone()).unwrap(); + VlessOutbound::new_with_test_tls_roots_and_endpoints( + &config, + endpoint, + download_endpoint, + Dialer::default(), + [root], + ) + .unwrap() + } else { + VlessOutbound::new_with_endpoints(&config, endpoint, download_endpoint, Dialer::default()) + .unwrap() + } +} + +#[test] +fn native_fixture_prepares_the_independent_download_endpoint() { + let f = json!({"node": { + "name": "peer", "type": "vless", "server": "192.0.2.1", "port": 443, + "uuid": "00000000-0000-4000-8000-000000000001", "network": "xhttp", + "tls": true, "servername": "fixture.test", + "reality-opts": { + "public-key": "CQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "support-x25519mlkem768": true + }, + "xhttp-opts": { + "mode": "stream-up", "path": "/x", + "download-settings": {"port": 444} + } + }}); + let _outbound = node(&f, None); +} + +async fn origin(fixture: &Value, mode: u8, family: &str) -> (tokio::net::TcpStream, Destination) { + let mut control = tokio::net::TcpStream::connect(fixture["origin_control"].as_str().unwrap()) + .await + .unwrap(); + control.set_nodelay(true).unwrap(); + control + .write_u8(if family == "ipv6" && mode >= 10 { + mode | 0x80 + } else if mode < 10 { + if family == "ipv6" { 6 } else { 4 } + } else { + mode + }) + .await + .unwrap(); + let port = control.read_u16().await.unwrap(); + let ip: IpAddr = fixture[if family == "ipv6" { + "origin_ipv6" + } else { + "origin_ipv4" + }] + .as_str() + .unwrap() + .parse() + .unwrap(); + assert!(!ip.is_loopback() && !ip.is_unspecified()); + let target = if family == "domain" { + Destination::domain("vcore-fixture.test", port).unwrap() + } else { + SocketAddr::new(ip, port).into() + }; + (control, target) +} +fn session(target: Destination) -> StreamSession { + StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:1".parse().unwrap(), + destination: target, + sniffed_domain: None, + } +} + +#[tokio::test] +#[ignore = "isolated SECURITY JLS runner"] +async fn native_jls_fail_closed() { + let name = "native_jls_fail_closed"; + event(name, "BEGIN"); + let original = fixture(); + let split = original["node"]["xhttp-opts"]["download-settings"].is_object(); + for download in [false, true] + .into_iter() + .filter(|download| !download || split) + { + for field in ["username", "password"] { + let mut f = original.clone(); + let credentials = f["node"]["jls-opts"].clone(); + let leg = if download { + let leg = &mut f["node"]["xhttp-opts"]["download-settings"]; + leg["jls-opts"] = credentials; + leg + } else { + &mut f["node"] + }; + leg["jls-opts"][field] = json!("incorrect-credential"); + let (mut control, destination) = origin(&f, 13, "ipv4").await; + let outbound = node(&f, None); + let failure = tokio::time::timeout(Duration::from_secs(5), async { + let mut io = outbound + .connect_stream(session(destination), &EstablishContext::default()) + .await + .map_err(|_| std::io::Error::other("connection rejected"))? + .io; + io.write_all(b"must-not-arrive").await?; + io.flush().await?; + io.read_u8().await + }) + .await + .expect("JLS rejection must not be a timeout"); + assert!( + failure.is_err(), + "invalid JLS identity admitted a business stream" + ); + outbound.shutdown().await; + assert!( + tokio::time::timeout(Duration::from_millis(200), control.read_u8()) + .await + .is_err(), + "JLS rejection must open no origin connection" + ); + } + } + // Use the same public node factory after failures; no fallback or stale identity. + let (mut control, destination) = origin(&original, 13, "ipv4").await; + let outbound = node(&original, None); + let mut io = outbound + .connect_stream(session(destination), &EstablishContext::default()) + .await + .unwrap() + .io; + io.write_all(b"authenticated").await.unwrap(); + io.flush().await.unwrap(); + let mut response = [0; 13]; + io.read_exact(&mut response).await.unwrap(); + assert_eq!(&response, b"authenticated"); + assert_eq!(control.read_u8().await.unwrap(), b'A'); + drop(io); + outbound.shutdown().await; + event(name, "PASS"); +} + +#[tokio::test] +#[ignore = "isolated SECURITY ECH runner"] +async fn native_ech_fail_closed() { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + let name = "native_ech_fail_closed"; + event(name, "BEGIN"); + let original = fixture(); + let split = original["node"]["xhttp-opts"]["download-settings"].is_object(); + for download in [false, true] + .into_iter() + .filter(|download| !download || split) + { + let mut f = original.clone(); + let ech = f["node"]["ech-opts"].clone(); + if split { + f["node"]["xhttp-opts"]["download-settings"]["ech-opts"] = ech.clone(); + } + let leg = if download { + &mut f["node"]["xhttp-opts"]["download-settings"] + } else { + &mut f["node"] + }; + let mut wrong = STANDARD.decode(ech["config"].as_str().unwrap()).unwrap(); + // A well-formed but stale public key must fail during ECH, not parsing. + wrong[11..43].fill(7); + leg["ech-opts"] = json!({"enable":true,"config":STANDARD.encode(wrong)}); + let (mut control, destination) = origin(&f, 13, "ipv4").await; + let outbound = node(&f, None); + let failure = tokio::time::timeout(Duration::from_secs(5), async { + let mut io = outbound + .connect_stream(session(destination), &EstablishContext::default()) + .await + .map_err(|_| std::io::Error::other("connection rejected"))? + .io; + io.write_all(b"must-not-arrive").await?; + io.flush().await?; + io.read_u8().await + }) + .await + .expect("ECH rejection must not be a timeout"); + assert!(failure.is_err(), "rejected ECH admitted a business stream"); + outbound.shutdown().await; + assert!( + tokio::time::timeout(Duration::from_millis(200), control.read_u8()) + .await + .is_err(), + "ECH rejection opened an origin connection" + ); + } + event(name, "PASS"); +} + +#[tokio::test] +#[ignore = "isolated SECURITY hybrid REALITY runner"] +async fn native_hybrid_fail_closed() { + use vcore::{config::SecurityConfig, security::SecurityClient}; + let name = "native_hybrid_fail_closed"; + event(name, "BEGIN"); + let original = fixture(); + let (mut observer, target) = origin(&original, 13, "ipv4").await; + let outbound = node(&original, None); + let mut io = outbound + .connect_stream(session(target), &EstablishContext::default()) + .await + .unwrap() + .io; + io.write_all(b"control").await.unwrap(); + io.flush().await.unwrap(); + let mut reply = [0; 7]; + io.read_exact(&mut reply).await.unwrap(); + assert_eq!(&reply, b"control"); + assert_eq!(observer.read_u8().await.unwrap(), b'A'); + drop(io); + outbound.shutdown().await; + assert_eq!(observer.read_u8().await.unwrap(), b'D'); + + let split = original["node"]["xhttp-opts"]["download-settings"].is_object(); + for download in [false, true] + .into_iter() + .filter(|download| !download || split) + { + for fault in ["public-key", "short-id", "servername"] { + let mut f = original.clone(); + let base_reality = f["node"]["reality-opts"].clone(); + let target = if download { + let leg = &mut f["node"]["xhttp-opts"]["download-settings"]; + leg["reality-opts"] = base_reality; + leg + } else { + &mut f["node"] + }; + match fault { + "public-key" => { + target["reality-opts"][fault] = + json!("CQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + } + "short-id" => target["reality-opts"][fault] = json!("ffffffffffffffff"), + _ => target[fault] = json!("wrong.fixture.test"), + } + let (mut control, destination) = origin(&f, 13, "ipv4").await; + let outbound = node(&f, None); + let failed = tokio::time::timeout(Duration::from_secs(5), async { + let mut io = outbound + .connect_stream(session(destination), &EstablishContext::default()) + .await + .map_err(|_| std::io::Error::other("connection rejected"))? + .io; + io.write_all(b"must-not-arrive").await?; + io.flush().await?; + io.read_u8().await + }) + .await + .expect("authentication failure must not be a timeout"); + assert!( + failed.is_err(), + "REALITY {fault} accepted on download={download}" + ); + outbound.shutdown().await; + assert!( + tokio::time::timeout(Duration::from_millis(200), control.read_u8()) + .await + .is_err(), + "rejected identity reached the origin" + ); + } + } + + let config = Config::parse_yaml( + json!({"socks-port":1080,"proxies":[original["node"]],"rules":["MATCH,peer"]}) + .to_string() + .as_bytes(), + ) + .unwrap(); + let ProxyProtocol::Vless(proxy) = &config.proxies[0].protocol else { + panic!() + }; + let mut legs = vec![&proxy.security]; + if let Some(download) = proxy.download() { + legs.push(&download.security); + } + for security in legs { + assert!(matches!(security, SecurityConfig::Reality(r) if r.support_x25519mlkem768)); + for field in [ + "downgrade_port", + "hrr_port", + "ordinary_tls_port", + "tls12_port", + ] { + let port = original[field].as_u64().unwrap() as u16; + let stream = tokio::net::TcpStream::connect(( + original["node"]["server"].as_str().unwrap(), + port, + )) + .await + .unwrap(); + let client = SecurityClient::from_security(security).unwrap(); + let result = + tokio::time::timeout(Duration::from_secs(5), client.connect(Box::new(stream))) + .await + .expect("peer rejection must not be a timeout"); + assert!(result.is_err(), "required hybrid security accepted {field}"); + } + } + event(name, "PASS"); +} + +#[tokio::test] +#[ignore = "official isolated container peer required"] +async fn native_tcp_integrity_and_server_first() { + let name = "native_tcp_integrity_and_server_first"; + event(name, "BEGIN"); + let fixture = fixture(); + tokio::time::timeout(Duration::from_secs(120), async { + for family in ["ipv4", "ipv6", "domain"] { + let (mut control, target) = origin(&fixture, 10, family).await; + let outbound = node(&fixture, None); + let mut stream = outbound + .connect_stream(session(target), &EstablishContext::default()) + .await + .unwrap() + .io; + let mut first = [0; 5]; + stream.read_exact(&mut first).await.unwrap(); + assert_eq!(&first, b"hello"); + assert_eq!(control.read_u8().await.unwrap(), b'A'); + let body = vec![0x5a; 10 * 1024 * 1024]; + stream.write_all(&body).await.unwrap(); + stream.flush().await.unwrap(); + let mut reply = vec![0; body.len() + 7]; + stream.read_exact(&mut reply).await.unwrap(); + assert_eq!(&reply[..body.len()], body); + assert_eq!(&reply[body.len()..], b"trailer"); + stream.shutdown().await.unwrap(); + drop(stream); + outbound.shutdown().await; + assert_eq!(control.read_u8().await.unwrap(), b'D'); + } + }) + .await + .unwrap(); + event(name, "PASS"); +} + +struct FixtureResolver(IpAddr); +#[async_trait::async_trait] +impl Resolver for FixtureResolver { + async fn resolve(&self, host: &str, port: u16) -> io::Result { + if host != "vcore-fixture.test" { + return Err(io::ErrorKind::NotFound.into()); + } + Ok(ResolvedEndpoint { + logical_host: host.into(), + port, + addresses: vec![SocketAddr::new(self.0, port)], + }) + } +} +#[tokio::test] +#[ignore = "official isolated container peer required"] +async fn native_three_udp_encodings() { + udp_encodings("native_three_udp_encodings", false).await; +} + +#[tokio::test] +#[ignore = "requires the owned container harness"] +async fn native_vision_xudp() { + udp_encodings("native_vision_xudp", true).await; +} + +async fn udp_encodings(name: &str, vision: bool) { + event(name, "BEGIN"); + let fixture = fixture(); + assert_eq!(fixture["vision_probe"].as_bool().unwrap_or(false), vision); + tokio::time::timeout(Duration::from_secs(120), async { + let resolution = ResolutionContext::measurement( + Arc::new(FixtureResolver( + fixture["origin_ipv4"].as_str().unwrap().parse().unwrap(), + )), + true, + ); + for encoding in [None, Some("none"), Some("packetaddr")] { + if vision && encoding.is_some() { + continue; + } + for family in ["ipv4", "ipv6", "domain"] { + let (mut control, target) = origin(&fixture, 4, family).await; + let outbound = node(&fixture, encoding); + // V2Ray's UDP response writer uses a 2048-byte buffer and + // needs room for the length prefix / packetaddr address. + // This fixture-specific cap does not reduce VCore's budget. + let cap = if fixture["peer_kind"] == "V2" { + if encoding.is_none() { + 2048 + } else if encoding == Some("packetaddr") { + 2027 + } else { + 2046 + } + } else { + 15000 + }; + let budget = DatagramBudget::new(cap, cap); + let request = DatagramRequest::new(DatagramSession::new( + InboundKind::InternalMeasure, + "127.0.0.1:1".parse().unwrap(), + )) + .with_budget(budget); + let mut io = outbound + .open_datagram( + request, + &EstablishContext::with_resolution( + Duration::from_secs(10), + resolution.clone(), + ), + ) + .await + .unwrap(); + for size in [1, 64, 512, 1200, cap as usize] { + for index in 0..100 { + let payload: Vec = (0..size).map(|i| (i + index) as u8).collect(); + io.send(Datagram { + remote: target.clone(), + payload: payload.clone().into(), + sniffed_domain: None, + }) + .await + .unwrap(); + let count = control.read_u16().await.unwrap() as usize; + let _source = control.read_u16().await.unwrap(); + assert_eq!(count, size); + let mut observed = vec![0; count]; + control.read_exact(&mut observed).await.unwrap(); + assert_eq!(observed, payload); + let reply = io.receive().await.unwrap(); + assert_eq!(reply.payload, payload); + assert_eq!(reply.remote.port(), target.port()); + } + } + assert!( + io.send(Datagram { + remote: target, + payload: vec![0; cap as usize + 1].into(), + sniffed_domain: None + }) + .await + .is_err() + ); + io.close().await.unwrap(); + outbound.shutdown().await; + } + } + }) + .await + .unwrap(); + event(name, "PASS"); +} diff --git a/tests/vless_public.rs b/tests/vless_public.rs new file mode 100644 index 0000000..282730c --- /dev/null +++ b/tests/vless_public.rs @@ -0,0 +1,835 @@ +//! Public VLESS consumer gate. Every origin/peer is owned by the container runner. +#![cfg(all(feature = "ffi", feature = "outbound-vless", feature = "interop-test"))] +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + ffi::{CStr, CString}, + io::{self, Read, Write}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener, TcpStream, UdpSocket}, + thread, + time::{Duration, Instant}, +}; +use vcore::resources::case_events::Case as RecordedCase; +// The same public consumers are reused by XHTTP. Keep their evidence separate +// instead of letting a current XHTTP run masquerade as historical VLESS evidence. +struct Case; +impl Case { + fn start(suite: &'static str, assertion: &'static str) -> RecordedCase { + let suite = if std::env::var("VCORE_PROTOCOL_STAGE").as_deref() == Ok("XHTTP") { + match suite { + "VLESS-PUBLIC" => "XHTTP-PUBLIC", + "VLESS-BASE" => "XHTTP-BASE", + "VLESS-LIFE" => "XHTTP-LIFE", + "VLESS-OWNED" => "XHTTP-OWNED", + _ => panic!("unknown public consumer suite"), + } + } else if std::env::var("VCORE_PROTOCOL_STAGE").as_deref() == Ok("HYSTERIA2") { + match suite { + "VLESS-PUBLIC" => "HYSTERIA2-PUBLIC", + "VLESS-BASE" => "HYSTERIA2-BASE", + "VLESS-LIFE" => "HYSTERIA2-LIFE", + "VLESS-OWNED" => "HYSTERIA2-OWNED", + _ => panic!("unknown public consumer suite"), + } + } else if std::env::var("VCORE_PROTOCOL_STAGE").as_deref() == Ok("SECURITY") { + match suite { + "VLESS-PUBLIC" => "SECURITY-PUBLIC", + "VLESS-BASE" => "SECURITY-BASE", + "VLESS-LIFE" => "SECURITY-LIFE", + "VLESS-OWNED" => "SECURITY-OWNED", + _ => panic!("unknown public consumer suite"), + } + } else if std::env::var("VCORE_PROTOCOL_STAGE").as_deref() == Ok("INTEGRATION") { + match suite { + "VLESS-PUBLIC" => "INTEGRATION-PUBLIC", + "VLESS-BASE" => "INTEGRATION-BASE", + "VLESS-LIFE" => "INTEGRATION-LIFE", + "VLESS-OWNED" => "INTEGRATION-OWNED", + _ => panic!("unknown public consumer suite"), + } + } else { + suite + }; + RecordedCase::new(suite, assertion) + } +} +const TIMEOUT: Duration = Duration::from_secs(10); +const DOMAIN: &str = "vcore-fixture.test"; + +#[cfg(feature = "outbound-hysteria2")] +#[path = "vless_public/hysteria2.rs"] +mod hysteria2; +#[path = "vless_public/integration/mod.rs"] +mod integration; +#[path = "vless_public/runtime.rs"] +mod runtime; +#[cfg(target_os = "macos")] +#[path = "vmess_public/tun.rs"] +mod tun; + +fn fixture() -> Value { + let value: Value = serde_json::from_slice( + &std::fs::read(std::env::var("VCORE_VLESS_INPUT").expect("use isolated VLESS runner")) + .unwrap(), + ) + .unwrap(); + assert_eq!(value["isolation"], "containers"); + value +} + +#[cfg(feature = "outbound-hysteria2")] +#[test] +#[ignore = "isolated HYSTERIA2 runner"] +fn hysteria2_client_first() { + let _case = RecordedCase::new("HYSTERIA2-BASE", "client_first"); + let f = fixture(); + initialize(&f); + let port = free_port(); + let core = Core::start(&config(f["node"].clone(), port)); + echo(port, &f); + core.stop(); +} + +#[cfg(feature = "outbound-hysteria2")] +#[test] +#[ignore = "isolated HYSTERIA2 runner"] +fn hysteria2_udp_base() { + let _case = RecordedCase::new("HYSTERIA2-BASE", "udp_ipv4_ipv6_domain_fragmentation"); + let f = fixture(); + initialize(&f); + let port = free_port(); + let core = Core::start(&config(f["node"].clone(), port)); + for (ipv6, domain) in [(false, false), (true, false), (false, true)] { + let mut association = Association::new(&f, port, ipv6, domain); + for size in [1, 64, 512, 1200, 4096] { + for sequence in 0..100_u8 { + let mut bytes = vec![sequence; size]; + if size > 1 { + bytes[1] = size as u8; + } + association.exchange(&bytes); + } + } + drop(association); + } + core.stop(); +} + +#[cfg(feature = "outbound-hysteria2")] +#[test] +#[ignore = "isolated HYSTERIA2 runner"] +fn hysteria2_tcp_base() { + let _case = RecordedCase::new("HYSTERIA2-BASE", "tcp_ipv4_ipv6_domain_and_measure"); + let f = fixture(); + assert_eq!(f["node"]["type"], "hysteria2"); + initialize(&f); + let port = free_port(); + let core = Core::start(&config(f["node"].clone(), port)); + for (ipv6, domain) in [(false, false), (true, false), (false, true)] { + bulk(port, &f, ipv6, domain); + } + echo(port, &f); + core.stop(); + let mut origin = Origin::new(&f, 14, false); + let result = invoke( + "measureDelay", + None, + json!({"configYamls":[json!({"proxies":[f["node"]]}).to_string()], "timeout":5, + "url":format!("http://{}/",origin.target)}), + ); + assert_eq!(result["results"][0]["success"], true); + origin.marker(b'A'); + origin.marker(b'D'); +} +fn initialize(f: &Value) { + invoke("initialize", None, json!({"dataDir": f["data_dir"]})); +} +fn invoke(method: &str, id: Option<&str>, payload: Value) -> Value { + let response = invoke_response(method, id, payload); + assert_eq!( + response["success"], true, + "Invoke {method}: {}", + response["error"] + ); + response["data"].clone() +} +fn invoke_response(method: &str, id: Option<&str>, payload: Value) -> Value { + let mut input = + json!({"apiVersion":vcore::INVOKE_API_VERSION,"method":method,"payload":payload}); + if let Some(id) = id { + input["instanceId"] = json!(id); + } + let input = CString::new(input.to_string()).unwrap(); + // SAFETY: NUL-terminated input lives for the call; returned allocation is freed once. + let result = unsafe { vcore::ffi::VCoreInvoke(input.as_ptr()) }; + assert!(!result.is_null()); + let bytes = unsafe { CStr::from_ptr(result) }.to_bytes().to_vec(); + unsafe { vcore::ffi::VCoreFree(result) }; + serde_json::from_slice(&bytes).unwrap() +} +struct Core(Option); +impl Core { + fn prepare(config: &Value) -> Self { + invoke( + "validateConfig", + None, + json!({"configYaml":config.to_string()}), + ); + let id = invoke("createInstance", None, json!({}))["instanceId"] + .as_str() + .unwrap() + .to_owned(); + let core = Self(Some(id)); + invoke( + "prepare", + core.0.as_deref(), + json!({"configYaml":config.to_string()}), + ); + core + } + fn start(config: &Value) -> Self { + let core = Self::prepare(config); + invoke("start", core.0.as_deref(), json!({})); + core + } + fn stop(mut self) { + stop(self.0.take().unwrap()); + } +} +fn stop(id: String) { + let (tx, rx) = std::sync::mpsc::sync_channel(1); + let worker = thread::spawn(move || { + let result = std::panic::catch_unwind(|| { + invoke("stop", Some(&id), json!({})); + invoke("destroyInstance", Some(&id), json!({})); + }); + tx.send(result).unwrap(); + }); + let result = rx + .recv_timeout(Duration::from_secs(5)) + .expect("Stop exceeds 5 seconds"); + worker.join().unwrap(); + if let Err(panic) = result { + std::panic::resume_unwind(panic); + } +} +impl Drop for Core { + fn drop(&mut self) { + if let Some(id) = self.0.take() { + let _ = std::panic::catch_unwind(|| stop(id)); + } + } +} +fn free_port() -> u16 { + for _ in 0..32 { + let tcp = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = tcp.local_addr().unwrap().port(); + if let (Ok(_v6), Ok(_udp), Ok(_udp6)) = ( + TcpListener::bind((Ipv6Addr::LOCALHOST, port)), + UdpSocket::bind((Ipv4Addr::LOCALHOST, port)), + UdpSocket::bind((Ipv6Addr::LOCALHOST, port)), + ) { + return port; + } + } + panic!("cannot reserve fixture port"); +} +fn socket(address: SocketAddr) -> TcpStream { + let io = TcpStream::connect_timeout(&address, TIMEOUT).unwrap(); + io.set_read_timeout(Some(TIMEOUT)).unwrap(); + io.set_write_timeout(Some(TIMEOUT)).unwrap(); + io.set_nodelay(true).unwrap(); + io +} +fn config(node: Value, port: u16) -> Value { + json!({"socks-port":port,"ipv6":true,"proxies":[node],"rules":["MATCH,peer"]}) +} +fn login(port: u16) -> TcpStream { + let mut client = socket((Ipv4Addr::LOCALHOST, port).into()); + client.write_all(&[5, 1, 0]).unwrap(); + let mut reply = [0; 2]; + client.read_exact(&mut reply).unwrap(); + assert_eq!(reply, [5, 0]); + client +} +fn address(target: SocketAddr, domain: bool) -> Vec { + let mut bytes = Vec::new(); + if domain { + bytes.extend([3, DOMAIN.len() as u8]); + bytes.extend(DOMAIN.as_bytes()); + } else { + match target.ip() { + IpAddr::V4(ip) => { + bytes.push(1); + bytes.extend(ip.octets()); + } + IpAddr::V6(ip) => { + bytes.push(4); + bytes.extend(ip.octets()); + } + } + } + bytes.extend(target.port().to_be_bytes()); + bytes +} +fn reply(client: &mut TcpStream) -> SocketAddr { + let mut header = [0; 4]; + client.read_exact(&mut header).unwrap(); + assert_eq!(&header[..3], &[5, 0, 0]); + let ip = match header[3] { + 1 => { + let mut bytes = [0; 4]; + client.read_exact(&mut bytes).unwrap(); + IpAddr::from(bytes) + } + 4 => { + let mut bytes = [0; 16]; + client.read_exact(&mut bytes).unwrap(); + IpAddr::from(bytes) + } + _ => panic!("SOCKS response address"), + }; + let mut port = [0; 2]; + client.read_exact(&mut port).unwrap(); + SocketAddr::new(ip, u16::from_be_bytes(port)) +} +fn connect(port: u16, target: SocketAddr, domain: bool) -> TcpStream { + let mut client = login(port); + let mut request = vec![5, 1, 0]; + request.extend(address(target, domain)); + client.write_all(&request).unwrap(); + reply(&mut client); + client +} +struct Origin { + observer: TcpStream, + target: SocketAddr, +} +impl Origin { + fn new(f: &Value, mode: u8, ipv6: bool) -> Self { + let mut observer = socket(f["origin_control"].as_str().unwrap().parse().unwrap()); + observer + .write_all(&[mode | if ipv6 && mode >= 10 { 128 } else { 0 }]) + .unwrap(); + let mut port = [0; 2]; + observer.read_exact(&mut port).unwrap(); + let ip: IpAddr = f[if ipv6 { "origin_ipv6" } else { "origin_ipv4" }] + .as_str() + .unwrap() + .parse() + .unwrap(); + assert!(!ip.is_loopback() && !ip.is_unspecified()); + Self { + observer, + target: SocketAddr::new(ip, u16::from_be_bytes(port)), + } + } + fn marker(&mut self, expected: u8) { + let mut byte = [0]; + self.observer.read_exact(&mut byte).unwrap(); + assert_eq!(byte[0], expected); + } + fn quiet(&mut self) { + self.observer + .set_read_timeout(Some(Duration::from_millis(100))) + .unwrap(); + let error = self.observer.read(&mut [0; 1]).unwrap_err(); + assert!(matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + )); + self.observer.set_read_timeout(Some(TIMEOUT)).unwrap(); + } + fn udp(&mut self, expected: &[u8]) { + let mut header = [0; 4]; + self.observer.read_exact(&mut header).unwrap(); + assert_ne!(&header[2..], &[0, 0]); + let length = u16::from_be_bytes([header[0], header[1]]) as usize; + assert_eq!(length, expected.len()); + let mut bytes = vec![0; length]; + self.observer.read_exact(&mut bytes).unwrap(); + assert_eq!(bytes, expected); + } +} +struct Association { + control: TcpStream, + client: UdpSocket, + relay: SocketAddr, + origin: Origin, + domain: bool, +} +impl Association { + fn new(f: &Value, port: u16, ipv6: bool, domain: bool) -> Self { + let client = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + client.set_read_timeout(Some(TIMEOUT)).unwrap(); + socket2::SockRef::from(&client) + .set_send_buffer_size(65536) + .unwrap(); + let mut control = login(port); + let mut request = vec![5, 3, 0]; + request.extend(address(client.local_addr().unwrap(), false)); + control.write_all(&request).unwrap(); + let relay = reply(&mut control); + Self { + control, + client, + relay, + origin: Origin::new(f, if ipv6 { 6 } else { 4 }, ipv6), + domain, + } + } + fn packet(&self, payload: &[u8]) -> Vec { + let mut packet = vec![0, 0, 0]; + packet.extend(address(self.origin.target, self.domain)); + packet.extend(payload); + packet + } + fn exchange(&mut self, payload: &[u8]) { + let packet = self.packet(payload); + assert_eq!( + self.client.send_to(&packet, self.relay).unwrap(), + packet.len() + ); + self.origin.udp(payload); + let mut bytes = [0; 20000]; + let (size, source) = self.client.recv_from(&mut bytes).unwrap(); + assert_eq!(source, self.relay); + assert_eq!(&bytes[..3], &[0, 0, 0]); + let offset = match bytes[3] { + 1 => 8, + 4 => 20, + 3 => 5 + bytes[4] as usize, + _ => panic!("UDP response type"), + }; + assert_eq!( + &bytes[offset..offset + 2], + &self.origin.target.port().to_be_bytes() + ); + let literal = address(self.origin.target, false); + let named = address(self.origin.target, self.domain); + assert!(bytes[3..offset + 2] == literal || bytes[3..offset + 2] == named); + assert_eq!(&bytes[offset + 2..size], payload); + } +} +fn echo(port: u16, f: &Value) { + let mut origin = Origin::new(f, 13, false); + let mut client = connect(port, origin.target, false); + client.write_all(b"client-first").unwrap(); + let mut bytes = [0; 12]; + client.read_exact(&mut bytes).unwrap(); + assert_eq!(&bytes, b"client-first"); + origin.marker(b'A'); + drop(client); + origin.marker(b'D'); +} +fn bulk(port: u16, f: &Value, ipv6: bool, domain: bool) { + let _case = Case::start("VLESS-BASE", "tcp_10mib_both_directions"); + let mut origin = Origin::new(f, 10, ipv6); + let mut client = connect(port, origin.target, domain); + let mut hello = [0; 5]; + client.read_exact(&mut hello).unwrap(); + assert_eq!(&hello, b"hello"); + origin.marker(b'A'); + let data = vec![0x5a; 10 * 1024 * 1024]; + for fragment in data.chunks(4093) { + client.write_all(fragment).unwrap(); + } + let mut hash = Sha256::new(); + let mut remaining = data.len(); + let mut bytes = [0; 6151]; + while remaining > 0 { + let size = bytes.len().min(remaining); + let n = client.read(&mut bytes[..size]).unwrap_or_else(|error| { + panic!("bulk receive failed with {remaining} bytes remaining: {error}") + }); + assert_ne!(n, 0); + hash.update(&bytes[..n]); + remaining -= n; + } + assert_eq!(hash.finalize(), Sha256::digest(&data)); + let mut trailer = [0; 7]; + client.read_exact(&mut trailer).unwrap(); + assert_eq!(&trailer, b"trailer"); + client.shutdown(std::net::Shutdown::Write).unwrap(); + assert_eq!(client.read(&mut [0]).unwrap(), 0); + origin.marker(b'D'); +} +fn dns(config: &mut Value, origin: &Origin, via: &str) { + config["dns"] = + json!({"enable":true,"ipv6":false,"nameserver":[format!("udp://{}#{via}",origin.target)]}); +} +#[test] +#[ignore = "isolated VLESS runner"] +fn public_base() { + let _case = Case::start("VLESS-PUBLIC", "public_base"); + let f = fixture(); + initialize(&f); + let port = free_port(); + let dns_origin = Origin::new(&f, 17, false); + let mut yaml = config(f["node"].clone(), port); + dns(&mut yaml, &dns_origin, "DIRECT"); + let core = Core::start(&yaml); + for (v6, domain) in [(false, false), (true, false), (false, true)] { + bulk(port, &f, v6, domain); + } + echo(port, &f); + core.stop(); + drop(dns_origin); + for codec in if f["node"]["flow"] == "xtls-rprx-vision" { + vec!["xudp"] + } else { + vec!["none", "xudp", "packetaddr"] + } { + let _codec = Case::start("VLESS-BASE", "udp_each_codec_and_family"); + // Each runtime owns a fresh DNS fixture. A previous TCP bulk transfer + // must not consume this container origin's bounded idle lifetime. + let mut dns_origin = Origin::new(&f, 17, false); + let mut node = f["node"].clone(); + node["packet-encoding"] = json!(codec); + let mut yaml = config(node, port); + dns(&mut yaml, &dns_origin, "DIRECT"); + let core = Core::start(&yaml); + // Keep source sockets alive across all three cases, preventing peer NAT + // source-tuple reuse from aliasing a previous association. + let mut associations = Vec::new(); + for (v6, domain) in [(false, false), (true, false), (false, true)] { + let mut association = Association::new(&f, port, v6, domain); + let cap = if f["peer_kind"] == "V2" { + if codec == "xudp" { + 2048 + } else { + 2046 - (if codec == "packetaddr" { + if v6 || domain { 19 } else { 7 } + } else { + 0 + }) + } + } else { + 15000 + - (if codec == "packetaddr" { + if v6 || domain { 19 } else { 7 } + } else { + 0 + }) + }; + for size in [1, 64, 512, 1200, cap] { + for sequence in 0..100u8 { + let payload = vec![sequence; size]; + association.exchange(&payload); + } + } + associations.push(association); + } + core.stop(); + drop(associations); + // packetaddr's domain lookup must use this controlled endpoint. Magic + // names close the fixture; there is no system resolver fallback. + if codec == "packetaddr" { + let mut header = [0; 4]; + dns_origin.observer.read_exact(&mut header).unwrap(); + let n = u16::from_be_bytes([header[0], header[1]]) as usize; + let mut query = vec![0; n]; + dns_origin.observer.read_exact(&mut query).unwrap(); + assert_eq!(&query[12..], b"\x0dvcore-fixture\x04test\0\0\x01\0\x01"); + } + } + let mut origin = Origin::new(&f, 14, false); + let result = invoke( + "measureDelay", + None, + json!({"configYamls":[json!({"proxies":[f["node"]]}).to_string()],"timeout":5,"url":format!("http://{}/",origin.target)}), + ); + assert_eq!(result["results"][0]["success"], true); + origin.marker(b'A'); + origin.marker(b'D'); +} + +fn denied(node: Value, f: &Value) { + let mut origin = Origin::new(f, 13, false); + let port = free_port(); + let core = Core::start(&config(node, port)); + let mut client = login(port); + let mut request = vec![5, 1, 0]; + request.extend(address(origin.target, false)); + request.extend(b"no-origin-bytes"); + client.write_all(&request).unwrap(); + client + .set_read_timeout(Some(Duration::from_millis(500))) + .unwrap(); + let mut response = [0; 4]; + if client.read_exact(&mut response).is_ok() && response[1] == 0 { + let mut tail = vec![0; if response[3] == 1 { 6 } else { 18 }]; + client.read_exact(&mut tail).unwrap(); + assert!( + !matches!(client.read(&mut [0;32]),Ok(n) if n>0), + "rejected session delivered business data" + ); + } + drop(client); + origin.quiet(); + core.stop(); +} + +#[test] +#[ignore = "isolated SECURITY JLS runner"] +fn public_jls_download_identity() { + let _case = Case::start("VLESS-PUBLIC", "public_jls_download_identity"); + let f = fixture(); + initialize(&f); + let original = f["node"].clone(); + assert!(original["xhttp-opts"]["download-settings"].is_object()); + let port = free_port(); + let core = Core::start(&config(original.clone(), port)); + echo(port, &f); + core.stop(); + let mut replaced = original.clone(); + replaced["xhttp-opts"]["download-settings"]["jls-opts"] = f["jls_download_credentials"].clone(); + replaced["xhttp-opts"]["download-settings"]["client-fingerprint"] = json!("firefox"); + let core = Core::start(&config(replaced.clone(), port)); + bulk(port, &f, false, false); + let mut udp = Association::new(&f, port, false, false); + udp.exchange(b"independent-jls-download"); + core.stop(); + for field in ["username", "password"] { + let mut bad = replaced.clone(); + bad["xhttp-opts"]["download-settings"]["jls-opts"][field] = json!("incorrect-credential"); + denied(bad, &f); + } + // An explicit clear must remove JLS, even though the peer still requires it. + let mut cleared = original.clone(); + cleared["xhttp-opts"]["download-settings"]["jls-opts"] = json!({}); + denied(cleared, &f); + // A failed independently authenticated leg cannot poison a new valid node. + let core = Core::start(&config(original, port)); + echo(port, &f); + core.stop(); +} + +#[test] +#[ignore = "isolated SECURITY ECH runner"] +fn public_ech_download_identity() { + let _case = Case::start("VLESS-PUBLIC", "public_ech_download_identity"); + let f = fixture(); + initialize(&f); + let original = f["node"].clone(); + assert!(original["xhttp-opts"]["download-settings"].is_object()); + let port = free_port(); + let core = Core::start(&config(original.clone(), port)); + echo(port, &f); + core.stop(); + let mut replaced = original.clone(); + replaced["xhttp-opts"]["download-settings"]["ech-opts"] = + json!({"enable":true,"config":f["ech_download_config"]}); + replaced["xhttp-opts"]["download-settings"]["client-fingerprint"] = json!("firefox"); + let core = Core::start(&config(replaced.clone(), port)); + bulk(port, &f, false, false); + let mut udp = Association::new(&f, port, false, false); + udp.exchange(b"independent-ech-download"); + core.stop(); + // Only an explicit clear permits the download leg to use ordinary TLS. + replaced["xhttp-opts"]["download-settings"]["ech-opts"] = json!({}); + let core = Core::start(&config(replaced, port)); + echo(port, &f); + core.stop(); + let core = Core::start(&config(original, port)); + echo(port, &f); + core.stop(); +} + +#[test] +#[ignore = "isolated VLESS runner"] +fn public_negative() { + let _case = Case::start("VLESS-PUBLIC", "public_negative"); + let f = fixture(); + initialize(&f); + let original = f["node"].clone(); + let port = free_port(); + let core = Core::start(&config(original.clone(), port)); + echo(port, &f); + core.stop(); + let mut bad = original.clone(); + bad["uuid"] = json!("08080808-0808-0808-0808-080808080808"); + denied(bad, &f); + if original["tls"] == true && original["network"].as_str().unwrap_or("tcp") == "tcp" { + let mut bad = original.clone(); + if original["flow"] == "xtls-rprx-vision" { + bad["port"] = json!(f["flow_reject_port"].as_u64().unwrap()); + denied(bad, &f); + // The official listener accepts an explicitly empty flow for a + // Vision user. This is a user choice, never automatic fallback. + let mut plain = original.clone(); + plain["flow"] = json!(""); + let port = free_port(); + let core = Core::start(&config(plain, port)); + echo(port, &f); + core.stop(); + } else { + bad["flow"] = json!("xtls-rprx-vision"); + denied(bad, &f); + } + } + if original.get("reality-opts").is_some() { + let mut bad = original.clone(); + bad["reality-opts"]["public-key"] = json!("CQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + denied(bad, &f); + let mut bad = original.clone(); + bad["reality-opts"]["short-id"] = json!("ffffffffffffffff"); + denied(bad, &f); + let mut bad = original.clone(); + bad["servername"] = json!("rejected.fixture.test"); + denied(bad, &f); + } + if original["tls"] == true && original.get("reality-opts").is_none() { + let mut untrusted = original.clone(); + untrusted.as_object_mut().unwrap().remove("fingerprint"); + denied(untrusted.clone(), &f); + let mut bad = original.clone(); + bad["fingerprint"] = json!("00".repeat(32)); + bad["skip-cert-verify"] = json!(true); + denied(bad, &f); + let mut bad = original.clone(); + bad["servername"] = json!("wrong.invalid"); + denied(bad.clone(), &f); + bad["skip-cert-verify"] = json!(true); + denied(bad, &f); + if original["network"] == "ws" { + let mut fallback = original.clone(); + fallback.as_object_mut().unwrap().remove("servername"); + let port = free_port(); + let core = Core::start(&config(fallback, port)); + echo(port, &f); + core.stop(); + } + // No pin: skip-cert-verify is observable, not silently ignored. + untrusted["skip-cert-verify"] = json!(true); + let port = free_port(); + let core = Core::start(&config(untrusted, port)); + echo(port, &f); + core.stop(); + // A matching leaf pin intentionally replaces PKI/name checks, per the + // existing StandardTlsClient contract. A nonmatching pin cannot bypass. + } + let mut bad = original.clone(); + match original["network"].as_str().unwrap_or("tcp") { + "ws" => bad["ws-opts"]["path"] = json!("/wrong"), + "grpc" => bad["grpc-opts"]["grpc-service-name"] = json!("wrong"), + "h2" => bad["h2-opts"]["host"] = json!(["wrong.invalid"]), + "http" => bad["http-opts"]["path"] = json!(["/wrong"]), + "xhttp" => bad["xhttp-opts"]["path"] = json!("/wrong"), + _ => return, + }; + denied(bad, &f); + if original["network"] == "h2" { + let mut bad = original; + bad["h2-opts"]["path"] = json!("/wrong"); + denied(bad, &f); + } +} + +#[test] +#[ignore = "isolated VLESS runner"] +fn public_tls_identity_and_verification_name() { + let _case = Case::start("VLESS-PUBLIC", "public_tls_identity_and_verification_name"); + let f = fixture(); + initialize(&f); + let original = f["node"].clone(); + let port = free_port(); + let core = Core::start(&config(original.clone(), port)); + echo(port, &f); + let mut udp = Association::new(&f, port, false, false); + udp.exchange(b"valid-client-identity"); + core.stop(); + // The CA pin checks certificate names; SNI can differ independently. + let mut named = original.clone(); + named["servername"] = json!("independent-sni.invalid"); + named["name-cert-verify"] = json!("localhost"); + let core = Core::start(&config(named, port)); + echo(port, &f); + core.stop(); + let mut bad = original.clone(); + bad["name-cert-verify"] = json!("wrong.invalid"); + bad["skip-cert-verify"] = json!(true); + denied(bad, &f); + for name in ["missing", "wrong-ca", "expired"] { + let mut bad = original.clone(); + bad.as_object_mut().unwrap().remove("certificate"); + bad.as_object_mut().unwrap().remove("private-key"); + if name != "missing" { + bad.as_object_mut() + .unwrap() + .extend(f["identities"][name].as_object().unwrap().clone()); + } + denied(bad, &f); + } + // New node/client after bad identities cannot inherit another node's TLS + // session or mTLS credentials, and a failure does not poison valid identity. + let core = Core::start(&config(original, port)); + echo(port, &f); + core.stop(); +} + +#[test] +#[ignore = "isolated VLESS runner"] +fn public_alpn_rejection() { + let _case = Case::start("VLESS-PUBLIC", "public_alpn_rejection"); + let f = fixture(); + initialize(&f); + // The official listener advertises h2 when its gRPC entrance is enabled. + // A valid gRPC control proves authentication/origin readiness first. + let mut control = f["node"].clone(); + control["network"] = json!("grpc"); + control["alpn"] = json!(["h2"]); + control.as_object_mut().unwrap().remove("ws-opts"); + control["grpc-opts"] = json!({"grpc-service-name":"vless-alpn"}); + let port = free_port(); + let core = Core::start(&config(control, port)); + echo(port, &f); + core.stop(); + denied(f["node"].clone(), &f); +} + +#[test] +#[ignore = "isolated VLESS runner"] +fn public_udp_first_response() { + let _case = Case::start("VLESS-PUBLIC", "public_udp_first_response"); + let f = fixture(); + initialize(&f); + let port = free_port(); + let core = Core::start(&config(f["node"].clone(), port)); + let mut association = Association::new(&f, port, false, false); + association.exchange(&vec![1; 15000]); + core.stop(); +} + +#[test] +#[ignore = "isolated VLESS runner"] +fn public_legacy_regression() { + let _case = Case::start("VLESS-PUBLIC", "public_legacy_regression"); + let f = fixture(); + initialize(&f); + let port = free_port(); + let core = Core::start(&config(f["node"].clone(), port)); + echo(port, &f); + bulk(port, &f, false, false); + let mut udp = Association::new(&f, port, false, false); + for size in [1, 64, 512, 1200, 8192] { + for n in 0..100u8 { + udp.exchange(&vec![n; size]); + } + } + core.stop(); +} + +#[test] +#[ignore = "owned isolated native peer required"] +fn public_legacy_tcp() { + let _case = Case::start("VLESS-PUBLIC", "public_legacy_tcp"); + let f = fixture(); + initialize(&f); + let port = free_port(); + let core = Core::start(&config(f["node"].clone(), port)); + echo(port, &f); + // The gate is the legacy TLS transport, not a new UDP size contract for V2Ray. + bulk(port, &f, false, false); + core.stop(); +} diff --git a/tests/vless_public/hysteria2.rs b/tests/vless_public/hysteria2.rs new file mode 100644 index 0000000..5e79618 --- /dev/null +++ b/tests/vless_public/hysteria2.rs @@ -0,0 +1,195 @@ +//! Hysteria2 consumers through public YAML/Invoke and real container peers. +use super::*; +use runtime::{assert_closed, exchange, live, select}; + +#[test] +#[ignore = "isolated HYSTERIA2 runner"] +fn public_concrete_upstream() { + let _case = RecordedCase::new("HYSTERIA2-PUBLIC", "public_concrete_upstream"); + let f = fixture(); + initialize(&f); + let port = free_port(); + let mut node = f["node"].clone(); + node["dialer-proxy"] = json!("hop"); + let mut yaml = config(node, port); + yaml["proxies"] + .as_array_mut() + .unwrap() + .push(f["hop"].clone()); + let core = Core::start(&yaml); + echo(port, &f); + let mut udp = Association::new(&f, port, false, false); + udp.exchange(b"upstream-udp"); + core.stop(); +} + +#[test] +#[ignore = "isolated HYSTERIA2 runner"] +fn public_udp_boundaries() { + let _case = RecordedCase::new("HYSTERIA2-PUBLIC", "public_udp_boundaries"); + let f = fixture(); + initialize(&f); + let port = free_port(); + for mtu in [64, 1197, 65535] { + let mut node = f["node"].clone(); + node["udp-mtu"] = json!(mtu); + let core = Core::start(&config(node, port)); + let mut first = Association::new(&f, port, false, false); + let mut second = Association::new(&f, port, true, false); + for n in 0..100 { + first.exchange(&[1, n]); + second.exchange(&[2, n]); + } + first.exchange(&vec![1; 4096]); + // Wire format: eight fixed bytes, one-byte authority length, then + // the authority. A tiny MTU and long IPv6 target can exhaust the + // protocol's 255-fragment count before the 4096-byte business cap. + let second_max = ((mtu - 9 - second.origin.target.to_string().len()) * 255).min(4096); + second.exchange(&vec![2; second_max]); + first + .client + .send_to(&first.packet(&vec![3; 4097]), first.relay) + .unwrap(); + first.origin.quiet(); + second.exchange(b"oversize-does-not-kill-sibling"); + let rogue = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + rogue + .send_to(&first.packet(b"unauthorized"), first.relay) + .unwrap(); + first.origin.quiet(); + let mut other = Origin::new(&f, 4, false); + let mut packet = vec![0, 0, 0]; + packet.extend(address(other.target, false)); + packet.extend(b"other-target"); + second.client.send_to(&packet, second.relay).unwrap(); + other.udp(b"other-target"); + let mut response = [0; 128]; + let (n, _) = second.client.recv_from(&mut response).unwrap(); + assert!(response[..n].ends_with(b"other-target")); + second.origin.quiet(); + // A local send error may close its SOCKS association, not siblings or + // the authenticated QUIC session. Check this association last. + second + .client + .send_to(&second.packet(&vec![3; second_max + 1]), second.relay) + .unwrap(); + second.origin.quiet(); + let mut third = Association::new(&f, port, false, false); + third.exchange(b"shared-session-survives"); + core.stop(); + } +} + +fn denied(port: u16, f: &Value, label: &str) { + let mut unused = Origin::new(f, 13, false); + let mut client = login(port); + let mut request = vec![5, 1, 0]; + request.extend(address(unused.target, false)); + client.write_all(&request).unwrap(); + let mut reply = [0; 10]; + client.read_exact(&mut reply).unwrap(); + assert_ne!(reply[1], 0, "{label}"); + unused.quiet(); +} + +#[test] +#[ignore = "isolated HYSTERIA2 runner"] +fn public_graph_and_hop_snapshot() { + let _case = RecordedCase::new("HYSTERIA2-PUBLIC", "public_graph_and_hop_snapshot"); + let f = fixture(); + initialize(&f); + let port = free_port(); + let controller = free_port(); + let mut node = f["node"].clone(); + node["dialer-proxy"] = json!("hop"); + node["server"] = json!("peer.fixture.test"); + let mut yaml = config(node.clone(), port); + let mut dns_origin = Origin::new(&f, 19, false); + dns_origin + .observer + .write_all( + &f["node"]["server"] + .as_str() + .unwrap() + .parse::() + .unwrap() + .octets(), + ) + .unwrap(); + dns(&mut yaml, &dns_origin, "DIRECT"); + yaml["proxies"] + .as_array_mut() + .unwrap() + .push(f["hop"].clone()); + let core = Core::start(&yaml); + echo(port, &f); + let mut udp = Association::new(&f, port, false, true); + udp.exchange(b"concrete-upstream"); + core.stop(); + drop(udp); + + node["server"] = f["node"]["server"].clone(); + // A one-port set still creates new physical sockets. It tests selection + // freezing independently of the native multi-port DNAT acceptance matrix. + node["ports"] = json!(f["node"]["port"].as_u64().unwrap().to_string()); + node["hop-interval"] = json!(5); + node["dialer-proxy"] = json!("outer"); + yaml["proxies"][0] = node; + yaml["external-controller"] = json!(format!("127.0.0.1:{controller}")); + yaml["secret"] = json!("fixture-only"); + yaml["proxy-groups"] = json!([ + {"name":"outer","type":"select","proxies":["inner"]}, + {"name":"inner","type":"select","proxies":["hop","DIRECT","REJECT"]} + ]); + let core = Core::start(&yaml); + let (mut old, mut origin) = live(port, &f); + let mut udp = Association::new(&f, port, false, false); + udp.exchange(b"before-switch"); + select(controller, "REJECT"); + let until = Instant::now() + Duration::from_secs(12); + while Instant::now() < until { + exchange(&mut old, b"same-authenticated-session"); + udp.exchange(b"same-upstream-snapshot"); + thread::sleep(Duration::from_millis(100)); + } + // New logical streams can reuse the old authenticated session, including + // after its physical socket has hopped. They are not a new group choice. + echo(port, &f); + core.stop(); + assert_closed(&mut old); + origin.marker(b'D'); + drop((old, origin, udp)); + + // A fresh physical session must honor the newly selected REJECT. + let core = Core::start(&yaml); + select(controller, "REJECT"); + denied(port, &f, "fresh session observes REJECT"); + select(controller, "DIRECT"); + echo(port, &f); + core.stop(); + // Unselected cycles are rejected before creating any socket. + yaml["proxy-groups"][1]["proxies"] = json!(["DIRECT", "peer"]); + assert_eq!( + invoke_response( + "validateConfig", + None, + json!({"configYaml":yaml.to_string()}) + )["success"], + false + ); + // A broken selected member must not fall back to DIRECT. + yaml["proxy-groups"][1]["proxies"] = json!(["hop", "DIRECT"]); + let mut broken = f["hop"].clone(); + broken["port"] = json!(1); + yaml["proxies"][1] = broken; + let core = Core::start(&yaml); + denied(port, &f, "broken member has no DIRECT fallback"); + core.stop(); + // The business UDP flag is not the raw upstream carrier capability. + let mut hop = f["hop"].clone(); + hop["udp"] = json!(false); + yaml["proxies"][1] = hop; + let core = Core::start(&yaml); + echo(port, &f); + core.stop(); +} diff --git a/tests/vless_public/integration/connector.rs b/tests/vless_public/integration/connector.rs new file mode 100644 index 0000000..3b3d330 --- /dev/null +++ b/tests/vless_public/integration/connector.rs @@ -0,0 +1,331 @@ +use super::*; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +use vcore::{ + config::{Config, ProxyProtocol}, + dialer::{Dialer, ResolvedEndpoint, SocketProtector}, + dispatch::{DatagramBudget, DatagramTransport, DispatchError}, + outbound::{ + AnyTlsOutbound, ConnectedStream, DatagramRequest, EstablishContext, OutboundConnector, + ShadowsocksOutbound, Socks5Outbound, UpstreamPath, VlessOutbound, + hysteria2::Hysteria2Outbound, trojan::TrojanOutbound, vmess::VmessOutbound, + }, + security::{SecurityContext, StandardTlsClient, TlsCertificatePolicy, TlsClientOptions}, + session::{Datagram, DatagramSession, Destination, InboundKind, StreamSession}, +}; + +pub(super) fn node( + raw: &Value, + upstream: Option>, + dialer: Dialer, +) -> Arc { + let mut raw = raw.clone(); + raw["name"] = json!("peer"); + raw.as_object_mut().unwrap().remove("dialer-proxy"); + let parsed = Config::parse_yaml(config(raw, 1080).to_string().as_bytes()).unwrap(); + let proxy = &parsed.proxies[0]; + let path = upstream.map(UpstreamPath::proxy).unwrap_or_else(|| { + UpstreamPath::direct( + ResolvedEndpoint { + logical_host: proxy.address().into(), + port: proxy.port(), + addresses: vec![SocketAddr::new( + proxy.address().parse().unwrap(), + proxy.port(), + )], + }, + dialer, + ) + }); + match &proxy.protocol { + ProxyProtocol::Socks5(c) => Arc::new(Socks5Outbound::new_with_path(c, path).unwrap()), + ProxyProtocol::Shadowsocks(c) => { + Arc::new(ShadowsocksOutbound::new_with_path(c, path).unwrap()) + } + ProxyProtocol::Trojan(c) => Arc::new(TrojanOutbound::new_with_path(c, path).unwrap()), + ProxyProtocol::Vmess(c) => Arc::new(VmessOutbound::new_with_path(c, path).unwrap()), + ProxyProtocol::Vless(c) => Arc::new(VlessOutbound::new_with_path(c, path).unwrap()), + ProxyProtocol::Hysteria2(c) => Arc::new(Hysteria2Outbound::new_with_path(c, path).unwrap()), + ProxyProtocol::AnyTls(c) => { + let tls = StandardTlsClient::with_options( + &SecurityContext::new(), + &c.server_name, + TlsClientOptions { + alpn: c.tls.alpn.clone(), + client_fingerprint: c.tls.client_fingerprint, + certificate: TlsCertificatePolicy { + verification_name: None, + skip_cert_verify: c.tls.skip_cert_verify, + fingerprint: c.tls.fingerprint, + }, + ..Default::default() + }, + 0, + 65536, + ) + .unwrap(); + Arc::new( + AnyTlsOutbound::new( + vcore::outbound::server_destination(&c.address, c.port).unwrap(), + path, + &c.password, + Arc::new(tls), + 65536, + ) + .unwrap(), + ) + } + } +} + +fn request() -> DatagramRequest { + DatagramRequest::new(DatagramSession::new( + InboundKind::Socks5, + "127.0.0.1:1".parse().unwrap(), + )) +} +fn session(destination: Destination) -> StreamSession { + StreamSession { + inbound: InboundKind::Socks5, + source: "127.0.0.1:1".parse().unwrap(), + destination, + sniffed_domain: None, + } +} + +pub(super) fn budget_pair(f: &Value) -> Value { + let mut case = RecordedCase::new("INTEGRATION-PAIR", "directional_budget"); + let probe = ResourceProbe::default(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let result = runtime.block_on(probe.scope(async { + let first = node(&f["first"],None,Dialer::default()); + let last = node(&f["last"],Some(first.clone()),Dialer::default()); + let mut observations = Vec::new(); + for (transmit,receive) in [(128,128),(512,256)] { + let mut udp = last.open_datagram(request().with_budget(DatagramBudget::new(transmit,receive)),&EstablishContext::default()).await.unwrap(); + let mut origin = Origin::new(f,4,false); + let remote = Destination::Ip(origin.target); + let budget = udp.payload_budget(&remote); + assert_eq!(budget,DatagramBudget::new(transmit,receive)); + let packet = |size| Datagram { remote:remote.clone(),payload:vec![7;size].into(),sniffed_domain:None }; + assert!(udp.send(packet(usize::from(transmit)+1)).await.is_err()); + origin.quiet(); + let size = usize::from(transmit.min(receive)); + for _ in 0..100 { + udp.send(packet(size)).await.unwrap(); + let reply = tokio::time::timeout(TIMEOUT,udp.receive()).await.unwrap().unwrap(); + assert_eq!(reply.remote,remote); + assert_eq!(reply.payload,vec![7;size]); + origin.udp(&vec![7;size]); + } + udp.close().await.unwrap(); + observations.push(json!({"transmit":transmit,"receive":receive,"roundtrip_size":size,"packets":100,"oversize_rejected_before_origin":true})); + } + last.begin_shutdown(); first.begin_shutdown(); + last.shutdown().await; first.shutdown().await; + drop((last,first)); + observations + })); + assert!(probe.snapshot().is_idle(), "{:?}", probe.snapshot()); + case.resources(probe.snapshot()); + json!(result) +} + +struct RejectProtect(AtomicUsize); + +struct NoDatagrams { + inner: Arc, + rejected: AtomicUsize, +} +#[async_trait::async_trait] +impl OutboundConnector for NoDatagrams { + async fn connect_stream( + &self, + session: StreamSession, + context: &EstablishContext, + ) -> Result { + self.inner.connect_stream(session, context).await + } + async fn open_datagram( + &self, + _: DatagramRequest, + _: &EstablishContext, + ) -> Result, DispatchError> { + self.rejected.fetch_add(1, Ordering::SeqCst); + Err(DispatchError::NotAllowed) + } +} + +pub(super) fn carrier_pair(f: &Value) -> Value { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut case = RecordedCase::new("INTEGRATION-PAIR", "carrier_capability"); + let probe = ResourceProbe::default(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let kind = f["last"]["type"].as_str().unwrap(); + let tcp_allowed = kind != "hysteria2"; + let udp_allowed = !matches!(kind, "socks5" | "ss" | "hysteria2"); + runtime.block_on(probe.scope(async { + let first = node(&f["first"], None, Dialer::default()); + let controlled = Arc::new(NoDatagrams { + inner: first.clone(), + rejected: AtomicUsize::new(0), + }); + let last = node(&f["last"], Some(controlled.clone()), Dialer::default()); + let mut origin = Origin::new(f, 13, false); + let stream = last + .connect_stream(session(origin.target.into()), &EstablishContext::default()) + .await; + if tcp_allowed { + let mut stream = stream.unwrap().io; + stream.write_all(b"tcp-carrier").await.unwrap(); + // AsyncWrite may buffer (notably VMess); this direct caller must + // flush before waiting for the peer, just like the public relay. + stream.flush().await.unwrap(); + let mut bytes = [0; 11]; + stream.read_exact(&mut bytes).await.unwrap(); + assert_eq!(&bytes, b"tcp-carrier"); + origin.marker(b'A'); + drop(stream); + } else { + assert!(stream.is_err()); + origin.quiet(); + } + let mut origin = Origin::new(f, 4, false); + let datagram = last + .open_datagram(request(), &EstablishContext::default()) + .await; + if udp_allowed { + let mut datagram = datagram.unwrap(); + datagram + .send(Datagram { + remote: origin.target.into(), + payload: bytes::Bytes::from_static(b"stream-carried-udp"), + sniffed_domain: None, + }) + .await + .unwrap(); + let response = tokio::time::timeout(TIMEOUT, datagram.receive()) + .await + .unwrap() + .unwrap(); + assert_eq!(&response.payload[..], b"stream-carried-udp"); + origin.udp(b"stream-carried-udp"); + datagram.close().await.unwrap(); + } else { + assert!(datagram.is_err()); + origin.quiet(); + } + let rejected = controlled.rejected.load(Ordering::SeqCst); + assert_eq!( + rejected, + usize::from(!tcp_allowed) + usize::from(!udp_allowed) + ); + last.begin_shutdown(); + first.begin_shutdown(); + last.shutdown().await; + first.shutdown().await; + drop((last, controlled, first)); + })); + assert!(probe.snapshot().is_idle(), "{:?}", probe.snapshot()); + case.resources(probe.snapshot()); + json!({"upstream_datagrams":"rejected","tcp_allowed":tcp_allowed,"udp_allowed":udp_allowed,"no_bypass":true}) +} +impl SocketProtector for RejectProtect { + fn protect(&self, _: i32) -> io::Result<()> { + self.0.fetch_add(1, Ordering::SeqCst); + Err(io::ErrorKind::PermissionDenied.into()) + } +} + +#[test] +#[ignore = "owned INTEGRATION container fixture required"] +fn protect_failure() { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + let f = fixture(); + initialize(&f); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + for protocol in PROTOCOLS { + let mut case = RecordedCase::new("INTEGRATION-FAILURES", protocol); + let probe = ResourceProbe::default(); + let mut origin = Origin::new(&f, 13, false); + runtime.block_on(probe.scope(async { + let protector = Arc::new(RejectProtect(AtomicUsize::new(0))); + let outbound = node( + &f["nodes"][protocol], + None, + Dialer::default().with_protector(protector.clone()), + ); + let rejected = outbound + .connect_stream(session(origin.target.into()), &EstablishContext::default()) + .await; + assert!(rejected.is_err()); + assert!(protector.0.load(Ordering::SeqCst) > 0); + origin.quiet(); + outbound.begin_shutdown(); + outbound.shutdown().await; + drop(outbound); + })); + assert!(probe.snapshot().is_idle()); + case.resources(probe.snapshot()); + { + let _case = RecordedCase::new("INTEGRATION-FAILURES-AUTH", protocol); + let mut bad = f["nodes"][protocol].clone(); + if matches!(protocol, "vmess" | "vless") { + bad["uuid"] = json!("08080808-0808-0808-0808-080808080808"); + } else if protocol == "ss" { + bad["password"] = json!(STANDARD.encode([23; 16])); + } else { + bad["password"] = json!("invalid-synthetic-identity"); + } + shadowsocks::rejected(bad, &f); + if !matches!(protocol, "socks5" | "ss") { + let mut bad = f["nodes"][protocol].clone(); + bad["fingerprint"] = json!("00".repeat(32)); + bad["skip-cert-verify"] = json!(true); + shadowsocks::rejected(bad, &f); + } + } + { + let mut case = RecordedCase::new("INTEGRATION-FAILURES-SOURCE", protocol); + let port = free_port(); + let probe = ResourceProbe::default(); + let core = + probe.scope_sync(|| Core::start(&config(f["nodes"][protocol].clone(), port))); + let mut first = Association::new(&f, port, false, false); + let mut second = Association::new(&f, port, false, false); + first.exchange(b"first-association"); + second.exchange(b"sibling-association"); + let rogue = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + rogue + .send_to(&first.packet(b"unauthorized-source"), first.relay) + .unwrap(); + first.origin.quiet(); + first + .client + .send_to(&first.packet(&vec![0; 65246]), first.relay) + .unwrap(); + first.origin.quiet(); + second.exchange(b"survives-foreign-and-oversize"); + drop(first); + second.exchange(b"survives-single-association-cancel"); + core.stop(); + runtime::assert_closed(&mut second.control); + assert!(probe.snapshot().is_idle()); + case.resources(probe.snapshot()); + } + } + observe( + json!({"protect_rejected":PROTOCOLS,"auth_rejected":PROTOCOLS,"certificate_pin_rejected":["anytls","trojan","vmess","vless","hysteria2"],"source_and_oversize_isolated":PROTOCOLS,"cancel_preserves_sibling":PROTOCOLS,"no_origin_bytes":true,"stop_idle":true}), + ); +} diff --git a/tests/vless_public/integration/lifecycle.rs b/tests/vless_public/integration/lifecycle.rs new file mode 100644 index 0000000..d5aee9a --- /dev/null +++ b/tests/vless_public/integration/lifecycle.rs @@ -0,0 +1,168 @@ +use super::*; + +const NEW_PROTOCOLS: [&str; 4] = ["trojan", "vmess", "vless", "hysteria2"]; + +pub(super) fn fd_count() -> usize { + std::fs::read_dir("/dev/fd").unwrap().count() +} + +fn quiet_reader(stream: &mut TcpStream) { + stream.set_nonblocking(true).unwrap(); + let result = stream.read(&mut [0; 8]); + assert!( + matches!(result, Err(error) if error.kind() == io::ErrorKind::WouldBlock), + "origin emitted data after Stop" + ); +} + +#[test] +#[ignore = "owned INTEGRATION container fixture required; at least 500 seconds"] +fn public_lifetimes() { + lifetimes(100); +} + +#[test] +#[ignore = "owned INTEGRATION container fixture required; development subset only"] +fn lifecycle_tracer() { + lifetimes(20); +} + +fn lifetimes(count: usize) { + let f = fixture(); + initialize(&f); + for protocol in NEW_PROTOCOLS { + let port = free_port(); + let core = Core::start(&config(f["nodes"][protocol].clone(), port)); + echo(port, &f); + core.stop(); + } + let mut observations = Vec::new(); + for cycle in 0..count { + let protocol = NEW_PROTOCOLS[(cycle / 5) % NEW_PROTOCOLS.len()]; + let mode = cycle % 5; + let mut case = RecordedCase::new("INTEGRATION-LIFECYCLE", "stop_and_remain_quiet"); + let probe = ResourceProbe::default(); + case.checkpoint("baseline", probe.snapshot()); + let baseline = fd_count(); + let port = free_port(); + let mut tcp = Vec::new(); + let mut udp = Vec::new(); + let mut blackhole = None; + let mut pending_client = None; + let mut occupied = None; + let mut node = f["nodes"][protocol].clone(); + if mode == 2 { + let origin = Origin::new(&f, if protocol == "hysteria2" { 20 } else { 15 }, false); + node["server"] = json!(origin.target.ip().to_string()); + node["port"] = json!(origin.target.port()); + blackhole = Some(origin); + } + let yaml = config(node, port); + let core = probe.scope_sync(|| { + if mode == 3 { + occupied = Some(UdpSocket::bind((Ipv4Addr::LOCALHOST, port)).unwrap()); + let core = Core::prepare(&yaml); + assert_eq!( + invoke_response("start", core.0.as_deref(), json!({}))["success"], + false + ); + core + } else { + Core::start(&yaml) + } + }); + match mode { + 0 => echo(port, &f), + 2 => { + let origin = blackhole.as_mut().unwrap(); + let mut client = login(port); + let mut request = vec![5, 1, 0]; + request.extend(address(origin.target, false)); + client.write_all(&request).unwrap(); + origin.marker(b'A'); + pending_client = Some(client); + } + 3 => {} + _ => { + let (first, mut first_origin) = runtime::live(port, &f); + let (mut second, second_origin) = runtime::live(port, &f); + let mut one = Association::new(&f, port, false, false); + let mut two = Association::new(&f, port, false, false); + one.exchange(b"first-association"); + two.exchange(b"second-association"); + if mode == 4 { + drop(first); + first_origin.marker(b'D'); + runtime::exchange(&mut second, b"sibling-survives-cancel"); + one.exchange(b"udp-sibling-one"); + two.exchange(b"udp-sibling-two"); + } else { + tcp.push((first, first_origin)); + } + tcp.push((second, second_origin)); + udp.extend([one, two]); + } + } + let active = probe.snapshot(); + case.checkpoint("active", active.clone()); + let stopping = Instant::now(); + core.stop(); + let stop_ms = stopping.elapsed().as_millis(); + assert!(stop_ms < 5000); + let stopped = probe.snapshot(); + assert!(stopped.is_idle(), "cycle {cycle}: {stopped:?}"); + case.checkpoint("after-stop", stopped.clone()); + drop(occupied); + drop(pending_client); + for (client, origin) in &mut tcp { + runtime::assert_closed(client); + origin.marker(b'D'); + } + for association in &mut udp { + runtime::assert_closed(&mut association.control); + association.client.set_nonblocking(true).unwrap(); + } + if let Some(origin) = &mut blackhole + && protocol != "hysteria2" + { + origin.marker(b'D'); + } + let retained = 2 * tcp.len() + 3 * udp.len() + usize::from(blackhole.is_some()); + let after_stop = fd_count(); + assert!( + after_stop <= baseline + retained, + "cycle {cycle}: FD retained at Stop: {after_stop} > {}", + baseline + retained + ); + let rebound_tcp = TcpListener::bind((Ipv4Addr::LOCALHOST, port)).unwrap(); + let rebound_udp = UdpSocket::bind((Ipv4Addr::LOCALHOST, port)).unwrap(); + let quiet = Instant::now(); + while quiet.elapsed() < Duration::from_secs(5) { + assert_eq!(probe.snapshot(), stopped, "resource activity after Stop"); + assert!(fd_count() <= baseline + retained + 2); + for (_, origin) in &mut tcp { + quiet_reader(&mut origin.observer); + } + for association in &mut udp { + assert_eq!( + association.client.recv(&mut [0; 32]).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + quiet_reader(&mut association.origin.observer); + } + if let Some(origin) = &mut blackhole { + quiet_reader(&mut origin.observer); + } + thread::sleep(Duration::from_millis(25)); + } + let quiet_seconds = quiet.elapsed().as_secs_f64(); + case.checkpoint("quiet", probe.snapshot()); + case.resources(probe.snapshot()); + drop((tcp, udp, blackhole, rebound_tcp, rebound_udp)); + let end = fd_count(); + assert!(end <= baseline); + observations.push(json!({"cycle":cycle,"protocol":protocol,"mode":mode,"baseline_fd":baseline,"retained_fixture_fd":retained,"after_stop_fd":after_stop,"final_fd":end,"stop_ms":stop_ms,"quiet_seconds":quiet_seconds,"active":active,"after_stop":stopped,"quiet":probe.snapshot(),"ports_rebound":true})); + println!("INTEGRATION lifecycle: {}/{count}", cycle + 1); + } + observe(json!({"count":count,"cycles":observations})); +} diff --git a/tests/vless_public/integration/mod.rs b/tests/vless_public/integration/mod.rs new file mode 100644 index 0000000..bc18dc7 --- /dev/null +++ b/tests/vless_public/integration/mod.rs @@ -0,0 +1,376 @@ +//! INTEGRATION consumers share the established public Invoke/SOCKS/origin boundaries. +use super::*; +use vcore::resources::observation::{QueueKind, ResourceKind, ResourceProbe}; + +mod connector; +mod lifecycle; +#[cfg(target_os = "macos")] +mod pressure; +mod shadowsocks; + +#[test] +#[ignore = "owned INTEGRATION container fixture required"] +fn resource_tracer() { + let f = fixture(); + initialize(&f); + for protocol in PROTOCOLS { + let mut case = RecordedCase::new("INTEGRATION-RESOURCE-TRACER", protocol); + let probe = ResourceProbe::default(); + case.checkpoint("baseline", probe.snapshot()); + let port = free_port(); + let core = probe.scope_sync(|| Core::start(&config(f["nodes"][protocol].clone(), port))); + let (mut tcp, mut origin) = runtime::live(port, &f); + let mut udp = Association::new(&f, port, false, false); + udp.exchange(b"observable"); + let active = probe.snapshot(); + assert!( + active.current(ResourceKind::Socket) > 0, + "missing Invoke resource scope: {protocol}" + ); + assert!( + active.current(ResourceKind::Task) > 0, + "runtime tasks are not observed: {protocol}" + ); + let queues = probe.queues(); + let udp_queue = &queues[QueueKind::SocksUdp as usize]; + assert_eq!(udp_queue.capacity, 16); + assert!((1..=16).contains(&udp_queue.peak)); + if protocol == "hysteria2" { + assert!(queues.iter().all(|q| q.peak > 0 && q.peak <= q.capacity)); + } + case.checkpoint("active", active); + core.stop(); + assert!( + probe.snapshot().is_idle(), + "{protocol}: {:?}", + probe.snapshot() + ); + case.checkpoint("after-stop", probe.snapshot()); + runtime::assert_closed(&mut tcp); + origin.marker(b'D'); + runtime::assert_closed(&mut udp.control); + case.resources(probe.snapshot()); + } + observe(json!({"protocols":PROTOCOLS,"active_observed":true,"stop_idle":true})); +} + +const PROTOCOLS: [&str; 7] = [ + "socks5", + "anytls", + "ss", + "trojan", + "vmess", + "vless", + "hysteria2", +]; + +fn observe(value: Value) { + std::fs::write( + std::env::var("VCORE_INTEGRATION_OBSERVATIONS").unwrap(), + value.to_string(), + ) + .unwrap(); +} + +fn denied_target(port: u16, f: &Value) { + let mut origin = Origin::new(f, 13, false); + let mut client = login(port); + let mut request = vec![5, 1, 0]; + request.extend(address(origin.target, false)); + client.write_all(&request).unwrap(); + let mut response = [0; 10]; + client.read_exact(&mut response).unwrap(); + assert_ne!(response[1], 0); + origin.quiet(); +} + +#[test] +#[ignore = "owned INTEGRATION container fixture required"] +fn graph() { + let f = fixture(); + initialize(&f); + for protocol in PROTOCOLS { + let _case = RecordedCase::new("INTEGRATION-GRAPH", protocol); + let port = free_port(); + let controller = free_port(); + let mut node = f["nodes"][protocol].clone(); + node["dialer-proxy"] = json!("outer"); + let mut yaml = json!({"socks-port":port,"external-controller":format!("127.0.0.1:{controller}"),"secret":"fixture-only","proxies":[node,f["hop"]],"proxy-groups":[{"name":"outer","type":"select","proxies":["inner"]},{"name":"inner","type":"select","proxies":["hop","DIRECT","REJECT"]}],"rules":["MATCH,peer"]}); + let core = Core::start(&yaml); + let (mut old, mut origin) = runtime::live(port, &f); + let mut udp = Association::new(&f, port, false, false); + udp.exchange(b"before-group-switch"); + runtime::select(controller, "REJECT"); + runtime::exchange(&mut old, b"old-transport-snapshot"); + udp.exchange(b"old-udp-snapshot"); + if protocol == "hysteria2" { + echo(port, &f); // Reuse is allowed only on the old physical session. + } else { + // AnyTLS reuses idle sessions, not a session with an active stream; + // both its TCP and UoT sessions are deliberately held active here. + denied_target(port, &f); + } + runtime::select(controller, "DIRECT"); + echo(port, &f); + runtime::exchange(&mut old, b"still-old-transport"); + core.stop(); + runtime::assert_closed(&mut old); + origin.marker(b'D'); + drop((old, origin, udp)); + // No pool exists in a fresh session: REJECT must deny every protocol. + yaml["proxy-groups"][1]["proxies"] = json!(["REJECT", "hop", "DIRECT"]); + let core = Core::start(&yaml); + denied_target(port, &f); + core.stop(); + yaml["proxy-groups"][1]["proxies"] = json!(["DIRECT", "peer"]); + assert_eq!( + invoke_response( + "validateConfig", + None, + json!({"configYaml":yaml.to_string()}) + )["success"], + false + ); + yaml["proxy-groups"][1]["proxies"] = json!(["hop", "DIRECT"]); + yaml["proxies"][1]["port"] = json!(1); + let core = Core::start(&yaml); + denied_target(port, &f); + core.stop(); + } + observe( + json!({"protocols":PROTOCOLS,"nested_select":true,"old_transport_snapshot":true,"cold_reject":true,"direct":true,"unselected_cycle":true,"no_fallback":true}), + ); +} + +#[test] +#[ignore = "owned INTEGRATION container fixture required"] +fn dns_measure() { + let f = fixture(); + initialize(&f); + for protocol in PROTOCOLS { + let _case = RecordedCase::new("INTEGRATION-DNS-MEASURE", protocol); + let node = f["nodes"][protocol].clone(); + let port = free_port(); + let mut dns_origin = Origin::new(&f, 17, false); + let mut yaml = config(node.clone(), port); + dns(&mut yaml, &dns_origin, "peer"); + yaml["proxy-groups"] = json!([{"name":"denied","type":"select","proxies":["REJECT"]}]); + yaml["rules"] = json!([ + format!("IP-CIDR,{}/32,peer", f["origin_ipv4"].as_str().unwrap()), + "MATCH,denied" + ]); + let core = Core::start(&yaml); + let mut origin = Origin::new(&f, 13, false); + let mut client = connect(port, origin.target, true); + runtime::exchange(&mut client, b"controlled-dns-route"); + origin.marker(b'A'); + let mut header = [0; 4]; + dns_origin.observer.read_exact(&mut header).unwrap(); + let mut query = vec![0; u16::from_be_bytes([header[0], header[1]]) as usize]; + dns_origin.observer.read_exact(&mut query).unwrap(); + assert_eq!(&query[12..], b"\x0dvcore-fixture\x04test\0\0\x01\0\x01"); + core.stop(); + runtime::assert_closed(&mut client); + origin.marker(b'D'); + drop((client, origin, dns_origin)); + let mut origin = Origin::new(&f, 14, false); + let result = invoke( + "measureDelay", + None, + json!({"configYamls":[json!({"proxies":[node]}).to_string()],"timeout":5,"url":format!("http://{}/",origin.target)}), + ); + assert_eq!(result["results"][0]["success"], true); + origin.marker(b'A'); + origin.marker(b'D'); + let rejected = invoke( + "measureDelay", + None, + json!({"configYamls":[json!({"proxies":[node],"proxy-groups":[{"name":"group","type":"select","proxies":["peer"]}]}).to_string()],"timeout":5,"url":format!("http://{}/",origin.target)}), + ); + assert_eq!(rejected["results"][0]["success"], false); + origin.quiet(); + } + observe( + json!({"protocols":PROTOCOLS,"controlled_dns_via_proxy":true,"ip_rule_consumer":true,"node_measure":true,"group_measure_rejected":true}), + ); +} + +#[test] +#[cfg(target_os = "macos")] +#[ignore = "owned INTEGRATION container fixture required"] +fn entrypoints() { + let f = fixture(); + let mut observations = Vec::new(); + for protocol in PROTOCOLS { + let _case = RecordedCase::new("INTEGRATION-ENTRYPOINTS", protocol); + let mut single = f.clone(); + single["node"] = f["nodes"][protocol].clone(); + runtime::entrypoints(&single); + observations.push(json!({"protocol":protocol,"http_forward":true,"http_connect":true,"tun_tcp":true,"tun_udp":true,"tun_dns":true})); + } + std::fs::write( + std::env::var("VCORE_INTEGRATION_OBSERVATIONS").unwrap(), + json!({"entrypoints":observations}).to_string(), + ) + .unwrap(); +} + +#[test] +#[ignore = "owned INTEGRATION container fixture required"] +fn ss_bulk_stress() { + let _case = RecordedCase::new("INTEGRATION-SS", "bulk_stress"); + let f = fixture(); + initialize(&f); + let port = free_port(); + let mut first = f["first"].clone(); + let mut last = f["last"].clone(); + assert_eq!(first["type"], "ss"); + assert_eq!(last["type"], "ss"); + first["name"] = json!("hop"); + last["dialer-proxy"] = json!("hop"); + let core = + Core::start(&json!({"socks-port":port,"proxies":[last,first],"rules":["MATCH,peer"]})); + for _ in 0..50 { + bulk(port, &f, false, false); + } + core.stop(); +} + +#[test] +#[ignore = "owned INTEGRATION container fixture required"] +fn carrier_tracer() { + let f = fixture(); + initialize(&f); + observe(connector::carrier_pair(&f)); +} + +#[test] +#[ignore = "owned INTEGRATION container fixture required"] +fn ordered_pair() { + let _case = RecordedCase::new("INTEGRATION-PAIR", "ordered_pair"); + let f = fixture(); + initialize(&f); + let mut observations = Vec::new(); + for outer_ipv6 in [false, true] { + for grouped in [false, true] { + let port = free_port(); + let mut first = f["first"].clone(); + let mut last = f["last"].clone(); + first["name"] = json!("hop"); + last["name"] = json!("peer"); + if outer_ipv6 { + first["server"] = f["first_ipv6"].clone(); + last["server"] = f["last_ipv6"].clone(); + } + last["dialer-proxy"] = json!(if grouped { "outer" } else { "hop" }); + let mut yaml = json!({"socks-port":port,"proxies":[last,first],"rules":["MATCH,peer"]}); + if grouped { + yaml["proxy-groups"] = json!([ + {"name":"outer","type":"select","proxies":["inner"]}, + {"name":"inner","type":"select","proxies":["hop","DIRECT","REJECT"]} + ]); + } + let core = Core::start(&yaml); + for (ipv6, domain) in [(false, false), (true, false), (false, true)] { + bulk(port, &f, ipv6, domain); + } + echo(port, &f); + // Keep source tuples alive until Stop. Trojan's documented Mihomo + // domain-UDP gap is separately exercised with the official Xray + // terminal below, retaining this very same first-hop protocol. + let mut associations = Vec::new(); + let targets = if f["last"]["type"] == "trojan" { + vec![(false, false), (true, false)] + } else { + vec![(false, false), (true, false), (false, true)] + }; + let target_count = targets.len(); + for (ipv6, domain) in targets { + let mut association = Association::new(&f, port, ipv6, domain); + for size in [1, 64, 512, 1200] { + for sequence in 0..100_u8 { + association.exchange(&vec![sequence; size]); + } + } + associations.push(association); + } + core.stop(); + drop(associations); + let _tcp = TcpListener::bind((Ipv4Addr::LOCALHOST, port)).unwrap(); + let _udp = UdpSocket::bind((Ipv4Addr::LOCALHOST, port)).unwrap(); + observations.push(json!({ + "outer_ipv6":outer_ipv6,"nested_select":grouped, + "tcp_target_kinds":3,"tcp_bytes_each_direction":3*10*1024*1024, + "udp_target_kinds":target_count,"udp_packets":target_count*400,"udp_sizes":[1,64,512,1200], + "server_first":true,"client_first":true,"ports_rebound":true + })); + } + } + std::fs::write( + std::env::var("VCORE_INTEGRATION_OBSERVATIONS").unwrap(), + json!({"first":f["first"]["type"],"last":f["last"]["type"],"paths":observations,"domain_native":domain_terminal(&f),"budgets":connector::budget_pair(&f),"routed_udp":routed_udp_pair(&f),"carrier_capability":connector::carrier_pair(&f)}) + .to_string(), + ) + .unwrap(); +} + +fn routed_udp_pair(f: &Value) -> Value { + let _case = RecordedCase::new("INTEGRATION-PAIR", "routed_udp_permission"); + for enabled in [true, false] { + let port = free_port(); + let mut first = f["first"].clone(); + first["name"] = json!("hop"); + first["udp"] = json!(false); + let mut last = f["last"].clone(); + last["dialer-proxy"] = json!("hop"); + last["udp"] = json!(enabled); + let core = + Core::start(&json!({"socks-port":port,"proxies":[last,first],"rules":["MATCH,peer"]})); + echo(port, f); + let mut udp = Association::new(f, port, false, false); + if enabled { + udp.exchange(b"carrier-is-not-routed-business-udp"); + } else { + let packet = udp.packet(b"routed-udp-denied"); + udp.client.send_to(&packet, udp.relay).unwrap(); + udp.origin.quiet(); + } + core.stop(); + } + json!({"upstream_business_udp_disabled":true,"carrier_tcp":true,"carrier_udp":true,"leaf_business_udp_rejected":true}) +} + +fn domain_terminal(f: &Value) -> Vec { + if f["last"]["type"] != "trojan" { + return vec![]; + } + let _case = RecordedCase::new("INTEGRATION-PAIR", "native_domain_terminal"); + let mut observations = Vec::new(); + for outer_ipv6 in [false, true] { + for grouped in [false, true] { + let port = free_port(); + let mut first = f["first"].clone(); + let mut last = f["domain_last"].clone(); + first["name"] = json!("hop"); + if outer_ipv6 { + first["server"] = f["first_ipv6"].clone(); + last["server"] = f["domain_last_ipv6"].clone(); + } + last["dialer-proxy"] = json!(if grouped { "outer" } else { "hop" }); + let mut yaml = json!({"socks-port":port,"proxies":[last,first],"rules":["MATCH,peer"]}); + if grouped { + yaml["proxy-groups"] = json!([{"name":"outer","type":"select","proxies":["inner"]},{"name":"inner","type":"select","proxies":["hop"]}]); + } + let core = Core::start(&yaml); + let mut udp = Association::new(f, port, false, true); + for size in [1, 64, 512, 1200] { + for sequence in 0..100_u8 { + udp.exchange(&vec![sequence; size]); + } + } + core.stop(); + observations.push(json!({"outer_ipv6":outer_ipv6,"nested_select":grouped,"peer":"XR","udp_packets":400,"target":"domain"})); + } + } + observations +} diff --git a/tests/vless_public/integration/pressure.rs b/tests/vless_public/integration/pressure.rs new file mode 100644 index 0000000..92b58f0 --- /dev/null +++ b/tests/vless_public/integration/pressure.rs @@ -0,0 +1,342 @@ +use super::*; + +const NEW_PROTOCOLS: [&str; 4] = ["trojan", "vmess", "vless", "hysteria2"]; + +struct Flows { + tcp: Vec<(TcpStream, Origin)>, + udp: Vec, + setup_us: Vec, +} + +impl Flows { + fn open(f: &Value, port: u16, controller: u16, generation: usize) -> Self { + let mut flows = Self { + tcp: Vec::with_capacity(20), + udp: Vec::with_capacity(20), + setup_us: Vec::with_capacity(40), + }; + for protocol in NEW_PROTOCOLS { + runtime::select(controller, protocol); + for _ in 0..5 { + let start = Instant::now(); + flows.tcp.push(runtime::live(port, f)); + flows.setup_us.push(start.elapsed().as_micros() as u64); + let start = Instant::now(); + let mut udp = Association::new(f, port, false, false); + udp.exchange(&payload(generation, flows.udp.len(), 0, true)); + flows.udp.push(udp); + flows.setup_us.push(start.elapsed().as_micros() as u64); + } + } + assert_eq!((flows.tcp.len(), flows.udp.len()), (20, 20)); + flows + } + + fn exchange(&mut self, generation: usize, wave: usize) { + for (index, ((tcp, _), udp)) in self.tcp.iter_mut().zip(&mut self.udp).enumerate() { + runtime::exchange(tcp, &payload(generation, index, wave, false)); + udp.exchange(&payload(generation, index, wave, true)); + } + } + + fn assert_tcp_closed(&mut self) { + for (tcp, _) in &mut self.tcp { + runtime::assert_closed(tcp); + } + } +} + +fn payload(generation: usize, index: usize, wave: usize, udp: bool) -> Vec { + let mut bytes = vec![u8::from(udp); 257]; + for (slot, value) in bytes + .as_chunks_mut::<8>() + .0 + .iter_mut() + .zip([generation, index, wave]) + { + slot.copy_from_slice(&(value as u64).to_be_bytes()); + } + bytes +} + +fn close_peer(f: &Value) { + let mut client = socket(f["peer_controller"].as_str().unwrap().parse().unwrap()); + client.write_all(b"DELETE /connections HTTP/1.1\r\nHost: fixture\r\nAuthorization: Bearer synthetic-integration-control\r\nConnection: close\r\n\r\n").unwrap(); + let mut response = Vec::new(); + client.read_to_end(&mut response).unwrap(); + assert!(response.starts_with(b"HTTP/1.1 204")); +} + +fn start(f: &Value, probe: &ResourceProbe) -> (Core, u16, u16) { + let port = free_port(); + let controller = free_port(); + let nodes: Vec<_> = NEW_PROTOCOLS + .iter() + .map(|protocol| { + let mut node = f["nodes"][protocol].clone(); + node["name"] = json!(protocol); + node + }) + .collect(); + let yaml = json!({"socks-port":port,"external-controller":format!("127.0.0.1:{controller}"),"secret":"fixture-only","proxies":nodes,"proxy-groups":[{"name":"inner","type":"select","proxies":NEW_PROTOCOLS}],"rules":["MATCH,inner"]}); + (probe.scope_sync(|| Core::start(&yaml)), port, controller) +} + +fn sample(probe: &ResourceProbe) -> Value { + json!({"heap_in_use":heap_in_use(),"rss_kib":rss_kib(),"fd":lifecycle::fd_count(),"resources":probe.snapshot(),"queues":probe.queues()}) +} + +fn rss_kib() -> u64 { + let output = std::process::Command::new("/bin/ps") + .args(["-o", "rss=", "-p", &std::process::id().to_string()]) + .output() + .unwrap(); + assert!(output.status.success()); + std::str::from_utf8(&output.stdout) + .unwrap() + .trim() + .parse() + .unwrap() +} + +fn heap_in_use() -> usize { + #[repr(C)] + #[derive(Default)] + struct Statistics { + blocks_in_use: std::ffi::c_uint, + size_in_use: usize, + max_size_in_use: usize, + size_allocated: usize, + } + unsafe extern "C" { + fn malloc_zone_statistics(zone: *mut std::ffi::c_void, statistics: *mut Statistics); + } + let mut statistics = Statistics::default(); + // SAFETY: macOS SDK C layout; NULL requests aggregate allocator statistics. + unsafe { malloc_zone_statistics(std::ptr::null_mut(), &mut statistics) }; + statistics.size_in_use +} + +fn stop_quiet( + core: Core, + probe: &ResourceProbe, + port: u16, + controller: u16, + baseline: usize, +) -> Value { + let start = Instant::now(); + core.stop(); + let stop_ms = start.elapsed().as_millis(); + let resources = probe.snapshot(); + assert!(stop_ms < 5000 && resources.is_idle()); + let after_fd = lifecycle::fd_count(); + assert!( + after_fd <= baseline, + "FD baseline {baseline}, after Stop {after_fd}" + ); + let after_stop = sample(probe); + let rebound = ( + TcpListener::bind((Ipv4Addr::LOCALHOST, port)).unwrap(), + UdpSocket::bind((Ipv4Addr::LOCALHOST, port)).unwrap(), + TcpListener::bind((Ipv4Addr::LOCALHOST, controller)).unwrap(), + ); + let quiet = Instant::now(); + while quiet.elapsed() < Duration::from_secs(5) { + assert_eq!(probe.snapshot(), resources); + assert!(lifecycle::fd_count() <= baseline + 3); + thread::sleep(Duration::from_millis(25)); + } + drop(rebound); + json!({"stop_ms":stop_ms,"after_stop":after_stop,"quiet_seconds":quiet.elapsed().as_secs_f64(),"quiet":sample(probe),"ports_rebound":true}) +} + +#[test] +#[ignore = "owned INTEGRATION container fixture required"] +fn rebuild() { + run_rebuild(100); +} + +#[test] +#[ignore = "owned INTEGRATION container fixture required; development subset only"] +fn rebuild_tracer() { + run_rebuild(3); +} + +fn idle_baseline() -> (usize, usize) { + let cold = lifecycle::fd_count(); + // Tokio's process-global Unix signal pair survives individual runtimes. + // Initialize ONLY an empty IO driver before freezing the baseline; never + // warm away leaked protocol resources or grant post-Stop cleanup grace. + drop( + tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build() + .unwrap(), + ); + let idle = lifecycle::fd_count(); + assert!(idle >= cold && idle - cold <= 2); + (cold, idle) +} + +fn run_rebuild(count: usize) { + let f = fixture(); + initialize(&f); + let probe = ResourceProbe::default(); + let (cold_fd, baseline) = idle_baseline(); + let mut case = RecordedCase::new("INTEGRATION-REBUILD", "forty_flows_same_session"); + case.checkpoint("baseline", probe.snapshot()); + let (core, port, controller) = start(&f, &probe); + let mut cycles = Vec::new(); + for generation in 0..count { + let mut flows = Flows::open(&f, port, controller, generation); + flows.exchange(generation, 1); + let active = sample(&probe); + close_peer(&f); + flows.assert_tcp_closed(); + let setup = flows.setup_us.clone(); + drop(flows); + cycles.push(json!({"generation":generation,"tcp":20,"udp":20,"per_protocol_tcp":5,"per_protocol_udp":5,"active":active,"setup_us":setup,"fault":"owned-peer-connections-closed","new_clients":true,"after_clients":sample(&probe)})); + println!("INTEGRATION rebuild: {}/{count}", generation + 1); + } + let stopped = stop_quiet(core, &probe, port, controller, baseline); + case.resources(probe.snapshot()); + observe( + json!({"count":count,"same_running_session":true,"protocols":NEW_PROTOCOLS,"cold_fd":cold_fd,"baseline_fd":baseline,"cycles":cycles,"stopped":stopped}), + ); +} + +fn median(values: impl Iterator) -> u64 { + let mut values: Vec<_> = values.collect(); + values.sort_unstable(); + assert!(!values.is_empty()); + (values[(values.len() - 1) / 2] + values[values.len() / 2]) / 2 +} + +fn append_sample(value: &Value) { + let path = std::env::var("VCORE_INTEGRATION_OBSERVATIONS").unwrap() + ".jsonl"; + let mut file = std::fs::OpenOptions::new() + .append(true) + .create(true) + .open(path) + .unwrap(); + writeln!(file, "{value}").unwrap(); +} + +#[test] +#[ignore = "owned INTEGRATION container fixture required; at least 1800 seconds"] +fn soak() { + run_soak(1800); +} + +#[test] +#[ignore = "owned INTEGRATION container fixture required; development subset only"] +fn soak_tracer() { + run_soak(65); +} + +fn run_soak(seconds: u64) { + let f = fixture(); + initialize(&f); + let (cold_fd, baseline) = idle_baseline(); + let probe = ResourceProbe::default(); + let mut case = RecordedCase::new("INTEGRATION-SOAK", "mixed_forty_flows"); + case.checkpoint("baseline", probe.snapshot()); + let (core, port, controller) = start(&f, &probe); + let mut flows = Flows::open(&f, port, controller, 0); + let first_setup = flows.setup_us.clone(); + let first_median = median(first_setup.iter().copied()); + let start = Instant::now(); + let mut waves = 0; + let mut switches = Vec::new(); + let mut faults = Vec::new(); + let mut samples = Vec::new(); + let mut next_switch = 1; + let mut next_fault = 60; + let mut next_sample = if seconds >= 1800 { 300 } else { 0 }; + while start.elapsed() < Duration::from_secs(seconds) { + flows.exchange(faults.len(), waves); + waves += 1; + while start.elapsed().as_secs() >= next_switch { + runtime::select(controller, NEW_PROTOCOLS[switches.len() % 4]); + switches.push(start.elapsed().as_secs_f64()); + next_switch += 1; + } + if start.elapsed().as_secs() >= next_fault { + let fault = start.elapsed().as_secs_f64(); + close_peer(&f); + flows.assert_tcp_closed(); + drop(flows); + flows = Flows::open(&f, port, controller, faults.len() + 1); + faults.push(json!({"start":fault,"end":start.elapsed().as_secs_f64(),"old_tcp_closed":20,"new_tcp":20,"new_udp":20,"replay":false})); + next_fault += 60; + } + if start.elapsed().as_secs() >= next_sample { + let mut point = sample(&probe); + point["seconds"] = json!(start.elapsed().as_secs_f64()); + point["waves"] = json!(waves); + point["faults"] = json!(faults.len()); + append_sample(&point); + samples.push(point); + next_sample += 60; + println!( + "INTEGRATION soak: {:.1}/{seconds}s, waves={waves}, faults={}", + start.elapsed().as_secs_f64(), + faults.len() + ); + } + thread::sleep(Duration::from_millis(100)); + } + let elapsed = start.elapsed().as_secs_f64(); + let last_setup = flows.setup_us.clone(); + let last_median = median(last_setup.iter().copied()); + assert!(last_median <= first_median * 2); + let active_end = sample(&probe); + drop(flows); + let stopped = stop_quiet(core, &probe, port, controller, baseline); + case.resources(probe.snapshot()); + let mut report = json!({"seconds":elapsed,"requested_seconds":seconds,"protocols":NEW_PROTOCOLS,"tcp":20,"udp":20,"per_protocol_tcp":5,"per_protocol_udp":5,"waves":waves,"normal_verified_bytes":waves*20*2*2*257,"normal_corruption":0,"normal_misdirection":0,"normal_unexpected_loss":0,"switches":switches,"faults":faults,"cold_fd":cold_fd,"baseline_fd":baseline,"samples":samples,"first_setup_us":first_setup,"last_setup_us":last_setup,"first_setup_median_us":first_median,"last_setup_median_us":last_median,"active_end":active_end,"stopped":stopped}); + if seconds >= 1800 { + assert!(samples.len() >= 25 && faults.len() >= 29 && switches.len() >= 1799); + let early = &samples[..10]; + let late = &samples[samples.len() - 10..]; + let first_heap = median(early.iter().map(|p| p["heap_in_use"].as_u64().unwrap())); + let last_heap = median(late.iter().map(|p| p["heap_in_use"].as_u64().unwrap())); + let allowed = (1024 * 1024).max(first_heap / 20); + report["heap"] = + json!({"early_median":first_heap,"late_median":last_heap,"allowed_growth":allowed}); + // Persist the complete curve even if its final acceptance fails. + observe(report.clone()); + assert!( + last_heap <= first_heap + allowed, + "heap growth exceeds INTEGRATION bound" + ); + for kind in 0..8 { + let maximum = |points: &[Value]| { + points + .iter() + .map(|p| p["resources"]["counts"][kind]["current"].as_u64().unwrap()) + .max() + .unwrap() + }; + assert!( + maximum(late) <= maximum(early), + "growing live resource kind {kind}" + ); + } + assert!( + late.iter().map(|p| p["fd"].as_u64().unwrap()).max() + <= early.iter().map(|p| p["fd"].as_u64().unwrap()).max() + ); + assert!( + samples + .iter() + .all(|p| p["queues"].as_array().unwrap().iter().all(|q| q["peak"] + .as_u64() + .unwrap() + > 0 + && q["peak"].as_u64() <= q["capacity"].as_u64())) + ); + } + observe(report); +} diff --git a/tests/vless_public/integration/shadowsocks.rs b/tests/vless_public/integration/shadowsocks.rs new file mode 100644 index 0000000..7774077 --- /dev/null +++ b/tests/vless_public/integration/shadowsocks.rs @@ -0,0 +1,110 @@ +use super::*; +use base64::{Engine as _, engine::general_purpose::STANDARD}; + +fn cipher(node: &Value) -> &'static str { + match node["cipher"].as_str().unwrap() { + "2022-blake3-aes-128-gcm" => "2022-blake3-aes-128-gcm", + "2022-blake3-aes-256-gcm" => "2022-blake3-aes-256-gcm", + "2022-blake3-chacha20-poly1305" => "2022-blake3-chacha20-poly1305", + _ => panic!("unexpected SS algorithm"), + } +} + +pub(super) fn rejected(node: Value, f: &Value) { + let port = free_port(); + let probe = ResourceProbe::default(); + let core = probe.scope_sync(|| Core::start(&config(node, port))); + let mut origin = Origin::new(f, 13, false); + let mut client = login(port); + let mut request = vec![5, 1, 0]; + request.extend(address(origin.target, false)); + client.write_all(&request).unwrap(); + let mut response = [0; 10]; + client.read_exact(&mut response).unwrap(); + if response[1] == 0 { + let _ = client.write_all(b"invalid-identity-must-not-reach-origin"); + runtime::assert_closed(&mut client); + } + origin.quiet(); + core.stop(); + assert!(probe.snapshot().is_idle()); + origin.quiet(); +} + +#[test] +#[ignore = "owned INTEGRATION official Mihomo container required"] +fn algorithms() { + let f = fixture(); + initialize(&f); + let nodes = f["ss_nodes"].as_array().unwrap(); + assert_eq!(nodes.len(), 3); + let mut observations = Vec::new(); + for node in nodes { + let _case = RecordedCase::new("INTEGRATION-SS-ALGORITHMS", cipher(node)); + let port = free_port(); + let core = Core::start(&config(node.clone(), port)); + for (ipv6, domain) in [(false, false), (true, false), (false, true)] { + bulk(port, &f, ipv6, domain); + let mut udp = Association::new(&f, port, ipv6, domain); + for size in [1, 64, 512, 1200, 4096] { + for sequence in 0..100_u8 { + udp.exchange(&vec![sequence; size]); + } + } + } + core.stop(); + let mut bad = node.clone(); + let length = if node["cipher"] == "2022-blake3-aes-128-gcm" { + 16 + } else { + 32 + }; + bad["password"] = json!(STANDARD.encode(vec![23; length])); + rejected(bad, &f); + observations.push(json!({"cipher":node["cipher"],"tcp_targets":3,"tcp_bytes_each_direction":31457280,"udp_targets":3,"udp_packets":1500,"wrong_key_rejected":true})); + } + observe(json!({"algorithms":observations})); +} + +#[test] +#[ignore = "owned INTEGRATION official ssserver container required"] +fn eih() { + let f = fixture(); + initialize(&f); + let nodes = f["eih_nodes"].as_array().unwrap(); + assert_eq!(nodes.len(), 2); + let mut observations = Vec::new(); + for node in nodes { + let _case = RecordedCase::new("INTEGRATION-SS-EIH", cipher(node)); + for outer_ipv6 in [false, true] { + let port = free_port(); + let mut node = node.clone(); + if outer_ipv6 { + node["server"] = f["eih_ipv6"].clone(); + } + let core = Core::start(&config(node, port)); + for ipv6 in [false, true] { + bulk(port, &f, ipv6, false); + let mut udp = Association::new(&f, port, ipv6, false); + for size in [1, 64, 512, 1200, 4096] { + println!("EIH UDP: outer_ipv6={outer_ipv6}, inner_ipv6={ipv6}, size={size}"); + for sequence in 0..100_u8 { + udp.exchange(&vec![sequence; size]); + } + } + } + core.stop(); + } + let keys: Vec<_> = node["password"].as_str().unwrap().split(':').collect(); + let length = STANDARD.decode(keys[0]).unwrap().len(); + for index in 0..2 { + let mut keys: Vec<_> = keys.iter().map(ToString::to_string).collect(); + keys[index] = STANDARD.encode(vec![23; length]); + let mut bad = node.clone(); + bad["password"] = json!(keys.join(":")); + rejected(bad, &f); + } + observations.push(json!({"cipher":node["cipher"],"identity_depth":1,"outer_families":2,"tcp_targets":2,"tcp_bytes_each_direction":41943040,"udp_targets":2,"udp_packets":2000,"wrong_identity_rejected":true,"wrong_user_rejected":true})); + } + observe(json!({"native_terminal_eih":observations,"arbitrary_relay_claim":false})); +} diff --git a/tests/vless_public/runtime.rs b/tests/vless_public/runtime.rs new file mode 100644 index 0000000..95dd57c --- /dev/null +++ b/tests/vless_public/runtime.rs @@ -0,0 +1,676 @@ +use super::*; + +#[test] +#[ignore = "isolated VLESS runner"] +fn public_udp_isolation() { + let _case = Case::start("VLESS-PUBLIC", "runtime::public_udp_isolation"); + let f = fixture(); + initialize(&f); + let port = free_port(); + for codec in if f["node"]["flow"] == "xtls-rprx-vision" { + vec!["xudp"] + } else { + vec!["none", "xudp", "packetaddr"] + } { + let mut node = f["node"].clone(); + node["packet-encoding"] = json!(codec); + let core = Core::start(&config(node, port)); + let mut first = Association::new(&f, port, false, false); + let mut second = Association::new(&f, port, false, false); + for n in 0..100 { + first.exchange(&[1, n]); + second.exchange(&[2, n]); + } + let rogue = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + rogue + .send_to(&first.packet(b"unauthorized"), first.relay) + .unwrap(); + first.origin.quiet(); + // Public SOCKS ingress budget is independent of the native peer's + // smaller path limit. Reject before contacting the real origin. + let cap = 65245; + first + .client + .send_to(&first.packet(&vec![0; cap + 1]), first.relay) + .unwrap(); + first.origin.quiet(); + second.exchange(b"oversize-isolated"); + // A different target in the same association must not be delivered to + // the original raw target. Encoded transports carry the new target. + let mut other = Origin::new(&f, 4, false); + let mut packet = vec![0, 0, 0]; + packet.extend(address(other.target, false)); + packet.extend(b"other-target"); + second.client.send_to(&packet, second.relay).unwrap(); + if codec == "none" + && !(f["node"]["smux"]["enabled"] == true && f["node"]["smux"]["only-tcp"] != true) + { + other.quiet(); + } else { + other.udp(b"other-target"); + let mut response = [0; 128]; + let (n, _) = second.client.recv_from(&mut response).unwrap(); + assert!(response[..n].ends_with(b"other-target")); + } + second.origin.quiet(); + core.stop(); + } +} + +#[test] +#[cfg(target_os = "macos")] +#[ignore = "isolated VLESS runner"] +fn public_entrypoints() { + let _case = Case::start("VLESS-PUBLIC", "runtime::public_entrypoints"); + let f = fixture(); + entrypoints(&f); +} + +#[cfg(target_os = "macos")] +pub(super) fn entrypoints(f: &Value) { + use std::os::{fd::AsRawFd, unix::net::UnixDatagram}; + initialize(f); + let port = free_port(); + let yaml = json!({"port":port,"proxies":[f["node"]],"rules":["MATCH,peer"]}); + let core = Core::start(&yaml); + let mut origin = Origin::new(f, 14, false); + let mut client = socket((Ipv4Addr::LOCALHOST, port).into()); + write!( + client, + "GET http://{}/ HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n", + origin.target, origin.target + ) + .unwrap(); + let mut response = Vec::new(); + client.read_to_end(&mut response).unwrap(); + assert!(response.starts_with(b"HTTP/1.1 200")); + origin.marker(b'A'); + origin.marker(b'D'); + let mut origin = Origin::new(f, 13, false); + let mut client = socket((Ipv4Addr::LOCALHOST, port).into()); + write!( + client, + "CONNECT {} HTTP/1.1\r\nHost: {}\r\n\r\n", + origin.target, origin.target + ) + .unwrap(); + let mut header = Vec::new(); + while !header.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + client.read_exact(&mut byte).unwrap(); + header.extend(byte); + assert!(header.len() < 4096); + } + assert!(header.starts_with(b"HTTP/1.1 200")); + exchange(&mut client, b"http-connect"); + origin.marker(b'A'); + core.stop(); + assert_closed(&mut client); + origin.marker(b'D'); + drop((client, origin)); + let (host, peer) = UnixDatagram::pair().unwrap(); + host.set_nonblocking(true).unwrap(); + peer.set_read_timeout(Some(TIMEOUT)).unwrap(); + let mut dns_origin = Origin::new(f, 17, false); + let mut yaml = json!({"tun":{"enable":true},"proxies":[f["node"]],"rules":["MATCH,peer"]}); + dns(&mut yaml, &dns_origin, "peer"); + let core = Core::prepare(&yaml); + invoke( + "start", + core.0.as_deref(), + json!({"tunFd":host.as_raw_fd(),"tunFraming":"utun"}), + ); + let mut udp = Origin::new(f, 4, false); + peer.send(&tun::packet(udp.target, 17, (0, 0, 0), b"tun-udp")) + .unwrap(); + udp.udp(b"tun-udp"); + assert_eq!(&tun::receive(&peer, 17)[28..], b"tun-udp"); + let mut origin = Origin::new(f, 13, false); + peer.send(&tun::packet(origin.target, 6, (1, 0, 2), &[])) + .unwrap(); + let syn = tun::receive(&peer, 6); + assert_eq!(syn[33] & 0x12, 0x12); + let ack = u32::from_be_bytes(syn[24..28].try_into().unwrap()).wrapping_add(1); + peer.send(&tun::packet(origin.target, 6, (2, ack, 0x18), b"tun-tcp")) + .unwrap(); + let mut reply = Vec::new(); + while reply.len() < 7 { + let packet = tun::receive(&peer, 6); + let offset = 20 + usize::from(packet[32] >> 4) * 4; + reply.extend_from_slice(&packet[offset..]); + } + assert_eq!(reply, b"tun-tcp"); + origin.marker(b'A'); + let query=b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x0dvcore-fixture\x04test\x00\x00\x01\x00\x01"; + peer.send(&tun::packet( + "198.18.0.1:53".parse().unwrap(), + 17, + (0, 0, 0), + query, + )) + .unwrap(); + // DNS rewrites the query ID; observe the question on the native peer path. + let mut size = [0; 4]; + dns_origin.observer.read_exact(&mut size).unwrap(); + let mut observed = vec![0; u16::from_be_bytes([size[0], size[1]]) as usize]; + dns_origin.observer.read_exact(&mut observed).unwrap(); + assert_eq!(&observed[12..], &query[12..]); + let response = tun::receive(&peer, 17); + assert_eq!(&response[28..30], &query[..2]); + assert!(response.ends_with(&match udp.target.ip() { + IpAddr::V4(ip) => ip.octets(), + _ => unreachable!(), + })); + core.stop(); + origin.marker(b'D'); + assert!(unsafe { libc::fcntl(host.as_raw_fd(), libc::F_GETFL) } & libc::O_NONBLOCK != 0); +} + +fn fd_count() -> usize { + std::fs::read_dir("/dev/fd").unwrap().count() +} +pub(super) fn assert_closed(client: &mut TcpStream) { + match client.read(&mut [0; 32]) { + Ok(0) => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::ConnectionReset | io::ErrorKind::BrokenPipe + ) => {} + other => panic!("stream not closed: {other:?}"), + } +} +pub(super) fn live(port: u16, f: &Value) -> (TcpStream, Origin) { + let mut origin = Origin::new(f, 13, false); + let mut client = connect(port, origin.target, false); + exchange(&mut client, b"live"); + origin.marker(b'A'); + (client, origin) +} +pub(super) fn exchange(client: &mut TcpStream, bytes: &[u8]) { + client.write_all(bytes).unwrap(); + let mut result = vec![0; bytes.len()]; + client.read_exact(&mut result).unwrap(); + assert_eq!(result, bytes); +} + +#[test] +#[ignore = "isolated VLESS runner"] +fn public_lifecycle() { + let _case = Case::start("VLESS-PUBLIC", "runtime::public_lifecycle"); + let f = fixture(); + initialize(&f); + Core::start(&config(f["node"].clone(), free_port())).stop(); + for cycle in 0..20 { + let _cycle = Case::start("VLESS-LIFE", "stop_and_remain_quiet"); + let baseline = fd_count(); + let port = free_port(); + let mut held = None; + let mut udp = None; + let mut blackholes = Vec::new(); + match cycle % 5 { + 0 => { + let core = Core::start(&config(f["node"].clone(), port)); + echo(port, &f); + core.stop(); + } + 3 => { + let occupied = UdpSocket::bind((Ipv4Addr::LOCALHOST, port)).unwrap(); + let core = Core::prepare(&config(f["node"].clone(), port)); + assert_eq!( + invoke_response("start", core.0.as_deref(), json!({}))["success"], + false + ); + core.stop(); + drop(occupied); + } + 2 => { + let h3 = f["node"]["alpn"] == json!(["h3"]); + let origin = Origin::new(&f, if h3 { 20 } else { 15 }, false); + let mut node = f["node"].clone(); + node["server"] = json!(origin.target.ip().to_string()); + node["port"] = json!(origin.target.port()); + let target = origin.target; + blackholes.push((origin, h3)); + // Explicit XHTTP legs start concurrently. Give each its own + // observer: the serial TCP blackhole otherwise reports the + // second queued accept only after the first connection closes. + if node["network"] == "xhttp" && node["xhttp-opts"]["download-settings"].is_object() + { + let download = &mut node["xhttp-opts"]["download-settings"]; + let down_h3 = download.get("alpn").map_or(h3, |v| *v == json!(["h3"])); + let origin = Origin::new(&f, if down_h3 { 20 } else { 15 }, false); + download["server"] = json!(origin.target.ip().to_string()); + download["port"] = json!(origin.target.port()); + blackholes.push((origin, down_h3)); + } + let core = Core::start(&config(node, port)); + let mut client = login(port); + let mut request = vec![5, 1, 0]; + request.extend(address(target, false)); + client.write_all(&request).unwrap(); + // Prove every physical handshake is in flight before Stop. + for (origin, _) in &mut blackholes { + origin.marker(b'A'); + } + core.stop(); + let _ = client.read(&mut [0; 32]); + drop(client); + for (origin, quic) in &mut blackholes { + if !*quic { + origin.marker(b'D'); + } + } + } + _ => { + let core = Core::start(&config(f["node"].clone(), port)); + let (first, mut origin1) = live(port, &f); + let (mut second, origin2) = live(port, &f); + let mut one = Association::new(&f, port, false, false); + let mut two = Association::new(&f, port, false, false); + one.exchange(b"association-1"); + two.exchange(b"association-2"); + drop(first); + origin1.marker(b'D'); + exchange(&mut second, b"sibling-survives"); + one.exchange(b"udp-survives"); + two.exchange(b"udp-other"); + drop(origin1); + core.stop(); + assert_closed(&mut second); + let mut origin2 = origin2; + origin2.marker(b'D'); + assert_closed(&mut one.control); + assert_closed(&mut two.control); + held = Some((second, origin2)); + udp = Some((one, two)); + } + } + // All retained descriptors belong to the test, never the runtime. Check + // immediately on Stop return, not after giving cleanup a grace period. + let retained = + usize::from(held.is_some()) * 2 + usize::from(udp.is_some()) * 6 + blackholes.len(); + assert!( + fd_count() <= baseline + retained, + "descriptors retained at Stop return" + ); + let tcp = TcpListener::bind((Ipv4Addr::LOCALHOST, port)).unwrap(); + let udp_guard = UdpSocket::bind((Ipv4Addr::LOCALHOST, port)).unwrap(); + let quiet = Instant::now(); + while quiet.elapsed() < Duration::from_secs(5) { + assert!(fd_count() <= baseline + retained + 2); + if let Some((first, second)) = &mut udp { + for item in [first, second] { + item.client.set_nonblocking(true).unwrap(); + assert_eq!( + item.client.recv(&mut [0; 64]).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + item.origin.observer.set_nonblocking(true).unwrap(); + assert_eq!( + item.origin.observer.read(&mut [0]).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + } + } + for (origin, _) in &mut blackholes { + origin.observer.set_nonblocking(true).unwrap(); + let mut marker = [0; 2]; + let result = origin.observer.read(&mut marker); + assert!( + matches!(&result, Err(error) if error.kind() == io::ErrorKind::WouldBlock), + "blackhole event after Stop: {result:?}, {marker:?}" + ); + } + thread::sleep(Duration::from_millis(25)); + } + drop((held, udp, blackholes, tcp, udp_guard)); + assert!(fd_count() <= baseline); + } +} + +pub(super) fn select(controller: u16, name: &str) { + let body = json!({"name":name}).to_string(); + let mut client = socket((Ipv4Addr::LOCALHOST, controller).into()); + write!(client,"PUT /proxies/inner HTTP/1.1\r\nHost: fixture\r\nAuthorization: Bearer fixture-only\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()).unwrap(); + let mut response = Vec::new(); + client.read_to_end(&mut response).unwrap(); + assert!(response.starts_with(b"HTTP/1.1 204")); +} + +#[test] +#[ignore = "isolated VLESS runner"] +fn grpc_pool_keeps_physical_selection_until_new_transport() { + let _case = Case::start( + "VLESS-PUBLIC", + "runtime::grpc_pool_keeps_physical_selection_until_new_transport", + ); + let f = fixture(); + initialize(&f); + let port = free_port(); + let controller = free_port(); + let mut node = f["node"].clone(); + node["dialer-proxy"] = json!("inner"); + let mut yaml = config(node, port); + yaml["proxies"] + .as_array_mut() + .unwrap() + .push(f["hop"].clone()); + yaml["external-controller"] = json!(format!("127.0.0.1:{controller}")); + yaml["secret"] = json!("fixture-only"); + yaml["proxy-groups"] = json!([{"name":"inner","type":"select","proxies":["hop","REJECT"]}]); + let core = Core::start(&yaml); + let (mut old, mut origin) = live(port, &f); + select(controller, "REJECT"); + if f["node"]["network"] == "xhttp" + && f["node"]["alpn"] == json!(["http/1.1"]) + && f["node"]["smux"]["enabled"] != true + { + // H1 can recycle a completed POST, but an active download GET cannot + // carry another logical session. Its new physical connection must see + // the new REJECT selection; the old session keeps its original path. + let mut unused = Origin::new(&f, 13, false); + let mut denied = login(port); + let mut request = vec![5, 1, 0]; + request.extend(address(unused.target, false)); + denied.write_all(&request).unwrap(); + let mut reply = [0; 10]; + denied.read_exact(&mut reply).unwrap(); + if reply[1] == 0 { + // A lazily opened XHTTP request can report its failure after the + // local SOCKS handshake, but must never reach the target. + denied.write_all(b"must-not-reach-origin").unwrap(); + assert_closed(&mut denied); + } + unused.quiet(); + } else { + // Multiplexed transports can reuse the original authenticated hop. + echo(port, &f); + } + exchange(&mut old, b"same-transport"); + core.stop(); + assert_closed(&mut old); + origin.marker(b'D'); +} +#[test] +#[ignore = "isolated VLESS runner"] +fn public_graph() { + let _case = Case::start("VLESS-PUBLIC", "runtime::public_graph"); + let f = fixture(); + initialize(&f); + let port = free_port(); + let controller = free_port(); + let mut node = f["node"].clone(); + if node["network"] == "grpc" { + // Force expansion for an active stream to distinguish new physical + // selection from existing-pool snapshot reuse (tested separately). + node["grpc-opts"]["max-connections"] = json!(0); + node["grpc-opts"]["max-streams"] = json!(1); + } + if node["network"] == "xhttp" { + node["xhttp-opts"]["reuse-settings"] = json!({"h-max-request-times":"1"}); + if node["xhttp-opts"]["download-settings"].is_object() { + node["xhttp-opts"]["download-settings"]["reuse-settings"] = + json!({"h-max-request-times":"1"}); + } + } + if node["smux"]["enabled"] == true { + node["smux"]["max-connections"] = json!(0); + node["smux"]["max-streams"] = json!(1); + } + node["dialer-proxy"] = json!("hop"); + node["server"] = json!("peer.fixture.test"); + let mut yaml = config(node.clone(), port); + let mut dns_origin = Origin::new(&f, 19, false); + dns_origin + .observer + .write_all( + &f["node"]["server"] + .as_str() + .unwrap() + .parse::() + .unwrap() + .octets(), + ) + .unwrap(); + dns(&mut yaml, &dns_origin, "DIRECT"); + yaml["proxies"] + .as_array_mut() + .unwrap() + .push(f["hop"].clone()); + let core = Core::start(&yaml); + echo(port, &f); + let mut a = Association::new(&f, port, false, false); + a.exchange(b"concrete-hop"); + core.stop(); + drop(a); + node["server"] = f["node"]["server"].clone(); + node["dialer-proxy"] = json!("outer"); + yaml["proxies"][0] = node; + yaml["external-controller"] = json!(format!("127.0.0.1:{controller}")); + yaml["secret"] = json!("fixture-only"); + yaml["proxy-groups"] = json!([{"name":"outer","type":"select","proxies":["inner"]},{"name":"inner","type":"select","proxies":["hop","DIRECT","REJECT"]}]); + let core = Core::start(&yaml); + let (mut stream, mut origin) = live(port, &f); + let mut udp = Association::new(&f, port, false, false); + udp.exchange(b"before-switch"); + select(controller, "REJECT"); + exchange(&mut stream, b"old-stream-snapshot"); + udp.exchange(b"old-udp-snapshot"); + let mut unused = Origin::new(&f, 13, false); + let mut denied = login(port); + let mut request = vec![5, 1, 0]; + request.extend(address(unused.target, false)); + denied.write_all(&request).unwrap(); + let mut result = [0; 10]; + denied.read_exact(&mut result).unwrap(); + assert_ne!(result[1], 0); + unused.quiet(); + drop((denied, unused)); + select(controller, "DIRECT"); + echo(port, &f); + exchange(&mut stream, b"still-old-snapshot"); + core.stop(); + assert_closed(&mut stream); + origin.marker(b'D'); + drop((stream, origin, udp)); + // Even an unselected cycle is rejected before any start/socket creation. + yaml["proxy-groups"][1]["proxies"] = json!(["DIRECT", "peer"]); + assert_eq!( + invoke_response( + "validateConfig", + None, + json!({"configYaml":yaml.to_string()}) + )["success"], + false + ); + // A failing selected concrete upstream never switches to DIRECT. + let mut broken = f["hop"].clone(); + broken["port"] = json!(1); + yaml["proxies"][1] = broken; + yaml["proxy-groups"][1]["proxies"] = json!(["hop", "DIRECT"]); + let core = Core::start(&yaml); + let mut unused = Origin::new(&f, 13, false); + let mut denied = login(port); + let mut request = vec![5, 1, 0]; + request.extend(address(unused.target, false)); + denied.write_all(&request).unwrap(); + denied.read_exact(&mut result).unwrap(); + assert_ne!(result[1], 0); + unused.quiet(); + core.stop(); +} + +#[test] +#[ignore = "isolated VLESS runner"] +fn public_ipv6_and_gates() { + let _case = Case::start("VLESS-PUBLIC", "runtime::public_ipv6_and_gates"); + let f = fixture(); + initialize(&f); + let port = free_port(); + let mut node = f["node"].clone(); + node["server"] = f["server_ipv6"].clone(); + let core = Core::start(&config(node, port)); + echo(port, &f); + let mut udp = Association::new(&f, port, true, false); + udp.exchange(b"outer-inner-ipv6"); + core.stop(); + drop(udp); + let mut node = f["node"].clone(); + node["udp"] = json!(false); + let mut yaml = config(node, port); + yaml["ipv6"] = json!(false); + let core = Core::start(&yaml); + echo(port, &f); + let mut udp = Association::new(&f, port, false, false); + udp.client + .send_to(&udp.packet(b"disabled"), udp.relay) + .unwrap(); + udp.origin.quiet(); + let mut origin = Origin::new(&f, 13, true); + let mut client = login(port); + let mut request = vec![5, 1, 0]; + request.extend(address(origin.target, false)); + client.write_all(&request).unwrap(); + let mut response = [0; 10]; + client.read_exact(&mut response).unwrap(); + assert_ne!(response[1], 0); + origin.quiet(); + core.stop(); +} + +#[tokio::test] +#[ignore = "isolated VLESS runner"] +async fn owned_resources() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use vcore::{ + config::{Config, ProxyProtocol}, + dialer::{Dialer, ResolvedEndpoint}, + outbound::{DatagramRequest, EstablishContext, OutboundConnector, VlessOutbound}, + resources::observation::{ResourceKind, ResourceProbe}, + session::{Datagram, DatagramSession, InboundKind, StreamSession}, + }; + let _case = Case::start("VLESS-PUBLIC", "runtime::owned_resources"); + let f = fixture(); + let parsed = + Config::parse_yaml(config(f["node"].clone(), 1080).to_string().as_bytes()).unwrap(); + let ProxyProtocol::Vless(node) = &parsed.proxies[0].protocol else { + unreachable!() + }; + for _ in 0..20 { + let mut case = Case::start("VLESS-OWNED", "stop_and_remain_quiet"); + let probe = ResourceProbe::default(); + case.checkpoint("baseline", probe.snapshot()); + probe + .scope(async { + let endpoint = ResolvedEndpoint { + logical_host: node.address.clone(), + port: node.port, + addresses: vec![SocketAddr::new(node.address.parse().unwrap(), node.port)], + }; + let download_endpoint = node.download().map(|download| ResolvedEndpoint { + logical_host: download.address.clone(), + port: download.port, + addresses: vec![SocketAddr::new( + download.address.parse().unwrap(), + download.port, + )], + }); + let outbound = VlessOutbound::new_with_endpoints( + node, + endpoint, + download_endpoint, + Dialer::default(), + ) + .unwrap(); + let mut origin1 = Origin::new(&f, 13, false); + let mut origin2 = Origin::new(&f, 13, false); + let mut udp_origin = Origin::new(&f, 4, false); + let session = |target| StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:1".parse().unwrap(), + destination: vcore::session::Destination::Ip(target), + sniffed_domain: None, + }; + let mut first = outbound + .connect_stream(session(origin1.target), &EstablishContext::default()) + .await + .unwrap() + .io; + first.write_all(b"a").await.unwrap(); + first.flush().await.unwrap(); + assert_eq!(first.read_u8().await.unwrap(), b'a'); + origin1.marker(b'A'); + let mut second = outbound + .connect_stream(session(origin2.target), &EstablishContext::default()) + .await + .unwrap() + .io; + second.write_all(b"b").await.unwrap(); + second.flush().await.unwrap(); + assert_eq!(second.read_u8().await.unwrap(), b'b'); + origin2.marker(b'A'); + drop(first); + tokio::task::spawn_blocking(move || origin1.marker(b'D')) + .await + .unwrap(); + second.write_all(b"c").await.unwrap(); + second.flush().await.unwrap(); + assert_eq!(second.read_u8().await.unwrap(), b'c'); + let request = DatagramRequest::new(DatagramSession::new( + InboundKind::InternalMeasure, + "127.0.0.1:1".parse().unwrap(), + )); + let mut udp = outbound + .open_datagram(request, &EstablishContext::default()) + .await + .unwrap(); + udp.send(Datagram { + remote: udp_origin.target.into(), + payload: bytes::Bytes::from_static(b"owned"), + sniffed_domain: None, + }) + .await + .unwrap(); + assert_eq!(udp.receive().await.unwrap().payload.as_ref(), b"owned"); + udp_origin.udp(b"owned"); + for kind in [ + ResourceKind::Association, + ResourceKind::Session, + ResourceKind::Socket, + ] { + assert!(probe.snapshot().peak(kind) > 0); + } + if matches!( + &node.transport, + vcore::config::VlessTransport::Stream( + vcore::config::StreamTransport::Grpc { .. } + | vcore::config::StreamTransport::H2 { .. } + ) + ) { + assert!(probe.snapshot().peak(ResourceKind::Task) > 0); + } + outbound.begin_shutdown(); + assert!(second.read_u8().await.is_err()); + drop(second); + // Stop already cancelled the owned IO; XUDP's final END write + // may report that cancellation. Resource release is mandatory. + let _ = udp.close().await; + drop(udp); + tokio::time::timeout(Duration::from_secs(5), outbound.shutdown()) + .await + .unwrap(); + assert!(probe.snapshot().is_idle(), "{:?}", probe.snapshot()); + case.checkpoint("after-stop", probe.snapshot()); + let snapshot = probe.snapshot(); + let quiet = tokio::time::Instant::now(); + while quiet.elapsed() < Duration::from_secs(5) { + assert_eq!(probe.snapshot(), snapshot); + tokio::time::sleep(Duration::from_millis(25)).await; + } + case.checkpoint("quiet", probe.snapshot()); + }) + .await; + case.resources(probe.snapshot()); + } +} diff --git a/tests/vless_transports.rs b/tests/vless_transports.rs new file mode 100644 index 0000000..5bde7a3 --- /dev/null +++ b/tests/vless_transports.rs @@ -0,0 +1,268 @@ +#![cfg(feature = "outbound-vless")] +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use vcore::{ + config::{Config, ProxyProtocol}, + dispatch::{BoxStream, DatagramTransport, DispatchError}, + outbound::{ + ConnectedStream, DatagramRequest, EstablishContext, OutboundConnector, UpstreamPath, + VlessOutbound, + }, + session::{Destination, InboundKind, StreamSession}, +}; +struct Supplied(Mutex>); +#[async_trait::async_trait] +impl OutboundConnector for Supplied { + async fn connect_stream( + &self, + session: StreamSession, + _: &EstablishContext, + ) -> Result { + Ok(ConnectedStream { + io: self.0.lock().unwrap().take().unwrap(), + effective_peer: session.destination, + }) + } + async fn open_datagram( + &self, + _: DatagramRequest, + _: &EstablishContext, + ) -> Result, DispatchError> { + Err(DispatchError::NotAllowed) + } +} +fn node(io: BoxStream, extra: serde_json::Value) -> VlessOutbound { + let mut node = serde_json::json!({"name":"edge","type":"vless","server":"example.com","port":443,"uuid":"07070707-0707-0707-0707-070707070707"}); + node.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + let config = Config::parse_yaml( + &serde_json::to_vec( + &serde_json::json!({"socks-port":1080,"proxies":[node],"rules":["MATCH,edge"]}), + ) + .unwrap(), + ) + .unwrap(); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + unreachable!() + }; + VlessOutbound::new_with_path( + node, + UpstreamPath::proxy(Arc::new(Supplied(Mutex::new(Some(io))))), + ) + .unwrap() +} +fn session() -> StreamSession { + StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:1".parse().unwrap(), + destination: Destination::Ip("192.0.2.1:80".parse().unwrap()), + sniffed_domain: None, + } +} + +#[tokio::test] +async fn http_camouflage_shutdown_releases_both_directions_without_waiting_for_peer_eof() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "VLESS-UNIT", + "http_camouflage_shutdown_releases_both_directions_without_waiting_for_peer_eof", + ); + tokio::time::timeout(Duration::from_secs(2), async { + let (io, mut peer) = tokio::io::duplex(4096); + let node = node(Box::new(io), serde_json::json!({"network":"http"})); + let remote = tokio::spawn(async move { + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + head.push(peer.read_u8().await.unwrap()); + } + let header = vcore::outbound::encode_request_header( + uuid::Uuid::from_bytes([7; 16]), + vcore::outbound::VlessCommand::Tcp, + Some(&session().destination), + ) + .unwrap(); + let mut received = vec![0; header.len()]; + peer.read_exact(&mut received).await.unwrap(); + assert_eq!(received, header); + peer.write_all(b"HTTP/1.1 200 OK\r\n\r\n\0\0hello") + .await + .unwrap(); + peer + }); + let mut io = node + .connect_stream(session(), &EstablishContext::default()) + .await + .unwrap() + .io; + let mut hello = [0; 5]; + io.read_exact(&mut hello).await.unwrap(); + assert_eq!(&hello, b"hello"); + let mut peer = remote.await.unwrap(); + io.shutdown().await.unwrap(); + assert_eq!(peer.read(&mut [0; 1]).await.unwrap(), 0); + assert!( + peer.write_all(b"late").await.is_err(), + "HTTP close retained the read direction" + ); + assert!(!matches!(io.read(&mut [0;1]).await, Ok(n) if n>0)); + drop(io); + node.shutdown().await; + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn grpc_server_first_flushes_vless_request_without_an_application_write() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "VLESS-UNIT", + "grpc_server_first_flushes_vless_request_without_an_application_write", + ); + let (io, peer) = tokio::io::duplex(4096); + let node = node( + Box::new(io), + serde_json::json!({"network":"grpc","grpc-opts":{"grpc-service-name":"service"}}), + ); + let tasks = tokio_util::task::TaskTracker::new(); + let sessions = tasks.clone(); + tasks.spawn(async move { + let mut connection = h2::server::handshake(peer).await.unwrap(); + while let Some(Ok((request, mut respond))) = connection.accept().await { + sessions.spawn(async move { + let mut receive = request.into_body(); + let Some(Ok(data)) = receive.data().await else { + return; + }; + assert!(!data.is_empty()); + let mut send = respond + .send_response( + http::Response::builder() + .status(200) + .header("content-type", "application/grpc") + .body(()) + .unwrap(), + false, + ) + .unwrap(); + send.send_data( + bytes::Bytes::from_static(b"\0\0\0\0\x09\x0a\x07\0\0hello"), + false, + ) + .unwrap(); + let _ = receive.data().await; + }); + } + }); + let result = async { + let mut stream = node + .connect_stream( + session(), + &EstablishContext::with_timeout(Duration::from_millis(100)), + ) + .await + .unwrap() + .io; + let mut answer = [0; 5]; + let result = stream.read_exact(&mut answer).await; + (result, answer) + } + .await; + node.shutdown().await; + tasks.close(); + tasks.wait().await; + result + .0 + .expect("VLESS request was not flushed before waiting for the response"); + assert_eq!(&result.1, b"hello"); +} +#[tokio::test] +async fn http_upgrade_consumes_early_prefix_and_requires_valid_101_even_fast_open() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "VLESS-UNIT", + "http_upgrade_consumes_early_prefix_and_requires_valid_101_even_fast_open", + ); + use base64::Engine as _; + for fast in [false, true] { + for accept in [false, true] { + let (io, mut peer) = tokio::io::duplex(4096); + let node = node( + Box::new(io), + serde_json::json!({"network":"ws","ws-opts":{"path":"/upgrade","headers":{"Host":"cover.invalid:443"},"v2ray-http-upgrade":true,"v2ray-http-upgrade-fast-open":fast,"max-early-data":1}}), + ); + let remote = tokio::spawn(async move { + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + head.push(peer.read_u8().await.unwrap()); + } + let head = String::from_utf8(head).unwrap().to_ascii_lowercase(); + assert!(head.starts_with("get /upgrade http/1.1\r\n")); + assert!(head.contains("host: cover.invalid:443\r\n")); + assert!(!head.contains("sec-websocket-key")); + let encoded = head + .lines() + .find_map(|line| line.strip_prefix("sec-websocket-protocol: ")) + .unwrap(); + assert_eq!( + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(encoded.to_ascii_uppercase()) + .unwrap(), + [0] + ); + let expected = vcore::outbound::encode_request_header( + uuid::Uuid::from_bytes([7; 16]), + vcore::outbound::VlessCommand::Tcp, + Some(&session().destination), + ) + .unwrap(); + let mut rest = vec![0; expected.len() - 1]; + if fast { + peer.read_exact(&mut rest).await.unwrap(); + assert_eq!(rest, &expected[1..]); + } + let status = if accept { + b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n\0\0answer".as_slice() + } else { + b"HTTP/1.1 403 Forbidden\r\n\r\n".as_slice() + }; + peer.write_all(status).await.unwrap(); + if accept { + if !fast { + peer.read_exact(&mut rest).await.unwrap(); + assert_eq!(rest, &expected[1..]); + } + let mut data = [0; 4]; + peer.read_exact(&mut data).await.unwrap(); + assert_eq!(&data, b"body"); + } else { + let mut tail = Vec::new(); + peer.read_to_end(&mut tail).await.unwrap(); + } + }); + tokio::time::timeout(Duration::from_secs(2), async { + let result = node + .connect_stream(session(), &EstablishContext::default()) + .await; + if accept { + let mut stream = result.unwrap().io; + stream.write_all(b"body").await.unwrap(); + stream.flush().await.unwrap(); + let mut answer = [0; 6]; + stream.read_exact(&mut answer).await.unwrap(); + assert_eq!(&answer, b"answer"); + } else { + assert!(result.is_err(), "failed upgrade reported success"); + } + remote.await.unwrap(); + node.shutdown().await; + }) + .await + .unwrap(); + } + } +} diff --git a/tests/vmess_codec.rs b/tests/vmess_codec.rs new file mode 100644 index 0000000..f96c214 --- /dev/null +++ b/tests/vmess_codec.rs @@ -0,0 +1,303 @@ +#![cfg(feature = "outbound-vmess")] + +use vcore::{ + outbound::vmess::{ + BodyCipher, BodyOptions, ClientHandshake, Command, VmessIdentity, VmessStream, + }, + session::Destination, +}; + +#[tokio::test] +async fn xudp_cancelled_send_and_bad_frame_release_io_and_cannot_resume() { + #[cfg(feature = "interop-test")] + let _evidence = vcore::resources::case_events::Case::new( + "VMESS-CODEC", + "xudp_cancelled_send_and_bad_frame_release_io_and_cannot_resume", + ); + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use vcore::{dispatch::DatagramTransport, session::Datagram, xudp::XudpTransport}; + let (io, mut peer) = tokio::io::duplex(1); + let mut client = XudpTransport::new(Box::new(io), [0; 8], 16); + let datagram = Datagram { + remote: "192.0.2.1:53" + .parse::() + .unwrap() + .into(), + payload: bytes::Bytes::from_static(b"payload"), + sniffed_domain: None, + }; + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(10), + client.send(datagram.clone()) + ) + .await + .is_err() + ); + let mut wire = Vec::new(); + tokio::time::timeout( + std::time::Duration::from_millis(100), + peer.read_to_end(&mut wire), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(wire.len(), 1); + assert!(client.send(datagram.clone()).await.is_err()); + assert!(client.receive().await.is_err()); + client.close().await.unwrap(); + let (io, mut peer) = tokio::io::duplex(64); + let mut client = XudpTransport::new(Box::new(io), [0; 8], 16); + peer.write_all(&[0, 3]).await.unwrap(); + assert!(client.receive().await.is_err()); + assert!(client.send(datagram).await.is_err()); + assert_eq!(peer.read(&mut [0; 1]).await.unwrap(), 0); + // The two packet-mode VMess codecs must release IO on cancellation too, + // without waiting for another call or the association owner to drop them. + use vcore::{ + dispatch::DatagramBudget, dns::resolution::ResolutionContext, + outbound::vmess::VmessDatagram, + }; + for packetaddr in [false, true] { + let (io, mut peer) = tokio::io::duplex(1); + let target: Destination = "192.0.2.1:53" + .parse::() + .unwrap() + .into(); + let handshake = ClientHandshake::new( + &VmessIdentity::new(uuid::Uuid::from_bytes([7; 16])), + Command::Udp, + &target, + BodyOptions::new(BodyCipher::None, false, false).unwrap(), + ) + .unwrap(); + let stream = VmessStream::new( + Box::new(io), + handshake, + tokio::time::Instant::now() + std::time::Duration::from_secs(1), + ); + let budget = DatagramBudget::new(32, 32); + let mut client = if packetaddr { + VmessDatagram::packet_addr(stream, budget, ResolutionContext::default()) + } else { + VmessDatagram::raw(stream, target.clone(), budget) + }; + let datagram = Datagram { + remote: target, + payload: bytes::Bytes::from_static(b"payload"), + sniffed_domain: None, + }; + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(10), + client.send(datagram.clone()) + ) + .await + .is_err() + ); + let mut wire = Vec::new(); + tokio::time::timeout( + std::time::Duration::from_millis(100), + peer.read_to_end(&mut wire), + ) + .await + .expect("cancelled VMess packet send retained IO") + .unwrap(); + assert_eq!(wire.len(), 1); + assert!(client.send(datagram).await.is_err()); + assert!(client.receive().await.is_err()); + client.close().await.unwrap(); + } +} + +#[tokio::test] +async fn xudp_zero_global_id_omits_the_optional_extension_like_mihomo() { + #[cfg(feature = "interop-test")] + let _evidence = vcore::resources::case_events::Case::new( + "VMESS-CODEC", + "xudp_zero_global_id_omits_the_optional_extension_like_mihomo", + ); + use tokio::io::AsyncReadExt; + use vcore::{dispatch::DatagramTransport, session::Datagram, xudp::XudpTransport}; + let (io, mut peer) = tokio::io::duplex(128); + let mut transport = XudpTransport::new(Box::new(io), [0; 8], 1500); + transport + .send(Datagram { + remote: "1.2.3.4:53".parse::().unwrap().into(), + payload: bytes::Bytes::from_static(&[7]), + sniffed_domain: None, + }) + .await + .unwrap(); + let mut wire = [0; 17]; + peer.read_exact(&mut wire).await.unwrap(); + assert_eq!(wire, [0, 12, 0, 0, 1, 1, 2, 0, 53, 1, 1, 2, 3, 4, 0, 1, 7]); +} + +#[test] +fn packetaddr_has_ip_only_address_first_wire_and_rejects_truncation() { + #[cfg(feature = "interop-test")] + let _evidence = vcore::resources::case_events::Case::new( + "VMESS-CODEC", + "packetaddr_has_ip_only_address_first_wire_and_rejects_truncation", + ); + use vcore::outbound::address::{decode_packet_addr, encode_packet_addr}; + let peer = Destination::Ip("1.2.3.4:53".parse().unwrap()); + let mut wire = bytes::BytesMut::new(); + encode_packet_addr(&peer, &mut wire).unwrap(); + assert_eq!(&wire[..], &[1, 1, 2, 3, 4, 0, 53]); + assert_eq!(decode_packet_addr(&wire).unwrap(), (peer, 7)); + let peer = Destination::Ip("[::1]:443".parse().unwrap()); + wire.clear(); + encode_packet_addr(&peer, &mut wire).unwrap(); + assert_eq!( + &wire[..], + &[2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 187] + ); + assert_eq!(decode_packet_addr(&wire).unwrap(), (peer, 19)); + for length in 0..19 { + assert!(decode_packet_addr(&wire[..length]).is_err()); + } + assert!(decode_packet_addr(&[3, 0, 0]).is_err()); + assert!(decode_packet_addr(&[1, 1, 2, 3, 4, 0, 0]).is_err()); + assert!( + encode_packet_addr( + &Destination::domain("fixture.invalid", 53).unwrap(), + &mut bytes::BytesMut::new() + ) + .is_err() + ); +} + +#[test] +fn vmess_aead_requests_are_fresh_bounded_and_redacted() { + #[cfg(feature = "interop-test")] + let _evidence = vcore::resources::case_events::Case::new( + "VMESS-CODEC", + "vmess_aead_requests_are_fresh_bounded_and_redacted", + ); + let identity = VmessIdentity::new(uuid::Uuid::from_bytes([7; 16])); + let target = Destination::domain("fixture.invalid", 443).unwrap(); + let options = BodyOptions::new(BodyCipher::None, false, false).unwrap(); + let first = ClientHandshake::new(&identity, Command::Tcp, &target, options).unwrap(); + let second = ClientHandshake::new(&identity, Command::Tcp, &target, options).unwrap(); + assert_ne!(first.request(), second.request()); + assert!((99..=384).contains(&first.request().len())); + assert!(!format!("{identity:?}").contains("07070707")); + assert!(!format!("{first:?}").contains("fixture.invalid")); + assert!(first.response_length(&[0; 18]).is_err()); + assert!(BodyOptions::new(BodyCipher::None, true, false).is_err()); + assert!(BodyOptions::new(BodyCipher::None, false, true).is_err()); +} + +#[tokio::test] +async fn vmess_response_authentication_cannot_be_skipped_by_none_cipher() { + #[cfg(feature = "interop-test")] + let _evidence = vcore::resources::case_events::Case::new( + "VMESS-CODEC", + "vmess_response_authentication_cannot_be_skipped_by_none_cipher", + ); + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let identity = VmessIdentity::new(uuid::Uuid::from_bytes([7; 16])); + let target = Destination::domain("fixture.invalid", 443).unwrap(); + let handshake = ClientHandshake::new( + &identity, + Command::Tcp, + &target, + BodyOptions::new(BodyCipher::None, false, false).unwrap(), + ) + .unwrap(); + let (client, mut peer) = tokio::io::duplex(1024); + let mut stream = VmessStream::new( + Box::new(client), + handshake, + tokio::time::Instant::now() + std::time::Duration::from_secs(1), + ); + peer.write_all(&[0; 18]).await.unwrap(); + let mut data = [0; 1]; + assert_eq!( + stream.read(&mut data).await.unwrap_err().kind(), + std::io::ErrorKind::InvalidData + ); + assert!(stream.write_all(b"after-failure").await.is_err()); +} + +#[tokio::test] +async fn vmess_whole_close_wakes_reader_and_releases_io_before_response() { + #[cfg(feature = "interop-test")] + let _evidence = vcore::resources::case_events::Case::new( + "VMESS-CODEC", + "vmess_whole_close_wakes_reader_and_releases_io_before_response", + ); + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let handshake = ClientHandshake::new( + &VmessIdentity::new(uuid::Uuid::from_bytes([7; 16])), + Command::Tcp, + &Destination::domain("fixture.invalid", 443).unwrap(), + BodyOptions::new(BodyCipher::Auto, false, false).unwrap(), + ) + .unwrap(); + let (io, mut peer) = tokio::io::duplex(1024); + let stream = VmessStream::new( + Box::new(io), + handshake, + tokio::time::Instant::now() + std::time::Duration::from_secs(1), + ) + .with_whole_close(); + let (mut reader, mut writer) = tokio::io::split(stream); + let mut byte = [0; 1]; + let read = reader.read(&mut byte); + tokio::pin!(read); + assert!(futures_util::poll!(read.as_mut()).is_pending()); + writer.shutdown().await.unwrap(); + assert_eq!( + tokio::time::timeout(std::time::Duration::from_millis(100), read) + .await + .unwrap() + .unwrap(), + 0 + ); + assert_eq!(peer.read(&mut [0; 1]).await.unwrap(), 0); + assert!(writer.write_all(b"after-close").await.is_err()); + writer.shutdown().await.unwrap(); +} +#[tokio::test] +async fn xudp_explicit_budget_rejects_max_plus_one_before_writing() { + #[cfg(feature = "interop-test")] + let _evidence = vcore::resources::case_events::Case::new( + "VMESS-CODEC", + "xudp_explicit_budget_rejects_max_plus_one_before_writing", + ); + use vcore::{ + dispatch::{DatagramBudget, DatagramTransport}, + session::Datagram, + }; + let (client, mut peer) = tokio::io::duplex(64); + let mut client = vcore::xudp::XudpTransport::with_budget( + Box::new(client), + [0; 8], + DatagramBudget::new(4, 4), + ); + let remote = "192.0.2.1:53" + .parse::() + .unwrap() + .into(); + assert!( + client + .send(Datagram { + remote, + payload: vec![0; 5].into(), + sniffed_domain: None + }) + .await + .is_err() + ); + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(10), + tokio::io::AsyncReadExt::read_u8(&mut peer) + ) + .await + .is_err() + ); +} diff --git a/tests/vmess_config.rs b/tests/vmess_config.rs new file mode 100644 index 0000000..685085b --- /dev/null +++ b/tests/vmess_config.rs @@ -0,0 +1,313 @@ +#![cfg(feature = "outbound-vmess")] +use serde_json::{Value, json}; +use vcore::config::Config; + +fn document(extra: Value) -> Vec { + let mut node = json!({"name":"edge","type":"vmess","server":"example.com","port":443,"uuid":"07070707-0707-0707-0707-070707070707"}); + node.as_object_mut() + .unwrap() + .extend(extra.as_object().unwrap().clone()); + serde_json::to_vec(&json!({"socks-port":1080,"proxies":[node],"rules":["MATCH,edge"]})).unwrap() +} + +#[test] +fn vmess_default_node_and_explicit_cipher_aliases_are_accepted_without_io() { + #[cfg(feature = "interop-test")] + let _evidence = vcore::resources::case_events::Case::new( + "VMESS-CFG", + "vmess_default_node_and_explicit_cipher_aliases_are_accepted_without_io", + ); + let parsed = Config::parse_yaml(&document(json!({}))).expect("VMess default node"); + assert_eq!(parsed.proxies[0].address(), "example.com"); + assert_eq!(parsed.proxies[0].port(), 443); + assert!(!parsed.proxies[0].udp); + for cipher in ["auto", "aes-128-gcm", "chacha20-poly1305", "none", "zero"] { + assert!( + Config::parse_yaml(&document(json!({"cipher":cipher,"alterId":0,"udp":true}))).is_ok() + ); + } + assert!(!format!("{parsed:?}").contains("07070707")); +} + +#[test] +fn vmess_transport_and_security_combinations_are_strict() { + #[cfg(feature = "interop-test")] + let _evidence = vcore::resources::case_events::Case::new( + "VMESS-CFG", + "vmess_transport_and_security_combinations_are_strict", + ); + for tls in [false, true] { + for (network, options) in [ + ("tcp", json!({})), + ( + "ws", + json!({"ws-opts":{"path":"/path?q=1","headers":{"Host":"cover.example:443"},"max-early-data":2048}}), + ), + ("grpc", json!({"grpc-opts":{"grpc-service-name":"edge"}})), + ( + "http", + json!({"http-opts":{"method":"POST","path":["/a","/b"],"headers":{"Host":["a.example","b.example"],"X-Test":["one","two"]}}}), + ), + ( + "h2", + json!({"h2-opts":{"host":["cover.example","[2001:db8::1]:443"],"path":"/h2"}}), + ), + ] { + let mut fields = json!({"network":network,"tls":tls,"global-padding":true,"authenticated-length":true,"packet-encoding":"packetaddr"}); + fields + .as_object_mut() + .unwrap() + .extend(options.as_object().unwrap().clone()); + assert!( + Config::parse_yaml(&document(fields)).is_ok(), + "{network} tls={tls}" + ); + } + } + for fields in [ + json!({"alterId":1}), + json!({"uuid":"invalid-secret"}), + json!({"cipher":"none","global-padding":true}), + json!({"cipher":"zero","authenticated-length":true}), + json!({"network":"bogus"}), + json!({"network":"h2"}), + json!({"network":"grpc"}), + json!({"tls":false,"servername":"secret.invalid"}), + json!({"tls":false,"skip-cert-verify":false}), + json!({"tls":false,"alpn":[]}), + json!({"packet-encoding":"packet"}), + json!({"ws-opts":{}}), + json!({"http-opts":{}}), + json!({"network":"h2","h2-opts":{"host":[]}}), + json!({"network":"http","http-opts":{"method":"GET\r\n"}}), + json!({"network":"http","http-opts":{"headers":{"Content-Length":["1"]}}}), + json!({"network":"http","http-opts":{"headers":{"X-Test":[]}}}), + json!({"network":"ws","ws-opts":{"max-early-data":2049}}), + json!({"network":"ws","tls":true,"alpn":["h2"]}), + json!({"network":"grpc","grpc-opts":{"grpc-service-name":""}}), + ] { + assert!( + Config::parse_yaml(&document(fields.clone())).is_err(), + "accepted {fields}" + ); + } + for key in [ + "name", + "type", + "server", + "port", + "uuid", + "alterId", + "cipher", + "udp", + "network", + "tls", + "servername", + "alpn", + "fingerprint", + "skip-cert-verify", + "packet-encoding", + "global-padding", + "authenticated-length", + "dialer-proxy", + "ws-opts", + "grpc-opts", + "http-opts", + "h2-opts", + ] { + assert!( + Config::parse_yaml(&document(json!({key:null}))).is_err(), + "accepted null {key}" + ); + } +} + +#[tokio::test] +async fn vmess_field_boundaries_and_normalized_transport_values() { + #[cfg(feature = "interop-test")] + let _evidence = vcore::resources::case_events::Case::new( + "VMESS-CFG", + "vmess_field_boundaries_and_normalized_transport_values", + ); + use vcore::config::{ProxyProtocol, VmessTransport}; + for fields in [ + json!({"server":""}), + json!({"port":0}), + json!({"port":65536}), + json!({"uuid":"07070707070707070707070707070707"}), + json!({"network":"ws","ws-opts":{"headers":{"Host":"cover.example:bogus"}}}), + json!({"network":"h2","h2-opts":{"host":["cover.example:"]}}), + json!({"network":"http","http-opts":{"headers":{"Host":["cover.example:65536"]}}}), + json!({"network":"ws","ws-opts":{"path":"/","headers":{"host":"a.example","Host":"b.example"}}}), + json!({"network":"ws","ws-opts":{"path":"/q?x=1","max-early-data":1,"early-data-header-name":""}}), + json!({"tls":true,"fingerprint":"secret-not-a-fingerprint"}), + json!({"tls":true,"alpn":[""]}), + json!({"network":"http","http-opts":{"path":["relative"]}}), + json!({"network":"ws","ws-opts":{"unknown":1}}), + json!({"network":"grpc","grpc-opts":{"grpc-service-name":null}}), + json!({"network":"h2","h2-opts":{"host":null}}), + json!({"network":"http","http-opts":{"headers":{"X-Foo":["value\r\nInjected:1"]}}}), + json!({"future":true}), + ] { + let result = Config::parse_yaml(&document(fields.clone())); + assert!(result.is_err(), "accepted {fields}"); + assert!( + !result + .unwrap_err() + .to_string() + .contains("secret-not-a-fingerprint") + ); + } + for key in [ + "server", + "uuid", + "cipher", + "network", + "servername", + "fingerprint", + "packet-encoding", + ] { + assert!(Config::parse_yaml(&document(json!({key:[],"tls":true}))).is_err()); + } + for key in [ + "port", + "alterId", + "udp", + "tls", + "skip-cert-verify", + "global-padding", + "authenticated-length", + "alpn", + ] { + assert!(Config::parse_yaml(&document(json!({key:{}}))).is_err()); + } + for missing in ["name", "type", "server", "port", "uuid"] { + let mut value: Value = serde_json::from_slice(&document(json!({}))).unwrap(); + value["proxies"][0].as_object_mut().unwrap().remove(missing); + assert!(Config::parse_yaml(value.to_string().as_bytes()).is_err()); + } + let parsed = Config::parse_yaml(&document( + json!({"network":"ws","tls":true,"ws-opts":{"headers":{"Host":"localhost:443"}}}), + )) + .unwrap(); + let ProxyProtocol::Vmess(node) = &parsed.proxies[0].protocol else { + unreachable!() + }; + assert_eq!(node.server_name, "localhost"); + let parsed=Config::parse_yaml(&document(json!({"network":"http","http-opts":{"method":"POST","path":["/a?x=1","/b"],"headers":{"Host":["one.example:80","[2001:db8::1]:80"],"X-Test":["one","two"]}}}))).unwrap(); + let ProxyProtocol::Vmess(node) = &parsed.proxies[0].protocol else { + unreachable!() + }; + let VmessTransport::Http { uris, .. } = &node.transport else { + unreachable!() + }; + assert!(uris[0].contains("/a%3Fx=1")); + use tokio::io::AsyncReadExt; + let mut selected = std::collections::BTreeSet::new(); + for _ in 0..1024 { + let (client, mut observer) = tokio::io::duplex(4096); + let _stream = vcore::transport::http_obfs( + Box::new(client), + &node.transport.http_options().unwrap().unwrap(), + b"prefix-once", + tokio::time::Instant::now() + std::time::Duration::from_secs(1), + ) + .await + .unwrap(); + let mut bytes = [0; 4096]; + let n = observer.read(&mut bytes).await.unwrap(); + let head = std::str::from_utf8(&bytes[..n]).unwrap(); + assert!(head.starts_with("POST /")); + assert!(head.ends_with("\r\n\r\nprefix-once")); + assert!(head.contains("x-test: ")); + selected.insert(head.to_owned()); + } + // Sampling is bounded; all eight independently selected combinations should + // appear. A selector permanently taking the first entry cannot pass. + assert_eq!(selected.len(), 8); +} + +#[tokio::test] +async fn vmess_normalized_websocket_fields_preserve_headers_and_early_data_order() { + #[cfg(feature = "interop-test")] + let _evidence = vcore::resources::case_events::Case::new( + "VMESS-CFG", + "vmess_normalized_websocket_fields_preserve_headers_and_early_data_order", + ); + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + use futures_util::StreamExt; + use tokio::io::AsyncWriteExt; + for (maximum, header) in [ + (0, None), + (1, None), + (2048, None), + (2048, Some("X-Early")), + (2048, Some("")), + ] { + let mut fields = json!({"network":"ws","ws-opts":{"path":"/edge/","headers":{"Host":"cover.example:443","X-Custom":"exact-value"},"max-early-data":maximum}}); + if let Some(header) = header { + fields["ws-opts"]["early-data-header-name"] = json!(header); + } + let parsed = Config::parse_yaml(&document(fields)).unwrap(); + let vcore::config::ProxyProtocol::Vmess(node) = &parsed.proxies[0].protocol else { + unreachable!() + }; + let (client, peer) = tokio::io::duplex(65536); + let prefix = vec![7; 2049]; + let expected = prefix.clone(); + let reader = tokio::spawn(async move { + let mut early = Vec::new(); + #[allow( + clippy::result_large_err, + reason = "Tungstenite's header callback requires an unboxed HTTP error response" + )] + let mut ws = tokio_tungstenite::accept_hdr_async( + peer, + |request: &http::Request<()>, response: http::Response<()>| { + assert_eq!(request.headers()["host"], "cover.example:443"); + assert_eq!(request.headers()["x-custom"], "exact-value"); + let path = request.uri().path(); + if maximum == 0 { + assert_eq!(path, "/edge/"); + assert!(!request.headers().contains_key("sec-websocket-protocol")); + } else if header == Some("") { + early = URL_SAFE_NO_PAD + .decode(path.strip_prefix("/edge/").unwrap()) + .unwrap(); + } else { + assert_eq!(path, "/edge/"); + early = URL_SAFE_NO_PAD + .decode( + request.headers()[header.unwrap_or("sec-websocket-protocol")] + .as_bytes(), + ) + .unwrap(); + } + assert_eq!(early.len(), maximum); + Ok(response) + }, + ) + .await + .unwrap(); + while early.len() < expected.len() { + early.extend(ws.next().await.unwrap().unwrap().into_data()); + } + assert_eq!(early, expected); + assert_eq!( + ws.next().await.unwrap().unwrap().into_data().as_ref(), + b"continuation" + ); + }); + let mut stream = vcore::transport::connect_websocket( + Box::new(client), + &node.transport.websocket_options().unwrap().unwrap(), + &prefix, + tokio::time::Instant::now() + std::time::Duration::from_secs(1), + ) + .await + .unwrap(); + stream.write_all(b"continuation").await.unwrap(); + stream.flush().await.unwrap(); + reader.await.unwrap(); + } +} diff --git a/tests/vmess_lifecycle.rs b/tests/vmess_lifecycle.rs new file mode 100644 index 0000000..2379c32 --- /dev/null +++ b/tests/vmess_lifecycle.rs @@ -0,0 +1,176 @@ +#![cfg(all(unix, feature = "outbound-vmess"))] +use std::{ + io, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; +use vcore::{ + config::{Config, ProxyProtocol}, + dialer::{Dialer, ResolvedEndpoint, SocketProtector}, + outbound::{EstablishContext, OutboundConnector, UpstreamPath, vmess::VmessOutbound}, + session::{InboundKind, StreamSession}, +}; + +struct SuppliedStream { + io: std::sync::Mutex>, +} +#[async_trait::async_trait] +impl OutboundConnector for SuppliedStream { + async fn connect_stream( + &self, + session: StreamSession, + _context: &EstablishContext, + ) -> Result { + tokio::time::sleep(Duration::from_millis(150)).await; + Ok(vcore::outbound::ConnectedStream { + io: Box::new(self.io.lock().unwrap().take().unwrap()), + effective_peer: session.destination, + }) + } + async fn open_datagram( + &self, + _: vcore::outbound::DatagramRequest, + _: &EstablishContext, + ) -> Result, vcore::dispatch::DispatchError> { + Err(vcore::dispatch::DispatchError::NotAllowed) + } +} + +#[tokio::test] +async fn vmess_all_handshakes_keep_the_original_deadline_and_join_cancelled_io() { + #[cfg(feature = "interop-test")] + let _evidence = vcore::resources::case_events::Case::new( + "VMESS-CANCEL", + "vmess_all_handshakes_keep_the_original_deadline_and_join_cancelled_io", + ); + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + for mode in ["tcp", "tls", "ws", "grpc", "http", "h2"] { + let mut node = serde_json::json!({"name":"edge","type":"vmess","server":"fixture.invalid","port":443,"uuid":"07070707-0707-0707-0707-070707070707","network":if mode=="tls" {"tcp"}else{mode}}); + match mode { + "tls" => { + node["tls"] = serde_json::json!(true); + } + "grpc" => node["grpc-opts"] = serde_json::json!({"grpc-service-name":"edge"}), + "h2" => node["h2-opts"] = serde_json::json!({"host":["fixture.invalid"]}), + _ => {} + } + let parsed = Config::parse_yaml( + serde_json::json!({"socks-port":1080,"proxies":[node],"rules":["MATCH,edge"]}) + .to_string() + .as_bytes(), + ) + .unwrap(); + let ProxyProtocol::Vmess(node) = &parsed.proxies[0].protocol else { + unreachable!() + }; + let (io, mut peer) = tokio::io::duplex(65536); + let outbound = VmessOutbound::new_with_path( + node, + UpstreamPath::proxy(Arc::new(SuppliedStream { + io: std::sync::Mutex::new(Some(io)), + })), + ) + .unwrap(); + let session = StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:1".parse().unwrap(), + destination: "192.0.2.2:80" + .parse::() + .unwrap() + .into(), + sniffed_domain: None, + }; + let start = tokio::time::Instant::now(); + let context = EstablishContext::with_timeout(Duration::from_millis(250)); + let task = async { + if let Ok(mut connected) = outbound.connect_stream(session, &context).await { + let _ = connected.io.write_all(b"first").await; + let _ = connected.io.flush().await; + assert!(connected.io.read(&mut [0; 1]).await.is_err()); + } + }; + tokio::time::timeout(Duration::from_millis(325), task) + .await + .expect("consumer reset the original deadline"); + assert!(start.elapsed() >= Duration::from_millis(240)); + tokio::time::timeout(Duration::from_secs(1), outbound.shutdown()) + .await + .unwrap(); + let mut observed = Vec::new(); + tokio::time::timeout(Duration::from_secs(1), peer.read_to_end(&mut observed)) + .await + .unwrap() + .unwrap(); + assert!(!observed.is_empty()); + assert!(observed.len() <= 65536); + } +} + +struct RejectProtect(AtomicUsize); +impl SocketProtector for RejectProtect { + fn protect(&self, _: i32) -> io::Result<()> { + self.0.fetch_add(1, Ordering::SeqCst); + Err(io::ErrorKind::PermissionDenied.into()) + } +} + +#[tokio::test] +async fn vmess_expired_deadline_and_protect_failure_never_fall_back() { + #[cfg(feature = "interop-test")] + let _evidence = vcore::resources::case_events::Case::new( + "VMESS-CANCEL", + "vmess_expired_deadline_and_protect_failure_never_fall_back", + ); + let parsed = Config::parse_yaml(b"socks-port: 1080\nproxies: [{name: edge, type: vmess, server: 192.0.2.1, port: 443, uuid: 07070707-0707-0707-0707-070707070707}]\nrules: ['MATCH,edge']").unwrap(); + let ProxyProtocol::Vmess(config) = &parsed.proxies[0].protocol else { + unreachable!() + }; + let protect = Arc::new(RejectProtect(AtomicUsize::new(0))); + let endpoint = ResolvedEndpoint { + logical_host: "192.0.2.1".into(), + port: 443, + addresses: vec!["192.0.2.1:443".parse().unwrap()], + }; + let outbound = VmessOutbound::new_with_path( + config, + UpstreamPath::direct(endpoint, Dialer::default().with_protector(protect.clone())), + ) + .unwrap(); + let session = StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:1".parse().unwrap(), + destination: "192.0.2.2:80" + .parse::() + .unwrap() + .into(), + sniffed_domain: None, + }; + assert!( + outbound + .connect_stream( + session.clone(), + &EstablishContext::with_timeout(Duration::ZERO) + ) + .await + .is_err() + ); + assert_eq!(protect.0.load(Ordering::SeqCst), 0); + assert!( + outbound + .connect_stream(session.clone(), &EstablishContext::default()) + .await + .is_err() + ); + assert_eq!(protect.0.load(Ordering::SeqCst), 1); + outbound.shutdown().await; + assert!( + outbound + .connect_stream(session, &EstablishContext::default()) + .await + .is_err() + ); + assert_eq!(protect.0.load(Ordering::SeqCst), 1); +} diff --git a/tests/vmess_native.rs b/tests/vmess_native.rs new file mode 100644 index 0000000..01ceeb5 --- /dev/null +++ b/tests/vmess_native.rs @@ -0,0 +1,625 @@ +#![cfg(all(feature = "outbound-vmess", feature = "interop-test"))] +use std::{io, net::SocketAddr, time::Duration}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use vcore::{ + dialer::{Dialer, ResolvedEndpoint}, + outbound::vmess::{ + BodyCipher, BodyOptions, ClientHandshake, Command, VmessIdentity, VmessStream, + }, + security::{SecurityContext, StandardTlsClient, TlsCertificatePolicy, TlsClientOptions}, + session::Destination, + transport::{ + HttpObfsOptions, StreamDriver, WebSocketOptions, connect_websocket, grpc, http_obfs, + legacy_h2, + }, +}; + +const UUID: uuid::Uuid = uuid::Uuid::from_bytes([7; 16]); +const PAYLOAD_BYTES: usize = 10 * 1024 * 1024; + +#[path = "vmess_native/origin.rs"] +mod origin; +#[path = "vmess_native/udp_ab.rs"] +mod udp_ab; + +fn native_udp_budget( + fixture: &serde_json::Value, + codec: &str, + cipher: BodyCipher, + padding: bool, + length: bool, + family: &str, +) -> usize { + let address = if codec == "packetaddr" { + if family == "ipv4" { 7 } else { 19 } + } else { + 0 + }; + let host = (fixture["udp_path_limit"].as_u64().unwrap() as usize).min(15000 - address); + if fixture["peer_kind"] != "V2" { + return host; + } + // Official V2Ray 5.53's packet writer is bounded by its 2048-byte + // buffer. Mux data is framed outside the body packet boundary; raw and + // packetaddr must leave room for VMess length, tag and maximum padding. + // This is a native-fixture budget, never a production protocol limit. + if codec == "xudp" { + return host.min(2048); + } + let overhead = if cipher == BodyCipher::None { + 2 + } else { + 16 + if length { 18 } else { 2 } + if padding { 63 } else { 0 } + }; + host.min(2048 - overhead - address) +} + +fn event_for(assertion: &str, status: &str) { + use std::io::Write; + let path = std::env::var("VCORE_CASE_EVENTS").expect("owned harness events"); + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + writeln!(file, "{}", serde_json::json!({"schema_version":1,"suite":"VMESS-WIRE","assertion":assertion,"status":status})).unwrap(); +} + +async fn stream( + peer: SocketAddr, + target: Destination, + cipher: BodyCipher, + padding: bool, + length: bool, +) -> io::Result<(VmessStream, Option)> { + wire_stream(peer, target, cipher, padding, length, Command::Tcp).await +} + +async fn wire_stream( + peer: SocketAddr, + target: Destination, + cipher: BodyCipher, + padding: bool, + length: bool, + command: Command, +) -> io::Result<(VmessStream, Option)> { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + let endpoint = ResolvedEndpoint { + logical_host: peer.ip().to_string(), + port: peer.port(), + addresses: vec![peer], + }; + let raw = tokio::time::timeout_at(deadline, Dialer::default().connect(&endpoint)).await??; + let handshake = ClientHandshake::new( + &VmessIdentity::new(UUID), + command, + &target, + BodyOptions::new(cipher, padding, length)?, + )?; + let mut raw = Box::new(raw) as vcore::dispatch::BoxStream; + let fixture: serde_json::Value = serde_json::from_str( + &std::env::var("VCORE_VMESS_TRANSPORT").unwrap_or_else(|_| "{}".into()), + ) + .unwrap(); + let mode = fixture["mode"].as_str().unwrap_or("tcp"); + let encrypted = fixture["tls"].as_bool().unwrap_or(false); + let scheme = if encrypted { "https" } else { "http" }; + if encrypted { + let pin = fixture["pin"].as_str().unwrap(); + let mut fingerprint = [0; 32]; + for (index, byte) in fingerprint.iter_mut().enumerate() { + *byte = u8::from_str_radix(&pin[2 * index..2 * index + 2], 16).unwrap(); + } + let alpn = match mode { + "grpc" | "h2" => vec![b"h2".to_vec()], + "ws" => vec![b"http/1.1".to_vec()], + _ => vec![], + }; + let tls = StandardTlsClient::with_options( + &SecurityContext::new(), + "localhost", + TlsClientOptions { + required_alpn: alpn.first().cloned(), + alpn, + certificate: TlsCertificatePolicy { + fingerprint: Some(fingerprint), + ..Default::default() + }, + ..Default::default() + }, + 0, + 65536, + )?; + raw = tokio::time::timeout_at(deadline, tls.connect(raw)).await??; + } + let mut driver = None; + match mode { + "tcp" => {} + "ws" => { + let scheme = if encrypted { "wss" } else { "ws" }; + let options = WebSocketOptions::new( + &format!("{scheme}://localhost:{}/vmess-ws", peer.port()), + http::HeaderMap::new(), + None, + )?; + raw = connect_websocket(raw, &options, handshake.request(), deadline).await?; + } + "grpc" | "h2" => { + let uri = format!( + "{scheme}://localhost/{}", + if mode == "grpc" { + "vmess-grpc/Tun" + } else { + "vmess-h2" + } + ); + let connected = if mode == "grpc" { + grpc(raw, &uri, deadline).await? + } else { + legacy_h2(raw, &uri, deadline).await? + }; + raw = connected.0; + driver = Some(connected.1); + } + "http" => { + let options = HttpObfsOptions::new( + http::Method::GET, + &format!("{scheme}://localhost:{}/vmess-http", peer.port()), + http::HeaderMap::new(), + )?; + raw = http_obfs(raw, &options, handshake.request(), deadline).await?; + } + _ => panic!("unknown owned transport"), + } + if !matches!(mode, "ws" | "http") { + raw.write_all(handshake.request()).await?; + raw.flush().await?; + } + let stream = VmessStream::new(raw, handshake, deadline); + Ok(( + if matches!(mode, "grpc" | "http" | "h2") { + stream.with_whole_close() + } else { + stream + }, + driver, + )) +} + +#[tokio::test] +#[ignore = "requires owned official native peer"] +async fn native_cipher_matrix() { + event_for("native_cipher_matrix", "BEGIN"); + let peer: SocketAddr = std::env::var("VCORE_VMESS_PEER").unwrap().parse().unwrap(); + for cipher in [ + BodyCipher::None, + BodyCipher::Auto, + BodyCipher::Aes128Gcm, + BodyCipher::Chacha20Poly1305, + ] { + for padding in [false, true] { + for length in [false, true] { + if cipher == BodyCipher::None && (padding || length) { + continue; + } + println!("VMess fixture: {cipher:?} padding={padding} length={length}"); + tokio::time::timeout(Duration::from_secs(30), async { + let mut origin = origin::TcpOrigin::new(10).await; + let target = origin.target.clone(); + let (mut client, driver) = + stream(peer, target, cipher, padding, length).await.unwrap(); + let mut greeting = [0; 5]; + tokio::time::timeout(Duration::from_secs(3), client.read_exact(&mut greeting)) + .await + .expect("server-first greeting deadline") + .unwrap(); + assert_eq!(&greeting, b"hello"); + tokio::time::timeout( + Duration::from_secs(3), + client.write_all(&vec![0x5a; PAYLOAD_BYTES]), + ) + .await + .expect("upload deadline") + .unwrap(); + client.flush().await.unwrap(); + let mut echo = vec![0; PAYLOAD_BYTES]; + let echoed = + tokio::time::timeout(Duration::from_secs(3), client.read_exact(&mut echo)) + .await; + assert!( + echoed.is_ok(), + "native echo deadline: echo_bytes={}", + echo.iter().filter(|byte| **byte == 0x5a).count() + ); + echoed.unwrap().unwrap(); + assert!(echo.iter().all(|byte| *byte == 0x5a)); + let mut tail = [0; 7]; + tokio::time::timeout(Duration::from_secs(3), client.read_exact(&mut tail)) + .await + .expect("normal response tail deadline") + .unwrap(); + assert_eq!(&tail, b"trailer"); + client.shutdown().await.unwrap(); + let mut remaining = Vec::new(); + tokio::time::timeout( + Duration::from_secs(3), + client.read_to_end(&mut remaining), + ) + .await + .expect("close deadline") + .unwrap(); + assert!(remaining.is_empty()); + origin.accepted().await; + origin.completed().await; + drop(client); + if let Some(driver) = driver { + driver.stop().await.unwrap(); + } + }) + .await + .expect("native exchange deadline"); + } + } + } + event_for("native_cipher_matrix", "PASS"); +} + +#[tokio::test] +#[ignore = "requires owned official native peer"] +async fn native_raw_udp_boundaries() { + use vcore::{ + dispatch::{DatagramBudget, DatagramTransport}, + outbound::vmess::VmessDatagram, + session::Datagram, + }; + event_for("native_raw_udp_boundaries", "BEGIN"); + let peer: SocketAddr = std::env::var("VCORE_VMESS_PEER").unwrap().parse().unwrap(); + let fixture: serde_json::Value = + serde_json::from_str(&std::env::var("VCORE_VMESS_TRANSPORT").unwrap()).unwrap(); + for cipher in [ + BodyCipher::None, + BodyCipher::Auto, + BodyCipher::Aes128Gcm, + BodyCipher::Chacha20Poly1305, + ] { + for padding in [false, true] { + for length in [false, true] { + if cipher == BodyCipher::None && (padding || length) { + continue; + } + for family in ["ipv4", "ipv6", "domain"] { + let path_limit = + native_udp_budget(&fixture, "raw", cipher, padding, length, family); + println!( + "raw UDP: {cipher:?} padding={padding} length={length} target={family}" + ); + tokio::time::timeout(Duration::from_secs(15), async { + let mut origin = udp_ab::ContainerOrigin::connect(&origin::fixture(), family).await.unwrap(); + let address = origin.address; + let target = if family == "domain" { + Destination::domain("vcore-fixture.test", address.port()).unwrap() + } else { + address.into() + }; + let (client, driver) = + wire_stream(peer, target.clone(), cipher, padding, length, Command::Udp) + .await + .unwrap(); + let mut client = VmessDatagram::raw(client, target.clone(), DatagramBudget::new(path_limit as u16, path_limit as u16)); + for size in [1, 64, 512, 1200, path_limit] { + println!("raw UDP size={size}"); + for sequence in 0..100 { + let packet = vec![sequence as u8; size]; + client.send(Datagram { remote: target.clone(), payload: packet.clone().into(), sniffed_domain: None }).await.unwrap(); + let (received, _) = tokio::time::timeout( + Duration::from_secs(1), + origin.receive(), + ) + .await + .unwrap_or_else(|_| panic!("native UDP origin deadline: size={size} sequence={sequence}")) + .unwrap(); + assert_eq!(received.len(), size, "raw UDP origin length, sequence={sequence}"); + assert!(received == packet, "raw UDP origin content, size={size} sequence={sequence}"); + let echo = tokio::time::timeout( + Duration::from_secs(1), + client.receive(), + ) + .await + .expect("native UDP response deadline") + .unwrap(); + assert_eq!(echo.remote, target); + assert_eq!(echo.payload.len(), size, "raw UDP response length, sequence={sequence}"); + assert!(echo.payload == packet, "raw UDP response content, size={size} sequence={sequence}"); + } + } + assert!(client.send(Datagram { remote: target, payload: vec![0; path_limit + 1].into(), sniffed_domain: None }).await.is_err()); + assert!( + tokio::time::timeout( + Duration::from_millis(30), + origin.receive() + ) + .await + .is_err() + ); + drop(client); + if let Some(driver) = driver { + driver.stop().await.unwrap(); + } + }) + .await + .expect("native UDP matrix deadline"); + } + } + } + } + event_for("native_raw_udp_boundaries", "PASS"); +} + +#[tokio::test] +#[ignore = "requires owned official native peer"] +async fn native_encoded_udp_boundaries() { + use bytes::Bytes; + use vcore::{ + dispatch::{DatagramBudget, DatagramTransport}, + dns::resolution::ResolutionContext, + outbound::vmess::VmessDatagram, + session::Datagram, + }; + event_for("native_encoded_udp_boundaries", "BEGIN"); + let peer: SocketAddr = std::env::var("VCORE_VMESS_PEER").unwrap().parse().unwrap(); + let fixture: serde_json::Value = + serde_json::from_str(&std::env::var("VCORE_VMESS_TRANSPORT").unwrap()).unwrap(); + for codec in ["xudp", "packetaddr"] { + for cipher in [ + BodyCipher::None, + BodyCipher::Auto, + BodyCipher::Aes128Gcm, + BodyCipher::Chacha20Poly1305, + ] { + for padding in [false, true] { + for length in [false, true] { + if cipher == BodyCipher::None && (padding || length) { + continue; + } + for family in ["ipv4", "ipv6", "domain"] { + let maximum = + native_udp_budget(&fixture, codec, cipher, padding, length, family); + println!( + "encoded UDP: {codec} {cipher:?} padding={padding} length={length} target={family}" + ); + tokio::time::timeout(Duration::from_secs(15), async { + let mut origin = udp_ab::ContainerOrigin::connect(&origin::fixture(), family).await.unwrap(); + let address = origin.address; + let target = if family == "domain" { + Destination::domain("vcore-fixture.test", address.port()).unwrap() + } else { + address.into() + }; + let (command, magic) = if codec == "xudp" { + (Command::Mux, "v1.mux.cool") + } else { + (Command::Udp, "sp.packet-addr.v2fly.arpa") + }; + let (client, driver) = wire_stream( + peer, + Destination::domain(magic, 443).unwrap(), + cipher, + padding, + length, + command, + ) + .await + .unwrap(); + let resolver = std::sync::Arc::new(FixtureResolver( + std::sync::atomic::AtomicUsize::new(0), + )); + let mut transport: Box = if codec == "xudp" { + Box::new(vcore::xudp::XudpTransport::with_budget( + Box::new(client), + [0; 8], + DatagramBudget::new(maximum as u16, maximum as u16), + )) + } else { + Box::new(VmessDatagram::packet_addr( + client, + DatagramBudget::new(maximum as u16, maximum as u16), + ResolutionContext::measurement(resolver.clone(), true), + )) + }; + for size in [1, 64, 512, 1200, maximum] { + println!("encoded UDP size={size}"); + for sequence in 0..100 { + let packet = vec![sequence as u8; size]; + transport + .send(Datagram { + remote: target.clone(), + payload: Bytes::from(packet.clone()), + sniffed_domain: None, + }) + .await + .unwrap(); + let (incoming, _) = tokio::time::timeout( + Duration::from_secs(1), + origin.receive(), + ) + .await + .unwrap_or_else(|_| panic!("encoded UDP origin deadline: size={size} sequence={sequence}")) + .unwrap(); + assert_eq!(incoming.len(), size, "encoded UDP origin length, sequence={sequence}"); + assert!(incoming == packet, "encoded UDP origin content, size={size} sequence={sequence}"); + let response = tokio::time::timeout( + Duration::from_secs(1), + transport.receive(), + ) + .await + .unwrap_or_else(|_| panic!("encoded UDP response deadline: size={size} sequence={sequence}")) + .unwrap(); + assert_eq!(response.payload.len(), size, "encoded UDP response length, sequence={sequence}"); + assert!(response.payload == packet, "encoded UDP response content, size={size} sequence={sequence}"); + assert_eq!(response.remote.port(), address.port()); + if family != "domain" || codec == "packetaddr" { + assert_eq!(response.remote, address.into()); + } + } + } + assert_eq!( + resolver.0.load(std::sync::atomic::Ordering::SeqCst), + if family == "domain" && codec == "packetaddr" { + 500 + } else { + 0 + } + ); + let oversized = transport.send(Datagram { + remote: target.clone(), payload: vec![0; maximum + 1].into(), sniffed_domain: None, + }).await; + assert!(oversized.is_err()); + assert!(tokio::time::timeout(Duration::from_millis(30), origin.receive()).await.is_err()); + transport.close().await.unwrap(); + drop(transport); + if let Some(driver) = driver { + driver.stop().await.unwrap(); + } + }) + .await + .expect("encoded UDP matrix deadline"); + } + } + } + } + } + event_for("native_encoded_udp_boundaries", "PASS"); +} + +struct FixtureResolver(std::sync::atomic::AtomicUsize); +#[async_trait::async_trait] +impl vcore::dialer::Resolver for FixtureResolver { + async fn resolve(&self, host: &str, port: u16) -> io::Result { + assert_eq!( + host, "vcore-fixture.test", + "internal magic names must not be resolved" + ); + self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let address = std::env::var("VCORE_VMESS_ORIGIN_V4") + .unwrap_or_else(|_| "127.0.0.1".into()) + .parse::() + .map_err(|_| io::Error::other("invalid fixture resolver address"))?; + Ok(ResolvedEndpoint { + logical_host: host.into(), + port, + addresses: vec![SocketAddr::new(address, port)], + }) + } +} + +#[tokio::test] +#[ignore = "requires owned official native peer"] +async fn native_mihomo_close_alignment() { + event_for("native_mihomo_close_alignment", "BEGIN"); + tokio::time::timeout(Duration::from_secs(10), async { + let peer: SocketAddr = std::env::var("VCORE_VMESS_PEER").unwrap().parse().unwrap(); + let fixture: serde_json::Value = + serde_json::from_str(&std::env::var("VCORE_VMESS_TRANSPORT").unwrap()).unwrap(); + let mode = fixture["mode"].as_str().unwrap(); + let expected_tail = matches!(mode, "tcp" | "ws"); + let mut origin = origin::TcpOrigin::new(11).await; + let target = origin.target.clone(); + let (mut client, driver) = stream(peer, target, BodyCipher::Auto, false, false) + .await + .unwrap(); + let mut greeting = [0; 5]; + client.read_exact(&mut greeting).await.unwrap(); + assert_eq!(&greeting, b"hello"); + client.write_all(b"ping").await.unwrap(); + client.flush().await.unwrap(); + let mut echo = [0; 4]; + client.read_exact(&mut echo).await.unwrap(); + assert_eq!(&echo, b"ping"); + client.shutdown().await.unwrap(); + let mut tail = Vec::new(); + client.read_to_end(&mut tail).await.unwrap(); + assert_eq!( + tail.as_slice(), + if expected_tail { + b"native-after-upload-eof".as_slice() + } else { + b"" + }, + "close behavior differs from the official Mihomo client" + ); + origin.accepted().await; + origin.completed().await; + drop(client); + if let Some(driver) = driver { + driver.stop().await.unwrap(); + } + }) + .await + .expect("native close differential deadline"); + event_for("native_mihomo_close_alignment", "PASS"); +} + +#[tokio::test] +#[ignore = "requires owned official native peer"] +async fn native_identity_time_replay_rejection() { + event_for("native_identity_time_replay_rejection", "BEGIN"); + let peer: SocketAddr = std::env::var("VCORE_VMESS_PEER").unwrap().parse().unwrap(); + let mut origin = origin::TcpOrigin::new(12).await; + let destination = origin.target.clone(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let options = BodyOptions::new(BodyCipher::None, false, false).unwrap(); + let mut replay: Option> = None; + for (id, time, accepted) in [ + (UUID, now, true), + (uuid::Uuid::from_bytes([8; 16]), now, false), + (UUID, now - 600, false), + (UUID, now + 600, false), + (UUID, now, false), + ] { + let handshake = ClientHandshake::with_test_timestamp( + &VmessIdentity::new(id), + Command::Tcp, + &destination, + options, + time, + ) + .unwrap(); + let request = if id == UUID && time == now && !accepted { + replay.as_ref().unwrap() + } else { + handshake.request() + }; + let mut raw = Dialer::default().connect_address(peer).await.unwrap(); + raw.write_all(request).await.unwrap(); + raw.write_all(b"synthetic-probe").await.unwrap(); + if accepted { + replay = Some(handshake.request().to_vec()); + origin.accepted().await; + origin.completed().await; + let mut client = VmessStream::new( + Box::new(raw), + handshake, + tokio::time::Instant::now() + Duration::from_secs(2), + ); + let mut ok = [0; 2]; + client.read_exact(&mut ok).await.unwrap(); + assert_eq!(&ok, b"ok"); + } else { + origin.not_accepted().await; + let mut client = VmessStream::new( + Box::new(raw), + handshake, + tokio::time::Instant::now() + Duration::from_millis(350), + ); + let mut reply = [0; 1]; + assert!( + client.read(&mut reply).await.is_err(), + "rejected authentication returned business data" + ); + } + } + event_for("native_identity_time_replay_rejection", "PASS"); +} diff --git a/tests/vmess_native/origin.rs b/tests/vmess_native/origin.rs new file mode 100644 index 0000000..a4ae2af --- /dev/null +++ b/tests/vmess_native/origin.rs @@ -0,0 +1,62 @@ +//! Client-only control for origins owned by the isolated-container harness. +use super::*; + +pub fn fixture() -> serde_json::Value { + let path = std::env::var("VCORE_VMESS_AB_INPUT").expect("isolated origin fixture required"); + let value: serde_json::Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(value["isolation"], "containers"); + value +} + +pub struct TcpOrigin { + control: tokio::net::TcpStream, + pub target: Destination, +} + +impl TcpOrigin { + pub async fn new(mode: u8) -> Self { + let fixture = fixture(); + let mut control = + tokio::net::TcpStream::connect(fixture["origin_control"].as_str().unwrap()) + .await + .unwrap(); + control.set_nodelay(true).unwrap(); + control.write_u8(mode).await.unwrap(); + let port = control.read_u16().await.unwrap(); + let ip: std::net::IpAddr = fixture["origin_ipv4"].as_str().unwrap().parse().unwrap(); + assert!(!ip.is_loopback() && !ip.is_unspecified()); + Self { + control, + target: SocketAddr::new(ip, port).into(), + } + } + + pub async fn accepted(&mut self) { + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), self.control.read_u8()) + .await + .unwrap() + .unwrap(), + b'A' + ); + } + + pub async fn completed(&mut self) { + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), self.control.read_u8()) + .await + .unwrap() + .unwrap(), + b'D' + ); + } + + pub async fn not_accepted(&mut self) { + assert!( + tokio::time::timeout(Duration::from_millis(350), self.control.read_u8()) + .await + .is_err(), + "rejected authentication reached origin" + ); + } +} diff --git a/tests/vmess_native/udp_ab.rs b/tests/vmess_native/udp_ab.rs new file mode 100644 index 0000000..d1e6038 --- /dev/null +++ b/tests/vmess_native/udp_ab.rs @@ -0,0 +1,477 @@ +//! Diagnostic only: the same packet loop drives VCore and an official Mihomo client. +//! SOCKS5 below is a small fixture adapter, not VCore's SOCKS implementation. +use super::*; +use serde_json::{Value, json}; +use std::{fs::OpenOptions, io::Write}; +use vcore::{ + dispatch::{DatagramBudget, DatagramTransport}, + dns::resolution::ResolutionContext, + outbound::vmess::VmessDatagram, + session::Datagram, +}; + +enum Client { + Vcore(Box), + Mihomo { + _control: tokio::net::TcpStream, + socket: tokio::net::UdpSocket, + relay: SocketAddr, + }, +} + +pub(super) struct ContainerOrigin { + observer: tokio::net::TcpStream, + pub(super) address: SocketAddr, +} + +impl ContainerOrigin { + pub(super) async fn connect(fixture: &Value, family: &str) -> io::Result { + let control = fixture["origin_control"] + .as_str() + .ok_or_else(|| io::Error::other("isolated origin required"))?; + let mut observer = tokio::net::TcpStream::connect(control).await?; + observer.set_nodelay(true)?; + let ipv6 = family == "ipv6"; + observer.write_u8(if ipv6 { 6 } else { 4 }).await?; + let port = observer.read_u16().await?; + let ip = fixture[if ipv6 { "origin_ipv6" } else { "origin_ipv4" }] + .as_str() + .ok_or_else(|| io::Error::other("isolated origin address missing"))? + .parse::() + .map_err(|_| io::Error::other("invalid isolated origin address"))?; + if ip.is_loopback() || ip.is_unspecified() { + return Err(io::Error::other("host origin fallback forbidden")); + } + Ok(Self { + observer, + address: SocketAddr::new(ip, port), + }) + } + + pub(super) async fn receive(&mut self) -> io::Result<(Vec, u16)> { + let length = self.observer.read_u16().await? as usize; + let source_port = self.observer.read_u16().await?; + if length > 20000 { + return Err(io::Error::other("origin observation exceeds bound")); + } + let mut packet = vec![0; length]; + self.observer.read_exact(&mut packet).await?; + Ok((packet, source_port)) + } +} + +impl Client { + async fn send(&mut self, target: &Destination, payload: &[u8]) -> io::Result<()> { + match self { + Self::Vcore(io) => io + .send(Datagram { + remote: target.clone(), + payload: payload.to_vec().into(), + sniffed_domain: None, + }) + .await + .map_err(io::Error::other), + Self::Mihomo { socket, relay, .. } => { + let mut frame = vec![0, 0, 0]; + match target { + Destination::Ip(address) => match address.ip() { + std::net::IpAddr::V4(ip) => { + frame.push(1); + frame.extend(ip.octets()); + } + std::net::IpAddr::V6(ip) => { + frame.push(4); + frame.extend(ip.octets()); + } + }, + Destination::Domain { host, .. } => { + frame.extend([3, host.len() as u8]); + frame.extend(host.as_bytes()); + } + } + frame.extend(target.port().to_be_bytes()); + frame.extend(payload); + let sent = socket.send_to(&frame, *relay).await?; + if sent != frame.len() { + return Err(io::Error::other("fixture short UDP send")); + } + Ok(()) + } + } + } + + async fn receive(&mut self, target_port: u16) -> io::Result> { + match self { + Self::Vcore(io) => { + let packet = io.receive().await.map_err(io::Error::other)?; + if packet.remote.port() != target_port { + return Err(io::Error::other("response target port")); + } + Ok(packet.payload.to_vec()) + } + Self::Mihomo { socket, relay, .. } => { + let mut frame = vec![0; 20000]; + let (length, source) = socket.recv_from(&mut frame).await?; + frame.truncate(length); + if source != *relay || frame.len() < 4 || frame[..3] != [0, 0, 0] { + return Err(io::Error::other("fixture invalid SOCKS UDP response")); + } + let offset = match frame[3] { + 1 => 8, + 4 => 20, + 3 if frame.len() >= 5 => 5 + frame[4] as usize, + _ => return Err(io::Error::other("fixture address type")), + }; + if frame.len() < offset + 2 + || u16::from_be_bytes([frame[offset], frame[offset + 1]]) != target_port + { + return Err(io::Error::other("fixture response port")); + } + Ok(frame[offset + 2..].to_vec()) + } + } + } +} + +async fn connect( + kind: &str, + option: &Value, + target: &Destination, + maximum: u16, +) -> io::Result<(Client, Option)> { + if kind == "mihomo" { + let port = option["socks_port"].as_u64().unwrap() as u16; + let host = option["socks_host"].as_str().unwrap_or("127.0.0.1"); + let mut control = tokio::net::TcpStream::connect((host, port)).await?; + control.write_all(&[5, 1, 0]).await?; + let mut reply = [0; 2]; + control.read_exact(&mut reply).await?; + if reply != [5, 0] { + return Err(io::Error::other("SOCKS fixture method")); + } + control.write_all(&[5, 3, 0, 1, 0, 0, 0, 0, 0, 0]).await?; + let mut reply = [0; 4]; + control.read_exact(&mut reply).await?; + if reply[..3] != [5, 0, 0] || reply[3] != 1 { + return Err(io::Error::other("SOCKS fixture associate")); + } + let mut address = [0; 6]; + control.read_exact(&mut address).await?; + let peer_ip = control.peer_addr()?.ip(); + let bound_ip = std::net::IpAddr::from([address[0], address[1], address[2], address[3]]); + if !bound_ip.is_unspecified() && bound_ip != peer_ip { + return Err(io::Error::other("unexpected SOCKS fixture relay address")); + } + let relay = SocketAddr::new(peer_ip, u16::from_be_bytes([address[4], address[5]])); + let socket = std::net::UdpSocket::bind("0.0.0.0:0")?; + // Only the probe-owned ingress socket: leave peer and host settings alone. + socket2::SockRef::from(&socket).set_send_buffer_size(65536)?; + socket.set_nonblocking(true)?; + return Ok(( + Client::Mihomo { + _control: control, + socket: tokio::net::UdpSocket::from_std(socket)?, + relay, + }, + None, + )); + } + let cipher = match option["cipher"].as_str().unwrap() { + "none" => BodyCipher::None, + "auto" => BodyCipher::Auto, + "aes-128-gcm" => BodyCipher::Aes128Gcm, + "chacha20-poly1305" => BodyCipher::Chacha20Poly1305, + _ => panic!("fixture cipher"), + }; + let codec = option["codec"].as_str().unwrap(); + let (command, destination) = match codec { + "raw" => (Command::Udp, target.clone()), + "xudp" => (Command::Mux, Destination::domain("v1.mux.cool", 443)?), + "packetaddr" => ( + Command::Udp, + Destination::domain("sp.packet-addr.v2fly.arpa", 443)?, + ), + _ => panic!("fixture codec"), + }; + let peer = std::env::var("VCORE_VMESS_PEER").unwrap().parse().unwrap(); + let (stream, driver) = wire_stream( + peer, + destination, + cipher, + option["padding"].as_bool().unwrap(), + option["length"].as_bool().unwrap(), + command, + ) + .await?; + let budget = DatagramBudget::new(maximum, maximum); + let io: Box = match codec { + "raw" => Box::new(VmessDatagram::raw(stream, target.clone(), budget)), + "xudp" => Box::new(vcore::xudp::XudpTransport::new( + Box::new(stream), + [0; 8], + maximum, + )), + _ => Box::new(VmessDatagram::packet_addr( + stream, + budget, + ResolutionContext::measurement( + std::sync::Arc::new(FixtureResolver(std::sync::atomic::AtomicUsize::new(0))), + true, + ), + )), + }; + Ok((Client::Vcore(io), driver)) +} + +#[tokio::test] +#[ignore = "requires owned official Mihomo client and server; diagnostic only"] +async fn matched_clients() { + let fixture: Value = serde_json::from_str( + &std::fs::read_to_string(std::env::var("VCORE_VMESS_AB_INPUT").unwrap()).unwrap(), + ) + .unwrap(); + assert_eq!( + fixture["isolation"], "containers", + "native server fallback forbidden" + ); + let events = std::env::var("VCORE_VMESS_AB_EVENTS").unwrap(); + let mut events = OpenOptions::new() + .create_new(true) + .write(true) + .open(events) + .unwrap(); + let sizes = fixture["sizes"].as_array().unwrap(); + let maximum = if fixture["include_boundary"].as_bool().unwrap_or(false) { + vcore::outbound::vmess::MAX_PACKET_BYTES as u16 + } else { + sizes.iter().map(|v| v.as_u64().unwrap()).max().unwrap() as u16 + }; + let repeats = fixture["packets_per_size"].as_u64().unwrap(); + let rounds = fixture["rounds"].as_u64().unwrap(); + let mut ordinal = 0usize; + let mut failures = 0; + // Mihomo's SOCKS NAT key is only the incoming UDP source tuple. Keep each + // probe-owned socket reserved until this transport ends so another case + // cannot inherit an earlier case's codec/destination. No peer setting changes. + let mut retired_sources = Vec::new(); + for round in 0..rounds { + for (index, option) in fixture["options"].as_array().unwrap().iter().enumerate() { + for (family_index, family) in fixture["families"].as_array().unwrap().iter().enumerate() + { + let family = family.as_str().unwrap(); + let mut case_sizes: Vec = sizes + .iter() + .map(|size| size.as_u64().unwrap() as usize) + .collect(); + if fixture["include_boundary"].as_bool().unwrap_or(false) { + let overhead = if option["codec"] == "packetaddr" { + if family == "ipv4" { 7 } else { 19 } + } else { + 0 + }; + case_sizes.push(vcore::outbound::vmess::MAX_PACKET_BYTES - overhead); + } + let clients = if (round as usize + index + family_index).is_multiple_of(2) { + ["vcore", "mihomo"] + } else { + ["mihomo", "vcore"] + }; + for kind in clients { + ordinal += 1; + let mut origin = tokio::time::timeout( + Duration::from_secs(5), + ContainerOrigin::connect(&fixture, family), + ) + .await + .unwrap() + .unwrap(); + let address = origin.address; + let target = if family == "domain" { + Destination::domain("vcore-fixture.test", address.port()).unwrap() + } else { + address.into() + }; + let mut record = json!({"ordinal":ordinal,"round":round,"client":kind,"option":index,"family":family,"origin_port":address.port(),"completed":0,"status":"FAIL","phase":"setup"}); + let mut client = None; + let mut driver = None; + let started = std::time::Instant::now(); + let outcome: io::Result<()> = async { + let connected = tokio::time::timeout( + Duration::from_secs(10), + connect(kind, option, &target, maximum), + ) + .await??; + client = Some(connected.0); + driver = connected.1; + let client = client.as_mut().unwrap(); + if let Client::Mihomo { socket, .. } = &*client { + record["fixture_source_port"] = socket.local_addr()?.port().into(); + } + for &size in &case_sizes { + for sequence in 0..repeats { + record["size"] = size.into(); + record["sequence"] = sequence.into(); + let packet = vec![sequence as u8; size]; + record["phase"] = "send".into(); + tokio::time::timeout( + Duration::from_secs(1), + client.send(&target, &packet), + ) + .await??; + record["phase"] = "origin".into(); + let (received, source_port) = + tokio::time::timeout(Duration::from_secs(1), origin.receive()) + .await??; + record["server_udp_port"] = source_port.into(); + if received != packet { + return Err(io::Error::other("origin content")); + } + // The isolated origin sends its own echo, not the host. + record["phase"] = "reply".into(); + let echo = tokio::time::timeout( + Duration::from_secs(1), + client.receive(address.port()), + ) + .await??; + if echo != packet { + return Err(io::Error::other("response content")); + } + record["completed"] = + (record["completed"].as_u64().unwrap() + 1).into(); + } + } + Ok(()) + } + .await; + if let Err(error) = outcome { + failures += 1; + record["error_kind"] = format!("{:?}", error.kind()).into(); + println!( + "UDP-AB failure: client={kind} round={round} option={index} family={family} phase={} size={} sequence={}", + record["phase"], record["size"], record["sequence"] + ); + } else { + record["status"] = "PASS".into(); + record["phase"] = "complete".into(); + } + record["elapsed_ms"] = (started.elapsed().as_millis() as u64).into(); + if let Some(Client::Mihomo { socket, .. }) = client.take() { + retired_sources.push(socket); + } + if let Some(driver) = driver { + record["driver_joined"] = driver.stop().await.is_ok().into(); + } + writeln!(events, "{record}").unwrap(); + events.flush().unwrap(); + } + } + } + } + let ports: std::collections::HashSet<_> = retired_sources + .iter() + .map(|socket| socket.local_addr().unwrap().port()) + .collect(); + assert_eq!( + ports.len(), + retired_sources.len(), + "fixture source port reused" + ); + drop(retired_sources); + assert_eq!( + failures, 0, + "matched UDP client associations failed; inspect structured observations" + ); +} + +/// Change only the SOCKS ingress source tuple: a retained native NAT entry can +/// send a later fixture case to the earlier raw-UDP destination. All service +/// roles and both origins remain in containers. +#[tokio::test] +#[ignore = "requires isolated official peer and origins; expected-failure diagnostic"] +async fn socks_nat_source_reuse() { + let fixture: Value = serde_json::from_str( + &std::fs::read_to_string(std::env::var("VCORE_VMESS_AB_INPUT").unwrap()).unwrap(), + ) + .unwrap(); + assert_eq!(fixture["isolation"], "containers"); + let mut events = OpenOptions::new() + .create_new(true) + .write(true) + .open(std::env::var("VCORE_VMESS_AB_EVENTS").unwrap()) + .unwrap(); + tokio::time::timeout(Duration::from_secs(15), async { + let mut first = ContainerOrigin::connect(&fixture, "ipv4").await.unwrap(); + let mut second = ContainerOrigin::connect(&fixture, "ipv4").await.unwrap(); + let a: Destination = first.address.into(); + let b: Destination = second.address.into(); + let (mut seed, _) = connect("mihomo", &fixture["options"][0], &a, 1200) + .await + .unwrap(); + seed.send(&a, &[0x41]).await.unwrap(); + assert_eq!(first.receive().await.unwrap().0, [0x41]); + assert_eq!(seed.receive(first.address.port()).await.unwrap(), [0x41]); + let seed_port = match &seed { + Client::Mihomo { socket, .. } => socket.local_addr().unwrap().port(), + _ => unreachable!(), + }; + writeln!( + events, + "{}", + json!({"ordinal":1,"client":"mihomo","phase":"seed", + "status":"PASS","completed":1,"fixture_source_port":seed_port}) + ) + .unwrap(); + + let (mut reused, _) = connect("mihomo", &fixture["options"][13], &b, 1200) + .await + .unwrap(); + match (&mut seed, &mut reused) { + (Client::Mihomo { socket: old, .. }, Client::Mihomo { socket: new, .. }) => { + std::mem::swap(old, new) + } + _ => unreachable!(), + } + reused.send(&b, &[0x42]).await.unwrap(); + let stale = tokio::time::timeout(Duration::from_secs(1), first.receive()).await; + let misdirected = matches!(stale, Ok(Ok((ref packet, _))) if packet == &[0x42]); + let absent = tokio::time::timeout(Duration::from_secs(1), second.receive()) + .await + .is_err(); + writeln!( + events, + "{}", + json!({"ordinal":2,"client":"mihomo","phase":"source-reused", + "status":if misdirected && absent {"REPRODUCED"} else {"FAIL"}, + "completed":0,"fixture_source_port":seed_port, + "old_origin_received_new_payload":misdirected,"new_origin_received_nothing":absent}) + ) + .unwrap(); + events.flush().unwrap(); + assert!( + misdirected && absent, + "native NAT source reuse was not reproduced" + ); + + let (mut fresh, _) = connect("mihomo", &fixture["options"][13], &b, 1200) + .await + .unwrap(); + let fresh_port = match &fresh { + Client::Mihomo { socket, .. } => socket.local_addr().unwrap().port(), + _ => unreachable!(), + }; + assert_ne!(fresh_port, seed_port); + fresh.send(&b, &[0x43]).await.unwrap(); + assert_eq!(second.receive().await.unwrap().0, [0x43]); + assert_eq!(fresh.receive(second.address.port()).await.unwrap(), [0x43]); + writeln!( + events, + "{}", + json!({"ordinal":3,"client":"mihomo","phase":"unique-source-control", + "status":"PASS","completed":1,"fixture_source_port":fresh_port}) + ) + .unwrap(); + events.flush().unwrap(); + }) + .await + .unwrap(); +} diff --git a/tests/vmess_public.rs b/tests/vmess_public.rs new file mode 100644 index 0000000..c046f17 --- /dev/null +++ b/tests/vmess_public.rs @@ -0,0 +1,584 @@ +//! Public VMess consumer gate. Every origin/peer is owned by the container runner. +#![cfg(all(feature = "ffi", feature = "outbound-vmess", feature = "interop-test"))] +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + ffi::{CStr, CString}, + io::{self, Read, Write}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpListener, TcpStream, UdpSocket}, + thread, + time::{Duration, Instant}, +}; +use vcore::resources::case_events::Case; +const TIMEOUT: Duration = Duration::from_secs(10); +const DOMAIN: &str = "vcore-fixture.test"; + +#[path = "vmess_public/runtime.rs"] +mod runtime; +#[cfg(target_os = "macos")] +#[path = "vmess_public/tun.rs"] +mod tun; + +fn fixture() -> Value { + let value: Value = serde_json::from_slice( + &std::fs::read(std::env::var("VCORE_VMESS_AB_INPUT").expect("use isolated VMESS runner")) + .unwrap(), + ) + .unwrap(); + assert_eq!(value["isolation"], "containers"); + value +} +fn initialize(f: &Value) { + invoke("initialize", None, json!({"dataDir": f["data_dir"]})); +} +fn invoke(method: &str, id: Option<&str>, payload: Value) -> Value { + let response = invoke_response(method, id, payload); + assert_eq!( + response["success"], true, + "Invoke {method}: {}", + response["error"] + ); + response["data"].clone() +} +fn invoke_response(method: &str, id: Option<&str>, payload: Value) -> Value { + let mut input = + json!({"apiVersion":vcore::INVOKE_API_VERSION,"method":method,"payload":payload}); + if let Some(id) = id { + input["instanceId"] = json!(id); + } + let input = CString::new(input.to_string()).unwrap(); + // SAFETY: NUL-terminated input lives for the call; returned allocation is freed once. + let result = unsafe { vcore::ffi::VCoreInvoke(input.as_ptr()) }; + assert!(!result.is_null()); + let bytes = unsafe { CStr::from_ptr(result) }.to_bytes().to_vec(); + unsafe { vcore::ffi::VCoreFree(result) }; + serde_json::from_slice(&bytes).unwrap() +} +struct Core(Option); +impl Core { + fn prepare(config: &Value) -> Self { + invoke( + "validateConfig", + None, + json!({"configYaml":config.to_string()}), + ); + let id = invoke("createInstance", None, json!({}))["instanceId"] + .as_str() + .unwrap() + .to_owned(); + let core = Self(Some(id)); + invoke( + "prepare", + core.0.as_deref(), + json!({"configYaml":config.to_string()}), + ); + core + } + fn start(config: &Value) -> Self { + let core = Self::prepare(config); + invoke("start", core.0.as_deref(), json!({})); + core + } + fn stop(mut self) { + stop(self.0.take().unwrap()); + } +} +fn stop(id: String) { + let (tx, rx) = std::sync::mpsc::sync_channel(1); + let worker = thread::spawn(move || { + let result = std::panic::catch_unwind(|| { + invoke("stop", Some(&id), json!({})); + invoke("destroyInstance", Some(&id), json!({})); + }); + tx.send(result).unwrap(); + }); + let result = rx + .recv_timeout(Duration::from_secs(5)) + .expect("Stop exceeds 5 seconds"); + worker.join().unwrap(); + if let Err(panic) = result { + std::panic::resume_unwind(panic); + } +} +impl Drop for Core { + fn drop(&mut self) { + if let Some(id) = self.0.take() { + let _ = std::panic::catch_unwind(|| stop(id)); + } + } +} +fn free_port() -> u16 { + for _ in 0..32 { + let tcp = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + let port = tcp.local_addr().unwrap().port(); + if let (Ok(_v6), Ok(_udp), Ok(_udp6)) = ( + TcpListener::bind((Ipv6Addr::LOCALHOST, port)), + UdpSocket::bind((Ipv4Addr::LOCALHOST, port)), + UdpSocket::bind((Ipv6Addr::LOCALHOST, port)), + ) { + return port; + } + } + panic!("cannot reserve fixture port"); +} +fn socket(address: SocketAddr) -> TcpStream { + let io = TcpStream::connect_timeout(&address, TIMEOUT).unwrap(); + io.set_read_timeout(Some(TIMEOUT)).unwrap(); + io.set_write_timeout(Some(TIMEOUT)).unwrap(); + io.set_nodelay(true).unwrap(); + io +} +fn config(node: Value, port: u16) -> Value { + json!({"socks-port":port,"ipv6":true,"proxies":[node],"rules":["MATCH,peer"]}) +} +fn login(port: u16) -> TcpStream { + let mut client = socket((Ipv4Addr::LOCALHOST, port).into()); + client.write_all(&[5, 1, 0]).unwrap(); + let mut reply = [0; 2]; + client.read_exact(&mut reply).unwrap(); + assert_eq!(reply, [5, 0]); + client +} +fn address(target: SocketAddr, domain: bool) -> Vec { + let mut bytes = Vec::new(); + if domain { + bytes.extend([3, DOMAIN.len() as u8]); + bytes.extend(DOMAIN.as_bytes()); + } else { + match target.ip() { + IpAddr::V4(ip) => { + bytes.push(1); + bytes.extend(ip.octets()); + } + IpAddr::V6(ip) => { + bytes.push(4); + bytes.extend(ip.octets()); + } + } + } + bytes.extend(target.port().to_be_bytes()); + bytes +} +fn reply(client: &mut TcpStream) -> SocketAddr { + let mut header = [0; 4]; + client.read_exact(&mut header).unwrap(); + assert_eq!(&header[..3], &[5, 0, 0]); + let ip = match header[3] { + 1 => { + let mut bytes = [0; 4]; + client.read_exact(&mut bytes).unwrap(); + IpAddr::from(bytes) + } + 4 => { + let mut bytes = [0; 16]; + client.read_exact(&mut bytes).unwrap(); + IpAddr::from(bytes) + } + _ => panic!("SOCKS response address"), + }; + let mut port = [0; 2]; + client.read_exact(&mut port).unwrap(); + SocketAddr::new(ip, u16::from_be_bytes(port)) +} +fn connect(port: u16, target: SocketAddr, domain: bool) -> TcpStream { + let mut client = login(port); + let mut request = vec![5, 1, 0]; + request.extend(address(target, domain)); + client.write_all(&request).unwrap(); + reply(&mut client); + client +} +struct Origin { + observer: TcpStream, + target: SocketAddr, +} +impl Origin { + fn new(f: &Value, mode: u8, ipv6: bool) -> Self { + let mut observer = socket(f["origin_control"].as_str().unwrap().parse().unwrap()); + observer + .write_all(&[mode | if ipv6 && mode >= 10 { 128 } else { 0 }]) + .unwrap(); + let mut port = [0; 2]; + observer.read_exact(&mut port).unwrap(); + let ip: IpAddr = f[if ipv6 { "origin_ipv6" } else { "origin_ipv4" }] + .as_str() + .unwrap() + .parse() + .unwrap(); + assert!(!ip.is_loopback() && !ip.is_unspecified()); + Self { + observer, + target: SocketAddr::new(ip, u16::from_be_bytes(port)), + } + } + fn marker(&mut self, expected: u8) { + let mut byte = [0]; + self.observer.read_exact(&mut byte).unwrap(); + assert_eq!(byte[0], expected); + } + fn quiet(&mut self) { + self.observer + .set_read_timeout(Some(Duration::from_millis(100))) + .unwrap(); + let error = self.observer.read(&mut [0; 1]).unwrap_err(); + assert!(matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + )); + self.observer.set_read_timeout(Some(TIMEOUT)).unwrap(); + } + fn udp(&mut self, expected: &[u8]) { + let mut header = [0; 4]; + self.observer.read_exact(&mut header).unwrap(); + assert_ne!(&header[2..], &[0, 0]); + let length = u16::from_be_bytes([header[0], header[1]]) as usize; + assert_eq!(length, expected.len()); + let mut bytes = vec![0; length]; + self.observer.read_exact(&mut bytes).unwrap(); + assert_eq!(bytes, expected); + } +} +struct Association { + control: TcpStream, + client: UdpSocket, + relay: SocketAddr, + origin: Origin, + domain: bool, +} +impl Association { + fn new(f: &Value, port: u16, ipv6: bool, domain: bool) -> Self { + let client = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + client.set_read_timeout(Some(TIMEOUT)).unwrap(); + socket2::SockRef::from(&client) + .set_send_buffer_size(65536) + .unwrap(); + let mut control = login(port); + let mut request = vec![5, 3, 0]; + request.extend(address(client.local_addr().unwrap(), false)); + control.write_all(&request).unwrap(); + let relay = reply(&mut control); + Self { + control, + client, + relay, + origin: Origin::new(f, if ipv6 { 6 } else { 4 }, ipv6), + domain, + } + } + fn packet(&self, payload: &[u8]) -> Vec { + let mut packet = vec![0, 0, 0]; + packet.extend(address(self.origin.target, self.domain)); + packet.extend(payload); + packet + } + fn exchange(&mut self, payload: &[u8]) { + let packet = self.packet(payload); + assert_eq!( + self.client.send_to(&packet, self.relay).unwrap(), + packet.len() + ); + self.origin.udp(payload); + let mut bytes = [0; 20000]; + let (size, source) = self.client.recv_from(&mut bytes).unwrap(); + assert_eq!(source, self.relay); + assert_eq!(&bytes[..3], &[0, 0, 0]); + let offset = match bytes[3] { + 1 => 8, + 4 => 20, + 3 => 5 + bytes[4] as usize, + _ => panic!("UDP response type"), + }; + assert_eq!( + &bytes[offset..offset + 2], + &self.origin.target.port().to_be_bytes() + ); + let literal = address(self.origin.target, false); + let named = address(self.origin.target, self.domain); + assert!(bytes[3..offset + 2] == literal || bytes[3..offset + 2] == named); + assert_eq!(&bytes[offset + 2..size], payload); + } +} +fn echo(port: u16, f: &Value) { + let mut origin = Origin::new(f, 13, false); + let mut client = connect(port, origin.target, false); + client.write_all(b"client-first").unwrap(); + let mut bytes = [0; 12]; + client.read_exact(&mut bytes).unwrap(); + assert_eq!(&bytes, b"client-first"); + origin.marker(b'A'); + drop(client); + origin.marker(b'D'); +} +fn bulk(port: u16, f: &Value, ipv6: bool, domain: bool) { + let _case = Case::new("VMESS-BASE", "tcp_10mib_both_directions"); + let mut origin = Origin::new(f, 10, ipv6); + let mut client = connect(port, origin.target, domain); + let mut hello = [0; 5]; + client.read_exact(&mut hello).unwrap(); + assert_eq!(&hello, b"hello"); + origin.marker(b'A'); + let data = vec![0x5a; 10 * 1024 * 1024]; + for fragment in data.chunks(4093) { + client.write_all(fragment).unwrap(); + } + let mut hash = Sha256::new(); + let mut remaining = data.len(); + let mut bytes = [0; 6151]; + while remaining > 0 { + let size = bytes.len().min(remaining); + let n = client.read(&mut bytes[..size]).unwrap(); + assert_ne!(n, 0); + hash.update(&bytes[..n]); + remaining -= n; + } + assert_eq!(hash.finalize(), Sha256::digest(&data)); + let mut trailer = [0; 7]; + client.read_exact(&mut trailer).unwrap(); + assert_eq!(&trailer, b"trailer"); + client.shutdown(std::net::Shutdown::Write).unwrap(); + assert_eq!(client.read(&mut [0]).unwrap(), 0); + origin.marker(b'D'); +} +fn dns(config: &mut Value, origin: &Origin, via: &str) { + config["dns"] = + json!({"enable":true,"ipv6":false,"nameserver":[format!("udp://{}#{via}",origin.target)]}); +} +#[test] +#[ignore = "isolated VMESS runner"] +fn public_base() { + let _case = Case::new("VMESS-PUBLIC", "public_base"); + let f = fixture(); + initialize(&f); + let port = free_port(); + let mut dns_origin = Origin::new(&f, 17, false); + let mut yaml = config(f["node"].clone(), port); + dns(&mut yaml, &dns_origin, "DIRECT"); + let core = Core::start(&yaml); + for (v6, domain) in [(false, false), (true, false), (false, true)] { + bulk(port, &f, v6, domain); + } + echo(port, &f); + core.stop(); + for codec in ["", "xudp", "packetaddr"] { + let _codec = Case::new("VMESS-BASE", "udp_each_codec_and_family"); + let mut node = f["node"].clone(); + node["packet-encoding"] = json!(codec); + let mut yaml = config(node, port); + dns(&mut yaml, &dns_origin, "DIRECT"); + let core = Core::start(&yaml); + // Keep source sockets alive across all three cases, preventing peer NAT + // source-tuple reuse from aliasing a previous association. + let mut associations = Vec::new(); + for (v6, domain) in [(false, false), (true, false), (false, true)] { + let mut association = Association::new(&f, port, v6, domain); + let cap = if f["peer_kind"] == "V2" { + if codec == "xudp" { + 2048 + } else { + 2030 - (if codec == "packetaddr" { + if v6 || domain { 19 } else { 7 } + } else { + 0 + }) + } + } else { + 15000 + - (if codec == "packetaddr" { + if v6 || domain { 19 } else { 7 } + } else { + 0 + }) + }; + for size in [1, 64, 512, 1200, cap] { + for sequence in 0..100u8 { + let payload = vec![sequence; size]; + association.exchange(&payload); + } + } + associations.push(association); + } + core.stop(); + drop(associations); + } + // packetaddr's business-domain lookup was answered by this controlled DNS + // endpoint. Magic names cause the fixture to close, never a system lookup. + let mut header = [0; 4]; + dns_origin.observer.read_exact(&mut header).unwrap(); + let n = u16::from_be_bytes([header[0], header[1]]) as usize; + let mut query = vec![0; n]; + dns_origin.observer.read_exact(&mut query).unwrap(); + assert_eq!(&query[12..], b"\x0dvcore-fixture\x04test\0\0\x01\0\x01"); + let mut origin = Origin::new(&f, 14, false); + let result = invoke( + "measureDelay", + None, + json!({"configYamls":[json!({"proxies":[f["node"]]}).to_string()],"timeout":5,"url":format!("http://{}/",origin.target)}), + ); + assert_eq!(result["results"][0]["success"], true); + origin.marker(b'A'); + origin.marker(b'D'); +} + +fn denied(node: Value, f: &Value) { + let mut origin = Origin::new(f, 13, false); + let port = free_port(); + let core = Core::start(&config(node, port)); + let mut client = login(port); + let mut request = vec![5, 1, 0]; + request.extend(address(origin.target, false)); + request.extend(b"no-origin-bytes"); + client.write_all(&request).unwrap(); + client + .set_read_timeout(Some(Duration::from_millis(500))) + .unwrap(); + let mut response = [0; 4]; + if client.read_exact(&mut response).is_ok() && response[1] == 0 { + let mut tail = vec![0; if response[3] == 1 { 6 } else { 18 }]; + client.read_exact(&mut tail).unwrap(); + assert!( + !matches!(client.read(&mut [0;32]),Ok(n) if n>0), + "rejected session delivered business data" + ); + } + drop(client); + origin.quiet(); + core.stop(); +} +#[test] +#[ignore = "isolated VMESS runner"] +fn public_negative() { + let _case = Case::new("VMESS-PUBLIC", "public_negative"); + let f = fixture(); + initialize(&f); + let original = f["node"].clone(); + let port = free_port(); + let core = Core::start(&config(original.clone(), port)); + echo(port, &f); + core.stop(); + let mut bad = original.clone(); + bad["uuid"] = json!("08080808-0808-0808-0808-080808080808"); + denied(bad, &f); + if original["tls"] == true { + let mut untrusted = original.clone(); + untrusted.as_object_mut().unwrap().remove("fingerprint"); + denied(untrusted.clone(), &f); + let mut bad = original.clone(); + bad["fingerprint"] = json!("00".repeat(32)); + bad["skip-cert-verify"] = json!(true); + denied(bad, &f); + let mut bad = original.clone(); + bad["servername"] = json!("wrong.invalid"); + denied(bad.clone(), &f); + bad["skip-cert-verify"] = json!(true); + denied(bad, &f); + if original["network"] == "ws" { + let mut fallback = original.clone(); + fallback.as_object_mut().unwrap().remove("servername"); + let port = free_port(); + let core = Core::start(&config(fallback, port)); + echo(port, &f); + core.stop(); + } + // No pin: skip-cert-verify is observable, not silently ignored. + untrusted["skip-cert-verify"] = json!(true); + let port = free_port(); + let core = Core::start(&config(untrusted, port)); + echo(port, &f); + core.stop(); + // A matching leaf pin intentionally replaces PKI/name checks, per the + // existing StandardTlsClient contract. A nonmatching pin cannot bypass. + } + let mut bad = original.clone(); + match original["network"].as_str().unwrap() { + "ws" => bad["ws-opts"]["path"] = json!("/wrong"), + "grpc" => bad["grpc-opts"]["grpc-service-name"] = json!("wrong"), + "h2" => bad["h2-opts"]["host"] = json!(["wrong.invalid"]), + "http" => bad["http-opts"]["path"] = json!(["/wrong"]), + _ => return, + }; + denied(bad, &f); + if original["network"] == "h2" { + let mut bad = original; + bad["h2-opts"]["path"] = json!("/wrong"); + denied(bad, &f); + } +} + +#[test] +#[ignore = "isolated VMESS runner"] +fn public_alpn_rejection() { + let _case = Case::new("VMESS-PUBLIC", "public_alpn_rejection"); + let f = fixture(); + initialize(&f); + // The official listener advertises h2 when its gRPC entrance is enabled. + // A valid gRPC control proves authentication/origin readiness first. + let mut control = f["node"].clone(); + control["network"] = json!("grpc"); + control["alpn"] = json!(["h2"]); + control.as_object_mut().unwrap().remove("ws-opts"); + control["grpc-opts"] = json!({"grpc-service-name":"vmess-alpn"}); + let port = free_port(); + let core = Core::start(&config(control, port)); + echo(port, &f); + core.stop(); + denied(f["node"].clone(), &f); +} + +#[test] +#[ignore = "isolated VMESS runner"] +fn public_udp_first_response() { + let _case = Case::new("VMESS-PUBLIC", "public_udp_first_response"); + let f = fixture(); + initialize(&f); + let port = free_port(); + let core = Core::start(&config(f["node"].clone(), port)); + let mut association = Association::new(&f, port, false, false); + association.exchange(&vec![1; 15000]); + core.stop(); +} + +#[test] +#[ignore = "isolated VMESS runner"] +fn public_body_options() { + let _case = Case::new("VMESS-PUBLIC", "public_body_options"); + let f = fixture(); + initialize(&f); + let port = free_port(); + for cipher in ["auto", "aes-128-gcm", "chacha20-poly1305", "none", "zero"] { + for flags in 0..if cipher == "none" || cipher == "zero" { + 1 + } else { + 4 + } { + let _body = Case::new("VMESS-BODY", "config_controls_aead_body"); + let mut node = f["node"].clone(); + node["cipher"] = json!(cipher); + node["global-padding"] = json!(flags & 1 != 0); + node["authenticated-length"] = json!(flags & 2 != 0); + let core = Core::start(&config(node, port)); + echo(port, &f); + let mut udp = Association::new(&f, port, false, false); + udp.exchange(b"configured-body-options"); + core.stop(); + } + } +} + +#[test] +#[ignore = "isolated VMESS runner"] +fn public_legacy_regression() { + let _case = Case::new("VMESS-PUBLIC", "public_legacy_regression"); + let f = fixture(); + initialize(&f); + let port = free_port(); + let core = Core::start(&config(f["node"].clone(), port)); + echo(port, &f); + bulk(port, &f, false, false); + let mut udp = Association::new(&f, port, false, false); + for size in [1, 64, 512, 1200, 8192] { + for n in 0..100u8 { + udp.exchange(&vec![n; size]); + } + } + core.stop(); +} diff --git a/tests/vmess_public/runtime.rs b/tests/vmess_public/runtime.rs new file mode 100644 index 0000000..ef18d9f --- /dev/null +++ b/tests/vmess_public/runtime.rs @@ -0,0 +1,541 @@ +use super::*; + +#[test] +#[ignore = "isolated VMESS runner"] +fn public_udp_isolation() { + let _case = Case::new("VMESS-PUBLIC", "runtime::public_udp_isolation"); + let f = fixture(); + initialize(&f); + let port = free_port(); + for codec in ["", "xudp", "packetaddr"] { + let mut node = f["node"].clone(); + node["packet-encoding"] = json!(codec); + let core = Core::start(&config(node, port)); + let mut first = Association::new(&f, port, false, false); + let mut second = Association::new(&f, port, false, false); + for n in 0..100 { + first.exchange(&[1, n]); + second.exchange(&[2, n]); + } + let rogue = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + rogue + .send_to(&first.packet(b"unauthorized"), first.relay) + .unwrap(); + first.origin.quiet(); + let cap = if codec == "packetaddr" { 14993 } else { 15000 }; + first + .client + .send_to(&first.packet(&vec![0; cap + 1]), first.relay) + .unwrap(); + first.origin.quiet(); + second.exchange(b"oversize-isolated"); + // A different target in the same association must not be delivered to + // the original raw target. Encoded transports carry the new target. + let mut other = Origin::new(&f, 4, false); + let mut packet = vec![0, 0, 0]; + packet.extend(address(other.target, false)); + packet.extend(b"other-target"); + second.client.send_to(&packet, second.relay).unwrap(); + if codec.is_empty() { + other.quiet(); + } else { + other.udp(b"other-target"); + let mut response = [0; 128]; + let (n, _) = second.client.recv_from(&mut response).unwrap(); + assert!(response[..n].ends_with(b"other-target")); + } + second.origin.quiet(); + core.stop(); + } +} + +#[test] +#[cfg(target_os = "macos")] +#[ignore = "isolated VMESS runner"] +fn public_entrypoints() { + use std::os::{fd::AsRawFd, unix::net::UnixDatagram}; + let _case = Case::new("VMESS-PUBLIC", "runtime::public_entrypoints"); + let f = fixture(); + initialize(&f); + let port = free_port(); + let yaml = json!({"port":port,"proxies":[f["node"]],"rules":["MATCH,peer"]}); + let core = Core::start(&yaml); + let mut origin = Origin::new(&f, 14, false); + let mut client = socket((Ipv4Addr::LOCALHOST, port).into()); + write!( + client, + "GET http://{}/ HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n", + origin.target, origin.target + ) + .unwrap(); + let mut response = Vec::new(); + client.read_to_end(&mut response).unwrap(); + assert!(response.starts_with(b"HTTP/1.1 200")); + origin.marker(b'A'); + origin.marker(b'D'); + let mut origin = Origin::new(&f, 13, false); + let mut client = socket((Ipv4Addr::LOCALHOST, port).into()); + write!( + client, + "CONNECT {} HTTP/1.1\r\nHost: {}\r\n\r\n", + origin.target, origin.target + ) + .unwrap(); + let mut header = Vec::new(); + while !header.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + client.read_exact(&mut byte).unwrap(); + header.extend(byte); + assert!(header.len() < 4096); + } + assert!(header.starts_with(b"HTTP/1.1 200")); + exchange(&mut client, b"http-connect"); + origin.marker(b'A'); + core.stop(); + assert_closed(&mut client); + origin.marker(b'D'); + drop((client, origin)); + let (host, peer) = UnixDatagram::pair().unwrap(); + host.set_nonblocking(true).unwrap(); + peer.set_read_timeout(Some(TIMEOUT)).unwrap(); + let mut dns_origin = Origin::new(&f, 17, false); + let mut yaml = json!({"tun":{"enable":true},"proxies":[f["node"]],"rules":["MATCH,peer"]}); + dns(&mut yaml, &dns_origin, "peer"); + let core = Core::prepare(&yaml); + invoke( + "start", + core.0.as_deref(), + json!({"tunFd":host.as_raw_fd(),"tunFraming":"utun"}), + ); + let mut udp = Origin::new(&f, 4, false); + peer.send(&tun::packet(udp.target, 17, (0, 0, 0), b"tun-udp")) + .unwrap(); + udp.udp(b"tun-udp"); + assert_eq!(&tun::receive(&peer, 17)[28..], b"tun-udp"); + let mut origin = Origin::new(&f, 13, false); + peer.send(&tun::packet(origin.target, 6, (1, 0, 2), &[])) + .unwrap(); + let syn = tun::receive(&peer, 6); + assert_eq!(syn[33] & 0x12, 0x12); + let ack = u32::from_be_bytes(syn[24..28].try_into().unwrap()).wrapping_add(1); + peer.send(&tun::packet(origin.target, 6, (2, ack, 0x18), b"tun-tcp")) + .unwrap(); + let mut reply = Vec::new(); + while reply.len() < 7 { + let packet = tun::receive(&peer, 6); + let offset = 20 + usize::from(packet[32] >> 4) * 4; + reply.extend_from_slice(&packet[offset..]); + } + assert_eq!(reply, b"tun-tcp"); + origin.marker(b'A'); + let query=b"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x0dvcore-fixture\x04test\x00\x00\x01\x00\x01"; + peer.send(&tun::packet( + "198.18.0.1:53".parse().unwrap(), + 17, + (0, 0, 0), + query, + )) + .unwrap(); + // DNS rewrites the query ID; observe the question on the native peer path. + let mut size = [0; 4]; + dns_origin.observer.read_exact(&mut size).unwrap(); + let mut observed = vec![0; u16::from_be_bytes([size[0], size[1]]) as usize]; + dns_origin.observer.read_exact(&mut observed).unwrap(); + assert_eq!(&observed[12..], &query[12..]); + let response = tun::receive(&peer, 17); + assert_eq!(&response[28..30], &query[..2]); + assert!(response.ends_with(&match udp.target.ip() { + IpAddr::V4(ip) => ip.octets(), + _ => unreachable!(), + })); + core.stop(); + origin.marker(b'D'); + assert!(unsafe { libc::fcntl(host.as_raw_fd(), libc::F_GETFL) } & libc::O_NONBLOCK != 0); +} + +fn fd_count() -> usize { + std::fs::read_dir("/dev/fd").unwrap().count() +} +fn assert_closed(client: &mut TcpStream) { + match client.read(&mut [0; 32]) { + Ok(0) => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::ConnectionReset | io::ErrorKind::BrokenPipe + ) => {} + other => panic!("stream not closed: {other:?}"), + } +} +fn live(port: u16, f: &Value) -> (TcpStream, Origin) { + let mut origin = Origin::new(f, 13, false); + let mut client = connect(port, origin.target, false); + exchange(&mut client, b"live"); + origin.marker(b'A'); + (client, origin) +} +fn exchange(client: &mut TcpStream, bytes: &[u8]) { + client.write_all(bytes).unwrap(); + let mut result = vec![0; bytes.len()]; + client.read_exact(&mut result).unwrap(); + assert_eq!(result, bytes); +} + +#[test] +#[ignore = "isolated VMESS runner"] +fn public_lifecycle() { + let _case = Case::new("VMESS-PUBLIC", "runtime::public_lifecycle"); + let f = fixture(); + initialize(&f); + Core::start(&config(f["node"].clone(), free_port())).stop(); + for cycle in 0..20 { + let _cycle = Case::new("VMESS-LIFE", "stop_and_remain_quiet"); + let baseline = fd_count(); + let port = free_port(); + let mut held = None; + let mut udp = None; + let mut blackhole = None; + match cycle % 5 { + 0 => { + let core = Core::start(&config(f["node"].clone(), port)); + echo(port, &f); + core.stop(); + } + 3 => { + let occupied = UdpSocket::bind((Ipv4Addr::LOCALHOST, port)).unwrap(); + let core = Core::prepare(&config(f["node"].clone(), port)); + assert_eq!( + invoke_response("start", core.0.as_deref(), json!({}))["success"], + false + ); + core.stop(); + drop(occupied); + } + 2 => { + let mut origin = Origin::new(&f, 15, false); + let mut node = f["node"].clone(); + node["server"] = json!(origin.target.ip().to_string()); + node["port"] = json!(origin.target.port()); + let core = Core::start(&config(node, port)); + let mut client = login(port); + let mut request = vec![5, 1, 0]; + request.extend(address(origin.target, false)); + client.write_all(&request).unwrap(); + origin.marker(b'A'); + core.stop(); + let _ = client.read(&mut [0; 32]); + drop(client); + origin.marker(b'D'); + blackhole = Some(origin); + } + _ => { + let core = Core::start(&config(f["node"].clone(), port)); + let (first, mut origin1) = live(port, &f); + let (mut second, origin2) = live(port, &f); + let mut one = Association::new(&f, port, false, false); + let mut two = Association::new(&f, port, false, false); + one.exchange(b"association-1"); + two.exchange(b"association-2"); + drop(first); + origin1.marker(b'D'); + exchange(&mut second, b"sibling-survives"); + one.exchange(b"udp-survives"); + two.exchange(b"udp-other"); + drop(origin1); + core.stop(); + assert_closed(&mut second); + let mut origin2 = origin2; + origin2.marker(b'D'); + assert_closed(&mut one.control); + assert_closed(&mut two.control); + held = Some((second, origin2)); + udp = Some((one, two)); + } + } + // All retained descriptors belong to the test, never the runtime. Check + // immediately on Stop return, not after giving cleanup a grace period. + let retained = usize::from(held.is_some()) * 2 + + usize::from(udp.is_some()) * 6 + + usize::from(blackhole.is_some()); + assert!( + fd_count() <= baseline + retained, + "descriptors retained at Stop return" + ); + let tcp = TcpListener::bind((Ipv4Addr::LOCALHOST, port)).unwrap(); + let udp_guard = UdpSocket::bind((Ipv4Addr::LOCALHOST, port)).unwrap(); + let quiet = Instant::now(); + while quiet.elapsed() < Duration::from_secs(5) { + assert!(fd_count() <= baseline + retained + 2); + if let Some((first, second)) = &mut udp { + for item in [first, second] { + item.client.set_nonblocking(true).unwrap(); + assert_eq!( + item.client.recv(&mut [0; 64]).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + item.origin.observer.set_nonblocking(true).unwrap(); + assert_eq!( + item.origin.observer.read(&mut [0]).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + } + } + if let Some(origin) = &mut blackhole { + origin.observer.set_nonblocking(true).unwrap(); + assert_eq!( + origin.observer.read(&mut [0]).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + } + thread::sleep(Duration::from_millis(25)); + } + drop((held, udp, blackhole, tcp, udp_guard)); + assert!(fd_count() <= baseline); + } +} + +fn select(controller: u16, name: &str) { + let body = json!({"name":name}).to_string(); + let mut client = socket((Ipv4Addr::LOCALHOST, controller).into()); + write!(client,"PUT /proxies/inner HTTP/1.1\r\nHost: fixture\r\nAuthorization: Bearer fixture-only\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()).unwrap(); + let mut response = Vec::new(); + client.read_to_end(&mut response).unwrap(); + assert!(response.starts_with(b"HTTP/1.1 204")); +} +#[test] +#[ignore = "isolated VMESS runner"] +fn public_graph() { + let _case = Case::new("VMESS-PUBLIC", "runtime::public_graph"); + let f = fixture(); + initialize(&f); + let port = free_port(); + let controller = free_port(); + let mut node = f["node"].clone(); + node["dialer-proxy"] = json!("hop"); + node["server"] = json!("peer.fixture.test"); + let mut yaml = config(node.clone(), port); + yaml["proxies"] + .as_array_mut() + .unwrap() + .push(f["hop"].clone()); + let core = Core::start(&yaml); + echo(port, &f); + let mut a = Association::new(&f, port, false, false); + a.exchange(b"concrete-hop"); + core.stop(); + drop(a); + node["server"] = f["node"]["server"].clone(); + node["dialer-proxy"] = json!("outer"); + yaml["proxies"][0] = node; + yaml["external-controller"] = json!(format!("127.0.0.1:{controller}")); + yaml["secret"] = json!("fixture-only"); + yaml["proxy-groups"] = json!([{"name":"outer","type":"select","proxies":["inner"]},{"name":"inner","type":"select","proxies":["hop","DIRECT","REJECT"]}]); + let core = Core::start(&yaml); + let (mut stream, mut origin) = live(port, &f); + let mut udp = Association::new(&f, port, false, false); + udp.exchange(b"before-switch"); + select(controller, "REJECT"); + exchange(&mut stream, b"old-stream-snapshot"); + udp.exchange(b"old-udp-snapshot"); + let mut unused = Origin::new(&f, 13, false); + let mut denied = login(port); + let mut request = vec![5, 1, 0]; + request.extend(address(unused.target, false)); + denied.write_all(&request).unwrap(); + let mut result = [0; 10]; + denied.read_exact(&mut result).unwrap(); + assert_ne!(result[1], 0); + unused.quiet(); + drop((denied, unused)); + select(controller, "DIRECT"); + echo(port, &f); + exchange(&mut stream, b"still-old-snapshot"); + core.stop(); + assert_closed(&mut stream); + origin.marker(b'D'); + drop((stream, origin, udp)); + // Even an unselected cycle is rejected before any start/socket creation. + yaml["proxy-groups"][1]["proxies"] = json!(["DIRECT", "peer"]); + assert_eq!( + invoke_response( + "validateConfig", + None, + json!({"configYaml":yaml.to_string()}) + )["success"], + false + ); + // A failing selected concrete upstream never switches to DIRECT. + let mut broken = f["hop"].clone(); + broken["port"] = json!(1); + yaml["proxies"][1] = broken; + yaml["proxy-groups"][1]["proxies"] = json!(["hop", "DIRECT"]); + let core = Core::start(&yaml); + let mut unused = Origin::new(&f, 13, false); + let mut denied = login(port); + let mut request = vec![5, 1, 0]; + request.extend(address(unused.target, false)); + denied.write_all(&request).unwrap(); + denied.read_exact(&mut result).unwrap(); + assert_ne!(result[1], 0); + unused.quiet(); + core.stop(); +} + +#[test] +#[ignore = "isolated VMESS runner"] +fn public_ipv6_and_gates() { + let _case = Case::new("VMESS-PUBLIC", "runtime::public_ipv6_and_gates"); + let f = fixture(); + initialize(&f); + let port = free_port(); + let mut node = f["node"].clone(); + node["server"] = f["server_ipv6"].clone(); + let core = Core::start(&config(node, port)); + echo(port, &f); + let mut udp = Association::new(&f, port, true, false); + udp.exchange(b"outer-inner-ipv6"); + core.stop(); + drop(udp); + let mut node = f["node"].clone(); + node["udp"] = json!(false); + let mut yaml = config(node, port); + yaml["ipv6"] = json!(false); + let core = Core::start(&yaml); + echo(port, &f); + let mut udp = Association::new(&f, port, false, false); + udp.client + .send_to(&udp.packet(b"disabled"), udp.relay) + .unwrap(); + udp.origin.quiet(); + let mut origin = Origin::new(&f, 13, true); + let mut client = login(port); + let mut request = vec![5, 1, 0]; + request.extend(address(origin.target, false)); + client.write_all(&request).unwrap(); + let mut response = [0; 10]; + client.read_exact(&mut response).unwrap(); + assert_ne!(response[1], 0); + origin.quiet(); + core.stop(); +} + +#[tokio::test] +#[ignore = "isolated VMESS runner"] +async fn owned_resources() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use vcore::{ + config::{Config, ProxyProtocol}, + dialer::{Dialer, ResolvedEndpoint}, + outbound::{ + DatagramRequest, EstablishContext, OutboundConnector, UpstreamPath, + vmess::VmessOutbound, + }, + resources::observation::{ResourceKind, ResourceProbe}, + session::{Datagram, DatagramSession, InboundKind, StreamSession}, + }; + let _case = Case::new("VMESS-PUBLIC", "runtime::owned_resources"); + let f = fixture(); + let parsed = + Config::parse_yaml(config(f["node"].clone(), 1080).to_string().as_bytes()).unwrap(); + let ProxyProtocol::Vmess(node) = &parsed.proxies[0].protocol else { + unreachable!() + }; + for _ in 0..20 { + let mut case = Case::new("VMESS-OWNED", "stop_and_remain_quiet"); + let probe = ResourceProbe::default(); + case.checkpoint("baseline", probe.snapshot()); + probe + .scope(async { + let endpoint = ResolvedEndpoint { + logical_host: node.address.clone(), + port: node.port, + addresses: vec![SocketAddr::new(node.address.parse().unwrap(), node.port)], + }; + let outbound = VmessOutbound::new_with_path( + node, + UpstreamPath::direct(endpoint, Dialer::default()), + ) + .unwrap(); + let mut origin1 = Origin::new(&f, 13, false); + let mut origin2 = Origin::new(&f, 13, false); + let mut udp_origin = Origin::new(&f, 4, false); + let session = |target| StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:1".parse().unwrap(), + destination: vcore::session::Destination::Ip(target), + sniffed_domain: None, + }; + let mut first = outbound + .connect_stream(session(origin1.target), &EstablishContext::default()) + .await + .unwrap() + .io; + first.write_all(b"a").await.unwrap(); + first.flush().await.unwrap(); + assert_eq!(first.read_u8().await.unwrap(), b'a'); + origin1.marker(b'A'); + let mut second = outbound + .connect_stream(session(origin2.target), &EstablishContext::default()) + .await + .unwrap() + .io; + second.write_all(b"b").await.unwrap(); + second.flush().await.unwrap(); + assert_eq!(second.read_u8().await.unwrap(), b'b'); + origin2.marker(b'A'); + drop(first); + tokio::task::spawn_blocking(move || origin1.marker(b'D')) + .await + .unwrap(); + second.write_all(b"c").await.unwrap(); + second.flush().await.unwrap(); + assert_eq!(second.read_u8().await.unwrap(), b'c'); + let request = DatagramRequest::new(DatagramSession::new( + InboundKind::InternalMeasure, + "127.0.0.1:1".parse().unwrap(), + )); + let mut udp = outbound + .open_datagram(request, &EstablishContext::default()) + .await + .unwrap(); + udp.send(Datagram { + remote: udp_origin.target.into(), + payload: bytes::Bytes::from_static(b"owned"), + sniffed_domain: None, + }) + .await + .unwrap(); + assert_eq!(udp.receive().await.unwrap().payload.as_ref(), b"owned"); + udp_origin.udp(b"owned"); + for kind in [ + ResourceKind::Association, + ResourceKind::Session, + ResourceKind::Socket, + ] { + assert!(probe.snapshot().peak(kind) > 0); + } + if node.transport.required_alpn() == Some(b"h2") { + assert!(probe.snapshot().peak(ResourceKind::Task) > 0); + } + outbound.begin_shutdown(); + assert!(second.read_u8().await.is_err()); + drop(second); + udp.close().await.unwrap(); + drop(udp); + tokio::time::timeout(Duration::from_secs(5), outbound.shutdown()) + .await + .unwrap(); + assert!(probe.snapshot().is_idle(), "{:?}", probe.snapshot()); + case.checkpoint("after-stop", probe.snapshot()); + let snapshot = probe.snapshot(); + let quiet = tokio::time::Instant::now(); + while quiet.elapsed() < Duration::from_secs(5) { + assert_eq!(probe.snapshot(), snapshot); + tokio::time::sleep(Duration::from_millis(25)).await; + } + case.checkpoint("quiet", probe.snapshot()); + }) + .await; + case.resources(probe.snapshot()); + } +} diff --git a/tests/vmess_public/tun.rs b/tests/vmess_public/tun.rs new file mode 100644 index 0000000..68aa9f7 --- /dev/null +++ b/tests/vmess_public/tun.rs @@ -0,0 +1,77 @@ +//! Host-fd/netstack interop only: no real interface, route or system proxy. +use super::*; +use std::os::unix::net::UnixDatagram; + +fn checksum(bytes: &[u8]) -> u16 { + let mut sum: u32 = bytes + .chunks(2) + .map(|pair| u32::from(u16::from_be_bytes([pair[0], *pair.get(1).unwrap_or(&0)]))) + .sum(); + while sum >> 16 != 0 { + sum = (sum & 0xffff) + (sum >> 16); + } + !(sum as u16) +} + +pub(crate) fn packet( + target: SocketAddr, + protocol: u8, + seq_ack_flags: (u32, u32, u8), + payload: &[u8], +) -> Vec { + let SocketAddr::V4(target) = target else { + panic!("IPv4 fixture"); + }; + let source = Ipv4Addr::new(192, 0, 2, 10); + let mut segment = vec![0; if protocol == 6 { 20 } else { 8 }]; + segment[..2].copy_from_slice(&12000_u16.to_be_bytes()); + segment[2..4].copy_from_slice(&target.port().to_be_bytes()); + if protocol == 6 { + segment[4..8].copy_from_slice(&seq_ack_flags.0.to_be_bytes()); + segment[8..12].copy_from_slice(&seq_ack_flags.1.to_be_bytes()); + segment[12] = 0x50; + segment[13] = seq_ack_flags.2; + segment[14..16].copy_from_slice(&u16::MAX.to_be_bytes()); + } else { + segment[4..6].copy_from_slice(&((8 + payload.len()) as u16).to_be_bytes()); + } + segment.extend_from_slice(payload); + let mut pseudo = source.octets().to_vec(); + pseudo.extend_from_slice(&target.ip().octets()); + pseudo.extend_from_slice(&[0, protocol]); + pseudo.extend_from_slice(&(segment.len() as u16).to_be_bytes()); + pseudo.extend_from_slice(&segment); + let offset = if protocol == 6 { 16 } else { 6 }; + let value = checksum(&pseudo); + segment[offset..offset + 2].copy_from_slice(&value.to_be_bytes()); + let mut ip = vec![0; 20]; + ip[0] = 0x45; + ip[2..4].copy_from_slice(&((20 + segment.len()) as u16).to_be_bytes()); + ip[6] = 0x40; + ip[8] = 64; + ip[9] = protocol; + ip[12..16].copy_from_slice(&source.octets()); + ip[16..20].copy_from_slice(&target.ip().octets()); + let value = checksum(&ip); + ip[10..12].copy_from_slice(&value.to_be_bytes()); + ip.extend_from_slice(&segment); + let mut framed = 2_u32.to_be_bytes().to_vec(); // Darwin AF_INET PI header. + framed.extend_from_slice(&ip); + framed +} + +pub(crate) fn receive(peer: &UnixDatagram, protocol: u8) -> Vec { + let deadline = Instant::now() + TIMEOUT; + loop { + assert!(Instant::now() < deadline, "TUN packet deadline"); + let mut bytes = [0; 1504]; + let size = peer.recv(&mut bytes).unwrap(); + assert!(size >= 24); + assert_eq!(&bytes[..4], 2_u32.to_be_bytes()); + let ip = &bytes[4..size]; + assert_eq!(checksum(&ip[..20]), 0); + if ip[9] == protocol { + return ip.to_vec(); + } + } +} diff --git a/tests/xhttp_budget.rs b/tests/xhttp_budget.rs new file mode 100644 index 0000000..84bc32a --- /dev/null +++ b/tests/xhttp_budget.rs @@ -0,0 +1,105 @@ +#![cfg(feature = "outbound-vless")] +use std::{ + io, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; +use vcore::{ + config::{Config, ProxyProtocol}, + dispatch::{DatagramBudget, DatagramTransport, DispatchError}, + outbound::{ + ConnectedStream, DatagramRequest, EstablishContext, OutboundConnector, VlessOutbound, + }, + session::{Datagram, Destination, InboundKind, StreamSession}, +}; + +struct Upstream { + budget: u16, + sends: Arc, + opens: AtomicUsize, +} +struct Transport { + budget: u16, + sends: Arc, +} +#[async_trait::async_trait] +impl DatagramTransport for Transport { + async fn send(&mut self, _: Datagram) -> Result<(), DispatchError> { + self.sends.fetch_add(1, Ordering::SeqCst); + Err(io::Error::from(io::ErrorKind::NotConnected).into()) + } + async fn receive(&mut self) -> Result { + std::future::pending().await + } + async fn close(&mut self) -> Result<(), DispatchError> { + Ok(()) + } + fn payload_budget(&self, _: &Destination) -> DatagramBudget { + DatagramBudget::new(self.budget, self.budget) + } +} +#[async_trait::async_trait] +impl OutboundConnector for Upstream { + async fn connect_stream( + &self, + _: StreamSession, + _: &EstablishContext, + ) -> Result { + panic!("H3 fell back to TCP") + } + async fn open_datagram( + &self, + request: DatagramRequest, + _: &EstablishContext, + ) -> Result, DispatchError> { + assert_eq!(request.budget(), DatagramBudget::new(1400, 1400)); + self.opens.fetch_add(1, Ordering::SeqCst); + if self.budget == 0 { + return Err(DispatchError::NotAllowed); + } + Ok(Box::new(Transport { + budget: self.budget, + sends: self.sends.clone(), + })) + } +} +#[tokio::test] +async fn h3_rejects_incapable_or_small_budget_upstreams_before_any_datagram() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "h3_rejects_incapable_or_small_budget_upstreams_before_any_datagram", + ); + let config=Config::parse_yaml(b"socks-port: 1080\nproxies: [{name: edge, type: vless, server: 192.0.2.1, port: 443, uuid: 07070707-0707-0707-0707-070707070707, tls: true, network: xhttp, alpn: [h3]}]\nrules: ['MATCH,edge']").unwrap(); + let ProxyProtocol::Vless(config) = &config.proxies[0].protocol else { + unreachable!() + }; + for budget in [0, 1199] { + let upstream = Arc::new(Upstream { + budget, + sends: Arc::default(), + opens: AtomicUsize::new(0), + }); + let outbound = VlessOutbound::new_with_upstream(config, upstream.clone()).unwrap(); + let result = outbound + .connect_stream( + StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:1".parse().unwrap(), + destination: "192.0.2.2:80" + .parse::() + .unwrap() + .into(), + sniffed_domain: None, + }, + &EstablishContext::default(), + ) + .await; + assert!(result.is_err()); + assert_eq!(upstream.opens.load(Ordering::SeqCst), 1); + assert_eq!(upstream.sends.load(Ordering::SeqCst), 0); + outbound.shutdown().await; + } +} diff --git a/tests/xhttp_config.rs b/tests/xhttp_config.rs new file mode 100644 index 0000000..82ee494 --- /dev/null +++ b/tests/xhttp_config.rs @@ -0,0 +1,614 @@ +#![cfg(feature = "outbound-vless")] + +use serde_json::{Value, json}; +use vcore::config::{Config, ProxyProtocol}; + +fn document(options: Value) -> Vec { + serde_json::to_vec(&json!({ + "socks-port":1080, + "proxies":[{"name":"edge","type":"vless","server":"example.com","port":443, + "uuid":"07070707-0707-0707-0707-070707070707","network":"xhttp","tls":true, + "xhttp-opts":options}], + "rules":["MATCH,edge"] + })) + .unwrap() +} + +#[test] +fn xhttp_authorities_reject_userinfo_on_both_legs_without_echoing_it() { + for host in [ + "synthetic-secret@cover.example", + "name:synthetic-secret@cover.example:443", + "@cover.example", + ] { + for options in [ + json!({"host":host}), + json!({"download-settings":{"host":host}}), + ] { + let error = Config::parse_yaml(&document(options)) + .unwrap_err() + .to_string(); + assert!(!error.contains(host)); + assert!(!error.contains("synthetic-secret")); + } + assert!( + vcore::transport::xhttp::XHttpConfig::new( + host, + "/", + vcore::transport::xhttp::XHttpMode::StreamOne + ) + .is_err() + ); + } + for host in ["cover.example:443", "192.0.2.1:8443", "[2001:db8::1]:443"] { + for options in [ + json!({"host":host}), + json!({"download-settings":{"host":host}}), + ] { + assert!(Config::parse_yaml(&document(options)).is_ok()); + } + } +} + +#[test] +fn xhttp_fields_reject_wrong_types_null_and_every_range_boundary_before_io() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "xhttp_fields_reject_wrong_types_null_and_every_range_boundary_before_io", + ); + let fields = [ + "host", + "path", + "mode", + "headers", + "no-grpc-header", + "x-padding-bytes", + "x-padding-obfs-mode", + "x-padding-key", + "x-padding-header", + "x-padding-placement", + "x-padding-method", + "uplink-http-method", + "session-placement", + "session-key", + "session-table", + "session-length", + "seq-placement", + "seq-key", + "uplink-data-placement", + "uplink-data-key", + "uplink-chunk-size", + "sc-max-each-post-bytes", + "sc-min-posts-interval-ms", + "reuse-settings", + "download-settings", + ]; + for field in fields { + for value in [Value::Null, json!([]), json!(42)] { + assert!( + Config::parse_yaml(&document(json!({field:value}))).is_err(), + "accepted invalid type for {field}: {value}" + ); + } + } + for field in [ + "server", + "port", + "tls", + "servername", + "alpn", + "skip-cert-verify", + "name-cert-verify", + "fingerprint", + "certificate", + "private-key", + "path", + "host", + "headers", + "reality-opts", + "reuse-settings", + ] { + for value in [Value::Null, json!(42.5)] { + assert!( + Config::parse_yaml(&document(json!({"download-settings":{field:value}}))).is_err(), + "download {field}" + ); + } + } + for field in ["reuse-settings", "reality-opts"] { + assert!(Config::parse_yaml(&document(json!({"download-settings":{field:[]}}))).is_err()); + } + for (field, low, high, extra) in [ + ("x-padding-bytes", 1u64, 4096, json!({})), + ("sc-max-each-post-bytes", 1, 16777216, json!({})), + ("sc-min-posts-interval-ms", 1, 60000, json!({})), + ( + "uplink-chunk-size", + 64, + 8192, + json!({"uplink-data-placement":"header","uplink-data-key":"data"}), + ), + ("session-length", 10, 128, json!({"session-table":"number"})), + ] { + for value in [low.to_string(), high.to_string(), format!("{low}-{high}")] { + let mut options = extra.clone(); + options[field] = json!(value); + assert!( + Config::parse_yaml(&document(options)).is_ok(), + "valid boundary {field}" + ); + } + for value in [ + (high + 1).to_string(), + "-1".into(), + "1-0".into(), + "1.0".into(), + " 1".into(), + "1-2-3".into(), + String::new(), + ] { + let mut options = extra.clone(); + options[field] = json!(value); + assert!( + Config::parse_yaml(&document(options)).is_err(), + "invalid range {field}" + ); + } + } + for field in [ + "max-concurrency", + "max-connections", + "c-max-reuse-times", + "h-max-request-times", + "h-max-reusable-secs", + ] { + for value in [json!(null), json!(0), json!("-1"), json!("2147483648")] { + for download in [false, true] { + let mut options = json!({"reuse-settings":{field:value}}); + if download { + options = json!({"download-settings":options}); + } + assert!(Config::parse_yaml(&document(options)).is_err()); + } + } + assert!( + Config::parse_yaml(&document(json!({"reuse-settings":{field:"0-2147483647"}}))).is_ok() + ); + } + for download in [false, true] { + for value in [ + json!(null), + json!("0"), + json!([]), + json!(2147483648_i64), + json!(-2147483649_i64), + ] { + let mut options = json!({"reuse-settings":{"h-keep-alive-period":value}}); + if download { + options = json!({"download-settings":options}); + } + assert!(Config::parse_yaml(&document(options)).is_err()); + } + for value in [i32::MIN, -1, 0, 1, i32::MAX] { + let mut options = json!({"reuse-settings":{"h-keep-alive-period":value}}); + if download { + options = json!({"download-settings":options}); + } + assert!(Config::parse_yaml(&document(options)).is_ok()); + } + } +} + +#[test] +fn empty_http_authority_falls_back_to_each_legs_authentication_name() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "empty_http_authority_falls_back_to_each_legs_authentication_name", + ); + for (host, down_host, up, down) in [ + ("", None, "example.com", "download.example.com"), + ( + "custom.example.com", + Some(""), + "custom.example.com", + "download.example.com", + ), + ( + "custom.example.com", + None, + "custom.example.com", + "custom.example.com", + ), + ] { + let mut options = + json!({"host":host,"download-settings":{"servername":"download.example.com"}}); + if let Some(value) = down_host { + options["download-settings"]["host"] = json!(value); + } + let config = Config::parse_yaml(&document(options)).unwrap(); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + unreachable!() + }; + assert_eq!(node.xhttp().unwrap().host, up); + assert_eq!(node.download().unwrap().host, down); + } +} + +#[test] +fn download_security_changes_do_not_silently_discard_inherited_certificate_policy() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "download_security_changes_do_not_silently_discard_inherited_certificate_policy", + ); + for download in [ + json!({"tls":false}), + json!({"reality-opts":{"public-key":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}), + ] { + for clear in [false, true] { + let mut down = download.clone(); + if clear { + down["fingerprint"] = json!(""); + down["name-cert-verify"] = json!(""); + down["skip-cert-verify"] = json!(false); + } + let mut value: Value = + serde_json::from_slice(&document(json!({"download-settings":down}))).unwrap(); + value["proxies"][0]["fingerprint"] = json!("ab".repeat(32)); + value["proxies"][0]["name-cert-verify"] = json!("verify.example.com"); + value["proxies"][0]["skip-cert-verify"] = json!(true); + assert_eq!( + Config::parse_yaml(&serde_json::to_vec(&value).unwrap()).is_ok(), + clear + ); + } + } +} + +#[test] +fn h3_requires_exclusive_alpn_and_standard_tls_on_each_leg() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "h3_requires_exclusive_alpn_and_standard_tls_on_each_leg", + ); + let mut value: Value = + serde_json::from_slice(&document(json!({"download-settings":{"alpn":["h3"]}}))).unwrap(); + value["proxies"][0]["alpn"] = json!(["h3"]); + assert!(Config::parse_yaml(&serde_json::to_vec(&value).unwrap()).is_ok()); + for (alpn, tls, reality) in [ + (json!(["h3", "h2"]), true, false), + (json!(["h3"]), false, false), + (json!(["h3"]), true, true), + ] { + let mut invalid = value.clone(); + let node = &mut invalid["proxies"][0]; + node["alpn"] = alpn; + node["tls"] = tls.into(); + if reality { + node["reality-opts"] = + json!({"public-key":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}); + } + assert!(Config::parse_yaml(&serde_json::to_vec(&invalid).unwrap()).is_err()); + } + value["proxies"][0]["alpn"] = json!(["h2"]); + for options in [ + json!({"alpn":["h3"],"tls":false}), + json!({"alpn":["h3"],"reality-opts":{"public-key":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}}), + ] { + value["proxies"][0]["xhttp-opts"]["download-settings"] = options; + assert!(Config::parse_yaml(&serde_json::to_vec(&value).unwrap()).is_err()); + } +} + +#[test] +fn custom_headers_inherit_replace_clear_and_do_not_leak_values() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "custom_headers_inherit_replace_clear_and_do_not_leak_values", + ); + for (download, expected) in [ + (json!({}), Some("private-upload-value")), + ( + json!({"headers":{"X-Test":"private-download-value"}}), + Some("private-download-value"), + ), + (json!({"headers":{}}), None), + ] { + let config = Config::parse_yaml(&document(json!({ + "headers":{"X-Test":"private-upload-value"},"download-settings":download + }))) + .expect("XHTTP custom headers"); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + unreachable!() + }; + let upload = node.xhttp().unwrap(); + let down = node.download().unwrap(); + assert_eq!(upload.headers.as_map()["x-test"], "private-upload-value"); + assert_eq!( + down.headers + .as_map() + .get("x-test") + .map(|v| v.to_str().unwrap()), + expected + ); + for debug in [ + format!("{config:?}"), + format!("{upload:?}"), + format!("{down:?}"), + ] { + assert!(!debug.contains("private-")); + } + } +} + +#[test] +fn conflicting_request_fields_are_rejected_on_both_legs_before_io() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "conflicting_request_fields_are_rejected_on_both_legs_before_io", + ); + for options in [ + json!({"session-placement":"header","download-settings":{"headers":{"x-session":"override"}}}), + json!({"x-padding-obfs-mode":true,"x-padding-placement":"header","x-padding-header":"X-Pad","download-settings":{"headers":{"x-pad":"override"}}}), + json!({"session-placement":"query","download-settings":{"path":"/x?x_session=override"}}), + json!({"path":"/x?pad=override","x-padding-obfs-mode":true,"x-padding-placement":"query","x-padding-key":"pad"}), + json!({"headers":{"Cookie":"pad=override"},"x-padding-obfs-mode":true,"x-padding-placement":"cookie","x-padding-key":"pad"}), + json!({"session-placement":"header","session-key":"data-0","uplink-data-placement":"header","uplink-data-key":"data"}), + json!({"seq-placement":"header","seq-key":"X-Session","session-placement":"header"}), + ] { + assert!( + Config::parse_yaml(&document(options.clone())).is_err(), + "accepted conflicting options: {options}" + ); + } +} + +#[test] +fn http_version_selects_h1_only_for_its_single_alpn_and_supports_plaintext() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "http_version_selects_h1_only_for_its_single_alpn_and_supports_plaintext", + ); + use vcore::config::{SecurityConfig, XHttpVersion}; + for tls in [true, false] { + for (alpn, version) in [ + (json!(["http/1.1"]), XHttpVersion::Http1), + (json!(["h2"]), XHttpVersion::Http2), + (json!(["http/1.1", "h2"]), XHttpVersion::Http2), + (json!([]), XHttpVersion::Http2), + ] { + let mut value: Value = + serde_json::from_slice(&document(json!({"download-settings":{}}))).unwrap(); + value["proxies"][0]["tls"] = tls.into(); + value["proxies"][0]["alpn"] = alpn; + let config = Config::parse_yaml(&serde_json::to_vec(&value).unwrap()).unwrap(); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + unreachable!() + }; + let upload = node.xhttp().unwrap(); + assert_eq!(upload.http_version, version); + assert_eq!(node.download().unwrap().http_version, version); + assert_eq!(upload.host, "example.com"); + assert_eq!(matches!(node.security, SecurityConfig::None), !tls); + if let SecurityConfig::Tls(config) = &node.security { + let expected = if version == XHttpVersion::Http1 { + b"http/1.1".as_slice() + } else { + b"h2".as_slice() + }; + assert_eq!(config.alpn, vec![expected.to_vec()]); + assert_eq!(config.required_alpn.as_deref(), Some(expected)); + } + } + } +} + +#[test] +fn download_certificate_policy_inherits_and_explicit_false_or_empty_clears() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "download_certificate_policy_inherits_and_explicit_false_or_empty_clears", + ); + use vcore::config::SecurityConfig; + for clear in [false, true] { + let download = if clear { + json!({"skip-cert-verify":false,"name-cert-verify":"","fingerprint":""}) + } else { + json!({}) + }; + let mut value: Value = + serde_json::from_slice(&document(json!({"download-settings":download}))).unwrap(); + let node = &mut value["proxies"][0]; + node["skip-cert-verify"] = true.into(); + node["name-cert-verify"] = "verify.example.com".into(); + node["fingerprint"] = "ab".repeat(32).into(); + let config = Config::parse_yaml(&serde_json::to_vec(&value).unwrap()).unwrap(); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + unreachable!() + }; + let SecurityConfig::Tls(download) = &node.download().unwrap().security else { + unreachable!() + }; + assert_eq!(download.certificate.skip_cert_verify, !clear); + assert_eq!( + download.certificate.verification_name.as_deref(), + if clear { + None + } else { + Some("verify.example.com") + } + ); + assert_eq!( + download.certificate.fingerprint, + if clear { None } else { Some([0xab; 32]) } + ); + let SecurityConfig::Tls(upload) = &node.security else { + unreachable!() + }; + assert!(upload.certificate.skip_cert_verify); + assert_eq!(upload.certificate.fingerprint, Some([0xab; 32])); + } +} + +#[test] +fn download_mtls_identity_is_inherited_or_replaced_and_cleared_as_a_pair() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "download_mtls_identity_is_inherited_or_replaced_and_cleared_as_a_pair", + ); + use base64::Engine as _; + use vcore::config::SecurityConfig; + let identity = |name: &str| { + let value = rcgen::generate_simple_self_signed(vec![name.into()]).unwrap(); + let pem = |kind, bytes| { + format!( + "-----BEGIN {kind}-----\n{}\n-----END {kind}-----\n", + base64::engine::general_purpose::STANDARD.encode(bytes) + ) + }; + ( + pem("CERTIFICATE", value.cert.der().as_ref()), + pem("PRIVATE KEY", &value.signing_key.serialize_der()), + ) + }; + let (certificate, private_key) = identity("upload.invalid"); + let (other_certificate, other_key) = identity("download.invalid"); + for (fields, expected) in [ + (json!({}), Some(certificate.as_str())), + ( + json!({"certificate":other_certificate,"private-key":other_key}), + Some(other_certificate.as_str()), + ), + (json!({"certificate":"","private-key":""}), None), + ] { + let mut value: Value = + serde_json::from_slice(&document(json!({"download-settings":fields}))).unwrap(); + value["proxies"][0]["certificate"] = certificate.clone().into(); + value["proxies"][0]["private-key"] = private_key.clone().into(); + let parsed = Config::parse_yaml(&serde_json::to_vec(&value).unwrap()).unwrap(); + let ProxyProtocol::Vless(node) = &parsed.proxies[0].protocol else { + unreachable!() + }; + let SecurityConfig::Tls(download) = &node.download().unwrap().security else { + unreachable!() + }; + assert_eq!( + download.identity.as_ref().map(|v| v.certificate.as_str()), + expected + ); + assert!(!format!("{parsed:?}").contains("BEGIN")); + } + for fields in [ + json!({"certificate":certificate}), + json!({"private-key":private_key}), + json!({"certificate":certificate,"private-key":other_key}), + json!({"certificate":"","private-key":private_key}), + json!({"certificate":null,"private-key":null}), + ] { + assert!(Config::parse_yaml(&document(json!({"download-settings":fields}))).is_err()); + } +} + +#[test] +fn download_reality_object_replaces_inherits_or_clears_without_merging_keys() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "download_reality_object_replaces_inherits_or_clears_without_merging_keys", + ); + use base64::Engine as _; + use vcore::config::SecurityConfig; + let key = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7; 32]); + let other = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([8; 32]); + for (download, expected) in [ + (json!({}), Some(([7; 32], vec![0xaa]))), + ( + json!({"reality-opts":{"public-key":other}}), + Some(([8; 32], vec![])), + ), + (json!({"reality-opts":{}}), None), + ] { + let mut value: Value = serde_json::from_slice(&document( + json!({"mode":"packet-up","download-settings":download}), + )) + .unwrap(); + value["proxies"][0]["reality-opts"] = json!({"public-key":key,"short-id":"aa"}); + let parsed = Config::parse_yaml(&serde_json::to_vec(&value).unwrap()).unwrap(); + let ProxyProtocol::Vless(node) = &parsed.proxies[0].protocol else { + unreachable!() + }; + match (&node.download().unwrap().security, expected) { + (SecurityConfig::Reality(config), Some((key, id))) => { + assert_eq!(config.public_key, key); + assert_eq!(config.short_id, id); + } + (SecurityConfig::Tls(_), None) => {} + _ => panic!("wrong download security"), + } + assert!(matches!(node.security, SecurityConfig::Reality(_))); + } + for reality in [ + json!({"short-id":"aa"}), + json!({"public-key":""}), + json!({"public-key":null}), + json!({"public-key":key,"short-id":null}), + ] { + assert!( + Config::parse_yaml(&document( + json!({"download-settings":{"reality-opts":reality}}) + )) + .is_err() + ); + } +} + +#[test] +fn reuse_object_presence_and_download_whole_object_replacement_are_preserved() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "reuse_object_presence_and_download_whole_object_replacement_are_preserved", + ); + for (options, upload, down) in [ + (json!({"download-settings":{}}), false, false), + ( + json!({"reuse-settings":{},"download-settings":{}}), + true, + true, + ), + ( + json!({"download-settings":{"reuse-settings":{}}}), + false, + true, + ), + ] { + let config = Config::parse_yaml(&document(options)).unwrap(); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + unreachable!() + }; + assert_eq!(node.xhttp().unwrap().reuse.is_some(), upload); + assert_eq!(node.download().unwrap().reuse.is_some(), down); + } + let config = Config::parse_yaml(&document(json!({"reuse-settings":{"max-concurrency":"2-4","h-keep-alive-period":-1},"download-settings":{"reuse-settings":{}}}))).unwrap(); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + unreachable!() + }; + assert_ne!(node.xhttp().unwrap().reuse, node.download().unwrap().reuse); + for value in [ + json!(null), + json!({"max-connections":1}), + json!({"max-concurrency":"1-0"}), + json!({"h-max-request-times":"-1"}), + json!({"h-keep-alive-period":1.5}), + ] { + assert!(Config::parse_yaml(&document(json!({"reuse-settings":value}))).is_err()); + } +} diff --git a/tests/xhttp_h3_shutdown.rs b/tests/xhttp_h3_shutdown.rs new file mode 100644 index 0000000..3e2217b --- /dev/null +++ b/tests/xhttp_h3_shutdown.rs @@ -0,0 +1,302 @@ +#![cfg(feature = "outbound-vless")] +//! Memory-only QUIC regression through the public VLESS/XHTTP connector. +#[path = "support/memory_quic.rs"] +mod memory_quic; + +use bytes::{Buf, Bytes}; +use memory_quic::{MemoryPackets, MemoryUpstream, PeerSocket}; +use std::{ + future::{Future, poll_fn}, + io, + net::SocketAddr, + pin::Pin, + sync::{ + Arc, Mutex, + atomic::{AtomicU8, Ordering}, + }, + task::Poll, + time::Duration, +}; +use tokio::{ + io::{AsyncWrite, AsyncWriteExt}, + sync::mpsc, + task::JoinSet, +}; +use vcore::{ + config::{Config, ProxyProtocol}, + dispatch::{DatagramBudget, DatagramTransport, DispatchError}, + outbound::{EstablishContext, OutboundConnector, UpstreamPath, VlessOutbound}, + session::{Datagram, Destination, InboundKind, StreamSession}, +}; + +#[derive(Debug)] +struct PeerRuntime { + tasks: Mutex>>, +} + +impl PeerRuntime { + fn new() -> Arc { + Arc::new(Self { + tasks: Mutex::new(Some(JoinSet::new())), + }) + } + + async fn stop(&self) { + // Close task admission before aborting, so a retiring driver cannot + // leave new background work outside this join barrier. + let Some(mut tasks) = self.tasks.lock().unwrap().take() else { + return; + }; + tasks.abort_all(); + while let Some(result) = tasks.join_next().await { + if let Err(error) = result { + assert!( + error.is_cancelled(), + "memory QUIC peer task failed: {error}" + ); + } + } + } +} + +impl quinn::Runtime for PeerRuntime { + fn new_timer(&self, instant: std::time::Instant) -> Pin> { + quinn::Runtime::new_timer(&quinn::TokioRuntime, instant) + } + + fn spawn(&self, future: Pin + Send>>) { + if let Some(tasks) = self.tasks.lock().unwrap().as_mut() { + tasks.spawn(future); + } + } + + fn wrap_udp_socket( + &self, + _: std::net::UdpSocket, + ) -> io::Result> { + Err(io::ErrorKind::PermissionDenied.into()) + } +} + +struct SlowPackets { + inner: MemoryPackets, + state: Arc, + handshake_delay: Option, + delayed_receive: Option<(Datagram, Pin>)>, +} +#[async_trait::async_trait] +impl DatagramTransport for SlowPackets { + fn payload_budget(&self, target: &Destination) -> DatagramBudget { + self.inner.payload_budget(target) + } + async fn send(&mut self, packet: Datagram) -> Result<(), DispatchError> { + match self.state.load(Ordering::SeqCst) { + 1 => tokio::time::sleep(Duration::from_millis(20)).await, + 2 => std::future::pending().await, + _ => {} + } + self.inner.send(packet).await + } + async fn receive(&mut self) -> Result { + if self.delayed_receive.is_none() { + let packet = self.inner.receive().await?; + let Some(delay) = self.handshake_delay.take() else { + return Ok(packet); + }; + // Keep the packet and timer across cancelled receive futures. + self.delayed_receive = Some((packet, Box::pin(tokio::time::sleep(delay)))); + } + self.delayed_receive.as_mut().unwrap().1.as_mut().await; + Ok(self.delayed_receive.take().unwrap().0) + } + async fn close(&mut self) -> Result<(), DispatchError> { + self.inner.close().await + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Closing { + Normal, + Deadline, + Stop, +} + +#[tokio::test] +async fn h3_shutdown_does_not_cancel_the_last_upload() { + for mode in ["stream-one", "stream-up"] { + exercise(mode, Closing::Normal, None).await; + } +} + +#[tokio::test] +async fn h3_shutdown_cleanup_joins_peer_after_a_delayed_handshake() { + for mode in ["stream-one", "stream-up"] { + // Inflate the peer's RTT/PTO before uploading. Its normal QUIC close + // retention can then exceed the fixture's two-second cleanup budget. + exercise(mode, Closing::Normal, Some(Duration::from_millis(500))).await; + } +} + +#[tokio::test] +async fn h3_stalled_upload_is_bounded_and_owner_stop_joins_it() { + for closing in [Closing::Deadline, Closing::Stop] { + exercise("stream-one", closing, None).await; + } +} + +async fn exercise(mode: &str, closing: Closing, handshake_delay: Option) { + tokio::time::timeout(Duration::from_secs(6), run(mode, closing, handshake_delay)) + .await + .expect("memory QUIC fixture exceeded its cleanup bound"); +} + +async fn run(mode: &str, closing: Closing, handshake_delay: Option) { + let server_address: SocketAddr = "192.0.2.1:443".parse().unwrap(); + let client_address: SocketAddr = "192.0.2.2:1234".parse().unwrap(); + let (to_server, from_client) = mpsc::channel(32); + let (to_client, from_server) = mpsc::channel(32); + let client_io = MemoryPackets { + send: to_server, + receive: from_server, + remote: server_address, + }; + let socket = Arc::new(PeerSocket { + send: to_client, + receive: Mutex::new(from_client), + local: server_address, + remote: client_address, + }); + let certificate = rcgen::generate_simple_self_signed(vec!["fixture.invalid".into()]).unwrap(); + let key = rustls::pki_types::PrivatePkcs8KeyDer::from(certificate.signing_key.serialize_der()); + let mut tls = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![certificate.cert.der().clone()], key.into()) + .unwrap(); + tls.alpn_protocols = vec![b"h3".to_vec()]; + let crypto = quinn::crypto::rustls::QuicServerConfig::try_from(tls).unwrap(); + let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(crypto)); + Arc::get_mut(&mut server_config.transport) + .unwrap() + .mtu_discovery_config(None); + let peer_runtime = PeerRuntime::new(); + let endpoint = quinn::Endpoint::new_with_abstract_socket( + quinn::EndpointConfig::default(), + Some(server_config), + socket, + peer_runtime.clone(), + ) + .unwrap(); + let (started, ready) = tokio::sync::oneshot::channel(); + let (cleanup, cleaned) = tokio::sync::oneshot::channel(); + let reader = tokio::spawn(async move { + let connection = endpoint.accept().await.unwrap().await.unwrap(); + let mut h3 = h3::server::builder() + .build::<_, Bytes>(h3_quinn::Connection::new(connection.clone())) + .await + .unwrap(); + let mut downloads = Vec::new(); + let mut request = loop { + let (headers, mut stream) = h3 + .accept() + .await + .unwrap() + .unwrap() + .resolve_request() + .await + .unwrap(); + stream.send_response(http::Response::new(())).await.unwrap(); + if headers.method() == http::Method::POST { + break stream; + } + assert_eq!(headers.method(), http::Method::GET); + downloads.push(stream); // Never send a download tail or EOF. + }; + started.send(()).unwrap(); + let mut received = Vec::new(); + while let Ok(Ok(Some(mut data))) = + tokio::time::timeout(Duration::from_secs(1), request.recv_data()).await + { + received.extend_from_slice(&data.copy_to_bytes(data.remaining())); + } + let _ = cleaned.await; + drop(h3); + endpoint.close(0_u32.into(), b""); + received + }); + let config = serde_json::json!({ + "socks-port":1080, + "proxies":[{ + "name":"peer", "type":"vless", "server":"192.0.2.1", "port":443, + "uuid":"07070707-0707-0707-0707-070707070707", + "tls":true, "skip-cert-verify":true, "servername":"fixture.invalid", + "alpn":["h3"], "network":"xhttp", "xhttp-opts":{"mode":mode} + }], + "rules":["MATCH,peer"] + }); + let config = Config::parse_yaml(config.to_string().as_bytes()).unwrap(); + let ProxyProtocol::Vless(config) = &config.proxies[0].protocol else { + panic!("protocol") + }; + let state = Arc::new(AtomicU8::new(0)); + let outbound = VlessOutbound::new_with_path( + config, + UpstreamPath::proxy(Arc::new(MemoryUpstream(Mutex::new(Some(SlowPackets { + inner: client_io, + state: state.clone(), + handshake_delay, + delayed_receive: None, + }))))), + ) + .unwrap(); + let session = StreamSession { + inbound: InboundKind::InternalMeasure, + source: client_address, + destination: Destination::domain("target.invalid", 443).unwrap(), + sniffed_domain: None, + }; + let mut stream = outbound + .connect_stream(session, &EstablishContext::default()) + .await + .unwrap() + .io; + ready.await.unwrap(); + state.store( + if closing == Closing::Normal { 1 } else { 2 }, + Ordering::SeqCst, + ); + stream.write_all(&[b'x'; 16384]).await.unwrap(); + stream.flush().await.unwrap(); + assert!( + poll_fn(|cx| Poll::Ready(Pin::new(&mut stream).poll_shutdown(cx))) + .await + .is_pending() + ); + if closing == Closing::Stop { + outbound.shutdown().await; + } + let result = tokio::time::timeout(Duration::from_millis(1200), stream.shutdown()) + .await + .expect("upload completion prevented bounded close"); + if closing != Closing::Stop { + result.unwrap(); + } + if closing != Closing::Stop { + stream.shutdown().await.unwrap(); + } + let _ = cleanup.send(()); + let received = reader.await.unwrap(); + outbound.shutdown().await; + // QUIC retains closed connections for 3 * PTO, which depends on RTT and + // can legitimately exceed two seconds. After the upload and client Stop, + // reclaim the fixture's tasks explicitly instead of timing its draining. + tokio::time::timeout(Duration::from_secs(2), peer_runtime.stop()) + .await + .expect("memory QUIC peer tasks did not join"); + if closing == Closing::Normal { + assert!( + received.ends_with(&[b'x'; 16384]), + "mode={mode}: only {} upload bytes reached the peer", + received.len() + ); + } +} diff --git a/tests/xhttp_native.rs b/tests/xhttp_native.rs new file mode 100644 index 0000000..fd8f59d --- /dev/null +++ b/tests/xhttp_native.rs @@ -0,0 +1,276 @@ +#![cfg(all(feature = "outbound-vless", feature = "interop-test"))] + +use serde_json::{Value, json}; +use std::{ + net::{IpAddr, SocketAddr}, + sync::Arc, + time::Duration, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use vcore::{ + config::{Config, ProxyProtocol}, + dialer::{Dialer, ResolvedEndpoint, Resolver}, + dispatch::DatagramBudget, + outbound::{DatagramRequest, EstablishContext, OutboundConnector, VlessOutbound}, + session::{Datagram, DatagramSession, Destination, InboundKind, StreamSession}, +}; + +#[path = "xhttp_native/close.rs"] +mod close; +#[path = "xhttp_native/keepalive.rs"] +mod keepalive; +#[path = "xhttp_native/lifecycle.rs"] +mod lifecycle; +#[path = "xhttp_native/security.rs"] +mod security; + +fn node(fixture: &Value, encoding: Option<&str>) -> VlessOutbound { + let mut raw = fixture["node"].clone(); + if let Some(encoding) = encoding { + raw["packet-encoding"] = encoding.into(); + } + let config = Config::parse_yaml( + json!({"socks-port":1080,"proxies":[raw],"rules":["MATCH,peer"]}) + .to_string() + .as_bytes(), + ) + .unwrap(); + let ProxyProtocol::Vless(config) = &config.proxies[0].protocol else { + unreachable!() + }; + let endpoint_for = |address: &str, port| ResolvedEndpoint { + logical_host: address.into(), + port, + addresses: vec![SocketAddr::new( + address + .parse() + .unwrap_or_else(|_| fixture["server_ipv4"].as_str().unwrap().parse().unwrap()), + port, + )], + }; + let endpoint = endpoint_for(&config.address, config.port); + let download = config + .download() + .map(|leg| endpoint_for(&leg.address, leg.port)); + if fixture["root_der"].is_array() { + let root: Vec = serde_json::from_value(fixture["root_der"].clone()).unwrap(); + VlessOutbound::new_with_test_tls_roots_and_endpoints( + config, + endpoint, + download, + Dialer::default(), + [root], + ) + .unwrap() + } else { + VlessOutbound::new_with_endpoints(config, endpoint, download, Dialer::default()).unwrap() + } +} + +#[tokio::test] +#[ignore = "owned isolated Mihomo and origin containers required"] +async fn native_xhttp_request_fields() { + let fixture: Value = serde_json::from_slice( + &std::fs::read(std::env::var("VCORE_XHTTP_INPUT").unwrap()).unwrap(), + ) + .unwrap(); + assert_eq!(fixture["isolation"], "containers"); + let case = + vcore::resources::case_events::Case::new("XHTTP-XHTTP", "native_xhttp_request_fields"); + tokio::time::timeout(Duration::from_secs(120), async { + let outbound = node(&fixture, None); + let tiny = fixture["tiny"].as_bool().unwrap_or(false); + for family in ["ipv4", "ipv6", "domain"] { + let mut control = + tokio::net::TcpStream::connect(fixture["origin_control"].as_str().unwrap()) + .await + .unwrap(); + control.set_nodelay(true).unwrap(); + control + .write_u8((if tiny { 13 } else { 10 }) | if family == "ipv6" { 0x80 } else { 0 }) + .await + .unwrap(); + let port = control.read_u16().await.unwrap(); + let ip: IpAddr = fixture[if family == "ipv6" { + "origin_ipv6" + } else { + "origin_ipv4" + }] + .as_str() + .unwrap() + .parse() + .unwrap(); + assert!(!ip.is_loopback() && !ip.is_unspecified()); + let target = if family == "domain" { + Destination::domain("vcore-fixture.test", port).unwrap() + } else { + SocketAddr::new(ip, port).into() + }; + let mut io = outbound + .connect_stream( + StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:1".parse().unwrap(), + destination: target, + sniffed_domain: None, + }, + &EstablishContext::default(), + ) + .await + .unwrap() + .io; + if !tiny { + let mut greeting = [0; 5]; + io.read_exact(&mut greeting).await.unwrap(); + assert_eq!(&greeting, b"hello"); + } + let payload = vec![0x5a; if tiny { 256 } else { 10 * 1024 * 1024 }]; + let mut reply = vec![0; payload.len() + if tiny { 0 } else { 7 }]; + // The tiny-POST origin echoes as it reads. Drain both directions, + // like the real runtime: intentionally withholding all reads can + // exhaust h2's bounded count of buffered small DATA frames. + let (mut reader, mut writer) = tokio::io::split(&mut io); + tokio::try_join!( + async { + writer.write_all(&payload).await?; + writer.flush().await + }, + async { reader.read_exact(&mut reply).await.map(|_| ()) } + ) + .unwrap(); + drop((reader, writer)); + assert_eq!(control.read_u8().await.unwrap(), b'A'); + assert_eq!(&reply[..payload.len()], payload); + if !tiny { + assert_eq!(&reply[payload.len()..], b"trailer"); + } + io.shutdown().await.unwrap(); + drop(io); + assert_eq!( + tokio::time::timeout(Duration::from_secs(5), control.read_u8()) + .await + .expect("origin did not observe whole-connection close") + .unwrap(), + b'D' + ); + } + outbound.shutdown().await; + }) + .await + .unwrap(); + drop(case); +} + +struct FixtureResolver(IpAddr); +#[async_trait::async_trait] +impl Resolver for FixtureResolver { + async fn resolve(&self, host: &str, port: u16) -> std::io::Result { + if host != "vcore-fixture.test" { + return Err(std::io::ErrorKind::NotFound.into()); + } + Ok(ResolvedEndpoint { + logical_host: host.into(), + port, + addresses: vec![SocketAddr::new(self.0, port)], + }) + } +} + +#[tokio::test] +#[ignore = "owned isolated Mihomo and origin containers required"] +async fn native_xhttp_udp_encodings() { + let fixture: Value = serde_json::from_slice( + &std::fs::read(std::env::var("VCORE_XHTTP_INPUT").unwrap()).unwrap(), + ) + .unwrap(); + assert_eq!(fixture["isolation"], "containers"); + let case = + vcore::resources::case_events::Case::new("XHTTP-XHTTP", "native_xhttp_udp_encodings"); + tokio::time::timeout(Duration::from_secs(120), async { + let resolution = vcore::dns::resolution::ResolutionContext::measurement( + Arc::new(FixtureResolver( + fixture["origin_ipv4"].as_str().unwrap().parse().unwrap(), + )), + true, + ); + for encoding in [None, Some("none"), Some("packetaddr")] { + let outbound = node(&fixture, encoding); + for family in ["ipv4", "ipv6", "domain"] { + let mut control = + tokio::net::TcpStream::connect(fixture["origin_control"].as_str().unwrap()) + .await + .unwrap(); + control.set_nodelay(true).unwrap(); + control + .write_u8(if family == "ipv6" { 6 } else { 4 }) + .await + .unwrap(); + let port = control.read_u16().await.unwrap(); + let ip: IpAddr = fixture[if family == "ipv6" { + "origin_ipv6" + } else { + "origin_ipv4" + }] + .as_str() + .unwrap() + .parse() + .unwrap(); + assert!(!ip.is_loopback() && !ip.is_unspecified()); + let target = if family == "domain" { + Destination::domain("vcore-fixture.test", port).unwrap() + } else { + SocketAddr::new(ip, port).into() + }; + let request = DatagramRequest::new(DatagramSession::new( + InboundKind::InternalMeasure, + "127.0.0.1:1".parse().unwrap(), + )) + .with_budget(DatagramBudget::new(15000, 15000)); + let mut io = outbound + .open_datagram( + request, + &EstablishContext::with_resolution( + Duration::from_secs(10), + resolution.clone(), + ), + ) + .await + .unwrap(); + for size in [1, 64, 512, 1200, 15000] { + for index in 0..100 { + let payload: Vec = (0..size).map(|i| (i + index) as u8).collect(); + io.send(Datagram { + remote: target.clone(), + payload: payload.clone().into(), + sniffed_domain: None, + }) + .await + .unwrap(); + assert_eq!(usize::from(control.read_u16().await.unwrap()), size); + let _source = control.read_u16().await.unwrap(); + let mut received = vec![0; size]; + control.read_exact(&mut received).await.unwrap(); + assert_eq!(received, payload); + let reply = io.receive().await.unwrap(); + assert_eq!(reply.payload, payload); + assert_eq!(reply.remote.port(), port); + } + } + assert!( + io.send(Datagram { + remote: target, + payload: vec![0; 15001].into(), + sniffed_domain: None + }) + .await + .is_err() + ); + io.close().await.unwrap(); + } + outbound.shutdown().await; + } + }) + .await + .unwrap(); + drop(case); +} diff --git a/tests/xhttp_native/close.rs b/tests/xhttp_native/close.rs new file mode 100644 index 0000000..9a2516c --- /dev/null +++ b/tests/xhttp_native/close.rs @@ -0,0 +1,62 @@ +use super::*; + +#[tokio::test] +#[ignore = "owned isolated native handler and Mihomo client required"] +async fn native_mihomo_xhttp_close() { + let fixture: Value = serde_json::from_slice( + &std::fs::read(std::env::var("VCORE_XHTTP_INPUT").unwrap()).unwrap(), + ) + .unwrap(); + let _case = + vcore::resources::case_events::Case::new("XHTTP-XHTTP", "close::native_mihomo_xhttp_close"); + assert_eq!(fixture["close_reference"]["scope"], "same-mode"); + assert_eq!(fixture["close_reference"]["terminated"], true); + tokio::time::timeout(Duration::from_secs(15), async { + let outbound = node(&fixture, None); + let mut control = + tokio::net::TcpStream::connect(fixture["origin_control"].as_str().unwrap()) + .await + .unwrap(); + control.write_u8(11).await.unwrap(); + let port = control.read_u16().await.unwrap(); + let mut stream = outbound + .connect_stream( + StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:1".parse().unwrap(), + destination: SocketAddr::new( + fixture["origin_ipv4"].as_str().unwrap().parse().unwrap(), + port, + ) + .into(), + sniffed_domain: None, + }, + &EstablishContext::default(), + ) + .await + .unwrap() + .io; + let mut hello = [0; 5]; + stream.read_exact(&mut hello).await.unwrap(); + assert_eq!(&hello, b"hello"); + stream.write_all(b"ping").await.unwrap(); + stream.flush().await.unwrap(); + let mut reply = [0; 4]; + stream.read_exact(&mut reply).await.unwrap(); + assert_eq!(&reply, b"ping"); + stream.shutdown().await.unwrap(); + let mut tail = Vec::new(); + let _terminal = stream.read_to_end(&mut tail).await; + let hex: String = tail.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!( + hex, + fixture["close_reference"]["tail_hex"].as_str().unwrap() + ); + assert_eq!(control.read_u8().await.unwrap(), b'A'); + assert_eq!(control.read_u8().await.unwrap(), b'D'); + drop(stream); + outbound.shutdown().await; + }) + .await + .unwrap(); +} diff --git a/tests/xhttp_native/keepalive.rs b/tests/xhttp_native/keepalive.rs new file mode 100644 index 0000000..dd6b547 --- /dev/null +++ b/tests/xhttp_native/keepalive.rs @@ -0,0 +1,82 @@ +use super::*; + +#[tokio::test] +#[ignore = "owned isolated Xray H3 handler required"] +async fn native_h3_keepalive_observes_each_leg_and_stop() { + let fixture: Value = serde_json::from_slice( + &std::fs::read(std::env::var("VCORE_XHTTP_INPUT").unwrap()).unwrap(), + ) + .unwrap(); + let _case = vcore::resources::case_events::Case::new( + "XHTTP-XHTTP", + "keepalive::native_h3_keepalive_observes_each_leg_and_stop", + ); + for (upload, download, seconds) in [(0, -1, 11), (-1, 1, 3), (1, 0, 11)] { + let mut f = fixture.clone(); + f["node"]["xhttp-opts"]["reuse-settings"] = json!({"h-keep-alive-period":upload}); + f["node"]["xhttp-opts"]["download-settings"] = + json!({"reuse-settings":{"h-keep-alive-period":download}}); + let outbound = node(&f, None); + let mut control = tokio::net::TcpStream::connect(f["origin_control"].as_str().unwrap()) + .await + .unwrap(); + control.write_u8(13).await.unwrap(); + let port = control.read_u16().await.unwrap(); + let mut io = outbound + .connect_stream( + StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:1".parse().unwrap(), + destination: SocketAddr::new( + f["origin_ipv4"].as_str().unwrap().parse().unwrap(), + port, + ) + .into(), + sniffed_domain: None, + }, + &EstablishContext::default(), + ) + .await + .unwrap() + .io; + io.write_all(b"ping").await.unwrap(); + io.flush().await.unwrap(); + let mut data = [0; 4]; + io.read_exact(&mut data).await.unwrap(); + assert_eq!(&data, b"ping"); + assert_eq!(control.read_u8().await.unwrap(), b'A'); + tokio::time::sleep(Duration::from_millis(500)).await; + let before = outbound.xhttp_quic_ping_counts(); + assert_eq!((before.0.len(), before.1.len()), (1, 1)); + tokio::time::sleep(Duration::from_secs(seconds)).await; + let after = outbound.xhttp_quic_ping_counts(); + for (period, first, last) in [ + (upload, &before.0, &after.0), + (download, &before.1, &after.1), + ] { + assert_eq!(first.len(), last.len()); + if period < 0 { + assert_eq!(first, last, "disabled leg sent idle PING"); + } else { + assert!(last[0] > first[0], "configured/default leg sent no PING"); + } + } + io.write_all(b"pong").await.unwrap(); + io.flush().await.unwrap(); + io.read_exact(&mut data).await.unwrap(); + assert_eq!(&data, b"pong"); + io.shutdown().await.unwrap(); + drop(io); + assert_eq!( + tokio::time::timeout(Duration::from_secs(5), control.read_u8()) + .await + .unwrap() + .unwrap(), + b'D' + ); + tokio::time::timeout(Duration::from_secs(5), outbound.shutdown()) + .await + .unwrap(); + assert_eq!(outbound.xhttp_quic_ping_counts(), (vec![], vec![])); + } +} diff --git a/tests/xhttp_native/lifecycle.rs b/tests/xhttp_native/lifecycle.rs new file mode 100644 index 0000000..6c9fb71 --- /dev/null +++ b/tests/xhttp_native/lifecycle.rs @@ -0,0 +1,142 @@ +use super::*; +use vcore::resources::{ + case_events::Case, + observation::{ResourceKind, ResourceProbe}, +}; + +async fn origin(f: &Value, mode: u8) -> (tokio::net::TcpStream, Destination) { + let mut control = tokio::net::TcpStream::connect(f["origin_control"].as_str().unwrap()) + .await + .unwrap(); + control.set_nodelay(true).unwrap(); + control.write_u8(mode).await.unwrap(); + let port = control.read_u16().await.unwrap(); + let ip = f["origin_ipv4"].as_str().unwrap().parse().unwrap(); + (control, Destination::Ip(SocketAddr::new(ip, port))) +} + +async fn tcp(outbound: &VlessOutbound, target: Destination) -> vcore::dispatch::BoxStream { + outbound + .connect_stream( + StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:1".parse().unwrap(), + destination: target, + sniffed_domain: None, + }, + &EstablishContext::default(), + ) + .await + .unwrap() + .io +} +async fn exchange(io: &mut vcore::dispatch::BoxStream, byte: u8) { + io.write_u8(byte).await.unwrap(); + io.flush().await.unwrap(); + assert_eq!(io.read_u8().await.unwrap(), byte); +} + +#[tokio::test] +#[ignore = "owned isolated protocol peers and origins required"] +async fn native_xhttp_owned_resources() { + let f: Value = serde_json::from_slice( + &std::fs::read(std::env::var("VCORE_XHTTP_INPUT").unwrap()).unwrap(), + ) + .unwrap(); + assert_eq!(f["isolation"], "containers"); + let _case = Case::new("XHTTP-XHTTP", "lifecycle::native_xhttp_owned_resources"); + tokio::time::timeout(Duration::from_secs(220), async { + for _ in 0..20 { + let mut cycle = Case::new("XHTTP-OWNED", "stop_and_remain_quiet"); + let probe = ResourceProbe::default(); + cycle.checkpoint("baseline", probe.snapshot()); + probe + .scope(async { + let outbound = node(&f, None); + let (mut control1, target1) = origin(&f, 13).await; + let (mut control2, target2) = origin(&f, 13).await; + let (mut control_udp, target_udp) = origin(&f, 4).await; + let mut first = tcp(&outbound, target1).await; + let mut sibling = tcp(&outbound, target2).await; + exchange(&mut first, b'a').await; + exchange(&mut sibling, b'b').await; + assert_eq!(control1.read_u8().await.unwrap(), b'A'); + assert_eq!(control2.read_u8().await.unwrap(), b'A'); + first.shutdown().await.unwrap(); + drop(first); + assert_eq!( + tokio::time::timeout(Duration::from_secs(5), control1.read_u8()) + .await + .unwrap() + .unwrap(), + b'D' + ); + exchange(&mut sibling, b'c').await; + let mut udp = outbound + .open_datagram( + DatagramRequest::new(DatagramSession::new( + InboundKind::InternalMeasure, + "127.0.0.1:1".parse().unwrap(), + )), + &EstablishContext::default(), + ) + .await + .unwrap(); + udp.send(Datagram { + remote: target_udp, + payload: bytes::Bytes::from_static(b"owned"), + sniffed_domain: None, + }) + .await + .unwrap(); + assert_eq!(udp.receive().await.unwrap().payload.as_ref(), b"owned"); + assert_eq!(control_udp.read_u16().await.unwrap(), 5); + assert_ne!(control_udp.read_u16().await.unwrap(), 0); + let mut observed = [0; 5]; + control_udp.read_exact(&mut observed).await.unwrap(); + assert_eq!(&observed, b"owned"); + for kind in [ + ResourceKind::Association, + ResourceKind::Session, + ResourceKind::Socket, + ResourceKind::Task, + ] { + assert!( + probe.snapshot().peak(kind) > 0, + "unobserved resource: {kind:?}" + ); + } + outbound.begin_shutdown(); + assert!( + tokio::time::timeout(Duration::from_secs(5), sibling.read_u8()) + .await + .unwrap() + .is_err() + ); + drop(sibling); + let _ = udp.close().await; + drop(udp); + tokio::time::timeout(Duration::from_secs(5), outbound.shutdown()) + .await + .expect("node Stop exceeded five seconds"); + assert!( + probe.snapshot().is_idle(), + "Stop retained owned resources: {:?}", + probe.snapshot() + ); + cycle.checkpoint("after-stop", probe.snapshot()); + let stopped = probe.snapshot(); + let quiet = tokio::time::Instant::now(); + while quiet.elapsed() < Duration::from_secs(5) { + assert_eq!(probe.snapshot(), stopped); + tokio::time::sleep(Duration::from_millis(25)).await; + } + cycle.checkpoint("quiet", probe.snapshot()); + }) + .await; + cycle.resources(probe.snapshot()); + } + }) + .await + .unwrap(); +} diff --git a/tests/xhttp_native/security.rs b/tests/xhttp_native/security.rs new file mode 100644 index 0000000..9dd5d94 --- /dev/null +++ b/tests/xhttp_native/security.rs @@ -0,0 +1,100 @@ +//! Authentication is checked against native container peers, never a host TLS server. +use super::*; +use vcore::resources::{case_events::Case, observation::ResourceProbe}; + +#[tokio::test] +#[ignore = "owned isolated authentication peers required"] +async fn native_xhttp_security() { + let fixture: Value = serde_json::from_slice( + &std::fs::read(std::env::var("VCORE_XHTTP_INPUT").unwrap()).unwrap(), + ) + .unwrap(); + assert_eq!(fixture["isolation"], "containers"); + let case = Case::new("XHTTP-XHTTP", "security::native_xhttp_security"); + let probe = ResourceProbe::default(); + probe + .scope(async { + let outbound = node(&fixture, None); + let mut control = + tokio::net::TcpStream::connect(fixture["origin_control"].as_str().unwrap()) + .await + .unwrap(); + control.set_nodelay(true).unwrap(); + // Mode 12 records any connection before returning an acknowledgement; + // mode 10 sends first and checks both directions independently. + let reject = fixture["reject"].as_bool().unwrap(); + control + .write_u8(if reject { 12 } else { 10 }) + .await + .unwrap(); + let port = control.read_u16().await.unwrap(); + let destination = SocketAddr::new( + fixture["origin_ipv4"].as_str().unwrap().parse().unwrap(), + port, + ) + .into(); + let context = EstablishContext::with_timeout(Duration::from_secs(4)); + let outcome = + tokio::time::timeout(Duration::from_secs(if reject { 5 } else { 120 }), async { + let connected = outbound + .connect_stream( + StreamSession { + inbound: InboundKind::InternalMeasure, + source: "127.0.0.1:1".parse().unwrap(), + destination, + sniffed_domain: None, + }, + &context, + ) + .await + .map_err(std::io::Error::other)?; + let mut io = connected.io; + if reject { + io.write_all(b"synthetic-probe").await?; + io.flush().await?; + let mut reply = [0; 2]; + io.read_exact(&mut reply).await?; + return Ok::<_, std::io::Error>(()); + } + let mut first = [0; 5]; + io.read_exact(&mut first).await?; + assert_eq!(&first, b"hello"); + assert_eq!(control.read_u8().await?, b'A'); + let payload = vec![0x5a; 10 * 1024 * 1024]; + io.write_all(&payload).await?; + io.flush().await?; + let mut reply = vec![0; payload.len() + 7]; + io.read_exact(&mut reply).await?; + assert_eq!(&reply[..payload.len()], payload); + assert_eq!(&reply[payload.len()..], b"trailer"); + io.shutdown().await?; + drop(io); + assert_eq!(control.read_u8().await?, b'D'); + Ok(()) + }) + .await + .expect("native authentication/data path exceeded its deadline"); + if reject { + assert!(outcome.is_err(), "invalid native identity exchanged data"); + assert!( + tokio::time::timeout(Duration::from_millis(300), control.read_u8()) + .await + .is_err(), + "origin accepted traffic on a rejected path" + ); + } else { + outcome.unwrap(); + } + outbound.begin_shutdown(); + tokio::time::timeout(Duration::from_secs(5), outbound.shutdown()) + .await + .unwrap(); + drop(outbound); + assert!( + probe.snapshot().is_idle(), + "resources remain after shutdown" + ); + }) + .await; + drop(case); +} diff --git a/tests/xhttp_requests.rs b/tests/xhttp_requests.rs new file mode 100644 index 0000000..c6cf748 --- /dev/null +++ b/tests/xhttp_requests.rs @@ -0,0 +1,851 @@ +#![cfg(feature = "outbound-vless")] + +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use vcore::{ + config::{Config, ProxyProtocol}, + transport::xhttp::{XHttpClient, XHttpConfig, XHttpMode}, +}; + +#[tokio::test(start_paused = true)] +async fn packet_up_coalesces_small_writes_and_flushes_without_more_caller_io() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "packet_up_coalesces_small_writes_and_flushes_without_more_caller_io", + ); + use std::{pin::Pin, task::Poll}; + use tokio::io::AsyncWrite; + let client = + XHttpClient::new(XHttpConfig::new("example.com", "/x", XHttpMode::PacketUp).unwrap()); + let (io, remote) = tokio::io::duplex(16384); + let (posted, mut received) = tokio::sync::mpsc::channel(2); + let peer = tokio::spawn(async move { + let mut connection = h2::server::handshake(remote).await.unwrap(); + let tasks = tokio_util::task::TaskTracker::new(); + let mut downloads = Vec::new(); + while let Some(Ok((request, mut reply))) = connection.accept().await { + if request.method() == http::Method::GET { + downloads.push(reply.send_response(http::Response::new(()), false).unwrap()); + } else { + let posted = posted.clone(); + tasks.spawn(async move { + let mut input = request.into_body(); + let mut payload = Vec::new(); + while let Some(data) = input.data().await { + let data = data.unwrap(); + input.flow_control().release_capacity(data.len()).unwrap(); + payload.extend_from_slice(&data); + } + reply.send_response(http::Response::new(()), true).unwrap(); + posted.send(payload).await.unwrap(); + }); + } + } + tasks.close(); + tasks.wait().await; + }); + let mut stream = client.connect(Box::new(io)).await.unwrap(); + let started = tokio::time::Instant::now(); + for fragment in [b"abc", b"def", b"ghi", b"jkl"] { + futures_util::future::poll_fn(|cx| { + assert!( + matches!( + Pin::new(&mut stream).poll_write(cx, fragment), + Poll::Ready(Ok(3)) + ), + "a small write waited for its own POST instead of joining the batch" + ); + Poll::Ready(()) + }) + .await; + } + // No further poll on the stream: its owned timer must flush the batch. + let payload = tokio::time::timeout(Duration::from_secs(1), received.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(payload, b"abcdefghijkl"); + assert!(started.elapsed() >= Duration::from_millis(30)); + stream.flush().await.unwrap(); + stream.write_all(b"tail").await.unwrap(); + let closing = tokio::time::Instant::now(); + stream.shutdown().await.unwrap(); + assert_eq!(received.recv().await.unwrap(), b"tail"); + assert!( + closing.elapsed() < Duration::from_millis(30), + "close must flush without waiting for the normal batching timer" + ); + drop(stream); + client.stop().await; + peer.await.unwrap(); +} + +#[tokio::test(start_paused = true)] +async fn packet_up_backpressures_bounded_batches_and_stop_cancels_the_timer() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "packet_up_backpressures_bounded_batches_and_stop_cancels_the_timer", + ); + use std::{pin::Pin, task::Poll}; + use tokio::io::AsyncWrite; + let client = + XHttpClient::new(XHttpConfig::new("example.com", "/x", XHttpMode::PacketUp).unwrap()); + let (io, remote) = tokio::io::duplex(16384); + let peer = tokio::spawn(async move { + if let Ok(mut connection) = h2::server::handshake(remote).await { + while connection.accept().await.is_some() {} + } + }); + let mut stream = client.connect(Box::new(io)).await.unwrap(); + let accepted = futures_util::future::poll_fn(|cx| { + let mut accepted = 0; + // Do not advance the timer or poll the HTTP peer. A full batch must + // backpressure before accepting unbounded caller data. + loop { + match Pin::new(&mut stream).poll_write(cx, &[0x5a; 4096]) { + Poll::Ready(Ok(n)) => { + assert!(n > 0 && n <= 4096); + accepted += n; + assert!(accepted <= 65536); + } + Poll::Pending => break, + Poll::Ready(Err(error)) => panic!("{error}"), + } + } + assert!(Pin::new(&mut stream).poll_flush(cx).is_pending()); + Poll::Ready(accepted) + }) + .await; + assert_eq!(accepted, 65536); + tokio::time::timeout(Duration::from_secs(1), client.stop()) + .await + .unwrap(); + assert!(stream.write_all(b"late").await.is_err()); + assert!(stream.flush().await.is_err()); + assert!(stream.read_u8().await.is_err()); + drop(stream); + peer.await.unwrap(); +} + +#[tokio::test] +async fn first_vless_read_never_acknowledges_or_replays_a_concurrent_packet_write() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "first_vless_read_never_acknowledges_or_replays_a_concurrent_packet_write", + ); + use std::{pin::Pin, task::Poll}; + use tokio::io::AsyncWrite; + tokio::time::timeout(Duration::from_secs(3), async { + for explicit_flush in [false, true] { + let client = XHttpClient::new( + XHttpConfig::new("example.com", "/x", XHttpMode::PacketUp).unwrap(), + ); + let (io, remote) = tokio::io::duplex(16384); + let peer = tokio::spawn(async move { + let mut connection = h2::server::handshake(remote).await.unwrap(); + let mut downloads = Vec::new(); + let tasks = tokio_util::task::TaskTracker::new(); + let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + while let Some(Ok((request, mut reply))) = connection.accept().await { + if request.method() == http::Method::GET { + let mut send = reply.send_response(http::Response::new(()), false).unwrap(); + send.send_data(bytes::Bytes::from_static(b"\0\0r"), false) + .unwrap(); + downloads.push(send); + } else { + let received = received.clone(); + tasks.spawn(async move { + let mut input = request.into_body(); + let mut payload = Vec::new(); + while let Some(Ok(data)) = input.data().await { + input.flow_control().release_capacity(data.len()).unwrap(); + payload.extend_from_slice(&data); + } + received.lock().unwrap().extend(payload); + reply.send_response(http::Response::new(()), true).unwrap(); + }); + } + } + tasks.close(); + tasks.wait().await; + received.lock().unwrap().clone() + }); + let raw = client.connect(Box::new(io)).await.unwrap(); + let mut stream = + vcore::outbound::VlessStream::new(raw, bytes::Bytes::from_static(b"header")); + stream.write_all(b"preface").await.unwrap(); + if explicit_flush { + stream.flush().await.unwrap(); + } + // Interleave the first read with a write. Buffered acceptance is + // allowed, but neither accepted nor pending bytes may be replayed. + let accepted = futures_util::future::poll_fn(|cx| { + Poll::Ready(match Pin::new(&mut stream).poll_write(cx, b"packet") { + Poll::Ready(result) => result.unwrap(), + Poll::Pending => 0, + }) + }) + .await; + assert_eq!(stream.read_u8().await.unwrap(), b'r'); + stream.write_all(&b"packet"[accepted..]).await.unwrap(); + stream.flush().await.unwrap(); + stream.shutdown().await.unwrap(); + drop(stream); + client.stop().await; + assert_eq!( + peer.await.unwrap(), + b"headerprefacepacket", + "explicit_flush={explicit_flush}" + ); + } + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn response_codes_follow_mihomo_streaming_and_packet_rules() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "response_codes_follow_mihomo_streaming_and_packet_rules", + ); + tokio::time::timeout(Duration::from_secs(3), async { + for (mode, get_status, post_status, read_ok, write_ok) in [ + (XHttpMode::StreamOne, 200, 201, true, true), + (XHttpMode::StreamUp, 200, 201, true, true), + (XHttpMode::StreamUp, 201, 200, false, true), + (XHttpMode::PacketUp, 201, 200, false, true), + (XHttpMode::PacketUp, 200, 201, true, false), + ] { + let client = XHttpClient::new(XHttpConfig::new("example.com", "/x", mode).unwrap()); + let (io, remote) = tokio::io::duplex(16384); + let peer = tokio::spawn(async move { + let mut connection = h2::server::handshake(remote).await.unwrap(); + let mut bodies = Vec::new(); + let mut replies = Vec::new(); + while let Some(Ok((request, mut reply))) = connection.accept().await { + let status = if request.method() == http::Method::GET { + get_status + } else { + post_status + }; + let response = http::Response::builder().status(status).body(()).unwrap(); + let mut send = reply.send_response(response, false).unwrap(); + send.send_data( + bytes::Bytes::from_static(b"ready"), + mode == XHttpMode::PacketUp && request.method() != http::Method::GET, + ) + .unwrap(); + bodies.push(request.into_body()); + replies.push(send); + } + }); + let mut stream = client.connect(Box::new(io)).await.unwrap(); + let result = stream.read_exact(&mut [0; 5]).await; + assert_eq!( + result.is_ok(), + read_ok, + "{mode:?} GET={get_status} POST={post_status}" + ); + if read_ok { + let written = async { + stream.write_all(b"payload").await?; + stream.flush().await + } + .await; + assert_eq!(written.is_ok(), write_ok, "{mode:?} POST={post_status}"); + } + let _ = stream.shutdown().await; + client.stop().await; + peer.await.unwrap(); + } + }) + .await + .unwrap(); +} + +async fn observe_stream_request(options: serde_json::Value) -> http::Request<()> { + tokio::time::timeout(Duration::from_secs(2), async { + let document = serde_json::json!({"socks-port":1080,"proxies":[{ + "name":"edge","type":"vless","server":"example.com","port":443, + "uuid":"07070707-0707-0707-0707-070707070707","network":"xhttp","tls":true, + "xhttp-opts":options + }],"rules":["MATCH,edge"]}); + let parsed = Config::parse_yaml(&serde_json::to_vec(&document).unwrap()).unwrap(); + let ProxyProtocol::Vless(node) = &parsed.proxies[0].protocol else { + unreachable!() + }; + let mut config = + XHttpConfig::new("example.com", "/x?original=yes", XHttpMode::StreamOne).unwrap(); + config.headers = node.xhttp().unwrap().headers.clone(); + config.request = node.xhttp().unwrap().request.clone(); + let client = XHttpClient::new(config); + let (io, remote) = tokio::io::duplex(16384); + let (observed, observation) = tokio::sync::oneshot::channel(); + let peer = tokio::spawn(async move { + let mut connection = h2::server::handshake(remote).await.unwrap(); + let (request, mut respond) = connection.accept().await.unwrap().unwrap(); + observed.send(request.map(|_| ())).unwrap(); + let mut sender = respond + .send_response(http::Response::new(()), false) + .unwrap(); + sender + .send_data(bytes::Bytes::from_static(b"ready"), false) + .unwrap(); + while connection.accept().await.is_some() {} + }); + let mut stream = client.connect(Box::new(io)).await.unwrap(); + let mut ready = [0; 5]; + stream.read_exact(&mut ready).await.unwrap(); + let observed = observation.await.unwrap(); + stream.shutdown().await.unwrap(); + drop(stream); + peer.await.unwrap(); + observed + }) + .await + .unwrap() +} + +#[tokio::test] +async fn streaming_content_type_can_be_disabled_without_disabling_padding() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "streaming_content_type_can_be_disabled_without_disabling_padding", + ); + let request = + observe_stream_request(serde_json::json!({"mode":"stream-one","no-grpc-header":true})) + .await; + assert!(!request.headers().contains_key("content-type")); + assert!( + request.headers()["referer"] + .to_str() + .unwrap() + .contains("x_padding=") + ); +} + +#[tokio::test] +async fn custom_semantic_headers_are_allowed_when_no_generated_field_overwrites_them() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "custom_semantic_headers_are_allowed_when_no_generated_field_overwrites_them", + ); + let request = observe_stream_request(serde_json::json!({ + "mode":"stream-one", "no-grpc-header":true, + "headers":{"Content-Type":"application/octet-stream","Referer":"https://example.com/custom"}, + "x-padding-obfs-mode":true,"x-padding-placement":"query","x-padding-key":"pad" + })).await; + assert_eq!( + request.headers()["content-type"], + "application/octet-stream" + ); + assert_eq!(request.headers()["referer"], "https://example.com/custom"); +} + +#[tokio::test] +async fn padding_uses_the_configured_http_location_and_encoding() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "padding_uses_the_configured_http_location_and_encoding", + ); + for placement in ["queryInHeader", "header", "query", "cookie"] { + for method in ["repeat-x", "tokenish"] { + let mut options = serde_json::json!({"mode":"stream-one", "x-padding-obfs-mode":true, + "x-padding-placement":placement, "x-padding-method":method, "x-padding-bytes":"100"}); + if placement != "header" { + options["x-padding-key"] = "pad".into(); + } + if matches!(placement, "header" | "queryInHeader") { + options["x-padding-header"] = "X-Pad".into(); + } + let request = observe_stream_request(options).await; + assert!(!request.headers().contains_key("referer")); + let padding = match placement { + "header" => request.headers()["x-pad"].to_str().unwrap().to_string(), + "queryInHeader" => url::Url::parse(request.headers()["x-pad"].to_str().unwrap()) + .unwrap() + .query_pairs() + .find(|(name, _)| name == "pad") + .unwrap() + .1 + .into_owned(), + "query" => url::Url::parse(&request.uri().to_string()) + .unwrap() + .query_pairs() + .find(|(name, _)| name == "pad") + .unwrap() + .1 + .into_owned(), + "cookie" => request.headers()["cookie"] + .to_str() + .unwrap() + .strip_prefix("pad=") + .unwrap() + .into(), + _ => unreachable!(), + }; + assert!(request.uri().query().unwrap().contains("original=yes")); + if method == "repeat-x" { + assert_eq!(padding, "X".repeat(100)); + } else { + assert!(padding.bytes().all(|byte| byte.is_ascii_alphanumeric())); + assert!((100..=160).contains(&padding.len())); + assert_ne!(padding, "X".repeat(100)); + } + } + } +} + +async fn observe_packet( + options: serde_json::Value, + payload: &[u8], +) -> Vec<(http::Request<()>, Vec)> { + tokio::time::timeout(Duration::from_secs(3), async { + let doc = serde_json::json!({"socks-port":1080,"proxies":[{ + "name":"edge","type":"vless","server":"example.com","port":443, + "uuid":"07070707-0707-0707-0707-070707070707","network":"xhttp","tls":true, + "xhttp-opts":options}],"rules":["MATCH,edge"]}); + let config = Config::parse_yaml(&serde_json::to_vec(&doc).unwrap()).unwrap(); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + unreachable!() + }; + let mut config = + XHttpConfig::new("example.com", "/x?original=yes", XHttpMode::PacketUp).unwrap(); + config.headers = node.xhttp().unwrap().headers.clone(); + config.request = node.xhttp().unwrap().request.clone(); + let client = XHttpClient::new(config); + let (io, remote) = tokio::io::duplex(16384); + let (sent, mut observations) = tokio::sync::mpsc::channel(8); + let peer = tokio::spawn(async move { + let mut connection = h2::server::handshake(remote).await.unwrap(); + let tasks = tokio_util::task::TaskTracker::new(); + let mut downloads = Vec::new(); + while let Some(Ok((request, mut respond))) = connection.accept().await { + let sent = sent.clone(); + if request.method() == http::Method::GET { + sent.send((request.map(|_| ()), Vec::new())).await.unwrap(); + let mut send = respond + .send_response(http::Response::new(()), false) + .unwrap(); + send.send_data(bytes::Bytes::from_static(b"ready"), false) + .unwrap(); + downloads.push(send); + } else { + tasks.spawn(async move { + let (parts, mut body) = request.into_parts(); + let mut bytes = Vec::new(); + while let Some(Ok(data)) = body.data().await { + bytes.extend_from_slice(&data); + body.flow_control().release_capacity(data.len()).unwrap(); + } + sent.send((http::Request::from_parts(parts, ()), bytes)) + .await + .unwrap(); + respond + .send_response(http::Response::new(()), true) + .unwrap(); + }); + } + } + tasks.close(); + tasks.wait().await; + }); + let mut stream = client.connect(Box::new(io)).await.unwrap(); + let mut ready = [0; 5]; + stream.read_exact(&mut ready).await.unwrap(); + stream.write_all(payload).await.unwrap(); + stream.shutdown().await.unwrap(); + drop(stream); + peer.await.unwrap(); + let mut result = Vec::new(); + while let Some(observed) = observations.recv().await { + result.push(observed); + } + result + }) + .await + .unwrap() +} + +#[tokio::test] +async fn packet_metadata_and_upload_methods_are_sent_in_each_supported_location() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "packet_metadata_and_upload_methods_are_sent_in_each_supported_location", + ); + for (placement, method) in [ + ("path", "POST"), + ("query", "PUT"), + ("header", "PATCH"), + ("cookie", "DELETE"), + ] { + let mut options = serde_json::json!({"mode":"packet-up","session-placement":placement, + "seq-placement":placement,"session-table":"number","session-length":"10","uplink-http-method":method}); + if placement != "path" { + options["session-key"] = "sid".into(); + options["seq-key"] = "seq".into(); + } + let requests = observe_packet(options, b"data").await; + let download = requests + .iter() + .find(|(r, _)| r.method() == http::Method::GET) + .unwrap(); + let upload = requests.iter().find(|(r, _)| r.method() == method).unwrap(); + let metadata = |request: &http::Request<()>, key: &str| -> Option { + match placement { + "path" => request + .uri() + .path() + .strip_prefix("/x/") + .unwrap() + .split('/') + .nth(if key == "sid" { 0 } else { 1 }) + .map(str::to_owned), + "query" => url::Url::parse(&request.uri().to_string()) + .unwrap() + .query_pairs() + .find(|(k, _)| k == key) + .map(|(_, v)| v.into_owned()), + "header" => request + .headers() + .get(key) + .map(|v| v.to_str().unwrap().to_owned()), + "cookie" => request.headers()["cookie"] + .to_str() + .unwrap() + .split("; ") + .find_map(|pair| pair.strip_prefix(&format!("{key}=")).map(str::to_owned)), + _ => unreachable!(), + } + }; + let id = metadata(&upload.0, "sid").unwrap(); + assert_eq!(id.len(), 10); + assert!(id.bytes().all(|b| b.is_ascii_digit())); + assert_eq!(metadata(&download.0, "sid"), Some(id)); + assert_eq!(metadata(&upload.0, "seq"), Some("0".into())); + assert_eq!(metadata(&download.0, "seq"), None); + assert_eq!(upload.1, b"data"); + } +} + +#[tokio::test] +async fn packet_upload_can_move_payload_into_bounded_header_or_cookie_chunks() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "packet_upload_can_move_payload_into_bounded_header_or_cookie_chunks", + ); + use base64::Engine as _; + for placement in ["body", "auto", "header", "cookie"] { + let mut options = serde_json::json!({"mode":"packet-up","uplink-data-placement":placement, + "sc-max-each-post-bytes":"128","sc-min-posts-interval-ms":"1"}); + if matches!(placement, "header" | "cookie") { + options["uplink-data-key"] = "data".into(); + options["uplink-chunk-size"] = "64".into(); + } + let payload: Vec = (0..250).map(|v| v as u8).collect(); + let requests = observe_packet(options, &payload).await; + let mut actual = Vec::new(); + let mut packets = 0; + for (request, body) in requests + .iter() + .filter(|(r, _)| r.method() != http::Method::GET) + { + packets += 1; + if matches!(placement, "body" | "auto") { + actual.extend_from_slice(body); + continue; + } + assert!(body.is_empty()); + let mut encoded = String::new(); + for index in 0..10 { + let chunk = if placement == "header" { + request + .headers() + .get(format!("data-{index}")) + .map(|v| v.to_str().unwrap()) + } else { + request.headers()["cookie"] + .to_str() + .unwrap() + .split("; ") + .find_map(|v| v.strip_prefix(&format!("data_{index}="))) + }; + let Some(chunk) = chunk else { + break; + }; + assert!(chunk.len() <= 64); + encoded.push_str(chunk); + } + actual.extend( + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(encoded) + .unwrap(), + ); + } + assert_eq!(packets, 2); + assert_eq!(actual, payload); + } +} + +#[tokio::test] +async fn http1_stream_one_is_chunked_duplex_and_shutdown_closes_both_directions() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "http1_stream_one_is_chunked_duplex_and_shutdown_closes_both_directions", + ); + tokio::time::timeout(Duration::from_secs(2), async { + let (io, mut peer) = tokio::io::duplex(4096); + let peer = tokio::spawn(async move { + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + head.push(peer.read_u8().await.unwrap()); + assert!(head.len() < 16384); + } + let head = String::from_utf8(head).unwrap().to_ascii_lowercase(); + assert!(head.starts_with("post /x/ http/1.1\r\n")); + assert!(head.contains("transfer-encoding: chunked\r\n")); + assert!(head.contains("host: example.com\r\n")); + let mut line = Vec::new(); + while !line.ends_with(b"\r\n") { + line.push(peer.read_u8().await.unwrap()); + } + assert_eq!( + usize::from_str_radix(std::str::from_utf8(&line[..line.len() - 2]).unwrap(), 16) + .unwrap(), + 7 + ); + let mut body = [0; 9]; + peer.read_exact(&mut body).await.unwrap(); + assert_eq!(&body, b"payload\r\n"); + peer.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nreply\r\n") + .await + .unwrap(); + let mut rest = Vec::new(); + peer.read_to_end(&mut rest).await.unwrap(); + assert!(peer.write_all(b"5\r\nextra\r\n").await.is_err()); + }); + let mut config = XHttpConfig::new("example.com", "/x", XHttpMode::StreamOne).unwrap(); + config.http_version = vcore::config::XHttpVersion::Http1; + let client = XHttpClient::new(config); + let mut io = client.connect(Box::new(io)).await.unwrap(); + io.write_all(b"payload").await.unwrap(); + let mut reply = [0; 5]; + io.read_exact(&mut reply).await.unwrap(); + assert_eq!(&reply, b"reply"); + io.shutdown().await.unwrap(); + assert!(io.read(&mut [0; 1]).await.is_err()); + drop(io); + client.stop().await; + peer.await.unwrap(); + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn http1_packet_up_shares_a_session_across_two_connections() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "http1_packet_up_shares_a_session_across_two_connections", + ); + tokio::time::timeout(Duration::from_secs(2), async { + async fn head(peer: &mut tokio::io::DuplexStream) -> String { + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + head.push(peer.read_u8().await.unwrap()); + assert!(head.len() < 16384); + } + String::from_utf8(head).unwrap() + } + let (up, mut upload) = tokio::io::duplex(4096); + let (down, mut download) = tokio::io::duplex(4096); + let (path, observed) = tokio::sync::oneshot::channel(); + let download_task = tokio::spawn(async move { + let head = head(&mut download).await; + let url = head + .lines() + .next() + .unwrap() + .split(' ') + .nth(1) + .unwrap() + .to_owned(); + assert!(head.starts_with("GET ")); + path.send(url).unwrap(); + download + .write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nready\r\n") + .await + .unwrap(); + assert_eq!(download.read(&mut [0; 1]).await.unwrap(), 0); + }); + let upload_task = tokio::spawn(async move { + let session = observed.await.unwrap(); + for sequence in 0..2 { + let head = head(&mut upload).await; + assert!(head.starts_with(&format!("POST {session}/{sequence} HTTP/1.1\r\n"))); + assert!( + head.to_ascii_lowercase() + .contains("transfer-encoding: chunked") + ); + let mut bytes = Vec::new(); + loop { + let mut line = Vec::new(); + while !line.ends_with(b"\r\n") { + line.push(upload.read_u8().await.unwrap()); + } + let count = usize::from_str_radix( + std::str::from_utf8(&line[..line.len() - 2]).unwrap(), + 16, + ) + .unwrap(); + let mut chunk = vec![0; count + 2]; + upload.read_exact(&mut chunk).await.unwrap(); + assert_eq!(&chunk[count..], b"\r\n"); + if count == 0 { + break; + } + bytes.extend_from_slice(&chunk[..count]); + } + assert_eq!(&bytes, b"packet"); + upload + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + .await + .unwrap(); + } + assert_eq!(upload.read(&mut [0; 1]).await.unwrap(), 0); + }); + let mut config = XHttpConfig::new("example.com", "/x", XHttpMode::PacketUp).unwrap(); + config.http_version = vcore::config::XHttpVersion::Http1; + let client = XHttpClient::new(config); + let mut io = client + .connect_with_download(Box::new(up), &client, Box::new(down)) + .await + .unwrap(); + let mut ready = [0; 5]; + io.read_exact(&mut ready).await.unwrap(); + io.write_all(b"packet").await.unwrap(); + io.flush().await.unwrap(); + io.write_all(b"packet").await.unwrap(); + io.shutdown().await.unwrap(); + drop(io); + client.stop().await; + upload_task.await.unwrap(); + download_task.await.unwrap(); + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn http1_response_body_can_arrive_after_the_response_headers() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "http1_response_body_can_arrive_after_the_response_headers", + ); + tokio::time::timeout(Duration::from_secs(2), async { + let (io, mut peer) = tokio::io::duplex(4096); + let peer = tokio::spawn(async move { + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + head.push(peer.read_u8().await.unwrap()); + } + peer.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n") + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(20)).await; + peer.write_all(b"5\r\nhello\r\n").await.unwrap(); + let mut rest = Vec::new(); + peer.read_to_end(&mut rest).await.unwrap(); + }); + let mut config = XHttpConfig::new("example.com", "/x", XHttpMode::StreamOne).unwrap(); + config.http_version = vcore::config::XHttpVersion::Http1; + let client = XHttpClient::new(config); + let mut io = client.connect(Box::new(io)).await.unwrap(); + io.write_all(b"payload").await.unwrap(); + let mut reply = [0; 5]; + io.read_exact(&mut reply).await.unwrap(); + assert_eq!(&reply, b"hello"); + io.shutdown().await.unwrap(); + client.stop().await; + peer.await.unwrap(); + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn custom_request_headers_reach_the_http_peer_without_changing_body() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "custom_request_headers_reach_the_http_peer_without_changing_body", + ); + tokio::time::timeout(Duration::from_secs(2), async { + let document = serde_json::json!({"socks-port":1080,"proxies":[{ + "name":"edge","type":"vless","server":"example.com","port":443, + "uuid":"07070707-0707-0707-0707-070707070707","network":"xhttp","tls":true, + "xhttp-opts":{"mode":"stream-one","headers":{"X-Custom":"private-value"}} + }],"rules":["MATCH,edge"]}); + let parsed = Config::parse_yaml(&serde_json::to_vec(&document).unwrap()).unwrap(); + let ProxyProtocol::Vless(node) = &parsed.proxies[0].protocol else { + unreachable!() + }; + let mut config = XHttpConfig::new("example.com", "/x", XHttpMode::StreamOne).unwrap(); + config.headers = node.xhttp().unwrap().headers.clone(); + let client = XHttpClient::new(config); + assert!(!format!("{client:?}").contains("private-value")); + let (io, remote) = tokio::io::duplex(16384); + let (observed, observation) = tokio::sync::oneshot::channel(); + let peer = tokio::spawn(async move { + let mut connection = h2::server::handshake(remote).await.unwrap(); + let (request, mut respond) = connection.accept().await.unwrap().unwrap(); + assert_eq!(request.headers()["x-custom"], "private-value"); + assert_eq!(request.headers()["content-type"], "application/grpc"); + assert!( + request.headers()["referer"] + .to_str() + .unwrap() + .contains("x_padding=") + ); + let mut received = request.into_body(); + let mut sender = respond + .send_response(http::Response::new(()), false) + .unwrap(); + let task = tokio::spawn(async move { + let data = received.data().await.unwrap().unwrap(); + assert_eq!(&data[..], b"payload"); + sender + .send_data(bytes::Bytes::from_static(b"reply"), true) + .unwrap(); + observed.send(()).unwrap(); + }); + while connection.accept().await.is_some() {} + task.await.unwrap(); + }); + let mut stream = client.connect(Box::new(io)).await.unwrap(); + stream.write_all(b"payload").await.unwrap(); + let mut reply = [0; 5]; + stream.read_exact(&mut reply).await.unwrap(); + assert_eq!(&reply, b"reply"); + observation.await.unwrap(); + stream.shutdown().await.unwrap(); + drop(stream); + peer.await.unwrap(); + }) + .await + .unwrap(); +} diff --git a/tests/xhttp_reuse.rs b/tests/xhttp_reuse.rs new file mode 100644 index 0000000..2fe002a --- /dev/null +++ b/tests/xhttp_reuse.rs @@ -0,0 +1,538 @@ +#![cfg(all(feature = "outbound-vless", feature = "interop-test"))] + +use std::{ + sync::atomic::{AtomicUsize, Ordering}, + time::Duration, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_util::task::TaskTracker; +use vcore::transport::xhttp::{XHttpClient, XHttpConfig, XHttpMode}; + +fn config(reuse: serde_json::Value) -> XHttpConfig { + let raw = serde_json::json!({"socks-port":1080,"proxies":[{"name":"peer","type":"vless","server":"example.com","port":443,"uuid":"07070707-0707-0707-0707-070707070707","network":"xhttp","tls":true,"xhttp-opts":{"mode":"stream-one","reuse-settings":reuse}}],"rules":["MATCH,peer"]}); + let raw = vcore::config::Config::parse_yaml(raw.to_string().as_bytes()).unwrap(); + let vcore::config::ProxyProtocol::Vless(node) = &raw.proxies[0].protocol else { + unreachable!() + }; + let mut config = XHttpConfig::new("example.com", "/x", XHttpMode::StreamOne).unwrap(); + config.reuse = node.xhttp().unwrap().reuse.clone(); + config +} + +async fn ready(stream: &mut vcore::dispatch::BoxStream) { + let mut greeting = [0; 5]; + stream.read_exact(&mut greeting).await.unwrap(); + assert_eq!(&greeting, b"ready"); +} + +async fn peer( + calls: &AtomicUsize, + tasks: &TaskTracker, +) -> std::io::Result { + calls.fetch_add(1, Ordering::Relaxed); + let (io, remote) = tokio::io::duplex(16384); + tasks.spawn(async move { + let mut connection = h2::server::handshake(remote).await.unwrap(); + let exchanges = TaskTracker::new(); + let mut downloads = Vec::new(); + while let Some(Ok((request, mut reply))) = connection.accept().await { + if request.method() == http::Method::GET { + let mut response = reply.send_response(http::Response::new(()), false).unwrap(); + response + .send_data(bytes::Bytes::from_static(b"ready"), false) + .unwrap(); + downloads.push(response); + continue; + } + exchanges.spawn(async move { + let mut body = request.into_body(); + let mut response = reply.send_response(http::Response::new(()), false).unwrap(); + response + .send_data(bytes::Bytes::from_static(b"ready"), false) + .unwrap(); + while let Some(Ok(data)) = body.data().await { + if body.flow_control().release_capacity(data.len()).is_err() + || response.send_data(data, false).is_err() + { + break; + } + } + }); + } + exchanges.close(); + exchanges.wait().await; + }); + Ok(Box::new(io)) +} + +#[tokio::test] +async fn enabled_reuse_shares_physical_h2_without_one_close_killing_its_sibling() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "enabled_reuse_shares_physical_h2_without_one_close_killing_its_sibling", + ); + tokio::time::timeout(Duration::from_secs(3), async { + let probe = vcore::resources::observation::ResourceProbe::default(); + probe + .scope(async { + let tasks = TaskTracker::new(); + let calls = AtomicUsize::new(0); + let mut config = + XHttpConfig::new("example.com", "/x", XHttpMode::StreamOne).unwrap(); + config.reuse = Some(Default::default()); + let client = XHttpClient::new(config); + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + let mut first = client + .open(deadline, || peer(&calls, &tasks)) + .await + .unwrap(); + let mut second = client + .open(deadline, || peer(&calls, &tasks)) + .await + .unwrap(); + for stream in [&mut first, &mut second] { + let mut greeting = [0; 5]; + stream.read_exact(&mut greeting).await.unwrap(); + assert_eq!(&greeting, b"ready"); + } + assert_eq!(calls.load(Ordering::Relaxed), 1); + first.shutdown().await.unwrap(); + second.write_all(b"alive").await.unwrap(); + let mut reply = [0; 5]; + second.read_exact(&mut reply).await.unwrap(); + assert_eq!(&reply, b"alive"); + client.stop().await; + assert!(probe.snapshot().is_idle(), "{:?}", probe.snapshot()); + assert!(second.read(&mut [0; 1]).await.is_err()); + tasks.close(); + tasks.wait().await; + }) + .await; + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn reuse_thresholds_expand_and_retire_without_closing_active_sessions() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "reuse_thresholds_expand_and_retire_without_closing_active_sessions", + ); + use serde_json::json; + tokio::time::timeout(Duration::from_secs(5), async { + for (settings, counts) in [ + (json!({}), [1, 1, 1]), + (json!({"max-connections":"2"}), [1, 2, 2]), + ( + json!({"max-connections":"1","max-concurrency":"1"}), + [1, 2, 3], + ), + (json!({"h-max-request-times":"1"}), [1, 2, 3]), + (json!({"c-max-reuse-times":"1"}), [1, 1, 2]), + ] { + let tasks = TaskTracker::new(); + let calls = AtomicUsize::new(0); + let client = XHttpClient::new(config(settings.clone())); + let mut streams = Vec::new(); + for count in counts { + let mut stream = client + .open(tokio::time::Instant::now() + Duration::from_secs(1), || { + peer(&calls, &tasks) + }) + .await + .unwrap(); + ready(&mut stream).await; + assert_eq!(calls.load(Ordering::Relaxed), count, "{settings}"); + streams.push(stream); + } + for mut stream in streams { + stream.write_all(b"live").await.unwrap(); + let mut reply = [0; 4]; + stream.read_exact(&mut reply).await.unwrap(); + assert_eq!(&reply, b"live"); + stream.shutdown().await.unwrap(); + } + client.stop().await; + tasks.close(); + tasks.wait().await; + } + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn expired_transport_is_not_assigned_again_while_old_session_stays_live() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "expired_transport_is_not_assigned_again_while_old_session_stays_live", + ); + tokio::time::timeout(Duration::from_secs(3), async { + let tasks = TaskTracker::new(); + let calls = AtomicUsize::new(0); + let client = XHttpClient::new(config(serde_json::json!({"h-max-reusable-secs":"1"}))); + let mut first = client + .open(tokio::time::Instant::now() + Duration::from_secs(1), || { + peer(&calls, &tasks) + }) + .await + .unwrap(); + ready(&mut first).await; + tokio::time::sleep(Duration::from_millis(1050)).await; + let mut second = client + .open(tokio::time::Instant::now() + Duration::from_secs(1), || { + peer(&calls, &tasks) + }) + .await + .unwrap(); + ready(&mut second).await; + assert_eq!(calls.load(Ordering::Relaxed), 2); + first.write_all(b"a").await.unwrap(); + assert_eq!(first.read_u8().await.unwrap(), b'a'); + first.shutdown().await.unwrap(); + second.shutdown().await.unwrap(); + client.stop().await; + tasks.close(); + tasks.wait().await; + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn pooled_packet_posts_do_not_each_consume_a_transport_lease() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "pooled_packet_posts_do_not_each_consume_a_transport_lease", + ); + tokio::time::timeout(Duration::from_secs(3), async { + let tasks = TaskTracker::new(); + let calls = AtomicUsize::new(0); + let posts = std::sync::Arc::new(AtomicUsize::new(0)); + let factory = || { + calls.fetch_add(1, Ordering::Relaxed); + let (io, remote) = tokio::io::duplex(16384); + let posts = posts.clone(); + tasks.spawn(async move { + let mut connection = h2::server::handshake(remote).await.unwrap(); + let requests = TaskTracker::new(); + let mut downloads = Vec::new(); + while let Some(Ok((request, mut respond))) = connection.accept().await { + if request.method() == http::Method::GET { + let mut body = respond + .send_response(http::Response::new(()), false) + .unwrap(); + body.send_data(bytes::Bytes::from_static(b"ready"), false) + .unwrap(); + downloads.push(body); + } else { + let posts = posts.clone(); + requests.spawn(async move { + let mut body = request.into_body(); + let mut total = 0; + while let Some(Ok(data)) = body.data().await { + total += data.len(); + body.flow_control().release_capacity(data.len()).unwrap(); + } + assert_eq!(total, 6); + posts.fetch_add(1, Ordering::Relaxed); + respond + .send_response(http::Response::new(()), true) + .unwrap(); + }); + } + } + requests.close(); + requests.wait().await; + }); + async { Ok(Box::new(io) as vcore::dispatch::BoxStream) } + }; + let mut options = config(serde_json::json!({"h-max-request-times":"1"})); + options.mode = XHttpMode::PacketUp; + let client = XHttpClient::new(options); + let mut stream = client + .open( + tokio::time::Instant::now() + Duration::from_secs(2), + factory, + ) + .await + .unwrap(); + ready(&mut stream).await; + for _ in 0..3 { + stream.write_all(b"packet").await.unwrap(); + stream.flush().await.unwrap(); + } + assert_eq!(posts.load(Ordering::Relaxed), 3); + assert_eq!(calls.load(Ordering::Relaxed), 1); + let mut second = client + .open( + tokio::time::Instant::now() + Duration::from_secs(1), + factory, + ) + .await + .unwrap(); + ready(&mut second).await; + assert_eq!(calls.load(Ordering::Relaxed), 2); + stream.shutdown().await.unwrap(); + second.shutdown().await.unwrap(); + client.stop().await; + tasks.close(); + tasks.wait().await; + }) + .await + .unwrap(); +} + +#[tokio::test(start_paused = true)] +async fn h2_keepalive_uses_default_explicit_and_disabled_idle_periods() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "h2_keepalive_uses_default_explicit_and_disabled_idle_periods", + ); + for (seconds, expected) in [(0, Some(45)), (2, Some(2)), (-1, None)] { + let (io, mut remote) = tokio::io::duplex(16384); + let (sent, received) = tokio::sync::oneshot::channel(); + let peer = tokio::spawn(async move { + let mut preface = [0; 24]; + remote.read_exact(&mut preface).await.unwrap(); + assert_eq!(&preface, b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"); + remote + .write_all(&[0, 0, 0, 4, 0, 0, 0, 0, 0]) + .await + .unwrap(); + let mut sent = Some(sent); + loop { + let mut head = [0; 9]; + if remote.read_exact(&mut head).await.is_err() { + break; + } + let length = u32::from_be_bytes([0, head[0], head[1], head[2]]) as usize; + assert!(length <= 16384); + let mut payload = vec![0; length]; + remote.read_exact(&mut payload).await.unwrap(); + if head[3] == 4 && head[4] & 1 == 0 { + remote + .write_all(&[0, 0, 0, 4, 1, 0, 0, 0, 0]) + .await + .unwrap(); + } + if head[3] == 6 && head[4] & 1 == 0 { + assert_eq!(length, 8); + if let Some(sent) = sent.take() { + let _ = sent.send(tokio::time::Instant::now()); + } + head[4] = 1; + remote.write_all(&head).await.unwrap(); + remote.write_all(&payload).await.unwrap(); + } + } + }); + let client = XHttpClient::new(config(serde_json::json!({"h-keep-alive-period":seconds}))); + let start = tokio::time::Instant::now(); + let _stream = client.connect(Box::new(io)).await.unwrap(); + let observed = tokio::time::timeout(Duration::from_secs(51), received).await; + if let Some(seconds) = expected { + let elapsed = observed.expect("idle PING absent").unwrap() - start; + assert_eq!(elapsed, Duration::from_secs(seconds)); + } else { + assert!(observed.is_err(), "disabled keepalive sent a PING"); + } + client.stop().await; + peer.await.unwrap(); + } +} + +#[tokio::test] +async fn download_uses_its_own_reuse_counters_and_keeps_shared_sessions_alive() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "download_uses_its_own_reuse_counters_and_keeps_shared_sessions_alive", + ); + tokio::time::timeout(Duration::from_secs(3), async { + let tasks = TaskTracker::new(); + let uploads = AtomicUsize::new(0); + let downloads = AtomicUsize::new(0); + let mut options = config(serde_json::json!({"h-max-request-times":"1"})); + options.mode = XHttpMode::StreamUp; + let client = XHttpClient::new(options); + let download = XHttpClient::new(config(serde_json::json!({}))); + let mut streams = Vec::new(); + for _ in 0..3 { + let mut stream = client + .open_with_download( + tokio::time::Instant::now() + Duration::from_secs(1), + &download, + || peer(&uploads, &tasks), + || peer(&downloads, &tasks), + ) + .await + .unwrap(); + ready(&mut stream).await; + streams.push(stream); + } + assert_eq!(uploads.load(Ordering::Relaxed), 3); + assert_eq!(downloads.load(Ordering::Relaxed), 1); + for mut stream in streams { + stream.shutdown().await.unwrap(); + } + client.stop().await; + download.stop().await; + tasks.close(); + tasks.wait().await; + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn h1_packet_upload_reuses_its_idle_connection_but_reopens_the_cancelled_get() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "h1_packet_upload_reuses_its_idle_connection_but_reopens_the_cancelled_get", + ); + tokio::time::timeout(Duration::from_secs(3), async { + let tasks = TaskTracker::new(); + let calls = AtomicUsize::new(0); + let factory = || { + calls.fetch_add(1, Ordering::Relaxed); + let (io, mut peer) = tokio::io::duplex(16384); + tasks.spawn(async move { + loop { + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + let Ok(byte) = peer.read_u8().await else { + return; + }; + head.push(byte); + assert!(head.len() < 16384); + } + if head.starts_with(b"GET ") { + peer.write_all( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nready\r\n", + ) + .await + .unwrap(); + assert_eq!(peer.read(&mut [0; 1]).await.unwrap(), 0); + return; + } + let mut bytes = Vec::new(); + loop { + let mut line = Vec::new(); + while !line.ends_with(b"\r\n") { + line.push(peer.read_u8().await.unwrap()); + } + let count = usize::from_str_radix( + std::str::from_utf8(&line[..line.len() - 2]).unwrap(), + 16, + ) + .unwrap(); + let mut chunk = vec![0; count + 2]; + peer.read_exact(&mut chunk).await.unwrap(); + assert_eq!(&chunk[count..], b"\r\n"); + if count == 0 { + break; + } + bytes.extend_from_slice(&chunk[..count]); + } + assert_eq!(&bytes, b"packet"); + peer.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + .await + .unwrap(); + } + }); + async { Ok(Box::new(io) as vcore::dispatch::BoxStream) } + }; + let mut options = config(serde_json::json!({})); + options.mode = XHttpMode::PacketUp; + options.http_version = vcore::config::XHttpVersion::Http1; + let client = XHttpClient::new(options); + for _ in 0..2 { + let mut stream = client + .open( + tokio::time::Instant::now() + Duration::from_secs(1), + factory, + ) + .await + .unwrap(); + ready(&mut stream).await; + stream.write_all(b"packet").await.unwrap(); + stream.shutdown().await.unwrap(); + } + assert_eq!(calls.load(Ordering::Relaxed), 3); + client.stop().await; + tasks.close(); + tasks.wait().await; + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn stopping_during_handshake_prevents_late_driver_admission() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "stopping_during_handshake_prevents_late_driver_admission", + ); + tokio::time::timeout(Duration::from_secs(2), async { + let client = + XHttpClient::new(XHttpConfig::new("example.com", "/x", XHttpMode::StreamOne).unwrap()); + let (io, mut remote) = tokio::io::duplex(1); + let connecting = client.connect(Box::new(io)); + tokio::pin!(connecting); + assert!( + tokio::time::timeout(Duration::from_millis(10), &mut connecting) + .await + .is_err() + ); + client.stop().await; + let peer = tokio::spawn(async move { + let mut preface = Vec::new(); + remote.read_to_end(&mut preface).await.unwrap(); + assert!(b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".starts_with(&preface)); + remote + }); + let result = connecting.await; + assert!( + result.is_err(), + "stopped client admitted a new logical stream" + ); + drop(peer.await.unwrap()); + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn stop_wakes_a_handshake_even_when_the_peer_never_responds() { + #[cfg(feature = "interop-test")] + let _case = vcore::resources::case_events::Case::new( + "XHTTP-UNIT", + "stop_wakes_a_handshake_even_when_the_peer_never_responds", + ); + let client = + XHttpClient::new(XHttpConfig::new("example.com", "/x", XHttpMode::StreamOne).unwrap()); + let (io, _remote) = tokio::io::duplex(1); + let connecting = client.connect(Box::new(io)); + tokio::pin!(connecting); + assert!( + tokio::time::timeout(Duration::from_millis(10), &mut connecting) + .await + .is_err() + ); + client.stop().await; + assert!( + tokio::time::timeout(Duration::from_millis(100), connecting) + .await + .expect("Stop left the handshake pending") + .is_err() + ); +} diff --git a/tests/xray_interop.rs b/tests/xray_interop.rs index 13590ca..1d60fe0 100644 --- a/tests/xray_interop.rs +++ b/tests/xray_interop.rs @@ -1,6 +1,6 @@ //! Opt-in interoperability coverage against an installed Xray-core binary. //! -//! Run with `bash tests/run_xray_interop.sh`. +//! Compile-only legacy fixture pending full container migration; see tests/README.md. //! The REALITY phase uses a local ECDSA-only TLS 1.3 camouflage target and all //! proxied echo targets are local, so the test does not depend on public nodes. @@ -43,6 +43,10 @@ fn sole_vless(config: &Config) -> &VlessOutboundConfig { ProxyProtocol::Socks5(_) => panic!("interop config must contain VLESS"), ProxyProtocol::AnyTls(_) => panic!("interop config must contain VLESS"), ProxyProtocol::Shadowsocks(_) => panic!("interop config must contain VLESS"), + ProxyProtocol::Trojan(_) => panic!("interop config must contain VLESS"), + ProxyProtocol::Vmess(_) | ProxyProtocol::Hysteria2(_) => { + panic!("interop config must contain VLESS") + } } } @@ -786,7 +790,7 @@ async fn run_interop() -> io::Result<()> { .map_err(|_| { io::Error::new( io::ErrorKind::InvalidInput, - "XRAY_INTEROP_ADDRESS is missing; run tests/run_xray_interop.sh", + "XRAY_INTEROP_ADDRESS is missing; legacy fixture; see tests/README.md", ) })? .parse::() @@ -794,7 +798,7 @@ async fn run_interop() -> io::Result<()> { let mode = env::var("XRAY_INTEROP_MODE").map_err(|_| { io::Error::new( io::ErrorKind::InvalidInput, - "XRAY_INTEROP_MODE is missing; run tests/run_xray_interop.sh", + "XRAY_INTEROP_MODE is missing; legacy fixture; see tests/README.md", ) })?; if !matches!(mode.as_str(), "auto" | "packet-up" | "stream-one") { @@ -822,7 +826,7 @@ async fn run_interop() -> io::Result<()> { let security = env::var("XRAY_INTEROP_SECURITY").map_err(|_| { io::Error::new( io::ErrorKind::InvalidInput, - "XRAY_INTEROP_SECURITY is missing; run tests/run_xray_interop.sh", + "XRAY_INTEROP_SECURITY is missing; legacy fixture; see tests/README.md", ) })?;