diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06aabaa..e614808 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,9 +79,9 @@ jobs: python-version: "3.x" check-latest: true architecture: ${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }} - - uses: astral-sh/setup-uv@v10.2.0 + - uses: astral-sh/setup-uv@v10.3.3 with: - version: "0.12" + version: "0.13.6" - name: Linux native dependencies if: matrix.platform == 'linux' run: | @@ -90,9 +90,9 @@ jobs: - name: Cache native Rust dependencies uses: Swatinem/rust-cache@v2 with: - key: release-${{ matrix.name }}-${{ hashFiles('scripts/src/vole_scripts/*.py', 'scripts/cmake/**') }} + key: release-${{ matrix.name }}-${{ hashFiles('scripts/src/vole_scripts/*.py', 'scripts/cmake/**', 'scripts/native/**') }} workspaces: ". -> target" - env-vars: ImageOS ImageVersion SDKROOT DEVELOPER_DIR BORING AWS_LC LIBCLANG BINDGEN + env-vars: ImageOS ImageVersion SDKROOT DEVELOPER_DIR VOLE BORING AWS_LC LIBCLANG BINDGEN cache-bin: "false" - name: Build and package production CLI shell: bash @@ -193,9 +193,9 @@ jobs: python-version: "3.x" check-latest: true architecture: ${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }} - - uses: astral-sh/setup-uv@v10.2.0 + - uses: astral-sh/setup-uv@v10.3.3 with: - version: "0.12" + version: "0.13.6" - name: Locked source run: cargo fetch --locked - name: Linux native dependencies @@ -222,7 +222,7 @@ jobs: - name: Cache native Rust dependencies uses: Swatinem/rust-cache@v2 with: - key: release-${{ matrix.name }}-${{ hashFiles('scripts/src/vole_scripts/*.py', 'scripts/cmake/**') }} + key: release-${{ matrix.name }}-${{ hashFiles('scripts/src/vole_scripts/*.py', 'scripts/cmake/**', 'scripts/native/**') }} workspaces: ". -> target" env-vars: ImageOS ImageVersion SDKROOT DEVELOPER_DIR ANDROID_NDK_HOME VOLE BORING AWS_LC LIBCLANG BINDGEN cache-bin: "false" @@ -295,13 +295,13 @@ jobs: with: python-version: "3.x" check-latest: true - - uses: astral-sh/setup-uv@v10.2.0 + - uses: astral-sh/setup-uv@v10.3.3 with: - version: "0.12" + version: "0.13.6" - name: Cache native Rust dependencies uses: Swatinem/rust-cache@v2 with: - key: release-ffi-apple-${{ matrix.target }}-${{ hashFiles('scripts/src/vole_scripts/*.py', 'scripts/cmake/**') }} + key: release-ffi-apple-${{ matrix.target }}-${{ hashFiles('scripts/src/vole_scripts/*.py', 'scripts/cmake/**', 'scripts/native/**') }} workspaces: ". -> target" env-vars: ImageOS ImageVersion SDKROOT DEVELOPER_DIR VOLE BORING AWS_LC LIBCLANG BINDGEN cache-bin: "false" @@ -335,9 +335,9 @@ jobs: with: python-version: "3.x" check-latest: true - - uses: astral-sh/setup-uv@v10.2.0 + - uses: astral-sh/setup-uv@v10.3.3 with: - version: "0.12" + version: "0.13.6" - uses: actions/download-artifact@v8 with: pattern: vole-apple-*-${{ github.sha }} @@ -368,9 +368,9 @@ jobs: with: python-version: "3.x" check-latest: true - - uses: astral-sh/setup-uv@v10.2.0 + - uses: astral-sh/setup-uv@v10.3.3 with: - version: "0.12" + version: "0.13.6" - uses: actions/download-artifact@v8 with: pattern: vole-release-build-*-${{ github.sha }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ddec080..6365e61 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ env: jobs: client-contracts: name: ${{ matrix.name }} - runs-on: macos-26 + runs-on: ubuntu-26.04 timeout-minutes: 90 strategy: fail-fast: false @@ -41,16 +41,23 @@ jobs: with: python-version: "3.x" check-latest: true - - uses: astral-sh/setup-uv@v10.2.0 + - uses: astral-sh/setup-uv@v10.3.3 with: - version: "0.12" + version: "0.13.6" + - name: Linux native dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang libclang-dev cmake ninja-build perl pkg-config + - name: Prepare official BLAKE3 static dependency + shell: bash + run: uv run --project scripts --locked vole-scripts deps --github-env "$GITHUB_ENV" - name: Cache Rust dependencies uses: Swatinem/rust-cache@v2 with: - key: tests-${{ matrix.profile }} + key: tests-${{ matrix.profile }}-${{ hashFiles('scripts/native/**', 'scripts/src/vole_scripts/*.py') }} workspaces: ". -> target" # Native TLS artifacts must not cross runner SDK/image changes. - env-vars: ImageOS ImageVersion SDKROOT DEVELOPER_DIR BORING AWS_LC LIBCLANG BINDGEN + env-vars: ImageOS ImageVersion SDKROOT DEVELOPER_DIR BORING AWS_LC LIBCLANG BINDGEN VOLE_BLAKE3 cache-bin: "false" - name: Format and scripts if: matrix.profile == 'features' @@ -86,14 +93,16 @@ jobs: cargo metadata --locked --all-features --format-version 1 > "$RUNNER_TEMP/vole-metadata.json" uv run --project scripts --locked python - "$RUNNER_TEMP/vole-metadata.json" <<'PY' import json + import re import sys + import tomllib from pathlib import Path metadata = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) packages = metadata["packages"] nodes = {node["id"]: node for node in metadata["resolve"]["nodes"]} registry = {"registry+https://github.com/rust-lang/crates.io-index", "registry+https://index.crates.io/"} - boring_source = "git+https://github.com/YuanDevTeam/boring?branch=release#43c1c1d5b9464b3f2d5204be8664778fada7dbaf" + smoltcp_source = "git+https://github.com/smoltcp-rs/smoltcp?rev=24bda3b4f982625d8453d4360172557d24921c76#24bda3b4f982625d8453d4360172557d24921c76" def require(condition, message): if not condition: @@ -112,21 +121,89 @@ jobs: return set(nodes[item["id"]]["features"]) require(not any("watfaq" in (item.get("source") or "").lower() for item in packages), "retired dependency source is forbidden") - rustls = package("rustls", "0.23.45") - package("tokio-rustls", "0.26.5") - package("ring") + workspace_root = Path(metadata["workspace_root"]) + manifest = tomllib.loads((workspace_root / "Cargo.toml").read_text(encoding="utf-8")) + boring_repository = "https://github.com/YuanDevTeam/boring" + boring_revision = manifest["dependencies"]["boring"].get("rev") + require(isinstance(boring_revision, str) and re.fullmatch(r"[0-9a-f]{40}", boring_revision), "Boring PR dependencies must pin a complete commit SHA") + boring_specs = [manifest["dependencies"][name] for name in ("boring", "tokio-boring")] + boring_specs.append(manifest["dev-dependencies"]["boring-sys"]) + boring_specs.extend(manifest["patch"]["crates-io"][name] for name in ("boring", "boring-sys")) + for spec in boring_specs: + require(spec.get("git") == boring_repository and spec.get("rev") == boring_revision and not {"path", "branch", "tag"} & spec.keys(), "Boring dependencies and patches must share one Git commit source") + boring_source = f"git+{boring_repository}?rev={boring_revision}#{boring_revision}" + require(not (workspace_root / "vendor").exists(), "Core vendor sources are forbidden") + require(not (workspace_root / "crates" / "vole-blake3-raw" / "vendor").exists(), "BLAKE3 must use the installed official C library") + native_manifest = json.loads((workspace_root / "scripts" / "native" / "blake3" / "vcpkg.json").read_text(encoding="utf-8")) + require(native_manifest.get("builtin-baseline") == "9eec002df1cd109f8f104cd966a763daf9751489", "unapproved official BLAKE3 C registry baseline") + require(native_manifest.get("dependencies") == [{"name": "blake3", "default-features": False}], "BLAKE3 C must use the reviewed static-only package") + require(native_manifest.get("overrides") == [{"name": "blake3", "version": "1.8.7", "port-version": 0}], "unapproved official BLAKE3 C version") + for name, version in (("idna", "1.1.0"), ("url", "2.5.8")): + require(package(name, version).get("license") == "MIT OR Apache-2.0", f"unapproved {name} license") + for item in packages: + if item["name"].startswith(("icu_", "unicode-")): + require(item.get("source") in registry, f"{item['name']} must use official registry Unicode data") + rust_blake3 = package("blake3", "1.8.7") + require(rust_blake3.get("license") == "CC0-1.0 OR Apache-2.0 OR Apache-2.0 WITH LLVM-exception", "unapproved Rust BLAKE3 license") + rustls = package("rustls", "0.23.46") + package("tokio-rustls", "0.26.6") + package("rustls-native-certs", "0.8.5") + ring = package("ring") + quinn = package("quinn", "0.11.12") + proto = package("quinn-proto", "0.11.19") + require(proto.get("license") == "MIT OR Apache-2.0", "unapproved QUIC protocol license") + require({"rustls-ring", "ring"} <= features(proto), "QUIC protocol must use rustls/ring") + require("rustls-ring" in features(quinn), "Quinn wrapper must use rustls/ring") + require(not (features(proto) | features(quinn)) & {"aws-lc-rs", "aws-lc-rs-fips", "rustls-aws-lc-rs", "rustls-aws-lc-rs-fips"}, "Quinn must keep the approved ring TLS provider") + require(proto["id"] in {dependency["pkg"] for dependency in nodes[quinn["id"]]["deps"]}, "Quinn wrapper must resolve the official protocol source") + h3 = package("h3", "0.0.8") + require(h3.get("license") == "MIT", "unapproved Quinn HTTP/3 license") + h3_quinn = package("h3-quinn", "0.0.10") + require(h3["id"] in {dependency["pkg"] for dependency in nodes[h3_quinn["id"]]["deps"]}, "Quinn HTTP/3 adapter must resolve the official source") + quiche = package("quiche", "0.30.0") + require(features(quiche) == {"boring", "boring-sys", "boringssl-boring-crate", "foreign-types-shared"}, "quiche must use the shared Boring provider without FFI or qlog") + require(quiche.get("license") == "BSD-2-Clause", "unapproved quiche license") + stack = package("smoltcp", "0.14.0", {smoltcp_source}) + require(stack.get("license") == "0BSD", "unapproved raw IP stack license") + rsa = package("rsa", "0.9.10") + rcgen = package("rcgen", "0.14.10") + require(features(rsa) == {"std"}, "RSA is restricted to controller key generation") + require(features(rcgen) == {"crypto", "ring", "x509-parser"}, "certificate generation must use ring and the reviewed CA parser") + x509 = package("x509-parser", "0.18.1") + require(x509.get("license") == "MIT OR Apache-2.0", "unapproved X.509 parser license") + require(features(x509) == {"default", "ring", "verify"}, "X.509 verification must use ring") + x509_parents = {node["id"] for node in nodes.values() if any(dependency["pkg"] == x509["id"] for dependency in node.get("deps", []))} + require(x509_parents == {metadata["resolve"]["root"], rcgen["id"]}, "X.509 parsing is restricted to Vole and rcgen") + for item in (rsa, rcgen): + require(item.get("license") == "MIT OR Apache-2.0", f"unapproved {item['name']} license") + parents = {node["id"] for node in nodes.values() if any(dependency["pkg"] == item["id"] for dependency in node.get("deps", []))} + require(parents == {metadata["resolve"]["root"]}, f"{item['name']} must be consumed only by Vole") + require(ring["id"] in {dependency["pkg"] for dependency in nodes[rcgen["id"]]["deps"]}, "rcgen must use the approved ring package") + wireguard = package("boringtun", "0.7.1") + require(wireguard.get("license") == "BSD-3-Clause", "unapproved WireGuard license") + require(not features(wireguard) & {"device", "ffi-bindings", "jni-bindings"}, "WireGuard must use only the userspace protocol") require("ring" in features(rustls) and not features(rustls) & {"reality", "aws_lc_rs", "fips"}, "rustls must use only the ring TLS provider") - hpke = package("hpke", "0.14.1") - require(features(hpke) == {"alloc", "aes", "chacha", "x25519", "hkdfsha2"}, "unapproved ECH algorithms") + mieru_aead = next(item for item in packages if item["name"] == "chacha20poly1305" and item["version"] == "0.11.0") + require(mieru_aead.get("source") in registry and "zeroize" in features(mieru_aead), "Mieru must use official zeroizing XChaCha20-Poly1305") + mieru_proto = package("prost", "0.14.4") + require(mieru_proto.get("license") == "Apache-2.0" and mieru_proto.get("source") in registry, "Mieru protobuf must use official Apache-2.0 prost") + hpke = package("hpke", "0.15.1") + require(features(hpke) == {"alloc", "aes", "chacha", "x25519", "hkdfsha2", "nistp", "mlkem", "shake"}, "unapproved HPKE algorithms") + age = package("age", "0.11.5") + require(features(age) == {"armor"}, "AGE must use only native armored-file decoding") + package("age-core", "0.11.0") + package("x-wing", "0.1.1") + package("ml-kem", "0.3.4") native = {} for name, required in (("boring", {"reality", "client-fingerprint", "shadow-tls-v3", "jls"}), ("boring-sys", {"reality", "shadow-tls-v3", "jls"}), ("tokio-boring", set())): - item = package(name, "5.2.0", {boring_source}) + item = package(name, "5.4.0", {boring_source}) native[name] = item require(required <= features(item), f"{name} is missing required TLS features") require(not features(item) & {"fips", "fips-precompiled", "rpk", "pq-experimental", "restls"}, f"{name} enables an unapproved TLS mode") for parent, children in (("boring", {"boring-sys"}), ("tokio-boring", {"boring", "boring-sys"})): dependencies = {dependency["pkg"] for dependency in nodes[native[parent]["id"]]["deps"]} require(all(native[child]["id"] in dependencies for child in children), f"{parent} must use the approved native TLS packages") + require(native["boring"]["id"] in {dependency["pkg"] for dependency in nodes[quiche["id"]]["deps"]}, "quiche must share Vole's approved Boring package") aws = [item for item in packages if item["name"].lower().startswith("aws-lc")] if aws: require({item["name"] for item in aws} == {"aws-lc-rs", "aws-lc-sys"}, "unapproved AWS-LC package") @@ -140,8 +217,17 @@ jobs: for name, required in (("shadowsocks", {"aead-cipher-2022"}), ("shadowsocks-crypto", {"v2", "aws-lc"})): require(required <= features(chain[name]), f"{name} must enable SS 2022") require(not any(features(item) & {"fips", "aead-cipher-2022-extra", "v2-extra"} for item in chain.values()), "unapproved SS 2022 or AWS-LC mode") - print("Locked TLS sources, providers, ECH algorithms and SS 2022 dependency chain passed") + print("Locked TLS/Unicode/BLAKE3 sources, controller generation, HPKE/AGE algorithms and SS 2022 dependency chain passed") PY + - name: Official BLAKE3 raw memory vectors + if: matrix.profile == 'features' + run: | + cargo test --locked -p vole-blake3-raw + - name: H3 memory fixtures + if: matrix.profile == 'features' + run: | + cargo test --locked --features cli --lib transport::quiche_h3::tests:: + cargo test --locked --features cli --lib transport::quinn_h3::tests:: - name: Production and netstack clippy if: matrix.profile == 'features' run: | @@ -177,6 +263,8 @@ jobs: xhttp_h3_shutdown hysteria2_paths hysteria2_config shadowsocks_backpressure shadowtls_config shadowtls_stream uot_config shadowsocks_uot hysteria2_packet_ids tuic_config tuic_memory httpupgrade_config httpupgrade_memory vmess_lifecycle + grpc_config grpc_pool grpc_protocol_pool + sing_mux_config sing_mux ) target_flags=() for target in "${targets[@]}"; do target_flags+=(--test "$target"); done @@ -184,14 +272,29 @@ jobs: # Keep this explicit allowlist: unfiltered legacy tests may open host listeners. filters=( geodata:: routing:: security:: outbound::vless::encryption config:: - inbound::mixed::tests:: inbound::socks5::association::tests:: + connections::tests:: routing::diagnostics::tests:: dialer::connections::tests:: + polled_datagram_ + resource::http::tests:: fake_ip_ sniffing::tests:: tcp_sniffer::tests:: + resource::age_provider::tests:: resource::bundle::tests:: resource::bundle_limits::tests:: + resource::cache::tests:: resource::subscription::tests:: + dns::fallback::tests:: dns::runtime::tests::dns_fallback_ + dns::runtime::tests::resolve_ipv6_ + dns::source::tests:: dns::runtime::tests::dns_source_ + dns::runtime::encrypted_tests:: + dns::options::tests:: dns::runtime::quic_tests:: dns::runtime::ech_tests:: + controller_secure_ + outbound::http::tests:: outbound::wireguard::tests:: inbound::mixed::tests:: inbound::socks5::association::tests:: inbound::http::parser::tests:: inbound::http::framing::tests:: outbound::hysteria2:: outbound::shadowsocks::tests::official_tcp_ outbound::anytls:: outbound::uot:: outbound::tuic:: transport::h2_write::tests:: - transport::sing_mux::smux_driver::tests:: transport::sing_mux::tests:: - platform::tun_rs_io::tests:: platform::windows_tun_io::tests:: + outbound::trusttunnel:: outbound::mieru:: outbound::masque:: security::masque:: config::masque:: + transport::quic:: transport::quinn_h3:: transport::quiche_h3:: transport::masque_codec:: transport::h3_connect:: + transport::smux::tests:: transport::sing_mux::tests:: + grpc_duplex_pool_keeps_response + outbound::sing_mux::tests:: + platform::linux_network::tests:: platform::tun_rs_io::tests:: platform::windows_tun_io::tests:: platform::windows_wintun_io::tests:: tun_runtime::tests:: - invoke::foreground::tests:: runtime::shutdown_tests:: + invoke::foreground::tests:: runtime::shutdown_tests:: runtime::providers::tests:: resource::probe::tests:: invoke::measure_delay::tests:: ) for filter in "${filters[@]}"; do run_test 1 --lib "$filter"; done run_test 1 --bin vole cli::tests:: @@ -200,14 +303,28 @@ jobs: dialer::tests::socket_initialization_cancellation_joins_started_and_queued_work dialer::tests::socket_initialization_shared_pool_contention_reclaims_cancelled_work outbound::connector::tests::authenticated_continuation_keeps_group_choice_but_has_a_new_io_deadline + outbound::connector::tests::ingress_connector_dispatcher_preserves_user_for_dialer_proxy_and_continuations runtime::tests::integration_feature_admission_is_explicit_without_opening_sockets invoke::tests::version_and_state_use_the_fixed_response_envelope invoke::tests::initialize_is_idempotent_only_for_the_same_data_directory invoke::tests::envelope_and_payload_are_strict invoke::tests::runtime_thread_cannot_reenter_invoke invoke::tests::engine_workers_preserve_invoke_admission_and_shutdown + invoke::tests::update_dns_network_raw_admission_is_bounded_before_value_or_registry_work + invoke::tests::update_dns_network_is_runtime_local_strict_and_monotonic invoke::tests::engine_completion_notifies_after_panic_and_join_reports_failure + dns::runtime::tests::subrule_dns_rules_keep_round_robin_for_new_physical_connections + dns::runtime::tests::runtime_dns_separates_bootstrap_proxy_direct_and_hosts + dns::runtime::tests::udp_truncation_retries_the_same_upstream_over_tcp dns::runtime::tests::concurrent_redir_host_hint_reads_and_dns_updates_complete + dns::runtime::tests::domain_policies_use_specificity_and_preserve_geosite_order + dns::runtime::tests::dns_policy_selection_is_consistent_across_snapshot_swaps + dns::runtime::tests::classical_logical_selectors_drive_dns_policy_and_sniffing_without_egress + dns::runtime::tests::rule_budget_propagates_through_fake_ip_policy_and_sniffer_without_egress + runtime::tests::prepares_builtin_routes_without_static_nodes_or_bootstrap + runtime::tests::prepares_udp_capabilities_through_runtime_wrappers + runtime::tests::prepares_shared_mux_capabilities_and_registers_only_final_graph_nodes + runtime::tests::controller_diagnostics_are_opt_in_for_business_routes invoke::tests::null_invalid_utf8_and_oversized_input_return_json_failures invoke::tests::same_instance_command_is_fail_fast invoke::tests::android_protector_is_required_only_for_tun @@ -231,15 +348,22 @@ jobs: test "$(grep -Ec '^test result: ok\. [1-9][0-9]* passed; 0 failed;' "$test_log")" -eq "$expected_summaries" rm -f "$test_log" } - protocols=(socks5 anytls shadowsocks trojan vmess vless hysteria2 tuic) + protocols=(socks5 anytls shadowsocks trojan trusttunnel mieru masque vmess vless hysteria2 tuic wireguard) cargo check --locked --no-default-features --lib for protocol in "${protocols[@]}"; do cargo check --locked --no-default-features --features "outbound-$protocol" --lib run_test 1 --features "outbound-$protocol" --lib runtime::tests::integration_feature_admission_is_explicit_without_opening_sockets -- --exact done - for feature in inbound-http inbound-socks5 stream-transport quic-transport shadow-tls-v3 tun invoke ffi cli windows-wintun windows-uwp; do + for feature in outbound-http inbound-http inbound-socks5 stream-transport quic-transport quinn-h3-transport quiche-h3-transport dns-quic sing-mux shadow-tls-v3 tun invoke ffi cli windows-wintun windows-uwp; do cargo check --locked --no-default-features --features "$feature" --lib done + dns_graph="$(mktemp "$RUNNER_TEMP/vole-dns-graph.XXXXXX")" + cargo tree --locked --no-default-features --features dns-quic --target all --edges normal,build --prefix none | tee "$dns_graph" + if grep -Eq '^(quiche|boring|boring-sys|tokio-boring) v' "$dns_graph"; then + echo "The production DNS feature must use Quinn/rustls without quiche or Boring" + exit 1 + fi + rm -f "$dns_graph" cargo check --locked --no-default-features --features cli --bin vole run_test 2 --test feature_foundations --test hysteria2_config cargo check --locked --lib diff --git a/AGENTS.md b/AGENTS.md index a5f8a66..2b51361 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,29 +2,33 @@ Vole is a standalone Rust proxy core with native libraries and an optional foreground CLI. The native library contract is the JSON Invoke API, with no API or configuration revision fields. The `invoke` feature owns the shared request handler; `cli` and `ffi` are its command-line and native transports. The CLI parses flags, calls Invoke, renders responses, and sets exit status without requiring C ABI or Windows package entry points. Runtime configuration uses the strict schema documented in `docs/config.yaml`; native callers pass it inline as `configYaml` / `configYamls`; the explicit Invoke `foreground` operation owns bounded file/stdin reads, environment/path defaults, signals, and cleanup for CLI use. YAML does not contain `default-proxy`. Business proxy listeners use only `mixed-port`: shared HTTP/SOCKS5 TCP and SOCKS5 UDP require both inbound features. Top-level `port`, `socks-port`, `udp`, and `listeners` are rejected; proxy-node `port` and `udp` retain their outbound semantics. `allow-lan` and `authentication` are independent; omitted or empty authentication leaves both loopback and wildcard bindings unauthenticated. Public lifecycle state is runtime-local and single-instance. -iOS, tvOS 17+ (ARM64 device/simulator), macOS, Android and Linux use config-owned `tun.file-descriptor` through the Unix `tun-rs` adapter; positive descriptors remain host-owned and Vole closes only its duplicate. With descriptor 0, Linux and macOS can create/open native TUN devices from `tun.device`. Linux validates a real single-queue raw-IP TUN and its configured MTU; the host owns addresses, DNS, namespace/routing isolation, and physical egress. Apple packetFlow does not publicly guarantee raw-fd access; Linux or simulator results do not sign off a physical Network Extension. Windows has mutually exclusive `windows-uwp` (`windows-rs` / `Windows.Networking.Vpn`) and `windows-wintun` builds sharing the same core. Validate Wintun devices and packaged UWP integration separately. +iOS, tvOS 17+ (ARM64 device/simulator), macOS, Android and Linux use config-owned `tun.file-descriptor` through the Unix `tun-rs` adapter; positive descriptors remain host-owned and Vole closes only its duplicate. With descriptor 0, Linux and macOS can create/open native TUN devices from `tun.device`. Linux validates a real single-queue raw-IP TUN and its configured MTU; the host owns borrowed-fd addresses, DNS, namespace/routing isolation, and physical egress. Core-owned Linux TUN supports auto-detect-interface, auto-route and per-link systemd-resolved setup with rollback; package installation supplies capabilities and noninteractive DNS authorization. Apple packetFlow does not publicly guarantee raw-fd access; Linux or simulator results do not sign off a physical Network Extension. Windows has mutually exclusive `windows-uwp` (`windows-rs` / `Windows.Networking.Vpn`) and `windows-wintun` builds sharing the same core. Validate Wintun devices and packaged UWP integration separately. # Sources of Truth Current source, tests, and the public contract documents under `docs/` define implemented behavior. Keep documentation focused on current usage and contracts; record validation results in the relevant CI run or PR rather than accumulating development logs. When touching a documented boundary, reconcile its document with the Invoke API and current tests in the same change. +Keep all development plans in the workspace's `references/plan/` directory outside Git repositories (`../references/plan/` from this checkout). Repository documentation contains finalized conclusions, accepted decisions, and current contracts; implementation plans and task lists stay in that external directory. + Read the relevant document completely before changing that area: +- Mihomo compatibility, protocol interoperability, or compatibility/performance tradeoffs: `docs/mihomo-compatibility.md`. Apply its compatibility principles to implementation, tests, and reviews. For performance or defect fixes, first compare local Mihomo, clash-rs and official client source, then profile the matched workload to locate the bottleneck, and only then change Vole. Distinguish Core, peer and benchmark causes; retain confirmed peer failures instead of changing Core for them. Shared-layer changes require explicit regressions of affected previously passing windows; historical PASS does not sign off changed code. - Invoke, FFI, lifecycle, Android protect, or config delivery: `docs/invoke-api.md`, `src/invoke/`, 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 listeners: `docs/inbounds.md` and `src/inbound/`. +- Business HTTP rewrite, selective TLS, or its separate configuration: `docs/http-processing.md` and `src/http_processing/`. - SOCKS5, AnyTLS, Trojan, VMess, Hysteria2, TUIC v5 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`. -- 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`. Before local boring development or opening/updating a PR, follow the dependency-source workflow in `docs/tls-dependencies.md`. Keep dependency auditing in CI rather than duplicating it in platform build scripts. +- 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`. Before local Boring development or opening/updating a Vole PR, follow the path-to-commit workflow and Boring PR prerequisite in `docs/tls-dependencies.md`. Keep dependency auditing in CI rather than duplicating it in platform build scripts. - TUN fields, resource acquisition, fd ownership, packet I/O, MTU, or UDP idle policy: `docs/config.yaml`, `docs/tun-platform.md`, and `docs/tun-icmp-dns.md`. - Windows VPN/TUN, outbound binding, AppContainer packet buffers, or package lifecycle: `docs/windows-vpn.md` and `docs/tun-platform.md`. - CLI, foreground file/environment/path/signal behavior, or tag releases: `docs/cli.md`, `docs/invoke-api.md`, `scripts/README.md`, `src/cli.rs`, `src/invoke/foreground.rs`, and `.github/workflows/release.yml`; distinguish configured workflows from executed release validation. - Builds: `scripts/README.md`; `vole-scripts build` compiles platform outputs, and `vole_scripts.release` packages them for CI. - Offline regressions: `tests/README.md`. Rust memory/configuration tests remain in Vole; run explicit pure-memory filters, with all-target coverage limited to `--no-run`. -- Protocol interoperability or pressure: the public [container-benchmark](https://github.com/YuanDevTeam/container-benchmark) owns all container orchestration and fixtures. Supply the checkout explicitly with `--source vole=PATH`; Vole builds do not import it or infer workspace paths. +- Protocol interoperability or pressure: isolated benchmark tooling owns container orchestration and fixtures. Supply the checkout explicitly with `--source vole=PATH`; Vole builds do not import it or infer workspace paths. Keep benchmark results, comparisons and conclusions in the benchmark project; Vole documents implementation contracts and acceptance criteria. Keep tooling repository addresses and raw runs outside Vole. - Server-side tests: `docs/testing-isolation.md`. Use isolated benchmark containers for every server peer and network origin; container failure is not permission for a host fallback. - 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. @@ -32,12 +36,12 @@ Read the relevant document completely before changing that area: - `src/invoke/` owns shared JSON dispatch, registry, lifecycle admission, panic containment, and engine completion. `start(configYaml)` performs internal preparation and platform acquisition; no public `prepare` or fd/framing payload exists. `validateConfig` needs no initialization. - `src/ffi/` adapts C ABI and Android JNI to Invoke and preserves native buffer/callback boundaries. -- `src/cli.rs` owns five flags, Invoke request conversion, rendering, and exit status. `src/invoke/foreground.rs` owns file/stdin reads, native path metadata, environment defaults, process signals, bounded stderr, and foreground cleanup. Foreground validation reads its input but does not initialize data, resolve remote endpoints, or inspect platform devices. +- `src/cli.rs` owns argument parsing, Invoke request conversion, rendering, and exit status. `src/invoke/foreground.rs` owns file/stdin reads, native path metadata, environment defaults, process signals, stderr logging, and foreground cleanup. Foreground validation reads its input but does not initialize data, resolve remote endpoints, or inspect platform devices. - `src/runtime.rs` prepares and owns DNS, routing, outbound graphs, listeners, GeoData, and long-lived tasks. - `src/tun_runtime.rs` connects platform raw-IP I/O to `vole-netstack` and dispatches TCP, UDP, DNS, ICMP, and sniffing. - `src/platform/` contains platform adapters. Keep framing internal, propagate configured MTU, and preserve original borrowed-fd flags and ownership. Windows backend selection occurs at compilation; keep Windows callback semantics here instead of simulating a Unix fd. - `src/dialer.rs` is the shared physical TCP/UDP socket seam. Fix socket protection or Windows `(source IP, interface index)` binding once here rather than in each outbound. -- Static `select` groups share Running Session selection between routing and `dialer-proxy`. Keep protocol configuration immutable, validate the complete node/member DAG, and retain only declared dependencies to avoid Arc cycles. Resolve upstreams at the `OutboundConnector` seam using one setup deadline and per-group selection snapshot; prepare every potential DIRECT server endpoint. `measureDelay` remains node-only. +- Static and provider-backed select, url-test and fallback groups share Running Session selection between routing and `dialer-proxy`. Provider candidates validate the complete graph before publication; existing sessions retain their old resource generation until release. Keep protocol configuration immutable, validate the complete node/member DAG, and retain only declared dependencies to avoid Arc cycles. Resolve upstreams at the `OutboundConnector` seam using one setup deadline and per-group selection snapshot; resolve DIRECT server endpoints on demand through the proxy DNS role. `measureDelay` remains node-only. - `crates/vole-netstack` is platform-independent raw-IP state and must not depend on WinRT, JNI, Swift, or host UI frameworks. - Core runtime lifecycle does not infer App, extension, service, or daemon roles and does not implement cross-process state or IPC. The Windows-only host Invoke is the explicit package integration seam for profile/status/Session Snapshot/StartupTask operations and an optional bounded `sessionBackend`; its Session Host owns backend process liveness without interpreting arguments, files, ports, or protocols. Provider runtime state remains process-local. @@ -45,7 +49,7 @@ Read the relevant document completely before changing that area: 1. Keep the latest-only schema strict. Reject unknown and obsolete fields instead of adding compatibility branches or silent migration. 2. Preserve bounded queues, packet/buffer/parser limits, cancellation, and synchronous stop barriers. Do not turn UDP callback paths into blocking waits. -3. Keep secrets, UUIDs, DNS questions, full config/request bodies, and runtime traffic destinations out of logs and errors. Configuration-service endpoints may appear only as a bounded, sanitized origin or source index when operational diagnosis requires it; never log userinfo, query, fragment, private addresses, or redirect targets. +3. CLI and Controller logs preserve each emitted event's complete target, message and fields, including strings, errors and Debug values. Apply configured log levels and bounded subscription queues, without content redaction, field allowlists, truncation or additional rate limits. Do not add logging of configuration/request bodies or credentials merely to populate logs; API error and snapshot contracts remain separate. 4. Platform trust boundaries fail closed. Android protect failure, WinRT VPN physical-interface loss, invalid packet-buffer ownership, or unsupported target startup must not fall back to an unprotected socket. Desktop Wintun leaves interface addresses, DNS, routes and physical-egress isolation to the host; it does not inherit the WinRT Provider's binding or network-change monitor. 5. Packaged Windows VPN uses official `Windows.Networking.Vpn` through `windows-rs`. `VpnPacketBuffer` bytes are copied within callbacks. After acquiring a framework buffer, return it to Windows before propagating any later local error; a return-API failure propagates the platform error and fails closed. Every framework buffer is returned exactly once. Network changes stop this path; they do not silently rebind or reconnect. 6. Reuse the existing raw-IP netstack, runtime and outbound graph. Windows WinRT VPN and desktop Wintun use separate platform adapters with the shared Dialer and synchronous stop barrier; Wintun's DLL and first-release network configuration are host-owned. Add no second Windows proxy core, per-protocol socket factory or fake fd layer. diff --git a/CONTEXT.md b/CONTEXT.md index f3b6689..9040004 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -72,14 +72,21 @@ _Avoid_: Managed core, child core, URI configuration A named, concrete outbound protocol path that can be selected as a route target or referenced by another proxy node through `dialer-proxy`. _Avoid_: Proxy when a proxy group or route target is intended, server profile +**Routing control**: +A named rule-flow effect that updates the current decision and resumes rule evaluation without establishing a protocol connection. It is not a measurable proxy node. +_Avoid_: Physical outbound, transport, fake connection + **Proxy group**: -A named route or upstream target whose current-session selection identifies one direct member. Routing and `dialer-proxy` share the same selection; an upstream group's DIRECT member connects the current node's prepared server. +A named route or upstream target whose current-session selection identifies one direct member. Routing and `dialer-proxy` share the same selection; an upstream group's DIRECT member connects the current node's server resolved on demand. _Avoid_: Folder, provider, subscription **Route target**: -A proxy node, proxy group, or reserved built-in target that routing rules, the final `MATCH`, and DNS routes can select. +A proxy node, routing control, proxy group, or reserved built-in target. Rule-flow controls run only during rule evaluation; selecting one as a physical upstream does not establish a connection. _Avoid_: Proxy when the distinction between a node and group matters, dialer **Group selection**: The running-session state identifying the direct member route target currently chosen by a proxy group. _Avoid_: Variant, configuration switch, active profile + +**Resource generation**: +A validated combination of provider content, proxy nodes, groups, rules and DNS policies. New sessions use the published generation; existing sessions retain their dependencies until completion. Failed candidates preserve the usable generation and its HTTP cache. diff --git a/Cargo.lock b/Cargo.lock index 8d144cd..7222452 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,25 +8,35 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + [[package]] name = "aead" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.2.2", + "inout 0.2.5", ] [[package]] name = "aes" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1" dependencies = [ - "cipher", + "cipher 0.5.2", "cpubits", - "cpufeatures", + "cpufeatures 0.3.1", "zeroize", ] @@ -36,14 +46,70 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" dependencies = [ - "aead", + "aead 0.6.1", "aes", - "cipher", + "cipher 0.5.2", "ctr", "ctutils", "ghash", ] +[[package]] +name = "age" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "047a482d1843edf1ce76ada63183698144030fe1191bd5ddba6e41e164e0bc43" +dependencies = [ + "age-core", + "base64 0.21.7", + "bech32 0.9.1", + "chacha20poly1305 0.10.1", + "cookie-factory", + "hmac 0.12.1", + "i18n-embed", + "i18n-embed-fl", + "lazy_static", + "nom", + "pin-project", + "rand 0.8.8", + "rust-embed", + "scrypt", + "sha2 0.10.9", + "subtle", + "x25519-dalek 2.0.1", + "zeroize", +] + +[[package]] +name = "age-core" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2bf6a89c984ca9d850913ece2da39e1d200563b0a94b002b253beee4c5acf99" +dependencies = [ + "base64 0.21.7", + "chacha20poly1305 0.10.1", + "cookie-factory", + "hkdf 0.12.4", + "io_tee", + "nom", + "rand 0.8.8", + "secrecy", + "sha2 0.10.9", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -68,15 +134,27 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "arc-swap" -version = "1.9.2" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" dependencies = [ "rustversion", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "arrayvec" version = "0.7.8" @@ -95,7 +173,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror", + "thiserror 2.0.21", "time", ] @@ -130,7 +208,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -147,9 +225,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "048006790404103c488e6c5616b3e7e702a71292aadda08e8f7698a9e0d2eb53" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -158,9 +236,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.45.0" +version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "8bfbe32ac36b3c428696ce315464181bc4029d689fd58e3f2a4e58c252242f52" dependencies = [ "cc", "cmake", @@ -169,12 +247,57 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64" version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + +[[package]] +name = "bech32" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8" + [[package]] name = "bindgen" version = "0.72.1" @@ -184,15 +307,30 @@ dependencies = [ "bitflags 2.13.2", "cexpr", "clang-sys", - "itertools", + "itertools 0.13.0", "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 2.1.3", "shlex 1.3.0", "syn 2.0.119", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit-vec" version = "0.9.1" @@ -214,13 +352,22 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "blake2" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ - "digest", + "digest 0.11.3", ] [[package]] @@ -233,7 +380,16 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", ] [[package]] @@ -243,12 +399,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] name = "boring" -version = "5.2.0" -source = "git+https://github.com/YuanDevTeam/boring?branch=release#43c1c1d5b9464b3f2d5204be8664778fada7dbaf" +version = "5.4.0" +source = "git+https://github.com/YuanDevTeam/boring?rev=c717137e6c794a0b069f64686a375858ffc80818#c717137e6c794a0b069f64686a375858ffc80818" dependencies = [ "bitflags 2.13.2", "boring-sys", @@ -261,8 +418,8 @@ dependencies = [ [[package]] name = "boring-sys" -version = "5.2.0" -source = "git+https://github.com/YuanDevTeam/boring?branch=release#43c1c1d5b9464b3f2d5204be8664778fada7dbaf" +version = "5.4.0" +source = "git+https://github.com/YuanDevTeam/boring?rev=c717137e6c794a0b069f64686a375858ffc80818#c717137e6c794a0b069f64686a375858ffc80818" dependencies = [ "bindgen", "cmake", @@ -270,11 +427,36 @@ dependencies = [ "fslock", ] +[[package]] +name = "boringtun" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15dd6a8a89cbe8997f37ca0cf035e6ea4d64cd2ecea4aed83ffb9f99f7126939" +dependencies = [ + "aead 0.5.2", + "base64 0.22.1", + "blake2 0.10.6", + "chacha20poly1305 0.10.1", + "hex", + "hmac 0.12.1", + "ip_network", + "ip_network_table", + "libc", + "nix 0.31.3", + "parking_lot", + "portable-atomic", + "rand_core 0.6.4", + "ring", + "tracing", + "untrusted 0.9.0", + "x25519-dalek 2.0.1", +] + [[package]] name = "brotli" -version = "9.0.0" +version = "9.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" +checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -283,9 +465,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "6.0.1" +version = "6.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -317,9 +499,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.5.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" dependencies = [ "find-msvc-tools", "jobserver", @@ -348,6 +530,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.2" @@ -355,9 +548,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", - "rand_core", + "cipher 0.5.2", + "cpufeatures 0.3.1", + "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead 0.5.2", + "chacha20 0.9.1", + "cipher 0.4.4", + "poly1305 0.8.0", + "zeroize", ] [[package]] @@ -366,10 +573,22 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" dependencies = [ - "aead", - "chacha20", - "cipher", - "poly1305", + "aead 0.6.1", + "chacha20 0.10.2", + "cipher 0.5.2", + "poly1305 0.9.1", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", + "zeroize", ] [[package]] @@ -378,9 +597,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer", - "crypto-common", - "inout", + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.5", ] [[package]] @@ -409,6 +628,12 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -421,6 +646,15 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -449,6 +683,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -460,13 +703,39 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-bigint" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11" +dependencies = [ + "cpubits", + "ctutils", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -475,7 +744,7 @@ checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "getrandom 0.4.3", "hybrid-array", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -484,16 +753,32 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher", + "cipher 0.5.2", ] [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a" dependencies = [ "cmov", + "subtle", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", ] [[package]] @@ -503,9 +788,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "curve25519-dalek-derive", - "fiat-crypto", + "fiat-crypto 0.3.0", "rustc_version", "subtle", "zeroize", @@ -528,11 +813,17 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "debug_panic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9377eb110cece2e9431deb8d7d2ec8c116510b896741f9f2bf02b352147aa2a6" + [[package]] name = "defmt" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -540,23 +831,43 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "defmt-parser" -version = "1.0.0" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" dependencies = [ - "thiserror", + "const-oid 0.10.2", + "zeroize", ] [[package]] @@ -575,9 +886,21 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] [[package]] name = "digest" @@ -585,10 +908,11 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", "ctutils", + "zeroize", ] [[package]] @@ -599,7 +923,33 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", +] + +[[package]] +name = "domain" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b68712c02bff2348323c1441ab2e8528e32af4f8f9edf29356356290328c8e6" +dependencies = [ + "bumpalo", + "bytes", + "domain-macros", + "hashbrown", + "jiff", + "octseq", + "serde", +] + +[[package]] +name = "domain-macros" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fba8fbc4580c745dfeab102232dcb729f030ac3e2c9ff5374a9dfd6dc9ddf8e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -630,15 +980,35 @@ dependencies = [ [[package]] name = "either" -version = "1.18.0" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "elliptic-curve" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hkdf 0.13.0", + "hybrid-array", + "rand_core 0.10.1", + "sec1", + "subtle", + "zeroize", +] [[package]] name = "encoding_rs" -version = "0.8.42" +version = "0.8.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5" dependencies = [ "cfg-if", "core_detect", @@ -648,11 +1018,23 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -664,6 +1046,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fancy-regex" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20ed7f62ce0ae0da1a8b93c3c7104ab7e0760a74491e097c701fff966e945390" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -674,16 +1067,41 @@ dependencies = [ ] [[package]] -name = "fiat-crypto" -version = "0.3.0" +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fiat-crypto" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml", +] + [[package]] name = "find-msvc-tools" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "flate2" @@ -695,6 +1113,50 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb74634707bebd0ce645a981148e8fb8c7bccd4c33c652aeffd28bf2f96d555a" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 1.1.0", + "self_cell 0.10.3", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "flume" version = "0.12.0" @@ -713,6 +1175,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.5.0" @@ -731,7 +1199,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -821,7 +1289,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -853,6 +1321,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getifaddrs" version = "0.6.2" @@ -877,6 +1355,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -886,8 +1376,8 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", - "rand_core", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -906,11 +1396,22 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "h2" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" dependencies = [ "atomic-waker", "bytes", @@ -967,6 +1468,18 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown", +] [[package]] name = "heapless" @@ -978,13 +1491,37 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hkdf" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac", + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", ] [[package]] @@ -993,27 +1530,62 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest", + "digest 0.11.3", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.2.1", ] [[package]] name = "hpke" -version = "0.14.1" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5324110b02044183df000f0bd7d2d7e61f1000631508627e77f63983b6fdffb" +checksum = "8f512e325a1a71bf3273fb9b7fc9e5d96dac136fb4a95d0e6637001b12ea3e09" dependencies = [ - "aead", + "aead 0.6.1", "aes-gcm", - "chacha20poly1305", - "hkdf", + "chacha20poly1305 0.11.0", + "curve25519-dalek 5.0.0", + "hkdf 0.13.0", "hybrid-array", - "rand_core", - "sha2", + "ml-kem", + "p256", + "p384", + "p521", + "rand_core 0.10.1", + "sha2 0.11.1", + "sha3", + "shake", "subtle", - "x25519-dalek", + "turboshake", + "x-wing", + "x25519-dalek 3.0.0", "zeroize", ] +[[package]] +name = "httlib-hpack" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40cf60e5e8567c6ff914a590f1452821de9377a560338a562e570a6ff052aae3" +dependencies = [ + "httlib-huffman", +] + +[[package]] +name = "httlib-huffman" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a9fcbcc408c5526c3ab80d534e5c86e7967c1fb7aa0a8c76abd1edc27deb877" + [[package]] name = "http" version = "1.5.0" @@ -1046,18 +1618,20 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "ctutils", + "subtle", "typenum", + "zeroize", ] [[package]] name = "hyper" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", - "futures-channel", "futures-core", "http", "http-body", @@ -1071,9 +1645,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ "bytes", "http", @@ -1083,11 +1657,77 @@ dependencies = [ "tokio", ] +[[package]] +name = "i18n-config" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" +dependencies = [ + "basic-toml", + "log", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669ffc2c93f97e6ddf06ddbe999fcd6782e3342978bb85f7d3c087c7978404c4" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "log", + "parking_lot", + "rust-embed", + "thiserror 1.0.69", + "unic-langid", + "walkdir", +] + +[[package]] +name = "i18n-embed-fl" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04b2969d0b3fc6143776c535184c19722032b43e6a642d710fa3f88faec53c2d" +dependencies = [ + "find-crate", + "fluent", + "fluent-syntax", + "i18n-config", + "i18n-embed", + "proc-macro-error2", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -1099,9 +1739,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", @@ -1112,9 +1752,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1126,15 +1766,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ "displaydoc", "icu_collections", @@ -1147,15 +1787,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", @@ -1199,19 +1839,87 @@ dependencies = [ [[package]] name = "inout" -version = "0.2.2" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "inout" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" dependencies = [ "hybrid-array", ] +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "intrusive-collections" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4275b20e6057cd7733fd8df8a5a31701e4fe44497dad0f3fa0e1c4fb971506be" + +[[package]] +name = "io_tee" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" + +[[package]] +name = "ip_network" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2f047c0a98b2f299aa5d6d7088443570faae494e9ae1305e48be000c9e0eb1" + +[[package]] +name = "ip_network_table" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4099b7cfc5c5e2fe8c5edf3f6f7adf7a714c9cc697534f63a5a5da30397cb2c0" +dependencies = [ + "ip_network", + "ip_network_table-deps-treebitmap", +] + +[[package]] +name = "ip_network_table-deps-treebitmap" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e537132deb99c0eb4b752f0346b6a836200eaaa3516dd7e5514b63930a09e5d" + [[package]] name = "ipnet" version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +[[package]] +name = "ipnetwork" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" + [[package]] name = "itertools" version = "0.13.0" @@ -1221,12 +1929,58 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "jobserver" version = "0.1.35" @@ -1239,9 +1993,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" dependencies = [ "cfg-if", "futures-util", @@ -1255,20 +2009,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", ] [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin 0.9.9", +] [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libloading" @@ -1290,6 +2057,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1313,9 +2086,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.34" +version = "0.4.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" [[package]] name = "lru-slab" @@ -1329,12 +2102,30 @@ version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9106e1d747ffd48e6be5bb2d97fa706ed25b144fbee4d5c02eae110cd8d6badd" +[[package]] +name = "lzma-rust2" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c46f3fcd2e888afdb01a07efc071cb21300e07c68d32c3e8b01b5c8cb1fcaa" + [[package]] name = "managed" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" +[[package]] +name = "maxminddb" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b43d2458d977b610b04a3858e6504f06133663d303a1a0606601b530bfc9bc6" +dependencies = [ + "ipnetwork", + "memchr", + "serde", + "thiserror 2.0.21", +] + [[package]] name = "md-5" version = "0.11.0" @@ -1342,7 +2133,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest", + "digest 0.11.3", ] [[package]] @@ -1360,6 +2151,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1378,15 +2185,42 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84ca78b66f46050d49fa21693ae8adab2081d8101b65d8067316b6b70b64a0d1" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "rand_core 0.10.1", + "sha3", + "shake", + "zeroize", +] + +[[package]] +name = "module-lattice" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e412c8ee83a1c678f427806219c49c96b2511b9f07497514f0e43fb26155" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", + "zeroize", +] + [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -1403,13 +2237,13 @@ dependencies = [ "core-foundation", "ipnet", "libc", - "netlink-packet-core", + "netlink-packet-core 0.8.2", "netlink-packet-route 0.25.1", - "netlink-sys", + "netlink-sys 0.8.8", "nix 0.30.1", "scopeguard", "system-configuration-sys", - "thiserror", + "thiserror 2.0.21", "widestring", "windows 0.61.3", ] @@ -1423,6 +2257,12 @@ dependencies = [ "paste", ] +[[package]] +name = "netlink-packet-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6e2daf9a8c2e21302714706860a0e6be02e03d17294ecc66b354ea7d4059dd" + [[package]] name = "netlink-packet-route" version = "0.25.1" @@ -1432,19 +2272,20 @@ dependencies = [ "bitflags 2.13.2", "libc", "log", - "netlink-packet-core", + "netlink-packet-core 0.8.2", ] [[package]] name = "netlink-packet-route" -version = "0.31.0" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +checksum = "59d48ffc8b73a506e1ff0878528c72668f2bfbc3d875b6be890a96be5d0d5576" dependencies = [ "bitflags 2.13.2", "libc", "log", - "netlink-packet-core", + "netlink-packet-core 0.9.0", + "zerocopy", ] [[package]] @@ -1458,6 +2299,17 @@ dependencies = [ "log", ] +[[package]] +name = "netlink-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c99d38e00b420df49e940fe0826e667c3dad82ac68eb4c2bbf754c1c14a83a10" +dependencies = [ + "bytes", + "libc", + "log", +] + [[package]] name = "nix" version = "0.30.1" @@ -1510,6 +2362,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1525,6 +2393,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1532,6 +2410,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "octets" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d59d30d3ad7f7e1c9a66e51c9cb71ae1c794b28ffe7053fd785defb0d180069" + +[[package]] +name = "octseq" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "182eab3e1cd9cdc0ecf1ce3342d9844f3dc7d098f0694569bfdf327b612d69fd" +dependencies = [ + "bytes", + "serde", ] [[package]] @@ -1549,6 +2444,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-macros" version = "0.1.1" @@ -1560,6 +2461,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + [[package]] name = "oslog" version = "0.2.0" @@ -1569,6 +2476,41 @@ dependencies = [ "cc", ] +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "elliptic-curve", + "primefield", + "primeorder", +] + +[[package]] +name = "p384" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" +dependencies = [ + "elliptic-curve", + "fiat-crypto 0.3.0", + "primefield", + "primeorder", +] + +[[package]] +name = "p521" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" +dependencies = [ + "base16ct", + "elliptic-curve", + "primefield", + "primeorder", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1598,6 +2540,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1630,20 +2582,52 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + [[package]] name = "poly1305" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" dependencies = [ - "cpufeatures", - "universal-hash", + "cpufeatures 0.3.1", + "universal-hash 0.6.1", ] [[package]] @@ -1653,8 +2637,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", - "cpufeatures", - "universal-hash", + "cpufeatures 0.3.1", + "universal-hash 0.6.1", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", ] [[package]] @@ -1668,9 +2667,65 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "primefield", + "wnaf", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "proc-macro2" @@ -1681,6 +2736,67 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "psl" +version = "2.1.246" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27d58bd13c235442118460335daf360f0fefaa9035ec4ad957f851d738e7b7d6" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "quiche" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b35a721a2a56797347929745fa783fc99e680d9484b80324ce40106b75493a" +dependencies = [ + "boring", + "boring-sys", + "bytes", + "debug_panic", + "either", + "enum_dispatch", + "foreign-types-shared", + "intrusive-collections", + "libc", + "libm", + "log", + "octets", + "slab", + "smallvec", + "windows-sys 0.59.0", +] + [[package]] name = "quinn" version = "0.11.12" @@ -1693,10 +2809,10 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "socket2", - "thiserror", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -1704,21 +2820,21 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.18" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.3", "rand_pcg", "ring", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -1726,9 +2842,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc", @@ -1747,21 +2863,57 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "chacha20", + "chacha20 0.10.2", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1776,7 +2928,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -1792,6 +2944,15 @@ dependencies = [ "yasna", ] +[[package]] +name = "redb" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39178e0a867f9e3eaa6292a4ab1dd3613f2519c5b564341e1e7b29b270736cc4" +dependencies = [ + "libc", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1846,18 +3007,79 @@ dependencies = [ [[package]] name = "route_manager" -version = "0.2.13" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e06d678f16f191d7232488ef782418aafb953bccfc6c38d28255ed6610c18dce" +checksum = "694798681cd69a10928204225a680ad9416b081d9e9f8718d7a61e2d134da323" dependencies = [ "flume", "libc", - "netlink-packet-core", - "netlink-packet-route 0.31.0", - "netlink-sys", + "netlink-packet-core 0.9.0", + "netlink-packet-route 0.33.0", + "netlink-sys 0.9.0", "windows-sys 0.61.2", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-embed" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19afa5b4b6a611de00bd1bdae6ae6f39084c9399f0679c3f52d8469cf335cc23" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0d8afda6374eac59e066abee06d265247ebbaf3006cf878e2879e8356e34053" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d84e8ba78bd384263e5922f084cbe1b081c3b7e69add59c8fb097b879ba968a" +dependencies = [ + "sha2 0.11.1", + "walkdir", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.3" @@ -1884,9 +3106,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags 2.13.2", "errno", @@ -1897,9 +3119,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "once_cell", "ring", @@ -1909,6 +3131,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -1921,9 +3155,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -1937,28 +3171,127 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] -name = "ryu" -version = "1.0.23" +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + +[[package]] +name = "sealed" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b68e2ea526d9fb32f23ca8894fb5da9e743f34c2f41701f0501dc8a25c4b343" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der 0.8.3", + "hybrid-array", + "subtle", + "zeroize", +] + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] [[package]] -name = "scopeguard" -version = "1.2.0" +name = "security-framework-sys" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] [[package]] -name = "sealed" -version = "0.7.0" +name = "self_cell" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b68e2ea526d9fb32f23ca8894fb5da9e743f34c2f41701f0501dc8a25c4b343" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", + "self_cell 1.3.0", ] +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -1967,9 +3300,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "sendfd" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b183bfd5b1bc64ab0c1ef3ee06b008a9ef1b68a7d3a99ba566fbfe7a7c6d745b" +checksum = "c8fa82bbd0ce6c16273ec6c310d3973f8b8f8665ecfe0ca02a49b3ea61b2418c" dependencies = [ "libc", "tokio", @@ -2002,14 +3335,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -2043,6 +3376,18 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sevenz-rust2" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30146010a60b5773ef63f7b0033576c7a0487ecc87a30d97b787ca8e27c85140" +dependencies = [ + "crc32fast", + "js-sys", + "lzma-rust2", + "wasm-bindgen", +] + [[package]] name = "sha1" version = "0.11.0" @@ -2050,19 +3395,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" -version = "0.11.0" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", ] [[package]] @@ -2072,7 +3445,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2065b026dbe4f47048eca384adf07f693bf901050d80abd3adb7b2422709b2d" dependencies = [ "aes", - "base64", + "base64 0.23.1", "blake3", "byte_string", "bytes", @@ -2084,7 +3457,7 @@ dependencies = [ "lru_time_cache", "percent-encoding", "pin-project", - "rand", + "rand 0.10.3", "sealed", "sendfd", "serde", @@ -2093,7 +3466,7 @@ dependencies = [ "shadowsocks-crypto", "socket2", "spin 0.12.3", - "thiserror", + "thiserror 2.0.21", "tokio", "tokio-tfo", "trait-variant", @@ -2113,8 +3486,8 @@ dependencies = [ "blake3", "bytes", "cfg-if", - "chacha20poly1305", - "rand", + "chacha20poly1305 0.11.0", + "rand 0.10.3", ] [[package]] @@ -2123,7 +3496,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" dependencies = [ - "digest", + "digest 0.11.3", "keccak", "sponge-cursor", ] @@ -2150,6 +3523,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -2170,15 +3553,14 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.1" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" [[package]] name = "smoltcp" version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f8b28ad56c6e35524a37dd492af5d1a47e31e1a4d175cd12f89c075f01980f" +source = "git+https://github.com/smoltcp-rs/smoltcp?rev=24bda3b4f982625d8453d4360172557d24921c76#24bda3b4f982625d8453d4360172557d24921c76" dependencies = [ "bitflags 1.3.2", "byteorder", @@ -2216,11 +3598,24 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "sponge-cursor" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" +dependencies = [ + "zeroize", +] [[package]] name = "stable_deref_trait" @@ -2234,6 +3629,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -2253,9 +3654,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -2281,7 +3682,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2309,22 +3710,42 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ - "thiserror-impl", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2359,11 +3780,12 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -2375,9 +3797,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -2392,8 +3814,8 @@ dependencies = [ [[package]] name = "tokio-boring" -version = "5.2.0" -source = "git+https://github.com/YuanDevTeam/boring?branch=release#43c1c1d5b9464b3f2d5204be8664778fada7dbaf" +version = "5.4.0" +source = "git+https://github.com/YuanDevTeam/boring?rev=c717137e6c794a0b069f64686a375858ffc80818#c717137e6c794a0b069f64686a375858ffc80818" dependencies = [ "boring", "boring-sys", @@ -2408,14 +3830,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "tokio-rustls" -version = "0.26.5" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -2452,9 +3874,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", @@ -2466,6 +3888,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "tracing" version = "0.1.44" @@ -2495,6 +3926,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", ] [[package]] @@ -2516,11 +3948,11 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tun-rs" -version = "2.8.11" +version = "2.8.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19e3459c60bb1cd4a5969fced4299be335b3e3169d8cb3466ffe23da56eeeeb4" +checksum = "280a6cc03289f9254d9268eadd5712c476dbd7a47ef51aae86e8b9d0b45dcd43" dependencies = [ - "byteorder", + "ahash", "bytes", "encoding_rs", "getifaddrs", @@ -2548,9 +3980,29 @@ dependencies = [ "http", "httparse", "log", - "rand", + "rand 0.10.3", "sha1", - "thiserror", + "thiserror 2.0.21", +] + +[[package]] +name = "turboshake" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f892c6904b0bd5a9241eac848347abcbbbd2b9f3892bad23ac3e6efab8d6f06a" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", ] [[package]] @@ -2559,11 +4011,46 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "universal-hash" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] [[package]] name = "universal-hash" @@ -2571,7 +4058,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common", + "crypto-common 0.2.2", "ctutils", ] @@ -2607,73 +4094,117 @@ dependencies = [ [[package]] name = "utf8_iter" -version = "1.0.4" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" [[package]] name = "uuid" -version = "1.26.1" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "js-sys", "serde_core", + "sha1_smol", "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "vole" version = "0.1.0" dependencies = [ "aes", + "age", + "age-core", "arc-swap", "async-trait", - "base64", - "blake2", + "base64 0.23.1", + "bech32 0.12.0", + "blake2 0.11.0", "blake3", "boring", "boring-sys", + "boringtun", + "brotli", "bytes", + "chacha20poly1305 0.11.0", "crc32fast", + "domain", + "fancy-regex", + "flate2", "foreign-types", "futures-util", + "getifaddrs", "h2", "h3", "h3-quinn", + "hostname", "hpke", + "httlib-hpack", "http", "httparse", "hyper", "hyper-util", "idna", "libc", - "log", + "maxminddb", "md-5", "oslog", + "prost", + "psl", + "quiche", "quinn", "quinn-proto", - "rand", + "rand 0.10.3", "rcgen", + "redb", "regex", "regex-syntax", "ring", + "rsa", "rustls", + "rustls-native-certs", "serde", "serde_json", "serde_yaml_ng", - "sha2", + "sevenz-rust2", + "sha2 0.11.1", "shadowsocks", "shake", "socket2", "tempfile", - "thiserror", + "thiserror 2.0.21", + "time", "tokio", "tokio-boring", "tokio-rustls", + "tokio-tfo", "tokio-tungstenite", "tokio-util", "tracing", + "tracing-core", "tun-rs", "url", "uuid", @@ -2683,8 +4214,11 @@ dependencies = [ "windows 0.62.2", "windows-collections 0.3.2", "windows-core 0.62.2", + "x509-parser", + "yaml-rust2", "yamux", "zeroize", + "zstd", ] [[package]] @@ -2703,16 +4237,26 @@ dependencies = [ "bytes", "futures-util", "smoltcp", - "thiserror", + "thiserror 2.0.21", "tokio", "tokio-util", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -2723,14 +4267,22 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" dependencies = [ "cfg-if", - "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", @@ -2738,9 +4290,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2748,22 +4300,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" dependencies = [ "unicode-ident", ] @@ -2780,9 +4332,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.9" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] @@ -2809,6 +4361,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -3005,6 +4566,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -3180,20 +4750,64 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x-wing" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01576e56599d01d75c5efcd5b30fa5de29e776f5a225e43fbf8c92c7302d4c8b" +dependencies = [ + "kem", + "ml-kem", + "sha3", + "shake", + "x25519-dalek 3.0.0", + "zeroize", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "x25519-dalek" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" dependencies = [ - "curve25519-dalek", - "rand_core", + "curve25519-dalek 5.0.0", + "rand_core 0.10.1", "zeroize", ] @@ -3211,10 +4825,20 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror", + "thiserror 2.0.21", "time", ] +[[package]] +name = "yaml-rust2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18" +dependencies = [ + "arraydeque", + "hashlink", +] + [[package]] name = "yamux" version = "0.14.1" @@ -3236,15 +4860,15 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", "time", ] [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3253,16 +4877,36 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -3280,15 +4924,15 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ "zeroize_derive", ] @@ -3317,10 +4961,11 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -3328,13 +4973,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3342,3 +4987,31 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.2+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 0fa6d8d..3d32002 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,121 +30,173 @@ required-features = ["ffi", "windows-uwp"] [features] default = [ + "dns-quic", "inbound-http", "inbound-socks5", + "outbound-http", "outbound-anytls", "outbound-socks5", "outbound-shadowsocks", "outbound-trojan", + "outbound-trusttunnel", + "outbound-mieru", + "outbound-masque", "outbound-vmess", "outbound-vless", "outbound-hysteria2", "outbound-tuic", + "outbound-wireguard", "shadow-tls-v3", + "sing-mux", ] -inbound-http = [] -inbound-socks5 = [] -outbound-anytls = ["dep:md-5", "tls-fingerprint"] +inbound-http = ["dep:rcgen", "rcgen/x509-parser", "dep:rsa", "dep:tokio-tungstenite", "dep:hyper", "dep:hyper-util", "dep:x509-parser"] +inbound-socks5 = ["dep:rcgen", "dep:rsa", "dep:ring", "dep:tokio-tungstenite"] +outbound-anytls = ["dep:md-5", "stream-security", "ech"] outbound-socks5 = [] -outbound-shadowsocks = ["dep:shadowsocks", "dep:log"] -outbound-vless = ["tls-fingerprint", "boring/reality", "boring/jls", "boring/mlkem", "dep:blake3", "dep:hpke", "dep:zeroize", "dep:vole-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"] +outbound-http = [] +outbound-shadowsocks = ["dep:shadowsocks", "dep:blake3", "stream-security", "stream-transport", "ech", "dep:aes", "dep:ring"] +outbound-vless = ["ech", "stream-security", "boring/mlkem", "dep:blake3", "dep:vole-blake3-raw", "stream-transport", "quinn-h3-transport", "dep:hyper", "dep:hyper-util"] +outbound-trojan = ["stream-transport", "stream-security", "ech"] +outbound-trusttunnel = ["stream-transport", "tls-fingerprint", "ech", "quiche-h3-transport"] +outbound-mieru = ["dep:boring", "dep:chacha20poly1305", "dep:hostname", "dep:prost", "dep:ring"] +outbound-masque = ["stream-transport", "quiche-h3-transport", "dep:vole-netstack"] +outbound-vmess = ["stream-transport", "stream-security", "ech", "dep:aes", "dep:md-5", "dep:ring", "dep:shake"] +stream-security = ["tls-fingerprint", "boring/reality", "boring/jls"] tls-fingerprint = ["dep:boring", "dep:tokio-boring"] -shadow-tls-v3 = ["tls-fingerprint", "boring/shadow-tls-v3", "dep:ring", "dep:zeroize"] -outbound-hysteria2 = ["quic-transport", "dep:quinn-proto", "dep:h3", "dep:h3-quinn", "dep:blake2"] +ech = [] +shadow-tls-v3 = ["tls-fingerprint", "boring/shadow-tls-v3", "dep:ring"] +outbound-hysteria2 = ["ech", "quic-transport", "dep:h3", "dep:h3-quinn", "dep:blake2", "dep:hyper", "dep:hyper-util"] outbound-tuic = ["quic-transport"] -stream-transport = ["dep:tokio-tungstenite", "dep:httparse", "dep:log"] -quic-transport = ["dep:quinn"] +sing-mux = ["stream-transport", "dep:yamux", "tokio-util/compat"] +outbound-wireguard = ["dep:boringtun", "dep:vole-netstack"] +stream-transport = ["dep:tokio-tungstenite", "dep:httparse"] +quic-transport = ["dep:quinn", "dep:quinn-proto"] +# Exposes h3::ConnectionState for connection error/closing checks before reuse. +# h3-quinn does not enable this feature on Vole's behalf. +quinn-h3-transport = ["quic-transport", "dep:h3", "dep:h3-quinn", "h3/i-implement-a-third-party-backend-and-opt-into-breaking-changes"] +quiche-h3-transport = ["quic-transport", "dep:quiche", "dep:boring"] +dns-quic = ["quinn-h3-transport"] # Enables local interoperability-only trust-root injection. Release build # scripts must not enable this feature. interop-test = ["outbound-anytls", "outbound-vless"] -# Allows plaintext GeoData fixtures for isolated benchmarks, never production. -benchmark-geodata-http = [] -invoke = ["tun", "tokio/rt-multi-thread", "tokio/signal"] +invoke = ["tun", "dns-quic", "tokio/rt-multi-thread", "tokio/signal"] ffi = ["invoke"] cli = ["invoke"] -windows-wintun = ["tun", "dep:tun-rs"] +windows-wintun = ["tun", "dep:tun-rs", "dep:windows"] windows-uwp = ["invoke", "dep:windows", "dep:windows-collections", "dep:windows-core"] tun = [ - "dep:vole-netstack", "inbound-http", "inbound-socks5", "outbound-anytls", + "outbound-http", "outbound-socks5", "outbound-shadowsocks", "outbound-trojan", + "outbound-trusttunnel", + "outbound-mieru", + "outbound-masque", "outbound-vmess", "outbound-vless", "outbound-hysteria2", "outbound-tuic", + "outbound-wireguard", "shadow-tls-v3", + "sing-mux", ] [dependencies] -aes = { version = "0.9.3", optional = true, features = ["zeroize"] } +age = { version = "=0.11.5", default-features = false, features = ["armor"] } +age-core = "=0.11.0" +aes = { version = "0.9.4", optional = true, features = ["zeroize"] } arc-swap = "1" async-trait = "0.1" base64 = "0.23" +bech32 = "=0.12.0" blake2 = { version = "=0.11.0", optional = true } blake3 = { version = "=1.8.7", optional = true } -boring = { git = "https://github.com/YuanDevTeam/boring", branch = "release", version = "=5.2.0", features = ["client-fingerprint"], optional = true } +boringtun = { version = "=0.7.1", default-features = false, optional = true } +boring = { git = "https://github.com/YuanDevTeam/boring", rev = "c717137e6c794a0b069f64686a375858ffc80818", version = "=5.4.0", features = ["client-fingerprint"], optional = true } bytes = "1" -crc32fast = { version = "1.5.2", optional = true } +chacha20poly1305 = { version = "=0.11.0", default-features = false, features = ["zeroize"], optional = true } +crc32fast = "=1.5.4" +domain = { version = "=0.12.3", default-features = false, features = ["std"] } +fancy-regex = { version = "=0.20.0", default-features = false, features = ["std", "unicode"] } +flate2 = { version = "=1.1.10", default-features = false, features = ["rust_backend"] } +brotli = { version = "=9.1.1", default-features = false, features = ["std"] } futures-util = { version = "0.3", features = ["sink"] } -h2 = "0.4.19" +getifaddrs = "=0.6.2" +h2 = "0.4.20" 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 } +hpke = { version = "=0.15.1", default-features = false, features = ["alloc", "aes", "chacha", "x25519", "nistp", "mlkem", "shake"] } +hostname = { version = "=0.4.2", default-features = false, 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 } +hyper = { version = "=1.12.0", default-features = false, features = ["client", "http1"], optional = true } +hyper-util = { version = "=0.1.21", default-features = false, features = ["tokio"], optional = true } httparse = { version = "1.10", optional = true } +httlib-hpack = "=0.1.3" 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; Vole uses tracing. -log = { version = "0.4", optional = true, features = ["max_level_off", "release_max_level_off"] } md-5 = { version = "0.11", optional = true } +maxminddb = { version = "=0.32.0", default-features = false } 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 } +quinn-proto = { version = "=0.11.19", default-features = false, features = ["rustls-ring"], optional = true } +quiche = { version = "=0.30.0", default-features = false, features = ["boringssl-boring-crate"], optional = true } rand = "0.10" +rcgen = { version = "=0.14.10", default-features = false, features = ["crypto", "ring"], optional = true } +rsa = { version = "=0.9.10", default-features = false, features = ["std"], optional = true } ring = { version = "0.17.14", optional = true } +redb = "=4.4.0" +psl = "=2.1.246" +prost = { version = "=0.14.4", default-features = false, features = ["derive", "std"], optional = true } regex = "1.13" regex-syntax = { version = "0.8", default-features = false, features = ["std", "unicode-case"] } -rustls = { version = "=0.23.45", default-features = false, features = ["ring", "std", "tls12"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" +rustls = { version = "=0.23.46", default-features = false, features = ["ring", "std", "tls12"] } +rustls-native-certs = "=0.8.5" +serde = { version = "1", features = ["derive", "rc"] } +serde_json = { version = "1", features = ["raw_value"] } serde_yaml_ng = "0.10" +yaml-rust2 = { version = "=0.13.0", default-features = false } +sevenz-rust2 = { version = "=0.24.2", default-features = false } 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" +time = { version = "=0.3.55", features = ["formatting"] } tokio = { version = "1", features = ["io-util", "macros", "net", "rt", "sync", "time"] } -tokio-boring = { git = "https://github.com/YuanDevTeam/boring", branch = "release", version = "=5.2.0", optional = true } -tokio-rustls = { version = "=0.26.5", default-features = false, features = ["ring", "tls12"] } +tokio-boring = { git = "https://github.com/YuanDevTeam/boring", rev = "c717137e6c794a0b069f64686a375858ffc80818", version = "=5.4.0", optional = true } +tokio-rustls = { version = "=0.26.6", 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" +tracing-core = "0.1.36" url = "2" -uuid = { version = "1", features = ["serde"] } +uuid = { version = "=1.29.0", features = ["serde", "v5"] } vole-netstack = { path = "crates/vole-netstack", optional = true } vole-blake3-raw = { path = "crates/vole-blake3-raw", optional = true } webpki-roots = "1" +x509-parser = { version = "=0.18.1", default-features = false, optional = true } yamux = { version = "=0.14.1", optional = true } -zeroize = { version = "=1.9.0", optional = true } +zeroize = { version = "=1.9.1" } +zstd = { version = "0.14.3", default-features = false } [target.'cfg(any(target_os = "ios", target_os = "tvos", target_os = "macos"))'.dependencies] oslog = { version = "0.2", default-features = false } +[target.'cfg(target_os = "linux")'.dependencies] +# Public socket adapter; shared Dialer retains protection, deadlines and Stop. +tokio-tfo = "=0.4.3" +# Core-owned Linux TUN setup uses tun-rs's opt-in address management API. +tun-rs = { version = "=2.8.16", default-features = false, features = ["address-management"] } + [target.'cfg(unix)'.dependencies] # Keep one Cargo dependency name across target tables. Unix compiles this small # adapter dependency; platform I/O remains gated by the tun feature. -tun-rs = { version = "=2.8.11", default-features = false } +tun-rs = { version = "=2.8.16", default-features = false } [target.'cfg(windows)'.dependencies] -tun-rs = { version = "=2.8.11", default-features = false, optional = true, features = ["interruptible"] } +tun-rs = { version = "=2.8.16", default-features = false, optional = true, features = ["interruptible"] } windows = { version = "=0.62.2", optional = true, features = [ "ApplicationModel_Background", "ApplicationModel_Core", @@ -163,6 +215,7 @@ windows = { version = "=0.62.2", optional = true, features = [ "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_Security", + "Win32_Security_Cryptography", "Win32_Security_Isolation", "Win32_System_Com", "Win32_System_JobObjects", @@ -177,8 +230,11 @@ windows-collections = { version = "=0.3.2", optional = true } windows-core = { version = "=0.62.2", optional = true } [dev-dependencies] +h3 = { version = "=0.0.8", default-features = false } +h3-quinn = { version = "=0.0.10", default-features = false } +sevenz-rust2 = { version = "=0.24.2", default-features = false, features = ["compress"] } # Native peer controls for bounded in-memory ALPS tests; same source as boring. -boring-sys = { git = "https://github.com/YuanDevTeam/boring", branch = "release", version = "=5.2.0" } +boring-sys = { git = "https://github.com/YuanDevTeam/boring", rev = "c717137e6c794a0b069f64686a375858ffc80818", version = "=5.4.0" } foreign-types = "=0.5.0" tokio = { version = "1", features = ["test-util"] } # Independent EIH relay fixture only; production crypto remains the official SS crate. @@ -208,3 +264,8 @@ unsafe_op_in_unsafe_fn = "deny" [lints.clippy] all = "warn" pedantic = "allow" + +[patch.crates-io] +# quiche and Vole share the same approved Boring commit. +boring = { git = "https://github.com/YuanDevTeam/boring", rev = "c717137e6c794a0b069f64686a375858ffc80818" } +boring-sys = { git = "https://github.com/YuanDevTeam/boring", rev = "c717137e6c794a0b069f64686a375858ffc80818" } diff --git a/README.md b/README.md index d47c58e..67538be 100644 --- a/README.md +++ b/README.md @@ -6,36 +6,46 @@ Vole is a Rust proxy core for VPN clients and local proxies, available as native libraries and a foreground CLI. It routes TCP/UDP traffic through direct connections, proxy nodes, groups, and chains, with integrated DNS, GeoData, and a cross-platform TUN data plane. -Configuration uses **Mihomo-compatible YAML for the supported feature set**. Vole focuses on client-side capabilities rather than implementing every Mihomo field or its complete Dashboard API. +Configuration uses **Mihomo-style YAML for the supported feature set**. Vole prioritizes memory efficiency and throughput. Compatibility targets interoperability with Mihomo listeners and official protocol servers, without requiring identical behavior for every field; see the [compatibility principles](docs/mihomo-compatibility.md#兼容原则). ## What Vole can do - **Accept application and VPN traffic:** HTTP forwarding, CONNECT and Upgrade; SOCKS5 CONNECT and UDP ASSOCIATE; host-provided IPv4/IPv6 TUN packets. -- **Route by destination:** domain, domain suffix/keyword, IP CIDR, destination port, TCP/UDP, GeoSite and GeoIP rules, with explicit DIRECT and REJECT actions. -- **Choose and chain proxies:** nested `select` groups, live group selection, and `dialer-proxy` chains that can reference nodes or groups. Selection changes apply to new physical transports without moving existing connections. -- **Handle DNS:** controlled UDP/TCP nameservers, GeoSite-based nameserver policies, sequential failover, caching and duplicate-query coalescing; intercept configured TCP/UDP TUN DNS targets (port 53 by default). -- **Identify traffic for routing:** HTTP, TLS and QUIC domain sniffing, plus TUN DNS hints, without rewriting the actual destination. ICMPv4/ICMPv6 Echo is answered locally. -- **Manage routing assets:** load referenced categories from `geosite.dat` / `geoip.dat` on demand and update them through the configured route. GeoSite supports Domain, Full, Plain and Regex records, attribute intersections and inverted selectors; GeoIP supports inverted selectors. All platforms retain selected records without a count cap or truncation; GeoData has no total memory quota. See [GeoData boundaries](docs/geodata.md#内存与安全边界). -- **Expose client controls:** a loopback Controller for group selection and TUN traffic rates/totals, plus isolated node/chain delay measurement through Invoke API. +- **Route traffic:** rule/global/direct modes; domain, source/destination IP and port, TCP/UDP, GeoSite/GeoIP, rule providers and logical rules; process/UID rules on supported platforms; named direct/dns/reject outbounds and built-in routing actions. +- **Choose and chain proxies:** nested select/url-test/fallback/load-balance groups, health checks and persistent selection; `dialer-proxy` chains can reference nodes or groups. Existing connections retain their selected path. +- **Handle DNS:** hosts, separate bootstrap/node/direct resolvers, UDP/TCP/DoH/DoT/DoQ, domain/GeoSite/rule-set policies, fallback, bounded caching, redir-host and persistent fake-IP mappings; intercept configured TUN DNS targets. +- **Identify traffic for routing:** HTTP/1, HTTP/2 and TLS sniffing for TUN and mixed TCP; QUIC v1/v2 sniffing for TUN and SOCKS5 UDP associations, with force/skip conditions and configurable destination override. ICMPv4/ICMPv6 Echo is answered locally. +- **Manage routing assets:** HTTP/file/inline proxy and rule providers, URI/Base64/AGE subscriptions, dynamic groups, filters and overrides; YAML/Text and domain/ipcidr MRS. GeoData loads complete referenced categories and supports routed downloads and periodic updates. Failed updates retain usable state. See [GeoData boundaries](docs/geodata.md#内存与安全边界). +- **Expose client controls:** group/resource state, connection snapshots and cancellation, rule counters, effective configuration, bounded WebSocket streams and DNS diagnostics; supported configuration updates, TUN traffic metrics and isolated node/chain delay measurement. See the [Controller contract](docs/controller-api.md) for update boundaries. +- **Process selected business HTTP:** URL rewrites and redirects, with optional host-supplied CA interception for explicitly allowed HTTPS domains and bounded H1/H2 streaming. This uses a [separate HTTP configuration](docs/http-processing.md). ## Proxy protocols -All eight protocols below are enabled in the default build. UDP support is configured per node. +The default build enables the protocols below. UDP support depends on the protocol mode and node configuration. | Protocol | Capabilities | | --- | --- | -| [VLESS](docs/vless.md) | TCP, WebSocket / HTTPUpgrade, gRPC, HTTP first-packet camouflage, legacy H2, [XHTTP H1/H2/H3](docs/xhttp.md); Vision, Encryption, REALITY, JLS, static ECH and sing-mux in supported combinations | -| [VMess AEAD](docs/outbounds.md#vmess-aead) | TCP, WebSocket / HTTPUpgrade, gRPC, HTTP first-packet camouflage and legacy H2; TCP/UDP, plaintext or standard TLS | +| [VLESS](docs/vless.md) | TCP, WebSocket / HTTPUpgrade, gRPC, HTTP first-packet camouflage, legacy H2, [XHTTP H1/H2/H3](docs/xhttp.md); Vision, Encryption, REALITY, JLS and ECH in supported combinations | +| [VMess AEAD](docs/outbounds.md#vmess-aead) | TCP, WebSocket / HTTPUpgrade, gRPC, HTTP first-packet camouflage and legacy H2; TCP/UDP, plaintext or supported TLS modes | | [Trojan](docs/outbounds.md#trojan) | TCP/UDP over TLS TCP, WebSocket / HTTPUpgrade or gRPC | -| [Shadowsocks 2022](docs/outbounds.md#shadowsocks-2022) | TCP/UDP; AES-128-GCM, AES-256-GCM and ChaCha20-Poly1305; AES identity chains; optional strict ShadowTLS v3 TCP wrapping and UoT v2 | +| [Shadowsocks 2022](docs/outbounds.md#shadowsocks-2022) | TCP/UDP; three 2022 ciphers and AES identity chains; optional obfs, WebSocket, JLS or strict ShadowTLS v3 plugins; UoT v2 | | [AnyTLS](docs/outbounds.md#anytls) | TLS sessions and UDP over TCP v2 | +| [HTTP / HTTPS CONNECT](docs/outbounds.md#http--https-connect) | TCP CONNECT, optional proxy authentication and TLS | | [SOCKS5](docs/outbounds.md#socks5) | CONNECT and UDP ASSOCIATE, with optional username/password authentication | -| [Hysteria2](docs/outbounds.md#hysteria2) | QUIC TCP/UDP, bandwidth control, Salamander, port hopping and mTLS | -| [TUIC v5](docs/outbounds.md#tuic-v5) | QUIC TCP/UDP, native/quic UDP relay modes and selectable congestion control | +| [Hysteria2](docs/outbounds.md#hysteria2) | QUIC TCP/UDP, bandwidth control, Salamander/Gecko, port hopping or Realm/STUN, and mTLS | +| [TUIC v5](docs/outbounds.md#tuic-v5) | QUIC TCP/UDP, native/quic UDP relay modes, UoT v2, selectable congestion control and `disable-sni` with certificate verification retained | +| [WireGuard](docs/outbounds.md#wireguard) | TCP/UDP over standard WireGuard, multiple peers and node DNS, with a user-space IP stack | +| [TrustTunnel](docs/outbounds.md#trusttunnel) | TCP/UDP over H2 or H3 CONNECT; authentication, health checks, mTLS and ECH | +| [Mieru](docs/outbounds.md#mieru) | TCP/UDP over TCP or UDP carriers; multiplexing, STANDARD/NO-WAIT handshakes and traffic patterns | +| [MASQUE](docs/outbounds.md#masque) | TCP/UDP over H2/H3 CONNECT-IP; TCP-only H3 L4 proxy mode | -Standard TLS connections support certificate verification and SHA-256 certificate pins. Applicable TCP TLS paths can use Chrome, Firefox or Safari ClientHello templates; `client-fingerprint` is independent of the certificate `fingerprint`. See [TLS profiles and certificate policy](docs/tls-client-fingerprint.md) for the exact names and combinations. These features do not promise indistinguishability from a browser or unrestricted protocol combinations. +The shared [sing-mux wrapper](docs/xhttp.md#通用-sing-mux) supports all enabled node protocols. It provides TCP and, with `only-tcp: false`, UDP over the node's ordinary TCP path. The peer must provide sing-mux decoding; a normal HTTP/SOCKS5 listener does not automatically decode it. -SS2022 uses the unmodified official Rust library; its known empty-first-write/server-first and padding risks remain documented in the [Shadowsocks contract](docs/outbounds.md#shadowsocks-2022). +VMess, VLESS, Trojan and AnyTLS support ShadowTLS v3 or JLS on their supported TCP carriers; VMess and Trojan also support REALITY. These modes follow the [shared stream security constraints](docs/outbounds.md#流式安全身份), including transport exclusions and mutually exclusive identities. + +Standard TLS connections support certificate verification and SHA-256 certificate pins. Applicable TCP TLS paths can use Chrome, Firefox, Safari or iOS ClientHello templates; `client-fingerprint` is independent of the certificate `fingerprint`. See [TLS profiles and certificate policy](docs/tls-client-fingerprint.md) for the exact names and combinations. These features do not promise indistinguishability from a browser or unrestricted protocol combinations. + +SS2022 uses the unmodified official Rust crypto and codecs with Vole-owned initialized padding and server-first handling. See the [Shadowsocks contract](docs/outbounds.md#shadowsocks-2022). ## Mihomo-style configuration @@ -70,13 +80,13 @@ rules: - MATCH,Proxy ``` -Replace the example endpoint and credentials. GeoSite/GeoIP rules require the corresponding assets under `/geodata`; missing assets leave those rule types unavailable. See the [complete configuration reference](docs/config.yaml) and [GeoData behavior](docs/geodata.md). +Replace the example endpoint and credentials. GeoSite/DAT/ASN assets use `/geodata`; the default GeoIP mode uses a country MMDB in `` (`geodata-mode: true` selects DAT); missing required assets are downloaded through the configured route, and affected rules remain unavailable until loading succeeds. See the [complete configuration reference](docs/config.yaml) and [GeoData behavior](docs/geodata.md). `mixed-port` shares one TCP port between HTTP and SOCKS5 and enables SOCKS5 UDP on the same port. `allow-lan` controls binding independently of `authentication`: omitting `authentication` or using `[]` allows unauthenticated access on either loopback or wildcard addresses; configured credentials are checked by both HTTP and SOCKS5. Top-level `port`, `socks-port`, `udp` and `listeners` are rejected; proxy-node `port` and `udp` keep their outbound meanings. -Compatibility is scoped to documented fields and behavior, not arbitrary Mihomo configurations. Groups currently support static `select`; DNS nameservers use literal IPs over UDP/TCP. Providers, automatic group selection, encrypted DNS and fake-IP are outside the current feature set. Unknown fields and invalid combinations are rejected rather than silently ignored; Vole-specific semantics are called out in the relevant contracts. +Compatibility covers the documented subset of Mihomo fields. Unknown fields and unsupported combinations are rejected. See [current compatibility scope](docs/mihomo-compatibility.md) and the individual contracts for platform and protocol limits. -Library hosts start an instance with inline `configYaml`; TUN device, file descriptor, MTU, DNS interception and UDP timeout belong to the `tun` configuration. Platform callbacks remain runtime-local. The CLI sends options through the same Invoke API, whose foreground operation reads the file selected by `-f`. Vole leaves interface addresses, DNS and system routes to the host. +Library hosts start an instance with inline `configYaml`; TUN device, file descriptor, MTU, DNS interception and UDP timeout belong to the `tun` configuration. Platform callbacks remain runtime-local. The CLI sends options through the same Invoke API, whose foreground operation reads the file selected by `-f`. Linux core-owned TUN can configure automatic routing and systemd-resolved DNS; borrowed descriptors and App platform networks remain host-owned. ## CLI @@ -97,12 +107,12 @@ vole -t -f ./config.yaml | iOS / macOS | Host-provided utun file descriptor; macOS can also open/create a native utun | | tvOS 17+ | Host-provided utun file descriptor; ARM64 device and simulator targets | | Android | `VpnService` file descriptor with outbound socket protection | -| Linux | Real single-queue raw-IP TUN from a borrowed fd or a core-opened device; the host owns system network configuration | +| Linux | Real single-queue raw-IP TUN from a borrowed fd or a core-opened device; core-owned Linux TUN supports automatic routes and system DNS | | Windows | Mutually exclusive `windows-wintun` desktop and `windows-uwp` packaged Provider/Session Host builds sharing the core | -Vole provides native libraries and a foreground CLI; platform hosts still own system network configuration. Unix hosts own the original TUN descriptor; Vole uses and closes its own duplicate. Apple's public packetFlow API does not guarantee raw-fd access, so actual Network Extension integration and device validation remain host responsibilities. See [TUN integration](docs/tun-platform.md) and [platform acceptance boundaries](docs/acceptance.md). +Vole provides native libraries and a foreground CLI. Apple, Android and Windows hosts own system network configuration and application lifecycle; Linux core-owned TUN additionally supports automatic routes and system DNS. Unix hosts own the original TUN descriptor; Vole uses and closes its own duplicate. Apple's public packetFlow API does not guarantee raw-fd access, so actual Network Extension integration and device validation remain host responsibilities. See [TUN integration](docs/tun-platform.md) and [platform acceptance boundaries](docs/acceptance.md). -Desktop Wintun loads a host-provided `wintun.dll` from the process executable's directory. The host configures interface addresses, DNS, routes and physical egress; Windows Wintun device validation is separate from the existing packaged VPN results. See [CLI usage and tag releases](docs/cli.md) for command-line delivery. +Desktop Wintun loads a host-provided `wintun.dll` from the process executable's directory. The host configures interface addresses, DNS, routes and physical egress; Windows Wintun and packaged UWP require separate device validation. See [CLI usage and tag releases](docs/cli.md) for command-line delivery. The cross-platform C ABI accepts JSON requests through Invoke API: @@ -113,19 +123,11 @@ void VoleFree(char *response); One public instance follows `initialize → createInstance → start(configYaml) → stop → destroyInstance`; preparation is internal to `start`. `validateConfig` requires no initialization. The CLI uses the explicit foreground Invoke operation for files, environment defaults, signals and cleanup. The API also provides state queries, GeoData status and delay measurement. See [Invoke API](docs/invoke-api.md), [Controller API](docs/controller-api.md) and the [Windows integration example](example/windows-uwp/README.md). -## Benchmark - -[**Vole / Mihomo TUN benchmark**](https://github.com/YuanDevTeam/container-benchmark) contains the reproducible setup, measured results and comparison charts for both cores under the same native Linux TUN environment. - -The benchmark project also owns protocol interoperability (`interop`) and memory-pressure runs (`stress`), with an explicit `--source vole=PATH` checkout. Vole's own scripts only compile core and platform artifacts. - -Workloads, input assets and measured results are maintained in the benchmark project. -See [acceptance boundaries](docs/acceptance.md) for the scope of build, network and device validation. - ## Documentation - [Documentation index](docs/README.md) - [Configuration reference](docs/config.yaml) +- [Mihomo compatibility scope](docs/mihomo-compatibility.md) - [Core and platform builds](scripts/README.md) - [CLI usage and tag releases](docs/cli.md) - [Core regression tests](tests/README.md) @@ -137,7 +139,11 @@ See [acceptance boundaries](docs/acceptance.md) for the scope of build, network Vole builds on and learns from public dependencies, protocol implementations and platform references: - TUN dependencies: the local [`vole-netstack`](crates/vole-netstack/README.md) uses [smoltcp](https://github.com/smoltcp-rs/smoltcp); Unix and Windows Wintun packet I/O use [tun-rs](https://github.com/tun-rs/tun-rs) (Apache-2.0). +- Provider case mapping uses Rust's standard Unicode case conversion through a bounded adapter. +- WireGuard: [boringtun](https://github.com/cloudflare/boringtun) (BSD-3-Clause), without host-device, FFI or JNI features. - [Wintun](https://www.wintun.net/): the runtime DLL is supplied by the host, not bundled. The upstream [API header](https://github.com/tun-rs/tun-rs/blob/2.8.11/src/platform/windows/tun/wintun.h) included by tun-rs is copyright 2018–2021 WireGuard LLC, licensed `GPL-2.0 OR MIT`; distribution of the linked API bindings must retain its copyright and MIT alternative license notice. +- Offline archives: [sevenz-rust2](https://github.com/hasenbanck/sevenz-rust2) and [lzma-rust2](https://github.com/hasenbanck/lzma-rust2) (Apache-2.0), used through a bounded member reader. +- Name expressions: [fancy-regex](https://github.com/fancy-regex/fancy-regex) (MIT), used only for bounded provider/group lookaround and backreferences. - Networking and routing references: [clash-rs](https://github.com/Watfaq/clash-rs), [netstack-smoltcp](https://github.com/cavivie/netstack-smoltcp), [Mihomo](https://github.com/MetaCubeX/mihomo), [Xray-core](https://github.com/XTLS/Xray-core) and [Leaf](https://github.com/eycorsican/leaf). These reference projects are not netstack dependencies. - TLS and Shadowsocks: [rustls](https://github.com/rustls/rustls), [boring](https://github.com/cloudflare/boring), [BoringSSL](https://boringssl.googlesource.com/boringssl/) and [shadowsocks-rust](https://github.com/shadowsocks/shadowsocks-rust). Derived replay-window code retains its [MIT notices](src/outbound/shadowsocks/packet_window.rs). - Windows integration: [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). diff --git a/crates/vole-blake3-raw/Cargo.toml b/crates/vole-blake3-raw/Cargo.toml index 9ae31fc..3680e59 100644 --- a/crates/vole-blake3-raw/Cargo.toml +++ b/crates/vole-blake3-raw/Cargo.toml @@ -4,9 +4,10 @@ version = "0.1.0" edition = "2024" license = "MIT" publish = false +links = "blake3" [build-dependencies] -cc = "=1.5.1" +cc = "=1.11.0" [dev-dependencies] blake3 = "=1.8.7" diff --git a/crates/vole-blake3-raw/README.md b/crates/vole-blake3-raw/README.md index c66357b..cac3c36 100644 --- a/crates/vole-blake3-raw/README.md +++ b/crates/vole-blake3-raw/README.md @@ -4,57 +4,70 @@ 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. Vole-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. +its hasher with volatile stores; it cannot guarantee erasure of upstream +internal temporaries. -## Check +The official Rust 1.8.7 derive API takes `&str`. Arbitrary binary protocol +contexts may contain invalid UTF-8, so conversion to UTF-8, hex or base64 would +change the derivation. NUL itself is valid UTF-8 and can occur in a Rust string. +The official C `blake3_hasher_init_derive_key_raw` accepts a pointer and length. +Rust does not mirror the C hasher layout or implement a compression/KDF algorithm. + +This reproduces an existing protocol. BLAKE3's authors recommend a fixed, +globally unique application-specific context; wire compatibility alone does +not establish the security of VLESS Encryption's protocol design. + +## Dependency and build + +The implementation is official [BLAKE3 1.8.7](https://github.com/BLAKE3-team/BLAKE3/tree/1.8.7/c), +installed as a static library through the official +[vcpkg port](https://github.com/microsoft/vcpkg/tree/e456309491fd875487bf02b8a0dca76c1b1b00cc/ports/blake3). +[The manifest](../../scripts/native/blake3/vcpkg.json) locks the registry commit +and exact port version. vcpkg manages release acquisition, checksum verification, +its official Windows ARM patch and upstream license texts in the dependency cache. +Vole-owned adapter/build/Rust code and package configuration are MIT. + +There is no bundled BLAKE3 source. `build.rs` compiles only `src/derive.c` and +links the supplied static archive. It never downloads dependencies or searches +Cargo source caches. Platform build scripts prepare the dependency automatically; +see [build instructions](../../scripts/README.md). + +The Vole-owned package triplet sets compile-time `vole_c_blake3_*` names for all +C symbols, keeping the official source and port unchanged. This prevents +collisions with the official Rust crate's native SIMD callbacks in the same +Core or test executable. ARM retains official NEON; x86 uses the official C +intrinsics mode so compiler macros also rename SIMD exports. Package upgrades +require reviewing the upstream symbol set and rerunning vectors and target builds. + +For direct Cargo use, provide absolute public include and static library directories: ```sh +export VOLE_BLAKE3_INCLUDE_DIR=/absolute/native-package/include +export VOLE_BLAKE3_LIB_DIR=/absolute/native-package/lib cargo test --locked -p vole-blake3-raw ``` -This checks a primitive, not the Encryption handshake, ticket authentication, -runtime ownership, platform integration, or end-to-end Encryption acceptance. +Native builds accept those two variables. Cross builds require target-specific +pairs such as `VOLE_BLAKE3_INCLUDE_DIR_AARCH64_APPLE_IOS` and +`VOLE_BLAKE3_LIB_DIR_AARCH64_APPLE_IOS`; target-specific paths take precedence. +The archive must be the matching namespaced static package, built for the same +architecture, SDK/API and CRT; Unix shared-library consumers require PIC. +The public header must declare version 1.8.7. Missing paths, a wrong header +version or a missing archive fail closed. Cargo declares this native identity +with `links = "blake3"`. + +## Check + +The four memory-only tests verify the linked C library version, arbitrary binary +contexts, independent vectors and coexistence with the official Rust crate. +They check a primitive; Encryption handshake, ticket authentication, runtime +ownership and platform behavior need their own tests. -The binary-context fixture has 52 values generated with the independent +The 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. +The generator and Go module lock are in `tests/generator/`; `go run .` reproduces +the JSON on stdout and is not part of the Rust build. Inputs contain invalid +UTF-8 and NUL and cover 64/1024-byte boundaries and wire contexts up to 17005 +bytes. UTF-8 cases also compare the official Rust derive interface in the same +test executable. diff --git a/crates/vole-blake3-raw/build.rs b/crates/vole-blake3-raw/build.rs index 7d36ec4..36a8ad0 100644 --- a/crates/vole-blake3-raw/build.rs +++ b/crates/vole-blake3-raw/build.rs @@ -1,46 +1,76 @@ +use std::{env, fs, path::PathBuf}; + +const VERSION: &str = "1.8.7"; + +fn dependency_dir(name: &str, target: &str, host: &str) -> PathBuf { + let scoped = format!("{name}_{}", target.to_uppercase().replace('-', "_")); + println!("cargo:rerun-if-env-changed={scoped}"); + println!("cargo:rerun-if-env-changed={name}"); + let value = env::var_os(&scoped) + .or_else(|| (target == host).then(|| env::var_os(name)).flatten()) + .unwrap_or_else(|| { + panic!( + "set {scoped} to the absolute directory of the target's installed official BLAKE3 {VERSION} static package; native builds also accept {name}" + ) + }); + let path = PathBuf::from(value); + assert!(path.is_absolute(), "{scoped} must be an absolute path"); + assert!(path.is_dir(), "{scoped} must name an existing directory"); + // Preserve the native path spelling for MSVC's include and linker flags. + // Windows canonicalization adds a verbatim prefix that cl.exe rejects. + path +} + fn main() { - let mut build = cc::Build::new(); - build - .include("vendor") + let target = env::var("TARGET").expect("Cargo TARGET"); + let host = env::var("HOST").expect("Cargo HOST"); + let include = dependency_dir("VOLE_BLAKE3_INCLUDE_DIR", &target, &host); + let lib = dependency_dir("VOLE_BLAKE3_LIB_DIR", &target, &host); + let header = include.join("blake3.h"); + let header_text = fs::read_to_string(&header).expect("read installed public blake3.h"); + let expected_version = format!("\"{VERSION}\""); + assert!( + header_text.lines().any(|line| { + let mut fields = line.split_whitespace(); + fields.next() == Some("#define") + && fields.next() == Some("BLAKE3_VERSION_STRING") + && fields.next() == Some(expected_version.as_str()) + }), + "installed blake3.h must declare BLAKE3_VERSION_STRING {VERSION}" + ); + let archive_name = if target.ends_with("-msvc") { + "blake3.lib" + } else { + "libblake3.a" + }; + let archive = lib.join(archive_name); + assert!( + archive.is_file(), + "missing static BLAKE3 archive {archive_name}" + ); + + // Only this Vole-owned adapter is compiled here. Package management owns + // the public header and archive; this build never discovers or downloads C. + let mut adapter = cc::Build::new(); + adapter + .include(&include) .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. + // Match the external package's compile-time namespace without changing + // upstream's public header or implementation. This is required when the + // official Rust crate also contributes its native SIMD callbacks. 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!("vole_raw_{symbol}").as_str()); + adapter.define(symbol, format!("vole_c_{symbol}").as_str()); } - build.compile("vole_blake3_raw"); + adapter.compile("vole_blake3_raw"); + println!("cargo:rustc-link-search=native={}", lib.display()); + println!("cargo:rustc-link-lib=static=blake3"); println!("cargo:rerun-if-changed=src/derive.c"); - println!("cargo:rerun-if-changed=vendor"); + println!("cargo:rerun-if-changed={}", header.display()); + println!("cargo:rerun-if-changed={}", archive.display()); } diff --git a/crates/vole-blake3-raw/src/derive.c b/crates/vole-blake3-raw/src/derive.c index c335590..a220057 100644 --- a/crates/vole-blake3-raw/src/derive.c +++ b/crates/vole-blake3-raw/src/derive.c @@ -1,4 +1,4 @@ -/* Vole-owned one-shot adapter. The BLAKE3 implementation is unmodified. */ +/* Vole-owned one-shot adapter to the installed official BLAKE3 C API. */ #include "blake3.h" void vole_blake3_raw_derive(const unsigned char *context, size_t context_len, diff --git a/crates/vole-blake3-raw/tests/derive.rs b/crates/vole-blake3-raw/tests/derive.rs index ecbfef0..de55ec6 100644 --- a/crates/vole-blake3-raw/tests/derive.rs +++ b/crates/vole-blake3-raw/tests/derive.rs @@ -1,5 +1,16 @@ use vole_blake3_raw::derive_key; +#[test] +fn links_the_required_official_c_library_version() { + unsafe extern "C" { + #[link_name = "vole_c_blake3_version"] + fn blake3_version() -> *const std::ffi::c_char; + } + // SAFETY: the public API returns a static NUL-terminated version string. + let version = unsafe { std::ffi::CStr::from_ptr(blake3_version()) }; + assert_eq!(version.to_bytes(), b"1.8.7"); +} + #[test] fn derives_binary_context_without_utf8_conversion() { // Independent github.com/metacubex/blake3 v0.1.0 DeriveKey result. diff --git a/crates/vole-blake3-raw/vendor/.gitattributes b/crates/vole-blake3-raw/vendor/.gitattributes deleted file mode 100644 index 9852d38..0000000 --- a/crates/vole-blake3-raw/vendor/.gitattributes +++ /dev/null @@ -1,3 +0,0 @@ -# Preserve the official release bytes, including upstream whitespace. -blake3_impl.h whitespace=-blank-at-eol -LICENSE_A2LLVM whitespace=-blank-at-eof diff --git a/crates/vole-blake3-raw/vendor/LICENSE_A2 b/crates/vole-blake3-raw/vendor/LICENSE_A2 deleted file mode 100644 index d512ca9..0000000 --- a/crates/vole-blake3-raw/vendor/LICENSE_A2 +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2019 Jack O'Connor and Samuel Neves - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/crates/vole-blake3-raw/vendor/LICENSE_A2LLVM b/crates/vole-blake3-raw/vendor/LICENSE_A2LLVM deleted file mode 100644 index 8d11bec..0000000 --- a/crates/vole-blake3-raw/vendor/LICENSE_A2LLVM +++ /dev/null @@ -1,219 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2019 Jack O'Connor and Samuel Neves - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - - ----- LLVM Exceptions to the Apache 2.0 License ---- - -As an exception, if, as a result of your compiling your source code, portions -of this Software are embedded into an Object form of such source code, you -may redistribute such embedded portions in such Object form without complying -with the conditions of Sections 4(a), 4(b) and 4(d) of the License. - -In addition, if you combine or link compiled forms of this Software with -software that is licensed under the GPLv2 ("Combined Software") and if a -court of competent jurisdiction determines that the patent provision (Section -3), the indemnity provision (Section 9) or other Section of the License -conflicts with the conditions of the GPLv2, you may retroactively and -prospectively choose to deem waived or otherwise exclude such Section(s) of -the License, but only in their entirety and only with respect to the Combined -Software. - diff --git a/crates/vole-blake3-raw/vendor/LICENSE_CC0 b/crates/vole-blake3-raw/vendor/LICENSE_CC0 deleted file mode 100644 index 0e259d4..0000000 --- a/crates/vole-blake3-raw/vendor/LICENSE_CC0 +++ /dev/null @@ -1,121 +0,0 @@ -Creative Commons Legal Code - -CC0 1.0 Universal - - CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE - LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN - ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS - INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES - REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS - PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM - THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED - HEREUNDER. - -Statement of Purpose - -The laws of most jurisdictions throughout the world automatically confer -exclusive Copyright and Related Rights (defined below) upon the creator -and subsequent owner(s) (each and all, an "owner") of an original work of -authorship and/or a database (each, a "Work"). - -Certain owners wish to permanently relinquish those rights to a Work for -the purpose of contributing to a commons of creative, cultural and -scientific works ("Commons") that the public can reliably and without fear -of later claims of infringement build upon, modify, incorporate in other -works, reuse and redistribute as freely as possible in any form whatsoever -and for any purposes, including without limitation commercial purposes. -These owners may contribute to the Commons to promote the ideal of a free -culture and the further production of creative, cultural and scientific -works, or to gain reputation or greater distribution for their Work in -part through the use and efforts of others. - -For these and/or other purposes and motivations, and without any -expectation of additional consideration or compensation, the person -associating CC0 with a Work (the "Affirmer"), to the extent that he or she -is an owner of Copyright and Related Rights in the Work, voluntarily -elects to apply CC0 to the Work and publicly distribute the Work under its -terms, with knowledge of his or her Copyright and Related Rights in the -Work and the meaning and intended legal effect of CC0 on those rights. - -1. Copyright and Related Rights. A Work made available under CC0 may be -protected by copyright and related or neighboring rights ("Copyright and -Related Rights"). Copyright and Related Rights include, but are not -limited to, the following: - - i. the right to reproduce, adapt, distribute, perform, display, - communicate, and translate a Work; - ii. moral rights retained by the original author(s) and/or performer(s); -iii. publicity and privacy rights pertaining to a person's image or - likeness depicted in a Work; - iv. rights protecting against unfair competition in regards to a Work, - subject to the limitations in paragraph 4(a), below; - v. rights protecting the extraction, dissemination, use and reuse of data - in a Work; - vi. database rights (such as those arising under Directive 96/9/EC of the - European Parliament and of the Council of 11 March 1996 on the legal - protection of databases, and under any national implementation - thereof, including any amended or successor version of such - directive); and -vii. other similar, equivalent or corresponding rights throughout the - world based on applicable law or treaty, and any national - implementations thereof. - -2. Waiver. To the greatest extent permitted by, but not in contravention -of, applicable law, Affirmer hereby overtly, fully, permanently, -irrevocably and unconditionally waives, abandons, and surrenders all of -Affirmer's Copyright and Related Rights and associated claims and causes -of action, whether now known or unknown (including existing as well as -future claims and causes of action), in the Work (i) in all territories -worldwide, (ii) for the maximum duration provided by applicable law or -treaty (including future time extensions), (iii) in any current or future -medium and for any number of copies, and (iv) for any purpose whatsoever, -including without limitation commercial, advertising or promotional -purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each -member of the public at large and to the detriment of Affirmer's heirs and -successors, fully intending that such Waiver shall not be subject to -revocation, rescission, cancellation, termination, or any other legal or -equitable action to disrupt the quiet enjoyment of the Work by the public -as contemplated by Affirmer's express Statement of Purpose. - -3. Public License Fallback. Should any part of the Waiver for any reason -be judged legally invalid or ineffective under applicable law, then the -Waiver shall be preserved to the maximum extent permitted taking into -account Affirmer's express Statement of Purpose. In addition, to the -extent the Waiver is so judged Affirmer hereby grants to each affected -person a royalty-free, non transferable, non sublicensable, non exclusive, -irrevocable and unconditional license to exercise Affirmer's Copyright and -Related Rights in the Work (i) in all territories worldwide, (ii) for the -maximum duration provided by applicable law or treaty (including future -time extensions), (iii) in any current or future medium and for any number -of copies, and (iv) for any purpose whatsoever, including without -limitation commercial, advertising or promotional purposes (the -"License"). The License shall be deemed effective as of the date CC0 was -applied by Affirmer to the Work. Should any part of the License for any -reason be judged legally invalid or ineffective under applicable law, such -partial invalidity or ineffectiveness shall not invalidate the remainder -of the License, and in such case Affirmer hereby affirms that he or she -will not (i) exercise any of his or her remaining Copyright and Related -Rights in the Work or (ii) assert any associated claims and causes of -action with respect to the Work, in either case contrary to Affirmer's -express Statement of Purpose. - -4. Limitations and Disclaimers. - - a. No trademark or patent rights held by Affirmer are waived, abandoned, - surrendered, licensed or otherwise affected by this document. - b. Affirmer offers the Work as-is and makes no representations or - warranties of any kind concerning the Work, express, implied, - statutory or otherwise, including without limitation warranties of - title, merchantability, fitness for a particular purpose, non - infringement, or the absence of latent or other defects, accuracy, or - the present or absence of errors, whether or not discoverable, all to - the greatest extent permissible under applicable law. - c. Affirmer disclaims responsibility for clearing rights of other persons - that may apply to the Work or any use thereof, including without - limitation any person's Copyright and Related Rights in the Work. - Further, Affirmer disclaims responsibility for obtaining any necessary - consents, permissions or other rights required for any use of the - Work. - d. Affirmer understands and acknowledges that Creative Commons is not a - party to this document and has no duty or obligation with respect to - this CC0 or use of the Work. diff --git a/crates/vole-blake3-raw/vendor/blake3.c b/crates/vole-blake3-raw/vendor/blake3.c deleted file mode 100644 index 00f91f4..0000000 --- a/crates/vole-blake3-raw/vendor/blake3.c +++ /dev/null @@ -1,651 +0,0 @@ -#include -#include -#include -#include - -#include "blake3.h" -#include "blake3_impl.h" - -const char *blake3_version(void) { return BLAKE3_VERSION_STRING; } - -INLINE void chunk_state_init(blake3_chunk_state *self, const uint32_t key[8], - uint8_t flags) { - memcpy(self->cv, key, BLAKE3_KEY_LEN); - self->chunk_counter = 0; - memset(self->buf, 0, BLAKE3_BLOCK_LEN); - self->buf_len = 0; - self->blocks_compressed = 0; - self->flags = flags; -} - -INLINE void chunk_state_reset(blake3_chunk_state *self, const uint32_t key[8], - uint64_t chunk_counter) { - memcpy(self->cv, key, BLAKE3_KEY_LEN); - self->chunk_counter = chunk_counter; - self->blocks_compressed = 0; - memset(self->buf, 0, BLAKE3_BLOCK_LEN); - self->buf_len = 0; -} - -INLINE size_t chunk_state_len(const blake3_chunk_state *self) { - return (BLAKE3_BLOCK_LEN * (size_t)self->blocks_compressed) + - ((size_t)self->buf_len); -} - -INLINE size_t chunk_state_fill_buf(blake3_chunk_state *self, - const uint8_t *input, size_t input_len) { - size_t take = BLAKE3_BLOCK_LEN - ((size_t)self->buf_len); - if (take > input_len) { - take = input_len; - } - uint8_t *dest = self->buf + ((size_t)self->buf_len); - memcpy(dest, input, take); - self->buf_len += (uint8_t)take; - return take; -} - -INLINE uint8_t chunk_state_maybe_start_flag(const blake3_chunk_state *self) { - if (self->blocks_compressed == 0) { - return CHUNK_START; - } else { - return 0; - } -} - -typedef struct { - uint32_t input_cv[8]; - uint64_t counter; - uint8_t block[BLAKE3_BLOCK_LEN]; - uint8_t block_len; - uint8_t flags; -} output_t; - -INLINE output_t make_output(const uint32_t input_cv[8], - const uint8_t block[BLAKE3_BLOCK_LEN], - uint8_t block_len, uint64_t counter, - uint8_t flags) { - output_t ret; - memcpy(ret.input_cv, input_cv, 32); - memcpy(ret.block, block, BLAKE3_BLOCK_LEN); - ret.block_len = block_len; - ret.counter = counter; - ret.flags = flags; - return ret; -} - -// Chaining values within a given chunk (specifically the compress_in_place -// interface) are represented as words. This avoids unnecessary bytes<->words -// conversion overhead in the portable implementation. However, the hash_many -// interface handles both user input and parent node blocks, so it accepts -// bytes. For that reason, chaining values in the CV stack are represented as -// bytes. -INLINE void output_chaining_value(const output_t *self, uint8_t cv[32]) { - uint32_t cv_words[8]; - memcpy(cv_words, self->input_cv, 32); - blake3_compress_in_place(cv_words, self->block, self->block_len, - self->counter, self->flags); - store_cv_words(cv, cv_words); -} - -INLINE void output_root_bytes(const output_t *self, uint64_t seek, uint8_t *out, - size_t out_len) { - if (out_len == 0) { - return; - } - uint64_t output_block_counter = seek / 64; - size_t offset_within_block = seek % 64; - uint8_t wide_buf[64]; - if(offset_within_block) { - blake3_compress_xof(self->input_cv, self->block, self->block_len, output_block_counter, self->flags | ROOT, wide_buf); - const size_t available_bytes = 64 - offset_within_block; - const size_t bytes = out_len > available_bytes ? available_bytes : out_len; - memcpy(out, wide_buf + offset_within_block, bytes); - out += bytes; - out_len -= bytes; - output_block_counter += 1; - } - if(out_len / 64) { - blake3_xof_many(self->input_cv, self->block, self->block_len, output_block_counter, self->flags | ROOT, out, out_len / 64); - } - output_block_counter += out_len / 64; - out += out_len & -64; - out_len -= out_len & -64; - if(out_len) { - blake3_compress_xof(self->input_cv, self->block, self->block_len, output_block_counter, self->flags | ROOT, wide_buf); - memcpy(out, wide_buf, out_len); - } -} - -INLINE void chunk_state_update(blake3_chunk_state *self, const uint8_t *input, - size_t input_len) { - if (self->buf_len > 0) { - size_t take = chunk_state_fill_buf(self, input, input_len); - input += take; - input_len -= take; - if (input_len > 0) { - blake3_compress_in_place( - self->cv, self->buf, BLAKE3_BLOCK_LEN, self->chunk_counter, - self->flags | chunk_state_maybe_start_flag(self)); - self->blocks_compressed += 1; - self->buf_len = 0; - memset(self->buf, 0, BLAKE3_BLOCK_LEN); - } - } - - while (input_len > BLAKE3_BLOCK_LEN) { - blake3_compress_in_place(self->cv, input, BLAKE3_BLOCK_LEN, - self->chunk_counter, - self->flags | chunk_state_maybe_start_flag(self)); - self->blocks_compressed += 1; - input += BLAKE3_BLOCK_LEN; - input_len -= BLAKE3_BLOCK_LEN; - } - - chunk_state_fill_buf(self, input, input_len); -} - -INLINE output_t chunk_state_output(const blake3_chunk_state *self) { - uint8_t block_flags = - self->flags | chunk_state_maybe_start_flag(self) | CHUNK_END; - return make_output(self->cv, self->buf, self->buf_len, self->chunk_counter, - block_flags); -} - -INLINE output_t parent_output(const uint8_t block[BLAKE3_BLOCK_LEN], - const uint32_t key[8], uint8_t flags) { - return make_output(key, block, BLAKE3_BLOCK_LEN, 0, flags | PARENT); -} - -// Given some input larger than one chunk, return the number of bytes that -// should go in the left subtree. This is the largest power-of-2 number of -// chunks that leaves at least 1 byte for the right subtree. -INLINE size_t left_subtree_len(size_t input_len) { - // Subtract 1 to reserve at least one byte for the right side. input_len - // should always be greater than BLAKE3_CHUNK_LEN. - size_t full_chunks = (input_len - 1) / BLAKE3_CHUNK_LEN; - return round_down_to_power_of_2(full_chunks) * BLAKE3_CHUNK_LEN; -} - -// Use SIMD parallelism to hash up to MAX_SIMD_DEGREE chunks at the same time -// on a single thread. Write out the chunk chaining values and return the -// number of chunks hashed. These chunks are never the root and never empty; -// those cases use a different codepath. -INLINE size_t compress_chunks_parallel(const uint8_t *input, size_t input_len, - const uint32_t key[8], - uint64_t chunk_counter, uint8_t flags, - uint8_t *out) { -#if defined(BLAKE3_TESTING) - assert(0 < input_len); - assert(input_len <= MAX_SIMD_DEGREE * BLAKE3_CHUNK_LEN); -#endif - - const uint8_t *chunks_array[MAX_SIMD_DEGREE]; - size_t input_position = 0; - size_t chunks_array_len = 0; - while (input_len - input_position >= BLAKE3_CHUNK_LEN) { - chunks_array[chunks_array_len] = &input[input_position]; - input_position += BLAKE3_CHUNK_LEN; - chunks_array_len += 1; - } - - blake3_hash_many(chunks_array, chunks_array_len, - BLAKE3_CHUNK_LEN / BLAKE3_BLOCK_LEN, key, chunk_counter, - true, flags, CHUNK_START, CHUNK_END, out); - - // Hash the remaining partial chunk, if there is one. Note that the empty - // chunk (meaning the empty message) is a different codepath. - if (input_len > input_position) { - uint64_t counter = chunk_counter + (uint64_t)chunks_array_len; - blake3_chunk_state chunk_state; - chunk_state_init(&chunk_state, key, flags); - chunk_state.chunk_counter = counter; - chunk_state_update(&chunk_state, &input[input_position], - input_len - input_position); - output_t output = chunk_state_output(&chunk_state); - output_chaining_value(&output, &out[chunks_array_len * BLAKE3_OUT_LEN]); - return chunks_array_len + 1; - } else { - return chunks_array_len; - } -} - -// Use SIMD parallelism to hash up to MAX_SIMD_DEGREE parents at the same time -// on a single thread. Write out the parent chaining values and return the -// number of parents hashed. (If there's an odd input chaining value left over, -// return it as an additional output.) These parents are never the root and -// never empty; those cases use a different codepath. -INLINE size_t compress_parents_parallel(const uint8_t *child_chaining_values, - size_t num_chaining_values, - const uint32_t key[8], uint8_t flags, - uint8_t *out) { -#if defined(BLAKE3_TESTING) - assert(2 <= num_chaining_values); - assert(num_chaining_values <= 2 * MAX_SIMD_DEGREE_OR_2); -#endif - - const uint8_t *parents_array[MAX_SIMD_DEGREE_OR_2]; - size_t parents_array_len = 0; - while (num_chaining_values - (2 * parents_array_len) >= 2) { - parents_array[parents_array_len] = - &child_chaining_values[2 * parents_array_len * BLAKE3_OUT_LEN]; - parents_array_len += 1; - } - - blake3_hash_many(parents_array, parents_array_len, 1, key, - 0, // Parents always use counter 0. - false, flags | PARENT, - 0, // Parents have no start flags. - 0, // Parents have no end flags. - out); - - // If there's an odd child left over, it becomes an output. - if (num_chaining_values > 2 * parents_array_len) { - memcpy(&out[parents_array_len * BLAKE3_OUT_LEN], - &child_chaining_values[2 * parents_array_len * BLAKE3_OUT_LEN], - BLAKE3_OUT_LEN); - return parents_array_len + 1; - } else { - return parents_array_len; - } -} - -// The wide helper function returns (writes out) an array of chaining values -// and returns the length of that array. The number of chaining values returned -// is the dynamically detected SIMD degree, at most MAX_SIMD_DEGREE. Or fewer, -// if the input is shorter than that many chunks. The reason for maintaining a -// wide array of chaining values going back up the tree, is to allow the -// implementation to hash as many parents in parallel as possible. -// -// As a special case when the SIMD degree is 1, this function will still return -// at least 2 outputs. This guarantees that this function doesn't perform the -// root compression. (If it did, it would use the wrong flags, and also we -// wouldn't be able to implement extendable output.) Note that this function is -// not used when the whole input is only 1 chunk long; that's a different -// codepath. -// -// Why not just have the caller split the input on the first update(), instead -// of implementing this special rule? Because we don't want to limit SIMD or -// multi-threading parallelism for that update(). -size_t blake3_compress_subtree_wide(const uint8_t *input, size_t input_len, - const uint32_t key[8], - uint64_t chunk_counter, uint8_t flags, - uint8_t *out, bool use_tbb) { - // Note that the single chunk case does *not* bump the SIMD degree up to 2 - // when it is 1. If this implementation adds multi-threading in the future, - // this gives us the option of multi-threading even the 2-chunk case, which - // can help performance on smaller platforms. - if (input_len <= blake3_simd_degree() * BLAKE3_CHUNK_LEN) { - return compress_chunks_parallel(input, input_len, key, chunk_counter, flags, - out); - } - - // With more than simd_degree chunks, we need to recurse. Start by dividing - // the input into left and right subtrees. (Note that this is only optimal - // as long as the SIMD degree is a power of 2. If we ever get a SIMD degree - // of 3 or something, we'll need a more complicated strategy.) - size_t left_input_len = left_subtree_len(input_len); - size_t right_input_len = input_len - left_input_len; - const uint8_t *right_input = &input[left_input_len]; - uint64_t right_chunk_counter = - chunk_counter + (uint64_t)(left_input_len / BLAKE3_CHUNK_LEN); - - // Make space for the child outputs. Here we use MAX_SIMD_DEGREE_OR_2 to - // account for the special case of returning 2 outputs when the SIMD degree - // is 1. - uint8_t cv_array[2 * MAX_SIMD_DEGREE_OR_2 * BLAKE3_OUT_LEN]; - size_t degree = blake3_simd_degree(); - if (left_input_len > BLAKE3_CHUNK_LEN && degree == 1) { - // The special case: We always use a degree of at least two, to make - // sure there are two outputs. Except, as noted above, at the chunk - // level, where we allow degree=1. (Note that the 1-chunk-input case is - // a different codepath.) - degree = 2; - } - uint8_t *right_cvs = &cv_array[degree * BLAKE3_OUT_LEN]; - - // Recurse! - size_t left_n = SIZE_MAX; - size_t right_n = SIZE_MAX; - -#if defined(BLAKE3_USE_TBB) - blake3_compress_subtree_wide_join_tbb( - key, flags, use_tbb, - // left-hand side - input, left_input_len, chunk_counter, cv_array, &left_n, - // right-hand side - right_input, right_input_len, right_chunk_counter, right_cvs, &right_n); -#else - left_n = blake3_compress_subtree_wide( - input, left_input_len, key, chunk_counter, flags, cv_array, use_tbb); - right_n = blake3_compress_subtree_wide(right_input, right_input_len, key, - right_chunk_counter, flags, right_cvs, - use_tbb); -#endif // BLAKE3_USE_TBB - - // The special case again. If simd_degree=1, then we'll have left_n=1 and - // right_n=1. Rather than compressing them into a single output, return - // them directly, to make sure we always have at least two outputs. - if (left_n == 1) { - memcpy(out, cv_array, 2 * BLAKE3_OUT_LEN); - return 2; - } - - // Otherwise, do one layer of parent node compression. - size_t num_chaining_values = left_n + right_n; - return compress_parents_parallel(cv_array, num_chaining_values, key, flags, - out); -} - -// Hash a subtree with compress_subtree_wide(), and then condense the resulting -// list of chaining values down to a single parent node. Don't compress that -// last parent node, however. Instead, return its message bytes (the -// concatenated chaining values of its children). This is necessary when the -// first call to update() supplies a complete subtree, because the topmost -// parent node of that subtree could end up being the root. It's also necessary -// for extended output in the general case. -// -// As with compress_subtree_wide(), this function is not used on inputs of 1 -// chunk or less. That's a different codepath. -INLINE void -compress_subtree_to_parent_node(const uint8_t *input, size_t input_len, - const uint32_t key[8], uint64_t chunk_counter, - uint8_t flags, uint8_t out[2 * BLAKE3_OUT_LEN], - bool use_tbb) { -#if defined(BLAKE3_TESTING) - assert(input_len > BLAKE3_CHUNK_LEN); -#endif - - uint8_t cv_array[MAX_SIMD_DEGREE_OR_2 * BLAKE3_OUT_LEN]; - size_t num_cvs = blake3_compress_subtree_wide(input, input_len, key, - chunk_counter, flags, cv_array, use_tbb); - assert(num_cvs <= MAX_SIMD_DEGREE_OR_2); - // The following loop never executes when MAX_SIMD_DEGREE_OR_2 is 2, because - // as we just asserted, num_cvs will always be <=2 in that case. But GCC - // (particularly GCC 8.5) can't tell that it never executes, and if NDEBUG is - // set then it emits incorrect warnings here. We tried a few different - // hacks to silence these, but in the end our hacks just produced different - // warnings (see https://github.com/BLAKE3-team/BLAKE3/pull/380). Out of - // desperation, we ifdef out this entire loop when we know it's not needed. -#if MAX_SIMD_DEGREE_OR_2 > 2 - // If MAX_SIMD_DEGREE_OR_2 is greater than 2 and there's enough input, - // compress_subtree_wide() returns more than 2 chaining values. Condense - // them into 2 by forming parent nodes repeatedly. - uint8_t out_array[MAX_SIMD_DEGREE_OR_2 * BLAKE3_OUT_LEN / 2]; - while (num_cvs > 2) { - num_cvs = - compress_parents_parallel(cv_array, num_cvs, key, flags, out_array); - memcpy(cv_array, out_array, num_cvs * BLAKE3_OUT_LEN); - } -#endif - memcpy(out, cv_array, 2 * BLAKE3_OUT_LEN); -} - -INLINE void hasher_init_base(blake3_hasher *self, const uint32_t key[8], - uint8_t flags) { - memcpy(self->key, key, BLAKE3_KEY_LEN); - chunk_state_init(&self->chunk, key, flags); - self->cv_stack_len = 0; -} - -void blake3_hasher_init(blake3_hasher *self) { hasher_init_base(self, IV, 0); } - -void blake3_hasher_init_keyed(blake3_hasher *self, - const uint8_t key[BLAKE3_KEY_LEN]) { - uint32_t key_words[8]; - load_key_words(key, key_words); - hasher_init_base(self, key_words, KEYED_HASH); -} - -void blake3_hasher_init_derive_key_raw(blake3_hasher *self, const void *context, - size_t context_len) { - blake3_hasher context_hasher; - hasher_init_base(&context_hasher, IV, DERIVE_KEY_CONTEXT); - blake3_hasher_update(&context_hasher, context, context_len); - uint8_t context_key[BLAKE3_KEY_LEN]; - blake3_hasher_finalize(&context_hasher, context_key, BLAKE3_KEY_LEN); - uint32_t context_key_words[8]; - load_key_words(context_key, context_key_words); - hasher_init_base(self, context_key_words, DERIVE_KEY_MATERIAL); -} - -void blake3_hasher_init_derive_key(blake3_hasher *self, const char *context) { - blake3_hasher_init_derive_key_raw(self, context, strlen(context)); -} - -// As described in hasher_push_cv() below, we do "lazy merging", delaying -// merges until right before the next CV is about to be added. This is -// different from the reference implementation. Another difference is that we -// aren't always merging 1 chunk at a time. Instead, each CV might represent -// any power-of-two number of chunks, as long as the smaller-above-larger stack -// order is maintained. Instead of the "count the trailing 0-bits" algorithm -// described in the spec, we use a "count the total number of 1-bits" variant -// that doesn't require us to retain the subtree size of the CV on top of the -// stack. The principle is the same: each CV that should remain in the stack is -// represented by a 1-bit in the total number of chunks (or bytes) so far. -INLINE void hasher_merge_cv_stack(blake3_hasher *self, uint64_t total_len) { - size_t post_merge_stack_len = (size_t)popcnt(total_len); - while (self->cv_stack_len > post_merge_stack_len) { - uint8_t *parent_node = - &self->cv_stack[(self->cv_stack_len - 2) * BLAKE3_OUT_LEN]; - output_t output = parent_output(parent_node, self->key, self->chunk.flags); - output_chaining_value(&output, parent_node); - self->cv_stack_len -= 1; - } -} - -// In reference_impl.rs, we merge the new CV with existing CVs from the stack -// before pushing it. We can do that because we know more input is coming, so -// we know none of the merges are root. -// -// This setting is different. We want to feed as much input as possible to -// compress_subtree_wide(), without setting aside anything for the chunk_state. -// If the user gives us 64 KiB, we want to parallelize over all 64 KiB at once -// as a single subtree, if at all possible. -// -// This leads to two problems: -// 1) This 64 KiB input might be the only call that ever gets made to update. -// In this case, the root node of the 64 KiB subtree would be the root node -// of the whole tree, and it would need to be ROOT finalized. We can't -// compress it until we know. -// 2) This 64 KiB input might complete a larger tree, whose root node is -// similarly going to be the root of the whole tree. For example, maybe -// we have 196 KiB (that is, 128 + 64) hashed so far. We can't compress the -// node at the root of the 256 KiB subtree until we know how to finalize it. -// -// The second problem is solved with "lazy merging". That is, when we're about -// to add a CV to the stack, we don't merge it with anything first, as the -// reference impl does. Instead we do merges using the *previous* CV that was -// added, which is sitting on top of the stack, and we put the new CV -// (unmerged) on top of the stack afterwards. This guarantees that we never -// merge the root node until finalize(). -// -// Solving the first problem requires an additional tool, -// compress_subtree_to_parent_node(). That function always returns the top -// *two* chaining values of the subtree it's compressing. We then do lazy -// merging with each of them separately, so that the second CV will always -// remain unmerged. (That also helps us support extendable output when we're -// hashing an input all-at-once.) -INLINE void hasher_push_cv(blake3_hasher *self, uint8_t new_cv[BLAKE3_OUT_LEN], - uint64_t chunk_counter) { - hasher_merge_cv_stack(self, chunk_counter); - memcpy(&self->cv_stack[self->cv_stack_len * BLAKE3_OUT_LEN], new_cv, - BLAKE3_OUT_LEN); - self->cv_stack_len += 1; -} - -INLINE void blake3_hasher_update_base(blake3_hasher *self, const void *input, - size_t input_len, bool use_tbb) { - // Explicitly checking for zero avoids causing UB by passing a null pointer - // to memcpy. This comes up in practice with things like: - // std::vector v; - // blake3_hasher_update(&hasher, v.data(), v.size()); - if (input_len == 0) { - return; - } - - const uint8_t *input_bytes = (const uint8_t *)input; - - // If we have some partial chunk bytes in the internal chunk_state, we need - // to finish that chunk first. - if (chunk_state_len(&self->chunk) > 0) { - size_t take = BLAKE3_CHUNK_LEN - chunk_state_len(&self->chunk); - if (take > input_len) { - take = input_len; - } - chunk_state_update(&self->chunk, input_bytes, take); - input_bytes += take; - input_len -= take; - // If we've filled the current chunk and there's more coming, finalize this - // chunk and proceed. In this case we know it's not the root. - if (input_len > 0) { - output_t output = chunk_state_output(&self->chunk); - uint8_t chunk_cv[32]; - output_chaining_value(&output, chunk_cv); - hasher_push_cv(self, chunk_cv, self->chunk.chunk_counter); - chunk_state_reset(&self->chunk, self->key, self->chunk.chunk_counter + 1); - } else { - return; - } - } - - // Now the chunk_state is clear, and we have more input. If there's more than - // a single chunk (so, definitely not the root chunk), hash the largest whole - // subtree we can, with the full benefits of SIMD (and maybe in the future, - // multi-threading) parallelism. Two restrictions: - // - The subtree has to be a power-of-2 number of chunks. Only subtrees along - // the right edge can be incomplete, and we don't know where the right edge - // is going to be until we get to finalize(). - // - The subtree must evenly divide the total number of chunks up until this - // point (if total is not 0). If the current incomplete subtree is only - // waiting for 1 more chunk, we can't hash a subtree of 4 chunks. We have - // to complete the current subtree first. - // Because we might need to break up the input to form powers of 2, or to - // evenly divide what we already have, this part runs in a loop. - while (input_len > BLAKE3_CHUNK_LEN) { - size_t subtree_len = round_down_to_power_of_2(input_len); - uint64_t count_so_far = self->chunk.chunk_counter * BLAKE3_CHUNK_LEN; - // Shrink the subtree_len until it evenly divides the count so far. We know - // that subtree_len itself is a power of 2, so we can use a bitmasking - // trick instead of an actual remainder operation. (Note that if the caller - // consistently passes power-of-2 inputs of the same size, as is hopefully - // typical, this loop condition will always fail, and subtree_len will - // always be the full length of the input.) - // - // An aside: We don't have to shrink subtree_len quite this much. For - // example, if count_so_far is 1, we could pass 2 chunks to - // compress_subtree_to_parent_node. Since we'll get 2 CVs back, we'll still - // get the right answer in the end, and we might get to use 2-way SIMD - // parallelism. The problem with this optimization, is that it gets us - // stuck always hashing 2 chunks. The total number of chunks will remain - // odd, and we'll never graduate to higher degrees of parallelism. See - // https://github.com/BLAKE3-team/BLAKE3/issues/69. - while ((((uint64_t)(subtree_len - 1)) & count_so_far) != 0) { - subtree_len /= 2; - } - // The shrunken subtree_len might now be 1 chunk long. If so, hash that one - // chunk by itself. Otherwise, compress the subtree into a pair of CVs. - uint64_t subtree_chunks = subtree_len / BLAKE3_CHUNK_LEN; - if (subtree_len <= BLAKE3_CHUNK_LEN) { - blake3_chunk_state chunk_state; - chunk_state_init(&chunk_state, self->key, self->chunk.flags); - chunk_state.chunk_counter = self->chunk.chunk_counter; - chunk_state_update(&chunk_state, input_bytes, subtree_len); - output_t output = chunk_state_output(&chunk_state); - uint8_t cv[BLAKE3_OUT_LEN]; - output_chaining_value(&output, cv); - hasher_push_cv(self, cv, chunk_state.chunk_counter); - } else { - // This is the high-performance happy path, though getting here depends - // on the caller giving us a long enough input. - uint8_t cv_pair[2 * BLAKE3_OUT_LEN]; - compress_subtree_to_parent_node(input_bytes, subtree_len, self->key, - self->chunk.chunk_counter, - self->chunk.flags, cv_pair, use_tbb); - hasher_push_cv(self, cv_pair, self->chunk.chunk_counter); - hasher_push_cv(self, &cv_pair[BLAKE3_OUT_LEN], - self->chunk.chunk_counter + (subtree_chunks / 2)); - } - self->chunk.chunk_counter += subtree_chunks; - input_bytes += subtree_len; - input_len -= subtree_len; - } - - // If there's any remaining input less than a full chunk, add it to the chunk - // state. In that case, also do a final merge loop to make sure the subtree - // stack doesn't contain any unmerged pairs. The remaining input means we - // know these merges are non-root. This merge loop isn't strictly necessary - // here, because hasher_push_chunk_cv already does its own merge loop, but it - // simplifies blake3_hasher_finalize below. - if (input_len > 0) { - chunk_state_update(&self->chunk, input_bytes, input_len); - hasher_merge_cv_stack(self, self->chunk.chunk_counter); - } -} - -void blake3_hasher_update(blake3_hasher *self, const void *input, - size_t input_len) { - bool use_tbb = false; - blake3_hasher_update_base(self, input, input_len, use_tbb); -} - -#if defined(BLAKE3_USE_TBB) -void blake3_hasher_update_tbb(blake3_hasher *self, const void *input, - size_t input_len) { - bool use_tbb = true; - blake3_hasher_update_base(self, input, input_len, use_tbb); -} -#endif // BLAKE3_USE_TBB - -void blake3_hasher_finalize(const blake3_hasher *self, uint8_t *out, - size_t out_len) { - blake3_hasher_finalize_seek(self, 0, out, out_len); -} - -void blake3_hasher_finalize_seek(const blake3_hasher *self, uint64_t seek, - uint8_t *out, size_t out_len) { - // Explicitly checking for zero avoids causing UB by passing a null pointer - // to memcpy. This comes up in practice with things like: - // std::vector v; - // blake3_hasher_finalize(&hasher, v.data(), v.size()); - if (out_len == 0) { - return; - } - - // If the subtree stack is empty, then the current chunk is the root. - if (self->cv_stack_len == 0) { - output_t output = chunk_state_output(&self->chunk); - output_root_bytes(&output, seek, out, out_len); - return; - } - // If there are any bytes in the chunk state, finalize that chunk and do a - // roll-up merge between that chunk hash and every subtree in the stack. In - // this case, the extra merge loop at the end of blake3_hasher_update - // guarantees that none of the subtrees in the stack need to be merged with - // each other first. Otherwise, if there are no bytes in the chunk state, - // then the top of the stack is a chunk hash, and we start the merge from - // that. - output_t output; - size_t cvs_remaining; - if (chunk_state_len(&self->chunk) > 0) { - cvs_remaining = self->cv_stack_len; - output = chunk_state_output(&self->chunk); - } else { - // There are always at least 2 CVs in the stack in this case. - cvs_remaining = self->cv_stack_len - 2; - output = parent_output(&self->cv_stack[cvs_remaining * 32], self->key, - self->chunk.flags); - } - while (cvs_remaining > 0) { - cvs_remaining -= 1; - uint8_t parent_block[BLAKE3_BLOCK_LEN]; - memcpy(parent_block, &self->cv_stack[cvs_remaining * 32], 32); - output_chaining_value(&output, &parent_block[32]); - output = parent_output(parent_block, self->key, self->chunk.flags); - } - output_root_bytes(&output, seek, out, out_len); -} - -void blake3_hasher_reset(blake3_hasher *self) { - chunk_state_reset(&self->chunk, self->key, 0); - self->cv_stack_len = 0; -} diff --git a/crates/vole-blake3-raw/vendor/blake3.h b/crates/vole-blake3-raw/vendor/blake3.h deleted file mode 100644 index c5b7998..0000000 --- a/crates/vole-blake3-raw/vendor/blake3.h +++ /dev/null @@ -1,86 +0,0 @@ -#ifndef BLAKE3_H -#define BLAKE3_H - -#include -#include - -#if !defined(BLAKE3_API) -# if defined(_WIN32) || defined(__CYGWIN__) -# if defined(BLAKE3_DLL) -# if defined(BLAKE3_DLL_EXPORTS) -# define BLAKE3_API __declspec(dllexport) -# else -# define BLAKE3_API __declspec(dllimport) -# endif -# define BLAKE3_PRIVATE -# else -# define BLAKE3_API -# define BLAKE3_PRIVATE -# endif -# elif __GNUC__ >= 4 -# define BLAKE3_API __attribute__((visibility("default"))) -# define BLAKE3_PRIVATE __attribute__((visibility("hidden"))) -# else -# define BLAKE3_API -# define BLAKE3_PRIVATE -# endif -#endif - -#ifdef __cplusplus -extern "C" { -#endif - -#define BLAKE3_VERSION_STRING "1.8.7" -#define BLAKE3_KEY_LEN 32 -#define BLAKE3_OUT_LEN 32 -#define BLAKE3_BLOCK_LEN 64 -#define BLAKE3_CHUNK_LEN 1024 -#define BLAKE3_MAX_DEPTH 54 - -// This struct is a private implementation detail. It has to be here because -// it's part of the blake3_hasher structure defined below. -typedef struct { - uint32_t cv[8]; - uint64_t chunk_counter; - uint8_t buf[BLAKE3_BLOCK_LEN]; - uint8_t buf_len; - uint8_t blocks_compressed; - uint8_t flags; -} blake3_chunk_state; - -typedef struct { - uint32_t key[8]; - blake3_chunk_state chunk; - uint8_t cv_stack_len; - // The stack size is MAX_DEPTH + 1 because we do lazy merging. For example, - // with 7 chunks, we have 3 entries in the stack. Adding an 8th chunk - // requires a 4th entry, rather than merging everything down to 1, because we - // don't know whether more input is coming. This is different from how the - // reference implementation does things. - uint8_t cv_stack[(BLAKE3_MAX_DEPTH + 1) * BLAKE3_OUT_LEN]; -} blake3_hasher; - -BLAKE3_API const char *blake3_version(void); -BLAKE3_API void blake3_hasher_init(blake3_hasher *self); -BLAKE3_API void blake3_hasher_init_keyed(blake3_hasher *self, - const uint8_t key[BLAKE3_KEY_LEN]); -BLAKE3_API void blake3_hasher_init_derive_key(blake3_hasher *self, const char *context); -BLAKE3_API void blake3_hasher_init_derive_key_raw(blake3_hasher *self, const void *context, - size_t context_len); -BLAKE3_API void blake3_hasher_update(blake3_hasher *self, const void *input, - size_t input_len); -#if defined(BLAKE3_USE_TBB) -BLAKE3_API void blake3_hasher_update_tbb(blake3_hasher *self, const void *input, - size_t input_len); -#endif // BLAKE3_USE_TBB -BLAKE3_API void blake3_hasher_finalize(const blake3_hasher *self, uint8_t *out, - size_t out_len); -BLAKE3_API void blake3_hasher_finalize_seek(const blake3_hasher *self, uint64_t seek, - uint8_t *out, size_t out_len); -BLAKE3_API void blake3_hasher_reset(blake3_hasher *self); - -#ifdef __cplusplus -} -#endif - -#endif /* BLAKE3_H */ diff --git a/crates/vole-blake3-raw/vendor/blake3_dispatch.c b/crates/vole-blake3-raw/vendor/blake3_dispatch.c deleted file mode 100644 index 14dfbbe..0000000 --- a/crates/vole-blake3-raw/vendor/blake3_dispatch.c +++ /dev/null @@ -1,332 +0,0 @@ -#include -#include -#include - -#include "blake3_impl.h" - -#if defined(_MSC_VER) -#include -#endif - -#if defined(IS_X86) -#if defined(_MSC_VER) -#include -#elif defined(__GNUC__) -#include -#else -#undef IS_X86 /* Unimplemented! */ -#endif -#endif - -#if !defined(BLAKE3_ATOMICS) -#if defined(__has_include) -#if __has_include() && !defined(_MSC_VER) -#define BLAKE3_ATOMICS 1 -#else -#define BLAKE3_ATOMICS 0 -#endif /* __has_include() && !defined(_MSC_VER) */ -#else -#define BLAKE3_ATOMICS 0 -#endif /* defined(__has_include) */ -#endif /* BLAKE3_ATOMICS */ - -#if BLAKE3_ATOMICS -#define ATOMIC_INT _Atomic int -#define ATOMIC_LOAD(x) x -#define ATOMIC_STORE(x, y) x = y -#elif defined(_MSC_VER) -#define ATOMIC_INT LONG -#define ATOMIC_LOAD(x) InterlockedOr(&x, 0) -#define ATOMIC_STORE(x, y) InterlockedExchange(&x, y) -#else -#define ATOMIC_INT int -#define ATOMIC_LOAD(x) x -#define ATOMIC_STORE(x, y) x = y -#endif - -#define MAYBE_UNUSED(x) (void)((x)) - -#if defined(IS_X86) -static uint64_t xgetbv(void) { -#if defined(_MSC_VER) - return _xgetbv(0); -#else - uint32_t eax = 0, edx = 0; - __asm__ __volatile__("xgetbv\n" : "=a"(eax), "=d"(edx) : "c"(0)); - return ((uint64_t)edx << 32) | eax; -#endif -} - -static void cpuid(uint32_t out[4], uint32_t id) { -#if defined(_MSC_VER) - __cpuid((int *)out, id); -#elif defined(__i386__) || defined(_M_IX86) - __asm__ __volatile__("movl %%ebx, %1\n" - "cpuid\n" - "xchgl %1, %%ebx\n" - : "=a"(out[0]), "=r"(out[1]), "=c"(out[2]), "=d"(out[3]) - : "a"(id)); -#else - __asm__ __volatile__("cpuid\n" - : "=a"(out[0]), "=b"(out[1]), "=c"(out[2]), "=d"(out[3]) - : "a"(id)); -#endif -} - -static void cpuidex(uint32_t out[4], uint32_t id, uint32_t sid) { -#if defined(_MSC_VER) - __cpuidex((int *)out, id, sid); -#elif defined(__i386__) || defined(_M_IX86) - __asm__ __volatile__("movl %%ebx, %1\n" - "cpuid\n" - "xchgl %1, %%ebx\n" - : "=a"(out[0]), "=r"(out[1]), "=c"(out[2]), "=d"(out[3]) - : "a"(id), "c"(sid)); -#else - __asm__ __volatile__("cpuid\n" - : "=a"(out[0]), "=b"(out[1]), "=c"(out[2]), "=d"(out[3]) - : "a"(id), "c"(sid)); -#endif -} - - -enum cpu_feature { - SSE2 = 1 << 0, - SSSE3 = 1 << 1, - SSE41 = 1 << 2, - AVX = 1 << 3, - AVX2 = 1 << 4, - AVX512F = 1 << 5, - AVX512VL = 1 << 6, - /* ... */ - UNDEFINED = 1 << 30 -}; - -#if !defined(BLAKE3_TESTING) -static /* Allow the variable to be controlled manually for testing */ -#endif - ATOMIC_INT g_cpu_features = UNDEFINED; - -#if !defined(BLAKE3_TESTING) -static -#endif - enum cpu_feature - get_cpu_features(void) { - - /* If TSAN detects a data race here, try compiling with -DBLAKE3_ATOMICS=1 */ - enum cpu_feature features = ATOMIC_LOAD(g_cpu_features); - if (features != UNDEFINED) { - return features; - } else { -#if defined(IS_X86) - uint32_t regs[4] = {0}; - uint32_t *eax = ®s[0], *ebx = ®s[1], *ecx = ®s[2], *edx = ®s[3]; - (void)edx; - features = 0; - cpuid(regs, 0); - const int max_id = *eax; - cpuid(regs, 1); -#if defined(__amd64__) || defined(_M_X64) - features |= SSE2; -#else - if (*edx & (1UL << 26)) - features |= SSE2; -#endif - if (*ecx & (1UL << 9)) - features |= SSSE3; - if (*ecx & (1UL << 19)) - features |= SSE41; - - if (*ecx & (1UL << 27)) { // OSXSAVE - const uint64_t mask = xgetbv(); - if ((mask & 6) == 6) { // SSE and AVX states - if (*ecx & (1UL << 28)) - features |= AVX; - if (max_id >= 7) { - cpuidex(regs, 7, 0); - if (*ebx & (1UL << 5)) - features |= AVX2; - if ((mask & 224) == 224) { // Opmask, ZMM_Hi256, Hi16_Zmm - if (*ebx & (1UL << 31)) - features |= AVX512VL; - if (*ebx & (1UL << 16)) - features |= AVX512F; - } - } - } - } - ATOMIC_STORE(g_cpu_features, features); - return features; -#else - /* How to detect NEON? */ - return 0; -#endif - } -} -#endif - -void blake3_compress_in_place(uint32_t cv[8], - const uint8_t block[BLAKE3_BLOCK_LEN], - uint8_t block_len, uint64_t counter, - uint8_t flags) { -#if defined(IS_X86) - const enum cpu_feature features = get_cpu_features(); - MAYBE_UNUSED(features); -#if !defined(BLAKE3_NO_AVX512) - if (features & AVX512VL) { - blake3_compress_in_place_avx512(cv, block, block_len, counter, flags); - return; - } -#endif -#if !defined(BLAKE3_NO_SSE41) - if (features & SSE41) { - blake3_compress_in_place_sse41(cv, block, block_len, counter, flags); - return; - } -#endif -#if !defined(BLAKE3_NO_SSE2) - if (features & SSE2) { - blake3_compress_in_place_sse2(cv, block, block_len, counter, flags); - return; - } -#endif -#endif - blake3_compress_in_place_portable(cv, block, block_len, counter, flags); -} - -void blake3_compress_xof(const uint32_t cv[8], - const uint8_t block[BLAKE3_BLOCK_LEN], - uint8_t block_len, uint64_t counter, uint8_t flags, - uint8_t out[64]) { -#if defined(IS_X86) - const enum cpu_feature features = get_cpu_features(); - MAYBE_UNUSED(features); -#if !defined(BLAKE3_NO_AVX512) - if (features & AVX512VL) { - blake3_compress_xof_avx512(cv, block, block_len, counter, flags, out); - return; - } -#endif -#if !defined(BLAKE3_NO_SSE41) - if (features & SSE41) { - blake3_compress_xof_sse41(cv, block, block_len, counter, flags, out); - return; - } -#endif -#if !defined(BLAKE3_NO_SSE2) - if (features & SSE2) { - blake3_compress_xof_sse2(cv, block, block_len, counter, flags, out); - return; - } -#endif -#endif - blake3_compress_xof_portable(cv, block, block_len, counter, flags, out); -} - - -void blake3_xof_many(const uint32_t cv[8], - const uint8_t block[BLAKE3_BLOCK_LEN], - uint8_t block_len, uint64_t counter, uint8_t flags, - uint8_t out[64], size_t outblocks) { - if (outblocks == 0) { - // The current assembly implementation always outputs at least 1 block. - return; - } -#if defined(IS_X86) - const enum cpu_feature features = get_cpu_features(); - MAYBE_UNUSED(features); -#if !defined(_WIN32) && !defined(__CYGWIN__) && !defined(BLAKE3_NO_AVX512) - if (features & AVX512VL) { - blake3_xof_many_avx512(cv, block, block_len, counter, flags, out, outblocks); - return; - } -#endif -#endif - for(size_t i = 0; i < outblocks; ++i) { - blake3_compress_xof(cv, block, block_len, counter + i, flags, out + 64*i); - } -} - -void blake3_hash_many(const uint8_t *const *inputs, size_t num_inputs, - size_t blocks, const uint32_t key[8], uint64_t counter, - bool increment_counter, uint8_t flags, - uint8_t flags_start, uint8_t flags_end, uint8_t *out) { -#if defined(IS_X86) - const enum cpu_feature features = get_cpu_features(); - MAYBE_UNUSED(features); -#if !defined(BLAKE3_NO_AVX512) - if ((features & (AVX512F|AVX512VL)) == (AVX512F|AVX512VL)) { - blake3_hash_many_avx512(inputs, num_inputs, blocks, key, counter, - increment_counter, flags, flags_start, flags_end, - out); - return; - } -#endif -#if !defined(BLAKE3_NO_AVX2) - if (features & AVX2) { - blake3_hash_many_avx2(inputs, num_inputs, blocks, key, counter, - increment_counter, flags, flags_start, flags_end, - out); - return; - } -#endif -#if !defined(BLAKE3_NO_SSE41) - if (features & SSE41) { - blake3_hash_many_sse41(inputs, num_inputs, blocks, key, counter, - increment_counter, flags, flags_start, flags_end, - out); - return; - } -#endif -#if !defined(BLAKE3_NO_SSE2) - if (features & SSE2) { - blake3_hash_many_sse2(inputs, num_inputs, blocks, key, counter, - increment_counter, flags, flags_start, flags_end, - out); - return; - } -#endif -#endif - -#if BLAKE3_USE_NEON == 1 - blake3_hash_many_neon(inputs, num_inputs, blocks, key, counter, - increment_counter, flags, flags_start, flags_end, out); - return; -#endif - - blake3_hash_many_portable(inputs, num_inputs, blocks, key, counter, - increment_counter, flags, flags_start, flags_end, - out); -} - -// The dynamically detected SIMD degree of the current platform. -size_t blake3_simd_degree(void) { -#if defined(IS_X86) - const enum cpu_feature features = get_cpu_features(); - MAYBE_UNUSED(features); -#if !defined(BLAKE3_NO_AVX512) - if ((features & (AVX512F|AVX512VL)) == (AVX512F|AVX512VL)) { - return 16; - } -#endif -#if !defined(BLAKE3_NO_AVX2) - if (features & AVX2) { - return 8; - } -#endif -#if !defined(BLAKE3_NO_SSE41) - if (features & SSE41) { - return 4; - } -#endif -#if !defined(BLAKE3_NO_SSE2) - if (features & SSE2) { - return 4; - } -#endif -#endif -#if BLAKE3_USE_NEON == 1 - return 4; -#endif - return 1; -} diff --git a/crates/vole-blake3-raw/vendor/blake3_impl.h b/crates/vole-blake3-raw/vendor/blake3_impl.h deleted file mode 100644 index 88e71e4..0000000 --- a/crates/vole-blake3-raw/vendor/blake3_impl.h +++ /dev/null @@ -1,333 +0,0 @@ -#ifndef BLAKE3_IMPL_H -#define BLAKE3_IMPL_H - -#include -#include -#include -#include -#include - -#include "blake3.h" - -#ifdef __cplusplus -extern "C" { -#endif - -// internal flags -enum blake3_flags { - CHUNK_START = 1 << 0, - CHUNK_END = 1 << 1, - PARENT = 1 << 2, - ROOT = 1 << 3, - KEYED_HASH = 1 << 4, - DERIVE_KEY_CONTEXT = 1 << 5, - DERIVE_KEY_MATERIAL = 1 << 6, -}; - -// This C implementation tries to support recent versions of GCC, Clang, and -// MSVC. -#if defined(_MSC_VER) -#define INLINE static __forceinline -#else -#define INLINE static inline __attribute__((always_inline)) -#endif - -#ifdef __cplusplus -#define NOEXCEPT noexcept -#else -#define NOEXCEPT -#endif - -#if (defined(__x86_64__) || defined(_M_X64)) && !defined(_M_ARM64EC) -#define IS_X86 -#define IS_X86_64 -#endif - -#if defined(__i386__) || defined(_M_IX86) -#define IS_X86 -#define IS_X86_32 -#endif - -#if defined(__aarch64__) || defined(_M_ARM64) || defined(_M_ARM64EC) -#define IS_AARCH64 -#endif - -#if defined(IS_X86) -#if defined(_MSC_VER) -#include -#endif -#endif - -#if !defined(BLAKE3_USE_NEON) - // If BLAKE3_USE_NEON not manually set, autodetect based on AArch64ness - #if defined(IS_AARCH64) - #if defined(__ARM_BIG_ENDIAN) - #define BLAKE3_USE_NEON 0 - #else - #define BLAKE3_USE_NEON 1 - #endif - #else - #define BLAKE3_USE_NEON 0 - #endif -#endif - -#if defined(IS_X86) -#define MAX_SIMD_DEGREE 16 -#elif BLAKE3_USE_NEON == 1 -#define MAX_SIMD_DEGREE 4 -#else -#define MAX_SIMD_DEGREE 1 -#endif - -// There are some places where we want a static size that's equal to the -// MAX_SIMD_DEGREE, but also at least 2. -#define MAX_SIMD_DEGREE_OR_2 (MAX_SIMD_DEGREE > 2 ? MAX_SIMD_DEGREE : 2) - -static const uint32_t IV[8] = {0x6A09E667UL, 0xBB67AE85UL, 0x3C6EF372UL, - 0xA54FF53AUL, 0x510E527FUL, 0x9B05688CUL, - 0x1F83D9ABUL, 0x5BE0CD19UL}; - -static const uint8_t MSG_SCHEDULE[7][16] = { - {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, - {2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8}, - {3, 4, 10, 12, 13, 2, 7, 14, 6, 5, 9, 0, 11, 15, 8, 1}, - {10, 7, 12, 9, 14, 3, 13, 15, 4, 0, 11, 2, 5, 8, 1, 6}, - {12, 13, 9, 11, 15, 10, 14, 8, 7, 2, 5, 3, 0, 1, 6, 4}, - {9, 14, 11, 5, 8, 12, 15, 1, 13, 3, 0, 10, 2, 6, 4, 7}, - {11, 15, 5, 0, 1, 9, 8, 6, 14, 10, 2, 12, 3, 4, 7, 13}, -}; - -/* Find index of the highest set bit */ -/* x is assumed to be nonzero. */ -static unsigned int highest_one(uint64_t x) { -#if defined(__GNUC__) || defined(__clang__) - return 63 ^ (unsigned int)__builtin_clzll(x); -#elif defined(_MSC_VER) && defined(IS_X86_64) - unsigned long index; - _BitScanReverse64(&index, x); - return index; -#elif defined(_MSC_VER) && defined(IS_X86_32) - if(x >> 32) { - unsigned long index; - _BitScanReverse(&index, (unsigned long)(x >> 32)); - return 32 + index; - } else { - unsigned long index; - _BitScanReverse(&index, (unsigned long)x); - return index; - } -#else - unsigned int c = 0; - if(x & 0xffffffff00000000ULL) { x >>= 32; c += 32; } - if(x & 0x00000000ffff0000ULL) { x >>= 16; c += 16; } - if(x & 0x000000000000ff00ULL) { x >>= 8; c += 8; } - if(x & 0x00000000000000f0ULL) { x >>= 4; c += 4; } - if(x & 0x000000000000000cULL) { x >>= 2; c += 2; } - if(x & 0x0000000000000002ULL) { c += 1; } - return c; -#endif -} - -// Count the number of 1 bits. -INLINE unsigned int popcnt(uint64_t x) { -#if defined(__GNUC__) || defined(__clang__) - return (unsigned int)__builtin_popcountll(x); -#else - unsigned int count = 0; - while (x != 0) { - count += 1; - x &= x - 1; - } - return count; -#endif -} - -// Largest power of two less than or equal to x. As a special case, returns 1 -// when x is 0. -INLINE uint64_t round_down_to_power_of_2(uint64_t x) { - return 1ULL << highest_one(x | 1); -} - -INLINE uint32_t counter_low(uint64_t counter) { return (uint32_t)counter; } - -INLINE uint32_t counter_high(uint64_t counter) { - return (uint32_t)(counter >> 32); -} - -INLINE uint32_t load32(const void *src) { - const uint8_t *p = (const uint8_t *)src; - return ((uint32_t)(p[0]) << 0) | ((uint32_t)(p[1]) << 8) | - ((uint32_t)(p[2]) << 16) | ((uint32_t)(p[3]) << 24); -} - -INLINE void load_key_words(const uint8_t key[BLAKE3_KEY_LEN], - uint32_t key_words[8]) { - key_words[0] = load32(&key[0 * 4]); - key_words[1] = load32(&key[1 * 4]); - key_words[2] = load32(&key[2 * 4]); - key_words[3] = load32(&key[3 * 4]); - key_words[4] = load32(&key[4 * 4]); - key_words[5] = load32(&key[5 * 4]); - key_words[6] = load32(&key[6 * 4]); - key_words[7] = load32(&key[7 * 4]); -} - -INLINE void load_block_words(const uint8_t block[BLAKE3_BLOCK_LEN], - uint32_t block_words[16]) { - for (size_t i = 0; i < 16; i++) { - block_words[i] = load32(&block[i * 4]); - } -} - -INLINE void store32(void *dst, uint32_t w) { - uint8_t *p = (uint8_t *)dst; - p[0] = (uint8_t)(w >> 0); - p[1] = (uint8_t)(w >> 8); - p[2] = (uint8_t)(w >> 16); - p[3] = (uint8_t)(w >> 24); -} - -INLINE void store_cv_words(uint8_t bytes_out[32], uint32_t cv_words[8]) { - store32(&bytes_out[0 * 4], cv_words[0]); - store32(&bytes_out[1 * 4], cv_words[1]); - store32(&bytes_out[2 * 4], cv_words[2]); - store32(&bytes_out[3 * 4], cv_words[3]); - store32(&bytes_out[4 * 4], cv_words[4]); - store32(&bytes_out[5 * 4], cv_words[5]); - store32(&bytes_out[6 * 4], cv_words[6]); - store32(&bytes_out[7 * 4], cv_words[7]); -} - -void blake3_compress_in_place(uint32_t cv[8], - const uint8_t block[BLAKE3_BLOCK_LEN], - uint8_t block_len, uint64_t counter, - uint8_t flags); - -void blake3_compress_xof(const uint32_t cv[8], - const uint8_t block[BLAKE3_BLOCK_LEN], - uint8_t block_len, uint64_t counter, uint8_t flags, - uint8_t out[64]); - -void blake3_xof_many(const uint32_t cv[8], - const uint8_t block[BLAKE3_BLOCK_LEN], - uint8_t block_len, uint64_t counter, uint8_t flags, - uint8_t out[64], size_t outblocks); - -void blake3_hash_many(const uint8_t *const *inputs, size_t num_inputs, - size_t blocks, const uint32_t key[8], uint64_t counter, - bool increment_counter, uint8_t flags, - uint8_t flags_start, uint8_t flags_end, uint8_t *out); - -size_t blake3_simd_degree(void); - -BLAKE3_PRIVATE size_t blake3_compress_subtree_wide(const uint8_t *input, size_t input_len, - const uint32_t key[8], - uint64_t chunk_counter, uint8_t flags, - uint8_t *out, bool use_tbb); - -#if defined(BLAKE3_USE_TBB) -BLAKE3_PRIVATE void blake3_compress_subtree_wide_join_tbb( - // shared params - const uint32_t key[8], uint8_t flags, bool use_tbb, - // left-hand side params - const uint8_t *l_input, size_t l_input_len, uint64_t l_chunk_counter, - uint8_t *l_cvs, size_t *l_n, - // right-hand side params - const uint8_t *r_input, size_t r_input_len, uint64_t r_chunk_counter, - uint8_t *r_cvs, size_t *r_n) NOEXCEPT; -#endif - -// Declarations for implementation-specific functions. -void blake3_compress_in_place_portable(uint32_t cv[8], - const uint8_t block[BLAKE3_BLOCK_LEN], - uint8_t block_len, uint64_t counter, - uint8_t flags); - -void blake3_compress_xof_portable(const uint32_t cv[8], - const uint8_t block[BLAKE3_BLOCK_LEN], - uint8_t block_len, uint64_t counter, - uint8_t flags, uint8_t out[64]); - -void blake3_hash_many_portable(const uint8_t *const *inputs, size_t num_inputs, - size_t blocks, const uint32_t key[8], - uint64_t counter, bool increment_counter, - uint8_t flags, uint8_t flags_start, - uint8_t flags_end, uint8_t *out); - -#if defined(IS_X86) -#if !defined(BLAKE3_NO_SSE2) -void blake3_compress_in_place_sse2(uint32_t cv[8], - const uint8_t block[BLAKE3_BLOCK_LEN], - uint8_t block_len, uint64_t counter, - uint8_t flags); -void blake3_compress_xof_sse2(const uint32_t cv[8], - const uint8_t block[BLAKE3_BLOCK_LEN], - uint8_t block_len, uint64_t counter, - uint8_t flags, uint8_t out[64]); -void blake3_hash_many_sse2(const uint8_t *const *inputs, size_t num_inputs, - size_t blocks, const uint32_t key[8], - uint64_t counter, bool increment_counter, - uint8_t flags, uint8_t flags_start, - uint8_t flags_end, uint8_t *out); -#endif -#if !defined(BLAKE3_NO_SSE41) -void blake3_compress_in_place_sse41(uint32_t cv[8], - const uint8_t block[BLAKE3_BLOCK_LEN], - uint8_t block_len, uint64_t counter, - uint8_t flags); -void blake3_compress_xof_sse41(const uint32_t cv[8], - const uint8_t block[BLAKE3_BLOCK_LEN], - uint8_t block_len, uint64_t counter, - uint8_t flags, uint8_t out[64]); -void blake3_hash_many_sse41(const uint8_t *const *inputs, size_t num_inputs, - size_t blocks, const uint32_t key[8], - uint64_t counter, bool increment_counter, - uint8_t flags, uint8_t flags_start, - uint8_t flags_end, uint8_t *out); -#endif -#if !defined(BLAKE3_NO_AVX2) -void blake3_hash_many_avx2(const uint8_t *const *inputs, size_t num_inputs, - size_t blocks, const uint32_t key[8], - uint64_t counter, bool increment_counter, - uint8_t flags, uint8_t flags_start, - uint8_t flags_end, uint8_t *out); -#endif -#if !defined(BLAKE3_NO_AVX512) -void blake3_compress_in_place_avx512(uint32_t cv[8], - const uint8_t block[BLAKE3_BLOCK_LEN], - uint8_t block_len, uint64_t counter, - uint8_t flags); - -void blake3_compress_xof_avx512(const uint32_t cv[8], - const uint8_t block[BLAKE3_BLOCK_LEN], - uint8_t block_len, uint64_t counter, - uint8_t flags, uint8_t out[64]); - -void blake3_hash_many_avx512(const uint8_t *const *inputs, size_t num_inputs, - size_t blocks, const uint32_t key[8], - uint64_t counter, bool increment_counter, - uint8_t flags, uint8_t flags_start, - uint8_t flags_end, uint8_t *out); - -#if !defined(_WIN32) && !defined(__CYGWIN__) -void blake3_xof_many_avx512(const uint32_t cv[8], - const uint8_t block[BLAKE3_BLOCK_LEN], - uint8_t block_len, uint64_t counter, uint8_t flags, - uint8_t* out, size_t outblocks); -#endif -#endif -#endif - -#if BLAKE3_USE_NEON == 1 -void blake3_hash_many_neon(const uint8_t *const *inputs, size_t num_inputs, - size_t blocks, const uint32_t key[8], - uint64_t counter, bool increment_counter, - uint8_t flags, uint8_t flags_start, - uint8_t flags_end, uint8_t *out); -#endif - -#ifdef __cplusplus -} -#endif - -#endif /* BLAKE3_IMPL_H */ diff --git a/crates/vole-blake3-raw/vendor/blake3_portable.c b/crates/vole-blake3-raw/vendor/blake3_portable.c deleted file mode 100644 index 062dd1b..0000000 --- a/crates/vole-blake3-raw/vendor/blake3_portable.c +++ /dev/null @@ -1,160 +0,0 @@ -#include "blake3_impl.h" -#include - -INLINE uint32_t rotr32(uint32_t w, uint32_t c) { - return (w >> c) | (w << (32 - c)); -} - -INLINE void g(uint32_t *state, size_t a, size_t b, size_t c, size_t d, - uint32_t x, uint32_t y) { - state[a] = state[a] + state[b] + x; - state[d] = rotr32(state[d] ^ state[a], 16); - state[c] = state[c] + state[d]; - state[b] = rotr32(state[b] ^ state[c], 12); - state[a] = state[a] + state[b] + y; - state[d] = rotr32(state[d] ^ state[a], 8); - state[c] = state[c] + state[d]; - state[b] = rotr32(state[b] ^ state[c], 7); -} - -INLINE void round_fn(uint32_t state[16], const uint32_t *msg, size_t round) { - // Select the message schedule based on the round. - const uint8_t *schedule = MSG_SCHEDULE[round]; - - // Mix the columns. - g(state, 0, 4, 8, 12, msg[schedule[0]], msg[schedule[1]]); - g(state, 1, 5, 9, 13, msg[schedule[2]], msg[schedule[3]]); - g(state, 2, 6, 10, 14, msg[schedule[4]], msg[schedule[5]]); - g(state, 3, 7, 11, 15, msg[schedule[6]], msg[schedule[7]]); - - // Mix the rows. - g(state, 0, 5, 10, 15, msg[schedule[8]], msg[schedule[9]]); - g(state, 1, 6, 11, 12, msg[schedule[10]], msg[schedule[11]]); - g(state, 2, 7, 8, 13, msg[schedule[12]], msg[schedule[13]]); - g(state, 3, 4, 9, 14, msg[schedule[14]], msg[schedule[15]]); -} - -INLINE void compress_pre(uint32_t state[16], const uint32_t cv[8], - const uint8_t block[BLAKE3_BLOCK_LEN], - uint8_t block_len, uint64_t counter, uint8_t flags) { - uint32_t block_words[16]; - block_words[0] = load32(block + 4 * 0); - block_words[1] = load32(block + 4 * 1); - block_words[2] = load32(block + 4 * 2); - block_words[3] = load32(block + 4 * 3); - block_words[4] = load32(block + 4 * 4); - block_words[5] = load32(block + 4 * 5); - block_words[6] = load32(block + 4 * 6); - block_words[7] = load32(block + 4 * 7); - block_words[8] = load32(block + 4 * 8); - block_words[9] = load32(block + 4 * 9); - block_words[10] = load32(block + 4 * 10); - block_words[11] = load32(block + 4 * 11); - block_words[12] = load32(block + 4 * 12); - block_words[13] = load32(block + 4 * 13); - block_words[14] = load32(block + 4 * 14); - block_words[15] = load32(block + 4 * 15); - - state[0] = cv[0]; - state[1] = cv[1]; - state[2] = cv[2]; - state[3] = cv[3]; - state[4] = cv[4]; - state[5] = cv[5]; - state[6] = cv[6]; - state[7] = cv[7]; - state[8] = IV[0]; - state[9] = IV[1]; - state[10] = IV[2]; - state[11] = IV[3]; - state[12] = counter_low(counter); - state[13] = counter_high(counter); - state[14] = (uint32_t)block_len; - state[15] = (uint32_t)flags; - - round_fn(state, &block_words[0], 0); - round_fn(state, &block_words[0], 1); - round_fn(state, &block_words[0], 2); - round_fn(state, &block_words[0], 3); - round_fn(state, &block_words[0], 4); - round_fn(state, &block_words[0], 5); - round_fn(state, &block_words[0], 6); -} - -void blake3_compress_in_place_portable(uint32_t cv[8], - const uint8_t block[BLAKE3_BLOCK_LEN], - uint8_t block_len, uint64_t counter, - uint8_t flags) { - uint32_t state[16]; - compress_pre(state, cv, block, block_len, counter, flags); - cv[0] = state[0] ^ state[8]; - cv[1] = state[1] ^ state[9]; - cv[2] = state[2] ^ state[10]; - cv[3] = state[3] ^ state[11]; - cv[4] = state[4] ^ state[12]; - cv[5] = state[5] ^ state[13]; - cv[6] = state[6] ^ state[14]; - cv[7] = state[7] ^ state[15]; -} - -void blake3_compress_xof_portable(const uint32_t cv[8], - const uint8_t block[BLAKE3_BLOCK_LEN], - uint8_t block_len, uint64_t counter, - uint8_t flags, uint8_t out[64]) { - uint32_t state[16]; - compress_pre(state, cv, block, block_len, counter, flags); - - store32(&out[0 * 4], state[0] ^ state[8]); - store32(&out[1 * 4], state[1] ^ state[9]); - store32(&out[2 * 4], state[2] ^ state[10]); - store32(&out[3 * 4], state[3] ^ state[11]); - store32(&out[4 * 4], state[4] ^ state[12]); - store32(&out[5 * 4], state[5] ^ state[13]); - store32(&out[6 * 4], state[6] ^ state[14]); - store32(&out[7 * 4], state[7] ^ state[15]); - store32(&out[8 * 4], state[8] ^ cv[0]); - store32(&out[9 * 4], state[9] ^ cv[1]); - store32(&out[10 * 4], state[10] ^ cv[2]); - store32(&out[11 * 4], state[11] ^ cv[3]); - store32(&out[12 * 4], state[12] ^ cv[4]); - store32(&out[13 * 4], state[13] ^ cv[5]); - store32(&out[14 * 4], state[14] ^ cv[6]); - store32(&out[15 * 4], state[15] ^ cv[7]); -} - -INLINE void hash_one_portable(const uint8_t *input, size_t blocks, - const uint32_t key[8], uint64_t counter, - uint8_t flags, uint8_t flags_start, - uint8_t flags_end, uint8_t out[BLAKE3_OUT_LEN]) { - uint32_t cv[8]; - memcpy(cv, key, BLAKE3_KEY_LEN); - uint8_t block_flags = flags | flags_start; - while (blocks > 0) { - if (blocks == 1) { - block_flags |= flags_end; - } - blake3_compress_in_place_portable(cv, input, BLAKE3_BLOCK_LEN, counter, - block_flags); - input = &input[BLAKE3_BLOCK_LEN]; - blocks -= 1; - block_flags = flags; - } - store_cv_words(out, cv); -} - -void blake3_hash_many_portable(const uint8_t *const *inputs, size_t num_inputs, - size_t blocks, const uint32_t key[8], - uint64_t counter, bool increment_counter, - uint8_t flags, uint8_t flags_start, - uint8_t flags_end, uint8_t *out) { - while (num_inputs > 0) { - hash_one_portable(inputs[0], blocks, key, counter, flags, flags_start, - flags_end, out); - if (increment_counter) { - counter += 1; - } - inputs += 1; - num_inputs -= 1; - out = &out[BLAKE3_OUT_LEN]; - } -} diff --git a/crates/vole-netstack/Cargo.toml b/crates/vole-netstack/Cargo.toml index 95e5fa7..961eecb 100644 --- a/crates/vole-netstack/Cargo.toml +++ b/crates/vole-netstack/Cargo.toml @@ -9,7 +9,8 @@ description = "Bounded userspace IPv4/IPv6 TCP and UDP netstack for Vole" [dependencies] bytes = "1" futures-util = "0.3" -smoltcp = { version = "0.14", default-features = false, features = [ +# Upstream recovery fixes preserve retries when bounded packet output is full. +smoltcp = { git = "https://github.com/smoltcp-rs/smoltcp", rev = "24bda3b4f982625d8453d4360172557d24921c76", default-features = false, features = [ "std", "medium-ip", "auto-icmp-echo-reply", diff --git a/crates/vole-netstack/README.md b/crates/vole-netstack/README.md index ab58cff..0da7b71 100644 --- a/crates/vole-netstack/README.md +++ b/crates/vole-netstack/README.md @@ -81,20 +81,104 @@ control.stop().await; smoltcp sockets and woken pending TCP operations. Dropping the sole `PacketStream` also stops the driver and releases all endpoints. +## Active TCP client + +`NetStack::start_client(config, addresses)` returns `ClientNetStackParts` with +the same raw packet endpoints, control and statistics, plus a cloneable +`TcpConnector`. `ClientAddresses` supplies optional IPv4/IPv6 source addresses; +at least one must be unicast. An omitted family fails with `AddrNotAvailable`. +The client mode does not create a passive accept or UDP datagram queue, and +discards incoming TCP packets that do not belong to a requested connection. + +An optional `ClientConfig` retains up to two real `ClientPrefix` candidates, +including two addresses in one family, and selects the source for each peer. +It separates the bounded receive packet size from the TX MTU and can opt into +per-flow ICMP PMTU feedback in the packet adapter. Mapped IPv4 and duplicate candidates normalize +before startup. IPv4-only clients permit MTU 68..65535; a real IPv6 candidate +requires at least 1280. Passive TUN validation still requires 1280. +`ClientAddresses` converts with the former receive bound and PMTU disabled. + +```rust,ignore +let parts = vole_netstack::NetStack::start_client(config, addresses)?; +// The owner must concurrently pump packet_sink and packet_stream. +let stream = parts.tcp_connector.connect(peer, deadline).await?; +assert_eq!(stream.destination_addr(), peer); +parts.control.stop().await; +``` + +`connect` takes an IP `SocketAddr` and an absolute Tokio `Instant`; bounded +command admission and the handshake use that one deadline. It returns only +after the socket reaches Established, with `source_addr` representing the +configured local IP and allocated ephemeral port. `destination_addr` is the +remote peer. The driver alone owns socket state and allocates source ports; +there is no host socket, DNS resolver, system interface, route or NAT setup. + +The command queue holds at most 32 requests, independently of active socket +count. Waiting for queue space remains cancellable and deadline-bound. Future +Drop, deadline or handshake failure aborts the pending socket and reclaims its +flow after any reset reaches the existing output queue; a full output never +blocks Stop. Successful streams retain the existing direction buffers and TCP +half-close behavior. Stop closes admission, wakes queued/in-flight connects +and releases every socket before returning. Passive `start`/`start_tcp` TUN +uses the same configured data buffers and admits validated TCP PMTU feedback +without changing connection admission or adding packet queues. + +An active owner may call `tcp_connector.set_initial_mtu(mtu, deadline)` after +its transport handshake and before any TCP connection. The same bounded +command queue confirms a valid reduction before the first SYN is exposed. +The driver rebuilds smoltcp's device-capability snapshot with unchanged source +prefixes and routes; the independent receive packet limit is retained. Invalid +budgets, increases, expired deadlines or an already started TCP flow fail. + ## Driver buffering and scheduling +TCP uses the official unmodified smoltcp engine. Active clients initialize its +device with the known transport MTU; callers should use their actual packet +budget when available. ICMP feedback cannot change smoltcp's socket MSS. +Instead, validated feedback reduces that flow's output budget, and the packet +adapter splits later TCP output, including ordinary retransmissions. Recovery +uses smoltcp's own retransmission/congestion timers; no immediate retransmission +or Mihomo timer equality is promised. + +Active clients retain the engine's one-second initial retransmission timeout +and use a 200 ms minimum after RTT sampling. Passive TUN sockets keep the +engine defaults; RTT estimates and timeout backoff remain engine-owned. + +Passive TUN TCP enables this bounded fallback as well; an incoming error must +identify an active intercepted flow. Active clients additionally require a +configured local source address and explicitly enable PMTU discovery. +The adapter validates the local/remote tuple, outer destination and quoted +sequence against output actually placed in the raw queue and not acknowledged. +Sent ranges, ACK watermarks and path budgets each hold at most 64 metadata +entries, with no payload history. Exhaustion forgets old optimization metadata; +it cannot authorize a quote for unsent bytes. Partial ACKs trim references, +sequence arithmetic handles wrap, and flow reclamation removes its metadata. + +Software segmentation supports non-fragmented IPv4 and plain IPv6 TCP. IP/TCP +options, sequence, checksums and flags are preserved; FIN/PSH appear only on +the last segment and CWR only on the first. Oversized SYN/RST/URG or packets +requiring unsupported IP fragmentation/IPv6 extension handling are dropped; +UDP message boundaries are never split. IPv6 budgets below 1280 are ignored. +Continual software segmentation creates more packets and allocations than a +correct initial device MTU, so it is a fallback for later path reductions. + Device ingress holds at most one pending packet, not another packet queue. smoltcp TX tokens reserve capacity in the existing bounded raw output queue before consuming ingress or allocating an output packet. Consuming a token sends directly into that queue; dropping an unused token returns its permit. -There is no intermediate device TX backlog. TCP protocol state retains pending -output, including resets, until a token is available. Low-priority ICMP echo +During segmentation, at most one original output packet and its offset remain +in the device; each generated segment reserves its raw queue slot before +allocation. No additional packet queue exists. A pending original prevents +further smoltcp TX tokens until its segments drain. TCP protocol state retains +pending output, including resets, until a token is available. Low-priority ICMP echo replies are dropped when output is full, without waiting or retaining a reply. `PacketStream::recv`, `recv_batch` and `try_recv` wake the driver when they restore output capacity. An immediate protocol timer is deferred while output is full, avoiding a zero-delay retry loop; the wakeup resumes it promptly. Bounded periodic polling still performs lifecycle maintenance. +Stop releases the pending original and all PMTU metadata before reporting +completion; it never waits for a blocked output consumer. The driver consumes at most eight already-ready ingress packets before full socket maintenance. TCP ingress still advances packet by packet; cancellation diff --git a/crates/vole-netstack/src/client.rs b/crates/vole-netstack/src/client.rs new file mode 100644 index 0000000..6046bdc --- /dev/null +++ b/crates/vole-netstack/src/client.rs @@ -0,0 +1,439 @@ +use std::{ + io, + net::{IpAddr, Ipv4Addr}, +}; + +use crate::{ClientAddresses, ConfigError}; + +/// Largest ordinary IPv6 packet, including its fixed header. Jumbograms are +/// outside the raw client codec; this limit is independent of the TX MTU. +pub const MAX_RECEIVE_PACKET_SIZE: usize = 40 + 65_535; + +/// A local address and its actual prefix length, retaining the address's host +/// bits. Field names in a proxy configuration do not determine its family. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ClientPrefix { + pub address: IpAddr, + pub prefix_len: u8, +} + +/// One internal client profile. It adds no system address or route settings. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct ClientConfig { + pub prefixes: [Option; 2], + /// Zero preserves the legacy behavior of using the configured TX MTU. + pub receive_packet_limit: usize, + /// Enables correlated packet-layer ICMP PMTU feedback in active mode only. + pub path_mtu_discovery: bool, +} + +impl From for ClientConfig { + fn from(addresses: ClientAddresses) -> Self { + Self { + prefixes: [ + addresses.ipv4.map(|address| ClientPrefix { + address: address.into(), + prefix_len: 32, + }), + addresses.ipv6.map(|address| ClientPrefix { + address: address.into(), + prefix_len: 128, + }), + ], + receive_packet_limit: 0, + path_mtu_discovery: false, + } + } +} + +impl ClientConfig { + /// Normalizes mapped addresses and duplicate source candidates, then + /// checks the active family's MTU and the separate receive allocation bound. + /// + /// # Errors + /// + /// Returns a configuration error for invalid or non-unicast prefixes, + /// subnet broadcasts, missing local addresses, or invalid packet bounds. + pub fn validate(mut self, mtu: usize) -> Result { + let mut normalized = [None; 2]; + for (slot, prefix) in self.prefixes.into_iter().enumerate() { + let Some(mut prefix) = prefix else { continue }; + if let IpAddr::V6(address) = prefix.address + && let Some(address) = address.to_ipv4_mapped() + { + prefix.address = address.into(); + prefix.prefix_len = prefix + .prefix_len + .checked_sub(96) + .ok_or(ConfigError::ClientPrefix)?; + } + let maximum = if prefix.address.is_ipv4() { 32 } else { 128 }; + if prefix.prefix_len > maximum { + return Err(ConfigError::ClientPrefix); + } + match prefix.address { + IpAddr::V4(address) => { + if address.is_unspecified() || address.is_multicast() { + return Err(ConfigError::ClientAddress("IPv4")); + } + if address.is_broadcast() || subnet_broadcast(prefix, false) == Some(address) { + return Err(ConfigError::ClientBroadcast); + } + } + IpAddr::V6(address) => { + if address.is_unspecified() || address.is_multicast() { + return Err(ConfigError::ClientAddress("IPv6")); + } + } + } + normalized[slot] = Some(prefix); + } + if normalized.iter().all(Option::is_none) { + return Err(ConfigError::NoClientAddress); + } + + // An address supplied under one prefix can be another supplied + // prefix's subnet broadcast. Check before merging duplicate sources. + for prefix in normalized.into_iter().flatten() { + if let Some(broadcast) = subnet_broadcast(prefix, true) + && normalized + .iter() + .flatten() + .any(|source| source.address == IpAddr::V4(broadcast)) + { + return Err(ConfigError::ClientBroadcast); + } + } + + self.prefixes = [None; 2]; + let mut count = 0; + for prefix in normalized.into_iter().flatten() { + if let Some(existing) = self.prefixes[..count] + .iter_mut() + .flatten() + .find(|existing| existing.address == prefix.address) + { + existing.prefix_len = existing.prefix_len.max(prefix.prefix_len); + } else { + self.prefixes[count] = Some(prefix); + count += 1; + } + } + let minimum = if self.has_ipv6() { 1_280 } else { 68 }; + if !(minimum..=65_535).contains(&mtu) { + return Err(ConfigError::ClientMtu { + value: mtu, + minimum, + }); + } + if self.receive_packet_limit == 0 { + self.receive_packet_limit = mtu; + } + if !(1..=MAX_RECEIVE_PACKET_SIZE).contains(&self.receive_packet_limit) { + return Err(ConfigError::ReceivePacketLimit(self.receive_packet_limit)); + } + Ok(self) + } + + /// Iterates the at most two canonical source candidates in stable order. + pub fn prefixes(&self) -> impl Iterator + '_ { + self.prefixes.iter().copied().flatten() + } + + #[must_use] + pub fn has_ipv6(&self) -> bool { + self.prefixes().any(|prefix| prefix.address.is_ipv6()) + } + + #[must_use] + pub fn contains_address(&self, address: IpAddr) -> bool { + let address = unmap(address); + self.prefixes().any(|prefix| prefix.address == address) + } + + /// Selects an actual local source with the applicable RFC 6724 rules. + /// Configured addresses are stable and non-deprecated on a single output + /// interface, so those rules are ties. Complete ties retain input order. + /// + /// # Errors + /// + /// Returns an I/O error for an invalid destination or unavailable family. + pub fn source_for(&self, destination: IpAddr) -> io::Result { + let destination = unmap(destination); + if destination.is_unspecified() + || destination.is_multicast() + || matches!(destination, IpAddr::V4(ip) if ip.is_broadcast()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid client peer", + )); + } + if destination.is_loopback() && !self.contains_address(destination) { + return Err(io::Error::new( + io::ErrorKind::NetworkUnreachable, + "client loopback peer is not a local address", + )); + } + let mut selected = None; + for candidate in self + .prefixes() + .filter(|prefix| prefix.address.is_ipv4() == destination.is_ipv4()) + { + if selected.is_none_or(|current| prefer_source(candidate, current, destination)) { + selected = Some(candidate); + } + } + selected.map(|prefix| prefix.address).ok_or_else(|| { + io::Error::new( + io::ErrorKind::AddrNotAvailable, + "active client source family is unavailable", + ) + }) + } +} + +fn unmap(address: IpAddr) -> IpAddr { + match address { + IpAddr::V6(ip) => ip.to_ipv4_mapped().map_or(address, IpAddr::V4), + IpAddr::V4(_) => address, + } +} + +fn subnet_broadcast(prefix: ClientPrefix, skip_loopback: bool) -> Option { + let IpAddr::V4(address) = prefix.address else { + return None; + }; + if prefix.prefix_len >= 31 || (skip_loopback && address.is_loopback()) { + return None; + } + let host_mask = u32::try_from((1_u64 << (32 - prefix.prefix_len)) - 1).ok()?; + Some(Ipv4Addr::from(u32::from(address) | host_mask)) +} + +fn prefer_source(candidate: ClientPrefix, current: ClientPrefix, destination: IpAddr) -> bool { + if candidate.address == destination || current.address == destination { + return candidate.address == destination; + } + let candidate_scope = scope(candidate.address); + let current_scope = scope(current.address); + let destination_scope = scope(destination); + if candidate_scope != current_scope { + let candidate_appropriate = candidate_scope >= destination_scope; + let current_appropriate = current_scope >= destination_scope; + if candidate_appropriate != current_appropriate { + return candidate_appropriate; + } + return if candidate_appropriate { + candidate_scope < current_scope + } else { + candidate_scope > current_scope + }; + } + let matching_label = |source| label(source) == label(destination); + if matching_label(candidate.address) != matching_label(current.address) { + return matching_label(candidate.address); + } + common_prefix(candidate, destination) > common_prefix(current, destination) +} + +fn scope(address: IpAddr) -> u8 { + if address.is_loopback() { + return 2; + } + match address { + IpAddr::V4(ip) if ip.is_link_local() => 2, + IpAddr::V6(ip) => { + let bytes = ip.octets(); + if bytes[0] == 0xfe && bytes[1] & 0xc0 == 0x80 { + 2 + } else if bytes[0] == 0xfe && bytes[1] & 0xc0 == 0xc0 { + 5 + } else { + 14 + } + } + IpAddr::V4(_) => 14, + } +} + +fn label(address: IpAddr) -> u8 { + let IpAddr::V6(ip) = address else { return 4 }; + if ip.is_loopback() { + return 0; + } + let bytes = ip.octets(); + if bytes[..2] == [0x20, 0x02] { + 2 + } else if bytes[..4] == [0x20, 0x01, 0, 0] { + 5 + } else if bytes[0] & 0xfe == 0xfc { + 13 + } else if bytes[0] == 0xfe && bytes[1] & 0xc0 == 0xc0 { + 11 + } else if bytes[..2] == [0x3f, 0xfe] { + 12 + } else if bytes[..12] == [0; 12] { + 3 + } else { + 1 + } +} + +fn common_prefix(source: ClientPrefix, destination: IpAddr) -> u8 { + let differing = match (source.address, destination) { + (IpAddr::V4(a), IpAddr::V4(b)) => (u32::from(a) ^ u32::from(b)).leading_zeros(), + (IpAddr::V6(a), IpAddr::V6(b)) => (u128::from(a) ^ u128::from(b)).leading_zeros(), + _ => return 0, + }; + u8::try_from(differing) + .unwrap_or(128) + .min(source.prefix_len) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn prefix(address: &str, prefix_len: u8) -> ClientPrefix { + ClientPrefix { + address: address.parse().unwrap(), + prefix_len, + } + } + + #[test] + fn source_selection_keeps_two_same_family_candidates_and_prefix_bounds() { + let config = ClientConfig { + prefixes: [ + Some(prefix("2001:db8:1::2", 48)), + Some(prefix("2001:db8:2::2", 48)), + ], + ..Default::default() + } + .validate(1280) + .unwrap(); + assert_eq!( + config.source_for("2001:db8:1::9".parse().unwrap()).unwrap(), + "2001:db8:1::2".parse::().unwrap() + ); + assert_eq!( + config.source_for("2001:db8:2::9".parse().unwrap()).unwrap(), + "2001:db8:2::2".parse::().unwrap() + ); + // Equal configured /32 matches tie even though the second address is + // closer beyond those bits; the first candidate must stay selected. + let config = ClientConfig { + prefixes: [ + Some(prefix("2001:db8:1::2", 32)), + Some(prefix("2001:db8:2::2", 32)), + ], + ..config + } + .validate(1280) + .unwrap(); + assert_eq!( + config.source_for("2001:db8:2::9".parse().unwrap()).unwrap(), + "2001:db8:1::2".parse::().unwrap() + ); + let config = ClientConfig { + prefixes: [Some(prefix("fe80::2", 64)), Some(prefix("2001:db8::2", 64))], + ..config + } + .validate(1280) + .unwrap(); + assert_eq!( + config.source_for("fe80::9".parse().unwrap()).unwrap(), + "fe80::2".parse::().unwrap() + ); + assert_eq!( + config.source_for("2001:db8::9".parse().unwrap()).unwrap(), + "2001:db8::2".parse::().unwrap() + ); + } + + #[test] + fn mapped_duplicates_and_broadcasts_follow_actual_prefixes() { + for prefixes in [ + [ + Some(prefix("::ffff:10.23.0.2", 120)), + Some(prefix("10.23.0.2", 32)), + ], + [ + Some(prefix("10.23.0.2", 32)), + Some(prefix("::ffff:10.23.0.2", 120)), + ], + ] { + let config = ClientConfig { + prefixes, + ..Default::default() + } + .validate(68) + .unwrap(); + assert_eq!( + config.prefixes().collect::>(), + vec![ClientPrefix { + address: "10.23.0.2".parse().unwrap(), + prefix_len: 32 + }] + ); + assert!(!config.has_ipv6()); + } + for prefixes in [ + [Some(prefix("10.23.0.255", 24)), None], + [ + Some(prefix("10.23.0.2", 24)), + Some(prefix("10.23.0.255", 32)), + ], + ] { + assert_eq!( + ClientConfig { + prefixes, + ..Default::default() + } + .validate(68), + Err(ConfigError::ClientBroadcast) + ); + } + ClientConfig { + prefixes: [Some(prefix("10.23.0.255", 31)), None], + ..Default::default() + } + .validate(68) + .unwrap(); + } + + #[test] + fn active_family_mtu_and_receive_budget_leave_passive_bounds_intact() { + let ipv4 = ClientConfig { + prefixes: [Some(prefix("10.23.0.2", 24)), None], + receive_packet_limit: MAX_RECEIVE_PACKET_SIZE, + path_mtu_discovery: true, + }; + let ipv4 = ipv4.validate(68).unwrap(); + assert_eq!(ipv4.receive_packet_limit, MAX_RECEIVE_PACKET_SIZE); + let config = crate::NetStackConfig { + mtu: 68, + ..Default::default() + }; + config.validate_client(&ipv4).unwrap(); + assert_eq!(config.validate(), Err(ConfigError::Mtu(68))); + let ipv6 = ClientConfig { + prefixes: [Some(prefix("fd23::2", 64)), None], + ..ipv4 + }; + assert!(matches!( + ipv6.validate(1279), + Err(ConfigError::ClientMtu { minimum: 1280, .. }) + )); + ipv6.validate(1280).unwrap(); + let legacy = ClientConfig::from(ClientAddresses { + ipv4: Some(Ipv4Addr::new(10, 23, 0, 2)), + ipv6: None, + }) + .validate(1500) + .unwrap(); + assert_eq!(legacy.receive_packet_limit, 1500); + assert!(!legacy.path_mtu_discovery); + } +} diff --git a/crates/vole-netstack/src/config.rs b/crates/vole-netstack/src/config.rs index 86d6c5d..cca0402 100644 --- a/crates/vole-netstack/src/config.rs +++ b/crates/vole-netstack/src/config.rs @@ -1,12 +1,24 @@ -use std::time::Duration; +use std::{ + net::{Ipv4Addr, Ipv6Addr}, + time::Duration, +}; use thiserror::Error; +/// Configured source addresses for an active raw-IP client stack. +/// +/// An omitted family is unavailable; the driver never invents a source address. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct ClientAddresses { + pub ipv4: Option, + pub ipv6: Option, +} + /// Finite buffering and timing configuration for one netstack instance. /// /// Queue counts are hard limits. Each TCP buffer includes both the smoltcp /// socket buffer and the application-facing queue for one flow in that -/// direction. Each layer reserves half of the configured total. +/// direction. Each layer is bounded to half of the configured total. #[derive(Clone, Debug, Eq, PartialEq)] pub struct NetStackConfig { /// Maximum raw IP packet size and the IP-medium MTU advertised to smoltcp. @@ -59,6 +71,24 @@ impl NetStackConfig { if !(1_280..=65_535).contains(&self.mtu) { return Err(ConfigError::Mtu(self.mtu)); } + self.validate_resources() + } + + pub(crate) fn validate_client( + &self, + client: &crate::client::ClientConfig, + ) -> Result<(), ConfigError> { + let minimum = if client.has_ipv6() { 1_280 } else { 68 }; + if !(minimum..=65_535).contains(&self.mtu) { + return Err(ConfigError::ClientMtu { + value: self.mtu, + minimum, + }); + } + self.validate_resources() + } + + fn validate_resources(&self) -> Result<(), ConfigError> { for (name, value) in [ ("packet_queue", self.packet_queue), ("tcp_accept_queue", self.tcp_accept_queue), @@ -96,6 +126,18 @@ impl NetStackConfig { #[derive(Debug, Error, Eq, PartialEq)] pub enum ConfigError { + #[error("active netstack requires at least one configured source address")] + NoClientAddress, + #[error("active netstack {0} source address must be unicast")] + ClientAddress(&'static str), + #[error("active netstack source prefix is invalid")] + ClientPrefix, + #[error("active netstack source address is a subnet broadcast")] + ClientBroadcast, + #[error("active netstack MTU must be between {minimum} and 65535 bytes, got {value}")] + ClientMtu { value: usize, minimum: usize }, + #[error("active netstack receive packet limit must be between 1 and 65575 bytes, got {0}")] + ReceivePacketLimit(usize), #[error("MTU must be between 1280 and 65535 bytes, got {0}")] Mtu(usize), #[error("resource limit `{0}` must be greater than zero")] diff --git a/crates/vole-netstack/src/device.rs b/crates/vole-netstack/src/device.rs index dbfc78d..e17ea08 100644 --- a/crates/vole-netstack/src/device.rs +++ b/crates/vole-netstack/src/device.rs @@ -4,14 +4,34 @@ use smoltcp::{ }; use tokio::sync::mpsc; -use crate::Packet; +use crate::{ + Packet, + pmtu::PathMtuHint, + segmentation::{PathMtu, PendingSegments, is_pure_tcp_ack, reverse}, + tcp::FlowKey, +}; + +enum PendingTransmit { + Segments(PendingSegments), + Reply(Packet), +} +impl PendingTransmit { + fn flow(&self) -> Option { + match self { + Self::Segments(segments) => Some(segments.flow()), + Self::Reply(packet) => PendingSegments::new(packet.clone()).map(|part| part.flow()), + } + } +} -/// A raw-IP device with one pending ingress and reserved bounded output slots. +/// A raw-IP device with one pending ingress and one bounded pending output. pub(crate) struct RawIpDevice { rx: Option, output: mpsc::Sender, emitted: u64, capabilities: DeviceCapabilities, + pending_tx: Option, + path_mtu: Option, } impl RawIpDevice { @@ -24,6 +44,8 @@ impl RawIpDevice { output, emitted: 0, capabilities, + pending_tx: None, + path_mtu: None, } } @@ -53,7 +75,94 @@ impl RawIpDevice { } pub(crate) fn tx_is_full(&self) -> bool { - self.output.capacity() == 0 + self.pending_tx.is_some() || self.output.capacity() == 0 + } + + pub(crate) fn enable_path_mtu(&mut self) { + self.path_mtu = Some(PathMtu::default()); + } + + pub(crate) fn disable_path_mtu(&mut self) { + self.path_mtu = None; + } + + pub(crate) fn set_initial_mtu(&mut self, mtu: usize) { + self.capabilities.max_transmission_unit = mtu; + } + + pub(crate) fn apply_path_mtu(&mut self, hint: PathMtuHint) -> bool { + self.path_mtu + .as_mut() + .is_some_and(|paths| paths.apply(hint, self.capabilities.max_transmission_unit)) + } + + pub(crate) fn acknowledge(&mut self, packet: &Packet) { + if let Some(paths) = &mut self.path_mtu { + paths.acknowledge(packet); + } + } + + pub(crate) fn forget_flow(&mut self, incoming: FlowKey) { + let outgoing = reverse(incoming); + if let Some(paths) = &mut self.path_mtu { + paths.forget(outgoing); + } + if self + .pending_tx + .as_ref() + .is_some_and(|pending| pending.flow() == Some(outgoing)) + { + self.pending_tx = None; + } + } + + pub(crate) fn clear(&mut self) { + self.rx = None; + self.pending_tx = None; + self.path_mtu = None; + } + + /// Drain only into the existing raw output queue, without waiting. Every + /// segment reserves its slot before allocating a packet. A partial drain + /// retains one original packet and resumes on `PacketStream` notification. + pub(crate) fn flush_pending(&mut self) { + while let Some(pending) = &mut self.pending_tx { + let Ok(permit) = self.output.try_reserve() else { + break; + }; + if matches!(pending, PendingTransmit::Reply(_)) { + let Some(PendingTransmit::Reply(packet)) = self.pending_tx.take() else { + unreachable!(); + }; + if let Some(paths) = &mut self.path_mtu { + paths.emitted(&packet); + } + permit.send(packet); + self.emitted = self.emitted.wrapping_add(1); + continue; + } + let PendingTransmit::Segments(pending) = pending else { + unreachable!(); + }; + let budget = self + .path_mtu + .as_ref() + .map_or(self.capabilities.max_transmission_unit, |paths| { + paths.budget(pending.flow(), self.capabilities.max_transmission_unit) + }); + let Some(packet) = pending.next(budget) else { + self.pending_tx = None; + break; + }; + if let Some(paths) = &mut self.path_mtu { + paths.emitted(&packet); + } + permit.send(packet); + self.emitted = self.emitted.wrapping_add(1); + if pending.is_finished() { + self.pending_tx = None; + } + } } } @@ -64,21 +173,41 @@ impl Device for RawIpDevice { fn receive(&mut self, _timestamp: Instant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> { // Reserve before consuming ingress: smoltcp can need an immediate // response, and concurrent generic UDP senders share this capacity. - let permit = self.output.try_reserve().ok()?; + if self.pending_tx.is_some() { + return None; + } + let permit = match self.output.try_reserve() { + Ok(permit) => Some(permit), + Err(mpsc::error::TrySendError::Full(())) + if self.rx.as_ref().is_some_and(is_pure_tcp_ack) => + { + None + } + Err(_) => return None, + }; let packet = self.rx.take()?; Some(( RawRxToken(packet), RawTxToken { permit, emitted: &mut self.emitted, + pending: &mut self.pending_tx, + path_mtu: &mut self.path_mtu, + mtu: self.capabilities.max_transmission_unit, }, )) } fn transmit(&mut self, _timestamp: Instant) -> Option> { + if self.pending_tx.is_some() { + return None; + } Some(RawTxToken { - permit: self.output.try_reserve().ok()?, + permit: Some(self.output.try_reserve().ok()?), emitted: &mut self.emitted, + pending: &mut self.pending_tx, + path_mtu: &mut self.path_mtu, + mtu: self.capabilities.max_transmission_unit, }) } @@ -99,8 +228,11 @@ impl RxToken for RawRxToken { } pub(crate) struct RawTxToken<'a> { - permit: mpsc::Permit<'a, Packet>, + permit: Option>, emitted: &'a mut u64, + pending: &'a mut Option, + path_mtu: &'a mut Option, + mtu: usize, } impl TxToken for RawTxToken<'_> { @@ -110,7 +242,35 @@ impl TxToken for RawTxToken<'_> { { let mut bytes = vec![0_u8; len]; let result = f(&mut bytes); - self.permit.send(Packet::new(bytes)); + let mut packet = Packet::new(bytes); + let Some(permit) = self.permit else { + // A pure incoming ACK can still require a challenge ACK or RST. + // Preserve that response in the existing single pending slot; + // never acknowledge a generated reply by silently dropping it. + *self.pending = Some(PendingTransmit::Reply(packet)); + return result; + }; + if let Some(paths) = self.path_mtu.as_mut() { + // Metadata parsing is limited to trusted smoltcp output. Packets + // within the path budget keep their allocation and byte contents. + if let Some(mut pending) = PendingSegments::new(packet.clone()) { + let budget = paths.budget(pending.flow(), self.mtu); + if packet.len() > budget { + let Some(first) = pending.next(budget) else { + return result; + }; + packet = first; + if !pending.is_finished() { + *self.pending = Some(PendingTransmit::Segments(pending)); + } + } + } else if packet.len() > self.mtu { + // No UDP boundary splitting or implicit IP fragmentation. + return result; + } + paths.emitted(&packet); + } + permit.send(packet); *self.emitted = self.emitted.wrapping_add(1); result } @@ -120,6 +280,129 @@ impl TxToken for RawTxToken<'_> { mod tests { use super::*; + #[test] + fn full_tx_consumes_pure_ack_without_allocating_an_output_or_queue() { + use crate::segmentation::tests::tcp_packet; + let flow = FlowKey { + source: "10.23.0.2:4000".parse().unwrap(), + destination: "198.51.100.2:443".parse().unwrap(), + }; + let (output, mut receiver) = mpsc::channel(1); + let mut device = RawIpDevice::new(1500, output.clone()); + let occupied = Packet::new(vec![0x45; 40]); + output.try_send(occupied.clone()).unwrap(); + for acknowledgment in 10..13 { + let ack = tcp_packet(flow, 1, acknowledgment, 16, 0); + device.push_rx(ack.clone()).unwrap(); + let (rx, tx) = device + .receive(Instant::now()) + .expect("full TX blocked pure TCP ACK"); + rx.consume(|bytes| assert_eq!(bytes, ack.data())); + drop(tx); // Valid ordinary ACKs do not require an immediate reply. + assert!(device.rx_is_empty()); + assert!(device.pending_tx.is_none()); + assert_eq!(device.emitted(), 0); + assert_eq!(output.capacity(), 0); + } + assert_eq!(receiver.try_recv().unwrap(), occupied); + assert!(receiver.try_recv().is_err()); + } + + #[test] + fn full_tx_preserves_ack_or_reset_reply_in_the_existing_pending_slot() { + use crate::segmentation::tests::tcp_packet; + let flow = FlowKey { + source: "10.23.0.2:4000".parse().unwrap(), + destination: "198.51.100.2:443".parse().unwrap(), + }; + for flags in [16, 20] { + let (output, mut receiver) = mpsc::channel(1); + let mut device = RawIpDevice::new(1500, output.clone()); + device.enable_path_mtu(); + let occupied = Packet::new(vec![0x45; 40]); + output.try_send(occupied.clone()).unwrap(); + device.push_rx(tcp_packet(flow, 1, 10, 16, 0)).unwrap(); + let (rx, tx) = device + .receive(Instant::now()) + .expect("pure ACK did not enter"); + rx.consume(|_| {}); + let response = tcp_packet(reverse(flow), 10, 1, flags, 0); + tx.consume(response.len(), |bytes| { + bytes.copy_from_slice(response.data()); + }); + assert!(device.pending_tx.is_some()); + assert_eq!(device.emitted(), 0); + assert!(device.transmit(Instant::now()).is_none()); + device.flush_pending(); + assert_eq!(device.emitted(), 0); // Never overwrite or bypass full output. + assert_eq!(receiver.try_recv().unwrap(), occupied); + device.flush_pending(); + assert_eq!(receiver.try_recv().unwrap(), response); + assert_eq!(device.emitted(), 1); + assert!(device.pending_tx.is_none()); + assert!(receiver.try_recv().is_err()); + } + } + + #[test] + fn full_tx_keeps_syn_data_fin_urgent_and_invalid_ack_pending() { + use crate::segmentation::tests::tcp_packet; + let flow = FlowKey { + source: "10.23.0.2:4000".parse().unwrap(), + destination: "198.51.100.2:443".parse().unwrap(), + }; + let mut inputs = vec![ + tcp_packet(flow, 1, 0, 2, 0), + tcp_packet(flow, 1, 10, 16, 1), + tcp_packet(flow, 1, 10, 17, 0), + tcp_packet(flow, 1, 10, 20, 0), + tcp_packet(flow, 1, 10, 48, 0), + ]; + let mut invalid = tcp_packet(flow, 1, 10, 16, 0).data().to_vec(); + *invalid.last_mut().unwrap() ^= 1; + inputs.push(Packet::new(invalid)); + for input in inputs { + let (output, _receiver) = mpsc::channel(1); + let mut device = RawIpDevice::new(1500, output.clone()); + output.try_send(Packet::new(vec![0x45; 40])).unwrap(); + device.push_rx(input).unwrap(); + assert!(device.receive(Instant::now()).is_none()); + assert!(!device.rx_is_empty()); + assert!(device.pending_tx.is_none()); + } + } + + #[test] + fn full_tx_pending_reply_blocks_a_second_ack_and_clear_releases_both() { + use crate::segmentation::tests::tcp_packet; + let flow = FlowKey { + source: "10.23.0.2:4000".parse().unwrap(), + destination: "198.51.100.2:443".parse().unwrap(), + }; + let (output, mut receiver) = mpsc::channel(1); + let mut device = RawIpDevice::new(1500, output.clone()); + output.try_send(Packet::new(vec![0x45; 40])).unwrap(); + device.push_rx(tcp_packet(flow, 1, 10, 16, 0)).unwrap(); + let (rx, tx) = device + .receive(Instant::now()) + .expect("pure ACK did not enter"); + rx.consume(|_| {}); + let response = tcp_packet(reverse(flow), 10, 1, 16, 0); + tx.consume(response.len(), |bytes| { + bytes.copy_from_slice(response.data()); + }); + device.push_rx(tcp_packet(flow, 1, 11, 16, 0)).unwrap(); + assert!(device.receive(Instant::now()).is_none()); + assert!(device.transmit(Instant::now()).is_none()); + assert!(device.pending_tx.is_some() && !device.rx_is_empty()); + device.clear(); + assert!(device.pending_tx.is_none() && device.rx_is_empty()); + receiver.try_recv().unwrap(); + device.flush_pending(); + assert!(receiver.try_recv().is_err()); + assert_eq!(device.emitted(), 0); + } + #[test] fn full_tx_queue_does_not_consume_rx() { let (output, mut receiver) = mpsc::channel(1); @@ -187,4 +470,90 @@ mod tests { assert!(device.transmit(Instant::now()).is_none()); assert!(!device.rx_is_empty()); } + + #[test] + fn split_output_resumes_with_one_reserved_slot_and_adopts_a_smaller_budget() { + use crate::segmentation::tests::{hint, tcp_packet}; + let flow = FlowKey { + source: "10.23.0.2:4000".parse().unwrap(), + destination: "198.51.100.2:443".parse().unwrap(), + }; + let (output, mut receiver) = mpsc::channel(1); + let mut device = RawIpDevice::new(9000, output); + device.enable_path_mtu(); + let first = tcp_packet(flow, 1000, 91, 16, 7000); + device + .transmit(Instant::now()) + .unwrap() + .consume(first.len(), |bytes| bytes.copy_from_slice(first.data())); + let emitted = receiver.try_recv().unwrap(); + assert!(device.apply_path_mtu(hint(&emitted, 1280))); + + let next = tcp_packet(flow, 10000, 91, 16, 7000); + device + .transmit(Instant::now()) + .unwrap() + .consume(next.len(), |bytes| bytes.copy_from_slice(next.data())); + assert_eq!(device.emitted(), 2); + assert!(device.pending_tx.is_some() && device.transmit(Instant::now()).is_none()); + device.flush_pending(); + assert_eq!(device.emitted(), 2); // full queue, no segment allocation + let segment = receiver.try_recv().unwrap(); + assert_eq!(segment.len(), 1280); + let mut unsent = hint(&segment, 576); + unsent.sequence = 12000; // original exists, this tail has not left it + assert!(!device.apply_path_mtu(unsent)); + assert!(device.apply_path_mtu(hint(&segment, 576))); + let mut payload_size = segment.len() - 56; + while device.pending_tx.is_some() { + device.flush_pending(); + let packet = receiver.try_recv().unwrap(); + assert!(packet.len() <= 576); + payload_size += packet.len() - 56; + } + assert_eq!(payload_size, 7000); + assert!(device.transmit(Instant::now()).is_some()); + // New oversized output can be discarded synchronously without having + // to drain the consumer, including its retained original allocation. + device + .transmit(Instant::now()) + .unwrap() + .consume(next.len(), |bytes| bytes.copy_from_slice(next.data())); + assert!(device.pending_tx.is_some()); + device.clear(); + assert!(device.pending_tx.is_none() && device.path_mtu.is_none()); + } + + #[test] + fn reclaimed_tuple_discards_pending_bytes_and_old_quote_authorization() { + use crate::segmentation::tests::{hint, tcp_packet}; + let flow = FlowKey { + source: "10.23.0.2:4000".parse().unwrap(), + destination: "198.51.100.2:443".parse().unwrap(), + }; + let (output, mut receiver) = mpsc::channel(1); + let mut device = RawIpDevice::new(1500, output); + device.enable_path_mtu(); + let packet = tcp_packet(flow, 1000, 91, 16, 1400); + device + .transmit(Instant::now()) + .unwrap() + .consume(packet.len(), |bytes| bytes.copy_from_slice(packet.data())); + let emitted = receiver.try_recv().unwrap(); + assert!(device.apply_path_mtu(hint(&emitted, 576))); + device + .transmit(Instant::now()) + .unwrap() + .consume(packet.len(), |bytes| bytes.copy_from_slice(packet.data())); + assert!(device.pending_tx.is_some()); + device.forget_flow(reverse(flow)); + assert!(device.pending_tx.is_none()); + assert!(!device.apply_path_mtu(hint(&emitted, 296))); + receiver.try_recv().unwrap(); + device + .transmit(Instant::now()) + .unwrap() + .consume(packet.len(), |bytes| bytes.copy_from_slice(packet.data())); + assert_eq!(receiver.try_recv().unwrap().len(), packet.len()); + } } diff --git a/crates/vole-netstack/src/echo.rs b/crates/vole-netstack/src/echo.rs new file mode 100644 index 0000000..ca27f3b --- /dev/null +++ b/crates/vole-netstack/src/echo.rs @@ -0,0 +1,426 @@ +//! Checked Echo wire helpers. No socket, task, DNS or routing ownership. +use std::net::{IpAddr, Ipv4Addr}; + +use smoltcp::{ + phy::ChecksumCapabilities, + wire::{ + Icmpv4Packet, Icmpv4Repr, Icmpv6Packet, Icmpv6Repr, IpProtocol, IpRepr, Ipv4Packet, + Ipv4Repr, Ipv6Packet, Ipv6Repr, + }, +}; + +/// The addresses here are the actual endpoints of this packet direction. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EchoPacketView<'a> { + pub source: IpAddr, + pub destination: IpAddr, + pub identifier: u16, + pub sequence: u16, + pub payload: &'a [u8], +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum EchoPacketError { + #[error("Echo packet endpoints use different address families")] + AddressFamily, + #[error("Echo packet exceeds the current raw-IP MTU")] + Mtu, +} + +/// Recognized Echo failures and unsupported raw-IP shapes cannot fall through +/// to the existing synthetic responder. Other ICMP/error/PMTU remains `NotEcho`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EchoPacketClass<'a> { + NotEcho, + Rejected, + Request(EchoPacketView<'a>), +} +#[must_use] +pub fn classify_echo_request(bytes: &[u8]) -> EchoPacketClass<'_> { + let recognized = match bytes.first().map(|b| b >> 4) { + Some(4) if bytes.get(9) == Some(&1) => { + // Includes non-initial fragments: no reassembly or local success. + if bytes.len() < 20 || u16::from_be_bytes([bytes[6], bytes[7]]) & 0x3fff != 0 { + return EchoPacketClass::Rejected; + } + let offset = usize::from(bytes[0] & 15) * 4; + if offset < 20 { + return EchoPacketClass::Rejected; + } + match bytes.get(offset) { + Some(8) => true, + None => return EchoPacketClass::Rejected, + _ => false, + } + } + Some(6) => match bytes.get(6) { + Some(58) => match bytes.get(40) { + Some(128) => true, + None => return EchoPacketClass::Rejected, + _ => false, + }, + // The existing netstack dispatches the base next_header only and + // does not implement these extension chains, including PMTU in + // one. Make that unsupported-shape drop explicit before autoEcho. + Some(0 | 43 | 44 | 50 | 51 | 60) => return EchoPacketClass::Rejected, + _ => false, + }, + _ => false, + }; + if !recognized { + return EchoPacketClass::NotEcho; + } + parse_echo_request_view(bytes).map_or(EchoPacketClass::Rejected, EchoPacketClass::Request) +} + +/// Classify only a complete, checksum-valid, unfragmented unicast Echo Request. +/// None leaves ownership and dispatch of the original packet with the caller. +#[must_use] +pub fn parse_echo_request_view(bytes: &[u8]) -> Option> { + parse_echo_view(bytes, false) +} + +fn unicast(address: IpAddr) -> bool { + match address { + IpAddr::V4(ip) => !ip.is_unspecified() && !ip.is_multicast() && ip != Ipv4Addr::BROADCAST, + IpAddr::V6(ip) => !ip.is_unspecified() && !ip.is_multicast(), + } +} + +fn parse_echo_view(bytes: &[u8], reply: bool) -> Option> { + let caps = ChecksumCapabilities::default(); + let (source, destination, identifier, sequence, payload) = match bytes.first()? >> 4 { + 4 => { + let ip = Ipv4Packet::new_checked(bytes).ok()?; + if ip.next_header() != IpProtocol::Icmp + || usize::from(ip.total_len()) != bytes.len() + || ip.more_frags() + || ip.frag_offset() != 0 + { + return None; + } + let repr = Ipv4Repr::parse(&ip, &caps).ok()?; + if repr.next_header != IpProtocol::Icmp { + return None; + } + let icmp = Icmpv4Packet::new_checked(ip.payload()).ok()?; + let (identifier, sequence, payload) = match Icmpv4Repr::parse(&icmp, &caps).ok()? { + Icmpv4Repr::EchoRequest { + ident, + seq_no, + data, + } if !reply => (ident, seq_no, data), + Icmpv4Repr::EchoReply { + ident, + seq_no, + data, + } if reply => (ident, seq_no, data), + _ => return None, + }; + ( + repr.src_addr.into(), + repr.dst_addr.into(), + identifier, + sequence, + payload, + ) + } + 6 => { + let ip = Ipv6Packet::new_checked(bytes).ok()?; + if ip.total_len() != bytes.len() || ip.next_header() != IpProtocol::Icmpv6 { + return None; + } + let repr = Ipv6Repr::parse(&ip).ok()?; + let icmp = Icmpv6Packet::new_checked(ip.payload()).ok()?; + let (identifier, sequence, payload) = + match Icmpv6Repr::parse(&repr.src_addr, &repr.dst_addr, &icmp, &caps).ok()? { + Icmpv6Repr::EchoRequest { + ident, + seq_no, + data, + } if !reply => (ident, seq_no, data), + Icmpv6Repr::EchoReply { + ident, + seq_no, + data, + } if reply => (ident, seq_no, data), + _ => return None, + }; + ( + repr.src_addr.into(), + repr.dst_addr.into(), + identifier, + sequence, + payload, + ) + } + _ => return None, + }; + if !unicast(source) || !unicast(destination) { + return None; + } + Some(EchoPacketView { + source, + destination, + identifier, + sequence, + payload, + }) +} + +/// Validate a body returned by a Linux DGRAM ICMP socket. `source` comes from +/// recvmsg's peer sockaddr, `destination` from `IP_PKTINFO/IPV6_PKTINFO`. The IPv6 +/// checksum therefore uses physical addresses, never the original TUN tuple. +#[must_use] +pub fn parse_echo_reply_body( + source: IpAddr, + destination: IpAddr, + bytes: &[u8], +) -> Option> { + if !unicast(source) || !unicast(destination) { + return None; + } + let caps = ChecksumCapabilities::default(); + let (identifier, sequence, payload) = match (source, destination) { + (IpAddr::V4(_), IpAddr::V4(_)) => { + let packet = Icmpv4Packet::new_checked(bytes).ok()?; + match Icmpv4Repr::parse(&packet, &caps).ok()? { + Icmpv4Repr::EchoReply { + ident, + seq_no, + data, + } => (ident, seq_no, data), + _ => return None, + } + } + (IpAddr::V6(src), IpAddr::V6(dst)) => { + let packet = Icmpv6Packet::new_checked(bytes).ok()?; + match Icmpv6Repr::parse(&src, &dst, &packet, &caps).ok()? { + Icmpv6Repr::EchoReply { + ident, + seq_no, + data, + } => (ident, seq_no, data), + _ => return None, + } + } + _ => return None, + }; + Some(EchoPacketView { + source, + destination, + identifier, + sequence, + payload, + }) +} + +/// Build a reply from the peer-validated Echo body and the retained TUN tuple. +/// The caller supplies reply-direction endpoints, not the original request. +/// Allocation happens only after address-family and complete length checks. +/// +/// # Errors +/// Returns an error if the address families differ or the complete packet +/// exceeds the raw-IP MTU or IP length limit. +pub fn encode_echo_reply_into( + packet: &EchoPacketView<'_>, + mtu: usize, + frame: &mut Vec, +) -> Result<(), EchoPacketError> { + emit_echo(packet, true, mtu, frame) +} + +fn emit_echo( + packet: &EchoPacketView<'_>, + reply: bool, + mtu: usize, + frame: &mut Vec, +) -> Result<(), EchoPacketError> { + if packet.source.is_ipv4() != packet.destination.is_ipv4() { + return Err(EchoPacketError::AddressFamily); + } + let body_len = 8usize + .checked_add(packet.payload.len()) + .ok_or(EchoPacketError::Mtu)?; + let header_len = if packet.source.is_ipv4() { + 20usize + } else { + 40usize + }; + let packet_len = header_len + .checked_add(body_len) + .ok_or(EchoPacketError::Mtu)?; + if packet_len > mtu || packet_len > usize::from(u16::MAX) { + return Err(EchoPacketError::Mtu); + } + let protocol = if packet.source.is_ipv4() { + IpProtocol::Icmp + } else { + IpProtocol::Icmpv6 + }; + let ip = IpRepr::new( + packet.source.into(), + packet.destination.into(), + protocol, + body_len, + 64, + ); + let caps = ChecksumCapabilities::default(); + frame.clear(); + frame.resize(packet_len, 0); + ip.emit(&mut frame[..], &caps); + match (packet.source, packet.destination) { + (IpAddr::V4(_), IpAddr::V4(_)) => { + let repr = if reply { + Icmpv4Repr::EchoReply { + ident: packet.identifier, + seq_no: packet.sequence, + data: packet.payload, + } + } else { + Icmpv4Repr::EchoRequest { + ident: packet.identifier, + seq_no: packet.sequence, + data: packet.payload, + } + }; + repr.emit( + &mut Icmpv4Packet::new_unchecked(&mut frame[header_len..]), + &caps, + ); + } + (IpAddr::V6(source), IpAddr::V6(destination)) => { + let repr = if reply { + Icmpv6Repr::EchoReply { + ident: packet.identifier, + seq_no: packet.sequence, + data: packet.payload, + } + } else { + Icmpv6Repr::EchoRequest { + ident: packet.identifier, + seq_no: packet.sequence, + data: packet.payload, + } + }; + repr.emit( + &source, + &destination, + &mut Icmpv6Packet::new_unchecked(&mut frame[header_len..]), + &caps, + ); + } + _ => return Err(EchoPacketError::AddressFamily), + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(ipv6: bool, payload: &[u8]) -> EchoPacketView<'_> { + EchoPacketView { + source: if ipv6 { "2001:db8::1" } else { "192.0.2.1" } + .parse() + .unwrap(), + destination: if ipv6 { "2001:db8::2" } else { "192.0.2.2" } + .parse() + .unwrap(), + identifier: 0xfedc, + sequence: 0xabcd, + payload, + } + } + + #[test] + fn checked_echo_preserves_tuple_identity_odd_payload_and_both_checksums() { + for ipv6 in [false, true] { + for payload in [&b""[..], &b"a"[..], &b"odd-payload"[..]] { + let view = request(ipv6, payload); + let mut input = Vec::new(); + emit_echo(&view, false, 1500, &mut input).unwrap(); + let parsed = parse_echo_request_view(&input).unwrap(); + assert_eq!(parsed, view); + assert_eq!( + classify_echo_request(&input), + EchoPacketClass::Request(view) + ); + let reply = EchoPacketView { + source: parsed.destination, + destination: parsed.source, + ..parsed + }; + let mut frame = Vec::new(); + encode_echo_reply_into(&reply, 1500, &mut frame).unwrap(); + assert_eq!(parse_echo_view(&frame, true), Some(reply)); + let header = if ipv6 { 40 } else { 20 }; + assert_eq!( + parse_echo_reply_body(reply.source, reply.destination, &frame[header..]), + Some(reply) + ); + assert!(parse_echo_request_view(&frame).is_none()); + } + } + } + + #[test] + fn malformed_echo_never_becomes_a_valid_forwarding_request() { + for ipv6 in [false, true] { + let mut valid = Vec::new(); + emit_echo(&request(ipv6, b"odd"), false, 1500, &mut valid).unwrap(); + let mut bad_checksum = valid.clone(); + *bad_checksum.last_mut().unwrap() ^= 1; + assert!(parse_echo_request_view(&bad_checksum).is_none()); + assert_eq!( + classify_echo_request(&bad_checksum), + EchoPacketClass::Rejected + ); + let mut trailing = valid.clone(); + trailing.push(0); + assert!(parse_echo_request_view(&trailing).is_none()); + assert!(parse_echo_request_view(&valid[..valid.len() - 1]).is_none()); + } + let mut fragmented = Vec::new(); + emit_echo(&request(false, b"x"), false, 1500, &mut fragmented).unwrap(); + fragmented[6] |= 0x20; + Ipv4Packet::new_unchecked(&mut fragmented[..]).fill_checksum(); + assert!(parse_echo_request_view(&fragmented).is_none()); + assert_eq!( + classify_echo_request(&fragmented), + EchoPacketClass::Rejected + ); + let mut extension = vec![0u8; 56]; + extension[0] = 0x60; + extension[6] = 44; + extension[40] = 58; + extension[48] = 128; + assert_eq!(classify_echo_request(&extension), EchoPacketClass::Rejected); + } + + #[test] + fn reply_allocation_is_rejected_before_touching_the_existing_frame() { + let packet = request(true, b"payload"); + let mut frame = vec![1, 2, 3]; + let before_capacity = frame.capacity(); + assert_eq!( + encode_echo_reply_into(&packet, 1, &mut frame), + Err(EchoPacketError::Mtu) + ); + assert_eq!(frame, [1, 2, 3]); + assert_eq!(frame.capacity(), before_capacity); + } + + #[test] + fn invalid_echo_code_is_dropped_but_non_echo_errors_keep_the_original_path() { + let mut packet = Vec::new(); + emit_echo(&request(false, b"odd"), false, 1500, &mut packet).unwrap(); + packet[21] = 1; + Icmpv4Packet::new_unchecked(&mut packet[20..]).fill_checksum(); + assert_eq!(classify_echo_request(&packet), EchoPacketClass::Rejected); + packet[20] = 3; + packet[21] = 4; + Icmpv4Packet::new_unchecked(&mut packet[20..]).fill_checksum(); + assert_eq!(classify_echo_request(&packet), EchoPacketClass::NotEcho); + } +} diff --git a/crates/vole-netstack/src/lib.rs b/crates/vole-netstack/src/lib.rs index 8a3e591..08e74ae 100644 --- a/crates/vole-netstack/src/lib.rs +++ b/crates/vole-netstack/src/lib.rs @@ -5,21 +5,31 @@ //! packets from [`PacketStream`]. TCP and UDP are exposed separately to the //! outbound dispatcher. +mod client; mod config; mod device; +mod echo; mod icmp; mod packet; +mod pmtu; +mod segmentation; mod stack; mod tcp; mod udp; -pub use config::{ConfigError, NetStackConfig}; +pub use client::{ClientConfig, ClientPrefix, MAX_RECEIVE_PACKET_SIZE}; +pub use config::{ClientAddresses, ConfigError, NetStackConfig}; +pub use echo::{ + EchoPacketClass, EchoPacketError, EchoPacketView, classify_echo_request, + encode_echo_reply_into, parse_echo_reply_body, parse_echo_request_view, +}; pub use packet::Packet; +pub use pmtu::{PathMtuHint, build_packet_too_big, parse_path_mtu_hint}; pub use stack::{ - NetStack, NetStackControl, NetStackError, NetStackParts, NetStackStats, PacketSink, - PacketStream, ResourceSnapshot, TcpNetStackParts, + ClientNetStackParts, NetStack, NetStackControl, NetStackError, NetStackParts, NetStackStats, + PacketSink, PacketStream, ResourceSnapshot, TcpNetStackParts, }; -pub use tcp::{TcpListener, TcpStream}; +pub use tcp::{TcpConnector, TcpListener, TcpStream}; pub use udp::{ UdpDatagram, UdpError, UdpPacketView, UdpSender, UdpSocket, encode_udp_packet_into, parse_udp_packet_view, diff --git a/crates/vole-netstack/src/pmtu.rs b/crates/vole-netstack/src/pmtu.rs new file mode 100644 index 0000000..ee0e2a0 --- /dev/null +++ b/crates/vole-netstack/src/pmtu.rs @@ -0,0 +1,431 @@ +//! Client-only ICMP path feedback. Parsing borrows the packet and allocates no +//! state; bounded egress metadata owns tuple/sequence correlation and path MTU. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + +use crate::Packet; + +const TCP: u8 = 6; +const UDP: u8 = 17; +const ICMPV4: u8 = 1; +const ICMPV6: u8 = 58; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PathMtuHint { + pub outer_destination: IpAddr, + pub local: SocketAddr, + pub remote: SocketAddr, + pub sequence: u32, + pub mtu: usize, +} + +/// Parses a complete ICMP Fragmentation Needed / Packet Too Big message and +/// its possibly truncated TCP quotation. An embedded original IP total length +/// can exceed the quotation length; eight TCP bytes suffice for correlation. +/// +/// The caller must match the outer destination and local/remote tuple against +/// an active client socket. Its packet adapter must validate the quoted sequence +/// against actually emitted output before accepting any MTU reduction. +#[must_use] +pub fn parse_path_mtu_hint(packet: &[u8]) -> Option { + let outer = complete_ip(packet)?; + let icmp = outer.payload; + if icmp.len() < 8 || !unicast(outer.source) || !unicast(outer.destination) { + return None; + } + let (mtu, quote) = match (outer.source, outer.protocol, icmp[0], icmp[1]) { + (IpAddr::V4(_), ICMPV4, 3, 4) => { + if checksum(icmp) != 0 { + return None; + } + let quote = &icmp[8..]; + let mut mtu = usize::from(u16::from_be_bytes([icmp[6], icmp[7]])); + if mtu == 0 { + mtu = legacy_ipv4_mtu(quote)?; + } + (mtu.max(68), quote) + } + (IpAddr::V6(_), ICMPV6, 2, 0) => { + if transport_checksum(outer.source, outer.destination, ICMPV6, icmp) != 0 { + return None; + } + let mtu = usize::try_from(u32::from_be_bytes(icmp[4..8].try_into().ok()?)).ok()?; + // The frozen client reference discards this hint instead of + // replacing it with the IPv6 minimum link MTU. + if mtu < 1_280 { + return None; + } + (mtu, &icmp[8..]) + } + _ => return None, + }; + let quoted = quoted_ip(quote)?; + if quoted.protocol != TCP + || quoted.payload.len() < 8 + || quoted.source.is_ipv4() != outer.source.is_ipv4() + || !unicast(quoted.source) + || !unicast(quoted.destination) + { + return None; + } + let tcp = quoted.payload; + Some(PathMtuHint { + outer_destination: outer.destination, + local: SocketAddr::new(quoted.source, u16::from_be_bytes([tcp[0], tcp[1]])), + remote: SocketAddr::new(quoted.destination, u16::from_be_bytes([tcp[2], tcp[3]])), + sequence: u32::from_be_bytes(tcp[4..8].try_into().ok()?), + mtu, + }) +} + +/// Creates local too-big feedback for a complete emitted TCP/UDP packet that +/// the controlled packet transport cannot carry. The quotation is bounded by +/// the IPv4 576-byte / IPv6 1280-byte ICMP packet size and preserves the bytes +/// used for active-flow correlation. No host packet or network I/O is performed. +#[must_use] +pub fn build_packet_too_big(quoted: &[u8], mtu: usize) -> Option { + let original = complete_ip(quoted)?; + if !matches!(original.protocol, TCP | UDP) + || original.payload.len() < 8 + || !unicast(original.source) + || !unicast(original.destination) + || quoted.len() <= mtu + { + return None; + } + match (original.source, original.destination) { + (IpAddr::V4(local), IpAddr::V4(remote)) => { + let mtu = u16::try_from(mtu.max(68)).ok()?; + let quote_len = quoted.len().min(576 - 20 - 8); + let total_len = 20 + 8 + quote_len; + let mut bytes = vec![0; total_len]; + bytes[0] = 0x45; + bytes[2..4].copy_from_slice(&u16::try_from(total_len).ok()?.to_be_bytes()); + bytes[8] = 64; + bytes[9] = ICMPV4; + bytes[12..16].copy_from_slice(&remote.octets()); + bytes[16..20].copy_from_slice(&local.octets()); + bytes[20] = 3; + bytes[21] = 4; + bytes[26..28].copy_from_slice(&mtu.to_be_bytes()); + bytes[28..].copy_from_slice("ed[..quote_len]); + let icmp_checksum = checksum(&bytes[20..]); + bytes[22..24].copy_from_slice(&icmp_checksum.to_be_bytes()); + let ip_checksum = checksum(&bytes[..20]); + bytes[10..12].copy_from_slice(&ip_checksum.to_be_bytes()); + Some(Packet::new(bytes)) + } + (IpAddr::V6(local), IpAddr::V6(remote)) => { + if mtu < 1_280 { + return None; + } + let mtu = u32::try_from(mtu).ok()?; + let quote_len = quoted.len().min(1_280 - 40 - 8); + // A very long quoted extension chain cannot supply the required + // transport prefix within the permitted ICMP packet size. + if quoted_ip("ed[..quote_len])?.payload.len() < 8 { + return None; + } + let payload_len = 8 + quote_len; + let mut bytes = vec![0; 40 + payload_len]; + bytes[0] = 0x60; + bytes[4..6].copy_from_slice(&u16::try_from(payload_len).ok()?.to_be_bytes()); + bytes[6] = ICMPV6; + bytes[7] = 64; + bytes[8..24].copy_from_slice(&remote.octets()); + bytes[24..40].copy_from_slice(&local.octets()); + bytes[40] = 2; + bytes[44..48].copy_from_slice(&mtu.to_be_bytes()); + bytes[48..].copy_from_slice("ed[..quote_len]); + let value = transport_checksum(remote.into(), local.into(), ICMPV6, &bytes[40..]); + bytes[42..44].copy_from_slice(&value.to_be_bytes()); + Some(Packet::new(bytes)) + } + _ => None, + } +} + +struct IpView<'a> { + source: IpAddr, + destination: IpAddr, + protocol: u8, + payload: &'a [u8], +} + +fn complete_ip(bytes: &[u8]) -> Option> { + match bytes.first()? >> 4 { + 4 => { + if bytes.len() < 20 { + return None; + } + let header_len = usize::from(bytes[0] & 0x0f) * 4; + let total_len = usize::from(u16::from_be_bytes([bytes[2], bytes[3]])); + if header_len < 20 + || header_len > bytes.len() + || total_len != bytes.len() + || checksum(&bytes[..header_len]) != 0 + || u16::from_be_bytes([bytes[6], bytes[7]]) & 0x3fff != 0 + { + return None; + } + ipv4_view(bytes, header_len) + } + 6 => { + if bytes.len() < 40 + || 40 + usize::from(u16::from_be_bytes([bytes[4], bytes[5]])) != bytes.len() + { + return None; + } + ipv6_view(bytes, false) + } + _ => None, + } +} + +fn quoted_ip(bytes: &[u8]) -> Option> { + match bytes.first()? >> 4 { + 4 => { + if bytes.len() < 20 { + return None; + } + let header_len = usize::from(bytes[0] & 0x0f) * 4; + if header_len < 20 + || header_len > bytes.len() + || u16::from_be_bytes([bytes[6], bytes[7]]) & 0x1fff != 0 + { + return None; + } + // RFC ICMP quotations commonly omit the original TCP body; no + // full-packet length or transport checksum is available here. + ipv4_view(bytes, header_len) + } + 6 if bytes.len() >= 40 => ipv6_view(bytes, true), + _ => None, + } +} + +fn ipv4_view(bytes: &[u8], header_len: usize) -> Option> { + Some(IpView { + source: Ipv4Addr::from(<[u8; 4]>::try_from(&bytes[12..16]).ok()?).into(), + destination: Ipv4Addr::from(<[u8; 4]>::try_from(&bytes[16..20]).ok()?).into(), + protocol: bytes[9], + payload: &bytes[header_len..], + }) +} + +fn ipv6_view(bytes: &[u8], quoted: bool) -> Option> { + let source = Ipv6Addr::from(<[u8; 16]>::try_from(&bytes[8..24]).ok()?); + let destination = Ipv6Addr::from(<[u8; 16]>::try_from(&bytes[24..40]).ok()?); + if source.to_ipv4_mapped().is_some() || destination.to_ipv4_mapped().is_some() { + return None; + } + let mut next = bytes[6]; + let mut offset: usize = 40; + loop { + match next { + 0 | 43 | 60 | 135 | 51 => { + let header = bytes.get(offset..offset.checked_add(2)?)?; + let length = if next == 51 { + (usize::from(header[1]) + 2) * 4 + } else { + (usize::from(header[1]) + 1) * 8 + }; + offset = offset.checked_add(length)?; + if offset > bytes.len() { + return None; + } + next = header[0]; + } + 44 => { + let header = bytes.get(offset..offset.checked_add(8)?)?; + let fragment = u16::from_be_bytes([header[2], header[3]]); + if fragment & 0xfff8 != 0 || (!quoted && fragment & 1 != 0) { + return None; + } + next = header[0]; + offset += 8; + } + _ => { + return Some(IpView { + source: source.into(), + destination: destination.into(), + protocol: next, + payload: &bytes[offset..], + }); + } + } + } +} + +fn unicast(address: IpAddr) -> bool { + !address.is_unspecified() + && !address.is_multicast() + && !matches!(address, IpAddr::V4(ip) if ip.is_broadcast()) +} + +fn legacy_ipv4_mtu(quote: &[u8]) -> Option { + if quote.len() < 20 || quote[0] >> 4 != 4 { + return None; + } + let header_len = usize::from(quote[0] & 0x0f) * 4; + let total_len = usize::from(u16::from_be_bytes([quote[2], quote[3]])); + if header_len < 20 || total_len < header_len { + return None; + } + Some( + [ + 65_535, 32_000, 17_914, 8_166, 4_352, 2_002, 1_492, 1_006, 508, 296, 68, + ] + .into_iter() + .find(|plateau| *plateau < total_len) + .unwrap_or(68), + ) +} + +fn sum_words(bytes: &[u8]) -> u32 { + let (words, remainder) = bytes.as_chunks::<2>(); + let mut sum = words + .iter() + .map(|word| u32::from(u16::from_be_bytes([word[0], word[1]]))) + .sum::(); + if let Some(last) = remainder.first() { + sum += u32::from(*last) << 8; + } + sum +} + +fn fold_checksum(mut sum: u32) -> u16 { + while sum >> 16 != 0 { + sum = (sum & 0xffff) + (sum >> 16); + } + !u16::try_from(sum).expect("folded checksum fits u16") +} + +fn checksum(bytes: &[u8]) -> u16 { + fold_checksum(sum_words(bytes)) +} + +fn transport_checksum(source: IpAddr, destination: IpAddr, protocol: u8, payload: &[u8]) -> u16 { + let mut sum = sum_words(payload) + u32::from(protocol); + if let (IpAddr::V6(source), IpAddr::V6(destination)) = (source, destination) { + sum += sum_words(&source.octets()) + sum_words(&destination.octets()); + // The caller's complete ordinary IPv6 packet bounds this conversion. + let length = u32::try_from(payload.len()).unwrap_or(u32::MAX); + sum += (length >> 16) + (length & 0xffff); + } + fold_checksum(sum) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn emitted_packet(ipv6: bool, size: usize) -> Vec { + let header_len = if ipv6 { 40 } else { 20 }; + let mut bytes = vec![0; size]; + if ipv6 { + bytes[0] = 0x60; + bytes[4..6].copy_from_slice(&u16::try_from(size - 40).unwrap().to_be_bytes()); + bytes[6] = TCP; + bytes[7] = 64; + bytes[8..24].copy_from_slice(&"2001:db8:1::2".parse::().unwrap().octets()); + bytes[24..40].copy_from_slice(&"2001:db8:2::9".parse::().unwrap().octets()); + } else { + bytes[0] = 0x45; + bytes[2..4].copy_from_slice(&u16::try_from(size).unwrap().to_be_bytes()); + bytes[6] = 0x40; + bytes[8] = 64; + bytes[9] = TCP; + bytes[12..16].copy_from_slice(&[10, 23, 0, 2]); + bytes[16..20].copy_from_slice(&[198, 51, 100, 9]); + let value = checksum(&bytes[..20]); + bytes[10..12].copy_from_slice(&value.to_be_bytes()); + } + bytes[header_len..header_len + 2].copy_from_slice(&49_152_u16.to_be_bytes()); + bytes[header_len + 2..header_len + 4].copy_from_slice(&443_u16.to_be_bytes()); + bytes[header_len + 4..header_len + 8].copy_from_slice(&0xffff_fffe_u32.to_be_bytes()); + bytes[header_len + 12] = 0x50; + bytes + } + + fn repair_ipv4(bytes: &mut [u8]) { + let length = u16::try_from(bytes.len()).unwrap(); + bytes[2..4].copy_from_slice(&length.to_be_bytes()); + bytes[10..12].fill(0); + bytes[22..24].fill(0); + let value = checksum(&bytes[20..]); + bytes[22..24].copy_from_slice(&value.to_be_bytes()); + let value = checksum(&bytes[..20]); + bytes[10..12].copy_from_slice(&value.to_be_bytes()); + } + + #[test] + fn real_truncated_icmp_quote_preserves_tuple_sequence_and_checksum_policy() { + for (ipv6, mtu, maximum) in [(false, 296, 576), (true, 1280, 1280)] { + let original = emitted_packet(ipv6, 1500); + let reply = build_packet_too_big(&original, mtu).unwrap(); + assert_eq!(reply.len(), maximum); + // Independently check generated checksums using smoltcp's wire + // decoder, rather than relying only on this module's parser. + if ipv6 { + let ip = smoltcp::wire::Ipv6Packet::new_checked(reply.data()).unwrap(); + let icmp = smoltcp::wire::Icmpv6Packet::new_checked(ip.payload()).unwrap(); + assert!(icmp.verify_checksum(&ip.src_addr(), &ip.dst_addr())); + } else { + let ip = smoltcp::wire::Ipv4Packet::new_checked(reply.data()).unwrap(); + assert!(ip.verify_checksum()); + let icmp = smoltcp::wire::Icmpv4Packet::new_checked(ip.payload()).unwrap(); + assert!(icmp.verify_checksum()); + } + let hint = parse_path_mtu_hint(reply.data()).unwrap(); + assert_eq!(hint.mtu, mtu); + assert_eq!(hint.sequence, 0xffff_fffe); + assert_eq!(hint.local.port(), 49_152); + assert_eq!(hint.remote.port(), 443); + assert_eq!(hint.outer_destination, hint.local.ip()); + let mut corrupt = reply.data().to_vec(); + *corrupt.last_mut().unwrap() ^= 1; + assert!(parse_path_mtu_hint(&corrupt).is_none()); + assert!(parse_path_mtu_hint(&reply.data()[..reply.len() - 1]).is_none()); + } + // RFC 792 permits an original IPv4 header plus eight TCP bytes only, + // even though the embedded total length continues to say 1500. + let original = emitted_packet(false, 1500); + let mut minimal = build_packet_too_big(&original, 68).unwrap().data()[..56].to_vec(); + repair_ipv4(&mut minimal); + assert_eq!(parse_path_mtu_hint(&minimal).unwrap().mtu, 68); + minimal[26..28].fill(0); + repair_ipv4(&mut minimal); + assert_eq!(parse_path_mtu_hint(&minimal).unwrap().mtu, 1492); + minimal[28 + 6..28 + 8].copy_from_slice(&1_u16.to_be_bytes()); + repair_ipv4(&mut minimal); + assert!(parse_path_mtu_hint(&minimal).is_none()); + } + + #[test] + fn ipv6_under_minimum_and_truncated_extension_quotes_have_real_semantics() { + let original = emitted_packet(true, 1500); + assert!(build_packet_too_big(&original, 1279).is_none()); + let mut bytes = build_packet_too_big(&original, 1280) + .unwrap() + .data() + .to_vec(); + bytes[44..48].copy_from_slice(&1279_u32.to_be_bytes()); + bytes[42..44].fill(0); + let outer = complete_ip(&bytes).unwrap(); + let value = transport_checksum(outer.source, outer.destination, ICMPV6, outer.payload); + bytes[42..44].copy_from_slice(&value.to_be_bytes()); + assert!(parse_path_mtu_hint(&bytes).is_none()); + + // A quoted first IPv6 fragment can expose TCP's tuple and sequence; + // a non-first fragment cannot. Only the eight-byte TCP prefix remains. + let mut quote = original[..40].to_vec(); + quote[6] = 44; + quote.extend_from_slice(&[TCP, 0, 0, 1, 0, 0, 0, 9]); + quote.extend_from_slice(&original[40..48]); + assert_eq!(quoted_ip("e).unwrap().payload.len(), 8); + quote[43] = 9; + assert!(quoted_ip("e).is_none()); + assert!(quoted_ip("e[..47]).is_none()); + } +} diff --git a/crates/vole-netstack/src/segmentation.rs b/crates/vole-netstack/src/segmentation.rs new file mode 100644 index 0000000..8104d08 --- /dev/null +++ b/crates/vole-netstack/src/segmentation.rs @@ -0,0 +1,536 @@ +//! Bounded packet-layer TCP adaptation around the unmodified smoltcp engine. +//! The TCP socket continues to own ACK, retransmission and congestion state. + +use std::collections::VecDeque; + +use smoltcp::wire::{IpProtocol, Ipv4Packet, Ipv6Packet, TcpPacket}; + +use crate::{Packet, pmtu::PathMtuHint, tcp::FlowKey}; + +/// Exhaustion forgets older optimizations; it never authorizes unseen bytes. +const CACHE_ENTRIES: usize = 64; + +#[derive(Clone, Copy)] +struct TcpView { + flow: FlowKey, + ip_len: usize, + tcp_len: usize, + sequence: u32, + acknowledgment: u32, + flags: u8, + payload_len: usize, +} + +impl TcpView { + fn parse(bytes: &[u8], verify_checksum: bool) -> Option { + let (source, destination, ip_len) = match bytes.first()? >> 4 { + 4 => { + let ip = Ipv4Packet::new_checked(bytes).ok()?; + if usize::from(ip.total_len()) != bytes.len() + || ip.next_header() != IpProtocol::Tcp + || u16::from_be_bytes([bytes[6], bytes[7]]) & 0x3fff != 0 + || (verify_checksum && !ip.verify_checksum()) + { + return None; + } + ( + ip.src_addr().into(), + ip.dst_addr().into(), + usize::from(ip.header_len()), + ) + } + 6 => { + let ip = Ipv6Packet::new_checked(bytes).ok()?; + if ip.total_len() != bytes.len() || ip.next_header() != IpProtocol::Tcp { + return None; + } + (ip.src_addr().into(), ip.dst_addr().into(), 40) + } + _ => return None, + }; + let tcp = TcpPacket::new_checked(&bytes[ip_len..]).ok()?; + if verify_checksum && !tcp.verify_checksum(&source, &destination) { + return None; + } + let tcp_len = usize::from(tcp.header_len()); + Some(Self { + flow: FlowKey { + source: std::net::SocketAddr::new(source.into(), tcp.src_port()), + destination: std::net::SocketAddr::new(destination.into(), tcp.dst_port()), + }, + ip_len, + tcp_len, + sequence: u32::from_be_bytes(bytes[ip_len + 4..ip_len + 8].try_into().ok()?), + acknowledgment: u32::from_be_bytes(bytes[ip_len + 8..ip_len + 12].try_into().ok()?), + flags: bytes[ip_len + 13], + payload_len: bytes.len().checked_sub(ip_len + tcp_len)?, + }) + } +} + +/// Only the queue-pressure fast path uses this classifier. The normal path +/// still lets smoltcp validate every accepted packet. A rejected candidate is +/// retained, not discarded, until ordinary output reservation can succeed. +pub(crate) fn is_pure_tcp_ack(packet: &Packet) -> bool { + TcpView::parse(packet.data(), true).is_some_and(|view| { + view.payload_len == 0 && view.flags & 16 != 0 && view.flags & !(16 | 64 | 128) == 0 + }) +} + +/// At most one original packet is retained while the bounded output drains. +/// Options are copied unchanged; FIN/PSH belong to the last segment and CWR +/// belongs to the first. IPv6 extensions and IP fragmentation are not split. +pub(crate) struct PendingSegments { + original: Packet, + view: TcpView, + offset: usize, +} + +impl PendingSegments { + pub(crate) fn new(original: Packet) -> Option { + let view = TcpView::parse(original.data(), false)?; + Some(Self { + original, + view, + offset: 0, + }) + } + + pub(crate) fn flow(&self) -> FlowKey { + self.view.flow + } + + pub(crate) fn next(&mut self, budget: usize) -> Option { + if self.offset >= self.view.payload_len + || (self.view.flow.source.is_ipv6() && budget < 1280) + // SYN/RST/URG need special sequence or urgent-pointer semantics; + // fail closed rather than fragmenting such an oversized packet. + || self.view.flags & (2 | 4 | 32) != 0 + { + return None; + } + let header_len = self.view.ip_len + self.view.tcp_len; + let capacity = budget + .checked_sub(header_len) + .filter(|length| *length != 0)?; + let length = capacity.min(self.view.payload_len - self.offset); + let mut bytes = Vec::with_capacity(header_len + length); + bytes.extend_from_slice(&self.original.data()[..header_len]); + bytes.extend_from_slice( + &self.original.data()[header_len + self.offset..header_len + self.offset + length], + ); + let sequence = self + .view + .sequence + .wrapping_add(u32::try_from(self.offset).ok()?); + bytes[self.view.ip_len + 4..self.view.ip_len + 8].copy_from_slice(&sequence.to_be_bytes()); + if self.offset != 0 { + bytes[self.view.ip_len + 13] &= !0x80; + } + self.offset += length; + if self.offset != self.view.payload_len { + bytes[self.view.ip_len + 13] &= !(1 | 8); + } + let packet_len = bytes.len(); + if self.view.flow.source.is_ipv4() { + let mut ip = Ipv4Packet::new_unchecked(&mut bytes); + ip.set_total_len(u16::try_from(packet_len).ok()?); + ip.fill_checksum(); + } else { + Ipv6Packet::new_unchecked(&mut bytes) + .set_payload_len(u16::try_from(packet_len - 40).ok()?); + } + TcpPacket::new_unchecked(&mut bytes[self.view.ip_len..]).fill_checksum( + &self.view.flow.source.ip().into(), + &self.view.flow.destination.ip().into(), + ); + Some(Packet::new(bytes)) + } + + pub(crate) fn is_finished(&self) -> bool { + self.offset >= self.view.payload_len + } +} + +#[derive(Clone, Copy)] +struct SentRange { + flow: FlowKey, + first: u32, + end: u32, +} + +#[derive(Clone, Copy)] +struct Path { + flow: FlowKey, + mtu: usize, +} + +#[derive(Clone, Copy)] +struct Acknowledgment { + flow: FlowKey, + sequence: u32, +} + +/// Quotes must identify an actually emitted and still unacknowledged range. +/// Passive TUN and opted-in client mode allocate this bounded metadata; it +/// stores no body and never grows with the number of live connections. +#[derive(Default)] +pub(crate) struct PathMtu { + sent: VecDeque, + paths: VecDeque, + acknowledgments: VecDeque, +} + +impl PathMtu { + pub(crate) fn emitted(&mut self, packet: &Packet) { + let Some(view) = TcpView::parse(packet.data(), false) else { + return; + }; + let length = + view.payload_len + usize::from(view.flags & 1 != 0) + usize::from(view.flags & 2 != 0); + if length == 0 { + return; + } + let Ok(length) = u32::try_from(length) else { + return; + }; + let mut first = view.sequence; + let end = first.wrapping_add(length); + if let Some(ack) = self + .acknowledgments + .iter() + .find(|ack| ack.flow == view.flow) + { + // A retransmitted original can still be waiting for output when + // its previous copy is ACKed. Do not re-authorize those bytes. + if at_or_after(ack.sequence, end) { + return; + } + if in_range(ack.sequence, first, end) { + first = ack.sequence; + } + } + if self.sent.len() == CACHE_ENTRIES { + self.sent.pop_front(); + } + self.sent.push_back(SentRange { + flow: view.flow, + first, + end, + }); + } + + pub(crate) fn acknowledge(&mut self, packet: &Packet) { + // Egress is trusted smoltcp output. Ingress must pass both checksums + // before it can revoke or trim the quote authorization metadata. + let Some(view) = TcpView::parse(packet.data(), true) else { + return; + }; + if view.flags & 16 == 0 { + return; + } + let flow = reverse(view.flow); + // Only a range previously exposed to the peer can seed its ACK + // watermark. ACKs outside it may revoke optimization metadata below, + // but cannot create a lasting reference for queued, unsent bytes. + if self.sent.iter().any(|sent| { + sent.flow == flow + && (in_range(view.acknowledgment, sent.first, sent.end) + || view.acknowledgment == sent.end) + }) { + if let Some(ack) = self.acknowledgments.iter_mut().find(|ack| ack.flow == flow) { + if at_or_after(view.acknowledgment, ack.sequence) { + ack.sequence = view.acknowledgment; + } + } else { + if self.acknowledgments.len() == CACHE_ENTRIES { + self.acknowledgments.pop_front(); + } + self.acknowledgments.push_back(Acknowledgment { + flow, + sequence: view.acknowledgment, + }); + } + } + for sent in &mut self.sent { + if sent.flow == flow && in_range(view.acknowledgment, sent.first, sent.end) { + sent.first = view.acknowledgment; + } + } + self.sent + .retain(|sent| sent.flow != flow || !at_or_after(view.acknowledgment, sent.end)); + } + + pub(crate) fn budget(&self, flow: FlowKey, initial_mtu: usize) -> usize { + self.paths + .iter() + .find(|path| path.flow == flow) + .map_or(initial_mtu, |path| path.mtu.min(initial_mtu)) + } + + pub(crate) fn apply(&mut self, hint: PathMtuHint, initial_mtu: usize) -> bool { + let flow = FlowKey { + source: hint.local, + destination: hint.remote, + }; + if hint.outer_destination != hint.local.ip() + || hint.mtu < if hint.local.is_ipv6() { 1280 } else { 68 } + || hint.mtu >= self.budget(flow, initial_mtu) + || !self + .sent + .iter() + .any(|sent| sent.flow == flow && in_range(hint.sequence, sent.first, sent.end)) + { + return false; + } + self.paths.retain(|path| path.flow != flow); + if self.paths.len() == CACHE_ENTRIES { + self.paths.pop_front(); + } + self.paths.push_back(Path { + flow, + mtu: hint.mtu, + }); + true + } + + pub(crate) fn forget(&mut self, flow: FlowKey) { + self.sent.retain(|sent| sent.flow != flow); + self.paths.retain(|path| path.flow != flow); + self.acknowledgments.retain(|ack| ack.flow != flow); + } +} + +pub(crate) fn reverse(flow: FlowKey) -> FlowKey { + FlowKey { + source: flow.destination, + destination: flow.source, + } +} + +fn in_range(value: u32, first: u32, end: u32) -> bool { + value.wrapping_sub(first) < end.wrapping_sub(first) +} + +fn at_or_after(value: u32, reference: u32) -> bool { + value.wrapping_sub(reference).cast_signed() >= 0 +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + fn flow(ipv6: bool, port: u16) -> FlowKey { + if ipv6 { + FlowKey { + source: format!("[2001:db8::2]:{port}").parse().unwrap(), + destination: "[2001:db8:1::2]:443".parse().unwrap(), + } + } else { + FlowKey { + source: format!("10.23.0.2:{port}").parse().unwrap(), + destination: "198.51.100.2:443".parse().unwrap(), + } + } + } + + pub(crate) fn tcp_packet( + flow: FlowKey, + sequence: u32, + ack: u32, + flags: u8, + size: usize, + ) -> Packet { + let ip_len = if flow.source.is_ipv6() { 40 } else { 24 }; + let tcp_len = 32; + let mut bytes = vec![0; ip_len + tcp_len + size]; + let packet_len = bytes.len(); + if let (std::net::IpAddr::V4(source), std::net::IpAddr::V4(destination)) = + (flow.source.ip(), flow.destination.ip()) + { + bytes[0] = 0x46; + bytes[2..4].copy_from_slice(&u16::try_from(packet_len).unwrap().to_be_bytes()); + bytes[6] = 0x40; + bytes[8] = 64; + bytes[9] = 6; + bytes[12..16].copy_from_slice(&source.octets()); + bytes[16..20].copy_from_slice(&destination.octets()); + bytes[20..24].copy_from_slice(&[1, 1, 0, 0]); + Ipv4Packet::new_unchecked(&mut bytes).fill_checksum(); + } else if let (std::net::IpAddr::V6(source), std::net::IpAddr::V6(destination)) = + (flow.source.ip(), flow.destination.ip()) + { + bytes[0] = 0x60; + bytes[4..6].copy_from_slice(&u16::try_from(packet_len - 40).unwrap().to_be_bytes()); + bytes[6] = 6; + bytes[7] = 64; + bytes[8..24].copy_from_slice(&source.octets()); + bytes[24..40].copy_from_slice(&destination.octets()); + } + bytes[ip_len..ip_len + 2].copy_from_slice(&flow.source.port().to_be_bytes()); + bytes[ip_len + 2..ip_len + 4].copy_from_slice(&flow.destination.port().to_be_bytes()); + bytes[ip_len + 4..ip_len + 8].copy_from_slice(&sequence.to_be_bytes()); + bytes[ip_len + 8..ip_len + 12].copy_from_slice(&ack.to_be_bytes()); + bytes[ip_len + 12] = 0x80; + bytes[ip_len + 13] = flags; + bytes[ip_len + 14..ip_len + 16].copy_from_slice(&32768_u16.to_be_bytes()); + bytes[ip_len + 20..ip_len + 32].copy_from_slice(&[1, 1, 8, 10, 0, 0, 0, 9, 0, 0, 0, 8]); + for (index, byte) in bytes[ip_len + tcp_len..].iter_mut().enumerate() { + *byte = u8::try_from(index % 251).unwrap(); + } + TcpPacket::new_unchecked(&mut bytes[ip_len..]) + .fill_checksum(&flow.source.ip().into(), &flow.destination.ip().into()); + Packet::new(bytes) + } + + pub(crate) fn hint(packet: &Packet, mtu: usize) -> PathMtuHint { + let view = TcpView::parse(packet.data(), true).unwrap(); + PathMtuHint { + outer_destination: view.flow.source.ip(), + local: view.flow.source, + remote: view.flow.destination, + sequence: view.sequence, + mtu, + } + } + + #[test] + fn segments_preserve_content_options_checksum_flags_and_wrap() { + for (ipv6, budget) in [(false, 576), (true, 1280)] { + let original = tcp_packet(flow(ipv6, 4000), u32::MAX - 300, 91, 0xd9, 7000); + let original_view = TcpView::parse(original.data(), true).unwrap(); + let mut pending = PendingSegments::new(original.clone()).unwrap(); + let mut received = Vec::new(); + let mut first = true; + while let Some(segment) = pending.next(budget) { + assert!(segment.len() <= budget); + let view = TcpView::parse(segment.data(), true).unwrap(); + assert_eq!(view.flow, original_view.flow); + assert_eq!( + view.sequence, + original_view + .sequence + .wrapping_add(u32::try_from(received.len()).unwrap()) + ); + assert_eq!(view.acknowledgment, 91); + assert_eq!(view.flags & 0x80 != 0, first); + assert_eq!( + view.flags & (1 | 8), + if pending.is_finished() { 1 | 8 } else { 0 } + ); + assert_ne!(view.flags & (16 | 64), 0); + let header_end = view.ip_len + view.tcp_len; + assert_eq!( + &segment.data()[view.ip_len + 20..header_end], + &original.data()[view.ip_len + 20..header_end] + ); + if !ipv6 { + assert_eq!(&segment.data()[20..24], &original.data()[20..24]); + } + received.extend_from_slice(&segment.data()[header_end..]); + first = false; + } + assert_eq!( + received, + original.data()[original_view.ip_len + original_view.tcp_len..] + ); + assert!(pending.is_finished()); + } + } + + #[test] + fn changing_budget_and_unsupported_ip_modes_fail_closed() { + let original = tcp_packet(flow(false, 4000), 100, 91, 0x10, 7000); + let mut pending = PendingSegments::new(original).unwrap(); + assert_eq!(pending.next(1280).unwrap().len(), 1280); + let second = pending.next(576).unwrap(); + assert_eq!(second.len(), 576); + assert_eq!( + TcpView::parse(second.data(), true).unwrap().sequence, + 100 + 1280 - 56 + ); + for flags in [2, 4, 32] { + let mut pending = + PendingSegments::new(tcp_packet(flow(false, 4000), 100, 91, flags, 7000)).unwrap(); + assert!(pending.next(576).is_none()); + } + let ipv6 = tcp_packet(flow(true, 4000), 100, 91, 16, 7000); + assert!( + PendingSegments::new(ipv6.clone()) + .unwrap() + .next(1279) + .is_none() + ); + let mut extension = ipv6.data().to_vec(); + extension[6] = 60; + assert!(PendingSegments::new(Packet::new(extension)).is_none()); + let mut fragmented = tcp_packet(flow(false, 4000), 100, 91, 16, 7000) + .data() + .to_vec(); + fragmented[6] |= 0x20; + assert!(PendingSegments::new(Packet::new(fragmented)).is_none()); + } + + #[test] + fn quotes_require_emitted_unacked_ranges_and_isolate_flows() { + for ipv6 in [false, true] { + let mut paths = PathMtu::default(); + let original = tcp_packet(flow(ipv6, 4000), u32::MAX - 300, 91, 0x10, 1400); + let original_hint = hint(&original, if ipv6 { 1280 } else { 576 }); + assert!(!paths.apply(original_hint, 1500)); + paths.emitted(&original); + let mut wrong = original_hint; + wrong.remote.set_port(444); + assert!(!paths.apply(wrong, 1500)); + wrong = original_hint; + wrong.sequence = original_hint.sequence.wrapping_add(1400); + assert!(!paths.apply(wrong, 1500)); + let partial_ack = original_hint.sequence.wrapping_add(500); + paths.acknowledge(&tcp_packet( + reverse(flow(ipv6, 4000)), + 91, + partial_ack, + 16, + 0, + )); + assert!(!paths.apply(original_hint, 1500)); + let mut tail_hint = original_hint; + tail_hint.sequence = partial_ack; + assert!(paths.apply(tail_hint, 1500)); + assert_eq!(paths.budget(flow(ipv6, 4001), 1500), 1500); + let final_ack = original_hint.sequence.wrapping_add(1400); + paths.acknowledge(&tcp_packet(reverse(flow(ipv6, 4000)), 91, final_ack, 16, 0)); + paths.emitted(&original); // previously queued retransmit copy + tail_hint.mtu -= if ipv6 { 0 } else { 100 }; + assert!(!paths.apply(tail_hint, 1500)); + paths.forget(flow(ipv6, 4000)); + assert_eq!(paths.budget(flow(ipv6, 4000), 1500), 1500); + assert!( + paths.sent.is_empty() && paths.paths.is_empty() && paths.acknowledgments.is_empty() + ); + // Tuple reuse starts without the predecessor's ACK watermark. + paths.emitted(&original); + assert!(paths.apply(original_hint, 1500)); + } + } + + #[test] + fn caches_remain_bounded_and_eviction_cannot_authorize_old_quotes() { + let mut paths = PathMtu::default(); + for port in 4000..4200 { + let packet = tcp_packet(flow(false, port), 100, 91, 16, 1400); + paths.emitted(&packet); + assert!(paths.apply(hint(&packet, 1280), 1500)); + paths.acknowledge(&tcp_packet(reverse(flow(false, port)), 91, 101, 16, 0)); + assert!(paths.sent.len() <= CACHE_ENTRIES); + assert!(paths.paths.len() <= CACHE_ENTRIES); + assert!(paths.acknowledgments.len() <= CACHE_ENTRIES); + } + assert_eq!(paths.sent.len(), CACHE_ENTRIES); + assert_eq!(paths.paths.len(), CACHE_ENTRIES); + assert_eq!(paths.acknowledgments.len(), CACHE_ENTRIES); + let old = tcp_packet(flow(false, 4000), 100, 91, 16, 1400); + assert!(!paths.apply(hint(&old, 1000), 1500)); + assert_eq!(paths.budget(flow(false, 4000), 1500), 1500); + } +} diff --git a/crates/vole-netstack/src/stack.rs b/crates/vole-netstack/src/stack.rs index cf3b59a..027233a 100644 --- a/crates/vole-netstack/src/stack.rs +++ b/crates/vole-netstack/src/stack.rs @@ -22,15 +22,21 @@ use tokio::sync::{Notify, mpsc, watch}; use tokio_util::sync::CancellationToken; use crate::{ - NetStackConfig, Packet, + ClientConfig, NetStackConfig, Packet, config::ConfigError, device::RawIpDevice, icmp::{IcmpIngress, classify as classify_icmp}, - tcp::{FlowKey, TcpListener, TcpStream, TcpStreamHandle}, + pmtu::parse_path_mtu_hint, + tcp::{ + ClientRequest, ConnectRequest, FlowKey, InitialMtuRequest, TcpConnector, TcpListener, + TcpStream, TcpStreamHandle, connect_stopped, connect_timeout, + }, udp::{UdpDatagram, UdpSocket, parse_udp_packet}, }; const RAW_INGRESS_BATCH: usize = 8; +const TCP_CONNECT_QUEUE: usize = 32; +const EPHEMERAL_PORT_START: u16 = 49_152; /// Running stack and all of its application-facing endpoints. pub struct NetStack { @@ -56,6 +62,60 @@ pub struct TcpNetStackParts { pub stats: NetStackStats, } +/// Active TCP endpoints with configured sources and no passive accept/UDP queue. +pub struct ClientNetStackParts { + pub packet_sink: PacketSink, + pub packet_stream: PacketStream, + pub tcp_connector: TcpConnector, + pub control: NetStackControl, + pub stats: NetStackStats, +} + +struct DriverParts { + packet_sink: PacketSink, + packet_stream: PacketStream, + tcp_listener: Option, + tcp_connector: Option, + udp_socket: Option, + control: NetStackControl, + stats: NetStackStats, +} + +impl DriverParts { + fn into_stack(self) -> NetStack { + NetStack { + parts: NetStackParts { + packet_sink: self.packet_sink, + packet_stream: self.packet_stream, + tcp_listener: self.tcp_listener.expect("passive endpoints requested"), + udp_socket: self.udp_socket.expect("UDP endpoints requested"), + control: self.control, + stats: self.stats, + }, + } + } + + fn into_tcp(self) -> TcpNetStackParts { + TcpNetStackParts { + packet_sink: self.packet_sink, + packet_stream: self.packet_stream, + tcp_listener: self.tcp_listener.expect("passive endpoints requested"), + control: self.control, + stats: self.stats, + } + } + + fn into_client(self) -> ClientNetStackParts { + ClientNetStackParts { + packet_sink: self.packet_sink, + packet_stream: self.packet_stream, + tcp_connector: self.tcp_connector.expect("active endpoints requested"), + control: self.control, + stats: self.stats, + } + } +} + impl NetStack { /// Starts one netstack driver on the current Tokio runtime. /// @@ -64,20 +124,7 @@ impl NetStack { /// Returns [`NetStackError::Config`] for inconsistent bounds and /// [`NetStackError::NoRuntime`] when called outside a Tokio runtime. pub fn start(config: NetStackConfig) -> Result { - Self::start_parts(config, true).map(|(parts, udp_socket)| Self::with_udp(parts, udp_socket)) - } - - fn with_udp(parts: TcpNetStackParts, udp_socket: Option) -> Self { - Self { - parts: NetStackParts { - packet_sink: parts.packet_sink, - packet_stream: parts.packet_stream, - tcp_listener: parts.tcp_listener, - udp_socket: udp_socket.expect("UDP endpoints requested"), - control: parts.control, - stats: parts.stats, - }, - } + Self::start_parts(config, true, None).map(DriverParts::into_stack) } /// Starts only the TCP/ICMP driver; callers handle UDP with the pure codec. @@ -86,14 +133,36 @@ impl NetStack { /// /// Returns the same configuration and runtime errors as [`Self::start`]. pub fn start_tcp(config: NetStackConfig) -> Result { - Self::start_parts(config, false).map(|(parts, _)| parts) + Self::start_parts(config, false, None).map(DriverParts::into_tcp) + } + + /// Starts active TCP using only the supplied local IP addresses. + /// + /// UDP remains caller-owned through the pure packet codec. Unsolicited + /// incoming TCP flows are discarded; passive TUN behavior is unchanged. + /// + /// # Errors + /// + /// Returns the errors of [`Self::start`] and a configuration error when + /// no local address is supplied or a supplied address is not unicast. + pub fn start_client( + config: NetStackConfig, + addresses: impl Into, + ) -> Result { + let addresses = addresses.into().validate(config.mtu)?; + Self::start_parts(config, false, Some(addresses)).map(DriverParts::into_client) } fn start_parts( config: NetStackConfig, with_udp: bool, - ) -> Result<(TcpNetStackParts, Option), NetStackError> { - config.validate()?; + addresses: Option, + ) -> Result { + if let Some(client) = &addresses { + config.validate_client(client)?; + } else { + config.validate()?; + } tokio::runtime::Handle::try_current().map_err(|_| NetStackError::NoRuntime)?; let cancellation = CancellationToken::new(); @@ -101,7 +170,36 @@ impl NetStack { let stats = NetStackStats::default(); let (raw_inbound_tx, raw_inbound_rx) = mpsc::channel(config.packet_queue); let (raw_outbound_tx, raw_outbound_rx) = mpsc::channel(config.packet_queue); - let (tcp_accept_tx, tcp_accept_rx) = mpsc::channel(config.tcp_accept_queue); + let (tcp_accept_tx, tcp_listener, client, tcp_connector) = + if let Some(addresses) = addresses { + let (sender, receiver) = mpsc::channel(TCP_CONNECT_QUEUE); + ( + None, + None, + Some(ClientDriver { + addresses, + requests: receiver, + next_port: EPHEMERAL_PORT_START, + }), + Some(TcpConnector { + sender, + addresses, + cancellation: cancellation.clone(), + notify: notify.clone(), + }), + ) + } else { + let (sender, receiver) = mpsc::channel(config.tcp_accept_queue); + ( + Some(sender), + Some(TcpListener { + receiver, + cancellation: cancellation.clone(), + }), + None, + None, + ) + }; let (udp_tx, udp_socket) = if with_udp { let (sender, receiver) = mpsc::channel(config.udp_queue); ( @@ -118,8 +216,9 @@ impl NetStack { }; let (stopped_tx, stopped_rx) = watch::channel(false); - let mtu = config.mtu; - let driver = Driver::new( + let receive_packet_limit = + addresses.map_or(config.mtu, |client| client.receive_packet_limit); + let mut driver = Driver::new( config, raw_inbound_rx, raw_outbound_tx.clone(), @@ -130,32 +229,32 @@ impl NetStack { stats.clone(), stopped_tx, ); + if let Some(client) = client { + driver.configure_client(&client.addresses); + driver.client = Some(client); + } tokio::spawn(driver.run()); - Ok(( - TcpNetStackParts { - packet_sink: PacketSink { - sender: raw_inbound_tx, - cancellation: cancellation.clone(), - mtu, - }, - packet_stream: PacketStream { - receiver: raw_outbound_rx, - cancellation: cancellation.clone(), - notify, - }, - tcp_listener: TcpListener { - receiver: tcp_accept_rx, - cancellation: cancellation.clone(), - }, - control: NetStackControl { - cancellation, - stopped: stopped_rx, - }, - stats, + Ok(DriverParts { + packet_sink: PacketSink { + sender: raw_inbound_tx, + cancellation: cancellation.clone(), + mtu: receive_packet_limit, + }, + packet_stream: PacketStream { + receiver: raw_outbound_rx, + cancellation: cancellation.clone(), + notify, }, + tcp_listener, + tcp_connector, udp_socket, - )) + control: NetStackControl { + cancellation, + stopped: stopped_rx, + }, + stats, + }) } #[must_use] @@ -400,13 +499,22 @@ fn validate_raw_packet(packet: &Packet, mtu: usize) -> Result<(), NetStackError> struct TcpEntry { socket: SocketHandle, handle: Arc, + connecting: Option, + connect_cancellation: Option, +} + +struct ClientDriver { + addresses: ClientConfig, + requests: mpsc::Receiver, + next_port: u16, } struct Driver { config: NetStackConfig, raw_inbound: mpsc::Receiver, raw_outbound: mpsc::Sender, - tcp_accept: mpsc::Sender, + tcp_accept: Option>, + client: Option, udp_outbound: Option>, cancellation: CancellationToken, notify: Arc, @@ -425,7 +533,7 @@ impl Driver { config: NetStackConfig, raw_inbound: mpsc::Receiver, raw_outbound: mpsc::Sender, - tcp_accept: mpsc::Sender, + tcp_accept: Option>, udp_outbound: Option>, cancellation: CancellationToken, notify: Arc, @@ -435,6 +543,10 @@ impl Driver { let mut interface_config = InterfaceConfig::new(HardwareAddress::Ip); interface_config.random_seed = 0x4f_6e_65_56_43_6f_72_65; let mut device = RawIpDevice::new(config.mtu, raw_outbound.clone()); + // Passive TUN TCP uses the same bounded, body-free PMTU metadata as + // IP-carrier clients. A quote still needs the exact live socket and + // an emitted, unacknowledged sequence before it can lower its budget. + device.enable_path_mtu(); let mut interface = Interface::new(interface_config, &mut device, Instant::now()); interface.set_any_ip(true); interface.update_ip_addrs(|addresses| { @@ -462,6 +574,7 @@ impl Driver { raw_inbound, raw_outbound, tcp_accept, + client: None, udp_outbound, cancellation, notify, @@ -475,33 +588,100 @@ impl Driver { } } + fn configure_client(&mut self, addresses: &ClientConfig) { + if !addresses.path_mtu_discovery { + self.device.disable_path_mtu(); + } + self.interface.set_any_ip(false); + self.interface.update_ip_addrs(|configured| { + configured.clear(); + for prefix in addresses.prefixes() { + configured + .push(IpCidr::new(prefix.address.into(), prefix.prefix_len)) + .expect("client IP address capacity"); + } + }); + self.interface.routes_mut().remove_default_ipv4_route(); + self.interface.routes_mut().remove_default_ipv6_route(); + // The IP-medium device emits every destination into the caller's + // packet path; these routes never configure a system interface. + if let Some(ip) = addresses + .prefixes() + .find_map(|prefix| match prefix.address { + IpAddr::V4(ip) => Some(ip), + IpAddr::V6(_) => None, + }) + { + self.interface + .routes_mut() + .add_default_ipv4_route(ip) + .expect("IPv4 route capacity"); + } + if let Some(ip) = addresses + .prefixes() + .find_map(|prefix| match prefix.address { + IpAddr::V6(ip) => Some(ip), + IpAddr::V4(_) => None, + }) + { + self.interface + .routes_mut() + .add_default_ipv6_route(ip) + .expect("IPv6 route capacity"); + } + } + + async fn next_request(client: &mut Option) -> Option { + match client { + Some(client) => client.requests.recv().await, + None => std::future::pending().await, + } + } + async fn run(mut self) { loop { self.drive(); - if self.cancellation.is_cancelled() { + if self.cancellation.is_cancelled() || !self.wait_for_work().await { break; } + } + } - let delay = self.next_delay(); - tokio::select! { - biased; - () = self.cancellation.cancelled() => break, - () = self.notify.notified() => {} - // A full output still allows one ingress classification: - // low-priority ICMP drops immediately, while TCP retains its - // one pending packet until output capacity returns. - packet = self.raw_inbound.recv(), if self.device.rx_is_empty() => { - let Some(packet) = packet else { break; }; - let batch_count = self.handle_packet_batch(packet); - // recv() already charges the first packet. try_recv() - // does not, so retain the old per-packet cooperative - // accounting without repeating socket maintenance. - for _ in 1..batch_count { - tokio::task::consume_budget().await; - } + async fn wait_for_work(&mut self) -> bool { + let delay = self.next_delay(); + let has_connect_requests = self + .client + .as_ref() + .is_some_and(|client| !client.requests.is_closed()); + tokio::select! { + biased; + () = self.cancellation.cancelled() => false, + // A full output still allows one ingress classification: + // low-priority ICMP drops immediately, while TCP retains its + // one pending packet until output capacity returns. + packet = self.raw_inbound.recv(), if self.device.rx_is_empty() => { + let Some(packet) = packet else { return false; }; + let batch_count = self.handle_packet_batch(packet); + // recv() already charges the first packet. try_recv() + // does not, so retain the old per-packet cooperative + // accounting without repeating socket maintenance. + for _ in 1..batch_count { + tokio::task::consume_budget().await; + } + true + } + // Socket maintenance runs before this wait on every iteration. + // Its own notifications must not outrank an already-ready packet, + // especially TCP ACKs which release the relay's bounded buffers. + () = self.notify.notified() => true, + request = Self::next_request(&mut self.client), if has_connect_requests => { + if let Some(request) = request { + self.handle_client_request(request); + tokio::task::consume_budget().await; } - () = tokio::time::sleep(delay) => {} + true } + () = tokio::time::sleep(delay) => true, } } @@ -544,6 +724,27 @@ impl Driver { } fn handle_packet(&mut self, packet: Packet) { + if self + .client + .as_ref() + .is_none_or(|client| client.addresses.path_mtu_discovery) + && let Some(hint) = parse_path_mtu_hint(packet.data()) + { + let local_address = self + .client + .as_ref() + .is_none_or(|client| client.addresses.contains_address(hint.local.ip())); + if hint.outer_destination == hint.local.ip() + && local_address + && self.tcp_entries.contains_key(&FlowKey { + source: hint.remote, + destination: hint.local, + }) + { + self.device.apply_path_mtu(hint); + } + return; + } if self.config.fake_icmp_echo { match classify_icmp(&packet) { IcmpIngress::NotIcmp => {} @@ -579,7 +780,8 @@ impl Driver { } fn handle_icmp_packet(&mut self, packet: Packet) { - if !self.device.rx_is_empty() || self.device.tx_is_full() { + if packet.len() > self.config.mtu || !self.device.rx_is_empty() || self.device.tx_is_full() + { self.stats.0.icmp_dropped.fetch_add(1, Ordering::AcqRel); return; } @@ -609,9 +811,18 @@ impl Driver { return; }; - if !self.tcp_entries.contains_key(&flow) && (!syn || ack || !self.admit_tcp(flow)) { - return; + if !self.tcp_entries.contains_key(&flow) { + if self.client.is_some() { + if syn && !ack { + self.stats.0.rejected_tcp.fetch_add(1, Ordering::AcqRel); + } + return; + } + if !syn || ack || !self.admit_tcp(flow) { + return; + } } + self.device.acknowledge(&packet); if self.device.push_rx(packet).is_err() { // A pending ingress packet must never be overwritten. self.stats.0.rejected_tcp.fetch_add(1, Ordering::AcqRel); @@ -619,35 +830,28 @@ impl Driver { } fn admit_tcp(&mut self, flow: FlowKey) -> bool { - if self.tcp_accept.capacity() == 0 { + let Some(accept) = self.tcp_accept.as_ref() else { + return false; + }; + if accept.capacity() == 0 { self.stats.0.rejected_tcp.fetch_add(1, Ordering::AcqRel); return false; } - let recv_layer_buffer = self.config.recv_layer_buffer_size(); - let send_layer_buffer = self.config.send_layer_buffer_size(); - let mut socket = tcp::Socket::new( - tcp::SocketBuffer::new(vec![0_u8; recv_layer_buffer]), - tcp::SocketBuffer::new(vec![0_u8; send_layer_buffer]), - ); - socket.set_keep_alive(Some(smoltcp::time::Duration::from_secs(28))); - socket.set_timeout(Some(self.config.tcp_idle_timeout.into())); - socket.set_ack_delay(Some(smoltcp::time::Duration::from_millis(10))); - socket.set_nagle_enabled(false); - socket.set_congestion_control(tcp::CongestionControl::Cubic); + let mut socket = self.new_tcp_socket(); if socket.listen(flow.destination).is_err() { self.stats.0.rejected_tcp.fetch_add(1, Ordering::AcqRel); return false; } let handle = Arc::new(TcpStreamHandle::new( - recv_layer_buffer, - send_layer_buffer, + self.config.recv_layer_buffer_size(), + self.config.send_layer_buffer_size(), self.notify.clone(), )); let socket_handle = self.sockets.add(socket); let stream = TcpStream::new(flow, handle.clone()); - if self.tcp_accept.try_send(stream).is_err() { + if accept.try_send(stream).is_err() { self.sockets.remove(socket_handle); self.stats.0.rejected_tcp.fetch_add(1, Ordering::AcqRel); return false; @@ -657,6 +861,8 @@ impl Driver { TcpEntry { socket: socket_handle, handle, + connecting: None, + connect_cancellation: None, }, ); self.half_open.insert(flow); @@ -664,20 +870,180 @@ impl Driver { true } + fn new_tcp_socket(&self) -> tcp::Socket<'static> { + let mut socket = tcp::Socket::new( + tcp::SocketBuffer::new(vec![0; self.config.recv_layer_buffer_size()]), + tcp::SocketBuffer::new(vec![0; self.config.send_layer_buffer_size()]), + ); + socket.set_keep_alive(Some(smoltcp::time::Duration::from_secs(28))); + socket.set_timeout(Some(self.config.tcp_idle_timeout.into())); + socket.set_ack_delay(Some(smoltcp::time::Duration::from_millis(10))); + socket.set_nagle_enabled(false); + socket.set_congestion_control(tcp::CongestionControl::Cubic); + if self.client.is_some() { + // The initial SYN timeout stays unchanged; RTT samples set the lower floor. + socket.set_min_rto(smoltcp::time::Duration::from_millis(200)); + } + socket + } + + fn connect_tcp(&mut self, request: ConnectRequest) { + if request.cancellation.is_cancelled() || request.response.is_closed() { + return; + } + if self.cancellation.is_cancelled() { + let _ = request.response.send(Err(connect_stopped())); + return; + } + if tokio::time::Instant::now() >= request.deadline { + let _ = request.response.send(Err(connect_timeout())); + return; + } + let client = self.client.as_mut().expect("active request has a driver"); + let source = match client.addresses.source_for(request.destination.ip()) { + Ok(source) => source, + Err(error) => { + let _ = request.response.send(Err(error)); + return; + } + }; + let mut selected = None; + for _ in EPHEMERAL_PORT_START..=u16::MAX { + let port = client.next_port; + client.next_port = if port == u16::MAX { + EPHEMERAL_PORT_START + } else { + port + 1 + }; + let incoming = FlowKey { + source: request.destination, + destination: SocketAddr::new(source, port), + }; + if !self.tcp_entries.contains_key(&incoming) { + selected = Some(incoming); + break; + } + } + let Some(flow) = selected else { + let _ = request.response.send(Err(std::io::Error::new( + std::io::ErrorKind::AddrInUse, + "active TCP source ports exhausted for peer", + ))); + return; + }; + let mut socket = self.new_tcp_socket(); + if socket + .connect(self.interface.context(), flow.source, flow.destination) + .is_err() + { + let _ = request.response.send(Err(std::io::Error::new( + std::io::ErrorKind::AddrNotAvailable, + "active TCP endpoint is unavailable", + ))); + return; + } + let handle = Arc::new(TcpStreamHandle::new( + self.config.recv_layer_buffer_size(), + self.config.send_layer_buffer_size(), + self.notify.clone(), + )); + let socket = self.sockets.add(socket); + let connect_cancellation = Some(request.cancellation.clone()); + self.tcp_entries.insert( + flow, + TcpEntry { + socket, + handle, + connecting: Some(request), + connect_cancellation, + }, + ); + self.half_open.insert(flow); + self.update_flow_stats(); + } + + fn handle_client_request(&mut self, request: ClientRequest) { + match request { + ClientRequest::Connect(request) => self.connect_tcp(request), + ClientRequest::InitialMtu(request) => self.set_initial_mtu(request), + } + } + + fn set_initial_mtu(&mut self, request: InitialMtuRequest) { + if request.response.is_closed() { + return; + } + let result = if self.cancellation.is_cancelled() { + Err(connect_stopped()) + } else if tokio::time::Instant::now() >= request.deadline { + Err(connect_timeout()) + } else if !self.tcp_entries.is_empty() || self.device.emitted() != 0 { + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "initial TCP MTU is already in use", + )) + } else { + let addresses = self + .client + .as_ref() + .expect("active command has a client") + .addresses; + if request.mtu > self.config.mtu || addresses.validate(request.mtu).is_err() { + Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid initial TCP packet budget", + )) + } else { + self.config.mtu = request.mtu; + self.device.set_initial_mtu(request.mtu); + // smoltcp snapshots DeviceCapabilities when Interface is + // created. Before the first flow, rebuild that snapshot and + // restore the same sources/routes without replacing sockets. + let mut config = InterfaceConfig::new(HardwareAddress::Ip); + config.random_seed = 0x4f_6e_65_56_43_6f_72_65; + self.interface = Interface::new(config, &mut self.device, Instant::now()); + self.configure_client(&addresses); + Ok(()) + } + }; + let _ = request.response.send(result); + } + fn drive(&mut self) { if self.raw_outbound.is_closed() { self.cancellation.cancel(); return; } + self.device.flush_pending(); + // Busy packet traffic must not starve command admission. The same + // bounded prefix as raw ingress avoids draining an unbounded burst. + for _ in 0..RAW_INGRESS_BATCH { + let request = self + .client + .as_mut() + .and_then(|client| client.requests.try_recv().ok()); + let Some(request) = request else { + break; + }; + self.handle_client_request(request); + } if !self.device.tx_is_full() { self.interface .poll(Instant::now(), &mut self.device, &mut self.sockets); + } else if !self.device.rx_is_empty() { + // ACK/window progress must not wait for outbound DATA capacity. + // Device::receive allows only a pure ACK on this path and keeps + // any immediate response in the same existing pending slot. + self.interface + .poll_ingress_single(Instant::now(), &mut self.device, &mut self.sockets); } self.drive_tcp_sockets(); + self.device.flush_pending(); if !self.device.tx_is_full() { self.interface .poll(Instant::now(), &mut self.device, &mut self.sockets); } + self.device.flush_pending(); } fn drive_tcp_sockets(&mut self) { @@ -687,12 +1053,13 @@ impl Driver { .and_then(parse_tcp_flow) .map(|(flow, _, _)| flow); let mut inactive = Vec::new(); - for (flow, entry) in &self.tcp_entries { + for (flow, entry) in &mut self.tcp_entries { let socket = self.sockets.get_mut::(entry.socket); + Self::finish_connect(*flow, entry, socket); let handle = &entry.handle; let mut received = false; - while socket.can_recv() && !handle.app_recv.is_full() { + while socket.can_recv() { match socket.recv(|bytes| { let count = handle.app_recv.write(bytes); (count, count) @@ -706,7 +1073,7 @@ impl Driver { } let mut sent = false; - while socket.can_send() && !handle.app_send.is_empty() { + while socket.can_send() { match socket.send(|bytes| { let count = handle.app_send.read(bytes); (count, count) @@ -723,7 +1090,7 @@ impl Driver { socket.state(), tcp::State::Listen | tcp::State::SynSent | tcp::State::SynReceived ); - if past_handshake { + if past_handshake && !self.half_open.is_empty() { self.half_open.remove(flow); } if past_handshake && !socket.may_recv() && !socket.can_recv() { @@ -766,6 +1133,7 @@ impl Driver { for flow in inactive { if let Some(entry) = self.tcp_entries.remove(&flow) { + self.device.forget_flow(flow); self.sockets.remove(entry.socket); self.half_open.remove(&flow); entry.handle.socket_closed.store(true, Ordering::Release); @@ -776,6 +1144,55 @@ impl Driver { self.update_flow_stats(); } + fn finish_connect(incoming: FlowKey, entry: &mut TcpEntry, socket: &mut tcp::Socket<'_>) { + let handle = &entry.handle; + if entry + .connect_cancellation + .as_ref() + .is_some_and(CancellationToken::is_cancelled) + { + // Covers cancellation after the response was queued but before + // the caller accepted its stream, including an established race. + socket.abort(); + handle.dropped.store(true, Ordering::Release); + } + let request = entry.connecting.take_if(|request| { + request.response.is_closed() + || request.cancellation.is_cancelled() + || tokio::time::Instant::now() >= request.deadline + || matches!( + socket.state(), + tcp::State::Established | tcp::State::CloseWait | tcp::State::Closed + ) + }); + let Some(request) = request else { + return; + }; + let result = if request.response.is_closed() || request.cancellation.is_cancelled() { + Err(connect_stopped()) + } else if tokio::time::Instant::now() >= request.deadline { + Err(connect_timeout()) + } else if socket.state() == tcp::State::Closed { + Err(std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "active TCP handshake was closed", + )) + } else { + Ok(TcpStream::new( + FlowKey { + source: incoming.destination, + destination: incoming.source, + }, + handle.clone(), + )) + }; + if result.is_err() { + socket.abort(); + handle.dropped.store(true, Ordering::Release); + } + let _ = request.response.send(result); + } + fn next_delay(&mut self) -> Duration { let smoltcp_delay = self .interface @@ -784,11 +1201,21 @@ impl Driver { // A ready egress timer cannot make progress while output is full. // PacketStream notifies on capacity recovery; retain a bounded timer // for lifecycle maintenance instead of repeatedly sleeping for zero. - if smoltcp_delay.is_zero() && self.device.tx_is_full() { + let delay = if smoltcp_delay.is_zero() && self.device.tx_is_full() { self.config.max_poll_interval } else { smoltcp_delay.min(self.config.max_poll_interval) - } + }; + self.tcp_entries + .values() + .filter_map(|entry| entry.connecting.as_ref()) + .fold(delay, |delay, request| { + delay.min( + request + .deadline + .saturating_duration_since(tokio::time::Instant::now()), + ) + }) } fn update_flow_stats(&self) { @@ -811,9 +1238,25 @@ impl Driver { fn shutdown(&mut self) { self.cancellation.cancel(); - self.device.discard_rx(); - for entry in self.tcp_entries.values() { + self.device.clear(); + if let Some(client) = &mut self.client { + client.requests.close(); + while let Ok(request) = client.requests.try_recv() { + match request { + ClientRequest::Connect(request) => { + let _ = request.response.send(Err(connect_stopped())); + } + ClientRequest::InitialMtu(request) => { + let _ = request.response.send(Err(connect_stopped())); + } + } + } + } + for entry in self.tcp_entries.values_mut() { entry.handle.mark_stopped(); + if let Some(request) = entry.connecting.take() { + let _ = request.response.send(Err(connect_stopped())); + } } self.tcp_entries.clear(); self.half_open.clear(); @@ -916,6 +1359,312 @@ mod tests { Packet::new(ip.into_inner()) } + // Keep both real handshakes and the failed-emit timeline in one regression. + #[allow(clippy::too_many_lines)] + #[tokio::test] + async fn failed_fast_retransmit_survives_another_socket_filling_tx() { + use crate::segmentation::tests::tcp_packet; + use tokio::io::AsyncWriteExt; + let config = NetStackConfig { + packet_queue: 1, + ..NetStackConfig::default() + }; + let (_inbound, receiver) = mpsc::channel(config.packet_queue); + let (outbound, mut packets) = mpsc::channel(config.packet_queue); + let (accept, mut streams) = mpsc::channel(config.tcp_accept_queue); + let (stopped, _) = watch::channel(false); + let mut driver = Driver::new( + config, + receiver, + outbound, + Some(accept), + None, + CancellationToken::new(), + Arc::new(Notify::new()), + NetStackStats::default(), + stopped, + ); + let flows = [ + FlowKey { + source: "10.0.0.2:12000".parse().unwrap(), + destination: "192.0.2.1:12001".parse().unwrap(), + }, + FlowKey { + source: "10.0.0.3:12002".parse().unwrap(), + destination: "192.0.2.1:12001".parse().unwrap(), + }, + ]; + let mut sequences = [0; 2]; + for (index, flow) in flows.into_iter().enumerate() { + let remote_sequence = 100 + u32::try_from(index).unwrap() * 100; + driver.handle_packet_batch(tcp_packet(flow, remote_sequence, 0, 2, 0)); + driver.drive(); + let syn_ack = packets.try_recv().expect("actual SYN-ACK missing"); + let ip = Ipv4Packet::new_checked(syn_ack.data()).unwrap(); + let tcp = TcpPacket::new_checked(ip.payload()).unwrap(); + assert!(tcp.syn() && tcp.ack()); + sequences[index] = + u32::from_be_bytes(ip.payload()[4..8].try_into().unwrap()).wrapping_add(1); + driver.handle_packet_batch(tcp_packet( + flow, + remote_sequence + 1, + sequences[index], + 16, + 0, + )); + driver.drive(); + assert!(packets.try_recv().is_err()); + } + let mut first_stream = streams.try_recv().unwrap(); + let mut retry_stream = streams.try_recv().unwrap(); + let first_handle = driver.tcp_entries[&flows[0]].socket; + let retry_handle = driver.tcp_entries[&flows[1]].socket; + let handles: Vec<_> = driver.sockets.iter().map(|(handle, _)| handle).collect(); + assert!( + handles + .iter() + .position(|handle| *handle == first_handle) + .unwrap() + < handles + .iter() + .position(|handle| *handle == retry_handle) + .unwrap(), + "fixture requires the output-filling socket before the retry socket" + ); + + let lost = b"actual-unacknowledged-DATA"; + retry_stream.write_all(lost).await.unwrap(); + driver.drive_tcp_sockets(); + let now = Instant::now(); + driver + .interface + .poll_egress(now, &mut driver.device, &mut driver.sockets); + let original = packets.try_recv().expect("actual original DATA missing"); + let ip = Ipv4Packet::new_checked(original.data()).unwrap(); + let tcp = TcpPacket::new_checked(ip.payload()).unwrap(); + assert_eq!(tcp.dst_port(), flows[1].source.port()); + assert_eq!(tcp.payload(), lost); + let original_sequence = tcp.seq_number(); + assert_eq!( + driver.sockets.get::(retry_handle).send_queue(), + lost.len() + ); + + // The actual TCP ACK parser schedules fast retransmit. Do not poll + // egress until the earlier socket has an ordinary DATA packet ready. + let ack_at = now + smoltcp::time::Duration::from_millis(1); + for _ in 0..3 { + driver.handle_packet(tcp_packet(flows[1], 201, sequences[1], 16, 0)); + driver + .interface + .poll_ingress_single(ack_at, &mut driver.device, &mut driver.sockets); + assert!(driver.device.rx_is_empty()); + } + let filling = b"earlier-socket-occupies-the-only-output-slot"; + first_stream.write_all(filling).await.unwrap(); + driver.drive_tcp_sockets(); + driver.interface.poll_egress( + now + smoltcp::time::Duration::from_millis(2), + &mut driver.device, + &mut driver.sockets, + ); + let occupied = packets + .try_recv() + .expect("earlier socket did not emit DATA"); + let ip = Ipv4Packet::new_checked(occupied.data()).unwrap(); + let tcp = TcpPacket::new_checked(ip.payload()).unwrap(); + assert_eq!(tcp.dst_port(), flows[0].source.port()); + assert_eq!(tcp.payload(), filling); + assert!(!driver.device.tx_is_full()); + + // Capacity is now free. No new peer ACK, application write or real-time + // wait follows the failed emit; only one public egress poll at +1ms. + driver.interface.poll_egress( + now + smoltcp::time::Duration::from_millis(3), + &mut driver.device, + &mut driver.sockets, + ); + let retried = packets.try_recv().is_ok_and(|packet| { + let ip = Ipv4Packet::new_checked(packet.data()).unwrap(); + let tcp = TcpPacket::new_checked(ip.payload()).unwrap(); + tcp.dst_port() == flows[1].source.port() + && tcp.seq_number() == original_sequence + && tcp.payload() == lost + && !tcp.syn() + && !tcp.fin() + && !tcp.rst() + }); + driver.shutdown(); + drop((first_stream, retry_stream)); + assert!(driver.cancellation.is_cancelled()); + assert!(driver.tcp_entries.is_empty()); + assert_eq!(driver.sockets.iter().count(), 0); + assert!( + retried, + "failed fast retransmit must remain immediately retryable after TX capacity returns" + ); + } + + #[tokio::test] + async fn full_tx_pure_ack_releases_actual_smoltcp_send_queue() { + use tokio::io::AsyncWriteExt; + let config = NetStackConfig { + packet_queue: 1, + ..NetStackConfig::default() + }; + let (_sender, inbound) = mpsc::channel(config.packet_queue); + let (outbound, mut packets) = mpsc::channel(config.packet_queue); + let (accept, mut streams) = mpsc::channel(config.tcp_accept_queue); + let (stopped, _) = watch::channel(false); + let mut driver = Driver::new( + config, + inbound, + outbound, + Some(accept), + None, + CancellationToken::new(), + Arc::new(Notify::new()), + NetStackStats::default(), + stopped, + ); + driver.handle_packet_batch(tcp_handshake_packet(100, None)); + driver.drive(); + let syn_ack = packets.try_recv().unwrap(); + let ip = Ipv4Packet::new_checked(syn_ack.data()).unwrap(); + let tcp = TcpPacket::new_checked(ip.payload()).unwrap(); + let acknowledged = tcp.seq_number().0.wrapping_add(1); + driver.handle_packet_batch(tcp_handshake_packet(101, Some(acknowledged))); + driver.drive(); + let mut stream = streams.try_recv().unwrap(); + let payload = b"unacknowledged-before-full-output"; + stream.write_all(payload).await.unwrap(); + driver.drive(); + let data = packets.try_recv().unwrap(); + let ip = Ipv4Packet::new_checked(data.data()).unwrap(); + let tcp = TcpPacket::new_checked(ip.payload()).unwrap(); + assert_eq!(tcp.payload(), payload); + let ack = tcp + .seq_number() + .0 + .wrapping_add(i32::try_from(payload.len()).unwrap()); + assert_eq!( + driver + .sockets + .get::(driver.tcp_entries.values().next().unwrap().socket) + .send_queue(), + payload.len() + ); + driver.raw_outbound.try_send(packet(99)).unwrap(); + assert!(driver.device.tx_is_full()); + // A real ACK must reach the live smoltcp socket while the downstream + // packet consumer is blocked; no fabricated socket-state observation. + driver.handle_packet_batch(tcp_handshake_packet(101, Some(ack))); + assert!( + driver.device.rx_is_empty(), + "ACK remained blocked behind full TX" + ); + assert_eq!( + driver + .sockets + .get::(driver.tcp_entries.values().next().unwrap().socket) + .send_queue(), + 0 + ); + assert_eq!(packets.try_recv().unwrap(), packet(99)); + driver.shutdown(); + assert!(driver.cancellation.is_cancelled()); + } + + #[tokio::test] + async fn full_tx_smoltcp_immediate_reset_is_retained_until_capacity_returns() { + let config = NetStackConfig { + packet_queue: 1, + ..NetStackConfig::default() + }; + let (_sender, inbound) = mpsc::channel(config.packet_queue); + let (outbound, mut packets) = mpsc::channel(config.packet_queue); + let (accept, _streams) = mpsc::channel(config.tcp_accept_queue); + let (stopped, _) = watch::channel(false); + let mut driver = Driver::new( + config, + inbound, + outbound, + Some(accept), + None, + CancellationToken::new(), + Arc::new(Notify::new()), + NetStackStats::default(), + stopped, + ); + driver.handle_packet_batch(tcp_handshake_packet(100, None)); + driver.drive(); + let syn_ack = packets.try_recv().unwrap(); + let ip = Ipv4Packet::new_checked(syn_ack.data()).unwrap(); + let tcp = TcpPacket::new_checked(ip.payload()).unwrap(); + let wrong_ack = tcp.seq_number().0.wrapping_add(2); + driver.raw_outbound.try_send(packet(99)).unwrap(); + driver.handle_packet_batch(tcp_handshake_packet(101, Some(wrong_ack))); + assert!( + driver.device.rx_is_empty(), + "full TX blocked the invalid-ACK response path" + ); + assert_eq!(packets.try_recv().unwrap(), packet(99)); + driver.drive(); + let reset = packets.try_recv().unwrap(); + let ip = Ipv4Packet::new_checked(reset.data()).unwrap(); + assert!(TcpPacket::new_checked(ip.payload()).unwrap().rst()); + assert!(packets.try_recv().is_err()); + driver.shutdown(); + assert!(driver.device.rx_is_empty()); + assert!(driver.cancellation.is_cancelled()); + } + + #[tokio::test] + async fn ready_ingress_progresses_before_repeated_app_notifications() { + for cancelled in [false, true] { + let config = NetStackConfig::default(); + let (sender, inbound) = mpsc::channel(config.packet_queue); + let (outbound, _packets) = mpsc::channel(config.packet_queue); + let (accept, _streams) = mpsc::channel(config.tcp_accept_queue); + let (stopped, _) = watch::channel(false); + let cancellation = CancellationToken::new(); + let notify = Arc::new(Notify::new()); + let mut driver = Driver::new( + config, + inbound, + outbound, + Some(accept), + None, + cancellation.clone(), + notify.clone(), + NetStackStats::default(), + stopped, + ); + sender.try_send(tcp_handshake_packet(100, None)).unwrap(); + // Application writes and output capacity recovery can make this + // notification ready on every iteration of a busy duplex relay. + for _ in 0..3 { + notify.notify_one(); + if cancelled { + cancellation.cancel(); + } + let running = driver.wait_for_work().await; + assert_eq!(running, !cancelled); + if cancelled { + assert_eq!(driver.raw_inbound.len(), 1); + assert!(driver.tcp_entries.is_empty()); + break; + } + assert!( + driver.raw_inbound.is_empty(), + "ready IP input lost to app notification" + ); + assert_eq!(driver.tcp_entries.len(), 1); + sender.try_send(tcp_handshake_packet(100, None)).unwrap(); + } + } + } + #[tokio::test] async fn driver_batch_preserves_queued_suffix_when_tcp_ingress_fills_tx() { let config = NetStackConfig { @@ -931,7 +1680,7 @@ mod tests { config, inbound, outbound, - accept, + Some(accept), Some(udp), CancellationToken::new(), Arc::new(Notify::new()), @@ -1001,7 +1750,7 @@ mod tests { config, inbound, outbound, - accept, + Some(accept), Some(udp), CancellationToken::new(), Arc::new(Notify::new()), @@ -1073,7 +1822,7 @@ mod tests { config, inbound, outbound, - accept, + Some(accept), None, CancellationToken::new(), Arc::new(Notify::new()), diff --git a/crates/vole-netstack/src/tcp.rs b/crates/vole-netstack/src/tcp.rs index 756e44c..30cb38b 100644 --- a/crates/vole-netstack/src/tcp.rs +++ b/crates/vole-netstack/src/tcp.rs @@ -13,17 +13,178 @@ use std::{ use futures_util::task::AtomicWaker; use tokio::{ io::{AsyncRead, AsyncWrite, ReadBuf}, - sync::{Notify, mpsc}, + sync::{Notify, mpsc, oneshot}, + time::Instant, }; use tokio_util::sync::CancellationToken; +use crate::ClientConfig; + +pub(crate) struct ConnectRequest { + pub(crate) destination: SocketAddr, + pub(crate) deadline: Instant, + pub(crate) cancellation: CancellationToken, + pub(crate) response: oneshot::Sender>, +} + +pub(crate) struct InitialMtuRequest { + pub(crate) mtu: usize, + pub(crate) deadline: Instant, + pub(crate) response: oneshot::Sender>, +} + +pub(crate) enum ClientRequest { + Connect(ConnectRequest), + InitialMtu(InitialMtuRequest), +} + +struct ConnectGuard { + cancellation: CancellationToken, + notify: Arc, + armed: bool, +} + +impl Drop for ConnectGuard { + fn drop(&mut self) { + if self.armed { + self.cancellation.cancel(); + self.notify.notify_one(); + } + } +} + +/// Active TCP connections driven entirely by the stack's raw-IP endpoints. +#[derive(Clone)] +pub struct TcpConnector { + pub(crate) sender: mpsc::Sender, + pub(crate) cancellation: CancellationToken, + pub(crate) notify: Arc, + pub(crate) addresses: ClientConfig, +} + +impl TcpConnector { + /// Reduces the initial transmit MTU to a known packet-transport budget. + /// + /// This must complete before the first TCP connection. Receive packet + /// limits and source prefixes remain unchanged. Admission and completion + /// share the supplied deadline, and Stop never waits on this command. + /// + /// # Errors + /// + /// Returns `InvalidInput` for an increase or invalid family MTU, + /// `AlreadyExists` once TCP output or a flow exists, `TimedOut` at the + /// deadline, and `ConnectionAborted` when the stack stops. + pub async fn set_initial_mtu(&self, mtu: usize, deadline: Instant) -> std::io::Result<()> { + if Instant::now() >= deadline { + return Err(connect_timeout()); + } + let (response, received) = oneshot::channel(); + let request = ClientRequest::InitialMtu(InitialMtuRequest { + mtu, + deadline, + response, + }); + tokio::select! { + biased; + () = self.cancellation.cancelled() => return Err(connect_stopped()), + () = tokio::time::sleep_until(deadline) => return Err(connect_timeout()), + result = self.sender.send(request) => result.map_err(|_| connect_stopped())?, + } + tokio::select! { + biased; + () = self.cancellation.cancelled() => Err(connect_stopped()), + () = tokio::time::sleep_until(deadline) => Err(connect_timeout()), + result = received => result.unwrap_or_else(|_| Err(connect_stopped())), + } + } + + /// Connects to an IP endpoint and returns only after TCP is established. + /// + /// Queue admission and handshake share the supplied absolute deadline. + /// Dropping this future cancels its pending request and socket; no background + /// connect task survives the caller. The stack performs no DNS or host I/O. + /// + /// # Errors + /// + /// Returns `AddrNotAvailable` for an unconfigured address family, + /// `InvalidInput` for a non-unicast/zero-port peer, `TimedOut` at the + /// deadline, or an I/O error when TCP fails or the stack stops. + pub async fn connect( + &self, + mut destination: SocketAddr, + deadline: Instant, + ) -> std::io::Result { + if let std::net::IpAddr::V6(ip) = destination.ip() + && let Some(ip) = ip.to_ipv4_mapped() + { + destination = SocketAddr::new(ip.into(), destination.port()); + } + let invalid_ip = match destination.ip() { + std::net::IpAddr::V4(ip) => { + ip.is_unspecified() || ip.is_multicast() || ip.is_broadcast() + } + std::net::IpAddr::V6(ip) => ip.is_unspecified() || ip.is_multicast(), + }; + if invalid_ip || destination.port() == 0 { + return Err(Error::new( + ErrorKind::InvalidInput, + "invalid active TCP peer", + )); + } + self.addresses.source_for(destination.ip())?; + if Instant::now() >= deadline { + return Err(connect_timeout()); + } + let cancellation = CancellationToken::new(); + let mut guard = ConnectGuard { + cancellation: cancellation.clone(), + notify: self.notify.clone(), + armed: true, + }; + let (response, received) = oneshot::channel(); + let request = ConnectRequest { + destination, + deadline, + cancellation, + response, + }; + tokio::select! { + biased; + () = self.cancellation.cancelled() => return Err(connect_stopped()), + () = tokio::time::sleep_until(deadline) => return Err(connect_timeout()), + result = self.sender.send(ClientRequest::Connect(request)) => result.map_err(|_| connect_stopped())?, + } + let result = tokio::select! { + biased; + () = self.cancellation.cancelled() => Err(connect_stopped()), + () = tokio::time::sleep_until(deadline) => Err(connect_timeout()), + result = received => result.unwrap_or_else(|_| Err(connect_stopped())), + }; + if result.is_ok() { + guard.armed = false; + } + result + } +} + +pub(crate) fn connect_stopped() -> Error { + Error::new( + ErrorKind::ConnectionAborted, + "netstack is stopping or stopped", + ) +} + +pub(crate) fn connect_timeout() -> Error { + Error::new(ErrorKind::TimedOut, "netstack TCP connect deadline elapsed") +} + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub(crate) struct FlowKey { pub(crate) source: SocketAddr, pub(crate) destination: SocketAddr, } -/// A bounded queue whose allocation is made once when a flow is accepted. +/// A bounded queue whose storage grows with use and is retained until drop. pub(crate) struct ByteQueue { bytes: Mutex>, capacity: usize, @@ -32,7 +193,7 @@ pub(crate) struct ByteQueue { impl ByteQueue { fn new(capacity: usize) -> Self { Self { - bytes: Mutex::new(VecDeque::with_capacity(capacity)), + bytes: Mutex::new(VecDeque::new()), capacity, } } @@ -46,6 +207,16 @@ impl ByteQueue { pub(crate) fn write(&self, source: &[u8]) -> usize { let mut bytes = self.lock(); let count = source.len().min(self.capacity - bytes.len()); + let needed = bytes.len() + count; + if needed > bytes.capacity() { + // Amortized VecDeque growth can overshoot even a power-of-two + // limit when the first write is an arbitrary-sized TCP segment. + let target = needed + .max(bytes.capacity().saturating_mul(2)) + .min(self.capacity); + let additional = target - bytes.len(); + bytes.reserve_exact(additional); + } bytes.extend(&source[..count]); count } @@ -60,10 +231,6 @@ impl ByteQueue { self.lock().is_empty() } - pub(crate) fn is_full(&self) -> bool { - self.lock().len() == self.capacity - } - #[cfg(test)] pub(crate) fn len(&self) -> usize { self.lock().len() @@ -119,7 +286,7 @@ impl TcpStreamHandle { } } -/// One TCP flow intercepted from the raw-IP side. +/// One intercepted or actively established userspace TCP flow. pub struct TcpStream { flow: FlowKey, pub(crate) handle: Arc, @@ -130,13 +297,13 @@ impl TcpStream { Self { flow, handle } } - /// Address of the application that wrote the original TUN packet. + /// Intercepted application address, or local source of an active connection. #[must_use] pub const fn source_addr(&self) -> SocketAddr { self.flow.source } - /// Original destination that the outbound dispatcher must dial. + /// Original intercepted destination, or remote peer of an active connection. #[must_use] pub const fn destination_addr(&self) -> SocketAddr { self.flow.destination @@ -323,6 +490,68 @@ mod tests { assert_eq!(&bytes[..4], b"abcd"); } + #[test] + fn byte_queue_grows_with_use_without_exceeding_its_storage_limit() { + for limit in [16 * 1024, 16 * 1024 - 3] { + let queue = ByteQueue::new(limit); + assert_eq!(queue.lock().capacity(), 0); + assert_eq!(queue.write(&[]), 0); + assert_eq!(queue.lock().capacity(), 0); + + assert_eq!(queue.write(&[42]), 1); + assert!(queue.lock().capacity() < limit); + let mut hello = [0]; + assert_eq!(queue.read(&mut hello), 1); + assert_eq!(hello, [42]); + + let payload: Vec<_> = (0_u8..=250).cycle().take(limit).collect(); + for chunk in payload.chunks(1460) { + assert_eq!(queue.write(chunk), chunk.len()); + assert!(queue.lock().capacity() <= limit); + } + let full_capacity = queue.lock().capacity(); + assert_eq!(queue.len(), limit); + assert_eq!(queue.write(b"full"), 0); + assert_eq!(queue.lock().capacity(), full_capacity); + + let mut prefix = vec![0; 6 * 1024]; + assert_eq!(queue.read(&mut prefix), prefix.len()); + assert_eq!(prefix, payload[..prefix.len()]); + let suffix = vec![7; prefix.len()]; + assert_eq!(queue.write(&suffix), suffix.len()); + let mut remaining = vec![0; limit]; + let mut received = 0; + while received < limit { + let count = queue.read(&mut remaining[received..]); + assert!(count > 0); + received += count; + } + assert_eq!(&remaining[..limit - suffix.len()], &payload[prefix.len()..]); + assert_eq!(&remaining[limit - suffix.len()..], &suffix); + assert!(queue.is_empty()); + assert_eq!(queue.lock().capacity(), full_capacity); + } + + let queue = ByteQueue::new(13); + assert_eq!(queue.write(b"abcdef"), 6); + let mut prefix = [0; 4]; + assert_eq!(queue.read(&mut prefix), 4); + assert_eq!(&prefix, b"abcd"); + assert_eq!(queue.write(b"ghijkl"), 6); + assert!(queue.lock().capacity() <= 13); + assert_eq!(queue.write(b"mnopq"), 5); + assert_eq!(queue.write(b"full"), 0); + assert!(queue.lock().capacity() <= 13); + let mut remaining = [0; 13]; + let mut received = 0; + while received < remaining.len() { + let count = queue.read(&mut remaining[received..]); + assert!(count > 0); + received += count; + } + assert_eq!(&remaining, b"efghijklmnopq"); + } + #[tokio::test] async fn closed_socket_unblocks_buffered_flush_and_rejects_writes() { let handle = Arc::new(TcpStreamHandle::new(16, 16, Arc::new(Notify::new()))); diff --git a/crates/vole-netstack/tests/netstack.rs b/crates/vole-netstack/tests/netstack.rs index d078cb6..fc1546a 100644 --- a/crates/vole-netstack/tests/netstack.rs +++ b/crates/vole-netstack/tests/netstack.rs @@ -1,14 +1,1031 @@ use std::{ - net::{Ipv4Addr, Ipv6Addr, SocketAddr}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, time::Duration, }; use bytes::Bytes; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use vole_netstack::{NetStack, NetStackConfig, NetStackError, Packet, UdpDatagram}; +use vole_netstack::{ + ClientAddresses, ClientConfig, ClientPrefix, NetStack, NetStackConfig, NetStackError, Packet, + UdpDatagram, +}; const WAIT: Duration = Duration::from_secs(2); +fn client_addresses() -> ClientAddresses { + ClientAddresses { + ipv4: Some("10.23.0.2".parse().unwrap()), + ipv6: Some("fd23::2".parse().unwrap()), + } +} + +fn tcp_flow(packet: &Packet) -> Flow { + let bytes = packet.data(); + let offset = ip_header_len(bytes); + let (source, destination) = match bytes[0] >> 4 { + 4 => ( + Ipv4Addr::from(<[u8; 4]>::try_from(&bytes[12..16]).unwrap()).into(), + Ipv4Addr::from(<[u8; 4]>::try_from(&bytes[16..20]).unwrap()).into(), + ), + 6 => ( + Ipv6Addr::from(<[u8; 16]>::try_from(&bytes[8..24]).unwrap()).into(), + Ipv6Addr::from(<[u8; 16]>::try_from(&bytes[24..40]).unwrap()).into(), + ), + _ => unreachable!(), + }; + Flow { + source: SocketAddr::new( + source, + u16::from_be_bytes(bytes[offset..offset + 2].try_into().unwrap()), + ), + destination: SocketAddr::new( + destination, + u16::from_be_bytes(bytes[offset + 2..offset + 4].try_into().unwrap()), + ), + } +} + +fn client_prefix(address: &str, prefix_len: u8) -> ClientPrefix { + ClientPrefix { + address: address.parse().unwrap(), + prefix_len, + } +} + +fn tcp_mss(packet: &Packet) -> Option { + let offset = ip_header_len(packet.data()); + let header_len = usize::from(packet.data()[offset + 12] >> 4) * 4; + let mut options = &packet.data()[offset + 20..offset + header_len]; + while let Some(kind) = options.first().copied() { + match kind { + 0 => break, + 1 => options = &options[1..], + _ => { + let length = usize::from(options[1]); + assert!(length >= 2 && length <= options.len()); + if kind == 2 { + assert_eq!(length, 4); + return Some(u16::from_be_bytes([options[2], options[3]])); + } + options = &options[length..]; + } + } + } + None +} + +fn tcp_payload(packet: &Packet) -> &[u8] { + let offset = ip_header_len(packet.data()); + let start = offset + usize::from(packet.data()[offset + 12] >> 4) * 4; + &packet.data()[start..] +} + +async fn timeout_tcp_data(stream: &mut vole_netstack::PacketStream) -> Packet { + loop { + let packet = timeout_packet(stream).await; + if !tcp_payload(&packet).is_empty() { + return packet; + } + } +} + +async fn active_connect( + parts: &mut vole_netstack::ClientNetStackParts, + destination: SocketAddr, +) -> (vole_netstack::TcpStream, Flow, u32) { + active_connect_with_mss(parts, destination, 1460).await +} + +async fn active_connect_with_mss( + parts: &mut vole_netstack::ClientNetStackParts, + destination: SocketAddr, + peer_mss: u16, +) -> (vole_netstack::TcpStream, Flow, u32) { + let mut connect = Box::pin( + parts + .tcp_connector + .connect(destination, tokio::time::Instant::now() + WAIT), + ); + assert!(futures_util::poll!(connect.as_mut()).is_pending()); + let syn = timeout_tcp_flag(&mut parts.packet_stream, TcpFlags::SYN).await; + let local = tcp_flow(&syn); + let peer = Flow { + source: local.destination, + destination: local.source, + }; + let sequence = tcp_sequence(&syn).wrapping_add(1); + parts + .packet_sink + .send(build_tcp_mss( + &peer, + 700, + sequence, + TcpFlags::SYN | TcpFlags::ACK, + &[], + peer_mss, + )) + .await + .unwrap(); + let stream = tokio::time::timeout(WAIT, connect).await.unwrap().unwrap(); + (stream, peer, sequence) +} + +#[tokio::test] +async fn client_pmtu_partial_ack_recovers_and_stop_releases_a_blocked_split() { + let mut parts = NetStack::start_client( + NetStackConfig { + mtu: 9000, + packet_queue: 1, + max_poll_interval: Duration::from_millis(2), + ..NetStackConfig::default() + }, + ClientConfig { + prefixes: [Some(client_prefix("10.23.0.2", 24)), None], + receive_packet_limit: 9000, + path_mtu_discovery: true, + }, + ) + .unwrap(); + let (mut stream, peer, sequence) = + active_connect_with_mss(&mut parts, "198.51.100.20:443".parse().unwrap(), 8960).await; + let payload: Vec<_> = (0..2048) + .map(|index| u8::try_from(index % 251).unwrap()) + .collect(); + stream.write_all(&payload).await.unwrap(); + let original = timeout_tcp_data(&mut parts.packet_stream).await; + assert!( + tcp_payload(&original).len() >= 2000, + "jumbo fixture emitted only {} bytes", + tcp_payload(&original).len() + ); + let payload = &payload[..tcp_payload(&original).len()]; + assert_eq!(tcp_payload(&original), payload); + let acknowledged = 900; + parts + .packet_sink + .send(build_tcp( + &peer, + 701, + sequence.wrapping_add(acknowledged), + TcpFlags::ACK, + b"!", + )) + .await + .unwrap(); + let mut barrier = [0]; + tokio::time::timeout(WAIT, stream.read_exact(&mut barrier)) + .await + .unwrap() + .unwrap(); + assert_eq!(barrier, *b"!"); + // A checksum-valid quote inside the acknowledged prefix is stale. + parts + .packet_sink + .send(build_path_mtu_hint(&original, 576)) + .await + .unwrap(); + // Quote the first unacknowledged sequence using a complete genuine TCP + // packet. The ICMP message still retains only its eight-byte TCP prefix. + let tail = build_tcp( + &Flow { + source: peer.destination, + destination: peer.source, + }, + sequence.wrapping_add(acknowledged), + 702, + TcpFlags::ACK, + &payload[usize::try_from(acknowledged).unwrap()..], + ); + parts + .packet_sink + .send(build_path_mtu_hint(&tail, 576)) + .await + .unwrap(); + let recovered = timeout_tcp_data(&mut parts.packet_stream).await; + assert_eq!( + tcp_sequence(&recovered), + sequence.wrapping_add(acknowledged) + ); + assert!(recovered.len() <= 576); + assert_eq!( + tcp_payload(&recovered), + &payload[900..900 + tcp_payload(&recovered).len()] + ); + // A one-slot output leaves thousands of bytes in the packet adapter's + // original. Waiting neither drains the output nor makes Stop depend on it. + tokio::time::sleep(Duration::from_millis(5)).await; + tokio::time::timeout(WAIT, parts.control.stop()) + .await + .unwrap(); + assert!(stream.is_stopped()); + assert_eq!(parts.stats.snapshot().active_tcp, 0); + assert!(parts.packet_stream.recv().await.is_none()); +} + +#[tokio::test] +async fn masque_client_mtu_sources_and_independent_receive_budget_use_actual_packets() { + for (mtu, prefix, destination, expected_mss) in [ + (68, client_prefix("10.23.0.2", 24), "10.23.0.20:443", 28), + (1280, client_prefix("fd23::2", 64), "[fd23::20]:443", 1220), + ] { + let mut parts = NetStack::start_client( + NetStackConfig { + mtu: 1500, + ..NetStackConfig::default() + }, + ClientConfig { + prefixes: [Some(prefix), None], + receive_packet_limit: 1500, + path_mtu_discovery: true, + }, + ) + .unwrap(); + parts + .tcp_connector + .set_initial_mtu(mtu, tokio::time::Instant::now() + WAIT) + .await + .unwrap(); + let mut connect = Box::pin(parts.tcp_connector.connect( + destination.parse().unwrap(), + tokio::time::Instant::now() + WAIT, + )); + assert!(futures_util::poll!(connect.as_mut()).is_pending()); + let syn = timeout_tcp_flag(&mut parts.packet_stream, TcpFlags::SYN).await; + assert!(syn.len() <= mtu); + assert_eq!(tcp_mss(&syn), Some(expected_mss)); + let flow = tcp_flow(&syn); + assert_eq!(flow.source.ip(), prefix.address); + let peer = Flow { + source: flow.destination, + destination: flow.source, + }; + let local_sequence = tcp_sequence(&syn).wrapping_add(1); + parts + .packet_sink + .send(build_tcp_mss( + &peer, + 700, + local_sequence, + TcpFlags::SYN | TcpFlags::ACK, + &[], + if mtu == 68 { 28 } else { 1220 }, + )) + .await + .unwrap(); + let mut stream = tokio::time::timeout(WAIT, connect).await.unwrap().unwrap(); + let incoming = vec![0x61; 1400]; + let packet = build_tcp(&peer, 701, local_sequence, TcpFlags::ACK, &incoming); + assert!(packet.len() > mtu); + parts.packet_sink.send(packet).await.unwrap(); + let mut received = vec![0; incoming.len()]; + tokio::time::timeout(WAIT, stream.read_exact(&mut received)) + .await + .unwrap() + .unwrap(); + assert_eq!(received, incoming); + let outgoing = vec![0x62; 100]; + stream.write_all(&outgoing).await.unwrap(); + let mut emitted = Vec::new(); + while emitted.len() < outgoing.len() { + let packet = timeout_tcp_data(&mut parts.packet_stream).await; + assert!(packet.len() <= mtu); + if mtu == 68 { + assert!( + tcp_payload(&packet).len() <= 28, + "small path MSS was ignored" + ); + } + emitted.extend_from_slice(tcp_payload(&packet)); + parts + .packet_sink + .send(build_tcp( + &peer, + 2101, + local_sequence.wrapping_add(u32::try_from(emitted.len()).unwrap()), + TcpFlags::ACK, + &[], + )) + .await + .unwrap(); + } + assert_eq!(emitted, outgoing); + parts.control.stop().await; + assert!(stream.is_stopped()); + assert_eq!(parts.stats.snapshot().active_tcp, 0); + } + assert_two_configured_sources().await; +} + +#[tokio::test] +async fn initial_mtu_rejects_invalid_late_and_stopped_commands() { + let mut parts = NetStack::start_client( + NetStackConfig::default(), + ClientConfig { + prefixes: [Some(client_prefix("10.23.0.2", 24)), None], + receive_packet_limit: 1500, + path_mtu_discovery: true, + }, + ) + .unwrap(); + let connector = &parts.tcp_connector; + for mtu in [67, 1501] { + assert_eq!( + connector + .set_initial_mtu(mtu, tokio::time::Instant::now() + WAIT) + .await + .unwrap_err() + .kind(), + std::io::ErrorKind::InvalidInput + ); + } + assert_eq!( + connector + .set_initial_mtu(1280, tokio::time::Instant::now()) + .await + .unwrap_err() + .kind(), + std::io::ErrorKind::TimedOut + ); + connector + .set_initial_mtu(1280, tokio::time::Instant::now() + WAIT) + .await + .unwrap(); + let mut connect = Box::pin(connector.connect( + "198.51.100.2:443".parse().unwrap(), + tokio::time::Instant::now() + WAIT, + )); + assert!(futures_util::poll!(connect.as_mut()).is_pending()); + let syn = timeout_tcp_flag(&mut parts.packet_stream, TcpFlags::SYN).await; + assert_eq!(tcp_mss(&syn), Some(1240)); + assert_eq!( + connector + .set_initial_mtu(1000, tokio::time::Instant::now() + WAIT) + .await + .unwrap_err() + .kind(), + std::io::ErrorKind::AlreadyExists + ); + parts.control.stop().await; + assert_eq!( + connector + .set_initial_mtu(1280, tokio::time::Instant::now() + WAIT) + .await + .unwrap_err() + .kind(), + std::io::ErrorKind::ConnectionAborted + ); + assert!(connect.await.is_err()); +} + +async fn assert_two_configured_sources() { + let mut parts = NetStack::start_client( + NetStackConfig::default(), + ClientConfig { + prefixes: [ + Some(client_prefix("10.23.0.2", 24)), + Some(client_prefix("10.24.0.2", 24)), + ], + receive_packet_limit: 1500, + path_mtu_discovery: true, + }, + ) + .unwrap(); + for (destination, expected_source) in [ + ("[::ffff:10.23.0.20]:443", "10.23.0.2"), + ("10.24.0.20:443", "10.24.0.2"), + ] { + let (stream, _, _) = active_connect(&mut parts, destination.parse().unwrap()).await; + assert_eq!( + stream.source_addr().ip(), + expected_source.parse::().unwrap() + ); + drop(stream); + } + parts.control.stop().await; + assert_eq!(parts.stats.snapshot().active_tcp, 0); +} + +#[tokio::test] +async fn masque_client_pmtu_resegments_only_the_active_quoted_flow() { + for (first_peer, second_peer, payload_len, mtu) in [ + ("198.51.100.20:443", "198.51.100.21:443", 800, 576), + ("[2001:db8:2::20]:443", "[2001:db8:2::21]:443", 1400, 1280), + ] { + let mut parts = NetStack::start_client( + NetStackConfig::default(), + ClientConfig { + prefixes: [ + Some(client_prefix("10.23.0.2", 24)), + Some(client_prefix("fd23::2", 64)), + ], + receive_packet_limit: 1500, + path_mtu_discovery: true, + }, + ) + .unwrap(); + let (mut first, peer, sequence) = + active_connect(&mut parts, first_peer.parse().unwrap()).await; + let (mut second, _, _) = active_connect(&mut parts, second_peer.parse().unwrap()).await; + first.write_all(&vec![0x41; payload_len]).await.unwrap(); + let acknowledged = timeout_tcp_data(&mut parts.packet_stream).await; + assert_eq!( + tcp_payload(&acknowledged).len(), + payload_len, + "baseline first write was segmented for {first_peer}" + ); + assert_eq!(tcp_sequence(&acknowledged), sequence); + parts + .packet_sink + .send(build_tcp( + &peer, + 701, + sequence.wrapping_add(u32::try_from(payload_len).unwrap()), + TcpFlags::ACK, + b"!", + )) + .await + .unwrap(); + // Queue admission is not TCP ACK processing. Consume actual peer data + // from the same ACK so the second write starts with released flight + // credit, rather than racing the driver's pre-ingress egress pass. + let mut barrier = [0]; + tokio::time::timeout(WAIT, first.read_exact(&mut barrier)) + .await + .unwrap() + .unwrap(); + assert_eq!(&barrier, b"!"); + // A real, checksummed error for already acknowledged bytes cannot alter + // this socket or seed a destination-wide cache for either connection. + parts + .packet_sink + .send(build_path_mtu_hint(&acknowledged, mtu)) + .await + .unwrap(); + first.write_all(&vec![0x42; payload_len]).await.unwrap(); + let outstanding = timeout_tcp_data(&mut parts.packet_stream).await; + assert!( + outstanding.len() > usize::from(mtu), + "new frame for {first_peer}: length={}, sequence={}, expected_sequence={}", + outstanding.len(), + tcp_sequence(&outstanding), + sequence.wrapping_add(u32::try_from(payload_len).unwrap()) + ); + assert_eq!( + tcp_sequence(&outstanding), + sequence.wrapping_add(u32::try_from(payload_len).unwrap()) + ); + parts + .packet_sink + .send(build_path_mtu_hint(&outstanding, mtu)) + .await + .unwrap(); + let retransmitted = timeout_tcp_data(&mut parts.packet_stream).await; + assert_eq!(tcp_flow(&retransmitted).source, first.source_addr()); + assert_eq!(tcp_sequence(&retransmitted), tcp_sequence(&outstanding)); + assert!(retransmitted.len() <= usize::from(mtu)); + let fragment_size = usize::from(mtu) - ip_header_len(outstanding.data()) - 20; + assert_eq!( + tcp_payload(&retransmitted), + &tcp_payload(&outstanding)[..fragment_size] + ); + second.write_all(&vec![0x43; payload_len]).await.unwrap(); + loop { + let packet = timeout_tcp_data(&mut parts.packet_stream).await; + if tcp_flow(&packet).source == second.source_addr() { + assert!( + packet.len() > usize::from(mtu), + "PMTU leaked into a sibling flow" + ); + assert_eq!(tcp_payload(&packet), &vec![0x43; payload_len]); + break; + } + } + parts.control.stop().await; + assert!(first.is_stopped() && second.is_stopped()); + assert_eq!(parts.stats.snapshot().active_tcp, 0); + } +} + +#[tokio::test] +async fn passive_tun_pmtu_resegments_only_live_sent_tcp_and_preserves_siblings() { + for (first_flow, second_flow, length, mtu) in [ + (Flow::v4(21001), Flow::v4(21002), 800, 576u16), + (Flow::v6(21001), Flow::v6(21002), 1400, 1280u16), + ] { + let mut parts = NetStack::start(NetStackConfig::default()) + .unwrap() + .into_parts(); + let mut streams = Vec::new(); + for flow in [&first_flow, &second_flow] { + parts + .packet_sink + .send(build_tcp_mss(flow, 100, 0, TcpFlags::SYN, &[], 1460)) + .await + .unwrap(); + let reply = timeout_tcp_flag(&mut parts.packet_stream, TcpFlags::SYN).await; + assert!(is_syn_ack(&reply, flow)); + parts + .packet_sink + .send(build_tcp( + flow, + 101, + tcp_sequence(&reply).wrapping_add(1), + TcpFlags::ACK, + &[], + )) + .await + .unwrap(); + streams.push( + tokio::time::timeout(WAIT, parts.tcp_listener.accept()) + .await + .unwrap() + .unwrap(), + ); + } + streams[0].write_all(&vec![0x51; length]).await.unwrap(); + let outstanding = timeout_tcp_data(&mut parts.packet_stream).await; + assert!(outstanding.len() > usize::from(mtu)); + parts + .packet_sink + .send(build_path_mtu_hint(&outstanding, mtu)) + .await + .unwrap(); + let retransmitted = timeout_tcp_data(&mut parts.packet_stream).await; + assert_eq!(tcp_sequence(&retransmitted), tcp_sequence(&outstanding)); + assert_eq!(tcp_flow(&retransmitted).destination, first_flow.source); + assert!(retransmitted.len() <= usize::from(mtu)); + streams[1].write_all(&vec![0x52; length]).await.unwrap(); + loop { + let packet = timeout_tcp_data(&mut parts.packet_stream).await; + if tcp_flow(&packet).destination == second_flow.source { + assert!(packet.len() > usize::from(mtu)); + assert_eq!(tcp_payload(&packet), vec![0x52; length]); + break; + } + } + parts.control.stop().await; + assert_eq!(parts.stats.snapshot().active_tcp, 0); + } +} + +fn build_path_mtu_hint(quoted: &Packet, mtu: u16) -> Packet { + let flow = tcp_flow(quoted); + // Quote only the IP header and the first eight transport bytes. The original + // IP total length intentionally exceeds the quotation's allocation. + let quoted_len = ip_header_len(quoted.data()) + 8; + let mut message = vec![0; 8 + quoted_len]; + message[8..].copy_from_slice("ed.data()[..quoted_len]); + match flow.source.ip() { + IpAddr::V4(local) => { + message[0] = 3; + message[1] = 4; + message[6..8].copy_from_slice(&mtu.to_be_bytes()); + let checksum = internet_checksum(&message); + message[2..4].copy_from_slice(&checksum.to_be_bytes()); + build_ipv4("192.0.2.1".parse().unwrap(), local, 1, &message) + } + IpAddr::V6(local) => { + let router = "2001:db8::1".parse().unwrap(); + message[0] = 2; + message[4..8].copy_from_slice(&u32::from(mtu).to_be_bytes()); + let checksum = transport_checksum_v6(router, local, 58, &message); + message[2..4].copy_from_slice(&checksum.to_be_bytes()); + build_ipv6(router, local, 58, &message) + } + } +} + +async fn wait_no_tcp(stats: &vole_netstack::NetStackStats) { + tokio::time::timeout(WAIT, async { + while stats.snapshot().active_tcp != 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("TCP state was not reclaimed"); + assert_eq!(stats.snapshot().half_open_tcp, 0); +} + +async fn timeout_tcp_flag(stream: &mut vole_netstack::PacketStream, flag: u8) -> Packet { + loop { + let packet = timeout_packet(stream).await; + let offset = ip_header_len(packet.data()); + if packet.data()[offset + 13] & flag != 0 { + return packet; + } + } +} + +#[tokio::test] +async fn active_tcp_uses_configured_sources_and_delivers_established_duplex_streams() { + let mut parts = NetStack::start_client(NetStackConfig::default(), client_addresses()).unwrap(); + for destination in ["198.51.100.20:443", "[2001:db8:2::20]:443"] { + let destination: SocketAddr = destination.parse().unwrap(); + let mut connect = Box::pin( + parts + .tcp_connector + .connect(destination, tokio::time::Instant::now() + WAIT), + ); + assert!(futures_util::poll!(connect.as_mut()).is_pending()); + let syn = timeout_packet(&mut parts.packet_stream).await; + let flow = tcp_flow(&syn); + assert_eq!(flow.destination, destination); + assert_eq!( + flow.source.ip(), + if destination.is_ipv4() { + IpAddr::V4(client_addresses().ipv4.unwrap()) + } else { + IpAddr::V6(client_addresses().ipv6.unwrap()) + } + ); + let offset = ip_header_len(syn.data()); + assert_eq!( + syn.data()[offset + 13] & (TcpFlags::SYN | TcpFlags::ACK), + TcpFlags::SYN + ); + assert!( + futures_util::poll!(connect.as_mut()).is_pending(), + "connect returned before the handshake" + ); + let peer = Flow { + source: flow.destination, + destination: flow.source, + }; + let local_sequence = tcp_sequence(&syn).wrapping_add(1); + parts + .packet_sink + .send(build_tcp( + &peer, + 800, + local_sequence, + TcpFlags::SYN | TcpFlags::ACK, + &[], + )) + .await + .unwrap(); + let mut stream = tokio::time::timeout(WAIT, connect).await.unwrap().unwrap(); + assert_eq!(stream.source_addr(), flow.source); + assert_eq!(stream.destination_addr(), destination); + assert_eq!(parts.stats.snapshot().half_open_tcp, 0); + stream.write_all(b"hello").await.unwrap(); + loop { + let packet = timeout_packet(&mut parts.packet_stream).await; + let offset = ip_header_len(packet.data()); + let start = offset + usize::from(packet.data()[offset + 12] >> 4) * 4; + if start != packet.len() { + assert_eq!(&packet.data()[start..], b"hello"); + assert_eq!(tcp_flow(&packet).source, flow.source); + break; + } + } + parts + .packet_sink + .send(build_tcp( + &peer, + 801, + local_sequence.wrapping_add(5), + TcpFlags::ACK, + b"world", + )) + .await + .unwrap(); + let mut payload = [0; 5]; + tokio::time::timeout(WAIT, stream.read_exact(&mut payload)) + .await + .unwrap() + .unwrap(); + assert_eq!(&payload, b"world"); + stream.shutdown().await.unwrap(); + timeout_tcp_flag(&mut parts.packet_stream, 1).await; + parts + .packet_sink + .send(build_tcp( + &peer, + 806, + local_sequence.wrapping_add(6), + TcpFlags::ACK | 1, + b"tail", + )) + .await + .unwrap(); + let mut tail = Vec::new(); + tokio::time::timeout(WAIT, stream.read_to_end(&mut tail)) + .await + .unwrap() + .unwrap(); + assert_eq!(tail, b"tail"); + } + parts.control.stop().await; + assert_eq!(parts.stats.snapshot().active_tcp, 0); +} + +#[tokio::test] +async fn active_tcp_shutdown_drains_accepted_tail_before_fin_without_prior_acks() { + assert_active_accepted_tail(false).await; +} + +#[tokio::test] +async fn active_tcp_drop_drains_accepted_tail_before_fin_without_prior_acks() { + assert_active_accepted_tail(true).await; +} + +async fn assert_active_accepted_tail(drop_after_write: bool) { + let config = NetStackConfig::default(); + let payload: Vec = (0..24 * 1024) + .map(|index| u8::try_from(index % 251).unwrap()) + .collect(); + assert!(payload.len() > config.tcp_send_buffer / 2); + assert!(payload.len() < config.tcp_send_buffer); + let mut parts = NetStack::start_client(config, client_addresses()).unwrap(); + let (mut stream, peer, sequence) = + active_connect(&mut parts, "198.51.100.20:443".parse().unwrap()).await; + tokio::time::timeout(WAIT, stream.write_all(&payload)) + .await + .expect("accepted prefix did not fit the existing total send budget") + .unwrap(); + + // No DATA ACK was supplied. A real pending shutdown verifies that there + // is still an application tail, without observing private queue counters. + let mut stream = Some(stream); + let mut shutdown = Box::pin(stream.as_mut().unwrap().shutdown()); + assert!(futures_util::poll!(shutdown.as_mut()).is_pending()); + if drop_after_write { + drop(shutdown); + drop(stream.take()); + receive_active_accepted_tail(&mut parts, &peer, sequence, &payload).await; + } else { + tokio::join!( + async { + tokio::time::timeout(WAIT, shutdown) + .await + .expect("ACK recovery did not complete shutdown") + .unwrap(); + }, + receive_active_accepted_tail(&mut parts, &peer, sequence, &payload), + ); + let mut byte = [0]; + let mut read = Box::pin(stream.as_mut().unwrap().read(&mut byte)); + assert!( + futures_util::poll!(read.as_mut()).is_pending(), + "local write shutdown closed the receive half before peer FIN" + ); + } + assert_eq!(parts.stats.snapshot().active_tcp, 1); + + // FIN has not been acknowledged yet. Close the peer with a real packet + // that cumulatively acknowledges the accepted DATA and the local FIN. + let fin_end = sequence + .wrapping_add(u32::try_from(payload.len()).unwrap()) + .wrapping_add(1); + parts + .packet_sink + .send(build_tcp(&peer, 701, fin_end, TcpFlags::ACK | 1, &[])) + .await + .unwrap(); + tokio::time::timeout(WAIT, async { + loop { + let packet = timeout_packet(&mut parts.packet_stream).await; + let offset = ip_header_len(packet.data()); + assert_eq!(packet.data()[offset + 13] & 4, 0, "unexpected RST"); + if tcp_acknowledgement(&packet) == 702 { + assert_eq!(tcp_sequence(&packet), fin_end); + assert_eq!(tcp_payload(&packet), []); + break; + } + } + }) + .await + .expect("peer FIN was not acknowledged"); + if let Some(stream) = stream.as_mut() { + let mut byte = [0]; + assert_eq!( + tokio::time::timeout(WAIT, stream.read(&mut byte)) + .await + .expect("peer FIN did not reach the application") + .unwrap(), + 0 + ); + } + wait_no_tcp(&parts.stats).await; + parts.control.stop().await; + assert_eq!(parts.stats.snapshot().half_open_tcp, 0); + assert!(parts.packet_stream.recv().await.is_none()); +} + +async fn receive_active_accepted_tail( + parts: &mut vole_netstack::ClientNetStackParts, + peer: &Flow, + sequence: u32, + expected: &[u8], +) { + tokio::time::timeout(WAIT, async { + let mut received = 0; + loop { + let packet = timeout_packet(&mut parts.packet_stream).await; + let flow = tcp_flow(&packet); + assert_eq!(flow.source, peer.destination); + assert_eq!(flow.destination, peer.source); + let offset = ip_header_len(packet.data()); + let flags = packet.data()[offset + 13]; + assert_eq!(flags & 4, 0, "accepted tail was reset"); + let payload = tcp_payload(&packet); + let start = usize::try_from(tcp_sequence(&packet).wrapping_sub(sequence)).unwrap(); + if !payload.is_empty() { + assert!(start <= received, "wire prefix contains a sequence hole"); + let end = start + payload.len(); + assert!( + end <= expected.len(), + "unexpected bytes beyond accepted tail" + ); + assert_eq!(payload, &expected[start..end]); + received = received.max(end); + } + if flags & 1 != 0 { + assert_eq!(received, expected.len()); + assert_eq!(start + payload.len(), expected.len(), "early FIN sequence"); + assert_eq!(parts.stats.snapshot().active_tcp, 1); + return; + } + if !payload.is_empty() { + parts + .packet_sink + .send(build_tcp( + peer, + 701, + sequence.wrapping_add(u32::try_from(received).unwrap()), + TcpFlags::ACK, + &[], + )) + .await + .unwrap(); + } + } + }) + .await + .expect("accepted tail/FIN did not drain after real ACK recovery"); +} + +#[tokio::test] +async fn active_tcp_refused_handshake_reclaims_its_socket() { + let mut parts = NetStack::start_client(NetStackConfig::default(), client_addresses()).unwrap(); + let mut connect = Box::pin(parts.tcp_connector.connect( + "198.51.100.20:443".parse().unwrap(), + tokio::time::Instant::now() + WAIT, + )); + assert!(futures_util::poll!(connect.as_mut()).is_pending()); + let syn = timeout_packet(&mut parts.packet_stream).await; + let flow = tcp_flow(&syn); + let peer = Flow { + source: flow.destination, + destination: flow.source, + }; + parts + .packet_sink + .send(build_tcp( + &peer, + 0, + tcp_sequence(&syn).wrapping_add(1), + TcpFlags::ACK | 4, + &[], + )) + .await + .unwrap(); + assert_eq!( + tokio::time::timeout(WAIT, connect) + .await + .unwrap() + .unwrap_err() + .kind(), + std::io::ErrorKind::ConnectionRefused + ); + wait_no_tcp(&parts.stats).await; + parts.control.stop().await; +} + +#[tokio::test] +async fn active_tcp_cancel_and_deadline_emit_reset_and_reclaim_state() { + for (drop_future, establish) in [(true, false), (false, false), (true, true)] { + let mut parts = NetStack::start_client( + NetStackConfig { + max_poll_interval: Duration::from_secs(5), + ..NetStackConfig::default() + }, + client_addresses(), + ) + .unwrap(); + let mut connect = Box::pin(parts.tcp_connector.connect( + "198.51.100.20:443".parse().unwrap(), + tokio::time::Instant::now() + Duration::from_millis(150), + )); + assert!(futures_util::poll!(connect.as_mut()).is_pending()); + let syn = timeout_packet(&mut parts.packet_stream).await; + if establish { + let flow = tcp_flow(&syn); + let peer = Flow { + source: flow.destination, + destination: flow.source, + }; + parts + .packet_sink + .send(build_tcp( + &peer, + 800, + tcp_sequence(&syn).wrapping_add(1), + TcpFlags::SYN | TcpFlags::ACK, + &[], + )) + .await + .unwrap(); + timeout_tcp_flag(&mut parts.packet_stream, TcpFlags::ACK).await; + tokio::time::timeout(WAIT, async { + while parts.stats.snapshot().half_open_tcp != 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + // The caller has not polled the queued Established result. Drop + // must still abort, rather than leaving an unclaimed live socket. + } + if drop_future { + drop(connect); + } else { + assert_eq!( + connect.await.unwrap_err().kind(), + std::io::ErrorKind::TimedOut + ); + } + let reset = timeout_packet(&mut parts.packet_stream).await; + let offset = ip_header_len(reset.data()); + assert_ne!(reset.data()[offset + 13] & 4, 0); + assert_eq!(tcp_flow(&reset).source, tcp_flow(&syn).source); + wait_no_tcp(&parts.stats).await; + parts.control.stop().await; + } +} + +#[tokio::test] +async fn active_tcp_stop_releases_full_output_pending_connects_and_rejects_unsolicited_syn() { + let mut parts = NetStack::start_client( + NetStackConfig { + packet_queue: 1, + max_poll_interval: Duration::from_secs(5), + ..NetStackConfig::default() + }, + client_addresses(), + ) + .unwrap(); + let destination = "198.51.100.20:443".parse().unwrap(); + let mut first = Box::pin( + parts + .tcp_connector + .connect(destination, tokio::time::Instant::now() + WAIT), + ); + let mut second = Box::pin( + parts + .tcp_connector + .connect(destination, tokio::time::Instant::now() + WAIT), + ); + assert!(futures_util::poll!(first.as_mut()).is_pending()); + assert!(futures_util::poll!(second.as_mut()).is_pending()); + let stranger = Flow { + source: destination, + destination: "10.23.0.2:8000".parse().unwrap(), + }; + parts + .packet_sink + .send(build_tcp(&stranger, 100, 0, TcpFlags::SYN, &[])) + .await + .unwrap(); + tokio::time::timeout(WAIT, async { + while parts.stats.snapshot().active_tcp != 2 || parts.stats.snapshot().rejected_tcp != 1 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + // The sole output slot still contains a SYN; stop must not await draining. + tokio::time::timeout(Duration::from_millis(500), parts.control.stop()) + .await + .unwrap(); + assert_eq!( + first.await.unwrap_err().kind(), + std::io::ErrorKind::ConnectionAborted + ); + assert_eq!( + second.await.unwrap_err().kind(), + std::io::ErrorKind::ConnectionAborted + ); + wait_no_tcp(&parts.stats).await; + assert!(parts.packet_stream.recv().await.is_none()); + assert!(matches!( + parts + .packet_sink + .try_send(build_tcp(&stranger, 100, 0, TcpFlags::SYN, &[])), + Err(NetStackError::Stopped) + )); +} + #[tokio::test] async fn ready_tcp_burst_preserves_flow_binding_and_neighboring_icmp() { let mut parts = NetStack::start(NetStackConfig { @@ -98,26 +1115,10 @@ async fn configured_jumbo_mtu_reaches_tcp_mss_for_both_ip_families() { .unwrap(); let reply = timeout_packet(&mut parts.packet_stream).await; assert!(is_syn_ack(&reply, &flow)); - let offset = ip_header_len(reply.data()); - let header_len = usize::from(reply.data()[offset + 12] >> 4) * 4; - let mut options = &reply.data()[offset + 20..offset + header_len]; - let mut mss = None; - while let Some(kind) = options.first().copied() { - match kind { - 0 => break, - 1 => options = &options[1..], - _ => { - let length = usize::from(options[1]); - assert!(length >= 2 && length <= options.len()); - if kind == 2 { - assert_eq!(length, 4); - mss = Some(u16::from_be_bytes([options[2], options[3]])); - } - options = &options[length..]; - } - } - } - assert_eq!(mss, Some(u16::try_from(9000 - offset - 20).unwrap())); + assert_eq!( + tcp_mss(&reply), + Some(u16::try_from(9000 - ip_header_len(reply.data()) - 20).unwrap()) + ); } parts.control.stop().await; } @@ -352,7 +1353,11 @@ async fn dropping_the_only_output_consumer_stops_active_tcp_and_other_endpoints( #[tokio::test] async fn asymmetric_tcp_buffers_bound_each_direction_and_stop_unblocks_writes() { - for (recv_buffer, send_buffer) in [(4 * 1024, 16 * 1024), (16 * 1024, 4 * 1024)] { + for (recv_buffer, send_buffer) in [ + (4 * 1024, 16 * 1024), + (16 * 1024, 4 * 1024), + (32 * 1024, 64 * 1024), + ] { for flow in [Flow::v4(13_500), Flow::v6(13_501)] { let mut parts = NetStack::start_tcp(NetStackConfig { tcp_recv_buffer: recv_buffer, @@ -929,15 +1934,47 @@ async fn timeout_packet(stream: &mut vole_netstack::PacketStream) -> Packet { } fn build_tcp(flow: &Flow, sequence: u32, ack: u32, flags: u8, payload: &[u8]) -> Packet { - let mut transport = vec![0_u8; 20 + payload.len()]; + build_tcp_options(flow, sequence, ack, flags, payload, &[]) +} + +fn build_tcp_mss( + flow: &Flow, + sequence: u32, + ack: u32, + flags: u8, + payload: &[u8], + mss: u16, +) -> Packet { + let bytes = mss.to_be_bytes(); + build_tcp_options( + flow, + sequence, + ack, + flags, + payload, + &[2, 4, bytes[0], bytes[1]], + ) +} + +fn build_tcp_options( + flow: &Flow, + sequence: u32, + ack: u32, + flags: u8, + payload: &[u8], + options: &[u8], +) -> Packet { + let header_len = 20 + options.len(); + let mut transport = vec![0_u8; header_len + payload.len()]; transport[..2].copy_from_slice(&flow.source.port().to_be_bytes()); transport[2..4].copy_from_slice(&flow.destination.port().to_be_bytes()); transport[4..8].copy_from_slice(&sequence.to_be_bytes()); transport[8..12].copy_from_slice(&ack.to_be_bytes()); - transport[12] = 5 << 4; + transport[12] = u8::try_from(header_len / 4).unwrap() << 4; transport[13] = flags; transport[14..16].copy_from_slice(&u16::MAX.to_be_bytes()); - transport[20..].copy_from_slice(payload); + transport[20..header_len].copy_from_slice(options); + transport[header_len..].copy_from_slice(payload); match (flow.source, flow.destination) { (SocketAddr::V4(source), SocketAddr::V4(destination)) => { diff --git a/docs/README.md b/docs/README.md index 3de2a63..c04f44f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,12 +4,13 @@ ## 配置与接口 -- [配置协议](config.yaml):严格 YAML、节点、静态 select 组、DNS 与规则。 +- [配置协议](config.yaml):严格 YAML、节点、providers、动态与自动策略组、DNS 与规则。 +- [Mihomo 兼容原则与范围](mihomo-compatibility.md):互通要求、性能取舍、已实现能力及协议与平台边界。 - [Invoke API](invoke-api.md):严格请求格式、核心软件版本与构建身份、生命周期、平台回调及内联配置。 -- [Controller](controller-api.md):认证、运行中组选择和 TUN 流量。 +- [Controller](controller-api.md):认证、组与资源、连接管理、规则/配置/DNS 诊断和 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。 +- [出站](outbounds.md):HTTP(S) CONNECT、SOCKS5、AnyTLS、Trojan、VMess AEAD、Hysteria2、TUIC v5、SS 2022、WireGuard、TrustTunnel、Mieru、MASQUE。 +- [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):认证、密钥与失败边界。 diff --git a/docs/acceptance.md b/docs/acceptance.md index 2269628..1138150 100644 --- a/docs/acceptance.md +++ b/docs/acceptance.md @@ -2,20 +2,69 @@ 只报告实际执行的检查。结果记录在对应 CI、PR 或 benchmark 运行中,本文维护验证范围。 +兼容验收遵循 [Mihomo 兼容原则](mihomo-compatibility.md#兼容原则):验证与 Mihomo listener 和官方协议 server 的互通,性能优化综合评估吞吐、CPU、内存和丢包率的收益与代价。字段对照用于记录支持范围与差异,不要求逐字段行为相等。性能达标须依据当次明确的内存预算、目标负载及实测结果判断,不能仅凭连接成功或配置解析通过。 + | 层次 | 验证内容 | | --- | --- | | 离线 / 纯内存 | 配置、协议编解码、取消、生命周期、内存与 FFI 所有权 | | 平台编译 | 各目标构建、CLI 基本运行、库文件和发布归档 | | 原生消费者 | C/Swift/JNI 等真实宿主的加载、调用与资源释放 | -| 容器互通 | 官方协议对端、真实双向流量、认证、UDP 与关闭行为 | +| 容器互通 | Mihomo listener 与官方协议 server、真实双向流量、认证、支持模式的 UDP 与关闭行为 | | 压力 | 实际吞吐、CPU、内存峰值、丢包、DNS 完成数及退出清理 | | 设备 / 发布 | 真机 TUN、物理出口、权限、生命周期、签名安装及商店要求 | +协议互通先验证握手、认证、双向数据、关闭与资源清理。吞吐、CPU、内存和丢包率独立记录, +不以压力阈值判定互通失败;全部互通完成后,再单独进行压力验收与性能优化。 + 编译不替代原生消费者或设备验证;模拟器、Wintun 与 UWP 分别验收。 Linux RSS 不等于 Apple Network Extension physical footprint。 性能结果须同时给出输入、目标负载和实际负载,保留失败与丢包情况。 +## 性能采纳标准 + +所有平台与构建使用统一的内存策略和 benchmark 预算,内存统一使用 MiB: + +- **1 Gbps:≤43 MiB(45,088,768 bytes)**。 +- **2 Gbps:≤45 MiB(47,185,920 bytes)**。 + +每档使用单一内存门槛,不再区分硬上限与优化目标。 + +内存策略不按平台或编译模式切换,CLI 与 FFI 共用同一实现。 + +真实 iOS/tvOS VPN 扩展仍以整个 Network Extension 进程的 physical footprint 验收, +包含 Core、宿主桥接与扩展运行时。 +上述数值是项目按负载划分的验收预算,不声明为各设备的系统限额,也不能只计算 Rust heap。 +Linux 压力使用 RSS;Linux 结果只作优化筛选,不替代真机扩展验收。 +其他平台记录承载 Core 的整个后台进程及实际指标口径,排除独立负载客户端和原站。 +不同平台和测量指标的数值不直接混合排名。 + +定速压力使用既有 Linux 原生 TUN 环境。冷启动、配置和 GeoData 更新、负载、收尾及 +Stop 都应覆盖;未执行的生命周期场景单独标明,不从一次压力结果推导。 +测试执行完成不代表达到验收门槛。 + +性能对照使用相同输入、负载和重复次数的普通 Release 构建,分别比较吞吐/QPS 中位数 +与同平台进程内存峰值。Linux 保留原压力负载和 RSS 观察逻辑,覆盖 exec 至退出/reap, +包括启动、负载、drain 和 Stop;heaptrack 等仪器开销、tracked heap 和 wait4 读数 +不能替代这一验收口径。 + +- 整体验收必须满足对应负载的内存门槛,协议数据正确性、取消与资源清理必须保持;不能用吞吐或丢包改善抵消内存超限。 +- 吞吐、CPU、内存和丢包率都允许渐进式改进,不要求一次修改达到全部目标、各项同步改善或单项绝对不退步,不设单次改善幅度下限。不固定指标优化顺序,按实测瓶颈和综合收益选择下一步。 +- 允许指标之间的合理取舍,结合改善幅度、代价、实际完成量和运行间波动评估;综合收益为正的阶段性改进可以采纳。减少实际负载或业务未完成导致的 CPU、内存下降不能算作优化收益。 +- 当前组合和所有受影响组合均需完成相同输入、负载下的回归,不能只凭目标组合的结果采纳。涉及内存增长时仍须满足对应内存门槛;降低内存但尚未达标的阶段性改进也可采纳,超限 FAIL 保留。采纳改进与整体验收分别判断,未达标项不改签为通过。 +- Linux 压力与横向对比均覆盖纯 TCP、纯 UDP、混合 TCP/UDP 三种业务;每种业务独立记录与验收。不支持 UDP 的协议只验收纯 TCP,其他口径明确标记不适用。 +- Linux 定速压力在 **1 Gbps** 下内存须 **≤43 MiB**。性能不设独立的绝对容量轴,以实际吞吐 **≥950 Mbps**、适用的 UDP 交付丢包率 **≤1%** 作为默认参考,满足即性能通过,不再专项优化。完整性和测量独立验收:纯 TCP 要求已发送业务字节与接收字节、校验和一致,无超时、断流或未完成流;TCP 重传独立记录。期限内交付缺口不能单独归因为永久丢失或 Core 缺陷,须先区分 Core、对端与测试工具。 +- 性能对照客户端固定为 Vole 和 Mihomo。1 Gbps 未达到默认性能参考时,补测同协议组合、服务端、流量类型、输入和负载的 Mihomo;逐指标采用较容易达到的标准,达到后也认为性能通过,不再专项优化。Mihomo 与 Mihomo listener 或官方服务端存在兼容问题时,照常统计并比较吞吐、CPU、内存和丢包率,兼容问题及内容错误独立记录。官方客户端仅作源码技术参考,不作为测试对照客户端。 +- 吞吐按实际写入和读到的字节统计,包含错误内容及错误返回前的部分 I/O;内容校验失败只记录首错和累计错误,继续压力收发。数据正确性与吞吐计数分开,错误数据不计为完整、正确交付。对照须保留匹配输入、真实 TUN、实际负载窗口、进程观测、正常 Stop/join 及清理证据;缺测明确记录,不伪造数值。初期逐指标参考为吞吐下限 **min(950 Mbps, Mihomo 实测吞吐)**、UDP 丢包率上限 **max(1%, Mihomo 实测丢包率)**;CPU 继续记录。Vole 内存仍须 **≤43 MiB**,自身数据正确性、TCP 完整交付、实际窗口、DNS 与正常退出清理要求保持;不能用性能通过豁免这些门禁。旧复合负载标记包含此前吞吐目标时,不将其 false 当作实际窗口无效;使用实际活动窗和收发计数判断。历史 FAIL/ERROR、兼容问题与实际身份保留,不追溯改签。 +- 每个精确协议组合、流量类型和负载窗口优先争取达到或优于匹配 Mihomo,独立生产候选的优化尝试**最多20轮**。达到适用参考即停止该项性能优化;同一候选的重复、原版对照、纯观测及仅编译/内存测试不重复计轮。经多轮仍不能达到目标时,可记录结论提前结束;20轮是上限,不要求为凑数做满。 +- 结束上述优化阶段后,允许与匹配 Mihomo **最高相对10%差距**:吞吐下限为 **min(950 Mbps, 0.9 × Mihomo 实测吞吐)**,UDP丢包率上限为 **max(1%, min(100%, 1.1 × Mihomo 实测丢包率))**。这是相对比例,不是增加10个百分点;Mihomo丢包率为0时仍沿用1%较容易参考。必须保留独立候选及真实TUN结果的轮次证据、结束原因、严格结果与降级阶段;没有适用证据不自动放宽。内存、数据/来源、TCP完整交付、实际窗口、DNS、测量与Stop/清理门槛不放宽,历史FAIL/ERROR不重写。 +- **2 Gbps** 用于内存过载验收,内存须 **≤45 MiB**;吞吐、CPU 和丢包率只记录,不设吞吐及丢包门槛。允许通过掐连接、丢包、断连和限制队列大小等方式保护内存。仍须完成真实 TUN 的完整压力窗口,并验证已接收内容、源地址、观测有效性和退出清理;提前结束或没有实际流量不能代替压力验收。 +- UDP 交付丢包率以实际发送与唯一有效接收的数据报数计算,按上下行发送包数加权汇总,同时保留两个方向的独立计数和含错误、重复数据的原始收包数。丢包在固定收尾后统计;吞吐仅统计活动窗口,不把漏发算作丢包。iOS/tvOS 吞吐负载根据真机与链路能力校准,内存预算保持一致。 + +当前默认压力档位仅 **1 Gbps**,2 Gbps 后续单独验证。完整性、性能参考、内存、实际完成量、负载窗口、CPU、UDP loss、DNS 完成数、测量有效性及退出/join/资源清理分别记录。2 Gbps 下的超时、丢包、断连和未完成量保留原始状态,不因允许过载保护而改签为完整交付;Vole 自身的错误内容或源地址仍使验收失败。测量成功不等于业务成功,编译、配置解析或连接成功不代替压力验收。显式安排的 3 Gbps 专项沿用 **≤45 MiB** 的内存过载标准;历史结果保留当时语义,不追溯改签。 + +过载保护应遵循[运行时资源策略](runtime-resource-policy.md)的资源所有权、局部容量、取消和同步 Stop 边界。 + 核心测试入口见 [tests](../tests/README.md),平台与发布入口见 [scripts](../scripts/README.md)。 -网络互通和压力由 [container-benchmark](https://github.com/YuanDevTeam/container-benchmark) -执行,遵守 [测试隔离](testing-isolation.md)。设备要求见 [TUN 平台层](tun-platform.md) +网络互通和压力由独立容器测试工具执行,结果与结论统一保存在 benchmark 工程, +遵守 [测试隔离](testing-isolation.md)。设备要求见 [TUN 平台层](tun-platform.md) 与 [Windows VPN](windows-vpn.md)。 diff --git a/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md b/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md index 52a29e2..7af4674 100644 --- a/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md +++ b/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md @@ -4,10 +4,18 @@ status: accepted # ADR 0005:将 select 代理组建模为会话路由与上游目标 -`select` 是普通规则、最终 `MATCH`、DNS 与 `dialer-proxy` 共享的命名目标。组拥有一个有序、非空的直接成员列表,成员可以是代理节点、嵌套 `select` 组、`DIRECT` 或 `REJECT`;省略 `default-selected` 时选择第一项,显式值必须精确命中一个直接成员。节点上游和全部组成员边统一为无环图,包括未选成员;独立 `measureDelay` 仍只接受具体节点链。 +`select` 是普通规则、`MATCH`、DNS 与 `dialer-proxy` 共享的命名目标。组拥有一个有序、非空的直接成员列表,成员可以是代理节点、嵌套 `select` 组、`DIRECT` 或 `REJECT`;`default-selected` 精确匹配直接成员,省略、null、空串或未命中时选择第一项。节点上游和全部组成员边统一为无环图,包括未选成员;独立 `measureDelay` 仍只接受具体节点链。 -组选择只属于当前 Vole 运行会话,由调用方在下一次配置中重新提供。新建底层 transport 时读取当前选择;同一次建链对每个上游组只读一次,SOCKS5 UDP 的控制连接和 relay 路径共享快照与绝对期限。不同组没有原子快照保证。在途握手、既有 TCP / UDP、DNS cache / singleflight / TCP pool 和旧 AnyTLS 会话不迁移;复用旧会话的新业务流仍保持原路径。被选成员失败时原样返回,不隐式 failover。 +`mode` 支持 rule、global、direct;rule 模式允许空规则和任意位置的 MATCH,未命中直连。global 使用 GLOBAL 组;未显式声明时自动生成,成员顺序为 DIRECT、REJECT、全部节点、全部声明组,默认 DIRECT。该组与普通 select 共用运行时选择和 Controller 接口。 + +组当前选择属于运行会话;profile.store-selected 开启时由 Core 保存 Controller 的显式选择,下次启动恢复有效缓存成员。新建底层 transport 时读取当前选择;同一次建链对每个上游组只读一次,SOCKS5 UDP 的控制连接和 relay 路径共享快照与绝对期限。不同组没有原子快照保证。在途握手、既有 TCP / UDP、DNS cache / singleflight / TCP pool 和旧 AnyTLS 会话不迁移;复用旧会话的新业务流仍保持原路径。被选成员失败时原样返回,不隐式 failover。 路由组保留 `Dispatcher` seam;组上游在 `OutboundConnector` seam 解析,共享同一个原子选择索引,但不重新执行业务规则,不丢弃 effective peer、UDP 预算或建链期限。协议配置保持不可变,组连接器只持有声明的依赖;依赖优先构造、逆序停止与释放,避免全局节点注册表形成 Arc 环或深图递归析构。 -上游组的 DIRECT 连接当前节点服务器,不是业务目标;REJECT 直接使建链失败。节点无上游,或上游组经纯组链能到达 DIRECT 时,prepare 预解析其主端点及独立下载端点,包括未选路径;仅经代理访问的节点保持逻辑域名。潜在首跳解析失败时 prepare 失败,不在切组后临时借系统 DNS。 +上游组的 DIRECT 连接当前节点服务器,不是业务目标;REJECT 直接使建链失败。节点的主端点与独立下载端点在实际建链时通过运行时节点 resolver 解析;准备阶段不预解析潜在首跳。未选成员的 DNS 故障不阻塞启动。仅经代理访问的节点保持逻辑域名。DNS 关闭时使用系统解析,开启时使用配置的节点 nameserver/policy,不因失败切换系统解析;所有路径共享建链期限、递归依赖检查和停止取消。独立 measureDelay 的节点准备仍使用受控引导 resolver。 + +Provider 成员通过完整资源候选生成。候选重新校验名称及节点/组依赖图后发布,新连接使用当前资源,既有 TCP/UDP 与在途 DNS 保留仍使用的旧资源;不再使用的资源按原停止屏障释放。失败候选不替换可用配置或 HTTP 缓存。动态组移除当前成员时回到有效默认项或首成员,空组按 empty-fallback 处理。下载使用已发布资源,不依赖候选发布完成。 + +url-test 与 fallback 复用同一组选择和依赖图。共享 HTTP(S) 健康探测分别执行延迟容差和按序恢复,检查遵循 lazy、超时、并发上限和停止取消。探测结果不声明 UDP 能力,也不迁移既有连接。 + +load-balance 与路由、dialer-proxy 共用健康状态与策略实例。一致性哈希、轮询和粘滞选择在新连接建链时执行;UDP 对含负载均衡组的目标分别保留 transport,过期或明确的新 QUIC 流才重新选择。动态资源代际仍固定在旧会话上。上游组以当前节点服务器为目标,单次建链的所有协议步骤共用同一选择。 diff --git a/docs/adr/0006-control-live-proxy-group-selection-through-controller.md b/docs/adr/0006-control-live-proxy-group-selection-through-controller.md index 92b384f..607d6be 100644 --- a/docs/adr/0006-control-live-proxy-group-selection-through-controller.md +++ b/docs/adr/0006-control-live-proxy-group-selection-through-controller.md @@ -6,6 +6,8 @@ status: accepted Vole 首版只通过 Controller 暴露代理组的实时选择,不新增 Invoke 方法,也不扩展 Windows 控制管道。Controller 采用 mihomo `select` 的核心调用形状:`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}`;PUT body 为 `{"name":"member"}`,成功返回空的 `204`。组状态只承诺 `name`、`type`、`all`、`now`,不宣称兼容完整 mihomo Dashboard API。 -Windows WinRT VPN 的实际运行时位于独立 Session Host,桌面 Wintun、Apple 与 Android 的运行时位于 Invoke owner 背后;Controller 是现有平台中唯一能统一到达实际运行会话的 Adapter。组路由可以在无 TUN 的本地代理运行时中使用,`/traffic` 仍只在存在 TUN 统计时提供。 +Windows WinRT VPN 的实际运行时位于独立 Session Host,桌面 Wintun、Apple 与 Android 的运行时位于 Invoke owner 背后;Controller 是现有平台中唯一能统一到达实际运行会话的 Adapter。Controller 也可用于合法 mixed-only 静态节点或纯 DIRECT 配置,无需额外开启 TUN 或显式代理组;组路由可以在无 TUN 的本地代理运行时中使用,`/traffic` 仍只在存在 TUN 统计时提供。 -配置同时包含代理组和 Controller 时,必须为整个 Controller 配置 Bearer secret。请求使用精确名称和严格、有界的 JSON,任何失败都不得改变选择。没有 Controller 的代理组配置仍然有效,但本次会话中没有公开的实时切换入口;调用方在成功切换后自行持久化下一次启动所需的 `default-selected`,持久化失败不回滚已经成功的运行时选择。 +Controller 的监听地址和可选 `secret` 遵循 Mihomo 的配置语义:支持 IP、`localhost` 或空 host 与数值端口,端口 0 由系统选择;省略或空串 `secret` 表示不要求认证,非空值统一启用 Bearer 鉴权,代理组不增加认证要求。请求使用精确名称和严格、有界的 JSON,任何失败都不得改变选择。没有 Controller 的代理组配置仍然有效,但本次会话中没有公开的实时切换入口;Core 按 `profile.store-selected`(默认 true)保存 Controller 的显式选择;下次启动优先恢复仍存在的缓存成员,否则使用配置默认或首成员。持久化失败不回滚已经成功的运行时选择。 + +Controller 同时展示动态 provider、自动组和节点健康状态;组选择与探测任务均位于实际运行会话。Controller 的节点/组 delay 与 Provider healthcheck 在当前资源代际中共用自动健康探测器、DNS、Dialer 和停止屏障;每节点手动历史有界,实际内存仅在请求时采集。独立 measureDelay 保持 node-only Invoke 接口,复用内部 HTTP/TLS 探测能力。 diff --git a/docs/adr/0009-share-invoke-between-cli-and-native-hosts.md b/docs/adr/0009-share-invoke-between-cli-and-native-hosts.md index 93704e9..46b1ea4 100644 --- a/docs/adr/0009-share-invoke-between-cli-and-native-hosts.md +++ b/docs/adr/0009-share-invoke-between-cli-and-native-hosts.md @@ -6,6 +6,8 @@ status: accepted CLI 和原生传输都调用同一 Invoke 请求处理器,让配置准入、实例表、平台资源获取与停止屏障具有一份实现。公开业务启动合并为 `start(configYaml)`,配置准备保留在命令内部;TUN 的设备和借用描述符来自 YAML,包格式由平台内部选择。 -前台进程需要文件与标准输入、环境和路径默认值、退出信号及完整清理,因此这些行为归属显式的 `foreground` 操作;CLI 只处理五个参数、请求转换、输出和退出码。普通业务生命周期不接管宿主信号或角色,使 VPN、服务和嵌入式宿主继续管理自己的进程生命周期。原生路径作为字符串或平台编码元数据无损传递,不通过有损文本转换。 +前台进程需要文件与标准输入、环境和路径默认值、退出信号及完整清理,因此这些行为归属显式的 `foreground` 操作;CLI 只处理参数、请求转换、输出和退出码。普通业务生命周期不接管宿主信号或角色,使 VPN、服务和嵌入式宿主继续管理自己的进程生命周期。原生路径作为字符串或平台编码元数据无损传递,不通过有损文本转换。 前台与业务调用共用单实例约束。前台等待信号或引擎完成时释放命令锁,结束时 join 启动/引擎工作并停止、销毁实例;观察到完成不代替真实清理屏障。当前请求及文件行为见 [Invoke API](../invoke-api.md)与 [CLI](../cli.md)。 + +独立 HTTP 处理配置使用可选 `httpConfigYaml`;CLI 对应 `-http-config` 仅传显式路径给 foreground,读取与验证仍归 Invoke。此参数只服务于独立 HTTP 附加配置,不扩展 Mihomo YAML,也不改变单实例与停止所有权。 diff --git a/docs/adr/0010-freeze-http-resources-and-private-material-per-session.md b/docs/adr/0010-freeze-http-resources-and-private-material-per-session.md new file mode 100644 index 0000000..8bcb8a4 --- /dev/null +++ b/docs/adr/0010-freeze-http-resources-and-private-material-per-session.md @@ -0,0 +1,11 @@ +--- +status: accepted +--- + +# ADR 0010:会话固定 HTTP 资源和私密材料 + +独立 HTTP 配置的资源更新只产生完整的待应用候选;运行会话固定主设置、实际 payload、编译规则和 CA 身份。显式重连先准备候选,再经过停止屏障切换;失败恢复保留的旧实际内容,不重新读取已变化的来源。普通 Stop 释放私密材料。节点选择和分流资源更新不应用待修改的 HTTP 配置。 + +CA 由 Invoke 的独立私密上下文提供,CLI 只传明确的私密文件路径;共享 YAML、普通缓存和可序列化启动快照不承载私钥。Windows 跨进程交付使用绑定启动身份的当前用户 DPAPI sidecar。静态校验和离线演算不获取 CA、不读取外部资源,也不初始化 VPN。 + +URL、头和正文阶段始终按原始 URL 选规则。匹配正文在有界事务内累积,完整成功后才提交输出;格式或工作区不足时回放原文。共享工作区在分配前预留,许可随实际缓冲持有至发送结束;传输错误、取消和 Stop 仍作为真实失败处理。 diff --git a/docs/cli.md b/docs/cli.md index 682c0f4..9ca2eeb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -3,17 +3,33 @@ `vole` 是前台入口。CLI 解析参数并调用 Invoke;配置读取、初始化、信号与清理由 Invoke 的 `foreground` 操作处理,运行时复用同一个内核。 +GNU/Linux 的 TUN 会话在 Core 工作线程启动前,将 glibc 的 arena 默认设为 1, +mmap 阈值设为 32 KiB,降低包处理时的堆保留。显式 `MALLOC_ARENA_MAX`、 +`MALLOC_MMAP_THRESHOLD_` 或 `GLIBC_TUNABLES` 环境设置优先。 +前台入口和普通业务 `start` 共用该策略,会影响同一进程内的宿主分配器;无 TUN 会话保留宿主策略。 +帮助和配置校验保持纯查询行为。 + ```sh vole -f /path/to/config.yaml vole -d /path/to/data -f ./config.yaml vole -t -f ./config.yaml cat config.yaml | vole -f - +vole -t -config "$(base64 < config.yaml)" ``` | 参数 | 作用 | | --- | --- | | `-d ` | 配置及数据目录,默认用户目录下的 `.config/vole` | | `-f ` | 配置路径;默认 `/config.yaml`,`-f -` 从标准输入读取 | +| `-config ` | 标准 Base64 编码的主配置,非空时优先于 `-f` | +| `-age-secret-key ` | 解密 AGE armor 主配置的身份,可含多行身份 | +| `-ext-ctl
` | 非空时覆盖 HTTP Controller 地址 | +| `-ext-ctl-tls
` | 非空时覆盖 TLS Controller 地址 | +| `-ext-ctl-unix ` | 非空时覆盖 Linux 本地 Controller 路径 | +| `-ext-ctl-routing-mark ` | 非零时覆盖 Linux 管理 TCP socket mark | +| `-secret ` | 非空时覆盖 Controller 认证密钥 | +| `-http-config ` | 显式的独立 HTTP 附加配置,交给 Invoke 读取与验证 | +| `-http-ca ` | 独立私密 CA JSON 路径,交给 Invoke 读取;`-t` 不读取它 | | `-t` | 校验配置后退出,不初始化数据、联网或启动 TUN | | `-v` | 输出内核版本及构建身份 | | `-h` | 输出帮助 | @@ -24,14 +40,50 @@ cat config.yaml | vole -f - `-d` 与 `-f` 独立。与 Mihomo 一致,相对路径从启动工作目录解析并折叠 `.`/`..`; 绝对路径原样交给文件系统,保留符号链接后的 `..` 语义。路径支持空格及平台原生字符。 -`VOLE_HOME_DIR`、`VOLE_CONFIG_FILE` 提供环境默认值,显式参数优先。 +`VOLE_HOME_DIR`、`VOLE_CONFIG_FILE`、`VOLE_CONFIG_STRING` 和 `VOLE_AGE_SECRET_KEY` +提供环境默认值,分别对应 `-d/-f/-config/-age-secret-key`;显式参数优先,显式空值清除相应环境默认。 +非空 Base64 主配置优先,其次是配置路径 `-` 明确选择的标准输入,最后为配置文件;不会探测标准输入。 +`VOLE_CONFIG_FILE=-` 同样明确选择标准输入。Base64 使用带 padding 的标准字母表,允许 CR/LF, +不接受 URL-safe 字母表;编码文本最多 11,184,812 字节,解码后最多 8 MiB。 +解码后的输入和文件/标准输入均可为标准 AGE armor,复用 provider 的 X25519/hybrid 身份与认证解密; +身份文本最多 16 KiB,解密后的 YAML 最多 8 MiB。编码、密文、明文按阶段释放,不在运行会话中保留编码副本; +临时身份与请求缓冲清零,错误不包含正文或密钥。帮助和版本不读取这些环境值或配置来源。 Unix 用户目录取 `HOME`,Windows 取 `USERPROFILE`,缺失时使用工作目录。 默认 `.config/vole` 不存在或不可访问时,若设置 `XDG_CONFIG_HOME`,使用其中的 `vole` 目录。 -配置文件必须是普通文件,文件和标准输入上限均为 256 KiB,CLI 不写回配置。 +配置文件必须是普通文件,文件和标准输入上限均为 8 MiB,CLI 不写回配置。 +正常运行时,`VOLE_OVERRIDE_EXTERNAL_CONTROLLER`、`VOLE_OVERRIDE_EXTERNAL_CONTROLLER_TLS`、 +`VOLE_OVERRIDE_EXTERNAL_CONTROLLER_UNIX`、`VOLE_OVERRIDE_SECRET` 提供 Controller 覆盖的环境默认。显式参数优先,显式空值清除该环境默认, +且不覆盖 YAML 原值;只更改具体的 Controller 配置,不提供通用字段覆盖。 +地址沿用 YAML 的 IP/localhost/空 host 与数字端口规则,不查询域名;密钥不会输出到日志或错误。 +`-ext-ctl-routing-mark` 在 CLI 按 Go flag 的平台 int/base-0 语法解析,支持符号、进制前缀和合法下划线; +非法显式数字参数返回 2,`-t` 也执行参数语法检查。Invoke 接收数值元数据;32 位 socket mark 范围由运行期统一校验。 +`VOLE_OVERRIDE_EXTERNAL_CONTROLLER_ROUTING_MARK` 是十进制环境默认,沿用 Mihomo 的 Atoi 返回值;缺失或语法错误使用 0,溢出返回平台饱和值后仍受 socket 范围校验; +显式 0 屏蔽环境默认但保留 YAML mark。此项只用于管理 TCP,不改变业务 routing-mark 或 Unix socket。 +`-t` 与 Mihomo 一致,只校验输入配置,不读取环境/应用运行期覆盖;`-h/-v` 不读取其环境值。 + +独立 HTTP 配置上限 1 MiB,使用 `-http-config ` 或 `--http-config=`。省略不探测文件;空路径与 `-` 失败,空文件关闭处理。相对 provider 文件从 HTTP 文件目录解析,缓存从数据目录解析。`-t` 只校验主文档/inline 内容,不读取外部 provider、下载或私密 CA;`-v/-h` 不读取附加文件。详细输入见 [HTTP 处理](http-processing.md)。 + +开启选择性 HTTPS 时,额外传 `-http-ca /private/ca.json`,格式为 `{"certificate":"...PEM...","privateKey":"...PEM..."}`。Unix 文件必须属于当前用户、关闭 group/other 权限(如 0600),且不能是符号链接。私钥不进入共享规则、命令行正文、普通快照或日志;宿主管理证书生成与客户端信任。 +客户端 certificate/private-key 可引用 PEM 文件,相对路径从 -d 的数据目录解析;-t 同样读取并校验身份,仍不初始化目录。 + +`VOLE_SAFE_PATHS` 可为主配置的 file provider 读取和 HTTP provider 缓存增加授权目录。 +使用系统路径列表分隔符(Unix 为 `:`、Windows 为 `;`),保留原生路径编码;相对目录从启动工作目录解析, +空项不授权当前目录。授权目录必须已经存在,初始化后固定,并由 provider 候选与恢复快照继承。 +文件读取不创建目录;只有 HTTP provider 缓存可在授权根内新建目录及写入正文/sidecar。 +路径中的 `..`、祖先或目标符号链接逃逸被拒绝。默认仍限 dataDir;file provider 在额外目录内不会回写 bundle 回退。 +本次主配置/独立 HTTP 文件保留为不可被缓存覆盖的输入,显式私密 CA 路径不授予 provider 读写。 +该环境只进入 provider 资源上下文,不改变主配置、客户端身份/私密 CA、独立 HTTP 设置或 Controller socket 的文件合同; +不修改全进程环境,也不提供任意目录写入授权。`-t/-h/-v` 不读取此环境值。 正常运行执行 `initialize → createInstance → start(configYaml)`,等待退出后 `stop → destroyInstance`。Unix 支持 SIGINT/SIGTERM,Windows 支持 Ctrl+C/Ctrl+Break; -退出等待内核清理完成。诊断写入有界 stderr,不包含配置正文或凭据。 +退出等待内核清理完成。日志写入 stderr,遵循配置的 `log-level`,完整保留已生成事件的 target、message 和所有字段;不按敏感字段脱敏、不截断内容,也不另设每秒条数限制。 +Unix 的 SIGHUP 通过同一候选工作器重载主配置:文件源重读该文件,Base64/标准输入源复用首次 +解密后的有效 YAML,不重读标准输入、环境默认、独立 HTTP 文件或私密 CA。文件 AGE 保留已解析身份, +不保留原始密钥文本。Controller 覆盖继续应用于候选;规则、节点与普通 provider 候选沿原热更新流程。 +坏候选、繁忙或超时保留当前会话;启动资源变化报告 reconnect required,SIGHUP 不自动停止或重连, +需宿主协调或重启前台进程。活动 HTTP payload/CA 继续固定,HTTP provider 待应用内容不会由 SIGHUP 应用。 +退出或引擎停止会取消并等待已开始的重载工作;宿主原 TUN fd 不被替换或关闭。 具体请求格式见 [Invoke API](invoke-api.md)。 ## TUN 与发布 @@ -39,7 +91,8 @@ Unix 用户目录取 `HOME`,Windows 取 `USERPROFILE`,缺失时使用工作 TUN 完全由 YAML 配置。Unix 的 `tun.file-descriptor > 0` 借用宿主 fd,内核只关闭自己的副本; 省略或为 0 时 Linux/macOS 可根据 `tun.device` 创建或打开原生 TUN。 Windows CLI 固定使用 Wintun,DLL 由宿主提供;Windows FFI 编译时可选择 Wintun 或 UWP。 -接口地址、DNS、系统路由及物理出口隔离由宿主管理,CLI 直接把配置交给内核。 +Linux 自建 TUN 的自动路由与 DNS 接管由 Core 执行,CLI 直接把配置交给内核。 +其他平台和借用 fd 的接口地址、DNS、系统路由及物理出口隔离仍由宿主管理。 平台接入见 [TUN](tun-platform.md) 和 [Windows VPN](windows-vpn.md)。 构建命令、tag 发布矩阵和归档内容统一见 [编译与发布](../scripts/README.md)。 diff --git a/docs/config.yaml b/docs/config.yaml index 51247e2..0feb89c 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -1,11 +1,14 @@ # Vole 配置的完整示例。 # -# 配置只接受本文列出的结构;未知或已移除的字段、anchor、alias 和自定义 tag 都会失败。 +# 配置只接受本文列出的结构;未知或已移除的字段和自定义 tag 都会失败。支持 anchor、alias 和 YAML merge。 +# 原始配置上限 8 MiB,展开预算 32 MiB,嵌套最多 64 层;递归 alias 失败。 # 顶层 port、socks-port、udp 和 listeners 都不接受;代理节点的 port / udp 仍有效。 # # 库宿主先通过 Invoke API 调用 initialize({dataDir}),再通过 configYaml 内联 # 传入配置;CLI 使用 -f 指定文件,-d 指定数据目录,省略时读取数据目录下的 config.yaml。 -# GeoData 固定由 Vole 管理在 dataDir/geodata 中;CLI 的 -t 只校验,不创建目录或联网。 +# GeoSite/DAT/ASN 由 Vole 管理在 dataDir/geodata 中;国家 MMDB 位于 dataDir 根目录。 +# CLI 的 -t 只校验,不创建目录或联网。 +# certificate/private-key 可为成对 PEM 或文件路径;相对路径从 dataDir 解析,校验时读取并验证。 # 所有平台均不设 GeoData 条数上限,不按数量截断,也不设总内存预算; # 仅加载当前业务规则和 DNS policy 引用的分类/属性交集,详见 docs/geodata.md。 @@ -14,45 +17,133 @@ # IPv6 包。dns.ipv6 只有与本项同时为 true 时才生效。 ipv6: true -# GeoData 下载配置可省略;出现时三项必须齐全。URL 必须是以域名为主机、无 -# userinfo/fragment 的绝对 HTTPS URL,更新周期固定为 24 小时。validateConfig 和 -# measureDelay 不下载。公共实例启动后,只有实际规则需要资产且自动更新已启用时 -# 才后台检查。下载使用最终 MATCH route target 在创建新物理 transport 时解析到的 -# 当前节点、DIRECT 或 REJECT;代理组不自动故障转移,也不回退 DIRECT。 +# mode 缺省 rule:顺序规则首条命中,未命中 DIRECT;global 使用 GLOBAL 组,direct 直连。 +# log-level 缺省 info,还支持 debug、warning、error、silent;两字段值不区分大小写。 +mode: rule +log-level: info + +# unified-delay 默认 false;true 时复用探测连接再次发送 HEAD,成功时记录第二次耗时。 +# profile.store-selected 默认 true,将 Controller 的显式选择保存到 dataDir/selected.json。 +# 启动时缓存中的有效选择优先于 default-selected;成员已删除时使用配置默认/首成员。 +# 文件损坏时使用配置;写入失败保留已生效选择,并在 Controller 状态中报告。 +unified-delay: false +find-process-mode: strict # strict 按实际身份谓词惰性查询;always 每业务决策一次;off 不查询 +# 物理 TCP 与 mixed 接入连接的探活。单位秒;0 使用 15 秒,负数保留对应 OS 值。 +# Android 始终禁用 TCP keep-alive;Windows 保留系统探测次数,其余平台为 9 次。 +# tcp-concurrent=true 同时尝试解析结果,首次成功取消其余尝试;ip-version 的偏好 +# 给首选地址族最多 300ms 优先窗口。已建立的复用连接保留创建时的设置。 +# IP4P 仅用于受控物理端点:2001:0000::/32 的 octets[10..12] 为网络序端口, +# octets[12..16] 为IPv4;普通地址保持。逻辑server/SNI与认证身份不改变。 +experimental: + dialer-ip4p-convert: false + # Quinn 的 Linux DIRECT 物理首跳:TUIC/XHTTP H3/DoQ/DoH H3。 + # GSO 开启时依 OS 能力每次最多8段;ECN 缺省关闭,不改变进程环境。 + quic-go-disable-gso: false + quic-go-disable-ecn: true +# 开启时转换物理TCP和SS2022/SOCKS5/WireGuard UDP节点端点;不转换DIRECT无连接UDP或DNS答复。 + +tcp-concurrent: false +keep-alive-idle: 0 +keep-alive-interval: 0 +disable-keep-alive: false +# 物理 TCP/UDP 的默认出口;空接口/零 mark 使用平台默认或 TUN 自动识别。 +# 节点的非空 interface-name / 非零 routing-mark 分别覆盖全局值,空/0 继续继承。 +# 接口支持 Linux、Apple 和 Windows,名称在建 socket 时校验;失败不回退其他出口。 +# 回环物理目标保留本机路径,不应用公开接口/mark;UWP 回环目标的现有安全门禁不变。 +# Android 显式接口绑定尚不可用,保留原 protect;非零 mark 仅 Linux 可用。 +# Linux 自建 TUN auto-route 仅捕获未标记流量;物理首跳保留有效的非零公开 mark, +# 未配置 mark 时注入私有防环值。全局/节点与动态 Provider 使用同一继承与 socket 策略。 +# UWP 仅允许选择 Provider 当前物理适配器,不改变其源地址/接口索引;Wintun 不继承该绑定。 +interface-name: "" +routing-mark: 0 +profile: + store-selected: true + store-fake-ip: false # true:dataDir/fake-ip.redb 持久化双向映射与分配位置 + +# 资源下载使用 global-ua(缺省 Vole/ 加 Core 版本);etag-support 缺省 true。 +# geodata-mode 默认 false,使用国家 MMDB;true 使用 GeoIP DAT。此示例显式选择 DAT。 +# URL 缺省 MetaCubeX meta-rules-dat latest DAT / 国家 MMDB / ASN MMDB。 +# URL 以域名为主机、无 fragment,生产构建使用 HTTPS;userinfo 用于 HTTP Basic。 +# HTTP 资源自动流式解压 gzip,状态只保存来源摘要与脱敏 origin。 +# 自动更新默认 false;启动后仍获取缺失的必需资产。周期单位小时,默认 24,0 关闭周期检查。 +global-ua: "Vole/0.1.0" +etag-support: true +geodata-mode: true geox-url: geoip: https://assets.example.com/geoip.dat + mmdb: https://assets.example.com/geoip.metadb geosite: https://assets.example.com/geosite.dat + asn: https://assets.example.com/ASN.mmdb geo-auto-update: true geo-update-interval: 24 +# 客户端共享信任根:内置 WebPKI + 启动时可读系统根 + 此处证书内容。 +# 字段沿用 Mihomo 的真实拼写 custom-certifactes;内容为 PEM(或可表示的 DER),不是路径。 +# 不设置本项仍使用基础信任;pure validate 不读取系统根。平台读取限制见 tls-client-fingerprint.md。 +# tls: +# custom-certifactes: +# - |- +# -----BEGIN CERTIFICATE----- +# +# -----END CERTIFICATE----- +# 同一 tls 对象的 certificate/private-key 仅用于 HTTPS Controller,可用配对 PEM 或 dataDir 文件; +# 均空时生成随机 RSA 2048 自签证书。内联固定,文件证书对热更新失败保留旧值。 +# client-auth-type: request/require-any/verify-if-given/require-and-verify,其它字符串为默认不请求证书; +# 非空 client-auth-cert 在默认模式时提升为 require-and-verify,CA 内容/文件仅启动加载。 +# 上述服务端字段不改变 custom-certifactes 的客户端根库,也不用于独立 measureDelay 配置。 + # 可选的 HTTP/SOCKS5 混合入口;mixed-port 省略或 0 时关闭,1–65535 时启用。 # TCP 在同端口接入 HTTP 转发 / CONNECT / Upgrade 和 SOCKS5 CONNECT / UDP ASSOCIATE。 # SOCKS5 UDP 随入口启用并绑定同端口,不提供独立入站 UDP 开关或 listeners 配置。 # 构建必须同时包含 inbound-http 和 inbound-socks5;缺少任一 feature 则拒绝启用入口。 # allow-lan 默认 false,绑定回环;true 时绑定通配地址,与 authentication 独立。 # ipv6: true 时同时绑定同端口 IPv6(v6-only);false 时只监听 IPv4。 -# authentication 省略或 [] 表示任何绑定地址均免认证;非空时必须恰好包含一项 user:password, -# 按首个冒号分隔,用户名 / 密码各 1–255 UTF-8 字节,HTTP/SOCKS5 共用。 -# 共享或非空认证要求 mixed-port 启用。 +# authentication 省略、null 或 [] 表示任何绑定地址均免认证;非空支持多个 user:password, +# 按首个冒号分隔,不 trim,允许空用户名或密码。HTTP 受 32 KiB 总头预算约束;SOCKS5 各字段最多 255 字节。 +# mixed-port 关闭时可以保留合法的 allow-lan 和 authentication,供之后启用入口使用。 # HTTP 逐请求认证、选路,支持流式正文 / 分块、Keep-Alive、CONNECT 与 Upgrade。 # 共享只适合可信网络,Basic 不加密链路;详细边界见 docs/inbounds.md#http。 # UDP 必须属于有效的 TCP 控制连接,源 IP/端口校验和资源上限见 inbounds.md#socks5。 mixed-port: 1080 allow-lan: false +bind-address: "*" # allow-lan=true 时可指定单个 IP;localhost 始终绑定回环 +lan-allowed-ips: [0.0.0.0/0, "::/0"] +lan-disallowed-ips: [] # 拒绝优先 +skip-auth-prefixes: [] # 仅跳过认证,仍执行来源访问控制 authentication: - "probe-user:probe-password" -# 可选的运行时 Controller,地址必须是回环 IP 和显式非零端口。它可服务于启用 -# TUN 的配置或含 proxy-groups 的配置;运行配置仍需开启 mixed-port 或 TUN。 -# 只要 Controller 管理 proxy-groups,secret 就必填;仅有 TUN 流量接口时可省略。 -# secret 出现时必须为 1–255 UTF-8 字节,并作为全部 Controller 路由的 Bearer token。 +# 可选的运行时 Controller,接受 IP:port、localhost:port 或 :port;端口 0 由系统分配。 +# 静态节点、纯 DIRECT 及未启用代理入口的配置也可使用。 +# secret 省略或空串时免认证;非空时保护 Controller 业务路由,CORS 预检先于认证。 +# external-controller-tls 可单独启用,或与 HTTP 同时启用;HTTPS 实际提供 H2/H1,明文仅 H1。 +# CORS origins 缺省/[]/含 * 时允许所有来源;精确/单 * 匹配忽略大小写,无匹配不返回允许头。 +# 允许 GET/POST/PUT/PATCH/DELETE 与 OPTIONS;Content-Type/Authorization/Origin,max-age 300, +# 不允许 credentials;allow-private-network 缺省 true,只在成功预检且请求 true 时返回允许头。 # Controller 提供 GET /traffic、GET /group、GET /group/{name}、 -# GET /proxies/{name} 和 PUT /proxies/{name},不属于 Invoke API。 +# GET /proxies/{name} 和 PUT /proxies/{name};另含连接查询/关闭、有效配置、规则 +# 与有界原始 DNS 记录查询、节点/组 delay、Provider healthcheck、实际进程内存; +# 完整接口见 controller-api.md;不属于 Invoke API。 external-controller: 127.0.0.1:9090 +# external-controller-tls: 127.0.0.1:9443 +# Linux 本地 HTTP/1 / WebSocket 管理入口;空串关闭,相对路径从 dataDir 解析。 +# 父目录须已存在;0600 在 listen 前设置,已有文件/目录/链接/socket 均不接管。 +# 本地入口依赖文件权限,不要求 secret;并行网络 HTTP/HTTPS 仍使用原 Bearer。 +external-controller-unix: "" +# 仅 Linux 管理 HTTP/HTTPS TCP listener 及已接受 socket 使用 SO_MARK;0 不设置。 +# 与业务 routing-mark 独立,不用于 Unix socket;非零值在其它系统启动网络入口时不支持。 +external-controller-routing-mark: 0 +external-controller-cors: + allow-origins: ["*"] + allow-private-network: true secret: "vole-runtime-secret" -# TUN 使用 Mihomo 同名字段;未实现的 auto-route、auto-redirect、 -# auto-detect-interface、地址/路由、stack、offload 等字段仍严格拒绝。 +# TUN 使用 Mihomo 同名字段;auto-route/auto-detect-interface 仅供 Linux 自建 TUN。 +# Linux 缺省均为 true,其他平台缺省 false;借用 fd 的网络配置始终由宿主负责。 +# Linux 自建 auto-route 消费 inet6-address、route-address/route-exclude-address、 +# iproute2-table-index/iproute2-rule-index、strict-route、输入接口和 UID/端口排除。 +# 借用 fd、Apple/Android/Windows 仍由宿主应用这些网络设置;改动需要宿主重配/重连。 +# auto-redirect、stack、offload 等字段仍严格拒绝。 # file-descriptor 缺省或 0 时由 core 打开本机平台 TUN;正数为当前进程已有 fd。 # Unix 借用 fd 必须已 nonblocking,核心仅复制并关闭副本;帧格式由平台固定处理。 # Linux / macOS 支持原生打开;Android / iOS / tvOS 仍要求宿主提供 fd。 @@ -67,8 +158,34 @@ secret: "vole-runtime-secret" # dns-hijack 缺省 [0.0.0.0:53];[] 关闭。只在 dns.enable:true 的 TUN TCP/UDP 上生效。 # 精确 IP:port 匹配指定目标;any/未指定地址匹配任何地址族的 53 端口。 # udp-timeout 单位秒,缺省或 0 为 300;控制源关联及各目的流的空闲失效。 -# 配置必须启用 mixed-port 或 TUN。 +# Linux 专属 ICMP:真实 Echo 走共享 Dialer 的 DIRECT;真实 TUN 前缀、启用的 fake-IP 池或 +# disable-icmp-forwarding:true 使用本地合成,不代表真实 RTT。其他平台拒绝这两个字段。 +# icmp-timeout 为非负 int64 秒,缺省/0 为 10;超出本机单调时钟范围明确拒绝。 +# 运行组须由安装器/宿主在出口 namespace 预先授权 ping_group_range;Core 不改 sysctl、不提权、无 IP raw fallback。 +# 最多两个物理 ping socket、64 个许可、入/出各 8 项,真实包限 min(mtu,2048);Stop 清理全部所有者。 +# 这些值变化须显式重连;详细合同见 tun-icmp-dns.md 与 tun-platform.md。 +# mixed-port 与 TUN 均关闭时仍可运行 Controller 和资源任务。 tun: + # icmp-timeout: 10 # 仅 Linux;0 同缺省,其他平台不要填写 + # disable-icmp-forwarding: false # 仅 Linux;true 保留本地合成回应 + # auto-route: true # Linux:自动路由;DNS 启用且劫持网关时接管 systemd-resolved + # auto-detect-interface: true # Linux:出站自动绑定物理接口 + # inet6-address: ["fdfe:dcba:9876::1/126"] # 顶层 ipv6 关闭时不添加;[] 关闭 IPv6 捕获 + # route-address: [] # 各地址族省略时捕获其默认网段 + # route-exclude-address: ["192.168.0.0/16", "fc00::/7"] + # iproute2-table-index: 2022 # 0 使用缺省值;不得占用系统表 + # iproute2-rule-index: 9000 # 0 使用缺省值;本会话使用该值到 +8 + # strict-route: false # 自动路由时,阻止所选输入绕过未捕获的 IPv6 地址族 + # include-interface: [lo, veth-client] # Linux ingress接口;本机output为lo;非空时忽略exclude-interface + # exclude-interface: [veth-bypass] # 仅include为空时使用;两者均空保持仅本机捕获 + # include-uid: [1001] # Linux 本机应用捕获范围;不解析 Android 包名 + # include-uid-range: ["1001:1010"] + # exclude-uid: [1005] # exclude 优先于 include + # exclude-uid-range: ["2000:2010"] + # exclude-src-port: [5353] + # exclude-src-port-range: ["30000:30010"] + # exclude-dst-port: [22] + # exclude-dst-port-range: ["6000:6010"] enable: true device: "" file-descriptor: 0 @@ -77,49 +194,58 @@ tun: - any:53 udp-timeout: 300 -# 可选的 TUN TCP/UDP 嗅探器。省略或 enable: false 表示关闭。启用时至少配置 -# HTTP、TLS、QUIC 之一;协议值为空或省略 ports 时分别使用 80/443/443。每种协议 -# 最多 64 个单端口或升序闭区间;HTTP 与 TLS 的 TCP 端口不能重叠,QUIC 使用 -# UDP,可与二者使用相同端口。嗅探结果只参与选路,不改写实际目标。 +# TCP 嗅探用于 TUN 和 mixed;QUIC 多包嗅探用于 TUN UDP。 +# 省略或 enable: false 关闭;未列出协议时不嗅探。协议 ports 省略/为空表示全部端口。 +# HTTP/TLS 重叠端口共享有界预读,按 TLS、HTTP 顺序识别;每协议最多 64 个端口/区间。 sniffer: enable: true + force-dns-mapping: true + parse-pure-ip: true + force-domain: [] # Clash 通配、geosite:、rule-set:;可强制嗅探已知域名/fake-IP + skip-domain: [] # 对识别出的域名生效 + skip-src-address: [] # CIDR、geoip:、rule-set: + skip-dst-address: [] + override-destination: true # false 只提供选路域名;true 同时替换实际目标 sniff: HTTP: ports: [80, 8080-8380] + override-destination: false # 协议项覆盖全局值 TLS: ports: [443, 8443] QUIC: ports: [443, 8443] -# 至少配置一个具体代理节点。节点和代理组定义名共享同一大小写敏感的命名空间, -# 引用精确匹配且不做 Unicode normalization 或大小写折叠。名称必须为 1–64 UTF-8 -# 字节,首尾不能是任何 Unicode 空白字符,不能含控制字符,也不能包含逗号、井号、 -# 斜杠、问号、`&`、`=`、`%`、反斜杠,或使用 `.`、`..`、`DIRECT`、`REJECT`、 -# `RULES`;内部普通空格、CJK 和 emoji 可用。dialer-proxy 可引用具体节点或 select -# 组。所有节点上游和全部组成员(包括未选成员)组成同一张无环图;未知引用、自引用 -# 和混合环均失败。代理数量和链深没有独立上限,仍受 YAML 256 KiB 和名称唯一约束。 -# 节点没有上游,或上游组经纯组成员链能到达 DIRECT 时,prepare 预解析该节点及其 -# 独立下载端点,即使 DIRECT 尚未选中;纯代理路径保留逻辑域名。候选首跳 DNS 失败 -# 则 prepare 失败,切换 DIRECT 不临时调用系统 DNS。物理首跳连接本机服务时,server 必须使用 127.0.0.0/8 -# 范围内的 IPv4 字面量或 ::1;域名解析到回环地址会失败关闭。 +# proxies 可省略或设为 [],允许仅使用 DIRECT、REJECT 或只包含内置动作的代理组。 +# VLESS/VMess uuid 支持标准、紧凑、花括号和 URN;其他字符串按 UUIDv5(nil, 原始UTF-8)映射。 +# 引用的节点和代理组必须存在。节点和代理组定义名共享同一大小写敏感的命名空间, +# 引用精确匹配且不做 Unicode normalization 或大小写折叠。名称必须为非空字符串; +# DIRECT、REJECT、REJECT-DROP、COMPATIBLE、PASS、PASS-RULE 为保留定义名。 +# 路径与 URL 中的保留字符由调用方按相应协议编码,不限制节点本身的名称。 +# dialer-proxy 可指定 DIRECT、COMPATIBLE 或引用具体节点、策略组。全部上游和组成员(包括未选成员)须无环。 +# 代理数量和链深没有独立上限,仍受 YAML 大小与名称唯一约束。 +# 实际建立物理首跳时才通过节点 resolver 解析主端点和独立下载端点。 +# 未选节点的 DNS 失败不阻塞准备;DNS 关闭时才使用系统 resolver。 +# DNS 开启时可通过 hosts 配置本机端点;关闭 DNS 或独立测速的系统 resolver +# 仍拒绝解析到回环的服务器域名,本机服务器可使用 IP 字面量。 # # VLESS 子集: # - type 固定 vless,network 默认 tcp,支持 tcp/ws/grpc/http/h2/xhttp,tls 默认 false; # TCP 支持明文或 TLS 1.2/1.3;XHTTP H1/H2 支持明文、TLS1.3 或 REALITY。 # - encryption 省略/空/none,或 mlkem768x25519plus.{native,xorpub,random}.{1rtt,0rtt}.…; # key/padding 链、上限和恢复规则见 vless.md;该层独立于外层 TLS/传输。 -# flow 省略/空/xtls-rprx-vision;Vision 仅 TCP + TLS1.3/REALITY 或 Encryption, -# packet-encoding 必须 xudp(即使 udp=false); +# flow 省略/null/空串/xtls-rprx-vision;Vision 仅 TCP + TLS1.3/REALITY 或 Encryption, +# packet-encoding 省略、xudp 或 none 均使用 XUDP;packetaddr 不可与 Vision 组合(即使 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 解析。 +# - packet-encoding 省略、xudp 或 none 均使用 XUDP;packetaddr/packet 为 IP-only +# 地址帧;packetaddr 域名通过受控 DNS 解析。 +# - REALITY 不用于 WS/HTTPUpgrade;该组合在配置期拒绝。 # - TLS 省略 reality-opts;REALITY 必须提供 reality-opts.public-key,可选 short-id; -# support-x25519mlkem768 默认 false;true 强制真实混合协商、不回退经典组。 -# true 只允许 client-fingerprint chrome 或未命名(省略/none/空串);不改写模板。 +# support-x25519mlkem768 默认 false;true 允许同次握手协商真实混合或经典组。 +# chrome 和未命名模板提供混合/X25519 双 share;其余 TLS1.3 模板保留真实经典 share。 # - xhttp-opts 请求字段与值域详见 docs/xhttp.md;mode 只接受 # auto/packet-up/stream-up/stream-one;stream-one 不能与 download-settings 同用; # - download-settings 一旦出现(包括 {}),就为下行 GET 创建独立的 @@ -127,7 +253,7 @@ sniffer: # XHTTP session ID,不进行第二次 VLESS 握手。两条请求最终必须由 CDN 或 # 反向代理汇聚到同一个服务端 XHTTP 处理器和会话存储。 # - download-settings 缺省叶字段继承;headers/reuse-settings/reality-opts/jls-opts/ech-opts 整对象 -# 替换,{} 分别清空请求头、重置复用默认、清除 REALITY/JLS/ECH。false/空串不等同缺省; +# 替换,{} 分别清空请求头、重置复用默认、清除 REALITY/JLS/ShadowTLS/ECH。false/空串不等同缺省; # certificate/private-key 成对替换或同时空串清除。改变安全类型须显式清除冲突 # 策略。两个独立 listener 不能组成共享会话。 # auto 为 TLS -> packet-up、REALITY + @@ -136,26 +262,158 @@ sniffer: # 同时关闭下载腿,不提供 TCP 半关闭后继续读取尾包的保证;packet-up 的待完成 # 上传最多等待 1 秒。需要完整响应的应用须在关闭前读完响应。 # - 标准 TLS 支持 skip-cert-verify/fingerprint/name-cert-verify,以及配对的 -# 内联 PEM certificate/private-key;不能混用 REALITY。完整策略见 vless.md。 +# 内联 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;节点缺省关闭。 +# gRPC 与 Trojan/VMess 共用 UA、空闲 PING、max-connections/min-streams/max-streams 池阈值。 +# - 公共节点 smux 支持 h2mux/smux/yamux、padding/only-tcp 与池阈值;与 Vision 互斥。 +# - random 在进程首次按 Chrome/Safari/iOS/Firefox = 6:3:2:1 选定后稳定复用。 +# - client-fingerprint 接受 none/chrome/firefox/safari/ios/android/edge/"360"/qq/random; +# chrome=Chrome133,firefox=Firefox120,safari=Safari16.0;android/360只有TLS1.2能力;节点缺省关闭。 # 适用于标准 TCP TLS/REALITY,不改变证书 fingerprint;下载腿缺省继承、 # 显式名称覆盖、none/空串清除,转 H3/明文必须清除指纹。 -# 非空 ALPS 服务端设置暂拒绝;边界见 docs/tls-client-fingerprint.md。 +# 不声明TLS ALPS,H2沿标准SETTINGS/ACK;意外协商的非空设置仍拒绝,见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。 +# - ech-opts 的静态/动态来源只用于标准 TLS,详见下方示意与 docs/vless.md#ech。 +# query-server-name 只覆盖动态 HTTPS 记录查询名,不改变拨号、SNI 或证书验证名。 +# shadow-tls-opts 必须 {version: 3, password: 非空密码},与 REALITY/JLS/ECH、Vision 互斥; +# 使用既有 servername/SNI 与证书策略,只用于 TCP 承载和 XHTTP H1/H2。 # # 下方 vless-edge 通过 upstream-select 当前选择的 socks-hop 建链: # 客户端 -> socks-hop -> vless-edge -> 目标。 +# 普通协议节点和具名 direct 可声明 tfo: false(默认)。Linux 单个可用物理 TCP 地址启用 +# TCP Fast Open;多地址沿普通 eager TCP 选择,其他系统沿普通受控 TCP。原生 UDP 不受影响。 +# 仅真实物理首跳消费;经代理使用上游节点自己的 tfo,组落到 DIRECT 时使用当前节点 tfo。 +# 普通协议节点和具名 direct 可声明 mptcp: false(默认,非 null 布尔值)。仅 Linux +# 无 socket protector、显式/自动接口、专用 DNS binding 或源 IP 约束的物理 TCP 路径尝试 MPTCP。 +# 系统路径管理器与额外路径由宿主管理,Core 不配置接口/子流;routing-mark 仍沿现有防环策略。 +# 受约束路径、其他系统或本机/对端不支持时使用普通受控 TCP,不绕过保护或绑定失败。 +# 代理链只消费实际首跳自己的值,组落到 DIRECT 时使用当前节点值;原生 UDP/QUIC 不受影响。 +# 普通协议节点和具名 direct 支持 ip-version: dual/ipv4/ipv6/ipv4-prefer/ipv6-prefer;默认 dual。 +# 普通协议节点和具名 direct 可声明 interface-name / routing-mark,仅物理首跳消费;经 dialer-proxy 时 +# 使用真正上游节点的出口策略,组落到 DIRECT 时保留当前节点策略。XHTTP 两腿使用相同策略。 +# 普通协议节点支持 smux 包装,要求对端提供 sing-mux 解码;普通 HTTP/SOCKS listener 不自动解码。 +# only-tcp: false 提供 mux UDP,即使底层 udp 为 false;true 保留底层 UDP 能力与编码。 +# smux.brutal-opts 启用后,每条新会话先与支持 TCP Brutal 的对端协商;远端下行协商不限定客户端平台。 +# Linux 本地上行只在可确认独占的直连物理 TCP 上尝试;代理 hop、共享/多腿传输与 mptcp:true 不设置本地算法。 +# Core 不安装/加载模块;本地不可用保留有效远端协商。池保持单会话,退役等待旧流释放;详见 xhttp.md。 +# HTTP(S) CONNECT 示例:type: http,必填 name/server/port,tls 默认 false。 +# username/password 均非空才认证;headers 为字符串映射;仅 TCP,不支持 udp: true。 +# tls: true 时支持 sni/skip-cert-verify/name-cert-verify/fingerprint, +# 以及成对的 certificate/private-key(PEM 或文件路径)。支持 dialer-proxy;详见 outbounds.md。 +# TrustTunnel 支持 H2/H3 CONNECT TCP/UDP,type: trusttunnel;username/password 默认空串, +# 始终发送 Basic 认证,不要求非空。quic 默认 false(TLS+H2),true 使用 QUIC/TLS1.3+H3。 +# sni 缺省/空串使用 server;alpn 缺省/[] 随模式使用 [h2]/[h3],自定义列表须包含该值, +# H3 实际协商必须为 h3;H2 接受 h2,或在认证后无 ALPN 时使用 H2 prior knowledge;明确协商其他协议拒绝。 +# 两模式共享证书策略、mTLS、ech-opts;client-fingerprint 仅 H2 消费, +# H3 将该字符串视为 no-op,不校验模板名。H3 外层始终需要双向至少1200字节UDP路径。 +# 逻辑客户端池 max-connections/min-streams/max-streams 全为 0 时采用 8/5/0; +# 优先复用流数最少的客户端,max-connections > 0 时,达到客户端数上限或 +# 当前流数 < min-streams 即复用;max-connections <= 0 时,按 +# max-streams > 0 且当前流数 < max-streams 复用;未满足条件则增加客户端。 +# 零活跃流始终复用,混合填写不报互斥,阈值不限制业务总连接数。物理连接空闲30秒关闭。 +# udp: true 启用 _udp2 多目标数据报;health-check: true 启用 _check 协议探测, +# 成功CONNECT响应重置7秒计时,单次探测期限7秒;二者缺省false。UDP业务在两模式均走可靠流。 +# congestion-controller 仅H3消费:cubic/new_reno 使用 quiche 原生 CUBIC/Reno; +# bbr/bbr_meta_v1/bbr_meta_v2 均映射到 quiche 原生 Bbr2Gcongestion,省略/未知使用 cubic。 +# H2 不消费 congestion-controller。 proxies: - # TUIC v5:固定 QUIC/TLS1.3;必填 UUID 和原样 password(可空,最多 65535 UTF-8 字节)。 - # 不接受 v4、token、0-RTT、client-fingerprint、mTLS、ECH 或其他 TLS 后端选项。 + # 功能出站没有 server/port;不接受 udp、smux 或 dialer-proxy。 + # direct 另接受 interface-name/routing-mark/ip-version/tfo/mptcp,域名使用 direct-nameserver。 + - {name: direct-local, type: direct} + # dns 仅接受 name/type,复用当前会话解析器,可由普通规则接管任意端口的 TCP/UDP DNS。 + - {name: dns-local, type: dns} + # reject 仅接受 name/type;立即结束,不增加 action 字段。 + - {name: reject-local, type: reject} + # rematch 是规则控制,不建立连接。两个 target 至少出现一个,省略保留原值; + # target-rematch-name: '' 清标记;target-sub-rule: '' 或未知名称回主规则。 + # 已存在的空子列表耗尽后 DIRECT;省略 target-sub-rule 保留当前根列表。 + # 仅业务 rule 模式和 DNS #RULES 消费,不能用于 node-only 测速或物理上游。 + - {name: mark-blue, type: rematch, target-rematch-name: blue} + - name: trusttunnel-edge + type: trusttunnel + server: trusttunnel.example.com + port: 443 + quic: false # true 改为H3,其余业务配置不变 + username: synthetic-user + password: synthetic-password + udp: true + health-check: true + + # Mieru:transport 必填 TCP 或 UDP;二者均承载 TCP 和 udp: true 的 UDP Associate。 + # username/password 各为 1–64 UTF-8 字节,保留原值。port 与非空 port-range 互斥, + # 范围如 "24810-24812",每次新物理连接随机选择包含两端的端口。 + # multiplexing 为 MULTIPLEXING_DEFAULT/OFF/LOW/MIDDLE/HIGH,默认 LOW; + # handshake-mode 为 HANDSHAKE_DEFAULT/STANDARD/NO_WAIT,默认 STANDARD。 + # 枚举须携带上述完整前缀。UDP 底层提供 ACK/重传/排序/窗口,不是裸 UDP 转发。 + # traffic-pattern:可选的标准 Base64 Mieru protobuf;省略/空值使用协议默认策略。 + - name: mieru-edge + type: mieru + server: mieru.example.com + port: 24810 + transport: TCP + username: synthetic-user + password: synthetic-password + udp: true + multiplexing: MULTIPLEXING_LOW + handshake-mode: HANDSHAKE_STANDARD + + # MASQUE L4:标准Base64 SEC1 ECDSA私钥与SPKI服务端公钥;独立客户端证书。 + # 仅TCP,udp: true仍关闭基础UDP;SNI缺省为consumer-masque-proxy.cloudflareclient.com。 + # ip/ipv6/uri/mtu及正handshake-timeout在L4不消费;负超时拒绝。 + # name-cert-verify占位不消费;skip-cert-verify关闭公钥固定但仍验证握手签名。 + # - name: masque-l4 + # type: masque + # server: masque.example.com + # port: 443 + # network: h3-l4proxy + # # IP tunnel: network: quic 或 h2, ip: 192.0.2.2, ipv6: 2001:db8::2, udp: true + # # IP mode MTU defaults to 1280; IPv4-only permits 68..65535. + # # uri: https://cloudflareaccess.com # IP tunnel CONNECT authority + # # handshake-timeout: 5 # 整数秒;0 沿创建调用期限;正值由节点持有 + # remote-dns-resolve: false # true 且 dns 非空时经本节点解析业务域名 + # dns: [] # IP 模式支持 UDP/TCP 等;L4 需要 tcp:// 或其它 TCP DNS 传输 + # private-key: "" + # public-key: "" + # congestion-controller: bbr # 同 TrustTunnel H3 的算法映射 + # ip-stack: {mode: auto, congestion-controller: cubic} + + # 标准 WireGuard:用户态内层 TCP/UDP,不创建系统接口/路由/NAT。 + # 密钥为公开测试值,不可用于真实连接。外层始终需要双向 UDP;业务 udp 默认 false。 + # ip/ipv6 接受 IP 或 CIDR,按实际地址族选择,每族最多一个;至少配置一个。 + # MTU 缺省/0 为 1408,范围 1280–65503;实际上游双向预算须至少 MTU+32。 + # 与 Mihomo 一致,单 peer 顶层 allowed-ips 接受但忽略。完整行为见 outbounds.md#wireguard。 + - name: wireguard-edge + type: wireguard + server: wireguard.example.com + port: 51820 + ip: 10.84.0.2/24 + # ipv6: fd84::2/64 + private-key: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=" + public-key: "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=" + # pre-shared-key: "AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM=" + mtu: 1408 + persistent-keepalive: 0 # uint16 秒;0 不定期保活 + allowed-ips: [] # 无 peers 时忽略;各本地地址族使用全地址路由 + reserved: [] # 空、三字节数组或标准 Base64 字符串(如 "U4An");peer 缺省继承顶层值 + remote-dns-resolve: false # true 且 dns 非空时,业务域名通过本节点查询 + # 支持 Core DNS 上游;fragment 出口 token 忽略,键值参数保留且只解码一次。 + # 不继承全局 prefer-h3,HTTPS h3=true 仍生效;上游域名仍由 Core 代理 DNS bootstrap。 + dns: [] + refresh-server-ip-interval: 0 # 秒;0 不更新,新业务触发到期检查 + # peers 非空时忽略顶层 server/port/public-key;每项 allowed-ips 必须非空。 + # peers: + # - server: wireguard.example.com + # port: 51820 + # public-key: "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=" + # allowed-ips: ["0.0.0.0/0"] + # reserved: [0, 0, 0] + udp: true + # dialer-proxy: upstream-select # 上游必须提供足够的双向 UDP 预算 + + # TUIC v5:固定 QUIC/TLS1.3;UUID 必填,password 缺省为空,原样使用(最多 65535 UTF-8 字节)。 + # 支持成对 PEM/文件 certificate/private-key 客户端身份;不接受 v4、token、0-RTT、client-fingerprint、ECH。 - name: tuic-edge type: tuic server: tuic.example.com @@ -167,12 +425,28 @@ proxies: skip-cert-verify: false # fingerprint: "" # name-cert-verify: verified.example.com + # certificate/private-key: 成对内联 PEM 或文件路径;不改变服务端信任根 + # 文件对由既有资源工作器检查并用于新物理连接;失败保旧,边界见 docs/tls-client-fingerprint.md congestion-controller: cubic # cubic(默认)/new_reno/bbr - udp: true # 缺省 false;只控制业务 UDP,QUIC 首跳始终需要数据报 + disable-sni: false # true只关闭SNI,证书名称/pin验证保持 + udp: true # 可省略或设 false,均保留业务 UDP;组 disable-udp 仍生效 udp-relay-mode: native # native(默认)/quic + # ip: 192.0.2.1 # 仅覆盖物理入口,server/SNI 保留 + # heartbeat-interval: 10000 # 毫秒;<=0 回落 10000,同时控制 keepalive/HEARTBEAT + # recv-window-conn: 0 # 实际为 stream 窗口:缺省/0 初始1.5MiB/上限15MiB;正数固定 + # recv-window: 0 # 实际为 connection 窗口:缺省/0 初始2MiB/上限64MiB;正数固定 + # 固定 Middle 策略下,缺省实际 stream4MiB/connection2MiB,不预分配全部信用。 + # max-open-streams: 100 # 0 同默认;参考阈值100=>90,实际同时所有者默认89,正N!=100为N-1 + # max-udp-relay-packet-size: 1252 # 仅在缺省 frame 上限时用于计算 frame=该值+29 + # max-datagram-frame-size: 1281 # 完整 QUIC DATAGRAM frame,参考限制到1400;与累计256KiB缓冲独立 + # disable-mtu-discovery: false # 发现受实际上游双向预算限制 + # udp-over-stream: false # QUIC可靠流承载业务UDP;QUIC物理入口仍须数据报 + # udp-over-stream-version: 2 # 省略同2;仅接受整数2 + # dialer-proxy: upstream-select # 所选上游必须实际支持双向至少 1200 字节数据报 - # Hysteria2:固定 QUIC/TLS1.3,外层总是需要 UDP;业务 UDP 默认关闭。 + # Hysteria2:固定 QUIC/TLS1.3,外层总是需要 UDP;业务 UDP 始终可用。 + # udp 可省略或填 true/false,均不禁用业务 UDP;组 disable-udp 仍生效。 # 完整字段/预算及开发验收边界见 outbounds.md#hysteria2。标准 TLS 证书策略与其他协议共用。 - name: hysteria2-edge type: hysteria2 @@ -182,16 +456,38 @@ proxies: sni: hysteria2.example.com alpn: [h3] # 缺省或 [] 都归一为 h3 skip-cert-verify: false + # name-cert-verify: verified.example.com # 独立证书验证名,不改变 SNI # fingerprint: "" - # certificate/private-key: 成对内联 PEM 客户端身份 + # 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 + udp-mtu: 1400 # 单个协议分片预算;0 同默认,范围 64–65535 + # initial-stream-receive-window: 65536 # byte,0/省略为64KiB + # max-stream-receive-window: 65536 # 固定信用=max(initial,min(maximum,4MiB)),不预分配缓冲 + # initial-connection-receive-window: 4194304 # 固定连接信用;0/省略为4MiB,可显式增大 + # max-connection-receive-window: 4194304 # 接受独立上限;Middle策略仍使用连接initial,不自适应增长 + # handshake-timeout: 0 # 秒;正值由节点独立持有共享握手,各调用保留自己的期限 + + # obfs: gecko # 或 salamander + # obfs-min-packet-size: 512 # 仅 Gecko,正值且 min≤max≤2048 + # obfs-max-packet-size: 1200 # 填充目标,仍受真实物理UDP预算限制 # obfs-password: "independent-synthetic-password" - # ports: "443,8443-8445" # 非空集合控制初始及后续入口;出现时 port 可省略 - # hop-interval: "5-7" # 仅与 ports 配套,默认 30 秒,最小 5 秒 + # realm-opts: # enable true 时不能启用 ports/hop-interval + # enable: true + # server-url: https://realm.example.com # HTTP(S) base,无userinfo/query/fragment + # token: synthetic-token + # realm-id: synthetic-zone + # stun-servers: [stun.example.com:3478] + # sni: realm.example.com # 以下仅HTTPS控制面,独立于业务QUIC身份 + # alpn: [h2, http/1.1] + # skip-cert-verify: false + # name-cert-verify: realm.example.com + # fingerprint: "" + # certificate: ./client.crt # 与private-key成对,或内联PEM + # private-key: ./client.key + # ports: "443/8443-8445" # 逗号或斜线;有效集合控制初始/后续入口,port可省略或0 + # hop-interval: "5-7" # 随 ports;0使用30秒,正数小于5归一为5,范围归一 # dialer-proxy: upstream-select # VMess AEAD:alterId 仅 0;cipher 默认 auto(实际 AES 硬件检测),还支持 @@ -200,7 +496,12 @@ proxies: # network 默认 tcp,支持 tcp/ws/grpc/http/h2。tls 默认 false;关闭时 # 禁止 servername/alpn/fingerprint/skip-cert-verify 等 TLS 专属字段。 # servername 优先显式值,其次 WS Host 去端口,否则 server。 + # TLS 支持 name-cert-verify 和成对的内联 PEM 或文件路径 certificate/private-key; + # 验证名不改变 servername,客户端身份不增加服务端信任根。 # ws-opts/grpc-opts 与下面 Trojan 的结构相同;HTTP/H2 选项见示例注释。 + # tls: true 时可选 shadow-tls-opts: {version: 3, password: cover-password} 或 + # jls-opts: {username: user, password: password};身份单选。REALITY 仅 TCP/HTTP/H2/gRPC, + # 必须有效 client-fingerprint,不用于 WS/HTTPUpgrade。 # UDP 默认 false;packet-encoding 默认为空(raw),或 xudp/packetaddr。 # raw transport 固定首包目标;packetaddr 域名由受控 Vole DNS 解析为 IP。 # TCP/WS 保留下载侧;gRPC/HTTP/H2 上传 EOF 关闭逻辑流,详见 outbounds.md#vmess-aead。 @@ -225,11 +526,12 @@ proxies: # headers: {Host: [cover.example:443], X-Custom: [one, two]} # 每次握手各选一个路径与每个头的一个值;禁止 Content-Length/Transfer-Encoding。 # network: h2 - # h2-opts: - # host: [cover.example:443] # 必填、非空;每流选一个,不改变 TLS 认证名 + # h2-opts: # 省略/host: [] 使用 www.example.com;path 省略/空为 / + # host: [cover.example:443] # 每流选一个;缺省/空列表用 www.example.com,不改变 TLS 认证名 # path: /cover # 默认 /;普通 PUT body,不使用 gRPC framing # Trojan(outbound-trojan feature):固定 TLS,不接受 tls 开关。 + # 可选通用 ShadowTLS v3/JLS;TCP/gRPC 另支持 REALITY,须有效 client-fingerprint。 # password 必须非空,按原始 UTF-8 字节认证,不 trim。 # network 接受 tcp(默认)、ws、grpc。sni 默认 server,不回退 WS Host; # alpn 默认 tcp=[]、ws=[http/1.1]、grpc=[h2]。显式列表保留顺序,WS/gRPC @@ -237,6 +539,7 @@ proxies: # Stop / 取消仍关闭全部会话及自有 driver,不更改 XHTTP 的全关闭约定。 # skip-cert-verify 默认 false;fingerprint 沿用 AnyTLS 的证书 SHA-256 # pin 语义,pin 与 skip 同时存在仍必须匹配 pin。udp 默认 false。 + # name-cert-verify 只改变证书验证名;certificate/private-key 为成对内联 PEM 或文件路径 客户端身份。 # UDP 在 TLS 流内传输;单包负载最多 8192 字节并与入口预算取较小值, # 不拆包或截断。域名地址保持域名交由服务端处理;Mihomo v1.19.31 的 # Trojan listener 不接收域名 UDP 帧,使用该对端时需要 IP 业务目标。 @@ -252,7 +555,7 @@ proxies: # ws-opts: # path: /edge?query=1 # 默认 /,必须绝对路径 # headers: {Host: cover.example:443, X-Custom: value} - # max-early-data: 2048 # 0–2048,默认 0 + # max-early-data: 4096 # 默认0;发送按实际HTTP头预算钳位,不预分配此上限 # early-data-header-name: Sec-WebSocket-Protocol # v2ray-http-upgrade: true # 默认 false;原始流,不发送 WebSocket 帧 # v2ray-http-upgrade-fast-open: false # 仅 upgrade=true 可开启 @@ -262,8 +565,14 @@ proxies: # 不接受保留握手头、重复大小写头、CR/LF、userinfo 或超限 HTTP 头。 # network: grpc # grpc-opts: - # grpc-service-name: edge # 必填;普通名映射 /edge/Tun;/ 开头使用完整路径 - # ws-opts / grpc-opts 仅可用于对应 network;不提供 gRPC 连接池。 + # grpc-service-name: edge # 省略/空默认 GunService;普通名映射 /edge/Tun;/ 开头使用完整路径 + # grpc-user-agent: grpc-go/1.36.0 # 省略/空使用此默认值 + # ping-interval: 0 # 秒数;0 不主动 PING;负值明确拒绝 + # max-connections: 4 # 正值时优先按连接上限/min-streams 扩容;显式1/2保留各自策略 + # min-streams: 0 + # max-streams: 0 # max-connections 非正时按此复用阈值扩容 + # 三阈值省略或字面全零时为4/0/0(Mihomo为1/0/0);负阈值禁用分支。 + # ws-opts / grpc-opts 仅可用于对应 network;grpc-opts 可省略。 - name: vless-edge type: vless @@ -281,11 +590,11 @@ proxies: path: /onev mode: auto headers: {X-Transport: edge} - # 以下范围均为字符串 N 或 N-M。所有请求字段与合法配套见 docs/xhttp.md。 + # 范围用字符串 N 或 N-M;uplink-chunk-size/sc-max-each-post-bytes/sc-min-posts-interval-ms 也接受整数。 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: # 省略无额外池;{} 启用默认池,阈值不是全节点数量上限 + sc-min-posts-interval-ms: "10" # packet-up,0–60000且最大值为正 + reuse-settings: # 省略时仅 H3 共享活动连接(每条 4 个租约、无空闲缓存);{} 启用显式池 max-concurrency: "0" max-connections: "0" c-max-reuse-times: "0" @@ -307,18 +616,24 @@ proxies: fingerprint: "" # 清除继承的 pin client-fingerprint: "" # 清除继承的 ClientHello profile name-cert-verify: "" # 恢复下载认证名 - # certificate: # 与 private-key 成对;禁止文件路径 - # private-key: + # certificate: ./client.crt # 与 private-key 成对,或两者都使用内联 PEM + # private-key: ./client.key # reality-opts: {} # 清除继承的 REALITY;非空对象必须提供 public-key # jls-opts: {} # 清除继承的 JLS;非空对象必须提供 username/password + # shadow-tls-opts: {} # 清除继承的 ShadowTLS;非空须 version: 3 和 password smux: enabled: false - protocol: h2mux # h2mux/smux/yamux - max-connections: 1 # 0–i32::MAX;正值与正 max-streams 互斥 + protocol: h2mux # 缺省/空为 h2mux,另支持 smux/yamux + max-connections: 1 # 带符号平台整数;负阈值禁用;正值优先于 max-streams min-streams: 0 max-streams: 0 padding: false - only-tcp: false # true 保留 packet-encoding;false 使用 sing-mux UDP + only-tcp: false # true 委托底层 UDP;false 提供 sing-mux UDP + statistic: false # true 记录真实 Inner 连接,可由 Controller 关闭,不重复计入业务总字节 + brutal-opts: + enabled: false # true 要求服务端支持并启用 TCP Brutal;失败不回退普通 mux + up: 100 Mbps # 客户端发送能力;裸整数/纯数字字符串单位为 Mbps,b=bit、B=byte + down: 100 Mbps # 客户端接收能力;接受十进制 K/M/G/T bps/Bps,缺省为 0 dialer-proxy: upstream-select # REALITY 写法示意:在 VLESS 节点中保留 tls: true,并增加以下 mapping;不要与 @@ -328,35 +643,47 @@ proxies: # 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。 + # 标准 TLS 的可选静态/动态 ECH;主腿与 download-settings 使用相同字段。 + # 非空 config 是标准 Base64 ECHConfigList;省略/空串时通过节点 DNS 查询 HTTPS key 5。 + # 实际只协商 TLS1.3,servername 必须是 DNS 名;静态 config 不查询 DNS,失败不自动明文回落。 + # {} 清除;不能与同腿 REALITY/JLS、Vision 混用,详细契约见 docs/vless.md#ech。 # ech-opts: # enable: true + # query-server-name: query.example # 省略则查询 servername,静态 config 优先 # config: "" # SOCKS5 出站支持 CONNECT 和 UDP ASSOCIATE。username/password 必须同时省略 - # 或同时出现;tls 只能省略或为 false。udp 控制 UDP ASSOCIATE。 + # 或同时出现;tls 默认 false,true 使用 server 作为 TLS 名称,不接受 sni/alpn/client-fingerprint。 + # TLS 支持 skip-cert-verify/name-cert-verify/fingerprint 和成对内联 PEM 或文件路径 certificate/private-key。 + # TLS 只保护 TCP 控制连接,UDP relay 数据不经 TLS;udp 控制 UDP ASSOCIATE。 - name: socks-hop type: socks5 server: socks.example.com port: 1080 + mptcp: false # true 仅在上述 Linux 宿主管理的无接口/源地址约束路径尝试 username: user password: password udp: true - # AnyTLS 只提供客户端出站,配置只接受以下字段和可选 dialer-proxy。 + # AnyTLS 只提供客户端出站,接受以下协议字段和节点通用字段。 # sni 省略时使用 server。TLS 1.3/1.2 默认 WebPKI,alpn 省略/[] 时不发送。 # skip-cert-verify 不能覆盖 fingerprint;pin 是证书 SHA-256,不是客户端伪造指纹。 # 叶 pin 直接决定信任;非叶 pin 作为根继续验证链和名称。所有策略均验证握手签名。 - # 不支持 REALITY、fp、ECH、mTLS 或 session/padding 调参字段,详见 outbounds.md#anytls。 + # name-cert-verify 只改变证书验证名;certificate/private-key 为成对内联 PEM 或文件路径 客户端身份。 + # 支持共享 client-fingerprint 和静态/动态 ech-opts;普通TLS、ShadowTLS v3或JLS身份单选。 + # 不支持 REALITY 或 padding 调参,详见 outbounds.md#anytls。 + # 两个 idle 时间字段为整数秒,<=5 回落30秒;min-idle-session 只保留现有空闲会话。 # TCP 使用 AnyTLS session;udp: true 使用 UoT v2 datagram mode。 - name: anytls-edge type: anytls server: anytls.example.com port: 443 password: "private-anytls-password" + idle-session-check-interval: 30 + idle-session-timeout: 30 + min-idle-session: 0 + disable-reuse: false + client-metadata: "" # 原样进入 Settings.client,不自动生成设备或产品标识 sni: anytls.example.com # alpn: [h2, http/1.1] skip-cert-verify: false @@ -370,7 +697,7 @@ proxies: # AES password 可用 iPSK1:...:iPSKn:uPSK 身份链,其他算法仅单一 Base64 PSK。 # 以下仅为公开测试 key,不可用于真实服务。UDP 默认 false。 # 上游 padding 风险和验收边界见 outbounds.md#shadowsocks-2022; - # 完整互通尚未签收。dialer-proxy 可引用具体节点或 select 组。 + # dialer-proxy 可指定 DIRECT 或引用具体节点、策略组。互通范围见 acceptance.md。 - name: ss-edge type: ss server: ss.example.com @@ -378,7 +705,14 @@ proxies: cipher: 2022-blake3-aes-128-gcm password: "BwcHBwcHBwcHBwcHBwcHBw==" udp: true - # 可选 TCP 包装,两字段必须成对。仅 strict ShadowTLS v3 / TLS1.3。 + # 可选流式插件,plugin/plugin-opts成对且只选一种: + # obfs: {mode: http 或 tls, host: bing.com} + # v2ray-plugin/gost-plugin: {mode: websocket, host: bing.com, path: /, mux: true, tls: false} + # v2ray-plugin另接受v2ray-http-upgrade/v2ray-http-upgrade-fast-open。 + # TLS开启后才接受plugin-opts内的skip-cert-verify/name-cert-verify/fingerprint、 + # 配对certificate/private-key与ech-opts;Host头同时确定SNI。 + # jls: {host: cover.invalid, username: jls-user, password: jls-password, alpn: [h2, http/1.1]} + # ShadowTLS仅显式v3,使用完整TLS1.3认证。 # UoT 默认关闭。开启须 udp: true,UDP 经 SS TCP(含可选插件),不回退原生 UDP。 # udp-over-tcp: true # udp-over-tcp-version: 2 # 开启时省略或整数 2;关闭时不得配置版本 @@ -392,24 +726,47 @@ proxies: # skip-cert-verify: false # fingerprint: "" # name-cert-verify: verified.example.com # 可选独立证书验证名 - # client-fingerprint: chrome # 仅有插件时允许;共享七值,缺省/none/空串关闭命名模板 + # client-fingerprint: chrome # 仅有插件时允许;共享命名模板(须具备TLS1.3能力),缺省/none/空串关闭命名模板 -# 可选的静态 `select` 代理组。字段外形是严格 Mihomo 子集:每组只接受 name、 -# type、proxies 和可选 default-selected,type 只能是 select,proxies 至少一项。 -# 成员可精确引用具体节点、另一个 select 组、DIRECT 或 REJECT;RULES 不是成员。 -# 组声明顺序、成员顺序和重复成员全部保留,节点与组的完整混合图必须无环;组数、成员数和嵌套 -# 深度没有独立上限,仍受 256 KiB 配置总大小和 O(V+E) 无环校验约束。 +# select/url-test/fallback/load-balance 组支持静态 proxies 与动态 use;include-all 同时包含静态节点和所有 providers, +# 也可分别使用 include-all-proxies / include-all-providers。后者启用时忽略 use。 +# filter 筛选动态成员;exclude-filter / exclude-type 同时排除显式成员。 +# 名称正则区分大小写;支持 lookahead、定长 lookbehind、数字/ASCII命名 backreference。 +# 命名组接受 (?...) / (?'name'...),引用接受 \k / \k'name';注释接受 (?#...)。 +# 混合捕获按 .NET 规则先给未命名组编号,再给命名组编号;数字回引、$1 / ${1} / $+ 使用该编号。 +# 反引号分隔的多表达式按匹配顺序排列。节点改名另支持 ${name} / $$ 等 .NET replacement。 +# 重复命名、显式数字组和递归/条件/平衡组不支持,不宣称完整.NET方言。 +# 复杂表达式有内部编译/执行预算;失败候选保留旧资源,不静默当不匹配。 +# 静态成员保留顺序与重复项。 +# 过滤后组成员为空时使用 empty-fallback(缺省 COMPATIBLE,即 DIRECT),可指定节点或内置目标。 +# disable-udp 缺省 false;true 禁用该组的业务 UDP。规则模式跳过不支持 UDP 的目标, +# 继续匹配后续规则;global 模式及显式 DNS 出口仍会拒绝。hidden/icon 为 Controller 元数据。 +# 节点与组的完整混合图必须无环。资源更新后保留仍存在的当前选择,否则使用默认/首成员。 +# 未显式定义 GLOBAL 时,自动追加一个 select 组;成员依次为 DIRECT、REJECT、 +# 所有节点和声明的组,默认 DIRECT。可显式声明 GLOBAL 覆盖其成员及初始选择。 # -# default-selected 省略时选中第一项;显式值必须是当前组的直接成员。重复名称按 -# 第一项命中。没有 Controller 时,该初始选择在本次 session 内固定;有 Controller -# 时可实时切换。选择只存于 Vole 当前 session 内存,Vole 不写回 YAML;宿主若需 -# 跨 session 保留,必须持久化选择并在下次配置中注入 default-selected。 -# -# 切换只影响之后新建的物理 TCP 连接、UDP association/transport 和 DNS transport; -# 既有 TCP 连接、UDP association、DNS cache/singleflight、DNS TCP pool 和其中的 -# transport 不迁移、不清空。选中成员失败时原样失败,代理组不自动换成员。 +# select 的 default-selected 省略、null、空串或未命中时选中第一项;有效缓存选择优先。名称精确匹配。 +# url-test 按 URL 探测延迟选择,tolerance(毫秒,默认 0)抑制小幅切换;fallback +# 按声明顺序选择首个健康成员,恢复后可回到前面的成员。全部失败时返回首成员。 +# 四种组均接受 url/interval/timeout/lazy/expected-status/max-failed-times。 +# url 缺省使用首个 provider 的健康 URL,否则 https://www.gstatic.com/generate_204。 +# interval 单位秒,自动组缺省/0 为 300;select 缺省 0,不主动周期探测。 +# timeout 单位毫秒,缺省/0 为 5000;lazy 默认 true,无近期使用时跳过周期探测。 +# expected-status 省略/空/* 接受所有最终 HTTP 状态;单值可写整数 204 或字符串 "204",区间用 "200-299"。 +# max-failed-times 缺省/0 为 5,建链失败达到阈值后触发探测,不重试同一次业务连接。 +# HTTP 探测结果不代表 UDP 可用性,业务 UDP 仍检查节点能力与 disable-udp。 +# Controller 可手动固定 url-test/fallback 成员,成员不可用时恢复自动选择。 +# load-balance 的 strategy 为 consistent-hashing(缺省)、round-robin、sticky-sessions。 +# 一致性哈希按目标可注册主域名或 IP 分桶;轮询跳过不健康成员;粘滞会话按来源 IP +# 与目标主域名/IP 缓存 10 分钟,最多 1000 项。哈希种子属于当前进程,不承诺重启后相同。 +# hash-key 仅接受空字符串(缺省)或 in-user;in-user 仅用于一致性哈希/粘滞策略, +# 使用已认证用户名单独分桶,无用户名时回退地址策略。业务路由和 dialer-proxy 都保留此身份。 +# load-balance 没有 now/手动选择;UDP 按目标会话固定叶节点,健康变化不迁移旧会话。 +# 自动或手动切换只影响新连接;既有连接、认证会话及其中复用的流仍保留原路径。 # 组作为 dialer-proxy 时,与路由共用当前选择,但不重新执行业务规则;DIRECT 连接 -# 当前节点的服务器而不是业务目标,REJECT 在建链时失败且不回退。单次建链对每个组 +# 当前节点的服务器而不是业务目标,REJECT 在建链时失败且不回退。成员也可为 PASS/ +# PASS-RULE;仅 rule 模式消费控制,global、dialer-proxy 或显式 DNS 出口选中时拒绝。 +# 单次建链对每个组 # 只读一次选择,SOCKS5 UDP 控制流与 relay 路径共用该快照和期限;不同组没有原子 # 快照保证。复用旧 AnyTLS 会话的新业务流仍沿用该会话路径,只有新建底层 transport # 才读取新选择。Hysteria2/TUIC 已建立会话的新逻辑流、Hysteria2 跳端口 socket @@ -429,32 +786,228 @@ proxy-groups: proxies: [vless-edge, fallback-select, DIRECT, REJECT, vless-edge] default-selected: vless-edge -# 运行时 DNS 可省略;enable: true 时主 nameserver 必须有 1–4 项。ipv6 省略时 -# 默认为 true;false 时直接 AAAA 查询返回空结果,域名解析不查询 AAAA。nameserver -# 只接受裸 IP、udp://IP[:port] 或 tcp://IP[:port]。没有 fragment 时固定 DIRECT; -# fragment 只接受 DIRECT、RULES、实际节点名或代理组名。`PROXY`/`#PROXY` 没有 -# 默认出口含义,只有存在同名 route target 时才有效。DNS route 选中代理组后, -# 仅新建物理 transport 解析当前成员;cache 与既有 TCP pool 不随切换清空。 -# nameserver-policy 按顺序匹配 -# `geosite:[,...]`,逗号项为 OR,命中后只在当前组内故障转移。 -# selector 可为 cn、cn@ads@cn、!cn@ads;多个属性为 key 存在性 AND(忽略 bool/int 值), -# 前置 ! 取反整个匹配结果,@!cn 是字面 key。相同归一 selector 不能跨项重复, -# 但相同基础分类的不同交集/正反选可共存;资产不可用不因反选变成全匹配。 +# 资源声明及使用示例。http 支持 HTTP(S),file 读取本地文件,inline 使用 payload。 +# path 必须位于 dataDir 内;HTTP 缺省路径在 proxies/ 或 rules/ 下按实际 URL/header/UA 请求摘要分配。 +# HTTP interval 单位秒,0 只获取一次;file 监测文件变化。下载失败退避重试。 +# proxy 指定当前运行配置中的出口;缺省按规则选路。不会等待新资源才能建立下载连接。 +# header 值为字符串数组;size-limit 单位字节,0 不设下载上限,解析仍有容量边界。 +# proxy provider 支持 {proxies: [...]} YAML、URI 列表及标准 Base64 编码的 URI 列表。 +# URI 转换后仍执行原名称过滤、override 与完整严格节点/依赖图校验。 +# 支持当前协议范围内的 vless/vmess/trojan/ss/anytls/hy2/hysteria2/tuic/mierus, +# 以及 socks/socks5/socks5h/http/https;SS 仍只允许 SS2022,TUIC 仅 v5。 +# URI HTTPS 节点保持默认验证证书,不复制参考转换器的隐式 skip-cert-verify。 +# 可用 filter/exclude-filter/exclude-type。URI 转换失败保留旧资源,不记录原始 URI。 +# HTTP 来源 URL 的 userinfo 使用 Basic;跨 origin 跳转不转发原认证/Cookie。 +# gzip 流式解压后写缓存;size-limit、hash 与后续解析针对解压内容,gzip 损坏或超限不发布。 +# 来源摘要绑定精确 URL、实际 header/UA;旧 URL-only 元数据保留可用内容但不复用 ETag。 +# provider 的 dialer-proxy 覆盖每个节点的同名字段,override 最后应用; +# 它只控制节点上游,资源下载仍使用独立的 proxy 字段。 +# override 支持 tfo、mptcp、udp、udp-over-tcp、up/down、dialer-proxy、ip-version、interface-name、routing-mark、skip-cert-verify、name-cert-verify、 +# additional-prefix/suffix、proxy-name: [{pattern: '表达式', target: '替换文本'}]、override-expr 字符串数组; +# 协议专属覆盖只用于支持该字段的节点。未实现的覆盖字段严格拒绝。 +# override-expr 为原生有限 yq 方言,校验时预编译:原名称过滤 → 结构化 override / 改名 / 前后缀 +# → 表达式按列表顺序执行 → 严格节点及引用图校验。每项须产出一个 mapping,失败保留旧资源。 +# 支持根/字段/引号字段、正负数组索引、[] 通配、select 目标、= / |= / += / -= / *=、del、 +# pipeline / 逗号流 / 数组收集 / mapping / 插值、标量、算术/比较/and/or/默认值 //。 +# 函数:length/keys/has/contains/select;reverse/sort/unique/flatten/any/all; +# map/map_values/filter/to_entries/from_entries/with_entries/any_c/all_c; +# test/sub/split/join/upcase/downcase/trim/tostring/tonumber/type/not。 +# transform 函数允许分号分隔的独立参数流;any_c/all_c 按成员短路,from_entries 要求 key/value。 +# upcase/downcase 使用 Rust 标准 Unicode 全映射,单字符可扩展为多个字符;trim 独立处理空白。 +# 整数 literal 为有符号 64 位,含 -9223372036854775808;超出该范围的整数 literal 拒绝。 +# = 右侧读取根,|= 读取旧目标;赋值右侧的 pipeline/and/or 须带括号,多结果须收集。 +# mapping 值取最后结果,无结果省略;插值取首结果。sub 全局替换,使用 Go 的 $capture。 +# 每项最多 16 KiB/4096 tokens 与节点、深度 32;整份配置编译最多 512 KiB/65536 tokens +# 与节点/1 秒。整份候选共享取消、20 万步、100ms、32 MiB 累积值分配估算、值深度 64 +# 与 32 个有界线性正则预算。无 shell/yq、变量、reduce、动态路径、递归下降、文件/环境或文档流。 +# 最终节点仍须符合本文协议范围;表达式不提供任意脚本或额外配置字段。 +# proxy providers 的 health-check 接受 enable/url/interval/timeout/lazy/expected-status; +# enable 默认 false,开启后 interval 缺省/0 为 300 秒。空 URL 不发起探测。 +# 相同节点/URL 的检查共享结果,每批最多 10 个并发,超时与停止均取消 I/O。 +# 更新先解析并校验整个依赖图,成功才替换;失败保留旧配置与 HTTP 缓存。 +# HTTP proxy provider 的订阅用量来自 Subscription-Userinfo 响应头,成功更新和 304 后 +# 随缓存保存;缺少该头保留旧值,失败候选不更新。Controller 不用本地流量填充订阅用量。 +# proxy providers 的 age-secret-key 可含多行 X25519/Hybrid AGE 私钥,空行和 # 注释跳过。 +# HTTP/file 来源写法:age-secret-key: |,下方缩进填写私钥;不用于 rule providers。 +# 配置期验证私钥;仅精确 AGE armor 前缀触发原生解密,之后识别 YAML/URI/Base64。 +# file、初始缓存和 HTTP 更新共用解密;缓存/hash/ETag 保留原密文,解密失败不发布候选。 +# 私钥最多 16 KiB/128 个,AGE 头最多 64 KiB/1024 次身份尝试;解密输出限 8 MiB, +# URI、JSON 选项和多端口节点转换共用 32 MiB 内部展开计费;不是整进程 RSS 保证。 +# 读取/解密共享 90 秒期限,并在读块和密钥操作之间检查取消。 +# 新连接使用新资源,旧 TCP/UDP 会话保留原资源;停止等待下载、解析和资源释放。 +# rule providers 支持 domain/ipcidr/classical,format 为 yaml/text/mrs;MRS 仅 domain/ipcidr。 +# YAML 规则文件为 {payload: [...]},text 每行一条,支持空行和 # 注释。 +# classical 接受已支持的无动作规则,不接受 MATCH、RULE-SET、SUB-RULE。 +# 文本/MRS 解压上限 64 MiB;YAML 沿用 8 MiB 输入和 32 MiB 展开预算。 +# 非inline规则provider可设置path-in-bundle(例如rules/cn.mrs),从dataDir/BundleMRS.7z读取成员。 +# 有效缓存优先,其次bundle,再用原file/http来源;坏缓存同样可以回退bundle。 +# bundle只读取指定压缩块,支持COPY/LZMA1/LZMA2及已列入资源契约的filter;超预算/坏成员回退原来源。 +# inline忽略path-in-bundle;proxy-providers不接受此字段。 +proxy-providers: + local-nodes: + type: inline + # override: + # override-expr: ['.name |= (trim | upcase)', '.udp = true'] + payload: [] + health-check: + enable: false + url: https://www.gstatic.com/generate_204 + interval: 300 + timeout: 5000 + lazy: true + expected-status: "204" +rule-providers: + local-domains: + type: inline + behavior: domain + payload: ["+.internal.example"] +# 动态组写法:{name: automatic-members, type: select, use: [local-nodes]} +# 规则写法:RULE-SET,local-domains,DIRECT;ipcidr/classical 可追加 no-resolve。 +# DNS policy 支持 rule-set:local-domains(逗号分隔多个集合);只接受 domain/classical, +# classical 在 DNS 场景使用域名与 GeoSite 条件,支持仅由这些条件组成的 AND/OR/NOT。 +# 混合 IP、端口、来源或网络条件的逻辑项不参与域名 selector。 + +# hosts 支持单 IP、IP 数组、域名别名与 Clash 域名通配。 +# 值恰好为 lan 或 [lan] 时,启动准备捕获接口 IP,排除回环和 link-local;空快照使启动失败。 +# 校验不读取网卡;快照不随地址变化或 Provider 更新刷新,不限于私有网段。 +hosts: + "internal.example": [192.0.2.10] + "alias.example": internal.example + +# DNS 的 enable、ipv6 默认 false,use-hosts/use-system-hosts 默认 true。 +# enable 关闭时不接管 TUN DNS,内部解析使用系统 resolver;配置中的 hosts 仍可用于内部解析。 +# enable 开启时使用配置的 resolver,不因上游失败回退到系统 DNS。 +# nameserver 默认 [https://doh.pub/dns-query, tls://223.5.5.5:853],不能显式留空。 +# 支持裸 IP、UDP/TCP、tls://、http(s)://、quic://(DoQ 默认 853),允许上游域名。 +# DoH GET 使用 ID 0 与无 padding +# Base64URL dns=;清除原 query/fragment,URL userinfo 转 Basic,不进入 Host/path 或日志。 +# HTTPS 实际 ALPN 协商 H2/H1,无 ALPN 回退 H1;明文 HTTP 仅 H1,无 h2c。 +# prefer-h3: true 让 HTTPS 首次建连竞争已认证 H3 与 TCP TLS,第一条成功胜出;失败允许另一条继续。 +# HTTPS #h3=true 强制实际 H3,不降级 H2/H1;明文 HTTP 的 h3/prefer-h3 不改变 H1 行为。 +# DoT、DoH、DoQ 使用共享 TLS 信任根,校验证书与名称;QUIC 使用受控 UDP,不绕过 DNS 出口/平台绑定。 +# default-nameserver 接受 IP 上游或显式 system,用于 bootstrap。 +# system、system://、dhcp://system 使用物理系统 DNS;dhcp://<精确接口名> 使用该接口的 DHCP DNS。 +# 各角色、policy、fallback 均支持;DHCP 接口不能用于 default-nameserver。来源始终物理直连, +# fragment/代理规则不改变物理出口,但 ECS/类型过滤包装仍适用。 +# 只读现有网络来源;缺失、加密要求无法满足或物理绑定失败时不可用。 +# rcode://success/format_error/server_failure/name_error/not_implemented/refused 返回本地 RCODE 0–5, +# 可用于各 nameserver 列表与 policy(default-nameserver 除外);不建立网络连接或额外设置 RA。 +# 无 fragment 时默认 DIRECT;respect-rules: true 时默认 RULES,且必须配置 +# proxy-server-nameserver。显式 fragment 可选 DIRECT、COMPATIBLE、REJECT、REJECT-DROP、RULES 或已配置的节点/组名; +# fragment 使用 #出口或接口&参数=值,整个 fragment 先百分号解码,再拆分;重复项以后者为准。 +# 出口名优先;没有同名节点/组时按接口直接连接,覆盖全局接口并沿用全局 mark。 +# 接口身份隔离 DoT/DoH/DoQ 的连接复用;system/DHCP、RCODE 和节点私有 DNS 保持自身出口语义。 +# ecs=IP/CIDR 与 ecs-override=true 控制 ECS,保留其他 EDNS 参数。 +# disable-ipv4=true、disable-ipv6=true、disable-qtype-N=true 抑制相应 question 并过滤全部响应 sections; +# ECS/过滤适用于 system、DHCP、RCODE 及各 resolver 角色,DHCP 接口名不含 fragment。 +# skip-cert-verify=true、name-cert-verify 用于 DoT/DoH/DoQ;显式名称验证优先且不改变 SNI。 +# disable-reuse=true 仅禁用指定 DoT 复用;HTTPS 仍可复用,未知键值参数拒绝。 +# proxy-server-nameserver 专门解析节点,direct-nameserver +# 专门解析 DIRECT 业务目标;未配置时复用 nameserver。两者不隐式开启 respect-rules。 +# direct-nameserver-follow-policy 默认 false;true 让直连解析遵循 nameserver-policy。 +# 新建 DNS 连接采用当前组选择,cache 和已有连接不随选择改变而清空。 +# nameserver-policy 接受域名、Clash 通配域名和 geosite;值为单个 nameserver 字符串 +# 或非空数组。域名可用逗号连接;* 匹配一层,.example.com 仅匹配子域, +# +.example.com 匹配本域及所有子域。连续域名项优先匹配字面标签,其次 *,最后 .; +# 相同归一域名模式以后项为准。GeoSite 项隔开域名区块,区块与 GeoSite 按声明顺序匹配。 +# `geosite:[,...]` 的逗号项为 OR,selector 可为 cn、cn@ads@cn、 +# !cn@ads;多个属性为 key 存在性 AND(忽略 bool/int 值),前置 ! 取反整个匹配结果, +# @!cn 是字面 key。资产不可用不因反选变成全匹配。命中后仅使用当前策略的上游。 dns: enable: true ipv6: true + prefer-h3: false + ipv6-timeout: 100 # 毫秒;内部 A/AAAA 并发,A 完成后最多等待 AAAA;0 同缺省 100 + cache-max-size: 4096 # 每个 DNS 角色的结果总条目数;0 同缺省 4096,A/AAAA 和 wire 共用 + cache-algorithm: lru # 精确 arc 使用 ARC;缺省与其他字符串使用 LRU,与 Mihomo 一致 + fallback: [] # A/AAAA 的备用解析组;不是 nameserver 内部的故障转移 + fallback-lazy-query: false # false:与主组同时查询;true:确定需要时才查询 + fallback-filter: + geoip: true # 非本地地址且不属于 geoip-code 时使用 fallback + geoip-code: CN + ipcidr: [] # 任意返回地址命中时使用 fallback + domain: [] # Clash 域名通配,匹配后只查询 fallback + enhanced-mode: redir-host # 或 fake-ip;内部节点/直连解析始终取得真实地址 + fake-ip-range: 198.18.0.1/16 + fake-ip-range6: "" # 设置 IPv6 CIDR 且启用 IPv6 后分配 AAAA + fake-ip-ttl: 1 # 秒;运行时最小为 1,不决定映射寿命 + fake-ip-filter-mode: blacklist # blacklist / whitelist / rule + fake-ip-filter: [dns.msftnsci.com, www.msftnsci.com, www.msftconnecttest.com] + # blacklist:命中返回真实地址;whitelist:只有命中才分配 fake-IP。 + # 普通过滤项支持 Clash 域名通配、geosite: 和 rule-set:。 + # rule:使用 DOMAIN-SUFFIX,example.com,real-ip 等域名规则;动作 real-ip/fake-ip。 + # rule 未命中分配 fake-IP;两种地址池均保留网络地址后的前三个地址及广播地址。 + use-hosts: true + use-system-hosts: true + respect-rules: false + default-nameserver: [114.114.114.114, 223.5.5.5, 8.8.8.8, 1.0.0.1] + proxy-server-nameserver: [223.5.5.5] + proxy-server-nameserver-policy: + "+.internal.example": 192.0.2.53 + direct-nameserver: [223.5.5.5] + direct-nameserver-follow-policy: true nameserver: - "tcp://1.1.1.1:53#main-select" nameserver-policy: + "blocked.example": "rcode://refused" + "internal.example": "223.5.5.5" + "+.example.com": ["tcp://1.1.1.1:53#DIRECT"] "geosite:private,cn,apple": - "tcp://223.5.5.5:53#DIRECT" - "tcp://1.1.1.1:53#main-select" -# rules 是运行配置必填项,按顺序首条命中。DIRECT 与 REJECT 是内置动作,其他 -# 动作必须精确使用实际节点名或代理组名。`PROXY` 没有特殊别名。rules 不能为空, -# 必须恰好以一个指向实际节点或代理组的 MATCH 结束;不存在隐式 MATCH 或 DIRECT -# 回退。选中组时使用当前成员,组本身不做 health check、测速或自动 failover。 +# rules 可省略或为空;rule 模式按顺序首条命中,未命中 DIRECT。MATCH 可放在任意 +# 位置,普通目标命中后其后规则不再执行。内置 DIRECT/COMPATIBLE 直连,REJECT 拒绝, +# REJECT-DROP 的 TCP 等待 60 秒或 Stop(期间不发往远端),UDP 逐包丢弃。其他出口精确引用节点或组。 +# 支持 DOMAIN、DOMAIN-SUFFIX、DOMAIN-KEYWORD、DOMAIN-WILDCARD、DOMAIN-REGEX、 +# GEOSITE、GEOIP、SRC-GEOIP、IP-ASN、SRC-IP-ASN、IP-CIDR、IP-CIDR6、SRC-IP-CIDR、 +# IP-SUFFIX、SRC-IP-SUFFIX、DST-PORT、SRC-PORT、NETWORK、 +# IN-TYPE、IN-NAME、IN-USER、IN-PORT、REMATCH-NAME、RULE-SET、SUB-RULE、AND/OR/NOT 和 MATCH。来源信息来自实际入站会话。 +# PROCESS-NAME、PROCESS-PATH 及各自 -WILDCARD/-REGEX 支持已确认的本机 socket owner,忽略大小写。 +# Linux/Android 还接受 UID(uint32 单值/范围,/ 分隔,最多 28 段);UID 0 不匹配。 +# 进程路径保留 Unicode,最多 32 KiB;正则沿同一编译/决策预算,不当作域名处理。 +# Linux 查询当前网络 namespace 的完整源/目标元组,再以 inode 找进程;macOS 使用公开 libproc, +# Windows 使用 owner-PID 表与普通 limited-information 句柄。权限不足、LAN/跨 namespace、 +# wildcard/复用归属不明确或查询预算耗尽时身份未知,不提权;iOS/tvOS 无跨 App 进程信息。 +# Android 不新增宿主回调;受平台 /proc 权限约束,不承诺包名或任意应用身份可得。 +# 同时最多四个扫描,许可最多等待 100 ms,每扫描最多 100 ms/16384 项/1 MiB 表。 +# strict 无身份条件及 off 不扫描;一次决策不重试,Stop 等待已开始的系统查询结束。 +# CIDR 两种别名都按 payload 实际地址族解析;GEOIP/IP-ASN/CIDR/IP-SUFFIX/RULE-SET 可追加 src,自动禁止 DNS。 +# IP-SUFFIX 的 address/bits 比较低 bits 位,保留原地址,按 IPv4/IPv6 分族;0 位匹配同族全部。 +# SRC-IP-SUFFIX 只使用真实来源地址;目的后缀支持 no-resolve,并复用既有惰性 DNS 结果。 +# RULE-SET 的 src 在局部视图交换源/目标 IP 与端口,保留域名、入口类型和身份。 +# IN-TYPE 为 / 分隔的 Mihomo 类型名,大小写无关,SOCKS 展开为 SOCKS4/SOCKS5; +# IN-NAME 为 / 分隔、区分大小写的入口名:TUN为DEFAULT-TUN,mixed的HTTP/SOCKS5为DEFAULT-MIXED;内部请求为空。 +# IN-USER 为 / 分隔、区分大小写的已认证用户名。三种列表逐项 trim,空项报错。 +# IN-PORT/DST-PORT/SRC-PORT 为 uint16 端口或范围,/ 分隔,含 0,逆序范围归一, +# 最多 28 段;分隔中的空段跳过。真实入口和服务器端口仍须非零。 +# 通配符 * 匹配任意长度、? 匹配一个字符;DOMAIN-REGEX 默认忽略大小写,普通表达式走线性引擎。 +# 有限复杂语法支持前向正/负断言、固定宽度后向断言、数字及 ASCII 命名 backreference、原子组, +# 命名捕获支持 (?...) 与 (?'name'...);引用支持 \k 与 \k'name'。 +# 混合命名/未命名捕获的数字引用沿 .NET 编号;每模式最多512个捕获,转译仅改变编号。 +# 不支持重复命名、显式数字组、递归、条件/平衡组、possessive quantifier或变长后向断言; +# 此有限方言不保证全部 regexp2/.NET Unicode、锚点或捕获语义等价。无法编译或越界明确报错。 +# 每模式限制 4 KiB、512 AST 节点/32 层、有限重复 1024,委托编译/搜索缓存有近似大小边界。 +# 一次决策跨逻辑/子规则、classical provider、候选地址和 DNS 续跑共享 sticky 执行预算: +# 每次复杂匹配预留 2049 回溯额度,总共 65536;所有谓词及正则共同消费 64 Mi work units; +# 累计实际正则调用耗时上限 2 ms,DNS 等待不计入。时间/取消仅在同步引擎调用前后检查, +# 不能中途抢占或保证严格 2 ms/Stop 上界。执行失败终止当前决策,不视作未匹配或 DIRECT。 +# 逻辑规则例如 AND,((DOMAIN-SUFFIX,example.com),(SRC-PORT,1000-2000)),DIRECT; +# NOT 恰好一个子规则,逻辑最多嵌套 32 层。主列表与所有 sub-rules 共用 65536 条、 +# 单条 16 KiB、文本合计 8 MiB 的预算;子规则名计入文本。子规则调用最多 32 层, +# 名称必须存在且全图无环;定义共享存储,不按调用次数复制。 +# SUB-RULE,(纯条件),名称 在条件匹配时进入指定列表;子列表耗尽则继续调用者。 +# PASS(含组选择为 PASS)退出所有子调用并继续主列表下一条;PASS-RULE 仅继续 +# 当前子列表,出现在主列表时为 REJECT。两者不能出现在逻辑谓词或 classical 集合中。 +# REMATCH-NAME,blue/Green,DIRECT 按 / 分隔、逐项 trim,名称非空且区分大小写。 +# rematch 节点更新本次决策标记或根列表,从首条重新求值,不创建连接或回到调用位置。 +# 重入保留已完成 DNS/process、来源/目标及原期限、取消、预算、规则禁用快照。 +# 同次决策最多 32 个 rematch 节点,节点名重复即失败;不增加可调递归参数。 +# 组的控制目标先于 disable-udp/节点 UDP 检查;跳过不推进轮询。实际出口只选择一次。 # GEOSITE 支持 [!][@...],GEOIP 支持 [!] 和可选 no-resolve。 +# SRC-GEOIP 与 GEOIP,...,src 等价,来源规则不触发 DNS;lan 为无需资产的内置 GeoIP。 +# IP-ASN 按实际目标 IP 查询 ASN.mmdb,可带 no-resolve;SRC-IP-ASN 使用实际来源 IP。 +# ASN payload 逐字匹配,不转换 AS 前缀或补零;仅引用时加载,数值型数据库无记录匹配 0。 # 例如 GEOSITE,google@ads,REJECT / GEOSITE,!cn@ads,main-select / GEOIP,!CN,DIRECT。 # geolocation-!cn 为字面分类名;GeoIP DAT reverse_match 接受但忽略,取反由 ! 控制。 # GEOSITE 没有域名不匹配;分类缺失不匹配,而有效空属性交集的反选匹配非空域名。 @@ -463,15 +1016,20 @@ rules: - GEOSITE,cn,DIRECT - GEOIP,CN,DIRECT,no-resolve - DOMAIN-SUFFIX,anytls.example,anytls-edge - - NETWORK,UDP,anytls-edge + - SUB-RULE,(NETWORK,UDP),udp-policy - MATCH,main-select -# measureDelay 使用独立的 node-only 配置:顶层只能包含 proxies,不能包含 +sub-rules: + udp-policy: + - DOMAIN-SUFFIX,example.com,PASS-RULE + - NETWORK,UDP,anytls-edge + +# measureDelay 使用独立的 node-only 配置:顶层只接受 proxies 和 tls.custom-certifactes,不能包含 # proxy-groups。节点字段和代理图校验与上文一致,但不能出现 mixed-port/authentication、 -# tun、sniffer、Controller、DNS、rules 或 GeoData;此接口的 dialer-proxy 只能引用具体 +# tun、sniffer、Controller、DNS、rules 或 GeoData;此接口的 dialer-proxy 可指定 DIRECT 或具体 # 节点。Vole 从“未被任何 dialer-proxy 引用”的节点推导唯一链头,且该链必须覆盖 # 全部节点。测速不创建监听器、公共实例、TUN/protect 租约、DNS、规则引擎、代理组 -# 或 GeoData 注册。 +# 或 GeoData 注册。rematch 是路由控制,不属于可测量的具体节点,测速配置拒绝它。 # # 最小测速示例: # proxies: diff --git a/docs/controller-api.md b/docs/controller-api.md index fbb0301..c97a70c 100644 --- a/docs/controller-api.md +++ b/docs/controller-api.md @@ -1,25 +1,81 @@ # 运行时 Controller API -Controller 是当前 Running Session 的回环 HTTP 接口。它提供一次性 TUN 流量快照,以及静态 `select` 代理组的状态查询和实时选择;它不是 Invoke API,也不承诺完整 Mihomo Dashboard 兼容。 +Controller 是当前 Running Session 的 HTTP 接口。它提供业务连接管理、有效设置与规则查询、单次 TUN 流量快照、按需 WebSocket 推送,以及静态与动态 `select`、`url-test`、`fallback`、`load-balance` 代理组的状态查询和实时选择;它不是 Invoke API,也不承诺完整 Mihomo Dashboard 兼容。 ## 配置与生命周期 ```yaml external-controller: 127.0.0.1:9090 +external-controller-tls: 127.0.0.1:9443 # 可单独启用或与 HTTP 同时启用 +external-controller-unix: controller.sock # Linux,可独立启用 +external-controller-routing-mark: 0 # Linux,仅管理 TCP +external-controller-cors: + allow-origins: ["https://ui.example"] + allow-private-network: false secret: "vole-runtime-secret" ``` -- 省略 `external-controller` 表示不启动 Controller,此时不能单独配置 `secret`。含代理组但没有 Controller 的配置合法,组在本次 session 内保持初始选择。 -- `external-controller` 只接受带显式非零端口的回环 IP 地址。它要求启用 TUN 或至少定义一个 `proxy-groups`;非 TUN 配置须通过 `mixed-port` 满足运行配置的入站要求。 -- 只要同时配置代理组与 Controller,`secret` 就必填;仅有 TUN 流量接口时可省略。`secret` 出现时必须为 1–255 UTF-8 字节,并保护本文定义的全部路由。 +- 三个 Controller 地址均省略或空串时不启动 Controller,也不采集业务连接、规则命中和建链诊断;配置中的 `secret` 可保留。代理组选择、健康检查、资源更新与 TUN 原始流量统计独立运行。 +- `external-controller` 接受 IP、`localhost` 或空 host 与数值端口组成的地址,如 `127.0.0.1:9090`、`0.0.0.0:9090`、`[::]:9090`、`localhost:9090`、`:9090`。端口范围为 0–65535,0 由系统选择端口;`localhost` 使用 IPv4 回环。其他域名和命名服务端口不在当前范围内。静态节点、纯 DIRECT 及关闭业务入口的配置均可使用。 +- 空 host、`0.0.0.0` 和 `[::]` 均优先使用允许 IPv4 映射连接的双栈通配监听,仅地址族不可用时改用 IPv4。Controller 的地址族选择独立于顶层 `ipv6`。 +- `external-controller-tls` 使用相同地址格式、绑定与端口范围。HTTP/HTTPS 共享当前会话、API handler、选择状态和 Bearer 认证;HTTPS 按实际 ALPN 提供 H2/H1,无 ALPN 使用 H1;明文 HTTP 仅 H1。 +- `external-controller-unix` 是 Linux 文件系统路径,空串关闭;相对路径从 dataDir 解析,不自动创建父目录。 + bind 前通过实际 socket fd 限制创建权限,绑定后在 listen 前恢复当前用户 0600 权限并复验捕获的 dev+ino; + 已有文件、目录、符号链接或 socket 都使绑定失败,不接管旧对象。 + 本地 HTTP/1 与 WebSocket 共用现有 API、CORS、资源和订阅限额,通过文件权限授权,不要求 Bearer 或 token。 + 网络 HTTP/HTTPS 保留原 secret;本地访问不会修改它。Stop 和启动失败只清理本次创建且 dev+ino 仍一致的 socket, + 也覆盖绑定后尚未 serve 就丢弃的情况;其它平台启动该入口返回不支持。 +- `external-controller-routing-mark` 仅作用 Linux HTTP/HTTPS 的 TCP listener 和已接受 socket, + 在 bind 前设置 SO_MARK;Unix socket 与业务 Dialer 不受影响。0 不设置;沿用 socket mark 的 + 32 位范围(负的 i32 值转换为同位模式的 u32)。SO_MARK 权限或系统错误原样导致绑定/接受失败, + 不静默清零或回退;非 Linux 在启用网络监听且 mark 非零时返回不支持。 +- `secret` 始终可选,省略或空串表示不要求认证;配置非空值时保护本文定义的业务路由,CORS 预检在认证之前处理。配置仅受整体 YAML 大小预算约束,HTTP 请求头仍限 32 KiB。 - `measureDelay` 的 node-only 配置不能包含 Controller 或代理组字段。 -- `validateConfig` 只校验字段;`prepare` 不监听端口;`start` 绑定端口,绑定失败则启动失败。`stop` 和 `destroyInstance` 关闭监听器。 +- `validateConfig` 只校验 Controller 字段;`start` 加载服务端证书并绑定端口,任一失败则启动失败。`stop` 和 `destroyInstance` 关闭全部监听器并等待请求与证书 watcher 回收。 -Controller 与公共运行时 session 同生共灭。代理组选择只保存在 Vole 当前 session 的内存中;Vole 不写回配置。宿主如需跨 session 保留选择,必须自行持久化,并在下次 `configYaml` 中提供对应的 `default-selected`。 +Controller 与公共运行时 session 同生共灭。`profile.store-selected` 默认 true,Core 将显式选择保存到 dataDir/selected.json,在下次启动时恢复有效成员;不写回 YAML。关闭该选项后只保留会话内选择。写入失败不回滚已生效的选择,`GET /proxies` 的 `selectionError` 给出脱敏原因。 + +## HTTPS 证书与客户端认证 + +```yaml +tls: + certificate: controller.pem + private-key: controller.key + client-auth-type: require-and-verify + client-auth-cert: controller-client-ca.pem +``` + +`certificate/private-key` 接受配对 PEM 内容或 dataDir 内的文件路径。两者均空时生成随机 RSA 2048 密钥与自签证书,没有配置域名或公共信任;没有 HTTPS 地址时不加载服务端材料。内联证书在本会话中固定,文件证书对每秒检查一次:匹配且有效的完整候选原子替换,坏候选保留旧证书;在途 TLS 使用已选择的证书,新握手取得新候选。只读取普通文件,证书对合计最多 8 MiB;错误不输出内容、私钥或路径。 + +`client-auth-type` 对客户端证书的要求如下;TLS 始终校验客户端 CertificateVerify 握手签名。 + +| 值 | 无客户端证书 | 提供客户端证书 | +| --- | --- | --- | +| 省略、空串或其他字符串 | 允许,不请求证书 | 不要求证书 | +| `request` | 允许 | 校验结构与握手签名,不校验信任链 | +| `require-any` | 拒绝 | 校验结构与握手签名,不校验信任链 | +| `verify-if-given` | 允许 | 必须通过配置 CA 的证书验证 | +| `require-and-verify` | 拒绝 | 必须通过配置 CA 的证书验证 | + +默认模式携带非空 `client-auth-cert` 时自动提升为 `require-and-verify`;显式 `request/require-any` 保留原要求。CA 接受 PEM 内容或 dataDir 内普通文件,最多 8 MiB,只在启动读取;不随服务端证书 watcher 自动更新。客户端认证与 API Bearer 分别执行,成功的 TLS 握手不会跳过非空 secret。服务端证书、客户端 CA 与 `tls.custom-certifactes` 的出站信任根分别消费。 + +## CORS + +`external-controller-cors.allow-origins` 缺省、空数组或含 `*` 时允许全部来源,响应为 `Access-Control-Allow-Origin: *`;其它列表按忽略大小写的精确匹配或单个 `*` 通配匹配,成功时回显请求来源。无 Origin 或来源不匹配时不返回允许来源头,普通 API 请求仍走原路由与 Bearer。 + +OPTIONS 携带 Origin 和非空 `Access-Control-Request-Method` 时为预检,在 Bearer 前返回 200。允许 GET/POST/PUT/PATCH/DELETE,OPTIONS 同样有效;请求头仅允许 Content-Type、Authorization 与参考自动加入的 Origin,缓存 300 秒,不返回 Allow-Credentials。拒绝的预检仍为 200,但不返回允许头。普通响应含 `Vary: Origin`,预检另含请求 method/headers 的 Vary。 + +`allow-private-network` 缺省 true;只对成功预检且请求 `Access-Control-Request-Private-Network: true` 返回相应允许头,显式 false 禁止返回该头。CORS 不改变 Controller 地址、secret 或 API 支持范围。 + +与 Mihomo 一致,`allow-origins` 只控制 CORS 响应头,不按 `Origin` 拒绝 WebSocket Upgrade。浏览器 WebSocket 不受 Fetch CORS 约束;订阅访问仍由独立的 secret/token 鉴权决定,空 secret 不要求认证。 + +## 请求预算与停止 + +HTTP/HTTPS/Unix 合计最多 8 条活动连接。普通 H1 每连接处理一个请求后关闭,WebSocket 升级后保持当前订阅;H2 每连接最多 8 条并发请求,超出准入复位 stream,接收窗口每流 1 KiB、连接 8 KiB,发送缓冲每流 16 KiB。头部上限 32 KiB/100 字段,H2 target 上限 8 KiB,组选择与 PATCH body 上限 1 KiB,内联配置 PUT body 上限 8 MiB;握手、H1 读头、body 与写响应各有 5 秒期限。停止取消活动请求与订阅、关闭协议 driver,并等待所有 H2 请求和文件候选读取完成。 ## Bearer 鉴权 -配置 `secret` 后,每个请求都必须携带: +配置非空 `secret` 后,除上述 CORS 预检外每个请求都必须携带: ```http Authorization: Bearer vole-runtime-secret @@ -30,8 +86,14 @@ Authorization: Bearer vole-runtime-secret - scheme 必须精确为 `Bearer`,token 必须与当前 session 配置完全一致; - 缺失、重复、格式错误或不匹配都返回 `401 Unauthorized`; - 比较过程不能通过日志、响应或明显的提前返回时序泄漏 token; -- 未配置 `secret` 时不要求 `Authorization`;Controller 不使用 HTTP Basic,顶层 `authentication` 只属于 HTTP/SOCKS5 业务代理。 -- `allow-lan` 只控制业务代理监听,不改变 Controller 的回环限制或 Bearer 鉴权。 +- 省略或空串 `secret` 时不要求 `Authorization`;Controller 不使用 HTTP Basic,顶层 `authentication` 只属于 HTTP/SOCKS5 业务代理。 +- `allow-lan` 只控制业务代理监听,不改变 Controller 的监听地址或 Bearer 鉴权。 + +WebSocket 浏览器可在下述四个升级路径使用唯一的 `token` query;非空 secret 时 token 优先于 Authorization。缺失 token 则沿用相同 Bearer 校验。重复 token 返回 400,错误 token 返回 401;普通 REST 不接受 token 替代 Bearer。token 不进入日志。 + +## `GET /version` + +返回 `{"meta":false,"version":"..."}`。version 是当前 Vole crate 版本,meta 为 false;该接口不宣称自身为 Mihomo,也不增加 Invoke/Windows 协商字段。 ## `GET /traffic` @@ -55,13 +117,13 @@ Authorization: Bearer vole-runtime-secret Apple utun 的四字节包信息头不计入统计。统计只覆盖跨过 TUN L3 边界的包,包括 TUN DNS 和 ICMP;不重复计算 TLS、XHTTP 或其他代理封装,也不包含 HTTP/SOCKS5 代理入站、GeoData 下载、Controller 请求和 `measureDelay`。 -请求不会等待下一次采样,不保持分块响应,不升级 WebSocket,也不为调用方保存“上次读取”状态。首个采样窗口完成前和空闲窗口内,`up`/`down` 为 0;累计值在同一 session 内单调不减并采用饱和语义。没有 TUN 的代理组 Controller 对该路由返回 `404 Not Found`。 +普通 GET 不会等待下一次采样,不保持分块响应,也不为调用方保存“上次读取”状态;升级订阅见下文。首个采样窗口完成前和空闲窗口内,`up`/`down` 为 0;累计值在同一 session 内单调不减并采用饱和语义。没有 TUN 的代理组 Controller 对该路由返回 `404 Not Found`。 TUN 按最多 8 包中有效消费/平台报告成功的包合并记账,取消或错误不会遗漏已经完成的前缀。批次恰好跨越一秒采样边界时,该批字节统一归入记账时的窗口,累计值不受影响;流量快照不是逐包时间戳统计。 ## 代理组查询 -`GET /group` 按 YAML 声明顺序返回全部代理组: +`GET /group` 按 YAML 声明顺序返回代理组,并在末尾包含自动生成的 `GLOBAL`(如未显式声明): ```json { @@ -83,11 +145,60 @@ GET /group/{name} GET /proxies/{name} ``` -- `name` 是组定义名,`type` 固定为 `Selector`; +- `name` 是组定义名,`type` 分别为 `Selector`、`URLTest`、`Fallback` 或 `LoadBalance`; - `all` 是配置中的直接成员,严格保留顺序和重复项; - `now` 是当前选中的直接成员名。若该成员是嵌套组,`now` 仍返回组名,不展开为最终叶节点; - 名称和值大小写敏感并精确匹配,不做大小写折叠或 Unicode normalization。 +`GET /proxies` 返回以名称为键的 `proxies` 对象,包含组状态及节点的 `alive/history`。 +`GET /proxies/{name}` 也可查询节点。history 当前保留最近一次探测,delay 单位毫秒, +time 为 Unix 秒;未探测节点初始 alive 为 true、history 为空。HTTP 健康状态不声明 UDP 可用性。 + +load-balance 根据每条新连接的元数据选择成员,响应不含 `now`,不接受手动选择。业务 UDP 按目标会话固定出口。 + +url-test/fallback 的手动选择在该成员健康时保持固定,失败后恢复自动选择。url-test 使用 tolerance 抑制小幅切换,fallback 按成员顺序选择与恢复。这两类组的 `fixed` 是固定成员名字符串;未固定为空串,select/load-balance 不返回该字段。 + +`DELETE /proxies/{name}` 清除 url-test/fallback 的固定成员,使用已有健康结果重新进行自动选择,并按 profile 设置保存空选择;不主动探测、不改变现有业务路径。成功返回 204,未知名称 404,普通节点、select 或 load-balance 返回 400。 + +## 主动探测与内存 + +`GET /proxies/{name}/delay?url=https%3A%2F%2Fexample.com%2F&timeout=5000&expected=200-299` +通过当前节点或代理组探测 URL,成功返回 `{"delay":12}`,单位毫秒。 +`GET /group/{name}/delay` 使用相同 query,返回成功成员的名称到毫秒映射,并清除自动组的手动固定选择。 +url-test 组采用配置中的测试 URL,其他组采用请求 URL。 +`timeout` 必须传入:节点路由为有符号 16 位毫秒,组路由为有符号 32 位毫秒;非正数立即到期。 +`expected` 可省略,或使用现有状态范围语法。未知目标返回 404,无效请求 400,探测超时 504,节点失败或零 delay 503。 +HTTP 响应完成时即返回延迟,即使状态不符合 `expected`;该 URL 的健康记录会记为失败。 +节点的一般存活状态记录传输是否完成,与配置 URL 的健康状态分开。 + +`GET /providers/proxies/{name}/healthcheck` 使用 Provider 已配置的健康 URL、timeout 和 expected-status, +完成后返回 204;单个成员失败写入共享健康状态,不使整个响应失败。探测使用当前资源代际、DNS 和出口, +排除业务连接和流量统计,不触碰 lazy 活动时间。主动与自动探测共用每代际 10 个并发槽,停止取消所有等待。 +每个节点只保留一份最近手动结果;任意 URL 不增加无界历史。同 URL 的配置探测状态同步更新,供自动组选路使用。 + +`GET /memory` 返回一次 `{"inuse":123456,"oslimit":null}` 快照:Linux/Android 读取自身 `/proc/self/status` 的 VmRSS, +Apple 使用自身 Mach task resident size,Windows 使用当前进程 working set。单位均为字节;读取失败为 null。 +未获取进程内存上限,oslimit 为 null。普通 GET 不启动后台采样;不同平台口径不能直接横比。 + +## WebSocket 订阅 + +在 HTTP/1.1 上使用标准 WebSocket Upgrade;HTTPS 使用 H1 ALPN,现有 H2 请求继续提供普通 REST,不启用扩展 CONNECT。认证和 CORS 复用同一请求处理链,升级响应含适用的 CORS 头。 + +| 路径 | 消息内容 | 发送时机 | +| --- | --- | --- | +| `/traffic` | `up/down/upTotal/downTotal` | 每秒 | +| `/memory` | 与普通 GET 相同的 `inuse/oslimit` | 每秒,直接读取真实值 | +| `/connections` | 与普通 GET 相同的业务快照 | 立即一次,其后按 `interval` 毫秒;缺省 1000,内部接受 100–60000 | +| `/logs` | `type/payload`,或 `format=structured` 时的 `time/level/message/fields` | 有日志时 | + +**WebSocket `/traffic` 使用业务 Registry 的应用负载字节**,与 `/connections` 累计值一致;速率是本订阅实际采样间隔内的字节/秒。普通 GET `/traffic` 仍是 TUN L3 包统计,两者不能混用。Provider 更新不重置业务累计值。 + +`/logs?level=info` 支持 debug/info/warning/error/silent,缺省 info,并受当前运行时 log-level 过滤,debug 同时接收 tracing 的 TRACE 事件。结构化 time 为 UTC 时分秒,warning 转为 warn。日志总线属于当前运行时,独立于宿主 tracing subscriber;完整保留已生成事件的 target、message 和所有字段,包括字符串、错误和 Debug 值,不按敏感字段脱敏或截断内容。它不提供历史回放。 + +HTTP/HTTPS 合计最多 4 个订阅,仍占用既有 8 条连接名额。每条输出消息最多 256 KiB,序列化扩容前检查;connections 订阅至多捕获 512 条引用,超预算关闭当前订阅,普通 REST 快照保持完整。接收缓冲/消息/帧各限 1 KiB,业务 Text/Binary 不作为 Controller 命令;Ping/Pong/Close 按协议处理。写入和控制帧 flush 最多 5 秒,慢客户端只保留当前发送消息,不排队后续快照。 + +日志环仅在有订阅时创建,保留 64 条共享事件,不另设每秒条数限制;队列过载时慢订阅者会落后,最后订阅离开释放历史缓冲。WebSocket 仍受上述消息大小和写入期限约束,超出消息预算时关闭订阅,不发送被截断的事件。无订阅时不格式化日志,也不运行周期采样或日志轮询。Stop 取消并 join 当前连接任务,释放订阅许可与日志接收器。 + ## `PUT /proxies/{name}` 切换组的当前直接成员: @@ -115,43 +226,166 @@ Content-Length: 26 ## 名称与路径 -节点和代理组定义名共享一个命名空间。名称必须为 1–64 UTF-8 字节,首尾不能是任何 Unicode 空白字符,不能含控制字符或 `, # / ? & = % \\`,也不能是 `.` 或 `..`。定义名额外保留 `DIRECT`、`REJECT` 和 `RULES`;前两者仍可作为组成员,`RULES` 只作为 DNS sentinel。内部普通空格、CJK 和 emoji 可以使用。 +节点和代理组定义名共享一个大小写敏感的命名空间,名称为非空 UTF-8 字符串。保留定义名为 `DIRECT`、`REJECT`、`REJECT-DROP`、`COMPATIBLE`、`PASS` 和 `PASS-RULE`;其中 DIRECT/COMPATIBLE 直连,REJECT/REJECT-DROP 按对应拒绝语义执行,PASS/PASS-RULE 属于规则控制。 -路径中的 `{name}` 必须是单个 segment。Controller 对 percent-encoding 严格解码一次,再执行相同的 UTF-8 和名称校验;无效 `%`、无效 UTF-8、解码后的 `/`、二次编码形式、query 和 fragment 都会失败。客户端应对非 ASCII 或路径保留字节执行一次标准 percent-encoding;`+` 不会被当作空格。 +路径中的 `{name}` 对 percent-encoding 严格解码一次;原始路径分隔符、无效 `%` 和无效 UTF-8 会失败。名称中的 `/`、`?`、`#`、`%` 等字符应 percent-encode;`%252F` 表示字面名称 `%2F`,不会再次解码。`+` 保留为 `+`。请求总大小仍受 HTTP 边界约束。 ## 选择生效边界 -代理组成员列表在配置期固定,只有当前选择可在 Running Session 内改变: +静态成员来自配置,动态成员随 provider 成功更新;Controller 读取当前可用的成员和选择: - 新建的物理 TCP 连接使用切换后的当前叶节点;既有 TCP 连接保持原路径; -- 新建的 UDP association/物理 transport 使用切换后的当前叶节点;既有 association 不迁移; +- 新建 UDP 目标会话使用切换后的当前叶节点;既有目标会话不迁移; - 新建的 DNS transport 使用切换后的当前叶节点;DNS cache、singleflight 状态和已在池中的 TCP transport 不清空、不迁移; -- 切换不主动断开连接、不刷新缓存、不重启运行时,也不修改 YAML 或下一次 session 的默认值; -- 选中节点、`DIRECT` 或 `REJECT` 时按原样执行;选中嵌套组时继续解析其当前成员。失败按原样返回,不自动选择其他成员。 +- 切换不主动断开连接、不刷新缓存、不重启运行时,也不修改 YAML;可按 profile 配置保存选择; +- 选中节点、`DIRECT` 或 `REJECT` 时按原样执行;选中嵌套组时继续解析其当前成员。select 的失败按原样返回;自动组根据探测结果选择后续连接的成员。 -`dialer-proxy` 与规则、DNS 共享同一组选择,但经共享连接器直接建链,不重新执行业务规则。上游组的 `DIRECT` 连接当前节点预解析的服务器,`REJECT` 使建链失败。单次建链对同一组只读取一次选择,SOCKS5 UDP 的控制连接和 relay 路径共享该快照;不同组之间不提供原子快照。已经读取旧选择的在途握手继续原路径。复用旧 AnyTLS 会话的新业务流也保留原路径,只有真正新建底层 transport 才读取新选择。 +`dialer-proxy` 与规则、DNS 共享同一组选择,但经共享连接器直接建链,不重新执行业务规则。上游组的 `DIRECT` 连接当前节点按需解析的服务器,`REJECT` 使建链失败。单次建链对同一组只读取一次选择,SOCKS5 UDP 的控制连接和 relay 路径共享该快照;不同组之间不提供原子快照。已经读取旧选择的在途握手继续原路径。复用旧 AnyTLS 会话的新业务流也保留原路径,只有真正新建底层 transport 才读取新选择。 Hysteria2 已认证 QUIC 会话也保留其 `dialer-proxy` 上游组快照,包括复用该会话的新业务流和端口跳跃所需的新物理 socket;这些不是新的认证会话,不能因切组而迁移原路径。只有重新建立的认证会话才读取新的上游组选择。节点本身作为路由组成员时,后续业务的路由选节点仍按当前组选择执行。 +## Provider 资源 + +`GET /providers/proxies` 与 `GET /providers/rules` 返回 `{"providers":{"name":{...}}}`; +`GET /providers/{proxies|rules}/{name}` 查询单个资源,`PUT` 同一路径主动刷新,成功返回 204。 +配置缺失返回 404,下载、解析或候选校验失败返回 503,旧资源继续服务。 + +状态包含 `name/type/vehicleType`、`available`、`downloaded`、`updating`、`updatedAt`、 +`count` 与脱敏的 `error`。`updatedAt` 为 Unix 秒,未成功获取时为空;inline 或 bundled payload +可以在未下载时可用。响应不包含订阅 URL、认证头或节点凭据。 + +HTTP proxy provider 收到 `Subscription-Userinfo` 后另含 `subscriptionInfo` 对象, +字段为 `Upload`、`Download`、`Total`、`Expire`,均为有符号整数;前三项为订阅字节数, +Expire 为 Unix 秒。未知时省略该对象,缺失子项为 0;不以本地业务流量代替订阅信息。 +数值接受十进制整数、有限小数和科学计数法,小数向零截断;非法或超范围子项忽略。 +成功候选或合法 304 才更新元信息;未带该头时保留旧值,失败更新保留已发布对象。 +缓存恢复只接受与精确 URL、实际请求 header/UA 的摘要及内容 hash 一致的元信息。 + +更新将节点、组和规则作为完整候选检查,发布时保留仍存在的当前选择。 +既有会话持有旧资源,最后一个使用者释放后回收;下载通过当前可用的运行配置建立出口, +不等待候选资源。空动态组使用配置的 `empty-fallback`,默认 COMPATIBLE(直连)。 +组响应另含 `hidden` 与 `icon`,`disable-udp` 会拒绝该组的业务 UDP。 + +## 业务连接和诊断 + +`GET /connections` 返回 Mihomo 对应字段 `uploadTotal`、`downloadTotal`、`connections`、 +`memory`。业务连接包含 `id`、`metadata`、`upload`、`download`、`start`、`chains`、 +`providerChains`、`rule`、`rulePayload`。时间使用 RFC 3339;端口为字符串。 + +- 只统计 TUN、HTTP、SOCKS5 的业务 TCP/UDP 负载,排除 TUN DNS 拦截、ICMP、Controller、 + Core 资源下载和健康探测。累计值在本次 Running Session 内保留,provider 更新不会重置。 +- TCP 为一条业务流,UDP 为同一来源与目标的会话;各目标独立计数、取消并固定所选出口。 + XUDP 对同一来源、同一具体节点和未改写的 IP 目标共享物理关联,其它路径保留独立 transport。 + 任一目标退出后,该共享关联仅服务既有目标;新目标使用新关联,避免旧回复串入新会话。 + 会话超时、关闭或新的 QUIC flow-id 后重新选择出口。资源代际不会迁移既有会话。 +- `chains` 按叶节点到业务路由组排列;当前不展开节点内部的 `dialer-proxy` 协议链。 + `providerChains` 给出这些节点关联的 provider 名称,可与 provider 状态中的脱敏 `error` 对照。 +- `metadata` 提供来源、目标、入口、协议、嗅探域名与 `dnsMode`;`uid`、`process`、 + `processPath` 来自本次业务选路实际取得的本机身份,未知分别为 0、空串、空串。 + Controller 查询不追加身份查询,是否获取沿 `find-process-mode`;不查询 ASN。 + 扩展字段 `dnsPath` 是当前建链任务实际经过的有界 DNS 阶段标签,例如缓存、policy、 + fallback、fake-IP 与解析角色;不包含上游地址、URL 或认证头。 +- 扩展字段 `errors` 最多保留最近 32 次建链/数据报失败的连接信息与固定错误码, + 不返回底层错误原文。`memory` 是本次请求采集的当前进程 resident bytes,不可用时为 null。 + +连接元数据保存原生地址、端口和时间,重复的节点、组及规则文本共享存储。 +查询只在短锁内取得连接引用,再固定计数和 DNS 路径并直接序列化;快照不保活 +业务 I/O 或资源代际。字节计数即时饱和累计,零字节 I/O 不更新计数。 + +入站首次改写前保存不可变原目标、真实 socket tuple、入口及认证身份;嗅探、DNS、 +上游代理链不会覆盖它。Controller 沿现有字段展示有效目标;有效目标仍是未解析域名时, +`destinationIP` 可取保存的原 IP。TCP 身份核查使用实际入站 socket,HTTP CONNECT 的 +业务目标不冒充入站 socket 的本地端点;UDP 身份按当前目标流获取,不按数据包建快照。 +未启用 Controller 的 mixed UDP 保留原有逐包选路;其进程查询按该次决策执行, +原始事实使用栈值,不逐包分配共享快照。TUN 与 Controller 管理的 UDP 流沿现有流缓存。 + +`DELETE /connections/{id}` 取消指定连接,`DELETE /connections` 取消当前全部连接。 +均返回 204,未知 ID 同样返回 204;取消唤醒正在等待的 I/O 并释放对应流资源。 +这不禁止用户重新建连,也不重置累计流量。正常 stop 会取消全部连接并沿既有停止屏障回收。 + +`GET /rules` 返回 `rules` 数组,包括规则索引、类型、payload、目标及命中次数和最后命中时间。 +只列出主规则。普通 SUB-RULE 命中归入主调用行;rematch 选中的独立子列表没有主规则索引, +不增加任意主行的命中计数,其业务连接的 rule/rulePayload 为空。返回主列表后按实际命中行记录。 +计数属于当前资源代际,只记录业务的新选路,不将每个 UDP 包重复计为一次选路。 +没有采集未命中次数、GeoData 记录大小;`size` 为 -1。规则列表反映当前有效路由模式, +`extra.disabled` 来自与实际路由共用的一份临时开关快照。 + +`PATCH /rules/disable` 接受 JSON 索引到布尔值对象,例如 `{"0":true,"2":false}`。 +`true` 跳过该主规则,`false` 恢复;跳过 SUB-RULE 入口时不会进入其子规则。 +请求要求唯一 `Content-Length` 和严格 `application/json`,正文最多 1 KiB,不接受 chunked。 +索引必须是当前列表范围内的非负整数,值只能是 bool;重复索引、未知索引、非法 JSON 或多份 JSON +使整请求返回 400,任何规则都不改变。空对象返回 204。有效请求一次原子发布,成功返回 204。 +并发请求在提交时合并到当时的开关状态,不丢失其他索引已经成功提交的改动;同一索引以最后提交为准。 +新选路取得一个快照,后续 DNS/进程惰性查询继续沿用该快照;已建立 TCP 和 UDP 会话保留原路径。 + +临时开关不修改 YAML,也不持久化。任何新规则程序编译与资源代际发布(含 provider 更新或配置替换) +都清除临时开关,失败候选保留原状态。请求只在一个当前快照内校验并应用;接口没有 generation 字段, +旧列表中的数字索引在重载后不保证仍表示同一规则,调用方应在资源变更后重新获取列表。 +开关使用不可变紧凑位集,最多 8 KiB 位数据,首次实际禁用时才分配;诊断只共享开关状态,不保活整个程序。 + +`GET /configs` 返回归一化后的运行设置:mixed 入口、mode、log-level、IPv6、sniffing、 +连接选项、profile 和 TUN 字段。它使用字段白名单,不提供完整 YAML、节点凭据、订阅位置、 +认证头、Controller secret 或认证用户列表。在线修改使用下文的 +[`PATCH /configs`、`PUT /configs`](#patch-configsput-configs),受候选准入与需重连条件限制。 + +`GET /dns/query?name=example.com&type=A` 使用当前上游 resolver 与相同缓存查询, +接受 Mihomo Controller DNS 类型表的精确名称,包括 A、AAAA、TXT、MX、SRV、HTTPS、SVCB 和 DNSSEC 类型; +类型名称区分大小写,省略或空 type 使用 A,不接受数字或 `TYPE1234` 形式。重复、未知 query 参数返回 400。 +响应保留 `Status/Question/Answer/TC/RD/RA/AD/CD` 和扩展 `QueryPath`,非空的权威与附加段另含 +`Authority/Additional`。每条记录提供 `name/type/TTL/data`,名称包含末尾点,TTL 使用本次响应的实际值, +RDATA 使用 DNS 展示格式;未知记录使用 RFC 3597 十六进制形式。Answer 保持数组,空权威与附加段省略。 +与 Mihomo 一样,此诊断接口跳过入口的 hosts、fake-IP 与 IPv6 响应过滤,返回上游真实答案; +policy 与 fallback 仍生效。展示借用现有最多 4096 字节 wire 响应,不建立后台记录树或查询日志, +JSON 转义和名称展开后的正文最多 256 KiB。DNS 关闭、查询失败、无效或截断的 wire 响应、输出超限返回 500, +不以部分记录或伪造 TC 标记返回成功;该展示层不另加记录数量限制,上游仍沿用 DNS 解析预算。 + +这些接口复用 Controller 的 Bearer 鉴权。目标与 DNS 路径只通过显式查询返回, +不会写入普通日志。`/connections` 与 WebSocket `/traffic` 是业务负载统计,普通 GET `/traffic` 表示 TUN 原始 IP 包流量, +`/proxies` 的节点健康数据继续表示探测结果,三者不可相互替代。 + +`POST /cache/dns/flush` 清除当前资源代际所有 DNS 角色的普通地址、负响应、原始响应缓存和 +域名提示,成功返回 204;DNS 未启用时同样返回 204。旧在途查询可以完成,但不能回填 +清理后的缓存,新查询不会复用这些旧查询。fake-IP 映射和活动业务会话保留。 + ## HTTP 状态与协议边界 | 状态 | 含义 | | --- | --- | | `200 OK` | 查询成功,返回 `application/json` | -| `204 No Content` | 选择成功,正文为空 | -| `400 Bad Request` | 路径、JSON、长度格式或成员无效 | +| `204 No Content` | 选择、规则开关、配置应用、刷新或清理成功,正文为空 | +| `400 Bad Request` | 路径、JSON、长度格式、成员或候选配置无效 | | `401 Unauthorized` | Bearer 鉴权失败 | -| `403 Forbidden` | 请求来源不是回环地址 | -| `404 Not Found` | 路由、组或当前 session 中的 TUN 流量资源不存在 | +| `404 Not Found` | 路由、组、当前 session 的 TUN 流量或配置应用资源不存在 | | `405 Method Not Allowed` | 路由存在但方法不允许 | | `408 Request Timeout` | header 或 body 读取超时 | -| `411 Length Required` | PUT 缺少 `Content-Length` | -| `413 Payload Too Large` | PUT 正文超过 1 KiB | -| `415 Unsupported Media Type` | PUT 不是严格 `application/json` | +| `409 Conflict` | 在线配置修改涉及需重连的启动资源 | +| `411 Length Required` | 管理写请求缺少 `Content-Length` | +| `413 Payload Too Large` | 管理写请求正文超过对应路径预算 | +| `415 Unsupported Media Type` | 管理写请求不是严格 `application/json` | +| `503 Service Unavailable` | 探测、资源更新或候选准备失败,订阅准入已满,配置应用忙碌或已停止 | +| `504 Gateway Timeout` | 主动探测或配置应用等待超时 | -第一版不提供 provider/`use`、health check、自动 failover、`url-test`/`fallback`/`load-balance`、delay 测试、连接管理、配置修改、WebSocket 推送、Controller 版本协商或完整 Dashboard response。除 `GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}` 外,其他路径和方法均不属于公共协议。 +公开范围为本文列出的版本、连接、规则、配置查询与在线应用、DNS、主动探测、内存、provider、代理组查询/选择/固定清除,以及四个 WebSocket 路径。它不承诺完整 Dashboard response;其他路径和方法不属于公共协议。 Windows WinRT VPN 的完整运行时和 Controller 位于独立 Session Host;桌面 Wintun 的 -Controller 位于普通业务运行时。宿主通过回环 HTTP 直接访问实际 Running Session; +Controller 位于普通业务运行时。宿主通过配置的 HTTP 地址直接访问实际 Running Session; 组查询和切换不经过 `VoleInvoke`、Windows bridge 或 Provider 控制管道, 不携带 `instanceId`,也不占用 Invoke 命令锁。 + + +## `PATCH /configs`、`PUT /configs` + +配置应用与 Invoke `updateConfig(configYaml)` 共用一个候选许可和运行时 worker,不更改持久 YAML。`Content-Type` 必须为 `application/json`,必须提供唯一有效的 `Content-Length`,不接受 chunked body。Bearer 与 CORS 沿用普通 REST 规则;无 query 参数。 + +PATCH 仅接受 `mode`、`log-level`、`sniffing`、`tcp-concurrent`、`find-process-mode`、`interface-name`。`sniffing` 是 Mihomo Controller 的 API 字段,对应配置的 `sniffer.enable`;不向 YAML 增加同名旧字段。其余字段和值由严格配置解析器验证;未知字段失败。PUT 只接受 `{"payload":"完整 Mihomo YAML"}`,不接受任意本地路径。成功返回 204。 + +候选基于新的本地可用 provider 内容完整构图后发布,HTTP provider 继续遵循既有本地缓存/bundle/后续更新语义,204 不表示所有远端订阅都已同步刷新。配置、图与 provider 模板一起替换;后续订阅更新使用新模板。坏配置保留活动代际;已建立业务保持其原代际,新连接取得新代际。可继承且仍存在的组选择保留。 + +地址、监听/TUN、全局 IPv6、TLS 根与 Controller 证书、安全配置、fake-IP 范围、profile 存储和系统网络相关启动资源需要宿主重新连接,在线更改返回 409。Linux 原生 auto-route 持有的物理出口隔离同样不在在线重建范围。在线 DNS 策略/hosts 改动在共享缓存 epoch 下清缓存与域名提示;退休 DNS 树继续服务其已持有业务,但不能重新填充活动域名提示。fake-IP 映射保持。 + +许可在扩展请求 body 和解析前取得,忙时返回 503。解析错误返回 400,准备失败返回固定脱敏 503;应用等待上限 90 秒,超时返回 504,并取消候选。等待 future 被取消会取消候选;普通请求半关闭不代表取消。已开始的阻塞任务继续由 worker 等待收尾,并持有候选许可;返回错误或超时不承诺所有收尾已同步完成。Stop 关闭准入、取消候选并等待完整 worker 收尾。没有运行时资源的 Controller 返回 404。 + +`POST /configs/geo` 强制检查当前所需的 GeoData,复用现有下载、ETag、校验和原子发布; +不下载未被活动注册引用的资产。`POST /cache/fakeip/flush` 清除 DNS 结果及 fake-IP 前向分配绑定, +保留反向映射和分配游标,已有地址仍受原池的容量与正常复用约束;持久化事务完成后才改内存。 +两者不接受正文,成功为 204;与配置应用共用单候选许可,Stop 等待已开始的文件事务和管理任务。 diff --git a/docs/geodata.md b/docs/geodata.md index 8b71d18..259fed8 100644 --- a/docs/geodata.md +++ b/docs/geodata.md @@ -1,11 +1,17 @@ # GeoData 规则与资产 -Vole 管理 `dataDir/geodata` 下的 `geosite.dat` 和 `geoip.dat`,只为当前配置实际引用的 -分类与属性交集构建匹配器。所有平台均不设 GeoData 条数上限,不按数量截断记录, +Vole 管理 `dataDir/geodata` 下的 `geosite.dat`、DAT 模式的 `geoip.dat` 和独立 `ASN.mmdb`, +国家 MMDB 位于 `dataDir` 根目录。`geodata-mode` 缺省为 false(MMDB),true 使用 DAT。 +只为当前配置实际引用的 +分类与属性交集构建匹配器。主规则和所有 `sub-rules` 定义中的谓词、逻辑子项及 +classical provider 内容共同收集引用;每个定义只遍历一次,不沿调用边展开。所有平台均不设 GeoData 条数上限,不按数量截断记录, 也不设总内存预算。资产缺失或加载失败时,对应种类不可用,跳过相应 Geo 规则并继续 普通选路;卸载前的下载或结构校验失败保留当前快照。配置、格式、整数表示和 可恢复分配错误仍然失败关闭。 +分类加载与 CIDR 无损压缩完成后回收自有数组和字面值 arena 的扩容余量;保留全部 +记录、属性交集和正则。容量账本计入可能的旧、新缓冲重叠,实际 RSS 仍由 allocator 决定。 + ## 规则 ```yaml @@ -21,25 +27,33 @@ rules: ```text GEOSITE,[!][@...], -GEOIP,[!],[,no-resolve] +GEOIP,[!],[,no-resolve][,src] +SRC-GEOIP,[!], +IP-ASN,,[,no-resolve][,src] +SRC-IP-ASN,, ``` - 规则类型和 `code` 不区分 ASCII 大小写,route target 名称大小写敏感并精确匹配。 -- `target` 只能是 `DIRECT`、`REJECT`、实际代理节点名或静态 `select` 组名。 +- `target` 可以是 `DIRECT`、`REJECT`、当前资源中的代理节点或已支持的代理组。 - 基础 `code` 必须匹配 `[A-Za-z0-9][A-Za-z0-9._+!-]{0,63}`,按 ASCII 大小写折叠后定位。 -- `GEOSITE` 恰好三段;`GEOIP` 只允许可选且大小写敏感的 `no-resolve`。 +- `GEOSITE` 恰好三段;`GEOIP` 接受大小写敏感的 `no-resolve`、`src`,顺序无关。 + `src` 和 `SRC-GEOIP` 使用真实来源 IP,不触发 DNS。 - `GEOSITE,cn@ads@cn,DIRECT` 只保留同时存在 `ads` 和 `cn` 属性 key 的记录,多个属性为 AND。 属性只检查 key 存在,不解释 bool/int 值;`false`、`0` 或无 typed value 均不代表 key 缺失。 - 属性名按 Mihomo 的先小写、后 Unicode simple-fold 比较语义归一;交集顺序与重复 key 不改变 结果。空 `@` 项忽略,非空属性首尾空白移除,但内部空白、控制字符和逗号不允许。 - 前置 `!` 对整个正向匹配结果取反:`!cn@ads` 是 NOT(匹配 `cn@ads`),不是“没有 ads 的记录”。 `cn@!ads` 查找字面 key `!ads`;`geolocation-!cn` 是字面分类名。 -- GeoIP 支持前置 `!`,不支持属性。业务规则不支持单条多个 code、源地址 GeoIP 或隐式局域网分类。 -- 规则按顺序首条命中,最终 `MATCH` 必须指向实际代理节点或代理组。 +- GeoIP 支持前置 `!`,不支持属性或单条多个 code。`lan` 是内置分类:私有、未指定、 + 回环、多播与链路本地单播地址;来源与目标均可使用,不读取 GeoIP 资产。 +- `IP-ASN` 的 payload 逐字匹配 ASN 十进制值,不转换 `AS` 前缀或补零; + `src` 和 `SRC-IP-ASN` 使用实际来源 IP,不触发 DNS。不可用的 ASN 不触发惰性 DNS。 +- rule 模式按顺序首条命中,未命中 DIRECT;可选的 `MATCH` 使用 `DIRECT`、`REJECT`、实际代理节点或代理组。 DNS `nameserver-policy` 中的 `geosite:[,...]` 接受相同的属性与反选语义, 逗号连接的 selector 为 OR。业务规则与 DNS policy 共享基础分类、value arena 与正则, 每个基础分类只解码一次;不同属性交集保留各自匹配索引,正反选不复制记录。 +普通域名与通配域名 policy 不加载 GeoData,其匹配顺序见 [DNS Policy](tun-icmp-dns.md#policy)。 存在分类但属性筛选为空时,该 selector 仍可用:正选不匹配,反选匹配所有合法非空域名。 分类缺失、资产不可用或未声明的 selector 不能通过反选变成全匹配;没有域名时所有 GeoSite @@ -48,59 +62,120 @@ Unicode 版本的 Mihomo 在所有新字符上完全等价。 ## 资产格式 +默认资产和统一验证来源采用 [MetaCubeX/meta-rules-dat 官方完整资产](https://github.com/MetaCubeX/meta-rules-dat#readme), +保留原始文件,仅按配置引用加载分类。 + | 规则 | 文件 | 顶层消息 | 内容 | | --- | --- | --- | --- | | `GEOSITE` | `geosite.dat` | `GeoSiteList` | code 与 Domain 记录 | | `GEOIP` | `geoip.dat` | `GeoIPList` | code 与 IPv4/IPv6 CIDR | - -- 文件路径固定,YAML 不能指定本地路径或环境变量。 -- 文件必须是普通文件;校验外层帧、code、分类引用、实际选中的 CIDR / Domain 记录。 -- 第一遍扫描建立 code 到文件范围的索引,容量按需增长;第二遍只解析被配置引用的分类。 +| `GEOIP`(默认模式) | 根目录 `country.mmdb`、`geoip.db` 或 `geoip.metadb` | MaxMind DB | 直接 IPv4/IPv6 国家/分类索引查询 | +| `IP-ASN` / `SRC-IP-ASN` | 上游 `GeoLite2-ASN.mmdb`,管理名 `ASN.mmdb` | MaxMind DB | IPv4/IPv6 ASN 索引 | +| `RULE-SET` | `*.mrs` | Zstd 压缩的 MRSv1 | `domain` / `ipcidr` rule provider,与 GeoData 分别配置 | + +增强 GeoSite 仍使用相同的 DAT 格式,支持四种 Domain 类型与属性标签,不按提供方区别加载。 +MRS 不能直接替代 GeoSite/GeoIP 资产;sing-box 的 `geosite.db` 与 SRS 不是 Mihomo 的 GeoSite 输入格式。 + +- GeoData 的 YAML 字段不能指定本地路径或环境变量。MMDB 在 `dataDir` 的直接子文件中按文件名字典序, + 不区分大小写寻找 `Country.mmdb`、`geoip.db`、`geoip.metadb`,选择第一个并保留实际名称; + 不因它损坏而改选另一个数据库。缺省下载目标为 `geoip.metadb`。 +- 所有资产必须是普通文件;DAT 校验外层帧、code、分类引用、实际选中的 CIDR / Domain 记录。 +- DAT 第一遍扫描建立 code 到文件范围的索引,容量按需增长;第二遍只解析被配置引用的分类。 - 只引用属性交集时,先流式检查属性;未命中任何交集的记录按 wire/length 边界跳过, 不校验 value 或编译 Regex。引用无属性 selector 时则包含该分类的全部记录。 -- 目标 code 缺失、重复或损坏时,整份对应种类的快照不可用,不能当作空分类。 -- GeoSite 和 GeoIP 分别校验和报告可用性,一类不可用不影响基础规则。 +- DAT 目标 code 缺失、重复或损坏时,整份对应种类的快照不可用,不能当作空分类。 +- GeoSite、GeoIP 和 ASN 分别校验和报告可用性,一类不可用不影响其他种类或基础规则。 + +## 国家 MMDB + +国家库支持 MaxMind `country.iso_code`、`sing-geoip` 根字符串和 `Meta-geoip0` 根字符串/字符串数组。 +`.db`、`.metadb` 和 `.mmdb` 是文件命名;实际 schema 由 MMDB metadata 识别,不使用 SQLite。 +MaxMind 国家代码按 ASCII 大小写折叠,sing/Meta 保留原字符串大小写。国家库与 DAT 二选一, +GEOIP、来源规则、classical/地址选择器及 DNS fallback 共用一个国家库快照。 +MaxMind/sing 使用 owned Reader,查询借用字符串。Meta 完整校验后优先将配置引用的 +分类投影为与 DAT 共用的紧凑 CIDR,正反选择器共享分类,保留多 code、IPv4/IPv6、 +别名地址及无记录地址的语义;构建完成后释放原始文件缓冲,磁盘资产保持原样。 +投影不更省内存、遍历不适合或记录无法按 Meta schema 解码时,保留原 Reader, +不截断记录或拒绝原本可用的库。Reader 路径的多 code 临时索引只保存字符串引用。 +不建立逐地址缓存。缺库、坏索引和无效记录 +不通过反选成为全匹配;有效库的无记录地址按空 code 集合处理。 +保留 maxminddb 的默认单次解码与索引验证保护;解码错误不当作合法的空记录。 +官方 `sing-geoip` / `Meta-geoip0` 的空 description 可加载:只在元数据图已完整验证且 +唯一错误为描述为空时,以临时描述完成原库的全部索引与数据校验,再恢复原字节和元数据。 +此兼容不改写磁盘资产,不放宽指针、separator 或数据图验证。 + +国家 MMDB 更新与 ASN 同样先借用候选字节完整校验,旧读者排空后构建国家库表示, +或将同一缓冲移动到 owned Reader; +无效候选、取消或提交失败保留/恢复旧代。模式和实际文件名共同绑定调度来源与 ETag/hash, +下载暂存及原子替换始终使用被选中的实际路径,GeoSite/ASN 的存储与状态目录不变。 + +## ASN MMDB + +仅在规则或 classical 内容引用 ASN 时读取 `ASN.mmdb`。支持数据库类型 +`GeoLite2-ASN`、`DBIP-ASN-Lite (compat=GeoLite2-ASN)` 和 +`ipinfo generic_asn_free.mmdb`;前两者读取 `autonomous_system_number`,后者读取 +`asn` 并移除其 `AS` 前缀。合法的数值型数据库无记录时按 ASN `0` 匹配;数据库 +不可用、索引错误或记录字段无效不会被当作 ASN `0`。IPinfo 无记录不匹配。 + +使用一个拥有文件字节的 MMDB Reader,直接查询 IPv4/IPv6 索引,不展开 CIDR,也不 +建立按地址增长的缓存。查询只解码所需字段,不复制完整记录。ASN 与目标 GEOIP 共用 +既有惰性 DNS 路径;`no-resolve` 不主动解析,但可使用先前规则已解析的地址。 + +更新先以唯一候选字节缓冲构造借用 Reader,完整校验 metadata、树、separator 和 +数据图;校验失败时保留当前内存与磁盘代次。校验期间旧文件字节与候选字节必然重叠, +验证还会临时分配第三方 metadata 和图检查状态。借用 Reader 释放后,暂停新快照读取、 +等待旧读者结束;超时或取消时恢复旧快照。旧存储析构后才将同一候选缓冲移入 owned +Reader,重建 metadata,避免另一份全量候选复制。 + +ASN 提交保留旧磁盘文件的 hard link(不可用时复制);提交失败先丢弃候选内存,再 +恢复旧稳定文件和调度状态并重新加载旧匹配器。恢复磁盘或状态遇真实 I/O 错误时报告 +该错误;不能恢复旧文件时 ASN 降级,其他健康种类继续可用。停止仍等待管理任务完成。 +普通 DAT 更新与外部重载保留后文的独立降级契约。 ## 自动更新 -更新配置必须整体出现或整体省略: +HTTP 传输由共享资源模块维护,所有跳转经过当前 Core dispatcher。下载、管理阻塞任务和解析均在停止屏障内;取消及下载 future 被丢弃时清理未发布的暂存文件。 + +四个 URL 字段独立可选。缺省使用 MetaCubeX meta-rules-dat 的 latest GeoIP/GeoSite DAT、 +国家 `geoip.metadb` 与 +`GeoLite2-ASN.mmdb` URL;自动更新默认关闭,周期默认 24 小时: ```yaml +geodata-mode: true # DAT;省略或 false 使用国家 MMDB geox-url: geoip: https://assets.example.com/geoip.dat + mmdb: https://assets.example.com/geoip.metadb geosite: https://assets.example.com/geosite.dat + asn: https://assets.example.com/ASN.mmdb geo-auto-update: true geo-update-interval: 24 ``` 约束: -- `geox-url` 只能包含 `geoip` 和 `geosite`;URL 必须是以域名为主机、无 userinfo/fragment 的绝对 HTTPS URL。 -- `geo-update-interval` 只接受整数 `24`。 -- `validateConfig` 和 `prepare` 不下载。实例启动后,只有自动更新已开启且规则实际需要资产时才运行更新任务。 -- 下载固定使用最终 `MATCH` route target。若它是代理组,每次新建下载物理 transport 时沿嵌套组解析到当时选中的具体节点、`DIRECT` 或 `REJECT`;失败不自动换成员,也不隐式回退 DIRECT 或系统代理。 -- 缺失资产立即检查;失败后按 1 分钟、5 分钟、15 分钟、1 小时退避,之后保持 1 小时上限。成功后恢复 24 小时周期。 -- ETag、SHA-256、源 URL 和下次检查时间保存在 Vole 管理的状态中。合法 304 在匹配器 +- `geox-url` 只能包含 `geoip`、`mmdb`、`geosite` 和 `asn`;URL 必须是以域名为主机、无 fragment 的绝对 HTTPS URL。 + URL userinfo 按百分号编码解码后用于 HTTP Basic,不进入 Host、请求路径或日志;跨 origin 跳转不转发原认证。 +- `geo-update-interval` 单位为小时,支持非负整数;0 停用周期更新。 +- `validateConfig` 和准备阶段不下载。运行后仅为当前引用的资产启动资源任务;缺失或不可用时获取默认或配置的 URL,自动更新控制可用资产的周期检查。 +- 下载按当前 mode 与业务规则选路,未命中使用 DIRECT;可选目标包含内置动作、节点和代理组。选中代理组时,每次新建下载物理 transport 时沿嵌套组解析到当时选中的叶节点;失败不自动换成员,也不隐式回退 DIRECT 或系统代理。 +- 缺失资产立即检查;失败后按 1 分钟、5 分钟、15 分钟、1 小时退避,之后保持 1 小时上限。成功后恢复配置的更新周期。 +- `global-ua` 控制资源请求的 User-Agent,默认 `Vole/` 加 Core 版本;`etag-support` 默认 true,false 时不发送条件请求。ETag、SHA-256、精确来源 URL 的摘要、脱敏 origin 和下次检查时间保存在 Vole 管理的状态中。 + 身份摘要包含认证和 query,改变来源不会复用另一份凭据的 ETag。旧无凭据来源可导出摘要;旧带凭据元数据不授权 304。 + 合法 304 在匹配器 已可用时只推进调度;有必需种类不可用时,先按相同重载流程从本地资产恢复匹配器。 -- 下载以固定大小缓冲流式写入同目录暂存文件,不限制资产总字节;保留 90 秒期限、取消、HTTPS/HTTP 解析边界和字节计数溢出检查。流式落盘期间保留当前快照;文件大小、wire 结构和 staging 检查通过后,先卸载并排空旧匹配器,再构建候选。分类需求校验成功后原子替换文件,SHA-256 随状态保存;失败保留上一份磁盘资产,不保证恢复旧内存快照。 +- 下载以固定大小缓冲流式写入同目录暂存文件,自动解压 gzip 并校验完整性;SHA-256 与字节计数针对解压内容,不限制资产总字节。 + 保留 90 秒期限、取消、HTTPS/HTTP 解析边界和字节计数溢出检查。流式落盘期间保留当前快照;文件大小、wire 结构和 staging 检查通过后,先卸载并排空旧匹配器,再构建候选。分类需求校验成功后原子替换文件,SHA-256 随状态保存;失败保留上一份磁盘资产,不保证恢复旧内存快照。 - 跨进程更新锁和原子重命名只保护共享数据目录,不提供多实例调度协议。 -隔离实验可显式编译独立、非默认 `benchmark-geodata-http` feature,使 GeoData 更新 -接受以域名为主机、无 userinfo/fragment 的 HTTP fixture;仍通过同一个 dispatcher 和 -流式下载器,不建立 TLS。HTTP 重定向可保持 HTTP 或升级到 HTTPS,HTTPS 不允许降级。 -该入口没有环境变量或新的 YAML/FFI 开关;未编译该 feature 时仍强制 HTTPS,HTTPS 的 -公开根和完整证书验证不变。平台构建及 delivery 拒绝该测试 feature。HTTP 实验只验收 -更新逻辑与资源占用,不构成生产 HTTPS 信任链验收,不应启用 `interop-test`。 - ## GeoSite 匹配 TUN 的 IP 目标采用以下选路提示优先级: ```text -嗅探域名 > TUN DNS 提示 > 无域名 +嗅探域名 > 原始/恢复的域名 > DNS 提示 > 无域名 ``` -目标本来是域名时使用目标域名,不受提示覆盖。嗅探和 DNS 提示只参与选路,不改写 +DNS 提示只在目标缺少域名时使用;嗅探域名优先参与域名规则。启用嗅探的目标覆盖时替换 实际出站目标。没有域名时,`GEOSITE` 不匹配,也不触发 DNS。 运行时目标、嗅探和 DNS 提示的域名规范化(不是资产加载条件): @@ -148,6 +223,11 @@ Domain/Full 在加载时原地排序紧凑记录,运行时对完整名称及 不再承诺 Regex 热路径无分配。Domain/Full 的紧凑无分配查找保持不变。 库的局部保护不是全部正则合计内存或整进程峰值的上界。 +GeoSite 正则保持上述大小写敏感的 Go/RE2 类行为,不使用 `DOMAIN-REGEX` 的默认 IgnoreCase +或复杂引擎。业务规则、DNS selector、fake-IP filter 与嗅探决策把其线性搜索计入同一 +sticky 执行预算;预算失败向调用者传播,不能通过反选变为命中或默认直连。此预算限制 +单次决策的执行工作,不限制 GeoData 记录数量、加载量或资产总内存。 + ## GeoIP 与 `no-resolve` - 目标已经是 IP 时直接匹配同地址族 CIDR,`no-resolve` 不改变结果。 @@ -155,7 +235,8 @@ Domain/Full 在加载时原地排序紧凑记录,运行时对完整名称及 - 之后首条不带 `no-resolve` 的 IP 类规则最多触发一次运行时 DNS,结果只供当前和后续规则复用。 - A 结果先于 AAAA;第一个命中 CIDR 的有效地址成为当前会话或数据报的固定目标。 - `dns.ipv6: false` 时不请求或采用 AAAA。 -- 运行时 DNS 不可用或失败时继续下一条规则,不使用引导解析器替代。 +- 运行时 DNS 不可用或上游失败时继续下一条规则,不使用引导解析器替代; + 规则执行预算、取消或引擎错误终止本次决策。 - `GEOIP,!cn,...` 对同一次 CIDR 匹配结果取反,不执行反向 DNS。DAT 的 `reverse_match` 字段按 Mihomo loader 行为接受但忽略;是否取反只由 selector 的前置 `!` 决定。 @@ -167,6 +248,7 @@ Domain/Full 在加载时原地排序紧凑记录,运行时对完整名称及 - 更新在下载和 staging 检查期间继续使用当前快照。构建新匹配器前,原子发布空匹配器、 释放 Manager 的旧快照引用,并等待最后一位旧选路读者完成、旧匹配器存储析构结束。 此后只构建一次候选;磁盘与调度状态提交成功后直接原子发布,不从最终文件重复加载。 + 国家 MMDB 与 ASN 使用前文的完整预校验与失败恢复流程。 - 注册、重载与更新提交通过 Manager 管理锁串行,避免旧重载覆盖新发布;业务和 DNS 的 ArcSwap 读取不获取该锁,也不等待排空。旧读者继续完成本次求值;排空、加载期间 新请求复用首次资产缺失的语义,仅跳过不可用的 Geo 规则,继续普通规则与既有 DNS @@ -175,7 +257,7 @@ Domain/Full 在加载时原地排序紧凑记录,运行时对完整名称及 取消不构建或发布新候选。后台状态观测、开始更新、提交和 304 恢复均移出业务 worker, Stop 取消后仍等待管理任务结束,不遗留后台发布。已开始的 CPU 加载不能中途强制 中断;结束后检查取消并丢弃候选,因此 25 ms 不是完整 Stop 的耗时保证。 -- 重载按已注册的 selector 重建属性索引与匹配器,两类可用性独立。卸载后的所选记录 +- 重载按已注册的 selector 与 ASN 需求重建匹配器,三类可用性独立。卸载后的所选记录 加载失败使对应种类保持不可用,仍发布另一类健康数据;不重新安装旧整体快照。 更新候选无效时保留旧磁盘文件并记录失败,后续继续按现有调度重试。 - 重载首次取消检查前先记录待重载状态。卸载前取消保留旧匹配器;即使新磁盘代次 @@ -183,7 +265,8 @@ Domain/Full 在加载时原地排序紧凑记录,运行时对完整名称及 排空超时或卸载后取消保持空匹配器及待重载状态,自动更新关闭时亦可恢复。 已发布的独立降级结果不标记为待重载,失败种类保持不可用,直到下一次明确重载或更新。 - 新流使用当时的可用快照;已经完成的 TCP 选路和 TUN UDP 固定 action 不回溯。 - 当前完整重建所需两类数据,不跨代复用分类存储;排空防止两代 matcher 存储同时存活, + 当前完整重建所需的各类数据,不跨代复用分类存储;排空防止两代 owned matcher 同时存活, + ASN 校验期的字节重叠见上文, 不保证 allocator 立即归还 RSS,也不限制单代加载峰值。 - 分类表在加载时按 code 排序,运行时大小写无关二分定位;规则仍按配置顺序首命中。 每次业务选路捕获一份当前不可变快照,整次求值复用,不在每条 Geo 规则上重复读取 ArcSwap。 @@ -208,8 +291,8 @@ Domain/Full 在加载时原地排序紧凑记录,运行时对完整名称及 所有平台均不设置 GeoData 原始记录、分类、唯一引用、累计 value/Regex 源码字节或 资产文件大小的独立额度,也不设置 GeoData 总内存预算;不因数量静默截断或丢弃记录。 -DNS GeoSite policy 没有独立项数/引用数上限。普通 YAML 256 KiB、显式 rules 1,024 条/ -合计 128 KiB/单条 1 KiB 仍是配置入口边界,不是 DAT 内记录数限制。 +DNS GeoSite policy 没有独立项数/引用数上限。普通 YAML 8 MiB、显式 rules 65,536 条/ +合计 8 MiB/单条 16 KiB 仍是配置入口边界,不是 DAT 内记录数限制。 基础分类、字面值和正则只存一份,属性交集使用共享 arena 的紧凑索引;多个交集仍会 增加索引容量与加载成本,不能把共享存储理解为内存常量。保留 checked arithmetic、 @@ -221,19 +304,21 @@ fallible Vec 扩容、合法 protobuf 帧/长度、code、选中匹配值的 UTF 容量账本仅作诊断:统计 Vole 自有 matcher/index/value/临时 Vec 的可见容量,扩容时 记录新旧缓冲可能重叠的容量;没有最大值、不控制准入。`regex::bytes::Regex` 不提供 -完整内部状态的准确 `memory_usage`,所以账本**不包含正则内部编译状态、搜索缓存、 +完整内部状态的准确 `memory_usage`;国家/ASN 文件缓冲容量计入快照容量,借用候选 +验证期的新旧字节重叠、Meta 投影构建期间的源字节、Reader 路径的多 code 临时引用索引与 +MMDB metadata/图校验状态未计入。因此账本**不包含正则内部编译状态、搜索缓存、 动态 scratch、配置需求集合、allocator 开销或整个进程 footprint**。这些未计入的 内存并不是零;accounted peak 不能作为加载或运行期峰值上界。 -50,000,000 bytes 是限定输入与联合负载下的进程实测验收目标,不是加载器保证。 +进程 RSS 的硬上限与默认 1 / 2 Gbps 分档压力标准统一见[验收边界](acceptance.md#性能采纳标准), +它们是压力测试标准,不是加载器保证。 -互通和内存/吞吐压力由公开 [container-benchmark](https://github.com/YuanDevTeam/container-benchmark) -维护;`interop`、`stress` 和 `compare` 以 `--source vole=PATH` 显式指定被测 checkout。 +互通和内存/吞吐压力由独立容器测试工具维护,结果与结论统一保存在 benchmark 工程; +`interop`、`stress` 和 `compare` 以 `--source vole=PATH` 显式指定被测 checkout。 Vole 自有脚本仅负责编译。压力与横向比较固定只引用完整 `geosite:cn` / `geoip:cn`, -更新下载保留原始增强 DAT,但不因此加载其他分类。真实资产的类型分布、明确标识的四类型/复杂正则合成用例、 -属性/反选见证、加载与真实更新期间的峰值、联合吞吐/DNS/RSS 结果以 benchmark 的本轮文字结论为准。 -历史 Domain/Full 子集或单纯离线加载不能替代完整真实资产的 fd-TUN 压力,也不能证明 -任意正则的加载峰值。Linux RSS 不替代 Apple Network Extension physical footprint; -少量转发见证不等同逐条规则语义穷举。 +更新下载保留原始增强 DAT,但不因此加载其他分类。加载峰值、热更新覆盖范围与 +联合吞吐/DNS/RSS 以 benchmark 工程的实际记录为准。 +合成回归和离线加载不替代完整真实资产的 TUN 压力,少量转发不等同逐条规则穷举。 +Linux RSS 不替代 Apple Network Extension physical footprint。 ## 失败语义 @@ -242,8 +327,8 @@ Vole 自有脚本仅负责编译。压力与横向比较固定只引用完整 `g wire 结构或 staging 检查失败发生在卸载之前,当前快照继续可用。 - 首次加载、外部资产重载或卸载后的所选记录加载失败,使对应种类不可用;健康种类 独立发布。排空超时或卸载后取消也保持已卸载状态,不能通过反选绕过不可用语义。 - 失败更新保留旧磁盘资产,但不恢复旧内存快照;后续成功加载再恢复分流。 -- 不可用的 GeoIP 规则不得触发惰性 DNS。 + DAT 失败更新保留旧磁盘资产,不恢复旧内存快照;国家 MMDB 与 ASN 更新按前文恢复旧代。 +- 不可用的 GeoIP 或 ASN 规则不得触发惰性 DNS。 - 调度状态损坏时在更新锁内重置;异常退出留下的更新状态在锁释放后恢复。 自动化与实机覆盖范围见 [验收矩阵](acceptance.md)。 diff --git a/docs/http-processing.md b/docs/http-processing.md new file mode 100644 index 0000000..adecc91 --- /dev/null +++ b/docs/http-processing.md @@ -0,0 +1,118 @@ +# HTTP 处理 + +独立 HTTP 配置作用于 TUN 和 mixed-port 的业务 TCP。Core 的 DNS、provider、GeoData、节点握手及 Controller 不进入业务重写。主代理配置仍使用 Mihomo YAML。 + +## 配置与资源 + +`httpConfigYaml` 使用单份 YAML 文档。未知字段、旧格式、重复键、类型错误及 `null` 冒充省略值均拒绝。四个顶层字段均可省略: + +```yaml +http-ports: [80, 8080] +rewrite-providers: {} +rewrite: + enabled: false + rules: [] +mitm: + enabled: false + ports: [443] + domains: [] + exclude-domains: [] +``` + +端口有效且不可重复。`rewrite.enabled` 控制真实执行,关闭仍校验直接规则与 inline 内容;规则和引用的 `enabled` 默认 true。关闭引用不读取来源。MITM 为独立本机授权,远程规则不能扩大它。 + +```yaml +rewrite-providers: + local: {type: file, path: ./rules/local.yaml} + remote: + type: http + url: https://rules.example.com/http.yaml + path: ./rewrite-cache/remote.yaml + interval: 86400 + header: + Authorization: ['Bearer example-token'] + builtin: + type: inline + payload: + - {url: 'https://example.com/debug', match: exact, action: reject} +rewrite: + enabled: true + rules: + - {url: 'http://old.example/v1/', match: prefix, action: replace, replacement: 'https://new.example/v2/'} + - {rule-set: local} + - {rule-set: remote} + - {rule-set: builtin, enabled: false} +``` + +file 只接受必填 `path`;inline 只接受必填 `payload`;http 接受必填 `url` 和可选 `path`、`interval`、多值 `header`。外部文件只有 `payload`,与本机直接规则同形,不能嵌套引用或携带本机授权/CA。按引用位置展开,不允许重复引用或未知名称。 + +file 相对路径锚定宿主提供的 HTTP 文件目录,HTTP 缓存锚定初始化的数据目录;缓存不能与主文件、源文件或其他缓存冲突。URL 与规范化请求头绑定来源;认证变化不能借用旧缓存。304 要求完整有效的同来源内容。全部来源及总体编译通过后,才提交缓存与完整待应用候选;失败保留旧内容。 + +file 在显式准备时读取;http 的 interval 默认 86400 秒,0 只关闭周期更新。准备与下载使用底层资源路径,不经过本次 HTTP 业务处理。 + +当前工程上限为主文档与实际外部内容合计 1 MiB、展开规则 64 条、选择器/搜索各 2 KiB、非正文规则文本合计 64 KiB、单个 URL 结果 8 KiB。正则编译与 DFA 缓存分别限 128/64 KiB;YAML 别名、模板输出及并行准备也受限。超限失败,不截断规则;这些准入上限不代表进程 RSS。 + +HTTP 处理需要编译启用 `inbound-http`。缺少该能力时,非空 HTTP 配置在静态校验时拒绝,不读取 provider 或私密 CA;省略或空输入仍表示不启用。 + +## URL 阶段 + +规则共有必填 `url`、`action`,可选 `match`、`enabled` 和展示用 `name`。`match` 为 regex(默认)、exact 或 prefix,不根据文本猜测。URL 动作为 replace、redirect302、redirect307、reject;前三者必须有 replacement,reject 不接受替换参数。 + +`replacement-mode` 默认 match:exact 替换整个 URL,prefix 保留后缀,regex 仅替换首个匹配片段。显式 url 使用整个展开结果。prefix 只在无查询的 replacement 以 `/` 结尾、后缀也以 `/` 开头时去掉一个边界斜杠,不拼接路径或合并查询。 + +正则模板支持 `$0`、`$N`、`${N}`、`${name}`、`$$`,不支持 `$name`;未知捕获加载失败,可选未参与捕获为空。字面模式不展开捕获。仅接受合法绝对 HTTP(S) URL,拒绝 userinfo、fragment、端口 0、控制/空白字符和反斜杠。离线输入 trim 并去掉 fragment;配置目标不允许 fragment。 + +所有阶段按原始规范化 URL(U0)筛选。URL 动作首条命中产生 U1,不递归。非法生成目标本地返回空 502;redirect 与 reject 本地结束,不访问原站,reject 为空 403。相同 origin 的路径/查询改写保留已选 IP;跨 origin 重选路、重建 authority,先清理收到的 Authorization/Cookie。原始目标与入口身份保留。 + +## Header 与正文 + +`request-header-set/add/remove/replace` 和 `response-header-set/add/remove/replace` 按规则顺序累计执行。`header` 为字段名;set/add 必须有字面 `value`,remove 不携带值,replace 使用 `search` 与 `replacement`。多值字段逐项处理;set 覆盖原值,add 追加独立值。协议控制字段、Connection 提名字段不能修改。阶段失败时整个 header 阶段回退;跨 origin 清理旧凭据后,规则可明确设置新的 Authorization/Cookie。 + +正文动作是 `request-body-set/replace/json-patch` 和对应的 `response-body-*`。set 使用 UTF-8 字面 `value`,包含空字符串;replace 使用 `search`、`replacement`。`search-mode` 默认 literal,可选 regex;`occurrences` 默认 all,可选 first。只有 regex search 的替换展开其自身捕获,其他值不展开 URL 捕获。所有正文操作按序执行,一条失败则整个正文阶段原文旁路,保留独立 header 阶段的成功修改。 + +```yaml +rewrite: + enabled: true + rules: + - {url: 'https://api.example/v1/', match: prefix, action: request-header-set, header: X-Client, value: Vole} + - url: 'https://api.example/v1/settings' + match: exact + action: response-body-json-patch + patch: + - {op: add, path: /enabled, value: true} + - {op: replace, path: /message, value: ready} + - {url: 'http://example.com/', match: prefix, action: response-body-replace, search: old, replacement: new} +``` + +JSON Patch 支持 RFC 6902 的 add/remove/replace/move/copy/test 和 RFC 6901 Pointer;`value` 可为真正的 null、布尔、数值、字符串、数组、对象。配置数值必须能无损转换,不能静默舍入;业务 JSON 保留数值原文并拒绝重复键。空 patch 不触发解析或重新编码。非法 JSON、Pointer 或失败的 test 导致整个正文阶段原样旁路。 + +只有被正文规则选中的消息才缓冲。原始、解码、中间和最终正文各限 256 KiB;进程共享 2 MiB 正文工作区,包含 JSON 与 codec 临时空间。先预留再分配,无额度直接旁路;发送期间保留额度。未知长度最多探测上限加一字节,超限按原实体字节回放已读前缀和余下数据,不截断。传输中断、超时和取消仍是错误,不能假装完成。 + +支持 identity、gzip、deflate、br;按收到的 Content-Type、charset、Content-Encoding 和原始请求 Accept-Encoding 判断资格。改写后使用 UTF-8 identity,修正长度与表示元数据并删除失效的校验/签名字段。没有实际改动时保留原始编码表示。未知编码、非法文本、压缩校验失败或预算不足均整阶段原文旁路。 + +HEAD、无正文状态、206/Content-Range、CONNECT、Upgrade、SSE、gRPC、multipart 及带 trailer 的消息不做正文改写,普通 header 规则仍可执行。选中正文的 H1 Expect: 100-continue 在读取前完成握手;未匹配消息保持原有流式背压、早响应和半关闭语义。H1/H2 与成功解密的 HTTPS 共用规则与预算。 + +## 选择性 HTTPS + +```yaml +mitm: + enabled: true + domains: + - {pattern: private.example} + - {pattern: '*.example.net', enabled: true} + exclude-domains: + - {pattern: excluded.example.net} + ports: [443] +``` + +域名项只有 pattern 和默认 true 的 enabled。支持精确名称及 `*.suffix` 子域匹配,后者不包含域本身;启用排除项优先,关闭项仍校验。开启要求有效允许域名、端口及匹配的有效 CA/私钥。关闭不构造 CA;宿主提供独立私密输入,规则、payload 和普通快照不承载私钥。 + +MITM 支持 HTTP/1.1 与 HTTP/2。HTTP/3 MITM 暂不支持;UDP/QUIC(含 H3)按原字节透明转发,不进入 URL/header/body rewrite,也不通过阻断 QUIC 强制客户端回落。 + +只终止允许的 SNI/端口;无 SNI、ECH(含 GREASE)和未授权名称保持透明。每个原始 HTTP authority 独立授权,同一 H2 连接不扩大权限。最终上游按实际目标验证证书,使用受保护出口;不通过关闭验证处理 pinning 或客户端证书要求。 + +H1 上游协商 HTTP/1.1,H2 下游对接实际协商的 H2 或 H1。普通 H2 CONNECT 返回 405;WebSocket 使用 H1 Upgrade。叶证书最多 32 项、材料预算 256 KiB、空闲 10 分钟,有效期不超过 24 小时或 CA。 + +H2 每连接最多 8 个活动请求,流/连接接收窗口 32/64 KiB,头最多 32 KiB/100 字段,帧 16 KiB,关闭动态 HPACK 表。上游不跨请求复用。上传使用 30 秒无进展期限;上传完成后响应头另有 30 秒等待期限,提前响应仍与上传并发。取消和 Stop 覆盖上下游、正文与驱动。 + +当前会话固定主设置、实际 payload、授权和 CA。保存/资源刷新只产生待应用候选,节点选择和分流更新继承 active。显式重连准备候选后切换,后续启动失败使用旧实际内容恢复,不重读已变化文件;普通 Stop 释放私密材料。共享入口见 [Invoke API](invoke-api.md) 与 [CLI](cli.md)。 diff --git a/docs/inbounds.md b/docs/inbounds.md index 2eadbb3..ef1ac3f 100644 --- a/docs/inbounds.md +++ b/docs/inbounds.md @@ -1,11 +1,11 @@ # HTTP 与 SOCKS5 混合入站 -HTTP 与 SOCKS5 共用一个混合入口,并和 TUN 共用 Dispatcher、规则、DNS、静态 select 组及出站图。 +HTTP 与 SOCKS5 共用一个混合入口,并和 TUN 共用 Dispatcher、规则、DNS、动态策略组及出站图。 字段全集见[配置协议](config.yaml),Controller 另见[接口](controller-api.md)。 ## 监听与认证 -- `mixed-port` 省略或 0 关闭,启用时为 1–65535;混合入口和 TUN 至少启用一个。 +- `mixed-port` 省略或 0 关闭,启用时为 1–65535;关闭混合入口和 TUN 的配置仍可启动。 同一 TCP 端口接入 HTTP 转发、CONNECT、Upgrade 和 SOCKS5 CONNECT、UDP ASSOCIATE。 SOCKS5 UDP 随入口启用并绑定同地址、同端口,不提供独立入站 UDP 开关。 - 顶层 `port`、`socks-port`、`udp` 和 `listeners` 均拒绝,不迁移或忽略旧字段。 @@ -15,15 +15,37 @@ HTTP 与 SOCKS5 共用一个混合入口,并和 TUN 共用 Dispatcher、规则 - `allow-lan: false` 绑定 IPv4 回环,true 绑定通配地址。`ipv6: true` 增加独立 IPv6-only 回环/通配 socket。仅系统明确不支持地址族时可省略 IPv6,其余绑定错误 整体回滚。取得 Controller 和全部业务 socket 后才启动接收任务。 +- `bind-address` 默认为 `*`;`allow-lan: true` 时可指定单个 IPv4/IPv6 地址,false 仍绑定回环。 + `localhost` 始终使用回环策略,并随 `ipv6` 控制 IPv6 回环 socket。 + `lan-allowed-ips` 默认允许全部 IPv4/IPv6,`lan-disallowed-ips` 的拒绝规则优先。 + 两者作用于 TCP 来源;SOCKS5 UDP 必须属于已获准的 TCP 关联。 + `skip-auth-prefixes` 只跳过匹配来源的认证,不跳过来源访问控制。 - `allow-lan` 与 `authentication` 独立;通配绑定不强制认证。 - `authentication` 省略或空列表时,HTTP/SOCKS5 在任何绑定地址均免认证; - 非空必须恰好一项 `user:password`, - 按第一个冒号拆分,两项各 1–255 UTF-8 字节,不 trim。null 不视为省略。 - 配置凭据后 HTTP/SOCKS5 均校验凭据;共享或非空凭据须有启用的混合入口消费。 -- Basic / SOCKS 用户密码没有链路加密,只适合可信网络。Controller 仍独立回环监听, - 使用自己的 Bearer 认证,不随共享开关开放。 + `authentication` 省略、null 或空列表时,HTTP/SOCKS5 在任何绑定地址均免认证; + 非空接受多个 `user:password`,相同用户名以最后一项为准, + 按第一个冒号拆分,不 trim,允许空用户名或密码。配置和 HTTP 不套用 SOCKS5 的 + 长度限制;HTTP 凭据受整个 32 KiB 请求头预算约束,SOCKS5 各字段仍使用一字节 wire 长度。 + 配置凭据后 HTTP/SOCKS5 均校验凭据;混合入口关闭时允许保留合法的共享和认证配置。 +- Basic / SOCKS 用户密码没有链路加密,只适合可信网络。Controller 按 external-controller 独立监听, + 使用自己的可选 Bearer 认证,不随共享开关改变地址或认证。 - 不设置全局业务连接/关联准入数。Stop 取消并等待全部连接、relay 和接收任务, 返回后端口可重新绑定。 +- 接收的 TCP 连接启用 `TCP_NODELAY`,小包应答不等待 Nagle 聚合。 + +## 选路元数据 + +`IN-TYPE` 使用实际入口类型:普通 HTTP/Upgrade 为 HTTP,CONNECT 为 HTTPS, +SOCKS5 为 SOCKS5;HTTPS 类型只表示 CONNECT,不检查目标 TLS。`IN-PORT` 读取 +接收连接或授权 UDP socket 的真实本地端口。 + +`IN-USER` 与 load-balance 的 `hash-key: in-user` 只使用验证成功的配置用户名。 +HTTP 每个请求重新验证,SOCKS TCP 和 UDP 授权关联保留握手身份。无认证或 +`skip-auth-prefixes` 旁路时身份为空,即使请求声称一个 Proxy-Authorization 用户。 +身份随请求/关联保存,只持有用户名,不保存密码、请求头或完整配置代际;Debug 不输出用户名。 +业务路由和 dialer-proxy 共用该身份,内部 DNS、资源下载及主动测速没有入口用户。 + +Mihomo 当前实现不把 SOCKS TCP 身份传给独立 UDP 接收;Vole 的授权 UDP 保留 +真实握手身份。Mihomo HTTP 在无认证时也可能记录请求声称的用户,Vole 保持为空。 ## HTTP @@ -51,7 +73,8 @@ HTTP/1.0 和 HTTP/1.1 使用绝对 `http://host[:port]/path?query` URI。每个 ### 隧道 -CONNECT 在上游成功后才返回 200,保留头后的预读数据。Upgrade 只接受无正文请求; +CONNECT 通常在上游成功后返回 200,保留头后的预读数据。匹配 TCP 嗅探策略时先返回 200, +再读取有界业务前缀并按嗅探结果建链;此后建链失败直接关闭隧道。Upgrade 只接受无正文请求; 合法 101、Connection/Upgrade 一致且选中请求协议时才切隧道,保留双方预读和 TCP 半关闭。非 101 按普通响应处理,非法切换返回 502。 @@ -62,13 +85,15 @@ CONNECT 在上游成功后才返回 200,保留头后的预读数据。Upgrade 头 32 KiB/100 字段、读头/建链 10 秒;每方向预读/复制 8 KiB。chunk 行 1 KiB, trailer 8 KiB/100 字段,每个最终响应前最多 16 个临时响应。正文逐次读写空闲期限 -30 秒;响应开始后独立计算读头总期限,上传结束后等待响应最多 10 秒。正文长度不 -决定分配大小,Stop 覆盖认证、建链、正文及隧道。 +30 秒。上传与响应读取并行;上传完成后,当前完整响应头的总等待上限为 30 秒, +观察到首字节后另受 10 秒绝对读头期限约束,两者取较早者。每个临时响应转发后 +重新开始下一响应头的等待。正文长度不决定分配大小,Stop 覆盖认证、建链、正文及隧道。 ## SOCKS5 仅支持 RFC 1928/1929 的 SOCKS5 CONNECT、UDP ASSOCIATE 和用户名密码协商。 -不降级绕过已配置认证。精确读取握手并保留 CONNECT 后业务字节;上游成功才回复成功。 +不降级绕过已配置认证。精确读取握手并保留 CONNECT 后业务字节;通常在上游成功后回复成功。匹配 TCP 嗅探策略 +时先确认 CONNECT,取得业务前缀后建链,失败关闭已确认的连接。 目标保留 IPv4、IPv6 或域名交给 Dispatcher,入站不提前解析。TCP 每方向复制 4 KiB, 保留半关闭。 @@ -92,3 +117,6 @@ wire 最大 65,507 字节,额外 sentinel 识别截断,自有 UDP socket 请 TUN 使用独立 MTU;SOCKS5 数据不计入 Controller 的 TUN 流量。 [验收边界](acceptance.md)区分容器双栈与真实 LAN/物理 IPv6,不能相互替代。 + + +显式启用独立 HTTP 配置时,mixed HTTP/CONNECT 与 TUN 共用[URL rewrite](http-processing.md)和同代 Dispatcher。规则先确定最终 URL,再选路;本地动作不连接旧目标。请求正文与 Upgrade 继续复用本文件的流式 framing/relay;入口认证和用户名语义保持。 diff --git a/docs/invoke-api.md b/docs/invoke-api.md index 2cf865b..1d580ab 100644 --- a/docs/invoke-api.md +++ b/docs/invoke-api.md @@ -1,8 +1,10 @@ # Vole Invoke API -业务实例通过内联的 `configYaml` 启动,测速通过 `configYamls` 传入节点配置;前台 `foreground` 操作负责从文件或标准输入加载同一配置。每份已加载的 Vole 运行时最多拥有一个公共实例。业务代理入口仅通过 `mixed-port` 配置,HTTP/SOCKS5 TCP 与 SOCKS5 UDP 共用端口;顶层 `port`、`socks-port`、`udp` 和 `listeners` 都会失败。代理组实时选择沿用 Controller。静态 ECH 只使用节点内联配置,不新增 bootstrap DNS 入参。 +业务实例通过内联的 `configYaml` 启动,测速通过 `configYamls` 传入节点配置;前台 `foreground` 操作负责从明确的 Base64、文件或标准输入加载同一配置。每份已加载的 Vole 运行时最多拥有一个公共实例。业务代理入口仅通过 `mixed-port` 配置,HTTP/SOCKS5 TCP 与 SOCKS5 UDP 共用端口;顶层 `port`、`socks-port`、`udp` 和 `listeners` 都会失败。代理组实时选择沿用 Controller。ECH 使用节点内联静态配置或运行时节点 DNS 角色;measureDelay 的动态 ECH 使用原 Dialer 的 system-source 原始 HTTPS 记录叶服务,平台物理来源不可用时失败,不增加 DNS 或宿主元数据入参。 -共享请求处理位于 `src/invoke/`。Rust 调用方使用 `vole::invoke::invoke_bytes(&[u8]) -> Vec`;CLI 和 C ABI 都调用该入口。`invoke` feature 提供共享操作,`cli` 和 `ffi` 分别添加命令行与原生传输适配。 +共享请求处理位于 `src/invoke/`。Rust 调用方使用 `vole::invoke::invoke_bytes(&[u8]) -> Vec`;C ABI 使用同一入口。 +CLI 使用共用解析、准入、派发和 panic 边界的 `invoke_owned_bytes(Vec)`, +将序列化输入的所有权交给 Invoke,使其在进入长驻前台操作前清零并释放原始 JSON;不增加 JSON 方法。`invoke` feature 提供共享操作,`cli` 和 `ffi` 分别添加命令行与原生传输适配。 ## C ABI @@ -53,7 +55,7 @@ void VoleFree(char *response); - 运行时级方法必须省略 `instanceId`;实例级方法必须携带 Vole 返回的非空 ID。 - 未知或已移除的 envelope 字段和 payload 字段都会失败,不静默忽略。 - 无业务数据的方法成功时返回空对象,不返回 `null`。 -- Invoke envelope 最大 3 MiB,单份 YAML 最大 256 KiB。 +- Invoke envelope 上限为 240 MiB + 64 KiB(五份 YAML 的 JSON 最坏转义及 envelope),单份 YAML 最大 8 MiB。 ## 初始化与生命周期 @@ -90,7 +92,7 @@ stopped -> preparing -> prepared -> starting -> running - `validateConfig` 是可并发的纯校验方法,不要求或执行 `initialize`。 - `measureDelay` 使用独立批次和私有工作器,不进入公共实例表。 - 公开生命周期为 `initialize -> createInstance -> start(configYaml) -> stop -> destroyInstance`。`preparing`、`prepared` 和 `starting` 是 `start` 内部阶段,没有公共 `prepare` 方法。 -- 不支持配置热重载;切换配置需要 `stop -> start(新 configYaml)`。唯一的运行期路由变更是通过 Controller 修改静态 `select` 组的当前选择,它不修改启动配置。 +- `updateConfig` 与 Controller 共用一个候选应用器;路由、节点、provider 和允许的 DNS/连接策略更新只影响新连接。TUN、监听器、系统网络等启动资源变更需要 `stop -> start(新 configYaml)`。既有连接保留原资源和路径。 - `stop` 在 stopped 状态幂等;`destroyInstance` 是最终同步清理屏障。 ## 方法 @@ -119,6 +121,29 @@ stopped -> preparing -> prepared -> starting -> running 参数和幂等语义见“初始化与生命周期”。成功时返回规范化后的 `dataDir`。 +### `updateDnsNetwork` + +初始化后的运行时级方法,不带 `instanceId`。宿主提供完整、只读的物理网络 DNS 快照;不改变 `start(configYaml)`,不配置接口、路由、DNS 或 App 生命周期。 + +```json +{ + "method": "updateDnsNetwork", + "payload": { + "generation": 1, + "system": [ + {"address": "192.0.2.53", "interface": "en0", "scopeId": 0, "requiresEncryption": false} + ], + "dhcp": [ + {"interface": "en0", "servers": [{"address": "192.0.2.54", "scopeId": 0}], "requiresEncryption": false} + ] + } +} +``` + +`generation` 必须是正整数并单调增加;相同代次的相同内容幂等,不同内容或较旧代次失败。system/dhcp 必填,空数组撤销来源;一个 DHCP 接口只能出现一次。所有层级拒绝未知字段,metadata 原始 JSON(含内部空白)及规范化快照各限 64 KiB;在完整 JSON Value 解析前通过借用 RawValue 准入,额外 envelope 字节(含外部空白)独立限 64 KiB。这与其他方法沿用的全 envelope 上限不同。地址只接受非回环的单播字面 IP;IPv6 link-local 必须提供匹配物理接口的非零 scopeId,IPv4 scopeId 必须为 0。interface 是 1–256 字节的精确名称或 UWP 的绑定 GUID,不允许控制字符、路径分隔符或首尾空白。scopeId 默认 0,requiresEncryption 默认 false,宿主必须如实标记加密要求,不能将无法确认的数据描述为明文来源。 + +成功返回 `{}`。快照在同一运行时内共享,优先于 OS 发现,并与全部仍活跃资源代次的 DNS cache/hints 清理共用 epoch 屏障;旧查询不能回填,fake-IP 和活跃流保留。Windows 前台进程和 Session Host 的 runtime registry 各自独立,本方法不提供 IPC。平台可用性和物理绑定见 [DNS 来源](tun-icmp-dns.md#显式系统与-dhcp-来源)。 + ### `getGeoDataState` 运行时级只读方法,要求已经初始化: @@ -127,7 +152,7 @@ stopped -> preparing -> prepared -> starting -> running {"method":"getGeoDataState","payload":{}} ``` -返回 `geosite` 和 `geoip` 两项: +返回 `geosite`、`geoip` 和 `asn` 三项;三种资产分别报告需求与可用性: ```json { @@ -150,10 +175,22 @@ stopped -> preparing -> prepared -> starting -> running "lastError": null, "etag": null, "hash": null + }, + "asn": { + "required": false, + "available": false, + "updating": false, + "lastSuccess": null, + "nextCheck": null, + "lastError": null, + "etag": null, + "hash": null } } ``` +`asn` 使用相同的 `required`、`available`、`updating`、`lastSuccess`、`nextCheck`、 +`lastError`、`etag` 和 `hash` 字段。 时间使用 Unix 秒,hash 为小写 SHA-256。调用不会创建实例、联网或改变更新调度。 ### `createInstance` @@ -191,11 +228,11 @@ stopped -> preparing -> prepared -> starting -> running } ``` -无需 `initialize`,完成大小、YAML、结构、共享 route-target 命名空间、引用和字段组合校验。节点的 `dialer-proxy` 可引用节点或组;全部上游和组成员边(包括未选成员)统一做无环校验。不创建实例、不解析远端域名、不联网,也不读取 GeoData。资产缺失不影响纯配置校验。 +无需 `initialize`,完成大小、YAML、结构、共享 route-target 命名空间、引用和字段组合校验。节点的 `dialer-proxy` 可指定 DIRECT 或引用节点、组;全部上游和组成员边(包括未选成员)统一做无环校验。不创建实例、不解析远端域名、不联网,也不读取 GeoData。资产缺失不影响纯配置校验。显式引用的客户端证书与密钥文件在校验时读取;相对路径使用已初始化的 dataDir,未初始化时使用当前工作目录。 ### `start` -实例级方法,只允许 stopped 状态。唯一 payload 字段是内联配置: +实例级方法,只允许 stopped 状态。`configYaml` 必填,独立 HTTP 设置与私密上下文可选: ```json { @@ -211,8 +248,8 @@ TUN 资源参数来自 YAML 的 `tun`,平台包格式由内核选择。 同一命令内先准备配置,再获取平台资源并启动: - 读取当时可用的本地 GeoData,不启动或等待下载。 -- 为所有潜在物理首跳执行引导 DNS:节点无上游,或其上游组经纯组成员链能选到 DIRECT 时,预解析节点服务器及独立 VLESS 下载端点,包括尚未选中的 DIRECT 路径。候选解析失败则启动失败;仅经代理访问的域名交给下一跳。切换 DIRECT 不新增系统 DNS。 -- 校验每个静态 `select` 组的初始选择;省略 `default-selected` 时使用第一项,显式值必须是直接成员。启动时创建本次 session 的可变选择状态。 +- 准备阶段只构建并校验代理图;实际建立物理首跳时,通过运行时节点 resolver 解析服务器及独立 VLESS 下载端点。未选中的备用节点不会因 DNS 故障阻塞启动。仅经代理访问的域名交给下一跳;系统解析仅用于 DNS 关闭的配置。 +- 按当前资源构建代理组。`profile.store-selected` 开启时优先恢复仍有效的缓存选择,否则使用有效的 `default-selected` 或首成员;动态空组使用 `empty-fallback`。自动组随后依据健康探测选择成员。 - 含 TUN 的配置从内部 preparing 阶段起持有运行时本地的 TUN/protect 租约,直到停止或销毁。Android 必须在 `start` 前注册 protect controller。 - 所有监听器和关键数据面成功后才进入 running。准备、资源获取或启动失败均清理本次临时资源并回到 stopped;重试需要再次提交完整 `configYaml`。 - GeoData 更新只在启动后按需后台运行,不属于启动关键路径。 @@ -223,10 +260,16 @@ Apple 使用 utun 包头,Android 与 Linux 使用 raw-IP。Linux 要求真实 PI/VNET header,并验证实际 MTU 与 `tun.mtu` 相同;跨网络命名空间的校验需要宿主提供权限。 `tun.file-descriptor` 省略或为 0 时,Linux 与 macOS 根据 `tun.device` 创建或打开原生 -TUN;移动平台仍需要宿主描述符。接口地址、DNS、路由和物理出口隔离由宿主配置,Vole -不自动管理系统路由。`tun.mtu` 省略或为 0 使用 9000;完整六字段契约及借用 fd 边界见 +TUN;移动平台仍需要宿主描述符。Linux 自建 TUN 的 IPv6 地址、CIDR 捕获/排除、 +UID/端口范围、路由索引和 strict-route 从同一配置读取;借用 fd 与 App 管理的平台 +由宿主应用,运行期变更需要重配/重连。Linux 自建 TUN 支持 `auto-route`、 +`auto-detect-interface` 与 systemd-resolved 接管;其他平台和借用 fd 的系统网络由宿主配置。 +`tun.mtu` 省略或为 0 使用 9000;字段契约及借用 fd 边界见 [配置参考](config.yaml)与 [TUN 平台层](tun-platform.md)。 +物理出口来自同一 YAML 的全局/节点 `interface-name/routing-mark` 与普通 DNS `#接口`, +不增加 Invoke 参数。平台能力、继承和 auto-route 共存边界见[出站契约](outbounds.md)。 + Windows 编译时选择互斥的 `windows-wintun` 或 `windows-uwp`: - Wintun 构建中,业务 `start(configYaml)` 在 `tun.enable: true` 时自动打开或创建 @@ -240,6 +283,24 @@ Windows 编译时选择互斥的 `windows-wintun` 或 `windows-uwp`: Stop 关闭本次持有的 fd、session 和原生句柄;Wintun 不承诺移除宿主已有的同名接口 或卸载驱动。平台启动失败不切换另一种 Windows 后端。 +### `updateConfig` + +实例级方法,只允许 running 状态。接受 `configYaml`、可选 `httpConfigYaml` 与只含 `baseDir` 的 `httpContext`;不接受 CA: + +```json +{"method":"updateConfig","instanceId":"1","payload":{"configYaml":"rules: [MATCH,DIRECT]"}} +``` + +共享 Controller 的单候选许可、解析、完整图准备与原子发布,不建立第二个运行时。 +先取得许可,再解码最多 8 MiB 的 YAML;并发应用返回 busy。无效候选或准备失败保留旧代, +改变启动资源返回 reconnect required。成功仅表示候选配置及当前可用资源已经发布;HTTP provider +继续沿原更新流程刷新,不表示所有远端订阅已下载。Controller GET `/configs` 可确认当前有效值。 + +沿用实例命令锁:调用完成前,同实例 Stop/getState/其他命令返回 busy;候选等待上限 90 秒。 +超时或取消会阻止未发布候选继续提交;工作器保留许可并回收已开始的准备任务,Stop 仍等待其完成。如超时与发布同时发生, +通过 Controller 查询确认当前值。既有连接仍持有旧代至结束;新连接使用新代。Provider 身份和 +Windows Session Snapshot 不由此重建;Windows UWP 的活动 Provider 配置仍由宿主管理和重连。 + ### `foreground` 运行时级前台操作,省略 `instanceId`: @@ -270,8 +331,32 @@ Stop 关闭本次持有的 fd、session 和原生句柄;Wintun 不承诺移除 若定义 `XDG_CONFIG_HOME`,使用 `/vole`。相对数据目录和显式 配置路径分别从启动工作目录解析并折叠 `.`/`..`,绝对路径原样保留; 配置默认 `/config.yaml`。 -`configPath: "-"` 从标准输入读取。文件必须是普通文件,文件和标准输入均最多 256 KiB; -不会下载、创建模板或改写配置。详细命令行映射见 [CLI](cli.md)。 +`configPath: "-"` 从标准输入读取。文件必须是普通文件,文件和标准输入均最多 8 MiB; +证书/密钥相对路径从选定 dataDir 解析,validate 也读取身份文件但不初始化目录。 +不会下载、创建模板或改写配置。 +可选 `configString` 和 `ageSecretKey` 是 UTF-8 文本,分别取 `VOLE_CONFIG_STRING`、 +`VOLE_AGE_SECRET_KEY` 的环境默认;显式空值清除该默认。非空 `configString` 按带 padding 的 +标准 Base64 解码(允许 CR/LF),优先于配置路径,因此即使路径为 `-` 也不打开或锁定标准输入。 +没有非空 Base64 时才使用明确选择的 `-`(含 `VOLE_CONFIG_FILE=-`)或文件,不探测输入。 +编码文本最多 11,184,812 字节,解码后的内容、主文件和标准输入均最多 8 MiB。 +输入以标准 AGE armor 开头时复用资源层的认证解密,身份最多 16 KiB,解密后 YAML 最多 8 MiB; +解密受 30 秒处理期限约束,运行模式的退出信号取消并 join 解码工作器后返回。 +临时身份与私密请求缓冲清零,编码/密文在后续阶段及时释放。配置仍只解析一次后交给共享启动路径。 +运行模式可选 `externalController`、`externalControllerTls`、`externalControllerUnix` 和 `secret` 文本覆盖, +分别对应 `VOLE_OVERRIDE_EXTERNAL_CONTROLLER`、`VOLE_OVERRIDE_EXTERNAL_CONTROLLER_TLS`、 +`VOLE_OVERRIDE_EXTERNAL_CONTROLLER_UNIX`、`VOLE_OVERRIDE_SECRET` 的环境默认。显式参数优先,空串仅清除环境默认,不清空原 YAML 字段。 +地址复用主配置的既有严格解析器,覆盖在 foreground 解析/校验后应用到同一启动配置;没有任意 map 合并。 +`validate` 保持 Mihomo 的配置测试语义,不读取这些环境值或解析/应用覆盖。`help` 也不读取。 +另有可选数值 `externalControllerRoutingMark`,对应 `VOLE_OVERRIDE_EXTERNAL_CONTROLLER_ROUTING_MARK` 的十进制环境默认; +非零值才覆盖,显式 0 不读环境且不清除 YAML mark。仅 Linux 网络 Controller 消费,沿用既有 socket mark 范围; +它不更改业务 mark 或本地 Unix 入口。CLI 先执行 Go flag 整数语法检查,`validate` 仍不读取该环境默认或应用运行期覆盖。 +覆盖元数据不进入普通 `start` payload 或 Mihomo YAML schema;Controller 的 TLS 身份仍由主配置提供。新激活的入口保留主配置中的 secret/CORS;Unix 本地入口另依文件权限授权,不改变网络鉴权。 +前台运行可通过 `VOLE_SAFE_PATHS` 的系统原生路径列表扩展 provider 授权根;仅用于主配置 file provider 读取、 +HTTP provider 缓存正文及 sidecar 写入。目录在初始化时 canonicalize 并冻结,沿普通候选、HUP 和恢复快照继承; +重复初始化不得改变已明确设置的授权上下文。空项不授权工作目录,读取不创建目录,路径 traversal 与符号链接逃逸拒绝。 +本次显式主配置/独立 HTTP 输入禁止被缓存覆盖,显式私密 CA 路径禁止 provider 读写。 +它不扩展主配置写回、客户端身份/私密 CA、独立 HTTP 设置或 Controller 清理权限,也不进入 YAML/普通 initialize payload。 +详细命令行映射见 [CLI](cli.md)。 普通 Unicode 路径使用 JSON 字符串。需要保留平台原生编码时,Unix 可使用 `{"unixBytes":[47,116,109,112,255]}`,Windows 可使用 @@ -281,9 +366,21 @@ Stop 关闭本次持有的 fd、session 和原生句柄;Wintun 不承诺移除 `run` 同步占用当前调用直到会话结束;同一运行时只允许一个前台运行操作。它消费进程的 标准输入和退出信号,并安装有界 stderr 诊断,适用于前台进程宿主。嵌入式、服务或 VPN 宿主继续用普通业务生命周期方法和自身的退出机制。 +解析后的配置直接交给同一启动路径,不重复读取或解析。 +CLI 与普通 `start` 共用统一的 TUN allocator 策略、内存诊断和 Stop 后回收; +无 TUN 的会话不修改 allocator。 +GNU/Linux 的设置是进程级、一次性的,显式 allocator 环境覆盖仍优先, +完整语义见[运行时资源策略](runtime-resource-policy.md#统一内存策略)。 Unix 等待 SIGINT/SIGTERM,Windows 等待 Ctrl+C/Ctrl+Break;标准输入先读取,之后在 内部准备和启动前注册信号。启动中收到信号仍等待启动工作器结束,再停止和销毁;运行期 -通过引擎完成通知等待结束,随后 join 确认结果,不持有命令锁等待信号。启动、关键数据面、 +通过引擎完成通知等待结束,随后 join 确认结果,不持有命令锁等待信号。 +Unix SIGHUP 只通过已有候选许可与工作器热更新主配置:文件源有界重读,Base64/stdin 复用首次有效 YAML。 +后者不保留 Base64、AGE 密文或原始 JSON;文件 AGE 仅保留已解析身份,不重读密钥环境或独立 HTTP 私密 CA 文件。 +同一请求启动时的具体 Controller 覆盖继续应用于新候选。错误候选保留当前配置;启动资源变化返回 +reconnect required 的本地摘要并保留当前会话,HUP 不自动 Stop/start,也不关闭或替换宿主 fd。 +候选继承活动 HTTP policy 的同一实际 payload/CA,普通 provider 更新继续正常执行;独立 HTTP 文件和 +HTTP pending 不由 HUP 读取或应用。退出信号/引擎结束取消并等待重载的已开始工作,Stop 保留原同步屏障。 +启动、关键数据面、 信号或清理失败返回失败响应,错误保留有界摘要;操作返回前完成本次清理。 ### `stop` @@ -336,10 +433,11 @@ Unix 等待 SIGINT/SIGTERM,Windows 等待 Ctrl+C/Ctrl+Break;标准输入先 - `configYamls` 接受 1–5 份非空节点配置,`timeout` 为 1–30 秒。 - 同一运行时一次只允许一个测速批次,最多并发五个私有工作器。 -- 节点配置顶层只允许 `proxies`,不接受 `proxy-groups`;`dialer-proxy` 也只能引用具体节点。Vole 推导唯一链头,且该链必须覆盖全部节点。 +- 节点配置顶层只允许 `proxies` 和客户端信任 `tls.custom-certifactes`,不接受服务端 TLS 字段或 `proxy-groups`;`dialer-proxy` 可指定 DIRECT 或引用具体节点。Vole 推导唯一链头,且该链必须覆盖全部节点。 +- `type: rematch` 是运行时规则控制,不属于具体可测节点,测速配置拒绝它。 - 工作器只准备出站图并执行 TCP、可选 TLS 和 HTTP/1.1 HEAD;不创建公共实例、监听器、TUN、DNS、规则、嗅探器或 GeoData。 - 宿主测量组成员时,按当前选择快照展开具体链后提交;上游选到 DIRECT 时移除相应 `dialer-proxy`,选到 REJECT 时不发起该项测量。该快照不改变运行中的组选择。 -- URL 必须是无 userinfo 和 fragment 的绝对 HTTP/HTTPS URL;HTTPS 使用发布信任根。 +- URL 必须是无 userinfo 和 fragment 的绝对 HTTP/HTTPS URL;HTTPS 与本次节点握手共享内置、可读系统及配置自定义信任根;各测量工作器的信任互相隔离。 - 任意合法 HTTP 状态都表示探测成功;不跟随重定向、不读取正文。 - 单项失败不取消其他项;方法返回前释放全部私有任务。 @@ -379,7 +477,7 @@ ProtectFd(fd) -> bool - 每个出站 TCP/UDP socket 在 connect 前同步调用 protect。 - socket 初始化和 protect 可以在受跟踪的 Tokio 阻塞线程执行;不保证回调来自某个 固定业务线程。取消不能中断已经开始的同步回调,Stop 返回前必须等待其完成。 -- TCP/UDP 共用每初始化作用域 64 个未完成提交的内部许可;提交前异步等待并继承 +- TCP/UDP 共用每初始化作用域 4 个未完成提交的内部许可;提交前异步等待并继承 调用方取消/期限,不因繁忙拒绝业务连接。许可直到阻塞任务实际结束才释放。 - 返回 false、抛出异常或 controller 失效都会使当前连接失败关闭。 - 回调必须快速、同步、非阻塞,且不能重入 Invoke 或注册接口。 @@ -388,9 +486,9 @@ ProtectFd(fd) -> bool ## Controller -配置 `external-controller` 后,运行时可提供回环 `GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}`。代理组 Controller 可以在非 TUN 的 `mixed-port` 配置中运行;此时 `/traffic` 不存在。只要 Controller 管理代理组,`secret` 就必填并保护全部路由。 +配置 `external-controller` 后,运行时可提供 HTTP `GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}`。代理组 Controller 可以在非 TUN 的 `mixed-port` 配置中运行;此时 `/traffic` 不存在。`secret` 省略或空串时免认证;非空时保护全部路由,监听地址由配置决定。 -组成员列表是配置期固定的,选择只存于当前 Running Session。成功切换只影响之后新建的物理 TCP、UDP 和 DNS transport,不迁移既有连接、UDP association、DNS 状态或 TCP pool,也不触发 failover。已认证 Hysteria2 会话的后续逻辑流和跳端口 socket 保留初始上游选择;新的认证会话才读取新选择。Controller 查询不携带 `instanceId`,不进入 Invoke 命令锁;完整语义见 [Controller API](controller-api.md)。 +静态成员来自配置,动态成员随 provider 成功更新;显式选择按 `profile.store-selected` 保存。成功切换只影响之后新建的物理 TCP、UDP 和 DNS transport,不迁移既有连接、UDP association、DNS 状态或 TCP pool。自动组根据健康状态处理后续连接的选择与恢复;select 的连接失败按原样返回。已认证 Hysteria2 会话的后续逻辑流和跳端口 socket 保留初始上游选择;新的认证会话才读取新选择。Controller 查询不携带 `instanceId`,不进入 Invoke 命令锁;完整语义见 [Controller API](controller-api.md)。 ## Windows 安装包桥接 @@ -473,10 +571,72 @@ ProtectFd(fd) -> bool ## 编码与安全边界 - 所有 JSON DTO 拒绝未知字段。 -- 配置、错误和日志按 UTF-8 字节计数并受固定上限约束。 +- 配置和错误按 UTF-8 字节计数并受固定上限约束;CLI 与 Controller 日志完整输出已生成的事件,级别与订阅预算见各自契约。 - TUN 原始数据包按 `tun.mtu` 限制;最终代理 UDP 响应最多为该 MTU 减去 48 字节。默认 9000 对应 8952 字节;显式 1500 对应 1452 字节,Windows UWP 的最高 1400 对应 1352 字节。 - 嵌套 UDP 协议可以增加有界帧头,但解封装后的最终负载仍受该平台响应上限限制。 -- 节点和代理组定义名共享大小写敏感的严格 UTF-8 命名空间;`DIRECT`、`REJECT` 和 `RULES` 不能用作定义名。 -- Secret、password、UUID、REALITY key、short ID、目标地址和完整配置不得进入日志。 +- 节点和代理组定义名共享大小写敏感的严格 UTF-8 命名空间;内置动作名称保留,具体名称与编码规则见 [Controller](controller-api.md#名称与路径)。 +- Invoke 不额外记录完整配置或凭据;CLI 与 Controller 不对已生成的日志消息或字段进行二次脱敏。 - Apple Unified Logging 的 subsystem 固定为 `io.github.yuandevteam.vole`,category 为 `vole`,不继承宿主应用的 bundle identifier;Console 或 `log stream` 应按该标识筛选。 + + +## 独立 HTTP 设置与私密上下文 + +`validateConfig`、`start`、`reconnect` 可选 `httpConfigYaml`。HTTP YAML 不承载 CA;旧数组规则、字符串域名项和共享 PEM 字段均拒绝。字段、动作与 provider 见 [HTTP 处理](http-processing.md)。 + +```json +{ + "method": "start", + "instanceId": "1", + "payload": { + "configYaml": "rules: ['MATCH,DIRECT']", + "httpConfigYaml": "mitm: {enabled: true, domains: [{pattern: example.com}]}", + "httpContext": { + "baseDir": "/absolute/http-settings", + "ca": {"certificate": "...PEM...", "privateKey": "...PEM..."} + } + } +} +``` + +baseDir 使用原生路径元数据并且必须绝对;相对 file 来源必须有该上下文。HTTP 缓存使用已初始化 dataDir。CA 仅由 start/reconnect 和 Windows startVpn 接受;私密 DTO 不可普通序列化、Debug 脱敏,临时密钥输入在释放时零化。关闭 MITM 不要求 CA。 + +validateConfig 只静态校验,不读取外部 HTTP provider 或 CA。返回 `httpResourcesValidated` 表示外部资源是否全部可在输入中校验,`httpsReady` 固定 false;静态通过不代表真实 HTTPS 就绪。foreground 的 validate 也不读取 `httpCaPath`。 + +### `reconnect` + +实例级、只允许 running,payload 与 start 相同。先准备新资源候选,再执行停止屏障并启动。后续启动失败时用临时保留的旧主配置、实际 provider 内容及原 HTTP/CA 快照恢复,方法仍报告失败。新候选准备失败不停止旧引擎。恢复也失败则回到 stopped 并明确报告;普通 Stop 不长期保留恢复密钥。 + +updateConfig 省略 HTTP 时继承 active;同设置可继续使用 active。显式改变 HTTP(包括空串关闭)或来源基目录返回 reconnect required,整个 routing 更新失败;不把待应用候选混入当前会话。节点切换与普通 provider 更新也不应用 pending HTTP。 + +### `refreshHttpProviders` + +运行时级,要求 initialize,无须创建或启动实例。只接受 `httpConfigYaml` 与可选只含 baseDir 的 httpContext: + +```json +{"method":"refreshHttpProviders","payload":{"httpConfigYaml":"...","httpContext":{"baseDir":"/absolute/http-settings"}}} +``` + +完整准备与缓存提交成功返回 `{"resourcesReady":true,"applied":false}`。未运行时只执行一轮;运行时与现有资源更新共用受保护出口、候选许可、取消和 Stop 屏障。周期 HTTP 更新只发布待应用候选;file 不 watch,interval 0 不周期刷新。Windows UWP 的物理更新由已绑定的 Session Host 承担,普通前台 Invoke 不回退到未绑定 socket。 + +### `testHttp` + +运行时级离线演算,不初始化、不联网、不读文件、不读取 CA。输入必填 httpConfigYaml 和 url,可选只含 baseDir 的 httpContext、request/response 样本: + +```json +{ + "method":"testHttp", + "payload":{ + "httpConfigYaml":"rewrite: {rules: [{url: '.', action: reject}]}", + "url":"https://example.com/path", + "request":{"method":"POST","headers":[["Content-Type","application/json"]],"bodyBase64":"e30="}, + "response":{"status":200,"headers":[["Content-Type","application/json"]],"bodyBase64":"e30="} + } +} +``` + +method 默认 GET,response.status 必填;headers 是保留顺序与重复项的二元数组,bodyBase64 表示原始编码实体。外部资源只取同来源已准备内存候选;缺少则返回 resourcesReady false/unprepared,不伪装为未命中。总开关关闭仍可演算启用规则;没有样本只报告被选中。结果包含 U0/U1、来源索引、URL 结果、各阶段执行或旁路与样本输出,httpsReady 不宣称真实信任就绪。结果不含 CA,也不持久化正文。 + +### foreground 私密文件 + +可选 httpConfigPath、httpCaPath 都是明确的普通文件路径,不自动搜索、不从环境获取;HTTP 文件最多 1 MiB。私密文件格式为 `{"certificate":"...PEM...","privateKey":"...PEM..."}`,Unix 要求当前用户所有、group/other 权限全关闭,并拒绝符号链接。foreground 保留 HTTP 文件目录作为 baseDir。CLI 只传路径,读取仍归 Invoke;空路径与 `-` 拒绝。 diff --git a/docs/mihomo-compatibility.md b/docs/mihomo-compatibility.md new file mode 100644 index 0000000..709251c --- /dev/null +++ b/docs/mihomo-compatibility.md @@ -0,0 +1,114 @@ +# Mihomo 兼容原则与范围 + +## 兼容原则 + +Vole Core 的性能优化综合评估**吞吐、CPU、内存和丢包率的实测收益与代价**,所有指标都允许渐进式改进;优化采纳与最终验收分别遵循[验收边界](acceptance.md#性能采纳标准)。与 Mihomo 的兼容要求是:在 Vole 支持的协议和模式范围内,与 **Mihomo listener 和官方协议 server 端互通**,不寻求配置字段行为完全一致。 + +TUIC v5 的 native/quic 服务端互通目标为官方 [Itsusinn/tuic](https://github.com/Itsusinn/tuic)。 +官方服务端未提供的 UoT v2 与服务端 mTLS 客户端证书认证使用 Mihomo 验证,分别保留对应对端的验收范围。 + +实现策略、默认值、窗口、缓冲区、定时器和资源生命周期可按 Vole 的性能目标取舍;差异必须在对应契约中明确说明,并保持协议数据正确性、认证和安全边界。字段名称继续参考 Mihomo,当前接受的配置仍以 Vole 文档为准。 + +兼容验收以真实对端的握手、认证、数据传输和关闭验证为依据;内存与吞吐通过相同输入和目标负载的压力测试验证。逐字段对照用于确认支持范围和说明差异,不以字段行为相等作为验收门槛。性能对照客户端固定为 Vole 和 Mihomo;兼容问题独立记录,实际收发吞吐包含错误数据,CPU、内存和丢包率照常比较。逐指标判据与测量边界见[验收边界](acceptance.md#性能采纳标准),服务端互通目标保持。 + +## 当前支持范围 + +配置名称以 [Mihomo v1.19.32](https://github.com/MetaCubeX/mihomo/tree/88dcbf7f1614a67c3b36b848ee3592dfa92ada36) 为参考。 +Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配置子集不等于完整替代 Mihomo。 +未知字段和不支持的组合明确拒绝,不静默修改输入。已废弃的 dns.fallback-filter.geosite 不接受,使用 nameserver-policy 的 geosite 选择器。 + +| 范围 | 当前支持 | +| --- | --- | +| 配置与选路 | YAML anchor/merge;rule/global/direct;内置 DIRECT/COMPATIBLE/REJECT/REJECT-DROP;域名、地址、端口、网络、GeoData、RULE-SET 和 AND/OR/NOT | +| 物理出口 | 全局/节点 interface-name、Linux routing-mark、节点 tfo/mptcp 与 Provider 对应 override、物理 TCP 与指定 UDP 节点的 IP4P 转换、普通 DNS #接口;平台约束和 auto-route 防环见出站与 TUN 契约 | +| 资源 | HTTP/file/inline proxy/rule providers;YAML/URI/标准 Base64 订阅、AGE;共享流式 gzip/URL Basic;动态成员、筛选、覆盖、健康检查;Provider dialer-proxy 与 HTTP 订阅信息;YAML/Text 与 domain/ipcidr MRS;有界离线规则bundle;失败保留旧代 | +| 策略组 | select/url-test/fallback/load-balance;默认和持久选择;延迟容差、恢复、三种负载均衡策略;旧连接保持原路径 | +| DNS | hosts(含启动 LAN 地址快照)、bootstrap/节点/直连解析角色、policy、respect-rules;UDP/TCP/DoH/DoT/DoQ、DoH/H3;fallback;redir-host/fake-IP 与映射持久化 | +| mixed-port | HTTP/SOCKS5 TCP 与 SOCKS5 UDP;LAN 地址限制、多用户认证、认证跳过网段;allow-lan 不强制认证 | +| 出站 | 具名 direct/dns/reject;HTTP(S) CONNECT、SOCKS5、AnyTLS、Trojan、VMess AEAD、VLESS、Hysteria2、TUIC v5、Shadowsocks 2022、标准 WireGuard 单/多 peer、TrustTunnel H2/H3、Mieru TCP/UDP 载体、MASQUE H2/H3 IP 与 H3 L4;协议依赖启用的 Cargo feature | +| sing-mux | h2mux/smux v1/yamux、padding、TCP/UDP;brutal-opts 远端带宽协商及受限 Linux 本地发送侧设置 | +| QUIC 配置 | HY2 独立 initial/max 接收窗口、秒 handshake-timeout;TUIC v5 入口ip、毫秒heartbeat、反向命名的stream/connection窗口、所有者池阈值、frame/fragment限制、PMTUD与UoT v2 | +| 客户端 TLS | tls.custom-certifactes 证书内容;启动时合并可读系统根、WebPKI 与显式根并跨组件共享;TUIC v5 与其它 TLS 节点复用 cert/private-key 身份加载 | +| 嗅探 | TUN/mixed TCP 的 HTTP1/HTTP2/TLS;TUN 与授权 SOCKS5 UDP 共用 QUIC v1/v2;域名/地址排除、强制条件和目标覆盖 | +| Linux 自建设备 | auto-detect-interface、auto-route、IPv6 地址、CIDR 捕获/排除、输入接口与 UID/端口作用域、路由索引、strict-route 与 systemd-resolved DNS 接管;失败和停止时恢复本次修改 | +| 运行状态 | 连接快照、流量计数和关闭;组与资源状态;规则命中、有效配置、受限 DNS 诊断 | + +[业务 HTTP 处理](http-processing.md) 是独立扩展:URL rewrite 与选择性 HTTPS MITM 使用单独输入, +不向 Mihomo 主配置增加字段;CA 信任与安全存储归宿主管理。 + +## 必须保留的边界 + +- 业务入口只有 mixed-port 与 TUN;不支持 port、socks-port、listeners、redir/tproxy 或服务端协议配置。 +- tls 同时接受客户端信任根及 Controller 服务端 TLS 配置;无效显式客户端信任根拒绝配置,Mihomo 会跳过无效项。iOS/tvOS、Android 不保证取得完整系统信任库; + 所有后端仍使用 WebPKI 校验,未复制 Go 的原生系统验证器。详细范围见 [TLS 契约](tls-client-fingerprint.md)。 + 独立测量 YAML 只接受 proxies 与同名客户端 tls 子集,不接受 TLS 服务端配置。 +- Shadowsocks 只支持三个 2022 cipher;插件支持 obfs 的 http/tls、v2ray-plugin/gost-plugin 的 websocket、JLS 和 strict ShadowTLS v3,UoT 仅 v2。 + 通用 ShadowTLS v3/JLS 与 VMess/Trojan 的流式 REALITY 沿实际 TCP TLS 消费路径接入, + 各协议和 XHTTP 两腿的组合门禁见出站契约;不把公共字段推导成所有载体均可使用。 +- DNS 不提供独立 listen。DoQ、DoH/H3、prefer-h3、ECS、上游类型过滤、TLS 验证参数、 + DoT 独立禁用复用、LRU/ARC 与显式 system/DHCP 上游已支持, + 来源发现和物理绑定的可用平台及参考差异以 DNS 契约为准。 + DoH 使用 GET、URL Basic 与实际 H3/H2/H1;详细解析角色、缓存、预算与过滤行为见 [DNS 契约](tun-icmp-dns.md)。 +- Provider/group 名称筛选、改名和 DOMAIN-REGEX 使用有界的常用复杂表达式子集, + 支持 lookahead、定长 lookbehind、数字/ASCII 命名 backreference 与两种命名拼写。 + 混合捕获按 .NET 的未命名优先、命名随后编号,匹配与名称替换使用同一编号; + 普通表达式保留 Rust regex 快速路径。不支持重复命名、显式数字组、递归、条件/平衡组或 + 完整 .NET 方言。编译、执行和输出预算保持;GeoSite regex 的现有线性语义不扩展。 +- gRPC 三协议共用选项和节点连接池。负 ping-interval 明确拒绝,不复制固定参考 + HTTP/2 客户端的立即重复 PING;超出本机可表示期限的正秒数也明确拒绝,不复制 Duration 溢出。 +- 出站 mTLS 的文件身份由既有资源工作器检查,整对校验后供新物理连接使用;旧连接可继续, + 更新不重建代理图或重置健康状态。活跃文件对的已发布 PEM 输入合计最多 8 MiB,超限或失败保旧; + 内联身份与业务 HTTP/MITM CA 固定,详见 [TLS 身份](tls-client-fingerprint.md)。 +- 订阅元信息只在成功候选或合法 304 后发布,失败响应或坏候选保留旧值; + 与 Mihomo 在读取响应头时更新的时点不同。数值仅接受有限且可表示的十进制数, + 不保留非有限/溢出浮点转换的参考行为。 +- URI 转换接受 SIP003 的 obfs、v2ray-plugin 参数,不把插件 URI 参数通透转为 YAML。空 host 沿严格插件的默认值, + WebSocket TLS 仅由明确的 `tls` 标记启用。转换仍受当前严格节点/版本范围限制;HTTPS URI 节点默认校验证书,不复制参考 + 转换器的隐式跳过校验。Provider 缓存按精确 URL 与实际 header/UA 摘要隔离, + GeoData 按精确 URL 摘要隔离;凭据不写入来源元数据或 Debug。 +- WireGuard 使用标准用户态单/多 peer、最长前缀选择、reserved(数组或 Base64)、节点远端 DNS 与按需端点更新; + 每个本地地址族最多一个地址,MTU 至少1280。无 peers 时顶层 allowed-ips 与参考一致地忽略。 + peer 仅在实际使用时解析/建链;Mihomo 在节点初始化时解析所有 peer。 +- `mptcp` 是物理 TCP 首跳的可选能力,不改变协议报文。仅 Linux 的宿主管理、无 + protector、显式/自动接口、专用 DNS binding 或源 IP 约束路径尝试;其余路径使用普通 + 受控 TCP,保留原保护与绑定的失败关闭。本机/对端不支持时允许 TCP 回退; + 系统路径管理器、额外子流与出口网络配置仍归宿主,不将初始 socket 绑定推导为全部子流受控。 + 这比 Mihomo 的统一 MPTCP 开关更保守;原生 UDP/QUIC 不消费该字段。 +- `smux.brutal-opts.enabled/up/down` 的远端协商不限定客户端平台,要求对端实际启用 + TCP Brutal;本地模块缺失不否定有效远端协商。Linux 仅对可确认独占的直连物理 + TCP 尝试本地设置,不解包代理 hop、共享 H2/gRPC、XHTTP 多腿或 QUIC 的逻辑流, + `mptcp: true` 同样不设置本地 Brutal。Core 不加载模块或修改系统规则;单会话与 + 退役等待保持有界,详见 [sing-mux 契约](xhttp.md#通用-sing-mux)。 +- VLESS `packet-encoding` 省略、`xudp` 或 `none` 均使用 XUDP;`packetaddr` / `packet` 保留逐包 IP 地址编码,Vision 可使用 `none`。 +- Hysteria2/TUIC 的业务 UDP 始终可用;节点 `udp` 可省略或填 true/false,均不禁用它,组 `disable-udp` 仍生效。 +- 节点 UDP 能力按 [出站契约](outbounds.md) 声明;rule 模式跳过不支持 UDP 的目标,继续匹配后续规则。 + global 模式和显式 DNS 出口不执行此跳过。负载均衡组按其声明能力参与规则匹配,实际选中成员仍须支持 UDP。 +- 未启用 mixed-port 或 TUN 时仍可运行 Controller 和资源任务。Controller 接受通配 IP、localhost 和空 host;secret 可选,见 [Controller API](controller-api.md)。 + CORS 按 origins/预检/私网字段处理;HTTP 与 HTTPS H2/H1 共用 handler 与 Bearer,支持服务端证书文件更新和五种客户端认证模式。 + /dns/query 查询真实上游;连接表按 UDP 目标流管理,memory 返回当前进程 resident bytes,平台口径不同。 +- fake-IP TTL 最小一秒;映射生命周期独立于 TTL。本地选择和映射存储不保证与 Mihomo 数据库互读。 +- DNS、fake-IP 与嗅探的 classical selector 对完整 AND/OR/NOT 求值;参考只提供域名或目标 IP, + 其余元数据沿零值 TCP/HTTP、端口0、空入口名和未知身份,不伪造业务来源、53端口或进程身份。 + selector 不发起额外地址解析或进程扫描,仍共用原决策预算。业务 IN-NAME 使用固定的 + DEFAULT-TUN/DEFAULT-MIXED,内部请求没有业务入口名,不新增 listeners。 +- Apple、Android、Windows UWP 的网络设置、权限和 App 生命周期仍归宿主;Windows Wintun 使用外置 DLL。 + 借用 Unix fd 只复制并保留原描述符。Linux 本机捕获范围与网络恢复见 TUN 契约;旁路由、防火墙/NAT 不在当前范围。 + 六类进程规则与 find-process-mode 按真实可获取的本机身份工作,UID 仅 Linux/Android; + 不复制 port-only、跨 namespace 或任一同 UID 进程的弱归属推断。权限、预算或平台无法证明时未知, + 不提权、不增加 Android owner 回调;范围和扫描预算见配置契约。 + +平台编译、内存回归、CLI 对照诊断、协议互通和压力测试是不同的验证层次,见 +[验收边界](acceptance.md)。任一层通过都不代表所有字段取值、组合、平台或真机行为已验证。 + +QUIC 使用官方 Quinn 固定 Middle 接收窗口:每流为 max(initial, min(maximum, 4 MiB)), +连接为 initial,不复现 Mihomo 的自适应窗口。initial>max 保留初值,max 大于 VarInt clamp, +无法编码的 initial 明确拒绝;credit 通告沿 Quinn 原生实现。 +HY2 默认每流 64 KiB、连接 initial 4 MiB,优先多流公平性与接收驻留; +较高 RTT 的单流可显式增大窗口,配置不按这些默认值钳位。 +HY2 正 handshake-timeout 的共享建链由节点持有,单次调用退出不会提前销毁它; +每个等待调用仍受自己的期限约束,失败与 Stop 等待驱动清理。TUIC max-open-streams=1 接受但实际不能取得业务所有权; +frame 无法容纳 TUIC 头时可解析,业务 UDP 发送失败,不额外拒绝TCP配置。支持模式的互通与窗口增大后的 RSS/吞吐/UDP loss 不由 +这些内存 fixture 证明,由最终 CLI/container 对照分别记录。 + +Provider override 表达式的 upcase/downcase 使用 Rust 标准 Unicode 全映射,可能产生多个字符; +输出、取消和计算预算保持。它不固定到 Go 的 Unicode 15 单字符映射,也不改变 IDNA 域名处理。 diff --git a/docs/outbounds.md b/docs/outbounds.md index ce4fce2..9acdf65 100644 --- a/docs/outbounds.md +++ b/docs/outbounds.md @@ -7,33 +7,352 @@ VLESS/Vision/Encryption/JLS/ECH 见 [VLESS](vless.md),XHTTP/sing-mux 见[专 - 物理 TCP/UDP 只能由共享 Dialer 创建,保留 Android protect、Windows 物理绑定和受控解析。 传输包装器只消费 IO,不另建 socket 或系统 resolver。 -- `dialer-proxy` 可引用节点或静态 select 组;与业务路由共享选择,完整 DAG 在配置期验证。 +- 全局与节点 `interface-name/routing-mark` 使用同一物理出口策略:节点非空接口/非零 mark + 分别覆盖全局,空/0 继承;未指定接口时保留平台默认或 Linux TUN 自动识别。 + 接口支持 Linux、Apple 和 Windows;名称在建 socket 时核验,失效不回退。 + 回环物理目标保留本机路径,不套用公开接口或 mark;UWP 原有字面量回环门禁不变。 + Android 显式接口尚不可用,protect 仍强制执行;非零 mark 仅 Linux 可用。 + UWP 显式接口只能匹配 Provider 当前物理适配器,不能替换源地址/索引。 + Linux 自建 TUN auto-route 的捕获规则只匹配零 mark;物理首跳保留非零公开 mark, + 只有未配置 mark 才注入私有防环值。动态 Provider 候选沿用相同策略,失败仍保留旧代。 +- 代理链只由实际物理首跳消费出口策略;当前节点不覆盖其上游节点的策略。 + 上游组选择 DIRECT 时使用当前节点策略。XHTTP 两腿、多端点刷新、跳端口和新 QUIC + socket 沿用其所属路径的策略与选择快照;已建立连接不迁移。DNS、Provider、GeoData + 和健康检查继续使用当前运行图的受控出口,不建立额外裸 socket。 +- `dialer-proxy` 省略/空串使用物理 Dialer,也可指定 DIRECT、COMPATIBLE 或引用节点、策略组;与业务路由共享选择,完整 DAG 在配置期验证。 一次建链使用组快照和同一绝对期限;已有连接不随切组迁移,无自动 failover 或 DIRECT 回落。 +- 节点 `tfo` 缺省 false,具名 direct 同样可用。Linux 在只剩一个允许的物理 TCP 地址时 + 使用 TCP Fast Open;多地址保留普通串行/并发建链,其他系统使用普通受控 TCP。 + 代理链由实际物理首跳消费自己的 `tfo`,组选择 DIRECT 时使用当前节点值;不影响原生 UDP。 + TFO 不把尚未连接的 socket 当成地址竞速赢家;实际地址来自同一出口校验/转换, + 首读、空写、flush 和零上传半关闭也推进握手,并保留同一建链期限与同步 Stop。 + 不缓存、重新拨号或重放已接受的业务字节。Provider 同名覆盖保留显式 false。 +- 节点 `mptcp` 缺省 false,具名 direct 同样可用,Provider 同名覆盖保留显式 false; + 均只接受非 null 布尔值。Linux 的无约束物理 TCP 首跳可尝试 MPTCP,保留既有 + routing-mark 与防环策略;路径管理器、额外路径与系统路由由宿主管理,Core 不配置子流。 + 有 socket protector、显式或自动接口绑定、专用 DNS binding、源 IP 约束时使用普通 + 受控 TCP,避免内核额外子流越过初始 socket 的保护范围。Linux 自建 TUN 的 + `auto-detect-interface` 同样属于接口约束;启用 MPTCP 的宿主须自行确保物理路径与防环。 + 其他系统或本机/对端没有 MPTCP 能力时允许普通 TCP 回退,但保护、绑定及实际 IO 错误 + 不触发绕过策略的重拨。代理链仅实际物理首跳消费自身值,组落到 DIRECT 时使用 + 当前节点值;原生 UDP/QUIC 不受影响。多路径协商不代表已建立多条子流。 +- 普通协议节点和具名 direct 的 `ip-version` 为 `dual`(默认)、`ipv4`、`ipv6`、`ipv4-prefer` 或 `ipv6-prefer`。 + 限制或排序该节点的物理服务器地址与本地解析结果,仍受全局 IPv6 开关约束。 + 不要求远端代理按相同地址族解析业务域名。 +- `experimental.dialer-ip4p-convert` 缺省 false;开启后,物理 TCP 与 SS2022、SOCKS5、WireGuard + 的原生 UDP 节点端点识别 `2001:0000::/32`,按编码的 IPv4/端口建立实际首跳。 + 转换先于实际地址族、源地址和接口绑定;逻辑 server、TLS SNI、认证、代理链目标保持原值, + 收包校验使用实际端点。经代理的逻辑端点不在本地强制转换,实际物理首跳按其自身策略处理。 + 不转换 DIRECT 无连接业务 UDP、DNS 答复或其他原生 QUIC 节点入口,不增加缓存或连接池。 + 域名的编码地址不能通过转换取得新的回环例外;UWP 物理绑定下同样拒绝编码地址转为回环。 + 显式普通回环节点保留原行为;解析查询仍遵循 DNS 的既有 IPv6 开关,不为 IP4P 自动增加 AAAA 查询。 - gRPC/legacy H2 首次读取延迟响应头时先检查原建链期限,即使响应已就绪也不能绕过 - 超时;响应头已确认后的业务读取不再受该期限限制。 + 超时;响应头已确认后的业务读取不再受该期限限制。VMess/Trojan gRPC 由外层在原期限内 + 等待对端 H2 SETTINGS、提交并 flush 完整认证请求,此后允许 client-first 业务等待响应, + 不再沿用该期限;未完成 H2 握手或请求提交时仍受原期限限制。 - HTTP、SOCKS5、TUN、DNS 选路及 node-only `measureDelay` 使用同一连接器。 - UDP 默认为 false;收发预算分别逐层扣封装开销,超限不截断交付。 + 除 Hysteria2/TUIC 的业务 UDP 始终可用外,普通协议节点的业务 UDP 默认为 false; + 组 `disable-udp` 仍可禁用业务 UDP。收发预算分别逐层扣封装开销,超限不截断交付。 + Hysteria2/TUIC 的固有能力声明不替代对端、上游和实际数据报预算的检查。 +- UDP 关联复用由共享路由层管理。DIRECT、Mieru、TrustTunnel、WireGuard 内层 UDP、 + MASQUE/IP 内层 UDP 和 VMess packetaddr 均提供逐包目标与实际回包来源,可复用多目标关联。 + 开启逐目标连接追踪时,仅同一来源、同一具体出站实例和同一资源代际的未改写 IP 目标共享; + 逐目标路由、统计、关闭、关联退役和旧回复隔离见[资源策略](runtime-resource-policy.md#udp)。 + 域名、fake-IP 和需要地址还原的目标保持独立。VMess raw 的首目标约束不变; + Gecko 等固定对端的底层适配也不因此获得多目标能力,外层业务关联按自身能力处理。 + 每层明确声明共享能力,透明包装传递底层声明,不根据外层协议或字段推断其内部载体可共享。 +- 所有普通节点都可启用公共 `smux` 包装,使用节点自己的正常 TCP 传输与独立 mux 池, + 业务路由、`dialer-proxy`、provider 代际与测速都使用最终包装器。 + `only-tcp: false` 提供 mux UDP,包括底层 `udp: false` 或仅支持 TCP 的节点; + true 则原样委托底层 UDP,继承其能力。组 `disable-udp` 仍生效。 + 对端必须解码 sing-mux;普通 HTTP/SOCKS5 listener 不因此自动提供解码。 + 字段、半关闭及池边界见 [通用 sing-mux](xhttp.md#通用-sing-mux)。 +- `smux.brutal-opts` 在新 mux 会话接纳业务前完成一次远端 TCP Brutal 带宽协商, + 不限定客户端平台;对端必须支持并启用它。本地上行仅在 Linux 可证明独占的直连 + 物理 TCP 上尝试,保护与绑定保持。代理 hop、共享/多腿传输及 `mptcp: true` 不设置 + 本地算法,即使 MPTCP 已回退普通 TCP;完整协议范围见上述 sing-mux 契约。 + Core 不安装或加载模块,本地不可用不否定有效远端协商;参数设置失败恢复旧算法, + 恢复失败终止物理 socket。Brutal 使用单会话池,退役等待旧业务释放,沿原期限与 Stop。 - TLS 使用共享 WebPKI、叶证书 pin/非叶信任锚与 skip 策略。pin 不匹配不能被 skip 绕过, - TLS 握手签名始终验证。节点策略与恢复缓存不可变、互相隔离,标准 TLS 总票据预算为 4。 + TLS 握手签名始终验证。节点策略固定,恢复缓存按节点和身份代隔离,标准 TLS 总票据预算为 4。 字段及模板详见 [TLS 证书与指纹](tls-client-fingerprint.md)。 -- 未知字段、null、错型、非法枚举或不适用的传输选项在网络 IO 前拒绝。 + SNI/servername 空串使用协议默认名称;pin/name-cert-verify 空串关闭对应覆盖。 + certificate/private-key 均省略或为空时不启用客户端身份;非空身份仍须成对且有效。 +- 未知字段、错型、非法枚举或不适用的传输选项在网络 IO 前拒绝;显式 null 通常也拒绝, + HTTP 出站已有的可选 TLS 字段保留 null 等同省略的行为。 凭据、UUID、业务目标和完整请求不进入 Debug/错误;资源与 Stop 见[公共策略](runtime-resource-policy.md)。 +`experimental.quic-go-disable-gso` 缺省 false,`quic-go-disable-ecn` 缺省 true。 +这两个会话配置只由 Linux 上 TUIC、XHTTP H3、DoQ/DoH H3 的 DIRECT 物理首跳消费, +使用共享 Dialer 已保护、已绑定的同一 socket;不修改宿主进程环境。 +GSO 分段批次依实际路径与预算每次最多 10 段、整批最多 65,507 字节,每段保留原 UDP 预算。 +未分段的数据报沿用原方向预算,IPv6 可达 65,527 字节;关闭 GSO 后只发独立数据报。 +DoQ/DoH H3 的当前 DIRECT 发送预算为 IPv4 65,507、IPv6 65,527 字节,接收预算为 4 KiB,分段能力为 1; +这些 DNS 路径使用同 socket 的原生收发与 ECN 元数据,不据此声称实际 GSO 批量发送。 +ECN 开启时传递实际发送标记和接收元数据;关闭时不发送/交付 ECN 标记。 +系统无法开启 ECN 接收元数据时,保留同一受保护 socket,沿用原单包、无元数据路径。 +GRO 保持关闭,接收仍使用一个有界完整包,不放大 QUIC 接收区或 socket 缓冲。 +代理 hop、HY2 的 pacer/混淆/Realm、quiche 后端以及非 Linux 平台当前保持单包且不保留 ECN; +这些路径不声称消费新元数据能力。停止时撤销物理 socket 的共同租约;保留已关闭的 QUIC +连接或写就绪 poller 不会延长物理 fd 的生命周期。GSO 的明确能力错误只关闭后续批量发送,失败批次 +作为 UDP 丢失交给 Quinn 的正常 ACK/重传;不会立即重发该批次,不掩盖地址、策略或 ECN 错误。H3 的正常传输、认证、PMTUD、取消及同步 Stop 仍沿原所有者。 + +VMess + TLS、Trojan、AnyTLS、TrustTunnel、VLESS 和 Hysteria2 支持 `ech-opts.enable/config/query-server-name`, +静态优先和动态节点 DNS 来源、后端算法及失败边界见 [ECH](vless.md#ech)。 +TUIC 的固定参考未消费 ECH,因此不接受该字段。 + +## 流式安全身份 + +VMess/VLESS 在 `tls: true` 时接受 `shadow-tls-opts` 或 `jls-opts`;Trojan/AnyTLS +沿其固定 TLS 路径接受这两种身份,不增加 `tls` 开关。ShadowTLS 必须显式 +`version: 3` 和非空 `password`,SNI 使用节点现有 `servername/sni`;SS2022 插件 +仍使用自己的 `plugin-opts.host`。普通 TLS、ShadowTLS、JLS、REALITY 是同一腿的互斥模式, +不叠第二次 TLS,也不在认证失败后降级。ShadowTLS/JLS 只用于对应节点已有的 TCP 承载传输; +XHTTP 仅 H1/H2,主腿和下载腿独立持有身份。两者不能与 Vision 或 ECH 混用。 + +ShadowTLS cover 接受共享 pin/skip/name-cert-verify 与成对 `certificate/private-key`, +它们只影响 cover 握手;JLS 以用户名/密码认证,不能混用证书策略或 mTLS。 +ShadowTLS/JLS 均使用原生完整 TLS 1.3 认证且关闭会话恢复;指纹不能替代密码认证。 +受控上游、建立期限、取消和 Stop 仍由原连接器所有者管理。 + +VMess 的 TCP/HTTP/H2/gRPC、Trojan 的 TCP/gRPC 可用 `reality-opts`,须提供有效的 +`client-fingerprint`;WS/HTTPUpgrade 不接受 REALITY。REALITY 不混用证书策略、 +mTLS 或 ECH,沿用 [REALITY](reality-wire-protocol.md) 的无回退身份与密钥交换边界。 + +## 功能出站 + +具名 `direct`、`dns`、`reject` 只需 `name/type`,没有代理服务器端点,均提供 UDP。 +`direct` 另接受 `ip-version/interface-name/routing-mark/tfo/mptcp`,通过共享 Dialer 直连业务目标, +域名使用当前会话的 `direct-nameserver` 角色,并保留其取消和地址族策略。 +`dns/reject` 不接受未被其实际消费的节点出口参数。 + +`type: dns` 将普通规则选中的 TCP/UDP DNS 查询交给当前会话的共享解析器;目标端口不限于 53, +不新增 DNS 监听端口或解析引擎。TCP 与 TUN DNS 接管共用有界的长度帧处理,支持连续查询及半关闭; +UDP 保留原目标作为响应源,每个关联只保留一个活动查询、一个待处理请求和一个待交付响应, +队列已满时丢弃新请求。查询及响应上限为 4096 字节,响应还受入口实际 UDP 预算限制。 +丢弃会话取消其任务,停止节点关闭准入并等待所有工作退出。独立测量没有运行会话解析器时, +不能使用具名 DNS 出站。 + +具名 `reject` 立即结束 TCP,丢弃 UDP 并立即结束其接收路径;没有 `action` 字段。 +内置 `REJECT` 保持建链拒绝及 UDP 逐包丢弃。 +内置 `REJECT-DROP` 的 TCP 读写等待同一个 60 秒期限,不发往远端、不另留请求副本, +Stop 立即取消等待;UDP 逐包丢弃,沿用关联的正常空闲清理,不套用 TCP 延迟。 +`COMPATIBLE` 在规则、代理组、DNS 上游选择和 `dialer-proxy` 中使用直连语义。 + +## 规则重匹配 + +`type: rematch` 是规则控制,只有 `name/type/target-rematch-name/target-sub-rule`, +没有服务器、物理连接或额外任务。两个 target 字段至少提供一个非 null 值;null 与省略一致。 +省略某字段保留本次决策原值。`target-rematch-name: ''` 清除标记;非空字符串按原样设置, +`REMATCH-NAME,a/b` 将各项 trim 后逐字、区分大小写比较,不接受空项。 + +`target-sub-rule` 指向已定义子规则列表时,从该列表首条重新求值;存在但为空的列表耗尽后 DIRECT。 +空字符串或不存在的名称回到主列表首条;省略时保留当前根列表。 +它不是 `SUB-RULE` 调用,不返回原调用位置,也不要求名称在配置时存在。 +主列表中的普通 `SUB-RULE` 调用和 `PASS/PASS-RULE` 保留原作用域。 + +业务 rule 模式及 DNS `#RULES` 共享此流程,组间接选中 rematch 时也生效,先于 UDP 能力检查。 +重入保留来源、目标、已解析地址、process 结果和同一次决策的取消、期限、总执行预算与规则开关快照。 +同一决策最多进入 32 个 rematch 节点,重复节点名称或超预算即失败关闭;不会建立空连接或回落 DIRECT。 +已建立连接保持原路径。global 模式、显式 DNS 出口、`dialer-proxy`、健康探测和 node-only +`measureDelay` 不执行该控制;物理建链选中它时拒绝,测速配置直接拒绝 rematch 节点。 + +## HTTP / HTTPS CONNECT + +`type: http` 提供 TCP CONNECT;`tls: true` 在代理链路使用共享 TLS 与证书策略。 +支持 `username/password`、`headers`、`sni`、`skip-cert-verify`、`name-cert-verify`、 +`fingerprint` 及成对的 `certificate/private-key`(PEM 或文件路径)。用户名和密码均非空时发送 Basic 认证。 +HTTP CONNECT 本身不提供 UDP,也不接受 `udp` 字段。公共 smux 可承载 UDP。 +握手沿用原期限,失败关闭, +成功后保留响应头后的业务字节。启用此协议需要 `outbound-http` feature。 + +## TrustTunnel + +`type: trusttunnel` 提供 H2/H3 CONNECT TCP/UDP,使用 `outbound-trusttunnel`。 +quic 缺省 false 使用 TLS + HTTP/2;true 使用 QUIC v1/TLS1.3 + HTTP/3,无 0-RTT。 +username/password 默认空字符串,始终发送 Basic 认证;不增加非空限制。 +SNI 缺省/空串使用 server,ALPN 缺省/空列表随模式使用 h2/h3,自定义列表必须包含该值, +H3 实际握手必须协商 h3;H2 接受协商 h2,或在对端未返回 ALPN 时使用 H2 prior knowledge, +兼容 Mihomo listener。明确协商其他协议仍拒绝,不回退 HTTP/1。两模式使用共享证书策略、mTLS 和 ECH。 +client-fingerprint 仅 H2 消费;H3 接受该字符串但不校验模板名、不应用 TCP ClientHello 模板。 + +三项 max-connections/min-streams/max-streams 全为 0 才应用 8/5/0。 +逻辑客户端池先选择活跃流最少的客户端,零活跃流始终复用。 +max-connections > 0 时,达到客户端数上限或该客户端流数 < min-streams 即复用; +max-connections <= 0 时,只在 max-streams > 0 且流数 < max-streams 时复用。 +各分支未满足复用条件时增加客户端。 +混合配置和负阈值不额外拒绝;池参数是分配阈值,不是业务流总量上限。 +每个逻辑客户端复用自己的物理连接,空闲30秒后关闭;H3 的 QUIC 空闲期限74秒, +不增加周期 QUIC keepalive。逻辑空闲回收与 QUIC 传输空闲是不同期限。 + +TCP 流使用原始字节,无 gRPC 帧;建立流后独立消费响应头,允许服务端等待 client-first 业务。 +udp 缺省 false,开启后使用 `_udp2` CONNECT 承载多目标数据报。 +health-check 缺省 false,开启后使用 `_check` CONNECT;成功 CONNECT 响应重置 7 秒计时, +单次探测期限为7秒。两模式的UDP业务都使用可靠CONNECT流,不使用QUIC DATAGRAM。 +关闭只结束当前逻辑流;节点Stop取消并join池、探测、H2/H3及QUIC驱动,释放受控上游。 + +H3 外层无论业务 udp 是否开启,都要求受控上游提供双向至少1200字节UDP路径; +QUIC payload 最大1400并取实际双向上游预算交集,PMTUD不超过该预算,不回落TCP或DIRECT。 +congestion-controller 的 cubic、new_reno 使用官方 quiche 算法;bbr、bbr_meta_v1、 +bbr_meta_v2 均使用 quiche 的 Bbr2Gcongestion。省略或未知名称使用 cubic。 +H2 不消费 congestion-controller。 + +## Mieru + +`type: mieru` 使用 `outbound-mieru`,底层 transport 必填 TCP 或 UDP, +两者均承载 TCP 和 `udp: true` 的多目标 UDP Associate。用户名、密码各为1–64 UTF-8字节, +不 trim;XChaCha20-Poly1305 分段认证使用物理连接建立时的时间派生密钥。 +UDP 载体发送复用既有 boring 的原位 AEAD;接收在启用低熵编码时保留 RustCrypto, +其余接收使用 boring。收发方向各独占认证上下文,后端选择不改变低熵编码或协议字节。 +TCP 载体保留 RustCrypto,均使用原 ring PBKDF2 派生规则,不启用 TLS。 +业务目标通过协议内 SOCKS 请求传递,不进行 SOCKS 认证方法协商。 + +port 与非空 port-range 互斥;范围完整匹配十进制 `起点-终点`,两端为1–65535且起点不大于终点。 +每次新物理连接随机选择范围内端口,通过共享 Dialer 建链,复用连接保留原上游选择。 +MULTIPLEXING_DEFAULT/LOW 对应因子1,MIDDLE/HIGH为2/3;按可复用连接数及因子随机选择 +复用或新建,OFF始终新建。DEFAULT/LOW 将TCP CONNECT与UDP Associate分池。 +TCP载体的TCP CONNECT仅复用没有活动会话和建流预留的空闲连接,并发TCP会话使用独立载体, +避免慢读会话阻塞兄弟流;UDP Associate保留活动连接的概率复用。 +UDP载体的两类业务均保留各自池内的活动连接概率复用,分别持有发送字节预算与命令队列。 +DEFAULT/LOW 的 UDP载体 UdpAssociate 独立池,在原单向流预算恰为32 KiB时,每物理载体 +共享96 KiB发送信用;同载体各SID共用,待确认分片继续持有许可,首次发送不归还信用。 +这是原默认资源策略的例外:低层显式传入相同32 KiB值也使用96 KiB载体发送信用; +其他非默认小/大预算、TCP业务、TCP载体及OFF/MIDDLE/HIGH仍取原值。 +每SID接收预算、发送块上限、四写准入、命令FIFO2和物理TX8不随此信用扩大。 +LOW的UDP载体在原复用桶内优先选择活动会话与建流预留合计较少的连接,同负载按原随机起点选择; +新建桶概率不变。该合计是建流时的非原子调度估计,不作为容量准入或会话上限。 +上述分池不扩大接收预算,但分池和并发TCP仍可能增加物理连接和驱动数量。MIDDLE/HIGH保持两类业务共池, +共享TCP载体仍可能发生慢读队头阻塞。因子不是业务流上限。空闲和累计传输量使连接停止接收新流, +已有流继续工作;退休连接在空闲期限后回收。 + +HANDSHAKE_DEFAULT/STANDARD 发送请求并等待 SOCKS 回复;HANDSHAKE_NO_WAIT 延迟至 +首个业务写入发送请求和数据,再确认回复。枚举必须使用完整前缀。 +标准握手完成后直接交付逻辑字节流,不保留懒握手状态。 +关闭单个流仅释放其会话;节点 Stop 取消并 join 物理读写、清理及 UDP 读取任务。 +写队列、接收字节及 UDP 单包受运行时预算约束;取消不会重放加密帧。 +TCP 载体的 UDP Associate 接收信用取原流容量与单包接收预算(含最大 SOCKS 首部和 +四字节封装)的较小值;发送块与 TCP CONNECT 窗口不变。跨信用边界的已解密帧 +按块复制,包括最后的短尾片,避免排队切片继续持有整帧;物理读取仍先完整认证一帧。 +UDP 包解析后由响应持有已验证的接收缓冲,仅移除 SOCKS 首部视图,避免再次复制 payload。 +UDP 载体的 UDP Associate 写入在原字节预算与命令 FIFO 准入后返回,不逐包等待首次物理发送。 +同会话最多连续准入四笔逻辑写;第五笔非空写在分配新payload与许可前,等待同批最新写入的首次发送回执。 +最新回执完成后才开启下一批;空写不作发送屏障,已就绪的首次发送错误仍优先返回。 +显式 flush 和 shutdown 仍等待同会话最新写入的首次发送完成;准入不释放发送许可。 +TCP业务的写入和flush,以及TCP载体的完成边界保持不变。 +UDP 载体的标准握手 TCP CONNECT 流在显式 shutdown 时,先等原首次发送完成,再等本会话 +待确认数据的累计 ACK,随后发送原 Close。flush仍只等待首次发送,不等待整个物理载体。 +对端关闭或底层终止保留原结果;NoWait、UDP Associate、物理TCP、Drop和活动Stop沿原关闭边界, +不新增ACK等待期限。 +UDP载体上已opened的TCP会话本地Close首发完成后,同一物理载体最多保留16条小型关闭元数据, +以100 ms间隔调度至多两次best-effort重发;满额只放弃最旧重发记录,不拒新业务或改变原首发。 +重发在接收处理后优先占用原每轮TX8中的至多一次机会,不扩大TX额度;本轮接收耗尽额度时顺延。 +缓存不持有会话State、payload或发送许可;认证后的CloseResponse或同SID新写入撤销旧记录。 +Stop和载体原退休直接释放记录,不等待重发,也不延长接收grace或ACK等待。 +这是有限关闭恢复,不是可靠FIN,不补偿接收方向缺失的DATA,也不保证拥塞下的关闭期限。 +UDP 底层使用可靠会话,提供累计 ACK、重传、有序交付、分片和窗口。 +UDP Associate 在每次成功发送后固定该次重传间隔,后续 RTT 更新不移动已发送段的期限; +TCP CONNECT 仍按最新 RTT 计算。两类业务保留原退避、超时上限、快速重传与超时 flight 归并。 +密钥固定于物理载体,每包使用显式 nonce;重传保留明文,重新生成 nonce 和时间戳。 +收发分别取1400字节 MTU 与真实上游预算的交集。正常接收的应用未读队列与乱序缓存 +分别持有接收字节预算,默认各32 KiB。TCP CONNECT在原预算至少32 KiB且可容纳单包时, +给乱序缓存额外32 KiB固定余量,默认乱序容量64 KiB;UDP Associate保持原预算。 +额外余量不通告给对端,窗口仍扣除该余量与缺失首片预留,沿原预算计算。 +累计 ACK 只在连续数据成功进入应用队列后前移。 +乱序缓存优先保留较近的前缀,信用不足时只回收更远且尚未累计确认的数据。 +小于单包的应用预算沿用一个物理 Partial 槽。远端关闭保留已接收的连续前缀供读取。 +已建立 TCP 的正常 CloseRequest 若在原至少32 KiB预算下携带尚未收齐的末尾序号且无 Partial, +最多保留首次收到关闭后的100 ms来接收该序号前的在途数据;重复关闭不延长期限, +原乱序、字节及交付预算不变,新写入仍被关闭。错误关闭、UDP Associate及小预算沿原关闭路径。 +Stop 结束驱动并回收驱动缓存,不等待应用读取;外部仍持有流的已递交数据由读取或释放流回收。 +待确认明文仍持有发送预算。物理驱动每轮最多接收8个就绪数据报、发送8个数据报, +ACK 优先于该会话的批量数据,会话轮换服务;接收每8包让出一次执行机会。 +Mieru UDP 向共享 Dialer 请求512 KiB物理接收缓冲;当前Linux接受时通常为1 MiB有效容量。 +这是内部性能提示,OS可钳位或拒绝;保留原socket、protect、接口绑定及停止所有权, +不改变逻辑包预算,不新增配置字段,协议自有池仍管理自己的物理载体。 +创建60秒后的 UDP 载体停止接纳新会话,已有会话继续。 +`traffic-pattern` 接受标准 Base64 编码的 Mieru protobuf,保留可选字段的省略与显式零值。 +支持 seed、unlockAll、TCP 控制帧分片/间隔、nonce 前缀、padding 及低熵编码; +省略或空值使用协议默认策略。模式仅控制发送,接收不要求对端使用相同模式。 +低熵编码作用于数据密文,认证 tag 保持原值;按真实载体预算缩小明文分片, +不会扩大队列或接收预算。重传重新应用 nonce 与 padding,控制帧分片由物理写任务完成。 + +## MASQUE + +`type: masque` 由 `outbound-masque` 提供。缺省、空串及非 `h2`/`h3-l4proxy` +的 network 使用 H3 `cf-connect-ip`;每个节点按需建立一条共享 IP 隧道,TCP/UDP +经同一个内部 `vole-netstack`,不创建系统 TUN。`network: h2` 使用严格 H2 TLS +与普通 CONNECT,设置 `cf-connect-proto: cf-connect-ip` 和 `pq-enabled: false`; +精确 HTTP 200 后交付隧道,DATA 内 DATAGRAM capsule 直接携带原始 IP,不添加 context ID。 +H2 每 30 秒发送空闲 PING,ACK 期限为 15 秒;不因隧道暂时空闲而回收。 +H3 等待实际 peer SETTINGS 中的 +现代 DATAGRAM 支持,同时发送现代与旧 DATAGRAM setting;原始 IP 使用请求流关联及 +context ID 0。控制 capsule 有界解析,发送 IPv4/IPv6 完整 route advertisement。 +H3 IP 模式在所有受控抽象 socket 平台启用有界 QUIC PMTUD;具有 IPv6 本地地址时, +就绪前确认真实 DATAGRAM 预算可承载 1280 字节 IP 包,未满足时按建链期限失败。 +`ip`/`ipv6` 最多提供两条本地 prefix,标签不强制地址族;未带前缀分别补 /32 与 /128。 +mapped IPv4 归一,重复地址取较长前缀,同族两地址按目标选择源地址。 +MTU 缺省/零值为 1280;只有 IPv4 时允许 68..65535,存在实际 IPv6 时至少 1280。 +收包预算与发包 MTU 分离;TCP 的有效 MSS 和经过活动 tuple/已发送序号验证的 ICMP +PMTU 反馈按连接生效,通过包层软件分段恢复,重传由官方 smoltcp 的定时器触发。 +初始发送 MTU 取配置与真实隧道预算的交集,避免已知预算下的持续二次分段。 +内部队列、capsule 和 packet 均有界,停止等待其工作任务回收。 +H3 IP 的物理 UDP 请求沿原受控 dialer 携带 512 KiB 接收缓冲提示;系统可限制或忽略该提示,不构成进程 RSS 或真机内存保证。 +当底层实际报告发送预算缩小时,只反馈当前过大包,不关闭其它业务流。 +稳定路径的 MTU 下降目前不会自动触发 QUIC PMTU 重新探测。 + +`network: h3-l4proxy` 使用 Cloudflare L4 HTTP/3 CONNECT,每个 TCP 目标独占请求流,共享受控 QUIC 连接; +等待 2xx 响应后才交付业务流,拒绝响应不取消其它流,节点 Stop 等待所有驱动退出。 +基础 UDP 始终关闭,`udp: true` 也不启用它。 + +`remote-dns-resolve: true` 且 `dns` 非空时启用节点私有 DNS;否则忽略 dns 内容, +业务域名沿原受控本地解析。IP 模式的 DNS 查询通过同一个内部栈,L4 仅支持经节点的 +TCP DNS 传输,不将 UDP 查询改为 TCP 或改走 DIRECT。节点 DNS 沿用 Core DNS 上游格式, +不继承全局 prefer-h3;解析上游域名仍使用节点外部的 bootstrap 上下文,避免自递归。 +system/DHCP 来源沿共享物理来源策略处理,网络快照更新清理节点缓存;不可用时失败。 +私有 resolver 不保留空闲连接,由节点 Stop 关闭并等待驱动。 + +IP 模式 `handshake-timeout` 为整数秒:0 使用创建调用的建立期限,创建者取消会终止 +尚未就绪的创建;正值使用节点拥有的独立期限,创建者或其它等待者取消不终止共享创建。 +已交付业务不继承建立期限;节点 Stop 取消创建和业务并等待全部任务。 + +private-key 是标准 Base64 SEC1 ECDSA 私钥,public-key 是标准 Base64 ECDSA SPKI。 +客户端发送对应自签证书,服务端每张证书都必须使用固定公钥;不检查证书名称、 +信任根或有效期。skip-cert-verify 只关闭公钥固定,握手签名仍验证; +name-cert-verify 是 Mihomo 保留的占位字段,不消费。每个物理连接使用独立 TLS 配置, +关闭恢复与 0-RTT;H2 使用 rustls/ring,H3 使用 quiche 与共享 Boring, +两者保留相同的公钥固定与握手签名校验。 + +SNI 缺省或空串在 IP 模式使用 consumer-masque.cloudflareclient.com, +L4 使用 consumer-masque-proxy.cloudflareclient.com。IP URI 缺省为 +https://cloudflareaccess.com,不支持含变量的 IP flow URI template。 +L4 不解析 ip/ipv6/uri,不消费 mtu 或正 handshake-timeout;负超时拒绝。 +QUIC 使用 20 字节 CID、初始 payload 1242 与受控预算交集、30 秒 keepalive; +拥塞字段采用上述 TrustTunnel H3 的算法映射。 +`ip-stack` 支持省略/auto 与省略/cubic,不把其它栈或算法映射为现有栈。 + ## SOCKS5 仅 SOCKS5 CONNECT / UDP ASSOCIATE;用户名密码成对配置,不支持 SOCKS4。 TCP 保留域名交由上游解析;UDP 由控制连接拥有,校验 relay 来源并保留实际回包目标。 +Controller 跟踪的同源、同具体节点和同代际的未改写 IP 目标共享 UDP ASSOCIATE, +逐目标统计与关闭保持独立;目标退出使关联退役,新目标使用新关联,旧回复不能进入重建目标。 上游为 select 组时,控制连接和后续 UDP 数据报使用同次建链快照。 -SOCKS5 不提供 TLS、证书 pin 或 ClientHello 模板;它的预算独立于 TUN MTU。 +`tls: true` 使用共享 TLS、`skip-cert-verify`、`name-cert-verify`、`fingerprint` 和 +成对的 `certificate/private-key`(PEM 或文件路径)。TLS 名称固定使用 server;不接受 sni、alpn +或 ClientHello 模板。TLS 保护 CONNECT / UDP ASSOCIATE 控制连接,普通 UDP relay +数据不经过 TLS;预算独立于 TUN MTU。TLS 关闭时不能配置证书字段。 ## AnyTLS 支持 v2、兼容 v1 的会话、内置填充与 UoT v2。password 为 1–1024 UTF-8 字节; SNI 默认 server;ALPN 省略/空列表不发送,非空保持顺序,不强制选中某协议。 -不开放 TLS 版本、ECH、mTLS、REALITY、填充和会话参数;命名指纹沿用共享 TLS。 +支持独立的 `name-cert-verify` 和成对内联 PEM 或文件路径 `certificate/private-key` 客户端身份; +支持共享静态/动态 `ech-opts`,强制真实 TLS1.3 接受后才能发送认证与业务; +不开放 TLS 版本、REALITY和填充参数。命名指纹沿用共享 TLS。 连接先发送 SHA-256 密码摘要、认证填充和首个会话批次。Settings 固定声明 v=2 和 -填充方案 MD5,不附产品/版本标识;合法 ServerSettings v>=2 启用 v2,否则兼容 v1。 +填充方案 MD5,`client-metadata` 原样写入 client 值,缺省为空,不自行填产品/版本标识; +metadata 不含换行且须适合 u16 Settings 帧。合法 ServerSettings v>=2 启用 v2,否则兼容 v1。 首个流与认证批次一起发送。复用空闲会话时,v2 写 SYN 和目标后即返回流,再监视 最长 3 秒 SYNACK;v1 不等待。非空 SYNACK、超时、协议/读写器错误销毁整个会话, 已返回流在后续 IO 报错。仅 open 返回前发现空闲会话失效时可新建一次;不重放业务。 @@ -41,12 +360,20 @@ SNI 默认 server;ALPN 省略/空列表不发送,非空保持顺序,不强 未知流、控制 ID/方向错误、越界负载或非法设置是会话级错误;Alert/非空 SYNACK 仅返回固定诊断,不向日志或调用方回显远端内容。 一个物理会话同时仅一个活动流,完整结束后入空闲列表;优先复用序号最大者。 -检查/空闲超时均 30 秒,不预热;首次使用才启动清理 worker。ID/序号耗尽关闭会话。 +`idle-session-check-interval` / `idle-session-timeout` 使用整数秒,缺省或 <=5 回到30秒; +`min-idle-session` 缺省/负值为0,只保留现有空闲会话,不预热。回收从最新会话开始, +未过期项计入保留数,必要时刷新已过期项的空闲时间。首次使用才启动清理 worker。 +`disable-reuse: true` 不启动回收 worker、不领取/归还空闲会话,逻辑流结束即关闭该会话。 +ID/序号耗尽关闭会话。 写命令队列 2 项,流接收容量按运行时预算推导且至少 1;活动会话不设全局数量上限。 UoT v2 使用 `sp.v2.udp-over-tcp.arpa:0` 数据报模式,首包含请求,之后每包携带 IP/域名目标,协议负载最多 65,535 字节并取调用方预算交集;响应队列 1 项。 -关闭/取消/丢弃 UoT 同时结束读取任务和底层流。 +同一源关联、具体节点和资源代际内,未改写 IP 目标共享 UoT 传输;逐目标统计、 +关闭和固定出口保持独立,具体退役边界见[资源策略](runtime-resource-policy.md#udp)。 +单次发送 future 被取消时,最多保留一个未完成帧及写入偏移;后续发送或接收先完成该帧, +不重复首部或破坏后续边界。首帧仍沿用原建链期限;实际 IO 失败或超时使关联失效。 +显式关闭、父作用域取消或丢弃 UoT 同时结束读取任务和底层流,不等待未提交帧的写入容量。 填充为内置方案;新物理会话持有不可变快照,合法 UpdatePaddingScheme 仅影响后续 会话,非法更新只产生有界警告。文本、条目、逻辑填充量和帧负载都有界。 @@ -62,9 +389,12 @@ begin_shutdown 同步禁止新流、清空空闲并取消所有会话;shutdown SNI 默认 server;WS Host 只覆盖 HTTP authority,不作为 SNI 回退。ALPN 默认 TCP 空、 WS http/1.1、gRPC h2,显式列表及实际协商均须满足传输,不能静默降级。 +`name-cert-verify` 只覆盖证书验证名;成对的内联 PEM 或文件路径 `certificate/private-key` +用于客户端 mTLS 身份,共用标准 TLS 的验证、指纹与恢复策略。 WS path 默认 /,可带 query;头字段不得覆盖握手保留头或大小写重复。early-data -0–2048 字节,启用时默认 Sec-WebSocket-Protocol,可自定义;空头名表示路径后缀, +按配置最大值与当前请求16KiB头预算的较小值发送,不预留配置上限。path 的 `?ed=N` +覆盖显式ED配置并移除ed参数;启用时默认 Sec-WebSocket-Protocol,可自定义;空头名表示路径后缀, 此时禁止 query。禁用时不能附 ED 头名。首请求头与剩余数据只按序发送一次。 `ws-opts.v2ray-http-upgrade:true` 使用原始 HTTPUpgrade 流,不发送 WebSocket 帧。 普通模式在有效 101 后发送剩余认证前缀;`v2ray-http-upgrade-fast-open:true` 提前 @@ -74,10 +404,21 @@ fast-open 需要 upgrade。 Upgrade 的 ED 仅允许默认 Sec-WebSocket-Protocol 头(大小写不敏感),不支持 自定义头或路径 ED。101/头限额/截断失败不重放前缀,响应头后的字节保留给协议。 这些选项同样适用于 VMess 的明文和标准 TLS;不改变各协议认证、UDP 编码或关闭责任。 -gRPC service-name 必填,普通名映射 /name/Tun,以 / 开头则为完整路径;不开放池参数。 +gRPC 的 HTTP authority 使用非空 SNI,否则使用 server,不包含端口;不改变物理拨号地址。 +gRPC 选项与 VMess/VLESS 共用:省略或空 service-name 默认 GunService,普通名映射 /name/Tun,以 / 开头使用完整路径;省略或空 user-agent 默认 grpc-go/1.36.0。 +ping-interval 为秒,0 不主动 PING,负值明确拒绝;max-connections/min-streams/max-streams 接受带符号整数,负阈值禁用相应分支。三项池阈值省略或字面全零时归一为4/0/0,Mihomo为1/0/0;显式 max-connections=1 或2保留各自策略。正 max-connections 优先于 max-streams。 +每个节点独立拥有连接池,空闲连接总是复用;复用保留原上游选择、TLS 身份和认证策略,每条逻辑流仍单独发送 Trojan 认证头。 + +Trojan 私有 gRPC 池在原绝对期限内等待 H2 SETTINGS、提交并 flush 完整认证请求, +随后允许 client-first 上传等待迟到的 HTTP 响应,不把建链期限继续套用到业务。 +迟到的 HTTP/gRPC 状态仍完整校验;未完成认证提交、TLS 或 H2 握手继续受原期限约束。 TCP 保留半关闭;gRPC duplex 发送 END_STREAM,不用 RST_STREAM 提前结束读侧。 UDP 使用同一已认证流上的地址/长度/CRLF 帧,无额外 socket/读取泵/池。 +同一来源、具体节点和资源代际内,未改写 IP 目标共享该流,回复按每帧地址分流; +逐目标统计、关闭及旧回复隔离沿用[资源策略](runtime-resource-policy.md#udp)。 +不同来源、域名及需要地址还原的目标保持独立;关闭一个目标不关闭仍有成员的流, +但部分发送取消仍使整条关联失效,不重放已提交的帧。 payload 最多 8192 字节,待解析最多 8455 字节,并与调用方收发预算分别取交集。 非法/截断帧关闭,合法超接收预算整包丢弃;取消读取保留进度,部分发送取消使关联失效。 运行时先取消并 join 入站所有者,再等待节点 gRPC 驱动。 @@ -87,27 +428,36 @@ Mihomo IP 路径可传空包;Xray 不转发空包,且域名回复的 8192 ## VMess AEAD -标准 UUID,alterId 仅省略/0,请求和响应头均 AEAD,无旧认证回退。cipher 为 auto、 +UUID 接受标准、紧凑、花括号、URN 表示,其他字符串按 UUIDv5(nil, 原始 UTF-8) 映射,不 trim。 +alterId 仅省略/0,请求和响应头均 AEAD,无旧认证回退。cipher 不区分 ASCII 大小写,为 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, +物理出口、安全身份、传输握手及完整认证请求的写入和 flush 共用原建立绝对期限; +请求提交后,服务端可等待业务上传再发送响应头,不继承旧建立期限。响应 AEAD、身份和长度仍须 +验证通过才交付下行数据;失败关闭、传输自身的握手期限、取消和 Stop 边界不变。 + +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。 +TLS 支持独立的 `name-cert-verify` 和成对内联 PEM 或文件路径 `certificate/private-key` 客户端身份。 - WS ED/路径/头及 HTTPUpgrade 普通/fast-open 约束同 Trojan。 -- gRPC service-name 必填,普通名/完整路径规则同 Trojan,无额外池调度器。 +- gRPC 选项、默认值和节点连接池与 Trojan/VLESS 共用,每条逻辑流独立生成 VMess AEAD 认证;关闭逻辑流不关闭物理连接或兄弟流。 + authority 使用非空 servername,否则使用 server:port;name-cert-verify 不参与 authority。 - http 是 TCP 首包伪装:method 默认 GET,path 列表省略/空为 /,按 URL.Path 转义; 头为字符串列表,每次握手独立选择路径/头值,Host 默认物理 server authority。 仅首包封装一次,之后恢复原流;禁止 Content-Length/Transfer-Encoding。 -- h2 需要非空 authority 列表,path 默认 /,每流选一个 authority,通过 HTTP/2 PUT body + 完整 HTTP 首请求与认证前导写入、flush 沿用原建链期限;成功提交后,首个 HTTP + 响应可等待业务下行,不再继承旧建链期限。头解析上限、取消和整流关闭保持。 +- h2 的 authority 列表省略/空时使用 www.example.com,path 省略/空时为 /,每流选一个 authority,通过 HTTP/2 PUT body 双向传输;明文 prior knowledge、TLS h2,无 gRPC 帧。 关闭对齐 Mihomo:TCP/WS 可保留上传 EOF 后的读取;gRPC/HTTP/H2 关闭整条逻辑流, 完整响应应在关闭前读取,不统一要求半关闭尾包。 -UDP packet-encoding 默认空串(raw),外层仍 TCP: +UDP packet-encoding 默认空串(raw),外层为所选可靠流: | 编码 | 目标与预算 | | --- | --- | @@ -119,40 +469,92 @@ UDP body 最多 15,000 字节;packetaddr 另扣 IPv4 7 / IPv6 19 字节,未 预检;超限一字节即写前失败,不拆业务包。对端可能更小,V2Ray 部分返回路径总缓冲 仅 2048 字节,不能当作 Vole 上限。body wire 解析最多 16 KiB,响应头/XUDP 元数据有界。 三种 UDP 编码均不接受零长度业务包;HTTPUpgrade 不改变这个边界。 -TCP 写块最多 4 KiB 以适配 Mihomo 拷贝边界;UDP 不按此切块。16 位帧计数耗尽前关闭, -不重复 nonce。半帧发送取消关闭,接收取消保留进度;认证/地址/标签错误使关联失效。 -gRPC/H2 driver 由节点跟踪并同步退出,无全局会话额度或历史表。 +TCP 写块最多 4 KiB 以适配 Mihomo 拷贝边界;UDP 不按此切块。 +AES/ChaCha body 的收发记录计数各自按 16 位回绕,与 Mihomo/sing-vmess 和 V2Ray-core +一致;跨越 65,536 条记录时不额外断开、重连、重放或更换 cipher。长度认证沿用同一记录索引。 +该协议行为会在同一密钥下重复 nonce,不提供回绕后的 nonce 唯一性保证;外层 TLS 不更新 +内层 body 密钥。认证标签、长度、地址及其它解析错误仍失败关闭。 +raw/packetaddr 半帧发送取消关闭;XUDP 保留一个待写帧并在下次收发时完成, +遵循[VLESS 的 XUDP 取消边界](vless.md#udp)。接收取消保留进度;认证/地址/标签错误使关联失效。 +gRPC 池与 legacy 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 载体。 +支持共享证书 pin/skip、独立 `name-cert-verify` 和成对内联 PEM 或文件路径 mTLS; +验证名不改变 SNI。业务 UDP 始终可用;`udp` 可省略或填 true/false,均不禁用它, +组 `disable-udp` 仍生效。外层 QUIC 始终需要数据报路径。 - up/down 独立非负整数或十进制整数字符串,裸值 Mbit/s;单位 [KMGT]?[bB]ps 使用 十进制倍率,bit 向下取整为 byte/s。省略/归一零为自动;拒绝小数、符号、溢出。 -- udp-mtu 缺省/0 为 1197,其他值 64–65535,限制编码后单分片并取实际 DATAGRAM +- udp-mtu 缺省/0 为 1400,其他值 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 +- obfs 支持 salamander 或 gecko,须配非空独立 obfs-password。Gecko 对 QUIC 长头随机分片并填充, + 短头只用 Salamander;`obfs-min-packet-size/obfs-max-packet-size` 默认512/1200, + 正值且 min≤max≤2048,只用于 Gecko。max 是填充目标,不是 QUIC MTU,实际片仍受物理预算限制。 +- ports 为端口/闭区间集合,接受逗号或斜线分隔,逆序范围归一并去重;有效非空集合优先于 port, + 此时 port 可省略或为0。空集合或 `*` 使用非零 port,不启用跳跃。 + hop-interval 随 ports 使用整数秒 N、字符串 N 或 N-M,范围先排序,0 使用30秒, + 正数小于5归一为5,最终上界至少为归一后的下界;仍须可表示为 u32。 + +`realm-opts.enable: true` 使用 Realm 控制面/STUN/UDP 打洞,和 ports 跳端口互斥。 +必填 server-url、realm-id 与非空 stun-servers;token 默认空,服务端按实际认证处理。 +server-url 为无 userinfo/query/fragment 的 HTTP(S) base URL,不跟随重定向。 +STUN 可用 IP/域名及可选端口,默认3478;每个域名使用一个受控解析地址,同时探测两族应列出两族端点。 +HTTPS 控制面独立接受 sni/alpn、skip-cert-verify/name-cert-verify/fingerprint 与成对 +certificate/private-key(内联 PEM 或相对配置目录的文件);它们不改变业务 QUIC 身份。 +实际 UDP 对端由 Realm 发现,但原节点 server/SNI 仍用于 QUIC 认证,不解析未使用的原 server。 + +最多2个候选UDP association、16个STUN、32个控制面peer、128个打洞候选;首部/body各16KiB, +所有网络操作使用同一节点受控出口、建立期限和取消。胜出的原 association 移交 Quinn, +失败/取消/Stop 关闭未交付候选并等待驱动。无额外 resolver、物理 socket 工厂或 DIRECT 回落。 +Gecko 重组最多8个消息/每消息8片/1400字节,TTL8秒,不保留 padding backing。 + +每节点按需使用最多两个当前认证 QUIC 会话,自动和显式带宽模式共用此有界池, +按来源地址稳定选择,分担对端逐连接 UDP 处理。上行共用节点 wire pacer, +下行声明按连接分配且总和保持配置值;down 为一字节/秒时保留单连接。 +单条流的下载上限受所在连接的分配值限制,空闲连接不转移其声明额度。 +各连接内的逻辑流/关联复用且互不取消。HTTP/3 POST hysteria/auth 在业务前要求 233;TCP 类型 0x401,响应可延迟到首次读取,允许先写业务。 -认证/上游/请求共用绝对 10 秒预算。旧会话失败后新流可新建会话,已有业务不重放。 +认证/上游/请求提交共用绝对 10 秒预算。请求已提交后的业务及延迟响应不再使用建链期限; +响应失败仍使当前逻辑流失效。旧会话失败后新流可新建会话,已有业务不重放。 写 EOF 关闭当前逻辑 TCP 双向通道,与 Mihomo 一致,不影响兄弟流。 -每流接收窗口 256 KiB、连接收发各 1 MiB、DATAGRAM 收发各 256 KiB;最多 8 条单向 +四个 initial/max 接收窗口按字节配置;0 独立使用默认:stream 64 KiB,connection 4 MiB。 +采用固定 Middle 策略:每流信用为 max(initial, min(maximum, 4 MiB)),连接信用为 initial; +不执行消费量或 RTT 驱动的自适应增长。默认窗口优先多流公平性和较小的未读数据。 +较高 RTT 的单流吞吐受固定流窗口约束,可通过现有初始/最大窗口字段显式增大,同时增加接收驻留。 +initial 大于 max 时保留初始值,不缩小或额外拒绝;max 限制到 QUIC VarInt,无法编码的 +initial 拒绝。窗口是信用额度,不预分配整个缓冲。handshake-timeout 为整数秒,正值使共享 +建链由节点持有并使用独立期限;各调用仍受自身原建立期限约束,短等待退出不取消有效 pending。 +每槽位最多一个 pending,失败驱动清理后才能替换;<=0 保留创建调用的期限与取消语义。 +成功后的业务/端口跳跃不重新应用此期限,Stop 取消并等待全部驱动。 + +节点可靠发送窗口总额 1 MiB、DATAGRAM 接收预算 2 MiB、发送预算 4 MiB, +按固定会话槽位数均分;未使用槽位的额度不转移。可靠接收信用仍按连接配置, +第二会话另有独立协议状态、队列和物理 socket,因此这些预算不代表节点总内存不变。 +每连接最多 8 条单向 控制流,拒绝主动双向流。PMTUD 关闭,最大 QUIC payload 1400 并取上游预算交集; 空闲 30 秒/保活 10 秒。认证头 16 KiB,TCP 响应消息 2048/padding 4096 字节。 +共享 Dialer 为每个物理 UDP socket 请求 2 MiB 接收缓冲以承接突发,实际大小由系统决定; +原 socket 的保护、绑定和所有权继续保留,逻辑包预算及 QUIC 接收信用独立。 业务 UDP 原生 DATAGRAM,payload 最多 4096,最多 255 分片;未知关联不创建状态。 重组按关联/包/地址隔离,最多 64 个待完成包/256 KiB、TTL 5 秒,关联交付队列 32 包。 +每条认证连接另有 32 项原始消息发送队列;同一连接任务顺序编码分片,不增加后台任务。 +满队列等待可取消的准入,准入成功才使用 Packet ID;关闭关联后未发送项丢弃,Stop 取消并释放队列。 重复未完成分片忽略;完成后立即释放 16 位 Packet ID,允许后续重用,**不做业务去重**。 +缓存满时淘汰最早开始的未完成包以接纳新包,避免缺片占满缓存后使后续包停滞至 TTL; +总项数、总负载字节及单包预算保持不变。 +同一来源、具体节点和资源代际内,未改写的 IP 目标共用一个 UDP session ID 和交付队列; +逐目标统计、取消与出口固定仍由路由层管理,退役及旧回复隔离沿用[资源策略](runtime-resource-policy.md#udp)。 -down 写入 Hysteria-CC-RX。up=0 或服务端 auto 使用 BBR,否则取 up 与服务端正上限 +down 写入 Hysteria-CC-RX。up=0 或服务端 auto 使用官方 Quinn CUBIC,否则取 up 与服务端正上限 较小值(服务端 0 无限),使用固定带宽控制与实际 wire-byte pacer。Mihomo down=0 可能返回 RxAuto,优先自动模式。丢包补偿五秒窗口、最小采样量、ACK 比例下限 0.8。 +自动模式采用 CUBIC 的本地策略,符合 [Hysteria 拥塞控制协议](https://hysteria.network/docs/developers/Protocol/#congestion-control), +不承诺与 Mihomo/官方客户端的 BBR 在高 RTT 或有损网络下性能相同。 Salamander 采用公开 salt + XOR/BLAKE2b-256,每包加 8 字节,先扣 QUIC 预算但计入 pacer;这是混淆不是认证,错误密钥由 QUIC 拒绝,短包有界丢弃。 @@ -167,43 +569,92 @@ pacer;这是混淆不是认证,错误密钥由 QUIC 拒绝,短包有界丢 ## TUIC v5 固定 QUIC/TLS 1.3,复用官方 Quinn、rustls/ring 与共享受控数据报适配器。 -仅 v5;必填标准 UUID 与原样 UTF-8 password,后者可空、最长 65,535 字节。 +仅 v5,native/quic 服务端互通目标为官方 Itsusinn/tuic,UoT v2 与服务端 mTLS 使用 Mihomo; +必填标准 UUID。password 为原样 UTF-8,省略/空串为空密码,最长 65,535 字节。 SNI 缺省 server,ALPN 缺省 h3;显式空列表拒绝,非空有序列表必须实际协商命中。 -使用共享 pin/skip/独立验证名;无命名 TCP 指纹、mTLS、ECH、0-RTT 或跨节点恢复。 +`disable-sni: true` 只关闭 ClientHello 的 SNI,仍验证原认证名/显式 name-cert-verify 和 pin, +不隐式关闭证书验证。 +使用共享 pin/skip/独立验证名,支持配对内联 PEM 或文件路径的 certificate/private-key 客户端身份;无命名 TCP 指纹、ECH、0-RTT 或跨节点恢复。 拥塞使用官方 Quinn cubic(默认)、new_reno 或 bbr,不承诺与其他实现的吞吐相同。 +`log-level: debug` 在物理会话建立后记录实际 controller 的固定算法标签; +标签不包含节点、凭据或业务目标,配置验证和复用同一会话不重复记录。 认证使用当前真实 TLS 会话 exporter,UUID 为 label,原样 password 为 context。 Authenticate 与 Connect 没有成功 ACK;本地 open 成功不是密码通过证明,错误身份 必须由最终拒绝和原站零业务验证。Connect 为双向 QUIC 流,不引入 HTTP/3 请求。 流关闭结束该逻辑双向通道,与 Mihomo 对齐,不影响兄弟流。 -业务 udp 默认 false,但外层 QUIC 始终需要有效双向至少 1200 字节的数据报路径。 +业务 UDP 始终可用;`udp` 可省略或填 true/false,均不禁用它,组 `disable-udp` 仍生效。 +外层 QUIC 始终需要有效双向至少 1200 字节的数据报路径。 未开放 DATAGRAM、预算不足、protect 失败或只支持 TCP 的上游均失败,不回退 TCP -或 DIRECT。`udp` 业务路由开关不替代上游实际数据报能力和预算检查。 +或 DIRECT。业务 UDP 能力声明不替代上游实际数据报能力和预算检查。 SS UoT、AnyTLS 与支持 UDP ASSOCIATE 的 SOCKS5 可作为上游;组快照绑定物理会话。 `udp-relay-mode` 为 native(默认)或 quic。native 使用 DATAGRAM,按实际容量拆为 最多 255 片;quic 每包使用一条单向流,不受单 DATAGRAM MTU 限制,也不是 UoT。 +quic 收齐声明长度的报文后立即交付并释放接收流,TCP 通道的关闭与 FIN 保留规则不变。 两者逐包携带 IPv4/IPv6/域名,业务负载取 u16 和调用方双向预算交集,超限不截断。 未知关联不分配状态;每关联最多 64 个未完成包、256 KiB 分片负载、TTL 5 秒和 -32 项完成包交付队列。重复相同片忽略、冲突片丢弃;完成后释放 Packet ID,可回绕, -不做业务去重。发送过的关联关闭时发送有界 Dissociate,未发包关闭不创建对端状态。 - -每节点按需共享物理会话,流 credit 等待沿用调用方原期限。关联 ID 在同一物理会话 +32 项完成包交付队列。未完成包槽满时,新分片包逐出最老的未完成包;字节预算和 TTL 保持。 +重复相同片忽略、冲突片丢弃;完成后释放 Packet ID,可回绕, +不做业务去重。协议不携带跨片报文代次或完整内容校验;同一关联、相同 Packet ID 的 +跨包互补分片无法一般性区分,QUIC 对单片的认证不代替完整业务内容校验。 +发送过的关联关闭时发送有界 Dissociate,未发包关闭不创建对端状态。 + +启用逐目标连接追踪时,native 与 quic 模式允许同一 TUN UDP 来源、同一具体出站实例的 +未改写 IP 目标共用一个 TUIC 关联,每包仍携带目标地址;逐目标路由、统计与取消保留。 +目标改写、域名目标及不同出站实例在该追踪路径保留独立关联。 +未启用追踪时沿用既有路由缓存的关联复用规则。 + +每节点按需复用物理会话。native 按完整来源地址(含端口)稳定分配至两个按需池, +同源 TCP/UDP 保持池亲和;UoT v2 保持原共享池。quic relay 的 TCP 与原生 UDP +各自按需使用独立池,避免逐包单向流的回包积压占用 TCP 所在连接。每池仍沿原所有者 +阈值准入、退役与释放,不增加单个连接的窗口或远端单向流信用。两个当前池不是全节点 +总连接上限;退役会话仍由既有所有者生命周期释放。流 credit 等待沿用调用方原期限。 +native 模式(不含 UoT v2)向共享 Dialer 请求512 KiB物理 UDP 接收缓冲; +这是可钳位或拒绝的性能提示,沿用同一个受保护、已绑定的 socket,不改变 QUIC 信用、 +应用队列或逻辑包预算。quic relay 与 UoT v2 不请求该提示;Linux 内核缓冲与进程 RSS 分开核算。 +关联 ID 在同一物理会话 不重用;65,536 个 ID 用尽后新业务新建会话,旧流继续,最后一个旧所有者退出即回收。 TCP shutdown 立即完成逻辑关闭,发送所有权保留到 FIN/数据确认或最多 5 秒,防止 退役池提前丢弃已接受的末尾上传;该等待归节点所有,Stop 直接取消并 join。 切组不迁移已有池;故障后只有新业务可创建新池,已发送业务不自动重放。 -每流接收窗口 256 KiB,连接收发各 1 MiB,DATAGRAM 收发各 256 KiB;最多 32 条 -入站单向控制流,半条控制流最长 5 秒;拒绝服务端主动双向流。QUIC payload 最大 -1400 并取路径预算交集,PMTUD 关闭。保活/Heartbeat 每 10 秒,空闲 30 秒;两种 -UDP 模式的 Heartbeat 均用 DATAGRAM。Stop 等待认证、收发、重组、心跳及上游释放, -关闭交换最多一秒;这些都是局部结构限制,不是全局业务会话额度。 +`ip` 覆盖物理拨号地址,逻辑 server/SNI 保留。heartbeat-interval 单位毫秒,<=0 回落 +10000,同时控制 QUIC keepalive 与实际 v5 HEARTBEAT,两种 UDP relay 模式均用 DATAGRAM。 +recv-window-conn 实际为 stream,recv-window 实际为 connection;正值同时设 initial/max, +缺省或零值分别按 15 MiB/10、2 MiB 初值与 15 MiB、64 MiB 最大值归一化, +随后采用上述 Middle 策略,默认实际为固定 stream 4 MiB / connection 2 MiB。 +窗口是信用额度,不预分配全部缓冲;显式正值不按默认窗口钳位。 + +max-open-streams 控制客户端 TCP/UDP 所有者池,不扩大 Quinn 内部远端 stream 表; +远端双向流信用固定为 0,单向流为 32。0 使用100;参考客户端100阈值转换90,入池计数必须严格小于阈值,所以 +默认89个同时所有者,其它正N为N-1;1和负值可解析但无法开启业务。饱和新建物理 +会话,退役会话继续服务已有所有者,最后所有者退出回收,不重放已发送业务。 + +max-datagram-frame-size 是按规范 type/length varint 编码计的完整 frame 上限,最多1400;缺省以 +max-udp-relay-packet-size(0使用1252)+IPv6最坏TUIC头29计算。native relay 的业务 +fragment 上限取 frame-29 与实际路径扣头容量交集;能在实际容量内发送的包不分片。 +quic relay 按参考在一条单向流发送完整 u16 packet,不使用 native fragment 限额。 +frame 配置限制本机发送与分片,不修改 Quinn transport parameter。接收按独立物理预算、 +关联和完整包重组预算校验,允许对端采用不同分片大小;未知关联或超预算负载在解码前丢弃。 +DATAGRAM 累计收发缓冲仍各256 KiB,独立于 frame 上限。disable-mtu-discovery 缺省 +false,PMTUD 探测始终限制在受控双向路径预算内,QUIC payload 最多1400。 + +udp-over-stream 默认关闭;开启后使用 TUIC Connect 到 `sp.v2.udp-over-tcp.arpa:0` 的 UoT v2, +udp-over-stream-version 省略或整数2均使用v2,其余版本拒绝。首包含 Datagram-mode request, +后续每包携带目标与长度。仍需要 QUIC 数据报物理入口; +首包保留原绝对建链期限;取消单次发送保留一帧进度,后续收发继续完成。 +实际错误或超时使关联失效。响应队列1项、任务归节点Stop同步回收。 +UoT在可靠流中传输,有丢包导致的队头阻塞成本,不应用原生TUIC分片额度。 + +连接未确认发送缓冲1 MiB;最多32个并行入站控制解码器,半条控制流最长5秒, +拒绝服务端主动双向业务流。空闲30秒。Stop等待认证、收发、重组、心跳及上游释放, +关闭交换最多一秒;这些是局部结构限制,不是全局业务会话额度。 ## Shadowsocks 2022 -原样复用官方 shadowsocks-rust,仅三算法: +使用官方 shadowsocks-rust 的加密实现,仅三算法: | cipher | 密钥 | | --- | --- | @@ -212,15 +663,39 @@ UDP 模式的 Heartbeat 均用 DATAGRAM。Stop 等待认证、收发、重组、 | 2022-blake3-chacha20-poly1305 | 单一 32 字节 Base64 PSK | 标准 Base64 可省末尾 padding;空段/非法编码/长度错误拒绝,凭据不 trim。 -官方库只包装已有 TCP 流,UDP codec 单包有界交接,不调用其 socket 工厂。 +TCP 使用官方 `CryptoStream` 的加解密与记录分帧,Vole 构造目标地址与 padding 前导。 +原生 UDP 使用 Vole 的 SIP022 包层适配官方公共 `UdpCipher`,处理包头、EIH、padding 与地址, +只消费已有受控 IO,不调用 socket 工厂。每关联持有一个当前 TX cipher;AES 至多保留两个 +已认证 server session 的 RX cipher,ChaCha 收发复用同一 PSK cipher。未知 server ID 仅使用 +临时 cipher,通过认证、类型、时间、client session、地址、负载及重放校验后才保留。 +接收复用既有有界缓冲;取消、重放窗口与受控 IO 的生命周期见下文,close/Stop 回收关联 cipher。 + +### 流式传输插件 + +一个 SS2022 节点最多使用一个 `plugin/plugin-opts`,包装位于官方 SS2022 codec 之前; +原生 UDP 使用独立受控路径。插件不启动子进程,也不绑定本地代理端口。 + +- `plugin: obfs`:`mode: http/tls`,`host` 默认 `bing.com`;TLS 模式是 simple-obfs 报文, + 与提供身份认证的 ShadowTLS 不同。HTTP 首个响应头和 TLS 记录均有大小上限,保留尾部字节。 +- `plugin: v2ray-plugin` 或 `gost-plugin`:`mode: websocket`,支持 `host/path/headers/tls/mux`。 + host 缺省为 `bing.com`,空 path 使用 `/`,mux 缺省 true。TLS 模式使用 HTTP/1.1 ALPN, + 支持 `skip-cert-verify/name-cert-verify/fingerprint/certificate/private-key/ech-opts`; + 未启用 TLS 时不接受这些选项。Host 头同时确定 TLS SNI。v2ray-plugin 另外接受 + `v2ray-http-upgrade/v2ray-http-upgrade-fast-open`。两种 mux 分别是单通道 V2Ray mux 和裸 smux, + 不附加 sing-mux 的目标与响应头;它们的关闭结束整个下层会话。 +- `plugin: jls`:`host/username/password/alpn`;ALPN 缺省 h2、http/1.1,显式空列表保持为空。 + 节点 `client-fingerprint` 作用于 JLS 握手;不增加标准证书策略或客户端证书字段。 + +`client-fingerprint` 仅 ShadowTLS/JLS 插件消费,其余插件不接受该字段。 +插件参数只接受对应模式的字段;队列、窗口、连接池和配置范围均有界,取消/Stop 不等待远端回包。 ### ShadowTLS v3 SS 三算法的 TCP 可配置 `plugin: shadow-tls` 与完整 `plugin-opts`,不是新的代理类型。 仅整数 version 3,始终 strict/TLS 1.3;不提供 v1/v2、弱认证或版本回退。 host 为独立 cover DNS 名/IP,不参与拨号或业务解析;password 保留原始 UTF-8。 -ALPN 缺省 `[h2, http/1.1]`、空列表不发送;共享 pin/skip/name-cert-verify 与 -节点 client-fingerprint 只影响 cover,不能替代 ShadowTLS 密码。 +ALPN 缺省 `[h2, http/1.1]`、空列表不发送;支持共享 pin/skip/name-cert-verify、 +成对内联 PEM 或文件路径的 certificate/private-key;节点 client-fingerprint 只影响 cover,不能替代 ShadowTLS 密码。 顺序为受控上游流 → 原生完整 TLS 握手与 relay 认证 → v3 记录流 → 官方 SS2022。 借用原建链期限、取消和组快照,握手失败不发送 SS 目标或业务;pin/skip 不绕过 @@ -236,17 +711,19 @@ v3 的额外四字节记录特征仍存在,完整认证和互通不等于不 ### UDP over TCP v2 `udp-over-tcp` 默认 false;true 要求 `udp: true`,三算法均可用于裸 SS 或 -ShadowTLS v3。开启时 `udp-over-tcp-version` 省略或整数 2 均固定使用 v2; +任一流式插件。开启时 `udp-over-tcp-version` 省略或整数 2 均固定使用 v2; 关闭时不得提供版本,不支持 v1、协商或原生 UDP 降级。无 SS feature 在 IO 前拒绝。 -每个关联独占一条既有 SS TCP 路径,支持 TCP-only SOCKS5 上游与 select 组。 +同一源关联、具体节点和资源代际内,未改写 IP 目标共用一条既有 SS TCP 路径; +其它关联保持独立,不跨来源池化。支持 TCP-only SOCKS5 上游与 select 组。 目标固定为 `sp.v2.udp-over-tcp.arpa:0`;v2 non-connect 请求只发送一次,之后每包 携带地址和 u16 长度。业务域名先经过受控 `ResolutionContext` 转为 IP,magic 不参与 DNS 或路由。AnyTLS 复用同一有界 codec,但保留自己的域名与会话/FIN 所有权。 SS 首包写入并 flush 成功前阻止后台读取;零长度 UDP 仍有非空 UoT 帧。 延迟首包沿用原建链 deadline,成功建立后的业务不再使用旧期限。未发包关闭直接释放 -底层 IO,不触发官方空 SS 握手;半帧写取消使关联失效,不重发。每关联最多一个读取 +底层 IO,不触发官方空 SS 握手;半帧写取消保留一帧偏移,后续收发继续,不重发前缀。 +实际错误或超时使关联失效。每关联最多一个读取 任务、一个排队响应及一个正在解析/等待入队的响应;接收取消不丢 parser 状态,超接收 预算完整排空该包再读下一包。Stop 取消并等待读任务,释放流;不跨关联池化。 @@ -256,31 +733,89 @@ UoT 对端使用 16 KiB 接收缓冲;容器数据验收覆盖该实际边界 ### 共享 SS 流与数据报行为 -- TCP 每次最多交付 16 KiB;读或关闭先于首次写时,完成并刷新官方空首写, - 但受下述空首包限制影响,不承诺可靠 server-first。Pending 继续同次握手,已有首写不重复目标头。 +- TCP 每次最多交付 16 KiB;首次非空写与目标前导合并。首次读、显式空写或上传半关闭 + 可发送不含业务数据的前导,padding 长度为 1–900 字节,内容明确初始化为随机字节, + 并在读取响应前完成刷新,支持 server-first。Pending 继续同次握手,不重复目标头或业务。 + 握手完成后的空写不发送额外记录;零容量读取和单独 flush 不启动握手。 + SS 层上传半关闭保留读侧,外层传输与对端仍遵循各自的关闭契约; + Drop、取消与 Stop 不额外启动握手。 - 背压适配最多暂存一个 16 KiB 原文块并立即报告接受;后续保持同缓冲完成写入, 避免官方重试长度误计。flush/关闭排空,读取也推进待写但不被写背压阻塞。 - 原生 UDP 取消前预留 packet ID,底层实际发送后才成功;Pending 不重编码,无后台发送队列。 接收验证来源、认证、关联与完整负载,外层 wire 最大 65,507。 + 每次成功发送后,在下一包编码前合作退让;不添加发送队列或限速器。 + 同一来源、具体节点和资源代际内,未改写 IP 目标共享受控 socket、codec 缓冲和重放窗口; + 逐目标统计、取消及旧回复隔离沿用[资源策略](runtime-resource-policy.md#udp)。 - 每关联随机 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, -防止上游日志泄密;Vole tracing 不受影响。上游 Debug 不暴露。 重放窗口派生代码的来源/完整许可保留在源码头,发布仍审计实际链接图。 -**已知风险:**原样上游空 TCP 首写/空 UDP padding 路径扩展长度而未显式初始化, -存在把残留缓冲传给可解密对端的风险;普通 echo 或自有背压修复不代表此风险已解决。 -官方空 TCP 首写还可能随机生成零 padding,被其严格服务端拒绝;Vole 不修改或重试 -该上游行为。裸 SS、EIH 和外包 ShadowTLS v3 的 TCP 均受影响;入站转发先轮询读取时, -即使业务计划随后发送请求,也可能触发空首写。TCP 数据验收使用已提交非空首段的 -client-first 路径,server-first/空首包列为已知限制,不计成功;其他协议的 server-first -要求不变。内存回归分别验证适配器刷新与确定性的零 padding 拒绝,不将 codec -级回复算作 server-first 互通通过。SS UoT 的非空请求帧与专用读取门控独立验证。 +TCP 前导不使用上游私有的 padding 构造器;响应在交付前校验 request-salt 与本连接 +发送 salt 的绑定,类型、时间与认证仍由官方 codec 校验。裸 SS、AES EIH 与外包 +ShadowTLS v3 共用此路径,不通过伪造业务字节或握手失败重拨支持 server-first。 +空原生 UDP 使用调用者持有的临时编码缓冲:按 nonce、身份、地址、最大 900 字节 +padding 和 tag 的完整上界初始化,SIP022 包层仅将已初始化字节交给官方 cipher。 +缓冲不常驻,不为普通非空数据报增加整包初始化;session/packet ID、取消和重放保护不变。 +SS UoT 仍由非空请求帧和专用读取门控完成首包,不触发独立空 SS 握手。 首次固定头不够单次读取时,上游按探测防护拒绝,不改成 read_exact。 Mihomo 仅提供单 PSK;原样 ssserver 验证 AES 单层 EIH 多用户 TCP/UDP 终结及错误身份。 MTU1500 大包夹具显式启用官方双向 IP 分片。自有 1/2 层身份中继不是原生任意多层证明。 所有结果与平台/发布签收分开,见[验收边界](acceptance.md)。 + + +## WireGuard + +`type: wireguard` 使用官方 boringtun 的标准 Noise 协议和用户态 IP 栈。 +`outbound-wireguard` 可独立启用,不依赖 TUN;不创建系统接口、路由或 NAT。 +private-key 必填,ip/ipv6 至少提供一个本地地址;地址接受 IP 或 CIDR,保留主机地址, +每族最多一个。密钥必须是解码后 32 字节的标准 Base64,保存时清零回收,Debug/错误不暴露密钥。 + +未配置 peers 时,server/port/public-key 必填,pre-shared-key 可选;顶层 allowed-ips +与 Mihomo 一致地忽略,每个本地地址族使用全地址路由。有 peers 时,顶层端点与公钥 +不参与选择,每个 peer 必须提供 server/port/public-key 与非空 allowed-ips CIDR 列表。 +发送目的地址与解密包的来源地址使用同一不可变最长前缀表;相同前缀最后声明的 peer +优先。同一公钥的重复声明更新端点,保留未覆盖的 PSK,并追加允许前缀。 + +reserved 缺省为空(全零),否则接受三字节数组或解码后恰好三字节的标准 Base64 字符串。peer 缺省继承顶层值;发送时写入 +WireGuard 消息头的保留字节,接收时清零后验证 Noise。MTU 省略/0 为 1408,范围 +1280–65503;外层实际双向 UDP 预算必须至少为 MTU+32,不足时关闭上游,不静默缩小。 +udp 缺省 false,只控制业务 UDP;外层始终需要双向数据报。persistent-keepalive 为 +uint16 秒,0 关闭定期保活。当前不接受 Amnezia、workers、ip-stack 或系统接口模式字段。 + +每节点、资源代际按需拥有一套 TCP 栈;每个实际使用的 peer 独立拥有 Noise、外层会话 +和驱动,未使用的 peer 不启动网络任务。内层 UDP 使用真实临时端口。外层经过共享 +Dialer/UpstreamPath,节点端点由 Core 代理 DNS 角色解析;旧会话保留建链组快照与已认证 +入站身份。真实握手完成才交付业务流,握手重试与保活使用官方定时器;一个 peer +失败不会关闭其他 peer 或整个共享栈,后续新业务可重建失败会话,不重放旧业务。 + +remote-dns-resolve 默认 false;只有 true 且 dns 非空时,业务域名使用节点私有 DNS, +查询经过该 WireGuard 节点的内层 TCP/UDP。dns 支持 Core 的 UDP/TCP/DoT/DoH/DoQ/RCODE +上游;fragment 中的出口 token(包括未定义的名称)不改变这个固定出口,键值参数仍按 +通用 DNS 契约解码一次并消费 ECS、类型过滤、证书策略和 DoT 复用设置。私有解析器 +不继承全局 prefer-h3,HTTPS 的显式 h3=true 仍强制使用 H3。DNS 上游域名仍由 Core 的代理 DNS 角色 bootstrap, +不通过节点私有解析器解析自身端点。私有缓存有界,查询由调用方持有,不保留空闲 TCP +连接或独立后台清理任务。关闭该开关或 dns 为空时使用受控本地 DNS;两种路径都遵循 +节点 ip-version、全局 IPv6 开关、本地地址族与原建链期限,失败不回落 DIRECT。 + +refresh-server-ip-interval 默认 0,关闭更新;非零值以秒计,新业务在间隔到期后触发 +所有已建立 peer 的端点重新解析,负值每次检查均到期。不创建额外周期任务。IP 不变时保留物理会话; +变化时保留共享栈、TCP 状态和 UDP 端口,替换外层会话并重新握手。解析或新上游配置失败 +保留原端点,下次业务重试。 + +节点 raw 包队列和各 peer 发送队列各 32 项;每 UDP 关联响应队列 64 项。UDP 负载上限 +为 MTU 减本地 IP/UDP 头,再与调用方收发预算分别取交集;双栈保守扣 48 字节,IPv4 +扣 28 字节。TCP 栈输出交给已就绪 peer 时先尝试入队;Full 时只暂持当前包与原 peer +代际,释放 peer 槽位锁后,在同一次队列准入的轮询之间给一次协作调度机会,仍满则丢弃, +TCP 依赖重传。全局取消优先结束输出 pump,其次处理原 peer 取消,不把待交付包改投新代际。 +这次退让会暂缓共享输出 pump,不承诺其他 peer 零等待或固定墙钟时限。 +解密后到栈的 raw 包与 UDP 响应仍在满队列时丢当前包;业务 UDP 发送保留直接等待原 peer +发送队列的可取消路径,不经过 TCP 输出 pump。解密后校验本地目的地址、来源地址族、 +最长前缀所属 peer 和关联端口;这些是局部结构边界,不是业务连接总数限制。 + +握手、收发、更新与重试均可取消;驱动由节点持有,调用方 future 丢弃不会丢失任务 +所有权。Stop 关闭新准入,取消并等待所有 peer 与退役驱动,关闭各上游(分别最多一秒), +随后等待用户态栈退出;私有 DNS 使用弱节点引用,不延长节点生命周期。 diff --git a/docs/reality-wire-protocol.md b/docs/reality-wire-protocol.md index b6f7120..ed2d977 100644 --- a/docs/reality-wire-protocol.md +++ b/docs/reality-wire-protocol.md @@ -1,6 +1,6 @@ # REALITY V1 客户端协议 -本文定义 Vole 的 REALITY V1 客户端线上行为。它不是通用 REALITY 规范。经典模式为默认,可显式要求混合密钥交换。可选的四套 ClientHello 模板及混合兼容边界见 [TLS 指纹](tls-client-fingerprint.md)。 +本文定义 Vole 的 REALITY V1 客户端线上行为。它不是通用 REALITY 规范。经典模式为默认,可显式允许混合密钥交换。命名 ClientHello 模板及版本边界见 [TLS 指纹](tls-client-fingerprint.md)。 ## 版本边界 @@ -10,7 +10,7 @@ V1 只支持: - 客户端模式; - TLS 1.3; -- 经典 X25519,或显式要求 X25519MLKEM768 的 TLS 密钥交换; +- 经典 X25519,或显式允许 X25519MLKEM768 的 TLS 密钥交换; - classic Ed25519 临时证书认证; - 0–8 字节 short ID,在线上右侧补零到 8 字节; - BoringSSL 原生握手和加密实现。 @@ -28,11 +28,12 @@ Vole 不能读取该私钥,也不能为两个用途生成不同密钥。 原生封装按实际 GroupID 找到唯一 X25519 share,不依赖 share 下标。Firefox 可保留 额外 P-256 share,但认证始终绑定同一 X25519 临时私钥。Chrome133 的普通 TLS -ML-KEM group/share 在 classic REALITY 中裁剪。显式混合模式保留模板中的真实混合 share; -无命名指纹时只提供混合 share。若同时存在独立 X25519,优先用它派生认证,否则使用 +ML-KEM group/share 在 classic REALITY 中裁剪。显式允许混合时保留模板中的真实 share; +Chrome133 与无命名指纹提供 X25519MLKEM768/X25519 双 share,其他 TLS1.3 模板保留 +各自经典 share。若同时存在独立 X25519,优先用它派生认证,否则使用 实际混合 share 内的 X25519 分量,与 Mihomo 的认证取值一致。 -重复/缺失认证 share、低阶点均失败。显式混合模式还在 ServerHello 处理时要求 -实际选择 group 4588,经典组或 HRR 立即失败,不重新发起经典握手。REALITY 身份 +重复/缺失认证 share、低阶点均失败。服务端可在本次握手选择允许的经典或混合组, +不因此重试;HRR 仍立即失败。Android/360 模板仅有 TLS1.2,不能用于 REALITY。REALITY 身份 认证仍是经典 X25519/Ed25519,不能将混合 TLS 密钥交换称为后量子身份认证。 ClientHello 的 legacy session ID 固定为 32 字节。生成密文前先把该字段清零,再编码完整 TLS Handshake `ClientHello`,将其作为 AES-GCM 的 AAD。 diff --git a/docs/runtime-resource-policy.md b/docs/runtime-resource-policy.md index 64af05d..1fd2a37 100644 --- a/docs/runtime-resource-policy.md +++ b/docs/runtime-resource-policy.md @@ -28,11 +28,12 @@ Release 构建优先吞吐(`opt-level = 3`),保留 thin LTO 与现有缓 包队列 256 普通事件 / TCP accept 128 每关联 UDP 入站 64 -普通 UDP 响应 4,096 +普通 UDP 响应 256 DNS 响应 128 -每个 TCP 方向缓冲区 32 KiB -TLS / XHTTP 缓冲区 64 KiB -DNS 类型化缓存 256 项 +每个 TUN TCP 方向缓冲区 4 KiB +TLS / 流式 XHTTP 缓冲区 64 KiB +XHTTP packet-up 单批 H1/H2 64 KiB;H3 128 KiB +DNS 结果总容量(每角色) 默认 4,096 项,dns.cache-max-size DNS 原始响应缓存 64 项 / 256 KiB TUN 域名提示 256 项(按需) ``` @@ -41,6 +42,7 @@ MTU 由 `tun.mtu` 配置,默认值和平台限制统一见 [TUN 平台层](tun - 普通 UDP 在 reader 同步分流并直接提交到每源关联,不经过 TCP Driver 或共享 UDP 入站队列;DNS 查询独立提交受跟踪任务。 - 每关联请求、普通响应和 DNS 响应使用独立内部容量,不扩大 128 项的 TCP accept,也不增加公开配置字段。 +- netstack 每轮维护 socket 与连接请求后,优先处理已就绪的原始入站批次,再处理内部通知;停止保持最高优先级。原始入站暂满时,当前包最多保留一个协作调度回合并重试,持续满时丢弃;UDP、DNS 和停止继续独立推进,队列容量保持原值。 - TUN 域名提示只在 TUN 配置实际包含域名规则时创建,并从空容量按需增长。 - 域名提示的读写使用短同步锁,临界区内不等待 IO 或执行异步操作;新 TUN IP 流读取当前提示,活动 UDP 五元组保持既定 action。 - ICMP 响应复用原始包出站队列,不创建独立任务或长期状态。 @@ -52,41 +54,92 @@ MTU 由 `tun.mtu` 配置,默认值和平台限制统一见 [TUN 平台层](tun ## TCP 与握手 -- TCP 流按需创建任务,每方向缓冲区固定为 32 KiB。 -- rustls/XHTTP 的配置缓冲区为 64 KiB;boring 每次应用写入最多 min(节点缓冲预算, 16 KiB),原生 TLS 记录有界,命名模板证书消息/解压输出另限 128 KiB。 +- TCP 流按需创建任务;TUN 转发每方向缓冲区为 4 KiB。 +- netstack 的 TCP 应用队列从空分配按实际写入有界增长,保留每方向 16 KiB 逻辑上限;排空不收缩,连接释放时销毁。smoltcp 收发缓冲仍各为 16 KiB,不用进程内存或连接总数改变容量。 +- WireGuard 与 MASQUE H2/IP 的私有主动栈使用 64 KiB TCP 发送总预算: + smoltcp 32 KiB 与按需应用队列 32 KiB。接收总预算仍为 32 KiB; + MASQUE H3/IP、共享 TUN 与通用 netstack 默认值保持原样。 + 节点业务与私有 DNS 的 TCP 共用该主动栈;此预算不改变内层 UDP、原始包队列或连接数量策略。 +- 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 仍以逻辑连接整体关闭为准。 +- 共享 TLS 客户端的显式验证名、协议版本和 ClientHello profile 固定。内联客户端身份固定;文件身份整对校验后供新物理连接取快照,旧连接保留原身份。票据按节点和身份代隔离,换代后拒绝旧票据和旧连接晚到的票据,各代共用原节点总容量。boring 在应用层 ALPN 接受前不发布票据,失败或取消丢弃临时票据。标准 TLS 和 REALITY CloseWrite 只发送 close_notify,刷新最多5秒,保持读方向和底层传输;Drop 释放整个流。XHTTP 仍以逻辑连接整体关闭为准。 - 半开连接、出站握手和活动会话只记录当前值和峰值,不触发资源错误。 - 超时、取消、EOF 和协议错误负责回收;停止必须等待全部已跟踪任务结束。 - 引导解析器最多使用四个工作线程;全部忙时在调用方既有期限内等待,不返回人为容量错误。 ### 物理 socket 初始化 -TCP/UDP 共用 Dialer:创建 socket、nonblocking、源地址/Windows 接口绑定与 protect -在当前 Tokio runtime 的受跟踪阻塞任务中完成,避免 FD 表扩容等同步系统调用占满 -业务 worker。异步 connect、reactor 注册与收发仍在原 runtime,不新增独立线程池、 -CPU 数判断、业务数量配额、FD 预热或 DNS socket 复用。 +TCP/UDP 共用 Dialer。Linux 普通 socket 的创建、原生选项和 bind 在当前异步任务中 +完成,使用同一初始化许可、取消令牌和停止跟踪;此路径不执行宿主回调、名称解析或 +接口发现。配置 TFO、MPTCP、protect、源地址、显式接口、DNS binding 或自动接口发现 +时,继续使用原受跟踪阻塞任务;其他平台也保留该路径。异步 connect、reactor 注册与 +收发仍在原 runtime,不新增线程池、CPU 数判断、业务数量配额、FD 预热或 DNS socket 复用。 -每个初始化作用域最多持有 64 个未完成的阻塞提交,TCP/UDP 共用内部许可。 -在登记任务、创建 socket 和 `spawn_blocking` 前异步等待许可,不因繁忙返回容量错误; -等待继承调用方期限和取消,Stop 关闭准入并唤醒等待者。许可由阻塞任务持有到真正 -完成或被丢弃,调用方提前取消不会释放已开始工作的许可。它不限制已建立连接, +每个初始化作用域最多持有 4 个未完成的初始化操作,TCP/UDP 共用内部许可。 +在登记、创建 socket 或提交阻塞任务前异步等待许可,不因繁忙返回容量错误; +等待继承调用方期限和取消,Stop 关闭准入并唤醒等待者。许可持有到初始化真正 +完成或被丢弃,调用方提前取消不会释放已开始阻塞工作的许可。它不限制已建立连接, 不增加独立线程池或公开配置;也不把等待调用的数量或整进程内存变成有界保证。 -取消关闭结果接收器并跳过尚未开始的初始化;已开始的系统调用或同步 protect 不能 -强制中断,完成后丢弃未交付的 socket。每个 Running Session/测速图拥有独立的 +WireGuard 物理 UDP 路径通过原上游请求和共享 Dialer,尽力申请 512 KiB 接收缓冲。 +系统可以拒绝、放大或限制该提示;继续使用同一受保护 socket,不改变逻辑包预算、 +队列、初始化或停止契约。Linux 内核 socket 缓冲通常不计入进程 RSS,增加提示并不 +等于没有内存成本,也不能替代 Apple physical footprint 或真机验证。 + +阻塞路径取消时关闭结果接收器并跳过尚未开始的初始化;原生路径在创建前、策略执行后 +和返回前检查同一取消令牌。已开始的系统调用或同步 protect 不能强制中断,完成后 +丢弃未交付的 socket。每个 Running Session/测速图拥有独立的 初始化作用域。Stop 先关闭 admission,再取消并等待业务/协议任务,最后等待已登记 -初始化任务完成,才释放平台回调租约。测速 runtime 的销毁也使用完整 join,不能 +初始化操作完成,才释放平台回调租约。测速 runtime 的销毁也使用完整 join,不能 以有限 shutdown timeout 脱离在途初始化。protect 仍必须快速、同步、非阻塞; 线程位置变化不放宽宿主回调契约,也不改变保护失败关闭或 Windows 双绑定策略。 ## 共享流传输基础 -`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正文定界。 - -共享的无池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。 - -XUDP现在只拥有已认证流上的帧编码;VLESS响应头由VLESS包装层处理。元数据仍最多512字节,单payload仍受调用方预算和u16 wire上限约束,不新增全局会话额度。 +`stream-transport` 仅包装传入 IO,不创建 socket 或 DNS。WS/HTTP响应首部最多16 KiB、100字段;WS用户请求头最多100字段,额外固定升级头和early-data仍计入16 KiB总预算。WS early-data最多11,904原始字节,并按当前请求的实际剩余首部预算进一步缩小;头名称/路径后缀由类型化选项区分。WS消息和单帧最多64 KiB,写入切块16 KiB;HTTP首包正文直接写入,不整体复制或持续按HTTP正文定界。 + +共享的无池gRPC/legacy H2适配器每个实例仅拥有一条底层连接与一个逻辑流,不是全局并发许可。Trojan/VMess/VLESS共用节点级gRPC池,物理连接按节点实例隔离,复用保留原上游与安全策略,每条逻辑流独立认证。流窗口64 KiB、连接窗口128 KiB、最大HTTP/2帧和发送缓冲各16 KiB、解码负载64 KiB。读侧按实际消费量释放窗口;每poll最多处理32个片段/控制消息。Trojan池使用内部duplex模式,仅发送END_STREAM并保留读取和lease;VMess/VLESS只结束当前逻辑流。关闭前已接受的数据须到达所提供IO的flush边界,不能仅排入h2队列后立即reset/abort;每个逻辑写端最多一个待确认数据块,Stop可取消等待。节点池最多保留4条空闲连接,PING ACK期限15秒;节点Stop取消并join全部物理驱动。无池模式的owner.stop同样等待驱动任务退出;Drop只做取消兜底,不作为同步停止通过证据。WS/HTTP适配器按底层CloseWrite语义工作,协议包装层可以根据已声明的协议关闭契约结束整个逻辑流。所有握手使用调用方同一个绝对deadline。 + +Trojan/VMess/VLESS 的 YAML gRPC 池阈值省略或三项字面全零时采用 4/0/0,Mihomo 为 1/0/0。显式 +`max-connections: 1` 或 `max-connections: 2` 保留各自策略。连接按需建立,不预建四条,也不提高单连接的 +窗口或缓冲上限;新增连接仍增加自身的驱动、协议状态及接收信用。内部无池传输与 +TrustTunnel/MASQUE 的 CONNECT 所有者不使用这项 YAML 默认值,保留原连接策略。 + +MASQUE H2 CONNECT-IP 单独采用 128 KiB 流接收信用,连接接收信用仍为 128 KiB。 +其它共享流传输的 64 KiB 流窗口、16 KiB 发送缓冲和最大帧保持原样;信用按实际消费 +释放,不预分配全部额度,也不改变 capsule、flush、FIN 或同步 Stop 语义。 + +H2 CONNECT-IP 只合并已就绪的 capsule,每批最多16次出队/16 KiB编码字节, +不等待凑批;无效包也计入扫描预算。每条隧道复用一个16 KiB聚合缓冲,最多暂存 +一个已编码的溢出 capsule。合法大包仍独立发送,不因聚合阈值拒绝。 +每批仍通过原IO完成 write_all 与强flush后才处理下一批;TTL只调整一次, +结束前提交已取得的前缀,取消与Stop仍由原所有者回收,不增加后台发送任务。 + +Gun 读侧只暂存最多9字节的gRPC/protobuf头;校验后直接从当前H2帧交付负载前缀, +不累计完整消息或复制到第二份正文缓存。头部移入解析状态、负载交付给调用方后才释放 +对应H2信用;未读帧尾继续持有信用。取消保留头、帧与剩余长度;合法前缀交付后发现 +正文截断仍返回原协议错误。负载上限、空消息、trailers及写侧flush/FIN/Stop边界保持。 + +XUDP现在只拥有已认证流上的帧编码;VLESS响应头由VLESS包装层处理。元数据仍最多512字节,单payload仍受调用方预算和u16 wire上限约束,不新增全局会话额度。接收直接填充既有的单帧有界缓冲,保留合并到同次读取中的后续帧与取消后的解析进度,不增加固定暂存区。 + +Trojan UDP 接收直接读入既有的有界帧缓存,每次最多读取 2 KiB,不在等待中的 +异步接收状态内另留 2 KiB 临时区。开始读取时按需预留缓存,取消保留已接收的 +帧前缀;帧上限、包预算和关闭语义保持不变,关联释放时销毁缓存。 + +公共 sing-mux 包装器按节点拥有独立池,只消费底层协议到约定目标的 TCP IO。 +每池最多保留16条闲连接;命令/物理写队列16项、写块16 KiB。smux接收帧共享每物理连接的字节预算,单流队列槽数由该预算推导;当前默认值见[XHTTP/sing-mux](xhttp.md#通用-sing-mux)。 +h2mux每流接收窗口64 KiB、连接接收窗口512 KiB,按实际读取量释放信用。 +连接额度允许少量未读取流占住各自窗口时,其他逻辑流继续接收;不预分配全部窗口。 +yamux单连接累计接纳64流后退役,连接接收窗口16 MiB;活动兄弟流继续。 +满足 XHTTP PacketUp 自主写条件的私有 h2mux,普通 DATA 仅在现有 h2 capacity +范围内移交驱动后接纳,16 KiB 发送缓冲不变。每逻辑流固定一个确认状态,物理 flush +列表每流至多一个弱引用登记;不按 DATA 保留 oneshot。显式 flush/END_STREAM 前仍确认 +真实 supplied IO flush,首帧期限、部分写取消、来源隔离和同步 Stop 不变;不新增任务或全局额度。 +私有 h2mux 的底层写入有正进度而 flush Pending 时,以一个布尔状态触发一次驱动重调度; +没有后续写入便不再自唤醒。重调度不分配队列或确认状态,也不把 Pending 当作 flush 完成。 +h2mux/yamux上传半关闭保留读取与lease,smux v1关闭整条逻辑流。 +图只登记最终包装器;begin_shutdown同时关闭mux和底层准入,shutdown先join全部 +mux驱动,再等待底层协议。provider代际与node-only测速复用此屏障,没有全局池。 ## 协议局部预算 @@ -98,9 +151,15 @@ XUDP现在只拥有已认证流上的帧编码;VLESS响应头由VLESS包装层 普通 TUN UDP 关联: +- 共享已发布代际的不可变节点和策略组注册表,不为每个关联复制指针数组;旧会话保留原代际直到释放; - 关联表不设固定项数; - 每个关联的入站队列最多 64 项,满时只丢当前数据报,不等待慢关联并阻塞其他来源; -- 普通响应队列最多 4,096 项,满时只丢当前响应;没有共享 UDP 入站中转队列; +- 普通响应队列最多 256 项;满时每源关联最多暂持一个已接收响应,暂停该关联继续接收, + 同时等待写回容量、处理请求和取消,不增加队列或任务。取消或错误时丢弃未提交响应; + 没有共享 UDP 入站中转队列; +- 每源关联每处理 32 个实际响应显式退让;共享响应队列占用达到四分之一时提前退让, + 让唯一 writer 与其他关联推进。不等待凑批、不预取包、不按忙碌情况拒绝关联。 + 接收的原生合作预算继续保留。 - reader 拥有关联表和入站分发,唯一 TUN writer 直接、公平消费普通/DNS/TCP raw 三个通道;写回等待不阻塞 reader,不增加逐关联写回任务; - TCP/ICMP ingress 满时非阻塞丢当前完整 IP 包,避免阻塞 UDP/DNS,记录 packet queue drop;TCP 重传恢复,不引入 pending FIFO; - 使用代次感知所有权和子取消令牌; @@ -115,28 +174,101 @@ XUDP现在只拥有已认证流上的帧编码;VLESS响应头由VLESS包装层 响应队列满不会延长源关联所有者的生命周期。 - reader EOF、错误或取消时,先取消其 UDP 子作用域,再删除、取消并等待全部关联和 DNS 任务;父运行时统一取消并等待唯一 writer 与 netstack。UDP 子作用域停止不取消调用者令牌。 +开启 Controller 的业务 UDP 按目标独立统计、取消并固定出口。`DatagramTransport` 必须显式声明 +多目标共享能力,没有默认值;透明包装传递底层能力,不能在包装时退回隐式独占。 +公共路由层统一决定复用,仅允许同一源关联、同一具体节点和资源代际内的未改写 IP 目标。 +支持路径包括 DIRECT、SOCKS5 UDP、Trojan UDP、XUDP、VLESS/VMess packetaddr、UoT、 +原生 SS2022、HY2 UDP、TUIC native/quic、Mieru、TrustTunnel,以及 WireGuard/MASQUE/IP 的内层 UDP。 +内层复用只合并端口租约、逻辑会话或响应交接;底层物理隧道的已有池化保持原状。 +raw VLESS/VMess、Gecko、固定物理 peer、尚未确定具体出站的路由包装保留独立传输。 +VMess packetaddr 的 AES/ChaCha 与 none/zero body 沿用相同共享门禁;body cipher 不改变逐包目标编码。 +fake-IP、QUIC 目标改写、域名及其它需要地址还原的请求始终隔离,即使底层支持多目标; +两个原目标可能解析到同一个真实端点,不能仅靠回包源地址区分。 +共享路径只有一个接收者,按回复地址找到仍有效的目标,未请求或过期回复丢弃。 +帧写入中途取消时,不能重试已损坏的可靠流:整条关联失败关闭,其成员按关联错误退出。 +目标正常退出后物理关联退役,继续服务既有目标,新目标另建关联;防止目标重建或 flow-id 更换后 +旧回复串入新会话,不保留历史目标墓碑。最后目标退出关闭关联,Stop 同步释放全部关联。 +每目标只登记取消通知,不分配逐目标接收 future、任务或负载队列。 +DIRECT 在源关联内共用一个延迟分配的接收暂存缓冲,保留预算加一字节的超长包检测; +交付包只持有实际负载。持续的就绪队列每目标最多一个通知,接收只轮询被唤醒的目标, +不新增逐目标任务或负载队列。Mieru 通过私有单槽响应交接提供相同的就绪轮询, +容量仍为一项,满槽使后台完整包解析器等待;取消、关闭及 reader join 保留。 +未提供轮询接口的协议继续使用取消安全的异步接收。 +成功接收后的本地 FIFO 重排不唤醒正在运行的接收者;外部就绪、取消和工作预算耗尽仍保留唤醒。 +目标访问时检查自身过期,整个表每 10 秒清理并回收多余容量;流量不会推迟该期限。 +DELETE 通过取消令牌立即唤醒等待中的接收,关闭与 flow-id 替换移除旧就绪注册。 + ### 定向数据报预算与受控 QUIC `DatagramBudget` 分别表达当前层的发送和接收 payload 上限。嵌套协议先为下层申请有界 envelope 空间,再按实际协议/地址族开销扣除下层能力,与调用方预算取交集;不能把接收上限直接当作发送能力。DIRECT、SOCKS5、SS 2022、SS/AnyTLS UoT 和 VLESS XUDP 保留对端/地址校验。超出发送预算在写入前失败,超出接收预算丢当前包并有界让出执行权,close 后不能恢复收发。UoT 的流头不从 UDP payload 预算扣除;SS 首包门控、读取任务与单响应队列的局部上限见[出站](outbounds.md#udp-over-tcp-v2)。 -`quic-transport` 只把已有 `DatagramTransport` 适配成 Quinn 的受控 UDP 接口,没有内部 bind、DNS 或 DIRECT 回落。每个连接双向各最多32个排队数据报,另允许一个正在发送的包;TX满返回WouldBlock,RX满暂停读取。接收等待不会持有发送队列锁;Pending发送不会重复提交。单逻辑peer/物理peer映射和来源校验独立保留,不接受未请求的目标、GSO或源地址覆盖。物理 socket 仍只能来自 Dialer。 +UoT 写入最多暂持一帧及偏移;取消单次发送后,后续收发先完成它,原建链期限不重置。 +实际写入失败或超时使关联失效;关闭丢弃未提交帧,并取消、等待既有读取任务。 + +`quic-transport` 只把已有 `DatagramTransport` 适配成 Quinn/quiche 共用的受控 UDP 接口,没有内部 bind、DNS 或 DIRECT 回落。每个连接双向各最多32个排队数据报,另允许一个正在发送的包;TX满返回WouldBlock,RX满暂停读取。接收等待不会持有发送队列锁;Pending发送不会重复提交。单逻辑peer/物理peer映射和来源校验独立保留,不接受未请求的目标、GSO或源地址覆盖。物理 socket 仍只能来自 Dialer。 +已打开且受保护的 UDP 路径可借出一个在途发送操作;驱动等待发送或 pacing 时继续接收,保持32包队列与一个发送中的包。首次发送仍由原路径完成初始化;上游不支持此能力时沿用原发送。停止先释放在途操作,再关闭并释放上游。 +立即就绪的读取、发送容量与发送完成检查不登记新的等待者;仅在同一状态锁内确认 Pending 后登记唤醒,保留后续数据、容量恢复、取消与 Stop 的通知。 +队列通知采用边沿:TX由空变非空、RX由满变为有容量时通知驱动;已排队TX由就绪条件继续推进。 +普通RX消费保持底层已有的接收等待,避免逐包取消并重建同一等待。 + +XHTTP 和 DoH/H3 通过 `quinn-h3-transport` 使用官方 h3/h3-quinn,共享取消安全的请求包装与 +非 DATA 帧编码预算;它不创建 socket、查询池或额外业务 body 队列。未完成发送的请求丢弃时 reset, +成功提交 FIN 后不因丢弃发送半而 reset,也不等待逐请求 QUIC ACK。各协议窗口和并发边界见 +[XHTTP](xhttp.md) 与 [DNS](tun-icmp-dns.md)。 + +未显式配置 reuse-settings 的 XHTTP H3 复用既有活动连接,每条最多 4 个逻辑租约; +最后一个租约释放即移出候选池并按既有预算收尾,不缓存空闲连接。 +满足 [XHTTP 固定出口与安全边界](xhttp.md#http-transport-复用) 的 VLESS 私有 H2 腿采用相同 active-only 模型,每条最多 4 个逻辑租约,上传/显式下载各自隔离;其它 H2 路径、H1 与显式复用参数保持原契约。阈值溢出创建新物理连接,不拒绝逻辑流,不改变每流窗口或缓冲。 + +XHTTP packet-up 的单批 backing(含正在发送的正文)上限为 H1/H2 64 KiB、H3 128 KiB, +并与该 writer 采样的 POST 上限取较小值;按需扩容,不积累第二份正文。普通路径等待完整 +POST 响应才释放写背压和确认 flush。无 Encryption、无独立下载、无 padding 且 only-tcp=false +的 VLESS h2mux,仅 H3 packet-up body/auto 上传保留最多两个未退役响应槽:发送 FIN 完成、 +最后正文引用释放并归还唯一 Vec,且槽未满后才能复用正文;flush 仍等待连续成功响应。 +每批只有一份有界 owner 元数据,不复制正文,也不增加 QUIC 窗口、队列或停止预算。该 backing +上限不是进程 RSS 上限;HTTP/QUIC codec、发送窗口和 allocator 的共同持有峰值需实测。 + +`quiche-h3-transport` 为 TrustTunnel/MASQUE 使用官方 quiche 的低层驱动,共享上述数据报适配器,不创建新的 socket。 +接收时移动既有队列中的数据报,独占存储直接交给 quiche 原地解密;共享或不可变存储 +使用按接收预算延迟分配的复用暂存缓冲。Quinn 的借用接收接口和两端队列上限保持不变。 +每个 quiche H3 stream 的已读取 body 最多保留 64 KiB,每次业务写入最多 16 KiB; +只有本地主动请求创建业务状态,未知 DATAGRAM flow 丢弃,已注册 flow 的交付队列最多 64 项。 +这些是局部缓存界限,不宣称 QUIC 库内部总内存固定,也不限制活动业务流总数。 +TrustTunnel H3 使用 quiche 的本地发送容量比例 0.5,按当前可用拥塞窗口与对端信用 +折算应用写入额度,不足时沿原异步背压等待;不改变拥塞控制算法、接收窗口或协议帧,也不是固定字节或 RSS 上限。 +容量不足不能单独证明对端流控信用耗尽,不据此丢弃业务 UDP 帧。 +帧开始写入后必须完成;部分写入失败或被取消时关闭该来源关联,不丢弃帧尾后继续发送下一帧。 +MASQUE 与其余 quiche H3 默认使用原生比例 1.0。 QUIC双向可用payload至少1,200字节;endpoint必须显式把QUIC MTU限制在有效预算内。IPv4/IPv6路径MTU分别先扣除28/48字节IP+UDP头,边界和单字节不足均有定向测试。 -QUIC owner.stop先取消并等待驱动,再关闭并释放上游;上游close最多1秒。Driver Drop仅为取消/abort兜底,不能作为同步Stop验收。队列容量是单连接局部界限,不是全局QUIC连接准入数。 +Linux 受控物理 UDP 的收发与就绪注册可并发借用同一 socket。借用不跨 Pending 或 await, +不转移 socket 所有权;退役先关闭准入,再等待在途借用结束并取走资源,保留的 poller 不延长 fd 生命周期。 -TUIC 与 Hysteria2 共用这一接点;TUIC 的独立会话、关联 ID 退役、控制流、分片负载 +QUIC owner.stop先取消并等待驱动,再关闭并释放上游;上游close最多1秒。H3 的关闭预算内停止新请求准入并执行关闭交换,XHTTP 和 quiche 物理连接退役还保留有界的传输收尾机会,不承诺逐流远端 ACK。Driver Drop仅为取消/abort兜底,不能作为同步Stop验收。队列容量是单连接局部界限,不是全局QUIC连接准入数。 + +TUIC 与 Hysteria2 共用这一接点。接收窗口通过官方 Quinn API 固定设置: +每流 max(initial, min(maximum, 4 MiB)), +连接 initial;默认 TUIC 4 / 2 MiB,HY2 64 KiB / 4 MiB,不预分配全部信用额度。 +DoQ 初始及最小发送 payload 为 1200 字节,接收预算独立保留,允许较大的合法对端包。 + +TUIC 的独立会话、关联 ID 退役、控制流、分片负载 及交付队列边界见 [TUIC v5](outbounds.md#tuic-v5)。旧池的待建流也持有所有权, 不能在等待 credit 时被当成空闲池回收。TUIC 业务 UDP 队列使用独立观测类别。 +native 接收循环每处理最多 512 个就绪事件主动让出执行权,quic relay 保持 32 个; +底层异步等待和协作式调度可更早让出,不增加接收或交付队列。 ## DNS +- 生产运行时从归一化的`dns.cache-max-size`构造缓存,缺省或零值为4,096项。主、bootstrap及已配置的节点/直连解析角色各拥有独立结果容量;A/AAAA类型化结果与原始响应共用每个角色的这份条目数。原始响应另受64项/256KiB预算约束,TUN域名提示独立为256项。 +- `ResourceLimits::dns_address_cache_entries`默认256,只是显式低层构造可传入的容量,生产启动不会用它覆盖`dns.cache-max-size`。WireGuard/MASQUE的私有节点resolver通过独立`DnsConfig::default()`构造,当前默认同为4,096项,不继承用户全局缓存容量覆盖;DNS关闭时的系统ECH metadata resolver另固定64项。这些局部容量不构成进程RSS上限。 - 不设置固定的活动请求或活动传输总许可数。 - 相同 key 的 cache miss 使用 singleflight;leader 取消后 follower 重新选举。 - UDP 传输属于单次请求,同一尝试的重发复用当前传输。 -- 显式 TCP nameserver 按 endpoint 和配置的 route target 复用;一条连接同时只处理一个查询。 +- 上游尝试仅在选择 TCP/TLS 或 DoQ 后分配对应的异步状态;普通 UDP 不内嵌未使用的握手状态。装箱不创建额外任务,取消仍直接丢弃该尝试及其拥有的状态。 +- TCP、DoT、DoH 按上游身份、endpoint 和 route target 复用;TCP、DoT、HTTP/1.1 一条连接同时只处理一个查询,HTTP/2 和 HTTP/3 每连接最多 32 个并发查询。DNS TLS 不保留恢复票据,复用已认证连接。 - 活动 TCP 不设固定数量上限;空闲连接总数最多 4,同 key 最多 2,空闲超时 30 秒。 -- 查询、单次尝试和 UDP 重发期限分别为 5 秒、3 秒和 1 秒。 +- 查询、单次尝试和 UDP 重发期限分别为 5 秒、3 秒和 1 秒。每次查询最多并发 4 个上游,胜出后取消其余尝试;UDP TC 转同一上游 TCP。 - 响应最多扫描 64 条记录,类型化缓存和提示最多保留 16 个唯一 IP。 - 停止取消打开、发送、接收、重试和响应发送,并释放全部传输。 @@ -146,21 +278,75 @@ IP-only协议接点持有`ResolutionContext`,runtime DNS通过Weak绑定,不 ## 代理组与 Controller -- 静态 `select` 组只拥有不可变的有序成员和每组一个可原子替换的当前索引,不拥有协议连接、后台任务、队列、缓存或独立 worker。 -- 组数、每组成员数、重复成员数和嵌套深度没有独立固定上限,统一受 256 KiB YAML 上限约束;配置期使用 O(V+E) 的迭代 DAG 校验,运行时也以迭代方式解析当前叶节点。 +- 每个资源代际内的组成员保持有序且不可变;组保存当前选择、健康与策略状态,协议连接仍由节点拥有。健康探测由该资源代际统一调度,最多并发 10 项,停止时取消并等待完成。 +- provider 候选整体验证后发布,既有会话保留原资源代际;不再被使用的旧代际停止探测并释放资源。选择与 fake-IP 持久化由各自的存储所有者管理。 +- 组数、每组成员数、重复成员数和嵌套深度没有独立固定上限,统一受 8 MiB YAML 输入与 32 MiB 展开计费上限约束;配置期使用 O(V+E) 的迭代 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;超时、超限和解析失败只终止当前请求。 +- Controller 同时最多跟踪 8 个连接任务,包含最多 4 个 WebSocket 订阅。header、body 和输出各有 5 秒 I/O 期限;普通 PUT body 最大 1 KiB,`PUT /configs` 在取得独立配置应用许可后允许最多 8 MiB,并使用配置候选的 90 秒总期限。超时、超限和解析失败只终止当前请求。 +- WebSocket 按需采样,不为每个订阅保存历史快照;输出 JSON 在扩容前限制 256 KiB,连接快照先限制 512 个引用。客户端缓冲/消息/帧各 1 KiB,慢写取消当前订阅。日志总线仅在订阅时保留 64 条共享事件,不截断事件内容或另设每秒条数限制;无订阅不格式化或轮询,Stop join 连接并释放订阅。 完整接口见 [运行时 Controller](controller-api.md)。 +## Provider 与 HTTP 资源 + +Provider 在现有 AGE 解密后读取 YAML、URI 列表或标准 Base64 URI 订阅,转换结果继续走 +名称过滤、override、完整节点与依赖图校验及资源代次发布。输入和解密结果最多 8 MiB, +URI 转换使用 32 MiB 保守空间计费,单行最多 64 KiB,嵌套最多 64 层。 +URL/query、凭据解码、JSON/YAML 转换和选项 clone 前按单行最坏结构预留临时空间; +展开节点及名称索引单独保留计费,多端口来源逐个加入候选。外层 Base64 清理与解码 +先检查并存空间。JSON 先做不构建值树的语法检查,再复用有界 YAML visitor。 +这份估算不包含调用者已持有的源文件,不代表 allocator 精确容量或进程 RSS 上界; +不足时整个候选失败,不增加业务数量准入或新的配置字段。 + +HTTP(S) 资源共用流式 gzip 解码与 URL Basic。压缩 framing 与解压内容分别计数, +size-limit 作用于解压内容;完整 gzip 校验和落盘成功后才返回候选,坏流或取消删除暂存。 +解码输出块检查同一取消与期限,不整包缓冲解压结果。跨 origin 不转发原认证或 Cookie。 +Provider 来源身份摘要绑定精确 URL 和实际请求 header/UA;GeoData 用精确 URL 摘要。 +来源元数据和 Debug 不保存或输出明文凭据,GeoData 的来源显示仅保留 origin。 +Provider 旧 URL-only 身份须无条件刷新;旧内容可用与其 ETag/订阅元信息可信度分别判断, +来源身份不符时保留内容但无条件重新获取,不用旧身份授权 304。 + +## 离线规则 bundle + +`rule-providers.*.path-in-bundle` 使用 `dataDir/BundleMRS.7z` 中的精确成员名; +不展开到目录、不预置资产、不对其他 Provider 生效。初始化依次尝试有效缓存、bundle、 +原 file/http 来源;缺失或无效 bundle 沿用原来源。有效成员按原格式解析,原始字节与 +无 ETag 的本地来源标识原子写入缓存;本地来源不算已完成 HTTP 获取,重启也保留这一语义。 + +单次初始化按顺序读取各资源,同一读取只有一个单线程解码器。先检查签名与 CRC、 +128 KiB header/decoded header、单 encoded-header packed stream(16 MiB)及至多8个 coder, +再让官方 `sevenz-rust2` 读取 metadata。拒绝加密、外置 metadata 与未审核 codec; +支持 COPY、LZMA1/2、Delta 及 x86/ARM/ARM Thumb BCJ。随后检查所选压缩块的单输入链、 +4096项文件表、128 MiB压缩输入和256 MiB解码总量;仅保留目标成员,最多64 MiB, +并受 size-limit 进一步约束。solid前置成员流式丢弃,目标读至EOF后才接受校验结果。 + +字典、输出和保守metadata计费共享96 MiB准入估计;该估计不包括allocator、解析后的规则 +及整个进程其他资源,不能当RSS上界。检查在官方 decoder分配之前执行,metadata中的 +计数由受限header长度约束。超过局部边界的bundle使用原来源,不静默截断成员。 +读取与解码检查取消、30秒局部期限、累计256 MiB来源读取和65536次I/O操作。 +使用同一个打开的文件句柄,异步调用者等待阻塞任务完成,不以丢弃JoinHandle替代停止。 + +## Provider 名称表达式 + +普通筛选表达式继续使用线性时间的 `regex`。必要的 lookaround/backreference 使用 +`fancy-regex`,限制复杂源码 4 KiB、AST 512 节点/32 层、有限重复数 4096、委托编译和 +DFA 缓存各 512 KiB;不接受递归、条件式和控制动词。每次复杂匹配最多 10,000 回溯, +输入名称最多 4 KiB。一次候选共享最多 10,000 次复杂匹配/改名匹配和 100 ms 执行窗口; +改名输出最多 64 KiB,后续表达式继续计费。单次库调用不能中途抢占,调用间检查取消。 +编译、预算、运行错误与取消均使候选失败,保留当前资源和 HTTP 缓存,不静默漏选节点。 +这些边界仅用于名称表达式,不改变 GeoData/DOMAIN-REGEX 或业务连接准入。 + ## GeoData - 所有平台完整加载被引用分类的所选记录,不设记录条数硬上限或截断;GeoIP 与 GeoSite 没有优先额度分配。 - 同种基础 code 按 ASCII 大小写归一,业务规则与 DNS policy 共用分类和值存储。属性取交集;正反选择器共享匹配器,多种属性筛选共享值及已编译正则,不重复解码同一分类。未引用分类不构建匹配器。 - 各平台均不设独立 GeoData 分类、引用、文件大小、累计源码/值字节或总加载内存预算;DNS GeoSite policy 也不保留独立项数/引用数上限。 -- Loader 只解析被引用的分类,其他分类按 wire 长度跳过。 +- DAT Loader 只解析被引用的分类,其他分类按 wire 长度跳过。默认国家 MMDB 与 DAT 二选一; + Meta 国家库在完整校验后优先将配置引用的分类编为紧凑 CIDR,并释放原始文件缓冲; + 投影不更省或不适合时保留原 Reader。其他国家 schema 与 ASN 保留拥有字节的 Reader, + 按索引借用查询。各路径均不建立逐地址缓存,不截断记录或增加 GeoData 内存准入。 - Domain/Full 匹配器使用紧凑连续存储,运行期查找不分配。Regex 使用共享的 `regex::bytes::Regex` 并关闭 Unicode 匹配模式;库管理编译状态和搜索缓存,搜索可能 分配 scratch、增长缓存或竞争共享缓存,不能将 Domain/Full 的无分配性质扩展到 Regex。 @@ -183,22 +369,34 @@ IP-only协议接点持有`ResolutionContext`,runtime DNS通过Weak绑定,不 排空响应间隔不等于整体 Stop 时限,释放存储也不保证 allocator 立即归还 RSS。 - 卸载后的加载失败只使对应种类不可用,健康另一类仍发布;不恢复旧整体快照。 卸载前的下载/结构失败保留当前匹配器,失败更新均保留旧磁盘资产并按现有调度重试。 + 国家 MMDB 与 ASN 先借用候选校验,再排空旧快照,构建投影或移动候选字节,失败保留/恢复旧代。 304 在 matcher 已可用时只调度,不可用时重载本地资产。既有 TCP 选路与 TUN UDP 固定 action 不回溯;没有新增公开 API、业务数量配额或 GeoData 内存预算。 -GeoData 行为见 [规则与资产](geodata.md)。性能与压力测试由 -[container-benchmark](https://github.com/YuanDevTeam/container-benchmark) 维护, -测试结果记录在对应运行中。 +GeoData 行为见 [规则与资产](geodata.md)。性能与压力测试由独立容器测试工具执行, +结果与结论统一保存在 benchmark 工程。 + +## 统一内存策略 -## Apple 内存遥测 +所有平台与构建共享上述队列、缓冲、缓存、QUIC 窗口与线程策略。 +CLI 和 FFI 使用同一 Invoke 启动路径,无独立内存编译开关、YAML 字段或 Invoke 参数。 -iOS/tvOS TUN 通过 `TASK_VM_INFO` 尽力记录当前 physical footprint、进程生命周期峰值和限频阈值事件,运行期间最多每 30 秒采样一次。macOS 不启用该周期任务;平台接入与事件标识见 [TUN 平台层](tun-platform.md)。 +TUN 在生命周期边界及运行期间每 30 秒尽力记录进程内存和限频阈值事件。 +Apple 使用 `TASK_VM_INFO` 的当前与生命周期峰值 physical footprint;Linux 使用 +procfs 的 VmRSS/VmHWM。其他平台的采样不可用时只报告一次诊断,不伪造读数。 -- 采样失败只记录一次警告; -- 遥测不写入 `lastError`; -- 读数和阈值越界不改变 prepare、start、running、stop 或 panic recovery; -- 停止后的 allocator pressure relief 同样是尽力而为; -- 固定阈值只是诊断信号,不是产品内存承诺或 admission gate。 +GNU/Linux 的 TUN 在工作线程启动前,进程内一次性尝试设置 glibc arena 为 1、 +mmap 阈值为 32 KiB;显式 `MALLOC_ARENA_MAX`、`MALLOC_MMAP_THRESHOLD_` 或 +`GLIBC_TUNABLES` 覆盖优先。这是进程级 allocator 设置,可能影响同进程中的宿主分配。 +Stop 的同步释放屏障完成后,Apple 通过 malloc zone、GNU/Linux 通过 `malloc_trim` +尽力归还空闲内存;不支持该接口的平台保留原 allocator 行为。 + +- 未使用 TUN 时不配置 allocator、不启动周期采样或 Stop 回收。 +- 采样失败只记录一次警告,不写入 `lastError`。 +- 43/45 MiB 阈值分别对应默认 1 Gbps 与 2 Gbps 的预算,仅作诊断;Core 不推断负载档位,最终由 benchmark 按实际负载判定。这些观测不改变 prepare、start、running、stop 或 panic recovery。 +- Stop 后的 allocator 回收只执行一次且尽力而为,不保证立即降低进程内存。 + +内存预算与真实设备范围见[验收边界](acceptance.md)。 ## 日志与观测 @@ -207,10 +405,11 @@ iOS/tvOS TUN 通过 `TASK_VM_INFO` 尽力记录当前 physical footprint、进 独立 netstack 的 `udp_drops` 只记录通用 UDP endpoint 的入站丢弃;生产 TUN 使用 TCP-only netstack,因此该值为零不代表 UDP 无丢包。运行时记录 `udp_association_queue_drops` 和 `udp_response_queue_drops`,保留两者合计的 -`udp_queue_drops`;TCP/ICMP ingress Full 记录 `packet_queue_drops`。这些计数 +`udp_queue_drops`。普通响应暂持等待容量不计丢包,取消或错误后实际丢弃才计数; +TCP/ICMP ingress Full 记录 `packet_queue_drops`。这些计数 不包含物理 socket、内核 TUN 或对端设施中的丢包。 -日志必须有界且脱敏,不记录目标、DNS question、UUID、凭据、密钥、负载或完整配置。 +CLI 与 Controller 按日志级别完整输出事件的 target、message 和字段,不按敏感内容脱敏或删减。Controller 的订阅队列、WebSocket 消息与写入期限仍按上文限制;超过消息预算时关闭订阅,不发送截断事件。 仅`cfg(test)`/`interop-test`启用的`ResourceProbe`按测试作用域记录RAII当前值/峰值;子任务显式继承作用域,不使用进程全局reset或更换生产allocator。当前接入物理TCP/UDP、共享流/QUIC驱动与session、数据报association、DNS池/等待者及既有运行时活动guard。Hysteria2 的待完成分片通过 `Reassembly` 登记,交付后释放 Packet ID 和重组资源;零计数必须与实际执行过的重组路径一起解释。 @@ -225,3 +424,13 @@ TCP-only netstack,因此该值为零不代表 UDP 无丢包。运行时记录 4. 记录主机和对应物理平台证据。 没有测量证据时不增加新的资源控制层。实际验证范围见 [验收矩阵](acceptance.md)。 + + +## 在线配置候选 + +Controller 与 Invoke 共用每实例一个候选许可,在完整请求 body/JSON 分配前准入;队列仅一项。候选复用 provider 代际 worker,所有已启动阻塞解析、图准备、路径准备与 GeoData 注册操作均由该 worker join。失败不发布新图,不覆盖活动 provider body/metadata;完成图验证后可以创建受 dataDir 约束的缓存目录。配置替换原子发布模板、图、DNS、组和可用资源快照,退休代际由活动业务持有。此局部准入不限制正常业务连接数,也不将 YAML 上限描述为整进程 RSS 限制。 + + +SS2022 插件复用受控 TCP、原有 TLS 预算和 Stop 所有者。obfs/V2Ray mux 每次最多保留 +一个 16 KiB 输出帧;HTTP 头最大16 KiB,mux元数据最大512字节。裸smux的物理命令队列和 +聚合接收字节数均有界;取消/Stop等待全部自有驱动结束。 diff --git a/docs/testing-isolation.md b/docs/testing-isolation.md index 16ebf57..ea7f02e 100644 --- a/docs/testing-isolation.md +++ b/docs/testing-isolation.md @@ -4,8 +4,8 @@ 均在隔离容器中运行。宿主只运行待测客户端、测试驱动和观察工具。 纯内存、编解码和离线配置测试可在宿主执行;容器不可用时报告未执行,不回退到宿主服务端。 -编排、对端下载和压力测试由 [container-benchmark](https://github.com/YuanDevTeam/container-benchmark) -管理,执行时显式传入 `--source vole=PATH`。Vole 编译不依赖 benchmark checkout。 +编排、对端下载和压力测试由独立容器测试工具管理,执行时显式传入 +`--source vole=PATH`。Vole 编译不依赖测试工具 checkout。 ## 执行要求 @@ -14,10 +14,14 @@ 2. 对端使用官方最新稳定发行二进制和官方稳定镜像,记录实际版本。保留 Mihomo 回环 保护及第三方默认 socket 行为。仅 Caddy 可按需使用官方 xcaddy 构建并注明来源。 3. 地址来自容器网络;配置、证书和凭据放临时目录,fixture 只读挂载,日志有界且脱敏。 -4. 启动前注册清理,成功、失败、超时和中断均回收本次拥有的资源。构建、下载、配置、 - 日志和采样等临时文件随运行清除;公共依赖缓存与生产发布产物独立保留。 -5. 结果记录实际命令、环境、输入、流量、指标、失败和清理状态。双向业务应校对内容, +4. 启动前登记资源所有权,成功、失败、超时和中断均停止并等待本次容器退出。 + 先保存文字结论,再删除本次临时容器、网络和中间产物;已验证的共享依赖缓存独立保留。 + 清理前复查资源归属;清理失败时保留必要现场并记录错误。 +5. 结果记录实际命令、环境、输入、流量、指标、失败、停止和清理状态。双向业务应校对内容, 认证拒绝应确认原站未收到被拒绝的业务,客户端超时本身不能证明拒绝生效。 +6. CLI、原生 Invoke/FFI、协议、DNS、生命周期和压力的网络业务统一经过真实 TUN。 + 使用隔离的负载网络命名空间、真实 raw-IP fd,并校验 TUN 流量和回收; + Controller 仅作管理入口。纯内存、离线和编译检查不要求 TUN。 互通、压力和设备验收分别执行,范围见 [验收边界](acceptance.md)。具体对端与用例参数 -由 benchmark 维护,本仓库不复制运行记录。 +与实测结论统一由 benchmark 工程维护。 diff --git a/docs/tls-client-fingerprint.md b/docs/tls-client-fingerprint.md index 43e117d..0dc8be4 100644 --- a/docs/tls-client-fingerprint.md +++ b/docs/tls-client-fingerprint.md @@ -5,26 +5,34 @@ | 字段 | 含义 | 接受值 | | --- | --- | --- | | `fingerprint` | 服务端证书 DER 的 SHA-256 pin | 64 个十六进制字符,可带冒号 | -| `client-fingerprint` | 客户端 ClientHello 的命名模板 | 下列七值;空串也关闭 | +| `client-fingerprint` | 客户端 ClientHello 的命名模板 | 下列十值;空串也关闭 | | 公开值 | 实际模板 | | --- | --- | | `none` | 关闭命名指纹,不关闭 TLS 或 REALITY 认证 | | `chrome` | Chrome133 | -| `chrome120` | Chrome120(固定) | -| `firefox` / `firefox120` | Firefox120 | -| `safari` / `safari16` | Safari16.0 | +| `firefox` | Firefox120 | +| `safari` | Safari16.0 | +| `ios` | iOS14 | +| `android` | Android11 OkHttp,TLS1.2 | +| `edge` | Edge85 | +| `360` | 360 Browser7.5 的已实现能力子集,TLS1.2;YAML 中写为字符串 | +| `qq` | QQ11.1 | +| `random` | 进程首次按 Chrome133/Safari16/iOS14/Firefox120 = 6:3:2:1 选中一个模板 | 节点省略时关闭,显式启用推荐 `chrome`;这是节点级选项,无全局默认。 -大小写敏感,未知名称、`null`、错型全部拒绝;不接受 `chrome133`、`safari16.0`、 -移动端、随机或 PSK/PQ 专用名称。短名只随显式依赖升级和验收变更,不随 OS 自动选择。 +大小写敏感,未知名称、`null`、错型全部拒绝;不接受带版本号的名称、 +其它未列出名称、废弃的 `randomized` 或 PSK/PQ 专用名称。短名只随显式依赖升级和验收变更,不随 OS 自动选择。 +`random` 仅共享不可变模板选择,后续连接、配置替换及 Stop/Start 不重抽,不共享 TLS 会话。 +兼容约束按实际选中模板检查,不能通过 random 绕过 TLS 版本或 H3 限制。 ## 适用范围 -AnyTLS、Trojan、VMess + TLS、VLESS + TLS/REALITY/JLS 使用同一共享连接器。 +AnyTLS、Trojan、VMess、VLESS 的普通 TLS 和已支持的 REALITY/JLS/ShadowTLS 身份复用共享连接器; +TrustTunnel H2 保持标准 TLS。各协议可用身份与传输范围见 [出站](outbounds.md#流式安全身份)。 TCP、WS、gRPC、HTTP 首包伪装、legacy H2、Vision、XHTTP H1/H2 仍遵守各自的协议约束。 -关闭 TLS 时不能携带该字段。SS2022 + ShadowTLS v3 复用上述模板,作用于 cover -握手并强制 TLS1.3;其未命名握手也由 boring 执行。SOCKS5 和裸 SS 不接入 TLS 指纹。 +关闭 TLS 时不能携带该字段。SS2022 + ShadowTLS v3/JLS 复用上述模板,作用于插件握手;两种身份均强制 TLS1.3, +未命名握手也由 boring 执行。SOCKS5 和裸 SS 不接入 TLS 指纹。 XHTTP `download-settings.client-fingerprint` 缺省继承主腿,显式名称覆盖,`none` / 空串清除。 下载腿即使与主腿使用同一服务器,也拥有独立的 TLS 策略、身份和缓存。 @@ -42,64 +50,120 @@ client-fingerprint: chrome - 默认验证公开信任链、名称及有效期。叶证书 pin 匹配时以叶证书为显式信任依据, 不再要求公共根、名称或有效期;匹配非叶证书时将其作为信任锚,仍验证叶链、名称和有效期。 skip 只跳过常规验证,不能覆盖不匹配的 pin;所有策略仍验证握手签名。 -- 未启用指纹的标准 TLS 和 QUIC 使用 crates.io 官方 rustls + ring,不依赖 rustls fork。 +- 未启用指纹的流 TLS,以及 HY2/TUIC/DoQ/XHTTP H3/DoH H3 的 Quinn QUIC,使用官方 rustls + ring。 + TrustTunnel/MASQUE 的 H3 使用官方 quiche 与同一 boring;H3 不应用命名指纹。 - 命名指纹的标准 TLS,以及所有 REALITY/JLS,使用自有 boring fork;开发及 PR/发布的来源规则见 [TLS 依赖](tls-dependencies.md)。 -- 普通 TLS 两条路径共用同一个 WebPKI 证书验证器和发布信任根;叶 pin、非叶信任锚、 +- 普通 TLS、Quinn 与采用标准 TLS 认证的 quiche H3 共用同一个 WebPKI 证书验证器和会话信任根;叶 pin、非叶信任锚、 独立验证名、skip 优先级不变。TLS 握手签名仍由各自后端强制验证。 -- VLESS mTLS 继续使用配对的内联 PEM,密钥匹配在使用 IO 前检查;profile 不修改身份。 +- HTTP、SOCKS5、AnyTLS、Trojan、TrustTunnel、VMess、VLESS、Hysteria2、TUIC v5 与 SS2022 ShadowTLS cover 的 mTLS 使用配对内联 PEM 或文件路径, + 配置校验与资源生成时读取文件并检查密钥匹配;客户端证书不增加服务端信任根,profile 不修改身份。 +- quiche H3 的 ECH 仅支持 X25519/HKDF-SHA256,AEAD 为 AES128/256-GCM 或 ChaCha20-Poly1305。 + 静态和 DNS 来源按该后端选择;不兼容或未实际接受时失败,不用 GREASE 替代,不降级为明文 ClientHello。 - REALITY 仅允许自身临时证书认证,不能混用 pin/skip/mTLS 或降级到普通站点证书。 - [JLS](vless.md#jls) 使用共享凭据身份和完整原生 TLS1.3,不启用恢复或 PKI 回退;profile 不改变身份。 -- 连接器只包装 Dialer/上游交付的 IO,不解析 DNS、不创建 socket、不绕过平台 protect。 - -## 四模板边界 +- 连接器只包装 Dialer/上游交付的 IO;动态 ECH 通过既有解析上下文请求原始 HTTPS 记录, + DNS 角色与物理 IO 仍由运行时和共享 Dialer 拥有,不绕过平台 protect。 + +证书与密钥的相对路径从初始化的 `dataDir` 解析;CLI 校验模式使用 `-d` 的目录而不初始化它。 +未初始化的普通 `validateConfig` 和直接 Rust `Config::parse_yaml` 使用当前工作目录,绝对路径原样使用。 +每个资源生成过程读取的身份文件合计最多 8 MiB,只接受普通 UTF-8 PEM 文件;错误不回显路径或内容。 +内联身份固定;文件身份由运行时既有资源工作器每秒检查元数据,稳定读取并校验整对后发布, +失败保留旧身份。TLS 握手不读取文件。配置对象仍保留校验时的内联快照,文件更新不重建 +代理图、不重置规则、DNS 或健康状态,也不更新业务 HTTP/MITM CA。 +活跃的不同文件对按其当前已发布 PEM 输入大小合计最多 8 MiB;被旧 Provider 节点持有的 +来源也计入,同一路径对共享材料。新来源或更新超出预算时保留可用身份/资源;文件读取候选 +同样有界。已建立的连接只持有其实际 TLS 所需的旧代材料,不能阻塞同一文件对继续轮换。 + +## 客户端信任根 + +`tls.custom-certifactes` 使用 Mihomo 的真实字段拼写,接受证书内容字符串数组, +不读取路径,也不增加 `custom-certificates` 别名。内容在配置校验时解码,非法证书 +使 Vole 校验失败;与 Mihomo 对非法条目发警告后继续的行为不同。未知 TLS 字段 +继续拒绝。同一个顶层 `tls` 对象也接受 HTTPS Controller 的服务端证书与客户端认证字段, +由独立的服务端加载器消费,不加入出站信任根;契约见 [Controller](controller-api.md)。 +独立测量配置仅接受客户端 `custom-certifactes` 子集。 + +启动时读取可用系统根,合并内置 WebPKI 和自定义根,构造一份会话共享的不可变根库。 +普通/指纹 TLS、QUIC、DNS 各角色、WireGuard 私有 DNS、Provider/GeoData 下载及 +健康探测都使用该根库。资源更新复用它,不重读系统根;新启动才能取得新的系统信任。 +独立 `measureDelay` 配置也接受该客户端对象,节点与最终 HTTPS 探测共享其工作器根库。 +纯校验只解析显式证书内容,不读取系统证书存储。客户端身份不增加服务端根。 + +系统读取使用官方 rustls-native-certs:macOS 读取可导出的信任设置,Windows 读取 +Current User ROOT,Linux 发现 CA 文件/目录。SSL_CERT_FILE/SSL_CERT_DIR 可按该库 +规则覆盖来源,读取失败保留基础根和有效自定义根,不关闭验证或转为明文。 +导出根后的证书验证仍为 WebPKI,不承诺 native verifier 的全部信任、撤销与策略行为。 +Android、iOS 和 tvOS 在此库的 Unix 发现分支中,不能完整读取平台系统/用户信任; +这些平台保留内置与自定义根,实际可读文件仅补充来源,不宣称完整系统信任等价。 +Windows UWP/AppContainer 的真实根读取仍需独立设备验证;交叉编译不构成签收。 + +## 命名模板边界 模板由 fork 固定密码套件、组/share、签名算法、扩展排列及受限扩展编码。 | 模板 | 主要差异 | | --- | --- | -| Chrome120 | 经典 X25519 share,GREASE/乱序、ECH GREASE、Brotli、条件 padding、旧 ALPS 17513 | -| Chrome133 | 普通 TLS 与显式混合 REALITY 使用原生 X25519MLKEM768 和 X25519 双 share;新 ALPS 17613,无 padding | +| Chrome133 | 普通 TLS 与显式混合 REALITY 使用原生 X25519MLKEM768 和 X25519 双 share,无 padding | | Firefox120 | 自有 cipher 顺序、固定扩展顺序、X25519/P-256 双 share,无 GREASE/ALPS/证书压缩 | | Safari16.0 | 固定扩展和签名顺序、GREASE、条件 padding、真实 Zlib 解压,模板本身不加 ECH/ALPS | +| iOS14 | 独立套件与扩展顺序、GREASE、条件 padding,无证书压缩;真实 c023/c027/003c CBC-SHA256 协商 | +| Android11 OkHttp | 独立套件与扩展顺序,无 GREASE、证书压缩或 TLS1.3 声明 | +| Edge85 | 固定扩展顺序、GREASE、X25519 share、Brotli 证书解压 | +| 360 Browser7.5 | TLS1.2、P-256/P-384/P-521;不启用假 DHE/DSA、RC4 或禁用套件 | +| QQ11.1 | 独立命名模板,固定扩展顺序、GREASE、X25519 share、Brotli 证书解压 | TLS 版本范围、SNI、ALPN、证书策略、身份和恢复预算仍由节点及传输决定。 +Android/360 的参考模板只有 TLS1.2 能力;要求 TLS1.3 的身份与传输拒绝此组合,不伪造 share 或改名。 因此名称表示版本固定的 TLS 模板,不承诺完整浏览器行为或所有上下文的逐字节相同。 -最低 TLS1.2;Safari 的 TLS1.0/1.1 声明裁剪,Vision/XHTTP/REALITY 继续强制 TLS1.3。 +最低 TLS1.2;Safari/iOS 的 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。 +Core 使用标准 H2 SETTINGS,不声明 TLS ALPS,也不复制 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。 +`reality-opts.support-x25519mlkem768`:默认 false,true 允许在同次握手中协商 +真实 X25519MLKEM768(4588)或经典组。Chrome133 与未命名 profile 提供混合/X25519 +双 share,其余 TLS1.3 模板保留各自真实经典 share,不伪造混合能力或自动改成 Chrome。 +Android/360 的模板只有 TLS1.2,不能用于 REALITY;这项限制来自实际模板版本, +并非新增协议限制。下载腿继承/整体替换 REALITY 后独立验证最终 profile。 +服务端选择允许的经典组不触发重试;HRR、认证失败仍终止,不降级为普通 WebPKI。 ML-KEM 增强 TLS 密钥交换,REALITY 身份认证仍基于 X25519,不宣称后量子身份认证。 -Firefox 的 FFDHE、delegated credentials、record size limit 及 Safari 的模板专用 +Firefox 的 FFDHE、delegated credentials、record size limit 及 Safari/iOS 的模板专用 cipher 声明不开放新的配置能力;不能真实完成的对端选择明确失败,不静默换模板。 +iOS 的 c024/c028/003d 与 Safari/iOS 的 c008 仅声明,对端选中会失败;003c 使用 +已有原生 RSA/AES-CBC/SHA256 实际协商,并保留 deprecated 标记,默认套件不启用。 模板不模拟浏览器 HTTP/2 SETTINGS、QUIC 参数,也不会自动启用实际 ECH。 -VLESS 可另行显式配置[静态 ECH](vless.md#静态-ech):四模板均可使用,届时强制 TLS1.3、 +VLESS/VMess/Trojan/AnyTLS/Hysteria2 可另行显式配置 [ECH](vless.md#ech):具备 TLS1.3 能力的六种模板可使用,届时强制 TLS1.3、 加入真实 ECH 并关闭恢复;不再声称与未启用 ECH 的历史浏览器模板逐字节相同。 -当前 HTTP 驱动不导入 TLS ALPS 中的应用设置,因此**非空 ALPS 响应明确失败**; -未协商或协商空设置可用。Brotli/Zlib 解压输出和原生证书消息上限均为 128 KiB; +当前 HTTP 驱动不导入 TLS ALPS 中的应用设置,握手时不声明该可选扩展, +由标准 SETTINGS/ACK 初始化。意外协商的非空 ALPS 仍失败,不能静默忽略设置。Brotli/Zlib 解压输出和原生证书消息上限均为 128 KiB; 超限、截断、错误算法和损坏压缩流失败,不能绕过证书认证。 ## 所有权与关闭 -节点的 SNI、ALPN、验证策略、mTLS 身份、版本范围、profile 和 SSL 上下文不可变。 +节点的 SNI、ALPN、验证策略、版本范围和 profile 不随文件更新改变。每条新物理 TLS/QUIC +连接使用一次已发布的 mTLS 身份快照;既有 H2/gRPC/QUIC 物理连接可继续使用原身份。 +rustls 按身份代缓存不可变 resolver/config,Boring 在独立 SSL 对象中安装该次身份, +quiche 在该条物理连接的上下文中安装身份,均不修改进行中的握手。 +原生证书表示只由实际使用它的 TLS 客户端持有,材料登记只保留弱缓存;失败候选释放其原生需求和缓存。 每次独立构造建立独立缓存,clone 只共享同一节点。标准 TLS 的运行时票据总预算为 4; +文件身份换代后,旧代票据不能恢复新连接,旧连接晚到的票据也不进入新代缓存; +各代共享原节点的固定容量,不随轮换次数增加缓存。身份检查工作器随资源工作器同步停止。 TLS 1.3 票据只消费一次,TLS 1.2 会话也计入预算,过期票据清除,零预算关闭恢复。 非零 TLS1.2 ticket lifetime hint 与原生 session timeout 取较短者;零提示不扩大原生期限。 -Safari16 不声明 TLS1.2 session-ticket 扩展,该版本的恢复使用会话 ID,不能把不存在的 +Safari16/iOS14 不声明 TLS1.2 session-ticket 扩展,该版本的恢复使用会话 ID,不能把不存在的 票据提示当作它的过期时间。TLS1.3 的票据期限已由原生握手收敛。 握手及应用层 ALPN 策略通过前,暂存票据不会进入节点缓存。REALITY 始终不恢复。 -别名只复用模板,不扩大节点/下载腿缓存归属。冷连接和恢复连接分开验收;Vole 的 +同一模板的使用不扩大节点/下载腿缓存归属。冷连接和恢复连接分开验收;Vole 的 票据策略不同于 Mihomo,恢复 hello 不宣称逐字段相同,也不伪造 PSK/binder。 +quiche H3 为每条物理连接构造独立原生 TLS 上下文,禁用恢复与 0-RTT; +节点内已认证的 H3 连接仍可复用,关闭和取消由其驱动所有者同步回收。 +XHTTP H3 与 DoQ/DoH H3 同样禁用恢复与 0-RTT;ECH 从完整来源按 rustls 后端选择,未接受时握手失败。 CloseWrite 刷新 close_notify 最多 5 秒,保留底层连接和读方向;重复关闭幂等, 关闭后拒绝写入。Vision direct 切换保留同一 TLS 记录边界,先排空明文/刷新密文, diff --git a/docs/tls-dependencies.md b/docs/tls-dependencies.md index 15540f9..3746f3b 100644 --- a/docs/tls-dependencies.md +++ b/docs/tls-dependencies.md @@ -6,29 +6,56 @@ | 用途 | 来源与约束 | | --- | --- | -| 普通 TLS、QUIC、WebPKI | 官方 rustls/tokio-rustls,ring provider | -| 命名 ClientHello、REALITY、JLS、ShadowTLS v3 | YuanDevTeam/boring,release 分支及锁定 revision | +| 普通流 TLS、WebPKI | 官方 rustls/tokio-rustls,ring provider;rustls-native-certs 只读取可用平台根,不替换验证器 | +| Quinn QUIC | HY2、TUIC、DoQ、XHTTP H3 和 DoH/H3 使用官方 registry Quinn 0.11.12 / quinn-proto 0.11.19、rustls/ring;窗口与数据报预算通过公开 API 和 Core 包装处理;Linux DIRECT 的有限 GSO/ECN 使用同一受保护 fd 的 Linux ancillary API,不另建 socket | +| Quinn HTTP/3 | HY2 认证、XHTTP 和 DoH/H3 使用 registry h3 0.0.8 + h3-quinn 0.0.10,MIT;HY2 认证与业务共用同一 Quinn 连接,XHTTP/DNS 的取消和帧预算由 Core 公开 trait 包装处理,不修改上游源码 | +| quiche HTTP/3 | TrustTunnel 和 MASQUE 使用 registry quiche 0.30.0,BSD-2-Clause;自有低层驱动使用公开 API,共用同一 boring/boring-sys,不启用 quiche FFI、qlog 或另一套 TLS 来源 | +| QUIC 拥塞控制 | HY2/TUIC 保留其 Quinn 控制器;XHTTP/DoH 使用 Quinn 原生默认控制器,TrustTunnel/MASQUE 使用 quiche 原生控制器,配置映射见 [出站](outbounds.md) | +| MASQUE 密钥 TLS | H2 使用 rustls/ring,H3 使用 quiche + 同一 boring;ECDSA 身份、逐证书 SPKI pin 与握手签名认证保持独立于 WebPKI | +| Controller 随机服务端身份 | 官方 rsa 仅生成 RSA 2048 密钥;rcgen + ring 签发自签证书,实际 TLS 和握手签名仍由 rustls + ring 处理;随 Controller 入站 feature 启用 | +| 业务 HTTPS 处理 | rcgen + ring 使用宿主提供的 CA 签发短期叶证书;官方 x509-parser 校验 CA、密钥匹配与有效期,不生成根 CA 或安装信任,见 [HTTP 处理](http-processing.md) | +| 命名 ClientHello、REALITY、JLS、ShadowTLS v3 | YuanDevTeam/boring;开发使用同一 fork 的本地 path,PR 和发布使用固定 commit | | SS 2022 | 官方 shadowsocks,仅 `aead-cipher-2022` | +| Mieru 分段认证 | UDP 低熵编码及 TCP 保留官方 RustCrypto chacha20poly1305 0.11.0;其余 UDP 复用同一 boring/boring-sys 的安全可变 XChaCha20-Poly1305 原位 AEAD,均用 ring PBKDF2;Mieru 不使用 TLS,不引入另一套 TLS provider | | Encryption | 同一 boring 的 X25519、ML-KEM、AEAD、AES-CTR;官方 blake3 | -| 静态 ECH | 官方 hpke + rustls HPKE trait,命名模板使用 boring ECH 接口 | +| 静态/动态 ECH | rustls 使用官方 hpke + 公开 HPKE trait;命名模板及 quiche H3 使用同一 boring ECH selector | +| Provider AGE | 官方 age/age-core 的 armor、MAC 和 STREAM;Hybrid 身份用官方 hpke XWing,关闭插件与 CLI | + +Quinn 与 quinn-proto 均来自官方 registry,使用其原生拥塞实现。 +CI 核对锁定版本、registry 来源、wrapper 依赖边及 rustls/ring provider;不维护 protocol fork。 AWS-LC 只用于 `shadowsocks → shadowsocks-crypto → aws-lc-rs → aws-lc-sys`, 不用于 TLS/REALITY。禁止 FIPS、2022-extra 和第二套 REALITY 后端。 BoringSSL 子模块保持原样,fork 补丁通过 feature 控制。 +AGE 使用获准的官方兼容稳定版本 `age 0.11.5` 与 `age-core 0.11.0`。 +其 0.12 系列强制依赖的旧 ML-KEM 与当前 `hpke 0.15.1` 的正式 KEM 依赖不兼容; +此版本例外仅限上述两项,Hybrid 保持最新稳定 HPKE/RustCrypto 路径。 + ## 开发与升级 -1. 本地开发可将 boring、tokio-boring、测试用 boring-sys 一起切为同一自有 fork 的 - 相对 path,保留版本与 feature 约束并更新 Cargo.lock。 -2. 发起或更新 PR 前,将 fork 改动发布至 YuanDevTeam/boring 的 `release`,三个 crate - 一起切回 Git release 来源;更新锁文件和 CI 批准 revision。 -3. 在不依赖本地 fork 的 checkout 执行 locked 构建,核对来源、provider/feature graph, +1. 本地开发将 boring、tokio-boring、boring-sys 的直接/开发依赖,以及 + `[patch.crates-io]` 中 quiche 使用的 boring/boring-sys,统一指向 + `../boring/{boring,tokio-boring,boring-sys}` 对应的相对 path;保留版本与 feature + 约束并更新 Cargo.lock。 +2. 发起或更新 Vole PR 前,检查 Boring checkout 的源码变更和拟引用提交的公开状态。 + 若所需自有修改尚未提交、推送或进入 Boring PR,先整理提交并推送,发起或更新 + 对应 Boring PR,确认所需变更已包含于可公开获取的 commit。 +3. 将三个 crate 的直接/开发依赖和两个 patch 一起切为相同 Git URL 与 `rev`, + 使用完整 40 位 commit SHA;更新 Cargo.lock,CI 以清单中的 `rev` 核对实际来源。 + 可以引用已推送的 Boring PR head commit,来源不绑定分支或 tag。 +4. 在不依赖本地 fork 的 checkout 执行 locked 构建,核对来源、provider/feature graph, 运行受影响的 TLS、协议和平台回归。回退同样通过新提交恢复已验证的锁文件。 +`stream-security` 由 VMess/Trojan/AnyTLS/VLESS 共同启用,不依赖另一个出站协议; +Vision 仍单独随 VLESS 编译。ShadowTLS 由 `shadow-tls-v3` 独立控制。 普通 TLS 与 REALITY 使用独立的不可变连接器。REALITY/JLS 握手、临时密钥和原生 验证边界见 [REALITY](reality-wire-protocol.md);模板与证书策略见 [TLS 指纹](tls-client-fingerprint.md)。 -Encryption 的二进制 context 使用 [薄 FFI](../crates/vole-blake3-raw/README.md) 中未修改的 -官方 BLAKE3 C 源码,保留其来源、许可证和符号隔离。 +Encryption 的二进制 context 使用 [薄 FFI](../crates/vole-blake3-raw/README.md) 调用 +官方 BLAKE3 C API;平台脚本通过官方 vcpkg 准备静态包,编译时隔离符号,不内置第三方源码。 + +本地 path 验证需要配套 fork checkout,不代表固定 Git commit 的独立构建通过。 +PR 和发布验收使用实际锁定 commit;直接依赖、quiche patch、锁文件与 CI 必须一致。 ## 原生构建 @@ -36,3 +63,12 @@ Encryption 的二进制 context 使用 [薄 FFI](../crates/vole-blake3-raw/READM 同 ABI、同 NDK 的 `libc++_shared.so`。 构建与发布入口见 [编译与发布](../scripts/README.md)。 + +ECH 接收算法按实际后端区分。rustls HPKE adapter 支持 X25519、P-256/P-384/P-521、 +ML-KEM-768/1024、X-Wing、ML-KEM-768+P-256 和 ML-KEM-1024+P-384; +KDF 为 HKDF-SHA256/384/512、SHAKE128/256,AEAD 为 AES128/256-GCM 或 ChaCha20-Poly1305。 +使用官方 hpke 的 `nistp/mlkem/shake` feature 和公开 HPKE trait,不增加 TLS fork。 +命名模板与 quiche H3 的原生 ECH selector 只接收 X25519/HKDF-SHA256 和上述三种 AEAD。 +静态和动态来源均按最终后端重新选择;没有兼容配置则明确失败,不关闭 ECH、不降级或重拨。 +quiche H3 在发送首包前通过公开 SSL 接口配置 ECH,握手后检查实际接受状态与 ALPN, +通过后才开放 HTTP 请求。不能以原生通用 HPKE API 的值域推断 ECH selector 支持。 diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index 7dbfad6..60c1f00 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -1,6 +1,6 @@ # TUN ICMP 与 DNS -Vole 在 TUN netstack 内回答受支持的 ICMP Echo Request。启用运行时 DNS 后,匹配 `tun.dns-hijack` 的 TUN TCP/UDP、选路惰性解析和内部查询共享同一套有界 DNS 实现。 +Vole 在公共 TUN 入口处理 ICMP Echo:Linux 可经受控物理接口实际发送,接口内地址、fake-IP 与禁用转发的请求保留本地合成回应;其他平台保持 netstack 本地回应。启用运行时 DNS 后,匹配 `tun.dns-hijack` 的 TUN TCP/UDP、选路惰性解析和内部查询共享同一套有界 DNS 实现。 顶层 `ipv6: false` 时,IPv6 原始包会在进入 netstack 前被丢弃,因此不会触发 ICMPv6 回应或 IPv6 上的 DNS/业务流量;运行时 DNS 的有效 IPv6 能力同时要求顶层 `ipv6` 和 `dns.ipv6` 为 `true`。 @@ -11,18 +11,47 @@ Vole 在 TUN netstack 内回答受支持的 ICMP Echo Request。启用运行时 | IPv4 | type 8, code 0 | type 0, code 0 | | IPv6 | type 128, code 0 | type 129, code 0 | -响应会交换源地址和目标地址,保留 identifier、sequence 和 payload,重建最小 IP 头,将 TTL/Hop Limit 设为 64,并重新计算所有校验和。该路径完全位于 netstack 内,不调用 DNS、规则、出站、protect 或物理接口。 - -输入要求: - -- 校验 IP 版本、头长度、声明长度、地址和校验和; -- IPv4 可以携带合法 options,但响应不复制 options; -- 不重组 IPv4 分片或 IPv6 Fragment Header; -- IPv6 base Next Header 必须直接是 ICMPv6; -- 非 Echo、非零 code、截断和非单播地址一律丢弃; -- 单包失败不停止 netstack,也不生成 ICMP error。 - -smoltcp 负责生成 Echo Reply,Vole 负责更严格的输入分类和运行时门禁。响应直接尝试进入现有原始包出站队列;队列满时只丢当前低优先级响应并更新统计,不创建等待任务或积压队列。 +Linux 的 `tun.disable-icmp-forwarding` 缺省 false。合法 Echo Request 按以下方式处理: + +- 目标属于启动时读取的真实 TUN 接口地址前缀,或属于启用的 fake-IP 地址池时,由 netstack 本地合成回应;借用 TUN 的前缀从其所属网络 namespace 读取,不以配置的设备名或任意物理 LAN 网段代替。 +- `disable-icmp-forwarding: true` 时,所有合法 Echo 保留本地回应,不打开物理 ping socket;合成耗时不代表目标的真实 RTT。 +- 其余请求经共享 Dialer 的 DIRECT 物理出口发送,不执行 DNS、普通 TCP/UDP 规则、代理节点或组,也不声称普通代理协议可以承载 ICMP。 + +`tun.icmp-timeout` 单位为整数秒,缺省或 0 使用 10 秒,控制请求准入至物理答复匹配的期限。 +负值、超出有符号 64 位的整数及本机单调时钟无法表示的期限均拒绝,不复制 Duration 溢出。 +这两个字段仅 Linux 接受;其他平台将其视为未知字段,保持原本地合成行为。 +运行中改变归一化后的值须显式重连,沿用现有配置应用门禁,不增加独立控制 API。 + +真实请求先经过 IP 长度、单播地址、IPv4 头及 ICMP 校验和验证;IPv6 校验包含实际地址的伪头。 +IPv4 可携带合法 options,但不重组分片;IPv6 base Next Header 必须直接为 ICMPv6,不处理扩展首部链。 +已识别的非法 Echo、非零 code、截断、分片或不支持的形状直接丢弃,不回退到自动合成。 +非 Echo 与既有可验证 PMTU 仍交给原 netstack 路径,不将物理错误解释为成功。 + +真实 TUN 前缀快照读取失败时启动失败;显式禁用转发时不读取该快照。 +Linux 使用 `SOCK_DGRAM` ping socket;安装器或宿主须预先在相应出口 network namespace 中, +通过 `net.ipv4.ping_group_range` 授权运行组。Core 不写 sysctl、不请求提权,也不回退 IP raw socket。 +创建、源地址、protect、物理接口与 mark 均复用共享 Dialer 的初始化和停止所有者。 +权限不足、出口错误、不可达、超时或容量不足只丢弃相应真实请求,不伪造本地成功。 + +每会话最多两个物理 ping socket(每地址族一个),由一个驱动收发;不按目标创建任务或长期 socket。 +真实请求总共最多持有 64 个许可,入站与响应队列各 8 项,单个完整 IP 包最多 `min(tun.mtu, 2048)` 字节。 +发送后 pending 只保留地址、原始与 wire identifier/sequence、长度、SHA-256 摘要、期限和许可,不保留 payload。 +物理答复须携带有效接收接口与本地地址元数据,并匹配实际对端、校验和、wire 标识、长度、摘要及未过期记录;重复或不匹配答复丢弃。 +到期、发送失败、队列满或取消均释放对应所有权;不增加重传、无界目标表或待发送积压。 +这些是局部资源边界,不是进程 RSS 上限。 + +真实答复和本地合成答复均恢复原 TUN 请求的 identifier、sequence、地址方向与 payload, +重建最小 IP 头、TTL/Hop Limit 64 及校验和。本地合成继续使用 netstack 的原有 MTU 和有界 raw 出站队列, +不受真实转发的 2048 字节上限限制。真实答复进入独立有界通道,与 TCP/raw、UDP、DNS 共用唯一 TUN writer; +许可保留到写入完成、最终丢弃或取消。Stop 取消并等待驱动、reader 与 writer,释放 pending、队列和物理 socket; +返回后不再向 TUN 回包。上述物理路径仅限 Linux,不扩展其他平台的权限或网络设置职责。 + +TUN 的被动 TCP 与 MASQUE 的内部主动客户端均支持 ICMP PMTU 反馈:验证外层校验和、本地地址、 +被引用的活动 TCP tuple 及实际发送且尚未确认的序号,只降低对应连接的 MTU并重新分段。 +IPv6 宣告 MTU 小于 1280 时忽略,IPv4 小于 68 时取 68。 +分段使用官方 smoltcp 的包层适配,重传沿用其 TCP 定时器;元信息保存已发序号, +不保留报文正文,容量独立有界,关闭连接时清除。其他 ICMP error 不转发或创建新流, +不改变 TUN 的 MTU 门禁或 Echo 配置。 ## TUN DNS 入口 @@ -50,7 +79,51 @@ UDP 读取器只做有界 wire 分类和任务提交,不同步等待上游。 SVCB、HTTPS、TXT、PTR、MX、SRV、NS、SOA、DNSSEC 和未知 16 位 qtype 都可按原始查询转发。A/AAAA 响应中的可达 CNAME 链继续执行严格地址和 TTL 校验。 -TUN 域名提示存储只在启用 TUN 且业务规则包含域名类规则时创建。嗅探域名优先于 DNS 提示,两者都不改写实际目标。 +redir-host 域名提示在 DNS 开启且规则需要域名或已启用嗅探时创建,供业务入口共用。DNS 提示本身不改写目标;嗅探是否覆盖目标由 override-destination 控制。 + +## 嗅探 + +HTTP/TLS 用于 TUN 与 mixed TCP;QUIC v1/v2 多包识别由 TUN 和已授权的 SOCKS5 UDP 共用。 +`parse-pure-ip` 控制无域名目标,`force-dns-mapping` 控制 redir-host 提示,均默认 true。 +fake-IP 恢复后的域名与显式域名只在 `force-domain` 匹配时强制嗅探。 +`skip-src-address` / `skip-dst-address` 在读取前排除地址;`skip-domain` 排除识别结果。 +域名项支持通配、GeoSite 和 domain/classical 规则集合;地址项支持 CIDR、GeoIP 与 +ipcidr/classical 集合。classical 对完整 AND/OR/NOT 求值;只提供对应域名或目标 IP, +其余元数据沿参考零值 TCP/HTTP、端口0、空入口名和未知身份,不发起地址解析或进程扫描。 + +全局 `override-destination` 默认 true,协议级值优先。false 只将识别域名提供给规则, +保留实际目标;true 替换为识别域名,DIRECT 或必须 IP 的传输再解析。 +UDP 覆盖保留原始目标供回包使用,活动流不随后续查询变更。 + +HTTP/TLS 端口可重叠。每连接共享最多 64 KiB 预读,首字节最多一秒,随后解析最多一秒; +优先识别 TLS,再识别 HTTP。失败、超时或跳过均保留已读数据供业务转发。 +HTTP 识别 HTTP/1.x Host、absolute-form 与 CONNECT 目标,以及 HTTP/2 首个请求的 authority/Host。 +HTTP/2 接收完整 HEADERS/CONTINUATION 后解码 HPACK,动态表上限 4 KiB,最多 128 帧/字段, +编码与解码字节受相同 64 KiB 预算限制;歧义字段或非法帧不产生域名提示。 +TLS/QUIC 识别 ClientHello,检测到 ECH 时不使用外层 SNI。 +不嗅探劫持的 DNS。没有列出任何协议时不读取业务数据。 + +## Fake-IP + +`enhanced-mode: fake-ip` 在 TUN DNS 回答 A/AAAA 时分配合成地址;hosts 优先。 +TCP 和 UDP 的 TUN / mixed 入口恢复域名后选路,节点、DIRECT 与必须 IP 的出站通过内部解析器取得真实地址。 +SVCB/HTTPS 在选择 fake-IP 时返回空回答;过滤到真实地址的查询仍转发。 + +IPv4 池默认 `198.18.0.1/16`,IPv6 池默认关闭。每个池从网络地址 +4 开始分配, +排除广播地址并循环复用;没有配置相应地址池时返回 NODATA。 +`fake-ip-ttl` 最小为一秒,只控制 DNS 回答 TTL。内存模式每个地址族最多保留 1,000 项, +按使用情况淘汰。未知或已淘汰的合成地址不能作为真实目标拨号。 + +`fake-ip-filter-mode` 支持 blacklist、whitelist 和 rule。前两种使用域名通配、GeoSite 或 +域名规则集合;rule 按声明顺序匹配域名规则并使用 `real-ip` / `fake-ip` 动作,未匹配时分配。 +默认 blacklist 只包含三个 Microsoft 连通性探测域名,详见配置示例。 + +`profile.store-fake-ip: true` 将双向映射和分配位置写入 `dataDir/fake-ip.redb`, +写入完成后才返回地址。磁盘存储的缓存有界,映射不受内存模式的 1,000 项限制。 +相同地址池在重启后保留系统 DNS 缓存中的旧地址;修改池范围时清除对应地址族映射。 +资源更新共享当前地址池,过滤器可独立更新。关闭持久化后重启不保证旧映射有效。 +活动 UDP 流保留首包选择和真实目标,并使用独立传输区分解析到相同真实端点的不同名称; +回复恢复原合成地址,空闲和停止时释放。 ## Nameserver 与出口 @@ -61,45 +134,151 @@ dns: nameserver: - "tcp://1.1.1.1:53#main-select" nameserver-policy: + "+.example.com": "223.5.5.5" "geosite:private,cn": - "tcp://223.5.5.5:53#DIRECT" ``` ### Endpoint -- 主 nameserver 必须有 1–4 项。 -- 只接受裸 IPv4/IPv6、`udp://IP[:port]` 和 `tcp://IP[:port]`。 +- `dns.enable`、`dns.ipv6` 默认 false。主 nameserver 默认使用 DoH/DoT,可显式替换为非空列表。 +- 接受裸 IPv4/IPv6、`udp://`、`tcp://`、`tls://`、`https://`、`http://` 和 `quic://`。加密连接复用会话的内置、可读系统与 `tls.custom-certifactes` 信任根;各解析角色及 WireGuard/MASQUE 私有 DNS 同样适用,平台边界见 [TLS 契约](tls-client-fingerprint.md)。HTTPS 根据实际 ALPN 使用 HTTP/2 或 HTTP/1.1;没有 ALPN 时使用 HTTP/1.1。明文 HTTP 仅支持 HTTP/1.1,不启用 h2c。 +- `rcode://success`、`rcode://format_error`、`rcode://server_failure`、`rcode://name_error`、`rcode://not_implemented`、`rcode://refused` 分别返回本地 RCODE 0–5;可用于 nameserver、policy、fallback、节点和直连 resolver,不能用于 default-nameserver。 +- 同一个上游列表中存在本地 RCODE 时使用第一项本地响应,不建立网络连接,也不把 SERVFAIL/REFUSED 当作上游故障重试。响应保留原查询的 flags 和 additional,仅设置 QR 与 RCODE;不额外开启 RA,不写入结果缓存。 - `dns.ipv6: false` 不限制 IPv6 nameserver;顶层 `ipv6: false` 会阻止通过 DIRECT(含 `RULES` 选中 DIRECT)的 IPv6 nameserver 在本机物理建链,并继续当前组的故障转移。 -- Endpoint 必须是 IP 字面量,不支持 hostname、system 或 DHCP resolver。 -- 无 fragment 时固定使用 DIRECT。 -- Fragment 只接受 `DIRECT`、`RULES`、实际代理节点名或静态 `select` 组名。 +- 上游域名由 `default-nameserver` 引导解析,后者允许 IP 上游和显式 system。未启用 DNS 时,内部解析使用系统 resolver;启用后不因查询失败回退系统解析。 +- `system`、`system://`、`dhcp://system` 使用物理系统 DNS;`dhcp://<精确接口名>` 仅使用该接口的 DHCP DNS,不匹配其他接口。各 nameserver 角色、policy 和 fallback 均支持;DHCP 接口不能用于 default-nameserver。来源始终直连物理接口,fragment 与 respect-rules 不改其出口。 +- 无 fragment 时默认 DIRECT;`respect-rules: true` 改为 RULES,要求非空 `proxy-server-nameserver`。 +- Fragment 使用 `#出口或接口&参数=值`;出口接受 `DIRECT`、`COMPATIBLE`、`REJECT`、`REJECT-DROP`、`RULES`、实际代理节点名或支持的代理组名。 + 仅提供参数时沿用默认出口;整个 fragment 先百分号解码,再按 `&` 和首个 `=` 拆分, + 同名参数和出口 token 以后项为准。节点/组名称优先,没有同名目标时作为物理接口名。 + 指定接口直接连接该 DNS 上游,覆盖全局接口、沿用全局 mark;接口失效时失败,不回退。 + DoT/DoH/DoQ 的连接池身份包含接口,不能跨接口复用。未知键值参数拒绝。 + system/DHCP 来源始终使用物理出口,忽略出口 token;节点私有 DNS 也忽略出口 token。 - `#RULES` 只按 nameserver endpoint 和传输执行业务规则,不把 DNS question 当作选路域名。 -- 规则结果为 `REJECT` 时,当前尝试失败并继续当前组下一项。 - -代理组成员可以是具体节点、嵌套组、`DIRECT` 或 `REJECT`。DNS exchange 需要新建物理 transport 时解析当时的当前叶节点;选中成员失败不会使代理组自动改选,但 nameserver 列表仍按本节已有故障转移语义继续下一项。 +- 规则模式下,UDP 跳过不支持 UDP 的节点或组,继续匹配后续规则;显式出口和 global 模式不做此跳过。 +- `RULES` 与业务 TCP/UDP 使用同一子规则求值器:`SUB-RULE`、`PASS`、`PASS-RULE` 的作用域一致,`rematch` 与 `REMATCH-NAME` 保留同一决策上下文;规则控制和组控制先于 UDP 能力检查。 +- `COMPATIBLE` 使用直连语义;`REJECT` 结束当前尝试,`REJECT-DROP` 的 TCP 等待仍受当前 DNS 查询期限和 Stop 限制,UDP 立即结束。失败后继续当前上游组下一项。 + +代理组成员可以是具体节点、嵌套组、`DIRECT`、`COMPATIBLE`、`REJECT`、`REJECT-DROP`、`PASS` 或 `PASS-RULE`。PASS、PASS-RULE 和具名 rematch 仅在 rule 模式的 `RULES` 中参与规则流程;global 模式、显式命名组/节点或 dialer-proxy 选中控制目标时拒绝连接。DNS exchange 需要新建物理 transport 时解析当时的当前叶节点;选中成员失败不会使代理组自动改选,但 nameserver 列表仍按本节已有故障转移语义继续下一项。 + +### 上游参数 + +UDP/TCP/DoT/DoQ URL 只消费主机与端口;userinfo、路径和 query 与 Mihomo 一样忽略, +不形成认证或请求路径。fragment 的上游参数仍按下述规则消费。 + +`ecs` 接受 IPv4/IPv6 IP 或 CIDR,裸 IP 使用完整位数。已有 EDNS ECS 默认原样保留; +`ecs-override=true` 只替换首个 ECS,保留其 scope、OPT flags 和其他 option,编码时掩去前缀外的位。 +缺少 OPT 时添加 UDP size 为零的新 OPT。包装作用于所有解析角色和 system、DHCP、RCODE, +DHCP 接口名只取 URL endpoint,fragment 不参与接口查找。 + +`disable-ipv4=true`、`disable-ipv6=true` 和 `disable-qtype-<编号>=true` 抑制对应 question, +直接返回 NOERROR 空响应并设置 AA、RA,不读取来源或建链;编号限固定 Mihomo 参考已有 RR constructor。 +其他请求先验证上游响应,再过滤 Answer、Authority、Additional 中对应 RR。 +全报文通过官方 `domain` codec 重编码,保持所有存留名称引用有效;RFC 3597 未知 RDATA 保持原始字节。 +报文大小、记录数和名称压缩跳数仍受本节预算约束,不能通过过滤移除错误记录来绕过验证。 +类型参数和 `ecs-override` 仅在字面 `true` 时启用,与顶层及 DNS 的 IPv6 门禁独立。 + +DoT/DoH/DoQ 的 `skip-cert-verify=true` 和 `name-cert-verify` 接共享 TLS 安全层,显式名称验证 +先于跳过验证且不改变 SNI。`disable-reuse=true` 仅使指定 DoT 上游每次新建并关闭连接, +不改变 HTTPS 或其他上游的复用。不同参数的上游保持池身份隔离。 + +### DoH 请求 + +URL 只保留协议、带默认端口的主机、路径和 userinfo;丢弃原 query/fragment。 +路径按解码后的值重新编码,userinfo 百分号解码后生成 `Basic base64(用户名:密码)`, +省略密码视为空串。认证值不出现在 Host、请求路径或 Debug/日志中,也不会跨池身份复用。 +请求使用 GET、`Accept: application/dns-message`,没有 body 和默认 User-Agent; +`dns=` 为完整 DNS wire 的无 padding Base64URL。传输前 ID 改为 0,响应 ID 必须为 0, +之后恢复原 ID 并执行原有 question、完整报文及缓存校验。 + +H1/H2/H3 的请求及响应头各限 16 KiB,DNS body 仍限 4,096 字节。 +仅接受 HTTP 200 与 `application/dns-message`,响应长度声明、分帧及完整 DNS 校验必须通过。 + +`dns.prefer-h3` 默认 false。true 时 HTTPS 的冷初始化同时建立已认证的 TCP TLS 与实际 H3, +第一条成功连接用于查询;某条先失败时继续等待另一条。TCP 获胜时取消 QUIC 初始化并等待其 driver / adapter 收尾; +H3 获胜时丢弃本次 TCP checkout,底层已开始的初始化由 Dialer / connector 的 Stop barrier 回收。 +这不是 DNS 响应竞速;已选 H3 的查询失败不自动重放到 TCP。复用已选连接,H1/H2 的版本选择最多保留 4 项、空闲 30 秒,失败后重新选择;不为每次查询重新探测。 +`https://…#h3=true` 仅建立 H3,失败不能降级;明文 HTTP 的 h3/prefer-h3 不改变 H1 行为。 +bootstrap、主、节点和直连 DNS 角色均应用全局 prefer-h3;WireGuard/MASQUE 私有 DNS 与固定参考一致, +不继承全局偏好,但支持 URL `h3=true`。H3 共用 GET/Basic/ID 与包装,不启用 0-RTT 或恢复, +使用官方 Quinn、h3 与 h3-quinn,通过共享 TLS 1.3 / rustls 证书政策建立严格 `h3` ALPN;DoQ 同样使用 Quinn。 +普通 H3 请求 FIN 不等待逐请求 QUIC ACK。取消丢弃当前请求所有者,未完成发送 reset,接收半关闭使用原生 STOP(0), +不关闭仍可用的兄弟请求;响应错误仍使该连接不再接受新查询。GOAWAY 停止新请求准入,已存在的查询可完成。 + +### DoQ + +`quic://host[:port]` 默认 853,使用受控 UDP、TLS 1.3 与实际 `doq` ALPN。 +每条查询使用独立双向 stream:两字节网络序长度、完整 DNS wire,ID 设为 0,并结束写方向。 +响应必须是一份 12..=4,096 字节的完整 DNS message,ID 为 0,读至 FIN 后恢复原 ID 并校验; +空长度、超限、截断或额外消息均拒绝。并发查询共享连接,取消只停止当前 stream。 +DoQ/H3 都沿用原 DNS 路由、bootstrap、证书政策与查询包装,不新增 socket 工厂或系统解析回退。 +独立 `dns-quic` feature 提供两种能力,默认构建和 Invoke 生产入口启用;显式关闭该 feature 的 +精简 Core 构建拒绝 `quic://`、`prefer-h3: true` 和 HTTPS `h3=true`,不会接受后忽略。 ### Policy `nameserver-policy` 是有序映射: - 项数与 GeoSite 唯一引用数不设独立上限,仍受整个 YAML 输入大小约束; -- selector 只接受 `geosite:[,...]`,同一项内为 OR;每个 selector 可为 - `code`、`code@attr@attr` 或前置 `!` 的反选,属性为 key 存在性 AND; -- 归一后的相同 selector 不能跨项重复;相同基础 code 的不同属性交集或正反选可并存, - 与业务规则共享分类和记录;`@!cn` 是字面属性 key,不是属性排除; -- value 必须是 1–4 个 nameserver; -- 首项命中后只在该组内顺序故障转移,组内耗尽不查询主组; +- 接受普通域名和 Clash 通配域名,也可以用逗号连接多个模式;域名不区分大小写。 + `*.example.com` 匹配一层子域,`.example.com` 匹配所有子域,`+.example.com` 包含本域和所有子域; +- 连续域名项合为一个区块,按字面标签、`*`、前导 `.` 的优先级匹配;同一区块内重复的归一模式以后项为准; +- `geosite:[,...]` 同一项内为 OR,前缀不区分大小写;每个 selector 可为 + `code`、`code@attr@attr` 或前置 `!` 的反选,属性为 key 存在性 AND。与业务规则共享分类和记录, + `@!cn` 是字面属性 key,不是属性排除; +- GeoSite 项隔开域名区块,区块与 GeoSite 项按声明顺序匹配;重复 GeoSite selector 也按此顺序处理; +- value 接受单个 nameserver 字符串或非空数组;`rule-set:` 引用 domain/classical 集合。 + classical 以域名和其余零值元数据执行完整逻辑;不继承触发查询的业务端口、来源或入口身份; +- 命中后只在所选列表内并发查询,列表耗尽不查询其他 policy 或主组; - GeoSite 资产或分类不可用时该 policy 不命中,不能因反选变成全匹配; - 存在分类但属性筛选为空时仍可用,正选不命中、反选匹配合法非空名称。 +hosts 和 fake-IP 分配先于 policy;命中 fake-IP 过滤器、需要真实响应时才应用 policy 中的 RCODE。 +policy 的本地响应不进入 fallback;主 nameserver 的本地响应没有地址,已配置 fallback 时沿用 +本节的 fallback 选择规则。未配置 fallback 时直接返回本地响应。 + Policy 只决定当前 DNS exchange,不改写后续业务动作。分类、属性归一和语义边界见 [GeoData 规则](geodata.md#规则);所有平台均不按 GeoData 条数截断记录。 每次同步 policy 选择捕获一份不可变 GeoData 快照,所有 selector 与 policy 项共用, -不在一次求值中混用旧代、空快照和新代。捕获空快照时沿用资产缺失的主组回落语义。 +不在一次求值中混用旧代、空快照和新代。捕获空快照时跳过 GeoSite 项,继续其余 policy; +均未命中时使用主组。普通域名 policy 不依赖 GeoData。 快照在选择函数返回时释放,不复制规则,也不跨后续上游 I/O 的 `await` 持有。 Regex selector 记录与业务规则共用 `regex::bytes::Regex`;其搜索缓存由正则库管理, 可能分配 scratch 或发生缓存竞争,不承诺 DNS policy 正则搜索零分配。 +业务规则中的 DOMAIN-REGEX 与 classical rule-set 使用有界的独立复杂正则入口;GeoSite +保留大小写敏感的线性入口。DNS 请求的 fake-IP filter、nameserver-policy、fallback domain +及 respect-rules 出口共用执行预算,并跨 A/AAAA、别名、引导和并发上游候选保留。规则 +执行错误不选择默认上游、fallback、系统 DNS 或 synthetic IP,不写入缓存/域名提示; +wire 查询返回 SERVFAIL,内部解析返回脱敏规则错误。嗅探计划的地址/force/skip 选择同样 +共享预算,错误关闭当前会话/数据报,不忽略该失败继续连接。 + +### 显式系统与 DHCP 来源 + +App 通过运行时级 [`updateDnsNetwork`](invoke-api.md#updatednsnetwork) 提供完整网络快照,地址必须是字面 IP,接口必须精确。快照优先于 OS 发现,空数组明确撤销来源。替换时原子推进 DNS epoch 并清理全部角色的类型化/wire 缓存及 redir-host 提示;保留 fake-IP 映射和活跃流。旧查询可返回原调用者,但不能回填新网络缓存,新查询不加入旧 singleflight。DNS TCP 连接池按来源代次、接口和地址区分复用。 + +Linux CLI 无宿主快照时仅调用已有 `/usr/bin/busctl`,读取 systemd-resolved 的物理 link DNS 或 NetworkManager 已激活接口的 DHCP4 `domain_name_servers`;原生 DHCP 还需现有 resolved link 的加密策略,无法确认策略时不可用。不启动服务、不读 VPN stub、不发送 DHCP DISCOVER,也不读取其他接口的租约。发现仅在后续 DNS miss 按需刷新,元数据缓存 20 秒;到期本身不主动清结果缓存。重新发现的 endpoint、加密要求或绑定变化时,原子推进共享 epoch/source generation 并清 cache/hints,连接池不能复用旧来源 key。整体发现期限 3 秒,stdout/stderr 各限 64 KiB;取消会关闭并等待子进程。缺服务、无可靠租约或加密要求无法满足时来源不可用。被接管的 TUN 接口、回环/stub 地址和自身网关被排除。与 Mihomo 主动 DISCOVER/DHCPOFFER 的 DHCP 来源机制不同。 + +Apple 使用宿主快照和 `IP_BOUND_IF`/`IPV6_BOUND_IF`;App 负责取得真实物理 DNS 数据。Android 和 Wintun 当前没有可证明的精确物理接口绑定,显式 system/DHCP 来源不可用,不改变现有 protect 与宿主网络隔离合约。UWP 仅在实际 Session Host 的既有物理 GUID 绑定内发现系统 DNS;前台 Invoke registry 不是 IPC,不能通过它更新另一个进程的 VPN 会话。DHCP 租约 metadata 不可靠时保持不可用。来源要求加密但当前 UDP/TCP 路径无法满足时失败关闭,不降级为明文,也不补充公共 DNS。 + + +## Fallback + +`fallback` 只作用于主 resolver 的 A/AAAA。先应用 nameserver-policy;命中的 policy +独立查询自己的上游,不再进入 fallback。否则,`fallback-filter.domain` 的 Clash 通配 +命中时直接查询 fallback,不启动主组。GeoSite 分流使用 `nameserver-policy` 配置。 + +其余查询先取主组结果。主组失败、没有地址,或任一地址命中 `ipcidr` / GeoIP 条件时 +使用 fallback 的结果。`geoip` 默认 true、`geoip-code` 默认 CN:非本地且不属于该分类的 +地址触发 fallback;私有、回环、链路本地、多播和未指定地址不触发 GeoIP 条件, +但仍可被显式 `ipcidr` 匹配。GeoData 与业务规则共用快照;缺失分类不当作反选全匹配。 + +`fallback-lazy-query` 默认 false,两组并发查询,主组结果可接受时立即返回并取消剩余尝试; +true 只在确定需要时发起 fallback。fallback 返回后不再次套用过滤条件。 +选定的最终结果进入主 resolver 缓存,fake-IP 回答和 hosts 不触发上游查询。 +bootstrap、单独配置的节点/直连 resolver,以及非地址 qtype 不使用这组 fallback。 + ## Wire 校验 - DNS message 最大 4,096 字节。 @@ -114,48 +293,108 @@ Regex selector 记录与业务规则共用 `regex::bytes::Regex`;其搜索缓 ## 缓存与 singleflight -### 类型化缓存 +### 结果缓存 -A/AAAA 缓存最多 256 项,从空容量按需增长。TUN 域名提示存储启用时同样最多 256 项,并按 TTL 失效。 +`dns.cache-max-size` 控制每个 DNS 角色的结果总条目数,缺省或零值为 4,096。 +A/AAAA 的类型化结果和其他 qtype 的原始响应共享这份容量与替换策略。 +客户端携带 EDNS 或额外语义 flags 的 A/AAAA 使用同一有界原始响应缓存,以完整查询摘要区分 +ECS、DO、CD 等上下文并保留响应 additional/flags;普通 A/AAAA 仍使用小型地址缓存。 +`dns.cache-algorithm` 精确为 `arc` 时使用 ARC,缺省和其他字符串使用 LRU。 +LRU 命中和替换将条目移到最近使用端;满容量时淘汰最久未使用的结果,命中不延长 TTL。 +哈希索引和安全槽位链表按需分配,查询时只检查命中条目的寿命。运行时过期正结果按下节返回旧答复并刷新;其他过期结果重新查询。 +类型化结果仍最多保留 16 个地址,负缓存仍保留 30 秒。 +TUN 域名提示独立保留最多 256 项并按 TTL 失效;fake-IP 映射不计入这份结果缓存。 + +ARC 分别保留近期与常用结果,命中提升到常用组,使用两组淘汰历史自适应调整比例。 +历史项只保留 key 和过期时间,不持有地址或原始响应;有效结果最多为配置容量, +历史项最多为同样容量,所有索引和槽位最多为两倍容量。 +旧正结果仍占 resident 容量,旧答复只更新原列表内的最近使用顺序,不提升到常用组;超过旧答复保留期或历史项到期时直接删除,不新增淘汰历史。清空和解析器释放会回收结果、历史项及其存储。 ### 原始响应缓存 -- 最多 64 项,总保留内存 256 KiB,单响应最大 4,096 字节。 +- 在结果总容量内额外限制为最多 64 项、总保留内存 256 KiB,单响应最大 4,096 字节。 + 原始响应的 key、TTL 偏移及条目索引/链表元数据计入字节预算,ARC 的原始响应历史项也计入。 + 此预算满时先删除原始响应历史项,再淘汰最久未使用的原始响应,不改变 ARC 的自适应比例。 - Key 包含规范化 question、qclass、qtype 和移除 transaction ID 后的完整查询语义 SHA-256。 - 命中时恢复调用方 transaction ID 和 RD,并按剩余寿命更新所有可缓存 TTL。 - 正缓存寿命取可缓存记录的最小 TTL,并限制在 30–3,600 秒。 - NXDOMAIN 固定负缓存 30 秒;NOERROR 空 answer 只有在 authority 含 IN/SOA 时才缓存为 NODATA。 - Referral、TTL 0、无可缓存记录和扩展错误码不缓存。 -- 原始响应缓存不生成 TUN 域名提示。 +- 非地址 qtype 的原始响应不生成 TUN 域名提示;A/AAAA 仍按既有地址校验生成提示。 + +### 过期答复与刷新 + +运行时的完整正结果到期后,以 TTL=1 立即回答,并在后台刷新。旧结果最多保留到原到期时间后一小时,命中不延长寿命;它与新结果共用原有容量及原始响应字节预算,不另设旧记录池。原 TTL 为零、负答复、错误和不完整结果不延续为旧答复;hosts 与 fake-IP 仍在结果缓存之前处理。 + +原始响应带 DO、AD、CD 或 DNSSEC 记录时仅按原有效期缓存,不返回过期安全声明。普通类型化 A/AAAA 缓存只保存地址并重新合成无 AD/CD 的响应;非地址 qtype 不产生域名提示。过期 A/AAAA 沿当前 epoch 和资源退役门禁补充有界域名提示,提示仍使用原容量和 TTL 规则。 + +每个会话的 DNS 树及节点私有解析器共享最多四个后台刷新许可,无待执行队列;每个 key 复用原 singleflight,失败完成后五秒内不重试。单次刷新沿原五秒总查询期限。许可不足或已有刷新时仍立即返回旧答复。各资源树与私有解析器分别拥有取消和任务等待屏障,停止一个节点不停止主解析器;资源退役禁止新增刷新并取消旧刷新。Stop 等待全部刷新结束,不留下无所有者任务。没有运行时所有者的独立解析器保持原有过期 miss 行为。 ### Singleflight -未命中缓存时,以规范化 question 和 wire 语义摘要作为 key: +未命中缓存时,以规范化 question、wire 语义摘要和缓存 epoch 作为 key: -- Leader 在发起方任务内运行; +- 前台 miss 的 Leader 在发起方任务内运行;过期命中不等待,刷新 Leader 由上述运行时所有者持有; - Leader 被取消或释放时,RAII 通知 follower 重新选举; - 每个调用方恢复自己的 transaction ID 和 endpoint,并独立应用失败策略; - 不同 key 可以并行。 +### 清理 + +Controller `POST /cache/dns/flush` 清理主 resolver、bootstrap、节点和直连角色的类型化与 +原始响应缓存、ARC 历史项和 redir-host hints。所有角色共享一次 epoch 边界;清理完成后 +的新查询不能加入旧 singleflight,旧在途查询仍可向原调用方返回结果,但不能回填缓存或 hints。 +资源替换保留这份 epoch,旧资源 generation 也不能回填共享 hints。 +fake-IP 映射、活动业务会话、在途查询和上游连接池继续保留。 + +## Hosts 与解析职责 + +`hosts` 支持 IP、IP 数组和域名别名,匹配规则沿用 Clash 域名通配;默认包含 localhost,别名循环在配置阶段拒绝。`use-hosts`、`use-system-hosts` 独立控制配置与系统 hosts,默认均为 true;系统 hosts 在运行时创建时读取。TCP、UDP 节点的 TLS SNI 和逻辑目的名称不因解析为 IP 而改变。 + +值恰好为 `lan` 或 `[lan]` 时,启动准备捕获全部接口 IP,排除回环和 link-local, +包含可用的公网与 TUN 接口地址,不仅限私有网段。校验只保留内部标记,不读取网卡。 +无可用地址或枚举失败使启动失败;运行期间及 Provider 更新均保留启动快照。 + +`default-nameserver` 仅引导上游域名。`proxy-server-nameserver` 与其 policy 用于节点解析;`direct-nameserver` 用于直连,`direct-nameserver-follow-policy` 决定是否使用业务 policy。各角色拥有独立 cache/singleflight,未配置节点或直连 resolver 时复用主 resolver。有限并发、取消与连接池适用于各角色,依赖循环不会等待自身的 singleflight。 + +启用有效 IPv6 时,内部地址解析并发请求 A/AAAA,结果按 IPv4、IPv6 排列。 +`dns.ipv6-timeout` 单位为毫秒,缺省或 0 使用 100;从 A 完成后开始等待 AAAA, +不改变整个解析的五秒期限。任一地址族失败不丢弃另一族的有效地址;等待到期或调用方 +取消时释放未完成查询。IPv6 关闭时只查询 A。TUN 客户端直接 A/AAAA 查询仍保留原有 +wire/RCODE 语义,不使用这段地址合并等待。 + ## 查询、故障转移与连接复用 - 查询总期限 5 秒;单 nameserver 尝试最多 3 秒,并受剩余时间限制。 - UDP 尝试只打开一个传输;首次发送后 1 秒无合法响应时,在同一传输和 ID 上最多重发一次。 - 前两次 peer/ID/question 不匹配只丢当前响应,第三次使当前尝试失败。 -- 超时、I/O、格式错误、SERVFAIL、REFUSED、TC 或规则 REJECT 会继续当前组下一项。 -- NXDOMAIN 和 NODATA 是终态。 -- UDP TC 不自动切换 TCP;只有显式 `tcp://` endpoint 使用 TCP。 +- 同一上游策略最多同时发起 4 个实际 endpoint 尝试,字面上游和显式来源展开的 endpoint 共用许可;bootstrap/fallback 是独立子查询,不是全链或全局 socket 额度。优先返回有效响应并取消其余尝试。超时、I/O、格式错误、SERVFAIL、REFUSED 或 REJECT 不作为成功结果。 +- 未启用 fallback 或命中 nameserver-policy 时,NXDOMAIN 和 NODATA 是终态。 +- 验证来源、ID 和 question 后,UDP TC 在原期限内改用同一 endpoint 的 TCP。TCP TC 仍是失败响应。 - 全部上游失败时生成 SERVFAIL;非法 UDP 查询只丢当前数据报。 -显式 TCP nameserver 使用每运行时独立连接池: +TCP、DoT 和 DoH 使用解析器所属的连接池: -- Key 为 endpoint 和配置的 route target; -- 一条连接同时只处理一个查询,不做 pipeline; -- 活动连接不设固定业务数量上限;空闲连接总数最多 4、同 key 最多 2、超时 30 秒; -- 完成帧和响应校验后才能归还连接; -- 复用连接遇到 EOF、I/O、reset 或响应不匹配时,在原尝试期限内最多新建一次连接; +- Key 包含上游地址、TLS 验证策略、查询包装、传输、HTTP 路径与 Basic 身份、endpoint、最终 route target、 + 来源 generation 和物理接口绑定; +- TCP、DoT、HTTP/1.1 一条连接只处理一个查询,不做 pipeline;这部分空闲连接总数最多 4、同 key 最多 2、超时 30 秒; +- HTTP/2 同 key 首次建连串行发布,之后共享 sender;每连接最多 32 个并发 stream,超出时在查询原期限内等待。流接收窗口 4 KiB、连接接收窗口 128 KiB,禁用 server push;H2 空闲连接另限 4、超时 30 秒; +- DoQ/H3 同 key 串行初始化后复用,每连接最多 32 条查询;QUIC stream 固定接收窗口 4,098 字节、connection 接收窗口 128 KiB,发送额度 128 KiB,不接收服务器双向 stream 或 QUIC DATAGRAM。DoQ 不接收单向 stream;H3 最多接收 8 条单向 stream,控制流与业务流均从 4,098 字节信用开始,消费后由 Quinn 通告后续信用。该信用不是完整 HEADERS parser 内存上限。空闲 QUIC 连接另限 4、超时 30 秒; +- 活动物理连接不设固定业务数量上限;完整响应校验后才能归还 H1 连接或继续复用 H2; +- TCP/DoT/H1 复用连接遇到 EOF、I/O、reset 或响应不匹配时,在原尝试期限内最多新建一次连接; - 非法、截断或超限响应不重试、不归还连接。 +HTTP/2 单流取消发送 RST,其他流可继续使用原连接;响应错误使该连接不再接受新查询。 +解析器拥有连接 driver 与有界空闲回收任务,Session Stop 和资源代际退役关闭全部解析角色并等待 driver 结束;WireGuard/MASQUE 私有 DNS 同样由节点 Stop 关闭且不保留空闲连接。MASQUE IP 查询经过节点内部栈,L4 仅可使用 TCP DNS 传输;system/DHCP 始终沿物理来源策略,不因节点模式改变出口。 +DoQ/H3 的连接取消和退役最多等待一秒 QUIC close exchange,随后停止并等待 +Quinn 原生任务与受控 datagram driver;未发布初始化、握手取消和移交前的准备失败同样属于此 Stop 所有权。 +初始发送 MTU 为 1200 字节,H3 的 PMTUD 上限受当前发送路径预算约束;接收广告独立使用实际接收预算, +不把 fixture 中的 4096 字节接收值作为所有路径的统一上限。任一方向不足 1200 的 QUIC 路径拒绝。 +H3 在非 DATA 帧声明长度完整时提前检查:HEADERS 编码长度最多 24 KiB,SETTINGS 最多 256 字节, +CANCEL_PUSH / GOAWAY / MAX_PUSH 长度为 1..8 字节,PUSH_PROMISE 最多 24 KiB 加 8 字节且非空。 +未知 H3 帧也限 24 KiB,超限立即拒绝,不先累计完整 payload;这比 quiche 对未知帧最多 16 MiB 的跳过范围更严格。 +DATA 仍按应用读取流式处理,DNS body 保持 4096 字节限制;QPACK 与未知单向 stream 的分类沿官方 h3 实现。 + Controller 切换代理组后,只在后续需要创建新 DNS transport 时使用新选择。类型化/原始响应 cache、singleflight 状态和已经位于 TCP pool 中的 transport 不清空、不迁移;因此命中 cache 或复用既有 TCP transport 时,不会为切换单独建链。停止 session 才统一释放这些状态。 ## 队列与生命周期 @@ -164,11 +403,11 @@ Controller 切换代理组后,只在后续需要创建新 DNS transport 时使 | --- | ---: | | 每普通 UDP 关联入站 | 64 | | DNS 响应 | 128 | -| 普通 UDP 响应 | 4,096 | +| 普通 UDP 响应 | 256 | -reader 在进入 TCP netstack 前分流 UDP,拥有按源地址建立的关联表,没有共享 UDP 入站接收器或独立 DNS 请求准入数。每普通 UDP 关联有独立请求队列;DNS 查询继续提交受跟踪的任务,不等待上游。队列满时只丢当前请求或响应,不阻塞 reader;TCP accept 仍为 128 项。TUN UDP 响应按有效 MTU 减去 48 字节保守限制;默认 9000 MTU 时为 8,952 字节;显式 1500 为 1,452 字节,Windows UWP 使用 1400 时为 1,352 字节。 +reader 在进入 TCP netstack 前分流 UDP,拥有按源地址建立的关联表,没有共享 UDP 入站接收器或独立 DNS 请求准入数。每普通 UDP 关联有独立请求队列;DNS 查询继续提交受跟踪的任务,不等待上游。入站或 DNS 响应队列满时只丢当前数据报,不阻塞 reader。普通响应队列满时,每源关联最多暂持一个已接收响应并暂停继续接收,等待容量期间仍可发送请求或取消,不增加任务;取消或错误时丢弃未提交响应。TCP accept 仍为 128 项。TUN UDP 响应按有效 MTU 减去 48 字节保守限制;默认 9000 MTU 时为 8,952 字节;显式 1500 为 1,452 字节,Windows UWP 使用 1400 时为 1,352 字节。 -唯一受跟踪的 TUN writer 公平读取普通 UDP、DNS 和 TCP/ICMP raw 三个通道,任一通道关闭不丢弃其他通道的待写响应。UDP 构包后直接写平台,不再转发到共享 raw output。DNS 查询观测 permit 保留至该包被平台接受、最终丢弃或取消,写回阻塞和 ENOBUFS 重试不提前释放;WinRT Adapter 成功处理不等于框架最终交付。MTU/地址族错误只丢当前响应。 +唯一受跟踪的 TUN writer 公平读取普通 UDP、DNS 和 TCP/ICMP raw 通道,Linux 的真实 Echo 使用第四个有界通道;任一通道关闭不丢弃其他通道的待写响应。UDP 构包后直接写平台,不再转发到共享 raw output。DNS 查询观测 permit 保留至该包被平台接受、最终丢弃或取消,写回阻塞和 ENOBUFS 重试不提前释放;WinRT Adapter 成功处理不等于框架最终交付。MTU/地址族错误只丢当前响应。 运行时停止会取消 open、send、receive、retry 和 response-send,释放全部传输并等待已跟踪任务结束。停止返回后不得再向 TUN 回包。 @@ -183,18 +422,35 @@ DNS 提示或 GeoData 更新改路;新流使用当前数据。每目标按同 ## IP-only协议与独立测速接点 -公共`ResolutionContext`仅在协议必须取得IP地址的边界解析业务目标或逻辑上游;原始`Destination`保持不变,HTTP Host、TLS SNI和路由仍使用逻辑名称。代理endpoint的prepare bootstrap与该运行期解析分离,不递归使用尚未建立的同一出口。当前静态ECH不查询DNS;动态ECH及其可选宿主bootstrap入口尚未实现,不属于当前Invoke接口。 +`experimental.dialer-ip4p-convert` 只在已声明的物理 connected TCP 和指定 UDP 节点端点生效。 +物理端点解析后先解码、再按实际 IPv4/IPv6 应用地址族及 source/interface 绑定; +解析角色、逻辑 host/port、认证和代理链不改变。DNS 的查询族与缓存仍遵循原配置, +不自动开启 AAAA、不改写对外 DNS 记录或通用 IP-only 业务解析结果。 + +公共`ResolutionContext`仅在协议必须取得IP地址的边界解析业务目标或逻辑上游;原始`Destination`保持不变,HTTP Host、TLS SNI和路由仍使用逻辑名称。代理服务器在实际建链时通过节点 resolver 解析,准备阶段不解析备用节点。静态 ECH 不查询 DNS;动态 ECH 通过同一解析上下文消费节点角色的原始 HTTPS 记录。查询名可独立覆盖,不改业务/服务器逻辑名称;沿原建链期限、取消和 weak runtime 所有权,不增加 Invoke DNS 入参。DNS 关闭和独立测速使用物理 system source,不可用时失败。 -Running Session上下文仅弱引用本session的RuntimeDns,使用上文nameserver/policy与出口;未绑定、DNS关闭、上游不可达或session已停止时明确失败,不调用系统resolver兜底。IP字面量仍执行端口/地址族政策。解析共享同一次建链期限,Stop取消当前及后续查询;在进入可能等待自身的singleflight之前拒绝同名递归依赖,嵌套依赖深度最多32层。不会因为DNS经代理出口就无条件禁止整个图。 +Running Session上下文仅弱引用本 session 的 RuntimeDns;业务 IP-only 解析使用 nameserver/policy,代理服务器使用 proxy-server-nameserver/policy,DIRECT 目标使用 direct-nameserver。未绑定、上游不可达或 session 已停止时失败;只有配置关闭 DNS 时才使用系统解析。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 测速; -- DoH、DoT、DoQ、hostname/system/DHCP nameserver; -- 代理组 provider/health check/自动 failover、并行 nameserver 竞速、DNS fallback group、fake IP 和 hosts; -- 用户可配置的缓存容量、超时、重试或并发度; +- Linux Echo 以外的真实 ICMP 转发、PMTU 以外的 ICMP error、Traceroute、ICMP 选路规则或 ICMP 测速; +- 运行中随接口变化自动刷新 hosts: lan; +- 用户可配置的查询总超时、重试或并发度; - 使用 DNS question 执行业务规则,或用 DNS 出口改写后续业务动作。 自动化和物理 TUN 覆盖范围见 [验收矩阵](acceptance.md)。 + +## 入站规则元数据 + +TUN TCP 的 `IN-TYPE` 为 TUN,`IN-PORT` 为虚拟连接的原始目标端口;TUN UDP +没有本地监听端口,`IN-PORT` 为 0。两者没有认证用户,来源规则读取原始来源 IP/端口。 +这与 Mihomo 默认 MIPS 栈一致;其他栈的内部 TCP listener 端口可能不同。 + +业务规则中的 `IN-NAME` 为区分大小写的固定入口名:TUN 为 DEFAULT-TUN; +mixed 的 HTTP/SOCKS5 为 DEFAULT-MIXED。内部请求的名称为空,不新增可命名 listeners。 +Core 的 DNS、GeoData 下载及测速入口均映射为 INNER,端口为 0、用户为空。 +DNS `respect-rules` 使用同一个规则求值器,但不继承触发解析的业务用户。 +classical selector 的空元数据是单独参考视图(TCP/HTTP、端口0、无入口名和身份), +并不把内部 DNS 路由伪装成业务 HTTP 连接。 diff --git a/docs/tun-platform.md b/docs/tun-platform.md index 48e8206..451e46d 100644 --- a/docs/tun-platform.md +++ b/docs/tun-platform.md @@ -11,19 +11,20 @@ TunRuntime -> TunRsIo -> 宿主持有的 TUN fd 副本 - iOS/tvOS/macOS:宿主提供 utun 文件描述符,适配器处理四字节 packet-information 头。 - Android:`VpnService` 提供 raw-IP 文件描述符。 - Linux:真实单队列 TUN,使用 `IFF_TUN | IFF_NO_PI`、无 VNET header;接口实际 MTU - 必须与 `tun.mtu` 一致。宿主负责地址、网络命名空间和路由,Vole 不自动接管系统网络。 + 必须与 `tun.mtu` 一致。借用 fd 的地址、网络命名空间和路由由宿主负责。 `tun.file-descriptor` 缺省或 0 时,Linux / macOS 由 core 打开本机 TUN,Android / iOS / tvOS 仍需宿主提供 fd。`tun.device` 控制原生打开的接口名:空值在 Linux 使用 `Vole`, -macOS 自动选择 utun。core 只设置自有接口的 MTU 和自有 fd 的 nonblocking,不配置地址、 -DNS 或路由;Stop 关闭自有句柄,不承诺删除宿主已有接口。创建失败没有备用数据面。 +macOS 自动选择 utun。macOS 只设置自有接口的 MTU 和 nonblocking,网络配置仍由宿主负责。 +Linux 自建接口的自动网络配置见下节;关闭自动路由时只设置 MTU/nonblocking。 +Stop 关闭自有句柄,不承诺删除宿主已有接口。创建失败没有备用数据面。 正数 file-descriptor 选择下述借用路径;fd 数字必须对当前进程有效,CLI 不增加专用 fd 参数。 宿主始终拥有原始文件描述符。Vole 启动时: 1. 验证文件描述符有效且已设置 `O_NONBLOCK`; 2. 通过 `F_DUPFD_CLOEXEC` 创建 Vole 持有的副本; -3. 把副本交给 `tun-rs::SyncDevice::from_fd`,并用 Tokio `AsyncFd` 驱动; +3. 把副本交给 `tun-rs::SyncDevice::from_fd`,再用 Tokio `AsyncFd::register` 驱动; 4. 停止时只关闭副本。 借用路径不调用会修改共享 open-file-description 标志的异步构造器,也不重配置接口。 @@ -47,16 +48,16 @@ Linux 压测可以把 TUN 创建在客户端 namespace,再把借用 fd 交给 首轮验证为 GNU/glibc 原生构建与容器真实 TUN;musl、其他架构、安装交付和设备验收 不由一次 Linux 容器压测推导。Linux RSS/VmHWM 与 Apple physical footprint 分开报告。 -tvOS 首版为 17.0+、ARM64 真机和 Apple Silicon 模拟器,共用原有 TunIo、netstack 和 -Dialer;不新增低内存数量上限,不改变其他平台默认行为。iOS/tvOS 的 TUN 生命周期接入 -Apple Unified Logging、TASK_VM_INFO 当前/进程峰值观察及停止后的 allocator pressure relief。 -既有 35/40/45 MiB 阈值仅发出诊断,不是系统限额或业务准入条件;macOS 不启用这组 -移动扩展周期采样。tvOS 事件使用 tvos_memory_*,iOS 保留 ios_memory_*。 +tvOS 首版为 17.0+、ARM64 真机和 Apple Silicon 模拟器,共用 TunIo、netstack 和 +Dialer。所有平台使用统一的 TUN 内存诊断、allocator 配置和 Stop 后回收策略, +缓冲、队列和线程策略也不按平台或内存模式切换。内存按默认压力负载统一验收:1 Gbps 不超过 43 MiB, +2 Gbps 不超过 45 MiB;真实 iOS/tvOS 扩展按整个进程的 physical footprint +验收,完整口径见[验收边界](acceptance.md)。 Apple 公开 NEPacketTunnelFlow API 是 packetFlow 的包读写,不承诺可取得 raw fd。 -Vole 的借用 fd 契约不能证明宿主 KVC 提取 fd 是稳定公开方案。模拟器 socketpair 只验证 -合成 utun 数据面;真实宿主的 fd 获取/桥接、签名与 entitlement、系统路由、设备生命周期 -及物理内存须独立验收。当前没有新增 packetFlow 回调 ABI,也不暗中回退其他接入路径。 +Vole 的借用 fd 契约不能证明宿主 KVC 提取 fd 是稳定公开方案。真实宿主的 fd 获取/桥接、 +签名与 entitlement、系统路由、设备生命周期及物理内存须独立验收。 +当前没有新增 packetFlow 回调 ABI,也不暗中回退其他接入路径。 包 I/O 规则: @@ -84,7 +85,7 @@ Unix 读缓冲区和公共批次容器复用;非法包只丢该槽位,不丢 共享 UDP 入站中转队列。TCP/ICMP 使用独立有界 ingress;满时丢当前完整 IP 包并 计数,TCP 可重传,不等待容量而阻塞其他来源的 UDP/DNS。 -唯一 writer 公平轮转 TCP/ICMP raw、普通 UDP 响应和 DNS 响应三个独立通道。 +唯一 writer 公平轮转 TCP/ICMP raw、普通 UDP 响应和 DNS 响应;Linux 真实 Echo 另有独立有界通道。 UDP 直接在复用的 MTU frame 中构包,不再经过 response writer 和 raw output 的二次交接;每批最多处理 8 个已就绪项目,非法响应也消耗工作预算。DNS permit 保留到对应包被平台接受、最终丢弃或取消,ENOBUFS 待重试时不释放;Windows @@ -105,16 +106,108 @@ token 归还容量。满输出保留尚未处理的 TCP 包和协议状态,低 丢当前请求。writer 消费包会显式唤醒 Driver;满输出不以零延迟 timer 忙重试, 输出端关闭则停止 Driver、释放 socket 并唤醒全部等待者。 -netstack 的 TCP 接收与发送缓冲可独立配置,各方向的容量包含 smoltcp 与应用 -缓冲,两层各占一半。生产 TUN 仍从现有资源策略取得每方向 32 KiB,合计每流 -64 KiB 数据缓冲;不扩大默认容量、不新增用户 YAML 参数,也不改变其他协议 -的缓冲策略。该容量不包含代理 relay、包队列或 allocator 的内存。 +netstack 的 TCP 接收与发送缓冲可独立配置,各方向的容量包含 smoltcp 缓冲与应用 +队列,两层上限各占一半。生产 TUN 仍使用每方向 32 KiB、合计每流 64 KiB 的数据 +容量上限。应用队列从空分配开始,按实际写入有界增长,分配请求不超过该队列的 +逻辑上限;排空后保留已分配空间直到连接释放。smoltcp 缓冲容量、背压、唤醒、 +flush 与关闭语义保持不变。该容量不包含代理 relay、包队列或 allocator 的内存, +也不保证进程 RSS 与数据容量相同。 Linux 目前不启用 GRO/GSO:`tun-rs` 的 Linux offload 示例通过 builder 创建并 配置带 VNET header 的设备,而 Vole 的原生和借用路径均不启用该模式。对现有 raw-IP 设备调用 `recv_multiple` 不会自动合并读取或启用 offload。Vole 保留宿主 MTU/格式与 fd 标志,原生 builder 只打开 raw-IP TUN,不暗中启用 offload 或改变借用接口。 +## Linux 自建 TUN 的系统网络 + +`tun.file-descriptor: 0` 的 Linux 路径支持 `auto-route` 和 `auto-detect-interface`, +两者缺省 true。其他平台缺省 false;借用 fd 路径不修改宿主网络,无论这两个字段的值。 + +- `auto-detect-interface` 从主路由表检测每个地址族的默认物理出口,最多缓存一秒。 + 新建 TCP/UDP socket 绑定该接口,所有协议、DNS、资源下载共用 Dialer;没有出口时失败。 + 已有 socket 不迁移,下一次建链重新检测。回环目标保持本机访问。 + 全局/节点 `interface-name` 的显式非空值优先于自动识别,DNS `#接口` 优先于全局接口; + system/DHCP 来源保留自己的物理接口约束。显式接口不存在或绑定失败不会回退自动识别。 +- `auto-route` 独占创建配置命名的 raw-IP TUN,已有同名接口失败。IPv4 使用 + `dns.fake-ip-range` 的原始 IPv4 地址(默认 `198.18.0.1`),前缀固定 /30;IPv6 地址从 `inet6-address` 读取。 + 路由表 `iproute2-table-index`(缺省/0 为 2022)、规则优先级 + `iproute2-rule-index` 到 `+8`(缺省/0 从 9000 开始)必须空闲;保留主表的非默认路由, + 缺省仅本机未标记流量经 TUN,显式输入接口作用域见下节。物理 socket 保留全局/节点 + 继承后的非零 `routing-mark`;只有零 mark 才注入内部防环值,两者均绕过本会话捕获。 + 不依靠 source IP 避免递归,也不新增覆盖用户策略路由的旁路规则。配置接口与路由直接 + 通过内核接口完成,不调用 ip 子进程。动态 Provider 候选沿用相同 socket 策略。 + 回环物理目标继续忽略公开 mark/接口并使用私有隔离;system/DHCP 保留其精确物理绑定。 + 借用 fd 和关闭 auto-route 的路径不注入私有 mark,保留原配置 mark。 +- 自动路由启用,且 `dns.enable: true`、`dns-hijack` 匹配接口 IPv4 下一地址的 53 端口时, + 通过 systemd-resolved 给新接口设置 DNS(默认 `198.18.0.2`)、路由域 `~.` 和 default-route。 + 系统须使用 systemd-resolved;调用 `/usr/bin/resolvectl --no-ask-password`, + 每次最多五秒并收回子进程。未启用 DNS 或关闭匹配劫持时保留系统 DNS 行为。 + 不覆盖 `/etc/resolv.conf` 或其他接口设置,也不新增 DNS 配置字段。 +- 启动失败逆序撤销已应用的操作;正常 Stop 先停止业务,再恢复 DNS、规则与路由, + 最后释放自建接口。清理期间额外持有接口 fd,避免接口索引被提前复用。 + 已被外部移除的自有路由视为完成,其他清理错误作为停止错误返回。 + +安装包负责权限:`packaging/linux/post-install` 为 `/usr/bin/vole` 设置 +`CAP_NET_ADMIN`,并把执行权限限制给 `vole` 组。安装器为获授权用户添加组成员关系, +将 `50-vole-resolved.rules` 安装到 `/usr/share/polkit-1/rules.d/`,并依赖 systemd-resolved、 +polkit 与提供 setcap 的包。deb postinst / rpm %post 每次更新二进制后执行该脚本。 +运行时不弹出提权请求,不使用 sudo,也不把 capability 传给 resolvectl 子进程; +后者使用已安装的 [resolve1 授权](https://github.com/systemd/systemd/blob/main/src/resolve/org.freedesktop.resolve1.policy)。 +CLI 的进程启动、后台管理及用户授权仍由调用方负责。 + +### Linux 捕获范围 + +`inet6-address` 是接口地址列表,保留地址的 host bits,缺省为 +`fdfe:dcba:9876::1/126`;显式 `[]` 不配置 IPv6 捕获,顶层 `ipv6: false` +也不配置该地址族。IPv4 地址仍从有效 fake-IP 范围导出 `/30`,不新增独立 IPv4 地址字段。 + +`route-address` 与 `route-exclude-address` 接受统一的 IPv4/IPv6 CIDR。 +每个可用地址族没有指定捕获网段时使用默认网段;排除网段优先,先做精确 CIDR 相减, +再安装本会话独占表中的路由。保留 main 表的非默认路由;53 端口先尝试捕获表, +排除网段仍可绕过。捕获作用域内的未标记流量进入规则,Core 的非零公开或私有出口 mark 不进入捕获。 + +`include-interface/exclude-interface` 是 Linux RPDB 的输入接口名称筛选,不是物理出口。 +本机 output 使用 `lo`。非空 include 仅捕获所列输入,优先于 exclude(此时不使用 exclude); +要同时捕获本机须列出 `lo`。include 为空、exclude 非空时捕获未排除的输入和本机; +两者均空保留仅本机的缺省作用域。TUN 自身输入与 Core 的带标记物理 socket 始终绕过。 +接口名在配置阶段按 Linux 名称语法与 15 字节上限验证,不要求接口已存在; +缺失或消失的名称不匹配其他输入,之后出现或恢复同名接口可再次匹配,无需监视任务或重建规则。 +IPv4/IPv6 使用相同接口筛选;只改变本会话的捕获,不设置 forwarding、NAT 或防火墙。 + +`include-uid/include-uid-range` 限定本机 UID 捕获范围;省略时为全部 UID。 +`exclude-uid/exclude-uid-range` 优先排除,端口排除使用 +`exclude-src-port/exclude-src-port-range/exclude-dst-port/exclude-dst-port-range`。 +范围格式为 `start:end`,不枚举范围内每个 UID 或端口。排除流量跳过本会话捕获规则, +由后续系统路由决定去向。UID 仅排除本机应用,端口排除作用于所选输入流量;这些选项不改变出口的 protect/物理绑定。 + +`strict-route: true` 在自动路由中拒绝所选输入使用没有 TUN 地址的 IPv6 族, +UID/端口的显式旁路仍生效。它不代替宿主防火墙、权限设置或网络故障时的独立 kill switch。 +配置最多 256 项网络输入、派生最多 4096 条 CIDR 路由;超过预算在创建接口前失败。 +路由索引、输入接口、UID、端口和 CIDR 改动需要重建捕获计划,运行期应用会返回需要重连。 + +这些自动设置仅用于 Core 自建 Linux TUN 且 `auto-route: true` 的路径。 +借用 fd 与 Apple/Android/UWP/Wintun 的接口地址、捕获范围、DNS 和权限由宿主应用; +Core 不修改借用接口或将跨平台解析成功视为系统网络配置完成。 + +## Linux 物理 ICMP Echo + +Linux 的真实 Echo 在公共 TUN reader 进入 netstack 前分流,复用共享 Dialer 的受控物理出口, +只走 DIRECT,不解释 TCP/UDP 代理图。`tun.icmp-timeout` 缺省或 0 为 10 秒; +`disable-icmp-forwarding` 缺省 false。两个字段只在 Linux 配置中存在,其他平台拒绝未知字段并保留原本地合成行为。 +归一化后的 timeout 或 disable 变化由整个 TunConfig 的现有重连门禁处理。 + +真实 TUN 地址前缀在启动时从实际接口及其 network namespace 读取;这些前缀、启用的 fake-IP 池 +及禁用转发的合法 Echo 使用原本地回应。物理失败、权限不足或容量不足不合成成功。 +Linux 通过普通 DGRAM ping socket 收发 IPv4/IPv6,安装器或宿主须预先用 +`net.ipv4.ping_group_range` 授权运行组;现有 TUN 所需 CAP_NET_ADMIN 不替代此授权。 +Core 不改 sysctl、不运行提权命令、不回退 IP raw socket,也不修改借用 TUN 的地址、路由或 fd 标志。 + +一个驱动最多持有每族一个物理 ping socket、合计 64 个请求许可,入站/响应队列各 8 项; +真实包上限为 `min(tun.mtu, 2048)`,发送后的 pending 只留元数据与摘要。 +答复验证实际物理源地址、校验和、标识、长度、摘要和期限后,由现有唯一 TUN writer 公平写回。 +Stop 取消并等待全部所有者,清理 pending、队列、许可和 ping socket;停止后不得再回包。 +详细输入和合成边界见 [ICMP 契约](tun-icmp-dns.md#icmp-echo)。 + ## Windows Wintun 桌面 Wintun 使用宿主提供的动态库与原始 IP 适配器,通过同一 `TunIo` 边界进入 @@ -125,7 +218,7 @@ TunRuntime、netstack、DNS、规则和出站图。启用 TUN 的普通 Windows 宿主提供与程序架构一致的官方 `wintun.dll`。加载路径固定为进程可执行文件所在目录下的 `wintun.dll`;适配器名由 `tun.device` 控制,空值为 `Vole`,启动可打开已有同名适配器或创建新适配器。 Vole 不下载或打包该动态库,不搜索 PATH 或启动工作目录。 -系统接口地址、DNS、路由与防递归出口配置由宿主完成;YAML 不提供 DLL 路径或路由字段。 +系统接口地址、DNS、路由与防递归出口配置由宿主完成;YAML 不提供 DLL 路径,网络字段由宿主应用。 Wintun 使用配置的 MTU,共享原始包校验、IPv6 政策、局部队列预算和同步停止屏障。 UWP 的 1400 MTU 上限只适用于该安装包路径;超过上限明确拒绝,不静默截断。 Windows 不接受 Unix file-descriptor 借用路径。 @@ -178,14 +271,16 @@ UWP 因 `StartWithMainTransport` 平台上限要求配置不超过 1400;packet 包队列 256 普通事件 / TCP accept 128 每关联 UDP 入站 64 -普通 UDP 响应 4,096 +普通 UDP 响应 256 DNS 响应 128 ``` TCP 会话、普通 UDP 关联、半开连接和出站握手不设固定业务数量上限。结构安全由有界队列、每流缓冲区、解析大小、超时、空闲清理和缓存提供。 每关联入站、普通响应和 DNS 响应是独立内部队列预算,不扩大 TCP accept。 -满时只丢当前数据报;容量不构成公开配置或活动业务数量上限。TUN 使用 TCP-only +入站或 DNS 响应队列满时只丢当前数据报;普通响应队列满时,每源关联最多暂持一个 +已接收响应并暂停继续接收,等待容量期间仍可发送请求或取消。取消或错误时丢弃未提交响应。 +容量不构成公开配置或活动业务数量上限。TUN 使用 TCP-only netstack 端点及纯 UDP codec,独立 crate 的通用 UDP endpoint 不在生产 TUN 路径上。 写回受阻时 reader 仍可处理新请求。停止会取消并等待唯一 writer、reader 所有的 关联/DNS 任务和 netstack,不依赖输出队列腾出容量。 @@ -196,8 +291,8 @@ netstack 端点及纯 UDP codec,独立 crate 的通用 UDP endpoint 不在生 ## 物理出口 -- Linux:宿主负责网络命名空间或等效路由隔离,确保被捕获的客户端流量进入 TUN, - Vole 出站绕过 TUN;不能把仅绑定 source IP 当作绕路保证。没有自动路由配置或降级路径。 +- Linux:自建接口使用上述自动出口与内部路由隔离;借用 fd 继续由宿主负责命名空间 + 或等效路由隔离。仅绑定 source IP 不能作为绕路保证。 - Android:每个出站 TCP/UDP socket 在 connect 前调用宿主 protect;失败则当前连接失败关闭。 - Windows WinRT:Provider 为当前会话选择不可变的物理网络绑定;每个地址族只从非 link-local 地址中选择一个源 IP 和接口索引交给 Session Host,同时独立保留物理适配器全部去重的 on-link prefixes(包括 link-local)用于 VPN 路由。普通出站 socket 必须同时绑定源地址和 WinSock 接口索引。 - Windows WinRT 只有配置中显式使用 `127.0.0.0/8` 范围内的 IPv4 字面量或 `::1` 的本地出站可以跳过物理绑定;物理代理服务器的域名解析到任何回环地址都会失败关闭。 diff --git a/docs/vless.md b/docs/vless.md index c6f33a3..4233dc7 100644 --- a/docs/vless.md +++ b/docs/vless.md @@ -5,24 +5,27 @@ ## 配置与传输 -`type: vless`,`server`、非零 `port` 和标准格式 `uuid` 必填。`udp` 默认 false;`network` 默认 `tcp`,`tls` 默认 false。`encryption` 省略、空或 `none` 关闭该层;其他值见下文。`flow` 省略、空或 `xtls-rprx-vision`。未知字段、null、错型或不匹配的传输选项失败,不隐式迁移配置。 +`type: vless`,`server`、非零 `port` 和字符串 `uuid` 必填。UUID 接受标准、紧凑、花括号或 URN 表示;其他字符串按 UUIDv5(nil, 原始 UTF-8) 映射,不 trim,与 VMess 共用转换。`udp` 默认 false;`network` 默认 `tcp`,`tls` 默认 false。`encryption` 省略、空或 `none` 关闭该层;其他值见下文。`flow` 省略、null 或空串时关闭,也接受 `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 互斥。阈值不是硬流数上限。物理连接归节点所有,关闭逻辑流不会杀死兄弟流;复用不重选上游组。 +- 标准 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 支持默认头、自定义头或路径。`path` 的 `?ed=N` 覆盖显式 ED 配置并删除 ed 参数;实际早数据按当前请求剩余的16KiB头预算限制,不按配置上限预分配。`v2ray-http-upgrade` 开启升级后裸流;`v2ray-http-upgrade-fast-open` 必须与前者一起启用,只允许默认 ED 头。fast-open 提前发协议首包但仍等待合法 101,拒绝不返回连接成功。 +- gRPC 与 Trojan/VMess 共用配置:省略或空 `grpc-service-name` 默认 `GunService`,普通名映射 `/name/Tun`,`/` 开头使用完整路径;省略或空 `grpc-user-agent` 默认 `grpc-go/1.36.0`。`ping-interval` 为带符号秒数,0 不主动 PING,负值明确拒绝;三项池阈值接受带符号整数,负阈值禁用相应分支。空闲连接总是复用;max-connections>0 时按连接上限/min-streams 扩容,否则按 max-streams 复用阈值扩容。三项池阈值省略或字面全零时归一为4/0/0,Mihomo为1/0/0;显式 max-connections=1 或2保留各自策略。正 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 和附加安全封装仍拒绝。 +- gRPC 在原建链期限内等待有效 HTTP/2 SETTINGS 并提交完整 VLESS 请求;提交后可持续上传,等待 HTTP 响应不再消耗建链期限。延迟响应仍须通过原状态、内容类型及协议校验,Stop 取消并唤醒活动读写。 +- gRPC authority 使用非空显式 `servername`,否则使用 server:port;`name-cert-verify` 只改变证书验证名称。legacy H2 的 host 省略或为空列表时使用 www.example.com,path 省略或为空时使用 `/`。 +- `network: xhttp` 支持 H1/H2 明文、TLS1.3、REALITY、JLS 或 ShadowTLS v3,H3 仅标准 TLS1.3。请求字段、独立下载腿、池与版本选择见 [XHTTP](xhttp.md)。H3 的物理 UDP 仍由原 Dialer/上游图创建。 +- 公共节点 `smux` 以独立的 h2mux/smux v1/yamux 承载逻辑 TCP/UDP;VLESS 只提供正常 TCP 到约定目标,不拥有第二个 mux 池。它不是 XUDP;only-tcp、调度及关闭边界见 [通用 sing-mux](xhttp.md#通用-sing-mux)。 +- Vision 仅允许 `network: tcp`,要求 TLS 1.3/REALITY 或 Encryption;UDP 仅 XUDP,`packet-encoding: none` 同样归一为 XUDP。启用 Encryption 后外层 TLS 可省略,存在时仍保持独立认证。配置阶段拒绝 WS/gRPC/HTTP/H2/XHTTP、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)。 +`reality-opts.support-x25519mlkem768` 为非 null 布尔值,默认 false。true 允许同次握手 +协商真实混合或经典 TLS 密钥交换,可用于主连接和 XHTTP 独立下载连接。 +`chrome` 和未命名 profile 提供混合/X25519 双 share,其余 TLS1.3 模板保留真实经典 share; +不改写模板、重试握手或放宽 REALITY 认证。模板及下载继承约束见 [TLS 指纹](tls-client-fingerprint.md)。 ## JLS @@ -34,7 +37,7 @@ TLS 密钥交换,服务端选择经典组时失败;可用于主连接和 XHT 主连接不接受空对象;未知字段、null、错型、缺项及超限在 IO 前拒绝。 - 必须 `tls: true`。同一腿不能混用 REALITY、标准证书策略或客户端证书; ECH、其他安全封装、Vision 和 HTTP/3 也不属于此组合。 -- `servername`、有序 ALPN、传输必需 ALPN 与已支持的七值/四模板 +- `servername`、有序 ALPN、传输必需 ALPN 与具备 TLS1.3 能力的命名模板 `client-fingerprint` 沿用共享配置,不增加全局默认或别名。 - `download-settings.jls-opts` 缺省继承整个身份;非空对象必须完整替换两项, `{}` 清除,不能按叶继承另一个身份的密码。其他下载字段独立覆盖。 @@ -57,32 +60,38 @@ TLS 1.2 和 HRR 都不能满足 JLS;失败不发伪装 HTTP 探测、不重发 JLS 继续承载 TLS 记录,CloseWrite 沿用共享 TLS 有界关闭;XHTTP 保持整流关闭。 Encryption 位于外层传输内,身份彼此独立,不能由单层成功推断组合验收。 -## 静态 ECH +## ECH -仅显式静态 ECHConfigList;保护内层 ClientHello 名称,不隐藏 IP、外层名称或流量形态。 -无动态 HTTPS RR/bootstrap、公共 DNS 后备或自动更新。 +静态 ECHConfigList 或运行时 HTTPS 记录来源保护内层 ClientHello 名称, +不隐藏 IP、外层名称或流量形态。VMess/Trojan/AnyTLS/Hysteria2 共用此配置和失败契约。 ### 配置 ```yaml ech-opts: enable: true - config: "<服务端提供的标准 Base64 ECHConfigList>" + # config: "<服务端提供的标准 Base64 ECHConfigList>" # 非空时优先,不查询 DNS + # query-server-name: query.example # 只覆盖 DNS 查询名;省略时查询 servername ``` - 仅标准 TLS,内层 `servername` 必须是 DNS 名,实际只协商 TLS 1.3。 同腿不能与 REALITY、JLS 或 Vision 混用;可与 VLESS Encryption、证书策略、 mTLS 和既有传输组合。命名指纹仍只用于 TCP TLS,H3 仍拒绝命名指纹。 -- `enable` 默认 false。启用必须同时提供非空 `config`;不查询 HTTPS RR, - 不接收 `query-server-name`,没有公共 DNS 后备或自动更新配置。 +- `enable` 默认 false,关闭时忽略 config/query-server-name。启用且 config 为空或省略时, + 每条新物理 TLS/QUIC 连接通过节点 DNS 角色查询 HTTPS (65),消费第一个含二进制 + ECH key 5 参数的有效记录;沿用有界 wire cache 和 singleflight。静态 config 优先。 +- `query-server-name` 只覆盖查询名,不改实际拨号目标、SNI、证书验证名、ALPN 或端口。 + 不查询 SVCB (64) 后备,不按服务优先级重排,不自行跟随 AliasMode。 + DNS 启用时使用 proxy-server-nameserver 角色;关闭 DNS 和 node-only measureDelay 时 + 使用原 Dialer 的物理 system DNS 元数据。平台来源不可用则失败,无额外公共 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、未知字段、错型均拒绝;诊断不回显配置。 + ECHConfigList 最多 65,537 字节,仍受整份配置 8 MiB 上限限制。 +- ECH 版本为 `0xfe0d`。按实际 [后端算法](tls-dependencies.md) 选取第一个兼容条目, + 保留其原始编码;下载腿切换后端时按完整来源重新选择。未知版本或不兼容条目可跳过, + 无兼容条目、畸形长度、非法 public_name 或不支持的必需扩展失败,不能依赖 BoringSSL 静默忽略。 +- public_name 要求多标签 LDH 名称;Rustls 的名称类型另限制每标签 63 字节并拒绝纯数值末段, + Boring 命名指纹还拒绝十六进制 IP lookalike。这里保留真实后端限制,不能宣称任意 Go DNS 名称均兼容。 +- `{}` / `{enable: false}` 清除 ECH。null、未知字段、错型均拒绝;诊断不回显配置或查询名。 `xhttp-opts.download-settings.ech-opts` 缺省继承整个主腿对象;出现时整对象替换, 不逐字段合并,替换密钥须同时写 `enable: true`。显式 `{}` 可仅清除下载腿 ECH。 @@ -91,18 +100,22 @@ ech-opts: ### 后端与失败边界 -无指纹 TCP TLS 与 H3 使用官方 rustls + ring,通过公开 HPKE trait 接入官方 -`hpke`;命名指纹使用锁定的自有 boring fork 既有 ECH 接口。本次不修改 -第三方 TLS 实现,不恢复 rustls fork,也不扩展 Shadowsocks 的 AWS-LC 例外。 +无指纹 TCP TLS 与 XHTTP H3 的 Quinn 使用官方 rustls + ring,通过公开 HPKE trait 接入官方 +`hpke`;命名指纹使用锁定的同一 boring ECH 接口。XHTTP H3 保留 rustls 的完整 ECH +算法范围;Boring 命名指纹限定 X25519/HKDF-SHA256,加 AES128/256-GCM 或 ChaCha20-Poly1305。 +切换下载腿时按完整静态来源和实际后端重新选择,动态来源也按实际后端解析; +没有兼容配置就失败。动态来源只获取配置,不扩大后端算法范围。 两后端都必须真正接受 ECH 才交出业务连接。rustls QUIC 的 ECH 拒绝作为原生 -握手错误传播,H3 在握手完成前不发送 HTTP 请求;TCP 另检查 accepted 状态。 +握手错误传播,XHTTP H3 还检查实际 ALPN,通过前不发送 HTTP 请求。 +TCP 同样检查 accepted 状态。 不把 GREASE 当真实 ECH,不自动采用 retry config,不重拨、不回放业务,也不 -降级为未加密 ClientHello 的成功连接。错误或轮换后的旧密钥需要宿主更新配置。 +降级为未加密 ClientHello 的成功连接。静态旧密钥需宿主更新配置;动态新连接随 DNS 缓存有效期取得来源。 -静态 ECH 连接禁用 TLS 会话恢复和 0-RTT;XHTTP/gRPC 的已认证物理连接仍可在 +ECH 连接禁用 TLS 会话恢复和 0-RTT;XHTTP/gRPC 的已认证物理连接仍可在 原节点内复用。不同节点、主/下载腿、配置替换和后续运行实例不会共享 TLS 票据。 -本层只包装调用方提供的 IO,不创建 resolver、socket、后台任务或全局缓存。 +本层只包装调用方提供的 IO;DNS 查询由原建链期限和解析上下文取消拥有, +只弱引用运行时 DNS,不创建独立 resolver、socket、后台刷新任务或全局可变缓存。 正常 ECH 接受后沿用内层证书名称、pin 和 mTLS 策略。拒绝分支不返回业务成功, 也不能发送客户端证书。boring 使用原生 outer-name override,按外层名称和默认 @@ -146,27 +159,45 @@ Vision padding、TLS 记录过滤和读写切换状态独立管理。内层非 T ## UDP -`packet-encoding` 默认 **xudp**,与既有 Vole 行为一致: +`packet-encoding` 默认 **xudp**,`none` 与 `xudp` 使用相同编码: | 配置 | 线上语义 | 目标 | | --- | --- | --- | -| 省略 / `xudp` | VLESS CommandMux + 共享 XUDP 帧 | 每包携带 IP / 域名 | -| `none` | VLESS CommandUDP + 两字节长度 | 固定首包目标,后续换目标失败 | +| 省略 / `xudp` / `none` | VLESS CommandMux + 共享 XUDP 帧 | 每包携带 IP / 域名 | | `packetaddr` / `packet` | CommandUDP 到原生约定地址,长度帧内再带地址 | IP-only,域名经受控 ResolutionContext 解析 | -`none` 是 Vole 的显式 raw 拼写,不是对 Mihomo 同名字段的兼容承诺。原生约定地址不做本地 DNS。UDP 443 不额外过滤。 +packetaddr 的原生约定地址不做本地 DNS。UDP 443 不额外过滤。 -raw/packetaddr 每关联只拥有一个已建流,首次发送前已固定物理上游选择和建链期限;等待首包不会重新选择组或重启期限。packetaddr 不调用系统 resolver:运行期使用本 Running Session DNS,独立测速使用受控 bootstrap。 +VLESS 不发送非零 XUDP GlobalID,不请求服务端跨载体继承 UDP 关联。同一来源可能同时拥有 +不同节点、资源代际、域名隔离或退役后仍服务兄弟目标的通道;这些通道保持独立,不能仅按 +来源生成相同 GlobalID。当前关联失败后关闭,不自动重连或恢复服务端原有 UDP 出口映射。 -收发预算独立与调用方预算取交集。raw/XUDP payload 受 u16 上限约束,packetaddr 另扣 IPv4 7 / IPv6 19 字节地址头(未解析域名按较大开销预检)。raw/packetaddr 接收 wire 最多 65,537 字节,逐段读取,合法超接收预算的整包丢弃。对端可能有更小容量,原生夹具预算不能冒充生产协议上限。 +packetaddr 每关联只拥有一个已建流,首次发送前已固定物理上游选择和建链期限;等待首包不会重新选择组或重启期限。packetaddr 不调用系统 resolver:运行期使用本 Running Session DNS,独立测速使用受控 bootstrap。 -部分发送取消立即关闭 IO,不允许接着发送半帧;接收取消保留帧进度。非法帧或响应头关闭关联,不切换编码、不回退 DIRECT。显式 close 后不能恢复收发。 +收发预算独立与调用方预算取交集。XUDP payload 受 u16 上限约束,packetaddr 另扣 IPv4 7 / IPv6 19 字节地址头(未解析域名按较大开销预检)。packetaddr 接收 wire 最多 65,537 字节,逐段读取,合法超接收预算的整包丢弃。对端可能有更小容量,原生夹具预算不能冒充生产协议上限。 + +XUDP 在既有单帧字节预算内读取已就绪字节,消费当前帧后保留后续帧的尾部;不等待凑批, +也不为每个长度或地址字段分别进入底层 TLS/HTTP 接收。 +省略回复目标仅在关联始终使用同一目标时可恢复;多目标关联收到无目标回复会关闭,避免串流交付。 +无 VLESS Encryption 的 H1/H2/H3 XHTTP 由自有驱动独立推进已接受的写入,XUDP 首包保留建链 flush, +后续帧交给既有有界发送队列;其它传输仍逐帧 flush。 +packetaddr 在同一适用范围保留 VLESS 请求头与首个 DATA 帧的强制 flush,首个 DATA +仍受原建链绝对期限约束。后续完整帧被既有有界 writer 接纳后即可返回,不额外逐帧等待 +packet-up 的 POST 响应;容量不足时仍沿原背压等待。timer/IO 驱动独立推进末帧, +不依赖下一次收发。启用 VLESS Encryption 或非 XHTTP 路径仍逐帧 flush。 +返回成功表示接纳,不代表远端确认;显式 close/Stop 可以取消尚未确认的 UDP, +不承诺排空这些帧。来源隔离、目标地址恢复、部分发送取消及原有 TCP FIN/XHTTP 整流关闭合同保持。 + +packetaddr 和 XUDP 在关联中最多保留一个待写帧及偏移;取消发送后,下次收发先完成该帧与必要的 flush,再处理新包,保持共享通道边界。packetaddr 在发布关联前完成固定魔法目的的传输准备,首次报文仍受原建链期限约束,不因取消重置。相同来源、相同具体节点且无需地址恢复的 IP 目标可共享一条 packetaddr wire;每包回包地址用于分发,域名、fake-IP 与嗅探覆盖路径仍保留独立 wire。最后所有者关闭可直接终止未完成帧,不在其中插入 END 帧。接收取消保留帧进度。非法帧或响应头关闭关联,不切换编码、不回退 DIRECT。显式 close 后不能恢复收发。 ## 关闭和期限 -VLESS 响应头允许延迟到业务响应前;TCP 不等待它才允许发送请求。读取、首次响应和传输握手共用原建链期限,错误或超时释放 IO。UUID 和业务目标不进入 Debug/诊断。 +VLESS 响应头允许延迟到业务响应前;TCP 不等待它才允许发送请求。传输握手及请求头写入、flush 共用原建链期限,请求头成功提交后结束该期限;响应头随后按需读取、校验,持续上传不因缺少下行响应被原建链期限中断。业务等待由调用者期限、取消、EOF及运行时停止控制,格式错误仍释放IO。UUID 和业务目标不进入 Debug/诊断。 + +HTTP 首包伪装在完整请求前导提交后按需解析首响应,不为该响应保留旧建链期限; +VLESS Encryption 等仍未完成的协议握手继续受原建链期限约束,不从 HTTP 前导提交推定完成。 -普通 TCP/TLS 按底层 CloseWrite 语义工作;HTTP 首包伪装没有 Mihomo CloseWrite,shutdown 释放整个逻辑流并唤醒读侧。gRPC/H2 结束当前逻辑流,gRPC 的兄弟流与节点池不受影响。XHTTP 上传 EOF/shutdown 关闭整条逻辑连接,包括下载腿;需要完整响应时在关闭前读完。验收分别检查正常完整性和对齐 Mihomo 的关闭行为,不要求所有传输半关闭后都收到尾包。 +普通 TCP/TLS 按底层 CloseWrite 语义工作;HTTP 首包伪装没有 Mihomo CloseWrite,shutdown 释放整个逻辑流并唤醒读侧。gRPC/H2 结束当前逻辑流,gRPC 的兄弟流与节点池不受影响。XHTTP 上传 EOF/shutdown 关闭整条逻辑连接,包括下载腿;需要完整响应时在关闭前读完。验收检查正常数据完整性、与支持对端的互通,以及 Vole 声明的关闭、取消和 Stop 契约,不要求所有传输半关闭后都收到尾包。 ## 验证边界 @@ -179,6 +210,8 @@ HTTP/H2/扩展 WS 可用 Xray TLS 网关 → V2Ray transport;需 UDP/Encryptio Mihomo 解码。网关使用官方 XRAY_BUF_SPLICE=disable,避免下行 raw splice 绕过 TLS; 不代表 V2Ray 直接支持 ECH。无指纹 JLS/gRPC 及 Safari ECH/gRPC 的官方对照缺口采用 明确标注的 Chrome 关闭参照;Vole 原配置的数据面单独验证,不声称同配置差分。 -VLESS 不接受动态 ECH、ShadowTLS 或 Restls 配置;ShadowTLS v3 仅用于 SS2022 插件。 +VLESS 的 `shadow-tls-opts` 仅接已有 TCP 承载传输及 XHTTP H1/H2,包含独立下载腿; +须 `tls: true`、显式 version 3 和 password,不能与 Vision/JLS/REALITY/ECH 同腿混用。 +cover 的证书策略与 mTLS 独立于业务身份,行为见 [流式安全身份](outbounds.md#流式安全身份)。 -WS + REALITY(普通 WS、HTTPUpgrade、fast-open)的数据及认证使用真实 Mihomo listener;关闭验证采用明确标注的分层参照。当前 Mihomo WS 客户端分支未接入 REALITY,不能作为同组合对照,因此使用标准 TLS 的同种传输客户端关闭基线,并独立验证 REALITY。不得将该结果写成 Mihomo WS + REALITY 客户端互通或同组合差分通过。 +REALITY 只接流式 TLS 消费路径;普通 WS、HTTPUpgrade 及其 fast-open 与 REALITY 的组合在配置期拒绝。 diff --git a/docs/windows-vpn.md b/docs/windows-vpn.md index cf7343c..667d849 100644 --- a/docs/windows-vpn.md +++ b/docs/windows-vpn.md @@ -151,8 +151,15 @@ Session Host 的每个非回环出站 socket 必须同时应用: 只绑定其中一项不满足契约。只有配置中显式使用 `127.0.0.0/8` 范围内的 IPv4 字面量或 `::1` 的本地出站可以跳过;物理代理服务器的域名在准备阶段解析到任何回环地址都会失败关闭。局域网和私有地址不属于例外。缺少地址族、源地址绑定或接口设置失败时,当前连接失败关闭。 +全局/节点 `interface-name` 和普通 DNS `#接口` 只允许选择同一个 Provider 物理适配器。 +友好名、系统接口名和 GUID 由 Windows SDK 转换为实际接口身份;不匹配时当前连接失败, +不改变绑定源地址、接口索引或会话快照。非零 `routing-mark` 在 Windows 上不可用。 +桌面 Wintun 的显式接口选择仅设置普通出站 socket,不取得 Provider 绑定或代替宿主路由配置。 + `AssociateTransport` 只用于 Provider 的受管理唤醒传输,不用于普通代理流。Session Host 不持有 `VpnChannel`,不能给逐流 socket 获取同类豁免。 +显式 `system` DNS 仅由实际 Session Host 在显式查询按需读取既有物理适配器 GUID 的 DNS 数据(metadata 缓存 20 秒),继续执行同一 `(源 IP, 接口索引)` 绑定;不读取前台 Invoke registry,也不改变控制协议。`GetInterfaceDnsSettings` v3 不可用或存在无法满足的加密要求时来源不可用。原生 IPv6 DNS 的加密策略无法通过该只读查询确认,也保持不可用。无法确认 DHCP option 6 来源时 `dhcp://<接口>` 不可用。系统 DNS 发现不配置或恢复系统网络,App 仍负责原有网络设置和生命周期。 + ## 网络变化 Provider 是物理网络状态的唯一权威,并订阅 `NetworkStatusChanged`。事件到达后等待 2 秒,再复验适配器 GUID、选定源地址、全部去重 on-link prefixes 和 network identity;任一项变化就停止当前 VPN。 @@ -180,7 +187,7 @@ vole-windows-vpn-host.exe vole-windows-session-host.exe ``` -- manifest 只有一个主 Application,三个可执行参与者相互独立;产物架构一致,Rust 使用静态 CRT; +- manifest 只有一个主 Application,三个可执行参与者相互独立;产物架构一致,UWP 的 Rust 和原生依赖统一使用动态 CRT,安装包负责声明并部署对应架构的 Visual C++ Runtime 依赖; - Session Host 是 `windows.fullTrustProcess` extension,不显示在应用列表,也不注册 StartupTask 或 URI; - Provider 的 `windows.backgroundTasks` extension 显式使用 `windowsApp + appContainer`; - Provider activation class 来自 `vole.dll`; @@ -337,10 +344,10 @@ Stopped { version, packetCounters } ## Controller 与本地 SOCKS -- Controller 由 Session Host 监听完全信任的回环地址; +- Controller 由 Session Host 按 YAML 的 `external-controller` 地址监听; - `RuntimeReady` 前必须完成绑定,失败则连接失败; - 前台宿主持有配置中的端口和 secret,并调用 `GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 或 `PUT /proxies/{name}`; -- 配置代理组与 Controller 时 secret 必填并保护全部路由;仅有 TUN 流量 Controller 时 secret 仍可省略; +- `secret` 可省略或为空;配置非空 `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`; @@ -360,5 +367,13 @@ Stopped { version, packetCounters } - [`VpnManagementAgent`](https://learn.microsoft.com/uwp/api/windows.networking.vpn.vpnmanagementagent) - [`FullTrustProcessLauncher`](https://learn.microsoft.com/uwp/api/windows.applicationmodel.fulltrustprocesslauncher) - [Package identity](https://learn.microsoft.com/windows/apps/desktop/modernize/package-identity-overview) +- [UWP 与 C Runtime](https://learn.microsoft.com/cpp/c-runtime-library/windows-store-apps-the-windows-runtime-and-the-c-run-time) - [`RoInitialize`](https://learn.microsoft.com/windows/win32/api/roapi/nf-roapi-roinitialize) - [`CoIncrementMTAUsage`](https://learn.microsoft.com/windows/win32/api/combaseapi/nf-combaseapi-coincrementmtausage) + + +`startVpn` 可选 `httpConfigYaml` 和 `httpContext` 接收[独立 HTTP 规则](http-processing.md)与 baseDir/私密 CA。断开状态可准备来源;活动前台不使用默认 socket 获取资源。canonical Session Snapshot 固定主设置、实际 payload、原生基目录和公开 CA 指纹,参与 token;私钥仅在当前用户 DPAPI sidecar 中,entropy 绑定 package family 与 token,先发布 sidecar 再发布普通快照。随机密文不参与 token。 + +Session Host 完成 token 握手后才读取并解密 sidecar,复验 token、CA 身份和证私匹配;恢复实际规则不重读源文件。运行中的周期更新使用原 PhysicalBinding 的直接出口,只产生 pending;Stop 等待更新结束。私密 sidecar 随当前/上一份快照保留并同步清理,不进入普通缓存、Controller、日志或包通道。 + +主配置保持 8 MiB 上限,独立 HTTP 输入与实际来源合计保持 1 MiB;内部展开 HTTP 快照最多 8 MiB。普通 Session Snapshot 在写出前精确计算 JSON 转义后的大小,只预留实际所需字节;读取上限同时计入这两份内容、原生基目录和有界 backend 元数据。bridge 请求仍最多 1 MiB。公开 CA 身份使用证书 DER 的 SHA-256,不依赖 PEM 排版;真正使用 CA 时仍验证有效期、CA 约束和证私匹配。编译不代替实际 Windows DPAPI/VPN 验证。 diff --git a/docs/xhttp.md b/docs/xhttp.md index 48e87c6..00b3638 100644 --- a/docs/xhttp.md +++ b/docs/xhttp.md @@ -1,19 +1,19 @@ -# XHTTP 与 VLESS sing-mux +# XHTTP 与通用 sing-mux XHTTP/sing-mux 使用严格字段与类型;未知、null、无效或被忽略的组合在 IO 前拒绝。 -REALITY、JLS、静态 ECH 的身份规则见 [VLESS](vless.md),实际验证边界见[验收说明](acceptance.md)。 +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 探测关闭。 +H1/H2 可使用明文、标准 TLS 1.3、REALITY(经典默认、显式混合模式)、JLS 或 ShadowTLS v3。H3 使用官方 h3/h3-quinn 与 Quinn,必须使用标准 TLS 1.3,不允许同腿明文、REALITY、JLS 或 ShadowTLS。QUIC 收发走受控数据报,上游任一方向有效预算小于 1200 字节即拒绝,不绕开代理、protect 或接口绑定;发送从 1200 字节起探测 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(`@`)。 +`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"`,不接受数字、负数、倒置区间、空串或溢出。每次采样保持在闭区间内。 +区间字段接受字符串 `"N"` 或 `"N-M"`;`uplink-chunk-size`、`sc-max-each-post-bytes`、`sc-min-posts-interval-ms` 还接受 YAML 整数,使用相同的范围与配套校验。空串或纯空白使用字段默认,字符串中的数字及区间两端允许外围空白和正号。其他区间字段不接受数字类型;所有字段均拒绝 null、浮点数、布尔值、负数、倒置区间或溢出。每次采样保持在闭区间内。 | 字段 | 默认及约束 | | --- | --- | @@ -25,16 +25,16 @@ H1/H2 可使用明文、标准 TLS 1.3、REALITY(经典默认、显式混合 | `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 用于上传 | +| `uplink-http-method` | 默认 POST,接受合法的自定义 HTTP method token;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 | +| `uplink-chunk-size` | header/cookie 默认 `"0"` 自适应;非零区间小于64归一到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"`,不允许纯零 | +| `sc-min-posts-interval-ms` | packet-up,`"10"`,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`。 @@ -42,27 +42,59 @@ session/sequence 均支持 path/query/header/cookie。键使用至多 64 字节 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 同步等待。 +H3 响应 HEADERS 的解码字段预算为 16 KiB,编码 payload 预算为其 1.5 倍(24 KiB),在帧声明长度到达时检查;SETTINGS payload 最多 256 字节,携带单个 varint 的控制帧为 1–8 字节。未知非 DATA 帧也受 24 KiB 编码预算限制,超额提前拒绝,不等待完整 payload。这比 quiche 路径最多 16 MiB、流式跳过未知帧的范围更窄,以避免官方 h3 解码器累计整帧造成内存增长;DATA 仍增量交付,不受该整帧预算限制。 + +Quinn H3 接收适配层分别交付帧头和正文,不跨帧预读;至多保留一个原生接收块的剩余切片, +与原块共享内存。这使应用的读取进度持续约束 QUIC 接收额度,避免合并小 DATA 帧在解码层积压。 + +packet-up 在发送间隔内聚合小块写入,而不是把每个 write 变成一个 POST。最多保留一个批次,包括 HTTP 正在发送的正文;H1 与普通 H3 单响应路径每批最多 48 KiB,H2 每批最多 64 KiB,下述共享 H3 双响应路径每批最多 128 KiB,均与本连接采样的 POST 上限取较小值。普通路径在 POST 在途时向写入方施加背压,不另外积累下一批;头部 payload 仍按请求预算拆分。write 完成只表示数据已进入有界缓冲,flush 等待对应 POST 确认;计时器无需调用方再次读写即可发送。shutdown 立即尝试 flush 未发送批次,卡住的 POST 仍受一秒上限控制。计时器/上传任务归节点所有,Stop 同步等待。 + +批次缓冲按需扩容,分配容量也受该批次上限约束,避免不规则帧尺寸使倍增扩容超过预算;writer 不预分配完整批次。普通路径在 POST 确认后复用已独占的正文 backing;HTTP 尚持有引用时释放本地引用,不为回收再复制正文。每个 writer 至多保留这一批缓冲,逻辑连接关闭时释放。H3 的批次 backing 上限不等于进程 RSS 增量;HTTP/QUIC 编码及发送窗口可能同时持有其它有界数据。 + +VLESS 无 Encryption、无显式下载设置,且启用无 padding 的 h2mux、only-tcp=false 时,H3 packet-up 的 body/auto 上传串行发送正文,最多保留两个尚未退役的响应槽。正文仍只有一个不超过 128 KiB 的工作区:真实发送 FIN 完成且最后一个正文引用释放后,独占归还同一 Vec,才允许复用。每批增加一个有界 owner 元数据对象,不复制正文;两个响应槽满时连第三批预填也暂停。 同一qualified H3物理连接初始发送留存仍64KiB;只有该连接上一次业务mux UDP请求的元数据成功写入并flush后,才同步升至192KiB直到退役(不是peer确认)。同physical的TCP与UDP共享升档,未见UDP的其它physical及排除路径仍64KiB;不拆池、不增加队列/task/连接,不改变FIN/Stop。200 响应及不超过 16 KiB 的响应体读取成功,才按序确认连续前缀;后到先完成的响应仍占槽,flush 不跨过缺口。等待打开请求、发送、FIN 和缓冲归还时共同推进已有响应;错误取消未完成上传,Stop 沿用节点的取消与 join。其它路径保持单响应背压。 + +body/auto 模式的 POST 共享已冻结批次的不可变 backing,不额外复制一份业务负载; +发送任务和批次所有者分别释放引用,头部/cookie 模式继续使用有界编码。 +H1 编码保留正文 backing,不将整批正文再次拼入头部写缓冲;每次向所供应 IO 提供的正文片段最多 16 KiB,避免一次提交整批正文使 TLS 同时缓冲多个记录, +并保留原有 flush 确认与 Stop 屏障。 +H1 请求命令队列以指针槽持有完整请求,避免底层分块队列为空槽预留整个请求对象; +逻辑队列容量、请求顺序、正文共享及取消/Stop 所有权保持不变。 + +省略或留空间隔时,普通路径的部分批次使用 10 ms 聚合;上述共享 H3 双响应路径使用 2 ms。 +达到对应批次上限后立即提交,正文仍只使用一个有界工作区。 +显式配置的间隔与范围保持原值,满批仍遵守该间隔。 +未满批次及显式间隔以 POST 发起时间计时,前次交换和空闲时间计入间隔。 +空闲超过间隔后可立即发送新批次。底层准入和网络可能推迟发送,计时不代表线上首字节间隔。 ## 独立下载腿 `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`。 +可覆盖 `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`、`shadow-tls-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)。 +- `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。 +- shadow-tls-opts 缺省继承整个身份,非空对象须完整提供 version 3/password,`{}` 清除。仅 H1/H2,与 REALITY/JLS/ECH 互斥;cover 证书策略和 mTLS 按下载腿重算,模式切换须显式清除冲突身份。 +- ech-opts 缺省继承、对象整体替换,`{}` 清除。`enable: true` 使用非空静态 config 或节点 DNS 的 HTTPS key 5,静态优先;query-server-name 只覆盖查询名。按下载腿实际 TLS backend 从完整来源重选配置。仅标准 TLS,可用于 H1/H2/H3;切换到 REALITY/JLS/明文前必须显式清除继承的 ECH。失败不重拨或重放。 - 切换到明文或 REALITY 不会悄悄丢弃继承的证书策略;须显式清除冲突字段。每条腿独立重新校验最终安全配置。 -- 独立地址/端口在 prepare 时分别准备;两腿共享原绝对期限和每个上游组的选择快照,下载失败不会另起超时预算或绕过原图。 +- 独立地址/端口在实际建立物理连接时通过节点 resolver 分别解析;两腿共享原绝对期限和每个上游组的选择快照,下载失败不会另起超时预算或绕过原图。 ## HTTP transport 复用 -`reuse-settings` 缺省禁用节点池;`{}` 启用默认池。下载配置缺省继承整个主腿 reuse 对象,显式对象整体替换,未填写项回到零,不逐字段继承。 +`reuse-settings` 缺省时,H1 和通用 H2 transport 构造保持独立物理连接;H3 只共享活动物理连接,每条最多承载 4 个活动逻辑租约。 +仅 VLESS packet-up 且存在显式 download-settings、上传和下载均为 H2 与静态普通 TLS、JLS 或 REALITY 时,可按每腿的字面 IP、固定直接上游和固定物理出口证明共享活动连接,每条最多 4 个逻辑租约。上传与显式下载腿各有独立池,不跨节点或资源代复用;单腿、stream-one/stream-up、明文和其它版本组合保持原路径。 +REALITY 的公钥、short ID、SNI、指纹与混合密钥交换选项固定于各腿节点;新物理连接仍执行原生 REALITY 认证,不跨两腿或身份复用握手材料。 +实际租约同时受对端 HTTP/2 并发 stream 额度约束;收到 GOAWAY 后新逻辑请求另建物理连接,现有流继续排空,不重放已发送正文。 +域名、动态组/代理上游、文件客户端证书、动态 ECH、ShadowTLS、opaque protect、DNS/source/interface binding、自动出口发现、非 dual IP-version、MPTCP 和 Windows adapter binding 保持原独立路径;请求 TCP-control 的逻辑建链也独立。它们仍受支持,不增加配置拒绝。 +每次逻辑租约保留原路径预检:LazyDirect 使用字面端点 resolver,预解析 Direct 保留其端点而不新增 resolver 依赖,两者均保留 deadline/cancellation/地址族/IP4P 检查;Dialer 停机、节点 Stop、物理连接退役及原建链期限也覆盖等待池创建与命中活动连接的路径。mark、TFO、TLS 参数、HTTP 请求模板及队列预算保持原腿配置,不通过新默认 Dialer 重拨。 +最后一个租约释放后立即移出候选池,并进入既有的有界传输收尾流程,不保留空闲候选。 +这限制了并行连接的重复开销,不是全节点业务准入额度。 +`{}` 启用显式复用池,使用下表默认值与有界空闲缓存。 +下载配置缺省继承整个主腿 reuse 对象,显式对象整体替换,未填写项回到零,不逐字段继承。 | 字段 | 语义 | | --- | --- | @@ -75,31 +107,90 @@ packet-up 在发送间隔内聚合小块写入,而不是把每个 write 变成 前五项接受 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 可取消待写,不承诺异常终止交付。 +## 通用 sing-mux + +所有已启用的普通节点协议都接受 `smux`,字段为 enabled、protocol、max-connections、min-streams、max-streams、padding、only-tcp、statistic、brutal-opts。enabled 默认 false;protocol 缺省/空为 h2mux,也支持 smux v1/yamux。计数为带符号平台整数;负阈值禁用,两个正 max 字段同时配置时采用 max-connections 分支。padding、only-tcp、statistic 默认 false。Vision 与启用的 sing-mux 互斥。精简构建可独立启用 `sing-mux`,不要求 VLESS;未启用此 feature 时拒绝 enabled=true。 + +`smux.brutal-opts` 接受 `enabled/up/down`。enabled 默认 false;带宽默认为零, +裸数或纯数字字符串以 Mbps 为单位,也接受十进制 K/M/G/T 的 bps/Bps,b 表示位、 +B 表示字节。速率转换检查整数溢出;不接受小数、负数或二进制单位。 +开启后,每条新物理会话在业务准入前,沿同一个绝对建链期限完成一次 Mihomo +sing-mux 带宽协商;控制目标不进入业务路由、DNS 或连接表。down 告知服务端本端 +接收带宽,远端拒绝、截断、无效回复或超时使本次物理会话失败,错误不包含远端消息。 +本地是否安装 TCP Brutal 模块不影响远端协商。Linux 上,HTTP/SOCKS5、 +VLESS/VMess 的 RAW 或 WS、Trojan 的 RAW 或 WS,以及不启用插件 mux 的 SS2022, +可在确认独占且直连的物理 TCP 上设置本地发送侧 Brutal,速率取 up 与服务端接收 +带宽的较小值。保护、绑定与普通拨号路径相同,TFO 使用同一个受控 socket。 +其它平台、MPTCP、dialer-proxy 的代理首跳、gRPC/XHTTP、共享池、多腿和 QUIC +路径只协商远端发送侧,不透传本地 socket 控制能力。 + +Core 不安装或加载内核模块,不修改全局拥塞控制与系统规则。模块缺失、本地 +速率不适用或本地参数设置失败不把远端成功变成协商失败;参数设置失败会尝试 +恢复原拥塞控制,恢复失败则终止该物理 socket,避免保留意外默认发送速率。 +控制能力由物理 IO 所有者管理,关闭后立即失效,不复制 socket 或延长 Stop。 + +开启 Brutal 时,同一节点池同时只准入一条物理会话,不按 max-connections、 +min-streams 或 max-streams 扩容。yamux 仍保留累计 64 流退役边界,协商控制流 +也占一个流 ID;退役后等待既有租约全部释放,再为新请求建立会话,不并行复制 +完整配置带宽。等待使用调用方原期限,可被 Stop 取消;现有业务与关闭语义保持。 + +`statistic: true` 在 Running Session 的连接注册表记录每条真实底层认证流,类型 Inner、 +目标 sp.mux.sing-box.arpa:444,节点和 provider 名称共享不可变文本。复用流共用一条 +Inner 记录。它的字节单独可见,不重复计入业务 uploadTotal/downloadTotal;Controller +关闭此记录会终止底层 I/O 和所有兄弟复用流。退出/Stop 同步回收 driver 与记录。 + +空闲物理会话优先复用;max-connections>0 分支按连接数量与 min-streams 扩容,否则按 max-streams 阈值复用。两个 max 字段字面值均为零时 min-streams 归一为 8;归一在负阈值禁用之前执行,不把该值解释为所有调度分支的硬额度。每节点和资源代际的池独立,单流关闭不关闭兄弟流,节点 Stop 才取消和等待所有自有驱动。 + +物理建链、mux 请求元数据与 Brutal 控制协商沿用调用方的原绝对期限,复用不重置期限。 +业务流在首个正长度 payload 写入被底层接受,或读取到合法成功状态后,结束 setup 计时; +因此纯上传可持续运行,不因对端尚未产生下行 HTTP headers 或 mux 状态而超时。 +无业务首写时,首次读取与写入仍检查原期限;空 write、flush、shutdown 和空 read 不解除它。 +期限结束前未建立的流关闭 IO 与租约;迟到状态不能复活。解除期限后仍完整校验状态, +拒绝、非法状态或截断照常失败;不改变单流关闭、兄弟流与节点 Stop 的所有权。 + +smux v1 单流 shutdown 先等待已接受帧写入并 flush,再安排 FIN 并结束整条逻辑流。 +smux 的物理读写循环都遵守每轮 I/O 处理预算,主动让出调度。接收帧共用每连接 4 MiB 的分配预算,逐帧计入负载与管理费用,整帧释放后归还;有界单流队列的槽数由总预算推导,避免在总预算尚有余量时阻塞兄弟流。本地关闭立即释放排队数据,远端 FIN 仍在已收 DATA 排空后返回 EOF;写队列、flush 和帧顺序保持。 +VLESS XHTTP、无 VLESS Encryption 且未启用 sing-mux padding 时,smux DATA 帧的完成仅表示 +帧已完整进入自主驱动的底层 IO,不逐帧等待 POST 确认。显式 flush 仍通过同一有界命令队列 +等待真实底层 flush,shutdown 与 FIN 的顺序不变;其它路径保持逐批强 flush。 +该路径的 UDP 首帧保留原期限内的 setup flush,后续完整帧由 XHTTP 自有任务推进。 +最多仍保留一个待写 UDP 帧;取消后的偏移、首帧期限和 Stop 屏障保持。 +VLESS 的 PacketUp XHTTP、无 VLESS Encryption 且无 sing-mux padding 时,h2mux +普通 DATA 先申请既有 h2 容量,实际移交 Connection 驱动后返回;不逐帧等待 POST 确认。 +每个逻辑写端以固定确认状态合并进度,每次物理 flush 前至多登记一次;不按 DATA 分配 +oneshot,也不增加 payload 队列。显式 flush、元数据与首 UDP 帧仍等待真实底层 flush; +后续 UDP 尾帧无需额外 write 即可推进。其它 h2mux 和普通 gRPC/legacy H2/CONNECT 保持原行为。 +该私有路径在底层实际写出字节、但强 flush 尚待完成时,主动再调度一次已有的 H2 驱动, +使已排队 DATA 继续填充同一有界 PacketUp 批次。没有新写入进度便不重复唤醒, +也不提前完成 flush 确认;显式 flush、FIN 与 Stop 仍遵守原屏障。 +h2mux 和 yamux 关闭上传后保留读取与租约,允许对端继续发送尾部;h2mux 的上传 +数据须到达上游 IO 的 flush 边界再发送 END_STREAM。节点 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,不按声明长度无限分配。 +同一 UDP 来源、相同具体节点且目标 IP 无需还原时,多个目标共享一条逐包地址 mux 流; +Controller 仍逐目标记录与关闭业务连接。域名、fake-IP 和 QUIC 目标覆盖继续使用独立流, +避免响应地址歧义。发送至多保留一个受报文预算约束的帧及其写入进度,取消不会丢失 +已提交的前缀;下一次发送或接收先完成该帧与所需的 flush,再处理新报文。首帧仍受原建链 +期限约束,取消不会延长期限;实际 I/O 错误、超时与节点 Stop 终止该逻辑 UDP 流。 +显式关闭丢弃未完成帧并释放 IO,不额外等待发送;接收侧继续增量解析并保留取消进度。 + +sing-mux 的外层是底层协议的普通 TCP 到 `sp.mux.sing-box.arpa:444`,VLESS 使用 CommandTCP。对端必须提供 sing-mux 解码;普通 HTTP/SOCKS5 服务器不会自动解码,接受包装配置不等于默认服务器互通。only-tcp=false 时 UDP 使用 mux 的逐包地址格式,经受控解析处理域名,并提供有效 UDP 能力,即使底层 `udp: false`;true 时原样委托底层 UDP,保留其能力、编码、会话和预算。组 disable-udp 仍限制业务。padding 覆盖握手及最初 16 个物理写入帧;接收增量解析和丢弃 padding,不按声明长度无限分配。 ## 关闭、安全与验收边界 -XHTTP 按 Mihomo 整条逻辑连接关闭:shutdown 先尝试送完已接受的上传,再取消下载, +XHTTP 关闭整条逻辑连接:shutdown 先尝试送完已接受的上传,再取消下载, 所有模式的待决上传最多等待一秒;超时或节点 Stop 可以取消,异常终止不承诺交付。 -H1/H2 flush 等待已接受的 payload 到达所供应 IO 的 flush 边界;H3 flush 等待上传任务 -写入 QUIC,shutdown 进一步等待该请求上传的 QUIC 确认,再释放物理连接租约。 +H1/H2 流式 flush 等待已接受的 payload 到达所供应 IO 的 flush 边界;H3 stream-one / stream-up +flush 等待上传任务写入 QUIC。packet-up flush 等每批 POST 的成功响应与有界响应 body 结束。 +shutdown 提交上传 FIN 后释放物理连接租约,不提供逐请求的远端 ACK 屏障。 这些等待不依赖下载响应。需要完整回复的应用必须在关闭前读完;不承诺 TCP 半关闭 后继续收到尾包。复用只延长物理 transport 生命周期,不改变单个逻辑连接的关闭范围。 -H3 的 QUIC 驱动、数据报适配器和后台任务归节点所有。Stop 先执行有界关闭交换,再取消/join 内部任务和上游 IO,不把 Drop 或“等一会儿资源消失”当作完成屏障。 +H3 的 QUIC 驱动、数据报适配器和后台任务归节点所有。物理连接退役立即停止新请求准入, +在既有一秒关闭预算内继续发送和接收,再关闭并 join 内部任务与上游 IO;这段传输收尾 +不构成远端 ACK 屏障。未完成的上传取消会 reset 当前请求,已提交 FIN 的发送不因此 reset, +兄弟流继续。shutdown 返回不代表物理驱动已退出;Stop 返回以任务回收为准,不把 Drop 或等待时间当作完成证明。 H1/H2 原生验证优先使用官方 latest Mihomo;H3 使用官方 latest Xray。不被 Xray 直接解码的 packetaddr/sing-mux 使用明确标注的 Xray XHTTP → Mihomo VLESS 分层拓扑;mTLS 使用已批准的 xcaddy/Caddy H3 网关,后端仍是同一个 Xray handler。分层成功不改写直接对端失败。所有服务端与原站在隔离容器中,配置和私钥不入 Git;原生数据、公共入口、资源、跨平台构建和阶段签收分别记录。 diff --git a/packaging/linux/50-vole-resolved.rules b/packaging/linux/50-vole-resolved.rules new file mode 100644 index 0000000..c23f640 --- /dev/null +++ b/packaging/linux/50-vole-resolved.rules @@ -0,0 +1,12 @@ +// Installed authorization for the group allowed to execute the VPN binary. +polkit.addRule(function(action, subject) { + var actions = [ + "org.freedesktop.resolve1.set-dns-servers", + "org.freedesktop.resolve1.set-domains", + "org.freedesktop.resolve1.set-default-route", + "org.freedesktop.resolve1.revert" + ]; + if (subject.isInGroup("vole") && actions.indexOf(action.id) !== -1) { + return polkit.Result.YES; + } +}); diff --git a/packaging/linux/post-install b/packaging/linux/post-install new file mode 100755 index 0000000..4439aa9 --- /dev/null +++ b/packaging/linux/post-install @@ -0,0 +1,7 @@ +#!/bin/sh +# Run from deb postinst or rpm %post after placing the root-owned binary/rule. +set -eu +getent group vole >/dev/null || groupadd --system vole +chown root:vole /usr/bin/vole +chmod 0750 /usr/bin/vole +setcap cap_net_admin=ep /usr/bin/vole diff --git a/readme/README.ru.md b/readme/README.ru.md index 7b6f811..a5987d0 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -6,36 +6,44 @@ Vole — прокси-ядро на Rust для VPN-клиентов и локальных прокси, доступное как нативная библиотека и CLI переднего плана. Оно маршрутизирует TCP/UDP-трафик через прямые соединения, прокси-узлы, группы и цепочки, объединяя DNS, GeoData и кроссплатформенную плоскость данных TUN. -Конфигурация использует **совместимый с Mihomo YAML в пределах поддерживаемых возможностей**. Vole ориентирован на клиентские функции и не реализует все поля Mihomo или его полный Dashboard API. +Конфигурация использует **YAML в стиле Mihomo в пределах поддерживаемых возможностей**. Приоритет Vole — оптимизация памяти и достижение целевой пропускной способности. Совместимость означает взаимодействие с listener Mihomo и официальными серверами протоколов без требования одинакового поведения каждого поля; см. [принципы совместимости](../docs/mihomo-compatibility.md#兼容原则). ## Что умеет Vole - **Принимать трафик приложений и VPN:** пересылка HTTP, CONNECT и Upgrade; SOCKS5 CONNECT и UDP ASSOCIATE; IPv4/IPv6-пакеты TUN, предоставленные хостом. -- **Маршрутизировать по назначению:** правила по домену, суффиксу или ключевому слову домена, IP CIDR, порту назначения, TCP/UDP, GeoSite и GeoIP, с явными действиями DIRECT и REJECT. -- **Выбирать прокси и строить цепочки:** вложенные группы `select`, переключение групп во время работы и цепочки `dialer-proxy`, ссылающиеся на узлы или группы. Изменения выбора применяются к новым физическим транспортным соединениям, не перенося существующие. -- **Обрабатывать DNS:** управляемые UDP/TCP-серверы, политики выбора серверов по GeoSite, последовательное переключение при отказе, кеширование и объединение одинаковых запросов; перехват заданных TCP/UDP DNS-адресов в TUN (по умолчанию порт 53). -- **Определять трафик для маршрутизации:** извлечение домена из HTTP, TLS и QUIC, а также DNS-подсказки TUN, без изменения фактического назначения. На ICMPv4/ICMPv6 Echo ядро отвечает локально. -- **Управлять данными маршрутизации:** загрузка используемых категорий из `geosite.dat` / `geoip.dat` по запросу и обновление файлов через заданный маршрут. GeoSite поддерживает Domain, Full, Plain и Regex, пересечение атрибутов и инверсию; GeoIP поддерживает инверсию. На всех платформах выбранные записи сохраняются без жёсткого лимита количества или усечения; общая квота памяти GeoData не задана. См. [границы GeoData](../docs/geodata.md#内存与安全边界). -- **Предоставлять средства управления клиенту:** Controller на loopback-интерфейсе для выбора групп, текущей скорости и общего объёма TUN-трафика, а также изолированное измерение задержки узлов и цепочек через Invoke API. +- **Маршрутизировать трафик:** режимы rule/global/direct; домены, IP и порты источника и назначения, TCP/UDP, GeoSite/GeoIP, наборы правил и логические условия; правила процессов/UID на поддерживаемых платформах, именованные direct/dns/reject и встроенные действия маршрутизации. +- **Выбирать прокси и строить цепочки:** вложенные группы select/url-test/fallback/load-balance, проверки доступности и сохранение выбора; `dialer-proxy` ссылается на узлы или группы. Существующие соединения сохраняют свой маршрут. +- **Обрабатывать DNS:** hosts, отдельные bootstrap/узловые/прямые резолверы, UDP/TCP/DoH/DoT/DoQ, политики по доменам/GeoSite/rule-set, fallback, ограниченный кеш, redir-host и постоянные fake-IP; перехват DNS в TUN по конфигурации. +- **Определять трафик для маршрутизации:** HTTP/1, HTTP/2 и TLS для TUN и mixed TCP; QUIC v1/v2 для TUN и ассоциаций SOCKS5 UDP, условия принудительного анализа и исключения, настраиваемая замена назначения. Ответы на ICMPv4/ICMPv6 Echo формируются локально. +- **Управлять ресурсами:** HTTP/file/inline providers узлов и правил, подписки URI/Base64/AGE, динамические группы, фильтры и переопределения; YAML/Text и domain/ipcidr MRS. GeoData полностью загружает выбранные категории, поддерживает маршрутизируемые загрузки и периодические обновления; ошибки обновления сохраняют рабочее состояние. См. [границы GeoData](../docs/geodata.md#内存与安全边界). +- **Предоставлять управление:** состояние групп и ресурсов, список и закрытие соединений, счётчики правил, действующая конфигурация, ограниченные потоки WebSocket и DNS-диагностика; поддерживаемые обновления конфигурации, метрики TUN и изолированное измерение задержки. Границы обновлений описаны в [контракте Controller](../docs/controller-api.md). +- **Обрабатывать выбранный HTTP-трафик:** переписывание URL и перенаправления; выборочный MITM для явно разрешённых HTTPS-доменов с CA от хоста и ограниченная потоковая передача H1/H2. Используется [отдельная HTTP-конфигурация](../docs/http-processing.md). ## Прокси-протоколы -Все восемь протоколов ниже включены в сборку по умолчанию. Поддержка UDP настраивается для каждого узла. +В сборке по умолчанию включены перечисленные ниже протоколы. Поддержка UDP зависит от режима протокола и конфигурации узла. | Протокол | Возможности | | --- | --- | -| [VLESS](../docs/vless.md) | TCP, WebSocket / HTTPUpgrade, gRPC, HTTP-маскировка первого пакета, legacy H2, [XHTTP H1/H2/H3](../docs/xhttp.md); Vision, Encryption, REALITY, JLS, статический ECH и sing-mux в поддерживаемых сочетаниях | -| [VMess AEAD](../docs/outbounds.md#vmess-aead) | TCP, WebSocket / HTTPUpgrade, gRPC, HTTP-маскировка первого пакета и legacy H2; TCP/UDP, транспорт без TLS или со стандартным TLS | +| [VLESS](../docs/vless.md) | TCP, WebSocket / HTTPUpgrade, gRPC, HTTP-маскировка первого пакета, legacy H2, [XHTTP H1/H2/H3](../docs/xhttp.md); Vision, Encryption, REALITY, JLS, ECH и sing-mux в поддерживаемых сочетаниях | +| [VMess AEAD](../docs/outbounds.md#vmess-aead) | TCP, WebSocket / HTTPUpgrade, gRPC, HTTP-маскировка первого пакета и legacy H2; TCP/UDP, транспорт без TLS или с поддерживаемыми режимами TLS | | [Trojan](../docs/outbounds.md#trojan) | TCP/UDP через TLS TCP, WebSocket / HTTPUpgrade или gRPC | -| [Shadowsocks 2022](../docs/outbounds.md#shadowsocks-2022) | TCP/UDP; AES-128-GCM, AES-256-GCM и ChaCha20-Poly1305; цепочки идентификации AES; опциональный strict ShadowTLS v3 для TCP и UoT v2 | +| [Shadowsocks 2022](../docs/outbounds.md#shadowsocks-2022) | TCP/UDP; три алгоритма 2022 и цепочки идентификации AES; плагины obfs, WebSocket, JLS или strict ShadowTLS v3; UoT v2 | | [AnyTLS](../docs/outbounds.md#anytls) | TLS-сессии и UDP over TCP v2 | +| [HTTP / HTTPS CONNECT](../docs/outbounds.md#http--https-connect) | TCP CONNECT, опциональная аутентификация прокси и TLS | | [SOCKS5](../docs/outbounds.md#socks5) | CONNECT и UDP ASSOCIATE, с опциональной аутентификацией по имени пользователя и паролю | -| [Hysteria2](../docs/outbounds.md#hysteria2) | TCP/UDP через QUIC, управление пропускной способностью, Salamander, смена портов и mTLS | -| [TUIC v5](../docs/outbounds.md#tuic-v5) | TCP/UDP через QUIC, режимы передачи UDP native/quic и выбор алгоритма управления перегрузкой | +| [Hysteria2](../docs/outbounds.md#hysteria2) | TCP/UDP через QUIC, управление пропускной способностью, Salamander/Gecko, смена портов или Realm/STUN и mTLS | +| [TUIC v5](../docs/outbounds.md#tuic-v5) | TCP/UDP через QUIC, native/quic UDP, UoT v2, выбор алгоритма управления перегрузкой; `disable-sni` сохраняет проверку сертификата | +| [WireGuard](../docs/outbounds.md#wireguard) | TCP/UDP через стандартный WireGuard, несколько пиров и DNS узла, с пользовательским IP-стеком | +| [TrustTunnel](../docs/outbounds.md#trusttunnel) | TCP/UDP через H2/H3 CONNECT; аутентификация, проверки состояния, mTLS и ECH | +| [Mieru](../docs/outbounds.md#mieru) | TCP/UDP через транспорт TCP или UDP; мультиплексирование, STANDARD/NO-WAIT и traffic-pattern | +| [MASQUE](../docs/outbounds.md#masque) | TCP/UDP через H2/H3 CONNECT-IP; режим H3 L4 поддерживает только TCP | -Стандартные TLS-соединения поддерживают проверку сертификатов и закрепление сертификата по SHA-256. Поддерживаемые TCP-пути с TLS могут использовать шаблоны ClientHello Chrome, Firefox или Safari; `client-fingerprint` не зависит от `fingerprint` сертификата. Точные имена и сочетания описаны в [профилях TLS и политике сертификатов](../docs/tls-client-fingerprint.md). Эти функции не гарантируют неотличимость от браузера или произвольные сочетания протоколов. +VMess, VLESS, Trojan и AnyTLS поддерживают ShadowTLS v3 или JLS на своих поддерживаемых TCP-транспортах; VMess и Trojan также поддерживают REALITY. Ограничения транспортов и взаимоисключение режимов описаны в [общем контракте потоковой безопасности](../docs/outbounds.md#流式安全身份). -SS2022 использует официальную Rust-библиотеку без изменений; известные риски пустой первой записи, сценария server-first и padding описаны в [контракте Shadowsocks](../docs/outbounds.md#shadowsocks-2022). +Стандартные TLS-соединения поддерживают проверку сертификатов и закрепление сертификата по SHA-256. Поддерживаемые TCP-пути с TLS могут использовать шаблоны ClientHello Chrome, Firefox, Safari или iOS; `client-fingerprint` не зависит от `fingerprint` сертификата. Точные имена и сочетания описаны в [профилях TLS и политике сертификатов](../docs/tls-client-fingerprint.md). Эти функции не гарантируют неотличимость от браузера или произвольные сочетания протоколов. + +SS2022 использует официальные Rust-криптографию и кодеки без изменений; Vole инициализирует padding и обрабатывает server-first. См. [контракт Shadowsocks](../docs/outbounds.md#shadowsocks-2022). ## Конфигурация в стиле Mihomo @@ -70,13 +78,13 @@ rules: - MATCH,Proxy ``` -Замените адрес сервера и учётные данные в примере. Для правил GeoSite/GeoIP нужны соответствующие файлы в `/geodata`; без них эти типы правил недоступны. См. [полный справочник конфигурации](../docs/config.yaml) и [поведение GeoData](../docs/geodata.md). +Замените адрес сервера и учётные данные. GeoSite/GeoIP используют файлы в `/geodata`; отсутствующие необходимые ресурсы загружаются через заданный маршрут. До успешной загрузки соответствующие правила недоступны. См. [конфигурацию](../docs/config.yaml) и [поведение GeoData](../docs/geodata.md). `mixed-port` принимает HTTP и SOCKS5 на одном TCP-порту и включает SOCKS5 UDP на том же порту. `allow-lan` управляет адресом привязки независимо от `authentication`: если `authentication` отсутствует или равен `[]`, аутентификация не требуется ни на loopback, ни на всех интерфейсах; заданные учётные данные проверяются HTTP и SOCKS5. Поля верхнего уровня `port`, `socks-port`, `udp` и `listeners` отклоняются; `port` и `udp` прокси-узла сохраняют смысл параметров исходящего соединения. -Совместимость ограничена документированными полями и поведением и не распространяется на произвольные конфигурации Mihomo. Группы пока поддерживают статический `select`; DNS-серверы задаются буквальными IP-адресами и используют UDP/TCP. Providers, автоматический выбор в группах, зашифрованный DNS и fake-IP не входят в текущий набор возможностей. Неизвестные поля и недопустимые сочетания отклоняются, а не игнорируются; особенности семантики Vole отмечены в соответствующих контрактах. +Поддерживается документированное подмножество конфигурации Mihomo. Неизвестные поля и неподдерживаемые сочетания отклоняются. Ограничения платформ и протоколов описаны в [границах совместимости](../docs/mihomo-compatibility.md) и соответствующих контрактах. -Хост запускает экземпляр библиотеки с inline `configYaml`; устройство TUN, дескриптор, MTU, перехват DNS и тайм-аут UDP задаются в `tun`. Платформенные callbacks регистрируются локально в runtime. CLI передаёт параметры через тот же Invoke API, чья операция foreground читает файл `-f`. Адреса интерфейса, DNS и системные маршруты настраивает хост. +Хост запускает экземпляр библиотеки с inline `configYaml`; устройство TUN, дескриптор, MTU, перехват DNS и тайм-аут UDP задаются в `tun`. Платформенные callbacks регистрируются локально в runtime. CLI передаёт параметры через тот же Invoke API, чья операция foreground читает файл `-f`. Для собственного TUN в Linux ядро поддерживает автоматические маршруты и DNS через systemd-resolved; заимствованные fd и сеть платформ приложений остаются под управлением хоста. ## CLI @@ -97,12 +105,12 @@ vole -t -f ./config.yaml | iOS / macOS | Дескриптор utun от хоста; macOS также может открыть или создать нативный utun | | tvOS 17+ | Дескриптор файла utun, предоставленный хостом; ARM64-устройства и симулятор | | Android | Дескриптор `VpnService` с защитой исходящих сокетов | -| Linux | Настоящий raw-IP TUN с одной очередью через заимствованный fd или устройство, открытое ядром; системную сеть настраивает хост | +| Linux | Настоящий raw-IP TUN с одной очередью через заимствованный fd или устройство, открытое ядром; собственный TUN в Linux поддерживает автоматические маршруты и системный DNS | | Windows | Взаимоисключающие сборки `windows-wintun` для настольного процесса и `windows-uwp` для пакетного Provider/Session Host; общее ядро | -Vole предоставляет нативные библиотеки и CLI; настройка системной сети остаётся обязанностью хоста. На Unix исходный дескриптор TUN принадлежит хосту; Vole использует и закрывает собственную копию. Публичный API packetFlow от Apple не гарантирует доступ к raw fd, поэтому интеграция с Network Extension и проверка на устройствах остаются ответственностью хоста. См. [интеграцию TUN](../docs/tun-platform.md) и [границы приёмки платформ](../docs/acceptance.md). +Vole предоставляет нативные библиотеки и CLI. На Apple, Android и Windows системной сетью и жизненным циклом приложения управляет хост; собственный TUN в Linux дополнительно поддерживает автоматические маршруты и системный DNS. На Unix исходный дескриптор TUN принадлежит хосту; Vole использует и закрывает собственную копию. Публичный API packetFlow от Apple не гарантирует доступ к raw fd, поэтому интеграция с Network Extension и проверка на устройствах остаются ответственностью хоста. См. [интеграцию TUN](../docs/tun-platform.md) и [границы приёмки платформ](../docs/acceptance.md). -Настольный Wintun загружает предоставленный хостом `wintun.dll` из каталога исполняемого файла процесса. Адреса интерфейса, DNS, маршруты и физический выход настраивает хост; проверка Wintun на устройстве независима от результатов пакетного VPN. Использование CLI и поставка описаны в [документации CLI и релизов по тегам](../docs/cli.md). +Настольный Wintun загружает предоставленный хостом `wintun.dll` из каталога исполняемого файла процесса. Адреса интерфейса, DNS, маршруты и физический выход настраивает хост; Wintun и пакетный UWP требуют отдельных проверок на устройстве. Использование CLI и поставка описаны в [документации CLI и релизов по тегам](../docs/cli.md). Кроссплатформенный C ABI принимает JSON-запросы через Invoke API: @@ -113,20 +121,11 @@ void VoleFree(char *response); Один публичный экземпляр проходит жизненный цикл `initialize → createInstance → start(configYaml) → stop → destroyInstance`; подготовка выполняется внутри `start`. `validateConfig` не требует инициализации. CLI использует явную операцию foreground Invoke для файлов, окружения, сигналов и очистки. API также предоставляет запросы состояния, статус GeoData и измерение задержки. См. [Invoke API](../docs/invoke-api.md), [Controller API](../docs/controller-api.md) и [пример интеграции Windows](../example/windows-uwp/README.md). -## Benchmark - -[**TUN-бенчмарк Vole / Mihomo**](https://github.com/YuanDevTeam/container-benchmark) содержит воспроизводимую методику, результаты измерений и сравнительные графики для обоих ядер в одинаковой среде с нативным Linux TUN. - -Проект benchmark также выполняет проверку совместимости протоколов (`interop`) и тесты нагрузки на память (`stress`), используя явно указанный checkout `--source vole=PATH`. Собственные скрипты Vole только компилируют ядро и платформенные артефакты. - -Он измеряет смешанный TCP/UDP-трафик на **1 / 1,5 / 2 Гбит/с** с **1 000 DNS-запросов/с** и расширенными правилами `geosite:cn` / `geoip:cn`, показывая фактическую пропускную способность, CPU, наблюдаемый пиковый Linux RSS, потери UDP-пакетов и успешные DNS-запросы. Проверяется путь TUN/DNS/маршрутизации с выходом через DIRECT, а не пропускная способность зашифрованных прокси; Linux RSS не отражает потребление памяти Apple Network Extension. - -Отдельная команда `stress` использует те же полные категории `geosite:cn` / `geoip:cn` с нагрузкой 2 Гбит/с, 60 секунд и 1 000 DNS-запросов/с. Исходные DAT загружаются без усечения, но в память выбираются только категории CN. Опция `--geodata-update` добавляет реальную загрузку и перезагрузку во время трафика. Типы входных записей, измерения и ошибки приведены в README benchmark. Отдельное наблюдение не гарантирует память ниже 50 000 000 байт для произвольного входа и не заменяет проверку на устройствах Apple. - ## Документация - [Указатель документации](../docs/README.md) - [Справочник конфигурации](../docs/config.yaml) +- [Границы совместимости с Mihomo](../docs/mihomo-compatibility.md) - [Сборка ядра и платформенных артефактов](../scripts/README.md) - [CLI и релизы по тегам](../docs/cli.md) - [Регрессионные тесты ядра](../tests/README.md) @@ -138,6 +137,7 @@ void VoleFree(char *response); Vole использует публичные зависимости и опирается на реализации протоколов и примеры платформенной интеграции: - Зависимости TUN: локальный [`vole-netstack`](../crates/vole-netstack/README.md) использует [smoltcp](https://github.com/smoltcp-rs/smoltcp), а пакетный I/O на Unix и Windows Wintun — [tun-rs](https://github.com/tun-rs/tun-rs) (Apache-2.0). +- WireGuard: [boringtun](https://github.com/cloudflare/boringtun) (BSD-3-Clause), без host-device, FFI и JNI. - [Wintun](https://www.wintun.net/): DLL предоставляет хост; Vole её не включает. Исходный [заголовок API](https://github.com/tun-rs/tun-rs/blob/2.8.11/src/platform/windows/tun/wintun.h) в tun-rs имеет copyright 2018–2021 WireGuard LLC и лицензию `GPL-2.0 OR MIT`; при распространении связанных API bindings необходимо сохранить copyright и уведомление об альтернативной лицензии MIT. - Архитектурные ориентиры для сети и маршрутизации: [clash-rs](https://github.com/Watfaq/clash-rs), [netstack-smoltcp](https://github.com/cavivie/netstack-smoltcp), [Mihomo](https://github.com/MetaCubeX/mihomo), [Xray-core](https://github.com/XTLS/Xray-core) и [Leaf](https://github.com/eycorsican/leaf). Эти проекты не являются зависимостями netstack. - TLS и Shadowsocks: [rustls](https://github.com/rustls/rustls), [boring](https://github.com/cloudflare/boring), [BoringSSL](https://boringssl.googlesource.com/boringssl/) и [shadowsocks-rust](https://github.com/shadowsocks/shadowsocks-rust). Заимствованный код replay-window сохраняет [уведомления MIT](../src/outbound/shadowsocks/packet_window.rs). diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 0585008..b7b0371 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -6,36 +6,44 @@ Vole 是用于 VPN 客户端和本地代理的 Rust 代理内核,提供原生库和前台 CLI。它通过直连、代理节点、代理组和代理链转发 TCP/UDP 流量,并集成 DNS、GeoData 和跨平台 TUN 数据面。 -配置采用**与 Mihomo 兼容的 YAML,兼容范围限于 Vole 已支持的功能**。Vole 聚焦客户端能力,并未实现 Mihomo 的全部字段或完整 Dashboard API。 +配置采用 **Mihomo 风格的 YAML,范围限于 Vole 已支持的功能**。Vole 以内存优化和吞吐达标为第一目标;兼容要求是与 Mihomo listener 和官方协议 server 端互通,不寻求逐字段行为完全一致,见 [兼容原则](../docs/mihomo-compatibility.md#兼容原则)。 ## 内核能做什么 - **接入应用与 VPN 流量:**HTTP 转发、CONNECT 和 Upgrade;SOCKS5 CONNECT 和 UDP ASSOCIATE;宿主提供的 IPv4/IPv6 TUN 数据包。 -- **按目标路由:**支持域名、域名后缀/关键字、IP CIDR、目标端口、TCP/UDP、GeoSite 和 GeoIP 规则,以及明确的 DIRECT 和 REJECT 动作。 -- **选择和串联代理:**支持嵌套 `select` 组、实时切换组选择,以及引用节点或组的 `dialer-proxy` 代理链。选择变化只影响新建的物理传输连接,不迁移既有连接。 -- **处理 DNS:**支持指定出口的 UDP/TCP 上游、基于 GeoSite 的上游策略、顺序故障转移、缓存与重复查询合并;按配置拦截 TUN TCP/UDP DNS 目标,默认 53 端口。 -- **识别流量用于路由:**通过 HTTP、TLS、QUIC 域名嗅探与 TUN DNS 提示辅助路由,不改写实际目标地址。ICMPv4/ICMPv6 Echo 在本地应答。 -- **管理路由数据:**按需从 `geosite.dat` / `geoip.dat` 加载被引用的类别,并通过配置的路由更新文件。GeoSite 支持 Domain、Full、Plain 和 Regex、属性交集及反选;GeoIP 支持反选。所有平台完整保留所选记录,不设条数硬上限或截断,也不设 GeoData 总内存预算。详见 [GeoData 边界](../docs/geodata.md#内存与安全边界)。 -- **提供客户端控制:**回环 Controller 支持代理组选择及 TUN 流量速率/累计量查询;Invoke API 提供隔离的节点/代理链延迟测量。 +- **路由流量:**支持 rule/global/direct 模式,域名、来源/目标 IP 与端口、TCP/UDP、GeoSite/GeoIP、规则集合及逻辑规则;在支持的平台使用进程/UID 规则,提供具名 direct/dns/reject 出站与内置路由动作。 +- **选择和串联代理:**嵌套 select/url-test/fallback/load-balance 组、健康检查与选择持久化;`dialer-proxy` 可引用节点或组。既有连接保留原路径。 +- **处理 DNS:**hosts、独立 bootstrap/节点/直连解析角色,UDP/TCP/DoH/DoT/DoQ、域名/GeoSite/rule-set policy、fallback、有界缓存、redir-host 和持久 fake-IP;按配置接管 TUN DNS。 +- **识别流量用于路由:**TUN/mixed TCP 的 HTTP1/HTTP2/TLS 嗅探与 TUN、SOCKS5 UDP 关联的 QUIC v1/v2 嗅探,支持强制/排除条件和目标覆盖开关;本地应答 ICMPv4/ICMPv6 Echo。 +- **管理路由数据:**HTTP/file/inline 节点与规则 providers、URI/Base64/AGE 订阅、动态组、筛选和覆盖;YAML/Text 及 domain/ipcidr MRS。GeoData 完整加载引用类别,支持指定出口下载和周期更新,坏更新保留旧状态。详见 [GeoData 边界](../docs/geodata.md#内存与安全边界)。 +- **提供客户端控制:**组与资源状态、连接快照与关闭、规则命中、有效配置、有界 WebSocket 推送和 DNS 诊断;支持范围内的配置更新、TUN 流量与隔离的节点/代理链延迟测量。更新边界见 [Controller 契约](../docs/controller-api.md)。 +- **处理选定的业务 HTTP:**URL 改写和重定向,可使用宿主提供的 CA 对明确授权的 HTTPS 域名进行选择性解密,并以有界 H1/H2 流式转发。使用[独立 HTTP 配置](../docs/http-processing.md)。 ## 代理协议 -默认构建启用下列全部八种协议。UDP 支持按节点配置。 +默认构建启用下列协议。UDP 支持以协议模式和节点配置为准。 | 协议 | 能力 | | --- | --- | -| [VLESS](../docs/vless.md) | TCP、WebSocket / HTTPUpgrade、gRPC、HTTP 首包伪装、传统 H2、[XHTTP H1/H2/H3](../docs/xhttp.md);在支持的组合中提供 Vision、Encryption、REALITY、JLS、静态 ECH 和 sing-mux | -| [VMess AEAD](../docs/outbounds.md#vmess-aead) | TCP、WebSocket / HTTPUpgrade、gRPC、HTTP 首包伪装和传统 H2;TCP/UDP,明文或标准 TLS | +| [VLESS](../docs/vless.md) | TCP、WebSocket / HTTPUpgrade、gRPC、HTTP 首包伪装、传统 H2、[XHTTP H1/H2/H3](../docs/xhttp.md);在支持的组合中提供 Vision、Encryption、REALITY、JLS、ECH 和 sing-mux | +| [VMess AEAD](../docs/outbounds.md#vmess-aead) | TCP、WebSocket / HTTPUpgrade、gRPC、HTTP 首包伪装和传统 H2;TCP/UDP,明文或支持的 TLS 模式 | | [Trojan](../docs/outbounds.md#trojan) | 通过 TLS TCP、WebSocket / HTTPUpgrade 或 gRPC 传输 TCP/UDP | -| [Shadowsocks 2022](../docs/outbounds.md#shadowsocks-2022) | TCP/UDP;AES-128-GCM、AES-256-GCM 和 ChaCha20-Poly1305;AES 身份链;可选 strict ShadowTLS v3 TCP 包装和 UoT v2 | +| [Shadowsocks 2022](../docs/outbounds.md#shadowsocks-2022) | TCP/UDP;三种 2022 算法与 AES 身份链;可选 obfs、WebSocket、JLS 或 strict ShadowTLS v3 插件;UoT v2 | | [AnyTLS](../docs/outbounds.md#anytls) | TLS 会话与 UDP over TCP v2 | +| [HTTP / HTTPS CONNECT](../docs/outbounds.md#http--https-connect) | TCP CONNECT、可选代理认证与 TLS | | [SOCKS5](../docs/outbounds.md#socks5) | CONNECT 和 UDP ASSOCIATE,可选用户名/密码认证 | -| [Hysteria2](../docs/outbounds.md#hysteria2) | QUIC TCP/UDP、带宽控制、Salamander、端口跳跃和 mTLS | -| [TUIC v5](../docs/outbounds.md#tuic-v5) | QUIC TCP/UDP、native/quic UDP 转发模式,以及可选的拥塞控制算法 | +| [Hysteria2](../docs/outbounds.md#hysteria2) | QUIC TCP/UDP、带宽控制、Salamander/Gecko、端口跳跃或 Realm/STUN,以及 mTLS | +| [TUIC v5](../docs/outbounds.md#tuic-v5) | QUIC TCP/UDP、native/quic UDP 转发、UoT v2、拥塞控制选择;`disable-sni` 仍保留证书验证 | +| [WireGuard](../docs/outbounds.md#wireguard) | 标准 WireGuard TCP/UDP、多 peer 与节点 DNS,使用用户态 IP 栈 | +| [TrustTunnel](../docs/outbounds.md#trusttunnel) | H2/H3 CONNECT 承载 TCP/UDP,支持认证、健康检查、mTLS 和 ECH | +| [Mieru](../docs/outbounds.md#mieru) | TCP/UDP 载体承载 TCP/UDP,支持复用、STANDARD/NO-WAIT 握手与 traffic-pattern | +| [MASQUE](../docs/outbounds.md#masque) | H2/H3 CONNECT-IP 承载 TCP/UDP;H3 L4 模式仅支持 TCP | -标准 TLS 连接支持证书验证与 SHA-256 证书固定。适用的 TCP TLS 路径可使用 Chrome、Firefox 或 Safari ClientHello 模板;`client-fingerprint` 与证书 `fingerprint` 相互独立。准确的名称和支持组合见 [TLS 配置与证书策略](../docs/tls-client-fingerprint.md)。这些功能不保证与浏览器不可区分,也不支持任意协议组合。 +VMess、VLESS、Trojan 与 AnyTLS 可在各自支持的 TCP 载体上使用 ShadowTLS v3 或 JLS;VMess 与 Trojan 还支持 REALITY。传输限制与身份互斥遵循[共享流式安全约束](../docs/outbounds.md#流式安全身份)。 -SS2022 使用未经修改的官方 Rust 库;已知的空首写入、服务端先发送和 padding 风险见 [Shadowsocks 契约](../docs/outbounds.md#shadowsocks-2022)。 +标准 TLS 连接支持证书验证与 SHA-256 证书固定。适用的 TCP TLS 路径可使用 Chrome、Firefox、Safari 或 iOS ClientHello 模板;`client-fingerprint` 与证书 `fingerprint` 相互独立。准确的名称和支持组合见 [TLS 配置与证书策略](../docs/tls-client-fingerprint.md)。这些功能不保证与浏览器不可区分,也不支持任意协议组合。 + +SS2022 复用未经修改的官方 Rust 加密与编解码,由 Vole 初始化 padding 并处理 server-first,详见 [Shadowsocks 契约](../docs/outbounds.md#shadowsocks-2022)。 ## Mihomo 风格配置 @@ -70,13 +78,13 @@ rules: - MATCH,Proxy ``` -请替换示例中的服务器地址与凭据。GeoSite/GeoIP 规则需要 `/geodata` 下的对应数据文件;缺失文件时,相应规则类型不可用。详见[完整配置参考](../docs/config.yaml)与 [GeoData 行为](../docs/geodata.md)。 +请替换示例中的服务器地址与凭据。GeoSite/GeoIP 使用 `/geodata` 下的数据文件;缺失的必需资产通过配置出口下载,加载成功前对应规则不可用。详见[配置参考](../docs/config.yaml)与 [GeoData 行为](../docs/geodata.md)。 `mixed-port` 在同一 TCP 端口接入 HTTP 与 SOCKS5,并在同端口启用 SOCKS5 UDP。`allow-lan` 控制绑定地址,与 `authentication` 独立:`authentication` 省略或为 `[]` 时,回环与通配绑定均免认证;配置凭据后 HTTP 与 SOCKS5 均校验。顶层 `port`、`socks-port`、`udp` 和 `listeners` 都会拒绝;代理节点的 `port` 与 `udp` 保留出站含义。 -兼容范围限于文档列出的字段和行为,不覆盖任意 Mihomo 配置。代理组目前支持静态 `select`;DNS 上游使用固定 IP,通过 UDP/TCP 查询。Providers、自动代理组选择、加密 DNS 和 fake-IP 不在当前功能范围内。未知字段与无效组合会被拒绝,不会静默忽略;Vole 特有的语义会在相应契约中说明。 +兼容范围限于文档列出的 Mihomo 配置子集。未知字段与不支持的组合明确拒绝;当前平台和协议边界见 [Mihomo 兼容范围](../docs/mihomo-compatibility.md)与各项契约。 -库接口由宿主使用内联 `configYaml` 启动实例;TUN 设备、描述符、MTU、DNS 拦截和 UDP 超时归属于 `tun` 配置,平台回调仍在运行时本地注册。CLI 通过同一 Invoke API 传递参数,由其前台操作读取 `-f` 指定的配置。接口地址、DNS 与系统路由由宿主配置。 +库接口由宿主使用内联 `configYaml` 启动实例;TUN 设备、描述符、MTU、DNS 拦截和 UDP 超时归属于 `tun` 配置,平台回调仍在运行时本地注册。CLI 通过同一 Invoke API 传递参数,由其前台操作读取 `-f` 指定的配置。Linux 自建 TUN 可配置自动路由与 systemd-resolved DNS;借用 fd 和 App 平台的网络仍由宿主管理。 ## CLI @@ -97,12 +105,12 @@ vole -t -f ./config.yaml | iOS / macOS | 宿主提供 utun 文件描述符;macOS 也可打开或创建原生 utun | | tvOS 17+ | 宿主提供 utun 文件描述符;支持 ARM64 真机和模拟器目标 | | Android | `VpnService` 文件描述符与出站 socket 保护 | -| Linux | 借用 fd 或由内核打开设备的真实单队列 raw-IP TUN;宿主负责系统网络配置 | +| Linux | 借用 fd 或由内核打开设备的真实单队列 raw-IP TUN;Linux 自建设备支持自动路由与系统 DNS | | Windows | 编译时互斥的 `windows-wintun` 桌面后端与 `windows-uwp` 安装包 Provider/Session Host;共用内核 | -Vole 提供原生库和前台 CLI;系统网络配置仍由平台宿主负责。Unix 宿主持有原始 TUN 描述符;Vole 使用并关闭自己复制的描述符。Apple 公开的 packetFlow API 不保证可获取 raw fd,因此实际 Network Extension 集成和设备验证仍由宿主负责。详见 [TUN 集成](../docs/tun-platform.md)与[平台验收边界](../docs/acceptance.md)。 +Vole 提供原生库和前台 CLI。Apple、Android、Windows 的系统网络与 App 生命周期由宿主负责;Linux 自建 TUN 另外支持自动路由和系统 DNS。Unix 宿主持有原始 TUN 描述符;Vole 使用并关闭自己复制的描述符。Apple 公开的 packetFlow API 不保证可获取 raw fd,因此实际 Network Extension 集成和设备验证仍由宿主负责。详见 [TUN 集成](../docs/tun-platform.md)与[平台验收边界](../docs/acceptance.md)。 -桌面 Wintun 从进程可执行文件所在目录加载宿主提供的 `wintun.dll`。接口地址、DNS、路由与物理出口由宿主配置;Wintun 设备验收独立于已有安装包 VPN 结果。命令行使用与交付见 [CLI 与 tag 发布](../docs/cli.md)。 +桌面 Wintun 从进程可执行文件所在目录加载宿主提供的 `wintun.dll`。接口地址、DNS、路由与物理出口由宿主配置;Wintun 与安装包 UWP 分别进行设备验收。命令行使用与交付见 [CLI 与 tag 发布](../docs/cli.md)。 跨平台 C ABI 通过 Invoke API 接受 JSON 请求: @@ -113,20 +121,11 @@ void VoleFree(char *response); 单个公共实例遵循 `initialize → createInstance → start(configYaml) → stop → destroyInstance` 生命周期,准备过程由 `start` 内部完成;`validateConfig` 不要求初始化。CLI 通过显式的前台 Invoke 操作处理文件、环境默认值、信号与清理。API 还提供状态查询、GeoData 状态与延迟测量。详见 [Invoke API](../docs/invoke-api.md)、[Controller API](../docs/controller-api.md)与 [Windows 集成示例](../example/windows-uwp/README.md)。 -## Benchmark - -[**Vole / Mihomo TUN benchmark**](https://github.com/YuanDevTeam/container-benchmark) 提供两个内核在相同原生 Linux TUN 环境下的可复现测试设置、实测结果与对比图表。 - -benchmark 工程同时负责协议互通(`interop`)与内存压力(`stress`),通过显式 `--source vole=PATH` 提供被测 checkout;Vole 自有脚本只编译核心与平台产物。 - -测试使用 **1 / 1.5 / 2 Gbps** 混合 TCP/UDP 流量、**每秒 1,000 次 DNS 查询**和增强的 `geosite:cn` / `geoip:cn` 规则,报告实际吞吐量、CPU、观察到的 Linux 峰值 RSS、UDP 丢包与成功的 DNS 查询数。它使用 DIRECT 出口评估 TUN/DNS/路由路径,不衡量加密代理吞吐量;Linux RSS 不等同于 Apple Network Extension 内存占用。 - -独立 `stress` 命令使用同样的完整增强 `geosite:cn` / `geoip:cn`,叠加 2 Gbps / 60 秒 / 1,000 QPS DNS。原始 DAT 不裁剪,但只加载 CN 分类;可选 `--geodata-update` 在流量期间执行真实下载与重载。实际输入类型、测量和失败见 benchmark README。任何单次观测都不是任意输入低于 50,000,000 字节的保证,也不替代 Apple 真机验收。 - ## 文档 - [文档索引](../docs/README.md) - [配置参考](../docs/config.yaml) +- [Mihomo 兼容范围](../docs/mihomo-compatibility.md) - [核心与平台构建](../scripts/README.md) - [CLI 与 tag 发布](../docs/cli.md) - [核心回归测试](../tests/README.md) @@ -138,6 +137,7 @@ benchmark 工程同时负责协议互通(`interop`)与内存压力(`stress Vole 使用并参考以下公开依赖、协议实现和平台资料: - TUN 依赖:自有 [`vole-netstack`](../crates/vole-netstack/README.md) 使用 [smoltcp](https://github.com/smoltcp-rs/smoltcp),Unix 与 Windows Wintun 包 I/O 使用 [tun-rs](https://github.com/tun-rs/tun-rs)(Apache-2.0)。 +- WireGuard:[boringtun](https://github.com/cloudflare/boringtun)(BSD-3-Clause),不启用 host-device、FFI 或 JNI。 - [Wintun](https://www.wintun.net/):运行时 DLL 由宿主提供,不随 Vole 捆绑。tun-rs 中的上游 [API 头文件](https://github.com/tun-rs/tun-rs/blob/2.8.11/src/platform/windows/tun/wintun.h) 版权为 2018–2021 WireGuard LLC,许可证为 `GPL-2.0 OR MIT`;分发链接的 API bindings 时须保留其版权和 MIT 可选许可证声明。 - 网络与路由参考:[clash-rs](https://github.com/Watfaq/clash-rs)、[netstack-smoltcp](https://github.com/cavivie/netstack-smoltcp)、[Mihomo](https://github.com/MetaCubeX/mihomo)、[Xray-core](https://github.com/XTLS/Xray-core) 和 [Leaf](https://github.com/eycorsican/leaf)。这些参考项目并非 netstack 依赖。 - TLS 与 Shadowsocks:[rustls](https://github.com/rustls/rustls)、[boring](https://github.com/cloudflare/boring)、[BoringSSL](https://boringssl.googlesource.com/boringssl/) 和 [shadowsocks-rust](https://github.com/shadowsocks/shadowsocks-rust)。衍生的重放窗口代码保留了 [MIT 声明](../src/outbound/shadowsocks/packet_window.rs)。 diff --git a/scripts/README.md b/scripts/README.md index ea0e196..1c7d2e1 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,6 +1,7 @@ # 编译与发布 -在 Vole 根目录执行。需要 Rust、uv、C/C++、CMake、Perl 和 libclang,以及对应平台工具链。 +在 Vole 根目录执行。需要 Rust、uv、Git、C/C++、CMake、Perl 和 libclang,以及对应平台工具链。 +Linux 自动准备原生依赖还需要 curl、zip、unzip 和 tar。 ```sh uv run --project scripts --locked vole-scripts build cli @@ -34,6 +35,33 @@ Android 优先使用 `ANDROID_NDK_HOME`,否则在 `ANDROID_HOME/ndk` 中选择 `VOLE_ANDROID_NDK_VERSION` 可指定版本,`VOLE_ANDROID_API` 默认 24。 `CARGO_TARGET_DIR` 按 Cargo 约定覆盖中间产物目录;其余本地覆盖见 `builds.py`。 +所有编译入口自动准备官方 BLAKE3 1.8.7 C 静态库。依赖通过 +`native/blake3/vcpkg.json` 锁定正式 vcpkg registry/port,正常 bootstrap 和 install +使用宿主网络及用户缓存;首次构建需要联网。默认缓存位于 macOS 的 +`~/Library/Caches/vole/native`、Linux 的 `$XDG_CACHE_HOME/vole/native` +(未设置时为 `~/.cache/vole/native`)、Windows 的 `%LOCALAPPDATA%/vole/native`, +可用 `VOLE_NATIVE_CACHE_DIR` 覆盖。第三方源、工具、头文件和静态库留在依赖缓存, +不加入仓库或发布归档。自有 triplet 为 C 符号设置 `vole_c_` 编译宏;官方 port 和源码不改。 +Apple 使用对应 SDK 和上述最低版本,Android 使用同一 NDK/API,Windows 区分 +Windows/WindowsStore;Wintun 使用静态 CRT,UWP 的 Rust、BoringSSL 和 BLAKE3 +统一使用动态 CRT,BLAKE3 本身仍为静态库。UWP 宿主安装包负责声明并部署对应架构的 +Visual C++ Runtime 依赖;各目标安装目录独立。 + +直接执行 Cargo 时可先准备宿主依赖: + +```sh +uv run --project scripts --locked vole-scripts deps +``` + +该命令打印两个目标后缀环境变量;在当前 shell 中设置后执行 Cargo。 +Windows UWP 使用 `deps --backend uwp`,还需设置输出的 `CARGO_ENCODED_RUSTFLAGS`, +使 Rust 和原生依赖使用相同的动态 CRT。 +CI 使用 `deps --github-env "$GITHUB_ENV"` 自动传给后续步骤。 +显式提供 `VOLE_BLAKE3_INCLUDE_DIR_` 与 `VOLE_BLAKE3_LIB_DIR_` +可复用已准备的同目标 namespaced 静态包;`` 为大写 Rust target,连字符改成下划线。 +无后缀的两变量仅在宿主原生构建有效。缺少一项或路径无效直接失败,不回退到其他架构。 +平台构建命令本身不需要手工设置这些变量。 + ## 发布 [Release workflow](../.github/workflows/release.yml) 在 `vX.Y.Z` tag push 时构建并发布, diff --git a/scripts/cmake/windows-arm64.toolchain.cmake b/scripts/cmake/windows-arm64.toolchain.cmake index 14a748c..4e83c75 100644 --- a/scripts/cmake/windows-arm64.toolchain.cmake +++ b/scripts/cmake/windows-arm64.toolchain.cmake @@ -11,4 +11,12 @@ 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) +# Match Cargo's selected CRT, including UWP's dynamic CRT. An explicit setting +# from a native build script takes precedence over this toolchain default. +if(NOT DEFINED CMAKE_MSVC_RUNTIME_LIBRARY) + if("$ENV{CARGO_CFG_TARGET_FEATURE}" MATCHES "(^|,)crt-static(,|$)") + set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded) + else() + set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreadedDLL) + endif() +endif() diff --git a/scripts/native/blake3/namespace.cmake b/scripts/native/blake3/namespace.cmake new file mode 100644 index 0000000..273e2aa --- /dev/null +++ b/scripts/native/blake3/namespace.cmake @@ -0,0 +1,54 @@ +# Vole-owned package configuration. The official source and port stay intact. +# Only the blake3 package receives compile-time namespaces; dependency tools do not. +if(PORT STREQUAL "blake3") + set(_vole_blake3_symbols + 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 + blake3_compress_in_place_sse2 + blake3_compress_xof_sse2 + blake3_hash_many_sse2 + blake3_compress_in_place_sse41 + blake3_compress_xof_sse41 + blake3_hash_many_sse41 + blake3_hash_many_avx2 + blake3_compress_in_place_avx512 + blake3_compress_xof_avx512 + blake3_hash_many_avx512 + blake3_xof_many_avx512 + blake3_hash_many_neon + blake3_hash4_sse2 + blake3_hash4_sse41 + blake3_hash8_avx2 + blake3_hash4_avx512 + blake3_hash8_avx512 + blake3_hash16_avx512 + blake3_xof4_avx512 + blake3_xof8_avx512 + blake3_xof16_avx512 + ) + foreach(_vole_symbol IN LISTS _vole_blake3_symbols) + string(APPEND VCPKG_C_FLAGS " -D${_vole_symbol}=vole_c_${_vole_symbol}") + string(APPEND VCPKG_CXX_FLAGS " -D${_vole_symbol}=vole_c_${_vole_symbol}") + endforeach() + # Preprocessor namespaces apply to the official intrinsics C implementations. + # The unpreprocessed MSVC assembly exports cannot follow those definitions. + if(VCPKG_TARGET_ARCHITECTURE MATCHES "^(x64|x86)$") + list(APPEND VCPKG_CMAKE_CONFIGURE_OPTIONS "-DBLAKE3_SIMD_TYPE=x86-intrinsics") + endif() +endif() diff --git a/scripts/native/blake3/tvos.cmake b/scripts/native/blake3/tvos.cmake new file mode 100644 index 0000000..9ec0486 --- /dev/null +++ b/scripts/native/blake3/tvos.cmake @@ -0,0 +1,11 @@ +# vcpkg recognizes tvOS but has no dedicated chainload toolchain. Reuse its +# Apple compiler/flag setup after selecting CMake's tvOS platform explicitly. +set(CMAKE_SYSTEM_NAME tvOS CACHE STRING "") +# The main toolchain propagates Z_VCPKG_ROOT_DIR to nested try_compile checks. +# Initial port configuration supplies _VCPKG_ROOT_DIR before that cache exists. +if(DEFINED Z_VCPKG_ROOT_DIR) + set(_vole_vcpkg_root "${Z_VCPKG_ROOT_DIR}") +else() + set(_vole_vcpkg_root "${_VCPKG_ROOT_DIR}") +endif() +include("${_vole_vcpkg_root}/scripts/toolchains/ios.cmake") diff --git a/scripts/native/blake3/vcpkg.json b/scripts/native/blake3/vcpkg.json new file mode 100644 index 0000000..e6d532d --- /dev/null +++ b/scripts/native/blake3/vcpkg.json @@ -0,0 +1,18 @@ +{ + "name": "vole-native-dependencies", + "version-string": "0.1.0", + "builtin-baseline": "9eec002df1cd109f8f104cd966a763daf9751489", + "dependencies": [ + { + "name": "blake3", + "default-features": false + } + ], + "overrides": [ + { + "name": "blake3", + "version": "1.8.7", + "port-version": 0 + } + ] +} diff --git a/scripts/native/blake3/windows-arm64.cmake b/scripts/native/blake3/windows-arm64.cmake new file mode 100644 index 0000000..69e699d --- /dev/null +++ b/scripts/native/blake3/windows-arm64.cmake @@ -0,0 +1,16 @@ +# Use the same native ARM64 Clang/MSVC ABI as the Core build. The official +# vcpkg toolchain retains the selected SDK, CRT and UWP API-family restrictions. +set(CMAKE_C_COMPILER "$ENV{CC_aarch64_pc_windows_msvc}") +set(CMAKE_CXX_COMPILER "$ENV{CXX_aarch64_pc_windows_msvc}") +set(CMAKE_C_COMPILER_TARGET aarch64-pc-windows-msvc) +set(CMAKE_CXX_COMPILER_TARGET aarch64-pc-windows-msvc) +if(DEFINED Z_VCPKG_ROOT_DIR) + set(_vole_vcpkg_root "${Z_VCPKG_ROOT_DIR}") +else() + set(_vole_vcpkg_root "${_VCPKG_ROOT_DIR}") +endif() +if(CMAKE_SYSTEM_NAME STREQUAL "WindowsStore") + include("${_vole_vcpkg_root}/scripts/toolchains/uwp.cmake") +else() + include("${_vole_vcpkg_root}/scripts/toolchains/windows.cmake") +endif() diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml index 07ab29e..1fe618e 100644 --- a/scripts/pyproject.toml +++ b/scripts/pyproject.toml @@ -10,10 +10,10 @@ dependencies = [] vole-scripts = "vole_scripts.cli:main" [dependency-groups] -dev = ["ruff>=0,<1"] +dev = ["ruff>=0.17.3,<1"] [build-system] -requires = ["uv-build>=0.12,<0.13"] +requires = ["uv-build>=0.13.6,<0.14"] build-backend = "uv_build" [tool.ruff] diff --git a/scripts/src/vole_scripts/blake3.py b/scripts/src/vole_scripts/blake3.py new file mode 100644 index 0000000..77770a8 --- /dev/null +++ b/scripts/src/vole_scripts/blake3.py @@ -0,0 +1,263 @@ +"""Prepare the official static C package outside the source checkout.""" + +from __future__ import annotations + +import hashlib +import json +import platform +import re +import subprocess +from pathlib import Path + +CONFIG_DIR = Path(__file__).resolve().parents[2] / "native/blake3" +DEPENDENCY_NAMES = ("VOLE_BLAKE3_INCLUDE_DIR", "VOLE_BLAKE3_LIB_DIR") +ARCHITECTURES = {"aarch64": "arm64", "x86_64": "x64", "armv7": "arm"} +APPLE = { + "aarch64-apple-ios": ("iOS", "iphoneos", "IPHONEOS", "13.0"), + "aarch64-apple-ios-sim": ("iOS", "iphonesimulator", "IPHONEOS", "14.0"), + "aarch64-apple-darwin": ("Darwin", "macosx", "MACOSX", "11.0"), + "x86_64-apple-darwin": ("Darwin", "macosx", "MACOSX", "10.15"), + "aarch64-apple-tvos": ("tvOS", "appletvos", "TVOS", "17.0"), + "aarch64-apple-tvos-sim": ("tvOS", "appletvsimulator", "TVOS", "17.0"), +} +ANDROID_ABIS = { + "aarch64-linux-android": "arm64-v8a", + "x86_64-linux-android": "x86_64", + "armv7-linux-androideabi": "armeabi-v7a", +} + + +def _run(command: list[str], env: dict[str, str]) -> None: + subprocess.run(command, env=env, check=True) + + +def _quote(value: str | Path) -> str: + text = str(value).replace("\\", "/") + if "\n" in text or "\r" in text: + raise ValueError("native dependency paths cannot contain line breaks") + return '"' + text.replace("$", "\\$").replace('"', '\\"') + '"' + + +def _minimum(value: str, minimum: str) -> str: + if not re.fullmatch(r"\d+\.\d+(?:\.\d+)?", value): + raise ValueError("invalid Apple deployment target") + return max((value, minimum), key=lambda item: tuple(map(int, item.split(".")))) + + +def _triplet(target: str, env: dict[str, str], backend: str) -> tuple[str, list[Path]]: + try: + architecture = ARCHITECTURES[target.split("-", 1)[0]] + except KeyError as error: + raise ValueError("unsupported BLAKE3 target architecture") from error + windows = target.endswith("-pc-windows-msvc") + runtime = "static" if windows and backend != "uwp" else "dynamic" + lines = [ + f"set(VCPKG_TARGET_ARCHITECTURE {architecture})", + "set(VCPKG_LIBRARY_LINKAGE static)", + "set(VCPKG_BUILD_TYPE release)", + f"set(VCPKG_CRT_LINKAGE {runtime})", + "list(APPEND VCPKG_CMAKE_CONFIGURE_OPTIONS " + '"-DCMAKE_POSITION_INDEPENDENT_CODE=ON")', + ] + tracked = [CONFIG_DIR / "namespace.cmake"] + if target in APPLE: + system, sdk, deployment, minimum = APPLE[target] + key = deployment + "_DEPLOYMENT_TARGET" + env[key] = _minimum(env.get(key, minimum), minimum) + sdk_path = subprocess.run( + ["xcrun", "--sdk", sdk, "--show-sdk-path"], + env=env, + check=True, + stdout=subprocess.PIPE, + text=True, + ).stdout.strip() + lines += [ + f"set(VCPKG_CMAKE_SYSTEM_NAME {system})", + f"set(VCPKG_OSX_SYSROOT {_quote(sdk_path)})", + f"set(VCPKG_OSX_ARCHITECTURES " + f"{'x86_64' if architecture == 'x64' else 'arm64'})", + f"set(VCPKG_OSX_DEPLOYMENT_TARGET {env[key]})", + ] + if system == "tvOS": + tracked.append(CONFIG_DIR / "tvos.cmake") + lines.append(f"set(VCPKG_CHAINLOAD_TOOLCHAIN_FILE {_quote(tracked[-1])})") + elif target in ANDROID_ABIS: + ndk = Path(env["ANDROID_NDK_HOME"]).resolve() + api = env.get("VOLE_CMAKE_ANDROID_API", env.get("VOLE_ANDROID_API", "24")) + if not api.isdigit() or int(api) < 21: + raise ValueError("Android BLAKE3 API must be 21 or newer") + lines += [ + "set(VCPKG_CMAKE_SYSTEM_NAME Android)", + f"set(VCPKG_CMAKE_SYSTEM_VERSION {api})", + "set(VCPKG_ENV_PASSTHROUGH ANDROID_NDK_HOME)", + "list(APPEND VCPKG_CMAKE_CONFIGURE_OPTIONS " + f"{_quote('-DANDROID_ABI=' + ANDROID_ABIS[target])} " + f"{_quote('-DCMAKE_ANDROID_NDK=' + str(ndk))} " + '"-DANDROID_STL=c++_shared")', + ] + elif target.endswith("-unknown-linux-gnu"): + lines.append("set(VCPKG_CMAKE_SYSTEM_NAME Linux)") + elif windows: + if backend not in {"wintun", "uwp"}: + raise ValueError("Windows BLAKE3 backend must be wintun or uwp") + # vcpkg identifies desktop Windows with an empty system name, unlike + # CMake. A chainloaded Clang toolchain still needs the native SDK env. + system = "WindowsStore" if backend == "uwp" else '""' + lines += [ + f"set(VCPKG_CMAKE_SYSTEM_NAME {system})", + "set(VCPKG_LOAD_VCVARS_ENV ON)", + ] + # This is a C library, with no C++/CX or WinRT types. UWP still uses + # the official WindowsStore toolchain and its API-family definitions. + lines.append("set(VCPKG_MSVC_CXX_WINRT_EXTENSIONS OFF)") + if backend == "uwp": + lines.append( + "list(APPEND VCPKG_CMAKE_CONFIGURE_OPTIONS " + '"-DCMAKE_TRY_COMPILE_CONFIGURATION=Release" ' + '"-DVCPKG_MSVC_CXX_WINRT_EXTENSIONS=OFF" ' + '"-DCMAKE_TRY_COMPILE_PLATFORM_VARIABLES=' + 'VCPKG_MSVC_CXX_WINRT_EXTENSIONS")' + ) + if architecture == "arm64": + tracked.append(CONFIG_DIR / "windows-arm64.cmake") + lines += [ + f"set(VCPKG_CHAINLOAD_TOOLCHAIN_FILE {_quote(tracked[-1])})", + "set(VCPKG_ENV_PASSTHROUGH " + "CC_aarch64_pc_windows_msvc CXX_aarch64_pc_windows_msvc)", + ] + else: + raise ValueError("unsupported BLAKE3 Rust target") + lines.append( + "list(APPEND VCPKG_HASH_ADDITIONAL_FILES " + + " ".join(map(_quote, tracked)) + + ")" + ) + lines.append(f"include({_quote(CONFIG_DIR / 'namespace.cmake')})") + return "\n".join(lines) + "\n", tracked + + +def _cache_dir(env: dict[str, str]) -> Path: + if override := env.get("VOLE_NATIVE_CACHE_DIR"): + return Path(override).expanduser().resolve() + if platform.system() == "Windows": + root = Path(env["LOCALAPPDATA"]) + elif platform.system() == "Darwin": + root = Path.home() / "Library/Caches" + else: + root = Path(env.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))) + return root / "vole/native" + + +def _paths(target: str, include: str, lib: str) -> dict[str, str]: + suffix = "_" + target.upper().replace("-", "_") + archive = "blake3.lib" if target.endswith("-msvc") else "libblake3.a" + for value, name in ((include, "blake3.h"), (lib, archive)): + path = Path(value) + if not path.is_absolute() or not (path / name).is_file(): + raise RuntimeError(f"missing absolute BLAKE3 dependency path: {name}") + return dict( + zip((name + suffix for name in DEPENDENCY_NAMES), (include, lib), strict=True) + ) + + +def environment( + target: str, host: str, env: dict[str, str], backend: str = "wintun" +) -> dict[str, str]: + """Keep explicit dependency overrides; otherwise install the locked package.""" + env = env.copy() + suffix = "_" + target.upper().replace("-", "_") + overrides = [ + env[name + suffix] + if name + suffix in env + else env.get(name) + if target == host + else None + for name in DEPENDENCY_NAMES + ] + if any(value is not None for value in overrides): + if any(value is None for value in overrides): + raise RuntimeError("set both BLAKE3 include and library dependency paths") + return env | _paths(target, *overrides) + + manifest = (CONFIG_DIR / "vcpkg.json").read_bytes() + registry = json.loads(manifest)["builtin-baseline"] + triplet_text, tracked = _triplet(target, env, backend) + # Only our small package configuration enters this cache identity. Third- + # party source acquisition and package ABI tracking remain vcpkg's job. + identity = hashlib.sha256(manifest + triplet_text.encode()) + for path in tracked: + identity.update(path.read_bytes()) + triplet = f"{target.replace('_', '-')}-vole-{identity.hexdigest()[:12]}" + cache = _cache_dir(env) + overlay = cache / "triplets" + overlay.mkdir(parents=True, exist_ok=True) + (overlay / (triplet + ".cmake")).write_text(triplet_text, encoding="utf-8") + checkout = cache / ("vcpkg-" + registry) + if not (checkout / ".git").is_dir(): + _run( + [ + "git", + "clone", + "--filter=blob:none", + "--no-checkout", + "https://github.com/microsoft/vcpkg.git", + str(checkout), + ], + env, + ) + _run(["git", "-C", str(checkout), "checkout", "--detach", registry], env) + current = subprocess.run( + ["git", "-C", str(checkout), "rev-parse", "HEAD"], + env=env, + check=True, + stdout=subprocess.PIPE, + text=True, + ).stdout.strip() + if current != registry: + raise RuntimeError("managed vcpkg checkout does not match the locked registry") + windows = platform.system() == "Windows" + executable = checkout / ("vcpkg.exe" if windows else "vcpkg") + if not executable.is_file(): + bootstrap = checkout / ( + "bootstrap-vcpkg.bat" if windows else "bootstrap-vcpkg.sh" + ) + command = ( + [env.get("COMSPEC", "cmd.exe"), "/d", "/c", "call", str(bootstrap)] + if windows + else ["bash", str(bootstrap)] + ) + _run([*command, "-disableMetrics"], env) + host_architecture = ARCHITECTURES[host.split("-", 1)[0]] + host_system = {"Windows": "windows", "Darwin": "osx", "Linux": "linux"}[ + platform.system() + ] + installed = cache / "installed" / triplet + command = [ + str(executable), + "install", + f"--triplet={triplet}", + f"--host-triplet={host_architecture}-{host_system}", + f"--x-manifest-root={CONFIG_DIR}", + f"--overlay-triplets={overlay}", + f"--x-install-root={installed}", + "--disable-metrics", + "--x-wait-for-lock", + ] + try: + _run(command, env) + except subprocess.CalledProcessError: + for stage in ("detect_compiler", "blake3"): + logs = (checkout / "buildtrees" / stage).glob(f"config-{triplet}*-out.log") + for log in sorted(logs)[-2:]: + try: + with log.open("rb") as stream: + stream.seek(0, 2) + stream.seek(max(0, stream.tell() - 16_384)) + detail = stream.read(16_384).decode(errors="replace") + except OSError: + continue + print(f"\n{stage}/{log.name}:\n", flush=True) + print(detail, flush=True) + raise + package = installed / triplet + return env | _paths(target, str(package / "include"), str(package / "lib")) diff --git a/scripts/src/vole_scripts/builds.py b/scripts/src/vole_scripts/builds.py index 1f30556..080cc84 100644 --- a/scripts/src/vole_scripts/builds.py +++ b/scripts/src/vole_scripts/builds.py @@ -10,6 +10,8 @@ import tempfile from pathlib import Path +from . import blake3 + CORE_DIR = Path(__file__).resolve().parents[3] # Both transports enable the production core through invoke -> tun in Cargo.toml. DEFAULT_FEATURES = "ffi" @@ -105,11 +107,31 @@ def _profile() -> tuple[str, list[str]]: def _production_features(features: str) -> str: names = {name.rsplit("/", 1)[-1] for name in re.split(r"[,\s]+", features)} - if names & {"interop-test", "benchmark-geodata-http"}: + if "interop-test" in names: raise RuntimeError("platform builds cannot enable test-only features") return features +def _windows_runtime_environment( + target: str, env: dict[str, str], backend: str +) -> dict[str, str]: + if not target.endswith("-pc-windows-msvc") or backend != "uwp": + return env + # UWP supports the dynamic CRT. Preserve Cargo's environment flag priority, + # then override this checkout's desktop static-CRT default for all crates. + if "CARGO_ENCODED_RUSTFLAGS" in env: + encoded = env["CARGO_ENCODED_RUSTFLAGS"] + flags = encoded.split("\x1f") if encoded else [] + else: + target_flags = "CARGO_TARGET_" + target.upper().replace("-", "_") + "_RUSTFLAGS" + flags = env.get("RUSTFLAGS", env.get(target_flags, "")).split() + return env | { + "CARGO_ENCODED_RUSTFLAGS": "\x1f".join( + [*flags, "-C", "target-feature=-crt-static"] + ) + } + + def _cargo_build( target: str, profile_flags: list[str], @@ -118,6 +140,14 @@ def _cargo_build( artifacts: tuple[str, ...] = ("--lib",), ) -> Path: _production_features(features) + backend = "uwp" if "windows-uwp" in re.split(r"[,\s]+", features) else "wintun" + env = _windows_runtime_environment(target, env, backend) + env = blake3.environment( + target, + native_target(), + env, + backend, + ) _run( [ "cargo", @@ -139,6 +169,45 @@ def _cargo_build( return _cargo_target_dir(env) / target / profile +def prepare_native_dependencies( + target: str | None = None, backend: str = "wintun", github_env: Path | None = None +) -> None: + """Prepare the same package for direct Cargo checks outside platform builds.""" + host = native_target() + target = host if target is None else target + env = os.environ.copy() + if target in blake3.ANDROID_ABIS: + if platform.system() not in {"Darwin", "Linux"}: + raise RuntimeError("Android dependencies require macOS or Linux") + env["ANDROID_NDK_HOME"] = str(_android_ndk_home()) + elif target in APPLE_TARGETS: + if platform.system() != "Darwin": + raise RuntimeError("Apple dependencies require macOS") + for variable, override, default in ( + ("IPHONEOS", "VOLE_IOS_DEPLOYMENT_TARGET", "13.0"), + ("MACOSX", "VOLE_MACOS_DEPLOYMENT_TARGET", "10.15"), + ("TVOS", "VOLE_TVOS_DEPLOYMENT_TARGET", "17.0"), + ): + env.setdefault(variable + "_DEPLOYMENT_TARGET", _env(override, default)) + elif target != host: + raise ValueError("Linux and Windows dependencies require the native target") + if platform.system() == "Windows": + env = _windows_msvc_environment(CLI_TARGETS[host][1]) + env = _windows_runtime_environment(target, env, backend) + env = blake3.environment(target, host, env, backend) + suffix = "_" + target.upper().replace("-", "_") + entries = [ + f"{name + suffix}={env[name + suffix]}" for name in blake3.DEPENDENCY_NAMES + ] + if target.endswith("-pc-windows-msvc") and backend == "uwp": + entries.append("CARGO_ENCODED_RUSTFLAGS=" + env["CARGO_ENCODED_RUSTFLAGS"]) + if github_env is not None: + with github_env.open("a", encoding="utf-8") as stream: + stream.write("\n".join(entries) + "\n") + for entry in entries: + print(entry) + + def build_cli(target: str | None = None, profile: str = "release") -> Path: target = native_target() if target is None else target features = cli_features(target) @@ -176,7 +245,12 @@ def build_linux(target: str | None = None) -> Path: raise ValueError("Linux FFI build requires the native GNU Rust target") architecture = CLI_TARGETS[target][1] environment = os.environ.copy() - release = _cargo_build(target, ["--release"], DEFAULT_FEATURES, environment) + release = _cargo_build( + target, + ["--release"], + DEFAULT_FEATURES, + environment, + ) output = CORE_DIR / "dist/linux" / architecture shutil.rmtree(output, ignore_errors=True) (output / "include").mkdir(parents=True) diff --git a/scripts/src/vole_scripts/cli.py b/scripts/src/vole_scripts/cli.py index 9997cb6..c9ac152 100644 --- a/scripts/src/vole_scripts/cli.py +++ b/scripts/src/vole_scripts/cli.py @@ -4,8 +4,16 @@ import subprocess import sys from collections.abc import Sequence +from pathlib import Path -from .builds import build_android, build_apple, build_cli, build_linux, build_windows +from .builds import ( + build_android, + build_apple, + build_cli, + build_linux, + build_windows, + prepare_native_dependencies, +) def _parser() -> argparse.ArgumentParser: @@ -15,6 +23,17 @@ def _parser() -> argparse.ArgumentParser: ) commands = parser.add_subparsers(dest="command", required=True) + dependencies = commands.add_parser( + "deps", help="prepare official native dependencies" + ) + dependencies.add_argument( + "--target", help="Rust target triple (default: native host)" + ) + dependencies.add_argument("--backend", choices=("wintun", "uwp"), default="wintun") + dependencies.add_argument( + "--github-env", type=Path, help="append paths for CI Cargo checks" + ) + build = commands.add_parser("build", help="build platform artifacts") platforms = build.add_subparsers(dest="platform", required=True) for name, description in ( @@ -36,7 +55,9 @@ def _parser() -> argparse.ArgumentParser: def main(argv: Sequence[str] | None = None) -> int: args = _parser().parse_args(argv) try: - if args.platform == "cli": + if args.command == "deps": + prepare_native_dependencies(args.target, args.backend, args.github_env) + elif args.platform == "cli": build_cli(args.target, args.profile) elif args.platform == "apple": build_apple() diff --git a/scripts/tests/test_blake3.py b/scripts/tests/test_blake3.py new file mode 100644 index 0000000..9416b3b --- /dev/null +++ b/scripts/tests/test_blake3.py @@ -0,0 +1,232 @@ +"""Check target/SDK selection and package acquisition without native builds.""" + +import io +import json +import subprocess +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest.mock import patch + +from vole_scripts import blake3 + + +class Blake3DependencyTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.target = "aarch64-unknown-linux-gnu" + + def package(self, root): + for path in (root / "include/blake3.h", root / "lib/libblake3.a"): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"native fixture") + return str(root / "include"), str(root / "lib") + + def test_explicit_native_paths_do_not_install_packages(self): + include, lib = self.package(self.root) + env = dict(zip(blake3.DEPENDENCY_NAMES, (include, lib), strict=True)) + with patch.object(blake3, "_run") as run: + prepared = blake3.environment(self.target, self.target, env) + run.assert_not_called() + suffix = "_AARCH64_UNKNOWN_LINUX_GNU" + self.assertEqual(prepared[blake3.DEPENDENCY_NAMES[0] + suffix], include) + self.assertEqual(prepared[blake3.DEPENDENCY_NAMES[1] + suffix], lib) + + def test_partial_or_missing_explicit_packages_fail_before_acquisition(self): + for env in ( + {blake3.DEPENDENCY_NAMES[0]: str(self.root)}, + dict(zip(blake3.DEPENDENCY_NAMES, ("", ""), strict=True)), + dict( + zip(blake3.DEPENDENCY_NAMES, ("relative", str(self.root)), strict=True) + ), + ): + with ( + self.subTest(env=env), + patch.object(blake3, "_run") as run, + self.assertRaises(RuntimeError), + ): + blake3.environment(self.target, self.target, env) + run.assert_not_called() + + def test_cross_build_uses_scoped_paths_without_host_fallback(self): + include, lib = self.package(self.root) + env = dict( + zip(blake3.DEPENDENCY_NAMES, ("invalid host", "invalid host"), strict=True) + ) + suffix = "_AARCH64_UNKNOWN_LINUX_GNU" + env.update( + { + name + suffix: value + for name, value in zip( + blake3.DEPENDENCY_NAMES, (include, lib), strict=True + ) + } + ) + with patch.object(blake3, "_run") as run: + prepared = blake3.environment(self.target, "x86_64-apple-darwin", env) + run.assert_not_called() + self.assertEqual(prepared[blake3.DEPENDENCY_NAMES[0] + suffix], include) + + def test_all_apple_targets_select_their_sdk_and_minimum(self): + expected = ( + ("aarch64-apple-ios", "iOS", "iphoneos", "13.0"), + ("aarch64-apple-ios-sim", "iOS", "iphonesimulator", "14.0"), + ("aarch64-apple-darwin", "Darwin", "macosx", "11.0"), + ("x86_64-apple-darwin", "Darwin", "macosx", "10.15"), + ("aarch64-apple-tvos", "tvOS", "appletvos", "17.0"), + ("aarch64-apple-tvos-sim", "tvOS", "appletvsimulator", "17.0"), + ) + for target, system, sdk, minimum in expected: + with ( + self.subTest(target=target), + patch.object( + blake3.subprocess, + "run", + return_value=subprocess.CompletedProcess([], 0, "/SDK\n"), + ) as run, + ): + text, tracked = blake3._triplet(target, {}, "wintun") + self.assertEqual( + run.call_args.args[0], ["xcrun", "--sdk", sdk, "--show-sdk-path"] + ) + self.assertIn(f"set(VCPKG_CMAKE_SYSTEM_NAME {system})", text) + self.assertIn(f"set(VCPKG_OSX_DEPLOYMENT_TARGET {minimum})", text) + self.assertEqual( + any(path.name == "tvos.cmake" for path in tracked), system == "tvOS" + ) + + def test_android_keeps_the_selected_ndk_api_and_abi(self): + for target, abi in ( + ("aarch64-linux-android", "arm64-v8a"), + ("x86_64-linux-android", "x86_64"), + ): + with self.subTest(target=target): + text, _ = blake3._triplet( + target, + { + "ANDROID_NDK_HOME": str(self.root), + "VOLE_CMAKE_ANDROID_API": "28", + }, + "wintun", + ) + self.assertIn(f"-DANDROID_ABI={abi}", text) + self.assertIn("set(VCPKG_CMAKE_SYSTEM_VERSION 28)", text) + self.assertIn(str(self.root), text) + self.assertIn("-DCMAKE_POSITION_INDEPENDENT_CODE=ON", text) + + def test_windows_static_library_matches_backend_crt_and_app_api_family(self): + for architecture in ("aarch64", "x86_64"): + for backend, system in (("wintun", '""'), ("uwp", "WindowsStore")): + with self.subTest(architecture=architecture, backend=backend): + text, _ = blake3._triplet( + f"{architecture}-pc-windows-msvc", {}, backend + ) + runtime = "dynamic" if backend == "uwp" else "static" + self.assertIn(f"set(VCPKG_CRT_LINKAGE {runtime})", text) + self.assertIn("set(VCPKG_LIBRARY_LINKAGE static)", text) + self.assertIn(f"set(VCPKG_CMAKE_SYSTEM_NAME {system})", text) + self.assertIn("set(VCPKG_LOAD_VCVARS_ENV ON)", text) + if backend == "uwp": + self.assertIn("-DCMAKE_TRY_COMPILE_CONFIGURATION=Release", text) + self.assertIn("-DVCPKG_MSVC_CXX_WINRT_EXTENSIONS=OFF", text) + self.assertIn( + "-DCMAKE_TRY_COMPILE_PLATFORM_VARIABLES=" + "VCPKG_MSVC_CXX_WINRT_EXTENSIONS", + text, + ) + + def test_failed_install_reports_target_cmake_log_and_preserves_failure(self): + manifest = json.loads((blake3.CONFIG_DIR / "vcpkg.json").read_text()) + registry = manifest["builtin-baseline"] + checkout = self.root / ("vcpkg-" + registry) + (checkout / ".git").mkdir(parents=True) + (checkout / "vcpkg.exe").touch() + + def fail_install(command, environment): + triplet = next( + item.split("=", 1)[1] + for item in command + if item.startswith("--triplet=") + ) + logs = checkout / "buildtrees/detect_compiler" + logs.mkdir(parents=True) + (logs / f"config-{triplet}-out.log").write_text( + "outside diagnostic tail\n" + "x" * 20_000 + "\nSDK rc.exe missing\n" + ) + (logs / "config-other-triplet-out.log").write_text("unrelated build") + raise subprocess.CalledProcessError(29, command) + + output = io.StringIO() + with ( + patch.object(blake3.platform, "system", return_value="Windows"), + patch.object(blake3, "_run", side_effect=fail_install), + patch.object( + blake3.subprocess, + "run", + return_value=subprocess.CompletedProcess([], 0, registry + "\n"), + ), + redirect_stdout(output), + self.assertRaises(subprocess.CalledProcessError) as caught, + ): + blake3.environment( + "x86_64-pc-windows-msvc", + "x86_64-pc-windows-msvc", + {"VOLE_NATIVE_CACHE_DIR": str(self.root)}, + ) + self.assertEqual(caught.exception.returncode, 29) + self.assertIn("SDK rc.exe missing", output.getvalue()) + self.assertNotIn("outside diagnostic tail", output.getvalue()) + self.assertNotIn("unrelated build", output.getvalue()) + + def test_normal_install_uses_official_locked_registry_and_target_package(self): + manifest = json.loads((blake3.CONFIG_DIR / "vcpkg.json").read_text()) + registry = manifest["builtin-baseline"] + env = {"VOLE_NATIVE_CACHE_DIR": str(self.root)} + commands = [] + + def install(command, environment): + commands.append(command) + if command[:2] == ["git", "clone"]: + checkout = Path(command[-1]) + (checkout / ".git").mkdir(parents=True) + (checkout / "vcpkg").write_bytes(b"tool fixture") + elif "install" in command: + triplet = next( + item.split("=", 1)[1] + for item in command + if item.startswith("--triplet=") + ) + installed = next( + item.split("=", 1)[1] + for item in command + if item.startswith("--x-install-root=") + ) + self.package(Path(installed) / triplet) + + target = "x86_64-unknown-linux-gnu" + with ( + patch.object(blake3.platform, "system", return_value="Linux"), + patch.object(blake3, "_run", side_effect=install), + patch.object( + blake3.subprocess, + "run", + return_value=subprocess.CompletedProcess([], 0, registry + "\n"), + ), + ): + prepared = blake3.environment(target, target, env) + self.assertEqual(commands[0][4], "https://github.com/microsoft/vcpkg.git") + self.assertEqual(commands[1][-1], registry) + self.assertIn("--host-triplet=x64-linux", commands[-1]) + triplet = next( + item.split("=", 1)[1] + for item in commands[-1] + if item.startswith("--triplet=") + ) + self.assertRegex(triplet, r"^[a-z0-9-]+$") + self.assertIn("--x-wait-for-lock", commands[-1]) + self.assertIn("--x-manifest-root=" + str(blake3.CONFIG_DIR), commands[-1]) + include = prepared["VOLE_BLAKE3_INCLUDE_DIR_X86_64_UNKNOWN_LINUX_GNU"] + self.assertTrue((Path(include) / "blake3.h").is_file()) diff --git a/scripts/tests/test_builds.py b/scripts/tests/test_builds.py index 49b9466..d2ecd04 100644 --- a/scripts/tests/test_builds.py +++ b/scripts/tests/test_builds.py @@ -1,12 +1,14 @@ """Check platform outputs around the external compiler boundary.""" +import io import os import tempfile import unittest +from contextlib import redirect_stdout from pathlib import Path from unittest.mock import MagicMock, patch -from vole_scripts import builds +from vole_scripts import builds, cli class BuildTests(unittest.TestCase): @@ -25,6 +27,13 @@ def setUp(self): ) self.commands = [] self.enterContext(patch.object(builds, "_run", side_effect=self.compile)) + self.enterContext( + patch.object( + builds.blake3, + "environment", + side_effect=lambda target, host, env, backend: env, + ) + ) def write(self, relative, data=b"library"): path = self.root / relative @@ -39,6 +48,7 @@ def compile(self, command, *, env): elif command[:2] == ["cargo", "build"]: target = command[command.index("--target") + 1] for name in ( + "vole", "vole.exe", "vole.dll", "vole.dll.lib", @@ -98,6 +108,98 @@ def test_windows_cli_and_ffi_select_their_backends(self): ) self.assertEqual("--bins" in command, backend == "uwp") + def test_linux_cli_and_ffi_preserve_transport_features(self): + target = "aarch64-unknown-linux-gnu" + with ( + patch.object(builds.platform, "system", return_value="Linux"), + patch.object(builds, "native_target", return_value=target), + ): + builds.build_cli() + builds.build_linux() + self.assertEqual( + [command[command.index("--features") + 1] for command, _ in self.commands], + ["cli", "ffi"], + ) + + def test_uwp_build_selects_dynamic_crt_and_preserves_cargo_flag_precedence(self): + target_flags = "CARGO_TARGET_AARCH64_PC_WINDOWS_MSVC_RUSTFLAGS" + cases = ( + ({}, []), + ({target_flags: "-C debuginfo=1"}, ["-C", "debuginfo=1"]), + ( + {target_flags: "--cfg ignored", "RUSTFLAGS": "-C opt-level=2"}, + ["-C", "opt-level=2"], + ), + ( + { + target_flags: "--cfg ignored", + "RUSTFLAGS": "--cfg ignored_too", + "CARGO_ENCODED_RUSTFLAGS": '--cfg\x1fvalue="with space"', + }, + ["--cfg", 'value="with space"'], + ), + ({"CARGO_ENCODED_RUSTFLAGS": "", "RUSTFLAGS": "--cfg ignored"}, []), + ) + for overrides, expected in cases: + original = dict(os.environ) | overrides + with ( + self.subTest(overrides=overrides), + patch.object(builds.platform, "system", return_value="Windows"), + patch.object(builds, "_windows_architecture", return_value="arm64"), + patch.object( + builds, "_windows_msvc_environment", return_value=original + ), + ): + builds.build_windows("uwp") + self.assertEqual( + self.commands[-1][1] + .get("CARGO_ENCODED_RUSTFLAGS", "") + .split("\x1f"), + [*expected, "-C", "target-feature=-crt-static"], + ) + self.assertEqual(original, dict(os.environ) | overrides) + with ( + patch.object(builds.platform, "system", return_value="Windows"), + patch.object(builds, "_windows_architecture", return_value="arm64"), + patch.object( + builds, "_windows_msvc_environment", return_value=dict(os.environ) + ), + ): + builds.build_windows("wintun") + self.assertNotIn("CARGO_ENCODED_RUSTFLAGS", self.commands[-1][1]) + + def test_direct_uwp_dependencies_export_matching_cargo_crt_flags(self): + output = io.StringIO() + github_env = self.root / "github.env" + + def dependency_environment(target, host, env, backend): + self.assertEqual((target, host), ("aarch64-pc-windows-msvc",) * 2) + self.assertEqual(backend, "uwp") + suffix = "_AARCH64_PC_WINDOWS_MSVC" + return env | { + name + suffix: str(self.root / name) + for name in builds.blake3.DEPENDENCY_NAMES + } + + with ( + patch.object(builds.platform, "system", return_value="Windows"), + patch.object(builds, "_windows_architecture", return_value="arm64"), + patch.object( + builds, "_windows_msvc_environment", return_value=dict(os.environ) + ), + patch.object( + builds.blake3, "environment", side_effect=dependency_environment + ), + redirect_stdout(output), + ): + builds.prepare_native_dependencies(backend="uwp", github_env=github_env) + self.assertEqual(output.getvalue(), github_env.read_text()) + self.assertEqual(output.getvalue().count("\n"), 3) + self.assertIn( + "CARGO_ENCODED_RUSTFLAGS=-C\x1ftarget-feature=-crt-static\n", + output.getvalue(), + ) + def test_windows_requires_native_python(self): registry = MagicMock() with patch.dict("sys.modules", {"winreg": registry}): @@ -211,6 +313,10 @@ def test_android_uses_matching_api_and_cpp_runtime(self): for command, env in self.commands: target = command[command.index("--target") + 1] abi = env["VOLE_CMAKE_ANDROID_ABI"] + self.assertEqual( + command[command.index("--features") + 1], + "ffi", + ) self.assertEqual( env[f"BINDGEN_EXTRA_CLANG_ARGS_{target}"], f"--target={target}28" ) @@ -244,6 +350,13 @@ def test_apple_build_stages_five_slices_from_six_targets(self): self.assertEqual( sum(command[:2] == ["cargo", "build"] for command, _ in self.commands), 6 ) + self.assertTrue( + all( + command[command.index("--features") + 1] == "ffi" + for command, _ in self.commands + if command[:2] == ["cargo", "build"] + ) + ) self.assertEqual( self.commands[-1][0][:2], ["xcodebuild", "-create-xcframework"] ) @@ -264,6 +377,23 @@ def test_apple_build_stages_five_slices_from_six_targets(self): (work / "ios-simulator/libvole.a").read_bytes(), b"aarch64-apple-ios-sim" ) + def test_apple_preserves_production_override_and_rejects_test_features(self): + with ( + patch.object(builds.platform, "system", return_value="Darwin"), + patch.dict(os.environ, {"VOLE_FEATURES": "ffi,controller"}), + ): + builds.build_apple_target("aarch64-apple-ios") + command = self.commands[-1][0] + self.assertEqual(command[command.index("--features") + 1], "ffi,controller") + self.commands.clear() + with ( + patch.object(builds.platform, "system", return_value="Darwin"), + patch.dict(os.environ, {"VOLE_FEATURES": "ffi,interop-test"}), + self.assertRaisesRegex(RuntimeError, "test-only features"), + ): + builds.build_apple_target("aarch64-apple-ios") + self.assertEqual(self.commands, []) + def test_ndk_selection_uses_latest_installed_stable_revision(self): for version in ("30.2.9", "30.2.10", "30.10.1", "30.99.1-beta1"): folder = version.removesuffix("-beta1") @@ -273,3 +403,22 @@ def test_ndk_selection_uses_latest_installed_stable_revision(self): ) with patch.dict(os.environ, {"ANDROID_HOME": str(self.root / "sdk")}): self.assertEqual(builds._android_ndk_home(), self.root / "sdk/ndk/30.10.1") + + +class BuildCliTests(unittest.TestCase): + def test_arguments_reach_each_platform_builder(self): + for platform in ("cli", "apple", "android", "windows", "linux"): + args = ["build", platform] + positional = () + if platform == "cli": + args += ["--target", "aarch64-apple-darwin", "--profile", "debug"] + positional = ("aarch64-apple-darwin", "debug") + elif platform == "windows": + args += ["--backend", "wintun"] + positional = ("wintun",) + with ( + self.subTest(platform=platform), + patch.object(cli, "build_" + platform) as build, + ): + self.assertEqual(cli.main(args), 0) + build.assert_called_once_with(*positional) diff --git a/scripts/uv.lock b/scripts/uv.lock index d2a6d94..bfdb7ef 100644 --- a/scripts/uv.lock +++ b/scripts/uv.lock @@ -4,27 +4,27 @@ requires-python = ">=3.11" [[package]] name = "ruff" -version = "0.16.10" +version = "0.17.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" }, - { url = "https://files.pythonhosted.org/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" }, - { url = "https://files.pythonhosted.org/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" }, - { url = "https://files.pythonhosted.org/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" }, - { url = "https://files.pythonhosted.org/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" }, - { url = "https://files.pythonhosted.org/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" }, - { url = "https://files.pythonhosted.org/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" }, - { url = "https://files.pythonhosted.org/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" }, - { url = "https://files.pythonhosted.org/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" }, - { url = "https://files.pythonhosted.org/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" }, - { url = "https://files.pythonhosted.org/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" }, - { url = "https://files.pythonhosted.org/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" }, - { url = "https://files.pythonhosted.org/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" }, - { url = "https://files.pythonhosted.org/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" }, - { url = "https://files.pythonhosted.org/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" }, + { url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" }, + { url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" }, + { url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" }, + { url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" }, + { url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" }, + { url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" }, + { url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" }, + { url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" }, ] [[package]] @@ -40,4 +40,4 @@ dev = [ [package.metadata] [package.metadata.requires-dev] -dev = [{ name = "ruff", specifier = ">=0,<1" }] +dev = [{ name = "ruff", specifier = ">=0.17.3,<1" }] diff --git a/src/cli.rs b/src/cli.rs index bcbaff9..5dcfca9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,7 +6,8 @@ use std::{ path::PathBuf, process::ExitCode, }; -use vole::invoke::{InvokePath, invoke_bytes}; +use vole::invoke::{InvokePath, invoke_owned_bytes}; +use zeroize::{Zeroize, Zeroizing}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Mode { Help, @@ -15,11 +16,28 @@ enum Mode { Run, } -#[derive(Debug, PartialEq, Eq)] +#[derive(PartialEq, Eq)] struct Options { mode: Mode, data_dir: Option, config_file: Option, + config_string: Option>, + age_secret_key: Option>, + external_controller: Option>, + external_controller_tls: Option>, + external_controller_unix: Option>, + external_controller_routing_mark: Option, + secret: Option>, + http_config_file: Option, + http_ca_file: Option, +} + +impl std::fmt::Debug for Options { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Options") + .field("mode", &self.mode) + .finish_non_exhaustive() + } } impl Options { @@ -32,6 +50,15 @@ impl Options { let (mut version, mut validate) = (false, false); let mut data_dir = data_dir.map(PathBuf::from); let mut config_file = config_file.map(PathBuf::from); + let mut config_string = None; + let mut age_secret_key = None; + let mut external_controller = None; + let mut external_controller_tls = None; + let mut external_controller_unix = None; + let mut external_controller_routing_mark = None; + let mut secret = None; + let mut http_config_file = None; + let mut http_ca_file = None; while let Some(argument) = arguments.next() { let Some((flag, inline)) = flag_parts(&argument)? else { // Like Go flag, a positional argument or -- ends parsing. @@ -45,6 +72,15 @@ impl Options { mode: Mode::Help, data_dir, config_file, + config_string, + age_secret_key, + external_controller, + external_controller_tls, + external_controller_unix, + external_controller_routing_mark, + secret, + http_config_file, + http_ca_file, }); } b"v" | b"t" => { @@ -55,6 +91,57 @@ impl Options { validate = value; } } + b"ext-ctl-routing-mark" => { + let value = inline + .map(OsStr::to_owned) + .or_else(|| arguments.next()) + .ok_or(CliError::argument( + "missing value for -ext-ctl-routing-mark", + ))?; + external_controller_routing_mark = Some(parse_go_int(&value)?); + } + b"config" | b"age-secret-key" | b"ext-ctl" | b"ext-ctl-tls" | b"ext-ctl-unix" + | b"secret" => { + let value = inline + .map(OsStr::to_owned) + .or_else(|| arguments.next()) + .ok_or(CliError::argument(match flag { + b"config" => "missing value for -config", + b"age-secret-key" => "missing value for -age-secret-key", + b"ext-ctl" => "missing value for -ext-ctl", + b"ext-ctl-tls" => "missing value for -ext-ctl-tls", + b"ext-ctl-unix" => "missing value for -ext-ctl-unix", + _ => "missing value for -secret", + }))?; + let value = value.into_string().map(Zeroizing::new).map_err(|value| { + drop(Zeroizing::new(value.into_encoded_bytes())); + CliError::argument("text option requires UTF-8") + })?; + match flag { + b"config" => config_string = Some(value), + b"age-secret-key" => age_secret_key = Some(value), + b"ext-ctl" => external_controller = Some(value), + b"ext-ctl-tls" => external_controller_tls = Some(value), + b"ext-ctl-unix" => external_controller_unix = Some(value), + _ => secret = Some(value), + } + drop(Zeroizing::new(argument.into_encoded_bytes())); + } + b"http-config" | b"http-ca" => { + let value = inline + .map(OsStr::to_owned) + .or_else(|| arguments.next()) + .ok_or(CliError::argument(if flag == b"http-ca" { + "missing value for -http-ca" + } else { + "missing value for -http-config" + }))?; + if flag == b"http-ca" { + http_ca_file = Some(PathBuf::from(value)); + } else { + http_config_file = Some(PathBuf::from(value)); + } + } b"d" | b"f" => { let value = inline .map(OsStr::to_owned) @@ -83,6 +170,15 @@ impl Options { }, data_dir, config_file, + config_string, + age_secret_key, + external_controller, + external_controller_tls, + external_controller_unix, + external_controller_routing_mark, + secret, + http_config_file, + http_ca_file, }) } } @@ -110,6 +206,87 @@ fn flag_parts(argument: &OsStr) -> Result>, CliError> { } } +// Go flag.IntVar uses strconv.ParseInt(base=0, bitSize=strconv.IntSize). +fn parse_go_int(value: &OsStr) -> Result { + let invalid = || CliError::argument("invalid integer option value"); + let mut digits = value.as_encoded_bytes(); + let negative = digits.first() == Some(&b'-'); + if matches!(digits.first(), Some(b'+' | b'-')) { + digits = &digits[1..]; + } + if digits.is_empty() { + return Err(invalid()); + } + let mut radix = 10_u64; + let mut prefix = false; + if digits.len() > 1 && digits[0] == b'0' { + prefix = true; + let offset = match digits[1] { + b'x' | b'X' => { + radix = 16; + 2 + } + b'b' | b'B' => { + radix = 2; + 2 + } + b'o' | b'O' => { + radix = 8; + 2 + } + _ => { + radix = 8; + 1 + } + }; + digits = &digits[offset..]; + } + let decode = |byte: u8| { + match byte { + b'0'..=b'9' => Some(u64::from(byte - b'0')), + b'a'..=b'f' => Some(u64::from(byte - b'a' + 10)), + b'A'..=b'F' => Some(u64::from(byte - b'A' + 10)), + _ => None, + } + .filter(|digit| *digit < radix) + }; + let limit = (isize::MAX as u64) + u64::from(negative); + let mut value = 0_u64; + let mut previous_digit = false; + let mut any_digit = false; + for (index, &byte) in digits.iter().enumerate() { + if byte == b'_' { + if !(previous_digit || index == 0 && prefix) + || digits + .get(index + 1) + .and_then(|next| decode(*next)) + .is_none() + { + return Err(invalid()); + } + previous_digit = false; + } else { + let digit = decode(byte).ok_or_else(invalid)?; + value = value + .checked_mul(radix) + .and_then(|value| value.checked_add(digit)) + .filter(|value| *value <= limit) + .ok_or_else(invalid)?; + previous_digit = true; + any_digit = true; + } + } + if !any_digit { + return Err(invalid()); + } + let signed = if negative { + -(value as i128) + } else { + value as i128 + }; + Ok(signed as i64) +} + fn parse_bool(value: &OsStr) -> Result { match value.as_encoded_bytes() { b"1" | b"t" | b"T" | b"TRUE" | b"true" | b"True" => Ok(true), @@ -126,6 +303,9 @@ impl CliError { } } fn request(options: Options) -> Value { + if options.mode == Mode::Help { + return json!({"method":"foreground","payload":{"action":"help"}}); + } if options.mode == Mode::Version { return json!({"method":"version","payload":{}}); } @@ -138,12 +318,44 @@ fn request(options: Options) -> Value { payload["configPath"] = serde_json::to_value(InvokePath::from(path)).expect("native path serialization"); } - json!({"method":"foreground","payload":payload}) + for (name, value) in [ + ("configString", options.config_string), + ("ageSecretKey", options.age_secret_key), + ("externalController", options.external_controller), + ("externalControllerTls", options.external_controller_tls), + ("externalControllerUnix", options.external_controller_unix), + ("secret", options.secret), + ] { + if let Some(mut value) = value { + payload[name] = Value::String(std::mem::take(&mut *value)); + } + } + if let Some(mark) = options.external_controller_routing_mark { + payload["externalControllerRoutingMark"] = Value::from(mark); + } + if let Some(path) = options.http_config_file { + payload["httpConfigPath"] = + serde_json::to_value(InvokePath::from(path)).expect("native path serialization"); + } + if let Some(path) = options.http_ca_file { + payload["httpCaPath"] = + serde_json::to_value(InvokePath::from(path)).expect("native path serialization"); + } + let mut request = json!({"method":"foreground"}); + request["payload"] = payload; + request } -fn call(request: Value) -> Result { - let bytes = - serde_json::to_vec(&request).map_err(|_| CliError("request serialization failed"))?; - serde_json::from_slice(&invoke_bytes(&bytes)).map_err(|_| CliError("invalid Invoke response")) +fn call(mut request: Value) -> Result { + let bytes = serde_json::to_vec(&request); + for name in ["configString", "ageSecretKey", "secret"] { + if let Some(Value::String(value)) = request["payload"].get_mut(name) { + value.zeroize(); + } + } + drop(request); + let bytes = bytes.map_err(|_| CliError("request serialization failed"))?; + serde_json::from_slice(&invoke_owned_bytes(bytes)) + .map_err(|_| CliError("invalid Invoke response")) } fn render( response: &Value, @@ -198,6 +410,51 @@ mod tests { fn parse(args: &[&str]) -> Result { Options::parse(args.iter().map(OsString::from), None, None) } + #[test] + fn controller_mark_flag_uses_go_integer_syntax_and_numeric_invoke_metadata() { + for (input, expected) in [ + ("42", 42), + ("0x2a", 42), + ("0o52", 42), + ("0b10_1010", 42), + ("052", 42), + ("0x_2a", 42), + ("-1", -1), + ("+0", 0), + ] { + let options = Options::parse( + ["-t".into(), format!("-ext-ctl-routing-mark={input}").into()], + None, + None, + ) + .unwrap(); + assert_eq!( + request(options)["payload"]["externalControllerRoutingMark"], + expected + ); + } + for invalid in ["08", "1__2", "0x", "9223372036854775808"] { + assert!( + Options::parse( + [ + "-t".into(), + format!("-ext-ctl-routing-mark={invalid}").into() + ], + None, + None + ) + .is_err() + ); + } + } + #[test] + fn unix_controller_flag_only_transports_the_explicit_text_value() { + let options = Options::parse(["-ext-ctl-unix=/run/vole.sock".into()], None, None).unwrap(); + let value = request(options); + assert_eq!(value["payload"]["externalControllerUnix"], "/run/vole.sock"); + assert!(Options::parse(["-ext-ctl-unix".into()], None, None).is_err()); + } + #[test] fn supported_go_flag_spellings_and_priority() { for value in ["true", "T", "TRUE", "1", "t", "True"] { @@ -265,6 +522,50 @@ mod tests { ); } #[test] + fn encoded_configuration_and_age_options_only_transport_text() { + let options = parse(&[ + "-config", + "c2VjcmV0", + "--age-secret-key=private-fixture", + "-t", + ]) + .unwrap(); + assert!(!format!("{options:?}").contains("private-fixture")); + assert!(!format!("{options:?}").contains("c2VjcmV0")); + let value = request(options); + assert_eq!(value["payload"]["configString"], "c2VjcmV0"); + assert_eq!(value["payload"]["ageSecretKey"], "private-fixture"); + assert_eq!( + request(parse(&["-config=", "-t"]).unwrap())["payload"]["configString"], + "" + ); + assert_eq!( + request(parse(&["-config=invalid", "-v"]).unwrap()), + json!({"method":"version","payload":{}}) + ); + } + #[test] + fn controller_override_options_only_transport_specific_values() { + let options = parse(&[ + "-ext-ctl=:9900", + "--ext-ctl-tls", + "localhost:9943", + "-secret", + "private-token", + "-t", + ]) + .unwrap(); + assert!(!format!("{options:?}").contains("private-token")); + let value = request(options); + assert_eq!(value["payload"]["externalController"], ":9900"); + assert_eq!(value["payload"]["externalControllerTls"], "localhost:9943"); + assert_eq!(value["payload"]["secret"], "private-token"); + assert_eq!( + request(parse(&["-secret=", "-t"]).unwrap())["payload"]["secret"], + "" + ); + } + #[test] fn result_streams_and_failure_status() { let mut out = Vec::new(); let mut err = Vec::new(); @@ -289,4 +590,27 @@ mod tests { 1 ); } + + #[test] + fn http_addendum_option_only_transports_an_explicit_native_path() { + for option in ["-http-config=extra.yaml", "--http-config=extra.yaml"] { + let value = request(parse(&[option, "-t"]).unwrap()); + assert_eq!(value["payload"]["httpConfigPath"], "extra.yaml"); + } + let value = request( + parse(&[ + "-http-config", + "extra.yaml", + "-http-ca", + "private.json", + "-t", + ]) + .unwrap(), + ); + assert_eq!(value["payload"]["httpCaPath"], "private.json"); + assert_eq!( + request(parse(&["-http-config", "extra.yaml", "-v"]).unwrap()), + json!({"method":"version","payload":{}}) + ); + } } diff --git a/src/config/anytls.rs b/src/config/anytls.rs new file mode 100644 index 0000000..4aca682 --- /dev/null +++ b/src/config/anytls.rs @@ -0,0 +1,70 @@ +use std::time::Duration; + +use super::invalid; +use crate::Result; + +#[derive(Clone, PartialEq, Eq)] +pub struct AnyTlsSessionOptions { + pub check_interval: Duration, + pub idle_timeout: Duration, + pub min_idle: usize, + pub disable_reuse: bool, + pub client_metadata: String, +} + +impl Default for AnyTlsSessionOptions { + fn default() -> Self { + Self { + check_interval: Duration::from_secs(30), + idle_timeout: Duration::from_secs(30), + min_idle: 0, + disable_reuse: false, + client_metadata: String::new(), + } + } +} + +impl std::fmt::Debug for AnyTlsSessionOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AnyTlsSessionOptions") + .field("check_interval", &self.check_interval) + .field("idle_timeout", &self.idle_timeout) + .field("min_idle", &self.min_idle) + .field("disable_reuse", &self.disable_reuse) + .finish_non_exhaustive() + } +} + +impl AnyTlsSessionOptions { + pub(super) fn normalize( + interval: i64, + timeout: i64, + minimum: i64, + disable_reuse: bool, + client_metadata: String, + ) -> Result { + // Settings are a newline-delimited map. An embedded delimiter would + // turn metadata into other settings instead of a single client value. + if client_metadata.len() > usize::from(u16::MAX) - 80 || client_metadata.contains('\n') { + return invalid("AnyTLS client-metadata exceeds the Settings value boundary"); + } + fn seconds(value: i64) -> Result { + let duration = Duration::from_secs(if value <= 5 { 30 } else { value as u64 }); + if std::time::Instant::now().checked_add(duration).is_none() { + return invalid("AnyTLS session interval exceeds the clock range"); + } + Ok(duration) + } + Ok(Self { + check_interval: seconds(interval)?, + idle_timeout: seconds(timeout)?, + min_idle: usize::try_from(minimum.max(0)).map_err(|_| { + crate::VoleError::InvalidConfig( + "AnyTLS min-idle-session exceeds the platform range".into(), + ) + })?, + disable_reuse, + client_metadata, + }) + } +} diff --git a/src/config/controller.rs b/src/config/controller.rs new file mode 100644 index 0000000..a18a848 --- /dev/null +++ b/src/config/controller.rs @@ -0,0 +1,126 @@ +use std::sync::Arc; + +use serde::Deserialize; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ControllerCors { + #[serde(rename = "allow-origins", default)] + pub allow_origins: Vec, + #[serde(rename = "allow-private-network", default = "private_network_default")] + pub allow_private_network: bool, +} + +fn private_network_default() -> bool { + true +} + +impl Default for ControllerCors { + fn default() -> Self { + Self { + allow_origins: Vec::new(), + allow_private_network: true, + } + } +} + +#[derive(Clone, Default, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ControllerTls { + #[serde(default)] + pub certificate: Arc, + #[serde(rename = "private-key", default)] + pub private_key: Arc, + #[serde(rename = "client-auth-type", default)] + pub client_auth_type: Arc, + #[serde(rename = "client-auth-cert", default)] + pub client_auth_cert: Arc, +} + +impl std::fmt::Debug for ControllerTls { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ControllerTls").finish_non_exhaustive() + } +} + +pub(crate) fn normalize_mark(value: i64) -> super::Result { + super::EgressOptions::normalize(String::new(), value).map(|options| options.routing_mark) +} + +pub(crate) fn normalize_unix(value: Option) -> super::Result> { + let Some(value) = value.filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if value.contains('\0') { + return super::invalid("external-controller-unix requires a filesystem path without NUL"); + } + Ok(Some(value)) +} + +#[cfg(test)] +mod tests { + #[test] + fn controller_mark_uses_socket_range_without_changing_business_egress() { + use super::super::Config; + let config = Config::parse_yaml(b"routing-mark: 9\nexternal-controller: ':9000'\nexternal-controller-routing-mark: -1\n").unwrap(); + assert_eq!(config.egress.routing_mark, 9); + assert_eq!(config.external_controller.unwrap().routing_mark, u32::MAX); + assert!( + Config::parse_yaml(b"external-controller-routing-mark: 7\n") + .unwrap() + .external_controller + .is_none() + ); + for value in [i64::from(i32::MIN) - 1, i64::from(u32::MAX) + 1] { + assert!(super::normalize_mark(value).is_err()); + } + } + #[test] + fn controller_unix_is_independent_of_network_listeners_and_keeps_secret() { + use super::super::Config; + let config = Config::parse_yaml( + b"external-controller-unix: controller.sock\nsecret: network-token\n", + ) + .unwrap(); + let controller = config.external_controller.unwrap(); + assert!(!controller.http_enabled); + assert!(controller.tls_listen.is_none()); + assert_eq!(controller.unix_path.as_deref(), Some("controller.sock")); + assert_eq!(controller.secret.as_deref(), Some("network-token")); + assert!( + Config::parse_yaml(b"external-controller-unix: ''\nsecret: retained\n") + .unwrap() + .external_controller + .is_none() + ); + assert!(Config::parse_yaml(b"external-controller-unix: 7\n").is_err()); + assert!(super::normalize_unix(Some("bad\0path".into())).is_err()); + } + #[test] + fn controller_secure_configuration_keeps_http_tls_and_client_trust_separate() { + let config = super::super::Config::parse_yaml( + b"external-controller: 127.0.0.1:0\nexternal-controller-tls: 127.0.0.1:0\nsecret: token\nexternal-controller-cors:\n allow-origins: ['https://*.example']\n allow-private-network: true\ntls:\n certificate: certificate-material\n private-key: secret-private-material\n client-auth-type: unknown-mode\n", + ) + .unwrap(); + assert!(config.tls.certificates.is_empty()); + let controller = config.external_controller.unwrap(); + assert!(controller.http_enabled); + assert!(controller.tls_listen.is_some()); + assert!(controller.cors.allow_private_network); + assert_eq!(controller.cors.allow_origins, ["https://*.example"]); + assert_eq!(&*controller.server_tls.client_auth_type, "unknown-mode"); + assert!(!format!("{:?}", controller.server_tls).contains("secret-private-material")); + let tls_only = super::super::Config::parse_yaml(b"external-controller-tls: 127.0.0.1:0\n") + .unwrap() + .external_controller + .unwrap(); + assert!(!tls_only.http_enabled); + assert!(tls_only.tls_listen.is_some()); + assert!(tls_only.cors.allow_private_network); + let explicit: super::ControllerCors = + serde_yaml_ng::from_str("allow-private-network: false").unwrap(); + assert!(!explicit.allow_private_network); + let omitted: super::ControllerCors = serde_yaml_ng::from_str("{}").unwrap(); + assert!(omitted.allow_private_network); + } +} diff --git a/src/config/credentials.rs b/src/config/credentials.rs index db4dac0..1cb9874 100644 --- a/src/config/credentials.rs +++ b/src/config/credentials.rs @@ -1,12 +1,11 @@ -use std::io; +use std::{io, net::IpAddr, sync::Arc}; use base64::{Engine as _, engine::general_purpose::STANDARD}; -/// One client-proxy credential shared by the HTTP and SOCKS5 frontends. +/// Client-proxy credentials shared by the HTTP and SOCKS5 frontends. #[derive(Clone, PartialEq, Eq)] pub struct ProxyCredentials { - username: String, - password: String, + entries: Vec<(Arc, String)>, } impl std::fmt::Debug for ProxyCredentials { @@ -21,78 +20,181 @@ impl ProxyCredentials { pub fn new(username: impl Into, password: impl Into) -> io::Result { let username = username.into(); let password = password.into(); - for (kind, value) in [("user", &username), ("password", &password)] { - if !(1..=255).contains(&value.len()) { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("authentication {kind} must contain between 1 and 255 UTF-8 bytes"), - )); - } - } if username.contains(':') { return Err(io::Error::new( io::ErrorKind::InvalidInput, "authentication user must not contain `:`", )); } - Ok(Self { username, password }) + Ok(Self { + entries: vec![(Arc::from(username), password)], + }) } #[must_use] pub fn matches(&self, username: &[u8], password: &[u8]) -> bool { - // Always compare both fixed-size credential slots; never expose the - // matching prefix or short-circuit after a successful username check. - let mut difference = username.len() ^ self.username.len(); - difference |= password.len() ^ self.password.len(); - for index in 0..255 { - difference |= usize::from( - username.get(index).copied().unwrap_or(0) - ^ self.username.as_bytes().get(index).copied().unwrap_or(0), - ); - difference |= usize::from( - password.get(index).copied().unwrap_or(0) - ^ self.password.as_bytes().get(index).copied().unwrap_or(0), - ); + self.authenticate(username, password).is_some() + } + + pub(crate) fn authenticate(&self, username: &[u8], password: &[u8]) -> Option> { + let mut matched = None; + for (expected_user, expected_password) in &self.entries { + let mut difference = username.len() ^ expected_user.len(); + difference |= password.len() ^ expected_password.len(); + // Cover both complete fields without an early exit on content. + // HTTP credentials are not limited by SOCKS5's one-byte lengths. + let length = username + .len() + .max(expected_user.len()) + .max(password.len()) + .max(expected_password.len()); + for index in 0..length { + difference |= usize::from( + username.get(index).copied().unwrap_or(0) + ^ expected_user.as_bytes().get(index).copied().unwrap_or(0), + ); + difference |= usize::from( + password.get(index).copied().unwrap_or(0) + ^ expected_password + .as_bytes() + .get(index) + .copied() + .unwrap_or(0), + ); + } + if difference == 0 { + matched = Some(expected_user.clone()); + } + } + matched + } + pub(crate) fn multiple(values: Vec) -> Self { + let mut entries = std::collections::BTreeMap::new(); + for (username, password) in values.into_iter().flat_map(|value| value.entries) { + entries.insert(username, password); + } + Self { + entries: entries.into_iter().collect(), } - difference == 0 } #[must_use] pub fn authorization_header_value(&self) -> String { format!( "Basic {}", - STANDARD.encode(format!("{}:{}", self.username, self.password)) + STANDARD.encode(format!("{}:{}", self.entries[0].0, self.entries[0].1)) ) } + #[cfg(test)] pub(crate) fn verifies(&self, headers: &[(String, String)]) -> bool { + self.authenticate_headers(headers).is_some() + } + + pub(crate) fn authenticate_headers(&self, headers: &[(String, String)]) -> Option> { let mut values = headers .iter() .filter(|(name, _)| name.eq_ignore_ascii_case("proxy-authorization")) .map(|(_, value)| value.as_str()); let (Some(value), None) = (values.next(), values.next()) else { - return false; + return None; }; let mut parts = value.split_ascii_whitespace(); let (Some(scheme), Some(encoded), None) = (parts.next(), parts.next(), parts.next()) else { - return false; + return None; }; - if !scheme.eq_ignore_ascii_case("basic") || encoded.len() > 684 { - return false; + if !scheme.eq_ignore_ascii_case("basic") + || encoded.len() > crate::inbound::DEFAULT_HEADER_LIMIT + { + return None; } let Ok(decoded) = STANDARD.decode(encoded) else { - return false; + return None; }; - let Some(separator) = decoded.iter().position(|&byte| byte == b':') else { - return false; - }; - self.matches(&decoded[..separator], &decoded[separator + 1..]) + let separator = decoded.iter().position(|&byte| byte == b':')?; + self.authenticate(&decoded[..separator], &decoded[separator + 1..]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn http_credentials_cover_empty_values_and_complete_long_fields() { + for (user, password) in [("", ""), ("", "secret"), ("user", "")] { + let auth = ProxyCredentials::new(user, password).unwrap(); + assert!(auth.verifies(&[( + "Proxy-Authorization".into(), + auth.authorization_header_value(), + )])); + } + let user = "u".repeat(600); + let password = "p".repeat(600); + let auth = ProxyCredentials::new(&user, &password).unwrap(); + assert!(auth.verifies(&[( + "Proxy-Authorization".into(), + auth.authorization_header_value(), + )])); + let mut wrong_user = user.into_bytes(); + let mut wrong_password = password.into_bytes(); + wrong_user[599] = b'x'; + assert!(!auth.matches(&wrong_user, &wrong_password)); + wrong_user[599] = b'u'; + wrong_password[599] = b'x'; + assert!(!auth.matches(&wrong_user, &wrong_password)); + assert!(!auth.matches(&wrong_user, &wrong_password[..599])); + } + + #[test] + fn http_authentication_keeps_the_shared_header_budget() { + let auth = ProxyCredentials::new("user", "p".repeat(crate::inbound::DEFAULT_HEADER_LIMIT)) + .unwrap(); + assert!(!auth.verifies(&[( + "Proxy-Authorization".into(), + auth.authorization_header_value(), + )])); } } /// A fixed client-oriented listener policy, not an arbitrary address list. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ProxyAccess { + pub bind_address: Option, pub allow_lan: bool, pub ipv6: bool, } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxyAccessRules { + pub allowed: Vec, + pub denied: Vec, + pub skip_auth: Vec, +} +impl Default for ProxyAccessRules { + fn default() -> Self { + Self { + allowed: vec![ + super::IpCidr { + network: "0.0.0.0".parse().unwrap(), + prefix_len: 0, + }, + super::IpCidr { + network: "::".parse().unwrap(), + prefix_len: 0, + }, + ], + denied: Vec::new(), + skip_auth: Vec::new(), + } + } +} +impl ProxyAccessRules { + pub fn permits(&self, address: IpAddr) -> bool { + self.allowed.iter().any(|cidr| cidr.contains(address)) + && !self.denied.iter().any(|cidr| cidr.contains(address)) + } + pub fn skips_auth(&self, address: IpAddr) -> bool { + self.skip_auth.iter().any(|cidr| cidr.contains(address)) + } +} diff --git a/src/config/dns.rs b/src/config/dns.rs new file mode 100644 index 0000000..46f3301 --- /dev/null +++ b/src/config/dns.rs @@ -0,0 +1,658 @@ +use super::*; + +pub(super) fn default_nameservers() -> Vec { + ["https://doh.pub/dns-query", "tls://223.5.5.5:853"] + .map(str::to_owned) + .to_vec() +} +pub(super) fn default_bootstrap() -> Vec { + ["114.114.114.114", "223.5.5.5", "8.8.8.8", "1.0.0.1"] + .map(str::to_owned) + .to_vec() +} +pub(super) fn redir_host() -> String { + "redir-host".into() +} +pub(super) fn default_ipv6_timeout() -> u64 { + 100 +} + +impl Default for DnsConfig { + fn default() -> Self { + // Empty targets are sufficient for the built-in DIRECT upstreams. + let raw: RawDnsConfig = serde_yaml_ng::from_str("{}").expect("DNS defaults"); + raw.normalize(&RouteTargetsByName::new()) + .expect("valid DNS defaults") + } +} +impl DnsConfig { + pub(super) fn disabled() -> Result { + Ok(Self::default()) + } +} + +impl RawDnsConfig { + pub(super) fn normalize(self, targets: &RouteTargetsByName) -> Result { + if self.prefer_h3 && !cfg!(feature = "dns-quic") { + return invalid("DNS HTTP/3 support is disabled at build time"); + } + let fake_ip = self.fake_ip()?; + if self.enable && self.nameserver.is_empty() { + return invalid("enabled DNS requires nameserver"); + } + if self.default_nameserver.is_empty() { + return invalid("default-nameserver requires at least one server"); + } + if (self.respect_rules || !self.proxy_policies.is_empty()) + && self.proxy_nameserver.is_empty() + { + return invalid( + "respect-rules and proxy-server-nameserver-policy require proxy-server-nameserver", + ); + } + let parse = |values: Vec, route| { + values + .iter() + .map(|value| parse_dns_nameserver(value, targets, route)) + .collect::>>() + }; + let main_route = if self.respect_rules { + DnsRoute::Rules + } else { + DnsRoute::Direct + }; + let default_nameservers = parse(self.default_nameserver, DnsRoute::Direct)?; + if default_nameservers.iter().any(|server| { + matches!( + server.address, + DnsAddress::Domain(_) | DnsAddress::Source(DnsSource::Dhcp(_)) + ) + }) { + return invalid("default-nameserver requires literal IP addresses"); + } + let fallback = self + .fallback_filter + .normalize(parse(self.fallback, main_route)?, self.fallback_lazy_query)?; + Ok(DnsConfig { + fallback, + fake_ip, + enable: self.enable, + ipv6: self.ipv6, + prefer_h3: self.prefer_h3, + ipv6_timeout: Duration::from_millis(if self.ipv6_timeout == 0 { + default_ipv6_timeout() + } else { + self.ipv6_timeout + }), + cache_max_size: if self.cache_max_size == 0 { + crate::dns::MAX_CACHE_ENTRIES + } else { + self.cache_max_size + }, + cache_algorithm: if self.cache_algorithm == "arc" { + DnsCacheAlgorithm::Arc + } else { + DnsCacheAlgorithm::Lru + }, + nameservers: parse(self.nameserver, main_route)?, + nameserver_policies: normalize_dns_nameserver_policies( + self.nameserver_policies, + targets, + main_route, + )?, + default_nameservers, + proxy_nameservers: parse(self.proxy_nameserver, DnsRoute::Direct)?, + proxy_policies: normalize_dns_nameserver_policies( + self.proxy_policies, + targets, + DnsRoute::Direct, + )?, + direct_nameservers: parse(self.direct_nameserver, DnsRoute::Direct)?, + direct_follow_policy: self.direct_follow_policy, + use_hosts: self.use_hosts, + use_system_hosts: self.use_system_hosts, + hosts: vec![HostEntry { + pattern: "localhost".into(), + value: HostValue::Addresses(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]), + }], + }) + } +} + +pub(super) fn parse_dns_nameserver( + input: &str, + targets: &RouteTargetsByName, + default_route: DnsRoute, +) -> Result { + parse_dns_nameserver_with_route(input, Some(targets), default_route) +} + +/// A node's private DNS fixes its dispatcher independently of the URL route +/// token. Keep the original URL so every keyed option is decoded once. +pub(super) fn parse_node_dns_nameserver(input: &str) -> Result { + parse_dns_nameserver_with_route(input, None, DnsRoute::Direct) +} + +fn parse_dns_nameserver_with_route( + input: &str, + targets: Option<&RouteTargetsByName>, + default_route: DnsRoute, +) -> Result { + let bad = || { + VoleError::InvalidConfig( + "invalid DNS upstream; supported transports are UDP, TCP, TLS, HTTP(S), QUIC, RCODE, system and DHCP" + .into(), + ) + }; + let (endpoint, fragment) = input.split_once('#').unwrap_or((input, "")); + let (route_name, mut options) = + parse_nameserver_options(fragment, !endpoint.starts_with("https://"))?; + // Special sources remain physical, but use the same query wrappers. + let source = match endpoint { + "system" | "system://" | "dhcp://system" => Some(DnsSource::System), + endpoint if endpoint.starts_with("dhcp://") => { + let interface = &endpoint[7..]; + crate::dns::source::validate_interface(interface).map_err(|_| bad())?; + Some(DnsSource::Dhcp(interface.into())) + } + _ => None, + }; + if let Some(source) = source { + return Ok(DnsNameserver { + address: DnsAddress::Source(source), + transport: DnsTransport::Udp, + port: 53, + route: DnsRoute::Direct, + path: None, + http_auth: None, + options, + }); + } + let route = match targets { + Some(targets) if !route_name.is_empty() => match route_name.as_str() { + "DIRECT" | "COMPATIBLE" => DnsRoute::Direct, + "REJECT" => DnsRoute::Reject, + "REJECT-DROP" => DnsRoute::RejectDrop, + "RULES" => DnsRoute::Rules, + _ => match targets.get(&route_name).copied() { + Some(id) => DnsRoute::Route(id), + None => { + options.interface = EgressOptions::normalize(route_name, 0)?.interface; + DnsRoute::Direct + } + }, + }, + _ => default_route, + }; + if let Ok(address) = endpoint.parse::() { + return Ok(DnsNameserver { + address: address.into(), + transport: DnsTransport::Udp, + port: 53, + route, + path: None, + http_auth: None, + options, + }); + } + let endpoint = if endpoint.contains("://") { + endpoint.to_owned() + } else { + format!("udp://{endpoint}") + }; + let url = Url::parse(&endpoint).map_err(|_| bad())?; + if url.scheme() == "rcode" { + let host = url.host_str().ok_or_else(bad)?; + let rcode = match host { + "success" => 0, + "format_error" => 1, + "server_failure" => 2, + "name_error" => 3, + "not_implemented" => 4, + "refused" => 5, + _ => return Err(bad()), + }; + // Mihomo selects the local client by URL host. Paths and query + // parameters do not modify its behavior; a port changes the host. + if url.port().is_some() { + return Err(bad()); + } + return Ok(DnsNameserver { + transport: DnsTransport::Rcode(rcode), + address: DnsAddress::Domain(host.to_owned()), + port: 0, + route, + path: None, + http_auth: None, + options, + }); + } + let (transport, default_port) = match url.scheme() { + "udp" => (DnsTransport::Udp, 53), + "tcp" => (DnsTransport::Tcp, 53), + "tls" => (DnsTransport::Tls, 853), + "https" => (DnsTransport::Https, 443), + "http" => (DnsTransport::Http, 80), + "quic" if cfg!(feature = "dns-quic") => (DnsTransport::Quic, 853), + "quic" => return invalid("DNS QUIC support is disabled at build time"), + _ => return Err(bad()), + }; + let http = matches!(transport, DnsTransport::Http | DnsTransport::Https); + let has_userinfo = endpoint.split_once("://").is_some_and(|(_, rest)| { + rest.split(['/', '?']) + .next() + .is_some_and(|authority| authority.contains('@')) + }); + if url.fragment().is_some() { + return Err(bad()); + } + let http_auth = if http && has_userinfo { + use base64::Engine as _; + let mut value = percent_decode_bytes(url.username())?; + value.push(b':'); + value.extend_from_slice(&percent_decode_bytes(url.password().unwrap_or_default())?); + let encoded_len = value.len().div_ceil(3).saturating_mul(4); + if encoded_len + 6 > crate::dns::MAX_HTTP_HEADER_BYTES { + return invalid("DNS HTTP authentication exceeds the header budget"); + } + Some(DnsHttpAuth( + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(value) + ) + .into(), + )) + } else { + None + }; + let address = match url.host().ok_or_else(bad)? { + Host::Ipv4(ip) => DnsAddress::Ip(ip.into()), + Host::Ipv6(ip) => DnsAddress::Ip(ip.into()), + Host::Domain(host) => match host.parse::() { + Ok(ip) => DnsAddress::Ip(ip), + Err(_) => DnsAddress::Domain(crate::dns::canonicalize_name(host).map_err(|_| bad())?), + }, + }; + let path = if http { + // URL's browser-style parser resolves dot segments; Mihomo's URL.Path + // keeps them. Decode the original path before rebuilding its escapes. + let rest = endpoint.split_once("://").expect("DNS URL has a scheme").1; + let path = rest.find(['/', '?']).map_or("/", |index| { + let tail = &rest[index..]; + if tail.starts_with('/') { + tail.split('?').next().unwrap_or("/") + } else { + "/" + } + }); + Some(normalize_http_path(path)?) + } else { + None + }; + let port = url.port().unwrap_or(default_port); + validate_port(port, "DNS nameserver")?; + Ok(DnsNameserver { + transport, + address, + port, + route, + path, + http_auth, + options, + }) +} + +fn parse_nameserver_options( + fragment: &str, + ignore_h3: bool, +) -> Result<(String, DnsNameserverOptions)> { + let fragment = percent_decode(fragment)?; + let mut route = String::new(); + let mut params = std::collections::BTreeMap::new(); + for part in fragment.split('&') { + if let Some((key, value)) = part.split_once('=') { + params.insert(key, value); + } else { + route = part.to_owned(); + } + } + let mut options = DnsNameserverOptions::default(); + let mut disabled = std::collections::BTreeSet::new(); + for (key, value) in params { + match key { + "ecs" if !value.is_empty() => { + let (address, prefix) = value + .split_once('/') + .map_or((value, None), |(a, p)| (a, Some(p))); + let address = address + .parse::() + .map_err(|_| VoleError::InvalidConfig("invalid DNS ECS address".into()))?; + let bits = if address.is_ipv4() { 32 } else { 128 }; + let prefix_len = prefix + .map_or(Ok(bits), |p| p.parse::()) + .map_err(|_| VoleError::InvalidConfig("invalid DNS ECS prefix".into()))?; + if prefix_len > bits { + return invalid("invalid DNS ECS prefix"); + } + options.ecs = Some(DnsEcs { + address, + prefix_len, + }); + } + "ecs" => {} + "ecs-override" => options.ecs_override = value == "true", + "disable-ipv4" if value == "true" => { + disabled.insert(1); + } + "disable-ipv6" if value == "true" => { + disabled.insert(28); + } + "disable-ipv4" | "disable-ipv6" => {} + key if key.starts_with("disable-qtype-") => { + if value == "true" + && let Ok(kind) = key[14..].parse::() + && valid_disabled_type(kind) + { + disabled.insert(kind); + } + } + "skip-cert-verify" => options.skip_cert_verify = value == "true", + "name-cert-verify" => { + options.verification_name = normalize_verification_name(Some(value.to_owned()))? + } + "disable-reuse" => options.disable_reuse = value == "true", + "h3" if value != "true" || ignore_h3 => {} + "h3" if cfg!(feature = "dns-quic") => options.force_h3 = true, + _ => return invalid("unsupported DNS upstream parameter"), + } + } + options.disabled_types = disabled.into_iter().collect(); + Ok((route, options)) +} + +fn valid_disabled_type(kind: u16) -> bool { + // Registered RR constructors in the fixed Mihomo miekg/dns v1.1.63 + // reference. Unknown 16-bit questions remain forwardable, not filters. + matches!(kind, 1..=10 | 12..=21 | 23..=33 | 35..=37 | 39 | 41..=53 | 55..=65 | 99..=102 | 104..=109 | 128 | 249 | 250 | 255..=258 | 260 | 32768 | 32769) +} + +#[cfg(test)] +mod upstream_options_tests { + use super::*; + + #[test] + fn dns_interface_selector_prefers_proxy_names_and_separates_pool_identity() { + let mut targets = RouteTargetsByName::new(); + let target = RouteTargetId::Proxy(ProxyId::from_index(0)); + targets.insert("eth0".into(), target); + let proxy = + parse_dns_nameserver("tls://192.0.2.53#eth0", &targets, DnsRoute::Direct).unwrap(); + assert_eq!(proxy.route, DnsRoute::Route(target)); + assert!(proxy.options.interface.is_none()); + let interface = + parse_dns_nameserver("tls://192.0.2.53#eth1", &targets, DnsRoute::Rules).unwrap(); + assert_eq!(interface.route, DnsRoute::Direct); + assert_eq!(interface.options.interface.as_deref(), Some("eth1")); + let other = + parse_dns_nameserver("tls://192.0.2.53#eth2", &targets, DnsRoute::Direct).unwrap(); + let mut keys = std::collections::HashSet::new(); + keys.insert(interface); + keys.insert(other); + assert_eq!(keys.len(), 2); + assert_eq!( + parse_dns_nameserver( + "tcp://192.0.2.53#Ethernet%202&disable-reuse=true", + &targets, + DnsRoute::Direct + ) + .unwrap() + .options + .interface + .as_deref(), + Some("Ethernet 2") + ); + } + + #[test] + fn dns_quic_feature_gates_and_plain_http_fragment() { + let targets = RouteTargetsByName::new(); + let quic = parse_dns_nameserver("quic://dns.example", &targets, DnsRoute::Direct); + let h3 = parse_dns_nameserver( + "https://dns.example/dns-query#h3=true", + &targets, + DnsRoute::Direct, + ); + let raw: RawDnsConfig = serde_yaml_ng::from_str("prefer-h3: true").unwrap(); + let preferred = raw.normalize(&targets); + if cfg!(feature = "dns-quic") { + let quic = quic.unwrap(); + assert_eq!(quic.port, 853); + assert_eq!(quic.transport, DnsTransport::Quic); + assert!(h3.unwrap().options.force_h3); + assert!(preferred.unwrap().prefer_h3); + } else { + assert!(quic.is_err()); + assert!(h3.is_err()); + assert!(preferred.is_err()); + } + let http = parse_dns_nameserver( + "http://dns.example/dns-query#h3=true", + &targets, + DnsRoute::Direct, + ) + .unwrap(); + assert_eq!(http.transport, DnsTransport::Http); + assert!(!http.options.force_h3); + } + + #[test] + fn doh_url_clears_query_and_preserves_only_decoded_basic_identity() { + use base64::Engine as _; + let server = parse_dns_nameserver( + "https://a%3Alice:p%40ss@dns.example/a%2Fb%20c?discard=secret#DIRECT&ecs=192.0.2.1", + &RouteTargetsByName::new(), + DnsRoute::Direct, + ) + .unwrap(); + assert_eq!(server.path.as_deref(), Some("/a/b%20c")); + assert_eq!(server.port, 443); + let auth = server.http_auth.as_ref().unwrap(); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(auth.0.strip_prefix("Basic ").unwrap()) + .unwrap(), + b"a:lice:p@ss" + ); + assert_eq!(format!("{auth:?}"), "DnsHttpAuth([redacted])"); + let dot_path = parse_dns_nameserver( + "https://dns.example/a/%2e%2e/b", + &RouteTargetsByName::new(), + DnsRoute::Direct, + ) + .unwrap(); + assert_eq!(dot_path.path.as_deref(), Some("/a/../b")); + for (userinfo, decoded) in [ + ("alice", &b"alice:"[..]), + ("", &b":"[..]), + ("%FF:%00", &b"\xff:\0"[..]), + ] { + let server = parse_dns_nameserver( + &format!("http://{userinfo}@192.0.2.1?discard"), + &RouteTargetsByName::new(), + DnsRoute::Direct, + ) + .unwrap(); + assert_eq!(server.path.as_deref(), Some("/")); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(server.http_auth.unwrap().0.strip_prefix("Basic ").unwrap()) + .unwrap(), + decoded + ); + } + } + + #[test] + fn non_http_nameservers_ignore_http_only_url_components() { + for scheme in ["udp", "tcp", "tls", "quic"] { + if scheme == "quic" && !cfg!(feature = "dns-quic") { + continue; + } + let bare = format!("{scheme}://192.0.2.53#DIRECT&ecs=192.0.2.7/24"); + let decorated = format!( + "{scheme}://user:password@192.0.2.53/ignored?unused=value#DIRECT&ecs=192.0.2.7/24" + ); + let targets = RouteTargetsByName::new(); + let expected = parse_dns_nameserver(&bare, &targets, DnsRoute::Direct).unwrap(); + let actual = parse_dns_nameserver(&decorated, &targets, DnsRoute::Direct).unwrap(); + assert_eq!(actual, expected); + assert_eq!(parse_node_dns_nameserver(&decorated).unwrap(), expected); + assert!(actual.path.is_none()); + assert!(actual.http_auth.is_none()); + } + } + + #[test] + fn upstream_options_decode_fragments_and_use_last_values() { + let server = parse_dns_nameserver( + "tls://192.0.2.1#DIRECT%26RULES%26ecs=2001:db8::7/57%26ecs-override=true%26disable-ipv4=true%26disable-ipv6=True%26disable-qtype-16=true%26disable-qtype-65280=true%26skip-cert-verify=false%26skip-cert-verify=true%26name-cert-verify=dns.example%26disable-reuse=true", + &RouteTargetsByName::new(), + DnsRoute::Direct, + ).unwrap(); + assert_eq!(server.route, DnsRoute::Rules); + assert_eq!(server.options.ecs.unwrap().prefix_len, 57); + assert!(server.options.ecs_override); + assert_eq!(&*server.options.disabled_types, &[1, 16]); + assert!(server.options.skip_cert_verify); + assert_eq!( + server.options.verification_name.as_deref(), + Some("dns.example") + ); + assert!(server.options.disable_reuse); + let parameter_only = parse_dns_nameserver( + "192.0.2.1#disable-ipv6=true", + &RouteTargetsByName::new(), + DnsRoute::Rules, + ) + .unwrap(); + assert_eq!(parameter_only.route, DnsRoute::Rules); + assert_eq!(&*parameter_only.options.disabled_types, &[28]); + } + + #[test] + fn upstream_options_survive_system_dhcp_and_rcode_parsing() { + for endpoint in [ + "system", + "system://", + "dhcp://system", + "dhcp://en0", + "rcode://refused", + ] { + let server = parse_dns_nameserver( + &format!("{endpoint}#ecs=192.0.2.9&disable-ipv6=true"), + &RouteTargetsByName::new(), + DnsRoute::Direct, + ) + .unwrap(); + assert_eq!(server.options.ecs.unwrap().prefix_len, 32); + assert_eq!(&*server.options.disabled_types, &[28]); + if endpoint == "dhcp://en0" { + assert!( + matches!(server.address, DnsAddress::Source(DnsSource::Dhcp(ref interface)) if interface.as_ref() == "en0") + ); + } + } + } +} + +fn percent_decode(input: &str) -> Result { + String::from_utf8(percent_decode_bytes(input)?) + .map_err(|_| VoleError::InvalidConfig("invalid DNS route encoding".into())) +} + +fn percent_decode_bytes(input: &str) -> Result> { + let mut bytes = Vec::with_capacity(input.len()); + let mut iter = input.bytes(); + while let Some(byte) = iter.next() { + if byte == b'%' { + let a = iter.next().and_then(|b| (b as char).to_digit(16)); + let b = iter.next().and_then(|b| (b as char).to_digit(16)); + match (a, b) { + (Some(a), Some(b)) => bytes.push((a * 16 + b) as u8), + _ => return invalid("invalid DNS route encoding"), + } + } else { + bytes.push(byte); + } + } + Ok(bytes) +} + +fn normalize_http_path(path: &str) -> Result { + let mut result = String::new(); + for byte in percent_decode_bytes(path)? { + // Mihomo rebuilds URL.Path without RawPath. Go encodePath retains + // whole-path reserved separators except '?', with uppercase escapes. + if byte.is_ascii_alphanumeric() || b"-_.~$&+,/:;=@".contains(&byte) { + result.push(char::from(byte)); + } else { + use std::fmt::Write as _; + write!(result, "%{byte:02X}").expect("writing a String cannot fail"); + } + if result.len() > crate::dns::MAX_HTTP_HEADER_BYTES { + return invalid("DNS HTTP path exceeds the header budget"); + } + } + if result.is_empty() { + result.push('/'); + } + Ok(result) +} + +pub(super) fn normalize_hosts( + raw: std::collections::BTreeMap, +) -> Result> { + let mut hosts = vec![HostEntry { + pattern: "localhost".into(), + value: HostValue::Addresses(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]), + }]; + for (pattern, raw) in raw { + let DnsPolicySelector::Domains(patterns) = normalize_dns_policy_selector(&pattern)? else { + return invalid("hosts keys require domain patterns"); + }; + let values = match raw { + RawPolicyNameservers::One(value) => vec![value], + RawPolicyNameservers::Many(values) => values, + }; + let value = if values.as_slice() == ["lan"] { + HostValue::Lan + } else if values.len() == 1 && values[0].parse::().is_err() { + HostValue::Alias( + crate::dns::canonicalize_name(&values[0]) + .map_err(|_| VoleError::InvalidConfig("invalid hosts alias".into()))?, + ) + } else { + let addresses = values + .iter() + .map(|value| { + value.parse::().map_err(|_| { + VoleError::InvalidConfig("hosts requires IP addresses or one alias".into()) + }) + }) + .collect::>>()?; + if addresses.is_empty() { + return invalid("hosts requires a value"); + } + HostValue::Addresses(addresses) + }; + for pattern in patterns { + hosts.push(HostEntry { + pattern, + value: value.clone(), + }); + } + } + crate::dns::hosts::validate(&hosts) + .map_err(|_| VoleError::InvalidConfig("hosts contains a domain alias cycle".into()))?; + Ok(hosts) +} diff --git a/src/config/domain_regex.rs b/src/config/domain_regex.rs new file mode 100644 index 0000000..7d960f8 --- /dev/null +++ b/src/config/domain_regex.rs @@ -0,0 +1,805 @@ +//! Independent bounded DOMAIN-REGEX adapter. No GeoSite or name-filter policy. +use super::{Result, VoleError}; +use std::{ + fmt, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio_util::sync::CancellationToken; + +const PATTERN_BYTES: usize = 4096; +const AST_NODES: usize = 512; +const AST_DEPTH: usize = 32; +const REPEAT_BOUND: usize = 1024; +const COMPILED_BYTES: usize = 512 * 1024; +const DOMAIN_BYTES: usize = 253; +const CALL_BACKTRACKS: usize = 2048; +const DECISION_BACKTRACKS: usize = 64 * 1024; +const DECISION_WORK: u64 = 64 * 1024 * 1024; +const DECISION_MATCH_TIME: Duration = Duration::from_millis(2); + +fn invalid(message: &'static str) -> VoleError { + VoleError::InvalidConfig(message.to_owned()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MatchFailure { + Cancelled, + Budget, + InvalidInput, + Engine, +} +impl fmt::Display for MatchFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Cancelled => "domain rule evaluation cancelled", + Self::Budget => "domain rule execution budget exceeded", + Self::InvalidInput => "domain rule input exceeds the supported boundary", + Self::Engine => "domain rule evaluation failed", + }) + } +} +impl std::error::Error for MatchFailure {} + +/// One owned value per complete decision, including suspended DNS traversal. +/// No Clone: recursive rules, provider predicates and retries borrow this value. +pub(crate) struct DecisionBudget { + work: u64, + backtracks: usize, + match_time: Duration, + failure: Option, + cancellation: Vec, + deadline: Option, +} +impl Default for DecisionBudget { + fn default() -> Self { + Self::new() + } +} +impl DecisionBudget { + pub(crate) fn new() -> Self { + Self { + work: DECISION_WORK, + backtracks: DECISION_BACKTRACKS, + match_time: Duration::ZERO, + failure: None, + cancellation: Vec::new(), + deadline: None, + } + } + fn fail(&mut self, failure: MatchFailure) -> std::result::Result { + let failure = *self.failure.get_or_insert(failure); + Err(failure) + } + pub(crate) fn check( + &mut self, + cancel: Option<&CancellationToken>, + ) -> std::result::Result<(), MatchFailure> { + if let Some(failure) = self.failure { + return Err(failure); + } + if cancel.is_some_and(CancellationToken::is_cancelled) + || self + .cancellation + .iter() + .any(CancellationToken::is_cancelled) + { + return self.fail(MatchFailure::Cancelled); + } + if self + .deadline + .is_some_and(|deadline| Instant::now() >= deadline) + { + return self.fail(MatchFailure::Budget); + } + Ok(()) + } + /// Call once for every predicate/program visit, including non-regex rules. + pub(crate) fn visit( + &mut self, + cancel: Option<&CancellationToken>, + ) -> std::result::Result<(), MatchFailure> { + self.reserve(1, 0, cancel) + } + fn reserve( + &mut self, + work: u64, + backtracks: usize, + cancel: Option<&CancellationToken>, + ) -> std::result::Result<(), MatchFailure> { + self.check(cancel)?; + let (Some(work), Some(backtracks)) = ( + self.work.checked_sub(work), + self.backtracks.checked_sub(backtracks), + ) else { + return self.fail(MatchFailure::Budget); + }; + self.work = work; + self.backtracks = backtracks; + Ok(()) + } + fn finish( + &mut self, + duration: Duration, + cancel: Option<&CancellationToken>, + ) -> std::result::Result<(), MatchFailure> { + self.match_time = self.match_time.saturating_add(duration); + if self.match_time > DECISION_MATCH_TIME { + return self.fail(MatchFailure::Budget); + } + self.check(cancel) + } +} + +/// A decision-local budget shared by contexts, candidates and lazy DNS continuation. +/// Synchronous matching holds the lock only while consuming the budget. +#[derive(Clone)] +pub struct RuleDecision(Arc>); +tokio::task_local! { static ACTIVE_DECISION: RuleDecision; } +impl Default for RuleDecision { + fn default() -> Self { + ACTIVE_DECISION + .try_with(Clone::clone) + .unwrap_or_else(|_| Self(Arc::new(std::sync::Mutex::new(DecisionBudget::new())))) + } +} +impl fmt::Debug for RuleDecision { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("RuleDecision { .. }") + } +} +// Evaluation accounting does not change the value identity of a routing context. +impl PartialEq for RuleDecision { + fn eq(&self, _: &Self) -> bool { + true + } +} +impl Eq for RuleDecision {} +impl RuleDecision { + pub(crate) async fn scope(&self, future: F) -> F::Output { + ACTIVE_DECISION.scope(self.clone(), future).await + } + pub(crate) fn check(&self) -> std::result::Result<(), MatchFailure> { + self.with(|budget| budget.check(None)) + } + pub(crate) fn visit(&self) -> std::result::Result<(), MatchFailure> { + self.with(|budget| budget.visit(None)) + } + pub(crate) fn with( + &self, + f: impl FnOnce(&mut DecisionBudget) -> std::result::Result, + ) -> std::result::Result { + let mut budget = self.0.lock().map_err(|_| MatchFailure::Engine)?; + f(&mut budget) + } + pub(crate) fn set_cancellation(&self, cancellation: CancellationToken) { + if let Ok(mut budget) = self.0.lock() + && !budget.cancellation.contains(&cancellation) + { + if budget.cancellation.len() >= 64 { + budget.failure.get_or_insert(MatchFailure::Budget); + } else { + budget.cancellation.push(cancellation); + } + } + } + pub(crate) fn set_deadline(&self, deadline: Instant) { + if let Ok(mut budget) = self.0.lock() { + budget.deadline = Some(budget.deadline.map_or(deadline, |old| old.min(deadline))); + } + } + pub(crate) fn linear( + &self, + source_bytes: usize, + input_bytes: usize, + f: impl FnOnce() -> bool, + ) -> std::result::Result { + self.with(|budget| { + budget.reserve( + (source_bytes as u64 + 1).saturating_mul(input_bytes as u64 + 1), + 0, + None, + )?; + let started = Instant::now(); + let matched = f(); + budget.finish(started.elapsed(), None)?; + Ok(matched) + }) + } +} + +/// Payload identity is preserved; pattern text is deliberately hidden in Debug/errors. +#[derive(Clone)] +pub struct DomainRegex(Arc); +struct Compiled { + source: Arc, + weight: u64, + engine: Engine, +} +enum Engine { + Linear(regex::Regex), + Complex(fancy_regex::Regex), +} +impl fmt::Debug for DomainRegex { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("DomainRegex { .. }") + } +} +impl PartialEq for DomainRegex { + fn eq(&self, other: &Self) -> bool { + self.as_str() == other.as_str() + } +} +impl Eq for DomainRegex {} +impl DomainRegex { + pub fn new(pattern: &str) -> Result { + if pattern.len() > PATTERN_BYTES { + return Err(invalid("DOMAIN-REGEX compilation budget exceeded")); + } + let translated = common_dialect(pattern)?; + let (translated, _) = capture_dialect(&translated)?; + let tree = fancy_regex::Expr::parse_tree(&translated) + .map_err(|_| invalid("invalid or unsupported DOMAIN-REGEX syntax"))?; + let (nodes, weight, _) = inspect(&tree.expr)?; + let engine = if let Ok(regex) = regex::RegexBuilder::new(&translated) + .case_insensitive(true) + .nest_limit(AST_DEPTH as u32) + .size_limit(COMPILED_BYTES) + .dfa_size_limit(COMPILED_BYTES) + .build() + { + Engine::Linear(regex) + } else { + let fragment_bytes = (COMPILED_BYTES / nodes.max(1)).max(8192); + let regex = fancy_regex::RegexBuilder::new(&translated) + .case_insensitive(true) + .backtrack_limit(CALL_BACKTRACKS) + .seek(false) + .delegate_size_limit(fragment_bytes) + .delegate_dfa_size_limit(fragment_bytes) + .build() + .map_err(|_| invalid("invalid or unsupported DOMAIN-REGEX syntax"))?; + Engine::Complex(regex) + }; + Ok(Self(Arc::new(Compiled { + source: Arc::from(pattern), + weight, + engine, + }))) + } + pub fn as_str(&self) -> &str { + &self.0.source + } + pub fn is_match( + &self, + domain: &str, + decision: &RuleDecision, + ) -> std::result::Result { + decision.with(|budget| self.match_bounded(domain, budget, None)) + } + pub(crate) fn is_match_process( + &self, + value: &str, + decision: &RuleDecision, + ) -> std::result::Result { + decision.with(|budget| self.match_input(value, budget, None, false)) + } + fn match_bounded( + &self, + domain: &str, + budget: &mut DecisionBudget, + cancel: Option<&CancellationToken>, + ) -> std::result::Result { + self.match_input(domain, budget, cancel, true) + } + fn match_input( + &self, + domain: &str, + budget: &mut DecisionBudget, + cancel: Option<&CancellationToken>, + dns: bool, + ) -> std::result::Result { + budget.check(cancel)?; + if domain.is_empty() + || domain.len() + > if dns { + DOMAIN_BYTES + } else { + super::process::MAX_PROCESS_TEXT + } + || (dns && (!domain.is_ascii() || domain.bytes().any(|b| b.is_ascii_control()))) + || domain.contains('\0') + { + return budget.fail(MatchFailure::InvalidInput); + } + let complex = matches!(self.0.engine, Engine::Complex(_)); + let work = self.0.weight.saturating_mul(domain.len() as u64 + 1); + // fancy exposes only a per-regex backtrack bound, not actual steps or + // a caller-owned counter. Reserve its worst-case quota before execution. + // Reserving all of it prevents each rule from receiving a new total budget. + budget.reserve(work, if complex { CALL_BACKTRACKS + 1 } else { 0 }, cancel)?; + let started = Instant::now(); + let result = match &self.0.engine { + Engine::Linear(regex) => Ok(regex.is_match(domain)), + Engine::Complex(regex) => regex.is_match(domain).map_err(|error| { + if matches!( + error, + fancy_regex::Error::RuntimeError( + fancy_regex::RuntimeError::BacktrackLimitExceeded + ) + ) { + MatchFailure::Budget + } else { + MatchFailure::Engine + } + }), + }; + // Post-call accounting is not preemptive: cancellation/elapsed guards + // cannot interrupt the synchronous third-party VM in the current API. + budget.finish(started.elapsed(), cancel)?; + if let Err(failure) = result { + return budget.fail(failure); + } + result + } +} + +/// A small independent lexical compatibility fence, not a .NET parser. +/// The third-party public AST validates the accepted grammar afterward. +pub(super) fn common_dialect(pattern: &str) -> Result { + let bytes = pattern.as_bytes(); + let mut output = String::with_capacity(pattern.len()); + let (mut index, mut in_class, mut depth) = (0, false, 0_usize); + let mut scopes = Vec::new(); + let mut verbose = false; + while index < bytes.len() { + let tail = &pattern[index..]; + if bytes[index] == b'\\' { + let Some(&next) = bytes.get(index + 1) else { + return Err(invalid("invalid DOMAIN-REGEX escape")); + }; + if !in_class && tail.starts_with("\\k'") { + let rest = &tail[3..]; + let end = rest + .find('\'') + .ok_or_else(|| invalid("invalid DOMAIN-REGEX capture reference"))?; + if rest[..end].is_empty() || !rest[..end].bytes().all(|b| b.is_ascii_digit()) { + capture_name(&rest[..end])?; + } + output.push_str("\\k<"); + output.push_str(&rest[..end]); + output.push('>'); + index += 4 + end; + continue; + } + if !in_class && matches!(next, b'g' | b'K' | b'R' | b'N' | b'O' | b'h' | b'H') { + return Err(invalid("unsupported DOMAIN-REGEX escape")); + } + output.push('\\'); + let next = pattern[index + 1..].chars().next().expect("byte checked"); + output.push(next); + index += 1 + next.len_utf8(); + continue; + } + if in_class { + if tail.starts_with("&&") + || tail.starts_with("--") + || tail.starts_with("~~") + || bytes[index] == b'[' + { + return Err(invalid("unsupported DOMAIN-REGEX character class syntax")); + } + if bytes[index] == b']' { + in_class = false; + } + } else { + if verbose && bytes[index] == b'#' { + let end = tail.find('\n').map_or(tail.len(), |end| end + 1); + output.push_str(&tail[..end]); + index += end; + continue; + } + if let Some(rest) = tail.strip_prefix("(?#") { + let end = rest + .find(')') + .ok_or_else(|| invalid("invalid DOMAIN-REGEX comment"))?; + index += end + 4; + continue; + } + if bytes[index] == b'[' { + in_class = true; + } + if tail.starts_with("(?P") + || tail.starts_with("(*") + || tail.starts_with("(?~") + || tail.starts_with("(?|") + || tail.starts_with("(?(") + { + return Err(invalid("unsupported DOMAIN-REGEX group syntax")); + } + if let Some(rest) = tail.strip_prefix("(?'") { + let end = rest + .find('\'') + .ok_or_else(|| invalid("invalid DOMAIN-REGEX capture name"))?; + capture_name(&rest[..end])?; + output.push_str("(?<"); + output.push_str(&rest[..end]); + output.push('>'); + depth += 1; + scopes.push(verbose); + if depth > AST_DEPTH { + return Err(invalid("DOMAIN-REGEX compilation budget exceeded")); + } + index += end + 4; + continue; + } + if tail.starts_with("(?<") && !tail.starts_with("(?<=") && !tail.starts_with("(?') + .ok_or_else(|| invalid("invalid DOMAIN-REGEX capture name"))?; + capture_name(&rest[..end])?; + } + if matches!(bytes[index], b'*' | b'+' | b'?' | b'}') + && bytes.get(index + 1) == Some(&b'+') + { + return Err(invalid("unsupported DOMAIN-REGEX possessive quantifier")); + } + if bytes[index] == b'(' { + // Account lexical depth before calling a recursive third-party parser. + // Explicit common flags are accepted; Rust-only modes are rejected. + if let Some(rest) = tail.strip_prefix("(?") { + let flags = rest + .bytes() + .take_while(|b| b.is_ascii_alphabetic() || *b == b'-') + .count(); + if flags > 0 + && tail + .as_bytes() + .get(2 + flags) + .is_some_and(|b| matches!(b, b':' | b')')) + { + let mut on = true; + let old_verbose = verbose; + for flag in tail[2..2 + flags].bytes() { + match flag { + b'-' => on = false, + b'i' | b'm' | b's' => {} + b'x' => verbose = on, + _ => return Err(invalid("unsupported DOMAIN-REGEX inline mode")), + } + } + if bytes[index + 2 + flags] == b')' { + output.push_str(&tail[..3 + flags]); + index += 3 + flags; + continue; + } + depth += 1; + scopes.push(old_verbose); + if depth > AST_DEPTH { + return Err(invalid("DOMAIN-REGEX compilation budget exceeded")); + } + output.push_str(&tail[..3 + flags]); + index += 3 + flags; + continue; + } + } + depth += 1; + scopes.push(verbose); + if depth > AST_DEPTH { + return Err(invalid("DOMAIN-REGEX compilation budget exceeded")); + } + } else if bytes[index] == b')' { + depth = depth + .checked_sub(1) + .ok_or_else(|| invalid("invalid DOMAIN-REGEX group syntax"))?; + verbose = scopes + .pop() + .ok_or_else(|| invalid("invalid DOMAIN-REGEX group syntax"))?; + } + } + let next = tail.chars().next().expect("within input"); + output.push(next); + index += next.len_utf8(); + } + if in_class || depth != 0 { + return Err(invalid("invalid DOMAIN-REGEX syntax")); + } + Ok(output) +} +fn capture_name(name: &str) -> Result<()> { + if name.is_empty() + || !name + .bytes() + .next() + .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_') + || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') + { + return Err(invalid("unsupported DOMAIN-REGEX capture name")); + } + Ok(()) +} + +/// Return a bounded .NET-number -> engine-number map, rewriting numeric +/// backreferences only. Both engines otherwise keep their original grammar. +/// `pattern` has already passed common_dialect's lexical/depth fence. +pub(super) fn capture_dialect(pattern: &str) -> Result<(String, Vec)> { + let bytes = pattern.as_bytes(); + let mut groups = Vec::>::new(); + let mut references = Vec::new(); + let mut scopes = Vec::new(); + let (mut index, mut in_class, mut verbose) = (0, false, false); + while index < bytes.len() { + let tail = &pattern[index..]; + if bytes[index] == b'\\' { + let next = *bytes + .get(index + 1) + .ok_or_else(|| invalid("invalid regex escape"))?; + if !in_class && matches!(next, b'1'..=b'9') { + let end = index + + 1 + + bytes[index + 1..] + .iter() + .take_while(|b| b.is_ascii_digit()) + .count(); + let number = pattern[index + 1..end] + .parse::() + .map_err(|_| invalid("invalid regex capture reference"))?; + references.push((index, end, number)); + index = end; + continue; + } + if !in_class && tail.starts_with("\\k<") { + let end = index + + 3 + + tail[3..] + .find('>') + .ok_or_else(|| invalid("invalid regex capture reference"))?; + let key = &pattern[index + 3..end]; + if !key.is_empty() && key.bytes().all(|b| b.is_ascii_digit()) { + let number = key + .parse::() + .map_err(|_| invalid("invalid regex capture reference"))?; + references.push((index, end + 1, number)); + } + index = end + 1; + continue; + } + index += 1 + tail[1..].chars().next().expect("escape checked").len_utf8(); + continue; + } + if in_class { + if bytes[index] == b']' { + in_class = false; + } + } else if verbose && bytes[index] == b'#' { + index += tail.find('\n').map_or(tail.len(), |end| end + 1); + continue; + } else if bytes[index] == b'[' { + in_class = true; + } else if bytes[index] == b'(' { + if let Some(rest) = tail.strip_prefix("(?") { + let flags = rest + .bytes() + .take_while(|b| b.is_ascii_alphabetic() || *b == b'-') + .count(); + if flags > 0 + && bytes + .get(index + 2 + flags) + .is_some_and(|b| matches!(b, b':' | b')')) + { + let old_verbose = verbose; + let mut on = true; + for flag in tail[2..2 + flags].bytes() { + if flag == b'-' { + on = false; + } + if flag == b'x' { + verbose = on; + } + } + if bytes[index + 2 + flags] == b':' { + scopes.push(old_verbose); + } + index += 3 + flags; + continue; + } + } + scopes.push(verbose); + if tail.starts_with("(?<") && !tail.starts_with("(?<=") && !tail.starts_with("(?') + .ok_or_else(|| invalid("invalid regex capture name"))?; + let name = &pattern[index + 3..end]; + // Duplicate .NET names share a capture history; neither engine's + // ordinary single-slot name lookup can represent that history. + if groups.iter().any(|group| group.as_deref() == Some(name)) { + return Err(invalid("duplicate regex capture names are unsupported")); + } + groups.push(Some(name.to_owned())); + index = end + 1; + if groups.len() > AST_NODES { + return Err(invalid("regex capture budget exceeded")); + } + continue; + } + if !tail.starts_with("(?") { + groups.push(None); + if groups.len() > AST_NODES { + return Err(invalid("regex capture budget exceeded")); + } + } + } else if bytes[index] == b')' { + verbose = scopes + .pop() + .ok_or_else(|| invalid("invalid regex group syntax"))?; + } + index += tail.chars().next().expect("within pattern").len_utf8(); + } + let mut numbers = Vec::with_capacity(groups.len() + 1); + numbers.push(0); + for named in [false, true] { + for (index, group) in groups.iter().enumerate() { + if group.is_some() == named { + numbers.push(index + 1); + } + } + } + let mut rewritten = String::with_capacity(pattern.len()); + let mut offset = 0; + for (start, end, reference) in references { + rewritten.push_str(&pattern[offset..start]); + if let Some(&engine_number) = numbers + .get(reference) + .filter(|&&number| number != reference) + { + use std::fmt::Write as _; + write!(&mut rewritten, "\\k<{engine_number}>").expect("String write"); + } else { + // Unknown references remain the engine's compile error, rather than + // becoming a different group or an implicit compatibility fallback. + rewritten.push_str(&pattern[start..end]); + } + offset = end; + } + rewritten.push_str(&pattern[offset..]); + Ok((rewritten, numbers)) +} + +fn inspect(root: &fancy_regex::Expr) -> Result<(usize, u64, usize)> { + use fancy_regex::{AstNode, Expr}; + let mut pending = vec![(root, 0)]; + let (mut nodes, mut weight, mut groups) = (0_usize, 0_u64, 0_usize); + while let Some((expr, depth)) = pending.pop() { + nodes += 1; + if nodes > AST_NODES || depth > AST_DEPTH { + return Err(invalid("DOMAIN-REGEX compilation budget exceeded")); + } + weight = weight.saturating_add(match expr { + Expr::Literal { val, .. } => val.len().max(1) as u64, + Expr::Repeat { lo, hi, .. } => { + if *lo > REPEAT_BOUND || (*hi != usize::MAX && *hi > REPEAT_BOUND) { + return Err(invalid("DOMAIN-REGEX compilation budget exceeded")); + } + (*lo) + .max(if *hi == usize::MAX { DOMAIN_BYTES } else { *hi }) + .max(1) as u64 + } + _ => 1, + }); + match expr { + Expr::Empty + | Expr::Any { .. } + | Expr::Assertion(_) + | Expr::Literal { .. } + | Expr::Concat(_) + | Expr::Alt(_) + | Expr::LookAround(..) + | Expr::Repeat { .. } + | Expr::Delegate { .. } + | Expr::Backref { .. } + | Expr::AtomicGroup(_) => {} + Expr::Group(_) | Expr::AstNode(AstNode::AstGroup { .. }, _) => { + groups += 1; + } + Expr::AstNode( + AstNode::Backref { + relative_recursion_level: None, + .. + }, + _, + ) => {} + _ => return Err(invalid("unsupported DOMAIN-REGEX expression syntax")), + } + pending.extend(expr.children_iter().map(|child| (child, depth + 1))); + } + Ok((nodes, weight.max(1), groups)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn common_domain_expressions_compile_once_and_match() { + for (pattern, domain) in [ + (r"^(?!internal\.)[a-z0-9.-]+(?=\.test$)", "Api.Example.TEST"), + (r"(?<=edge\.)[a-z]+(?[a-z]+)\.\k