mirror of
https://github.com/YuanDevTeam/Vole.git
synced 2026-11-07 00:05:27 +00:00
feat: add client protocols, TLS capabilities, and platform validation (#1)
* chore: download latest official mihomo for interop tests * test: capture N0 baseline and protocol feasibility probes * docs: record N0 hybrid REALITY fork verification * test: verify N0 controlled Hysteria2 auth and streams Add an isolated Quinn adapter over existing DIRECT and SOCKS5 datagrams, bounded queues, protect rejection probes, and official latest peer fixtures. Record passing auth/TCP checks and the native Hysteria half-close failure separately. N0 remains incomplete; production sources and dependency locks are unchanged. * test: validate N0 native QUIC hopping and XHTTP H3 Keep the probes isolated from production. Verify one-state Hysteria hopping, protected rebind rejection, Xray H3 entry and existing HY2 regressions. Set only the owned Linux peer MTU to avoid Initial-packet EMSGSIZE; retain native half-close failures as separate gates. * fix: align XHTTP shutdown with Mihomo connection close * test: verify N0 supplied-stream transport interfaces Add isolated TLS, ordinary WebSocket and gRPC probes with owned I/O, bounded buffers and cancellation barriers. Match Mihomo close behavior: preserve TLS/WS reads, distinguish clean WS underlay EOF from truncation, and close both gRPC directions. Use separate official SOCKS5 peers to avoid fixture loop detection. Record 12 Debug/Release tests, 42 official Mihomo checks, dependency audits and Apple/Android compile checks. Keep production dependencies, third-party sources and remaining N0 gates unchanged. * fix: upgrade h2 and retain completed responses after reset * chore: use the current OneXray rustls fork origin * chore: adopt published rustls 0.23.45 and official TLS binding * chore: refresh N1 foundation and spike dependencies * feat: validate next-protocol field and combination catalogs * feat: share immutable TLS policies and bounded close semantics * feat: add shared stream transports and decouple XUDP framing * feat: add controlled datagram foundations and N1 acceptance harness * chore: finalize N1 with verified x25519 dependency * chore: publish N1 against the rustls release branch * feat: add bounded Trojan request and datagram codecs * feat: connect Trojan TLS TCP and UDP through the runtime * feat: support Trojan WebSocket and duplex gRPC transports * feat: enable Trojan and complete N2 consumer acceptance * feat(vmess): add bounded AEAD header and body codecs * fix(vmess): align close acceptance with Mihomo transports * feat(vmess): add UDP codecs and isolated peer comparisons * feat(vmess): complete public runtime and N3 acceptance * docs: bind N3 acceptance and refresh validation guidance * feat(vless): complete N4 transports, Vision and UDP support Extract VLESS assembly, add plain/TLS stream transports, HTTPUpgrade, node-owned gRPC pooling, mTLS and classic REALITY, and implement Vision through public TLS record boundaries. Preserve XHTTP and the XUDP default while adding raw and packetaddr. Complete 145 required groups and 39 field rows against isolated latest official peers, with 160 lifecycle/resource rounds, independent coverage and Apple/Android release builds. Preserve the three explicit WS/REALITY layered close comparisons and historical failures in the acceptance report. No third-party or dependency-lock changes. * feat(vless): complete N5 XHTTP and sing-mux support Add H1/H2/H3 request fields, independent download security and pools, bounded packet-up batching, and h2mux/smux/yamux over controlled transports. Keep Mihomo-aligned close and path semantics, node-owned shutdown barriers, and isolated official-peer fixtures including the approved Caddy mTLS gateway. Validate 416 required groups, 57 fields, 112 two-leg security checks, 240 lifecycle/resource cycles, independent coverage, and Apple/Android release builds. Record native-peer gaps and leave N6-N10, devices, CI, and release gates separate. * feat: integrate boring TLS profiles and retire rustls fork * test(tls): freeze selected fingerprint reference baseline * docs(tls): record selected-profile library acceptance * feat(tls): integrate selected client fingerprints and REALITY profiles * feat(tls): complete selected fingerprint CF5 acceptance * feat(hysteria2): complete N6 client support and acceptance Implement Hysteria2 over official Quinn/h3/rustls and shared controlled IO, including TCP/UDP, bandwidth pacing, Salamander, port hopping, upstream snapshots and synchronous Stop. Keep old-path receives active for the full bounded handover window, preserve independent reader wakeups, and require the exact nested TCP evidence assertions. Validate one frozen N6 run: 36 required groups, 20 field rows, native Mihomo/Hysteria peers, H3 regression, 40 lifecycle rounds and Apple/Android production builds. Record independent coverage, artifact hashes and native-peer limitations. * test(tls): record N7 hybrid REALITY prerequisite evidence Preserve the locked classic-only API failure and distinguish the locally accepted boring hybrid extension from pending VCore S03/D16 integration. Record wrapper feasibility and publication boundaries without changing production configuration or dependencies. * feat(vless): support explicitly required hybrid REALITY * feat(crypto): wrap official BLAKE3 binary-context derivation * feat(vless): implement authenticated Encryption wire transport * feat(vless): enable authenticated Encryption across public transports * build(tls): pin the authenticated ShadowTLS ClientHello hook * feat(vless): add authenticated JLS transport security Pin the separately approved boring JLS revision and expose strict main/download credentials under schema 24. Preserve native CertificateVerify and Finished, cancellation, and controlled IO ownership. Accept the JLS field slice with 122 isolated cases, 80 lifecycle/resource cycles, and Apple/Android release libraries; keep broader N7 gates separate. * docs(n7): reconcile current JLS acceptance progress * chore(vless): retire Restls while retaining JLS * feat(vless): complete N7 static ECH and security combinations Add static ECH for VLESS main and XHTTP download legs using existing TLS backends and official Rust HPKE. Keep Invoke v5 and advance the internal schema to revision 27. Accept the narrowed N7 scope with 43 required groups, 11 fields, 301 isolated native cases, 240 lifecycle/resource rounds, and Apple/Android release builds. Record frozen inputs, 470 container cleanups, explicit upstream comparison gaps, and prior failed runs in docs/acceptance/next-protocols/N7.md. * chore(protocols): retire WireGuard scope and foundations * test: complete N9 protocol integration and resource gates * build: add N10 production artifact evidence gates * fix: restore Windows snapshot encoding and fail-closed delivery CI * fix: use native ARM64 LLVM assembly and scoped delivery CI * build: keep delivery lockfile bytes stable across platforms * fix: preserve production guard lifecycle and isolate CI feature checks * build: adopt released boring and registry shadowsocks * ci: use release-oriented workflow names * docs: consolidate contracts and streamline test tooling Merge current protocol and platform contracts, remove milestone reports and obsolete spikes, and share core checks between local development and CI. Preserve all 884 executable case definitions and unique legacy assertions. Validation: core Debug/Release, feature checks, Clippy, netstack, offline script tests, formatting and documentation links passed locally. Container interop smoke attempts failed during official peer downloads; business paths remain NOT RUN. * test: replace milestone names with capability-based validation Rename protocol and fingerprint suites, fixtures, and evidence identifiers by capability. Preserve acceptance cases and golden wire data while documenting reference, wire, and interoperability evidence separately. * fix: address review findings and stabilize core CI Flush accepted gRPC/H2 data through the supplied IO before reset, and wait for pending smux writes before FIN. Sanitize AnyTLS peer failures, reject XHTTP userinfo, and remove stale Android delivery artifacts. Close container log capture even when inventory fails. Keep legacy host suites blocked before resource acquisition. Separate Shadowsocks adapter codec checks from upstream strict server acceptance. Lock the random empty-request zero-padding failure with deterministic negative and positive fixtures, without changing the dependency or production behavior. * test: isolate delivery fixture from Windows home lookup * test: preserve UDP socket semantics during peer teardown The memory peer treated its counterpart closing an mpsc channel as a local UDP BrokenPipe. That could terminate Quinn endpoint IO without waking a pending wait_idle, despite all four datagrams and CONNECTION_CLOSE having arrived. Keep a closed peer link silent and late sends best-effort, add a deterministic fixture regression, and preserve the existing data and shutdown deadlines. Debug/Release, Clippy, and 300 consecutive regression iterations pass. * fix: drain XHTTP uploads before releasing connection drivers * fix: enforce lazy setup deadlines and safe delivery paths Check the original deadline before accepting ready gRPC/H2 headers or sing-mux status, without limiting established reads. Reject symlinks and Windows reparse points in every delivery output path before filesystem mutations. Add deterministic memory and temporary-filesystem regressions. * test: join XHTTP H3 peer tasks during cleanup Quinn retains closed connections for three probe timeouts, so a delayed handshake can legitimately exceed the fixture's two-second wait_idle budget. Own, abort, and join the passive peer tasks after upload and client shutdown instead. Preserve upload integrity and client close bounds, and cover delayed handshakes in both streaming modes.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# The same lockfile bytes identify production artifacts on every build host.
|
||||
Cargo.lock text eol=lf
|
||||
@@ -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
|
||||
+25
-105
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
Generated
+885
-379
File diff suppressed because it is too large
Load Diff
+56
-16
@@ -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"
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
English · <a href="./readme/README.zh_CN.md">简体中文</a> · <a href="./readme/README.ru.md">Русский</a>
|
||||
</p>
|
||||
|
||||
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 <secret>
|
||||
|
||||
`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
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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<u8> = (0..16).map(|i| (i * 37 + 255) as u8).collect();
|
||||
let material: Vec<u8> = (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<u8> = (0..context_len).map(|i| (i * 37 + 255) as u8).collect();
|
||||
let material: Vec<u8> = (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<u8> = (0..length).map(|i| (i * 13 + 7) as u8).collect();
|
||||
assert_eq!(
|
||||
derive_key(context.as_bytes(), &material),
|
||||
blake3::derive_key(context, &material)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+202
@@ -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.
|
||||
+219
@@ -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.
|
||||
|
||||
+121
@@ -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.
|
||||
+651
@@ -0,0 +1,651 @@
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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<uint8_t> 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<uint8_t> 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;
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
#ifndef BLAKE3_H
|
||||
#define BLAKE3_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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 */
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "blake3_impl.h"
|
||||
|
||||
#if defined(_MSC_VER)
|
||||
#include <Windows.h>
|
||||
#endif
|
||||
|
||||
#if defined(IS_X86)
|
||||
#if defined(_MSC_VER)
|
||||
#include <intrin.h>
|
||||
#elif defined(__GNUC__)
|
||||
#include <immintrin.h>
|
||||
#else
|
||||
#undef IS_X86 /* Unimplemented! */
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if !defined(BLAKE3_ATOMICS)
|
||||
#if defined(__has_include)
|
||||
#if __has_include(<stdatomic.h>) && !defined(_MSC_VER)
|
||||
#define BLAKE3_ATOMICS 1
|
||||
#else
|
||||
#define BLAKE3_ATOMICS 0
|
||||
#endif /* __has_include(<stdatomic.h>) && !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;
|
||||
}
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
#ifndef BLAKE3_IMPL_H
|
||||
#define BLAKE3_IMPL_H
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
#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 <intrin.h>
|
||||
#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 */
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
#include "blake3_impl.h"
|
||||
#include <string.h>
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
+25
-39
@@ -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 只放跨任务约束与读取入口。
|
||||
|
||||
+49
-133
@@ -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、密钥、完整用户配置或私有目标。
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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 <file>`.
|
||||
- Read a ticket: `gh issue view <number> --repo OneVCore/VCore --comments`.
|
||||
- List tickets: `gh issue list --repo OneVCore/VCore --state open --json number,title,body,labels,comments`.
|
||||
- Comment: `gh issue comment <number> --repo OneVCore/VCore --body-file <file>`.
|
||||
- Apply/remove labels: `gh issue edit <number> --repo OneVCore/VCore --add-label "..."` / `--remove-label "..."`.
|
||||
- Close: `gh issue close <number> --repo OneVCore/VCore --comment "..."`.
|
||||
- Publish a request/spec: `gh issue create --repo OneXray/VCore --title "..." --body-file <file>`.
|
||||
- Read a ticket: `gh issue view <number> --repo OneXray/VCore --comments`.
|
||||
- List tickets: `gh issue list --repo OneXray/VCore --state open --json number,title,body,labels,comments`.
|
||||
- Comment: `gh issue comment <number> --repo OneXray/VCore --body-file <file>`.
|
||||
- Apply/remove labels: `gh issue edit <number> --repo OneXray/VCore --add-label "..."` / `--remove-label "..."`.
|
||||
- Close: `gh issue close <number> --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: #<number>` 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 <number> --repo OneVCore/VCore --add-assignee @me`.
|
||||
- Claim it with `gh issue edit <number> --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.
|
||||
|
||||
-111
@@ -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: "<certificate SHA-256, 64 hex digits>"
|
||||
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)。
|
||||
+179
-22
@@ -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>…;
|
||||
# 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: "<SHA-256 certificate pin, 64 hex digits>"
|
||||
# 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: <inline PEM> # 与 private-key 成对;禁止文件路径
|
||||
# private-key: <inline PEM>
|
||||
# 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: "<server-provided Base64 ECHConfigList>"
|
||||
|
||||
# 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: "<certificate SHA-256, 64 hex digits>"
|
||||
# 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
|
||||
|
||||
@@ -129,6 +129,8 @@ Content-Length: 26
|
||||
|
||||
`dialer-proxy` 与规则、DNS 共享同一组选择,但经共享连接器直接建链,不重新执行业务规则。上游组的 `DIRECT` 连接当前节点预解析的服务器,`REJECT` 使建链失败。单次建链对同一组只读取一次选择,SOCKS5 UDP 的控制连接和 relay 路径共享该快照;不同组之间不提供原子快照。已经读取旧选择的在途握手继续原路径。复用旧 AnyTLS 会话的新业务流也保留原路径,只有真正新建底层 transport 才读取新选择。
|
||||
|
||||
Hysteria2 已认证 QUIC 会话也保留其 `dialer-proxy` 上游组快照,包括复用该会话的新业务流和端口跳跃所需的新物理 socket;这些不是新的认证会话,不能因切组而迁移原路径。只有重新建立的认证会话才读取新的上游组选择。节点本身作为路由组成员时,后续业务的路由选节点仍按当前组选择执行。
|
||||
|
||||
## HTTP 状态与协议边界
|
||||
|
||||
| 状态 | 含义 |
|
||||
|
||||
@@ -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 验收。
|
||||
@@ -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,不能相互替代。
|
||||
+4
-4
@@ -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 安装包桥接
|
||||
|
||||
|
||||
@@ -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)。
|
||||
@@ -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<ClientConfig>` 只保存不可变的服务端公钥、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)。
|
||||
|
||||
@@ -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、可配置容量或长期缓存前,必须:
|
||||
|
||||
@@ -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 实现或运行时降级开关。
|
||||
@@ -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)。
|
||||
@@ -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)。
|
||||
@@ -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`,若原站或反向服务仍在宿主,也不满足本规则;迁移前不得用于新的服务端验收。
|
||||
@@ -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: <certificate SHA-256> # 与 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)。设备、性能、体积和发布许可证审查各自独立,
|
||||
本地互通或交叉编译不替代这些门禁。
|
||||
@@ -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。
|
||||
@@ -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 测速;
|
||||
|
||||
@@ -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 与结构上限
|
||||
|
||||
|
||||
+184
@@ -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>[.<key>…][.<padding>…]`。
|
||||
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 客户端互通或同组合差分通过。
|
||||
@@ -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/<sha256>.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)。
|
||||
+146
-10
@@ -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/<sha256>.json`,覆盖 YAML、可选进程顺序、路径和参数;
|
||||
- Session Snapshot 是 `LocalState/vcore/windows/sessions/<sha256>.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)
|
||||
|
||||
+105
@@ -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;原生数据、公共入口、资源、跨平台构建和阶段签收分别记录。
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
module LibVCore {
|
||||
header "vcore.h"
|
||||
link "c++"
|
||||
export *
|
||||
}
|
||||
|
||||
+13
-20
@@ -4,11 +4,11 @@
|
||||
<a href="../README.md">English</a> · <a href="./README.zh_CN.md">简体中文</a> · Русский
|
||||
</p>
|
||||
|
||||
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 <secret>
|
||||
|
||||
`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.
|
||||
|
||||
## Лицензия
|
||||
|
||||
|
||||
+14
-36
@@ -4,11 +4,11 @@
|
||||
<a href="../README.md">English</a> · 简体中文 · <a href="./README.ru.md">Русский</a>
|
||||
</p>
|
||||
|
||||
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 <secret>
|
||||
|
||||
`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
|
||||
|
||||
|
||||
+125
-128
@@ -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/<run-id>
|
||||
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/<fresh-run>
|
||||
uv run --project scripts --locked python -m vcore_scripts.protocol_fingerprint target/interop/runs/<fresh-run> --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/<fresh-run>
|
||||
uv run --project scripts --locked python -m vcore_scripts.protocol_fingerprint_reference --check-run target/interop/runs/<reference-run>
|
||||
uv run --project scripts --locked python -m vcore_scripts.protocol_jls target/interop/runs/<fresh-run>
|
||||
uv run --project scripts --locked python -m vcore_scripts.protocol_encryption target/interop/runs/<fresh-run>
|
||||
uv run --project scripts --locked vcore-scripts check reality-hybrid --run-dir target/interop/runs/<fresh-run>
|
||||
```
|
||||
|
||||
指纹验证的 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/<architecture>` 下的 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/<version>`。
|
||||
## 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 是显式平台人工验收工具,不是允许在宿主运行协议服务端的例外。
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -0,0 +1,47 @@
|
||||
/* Production artifact consumer. No runtime instance, sockets, or test features. */
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "vcore.h"
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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:])
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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:])
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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):")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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())
|
||||
@@ -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, "<fixture>")
|
||||
(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())
|
||||
@@ -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()))
|
||||
@@ -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"}}],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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, "<fixture-auth>"))
|
||||
)
|
||||
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)
|
||||
@@ -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), "<VCore>")
|
||||
text = re.sub(
|
||||
r"(?i)(authorization\s*[:=]\s*)(?:Bearer|Basic)\s+[^\s,}]+",
|
||||
r"\1<redacted>",
|
||||
text,
|
||||
)
|
||||
text = re.sub(
|
||||
r"(?i)-----BEGIN .*?PRIVATE KEY-----.*?-----END .*?PRIVATE KEY-----",
|
||||
"<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}", "<synthetic-id>", text
|
||||
)
|
||||
text = re.sub(
|
||||
r"(?i)((?:password|authorization|private[-_]key|token)\s*[:=]\s*)([^\s,}]+)",
|
||||
r"\1<redacted>",
|
||||
text,
|
||||
)
|
||||
text = re.sub(
|
||||
r"/(?:Users|home|private/var|var/folders)/[^\s\"']+", "<host-path>", text
|
||||
)
|
||||
return text
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user