From 29f6e5512d32cbf1a67852b104fda8facd1ff130 Mon Sep 17 00:00:00 2001 From: Mike Fara Date: Wed, 9 Sep 2026 05:10:37 -0400 Subject: [PATCH] Sync sanitized implementation and public CI --- .github/ci-policy.yml | 5 + .github/workflows/actions-policy.yml | 2 - .github/workflows/ci.yml | 63 +- .github/workflows/fuzz.yml | 46 + .github/workflows/interoperability.yml | 71 + .gitignore | 3 + Cargo.lock | 989 ++++++++++- Cargo.toml | 2 + README.md | 40 +- THIRD_PARTY_NOTICES.md | 5 + crates/hj-acme/Cargo.toml | 27 + crates/hj-acme/src/certificate.rs | 179 ++ crates/hj-acme/src/challenge.rs | 451 +++++ crates/hj-acme/src/config.rs | 309 ++++ crates/hj-acme/src/dns.rs | 267 +++ crates/hj-acme/src/lib.rs | 17 + crates/hj-acme/src/manager.rs | 511 ++++++ crates/hj-acme/src/manager_tests.rs | 441 +++++ crates/hj-acme/src/storage.rs | 278 +++ crates/hj-acme/src/transport.rs | 235 +++ crates/hj-config/src/lib.rs | 7 +- crates/hj-config/src/model.rs | 32 +- crates/hj-config/src/parse/balance.rs | 328 ++++ crates/hj-config/src/parse/mod.rs | 127 +- crates/hj-config/src/parse/raw.rs | 19 + crates/hj-config/src/parse/vhost.rs | 20 +- crates/hj-config/tests/config_bundle.rs | 74 + crates/hj-core/src/completion.rs | 66 + crates/hj-core/src/lib.rs | 2 + crates/hj-extension/Cargo.toml | 14 + crates/hj-extension/src/lib.rs | 228 +++ crates/hj-fastcgi/Cargo.toml | 19 + crates/hj-fastcgi/src/handler.rs | 767 +++++++++ crates/hj-fastcgi/src/lib.rs | 18 + crates/hj-fastcgi/src/pool.rs | 320 ++++ crates/hj-fastcgi/src/proto.rs | 279 +++ crates/hj-fastcgi/src/response.rs | 183 ++ crates/hj-h2/src/server/completion_tests.rs | 190 +++ crates/hj-h2/src/server/mod.rs | 29 + crates/hj-h2/src/server/send.rs | 42 +- crates/hj-h2/src/server/state.rs | 1 + crates/hj-log/src/error_json.rs | 186 ++ crates/hj-log/src/lib.rs | 52 +- crates/hj-log/src/tracing_layer.rs | 34 + crates/hj-ocsp/Cargo.toml | 17 + crates/hj-ocsp/src/fetch.rs | 354 ++++ crates/hj-ocsp/src/ffi.rs | 117 ++ crates/hj-ocsp/src/lib.rs | 216 +++ crates/hj-ocsp/src/refresh.rs | 214 +++ crates/hj-ocsp/src/tests.rs | 482 ++++++ crates/hj-proxy/src/lib.rs | 110 +- crates/hj-proxy/src/pool.rs | 55 +- crates/hj-proxy/src/pool/balance.rs | 320 ++++ crates/hj-proxy/src/pool/health.rs | 429 +++++ crates/hj-proxy/src/target.rs | 53 +- crates/hj-proxy/tests/h2_upstream.rs | 28 +- crates/hj-proxy/tests/upstream_groups.rs | 222 +++ .../header_compat/ignored-actions.htaccess | 7 + .../header_compat/semantic-gaps.htaccess | 7 + .../fixtures/header_compat/supported.htaccess | 8 + crates/hj-rewrite/tests/header_compat.rs | 104 ++ crates/hj-tls/Cargo.toml | 5 + crates/hj-tls/src/lib.rs | 388 ++++- crates/hj-tls/src/ocsp.rs | 319 ++++ crates/httpjet/Cargo.toml | 31 +- crates/httpjet/src/acme_runtime.rs | 479 ++++++ crates/httpjet/src/admin.rs | 216 +++ crates/httpjet/src/admin_auth.rs | 195 +++ crates/httpjet/src/admin_protocol.rs | 300 ++++ crates/httpjet/src/admin_resources.rs | 473 ++++++ crates/httpjet/src/admin_submission.rs | 65 + crates/httpjet/src/admin_write.rs | 233 +++ crates/httpjet/src/config_transaction.rs | 593 +++++++ crates/httpjet/src/extensions.rs | 9 + crates/httpjet/src/listener_plan.rs | 239 +++ crates/httpjet/src/lscache/mod.rs | 92 +- crates/httpjet/src/main.rs | 552 ++++-- crates/httpjet/src/metrics.rs | 94 ++ crates/httpjet/src/ocsp_runtime.rs | 197 +++ crates/httpjet/src/otel.rs | 1025 +++++++++++ crates/httpjet/src/otel/batch.rs | 321 ++++ crates/httpjet/src/otel/lsapi_test.rs | 126 ++ crates/httpjet/src/pipeline/e2e.rs | 263 ++- crates/httpjet/src/pipeline/fast_memo.rs | 25 +- crates/httpjet/src/pipeline/htaccess_apply.rs | 27 + crates/httpjet/src/pipeline/mod.rs | 333 +++- crates/httpjet/src/pipeline/proxy_glue.rs | 47 +- crates/httpjet/src/pipeline/rewrite_glue.rs | 31 +- crates/httpjet/src/pipeline/suffix_routing.rs | 21 + crates/httpjet/src/resource_generation.rs | 422 +++++ crates/httpjet/src/serving_generation.rs | 70 + crates/httpjet/src/state.rs | 968 ++++++++++- crates/httpjet/src/tcp_candidate.rs | 220 +++ crates/httpjet/src/uring/bridge.rs | 211 ++- crates/httpjet/src/uring/generation_test.rs | 1228 ++++++++++++++ crates/httpjet/src/uring/h3.rs | 635 ++++++- crates/httpjet/src/uring/ktls.rs | 10 +- crates/httpjet/src/uring/ktls_policy.rs | 406 +++++ crates/httpjet/src/uring/mod.rs | 1493 +++++++++++++++-- crates/httpjet/src/uring/otel_quic_test.rs | 135 ++ crates/httpjet/src/uring/otel_test.rs | 444 +++++ crates/httpjet/src/uring/request_body.rs | 549 +++++- crates/httpjet/src/uring/unix_path.rs | 224 +++ crates/httpjet/src/uring/worker_group.rs | 1194 +++++++++++++ crates/httpjet/src/waf.rs | 485 ++++++ crates/httpjet/tests/h1_corpus.rs | 60 + fuzz/.gitignore | 1 - fuzz/Cargo.lock | 636 +++++++ fuzz/README.md | 35 + fuzz/fuzz_targets/h1_chunked_decode.rs | 39 +- fuzz/fuzz_targets/h1_request_framing.rs | 83 +- fuzz/h1_properties.rs | 124 ++ fuzz/seeds/h1_chunked_decode.hex | 10 + fuzz/seeds/h1_request_framing.hex | 13 + packaging/oci/Containerfile | 42 + packaging/oci/MOBY-LICENSE | 201 +++ packaging/oci/README.md | 103 ++ packaging/oci/compose.yaml | 28 + packaging/oci/litespeed/conf/httpd_config.xml | 55 + packaging/oci/litespeed/conf/mime.properties | 6 + .../litespeed/conf/vhosts/example.test.xml | 10 + .../oci/litespeed/example/public/index.html | 5 + packaging/oci/seccomp-httpjet.json | 878 ++++++++++ vendor/monoio/src/driver/op.rs | 31 +- vendor/monoio/src/net/tcp/listener.rs | 7 + 125 files changed, 26705 insertions(+), 776 deletions(-) create mode 100644 .github/ci-policy.yml create mode 100644 .github/workflows/fuzz.yml create mode 100644 .github/workflows/interoperability.yml create mode 100644 crates/hj-acme/Cargo.toml create mode 100644 crates/hj-acme/src/certificate.rs create mode 100644 crates/hj-acme/src/challenge.rs create mode 100644 crates/hj-acme/src/config.rs create mode 100644 crates/hj-acme/src/dns.rs create mode 100644 crates/hj-acme/src/lib.rs create mode 100644 crates/hj-acme/src/manager.rs create mode 100644 crates/hj-acme/src/manager_tests.rs create mode 100644 crates/hj-acme/src/storage.rs create mode 100644 crates/hj-acme/src/transport.rs create mode 100644 crates/hj-config/src/parse/balance.rs create mode 100644 crates/hj-config/tests/config_bundle.rs create mode 100644 crates/hj-core/src/completion.rs create mode 100644 crates/hj-extension/Cargo.toml create mode 100644 crates/hj-extension/src/lib.rs create mode 100644 crates/hj-fastcgi/Cargo.toml create mode 100644 crates/hj-fastcgi/src/handler.rs create mode 100644 crates/hj-fastcgi/src/lib.rs create mode 100644 crates/hj-fastcgi/src/pool.rs create mode 100644 crates/hj-fastcgi/src/proto.rs create mode 100644 crates/hj-fastcgi/src/response.rs create mode 100644 crates/hj-h2/src/server/completion_tests.rs create mode 100644 crates/hj-log/src/error_json.rs create mode 100644 crates/hj-ocsp/Cargo.toml create mode 100644 crates/hj-ocsp/src/fetch.rs create mode 100644 crates/hj-ocsp/src/ffi.rs create mode 100644 crates/hj-ocsp/src/lib.rs create mode 100644 crates/hj-ocsp/src/refresh.rs create mode 100644 crates/hj-ocsp/src/tests.rs create mode 100644 crates/hj-proxy/src/pool/balance.rs create mode 100644 crates/hj-proxy/src/pool/health.rs create mode 100644 crates/hj-proxy/tests/upstream_groups.rs create mode 100644 crates/hj-rewrite/tests/fixtures/header_compat/ignored-actions.htaccess create mode 100644 crates/hj-rewrite/tests/fixtures/header_compat/semantic-gaps.htaccess create mode 100644 crates/hj-rewrite/tests/fixtures/header_compat/supported.htaccess create mode 100644 crates/hj-rewrite/tests/header_compat.rs create mode 100644 crates/hj-tls/src/ocsp.rs create mode 100644 crates/httpjet/src/acme_runtime.rs create mode 100644 crates/httpjet/src/admin.rs create mode 100644 crates/httpjet/src/admin_auth.rs create mode 100644 crates/httpjet/src/admin_protocol.rs create mode 100644 crates/httpjet/src/admin_resources.rs create mode 100644 crates/httpjet/src/admin_submission.rs create mode 100644 crates/httpjet/src/admin_write.rs create mode 100644 crates/httpjet/src/config_transaction.rs create mode 100644 crates/httpjet/src/extensions.rs create mode 100644 crates/httpjet/src/listener_plan.rs create mode 100644 crates/httpjet/src/ocsp_runtime.rs create mode 100644 crates/httpjet/src/otel.rs create mode 100644 crates/httpjet/src/otel/batch.rs create mode 100644 crates/httpjet/src/otel/lsapi_test.rs create mode 100644 crates/httpjet/src/resource_generation.rs create mode 100644 crates/httpjet/src/serving_generation.rs create mode 100644 crates/httpjet/src/tcp_candidate.rs create mode 100644 crates/httpjet/src/uring/generation_test.rs create mode 100644 crates/httpjet/src/uring/ktls_policy.rs create mode 100644 crates/httpjet/src/uring/otel_quic_test.rs create mode 100644 crates/httpjet/src/uring/otel_test.rs create mode 100644 crates/httpjet/src/uring/unix_path.rs create mode 100644 crates/httpjet/src/uring/worker_group.rs create mode 100644 crates/httpjet/src/waf.rs create mode 100644 crates/httpjet/tests/h1_corpus.rs create mode 100644 fuzz/Cargo.lock create mode 100644 fuzz/h1_properties.rs create mode 100644 fuzz/seeds/h1_chunked_decode.hex create mode 100644 fuzz/seeds/h1_request_framing.hex create mode 100644 packaging/oci/Containerfile create mode 100644 packaging/oci/MOBY-LICENSE create mode 100644 packaging/oci/README.md create mode 100644 packaging/oci/compose.yaml create mode 100644 packaging/oci/litespeed/conf/httpd_config.xml create mode 100644 packaging/oci/litespeed/conf/mime.properties create mode 100644 packaging/oci/litespeed/conf/vhosts/example.test.xml create mode 100644 packaging/oci/litespeed/example/public/index.html create mode 100644 packaging/oci/seccomp-httpjet.json diff --git a/.github/ci-policy.yml b/.github/ci-policy.yml new file mode 100644 index 0000000..f62e59b --- /dev/null +++ b/.github/ci-policy.yml @@ -0,0 +1,5 @@ +version: 1 +workflows: + .github/workflows/ci.yml: fast + .github/workflows/fuzz.yml: deep + .github/workflows/interoperability.yml: deep diff --git a/.github/workflows/actions-policy.yml b/.github/workflows/actions-policy.yml index aedfc57..d641e77 100644 --- a/.github/workflows/actions-policy.yml +++ b/.github/workflows/actions-policy.yml @@ -9,8 +9,6 @@ on: paths: - '.github/workflows/**' - '.github/dependabot.yml' - schedule: - - cron: '53 4 * * 1' workflow_dispatch: permissions: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28318a4..bccb3dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,13 @@ name: CI on: - push: - branches: [main] pull_request: + paths-ignore: + - '**.md' + - 'docs/**' + workflow_dispatch: + schedule: + - cron: '7 3 * * 1' permissions: contents: read @@ -14,6 +18,7 @@ concurrency: jobs: build-and-test: + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-24.04 timeout-minutes: 45 steps: @@ -27,6 +32,11 @@ jobs: rustup toolchain install 1.97.0 --profile minimal --component rustfmt rustup default 1.97.0 + - name: Install optional OCSP build dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends libssl-dev pkg-config + - name: Check formatting run: cargo fmt --all --check @@ -36,6 +46,9 @@ jobs: - name: Check profiling build run: cargo check -p httpjet --bin httpjet --features profiling --locked + - name: Replay HTTP/1 framing and chunked corpus + run: cargo test --locked -p httpjet --test h1_corpus -- --nocapture + - name: Reject the CDDL flamegraph dependency run: | if cargo tree --all-features -i inferno >/tmp/inferno-tree 2>&1; then @@ -77,3 +90,49 @@ jobs: - name: Verify DCO sign-offs run: scripts/check-dco.sh "${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" + + linux-matrix: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + strategy: + fail-fast: false + matrix: + runner: [ubuntu-22.04, ubuntu-24.04-arm] + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + env: + CARGO_BUILD_JOBS: '2' + CARGO_INCREMENTAL: '0' + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: false + - name: Install Rust + run: rustup toolchain install 1.97.0 --profile minimal + - name: Install optional OCSP build dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends libssl-dev pkg-config + - name: Record platform and check feature builds + run: | + uname -a + rustc +1.97.0 -Vv + cargo +1.97.0 check --locked -p httpjet --all-features + - name: Replay parser corpus + run: cargo +1.97.0 test --locked -p httpjet --test h1_corpus -- --nocapture + - name: Exercise native io_uring H1 and H2 + # A denied io_uring operation fails the job; it is not silently skipped. + run: cargo +1.97.0 test --locked -p httpjet uring::uds_tests::uds_listener_serves_h1_and_h2c_with_unix_peer + + oci-smoke: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Check out source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: false + - name: Install HTTP/3 smoke client + run: python3 -m pip install --disable-pip-version-check aioquic==1.3.0 + - name: Build and run non-privileged OCI example + run: bash scripts/oci-smoke.sh diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..715f8a0 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,46 @@ +name: Bounded fuzzing + +on: + schedule: + - cron: '23 4 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: fuzz-${{ github.ref }} + cancel-in-progress: true + +jobs: + fuzz: + runs-on: ubuntu-24.04 + timeout-minutes: 35 + strategy: + fail-fast: false + max-parallel: 2 + matrix: + target: [h1_chunked_decode, h1_request_framing, hpack_decode, hpack_roundtrip, lsapi_resp_header] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: false + - name: Install pinned fuzz toolchain + run: | + rustup toolchain install nightly-2026-08-31 --profile minimal --component rust-src + cargo +nightly-2026-08-31 install cargo-fuzz --version 0.13.2 --locked + - name: Run bounded ASan fuzz target + env: + FUZZ_SECONDS: '300' + FUZZ_TARGET: ${{ matrix.target }} + run: bash scripts/fuzz-bounded.sh "$FUZZ_TARGET" + - name: Preserve corpus and crash artifacts + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: fuzz-${{ matrix.target }}-${{ github.run_id }} + path: | + fuzz/artifacts/${{ matrix.target }}/ + fuzz/corpus/${{ matrix.target }}/ + fuzz/Cargo.lock + retention-days: 14 diff --git a/.github/workflows/interoperability.yml b/.github/workflows/interoperability.yml new file mode 100644 index 0000000..290cad6 --- /dev/null +++ b/.github/workflows/interoperability.yml @@ -0,0 +1,71 @@ +name: Protocol interoperability + +on: + schedule: + - cron: '17 3 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: interop-${{ github.ref }} + cancel-in-progress: true + +jobs: + protocols: + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + CARGO_BUILD_JOBS: '2' + CARGO_INCREMENTAL: '0' + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: false + - name: Install test dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends libssl-dev pkg-config + rustup toolchain install 1.97.0 --profile minimal + rustup default 1.97.0 + python3 -m venv "$RUNNER_TEMP/h3-venv" + "$RUNNER_TEMP/h3-venv/bin/pip" install 'aioquic==1.3.0' 'h2==4.4.1' 'cryptography==50.0.1' + echo "$RUNNER_TEMP/h3-venv/bin" >> "$GITHUB_PATH" + curl --fail --location --max-time 60 https://github.com/summerwind/h2spec/releases/download/v2.6.0/h2spec_linux_amd64.tar.gz -o "$RUNNER_TEMP/h2spec.tar.gz" + echo "157ee0de702e01ad40e752dbf074b366027e550c8e7504f9450da2809e279318 $RUNNER_TEMP/h2spec.tar.gz" | sha256sum --check + tar -xzf "$RUNNER_TEMP/h2spec.tar.gz" -C "$RUNNER_TEMP" h2spec + - name: Build synthetic server + # Debug is acceptable for correctness; this is not a throughput benchmark. + run: cargo build --locked -p httpjet --features otel --bin httpjet + - name: H2 canonical conformance against synthetic TLS fixture + run: python3 -B scripts/ci-h2spec.py --binary target/debug/httpjet --h2spec "$RUNNER_TEMP/h2spec" + - name: H1 WebSocket and H2 dispatch tracing + run: cargo test --locked -p httpjet --features otel traced_fast_and_bridged_requests + - name: H3 actual QUIC transfers and tracing + run: cargo test --locked -p httpjet --features otel traced_quic_roundtrip -- --ignored + - name: OCSP real TLS policy and certificate lifecycle + run: | + cargo test --locked -p hj-ocsp + cargo test --locked -p hj-tls --features ocsp ocsp::tests + cargo build --locked -p httpjet --features ocsp,acme --bin httpjet + python3 -B scripts/ocsp-runtime-test.py --binary target/debug/httpjet + python3 -B scripts/ocsp-runtime-test.py --binary target/debug/httpjet --optional + python3 -B scripts/ocsp-runtime-test.py --binary target/debug/httpjet --optional --must-staple + - name: Build pinned local ACME test CA + env: + GOTOOLCHAIN: go1.24.2 + GOMAXPROCS: '2' + run: | + GOBIN="$RUNNER_TEMP/acme-tools" go install github.com/letsencrypt/pebble/v2/cmd/pebble@v2.10.1 + GOBIN="$RUNNER_TEMP/acme-tools" go install github.com/letsencrypt/pebble/v2/cmd/pebble-challtestsrv@v2.10.1 + echo "HJ_PEBBLE_BIN=$RUNNER_TEMP/acme-tools/pebble" >> "$GITHUB_ENV" + echo "HJ_PEBBLE_DNS_BIN=$RUNNER_TEMP/acme-tools/pebble-challtestsrv" >> "$GITHUB_ENV" + echo "HJ_PEBBLE_SOURCE=$(go env GOMODCACHE)/github.com/letsencrypt/pebble/v2@v2.10.1" >> "$GITHUB_ENV" + - name: ACME issued-order recovery and renewal + run: cargo test --locked -p hj-acme pebble_issuance_recovery_and_renewal -- --ignored + - name: ACME real server bootstrap and H1 H2 H3 activation + run: | + cargo build --locked -p httpjet --features acme --bin httpjet + python3 scripts/acme-pebble-test.py --binary target/debug/httpjet --pebble "$HJ_PEBBLE_BIN" --dns "$HJ_PEBBLE_DNS_BIN" --pebble-source "$HJ_PEBBLE_SOURCE" --bootstrap + python3 scripts/acme-pebble-test.py --binary target/debug/httpjet --pebble "$HJ_PEBBLE_BIN" --dns "$HJ_PEBBLE_DNS_BIN" --pebble-source "$HJ_PEBBLE_SOURCE" --bootstrap --dns01 diff --git a/.gitignore b/.gitignore index fb1fbaf..1fade4b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ conf/ # The public, synthetic quick-start fixture is safe and intentionally versioned. !examples/litespeed/conf/ !examples/litespeed/conf/** +# The OCI quick-start uses a second synthetic, non-secret config fixture. +!packaging/oci/litespeed/conf/ +!packaging/oci/litespeed/conf/** diff --git a/Cargo.lock b/Cargo.lock index 479b48c..7cf03e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -404,6 +404,32 @@ dependencies = [ "cc", ] +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpp_demangle" version = "0.4.5" @@ -657,6 +683,30 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -670,6 +720,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -678,6 +729,23 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + [[package]] name = "futures-macro" version = "0.3.34" @@ -708,8 +776,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -846,6 +917,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "hj-acme" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "http", + "http-body-util", + "httpdate", + "instant-acme", + "rcgen", + "reqwest", + "rustix", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "tokio", + "webpki-roots", + "x509-parser", +] + [[package]] name = "hj-cache" version = "0.1.0" @@ -899,6 +993,31 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "hj-extension" +version = "0.1.0" +dependencies = [ + "async-trait", + "hj-core", + "http", + "thiserror 2.0.20", +] + +[[package]] +name = "hj-fastcgi" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "hj-core", + "hj-lsapi", + "http", + "http-body-util", + "thiserror 2.0.20", + "tokio", + "tracing", +] + [[package]] name = "hj-geo" version = "0.1.0" @@ -960,6 +1079,19 @@ dependencies = [ "tracing", ] +[[package]] +name = "hj-ocsp" +version = "0.1.0" +dependencies = [ + "base64", + "foreign-types", + "openssl", + "openssl-sys", + "parking_lot", + "reqwest", + "tokio", +] + [[package]] name = "hj-pagecache" version = "0.1.0" @@ -1042,10 +1174,12 @@ dependencies = [ "arc-swap", "hj-config", "hj-core", + "hj-ocsp", "parking_lot", "rcgen", "rustls", "rustls-pemfile", + "tokio", "tracing", "x509-parser", ] @@ -1113,16 +1247,21 @@ dependencies = [ "arc-swap", "async-trait", "aws-lc-rs", + "brotli", "bytes", "clap", "dashmap", "flate2", "flume", + "futures-executor", "hj-acl", + "hj-acme", "hj-cache", "hj-compress", "hj-config", "hj-core", + "hj-extension", + "hj-fastcgi", "hj-geo", "hj-h2", "hj-http", @@ -1148,19 +1287,31 @@ dependencies = [ "monoio", "monoio-rustls", "nix", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-otlp", + "opentelemetry-proto", + "opentelemetry_sdk", "pprof", + "prost 0.14.4", "quinn-proto", - "quinn-udp", + "quinn-udp 0.6.1", "rcgen", + "reqwest", "rustc-hash", "rustix", "rustls", + "serde", + "serde_json", "sha2", "socket2 0.6.5", + "subtle", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", "tracing-subscriber", + "zstd", ] [[package]] @@ -1185,18 +1336,148 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2 0.6.5", "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", ] [[package]] @@ -1218,6 +1499,29 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instant-acme" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f05ad37c421b962354c358d347d4a6130151df9407978372d3ad7f0c8f71a64" +dependencies = [ + "async-trait", + "aws-lc-rs", + "base64", + "bytes", + "http", + "http-body", + "http-body-util", + "httpdate", + "hyper-rustls", + "rcgen", + "rustls-pki-types", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", +] + [[package]] name = "io-uring" version = "0.6.4" @@ -1249,6 +1553,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "jobserver" version = "0.1.35" @@ -1304,6 +1657,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "lock_api" version = "0.4.14" @@ -1556,43 +1915,161 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "parking_lot" -version = "0.12.5" +name = "openssl" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "lock_api", - "parking_lot_core", + "bitflags 2.13.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "openssl-macros" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "pem" -version = "3.0.6" +name = "openssl-probe" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ - "base64", - "serde_core", + "cc", + "libc", + "pkg-config", + "vcpkg", ] [[package]] -name = "petgraph" -version = "0.6.5" +name = "opentelemetry" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.20", +] + +[[package]] +name = "opentelemetry-http" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" +dependencies = [ + "http", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost 0.14.4", + "reqwest", + "thiserror 2.0.20", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost 0.14.4", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "portable-atomic", + "rand 0.9.5", + "thiserror 2.0.20", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", "indexmap", @@ -1610,6 +2087,21 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1628,9 +2120,9 @@ dependencies = [ "log", "nix", "once_cell", - "prost", + "prost 0.12.6", "prost-build", - "prost-derive", + "prost-derive 0.12.6", "sha2", "smallvec", "spin 0.10.1", @@ -1693,7 +2185,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.12.6", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive 0.14.4", ] [[package]] @@ -1710,7 +2212,7 @@ dependencies = [ "once_cell", "petgraph", "prettyplease", - "prost", + "prost 0.12.6", "prost-types", "regex", "syn 2.0.119", @@ -1730,13 +2232,26 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "prost-types" version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" dependencies = [ - "prost", + "prost 0.12.6", ] [[package]] @@ -1755,6 +2270,26 @@ dependencies = [ "serde", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp 0.5.15", + "rustc-hash", + "rustls", + "socket2 0.6.5", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + [[package]] name = "quinn-proto" version = "0.11.17" @@ -1778,6 +2313,20 @@ dependencies = [ "web-time", ] +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quinn-udp" version = "0.6.1" @@ -1928,6 +2477,43 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -1954,6 +2540,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -1991,6 +2586,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -2010,6 +2617,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.15" @@ -2040,12 +2674,59 @@ dependencies = [ "wait-timeout", ] +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +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 = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -2076,6 +2757,19 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "sha1" version = "0.10.7" @@ -2129,6 +2823,22 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -2236,6 +2946,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -2348,6 +3067,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.12.0" @@ -2415,6 +3144,51 @@ dependencies = [ "tokio", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -2512,6 +3286,24 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.25.0" @@ -2528,6 +3320,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -2543,6 +3341,16 @@ dependencies = [ "libc", ] +[[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" @@ -2580,6 +3388,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.127" @@ -2612,6 +3430,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web-time" version = "1.1.0" @@ -2622,6 +3450,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -2647,6 +3484,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" @@ -2813,6 +3659,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "x509-parser" version = "0.18.1" @@ -2841,6 +3693,29 @@ dependencies = [ "time", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.56" @@ -2861,12 +3736,72 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index 29fa46f..b455d31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ hj-config = { path = "crates/hj-config" } sha2 = "0.10" hmac = "0.12" hj-core = { path = "crates/hj-core" } +hj-extension = { path = "crates/hj-extension" } hj-tls = { path = "crates/hj-tls" } hj-http = { path = "crates/hj-http" } hj-static = { path = "crates/hj-static" } @@ -33,6 +34,7 @@ hj-compress = { path = "crates/hj-compress" } hj-log = { path = "crates/hj-log" } hj-acl = { path = "crates/hj-acl" } hj-lsapi = { path = "crates/hj-lsapi" } +hj-fastcgi = { path = "crates/hj-fastcgi" } hj-rewrite = { path = "crates/hj-rewrite" } hj-proxy = { path = "crates/hj-proxy" } hj-pagecache = { path = "crates/hj-pagecache" } diff --git a/README.md b/README.md index 583b252..f9478ea 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,10 @@ httpjet is an experimental Linux web server written in Rust. It reads LiteSpeed-compatible XML, speaks LSAPI to `lsphp`, and supports H1/H2/H3, TLS, static files, proxying, rewrites, compression, and an opt-in page cache. -It is early-stage software built around one production configuration. Test on -alternate ports before using it elsewhere. +It is early-stage software. Test on alternate ports before using it in a +production environment. -httpjet powers [WindowsForum.com](https://windowsforum.com) in production. -Learn more—or at least enjoy the animation—at -[httpjet.net](https://httpjet.net). +Learn more—or at least enjoy the animation—at [httpjet.net](https://httpjet.net). ## Build and try it @@ -28,6 +26,38 @@ curl -H 'Host: example.test' http://127.0.0.1:8080/ Use `--root` with an existing LiteSpeed configuration tree. Run `httpjet serve --help` for runtime options. +Before adopting it, review the current [capability matrix and operational +limits](docs/capability-matrix.md), [configuration +examples](docs/configuration-examples.md), and [LiteSpeed migration +guide](docs/migrating-from-litespeed.md). Compatibility is limited to the +implemented/tested directive and protocol surface; `check --strict` warnings +must be reviewed rather than treated as parity. + +Optional Brotli/zstd uploads use the bounded, transport-independent +[request decompression policy](docs/request-decompression.md); gzip retains its +existing default behavior. + +Short-TTL origin caching is supported today. The missing reusable route-policy +controls and their isolated executable proof are documented in the +[microcache policy requirements](docs/microcache-policy.md). + +`.htaccess` response-header operations are intentionally a subset of Apache +`mod_headers`; the exact supported and ignored forms are pinned in the +[header-directive compatibility inventory](docs/header-directive-compatibility.md). + +A hardened, non-root [OCI example](packaging/oci/README.md) documents the +required writable mounts, separate TCP/UDP publication for HTTP/3, io_uring +runtime requirements, and its non-privileged smoke gate. + +Optional certificate automation is available with `--features acme` and +explicit runtime opt-in. See +[HTTP-01](docs/acme-http01.md) and [DNS-01/wildcard configuration](docs/acme-dns01.md); it is disabled by +default and does not replace existing certbot configuration automatically. + +Optional [OCSP stapling](docs/ocsp-stapling.md) uses `--features ocsp` and an +explicit responder URL. It is for compatible certificate authorities, not +current Let's Encrypt certificates (whose issuer retired OCSP). + ## Development The `httpjet` binary is under `crates/httpjet`; reusable components are the diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 3d8454f..5f49979 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -9,6 +9,7 @@ Third-party components keep their own licenses: | monoio-rustls | MIT OR Apache-2.0 | `vendor/monoio-rustls/` | | pprof-rs | Apache-2.0 | `vendor/pprof/` | | LSAPI reference material | BSD-3-Clause | `crates/hj-lsapi/vendor/` | +| Moby default seccomp profile | Apache-2.0 | `packaging/oci/seccomp-httpjet.json` | License texts remain with the vendored sources; local changes are summarized in `vendor/PATCHES.md`. Run `python3 scripts/check-dependency-licenses.py` for @@ -18,3 +19,7 @@ Compatibility work used public documentation, black-box behavior, and GPL-licensed OpenLiteSpeed source. No OpenLiteSpeed source tree is vendored. The profiling feature emits raw pprof data and does not distribute the CDDL-licensed `inferno` renderer. + +The OCI seccomp profile is derived from `moby/profiles` commit +`61eaf32614c7c71b60bd8927d3e6a4ffc8ff1f31`. Its default allowlist is retained +and the three io_uring syscalls required by httpjet are added. diff --git a/crates/hj-acme/Cargo.toml b/crates/hj-acme/Cargo.toml new file mode 100644 index 0000000..7cb1052 --- /dev/null +++ b/crates/hj-acme/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "hj-acme" +version.workspace = true +edition.workspace = true +publish.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +async-trait = { workspace = true } +http = { workspace = true } +rustix = { workspace = true } +bytes = { workspace = true } +http-body-util = { workspace = true } +httpdate = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = "1" +instant-acme = { version = "=0.8.5", default-features = false, features = ["aws-lc-rs"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls"] } +rustls-pki-types = { workspace = true } +rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem", "aws_lc_rs"] } +rustls = { workspace = true } +rustls-pemfile = { workspace = true } +webpki-roots = { workspace = true } +x509-parser = "0.18.1" diff --git a/crates/hj-acme/src/certificate.rs b/crates/hj-acme/src/certificate.rs new file mode 100644 index 0000000..aae5553 --- /dev/null +++ b/crates/hj-acme/src/certificate.rs @@ -0,0 +1,179 @@ +//! Validate a complete issued pair before persistence or resolver replacement. +use crate::{AcmeConfig, Domain, IssuedCertificate, ManagerError}; +use rustls::{ + RootCertStore, + client::{WebPkiServerVerifier, danger::ServerCertVerifier}, + pki_types::{ServerName, UnixTime}, + sign::CertifiedKey, +}; +use std::{collections::BTreeSet, sync::Arc}; +use x509_parser::{extensions::GeneralName, prelude::FromDer}; + +pub struct CertificateValidator { + verifier: Arc, + provider: Arc, +} + +/// Constructible only by strict certificate validation. Certificate and signing +/// key remain an indivisible pair when handed to an SNI resolver. +pub struct ValidatedCertificate { + pub(crate) key: Arc, + pub(crate) expires: u64, +} +impl ValidatedCertificate { + pub fn certified_key(&self) -> Arc { + self.key.clone() + } + pub fn expires_unix(&self) -> u64 { + self.expires + } +} + +impl CertificateValidator { + /// Public WebPKI roots by default; an explicit test root replaces them. + /// The issuance root can differ from the CA HTTPS endpoint's test root. + pub fn new(test_root: Option<&[u8]>) -> Result { + let mut roots = RootCertStore::empty(); + if let Some(pem) = test_root { + if pem.len() > 64 * 1024 { + return Err(ManagerError::State); + } + for cert in rustls_pemfile::certs(&mut &pem[..]) { + roots + .add(cert.map_err(|_| ManagerError::State)?) + .map_err(|_| ManagerError::State)?; + } + } else { + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + } + let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); + let verifier = + WebPkiServerVerifier::builder_with_provider(Arc::new(roots), provider.clone()) + .build() + .map_err(|_| ManagerError::State)?; + Ok(Self { verifier, provider }) + } + + pub fn validate( + &self, + issued: &IssuedCertificate, + config: &AcmeConfig, + ) -> Result { + if issued.certificate_pem.len() > 64 * 1024 || issued.private_key_pem.len() > 8192 { + return Err(ManagerError::Crypto); + } + let chain = rustls_pemfile::certs(&mut issued.certificate_pem.as_bytes()) + .collect::, _>>() + .map_err(|_| ManagerError::Crypto)?; + if chain.is_empty() || chain.len() > 8 { + return Err(ManagerError::Crypto); + } + let (remaining, leaf) = + x509_parser::certificate::X509Certificate::from_der(chain[0].as_ref()) + .map_err(|_| ManagerError::Crypto)?; + if !remaining.is_empty() { + return Err(ManagerError::Crypto); + } + let san = leaf + .subject_alternative_name() + .map_err(|_| ManagerError::Crypto)? + .ok_or(ManagerError::Crypto)?; + let mut names = BTreeSet::new(); + for name in &san.value.general_names { + let GeneralName::DNSName(name) = name else { + return Err(ManagerError::Crypto); + }; + let domain = if config.is_dns01() { + Domain::parse_dns01(name) + } else { + Domain::parse(name) + } + .map_err(|_| ManagerError::Crypto)?; + if !names.insert(domain) { + return Err(ManagerError::Crypto); + } + } + if names != *config.domains() { + return Err(ManagerError::Crypto); + } + let expires = u64::try_from(leaf.validity().not_after.timestamp()) + .map_err(|_| ManagerError::Crypto)?; + let now = UnixTime::now(); + if expires <= now.as_secs() { + return Err(ManagerError::Crypto); + } + for domain in config.domains() { + let name = ServerName::try_from(domain.verification_name()) + .map_err(|_| ManagerError::Crypto)?; + self.verifier + .verify_server_cert(&chain[0], &chain[1..], &name, &[], now) + .map_err(|_| ManagerError::Crypto)?; + } + let private_key = rustls_pemfile::private_key(&mut issued.private_key_pem.as_bytes()) + .map_err(|_| ManagerError::Crypto)? + .ok_or(ManagerError::Crypto)?; + let key = CertifiedKey::from_der(chain, private_key, &self.provider) + .map_err(|_| ManagerError::Crypto)?; + key.keys_match().map_err(|_| ManagerError::Crypto)?; + Ok(ValidatedCertificate { + key: Arc::new(key), + expires, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Directory; + #[test] + fn certificate_requires_trusted_matching_key_and_exact_dns_set() { + let key = rcgen::KeyPair::generate().unwrap(); + let params = rcgen::CertificateParams::new(vec!["example.test".to_owned()]).unwrap(); + let cert = params.self_signed(&key).unwrap(); + let config = AcmeConfig::new( + Directory::parse("https://ca.test/dir", false).unwrap(), + &["example.test"], + "/tmp/not-opened".into(), + true, + ) + .unwrap(); + let validator = CertificateValidator::new(Some(cert.pem().as_bytes())).unwrap(); + let mut pair = IssuedCertificate { + certificate_pem: cert.pem(), + private_key_pem: key.serialize_pem(), + }; + assert!(validator.validate(&pair, &config).is_ok()); + assert!( + CertificateValidator::new(None) + .unwrap() + .validate(&pair, &config) + .is_err() + ); + pair.private_key_pem = rcgen::KeyPair::generate().unwrap().serialize_pem(); + assert!(validator.validate(&pair, &config).is_err()); + pair.private_key_pem = key.serialize_pem(); + let other = AcmeConfig::new( + Directory::parse("https://ca.test/dir", false).unwrap(), + &["other.test"], + "/tmp/not-opened".into(), + true, + ) + .unwrap(); + assert!(validator.validate(&pair, &other).is_err()); + let extra = rcgen::CertificateParams::new(vec!["example.test".into(), "extra.test".into()]) + .unwrap() + .self_signed(&key) + .unwrap(); + let extra_validator = CertificateValidator::new(Some(extra.pem().as_bytes())).unwrap(); + pair.certificate_pem = extra.pem(); + assert!(extra_validator.validate(&pair, &config).is_err()); + let mut expired = rcgen::CertificateParams::new(vec!["example.test".into()]).unwrap(); + expired.not_before = rcgen::date_time_ymd(2000, 1, 1); + expired.not_after = rcgen::date_time_ymd(2001, 1, 1); + let expired = expired.self_signed(&key).unwrap(); + let expired_validator = CertificateValidator::new(Some(expired.pem().as_bytes())).unwrap(); + pair.certificate_pem = expired.pem(); + assert!(expired_validator.validate(&pair, &config).is_err()); + } +} diff --git a/crates/hj-acme/src/challenge.rs b/crates/hj-acme/src/challenge.rs new file mode 100644 index 0000000..7f46e88 --- /dev/null +++ b/crates/hj-acme/src/challenge.rs @@ -0,0 +1,451 @@ +use crate::{AcmeConfig, Domain}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::{Arc, Mutex, Weak}, + time::{Duration, Instant}, +}; + +const PREFIX: &str = "/.well-known/acme-challenge/"; +const CAPACITY: usize = 128; +const MAX_TTL: Duration = Duration::from_secs(15 * 60); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChallengeError { + Identifier, + Token, + Thumbprint, + Expiry, + Conflict, + Capacity, +} +impl std::fmt::Display for ChallengeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "ACME challenge rejected: {self:?}") + } +} +impl std::error::Error for ChallengeError {} + +type Key = (Domain, String); +struct Entry { + id: u64, + expires: Instant, + body: Arc, +} +struct Inner { + entries: BTreeMap, + sequence: u64, +} + +/// A bounded in-memory HTTP-01 registry; cloning shares the same active orders. +/// Only the future issuer may publish entries; requests never register tokens. +#[derive(Clone)] +pub struct ChallengeRegistry { + domains: Arc>, + inner: Arc>, +} + +/// Removing/dropping an order removes its challenge, including cancellation. +/// IDs prevent an expired old lease from deleting a newer entry for the same key. +#[must_use = "dropping the lease removes the active challenge"] +pub struct ChallengeLease { + key: Key, + id: u64, + inner: Weak>, +} +impl Drop for ChallengeLease { + fn drop(&mut self) { + if let Some(inner) = self.inner.upgrade() { + let mut state = inner.lock().unwrap_or_else(|e| e.into_inner()); + if state + .entries + .get(&self.key) + .is_some_and(|entry| entry.id == self.id) + { + state.entries.remove(&self.key); + } + } + } +} + +/// Transport-neutral result. The integration MUST terminate routing on Some, +/// use these headers unchanged, and never insert challenge responses in caches. +pub struct ChallengeResponse { + pub status: http::StatusCode, + pub body: Arc, +} +impl ChallengeResponse { + pub fn headers(&self) -> http::HeaderMap { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/octet-stream"), + ); + headers.insert( + http::header::CACHE_CONTROL, + http::HeaderValue::from_static("no-store"), + ); + if self.status == http::StatusCode::METHOD_NOT_ALLOWED { + headers.insert(http::header::ALLOW, http::HeaderValue::from_static("GET")); + } + headers + } +} + +fn base64url(value: &str) -> bool { + value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} +fn valid_token(value: &str) -> bool { + (22..=256).contains(&value.len()) && base64url(value) +} + +impl ChallengeRegistry { + pub fn new(config: &AcmeConfig) -> Self { + Self { + domains: Arc::new(config.domains().clone()), + inner: Arc::new(Mutex::new(Inner { + entries: BTreeMap::new(), + sequence: 0, + })), + } + } + pub fn register( + &self, + domain: &Domain, + token: &str, + thumbprint: &str, + ttl: Duration, + ) -> Result { + self.register_at(domain, token, thumbprint, ttl, Instant::now()) + } + fn register_at( + &self, + domain: &Domain, + token: &str, + thumbprint: &str, + ttl: Duration, + now: Instant, + ) -> Result { + if !self.domains.contains(domain) { + return Err(ChallengeError::Identifier); + } + if !valid_token(token) { + return Err(ChallengeError::Token); + } + // SHA-256 JWK thumbprint = 32 bytes / 43 unpadded base64url chars. + // The final sextet has two zero padding bits; reject non-canonical forms. + if thumbprint.len() != 43 + || !base64url(thumbprint) + || !b"AEIMQUYcgkosw048".contains(&thumbprint.as_bytes()[42]) + { + return Err(ChallengeError::Thumbprint); + } + if ttl.is_zero() || ttl > MAX_TTL { + return Err(ChallengeError::Expiry); + } + let expires = now.checked_add(ttl).ok_or(ChallengeError::Expiry)?; + let key = (domain.clone(), token.to_owned()); + let mut state = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + state.entries.retain(|_, entry| entry.expires > now); + if state.entries.contains_key(&key) { + return Err(ChallengeError::Conflict); + } + if state.entries.len() >= CAPACITY { + return Err(ChallengeError::Capacity); + } + let id = state + .sequence + .checked_add(1) + .ok_or(ChallengeError::Capacity)?; + state.sequence = id; + state.entries.insert( + key.clone(), + Entry { + id, + expires, + body: format!("{token}.{thumbprint}").into(), + }, + ); + Ok(ChallengeLease { + key, + id, + inner: Arc::downgrade(&self.inner), + }) + } + + /// `physical_tls` is the actual transport, never a forwarded scheme header. + /// `authority` is the validated request authority, not a wildcard vhost name. + /// No decoding, dot-segment removal or filesystem lookup occurs here. + pub fn lookup( + &self, + method: &http::Method, + authority: &str, + uri: &http::Uri, + physical_tls: bool, + ) -> Option { + self.lookup_at(method, authority, uri, physical_tls, Instant::now()) + } + fn lookup_at( + &self, + method: &http::Method, + authority: &str, + uri: &http::Uri, + physical_tls: bool, + now: Instant, + ) -> Option { + if physical_tls || authority.len() > 260 || authority.contains('@') { + return None; + } + let authority: http::uri::Authority = authority.parse().ok()?; + if authority.as_str().contains(':') && authority.port_u16().is_none() { + return None; + } + let domain = Domain::parse(authority.host()).ok()?; + if !self.domains.contains(&domain) { + return None; + } + let token = uri.path().strip_prefix(PREFIX)?; + let empty = |status| { + Some(ChallengeResponse { + status, + body: Arc::from(""), + }) + }; + if !valid_token(token) + || uri.query().is_some() + || uri.authority().is_some() + || uri.scheme().is_some() + { + return empty(http::StatusCode::NOT_FOUND); + } + if method != http::Method::GET { + return empty(http::StatusCode::METHOD_NOT_ALLOWED); + } + let key = (domain, token.to_owned()); + let mut state = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + if state + .entries + .get(&key) + .is_some_and(|entry| entry.expires <= now) + { + state.entries.remove(&key); + } + let Some(entry) = state.entries.get(&key) else { + return empty(http::StatusCode::NOT_FOUND); + }; + Some(ChallengeResponse { + status: http::StatusCode::OK, + body: entry.body.clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Directory; + const TOKEN: &str = "0123456789abcdefghijklmnopqrstu"; + const THUMB: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + fn setup() -> (ChallengeRegistry, Domain) { + let config = AcmeConfig::new( + Directory::parse("https://ca.test/dir", false).unwrap(), + &["example.test", "other.test"], + "/tmp/synthetic-acme".into(), + true, + ) + .unwrap(); + ( + ChallengeRegistry::new(&config), + Domain::parse("example.test").unwrap(), + ) + } + fn uri(token: &str) -> http::Uri { + format!("{PREFIX}{token}").parse().unwrap() + } + #[test] + fn host_scope_exact_path_and_non_cacheable_response() { + let (registry, domain) = setup(); + let lease = registry.register(&domain, TOKEN, THUMB, MAX_TTL).unwrap(); + let response = registry + .lookup(&http::Method::GET, "EXAMPLE.TEST.:80", &uri(TOKEN), false) + .unwrap(); + assert_eq!(response.status, 200); + assert_eq!(&*response.body, format!("{TOKEN}.{THUMB}")); + assert_eq!(response.headers()["cache-control"], "no-store"); + assert_eq!( + registry + .lookup(&http::Method::GET, "other.test", &uri(TOKEN), false) + .unwrap() + .status, + 404 + ); + assert!( + registry + .lookup(&http::Method::GET, "foreign.test", &uri(TOKEN), false) + .is_none() + ); + assert!( + registry + .lookup(&http::Method::GET, "example.test", &uri(TOKEN), true) + .is_none() + ); + for bad in [ + "x/../../../index.php", + "../threads", + "%2e%2e/index.php", + "x%2f..%2fadmin", + "index.php", + "short", + "abcdefghijklmnopqrstuv?x=1", + "abcdefghijklmnopqrstuv/", + ] { + assert_eq!( + registry + .lookup(&http::Method::GET, "example.test", &uri(bad), false) + .unwrap() + .status, + 404, + "{bad}" + ); + } + let response = registry + .lookup(&http::Method::POST, "example.test", &uri(TOKEN), false) + .unwrap(); + assert_eq!(response.status, 405); + assert_eq!(response.headers()["allow"], "GET"); + let absolute: http::Uri = format!("http://foreign.test{PREFIX}{TOKEN}") + .parse() + .unwrap(); + assert_eq!( + registry + .lookup(&http::Method::GET, "example.test", &absolute, false) + .unwrap() + .status, + 404 + ); + for authority in [ + "user@example.test", + "example.test:99999", + "example.test:bad", + "example.test.evil", + "example.test%00", + "example.test..", + ] { + assert!( + registry + .lookup(&http::Method::GET, authority, &uri(TOKEN), false) + .is_none(), + "{authority}" + ); + } + drop(lease); + assert_eq!( + registry + .lookup(&http::Method::GET, "example.test", &uri(TOKEN), false) + .unwrap() + .status, + 404 + ); + } + #[test] + fn invalid_registration_is_rejected() { + let (registry, domain) = setup(); + assert!(matches!( + registry.register( + &Domain::parse("foreign.test").unwrap(), + TOKEN, + THUMB, + MAX_TTL + ), + Err(ChallengeError::Identifier) + )); + for token in [ + "", + "short", + "abcdefghijklmnopqrstuv=", + "abcdefghijklmnopqrstuv/", + "abcdefghijklmnopqrstuv.", + ] { + assert!(matches!( + registry.register(&domain, token, THUMB, MAX_TTL), + Err(ChallengeError::Token) + )); + } + assert!(matches!( + registry.register(&domain, TOKEN, &format!("{}B", &THUMB[..42]), MAX_TTL), + Err(ChallengeError::Thumbprint) + )); + for ttl in [Duration::ZERO, MAX_TTL + Duration::from_secs(1)] { + assert!(matches!( + registry.register(&domain, TOKEN, THUMB, ttl), + Err(ChallengeError::Expiry) + )); + } + } + #[test] + fn expiry_and_old_lease_cannot_remove_new_challenge() { + let (registry, domain) = setup(); + let now = Instant::now(); + let old = registry + .register_at(&domain, TOKEN, THUMB, Duration::from_secs(1), now) + .unwrap(); + assert!(matches!( + registry.register_at(&domain, TOKEN, THUMB, MAX_TTL, now), + Err(ChallengeError::Conflict) + )); + let later = now + Duration::from_secs(1); + assert_eq!( + registry + .lookup_at( + &http::Method::GET, + "example.test", + &uri(TOKEN), + false, + later + ) + .unwrap() + .status, + 404 + ); + let new = registry + .register_at(&domain, TOKEN, THUMB, MAX_TTL, later) + .unwrap(); + drop(old); + assert_eq!( + registry + .lookup_at( + &http::Method::GET, + "example.test", + &uri(TOKEN), + false, + later + ) + .unwrap() + .status, + 200 + ); + drop(new); + } + #[test] + fn registry_capacity_and_raii_cancellation() { + let (registry, domain) = setup(); + let mut leases = Vec::new(); + for index in 0..CAPACITY { + leases.push( + registry + .register(&domain, &format!("{TOKEN}{index}"), THUMB, MAX_TTL) + .unwrap(), + ); + } + assert!(matches!( + registry.register(&domain, TOKEN, THUMB, MAX_TTL), + Err(ChallengeError::Capacity) + )); + leases.pop(); + let lease = registry.register(&domain, TOKEN, THUMB, MAX_TTL).unwrap(); + drop(leases); + drop(lease); + assert!(registry.inner.lock().unwrap().entries.is_empty()); + } +} diff --git a/crates/hj-acme/src/config.rs b/crates/hj-acme/src/config.rs new file mode 100644 index 0000000..288f6b0 --- /dev/null +++ b/crates/hj-acme/src/config.rs @@ -0,0 +1,309 @@ +use std::{ + collections::BTreeSet, + fmt, + net::IpAddr, + path::{Component, Path, PathBuf}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConfigError { + Domain, + Directory, + Identifiers, + Storage, + TermsNotAccepted, +} +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Domain => "ACME requires an explicit ASCII DNS name (no wildcard or IP)", + Self::Directory => { + "ACME directory must be HTTPS, or explicit test-mode HTTP on a loopback IP" + } + Self::Identifiers => "ACME requires 1..100 distinct identifiers", + Self::Storage => { + "ACME storage must be an absolute non-root path without dot components" + } + Self::TermsNotAccepted => "ACME terms acceptance must be explicit", + }) + } +} +impl std::error::Error for ConfigError {} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Domain(String); +impl Domain { + /// DNS-01 additionally supports a single complete leftmost wildcard label. + pub fn parse_dns01(value: &str) -> Result { + match value.strip_prefix("*.") { + Some(base) => { + let base = Self::parse(base)?; + if base.0.len() > 251 { + return Err(ConfigError::Domain); + } + Ok(Self(format!("*.{}", base.0))) + } + None => Self::parse(value), + } + } + pub fn verification_name(&self) -> String { + self.0 + .strip_prefix("*.") + .map_or_else(|| self.0.clone(), |base| format!("a.{base}")) + } + pub fn dns_base(&self) -> &str { + self.0.strip_prefix("*.").unwrap_or(&self.0) + } + pub fn parse(value: &str) -> Result { + let value = value.strip_suffix('.').unwrap_or(value); + if value.is_empty() + || value.len() > 253 + || !value.is_ascii() + || !value.contains('.') + || value.parse::().is_ok() + || !value.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label.as_bytes()[0].is_ascii_alphanumeric() + && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric() + && label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + }) + { + return Err(ConfigError::Domain); + } + Ok(Self(value.to_ascii_lowercase())) + } + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug)] +pub struct Directory(http::Uri); +impl Directory { + /// Test mode permits only literal loopback IP HTTP URLs, never a hostname + /// whose DNS result might change. HTTPS remains the production requirement. + pub fn parse(value: &str, allow_loopback_http: bool) -> Result { + if value.len() > 2048 { + return Err(ConfigError::Directory); + } + let uri: http::Uri = value.parse().map_err(|_| ConfigError::Directory)?; + let authority = uri.authority().ok_or(ConfigError::Directory)?; + let host = authority + .host() + .trim_start_matches('[') + .trim_end_matches(']'); + if host.is_empty() + || value.contains('#') + || authority.as_str().contains('@') + || uri.query().is_some() + || uri.path().is_empty() + || authority.port_u16() == Some(0) + || (authority.as_str().contains(':') + && authority.port_u16().is_none() + && !authority.as_str().ends_with(']')) + { + return Err(ConfigError::Directory); + } + let allowed = match uri.scheme_str() { + Some("https") => true, + Some("http") => { + allow_loopback_http && host.parse::().is_ok_and(|ip| ip.is_loopback()) + } + _ => false, + }; + if !allowed { + return Err(ConfigError::Directory); + } + Ok(Self(uri)) + } + pub fn uri(&self) -> &http::Uri { + &self.0 + } +} + +#[derive(Clone, Debug)] +pub struct AcmeConfig { + directory: Directory, + domains: BTreeSet, + storage: PathBuf, + dns01: bool, +} +impl AcmeConfig { + pub fn new( + directory: Directory, + identifiers: &[&str], + storage: PathBuf, + accept_terms: bool, + ) -> Result { + Self::build(directory, identifiers, storage, accept_terms, false) + } + pub fn new_dns01( + directory: Directory, + identifiers: &[&str], + storage: PathBuf, + accept_terms: bool, + ) -> Result { + Self::build(directory, identifiers, storage, accept_terms, true) + } + fn build( + directory: Directory, + identifiers: &[&str], + storage: PathBuf, + accept_terms: bool, + dns01: bool, + ) -> Result { + if !accept_terms { + return Err(ConfigError::TermsNotAccepted); + } + if identifiers.is_empty() || identifiers.len() > 100 { + return Err(ConfigError::Identifiers); + } + let domains: BTreeSet<_> = identifiers + .iter() + .map(|s| { + if dns01 { + Domain::parse_dns01(s) + } else { + Domain::parse(s) + } + }) + .collect::>()?; + if domains.len() != identifiers.len() { + return Err(ConfigError::Identifiers); + } + if dns01 + && domains + .iter() + .any(|d| d.dns_base().len() + "_acme-challenge.".len() > 253) + { + return Err(ConfigError::Domain); + } + if !storage.is_absolute() + || storage == Path::new("/") + || storage.as_os_str().len() > 4096 + || storage.as_os_str().as_encoded_bytes().contains(&0) + || storage + .components() + .any(|p| matches!(p, Component::ParentDir | Component::CurDir)) + || storage + .as_os_str() + .as_encoded_bytes() + .split(|b| *b == b'/') + .any(|p| p == b".") + { + return Err(ConfigError::Storage); + } + Ok(Self { + directory, + domains, + storage, + dns01, + }) + } + pub fn directory(&self) -> &Directory { + &self.directory + } + pub fn domains(&self) -> &BTreeSet { + &self.domains + } + pub fn storage(&self) -> &Path { + &self.storage + } + pub fn is_dns01(&self) -> bool { + self.dns01 + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn identifiers_are_explicit_and_canonical() { + assert_eq!( + Domain::parse("EXAMPLE.Test.").unwrap().as_str(), + "example.test" + ); + for bad in [ + "", + "*.example.test", + "127.0.0.1", + "[::1]", + "localhost", + "é.test", + "x..test", + "-x.test", + "x-.test", + "x.test..", + "x.test/path", + "x.test:80", + " x.test", + "x_test.test", + ] { + assert!(Domain::parse(bad).is_err(), "{bad}"); + } + assert!(Domain::parse(&format!("{}.test", "x".repeat(64))).is_err()); + } + #[test] + fn directory_trust_boundary() { + assert!(Directory::parse("https://ca.test/directory", false).is_ok()); + for good in ["http://127.0.0.1:14000/dir", "http://[::1]:14000/dir"] { + assert!(Directory::parse(good, true).is_ok()); + assert!(Directory::parse(good, false).is_err()); + } + for bad in [ + "http://localhost/dir", + "http://10.0.0.1/dir", + "ftp://ca.test/dir", + "https://u:p@ca.test/dir", + "https://ca.test/dir?secret=1", + "https://ca.test/dir#x", + "https://ca.test:99999/dir", + "https://ca.test:0/dir", + "/directory", + ] { + assert!(Directory::parse(bad, true).is_err(), "{bad}"); + } + } + #[test] + fn configuration_is_explicit_bounded_and_non_mutating() { + let dir = Directory::parse("https://ca.test/dir", false).unwrap(); + let make = + |names: &[&str], path: &str, tos| AcmeConfig::new(dir.clone(), names, path.into(), tos); + assert!(make(&["a.test"], "/var/lib/httpjet/acme", true).is_ok()); + assert!(make(&["a.test"], "/var/lib/httpjet/acme", false).is_err()); + assert!(make(&[], "/x", true).is_err()); + assert!(make(&["a.test", "A.TEST."], "/x", true).is_err()); + for path in ["/", "relative", "/x/../y", "/x/./y", "/x\0y"] { + assert!(make(&["a.test"], path, true).is_err(), "{path}"); + } + } + + #[test] + fn dns01_requires_room_for_the_challenge_owner() { + let dir = Directory::parse("https://ca.test/dir", false).unwrap(); + let base = format!( + "{}.{}.{}.{}", + "a".repeat(63), + "b".repeat(63), + "c".repeat(63), + "d".repeat(45) + ); + assert_eq!(base.len(), 237); + let wildcard = format!("*.{base}"); + assert!(AcmeConfig::new_dns01(dir.clone(), &[&base, &wildcard], "/x".into(), true).is_ok()); + let too_long = format!("{base}d"); + assert!(Domain::parse(&too_long).is_ok()); + assert!(AcmeConfig::new_dns01(dir, &[&too_long], "/x".into(), true).is_err()); + for bad in [ + "*.*.example.test", + "x*.example.test", + "*.localhost", + "*.127.0.0.1", + ] { + assert!(Domain::parse_dns01(bad).is_err()); + } + } +} diff --git a/crates/hj-acme/src/dns.rs b/crates/hj-acme/src/dns.rs new file mode 100644 index 0000000..933648f --- /dev/null +++ b/crates/hj-acme/src/dns.rs @@ -0,0 +1,267 @@ +//! Provider-neutral DNS-01, with an explicitly scoped webhook adapter. +use crate::{Directory, Domain}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeSet, fmt, time::Duration}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DnsError; +impl fmt::Display for DnsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("DNS challenge provider operation failed") + } +} +impl std::error::Error for DnsError {} + +#[derive(Clone)] +pub struct DnsScope { + zones: BTreeSet, +} +impl DnsScope { + pub fn new(zones: &[&str]) -> Result { + if zones.is_empty() || zones.len() > 16 { + return Err(DnsError); + } + let zones = zones + .iter() + .map(|s| Domain::parse(s).map_err(|_| DnsError)) + .collect::, _>>()?; + Ok(Self { zones }) + } + pub fn permits(&self, domain: &Domain) -> bool { + let base = domain.dns_base(); + self.zones.iter().any(|zone| { + base == zone.as_str() + || base + .strip_suffix(zone.as_str()) + .is_some_and(|prefix| prefix.ends_with('.')) + }) + } + pub fn identity(&self) -> String { + self.zones + .iter() + .map(Domain::as_str) + .collect::>() + .join(",") + } +} + +/// Exact TXT value ownership: cleanup MUST NOT delete another value in the RRset. +/// This contains public challenge material only, never provider credentials. +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct DnsRecord { + name: String, + value: String, +} +impl DnsRecord { + pub(crate) fn new(domain: &Domain, value: String) -> Result { + if domain.dns_base().len() + "_acme-challenge.".len() > 253 + || value.len() != 43 + || !value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return Err(DnsError); + } + Ok(Self { + name: format!("_acme-challenge.{}", domain.dns_base()), + value, + }) + } + pub fn name(&self) -> &str { + &self.name + } + pub fn value(&self) -> &str { + &self.value + } + pub(crate) fn domain(&self) -> Result { + Domain::parse(self.name.strip_prefix("_acme-challenge.").ok_or(DnsError)?) + .map_err(|_| DnsError) + } + pub(crate) fn valid_for(&self, scope: &DnsScope) -> bool { + self.domain() + .is_ok_and(|d| scope.permits(&d) && Self::new(&d, self.value.clone()).is_ok()) + } +} + +/// Credentials remain encapsulated by the provider, never in ACME snapshots. +/// Operations must be idempotent, propagate errors, and preserve unrelated TXT +/// values. The manager adds deadlines, durable cleanup intent and scope checks. +#[async_trait::async_trait] +pub trait DnsProvider: Send + Sync { + fn scope(&self) -> &DnsScope; + /// Stable non-secret adapter identity; changing it invalidates stored cleanup. + fn identity(&self) -> String; + async fn present(&self, record: &DnsRecord) -> Result<(), DnsError>; + async fn ready(&self, record: &DnsRecord) -> Result; + async fn cleanup(&self, record: &DnsRecord) -> Result<(), DnsError>; +} + +/// POSTs a small JSON protocol to an operator-managed DNS controller. The bearer +/// credential is scoped to this endpoint (no redirects or environment proxies). +/// Adapter operators are responsible for minimum DNS-provider zone privileges. +pub struct WebhookDnsProvider { + endpoint: Directory, + scope: DnsScope, + bearer: Option, + client: reqwest::Client, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Reply { + ok: bool, + #[serde(default)] + ready: bool, +} +impl WebhookDnsProvider { + /// HTTPS and a bearer token are mandatory unless the endpoint is explicitly + /// test-mode literal-loopback HTTP. Optional test roots never disable TLS. + pub fn new( + endpoint: Directory, + scope: DnsScope, + bearer: Option, + test_root: Option<&[u8]>, + ) -> Result { + if bearer.as_ref().is_some_and(|b| { + b.len() < 16 || b.len() > 4096 || !b.bytes().all(|c| c.is_ascii_graphic()) + }) || (endpoint.uri().scheme_str() == Some("https") && bearer.is_none()) + { + return Err(DnsError); + } + let mut builder = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .retry(reqwest::retry::never()) + .timeout(Duration::from_secs(5)) + .pool_max_idle_per_host(1); + if let Some(root) = test_root { + if root.len() > 65536 { + return Err(DnsError); + } + builder = builder + .tls_certs_only([reqwest::Certificate::from_pem(root).map_err(|_| DnsError)?]); + } + Ok(Self { + endpoint, + scope, + bearer, + client: builder.build().map_err(|_| DnsError)?, + }) + } + async fn call(&self, operation: &str, record: &DnsRecord) -> Result { + if !record.valid_for(&self.scope) { + return Err(DnsError); + } + let body = serde_json::to_vec(&serde_json::json!({"version": 1, "operation": operation, "name": record.name, "value": record.value})).map_err(|_| DnsError)?; + let mut request = self + .client + .post(self.endpoint.uri().to_string()) + .header("content-type", "application/json") + .body(body); + if let Some(bearer) = &self.bearer { + request = request.bearer_auth(bearer); + } + let mut response = request.send().await.map_err(|_| DnsError)?; + if !response.status().is_success() || response.content_length().is_some_and(|n| n > 16384) { + return Err(DnsError); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| DnsError)? { + if chunk.len() > 16384 - bytes.len() { + return Err(DnsError); + } + bytes.extend_from_slice(&chunk); + } + let reply: Reply = serde_json::from_slice(&bytes).map_err(|_| DnsError)?; + if !reply.ok { + return Err(DnsError); + } + Ok(reply) + } +} +#[async_trait::async_trait] +impl DnsProvider for WebhookDnsProvider { + fn scope(&self) -> &DnsScope { + &self.scope + } + fn identity(&self) -> String { + format!("webhook:{}:{}", self.endpoint.uri(), self.scope.identity()) + } + async fn present(&self, record: &DnsRecord) -> Result<(), DnsError> { + self.call("present", record).await.map(|_| ()) + } + async fn ready(&self, record: &DnsRecord) -> Result { + self.call("ready", record).await.map(|r| r.ready) + } + async fn cleanup(&self, record: &DnsRecord) -> Result<(), DnsError> { + self.call("cleanup", record).await.map(|_| ()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn scopes_are_dns_boundaries_and_wildcards_do_not_broaden_them() { + let scope = DnsScope::new(&["example.test"]).unwrap(); + for good in ["example.test", "a.example.test", "*.example.test"] { + assert!(scope.permits(&Domain::parse_dns01(good).unwrap())); + } + for bad in ["example.test.evil", "notexample.test", "other.test"] { + assert!(!scope.permits(&Domain::parse_dns01(bad).unwrap())); + } + assert!(Domain::parse("*.example.test").is_err()); + assert!(Domain::parse_dns01("*.*.example.test").is_err()); + assert!(Domain::parse_dns01("foo*.example.test").is_err()); + let record = DnsRecord::new( + &Domain::parse_dns01("*.example.test").unwrap(), + "A".repeat(43), + ) + .unwrap(); + assert_eq!(record.name(), "_acme-challenge.example.test"); + assert!(record.valid_for(&scope)); + } + + #[tokio::test] + async fn webhook_rejects_out_of_scope_and_oversized_or_redirected_replies() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + for response in [ + "HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:1/leak\r\nContent-Length: 0\r\n\r\n", + "HTTP/1.1 200 OK\r\nContent-Length: 16385\r\n\r\n", + "HTTP/1.1 200 OK\r\nContent-Length: 12\r\n\r\n{\"ok\":false}", + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = Directory::parse( + &format!("http://{}/dns", listener.local_addr().unwrap()), + true, + ) + .unwrap(); + let provider = WebhookDnsProvider::new( + endpoint, + DnsScope::new(&["example.test"]).unwrap(), + Some("fixture-credential-only".into()), + None, + ) + .unwrap(); + let bad = + DnsRecord::new(&Domain::parse("other.test").unwrap(), "A".repeat(43)).unwrap(); + assert_eq!(provider.present(&bad).await, Err(DnsError)); + assert!( + tokio::time::timeout(Duration::from_millis(10), listener.accept()) + .await + .is_err() + ); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0; 4096]; + let n = socket.read(&mut request).await.unwrap(); + assert!(n > 0); + socket.write_all(response.as_bytes()).await.unwrap(); + }); + let record = + DnsRecord::new(&Domain::parse("example.test").unwrap(), "A".repeat(43)).unwrap(); + assert_eq!(provider.present(&record).await, Err(DnsError)); + server.await.unwrap(); + } + } +} diff --git a/crates/hj-acme/src/lib.rs b/crates/hj-acme/src/lib.rs new file mode 100644 index 0000000..09d366e --- /dev/null +++ b/crates/hj-acme/src/lib.rs @@ -0,0 +1,17 @@ +//! Opt-in durable ACME HTTP-01 issuance, recovery and certificate validation. +//! Configuration/registry construction is inert; opening a manager provisions +//! private local state, and only `AcmeManager::issue` contacts the configured CA. +mod certificate; +mod challenge; +mod config; +mod dns; +mod manager; +mod storage; +mod transport; + +pub use certificate::{CertificateValidator, ValidatedCertificate}; +pub use challenge::{ChallengeError, ChallengeLease, ChallengeRegistry, ChallengeResponse}; +pub use config::{AcmeConfig, ConfigError, Directory, Domain}; +pub use dns::{DnsError, DnsProvider, DnsRecord, DnsScope, WebhookDnsProvider}; +pub use manager::{AcmeManager, IssuedCertificate, ManagerError}; +pub use storage::PrivateStore; diff --git a/crates/hj-acme/src/manager.rs b/crates/hj-acme/src/manager.rs new file mode 100644 index 0000000..ed52497 --- /dev/null +++ b/crates/hj-acme/src/manager.rs @@ -0,0 +1,511 @@ +//! Durable single-order ACME HTTP-01 driver. Certificate activation is separate. +use instant_acme::{ + Account, AuthorizationStatus, ChallengeType, Identifier, Key, NewOrder, OrderStatus, +}; +use rustls_pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeSet, + fmt, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use crate::{ + AcmeConfig, CertificateValidator, ChallengeRegistry, Domain, PrivateStore, + ValidatedCertificate, transport::BoundedHttp, +}; +use crate::{DnsProvider, DnsRecord}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ManagerError { + Storage, + State, + Crypto, + Authority, + Challenge, + Timeout, + Backoff, + UncertainOrder, + Dns, +} +impl fmt::Display for ManagerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Storage => "ACME durable storage failed; reconcile before retry", + Self::State => "ACME persisted state is invalid or belongs to another configuration", + Self::Crypto => "ACME key or CSR operation failed", + Self::Authority => "ACME authority operation failed", + Self::Challenge => "ACME authorization or challenge rejected", + Self::Timeout => "ACME order deadline exceeded", + Self::Backoff => "ACME retry is not due", + Self::Dns => "ACME DNS challenge operation failed; cleanup intent retained", + Self::UncertainOrder => { + "ACME order creation was interrupted; explicit reconciliation required" + } + }) + } +} +impl std::error::Error for ManagerError {} + +// Never derive Debug on persisted state or keys. All serialized snapshots stay +// in PrivateStore; operator-facing errors intentionally omit CA response text. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Snapshot { + version: u32, + directory: String, + domains: Vec, + account_key: Vec, + account_id: Option, + order: Option, + next_attempt: u64, + failures: u32, + installed: Option, + #[serde(default)] + dns_identity: Option, + #[serde(default)] + dns_cleanup: Vec, +} +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct PendingOrder { + // None is a write-ahead marker: never automatically repeat newOrder after + // losing its response. ACME has no idempotency key for this operation. + url: Option, + private_key: String, + csr: Vec, +} + +/// An issued pair is not trusted/installed until the TLS validation step succeeds. +/// No Debug implementation to keep the private key out of logs. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct IssuedCertificate { + pub certificate_pem: String, + pub private_key_pem: String, +} + +pub struct AcmeManager { + config: AcmeConfig, + store: PrivateStore, + state: Snapshot, + http: BoundedHttp, + challenges: ChallengeRegistry, + dns: Option>, +} + +impl AcmeManager { + /// Construction does not contact a CA. A fresh account key is persisted + /// before any possible registration, making account creation restart-safe. + /// `test_root` replaces platform roots; it never disables certificate checks. + pub fn open(config: AcmeConfig, test_root: Option<&[u8]>) -> Result { + if config.is_dns01() { + return Err(ManagerError::State); + } + Self::open_inner(config, test_root, None) + } + + pub fn open_dns01( + config: AcmeConfig, + test_root: Option<&[u8]>, + provider: Arc, + ) -> Result { + if !config.is_dns01() || !config.domains().iter().all(|d| provider.scope().permits(d)) { + return Err(ManagerError::State); + } + Self::open_inner(config, test_root, Some(provider)) + } + + fn open_inner( + config: AcmeConfig, + test_root: Option<&[u8]>, + dns: Option>, + ) -> Result { + let dns_identity = dns.as_ref().map(|p| p.identity()); + if dns_identity + .as_ref() + .is_some_and(|i| i.is_empty() || i.len() > 4096) + { + return Err(ManagerError::State); + } + let http = BoundedHttp::new(config.directory().clone(), test_root) + .map_err(|_| ManagerError::Authority)?; + let mut store = PrivateStore::open(&config).map_err(|_| ManagerError::Storage)?; + let domains = config + .domains() + .iter() + .map(|d| d.as_str().to_owned()) + .collect::>(); + let state = match store.read().map_err(|_| ManagerError::Storage)? { + Some(bytes) => { + let state: Snapshot = + serde_json::from_slice(&bytes).map_err(|_| ManagerError::State)?; + if state.version != 1 + || state.dns_identity != dns_identity + || state.dns_cleanup.len() > 100 + || state.dns_cleanup.iter().any(|r| { + !dns.as_ref().is_some_and(|p| { + r.valid_for(p.scope()) + && r.domain().is_ok_and(|d| { + config + .domains() + .iter() + .any(|configured| configured.dns_base() == d.as_str()) + }) + }) + }) + || state.directory != config.directory().uri().to_string() + || state.domains != domains + || state.account_key.len() > 4096 + || state.account_key.is_empty() + || state + .account_id + .as_ref() + .is_some_and(|u| !u.parse().is_ok_and(|u| http.permits(&u))) + || state.order.as_ref().is_some_and(|o| { + o.private_key.len() > 8192 + || o.csr.len() > 64 * 1024 + || o.url + .as_ref() + .is_some_and(|u| !u.parse().is_ok_and(|u| http.permits(&u))) + }) + { + return Err(ManagerError::State); + } + Key::from_pkcs8_der(PrivatePkcs8KeyDer::from(state.account_key.clone())) + .map_err(|_| ManagerError::State)?; + state + } + None => { + let (_, key) = Key::generate_pkcs8().map_err(|_| ManagerError::Crypto)?; + let state = Snapshot { + version: 1, + directory: config.directory().uri().to_string(), + domains, + account_key: key.secret_pkcs8_der().to_vec(), + account_id: None, + order: None, + next_attempt: 0, + failures: 0, + installed: None, + dns_identity, + dns_cleanup: Vec::new(), + }; + store + .replace(&serde_json::to_vec(&state).map_err(|_| ManagerError::State)?) + .map_err(|_| ManagerError::Storage)?; + state + } + }; + let challenges = ChallengeRegistry::new(&config); + Ok(Self { + config, + store, + state, + http, + challenges, + dns, + }) + } + + pub fn challenges(&self) -> ChallengeRegistry { + self.challenges.clone() + } + + fn persist(&mut self) -> Result<(), ManagerError> { + self.store + .replace(&serde_json::to_vec(&self.state).map_err(|_| ManagerError::State)?) + .map_err(|_| ManagerError::Storage) + } + + /// Runs one bounded issuance/recovery attempt, retaining the same CSR/key on + /// every retry. Caller must activate then acknowledge before starting renewal. + pub async fn issue(&mut self) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| ManagerError::State)? + .as_secs(); + if now < self.state.next_attempt { + return Err(ManagerError::Backoff); + } + if self.state.order.as_ref().is_some_and(|o| o.url.is_none()) { + return Err(ManagerError::UncertainOrder); + } + let backoff = 300_u64.saturating_mul(1 << self.state.failures.min(8)); + let jitter = u64::from(*self.state.account_key.last().unwrap()) * (backoff / 5) / 256; + self.state.next_attempt = now.saturating_add(backoff + jitter); + self.state.failures = self.state.failures.saturating_add(1); + self.persist()?; + self.cleanup_dns().await?; + let result = tokio::time::timeout(Duration::from_secs(120), self.issue_inner()) + .await + .unwrap_or(Err(ManagerError::Timeout)); + if self.http.retry_after() > self.state.next_attempt { + self.state.next_attempt = self.http.retry_after(); + self.persist()?; + } + self.cleanup_dns().await?; + result + } + + async fn issue_inner(&mut self) -> Result { + let builder = Account::builder_with_http(Box::new(self.http.clone())); + let der = PrivatePkcs8KeyDer::from(self.state.account_key.clone()); + let account = if let Some(id) = &self.state.account_id { + builder + .from_parts(id.clone(), der, self.state.directory.clone()) + .await + .map_err(|_| ManagerError::Authority)? + } else { + let key = Key::from_pkcs8_der(der.clone_key()).map_err(|_| ManagerError::Crypto)?; + let (account, _) = builder + .create_from_key( + (key, PrivateKeyDer::Pkcs8(der)), + self.state.directory.clone(), + ) + .await + .map_err(|_| ManagerError::Authority)?; + if !account.id().parse().is_ok_and(|u| self.http.permits(&u)) { + return Err(ManagerError::Authority); + } + self.state.account_id = Some(account.id().to_owned()); + self.persist()?; + account + }; + let mut order = if let Some(pending) = &self.state.order { + account + .order(pending.url.clone().ok_or(ManagerError::UncertainOrder)?) + .await + .map_err(|_| ManagerError::Authority)? + } else { + let key = rcgen::KeyPair::generate().map_err(|_| ManagerError::Crypto)?; + let mut params = rcgen::CertificateParams::new(self.state.domains.clone()) + .map_err(|_| ManagerError::Crypto)?; + params.distinguished_name = rcgen::DistinguishedName::new(); + let csr = params + .serialize_request(&key) + .map_err(|_| ManagerError::Crypto)?; + self.state.order = Some(PendingOrder { + url: None, + private_key: key.serialize_pem(), + csr: csr.der().to_vec(), + }); + self.persist()?; + let identifiers = self + .state + .domains + .iter() + .cloned() + .map(Identifier::Dns) + .collect::>(); + let order = account + .new_order(&NewOrder::new(&identifiers)) + .await + .map_err(|_| ManagerError::Authority)?; + if !order.url().parse().is_ok_and(|u| self.http.permits(&u)) { + return Err(ManagerError::Authority); + } + self.state.order.as_mut().unwrap().url = Some(order.url().to_owned()); + self.persist()?; + order + }; + let mut leases = Vec::new(); + if order.state().authorizations.len() > 100 { + return Err(ManagerError::Authority); + } + let mut names = BTreeSet::new(); + let mut authorizations = order.authorizations(); + while let Some(auth) = authorizations.next().await { + let mut auth = auth.map_err(|_| ManagerError::Authority)?; + let domain = (if self.config.is_dns01() { + Domain::parse_dns01(&auth.identifier().to_string()) + } else { + Domain::parse(&auth.identifier().to_string()) + }) + .map_err(|_| ManagerError::Challenge)?; + if !self.config.domains().contains(&domain) || !names.insert(domain.clone()) { + return Err(ManagerError::Challenge); + } + match auth.status { + AuthorizationStatus::Valid => continue, + AuthorizationStatus::Pending => {} + _ => return Err(ManagerError::Challenge), + } + let mut challenge = auth + .challenge(if self.config.is_dns01() { + ChallengeType::Dns01 + } else { + ChallengeType::Http01 + }) + .ok_or(ManagerError::Challenge)?; + if let Some(provider) = self.dns.clone() { + let record = DnsRecord::new(&domain, challenge.key_authorization().dns_value()) + .map_err(|_| ManagerError::Dns)?; + if !provider.scope().permits(&domain) { + return Err(ManagerError::Dns); + } + // Record intent BEFORE touching DNS. Unknown present outcomes + // are recoverable by idempotent exact-value cleanup on restart. + self.state.dns_cleanup.push(record.clone()); + self.persist()?; + tokio::time::timeout(Duration::from_secs(5), provider.present(&record)) + .await + .map_err(|_| ManagerError::Dns)? + .map_err(|_| ManagerError::Dns)?; + loop { + if tokio::time::timeout(Duration::from_secs(5), provider.ready(&record)) + .await + .map_err(|_| ManagerError::Dns)? + .map_err(|_| ManagerError::Dns)? + { + break; + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + } else { + leases.push( + self.challenges + .register( + &domain, + &challenge.token, + account.key_thumbprint(), + Duration::from_secs(180), + ) + .map_err(|_| ManagerError::Challenge)?, + ); + } + challenge + .set_ready() + .await + .map_err(|_| ManagerError::Authority)?; + } + if names != *self.config.domains() { + return Err(ManagerError::Challenge); + } + loop { + match order.state().status { + OrderStatus::Pending => {} + OrderStatus::Ready => { + order + .finalize_csr(&self.state.order.as_ref().unwrap().csr) + .await + .map_err(|_| ManagerError::Authority)?; + } + OrderStatus::Processing => {} + OrderStatus::Valid => { + let pem = order + .certificate() + .await + .map_err(|_| ManagerError::Authority)? + .ok_or(ManagerError::Authority)?; + return Ok(IssuedCertificate { + certificate_pem: pem, + private_key_pem: self.state.order.as_ref().unwrap().private_key.clone(), + }); + } + OrderStatus::Invalid => { + self.state.order = None; + self.persist()?; + return Err(ManagerError::Authority); + } + } + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| ManagerError::State)? + .as_secs(); + tokio::time::sleep(Duration::from_secs( + self.http.retry_after().saturating_sub(now).clamp(2, 120), + )) + .await; + order.refresh().await.map_err(|_| ManagerError::Authority)?; + } + } + + /// Validate and commit the pair in the SAME atomic account/order snapshot. + /// Only the returned validated object may be published to live resolvers. + /// A failed disk write must leave the previous resolver generation untouched. + pub fn install( + &mut self, + issued: IssuedCertificate, + validator: &CertificateValidator, + ) -> Result { + if !self.state.dns_cleanup.is_empty() { + return Err(ManagerError::Dns); + } + let validated = validator.validate(&issued, &self.config)?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| ManagerError::State)? + .as_secs(); + let remaining = validated.expires_unix().saturating_sub(now); + let jitter = u64::from(self.state.account_key[0]) * (remaining / 20) / 256; + self.state.next_attempt = now.saturating_add((remaining * 2 / 3).saturating_sub(jitter)); + self.state.installed = Some(issued); + self.state.failures = 0; + self.state.order = None; + self.persist()?; + Ok(validated) + } + + /// Revalidate the durable complete pair at boot, without network traffic. + pub fn installed( + &self, + validator: &CertificateValidator, + ) -> Result, ManagerError> { + self.state + .installed + .as_ref() + .map(|pair| validator.validate(pair, &self.config)) + .transpose() + } + + pub fn next_attempt_unix(&self) -> u64 { + self.state.next_attempt + } + + /// Bounded cooperative shutdown/restart cleanup. Failed operations stay in + /// the private journal, blocking new challenge mutations until reconciled. + /// SIGKILL cannot run async cleanup; the next owner replays this journal. + pub async fn cleanup_dns(&mut self) -> Result<(), ManagerError> { + if self.state.dns_cleanup.is_empty() { + return Ok(()); + } + tokio::time::timeout(Duration::from_secs(20), self.cleanup_dns_inner()) + .await + .map_err(|_| ManagerError::Dns)? + } + async fn cleanup_dns_inner(&mut self) -> Result<(), ManagerError> { + let provider = self.dns.clone().ok_or(ManagerError::State)?; + while let Some(record) = self.state.dns_cleanup.last().cloned() { + if !record.valid_for(provider.scope()) { + return Err(ManagerError::State); + } + tokio::time::timeout(Duration::from_secs(5), provider.cleanup(&record)) + .await + .map_err(|_| ManagerError::Dns)? + .map_err(|_| ManagerError::Dns)?; + self.state.dns_cleanup.pop(); + self.persist()?; + } + Ok(()) + } + + /// Reconcile an interrupted newOrder using a URL recovered by the operator + /// from this account's CA records. It is never a general URL fetch surface. + pub fn reconcile_order(&mut self, url: &str) -> Result<(), ManagerError> { + if !url.parse().is_ok_and(|u| self.http.permits(&u)) { + return Err(ManagerError::State); + } + let pending = self.state.order.as_mut().ok_or(ManagerError::State)?; + if pending.url.is_some() { + return Err(ManagerError::State); + } + pending.url = Some(url.to_owned()); + self.persist() + } +} + +#[cfg(test)] +#[path = "manager_tests.rs"] +mod tests; diff --git a/crates/hj-acme/src/manager_tests.rs b/crates/hj-acme/src/manager_tests.rs new file mode 100644 index 0000000..c1757d0 --- /dev/null +++ b/crates/hj-acme/src/manager_tests.rs @@ -0,0 +1,441 @@ +use super::*; +use crate::Directory; +use std::{ + fs, + os::unix::fs::DirBuilderExt, + path::PathBuf, + process::{Child, Command, Stdio}, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +static NEXT: AtomicU64 = AtomicU64::new(0); +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "hj-acme-manager-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::DirBuilder::new().mode(0o700).create(&path).unwrap(); + Self(path) + } + fn config(&self, directory: &str) -> AcmeConfig { + AcmeConfig::new( + Directory::parse(directory, true).unwrap(), + &["example.test"], + self.0.clone(), + true, + ) + .unwrap() + } +} +impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } +} + +#[tokio::test] +async fn account_identity_is_durable_and_config_bound_before_network() { + let f = Fixture::new(); + let config = f.config("http://127.0.0.1:1/dir"); + let manager = AcmeManager::open(config.clone(), None).unwrap(); + let key = manager.state.account_key.clone(); + drop(manager); + let mut manager = AcmeManager::open(config.clone(), None).unwrap(); + assert_eq!(key, manager.state.account_key); + assert_eq!(manager.issue().await.err(), Some(ManagerError::Authority)); + assert_eq!(manager.issue().await.err(), Some(ManagerError::Backoff)); + drop(manager); + assert!(matches!( + AcmeManager::open(f.config("http://127.0.0.1:2/dir"), None), + Err(ManagerError::State) + )); +} + +#[tokio::test] +async fn uncertain_creation_never_reissues_on_restart() { + let f = Fixture::new(); + let config = f.config("http://127.0.0.1:1/dir"); + let mut manager = AcmeManager::open(config.clone(), None).unwrap(); + manager.state.order = Some(PendingOrder { + url: None, + private_key: "pending".into(), + csr: vec![], + }); + manager.persist().unwrap(); + drop(manager); + let mut manager = AcmeManager::open(config, None).unwrap(); + assert_eq!( + manager.issue().await.err(), + Some(ManagerError::UncertainOrder) + ); + assert_eq!( + manager.reconcile_order("http://127.0.0.1:2/order/1"), + Err(ManagerError::State) + ); + manager + .reconcile_order("http://127.0.0.1:1/order/1") + .unwrap(); + assert_eq!( + manager.state.order.as_ref().unwrap().url.as_deref(), + Some("http://127.0.0.1:1/order/1") + ); +} + +#[tokio::test] +async fn authority_retry_after_survives_restart() { + let f = Fixture::new(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let config = f.config(&format!("http://{}/dir", listener.local_addr().unwrap())); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0; 4096]; + assert!(socket.read(&mut request).await.unwrap() > 0); + socket.write_all(b"HTTP/1.1 429 Too Many Requests\r\nRetry-After: 86400\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}").await.unwrap(); + }); + let mut manager = AcmeManager::open(config.clone(), None).unwrap(); + assert_eq!(manager.issue().await.err(), Some(ManagerError::Authority)); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + assert!(manager.next_attempt_unix() >= now + 86390); + drop(manager); + let mut manager = AcmeManager::open(config, None).unwrap(); + assert_eq!(manager.issue().await.err(), Some(ManagerError::Backoff)); + server.await.unwrap(); +} + +struct Process(Child); +struct FakeDns { + scope: crate::DnsScope, + values: Mutex>, + fail_cleanup: std::sync::atomic::AtomicBool, + stall_present: std::sync::atomic::AtomicBool, +} +#[async_trait::async_trait] +impl crate::DnsProvider for FakeDns { + fn scope(&self) -> &crate::DnsScope { + &self.scope + } + fn identity(&self) -> String { + format!("fake-dns:{}", self.scope.identity()) + } + async fn present(&self, record: &crate::DnsRecord) -> Result<(), crate::DnsError> { + self.values + .lock() + .unwrap() + .insert(record.value().to_owned()); + if self.stall_present.load(Ordering::Relaxed) { + tokio::time::sleep(Duration::from_secs(60)).await; + } + Ok(()) + } + async fn ready(&self, record: &crate::DnsRecord) -> Result { + Ok(self.values.lock().unwrap().contains(record.value())) + } + async fn cleanup(&self, record: &crate::DnsRecord) -> Result<(), crate::DnsError> { + if self.fail_cleanup.load(Ordering::Relaxed) { + return Err(crate::DnsError); + } + self.values.lock().unwrap().remove(record.value()); + Ok(()) + } +} + +#[tokio::test] +async fn dns_cancelled_present_is_recovered_without_deleting_unrelated_txt() { + use crate::DnsProvider; + let f = Fixture::new(); + let config = AcmeConfig::new_dns01( + Directory::parse("http://127.0.0.1:1/dir", true).unwrap(), + &["*.example.test"], + f.0.clone(), + true, + ) + .unwrap(); + let provider = Arc::new(FakeDns { + scope: crate::DnsScope::new(&["example.test"]).unwrap(), + values: Mutex::new(BTreeSet::from(["unrelated-owner-value".to_owned()])), + fail_cleanup: std::sync::atomic::AtomicBool::new(false), + stall_present: std::sync::atomic::AtomicBool::new(true), + }); + let mut manager = AcmeManager::open_dns01(config.clone(), None, provider.clone()).unwrap(); + let record = crate::DnsRecord::new( + &Domain::parse_dns01("*.example.test").unwrap(), + "A".repeat(43), + ) + .unwrap(); + manager.state.dns_cleanup.push(record.clone()); + manager.persist().unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(5), provider.present(&record)) + .await + .is_err() + ); + assert!(provider.ready(&record).await.unwrap()); + drop(manager); // simulate process loss with a recorded unknown present outcome + let changed_scope = Arc::new(FakeDns { + scope: crate::DnsScope::new(&["example.test", "other.test"]).unwrap(), + values: Mutex::new(BTreeSet::new()), + fail_cleanup: std::sync::atomic::AtomicBool::new(false), + stall_present: std::sync::atomic::AtomicBool::new(false), + }); + assert!(matches!( + AcmeManager::open_dns01(config.clone(), None, changed_scope), + Err(ManagerError::State) + )); + provider.fail_cleanup.store(true, Ordering::Relaxed); + let mut manager = AcmeManager::open_dns01(config.clone(), None, provider.clone()).unwrap(); + assert_eq!(manager.cleanup_dns().await, Err(ManagerError::Dns)); + assert_eq!(manager.state.dns_cleanup.len(), 1); + drop(manager); + provider.fail_cleanup.store(false, Ordering::Relaxed); + let mut manager = AcmeManager::open_dns01(config, None, provider.clone()).unwrap(); + manager.cleanup_dns().await.unwrap(); + assert!(manager.state.dns_cleanup.is_empty()); + assert_eq!( + *provider.values.lock().unwrap(), + BTreeSet::from(["unrelated-owner-value".to_owned()]) + ); +} + +#[test] +fn failed_candidate_validation_preserves_installed_pair() { + let f = Fixture::new(); + let config = f.config("http://127.0.0.1:1/dir"); + let key = rcgen::KeyPair::generate().unwrap(); + let cert = rcgen::CertificateParams::new(vec!["example.test".into()]) + .unwrap() + .self_signed(&key) + .unwrap(); + let validator = CertificateValidator::new(Some(cert.pem().as_bytes())).unwrap(); + let mut manager = AcmeManager::open(config.clone(), None).unwrap(); + let valid = manager + .install( + IssuedCertificate { + certificate_pem: cert.pem(), + private_key_pem: key.serialize_pem(), + }, + &validator, + ) + .unwrap(); + let before = manager.store.read().unwrap(); + let bad = IssuedCertificate { + certificate_pem: cert.pem(), + private_key_pem: rcgen::KeyPair::generate().unwrap().serialize_pem(), + }; + assert_eq!( + manager.install(bad, &validator).err(), + Some(ManagerError::Crypto) + ); + assert_eq!(manager.store.read().unwrap(), before); + drop(manager); + let reopened = AcmeManager::open(config, None).unwrap(); + assert_eq!( + reopened + .installed(&validator) + .unwrap() + .unwrap() + .certified_key() + .cert, + valid.certified_key().cert + ); +} + +impl Drop for Process { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} +fn port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +/// Requires explicit prebuilt Pebble v2.10.1 binaries and its source fixture root. +/// All binds are ephemeral loopback; validation is REAL, never ALWAYS_VALID. +#[tokio::test] +#[ignore = "requires HJ_PEBBLE_BIN, HJ_PEBBLE_DNS_BIN and HJ_PEBBLE_SOURCE"] +async fn pebble_issuance_recovery_and_renewal() { + let pebble = std::env::var("HJ_PEBBLE_BIN").expect("HJ_PEBBLE_BIN"); + let dns_bin = std::env::var("HJ_PEBBLE_DNS_BIN").expect("HJ_PEBBLE_DNS_BIN"); + let source = PathBuf::from(std::env::var("HJ_PEBBLE_SOURCE").expect("HJ_PEBBLE_SOURCE")); + let f = Fixture::new(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let http_port = listener.local_addr().unwrap().port(); + let ca_port = port(); + let management_port = port(); + let dns_port = port(); + let dns_management = port(); + let ca_config = serde_json::json!({"pebble": { + "listenAddress": format!("127.0.0.1:{ca_port}"), + "managementListenAddress": format!("127.0.0.1:{management_port}"), + "certificate": source.join("test/certs/localhost/cert.pem"), + "privateKey": source.join("test/certs/localhost/key.pem"), + "httpPort": http_port, "tlsPort": port(), "externalAccountBindingRequired": false, + "keyAlgorithm": "ecdsa", "retryAfter": {"authz": 1, "order": 1} + }}); + let config_path = f.0.join("pebble.json"); + fs::write(&config_path, serde_json::to_vec(&ca_config).unwrap()).unwrap(); + let _dns = Process( + Command::new(dns_bin) + .args([ + "-dnsserver", + &format!("127.0.0.1:{dns_port}"), + "-management", + &format!("127.0.0.1:{dns_management}"), + "-http01", + "", + "-https01", + "", + "-tlsalpn01", + "", + "-doh", + "", + "-defaultIPv6", + "", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + let _ca = Process( + Command::new(pebble) + .arg("-config") + .arg(&config_path) + .args(["-dnsserver", &format!("127.0.0.1:{dns_port}")]) + .env("PEBBLE_VA_NOSLEEP", "1") + .env("PEBBLE_AUTHZREUSE", "0") + .env("PEBBLE_WFE_NONCEREJECT", "0") + .env_remove("PEBBLE_VA_ALWAYS_VALID") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + let root = fs::read(source.join("test/certs/pebble.minica.pem")).unwrap(); + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(2)) + .tls_certs_only([reqwest::Certificate::from_pem(&root).unwrap()]) + .build() + .unwrap(); + let issuer_url = format!("https://127.0.0.1:{management_port}/roots/0"); + let issuer = tokio::time::timeout(Duration::from_secs(15), async { + loop { + if let Ok(response) = client.get(&issuer_url).send().await + && response.status().is_success() + { + break response.bytes().await.unwrap(); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .expect("Pebble ready"); + let config = f.config(&format!("https://127.0.0.1:{ca_port}/dir")); + let mut manager = AcmeManager::open(config.clone(), Some(&root)).unwrap(); + let registry = Arc::new(Mutex::new(manager.challenges())); + let hits = Arc::new(AtomicU64::new(0)); + let server_registry = registry.clone(); + let server_hits = hits.clone(); + let server = tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut buffer = Vec::new(); + let request = tokio::time::timeout(Duration::from_secs(2), async { + while !buffer.ends_with(b"\r\n\r\n") && buffer.len() < 8192 { + let mut b = [0; 1]; + if socket.read(&mut b).await.unwrap_or(0) == 0 { + break; + } + buffer.push(b[0]); + } + }) + .await; + if request.is_err() { + continue; + } + let text = String::from_utf8_lossy(&buffer); + let path = text + .lines() + .next() + .unwrap() + .split_whitespace() + .nth(1) + .unwrap(); + let host = text + .lines() + .find_map(|l| { + l.split_once(':') + .filter(|(k, _)| k.eq_ignore_ascii_case("host")) + .map(|(_, v)| v.trim()) + }) + .unwrap(); + let response = server_registry + .lock() + .unwrap() + .lookup(&http::Method::GET, host, &path.parse().unwrap(), false) + .unwrap(); + if response.status == http::StatusCode::OK { + server_hits.fetch_add(1, Ordering::Relaxed); + } + let wire = format!( + "HTTP/1.1 {} OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response.status.as_u16(), + response.body.len(), + response.body + ); + let _ = socket.write_all(wire.as_bytes()).await; + } + }); + let first = manager.issue().await.expect("initial HTTP-01 issuance"); + assert!( + hits.load(Ordering::Relaxed) > 0, + "CA must fetch registry token" + ); + let account_id = manager.state.account_id.clone(); + let order_url = manager.state.order.as_ref().unwrap().url.clone(); + // Crash after issuance but before installation: recover the SAME order/key. + manager.state.next_attempt = 0; + manager.persist().unwrap(); + drop(manager); + let mut manager = AcmeManager::open(config.clone(), Some(&root)).unwrap(); + *registry.lock().unwrap() = manager.challenges(); + let recovered = manager.issue().await.expect("recover valid order"); + assert_eq!(recovered.certificate_pem, first.certificate_pem); + assert_eq!(recovered.private_key_pem, first.private_key_pem); + assert_eq!(manager.state.order.as_ref().unwrap().url, order_url); + let validator = CertificateValidator::new(Some(&issuer)).unwrap(); + let installed = manager + .install(recovered, &validator) + .expect("verified durable pair"); + assert!(installed.expires_unix() > 0); + drop(manager); + let mut manager = AcmeManager::open(config, Some(&root)).unwrap(); + assert!(manager.installed(&validator).unwrap().is_some()); + assert_eq!(manager.state.account_id, account_id); + manager.state.next_attempt = 0; // advance only the synthetic renewal schedule + manager.persist().unwrap(); + *registry.lock().unwrap() = manager.challenges(); + let previous_hits = hits.load(Ordering::Relaxed); + let renewed = manager.issue().await.expect("renewal HTTP-01 issuance"); + assert_ne!(renewed.certificate_pem, first.certificate_pem); + assert!(hits.load(Ordering::Relaxed) > previous_hits); + manager.install(renewed, &validator).unwrap(); + assert_eq!(manager.state.account_id, account_id); + server.abort(); + let _ = server.await; +} diff --git a/crates/hj-acme/src/storage.rs b/crates/hj-acme/src/storage.rs new file mode 100644 index 0000000..1c3223c --- /dev/null +++ b/crates/hj-acme/src/storage.rs @@ -0,0 +1,278 @@ +//! Linux private, single-writer snapshot storage. Payload schemas belong to the +//! account/order manager; one snapshot must contain all mutually dependent state. +use std::{ + fs::File, + io::{self, Read, Write}, + os::unix::fs::MetadataExt, + path::Component, +}; + +use rustix::fs::{self, AtFlags, FlockOperation, Mode, OFlags}; + +use crate::AcmeConfig; + +const MAX_SNAPSHOT: usize = 1024 * 1024; +const SNAPSHOT: &str = "state.snapshot"; +const PENDING: &str = ".state.pending"; + +/// Holds an exclusive advisory lock on a pinned, pre-provisioned 0700 directory. +/// No Debug implementation: callers must not accidentally log account state. +pub struct PrivateStore { + directory: File, + write_failed: bool, +} + +fn invalid() -> io::Error { + io::Error::new( + io::ErrorKind::PermissionDenied, + "unsafe ACME storage metadata", + ) +} + +fn check_file(file: &File) -> io::Result<()> { + let m = file.metadata()?; + if !m.is_file() + || m.uid() != rustix::process::geteuid().as_raw() + || m.mode() & 0o7777 != 0o600 + || m.nlink() != 1 + || m.len() > MAX_SNAPSHOT as u64 + { + return Err(invalid()); + } + Ok(()) +} + +impl PrivateStore { + /// The operator must provision the final directory first. Walk every parent + /// using openat + NOFOLLOW, never resolving a symlink in the configured path. + pub fn open(config: &AcmeConfig) -> io::Result { + let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC; + let mut directory = File::from(fs::open("/", flags, Mode::empty())?); + let uid = rustix::process::geteuid().as_raw(); + for component in config.storage().components() { + let Component::Normal(name) = component else { + continue; + }; + let parent = directory.metadata()?; + // Root-owned sticky directories (e.g. /tmp) protect child names from + // other users. Every child is checked after opening its pinned fd. + if (parent.uid() != 0 && parent.uid() != uid) + || (parent.mode() & 0o022 != 0 + && !(parent.uid() == 0 && parent.mode() & 0o1000 != 0)) + { + return Err(invalid()); + } + directory = File::from(fs::openat(&directory, name, flags, Mode::empty())?); + } + let metadata = directory.metadata()?; + if metadata.uid() != uid || metadata.mode() & 0o7777 != 0o700 { + return Err(invalid()); + } + fs::flock(&directory, FlockOperation::NonBlockingLockExclusive)?; + let store = Self { + directory, + write_failed: false, + }; + // Validate persisted state before accepting ownership. A pending write + // is never promoted: only the fsynced, renamed snapshot is authoritative. + store.read()?; + if let Some(pending) = store.open_file(PENDING)? { + check_file(&pending)?; + fs::unlinkat(&store.directory, PENDING, AtFlags::empty())?; + store.directory.sync_all()?; + } + Ok(store) + } + + fn open_file(&self, name: &str) -> io::Result> { + match fs::openat( + &self.directory, + name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC, + Mode::empty(), + ) { + Ok(fd) => Ok(Some(File::from(fd))), + Err(rustix::io::Errno::NOENT) => Ok(None), + Err(error) => Err(error.into()), + } + } + + /// Returns only committed state, bounded even if a same-uid writer races us. + pub fn read(&self) -> io::Result>> { + let Some(file) = self.open_file(SNAPSHOT)? else { + return Ok(None); + }; + check_file(&file)?; + let mut bytes = Vec::new(); + file.take(MAX_SNAPSHOT as u64 + 1).read_to_end(&mut bytes)?; + if bytes.len() > MAX_SNAPSHOT { + return Err(invalid()); + } + Ok(Some(bytes)) + } + + /// An error after rename means the new snapshot may already be visible; + /// callers must stop/reconcile, not assume rollback or retry issuance. + pub fn replace(&mut self, bytes: &[u8]) -> io::Result<()> { + if self.write_failed { + return Err(io::Error::other( + "ACME storage requires reopen after write failure", + )); + } + if bytes.len() > MAX_SNAPSHOT { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "ACME snapshot exceeds limit", + )); + } + self.write_failed = true; + self.replace_inner(bytes)?; + self.write_failed = false; + Ok(()) + } + + fn replace_inner(&self, bytes: &[u8]) -> io::Result<()> { + if let Some(old) = self.open_file(SNAPSHOT)? { + check_file(&old)?; + } + let mut pending = File::from(fs::openat( + &self.directory, + PENDING, + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::RUSR | Mode::WUSR, + )?); + // A restrictive umask must fail closed rather than leave unreadable state. + check_file(&pending)?; + pending.write_all(bytes)?; + pending.sync_all()?; + fs::renameat(&self.directory, PENDING, &self.directory, SNAPSHOT)?; + self.directory.sync_all() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Directory; + use std::{ + fs as stdfs, + os::unix::fs::{DirBuilderExt, PermissionsExt, symlink}, + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, + }; + static NEXT: AtomicU64 = AtomicU64::new(0); + struct Fixture(PathBuf); + impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "hj-acme-store-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + stdfs::DirBuilder::new().mode(0o700).create(&path).unwrap(); + Self(path) + } + fn config(&self) -> AcmeConfig { + AcmeConfig::new( + Directory::parse("https://ca.test/directory", false).unwrap(), + &["example.test"], + self.0.clone(), + true, + ) + .unwrap() + } + fn private_file(&self, name: &str, bytes: &[u8]) { + stdfs::write(self.0.join(name), bytes).unwrap(); + stdfs::set_permissions(self.0.join(name), stdfs::Permissions::from_mode(0o600)) + .unwrap(); + } + } + impl Drop for Fixture { + fn drop(&mut self) { + stdfs::remove_dir_all(&self.0).unwrap(); + } + } + #[test] + fn snapshot_roundtrip_lock_and_restart() { + let f = Fixture::new(); + let mut store = PrivateStore::open(&f.config()).unwrap(); + assert!(store.read().unwrap().is_none()); + assert!(PrivateStore::open(&f.config()).is_err()); + store.replace(b"old-account-and-order").unwrap(); + store.replace(b"new-account-and-order").unwrap(); + drop(store); + f.private_file(PENDING, b"uncommitted partial state"); + let store = PrivateStore::open(&f.config()).unwrap(); + assert_eq!(store.read().unwrap().unwrap(), b"new-account-and-order"); + assert!(!f.0.join(PENDING).exists()); + } + #[test] + fn rejects_unsafe_permissions_links_and_oversize() { + let f = Fixture::new(); + stdfs::set_permissions(&f.0, stdfs::Permissions::from_mode(0o755)).unwrap(); + assert!(PrivateStore::open(&f.config()).is_err()); + stdfs::set_permissions(&f.0, stdfs::Permissions::from_mode(0o700)).unwrap(); + f.private_file("other", b"secret"); + symlink("other", f.0.join(SNAPSHOT)).unwrap(); + assert!(PrivateStore::open(&f.config()).is_err()); + stdfs::remove_file(f.0.join(SNAPSHOT)).unwrap(); + stdfs::hard_link(f.0.join("other"), f.0.join(SNAPSHOT)).unwrap(); + assert!(PrivateStore::open(&f.config()).is_err()); + stdfs::remove_file(f.0.join(SNAPSHOT)).unwrap(); + let mut store = PrivateStore::open(&f.config()).unwrap(); + assert!(store.replace(&vec![0; MAX_SNAPSHOT + 1]).is_err()); + assert!(store.read().unwrap().is_none()); + } + #[test] + fn symlink_parent_and_pending_fail_closed() { + let f = Fixture::new(); + symlink(&f.0, f.0.join("alias")).unwrap(); + let config = AcmeConfig::new( + Directory::parse("https://ca.test/dir", false).unwrap(), + &["example.test"], + f.0.join("alias"), + true, + ) + .unwrap(); + assert!(PrivateStore::open(&config).is_err()); + symlink("missing", f.0.join(PENDING)).unwrap(); + assert!(PrivateStore::open(&f.config()).is_err()); + assert!(f.0.join(PENDING).is_symlink()); + } + + #[test] + fn failed_write_preserves_committed_state_and_requires_reopen() { + let f = Fixture::new(); + let mut store = PrivateStore::open(&f.config()).unwrap(); + store.replace(b"committed").unwrap(); + f.private_file(PENDING, b"interrupted"); + assert!(store.replace(b"replacement").is_err()); + stdfs::remove_file(f.0.join(PENDING)).unwrap(); + assert!(store.replace(b"retry").is_err()); + assert_eq!(store.read().unwrap().unwrap(), b"committed"); + drop(store); + let mut reopened = PrivateStore::open(&f.config()).unwrap(); + reopened.replace(b"reconciled").unwrap(); + assert_eq!(reopened.read().unwrap().unwrap(), b"reconciled"); + } + + #[test] + fn unsafe_snapshot_objects_are_rejected_without_blocking() { + let f = Fixture::new(); + fs::mknodat( + fs::CWD, + f.0.join(SNAPSHOT), + fs::FileType::Fifo, + Mode::RUSR | Mode::WUSR, + 0, + ) + .unwrap(); + assert!(PrivateStore::open(&f.config()).is_err()); + stdfs::remove_file(f.0.join(SNAPSHOT)).unwrap(); + f.private_file(SNAPSHOT, b"secret"); + stdfs::set_permissions(f.0.join(SNAPSHOT), stdfs::Permissions::from_mode(0o644)).unwrap(); + assert!(PrivateStore::open(&f.config()).is_err()); + f.private_file(SNAPSHOT, &vec![0; MAX_SNAPSHOT + 1]); + assert!(PrivateStore::open(&f.config()).is_err()); + } +} diff --git a/crates/hj-acme/src/transport.rs b/crates/hj-acme/src/transport.rs new file mode 100644 index 0000000..227b35e --- /dev/null +++ b/crates/hj-acme/src/transport.rs @@ -0,0 +1,235 @@ +//! Restricted HTTP adapter for instant-acme. Never returns upstream text in errors. +use std::{ + future::Future, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use bytes::Bytes; +use http::{Method, Request, Uri}; +use http_body_util::{BodyExt, Full}; +use instant_acme::{BodyWrapper, BytesResponse, Error, HttpClient}; + +use crate::Directory; + +const MAX_BODY: usize = 1024 * 1024; + +#[derive(Clone)] +pub(crate) struct BoundedHttp { + client: reqwest::Client, + directory: Directory, + retry_after: Arc, +} + +impl BoundedHttp { + pub(crate) fn new(directory: Directory, test_root: Option<&[u8]>) -> Result { + let mut builder = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .retry(reqwest::retry::never()) + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(10)) + .pool_max_idle_per_host(1) + .user_agent("httpjet-acme/1"); + if let Some(pem) = test_root { + if pem.len() > 64 * 1024 { + return Err(Error::Str("ACME trust root exceeds limit")); + } + let root = reqwest::Certificate::from_pem(pem) + .map_err(|_| Error::Str("invalid ACME trust root"))?; + builder = builder.tls_certs_only([root]); + } + Ok(Self { + client: builder + .build() + .map_err(|_| Error::Str("ACME HTTP initialization failed"))?, + directory, + retry_after: Arc::new(AtomicU64::new(0)), + }) + } + + pub(crate) fn permits(&self, uri: &Uri) -> bool { + let base = self.directory.uri(); + let default_port = if base.scheme_str() == Some("https") { + 443 + } else { + 80 + }; + uri.to_string().len() <= 2048 + && uri.scheme_str() == base.scheme_str() + && uri + .host() + .zip(base.host()) + .is_some_and(|(a, b)| a.eq_ignore_ascii_case(b)) + && uri.port_u16().unwrap_or(default_port) == base.port_u16().unwrap_or(default_port) + && uri.authority().is_some_and(|a| { + !a.as_str().contains('@') + && !(a.as_str().contains(':') + && a.port_u16().is_none() + && !a.as_str().ends_with(']')) + }) + } + + pub(crate) fn retry_after(&self) -> u64 { + self.retry_after.load(Ordering::Relaxed) + } +} + +impl HttpClient for BoundedHttp { + fn request( + &self, + req: Request>, + ) -> Pin> + Send>> { + let this = self.clone(); + Box::pin(async move { + if !this.permits(req.uri()) + || !matches!(*req.method(), Method::GET | Method::HEAD | Method::POST) + { + return Err(Error::Str( + "ACME request outside permitted origin or method", + )); + } + let (parts, body) = req.into_parts(); + let body = body.collect().await.unwrap().to_bytes(); + if body.len() > MAX_BODY { + return Err(Error::Str("ACME request exceeds limit")); + } + let mut response = this + .client + .request(parts.method, parts.uri.to_string()) + .headers(parts.headers) + .body(body) + .send() + .await + .map_err(|_| Error::Str("ACME HTTP request failed"))?; + if response.status().is_redirection() { + return Err(Error::Str("ACME redirects are prohibited")); + } + if let Some(value) = response + .headers() + .get("retry-after") + .and_then(|h| h.to_str().ok()) + { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let until = value + .parse::() + .ok() + .map(|seconds| now.saturating_add(seconds)) + .or_else(|| { + httpdate::parse_http_date(value) + .ok()? + .duration_since(UNIX_EPOCH) + .ok() + .map(|d| d.as_secs()) + }); + if let Some(until) = until { + this.retry_after.fetch_max(until, Ordering::Relaxed); + } + } + if response + .content_length() + .is_some_and(|n| n > MAX_BODY as u64) + { + return Err(Error::Str("ACME response exceeds limit")); + } + let mut output = http::Response::builder().status(response.status()); + *output.headers_mut().unwrap() = response.headers().clone(); + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| Error::Str("ACME response failed"))? + { + if chunk.len() > MAX_BODY - bytes.len() { + return Err(Error::Str("ACME response exceeds limit")); + } + bytes.extend_from_slice(&chunk); + } + Ok(BytesResponse::from( + output.body(Full::new(Bytes::from(bytes)))?, + )) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn endpoint_origin_is_not_a_redirect_allowlist() { + let client = BoundedHttp::new( + Directory::parse("https://ca.test/directory", false).unwrap(), + None, + ) + .unwrap(); + for good in ["https://ca.test/new-account", "https://CA.test:443/order/1"] { + assert!(client.permits(&good.parse().unwrap())); + } + for bad in [ + "http://ca.test/order", + "https://ca.test:444/order", + "https://ca.test.evil/order", + "https://user@ca.test/order", + "/order", + ] { + assert!(!client.permits(&bad.parse().unwrap())); + } + } + + #[tokio::test] + async fn bounds_chunked_responses_and_rejects_redirects() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + for oversized in [false, true] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut buffer = [0; 4096]; + assert!(socket.read(&mut buffer).await.unwrap() > 0); + if oversized { + socket + .write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n") + .await + .unwrap(); + let chunk = vec![b'x'; 65536]; + for _ in 0..17 { + if socket.write_all(b"10000\r\n").await.is_err() { + break; + } + if socket.write_all(&chunk).await.is_err() { + break; + } + if socket.write_all(b"\r\n").await.is_err() { + break; + } + } + } else { + socket.write_all(b"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:1/secret\r\nContent-Length: 0\r\n\r\n").await.unwrap(); + } + }); + let directory = Directory::parse(&format!("http://{address}/dir"), true).unwrap(); + let client = BoundedHttp::new(directory.clone(), None).unwrap(); + let result = client + .request( + Request::builder() + .uri(directory.uri()) + .body(BodyWrapper::default()) + .unwrap(), + ) + .await; + assert!(matches!( + result, + Err(Error::Str("ACME response exceeds limit")) + | Err(Error::Str("ACME redirects are prohibited")) + )); + server.await.unwrap(); + } + } +} diff --git a/crates/hj-config/src/lib.rs b/crates/hj-config/src/lib.rs index 08f4445..c797c0b 100644 --- a/crates/hj-config/src/lib.rs +++ b/crates/hj-config/src/lib.rs @@ -12,7 +12,12 @@ pub mod units; pub use error::{ConfigError, Result}; pub use model::*; -pub use parse::load; +pub use parse::{load, parse_bundle}; + +/// Redacted failure for an in-memory configuration submission. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("invalid configuration submission")] +pub struct BundleError; /// Whether a vhostMap `` token is a wildcard PATTERN httpjet does not support: it contains /// a glob char (`*`/`?`) but is not the bare `*` catch-all (the only supported wildcard). Such a diff --git a/crates/hj-config/src/model.rs b/crates/hj-config/src/model.rs index 84d5792..dd6cb65 100644 --- a/crates/hj-config/src/model.rs +++ b/crates/hj-config/src/model.rs @@ -317,7 +317,7 @@ pub struct NamespacePolicy { pub ipc: bool, } -/// An external processor: a proxy upstream or an LSAPI (PHP) app. +/// An external processor: a proxy upstream or an application-protocol gateway. #[derive(Debug, Clone)] pub struct ExtProcessor { pub name: String, @@ -326,6 +326,7 @@ pub struct ExtProcessor { /// (Tier 1.2) Additional upstream addresses — failover peers tried in order /// when the primary's circuit breaker is open. pub extra_addresses: Vec, + pub load_balance: LoadBalanceConfig, /// (Tier 2) Upstream mTLS: client certificate + key for TLS upstream connections. pub client_cert_file: Option, pub client_key_file: Option, @@ -344,10 +345,39 @@ pub struct ExtProcessor { pub run_on_startup: i32, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub enum LoadBalancePolicy { + #[default] + PrimaryFirst, + WeightedRoundRobin, + WeightedLeastActive, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub struct LoadBalanceConfig { + pub health_check: Option, + /// Empty means unit weights for every address. + pub weights: Vec, + pub policy: LoadBalancePolicy, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct HealthCheckConfig { + pub mode: String, + pub interval: Duration, + pub timeout: Duration, + pub rise: u32, + pub fall: u32, + pub path: String, + pub host: Option, + pub expected_status: u16, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExtKind { Proxy, Lsapi, + FastCgi, } /// Address of an external processor: a TCP socket or a Unix domain socket path. diff --git a/crates/hj-config/src/parse/balance.rs b/crates/hj-config/src/parse/balance.rs new file mode 100644 index 0000000..3aadbe5 --- /dev/null +++ b/crates/hj-config/src/parse/balance.rs @@ -0,0 +1,328 @@ +use super::*; + +fn valid_authority(value: &str) -> bool { + let (host, port) = if let Some(rest) = value.strip_prefix('[') { + let Some((host, tail)) = rest.split_once(']') else { + return false; + }; + if host.parse::().is_err() { + return false; + } + if tail.is_empty() { + return true; + } + let Some(port) = tail.strip_prefix(':') else { + return false; + }; + (host, Some(port)) + } else { + let (host, port) = value + .split_once(':') + .map_or((value, None), |(h, p)| (h, Some(p))); + if host.is_empty() + || !host + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_')) + { + return false; + } + (host, port) + }; + !host.is_empty() + && port.is_none_or(|p| { + !p.is_empty() + && p.bytes().all(|b| b.is_ascii_digit()) + && p.parse::().is_ok_and(|p| p > 0) + }) +} + +pub(super) fn parse(e: &RawExtProcessor, path: &Path) -> Result { + let invalid = |directive, value: String, reason: &str| ConfigError::InvalidValue { + path: path.to_path_buf(), + directive, + value, + reason: reason.into(), + }; + let specified = + e.load_balance_policy.is_some() || e.address_weights.is_some() || e.health_check.is_some(); + if specified { + let mut scheme = None; + for address in &e.address { + let address = address.trim(); + let unix = address.starts_with("uds://"); + let (protocol, authority) = address.split_once("://").unwrap_or(("http", address)); + let protocol = if protocol == "uds" { "http" } else { protocol }; + if !matches!(protocol, "http" | "https" | "h2" | "h2s") + || authority.is_empty() + || (!unix && !valid_authority(authority)) + || authority + .bytes() + .any(|b| b <= 0x20 || b == b'@' || b == b'?' || b == b'#') + || (address.contains("://") + && !address.starts_with("uds://") + && !address.starts_with("UDS://") + && authority.contains('/')) + { + return Err(invalid( + "address", + address.into(), + "group addresses require an authority or Unix socket, not credentials/path/query", + )); + } + if scheme.is_some_and(|s| s != protocol) { + return Err(invalid( + "address", + address.into(), + "all peers in a group must use the same protocol", + )); + } + scheme = Some(protocol); + } + } + if specified + && (e.kind.as_deref().is_some_and(|v| v.trim() != "proxy") + || e.name.as_deref().is_none_or(|v| v.trim().is_empty()) + || e.address.is_empty()) + { + return Err(invalid( + "loadBalancePolicy", + String::new(), + "requires a named proxy with at least one address", + )); + } + let policy = match e + .load_balance_policy + .as_deref() + .map(str::trim) + .unwrap_or("primary-first") + { + "primary-first" => LoadBalancePolicy::PrimaryFirst, + "weighted-round-robin" => LoadBalancePolicy::WeightedRoundRobin, + "weighted-least-active" => LoadBalancePolicy::WeightedLeastActive, + other => return Err(invalid("loadBalancePolicy", other.into(), "unknown policy")), + }; + let weights = match &e.address_weights { + None => Vec::new(), + Some(value) => { + let weights: Vec = value + .split(',') + .map(|s| { + s.trim() + .parse::() + .ok() + .filter(|n| (1..=1000).contains(n)) + .ok_or_else(|| { + invalid( + "addressWeights", + value.clone(), + "weights must be integers in 1..=1000", + ) + }) + }) + .collect::>()?; + if weights.len() != e.address.len() { + return Err(invalid( + "addressWeights", + value.clone(), + "one weight is required per address", + )); + } + weights + } + }; + let health_check = e + .health_check + .as_ref() + .map(|h| { + let number = |v: &Option, default: u32, max: u32| -> Result { + match v { + None => Ok(default), + Some(s) => s + .trim() + .parse::() + .ok() + .filter(|n| *n > 0 && *n <= max) + .ok_or_else(|| { + invalid( + "healthCheck", + s.clone(), + "invalid positive integer or out of range", + ) + }), + } + }; + let mode = h.mode.as_deref().unwrap_or("connect").trim().to_owned(); + if !matches!(mode.as_str(), "connect" | "GET" | "HEAD") { + return Err(invalid( + "healthCheck", + mode, + "mode must be connect, GET or HEAD", + )); + } + let interval = Duration::from_secs(u64::from(number(&h.interval, 10, 3600)?)); + let timeout = Duration::from_secs(u64::from(number(&h.timeout, 2, 3600)?)); + if timeout > interval { + return Err(invalid( + "healthCheck", + String::new(), + "timeout exceeds interval", + )); + } + let path = h.path.clone().unwrap_or_else(|| "/".into()); + if !path.starts_with('/') + || path.starts_with("//") + || path.bytes().any(|b| b <= 0x20 || b >= 0x7f || b == b'#') + { + return Err(invalid( + "healthCheck", + path, + "path must be an ASCII origin-form request target", + )); + } + if h.host.as_ref().is_some_and(|s| { + !valid_authority(s) + || s.bytes().any(|b| { + b <= 0x20 || b >= 0x7f || matches!(b, b'/' | b'\\' | b'@' | b'#' | b'?') + }) + }) { + return Err(invalid( + "healthCheck", + String::new(), + "invalid Host override", + )); + } + let expected_status = number(&h.expected_status, 200, 599)? as u16; + if expected_status < 200 { + return Err(invalid( + "healthCheck", + expected_status.to_string(), + "expectedStatus must be 200..599", + )); + } + Ok(HealthCheckConfig { + mode, + interval, + timeout, + rise: number(&h.rise, 2, 100)?, + fall: number(&h.fall, 3, 100)?, + path, + host: h.host.clone(), + expected_status, + }) + }) + .transpose()?; + Ok(LoadBalanceConfig { + policy, + weights, + health_check, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + fn parse_xml(extra: &str, kind: &str) -> Result { + let e: RawExtProcessor = quick_xml::de::from_str(&format!( + "backend{kind}
127.0.0.1:1
127.0.0.1:2
{extra}
" + )).unwrap(); + parse(&e, Path::new("test.xml")) + } + #[test] + fn validates_authority_ports_and_ipv6() { + for value in ["localhost", "api.internal:8080", "[::1]:443"] { + assert!(valid_authority(value)); + } + for value in [ + "", + "localhost:", + "localhost:0", + "localhost:65536", + "localhost:abc", + "[broken]:80", + "user@host", + "host\\name", + ] { + assert!(!valid_authority(value)); + } + } + + #[test] + fn rejects_mixed_protocol_and_credential_group_addresses() { + for second in [ + "https://localhost:2", + "http://user@localhost:2", + "http://localhost:2/path", + "ftp://localhost:2", + ] { + let e: RawExtProcessor = quick_xml::de::from_str(&format!("backendproxy
http://localhost:1
{second}
weighted-round-robin
")).unwrap(); + assert!(parse(&e, Path::new("test.xml")).is_err()); + } + } + + #[test] + fn defaults_and_weighted_policies() { + assert_eq!( + parse_xml("", "proxy").unwrap(), + LoadBalanceConfig::default() + ); + for policy in [ + "primary-first", + "weighted-round-robin", + "weighted-least-active", + ] { + assert_eq!(parse_xml(&format!("{policy}2,1"), "proxy").unwrap().weights, vec![2,1]); + } + } + #[test] + fn rejects_invalid_or_non_proxy_options() { + for extra in [ + "random", + "1", + "0,1", + "1001,1", + "1,", + ] { + assert!(parse_xml(extra, "proxy").is_err()); + } + assert!( + parse_xml( + "primary-first", + "lsapi" + ) + .is_err() + ); + } + + #[test] + fn health_defaults_and_validation() { + let h = parse_xml("", "proxy") + .unwrap() + .health_check + .unwrap(); + assert_eq!( + ( + h.interval.as_secs(), + h.timeout.as_secs(), + h.rise, + h.fall, + h.expected_status + ), + (10, 2, 2, 3, 200) + ); + for field in [ + "POST", + "11", + "0", + "https://evil.test/", + "//evil.test/", + "bad host", + "101", + ] { + assert!( + parse_xml(&format!("{field}"), "proxy").is_err(), + "{field}" + ); + } + assert!(parse_xml("", "lsapi").is_err()); + } +} diff --git a/crates/hj-config/src/parse/mod.rs b/crates/hj-config/src/parse/mod.rs index 6a7c47f..9286c87 100644 --- a/crates/hj-config/src/parse/mod.rs +++ b/crates/hj-config/src/parse/mod.rs @@ -5,6 +5,7 @@ //! substitution and interpreting unions/booleans. The raw layer absorbs the //! liberal/legacy XML LiteSpeed tolerates (empty elements, missing fields). +mod balance; mod raw; mod scalar; mod vhost; @@ -43,13 +44,98 @@ pub fn load(server_root: impl AsRef) -> Result { Ok(cfg) } +/// Parse a complete in-memory configuration without reading config files. +/// Vhost documents are keyed by declaration name, not by submitted file paths. +/// Resource paths are normalized but NOT authorized here; callers must validate +/// them before building runtime state. Errors intentionally contain no input. +pub fn parse_bundle( + server_root: &Path, + server_xml: &str, + vhosts: &std::collections::BTreeMap, + mime: &str, +) -> std::result::Result { + use crate::BundleError; + let size = vhosts.iter().try_fold( + server_xml + .len() + .checked_add(mime.len()) + .ok_or(BundleError)?, + |size, (name, xml)| size.checked_add(name.len())?.checked_add(xml.len()), + ); + if size.is_none_or(|size| size > 1024 * 1024) || vhosts.len() > 128 { + return Err(BundleError); + } + bundle_document(server_xml, "httpServerConfig")?; + for xml in vhosts.values() { + bundle_document(xml, "virtualHostConfig")?; + } + // Legacy file parsing logs operator diagnostics that can contain XML values. + // A synchronous submission must not emit those values to ambient sinks. + tracing::subscriber::with_default(tracing::subscriber::NoSubscriber::default(), || { + let mut cfg = parse_server_text(server_root, Path::new(""), server_xml) + .map_err(|_| BundleError)?; + if cfg.vhost_order.len() != cfg.vhosts.len() + || cfg.vhosts.len() != vhosts.len() + || cfg.vhosts.keys().ne(vhosts.keys()) + { + return Err(BundleError); + } + vhost::load_vhost_sources(&mut cfg, true, |name, _| Ok(vhosts[name].clone())) + .map_err(|_| BundleError)?; + cfg.mime = parse_mime(mime); + Ok(cfg) + }) +} + +fn bundle_document(text: &str, root: &str) -> std::result::Result<(), crate::BundleError> { + use crate::BundleError; + use quick_xml::events::Event; + let mut reader = quick_xml::Reader::from_str(text); + let mut depth = 0usize; + let mut seen_root = false; + loop { + let event = reader.read_event().map_err(|_| BundleError)?; + let opens = matches!(event, Event::Start(_)); + match event { + Event::Start(tag) | Event::Empty(tag) => { + if depth >= 64 { + return Err(BundleError); + } + if depth == 0 { + if seen_root || tag.name().as_ref() != root { + return Err(BundleError); + } + seen_root = true; + } + if opens { + depth += 1; + } + } + Event::End(_) => depth = depth.checked_sub(1).ok_or(BundleError)?, + Event::DocType(_) => return Err(BundleError), + Event::Text(value) if depth == 0 => { + if !value.as_ref().bytes().all(|b| b.is_ascii_whitespace()) { + return Err(BundleError); + } + } + Event::CData(_) | Event::GeneralRef(_) if depth == 0 => return Err(BundleError), + Event::Eof => return (seen_root && depth == 0).then_some(()).ok_or(BundleError), + _ => {} + } + } +} + /// Parse just the server file (no vhost-file loading); useful for tests. pub(crate) fn load_server_file(server_root: &Path, path: &Path) -> Result { let text = std::fs::read_to_string(path).map_err(|e| ConfigError::Io { path: path.to_path_buf(), source: e, })?; - let raw: RawServer = quick_xml::de::from_str(&text).map_err(|e| ConfigError::Xml { + parse_server_text(server_root, path, &text) +} + +fn parse_server_text(server_root: &Path, path: &Path, text: &str) -> Result { + let raw: RawServer = quick_xml::de::from_str(text).map_err(|e| ConfigError::Xml { path: path.to_path_buf(), msg: e.to_string(), })?; @@ -68,7 +154,7 @@ pub(crate) fn load_server_file(server_root: &Path, path: &Path) -> Result) -> Option { }) } -fn convert_ext_list(r: Option, ctx: &SubstCtx) -> Vec { +fn convert_ext_list( + r: Option, + ctx: &SubstCtx, + path: &Path, +) -> Result> { let r = match r { Some(r) => r, - None => return Vec::new(), + None => return Ok(Vec::new()), }; - r.ext_processor + for e in &r.ext_processor { + balance::parse(e, path)?; + } + Ok(r.ext_processor .into_iter() .filter_map(|e| convert_ext(e, ctx)) - .collect() + .collect()) } fn convert_ext(e: RawExtProcessor, ctx: &SubstCtx) -> Option { + let load_balance = balance::parse(&e, Path::new("")).ok()?; let name = nonempty(e.name)?; let kind = match e.kind.as_deref().map(str::trim) { Some("lsapi") => ExtKind::Lsapi, + Some("fcgi" | "fastcgi") => ExtKind::FastCgi, _ => ExtKind::Proxy, }; // (Tier 1.2) Every
element is a peer; the first is primary and the @@ -365,6 +460,7 @@ fn convert_ext(e: RawExtProcessor, ctx: &SubstCtx) -> Option { let address = addr_iter.next().unwrap_or(ext_address("")); let extra_addresses: Vec<_> = addr_iter.collect(); Some(ExtProcessor { + load_balance, name, kind, address, @@ -556,6 +652,25 @@ pub(crate) fn parse_mime(text: &str) -> MimeMap { mod tests { use super::*; + #[test] + fn fastcgi_processor_has_a_distinct_opt_in_identity() { + for kind in ["fcgi", "fastcgi"] { + let xml = format!( + "app\ + {kind}
uds:///run/app.sock
\ +
" + ); + let config = + parse_server_text(Path::new("/srv/httpjet"), Path::new("test.xml"), &xml).unwrap(); + assert_eq!(config.ext_processors.len(), 1); + assert_eq!(config.ext_processors[0].kind, ExtKind::FastCgi); + assert!(matches!( + config.ext_processors[0].address, + ExtAddress::Uds(_) + )); + } + } + // ----- (Tier 2) per-listener proxyProtocol ----- #[test] diff --git a/crates/hj-config/src/parse/raw.rs b/crates/hj-config/src/parse/raw.rs index 08add75..8af0f4b 100644 --- a/crates/hj-config/src/parse/raw.rs +++ b/crates/hj-config/src/parse/raw.rs @@ -249,6 +249,12 @@ pub(super) struct RawExtList { #[derive(Debug, Deserialize, Default)] pub(super) struct RawExtProcessor { + #[serde(rename = "healthCheck", default)] + pub(super) health_check: Option, + #[serde(rename = "loadBalancePolicy", default)] + pub(super) load_balance_policy: Option, + #[serde(rename = "addressWeights", default)] + pub(super) address_weights: Option, #[serde(rename = "type", default)] pub(super) kind: Option, #[serde(default)] @@ -283,6 +289,19 @@ pub(super) struct RawExtProcessor { pub(super) run_on_startup: Option, } +#[derive(Debug, Deserialize, Default)] +pub(super) struct RawHealthCheck { + pub(super) mode: Option, + pub(super) interval: Option, + pub(super) timeout: Option, + pub(super) rise: Option, + pub(super) fall: Option, + pub(super) path: Option, + pub(super) host: Option, + #[serde(rename = "expectedStatus", default)] + pub(super) expected_status: Option, +} + #[derive(Debug, Deserialize, Default)] pub(super) struct RawPhpConfig { #[serde(rename = "detachedMode", default)] diff --git a/crates/hj-config/src/parse/vhost.rs b/crates/hj-config/src/parse/vhost.rs index 90fe612..95cfb88 100644 --- a/crates/hj-config/src/parse/vhost.rs +++ b/crates/hj-config/src/parse/vhost.rs @@ -95,6 +95,19 @@ pub(super) fn convert_vhost_decls( /// Load every vhost's per-vhost XML file into its `VHostDecl::config`. pub(super) fn load_vhost_files(cfg: &mut ServerConfig) -> Result<()> { + load_vhost_sources(cfg, false, |_, path| { + std::fs::read_to_string(path).map_err(|source| ConfigError::Io { + path: path.to_path_buf(), + source, + }) + }) +} + +pub(super) fn load_vhost_sources( + cfg: &mut ServerConfig, + strict: bool, + mut source: impl FnMut(&str, &std::path::Path) -> Result, +) -> Result<()> { let server_root = cfg.server_root.clone(); let hostname = cfg.server_name.clone(); let follow_symlink = cfg.security.follow_symlink; @@ -109,8 +122,9 @@ pub(super) fn load_vhost_files(cfg: &mut ServerConfig) -> Result<()> { decl.restrained, ) }; - let text = match std::fs::read_to_string(&config_file) { + let text = match source(&name, &config_file) { Ok(t) => t, + Err(e) if strict => return Err(e), Err(e) => { tracing::warn!(vhost = %name, path = %config_file.display(), error = %e, "skipping vhost: cannot read config file"); continue; @@ -138,7 +152,7 @@ pub(super) fn load_vhost_files(cfg: &mut ServerConfig) -> Result<()> { decl.config = Some(Arc::new(vc)); } } - Err(e @ ConfigError::InvalidValue { .. }) => return Err(e), + Err(e) if strict || matches!(e, ConfigError::InvalidValue { .. }) => return Err(e), Err(e) => { tracing::warn!(vhost = %name, error = %e, "skipping vhost: parse error"); } @@ -404,7 +418,7 @@ pub(crate) fn parse_vhost_config(text: &str, ctx: &SubstCtx) -> Result +bundle.test +1 +site$SERVER_ROOT/site +/path/that/must/not/be/read.xml +0 +"#; + +fn documents() -> BTreeMap { + BTreeMap::from([( + "site".into(), + "$VH_ROOT/www".into(), + )]) +} + +#[test] +fn bundle_normalizes_supplied_documents_without_file_loading() { + let cfg = parse_bundle( + Path::new("/synthetic-root"), + SERVER, + &documents(), + "text/custom = xyz", + ) + .unwrap(); + let site = cfg.vhosts["site"].config.as_ref().unwrap(); + assert_eq!(site.doc_root, Path::new("/synthetic-root/site/www")); + assert!(!site.allow_symbol_link); + assert_eq!(cfg.mime.by_suffix["xyz"], "text/custom"); + let mut docs = documents(); + docs.get_mut("site").unwrap().push_str("".into()); + assert!(parse_bundle(root, SERVER, &docs, "").is_err()); + let duplicate = SERVER.replace( + "", + "site", + ); + assert!(parse_bundle(root, &duplicate, &documents(), "").is_err()); + assert!(parse_bundle(root, SERVER, &documents(), &"x".repeat(1024 * 1024)).is_err()); + let error = parse_bundle(root, "", + "", + "trailing", + "", + "", + ] { + assert!(parse_bundle(root, xml, &BTreeMap::new(), "").is_err()); + } + let nested = format!( + "{}{}", + "".repeat(64), + "".repeat(64) + ); + assert!(parse_bundle(root, &nested, &BTreeMap::new(), "").is_err()); + assert!(parse_bundle(root, "", &BTreeMap::new(), "").is_ok()); +} diff --git a/crates/hj-core/src/completion.rs b/crates/hj-core/src/completion.rs new file mode 100644 index 0000000..b85a521 --- /dev/null +++ b/crates/hj-core/src/completion.rs @@ -0,0 +1,66 @@ +//! Optional response-lifetime notification, independent of any telemetry SDK. +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ResponseEnd { + Complete, + Error, + Cancelled, +} + +type Callback = Box; +struct Inner(Mutex>); +impl Drop for Inner { + fn drop(&mut self) { + if let Some(callback) = self.0.get_mut().unwrap().take() { + callback(ResponseEnd::Cancelled); + } + } +} + +/// An optional response extension. Transport ownership lasts through the final +/// write/flush, not merely body collection. Clones share exactly one notification; +/// dropping the last unfinished clone reports cancellation. Callbacks must be +/// nonblocking and must not panic. No allocation occurs unless explicitly created. +#[derive(Clone)] +pub struct ResponseCompletion(Arc); +impl ResponseCompletion { + pub fn new(callback: impl FnOnce(ResponseEnd) + Send + 'static) -> Self { + Self(Arc::new(Inner(Mutex::new(Some(Box::new(callback)))))) + } + pub fn finish(self, end: ResponseEnd) { + let callback = self.0.0.lock().unwrap().take(); + if let Some(callback) = callback { + callback(end); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn clones_notify_exactly_once_and_last_drop_cancels() { + for explicit in [false, true] { + let events = Arc::new(Mutex::new(Vec::new())); + let copy = events.clone(); + let completion = ResponseCompletion::new(move |end| copy.lock().unwrap().push(end)); + let other = completion.clone(); + if explicit { + completion.finish(ResponseEnd::Complete); + } else { + drop(completion); + assert!(events.lock().unwrap().is_empty()); + } + drop(other); + assert_eq!( + *events.lock().unwrap(), + vec![if explicit { + ResponseEnd::Complete + } else { + ResponseEnd::Cancelled + }] + ); + } + } +} diff --git a/crates/hj-core/src/lib.rs b/crates/hj-core/src/lib.rs index 82825e1..07fb2cb 100644 --- a/crates/hj-core/src/lib.rs +++ b/crates/hj-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod body; pub mod budget; +pub mod completion; pub mod context; pub mod handler; pub mod http_util; @@ -12,6 +13,7 @@ pub mod reqid; pub mod router; pub use body::{Body, BoxError, CountingBody, FileBody, IncomingBody, StreamBody, empty_incoming}; +pub use completion::{ResponseCompletion, ResponseEnd}; pub use context::{ClientCert, Proto, RedirectGuard, ReqCtx, TlsParams}; pub use handler::{Handler, HandlerError, Request, Response, ResponseTransform, text_response}; pub use http_util::{ diff --git a/crates/hj-extension/Cargo.toml b/crates/hj-extension/Cargo.toml new file mode 100644 index 0000000..f33fe93 --- /dev/null +++ b/crates/hj-extension/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "hj-extension" +version.workspace = true +edition.workspace = true +publish.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +hj-core = { workspace = true } +async-trait = { workspace = true } +http = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/hj-extension/src/lib.rs b/crates/hj-extension/src/lib.rs new file mode 100644 index 0000000..7135ac6 --- /dev/null +++ b/crates/hj-extension/src/lib.rs @@ -0,0 +1,228 @@ +//! Compile-time extension registry for httpjet. +//! +//! Extensions are ordinary Rust crates linked into the server binary. The +//! registry deliberately provides no native dynamic ABI: Rust trait-object ABI +//! stability, allocator ownership, and panic isolation cannot be promised +//! safely across independently built shared objects. + +use std::collections::HashSet; +use std::sync::Arc; + +use async_trait::async_trait; +use hj_core::{HandlerError, ReqCtx, Request, Response, ResponseTransform}; + +/// Read-only request view passed to a pre-handler extension. +/// +/// The body and mutable request parts are intentionally not exposed. A hook can +/// observe the resolved request identity and either continue or produce a +/// response, but cannot consume a streaming body, rewrite routing inputs, or +/// invalidate cache/security decisions made by the host. +#[derive(Clone, Copy)] +pub struct RequestView<'a> { + request: &'a Request, +} + +impl<'a> RequestView<'a> { + pub fn new(request: &'a Request) -> Self { + Self { request } + } + + pub fn method(&self) -> &http::Method { + self.request.method() + } + + pub fn uri(&self) -> &http::Uri { + self.request.uri() + } + + pub fn headers(&self) -> &http::HeaderMap { + self.request.headers() + } +} + +/// Result of a pre-handler extension. +pub enum PreHandlerDecision { + /// Continue to the next extension and then the built-in dispatch pipeline. + Continue, + /// Stop before rewrite, cache lookup, or a terminal backend and use this + /// response. The host still applies post-handler transforms and logging. + Respond(Response), +} + +/// Read-only hook after routing/trust/access checks and before dispatch. +#[async_trait] +pub trait PreHandler: Send + Sync { + async fn handle( + &self, + ctx: &ReqCtx, + request: RequestView<'_>, + ) -> Result; +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum RegisterError { + #[error( + "invalid extension name {0:?}; use 1-64 lowercase ASCII letters, digits, '.', '-' or '_'" + )] + InvalidName(String), + #[error("duplicate extension registration name {0:?}")] + DuplicateName(String), +} + +struct NamedPreHandler { + name: &'static str, + handler: Arc, +} + +struct NamedResponseTransform { + name: &'static str, + transform: Arc, +} + +/// Deterministically ordered registry assembled by the binary at compile time. +/// Registration order is execution order. +#[derive(Default)] +pub struct ExtensionRegistry { + names: HashSet<&'static str>, + pre_handlers: Vec, + response_transforms: Vec, +} + +impl ExtensionRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn register_pre_handler( + &mut self, + name: &'static str, + handler: Arc, + ) -> Result<(), RegisterError> { + self.reserve_name(name)?; + self.pre_handlers.push(NamedPreHandler { name, handler }); + Ok(()) + } + + pub fn register_response_transform( + &mut self, + name: &'static str, + transform: Arc, + ) -> Result<(), RegisterError> { + self.reserve_name(name)?; + self.response_transforms + .push(NamedResponseTransform { name, transform }); + Ok(()) + } + + fn reserve_name(&mut self, name: &'static str) -> Result<(), RegisterError> { + if !valid_name(name) { + return Err(RegisterError::InvalidName(name.to_string())); + } + if !self.names.insert(name) { + return Err(RegisterError::DuplicateName(name.to_string())); + } + Ok(()) + } + + pub fn is_empty(&self) -> bool { + self.names.is_empty() + } + + pub fn has_pre_handlers(&self) -> bool { + !self.pre_handlers.is_empty() + } + + pub fn registrations(&self) -> impl Iterator + '_ { + self.pre_handlers + .iter() + .map(|entry| (entry.name, "pre-handler")) + .chain( + self.response_transforms + .iter() + .map(|entry| (entry.name, "response-transform")), + ) + } + + pub async fn run_pre_handlers( + &self, + ctx: &ReqCtx, + request: &Request, + ) -> Result { + for entry in &self.pre_handlers { + match entry.handler.handle(ctx, RequestView::new(request)).await { + Ok(PreHandlerDecision::Continue) => {} + Ok(PreHandlerDecision::Respond(response)) => { + return Ok(PreHandlerDecision::Respond(response)); + } + Err(source) => { + return Err(ExtensionError { + name: entry.name, + source, + }); + } + } + } + Ok(PreHandlerDecision::Continue) + } + + pub async fn run_response_transforms(&self, ctx: &ReqCtx, response: &mut Response) { + for entry in &self.response_transforms { + entry.transform.transform(ctx, response).await; + } + } +} + +#[derive(Debug, thiserror::Error)] +#[error("extension {name:?} failed: {source}")] +pub struct ExtensionError { + pub name: &'static str, + #[source] + pub source: HandlerError, +} + +impl ExtensionError { + pub fn status(&self) -> http::StatusCode { + self.source.status() + } +} + +fn valid_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && name.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || b".-_".contains(&byte) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MarkerTransform; + + #[async_trait] + impl ResponseTransform for MarkerTransform { + async fn transform(&self, _ctx: &ReqCtx, response: &mut Response) { + response.headers_mut().insert( + "x-extension-test", + http::HeaderValue::from_static("present"), + ); + } + } + + #[test] + fn registration_names_are_bounded_and_unique_across_hook_kinds() { + let mut registry = ExtensionRegistry::new(); + registry + .register_response_transform("example.marker", Arc::new(MarkerTransform)) + .unwrap(); + assert!(matches!( + registry.register_response_transform("example.marker", Arc::new(MarkerTransform)), + Err(RegisterError::DuplicateName(_)) + )); + assert!(matches!( + registry.register_response_transform("Bad Name", Arc::new(MarkerTransform)), + Err(RegisterError::InvalidName(_)) + )); + } +} diff --git a/crates/hj-fastcgi/Cargo.toml b/crates/hj-fastcgi/Cargo.toml new file mode 100644 index 0000000..3bdcb13 --- /dev/null +++ b/crates/hj-fastcgi/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "hj-fastcgi" +version.workspace = true +edition.workspace = true +publish.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +hj-core = { workspace = true } +hj-lsapi = { workspace = true } +bytes = { workspace = true } +http = { workspace = true } +http-body-util = { workspace = true, features = ["channel"] } +async-trait = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } diff --git a/crates/hj-fastcgi/src/handler.rs b/crates/hj-fastcgi/src/handler.rs new file mode 100644 index 0000000..1a533c1 --- /dev/null +++ b/crates/hj-fastcgi/src/handler.rs @@ -0,0 +1,767 @@ +use std::{path::PathBuf, sync::Arc, time::Duration}; + +use async_trait::async_trait; +use bytes::{Bytes, BytesMut}; +use http_body_util::{BodyExt, channel::Channel, combinators::BoxBody}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use hj_core::{ + Body, BoxError, Handler, HandlerError, ReqCtx, Request, Response, + budget::{BodyBufferBudget, BodyBufferLease, DEFAULT_BODY_BUFFER_MEM}, +}; +use hj_lsapi::CgiEnvBuilder; + +use crate::{ + FastCgiPool, PoolError, Record, RecordType, begin_request, encode_name_value_pairs, + encode_stream, end_stream, parse_cgi_head, parse_end_request, parse_record, +}; + +const REQUEST_ID: u16 = 1; + +/// Pipeline-pinned FastCGI target. Construction rejects relative paths, and the +/// fields are private so URI-to-filesystem fallback cannot be introduced by a +/// direct handler caller. +#[derive(Clone, Debug)] +pub struct FastCgiScript { + script: PathBuf, + script_name: Option, + path_info: Option, +} + +impl FastCgiScript { + pub fn new(script: impl Into) -> Result { + let script = script.into(); + if !script.is_absolute() { + return Err("FastCGI script target must be absolute"); + } + Ok(Self { + script, + script_name: None, + path_info: None, + }) + } + + pub fn script_name(mut self, value: impl Into) -> Self { + self.script_name = Some(value.into()); + self + } + + pub fn path_info(mut self, value: impl Into) -> Self { + self.path_info = Some(value.into()); + self + } +} + +/// Bounded FastCGI responder handler. Routing must attach [`FastCgiScript`] +/// after resolving and validating the script beneath the configured context. +pub struct FastCgi { + pool: Arc, + max_body: u64, + max_params: usize, + max_response_header: usize, + max_response_headers: usize, + read_timeout: Duration, + stderr_limit: usize, + body_budget: Arc, + base_env: Vec<(String, String)>, +} + +impl FastCgi { + pub fn new(pool: Arc) -> Self { + Self { + pool, + max_body: 16 * 1024 * 1024, + max_params: 128 * 1024, + max_response_header: 64 * 1024, + max_response_headers: 256, + read_timeout: Duration::from_secs(60), + stderr_limit: 16 * 1024, + body_budget: Arc::new(BodyBufferBudget::new(DEFAULT_BODY_BUFFER_MEM)), + base_env: Vec::new(), + } + } + + pub fn max_body(mut self, bytes: u64) -> Self { + self.max_body = bytes; + self + } + + pub fn body_buffer_budget(mut self, budget: Arc) -> Self { + self.body_budget = budget; + self + } + + pub fn read_timeout(mut self, timeout: Duration) -> Self { + self.read_timeout = timeout; + self + } + + /// Add operator-controlled application environment without allowing it to + /// replace CGI identity, request headers, TLS assertions, or redirect state. + pub fn base_env( + mut self, + values: impl IntoIterator, + ) -> Result { + for (name, value) in values { + if !safe_extra_env_name(&name) || value.as_bytes().contains(&0) { + return Err(format!("unsafe FastCGI environment variable {name:?}")); + } + self.base_env.push((name, value)); + } + Ok(self) + } +} + +#[async_trait] +impl Handler for FastCgi { + async fn handle(&self, ctx: &mut ReqCtx, mut req: Request) -> Result { + let target = req + .extensions() + .get::() + .cloned() + .ok_or_else(|| HandlerError::Other("FastCGI script target was not resolved".into()))?; + let declared = content_length(&req)?; + if declared.is_some_and(|len| len > self.max_body) { + return Err(HandlerError::PayloadTooLarge); + } + let (body, _lease) = collect_body(req.body_mut(), self.max_body, &self.body_budget).await?; + if declared.is_some_and(|len| len != body.len() as u64) { + return Err(HandlerError::BadGateway( + "request body length did not match Content-Length".into(), + )); + } + req.headers_mut().insert( + http::header::CONTENT_LENGTH, + http::HeaderValue::from_str(&body.len().to_string()) + .map_err(|_| HandlerError::PayloadTooLarge)?, + ); + + let mut builder = CgiEnvBuilder::new(&target.script) + .server_software("httpjet") + .extra_ref(&self.base_env); + if let Some(value) = target.script_name { + builder = builder.script_name(value); + } + if let Some(value) = target.path_info { + builder = builder.path_info(value); + } + let env = builder.build(&req, ctx); + let params = encode_name_value_pairs( + REQUEST_ID, + env.iter() + .map(|(name, value)| (name.as_bytes(), value.as_bytes())), + self.max_params, + ) + .map_err(|_| HandlerError::RequestHeaderFieldsTooLarge)?; + drop(env); + + let mut conn = self.pool.acquire().await.map_err(map_pool_error)?; + write_request(&mut conn, params, &body, self.read_timeout).await?; + + let mut wire = BytesMut::with_capacity(16 * 1024); + let mut header = BytesMut::new(); + let mut stderr_seen = 0_usize; + let parsed = loop { + let record = next_record(&mut conn, &mut wire, self.read_timeout).await?; + require_response_record(&record)?; + match record.kind { + RecordType::Stdout if record.content.is_empty() => { + return Err(HandlerError::BadGateway( + "FastCGI response ended before CGI headers".into(), + )); + } + RecordType::Stdout => { + if header.len() >= self.max_response_header { + return Err(HandlerError::BadGateway( + "FastCGI response headers exceeded configured bound".into(), + )); + } + header.extend_from_slice(&record.content); + match parse_cgi_head( + header.clone().freeze(), + self.max_response_header, + self.max_response_headers, + ) { + Ok(parsed) => break parsed, + Err(crate::ResponseError::Incomplete) + if header.len() <= self.max_response_header => {} + Err(error) => return Err(bad_response(error)), + } + } + RecordType::Stderr => { + log_stderr(&record.content, &mut stderr_seen, self.stderr_limit) + } + _ => { + return Err(HandlerError::BadGateway( + "unexpected FastCGI record before response headers".into(), + )); + } + } + }; + + let status = parsed.status; + let headers = parsed.headers; + let prefix = parsed.body_prefix; + let is_head = req.method() == http::Method::HEAD; + let expected = if is_head { + None + } else { + response_content_length(&headers)? + }; + + let body = if is_head { + tokio::spawn(pump_response( + conn, + wire, + prefix, + None, + expected, + self.read_timeout, + stderr_seen, + self.stderr_limit, + true, + )); + Body::Empty + } else { + let (tx, channel) = Channel::::new(8); + tokio::spawn(pump_response( + conn, + wire, + prefix, + Some(tx), + expected, + self.read_timeout, + stderr_seen, + self.stderr_limit, + false, + )); + Body::Stream(BoxBody::new(channel)) + }; + let mut response = Response::new(body); + *response.status_mut() = status; + *response.headers_mut() = headers; + Ok(response) + } +} + +fn map_pool_error(error: PoolError) -> HandlerError { + match error { + PoolError::Timeout => HandlerError::GatewayTimeout, + PoolError::Connect => HandlerError::ServiceUnavailable, + } +} + +fn safe_extra_env_name(name: &str) -> bool { + let valid = !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_') + && name + .as_bytes() + .first() + .is_some_and(|byte| byte.is_ascii_uppercase() || *byte == b'_'); + if !valid + || name.starts_with("HTTP_") + || name.starts_with("SSL_") + || name.starts_with("REDIRECT_") + { + return false; + } + !matches!( + name, + "AUTH_TYPE" + | "CONTENT_LENGTH" + | "CONTENT_TYPE" + | "DOCUMENT_ROOT" + | "GATEWAY_INTERFACE" + | "HTTPS" + | "PATH_INFO" + | "PATH_TRANSLATED" + | "QUERY_STRING" + | "REMOTE_ADDR" + | "REMOTE_PORT" + | "REMOTE_USER" + | "REQUEST_METHOD" + | "REQUEST_TIME" + | "REQUEST_TIME_FLOAT" + | "REQUEST_URI" + | "SCRIPT_FILENAME" + | "SCRIPT_NAME" + | "SERVER_ADDR" + | "SERVER_NAME" + | "SERVER_PORT" + | "SERVER_PROTOCOL" + | "SERVER_SOFTWARE" + ) +} + +async fn write_request( + conn: &mut crate::pool::PooledConnection, + params: Vec, + body: &[u8], + timeout: Duration, +) -> Result<(), HandlerError> { + let operation = async { + conn.write_all(&begin_request(REQUEST_ID, true).expect("constant request id")) + .await?; + for record in params { + conn.write_all(&record).await?; + } + for record in encode_stream(RecordType::Stdin, REQUEST_ID, body) + .expect("bounded request id and chunks") + { + conn.write_all(&record).await?; + } + conn.write_all(&end_stream(RecordType::Stdin, REQUEST_ID).expect("constant request id")) + .await?; + conn.flush().await + }; + tokio::time::timeout(timeout, operation) + .await + .map_err(|_| HandlerError::GatewayTimeout)? + .map_err(HandlerError::Io) +} + +async fn next_record( + conn: &mut crate::pool::PooledConnection, + wire: &mut BytesMut, + timeout: Duration, +) -> Result { + loop { + if let Some(record) = parse_record(wire).map_err(|error| { + HandlerError::BadGateway(format!("malformed FastCGI record: {error}")) + })? { + return Ok(record); + } + let read = tokio::time::timeout(timeout, conn.read_buf(wire)) + .await + .map_err(|_| HandlerError::GatewayTimeout)? + .map_err(HandlerError::Io)?; + if read == 0 { + return Err(HandlerError::BadGateway( + "FastCGI connection closed before END_REQUEST".into(), + )); + } + } +} + +fn require_response_record(record: &Record) -> Result<(), HandlerError> { + if record.request_id != REQUEST_ID { + return Err(HandlerError::BadGateway( + "FastCGI response request id mismatch".into(), + )); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn pump_response( + mut conn: crate::pool::PooledConnection, + mut wire: BytesMut, + prefix: Bytes, + mut tx: Option>, + expected: Option, + timeout: Duration, + mut stderr_seen: usize, + stderr_limit: usize, + discard: bool, +) { + let mut sent = 0_u64; + let mut stdout_ended = false; + let mut stderr_ended = false; + let mut clean = feed(&mut tx, prefix, &mut sent, expected, discard).await; + while clean { + let record = match next_record(&mut conn, &mut wire, timeout).await { + Ok(record) => record, + Err(error) => { + tracing::warn!(target: "hj_fastcgi", %error, "FastCGI response stream failed"); + break; + } + }; + if require_response_record(&record).is_err() { + break; + } + match record.kind { + RecordType::Stdout if record.content.is_empty() && !stdout_ended => stdout_ended = true, + RecordType::Stdout if !stdout_ended => { + clean = feed(&mut tx, record.content, &mut sent, expected, discard).await; + } + RecordType::Stderr if record.content.is_empty() && !stderr_ended => { + stderr_ended = true; + } + RecordType::Stderr if !stderr_ended => { + log_stderr(&record.content, &mut stderr_seen, stderr_limit) + } + RecordType::EndRequest if stdout_ended => { + match parse_end_request(&record.content) { + Ok(app_status) if expected.is_none_or(|length| length == sent) => { + if app_status != 0 { + tracing::warn!(target: "hj_fastcgi", app_status, "FastCGI application returned nonzero status"); + } + conn.mark_reusable(); + clean = true; + } + _ => clean = false, + } + break; + } + _ => { + clean = false; + break; + } + } + } + if !clean { + tracing::warn!(target: "hj_fastcgi", "FastCGI response was incomplete; connection discarded"); + if let Some(sender) = tx.take() { + sender.abort(Box::new(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "incomplete FastCGI response", + ))); + } + } + drop(tx); +} + +async fn feed( + tx: &mut Option>, + chunk: Bytes, + sent: &mut u64, + expected: Option, + discard: bool, +) -> bool { + if expected.is_some_and(|length| chunk.len() as u64 > length.saturating_sub(*sent)) { + return false; + } + *sent += chunk.len() as u64; + if !discard && !chunk.is_empty() { + let Some(sender) = tx.as_mut() else { + return false; + }; + if sender.send_data(chunk).await.is_err() { + return false; + } + } + true +} + +fn content_length(req: &Request) -> Result, HandlerError> { + let mut length = None; + for value in req.headers().get_all(http::header::CONTENT_LENGTH) { + let value = value + .to_str() + .ok() + .filter(|value| !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| HandlerError::BadGateway("invalid Content-Length".into()))?; + if length.is_some_and(|previous| previous != value) { + return Err(HandlerError::BadGateway( + "conflicting Content-Length".into(), + )); + } + length = Some(value); + } + Ok(length) +} + +fn response_content_length(headers: &http::HeaderMap) -> Result, HandlerError> { + let mut length = None; + for raw in headers.get_all(http::header::CONTENT_LENGTH) { + for value in raw + .to_str() + .map_err(|_| HandlerError::BadGateway("invalid FastCGI Content-Length".into()))? + .split(',') + { + let value = value + .trim() + .parse::() + .map_err(|_| HandlerError::BadGateway("invalid FastCGI Content-Length".into()))?; + if length.is_some_and(|previous| previous != value) { + return Err(HandlerError::BadGateway( + "conflicting FastCGI Content-Length".into(), + )); + } + length = Some(value); + } + } + Ok(length) +} + +async fn collect_body( + body: &mut hj_core::IncomingBody, + max: u64, + budget: &Arc, +) -> Result<(Bytes, BodyBufferLease), HandlerError> { + let mut bytes = BytesMut::new(); + let mut lease = BodyBufferLease::new(Arc::clone(budget)); + while let Some(frame) = body.frame().await { + let frame = + frame.map_err(|error| HandlerError::BadGateway(format!("request body: {error}")))?; + if let Some(data) = frame.data_ref() { + if data.len() as u64 > max.saturating_sub(bytes.len() as u64) { + return Err(HandlerError::PayloadTooLarge); + } + if !lease.reserve(data.len() as u64) { + return Err(HandlerError::ServiceUnavailable); + } + bytes.extend_from_slice(data); + } + } + Ok((bytes.freeze(), lease)) +} + +fn bad_response(error: crate::ResponseError) -> HandlerError { + HandlerError::BadGateway(format!("invalid FastCGI CGI response: {error}")) +} + +fn log_stderr(bytes: &[u8], seen: &mut usize, limit: usize) { + if *seen >= limit || bytes.is_empty() { + return; + } + let keep = bytes.len().min(limit - *seen); + *seen += keep; + let escaped: String = String::from_utf8_lossy(&bytes[..keep]) + .chars() + .flat_map(char::escape_default) + .collect(); + tracing::warn!(target: "hj_fastcgi", stderr = %escaped, "FastCGI application stderr"); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{collections::BTreeMap, net::IpAddr}; + + use hj_core::{ + Proto, + config::{ServerConfig, VHostConfig}, + empty_incoming, + }; + use http_body_util::BodyExt; + + fn context() -> ReqCtx { + let server = ServerConfig { + server_root: Default::default(), + server_name: String::new(), + user: String::new(), + group: String::new(), + index_files: vec![], + tuning: Default::default(), + quic_enable: false, + use_ip_in_proxy_header: 0, + expires: Default::default(), + cache: Default::default(), + security: Default::default(), + suexec: Default::default(), + ext_processors: vec![], + php_config: None, + listeners: vec![], + vhosts: BTreeMap::new(), + vhost_order: vec![], + mime: Default::default(), + }; + ReqCtx { + server: Arc::new(server), + vhost_name: "example.test".into(), + vhost: Arc::new(VHostConfig { + doc_root: "/srv/www".into(), + ..Default::default() + }), + peer_ip: "127.0.0.1".parse::().unwrap(), + client_ip: "203.0.113.10".parse::().unwrap(), + is_tls: false, + protocol: Proto::Http1, + trusted_proxy: false, + env: vec![], + local_addr: "127.0.0.1:8080".parse().unwrap(), + peer_port: 50123, + request_time: std::time::UNIX_EPOCH, + request_id: Default::default(), + tls: None, + peer_unix: false, + redirect_guard: None, + } + } + + async fn server_record(stream: &mut tokio::net::TcpStream, wire: &mut BytesMut) -> Record { + loop { + if let Some(record) = parse_record(wire).unwrap() { + return record; + } + assert_ne!(stream.read_buf(wire).await.unwrap(), 0); + } + } + + fn decode_len(input: &mut &[u8]) -> usize { + let first = input[0]; + if first & 0x80 == 0 { + *input = &input[1..]; + first as usize + } else { + let value = u32::from_be_bytes([input[0] & 0x7f, input[1], input[2], input[3]]); + *input = &input[4..]; + value as usize + } + } + + fn decode_params(bytes: &[u8]) -> BTreeMap { + let mut input = bytes; + let mut values = BTreeMap::new(); + while !input.is_empty() { + let name_len = decode_len(&mut input); + let value_len = decode_len(&mut input); + let name = String::from_utf8(input[..name_len].to_vec()).unwrap(); + input = &input[name_len..]; + let value = String::from_utf8(input[..value_len].to_vec()).unwrap(); + input = &input[value_len..]; + values.insert(name, value); + } + values + } + + #[tokio::test] + async fn handler_pins_target_streams_response_and_reuses_only_clean_connection() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut wire = BytesMut::new(); + for iteration in 0..2 { + assert_eq!( + server_record(&mut stream, &mut wire).await.kind, + RecordType::BeginRequest + ); + let mut params = BytesMut::new(); + loop { + let record = server_record(&mut stream, &mut wire).await; + assert_eq!(record.kind, RecordType::Params); + if record.content.is_empty() { + break; + } + params.extend_from_slice(&record.content); + } + let env = decode_params(¶ms); + assert_eq!(env["SCRIPT_FILENAME"], "/srv/apps/index.fcgi"); + assert_eq!(env["SCRIPT_NAME"], "/app"); + assert_eq!(env["PATH_INFO"], "/tail"); + let mut stdin = BytesMut::new(); + loop { + let record = server_record(&mut stream, &mut wire).await; + assert_eq!(record.kind, RecordType::Stdin); + if record.content.is_empty() { + break; + } + stdin.extend_from_slice(&record.content); + } + assert!(stdin.is_empty()); + let response = format!( + "Status: 201 Created\r\nContent-Type: text/plain\r\nContent-Length: 6\r\n\r\nbody-{iteration}" + ); + for frame in + encode_stream(RecordType::Stdout, REQUEST_ID, response.as_bytes()).unwrap() + { + stream.write_all(&frame).await.unwrap(); + } + stream + .write_all(&end_stream(RecordType::Stdout, REQUEST_ID).unwrap()) + .await + .unwrap(); + stream + .write_all(&end_stream(RecordType::Stderr, REQUEST_ID).unwrap()) + .await + .unwrap(); + stream + .write_all(&crate::end_request(REQUEST_ID, 0).unwrap()) + .await + .unwrap(); + stream.flush().await.unwrap(); + } + }); + let pool = Arc::new( + FastCgiPool::new( + crate::Endpoint::Tcp(address), + 1, + Duration::from_secs(1), + Duration::from_secs(30), + ) + .unwrap(), + ); + let handler = FastCgi::new(pool); + for iteration in 0..2 { + let mut request = http::Request::builder() + .uri("/app/tail?q=1") + .header("host", "example.test") + .body(empty_incoming()) + .unwrap(); + request.extensions_mut().insert( + FastCgiScript::new("/srv/apps/index.fcgi") + .unwrap() + .script_name("/app") + .path_info("/tail"), + ); + let response = handler.handle(&mut context(), request).await.unwrap(); + assert_eq!(response.status(), http::StatusCode::CREATED); + let body = match response.into_body() { + Body::Stream(body) => body.collect().await.unwrap().to_bytes(), + _ => panic!("expected streamed FastCGI body"), + }; + assert_eq!(body, format!("body-{iteration}")); + } + server.await.unwrap(); + } + + #[tokio::test] + async fn missing_or_relative_script_target_fails_closed_before_pool_use() { + assert!(FastCgiScript::new("relative.fcgi").is_err()); + let pool = Arc::new( + FastCgiPool::new( + crate::Endpoint::Tcp("127.0.0.1:9".parse().unwrap()), + 1, + Duration::from_millis(10), + Duration::from_secs(1), + ) + .unwrap(), + ); + let error = match FastCgi::new(pool) + .handle(&mut context(), http::Request::new(empty_incoming())) + .await + { + Err(error) => error, + Ok(_) => panic!("missing target must fail"), + }; + assert!(matches!(error, HandlerError::Other(_))); + } + + #[test] + fn configured_environment_cannot_override_request_identity() { + let pool = Arc::new( + FastCgiPool::new( + crate::Endpoint::Tcp("127.0.0.1:9".parse().unwrap()), + 1, + Duration::from_millis(10), + Duration::from_secs(1), + ) + .unwrap(), + ); + assert!( + FastCgi::new(pool.clone()) + .base_env([("APP_MODE".into(), "production".into())]) + .is_ok() + ); + for name in [ + "SCRIPT_FILENAME", + "REMOTE_ADDR", + "HTTP_AUTHORIZATION", + "SSL_CLIENT_VERIFY", + "REDIRECT_STATUS", + "bad-name", + ] { + assert!( + FastCgi::new(pool.clone()) + .base_env([(name.into(), "forged".into())]) + .is_err(), + "{name} must be protected" + ); + } + } +} diff --git a/crates/hj-fastcgi/src/lib.rs b/crates/hj-fastcgi/src/lib.rs new file mode 100644 index 0000000..255f1c0 --- /dev/null +++ b/crates/hj-fastcgi/src/lib.rs @@ -0,0 +1,18 @@ +//! Bounded FastCGI client primitives. +//! +//! The crate is deliberately not wired into request routing yet. Protocol, +//! pooling and script-target validation land independently so an incomplete +//! gateway can never turn a configured script into source-file serving. + +mod handler; +mod pool; +mod proto; +mod response; + +pub use handler::{FastCgi, FastCgiScript}; +pub use pool::{Endpoint, FastCgiPool, PoolError}; +pub use proto::{ + FCGI_KEEP_CONN, FCGI_RESPONDER, Record, RecordType, WireError, begin_request, + encode_name_value_pairs, encode_stream, end_request, end_stream, parse_record, +}; +pub use response::{CgiHead, ResponseError, parse_cgi_head, parse_end_request}; diff --git a/crates/hj-fastcgi/src/pool.rs b/crates/hj-fastcgi/src/pool.rs new file mode 100644 index 0000000..8f5f9eb --- /dev/null +++ b/crates/hj-fastcgi/src/pool.rs @@ -0,0 +1,320 @@ +use std::{ + collections::VecDeque, + net::SocketAddr, + path::PathBuf, + pin::Pin, + sync::{Arc, Mutex}, + task::{Context, Poll}, + time::{Duration, Instant}, +}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Endpoint { + Tcp(SocketAddr), + TcpHost(String), + Unix(PathBuf), +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum PoolError { + #[error("FastCGI connection acquisition timed out")] + Timeout, + #[error("FastCGI connection failed")] + Connect, +} + +enum Stream { + Tcp(tokio::net::TcpStream), + Unix(tokio::net::UnixStream), +} + +impl AsyncRead for Stream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + match self.get_mut() { + Self::Tcp(stream) => Pin::new(stream).poll_read(cx, buf), + Self::Unix(stream) => Pin::new(stream).poll_read(cx, buf), + } + } +} + +impl AsyncWrite for Stream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + match self.get_mut() { + Self::Tcp(stream) => Pin::new(stream).poll_write(cx, buf), + Self::Unix(stream) => Pin::new(stream).poll_write(cx, buf), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.get_mut() { + Self::Tcp(stream) => Pin::new(stream).poll_flush(cx), + Self::Unix(stream) => Pin::new(stream).poll_flush(cx), + } + } + + fn poll_shutdown( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + match self.get_mut() { + Self::Tcp(stream) => Pin::new(stream).poll_shutdown(cx), + Self::Unix(stream) => Pin::new(stream).poll_shutdown(cx), + } + } +} + +struct Idle { + stream: Stream, + since: Instant, +} + +struct State { + open: usize, + idle: VecDeque, +} + +struct Inner { + endpoint: Endpoint, + max_open: usize, + acquire_timeout: Duration, + idle_timeout: Duration, + state: Mutex, + changed: tokio::sync::Notify, +} + +#[derive(Clone)] +pub struct FastCgiPool(Arc); + +impl FastCgiPool { + pub fn new( + endpoint: Endpoint, + max_open: usize, + acquire_timeout: Duration, + idle_timeout: Duration, + ) -> Result { + if max_open == 0 { + return Err(PoolError::Connect); + } + Ok(Self(Arc::new(Inner { + endpoint, + max_open, + acquire_timeout, + idle_timeout, + state: Mutex::new(State { + open: 0, + idle: VecDeque::new(), + }), + changed: tokio::sync::Notify::new(), + }))) + } + + pub async fn acquire(&self) -> Result { + tokio::time::timeout(self.0.acquire_timeout, self.acquire_inner()) + .await + .map_err(|_| PoolError::Timeout)? + } + + async fn acquire_inner(&self) -> Result { + loop { + let notified = self.0.changed.notified(); + let reserve = { + let mut state = self.0.state.lock().expect("FastCGI pool lock poisoned"); + let now = Instant::now(); + while state + .idle + .front() + .is_some_and(|idle| now.duration_since(idle.since) >= self.0.idle_timeout) + { + state.idle.pop_front(); + state.open -= 1; + } + if let Some(idle) = state.idle.pop_back() { + return Ok(PooledConnection { + inner: self.0.clone(), + stream: Some(idle.stream), + reusable: false, + }); + } + if state.open < self.0.max_open { + state.open += 1; + true + } else { + false + } + }; + if reserve { + let stream = match &self.0.endpoint { + Endpoint::Tcp(address) => tokio::net::TcpStream::connect(address) + .await + .map(Stream::Tcp), + Endpoint::TcpHost(address) => tokio::net::TcpStream::connect(address) + .await + .map(Stream::Tcp), + Endpoint::Unix(path) => tokio::net::UnixStream::connect(path) + .await + .map(Stream::Unix), + }; + return match stream { + Ok(stream) => Ok(PooledConnection { + inner: self.0.clone(), + stream: Some(stream), + reusable: false, + }), + Err(_) => { + self.0.release_open(); + Err(PoolError::Connect) + } + }; + } + notified.await; + } + } + + #[cfg(test)] + fn counts(&self) -> (usize, usize) { + let state = self.0.state.lock().unwrap(); + (state.open, state.idle.len()) + } +} + +impl Inner { + fn release_open(&self) { + let mut state = self.state.lock().expect("FastCGI pool lock poisoned"); + state.open -= 1; + drop(state); + self.changed.notify_one(); + } +} + +pub struct PooledConnection { + inner: Arc, + stream: Option, + reusable: bool, +} + +impl PooledConnection { + /// Mark this connection reusable only after a matching clean END_REQUEST. + pub(crate) fn mark_reusable(&mut self) { + self.reusable = true; + } +} + +impl AsyncRead for PooledConnection { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(self.stream.as_mut().expect("connection stream missing")).poll_read(cx, buf) + } +} + +impl AsyncWrite for PooledConnection { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(self.stream.as_mut().expect("connection stream missing")).poll_write(cx, buf) + } + + fn poll_flush( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + Pin::new(self.stream.as_mut().expect("connection stream missing")).poll_flush(cx) + } + + fn poll_shutdown( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + Pin::new(self.stream.as_mut().expect("connection stream missing")).poll_shutdown(cx) + } +} + +impl Drop for PooledConnection { + fn drop(&mut self) { + let stream = self.stream.take().expect("connection stream missing"); + if self.reusable { + let mut state = self.inner.state.lock().expect("FastCGI pool lock poisoned"); + state.idle.push_back(Idle { + stream, + since: Instant::now(), + }); + drop(state); + self.inner.changed.notify_one(); + } else { + drop(stream); + self.inner.release_open(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn total_open_cap_includes_idle_and_clean_connections_reuse() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let accepted = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count = accepted.clone(); + let server = tokio::spawn(async move { + while let Ok((_stream, _)) = listener.accept().await { + count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + }); + let pool = FastCgiPool::new( + Endpoint::Tcp(address), + 1, + Duration::from_millis(30), + Duration::from_secs(30), + ) + .unwrap(); + let first = pool.acquire().await.unwrap(); + assert!(matches!(pool.acquire().await, Err(PoolError::Timeout))); + let mut first = first; + first.mark_reusable(); + drop(first); + assert_eq!(pool.counts(), (1, 1)); + let second = pool.acquire().await.unwrap(); + assert_eq!(pool.counts(), (1, 0)); + drop(second); + assert_eq!(pool.counts(), (0, 0)); + tokio::task::yield_now().await; + assert_eq!(accepted.load(std::sync::atomic::Ordering::SeqCst), 1); + server.abort(); + } + + #[tokio::test] + async fn poisoned_connection_releases_capacity_for_a_fresh_dial() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let _first = listener.accept().await.unwrap(); + let _second = listener.accept().await.unwrap(); + }); + let pool = FastCgiPool::new( + Endpoint::Tcp(address), + 1, + Duration::from_secs(1), + Duration::from_secs(30), + ) + .unwrap(); + drop(pool.acquire().await.unwrap()); + drop(pool.acquire().await.unwrap()); + server.await.unwrap(); + assert_eq!(pool.counts(), (0, 0)); + } +} diff --git a/crates/hj-fastcgi/src/proto.rs b/crates/hj-fastcgi/src/proto.rs new file mode 100644 index 0000000..6a473f7 --- /dev/null +++ b/crates/hj-fastcgi/src/proto.rs @@ -0,0 +1,279 @@ +use bytes::{BufMut, Bytes, BytesMut}; + +pub const FCGI_VERSION_1: u8 = 1; +pub const FCGI_RESPONDER: u16 = 1; +pub const FCGI_KEEP_CONN: u8 = 1; +const HEADER_LEN: usize = 8; +const MAX_CONTENT_LEN: usize = u16::MAX as usize; +const MAX_PAIR_FIELD_LEN: usize = 0x7fff_ffff; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum RecordType { + BeginRequest = 1, + AbortRequest = 2, + EndRequest = 3, + Params = 4, + Stdin = 5, + Stdout = 6, + Stderr = 7, + Data = 8, +} + +impl TryFrom for RecordType { + type Error = WireError; + + fn try_from(value: u8) -> Result { + Ok(match value { + 1 => Self::BeginRequest, + 2 => Self::AbortRequest, + 3 => Self::EndRequest, + 4 => Self::Params, + 5 => Self::Stdin, + 6 => Self::Stdout, + 7 => Self::Stderr, + 8 => Self::Data, + _ => return Err(WireError::RecordType), + }) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct Record { + pub kind: RecordType, + pub request_id: u16, + pub content: Bytes, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum WireError { + #[error("FastCGI request id must be nonzero")] + RequestId, + #[error("unsupported FastCGI version")] + Version, + #[error("unsupported FastCGI record type")] + RecordType, + #[error("invalid FastCGI reserved byte")] + Reserved, + #[error("FastCGI field exceeds protocol bounds")] + FieldTooLarge, + #[error("FastCGI parameter block exceeds configured bound")] + ParamsTooLarge, +} + +fn record(kind: RecordType, request_id: u16, content: &[u8]) -> Result { + if request_id == 0 { + return Err(WireError::RequestId); + } + if content.len() > MAX_CONTENT_LEN { + return Err(WireError::FieldTooLarge); + } + let padding = (8 - content.len() % 8) % 8; + let mut out = BytesMut::with_capacity(HEADER_LEN + content.len() + padding); + out.put_u8(FCGI_VERSION_1); + out.put_u8(kind as u8); + out.put_u16(request_id); + out.put_u16(content.len() as u16); + out.put_u8(padding as u8); + out.put_u8(0); + out.extend_from_slice(content); + out.resize(out.len() + padding, 0); + Ok(out.freeze()) +} + +pub fn begin_request(request_id: u16, keep_connection: bool) -> Result { + let mut body = [0_u8; 8]; + body[..2].copy_from_slice(&FCGI_RESPONDER.to_be_bytes()); + body[2] = if keep_connection { FCGI_KEEP_CONN } else { 0 }; + record(RecordType::BeginRequest, request_id, &body) +} + +fn put_len(out: &mut BytesMut, len: usize) -> Result<(), WireError> { + if len < 128 { + out.put_u8(len as u8); + } else if len <= MAX_PAIR_FIELD_LEN { + out.put_u32((len as u32) | 0x8000_0000); + } else { + return Err(WireError::FieldTooLarge); + } + Ok(()) +} + +/// Encode one bounded PARAMS stream, including its required empty terminator. +pub fn encode_name_value_pairs( + request_id: u16, + pairs: I, + max_encoded_bytes: usize, +) -> Result, WireError> +where + N: AsRef<[u8]>, + V: AsRef<[u8]>, + I: IntoIterator, +{ + if request_id == 0 { + return Err(WireError::RequestId); + } + let mut encoded = BytesMut::new(); + for (name, value) in pairs { + let name = name.as_ref(); + let value = value.as_ref(); + let prefix = usize::from(name.len() >= 128) * 3 + usize::from(value.len() >= 128) * 3 + 2; + let added = prefix + .checked_add(name.len()) + .and_then(|size| size.checked_add(value.len())) + .ok_or(WireError::ParamsTooLarge)?; + if added > max_encoded_bytes.saturating_sub(encoded.len()) { + return Err(WireError::ParamsTooLarge); + } + put_len(&mut encoded, name.len())?; + put_len(&mut encoded, value.len())?; + encoded.extend_from_slice(name); + encoded.extend_from_slice(value); + } + let mut records = encode_stream(RecordType::Params, request_id, &encoded)?; + records.push(record(RecordType::Params, request_id, &[])?); + Ok(records) +} + +/// Split a byte stream into protocol-sized records. The caller emits the empty +/// terminator explicitly where the FastCGI stream contract requires one. +pub fn encode_stream( + kind: RecordType, + request_id: u16, + bytes: &[u8], +) -> Result, WireError> { + if request_id == 0 { + return Err(WireError::RequestId); + } + bytes + .chunks(MAX_CONTENT_LEN) + .map(|chunk| record(kind, request_id, chunk)) + .collect() +} + +/// Encode the required empty terminator for PARAMS, STDIN, or STDOUT. +pub fn end_stream(kind: RecordType, request_id: u16) -> Result { + record(kind, request_id, &[]) +} + +/// Encode a responder END_REQUEST record. +pub fn end_request(request_id: u16, app_status: u32) -> Result { + let mut content = [0_u8; 8]; + content[..4].copy_from_slice(&app_status.to_be_bytes()); + record(RecordType::EndRequest, request_id, &content) +} + +/// Parse exactly one record from the front of `input`; incomplete input is left +/// untouched. The reserved byte is required to be zero; padding contents are +/// opaque per the protocol and are skipped. +pub fn parse_record(input: &mut BytesMut) -> Result, WireError> { + if input.len() < HEADER_LEN { + return Ok(None); + } + if input[0] != FCGI_VERSION_1 { + return Err(WireError::Version); + } + let kind = RecordType::try_from(input[1])?; + let request_id = u16::from_be_bytes([input[2], input[3]]); + if request_id == 0 { + return Err(WireError::RequestId); + } + let content_len = u16::from_be_bytes([input[4], input[5]]) as usize; + let padding_len = input[6] as usize; + if input[7] != 0 { + return Err(WireError::Reserved); + } + let total = HEADER_LEN + content_len + padding_len; + if input.len() < total { + return Ok(None); + } + let mut frame = input.split_to(total); + let content = frame.split_off(HEADER_LEN).split_to(content_len).freeze(); + Ok(Some(Record { + kind, + request_id, + content, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn begin_request_round_trips_with_responder_role() { + let bytes = begin_request(7, true).unwrap(); + let mut input = BytesMut::from(bytes.as_ref()); + let parsed = parse_record(&mut input).unwrap().unwrap(); + assert_eq!(parsed.kind, RecordType::BeginRequest); + assert_eq!(parsed.request_id, 7); + assert_eq!(&parsed.content[..3], &[0, 1, FCGI_KEEP_CONN]); + assert!(parsed.content[3..].iter().all(|byte| *byte == 0)); + assert!(input.is_empty()); + } + + #[test] + fn params_use_one_and_four_byte_lengths_and_terminate() { + let long = vec![b'x'; 128]; + let records = encode_name_value_pairs( + 1, + [(b"A".as_slice(), b"B".as_slice()), (long.as_slice(), b"v")], + 1024, + ) + .unwrap(); + assert_eq!(records.len(), 2); + let mut first = BytesMut::from(records[0].as_ref()); + let content = parse_record(&mut first).unwrap().unwrap().content; + assert_eq!(&content[..4], &[1, 1, b'A', b'B']); + assert_eq!(&content[4..8], &[0x80, 0, 0, 128]); + let mut end = BytesMut::from(records[1].as_ref()); + assert!(parse_record(&mut end).unwrap().unwrap().content.is_empty()); + } + + #[test] + fn stream_splits_at_wire_limit_without_truncation() { + let body = vec![0x5a; MAX_CONTENT_LEN + 9]; + let records = encode_stream(RecordType::Stdin, 3, &body).unwrap(); + assert_eq!(records.len(), 2); + let mut recovered = Vec::new(); + for bytes in records { + let mut input = BytesMut::from(bytes.as_ref()); + recovered.extend_from_slice(&parse_record(&mut input).unwrap().unwrap().content); + } + assert_eq!(recovered, body); + } + + #[test] + fn bounds_and_malformed_frames_fail_closed() { + assert_eq!(begin_request(0, false), Err(WireError::RequestId)); + assert_eq!( + encode_name_value_pairs(1, [("name", "value")], 3), + Err(WireError::ParamsTooLarge) + ); + let mut bad = BytesMut::from(&b"\x02\x06\x00\x01\x00\x00\x00\x00"[..]); + assert_eq!(parse_record(&mut bad), Err(WireError::Version)); + let mut bad = BytesMut::from(&b"\x01\x06\x00\x01\x00\x00\x00\x01"[..]); + assert_eq!(parse_record(&mut bad), Err(WireError::Reserved)); + + let mut padded = record(RecordType::Stdout, 1, b"x").unwrap().to_vec(); + *padded.last_mut().unwrap() = 0xa5; + assert_eq!( + parse_record(&mut BytesMut::from(padded.as_slice())) + .unwrap() + .unwrap() + .content, + Bytes::from_static(b"x") + ); + } + + #[test] + fn incomplete_record_consumes_nothing() { + let encoded = begin_request(1, false).unwrap(); + for end in 0..encoded.len() { + let mut input = BytesMut::from(&encoded[..end]); + let before = input.clone(); + assert_eq!(parse_record(&mut input).unwrap(), None); + assert_eq!(input, before); + } + } +} diff --git a/crates/hj-fastcgi/src/response.rs b/crates/hj-fastcgi/src/response.rs new file mode 100644 index 0000000..919d348 --- /dev/null +++ b/crates/hj-fastcgi/src/response.rs @@ -0,0 +1,183 @@ +use bytes::Bytes; +use http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; + +#[derive(Debug)] +pub struct CgiHead { + pub status: StatusCode, + pub headers: HeaderMap, + pub body_prefix: Bytes, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ResponseError { + #[error("FastCGI response headers are incomplete")] + Incomplete, + #[error("FastCGI response headers exceed configured bounds")] + TooLarge, + #[error("FastCGI response header is malformed")] + Malformed, + #[error("FastCGI application did not complete the request")] + IncompleteRequest, +} + +/// Parse a complete CGI header block plus any already-buffered body prefix. +/// Only CRLF framing is accepted; folded and hop-by-hop fields fail closed. +pub fn parse_cgi_head( + bytes: Bytes, + max_header_bytes: usize, + max_headers: usize, +) -> Result { + let boundary = bytes + .windows(4) + .position(|window| window == b"\r\n\r\n") + .ok_or(ResponseError::Incomplete)?; + let head_len = boundary + 4; + if head_len > max_header_bytes || max_headers == 0 { + return Err(ResponseError::TooLarge); + } + let mut status = None; + let mut headers = HeaderMap::new(); + let mut count = 0_usize; + let mut lines = bytes[..boundary].split(|byte| *byte == b'\n').peekable(); + while let Some(raw) = lines.next() { + let line = if let Some(line) = raw.strip_suffix(b"\r") { + line + } else if lines.peek().is_none() { + raw + } else { + return Err(ResponseError::Malformed); + }; + if line.is_empty() || matches!(line.first(), Some(b' ' | b'\t')) { + return Err(ResponseError::Malformed); + } + let colon = line + .iter() + .position(|byte| *byte == b':') + .ok_or(ResponseError::Malformed)?; + let name = HeaderName::from_bytes(&line[..colon]).map_err(|_| ResponseError::Malformed)?; + let value = line[colon + 1..] + .strip_prefix(b" ") + .unwrap_or(&line[colon + 1..]); + if name == "status" { + if status.is_some() { + return Err(ResponseError::Malformed); + } + let code = value + .split(|byte| *byte == b' ') + .next() + .and_then(|value| std::str::from_utf8(value).ok()) + .and_then(|value| value.parse::().ok()) + .and_then(|value| StatusCode::from_u16(value).ok()) + .ok_or(ResponseError::Malformed)?; + status = Some(code); + continue; + } + if matches!( + name.as_str(), + "connection" + | "keep-alive" + | "proxy-connection" + | "transfer-encoding" + | "upgrade" + | "te" + | "trailer" + ) { + return Err(ResponseError::Malformed); + } + count += 1; + if count > max_headers { + return Err(ResponseError::TooLarge); + } + headers.append( + name, + HeaderValue::from_bytes(value).map_err(|_| ResponseError::Malformed)?, + ); + } + let status = status.unwrap_or_else(|| { + if headers.contains_key(http::header::LOCATION) { + StatusCode::FOUND + } else { + StatusCode::OK + } + }); + Ok(CgiHead { + status, + headers, + body_prefix: bytes.slice(head_len..), + }) +} + +/// Return the application status from a successful END_REQUEST record. +pub fn parse_end_request(content: &[u8]) -> Result { + if content.len() != 8 || content[4] != 0 || content[5..].iter().any(|byte| *byte != 0) { + return Err(ResponseError::IncompleteRequest); + } + Ok(u32::from_be_bytes(content[..4].try_into().unwrap())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_headers_duplicates_and_body_prefix_parse() { + let parsed = parse_cgi_head( + Bytes::from_static(b"Status: 201 Created\r\nContent-Type: text/plain\r\nSet-Cookie: a=1\r\nSet-Cookie: b=2\r\n\r\nbody"), + 1024, + 8, + ) + .unwrap(); + assert_eq!(parsed.status, 201); + assert_eq!(parsed.headers.get_all("set-cookie").iter().count(), 2); + assert_eq!(parsed.body_prefix, "body"); + } + + #[test] + fn location_defaults_to_redirect_and_plain_response_to_ok() { + assert_eq!( + parse_cgi_head(Bytes::from_static(b"Location: /next\r\n\r\n"), 100, 2) + .unwrap() + .status, + StatusCode::FOUND + ); + assert_eq!( + parse_cgi_head( + Bytes::from_static(b"Content-Type: text/plain\r\n\r\n"), + 100, + 2 + ) + .unwrap() + .status, + StatusCode::OK + ); + } + + #[test] + fn malformed_folded_hop_by_hop_duplicate_status_and_bounds_reject() { + for value in [ + b" Bad: fold\r\n\r\n".as_slice(), + b"Connection: close\r\n\r\n", + b"Status: 200 OK\r\nStatus: 201 X\r\n\r\n", + b"Broken\r\n\r\n", + b"X: bad\nY: ok\r\n\r\n", + ] { + assert_eq!( + parse_cgi_head(Bytes::copy_from_slice(value), 1024, 8).unwrap_err(), + ResponseError::Malformed + ); + } + assert_eq!( + parse_cgi_head(Bytes::from_static(b"A: 1\r\nB: 2\r\n\r\n"), 1024, 1).unwrap_err(), + ResponseError::TooLarge + ); + } + + #[test] + fn end_request_requires_complete_protocol_status_and_reserved_bytes() { + assert_eq!(parse_end_request(&[0, 0, 0, 7, 0, 0, 0, 0]), Ok(7)); + assert_eq!( + parse_end_request(&[0, 0, 0, 0, 2, 0, 0, 0]), + Err(ResponseError::IncompleteRequest) + ); + } +} diff --git a/crates/hj-h2/src/server/completion_tests.rs b/crates/hj-h2/src/server/completion_tests.rs new file mode 100644 index 0000000..7a9c12a --- /dev/null +++ b/crates/hj-h2/src/server/completion_tests.rs @@ -0,0 +1,190 @@ +use super::*; +use hj_core::{Body, ResponseCompletion, ResponseEnd}; +use std::sync::{Arc, Mutex}; + +type Events = Arc>>; +fn prepare(body: Body) -> (OutQueue, FxHashMap, VecDeque, Events) { + let events = Events::default(); + let copy = events.clone(); + let mut response = http::Response::new(body); + response + .extensions_mut() + .insert(ResponseCompletion::new(move |end| { + copy.lock().unwrap().push(end) + })); + let mut out = OutQueue::default(); + let mut streams = FxHashMap::default(); + let mut schedule = VecDeque::new(); + send::begin_response( + 1, + false, + response, + &mut Encoder::new(), + &mut out, + &mut streams, + &mut schedule, + &mut FxHashMap::default(), + &PeerSettings::default(), + &mut Vec::new(), + ); + (out, streams, schedule, events) +} + +#[tokio::test] +async fn flow_control_retains_completion_until_final_flush() { + let (mut out, mut streams, mut schedule, events) = + prepare(Body::Full(Bytes::from_static(b"body"))); + streams.get_mut(&1).unwrap().window = 0; + let mut credit = 4; + send::pump_streams( + &mut streams, + &mut schedule, + &mut out, + &mut credit, + &mut send::Pulls::new(), + &PeerSettings::default(), + ); + flush(&mut tokio::io::sink(), &mut out).await.unwrap(); + assert!( + events.lock().unwrap().is_empty(), + "headers are not full-response completion" + ); + streams.get_mut(&1).unwrap().window = 4; + send::pump_streams( + &mut streams, + &mut schedule, + &mut out, + &mut credit, + &mut send::Pulls::new(), + &PeerSettings::default(), + ); + assert!(streams.is_empty()); + assert!( + events.lock().unwrap().is_empty(), + "queued END_STREAM is not write completion" + ); + flush(&mut tokio::io::sink(), &mut out).await.unwrap(); + assert_eq!(*events.lock().unwrap(), vec![ResponseEnd::Complete]); +} + +#[tokio::test] +async fn headers_only_flush_failure_reports_error() { + let (mut out, _, _, events) = prepare(Body::Empty); + assert!(events.lock().unwrap().is_empty()); + let (mut writer, reader) = tokio::io::duplex(64); + drop(reader); + assert!(flush(&mut writer, &mut out).await.is_err()); + drop(out); + assert_eq!(*events.lock().unwrap(), vec![ResponseEnd::Error]); +} + +#[test] +fn reset_and_connection_drop_report_cancellation() { + let (_, mut streams, _, events) = prepare(Body::Full(Bytes::from_static(b"body"))); + send::cancel_outstream(&mut streams, 1); + assert_eq!(*events.lock().unwrap(), vec![ResponseEnd::Cancelled]); + let (out, _, _, events) = prepare(Body::Empty); + drop(out); + assert_eq!(*events.lock().unwrap(), vec![ResponseEnd::Cancelled]); +} + +#[tokio::test] +async fn uncached_file_retains_completion_until_eof_and_flush() { + use futures_util::StreamExt; + let path = std::env::temp_dir().join(format!( + "hj-h2-completion-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, b"file data").unwrap(); + let body = Body::File(hj_core::FileBody { + path: path.clone(), + file: None, + len: 9, + range: None, + cached: None, + }); + let (mut out, mut streams, mut schedule, events) = prepare(body); + let mut credit = 100; + let mut pulls = send::Pulls::new(); + while !streams.is_empty() { + send::pump_streams( + &mut streams, + &mut schedule, + &mut out, + &mut credit, + &mut pulls, + &PeerSettings::default(), + ); + assert!(events.lock().unwrap().is_empty()); + if !pulls.is_empty() { + let (sid, body, chunk) = + tokio::time::timeout(std::time::Duration::from_secs(2), pulls.next()) + .await + .unwrap() + .unwrap(); + send::apply_pull(sid, body, chunk, &mut streams, &mut out); + } + } + flush(&mut tokio::io::sink(), &mut out).await.unwrap(); + assert_eq!(*events.lock().unwrap(), vec![ResponseEnd::Complete]); + std::fs::remove_file(path).unwrap(); +} + +#[test] +fn body_error_reports_error_once() { + use http_body_util::BodyExt; + let (mut out, mut streams, _, events) = prepare(Body::Full(Bytes::from_static(b"body"))); + let body = http_body_util::Empty::::new() + .map_err(|e| -> hj_core::BoxError { match e {} }) + .boxed(); + send::apply_pull( + 1, + body, + Some(Err(std::io::Error::other("synthetic failure").into())), + &mut streams, + &mut out, + ); + drop(streams); + drop(out); + assert_eq!(*events.lock().unwrap(), vec![ResponseEnd::Error]); +} + +#[cfg(feature = "monoio")] +#[test] +fn monoio_flush_completes_only_after_writing_final_batch() { + use std::io::Read; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap(); + let client = std::thread::spawn(move || { + let mut stream = std::net::TcpStream::connect(addr).unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(2))) + .unwrap(); + // Read the final HTTP/2 frame, not TCP EOF: monoio may defer socket + // destruction until the runtime next polls its completion queue. + let mut head = [0; crate::frame::FrameHeader::LEN]; + stream.read_exact(&mut head).unwrap(); + let frame = crate::frame::FrameHeader::parse(&head).unwrap(); + assert_ne!(frame.flags & crate::frame::flags::END_STREAM, 0); + let mut payload = vec![0; frame.length as usize]; + stream.read_exact(&mut payload).unwrap(); + }); + let mut runtime = monoio::RuntimeBuilder::::new() + .enable_timer() + .build() + .unwrap(); + runtime.block_on(async { + let listener = monoio::net::TcpListener::from_std(listener).unwrap(); + let (mut stream, _) = listener.accept().await.unwrap(); + let (mut out, _, _, events) = prepare(Body::Empty); + assert!(events.lock().unwrap().is_empty()); + monoio_flush(&mut stream, &mut out, None).await.unwrap(); + assert_eq!(*events.lock().unwrap(), vec![ResponseEnd::Complete]); + }); + client.join().unwrap(); +} diff --git a/crates/hj-h2/src/server/mod.rs b/crates/hj-h2/src/server/mod.rs index 9c2680e..36bfd67 100644 --- a/crates/hj-h2/src/server/mod.rs +++ b/crates/hj-h2/src/server/mod.rs @@ -783,9 +783,21 @@ enum Seg { pub(super) struct OutQueue { inline: Vec, segs: Vec, + completions: Vec, } +#[cfg(test)] +mod completion_tests; impl OutQueue { + fn finish_responses(&mut self, success: bool) { + for completion in self.completions.drain(..) { + completion.finish(if success { + hj_core::ResponseEnd::Complete + } else { + hj_core::ResponseEnd::Error + }); + } + } #[inline] fn is_empty(&self) -> bool { self.segs.is_empty() @@ -829,6 +841,12 @@ impl OutQueue { /// inline runs and referenced bodies into records without us concatenating them first, so /// large bodies skip the body→buffer copy. Handles short writes by advancing the slices. async fn flush(w: &mut W, q: &mut OutQueue) -> std::io::Result<()> { + let result = flush_inner(w, q).await; + q.finish_responses(result.is_ok()); + result +} + +async fn flush_inner(w: &mut W, q: &mut OutQueue) -> std::io::Result<()> { use tokio::io::AsyncWriteExt; if q.is_empty() { return Ok(()); @@ -901,6 +919,17 @@ async fn monoio_flush( stream: &mut IO, q: &mut OutQueue, ktls_fd: Option, +) -> std::io::Result<()> { + let result = monoio_flush_inner(stream, q, ktls_fd).await; + q.finish_responses(result.is_ok()); + result +} + +#[cfg(feature = "monoio")] +async fn monoio_flush_inner( + stream: &mut IO, + q: &mut OutQueue, + ktls_fd: Option, ) -> std::io::Result<()> { use monoio::io::AsyncWriteRentExt; if q.is_empty() { diff --git a/crates/hj-h2/src/server/send.rs b/crates/hj-h2/src/server/send.rs index 8ed0d91..db5d8f3 100644 --- a/crates/hj-h2/src/server/send.rs +++ b/crates/hj-h2/src/server/send.rs @@ -140,6 +140,7 @@ pub(super) fn begin_response( block_scratch: &mut Vec, ) { let (mut head, body) = response.into_parts(); + let mut completion = head.extensions.remove::(); // §8.2.2: connection-specific ("hop-by-hop") fields are illegal on an h2 response — strip // them before encoding so a backend that emits e.g. `Connection`/`Transfer-Encoding` // (PHP over LSAPI, a proxied upstream) can't produce a malformed frame stream. @@ -220,8 +221,9 @@ pub(super) fn begin_response( let credit = pending_window.remove(&stream_id).unwrap_or(0); let window = (peer.initial_window + credit).min(i32::MAX as i64); - let headers_only = |out: &mut OutQueue| { + let headers_only = |out: &mut OutQueue, completion: Option| { out.frames(|b| write_field_block(b, stream_id, flags::END_STREAM, block, mf)); + out.completions.extend(completion); }; let headers_open = |out: &mut OutQueue| { out.frames(|b| write_field_block(b, stream_id, 0, block, mf)); @@ -230,10 +232,12 @@ pub(super) fn begin_response( send_schedule: &mut VecDeque, pending, body, - eof| { + eof, + completion| { outstreams.insert( stream_id, OutStream { + completion, pending, body, pulling: false, @@ -249,21 +253,28 @@ pub(super) fn begin_response( // HEAD and body-forbidden statuses send the header block only. HEAD keeps the // representation headers a GET would have; body-forbidden statuses are sanitized above. if body_forbidden { - headers_only(out); + headers_only(out, completion.take()); return; } match body { - Body::Empty => headers_only(out), + Body::Empty => headers_only(out, completion.take()), Body::Stream(s) => { headers_open(out); - register(outstreams, send_schedule, Bytes::new(), Some(s), false); + register( + outstreams, + send_schedule, + Bytes::new(), + Some(s), + false, + completion.take(), + ); } // Uncached file: stream it asynchronously (64 KiB chunks off tokio's blocking // pool) so a large file never blocks the connection task or its other streams. Body::File(f) if f.cached.is_none() => { if f.len == 0 { - headers_only(out); + headers_only(out, completion.take()); } else { headers_open(out); register( @@ -272,16 +283,24 @@ pub(super) fn begin_response( Bytes::new(), Some(file_stream_body(f.path, f.file, f.range, f.len)), false, + completion.take(), ); } } other => { let bytes = body_to_bytes(other); // Body::Full or a cached file — already in memory if bytes.is_empty() { - headers_only(out); + headers_only(out, completion.take()); } else { headers_open(out); - register(outstreams, send_schedule, bytes, None, true); + register( + outstreams, + send_schedule, + bytes, + None, + true, + completion.take(), + ); } } } @@ -471,6 +490,7 @@ fn pump_one_frame( st.window -= n as i64; if last { st.done = true; + out.completions.extend(st.completion.take()); } return true; } @@ -487,6 +507,7 @@ fn pump_one_frame( .write(b) }); st.done = true; + out.completions.extend(st.completion.take()); return true; } false @@ -572,6 +593,9 @@ pub(super) fn apply_pull( } None => st.eof = true, Some(Err(_e)) => { + if let Some(completion) = st.completion.take() { + completion.finish(hj_core::ResponseEnd::Error); + } out.frames(|b| frame::write_rst_stream(b, sid, error_code::INTERNAL_ERROR)); st.done = true; // dropped by the next pump_streams pass } @@ -674,6 +698,7 @@ mod tests { fn send_flow_control_rotates_before_reusing_connection_credit() { fn stream(body: &'static [u8]) -> OutStream { OutStream { + completion: None, pending: Bytes::from_static(body), body: None, pulling: false, @@ -870,6 +895,7 @@ mod tests { outstreams.insert( sid, OutStream { + completion: None, pending: Bytes::new(), body: None, pulling: true, diff --git a/crates/hj-h2/src/server/state.rs b/crates/hj-h2/src/server/state.rs index f0c80ac..2a04919 100644 --- a/crates/hj-h2/src/server/state.rs +++ b/crates/hj-h2/src/server/state.rs @@ -135,6 +135,7 @@ pub(super) struct StreamState { /// In-memory bodies (`Full` / `File`) seed `pending` and finish in one pass; streaming /// bodies (`Stream` — LSAPI / proxy / SSE) pull chunks asynchronously into `pending`. pub(super) struct OutStream { + pub(super) completion: Option, /// Bytes pulled but not yet sent (the window-blocked remainder of the current chunk). pub(super) pending: Bytes, /// The streaming source while it is "at home" (not currently being pulled). `None` diff --git a/crates/hj-log/src/error_json.rs b/crates/hj-log/src/error_json.rs new file mode 100644 index 0000000..65e2174 --- /dev/null +++ b/crates/hj-log/src/error_json.rs @@ -0,0 +1,186 @@ +use crate::{LogLevel, json_escape}; +use std::{ + fmt::Write as _, + time::{SystemTime, UNIX_EPOCH}, +}; +use tracing::field::{Field, Visit}; + +fn sensitive(name: &str) -> bool { + let n = name.to_ascii_lowercase().replace(['_', '-'], ""); + [ + "authorization", + "cookie", + "password", + "passwd", + "secret", + "token", + "apikey", + "privatekey", + "clientkey", + "signingkey", + "accesskey", + "credential", + ] + .iter() + .any(|s| n.contains(s)) + || n == "key" +} + +fn redact(name: &str, value: &str, cap: usize) -> String { + if value.len() > 32768 { + return "[OVERSIZED]".into(); + } + // Free-form diagnostics cannot reliably separate a secret from its surrounding + // text. Suppress the entire value when common credential syntax is present. + let lower = value.to_ascii_lowercase(); + if sensitive(name) + || lower + .split(|c: char| !c.is_ascii_alphanumeric()) + .any(sensitive) + || [ + "authorization:", + "bearer ", + "basic ", + "password=", + "password:", + "token=", + "secret=", + "api_key=", + "apikey=", + "cookie:", + "-----begin private key", + ] + .iter() + .any(|s| lower.contains(s)) + || value.contains("://") && (value.contains('@') || value.contains('?')) + { + return "[REDACTED]".into(); + } + value.chars().take(cap).collect() +} + +pub(crate) fn render( + level: LogLevel, + ts: SystemTime, + target: Option<&str>, + message: &str, + fields: &[(String, String)], +) -> String { + let q = |s: &str| format!("\"{}\"", json_escape(s)); + let values = fields + .iter() + .take(32) + .map(|(k, v)| { + format!( + "{}:{}", + q(&k.chars().take(128).collect::()), + q(&redact(k, v, 1024)) + ) + }) + .collect::>() + .join(","); + format!( + "{{\"schema_version\":1,\"timestamp_unix_ms\":{},\"level\":{},\"target\":{},\"message\":{},\"fields\":{{{}}}}}", + ts.duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + q(level.as_str()), + target + .map(|s| q(&redact("target", s, 256))) + .unwrap_or_else(|| "null".into()), + q(&redact("message", message, 8192)), + values + ) +} + +#[derive(Default)] +pub(crate) struct Collector { + pub message: String, + pub fields: Vec<(String, String)>, +} +impl Collector { + fn add(&mut self, name: &str, value: &str) { + if name == "message" { + self.message = redact(name, value, 8192); + } else if self.fields.len() < 32 { + self.fields.push((name.into(), redact(name, value, 1024))); + } + } +} +impl Visit for Collector { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if sensitive(field.name()) { + self.add(field.name(), "[REDACTED]"); + return; + } + // Bound formatting itself, not only the serialized result. + struct Buffer(String); + impl std::fmt::Write for Buffer { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + if self.0.len() + s.len() > 32768 { + return Err(std::fmt::Error); + } + self.0.push_str(s); + Ok(()) + } + } + let mut b = Buffer(String::new()); + if write!(&mut b, "{value:?}").is_err() { + self.add(field.name(), "[OVERSIZED]"); + } else { + self.add(field.name(), &b.0); + } + } + fn record_str(&mut self, field: &Field, value: &str) { + self.add(field.name(), value); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn escapes_and_redacts_without_changing_field_boundaries() { + let fields = vec![ + ("authorization".into(), "Bearer hidden".into()), + ("detail".into(), "https://user:pass@host/".into()), + ("code".into(), "502".into()), + ]; + let line = render( + LogLevel::Error, + UNIX_EPOCH, + Some("proxy"), + "bad\n\"value\"", + &fields, + ); + assert!(line.contains("\"timestamp_unix_ms\":0")); + assert!(line.contains("bad\\n\\\"value\\\"")); + assert!(line.contains("\"code\":\"502\"")); + assert!(!line.contains("hidden") && !line.contains("user:pass")); + assert!(!line.contains('\n')); + for value in [ + "Authorization: Basic abc", + "password=hunter2", + "https://host/?secret=abc", + "Cookie: sid=abc", + "{\"password\":\"hidden\"}", + "token = hidden", + ] { + assert_eq!(redact("message", value, 8192), "[REDACTED]"); + } + } + #[tokio::test] + async fn writer_json_mode_preserves_one_event_per_line() { + let path = std::env::temp_dir().join(format!("hj-json-errors-{}.log", std::process::id())); + let logger = + crate::ErrorLogger::spawn_with_format(&path, 0, 0, false, crate::ErrorLogFormat::Json); + logger.log_at(LogLevel::Error, UNIX_EPOCH, "line\none"); + logger.log_at(LogLevel::Warn, UNIX_EPOCH, "token=hidden"); + logger.shutdown().await; + let content = std::fs::read_to_string(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + assert_eq!(content.lines().count(), 2); + assert!(content.contains("line\\none")); + assert!(!content.contains("hidden")); + } +} diff --git a/crates/hj-log/src/lib.rs b/crates/hj-log/src/lib.rs index c8d2315..8276405 100644 --- a/crates/hj-log/src/lib.rs +++ b/crates/hj-log/src/lib.rs @@ -72,6 +72,7 @@ //! # } //! ``` +mod error_json; mod fmt; mod syslog; mod tracing_layer; @@ -643,11 +644,20 @@ impl LogLevel { /// logger. Emits `2026-05-31 13:55:36.000000 [LEVEL] message` lines. #[derive(Clone)] pub struct ErrorLogger { + format: ErrorLogFormat, tx: mpsc::UnboundedSender, /// Per-logger state: `(depth, gone)` (see [`AccessLogger`]). state: LoggerState, } +/// Error-file format. Text preserves the historical output byte for byte. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ErrorLogFormat { + #[default] + Text, + Json, +} + impl ErrorLogger { /// Spawn an error-log writer task. Parameters mirror /// [`AccessLogger::spawn`] (minus the line format). @@ -656,6 +666,26 @@ impl ErrorLogger { rolling_size: u64, keep_days: u64, compress_archive: bool, + ) -> Self { + let format = match std::env::var("HTTPJET_ERROR_LOG_FORMAT").as_deref() { + Ok("json") => ErrorLogFormat::Json, + Ok("text") | Err(_) => ErrorLogFormat::Text, + Ok(_) => { + eprintln!( + "httpjet: invalid HTTPJET_ERROR_LOG_FORMAT; expected text or json, using text" + ); + ErrorLogFormat::Text + } + }; + Self::spawn_with_format(path, rolling_size, keep_days, compress_archive, format) + } + + pub fn spawn_with_format( + path: impl AsRef, + rolling_size: u64, + keep_days: u64, + compress_archive: bool, + format: ErrorLogFormat, ) -> Self { let cfg = RollConfig { path: path.as_ref().to_path_buf(), @@ -669,7 +699,7 @@ impl ErrorLogger { tokio::spawn(writer::run(cfg, rx, state.clone(), None)), "error-log", ); - ErrorLogger { tx, state } + ErrorLogger { tx, state, format } } /// Log a message at `level` using `SystemTime::now()` as the timestamp. @@ -679,6 +709,10 @@ impl ErrorLogger { /// Log a message with an explicit timestamp (deterministic; used in tests). pub fn log_at(&self, level: LogLevel, ts: SystemTime, msg: impl AsRef) { + if self.format == ErrorLogFormat::Json { + self.structured_at(level, ts, None, msg.as_ref(), &[]); + return; + } let line = format!( "{} [{}] {}", fmt::error_time(ts), @@ -688,6 +722,22 @@ impl ErrorLogger { send_or_warn(&self.tx, &self.state, Msg::Line(line), "error-log"); } + pub(crate) fn is_json(&self) -> bool { + self.format == ErrorLogFormat::Json + } + + pub(crate) fn structured_at( + &self, + level: LogLevel, + ts: SystemTime, + target: Option<&str>, + message: &str, + fields: &[(String, String)], + ) { + let line = error_json::render(level, ts, target, message, fields); + send_or_warn(&self.tx, &self.state, Msg::Line(line), "error-log"); + } + /// Append a captured backend `stderr` line verbatim at `INFO` level. Embedded /// newlines are turned into separate records so each line is timestamped. pub fn capture_stderr(&self, raw: impl AsRef) { diff --git a/crates/hj-log/src/tracing_layer.rs b/crates/hj-log/src/tracing_layer.rs index cfc9365..f7ea0cf 100644 --- a/crates/hj-log/src/tracing_layer.rs +++ b/crates/hj-log/src/tracing_layer.rs @@ -52,6 +52,23 @@ impl Layer for ErrorLogLayer { return; } + if self.logger.is_json() { + let mut collector = crate::error_json::Collector::default(); + event.record(&mut collector); + self.logger.structured_at( + if *md.level() == Level::ERROR { + LogLevel::Error + } else { + LogLevel::Warn + }, + std::time::SystemTime::now(), + Some(md.target()), + &collector.message, + &collector.fields, + ); + return; + } + let mut collector = FieldCollector::default(); event.record(&mut collector); @@ -133,23 +150,40 @@ mod tests { let logger = ErrorLogger::spawn(&path, 0, 0, false); let layer = ErrorLogLayer::new(logger.clone()); + let json_path = dir.join(format!("hj-errlayer-json-{}.log", std::process::id())); + let _ = std::fs::remove_file(&json_path); + let json_logger = + ErrorLogger::spawn_with_format(&json_path, 0, 0, false, crate::ErrorLogFormat::Json); // `set_global_default` (unlike scoped `with_default`) raises the process-wide // runtime max level, so the `tracing::*` macros below are actually enabled. // This is the only subscriber-installing test in this (writer-only) binary. tracing::subscriber::set_global_default( tracing_subscriber::registry() .with(layer) + .with(ErrorLogLayer::new(json_logger.clone())) .with(tracing_subscriber::filter::LevelFilter::TRACE), ) .expect("install global subscriber for the layer test"); tracing::error!(code = 502, "backend down"); + tracing::error!( + authorization = "Bearer synthetic-secret", + code = 401, + "denied" + ); tracing::warn!("slow upstream"); tracing::info!("this should NOT be in the error log"); tracing::error!(target: "hj_log", "writer self-report should be dropped"); // Flush the writer to disk. logger.shutdown().await; + json_logger.shutdown().await; + let json = std::fs::read_to_string(&json_path).unwrap(); + std::fs::remove_file(&json_path).unwrap(); + assert!(json.contains("\"code\":\"502\"")); + assert!(json.contains("\"authorization\":\"[REDACTED]\"")); + assert!(!json.contains("synthetic-secret")); + assert!(!json.contains("writer self-report") && !json.contains("this should NOT")); let body = std::fs::read_to_string(&path).unwrap_or_default(); let _ = std::fs::remove_file(&path); diff --git a/crates/hj-ocsp/Cargo.toml b/crates/hj-ocsp/Cargo.toml new file mode 100644 index 0000000..3eec72b --- /dev/null +++ b/crates/hj-ocsp/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "hj-ocsp" +version.workspace = true +edition.workspace = true +publish.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +base64 = "0.22" +openssl = "=0.10.81" +openssl-sys = "=0.9.117" +foreign-types = "0.3" +reqwest = { version = "0.13", default-features = false, features = ["rustls"] } +tokio = { workspace = true } +parking_lot = { workspace = true } diff --git a/crates/hj-ocsp/src/fetch.rs b/crates/hj-ocsp/src/fetch.rs new file mode 100644 index 0000000..683965e --- /dev/null +++ b/crates/hj-ocsp/src/fetch.rs @@ -0,0 +1,354 @@ +use crate::{Error, MAX_RESPONSE}; +use base64::Engine; +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + time::Duration, +}; + +/// Explicit responder destination, never inferred from untrusted response data. +#[derive(Clone)] +pub struct Endpoint { + url: reqwest::Url, + loopback_test: bool, +} +impl Endpoint { + pub fn new(url: &str, loopback_test: bool) -> Result { + if url.len() > 2048 { + return Err(Error); + } + let url = reqwest::Url::parse(url).map_err(|_| Error)?; + if !matches!(url.scheme(), "http" | "https") + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.port_or_known_default().is_none_or(|p| p == 0) + { + return Err(Error); + } + let host = url.host_str().ok_or(Error)?.trim_matches(['[', ']']); + let literal = host.parse::().ok(); + if loopback_test { + // Test mode permits literal loopback ONLY, never private-network access. + if !literal.is_some_and(|ip| ip.is_loopback()) { + return Err(Error); + } + } else if literal.is_some_and(|ip| !public(ip)) { + return Err(Error); + } + Ok(Self { url, loopback_test }) + } + pub async fn fetch(&self, request: &[u8]) -> Result, Error> { + if request.is_empty() || request.len() > 4096 { + return Err(Error); + } + tokio::time::timeout(Duration::from_secs(10), self.fetch_inner(request)) + .await + .map_err(|_| Error)? + } + async fn fetch_inner(&self, request: &[u8]) -> Result, Error> { + let host = self.url.host_str().ok_or(Error)?.trim_matches(['[', ']']); + let port = self.url.port_or_known_default().ok_or(Error)?; + let addresses: Vec = match host.parse::() { + Ok(ip) => vec![SocketAddr::new(ip, port)], + Err(_) => tokio::net::lookup_host((host, port)) + .await + .map_err(|_| Error)? + .take(17) + .collect(), + }; + if addresses.is_empty() + || addresses.len() > 16 + || addresses.iter().any(|a| { + if self.loopback_test { + !a.ip().is_loopback() + } else { + !public(a.ip()) + } + }) + { + return Err(Error); + } + // Resolve once, verify every address, and pin the result for this fetch. + // Hostname/SNI verification still uses the configured host, not the IP. + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .retry(reqwest::retry::never()) + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(10)) + .resolve_to_addrs(host, &addresses) + .pool_max_idle_per_host(0) + .build() + .map_err(|_| Error)?; + let outgoing = match self.get_url(request)? { + Some(url) => client.get(url), + None => client + .post(self.url.clone()) + .header("content-type", "application/ocsp-request") + .body(request.to_vec()), + }; + let mut response = outgoing + .header("accept", "application/ocsp-response") + .send() + .await + .map_err(|_| Error)?; + if response.status() != reqwest::StatusCode::OK + || response + .content_length() + .is_some_and(|n| n > MAX_RESPONSE as u64) + || response + .headers() + .get("content-type") + .and_then(|h| h.to_str().ok()) + .is_none_or(|h| { + !h.split(';') + .next() + .unwrap_or_default() + .trim() + .eq_ignore_ascii_case("application/ocsp-response") + }) + || response.headers().contains_key("content-encoding") + { + return Err(Error); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|_| Error)? { + if chunk.len() > MAX_RESPONSE - bytes.len() { + return Err(Error); + } + bytes.extend_from_slice(&chunk); + } + if bytes.is_empty() { + return Err(Error); + } + Ok(bytes) + } + + // RFC 6960 A.1.1 / lightweight transport profile: measure the complete + // percent-encoded URL, not merely the DER or base64 payload. + fn get_url(&self, request: &[u8]) -> Result, Error> { + let encoded = base64::engine::general_purpose::STANDARD.encode(request); + let mut url = self.url.as_str().trim_end_matches('/').to_owned(); + url.push('/'); + for byte in encoded.bytes() { + match byte { + b'+' => url.push_str("%2B"), + b'/' => url.push_str("%2F"), + b'=' => url.push_str("%3D"), + _ => url.push(char::from(byte)), + } + } + if url.len() > 255 { + return Ok(None); + } + Ok(Some(reqwest::Url::parse(&url).map_err(|_| Error)?)) + } +} + +// Conservative public-unicast allowlist. Special-use and translation ranges +// are excluded even where some subranges have narrow global exceptions. +fn public(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => public_v4(ip), + IpAddr::V6(ip) => public_v6(ip), + } +} +fn public_v4(ip: Ipv4Addr) -> bool { + let [a, b, c, _] = ip.octets(); + !(a == 0 + || a == 10 + || a == 127 + || a >= 224 + || (a == 100 && (64..=127).contains(&b)) + || (a == 169 && b == 254) + || (a == 172 && (16..=31).contains(&b)) + || (a == 192 && (b == 168 || (b == 0 && (c == 0 || c == 2)) || (b == 88 && c == 99))) + || (a == 198 && ((18..=19).contains(&b) || (b == 51 && c == 100))) + || (a == 203 && b == 0 && c == 113)) +} +fn public_v6(ip: Ipv6Addr) -> bool { + let s = ip.segments(); + (0x2000..=0x3fff).contains(&s[0]) + && s[0] != 0x2002 + && !(s[0] == 0x2001 && (s[1] <= 0x1ff || s[1] == 0xdb8)) + && !(s[0] == 0x3fff && s[1] <= 0xfff) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Instant; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + }; + #[test] + fn get_encoding_and_complete_url_boundary() { + let endpoint = Endpoint::new("http://example.com/ocsp/", false).unwrap(); + assert_eq!( + endpoint.get_url(&[0xfb, 0xff]).unwrap().unwrap().as_str(), + "http://example.com/ocsp/%2B%2F8%3D" + ); + // One byte encodes to AA%3D%3D: eight URL bytes, plus separator. + for (length, get) in [(246, true), (247, false)] { + let mut base = "http://example.com/".to_owned(); + base.extend(std::iter::repeat_n('a', length - base.len())); + let endpoint = Endpoint::new(&base, false).unwrap(); + let result = endpoint.get_url(&[0]).unwrap(); + assert_eq!(result.is_some(), get); + if let Some(url) = result { + assert_eq!(url.as_str().len(), 255); + } + } + } + #[test] + fn endpoints_and_resolved_addresses_are_scoped() { + for bad in [ + "file:///etc/passwd", + "http://u:p@example.com/", + "http://127.1/", + "http://2130706433/", + "http://[::ffff:127.0.0.1]/", + "http://169.254.169.254/", + "http://example.com/?token=x", + ] { + assert!(Endpoint::new(bad, false).is_err(), "{bad}"); + } + assert!(Endpoint::new("http://127.0.0.1:1234/status", true).is_ok()); + assert!(Endpoint::new("http://localhost/status", true).is_err()); + assert!(Endpoint::new("https://ocsp.example.com/", false).is_ok()); + for bad in [ + "100.64.0.1", + "198.19.1.1", + "2001:db8::1", + "2002::1", + "fc00::1", + "fe80::1", + "3fff::1", + ] { + assert!(!public(bad.parse().unwrap())); + } + for good in ["8.8.8.8", "2606:4700::1111"] { + assert!(public(good.parse().unwrap())); + } + } + #[tokio::test] + async fn wire_uses_encoded_get_or_binary_post() { + for request in [vec![0xfb, 0xff], vec![42; 256]] { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = Endpoint::new( + &format!("http://{}/ocsp", listener.local_addr().unwrap()), + true, + ) + .unwrap(); + let expected = request.clone(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut received = Vec::new(); + let mut byte = [0; 1]; + while !received.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).await.unwrap(); + received.push(byte[0]); + assert!(received.len() < 4096); + } + let header = String::from_utf8(received).unwrap().to_ascii_lowercase(); + if expected.len() == 2 { + assert!(header.starts_with("get /ocsp/%2b%2f8%3d http/1.1\r\n")); + assert!(!header.contains("content-type:")); + } else { + assert!(header.starts_with("post /ocsp http/1.1\r\n")); + assert!(header.contains("content-type: application/ocsp-request\r\n")); + assert!(header.contains("content-length: 256\r\n")); + let mut body = vec![0; expected.len()]; + stream.read_exact(&mut body).await.unwrap(); + assert_eq!(body, expected); + } + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/ocsp-response\r\nContent-Length: 3\r\n\r\nabc").await.unwrap(); + }); + assert_eq!(endpoint.fetch(&request).await.unwrap(), b"abc"); + server.await.unwrap(); + } + } + #[tokio::test] + async fn bounded_fetch_rejects_redirects_encodings_and_oversized_bodies() { + for (reply, success) in [ + ( + "HTTP/1.1 200 OK\r\nContent-Type: application/ocsp-response\r\nContent-Length: 3\r\n\r\nabc", + true, + ), + ( + "HTTP/1.1 302 Found\r\nLocation: http://169.254.169.254/\r\nContent-Length: 0\r\n\r\n", + false, + ), + ( + "HTTP/1.1 200 OK\r\nContent-Type: application/ocsp-response\r\nContent-Length: 65537\r\n\r\n", + false, + ), + ( + "HTTP/1.1 200 OK\r\nContent-Type: application/ocsp-response\r\nContent-Encoding: gzip\r\nContent-Length: 3\r\n\r\nabc", + false, + ), + ( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 3\r\n\r\nabc", + false, + ), + ] { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = Endpoint::new( + &format!("http://{}/ocsp", listener.local_addr().unwrap()), + true, + ) + .unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buffer = [0; 4096]; + let _ = stream.read(&mut buffer).await.unwrap(); + stream.write_all(reply.as_bytes()).await.unwrap(); + }); + let result = endpoint.fetch(b"fixture-request").await; + assert_eq!(result.is_ok(), success); + if success { + assert_eq!(result.unwrap(), b"abc"); + } + server.await.unwrap(); + } + } + + #[tokio::test] + async fn chunked_limit_and_total_deadline_apply_without_content_length() { + for oversized in [true, false] { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = Endpoint::new( + &format!("http://{}/ocsp", listener.local_addr().unwrap()), + true, + ) + .unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buffer = [0; 4096]; + assert!(stream.read(&mut buffer).await.unwrap() > 0); + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/ocsp-response\r\nTransfer-Encoding: chunked\r\n\r\n").await.unwrap(); + if oversized { + let body = format!("10001\r\n{}\r\n0\r\n\r\n", "x".repeat(MAX_RESPONSE + 1)); + let _ = stream.write_all(body.as_bytes()).await; + } else { + // Wait for the client's deadline to close the connection; + // no arbitrary sleep and no task left behind by this test. + while stream.read(&mut buffer).await.unwrap_or(0) != 0 {} + } + }); + let start = Instant::now(); + assert!( + tokio::time::timeout(Duration::from_secs(12), endpoint.fetch(b"fixture-request")) + .await + .unwrap() + .is_err() + ); + if !oversized { + assert!(start.elapsed() >= Duration::from_secs(9)); + } + server.await.unwrap(); + } + } +} diff --git a/crates/hj-ocsp/src/ffi.rs b/crates/hj-ocsp/src/ffi.rs new file mode 100644 index 0000000..2ef374a --- /dev/null +++ b/crates/hj-ocsp/src/ffi.rs @@ -0,0 +1,117 @@ +//! Read-only OpenSSL 3 accessors absent from openssl-sys's OCSP wrapper. +//! Every pointer is borrowed from a live owned BasicResponse; none is freed here. +use crate::Error; +use foreign_types::ForeignTypeRef; +use openssl::{ + asn1::{Asn1GeneralizedTimeRef, Asn1TimeRef}, + nid::Nid, + ocsp::OcspBasicResponseRef, + stack::StackRef, + x509::{X509, X509AlgorithmRef, X509Ref}, +}; +use openssl_sys::{ASN1_GENERALIZEDTIME, OCSP_BASICRESP, X509_ALGOR}; +use std::ffi::{c_int, c_void}; + +unsafe extern "C" { + fn X509_check_ca(cert: *mut openssl_sys::X509) -> c_int; + fn OCSP_resp_get0_signer( + response: *mut OCSP_BASICRESP, + signer: *mut *mut openssl_sys::X509, + candidates: *mut openssl_sys::stack_st_X509, + ) -> c_int; + fn OCSP_resp_count(response: *mut OCSP_BASICRESP) -> c_int; + fn OCSP_resp_get0(response: *mut OCSP_BASICRESP, index: c_int) -> *mut c_void; + fn OCSP_resp_get0_produced_at(response: *const OCSP_BASICRESP) -> *const ASN1_GENERALIZEDTIME; + fn OCSP_resp_get0_tbs_sigalg(response: *const OCSP_BASICRESP) -> *const X509_ALGOR; + fn OCSP_BASICRESP_get_ext_by_critical( + response: *mut OCSP_BASICRESP, + critical: c_int, + last: c_int, + ) -> c_int; + fn OCSP_SINGLERESP_get_ext_by_critical( + response: *mut c_void, + critical: c_int, + last: c_int, + ) -> c_int; +} + +pub(crate) fn is_ca(cert: &X509Ref) -> bool { + // SAFETY: the valid certificate pointer is borrowed for a read-only check. + unsafe { X509_check_ca(cert.as_ptr()) > 0 } +} + +pub(crate) fn must_staple(cert: &X509Ref) -> Result { + let oid = openssl::asn1::Asn1Object::from_str("1.3.6.1.5.5.7.1.24").map_err(|_| Error)?; + // SAFETY: extension pointers are borrowed from the live input certificate, + // checked for null and duplicates before reading their bounded DER value. + unsafe { + let nid = oid.nid().as_raw(); + let index = openssl_sys::X509_get_ext_by_NID(cert.as_ptr(), nid, -1); + if index < 0 { + return Ok(false); + } + if openssl_sys::X509_get_ext_by_NID(cert.as_ptr(), nid, index) >= 0 { + return Err(Error); + } + let extension = openssl_sys::X509_get_ext(cert.as_ptr(), index); + if extension.is_null() { + return Err(Error); + } + // RFC 7633 SEQUENCE { INTEGER status_request(5) }. Other TLS feature + // requirements are unsupported, not silently treated as optional. + let data = openssl_sys::X509_EXTENSION_get_data(extension); + if data.is_null() { + return Err(Error); + } + if openssl::asn1::Asn1OctetStringRef::from_ptr(data).as_slice() != [0x30, 3, 2, 1, 5] { + return Err(Error); + } + Ok(true) + } +} + +pub(crate) fn signer_expiry<'a>( + response: &'a OcspBasicResponseRef, + candidates: &'a StackRef, +) -> Result<&'a Asn1TimeRef, Error> { + // SAFETY: get0 returns a borrowed certificate from response or candidates; + // both share the returned lifetime, and success/null are checked first. + unsafe { + let mut signer = std::ptr::null_mut(); + if OCSP_resp_get0_signer(response.as_ptr(), &mut signer, candidates.as_ptr()) != 1 + || signer.is_null() + { + return Err(Error); + } + Ok(X509Ref::from_ptr(signer).not_after()) + } +} + +pub(crate) fn profile( + response: &OcspBasicResponseRef, +) -> Result<(&Asn1GeneralizedTimeRef, Nid), Error> { + // SAFETY: valid response pointer owned by caller, checked single-response + // count and nulls before borrowing. Returned time lifetime is tied to input. + unsafe { + let pointer = response.as_ptr(); + if OCSP_resp_count(pointer) != 1 || OCSP_BASICRESP_get_ext_by_critical(pointer, 1, -1) >= 0 + { + return Err(Error); + } + let single = OCSP_resp_get0(pointer, 0); + if single.is_null() || OCSP_SINGLERESP_get_ext_by_critical(single, 1, -1) >= 0 { + return Err(Error); + } + let time = OCSP_resp_get0_produced_at(pointer); + let algorithm = OCSP_resp_get0_tbs_sigalg(pointer); + if time.is_null() || algorithm.is_null() { + return Err(Error); + } + Ok(( + Asn1GeneralizedTimeRef::from_ptr(time.cast_mut()), + X509AlgorithmRef::from_ptr(algorithm.cast_mut()) + .object() + .nid(), + )) + } +} diff --git a/crates/hj-ocsp/src/lib.rs b/crates/hj-ocsp/src/lib.rs new file mode 100644 index 0000000..5efdf1a --- /dev/null +++ b/crates/hj-ocsp/src/lib.rs @@ -0,0 +1,216 @@ +//! Bounded OCSP validation. TLS remains rustls; this crate authenticates staples. +mod fetch; +mod ffi; +mod refresh; +pub use fetch::Endpoint; +pub use refresh::{Decision, RefreshPool, Slot}; + +use foreign_types::ForeignTypeRef; +use openssl::{ + asn1::{Asn1GeneralizedTimeRef, Asn1Time, Asn1TimeRef}, + hash::MessageDigest, + nid::Nid, + ocsp::{OcspCertId, OcspCertStatus, OcspFlag, OcspRequest, OcspResponse, OcspResponseStatus}, + stack::Stack, + x509::{X509, X509VerifyResult, store::X509StoreBuilder, verify::X509VerifyFlags}, +}; +use std::{ + fmt, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +pub const MAX_RESPONSE: usize = 64 * 1024; +const MAX_AGE: u64 = 7 * 86400; +const SKEW: u64 = 300; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Error; +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("OCSP input, authorization or freshness rejected") + } +} +impl std::error::Error for Error {} +impl From for Error { + fn from(_: openssl::error::ErrorStack) -> Self { + Self + } +} + +/// An authenticated response with immutable wall-clock and monotonic expiry. +/// Construction is private: the resolver must never attach unverified bytes. +pub struct Staple { + der: Vec, + expires: u64, + deadline: Instant, +} +impl Staple { + pub fn bytes(&self) -> Option<&[u8]> { + self.bytes_at(SystemTime::now(), Instant::now()) + } + pub fn expires_unix(&self) -> u64 { + self.expires + } + fn bytes_at(&self, wall: SystemTime, mono: Instant) -> Option<&[u8]> { + (wall.duration_since(UNIX_EPOCH).ok()?.as_secs() < self.expires && mono < self.deadline) + .then_some(self.der.as_slice()) + } +} + +/// Non-GOOD outcomes are returned only after signature, identity and time checks. +pub enum Verdict { + Good(Staple), + Revoked, + Unknown, +} + +/// Pins the exact leaf and immediate issuer; no AIA or trust-root downloading. +pub struct Identity { + leaf: X509, + issuer: X509, + valid_from: u64, + valid_until: u64, + must_staple: bool, +} +impl Identity { + pub fn new(leaf_der: &[u8], issuer_der: &[u8]) -> Result { + if openssl::version::number() < 0x3000_0000 + || leaf_der.is_empty() + || issuer_der.is_empty() + || leaf_der.len() > MAX_RESPONSE + || issuer_der.len() > MAX_RESPONSE + { + return Err(Error); + } + let leaf = X509::from_der(leaf_der)?; + let issuer = X509::from_der(issuer_der)?; + let issuer_key = issuer.public_key()?; + if leaf.to_der()? != leaf_der + || issuer.to_der()? != issuer_der + || !ffi::is_ca(&issuer) + || issuer.issued(&leaf) != X509VerifyResult::OK + || !leaf.verify(&issuer_key)? + { + return Err(Error); + } + let valid_from = unix(leaf.not_before())?.max(unix(issuer.not_before())?); + let valid_until = unix(leaf.not_after())?.min(unix(issuer.not_after())?); + if valid_until <= valid_from { + return Err(Error); + } + Ok(Self { + must_staple: ffi::must_staple(&leaf)?, + leaf, + issuer, + valid_from, + valid_until, + }) + } + fn cert_id(&self) -> Result { + // Current lightweight OCSP profile uses SHA-256 for issuer identity. + Ok(OcspCertId::from_cert( + MessageDigest::sha256(), + &self.leaf, + &self.issuer, + )?) + } + pub fn request(&self) -> Result, Error> { + let mut request = OcspRequest::new()?; + request.add_id(self.cert_id()?)?; + Ok(request.to_der()?) + } + pub fn validate(&self, der: &[u8]) -> Result { + self.validate_at(der, SystemTime::now(), Instant::now()) + } + fn validate_at(&self, der: &[u8], wall: SystemTime, mono: Instant) -> Result { + let now = wall + .duration_since(UNIX_EPOCH) + .map_err(|_| Error)? + .as_secs(); + if der.is_empty() + || der.len() > MAX_RESPONSE + || now < self.valid_from + || now >= self.valid_until + { + return Err(Error); + } + let response = OcspResponse::from_der(der)?; + if response.status() != OcspResponseStatus::SUCCESSFUL || response.to_der()? != der { + return Err(Error); + } + let basic = response.basic()?; + let (produced, algorithm) = ffi::profile(&basic)?; + if !matches!( + algorithm, + Nid::SHA256WITHRSAENCRYPTION + | Nid::SHA384WITHRSAENCRYPTION + | Nid::SHA512WITHRSAENCRYPTION + | Nid::ECDSA_WITH_SHA256 + | Nid::ECDSA_WITH_SHA384 + | Nid::ECDSA_WITH_SHA512 + ) { + return Err(Error); + } + let mut candidates = Stack::new()?; + candidates.push(self.issuer.clone())?; + let mut trust = X509StoreBuilder::new()?; + trust.add_cert(self.issuer.clone())?; + trust.set_flags(X509VerifyFlags::PARTIAL_CHAIN)?; + let mut params = openssl::x509::verify::X509VerifyParam::new()?; + params.set_time(now.try_into().map_err(|_| Error)?); + params.set_auth_level(2); + trust.set_param(¶ms)?; + // NO_EXPLICIT prevents unrelated explicitly trusted OCSP roots from + // substituting for the issuer/delegated-responder authorization check. + basic.verify(&candidates, &trust.build(), OcspFlag::NO_EXPLICIT)?; + let id = self.cert_id()?; + let status = basic.find_status(&id).ok_or(Error)?; + let this_update = generalized_unix(status.this_update)?; + let next_update = generalized_unix(status.next_update().ok_or(Error)?)?; + let produced = generalized_unix(produced)?; + let expires = next_update + .min(this_update.saturating_add(MAX_AGE)) + .min(self.valid_until) + .min(unix(ffi::signer_expiry(&basic, &candidates)?)?); + if this_update > now.saturating_add(SKEW) + || now.saturating_sub(this_update) > MAX_AGE + || produced > now.saturating_add(SKEW) + || produced.saturating_add(SKEW) < this_update + || next_update <= this_update + || expires <= now + { + return Err(Error); + } + if status.status == OcspCertStatus::REVOKED { + return Ok(Verdict::Revoked); + } + if status.status == OcspCertStatus::UNKNOWN { + return Ok(Verdict::Unknown); + } + if status.status != OcspCertStatus::GOOD { + return Err(Error); + } + Ok(Verdict::Good(Staple { + der: der.to_vec(), + expires, + deadline: mono + .checked_add(Duration::from_secs(expires - now)) + .ok_or(Error)?, + })) + } +} + +fn unix(time: &Asn1TimeRef) -> Result { + let epoch = Asn1Time::from_unix(0)?; + let diff = epoch.diff(time)?; + let seconds = i64::from(diff.days) * 86400 + i64::from(diff.secs); + seconds.try_into().map_err(|_| Error) +} +fn generalized_unix(time: &Asn1GeneralizedTimeRef) -> Result { + // SAFETY: OpenSSL typedefs ASN1_TIME and ASN1_GENERALIZEDTIME to ASN1_STRING; + // the borrowed pointer stays owned by its response for this conversion. + unix(unsafe { Asn1TimeRef::from_ptr(time.as_ptr().cast()) }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/hj-ocsp/src/refresh.rs b/crates/hj-ocsp/src/refresh.rs new file mode 100644 index 0000000..2235f17 --- /dev/null +++ b/crates/hj-ocsp/src/refresh.rs @@ -0,0 +1,214 @@ +//! Per-certificate lifecycle; network operations never run on a handshake. +use crate::{Endpoint, Error, Identity, Staple, Verdict}; +use parking_lot::Mutex; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; + +pub enum Decision { + Staple(Arc), + Omit, + Reject, +} +struct State { + staple: Option>, + revoked: bool, + in_flight: bool, + failures: u8, + next_attempt: Instant, +} + +/// One immutable certificate identity. Certificate replacement needs a new +/// slot; hostname-only or public-key-only reuse is not a valid identity match. +pub struct Slot { + identity: Arc, + endpoint: Endpoint, + required: bool, + jitter: u64, + state: Mutex, +} +impl Slot { + pub fn new( + leaf: &[u8], + issuer: &[u8], + endpoint: Endpoint, + required: bool, + ) -> Result, Error> { + let identity = Identity::new(leaf, issuer)?; + let required = required || identity.must_staple; + let digest = openssl::sha::sha256(leaf); + let jitter = u64::from_be_bytes(digest[..8].try_into().map_err(|_| Error)?) % 31; + Ok(Arc::new(Self { + identity: Arc::new(identity), + endpoint, + required, + jitter, + state: Mutex::new(State { + staple: None, + revoked: false, + in_flight: false, + failures: 0, + next_attempt: Instant::now(), + }), + })) + } + pub fn decision(&self) -> Decision { + let state = self.state.lock(); + if state.revoked { + return Decision::Reject; + } + match state.staple.as_ref().filter(|s| s.bytes().is_some()) { + Some(staple) => Decision::Staple(staple.clone()), + None if self.required => Decision::Reject, + None => Decision::Omit, + } + } + pub fn due(&self) -> bool { + self.due_at(Instant::now()) + } + fn due_at(&self, now: Instant) -> bool { + let state = self.state.lock(); + !state.revoked && !state.in_flight && now >= state.next_attempt + } + fn begin(self: &Arc, now: Instant) -> Option { + let mut state = self.state.lock(); + if state.revoked || state.in_flight || now < state.next_attempt { + return None; + } + state.in_flight = true; + // Schedule BEFORE I/O, including ambiguous/cancelled fetch outcomes. + let seconds = (30_u64 << state.failures.min(7)).min(3600) + self.jitter; + state.failures = state.failures.saturating_add(1); + state.next_attempt = now + Duration::from_secs(seconds); + Some(Lease(self.clone())) + } + fn apply(&self, result: Result, now: Instant) { + let mut state = self.state.lock(); + if state.revoked { + return; + } + match result { + Ok(Verdict::Good(staple)) if staple.bytes().is_some() => { + let remaining = staple.deadline.saturating_duration_since(now).as_secs(); + let wait = (remaining * 2 / 3) + .saturating_sub(self.jitter) + .clamp(1, 3600); + state.staple = Some(Arc::new(staple)); + state.failures = 0; + state.next_attempt = now + Duration::from_secs(wait); + } + Ok(Verdict::Revoked) => { + state.revoked = true; + state.staple = None; + } + // UNKNOWN, bad signatures, HTTP failures and expired candidates do + // not replace a still-fresh response or extend its deadline. + _ => {} + } + } +} +struct Lease(Arc); +impl Drop for Lease { + fn drop(&mut self) { + self.0.state.lock().in_flight = false; + } +} + +/// Share one pool across all active slots: at most four network/validation jobs. +pub struct RefreshPool { + permits: Arc, +} +impl Default for RefreshPool { + fn default() -> Self { + Self { + permits: Arc::new(tokio::sync::Semaphore::new(4)), + } + } +} +impl RefreshPool { + /// Returns false when not due or capacity is occupied. No queued waiters. + pub async fn refresh(&self, slot: &Arc) -> bool { + let Ok(permit) = self.permits.clone().try_acquire_owned() else { + return false; + }; + let Some(_lease) = slot.begin(Instant::now()) else { + return false; + }; + let result = match slot.identity.request() { + Ok(request) => match slot.endpoint.fetch(&request).await { + Ok(bytes) => { + let identity = slot.identity.clone(); + // Retain the permit inside a non-cancellable blocking job; + // dropping this future cannot start a fifth verifier. + tokio::task::spawn_blocking(move || { + let _permit = permit; + identity.validate(&bytes) + }) + .await + .unwrap_or(Err(Error)) + } + Err(error) => Err(error), + }, + Err(error) => Err(error), + }; + slot.apply(result, Instant::now()); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + fn slot(required: bool) -> Arc { + let fixture = crate::tests::fixture(); + Slot::new( + &fixture.leaf.cert.to_der().unwrap(), + &fixture.ca.cert.to_der().unwrap(), + Endpoint::new("http://127.0.0.1:12345/", true).unwrap(), + required, + ) + .unwrap() + } + fn staple(seconds: u64) -> Staple { + Staple { + der: b"synthetic lifecycle state, not a validation fixture".to_vec(), + expires: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + seconds, + deadline: Instant::now() + Duration::from_secs(seconds), + } + } + #[test] + fn optional_required_expiry_and_verified_revocation_are_distinct() { + let optional = slot(false); + let required = slot(true); + assert!(matches!(optional.decision(), Decision::Omit)); + assert!(matches!(required.decision(), Decision::Reject)); + required.apply(Ok(Verdict::Good(staple(60))), Instant::now()); + assert!(matches!(required.decision(), Decision::Staple(_))); + required.apply(Err(Error), Instant::now()); + required.apply(Ok(Verdict::Unknown), Instant::now()); + assert!(matches!(required.decision(), Decision::Staple(_))); + required.apply(Ok(Verdict::Revoked), Instant::now()); + required.apply(Ok(Verdict::Good(staple(60))), Instant::now()); + assert!(matches!(required.decision(), Decision::Reject)); + assert!(!required.due_at(Instant::now() + Duration::from_secs(10000))); + let expired = slot(true); + expired.state.lock().staple = Some(Arc::new(staple(0))); + assert!(matches!(expired.decision(), Decision::Reject)); + } + #[test] + fn cancellation_releases_slot_but_retains_backoff() { + let slot = slot(false); + let now = Instant::now(); + let lease = slot.begin(now).unwrap(); + assert!(slot.begin(now).is_none()); + drop(lease); + assert!(!slot.due_at(now)); + assert!(slot.due_at(now + Duration::from_secs(61))); + } +} diff --git a/crates/hj-ocsp/src/tests.rs b/crates/hj-ocsp/src/tests.rs new file mode 100644 index 0000000..af64257 --- /dev/null +++ b/crates/hj-ocsp/src/tests.rs @@ -0,0 +1,482 @@ +use super::*; +use foreign_types::ForeignType; +use openssl::{ + asn1::{Asn1Integer, Asn1Object, Asn1OctetString}, + bn::BigNum, + ec::{EcGroup, EcKey}, + ocsp::OcspBasicResponse, + pkey::{PKey, Private}, + rsa::Rsa, + x509::{ + X509Extension, X509NameBuilder, + extension::{BasicConstraints, ExtendedKeyUsage, KeyUsage}, + }, +}; +use openssl_sys as sys; +use std::{ + ffi::{c_int, c_long, c_ulong, c_void}, + ptr, + sync::OnceLock, +}; + +unsafe extern "C" { + fn OCSP_resp_get0_produced_at( + r: *const sys::OCSP_BASICRESP, + ) -> *const sys::ASN1_GENERALIZEDTIME; + fn ASN1_GENERALIZEDTIME_set( + s: *mut sys::ASN1_GENERALIZEDTIME, + time: c_long, + ) -> *mut sys::ASN1_GENERALIZEDTIME; + fn OCSP_resp_get0_signature(r: *const sys::OCSP_BASICRESP) -> *const sys::ASN1_BIT_STRING; + fn OCSP_basic_add1_status( + r: *mut sys::OCSP_BASICRESP, + id: *mut sys::OCSP_CERTID, + status: c_int, + reason: c_int, + revoked: *mut sys::ASN1_TIME, + this_update: *mut sys::ASN1_TIME, + next_update: *mut sys::ASN1_TIME, + ) -> *mut c_void; + fn OCSP_basic_sign( + r: *mut sys::OCSP_BASICRESP, + signer: *mut sys::X509, + key: *mut sys::EVP_PKEY, + md: *const sys::EVP_MD, + certs: *mut sys::stack_st_X509, + flags: c_ulong, + ) -> c_int; + fn OCSP_BASICRESP_add_ext( + r: *mut sys::OCSP_BASICRESP, + ext: *mut sys::X509_EXTENSION, + loc: c_int, + ) -> c_int; + fn OCSP_SINGLERESP_add_ext(r: *mut c_void, ext: *mut sys::X509_EXTENSION, loc: c_int) -> c_int; +} + +pub(crate) struct Cert { + pub(crate) cert: X509, + key: PKey, +} +fn cert(serial: u32, issuer: Option<&Cert>, role: &str) -> Cert { + let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap(); + let key = if serial >= 100 { + PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap() + } else { + PKey::from_ec_key(EcKey::generate(&group).unwrap()).unwrap() + }; + let mut name = X509NameBuilder::new().unwrap(); + name.append_entry_by_text("CN", &format!("fixture-{role}-{serial}")) + .unwrap(); + let name = name.build(); + let mut builder = X509::builder().unwrap(); + builder.set_version(2).unwrap(); + let number = Asn1Integer::from_bn(&BigNum::from_u32(serial).unwrap()).unwrap(); + builder.set_serial_number(&number).unwrap(); + builder.set_subject_name(&name).unwrap(); + builder + .set_issuer_name(issuer.map_or(&name, |ca| ca.cert.subject_name())) + .unwrap(); + builder.set_pubkey(&key).unwrap(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let start = Asn1Time::from_unix(now - 86400 * 30).unwrap(); + let end = + Asn1Time::from_unix(now + if role == "responder" { 600 } else { 86400 * 30 }).unwrap(); + builder.set_not_before(&start).unwrap(); + builder.set_not_after(&end).unwrap(); + if role == "ca" { + builder + .append_extension(BasicConstraints::new().critical().ca().build().unwrap()) + .unwrap(); + builder + .append_extension( + KeyUsage::new() + .critical() + .key_cert_sign() + .crl_sign() + .digital_signature() + .build() + .unwrap(), + ) + .unwrap(); + } else { + builder + .append_extension(BasicConstraints::new().critical().build().unwrap()) + .unwrap(); + builder + .append_extension( + KeyUsage::new() + .critical() + .digital_signature() + .build() + .unwrap(), + ) + .unwrap(); + let mut eku = ExtendedKeyUsage::new(); + if role == "responder" { + eku.other("1.3.6.1.5.5.7.3.9"); + } else { + eku.server_auth(); + } + builder.append_extension(eku.build().unwrap()).unwrap(); + if role == "must-staple" || role == "unsupported-feature" { + let oid = Asn1Object::from_str("1.3.6.1.5.5.7.1.24").unwrap(); + let feature = if role == "must-staple" { 5 } else { 17 }; + let bytes = Asn1OctetString::new_from_bytes(&[0x30, 3, 2, 1, feature]).unwrap(); + builder + .append_extension(X509Extension::new_from_der(&oid, false, &bytes).unwrap()) + .unwrap(); + } + } + builder + .sign(issuer.map_or(&key, |ca| &ca.key), MessageDigest::sha256()) + .unwrap(); + Cert { + cert: builder.build(), + key, + } +} +pub(crate) struct Fixture { + pub(crate) ca: Cert, + pub(crate) leaf: Cert, + responder: Cert, + unauthorized: Cert, + foreign: Cert, +} +pub(crate) fn fixture() -> &'static Fixture { + static FIXTURE: OnceLock = OnceLock::new(); + FIXTURE.get_or_init(|| { + let ca = cert(1, None, "ca"); + let leaf = cert(2, Some(&ca), "leaf"); + let responder = cert(3, Some(&ca), "responder"); + let unauthorized = cert(4, Some(&ca), "leaf"); + let foreign = cert(5, None, "ca"); + Fixture { + ca, + leaf, + responder, + unauthorized, + foreign, + } + }) +} +fn identity() -> Identity { + let f = fixture(); + Identity::new(&f.leaf.cert.to_der().unwrap(), &f.ca.cert.to_der().unwrap()).unwrap() +} + +struct ResponseSpec { + status: OcspCertStatus, + this: i64, + next: Option, + duplicate: bool, + critical: u8, + sha1: bool, + produced: Option, +} +impl Default for ResponseSpec { + fn default() -> Self { + Self { + status: OcspCertStatus::GOOD, + this: -60, + next: Some(3600), + duplicate: false, + critical: 0, + sha1: false, + produced: None, + } + } +} +fn response(signer: &Cert, subject: &Cert, spec: ResponseSpec) -> Vec { + response_for(signer, subject, &fixture().ca, spec) +} +fn response_for(signer: &Cert, subject: &Cert, issuer: &Cert, spec: ResponseSpec) -> Vec { + let id = OcspCertId::from_cert(MessageDigest::sha256(), &subject.cert, &issuer.cert).unwrap(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let this = Asn1Time::from_unix(now + spec.this).unwrap(); + let next = spec.next.map(|v| Asn1Time::from_unix(now + v).unwrap()); + let revoked = Asn1Time::from_unix(now - 100).unwrap(); + // SAFETY: all fixture objects remain alive while OpenSSL copies their data. + // New BasicResponse ownership transfers immediately to its RAII wrapper. + unsafe { + let raw = sys::OCSP_BASICRESP_new(); + assert!(!raw.is_null()); + let basic = OcspBasicResponse::from_ptr(raw); + for _ in 0..(if spec.duplicate { 2 } else { 1 }) { + let single = OCSP_basic_add1_status( + raw, + id.as_ptr(), + spec.status.as_raw(), + 0, + if spec.status == OcspCertStatus::REVOKED { + revoked.as_ptr() + } else { + ptr::null_mut() + }, + this.as_ptr(), + next.as_ref().map_or(ptr::null_mut(), |t| t.as_ptr()), + ); + assert!(!single.is_null()); + if spec.critical != 0 { + let oid = Asn1Object::from_str("1.2.3.4").unwrap(); + let bytes = Asn1OctetString::new_from_bytes(&[5, 0]).unwrap(); + let extension = X509Extension::new_from_der(&oid, true, &bytes).unwrap(); + let result = if spec.critical == 1 { + OCSP_BASICRESP_add_ext(raw, extension.as_ptr(), -1) + } else { + OCSP_SINGLERESP_add_ext(single, extension.as_ptr(), -1) + }; + assert_eq!(result, 1); + } + } + let digest = if spec.sha1 { + MessageDigest::sha1() + } else { + MessageDigest::sha256() + }; + assert_eq!( + OCSP_basic_sign( + raw, + signer.cert.as_ptr(), + signer.key.as_ptr(), + digest.as_ptr(), + ptr::null_mut(), + 0 + ), + 1 + ); + if let Some(offset) = spec.produced { + let time = OCSP_resp_get0_produced_at(raw).cast_mut(); + assert!(!time.is_null()); + assert!(!ASN1_GENERALIZEDTIME_set(time, now + offset).is_null()); + assert_eq!( + OCSP_basic_sign( + raw, + signer.cert.as_ptr(), + signer.key.as_ptr(), + digest.as_ptr(), + ptr::null_mut(), + OcspFlag::NO_TIME.bits() + ), + 1 + ); + } + OcspResponse::create(OcspResponseStatus::SUCCESSFUL, Some(&basic)) + .unwrap() + .to_der() + .unwrap() + } +} + +#[test] +fn accepts_issuer_and_directly_authorized_responder() { + let f = fixture(); + let identity = identity(); + assert!(!identity.request().unwrap().is_empty()); + for signer in [&f.ca, &f.responder] { + let der = response(signer, &f.leaf, ResponseSpec::default()); + let Verdict::Good(staple) = identity.validate(&der).unwrap() else { + panic!("not good") + }; + assert_eq!(staple.bytes().unwrap(), der); + assert!(staple.expires_unix() <= unix(signer.cert.not_after()).unwrap()); + } +} + +#[test] +fn must_staple_forces_required_policy_and_other_features_fail_closed() { + let ca = &fixture().ca; + let leaf = cert(20, Some(ca), "must-staple"); + let endpoint = Endpoint::new("http://127.0.0.1:12345/status", true).unwrap(); + let slot = Slot::new( + &leaf.cert.to_der().unwrap(), + &ca.cert.to_der().unwrap(), + endpoint, + false, + ) + .unwrap(); + assert!(matches!(slot.decision(), Decision::Reject)); + let unsupported = cert(21, Some(ca), "unsupported-feature"); + assert!( + Identity::new( + &unsupported.cert.to_der().unwrap(), + &ca.cert.to_der().unwrap() + ) + .is_err() + ); +} +#[test] +fn rejects_wrong_signer_identity_tampering_and_trailing_bytes() { + let f = fixture(); + let id = identity(); + for signer in [&f.unauthorized, &f.foreign] { + assert!( + id.validate(&response(signer, &f.leaf, ResponseSpec::default())) + .is_err() + ); + } + assert!( + id.validate(&response(&f.ca, &f.unauthorized, ResponseSpec::default())) + .is_err() + ); + let good = response(&f.ca, &f.leaf, ResponseSpec::default()); + let mut corrupt = good.clone(); + // Tamper the actual response signature, not an unused embedded issuer copy. + let parsed = OcspResponse::from_der(&good).unwrap(); + let basic = parsed.basic().unwrap(); + // SAFETY: get0 signature is borrowed from the live parsed basic response. + let signature = unsafe { + let pointer = OCSP_resp_get0_signature(basic.as_ptr()); + assert!(!pointer.is_null()); + openssl::asn1::Asn1BitStringRef::from_ptr(pointer.cast_mut()).as_slice() + }; + let index = corrupt + .windows(signature.len()) + .position(|w| w == signature) + .unwrap() + + 5; + corrupt[index] ^= 1; + assert!(id.validate(&corrupt).is_err()); + let mut trailing = good; + trailing.push(0); + assert!(id.validate(&trailing).is_err()); + assert!(id.validate(&vec![0; MAX_RESPONSE + 1]).is_err()); + assert!( + Identity::new( + &f.leaf.cert.to_der().unwrap(), + &f.foreign.cert.to_der().unwrap() + ) + .is_err() + ); +} + +#[test] +fn accepts_rsa_sha256_and_caps_delegated_responder_expiry() { + let ca = cert(100, None, "ca"); + let leaf = cert(101, Some(&ca), "leaf"); + let id = Identity::new(&leaf.cert.to_der().unwrap(), &ca.cert.to_der().unwrap()).unwrap(); + let der = response_for(&ca, &leaf, &ca, ResponseSpec::default()); + assert!(matches!(id.validate(&der).unwrap(), Verdict::Good(_))); + let f = fixture(); + let der = response(&f.responder, &f.leaf, ResponseSpec::default()); + let Verdict::Good(staple) = identity().validate(&der).unwrap() else { + panic!("not good") + }; + assert_eq!( + staple.expires_unix(), + unix(f.responder.cert.not_after()).unwrap() + ); +} +#[test] +fn rejects_unsupported_profiles_and_freshness() { + let f = fixture(); + let id = identity(); + for spec in [ + ResponseSpec { + this: 600, + ..Default::default() + }, + ResponseSpec { + this: -(MAX_AGE as i64) - 1, + ..Default::default() + }, + ResponseSpec { + next: None, + ..Default::default() + }, + ResponseSpec { + next: Some(-1), + ..Default::default() + }, + ResponseSpec { + this: 60, + next: Some(30), + ..Default::default() + }, + ResponseSpec { + duplicate: true, + ..Default::default() + }, + ResponseSpec { + critical: 1, + ..Default::default() + }, + ResponseSpec { + critical: 2, + ..Default::default() + }, + ResponseSpec { + sha1: true, + ..Default::default() + }, + ResponseSpec { + produced: Some(600), + ..Default::default() + }, + ResponseSpec { + produced: Some(-600), + ..Default::default() + }, + ] { + assert!(id.validate(&response(&f.ca, &f.leaf, spec)).is_err()); + } + let unavailable = OcspResponse::create(OcspResponseStatus::TRY_LATER, None) + .unwrap() + .to_der() + .unwrap(); + assert!(id.validate(&unavailable).is_err()); +} +#[test] +fn authenticates_non_good_outcomes_and_enforces_two_clocks() { + let f = fixture(); + let id = identity(); + assert!(matches!( + id.validate(&response( + &f.ca, + &f.leaf, + ResponseSpec { + status: OcspCertStatus::REVOKED, + ..Default::default() + } + )) + .unwrap(), + Verdict::Revoked + )); + assert!(matches!( + id.validate(&response( + &f.ca, + &f.leaf, + ResponseSpec { + status: OcspCertStatus::UNKNOWN, + ..Default::default() + } + )) + .unwrap(), + Verdict::Unknown + )); + let wall = SystemTime::now(); + let mono = Instant::now(); + let der = response(&f.ca, &f.leaf, ResponseSpec::default()); + let Verdict::Good(staple) = id.validate_at(&der, wall, mono).unwrap() else { + panic!("not good") + }; + assert!(staple.bytes_at(wall, mono).is_some()); + assert!( + staple + .bytes_at(wall + Duration::from_secs(7200), mono) + .is_none() + ); + assert!( + staple + .bytes_at( + wall - Duration::from_secs(7200), + mono + Duration::from_secs(7200) + ) + .is_none() + ); +} diff --git a/crates/hj-proxy/src/lib.rs b/crates/hj-proxy/src/lib.rs index de2e68f..c53bede 100644 --- a/crates/hj-proxy/src/lib.rs +++ b/crates/hj-proxy/src/lib.rs @@ -56,7 +56,7 @@ use tokio::net::TcpStream; use tokio::sync::oneshot; pub use headers::is_websocket_upgrade; -pub use pool::{Upstream, UpstreamPool}; +pub use pool::{PeerSnapshot, RequestReservation, Upstream, UpstreamPool}; pub use target::{ProxyTarget, TargetParseError}; use crate::error::ProxyError; @@ -158,9 +158,10 @@ impl Proxy { /// generation until its in-flight requests drain; ad-hoc unnamed targets stay /// warm because they are not represented in the parsed ext-processor list. pub fn next_generation(&self, named_targets: impl IntoIterator) -> Self { - Proxy { + let targets: Vec<_> = named_targets.into_iter().collect(); + let next = Proxy { pool: self.pool.retained_generation( - named_targets, + targets.clone(), self.default_max_conns, self.default_keep_alive, self.default_connect_timeout, @@ -168,7 +169,23 @@ impl Proxy { default_max_conns: self.default_max_conns, default_keep_alive: self.default_keep_alive, default_connect_timeout: self.default_connect_timeout, - } + }; + next.prepare_groups(targets); + next + } + + pub fn with_targets(targets: impl IntoIterator) -> Self { + let proxy = Self::new(); + proxy.prepare_groups(targets); + proxy + } + fn prepare_groups(&self, targets: impl IntoIterator) { + self.pool.prepare_groups( + targets, + self.default_max_conns, + self.default_keep_alive, + self.default_connect_timeout, + ); } /// Forward an ordinary HTTP request to `target` and stream the response back. @@ -200,14 +217,14 @@ impl Proxy { target: &ProxyTarget, timeout_override: Option, ) -> Result { - let upstream = self.pool.get_or_create( + let (upstream, mut reservation) = self.pool.select( target, target.max_conns.unwrap_or(self.default_max_conns), target.keep_alive.unwrap_or(self.default_keep_alive), target .connect_timeout .unwrap_or(self.default_connect_timeout), - ); + )?; // One response-head duration applies to every forward shape. In particular, // a context override must govern bodyless requests and their transparent retry // as well as the post-upload wait for body-bearing requests. @@ -360,6 +377,9 @@ impl Proxy { } else { (None, None) }; + if let Some(r) = &mut reservation { + r.response_received(); + } let downstream_response = into_streaming_response(upstream_resp, response_done_tx); // Return the sender according to its protocol. An h2 sender can open another @@ -387,12 +407,14 @@ impl Proxy { upstream.release(sender); tokio::spawn(async move { let _permit = permit; + let _reservation = reservation; let _ = response_done.await; }); } else { let up = upstream.clone(); tokio::spawn(async move { let _permit = permit; + let _reservation = reservation; // (#70) Hold the h1 maxConns permit until the upstream connection is // ACTUALLY free. `sender.ready()` resolves when the in-flight response is // fully consumed, its body is dropped, or the upstream closes. There is no @@ -428,6 +450,26 @@ impl Proxy { req: Request, target: &ProxyTarget, ) -> Result { + let (selected, mut reservation) = self.pool.select( + target, + target.max_conns.unwrap_or(self.default_max_conns), + target.keep_alive.unwrap_or(self.default_keep_alive), + target + .connect_timeout + .unwrap_or(self.default_connect_timeout), + )?; + let mut selected_target = target.clone(); + if let Some(r) = &mut reservation { + r.permit = Some( + selected + .acquire() + .await + .ok_or(HandlerError::ServiceUnavailable)?, + ); + selected_target.authority = selected.authority.clone(); + selected_target.transport = selected.transport().clone(); + } + let target = &selected_target; let hostport = match &target.transport { TargetTransport::Tcp(hp) => hp.clone(), TargetTransport::Uds(_) => { @@ -436,19 +478,30 @@ impl Proxy { )); } }; - if target.is_tls() { + if target.is_tls() || target.http2 { return Err(HandlerError::BadGateway( - "secure websocket upstreams are not supported by the raw websocket relay".into(), + "secure websocket and HTTP/2 upstreams are not supported by the raw websocket relay".into(), )); } let connect_timeout = target .connect_timeout .unwrap_or(self.default_connect_timeout); - let stream = tokio::time::timeout(connect_timeout, TcpStream::connect(&hostport)) - .await - .map_err(|_| HandlerError::GatewayTimeout)? - .map_err(|e| HandlerError::BadGateway(format!("ws connect: {e}")))?; + let stream = + match tokio::time::timeout(connect_timeout, TcpStream::connect(&hostport)).await { + Ok(Ok(stream)) => { + selected.note_dial_success(); + stream + } + Ok(Err(e)) => { + selected.note_dial_failure(); + return Err(HandlerError::BadGateway(format!("ws connect: {e}"))); + } + Err(_) => { + selected.note_dial_failure(); + return Err(HandlerError::GatewayTimeout); + } + }; let _ = stream.set_nodelay(true); set_tcp_keepalive(&stream); @@ -481,6 +534,9 @@ impl Proxy { }; let status = upstream_resp.status(); + if let Some(r) = &mut reservation { + r.response_received(); + } if status == http::StatusCode::SWITCHING_PROTOCOLS { // Capture the upstream's exact 101 head (incl. Sec-WebSocket-Accept, // Sec-WebSocket-Protocol/-Extensions) BEFORE consuming the response @@ -507,6 +563,7 @@ impl Proxy { .or_insert_with(|| HeaderValue::from_static("upgrade")); return Ok(WebSocketUpgrade { + reservation, response: resp, upstream: UpstreamUpgraded::Hyper(upstream_io), }); @@ -515,8 +572,12 @@ impl Proxy { // Non-101: surface the upstream's response (buffered) to the client. drop(driver); let mut resp = into_streaming_response(upstream_resp, None); + if let Body::Stream(body) = std::mem::replace(resp.body_mut(), Body::Empty) { + *resp.body_mut() = Body::Stream(ReservedBody { body, reservation }.boxed()); + } headers::sanitize_response_headers(resp.headers_mut(), false); Ok(WebSocketUpgrade { + reservation: None, response: resp, upstream: UpstreamUpgraded::Rejected(status), }) @@ -585,6 +646,7 @@ impl Proxy { /// Result of [`Proxy::proxy_websocket`]. pub struct WebSocketUpgrade { + pub reservation: Option, /// The response to return to the downstream client (status `101` on /// success, or the upstream's rejection response). pub response: Response, @@ -619,6 +681,30 @@ impl UpstreamUpgraded { } // ---- request/response construction helpers ---- +struct ReservedBody { + body: StreamBody, + reservation: Option, +} +impl HttpBody for ReservedBody { + type Data = Bytes; + type Error = BoxError; + fn poll_frame( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll, BoxError>>> { + let result = std::pin::Pin::new(&mut self.body).poll_frame(cx); + if matches!(result, std::task::Poll::Ready(None | Some(Err(_)))) { + self.reservation.take(); + } + result + } + fn is_end_stream(&self) -> bool { + self.body.is_end_stream() + } + fn size_hint(&self) -> SizeHint { + self.body.size_hint() + } +} /// Compute the upstream request URI: use the target's explicit path-and-query /// when present, otherwise the inbound URI's path-and-query. The authority/host diff --git a/crates/hj-proxy/src/pool.rs b/crates/hj-proxy/src/pool.rs index cd0d42c..e8c1f5a 100644 --- a/crates/hj-proxy/src/pool.rs +++ b/crates/hj-proxy/src/pool.rs @@ -25,6 +25,9 @@ use tokio_rustls::TlsConnector; use crate::error::ProxyError; use crate::target::{ProxyTarget, TargetTransport}; +mod balance; +mod health; +pub use balance::{PeerSnapshot, RequestReservation}; /// Process-shared rustls client config for upstream TLS, built once on first use: the webpki /// (Mozilla) root store, server-auth only, no client certificate. The default crypto provider @@ -213,6 +216,9 @@ pub struct Upstream { } impl Upstream { + pub(crate) fn transport(&self) -> &TargetTransport { + &self.transport + } /// Create an upstream from a [`ProxyTarget`] with the given pool limits. pub(crate) fn new( target: &ProxyTarget, @@ -318,7 +324,7 @@ impl Upstream { /// Record a successful dial: reset the failure count and close the breaker /// (and clear the pool-level failover mark). - fn note_dial_success(&self) { + pub(crate) fn note_dial_success(&self) { self.fail_count.store(0, Ordering::Relaxed); *self.tripped_at.lock() = None; if let Some(b) = self.bad_until.lock().as_ref() { @@ -329,7 +335,7 @@ impl Upstream { /// Record a failed dial: trip the breaker once the threshold is reached. A /// trip also marks the peer bad at the POOL level for one half-open window, /// so new requests fail over to the next peer instead of fast-failing here. - fn note_dial_failure(&self) { + pub(crate) fn note_dial_failure(&self) { let n = self.fail_count.fetch_add(1, Ordering::Relaxed) + 1; if n >= CB_THRESHOLD { *self.tripped_at.lock() = Some(Instant::now()); @@ -581,6 +587,9 @@ impl Upstream { /// rewrite targets. #[derive(Default)] pub struct UpstreamPool { + health_tasks: Mutex>, + published_groups: Arc>>>>, + groups: Mutex>>, pools: Mutex>>, /// (Tier 1.2) Per-peer failover marks: epoch-ms until which the peer is skipped /// for NEW requests (set when the peer's breaker trips, cleared on a successful @@ -599,6 +608,7 @@ fn now_epoch_ms() -> u64 { #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct PoolKey { + scope: Option, name: Option, scheme: String, authority: String, @@ -618,6 +628,7 @@ impl PoolKey { connect_timeout: Duration, ) -> Self { PoolKey { + scope: target.scope.clone(), name: target.name.clone(), scheme: target.scheme.to_ascii_lowercase(), authority: target.authority.clone(), @@ -642,6 +653,9 @@ impl PoolKey { impl UpstreamPool { pub fn new() -> Self { UpstreamPool { + health_tasks: Mutex::new(Vec::new()), + published_groups: Arc::new(Mutex::new(None)), + groups: Mutex::new(HashMap::new()), pools: Mutex::new(HashMap::new()), bad_until: Mutex::new(HashMap::new()), failovers: AtomicU64::new(0), @@ -730,8 +744,28 @@ impl UpstreamPool { default_keep_alive: Duration, default_connect_timeout: Duration, ) -> Self { + let named_targets: Vec<_> = named_targets.into_iter().collect(); + let retained_groups: HashSet<_> = named_targets + .iter() + .map(|t| { + balance::GroupKey::new( + t, + default_max_conns, + default_keep_alive, + default_connect_timeout, + ) + }) + .collect(); + let groups = self + .groups + .lock() + .iter() + .filter(|(k, _)| retained_groups.contains(*k) && !k.authenticated()) + .map(|(k, g)| (k.clone(), g.clone())) + .collect(); let retained_named: HashSet = named_targets .into_iter() + .flat_map(|target| target.peers()) .filter(|target| target.name.is_some()) .map(|target| { PoolKey::new( @@ -757,6 +791,9 @@ impl UpstreamPool { .collect(); UpstreamPool { pools: Mutex::new(retained), + health_tasks: Mutex::new(Vec::new()), + published_groups: self.published_groups.clone(), + groups: Mutex::new(groups), bad_until: Mutex::new(HashMap::new()), failovers: AtomicU64::new(0), } @@ -768,6 +805,19 @@ mod tests { use super::*; use crate::Proxy; + #[test] + fn equally_named_vhost_processors_have_distinct_pools() { + let mut a = ProxyTarget::parse_url("http://127.0.0.1:29001") + .unwrap() + .in_scope("vhost:a"); + a.name = Some("backend".into()); + let b = a.clone().in_scope("vhost:b"); + let pool = UpstreamPool::new(); + let ua = pool.get_or_create(&a, 10, Duration::from_secs(5), Duration::from_secs(1)); + let ub = pool.get_or_create(&b, 10, Duration::from_secs(5), Duration::from_secs(1)); + assert!(!Arc::ptr_eq(&ua, &ub)); + } + fn ensure_crypto_provider() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); } @@ -780,6 +830,7 @@ mod tests { use hj_core::config::{ExtAddress, ExtKind, ExtProcessor}; let ep = ExtProcessor { + load_balance: Default::default(), name: "lb".into(), kind: ExtKind::Proxy, address: ExtAddress::Tcp("127.0.0.1:29001".parse().unwrap()), diff --git a/crates/hj-proxy/src/pool/balance.rs b/crates/hj-proxy/src/pool/balance.rs new file mode 100644 index 0000000..86bc4f6 --- /dev/null +++ b/crates/hj-proxy/src/pool/balance.rs @@ -0,0 +1,320 @@ +use super::*; +use hj_core::config::{LoadBalanceConfig, LoadBalancePolicy}; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(super) struct GroupKey { + peers: Vec, + config: LoadBalanceConfig, +} +impl GroupKey { + pub(super) fn new(t: &ProxyTarget, max: u32, keep: Duration, connect: Duration) -> Self { + Self { + peers: t + .peers() + .iter() + .map(|p| { + PoolKey::new( + p, + t.max_conns.unwrap_or(max), + t.keep_alive.unwrap_or(keep), + t.connect_timeout.unwrap_or(connect), + ) + }) + .collect(), + config: t.load_balance.clone(), + } + } + pub(super) fn authenticated(&self) -> bool { + self.peers.iter().any(PoolKey::has_configured_tls_identity) + } +} +pub(super) struct Peer { + pub(super) health: Mutex, + pub(super) target: ProxyTarget, + pub(super) upstream: Arc, + weight: u16, + active: AtomicU64, + selected: AtomicU64, + failures: AtomicU64, +} +pub(super) struct Group { + pub(super) peers: Vec>, + pub(super) config: LoadBalanceConfig, + currents: Mutex>, +} +/// A request, not a reusable connection. Move into the response or relay owner. +pub struct RequestReservation { + peer: Arc, + successful: bool, + pub(crate) permit: Option, +} +impl RequestReservation { + pub(crate) fn response_received(&mut self) { + self.successful = true; + } +} +impl Drop for RequestReservation { + fn drop(&mut self) { + self.peer.active.fetch_sub(1, Ordering::Relaxed); + if !self.successful { + self.peer.failures.fetch_add(1, Ordering::Relaxed); + } + } +} +#[derive(Debug)] +pub struct PeerSnapshot { + pub healthy: bool, + pub probes: u64, + pub transitions: u64, + pub scope: String, + pub group: String, + pub peer: usize, + pub active: u64, + pub selections: u64, + pub failures: u64, +} +impl Group { + fn select(&self) -> Option<(Arc, RequestReservation)> { + let mut current = self.currents.lock(); + let active: Vec<_> = self + .peers + .iter() + .map(|p| p.active.load(Ordering::Relaxed)) + .collect(); + let now = now_epoch_ms(); + let mut candidates: Vec = self + .peers + .iter() + .enumerate() + .filter_map(|(i, p)| { + let bad = p + .upstream + .bad_until + .lock() + .as_ref() + .map(|v| v.load(Ordering::Relaxed)) + .unwrap_or(0); + (bad <= now && p.health.lock().healthy).then_some(i) + }) + .collect(); + if self.config.policy == LoadBalancePolicy::WeightedLeastActive { + if let Some(best) = candidates.iter().copied().min_by(|&a, &b| { + (u128::from(active[a]) * u128::from(self.peers[b].weight)) + .cmp(&(u128::from(active[b]) * u128::from(self.peers[a].weight))) + }) { + let min = active[best]; + let weight = self.peers[best].weight; + candidates.retain(|&i| { + u128::from(active[i]) * u128::from(weight) + == u128::from(min) * u128::from(self.peers[i].weight) + }); + } + } + let selected = if self.config.policy == LoadBalancePolicy::PrimaryFirst { + *candidates.first()? + } else { + let mut total = 0i64; + for &i in &candidates { + let w = i64::from(self.peers[i].weight); + current[i] += w; + total += w; + } + let best = candidates + .into_iter() + .max_by_key(|&i| (current[i], std::cmp::Reverse(i)))?; + current[best] -= total; + best + }; + let peer = self.peers[selected].clone(); + peer.active.fetch_add(1, Ordering::Relaxed); + peer.selected.fetch_add(1, Ordering::Relaxed); + Some(( + peer.upstream.clone(), + RequestReservation { + peer, + successful: false, + permit: None, + }, + )) + } +} +impl UpstreamPool { + pub(crate) fn select( + &self, + target: &ProxyTarget, + max: u32, + keep: Duration, + connect: Duration, + ) -> Result<(Arc, Option), hj_core::HandlerError> { + // No group allocation or scheduler on the unchanged default path. + if target.name.is_none() || target.load_balance == LoadBalanceConfig::default() { + return Ok((self.get_or_create(target, max, keep, connect), None)); + } + self.group(target, max, keep, connect) + .select() + .map(|(up, r)| (up, Some(r))) + .ok_or(hj_core::HandlerError::ServiceUnavailable) + } + pub(super) fn group( + &self, + target: &ProxyTarget, + max: u32, + keep: Duration, + connect: Duration, + ) -> Arc { + let key = GroupKey::new(target, max, keep, connect); + self.groups + .lock() + .entry(key) + .or_insert_with(|| { + let peers: Vec<_> = target + .peers() + .into_iter() + .enumerate() + .map(|(i, mut p)| { + p.failover.clear(); + let upstream = self.get_or_create(&p, max, keep, connect); + Arc::new(Peer { + health: Mutex::new(super::health::HealthState::new( + target.load_balance.health_check.is_none(), + )), + target: p, + upstream, + weight: target.load_balance.weights.get(i).copied().unwrap_or(1), + active: AtomicU64::new(0), + selected: AtomicU64::new(0), + failures: AtomicU64::new(0), + }) + }) + .collect(); + Arc::new(Group { + currents: Mutex::new(vec![0; peers.len()]), + peers, + config: target.load_balance.clone(), + }) + }) + .clone() + } + pub fn peer_snapshots(&self) -> Vec { + let published = self.published_groups.lock().clone(); + let groups = published.unwrap_or_else(|| self.groups.lock().values().cloned().collect()); + groups + .iter() + .flat_map(|g| { + g.peers.iter().enumerate().map(|(i, p)| { + let health = p.health.lock(); + PeerSnapshot { + healthy: health.healthy, + probes: health.probes, + transitions: health.transitions, + scope: p.target.scope.clone().unwrap_or_default(), + group: p.target.name.clone().unwrap_or_default(), + peer: i, + active: p.active.load(Ordering::Relaxed), + selections: p.selected.load(Ordering::Relaxed), + failures: p.failures.load(Ordering::Relaxed), + } + }) + }) + .collect() + } +} +#[cfg(test)] +mod tests { + use super::*; + fn target(policy: LoadBalancePolicy) -> ProxyTarget { + let mut t = ProxyTarget::parse_url("http://127.0.0.1:1") + .unwrap() + .in_scope("server"); + t.name = Some("test".into()); + t.failover.push(TargetTransport::Tcp("127.0.0.1:2".into())); + t.load_balance = LoadBalanceConfig { + policy, + weights: vec![2, 1], + ..Default::default() + }; + t + } + fn select(pool: &UpstreamPool, t: &ProxyTarget) -> (Arc, Option) { + pool.select(t, 10, Duration::from_secs(5), Duration::from_secs(1)) + .unwrap() + } + #[test] + fn weighted_round_robin_and_drop_accounting() { + let p = UpstreamPool::new(); + let t = target(LoadBalancePolicy::WeightedRoundRobin); + let mut counts = [0; 2]; + for _ in 0..300 { + let (up, mut guard) = select(&p, &t); + counts[usize::from(up.authority.ends_with(":2"))] += 1; + guard.as_mut().unwrap().response_received(); + } + assert_eq!(counts, [200, 100]); + assert!( + p.peer_snapshots() + .iter() + .all(|s| s.active == 0 && s.failures == 0) + ); + } + #[test] + #[ignore = "release-only isolated selector comparison"] + fn release_default_selection_comparison() { + assert!(!cfg!(debug_assertions), "run with --release"); + let p = UpstreamPool::new(); + let mut t = target(LoadBalancePolicy::PrimaryFirst); + t.load_balance = LoadBalanceConfig::default(); + let mut direct = Vec::new(); + let mut selection = Vec::new(); + for round in 0..10 { + for indirect in [round % 2 == 0, round % 2 != 0] { + let start = std::time::Instant::now(); + for _ in 0..20_000 { + if indirect { + std::hint::black_box(select(&p, &t)); + } else { + std::hint::black_box(p.get_or_create( + &t, + 10, + Duration::from_secs(5), + Duration::from_secs(1), + )); + } + } + let elapsed = start.elapsed().as_nanos(); + if indirect { + selection.push(elapsed); + } else { + direct.push(elapsed); + } + } + } + direct.sort_unstable(); + selection.sort_unstable(); + let ratio = selection[5] as f64 / direct[5] as f64; + eprintln!( + "default selector/direct median ratio: {ratio:.3}; selector {} ns/op", + selection[5] / 20_000 + ); + assert!( + p.groups.lock().is_empty(), + "default must allocate no groups" + ); + // A generous smoke threshold avoids treating host scheduling noise as a benchmark. + assert!(ratio < 2.0, "unexpected default-path selection overhead"); + } + + #[test] + fn least_active_counts_retained_requests_and_isolates_scopes() { + let p = UpstreamPool::new(); + let t = target(LoadBalancePolicy::WeightedLeastActive); + let (a, ga) = select(&p, &t); + let (b, gb) = select(&p, &t); + assert_ne!(a.authority, b.authority); + let (_, gc) = select(&p, &t); + assert_eq!(p.peer_snapshots().iter().map(|s| s.active).sum::(), 3); + let (_, gd) = select(&p, &t.clone().in_scope("vhost:other")); + assert_eq!(p.groups.lock().len(), 2); + drop((ga, gb, gc, gd)); + assert!(p.peer_snapshots().iter().all(|s| s.active == 0)); + } +} diff --git a/crates/hj-proxy/src/pool/health.rs b/crates/hj-proxy/src/pool/health.rs new file mode 100644 index 0000000..81af5df --- /dev/null +++ b/crates/hj-proxy/src/pool/health.rs @@ -0,0 +1,429 @@ +use super::*; +use hj_core::config::{HealthCheckConfig, LoadBalanceConfig}; +use http_body_util::BodyExt; +use tokio::io::{AsyncRead, AsyncWrite}; + +pub(super) struct HealthState { + pub(super) healthy: bool, + pub(super) probes: u64, + pub(super) transitions: u64, + successes: u32, + failures: u32, +} +impl HealthState { + pub(super) fn new(healthy: bool) -> Self { + Self { + healthy, + probes: 0, + transitions: 0, + successes: 0, + failures: 0, + } + } + fn record(&mut self, ok: bool, config: &HealthCheckConfig) { + self.probes += 1; + let before = self.healthy; + if ok { + self.failures = 0; + self.successes = self.successes.saturating_add(1); + if self.successes >= config.rise { + self.healthy = true; + } + } else { + self.successes = 0; + self.failures = self.failures.saturating_add(1); + if self.failures >= config.fall { + self.healthy = false; + } + } + if before != self.healthy { + self.transitions += 1; + } + } +} +trait ProbeIo: AsyncRead + AsyncWrite + Unpin + Send {} +impl ProbeIo for T {} +struct Driver(tokio::task::AbortHandle); +impl Drop for Driver { + fn drop(&mut self) { + self.0.abort(); + } +} + +async fn probe(up: &Upstream, config: &HealthCheckConfig) -> bool { + tokio::time::timeout(config.timeout, async { + let io: Box = match &up.transport { + TargetTransport::Tcp(address) => { + let stream = TcpStream::connect(address).await.ok()?; + if config.mode == "connect" { + return Some(true); + } + if let Some(tls) = &up.tls_config { + let tls = tls.as_ref().ok()?.clone(); + let server_name = up.tls_server_name.clone()?; + let stream = TlsConnector::from(tls) + .connect(server_name, stream) + .await + .ok()?; + if up.requires_h2 + && require_h2_alpn(&up.authority, stream.get_ref().1.alpn_protocol()) + .is_err() + { + return None; + } + Box::new(stream) + } else { + Box::new(stream) + } + } + TargetTransport::Uds(path) => { + let stream = tokio::net::UnixStream::connect(path).await.ok()?; + if config.mode == "connect" { + return Some(true); + } + Box::new(stream) + } + }; + let io = hyper_util::rt::TokioIo::new(io); + let (mut sender, _driver) = if up.requires_h2 { + let (s, c) = + hyper::client::conn::http2::Builder::new(hyper_util::rt::TokioExecutor::new()) + .max_header_list_size(16 * 1024) + .handshake::<_, OutBody>(io) + .await + .ok()?; + let task = tokio::spawn(c); + (AnySender::H2(s), Driver(task.abort_handle())) + } else { + let (s, c) = hyper::client::conn::http1::Builder::new() + .max_buf_size(16 * 1024) + .handshake::<_, OutBody>(io) + .await + .ok()?; + let task = tokio::spawn(c); + (AnySender::H1(s), Driver(task.abort_handle())) + }; + sender.ready().await.ok()?; + let host = config.host.as_deref().unwrap_or(&up.authority); + let uri = if up.requires_h2 { + format!( + "{}://{host}{}", + if up.tls_config.is_some() { + "https" + } else { + "http" + }, + config.path + ) + } else { + config.path.clone() + }; + let request = http::Request::builder() + .method(config.mode.as_str()) + .uri(uri) + .header(http::header::HOST, host) + .body( + http_body_util::Empty::::new() + .map_err(|e| match e {}) + .boxed(), + ) + .ok()?; + let response = sender.send_request(request).await.ok()?; + Some(response.status().as_u16() == config.expected_status) + // The response and driver are dropped here; probe bodies are never drained. + }) + .await + .ok() + .flatten() + .unwrap_or(false) +} + +impl UpstreamPool { + pub(crate) fn prepare_groups( + &self, + targets: impl IntoIterator, + max: u32, + keep: Duration, + connect: Duration, + ) { + for target in targets { + if target.name.is_some() && target.load_balance != LoadBalanceConfig::default() { + self.group(&target, max, keep, connect); + } + } + } + pub fn stop_health_checks(&self) { + for task in self.health_tasks.lock().drain(..) { + task.abort(); + } + } + /// Call only after the candidate configuration has been published. + pub fn activate_health_checks(&self) { + self.stop_health_checks(); + let groups: Vec<_> = self.groups.lock().values().cloned().collect(); + *self.published_groups.lock() = Some(groups.clone()); + static LIMIT: OnceLock> = OnceLock::new(); + let limit = LIMIT.get_or_init(|| Arc::new(Semaphore::new(16))).clone(); + for group in groups { + let Some(config) = group.config.health_check.clone() else { + continue; + }; + for peer in &group.peers { + let peer = peer.clone(); + let config = config.clone(); + let limit = limit.clone(); + let task = tokio::spawn(async move { + let mut timer = tokio::time::interval(config.interval); + timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + timer.tick().await; + let Ok(permit) = limit.acquire().await else { + break; + }; + let ok = probe(&peer.upstream, &config).await; + drop(permit); + let mut state = peer.health.lock(); + state.record(ok, &config); + if ok && state.healthy { + peer.upstream.note_dial_success(); + } + } + }); + self.health_tasks.lock().push(task.abort_handle()); + } + } + } +} +impl Drop for UpstreamPool { + fn drop(&mut self) { + self.stop_health_checks(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn config() -> HealthCheckConfig { + HealthCheckConfig { + mode: "connect".into(), + interval: Duration::from_secs(10), + timeout: Duration::from_secs(2), + rise: 2, + fall: 3, + path: "/".into(), + host: None, + expected_status: 200, + } + } + #[test] + fn thresholds_and_reset() { + let mut h = HealthState::new(false); + let c = config(); + h.record(true, &c); + assert!(!h.healthy); + h.record(false, &c); + h.record(true, &c); + assert!(!h.healthy); + h.record(true, &c); + assert!(h.healthy); + for _ in 0..2 { + h.record(false, &c); + assert!(h.healthy); + } + h.record(false, &c); + assert!(!h.healthy); + assert_eq!(h.transitions, 2); + } + + #[tokio::test] + async fn http_probe_timeout_and_header_limit() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + for oversized in [false, true] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let target = + ProxyTarget::parse_url(&format!("http://{}", listener.local_addr().unwrap())) + .unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut buf = [0; 2048]; + let _ = socket.read(&mut buf).await; + if oversized { + let header = format!( + "HTTP/1.1 200 OK\r\nX-Large: {}\r\nContent-Length: 0\r\n\r\n", + "a".repeat(32768) + ); + let _ = socket.write_all(header.as_bytes()).await; + } + let _ = socket.read(&mut buf).await; + }); + let up = Upstream::new(&target, 1, Duration::from_secs(5), Duration::from_secs(1)); + let mut c = config(); + c.mode = "HEAD".into(); + c.timeout = Duration::from_millis(100); + assert!(!probe(&up, &c).await); + tokio::time::timeout(Duration::from_secs(2), server) + .await + .unwrap() + .unwrap(); + } + } + + #[tokio::test] + async fn tls_health_requires_trust_and_h2_alpn() { + use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + for (trusted, h2_alpn, expected) in [ + (true, true, true), + (false, true, false), + (true, false, false), + ] { + let generated = + rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap(); + let cert = generated.cert.der().clone(); + let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from( + generated.signing_key.serialize_der(), + )); + let mut tls = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert.clone()], key) + .unwrap(); + if h2_alpn { + tls.alpn_protocols = vec![b"h2".to_vec()]; + } + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let target = + ProxyTarget::parse_url(&format!("h2s://{}", listener.local_addr().unwrap())) + .unwrap(); + let server = tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + if let Ok(tls) = tokio_rustls::TlsAcceptor::from(Arc::new(tls)) + .accept(tcp) + .await + { + let service = hyper::service::service_fn(|_| async { + Ok::<_, std::convert::Infallible>(http::Response::new( + http_body_util::Empty::::new(), + )) + }); + let _ = hyper::server::conn::http2::Builder::new( + hyper_util::rt::TokioExecutor::new(), + ) + .serve_connection(hyper_util::rt::TokioIo::new(tls), service) + .await; + } + }); + let mut roots = rustls::RootCertStore::empty(); + if trusted { + roots.add(cert).unwrap(); + } + let client = with_upstream_alpn( + rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(), + true, + ); + let mut up = Upstream::new(&target, 1, Duration::from_secs(5), Duration::from_secs(1)); + Arc::get_mut(&mut up).unwrap().tls_config = Some(Ok(Arc::new(client))); + Arc::get_mut(&mut up).unwrap().tls_server_name = + Some(ServerName::try_from("localhost").unwrap()); + let mut c = config(); + c.mode = "GET".into(); + assert_eq!(probe(&up, &c).await, expected); + tokio::time::timeout(Duration::from_secs(2), server) + .await + .unwrap() + .unwrap(); + } + } + + #[tokio::test] + async fn http_probe_does_not_follow_redirect_or_drain_body() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let target = + ProxyTarget::parse_url(&format!("http://{}", listener.local_addr().unwrap())).unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + loop { + let byte = socket.read_u8().await.unwrap(); + request.push(byte); + if request.ends_with(b"\r\n\r\n") { + break; + } + assert!(request.len() < 16384); + } + let request = String::from_utf8(request).unwrap().to_lowercase(); + assert!(request.starts_with("get /health?ready=1 http/1.1")); + assert!(request.contains("host: probe.test")); + assert!(!request.contains("cookie:")); + assert!(!request.contains("authorization:")); + socket.write_all(b"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:1/\r\nContent-Length: 999999999\r\n\r\n").await.unwrap(); + let mut buf = [0; 16]; + let _ = socket.read(&mut buf).await; + }); + let up = Upstream::new(&target, 1, Duration::from_secs(5), Duration::from_secs(1)); + let mut c = config(); + c.mode = "GET".into(); + c.path = "/health?ready=1".into(); + c.host = Some("probe.test".into()); + assert!(!probe(&up, &c).await); + tokio::time::timeout(Duration::from_secs(2), server) + .await + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn rejected_candidate_and_published_metrics_generation() { + let mut t = ProxyTarget::parse_url("http://127.0.0.1:1") + .unwrap() + .in_scope("server"); + t.name = Some("test".into()); + t.load_balance.policy = hj_core::config::LoadBalancePolicy::WeightedRoundRobin; + let old = crate::Proxy::with_targets([t.clone()]); + old.pool().activate_health_checks(); + assert_eq!(old.pool().peer_snapshots().len(), 1); + let candidate = old.next_generation([]); + assert_eq!(old.pool().peer_snapshots().len(), 1); + assert!(candidate.pool().health_tasks.lock().is_empty()); + candidate.pool().activate_health_checks(); + assert!(old.pool().peer_snapshots().is_empty()); + } + #[tokio::test] + async fn no_probes_before_activation_and_retirement_stops_them() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut t = + ProxyTarget::parse_url(&format!("http://{}", listener.local_addr().unwrap())).unwrap(); + t.name = Some("health".into()); + let mut c = config(); + c.rise = 1; + t.load_balance.health_check = Some(c); + let p = UpstreamPool::new(); + p.prepare_groups( + [t.clone()], + 10, + Duration::from_secs(5), + Duration::from_secs(1), + ); + assert!( + p.select(&t, 10, Duration::from_secs(5), Duration::from_secs(1)) + .is_err() + ); + assert!(p.health_tasks.lock().is_empty()); + p.activate_health_checks(); + tokio::time::timeout(Duration::from_secs(2), listener.accept()) + .await + .unwrap() + .unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + while !p.peer_snapshots()[0].healthy { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + p.stop_health_checks(); + assert!(p.health_tasks.lock().is_empty()); + } +} diff --git a/crates/hj-proxy/src/target.rs b/crates/hj-proxy/src/target.rs index d7b6c1e..739ebbd 100644 --- a/crates/hj-proxy/src/target.rs +++ b/crates/hj-proxy/src/target.rs @@ -26,6 +26,9 @@ pub(crate) enum TargetTransport { /// A resolved reverse-proxy destination. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProxyTarget { + /// Configured scope distinguishes equally named vhost processors. + pub scope: Option, + pub load_balance: hj_core::config::LoadBalanceConfig, /// Scheme as written (`http` / `https` / `ws` / `wss` / `h2` / `h2s`). /// `https`/`wss`/`h2s` indicate the *upstream* leg is TLS (rare for LSWS /// backends, but parsed). @@ -64,6 +67,10 @@ pub struct ProxyTarget { } impl ProxyTarget { + pub fn in_scope(mut self, scope: impl Into) -> Self { + self.scope = Some(scope.into()); + self + } /// (Tier 1.2) Ordered peer list for pool selection: `self` (primary) first, /// then the failover transports as peer targets. Len 1 = single peer. pub(crate) fn peers(&self) -> Vec { @@ -133,6 +140,8 @@ impl ProxyTarget { } let sock = format!("/{}", sock.trim_start_matches('/')); return Ok(ProxyTarget { + scope: None, + load_balance: Default::default(), scheme: "http".into(), authority: "localhost".into(), transport: TargetTransport::Uds(sock), @@ -179,6 +188,8 @@ impl ProxyTarget { let authority = authority_raw_with_port(authority_raw, &scheme); Ok(ProxyTarget { + scope: None, + load_balance: Default::default(), transport: TargetTransport::Tcp(authority.clone()), failover: Vec::new(), client_cert_file: None, @@ -198,6 +209,12 @@ impl ProxyTarget { /// Build a target from an `ExtProcessor` of kind `Proxy`. pub fn from_ext_processor(ep: &ExtProcessor) -> ProxyTarget { + let parsed = match &ep.address { + ExtAddress::HostPort(address) if address.contains("://") => { + Self::parse_url(address).ok() + } + _ => None, + }; let (authority, transport) = match &ep.address { ExtAddress::Tcp(sa) => (sa.to_string(), TargetTransport::Tcp(sa.to_string())), ExtAddress::HostPort(hp) => { @@ -214,17 +231,27 @@ impl ProxyTarget { .iter() .map(|a| match a { ExtAddress::Tcp(sa) => TargetTransport::Tcp(sa.to_string()), - ExtAddress::HostPort(hp) => { - TargetTransport::Tcp(hp.trim_start_matches("UDS://").to_string()) - } + ExtAddress::HostPort(hp) => Self::parse_url(hp) + .map(|p| p.transport) + .unwrap_or_else(|_| { + TargetTransport::Tcp(hp.trim_start_matches("UDS://").to_string()) + }), ExtAddress::Uds(p) => TargetTransport::Uds(p.to_string_lossy().into_owned()), }) .collect(); ProxyTarget { - scheme: "http".into(), - http2: false, - authority, - transport, + scheme: parsed + .as_ref() + .map(|p| p.scheme.clone()) + .unwrap_or_else(|| "http".into()), + scope: Some("server".into()), + load_balance: ep.load_balance.clone(), + http2: parsed.as_ref().is_some_and(|p| p.http2), + authority: parsed + .as_ref() + .map(|p| p.authority.clone()) + .unwrap_or(authority), + transport: parsed.map(|p| p.transport).unwrap_or(transport), failover, path_and_query: String::new(), name: Some(ep.name.clone()), @@ -248,12 +275,15 @@ impl ProxyTarget { if t.scheme == "http" { t.scheme = "ws".into(); } + t.name = Some(format!("websocket:{}", ws.uri)); return t; } } let authority = authority_raw_with_port(addr, "ws"); ProxyTarget { scheme: "ws".into(), + scope: None, + load_balance: Default::default(), http2: false, authority: authority.clone(), transport: TargetTransport::Tcp(authority), @@ -261,7 +291,7 @@ impl ProxyTarget { client_cert_file: None, client_key_file: None, path_and_query: String::new(), - name: None, + name: Some(format!("websocket:{}", ws.uri)), max_conns: None, keep_alive: None, connect_timeout: None, @@ -403,6 +433,7 @@ mod tests { #[test] fn from_ext_processor_tcp() { let ep = ExtProcessor { + load_balance: Default::default(), name: "mcp-api".into(), kind: ExtKind::Proxy, address: ExtAddress::Tcp("127.0.0.1:8002".parse::().unwrap()), @@ -447,6 +478,12 @@ mod tests { let t = ProxyTarget::from_websocket_map(&ws); assert!(t.is_websocket()); assert_eq!(t.authority, "127.0.0.1:8001"); + assert_eq!(t.name.as_deref(), Some("websocket:/")); + let explicit = ProxyTarget::from_websocket_map(&WebSocketMap { + uri: "/chat".into(), + address: "ws://127.0.0.1:8001/socket".into(), + }); + assert_eq!(explicit.name.as_deref(), Some("websocket:/chat")); } #[test] diff --git a/crates/hj-proxy/tests/h2_upstream.rs b/crates/hj-proxy/tests/h2_upstream.rs index 555cb1e..764b0bd 100644 --- a/crates/hj-proxy/tests/h2_upstream.rs +++ b/crates/hj-proxy/tests/h2_upstream.rs @@ -191,6 +191,15 @@ async fn h2_upstream_serves_requests_with_normalized_version() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn h2_max_conns_counts_an_open_response_stream() { + held_response(false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn weighted_group_counts_h2_response_until_drop() { + held_response(true).await; +} + +async fn held_response(weighted: bool) { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let request_count = Arc::new(AtomicU32::new(0)); @@ -223,7 +232,12 @@ async fn h2_max_conns_counts_an_open_response_stream() { Duration::from_secs(60), Duration::from_secs(2), )); - let target = ProxyTarget::parse_url(&format!("h2://{addr}")).unwrap(); + let mut target = ProxyTarget::parse_url(&format!("h2://{addr}")).unwrap(); + if weighted { + target.name = Some("held".into()); + target.scope = Some("test".into()); + target.load_balance.policy = hj_core::config::LoadBalancePolicy::WeightedLeastActive; + } let first = proxy .forward( &ctx(), @@ -237,6 +251,9 @@ async fn h2_max_conns_counts_an_open_response_stream() { ) .await .expect("first h2 forward"); + if weighted { + assert_eq!(proxy.pool().peer_snapshots()[0].active, 1); + } let second_proxy = proxy.clone(); let second_target = target.clone(); @@ -270,6 +287,15 @@ async fn h2_max_conns_counts_an_open_response_stream() { .expect("second task") .expect("second h2 forward"); drop(second_resp); + if weighted { + tokio::time::timeout(Duration::from_secs(1), async { + while proxy.pool().peer_snapshots()[0].active != 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("all selected request reservations released"); + } assert_eq!(request_count.load(Ordering::SeqCst), 2); server.abort(); diff --git a/crates/hj-proxy/tests/upstream_groups.rs b/crates/hj-proxy/tests/upstream_groups.rs new file mode 100644 index 0000000..427635b --- /dev/null +++ b/crates/hj-proxy/tests/upstream_groups.rs @@ -0,0 +1,222 @@ +use hj_core::config::*; +use hj_core::{Proto, ReqCtx}; +use hj_proxy::{Proxy, ProxyTarget}; +use http_body_util::{BodyExt, Full}; +use std::sync::{ + Arc, + atomic::{AtomicU16, Ordering}, +}; +use std::time::Duration; +fn ctx() -> ReqCtx { + let server = ServerConfig { + server_root: Default::default(), + server_name: String::new(), + user: String::new(), + group: String::new(), + index_files: vec![], + tuning: Default::default(), + quic_enable: false, + use_ip_in_proxy_header: 0, + expires: Default::default(), + cache: Default::default(), + security: Default::default(), + suexec: Default::default(), + ext_processors: vec![], + php_config: None, + listeners: vec![], + vhosts: Default::default(), + vhost_order: vec![], + mime: Default::default(), + }; + ReqCtx { + server: Arc::new(server), + vhost_name: String::new(), + vhost: Arc::new(hj_core::config::VHostConfig::default()), + peer_ip: "127.0.0.1".parse().unwrap(), + client_ip: "127.0.0.1".parse().unwrap(), + is_tls: false, + peer_unix: false, + protocol: Proto::Http1, + trusted_proxy: false, + env: Vec::new(), + local_addr: "127.0.0.1:80".parse().unwrap(), + peer_port: 40000, + request_time: std::time::SystemTime::UNIX_EPOCH, + request_id: hj_core::reqid::next(), + redirect_guard: None, + tls: None, + } +} + +fn empty_body() -> hj_core::IncomingBody { + http_body_util::Empty::::new() + .map_err(|e| match e {}) + .boxed() +} + +fn processor(addresses: Vec, health: bool) -> ExtProcessor { + ExtProcessor { + name: "group".into(), + kind: ExtKind::Proxy, + address: ExtAddress::HostPort(addresses[0].clone()), + extra_addresses: addresses[1..] + .iter() + .cloned() + .map(ExtAddress::HostPort) + .collect(), + load_balance: LoadBalanceConfig { + policy: LoadBalancePolicy::WeightedRoundRobin, + weights: vec![2, 1], + health_check: health.then(|| HealthCheckConfig { + mode: "GET".into(), + interval: Duration::from_millis(20), + timeout: Duration::from_millis(20), + rise: 2, + fall: 3, + path: "/health".into(), + host: Some("probe.test".into()), + expected_status: 200, + }), + }, + client_cert_file: None, + client_key_file: None, + max_conns: 10, + init_timeout: Duration::from_secs(1), + retry_timeout: Duration::ZERO, + pc_keep_alive_timeout: Duration::from_secs(5), + resp_buffer: false, + env: vec![], + auto_start: 0, + path: None, + backlog: 0, + instances: 1, + run_on_startup: 0, + } +} +async fn backend( + id: &'static str, + h2: bool, +) -> (String, Arc, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = format!( + "{}://{}", + if h2 { "h2" } else { "http" }, + listener.local_addr().unwrap() + ); + let status = Arc::new(AtomicU16::new(200)); + let health = status.clone(); + let task = tokio::spawn(async move { + let mut connections = tokio::task::JoinSet::new(); + loop { + tokio::select! { + accepted=listener.accept() => { + let (stream,_)=accepted.unwrap(); let health=health.clone(); + connections.spawn(async move { + let service=hyper::service::service_fn(move |req: http::Request| { + let status=if req.uri().path()=="/health" {health.load(Ordering::Relaxed)} else {200}; + async move { + Ok::<_,std::convert::Infallible>(http::Response::builder().status(status).body(Full::new(bytes::Bytes::from_static(id.as_bytes()))).unwrap()) + } + }); + let io=hyper_util::rt::TokioIo::new(stream); + if h2 { let _=hyper::server::conn::http2::Builder::new(hyper_util::rt::TokioExecutor::new()).serve_connection(io,service).await; } + else { let _=hyper::server::conn::http1::Builder::new().serve_connection(io,service).await; } + }); + }, + _=connections.join_next(), if !connections.is_empty() => {} + } + } + }); + (address, status, task) +} +async fn request(proxy: &Proxy, target: &ProxyTarget) -> Result { + let req = http::Request::builder() + .uri("/data") + .header("host", "app.test") + .body(empty_body()) + .unwrap(); + let resp = proxy.forward(&ctx(), req, target, None).await?; + match resp.into_body() { + hj_core::Body::Stream(body) => { + Ok(String::from_utf8(body.collect().await.unwrap().to_bytes().to_vec()).unwrap()) + } + _ => panic!("expected streaming proxy body"), + } +} +async fn wait_health(proxy: &Proxy, count: usize) { + tokio::time::timeout(Duration::from_secs(3), async { + while proxy + .pool() + .peer_snapshots() + .iter() + .filter(|p| p.healthy) + .count() + != count + { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("health transition"); +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn configured_h1_and_h2_groups_distribute_two_to_one() { + for h2 in [false, true] { + let (a, _, sa) = backend("a", h2).await; + let (b, _, sb) = backend("b", h2).await; + let target = ProxyTarget::from_ext_processor(&processor(vec![a, b], false)); + assert_eq!(target.http2, h2); + let proxy = Proxy::with_targets([target.clone()]); + proxy.pool().activate_health_checks(); + let mut counts = [0, 0]; + for _ in 0..12 { + let body = request(&proxy, &target).await.unwrap(); + counts[usize::from(body == "b")] += 1; + } + assert_eq!(counts, [8, 4]); + proxy.pool().stop_health_checks(); + sa.abort(); + sb.abort(); + let _ = sa.await; + let _ = sb.await; + } +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn active_health_excludes_down_peers_and_recovers_without_user_traffic() { + let (a, ha, sa) = backend("a", false).await; + let (b, hb, sb) = backend("b", false).await; + let target = ProxyTarget::from_ext_processor(&processor(vec![a, b], true)); + let proxy = Proxy::with_targets([target.clone()]); + assert!(matches!( + request(&proxy, &target).await, + Err(hj_core::HandlerError::ServiceUnavailable) + )); + proxy.pool().activate_health_checks(); + wait_health(&proxy, 2).await; + ha.store(503, Ordering::Relaxed); + wait_health(&proxy, 1).await; + for _ in 0..5 { + assert_eq!(request(&proxy, &target).await.unwrap(), "b"); + } + hb.store(503, Ordering::Relaxed); + wait_health(&proxy, 0).await; + assert!(matches!( + request(&proxy, &target).await, + Err(hj_core::HandlerError::ServiceUnavailable) + )); + ha.store(200, Ordering::Relaxed); + wait_health(&proxy, 1).await; + assert_eq!(request(&proxy, &target).await.unwrap(), "a"); + proxy.pool().stop_health_checks(); + tokio::time::sleep(Duration::from_millis(30)).await; + let before: u64 = proxy.pool().peer_snapshots().iter().map(|p| p.probes).sum(); + tokio::time::sleep(Duration::from_millis(60)).await; + assert_eq!( + before, + proxy.pool().peer_snapshots().iter().map(|p| p.probes).sum() + ); + sa.abort(); + sb.abort(); + let _ = sa.await; + let _ = sb.await; +} diff --git a/crates/hj-rewrite/tests/fixtures/header_compat/ignored-actions.htaccess b/crates/hj-rewrite/tests/fixtures/header_compat/ignored-actions.htaccess new file mode 100644 index 0000000..c30db95 --- /dev/null +++ b/crates/hj-rewrite/tests/fixtures/header_compat/ignored-actions.htaccess @@ -0,0 +1,7 @@ +# These Apache 2.4 actions/directives are currently ignored by the parser. +RequestHeader set X-Request changed +RequestHeader unset Authorization +Header setifempty X-Default fallback +Header edit Location ^http: https: +Header edit* Set-Cookie insecure secure +Header note X-Upstream upstream_note diff --git a/crates/hj-rewrite/tests/fixtures/header_compat/semantic-gaps.htaccess b/crates/hj-rewrite/tests/fixtures/header_compat/semantic-gaps.htaccess new file mode 100644 index 0000000..9647831 --- /dev/null +++ b/crates/hj-rewrite/tests/fixtures/header_compat/semantic-gaps.htaccess @@ -0,0 +1,7 @@ +# These lines parse, but have narrower semantics than Apache mod_headers. +Header merge Cache-Control no-cache +Header echo ^X-Request- +Header always set X-Expr-Guard guarded "expr=%{REQUEST_STATUS} == 200" +Header set X-Expr-Value "expr=%{md5:foo}" +Header always set X-Early late-only early +Header set X-Colon: value diff --git a/crates/hj-rewrite/tests/fixtures/header_compat/supported.htaccess b/crates/hj-rewrite/tests/fixtures/header_compat/supported.htaccess new file mode 100644 index 0000000..07af65e --- /dev/null +++ b/crates/hj-rewrite/tests/fixtures/header_compat/supported.htaccess @@ -0,0 +1,8 @@ +# Current implemented response-header subset. +Header set X-Set replacement +Header add Set-Cookie a=1 +Header append Vary Accept-Encoding +Header merge Cache-Control no-store +Header unset X-Remove +Header always set X-Always present +Header set X-Env "%{FEATURE}e" env=FEATURE diff --git a/crates/hj-rewrite/tests/header_compat.rs b/crates/hj-rewrite/tests/header_compat.rs new file mode 100644 index 0000000..27e11ce --- /dev/null +++ b/crates/hj-rewrite/tests/header_compat.rs @@ -0,0 +1,104 @@ +//! Executable inventory for the current `Header`/`RequestHeader` compatibility +//! boundary. These tests deliberately pin gaps; changing an assertion requires +//! updating `docs/header-directive-compatibility.md` in the same review. + +use hj_rewrite::{HeaderOp, Htaccess}; + +const SUPPORTED: &str = include_str!("fixtures/header_compat/supported.htaccess"); +const IGNORED: &str = include_str!("fixtures/header_compat/ignored-actions.htaccess"); +const SEMANTIC_GAPS: &str = include_str!("fixtures/header_compat/semantic-gaps.htaccess"); + +#[test] +fn implemented_response_actions_are_emitted_in_source_order() { + let ht = Htaccess::parse(SUPPORTED).unwrap(); + let env = vec![("FEATURE".to_string(), "enabled".to_string())]; + assert_eq!( + ht.response_headers("/index.html", 200, &env), + vec![ + HeaderOp::Set { + name: "X-Set".into(), + value: "replacement".into(), + }, + HeaderOp::Add { + name: "Set-Cookie".into(), + value: "a=1".into(), + }, + HeaderOp::Append { + name: "Vary".into(), + value: "Accept-Encoding".into(), + }, + // Current gap: Merge is represented as Append and does not + // de-duplicate an existing comma-delimited value. + HeaderOp::Append { + name: "Cache-Control".into(), + value: "no-store".into(), + }, + HeaderOp::Unset { + name: "X-Remove".into(), + }, + HeaderOp::Set { + name: "X-Always".into(), + value: "present".into(), + }, + HeaderOp::Set { + name: "X-Env".into(), + value: "enabled".into(), + }, + ] + ); +} + +#[test] +fn onsuccess_is_status_gated_while_always_survives_an_error() { + let ht = Htaccess::parse(SUPPORTED).unwrap(); + assert_eq!( + ht.response_headers("/index.html", 500, &[]), + vec![HeaderOp::Set { + name: "X-Always".into(), + value: "present".into(), + }] + ); +} + +#[test] +fn request_header_and_unimplemented_response_actions_are_ignored() { + let ht = Htaccess::parse(IGNORED).unwrap(); + assert!(!ht.has_resp_op); + assert!(ht.response_headers("/index.html", 200, &[]).is_empty()); +} + +#[test] +fn parsed_semantic_gaps_remain_explicit() { + let ht = Htaccess::parse(SEMANTIC_GAPS).unwrap(); + + let success = ht.response_headers("/index.html", 200, &[]); + assert!(success.contains(&HeaderOp::Append { + name: "Cache-Control".into(), + value: "no-cache".into(), + })); + assert!( + !success.iter().any(|op| op.name().starts_with("^X-Request")), + "Header echo is parsed but intentionally emits no operation" + ); + assert!(success.contains(&HeaderOp::Set { + name: "X-Expr-Value".into(), + value: String::new(), + })); + assert!(success.contains(&HeaderOp::Set { + name: "X-Early".into(), + value: "late-only".into(), + })); + assert!(success.contains(&HeaderOp::Set { + name: "X-Colon:".into(), + value: "value".into(), + })); + + let error = ht.response_headers("/index.html", 500, &[]); + assert!( + error.contains(&HeaderOp::Set { + name: "X-Expr-Guard".into(), + value: "guarded".into(), + }), + "an unsupported expression currently collapses to no guard" + ); +} diff --git a/crates/hj-tls/Cargo.toml b/crates/hj-tls/Cargo.toml index 22618b5..81285d8 100644 --- a/crates/hj-tls/Cargo.toml +++ b/crates/hj-tls/Cargo.toml @@ -7,7 +7,12 @@ rust-version.workspace = true license.workspace = true repository.workspace = true +[features] +ocsp = ["dep:hj-ocsp", "dep:tokio"] + [dependencies] +hj-ocsp = { path = "../hj-ocsp", optional = true } +tokio = { workspace = true, optional = true } hj-config = { workspace = true } hj-core = { workspace = true } rustls = { workspace = true } diff --git a/crates/hj-tls/src/lib.rs b/crates/hj-tls/src/lib.rs index edbdabd..41a4a3f 100644 --- a/crates/hj-tls/src/lib.rs +++ b/crates/hj-tls/src/lib.rs @@ -62,6 +62,9 @@ //! * [`build_certified_key`] — assemble a [`CertifiedKey`] from cert + key files. //! * [`build_sni_resolver`] — build the per-listener SNI resolver. +#[cfg(feature = "ocsp")] +pub mod ocsp; + use std::collections::HashMap; use std::fs::File; use std::io::BufReader; @@ -106,17 +109,32 @@ pub(crate) const ALPN_PROTOCOLS: &[&[u8]] = &[b"h2", b"http/1.1"]; /// [`CertifiedKey`] regardless of the cert's SAN/CN. Lookup and insertion keys /// are ASCII-lowercased, mirroring OLS `getLcaseServerName`/`strnlower` /// (rustls also hands us an already-lowercased servername). -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] pub(crate) struct SniCertMap { by_name: HashMap>>, + #[cfg(feature = "ocsp")] + stapling: Option>, } impl SniCertMap { + /// Managed wildcard certificates match exactly one leftmost DNS label. + /// Keep legacy file-based SNI behavior separate and unchanged. + fn resolve_managed_name( + &self, + name: Option<&str>, + schemes: &[rustls::SignatureScheme], + ) -> Option> { + if self.by_name.is_empty() { + return None; + } + self.resolve_name(name, schemes).or_else(|| { + let (_, suffix) = name?.split_once('.')?; + self.resolve_name(Some(&format!("*.{suffix}")), schemes) + }) + } /// Create an empty map. pub(crate) fn new() -> Self { - Self { - by_name: HashMap::new(), - } + Self::default() } /// Register `name` → `ck`. Unlike rustls's resolver this performs **no** @@ -183,10 +201,12 @@ impl SniCertMap { /// explicitly mapped to a vhost cert (including raw-IP TLS with no SNI). This /// matches OLS `VHostMapFindSslContext`, which falls back to the listener-level /// `pMap->getSslContext()` when no mapped vhost supplies a context. -#[derive(Debug)] +#[derive(Debug, Clone)] struct SniWithDefault { sni: SniCertMap, - default: Arc, + default: Option>, + #[cfg(feature = "ocsp")] + stapling: Option>, } impl ResolvesServerCert for SniWithDefault { @@ -195,7 +215,7 @@ impl ResolvesServerCert for SniWithDefault { // fall back to the listener default cert. self.sni .resolve_name(client_hello.server_name(), client_hello.signature_schemes()) - .or_else(|| Some(self.default.clone())) + .or_else(|| self.default.clone()) } } @@ -205,7 +225,7 @@ impl ResolvesServerCert for SniWithDefault { /// Per handshake it is one atomic load + the existing exact-SNI lookup; /// in-flight connections keep the cert they negotiated, new handshakes pick up /// the swapped certs. The swap handle is [`CertReloadHandle`]. -struct ReloadableResolver(Arc>); +struct ReloadableResolver(Arc>, Arc>); impl std::fmt::Debug for ReloadableResolver { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -215,7 +235,23 @@ impl std::fmt::Debug for ReloadableResolver { impl ResolvesServerCert for ReloadableResolver { fn resolve(&self, client_hello: ClientHello<'_>) -> Option> { - self.0.load().resolve(client_hello) + let managed = self.1.load(); + if let Some(key) = managed + .resolve_managed_name(client_hello.server_name(), client_hello.signature_schemes()) + { + #[cfg(feature = "ocsp")] + if let Some(slots) = &managed.stapling { + return slots.resolve(key); + } + return Some(key); + } + let generation = self.0.load(); + let key = generation.resolve(client_hello)?; + #[cfg(feature = "ocsp")] + if let Some(slots) = &generation.stapling { + return slots.resolve(key); + } + Some(key) } } @@ -224,15 +260,63 @@ impl ResolvesServerCert for ReloadableResolver { /// the running resolver — an ACME renewal (new cert at the same path) takes /// effect with no restart. Cheaply cloneable. #[derive(Clone)] -pub struct CertReloadHandle(Arc>); +pub struct CertReloadHandle( + Arc>, + Arc>, + #[cfg(feature = "ocsp")] Arc>, +); impl CertReloadHandle { + /// Atomically replace an explicitly managed SNI overlay. The caller validates + /// trust, validity and the exact SAN set before this method; it rechecks the + /// key pair and bounds here. File-based SIGHUP reloads cannot erase the overlay. + /// This never changes client-certificate verification or listener properties. + pub fn replace_managed(&self, domains: &[String], key: Arc) -> Result<()> { + anyhow::ensure!( + !domains.is_empty() && domains.len() <= 100, + "invalid managed certificate domain count" + ); + key.keys_match() + .map_err(|_| anyhow!("managed certificate key mismatch"))?; + let mut map = SniCertMap::new(); + for domain in domains { + anyhow::ensure!( + !domain.is_empty() + && domain.len() <= 253 + && domain.is_ascii() + && !domain.strip_prefix("*.").unwrap_or(domain).contains('*'), + "invalid managed certificate domain" + ); + map.add(domain, key.clone()); + } + #[cfg(feature = "ocsp")] + if let Some(stapling) = self.2.load_full() { + map.stapling = Some(stapling.register(map.by_name.values().flatten().cloned())?); + } + self.1.store(Arc::new(map)); + Ok(()) + } + /// Re-read `listener`'s default + per-vhost certificates from `server` and /// atomically swap them into the live resolver. On any load error the CURRENT /// certs stay in place (a failed reload never breaks TLS); new handshakes use /// the new certs once this returns `Ok`. pub fn reload(&self, server: &ServerConfig, listener: &Listener) -> Result<()> { - let next = build_sni_with_default(server, listener)?; + #[allow(unused_mut)] + let mut next = build_sni_with_default(server, listener)?; + #[cfg(feature = "ocsp")] + if let Some(stapling) = self.2.load_full() { + next.stapling = Some( + stapling.register( + next.sni + .by_name + .values() + .flatten() + .cloned() + .chain(next.default.iter().cloned()), + )?, + ); + } self.0.store(Arc::new(next)); Ok(()) } @@ -262,7 +346,9 @@ fn build_sni_with_default(server: &ServerConfig, listener: &Listener) -> Result< })?; Ok(SniWithDefault { sni, - default: Arc::new(default_ck), + default: Some(Arc::new(default_ck)), + #[cfg(feature = "ocsp")] + stapling: None, }) } @@ -399,11 +485,10 @@ pub(crate) fn build_certified_key( ) -> Result { let chain = load_cert_chain(cert_file, chain_file)?; let key = load_private_key(key_file)?; - // NB: the listener's `enable_stapling` (OCSP stapling) is intentionally NOT wired - // here — no OCSP response is fetched/attached to the CertifiedKey. It is a parsed - // config knob with no implementation (the startup banner reports it as a no-op). - // Unneeded in the live deployment: Cloudflare terminates TLS to browsers and does - // not require origin-side stapling. + // Loading key material never fetches OCSP. The opt-in OCSP manager attaches + // only authenticated, fresh responses through generation-bound resolver slots. + // XML enableStapling alone does not initiate network traffic; the binary + // requires an explicit responder endpoint and an OCSP-enabled build. Ok(CertifiedKey::new(chain, key)) } @@ -776,6 +861,7 @@ pub fn build_server_config_reloadable( cfg, listener, ALPN_PROTOCOLS.iter().map(|p| p.to_vec()).collect(), + false, ) } @@ -798,6 +884,7 @@ fn build_server_config_inner( cfg: &ServerConfig, listener: &Listener, alpn: Vec>, + bootstrap: bool, ) -> Result<(Arc, CertReloadHandle)> { let tls = listener .tls @@ -811,10 +898,18 @@ fn build_server_config_inner( // 2. SNI resolver + listener default certificate, held behind an ArcSwap so // renewed certs can be live-reloaded (OPS9) without rebuilding this config. - let cert_swap = Arc::new(ArcSwap::from_pointee(build_sni_with_default( - cfg, listener, - )?)); - let resolver = Arc::new(ReloadableResolver(cert_swap.clone())); + let cert_swap = Arc::new(ArcSwap::from_pointee(if bootstrap { + SniWithDefault { + sni: build_sni_resolver(cfg, listener)?, + default: None, + #[cfg(feature = "ocsp")] + stapling: None, + } + } else { + build_sni_with_default(cfg, listener)? + })); + let managed = Arc::new(ArcSwap::from_pointee(SniCertMap::new())); + let resolver = Arc::new(ReloadableResolver(cert_swap.clone(), managed.clone())); // 3. Assemble the ServerConfig on the installed provider so the resolver, // verifier, and key material all share one crypto backend. @@ -857,7 +952,15 @@ fn build_server_config_inner( // `httpjet_tls_handshakes_{full,resumed}_total` observe the split in production. config.session_storage = ServerSessionMemoryCache::new(TLS_SESSION_CACHE_SIZE); - Ok((Arc::new(config), CertReloadHandle(cert_swap))) + Ok(( + Arc::new(config), + CertReloadHandle( + cert_swap, + managed, + #[cfg(feature = "ocsp")] + Arc::new(arc_swap::ArcSwapOption::empty()), + ), + )) } /// Resumable TLS sessions retained per process (stateful tickets / session IDs). 32 Ki @@ -932,9 +1035,15 @@ pub struct KtlsConfigTemplate { resolver: Arc, alpn: Vec>, session_storage: Arc, + #[cfg(feature = "ocsp")] + ocsp_no_resumption: bool, } impl KtlsConfigTemplate { + #[cfg(feature = "ocsp")] + pub fn disable_ocsp_resumption(&mut self) { + self.ocsp_no_resumption = true; + } /// Assemble a fresh `ServerConfig` (cheap: shares the Arc'd verifier/resolver/cache) /// installing `key_log` as this connection's secret sink. pub fn server_config_with_key_log( @@ -950,6 +1059,10 @@ impl KtlsConfigTemplate { config.max_early_data_size = 0; config.session_storage = self.session_storage.clone(); config.key_log = key_log; + #[cfg(feature = "ocsp")] + if self.ocsp_no_resumption { + ocsp::disable_resumption(&mut config); + } // kTLS programs the kernel TLS keys at the connection's CURRENT record sequence, which // we read from `dangerous_extract_secrets` after the handshake — so NewSessionTickets // (emitted under the server app key during the final flush, advancing the TX sequence) @@ -965,6 +1078,15 @@ impl KtlsConfigTemplate { pub fn build_ktls_template( cfg: &ServerConfig, listener: &Listener, +) -> Result<(KtlsConfigTemplate, CertReloadHandle)> { + build_ktls_template_with_bootstrap(cfg, listener, false) +} + +/// Explicit certificate-free bootstrap; verifier policy remains unchanged. +pub fn build_ktls_template_with_bootstrap( + cfg: &ServerConfig, + listener: &Listener, + bootstrap: bool, ) -> Result<(KtlsConfigTemplate, CertReloadHandle)> { let tls = listener .tls @@ -972,10 +1094,18 @@ pub fn build_ktls_template( .ok_or_else(|| anyhow!("listener {} has no TLS configuration", listener.name))?; let provider = provider()?; let verifier = build_listener_verifier(tls, listener)?; - let cert_swap = Arc::new(ArcSwap::from_pointee(build_sni_with_default( - cfg, listener, - )?)); - let resolver = Arc::new(ReloadableResolver(cert_swap.clone())); + let cert_swap = Arc::new(ArcSwap::from_pointee(if bootstrap { + SniWithDefault { + sni: build_sni_resolver(cfg, listener)?, + default: None, + #[cfg(feature = "ocsp")] + stapling: None, + } + } else { + build_sni_with_default(cfg, listener)? + })); + let managed = Arc::new(ArcSwap::from_pointee(SniCertMap::new())); + let resolver = Arc::new(ReloadableResolver(cert_swap.clone(), managed.clone())); // (security, 2026-08-30) Mirror the TCP builder: the stateful session cache is // enabled on client-cert-verifying listeners as well — see the rationale at // `build_server_config_inner` (chain⇄ticket binding, single-use tickets, 0-RTT off). @@ -989,8 +1119,18 @@ pub fn build_ktls_template( resolver, alpn: ALPN_PROTOCOLS.iter().map(|p| p.to_vec()).collect(), session_storage, + #[cfg(feature = "ocsp")] + ocsp_no_resumption: false, }; - Ok((template, CertReloadHandle(cert_swap))) + Ok(( + template, + CertReloadHandle( + cert_swap, + managed, + #[cfg(feature = "ocsp")] + Arc::new(arc_swap::ArcSwapOption::empty()), + ), + )) } /// QUIC/HTTP3 entry point. Like [`build_server_config`] (the TCP entry), uses @@ -1014,7 +1154,62 @@ pub fn build_server_config_alpn_reloadable( listener: &Listener, alpn: Vec>, ) -> Result<(Arc, CertReloadHandle)> { - build_server_config_inner(cfg, listener, alpn) + build_server_config_inner(cfg, listener, alpn, false) +} + +/// Opt-in ACME bootstrap with no default certificate. Unmanaged/no-SNI +/// handshakes without a configured vhost certificate FAIL until provisioned; +/// no self-signed identity or no-auth verifier is substituted. HTTP-01 can run +/// independently while TCP/QUIC client-auth requirements stay unchanged. +pub fn build_server_config_alpn_with_bootstrap( + cfg: &ServerConfig, + listener: &Listener, + alpn: Vec>, + bootstrap: bool, +) -> Result<(Arc, CertReloadHandle)> { + build_server_config_inner(cfg, listener, alpn, bootstrap) +} + +/// A listener's transport configurations prepared from one certificate/verifier +/// load. New resource generations receive fresh session stores; TCP and kTLS +/// share their store, while QUIC has a separate resumption namespace. +/// +/// Preparation does not publish or attach background certificate managers. One +/// certificate handle updates the shared resolver for every included transport. +pub struct PreparedListenerTls { + pub tcp: Arc, + pub quic: Option>, + pub ktls: Option, + pub certificates: CertReloadHandle, +} + +impl PreparedListenerTls { + pub fn prepare( + cfg: &ServerConfig, + listener: &Listener, + bootstrap: bool, + quic: bool, + ktls: bool, + ) -> Result { + // This builder owns the sole file/verifier read. Deriving the other + // transports must not reread paths that may change during acquisition. + let (template, certificates) = + build_ktls_template_with_bootstrap(cfg, listener, bootstrap)?; + let mut tcp = (*template.server_config_with_key_log(Arc::new(rustls::NoKeyLog))?).clone(); + tcp.enable_secret_extraction = false; + let quic = quic.then(|| { + let mut config = tcp.clone(); + config.alpn_protocols = vec![b"h3".to_vec()]; + config.session_storage = ServerSessionMemoryCache::new(TLS_SESSION_CACHE_SIZE); + Arc::new(config) + }); + Ok(Self { + tcp: Arc::new(tcp), + quic, + ktls: ktls.then_some(template), + certificates, + }) + } } /// Extract the per-request [`TlsParams`] from a completed [`rustls::ServerConnection`] @@ -1604,7 +1799,9 @@ mod tests { ); let wrapper = SniWithDefault { sni, - default: default_ck.clone(), + default: Some(default_ck.clone()), + #[cfg(feature = "ocsp")] + stapling: None, }; // Mapped (but SAN-uncovered) name -> the vhost cert, not the default. @@ -1744,6 +1941,75 @@ mod tests { ); } + #[test] + fn prepared_listener_tls_shares_resolver_but_isolates_generation_sessions() { + ensure_provider(); + let dir = tmpdir(); + let certificate = gen_cert(&["prepared.example.com"]); + let listener = Listener { + name: "TLS".into(), + address: "127.0.0.1:8443".into(), + secure: true, + vhost_map: vec![], + proxy_protocol: false, + uds_path: None, + tls: Some(ListenerTls { + key_file: write_tmp(&dir, "key.pem", &certificate.key_pem), + cert_file: write_tmp(&dir, "cert.pem", &certificate.cert_pem), + cert_chain: false, + ca_cert_file: None, + client_verify: 0, + verify_depth: 1, + enable_stapling: false, + crl_file: None, + }), + }; + let server = base_server(); + let bundle = PreparedListenerTls::prepare(&server, &listener, false, true, true).unwrap(); + let quic = bundle.quic.as_ref().unwrap(); + let ktls = bundle + .ktls + .as_ref() + .unwrap() + .server_config_with_key_log(Arc::new(rustls::NoKeyLog)) + .unwrap(); + assert!(Arc::ptr_eq(&bundle.tcp.cert_resolver, &quic.cert_resolver)); + assert!(Arc::ptr_eq(&bundle.tcp.cert_resolver, &ktls.cert_resolver)); + assert!(Arc::ptr_eq( + &bundle.tcp.session_storage, + &ktls.session_storage + )); + assert!(!Arc::ptr_eq( + &bundle.tcp.session_storage, + &quic.session_storage + )); + assert_eq!(quic.alpn_protocols, vec![b"h3".to_vec()]); + assert!(!bundle.tcp.enable_secret_extraction); + assert!(!quic.enable_secret_extraction); + assert!(ktls.enable_secret_extraction); + assert_eq!(bundle.tcp.max_early_data_size, 0); + assert_eq!(quic.max_early_data_size, 0); + let next = PreparedListenerTls::prepare(&server, &listener, false, false, false).unwrap(); + assert!(next.quic.is_none() && next.ktls.is_none()); + assert!(!Arc::ptr_eq( + &bundle.tcp.session_storage, + &next.tcp.session_storage + )); + assert!(!Arc::ptr_eq( + &bundle.tcp.cert_resolver, + &next.tcp.cert_resolver + )); + let snapshot = bundle.certificates.0.load_full(); + std::fs::write( + &listener.tls.as_ref().unwrap().cert_file, + b"invalid certificate", + ) + .unwrap(); + assert!(PreparedListenerTls::prepare(&server, &listener, false, true, true).is_err()); + assert!(Arc::ptr_eq(&snapshot, &bundle.certificates.0.load_full())); + std::fs::remove_dir_all(dir).unwrap(); + } + /// (OPS9) Live cert reload: overwriting the cert file at the SAME path (the /// ACME-renewal case) and calling `CertReloadHandle::reload` swaps the new /// cert into the live resolver — observed through the handle's ArcSwap — while @@ -1781,11 +2047,44 @@ mod tests { let (_cfg, handle) = build_server_config_reloadable(&server, &listener).expect("build reloadable config"); - let leaf_now = || handle.0.load().default.cert[0].as_ref().to_vec(); + let leaf_now = || { + handle.0.load().default.as_ref().unwrap().cert[0] + .as_ref() + .to_vec() + }; let expect_a = load_cert_chain(&cert_path, None).expect("load A")[0] .as_ref() .to_vec(); assert_eq!(leaf_now(), expect_a, "resolver initially presents cert A"); + let managed_a = Arc::new( + build_certified_key(&cert_path, None, &listener.tls.as_ref().unwrap().key_file) + .unwrap(), + ); + handle + .replace_managed(&["managed.example.com".to_owned()], managed_a) + .unwrap(); + let wildcard_key = handle + .1 + .load() + .resolve_name(Some("managed.example.com"), P256_SCHEMES) + .unwrap(); + let mut wildcard = SniCertMap::new(); + wildcard.add("*.example.com", wildcard_key); + assert!( + wildcard + .resolve_managed_name(Some("one.example.com"), P256_SCHEMES) + .is_some() + ); + assert!( + wildcard + .resolve_managed_name(Some("two.one.example.com"), P256_SCHEMES) + .is_none() + ); + assert!( + wildcard + .resolve_managed_name(Some("example.com"), P256_SCHEMES) + .is_none() + ); // Renew: overwrite the SAME paths with a fresh cert/key (B). let b = gen_cert(&["default.example.com"]); @@ -1806,6 +2105,35 @@ mod tests { expect_b, "resolver presents cert B after reload()" ); + assert_eq!( + handle + .1 + .load() + .resolve_name(Some("managed.example.com"), P256_SCHEMES) + .unwrap() + .cert[0] + .as_ref(), + expect_a + ); + let bootstrap = + build_server_config_alpn_with_bootstrap(&server, &listener, vec![b"h2".to_vec()], true) + .unwrap(); + assert!( + bootstrap.1.0.load().default.is_none(), + "bootstrap never substitutes an untrusted default identity" + ); + let mut missing_ca = listener.clone(); + missing_ca.tls.as_mut().unwrap().client_verify = 1; + assert!( + build_server_config_alpn_with_bootstrap( + &server, + &missing_ca, + vec![b"h2".to_vec()], + true + ) + .is_err(), + "bootstrap cannot bypass client CA validation" + ); } #[test] diff --git a/crates/hj-tls/src/ocsp.rs b/crates/hj-tls/src/ocsp.rs new file mode 100644 index 0000000..cb49e19 --- /dev/null +++ b/crates/hj-tls/src/ocsp.rs @@ -0,0 +1,319 @@ +//! Optional generation-bound OCSP state. No network work in the resolver. +use crate::CertReloadHandle; +use anyhow::{Result, anyhow, ensure}; +use hj_ocsp::{Decision, Endpoint, RefreshPool, Slot, Staple}; +use parking_lot::Mutex; +use rustls::sign::CertifiedKey; +use std::{ + collections::HashMap, + sync::{Arc, Weak}, +}; + +const MAX_IDENTITIES: usize = 128; + +/// Resumption skips certificate selection. OCSP-enabled listeners must disable +/// both stateful and stateless resumption so every new connection checks status. +pub fn disable_resumption(config: &mut rustls::ServerConfig) { + config.session_storage = Arc::new(rustls::server::NoServerSessionStorage {}); + config.send_tls13_tickets = 0; + config.ticketer = Arc::new(NoTickets); +} +#[derive(Debug)] +struct NoTickets; +impl rustls::server::ProducesTickets for NoTickets { + fn enabled(&self) -> bool { + false + } + fn lifetime(&self) -> u32 { + 0 + } + fn encrypt(&self, _: &[u8]) -> Option> { + None + } + fn decrypt(&self, _: &[u8]) -> Option> { + None + } +} + +/// One shared, bounded manager for all enabled listener resolver handles. +pub struct Stapling { + endpoint: Endpoint, + required: bool, + slots: Mutex, Weak>>, + pool: Arc, +} +impl Stapling { + pub fn new(endpoint: &str, loopback_test: bool, required: bool) -> Result> { + Ok(Arc::new(Self { + endpoint: Endpoint::new(endpoint, loopback_test)?, + required, + slots: Mutex::new(HashMap::new()), + pool: Arc::new(RefreshPool::default()), + })) + } + pub(crate) fn register( + &self, + keys: impl Iterator>, + ) -> Result> { + let mut registry = self.slots.lock(); + registry.retain(|_, value| value.strong_count() > 0); + let mut selected = HashMap::new(); + for key in keys { + ensure!( + (2..=8).contains(&key.cert.len()), + "OCSP needs leaf plus immediate issuer (at most eight certificates)" + ); + let total = key.cert.iter().map(|c| c.as_ref().len()).sum::(); + ensure!(total <= hj_ocsp::MAX_RESPONSE, "OCSP chain exceeds 64 KiB"); + let leaf = key.cert[0].as_ref(); + // Exact leaf identity, never SNI/public-key identity. A chain-only + // change must not reset a previously verified revocation. Reuse + // pins the first verified issuer and its conservative expiry cap. + let identity = leaf.to_vec(); + if selected.contains_key(leaf) { + continue; + } + let slot = match registry.get(&identity).and_then(Weak::upgrade) { + Some(slot) => slot, + None => { + ensure!( + registry.len() < MAX_IDENTITIES, + "OCSP live generation identity limit reached" + ); + let slot = Slot::new( + leaf, + key.cert[1].as_ref(), + self.endpoint.clone(), + self.required, + )?; + registry.insert(identity, Arc::downgrade(&slot)); + slot + } + }; + selected.insert( + leaf.to_vec(), + Entry { + slot, + stapled: Mutex::new(None), + }, + ); + } + Ok(Arc::new(Slots { selected })) + } + pub fn active_identities(&self) -> usize { + self.slots + .lock() + .values() + .filter(|s| s.strong_count() > 0) + .count() + } + /// At most four jobs in flight; callers run this on the application runtime + /// and cancel/drop it during shutdown. Retired generations own their slots + /// only while a resolver still holds that generation, not indefinitely. + pub async fn refresh(&self) { + let slots: Vec<_> = self + .slots + .lock() + .values() + .filter_map(Weak::upgrade) + .collect(); + let mut jobs = tokio::task::JoinSet::new(); + for slot in slots { + if !slot.due() { + continue; + } + if jobs.len() == 4 { + let _ = jobs.join_next().await; + } + let pool = self.pool.clone(); + jobs.spawn(async move { pool.refresh(&slot).await }); + } + while jobs.join_next().await.is_some() {} + } +} + +struct Entry { + slot: Arc, + stapled: Mutex, Arc)>>, +} +pub(crate) struct Slots { + selected: HashMap, Entry>, +} +impl std::fmt::Debug for Slots { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OcspSlots") + .field("identities", &self.selected.len()) + .finish() + } +} +impl Slots { + pub(crate) fn resolve(&self, key: Arc) -> Option> { + let entry = self.selected.get(key.cert.first()?.as_ref())?; + match entry.slot.decision() { + Decision::Reject => None, + Decision::Omit => { + if key.ocsp.is_none() { + Some(key) + } else { + let mut clean = (*key).clone(); + clean.ocsp = None; + Some(Arc::new(clean)) + } + } + Decision::Staple(staple) => { + let bytes = staple.bytes()?; + let mut cached = entry.stapled.lock(); + if let Some((old, key)) = &*cached { + if Arc::ptr_eq(old, &staple) { + return Some(key.clone()); + } + } + let mut next = (*key).clone(); + next.ocsp = Some(bytes.to_vec()); + let next = Arc::new(next); + *cached = Some((staple, next.clone())); + Some(next) + } + } + } +} + +impl CertReloadHandle { + /// Boot-time opt-in only, before listeners begin accepting. Both file and + /// managed generations are prepared before activation; failures publish none. + /// Caller MUST also disable resumption on all configs using this handle. + /// Subsequent reload/ACME replacement carries matching slots atomically with + /// the generation. The manager cannot be swapped or disabled while serving. + pub fn enable_ocsp(&self, manager: Arc) -> Result<()> { + if self.2.load_full().is_some() { + return Err(anyhow!("OCSP manager already installed")); + } + let mut file = (**self.0.load()).clone(); + let mut managed = (**self.1.load()).clone(); + file.stapling = Some( + manager.register( + file.sni + .by_name + .values() + .flatten() + .cloned() + .chain(file.default.iter().cloned()), + )?, + ); + managed.stapling = Some(manager.register(managed.by_name.values().flatten().cloned())?); + self.2.store(Some(manager)); + self.0.store(Arc::new(file)); + self.1.store(Arc::new(managed)); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ReloadableResolver, SniCertMap, SniWithDefault}; + use arc_swap::ArcSwap; + use rcgen::{BasicConstraints, CertificateParams, CertifiedIssuer, IsCa, KeyPair}; + fn ca() -> CertifiedIssuer<'static, KeyPair> { + crate::install_crypto_provider().unwrap(); + let mut params = CertificateParams::new(Vec::::new()).unwrap(); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + CertifiedIssuer::self_signed(params, KeyPair::generate().unwrap()).unwrap() + } + fn key(ca: &CertifiedIssuer<'_, KeyPair>) -> Arc { + let key = KeyPair::generate().unwrap(); + let cert = CertificateParams::new(vec!["example.test".into()]) + .unwrap() + .signed_by(&key, ca) + .unwrap(); + Arc::new( + CertifiedKey::from_der( + vec![cert.der().clone(), ca.der().clone()], + rustls::pki_types::PrivateKeyDer::Pkcs8(key.serialize_der().into()), + &crate::provider().unwrap(), + ) + .unwrap(), + ) + } + fn manager(required: bool) -> Arc { + Stapling::new("http://127.0.0.1:12345/ocsp", true, required).unwrap() + } + #[test] + fn exact_certificate_generations_keep_their_own_slots() { + let ca = ca(); + let a = key(&ca); + let b = key(&ca); + let manager = manager(true); + let first = manager.register(std::iter::once(a.clone())).unwrap(); + let same = manager.register(std::iter::once(a.clone())).unwrap(); + let next = manager.register(std::iter::once(b.clone())).unwrap(); + assert_eq!(manager.active_identities(), 2); + assert!(Arc::ptr_eq( + &first.selected[a.cert[0].as_ref()].slot, + &same.selected[a.cert[0].as_ref()].slot + )); + assert!(!next.selected.contains_key(a.cert[0].as_ref())); + assert!(first.resolve(b).is_none()); + drop(first); + drop(same); + assert_eq!(manager.active_identities(), 1); + } + #[test] + fn identity_budget_is_bounded_and_retired_generations_are_reclaimed() { + let ca = ca(); + let manager = manager(false); + let mut generations = Vec::new(); + for _ in 0..MAX_IDENTITIES { + generations.push(manager.register(std::iter::once(key(&ca))).unwrap()); + } + let next = key(&ca); + assert!(manager.register(std::iter::once(next.clone())).is_err()); + generations.pop(); + let _next = manager.register(std::iter::once(next)).unwrap(); + assert_eq!(manager.active_identities(), MAX_IDENTITIES); + } + #[test] + fn managed_rejection_cannot_fall_back_to_the_file_certificate() { + let ca = ca(); + let key = key(&ca); + let file = SniWithDefault { + sni: SniCertMap::new(), + default: Some(key.clone()), + stapling: None, + }; + let mut managed = SniCertMap::new(); + managed.add("example.test", key.clone()); + let manager = manager(true); + managed.stapling = Some(manager.register(std::iter::once(key.clone())).unwrap()); + let resolver = ReloadableResolver( + Arc::new(ArcSwap::from_pointee(file)), + Arc::new(ArcSwap::from_pointee(managed)), + ); + let mut config = rustls::ServerConfig::builder_with_provider(crate::provider().unwrap()) + .with_safe_default_protocol_versions() + .unwrap() + .with_no_client_auth() + .with_cert_resolver(Arc::new(resolver)); + disable_resumption(&mut config); + assert!(!config.session_storage.can_cache()); + assert!(!config.ticketer.enabled()); + assert_eq!(config.send_tls13_tickets, 0); + let mut roots = rustls::RootCertStore::empty(); + roots.add(ca.der().clone()).unwrap(); + let client_config = rustls::ClientConfig::builder_with_provider(crate::provider().unwrap()) + .with_safe_default_protocol_versions() + .unwrap() + .with_root_certificates(roots) + .with_no_client_auth(); + let mut client = rustls::ClientConnection::new( + Arc::new(client_config), + "example.test".try_into().unwrap(), + ) + .unwrap(); + let mut hello = Vec::new(); + client.write_tls(&mut hello).unwrap(); + let mut server = rustls::ServerConnection::new(Arc::new(config)).unwrap(); + server.read_tls(&mut hello.as_slice()).unwrap(); + assert!(server.process_new_packets().is_err()); + } +} diff --git a/crates/httpjet/Cargo.toml b/crates/httpjet/Cargo.toml index 320138c..229d63a 100644 --- a/crates/httpjet/Cargo.toml +++ b/crates/httpjet/Cargo.toml @@ -12,6 +12,9 @@ name = "httpjet" path = "src/main.rs" [features] +ocsp = ["hj-tls/ocsp"] +acme = ["dep:hj-acme"] +otel = ["dep:opentelemetry", "dep:opentelemetry_sdk", "dep:opentelemetry-otlp", "dep:opentelemetry-http", "dep:reqwest", "dep:opentelemetry-proto", "dep:prost", "dep:futures-executor"] # On-demand CPU profiles via a loopback+secret endpoint (raw pprof protobuf). # Default-OFF so plain `cargo build`/CI stays lean; the PGO/prod build opts in # with `--features profiling`. Zero runtime cost until the endpoint is hit. @@ -20,16 +23,30 @@ profiling = ["dep:pprof"] # measurement harness). Read/reset via the loopback /__alloc-count route. Default-OFF # (one Relaxed atomic per alloc); measurement builds only, never PGO/prod. allocount = [] -# Kernel-TLS egress on the monoio TLS path (STAGED, opt-in at BOTH build AND runtime). +# Kernel-TLS egress on the monoio TLS path (STAGED, build-gated and runtime-policy-gated). # The prod/PGO binary is built WITHOUT this, so its extra dep tree (ktls-sys/nix) and the -# proven userspace TLS path are untouched; the `--ktls` runtime flag is inert unless this -# feature is compiled in. After the rustls handshake on a monoio core the socket fd is -# upgraded to a kTLS socket (kernel encrypt/decrypt) and H1/H2 serve plaintext over the raw -# fd — killing the userspace AEAD copy on large-body egress. Validate on an alt port first. +# proven userspace TLS path are untouched. In a feature build `--ktls=auto` enables only +# for a physical default-route NIC with active TLS TX offload; `on` forces and `off` denies. +# After the rustls handshake on a monoio core the socket fd is +# upgraded to a kTLS socket (kernel encrypt/decrypt), H1/H2 serve plaintext over the raw +# fd, and H1 `Body::File` can use sendfile into NIC TLS TX offload. Validate on an alt port first. ktls = ["dep:ktls-sys", "dep:nix", "dep:aws-lc-rs"] [dependencies] +hj-acme = { path = "../hj-acme", optional = true } +futures-executor = { version = "0.3", optional = true } +opentelemetry-proto = { version = "0.32", default-features = false, features = ["gen-tonic-messages", "metrics", "trace"], optional = true } +prost = { version = "0.14", optional = true } +opentelemetry-http = { version = "0.32", default-features = false, optional = true } +reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"], optional = true } +opentelemetry = { version = "0.32", default-features = false, features = ["trace", "metrics"], optional = true } +opentelemetry_sdk = { version = "0.32", default-features = false, features = ["trace", "metrics"], optional = true } +opentelemetry-otlp = { version = "0.32", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-blocking-client", "reqwest-rustls"], optional = true } sha2 = { workspace = true } +subtle = "2.6" +serde = { workspace = true } +serde_json = "1" +thiserror = { workspace = true } # Optional: in-process sampling profiler → raw pprof protobuf (gated by `profiling`). # Rendering is intentionally left to `go tool pprof` or another external viewer, # keeping the CDDL-licensed inferno renderer out of httpjet's dependency graph. @@ -80,15 +97,19 @@ aws-lc-rs = { version = "1", optional = true } libc = { version = "0.2" } hj-config = { workspace = true } hj-core = { workspace = true } +hj-extension = { workspace = true } hj-http = { workspace = true } hj-static = { workspace = true } hj-lsapi = { workspace = true } +hj-fastcgi = { workspace = true } hj-proxy = { workspace = true } hj-rewrite = { workspace = true } hj-acl = { workspace = true } hj-geo = { workspace = true } hj-cache = { workspace = true } hj-compress = { workspace = true } +brotli = { workspace = true } +zstd = { workspace = true } hj-pagecache = { workspace = true } hj-log = { workspace = true } hj-tls = { workspace = true } diff --git a/crates/httpjet/src/acme_runtime.rs b/crates/httpjet/src/acme_runtime.rs new file mode 100644 index 0000000..f3b8fe5 --- /dev/null +++ b/crates/httpjet/src/acme_runtime.rs @@ -0,0 +1,479 @@ +//! Opt-in ACME lifecycle. Nothing runs without explicit CLI configuration. +use hj_acme::{ + AcmeConfig, AcmeManager, CertificateValidator, ChallengeRegistry, Directory, ManagerError, +}; +use hj_config::model::{Listener, ServerConfig}; +use std::{ + net::SocketAddr, + path::PathBuf, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +#[derive(clap::Args, Debug, Default)] +pub(crate) struct AcmeArgs { + #[arg(long, requires_all = ["acme_domains", "acme_store", "acme_accept_terms"])] + acme_directory: Option, + #[arg(long, value_delimiter = ',', requires = "acme_directory")] + acme_domains: Vec, + #[arg(long, requires = "acme_directory")] + acme_store: Option, + #[arg(long, requires = "acme_directory")] + acme_accept_terms: bool, + /// Start with no listener default certificate; TLS fails until ACME succeeds. + #[arg(long, requires = "acme_directory")] + pub acme_bootstrap: bool, + /// Permit loopback CA HTTP and alternate loopback validation ports for tests. + #[arg(long, requires = "acme_directory")] + acme_test_mode: bool, + #[arg(long, requires = "acme_test_mode")] + acme_test_ca_root: Option, + #[arg(long, requires = "acme_test_mode")] + acme_test_issuer_root: Option, + /// Recover an uncertain newOrder URL from the same account's CA records. + #[arg(long, requires = "acme_directory")] + acme_reconcile_order: Option, + /// Provider-neutral authenticated DNS controller endpoint (selects DNS-01). + #[arg(long, requires_all = ["acme_directory", "acme_dns_zones"])] + acme_dns_webhook: Option, + #[arg(long, value_delimiter = ',', requires = "acme_dns_webhook")] + acme_dns_zones: Vec, + #[arg(long, requires = "acme_dns_webhook")] + acme_dns_token_file: Option, + #[arg(long, requires_all = ["acme_dns_webhook", "acme_test_mode"])] + acme_dns_test_root: Option, +} + +pub(crate) struct Routing { + registry: ChallengeRegistry, + listener: String, + secure_listener: String, + bindings: Vec<(String, String)>, + dns01: bool, +} +impl Routing { + pub fn validate_reload(&self, server: Arc) -> Result<(), String> { + let router = hj_core::Router::build(server); + for (domain, vhost) in &self.bindings { + for listener in [&self.listener, &self.secure_listener] { + if self.dns01 && listener == &self.listener { + continue; + } + if !router + .resolve(listener, Some(domain)) + .is_some_and(|r| &r.name == vhost) + { + return Err( + "ACME domain/listener bindings are boot-frozen; restart to change them" + .into(), + ); + } + } + } + Ok(()) + } + pub fn response( + &self, + listener: &str, + request: &hj_core::Request, + is_tls: bool, + ) -> Option { + if self.dns01 || listener != self.listener || is_tls { + return None; + } + let authority = request + .headers() + .get(http::header::HOST) + .and_then(|v| v.to_str().ok()) + .or_else(|| request.uri().authority().map(|a| a.as_str()))?; + let result = self + .registry + .lookup(request.method(), authority, request.uri(), is_tls)?; + if request.headers().get_all(http::header::HOST).iter().count() > 1 { + return Some( + http::Response::builder() + .status(400) + .header("cache-control", "no-store") + .body(hj_core::Body::Empty) + .unwrap(), + ); + } + let mut response = http::Response::builder().status(result.status); + *response.headers_mut().unwrap() = result.headers(); + Some( + response + .body(hj_core::Body::Full(bytes::Bytes::copy_from_slice( + result.body.as_bytes(), + ))) + .unwrap(), + ) + } +} + +pub(crate) struct Prepared { + manager: AcmeManager, + validator: CertificateValidator, + domains: Vec, + targets: CertificateTargets, + pub routing: Arc, +} + +/// Active certificate resolvers selected at the resource-publication boundary. +/// Publication and renewal serialize here so a candidate receives the latest +/// managed certificate before it becomes reachable. Retired generations are +/// not updated because their acceptors have already stopped. +#[derive(Clone, Default)] +pub(crate) struct CertificateTargets(Arc>); + +#[derive(Default)] +struct CertificateTargetState { + handles: Vec<(Arc, hj_tls::CertReloadHandle)>, + managed: Option<(Vec, Arc)>, +} + +impl CertificateTargets { + pub(crate) fn replace( + &self, + handles: Vec<(Arc, hj_tls::CertReloadHandle)>, + ) -> anyhow::Result<()> { + let mut state = self.0.write().expect("ACME target lock poisoned"); + if let Some((domains, key)) = &state.managed { + for (_, handle) in &handles { + handle.replace_managed(domains, key.clone())?; + } + } + state.handles = handles; + Ok(()) + } + + fn install( + &self, + domains: &[String], + key: Arc, + ) -> anyhow::Result<()> { + let mut state = self.0.write().expect("ACME target lock poisoned"); + for (_, handle) in &state.handles { + handle.replace_managed(domains, key.clone())?; + } + state.managed = Some((domains.to_vec(), key)); + Ok(()) + } + + #[cfg(test)] + pub(crate) fn names(&self) -> Vec> { + self.0 + .read() + .expect("ACME target lock poisoned") + .handles + .iter() + .map(|(name, _)| name.clone()) + .collect() + } +} +fn root(path: Option<&PathBuf>) -> anyhow::Result>> { + use std::io::Read; + path.map(|path| { + let file = std::fs::File::open(path)?; + let mut bytes = Vec::new(); + file.take(65537).read_to_end(&mut bytes)?; + anyhow::ensure!(bytes.len() <= 65536, "ACME test root exceeds limit"); + Ok(bytes) + }) + .transpose() +} + +fn dns_token(path: Option<&PathBuf>) -> anyhow::Result> { + use rustix::fs::{Mode, OFlags}; + use std::{io::Read, os::unix::fs::MetadataExt}; + path.map(|path| { + anyhow::ensure!(path.is_absolute(), "DNS credential path must be absolute"); + let file = std::fs::File::from(rustix::fs::open( + path, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC, + Mode::empty(), + )?); + let metadata = file.metadata()?; + anyhow::ensure!( + metadata.is_file() + && metadata.uid() == rustix::process::geteuid().as_raw() + && metadata.mode() & 0o7777 == 0o600 + && metadata.nlink() == 1 + && metadata.len() <= 4096, + "unsafe DNS credential metadata" + ); + let mut bytes = Vec::new(); + file.take(4097).read_to_end(&mut bytes)?; + anyhow::ensure!(bytes.len() <= 4096, "DNS credential exceeds limit"); + let token = String::from_utf8(bytes) + .map_err(|_| anyhow::anyhow!("DNS credential must be UTF-8"))?; + Ok(token.trim_end_matches(['\r', '\n']).to_owned()) + }) + .transpose() +} +impl Prepared { + pub fn open( + args: &AcmeArgs, + server: Arc, + http_listener: &str, + secure: Option<&Listener>, + http_addr: SocketAddr, + https_addr: Option, + ) -> anyhow::Result> { + let Some(directory) = &args.acme_directory else { + return Ok(None); + }; + let secure = + secure.ok_or_else(|| anyhow::anyhow!("ACME requires a configured TLS listener"))?; + anyhow::ensure!( + https_addr.is_some(), + "ACME requires the TLS listener to be enabled" + ); + if args.acme_test_mode { + anyhow::ensure!( + http_addr.ip().is_loopback() && https_addr.unwrap().ip().is_loopback(), + "ACME test mode requires loopback listeners" + ); + } else if args.acme_dns_webhook.is_none() { + anyhow::ensure!(http_addr.port() == 80, "HTTP-01 requires public port 80"); + } + let config_builder = if args.acme_dns_webhook.is_some() { + AcmeConfig::new_dns01 + } else { + AcmeConfig::new + }; + let config = config_builder( + Directory::parse(directory, args.acme_test_mode)?, + &args + .acme_domains + .iter() + .map(String::as_str) + .collect::>(), + args.acme_store + .clone() + .ok_or_else(|| anyhow::anyhow!("missing ACME store"))?, + args.acme_accept_terms, + )?; + let router = hj_core::Router::build(server); + let mut bindings = Vec::new(); + for domain in config.domains() { + let name = domain.verification_name(); + let tls = router + .resolve(&secure.name, Some(&name)) + .ok_or_else(|| anyhow::anyhow!("ACME identifier has no TLS vhost"))?; + if !config.is_dns01() { + let plain = router + .resolve(http_listener, Some(&name)) + .ok_or_else(|| anyhow::anyhow!("ACME identifier has no HTTP vhost"))?; + anyhow::ensure!( + plain.name == tls.name, + "ACME HTTP/TLS vhost mapping differs" + ); + } + bindings.push((name, tls.name.clone())); + } + let ca_root = root(args.acme_test_ca_root.as_ref())?; + let issuer_root = root(args.acme_test_issuer_root.as_ref())?; + let validator = CertificateValidator::new(issuer_root.as_deref())?; + let domains = config + .domains() + .iter() + .map(|d| d.as_str().to_owned()) + .collect(); + let dns01 = config.is_dns01(); + let mut manager = if let Some(endpoint) = &args.acme_dns_webhook { + let scope = hj_acme::DnsScope::new( + &args + .acme_dns_zones + .iter() + .map(String::as_str) + .collect::>(), + )?; + let dns_root = root(args.acme_dns_test_root.as_ref())?; + let provider = hj_acme::WebhookDnsProvider::new( + Directory::parse(endpoint, args.acme_test_mode)?, + scope, + dns_token(args.acme_dns_token_file.as_ref())?, + dns_root.as_deref(), + )?; + AcmeManager::open_dns01(config, ca_root.as_deref(), Arc::new(provider))? + } else { + AcmeManager::open(config, ca_root.as_deref())? + }; + if let Some(url) = &args.acme_reconcile_order { + manager.reconcile_order(url)?; + } + let routing = Arc::new(Routing { + registry: manager.challenges(), + listener: http_listener.to_owned(), + secure_listener: secure.name.clone(), + bindings, + dns01, + }); + Ok(Some(Self { + manager, + validator, + domains, + targets: CertificateTargets::default(), + routing, + })) + } + pub fn attach_handles( + &mut self, + handles: Vec<(Arc, hj_tls::CertReloadHandle)>, + ) -> anyhow::Result<()> { + anyhow::ensure!( + !handles.is_empty(), + "ACME requires a live certificate resolver" + ); + self.targets.replace(handles)?; + if let Some(pair) = self.manager.installed(&self.validator)? { + self.targets.install(&self.domains, pair.certified_key())?; + } + Ok(()) + } + pub(crate) fn certificate_targets(&self) -> CertificateTargets { + self.targets.clone() + } + pub async fn run( + mut self, + shutdown: tokio_util::sync::CancellationToken, + ) -> anyhow::Result<()> { + // Replay crash-left cleanup immediately, not at the next renewal date. + // A failed cleanup leaves its journal intact and stops new mutations. + self.manager.cleanup_dns().await?; + loop { + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let wait = self.manager.next_attempt_unix().saturating_sub(now); + tokio::select! { _ = shutdown.cancelled() => { self.manager.cleanup_dns().await?; return Ok(()); }, _ = tokio::time::sleep(Duration::from_secs(wait.min(3600))) => {} } + if wait > 3600 { + continue; + } + let result = tokio::select! { _ = shutdown.cancelled() => None, result = self.manager.issue() => Some(result) }; + let Some(result) = result else { + self.manager.cleanup_dns().await?; + return Ok(()); + }; + match result { + Ok(issued) => match self.manager.install(issued, &self.validator) { + Ok(pair) => { + self.targets.install(&self.domains, pair.certified_key())?; + tracing::info!("ACME certificate generation committed and activated"); + } + Err(error) => { + tracing::error!(%error, "ACME installation failed; previous live certificate retained"); + if error == ManagerError::Storage { + return Err(error.into()); + } + } + }, + Err(error) => { + tracing::warn!(%error, "ACME attempt did not complete"); + if matches!( + error, + ManagerError::Storage | ManagerError::State | ManagerError::UncertainOrder + ) { + return Err(error.into()); + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http_body_util::BodyExt; + #[test] + fn dns_credentials_require_private_regular_nonlinked_files() { + use std::{ + fs, + os::unix::fs::{DirBuilderExt, PermissionsExt, symlink}, + }; + let root = + std::env::temp_dir().join(format!("hj-dns-credential-test-{}", std::process::id())); + fs::DirBuilder::new().mode(0o700).create(&root).unwrap(); + let token = root.join("token"); + fs::write(&token, "test-credential-with-enough-bytes\n").unwrap(); + fs::set_permissions(&token, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(dns_token(Some(&token)).is_err()); + fs::set_permissions(&token, fs::Permissions::from_mode(0o600)).unwrap(); + assert_eq!( + dns_token(Some(&token)).unwrap().as_deref(), + Some("test-credential-with-enough-bytes") + ); + let alias = root.join("alias"); + symlink(&token, &alias).unwrap(); + assert!(dns_token(Some(&alias)).is_err()); + fs::remove_file(&alias).unwrap(); + fs::hard_link(&token, &alias).unwrap(); + assert!(dns_token(Some(&token)).is_err()); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn routing_reserves_raw_challenges_only_on_its_plain_listener() { + let config = AcmeConfig::new( + Directory::parse("https://ca.test/dir", false).unwrap(), + &["example.test"], + "/tmp/unused-acme".into(), + true, + ) + .unwrap(); + let registry = ChallengeRegistry::new(&config); + let domain = hj_acme::Domain::parse("example.test").unwrap(); + let token = "A".repeat(22); + let _lease = registry + .register(&domain, &token, &"A".repeat(43), Duration::from_secs(60)) + .unwrap(); + let routing = Routing { + registry, + listener: "http".into(), + secure_listener: "https".into(), + bindings: vec![], + dns01: false, + }; + let request = |path: &str| { + http::Request::builder() + .uri(path) + .header("host", "example.test") + .body( + http_body_util::Empty::::new() + .map_err(|never| match never {}) + .boxed(), + ) + .unwrap() + }; + let path = format!("/.well-known/acme-challenge/{token}"); + assert_eq!( + routing + .response("http", &request(&path), false) + .unwrap() + .status(), + 200 + ); + assert!(routing.response("http", &request(&path), true).is_none()); + assert!(routing.response("other", &request(&path), false).is_none()); + for malformed in [ + format!("{path}?"), + "/.well-known/acme-challenge/../index.php".into(), + "/.well-known/acme-challenge/%2e%2e/index.php".into(), + ] { + let response = routing + .response("http", &request(&malformed), false) + .unwrap(); + assert_eq!(response.status(), 404); + assert_eq!(response.headers()["cache-control"], "no-store"); + } + let mut duplicate = request(&path); + duplicate + .headers_mut() + .append("host", "other.test".parse().unwrap()); + assert_eq!( + routing + .response("http", &duplicate, false) + .unwrap() + .status(), + 400 + ); + } +} diff --git a/crates/httpjet/src/admin.rs b/crates/httpjet/src/admin.rs new file mode 100644 index 0000000..4462dee --- /dev/null +++ b/crates/httpjet/src/admin.rs @@ -0,0 +1,216 @@ +//! Independent, opt-in read-only operational endpoint. Never used by the request pipeline. +use crate::state::ServerState; +use arc_swap::ArcSwap; +use sha2::{Digest, Sha256}; +use std::{fmt::Write as _, net::SocketAddr, sync::Arc, time::Duration}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, +}; + +const ROWS: usize = 256; +const MAX_BODY: usize = 1024 * 1024; + +pub fn parse_addr(value: &str) -> Result { + let addr: SocketAddr = value + .parse() + .map_err(|_| "expected a loopback socket address")?; + if !addr.ip().is_loopback() { + return Err("admin listener must bind loopback".into()); + } + Ok(addr) +} + +/// Version-local fingerprint of the normalized configuration, not certificate contents. +/// Stream Debug into a digest: do not create a plaintext copy of configuration secrets. +pub fn fingerprint(config: &hj_core::config::ServerConfig) -> String { + struct Sink(Sha256); + impl std::fmt::Write for Sink { + fn write_str(&mut self, value: &str) -> std::fmt::Result { + self.0.update(value.as_bytes()); + Ok(()) + } + } + let mut sink = Sink(Sha256::new()); + write!(&mut sink, "{config:?}").expect("digest writer cannot fail"); + format!("{:x}", sink.0.finalize()) +} + +fn quoted(value: &str) -> String { + let mut out = String::from("\""); + for c in value.chars().take(256) { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + c if c < ' ' => { + let _ = write!(out, "\\u{:04x}", c as u32); + } + c => out.push(c), + } + } + out.push('"'); + out +} + +fn snapshot(state: &ServerState) -> String { + let listeners = state + .server + .listeners + .iter() + .take(ROWS) + .map(|l| { + format!( + "{{\"name\":{},\"tls\":{},\"unix\":{}}}", + quoted(&l.name), + l.secure, + l.uds_path.is_some() + ) + }) + .collect::>() + .join(","); + let vhosts = state + .server + .vhosts + .keys() + .take(ROWS) + .map(|s| quoted(s)) + .collect::>() + .join(","); + let peers = state.proxy.pool().peer_snapshots(); + let upstreams = peers.iter().take(ROWS).map(|p| format!( + "{{\"scope\":{},\"group\":{},\"peer\":{},\"healthy\":{},\"active\":{},\"selections\":{}}}", + quoted(&p.scope), quoted(&p.group), p.peer, p.healthy, p.active, p.selections + )).collect::>().join(","); + let cache = state + .page_cache + .as_ref() + .map(|c| { + let s = c.stats(); + format!( + "{{\"entries\":{},\"memory_bytes\":{},\"hits\":{},\"misses\":{}}}", + s.entries, s.memory_bytes, s.hits, s.misses + ) + }) + .unwrap_or_else(|| "null".into()); + format!( + "{{\"schema_version\":1,\"generation\":{},\"config_fingerprint\":{},\"truncated\":{},\"listeners\":[{}],\"vhosts\":[{}],\"upstreams\":[{}],\"page_cache\":{}}}", + state.generation, + quoted(&state.config_fingerprint), + state.server.listeners.len() > ROWS + || state.server.vhosts.len() > ROWS + || peers.len() > ROWS, + listeners, + vhosts, + upstreams, + cache + ) +} + +fn classify(buf: &[u8]) -> u16 { + let mut headers = [httparse::EMPTY_HEADER; 32]; + let mut req = httparse::Request::new(&mut headers); + if !matches!(req.parse(buf), Ok(httparse::Status::Complete(n)) if n == buf.len()) { + return 400; + } + // No browser-origin access, upload framing, pipelining or mutable methods. + if req.headers.iter().any(|h| { + h.name.eq_ignore_ascii_case("origin") + || h.name.eq_ignore_ascii_case("transfer-encoding") + || h.name.eq_ignore_ascii_case("content-length") + }) { + return 400; + } + let hosts: Vec<_> = req + .headers + .iter() + .filter(|h| h.name.eq_ignore_ascii_case("host")) + .collect(); + if hosts.len() != 1 { + return 400; + } + let host = std::str::from_utf8(hosts[0].value).unwrap_or(""); + if !host + .parse::() + .is_ok_and(|a| a.ip().is_loopback()) + && host != "localhost" + { + return 400; + } + if req.method != Some("GET") { + return 405; + } + if req.path != Some("/v1/status") { + return 404; + } + 200 +} + +pub async fn serve(listener: TcpListener, holder: Arc>) { + let mut tasks = tokio::task::JoinSet::new(); + loop { + tokio::select! { + Some(_) = tasks.join_next(), if !tasks.is_empty() => {}, + accepted = listener.accept(), if tasks.len() < 4 => { + let Ok((mut stream, peer)) = accepted else { break; }; + if !peer.ip().is_loopback() { continue; } + let holder = holder.clone(); + tasks.spawn(async move { + let _ = tokio::time::timeout(Duration::from_secs(5), async { + let mut buf = Vec::new(); + let mut byte = [0]; + while buf.len() < 4096 && !buf.ends_with(b"\r\n\r\n") { + if stream.read(&mut byte).await? == 0 { return Ok::<_, std::io::Error>(()); } + buf.push(byte[0]); + } + let mut status = classify(&buf); + let mut body = if status == 200 { snapshot(&holder.load_full()) } else { "{}".into() }; + if body.len() > MAX_BODY { status = 503; body = "{}".into(); } + let head = format!("HTTP/1.1 {status} Response\r\nContent-Type: application/json\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); + stream.write_all(head.as_bytes()).await?; + stream.write_all(body.as_bytes()).await?; + stream.shutdown().await + }).await; + }); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn bind_and_request_boundaries() { + assert!(parse_addr("127.0.0.1:0").is_ok()); + assert!(parse_addr("0.0.0.0:9091").is_err()); + assert_eq!( + classify(b"GET /v1/status HTTP/1.1\r\nHost: 127.0.0.1:9091\r\n\r\n"), + 200 + ); + for bad in [ + "Origin: https://evil.test\r\n", + "Content-Length: 0\r\n", + "Transfer-Encoding: chunked\r\n", + ] { + assert_eq!( + classify( + format!("GET /v1/status HTTP/1.1\r\nHost: localhost\r\n{bad}\r\n").as_bytes() + ), + 400 + ); + } + assert_eq!( + classify(b"POST /v1/status HTTP/1.1\r\nHost: localhost\r\n\r\n"), + 405 + ); + assert_eq!(quoted("a\n\"\\"), "\"a\\u000a\\\"\\\\\""); + } + #[test] + fn fingerprints_are_repeatable_and_sensitive() { + let mut c = hj_core::config::ServerConfig::default(); + assert_eq!(fingerprint(&c), fingerprint(&c)); + let before = fingerprint(&c); + c.server_name = "changed".into(); + assert_ne!(before, fingerprint(&c)); + } +} diff --git a/crates/httpjet/src/admin_auth.rs b/crates/httpjet/src/admin_auth.rs new file mode 100644 index 0000000..605d8e1 --- /dev/null +++ b/crates/httpjet/src/admin_auth.rs @@ -0,0 +1,195 @@ +//! Credential loading for the opt-in local configuration writer. +//! Never log file contents or retain a plaintext token in the authentication state. +use sha2::{Digest, Sha256}; +use std::{ + fs::OpenOptions, + io::Read, + os::unix::fs::{MetadataExt, OpenOptionsExt}, + path::Path, +}; +use subtle::ConstantTimeEq; + +pub(crate) struct AuthToken([u8; 32]); + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct InvalidToken; + +impl std::fmt::Display for InvalidToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("admin token file or credential rejected") + } +} +impl std::error::Error for InvalidToken {} +impl std::fmt::Debug for AuthToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("AuthToken([REDACTED])") + } +} + +fn canonical(token: &[u8]) -> bool { + token.len() == 64 + && token + .iter() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(b)) +} + +impl AuthToken { + #[cfg(test)] + pub(crate) fn fixture(value: &[u8]) -> Self { + assert!(canonical(value)); + Self(Sha256::digest(value).into()) + } + /// Token = 32 random bytes rendered as 64 lowercase hex characters, with an + /// optional final LF. The path must reside in an operator-controlled directory. + /// O_NOFOLLOW rejects a final-component symlink; metadata is checked on the + /// opened descriptor, not through a racy path-based preflight check. + pub(crate) fn load(path: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(path) + .map_err(|_| InvalidToken)?; + let metadata = file.metadata().map_err(|_| InvalidToken)?; + // SAFETY: geteuid has no preconditions and does not mutate process identity. + let euid = unsafe { libc::geteuid() }; + if !metadata.is_file() + || !matches!(metadata.mode() & 0o7777, 0o400 | 0o600) + || (metadata.uid() != 0 && metadata.uid() != euid) + || metadata.nlink() != 1 + || !matches!(metadata.len(), 64 | 65) + { + return Err(InvalidToken); + } + // Bounded even if an authorized owner changes the file after metadata. + let mut bytes = Vec::with_capacity(66); + file.take(66) + .read_to_end(&mut bytes) + .map_err(|_| InvalidToken)?; + let value = bytes.strip_suffix(b"\n").unwrap_or(&bytes); + if !canonical(value) { + return Err(InvalidToken); + } + Ok(Self(Sha256::digest(value).into())) + } + + pub(crate) fn authenticate(&self, headers: &[httparse::Header<'_>]) -> bool { + let mut values = headers + .iter() + .filter(|h| h.name.eq_ignore_ascii_case("authorization")); + let Some(value) = values.next() else { + return false; + }; + values.next().is_none() && self.accepts(value.value) + } + + /// Reject malformed input before hashing; fixed-size digest comparison uses + /// a maintained constant-time primitive, not an early-exit byte comparison. + fn accepts(&self, header: &[u8]) -> bool { + if header.len() != 71 || !header[..7].eq_ignore_ascii_case(b"Bearer ") { + return false; + } + let value = &header[7..]; + if !canonical(value) { + return false; + } + let digest: [u8; 32] = Sha256::digest(value).into(); + bool::from(self.0.ct_eq(&digest)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + os::unix::fs::{PermissionsExt, symlink}, + path::PathBuf, + sync::atomic::{AtomicUsize, Ordering}, + }; + const TOKEN: &[u8] = b"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + static SEQUENCE: AtomicUsize = AtomicUsize::new(0); + struct Fixture(PathBuf); + impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "hj-admin-auth-{}-{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap(); + Self(path) + } + fn file(&self, bytes: &[u8], mode: u32) -> PathBuf { + let path = self.0.join("token"); + fs::write(&path, bytes).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap(); + path + } + } + impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } + } + #[test] + fn permissions_link_types_and_content_fail_closed() { + let fixture = Fixture::new(); + for mode in [0o644, 0o640, 0o666, 0o700, 0o4600] { + assert!(AuthToken::load(&fixture.file(TOKEN, mode)).is_err()); + } + for value in [ + b"".as_slice(), + b"short", + &[b'a'; 66], + &[b'G'; 64], + &[b'a'; 63], + ] { + assert!(AuthToken::load(&fixture.file(value, 0o600)).is_err()); + } + let path = fixture.file(TOKEN, 0o600); + let link = fixture.0.join("link"); + symlink(&path, &link).unwrap(); + assert!(AuthToken::load(&link).is_err()); + fs::remove_file(&link).unwrap(); + fs::hard_link(&path, &link).unwrap(); + assert!(AuthToken::load(&path).is_err()); + assert!(AuthToken::load(&fixture.0).is_err()); + } + #[test] + fn exact_bearer_credentials_and_redacted_debug() { + let fixture = Fixture::new(); + for mode in [0o400, 0o600] { + let mut value = TOKEN.to_vec(); + value.push(b'\n'); + let token = AuthToken::load(&fixture.file(&value, mode)).unwrap(); + let mut header = b"Bearer ".to_vec(); + header.extend_from_slice(TOKEN); + assert!(token.accepts(&header)); + let field = httparse::Header { + name: "Authorization", + value: &header, + }; + assert!(token.authenticate(&[field])); + assert!(!token.authenticate(&[])); + assert!(!token.authenticate(&[ + field, + httparse::Header { + name: "aUtHoRiZaTiOn", + value: &header + } + ])); + assert_eq!(format!("{token:?}"), "AuthToken([REDACTED])"); + for index in [7, 38, 70] { + let mut wrong = header.clone(); + wrong[index] = if wrong[index] == b'a' { b'b' } else { b'a' }; + assert!(!token.accepts(&wrong)); + } + for bad in [b"".as_slice(), TOKEN, b"Basic abc", b"Bearer short"] { + assert!(!token.accepts(bad)); + } + header.push(b' '); + assert!(!token.accepts(&header)); + } + } +} diff --git a/crates/httpjet/src/admin_protocol.rs b/crates/httpjet/src/admin_protocol.rs new file mode 100644 index 0000000..f6cf016 --- /dev/null +++ b/crates/httpjet/src/admin_protocol.rs @@ -0,0 +1,300 @@ +//! Bounded, one-request control protocol. Never connected to public serving. +use crate::admin_auth::AuthToken; +use std::{net::SocketAddr, time::Duration}; +use tokio::io::{AsyncRead, AsyncReadExt}; + +const MAX_HEAD: usize = 8192; +pub(crate) const MAX_BODY: usize = 1024 * 1024; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Operation { + Revision, + Validate, + Publish, +} + +pub(crate) struct Request { + pub(crate) operation: Operation, + pub(crate) revision: Option, + pub(crate) body: Vec, +} + +struct Head { + operation: Operation, + revision: Option, + length: usize, +} + +fn single<'a>(headers: &[httparse::Header<'a>], name: &str) -> Result, u16> { + let mut found = headers.iter().filter(|h| h.name.eq_ignore_ascii_case(name)); + let value = found.next().map(|h| h.value); + if found.next().is_some() { + return Err(400); + } + Ok(value) +} + +fn revision(value: &[u8]) -> Result { + let text = std::str::from_utf8(value).map_err(|_| 400_u16)?; + let inner = text + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .ok_or(400_u16)?; + let (incarnation, number) = inner.split_once('-').ok_or(400_u16)?; + if incarnation.len() != 32 + || !incarnation + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + || number.is_empty() + || number.starts_with('0') + || !number.bytes().all(|b| b.is_ascii_digit()) + || number.parse::().is_err() + { + return Err(400); + } + Ok(inner.to_owned()) +} + +fn parse(bytes: &[u8], local: SocketAddr, token: &AuthToken) -> Result { + if bytes.len() > MAX_HEAD { + return Err(431); + } + let mut headers = [httparse::EMPTY_HEADER; 32]; + let mut request = httparse::Request::new(&mut headers); + match request.parse(bytes) { + Ok(httparse::Status::Complete(n)) if n == bytes.len() => {} + _ => return Err(400), + } + if request.version != Some(1) || !local.ip().is_loopback() { + return Err(400); + } + let host = single(request.headers, "host")?.ok_or(400_u16)?; + let host = std::str::from_utf8(host).map_err(|_| 400_u16)?; + if host != format!("localhost:{}", local.port()) + && host.parse::().ok() != Some(local) + { + return Err(400); + } + if request.headers.iter().any(|h| { + [ + "origin", + "transfer-encoding", + "content-encoding", + "expect", + "upgrade", + "trailer", + ] + .iter() + .any(|name| h.name.eq_ignore_ascii_case(name)) + }) { + return Err(400); + } + // Reject before waiting for or allocating the submitted configuration body. + if !token.authenticate(request.headers) { + return Err(401); + } + let operation = match (request.method, request.path) { + (Some("GET"), Some("/v1/revision")) => Operation::Revision, + (Some("POST"), Some("/v1/config/validate")) => Operation::Validate, + (Some("PUT"), Some("/v1/config")) => Operation::Publish, + (_, Some("/v1/revision" | "/v1/config/validate" | "/v1/config")) => return Err(405), + _ => return Err(404), + }; + let length = single(request.headers, "content-length")?; + let expected = single(request.headers, "if-match")?; + if operation == Operation::Revision { + if length.is_some() || expected.is_some() { + return Err(400); + } + return Ok(Head { + operation, + revision: None, + length: 0, + }); + } + let expected = revision(expected.ok_or(428_u16)?)?; + if single(request.headers, "content-type")? != Some(b"application/json".as_slice()) { + return Err(415); + } + let length = length.ok_or(411_u16)?; + if length.is_empty() || !length.iter().all(u8::is_ascii_digit) { + return Err(400); + } + let length = std::str::from_utf8(length) + .ok() + .and_then(|s| s.parse::().ok()) + .ok_or(413_u16)?; + if length == 0 { + return Err(400); + } + if length > MAX_BODY { + return Err(413); + } + Ok(Head { + operation, + revision: Some(expected), + length, + }) +} + +/// Caller admits a bounded number of connections and closes after one response. +/// Extra/pipelined requests are never processed. The deadline covers header and +/// body together, so a slow sender cannot reset its budget one byte at a time. +pub(crate) async fn receive( + stream: &mut S, + local: SocketAddr, + token: &AuthToken, +) -> Result { + tokio::time::timeout(Duration::from_secs(5), async { + let mut bytes = Vec::with_capacity(1024); + while !bytes.ends_with(b"\r\n\r\n") { + if bytes.len() == MAX_HEAD { + return Err(431); + } + let byte = stream.read_u8().await.map_err(|_| 400_u16)?; + bytes.push(byte); + } + let head = parse(&bytes, local, token)?; + let mut body = vec![0; head.length]; + stream.read_exact(&mut body).await.map_err(|_| 400_u16)?; + Ok(Request { + operation: head.operation, + revision: head.revision, + body, + }) + }) + .await + .map_err(|_| 408_u16)? +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::AsyncWriteExt; + const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const REV: &str = "0123456789abcdef0123456789abcdef-1"; + fn local() -> SocketAddr { + "127.0.0.1:12345".parse().unwrap() + } + fn head(method: &str, path: &str, extras: &str) -> String { + format!( + "{method} {path} HTTP/1.1\r\nHost: {}\r\nAuthorization: Bearer {TOKEN}\r\n{extras}\r\n", + local() + ) + } + fn upload(extra: &str) -> String { + head( + "PUT", + "/v1/config", + &format!( + "If-Match: \"{REV}\"\r\nContent-Type: application/json\r\nContent-Length: 2\r\n{extra}" + ), + ) + } + #[test] + fn framing_authentication_preconditions_and_origin_boundaries() { + let auth = AuthToken::fixture(TOKEN.as_bytes()); + let valid = upload(""); + let parsed = parse(valid.as_bytes(), local(), &auth).unwrap(); + assert_eq!(parsed.operation, Operation::Publish); + assert_eq!(parsed.revision.as_deref(), Some(REV)); + for extra in [ + "Origin: null\r\n", + "Transfer-Encoding: chunked\r\n", + "Content-Encoding: gzip\r\n", + "Expect: 100-continue\r\n", + "Content-Length: 2\r\n", + "Host: localhost:12345\r\n", + "If-Match: *\r\n", + ] { + assert!( + parse(upload(extra).as_bytes(), local(), &auth).is_err(), + "{extra}" + ); + } + for (bad, expected) in [ + ( + valid.replace(&format!("Authorization: Bearer {TOKEN}\r\n"), ""), + 401, + ), + (upload(&format!("Authorization: Bearer {TOKEN}\r\n")), 401), + (valid.replace(&format!("If-Match: \"{REV}\"\r\n"), ""), 428), + ( + valid.replace("Content-Length: 2", "Content-Length: 1048577"), + 413, + ), + ( + valid.replace("Content-Length: 2", "Content-Length: +2"), + 400, + ), + (valid.replace("application/json", "text/plain"), 415), + (valid.replace("127.0.0.1:12345", "example.com"), 400), + (valid.replace("127.0.0.1:12345", "127.0.0.1:80"), 400), + (valid.replace("/v1/config", "/v1/config?token=x"), 404), + (format!("{valid}GET / HTTP/1.1\r\n\r\n"), 400), + ] { + assert_eq!(parse(bad.as_bytes(), local(), &auth).err(), Some(expected)); + } + for bad in [ + "*", + "W/\"x\"", + "\"0123456789abcdef0123456789abcdef-01\"", + "\"0123456789abcdef0123456789abcdef-18446744073709551616\"", + ] { + assert!(revision(bad.as_bytes()).is_err()); + } + } + #[tokio::test] + async fn bounded_reader_authenticates_before_body_and_reads_exact_length() { + let auth = AuthToken::fixture(TOKEN.as_bytes()); + let (mut client, mut server) = tokio::io::duplex(4096); + let unauthorized = upload("").replace(TOKEN, &"a".repeat(64)); + client.write_all(unauthorized.as_bytes()).await.unwrap(); + // Keep the sender open without a body; authentication must not wait. + let result = tokio::time::timeout( + Duration::from_millis(250), + receive(&mut server, local(), &auth), + ) + .await + .unwrap(); + assert_eq!(result.err(), Some(401)); + let mut valid = format!("{}{{}}ignored-pipeline", upload("")).into_bytes(); + let request = receive(&mut valid.as_slice(), local(), &auth) + .await + .unwrap(); + assert_eq!(request.body, b"{}"); + valid.clear(); + assert_eq!( + receive(&mut valid.as_slice(), local(), &auth).await.err(), + Some(400) + ); + } + + #[tokio::test] + async fn incomplete_header_and_body_share_a_bounded_deadline() { + let auth = AuthToken::fixture(TOKEN.as_bytes()); + let (mut header_client, mut header_server) = tokio::io::duplex(4096); + let (mut body_client, mut body_server) = tokio::io::duplex(4096); + header_client + .write_all(b"PUT /v1/config HTTP/1.1\r\n") + .await + .unwrap(); + body_client.write_all(upload("").as_bytes()).await.unwrap(); + body_client.write_all(b"{").await.unwrap(); + let (header, body) = tokio::join!( + receive(&mut header_server, local(), &auth), + receive(&mut body_server, local(), &auth), + ); + assert_eq!(header.err(), Some(408)); + assert_eq!(body.err(), Some(408)); + // The clients remained connected; these are deadlines, not EOF failures. + drop((header_client, body_client)); + let oversized = vec![b'a'; MAX_HEAD]; + assert_eq!( + receive(&mut oversized.as_slice(), local(), &auth) + .await + .err(), + Some(431) + ); + } +} diff --git a/crates/httpjet/src/admin_resources.rs b/crates/httpjet/src/admin_resources.rs new file mode 100644 index 0000000..d965409 --- /dev/null +++ b/crates/httpjet/src/admin_resources.rs @@ -0,0 +1,473 @@ +use hj_core::config::{ExtKind, ExtProcessor, ServerConfig}; +use std::fmt::Debug; +use std::path::{Component, Path, PathBuf}; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct InvalidResource; + +pub(crate) struct ResourceRoots(Vec); + +impl ResourceRoots { + pub(crate) fn new(roots: &[PathBuf]) -> Result { + if roots.is_empty() || roots.len() > 32 { + return Err(InvalidResource); + } + let roots = roots + .iter() + .map(|root| { + let root = root.canonicalize().map_err(|_| InvalidResource)?; + if !root.is_dir() || root.parent().is_none() { + return Err(InvalidResource); + } + Ok(root) + }) + .collect::, _>>()?; + Ok(Self(roots)) + } + + fn check(&self, path: &Path, directory: bool) -> Result<(), InvalidResource> { + if !path.is_absolute() || path.components().any(|c| matches!(c, Component::ParentDir)) { + return Err(InvalidResource); + } + let canonical = path.canonicalize().map_err(|_| InvalidResource)?; + if !self.0.iter().any(|root| canonical.starts_with(root)) { + return Err(InvalidResource); + } + let metadata = canonical.metadata().map_err(|_| InvalidResource)?; + if (directory && !metadata.is_dir()) || (!directory && !metadata.is_file()) { + return Err(InvalidResource); + } + Ok(()) + } + + /// This authorizes explicit config references at submission time. It is not + /// a filesystem sandbox: operators must control these roots and their links. + pub(crate) fn validate(&self, cfg: &ServerConfig) -> Result<(), InvalidResource> { + if let Some(path) = &cfg.security.geo_db_file { + self.check(path, false)?; + } + let mut processors: Vec<&ExtProcessor> = cfg.ext_processors.iter().collect(); + for decl in cfg.vhosts.values() { + let vhost = decl.config.as_deref().ok_or(InvalidResource)?; + self.check(&decl.vh_root, true)?; + self.check(&vhost.doc_root, true)?; + for context in &vhost.contexts { + if context.kind == hj_core::config::ContextKind::Static { + if let Some(path) = &context.location { + self.check(path, true)?; + } + } + } + processors.extend(vhost.extra_ext_processors.iter()); + } + for processor in processors { + if processor.kind == ExtKind::Proxy { + for path in [&processor.client_cert_file, &processor.client_key_file] + .into_iter() + .flatten() + { + self.check(path, false)?; + } + } + } + Ok(()) + } + + /// Authorize certificate and verifier inputs before a TCP trust candidate + /// opens them. ACME bootstrap may omit the listener default identity, but + /// client-verifier and vhost certificate inputs remain required resources. + pub(crate) fn validate_tcp_trust( + &self, + cfg: &ServerConfig, + acme_bootstrap: bool, + ) -> Result<(), InvalidResource> { + for listener in cfg.listeners.iter().filter(|listener| listener.secure) { + let tls = listener.tls.as_ref().ok_or(InvalidResource)?; + if !acme_bootstrap { + self.check(&tls.cert_file, false)?; + self.check(&tls.key_file, false)?; + } + for path in [&tls.ca_cert_file, &tls.crl_file].into_iter().flatten() { + self.check(path, false)?; + } + } + for vhost in cfg + .vhosts + .values() + .filter_map(|decl| decl.config.as_deref()) + { + if let Some(tls) = &vhost.vhssl { + self.check(&tls.cert_file, false)?; + self.check(&tls.key_file, false)?; + if let Some(path) = &tls.ca_cert_file { + self.check(path, false)?; + } + } + } + Ok(()) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct RestartRequired; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReplacementClass { + Application, + TcpTrust, +} + +// These model types do not implement equality. Compare complete Debug values +// internally, never returning or logging the values (they can contain secrets). +fn same(old: &T, new: &T) -> bool { + format!("{old:?}") == format!("{new:?}") +} + +fn retained_processors(processors: &[ExtProcessor]) -> Vec<&ExtProcessor> { + processors + .iter() + // LSAPI owns an external/spawned process family outside ServerState. + // Proxy and FastCGI pools are generation-owned and drain with old state. + .filter(|p| p.kind == ExtKind::Lsapi) + .collect() +} + +/// Classify a submission without granting bind authority. TCP trust replacement +/// keeps configuration-declared UDS and QUIC topology fixed; an operator-owned +/// CLI UDS endpoint is preserved separately by resource acquisition, while a +/// fixed QUIC endpoint receives a coherent in-place TLS/trust replacement. +/// Topology planning and descriptor acquisition perform the remaining checks. +pub(crate) fn classify_replacement( + old: &ServerConfig, + new: &ServerConfig, +) -> Result { + let mut old_listeners = old.listeners.clone(); + let mut new_listeners = new.listeners.clone(); + for listener in old_listeners.iter_mut().chain(&mut new_listeners) { + // TLS resolvers capture these mappings outside the application state. + if !listener.secure { + listener.vhost_map.clear(); + } + } + let mut old_php = old.php_config.clone(); + let mut new_php = new.php_config.clone(); + for php in old_php.iter_mut().chain(&mut new_php) { + php.suffixes.clear(); + } + let caps = |c: &ServerConfig| { + ( + c.tuning.max_cached_file_size, + c.tuning.total_in_mem_cache_size, + c.tuning.max_mmap_file_size, + c.tuning.total_mmap_cache_size, + ) + }; + let listener_change = !same(&old_listeners, &new_listeners); + if old.server_root != new.server_root + || old.user != new.user + || old.group != new.group + || old.quic_enable != new.quic_enable + || !same(&old_php, &new_php) + || !same(&old.suexec, &new.suexec) + || old.security.cgi_cpu_limit_secs != new.security.cgi_cpu_limit_secs + || !same(&old.cache, &new.cache) + || caps(old) != caps(new) + || (old.php_config.is_some() + && old.tuning.max_req_body_size != new.tuning.max_req_body_size) + || !same( + &retained_processors(&old.ext_processors), + &retained_processors(&new.ext_processors), + ) + { + return Err(RestartRequired); + } + let mut tls_vhost_change = false; + for name in old.vhosts.keys().chain(new.vhosts.keys()) { + let before = old.vhosts.get(name).and_then(|v| v.config.as_deref()); + let after = new.vhosts.get(name).and_then(|v| v.config.as_deref()); + tls_vhost_change |= !same( + &before.and_then(|v| v.vhssl.as_ref()), + &after.and_then(|v| v.vhssl.as_ref()), + ); + if !same( + &before.and_then(|v| v.isolation.as_ref()), + &after.and_then(|v| v.isolation.as_ref()), + ) || !same( + &before.and_then(|v| v.access_log_file.as_ref()), + &after.and_then(|v| v.access_log_file.as_ref()), + ) || !same( + &before.and_then(|v| v.error_log_file.as_ref()), + &after.and_then(|v| v.error_log_file.as_ref()), + ) || !same( + &before + .map(|v| retained_processors(&v.extra_ext_processors)) + .unwrap_or_default(), + &after + .map(|v| retained_processors(&v.extra_ext_processors)) + .unwrap_or_default(), + ) { + return Err(RestartRequired); + } + } + if listener_change || tls_vhost_change { + let stable_tcp_shape = old.listeners.len() == new.listeners.len() + && old.listeners.iter().zip(&new.listeners).all(|(old, new)| { + old.secure == new.secure + && old.address == new.address + && old.uds_path.is_none() + && new.uds_path.is_none() + }); + if !stable_tcp_shape { + return Err(RestartRequired); + } + Ok(ReplacementClass::TcpTrust) + } else { + Ok(ReplacementClass::Application) + } +} + +/// Preserve the original application-only guard until the resource endpoint +/// explicitly opts into the separately prepared TCP transaction. +#[cfg(test)] +pub(crate) fn validate_retained( + old: &ServerConfig, + new: &ServerConfig, +) -> Result<(), RestartRequired> { + match classify_replacement(old, new)? { + ReplacementClass::Application => Ok(()), + ReplacementClass::TcpTrust => Err(RestartRequired), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use hj_core::config::{ + ExtAddress, ExtProcessor, Listener, ListenerTls, LoadBalanceConfig, VHostConfig, VHostDecl, + VhostLogFile, VhostMap, + }; + use std::{path::PathBuf, sync::Arc}; + + fn config() -> ServerConfig { + let mut cfg = ServerConfig::default(); + cfg.listeners.push(Listener { + name: "local".into(), + address: "127.0.0.1:18080".into(), + secure: false, + vhost_map: vec![], + tls: None, + uds_path: None, + proxy_protocol: false, + }); + cfg.vhosts.insert( + "site".into(), + VHostDecl { + name: "site".into(), + vh_root: PathBuf::from("/site"), + config_file: PathBuf::new(), + allow_symbol_link: None, + restrained: false, + enable_script: true, + config: Some(Arc::new(VHostConfig::default())), + }, + ); + cfg + } + + #[test] + fn retained_resources_reject_without_exposing_values() { + let old = config(); + let changes: &[fn(&mut ServerConfig)] = &[ + |c| c.listeners[0].proxy_protocol = true, + |c| c.listeners[0].uds_path = Some("/secret/socket".into()), + |c| c.listeners[0].address = "127.0.0.1:18081".into(), + |c| c.quic_enable = true, + |c| c.user = "root".into(), + |c| c.group = "root".into(), + |c| c.server_root = "/secret/root".into(), + |c| c.tuning.total_in_mem_cache_size += 1, + |c| c.cache.default_ttl_secs += 1, + |c| c.suexec.enable = true, + |c| { + Arc::make_mut(c.vhosts.get_mut("site").unwrap().config.as_mut().unwrap()) + .access_log_file = Some(VhostLogFile { + path: "/secret/log".into(), + rolling_bytes: 100, + keep_days: 1, + log_headers: 0, + }) + }, + ]; + for change in changes { + let mut next = old.clone(); + change(&mut next); + assert_eq!(validate_retained(&old, &next), Err(RestartRequired)); + } + assert_eq!(format!("{:?}", RestartRequired), "RestartRequired"); + } + + #[test] + fn application_configuration_remains_reloadable() { + let old = config(); + let mut next = old.clone(); + next.listeners[0].vhost_map.push(VhostMap { + vhost: "site".into(), + domains: vec!["example.test".into()], + }); + next.tuning.per_ip_rate = 40; + next.tuning.enable_gzip = false; + let site = Arc::make_mut( + next.vhosts + .get_mut("site") + .unwrap() + .config + .as_mut() + .unwrap(), + ); + site.doc_root = "/site/next".into(); + site.rewrite.rules = "RewriteRule ^ /next [L]".into(); + assert_eq!(validate_retained(&old, &next), Ok(())); + let mut tls_old = old.clone(); + tls_old.listeners[0].secure = true; + next.listeners[0].secure = true; + assert_eq!(validate_retained(&tls_old, &next), Err(RestartRequired)); + } + + #[test] + fn generation_owned_fastcgi_changes_reload_but_lsapi_changes_do_not() { + let processor = |kind: ExtKind, address: &str| ExtProcessor { + name: "app".into(), + kind, + address: ExtAddress::Uds(address.into()), + extra_addresses: vec![], + load_balance: LoadBalanceConfig::default(), + client_cert_file: None, + client_key_file: None, + max_conns: 4, + init_timeout: std::time::Duration::from_secs(1), + retry_timeout: std::time::Duration::ZERO, + pc_keep_alive_timeout: std::time::Duration::from_secs(30), + resp_buffer: false, + env: vec![], + auto_start: 0, + path: None, + backlog: 16, + instances: 1, + run_on_startup: 0, + }; + let mut old = config(); + old.ext_processors + .push(processor(ExtKind::FastCgi, "/run/app-old.sock")); + let mut changed = old.clone(); + changed.ext_processors[0].address = ExtAddress::Uds("/run/app-new.sock".into()); + assert_eq!( + classify_replacement(&old, &changed), + Ok(ReplacementClass::Application) + ); + + let mut lsapi_old = config(); + lsapi_old + .ext_processors + .push(processor(ExtKind::Lsapi, "/run/php-old.sock")); + let mut lsapi_changed = lsapi_old.clone(); + lsapi_changed.ext_processors[0].address = ExtAddress::Uds("/run/php-new.sock".into()); + assert_eq!( + classify_replacement(&lsapi_old, &lsapi_changed), + Err(RestartRequired) + ); + } + + #[test] + fn tcp_trust_changes_are_distinct_from_application_only_updates() { + let old = config(); + for change in [ + |cfg: &mut ServerConfig| cfg.listeners[0].name = "replacement".into(), + |cfg: &mut ServerConfig| cfg.listeners[0].proxy_protocol = true, + ] { + let mut next = old.clone(); + change(&mut next); + assert_eq!( + classify_replacement(&old, &next), + Ok(ReplacementClass::TcpTrust) + ); + assert_eq!(validate_retained(&old, &next), Err(RestartRequired)); + } + + let mut quic = old.clone(); + quic.quic_enable = true; + let mut changed = quic.clone(); + changed.listeners[0].proxy_protocol = true; + assert_eq!( + classify_replacement(&quic, &changed), + Ok(ReplacementClass::TcpTrust) + ); + changed.quic_enable = false; + assert_eq!(classify_replacement(&quic, &changed), Err(RestartRequired)); + + let mut uds = old.clone(); + uds.listeners[0].uds_path = Some("/tmp/httpjet-test.sock".into()); + let mut changed = uds.clone(); + changed.listeners[0].name = "replacement".into(); + assert_eq!(classify_replacement(&uds, &changed), Err(RestartRequired)); + + let mut moved = old.clone(); + moved.listeners[0].address = "127.0.0.1:18081".into(); + assert_eq!(classify_replacement(&old, &moved), Err(RestartRequired)); + } + + #[test] + fn resource_roots_reject_escape_missing_and_nonregular_files() { + let root = std::env::temp_dir().join(format!( + "httpjet-admin-roots-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(root.join("allowed/site")).unwrap(); + std::fs::create_dir(root.join("outside")).unwrap(); + std::fs::write(root.join("allowed/data"), "fixture").unwrap(); + std::os::unix::fs::symlink(root.join("outside"), root.join("allowed/escape")).unwrap(); + let roots = ResourceRoots::new(&[root.join("allowed")]).unwrap(); + assert!(roots.check(&root.join("allowed/site"), true).is_ok()); + assert!(roots.check(&root.join("allowed/data"), false).is_ok()); + for path in [ + root.join("allowed/escape"), + root.join("allowed/../outside"), + root.join("allowed/missing"), + PathBuf::from("relative"), + ] { + assert_eq!(roots.check(&path, true), Err(InvalidResource)); + } + assert_eq!( + roots.check(&root.join("allowed/site"), false), + Err(InvalidResource) + ); + assert!(ResourceRoots::new(&[PathBuf::from("/")]).is_err()); + assert!(ResourceRoots::new(&[]).is_err()); + let mut cfg = config(); + let decl = cfg.vhosts.get_mut("site").unwrap(); + decl.vh_root = root.join("allowed/site"); + Arc::make_mut(decl.config.as_mut().unwrap()).doc_root = decl.vh_root.clone(); + assert!(roots.validate(&cfg).is_ok()); + cfg.listeners[0].secure = true; + cfg.listeners[0].tls = Some(ListenerTls { + key_file: root.join("allowed/data"), + cert_file: root.join("allowed/data"), + cert_chain: true, + ca_cert_file: None, + client_verify: 0, + verify_depth: 1, + enable_stapling: false, + crl_file: None, + }); + assert!(roots.validate_tcp_trust(&cfg, false).is_ok()); + cfg.listeners[0].tls.as_mut().unwrap().key_file = root.join("outside/secret"); + assert_eq!(roots.validate_tcp_trust(&cfg, false), Err(InvalidResource)); + assert!(roots.validate_tcp_trust(&cfg, true).is_ok()); + cfg.security.geo_db_file = Some(root.join("outside/secret")); + assert_eq!(roots.validate(&cfg), Err(InvalidResource)); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/httpjet/src/admin_submission.rs b/crates/httpjet/src/admin_submission.rs new file mode 100644 index 0000000..33b1518 --- /dev/null +++ b/crates/httpjet/src/admin_submission.rs @@ -0,0 +1,65 @@ +use hj_config::parse_bundle; +use hj_core::config::ServerConfig; +use serde::Deserialize; +use std::{collections::BTreeMap, path::Path}; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Submission { + server_xml: String, + vhosts: Vec, + mime: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct VhostDocument { + name: String, + xml: String, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct InvalidSubmission; + +pub(crate) fn parse(root: &Path, body: &[u8]) -> Result { + if body.len() > crate::admin_protocol::MAX_BODY { + return Err(InvalidSubmission); + } + let submission: Submission = serde_json::from_slice(body).map_err(|_| InvalidSubmission)?; + if submission.vhosts.len() > 128 { + return Err(InvalidSubmission); + } + let mut documents = BTreeMap::new(); + for vhost in submission.vhosts { + if vhost.name.is_empty() || documents.insert(vhost.name, vhost.xml).is_some() { + return Err(InvalidSubmission); + } + } + parse_bundle(root, &submission.server_xml, &documents, &submission.mime) + .map_err(|_| InvalidSubmission) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn envelope_rejects_ambiguous_or_incomplete_submissions() { + let root = Path::new("/synthetic-root"); + let valid = br#"{"server_xml":"","vhosts":[],"mime":""}"#; + assert!(parse(root, valid).is_ok()); + for invalid in [ + r#"{"server_xml":"","vhosts":[]}"#, + r#"{"server_xml":"","vhosts":[],"mime":"","secret":"hidden"}"#, + r#"{"server_xml":"","server_xml":"","vhosts":[],"mime":""}"#, + r#"{"server_xml":"","vhosts":[{"name":"x","xml":""},{"name":"x","xml":""}],"mime":""}"#, + r#"{"server_xml":"","vhosts":[],"mime":""} {}"#, + ] { + assert_eq!( + parse(root, invalid.as_bytes()).err(), + Some(InvalidSubmission) + ); + } + assert!(parse(root, &vec![b' '; crate::admin_protocol::MAX_BODY + 1]).is_err()); + } +} diff --git a/crates/httpjet/src/admin_write.rs b/crates/httpjet/src/admin_write.rs new file mode 100644 index 0000000..2baaeca --- /dev/null +++ b/crates/httpjet/src/admin_write.rs @@ -0,0 +1,233 @@ +use crate::{ + admin_auth::AuthToken, + admin_protocol::{self, Operation, Request}, + admin_resources::{self, ResourceRoots}, + admin_submission, + config_transaction::Coordinator, + state::ServerState, +}; +use std::{sync::Arc, time::Duration}; +use tokio::{io::AsyncWriteExt, net::TcpListener, sync::Semaphore}; + +pub(crate) struct Control { + pub(crate) coordinator: Arc, + pub(crate) roots: Arc, + pub(crate) no_mtls: bool, + pub(crate) per_ip_rate: Option, + pub(crate) on_publish: Arc, + tcp_replacement: Option, + candidates: Arc, +} + +#[derive(Clone)] +pub(crate) struct TcpReplacementPolicy { + pub(crate) acme_bootstrap: bool, + pub(crate) ktls: bool, + pub(crate) admission: crate::uring::bridge::BridgeAdmission, +} + +impl Control { + pub(crate) fn new( + coordinator: Arc, + roots: Arc, + no_mtls: bool, + per_ip_rate: Option, + on_publish: Arc, + ) -> Self { + Self { + coordinator, + roots, + no_mtls, + per_ip_rate, + on_publish, + tcp_replacement: None, + candidates: Arc::new(Semaphore::new(1)), + } + } + + pub(crate) fn with_tcp_replacement(mut self, policy: TcpReplacementPolicy) -> Self { + self.tcp_replacement = Some(policy); + self + } + + #[cfg(test)] + pub(crate) fn available_candidates(&self) -> usize { + self.candidates.available_permits() + } + + pub(crate) async fn execute(&self, request: Request) -> (u16, String) { + if request.operation == Operation::Revision { + let revision = self.coordinator.revision(); + return ( + 200, + serde_json::json!({"revision": revision, "persistence": "volatile"}).to_string(), + ); + } + let Ok(permit) = self.candidates.clone().try_acquire_owned() else { + return failure(503, "busy"); + }; + let Ok(transaction) = + tokio::time::timeout(Duration::from_secs(5), self.coordinator.begin()).await + else { + return failure(503, "busy"); + }; + let expected = request.revision.unwrap_or_default(); + if expected != transaction.revision() { + return failure(412, "revision_conflict"); + } + let current = transaction.current.clone(); + let roots = self.roots.clone(); + let no_mtls = self.no_mtls; + let per_ip_rate = self.per_ip_rate; + let tcp_replacement = self.tcp_replacement.clone(); + let cache_epoch: Arc = Arc::from(expected.as_str()); + let build = tokio::task::spawn_blocking(move || { + // Keep admission occupied even if the caller times out or disconnects. + let _permit = permit; + tracing::subscriber::with_default(tracing::subscriber::NoSubscriber::default(), || { + let mut cfg = admin_submission::parse(¤t.server.server_root, &request.body) + .map_err(|_| (400, "invalid_submission"))?; + crate::apply_no_mtls(&mut cfg, no_mtls); + if let Some(rate) = per_ip_rate { + cfg.tuning.per_ip_rate = rate; + } + let replacement = admin_resources::classify_replacement(¤t.server, &cfg) + .map_err(|_| (409, "restart_required"))?; + if replacement == admin_resources::ReplacementClass::TcpTrust + && tcp_replacement.is_none() + { + return Err((409, "restart_required")); + } + roots + .validate(&cfg) + .map_err(|_| (422, "invalid_resource"))?; + if replacement == admin_resources::ReplacementClass::TcpTrust { + roots + .validate_tcp_trust( + &cfg, + tcp_replacement + .as_ref() + .is_some_and(|policy| policy.acme_bootstrap), + ) + .map_err(|_| (422, "invalid_resource"))?; + } + for decl in cfg.vhosts.values() { + let vhost = decl.config.as_deref().ok_or((422, "invalid_config"))?; + if vhost.rewrite.enable && !vhost.rewrite.rules.trim().is_empty() { + hj_rewrite::RuleSet::parse(&vhost.rewrite.rules) + .map_err(|_| (422, "invalid_config"))?; + } + } + if crate::reload_would_brick_vhosts(¤t.server, &cfg).is_some() { + return Err((422, "invalid_config")); + } + let mut next = ServerState::reload(¤t, Arc::new(cfg)) + .map_err(|_| (422, "invalid_config"))?; + if next.response_cache_epoch.is_none() { + Arc::get_mut(&mut next) + .expect("unpublished candidate is uniquely owned") + .response_cache_epoch = Some(cache_epoch); + } + if replacement == admin_resources::ReplacementClass::TcpTrust { + Arc::get_mut(&mut next) + .expect("unpublished candidate is uniquely owned") + .trust_epoch = Arc::new(()); + } + Ok((next, replacement)) + }) + }); + let (next, replacement) = match tokio::time::timeout(Duration::from_secs(10), build).await { + Ok(Ok(Ok(next))) => next, + Ok(Ok(Err((status, code)))) => return failure(status, code), + Ok(Err(_)) => return failure(500, "build_failed"), + Err(_) => return failure(503, "build_timeout"), + }; + let resources = if replacement == admin_resources::ReplacementClass::TcpTrust { + let policy = self + .tcp_replacement + .as_ref() + .expect("TCP replacement was admitted only with launch policy"); + let tls = + match transaction.prepare_tcp_tls(next.clone(), policy.acme_bootstrap, policy.ktls) + { + Ok(tls) => tls, + Err(_) => return failure(409, "restart_required"), + }; + match transaction.prepare_tcp_workers(next.clone(), tls, policy.admission.clone()) { + Ok(resources) => Some(resources), + Err(_) => return failure(409, "restart_required"), + } + } else { + None + }; + if request.operation == Operation::Validate { + return (200, serde_json::json!({"revision": expected, "valid": true, "published": false, "persistence": "volatile"}).to_string()); + } + let revision = transaction.next_revision(); + let published = if let Some(resources) = resources { + transaction.publish_resources(&expected, next, resources) + } else { + transaction.publish(&expected, next) + }; + if let Err(error) = published { + return match error { + crate::config_transaction::PublishError::Closed => failure(503, "shutting_down"), + crate::config_transaction::PublishError::RetirementBusy => { + failure(503, "retirement_busy") + } + crate::config_transaction::PublishError::ResourceRequired => { + failure(409, "restart_required") + } + _ => failure(412, "revision_conflict"), + }; + } + (self.on_publish)(); + ( + 200, + serde_json::json!({"revision": revision, "published": true, "persistence": "volatile"}) + .to_string(), + ) + } +} + +fn failure(status: u16, code: &'static str) -> (u16, String) { + (status, serde_json::json!({"error": code}).to_string()) +} + +pub(crate) async fn serve(listener: TcpListener, auth: Arc, control: Arc) { + let Ok(local) = listener.local_addr() else { + return; + }; + if !local.ip().is_loopback() { + return; + } + let mut tasks = tokio::task::JoinSet::new(); + let shutdown = control.coordinator.shutdown(); + loop { + tokio::select! { + _ = shutdown.cancelled() => break, + Some(_) = tasks.join_next(), if !tasks.is_empty() => {}, + accepted = listener.accept(), if tasks.len() < 4 => { + let Ok((mut stream, peer)) = accepted else { break; }; + if !peer.ip().is_loopback() { continue; } + let auth = auth.clone(); + let control = control.clone(); + tasks.spawn(async move { + let (status, body) = match admin_protocol::receive(&mut stream, local, &auth).await { + Ok(request) => control.execute(request).await, + Err(status) => failure(status, "invalid_request"), + }; + tracing::info!(status, revision = %control.coordinator.revision(), "configuration control request completed"); + // Publication is final even if the response connection disappears. + // The client can resolve an uncertain result via GET /v1/revision. + let _ = tokio::time::timeout(Duration::from_secs(5), async { + let head = format!("HTTP/1.1 {status} Response\r\nContent-Type: application/json\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); + stream.write_all(head.as_bytes()).await?; + stream.write_all(body.as_bytes()).await?; + stream.shutdown().await + }).await; + }); + } + } + } +} diff --git a/crates/httpjet/src/config_transaction.rs b/crates/httpjet/src/config_transaction.rs new file mode 100644 index 0000000..f62e3af --- /dev/null +++ b/crates/httpjet/src/config_transaction.rs @@ -0,0 +1,593 @@ +//! Shared publication boundary for configuration writers; serving stays lock-free. +use crate::resource_generation::{QuicResources, ResourceSlots, TransportResources}; +use crate::state::ServerState; +use arc_swap::ArcSwap; +use std::{io::Read, sync::Arc}; +use tokio::sync::{Mutex, MutexGuard}; + +#[derive(Default)] +struct Publication { + closed: bool, + resources: ResourceSlots, + quic: Option, +} + +pub(crate) struct Coordinator { + #[cfg(feature = "acme")] + acme_targets: Option, + #[cfg(feature = "ocsp")] + ocsp_policy: Option, + tcp_launch_policy: Option, + uds_launch_policy: Option, + holder: Arc>, + incarnation: String, + writer: Mutex<()>, + publication: std::sync::Mutex, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum PublishError { + Conflict, + InvalidGeneration, + ResourceRequired, + RetirementBusy, + Closed, +} + +impl Coordinator { + #[cfg(feature = "acme")] + pub(crate) fn with_acme_targets( + mut self, + targets: Option, + ) -> Self { + self.acme_targets = targets; + self + } + #[cfg(feature = "ocsp")] + pub(crate) fn with_ocsp_policy(mut self, policy: crate::ocsp_runtime::OcspArgs) -> Self { + self.ocsp_policy = Some(policy); + self + } + pub(crate) fn with_tcp_launch_policy( + mut self, + policy: crate::listener_plan::TcpLaunchPolicy, + ) -> Self { + self.tcp_launch_policy = Some(policy); + self + } + pub(crate) fn with_uds_launch_policy( + mut self, + policy: crate::listener_plan::UdsLaunchPolicy, + ) -> Self { + self.uds_launch_policy = Some(policy); + self + } + pub(crate) fn close(&self) { + let mut publication = self.publication.lock().expect("publication mutex poisoned"); + publication.closed = true; + publication.resources.stop(); + if let Some(quic) = &publication.quic { + quic.stop(); + } + } + + #[cfg(test)] + pub(crate) fn install_initial_resources( + &self, + resources: TransportResources, + ) -> Result<(), PublishError> { + self.install_initial_resources_with_quic(resources, None) + } + + pub(crate) fn install_initial_resources_with_quic( + &self, + resources: TransportResources, + quic: Option, + ) -> Result<(), PublishError> { + let mut publication = self.publication.lock().expect("publication mutex poisoned"); + if publication.closed { + return Err(PublishError::Closed); + } + // QUIC is effective only when the operator actually launched HTTPS. + // XML may keep `quicEnable=1` while `--https-addr ""` intentionally + // runs an HTTP-only test instance; that topology owns no UDP resource. + let expects_quic = self.tcp_launch_policy.map_or_else( + || self.holder.load().server.quic_enable, + |policy| policy.https.is_some() && self.holder.load().server.quic_enable, + ); + if publication.resources.active.is_some() + || publication.quic.is_some() + || !resources.is_prepared_for(&self.holder.load().trust_epoch) + || resources.has_quic_policy() + || quic.is_some() != expects_quic + || cfg!(feature = "acme") + && self.acme_enabled() + && resources.certificate_handles().is_empty() + { + return Err(PublishError::ResourceRequired); + } + #[cfg(feature = "acme")] + if let Some(targets) = &self.acme_targets { + targets + .replace(resources.certificate_handles()) + .map_err(|_| PublishError::ResourceRequired)?; + } + publication.quic = quic; + publication.resources.replace(resources); + if let Some(quic) = &publication.quic { + quic.activate(); + } + Ok(()) + } + + pub(crate) fn reap_retired(&self) -> usize { + let finished = { + let mut publication = self.publication.lock().expect("publication mutex poisoned"); + publication.resources.take_finished() + }; + let count = finished.iter().filter(|owner| owner.is_some()).count(); + drop(finished); + count + } + + /// Final synchronous join while the application runtime is still alive. + /// Unlike periodic reaping this may wait for the worker drain deadline. + pub(crate) fn finish_shutdown(&self) { + let resources = { + let mut publication = self.publication.lock().expect("publication mutex poisoned"); + publication.closed = true; + publication.resources.stop(); + if let Some(quic) = &publication.quic { + quic.stop(); + } + ( + std::mem::take(&mut publication.resources), + publication.quic.take(), + ) + }; + drop(resources); + } + + pub(crate) fn shutdown(&self) -> tokio_util::sync::CancellationToken { + self.holder.load().shutdown.clone() + } + pub(crate) fn revision(&self) -> String { + format!("{}-{}", self.incarnation, self.holder.load().generation) + } + pub(crate) fn new(holder: Arc>) -> std::io::Result { + let mut entropy = [0_u8; 16]; + std::fs::File::open("/dev/urandom")?.read_exact(&mut entropy)?; + let incarnation = entropy.iter().map(|b| format!("{b:02x}")).collect(); + Ok(Self { + #[cfg(feature = "acme")] + acme_targets: None, + #[cfg(feature = "ocsp")] + ocsp_policy: None, + tcp_launch_policy: None, + uds_launch_policy: None, + holder, + incarnation, + writer: Mutex::new(()), + publication: std::sync::Mutex::new(Publication::default()), + }) + } + + fn acme_enabled(&self) -> bool { + #[cfg(feature = "acme")] + { + self.acme_targets.is_some() + } + #[cfg(not(feature = "acme"))] + { + false + } + } + + /// A writer owns the boundary through validation/build/publication. Request + /// readers do not acquire it. The mutable endpoint must bound admission + /// before waiting here, rather than creating unbounded candidate waiters. + pub(crate) async fn begin(&self) -> Transaction<'_> { + let lock = self.writer.lock().await; + Transaction { + owner: self, + current: self.holder.load_full(), + _lock: lock, + } + } +} + +pub(crate) struct Transaction<'a> { + owner: &'a Coordinator, + pub(crate) current: Arc, + _lock: MutexGuard<'a, ()>, +} + +impl Transaction<'_> { + /// Resolve current-generation handles under publication ownership, then do + /// certificate I/O outside that mutex while retaining the writer guard. + /// This is SIGHUP's certificate-only reload, not an atomic multi-cert commit. + pub(crate) fn reload_certificates( + &self, + server: &hj_core::config::ServerConfig, + ) -> anyhow::Result<()> { + let handles = { + let publication = self + .owner + .publication + .lock() + .expect("publication mutex poisoned"); + if publication.closed || !Arc::ptr_eq(&self.current, &self.owner.holder.load_full()) { + anyhow::bail!("certificate reload generation is no longer active"); + } + let active = + publication.resources.active.as_ref().ok_or_else(|| { + anyhow::anyhow!("active certificate resource owner is missing") + })?; + if !Arc::ptr_eq(&active.trust_epoch, &self.current.trust_epoch) { + anyhow::bail!("certificate resource epoch differs from active state"); + } + active.certificate_handles() + }; + let targets = handles + .iter() + .map(|(name, handle)| { + let mut matches = server + .listeners + .iter() + .filter(|l| l.secure && l.name == name.as_ref()); + let listener = matches + .next() + .ok_or_else(|| anyhow::anyhow!("active certificate listener is missing"))?; + if matches.next().is_some() { + anyhow::bail!("active certificate listener is ambiguous"); + } + Ok((handle, listener)) + }) + .collect::>>()?; + for (handle, listener) in targets { + handle.reload(server, listener)?; + } + Ok(()) + } + #[allow(dead_code)] + pub(crate) fn prepare_tcp_tls( + &self, + next: Arc, + bootstrap: bool, + ktls: bool, + ) -> anyhow::Result> { + self.candidate_view(next.clone()) + .map_err(|e| anyhow::anyhow!("candidate admission: {e:?}"))?; + let plan = self + .tcp_plan(&next.server) + .map_err(|e| anyhow::anyhow!("candidate plan: {e:?}"))?; + #[cfg(feature = "ocsp")] + if self.owner.ocsp_policy.as_ref().is_some_and(|p| p.enabled()) && plan.https.is_none() { + anyhow::bail!("OCSP launch policy requires candidate HTTPS"); + } + plan.https + .map(|target| { + let tls = crate::tcp_candidate::TcpTlsPolicy::prepare( + next.clone(), + target.identity, + bootstrap, + ktls, + )?; + #[cfg(feature = "ocsp")] + let tls = if let Some(policy) = &self.owner.ocsp_policy { + tls.with_ocsp(policy, plan.http.address, target.address)? + } else { + tls + }; + Ok(tls) + }) + .transpose() + } + /// Prepare the TCP portion against one unpublished candidate snapshot. + /// This does not publish or replace non-TCP resources. + #[allow(dead_code)] + pub(crate) fn prepare_tcp_workers( + &self, + next: Arc, + tls: Option, + admission: crate::uring::bridge::BridgeAdmission, + ) -> anyhow::Result { + #[cfg(feature = "ocsp")] + if !tls.as_ref().map_or_else( + || !self.owner.ocsp_policy.as_ref().is_some_and(|p| p.enabled()), + |p| p.matches_ocsp(self.owner.ocsp_policy.as_ref()), + ) { + anyhow::bail!("candidate OCSP attachment differs from launch policy"); + } + let view = self + .candidate_view(next.clone()) + .map_err(|e| anyhow::anyhow!("candidate admission: {e:?}"))?; + let plan = self + .tcp_plan(&next.server) + .map_err(|e| anyhow::anyhow!("candidate plan: {e:?}"))?; + let acquired = self + .acquire_tcp_plan(&next.server) + .map_err(|e| anyhow::anyhow!("candidate sockets: {e:?}"))?; + let uds = self + .acquire_uds() + .map_err(|e| anyhow::anyhow!("candidate UDS socket: {e:?}"))?; + crate::tcp_candidate::prepare(acquired, plan, uds, view, tls, admission) + } + + fn acquire_uds( + &self, + ) -> Result< + Option<( + crate::listener_plan::UdsLaunchPolicy, + crate::uring::worker_group::PreparedUdsHandoff, + )>, + PublishError, + > { + let Some(policy) = self.owner.uds_launch_policy.clone() else { + return Ok(None); + }; + let source = { + let publication = self + .owner + .publication + .lock() + .expect("publication mutex poisoned"); + if publication.closed { + return Err(PublishError::Closed); + } + if !Arc::ptr_eq(&self.current, &self.owner.holder.load_full()) { + return Err(PublishError::Conflict); + } + if !publication.resources.has_capacity() { + return Err(PublishError::RetirementBusy); + } + let active = publication + .resources + .active + .as_ref() + .ok_or(PublishError::ResourceRequired)?; + if !Arc::ptr_eq(&active.trust_epoch, &self.current.trust_epoch) { + return Err(PublishError::ResourceRequired); + } + active + .uds_handoff_source(&policy.path) + .map_err(|_| PublishError::ResourceRequired)? + }; + let prepared = source + .prepare(&policy.path) + .map_err(|_| PublishError::ResourceRequired)?; + Ok(Some((policy, prepared))) + } + /// Reconcile all planned TCP endpoints with the current owner. This only + /// supports handoffs, including renamed listeners. Add/remove transitions + /// reject before acquisition; lookup failure never falls back to a bind. + #[allow(dead_code)] + pub(crate) fn acquire_tcp_plan( + &self, + candidate: &hj_core::config::ServerConfig, + ) -> Result { + use crate::listener_plan::TcpTransition; + let plan = self.tcp_plan(candidate)?; + let previous = self.tcp_plan(&self.current.server)?; + let changes = plan.transitions_from(&previous); + if changes + .iter() + .any(|change| !matches!(change, TcpTransition::Handoff { .. })) + { + return Err(PublishError::ResourceRequired); + } + let mut http = None; + let mut https = None; + for change in changes { + let TcpTransition::Handoff { + source, + target, + address, + } = change + else { + unreachable!() + }; + let acquired = self.tcp_handoff(&source, address)?; + if target.tls { + https = Some(acquired); + } else { + http = Some(acquired); + } + } + Ok(crate::listener_plan::AcquiredTcpPlan { + http: http.ok_or(PublishError::ResourceRequired)?, + https, + }) + } + /// Pure planning only. Binding/handoff and full resource admission remain + /// separate, and the network API still rejects resource-changing requests. + #[allow(dead_code)] + pub(crate) fn tcp_plan<'a>( + &self, + candidate: &'a hj_core::config::ServerConfig, + ) -> Result, PublishError> { + self.owner + .tcp_launch_policy + .map(|p| p.plan(candidate)) + .ok_or(PublishError::ResourceRequired) + } + /// Acquire duplicates from the current owner, not from a fresh bind on the + /// same address. Selection uses the listener name and TCP/TLS transport kind, + /// never an incidental position in the startup resource vector. + #[allow(dead_code)] // Submitted topology mapping is not exposed yet. + pub(crate) fn tcp_handoff( + &self, + identity: &crate::uring::worker_group::TcpListenerId, + expected_address: std::net::SocketAddr, + ) -> Result { + let source = { + let publication = self + .owner + .publication + .lock() + .expect("publication mutex poisoned"); + if publication.closed { + return Err(PublishError::Closed); + } + if !Arc::ptr_eq(&self.current, &self.owner.holder.load_full()) { + return Err(PublishError::Conflict); + } + if !publication.resources.has_capacity() { + return Err(PublishError::RetirementBusy); + } + let active = publication + .resources + .active + .as_ref() + .ok_or(PublishError::ResourceRequired)?; + if !Arc::ptr_eq(&active.trust_epoch, &self.current.trust_epoch) { + return Err(PublishError::ResourceRequired); + } + active + .tcp_handoff_source(identity) + .map_err(|_| PublishError::ResourceRequired)? + }; + source + .prepare(expected_address) + .map_err(|_| PublishError::ResourceRequired) + } + /// Check resource admission before acquisition. Publication rechecks these + /// conditions; shutdown may race a candidate's blocking setup work. + #[allow(dead_code)] + pub(crate) fn candidate_view( + &self, + next: Arc, + ) -> Result { + let publication = self + .owner + .publication + .lock() + .expect("publication mutex poisoned"); + if publication.closed { + return Err(PublishError::Closed); + } + if !Arc::ptr_eq(&self.current, &self.owner.holder.load_full()) { + return Err(PublishError::Conflict); + } + if !publication.resources.has_capacity() { + return Err(PublishError::RetirementBusy); + } + if self.current.generation.checked_add(1) != Some(next.generation) { + return Err(PublishError::InvalidGeneration); + } + if Arc::ptr_eq(&self.current.trust_epoch, &next.trust_epoch) + || publication + .resources + .active + .as_ref() + .is_none_or(|active| !Arc::ptr_eq(&active.trust_epoch, &self.current.trust_epoch)) + { + return Err(PublishError::ResourceRequired); + } + Ok(crate::serving_generation::ServingView::candidate( + self.owner.holder.clone(), + next, + )) + } + + pub(crate) fn next_revision(&self) -> String { + // Called only after ServerState::reload checked generation overflow. + format!("{}-{}", self.owner.incarnation, self.current.generation + 1) + } + pub(crate) fn revision(&self) -> String { + format!("{}-{}", self.owner.incarnation, self.current.generation) + } + + /// Check immediately before publication, even if the candidate was built + /// earlier. The incarnation prevents preconditions surviving a restart. + pub(crate) fn publish( + self, + expected: &str, + next: Arc, + ) -> Result<(), PublishError> { + self.publish_inner(expected, next, None) + } + + /// Internal resource publication path. Network configuration still rejects + /// resource changes until candidate acquisition and per-resource gates exist. + #[allow(dead_code)] + pub(crate) fn publish_resources( + self, + expected: &str, + next: Arc, + resources: TransportResources, + ) -> Result<(), PublishError> { + self.publish_inner(expected, next, Some(resources)) + } + + fn publish_inner( + self, + expected: &str, + next: Arc, + mut resources: Option, + ) -> Result<(), PublishError> { + // Serialize shutdown with the complete publication/health transition. + let mut publication = self + .owner + .publication + .lock() + .expect("publication mutex poisoned"); + if publication.closed { + return Err(PublishError::Closed); + } + if expected != self.revision() + || !Arc::ptr_eq(&self.current, &self.owner.holder.load_full()) + { + return Err(PublishError::Conflict); + } + if self.current.generation.checked_add(1) != Some(next.generation) { + return Err(PublishError::InvalidGeneration); + } + // Application-only publication cannot install a new trust epoch without + // a matching prepared resource bundle and retirement transaction. + if let Some(resources) = &resources { + if !resources.is_prepared_for(&next.trust_epoch) + || self.owner.acme_enabled() && resources.certificate_handles().is_empty() + || Arc::ptr_eq(&self.current.trust_epoch, &next.trust_epoch) + || publication.resources.active.as_ref().is_none_or(|active| { + !Arc::ptr_eq(&active.trust_epoch, &self.current.trust_epoch) + }) + { + return Err(PublishError::ResourceRequired); + } + if resources.has_quic_policy() != publication.quic.is_some() { + return Err(PublishError::ResourceRequired); + } + if !publication.resources.has_capacity() { + return Err(PublishError::RetirementBusy); + } + } else if !Arc::ptr_eq(&self.current.trust_epoch, &next.trust_epoch) { + return Err(PublishError::ResourceRequired); + } + #[cfg(feature = "acme")] + if let (Some(targets), Some(resources)) = (&self.owner.acme_targets, &resources) { + // Apply the latest managed certificate while the candidate remains + // unreachable. After this succeeds no fallible operation remains. + targets + .replace(resources.certificate_handles()) + .map_err(|_| PublishError::ResourceRequired)?; + } + self.owner.holder.store(next.clone()); + if let Some(policy) = resources + .as_mut() + .and_then(TransportResources::take_quic_policy) + { + publication + .quic + .as_ref() + .expect("QUIC policy presence validated before publication") + .publish(policy); + } + self.current.proxy.pool().stop_health_checks(); + next.proxy.pool().activate_health_checks(); + if let Some(resources) = resources { + publication.resources.replace(resources); + } + Ok(()) + } +} diff --git a/crates/httpjet/src/extensions.rs b/crates/httpjet/src/extensions.rs new file mode 100644 index 0000000..1acc3b0 --- /dev/null +++ b/crates/httpjet/src/extensions.rs @@ -0,0 +1,9 @@ +//! Compile-time extension registration point. +//! +//! An extension crate is linked by adding it to `crates/httpjet/Cargo.toml`, +//! then registered here in deterministic execution order. The shipped registry +//! is intentionally empty, so production behavior and cost are unchanged. + +pub(crate) fn compiled_registry() -> hj_extension::ExtensionRegistry { + hj_extension::ExtensionRegistry::new() +} diff --git a/crates/httpjet/src/listener_plan.rs b/crates/httpjet/src/listener_plan.rs new file mode 100644 index 0000000..ae6b3f5 --- /dev/null +++ b/crates/httpjet/src/listener_plan.rs @@ -0,0 +1,239 @@ +//! Pure TCP topology planning under immutable operator launch policy. +//! Descriptor acquisition and systemd ownership checks happen separately. +use crate::uring::worker_group::TcpListenerId; +use hj_core::config::{Listener, ServerConfig}; +use std::net::SocketAddr; + +#[derive(Clone, Copy)] +pub(crate) struct TcpLaunchPolicy { + pub(crate) http: SocketAddr, + pub(crate) https: Option, +} + +#[derive(Clone)] +pub(crate) struct UdsLaunchPolicy { + pub(crate) path: std::path::PathBuf, +} + +pub(crate) struct TcpTarget<'a> { + pub(crate) identity: TcpListenerId, + pub(crate) address: SocketAddr, + pub(crate) listener: Option<&'a Listener>, +} + +pub(crate) struct TcpPlan<'a> { + pub(crate) http: TcpTarget<'a>, + pub(crate) https: Option>, + // Retain configured TLS metadata even when serving TLS is disabled; ACME + // startup validation already distinguishes configured and enabled TLS. + pub(crate) secure_listener: Option<&'a Listener>, +} + +/// All existing TCP endpoints acquired for one plan. Partial acquisition rolls +/// back by dropping duplicates; active descriptors remain worker-owned. +pub(crate) struct AcquiredTcpPlan { + pub(crate) http: crate::uring::worker_group::PreparedTcpHandoff, + pub(crate) https: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum TcpTransition { + Handoff { + source: TcpListenerId, + target: TcpListenerId, + address: SocketAddr, + }, + Add { + target: TcpListenerId, + address: SocketAddr, + }, + Remove { + source: TcpListenerId, + address: SocketAddr, + }, +} + +impl TcpPlan<'_> { + pub(crate) fn transitions_from(&self, previous: &TcpPlan<'_>) -> Vec { + fn compare( + old: Option<&TcpTarget<'_>>, + new: Option<&TcpTarget<'_>>, + out: &mut Vec, + ) { + match (old, new) { + (Some(old), Some(new)) if old.address == new.address => { + out.push(TcpTransition::Handoff { + source: old.identity.clone(), + target: new.identity.clone(), + address: new.address, + }); + } + (old, new) => { + if let Some(old) = old { + out.push(TcpTransition::Remove { + source: old.identity.clone(), + address: old.address, + }); + } + if let Some(new) = new { + out.push(TcpTransition::Add { + target: new.identity.clone(), + address: new.address, + }); + } + } + } + } + let mut changes = Vec::with_capacity(4); + compare(Some(&previous.http), Some(&self.http), &mut changes); + compare(previous.https.as_ref(), self.https.as_ref(), &mut changes); + changes + } +} + +impl TcpLaunchPolicy { + /// Preserve the existing startup selection rules, including the historical + /// plain-HTTP fallback to the first configured listener. XML addresses are + /// not bind authority in this launch mode. A plan creates no sockets and + /// does not authorize replacing inherited descriptors or QUIC endpoints. + pub(crate) fn plan(self, config: &ServerConfig) -> TcpPlan<'_> { + let http = config + .listeners + .iter() + .find(|l| !l.secure) + .or_else(|| config.listeners.first()); + let secure = config.listeners.iter().find(|l| l.secure); + fn target(listener: Option<&Listener>, address: SocketAddr, tls: bool) -> TcpTarget<'_> { + TcpTarget { + identity: TcpListenerId { + name: listener.map_or("Default", |l| l.name.as_str()).into(), + tls, + }, + address, + listener, + } + } + TcpPlan { + secure_listener: secure, + http: target(http, self.http, false), + https: self + .https + .zip(secure) + .map(|(addr, listener)| target(Some(listener), addr, true)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn listener(name: &str, secure: bool) -> Listener { + Listener { + name: name.into(), + secure, + address: "0.0.0.0:1".into(), + vhost_map: Vec::new(), + tls: None, + uds_path: None, + proxy_protocol: false, + } + } + fn policy() -> TcpLaunchPolicy { + TcpLaunchPolicy { + http: "127.0.0.1:18080".parse().unwrap(), + https: Some("127.0.0.1:18443".parse().unwrap()), + } + } + #[test] + fn transitions_distinguish_rename_add_remove_and_address_move() { + let old = ServerConfig { + listeners: vec![listener("http", false), listener("tls", true)], + ..Default::default() + }; + let mut renamed = old.clone(); + renamed.listeners[0].name = "new-http".into(); + renamed.listeners[1].name = "new-tls".into(); + let launch = policy(); + let changes = launch.plan(&renamed).transitions_from(&launch.plan(&old)); + assert_eq!(changes.len(), 2); + assert!( + matches!(&changes[0], TcpTransition::Handoff { source, target, address } + if source.name.as_ref() == "http" && target.name.as_ref() == "new-http" && *address == launch.http) + ); + assert!( + matches!(&changes[1], TcpTransition::Handoff { source, target, .. } + if source.name.as_ref() == "tls" && target.name.as_ref() == "new-tls" && source.tls && target.tls) + ); + let mut plain = old.clone(); + plain.listeners.pop(); + let removed = launch.plan(&plain).transitions_from(&launch.plan(&old)); + assert!(matches!(&removed[1], TcpTransition::Remove { source, .. } if source.tls)); + let added = launch.plan(&old).transitions_from(&launch.plan(&plain)); + assert!(matches!(&added[1], TcpTransition::Add { target, .. } if target.tls)); + let moved = TcpLaunchPolicy { + http: "127.0.0.1:18081".parse().unwrap(), + ..launch + }; + let changes = moved.plan(&old).transitions_from(&launch.plan(&old)); + assert!(matches!(&changes[0], TcpTransition::Remove { source, .. } if !source.tls)); + assert!(matches!(&changes[1], TcpTransition::Add { target, .. } if !target.tls)); + assert_eq!(changes.len(), 3); + } + + #[test] + fn xml_cannot_override_launch_bind_addresses_or_enable_disabled_tls() { + let cfg = ServerConfig { + listeners: vec![listener("secure", true), listener("plain", false)], + ..Default::default() + }; + let plan = policy().plan(&cfg); + assert_eq!(plan.http.identity.name.as_ref(), "plain"); + assert!(!plan.http.identity.tls); + assert_eq!(plan.http.address, policy().http); + let tls = plan.https.unwrap(); + assert_eq!(tls.identity.name.as_ref(), "secure"); + assert!(tls.identity.tls); + assert_eq!(Some(tls.address), policy().https); + assert!( + TcpLaunchPolicy { + https: None, + ..policy() + } + .plan(&cfg) + .https + .is_none() + ); + } + #[test] + fn empty_and_secure_only_configs_preserve_startup_fallback() { + let cfg = ServerConfig::default(); + let plan = policy().plan(&cfg); + assert_eq!(plan.http.identity.name.as_ref(), "Default"); + assert!(plan.http.listener.is_none()); + assert!(plan.https.is_none()); + let cfg = ServerConfig { + listeners: vec![listener("secure", true)], + ..Default::default() + }; + let plan = policy().plan(&cfg); + assert_eq!(plan.http.identity.name.as_ref(), "secure"); + assert!(std::ptr::eq(plan.http.listener.unwrap(), &cfg.listeners[0])); + assert_ne!(plan.http.identity, plan.https.unwrap().identity); + } + #[test] + fn candidate_plan_reads_candidate_policy_without_mutating_launch_policy() { + let launch = policy(); + let mut cfg = ServerConfig { + listeners: vec![listener("old", false)], + ..Default::default() + }; + assert_eq!(launch.plan(&cfg).http.identity.name.as_ref(), "old"); + cfg.listeners = vec![listener("tls", true), listener("new", false)]; + cfg.listeners[1].proxy_protocol = true; + let plan = launch.plan(&cfg); + assert_eq!(plan.http.identity.name.as_ref(), "new"); + assert!(plan.http.listener.unwrap().proxy_protocol); + assert_eq!(plan.http.address, launch.http); + assert!(plan.https.is_some()); + } +} diff --git a/crates/httpjet/src/lscache/mod.rs b/crates/httpjet/src/lscache/mod.rs index a988c44..0f4c23e 100644 --- a/crates/httpjet/src/lscache/mod.rs +++ b/crates/httpjet/src/lscache/mod.rs @@ -441,6 +441,8 @@ pub fn cache_lookup( force_miss: bool, gates: Option<&SharedCacheGates>, ) -> CacheOutcome { + #[cfg(feature = "otel")] + let _trace_stage = crate::otel::stage(crate::otel::StageKind::CacheLookup); // (#5) The raw request host is no longer a key input (it now keys by the canonical // vhost name); it survives inside the caller-built `identity` guard and the serve-time // self-redirect re-check below. @@ -520,7 +522,7 @@ pub fn cache_lookup( return CacheOutcome::Bypass; } - let key = build_cache_key(ctx, cc, store, &route); + let key = configuration_key(state, build_cache_key(ctx, cc, store, &route)); let key_hash = hash_key(&key); // (W-TinyLFU) Record this cacheable lookup in the admission sketch — true access frequency // (hit OR miss), so the store-admission gate can reject one-hit-wonders. Cheap atomic bumps. @@ -1178,6 +1180,19 @@ pub(crate) fn build_cache_key( key } +fn configuration_key( + state: &ServerState, + mut key: hj_pagecache::PageCacheKey, +) -> hj_pagecache::PageCacheKey { + if let Some(epoch) = &state.response_cache_epoch { + key.vary_value = format!( + "\0httpjet-config:{epoch}:{}\0{}", + state.generation, key.vary_value + ); + } + key +} + fn capsule_key( ctx: &ReqCtx, cc: &CacheCtx<'_>, @@ -1753,7 +1768,7 @@ fn capsule_public_fallback_lookup( return CacheOutcome::Miss(dedicated_key_hash); } - let key = capsule_public_fallback_key(ctx, cc, store); + let key = configuration_key(state, capsule_public_fallback_key(ctx, cc, store)); let key_hash = hash_key(&key); let (entry, stale) = match store.get_entry_uncounted(&key, cc.identity, now) { hj_pagecache::EntryState::Fresh(e) => (e, false), @@ -1941,7 +1956,7 @@ pub fn capsule_lookup( return CacheOutcome::Bypass; } - let key = capsule_key(ctx, cc, store); + let key = configuration_key(state, capsule_key(ctx, cc, store)); let key_hash = hash_key(&key); let now = Instant::now(); let entry = match store.get_entry_uncounted(&key, cc.identity, now) { @@ -2154,7 +2169,7 @@ fn stale_if_error_fallback( { return None; } - let key = build_cache_key(ctx, cc, store, route); + let key = configuration_key(state, build_cache_key(ctx, cc, store, route)); let now = Instant::now(); let entry = match store.get_entry(&key, identity, now) { hj_pagecache::EntryState::Fresh(e) @@ -2424,6 +2439,8 @@ pub async fn cache_store( cc: &CacheCtx<'_>, resp: Response, ) -> Response { + #[cfg(feature = "otel")] + let _trace_stage = crate::otel::stage(crate::otel::StageKind::CacheStore); let &CacheCtx { method, host, @@ -2730,7 +2747,7 @@ pub async fn cache_store( // gating the shell store on that guest-only frequency would defeat the capsule exactly // for guest-rare-but-member-popular URLs. Eager storage is bounded (the `x-wf-capsule` // header is backend-controlled) and capped by the page store. - let key = capsule_key(ctx, cc, store); + let key = configuration_key(state, capsule_key(ctx, cc, store)); let stored_at = Instant::now(); let stored_identity = identity.to_string(); let sie_secs = match &disposition { @@ -2791,7 +2808,7 @@ pub async fn cache_store( // bounded to the finite set of configured vhosts (no Host-header key inflation). // The PageScope owner equals the route owner by construction (the eligibility // match binds them from the same PrivateRoute), so the shared builder covers it. - let key = build_cache_key(ctx, cc, store, &route); + let key = configuration_key(state, build_cache_key(ctx, cc, store, &route)); // (W-TinyLFU admission) Spend RAM (store) + CPU (precompress) only on keys that show // REUSE. The frequency sketch is recorded on every cacheable lookup; admit when the // estimate meets a SIZE-WEIGHTED bar (a one-hit-wonder is rejected; a larger object needs @@ -3616,6 +3633,69 @@ mod tests { cache_test_ctx_with_capsule(crate::state::XfCapsuleConfig::disabled()) } + #[tokio::test] + async fn configuration_generations_isolate_late_cache_writers() { + let (old, ctx, store) = cache_test_ctx(); + let mut next = ServerState::reload(&old, old.server.clone()).unwrap(); + Arc::get_mut(&mut next).unwrap().response_cache_epoch = + Some(Arc::from("test-incarnation-1")); + let later = ServerState::reload(&next, next.server.clone()).unwrap(); + let method = Method::GET; + let cc = CacheCtx { + method: &method, + host: "forum.example", + cookie: None, + identity: "https\nforum.example\n/config", + req_path: "/config", + req_query: "", + chain: &[], + render_epoch: store.purge_epoch(), + has_range: false, + vary_value: None, + host_foreign: false, + }; + let base = build_cache_key(&ctx, &cc, &store, &PrivateRoute::Public); + let old_key = configuration_key(&old, base.clone()); + let next_key = configuration_key(&next, base.clone()); + let later_key = configuration_key(&later, base.clone()); + assert_eq!(old_key, base); + assert_ne!(old_key, next_key); + assert_ne!(next_key, later_key); + for (state, key, text) in [(&next, &next_key, "new"), (&old, &old_key, "late old")] { + state.page_cache_admission.record(hash_key(key)); + let response = http::Response::builder() + .status(200) + .header(CONTENT_TYPE, "text/plain") + .header(HDR_CACHE_CONTROL, "public,max-age=600") + .body(Body::Full(Bytes::from_static(text.as_bytes()))) + .unwrap(); + let _ = cache_store(state, &ctx, &cc, response).await; + } + let hj_pagecache::EntryState::Fresh(entry) = + store.get_entry(&next_key, cc.identity, Instant::now()) + else { + panic!("new generation entry missing"); + }; + assert!(matches!(&entry.body, PageBody::InMem(bytes) if bytes.as_ref() == b"new")); + let CacheOutcome::Hit(response) = cache_lookup(&next, &ctx, &cc, None, false, None) else { + panic!("new generation lookup did not hit"); + }; + assert!(matches!(response.into_body(), Body::Full(bytes) if bytes.as_ref() == b"new")); + assert!(matches!( + store.get_entry(&later_key, cc.identity, Instant::now()), + hj_pagecache::EntryState::Miss + )); + for key in [ + capsule_key(&ctx, &cc, &store), + capsule_public_fallback_key(&ctx, &cc, &store), + ] { + assert_ne!( + configuration_key(&old, key.clone()), + configuration_key(&next, key) + ); + } + } + #[test] fn capsule_refresh_cookie_preserves_empty_and_duplicate_vary_key_pairs() { let mut cfg = hj_pagecache::StoreConfig::default(); diff --git a/crates/httpjet/src/main.rs b/crates/httpjet/src/main.rs index aba4e6d..7338fe4 100644 --- a/crates/httpjet/src/main.rs +++ b/crates/httpjet/src/main.rs @@ -6,18 +6,37 @@ //! Production is systemd/socket-activation managed on :80/:443; test instances //! should use alternate ports and their own lsphp socket. +#[cfg(feature = "acme")] +mod acme_runtime; +mod admin; +mod admin_auth; +mod admin_protocol; +mod admin_resources; +mod admin_submission; +mod admin_write; mod allocount; +mod config_transaction; +mod extensions; +mod listener_plan; mod lscache; mod memtrim; mod metrics; +#[cfg(feature = "ocsp")] +mod ocsp_runtime; +#[cfg(feature = "otel")] +mod otel; mod peer_purge; mod phpslow; mod pipeline; +mod resource_generation; mod server; +mod serving_generation; mod statcache; mod state; +mod tcp_candidate; mod telemetry; mod uring; +mod waf; /// Process-wide allocator. mimalloc replaces glibc malloc to cut arena-lock / /// futex contention under the multi-thread tokio runtime — the bottleneck @@ -127,6 +146,12 @@ struct LsphpReloadArgs { #[derive(Parser, Debug)] struct ServeArgs { + #[cfg(feature = "ocsp")] + #[command(flatten)] + ocsp: ocsp_runtime::OcspArgs, + #[cfg(feature = "acme")] + #[command(flatten)] + acme: acme_runtime::AcmeArgs, /// Address for the plain-HTTP listener. #[arg(long, default_value = "127.0.0.1:8080")] http_addr: SocketAddr, @@ -222,6 +247,39 @@ struct ServeArgs { /// number once --page-cache is live) plus request/connection counters. #[arg(long, default_value = "127.0.0.1:9090")] metrics_addr: String, + /// Opt-in read-only operational JSON endpoint (loopback TCP only). + #[arg(long, value_parser = admin::parse_addr)] + admin_addr: Option, + /// Authenticated application-config writes on a separate loopback listener. + #[arg(long, value_parser = admin::parse_addr, requires_all = ["admin_token_file", "admin_resource_root"])] + admin_write_addr: Option, + /// Private file containing a 64-character lowercase hexadecimal bearer token. + #[arg(long, requires = "admin_write_addr")] + admin_token_file: Option, + /// Allowed existing filesystem root for submitted resources; repeatable. + #[arg(long, requires = "admin_write_addr")] + admin_resource_root: Vec, + /// Opt-in loopback HTTP request-inspection sidecar. The sidecar must return + /// 204 to allow or 403 to block; absent keeps the WAF seam completely inert. + #[arg(long, value_parser = admin::parse_addr)] + waf_sidecar_addr: Option, + #[arg(long, default_value = "/inspect", requires = "waf_sidecar_addr")] + waf_sidecar_path: String, + #[arg(long, default_value_t = 100, requires = "waf_sidecar_addr")] + waf_timeout_ms: u64, + #[arg(long, default_value_t = 64 * 1024, requires = "waf_sidecar_addr")] + waf_body_max: u64, + #[arg(long, default_value_t = 128, requires = "waf_sidecar_addr")] + waf_concurrency: usize, + /// Continue when the configured WAF sidecar is unavailable or malformed. + /// Default is fail-closed (503); this weaker policy requires explicit opt-in. + #[arg(long, default_value_t = false, requires = "waf_sidecar_addr")] + waf_fail_open: bool, + /// Additional request Content-Encoding values to decode before dispatch. + /// Gzip remains enabled for compatibility; Brotli and zstd are opt-in. + /// Accepted comma-separated values: br,zstd. + #[arg(long, default_value = "")] + request_decompression_extra: String, /// (telemetry) Append a cumulative per-request telemetry snapshot row to this /// file every --telemetry-flush-secs: durability across restarts + a /// self-contained time-series for the two-node A/B. Empty = no disk flush (the @@ -290,15 +348,24 @@ struct ServeArgs { /// a deploy-time decision. #[arg(long = "rewrite-ua-classify", default_value_t = false)] rewrite_ua_classify: bool, - /// (uring, STAGED) Kernel-TLS the io_uring TLS path: after the rustls handshake, + /// (uring, STAGED) Kernel-TLS on the io_uring TLS path: after the rustls handshake, /// upgrade the socket to kTLS so H1/H2 serve plaintext over the raw fd (kernel /// encrypt/decrypt), removing the userspace AEAD copy on large-body egress. Runs - /// only on the io_uring TLS path (the default transport). Only active in a - /// `--features ktls` build; otherwise startup fails. TLS 1.3 only (1.2 falls back - /// to userspace); peer KeyUpdate is handled (RX rekey + reply). STAGED — validate - /// on an alt port before production. - #[arg(long = "ktls", default_value_t = false)] - ktls: bool, + /// only on the io_uring TLS path (the default transport). `auto` (default) + /// requires a `--features ktls` build, a physical default-route NIC, and active + /// `tls-hw-tx-offload`; otherwise it logs one reason and stays on rustls. `on` + /// forces kTLS for isolated benchmarking and fails when the feature is absent; + /// `off` never enables it. A bare `--ktls` remains an alias for `--ktls=on`. + /// TLS 1.3 only (1.2 falls back to userspace); peer KeyUpdate is handled (RX + /// rekey + reply). STAGED — validate on an alt port before production. + #[arg( + long = "ktls", + value_enum, + default_value_t = uring::ktls_policy::KtlsMode::Auto, + num_args = 0..=1, + default_missing_value = "on" + )] + ktls: uring::ktls_policy::KtlsMode, } /// The `--page-cache-*` flag family, grouped (clap-flattened into the serve args). @@ -335,6 +402,12 @@ struct PageCacheArgs { /// cacheStorePath is intentionally NOT used. #[arg(long = "page-cache-store-path", default_value = "none")] store_path: String, + /// Integrity key used for persistent cache containers. + #[arg( + long = "page-cache-integrity-key", + default_value = "/usr/local/httpjet/conf/.jetcache.key" + )] + integrity_key: PathBuf, /// Byte cap of the in-RAM hot tier in front of the file store (zero-syscall /// zero-copy serves for the hottest bodies). Only meaningful with /// --page-cache-store-path. Default 192 MiB. @@ -702,6 +775,31 @@ fn resolve_page_cache_store_path(cli: &str) -> Option { } fn serve(root: &std::path::Path, args: ServeArgs) -> anyhow::Result<()> { + let request_decompression = + uring::request_body::RequestDecompression::parse_extra(&args.request_decompression_extra) + .map_err(anyhow::Error::msg)?; + let admin_write_config = match args.admin_write_addr { + Some(addr) => { + let token = admin_auth::AuthToken::load( + args.admin_token_file + .as_deref() + .ok_or_else(|| anyhow::anyhow!("admin token file required"))?, + ) + .map_err(|_| anyhow::anyhow!("invalid admin token file"))?; + let roots = admin_resources::ResourceRoots::new(&args.admin_resource_root) + .map_err(|_| anyhow::anyhow!("invalid admin resource roots"))?; + Some((addr, Arc::new(token), Arc::new(roots))) + } + None => None, + }; + #[cfg(not(feature = "otel"))] + anyhow::ensure!( + std::env::var("HTTPJET_OTEL").as_deref() != Ok("1") + && std::env::var("HTTPJET_OTEL_METRICS").as_deref() != Ok("1"), + "HTTPJET_OTEL requires a build with --features otel" + ); + #[cfg(feature = "otel")] + let _otel = otel::init()?; let workers = args .workers .or_else(|| std::thread::available_parallelism().ok().map(|n| n.get())) @@ -775,22 +873,6 @@ fn serve(root: &std::path::Path, args: ServeArgs) -> anyhow::Result<()> { } let server = Arc::new(cfg); - let http_listener = server - .listeners - .iter() - .find(|l| !l.secure) - .or_else(|| server.listeners.first()); - let http_listener_name: Arc = http_listener - .map(|l| l.name.clone()) - .unwrap_or_else(|| "Default".to_string()) - .into(); - let http_binding = uring::ListenerBinding { - proxy_protocol: http_listener.is_some_and(|l| l.proxy_protocol), - }; - - // The secure listener (if any) drives the TLS config + routing under :443. - let secure_listener = server.listeners.iter().find(|l| l.secure).cloned(); - let https_addr: Option = match args.https_addr.trim() { "" => None, s => Some( @@ -799,59 +881,112 @@ fn serve(root: &std::path::Path, args: ServeArgs) -> anyhow::Result<()> { ), }; + let tcp_launch_policy = listener_plan::TcpLaunchPolicy { + http: args.http_addr, + https: https_addr, + }; + let tcp_plan = tcp_launch_policy.plan(&server); + let http_bind_addr = tcp_plan.http.address; + let https_bind_addr = tcp_plan.https.as_ref().map(|target| target.address); + let http_listener_name = tcp_plan.http.identity.name.clone(); + let http_binding = uring::ListenerBinding { + proxy_protocol: tcp_plan.http.listener.is_some_and(|l| l.proxy_protocol), + }; + let secure_listener = tcp_plan.secure_listener.cloned(); + // Build the unified rustls config up front (surfaces cert errors before bind). + #[cfg(feature = "acme")] + let mut acme = acme_runtime::Prepared::open( + &args.acme, + server.clone(), + &http_listener_name, + secure_listener.as_ref(), + args.http_addr, + https_addr, + )?; // (OPS9) The `_reloadable` variants also return a CertReloadHandle: the cert // material lives behind an ArcSwap inside the (fixed) ServerConfig, so SIGHUP // can re-read renewed cert files and swap them in without a restart. hj_tls::install_crypto_provider()?; - let want_ktls = args.ktls && cfg!(feature = "ktls"); - let (tls_config, tls_cert_handle) = match (&secure_listener, https_addr) { - (Some(l), Some(_)) => { - let (cfg, handle) = hj_tls::build_server_config_reloadable(&server, l)?; - (Some(cfg), Some(handle)) - } - _ => (None, None), - }; - // kTLS (staged, io_uring only) needs a per-connection KeyLog to recover the TLS 1.3 - // traffic secrets for a KeyUpdate rekey — built from a shared config TEMPLATE so each - // connection cheaply gets its own config+KeyLog. Built only when `--ktls` is active in a - // `--features ktls` build; otherwise `None` (the userspace TLS path is used). - let (ktls_template, ktls_cert_handle): ( - Option>, - Option, - ) = match (&secure_listener, https_addr) { - (Some(l), Some(_)) if want_ktls => { - let (template, handle) = hj_tls::build_ktls_template(&server, l)?; - (Some(std::sync::Arc::new(template)), Some(handle)) - } - _ => (None, None), - }; - let _ = &ktls_template; // consumed only on the io_uring TLS path - // HTTP/3 (QUIC) config: same SNI resolver + Cloudflare mTLS verifier, ALPN h3. - // Raw h3-ALPN rustls config captured for the io_uring H3 path (it builds its own - // quinn-proto ServerConfig); `None` unless the uring H3 driver is requested. `rustls` - // is a uring-only optional dep, so this binding only exists in a uring build. - // Raw h3-ALPN rustls config for the io_uring H3 driver (it builds its own quinn-proto - // ServerConfig). `quic_cert_handle` is the reloadable cert handle registered for SIGHUP. - let mut h3_rustls_cfg: Option> = None; - let quic_cert_handle = match (&secure_listener, https_addr) { - (Some(l), Some(_)) if server.quic_enable => { - let (h3_tls, handle) = - hj_tls::build_server_config_alpn_reloadable(&server, l, vec![b"h3".to_vec()])?; - h3_rustls_cfg = Some(h3_tls); - Some(handle) - } - _ => None, - }; + #[cfg(feature = "acme")] + let acme_bootstrap = args.acme.acme_bootstrap; + #[cfg(not(feature = "acme"))] + let acme_bootstrap = false; + let ktls_decision = uring::ktls_policy::resolve(args.ktls)?; + let want_ktls = ktls_decision.enabled; + tracing::info!( + mode = ?args.ktls, + enabled = ktls_decision.enabled, + interface = ktls_decision.interface.as_deref().unwrap_or("none"), + driver = ktls_decision.driver.as_deref().unwrap_or("none"), + reason = %ktls_decision.reason, + "kTLS startup policy resolved" + ); + #[allow(unused_mut)] + let (mut tls_config, mut ktls_template, mut h3_rustls_cfg, tls_cert_handle) = + match (&secure_listener, https_addr) { + (Some(l), Some(_)) => { + let bundle = hj_tls::PreparedListenerTls::prepare( + &server, + l, + acme_bootstrap, + server.quic_enable, + want_ktls, + )?; + ( + Some(bundle.tcp), + bundle.ktls.map(std::sync::Arc::new), + bundle.quic, + Some(bundle.certificates), + ) + } + _ => (None, None, None, None), + }; + // All transports share one resolver and certificate handle. Keep that handle + // singular through manager attachment and SIGHUP; reloading cloned handles + // repeatedly would re-read files and undermine the coherent certificate swap. let alt_svc = https_addr .filter(|_| h3_rustls_cfg.is_some()) .map(|a| format!("h3=\":{}\"; ma=86400", a.port())); + #[cfg(feature = "acme")] + if let Some(acme) = acme.as_mut() { + let name: Arc = secure_listener + .as_ref() + .ok_or_else(|| anyhow::anyhow!("ACME requires a secure listener"))? + .name + .clone() + .into(); + acme.attach_handles( + tls_cert_handle + .clone() + .into_iter() + .map(|handle| (name.clone(), handle)) + .collect(), + )?; + } + #[cfg(feature = "acme")] + let acme_targets = acme + .as_ref() + .map(acme_runtime::Prepared::certificate_targets); + + #[cfg(feature = "ocsp")] + let ocsp = ocsp_runtime::prepare( + &args.ocsp, + args.http_addr, + https_addr, + &mut tls_config, + &mut ktls_template, + &mut h3_rustls_cfg, + tls_cert_handle.clone().into_iter(), + )?; + let php_socket = args.php_socket.clone(); let php_children = args.php_children; let no_php = args.no_php; let lsphp_external = args.lsphp_external.clone(); let metrics_addr = args.metrics_addr.trim().to_string(); + let admin_addr = args.admin_addr; let profile_token = { let t = args.profile_token.trim(); (!t.is_empty()).then(|| t.to_string()) @@ -1017,7 +1152,7 @@ fn serve(root: &std::path::Path, args: ServeArgs) -> anyhow::Result<()> { // read it too — an accepted residual documented in issue #260 (the // full fix is a dedicated PHP uid). if let Err(e) = hj_pagecache::diskstore::init_integrity_key( - &PathBuf::from("/usr/local/httpjet/conf/.jetcache.key"), + &args.page_cache.integrity_key, ) { tracing::warn!(error = %e, "jetcache integrity key unavailable; persisted containers run WITHOUT integrity tags"); } @@ -1151,6 +1286,48 @@ fn serve(root: &std::path::Path, args: ServeArgs) -> anyhow::Result<()> { ); } let state = ServerState::new(server, php_registry.clone(), alt_svc, page_cache, page_cache_dicts, args.page_cache.admit_threshold, xf_capsule, peer_purge, cf_send_zstd, php_slow, args.request_id_header, rewrite_tuning).map_err(anyhow::Error::msg)?; + let state = { + let mut state = state; + Arc::get_mut(&mut state) + .expect("unshared boot state") + .request_decompression = request_decompression; + if request_decompression.brotli || request_decompression.zstd { + tracing::info!( + brotli = request_decompression.brotli, + zstd = request_decompression.zstd, + "additional request decompression ENABLED" + ); + } + state + }; + #[cfg(feature = "acme")] + let state = { + let mut state = state; + if let Some(prepared) = &acme { + Arc::get_mut(&mut state).expect("unshared boot state").acme = Some(prepared.routing.clone()); + } + state + }; + let state = { + let mut state = state; + if let Some(address) = args.waf_sidecar_addr { + let policy = if args.waf_fail_open { + waf::FailurePolicy::Open + } else { + waf::FailurePolicy::Closed + }; + let sidecar = waf::Sidecar::new( + address, + args.waf_sidecar_path.clone(), + std::time::Duration::from_millis(args.waf_timeout_ms), + args.waf_body_max, + args.waf_concurrency, + policy, + )?; + Arc::get_mut(&mut state).expect("unshared boot state").waf = Some(Arc::new(sidecar)); + } + state + }; // (persist) Rebuild the page-cache index from the tmpfs file tier in the // background — the server serves from request #1, with not-yet-scanned keys // simply missing during the ~seconds-long walk. Each kept key pre-warms the @@ -1200,6 +1377,29 @@ fn serve(root: &std::path::Path, args: ServeArgs) -> anyhow::Result<()> { // runtime half — counters, shutdown, caches, pools — is shared across // generations, so gen-0 reflects current totals after any reload). let holder: Arc> = Arc::new(ArcSwap::from(state.clone())); + let transactions = config_transaction::Coordinator::new(holder.clone())? + .with_tcp_launch_policy(tcp_launch_policy); + let transactions = if let Some(path) = args.http_uds.clone() { + transactions.with_uds_launch_policy(listener_plan::UdsLaunchPolicy { path }) + } else { + transactions + }; + #[cfg(feature = "acme")] + let transactions = transactions.with_acme_targets(acme_targets); + #[cfg(feature = "ocsp")] + let transactions = transactions.with_ocsp_policy(args.ocsp.clone()); + let transactions = Arc::new(transactions); + let admin_write_listener = if let Some((addr, token, roots)) = admin_write_config { + Some((tokio::net::TcpListener::bind(addr).await?, token, roots)) + } else { + None + }; + state.proxy.pool().activate_health_checks(); + let admin_listener = if let Some(addr) = admin_addr { + Some(tokio::net::TcpListener::bind(addr).await?) + } else { + None + }; // (OPS8) Adopt systemd socket-activation fds when present so a binary // deploy (`systemctl restart httpjet.service`) never closes the listen @@ -1221,10 +1421,7 @@ fn serve(root: &std::path::Path, args: ServeArgs) -> anyhow::Result<()> { // socket-activation fds (bound as root, passed to this `nobody` process); alt-port / // manual runs self-bind one SO_REUSEPORT socket per worker inside `uring`. // kTLS (staged) runs on the io_uring TLS path of a `--features ktls` build. - let use_ktls = args.ktls && want_ktls; - if args.ktls && !use_ktls && !cfg!(feature = "ktls") { - anyhow::bail!("--ktls requires a `--features ktls` build"); - } + let use_ktls = want_ktls; if use_ktls { tracing::warn!("kTLS ENABLED (staged): serving TLS 1.3 over kernel-TLS sockets on the io_uring path (TLS 1.2 falls back to userspace). Peer KeyUpdate is handled (RX rekey + reply). Validate before production."); } @@ -1233,15 +1430,16 @@ fn serve(root: &std::path::Path, args: ServeArgs) -> anyhow::Result<()> { .map(|v| v.into_iter().map(|l| l.into_std()).collect::>>()) .transpose()?; let bridge_admission = uring::pipeline_admission(holder.clone()); - uring::spawn_uring_http( + let mut transport_workers = vec![uring::spawn_uring_http( holder.clone(), http_listener_name.clone(), - args.http_addr, + http_bind_addr, workers, inh_http_std, bridge_admission.clone(), http_binding, - )?; + )?]; + let mut quic_resources = None; tracing::info!(%args.http_addr, listener = %http_listener_name, workers, "plain HTTP up (io_uring thread-per-core transport)"); let mut handles: Vec> = Vec::new(); @@ -1249,25 +1447,25 @@ fn serve(root: &std::path::Path, args: ServeArgs) -> anyhow::Result<()> { // one core, fabricated loopback peers. TLS-over-UDS is unsupported. if let Some(uds) = args.http_uds.clone() { let inherited = inh_unix.unwrap_or_default().into_iter().next(); - uring::spawn_uring_uds( + transport_workers.push(uring::spawn_uring_uds( holder.clone(), http_listener_name.clone(), uds.clone(), - inherited, + inherited.map(uring::UdsListenerInput::Inherited), bridge_admission.clone(), - )?; + )?); tracing::info!(path = %uds.display(), "unix socket HTTP up"); } if let (Some(addr), Some(tls_config), Some(l)) = - (https_addr, tls_config, secure_listener.as_ref()) + (https_bind_addr, tls_config, secure_listener.as_ref()) { let name: Arc = l.name.clone().into(); let mtls = if args.no_mtls { 0 } else { l.tls.as_ref().map(|t| t.client_verify).unwrap_or(0) }; let inh_https_std = inh_https .map(|v| v.into_iter().map(|l| l.into_std()).collect::>>()) .transpose()?; - uring::spawn_uring_https( + transport_workers.push(uring::spawn_uring_https( holder.clone(), name.clone(), addr, @@ -1280,13 +1478,13 @@ fn serve(root: &std::path::Path, args: ServeArgs) -> anyhow::Result<()> { uring::ListenerBinding { proxy_protocol: l.proxy_protocol, }, - )?; + )?); tracing::info!(%addr, listener = %name, client_verify = mtls, workers, ktls = use_ktls, "TLS up (io_uring thread-per-core transport; H1/H2 over rustls-on-monoio; mTLS required for external peers when client_verify=2, loopback/private-LAN exempt)"); } // HTTP/3 (QUIC) on the same address (UDP) — io_uring quinn-proto driver → real pipeline. if let (Some(addr), Some(h3cfg), Some(l)) = - (https_addr, h3_rustls_cfg.take(), secure_listener.as_ref()) + (https_bind_addr, h3_rustls_cfg.take(), secure_listener.as_ref()) { let name: Arc = l.name.clone().into(); let mtls = if args.no_mtls { 0 } else { l.tls.as_ref().map(|t| t.client_verify).unwrap_or(0) }; @@ -1300,13 +1498,21 @@ fn serve(root: &std::path::Path, args: ServeArgs) -> anyhow::Result<()> { inh_quic.take(), bridge_admission.clone(), ) { - Ok(()) => tracing::warn!(%addr, listener = %name, client_verify = mtls, "h3/QUIC up (io_uring quinn-proto driver → real pipeline; mTLS required for external peers when client_verify=2, loopback/private-LAN exempt)"), + Ok((group, policy)) => { + quic_resources = Some(resource_generation::QuicResources::new( + group, + policy, + &state.trust_epoch, + )?); + tracing::warn!(%addr, listener = %name, client_verify = mtls, "h3/QUIC up (io_uring quinn-proto driver → real pipeline; mTLS required for external peers when client_verify=2, loopback/private-LAN exempt)"); + }, Err(e) => anyhow::bail!("failed to start io_uring h3 listener: {e}"), } } // (OPS1) Loopback metrics endpoint (default 127.0.0.1:9090; empty = off). if !metrics_addr.is_empty() { + // The independent admin listener does not expose metrics control routes. match metrics_addr.parse::() { Ok(addr) if !metrics::metrics_bind_allowed(&addr) => { tracing::error!( @@ -1331,6 +1537,26 @@ fn serve(root: &std::path::Path, args: ServeArgs) -> anyhow::Result<()> { Err(e) => tracing::error!(error = %e, addr = %metrics_addr, "invalid --metrics-addr"), } } + if let Some(listener) = admin_listener { + handles.push(tokio::spawn(admin::serve(listener, holder.clone()))); + } + if let Some((listener, token, roots)) = admin_write_listener { + let admission = bridge_admission.clone(); + let control = Arc::new(admin_write::Control::new( + transactions.clone(), roots, args.no_mtls, args.per_ip_rate, + Arc::new(move || admission.limit_changed()), + ) + .with_tcp_replacement(admin_write::TcpReplacementPolicy { + #[cfg(feature = "acme")] + acme_bootstrap: args.acme.acme_bootstrap, + #[cfg(not(feature = "acme"))] + acme_bootstrap: false, + ktls: use_ktls, + admission: bridge_admission.clone(), + })); + tracing::info!(address = %listener.local_addr()?, "authenticated config-write endpoint active; changes are volatile"); + handles.push(tokio::spawn(admin_write::serve(listener, token, control))); + } // (mem) Periodic mimalloc OS-trim: return retained/cold arena memory so it // does not accumulate as swapped-out dirty pages on a memory-pressured box. @@ -1359,6 +1585,15 @@ fn serve(root: &std::path::Path, args: ServeArgs) -> anyhow::Result<()> { } tracing::info!(workers, vhosts = state.server.vhosts.len(), "httpjet serving. Ctrl-C to stop."); + #[cfg(feature = "acme")] + if let Some(acme) = acme { + let shutdown = state.shutdown.clone(); + handles.push(tokio::spawn(async move { + if let Err(error) = acme.run(shutdown).await { + tracing::error!(%error, "ACME manager stopped; operator reconciliation required"); + } + })); + } // Serve until SIGINT/SIGTERM (systemctl stop / kill). SIGUSR1 reopens the // logs (logrotate); SIGUSR2 toggles the log level at runtime — neither // exits, so the wait is a loop. @@ -1367,9 +1602,28 @@ fn serve(root: &std::path::Path, args: ServeArgs) -> anyhow::Result<()> { let mut sigusr1 = signal(SignalKind::user_defined1()).expect("install SIGUSR1 handler"); let mut sigusr2 = signal(SignalKind::user_defined2()).expect("install SIGUSR2 handler"); let mut sighup = signal(SignalKind::hangup()).expect("install SIGHUP handler"); + // All transport groups and process-level setup are ready before any + // worker accepts. A startup error above retires still-inactive groups. + let resources = resource_generation::TransportResources::new( + state.trust_epoch.clone(), transport_workers, + )?; + let resources = if let (Some(handle), Some(listener)) = (tls_cert_handle, secure_listener.as_ref()) { + resources.with_certificate(uring::worker_group::TcpListenerId { + name: listener.name.clone().into(), tls: true, + }, handle)? + } else { resources }; + #[cfg(feature = "ocsp")] + let resources = if let Some(manager) = ocsp { + resources.with_ocsp(manager, &state.shutdown)? + } else { resources }; + transactions + .install_initial_resources_with_quic(resources, quic_resources) + .map_err(|_| anyhow::anyhow!("resource ownership installation rejected"))?; + let mut resource_reaper = tokio::time::interval(std::time::Duration::from_secs(1)); let mut debug_on = false; loop { tokio::select! { + _ = resource_reaper.tick() => { transactions.reap_retired(); }, _ = tokio::signal::ctrl_c() => break, _ = sigterm.recv() => break, _ = sighup.recv() => { @@ -1388,8 +1642,13 @@ fn serve(root: &std::path::Path, args: ServeArgs) -> anyhow::Result<()> { // with the already-built TLS acceptor (else --no-mtls would // make every reload look like a TLS change and get rejected). apply_no_mtls(&mut cfg, args.no_mtls); + if let Some(rate) = args.per_ip_rate { + cfg.tuning.per_ip_rate = rate; + } let new_server = Arc::new(cfg); - let cur = holder.load_full(); + let transaction = transactions.begin().await; + let revision = transaction.revision(); + let cur = transaction.current.clone(); if let Some(reason) = hard_config_change(&cur.server, &new_server) { tracing::warn!( reason, @@ -1405,24 +1664,23 @@ fn serve(root: &std::path::Path, args: ServeArgs) -> anyhow::Result<()> { "SIGHUP: reload would 404 mapped vhost(s) whose per-vhost config file failed to load — keeping current config" ); } else { + // Complete fallible application-state construction + // before touching independent certificate resolvers. + // Multi-resource atomic publication is tracked in #428. + let next = match ServerState::reload(&cur, new_server.clone()) { + Ok(next) => next, + Err(error) => { + tracing::error!(%error, "SIGHUP: reload rejected, keeping the current generation"); + continue; + } + }; // (OPS9) Live cert reload: re-read the (possibly // renewed) cert files and swap them into the running // resolver — new handshakes use the new certs, no // restart. A load failure keeps the current certs. - // Done BEFORE new_server is consumed by reload(). - if let Some(secure) = new_server.listeners.iter().find(|l| l.secure) { - visit_present_named( - [ - ("TLS", tls_cert_handle.as_ref()), - ("kTLS", ktls_cert_handle.as_ref()), - ("QUIC", quic_cert_handle.as_ref()), - ], - |kind, handle| { - if let Err(e) = handle.reload(&new_server, secure) { - tracing::error!(error = %e, kind, "SIGHUP: certificate reload failed; keeping current certs"); - } - }, - ); + // Application candidate validation has already passed. + if let Err(e) = transaction.reload_certificates(&new_server) { + tracing::error!(error = %e, "SIGHUP: active TLS certificate reload failed; retaining certificates whose reload failed"); } // (audit) Be precise about what "certs" means: only the // SNI/default SERVER certs are swapped. The client-cert @@ -1437,16 +1695,12 @@ fn serve(root: &std::path::Path, args: ServeArgs) -> anyhow::Result<()> { "SIGHUP: client-CA trust stores are BOOT-frozen; if an origin-pull CA changed, RESTART httpjet" ); } - match ServerState::reload(&cur, new_server) { - Ok(next) => { - holder.store(next); - bridge_admission.limit_changed(); - tracing::info!("SIGHUP: config hot-reloaded (config + SNI server certs; client-CA stores boot-frozen; cache + lsphp + connections preserved)"); - } - Err(e) => { - tracing::error!(error = %e, "SIGHUP: reload rejected, keeping the current generation"); - } + if let Err(error) = transaction.publish(&revision, next) { + tracing::error!(?error, "SIGHUP: publication conflict; keeping current config"); + continue; } + bridge_admission.limit_changed(); + tracing::info!("SIGHUP: config hot-reloaded (config + SNI server certs; client-CA stores boot-frozen; cache + lsphp + connections preserved)"); } } Err(e) => { @@ -1496,7 +1750,9 @@ fn serve(root: &std::path::Path, args: ServeArgs) -> anyhow::Result<()> { // the lsphp drain below) so systemd never SIGKILLs us mid-drain; an // unbounded stream (SSE/long-poll) that never ends is cut at the budget. tracing::info!("shutdown signal received; draining connections"); + transactions.close(); state.shutdown.cancel(); + holder.load().proxy.pool().stop_health_checks(); let drain_budget = std::time::Duration::from_secs(12); let drain_start = std::time::Instant::now(); loop { @@ -1524,6 +1780,7 @@ fn serve(root: &std::path::Path, args: ServeArgs) -> anyhow::Result<()> { for h in handles { h.abort(); } + transactions.finish_shutdown(); // Drain every started lsphp pool. drain_all preserves the Phase-3 // cancel-before-drain semantics per pool: it cancels each monitor ticker // (so the monitor does not fight the intentional stop with a restart), @@ -2026,17 +2283,6 @@ fn apply_no_mtls(cfg: &mut hj_core::config::ServerConfig, no_mtls: bool) { } } -fn visit_present_named( - handles: [(&'static str, Option<&T>); 3], - mut visit: impl FnMut(&'static str, &T), -) { - for (name, handle) in handles { - if let Some(handle) = handle { - visit(name, handle); - } - } -} - /// (OPS6) Decide whether a re-parsed config can be hot-reloaded. Returns /// `Some(reason)` when a change touches state that lives OUTSIDE the swappable /// `ServerState` — the bound listener sockets / TLS acceptor, or the lsphp pool — @@ -2162,11 +2408,10 @@ fn check(root: &std::path::Path, strict: bool) -> anyhow::Result<()> { for l in &cfg.listeners { let tls = match &l.tls { Some(t) => { - // OCSP stapling is parsed but NOT implemented (no responder fetch); say so - // rather than printing `stapling=true`, which falsely implies it is active. - // Harmless behind Cloudflare, which terminates TLS to clients. + // XML alone never starts responder traffic; runtime requires an + // explicit endpoint and an OCSP-enabled build. Do not imply active. let stapling = if t.enable_stapling { - "requested(no-op: unimplemented)" + "requested(requires --features ocsp and --ocsp-responder)" } else { "off" }; @@ -2216,6 +2461,7 @@ fn check(root: &std::path::Path, strict: bool) -> anyhow::Result<()> { let kind = match e.kind { ExtKind::Proxy => "proxy", ExtKind::Lsapi => "lsapi", + ExtKind::FastCgi => "fastcgi", }; println!(" - {} [{}] -> {:?}", e.name, kind, e.address); } @@ -2595,6 +2841,61 @@ fn lint_topology(cfg: &hj_config::ServerConfig, strict: bool) -> anyhow::Result< mod tests { use super::*; + fn parsed_ktls_mode(args: &[&str]) -> uring::ktls_policy::KtlsMode { + let cli = Cli::try_parse_from(["httpjet", "serve"].into_iter().chain(args.iter().copied())) + .expect("serve CLI should parse"); + let Some(Command::Serve(args)) = cli.command else { + panic!("expected serve command"); + }; + args.ktls + } + + #[test] + fn ktls_cli_defaults_auto_and_accepts_explicit_policy() { + use uring::ktls_policy::KtlsMode; + + assert_eq!(parsed_ktls_mode(&[]), KtlsMode::Auto); + assert_eq!(parsed_ktls_mode(&["--ktls=auto"]), KtlsMode::Auto); + assert_eq!(parsed_ktls_mode(&["--ktls=on"]), KtlsMode::On); + assert_eq!(parsed_ktls_mode(&["--ktls=off"]), KtlsMode::Off); + assert_eq!(parsed_ktls_mode(&["--ktls"]), KtlsMode::On); + assert!(Cli::try_parse_from(["httpjet", "serve", "--ktls=invalid"]).is_err()); + } + + #[test] + fn admin_write_cli_requires_explicit_auth_and_roots() { + let base = ["httpjet", "serve"]; + assert!(Cli::try_parse_from(base).is_ok()); + for args in [ + vec!["--admin-write-addr", "127.0.0.1:9092"], + vec!["--admin-token-file", "/token"], + vec!["--admin-resource-root", "/content"], + vec![ + "--admin-write-addr", + "0.0.0.0:9092", + "--admin-token-file", + "/token", + "--admin-resource-root", + "/content", + ], + ] { + assert!(Cli::try_parse_from(base.into_iter().chain(args)).is_err()); + } + assert!( + Cli::try_parse_from(base.into_iter().chain([ + "--admin-write-addr", + "127.0.0.1:9092", + "--admin-token-file", + "/token", + "--admin-resource-root", + "/content", + "--admin-resource-root", + "/resources", + ])) + .is_ok() + ); + } + #[test] fn pp_bind_classifier_warns_only_on_public_reachable_binds() { // Wildcard / any-address binds are reachable by untrusted direct peers. @@ -2821,21 +3122,4 @@ mod tests { routing_only.php_config.as_mut().unwrap().suffixes = vec!["html".into()]; assert_eq!(hard_config_change(&base, &routing_only), None); } - - #[test] - fn certificate_reload_visits_the_ktls_handle() { - let tls = 1; - let ktls = 2; - let quic = 3; - let mut visited = Vec::new(); - visit_present_named( - [ - ("TLS", Some(&tls)), - ("kTLS", Some(&ktls)), - ("QUIC", Some(&quic)), - ], - |name, handle| visited.push((name, *handle)), - ); - assert_eq!(visited, vec![("TLS", 1), ("kTLS", 2), ("QUIC", 3)]); - } } diff --git a/crates/httpjet/src/metrics.rs b/crates/httpjet/src/metrics.rs index d87e9dc..f72b6a9 100644 --- a/crates/httpjet/src/metrics.rs +++ b/crates/httpjet/src/metrics.rs @@ -343,6 +343,41 @@ fn render(state: &ServerState) -> String { state.page_cache.as_ref().map(|pc| pc.stats()), ); append_body_budget_metrics(&mut body, &state.body_budget); + #[cfg(feature = "otel")] + { + let (attempts, failures) = crate::otel::export_stats(); + let (queued, dropped, exported, failed) = crate::otel::span_stats(); + for (name, value, kind, help) in [ + ( + "queue", + queued, + "gauge", + "Spans awaiting batch admission; excludes current batch/export.", + ), + ( + "dropped_total", + dropped, + "counter", + "Sampled spans dropped by queue overflow or processor closure.", + ), + ( + "exported_total", + exported, + "counter", + "Spans in successful exporter calls; not collector ingestion confirmation.", + ), + ( + "export_failed_total", + failed, + "counter", + "Spans in failed exporter calls.", + ), + ] { + body.push_str(&format!("# HELP httpjet_otel_spans_{name} {help}\n# TYPE httpjet_otel_spans_{name} {kind}\nhttpjet_otel_spans_{name} {value}\n")); + } + body.push_str(&format!("# HELP httpjet_otel_http_exports_total Completed collector HTTP attempts.\n# TYPE httpjet_otel_http_exports_total counter\nhttpjet_otel_http_exports_total {attempts}\n# HELP httpjet_otel_http_export_failures_total Failed collector HTTP attempts.\n# TYPE httpjet_otel_http_export_failures_total counter\nhttpjet_otel_http_export_failures_total {failures}\n")); + } + append_proxy_peer_metrics(&mut body, &state.proxy.pool().peer_snapshots()); body.push_str(&format!( "# HELP httpjet_proxy_failover_total Requests served by a failover upstream peer because the primary was marked bad (Tier 1.2).\n# TYPE httpjet_proxy_failover_total counter\nhttpjet_proxy_failover_total {}\n", state.proxy.pool().failovers_total() @@ -780,6 +815,65 @@ fn request_target(req: &[u8]) -> Option { /// Render the live page-cache contents (loopback debug): per-URL-class histogram + the largest /// entries, so an operator can see EXACTLY what is cached (and whether it's the recurring set or /// junk) instead of inferring from aggregate counters. +fn append_proxy_peer_metrics(out: &mut String, peers: &[hj_proxy::PeerSnapshot]) { + use std::fmt::Write; + let escape = |s: &str| { + s.replace('\\', "\\\\") + .replace('\n', "\\n") + .replace('"', "\\\"") + }; + for (name, kind, help) in [ + ( + "healthy", + "gauge", + "Active health eligibility; one when probes are disabled.", + ), + ("probes_total", "counter", "Completed active health probes."), + ( + "health_transitions_total", + "counter", + "Active health eligibility changes.", + ), + ( + "active_requests", + "gauge", + "Selected requests retained through response or relay completion.", + ), + ( + "selections_total", + "counter", + "Requests selected for a configured peer.", + ), + ( + "prehead_failures_total", + "counter", + "Selected attempts ending before a response head, including cancellation.", + ), + ] { + let _ = writeln!( + out, + "# HELP httpjet_proxy_peer_{name} {help}\n# TYPE httpjet_proxy_peer_{name} {kind}" + ); + for p in peers { + let value = match name { + "healthy" => u64::from(p.healthy), + "probes_total" => p.probes, + "health_transitions_total" => p.transitions, + "active_requests" => p.active, + "selections_total" => p.selections, + _ => p.failures, + }; + let _ = writeln!( + out, + "httpjet_proxy_peer_{name}{{scope=\"{}\",group=\"{}\",peer=\"{}\"}} {value}", + escape(&p.scope), + escape(&p.group), + p.peer + ); + } + } +} + fn append_body_budget_metrics(out: &mut String, budget: &hj_core::budget::BodyBufferBudget) { use std::fmt::Write; for (name, kind, help, value) in [ diff --git a/crates/httpjet/src/ocsp_runtime.rs b/crates/httpjet/src/ocsp_runtime.rs new file mode 100644 index 0000000..20e0e0d --- /dev/null +++ b/crates/httpjet/src/ocsp_runtime.rs @@ -0,0 +1,197 @@ +//! Explicit OCSP opt-in. Existing XML alone does not initiate responder traffic. +use std::{net::SocketAddr, sync::Arc, time::Duration}; + +#[derive(clap::Args, Debug, Default, Clone, PartialEq, Eq)] +pub(crate) struct OcspArgs { + /// Explicit HTTP(S) responder for the listener's certificate issuer(s). + #[arg(long)] + ocsp_responder: Option, + /// Refuse new TLS handshakes without a fresh authenticated OCSP response. + #[arg(long, requires = "ocsp_responder")] + ocsp_required: bool, + /// Permit a literal-loopback responder, with loopback serving addresses only. + #[arg(long, requires = "ocsp_responder")] + ocsp_test_mode: bool, +} + +impl OcspArgs { + pub(crate) fn enabled(&self) -> bool { + self.ocsp_responder.is_some() + } +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn prepare( + args: &OcspArgs, + http_addr: SocketAddr, + https_addr: Option, + tcp: &mut Option>, + ktls: &mut Option>, + quic: &mut Option>, + handles: impl Iterator, +) -> anyhow::Result>> { + let Some(endpoint) = &args.ocsp_responder else { + return Ok(None); + }; + anyhow::ensure!( + https_addr.is_some() && tcp.is_some(), + "OCSP requires an enabled TLS listener" + ); + if args.ocsp_test_mode { + anyhow::ensure!( + http_addr.ip().is_loopback() && https_addr.unwrap().ip().is_loopback(), + "OCSP test mode requires loopback serving addresses" + ); + } + let manager = hj_tls::ocsp::Stapling::new(endpoint, args.ocsp_test_mode, args.ocsp_required)?; + for config in [tcp, quic].into_iter().filter_map(Option::as_mut) { + let config = Arc::get_mut(config).ok_or_else(|| { + anyhow::anyhow!("OCSP must be configured before TLS configs are shared") + })?; + hj_tls::ocsp::disable_resumption(config); + } + if let Some(template) = ktls { + Arc::get_mut(template) + .ok_or_else(|| { + anyhow::anyhow!("OCSP must be configured before kTLS template is shared") + })? + .disable_ocsp_resumption(); + } + for handle in handles { + handle.enable_ocsp(manager.clone())?; + } + tracing::info!( + identities = manager.active_identities(), + required = args.ocsp_required, + "OCSP manager configured; TLS resumption disabled; responder traffic starts after serving" + ); + Ok(Some(manager)) +} + +pub(crate) async fn run( + manager: Arc, + shutdown: tokio_util::sync::CancellationToken, +) { + loop { + tokio::select! { _ = shutdown.cancelled() => return, _ = manager.refresh() => {} } + tokio::select! { _ = shutdown.cancelled() => return, _ = tokio::time::sleep(Duration::from_secs(1)) => {} } + } +} + +/// Prepared refresh task owned by one resource generation. It stays alive +/// during worker retirement; dropping the owner cancels and aborts the task. +pub(crate) struct RefreshTask { + activation: tokio_util::sync::CancellationToken, + shutdown: tokio_util::sync::CancellationToken, + task: tokio::task::JoinHandle<()>, +} + +impl RefreshTask { + pub(crate) fn prepare( + manager: Arc, + parent: &tokio_util::sync::CancellationToken, + ) -> Self { + Self::prepare_with(parent, move |shutdown| run(manager, shutdown)) + } + + pub(crate) fn prepare_with(parent: &tokio_util::sync::CancellationToken, run: F) -> Self + where + F: FnOnce(tokio_util::sync::CancellationToken) -> Fut + Send + 'static, + Fut: std::future::Future + Send + 'static, + { + let activation = tokio_util::sync::CancellationToken::new(); + let shutdown = parent.child_token(); + let gate = activation.clone(); + let stop = shutdown.clone(); + let task = tokio::spawn(async move { + tokio::select! { + biased; + _ = stop.cancelled() => return, + _ = gate.cancelled() => {}, + } + run(stop).await; + }); + Self { + activation, + shutdown, + task, + } + } + + pub(crate) fn is_prepared(&self) -> bool { + !self.activation.is_cancelled() && !self.shutdown.is_cancelled() && !self.task.is_finished() + } + + pub(crate) fn activate(&self) { + self.activation.cancel(); + } +} + +impl Drop for RefreshTask { + fn drop(&mut self) { + self.shutdown.cancel(); + self.task.abort(); + } +} + +#[cfg(test)] +mod lifecycle_tests { + use super::*; + #[tokio::test] + async fn refresh_waits_for_activation_and_owner_drop_cancels_it() { + let parent = tokio_util::sync::CancellationToken::new(); + let (started_tx, mut started) = tokio::sync::oneshot::channel(); + let (dropped_tx, dropped) = tokio::sync::oneshot::channel(); + struct OnDrop(Option>); + impl Drop for OnDrop { + fn drop(&mut self) { + let _ = self.0.take().unwrap().send(()); + } + } + let task = RefreshTask::prepare_with(&parent, move |shutdown| async move { + let _guard = OnDrop(Some(dropped_tx)); + let _ = started_tx.send(()); + shutdown.cancelled().await; + }); + tokio::task::yield_now().await; + assert!(task.is_prepared()); + assert!(matches!( + started.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + task.activate(); + tokio::time::timeout(Duration::from_secs(1), started) + .await + .unwrap() + .unwrap(); + assert!(!task.is_prepared()); + drop(task); + tokio::time::timeout(Duration::from_secs(1), dropped) + .await + .unwrap() + .unwrap(); + assert!(!parent.is_cancelled()); + } + + #[tokio::test] + async fn discarded_and_shutdown_candidates_never_start_refresh() { + for shutdown_first in [false, true] { + let parent = tokio_util::sync::CancellationToken::new(); + let (tx, rx) = tokio::sync::oneshot::channel(); + let task = RefreshTask::prepare_with(&parent, move |_| async move { + let _ = tx.send(()); + }); + if shutdown_first { + parent.cancel(); + task.activate(); + } + drop(task); + assert!( + tokio::time::timeout(Duration::from_secs(1), rx) + .await + .unwrap() + .is_err() + ); + } + } +} diff --git a/crates/httpjet/src/otel.rs b/crates/httpjet/src/otel.rs new file mode 100644 index 0000000..29ce8ef --- /dev/null +++ b/crates/httpjet/src/otel.rs @@ -0,0 +1,1025 @@ +//! Explicit, opt-in spans. No blanket export of log fields or request metadata. +use opentelemetry::{ + Context, KeyValue, global, + trace::{FutureExt, SpanKind, TraceContextExt, Tracer}, +}; +use opentelemetry_otlp::{WithExportConfig, WithHttpConfig}; +use opentelemetry_sdk::trace::{Sampler, SdkTracerProvider}; +mod batch; +static BATCH_STATS: std::sync::OnceLock> = std::sync::OnceLock::new(); +pub fn span_stats() -> (u64, u64, u64, u64) { + BATCH_STATS.get().map_or((0, 0, 0, 0), |s| { + ( + s.queued.load(Ordering::Relaxed), + s.dropped.load(Ordering::Relaxed), + s.exported.load(Ordering::Relaxed), + s.failed.load(Ordering::Relaxed), + ) + }) +} +use std::{ + future::Future, + sync::atomic::{AtomicBool, Ordering}, + time::Duration, +}; + +static ENABLED: AtomicBool = AtomicBool::new(false); +static TRUSTED_PARENTS: std::sync::OnceLock> = std::sync::OnceLock::new(); + +fn parse_trusted_parents(value: &str) -> anyhow::Result> { + anyhow::ensure!( + value.len() <= 4096, + "OTel trusted-parent list exceeds 4096 bytes" + ); + if value.trim().is_empty() { + return Ok(Vec::new()); + } + let mut peers = Vec::new(); + for part in value.split(',') { + anyhow::ensure!( + peers.len() < 128, + "OTel trusted-parent list exceeds 128 entries" + ); + let peer: std::net::IpAddr = part + .trim() + .parse() + .map_err(|_| anyhow::anyhow!("OTel trusted parents require exact IP addresses"))?; + anyhow::ensure!( + !peer.is_unspecified() && !peer.is_multicast(), + "invalid OTel trusted-parent address" + ); + peers.push(peer); + } + Ok(peers) +} + +/// This trust decision is separate from forwarded-client-IP trust. Never import +/// baggage or tracestate, even from an explicitly authorized tracing gateway. +fn extract_parent(headers: &mut http::HeaderMap, trusted: bool) -> Context { + use opentelemetry::propagation::TextMapPropagator; + struct Parent<'a>(&'a str); + impl opentelemetry::propagation::Extractor for Parent<'_> { + fn get(&self, key: &str) -> Option<&str> { + (key == "traceparent").then_some(self.0) + } + fn keys(&self) -> Vec<&str> { + vec!["traceparent"] + } + } + let mut parent = Context::new(); + if trusted && headers.get_all("traceparent").iter().count() == 1 { + if let Some(value) = headers.get("traceparent").and_then(|v| v.to_str().ok()) { + // Deliberately support only the fixed-size W3C version-00 format. + if value.len() == 55 + && value.starts_with("00-") + && value.bytes().enumerate().all(|(i, b)| { + if [2, 35, 52].contains(&i) { + b == b'-' + } else { + b.is_ascii_digit() || (b'a'..=b'f').contains(&b) + } + }) + { + parent = opentelemetry_sdk::propagation::TraceContextPropagator::new() + .extract_with_context(&Context::new(), &Parent(value)); + } + } + } + for name in ["traceparent", "tracestate", "baggage"] { + headers.remove(name); + } + parent +} + +pub fn inbound_parent( + headers: &mut http::HeaderMap, + peer: std::net::IpAddr, + direct_tcp: bool, +) -> Context { + let trusted = direct_tcp + && TRUSTED_PARENTS + .get() + .is_some_and(|peers| peers.contains(&peer)); + extract_parent(headers, trusted) +} +static EXPORT_ATTEMPTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +static EXPORT_FAILURES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +pub fn export_stats() -> (u64, u64) { + ( + EXPORT_ATTEMPTS.load(Ordering::Relaxed), + EXPORT_FAILURES.load(Ordering::Relaxed), + ) +} +static METRICS: std::sync::OnceLock = std::sync::OnceLock::new(); +struct RequestMetrics { + completed: opentelemetry::metrics::Counter, + duration: opentelemetry::metrics::Histogram, + exports: opentelemetry::metrics::Counter, +} +impl RequestMetrics { + fn new(meter: &opentelemetry::metrics::Meter) -> Self { + Self { + completed: meter + .u64_counter("httpjet.pipeline.requests.completed") + .build(), + duration: meter + .f64_histogram("httpjet.pipeline.response_head.duration") + .with_unit("s") + .build(), + exports: meter.u64_counter("httpjet.otel.http.exports").build(), + } + } + fn response_head(&self, status: http::StatusCode, duration: Duration) { + let attributes = [KeyValue::new( + "http.response.status_code", + i64::from(status.as_u16()), + )]; + self.completed.add(1, &attributes); + self.duration.record(duration.as_secs_f64(), &attributes); + } +} + +#[derive(Debug)] +struct BoundedClient(reqwest::blocking::Client); +impl BoundedClient { + fn new() -> anyhow::Result { + Ok(Self( + reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(2)) + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build()?, + )) + } +} +#[async_trait::async_trait] +impl opentelemetry_http::HttpClient for BoundedClient { + async fn send_bytes( + &self, + request: http::Request, + ) -> Result, opentelemetry_http::HttpError> { + let result = self.send_bounded(request); + EXPORT_ATTEMPTS.fetch_add(1, Ordering::Relaxed); + if !result.as_ref().is_ok_and(|r| r.status().is_success()) { + EXPORT_FAILURES.fetch_add(1, Ordering::Relaxed); + } + if let Some(m) = METRICS.get() { + let success = result.as_ref().is_ok_and(|r| r.status().is_success()); + m.exports.add( + 1, + &[KeyValue::new( + "outcome", + if success { "success" } else { "failure" }, + )], + ); + } + result + } +} +impl BoundedClient { + fn send_bounded( + &self, + request: http::Request, + ) -> Result, opentelemetry_http::HttpError> { + use std::io::Read; + let response = self.0.execute(request.try_into()?)?; + let status = response.status(); + let headers = response.headers().clone(); + let mut body = Vec::new(); + response.take(65537).read_to_end(&mut body)?; + if body.len() > 65536 { + return Err("OTLP response exceeds 64 KiB".into()); + } + let mut result = http::Response::builder().status(status).body(body.into())?; + *result.headers_mut() = headers; + Ok(result) + } +} + +pub struct Runtime( + SdkTracerProvider, + Option, +); +impl Drop for Runtime { + fn drop(&mut self) { + ENABLED.store(false, Ordering::Relaxed); + // The metrics SDK currently ignores its shutdown timeout argument. Bound + // the caller's wait independently; the HTTP client still bounds each export. + let trace = self.0.clone(); + let metrics = self.1.take(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = trace.shutdown_with_timeout(Duration::from_secs(2)); + if let Some(m) = metrics { + let _ = m.shutdown(); + } + let _ = tx.send(()); + }); + let _ = rx.recv_timeout(Duration::from_secs(3)); + } +} + +pub fn enabled() -> bool { + ENABLED.load(Ordering::Relaxed) +} +#[cfg(test)] +pub(crate) fn enable_for_isolated_test() { + ENABLED.store(true, Ordering::Relaxed); +} + +pub fn execution_path(on_core: bool) { + Context::current().span().set_attribute(KeyValue::new( + "httpjet.execution.path", + if on_core { "on-core" } else { "bridge" }, + )); +} + +pub fn init() -> anyhow::Result> { + if std::env::var("HTTPJET_OTEL").as_deref() != Ok("1") { + anyhow::ensure!( + std::env::var("HTTPJET_OTEL_METRICS").as_deref() != Ok("1"), + "HTTPJET_OTEL_METRICS requires HTTPJET_OTEL=1" + ); + return Ok(None); + } + let ratio = std::env::var("HTTPJET_OTEL_SAMPLE_RATIO") + .unwrap_or_else(|_| "0.01".into()) + .parse::()?; + anyhow::ensure!( + ratio.is_finite() && (0.0..=1.0).contains(&ratio), + "invalid HTTPJET_OTEL_SAMPLE_RATIO" + ); + let trusted_parents = + parse_trusted_parents(&std::env::var("HTTPJET_OTEL_TRUSTED_PARENTS").unwrap_or_default())?; + let exporter = opentelemetry_otlp::SpanExporter::builder() + .with_http() + .with_http_client(BoundedClient::new()?) + .with_protocol(opentelemetry_otlp::Protocol::HttpBinary) + .with_timeout(Duration::from_secs(2)) + .build()?; + let processor = batch::Processor::new(exporter, 2048, 256, Duration::from_secs(1))?; + let _ = BATCH_STATS.set(processor.stats.clone()); + let provider = SdkTracerProvider::builder() + .with_span_processor(processor) + .with_sampler(Sampler::ParentBased(Box::new(Sampler::TraceIdRatioBased( + ratio, + )))) + .with_resource( + opentelemetry_sdk::Resource::builder_empty() + .with_service_name("httpjet") + .build(), + ) + .build(); + let metrics = if std::env::var("HTTPJET_OTEL_METRICS").as_deref() == Ok("1") { + use opentelemetry::metrics::MeterProvider; + let exporter = opentelemetry_otlp::MetricExporter::builder() + .with_http() + .with_http_client(BoundedClient::new()?) + .with_protocol(opentelemetry_otlp::Protocol::HttpBinary) + .with_timeout(Duration::from_secs(2)) + .build()?; + let reader = opentelemetry_sdk::metrics::PeriodicReader::builder(exporter) + .with_interval(Duration::from_secs(30)) + .build(); + let metrics = opentelemetry_sdk::metrics::SdkMeterProvider::builder() + .with_reader(reader) + .with_resource( + opentelemetry_sdk::Resource::builder_empty() + .with_service_name("httpjet") + .build(), + ) + .build(); + let meter = metrics.meter("httpjet"); + let _ = METRICS.set(RequestMetrics::new(&meter)); + Some(metrics) + } else { + None + }; + global::set_tracer_provider(provider.clone()); + let _ = TRUSTED_PARENTS.set(trusted_parents); + ENABLED.store(true, Ordering::Relaxed); + Ok(Some(Runtime(provider, metrics))) +} + +struct End(Context); +pub struct Stage { + _end: End, +} +#[derive(Clone, Copy)] +pub enum StageKind { + Rewrite, + CacheLookup, + CacheStore, +} +pub fn stage(kind: StageKind) -> Option { + if !enabled() { + return None; + } + let parent = Context::current(); + if !parent.span().is_recording() { + return None; + } + let name = match kind { + StageKind::Rewrite => "httpjet.rewrite", + StageKind::CacheLookup => "httpjet.cache.lookup", + StageKind::CacheStore => "httpjet.cache.store", + }; + let tracer = global::tracer("httpjet"); + let span = tracer + .span_builder(name) + .with_kind(SpanKind::Internal) + .start_with_context(&tracer, &parent); + Some(Stage { + _end: End(parent.with_span(span)), + }) +} +impl Drop for End { + fn drop(&mut self) { + self.0.span().end(); + } +} + +struct TracedBody { + body: hj_core::StreamBody, + end: Option, +} +impl TracedBody { + fn finish(&mut self, outcome: &'static str) { + if let Some(end) = self.end.take() { + end.0 + .span() + .set_attribute(KeyValue::new("httpjet.body.outcome", outcome)); + } + } +} +impl Drop for TracedBody { + fn drop(&mut self) { + self.finish("cancelled"); + } +} +impl http_body::Body for TracedBody { + type Data = bytes::Bytes; + type Error = hj_core::BoxError; + fn poll_frame( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll, Self::Error>>> { + let _context = self.end.as_ref().map(|end| end.0.clone().attach()); + let result = std::pin::Pin::new(&mut self.body).poll_frame(cx); + match &result { + std::task::Poll::Ready(None) => self.finish("complete"), + std::task::Poll::Ready(Some(Err(_))) => self.finish("error"), + std::task::Poll::Ready(Some(Ok(_))) if self.body.is_end_stream() => { + self.finish("complete") + } + _ => {} + } + result + } + fn size_hint(&self) -> http_body::SizeHint { + self.body.size_hint() + } + fn is_end_stream(&self) -> bool { + self.body.is_end_stream() + } +} +fn retain_stream_span(response: &mut hj_core::Response, end: End) { + use http_body::Body as _; + use http_body_util::BodyExt; + let body = std::mem::replace(response.body_mut(), hj_core::Body::Empty); + *response.body_mut() = match body { + hj_core::Body::Stream(body) if body.is_end_stream() => { + end.0 + .span() + .set_attribute(KeyValue::new("httpjet.body.outcome", "complete")); + hj_core::Body::Stream(body) + } + hj_core::Body::Stream(body) => hj_core::Body::Stream( + TracedBody { + body, + end: Some(end), + } + .boxed(), + ), + body => body, + }; +} + +#[derive(Clone)] +pub struct TransportContext(pub Context); + +/// Owned by a transport request, not by the pipeline's response-head future. +pub struct RequestTrace { + end: Option, + started: std::time::Instant, +} +impl RequestTrace { + pub fn new(parent: Context) -> Self { + let tracer = global::tracer("httpjet"); + let span = tracer + .span_builder("httpjet.request") + .with_kind(SpanKind::Server) + .start_with_context(&tracer, &parent); + Self { + end: Some(End(parent.with_span(span))), + started: std::time::Instant::now(), + } + } + pub fn context(&self) -> Context { + self.end.as_ref().unwrap().0.clone() + } + pub fn response_head(&self, status: http::StatusCode) { + if let Some(m) = METRICS.get() { + m.response_head(status, self.started.elapsed()); + } + self.context().span().set_attribute(KeyValue::new( + "http.response.status_code", + i64::from(status.as_u16()), + )); + } + pub fn finish(mut self, outcome: &'static str) { + if let Some(end) = self.end.take() { + end.0 + .span() + .set_attribute(KeyValue::new("httpjet.body.outcome", outcome)); + } + } + pub fn retain_stream(mut self, response: &mut hj_core::Response) { + retain_stream_span(response, self.end.take().unwrap()); + } + pub fn completion(self) -> hj_core::ResponseCompletion { + hj_core::ResponseCompletion::new(move |end| { + self.finish(match end { + hj_core::ResponseEnd::Complete => "complete", + hj_core::ResponseEnd::Error => "error", + hj_core::ResponseEnd::Cancelled => "cancelled", + }) + }) + } +} +impl Drop for RequestTrace { + fn drop(&mut self) { + if let Some(end) = self.end.take() { + end.0 + .span() + .set_attribute(KeyValue::new("httpjet.body.outcome", "cancelled")); + } + } +} + +pub async fn in_context(context: Context, future: impl Future) -> T { + future.with_context(context).await +} + +#[derive(Clone)] +pub struct ResponseHead { + context: Context, + started: std::time::Instant, +} +impl ResponseHead { + pub fn record(self, status: http::StatusCode) { + self.context.span().set_attribute(KeyValue::new( + "http.response.status_code", + i64::from(status.as_u16()), + )); + if let Some(metrics) = METRICS.get() { + metrics.response_head(status, self.started.elapsed()); + } + } +} + +pub async fn request_with_completion( + parent: Context, + future: impl Future, +) -> hj_core::Response { + let trace = RequestTrace::new(parent); + let context = trace.context(); + let mut response = in_context(context.clone(), future).await; + response.extensions_mut().insert(ResponseHead { + context, + started: trace.started, + }); + response.extensions_mut().insert(trace.completion()); + response +} + +pub async fn request_with_parent( + parent: Context, + future: impl Future, +) -> hj_core::Response { + let trace = RequestTrace::new(parent); + let mut response = future.with_context(trace.context()).await; + trace.response_head(response.status()); + trace.retain_stream(&mut response); + response +} + +#[derive(Clone, Copy)] +pub enum BackendKind { + Static, + Proxy, + WebSocket, + Lsapi, + FastCgi, + #[cfg(test)] + Generic, +} + +impl BackendKind { + fn span_spec(self) -> (&'static str, SpanKind) { + match self { + Self::Static => ("httpjet.static", SpanKind::Internal), + Self::Proxy => ("httpjet.proxy", SpanKind::Client), + Self::WebSocket => ("httpjet.websocket", SpanKind::Client), + Self::Lsapi => ("httpjet.lsapi", SpanKind::Client), + Self::FastCgi => ("httpjet.fastcgi", SpanKind::Client), + #[cfg(test)] + Self::Generic => ("httpjet.backend", SpanKind::Internal), + } + } +} + +pub async fn backend( + kind: BackendKind, + future: impl Future>, +) -> Result { + let tracer = global::tracer("httpjet"); + let (name, span_kind) = kind.span_spec(); + let span = tracer + .span_builder(name) + .with_kind(span_kind) + .start_with_context(&tracer, &Context::current()); + let context = Context::current().with_span(span); + let end = End(context.clone()); + let mut result = future.with_context(context.clone()).await; + let status = result + .as_ref() + .map(|r| r.status()) + .unwrap_or_else(|e| e.status()); + context.span().set_attribute(KeyValue::new( + "http.response.status_code", + i64::from(status.as_u16()), + )); + if let Ok(response) = &mut result { + retain_stream_span(response, end); + } + result +} + +pub fn inject(headers: &mut http::HeaderMap) { + let cx = Context::current(); + let span = cx.span(); + let sc = span.span_context(); + if sc.is_valid() { + let value = format!( + "00-{}-{}-{:02x}", + sc.trace_id(), + sc.span_id(), + sc.trace_flags().to_u8() + ); + if let Ok(value) = value.parse() { + headers.insert("traceparent", value); + } + } +} + +#[cfg(test)] +#[path = "otel/lsapi_test.rs"] +mod lsapi_test; + +#[cfg(test)] +mod tests { + use super::*; + use opentelemetry::trace::TracerProvider; + use opentelemetry_sdk::trace::{SpanData, SpanExporter}; + use std::sync::{Arc, Mutex}; + + async fn request(future: impl Future) -> hj_core::Response { + request_with_parent(Context::new(), future).await + } + + #[test] + fn inbound_parent_trust_and_header_boundaries() { + const VALID: &str = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + assert!(parse_trusted_parents("").unwrap().is_empty()); + assert_eq!(parse_trusted_parents("127.0.0.1, ::1").unwrap().len(), 2); + for invalid in [ + "0.0.0.0", + "::", + "224.0.0.1", + "10.0.0.0/8", + "host", + "127.0.0.1,", + ] { + assert!(parse_trusted_parents(invalid).is_err()); + } + assert!(parse_trusted_parents(&vec!["127.0.0.1"; 129].join(",")).is_err()); + assert!(parse_trusted_parents(&" ".repeat(4097)).is_err()); + for trusted in [false, true] { + for (value, duplicate, valid) in [ + (VALID, false, true), + (VALID, true, false), + ( + "00-00000000000000000000000000000000-00f067aa0ba902b7-01", + false, + false, + ), + ( + "00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01", + false, + false, + ), + ( + "01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + false, + false, + ), + ("invalid", false, false), + ] { + let mut headers = http::HeaderMap::new(); + headers.insert("traceparent", value.parse().unwrap()); + if duplicate { + headers.append("traceparent", VALID.parse().unwrap()); + } + headers.insert("tracestate", "vendor=secret".parse().unwrap()); + headers.insert("baggage", "secret=value".parse().unwrap()); + let cx = extract_parent(&mut headers, trusted); + assert_eq!(cx.span().span_context().is_valid(), trusted && valid); + assert!(cx.span().span_context().trace_state().header().is_empty()); + assert!(headers.is_empty()); + } + } + } + + #[derive(Debug, Clone)] + struct Capture(Arc>>); + impl SpanExporter for Capture { + async fn export(&self, batch: Vec) -> opentelemetry_sdk::error::OTelSdkResult { + self.0.lock().unwrap().extend(batch); + Ok(()) + } + } + + #[test] + fn metrics_reach_otlp_collector() { + use opentelemetry::metrics::MeterProvider; + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + let mut socket = loop { + if let Ok((s, _)) = listener.accept() { + break s; + } + assert!(std::time::Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(5)); + }; + socket + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + let mut b = [0]; + socket.read_exact(&mut b).unwrap(); + head.push(b[0]); + assert!(head.len() < 8192); + } + let head = String::from_utf8(head).unwrap().to_lowercase(); + assert!(head.starts_with("post /v1/metrics http/1.1")); + assert!(head.contains("application/x-protobuf")); + let size: usize = head + .lines() + .find_map(|l| l.strip_prefix("content-length:")) + .unwrap() + .trim() + .parse() + .unwrap(); + assert!(size < 65536); + let mut body = vec![0; size]; + socket.read_exact(&mut body).unwrap(); + use opentelemetry_proto::tonic::{ + collector::metrics::v1::ExportMetricsServiceRequest, + common::v1::any_value::Value as AttributeValue, + metrics::v1::{metric::Data, number_data_point::Value}, + }; + use prost::Message; + let decoded = ExportMetricsServiceRequest::decode(body.as_slice()).unwrap(); + let metrics: Vec<_> = decoded + .resource_metrics + .iter() + .flat_map(|r| &r.scope_metrics) + .flat_map(|s| &s.metrics) + .collect(); + let completed = metrics + .iter() + .find(|m| m.name == "httpjet.pipeline.requests.completed") + .unwrap(); + let Some(Data::Sum(sum)) = &completed.data else { + panic!("expected sum") + }; + assert!(sum.is_monotonic); + assert_eq!(sum.data_points.len(), 1); + let point = &sum.data_points[0]; + assert_eq!(point.value, Some(Value::AsInt(3))); + assert_eq!(point.attributes.len(), 1); + assert_eq!(point.attributes[0].key, "http.response.status_code"); + assert_eq!( + point.attributes[0].value.as_ref().unwrap().value, + Some(AttributeValue::IntValue(200)) + ); + let duration = metrics + .iter() + .find(|m| m.name == "httpjet.pipeline.response_head.duration") + .unwrap(); + assert_eq!(duration.unit, "s"); + let Some(Data::Histogram(histogram)) = &duration.data else { + panic!("expected histogram") + }; + assert_eq!(histogram.data_points.len(), 1); + let point = &histogram.data_points[0]; + assert_eq!(point.count, 3); + assert_eq!(point.sum, Some(0.375)); + assert_eq!(point.bucket_counts.iter().sum::(), 3); + assert_eq!(point.attributes, sum.data_points[0].attributes); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .unwrap(); + }); + let exporter = opentelemetry_otlp::MetricExporter::builder() + .with_http() + .with_http_client(BoundedClient::new().unwrap()) + .with_endpoint(format!("http://{addr}/v1/metrics")) + .build() + .unwrap(); + let reader = opentelemetry_sdk::metrics::PeriodicReader::builder(exporter) + .with_interval(Duration::from_secs(3600)) + .build(); + let provider = opentelemetry_sdk::metrics::SdkMeterProvider::builder() + .with_reader(reader) + .build(); + let meter = provider.meter("test"); + let metrics = RequestMetrics::new(&meter); + for _ in 0..3 { + metrics.response_head(http::StatusCode::OK, Duration::from_millis(125)); + } + provider.force_flush().unwrap(); + server.join().unwrap(); + // Shutdown can perform a final export; a closed collector must remain bounded. + let start = std::time::Instant::now(); + let _ = provider.shutdown(); + assert!(start.elapsed() < Duration::from_secs(3)); + } + + #[test] + fn collector_http_boundaries() { + use std::io::{Read, Write}; + let client = BoundedClient::new().unwrap(); + for mode in ["large", "redirect", "stall"] { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(4))) + .unwrap(); + let mut header = Vec::new(); + while !header.ends_with(b"\r\n\r\n") { + let mut b = [0]; + socket.read_exact(&mut b).unwrap(); + header.push(b[0]); + assert!(header.len() < 8192); + } + match mode { + "large" => { + let _ = + socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 65537\r\n\r\n"); + let _ = socket.write_all(&vec![b'a'; 65537]); + } + "redirect" => { + socket.write_all(b"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:1/\r\nContent-Length: 0\r\n\r\n").unwrap(); + } + _ => { + let mut b = [0]; + let _ = socket.read(&mut b); + } + } + }); + let request = http::Request::builder() + .uri(format!("http://{addr}/")) + .body(bytes::Bytes::new()) + .unwrap(); + let start = std::time::Instant::now(); + let response = client.send_bounded(request); + if mode == "redirect" { + assert_eq!(response.unwrap().status(), 302); + } else { + assert!(response.is_err()); + } + assert!(start.elapsed() < Duration::from_secs(3)); + server.join().unwrap(); + } + } + + #[test] + fn request_backend_parentage_and_real_otlp_export() { + use std::io::{Read, Write}; + let captured = Capture(Arc::default()); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(captured.clone()) + .build(); + global::set_tracer_provider(provider.clone()); + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + request(async { + backend(BackendKind::Generic, async { + let mut headers = http::HeaderMap::new(); + inject(&mut headers); + let h = headers["traceparent"].to_str().unwrap(); + assert_eq!(h.len(), 55); + assert!(h.starts_with("00-")); + Ok(http::Response::new(hj_core::Body::Empty)) + }) + .await + .unwrap() + }) + .await; + }); + provider.force_flush().unwrap(); + let spans = captured.0.lock().unwrap().clone(); + assert_eq!(spans.len(), 2); + let root = spans.iter().find(|s| s.name == "httpjet.request").unwrap(); + let child = spans.iter().find(|s| s.name == "httpjet.backend").unwrap(); + assert_eq!(child.parent_span_id, root.span_context.span_id()); + assert_eq!(child.span_context.trace_id(), root.span_context.trace_id()); + assert_eq!(root.attributes.len(), 1); + assert_eq!(root.attributes[0].key.as_str(), "http.response.status_code"); + // The explicitly trusted gateway can join an existing distributed trace. + captured.0.lock().unwrap().clear(); + let mut headers = http::HeaderMap::new(); + headers.insert( + "traceparent", + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + .parse() + .unwrap(), + ); + let parent = extract_parent(&mut headers, true); + rt.block_on(request_with_parent(parent, async { + http::Response::new(hj_core::Body::Empty) + })); + let joined = captured.0.lock().unwrap().clone(); + assert_eq!(joined.len(), 1); + assert_eq!(joined[0].parent_span_id.to_string(), "00f067aa0ba902b7"); + assert_eq!( + joined[0].span_context.trace_id().to_string(), + "4bf92f3577b34da6a3ce929d0e0e4736" + ); + captured.0.lock().unwrap().clear(); + let mut wire_parent = None; + rt.block_on(request(async { + let (response, wire) = lsapi_test::roundtrip().await; + wire_parent = Some(wire); + response + })); + let lsapi_spans = captured.0.lock().unwrap().clone(); + let lsapi = lsapi_spans + .iter() + .find(|s| s.name == "httpjet.lsapi") + .unwrap(); + let root = lsapi_spans + .iter() + .find(|s| s.name == "httpjet.request") + .unwrap(); + assert_eq!(lsapi.span_kind, SpanKind::Client); + assert_eq!(lsapi.parent_span_id, root.span_context.span_id()); + assert_eq!( + wire_parent.unwrap(), + format!( + "00-{}-{}-01", + lsapi.span_context.trace_id(), + lsapi.span_context.span_id() + ) + ); + // A retained streaming body must keep both request and backend spans open. + captured.0.lock().unwrap().clear(); + rt.block_on(async { + use http_body_util::BodyExt; + for consume in [true, false] { + captured.0.lock().unwrap().clear(); + let response = request(async { + backend(BackendKind::Generic, async { + let stream = http_body_util::Full::new(bytes::Bytes::from_static(b"body")) + .map_err(|e| -> hj_core::BoxError { match e {} }) + .boxed(); + Ok(http::Response::new(hj_core::Body::Stream(stream))) + }) + .await + .unwrap() + }) + .await; + assert!(captured.0.lock().unwrap().is_empty()); + if consume { + let hj_core::Body::Stream(body) = response.into_body() else { + panic!("expected stream") + }; + assert_eq!(body.collect().await.unwrap().to_bytes(), "body"); + } else { + drop(response); + } + let spans = captured.0.lock().unwrap(); + assert_eq!(spans.len(), 2); + let outcome = if consume { "complete" } else { "cancelled" }; + assert!( + spans.iter().all(|s| s + .attributes + .iter() + .any(|a| a.key.as_str() == "httpjet.body.outcome" + && a.value.as_str() == outcome)) + ); + } + }); + provider.shutdown().unwrap(); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + listener.set_nonblocking(true).unwrap(); + let collector = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + let mut socket = loop { + if let Ok((socket, _)) = listener.accept() { + break socket; + } + assert!( + std::time::Instant::now() < deadline, + "collector not contacted" + ); + std::thread::sleep(Duration::from_millis(5)); + }; + socket + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + let mut header = Vec::new(); + while !header.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + socket.read_exact(&mut byte).unwrap(); + header.push(byte[0]); + assert!(header.len() < 8192); + } + let header = String::from_utf8(header).unwrap().to_lowercase(); + assert!(header.starts_with("post /v1/traces http/1.1")); + assert!(header.contains("application/x-protobuf")); + let size: usize = header + .lines() + .find_map(|l| l.strip_prefix("content-length:")) + .unwrap() + .trim() + .parse() + .unwrap(); + assert!(size < 65536); + let mut body = vec![0; size]; + socket.read_exact(&mut body).unwrap(); + use opentelemetry_proto::tonic::collector::trace::v1::ExportTraceServiceRequest; + use prost::Message; + let decoded = ExportTraceServiceRequest::decode(body.as_slice()).unwrap(); + assert_eq!(decoded.resource_spans.len(), 1); + let resource = &decoded.resource_spans[0]; + assert!(resource.resource.as_ref().unwrap().attributes.iter().any(|a| { + a.key == "service.name" && a.value.as_ref().is_some_and(|v| { + matches!(&v.value, Some(opentelemetry_proto::tonic::common::v1::any_value::Value::StringValue(s)) if s == "httpjet.synthetic") + }) + })); + let spans: Vec<_> = resource.scope_spans.iter().flat_map(|s| &s.spans).collect(); + assert_eq!(spans.len(), 1); + assert_eq!(spans[0].name, "synthetic.export"); + assert_eq!(spans[0].trace_id.len(), 16); + assert_eq!(spans[0].span_id.len(), 8); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .unwrap(); + }); + let exporter = opentelemetry_otlp::SpanExporter::builder() + .with_http() + .with_http_client(BoundedClient::new().unwrap()) + .with_endpoint(format!("http://{addr}/v1/traces")) + .with_timeout(Duration::from_secs(2)) + .build() + .unwrap(); + let provider = SdkTracerProvider::builder() + .with_span_processor( + batch::Processor::new(exporter, 16, 4, Duration::from_millis(50)).unwrap(), + ) + .with_resource( + opentelemetry_sdk::Resource::builder_empty() + .with_service_name("httpjet.synthetic") + .build(), + ) + .build(); + let tracer = provider.tracer("httpjet.test"); + let mut span = tracer.start("synthetic.export"); + opentelemetry::trace::Span::end(&mut span); + provider.force_flush().unwrap(); + provider.shutdown().unwrap(); + collector.join().unwrap(); + } +} diff --git a/crates/httpjet/src/otel/batch.rs b/crates/httpjet/src/otel/batch.rs new file mode 100644 index 0000000..a214b64 --- /dev/null +++ b/crates/httpjet/src/otel/batch.rs @@ -0,0 +1,321 @@ +//! Loss-accounted, nonblocking SDK SpanProcessor. The SDK's batch processor +//! keeps its queue-drop count private; this adapter makes loss observable. +use opentelemetry::Context; +use opentelemetry_sdk::{ + Resource, + error::{OTelSdkError, OTelSdkResult}, + trace::{Span, SpanData, SpanExporter, SpanProcessor}, +}; +use std::sync::{ + Arc, Condvar, Mutex, + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc, +}; +use std::time::{Duration, Instant}; + +#[derive(Debug, Default)] +pub(super) struct Stats { + pub queued: AtomicU64, + pub dropped: AtomicU64, + pub exported: AtomicU64, + pub failed: AtomicU64, +} + +struct QueuedSpan { + span: Option, + stats: Arc, +} +impl Drop for QueuedSpan { + fn drop(&mut self) { + self.stats.queued.fetch_sub(1, Ordering::Relaxed); + // Includes queue overflow, closed worker, and the enqueue/shutdown race. + if self.span.is_some() { + self.stats.dropped.fetch_add(1, Ordering::Relaxed); + } + } +} +enum Message { + Span(QueuedSpan), + Flush(mpsc::SyncSender), + Resource(Resource), +} + +#[derive(Debug, Clone)] +pub(super) struct Processor { + tx: mpsc::SyncSender, + stop: Arc, + done: Arc<(Mutex>, Condvar)>, + pub stats: Arc, +} + +fn export( + exporter: &E, + batch: &mut Vec, + stats: &Stats, +) -> OTelSdkResult { + if batch.is_empty() { + return Ok(()); + } + let count = batch.len() as u64; + let result = futures_executor::block_on(exporter.export(std::mem::take(batch))); + if result.is_ok() { + &stats.exported + } else { + &stats.failed + } + .fetch_add(count, Ordering::Relaxed); + result +} + +impl Processor { + pub fn new( + mut exporter: E, + capacity: usize, + batch_size: usize, + interval: Duration, + ) -> std::io::Result { + assert!(capacity > 0 && batch_size > 0 && !interval.is_zero()); + let (tx, rx) = mpsc::sync_channel(capacity); + let stop = Arc::new(AtomicBool::new(false)); + let done = Arc::new((Mutex::new(None), Condvar::new())); + let stats = Arc::new(Stats::default()); + let worker_stop = stop.clone(); + let worker_done = done.clone(); + let worker_stats = stats.clone(); + std::thread::Builder::new() + .name("httpjet-otel-export".into()) + .spawn(move || { + let mut batch = Vec::with_capacity(batch_size); + let mut last = Instant::now(); + loop { + let message = if worker_stop.load(Ordering::Acquire) { + match rx.try_recv() { + Ok(m) => Ok(m), + Err(_) => break, + } + } else { + rx.recv_timeout(interval.saturating_sub(last.elapsed())) + }; + match message { + Ok(Message::Span(mut queued)) => { + batch.push(queued.span.take().unwrap()); + drop(queued); + if batch.len() >= batch_size { + let _ = export(&exporter, &mut batch, &worker_stats); + last = Instant::now(); + } + } + Ok(Message::Flush(reply)) => { + let _ = reply.send(export(&exporter, &mut batch, &worker_stats)); + last = Instant::now(); + } + Ok(Message::Resource(resource)) => exporter.set_resource(&resource), + Err(mpsc::RecvTimeoutError::Timeout) => { + let _ = export(&exporter, &mut batch, &worker_stats); + last = Instant::now(); + } + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + let result = export(&exporter, &mut batch, &worker_stats); + let shutdown = exporter.shutdown_with_timeout(Duration::from_secs(2)); + *worker_done.0.lock().unwrap() = Some(result.and(shutdown).is_ok()); + worker_done.1.notify_all(); + })?; + Ok(Self { + tx, + stop, + done, + stats, + }) + } + + fn send_until(&self, mut message: Message, deadline: Instant) -> OTelSdkResult { + loop { + match self.tx.try_send(message) { + Ok(()) => return Ok(()), + Err(mpsc::TrySendError::Disconnected(_)) => { + return Err(OTelSdkError::AlreadyShutdown); + } + Err(mpsc::TrySendError::Full(m)) => message = m, + } + if Instant::now() >= deadline { + return Err(OTelSdkError::Timeout(Duration::ZERO)); + } + std::thread::sleep(Duration::from_millis(1)); + } + } +} + +impl SpanProcessor for Processor { + fn on_start(&self, _: &mut Span, _: &Context) {} + fn on_end(&self, span: SpanData) { + if !span.span_context.is_sampled() { + return; + } + if self.stop.load(Ordering::Acquire) { + self.stats.dropped.fetch_add(1, Ordering::Relaxed); + return; + } + self.stats.queued.fetch_add(1, Ordering::Relaxed); + // Never wait on collector IO or queue capacity on a request thread. + let _ = self.tx.try_send(Message::Span(QueuedSpan { + span: Some(span), + stats: self.stats.clone(), + })); + } + fn force_flush(&self) -> OTelSdkResult { + if self.stop.load(Ordering::Acquire) { + return Err(OTelSdkError::AlreadyShutdown); + } + let timeout = Duration::from_secs(3); + let deadline = Instant::now() + timeout; + let (tx, rx) = mpsc::sync_channel(1); + self.send_until(Message::Flush(tx), deadline)?; + rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) + .map_err(|_| OTelSdkError::Timeout(timeout))? + } + fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult { + self.stop.store(true, Ordering::Release); + let result = self.done.0.lock().unwrap(); + let (result, _) = self + .done + .1 + .wait_timeout_while(result, timeout, |r| r.is_none()) + .unwrap(); + match *result { + Some(true) => Ok(()), + Some(false) => Err(OTelSdkError::InternalFailure( + "telemetry final export or shutdown failed".into(), + )), + None => Err(OTelSdkError::Timeout(timeout)), + } + } + fn set_resource(&mut self, resource: &Resource) { + // SDK calls this during provider construction, before request serving. + // A fresh processor's queue is empty, so the command cannot overflow. + self.tx + .try_send(Message::Resource(resource.clone())) + .expect("initial telemetry resource queue"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use opentelemetry::trace::{Span as _, Tracer, TracerProvider}; + use opentelemetry_sdk::trace::SdkTracerProvider; + + #[derive(Debug)] + struct GateExporter { + entered: mpsc::SyncSender<()>, + release: Mutex>, + first: AtomicBool, + fail: Arc, + } + impl SpanExporter for GateExporter { + async fn export(&self, _: Vec) -> OTelSdkResult { + if self.first.swap(false, Ordering::Relaxed) { + self.entered.send(()).unwrap(); + self.release + .lock() + .unwrap() + .recv_timeout(Duration::from_secs(5)) + .unwrap(); + } + if self.fail.load(Ordering::Relaxed) { + Err(OTelSdkError::InternalFailure("synthetic outage".into())) + } else { + Ok(()) + } + } + } + + #[test] + fn queue_saturation_loss_recovery_and_flush() { + let (entered_tx, entered_rx) = mpsc::sync_channel(1); + let (release_tx, release_rx) = mpsc::sync_channel(1); + let fail = Arc::new(AtomicBool::new(true)); + let processor = Processor::new( + GateExporter { + entered: entered_tx, + release: Mutex::new(release_rx), + first: AtomicBool::new(true), + fail: fail.clone(), + }, + 2, + 1, + Duration::from_millis(20), + ) + .unwrap(); + let provider = SdkTracerProvider::builder() + .with_span_processor(processor.clone()) + .build(); + let tracer = provider.tracer("queue-test"); + tracer.start("blocking-first").end(); + entered_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + let start = Instant::now(); + for _ in 0..100 { + tracer.start("burst").end(); + } + assert!( + start.elapsed() < Duration::from_millis(250), + "request path must not wait on collector" + ); + assert_eq!(processor.stats.queued.load(Ordering::Relaxed), 2); + assert_eq!(processor.stats.dropped.load(Ordering::Relaxed), 98); + assert_eq!(processor.stats.exported.load(Ordering::Relaxed), 0); + release_tx.send(()).unwrap(); + // Earlier full batches may fail before this flush barrier. Those losses + // are reported by counters, independently of the final batch result. + let _ = processor.force_flush(); + assert_eq!(processor.stats.failed.load(Ordering::Relaxed), 3); + assert_eq!(processor.stats.queued.load(Ordering::Relaxed), 0); + fail.store(false, Ordering::Relaxed); + tracer.start("recovered").end(); + processor.force_flush().unwrap(); + assert_eq!(processor.stats.exported.load(Ordering::Relaxed), 1); + processor + .shutdown_with_timeout(Duration::from_secs(1)) + .unwrap(); + processor + .shutdown_with_timeout(Duration::from_secs(1)) + .unwrap(); + tracer.start("after-shutdown").end(); + assert_eq!(processor.stats.dropped.load(Ordering::Relaxed), 99); + } + + #[test] + fn shutdown_wait_is_bounded_while_exporter_is_blocked() { + let (entered_tx, entered_rx) = mpsc::sync_channel(1); + let (release_tx, release_rx) = mpsc::sync_channel(1); + let processor = Processor::new( + GateExporter { + entered: entered_tx, + release: Mutex::new(release_rx), + first: AtomicBool::new(true), + fail: Arc::new(AtomicBool::new(false)), + }, + 2, + 1, + Duration::from_millis(20), + ) + .unwrap(); + let provider = SdkTracerProvider::builder() + .with_span_processor(processor.clone()) + .build(); + provider.tracer("shutdown-test").start("blocked").end(); + entered_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + let start = Instant::now(); + assert!(matches!( + processor.shutdown_with_timeout(Duration::from_millis(25)), + Err(OTelSdkError::Timeout(_)) + )); + assert!(start.elapsed() < Duration::from_millis(250)); + release_tx.send(()).unwrap(); + processor + .shutdown_with_timeout(Duration::from_secs(1)) + .unwrap(); + assert_eq!(processor.stats.exported.load(Ordering::Relaxed), 1); + } +} diff --git a/crates/httpjet/src/otel/lsapi_test.rs b/crates/httpjet/src/otel/lsapi_test.rs new file mode 100644 index 0000000..9aa74a6 --- /dev/null +++ b/crates/httpjet/src/otel/lsapi_test.rs @@ -0,0 +1,126 @@ +//! Synthetic LSAPI peer: no PHP process or production socket is used. +use super::*; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +struct Fixture(std::path::PathBuf); +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_file(self.0.join("lsapi.sock")); + let _ = std::fs::remove_dir(&self.0); + } +} + +pub(super) async fn roundtrip() -> (hj_core::Response, String) { + use std::os::unix::fs::DirBuilderExt; + let dir = std::env::temp_dir().join(format!( + "hj-otel-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::DirBuilder::new().mode(0o700).create(&dir).unwrap(); + let fixture = Fixture(dir); + let socket = fixture.0.join("lsapi.sock"); + let listener = tokio::net::UnixListener::bind(&socket).unwrap(); + let peer = tokio::spawn(async move { + tokio::time::timeout(Duration::from_secs(5), async { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut head = [0; 8]; + stream.read_exact(&mut head).await.unwrap(); + assert_eq!(&head[..2], b"LS"); + assert_eq!(head[2], hj_lsapi::PacketType::BeginRequest as u8); + assert_eq!(head[3] & 1, 0, "fixture expects little-endian framing"); + let size = u32::from_le_bytes(head[4..8].try_into().unwrap()) as usize; + assert!((44..65536).contains(&size)); + let mut body = vec![0; size - 8]; + stream.read_exact(&mut body).await.unwrap(); + let rd = |at: usize| u32::from_le_bytes(body[at..at + 4].try_into().unwrap()) as usize; + // Skip the special and CGI env tables, then align to the index. + let mut at = 36; + for _ in 0..2 { + loop { + let k = u16::from_be_bytes(body[at..at + 2].try_into().unwrap()) as usize; + let v = u16::from_be_bytes(body[at + 2..at + 4].try_into().unwrap()) as usize; + at += 4; + if k == 0 && v == 0 { + break; + } + at += k + v; + } + } + at += (8 - ((8 + at) % 8)) % 8; + let unknown_at = at + hj_lsapi::HEADER_INDEX_LEN; + let count = rd(24); + let raw_at = unknown_at + count * 16; + let raw = &body[raw_at..raw_at + rd(0)]; + let mut trace = None; + for i in 0..count { + let slot = unknown_at + i * 16; + let name = &raw[rd(slot)..rd(slot) + rd(slot + 4)]; + let value = &raw[rd(slot + 8)..rd(slot + 8) + rd(slot + 12)]; + assert_ne!(name, b"baggage"); + assert_ne!(name, b"tracestate"); + if name == b"traceparent" { + assert!(trace.is_none(), "exactly one propagated parent"); + trace = Some(String::from_utf8(value.to_vec()).unwrap()); + } + } + // Empty 200 response, with an explicit RESP_END. + let mut out = vec![b'L', b'S', hj_lsapi::PacketType::RespHeader as u8, 0]; + out.extend_from_slice(&16u32.to_le_bytes()); + out.extend_from_slice(&0i32.to_le_bytes()); + out.extend_from_slice(&200i32.to_le_bytes()); + out.extend_from_slice(&[b'L', b'S', hj_lsapi::PacketType::RespEnd as u8, 0]); + out.extend_from_slice(&8u32.to_le_bytes()); + stream.write_all(&out).await.unwrap(); + trace.expect("LSAPI unknown-header table must carry traceparent") + }) + .await + .expect("synthetic LSAPI exchange timed out") + }); + let pool = std::sync::Arc::new(hj_lsapi::LsapiPool::new(&socket, 1, Duration::from_secs(1))); + let handler = hj_lsapi::Lsapi::new(pool).read_timeout(Duration::from_secs(1)); + let mut ctx = hj_core::ReqCtx { + server: std::sync::Arc::new(Default::default()), + vhost_name: "synthetic".into(), + vhost: std::sync::Arc::new(hj_core::config::VHostConfig { + doc_root: fixture.0.clone(), + ..Default::default() + }), + peer_ip: "127.0.0.1".parse().unwrap(), + client_ip: "127.0.0.1".parse().unwrap(), + is_tls: false, + protocol: hj_core::Proto::Http1, + trusted_proxy: false, + env: vec![], + local_addr: "127.0.0.1:8080".parse().unwrap(), + peer_port: 12345, + peer_unix: false, + request_time: std::time::SystemTime::now(), + request_id: Default::default(), + tls: None, + redirect_guard: None, + }; + use http_body_util::BodyExt; + let mut req = http::Request::builder() + .uri("/index.php") + .header("host", "synthetic") + .header("traceparent", "public-input") + .header("baggage", "secret=value") + .body( + http_body_util::Empty::::new() + .map_err(|e| -> hj_core::BoxError { match e {} }) + .boxed(), + ) + .unwrap(); + // Apply the same public-input boundary as the common pipeline, then the + // actual terminal instrumentation used by run_handler. + extract_parent(req.headers_mut(), false); + let response = crate::pipeline::instrumented_handler(&handler, &mut ctx, req) + .await + .unwrap(); + assert_eq!(response.status(), 200); + (response, peer.await.unwrap()) +} diff --git a/crates/httpjet/src/pipeline/e2e.rs b/crates/httpjet/src/pipeline/e2e.rs index 4fddd0f..45913f0 100644 --- a/crates/httpjet/src/pipeline/e2e.rs +++ b/crates/httpjet/src/pipeline/e2e.rs @@ -56,10 +56,98 @@ fn mime() -> MimeMap { /// A minimal real `ServerState`: one plaintext listener `http` mapping the exact /// host `canon.test` (plus a `*` catch-all, so any OTHER host resolves to the /// same vhost but counts as foreign) to a single vhost rooted at `doc_root`. -fn build_state(doc_root: PathBuf) -> Arc { +pub(crate) fn build_state(doc_root: PathBuf) -> Arc { build_state_with(doc_root, Vec::new(), Vec::new()) } +#[cfg(feature = "otel")] +pub(crate) fn build_state_websocket(doc_root: PathBuf, address: String) -> Arc { + build_state_inner( + doc_root, + Vec::new(), + Vec::new(), + false, + None, + None, + |config| { + let vhost = config + .vhosts + .get_mut(VHOST) + .unwrap() + .config + .as_mut() + .unwrap(); + Arc::make_mut(vhost) + .websockets + .push(hj_core::config::WebSocketMap { + uri: "/socket".into(), + address, + }); + }, + ) +} + +/// Synthetic handshake fixture; called only by the isolated telemetry process. +#[cfg(feature = "otel")] +pub(crate) async fn traced_websocket_handshakes(state: &ServerState) -> Vec> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut observed = Vec::new(); + for named in [true, false] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let upstream = tokio::spawn(async move { + tokio::time::timeout(std::time::Duration::from_secs(3), async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + assert!(head.len() < 8192); + head.push(stream.read_u8().await.unwrap()); + } + stream + .write_all( + b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + let head = String::from_utf8(head).unwrap(); + assert!(!head.to_ascii_lowercase().contains("baggage:")); + assert!(!head.to_ascii_lowercase().contains("tracestate:")); + head.lines().find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case("traceparent") + .then(|| value.trim().to_owned()) + }) + }) + .await + .unwrap() + }); + let mut target = hj_proxy::ProxyTarget::parse_url(&format!("ws://{addr}/socket")).unwrap(); + target.name = named.then(|| "synthetic-websocket".to_owned()); + let mut req = http::Request::builder() + .uri("/socket") + .header("host", "canon.test") + .header("connection", "Upgrade") + .header("upgrade", "websocket") + .header("sec-websocket-version", "13") + .header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==") + .header("baggage", "secret=value") + .header("tracestate", "secret=value") + .body(hj_core::empty_incoming()) + .unwrap(); + let parent = crate::otel::inbound_parent(req.headers_mut(), addr.ip(), true); + let ctx = super::tests::bare_ctx_for_headers(); + let response = crate::otel::request_with_parent( + parent, + super::proxy_glue::proxy_websocket(state, &ctx, req, target), + ) + .await; + assert_eq!(response.status(), http::StatusCode::FORBIDDEN); + drop(response); + observed.push(upstream.await.unwrap()); + } + observed +} + fn build_state_with( doc_root: PathBuf, contexts: Vec, @@ -70,7 +158,7 @@ fn build_state_with( /// [`build_state_with`] with an origin page-cache attached (the `--page-cache` /// mode); the vhost gets a cache-enabled policy so store-side eligibility holds. -fn build_state_full( +pub(crate) fn build_state_full( doc_root: PathBuf, contexts: Vec, access_deny_dir: Vec, @@ -287,6 +375,177 @@ async fn static_get_serves_litespeed_etag_and_revalidates_to_304() { assert_eq!(body_bytes(resp.into_body()).as_ref(), b"hello after edit\n"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn waf_runs_before_static_cache_and_can_block_a_previously_allowed_path() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let sidecar = tokio::spawn(async move { + for status in ["204 No Content", "403 Forbidden"] { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buf = [0_u8; 1024]; + let header_end = loop { + let read = stream.read(&mut buf).await.unwrap(); + assert_ne!(read, 0); + request.extend_from_slice(&buf[..read]); + if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") { + break end + 4; + } + }; + let head = std::str::from_utf8(&request[..header_end]).unwrap(); + let length: usize = head + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(|value| value.trim().parse().unwrap()) + }) + .unwrap(); + while request.len() < header_end + length { + let read = stream.read(&mut buf).await.unwrap(); + assert_ne!(read, 0); + request.extend_from_slice(&buf[..read]); + } + let payload: serde_json::Value = + serde_json::from_slice(&request[header_end..header_end + length]).unwrap(); + assert_eq!(payload["path"], "/cached.txt"); + stream + .write_all( + format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .as_bytes(), + ) + .await + .unwrap(); + } + }); + + let root = temp_root("waf-before-cache"); + std::fs::write(root.join("cached.txt"), b"allowed once").unwrap(); + let mut state = build_state(root); + Arc::get_mut(&mut state).unwrap().waf = Some(Arc::new( + crate::waf::Sidecar::new( + address, + "/inspect".into(), + std::time::Duration::from_secs(1), + 1024, + 2, + crate::waf::FailurePolicy::Closed, + ) + .unwrap(), + )); + let allowed = run(&state, get(CANON_HOST, "/cached.txt", None)).await; + assert_eq!(allowed.status(), 200); + assert_eq!(body_bytes(allowed.into_body()), "allowed once"); + assert!( + fast_serve_get(&state, "/cached.txt").await.is_none(), + "WAF must disable the on-core cache path that bypasses dispatch" + ); + let blocked = run(&state, get(CANON_HOST, "/cached.txt", None)).await; + assert_eq!(blocked.status(), 403); + sidecar.await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn waf_sidecar_failure_policy_is_closed_unless_explicitly_opened() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let unavailable = listener.local_addr().unwrap(); + drop(listener); + let root = temp_root("waf-failure-policy"); + std::fs::write(root.join("visible.txt"), b"visible").unwrap(); + + for (policy, expected) in [ + (crate::waf::FailurePolicy::Closed, 503), + (crate::waf::FailurePolicy::Open, 200), + ] { + let mut state = build_state(root.clone()); + Arc::get_mut(&mut state).unwrap().waf = Some(Arc::new( + crate::waf::Sidecar::new( + unavailable, + "/inspect".into(), + std::time::Duration::from_millis(100), + 1024, + 1, + policy, + ) + .unwrap(), + )); + let response = run(&state, get(CANON_HOST, "/visible.txt", None)).await; + assert_eq!(response.status(), expected); + } +} + +struct BlockingExtension; + +#[async_trait::async_trait] +impl hj_extension::PreHandler for BlockingExtension { + async fn handle( + &self, + ctx: &hj_core::ReqCtx, + request: hj_extension::RequestView<'_>, + ) -> Result { + assert_eq!(ctx.client_ip, IpAddr::V4(Ipv4Addr::LOCALHOST)); + assert_eq!(request.uri().path(), "/blocked.txt"); + assert_eq!(request.method(), http::Method::GET); + Ok(hj_extension::PreHandlerDecision::Respond( + hj_core::text_response(http::StatusCode::IM_A_TEAPOT, "extension blocked"), + )) + } +} + +struct ExtensionHeader; + +#[async_trait::async_trait] +impl hj_core::ResponseTransform for ExtensionHeader { + async fn transform(&self, _ctx: &hj_core::ReqCtx, response: &mut Response) { + response + .headers_mut() + .insert("x-compile-extension", http::HeaderValue::from_static("ran")); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn compile_time_pre_handler_short_circuits_before_static_and_forces_the_full_funnel() { + let root = temp_root("extension-pre-handler"); + std::fs::write(root.join("blocked.txt"), b"must not be served").unwrap(); + let mut state = build_state(root); + let mut registry = hj_extension::ExtensionRegistry::new(); + registry + .register_pre_handler("test.block", Arc::new(BlockingExtension)) + .unwrap(); + Arc::get_mut(&mut state).unwrap().extensions = Arc::new(registry); + + let request = get(CANON_HOST, "/blocked.txt", None); + assert!( + fast_serve_req(&state, &request).await.is_none(), + "a pre-handler must prevent the on-core path from bypassing it" + ); + let response = run(&state, request).await; + assert_eq!(response.status(), http::StatusCode::IM_A_TEAPOT); + assert_eq!(body_bytes(response.into_body()), "extension blocked"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn compile_time_response_transform_runs_on_full_and_on_core_funnels() { + let root = temp_root("extension-response"); + std::fs::write(root.join("visible.txt"), b"visible").unwrap(); + let mut state = build_state(root); + let mut registry = hj_extension::ExtensionRegistry::new(); + registry + .register_response_transform("test.header", Arc::new(ExtensionHeader)) + .unwrap(); + Arc::get_mut(&mut state).unwrap().extensions = Arc::new(registry); + + let full = run(&state, get(CANON_HOST, "/visible.txt", None)).await; + assert_eq!(full.headers()["x-compile-extension"], "ran"); + + let fast = fast_serve_get(&state, "/visible.txt") + .await + .expect("a response-only extension leaves the on-core path available"); + assert_eq!(fast.headers()["x-compile-extension"], "ran"); +} + #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn static_symlink_into_access_deny_dir_is_forbidden() { diff --git a/crates/httpjet/src/pipeline/fast_memo.rs b/crates/httpjet/src/pipeline/fast_memo.rs index df379e5..00ea774 100644 --- a/crates/httpjet/src/pipeline/fast_memo.rs +++ b/crates/httpjet/src/pipeline/fast_memo.rs @@ -87,6 +87,7 @@ pub(super) enum HostSource { } pub(super) struct MemoKey<'a> { + pub generation: u64, pub listener: &'a str, pub https: bool, pub trusted_proxy: bool, @@ -185,6 +186,7 @@ fn vary_same(a: &[VaryItem], b: &[VaryItem]) -> bool { } struct MemoEntry { + generation: u64, listener: String, https: bool, trusted_proxy: bool, @@ -217,6 +219,7 @@ thread_local! { fn key_hash(k: &MemoKey) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); + k.generation.hash(&mut h); k.listener.hash(&mut h); k.https.hash(&mut h); k.trusted_proxy.hash(&mut h); @@ -232,7 +235,8 @@ fn key_hash(k: &MemoKey) -> u64 { /// Identity guard (page-cache rule 2): a hash collision degrades to a miss, /// never a wrong response. fn matches(e: &MemoEntry, k: &MemoKey) -> bool { - e.https == k.https + e.generation == k.generation + && e.https == k.https && e.trusted_proxy == k.trusted_proxy && e.host_src == k.host_src && e.listener == k.listener @@ -295,6 +299,7 @@ pub(super) fn store(k: &MemoKey, vary: Vec, resp: &Response, now: Inst } let h = key_hash(k); let entry = MemoEntry { + generation: k.generation, listener: k.listener.to_owned(), https: k.https, trusted_proxy: k.trusted_proxy, @@ -346,6 +351,7 @@ mod tests { fn key<'a>(path: &'a str, ae: &'a [u8]) -> MemoKey<'a> { MemoKey { + generation: 1, listener: "l1", https: true, trusted_proxy: false, @@ -379,6 +385,23 @@ mod tests { UaClassifyCache::new() } + #[test] + fn configuration_generation_never_replays_an_old_response() { + let now = Instant::now(); + let request = req(&[]); + let cache = ua_cache(); + let old = key("/generation", b""); + store(&old, Vec::new(), &resp(b"old"), now); + let mut next = key("/generation", b""); + next.generation = 2; + assert!(probe(&next, &request, &cache, now).is_none()); + store(&next, Vec::new(), &resp(b"new"), now); + store(&old, Vec::new(), &resp(b"late old"), now); + assert!( + matches!(probe(&next, &request, &cache, now).unwrap().into_body(), Body::Full(bytes) if bytes.as_ref() == b"new") + ); + } + #[test] fn roundtrip_and_ttl() { let t0 = Instant::now(); diff --git a/crates/httpjet/src/pipeline/htaccess_apply.rs b/crates/httpjet/src/pipeline/htaccess_apply.rs index 2a920e5..29d33de 100644 --- a/crates/httpjet/src/pipeline/htaccess_apply.rs +++ b/crates/httpjet/src/pipeline/htaccess_apply.rs @@ -474,4 +474,31 @@ mod tests { ); assert!(!resp.headers().contains_key("x-cache")); } + + #[test] + fn compatibility_fixture_pins_final_response_semantic_gaps() { + const FIXTURE: &str = + include_str!("../../../hj-rewrite/tests/fixtures/header_compat/semantic-gaps.htaccess"); + let chain = vec![Arc::new(Htaccess::parse(FIXTURE).unwrap())]; + let ctx = super::super::tests::bare_ctx_for_headers(); + let mut resp: Response = Response::new(hj_core::Body::Empty); + resp.headers_mut().insert( + http::header::CACHE_CONTROL, + http::HeaderValue::from_static("no-cache"), + ); + + apply_response_headers_for_request(&ctx, &chain, "/index.html", "/index.html", &mut resp); + + assert_eq!( + resp.headers().get(http::header::CACHE_CONTROL).unwrap(), + "no-cache, no-cache", + "Merge currently behaves as Append without list de-duplication" + ); + assert!( + !resp.headers().contains_key("x-colon"), + "Apache's optional trailing colon is currently rejected as an invalid field name" + ); + assert_eq!(resp.headers().get("x-expr-value").unwrap(), ""); + assert_eq!(resp.headers().get("x-early").unwrap(), "late-only"); + } } diff --git a/crates/httpjet/src/pipeline/mod.rs b/crates/httpjet/src/pipeline/mod.rs index eafa718..d295600 100644 --- a/crates/httpjet/src/pipeline/mod.rs +++ b/crates/httpjet/src/pipeline/mod.rs @@ -22,6 +22,7 @@ use std::time::Duration; use async_trait::async_trait; use hj_compress::ExpiresHeaders; use hj_core::{Body, Handler, Proto, ReqCtx, Request, Response, ResponseTransform}; +use hj_fastcgi::FastCgiScript; use hj_lsapi::{JailConfig, LsapiScript, SpecialEnvType}; use hj_proxy::{ProxyTarget, is_websocket_upgrade}; use hj_rewrite::Htaccess; @@ -33,7 +34,7 @@ use crate::lscache; use crate::state::ServerState; #[cfg(test)] -mod e2e; +pub(crate) mod e2e; #[cfg(test)] mod expires_tests; pub(crate) mod fast_memo; @@ -190,6 +191,18 @@ pub(crate) async fn fast_serve( req: &Request, ) -> Option { let req_start = std::time::Instant::now(); + // The on-core memo/static/page-cache path cannot perform asynchronous WAF + // inspection. When a sidecar is configured, force every request through the + // bridged full pipeline where inspection precedes every cache/backend path. + if state.waf.is_some() || state.extensions.has_pre_handlers() { + return None; + } + #[cfg(feature = "acme")] + if state.acme.is_some() && req.uri().path().starts_with("/.well-known/acme-challenge/") { + // Never memoize a challenge or let static/page-cache state hide a token + // transition. The full funnel checks the raw URI before normalization. + return None; + } // Reserved cache endpoints (`/__hj_cache_purge|_get|_ready`) are intercepted // before vhost routing on BOTH entry points: `handle()` checks them below the // bridge, but this on-core fast path runs FIRST — without the same gate here, @@ -291,6 +304,7 @@ pub(crate) async fn fast_serve( let memo_inline_ok = memo_inline.is_none_or(|rs| rs.path_cacheable); if memo_eligible_req && memo_inline_ok { let mk = fast_memo::MemoKey { + generation: state.generation, listener, https: effective_https, trusted_proxy, @@ -538,9 +552,7 @@ pub(crate) async fn fast_serve( if let lscache::CacheOutcome::Hit(mut resp) = lscache::cache_lookup(state, &ctx, &cc, inm, false, None) { - for t in &state.transforms { - t.transform(&ctx, &mut resp).await; - } + apply_response_transforms(state, &ctx, &mut resp).await; state.telemetry.record_cache_hit(peer_ip.is_loopback()); if !state.client_throttle.allow(peer_ip) { return None; // over the per-IP rate: dispatch() renders the 429 @@ -647,14 +659,13 @@ pub(crate) async fn fast_serve( ); // Header transforms (expires / Alt-Svc / compress) — they see an in-memory body now, // so CacheStaticTransform is a no-op (no block_in_place) and Compress negotiates per AE. - for t in &state.transforms { - t.transform(&ctx, &mut resp).await; - } + apply_response_transforms(state, &ctx, &mut resp).await; if resp.status() == StatusCode::OK && !resp.headers().contains_key(http::header::SET_COOKIE) { if memo_store_ok { if let Some(vary) = memo_vary_set(state, &ctx, req, memo_inline, &chain_with_dirs) { fast_memo::store( &fast_memo::MemoKey { + generation: state.generation, listener, https: effective_https, trusted_proxy, @@ -1079,7 +1090,88 @@ fn strip_empty_query(uri: &http::Uri) -> Option { http::Uri::from_parts(parts).ok() } +/// Apply compile-time response extensions before the host-owned transforms. +/// +/// This ordering gives extensions the terminal/cache response before compression, +/// while keeping cache-safety, compression and transport headers under httpjet's +/// final control. Every full and on-core response funnel calls this helper. +async fn apply_response_transforms(state: &ServerState, ctx: &ReqCtx, resp: &mut Response) { + state.extensions.run_response_transforms(ctx, resp).await; + for transform in &state.transforms { + transform.transform(ctx, resp).await; + } +} + pub async fn handle( + state: Arc, + listener: &str, + peer_ip: IpAddr, + local_addr: std::net::SocketAddr, + peer_port: u16, + is_tls: bool, + peer_unix: bool, + mtls_required: bool, + tls: Option, + proto: Proto, + sni: Option<&str>, + req: Request, +) -> Response { + #[cfg(feature = "otel")] + if crate::otel::enabled() { + let mut req = req; + let transport = req + .extensions_mut() + .remove::(); + // PROXY protocol replaces peer_ip with an asserted address. Until the + // original transport peer is carried separately, never trust it here. + let direct_tcp = !peer_unix + && state + .server + .listeners + .iter() + .find(|configured| configured.name == listener) + .is_some_and(|configured| !configured.proxy_protocol); + let parent = crate::otel::inbound_parent(req.headers_mut(), peer_ip, direct_tcp); + let future = handle_inner( + state, + listener, + peer_ip, + local_addr, + peer_port, + is_tls, + peer_unix, + mtls_required, + tls, + proto, + sni, + req, + ); + return match transport { + Some(context) => crate::otel::in_context(context.0, future).await, + None if proto == Proto::Http3 => { + crate::otel::request_with_completion(parent, future).await + } + None => crate::otel::request_with_parent(parent, future).await, + }; + } + handle_inner( + state, + listener, + peer_ip, + local_addr, + peer_port, + is_tls, + peer_unix, + mtls_required, + tls, + proto, + sni, + req, + ) + .await +} + +async fn handle_inner( state: Arc, listener: &str, peer_ip: IpAddr, @@ -1098,6 +1190,43 @@ pub async fn handle( // (telemetry) Total wall time, recorded at the single response funnel below. let req_start = std::time::Instant::now(); + #[cfg(feature = "acme")] + if let Some(mut response) = state + .acme + .as_ref() + .and_then(|acme| acme.response(listener, &req, is_tls)) + { + state + .metrics + .requests_total + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + state.telemetry.record_request( + proto, + response.status().as_u16(), + req_start.elapsed(), + state.telemetry.vhost_idx(""), + ); + if let Some(log) = &state.access_log { + let record = hj_log::AccessRecord { + client_ip: peer_ip, + ts: std::time::SystemTime::now(), + method: method_static(req.method()), + uri: "/.well-known/acme-challenge/[redacted]".into(), + protocol: proto.as_str(), + status: response.status().as_u16(), + bytes: 0, + referer: None, + user_agent: None, + host: header_str(&req, http::header::HOST), + remote_user: None, + request_id: Some(hj_core::reqid::next().to_string()), + peer_unix, + }; + response = log_access(log, response, record, None); + } + return response; + } + let has_authorization = req.headers().contains_key(http::header::AUTHORIZATION); // Normalize a trailing empty query ("/x?" -> "/x") before anything reads the URI. See // strip_empty_query for the rationale (it prevents the backend-canonicalization redirect @@ -1337,7 +1466,23 @@ pub async fn handle( ctx.set_env("HTTP_ACCEPT_ENCODING", ae.to_string()); } set_redirect_guard(&mut ctx, req.uri(), &req_host); - dispatch(&state, host_foreign, req_host, &mut ctx, req).await + match state.extensions.run_pre_handlers(&ctx, &req).await { + Ok(hj_extension::PreHandlerDecision::Continue) => { + dispatch(&state, host_foreign, req_host, &mut ctx, req).await + } + Ok(hj_extension::PreHandlerDecision::Respond(response)) => response, + Err(error) => { + let status = error.status(); + tracing::warn!( + request_id = %ctx.request_id, + vhost = %ctx.vhost_name, + status = status.as_u16(), + error = %error, + "compile-time pre-handler extension failed" + ); + error_page(status) + } + } }; if let Some(p) = &bw_path { @@ -1356,9 +1501,7 @@ pub async fn handle( .telemetry .sample_phase(crate::telemetry::PHASE_SAMPLE_RATE) .then(std::time::Instant::now); - for t in &state.transforms { - t.transform(&ctx, &mut resp).await; - } + apply_response_transforms(&state, &ctx, &mut resp).await; if let Some(t) = _ct { state.telemetry.shard().phase_compress.record(t.elapsed()); } @@ -2217,6 +2360,26 @@ async fn dispatch( { return error_page(status); } + // Opt-in WAF sidecar runs before rewrite, every cache lookup, and every + // terminal backend/static path. Large bodies are described as omitted + // instead of being copied beyond the inspection cap; the sidecar still + // decides whether that request shape is allowed. Sidecar errors obey the + // explicit process-lifetime failure policy (closed by default). + if let Some(waf) = &state.waf { + let include_body = + exact_request_body_size(&req).is_some_and(|length| length <= waf.inspect_body_max()); + match waf.inspect(ctx, &mut req, &orig_path, include_body).await { + Ok(crate::waf::Verdict::Allow) => {} + Ok(crate::waf::Verdict::Block) => return error_page(StatusCode::FORBIDDEN), + Err(error) if waf.failure_policy() == crate::waf::FailurePolicy::Open => { + tracing::warn!(request_id = %ctx.request_id, %error, "WAF sidecar failure allowed by explicit fail-open policy"); + } + Err(error) => { + tracing::error!(request_id = %ctx.request_id, %error, "WAF sidecar failure rejected request"); + return error_page(StatusCode::SERVICE_UNAVAILABLE); + } + } + } // Borrow the original path/query; only the `Rewritten` arm below promotes them to Owned, so // the common unchanged case (e.g. a static `.bin` whose rewrite is a no-op) clones neither. let mut cur_path: Cow = Cow::Borrowed(&orig_path); @@ -2815,7 +2978,7 @@ async fn dispatch( .iter() .find(|w| context_uri_matches(&cur_path, &w.uri)) { - let target = ProxyTarget::from_websocket_map(ws); + let target = ProxyTarget::from_websocket_map(ws).in_scope(&ctx.vhost_name); return proxy_websocket(state, ctx, req, target).await; } } @@ -2855,6 +3018,43 @@ async fn dispatch( // ---- 7. Suffix routing: LSAPI (php/html) or static ------------------- // Reuse the split resolved once above (B5) — `cur_path` is unchanged since. if let Some((script_abs, script_name, path_info)) = script_split { + if let Some(handler_name) = fastcgi_handler_for_script(ctx, &script_abs) { + let Some(handler) = state + .fastcgi_handler(&ctx.vhost_name, handler_name) + .cloned() + else { + tracing::error!(request_id = %ctx.request_id, vhost = %ctx.vhost_name, handler = handler_name, "CGI script handler is not an enabled FastCGI processor"); + return lscache::cache_store( + state, + ctx, + &cc, + error_page(StatusCode::SERVICE_UNAVAILABLE), + ) + .await; + }; + let target = pinned_script_target.clone().unwrap_or_else(|| { + panic!("script split reached FastCGI without a pinned filesystem target") + }); + let mut fcgi_script = match FastCgiScript::new(target) { + Ok(script) => script.script_name(script_name.clone()), + Err(error) => { + tracing::error!(request_id = %ctx.request_id, %error, "invalid pinned FastCGI script target"); + return error_page(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + if !path_info.is_empty() { + fcgi_script = fcgi_script.path_info(path_info.clone()); + } + req.extensions_mut().insert(fcgi_script); + if rewritten { + ctx.set_env("SCRIPT_NAME", script_name); + ctx.set_env("QUERY_STRING", cur_query.clone()); + ctx.set_env("REDIRECT_URL", orig_path.clone()); + } + let mut resp = run_handler(handler.as_ref(), ctx, req).await; + apply_response_headers_for_request(ctx, &chain, &rel_path, &orig_path, &mut resp); + return lscache::cache_store(state, ctx, &cc, resp).await; + } if let Some(registry) = state.lsapi.clone() { // Resolve this vhost's jail (config-gated + root-gated inside // JailConfig::resolve). With suEXEC off / non-root / no per-vhost @@ -3269,6 +3469,19 @@ pub(super) fn effective_php_suffixes<'s>( compute_php_suffixes(&state.php_suffixes, &ctx.vhost.script_handlers) } +fn fastcgi_handler_for_script<'a>(ctx: &'a ReqCtx, script: &std::path::Path) -> Option<&'a str> { + let suffix = script.extension()?.to_str()?; + ctx.vhost + .script_handlers + .iter() + .rev() + .find(|handler| { + handler.kind == hj_core::config::ContextKind::Cgi + && handler.suffix.eq_ignore_ascii_case(suffix) + }) + .map(|handler| handler.handler.as_str()) +} + /// Pure core of [`effective_php_suffixes`]: `global` ∪ (lsapi suffixes) \ (non-lsapi /// suffixes). Independent of `ServerState`/`ReqCtx` so it can be unit-tested. fn compute_php_suffixes<'a>( @@ -3326,11 +3539,69 @@ pub(super) fn resolve_vhost_jail(state: &ServerState, ctx: &ReqCtx) -> std::io:: ) } -pub(super) async fn run_handler(h: &H, ctx: &mut ReqCtx, req: Request) -> Response { +/// Compile-time terminal classification without changing the frozen Handler seam. +pub(super) trait TelemetryHandler: Handler { + #[cfg(feature = "otel")] + const KIND: crate::otel::BackendKind; +} +impl TelemetryHandler for hj_static::StaticFiles { + #[cfg(feature = "otel")] + const KIND: crate::otel::BackendKind = crate::otel::BackendKind::Static; +} +impl TelemetryHandler for proxy_glue::ProxyHandler { + #[cfg(feature = "otel")] + const KIND: crate::otel::BackendKind = crate::otel::BackendKind::Proxy; +} +impl TelemetryHandler for hj_lsapi::Lsapi { + #[cfg(feature = "otel")] + const KIND: crate::otel::BackendKind = crate::otel::BackendKind::Lsapi; +} +impl TelemetryHandler for hj_fastcgi::FastCgi { + #[cfg(feature = "otel")] + const KIND: crate::otel::BackendKind = crate::otel::BackendKind::FastCgi; +} + +#[cfg(feature = "otel")] +pub(crate) async fn instrumented_handler( + h: &H, + ctx: &mut ReqCtx, + mut req: Request, +) -> Result { + crate::otel::backend(H::KIND, async { + if matches!( + H::KIND, + crate::otel::BackendKind::Lsapi | crate::otel::BackendKind::FastCgi + ) { + crate::otel::inject(req.headers_mut()); + } + h.handle(ctx, req).await + }) + .await +} + +pub(super) async fn run_handler( + h: &H, + ctx: &mut ReqCtx, + req: Request, +) -> Response { // Capture before `handle` consumes `req`, for the 5xx-with-cause log below. let method = req.method().clone(); let path = req.uri().path().to_string(); - match h.handle(ctx, req).await { + let result = { + #[cfg(feature = "otel")] + { + if crate::otel::enabled() { + instrumented_handler(h, ctx, req).await + } else { + h.handle(ctx, req).await + } + } + #[cfg(not(feature = "otel"))] + { + h.handle(ctx, req).await + } + }; + match result { Ok(resp) => resp, Err(err) => { let status = err.status(); @@ -4587,6 +4858,40 @@ mod tests { assert!(added.contains("phtml") && added.contains("php") && added.contains("html")); } + #[test] + fn fastcgi_suffix_mapping_is_explicit_case_insensitive_and_last_wins() { + use hj_core::config::{ContextKind, ScriptHandler}; + + let mut ctx = bare_ctx_for_headers(); + let mut vhost = hj_core::config::VHostConfig::default(); + vhost.script_handlers = vec![ + ScriptHandler { + suffix: "FCGI".into(), + kind: ContextKind::Cgi, + handler: "old".into(), + }, + ScriptHandler { + suffix: "fcgi".into(), + kind: ContextKind::Cgi, + handler: "app".into(), + }, + ScriptHandler { + suffix: "php".into(), + kind: ContextKind::Lsapi, + handler: "php".into(), + }, + ]; + ctx.vhost = Arc::new(vhost); + assert_eq!( + fastcgi_handler_for_script(&ctx, std::path::Path::new("/srv/app.FCGI")), + Some("app") + ); + assert_eq!( + fastcgi_handler_for_script(&ctx, std::path::Path::new("/srv/app.php")), + None + ); + } + #[test] fn request_guard_tracks_inflight_and_decrements_on_drop() { use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/crates/httpjet/src/pipeline/proxy_glue.rs b/crates/httpjet/src/pipeline/proxy_glue.rs index 7c01f53..57a677f 100644 --- a/crates/httpjet/src/pipeline/proxy_glue.rs +++ b/crates/httpjet/src/pipeline/proxy_glue.rs @@ -29,6 +29,14 @@ pub(super) struct ProxyHandler { #[async_trait] impl Handler for ProxyHandler { async fn handle(&self, ctx: &mut ReqCtx, req: Request) -> Result { + #[cfg(feature = "otel")] + let req = { + let mut req = req; + if crate::otel::enabled() && self.target.name.is_some() { + crate::otel::inject(req.headers_mut()); + } + req + }; // Capture before `forward` consumes `req`, for the 5xx-with-cause log. let method = req.method().clone(); let path = req.uri().path().to_string(); @@ -64,6 +72,27 @@ impl Handler for ProxyHandler { /// Proxy a WebSocket upgrade: open the upstream, relay the 101, then bridge the /// two upgraded streams. pub(super) async fn proxy_websocket( + state: &ServerState, + ctx: &ReqCtx, + req: Request, + target: ProxyTarget, +) -> Response { + #[cfg(feature = "otel")] + if crate::otel::enabled() { + return crate::otel::backend(crate::otel::BackendKind::WebSocket, async { + let mut req = req; + if target.name.is_some() { + crate::otel::inject(req.headers_mut()); + } + Ok(proxy_websocket_inner(state, ctx, req, target).await) + }) + .await + .unwrap_or_else(|e| error_page(e.status())); + } + proxy_websocket_inner(state, ctx, req, target).await +} + +async fn proxy_websocket_inner( state: &ServerState, ctx: &ReqCtx, mut req: Request, @@ -74,7 +103,7 @@ pub(super) async fn proxy_websocket( .is_none() .then(|| hyper::upgrade::on(&mut req)); - let upgrade = match state.proxy.proxy_websocket(ctx, req, &target).await { + let mut upgrade = match state.proxy.proxy_websocket(ctx, req, &target).await { Ok(u) => u, Err(e) => { // Backend down on a WS upgrade → genuine fault (item 3). @@ -92,9 +121,10 @@ pub(super) async fn proxy_websocket( None => return error_page(StatusCode::BAD_GATEWAY), }; let resp = upgrade.response; + let reservation = upgrade.reservation.take(); if let Some(handoff) = uring_upgrade { - let io = start_uring_upgrade_relay(hyper_util::rt::TokioIo::new(upstream_io)); + let io = start_uring_upgrade_relay(hyper_util::rt::TokioIo::new(upstream_io), reservation); if handoff.handoff(io).await.is_err() { return error_page(StatusCode::BAD_GATEWAY); } @@ -104,6 +134,7 @@ pub(super) async fn proxy_websocket( // After we return `resp` (101), hyper upgrades the client connection; the // future resolves with the client IO, which we bridge to the upstream. tokio::spawn(async move { + let _reservation = reservation; match client_on_upgrade .expect("hyper upgrade future present") .await @@ -120,7 +151,10 @@ pub(super) async fn proxy_websocket( resp } -fn start_uring_upgrade_relay(upstream: U) -> UringUpgradeIo +fn start_uring_upgrade_relay( + upstream: U, + reservation: Option, +) -> UringUpgradeIo where U: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, { @@ -129,6 +163,7 @@ where let (to_upstream, mut downstream_rx) = tokio::sync::mpsc::channel::(8); let (upstream_tx, from_upstream) = tokio::sync::mpsc::channel::>(8); tokio::spawn(async move { + let _reservation = reservation; let (mut reader, mut writer) = tokio::io::split(upstream); let downstream_to_upstream = async { while let Some(bytes) = downstream_rx.recv().await { @@ -198,7 +233,9 @@ pub(super) fn resolve_proxy_target( .iter() .find(|e| e.name == handler) { - return Some(ProxyTarget::from_ext_processor(ep)); + return Some( + ProxyTarget::from_ext_processor(ep).in_scope(format!("vhost:{}", ctx.vhost_name)), + ); } state .ext_by_name @@ -214,7 +251,7 @@ mod tests { #[tokio::test] async fn uring_upgrade_relay_moves_bytes_both_directions() { let (client, mut upstream_peer) = tokio::io::duplex(1024); - let mut io = start_uring_upgrade_relay(client); + let mut io = start_uring_upgrade_relay(client, None); io.to_upstream .send(bytes::Bytes::from_static(b"client-frame")) diff --git a/crates/httpjet/src/pipeline/rewrite_glue.rs b/crates/httpjet/src/pipeline/rewrite_glue.rs index 5d46b65..54b55a8 100644 --- a/crates/httpjet/src/pipeline/rewrite_glue.rs +++ b/crates/httpjet/src/pipeline/rewrite_glue.rs @@ -128,6 +128,10 @@ pub(crate) struct RewriteOutcomeCache { } impl RewriteOutcomeCache { + pub(crate) fn empty_generation(&self) -> Self { + Self::new(self.ttl) + } + pub(crate) fn new(ttl: Duration) -> Self { RewriteOutcomeCache { map: DashMap::new(), @@ -151,15 +155,6 @@ impl RewriteOutcomeCache { } } - /// Drop every memoized outcome and reset the count. Called on SIGHUP reload so a - /// changed INLINE RewriteRule takes effect immediately, mirroring the sibling - /// `rewrite_cache.clear()` (the OutcomeKey carries no rule version, so a warm entry - /// would otherwise replay the pre-reload decision for up to one TTL). - pub(crate) fn clear(&self) { - self.map.clear(); - self.count.store(0, std::sync::atomic::Ordering::Relaxed); - } - /// Fresh cached outcome for `key`, or `None` if absent/expired/disabled. /// Production probes via `parts_hash` + `probe` (#313); the owned-key pair /// (`get`/`insert`) survives for the in-file tests. @@ -391,7 +386,7 @@ impl UaClassifyCache { self.clear(); } - /// Wholesale invalidation (SIGHUP config reload). + /// Wholesale invalidation when reclaiming a full memo. pub(crate) fn clear(&self) { self.map.clear(); self.count.store(0, Ordering::Relaxed); @@ -429,6 +424,8 @@ pub(super) fn run_rewrite( path: &str, query: &str, ) -> RwResult { + #[cfg(feature = "otel")] + let _trace_stage = crate::otel::stage(crate::otel::StageKind::Rewrite); if !state.rewrite_outcomes.enabled() { // `--rewrite-outcome-ttl-ms 0`: the cache is off entirely — no key build, // no counters (disabled is not "uncacheable"). @@ -1083,6 +1080,20 @@ mod tests { assert_eq!(rewrite_host(&ctx, &mk(None)), "fallback.example"); } + #[test] + fn empty_generation_preserves_old_outcomes_and_ttl() { + let old = Arc::new(RewriteOutcomeCache::new(Duration::from_secs(45))); + old.insert(okey("/a"), RwResult::Forbidden); + let next = Arc::new(old.empty_generation()); + assert_eq!(next.ttl, Duration::from_secs(45)); + assert!(next.get(&okey("/a")).is_none()); + assert!(matches!(old.get(&okey("/a")), Some(RwResult::Forbidden))); + old.insert(okey("/late"), RwResult::Forbidden); + assert!(next.get(&okey("/late")).is_none()); + next.insert(okey("/a"), RwResult::Gone); + assert!(matches!(old.get(&okey("/a")), Some(RwResult::Forbidden))); + } + #[test] fn outcome_cache_hit_miss_and_disable() { let c = Arc::new(RewriteOutcomeCache::new(DEFAULT_REWRITE_OUTCOME_TTL)); diff --git a/crates/httpjet/src/pipeline/suffix_routing.rs b/crates/httpjet/src/pipeline/suffix_routing.rs index c7fdd0e..eb0656e 100644 --- a/crates/httpjet/src/pipeline/suffix_routing.rs +++ b/crates/httpjet/src/pipeline/suffix_routing.rs @@ -62,6 +62,27 @@ pub(super) fn split_script_path( // vhost's `` LSAPI suffixes (per-vhost wins by being a // superset; suffixes mapped to a non-LSAPI handler are ignored here). let php_suffixes = effective_php_suffixes(state, ctx); + // Explicit CGI script-handler suffixes participate in the same filesystem + // resolution and authorization path. Dispatch later selects FastCGI only + // when the named processor is explicitly type=fcgi; unsupported CGI still + // resolves as executable and fails 503 rather than serving source bytes. + let cgi_suffixes: Vec = ctx + .vhost + .script_handlers + .iter() + .filter(|handler| handler.kind == hj_core::config::ContextKind::Cgi) + .map(|handler| handler.suffix.to_ascii_lowercase()) + .collect(); + let php_suffixes = if cgi_suffixes + .iter() + .all(|suffix| php_suffixes.contains(suffix)) + { + php_suffixes + } else { + let mut combined = php_suffixes.into_owned(); + combined.extend(cgi_suffixes); + std::borrow::Cow::Owned(combined) + }; // Hot-path gate: only chains that actually carry a `SetHandler`/`AddHandler`/ // `AddType` directive pay the per-prefix scope-match cost. Bool-field scan over // the (short) chain — no alloc/regex/syscall — so the common no-override case is diff --git a/crates/httpjet/src/resource_generation.rs b/crates/httpjet/src/resource_generation.rs new file mode 100644 index 0000000..ebcde81 --- /dev/null +++ b/crates/httpjet/src/resource_generation.rs @@ -0,0 +1,422 @@ +//! Bounded ownership of active and retiring transport generations. +use crate::uring::WorkerGroup; +use std::sync::Arc; + +pub(crate) const MAX_RETIRING_GENERATIONS: usize = 2; + +#[derive(Debug)] +pub(crate) struct ResourceOwnershipError; +impl std::fmt::Display for ResourceOwnershipError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("transport resources must be inactive, own the candidate trust epoch, and have unique TCP and UDS listener identities") + } +} +impl std::error::Error for ResourceOwnershipError {} + +pub(crate) struct TransportResources { + pub(crate) trust_epoch: Arc<()>, + groups: Vec, + certificates: std::collections::BTreeMap, hj_tls::CertReloadHandle>, + quic_policy: Option, + // Declared after workers so shutdown joins their drain before releasing + // certificate refresh ownership. Retirement stop() intentionally preserves it. + #[cfg(feature = "ocsp")] + ocsp: Option, +} + +/// Process-lifetime ownership of the bound UDP endpoints. Trust publications +/// update their accept policy in place; restarting SO_REUSEPORT QUIC workers +/// would strand established connection IDs on the wrong endpoint. +pub(crate) struct QuicResources { + group: WorkerGroup, + policy: crate::uring::h3::QuicReloadHandle, +} + +impl QuicResources { + pub(crate) fn new( + group: WorkerGroup, + policy: crate::uring::h3::QuicReloadHandle, + epoch: &Arc<()>, + ) -> Result { + if !group.is_prepared_for(epoch) { + group.stop(); + return Err(ResourceOwnershipError); + } + Ok(Self { group, policy }) + } + + pub(crate) fn activate(&self) { + self.group.activate(); + } + + pub(crate) fn stop(&self) { + self.group.stop(); + } + + pub(crate) fn publish(&self, policy: crate::uring::h3::PreparedQuicPolicy) { + self.policy.publish(policy); + } +} + +impl TransportResources { + pub(crate) fn with_quic_policy( + mut self, + policy: crate::uring::h3::PreparedQuicPolicy, + ) -> Result { + if self.quic_policy.is_some() || !policy.is_prepared_for(&self.trust_epoch) { + return Err(ResourceOwnershipError); + } + self.quic_policy = Some(policy); + Ok(self) + } + + pub(crate) fn has_quic_policy(&self) -> bool { + self.quic_policy.is_some() + } + + pub(crate) fn take_quic_policy(&mut self) -> Option { + self.quic_policy.take() + } + + pub(crate) fn with_certificate( + mut self, + identity: crate::uring::worker_group::TcpListenerId, + handle: hj_tls::CertReloadHandle, + ) -> Result { + if !identity.tls + || !self.is_prepared_for(&self.trust_epoch) + || self.certificates.contains_key(&identity.name) + || !self + .groups + .iter() + .any(|group| group.tcp_identity() == Some(&identity)) + { + return Err(ResourceOwnershipError); + } + self.certificates.insert(identity.name, handle); + Ok(self) + } + + pub(crate) fn certificate_handles(&self) -> Vec<(Arc, hj_tls::CertReloadHandle)> { + self.certificates + .iter() + .map(|(name, handle)| (name.clone(), handle.clone())) + .collect() + } + #[cfg(all(test, feature = "ocsp"))] + pub(crate) fn has_ocsp_refresh(&self) -> bool { + self.ocsp.is_some() + } + #[cfg(test)] + pub(crate) fn group_count(&self) -> usize { + self.groups.len() + } + #[cfg(feature = "ocsp")] + pub(crate) fn with_ocsp( + mut self, + manager: Arc, + shutdown: &tokio_util::sync::CancellationToken, + ) -> Result { + if !self.is_prepared_for(&self.trust_epoch) + || self.ocsp.is_some() + || shutdown.is_cancelled() + { + return Err(ResourceOwnershipError); + } + self.ocsp = Some(crate::ocsp_runtime::RefreshTask::prepare(manager, shutdown)); + Ok(self) + } + pub(crate) fn tcp_handoff_source( + &self, + identity: &crate::uring::worker_group::TcpListenerId, + ) -> std::io::Result { + self.groups + .iter() + .find(|group| group.tcp_identity() == Some(identity)) + .ok_or_else(|| std::io::Error::other("unknown TCP listener identity"))? + .tcp_handoff_source() + } + pub(crate) fn uds_handoff_source( + &self, + path: &std::path::Path, + ) -> std::io::Result { + self.groups + .iter() + .find(|group| group.uds_identity() == Some(path)) + .ok_or_else(|| std::io::Error::other("unknown UDS listener identity"))? + .uds_handoff_source() + } + pub(crate) fn new( + trust_epoch: Arc<()>, + groups: Vec, + ) -> Result { + let mut identities = std::collections::HashSet::new(); + let mut uds_identities = std::collections::HashSet::new(); + if groups.iter().any(|group| { + !group.is_prepared_for(&trust_epoch) + || group + .tcp_identity() + .is_some_and(|identity| !identities.insert(identity)) + || group + .uds_identity() + .is_some_and(|identity| !uds_identities.insert(identity.to_path_buf())) + }) { + for group in &groups { + group.stop(); + } + return Err(ResourceOwnershipError); + } + Ok(Self { + trust_epoch, + groups, + certificates: Default::default(), + quic_policy: None, + #[cfg(feature = "ocsp")] + ocsp: None, + }) + } + + pub(crate) fn activate(&self) { + #[cfg(feature = "ocsp")] + if let Some(task) = &self.ocsp { + task.activate(); + } + for group in &self.groups { + group.activate(); + } + } + + pub(crate) fn is_prepared_for(&self, epoch: &Arc<()>) -> bool { + #[cfg(feature = "ocsp")] + if self.ocsp.as_ref().is_some_and(|task| !task.is_prepared()) { + return false; + } + Arc::ptr_eq(&self.trust_epoch, epoch) + && self.groups.iter().all(|group| group.is_prepared_for(epoch)) + } + + pub(crate) fn stop(&self) { + for group in &self.groups { + group.stop(); + } + } + + fn is_finished(&self) -> bool { + self.groups.iter().all(WorkerGroup::is_finished) + } +} + +impl Drop for TransportResources { + fn drop(&mut self) { + // Signal every group before joining any: drain windows overlap rather + // than serially delaying cancellation of later transport families. + self.stop(); + } +} + +pub(crate) struct ResourceSlots { + pub(crate) active: Option, + retired: [Option; MAX_RETIRING_GENERATIONS], +} + +impl Default for ResourceSlots { + fn default() -> Self { + Self { + active: None, + retired: std::array::from_fn(|_| None), + } + } +} + +impl ResourceSlots { + pub(crate) fn has_capacity(&self) -> bool { + self.active.is_none() || self.retired.iter().any(Option::is_none) + } + + /// Requires an already checked free retirement slot. Only moves owners and + /// signals workers; no join, bind, allocation or thread creation occurs. + pub(crate) fn replace(&mut self, next: TransportResources) { + let slot = if self.active.is_some() { + Some( + self.retired + .iter_mut() + .find(|slot| slot.is_none()) + .expect("retirement capacity checked before publication"), + ) + } else { + None + }; + if let Some(old) = self.active.replace(next) { + old.stop(); + *slot.expect("active resource set has a retirement slot") = Some(old); + } + self.active.as_ref().unwrap().activate(); + } + + /// Move only finished owners out. The caller drops/joins them AFTER releasing + /// its publication mutex. An uninterruptible worker keeps its slot occupied. + pub(crate) fn take_finished( + &mut self, + ) -> [Option; MAX_RETIRING_GENERATIONS] { + std::array::from_fn(|i| { + if self.retired[i] + .as_ref() + .is_some_and(TransportResources::is_finished) + { + self.retired[i].take() + } else { + None + } + }) + } + + pub(crate) fn stop(&self) { + if let Some(active) = &self.active { + active.stop(); + } + for retired in self.retired.iter().flatten() { + retired.stop(); + } + } +} + +impl Drop for ResourceSlots { + fn drop(&mut self) { + self.stop(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::uring::worker_group::TcpListenerId; + + #[cfg(feature = "ocsp")] + #[tokio::test] + async fn retiring_workers_keep_refresh_until_generation_owner_is_dropped() { + let parent = tokio_util::sync::CancellationToken::new(); + let epoch = Arc::new(()); + let mut resources = TransportResources::new(epoch.clone(), Vec::new()).unwrap(); + let (started_tx, started) = tokio::sync::oneshot::channel(); + let (dropped_tx, mut dropped) = tokio::sync::oneshot::channel(); + struct OnDrop(Option>); + impl Drop for OnDrop { + fn drop(&mut self) { + let _ = self.0.take().unwrap().send(()); + } + } + resources.ocsp = Some(crate::ocsp_runtime::RefreshTask::prepare_with( + &parent, + move |_| async move { + let _guard = OnDrop(Some(dropped_tx)); + let _ = started_tx.send(()); + std::future::pending::<()>().await; + }, + )); + assert!(resources.is_prepared_for(&epoch)); + resources.activate(); + tokio::time::timeout(std::time::Duration::from_secs(1), started) + .await + .unwrap() + .unwrap(); + resources.stop(); + tokio::task::yield_now().await; + assert!(matches!( + dropped.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + )); + drop(resources); + tokio::time::timeout(std::time::Duration::from_secs(1), dropped) + .await + .unwrap() + .unwrap(); + assert!(!parent.is_cancelled()); + } + + #[test] + fn tcp_identity_selection_is_order_independent_and_transport_scoped() { + let parent = tokio_util::sync::CancellationToken::new(); + let epoch = Arc::new(()); + let mut owners = Vec::new(); + let mut groups = Vec::new(); + let mut expected = Vec::new(); + for (name, tls) in [("shared", true), ("other", false), ("shared", false)] { + let identity = TcpListenerId { + name: name.into(), + tls, + }; + let socket = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let mut group = WorkerGroup::for_tcp_epoch(&parent, epoch.clone(), identity.clone()); + owners.push(group.register_acceptor(&socket).unwrap()); + expected.push((identity, socket.local_addr().unwrap())); + groups.push(group); + } + let resources = TransportResources::new(epoch, groups).unwrap(); + resources.activate(); + for (identity, address) in expected.into_iter().rev() { + let handoff = resources + .tcp_handoff_source(&identity) + .unwrap() + .prepare(address) + .unwrap(); + assert_eq!(handoff.listeners.len(), 1); + assert_eq!(handoff.listeners[0].local_addr().unwrap(), address); + } + assert!( + resources + .tcp_handoff_source(&TcpListenerId { + name: "missing".into(), + tls: false, + }) + .is_err() + ); + drop(resources); + drop(owners); + assert!(!parent.is_cancelled()); + } + + #[test] + fn duplicate_tcp_identities_reject_the_entire_candidate() { + let parent = tokio_util::sync::CancellationToken::new(); + let epoch = Arc::new(()); + let identity = TcpListenerId { + name: "http".into(), + tls: false, + }; + let groups = (0..2) + .map(|_| WorkerGroup::for_tcp_epoch(&parent, epoch.clone(), identity.clone())) + .collect(); + assert!(TransportResources::new(epoch, groups).is_err()); + assert!(!parent.is_cancelled()); + } + + #[test] + fn resource_sets_reject_untagged_mismatched_and_active_workers() { + let parent = tokio_util::sync::CancellationToken::new(); + let epoch = Arc::new(()); + assert!(TransportResources::new(epoch.clone(), vec![WorkerGroup::new(&parent)]).is_err()); + assert!( + TransportResources::new( + epoch.clone(), + vec![WorkerGroup::for_epoch(&parent, Arc::new(()))] + ) + .is_err() + ); + let active = WorkerGroup::for_epoch(&parent, epoch.clone()); + active.activate(); + assert!(TransportResources::new(epoch.clone(), vec![active]).is_err()); + let cancelled = WorkerGroup::for_epoch(&parent, epoch.clone()); + cancelled.stop(); + assert!(TransportResources::new(epoch.clone(), vec![cancelled]).is_err()); + let prepared = WorkerGroup::for_epoch(&parent, epoch.clone()); + assert!(TransportResources::new(epoch, vec![prepared]).is_ok()); + let uds_epoch = Arc::new(()); + let unregistered_uds = WorkerGroup::for_uds_epoch( + &parent, + uds_epoch.clone(), + "/tmp/unregistered-httpjet.sock".into(), + ); + assert!(TransportResources::new(uds_epoch, vec![unregistered_uds]).is_err()); + assert!(!parent.is_cancelled()); + } +} diff --git a/crates/httpjet/src/serving_generation.rs b/crates/httpjet/src/serving_generation.rs new file mode 100644 index 0000000..d19451b --- /dev/null +++ b/crates/httpjet/src/serving_generation.rs @@ -0,0 +1,70 @@ +//! Connection/request views over compatible application generations. +use crate::state::ServerState; +use arc_swap::{ArcSwap, Guard}; +use std::sync::Arc; + +/// A resource generation's view is pinned again when accepting a connection. +/// Compatible application reloads remain visible. An incompatible trust epoch +/// cannot replace the connection's original application/trust combination. +#[derive(Clone)] +pub(crate) struct ServingView { + live: Arc>, + pinned: Arc, +} + +impl ServingView { + pub(crate) fn new(live: Arc>) -> Self { + Self { + pinned: live.load_full(), + live, + } + } + + /// Prepare against a candidate without publishing it. A fresh trust epoch + /// keeps this view on the candidate until the live holder publishes it. + pub(crate) fn candidate(live: Arc>, pinned: Arc) -> Self { + Self { live, pinned } + } + + pub(crate) fn trust_epoch(&self) -> Arc<()> { + self.pinned.trust_epoch.clone() + } + + /// True only while this view's accept-time trust policy matches the + /// published generation. QUIC uses this to reject, rather than admit under + /// a stale policy, during the brief cross-core ArcSwap propagation window. + pub(crate) fn is_current_trust_epoch(&self) -> bool { + Arc::ptr_eq(&self.live.load().trust_epoch, &self.pinned.trust_epoch) + } + + pub(crate) fn load(&self) -> Guard> { + let current = self.live.load(); + if Arc::ptr_eq(¤t.trust_epoch, &self.pinned.trust_epoch) { + current + } else { + Guard::from_inner(self.pinned.clone()) + } + } + + pub(crate) fn load_full(&self) -> Arc { + Guard::into_inner(self.load()) + } + + pub(crate) fn pin_connection(&self) -> Self { + Self { + live: self.live.clone(), + pinned: self.load_full(), + } + } +} + +impl From>> for ServingView { + fn from(live: Arc>) -> Self { + Self::new(live) + } +} + +/// Selected once before dispatch and carried through a fast-path miss into the +/// Tokio bridge. Remote request headers cannot manufacture this extension. +#[derive(Clone)] +pub(crate) struct RequestGeneration(pub(crate) Arc); diff --git a/crates/httpjet/src/state.rs b/crates/httpjet/src/state.rs index 7faf177..a620e0e 100644 --- a/crates/httpjet/src/state.rs +++ b/crates/httpjet/src/state.rs @@ -9,7 +9,8 @@ use tokio_util::sync::CancellationToken; use hj_acl::AccessControl; use hj_compress::{Compress, ExpiresRules}; use hj_core::Router; -use hj_core::config::{ExtKind, ExtProcessor, ServerConfig}; +use hj_core::config::{ExtAddress, ExtKind, ExtProcessor, ServerConfig}; +use hj_fastcgi::{Endpoint as FastCgiEndpoint, FastCgi, FastCgiPool}; use hj_http::ServeConfig; use hj_log::{AccessLogger, LogFormat}; @@ -182,6 +183,16 @@ impl Default for RewriteTuning { /// State shared across all workers and connections. Cheap to clone (`Arc`). pub struct ServerState { + #[cfg(feature = "acme")] + pub acme: Option>, + pub generation: u64, + /// Reused only by application-compatible reloads. Resource replacement must + /// publish a fresh epoch together with its matching listener trust policy. + pub(crate) trust_epoch: Arc<()>, + /// Set by the first API transaction; subsequent reloads retain this epoch + /// while generation separates response-cache entries across configurations. + pub response_cache_epoch: Option>, + pub config_fingerprint: String, pub server: Arc, pub router: Arc, pub serve_config: ServeConfig, @@ -189,12 +200,25 @@ pub struct ServerState { /// into heap (io_uring H1/H2/H3 transport buffering + hj-lsapi collect_to_cap). /// Process-lifetime: carried across SIGHUP so reservations never straddle two caps. pub body_budget: Arc, + /// Process-lifetime request content-decoding policy. Gzip is the historical + /// default; optional Brotli/zstd choices are injected from the CLI after + /// boot construction and carried unchanged across configuration reloads. + pub(crate) request_decompression: crate::uring::request_body::RequestDecompression, + /// Optional loopback request-inspection sidecar. Process-lifetime CLI + /// policy, carried unchanged across application configuration reloads. + pub waf: Option>, + /// Compile-time linked request/response extensions. Empty in the shipped + /// binary; process-lifetime and carried unchanged across config reloads. + pub extensions: Arc, /// Terminal static-file handler. pub static_handler: StaticFiles, /// Per-vhost lsphp pool registry (None if PHP is disabled or the default /// pool failed to start). With suEXEC off this holds exactly one entry (the /// canonical `"php"` pool) behaving byte-for-byte like today's single pool. pub lsapi: Option>, + /// Opt-in FastCGI handlers keyed by `(vhost scope, processor name)`. + /// A scoped processor always wins over a global processor of the same name. + pub fastcgi: HashMap<(Option, String), Arc>, /// Reverse-proxy engine for this config generation. Reload retains unchanged /// upstream Arcs while obsolete named definitions drain with the old state. pub proxy: Arc, @@ -243,7 +267,7 @@ pub struct ServerState { /// bitmap instead of the raw User-Agent (see `UaClassifyCache`). pub rewrite_ua_classify: bool, /// Bounded (ruleset id, UA) -> match-bitmap memo backing `rewrite_ua_classify`. - /// Cleared wholesale on SIGHUP reload (rulesets reparse with fresh ids). + /// Replaced on reload; in-flight requests retain their generation's memo. pub ua_classify: Arc, /// `Alt-Svc` header value advertising HTTP/3 (set when QUIC is enabled), /// pre-parsed to a `HeaderValue` once at startup so the per-response insert is a @@ -565,19 +589,101 @@ fn build_config_derived( } fn configured_proxy_targets(server: &ServerConfig) -> Vec { - server + let mut targets: Vec<_> = server .ext_processors .iter() - .chain( - server - .vhosts - .values() - .filter_map(|decl| decl.config.as_deref()) - .flat_map(|vhost| vhost.extra_ext_processors.iter()), - ) .filter(|processor| processor.kind == ExtKind::Proxy) .map(ProxyTarget::from_ext_processor) - .collect() + .collect(); + for (name, decl) in &server.vhosts { + if let Some(vhost) = &decl.config { + targets.extend( + vhost + .extra_ext_processors + .iter() + .filter(|ep| ep.kind == ExtKind::Proxy) + .map(|ep| { + ProxyTarget::from_ext_processor(ep).in_scope(format!("vhost:{name}")) + }), + ); + } + } + targets +} + +fn configured_fastcgi_handlers( + server: &ServerConfig, + body_budget: &Arc, +) -> Result, String), Arc>, String> { + fn insert( + handlers: &mut HashMap<(Option, String), Arc>, + scope: Option, + processor: &ExtProcessor, + max_body: u64, + body_budget: &Arc, + ) -> Result<(), String> { + if processor.kind != ExtKind::FastCgi { + return Ok(()); + } + let endpoint = match &processor.address { + ExtAddress::Tcp(address) => FastCgiEndpoint::Tcp(*address), + ExtAddress::HostPort(address) if !address.trim().is_empty() => { + FastCgiEndpoint::TcpHost(address.clone()) + } + ExtAddress::Uds(path) if !path.as_os_str().is_empty() => { + FastCgiEndpoint::Unix(path.clone()) + } + _ => { + return Err(format!( + "FastCGI processor {} has an empty address", + processor.name + )); + } + }; + let pool = Arc::new( + FastCgiPool::new( + endpoint, + processor.max_conns as usize, + processor.init_timeout, + processor.pc_keep_alive_timeout, + ) + .map_err(|error| format!("FastCGI processor {}: {error}", processor.name))?, + ); + let handler = FastCgi::new(pool) + .max_body(max_body) + .body_buffer_budget(Arc::clone(body_budget)) + .base_env(processor.env.clone())?; + let key = (scope, processor.name.clone()); + if handlers.insert(key, Arc::new(handler)).is_some() { + return Err(format!("duplicate FastCGI processor {}", processor.name)); + } + Ok(()) + } + + let mut handlers = HashMap::new(); + for processor in &server.ext_processors { + insert( + &mut handlers, + None, + processor, + server.tuning.max_req_body_size, + body_budget, + )?; + } + for (vhost_name, declaration) in &server.vhosts { + if let Some(vhost) = &declaration.config { + for processor in &vhost.extra_ext_processors { + insert( + &mut handlers, + Some(vhost_name.clone()), + processor, + server.tuning.max_req_body_size, + body_budget, + )?; + } + } + } + Ok(handlers) } /// Build the post-handler response-transform pipeline in its fixed order. Called from @@ -647,6 +753,16 @@ fn static_store_config(server: &ServerConfig) -> hj_pagecache::StoreConfig { } impl ServerState { + pub(crate) fn fastcgi_handler( + &self, + vhost_name: &str, + processor_name: &str, + ) -> Option<&Arc> { + self.fastcgi + .get(&(Some(vhost_name.to_string()), processor_name.to_string())) + .or_else(|| self.fastcgi.get(&(None, processor_name.to_string()))) + } + /// (#248) The access logger for a request served by `vhost_name`: the vhost's /// own `` file when it declares one, else the unified log. pub fn access_logger_for(&self, vhost_name: &str) -> Option<&Arc> { @@ -786,8 +902,25 @@ impl ServerState { let telemetry = Arc::new(crate::telemetry::Telemetry::new( server.vhosts.keys().cloned(), )); + let proxy = Arc::new(Proxy::with_targets(configured_proxy_targets(&server))); let geo = Arc::new(build_geo_rules(&server)?); + let body_budget = lsapi.as_ref().map(|r| r.body_budget()).unwrap_or_else(|| { + Arc::new(hj_core::budget::BodyBufferBudget::new( + hj_core::budget::DEFAULT_BODY_BUFFER_MEM, + )) + }); + let fastcgi = configured_fastcgi_handlers(&server, &body_budget)?; + let extensions = Arc::new(crate::extensions::compiled_registry()); + for (name, kind) in extensions.registrations() { + tracing::info!(extension = name, kind, "compile-time extension registered"); + } Ok(Arc::new(ServerState { + generation: 1, + trust_epoch: Arc::new(()), + response_cache_epoch: None, + #[cfg(feature = "acme")] + acme: None, + config_fingerprint: crate::admin::fingerprint(&server), server, router: cd.router, page_cache_inflight: Arc::new(crate::lscache::InflightRegistry::default()), @@ -806,14 +939,14 @@ impl ServerState { serve_config: cd.serve_config, // One server-wide buffered-body cap shared with the LSAPI handlers' // collect_to_cap (when PHP is enabled); transports reserve here too. - body_budget: lsapi.as_ref().map(|r| r.body_budget()).unwrap_or_else(|| { - Arc::new(hj_core::budget::BodyBufferBudget::new( - hj_core::budget::DEFAULT_BODY_BUFFER_MEM, - )) - }), + body_budget, + request_decompression: Default::default(), + waf: None, + extensions, static_handler: cd.static_handler, lsapi, - proxy: Arc::new(Proxy::new()), + fastcgi, + proxy, rewrite_cache: Arc::new(HtaccessCache::new()), inline_rules: cd.inline_rules, ext_by_name: cd.ext_by_name, @@ -859,26 +992,17 @@ impl ServerState { /// applied here (the sockets/acceptor/pool live outside `ServerState`) — the /// SIGHUP handler rejects a reload that touches those. pub fn reload(old: &ServerState, server: Arc) -> Result, String> { + let generation = old + .generation + .checked_add(1) + .ok_or("generation exhausted")?; + #[cfg(feature = "acme")] + if let Some(acme) = &old.acme { + acme.validate_reload(server.clone())?; + } // CF_SEND_ZSTD is a process-lifetime CLI flag; carry it across SIGHUP by // reading it back off the old generation's Compress (its single home). let cd = build_config_derived(&server, old.compress.cf_send_zstd())?; - // (#10) Drop the accumulated `.htaccess` parse cache on reload. The cache is - // keyed by attacker-controlled request directory prefixes (every absent - // intermediate dir of a requested path inserts a miss entry), so for an - // htaccess-enabled vhost (allowOverride=31) it grows with request-path - // cardinality. The per-insert soft cap (hj-rewrite) bounds steady-state growth; - // clearing here drops the whole map on SIGHUP so a reload also reclaims it (and - // picks up `.htaccess` edits immediately, instead of relying on mtime checks). - old.rewrite_cache.clear(); - // Also drop the INLINE-rewrite outcome memo — it carries no rule version, so a warm - // entry would replay the pre-reload rewrite/redirect/forbid decision for up to one TTL - // after a SIGHUP that edits inline RewriteRules (the htaccess cache clear above only - // covers `.htaccess`). Reload should take effect immediately for both. - old.rewrite_outcomes.clear(); - // And the UA-classification memo: reparsed rulesets get fresh ids (so stale - // entries could never be replayed anyway), but the reload is the natural - // point to drop the dead ones wholesale rather than let them squat the cap. - old.ua_classify.clear(); // Rebuild the transform pipeline from the NEW expires/compress + carried-over // static cache/alt_svc, so the reloaded generation behaves identically. let transforms = build_transforms( @@ -938,13 +1062,23 @@ impl ServerState { ); } let geo = Arc::new(build_geo_rules(&server)?); + let fastcgi = configured_fastcgi_handlers(&server, &old.body_budget)?; Ok(Arc::new(ServerState { + generation, + trust_epoch: old.trust_epoch.clone(), + response_cache_epoch: old.response_cache_epoch.clone(), + #[cfg(feature = "acme")] + acme: old.acme.clone(), + config_fingerprint: crate::admin::fingerprint(&server), server, router: cd.router, serve_config: cd.serve_config, // Process-lifetime budget: reservations in flight when a SIGHUP lands must // release against the SAME cap they were admitted under. body_budget: old.body_budget.clone(), + request_decompression: old.request_decompression, + waf: old.waf.clone(), + extensions: old.extensions.clone(), static_handler: cd.static_handler, inline_rules: cd.inline_rules, ext_by_name: cd.ext_by_name, @@ -957,13 +1091,16 @@ impl ServerState { mtls_required_vhosts: cd.mtls_required_vhosts, // ---- runtime half: carried forward (proxy filtered to new config) ---- lsapi: old.lsapi.clone(), + fastcgi, proxy, - rewrite_cache: old.rewrite_cache.clone(), + // Candidate construction must not clear live caches. Separate generations + // also prevent an in-flight old request repopulating the new rule memo. + rewrite_cache: Arc::new(HtaccessCache::new()), static_cache: old.static_cache.clone(), stat_cache: old.stat_cache.clone(), - rewrite_outcomes: old.rewrite_outcomes.clone(), + rewrite_outcomes: Arc::new(old.rewrite_outcomes.empty_generation()), rewrite_ua_classify: old.rewrite_ua_classify, - ua_classify: old.ua_classify.clone(), + ua_classify: Arc::new(crate::pipeline::UaClassifyCache::new()), access_log: old.access_log.clone(), // (#248) Per-vhost log writers are process-lifetime like the unified one: // a SIGHUP that adds/removes a vhost log file takes effect on RESTART @@ -1015,6 +1152,7 @@ mod tests { fn processor(name: &str, port: u16) -> ExtProcessor { ExtProcessor { + load_balance: Default::default(), name: name.into(), kind: ExtKind::Proxy, address: ExtAddress::HostPort(format!("127.0.0.1:{port}")), @@ -1095,6 +1233,717 @@ mod tests { ) } + #[tokio::test] + async fn admin_write_network_validates_publishes_and_rejects_stale() { + use crate::{ + admin_auth::AuthToken, admin_resources::ResourceRoots, admin_write::Control, + config_transaction::Coordinator, + }; + use arc_swap::ArcSwap; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + async fn send( + addr: std::net::SocketAddr, + method: &str, + path: &str, + revision: &str, + body: &str, + authorized: bool, + ) -> (u16, serde_json::Value) { + let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap(); + let auth = if authorized { TOKEN } else { "invalid" }; + let extra = if method == "GET" { + String::new() + } else { + format!( + "If-Match: \"{revision}\"\r\nContent-Type: application/json\r\nContent-Length: {}\r\n", + body.len() + ) + }; + let request = format!( + "{method} {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: Bearer {auth}\r\n{extra}\r\n{body}" + ); + stream.write_all(request.as_bytes()).await.unwrap(); + let mut bytes = Vec::new(); + tokio::time::timeout(Duration::from_secs(5), stream.read_to_end(&mut bytes)) + .await + .unwrap() + .unwrap(); + let response = String::from_utf8(bytes).unwrap(); + let (head, body) = response.split_once("\r\n\r\n").unwrap(); + ( + head.split_whitespace().nth(1).unwrap().parse().unwrap(), + serde_json::from_str(body).unwrap(), + ) + } + let root = temp_root("admin-write"); + let xml = "fixture"; + let cfg = Arc::new(hj_config::parse_bundle(&root, xml, &Default::default(), "").unwrap()); + let initial = state(cfg); + let holder = Arc::new(ArcSwap::from(initial.clone())); + let coordinator = Arc::new(Coordinator::new(holder.clone()).unwrap()); + let revision = coordinator.revision(); + let notifications = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let notify = notifications.clone(); + let control = Arc::new(Control::new( + coordinator.clone(), + Arc::new(ResourceRoots::new(&[root.clone()]).unwrap()), + false, + None, + Arc::new(move || { + notify.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + }), + )); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let task = tokio::spawn(crate::admin_write::serve( + listener, + Arc::new(AuthToken::fixture(TOKEN.as_bytes())), + control, + )); + let body = serde_json::json!({"server_xml": "fixturenext.html", "vhosts": [], "mime": "text/plain = txt"}).to_string(); + assert_eq!( + send(addr, "PUT", "/v1/config", &revision, &body, false) + .await + .0, + 401 + ); + assert!(Arc::ptr_eq(&initial, &holder.load_full())); + let validated = send(addr, "POST", "/v1/config/validate", &revision, &body, true).await; + assert_eq!(validated.0, 200); + assert_eq!(validated.1["published"], false); + assert!(Arc::ptr_eq(&initial, &holder.load_full())); + let committed = send(addr, "PUT", "/v1/config", &revision, &body, true).await; + assert_eq!(committed.0, 200); + assert_eq!(holder.load().server.index_files, vec!["next.html"]); + assert_eq!(holder.load().generation, 2); + assert_eq!(committed.1["persistence"], "volatile"); + assert_eq!(committed.1["revision"], coordinator.revision()); + assert_eq!( + send(addr, "PUT", "/v1/config", &revision, &body, true) + .await + .0, + 412 + ); + let current = coordinator.revision(); + assert_eq!( + send(addr, "PUT", "/v1/config", ¤t, "{broken secret", true) + .await + .0, + 400 + ); + assert_eq!(holder.load().generation, 2); + assert_eq!(notifications.load(std::sync::atomic::Ordering::SeqCst), 1); + let observed = send(addr, "GET", "/v1/revision", "", "", true).await; + assert_eq!(observed.1["revision"], current); + let restart = serde_json::json!({"server_xml": "secret-user", "vhosts": [], "mime": ""}).to_string(); + let rejected = send(addr, "PUT", "/v1/config", ¤t, &restart, true).await; + assert_eq!(rejected.0, 409); + assert_eq!(rejected.1, serde_json::json!({"error": "restart_required"})); + assert_eq!(holder.load().generation, 2); + let (a, b) = tokio::join!( + send(addr, "PUT", "/v1/config", ¤t, &body, true), + send(addr, "PUT", "/v1/config", ¤t, &body, true), + ); + assert_eq!(usize::from(a.0 == 200) + usize::from(b.0 == 200), 1); + assert!([200, 412, 503].contains(&a.0) && [200, 412, 503].contains(&b.0)); + assert_eq!(holder.load().generation, 3); + assert_eq!(notifications.load(std::sync::atomic::Ordering::SeqCst), 2); + coordinator.close(); + initial.shutdown.cancel(); + tokio::time::timeout(Duration::from_secs(2), task) + .await + .unwrap() + .unwrap(); + std::fs::remove_dir_all(root).unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn admin_write_publishes_prepared_tcp_trust_generation() { + use crate::{ + admin_protocol::{Operation, Request}, + admin_resources::ResourceRoots, + admin_write::{Control, TcpReplacementPolicy}, + config_transaction::Coordinator, + listener_plan::{TcpLaunchPolicy, UdsLaunchPolicy}, + resource_generation::TransportResources, + uring::{ListenerBinding, pipeline_admission, spawn_uring_http, spawn_uring_uds}, + }; + use arc_swap::ArcSwap; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let root = temp_root("admin-tcp-resource"); + let xml = |name: &str| { + format!( + "{name}\ +
127.0.0.1:1
0
\ +
" + ) + }; + let initial_xml = xml("old-http"); + let cfg = Arc::new( + hj_config::parse_bundle(&root, &initial_xml, &Default::default(), "").unwrap(), + ); + let initial = state(cfg); + let holder = Arc::new(ArcSwap::from(initial.clone())); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let admission = pipeline_admission(holder.clone()); + let workers = spawn_uring_http( + holder.clone(), + Arc::from("old-http"), + address, + 1, + Some(vec![listener]), + admission.clone(), + ListenerBinding::default(), + ) + .unwrap(); + let uds_path = root.join("http.sock"); + let uds_workers = spawn_uring_uds( + holder.clone(), + Arc::from("old-http"), + uds_path.clone(), + None, + admission.clone(), + ) + .unwrap(); + let coordinator = Arc::new( + Coordinator::new(holder.clone()) + .unwrap() + .with_tcp_launch_policy(TcpLaunchPolicy { + http: address, + https: None, + }) + .with_uds_launch_policy(UdsLaunchPolicy { + path: uds_path.clone(), + }), + ); + coordinator + .install_initial_resources( + TransportResources::new(initial.trust_epoch.clone(), vec![workers, uds_workers]) + .unwrap(), + ) + .unwrap(); + let control = Control::new( + coordinator.clone(), + Arc::new(ResourceRoots::new(std::slice::from_ref(&root)).unwrap()), + false, + None, + Arc::new(|| {}), + ) + .with_tcp_replacement(TcpReplacementPolicy { + acme_bootstrap: false, + ktls: false, + admission, + }); + let replacement_xml = xml("new-http"); + let request = |operation| Request { + operation, + revision: Some(coordinator.revision()), + body: serde_json::json!({ + "server_xml": &replacement_xml, + "vhosts": [], + "mime": "" + }) + .to_string() + .into_bytes(), + }; + let validated = control.execute(request(Operation::Validate)).await; + assert_eq!( + validated.0, 200, + "resource validation response: {validated:?}" + ); + assert!(Arc::ptr_eq(&initial, &holder.load_full())); + let mut validation_probe = tokio::net::UnixStream::connect(&uds_path).await.unwrap(); + validation_probe + .write_all(b"GET / HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + let mut validation_response = Vec::new(); + tokio::time::timeout( + Duration::from_secs(5), + validation_probe.read_to_end(&mut validation_response), + ) + .await + .unwrap() + .unwrap(); + assert!(validation_response.starts_with(b"HTTP/1.1 ")); + let response = control.execute(request(Operation::Publish)).await; + assert_eq!( + response.0, 200, + "resource publication response: {response:?}" + ); + assert_eq!(holder.load().generation, 2); + assert_eq!(holder.load().server.listeners[0].name, "new-http"); + assert!(!Arc::ptr_eq( + &initial.trust_epoch, + &holder.load().trust_epoch + )); + + let mut client = tokio::net::TcpStream::connect(address).await.unwrap(); + client + .write_all(b"GET / HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + let mut response = Vec::new(); + tokio::time::timeout(Duration::from_secs(5), client.read_to_end(&mut response)) + .await + .unwrap() + .unwrap(); + assert!(response.starts_with(b"HTTP/1.1 ")); + + let mut uds_client = tokio::net::UnixStream::connect(&uds_path).await.unwrap(); + uds_client + .write_all(b"GET / HTTP/1.1\r\nHost: test\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + let mut uds_response = Vec::new(); + tokio::time::timeout( + Duration::from_secs(5), + uds_client.read_to_end(&mut uds_response), + ) + .await + .unwrap() + .unwrap(); + assert!(uds_response.starts_with(b"HTTP/1.1 ")); + + coordinator.close(); + initial.shutdown.cancel(); + coordinator.finish_shutdown(); + std::fs::remove_dir_all(root).unwrap(); + } + + #[tokio::test] + async fn admin_cancelled_writer_releases_admission_without_publishing() { + use crate::{ + admin_protocol::{Operation, Request}, + admin_resources::ResourceRoots, + admin_write::Control, + config_transaction::Coordinator, + }; + use arc_swap::ArcSwap; + let root = temp_root("admin-cancel"); + let xml = ""; + let cfg = Arc::new(hj_config::parse_bundle(&root, xml, &Default::default(), "").unwrap()); + let initial = state(cfg); + let holder = Arc::new(ArcSwap::from(initial.clone())); + let coordinator = Arc::new(Coordinator::new(holder.clone()).unwrap()); + let control = Arc::new(Control::new( + coordinator.clone(), + Arc::new(ResourceRoots::new(&[root.clone()]).unwrap()), + false, + None, + Arc::new(|| {}), + )); + let request = || Request { + operation: Operation::Publish, + revision: Some(coordinator.revision()), + body: serde_json::json!({"server_xml": xml, "vhosts": [], "mime": ""}) + .to_string() + .into_bytes(), + }; + let held = coordinator.begin().await; + let pending_request = request(); + let pending_control = control.clone(); + let pending = tokio::spawn(async move { pending_control.execute(pending_request).await }); + tokio::time::timeout(Duration::from_secs(2), async { + while control.available_candidates() != 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(control.execute(request()).await.0, 503); + let read = tokio::time::timeout( + Duration::from_secs(1), + control.execute(Request { + operation: Operation::Revision, + revision: None, + body: Vec::new(), + }), + ) + .await + .unwrap(); + assert_eq!(read.0, 200); + pending.abort(); + assert!(pending.await.unwrap_err().is_cancelled()); + assert_eq!(control.available_candidates(), 1); + assert!(Arc::ptr_eq(&initial, &holder.load_full())); + // A writer waiting for SIGHUP also has a bounded lock-acquisition deadline. + let timed_out = control.execute(request()).await; + assert_eq!(timed_out, (503, "{\"error\":\"busy\"}".into())); + assert_eq!(control.available_candidates(), 1); + drop(held); + assert_eq!(control.execute(request()).await.0, 200); + assert_eq!(holder.load().generation, 2); + std::fs::remove_dir_all(root).unwrap(); + } + + #[tokio::test] + async fn reload_candidate_owns_fresh_rewrite_caches() { + let root = temp_root("candidate-caches"); + let cfg = config(&root, Vec::new(), Vec::new()); + let old = state(cfg.clone()); + let rules = + hj_rewrite::Htaccess::parse("RewriteEngine On\nRewriteRule ^ /next [L]").unwrap(); + old.ua_classify.get_or_compute(&rules.rules, "test-agent"); + assert_eq!(old.ua_classify.len(), 1); + let next = ServerState::reload(&old, cfg.clone()).unwrap(); + assert!(!Arc::ptr_eq(&old.rewrite_cache, &next.rewrite_cache)); + assert!(!Arc::ptr_eq(&old.rewrite_outcomes, &next.rewrite_outcomes)); + assert!(!Arc::ptr_eq(&old.ua_classify, &next.ua_classify)); + assert_eq!(old.ua_classify.len(), 1); + assert_eq!(next.ua_classify.len(), 0); + drop(next); + let mut invalid = (*cfg).clone(); + invalid.security.geo_allow.push("US".into()); + assert!(ServerState::reload(&old, Arc::new(invalid)).is_err()); + assert_eq!(old.ua_classify.len(), 1); + std::fs::remove_dir_all(root).unwrap(); + } + + #[tokio::test] + async fn connection_views_follow_only_compatible_application_generations() { + use crate::config_transaction::{Coordinator, PublishError}; + use crate::serving_generation::ServingView; + let root = temp_root("connection-generations"); + let cfg = config(&root, Vec::new(), Vec::new()); + let initial = state(cfg.clone()); + let holder = Arc::new(arc_swap::ArcSwap::from(initial.clone())); + let worker = ServingView::new(holder.clone()); + let early_connection = worker.pin_connection(); + let application = ServerState::reload(&initial, cfg.clone()).unwrap(); + assert!(Arc::ptr_eq(&initial.trust_epoch, &application.trust_epoch)); + holder.store(application.clone()); + assert!(Arc::ptr_eq(&early_connection.load_full(), &application)); + let later_connection = worker.pin_connection(); + + // A trust-epoch change requires the resource publication path; the + // existing application-only writer must not silently accept it. + let mut replacement = ServerState::reload(&application, cfg).unwrap(); + Arc::get_mut(&mut replacement).unwrap().trust_epoch = Arc::new(()); + let coordinator = Coordinator::new(holder.clone()).unwrap(); + let transaction = coordinator.begin().await; + let revision = transaction.revision(); + assert_eq!( + transaction.publish(&revision, replacement.clone()), + Err(PublishError::ResourceRequired) + ); + assert!(Arc::ptr_eq(&holder.load_full(), &application)); + + // Model a future complete resource-bundle publication. Each old + // connection stays with its acceptance snapshot, never the new trust. + holder.store(replacement.clone()); + assert!(Arc::ptr_eq(&early_connection.load_full(), &initial)); + assert!(Arc::ptr_eq(&later_connection.load_full(), &application)); + assert!(Arc::ptr_eq( + &ServingView::new(holder).pin_connection().load_full(), + &replacement + )); + std::fs::remove_dir_all(root).unwrap(); + } + + #[tokio::test] + async fn transaction_preconditions_serialize_writers_and_reject_old_incarnations() { + use crate::config_transaction::{Coordinator, PublishError}; + use arc_swap::ArcSwap; + let root = temp_root("transaction-publication"); + let cfg = config(&root, Vec::new(), Vec::new()); + let old = state(cfg.clone()); + let holder = Arc::new(ArcSwap::from(old.clone())); + let coordinator = Arc::new(Coordinator::new(holder.clone()).unwrap()); + let first = coordinator.begin().await; + let old_revision = first.revision(); + let next = ServerState::reload(&old, cfg.clone()).unwrap(); + + // A second writer must wait, then observe the published generation. + let waiting = coordinator.clone(); + let task = tokio::spawn(async move { + let transaction = waiting.begin().await; + (transaction.current.generation, transaction.revision()) + }); + tokio::task::yield_now().await; + assert!(!task.is_finished()); + first.publish(&old_revision, next.clone()).unwrap(); + let (generation, new_revision) = task.await.unwrap(); + assert_eq!(generation, 2); + assert_ne!(old_revision, new_revision); + assert!(Arc::ptr_eq(&holder.load_full(), &next)); + + let rejected = coordinator.begin().await; + let third = ServerState::reload(&next, cfg.clone()).unwrap(); + assert_eq!( + rejected.publish(&old_revision, third.clone()), + Err(PublishError::Conflict) + ); + assert!(Arc::ptr_eq(&holder.load_full(), &next)); + + let invalid = coordinator.begin().await; + let revision = invalid.revision(); + assert_eq!( + invalid.publish(&revision, next.clone()), + Err(PublishError::InvalidGeneration) + ); + assert!(Arc::ptr_eq(&holder.load_full(), &next)); + + // A new coordinator models a process restart at the same generation. + let restarted = Coordinator::new(Arc::new(ArcSwap::from(next.clone()))).unwrap(); + let transaction = restarted.begin().await; + assert_ne!(transaction.revision(), new_revision); + assert_eq!( + transaction.publish(&new_revision, third), + Err(PublishError::Conflict) + ); + + // Dropping validation-only work never publishes a generation. + drop(coordinator.begin().await); + assert!(Arc::ptr_eq(&holder.load_full(), &next)); + let pending = coordinator.begin().await; + let revision = pending.revision(); + let prepared = ServerState::reload(&next, cfg.clone()).unwrap(); + coordinator.close(); + assert_eq!( + pending.publish(&revision, prepared), + Err(PublishError::Closed) + ); + assert!(Arc::ptr_eq(&holder.load_full(), &next)); + let mut exhausted = state(cfg.clone()); + Arc::get_mut(&mut exhausted).unwrap().generation = u64::MAX; + assert!( + matches!(ServerState::reload(&exhausted, cfg), Err(error) if error == "generation exhausted") + ); + std::fs::remove_dir_all(root).unwrap(); + } + + #[tokio::test] + async fn resource_publication_bounds_retirement_without_joining_under_lock() { + use crate::config_transaction::{Coordinator, PublishError}; + use crate::resource_generation::TransportResources; + use std::sync::mpsc; + use std::time::Duration; + struct Signals { + activated: mpsc::Receiver, + stopped: mpsc::Receiver<()>, + release: mpsc::Sender<()>, + } + fn resources( + state: &Arc, + holder: &Arc>, + ) -> (TransportResources, Signals) { + let mut group = + crate::uring::WorkerGroup::for_epoch(&state.shutdown, state.trust_epoch.clone()); + let gate = group.activation_gate(); + let (activated_tx, activated) = mpsc::channel(); + let (stopped_tx, stopped) = mpsc::channel(); + let (release, release_rx) = mpsc::channel(); + let holder = holder.clone(); + group + .spawn(std::thread::Builder::new(), move |shutdown| { + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + if runtime.block_on(gate.wait()) { + activated_tx.send(holder.load().generation).unwrap(); + runtime.block_on(shutdown.cancelled()); + stopped_tx.send(()).unwrap(); + // Deliberately keep retirement unfinished until released. + // Dropping the test's sender also releases on a panic path. + let _ = release_rx.recv(); + } else { + let _ = activated_tx.send(0); + let _ = stopped_tx.send(()); + } + }) + .unwrap(); + ( + TransportResources::new(state.trust_epoch.clone(), vec![group]).unwrap(), + Signals { + activated, + stopped, + release, + }, + ) + } + fn next(old: &Arc) -> Arc { + let mut state = ServerState::reload(old, old.server.clone()).unwrap(); + Arc::get_mut(&mut state).unwrap().trust_epoch = Arc::new(()); + state + } + let root = temp_root("resource-publication"); + let initial = state(config(&root, Vec::new(), Vec::new())); + let holder = Arc::new(arc_swap::ArcSwap::from(initial.clone())); + let coordinator = Coordinator::new(holder.clone()).unwrap(); + let (owned, first) = resources(&initial, &holder); + coordinator.install_initial_resources(owned).unwrap(); + assert_eq!( + first + .activated + .recv_timeout(Duration::from_secs(2)) + .unwrap(), + 1 + ); + + let second_state = next(&initial); + for rejection in ["revision", "owner_epoch", "unchanged_epoch", "cancelled"] { + let submitted = if rejection == "unchanged_epoch" { + ServerState::reload(&initial, initial.server.clone()).unwrap() + } else { + second_state.clone() + }; + let owner_state = if rejection == "owner_epoch" { + &initial + } else { + &submitted + }; + let (owned, rejected) = resources(owner_state, &holder); + if rejection == "cancelled" { + owned.stop(); + } + let transaction = coordinator.begin().await; + let revision = if rejection == "revision" { + "stale-revision".into() + } else { + transaction.revision() + }; + let expected = if rejection == "revision" { + PublishError::Conflict + } else { + PublishError::ResourceRequired + }; + assert_eq!( + transaction.publish_resources(&revision, submitted, owned), + Err(expected) + ); + assert_eq!( + rejected + .activated + .recv_timeout(Duration::from_secs(2)) + .unwrap(), + 0 + ); + assert!(Arc::ptr_eq(&holder.load_full(), &initial)); + assert!( + first.stopped.try_recv().is_err(), + "rejected candidate must not stop the active set" + ); + } + let (owned, second) = resources(&second_state, &holder); + let transaction = coordinator.begin().await; + let revision = transaction.revision(); + transaction + .publish_resources(&revision, second_state.clone(), owned) + .unwrap(); + first.stopped.recv_timeout(Duration::from_secs(2)).unwrap(); + assert_eq!( + second + .activated + .recv_timeout(Duration::from_secs(2)) + .unwrap(), + 2 + ); + assert_eq!( + coordinator.reap_retired(), + 0, + "unfinished workers retain their slot" + ); + + let third_state = next(&second_state); + let (owned, third) = resources(&third_state, &holder); + let transaction = coordinator.begin().await; + let revision = transaction.revision(); + transaction + .publish_resources(&revision, third_state.clone(), owned) + .unwrap(); + second.stopped.recv_timeout(Duration::from_secs(2)).unwrap(); + assert_eq!( + third + .activated + .recv_timeout(Duration::from_secs(2)) + .unwrap(), + 3 + ); + + let fourth_state = next(&third_state); + let preflight = coordinator.begin().await; + assert!(matches!( + preflight.candidate_view(fourth_state.clone()), + Err(PublishError::RetirementBusy) + )); + drop(preflight); + let (owned, rejected) = resources(&fourth_state, &holder); + let transaction = coordinator.begin().await; + let revision = transaction.revision(); + assert_eq!( + transaction.publish_resources(&revision, fourth_state.clone(), owned), + Err(PublishError::RetirementBusy) + ); + assert_eq!( + rejected + .activated + .recv_timeout(Duration::from_secs(2)) + .unwrap(), + 0 + ); + assert!(Arc::ptr_eq(&holder.load_full(), &third_state)); + + first.release.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + while coordinator.reap_retired() == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + let (owned, fourth) = resources(&fourth_state, &holder); + let transaction = coordinator.begin().await; + let revision = transaction.revision(); + transaction + .publish_resources(&revision, fourth_state.clone(), owned) + .unwrap(); + third.stopped.recv_timeout(Duration::from_secs(2)).unwrap(); + assert_eq!( + fourth + .activated + .recv_timeout(Duration::from_secs(2)) + .unwrap(), + 4 + ); + + coordinator.close(); + fourth.stopped.recv_timeout(Duration::from_secs(2)).unwrap(); + let fifth_state = next(&fourth_state); + let preflight = coordinator.begin().await; + assert!(matches!( + preflight.candidate_view(fifth_state.clone()), + Err(PublishError::Closed) + )); + drop(preflight); + let (owned, rejected) = resources(&fifth_state, &holder); + let transaction = coordinator.begin().await; + let revision = transaction.revision(); + assert_eq!( + transaction.publish_resources(&revision, fifth_state, owned), + Err(PublishError::Closed) + ); + assert_eq!( + rejected + .activated + .recv_timeout(Duration::from_secs(2)) + .unwrap(), + 0 + ); + assert!(Arc::ptr_eq(&holder.load_full(), &fourth_state)); + second.release.send(()).unwrap(); + third.release.send(()).unwrap(); + fourth.release.send(()).unwrap(); + coordinator.finish_shutdown(); + coordinator.finish_shutdown(); // idempotent, with no remaining owners + drop(coordinator); + std::fs::remove_dir_all(root).unwrap(); + } + #[tokio::test] async fn reload_bounds_replaced_named_pool_generations_and_keeps_unchanged() { let root = temp_root("reload-pool"); @@ -1126,6 +1975,49 @@ mod tests { } } + #[tokio::test] + async fn admin_reads_published_generation_without_mutation_or_paths() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let root = temp_root("admin-snapshot"); + let cfg = config(&root, Vec::new(), Vec::new()); + let old = state(cfg.clone()); + let next = ServerState::reload(&old, cfg).unwrap(); + assert_eq!(old.generation, 1); + assert_eq!(next.generation, 2); + assert_eq!(old.config_fingerprint, next.config_fingerprint); + let holder = Arc::new(arc_swap::ArcSwap::from(old)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let task = tokio::spawn(crate::admin::serve(listener, holder.clone())); + for (method, generation) in [("GET", 1), ("POST", 2), ("GET", 2)] { + let mut socket = tokio::net::TcpStream::connect(addr).await.unwrap(); + socket + .write_all( + format!("{method} /v1/status HTTP/1.1\r\nHost: {addr}\r\n\r\n").as_bytes(), + ) + .await + .unwrap(); + let mut response = String::new(); + tokio::time::timeout( + std::time::Duration::from_secs(2), + socket.read_to_string(&mut response), + ) + .await + .unwrap() + .unwrap(); + if method == "GET" { + assert!(response.starts_with("HTTP/1.1 200")); + assert!(response.contains(&format!("\"generation\":{generation}"))); + assert!(!response.contains(root.to_str().unwrap())); + } else { + assert!(response.starts_with("HTTP/1.1 405")); + } + holder.store(next.clone()); + } + task.abort(); + let _ = task.await; + } + #[tokio::test] async fn reload_retains_same_name_endpoints_from_distinct_vhosts() { let root = temp_root("reload-vhosts"); @@ -1139,6 +2031,8 @@ mod tests { let target_a = ProxyTarget::from_ext_processor(&a); let target_b = ProxyTarget::from_ext_processor(&b); let old = state(server.clone()); + let target_a = target_a.in_scope("vhost:one"); + let target_b = target_b.in_scope("vhost:two"); let upstream_a = pooled(&old, &target_a); let upstream_b = pooled(&old, &target_b); let next = ServerState::reload(&old, server).unwrap(); diff --git a/crates/httpjet/src/tcp_candidate.rs b/crates/httpjet/src/tcp_candidate.rs new file mode 100644 index 0000000..4593610 --- /dev/null +++ b/crates/httpjet/src/tcp_candidate.rs @@ -0,0 +1,220 @@ +//! Candidate TCP worker acquisition. Publication belongs to the coordinator. +use crate::{ + listener_plan::{AcquiredTcpPlan, TcpPlan}, + serving_generation::ServingView, + uring, +}; +use std::sync::Arc; + +/// Already-prepared TLS policy. Certificate-manager attachment must happen +/// before these configs are shared with candidate workers. +pub(crate) struct TcpTlsPolicy { + candidate: Arc, + listener: crate::uring::worker_group::TcpListenerId, + config: Arc, + quic: Option>, + require_client_cert: bool, + ktls: Option>, + certificates: hj_tls::CertReloadHandle, + #[cfg(feature = "ocsp")] + ocsp: Option>, + #[cfg(feature = "ocsp")] + ocsp_policy: Option, +} + +impl TcpTlsPolicy { + pub(crate) fn prepare( + candidate: Arc, + identity: crate::uring::worker_group::TcpListenerId, + bootstrap: bool, + ktls: bool, + ) -> anyhow::Result { + let mut matches = candidate + .server + .listeners + .iter() + .filter(|l| l.secure && l.name == identity.name.as_ref()); + let listener = matches + .next() + .ok_or_else(|| anyhow::anyhow!("candidate TLS listener is missing"))?; + if !identity.tls || matches.next().is_some() { + anyhow::bail!("candidate TLS listener identity is ambiguous"); + } + let require_client_cert = listener.tls.as_ref().is_some_and(|t| t.client_verify == 2); + let bundle = hj_tls::PreparedListenerTls::prepare( + &candidate.server, + listener, + bootstrap, + candidate.server.quic_enable, + ktls, + )?; + Ok(Self { + candidate, + listener: identity, + config: bundle.tcp, + quic: bundle.quic, + require_client_cert, + ktls: bundle.ktls.map(Arc::new), + certificates: bundle.certificates, + #[cfg(feature = "ocsp")] + ocsp: None, + #[cfg(feature = "ocsp")] + ocsp_policy: None, + }) + } + + #[cfg(feature = "ocsp")] + pub(crate) fn with_ocsp( + mut self, + args: &crate::ocsp_runtime::OcspArgs, + http: std::net::SocketAddr, + https: std::net::SocketAddr, + ) -> anyhow::Result { + let mut tcp = Some(self.config); + let mut quic = self.quic; + self.ocsp = crate::ocsp_runtime::prepare( + args, + http, + Some(https), + &mut tcp, + &mut self.ktls, + &mut quic, + std::iter::once(self.certificates.clone()), + )?; + self.config = tcp.expect("OCSP preparation retains TCP config"); + self.quic = quic; + self.ocsp_policy = Some(args.clone()); + Ok(self) + } + + #[cfg(feature = "ocsp")] + pub(crate) fn matches_ocsp(&self, expected: Option<&crate::ocsp_runtime::OcspArgs>) -> bool { + match expected.filter(|p| p.enabled()) { + Some(expected) => self.ocsp.is_some() && self.ocsp_policy.as_ref() == Some(expected), + None => self.ocsp.is_none(), + } + } + + #[allow(dead_code)] // Resource-owned certificate-manager attachment follows. + pub(crate) fn certificate_handle(&self) -> hj_tls::CertReloadHandle { + self.certificates.clone() + } +} + +/// Returns only the TCP portion of the candidate. Callers must still acquire +/// all non-TCP resources and validate the complete generation before publishing. +pub(crate) fn prepare( + acquired: AcquiredTcpPlan, + plan: TcpPlan<'_>, + uds: Option<( + crate::listener_plan::UdsLaunchPolicy, + crate::uring::worker_group::PreparedUdsHandoff, + )>, + view: ServingView, + tls: Option, + admission: uring::bridge::BridgeAdmission, +) -> anyhow::Result { + let epoch = view.trust_epoch(); + let certificates = tls + .as_ref() + .map(|policy| (policy.listener.clone(), policy.certificate_handle())); + #[cfg(feature = "ocsp")] + let shutdown = view.load_full().shutdown.clone(); + #[cfg(feature = "ocsp")] + let ocsp = tls.as_ref().and_then(|policy| policy.ocsp.clone()); + if plan.https.is_some() != acquired.https.is_some() || plan.https.is_some() != tls.is_some() { + anyhow::bail!("candidate TCP/TLS resources do not match the plan"); + } + if let (Some(target), Some(policy)) = (&plan.https, &tls) { + if !Arc::ptr_eq(&policy.candidate, &view.load_full()) || target.identity != policy.listener + { + anyhow::bail!("TLS policy belongs to a different candidate or listener"); + } + } + let quic_policy = match tls.as_ref() { + Some(policy) => policy + .quic + .clone() + .map(|config| { + uring::h3::PreparedQuicPolicy::prepare( + view.clone(), + config, + policy.require_client_cert, + ) + }) + .transpose()?, + None => None, + }; + if quic_policy.is_some() != view.load_full().server.quic_enable { + anyhow::bail!("candidate QUIC policy does not match configured topology"); + } + if acquired.http.listeners.is_empty() + || acquired + .https + .as_ref() + .is_some_and(|h| h.listeners.is_empty()) + { + // An empty inherited list would trigger the factory's self-bind path. + anyhow::bail!("candidate TCP handoff has no owned descriptors"); + } + let mut groups = Vec::with_capacity(2 + usize::from(uds.is_some())); + let uds_listener_name = plan.http.identity.name.clone(); + let http = uring::spawn_uring_http( + view.clone(), + plan.http.identity.name, + plan.http.address, + acquired.http.listeners.len(), + Some(acquired.http.listeners), + admission.clone(), + uring::ListenerBinding { + proxy_protocol: plan.http.listener.is_some_and(|l| l.proxy_protocol), + }, + )?; + http.follow_acceptors(acquired.http.predecessor)?; + groups.push(http); + if let (Some(target), Some(acquired), Some(tls)) = (plan.https, acquired.https, tls) { + let https = uring::spawn_uring_https( + view.clone(), + target.identity.name, + target.address, + acquired.listeners.len(), + tls.config, + tls.require_client_cert, + tls.ktls, + Some(acquired.listeners), + admission.clone(), + uring::ListenerBinding { + proxy_protocol: target.listener.is_some_and(|l| l.proxy_protocol), + }, + )?; + https.follow_acceptors(acquired.predecessor)?; + groups.push(https); + } + if let Some((policy, prepared)) = uds { + groups.push(uring::spawn_uring_uds( + view, + uds_listener_name, + policy.path, + Some(uring::UdsListenerInput::Handoff(prepared)), + admission, + )?); + } + let resources = crate::resource_generation::TransportResources::new(epoch, groups)?; + let resources = if let Some(policy) = quic_policy { + resources.with_quic_policy(policy)? + } else { + resources + }; + let resources = if let Some((identity, handle)) = certificates { + resources.with_certificate(identity, handle)? + } else { + resources + }; + #[cfg(feature = "ocsp")] + let resources = if let Some(manager) = ocsp { + resources.with_ocsp(manager, &shutdown)? + } else { + resources + }; + Ok(resources) +} diff --git a/crates/httpjet/src/uring/bridge.rs b/crates/httpjet/src/uring/bridge.rs index fe7e613..73dda50 100644 --- a/crates/httpjet/src/uring/bridge.rs +++ b/crates/httpjet/src/uring/bridge.rs @@ -32,6 +32,10 @@ pub(crate) struct BridgeCtx { pub local: std::net::SocketAddr, pub proto: Proto, pub is_tls: bool, + /// This connection's H1 writer can consume a pinned `Body::File` directly. + /// True only for plain TCP H1 and successfully upgraded TLS 1.3 kTLS H1; + /// userspace TLS, H2/H3, and UDS must stream through their existing paths. + pub direct_file_egress: bool, /// (Tier 2) UDS connection: peer/local are loopback fabrications. pub peer_unix: bool, /// clientVerify=2 in effect for this listener (app-layer mTLS enforced at accept; @@ -48,13 +52,13 @@ pub(crate) struct BridgeCtx { impl BridgeCtx { /// A unix-domain-socket context: peer/local are fabricated loopback, the /// filesystem mode is the real access boundary. - #[cfg(test)] pub(crate) fn unix(local: std::net::SocketAddr, proto: Proto) -> Self { BridgeCtx { peer: std::net::SocketAddr::from(([127, 0, 0, 1], 0)), local, proto, is_tls: false, + direct_file_egress: false, peer_unix: true, mtls_required: false, sni: None, @@ -73,6 +77,7 @@ impl BridgeCtx { local, proto, is_tls: false, + direct_file_egress: proto == Proto::Http1, peer_unix: false, mtls_required: false, sni: None, @@ -86,6 +91,9 @@ impl BridgeCtx { /// drains incrementally (large files, large renders, SSE). pub(crate) enum BridgeBody { Full(Bytes), + /// A pinned file/range retained across the runtime boundary. Produced only + /// when the H1 writer can use `sendfile(2)` (plain TCP or TLS 1.3 kTLS). + File(hj_core::FileBody), /// Incremental chunks from the tokio forwarder. `len` is `Some` when the total length /// is known up front (a `Body::File`) so H1 can emit `Content-Length`; `None` ⇒ H1 /// frames it `Transfer-Encoding: chunked`. An `Err(())` item signals a mid-stream @@ -99,6 +107,7 @@ pub(crate) enum BridgeBody { /// A response handed back across the runtime boundary. pub(crate) struct BridgeResp { + pub completion: Option, pub status: http::StatusCode, pub headers: http::HeaderMap, pub body: BridgeBody, @@ -390,11 +399,15 @@ impl Bridge { pub(crate) fn bridge_resp_to_response(r: BridgeResp) -> Response { let body = match r.body { BridgeBody::Full(b) => Body::Full(b), + BridgeBody::File(f) => Body::File(f), BridgeBody::Stream { rx, .. } => Body::Stream(ChannelBody { rx }.boxed()), }; let mut resp = http::Response::new(body); *resp.status_mut() = r.status; *resp.headers_mut() = r.headers; + if let Some(completion) = r.completion { + resp.extensions_mut().insert(completion); + } resp } @@ -464,8 +477,9 @@ where biased; _ = cancel.cancelled() => {} _ = async move { + let direct_file = ctx.direct_file_egress; let response = handler(req, ctx).await; - forward_response(response, resp).await; + forward_response(response, resp, direct_file).await; } => {} } drop(permit); @@ -482,6 +496,7 @@ where /// mid-stream error instead aborts the response (see `BridgeBody::Stream`). pub(crate) fn bad_gateway() -> BridgeResp { BridgeResp { + completion: None, status: http::StatusCode::BAD_GATEWAY, headers: http::HeaderMap::new(), body: BridgeBody::Full(Bytes::from_static(b"upstream body truncated\n")), @@ -493,6 +508,7 @@ pub(crate) fn bad_gateway() -> BridgeResp { /// server capacity, not a client error. pub(crate) fn service_unavailable_resp() -> BridgeResp { BridgeResp { + completion: None, status: http::StatusCode::SERVICE_UNAVAILABLE, headers: http::HeaderMap::new(), body: BridgeBody::Full(Bytes::from_static(b"server busy\n")), @@ -500,10 +516,13 @@ pub(crate) fn service_unavailable_resp() -> BridgeResp { } } -fn full_resp(parts: http::response::Parts, body: Bytes, bw_rate: Option) -> BridgeResp { +fn full_resp(mut parts: http::response::Parts, body: Bytes, bw_rate: Option) -> BridgeResp { + let status = parts.status; + observe_response_head(&mut parts, status); // The Full arms own `parts` outright — move the header map instead of cloning it per // bridged response. BridgeResp { + completion: parts.extensions.remove::(), status: parts.status, headers: parts.headers, body: BridgeBody::Full(body), @@ -511,6 +530,59 @@ fn full_resp(parts: http::response::Parts, body: Bytes, bw_rate: Option) -> } } +/// Convert an on-core fast-path response without discarding a direct-egress H1 +/// file descriptor. Non-file bodies retain the historical fully-buffered fast +/// path; only an uncached `Body::File` eligible for direct egress stays a file. +pub(crate) async fn fast_response(r: Response, direct_file: bool) -> BridgeResp { + let (mut parts, body) = r.into_parts(); + let bw_rate = parts + .extensions + .remove::() + .map(|b| b.0); + match body { + Body::Empty => full_resp(parts, Bytes::new(), bw_rate), + Body::Full(bytes) => full_resp(parts, bytes, bw_rate), + Body::File(file) if file.cached.is_some() => { + full_resp(parts, file.cached_ranged().unwrap_or_default(), bw_rate) + } + Body::File(file) if direct_file => { + let status = parts.status; + observe_response_head(&mut parts, status); + BridgeResp { + completion: parts.extensions.remove::(), + status: parts.status, + headers: strip_framing(parts.headers), + body: BridgeBody::File(file), + bw_rate, + } + } + body => { + let (bytes, truncated) = buffer_body(body).await; + if truncated { + bad_gateway_for(parts) + } else { + full_resp(parts, bytes, bw_rate) + } + } + } +} + +fn bad_gateway_for(mut parts: http::response::Parts) -> BridgeResp { + observe_response_head(&mut parts, http::StatusCode::BAD_GATEWAY); + let mut response = bad_gateway(); + response.completion = parts.extensions.remove::(); + response +} + +fn observe_response_head(parts: &mut http::response::Parts, status: http::StatusCode) { + #[cfg(feature = "otel")] + if let Some(head) = parts.extensions.remove::() { + head.record(status); + } + #[cfg(not(feature = "otel"))] + let _ = (parts, status); +} + /// Hop-by-hop framing headers the bridge re-derives when it streams a response (it picks /// `Content-Length` vs `Transfer-Encoding: chunked` itself / the h2 framer reframes). fn strip_framing(mut h: http::HeaderMap) -> http::HeaderMap { @@ -535,7 +607,7 @@ fn is_event_stream(h: &http::HeaderMap) -> bool { /// Classify the handler's `Body` and hand it back across the bridge. Small / in-memory /// bodies stay `Full` (byte-identical to the pre-streaming path, zero extra copy); large /// files and large/SSE streams are forwarded incrementally. -async fn forward_response(r: Response, resp: oneshot::Sender) { +async fn forward_response(r: Response, resp: oneshot::Sender, direct_file: bool) { let (mut parts, body) = r.into_parts(); let bw_rate = parts .extensions @@ -558,6 +630,17 @@ async fn forward_response(r: Response, resp: oneshot::Sender) { bw_rate, )); } + Body::File(f) if direct_file => { + let status = parts.status; + observe_response_head(&mut parts, status); + let _ = resp.send(BridgeResp { + completion: parts.extensions.remove::(), + status: parts.status, + headers: strip_framing(parts.headers), + body: BridgeBody::File(f), + bw_rate, + }); + } Body::File(f) => forward_file(parts, f, resp, bw_rate).await, Body::Stream(s) => forward_stream(parts, s, resp, bw_rate).await, } @@ -567,7 +650,7 @@ async fn forward_response(r: Response, resp: oneshot::Sender) { /// files, so one reaching the bridge is large or ranged). `Content-Length` is known, so H1 /// emits it and writes raw (resumable downloads); a mid-read error aborts. async fn forward_file( - parts: http::response::Parts, + mut parts: http::response::Parts, mut f: hj_core::FileBody, resp: oneshot::Sender, bw_rate: Option, @@ -583,18 +666,21 @@ async fn forward_file( None => match tokio::fs::File::open(&f.path).await { Ok(file) => file, Err(_) => { - let _ = resp.send(bad_gateway()); + let _ = resp.send(bad_gateway_for(parts)); return; } }, }; if (pinned || start > 0) && file.seek(std::io::SeekFrom::Start(start)).await.is_err() { - let _ = resp.send(bad_gateway()); + let _ = resp.send(bad_gateway_for(parts)); return; } + let status = parts.status; + observe_response_head(&mut parts, status); let headers = strip_framing(parts.headers); let (tx, rrx) = mpsc::channel(STREAM_CHANNEL_DEPTH); let _ = resp.send(BridgeResp { + completion: parts.extensions.remove::(), status: parts.status, headers, body: BridgeBody::Stream { @@ -651,16 +737,19 @@ async fn forward_file( /// switches to incremental delivery past it. An error BEFORE the switch is a clean 502; /// after it, the stream is aborted. async fn forward_stream( - parts: http::response::Parts, + mut parts: http::response::Parts, mut s: hj_core::StreamBody, resp: oneshot::Sender, bw_rate: Option, ) { use http_body_util::BodyExt; if is_event_stream(&parts.headers) { + let status = parts.status; + observe_response_head(&mut parts, status); let headers = strip_framing(parts.headers); let (tx, rrx) = mpsc::channel(STREAM_CHANNEL_DEPTH); let _ = resp.send(BridgeResp { + completion: parts.extensions.remove::(), status: parts.status, headers, body: BridgeBody::Stream { rx: rrx, len: None }, @@ -676,9 +765,12 @@ async fn forward_stream( if let Some(d) = frame.data_ref() { acc.extend_from_slice(d); if acc.len() > STREAM_THRESHOLD { + let status = parts.status; + observe_response_head(&mut parts, status); let headers = strip_framing(parts.headers); let (tx, rrx) = mpsc::channel(STREAM_CHANNEL_DEPTH); let _ = resp.send(BridgeResp { + completion: parts.extensions.remove::(), status: parts.status, headers, body: BridgeBody::Stream { rx: rrx, len: None }, @@ -693,11 +785,14 @@ async fn forward_stream( } } Some(Err(_)) => { - let _ = resp.send(bad_gateway()); + let _ = resp.send(bad_gateway_for(parts)); return; } None => { + let status = parts.status; + observe_response_head(&mut parts, status); let _ = resp.send(BridgeResp { + completion: parts.extensions.remove::(), status: parts.status, headers: parts.headers, body: BridgeBody::Full(Bytes::from(acc)), @@ -1223,6 +1318,7 @@ mod tests { local: "127.0.0.1:80".parse().unwrap(), proto: Proto::Http1, is_tls: false, + direct_file_egress: true, peer_unix: false, mtls_required: false, sni: None, @@ -1233,6 +1329,7 @@ mod tests { match resp.body { BridgeBody::Full(b) => assert_eq!(&b[..], b"bridged /hello"), BridgeBody::Stream { .. } => panic!("small response must stay Full"), + BridgeBody::File(_) => panic!("small response must not become File"), } }); } @@ -1479,10 +1576,97 @@ mod tests { async fn run_forward(r: Response) -> BridgeResp { let (tx, rx) = oneshot::channel(); - forward_response(r, tx).await; + forward_response(r, tx, false).await; rx.await.unwrap() } + async fn run_forward_direct(r: Response) -> BridgeResp { + let (tx, rx) = oneshot::channel(); + forward_response(r, tx, true).await; + rx.await.unwrap() + } + + #[tokio::test] + async fn direct_plain_h1_forwarding_preserves_pinned_file_and_range() { + let path = std::env::temp_dir().join(format!( + "httpjet-bridge-direct-file-{}.bin", + std::process::id() + )); + std::fs::write(&path, b"0123456789").unwrap(); + let response = http::Response::new(Body::File(hj_core::FileBody { + path: path.clone(), + file: Some(std::fs::File::open(&path).unwrap()), + len: 10, + range: Some((2, 7)), + cached: None, + })); + match run_forward_direct(response).await.body { + BridgeBody::File(file) => { + assert!(file.file.is_some(), "the selected inode must stay pinned"); + assert_eq!(file.range, Some((2, 7))); + } + _ => panic!("eligible plaintext H1 file must remain a file"), + } + let _ = std::fs::remove_file(path); + } + + #[tokio::test] + async fn forwarding_preserves_completion_for_buffered_streamed_and_error_responses() { + for (mut response, status) in [ + ( + http::Response::new(Body::Full(Bytes::from_static(b"full"))), + 200, + ), + ( + stream_resp(vec![Bytes::from_static(b"small")], false, None), + 200, + ), + ( + stream_resp( + vec![Bytes::from_static(b"event")], + false, + Some("text/event-stream"), + ), + 200, + ), + (stream_resp(vec![], true, None), 502), + ( + http::Response::new(Body::File(hj_core::FileBody { + path: "/nonexistent-httpjet-test/path".into(), + file: None, + len: 4, + range: None, + cached: None, + })), + 502, + ), + ] { + let events = Arc::new(std::sync::Mutex::new(Vec::new())); + let copy = events.clone(); + response + .extensions_mut() + .insert(hj_core::ResponseCompletion::new(move |end| { + copy.lock().unwrap().push(end) + })); + let mut forwarded = run_forward(response).await; + assert_eq!(forwarded.status.as_u16(), status); + assert!( + events.lock().unwrap().is_empty(), + "bridge production is not transport completion" + ); + forwarded + .completion + .take() + .unwrap() + .finish(hj_core::ResponseEnd::Complete); + drop(forwarded); + assert_eq!( + *events.lock().unwrap(), + vec![hj_core::ResponseEnd::Complete] + ); + } + } + async fn drain(mut rx: mpsc::Receiver>) -> Result, ()> { let mut out = Vec::new(); while let Some(item) = rx.recv().await { @@ -1519,6 +1703,7 @@ mod tests { match run_forward(r).await.body { BridgeBody::Full(b) => assert_eq!(&b[..], b"hello world"), BridgeBody::Stream { .. } => panic!("sub-threshold stream must buffer to Full"), + BridgeBody::File(_) => panic!("stream must not become File"), } } @@ -1542,6 +1727,7 @@ mod tests { assert_eq!(&got[big.len()..], &tail[..]); } BridgeBody::Full(_) => panic!("over-threshold stream must switch to Stream"), + BridgeBody::File(_) => panic!("stream must not become File"), } // Framing headers are stripped on the streamed path. assert!(resp.headers.get(http::header::CONTENT_LENGTH).is_none()); @@ -1560,6 +1746,7 @@ mod tests { assert_eq!(drain(rx).await.unwrap(), b"data: 1\n\n"); } BridgeBody::Full(_) => panic!("SSE must stream immediately, not buffer"), + BridgeBody::File(_) => panic!("SSE must not become File"), } } @@ -1571,6 +1758,7 @@ mod tests { match resp.body { BridgeBody::Full(b) => assert_eq!(&b[..], b"upstream body truncated\n"), BridgeBody::Stream { .. } => panic!("pre-header error must be a buffered 502"), + BridgeBody::File(_) => panic!("stream error must not become File"), } } @@ -1586,6 +1774,7 @@ mod tests { ); } BridgeBody::Full(_) => panic!("over-threshold stream must switch to Stream"), + BridgeBody::File(_) => panic!("stream must not become File"), } } @@ -1622,6 +1811,7 @@ mod tests { ); } BridgeBody::Full(_) => panic!("an uncached Body::File must stream via forward_file"), + BridgeBody::File(_) => panic!("non-direct forwarding must stream files"), } let _ = std::fs::remove_file(&path); } @@ -1650,6 +1840,7 @@ mod tests { assert_eq!(drain(rx).await.expect("clean stream"), data); } BridgeBody::Full(_) => panic!("an uncached Body::File must stream via forward_file"), + BridgeBody::File(_) => panic!("non-direct forwarding must stream files"), } let _ = std::fs::remove_file(&path); } diff --git a/crates/httpjet/src/uring/generation_test.rs b/crates/httpjet/src/uring/generation_test.rs new file mode 100644 index 0000000..49c85af --- /dev/null +++ b/crates/httpjet/src/uring/generation_test.rs @@ -0,0 +1,1228 @@ +use super::*; + +struct FixtureTls { + server: Arc, + certificate: rustls::pki_types::CertificateDer<'static>, + client: Option<(rustls::pki_types::CertificateDer<'static>, Vec)>, +} + +impl FixtureTls { + fn new(require_client: bool) -> Self { + hj_tls::install_crypto_provider().unwrap(); + let signed = rcgen::generate_simple_self_signed(vec!["canon.test".into()]).unwrap(); + let certificate = signed.cert.der().clone(); + let key = + rustls::pki_types::PrivateKeyDer::try_from(signed.signing_key.serialize_der()).unwrap(); + let client = require_client.then(|| { + let signed = rcgen::generate_simple_self_signed(vec!["client.test".into()]).unwrap(); + ( + signed.cert.der().clone(), + signed.signing_key.serialize_der(), + ) + }); + let builder = rustls::ServerConfig::builder(); + let builder = if let Some((cert, _)) = &client { + let mut roots = rustls::RootCertStore::empty(); + roots.add(cert.clone()).unwrap(); + builder.with_client_cert_verifier( + rustls::server::WebPkiClientVerifier::builder(Arc::new(roots)) + .build() + .unwrap(), + ) + } else { + builder.with_no_client_auth() + }; + let mut server = builder + .with_single_cert(vec![certificate.clone()], key) + .unwrap(); + server.alpn_protocols = vec![b"http/1.1".to_vec()]; + Self { + server: Arc::new(server), + certificate, + client, + } + } +} + +enum FixtureStream { + Plain(std::net::TcpStream), + Tls(Box>), +} + +impl std::io::Read for FixtureStream { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + match self { + Self::Plain(stream) => stream.read(buffer), + Self::Tls(stream) => stream.read(buffer), + } + } +} +impl std::io::Write for FixtureStream { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + match self { + Self::Plain(stream) => stream.write(buffer), + Self::Tls(stream) => stream.write(buffer), + } + } + fn flush(&mut self) -> std::io::Result<()> { + match self { + Self::Plain(stream) => stream.flush(), + Self::Tls(stream) => stream.flush(), + } + } +} + +fn fixture_connect(address: SocketAddr, tls: Option<&FixtureTls>) -> FixtureStream { + try_fixture_connect(address, tls).unwrap() +} + +fn try_fixture_connect( + address: SocketAddr, + tls: Option<&FixtureTls>, +) -> std::io::Result { + let mut socket = std::net::TcpStream::connect(address)?; + socket + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + socket + .set_write_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let Some(tls) = tls else { + return Ok(FixtureStream::Plain(socket)); + }; + // Trust only the expected certificate, never bypass certificate validation. + let mut roots = rustls::RootCertStore::empty(); + roots.add(tls.certificate.clone()).unwrap(); + let builder = rustls::ClientConfig::builder().with_root_certificates(roots); + let mut config = if let Some((cert, key)) = &tls.client { + builder + .with_client_auth_cert( + vec![cert.clone()], + rustls::pki_types::PrivateKeyDer::try_from(key.clone()).unwrap(), + ) + .unwrap() + } else { + builder.with_no_client_auth() + }; + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + let mut connection = + rustls::ClientConnection::new(Arc::new(config), "canon.test".try_into().unwrap()).unwrap(); + while connection.is_handshaking() { + connection.complete_io(&mut socket)?; + } + assert_eq!(connection.peer_certificates().unwrap()[0], tls.certificate); + Ok(FixtureStream::Tls(Box::new(rustls::StreamOwned::new( + connection, socket, + )))) +} + +fn request_over_fixture(address: SocketAddr, tls: Option<&FixtureTls>) -> Vec { + use std::io::{Read, Write}; + let mut client = fixture_connect(address, tls); + client + .write_all(b"GET /index.html HTTP/1.1\r\nHost: canon.test\r\nConnection: close\r\n\r\n") + .unwrap(); + let mut bytes = Vec::new(); + client.read_to_end(&mut bytes).unwrap(); + bytes +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn distinct_http_candidate_publishes_then_drains_old_resource_generation() { + http_candidate_replacement(false, false, false, false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn same_address_http_candidate_preserves_accept_queue() { + http_candidate_replacement(true, false, false, false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn same_address_tls_candidate_rotates_certificate_and_drains_old_response() { + http_candidate_replacement(true, true, false, false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn same_address_tls_candidate_requires_new_client_auth_while_old_response_drains() { + http_candidate_replacement(true, true, true, false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn same_address_single_shot_handoff() { + const CHILD: &str = "HTTPJET_TEST_SINGLE_HANDOFF"; + if std::env::var_os(CHILD).is_none() { + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "uring::generation_test::same_address_single_shot_handoff", + "--nocapture", + ]) + .env(CHILD, "1") + .status() + .unwrap(); + assert!(status.success()); + return; + } + // Process isolation keeps this global runtime option from racing other tests. + MULTISHOT_ACCEPT.store(false, std::sync::atomic::Ordering::Relaxed); + http_candidate_replacement(true, false, false, false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn renamed_http_listener_hands_off_old_identity_and_drains_old_requests() { + http_candidate_replacement(true, false, false, true).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn planned_tcp_acquisition_owns_both_endpoints_and_rolls_back_together() { + hj_tls::install_crypto_provider().unwrap(); + use crate::config_transaction::Coordinator; + use crate::resource_generation::TransportResources; + let root = std::env::temp_dir().join(format!( + "hj-tcp-plan-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&root).unwrap(); + let state = crate::pipeline::e2e::build_state(root.clone()); + let server_root = state.server.server_root.clone(); + let http = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let https = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + http.set_nonblocking(true).unwrap(); + https.set_nonblocking(true).unwrap(); + let http_addr = http.local_addr().unwrap(); + let https_addr = https.local_addr().unwrap(); + let mut config = (*state.server).clone(); + let mut secure = config.listeners[0].clone(); + secure.name = "tls".into(); + secure.secure = true; + let mut ca_params = rcgen::CertificateParams::new(Vec::::new()).unwrap(); + ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + let ca = rcgen::CertifiedIssuer::self_signed(ca_params, rcgen::KeyPair::generate().unwrap()) + .unwrap(); + let signing_key = rcgen::KeyPair::generate().unwrap(); + let cert = rcgen::CertificateParams::new(vec!["canon.test".into()]) + .unwrap() + .signed_by(&signing_key, &ca) + .unwrap(); + let signed = rcgen::CertifiedKey { cert, signing_key }; + let cert_file = root.join("candidate-cert.pem"); + let key_file = root.join("candidate-key.pem"); + std::fs::write(&cert_file, format!("{}{}", signed.cert.pem(), ca.pem())).unwrap(); + std::fs::write(&key_file, signed.signing_key.serialize_pem()).unwrap(); + secure.tls = Some(hj_core::config::ListenerTls { + cert_file, + key_file, + cert_chain: true, + ca_cert_file: None, + client_verify: 0, + verify_depth: 1, + enable_stapling: false, + crl_file: None, + }); + config.listeners.push(secure); + let state = ServerState::reload(&state, Arc::new(config.clone())).unwrap(); + verify_certificate_owner_replacement(state.clone()).await; + let holder = Arc::new(arc_swap::ArcSwap::from(state.clone())); + let coordinator = Coordinator::new(holder.clone()) + .unwrap() + .with_tcp_launch_policy(crate::listener_plan::TcpLaunchPolicy { + http: http_addr, + https: Some(https_addr), + }); + #[cfg(feature = "ocsp")] + let responder = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + #[cfg(feature = "ocsp")] + let coordinator = { + use clap::Parser; + responder.set_nonblocking(true).unwrap(); + let endpoint = format!("http://{}/", responder.local_addr().unwrap()); + let args = crate::ServeArgs::try_parse_from([ + "httpjet", + "--ocsp-responder", + &endpoint, + "--ocsp-required", + "--ocsp-test-mode", + ]) + .unwrap(); + coordinator.with_ocsp_policy(args.ocsp) + }; + let mut groups = Vec::new(); + let mut owners = Vec::new(); + for (socket, name, tls) in [(&https, "tls", true), (&http, "http", false)] { + let mut group = WorkerGroup::for_tcp_epoch( + &state.shutdown, + state.trust_epoch.clone(), + worker_group::TcpListenerId { + name: name.into(), + tls, + }, + ); + owners.push(group.register_acceptor(socket).unwrap()); + groups.push(group); + } + coordinator + .install_initial_resources( + TransportResources::new(state.trust_epoch.clone(), groups).unwrap(), + ) + .unwrap(); + let transaction = coordinator.begin().await; + let acquired = transaction.acquire_tcp_plan(&config).unwrap(); + assert_eq!(acquired.http.listeners[0].local_addr().unwrap(), http_addr); + assert_eq!( + acquired.https.as_ref().unwrap().listeners[0] + .local_addr() + .unwrap(), + https_addr + ); + drop(acquired); + // A discarded complete candidate neither steals nor cancels the sources. + drop(transaction.acquire_tcp_plan(&config).unwrap()); + let mut next = ServerState::reload(&state, Arc::new(config.clone())).unwrap(); + Arc::get_mut(&mut next).unwrap().trust_epoch = Arc::new(()); + let policy = || { + transaction + .prepare_tcp_tls(next.clone(), false, false) + .unwrap() + .unwrap() + }; + #[cfg(feature = "ocsp")] + { + use clap::Parser; + let bare = || { + crate::tcp_candidate::TcpTlsPolicy::prepare( + next.clone(), + worker_group::TcpListenerId { + name: "tls".into(), + tls: true, + }, + false, + false, + ) + .unwrap() + }; + assert!( + transaction + .prepare_tcp_workers( + next.clone(), + Some(bare()), + pipeline_admission(holder.clone()) + ) + .is_err() + ); + let endpoint = format!("http://{}/", responder.local_addr().unwrap()); + let weaker = crate::ServeArgs::try_parse_from([ + "httpjet", + "--ocsp-responder", + &endpoint, + "--ocsp-test-mode", + ]) + .unwrap(); + let weaker = bare() + .with_ocsp(&weaker.ocsp, http_addr, https_addr) + .unwrap(); + assert!( + transaction + .prepare_tcp_workers( + next.clone(), + Some(weaker), + pipeline_admission(holder.clone()) + ) + .is_err() + ); + } + // A different candidate with the same generation and trust epoch is still + // not the snapshot whose certificates/policy were prepared. + let mut other = ServerState::reload(&state, Arc::new(config.clone())).unwrap(); + Arc::get_mut(&mut other).unwrap().trust_epoch = next.trust_epoch.clone(); + let wrong_snapshot = transaction.prepare_tcp_tls(other, false, false).unwrap(); + assert!( + transaction + .prepare_tcp_workers( + next.clone(), + wrong_snapshot, + pipeline_admission(holder.clone()) + ) + .is_err() + ); + let key_path = &config.listeners[1].tls.as_ref().unwrap().key_file; + std::fs::write(key_path, "invalid candidate key").unwrap(); + assert!( + transaction + .prepare_tcp_tls(next.clone(), false, false) + .is_err() + ); + std::fs::write(key_path, signed.signing_key.serialize_pem()).unwrap(); + assert!( + transaction + .prepare_tcp_workers(next.clone(), None, pipeline_admission(holder.clone())) + .is_err() + ); + let prepared = transaction + .prepare_tcp_workers( + next.clone(), + Some(policy()), + pipeline_admission(holder.clone()), + ) + .unwrap(); + assert_eq!(prepared.group_count(), 2); + #[cfg(feature = "ocsp")] + assert!(prepared.has_ocsp_refresh()); + drop(prepared); + // Inject a wrong predecessor after valid acquisition. HTTPS fails after + // HTTP is already prepared; both candidate groups must stop and join. + let mut wrong = transaction.acquire_tcp_plan(&config).unwrap(); + wrong.https.as_mut().unwrap().predecessor = wrong.http.predecessor.clone(); + assert!( + crate::tcp_candidate::prepare( + wrong, + transaction.tcp_plan(&next.server).unwrap(), + None, + transaction.candidate_view(next.clone()).unwrap(), + Some(policy()), + pipeline_admission(holder.clone()) + ) + .is_err() + ); + // Retire only the TLS descriptor owner. HTTP acquisition succeeds first, + // then the missing TLS source rejects and drops the partial candidate. + owners[0].cancel(); + assert!(matches!( + transaction.acquire_tcp_plan(&config), + Err(crate::config_transaction::PublishError::ResourceRequired) + )); + drop( + transaction + .tcp_handoff( + &worker_group::TcpListenerId { + name: "http".into(), + tls: false, + }, + http_addr, + ) + .unwrap(), + ); + drop(transaction); + #[cfg(feature = "ocsp")] + { + tokio::task::yield_now().await; + assert!( + matches!(responder.accept(), Err(e) if e.kind() == std::io::ErrorKind::WouldBlock), + "inactive candidates must not contact the OCSP responder" + ); + } + coordinator.finish_shutdown(); + for owner in &mut owners { + owner.cancel(); + } + drop((owners, http, https)); + // No accepted streams here: address reuse proves rollback released all fds. + drop(std::net::TcpListener::bind(http_addr).unwrap()); + drop(std::net::TcpListener::bind(https_addr).unwrap()); + state.shutdown.cancel(); + drop(coordinator); + drop(state); + std::fs::remove_dir_all(root).unwrap(); + std::fs::remove_dir_all(server_root).unwrap(); +} + +async fn verify_certificate_owner_replacement(state: Arc) { + use crate::config_transaction::Coordinator; + use crate::resource_generation::TransportResources; + let coordinator = Coordinator::new(Arc::new(arc_swap::ArcSwap::from(state.clone()))).unwrap(); + #[cfg(feature = "acme")] + let acme_targets = crate::acme_runtime::CertificateTargets::default(); + #[cfg(feature = "acme")] + let coordinator = coordinator.with_acme_targets(Some(acme_targets.clone())); + let resources = |state: &Arc| { + let listener = state.server.listeners.iter().find(|l| l.secure).unwrap(); + let identity = worker_group::TcpListenerId { + name: listener.name.clone().into(), + tls: true, + }; + let bundle = + hj_tls::PreparedListenerTls::prepare(&state.server, listener, false, false, false) + .unwrap(); + let group = WorkerGroup::for_tcp_epoch( + &state.shutdown, + state.trust_epoch.clone(), + identity.clone(), + ); + TransportResources::new(state.trust_epoch.clone(), vec![group]) + .unwrap() + .with_certificate(identity, bundle.certificates) + .unwrap() + }; + coordinator + .install_initial_resources(resources(&state)) + .unwrap(); + #[cfg(feature = "acme")] + assert_eq!( + acme_targets.names(), + vec![Arc::::from( + state + .server + .listeners + .iter() + .find(|l| l.secure) + .unwrap() + .name + .clone() + )] + ); + let transaction = coordinator.begin().await; + transaction.reload_certificates(&state.server).unwrap(); + let mut config = (*state.server).clone(); + config.listeners.iter_mut().find(|l| l.secure).unwrap().name = "replacement-tls".into(); + assert!(transaction.reload_certificates(&config).is_err()); + let mut next = ServerState::reload(&state, Arc::new(config)).unwrap(); + Arc::get_mut(&mut next).unwrap().trust_epoch = Arc::new(()); + #[cfg(feature = "acme")] + { + let discarded = resources(&next); + assert_eq!( + acme_targets.names(), + vec![Arc::::from( + state + .server + .listeners + .iter() + .find(|l| l.secure) + .unwrap() + .name + .clone() + )], + "candidate preparation must not redirect ACME renewals" + ); + drop(discarded); + } + let revision = transaction.revision(); + transaction + .publish_resources(&revision, next.clone(), resources(&next)) + .unwrap(); + #[cfg(feature = "acme")] + assert_eq!( + acme_targets.names(), + vec![Arc::::from("replacement-tls")], + "publication must atomically switch ACME renewal targets" + ); + let transaction = coordinator.begin().await; + transaction.reload_certificates(&next.server).unwrap(); + assert!( + transaction.reload_certificates(&state.server).is_err(), + "reload must not select the retired listener's handle" + ); + coordinator.close(); + assert!(transaction.reload_certificates(&next.server).is_err()); + drop(transaction); + coordinator.finish_shutdown(); +} + +async fn http_candidate_replacement( + same_address: bool, + tls: bool, + require_client: bool, + rename: bool, +) { + use crate::config_transaction::Coordinator; + use crate::resource_generation::TransportResources; + use std::io::{BufRead, Read, Write}; + let old_tls = tls.then(|| FixtureTls::new(false)); + let next_tls = tls.then(|| FixtureTls::new(require_client)); + let listener_identity = worker_group::TcpListenerId { + name: "http".into(), + tls, + }; + let root = std::env::temp_dir().join(format!( + "hj-resource-http-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let before = root.join("before"); + let after = root.join("after"); + let updated = root.join("updated"); + std::fs::create_dir_all(&before).unwrap(); + std::fs::create_dir(&after).unwrap(); + std::fs::create_dir(&updated).unwrap(); + let old_body = vec![b'o'; 8 * 1024 * 1024]; + std::fs::write(before.join("index.html"), &old_body).unwrap(); + std::fs::write(after.join("index.html"), b"new resource generation").unwrap(); + std::fs::write(updated.join("index.html"), b"subsequent application reload").unwrap(); + let old = crate::pipeline::e2e::build_state(before); + let server_root = old.server.server_root.clone(); + let holder = Arc::new(arc_swap::ArcSwap::from(old.clone())); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let old_address = listener.local_addr().unwrap(); + let coordinator = Coordinator::new(holder.clone()) + .unwrap() + .with_tcp_launch_policy(crate::listener_plan::TcpLaunchPolicy { + http: old_address, + https: Some(old_address), + }); + let group = if let Some(tls) = &old_tls { + spawn_uring_https( + holder.clone(), + "http".into(), + old_address, + 1, + tls.server.clone(), + false, + None, + Some(vec![listener]), + pipeline_admission(holder.clone()), + ListenerBinding::default(), + ) + } else { + spawn_uring_http( + holder.clone(), + "http".into(), + old_address, + 1, + Some(vec![listener]), + pipeline_admission(holder.clone()), + ListenerBinding::default(), + ) + } + .unwrap(); + coordinator + .install_initial_resources( + TransportResources::new(old.trust_epoch.clone(), vec![group]).unwrap(), + ) + .unwrap(); + + let client = fixture_connect(old_address, old_tls.as_ref()); + let mut old_client = std::io::BufReader::new(client); + old_client + .get_mut() + .write_all(b"GET /index.html HTTP/1.1\r\nHost: canon.test\r\nConnection: close\r\n\r\n") + .unwrap(); + let mut head = String::new(); + loop { + let mut line = String::new(); + assert!(old_client.read_line(&mut line).unwrap() > 0); + head.push_str(&line); + if line == "\r\n" { + break; + } + } + assert!(head.starts_with("HTTP/1.1 200")); + assert!( + head.to_ascii_lowercase() + .contains("content-length: 8388608") + ); + assert!( + old.metrics + .active_conns + .load(std::sync::atomic::Ordering::Relaxed) + > 0 + ); + + let mut config = (*old.server).clone(); + if rename { + config.listeners[0].name = "renamed".into(); + } + let candidate_name: Arc = config.listeners[0].name.clone().into(); + Arc::make_mut( + config + .vhosts + .get_mut("testvh") + .unwrap() + .config + .as_mut() + .unwrap(), + ) + .doc_root = after; + let mut next = ServerState::reload(&old, Arc::new(config)).unwrap(); + Arc::get_mut(&mut next).unwrap().trust_epoch = Arc::new(()); + let transaction = coordinator.begin().await; + let planned = transaction.tcp_plan(&next.server).unwrap(); + assert_eq!(planned.http.address, old_address); + if !tls { + assert_eq!(planned.http.identity.name, candidate_name); + assert!(planned.https.is_none()); + } + let revision = transaction.revision(); + let view = transaction.candidate_view(next.clone()).unwrap(); + assert!(matches!( + transaction.tcp_handoff(&listener_identity, "127.0.0.1:0".parse().unwrap()), + Err(crate::config_transaction::PublishError::ResourceRequired) + )); + if !tls && same_address { + let mut missing_tls = (*next.server).clone(); + let mut secure = missing_tls.listeners[0].clone(); + secure.name = "missing-secure".into(); + secure.secure = true; + missing_tls.listeners.push(secure); + assert!(matches!( + transaction.acquire_tcp_plan(&missing_tls), + Err(crate::config_transaction::PublishError::ResourceRequired) + )); + // An unsupported addition rejects without diverting the active queue. + assert!(request_over_fixture(old_address, None).ends_with(&old_body)); + } + assert!(matches!( + transaction.tcp_handoff( + &worker_group::TcpListenerId { + name: "missing-listener".into(), + tls + }, + old_address + ), + Err(crate::config_transaction::PublishError::ResourceRequired) + )); + assert!( + Arc::ptr_eq(&view.load_full(), &next), + "preparation must use the unpublished candidate" + ); + let (new_address, resources) = if same_address && !tls { + // Exercise coordinator acquisition and worker preparation, including + // rollback of a fully ready but never activated candidate. + drop( + transaction + .prepare_tcp_workers(next.clone(), None, pipeline_admission(holder.clone())) + .unwrap(), + ); + assert!(request_over_fixture(old_address, None).ends_with(&old_body)); + let groups = transaction + .prepare_tcp_workers(next.clone(), None, pipeline_admission(holder.clone())) + .unwrap(); + (old_address, groups) + } else { + let (listener, predecessor) = if same_address { + let mut handoff = if tls { + transaction + .tcp_handoff(&listener_identity, old_address) + .unwrap() + } else { + let acquired = transaction.acquire_tcp_plan(&next.server).unwrap(); + assert!(acquired.https.is_none()); + acquired.http + }; + assert_eq!(handoff.listeners.len(), 1); + (handoff.listeners.remove(0), Some(handoff.predecessor)) + } else { + (std::net::TcpListener::bind("127.0.0.1:0").unwrap(), None) + }; + listener.set_nonblocking(true).unwrap(); + let new_address = listener.local_addr().unwrap(); + let group = if let Some(tls) = &next_tls { + spawn_uring_https( + view, + candidate_name.clone(), + new_address, + 1, + tls.server.clone(), + require_client, + None, + Some(vec![listener]), + pipeline_admission(holder.clone()), + ListenerBinding::default(), + ) + } else { + spawn_uring_http( + view, + candidate_name.clone(), + new_address, + 1, + Some(vec![listener]), + pipeline_admission(holder.clone()), + ListenerBinding::default(), + ) + } + .unwrap(); + if same_address { + assert_eq!(new_address, old_address); + group.follow_acceptors(predecessor.unwrap()).unwrap(); + } + ( + new_address, + TransportResources::new(next.trust_epoch.clone(), vec![group]).unwrap(), + ) + }; + let queued = if same_address { + // Preparing duplicate ownership must not divert traffic from the old + // accept queue into an inactive SO_REUSEPORT group's separate queue. + assert!(request_over_fixture(old_address, old_tls.as_ref()).ends_with(&old_body)); + None + } else { + let mut new_client = std::net::TcpStream::connect(new_address).unwrap(); + new_client + .set_read_timeout(Some(std::time::Duration::from_millis(100))) + .unwrap(); + new_client + .write_all(b"GET /index.html HTTP/1.1\r\nHost: canon.test\r\nConnection: close\r\n\r\n") + .unwrap(); + let mut probe = [0u8; 1]; + let error = new_client + .read(&mut probe) + .expect_err("candidate must not serve before publication"); + assert!(matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + )); + Some(FixtureStream::Plain(new_client)) + }; + assert!(Arc::ptr_eq(&holder.load_full(), &old)); + transaction + .publish_resources(&revision, next.clone(), resources) + .unwrap(); + let mut new_client = queued.unwrap_or_else(|| { + let mut client = fixture_connect(new_address, next_tls.as_ref()); + client + .write_all(b"GET /index.html HTTP/1.1\r\nHost: canon.test\r\nConnection: close\r\n\r\n") + .unwrap(); + client + }); + if let FixtureStream::Plain(client) = &new_client { + client + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + } + let mut new_response = Vec::new(); + new_client.read_to_end(&mut new_response).unwrap(); + assert!(new_response.starts_with(b"HTTP/1.1 200")); + assert!(new_response.ends_with(b"new resource generation")); + if require_client { + let expected = next_tls.as_ref().unwrap(); + let no_identity = FixtureTls { + server: expected.server.clone(), + certificate: expected.certificate.clone(), + client: None, + }; + let refused = (|| -> std::io::Result<()> { + let mut client = try_fixture_connect(new_address, Some(&no_identity))?; + client.write_all( + b"GET /index.html HTTP/1.1\r\nHost: canon.test\r\nConnection: close\r\n\r\n", + )?; + let mut bytes = [0u8; 1]; + // TLS 1.3 can deliver the rejection after the client's handshake + // appears finished; a read must never expose an HTTP response. + let n = client.read(&mut bytes)?; + if n == 0 { + return Err(std::io::Error::other("client authentication rejected")); + } + Ok(()) + })(); + let error = refused.expect_err("new TLS policy must reject a client with no certificate"); + assert!( + matches!( + error + .get_ref() + .and_then(|error| error.downcast_ref::()), + Some(rustls::Error::AlertReceived( + rustls::AlertDescription::CertificateRequired + )) + ), + "expected an explicit client-certificate rejection, not a timeout or transport failure: {error:?}" + ); + } + // Listener retirement must precede response drain. In particular, an armed + // multishot SQE must not keep consuming accepts for the entire drain window. + if !same_address { + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while std::net::TcpStream::connect(old_address).is_ok() { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await + .expect("old acceptor must stop while its large response is still draining"); + } + assert!( + old.metrics + .active_conns + .load(std::sync::atomic::Ordering::Relaxed) + > 0 + ); + let mut drained = Vec::new(); + old_client.read_to_end(&mut drained).unwrap(); + assert_eq!( + drained, old_body, + "old in-flight response must finish under its original generation" + ); + drop(old_client); + drop(new_client); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while coordinator.reap_retired() == 0 { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + if !same_address { + assert!(std::net::TcpStream::connect(old_address).is_err()); + } + + // Candidate views must reference the live holder, not a private holder that + // would stop observing subsequent compatible application reloads. + let mut config = (*next.server).clone(); + Arc::make_mut( + config + .vhosts + .get_mut("testvh") + .unwrap() + .config + .as_mut() + .unwrap(), + ) + .doc_root = updated; + let application = ServerState::reload(&next, Arc::new(config)).unwrap(); + let transaction = coordinator.begin().await; + let revision = transaction.revision(); + transaction.publish(&revision, application).unwrap(); + assert!( + request_over_fixture(new_address, next_tls.as_ref()) + .ends_with(b"subsequent application reload") + ); + coordinator.close(); + let closed = coordinator.begin().await; + assert!(matches!( + closed.tcp_handoff(&listener_identity, old_address), + Err(crate::config_transaction::PublishError::Closed) + )); + drop(closed); + coordinator.finish_shutdown(); + std::fs::remove_dir_all(root).unwrap(); + std::fs::remove_dir_all(server_root).unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn bridge_uses_request_snapshot_across_application_publication() { + let root = std::env::temp_dir().join(format!( + "hj-request-snapshot-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let before = root.join("before"); + let after = root.join("after"); + std::fs::create_dir_all(&before).unwrap(); + std::fs::create_dir(&after).unwrap(); + std::fs::write(before.join("index.html"), b"before publication").unwrap(); + std::fs::write(after.join("index.html"), b"after publication").unwrap(); + let old = crate::pipeline::e2e::build_state(before); + let server_root = old.server.server_root.clone(); + let holder = Arc::new(arc_swap::ArcSwap::from(old.clone())); + let bridge = build_pipeline_bridge( + holder.clone(), + "http".into(), + pipeline_admission(holder.clone()), + ); + let mut config = (*old.server).clone(); + Arc::make_mut( + config + .vhosts + .get_mut("testvh") + .unwrap() + .config + .as_mut() + .unwrap(), + ) + .doc_root = after; + let next = ServerState::reload(&old, Arc::new(config)).unwrap(); + holder.store(next); + let make_request = || { + http::Request::builder() + .uri("/index.html") + .header("host", "canon.test") + .body(hj_core::empty_incoming()) + .unwrap() + }; + let ctx = BridgeCtx::plain( + "127.0.0.1:32123".parse().unwrap(), + "127.0.0.1:8080".parse().unwrap(), + Proto::Http1, + ); + let mut pinned = make_request(); + pinned.extensions_mut().insert(RequestGeneration(old)); + let response = bridge.dispatch_response(pinned, ctx.clone()).await; + assert_eq!(response.status(), http::StatusCode::OK); + let (body, truncated) = bridge::buffer_body(response.into_body()).await; + assert!(!truncated); + assert_eq!(body.as_ref(), b"before publication"); + let response = bridge.dispatch_response(make_request(), ctx).await; + assert_eq!(response.status(), http::StatusCode::OK); + let (body, truncated) = bridge::buffer_body(response.into_body()).await; + assert!(!truncated); + assert_eq!(body.as_ref(), b"after publication"); + drop(bridge); + std::fs::remove_dir_all(root).unwrap(); + std::fs::remove_dir_all(server_root).unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn quic_owner_survives_trust_publication_and_requires_matching_policy() { + let root = std::env::temp_dir().join(format!( + "hj-quic-publication-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + let base = crate::pipeline::e2e::build_state(root.clone()); + let mut config = (*base.server).clone(); + config.quic_enable = true; + let initial = ServerState::reload(&base, Arc::new(config)).unwrap(); + let server_root = initial.server.server_root.clone(); + let holder = Arc::new(arc_swap::ArcSwap::from(initial.clone())); + let bridge = build_pipeline_bridge( + holder.clone(), + "http".into(), + pipeline_admission(holder.clone()), + ); + let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + let address = socket.local_addr().unwrap(); + let mut initial_tls = (*FixtureTls::new(false).server).clone(); + initial_tls.alpn_protocols = vec![b"h3".to_vec()]; + let (group, handle) = h3::serve_h3_pipeline( + address, + 1, + Arc::new(initial_tls), + bridge, + false, + h3::H3RuntimeConfig::new( + || (h3::H3RequestLimits::new(16_384, 1024), 8), + initial.metrics.active_conns.clone(), + initial.body_budget.clone(), + ) + .with_serving_view(crate::serving_generation::ServingView::new(holder.clone())), + Some(vec![socket]), + initial.shutdown.clone(), + ) + .unwrap(); + let quic = + crate::resource_generation::QuicResources::new(group, handle.clone(), &initial.trust_epoch) + .unwrap(); + let coordinator = crate::config_transaction::Coordinator::new(holder.clone()).unwrap(); + coordinator + .install_initial_resources_with_quic( + crate::resource_generation::TransportResources::new( + initial.trust_epoch.clone(), + Vec::new(), + ) + .unwrap(), + Some(quic), + ) + .unwrap(); + + let mut next = ServerState::reload(&initial, initial.server.clone()).unwrap(); + Arc::get_mut(&mut next).unwrap().trust_epoch = Arc::new(()); + let revision = coordinator.revision(); + let rejected = coordinator.begin().await.publish_resources( + &revision, + next.clone(), + crate::resource_generation::TransportResources::new(next.trust_epoch.clone(), Vec::new()) + .unwrap(), + ); + assert_eq!( + rejected, + Err(crate::config_transaction::PublishError::ResourceRequired) + ); + assert!(Arc::ptr_eq(&holder.load_full(), &initial)); + + let transaction = coordinator.begin().await; + let view = transaction.candidate_view(next.clone()).unwrap(); + let mut replacement_tls = (*FixtureTls::new(false).server).clone(); + replacement_tls.alpn_protocols = vec![b"h3".to_vec()]; + let policy = h3::PreparedQuicPolicy::prepare(view, Arc::new(replacement_tls), true).unwrap(); + let resources = + crate::resource_generation::TransportResources::new(next.trust_epoch.clone(), Vec::new()) + .unwrap() + .with_quic_policy(policy) + .unwrap(); + transaction + .publish_resources(&revision, next.clone(), resources) + .unwrap(); + + assert!(Arc::ptr_eq(&holder.load_full(), &next)); + let (requires_client, epoch) = handle.test_snapshot(); + assert!(requires_client); + assert!(Arc::ptr_eq(&epoch.unwrap(), &next.trust_epoch)); + assert!(std::net::UdpSocket::bind(address).is_err()); + + coordinator.finish_shutdown(); + let rebound = std::net::UdpSocket::bind(address).unwrap(); + drop(rebound); + std::fs::remove_dir_all(root).unwrap(); + std::fs::remove_dir_all(server_root).unwrap(); +} + +#[tokio::test] +async fn http_only_launch_does_not_require_configured_quic_resource() { + let root = + std::env::temp_dir().join(format!("hj-http-only-quic-config-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let base = crate::pipeline::e2e::build_state(root.clone()); + let mut config = (*base.server).clone(); + config.quic_enable = true; + let state = ServerState::reload(&base, Arc::new(config)).unwrap(); + let holder = Arc::new(arc_swap::ArcSwap::from(state.clone())); + let coordinator = crate::config_transaction::Coordinator::new(holder) + .unwrap() + .with_tcp_launch_policy(crate::listener_plan::TcpLaunchPolicy { + http: "127.0.0.1:18080".parse().unwrap(), + https: None, + }); + coordinator + .install_initial_resources( + crate::resource_generation::TransportResources::new( + state.trust_epoch.clone(), + Vec::new(), + ) + .unwrap(), + ) + .expect("disabled HTTPS means configured QUIC is not an effective resource"); + coordinator.close(); + coordinator.finish_shutdown(); + std::fs::remove_dir_all(root).unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires python3 with aioquic; synthetic loopback QUIC only"] +async fn live_quic_reload_pins_established_and_updates_fresh_connections() { + use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt}; + + let root = std::env::temp_dir().join(format!( + "hj-live-quic-reload-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let before = root.join("before"); + let after = root.join("after"); + std::fs::create_dir_all(&before).unwrap(); + std::fs::create_dir(&after).unwrap(); + std::fs::write(before.join("index.html"), b"before publication").unwrap(); + std::fs::write(after.join("index.html"), b"after publication").unwrap(); + let initial = crate::pipeline::e2e::build_state(before); + let server_root = initial.server.server_root.clone(); + let holder = Arc::new(arc_swap::ArcSwap::from(initial.clone())); + let bridge = build_pipeline_bridge( + holder.clone(), + "http".into(), + pipeline_admission(holder.clone()), + ); + let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + let address = socket.local_addr().unwrap(); + let (group, handle) = h3::serve_h3_pipeline( + address, + 1, + h3::self_signed_config().unwrap(), + bridge, + false, + h3::H3RuntimeConfig::new( + || (h3::H3RequestLimits::new(16_384, 1024), 8), + initial.metrics.active_conns.clone(), + initial.body_budget.clone(), + ) + .with_serving_view(crate::serving_generation::ServingView::new(holder.clone())), + Some(vec![socket]), + initial.shutdown.clone(), + ) + .unwrap(); + group.activate(); + + let mut child = tokio::process::Command::new("python3") + .arg("-B") + .arg(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../scripts/h3_generation_client.py" + )) + .arg(address.port().to_string()) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let stdout = child.stdout.take().unwrap(); + let mut stdout = tokio::io::BufReader::new(stdout); + let mut ready = String::new(); + tokio::time::timeout( + std::time::Duration::from_secs(8), + stdout.read_line(&mut ready), + ) + .await + .expect("H3 client readiness timeout") + .unwrap(); + if ready.is_empty() { + let status = child.wait().await.unwrap(); + let mut stderr = String::new(); + child + .stderr + .take() + .unwrap() + .read_to_string(&mut stderr) + .await + .unwrap(); + panic!("H3 reload client exited before readiness ({status}): {stderr}"); + } + assert_eq!(ready.trim(), "READY"); + + let mut config = (*initial.server).clone(); + Arc::make_mut( + config + .vhosts + .get_mut("testvh") + .unwrap() + .config + .as_mut() + .unwrap(), + ) + .doc_root = after; + let mut next = ServerState::reload(&initial, Arc::new(config)).unwrap(); + Arc::get_mut(&mut next).unwrap().trust_epoch = Arc::new(()); + let policy = h3::PreparedQuicPolicy::prepare( + crate::serving_generation::ServingView::candidate(holder.clone(), next.clone()), + h3::self_signed_config().unwrap(), + false, + ) + .unwrap(); + holder.store(next); + handle.publish(policy); + child + .stdin + .as_mut() + .unwrap() + .write_all(b"\n") + .await + .unwrap(); + child.stdin.take(); + + let mut output = String::new(); + tokio::time::timeout( + std::time::Duration::from_secs(10), + stdout.read_to_string(&mut output), + ) + .await + .expect("H3 client completion timeout") + .unwrap(); + let status = child.wait().await.unwrap(); + if !status.success() { + let mut stderr = String::new(); + child + .stderr + .take() + .unwrap() + .read_to_string(&mut stderr) + .await + .unwrap(); + panic!("H3 reload client failed: {stderr}"); + } + assert!(output.contains("PASS: established H3 stayed pinned")); + + drop(group); + std::fs::remove_dir_all(root).unwrap(); + std::fs::remove_dir_all(server_root).unwrap(); +} diff --git a/crates/httpjet/src/uring/h3.rs b/crates/httpjet/src/uring/h3.rs index 0cfb7bd..a9984c1 100644 --- a/crates/httpjet/src/uring/h3.rs +++ b/crates/httpjet/src/uring/h3.rs @@ -17,6 +17,7 @@ use std::sync::Arc; use std::sync::atomic::AtomicU64; use std::time::Instant; +use arc_swap::ArcSwap; use std::os::fd::{AsRawFd, BorrowedFd}; use bytes::{Bytes, BytesMut}; @@ -43,6 +44,84 @@ const H3_SETTINGS_ERROR: u32 = 0x0109; const H3_MISSING_SETTINGS: u32 = 0x010a; const MAX_SETTINGS_PAYLOAD: usize = 64 * 1024; +/// One coherent accept-time QUIC policy. The quinn configuration and serving +/// view must move together: a connection may use either the old or new policy +/// during publication, but must never combine one generation's TLS verifier +/// with another generation's request policy. +struct QuicServerPolicy { + config: Arc, + serving_view: Option, + require_client_cert: bool, +} + +/// Fully validated replacement for future QUIC handshakes. Preparing this is +/// fallible; publishing it is one infallible ArcSwap store. +pub(crate) struct PreparedQuicPolicy(Arc); + +impl PreparedQuicPolicy { + pub(crate) fn prepare( + serving_view: crate::serving_generation::ServingView, + rustls: Arc, + require_client_cert: bool, + ) -> io::Result { + Ok(Self(Arc::new(QuicServerPolicy { + config: Arc::new(server_config(rustls)?), + serving_view: Some(serving_view), + require_client_cert, + }))) + } + + fn prepare_unscoped( + rustls: Arc, + require_client_cert: bool, + ) -> io::Result { + Ok(Self(Arc::new(QuicServerPolicy { + config: Arc::new(server_config(rustls)?), + serving_view: None, + require_client_cert, + }))) + } + + pub(crate) fn is_prepared_for(&self, epoch: &Arc<()>) -> bool { + self.0 + .serving_view + .as_ref() + .is_some_and(|view| Arc::ptr_eq(&view.trust_epoch(), epoch)) + } +} + +/// Shared by the coordinator and every per-core endpoint. Workers observe the +/// replacement without restarting or rebinding UDP sockets; quinn-proto applies +/// `set_server_config` only to future connections. +#[derive(Clone)] +pub(crate) struct QuicReloadHandle(Arc>); + +impl QuicReloadHandle { + fn new(initial: PreparedQuicPolicy) -> Self { + Self(Arc::new(ArcSwap::from(initial.0))) + } + + pub(crate) fn publish(&self, next: PreparedQuicPolicy) { + self.0.store(next.0); + } + + fn load_full(&self) -> Arc { + self.0.load_full() + } + + #[cfg(test)] + pub(crate) fn test_snapshot(&self) -> (bool, Option>) { + let policy = self.load_full(); + ( + policy.require_client_cert, + policy + .serving_view + .as_ref() + .map(crate::serving_generation::ServingView::trust_epoch), + ) + } +} + #[derive(Clone, Copy)] pub(crate) struct H3RequestLimits { max_header_bytes: usize, @@ -70,6 +149,7 @@ impl H3RequestLimits { #[derive(Clone)] pub(crate) struct H3RuntimeConfig { config: Arc (H3RequestLimits, u32) + Send + Sync>, + serving_view: Option, active_conns: Arc, /// (#236 residual) Server-wide buffered-body cap shared with H1/H2/LSAPI. H3 commits /// the whole request-stream buffer before parse, so the reservation is taken when a @@ -89,11 +169,20 @@ impl H3RuntimeConfig { { Self { config: Arc::new(config), + serving_view: None, active_conns, body_budget, } } + pub(crate) fn with_serving_view( + mut self, + view: crate::serving_generation::ServingView, + ) -> Self { + self.serving_view = Some(view); + self + } + fn request_limits(&self) -> H3RequestLimits { (self.config)().0 } @@ -101,6 +190,36 @@ impl H3RuntimeConfig { fn max_connections(&self) -> u32 { (self.config)().1 } + + #[cfg(test)] + fn accepted_state(&self, permit: super::ConnectionPermit, epoch: u64) -> H3State { + H3State { + _connection_permit: Some(permit), + serving_view: self.serving_view.as_ref().map(|view| view.pin_connection()), + epoch, + body_budget: Some(self.body_budget.clone()), + ..Default::default() + } + } + + fn accepted_state_with_policy( + &self, + permit: super::ConnectionPermit, + epoch: u64, + policy: &QuicServerPolicy, + ) -> H3State { + H3State { + _connection_permit: Some(permit), + serving_view: policy + .serving_view + .as_ref() + .map(|view| view.pin_connection()), + require_client_cert: policy.require_client_cert, + epoch, + body_budget: Some(self.body_budget.clone()), + ..Default::default() + } + } } /// Build a `SO_REUSEPORT` UDP socket (std) for a monoio runtime to adopt — the @@ -238,6 +357,7 @@ fn per_core_h3(core: usize, std_sock: std::net::UdpSocket, rustls_cfg: Arc, + serving_view: Option, connected: bool, control_setup: bool, control_stream: Option, @@ -278,6 +398,9 @@ struct H3State { total_req_bytes: std::rc::Rc>, /// Set once a required-client-certificate decision closes the connection. rejected: bool, + /// Accept-time mTLS policy. Established connections retain the decision + /// from the QUIC configuration that authenticated their handshake. + require_client_cert: bool, /// Encoded response bytes not yet fully written to the send stream, with the /// byte offset already accepted. QUIC stream flow control bounds how much /// `SendStream::write` accepts at once, so a large response (> the peer's stream @@ -297,6 +420,31 @@ struct H3State { epoch: u64, } +impl H3State { + fn request_snapshot( + &self, + fallback: H3RequestLimits, + ) -> ( + H3RequestLimits, + Option, + ) { + let generation = self + .serving_view + .as_ref() + .map(|view| crate::serving_generation::RequestGeneration(view.load_full())); + let limits = generation + .as_ref() + .map(|generation| { + H3RequestLimits::new( + generation.0.serve_config.max_req_header_size, + generation.0.serve_config.max_req_body_size, + ) + }) + .unwrap_or(fallback); + (limits, generation) + } +} + #[derive(Default)] struct RequestCancellations(std::collections::HashMap); @@ -338,9 +486,9 @@ struct Completion { /// the body flows out as the backend produces it instead of being buffered whole. enum CompletionKind { /// Whole encoded response (HEADERS [+ DATA]) — finish the stream once drained. - Full(Vec), + Full(Vec, Option), /// HEADERS frame only; DATA `Chunk`s follow. The stream stays open until `Chunk{fin}`. - Head(Vec), + Head(Vec, Option), /// One DATA payload; the driver emits its frame header without copying the payload. /// `fin` marks the last chunk (then finish the stream). Chunk { @@ -357,9 +505,28 @@ enum CompletionKind { /// Streamed DATA keeps the bridge's original `Bytes` and an inline frame header, avoiding both /// copies formerly made while framing and appending each chunk. struct PendingSend { + completion: Option, parts: std::collections::VecDeque, fin: bool, } +impl PendingSend { + /// Report completion only after every queued byte has entered QUIC and its + /// FIN is accepted. This is not acknowledgement by the remote application. + fn finish_if_drained(&mut self, finish: impl FnOnce() -> bool) -> bool { + if !self.fin || !self.parts.is_empty() { + return false; + } + let accepted = finish(); + if let Some(completion) = self.completion.take() { + completion.finish(if accepted { + hj_core::ResponseEnd::Complete + } else { + hj_core::ResponseEnd::Cancelled + }); + } + true + } +} enum PendingPart { Contiguous { @@ -1459,7 +1626,6 @@ fn service_conn( h3: &mut HashMap, hd: ConnectionHandle, now: Instant, - require_client_cert: bool, accepting_requests: bool, ) { let due = conns @@ -1503,7 +1669,11 @@ fn service_conn( }) }); let eligible = conns.get(&hd).is_some_and(|conn| { - h3_client_eligible(require_client_cert, leaf.is_some(), conn.remote_address()) + h3_client_eligible( + st.require_client_cert, + leaf.is_some(), + conn.remote_address(), + ) }); if eligible { st.client_leaf = leaf; @@ -1758,7 +1928,7 @@ async fn drive_connections( { let handles: Vec = conns.keys().copied().collect(); for hd in handles { - service_conn(endpoint, conns, h3, hd, now, false, true); + service_conn(endpoint, conns, h3, hd, now, true); if h3.get(&hd).is_some_and(|state| state.rejected) { flush_conn(udp, udp_state, max_gso, conns, hd, now, tx_scratch).await; continue; @@ -1872,11 +2042,7 @@ fn pump_stream(conn: &mut quinn_proto::Connection, st: &mut H3State, id: StreamI let mut cancelled = false; let done = loop { let Some(part) = entry.parts.front_mut() else { - if entry.fin { - let _ = ss.finish(); - break true; - } - break false; + break entry.finish_if_drained(|| ss.finish().is_ok()); }; match part.write(&mut ss) { Ok(true) => { @@ -1925,21 +2091,23 @@ async fn write_completion( }; match kind { // A whole buffered response: send and finish when drained. - CompletionKind::Full(resp) => { + CompletionKind::Full(resp, completion) => { st.request_cancellations.remove(&stream); st.pending.insert( stream, PendingSend { + completion, parts: std::iter::once(PendingPart::contiguous(resp)).collect(), fin: true, }, ); } // HEADERS of a streamed response: DATA chunks follow, so don't finish yet. - CompletionKind::Head(head) => { + CompletionKind::Head(head, completion) => { st.pending.insert( stream, PendingSend { + completion, parts: std::iter::once(PendingPart::contiguous(head)).collect(), fin: false, }, @@ -1963,7 +2131,11 @@ async fn write_completion( // a clean finish, so the peer sees the body was truncated (mirrors the H1 path). CompletionKind::Abort => { st.request_cancellations.remove(&stream); - st.pending.remove(&stream); + if let Some(mut pending) = st.pending.remove(&stream) { + if let Some(completion) = pending.completion.take() { + completion.finish(hj_core::ResponseEnd::Error); + } + } let _ = conn .send_stream(stream) .reset(quinn_proto::VarInt::from_u32(0x0102)); @@ -1993,7 +2165,6 @@ async fn drive_one_conn( now: Instant, local: SocketAddr, bridge: &Bridge, - require_client_cert: bool, inflight: &std::rc::Rc>, comp_tx: &flume::Sender, request_limits: H3RequestLimits, @@ -2003,20 +2174,16 @@ async fn drive_one_conn( if !conns.contains_key(&hd) { return false; } - service_conn( - endpoint, - conns, - h3, - hd, - now, - require_client_cert, - accepting_requests, - ); + service_conn(endpoint, conns, h3, hd, now, accepting_requests); if h3.get(&hd).is_some_and(|state| state.rejected) { return flush_conn(udp, udp_state, max_gso, conns, hd, now, tx_scratch).await; } let epoch = h3.get(&hd).map(|s| s.epoch).unwrap_or(0); let st = h3.entry(hd).or_default(); + let require_client_cert = st.require_client_cert; + // One compatible snapshot supplies both parsing limits and bridge dispatch. + // It is selected before reading this batch, not later on the Tokio runtime. + let (request_limits, request_generation) = st.request_snapshot(request_limits); let req_ids: Vec = st.requests.iter().copied().collect(); let mut finished = Vec::new(); for id in req_ids { @@ -2123,6 +2290,7 @@ async fn drive_one_conn( st.request_cancellations.insert(id, cancel.clone()); let bridge = bridge.clone(); let tx = comp_tx.clone(); + let request_generation = request_generation.clone(); // spawn() is synchronous (no await) — `st`'s borrow of `h3` is not held across an await. let work = async move { let _g = guard; // frees the in-flight slot on completion / drop / panic @@ -2144,18 +2312,24 @@ async fn drive_one_conn( &bridge, require_client_cert, request_limits, + request_generation, ) .await; drop(charge); let _ = async { - match outcome { - H3Outcome::Full(resp) => { - send(CompletionKind::Full(resp)).await.map_err(|_| ())?; + let H3Outcome { body, completion } = outcome; + match body { + H3Body::Full(resp) => { + send(CompletionKind::Full(resp, completion)) + .await + .map_err(|_| ())?; } - H3Outcome::Stream { head, mut rx } => { + H3Body::Stream { head, mut rx } => { // The driver is the sole stream writer: send the HEADERS, then forward each // DATA chunk in order. Each chunk is acknowledged only after QUIC drains it. - send(CompletionKind::Head(head)).await.map_err(|_| ())?; + send(CompletionKind::Head(head, completion)) + .await + .map_err(|_| ())?; loop { match rx.recv().await { Some(Ok(chunk)) => { @@ -2215,6 +2389,7 @@ async fn recv_drain( h3: &mut HashMap, epoch_ctr: &mut u64, runtime: &H3RuntimeConfig, + policy: &QuicServerPolicy, accepting_connections: bool, now: Instant, ) -> std::collections::HashSet { @@ -2320,7 +2495,12 @@ async fn recv_drain( } DatagramEvent::NewConnection(incoming) => { scratch.clear(); - let permit = accepting_connections.then(|| { + let permit = (accepting_connections + && policy + .serving_view + .as_ref() + .is_none_or(|view| view.is_current_trust_epoch())) + .then(|| { super::ConnectionPermit::try_acquire( runtime.active_conns.clone(), runtime.max_connections(), @@ -2343,12 +2523,8 @@ async fn recv_drain( conns.insert(handle, conn); h3.insert( handle, - H3State { - _connection_permit: Some(permit), - epoch: *epoch_ctr, - body_budget: Some(runtime.body_budget.clone()), - ..Default::default() - }, + runtime + .accepted_state_with_policy(permit, *epoch_ctr, policy), ); affected.insert(handle); } @@ -2405,9 +2581,9 @@ async fn pump( inflight: &std::rc::Rc>, bridge: &Bridge, local: SocketAddr, - require_client_cert: bool, comp_tx: &flume::Sender, runtime: &H3RuntimeConfig, + policy: &QuicServerPolicy, accepting: bool, mut to_drive: std::collections::HashSet, ) { @@ -2430,7 +2606,6 @@ async fn pump( now, local, bridge, - require_client_cert, inflight, comp_tx, runtime.request_limits(), @@ -2445,7 +2620,7 @@ async fn pump( // Process ACKs our sends elicited so cwnd/loss-detection stay current. for hd in recv_drain( udp, udp_state, recv_bufs, recv_metas, scratch, tx_scratch, endpoint, conns, h3, - epoch_ctr, runtime, accepting, now, + epoch_ctr, runtime, policy, accepting, now, ) .await { @@ -2507,9 +2682,11 @@ async fn endpoint_loop_concurrent( mut endpoint: Endpoint, local: SocketAddr, bridge: Bridge, - require_client_cert: bool, + policy_handle: QuicReloadHandle, runtime: H3RuntimeConfig, shutdown: CancellationToken, + ready: super::WorkerReadyTx, + activation: super::worker_group::ActivationGate, ) -> io::Result<()> { let mut conns: HashMap = HashMap::new(); let mut h3: HashMap = HashMap::new(); @@ -2533,8 +2710,19 @@ async fn endpoint_loop_concurrent( let mut recv_metas = [quinn_udp::RecvMeta::default(); GRO_BATCH]; let mut draining = false; let mut drain_deadline: Option = None; + // Socket capability probing and driver allocations belong to preparation, + // not the published serving lifetime. No packet is consumed before release. + if ready.send(Ok(())).is_err() || !activation.wait().await { + return Ok(()); + } + let mut policy = policy_handle.load_full(); loop { crate::memtrim::collect_if_requested_on_thread(); + let latest = policy_handle.load_full(); + if !Arc::ptr_eq(&policy, &latest) { + endpoint.set_server_config(Some(latest.config.clone())); + policy = latest; + } if draining && h3_drain_complete(&h3, inflight.get(), comp_rx.is_empty()) { close_h3_connections( &udp, @@ -2582,7 +2770,7 @@ async fn endpoint_loop_concurrent( let now = Instant::now(); drain_deadline = Some(now + super::URING_DRAIN_GRACE); let handles = conns.keys().copied().collect(); - pump(&udp, &udp_state, max_gso, &mut recv_bufs, &mut recv_metas, &mut scratch, &mut tx_scratch, &mut endpoint, &mut conns, &mut h3, &mut epoch_ctr, &inflight, &bridge, local, require_client_cert, &comp_tx, &runtime, false, handles).await; + pump(&udp, &udp_state, max_gso, &mut recv_bufs, &mut recv_metas, &mut scratch, &mut tx_scratch, &mut endpoint, &mut conns, &mut h3, &mut epoch_ctr, &inflight, &bridge, local, &comp_tx, &runtime, &policy, false, handles).await; } // (1) Finished request(s): write each response into its stream, then pump the // sends (the response streams out cooperatively, interleaved with ACK processing). @@ -2599,14 +2787,14 @@ async fn endpoint_loop_concurrent( write_completion(&udp, &udp_state, max_gso, &mut conns, &mut h3, c, now, &mut tx_scratch).await; to_drive.insert(hd); } - pump(&udp, &udp_state, max_gso, &mut recv_bufs, &mut recv_metas, &mut scratch, &mut tx_scratch, &mut endpoint, &mut conns, &mut h3, &mut epoch_ctr, &inflight, &bridge, local, require_client_cert, &comp_tx, &runtime, !draining, to_drive).await; + pump(&udp, &udp_state, max_gso, &mut recv_bufs, &mut recv_metas, &mut scratch, &mut tx_scratch, &mut endpoint, &mut conns, &mut h3, &mut epoch_ctr, &inflight, &bridge, local, &comp_tx, &runtime, &policy, !draining, to_drive).await; } // (2) Socket readable: GRO-drain queued datagrams, then pump (drive affected conns // + interleave further ACK processing). `readable()` is a poll op (cancel-safe). _ = udp.readable(false) => { let now = Instant::now(); - let affected = recv_drain(&udp, &udp_state, &mut recv_bufs, &mut recv_metas, &mut scratch, &mut tx_scratch, &mut endpoint, &mut conns, &mut h3, &mut epoch_ctr, &runtime, !draining, now).await; - pump(&udp, &udp_state, max_gso, &mut recv_bufs, &mut recv_metas, &mut scratch, &mut tx_scratch, &mut endpoint, &mut conns, &mut h3, &mut epoch_ctr, &inflight, &bridge, local, require_client_cert, &comp_tx, &runtime, !draining, affected).await; + let affected = recv_drain(&udp, &udp_state, &mut recv_bufs, &mut recv_metas, &mut scratch, &mut tx_scratch, &mut endpoint, &mut conns, &mut h3, &mut epoch_ctr, &runtime, &policy, !draining, now).await; + pump(&udp, &udp_state, max_gso, &mut recv_bufs, &mut recv_metas, &mut scratch, &mut tx_scratch, &mut endpoint, &mut conns, &mut h3, &mut epoch_ctr, &inflight, &bridge, local, &comp_tx, &runtime, &policy, !draining, affected).await; } // (3) A quinn-proto timer fired (handshake retransmit / idle / pacing) — pump the // connections whose timer is due. @@ -2616,7 +2804,7 @@ async fn endpoint_loop_concurrent( .iter_mut() .filter_map(|(hd, c)| c.poll_timeout().filter(|t| *t <= now).map(|_| *hd)) .collect(); - pump(&udp, &udp_state, max_gso, &mut recv_bufs, &mut recv_metas, &mut scratch, &mut tx_scratch, &mut endpoint, &mut conns, &mut h3, &mut epoch_ctr, &inflight, &bridge, local, require_client_cert, &comp_tx, &runtime, !draining, due).await; + pump(&udp, &udp_state, max_gso, &mut recv_bufs, &mut recv_metas, &mut scratch, &mut tx_scratch, &mut endpoint, &mut conns, &mut h3, &mut epoch_ctr, &inflight, &bridge, local, &comp_tx, &runtime, &policy, !draining, due).await; } } } @@ -3026,7 +3214,19 @@ fn h3_error(status: http::StatusCode) -> Vec { /// small/HIT bodies — sent as one `Completion::Full`) or a streamed response (HEADERS frame /// + a chunk source the spawn task forwards as `Completion::Chunk`s, so a large body flows /// out as the backend produces it instead of buffering whole). -enum H3Outcome { +struct H3Outcome { + body: H3Body, + completion: Option, +} +impl H3Outcome { + fn full(data: Vec) -> Self { + Self { + body: H3Body::Full(data), + completion: None, + } + } +} +enum H3Body { Full(Vec), Stream { head: Vec, @@ -3191,21 +3391,22 @@ async fn handle_h3_request( bridge: &Bridge, require_client_cert: bool, request_limits: H3RequestLimits, + request_generation: Option, ) -> H3Outcome { if require_client_cert && !has_client_cert && !hj_core::is_trusted_internal_peer(peer.ip()) { - return H3Outcome::Full(h3_error(http::StatusCode::FORBIDDEN)); + return H3Outcome::full(h3_error(http::StatusCode::FORBIDDEN)); } let parsed = match parse_h3_request_leased(req_bytes, body_lease) { Ok(v) => v, - Err(_) => return H3Outcome::Full(h3_error(http::StatusCode::BAD_REQUEST)), + Err(_) => return H3Outcome::full(h3_error(http::StatusCode::BAD_REQUEST)), }; if parsed.body.len() > request_limits.max_body_bytes { - return H3Outcome::Full(h3_error(http::StatusCode::BAD_REQUEST)); + return H3Outcome::full(h3_error(http::StatusCode::BAD_REQUEST)); } let (headers, initial_field_size) = match qpack_decode_limited(&parsed.field, request_limits.max_header_bytes) { Some(decoded) => decoded, - None => return H3Outcome::Full(h3_error(http::StatusCode::BAD_REQUEST)), + None => return H3Outcome::full(h3_error(http::StatusCode::BAD_REQUEST)), }; if parsed.trailers.as_deref().is_some_and(|field| { !valid_h3_trailers( @@ -3215,11 +3416,11 @@ async fn handle_h3_request( .saturating_sub(initial_field_size), ) }) { - return H3Outcome::Full(h3_error(http::StatusCode::BAD_REQUEST)); + return H3Outcome::full(h3_error(http::StatusCode::BAD_REQUEST)); } let head = match split_h3_request_headers(headers) { Some(h) => h, - None => return H3Outcome::Full(h3_error(http::StatusCode::BAD_REQUEST)), + None => return H3Outcome::full(h3_error(http::StatusCode::BAD_REQUEST)), }; // (N2) §4.1.2: a Content-Length that disagrees with the DATA length is malformed. // Parsed with the SAME strict resolver as H1 (#232 residual): ASCII-OWS trim, @@ -3233,15 +3434,15 @@ async fn handle_h3_request( .map(|(_, v)| v.as_slice()); match super::codec::resolve_content_length(values) { Ok(cl) => cl, - Err(()) => return H3Outcome::Full(h3_error(http::StatusCode::BAD_REQUEST)), + Err(()) => return H3Outcome::full(h3_error(http::StatusCode::BAD_REQUEST)), } }; if declared_cl.is_some_and(|cl| cl != parsed.body.len()) { - return H3Outcome::Full(h3_error(http::StatusCode::BAD_REQUEST)); + return H3Outcome::full(h3_error(http::StatusCode::BAD_REQUEST)); } let (builder, sni) = match build_h3_request_head(head) { Some(head) => head, - None => return H3Outcome::Full(h3_error(http::StatusCode::BAD_REQUEST)), + None => return H3Outcome::full(h3_error(http::StatusCode::BAD_REQUEST)), }; let body_b = parsed.body; let inbody: hj_core::IncomingBody = if body_b.is_empty() { @@ -3254,14 +3455,18 @@ async fn handle_h3_request( }; let mut req = match builder.body(inbody) { Ok(r) => r, - Err(_) => return H3Outcome::Full(h3_error(http::StatusCode::BAD_REQUEST)), + Err(_) => return H3Outcome::full(h3_error(http::StatusCode::BAD_REQUEST)), }; hj_core::coalesce_cookie_crumbs(req.headers_mut()); + if let Some(generation) = request_generation { + req.extensions_mut().insert(generation); + } let ctx = BridgeCtx { peer, local, proto: Proto::Http3, is_tls: true, + direct_file_egress: false, peer_unix: false, mtls_required: require_client_cert, sni, @@ -3272,6 +3477,7 @@ async fn handle_h3_request( let is_head = req.method() == http::Method::HEAD; match bridge.dispatch(req, ctx).await { Some(r) => { + let completion = r.completion; let status = r.status; let mut headers = r.headers; let streaming_unknown_len = matches!( @@ -3280,12 +3486,15 @@ async fn handle_h3_request( ); let body_forbidden = prepare_h3_response_headers(&mut headers, is_head, status, streaming_unknown_len); - match r.body { + let mut outcome = match r.body { // Small / HIT / sub-threshold dynamic bodies: buffered + sent whole — byte-identical // to the previous path. crate::uring::bridge::BridgeBody::Full(b) => { let body = if body_forbidden { &[][..] } else { &b[..] }; - H3Outcome::Full(encode_h3_response(status, &headers, body)) + H3Outcome::full(encode_h3_response(status, &headers, body)) + } + crate::uring::bridge::BridgeBody::File(_) => { + unreachable!("direct file bridge bodies are plaintext H1-only") } // Large / SSE / proxy bodies: stream the HEADERS now and forward DATA chunks as the // backend produces them, instead of buffering the whole body first. @@ -3293,20 +3502,26 @@ async fn handle_h3_request( let head = encode_h3_headers_frame(status, &headers); if body_forbidden { rx.close(); - H3Outcome::Full(head) + H3Outcome::full(head) } else { - H3Outcome::Stream { head, rx } + H3Outcome { + body: H3Body::Stream { head, rx }, + completion: None, + } } } - } + }; + outcome.completion = completion; + outcome } - None => H3Outcome::Full(h3_error(http::StatusCode::BAD_GATEWAY)), + None => H3Outcome::full(h3_error(http::StatusCode::BAD_GATEWAY)), } } /// Real-pipeline io_uring H3 listener: per-core monoio runtimes driving quinn-proto, each /// dispatching requests through `bridge`. This is the ONLY H3 transport (the tokio/quinn /// adapter was removed 2026-06-21); production serves H3 here unconditionally. +/// The returned group is prepared, not serving; its owner must call `activate`. pub(crate) fn serve_h3_pipeline( addr: SocketAddr, workers: usize, @@ -3316,37 +3531,49 @@ pub(crate) fn serve_h3_pipeline( runtime: H3RuntimeConfig, inherited: Option>, shutdown: CancellationToken, -) -> io::Result<()> { +) -> io::Result<(super::WorkerGroup, QuicReloadHandle)> { + let serving_view = runtime.serving_view.clone(); + let policy = match serving_view.clone() { + Some(view) => PreparedQuicPolicy::prepare(view, rustls_cfg, require_client_cert)?, + None => PreparedQuicPolicy::prepare_unscoped(rustls_cfg, require_client_cert)?, + }; + let policy_handle = QuicReloadHandle::new(policy); let sockets = h3_udp_sockets(inherited, addr, workers)?; let worker_count = sockets.len(); let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let mut group = match serving_view { + Some(view) => super::WorkerGroup::for_epoch(&shutdown, view.trust_epoch()), + None => super::WorkerGroup::new(&shutdown), + }; for (core, std_sock) in sockets.into_iter().enumerate() { - let cfg = rustls_cfg.clone(); let bridge = bridge.clone(); let runtime = runtime.clone(); - let shutdown = shutdown.clone(); + let policy_handle = policy_handle.clone(); let ready = ready_tx.clone(); - std::thread::Builder::new() - .name(format!("hj-uring-h3p-{core}")) - .stack_size(crate::RUNTIME_THREAD_STACK_BYTES) - .spawn(move || { + let activation = group.activation_gate(); + group.spawn( + std::thread::Builder::new() + .name(format!("hj-uring-h3p-{core}")) + .stack_size(crate::RUNTIME_THREAD_STACK_BYTES), + move |shutdown| { super::maybe_pin_core_thread(core, worker_count); per_core_h3_pipeline( core, std_sock, addr, - cfg, bridge, - require_client_cert, + policy_handle, runtime, shutdown, ready, + activation, ) - })?; + }, + )?; } drop(ready_tx); super::wait_for_worker_readiness("HTTP/3", worker_count, ready_rx)?; - Ok(()) + Ok((group, policy_handle)) } /// The per-core UDP socket set for the io_uring H3 transport: the inherited @@ -3388,21 +3615,14 @@ fn per_core_h3_pipeline( core: usize, std_sock: std::net::UdpSocket, local: SocketAddr, - rustls_cfg: Arc, bridge: Bridge, - require_client_cert: bool, + policy_handle: QuicReloadHandle, runtime: H3RuntimeConfig, shutdown: CancellationToken, ready: super::WorkerReadyTx, + activation: super::worker_group::ActivationGate, ) { - let server_cfg = match server_config(rustls_cfg) { - Ok(c) => Arc::new(c), - Err(e) => { - let _ = ready.send(Err(format!("build QUIC server config: {e}"))); - tracing::error!(core, error = %e, "uring h3-pipeline: server config build failed"); - return; - } - }; + let initial_policy = policy_handle.load_full(); let mut rt = match super::build_core_runtime() { Ok(runtime) => runtime, Err(error) => { @@ -3419,10 +3639,28 @@ fn per_core_h3_pipeline( return; } }; - let endpoint = Endpoint::new(Arc::new(EndpointConfig::default()), Some(server_cfg), true, None); - let _ = ready.send(Ok(())); - tracing::info!(core, "uring h3-pipeline: per-core quinn-proto endpoint serving (real pipeline via bridge, concurrent dispatch)"); - if let Err(e) = endpoint_loop_concurrent(udp, endpoint, local, bridge, require_client_cert, runtime, shutdown).await { + let endpoint = Endpoint::new( + Arc::new(EndpointConfig::default()), + Some(initial_policy.config.clone()), + true, + None, + ); + if let Err(e) = endpoint_loop_concurrent( + udp, + endpoint, + local, + bridge, + policy_handle, + runtime, + shutdown, + ready.clone(), + activation, + ) + .await + { + // Before readiness this rejects the entire candidate; after readiness + // the receiver is gone and the error is a serving-runtime failure. + let _ = ready.send(Err(format!("prepare or drive QUIC endpoint: {e}"))); tracing::error!(core, error = %e, "uring h3-pipeline: endpoint loop ended"); } }); @@ -3779,6 +4017,190 @@ mod h3_codec_tests { assert_eq!(runtime.request_limits().max_body_bytes, 32 * 1024 * 1024); } + #[tokio::test] + async fn accepted_quic_view_pins_limits_and_dispatch_generation() { + use crate::serving_generation::{RequestGeneration, ServingView}; + use std::sync::atomic::Ordering; + let root = std::env::temp_dir().join(format!( + "hj-quic-generation-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&root).unwrap(); + let initial = crate::pipeline::e2e::build_state(root.clone()); + let server_root = initial.server.server_root.clone(); + let holder = Arc::new(arc_swap::ArcSwap::from(initial.clone())); + let runtime = H3RuntimeConfig::new( + || (H3RequestLimits::new(1, 1), 8), + Arc::new(AtomicU64::new(0)), + initial.body_budget.clone(), + ) + .with_serving_view(ServingView::new(holder.clone())); + let permit = || { + crate::uring::ConnectionPermit::try_acquire(runtime.active_conns.clone(), 8).unwrap() + }; + let early = runtime.accepted_state(permit(), 1); + let mut application = + crate::state::ServerState::reload(&initial, initial.server.clone()).unwrap(); + Arc::get_mut(&mut application) + .unwrap() + .serve_config + .max_req_body_size = 512; + holder.store(application.clone()); + let (limits, selected) = early.request_snapshot(runtime.request_limits()); + assert_eq!(limits.max_body_bytes, 512); + assert!(Arc::ptr_eq(&selected.as_ref().unwrap().0, &application)); + let later = runtime.accepted_state(permit(), 2); + let mut replacement = + crate::state::ServerState::reload(&application, application.server.clone()).unwrap(); + Arc::get_mut(&mut replacement).unwrap().trust_epoch = Arc::new(()); + Arc::get_mut(&mut replacement) + .unwrap() + .serve_config + .max_req_body_size = 4096; + holder.store(replacement); + let (old_limits, old_snapshot) = early.request_snapshot(runtime.request_limits()); + assert_eq!( + old_limits.max_body_bytes, + initial.serve_config.max_req_body_size + ); + assert!(Arc::ptr_eq(&old_snapshot.unwrap().0, &initial)); + let (later_limits, later_snapshot) = later.request_snapshot(runtime.request_limits()); + assert_eq!(later_limits.max_body_bytes, 512); + assert!(Arc::ptr_eq(&later_snapshot.unwrap().0, &application)); + + // Decode a real H3 HEADERS frame and prove the generation selected before + // publication travels through dispatch, rather than reloading live state. + let seen = Arc::new(AtomicU64::new(0)); + let observed = seen.clone(); + let bridge = crate::uring::bridge::spawn_on_current(2, move |req, _| { + observed.store( + req.extensions() + .get::() + .unwrap() + .0 + .generation, + Ordering::SeqCst, + ); + async { http::Response::new(hj_core::Body::Empty) } + }); + let fields = literal_qpack_fields(&[ + (b":method", b"GET"), + (b":scheme", b"https"), + (b":authority", b"canon.test"), + (b":path", b"/"), + ]); + let mut wire = Vec::new(); + write_varint(&mut wire, 1); + write_varint(&mut wire, fields.len() as u64); + wire.extend(fields); + let _response = handle_h3_request( + wire, + None, + false, + None, + "127.0.0.1:32000".parse().unwrap(), + "127.0.0.1:8443".parse().unwrap(), + &bridge, + false, + limits, + selected, + ) + .await; + assert_eq!(seen.load(Ordering::SeqCst), application.generation); + drop(early); + drop(later); + assert_eq!(runtime.active_conns.load(Ordering::SeqCst), 0); + std::fs::remove_dir_all(root).unwrap(); + std::fs::remove_dir_all(server_root).unwrap(); + } + + #[tokio::test] + async fn quic_policy_publication_is_coherent_and_preserves_old_views() { + use crate::serving_generation::ServingView; + + let root = std::env::temp_dir().join(format!( + "hj-quic-policy-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&root).unwrap(); + let initial = crate::pipeline::e2e::build_state(root.clone()); + let server_root = initial.server.server_root.clone(); + let holder = Arc::new(arc_swap::ArcSwap::from(initial.clone())); + let old = PreparedQuicPolicy::prepare( + ServingView::new(holder.clone()), + self_signed_config().unwrap(), + false, + ) + .unwrap(); + let old_policy = old.0.clone(); + let handle = QuicReloadHandle::new(old); + + let mut candidate = + crate::state::ServerState::reload(&initial, initial.server.clone()).unwrap(); + Arc::get_mut(&mut candidate).unwrap().trust_epoch = Arc::new(()); + let replacement = PreparedQuicPolicy::prepare( + ServingView::candidate(holder.clone(), candidate.clone()), + self_signed_config().unwrap(), + true, + ) + .unwrap(); + let replacement_policy = replacement.0.clone(); + + assert!( + old_policy + .serving_view + .as_ref() + .unwrap() + .is_current_trust_epoch() + ); + assert!( + !replacement_policy + .serving_view + .as_ref() + .unwrap() + .is_current_trust_epoch() + ); + holder.store(candidate.clone()); + assert!( + !old_policy + .serving_view + .as_ref() + .unwrap() + .is_current_trust_epoch() + ); + assert!( + replacement_policy + .serving_view + .as_ref() + .unwrap() + .is_current_trust_epoch() + ); + + handle.publish(replacement); + let published = handle.load_full(); + assert!(Arc::ptr_eq(&published, &replacement_policy)); + assert!(published.require_client_cert); + assert!(Arc::ptr_eq( + &published.serving_view.as_ref().unwrap().load_full(), + &candidate + )); + assert!(Arc::ptr_eq( + &old_policy.serving_view.as_ref().unwrap().load_full(), + &initial + )); + + std::fs::remove_dir_all(root).unwrap(); + std::fs::remove_dir_all(server_root).unwrap(); + } + #[test] fn reuseport_server_disables_connection_migration() { let config = server_config(self_signed_config().unwrap()).unwrap(); @@ -4211,6 +4633,7 @@ mod h3_codec_tests { let (ack_tx, ack_rx) = flume::bounded(1); let part = PendingPart::data_frame(Bytes::from_static(b"abc"), Some(ack_tx)); let mut pending = PendingSend { + completion: None, parts: std::iter::once(part).collect(), fin: false, }; @@ -4219,6 +4642,36 @@ mod h3_codec_tests { assert_eq!(ack_rx.try_recv(), Ok(())); } + #[test] + fn completion_waits_for_drained_parts_and_accepted_fin() { + for accepted in [false, true] { + let events = Arc::new(std::sync::Mutex::new(Vec::new())); + let copy = events.clone(); + let mut pending = PendingSend { + completion: Some(hj_core::ResponseCompletion::new(move |end| { + copy.lock().unwrap().push(end) + })), + parts: std::iter::once(PendingPart::contiguous(vec![1, 2, 3])).collect(), + fin: false, + }; + assert!(!pending.finish_if_drained(|| panic!("body not drained"))); + assert!(acknowledge_front_part(&mut pending)); + assert!(!pending.finish_if_drained(|| panic!("FIN not available"))); + assert!(events.lock().unwrap().is_empty()); + pending.fin = true; + assert!(pending.finish_if_drained(|| accepted)); + drop(pending); + assert_eq!( + *events.lock().unwrap(), + vec![if accepted { + hj_core::ResponseEnd::Complete + } else { + hj_core::ResponseEnd::Cancelled + }] + ); + } + } + #[test] fn streamed_data_frame_retains_the_bridge_chunk_allocation() { let chunk = Bytes::from(vec![0x5a; 4096]); @@ -4252,6 +4705,7 @@ mod h3_codec_tests { let (ack_tx, ack_rx) = flume::bounded(1); let part = PendingPart::data_frame(Bytes::from_static(b"abc"), Some(ack_tx)); let pending = PendingSend { + completion: None, parts: std::iter::once(part).collect(), fin: false, }; @@ -4264,6 +4718,8 @@ mod h3_codec_tests { #[test] fn cancelling_response_stream_cancels_dispatch_and_drops_pending_chunks() { + let events = Arc::new(std::sync::Mutex::new(Vec::new())); + let copy = events.clone(); let id = StreamId::new(quinn_proto::Side::Client, Dir::Bi, 0); let token = CancellationToken::new(); let (ack_tx, ack_rx) = flume::bounded(1); @@ -4272,6 +4728,9 @@ mod h3_codec_tests { state.pending.insert( id, PendingSend { + completion: Some(hj_core::ResponseCompletion::new(move |end| { + copy.lock().unwrap().push(end) + })), parts: std::iter::once(PendingPart::data_frame( Bytes::from_static(b"backend chunk"), Some(ack_tx), @@ -4286,6 +4745,10 @@ mod h3_codec_tests { assert!(token.is_cancelled()); assert!(!state.request_cancellations.contains_key(&id)); assert!(!state.pending.contains_key(&id)); + assert_eq!( + *events.lock().unwrap(), + vec![hj_core::ResponseEnd::Cancelled] + ); assert!(matches!( ack_rx.try_recv(), Err(flume::TryRecvError::Disconnected) diff --git a/crates/httpjet/src/uring/ktls.rs b/crates/httpjet/src/uring/ktls.rs index 10408b5..b1b80be 100644 --- a/crates/httpjet/src/uring/ktls.rs +++ b/crates/httpjet/src/uring/ktls.rs @@ -1,6 +1,6 @@ //! Kernel-TLS (kTLS) for the monoio io_uring TLS path, **with TLS 1.3 KeyUpdate //! handling** so it survives a mid-stream rekey instead of dropping the connection. -//! Compiled only with `--features ktls`, activated only by `--ktls` (OFF by default). +//! Compiled only with `--features ktls`; runtime policy is `--ktls=auto|on|off`. //! //! Flow: a per-connection [`hj_tls`] `KeyLog` captures this connection's raw TLS 1.3 //! traffic secrets during the handshake (rustls only surfaces them via `KeyLog`, and @@ -9,9 +9,11 @@ //! generation). After the handshake we derive the AEAD key/iv from each secret //! (HKDF-Expand-Label, RFC 8446 §7.1, via aws-lc-rs — already the rustls provider), //! program the kernel socket (`TCP_ULP=tls` + `TLS_TX`/`TLS_RX`), and serve H1/H2 as -//! plaintext over the raw fd (kernel encrypt/decrypt) — killing the userspace AEAD + -//! copy on large-body egress. The drained post-handshake plaintext is handed to the -//! serve loop as a prefix, so the kernel RX resumes at the correct record sequence. +//! plaintext over the raw fd (kernel encrypt/decrypt). H1 file bodies additionally use +//! `sendfile(2)`, so a NIC with TLS TX offload can consume the pinned page-cache/static +//! file range without copying it through userspace. The drained post-handshake +//! plaintext is handed to the serve loop as a prefix, so the kernel RX resumes at the +//! correct record sequence. //! //! **KeyUpdate:** the kernel surfaces a post-handshake non-application-data record as a //! `recvmsg` control message that a plain read returns as `EIO`. [`KtlsStream`] catches diff --git a/crates/httpjet/src/uring/ktls_policy.rs b/crates/httpjet/src/uring/ktls_policy.rs new file mode 100644 index 0000000..e08d23d --- /dev/null +++ b/crates/httpjet/src/uring/ktls_policy.rs @@ -0,0 +1,406 @@ +//! Runtime policy for the optional kernel-TLS transport. +//! +//! `auto` is intentionally conservative: it enables kTLS only when the default +//! route resolves to a physical NIC whose ethtool feature table reports an +//! active `tls-hw-tx-offload`. Unknown topology or probe errors stay on rustls. + +use std::io; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, clap::ValueEnum)] +pub(crate) enum KtlsMode { + #[default] + Auto, + On, + Off, +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct Decision { + pub(crate) enabled: bool, + pub(crate) reason: String, + pub(crate) interface: Option, + pub(crate) driver: Option, +} + +trait Probe { + fn default_interface(&self) -> io::Result; + fn driver(&self, interface: &str) -> io::Result; + fn feature_active(&self, interface: &str, feature: &str) -> io::Result; +} + +pub(crate) fn resolve(mode: KtlsMode) -> anyhow::Result { + resolve_with(mode, cfg!(feature = "ktls"), &SystemProbe) +} + +fn resolve_with(mode: KtlsMode, compiled: bool, probe: &impl Probe) -> anyhow::Result { + match mode { + KtlsMode::Off => Ok(disabled("disabled by --ktls=off", None, None)), + KtlsMode::On if !compiled => { + anyhow::bail!("--ktls=on requires a `--features ktls` build") + } + KtlsMode::On => Ok(Decision { + enabled: true, + reason: "forced by --ktls=on; hardware offload was not required".into(), + interface: None, + driver: None, + }), + KtlsMode::Auto if !compiled => Ok(disabled( + "auto-disabled: binary was built without the ktls feature", + None, + None, + )), + KtlsMode::Auto => { + let interface = match probe.default_interface() { + Ok(interface) if interface != "lo" => interface, + Ok(_) => { + return Ok(disabled( + "auto-disabled: default route is loopback", + Some("lo".into()), + None, + )); + } + Err(error) => { + return Ok(disabled( + format!("auto-disabled: default-route probe failed: {error}"), + None, + None, + )); + } + }; + let driver = match probe.driver(&interface) { + Ok(driver) => driver, + Err(error) => { + return Ok(disabled( + format!("auto-disabled: driver probe failed: {error}"), + Some(interface), + None, + )); + } + }; + if virtual_driver(&driver) { + return Ok(disabled( + format!("auto-disabled: virtual NIC driver {driver}"), + Some(interface), + Some(driver), + )); + } + match probe.feature_active(&interface, "tls-hw-tx-offload") { + Ok(true) => Ok(Decision { + enabled: true, + reason: "auto-enabled: active NIC TLS transmit offload".into(), + interface: Some(interface), + driver: Some(driver), + }), + Ok(false) => Ok(disabled( + "auto-disabled: tls-hw-tx-offload is not active", + Some(interface), + Some(driver), + )), + Err(error) => Ok(disabled( + format!("auto-disabled: ethtool feature probe failed: {error}"), + Some(interface), + Some(driver), + )), + } + } + } +} + +fn disabled( + reason: impl Into, + interface: Option, + driver: Option, +) -> Decision { + Decision { + enabled: false, + reason: reason.into(), + interface, + driver, + } +} + +fn virtual_driver(driver: &str) -> bool { + let driver = driver.to_ascii_lowercase(); + ["gve", "virtio", "veth", "xen", "hv_netvsc", "tun", "tap"] + .iter() + .any(|needle| driver.contains(needle)) +} + +struct SystemProbe; + +impl Probe for SystemProbe { + fn default_interface(&self) -> io::Result { + let routes = std::fs::read_to_string("/proc/net/route")?; + routes + .lines() + .skip(1) + .filter_map(|line| { + let fields: Vec<_> = line.split_ascii_whitespace().collect(); + if fields.len() < 8 || fields[1] != "00000000" { + return None; + } + let flags = u16::from_str_radix(fields[3], 16).ok()?; + if flags & 1 == 0 { + return None; + } + let metric = fields[6].parse::().unwrap_or(u64::MAX); + Some((metric, fields[0].to_owned())) + }) + .min_by_key(|(metric, _)| *metric) + .map(|(_, interface)| interface) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no IPv4 default route")) + } + + fn driver(&self, interface: &str) -> io::Result { + #[repr(C)] + struct DriverInfo { + cmd: u32, + driver: [libc::c_char; 32], + version: [libc::c_char; 32], + fw_version: [libc::c_char; 32], + bus_info: [libc::c_char; 32], + erom_version: [libc::c_char; 32], + reserved2: [libc::c_char; 12], + n_priv_flags: u32, + n_stats: u32, + testinfo_len: u32, + eedump_len: u32, + regdump_len: u32, + } + let mut info: DriverInfo = unsafe { std::mem::zeroed() }; + info.cmd = ETHTOOL_GDRVINFO; + ethtool_ioctl(interface, (&mut info as *mut DriverInfo).cast())?; + c_chars(&info.driver) + } + + fn feature_active(&self, interface: &str, feature: &str) -> io::Result { + #[repr(C)] + struct SsetInfo { + cmd: u32, + reserved: u32, + mask: u64, + count: u32, + } + let mut set = SsetInfo { + cmd: ETHTOOL_GSSET_INFO, + reserved: 0, + mask: 1 << ETH_SS_FEATURES, + count: 0, + }; + ethtool_ioctl(interface, (&mut set as *mut SsetInfo).cast())?; + if set.mask & (1 << ETH_SS_FEATURES) == 0 || set.count == 0 { + return Ok(false); + } + let count = usize::try_from(set.count) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "feature count overflow"))?; + let strings_len = 12usize + .checked_add(count.checked_mul(ETH_GSTRING_LEN).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "feature table overflow") + })?) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "feature table overflow"))?; + let mut strings = vec![0u8; strings_len]; + put_u32(&mut strings, 0, ETHTOOL_GSTRINGS); + put_u32(&mut strings, 4, ETH_SS_FEATURES); + put_u32(&mut strings, 8, set.count); + ethtool_ioctl(interface, strings.as_mut_ptr().cast())?; + let Some(index) = (0..count).find(|index| { + let start = 12 + index * ETH_GSTRING_LEN; + nul_str(&strings[start..start + ETH_GSTRING_LEN]) == feature + }) else { + return Ok(false); + }; + + let blocks = count.div_ceil(32); + let features_len = 8usize + .checked_add(blocks.checked_mul(16).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "feature block overflow") + })?) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "feature block overflow"))?; + let mut features = vec![0u8; features_len]; + put_u32(&mut features, 0, ETHTOOL_GFEATURES); + put_u32(&mut features, 4, blocks as u32); + ethtool_ioctl(interface, features.as_mut_ptr().cast())?; + let block = index / 32; + let active = get_u32(&features, 8 + block * 16 + 8); + Ok(active & (1 << (index % 32)) != 0) + } +} + +fn ethtool_ioctl(interface: &str, data: *mut libc::c_void) -> io::Result<()> { + if interface.is_empty() + || interface.len() >= libc::IFNAMSIZ + || interface.as_bytes().contains(&0) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid interface name", + )); + } + // SAFETY: the socket and ifreq live through ioctl; `data` points at the + // command-specific writable buffer supplied by the caller. + unsafe { + let fd = libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0); + if fd < 0 { + return Err(io::Error::last_os_error()); + } + let mut request: libc::ifreq = std::mem::zeroed(); + for (slot, byte) in request.ifr_name.iter_mut().zip(interface.bytes()) { + *slot = byte as libc::c_char; + } + request.ifr_ifru.ifru_data = data.cast(); + let result = libc::ioctl(fd, SIOCETHTOOL, &mut request); + let error = io::Error::last_os_error(); + libc::close(fd); + if result < 0 { Err(error) } else { Ok(()) } + } +} + +fn c_chars(value: &[libc::c_char]) -> io::Result { + let bytes: Vec = value.iter().map(|byte| *byte as u8).collect(); + let value = nul_str(&bytes); + if value.is_empty() { + Err(io::Error::new( + io::ErrorKind::InvalidData, + "empty driver name", + )) + } else { + Ok(value.to_owned()) + } +} + +fn nul_str(bytes: &[u8]) -> &str { + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + std::str::from_utf8(&bytes[..end]).unwrap_or("") +} + +fn put_u32(buffer: &mut [u8], offset: usize, value: u32) { + buffer[offset..offset + 4].copy_from_slice(&value.to_ne_bytes()); +} + +fn get_u32(buffer: &[u8], offset: usize) -> u32 { + u32::from_ne_bytes( + buffer[offset..offset + 4] + .try_into() + .expect("four-byte field"), + ) +} + +const SIOCETHTOOL: libc::c_ulong = 0x8946; +const ETHTOOL_GDRVINFO: u32 = 0x0000_0003; +const ETHTOOL_GSTRINGS: u32 = 0x0000_001b; +const ETHTOOL_GSSET_INFO: u32 = 0x0000_0037; +const ETHTOOL_GFEATURES: u32 = 0x0000_003a; +const ETH_SS_FEATURES: u32 = 4; +const ETH_GSTRING_LEN: usize = 32; + +#[cfg(test)] +mod tests { + use super::*; + + struct MockProbe { + interface: io::Result, + driver: io::Result, + feature: io::Result, + } + + impl Probe for MockProbe { + fn default_interface(&self) -> io::Result { + clone_result(&self.interface) + } + fn driver(&self, _interface: &str) -> io::Result { + clone_result(&self.driver) + } + fn feature_active(&self, _interface: &str, _feature: &str) -> io::Result { + clone_result(&self.feature) + } + } + + fn clone_result(result: &io::Result) -> io::Result { + result + .as_ref() + .map(Clone::clone) + .map_err(|error| io::Error::new(error.kind(), error.to_string())) + } + + fn probe(driver: &str, feature: bool) -> MockProbe { + MockProbe { + interface: Ok("eth0".into()), + driver: Ok(driver.into()), + feature: Ok(feature), + } + } + + #[test] + fn auto_requires_compiled_physical_active_offload() { + assert!( + !resolve_with(KtlsMode::Auto, false, &probe("ice", true)) + .unwrap() + .enabled + ); + assert!( + !resolve_with(KtlsMode::Auto, true, &probe("ice", false)) + .unwrap() + .enabled + ); + assert!( + resolve_with(KtlsMode::Auto, true, &probe("ice", true)) + .unwrap() + .enabled + ); + } + + #[test] + fn auto_refuses_virtual_drivers_even_if_the_feature_claims_active() { + for driver in ["gve", "virtio_net", "veth", "xen-netfront", "hv_netvsc"] { + let decision = resolve_with(KtlsMode::Auto, true, &probe(driver, true)).unwrap(); + assert!(!decision.enabled, "{driver}"); + assert!(decision.reason.contains("virtual NIC"), "{driver}"); + } + } + + #[test] + fn loopback_and_probe_failures_fail_closed() { + let loopback = MockProbe { + interface: Ok("lo".into()), + driver: Ok("loopback".into()), + feature: Ok(true), + }; + assert!( + !resolve_with(KtlsMode::Auto, true, &loopback) + .unwrap() + .enabled + ); + let failed = MockProbe { + interface: Err(io::Error::new(io::ErrorKind::NotFound, "none")), + driver: Ok("ice".into()), + feature: Ok(true), + }; + assert!(!resolve_with(KtlsMode::Auto, true, &failed).unwrap().enabled); + } + + #[test] + fn on_forces_only_a_compiled_binary_and_off_never_enables() { + assert!(resolve_with(KtlsMode::On, false, &probe("ice", true)).is_err()); + assert!( + resolve_with(KtlsMode::On, true, &probe("gve", false)) + .unwrap() + .enabled + ); + assert!( + !resolve_with(KtlsMode::Off, true, &probe("ice", true)) + .unwrap() + .enabled + ); + } + + #[test] + fn system_probe_reads_current_host_without_enabling_virtual_gve() { + let decision = resolve_with(KtlsMode::Auto, true, &SystemProbe).unwrap(); + if decision.driver.as_deref() == Some("gve") { + assert!(!decision.enabled); + } + } +} diff --git a/crates/httpjet/src/uring/mod.rs b/crates/httpjet/src/uring/mod.rs index 797d0f9..cd053e8 100644 --- a/crates/httpjet/src/uring/mod.rs +++ b/crates/httpjet/src/uring/mod.rs @@ -11,19 +11,27 @@ pub(crate) mod bridge; pub(crate) mod codec; pub(crate) mod directio; +#[cfg(test)] +mod generation_test; pub(crate) mod h3; #[cfg(feature = "ktls")] pub(crate) mod ktls; +pub(crate) mod ktls_policy; pub(crate) mod proxy_protocol; -mod request_body; +pub(crate) mod request_body; +mod unix_path; +pub(crate) mod worker_group; +pub(crate) use worker_group::WorkerGroup; use std::io; use std::net::SocketAddr; +use std::os::fd::AsRawFd; use monoio::io::{AsyncReadRent, AsyncWriteRent, AsyncWriteRentExt}; use monoio::net::{TcpListener, TcpStream}; use socket2::{Domain, Protocol, Socket, Type}; +use crate::serving_generation::{RequestGeneration, ServingView}; use crate::state::ServerState; use bridge::{Bridge, BridgeCtx}; use codec::{ @@ -75,19 +83,97 @@ pub(crate) struct ListenerBinding { /// What each monoio connection handler needs to serve a request: the on-core /// cache-hit fast path (`pipeline::fast_serve`, no runtime hop) plus the bridge to /// the tokio side-runtime for everything the fast path declines (miss / dynamic). -/// `holder` gives the live `ServerState` generation (SIGHUP-safe); `listener_name` -/// is the routing key for vhost resolution. +/// `holder` follows compatible application generations, while retaining the +/// accepted connection's trust epoch. `listener_name` is the vhost routing key. #[derive(Clone)] pub(crate) struct CoreHandler { bridge: Bridge, - holder: Arc>, + holder: ServingView, listener_name: Arc, } impl CoreHandler { + fn pin_connection(&self) -> Self { + Self { + holder: self.holder.pin_connection(), + ..self.clone() + } + } + + #[cfg(feature = "otel")] + fn trace_request( + &self, + ctx: &BridgeCtx, + req: &mut hj_core::Request, + ) -> Option { + if !crate::otel::enabled() { + return None; + } + let state = req + .extensions() + .get::() + .map(|snapshot| arc_swap::Guard::from_inner(snapshot.0.clone())) + .unwrap_or_else(|| self.holder.load()); + let direct_peer = !ctx.peer_unix + && state + .server + .listeners + .iter() + .find(|l| l.name == self.listener_name.as_ref()) + .is_some_and(|l| !l.proxy_protocol); + let parent = crate::otel::inbound_parent(req.headers_mut(), ctx.peer.ip(), direct_peer); + let trace = crate::otel::RequestTrace::new(parent); + req.extensions_mut() + .insert(crate::otel::TransportContext(trace.context())); + Some(trace) + } + + async fn dispatch_h1( + &self, + ctx: BridgeCtx, + mut req: hj_core::Request, + upgrade: bool, + ) -> Option { + req.extensions_mut() + .insert(RequestGeneration(self.holder.load_full())); + if !upgrade && let Some(response) = self.fast(&ctx, &req).await { + return Some(bridge::fast_response(response, ctx.direct_file_egress).await); + } + #[cfg(feature = "otel")] + crate::otel::execution_path(false); + self.bridge.dispatch(req, ctx).await + } + + async fn dispatch_h2(&self, ctx: BridgeCtx, mut req: hj_core::Request) -> hj_core::Response { + req.extensions_mut() + .insert(RequestGeneration(self.holder.load_full())); + #[cfg(feature = "otel")] + let trace = self.trace_request(&ctx, &mut req); + let future = async { + if let Some(response) = self.fast(&ctx, &req).await { + return response; + } + #[cfg(feature = "otel")] + crate::otel::execution_path(false); + self.bridge.dispatch_response(req, ctx).await + }; + #[cfg(feature = "otel")] + if let Some(trace) = trace { + let mut response = crate::otel::in_context(trace.context(), future).await; + trace.response_head(response.status()); + response.extensions_mut().insert(trace.completion()); + return response; + } + future.await + } + /// Try the on-core cache-hit fast path; `Some(resp)` if served without the bridge. async fn fast(&self, ctx: &BridgeCtx, req: &hj_core::Request) -> Option { - let st = self.holder.load_full(); + let st = req + .extensions() + .get::() + .map(|snapshot| snapshot.0.clone()) + .unwrap_or_else(|| self.holder.load_full()); // Stamp Date here (insert-if-absent): the page cache strips the stored Date // expecting the serve boundary to re-add one, and the uring writers never do — the // tokio path stamps at server::stamp_date, this is its on-core fast-path twin. @@ -104,7 +190,11 @@ impl CoreHandler { req, ) .await - .map(hj_core::stamp_date) + .map(|response| { + #[cfg(feature = "otel")] + crate::otel::execution_path(true); + hj_core::stamp_date(response) + }) } } @@ -113,6 +203,8 @@ impl CoreHandler { /// for live connections to finish (H1 closes idle keep-alives, H2 GOAWAYs + drains) /// before the process exits. Mirrors the tokio path's bounded graceful shutdown. const URING_DRAIN_GRACE: std::time::Duration = std::time::Duration::from_secs(15); +#[cfg(all(test, feature = "otel"))] +mod otel_test; const WORKER_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); pub(super) type WorkerReadyTx = std::sync::mpsc::Sender>; @@ -176,6 +268,8 @@ async fn accept_drain_loop( shutdown: CancellationToken, secure: bool, core: CoreHandler, + mut accept_stopped: worker_group::AcceptRetirement, + raced: Option>, mut on_accept: F, ) where F: FnMut(TcpStream, SocketAddr) -> Fut, @@ -219,7 +313,16 @@ async fn accept_drain_loop( }; loop { crate::memtrim::collect_if_requested_on_thread(); - let next = async { + // The default multishot path does not allocate a cancellation handle + // per accepted connection; only the single-shot fallback needs one. + let single_canceller = std::cell::RefCell::new(None); + let mut next = std::pin::pin!(async { + if let Some(queued) = &raced { + if let Ok(stream) = queued.try_recv() { + let peer = stream.peer_addr()?; + return TcpStream::from_std(stream).map(|stream| (stream, peer)); + } + } loop { match multi.as_mut() { Some(stream) => match stream.next().await { @@ -243,14 +346,31 @@ async fn accept_drain_loop( continue; } }, - None => return listener.accept().await, + None => { + let canceller = monoio::io::Canceller::new(); + let cancel_handle = canceller.handle(); + *single_canceller.borrow_mut() = Some(canceller); + return listener.cancelable_accept(cancel_handle).await; + } } } - }; + }); monoio::select! { biased; - _ = shutdown.cancelled() => break, - accepted = next => { + _ = shutdown.cancelled() => { + let pending = single_canceller.borrow_mut().take(); + if let Some(canceller) = pending { + // Keep the accept future alive through cancellation. Dropping + // it would leave kernel completion/descriptor cleanup pending + // while the successor starts consuming this accept queue. + canceller.cancel(); + if let Ok((stream, _)) = next.await { + transfer_accepted(&accept_stopped, stream); + } + } + break; + }, + accepted = &mut next => { match accepted { Ok((stream, peer)) => { let state = core.holder.load(); @@ -290,6 +410,25 @@ async fn accept_drain_loop( } } } + // Stop the kernel accept SQE before waiting on existing responses. Merely + // breaking the user-space loop leaves a multishot accept armed throughout + // the drain, consuming connections from a socket a successor may inherit. + if let Some(accepts) = multi.as_mut() { + accepts.cancel(); + while let Some(completion) = accepts.next().await { + if let Ok(stream) = completion { + transfer_accepted(&accept_stopped, stream); + } + } + } + drop(multi); + drop(listener); + if let Some(queued) = raced { + for stream in queued.try_iter() { + accept_stopped.transfer(stream); + } + } + accept_stopped.cancel(); tracing::info!( core = core_idx, secure, @@ -312,6 +451,14 @@ async fn accept_drain_loop( } } +fn transfer_accepted(retirement: &worker_group::AcceptRetirement, stream: TcpStream) { + use std::os::fd::{FromRawFd, IntoRawFd}; + // No read/write operation has been issued on a just-accepted stream. Taking + // its sole fd owner detaches it from this monoio runtime before thread transfer. + let stream = unsafe { std::net::TcpStream::from_raw_fd(stream.into_raw_fd()) }; + retirement.transfer(stream); +} + /// Dev smoke hook for H1 over the real pipeline with a minimal ServerState. /// The production path is `serve` in `main.rs`, which builds the full /// state once and calls `spawn_uring_http` / `spawn_uring_https`. @@ -361,7 +508,7 @@ pub(crate) fn serve_uring( .expect("bridge ServerState construction is config-validated upstream"); let holder = Arc::new(arc_swap::ArcSwap::from(state)); let admission = pipeline_admission(holder.clone()); - spawn_uring_http( + let http_workers = spawn_uring_http( holder, listener_name, http_addr, @@ -370,6 +517,7 @@ pub(crate) fn serve_uring( admission, ListenerBinding::default(), )?; + http_workers.activate(); // Keep this runtime alive to drive the bridge; the monoio cores run independently. std::future::pending::<()>().await; Ok::<(), anyhow::Error>(()) @@ -380,18 +528,20 @@ pub(crate) fn serve_uring( /// CURRENT tokio runtime (each request loads the live `ServerState` generation /// from `holder`, so SIGHUP reloads are honored) + one pinned-core monoio /// io_uring runtime per worker, each adopting its own `SO_REUSEPORT` socket. The -/// monoio cores run on detached threads (process exit tears them down); the -/// returned `Ok(())` means the cores are up. Shared by `serve` (full +/// returned group owns the monoio threads and stops/joins them on drop; success +/// means every core has acknowledged readiness, but none accepts traffic until +/// the caller activates the group. Shared by `serve` (full /// state) and the `serve_uring` smoke hook (minimal state). pub(crate) fn spawn_uring_http( - holder: Arc>, + holder: impl Into, listener_name: Arc, http_addr: SocketAddr, workers: usize, inherited: Option>, admission: bridge::BridgeAdmission, binding: ListenerBinding, -) -> anyhow::Result<()> { +) -> anyhow::Result { + let holder = holder.into(); let shutdown = holder.load().shutdown.clone(); let active_conns = holder.load().metrics.active_conns.clone(); let bridge = build_pipeline_bridge(holder.clone(), listener_name.clone(), admission); @@ -407,15 +557,25 @@ pub(crate) fn spawn_uring_http( let listeners = uring_listeners(inherited, http_addr, workers)?; let worker_count = listeners.len(); let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let mut group = WorkerGroup::for_tcp_epoch( + &shutdown, + core.holder.trust_epoch(), + worker_group::TcpListenerId { + name: core.listener_name.clone(), + tls: false, + }, + ); for (core_i, std_listener) in listeners.into_iter().enumerate() { let core = core.clone(); - let shutdown = shutdown.clone(); let active_conns = active_conns.clone(); let ready = ready_tx.clone(); - std::thread::Builder::new() - .name(format!("hj-uring-{core_i}")) - .stack_size(crate::RUNTIME_THREAD_STACK_BYTES) - .spawn(move || { + let activation = group.activation_gate(); + let accept_stopped = group.register_acceptor(&std_listener)?; + group.spawn( + std::thread::Builder::new() + .name(format!("hj-uring-{core_i}")) + .stack_size(crate::RUNTIME_THREAD_STACK_BYTES), + move |shutdown| { maybe_pin_core_thread(core_i, worker_count); per_core_bridged( core_i, @@ -425,13 +585,16 @@ pub(crate) fn spawn_uring_http( shutdown, active_conns, ready, + activation, binding, + accept_stopped, ) - })?; + }, + )?; } drop(ready_tx); wait_for_worker_readiness("HTTP", worker_count, ready_rx)?; - Ok(()) + Ok(group) } /// (#296) Kill switch for per-core thread pinning (`--no-core-pinning`). @@ -548,7 +711,7 @@ fn uring_listeners( /// runs `pipeline::handle` with the per-connection context (peer/TLS/mTLS/SNI). /// Shared by the plaintext (`spawn_uring_http`) and TLS (`spawn_uring_https`) paths. fn build_pipeline_bridge( - holder: Arc>, + holder: impl Into, listener_name: Arc, admission: bridge::BridgeAdmission, ) -> Bridge { @@ -562,10 +725,37 @@ fn build_pipeline_bridge( // The pipeline only reads the name; share the Arc instead of re-allocating a String // for every bridged request (the closure runs concurrently across tokio workers). let lname = listener_name; - bridge::spawn_on_current_with_admission(admission, move |req, ctx: BridgeCtx| { - let state = holder.load_full(); + let view = holder.into(); + bridge::spawn_on_current_with_admission(admission, move |mut req, ctx: BridgeCtx| { + let state = req + .extensions_mut() + .remove::() + .map(|snapshot| snapshot.0) + .unwrap_or_else(|| view.load_full()); let lname = lname.clone(); async move { + // H1 already calls the same helper before bridging so it can retain + // its historical refusal/connection semantics. H2/H3 arrive here + // as one lease-backed full body; a second H1 pass is a no-op after + // successful decoding removed Content-Encoding. + let req = match request_body::finish_bridged_request( + req, + &state.body_budget, + state.serve_config.max_req_body_size, + state.request_decompression, + ) + .await + { + Ok(req) => req, + Err(status) => { + return hj_core::stamp_date( + http::Response::builder() + .status(status) + .body(hj_core::Body::Empty) + .expect("static request-decompression response"), + ); + } + }; // Stamp Date (insert-if-absent) on EVERY bridged response (H1/H2/H3): the uring // writers + native h2/h3 encoders don't add it and the cache strips the stored // one. Mirrors the tokio service boundary (server::stamp_date) so the two @@ -603,7 +793,7 @@ pub(crate) fn pipeline_admission( /// H1/H2 paths. This is the sole production H3 transport. /// Must be called from within the ambient tokio runtime (the bridge receiver runs there). pub(crate) fn spawn_uring_h3( - holder: Arc>, + holder: impl Into, listener_name: Arc, https_addr: SocketAddr, workers: usize, @@ -611,13 +801,15 @@ pub(crate) fn spawn_uring_h3( require_client_cert: bool, inherited: Option>, admission: bridge::BridgeAdmission, -) -> anyhow::Result<()> { +) -> anyhow::Result<(WorkerGroup, h3::QuicReloadHandle)> { + let holder = holder.into(); let shutdown = holder.load().shutdown.clone(); let runtime = { let st = holder.load(); let active_conns = st.metrics.active_conns.clone(); drop(st); - let config_holder = holder.clone(); + let serving_view = holder.clone(); + let config_holder = serving_view.clone(); h3::H3RuntimeConfig::new( move || { let state = config_holder.load(); @@ -636,9 +828,10 @@ pub(crate) fn spawn_uring_h3( b.body_budget.clone() }, ) + .with_serving_view(serving_view) }; let bridge = build_pipeline_bridge(holder, listener_name, admission); - h3::serve_h3_pipeline( + let group = h3::serve_h3_pipeline( https_addr, workers, rustls_cfg, @@ -648,7 +841,7 @@ pub(crate) fn spawn_uring_h3( inherited, shutdown, )?; - Ok(()) + Ok(group) } /// Spawn the io_uring TLS-HTTP transport on `https_addr`: one pinned-core monoio @@ -658,8 +851,9 @@ pub(crate) fn spawn_uring_h3( /// pipeline bridge. mTLS (clientVerify=2) is enforced at the application layer /// exactly as the tokio path: a non-internal peer presenting no client cert is /// refused post-handshake. +/// Returns a prepared group; call `activate` before expecting any handshake. pub(crate) fn spawn_uring_https( - holder: Arc>, + holder: impl Into, listener_name: Arc, https_addr: SocketAddr, workers: usize, @@ -669,7 +863,8 @@ pub(crate) fn spawn_uring_https( inherited: Option>, admission: bridge::BridgeAdmission, binding: ListenerBinding, -) -> anyhow::Result<()> { +) -> anyhow::Result { + let holder = holder.into(); let shutdown = holder.load().shutdown.clone(); let active_conns = holder.load().metrics.active_conns.clone(); let bridge = build_pipeline_bridge(holder.clone(), listener_name.clone(), admission); @@ -681,17 +876,27 @@ pub(crate) fn spawn_uring_https( let listeners = uring_listeners(inherited, https_addr, workers)?; let worker_count = listeners.len(); let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let mut group = WorkerGroup::for_tcp_epoch( + &shutdown, + core.holder.trust_epoch(), + worker_group::TcpListenerId { + name: core.listener_name.clone(), + tls: true, + }, + ); for (core_i, std_listener) in listeners.into_iter().enumerate() { let core = core.clone(); - let shutdown = shutdown.clone(); let active_conns = active_conns.clone(); let acceptor: monoio_rustls::TlsAcceptor = tls_config.clone().into(); let ktls_template = ktls_template.clone(); let ready = ready_tx.clone(); - std::thread::Builder::new() - .name(format!("hj-uring-tls-{core_i}")) - .stack_size(crate::RUNTIME_THREAD_STACK_BYTES) - .spawn(move || { + let activation = group.activation_gate(); + let accept_stopped = group.register_acceptor(&std_listener)?; + group.spawn( + std::thread::Builder::new() + .name(format!("hj-uring-tls-{core_i}")) + .stack_size(crate::RUNTIME_THREAD_STACK_BYTES), + move |shutdown| { maybe_pin_core_thread(core_i, worker_count); per_core_https( core_i, @@ -704,13 +909,16 @@ pub(crate) fn spawn_uring_https( shutdown, active_conns, ready, + activation, binding, + accept_stopped, ) - })?; + }, + )?; } drop(ready_tx); wait_for_worker_readiness("HTTPS", worker_count, ready_rx)?; - Ok(()) + Ok(group) } fn per_core_https( @@ -724,7 +932,9 @@ fn per_core_https( shutdown: CancellationToken, _active_conns: Arc, ready: WorkerReadyTx, + activation: worker_group::ActivationGate, binding: ListenerBinding, + accept_stopped: worker_group::AcceptRetirement, ) { let mut rt = match build_core_runtime() { Ok(runtime) => runtime, @@ -742,10 +952,12 @@ fn per_core_https( return; } }; - let _ = ready.send(Ok(())); + if ready.send(Ok(())).is_err() || !activation.wait().await { + return; + } tracing::info!(core = core_idx, "uring tls: per-core runtime serving (H1/H2 over TLS → real pipeline)"); - accept_drain_loop(core_idx, listener, shutdown.clone(), true, core.clone(), move |stream, peer| { - handle_tls_bridged(stream, peer, local, core.clone(), acceptor.clone(), require_client_cert, ktls_template.clone(), shutdown.clone(), binding) + accept_drain_loop(core_idx, listener, shutdown.clone(), true, core.clone(), accept_stopped, activation.raced_connections(), move |stream, peer| { + handle_tls_bridged(stream, peer, local, core.pin_connection(), acceptor.clone(), require_client_cert, ktls_template.clone(), shutdown.clone(), binding) }) .await; }); @@ -894,6 +1106,7 @@ async fn handle_tls_bridged( local, proto, is_tls: true, + direct_file_egress: false, peer_unix: false, mtls_required: require_client_cert, sni, @@ -931,7 +1144,19 @@ async fn handle_tls_bridged( serve_h2_bridged(ks, prefix, ctx, core, shutdown, Some(fd)) .await } - _ => handle_h1_bridged(ks, prefix, ctx, core, shutdown).await, + _ => { + let mut ktls_ctx = ctx; + ktls_ctx.direct_file_egress = true; + handle_h1_bridged( + ks, + prefix, + ktls_ctx, + core, + shutdown, + Some(fd), + ) + .await + } } return; } @@ -962,7 +1187,7 @@ async fn handle_tls_bridged( ); match proto { Proto::Http2 => serve_h2_bridged(stream, prefix, ctx, core, shutdown, None).await, - _ => handle_h1_bridged(stream, prefix, ctx, core, shutdown).await, + _ => handle_h1_bridged(stream, prefix, ctx, core, shutdown, None).await, } } @@ -974,7 +1199,9 @@ fn per_core_bridged( shutdown: CancellationToken, _active_conns: Arc, ready: WorkerReadyTx, + activation: worker_group::ActivationGate, binding: ListenerBinding, + accept_stopped: worker_group::AcceptRetirement, ) { let mut rt = match build_core_runtime() { Ok(runtime) => runtime, @@ -992,10 +1219,12 @@ fn per_core_bridged( return; } }; - let _ = ready.send(Ok(())); + if ready.send(Ok(())).is_err() || !activation.wait().await { + return; + } tracing::info!(core = core_idx, "uring serve: per-core runtime serving (H1/h2c → real pipeline)"); - accept_drain_loop(core_idx, listener, shutdown.clone(), false, core.clone(), move |stream, peer| { - handle_conn_bridged(stream, peer, local, core.clone(), shutdown.clone(), binding) + accept_drain_loop(core_idx, listener, shutdown.clone(), false, core.clone(), accept_stopped, activation.raced_connections(), move |stream, peer| { + handle_conn_bridged(stream, peer, local, core.pin_connection(), shutdown.clone(), binding, false) }) .await; }); @@ -1019,8 +1248,13 @@ async fn handle_conn_bridged( core: CoreHandler, shutdown: CancellationToken, binding: ListenerBinding, + peer_unix: bool, ) where - S: monoio::io::AsyncReadRent + monoio::io::AsyncWriteRent + monoio::io::Split + 'static, + S: monoio::io::AsyncReadRent + + monoio::io::AsyncWriteRent + + monoio::io::Split + + AsRawFd + + 'static, { let state = core.holder.load(); let header_read_timeout = state.serve_config.header_read_timeout; @@ -1067,24 +1301,20 @@ async fn handle_conn_bridged( } }; if is_h2 { - serve_h2_bridged( - stream, - acc, - BridgeCtx::plain(peer, local, Proto::Http2), - core, - shutdown, - None, - ) - .await; + let ctx = if peer_unix { + BridgeCtx::unix(local, Proto::Http2) + } else { + BridgeCtx::plain(peer, local, Proto::Http2) + }; + serve_h2_bridged(stream, acc, ctx, core, shutdown, None).await; } else { - handle_h1_bridged( - stream, - acc, - BridgeCtx::plain(peer, local, Proto::Http1), - core, - shutdown, - ) - .await; + let sendfile_fd = (!peer_unix).then(|| stream.as_raw_fd()); + let ctx = if peer_unix { + BridgeCtx::unix(local, Proto::Http1) + } else { + BridgeCtx::plain(peer, local, Proto::Http1) + }; + handle_h1_bridged(stream, acc, ctx, core, shutdown, sendfile_fd).await; } } @@ -1119,13 +1349,7 @@ async fn serve_h2_bridged( let service = move |req: hj_core::Request| { let core = core.clone(); let ctx = ctx.clone(); - async move { - // On-core cache-hit fast path (no bridge hop); else dispatch to the pipeline. - if let Some(resp) = core.fast(&ctx, &req).await { - return resp; - } - core.bridge.dispatch_response(req, ctx).await - } + async move { core.dispatch_h2(ctx, req).await } }; // `ktls_fd` (Some only for a kTLS connection) lets the h2 flush writev plaintext directly // from the OutQueue to the kernel-TLS socket (zero-copy); None ⇒ the coalesce path. @@ -1161,6 +1385,7 @@ async fn handle_h1_bridged( ctx: BridgeCtx, core: CoreHandler, shutdown: CancellationToken, + sendfile_fd: Option, ) where S: monoio::io::AsyncReadRent + monoio::io::AsyncWriteRent + monoio::io::Split + 'static, { @@ -1439,6 +1664,7 @@ async fn handle_h1_bridged( body_lease, &state.body_budget, max_body, + state.request_decompression, ) { Ok(bytes) => bytes, Err(status) => { @@ -1475,39 +1701,23 @@ async fn handle_h1_bridged( req.extensions_mut().insert(upgrade); upgrade_ready = Some(ready); } - // On-core cache-hit fast path first (no bridge hop); else dispatch across the bridge. - let resp: bridge::BridgeResp = if upgrade_ready.is_none() { - match core.fast(&ctx, &req).await { - Some(r) => { - let (mut p, b) = r.into_parts(); - // The fast path is buffered; a failed/short file read becomes a clean 502 before - // any success headers are committed. - let (body_bytes, truncated) = bridge::buffer_body(b).await; - if truncated { - bridge::bad_gateway() - } else { - bridge::BridgeResp { - status: p.status, - headers: p.headers, - body: bridge::BridgeBody::Full(body_bytes), - bw_rate: p - .extensions - .remove::() - .map(|b| b.0), - } - } - } - _ => match core.bridge.dispatch(req, ctx.clone()).await { - Some(br) => br, - None => return, - }, - } - } else { - match core.bridge.dispatch(req, ctx.clone()).await { - Some(br) => br, - None => return, - } + #[cfg(feature = "otel")] + let mut trace = core.trace_request(&ctx, &mut req); + let dispatch = core.dispatch_h1(ctx.clone(), req, upgrade_ready.is_some()); + #[cfg(feature = "otel")] + let response = match &trace { + Some(trace) => crate::otel::in_context(trace.context(), dispatch).await, + None => dispatch.await, + }; + #[cfg(not(feature = "otel"))] + let response = dispatch.await; + let Some(resp) = response else { + return; }; + #[cfg(feature = "otel")] + if let Some(trace) = &trace { + trace.response_head(resp.status); + } if resp.status == http::StatusCode::SWITCHING_PROTOCOLS { let Some(mut ready) = upgrade_ready else { write_status_close(&mut stream, 502, "Bad Gateway").await; @@ -1522,6 +1732,10 @@ async fn handle_h1_bridged( if written.is_err() { return; } + #[cfg(feature = "otel")] + if let Some(trace) = trace.take() { + trace.finish("upgraded"); + } relay_h1_upgrade(stream, std::mem::take(&mut acc), upgrade, &shutdown).await; return; } @@ -1549,9 +1763,20 @@ async fn handle_h1_bridged( if throttle.as_ref().is_none_or(|t| t.rate_bps() != want_rate) { throttle = hj_http::BandwidthThrottle::new(want_rate); } - let must_close = - write_h1_response(&mut stream, resp, is_head, keep_alive, &mut throttle).await; - if must_close { + let outcome = write_h1_response( + &mut stream, + resp, + is_head, + keep_alive, + &mut throttle, + sendfile_fd, + ) + .await; + #[cfg(feature = "otel")] + if let Some(trace) = trace.take() { + trace.finish(if outcome.failed { "error" } else { "complete" }); + } + if outcome.close { let _ = stream.shutdown().await; return; } @@ -2001,17 +2226,33 @@ mod early_hints_tests { } } +struct WriteOutcome { + close: bool, + #[allow(dead_code)] // observed by optional telemetry + failed: bool, +} +impl WriteOutcome { + fn new(failed: bool, keep_alive: bool) -> Self { + Self { + close: failed || !keep_alive, + failed, + } + } +} + async fn write_h1_response( stream: &mut S, - resp: bridge::BridgeResp, + mut resp: bridge::BridgeResp, is_head: bool, keep_alive: bool, throttle: &mut Option, -) -> bool + sendfile_fd: Option, +) -> WriteOutcome where S: AsyncWriteRent, { - match resp.body { + let completion = resp.completion.take(); + let outcome = match resp.body { bridge::BridgeBody::Full(body) => { let head = serialize_h1_response_head( resp.status, @@ -2028,7 +2269,21 @@ where } else { write_h1_vectored(stream, vec![bytes::Bytes::from(head), body]).await }; - result.is_err() || !keep_alive + WriteOutcome::new(result.is_err(), keep_alive) + } + bridge::BridgeBody::File(file) => { + let result = write_h1_file( + stream, + resp.status, + &resp.headers, + file, + is_head, + keep_alive, + throttle, + sendfile_fd, + ) + .await; + WriteOutcome::new(result.is_err(), keep_alive) } bridge::BridgeBody::Stream { rx, len } => { write_h1_stream( @@ -2043,6 +2298,150 @@ where ) .await } + }; + if let Some(completion) = completion { + completion.finish(if outcome.failed { + hj_core::ResponseEnd::Error + } else { + hj_core::ResponseEnd::Complete + }); + } + outcome +} + +/// Send a pinned file/range over plain TCP or TLS 1.3 kTLS H1. `sendfile(2)` is attempted in +/// bounded chunks; unsupported descriptor/filesystem combinations fall back at +/// the exact current offset to monoio positional reads, preserving correctness +/// even after a partial zero-copy transfer. +async fn write_h1_file( + stream: &mut S, + status: http::StatusCode, + headers: &http::HeaderMap, + mut body: hj_core::FileBody, + is_head: bool, + keep_alive: bool, + throttle: &mut Option, + sendfile_fd: Option, +) -> io::Result<()> +where + S: AsyncWriteRent, +{ + use std::os::fd::BorrowedFd; + + let (start, len) = body.range.map_or((0, body.len), |(start, end)| { + (start, end.saturating_sub(start) + 1) + }); + let head = serialize_h1_stream_head(status, headers, Some(len), is_head, keep_alive); + stream.write_all(head).await.0?; + if hj_core::response_body_forbidden(is_head, status) || len == 0 { + return Ok(()); + } + + let file = match body.file.take() { + Some(file) => monoio::fs::File::from_std(file)?, + None => monoio::fs::File::open(&body.path).await?, + }; + let mut offset = start; + let end = start + .checked_add(len) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "file range overflow"))?; + let mut use_sendfile = sendfile_fd.is_some_and(enable_nonblocking_sendfile); + const CHUNK: usize = 1024 * 1024; + + while offset < end { + let chunk = if throttle.is_some() { 64 * 1024 } else { CHUNK }; + let want = (end - offset).min(chunk as u64) as usize; + if use_sendfile { + let out_fd = sendfile_fd.expect("enabled sendfile has an output fd"); + // SAFETY: the connection owns `out_fd` for this entire response and + // the file object owns its descriptor until the loop completes. + let sent = rustix::fs::sendfile( + unsafe { BorrowedFd::borrow_raw(out_fd) }, + unsafe { BorrowedFd::borrow_raw(file.as_raw_fd()) }, + Some(&mut offset), + want, + ); + match sent { + Ok(0) => { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "sendfile reached EOF before Content-Length", + )); + } + Ok(written) => { + observe_h1_sendfile(written); + if let Some(bucket) = throttle.as_mut() { + let wait = bucket.acquire(written as u64); + if wait > 0 { + monoio::time::sleep(std::time::Duration::from_micros(wait)).await; + } + } + continue; + } + Err(error) if error == rustix::io::Errno::INTR => continue, + Err(error) if error == rustix::io::Errno::AGAIN => { + monoio::time::sleep(std::time::Duration::from_micros(100)).await; + continue; + } + Err( + rustix::io::Errno::INVAL + | rustix::io::Errno::NOSYS + | rustix::io::Errno::OPNOTSUPP, + ) => { + use_sendfile = false; + continue; + } + Err(error) => return Err(io::Error::from_raw_os_error(error.raw_os_error())), + } + } + + let (read, buffer) = file.read_at(vec![0u8; want], offset).await; + let read = read?; + if read == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "file reached EOF before Content-Length", + )); + } + if let Some(bucket) = throttle.as_mut() { + let wait = bucket.acquire(read as u64); + if wait > 0 { + monoio::time::sleep(std::time::Duration::from_micros(wait)).await; + } + } + stream + .write_all(bytes::Bytes::from(buffer).slice(..read)) + .await + .0?; + offset += read as u64; + } + Ok(()) +} + +#[cfg(test)] +static H1_SENDFILE_BYTES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +#[inline] +fn observe_h1_sendfile(written: usize) { + #[cfg(test)] + H1_SENDFILE_BYTES.fetch_add(written as u64, std::sync::atomic::Ordering::Relaxed); + #[cfg(not(test))] + let _ = written; +} + +/// `sendfile(2)` itself is synchronous. Monoio may own a blocking socket fd, +/// which would pin the entire thread-per-core worker when a client stops +/// reading. Switch the shared file description to nonblocking before the first +/// call; monoio's socket operations and the direct-write wrapper already +/// support this mode. +fn enable_nonblocking_sendfile(fd: std::os::fd::RawFd) -> bool { + // SAFETY: `fd` is the live connection descriptor and both fcntl operations + // only inspect/update its status flags. + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + flags >= 0 + && (flags & libc::O_NONBLOCK != 0 + || libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) == 0) } } @@ -2088,18 +2487,18 @@ async fn write_h1_stream( is_head: bool, keep_alive: bool, throttle: &mut Option, -) -> bool +) -> WriteOutcome where S: AsyncWriteRent, { let head = serialize_h1_stream_head(status, headers, len, is_head, keep_alive); let (wres, _h) = stream.write_all(head).await; if wres.is_err() { - return true; + return WriteOutcome::new(true, keep_alive); } if hj_core::response_body_forbidden(is_head, status) { rx.close(); - return !keep_alive; + return WriteOutcome::new(false, keep_alive); } let chunked = len.is_none(); while let Some(item) = rx.recv().await { @@ -2131,10 +2530,10 @@ where stream.write_all(b).await.0.map(|_| ()) }; if result.is_err() { - return true; + return WriteOutcome::new(true, keep_alive); } } - Err(()) => return true, // mid-stream upstream abort → close (framing desynced) + Err(()) => return WriteOutcome::new(true, keep_alive), // upstream abort } } if chunked { @@ -2142,10 +2541,10 @@ where .write_all(bytes::Bytes::from_static(b"0\r\n\r\n")) .await; if w.is_err() { - return true; + return WriteOutcome::new(true, keep_alive); } } - !keep_alive + WriteOutcome::new(false, keep_alive) } /// Serialize the HEAD of a STREAMED H1 response. `len: Some(n)` ⇒ `content-length: n`; @@ -2450,6 +2849,51 @@ mod chunked_tests { use super::codec::*; use super::*; + #[test] + fn multishot_cancel_observes_terminal_and_cannot_touch_reused_slot() { + let socket = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = socket.local_addr().unwrap(); + socket.set_nonblocking(true).unwrap(); + let inherited = socket.try_clone().unwrap(); + let mut runtime = build_core_runtime().unwrap(); + runtime.block_on(async move { + let listener = TcpListener::from_std(socket).unwrap(); + let mut old = listener.accept_multi().unwrap(); + let client = std::thread::spawn(move || std::net::TcpStream::connect(address).unwrap()); + let accepted = old.next().await.unwrap().unwrap(); + old.cancel(); + monoio::time::timeout(std::time::Duration::from_secs(2), async { + while let Some(completion) = old.next().await { + match completion { + Ok(connection) => drop(connection), + Err(error) => assert_eq!(error.raw_os_error(), Some(libc::ECANCELED)), + } + } + }) + .await + .expect("kernel must acknowledge multishot cancellation"); + // A fresh op can reuse the terminal operation's slab slot. All old + // stream methods must remain inert rather than detach/cancel it. + let successor = TcpListener::from_std(inherited).unwrap(); + let mut replacement = successor.accept_multi().unwrap(); + assert!(old.next().await.is_none()); + old.cancel(); + drop(old); + drop(listener); + let next = std::thread::spawn(move || std::net::TcpStream::connect(address).unwrap()); + let connection = + monoio::time::timeout(std::time::Duration::from_secs(2), replacement.next()) + .await + .expect("old stream must not cancel replacement") + .unwrap() + .unwrap(); + drop(connection); + drop(accepted); + drop(client.join().unwrap()); + drop(next.join().unwrap()); + }); + } + /// (#334) The monoio-fork multishot accept: one armed SQE yields every /// inbound connection; peer addrs come from getpeername (multishot CQEs /// carry no sockaddr); dropping the stream cancels the armed SQE without @@ -2639,12 +3083,200 @@ Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ runtime, CoreHandler { bridge, - holder, + holder: ServingView::new(holder), + listener_name: Arc::from("test"), + }, + ) + } + + #[cfg(feature = "ktls")] + fn ktls_file_test_core( + root: &std::path::Path, + source: std::path::PathBuf, + source_len: u64, + completion: Option>, + ) -> ( + tokio::runtime::Runtime, + CoreHandler, + Arc, + ) { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .unwrap(); + let state = runtime.block_on(async { + std::fs::create_dir_all(root.join("logs")).unwrap(); + let mut config = hj_core::config::ServerConfig::default(); + config.server_root = root.to_path_buf(); + config.tuning.max_keep_alive_req = 4; + crate::state::ServerState::new( + Arc::new(config), + None, + None, + None, + Arc::new(hj_compress::PageDictRegistry::empty()), + 1, + crate::state::XfCapsuleConfig::disabled(), + None, + false, + None, + false, + crate::state::RewriteTuning::default(), + ) + .unwrap() + }); + let observed_direct = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let observed_in_handler = observed_direct.clone(); + let bridge = bridge::spawn_bridge(1, move |req: hj_core::Request, ctx| { + let source = source.clone(); + let completion = completion.clone(); + observed_in_handler.store(ctx.direct_file_egress, std::sync::atomic::Ordering::Relaxed); + async move { + if req.uri().path() == "/short" { + return http::Response::new(hj_core::Body::Full(bytes::Bytes::from_static( + b"short-after-key-update", + ))); + } + let is_range = req.uri().path() == "/range"; + let mut response = http::Response::builder() + .status(if is_range { + http::StatusCode::PARTIAL_CONTENT + } else { + http::StatusCode::OK + }) + .body(hj_core::Body::File(hj_core::FileBody { + path: source.clone(), + file: Some(std::fs::File::open(&source).unwrap()), + len: source_len, + range: is_range.then_some((117, source_len - 219)), + cached: None, + })) + .unwrap(); + // Keep the transfer live long enough for the client to inject a + // KeyUpdate while sendfile owns the response write side. + response + .extensions_mut() + .insert(crate::pipeline::PerConnBandwidth(2 * 1024 * 1024)); + if let Some(completion) = completion { + response + .extensions_mut() + .insert(hj_core::ResponseCompletion::new(move |end| { + let _ = completion.send(end); + })); + } + response + } + }) + .unwrap(); + let holder = Arc::new(arc_swap::ArcSwap::from(state)); + ( + runtime, + CoreHandler { + bridge, + holder: ServingView::new(holder), listener_name: Arc::from("test"), }, + observed_direct, ) } + #[cfg(feature = "ktls")] + fn ktls_test_configs( + root: &std::path::Path, + ) -> ( + rustls::pki_types::CertificateDer<'static>, + monoio_rustls::TlsAcceptor, + Arc, + ) { + hj_tls::install_crypto_provider().unwrap(); + let signed = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap(); + let certificate = signed.cert.der().clone(); + let cert_file = root.join("cert.pem"); + let key_file = root.join("key.pem"); + std::fs::write(&cert_file, signed.cert.pem()).unwrap(); + std::fs::write(&key_file, signed.signing_key.serialize_pem()).unwrap(); + let listener = hj_core::config::Listener { + name: "test-tls".into(), + address: "127.0.0.1:0".into(), + secure: true, + vhost_map: Vec::new(), + tls: Some(hj_core::config::ListenerTls { + key_file, + cert_file, + cert_chain: false, + ca_cert_file: None, + client_verify: 0, + verify_depth: 1, + enable_stapling: false, + crl_file: None, + }), + uds_path: None, + proxy_protocol: false, + }; + let mut server = hj_core::config::ServerConfig::default(); + server.server_root = root.to_path_buf(); + let bundle = + hj_tls::PreparedListenerTls::prepare(&server, &listener, false, false, true).unwrap(); + ( + certificate, + monoio_rustls::TlsAcceptor::from(bundle.tcp), + Arc::new(bundle.ktls.unwrap()), + ) + } + + #[cfg(feature = "ktls")] + fn ktls_test_client( + address: SocketAddr, + certificate: rustls::pki_types::CertificateDer<'static>, + ) -> rustls::StreamOwned { + ktls_test_client_version(address, certificate, true) + } + + #[cfg(feature = "ktls")] + fn ktls_test_client_version( + address: SocketAddr, + certificate: rustls::pki_types::CertificateDer<'static>, + tls13: bool, + ) -> rustls::StreamOwned { + let mut roots = rustls::RootCertStore::empty(); + roots.add(certificate).unwrap(); + let versions = if tls13 { + &[&rustls::version::TLS13][..] + } else { + &[&rustls::version::TLS12][..] + }; + let mut config = rustls::ClientConfig::builder_with_protocol_versions(versions) + .with_root_certificates(roots) + .with_no_client_auth(); + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + let socket = std::net::TcpStream::connect(address).unwrap(); + socket + .set_read_timeout(Some(std::time::Duration::from_secs(15))) + .unwrap(); + socket + .set_write_timeout(Some(std::time::Duration::from_secs(15))) + .unwrap(); + let name = rustls::pki_types::ServerName::try_from("localhost") + .unwrap() + .to_owned(); + let connection = rustls::ClientConnection::new(Arc::new(config), name).unwrap(); + rustls::StreamOwned::new(connection, socket) + } + + #[cfg(feature = "ktls")] + fn h1_content_length(head: &[u8]) -> usize { + String::from_utf8_lossy(head) + .lines() + .find_map(|line| { + line.split_once(':').and_then(|(name, value)| { + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse().unwrap()) + }) + }) + .expect("response content-length") + } + fn read_h1_head(reader: &mut impl std::io::Read) -> Vec { let mut head = Vec::new(); let mut byte = [0u8; 1]; @@ -3090,6 +3722,17 @@ Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ response: bridge::BridgeResp, throttle: &mut Option, ) -> Vec { + let (wire, failed) = capture_h1_response_options(response, throttle, false, false); + assert!(!failed); + wire + } + + fn capture_h1_response_options( + response: bridge::BridgeResp, + throttle: &mut Option, + is_head: bool, + use_sendfile: bool, + ) -> (Vec, bool) { use std::io::Read; let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); @@ -3105,13 +3748,168 @@ Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ wire }); let mut runtime = build_core_runtime().unwrap(); - runtime.block_on(async move { + let failed = runtime.block_on(async move { let listener = TcpListener::from_std(std_listener).unwrap(); let (mut stream, _) = listener.accept().await.unwrap(); - assert!(write_h1_response(&mut stream, response, false, false, throttle).await); + let sendfile_fd = use_sendfile.then(|| stream.as_raw_fd()); + let result = + write_h1_response(&mut stream, response, is_head, false, throttle, sendfile_fd) + .await; + assert!(result.close); let _ = stream.shutdown().await; + result.failed }); - client.join().unwrap() + (client.join().unwrap(), failed) + } + + #[test] + fn plaintext_h1_sendfile_preserves_large_range_and_content_length() { + use std::sync::atomic::Ordering; + + let sendfile_before = H1_SENDFILE_BYTES.load(Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "httpjet-sendfile-range-{}-{}.bin", + std::process::id(), + std::thread::current().name().unwrap_or("test") + )); + let bytes: Vec = (0..(2 * 1024 * 1024 + 333)) + .map(|index| (index % 251) as u8) + .collect(); + std::fs::write(&path, &bytes).unwrap(); + let start = 117u64; + let end = bytes.len() as u64 - 219; + let response = bridge::BridgeResp { + completion: None, + status: http::StatusCode::PARTIAL_CONTENT, + headers: http::HeaderMap::new(), + body: bridge::BridgeBody::File(hj_core::FileBody { + path: path.clone(), + file: Some(std::fs::File::open(&path).unwrap()), + len: bytes.len() as u64, + range: Some((start, end)), + cached: None, + }), + bw_rate: None, + }; + let (wire, failed) = capture_h1_response_options(response, &mut None, false, true); + assert!(!failed); + let split = wire.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4; + let head = String::from_utf8_lossy(&wire[..split]).to_ascii_lowercase(); + let expected = (end - start + 1) as usize; + assert!(head.contains(&format!("content-length: {expected}\r\n"))); + assert_eq!(&wire[split..], &bytes[start as usize..=end as usize]); + assert!( + H1_SENDFILE_BYTES + .load(Ordering::Relaxed) + .saturating_sub(sendfile_before) + >= expected as u64, + "the selected range must traverse sendfile" + ); + let _ = std::fs::remove_file(path); + } + + #[test] + fn plaintext_h1_file_short_read_fails_closed_after_partial_body() { + let path = + std::env::temp_dir().join(format!("httpjet-sendfile-short-{}.bin", std::process::id())); + std::fs::write(&path, b"short").unwrap(); + let response = bridge::BridgeResp { + completion: None, + status: http::StatusCode::OK, + headers: http::HeaderMap::new(), + body: bridge::BridgeBody::File(hj_core::FileBody { + path: path.clone(), + file: Some(std::fs::File::open(&path).unwrap()), + len: 100, + range: None, + cached: None, + }), + bw_rate: None, + }; + let (wire, failed) = capture_h1_response_options(response, &mut None, false, true); + assert!(failed, "short source must close the connection as an error"); + assert!(wire.ends_with(b"short")); + let _ = std::fs::remove_file(path); + } + + #[test] + fn plaintext_h1_sendfile_client_abort_is_an_error() { + use std::io::Read; + + let path = + std::env::temp_dir().join(format!("httpjet-sendfile-abort-{}.bin", std::process::id())); + let source = std::fs::File::create(&path).unwrap(); + source.set_len(32 * 1024 * 1024).unwrap(); + drop(source); + let response = bridge::BridgeResp { + completion: None, + status: http::StatusCode::OK, + headers: http::HeaderMap::new(), + body: bridge::BridgeBody::File(hj_core::FileBody { + path: path.clone(), + file: Some(std::fs::File::open(&path).unwrap()), + len: 32 * 1024 * 1024, + range: None, + cached: None, + }), + bw_rate: None, + }; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + listener.set_nonblocking(true).unwrap(); + let client = std::thread::spawn(move || { + let mut socket = std::net::TcpStream::connect(address).unwrap(); + socket + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let mut head = Vec::new(); + let mut byte = [0u8; 1]; + while !head.ends_with(b"\r\n\r\n") { + socket.read_exact(&mut byte).unwrap(); + head.push(byte[0]); + } + drop(socket); + }); + let mut runtime = build_core_runtime().unwrap(); + let failed = runtime.block_on(async move { + let listener = TcpListener::from_std(listener).unwrap(); + let (mut socket, _) = listener.accept().await.unwrap(); + let fd = socket.as_raw_fd(); + let outcome = + write_h1_response(&mut socket, response, false, false, &mut None, Some(fd)).await; + outcome.failed + }); + client.join().unwrap(); + assert!( + failed, + "a peer abort must not complete a partial file response" + ); + let _ = std::fs::remove_file(path); + } + + #[test] + fn plaintext_h1_head_never_sends_file_bytes() { + let path = + std::env::temp_dir().join(format!("httpjet-sendfile-head-{}.bin", std::process::id())); + std::fs::write(&path, b"body-must-not-appear").unwrap(); + let response = bridge::BridgeResp { + completion: None, + status: http::StatusCode::OK, + headers: http::HeaderMap::new(), + body: bridge::BridgeBody::File(hj_core::FileBody { + path: path.clone(), + file: Some(std::fs::File::open(&path).unwrap()), + len: 20, + range: None, + cached: None, + }), + bw_rate: None, + }; + let (wire, failed) = capture_h1_response_options(response, &mut None, true, true); + assert!(!failed); + assert!(wire.ends_with(b"\r\n\r\n")); + assert!(!wire.windows(4).any(|w| w == b"body")); + let _ = std::fs::remove_file(path); } #[test] @@ -3122,6 +3920,7 @@ Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ let body = bytes::Bytes::from(vec![b'x'; 512 * 1024]); let tail = body.slice(body.len() - 16..); let response = bridge::BridgeResp { + completion: None, status: http::StatusCode::OK, headers: http::HeaderMap::new(), body: bridge::BridgeBody::Full(body), @@ -3149,6 +3948,7 @@ Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ let body = bytes::Bytes::from(vec![b'y'; 512 * 1024]); let tail = body.slice(body.len() - 16..); let response = bridge::BridgeResp { + completion: None, status: http::StatusCode::OK, headers: http::HeaderMap::new(), body: bridge::BridgeBody::Full(body), @@ -3166,6 +3966,7 @@ Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ #[test] fn full_and_stream_writers_discard_forbidden_response_bodies() { let full = capture_h1_response(bridge::BridgeResp { + completion: None, status: http::StatusCode::NO_CONTENT, headers: http::HeaderMap::new(), body: bridge::BridgeBody::Full(bytes::Bytes::from_static(b"full-sentinel")), @@ -3176,6 +3977,7 @@ Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ tx.try_send(Ok(bytes::Bytes::from_static(b"stream-sentinel"))) .unwrap(); let streamed = capture_h1_response(bridge::BridgeResp { + completion: None, status: http::StatusCode::NO_CONTENT, headers: http::HeaderMap::new(), body: bridge::BridgeBody::Stream { rx, len: Some(15) }, @@ -3200,6 +4002,7 @@ Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ .unwrap(); drop(tx); let wire = capture_h1_response(bridge::BridgeResp { + completion: None, status: http::StatusCode::OK, headers: http::HeaderMap::new(), body: bridge::BridgeBody::Stream { rx, len: None }, @@ -3276,6 +4079,7 @@ Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ BridgeCtx::plain(peer, local, Proto::Http1), core, CancellationToken::new(), + None, ) .await; }); @@ -3341,6 +4145,266 @@ Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ client.join().unwrap(); } + #[cfg(feature = "ktls")] + #[test] + fn ktls_h1_sendfile_preserves_range_across_mid_transfer_key_update() { + use std::io::{Read, Write}; + use std::sync::atomic::Ordering; + + let sendfile_before = H1_SENDFILE_BYTES.load(Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "httpjet-ktls-sendfile-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + let source = root.join("large.bin"); + let bytes: Vec = (0..(4 * 1024 * 1024 + 333)) + .map(|index| (index % 251) as u8) + .collect(); + std::fs::write(&source, &bytes).unwrap(); + let source_len = bytes.len() as u64; + let start = 117usize; + let end = source_len as usize - 219; + let expected = bytes[start..=end].to_vec(); + let expected_len = expected.len(); + let (tokio_runtime, core, observed_direct) = + ktls_file_test_core(&root, source, source_len, None); + let (certificate, acceptor, template) = ktls_test_configs(&root); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let local = listener.local_addr().unwrap(); + listener.set_nonblocking(true).unwrap(); + let client = std::thread::spawn(move || { + let mut stream = ktls_test_client(local, certificate); + stream + .write_all( + b"GET /range HTTP/1.1\r\nHost: localhost\r\nConnection: keep-alive\r\n\r\n", + ) + .unwrap(); + stream.flush().unwrap(); + let head = read_h1_head(&mut stream); + let head_text = String::from_utf8_lossy(&head).to_ascii_lowercase(); + assert!(head_text.starts_with("http/1.1 206 partial content\r\n")); + assert_eq!(h1_content_length(&head), expected.len()); + assert_eq!( + stream.conn.protocol_version(), + Some(rustls::ProtocolVersion::TLSv1_3) + ); + + let split = 128 * 1024; + let mut received = vec![0u8; expected.len()]; + stream.read_exact(&mut received[..split]).unwrap(); + assert_eq!(&received[..split], &expected[..split]); + + // Queue UpdateRequested plus the next pipelined request while the + // throttled response still owns the server's sendfile loop. H1's + // single-task ordering fences the TX transfer: the server consumes + // and applies the KeyUpdate before writing the following response. + stream.conn.refresh_traffic_keys().unwrap(); + stream + .write_all(b"GET /short HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + .unwrap(); + stream.flush().unwrap(); + stream.read_exact(&mut received[split..]).unwrap(); + assert_eq!(received, expected); + + let second_head = read_h1_head(&mut stream); + let second_len = h1_content_length(&second_head); + let mut second = vec![0u8; second_len]; + stream.read_exact(&mut second).unwrap(); + assert_eq!(&second, b"short-after-key-update"); + }); + + let mut runtime = build_core_runtime().unwrap(); + runtime.block_on(async move { + let listener = TcpListener::from_std(listener).unwrap(); + let (stream, peer) = listener.accept().await.unwrap(); + handle_tls_bridged( + stream, + peer, + local, + core, + acceptor, + false, + Some(template), + CancellationToken::new(), + ListenerBinding::default(), + ) + .await; + }); + client.join().unwrap(); + assert!( + H1_SENDFILE_BYTES + .load(Ordering::Relaxed) + .saturating_sub(sendfile_before) + >= expected_len as u64, + "the TLS 1.3 kTLS range must traverse sendfile" + ); + assert!(observed_direct.load(Ordering::Relaxed)); + drop(runtime); + drop(tokio_runtime); + std::fs::remove_dir_all(root).unwrap(); + } + + #[cfg(feature = "ktls")] + #[test] + fn ktls_h1_sendfile_client_abort_reports_transport_error() { + use std::io::{Read, Write}; + use std::os::fd::AsRawFd; + + let root = std::env::temp_dir().join(format!( + "httpjet-ktls-abort-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + let source = root.join("abort.bin"); + let file = std::fs::File::create(&source).unwrap(); + file.set_len(32 * 1024 * 1024).unwrap(); + drop(file); + let (completion_tx, completion_rx) = std::sync::mpsc::channel(); + let (tokio_runtime, core, observed_direct) = + ktls_file_test_core(&root, source, 32 * 1024 * 1024, Some(completion_tx)); + let (certificate, acceptor, template) = ktls_test_configs(&root); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let local = listener.local_addr().unwrap(); + listener.set_nonblocking(true).unwrap(); + let client = std::thread::spawn(move || { + let mut stream = ktls_test_client(local, certificate); + stream + .write_all(b"GET /abort HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + .unwrap(); + stream.flush().unwrap(); + let _ = read_h1_head(&mut stream); + let mut first = [0u8; 1]; + stream.read_exact(&mut first).unwrap(); + let reset = libc::linger { + l_onoff: 1, + l_linger: 0, + }; + // SAFETY: the option buffer is valid for this synchronous call and + // the client owns the socket until the StreamOwned is dropped. + let result = unsafe { + libc::setsockopt( + stream.sock.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_LINGER, + (&reset as *const libc::linger).cast(), + std::mem::size_of_val(&reset) as libc::socklen_t, + ) + }; + assert_eq!(result, 0); + drop(stream); + }); + + let mut runtime = build_core_runtime().unwrap(); + runtime.block_on(async move { + let listener = TcpListener::from_std(listener).unwrap(); + let (stream, peer) = listener.accept().await.unwrap(); + handle_tls_bridged( + stream, + peer, + local, + core, + acceptor, + false, + Some(template), + CancellationToken::new(), + ListenerBinding::default(), + ) + .await; + }); + client.join().unwrap(); + assert_eq!( + completion_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(), + hj_core::ResponseEnd::Error + ); + assert!(observed_direct.load(std::sync::atomic::Ordering::Relaxed)); + drop(runtime); + drop(tokio_runtime); + std::fs::remove_dir_all(root).unwrap(); + } + + #[cfg(feature = "ktls")] + #[test] + fn ktls_policy_tls12_connection_keeps_userspace_file_streaming() { + use std::io::{Read, Write}; + use std::sync::atomic::Ordering; + + let root = std::env::temp_dir().join(format!( + "httpjet-ktls-tls12-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + let source = root.join("tls12.bin"); + let bytes: Vec = (0..(512 * 1024 + 17)) + .map(|index| (index % 239) as u8) + .collect(); + std::fs::write(&source, &bytes).unwrap(); + let source_len = bytes.len() as u64; + let expected = bytes[117..=source_len as usize - 219].to_vec(); + let (tokio_runtime, core, observed_direct) = + ktls_file_test_core(&root, source, source_len, None); + let (certificate, acceptor, template) = ktls_test_configs(&root); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let local = listener.local_addr().unwrap(); + listener.set_nonblocking(true).unwrap(); + let client = std::thread::spawn(move || { + let mut stream = ktls_test_client_version(local, certificate, false); + stream + .write_all(b"GET /range HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n") + .unwrap(); + stream.flush().unwrap(); + let head = read_h1_head(&mut stream); + assert_eq!(h1_content_length(&head), expected.len()); + assert_eq!( + stream.conn.protocol_version(), + Some(rustls::ProtocolVersion::TLSv1_2) + ); + let mut body = vec![0u8; expected.len()]; + stream.read_exact(&mut body).unwrap(); + assert_eq!(body, expected); + }); + + let mut runtime = build_core_runtime().unwrap(); + runtime.block_on(async move { + let listener = TcpListener::from_std(listener).unwrap(); + let (stream, peer) = listener.accept().await.unwrap(); + handle_tls_bridged( + stream, + peer, + local, + core, + acceptor, + false, + Some(template), + CancellationToken::new(), + ListenerBinding::default(), + ) + .await; + }); + client.join().unwrap(); + assert!( + !observed_direct.load(Ordering::Relaxed), + "TLS 1.2 must retain the userspace streaming path" + ); + drop(runtime); + drop(tokio_runtime); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn h1_websocket_non_switching_response_uses_normal_framing() { let (_tokio_runtime, core) = websocket_test_core(); @@ -3376,6 +4440,7 @@ Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ BridgeCtx::plain(peer, local, Proto::Http1), core, CancellationToken::new(), + None, ) .await; }); @@ -3526,13 +4591,20 @@ Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ /// fabricated loopback peer (the socket file's mode/owner is the real access /// boundary). AF_UNIX has no SO_REUSEPORT, so the listener runs on ONE core — /// sized for the few on-box peers a UDS listener serves. +/// Returns a prepared group; call `activate` to begin accepting requests. +pub(crate) enum UdsListenerInput { + Inherited(std::os::unix::net::UnixListener), + Handoff(worker_group::PreparedUdsHandoff), +} + pub(crate) fn spawn_uring_uds( - holder: Arc>, + holder: impl Into, listener_name: Arc, path: std::path::PathBuf, - inherited: Option, + input: Option, admission: bridge::BridgeAdmission, -) -> anyhow::Result<()> { +) -> anyhow::Result { + let holder = holder.into(); let shutdown = holder.load().shutdown.clone(); let bridge = build_pipeline_bridge(holder.clone(), listener_name.clone(), admission); let core = CoreHandler { @@ -3540,28 +4612,56 @@ pub(crate) fn spawn_uring_uds( holder, listener_name, }; - let inherited = inherited.map(|l| { - let _ = l.set_nonblocking(true); - l - }); + let (listener, path_owner, predecessor) = match input { + Some(UdsListenerInput::Inherited(listener)) => (listener, None, None), + Some(UdsListenerInput::Handoff(prepared)) => { + let (listener, predecessor, owner) = prepared.into_parts(); + (listener, owner, Some(predecessor)) + } + None => { + let (listener, owner) = unix_path::OwnedUnixPath::bind(&path)?; + (listener, Some(Arc::new(owner)), None) + } + }; + listener.set_nonblocking(true)?; + let listener = Arc::new(listener); + let thread_listener = listener.try_clone()?; let (ready_tx, ready_rx) = std::sync::mpsc::channel(); - std::thread::Builder::new() - .name("hj-uring-uds".into()) - .stack_size(crate::RUNTIME_THREAD_STACK_BYTES) - .spawn(move || { + let mut group = WorkerGroup::for_uds_epoch(&shutdown, core.holder.trust_epoch(), path.clone()); + let retirement = group.register_uds_acceptor(listener, path_owner)?; + if let Some(predecessor) = predecessor { + group.follow_uds_acceptor(predecessor)?; + } + let activation = group.activation_gate(); + group.spawn( + std::thread::Builder::new() + .name("hj-uring-uds".into()) + .stack_size(crate::RUNTIME_THREAD_STACK_BYTES), + move |shutdown| { maybe_pin_core_thread(0, 1); - per_core_uds(core, path, inherited, shutdown, ready_tx) - })?; + per_core_uds( + core, + path, + thread_listener, + shutdown, + ready_tx, + activation, + retirement, + ) + }, + )?; wait_for_worker_readiness("UDS", 1, ready_rx)?; - Ok(()) + Ok(group) } fn per_core_uds( core: CoreHandler, path: std::path::PathBuf, - inherited: Option, + listener: std::os::unix::net::UnixListener, shutdown: CancellationToken, ready: WorkerReadyTx, + activation: worker_group::ActivationGate, + mut retirement: worker_group::UdsAcceptRetirement, ) { let mut rt = match build_core_runtime() { Ok(rt) => rt, @@ -3571,49 +4671,20 @@ fn per_core_uds( } }; rt.block_on(async move { - let listener = match inherited { - Some(l) => match monoio::net::UnixListener::from_std(l) { - Ok(l) => l, - Err(e) => { - let _ = ready.send(Err(format!("adopt unix listener: {e}"))); - return; - } - }, - None => { - // Stale socket file (crashed previous run): connect proves it is - // dead before the unlink. - if path.exists() - && std::os::unix::net::UnixStream::connect(&path).is_err() - && std::fs::remove_file(&path).is_err() - { - let _ = ready.send(Err(format!( - "stale unix socket {} could not be removed", - path.display() - ))); - return; - } - // SO_REUSEPORT (monoio's bind default) is unsupported on - // AF_UNIX — opt out explicitly. - let mut opts = monoio::net::ListenerOpts::default(); - opts.reuse_port = false; - match monoio::net::UnixListener::bind_with_config(&path, &opts) { - Ok(l) => { - // Group-writable so local services in the run group can - // dial; ownership is the unit's job (User=/Group=). - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o660)); - l - } - Err(e) => { - let _ = ready.send(Err(format!("bind unix listener: {e}"))); - return; - } - } + let listener = match monoio::net::UnixListener::from_std(listener) { + Ok(listener) => listener, + Err(error) => { + retirement.cancel(); + let _ = ready.send(Err(format!("adopt unix listener: {error}"))); + return; } }; - let _ = ready.send(Ok(())); + if ready.send(Ok(())).is_err() || !activation.wait().await { + retirement.cancel(); + return; + } tracing::info!(path = %path.display(), "uring UDS listener serving (H1/h2c → real pipeline, one core)"); - accept_drain_loop_unix(listener, shutdown, core).await; + accept_drain_loop_unix(listener, shutdown, core, &mut retirement).await; }); } @@ -3623,6 +4694,7 @@ async fn accept_drain_loop_unix( listener: monoio::net::UnixListener, shutdown: CancellationToken, core: CoreHandler, + retirement: &mut worker_group::UdsAcceptRetirement, ) { use std::cell::Cell; use std::rc::Rc; @@ -3667,9 +4739,10 @@ async fn accept_drain_loop_unix( stream, peer, local, - core.clone(), + core.pin_connection(), shutdown.clone(), ListenerBinding::default(), + true, ); let cnt = inflight.clone(); cnt.set(cnt.get() + 1); @@ -3685,6 +4758,9 @@ async fn accept_drain_loop_unix( } } } + // Release the successor as soon as this accept loop is quiescent; existing + // connections drain independently on the retiring generation. + retirement.cancel(); let start = std::time::Instant::now(); while inflight.get() > 0 && start.elapsed() < URING_DRAIN_GRACE { monoio::time::sleep(std::time::Duration::from_millis(50)).await; @@ -3706,6 +4782,25 @@ mod uds_tests { /// knowledge. The access log must render the fabricated peer as `unix:`. #[test] fn uds_listener_serves_h1_and_h2c_with_unix_peer() { + uds_listener_lifecycle(false, true); + } + + #[test] + fn inherited_uds_listener_retirement_preserves_manager_path() { + uds_listener_lifecycle(true, true); + } + + #[test] + fn prepared_uds_rollback_never_serves_and_removes_owned_path() { + uds_listener_lifecycle(false, false); + } + + #[test] + fn prepared_inherited_uds_rollback_preserves_manager_path() { + uds_listener_lifecycle(true, false); + } + + fn uds_listener_lifecycle(inherit: bool, activate: bool) { use std::io::{Read, Write}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -3722,6 +4817,8 @@ mod uds_tests { std::fs::create_dir_all(&doc_root).unwrap(); std::fs::write(doc_root.join("index.html"), b"uds ok").unwrap(); let sock_path = dir.join("http.sock"); + let inherited = + inherit.then(|| std::os::unix::net::UnixListener::bind(&sock_path).unwrap()); // Minimal real ServerState (mirror of the e2e/smoke constructions). let mut by_suffix = std::collections::BTreeMap::new(); @@ -3783,7 +4880,7 @@ mod uds_tests { .enable_all() .build() .unwrap(); - rt.block_on(async { + let workers = rt.block_on(async { let state = crate::state::ServerState::new( Arc::new(server), None, @@ -3805,10 +4902,10 @@ mod uds_tests { holder.clone(), Arc::from("uds-test"), sock_path.clone(), - None, + inherited.map(UdsListenerInput::Inherited), admission, ) - .expect("UDS listener spawns"); + .expect("UDS listener spawns") }); // Wait for the socket file, then serve an H1 request. @@ -3822,6 +4919,30 @@ mod uds_tests { client .write_all(b"GET /index.html HTTP/1.1\r\nHost: uds.test\r\nConnection: close\r\n\r\n") .unwrap(); + client + .set_read_timeout(Some(std::time::Duration::from_millis(100))) + .unwrap(); + let mut probe = [0u8; 1]; + let error = client + .read(&mut probe) + .expect_err("prepared listener must not respond"); + assert!(matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + )); + if !activate { + drop(workers); + match client.read(&mut probe) { + Ok(0) => {} + Err(error) if error.kind() == std::io::ErrorKind::ConnectionReset => {} + other => panic!("rolled-back listener retained queued connection: {other:?}"), + } + assert_eq!(sock_path.exists(), inherit); + drop(client); + std::fs::remove_dir_all(&dir).unwrap(); + return; + } + workers.activate(); let mut buf = String::new(); let _ = client.set_read_timeout(Some(std::time::Duration::from_secs(5))); let _ = client.read_to_string(&mut buf); @@ -3843,6 +4964,14 @@ mod uds_tests { } let _ = AtomicU64::new(0).load(Ordering::Relaxed); + drop(client); + drop(workers); + assert!(std::os::unix::net::UnixStream::connect(&sock_path).is_err()); + assert_eq!( + sock_path.exists(), + inherit, + "only self-owned paths are removed" + ); std::fs::remove_dir_all(&dir).ok(); } } diff --git a/crates/httpjet/src/uring/otel_quic_test.rs b/crates/httpjet/src/uring/otel_quic_test.rs new file mode 100644 index 0000000..cb55893 --- /dev/null +++ b/crates/httpjet/src/uring/otel_quic_test.rs @@ -0,0 +1,135 @@ +use super::*; + +#[test] +#[ignore = "requires python3 with aioquic; synthetic loopback QUIC only"] +fn traced_quic_roundtrip() { + if std::env::var_os("HTTPJET_OTEL_QUIC_TEST_CHILD").is_none() { + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--ignored", + "--exact", + "uring::otel_test::quic::traced_quic_roundtrip", + "--nocapture", + ]) + .env("HTTPJET_OTEL_QUIC_TEST_CHILD", "1") + .spawn() + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + if let Some(status) = child.try_wait().unwrap() { + assert!(status.success()); + return; + } + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("QUIC fixture timed out"); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + let capture = Capture::default(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(capture.clone()) + .build(); + opentelemetry::global::set_tracer_provider(provider.clone()); + crate::otel::enable_for_isolated_test(); + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + let root = std::env::temp_dir().join(format!("hj-otel-quic-{}", std::process::id())); + std::fs::create_dir(&root).unwrap(); + std::fs::write(root.join("index.html"), b"synthetic QUIC response").unwrap(); + std::fs::write(root.join("large.txt"), vec![b'x'; 2 * 1024 * 1024]).unwrap(); + let (bridge, server_root, view) = runtime.block_on(async { + let state = crate::pipeline::e2e::build_state(root.clone()); + let server_root = state.server.server_root.clone(); + let holder = Arc::new(arc_swap::ArcSwap::from(state)); + let view = ServingView::new(holder.clone()); + let bridge = build_pipeline_bridge( + holder, + "http".into(), + bridge::BridgeAdmission::dynamic(|| 16), + ); + (bridge, server_root, view) + }); + let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + let addr = socket.local_addr().unwrap(); + let shutdown = CancellationToken::new(); + let (workers, _policy) = h3::serve_h3_pipeline( + addr, + 1, + h3::self_signed_config().unwrap(), + bridge, + false, + h3::H3RuntimeConfig::new( + || (h3::H3RequestLimits::new(16_384, 1024 * 1024), 8), + Arc::new(std::sync::atomic::AtomicU64::new(0)), + Arc::new(hj_core::budget::BodyBufferBudget::new(8 * 1024 * 1024)), + ) + .with_serving_view(view), + Some(vec![socket]), + shutdown.clone(), + ) + .unwrap(); + workers.activate(); + let mut client = std::process::Command::new("python3") + .arg("-B") + .arg(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../scripts/otel_h3_client.py" + )) + .arg(addr.port().to_string()) + .spawn() + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + let status = loop { + if let Some(status) = client.try_wait().unwrap() { + break status; + } + if std::time::Instant::now() >= deadline { + let _ = client.kill(); + let _ = client.wait(); + shutdown.cancel(); + panic!("aioquic client timed out"); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + }; + shutdown.cancel(); + assert!(status.success()); + let spans = capture.0.lock().unwrap().clone(); + let roots: Vec<_> = spans + .iter() + .filter(|s| s.name == "httpjet.request") + .collect(); + assert_eq!(roots.len(), 2, "one completed root per actual QUIC request"); + for root in &roots { + assert_eq!(root.parent_span_id, opentelemetry::trace::SpanId::INVALID); + for (key, value) in [ + ("http.response.status_code", "200"), + ("httpjet.body.outcome", "complete"), + ] { + assert!( + root.attributes + .iter() + .any(|a| a.key.as_str() == key && a.value.as_str() == value) + ); + } + } + let backends: Vec<_> = spans + .iter() + .filter(|s| s.name == "httpjet.static") + .collect(); + assert_eq!(backends.len(), 2); + assert!(backends.iter().all(|s| { + roots + .iter() + .any(|r| r.span_context.span_id() == s.parent_span_id) + })); + provider.shutdown().unwrap(); + drop(runtime); + std::fs::remove_dir_all(root).unwrap(); + std::fs::remove_dir_all(server_root).unwrap(); +} diff --git a/crates/httpjet/src/uring/otel_test.rs b/crates/httpjet/src/uring/otel_test.rs new file mode 100644 index 0000000..6b3dec5 --- /dev/null +++ b/crates/httpjet/src/uring/otel_test.rs @@ -0,0 +1,444 @@ +use super::*; +use opentelemetry_sdk::trace::{SdkTracerProvider, SpanData, SpanExporter}; +use std::sync::Mutex; + +#[path = "otel_quic_test.rs"] +mod quic; + +#[derive(Clone, Debug, Default)] +struct Capture(Arc>>); +impl SpanExporter for Capture { + async fn export(&self, spans: Vec) -> opentelemetry_sdk::error::OTelSdkResult { + self.0.lock().unwrap().extend(spans); + Ok(()) + } +} + +#[test] +fn traced_fast_and_bridged_requests() { + // Isolate global SDK/runtime enablement from concurrently running unit tests. + if std::env::var_os("HTTPJET_OTEL_TRANSPORT_TEST_CHILD").is_none() { + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "uring::otel_test::traced_fast_and_bridged_requests", + "--nocapture", + ]) + .env("HTTPJET_OTEL_TRANSPORT_TEST_CHILD", "1") + .spawn() + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + loop { + if let Some(status) = child.try_wait().unwrap() { + assert!(status.success()); + return; + } + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("transport fixture timed out"); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + let capture = Capture::default(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(capture.clone()) + .build(); + opentelemetry::global::set_tracer_provider(provider.clone()); + crate::otel::enable_for_isolated_test(); + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + let root = std::env::temp_dir().join(format!("hj-otel-transport-{}", std::process::id())); + std::fs::create_dir(&root).unwrap(); + std::fs::write(root.join("index.html"), b"synthetic telemetry response").unwrap(); + let (core, server_root) = runtime.block_on(async { + let state = crate::pipeline::e2e::build_state(root.clone()); + let server_root = state.server.server_root.clone(); + let holder = Arc::new(arc_swap::ArcSwap::from(state)); + let listener_name: Arc = "http".into(); + let bridge = build_pipeline_bridge( + holder.clone(), + listener_name.clone(), + bridge::BridgeAdmission::dynamic(|| 16), + ); + ( + CoreHandler { + bridge, + holder: ServingView::new(holder), + listener_name, + }, + server_root, + ) + }); + let ctx = BridgeCtx { + peer: "127.0.0.1:12345".parse().unwrap(), + local: "127.0.0.1:8080".parse().unwrap(), + proto: hj_core::Proto::Http2, + is_tls: false, + direct_file_egress: false, + peer_unix: false, + mtls_required: false, + sni: None, + tls: None, + }; + // Drive the same H2 service dispatcher with both on-core and bridged requests. + runtime.block_on(async { + let cache_state = crate::pipeline::e2e::build_state_full( + root.clone(), + Vec::new(), + Vec::new(), + Some(Arc::new(hj_pagecache::PageStore::new(Default::default()))), + None, + ); + let cache_server_root = cache_state.server.server_root.clone(); + let cache_core = CoreHandler { + bridge: build_pipeline_bridge( + Arc::new(arc_swap::ArcSwap::from(cache_state.clone())), + "http".into(), + bridge::BridgeAdmission::dynamic(|| 16), + ), + holder: ServingView::new(Arc::new(arc_swap::ArcSwap::from(cache_state))), + listener_name: "http".into(), + }; + let req = http::Request::builder() + .uri("/index.html") + .header("host", "canon.test") + .body(hj_core::empty_incoming()) + .unwrap(); + let response = cache_core.dispatch_h2(ctx.clone(), req).await; + assert_eq!(response.status(), 200); + let _ = bridge::buffer_body(response.into_body()).await; + drop(cache_core); + std::fs::remove_dir_all(cache_server_root).unwrap(); + for method in ["GET", "POST"] { + let req = http::Request::builder() + .method(method) + .uri("/index.html") + .header("host", "canon.test") + .header("baggage", "secret=value") + .body(hj_core::empty_incoming()) + .unwrap(); + let response = core.dispatch_h2(ctx.clone(), req).await; + let _ = bridge::buffer_body(response.into_body()).await; + } + let mut h3_ctx = ctx.clone(); + h3_ctx.proto = hj_core::Proto::Http3; + h3_ctx.is_tls = true; + let req = http::Request::builder() + .uri("/index.html") + .header("host", "canon.test") + .body(hj_core::empty_incoming()) + .unwrap(); + let before = capture + .0 + .lock() + .unwrap() + .iter() + .filter(|s| s.name == "httpjet.request") + .count(); + let mut response = core.bridge.dispatch(req, h3_ctx.clone()).await.unwrap(); + assert_eq!(response.status, 200); + assert_eq!( + capture + .0 + .lock() + .unwrap() + .iter() + .filter(|s| s.name == "httpjet.request") + .count(), + before, + "H3 bridge production must not finish the root span" + ); + response + .completion + .take() + .unwrap() + .finish(hj_core::ResponseEnd::Complete); + + // A failure while buffering must preserve lifetime and report the actual + // outgoing 502, not the original successful backend response head. + let failing = bridge::spawn_bridge(1, |_, _| async { + crate::otel::request_with_completion(opentelemetry::Context::new(), async { + http::Response::new(hj_core::Body::File(hj_core::FileBody { + path: "/nonexistent-httpjet-test/path".into(), + file: None, + len: 4, + range: None, + cached: None, + })) + }) + .await + }) + .unwrap(); + let req = http::Request::new(hj_core::empty_incoming()); + let mut response = failing.dispatch(req, h3_ctx).await.unwrap(); + assert_eq!(response.status, 502); + response + .completion + .take() + .unwrap() + .finish(hj_core::ResponseEnd::Complete); + }); + // Drive the actual io_uring H1 reader/dispatcher/writer over loopback. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap(); + let client = std::thread::spawn(move || { + use std::io::{Read, Write}; + let mut stream = std::net::TcpStream::connect(addr).unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + stream + .write_all(b"GET /index.html HTTP/1.1\r\nHost: canon.test\r\nConnection: close\r\n\r\n") + .unwrap(); + let mut wire = Vec::new(); + stream.read_to_end(&mut wire).unwrap(); + assert!(wire.starts_with(b"HTTP/1.1 200")); + assert!(wire.ends_with(b"synthetic telemetry response")); + }); + let mut monoio = build_core_runtime().unwrap(); + monoio.block_on(async { + let listener = monoio::net::TcpListener::from_std(listener).unwrap(); + let (stream, _) = listener.accept().await.unwrap(); + let mut ctx = ctx.clone(); + ctx.proto = hj_core::Proto::Http1; + handle_h1_bridged( + stream, + Vec::new(), + ctx, + core.clone(), + CancellationToken::new(), + None, + ) + .await; + }); + client.join().unwrap(); + let websocket_headers = runtime.block_on(crate::pipeline::e2e::traced_websocket_handshakes( + &core.holder.load_full(), + )); + websocket_roundtrip(&runtime, &mut monoio, &root, &ctx, &capture); + let spans = capture.0.lock().unwrap().clone(); + let requests: Vec<_> = spans + .iter() + .filter(|s| s.name == "httpjet.request") + .collect(); + assert_eq!( + requests.len(), + 9, + "one root per request, no duplicate bridge roots" + ); + assert!( + requests + .iter() + .all(|s| s.parent_span_id == opentelemetry::trace::SpanId::INVALID) + ); + for path in ["on-core", "bridge"] { + assert!(requests.iter().any(|s| { + s.attributes + .iter() + .any(|a| a.key.as_str() == "httpjet.execution.path" && a.value.as_str() == path) + })); + } + assert!(requests.iter().any(|s| { + s.attributes + .iter() + .any(|a| a.key.as_str() == "httpjet.body.outcome" && a.value.as_str() == "complete") + })); + for backend in spans.iter().filter(|s| s.name == "httpjet.static") { + assert!( + requests + .iter() + .any(|r| r.span_context.span_id() == backend.parent_span_id) + ); + } + assert!(spans.iter().any(|s| s.name == "httpjet.static")); + let websocket_spans: Vec<_> = spans + .iter() + .filter(|s| s.name == "httpjet.websocket") + .collect(); + assert_eq!(websocket_spans.len(), 3); + for span in &websocket_spans { + assert_eq!(span.span_kind, opentelemetry::trace::SpanKind::Client); + assert!( + requests + .iter() + .any(|r| r.span_context.span_id() == span.parent_span_id) + ); + } + let named = websocket_spans[0]; + assert_eq!( + websocket_headers[0].as_deref(), + Some( + format!( + "00-{}-{}-01", + named.span_context.trace_id(), + named.span_context.span_id() + ) + .as_str() + ) + ); + assert!( + websocket_headers[1].is_none(), + "ad-hoc target must not receive generated context" + ); + for name in [ + "httpjet.rewrite", + "httpjet.cache.lookup", + "httpjet.cache.store", + ] { + let stages: Vec<_> = spans.iter().filter(|s| s.name == name).collect(); + assert!(!stages.is_empty(), "missing {name} stage"); + for stage in stages { + assert!( + stage.attributes.is_empty(), + "stage must not export request metadata" + ); + assert!( + requests + .iter() + .any(|r| r.span_context.span_id() == stage.parent_span_id) + ); + } + } + assert!(requests.iter().any(|s| { + s.attributes + .iter() + .any(|a| a.key.as_str() == "http.response.status_code" && a.value.as_str() == "502") + })); + provider.shutdown().unwrap(); + drop(core); + drop(runtime); + drop(monoio); + std::fs::remove_dir_all(root).unwrap(); + std::fs::remove_dir_all(server_root).unwrap(); +} + +fn websocket_roundtrip( + runtime: &tokio::runtime::Runtime, + monoio: &mut monoio::Runtime>, + root: &std::path::Path, + ctx: &BridgeCtx, + capture: &Capture, +) { + use std::io::{Read, Write}; + const CLIENT_FRAME: &[u8] = &[0x81, 0x82, 1, 2, 3, 4, b'h' ^ 1, b'i' ^ 2]; + const SERVER_FRAME: &[u8] = &[0x81, 2, b'o', b'k']; + let upstream = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let upstream_addr = upstream.local_addr().unwrap(); + let backend = std::thread::spawn(move || { + let (mut stream, _) = upstream.accept().unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + assert!(head.len() < 8192); + let mut byte = [0]; + stream.read_exact(&mut byte).unwrap(); + head.push(byte[0]); + } + stream.write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n").unwrap(); + let mut frame = [0; 8]; + stream.read_exact(&mut frame).unwrap(); + assert_eq!(frame, CLIENT_FRAME); + stream.write_all(SERVER_FRAME).unwrap(); + String::from_utf8(head).unwrap() + }); + let (core, server_root) = runtime.block_on(async { + let state = + crate::pipeline::e2e::build_state_websocket(root.into(), upstream_addr.to_string()); + let server_root = state.server.server_root.clone(); + let holder = Arc::new(arc_swap::ArcSwap::from(state)); + let listener_name: Arc = "http".into(); + let bridge = build_pipeline_bridge( + holder.clone(), + listener_name.clone(), + bridge::BridgeAdmission::dynamic(|| 16), + ); + ( + CoreHandler { + bridge, + holder: ServingView::new(holder), + listener_name, + }, + server_root, + ) + }); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap(); + let observed = capture.clone(); + let client = std::thread::spawn(move || { + let mut stream = std::net::TcpStream::connect(addr).unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + stream.write_all(b"GET /socket HTTP/1.1\r\nHost: canon.test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nBaggage: secret=value\r\n\r\n").unwrap(); + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + assert!(head.len() < 8192); + let mut byte = [0]; + stream.read_exact(&mut byte).unwrap(); + head.push(byte[0]); + } + assert!(head.starts_with(b"HTTP/1.1 101")); + // The root ends at the 101 handoff, not at eventual relay EOF. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + let done = observed.0.lock().unwrap().iter().any(|s| { + s.name == "httpjet.request" + && s.attributes.iter().any(|a| { + a.key.as_str() == "httpjet.body.outcome" && a.value.as_str() == "upgraded" + }) + }); + if done { + break; + } + assert!( + std::time::Instant::now() < deadline, + "101 root did not end before relay" + ); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + stream.write_all(CLIENT_FRAME).unwrap(); + let mut frame = [0; 4]; + stream.read_exact(&mut frame).unwrap(); + assert_eq!(frame, SERVER_FRAME); + }); + monoio.block_on(async { + let listener = monoio::net::TcpListener::from_std(listener).unwrap(); + let (stream, _) = listener.accept().await.unwrap(); + let mut ctx = ctx.clone(); + ctx.proto = hj_core::Proto::Http1; + handle_h1_bridged( + stream, + Vec::new(), + ctx, + core, + CancellationToken::new(), + None, + ) + .await; + }); + client.join().unwrap(); + let head = backend.join().unwrap(); + let spans = capture.0.lock().unwrap(); + let span = spans + .iter() + .rev() + .find(|s| s.name == "httpjet.websocket") + .unwrap(); + let parent = format!( + "traceparent: 00-{}-{}-01", + span.span_context.trace_id(), + span.span_context.span_id() + ); + assert!(head.to_ascii_lowercase().contains(&parent)); + assert!(!head.to_ascii_lowercase().contains("baggage:")); + std::fs::remove_dir_all(server_root).unwrap(); +} diff --git a/crates/httpjet/src/uring/request_body.rs b/crates/httpjet/src/uring/request_body.rs index dbea26b..7357e33 100644 --- a/crates/httpjet/src/uring/request_body.rs +++ b/crates/httpjet/src/uring/request_body.rs @@ -4,6 +4,95 @@ use std::sync::Arc; use bytes::Bytes; use hj_core::budget::{BodyBufferBudget, BodyBufferLease}; use http::{HeaderMap, HeaderValue, StatusCode, header}; +use http_body_util::BodyExt; + +/// Process-lifetime request decompression policy. +/// +/// Gzip preserves the historical behavior and is always enabled. Brotli and +/// zstd are deliberately separate, default-off operator choices. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct RequestDecompression { + pub(crate) brotli: bool, + pub(crate) zstd: bool, +} + +impl RequestDecompression { + pub(crate) fn parse_extra(value: &str) -> Result { + let mut policy = Self::default(); + for item in value + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + { + match item.to_ascii_lowercase().as_str() { + "br" | "brotli" => policy.brotli = true, + "zstd" => policy.zstd = true, + "gzip" => { + return Err( + "gzip is already enabled; --request-decompression-extra accepts only br,zstd" + .into(), + ); + } + other => { + return Err(format!( + "unsupported request decompression coding {other:?}; expected br or zstd" + )); + } + } + } + Ok(policy) + } + + fn allows(self, coding: ContentCoding) -> bool { + match coding { + ContentCoding::Gzip => true, + ContentCoding::Brotli => self.brotli, + ContentCoding::Zstd => self.zstd, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ContentCoding { + Gzip, + Brotli, + Zstd, +} + +impl ContentCoding { + fn from_headers(headers: &HeaderMap) -> Option { + // Content-Encoding is an ordered list. Decoding stacked or repeated + // codings would require applying them in reverse order; leave any such + // representation untouched rather than guessing or partly decoding it. + let value = headers.get_all(header::CONTENT_ENCODING); + let mut values = value.iter(); + let first = values.next()?.to_str().ok()?.trim(); + if values.next().is_some() || first.contains(',') { + return None; + } + if first.eq_ignore_ascii_case("gzip") { + Some(Self::Gzip) + } else if first.eq_ignore_ascii_case("br") { + Some(Self::Brotli) + } else if first.eq_ignore_ascii_case("zstd") { + Some(Self::Zstd) + } else { + None + } + } + + /// Account decoder-owned memory that is otherwise invisible to the body + /// budget. Brotli's standard stream format tops out at a 2^24-byte window; + /// zstd is explicitly configured to the same maximum. The extra MiB covers + /// decoder tables and input/output scratch. Gzip retains its historical + /// behavior and small fixed decoder allocation. + fn workspace_charge(self) -> u64 { + match self { + Self::Gzip => 0, + Self::Brotli | Self::Zstd => 17 * 1024 * 1024, + } + } +} pub(super) fn finish_body( headers: &mut HeaderMap, @@ -11,6 +100,7 @@ pub(super) fn finish_body( lease: Option, budget: &Arc, max_body: usize, + policy: RequestDecompression, ) -> Result { let encoded = match lease { Some(lease) => lease.into_bytes(data), @@ -23,75 +113,218 @@ pub(super) fn finish_body( lease.into_bytes(data) } }; - if encoded.is_empty() - || !headers - .get(header::CONTENT_ENCODING) - .and_then(|v| v.to_str().ok()) - .is_some_and(|v| v.trim().eq_ignore_ascii_case("gzip")) - { + decode_body(headers, encoded, budget, max_body, policy) +} + +/// Apply request-content decoding to a fully buffered bridged request. +/// +/// H2 and H3 already pass a single lease-backed `Full` body. Collecting +/// that frame does not duplicate its allocation, and the encoded lease remains +/// live while [`decode_body`] reserves and builds the decoded representation. +pub(super) async fn finish_bridged_request( + req: hj_core::Request, + budget: &Arc, + max_body: usize, + policy: RequestDecompression, +) -> Result { + let (mut parts, body) = req.into_parts(); + let encoded = body + .collect() + .await + .map_err(|_| StatusCode::BAD_REQUEST)? + .to_bytes(); + let decoded = decode_body(&mut parts.headers, encoded, budget, max_body, policy)?; + let body = if decoded.is_empty() { + hj_core::empty_incoming() + } else { + http_body_util::Full::new(decoded) + .map_err(|never| match never {}) + .boxed() + }; + Ok(http::Request::from_parts(parts, body)) +} + +fn decode_body( + headers: &mut HeaderMap, + encoded: Bytes, + budget: &Arc, + max_body: usize, + policy: RequestDecompression, +) -> Result { + let Some(coding) = ContentCoding::from_headers(headers) else { return Ok(encoded); + }; + if encoded.is_empty() || !policy.allows(coding) { + return Ok(encoded); + } + + // Charge the codec before constructing it. This makes concurrent decoder + // windows participate in the same process-wide request-body ledger as the + // encoded and decoded byte buffers. + let mut workspace_lease = BodyBufferLease::new(budget.clone()); + if !workspace_lease.reserve(coding.workspace_charge()) { + return Err(StatusCode::SERVICE_UNAVAILABLE); } - let mut decoder = flate2::read::GzDecoder::new(encoded.as_ref()); let mut decoded = Vec::new(); let mut decoded_lease = BodyBufferLease::new(budget.clone()); let mut chunk = [0; 8192]; + let result = match coding { + ContentCoding::Gzip => { + let mut decoder = flate2::read::GzDecoder::new(encoded.as_ref()); + decode_reader( + &mut decoder, + &mut chunk, + &mut decoded, + &mut decoded_lease, + max_body, + ) + } + ContentCoding::Brotli => { + // Standard Brotli streams cap lgwin at 24. The crate's default + // decoder does not enable the non-standard large-window extension. + let mut decoder = brotli::Decompressor::new(encoded.as_ref(), 8192); + decode_reader( + &mut decoder, + &mut chunk, + &mut decoded, + &mut decoded_lease, + max_body, + ) + } + ContentCoding::Zstd => { + let mut decoder = match zstd::stream::read::Decoder::new(encoded.as_ref()) { + Ok(decoder) => decoder, + Err(_) => return Ok(encoded), + }; + // Refuse frames that ask the native decoder to allocate a window + // beyond the amount charged above. + if decoder.window_log_max(24).is_err() { + return Ok(encoded); + } + decode_reader( + &mut decoder, + &mut chunk, + &mut decoded, + &mut decoded_lease, + max_body, + ) + } + }; + + match result { + DecodeResult::Complete => { + headers.insert(header::CONTENT_LENGTH, HeaderValue::from(decoded.len())); + headers.remove(header::CONTENT_ENCODING); + Ok(decoded_lease.into_bytes(decoded)) + } + DecodeResult::MalformedOrCodecCap => Ok(encoded), + DecodeResult::TooLarge => Err(StatusCode::PAYLOAD_TOO_LARGE), + DecodeResult::NoCapacity => Err(StatusCode::SERVICE_UNAVAILABLE), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DecodeResult { + Complete, + MalformedOrCodecCap, + TooLarge, + NoCapacity, +} + +fn decode_reader( + decoder: &mut impl Read, + chunk: &mut [u8; 8192], + decoded: &mut Vec, + decoded_lease: &mut BodyBufferLease, + max_body: usize, +) -> DecodeResult { loop { - let n = match decoder.read(&mut chunk) { + let n = match decoder.read(chunk) { Ok(n) => n, - Err(_) => return Ok(encoded), + Err(_) => return DecodeResult::MalformedOrCodecCap, }; if n == 0 { - break; + return DecodeResult::Complete; } let size = decoded.len().saturating_add(n); if size > max_body { - return Err(StatusCode::PAYLOAD_TOO_LARGE); + return DecodeResult::TooLarge; } - // Preserve the existing codec-cap fallback, but never interpret a - // capacity rejection as permission to forward an unaccounted expansion. + // Preserve the historical gzip codec-cap fallback: a valid compressed + // representation that exceeds the server's decode ceiling is forwarded + // unchanged rather than silently truncated. if size as u64 >= hj_compress::MAX_DECODE { - return Ok(encoded); + return DecodeResult::MalformedOrCodecCap; } if !decoded_lease.reserve(n as u64) { - return Err(StatusCode::SERVICE_UNAVAILABLE); + return DecodeResult::NoCapacity; + } + if decoded.try_reserve_exact(n).is_err() { + return DecodeResult::NoCapacity; } - decoded - .try_reserve_exact(n) - .map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?; decoded.extend_from_slice(&chunk[..n]); } - headers.insert(header::CONTENT_LENGTH, HeaderValue::from(decoded.len())); - headers.remove(header::CONTENT_ENCODING); - Ok(decoded_lease.into_bytes(decoded)) } #[cfg(test)] mod tests { use super::*; - fn gzip(input: &[u8]) -> Vec { - hj_compress::encode_bytes(hj_compress::Encoding::Gzip, input, &Default::default()).unwrap() + fn encode(coding: ContentCoding, input: &[u8]) -> Vec { + let encoding = match coding { + ContentCoding::Gzip => hj_compress::Encoding::Gzip, + ContentCoding::Brotli => hj_compress::Encoding::Brotli, + ContentCoding::Zstd => hj_compress::Encoding::Zstd, + }; + hj_compress::encode_bytes(encoding, input, &Default::default()).unwrap() } fn run( + coding: ContentCoding, data: Vec, budget: &Arc, max: usize, + policy: RequestDecompression, ) -> (Result, HeaderMap) { let mut headers = HeaderMap::new(); - headers.insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip")); + let value = match coding { + ContentCoding::Gzip => "gzip", + ContentCoding::Brotli => "br", + ContentCoding::Zstd => "zstd", + }; + headers.insert(header::CONTENT_ENCODING, HeaderValue::from_static(value)); headers.insert(header::CONTENT_LENGTH, HeaderValue::from(data.len())); let mut lease = BodyBufferLease::new(budget.clone()); assert!(lease.reserve(data.len() as u64)); - let result = finish_body(&mut headers, data, Some(lease), budget, max); + let result = finish_body(&mut headers, data, Some(lease), budget, max, policy); (result, headers) } + #[test] + fn policy_is_gzip_only_by_default_and_strictly_parsed() { + let default = RequestDecompression::default(); + assert!(default.allows(ContentCoding::Gzip)); + assert!(!default.allows(ContentCoding::Brotli)); + assert!(!default.allows(ContentCoding::Zstd)); + + let both = RequestDecompression::parse_extra("br,zstd").unwrap(); + assert!(both.brotli && both.zstd); + assert!(RequestDecompression::parse_extra("gzip").is_err()); + assert!(RequestDecompression::parse_extra("deflate").is_err()); + } + #[test] fn expansion_remains_charged_until_last_frame_alias_drops() { let budget = Arc::new(BodyBufferBudget::new(100_000)); - let (result, headers) = run(gzip(&vec![b'x'; 32_000]), &budget, 64_000); + let data = encode(ContentCoding::Gzip, &vec![b'x'; 32_000]); + let (result, headers) = run( + ContentCoding::Gzip, + data, + &budget, + 64_000, + RequestDecompression::default(), + ); let body = result.unwrap(); assert_eq!(headers[header::CONTENT_LENGTH], "32000"); assert!(!headers.contains_key(header::CONTENT_ENCODING)); @@ -106,9 +339,15 @@ mod tests { #[test] fn simultaneous_encoded_and_decoded_bytes_must_fit() { - let data = gzip(&vec![0; 8192]); + let data = encode(ContentCoding::Gzip, &vec![0; 8192]); let budget = Arc::new(BodyBufferBudget::new(8192)); - let (result, headers) = run(data, &budget, 16384); + let (result, headers) = run( + ContentCoding::Gzip, + data, + &budget, + 16384, + RequestDecompression::default(), + ); assert_eq!(result.unwrap_err(), StatusCode::SERVICE_UNAVAILABLE); assert_eq!(headers[header::CONTENT_ENCODING], "gzip"); assert_eq!(budget.in_flight(), 0); @@ -116,35 +355,135 @@ mod tests { } #[test] - fn expanded_body_obeys_request_limit() { - let budget = Arc::new(BodyBufferBudget::new(100_000)); - assert_eq!( - run(gzip(&vec![0; 9000]), &budget, 8192).0.unwrap_err(), - StatusCode::PAYLOAD_TOO_LARGE - ); - assert_eq!(budget.in_flight(), 0); + fn each_enabled_codec_decodes_and_rewrites_entity_headers() { + let policy = RequestDecompression { + brotli: true, + zstd: true, + }; + for coding in [ + ContentCoding::Gzip, + ContentCoding::Brotli, + ContentCoding::Zstd, + ] { + let budget = Arc::new(BodyBufferBudget::new(64 * 1024 * 1024)); + let (result, headers) = run( + coding, + encode(coding, b"transport-independent body"), + &budget, + 4096, + policy, + ); + let body = result.unwrap(); + assert_eq!(body.as_ref(), b"transport-independent body"); + assert_eq!(headers[header::CONTENT_LENGTH], "26"); + assert!(!headers.contains_key(header::CONTENT_ENCODING)); + } } #[test] - fn malformed_gzip_preserves_original_body_and_policy() { - let data = b"not a gzip stream".to_vec(); - let budget = Arc::new(BodyBufferBudget::new(100_000)); - let (result, headers) = run(data.clone(), &budget, 100_000); - let body = result.unwrap(); - assert_eq!(body.as_ref(), data); - assert_eq!(headers[header::CONTENT_ENCODING], "gzip"); - assert_eq!(budget.in_flight(), data.len() as u64); - drop(body); - assert_eq!(budget.in_flight(), 0); + fn optional_codecs_stay_encoded_without_opt_in() { + for coding in [ContentCoding::Brotli, ContentCoding::Zstd] { + let data = encode(coding, b"hello"); + let budget = Arc::new(BodyBufferBudget::new(1 << 20)); + let (result, headers) = run( + coding, + data.clone(), + &budget, + 4096, + RequestDecompression::default(), + ); + assert_eq!(result.unwrap().as_ref(), data); + assert!(headers.contains_key(header::CONTENT_ENCODING)); + } + } + + #[test] + fn expanded_body_obeys_request_limit_for_every_codec() { + let policy = RequestDecompression { + brotli: true, + zstd: true, + }; + for coding in [ + ContentCoding::Gzip, + ContentCoding::Brotli, + ContentCoding::Zstd, + ] { + let budget = Arc::new(BodyBufferBudget::new(64 * 1024 * 1024)); + assert_eq!( + run( + coding, + encode(coding, &vec![0; 9000]), + &budget, + 8192, + policy, + ) + .0 + .unwrap_err(), + StatusCode::PAYLOAD_TOO_LARGE + ); + assert_eq!(budget.in_flight(), 0); + } + } + + #[test] + fn malformed_codings_preserve_original_body_and_policy() { + let policy = RequestDecompression { + brotli: true, + zstd: true, + }; + for coding in [ + ContentCoding::Gzip, + ContentCoding::Brotli, + ContentCoding::Zstd, + ] { + let data = b"not a compressed stream".to_vec(); + let budget = Arc::new(BodyBufferBudget::new(64 * 1024 * 1024)); + let (result, headers) = run(coding, data.clone(), &budget, 100_000, policy); + let body = result.unwrap(); + assert_eq!(body.as_ref(), data); + assert!(headers.contains_key(header::CONTENT_ENCODING)); + assert_eq!(budget.in_flight(), data.len() as u64); + drop(body); + assert_eq!(budget.in_flight(), 0); + } + } + + #[test] + fn codec_workspace_and_body_buffers_share_one_budget() { + let policy = RequestDecompression { + brotli: true, + zstd: true, + }; + for coding in [ContentCoding::Brotli, ContentCoding::Zstd] { + let data = encode(coding, &vec![0; 8192]); + let budget = Arc::new(BodyBufferBudget::new(17 * 1024 * 1024)); + let (result, headers) = run(coding, data, &budget, 16_384, policy); + assert_eq!(result.unwrap_err(), StatusCode::SERVICE_UNAVAILABLE); + assert!(headers.contains_key(header::CONTENT_ENCODING)); + assert_eq!(budget.in_flight(), 0); + assert_eq!(budget.rejected(), 1); + } } #[test] fn disabled_budget_still_decodes_and_empty_body_stays_empty() { let budget = Arc::new(BodyBufferBudget::new(0)); - let (result, _) = run(gzip(b"hello"), &budget, 100); + let (result, _) = run( + ContentCoding::Gzip, + encode(ContentCoding::Gzip, b"hello"), + &budget, + 100, + RequestDecompression::default(), + ); assert_eq!(result.unwrap().as_ref(), b"hello"); assert_eq!(budget.in_flight(), 0); - let (result, headers) = run(Vec::new(), &budget, 100); + let (result, headers) = run( + ContentCoding::Gzip, + Vec::new(), + &budget, + 100, + RequestDecompression::default(), + ); assert!(result.unwrap().is_empty()); assert_eq!(headers[header::CONTENT_ENCODING], "gzip"); } @@ -152,31 +491,133 @@ mod tests { #[test] fn retained_expansion_blocks_another_request_until_released() { let budget = Arc::new(BodyBufferBudget::new(20_000)); - let data = gzip(&vec![b'x'; 12_000]); - let first = run(data.clone(), &budget, 20_000).0.unwrap(); + let data = encode(ContentCoding::Gzip, &vec![b'x'; 12_000]); + let first = run( + ContentCoding::Gzip, + data.clone(), + &budget, + 20_000, + RequestDecompression::default(), + ) + .0 + .unwrap(); assert_eq!(budget.in_flight(), 12_000); assert_eq!( - run(data.clone(), &budget, 20_000).0.unwrap_err(), + run( + ContentCoding::Gzip, + data.clone(), + &budget, + 20_000, + RequestDecompression::default(), + ) + .0 + .unwrap_err(), StatusCode::SERVICE_UNAVAILABLE ); assert_eq!(budget.in_flight(), 12_000); drop(first); - let next = run(data, &budget, 20_000).0.unwrap(); - assert_eq!(next.len(), 12_000); + let next = run( + ContentCoding::Gzip, + data, + &budget, + 20_000, + RequestDecompression::default(), + ) + .0 + .unwrap(); drop(next); assert_eq!(budget.in_flight(), 0); } #[test] fn late_decode_error_releases_partial_output_but_retains_encoded_input() { - let mut data = gzip(&vec![b'x'; 24_000]); + let mut data = encode(ContentCoding::Gzip, &vec![b'x'; 24_000]); let footer = data.len() - 8; data[footer] ^= 1; let budget = Arc::new(BodyBufferBudget::new(100_000)); - let body = run(data.clone(), &budget, 100_000).0.unwrap(); + let body = run( + ContentCoding::Gzip, + data.clone(), + &budget, + 100_000, + RequestDecompression::default(), + ) + .0 + .unwrap(); assert_eq!(body.as_ref(), data); assert_eq!(budget.in_flight(), data.len() as u64); drop(body); assert_eq!(budget.in_flight(), 0); } + + #[test] + fn stacked_or_repeated_content_codings_are_never_partly_decoded() { + let data = encode(ContentCoding::Gzip, b"hello"); + let budget = Arc::new(BodyBufferBudget::new(100_000)); + for value in ["gzip, br", "gzip, gzip"] { + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_ENCODING, + HeaderValue::from_str(value).unwrap(), + ); + let encoded = Bytes::copy_from_slice(&data); + let body = decode_body( + &mut headers, + encoded, + &budget, + 100_000, + RequestDecompression { + brotli: true, + zstd: true, + }, + ) + .unwrap(); + assert_eq!(body.as_ref(), data); + assert_eq!(headers[header::CONTENT_ENCODING], value); + } + } + + #[tokio::test] + async fn bridged_body_decoding_preserves_request_metadata_and_accounting() { + let budget = Arc::new(BodyBufferBudget::new(64 * 1024 * 1024)); + let data = encode(ContentCoding::Brotli, b"hello over h2 or h3"); + let mut lease = BodyBufferLease::new(budget.clone()); + assert!(lease.reserve(data.len() as u64)); + let bytes = lease.into_bytes(data); + let body = http_body_util::Full::new(bytes) + .map_err(|never| match never {}) + .boxed(); + let mut req = http::Request::builder() + .method("POST") + .uri("/upload?transport=bridged") + .header(header::CONTENT_ENCODING, "br") + .header(header::CONTENT_LENGTH, "23") + .header("x-test", "retained") + .body(body) + .unwrap(); + req.extensions_mut().insert(42_u32); + + let req = finish_bridged_request( + req, + &budget, + 4096, + RequestDecompression { + brotli: true, + zstd: false, + }, + ) + .await + .unwrap(); + assert_eq!(req.method(), http::Method::POST); + assert_eq!(req.uri(), "/upload?transport=bridged"); + assert_eq!(req.headers()["x-test"], "retained"); + assert_eq!(req.headers()[header::CONTENT_LENGTH], "19"); + assert!(!req.headers().contains_key(header::CONTENT_ENCODING)); + assert_eq!(req.extensions().get::(), Some(&42)); + let body = req.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(body.as_ref(), b"hello over h2 or h3"); + assert_eq!(budget.in_flight(), body.len() as u64); + drop(body); + assert_eq!(budget.in_flight(), 0); + } } diff --git a/crates/httpjet/src/uring/unix_path.rs b/crates/httpjet/src/uring/unix_path.rs new file mode 100644 index 0000000..c1cbdfc --- /dev/null +++ b/crates/httpjet/src/uring/unix_path.rs @@ -0,0 +1,224 @@ +//! Filesystem ownership for self-bound Unix listeners. Inherited listeners never +//! acquire this guard: their pathname belongs to the socket-activation manager. + +use std::{ + fs::{File, OpenOptions}, + io, + os::{ + fd::AsRawFd, + unix::{ + fs::{FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt}, + net::UnixListener, + }, + }, + path::{Path, PathBuf}, +}; + +pub(crate) struct OwnedUnixPath { + // Pin the directory across renames and symlink changes in ancestor paths. + _directory: File, + pinned_path: PathBuf, + requested_path: PathBuf, + device: u64, + inode: u64, +} + +impl OwnedUnixPath { + pub(super) fn bind(path: &Path) -> io::Result<(UnixListener, Self)> { + let name = path.file_name().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "unix listener needs a filename", + ) + })?; + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let directory = OpenOptions::new() + .read(true) + .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(parent)?; + let metadata = directory.metadata()?; + // Sticky directories (e.g. /tmp) protect our socket from other users. + // Otherwise directory entries must not be writable by group/other. + // The operator/root remains trusted; inode checks are not a sandbox + // against another process running with our own privileges. + let uid = unsafe { libc::geteuid() }; + if ![0, uid].contains(&metadata.uid()) + || (metadata.mode() & 0o022 != 0 && metadata.mode() & 0o1000 == 0) + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "unix listener directory must be operator-owned and protected from entry replacement", + )); + } + let pinned_path = + PathBuf::from(format!("/proc/self/fd/{}", directory.as_raw_fd())).join(name); + // Never probe-and-unlink an existing entry: connect failure does not + // prove ownership, and may mean permission failure rather than staleness. + let listener = UnixListener::bind(&pinned_path)?; + let metadata = std::fs::symlink_metadata(&pinned_path)?; + if !metadata.file_type().is_socket() { + return Err(io::Error::other( + "unix listener pathname changed during bind", + )); + } + let owned = Self { + _directory: directory, + pinned_path, + requested_path: path.to_path_buf(), + device: metadata.dev(), + inode: metadata.ino(), + }; + std::fs::set_permissions(&owned.pinned_path, std::fs::Permissions::from_mode(0o660))?; + listener.set_nonblocking(true)?; + Ok((listener, owned)) + } + + pub(crate) fn matches_requested_path(&self, path: &Path) -> bool { + self.requested_path == path + } +} + +impl Drop for OwnedUnixPath { + fn drop(&mut self) { + let Ok(metadata) = std::fs::symlink_metadata(&self.pinned_path) else { + return; + }; + if metadata.file_type().is_socket() + && metadata.dev() == self.device + && metadata.ino() == self.inode + && let Err(error) = std::fs::remove_file(&self.pinned_path) + { + tracing::warn!(%error, "could not remove owned unix listener pathname"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Fixture(PathBuf); + impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "hj-owned-uds-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&root).unwrap(); + Self(root) + } + fn path(&self) -> PathBuf { + self.0.join("http.sock") + } + } + impl Drop for Fixture { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).unwrap(); + } + } + + #[test] + fn owned_socket_is_removed_and_can_be_rebound() { + let fixture = Fixture::new(); + let (listener, owned) = OwnedUnixPath::bind(&fixture.path()).unwrap(); + assert_eq!( + std::fs::metadata(fixture.path()).unwrap().mode() & 0o777, + 0o660 + ); + drop(listener); + drop(owned); + assert!(!fixture.path().exists()); + let (listener, owned) = OwnedUnixPath::bind(&fixture.path()).unwrap(); + drop(listener); + drop(owned); + } + + #[test] + fn existing_files_symlinks_and_stale_sockets_are_never_removed() { + let fixture = Fixture::new(); + let path = fixture.path(); + std::fs::write(&path, b"unrelated").unwrap(); + assert!(OwnedUnixPath::bind(&path).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"unrelated"); + std::fs::remove_file(&path).unwrap(); + std::os::unix::fs::symlink("missing-target", &path).unwrap(); + assert!(OwnedUnixPath::bind(&path).is_err()); + assert!(std::fs::symlink_metadata(&path).unwrap().is_symlink()); + std::fs::remove_file(&path).unwrap(); + drop(UnixListener::bind(&path).unwrap()); + let inode = std::fs::metadata(&path).unwrap().ino(); + assert!(OwnedUnixPath::bind(&path).is_err()); + assert_eq!(std::fs::metadata(&path).unwrap().ino(), inode); + } + + #[test] + fn retirement_preserves_replacement_entry() { + let fixture = Fixture::new(); + let (listener, owned) = OwnedUnixPath::bind(&fixture.path()).unwrap(); + std::fs::remove_file(fixture.path()).unwrap(); + let replacement = UnixListener::bind(fixture.path()).unwrap(); + let inode = std::fs::metadata(fixture.path()).unwrap().ino(); + drop(listener); + drop(owned); + assert_eq!(std::fs::metadata(fixture.path()).unwrap().ino(), inode); + drop(replacement); + } + + #[test] + fn cleanup_follows_pinned_directory_not_replaced_parent() { + let fixture = Fixture::new(); + let parent = fixture.0.join("parent"); + std::fs::create_dir(&parent).unwrap(); + let (listener, owned) = OwnedUnixPath::bind(&parent.join("http.sock")).unwrap(); + let moved = fixture.0.join("moved"); + std::fs::rename(&parent, &moved).unwrap(); + std::fs::create_dir(&parent).unwrap(); + std::fs::write(parent.join("http.sock"), b"replacement").unwrap(); + drop(listener); + drop(owned); + assert!(!moved.join("http.sock").exists()); + assert_eq!( + std::fs::read(parent.join("http.sock")).unwrap(), + b"replacement" + ); + } + + #[test] + fn unprotected_shared_directory_is_rejected() { + let fixture = Fixture::new(); + std::fs::set_permissions(&fixture.0, std::fs::Permissions::from_mode(0o777)).unwrap(); + assert_eq!( + OwnedUnixPath::bind(&fixture.path()).err().unwrap().kind(), + io::ErrorKind::PermissionDenied + ); + assert!(!fixture.path().exists()); + } + + #[test] + fn sticky_shared_directory_is_supported() { + let fixture = Fixture::new(); + std::fs::set_permissions(&fixture.0, std::fs::Permissions::from_mode(0o1777)).unwrap(); + let (listener, owned) = OwnedUnixPath::bind(&fixture.path()).unwrap(); + drop(listener); + drop(owned); + assert!(!fixture.path().exists()); + } + + #[test] + fn final_parent_symlink_is_rejected_without_creating_socket() { + let fixture = Fixture::new(); + let real = fixture.0.join("real"); + let link = fixture.0.join("link"); + std::fs::create_dir(&real).unwrap(); + std::os::unix::fs::symlink(&real, &link).unwrap(); + assert!(OwnedUnixPath::bind(&link.join("http.sock")).is_err()); + assert!(!real.join("http.sock").exists()); + } +} diff --git a/crates/httpjet/src/uring/worker_group.rs b/crates/httpjet/src/uring/worker_group.rs new file mode 100644 index 0000000..6d5b5e9 --- /dev/null +++ b/crates/httpjet/src/uring/worker_group.rs @@ -0,0 +1,1194 @@ +//! Ownership of transport threads, including partial-startup rollback. + +use std::{io, thread}; +use tokio_util::sync::CancellationToken; + +const MAX_RACED_ACCEPTS: usize = 1024; + +/// Logical lookup key within a resource generation. This does not establish +/// socket ownership or policy compatibility: handoff still verifies SO_COOKIE +/// sets and the coordinator verifies the owning trust epoch. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct TcpListenerId { + pub(crate) name: std::sync::Arc, + pub(crate) tls: bool, +} + +/// Linux assigns a socket cookie to the kernel socket, not its descriptor or +/// address. Duplicated descriptors compare equal; separately bound reuseport +/// sockets do not. Query while the factory still owns the listening descriptor. +fn listener_cookie(listener: &std::net::TcpListener) -> io::Result { + use std::os::fd::AsRawFd; + let mut accepting: libc::c_int = 0; + let mut length = std::mem::size_of_val(&accepting) as libc::socklen_t; + // SAFETY: both option buffers and their lengths match the kernel option ABI. + let result = unsafe { + libc::getsockopt( + listener.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_ACCEPTCONN, + (&mut accepting as *mut libc::c_int).cast(), + &mut length, + ) + }; + if result != 0 { + return Err(io::Error::last_os_error()); + } + if accepting != 1 || length as usize != std::mem::size_of_val(&accepting) { + return Err(io::Error::other("TCP resource is not a listening socket")); + } + let mut cookie: u64 = 0; + let mut length = std::mem::size_of_val(&cookie) as libc::socklen_t; + let result = unsafe { + libc::getsockopt( + listener.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_COOKIE, + (&mut cookie as *mut u64).cast(), + &mut length, + ) + }; + if result != 0 { + return Err(io::Error::last_os_error()); + } + if length as usize != std::mem::size_of_val(&cookie) { + return Err(io::Error::other("invalid listening socket identity")); + } + Ok(cookie) +} + +/// Retain this owner while serving. Dropping it signals every worker before +/// joining any worker. Cancellation flows from the process to this group, never +/// from a failed candidate back to the process or another listener group. +/// +/// Joining is synchronous: workers must implement cancellation and their own +/// drain deadline. This does not bound an uninterruptible kernel operation. +#[must_use = "dropping the group stops and joins its listener workers"] +pub(crate) struct WorkerGroup { + shutdown: CancellationToken, + activation: CancellationToken, + trust_epoch: Option>, + tcp_identity: Option, + uds_identity: Option, + accept_stopped: Vec, + listener_sockets: std::collections::BTreeSet, + listener_owners: Vec>, + topology_frozen: std::sync::atomic::AtomicBool, + predecessors: std::sync::Arc>, + uds_predecessor: std::sync::Arc>, + uds_listener: Option>, + uds_stop_ack: Option, + uds_path_owner: Option>, + raced: Option<( + flume::Sender, + flume::Receiver, + )>, + threads: Vec>, +} + +/// A one-way readiness-to-serving barrier. Cancellation wins when both signals +/// are already present. Only the group owner can release this barrier. +#[derive(Clone)] +pub(crate) struct ActivationGate { + shutdown: CancellationToken, + activation: CancellationToken, + predecessors: std::sync::Arc>, + uds_predecessor: std::sync::Arc>, +} + +/// Opaque predecessor acknowledgments; only TCP worker factories register them. +#[derive(Clone)] +pub(crate) struct AcceptHandoff { + stopped: Vec, + listener_sockets: std::collections::BTreeSet, + raced: flume::Receiver, +} + +pub(crate) struct AcceptRetirement { + stopped: CancellationToken, + raced: flume::Sender, + listener: Option>, +} + +impl AcceptRetirement { + pub(crate) fn cancel(&mut self) { + self.listener.take(); + self.stopped.cancel(); + } + + pub(crate) fn transfer(&self, stream: std::net::TcpStream) { + // One receiver is retained by the old owner solely for future candidate + // registration. Ordinary shutdown has no successor and closes the fd. + if self.raced.receiver_count() > 1 && self.raced.try_send(stream).is_err() { + tracing::warn!("TCP handoff queue full or successor gone; rejecting raced connection"); + } + } +} + +/// Pin active socket ownership briefly under the coordinator lock, then duplicate +/// descriptors outside it. Dropping the source or prepared candidate closes only +/// these extra owners, never the active workers' descriptors. +pub(crate) struct TcpHandoffSource { + listeners: Vec>, + predecessor: AcceptHandoff, +} + +pub(crate) struct PreparedTcpHandoff { + pub(crate) listeners: Vec, + pub(crate) predecessor: AcceptHandoff, +} + +/// A duplicated AF_UNIX listener plus shared pathname ownership. The shared +/// owner prevents retirement of the predecessor from unlinking the live +/// successor's pathname. +pub(crate) struct PreparedUdsHandoff { + pub(crate) listener: std::os::unix::net::UnixListener, + predecessor: CancellationToken, + path_owner: Option>, +} + +pub(crate) struct UdsHandoffSource { + identity: std::path::PathBuf, + listener: std::sync::Arc, + predecessor: CancellationToken, + path_owner: Option>, +} + +pub(crate) struct UdsAcceptRetirement { + stopped: CancellationToken, + listener: Option>, +} + +impl UdsAcceptRetirement { + pub(crate) fn cancel(&mut self) { + self.listener.take(); + self.stopped.cancel(); + } +} + +impl Drop for UdsAcceptRetirement { + fn drop(&mut self) { + self.cancel(); + } +} + +impl UdsHandoffSource { + pub(crate) fn prepare(self, expected_path: &std::path::Path) -> io::Result { + if self.identity != expected_path { + return Err(io::Error::other( + "UDS handoff endpoint differs from planned path", + )); + } + Ok(PreparedUdsHandoff { + listener: self.listener.try_clone()?, + predecessor: self.predecessor, + path_owner: self.path_owner, + }) + } +} + +impl PreparedUdsHandoff { + pub(crate) fn into_parts( + self, + ) -> ( + std::os::unix::net::UnixListener, + CancellationToken, + Option>, + ) { + (self.listener, self.predecessor, self.path_owner) + } +} + +impl TcpHandoffSource { + pub(crate) fn prepare( + self, + expected_address: std::net::SocketAddr, + ) -> io::Result { + // Check every inherited/reuseport descriptor before cloning any. Name + // selection alone does not prove that this is the requested endpoint. + if self.listeners.is_empty() || expected_address.port() == 0 { + return Err(io::Error::other("TCP handoff requires a resolved endpoint")); + } + for socket in &self.listeners { + if socket.local_addr()? != expected_address { + return Err(io::Error::other( + "TCP handoff endpoint differs from planned address", + )); + } + } + let listeners = self + .listeners + .iter() + .map(|socket| socket.try_clone()) + .collect::>()?; + Ok(PreparedTcpHandoff { + listeners, + predecessor: self.predecessor, + }) + } +} + +impl ActivationGate { + pub(crate) async fn wait(&self) -> bool { + let activated = tokio::select! { + biased; + _ = self.shutdown.cancelled() => false, + _ = self.activation.cancelled() => !self.shutdown.is_cancelled(), + }; + if !activated { + return false; + } + if let Some(predecessors) = self.predecessors.get() { + for stopped in &predecessors.stopped { + tokio::select! { + biased; + _ = self.shutdown.cancelled() => return false, + _ = stopped.cancelled() => {}, + } + } + } + if let Some(stopped) = self.uds_predecessor.get() { + tokio::select! { + biased; + _ = self.shutdown.cancelled() => return false, + _ = stopped.cancelled() => {}, + } + } + !self.shutdown.is_cancelled() + } + + pub(crate) fn raced_connections(&self) -> Option> { + self.predecessors.get().map(|old| old.raced.clone()) + } +} + +impl WorkerGroup { + pub(crate) fn new(parent: &CancellationToken) -> Self { + Self { + shutdown: parent.child_token(), + activation: CancellationToken::new(), + trust_epoch: None, + tcp_identity: None, + uds_identity: None, + accept_stopped: Vec::new(), + listener_sockets: Default::default(), + listener_owners: Vec::new(), + topology_frozen: std::sync::atomic::AtomicBool::new(false), + predecessors: Default::default(), + uds_predecessor: Default::default(), + uds_listener: None, + uds_stop_ack: None, + uds_path_owner: None, + raced: None, + threads: Vec::new(), + } + } + + pub(crate) fn for_epoch(parent: &CancellationToken, epoch: std::sync::Arc<()>) -> Self { + let mut group = Self::new(parent); + group.trust_epoch = Some(epoch); + group + } + + pub(crate) fn for_tcp_epoch( + parent: &CancellationToken, + epoch: std::sync::Arc<()>, + identity: TcpListenerId, + ) -> Self { + let mut group = Self::for_epoch(parent, epoch); + group.tcp_identity = Some(identity); + group + } + + pub(crate) fn for_uds_epoch( + parent: &CancellationToken, + epoch: std::sync::Arc<()>, + path: std::path::PathBuf, + ) -> Self { + let mut group = Self::for_epoch(parent, epoch); + group.uds_identity = Some(path); + group + } + + pub(crate) fn tcp_identity(&self) -> Option<&TcpListenerId> { + self.tcp_identity.as_ref() + } + + pub(crate) fn uds_identity(&self) -> Option<&std::path::Path> { + self.uds_identity.as_deref() + } + + pub(crate) fn is_prepared_for(&self, epoch: &std::sync::Arc<()>) -> bool { + let uds_shape = match ( + self.uds_identity.as_ref(), + self.uds_listener.as_ref(), + self.uds_stop_ack.as_ref(), + ) { + (None, None, None) => true, + (Some(_), Some(listener), Some(_)) => listener.upgrade().is_some(), + _ => false, + }; + self.trust_epoch + .as_ref() + .is_some_and(|owned| std::sync::Arc::ptr_eq(owned, epoch)) + && !(self.tcp_identity.is_some() && self.uds_identity.is_some()) + && uds_shape + && !self.activation.is_cancelled() + && !self.shutdown.is_cancelled() + && self.threads.iter().all(|thread| !thread.is_finished()) + } + + pub(crate) fn activation_gate(&self) -> ActivationGate { + ActivationGate { + shutdown: self.shutdown.clone(), + activation: self.activation.clone(), + predecessors: self.predecessors.clone(), + uds_predecessor: self.uds_predecessor.clone(), + } + } + + pub(crate) fn register_uds_acceptor( + &mut self, + listener: std::sync::Arc, + path_owner: Option>, + ) -> io::Result { + let expected = self + .uds_identity + .as_deref() + .ok_or_else(|| io::Error::other("group has no UDS identity"))?; + let identity_matches = path_owner.as_ref().map_or_else( + || { + listener + .local_addr() + .is_ok_and(|addr| addr.as_pathname() == Some(expected)) + }, + |owner| owner.matches_requested_path(expected), + ); + if self.activation.is_cancelled() + || self.tcp_identity.is_some() + || self.uds_listener.is_some() + || self.uds_predecessor.get().is_some() + || !identity_matches + { + return Err(io::Error::other("invalid UDS acceptor registration")); + } + let stopped = CancellationToken::new(); + self.uds_listener = Some(std::sync::Arc::downgrade(&listener)); + self.uds_stop_ack = Some(stopped.clone()); + self.uds_path_owner = path_owner; + Ok(UdsAcceptRetirement { + stopped: stopped.clone(), + listener: Some(listener), + }) + } + + pub(crate) fn uds_handoff_source(&self) -> io::Result { + if !self.activation.is_cancelled() + || self.shutdown.is_cancelled() + || self.threads.iter().any(|worker| worker.is_finished()) + { + return Err(io::Error::other("UDS listener is not active")); + } + let listener = self + .uds_listener + .as_ref() + .and_then(std::sync::Weak::upgrade) + .ok_or_else(|| io::Error::other("not a UDS group"))?; + let predecessor = self + .uds_accept_stopped() + .ok_or_else(|| io::Error::other("UDS acceptor is missing"))?; + Ok(UdsHandoffSource { + identity: self + .uds_identity + .clone() + .expect("registered UDS listener has an identity"), + listener, + predecessor, + path_owner: self.uds_path_owner.clone(), + }) + } + + fn uds_accept_stopped(&self) -> Option { + // The worker-owned retirement handle holds the only other clone and + // cancels it after its accept loop has stopped. + self.uds_stop_ack.clone() + } + + pub(crate) fn register_acceptor( + &mut self, + listener: &std::net::TcpListener, + ) -> io::Result { + if self.activation.is_cancelled() + || self.predecessors.get().is_some() + || self + .topology_frozen + .load(std::sync::atomic::Ordering::Acquire) + { + return Err(io::Error::other("acceptor topology is already frozen")); + } + let cookie = listener_cookie(listener)?; + let listener = std::sync::Arc::new(listener.try_clone()?); + self.listener_sockets.insert(cookie); + self.listener_owners + .push(std::sync::Arc::downgrade(&listener)); + let stopped = CancellationToken::new(); + self.accept_stopped.push(stopped.clone()); + let (sender, _) = self + .raced + .get_or_insert_with(|| flume::bounded(MAX_RACED_ACCEPTS)); + Ok(AcceptRetirement { + stopped, + raced: sender.clone(), + listener: Some(listener), + }) + } + + pub(crate) fn tcp_handoff_source(&self) -> io::Result { + if !self.activation.is_cancelled() + || self.shutdown.is_cancelled() + || self.threads.iter().any(|worker| worker.is_finished()) + { + return Err(io::Error::other("TCP listener is not active")); + } + let predecessor = self + .accept_handoff() + .ok_or_else(|| io::Error::other("not a TCP group"))?; + let listeners = self + .listener_owners + .iter() + .map(|owner| { + owner + .upgrade() + .ok_or_else(|| io::Error::other("TCP listener is retiring")) + }) + .collect::>>()?; + Ok(TcpHandoffSource { + listeners, + predecessor, + }) + } + + pub(crate) fn accept_handoff(&self) -> Option { + self.topology_frozen + .store(true, std::sync::atomic::Ordering::Release); + self.raced.as_ref().map(|(_, receiver)| AcceptHandoff { + stopped: self.accept_stopped.clone(), + listener_sockets: self.listener_sockets.clone(), + raced: receiver.clone(), + }) + } + + /// Bind a prepared TCP successor to the old workers' explicit kernel-accept + /// completion acknowledgments. This does not stop the predecessor itself. + #[allow(dead_code)] // Resource topology acquisition wires this before publication. + pub(crate) fn follow_acceptors(&self, predecessor: AcceptHandoff) -> io::Result<()> { + if self.activation.is_cancelled() + || self.shutdown.is_cancelled() + || self.accept_stopped.is_empty() + || self.listener_sockets != predecessor.listener_sockets + { + return Err(io::Error::other("invalid acceptor handoff")); + } + self.predecessors + .set(predecessor) + .map_err(|_| io::Error::other("acceptor handoff already assigned")) + } + + /// Wait for the predecessor's AF_UNIX accept loop to stop before the + /// duplicated listener begins accepting. The kernel accept queue remains + /// attached to the shared socket inode throughout the handoff. + pub(crate) fn follow_uds_acceptor(&self, predecessor: CancellationToken) -> io::Result<()> { + if self.activation.is_cancelled() + || self.shutdown.is_cancelled() + || self.uds_listener.is_none() + || predecessor.is_cancelled() + { + return Err(io::Error::other("invalid UDS acceptor handoff")); + } + self.uds_predecessor + .set(predecessor) + .map_err(|_| io::Error::other("UDS acceptor handoff already assigned")) + } + + /// Release prepared workers. No allocation, bind or worker spawn occurs here. + /// The caller must establish the publication/close boundary before activating. + pub(crate) fn activate(&self) { + self.activation.cancel(); + } + + pub(crate) fn stop(&self) { + self.shutdown.cancel(); + } + + pub(crate) fn is_finished(&self) -> bool { + self.threads.iter().all(thread::JoinHandle::is_finished) + } + + pub(crate) fn spawn( + &mut self, + builder: thread::Builder, + run: impl FnOnce(CancellationToken) + Send + 'static, + ) -> io::Result<()> { + let shutdown = self.shutdown.clone(); + self.threads.push(builder.spawn(move || run(shutdown))?); + Ok(()) + } +} + +impl Drop for WorkerGroup { + fn drop(&mut self) { + self.shutdown.cancel(); + for handle in self.threads.drain(..) { + // A panicking worker still must not prevent joining its siblings. + if handle.join().is_err() { + tracing::error!("transport worker panicked during retirement"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + struct UdsFixture(std::path::PathBuf); + + impl UdsFixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "hj-uds-handoff-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + + fn socket(&self) -> std::path::PathBuf { + self.0.join("http.sock") + } + } + + impl Drop for UdsFixture { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.0).unwrap(); + } + } + + #[tokio::test] + async fn uds_handoff_preserves_path_owner_and_waits_for_predecessor() { + let fixture = UdsFixture::new(); + let path = fixture.socket(); + let (listener, owner) = super::super::unix_path::OwnedUnixPath::bind(&path).unwrap(); + let parent = CancellationToken::new(); + let old_epoch = Arc::new(()); + let mut old = WorkerGroup::for_uds_epoch(&parent, old_epoch, path.clone()); + let mut old_retirement = old + .register_uds_acceptor(Arc::new(listener), Some(Arc::new(owner))) + .unwrap(); + old.activate(); + + assert!( + old.uds_handoff_source() + .unwrap() + .prepare(&fixture.0.join("wrong.sock")) + .is_err() + ); + let prepared = old.uds_handoff_source().unwrap().prepare(&path).unwrap(); + let (listener, predecessor, owner) = prepared.into_parts(); + let new_epoch = Arc::new(()); + let mut next = WorkerGroup::for_uds_epoch(&parent, new_epoch, path.clone()); + let mut next_retirement = next + .register_uds_acceptor(Arc::new(listener), owner) + .unwrap(); + next.follow_uds_acceptor(predecessor).unwrap(); + let gate = next.activation_gate(); + next.activate(); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(10), gate.wait()) + .await + .is_err(), + "successor must not accept beside its predecessor" + ); + + old_retirement.cancel(); + assert!( + tokio::time::timeout(std::time::Duration::from_secs(1), gate.wait()) + .await + .unwrap() + ); + drop(old); + assert!(path.exists(), "successor retains pathname ownership"); + + next_retirement.cancel(); + drop(next); + assert!(!path.exists(), "last generation removes its owned pathname"); + assert!(!parent.is_cancelled()); + } + + fn waiting_worker(token: CancellationToken, exited: Arc) { + tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(token.cancelled()); + exited.fetch_add(1, Ordering::SeqCst); + } + + #[test] + fn tcp_acquisition_rejects_mixed_endpoints_without_changing_active_ownership() { + let first = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let second = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = first.local_addr().unwrap(); + let other = second.local_addr().unwrap(); + let parent = CancellationToken::new(); + let mut group = WorkerGroup::new(&parent); + let mut first_owner = group.register_acceptor(&first).unwrap(); + let mut second_owner = group.register_acceptor(&second).unwrap(); + group.activate(); + for expected in [address, other, "127.0.0.1:0".parse().unwrap()] { + assert!( + group + .tcp_handoff_source() + .unwrap() + .prepare(expected) + .is_err() + ); + } + // A rejected candidate must not stop either acceptor or steal its fd. + let one = std::net::TcpStream::connect(address).unwrap(); + let two = std::net::TcpStream::connect(other).unwrap(); + drop(first.accept().unwrap()); + drop(second.accept().unwrap()); + drop((one, two)); + group.stop(); + first_owner.cancel(); + second_owner.cancel(); + drop((first, second, group)); + assert!(!parent.is_cancelled()); + } + + #[test] + fn tcp_source_acquisition_preserves_identity_and_releases_rollback_owners() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let identity = listener_cookie(&listener).unwrap(); + let parent = CancellationToken::new(); + let mut group = WorkerGroup::new(&parent); + let mut retirement = group.register_acceptor(&listener).unwrap(); + assert!( + group.tcp_handoff_source().is_err(), + "prepared is not active" + ); + group.activate(); + let candidate = group + .tcp_handoff_source() + .unwrap() + .prepare(address) + .unwrap(); + assert_eq!(candidate.listeners.len(), 1); + assert_eq!(listener_cookie(&candidate.listeners[0]).unwrap(), identity); + drop(candidate); + // Rollback did not steal active ownership: another acquisition works. + let source = group.tcp_handoff_source().unwrap(); + drop(source); + drop(listener); + group.stop(); + assert!( + group.tcp_handoff_source().is_err(), + "retiring is not active" + ); + retirement.cancel(); + let rebound = std::net::TcpListener::bind(address).unwrap(); + drop(rebound); + assert!(!parent.is_cancelled()); + } + + #[test] + fn handoff_requires_identical_kernel_socket_set_not_matching_addresses() { + let first = super::super::reuseport_std_listener("127.0.0.1:0".parse().unwrap()).unwrap(); + let duplicate = first.try_clone().unwrap(); + let separate = super::super::reuseport_std_listener(first.local_addr().unwrap()).unwrap(); + assert_eq!( + listener_cookie(&first).unwrap(), + listener_cookie(&duplicate).unwrap() + ); + assert_ne!( + listener_cookie(&first).unwrap(), + listener_cookie(&separate).unwrap() + ); + let parent = CancellationToken::new(); + let mut old = WorkerGroup::new(&parent); + old.register_acceptor(&first).unwrap(); + let mut wrong = WorkerGroup::new(&parent); + wrong.register_acceptor(&separate).unwrap(); + assert!( + wrong + .follow_acceptors(old.accept_handoff().unwrap()) + .is_err() + ); + + let mut same = WorkerGroup::new(&parent); + same.register_acceptor(&duplicate).unwrap(); + same.follow_acceptors(old.accept_handoff().unwrap()) + .unwrap(); + assert!( + same.register_acceptor(&separate).is_err(), + "identity set is frozen after handoff assignment" + ); + + // An inherited reuseport set can contain several distinct kernel queues. + // Keeping only one queue would strand the rest, despite matching ports. + assert!( + old.register_acceptor(&separate).is_err(), + "exported predecessor topology is frozen" + ); + let mut old_set = WorkerGroup::new(&parent); + old_set.register_acceptor(&first).unwrap(); + old_set.register_acceptor(&separate).unwrap(); + let mut incomplete = WorkerGroup::new(&parent); + incomplete.register_acceptor(&duplicate).unwrap(); + assert!( + incomplete + .follow_acceptors(old_set.accept_handoff().unwrap()) + .is_err() + ); + let mut complete = WorkerGroup::new(&parent); + complete.register_acceptor(&duplicate).unwrap(); + complete + .register_acceptor(&separate.try_clone().unwrap()) + .unwrap(); + complete + .follow_acceptors(old_set.accept_handoff().unwrap()) + .unwrap(); + assert!(!parent.is_cancelled()); + } + + #[test] + fn non_listening_descriptor_cannot_register_tcp_handoff() { + let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + let fd: std::os::fd::OwnedFd = socket.into(); + let listener = std::net::TcpListener::from(fd); + let mut group = WorkerGroup::new(&CancellationToken::new()); + assert!(group.register_acceptor(&listener).is_err()); + assert!(group.accept_handoff().is_none()); + } + + #[test] + fn raced_accept_queue_is_bounded_and_shutdown_without_successor_closes() { + use std::io::Read; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let mut client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + client + .set_read_timeout(Some(std::time::Duration::from_secs(2))) + .unwrap(); + let (stream, _) = listener.accept().unwrap(); + let parent = CancellationToken::new(); + let mut group = WorkerGroup::new(&parent); + let retirement = group.register_acceptor(&listener).unwrap(); + retirement.transfer(stream.try_clone().unwrap()); + assert_eq!( + group.raced.as_ref().unwrap().1.len(), + 0, + "no successor means no queueing" + ); + let successor = group.accept_handoff().unwrap(); + for _ in 0..MAX_RACED_ACCEPTS + 1 { + retirement.transfer(stream.try_clone().unwrap()); + } + assert_eq!(successor.raced.len(), MAX_RACED_ACCEPTS); + drop(stream); + drop(successor); + drop(retirement); + drop(group); + assert_eq!( + client.read(&mut [0u8; 1]).unwrap(), + 0, + "all queued fd owners must close" + ); + assert!(!parent.is_cancelled()); + } + + #[tokio::test] + async fn successor_waits_for_every_acceptor_and_remains_cancellable() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let parent = CancellationToken::new(); + let mut old = WorkerGroup::new(&parent); + let mut first = old.register_acceptor(&listener).unwrap(); + let mut second = old.register_acceptor(&listener).unwrap(); + let mut next = WorkerGroup::new(&parent); + next.register_acceptor(&listener).unwrap(); + let gate = next.activation_gate(); + next.follow_acceptors(old.accept_handoff().unwrap()) + .unwrap(); + assert!( + next.follow_acceptors(old.accept_handoff().unwrap()) + .is_err() + ); + next.activate(); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(10), gate.wait()) + .await + .is_err() + ); + first.cancel(); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(10), gate.wait()) + .await + .is_err() + ); + second.cancel(); + assert!(gate.wait().await); + assert!(!parent.is_cancelled()); + + let mut pending = WorkerGroup::new(&parent); + pending.register_acceptor(&listener).unwrap(); + let mut cancelled = WorkerGroup::new(&parent); + cancelled.register_acceptor(&listener).unwrap(); + cancelled + .follow_acceptors(pending.accept_handoff().unwrap()) + .unwrap(); + let gate = cancelled.activation_gate(); + cancelled.activate(); + cancelled.stop(); + assert!(!gate.wait().await); + assert!(!parent.is_cancelled()); + } + + #[tokio::test] + async fn activation_is_explicit_and_cancellation_takes_precedence() { + let parent = CancellationToken::new(); + let group = WorkerGroup::new(&parent); + let gate = group.activation_gate(); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(20), gate.wait()) + .await + .is_err() + ); + group.activate(); + assert!(gate.wait().await); + assert!( + gate.wait().await, + "activation is retained, not a one-shot wake" + ); + parent.cancel(); + group.activate(); + assert!( + !gate.wait().await, + "activation cannot revive a cancelled group" + ); + } + + #[test] + fn dropping_prepared_group_releases_waiters_without_activation() { + let parent = CancellationToken::new(); + let mut group = WorkerGroup::new(&parent); + let exited = Arc::new(AtomicUsize::new(0)); + for _ in 0..3 { + let gate = group.activation_gate(); + let exited = exited.clone(); + group + .spawn(thread::Builder::new(), move |_| { + let active = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(gate.wait()); + assert!(!active, "rollback must not activate a prepared worker"); + exited.fetch_add(1, Ordering::SeqCst); + }) + .unwrap(); + } + drop(group); + assert_eq!(exited.load(Ordering::SeqCst), 3); + assert!(!parent.is_cancelled()); + } + + #[test] + fn dropping_group_joins_all_workers_without_cancelling_siblings() { + let parent = CancellationToken::new(); + let sibling = parent.child_token(); + let exited = Arc::new(AtomicUsize::new(0)); + let mut group = WorkerGroup::new(&parent); + for _ in 0..3 { + let exited = exited.clone(); + group + .spawn(thread::Builder::new(), move |token| { + waiting_worker(token, exited) + }) + .unwrap(); + } + drop(group); + assert_eq!(exited.load(Ordering::SeqCst), 3); + assert!(!parent.is_cancelled()); + assert!(!sibling.is_cancelled()); + } + + #[test] + fn process_cancellation_reaches_owned_workers() { + let parent = CancellationToken::new(); + let exited = Arc::new(AtomicUsize::new(0)); + let mut group = WorkerGroup::new(&parent); + let done = exited.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + group + .spawn(thread::Builder::new(), move |token| { + waiting_worker(token, done); + tx.send(()).unwrap(); + }) + .unwrap(); + parent.cancel(); + rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap(); + assert_eq!(exited.load(Ordering::SeqCst), 1); + drop(group); + } + + #[test] + fn failed_spawn_rolls_back_previously_started_workers() { + let parent = CancellationToken::new(); + let exited = Arc::new(AtomicUsize::new(0)); + let start = || -> io::Result { + let mut group = WorkerGroup::new(&parent); + let done = exited.clone(); + group.spawn(thread::Builder::new(), move |token| { + waiting_worker(token, done) + })?; + // Impossible stack allocation gives a deterministic OS spawn error. + group.spawn(thread::Builder::new().stack_size(usize::MAX), |_| {})?; + Ok(group) + }; + assert!(start().is_err()); + assert_eq!(exited.load(Ordering::SeqCst), 1); + assert!(!parent.is_cancelled()); + } + + #[test] + fn retirement_wakes_io_uring_accept_and_releases_listener() { + let parent = CancellationToken::new(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + listener.set_nonblocking(true).unwrap(); + let mut group = WorkerGroup::new(&parent); + let (tx, rx) = std::sync::mpsc::channel(); + group + .spawn(thread::Builder::new(), move |shutdown| { + let mut runtime = super::super::build_core_runtime().unwrap(); + runtime.block_on(async move { + let listener = monoio::net::TcpListener::from_std(listener).unwrap(); + tx.send(()).unwrap(); + monoio::select! { + _ = shutdown.cancelled() => {}, + _ = listener.accept() => panic!("unexpected connection to isolated listener"), + } + }); + }) + .unwrap(); + rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap(); + drop(group); + assert!(!parent.is_cancelled()); + // No detached runtime or descriptor keeps this address occupied. + let replacement = std::net::TcpListener::bind(address).unwrap(); + drop(replacement); + } + + #[test] + fn quic_driver_setup_failure_rejects_prepared_group() { + use super::super::{bridge, h3}; + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + let bridge = runtime.block_on(async { + bridge::spawn_on_current(2, |_, _| async { + http::Response::new(hj_core::Body::Empty) + }) + }); + // A real but incompatible datagram descriptor reaches driver probing: + // adopting a descriptor alone must not acknowledge QUIC readiness. + let (socket, peer) = std::os::unix::net::UnixDatagram::pair().unwrap(); + let fd: std::os::fd::OwnedFd = socket.into(); + let socket = std::net::UdpSocket::from(fd); + let valid = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + let address = valid.local_addr().unwrap(); + let parent = CancellationToken::new(); + let result = h3::serve_h3_pipeline( + address, + 2, + h3::self_signed_config().unwrap(), + bridge, + false, + h3::H3RuntimeConfig::new( + || (h3::H3RequestLimits::new(16_384, 1024), 2), + Arc::new(std::sync::atomic::AtomicU64::new(0)), + Arc::new(hj_core::budget::BodyBufferBudget::new(4096)), + ), + Some(vec![valid, socket]), + parent.clone(), + ); + assert!(result.is_err(), "driver setup must fail before readiness"); + assert!(!parent.is_cancelled()); + assert!(peer.send(b"closed candidate").is_err()); + // The valid sibling is joined too, even when it reached readiness first. + let replacement = std::net::UdpSocket::bind(address).unwrap(); + drop(replacement); + } + + #[test] + fn quic_group_retirement_releases_udp_socket_without_process_shutdown() { + use super::super::{bridge, h3}; + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + let bridge = runtime.block_on(async { + bridge::spawn_on_current(2, |_, _| async { + http::Response::new(hj_core::Body::Empty) + }) + }); + let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + let address = socket.local_addr().unwrap(); + let parent = CancellationToken::new(); + let (group, _policy) = h3::serve_h3_pipeline( + address, + 1, + h3::self_signed_config().unwrap(), + bridge, + false, + h3::H3RuntimeConfig::new( + || (h3::H3RequestLimits::new(16_384, 1024), 2), + Arc::new(std::sync::atomic::AtomicU64::new(0)), + Arc::new(hj_core::budget::BodyBufferBudget::new(4096)), + ), + Some(vec![socket]), + parent.clone(), + ) + .unwrap(); + group.activate(); + drop(group); + assert!(!parent.is_cancelled()); + let replacement = std::net::UdpSocket::bind(address).unwrap(); + drop(replacement); + } + + #[test] + fn prepared_http_waits_for_activation_before_serving() { + tcp_activation(false); + } + + #[test] + fn prepared_tls_waits_for_activation_before_handshake() { + tcp_activation(true); + } + + fn tcp_activation(tls: bool) { + use super::super::{ + ListenerBinding, h3, pipeline_admission, spawn_uring_http, spawn_uring_https, + }; + use std::io::{Read, Write}; + let root = std::env::temp_dir().join(format!( + "hj-activation-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir(&root).unwrap(); + std::fs::write(root.join("index.html"), b"activated response").unwrap(); + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + let state = runtime.block_on(async { crate::pipeline::e2e::build_state(root.clone()) }); + let server_root = state.server.server_root.clone(); + let shutdown = state.shutdown.clone(); + let active = state.metrics.active_conns.clone(); + let holder = Arc::new(arc_swap::ArcSwap::from(state)); + let socket = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + socket.set_nonblocking(true).unwrap(); + let address = socket.local_addr().unwrap(); + let group = runtime.block_on(async { + let admission = pipeline_admission(holder.clone()); + if tls { + spawn_uring_https( + holder, + "http".into(), + address, + 1, + h3::self_signed_config().unwrap(), + false, + None, + Some(vec![socket]), + admission, + ListenerBinding::default(), + ) + } else { + spawn_uring_http( + holder, + "http".into(), + address, + 1, + Some(vec![socket]), + admission, + ListenerBinding::default(), + ) + } + .unwrap() + }); + let mut client = std::net::TcpStream::connect(address).unwrap(); + client + .set_read_timeout(Some(std::time::Duration::from_millis(100))) + .unwrap(); + if tls { + let config = rustls::ClientConfig::builder() + .with_root_certificates(rustls::RootCertStore::empty()) + .with_no_client_auth(); + let mut handshake = + rustls::ClientConnection::new(Arc::new(config), "localhost".try_into().unwrap()) + .unwrap(); + handshake.write_tls(&mut client).unwrap(); + } else { + client + .write_all( + b"GET /index.html HTTP/1.1\r\nHost: canon.test\r\nConnection: close\r\n\r\n", + ) + .unwrap(); + } + let mut response = [0u8; 4096]; + let error = client + .read(&mut response) + .expect_err("prepared transport must remain silent"); + assert!(matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + )); + assert_eq!( + active.load(Ordering::SeqCst), + 0, + "prepared listener must not even accept" + ); + group.activate(); + client + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let received = client.read(&mut response).unwrap(); + assert!(received > 0); + if !tls { + assert!(response[..received].starts_with(b"HTTP/1.1 200")); + } + drop(client); + drop(group); + assert!(!shutdown.is_cancelled()); + drop(runtime); + std::fs::remove_dir_all(root).unwrap(); + std::fs::remove_dir_all(server_root).unwrap(); + } +} diff --git a/crates/httpjet/src/waf.rs b/crates/httpjet/src/waf.rs new file mode 100644 index 0000000..d616dd2 --- /dev/null +++ b/crates/httpjet/src/waf.rs @@ -0,0 +1,485 @@ +use std::{net::SocketAddr, sync::Arc, time::Duration}; + +use http_body::Body as _; +use http_body_util::{BodyExt, Full}; +use serde::Serialize; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use hj_core::{BoxError, ReqCtx, Request}; + +const MAX_RESPONSE_HEAD: usize = 4096; +const MAX_INSPECT_BODY: u64 = 1024 * 1024; +const MAX_CONCURRENCY: usize = 4096; +const MAX_TIMEOUT: Duration = Duration::from_secs(30); +// Separate from the transport's request-body ledger: WAF inspection temporarily +// owns a hexadecimal body/header representation and the serialized JSON payload. +const SERIALIZATION_BUDGET: usize = 64 * 1024 * 1024; +const MIN_SERIALIZATION_CHARGE: usize = 16 * 1024; + +fn process_serialization_budget() -> Arc { + static BUDGET: std::sync::OnceLock> = std::sync::OnceLock::new(); + BUDGET + .get_or_init(|| Arc::new(tokio::sync::Semaphore::new(SERIALIZATION_BUDGET))) + .clone() +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FailurePolicy { + Closed, + Open, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Verdict { + Allow, + Block, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum InspectError { + #[error("WAF sidecar configuration is invalid: {0}")] + Configuration(&'static str), + #[error("WAF sidecar timed out")] + Timeout, + #[error("WAF sidecar I/O failed")] + Io, + #[error("WAF sidecar serialization capacity is exhausted")] + Capacity, + #[error("WAF sidecar response is malformed")] + Protocol, +} + +pub(crate) struct Sidecar { + address: SocketAddr, + path: String, + timeout: Duration, + inspect_body_max: u64, + policy: FailurePolicy, + concurrency: Arc, + serialization_budget: Arc, +} + +impl Sidecar { + pub(crate) fn new( + address: SocketAddr, + path: String, + timeout: Duration, + inspect_body_max: u64, + max_concurrency: usize, + policy: FailurePolicy, + ) -> Result { + if !address.ip().is_loopback() || address.port() == 0 { + return Err(InspectError::Configuration("address must be loopback")); + } + if !path.starts_with('/') + || path.len() > 1024 + || path.bytes().any(|byte| byte.is_ascii_control()) + { + return Err(InspectError::Configuration( + "path must be an absolute HTTP path", + )); + } + if timeout.is_zero() + || timeout > MAX_TIMEOUT + || max_concurrency == 0 + || max_concurrency > MAX_CONCURRENCY + || inspect_body_max > MAX_INSPECT_BODY + { + return Err(InspectError::Configuration( + "timeout, concurrency or body inspection bound is out of range", + )); + } + Ok(Self { + address, + path, + timeout, + inspect_body_max, + policy, + concurrency: Arc::new(tokio::sync::Semaphore::new(max_concurrency)), + // Generations overlap during transactional reload, so this is a + // process-global budget rather than one fresh allowance per state. + serialization_budget: process_serialization_budget(), + }) + } + + pub(crate) fn failure_policy(&self) -> FailurePolicy { + self.policy + } + + pub(crate) fn inspect_body_max(&self) -> u64 { + self.inspect_body_max + } + + pub(crate) async fn inspect( + &self, + ctx: &ReqCtx, + req: &mut Request, + normalized_path: &str, + include_body: bool, + ) -> Result { + tokio::time::timeout(self.timeout, async { + let _permit = self + .concurrency + .acquire() + .await + .map_err(|_| InspectError::Io)?; + // Acquire a conservative byte-weighted lease before making any + // attacker-size-proportional copy. Holding a request-count permit is + // not enough: at the accepted 1 MiB/4096 maxima, hex + JSON copies + // could otherwise exhaust the process heap while the sidecar stalls. + let charge = serialization_charge(ctx, req, normalized_path, include_body)?; + let _serialization = self + .serialization_budget + .clone() + .acquire_many_owned(charge) + .await + .map_err(|_| InspectError::Capacity)?; + let body = if include_body { + let incoming = std::mem::replace(req.body_mut(), hj_core::empty_incoming()); + let bytes = incoming + .collect() + .await + .map_err(|_| InspectError::Io)? + .to_bytes(); + *req.body_mut() = Full::new(bytes.clone()) + .map_err(|never| match never {}) + .map_err(|error| Box::new(error) as BoxError) + .boxed(); + Some(hex(&bytes)) + } else { + None + }; + let headers: Vec> = req + .headers() + .iter() + .map(|(name, value)| EncodedHeader { + name: name.as_str(), + value_hex: hex(value.as_bytes()), + }) + .collect(); + let payload = serde_json::to_vec(&Inspection { + version: 1, + method: req.method().as_str(), + path: normalized_path, + query: req.uri().query().unwrap_or(""), + vhost: &ctx.vhost_name, + client_ip: ctx.client_ip.to_string(), + protocol: ctx.protocol.as_str(), + tls: ctx.is_tls, + headers, + body_hex: body, + body_omitted: !include_body, + }) + .map_err(|_| InspectError::Protocol)?; + let mut stream = tokio::net::TcpStream::connect(self.address) + .await + .map_err(|_| InspectError::Io)?; + let head = format!( + "POST {} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + self.path, + self.address, + payload.len() + ); + stream + .write_all(head.as_bytes()) + .await + .map_err(|_| InspectError::Io)?; + stream + .write_all(&payload) + .await + .map_err(|_| InspectError::Io)?; + stream.flush().await.map_err(|_| InspectError::Io)?; + read_verdict(&mut stream).await + }) + .await + .map_err(|_| InspectError::Timeout)? + } +} + +/// Conservative upper bound for allocations retained through sidecar I/O. +/// +/// Body/header values exist once as hex strings and once in the JSON output; +/// the 6x charge also covers Vec growth. Other strings use JSON's worst-case +/// six-byte escape plus Vec growth (12x). A fixed per-request/header allowance +/// bounds collection metadata and small-request concurrency. +fn serialization_charge( + ctx: &ReqCtx, + req: &Request, + normalized_path: &str, + include_body: bool, +) -> Result { + let body = if include_body { + usize::try_from( + req.body() + .size_hint() + .exact() + .ok_or(InspectError::Capacity)?, + ) + .map_err(|_| InspectError::Capacity)? + } else { + 0 + }; + let mut header_values = 0_usize; + let mut header_names = 0_usize; + let mut header_count = 0_usize; + for (name, value) in req.headers() { + header_values = header_values + .checked_add(value.as_bytes().len()) + .ok_or(InspectError::Capacity)?; + header_names = header_names + .checked_add(name.as_str().len()) + .ok_or(InspectError::Capacity)?; + header_count = header_count.checked_add(1).ok_or(InspectError::Capacity)?; + } + let text = req + .method() + .as_str() + .len() + .checked_add(normalized_path.len()) + .and_then(|n| n.checked_add(req.uri().query().unwrap_or("").len())) + .and_then(|n| n.checked_add(ctx.vhost_name.len())) + .and_then(|n| n.checked_add(ctx.protocol.as_str().len())) + .and_then(|n| n.checked_add(45)) // longest textual IP address + .ok_or(InspectError::Capacity)?; + let charge = body + .checked_mul(6) + .and_then(|n| n.checked_add(header_values.checked_mul(6)?)) + .and_then(|n| n.checked_add(header_names.checked_mul(2)?)) + .and_then(|n| n.checked_add(text.checked_mul(12)?)) + .and_then(|n| n.checked_add(header_count.checked_mul(128)?)) + .and_then(|n| n.checked_add(MIN_SERIALIZATION_CHARGE)) + .ok_or(InspectError::Capacity)? + .max(MIN_SERIALIZATION_CHARGE); + if charge > SERIALIZATION_BUDGET { + return Err(InspectError::Capacity); + } + u32::try_from(charge).map_err(|_| InspectError::Capacity) +} + +#[derive(Serialize)] +struct Inspection<'a> { + version: u8, + method: &'a str, + path: &'a str, + query: &'a str, + vhost: &'a str, + client_ip: String, + protocol: &'a str, + tls: bool, + headers: Vec>, + body_hex: Option, + body_omitted: bool, +} + +#[derive(Serialize)] +struct EncodedHeader<'a> { + name: &'a str, + value_hex: String, +} + +fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len().saturating_mul(2)); + for byte in bytes { + out.push(DIGITS[(byte >> 4) as usize] as char); + out.push(DIGITS[(byte & 0x0f) as usize] as char); + } + out +} + +async fn read_verdict(stream: &mut tokio::net::TcpStream) -> Result { + let mut head = Vec::with_capacity(512); + let mut byte = [0_u8; 1]; + while !head.ends_with(b"\r\n\r\n") { + if head.len() == MAX_RESPONSE_HEAD { + return Err(InspectError::Protocol); + } + if stream + .read_exact(&mut byte) + .await + .map_err(|_| InspectError::Io)? + == 0 + { + return Err(InspectError::Protocol); + } + head.push(byte[0]); + } + let text = std::str::from_utf8(&head).map_err(|_| InspectError::Protocol)?; + let mut lines = text.split("\r\n"); + let status = lines + .next() + .and_then(|line| line.strip_prefix("HTTP/1.1 ")) + .and_then(|line| line.split(' ').next()) + .and_then(|code| code.parse::().ok()) + .ok_or(InspectError::Protocol)?; + let mut content_length = None; + for line in lines.filter(|line| !line.is_empty()) { + let (name, value) = line.split_once(':').ok_or(InspectError::Protocol)?; + http::HeaderName::from_bytes(name.as_bytes()).map_err(|_| InspectError::Protocol)?; + if name.eq_ignore_ascii_case("transfer-encoding") { + return Err(InspectError::Protocol); + } + if name.eq_ignore_ascii_case("content-length") { + let parsed = value + .trim() + .parse::() + .map_err(|_| InspectError::Protocol)?; + if content_length.replace(parsed).is_some() { + return Err(InspectError::Protocol); + } + } + } + if content_length.unwrap_or(0) != 0 { + return Err(InspectError::Protocol); + } + match status { + 204 => Ok(Verdict::Allow), + 403 => Ok(Verdict::Block), + _ => Err(InspectError::Protocol), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn configuration_is_loopback_absolute_and_bounded() { + assert!( + Sidecar::new( + "127.0.0.1:9000".parse().unwrap(), + "/inspect".into(), + Duration::from_millis(50), + 1024, + 2, + FailurePolicy::Closed, + ) + .is_ok() + ); + assert!( + Sidecar::new( + "192.0.2.1:9000".parse().unwrap(), + "/inspect".into(), + Duration::from_millis(50), + 1024, + 2, + FailurePolicy::Closed, + ) + .is_err() + ); + assert!( + Sidecar::new( + "127.0.0.1:9000".parse().unwrap(), + "bad\r\npath".into(), + Duration::from_millis(50), + 1024, + 2, + FailurePolicy::Closed, + ) + .is_err() + ); + } + + #[test] + fn binary_values_encode_without_loss_or_controls() { + assert_eq!(hex(&[0, b'\r', 0xff]), "000dff"); + } + + fn context() -> ReqCtx { + ReqCtx { + server: Arc::new(Default::default()), + vhost_name: "example.test".into(), + vhost: Arc::new(Default::default()), + peer_ip: "127.0.0.1".parse().unwrap(), + client_ip: "203.0.113.10".parse().unwrap(), + is_tls: true, + peer_unix: false, + protocol: hj_core::Proto::Http2, + trusted_proxy: false, + env: vec![], + local_addr: "127.0.0.1:443".parse().unwrap(), + peer_port: 12345, + tls: None, + request_time: std::time::SystemTime::now(), + request_id: hj_core::reqid::next(), + redirect_guard: None, + } + } + + fn request(body_len: usize) -> Request { + http::Request::builder() + .method("POST") + .uri("/inspect?q=1") + .header("x-test", "value") + .body( + Full::new(bytes::Bytes::from(vec![0; body_len])) + .map_err(|never| match never {}) + .boxed(), + ) + .unwrap() + } + + #[test] + fn serialization_charge_is_byte_weighted_and_conservative() { + let ctx = context(); + let empty = serialization_charge(&ctx, &request(0), "/inspect", true).unwrap(); + let one_mib = + serialization_charge(&ctx, &request(MAX_INSPECT_BODY as usize), "/inspect", true) + .unwrap(); + assert!(empty as usize >= MIN_SERIALIZATION_CHARGE); + assert!(one_mib as usize >= 6 * MAX_INSPECT_BODY as usize); + assert!((one_mib as usize) < SERIALIZATION_BUDGET); + } + + #[tokio::test] + async fn aggregate_serialization_budget_blocks_then_releases() { + let budget = Arc::new(tokio::sync::Semaphore::new(SERIALIZATION_BUDGET)); + let charge = serialization_charge( + &context(), + &request(MAX_INSPECT_BODY as usize), + "/inspect", + true, + ) + .unwrap(); + let held = budget + .clone() + .acquire_many_owned(SERIALIZATION_BUDGET as u32) + .await + .unwrap(); + assert!(budget.clone().try_acquire_many_owned(charge).is_err()); + drop(held); + let lease = budget.clone().try_acquire_many_owned(charge).unwrap(); + assert_eq!( + budget.available_permits(), + SERIALIZATION_BUDGET - charge as usize + ); + drop(lease); + assert_eq!(budget.available_permits(), SERIALIZATION_BUDGET); + } + + #[test] + fn reload_generations_share_the_process_serialization_budget() { + let first = Sidecar::new( + "127.0.0.1:19090".parse().unwrap(), + "/inspect".into(), + Duration::from_millis(100), + 64 * 1024, + 128, + FailurePolicy::Closed, + ) + .unwrap(); + let second = Sidecar::new( + "127.0.0.1:19091".parse().unwrap(), + "/inspect".into(), + Duration::from_millis(100), + 64 * 1024, + 128, + FailurePolicy::Closed, + ) + .unwrap(); + assert!(Arc::ptr_eq( + &first.serialization_budget, + &second.serialization_budget + )); + } +} diff --git a/crates/httpjet/tests/h1_corpus.rs b/crates/httpjet/tests/h1_corpus.rs new file mode 100644 index 0000000..de5a833 --- /dev/null +++ b/crates/httpjet/tests/h1_corpus.rs @@ -0,0 +1,60 @@ +//! Deterministic, socket-free replay of the same invariants used by libFuzzer. +use httpjet::codec; +#[path = "../../../fuzz/h1_properties.rs"] +mod properties; + +fn replay(text: &str, check: fn(&[u8])) -> usize { + let mut cases = 0; + for (line_no, line) in text.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let mut bytes = Vec::new(); + if line != "-" { + assert_eq!(line.len() % 2, 0, "invalid seed line {}", line_no + 1); + assert!(line.is_ascii()); + for offset in (0..line.len()).step_by(2) { + bytes.push(u8::from_str_radix(&line[offset..offset + 2], 16).unwrap()); + } + } + assert!(bytes.len() <= 4096, "keep deterministic replay bounded"); + check(&bytes); + cases += 1; + // Replay every truncation and single-byte mutation, preserving malformed + // framing boundaries in normal stable-Rust CI without a fuzz runtime. + for end in 0..bytes.len() { + check(&bytes[..end]); + cases += 1; + } + for offset in 0..bytes.len() { + let saved = bytes[offset]; + for value in [0, b'\r', b'\n', b':', b'0', b'9', 255] { + bytes[offset] = value; + check(&bytes); + cases += 1; + } + bytes[offset] = saved; + } + } + assert!(cases > 100, "empty or unexpectedly sparse corpus"); + cases +} + +#[test] +fn h1_chunked_corpus_replay() { + let count = replay( + include_str!("../../../fuzz/seeds/h1_chunked_decode.hex"), + properties::h1_chunked_decode, + ); + eprintln!("replayed {count} chunked cases"); +} + +#[test] +fn h1_framing_corpus_replay() { + let count = replay( + include_str!("../../../fuzz/seeds/h1_request_framing.hex"), + properties::h1_request_framing, + ); + eprintln!("replayed {count} framing cases"); +} diff --git a/fuzz/.gitignore b/fuzz/.gitignore index a854700..b7fa0a7 100644 --- a/fuzz/.gitignore +++ b/fuzz/.gitignore @@ -2,4 +2,3 @@ /corpus /artifacts /coverage -Cargo.lock diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 0000000..1e0d0d7 --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,636 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hj-config" +version = "0.1.0" +dependencies = [ + "quick-xml", + "serde", + "thiserror", + "tracing", +] + +[[package]] +name = "hj-core" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "hj-config", + "http", + "http-body", + "http-body-util", + "httpdate", + "rustc-hash", + "thiserror", +] + +[[package]] +name = "hj-h2" +version = "0.1.0" +dependencies = [ + "bytes", + "futures-util", + "hj-core", + "http", + "http-body", + "http-body-util", + "rustc-hash", + "rustix", + "tokio", + "tokio-util", +] + +[[package]] +name = "hj-lsapi" +version = "0.1.0" +dependencies = [ + "async-trait", + "bytes", + "hj-core", + "http", + "http-body-util", + "libc", + "memmap2", + "parking_lot", + "rustix", + "thiserror", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "httpjet-fuzz" +version = "0.0.0" +dependencies = [ + "arbitrary", + "hj-h2", + "hj-lsapi", + "httparse", + "libfuzzer-sys", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/fuzz/README.md b/fuzz/README.md index db03f0b..386cd11 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -18,3 +18,38 @@ cargo +nightly fuzz tmin fuzz/artifacts//crash-XXXX ``` Then add the minimized input as a regression test in the owning crate. + +## Continuous checks + +The two H1 targets and `crates/httpjet/tests/h1_corpus.rs` call the same +`h1_properties.rs` functions against the exact codec source used by the server. +Reviewed seeds are stored as hex in `seeds/`; `-` represents an empty input. +Stable-Rust CI replays them, every truncation and bounded single-byte mutations: + +```bash +cargo test --locked -p httpjet --test h1_corpus -- --nocapture +``` + +For local ASan fuzzing with explicit budgets: + +```bash +# Default pinned toolchain: nightly-2026-08-31; cargo-fuzz 0.13.2. +FUZZ_SECONDS=300 bash scripts/fuzz-bounded.sh h1_request_framing +``` + +The wrapper validates the target/time, materializes H1 seeds without overwriting +existing corpus entries, fetches against the committed fuzz lockfile, builds +offline, and rejects successful runs that change the lockfile. Build timeout is +900 seconds; execution has a separate requested-budget-plus-30-second wall cap +and 10-second forced-termination grace. libFuzzer additionally limits each input +to 5 seconds, input length to 4096 bytes and RSS to 1536 MiB. `FUZZ_SECONDS` is +1..600 (default 300). `FUZZ_TOOLCHAIN` can select an installed local nightly. +New-function symbol printing is disabled because host LLVM symbolizers can stall; +sanitizer diagnostics remain enabled and bounded by the wall timeout. + +`.github/workflows/fuzz.yml` runs all five targets nightly/manually with at most +two concurrent jobs. Corpora/crash artifacts and the lockfile are retained for +14 days, including on failure. Jobs start from checked-in seeds, not automatically +trusted artifacts from prior runs. Review/minimize failures and promote useful +inputs to `seeds/` (H1) or crate regression tests. The short local smoke runs are +not evidence of vulnerability absence or a completed exhaustive fuzz campaign. diff --git a/fuzz/fuzz_targets/h1_chunked_decode.rs b/fuzz/fuzz_targets/h1_chunked_decode.rs index c13a30e..168bc0b 100644 --- a/fuzz/fuzz_targets/h1_chunked_decode.rs +++ b/fuzz/fuzz_targets/h1_chunked_decode.rs @@ -14,39 +14,8 @@ mod codec; use libfuzzer_sys::fuzz_target; -fuzz_target!(|data: &[u8]| { - // One-shot. - let mut one = codec::ChunkedDecoder::new(0); - let one_body = match one.advance(data) { - codec::ChunkStep::Done(end) => { - assert!( - end <= data.len(), - "chunked Done offset {end} past buffer len {}", - data.len() - ); - Some(std::mem::take(&mut one.body)) - } - _ => None, - }; +#[path = "../h1_properties.rs"] +#[allow(dead_code)] +mod properties; - // Incremental: feed one byte at a time into a growing buffer (the resumable - // contract). Must agree with one-shot on the decoded body when both complete. - let mut inc = codec::ChunkedDecoder::new(0); - let mut buf = Vec::with_capacity(data.len()); - let mut inc_body = None; - for &b in data { - buf.push(b); - match inc.advance(&buf) { - codec::ChunkStep::Done(_) => { - inc_body = Some(std::mem::take(&mut inc.body)); - break; - } - codec::ChunkStep::Bad => break, - codec::ChunkStep::NeedMore => {} - } - } - - if let (Some(o), Some(i)) = (one_body, inc_body) { - assert_eq!(o, i, "one-shot vs incremental chunked body mismatch"); - } -}); +fuzz_target!(|data: &[u8]| properties::h1_chunked_decode(data)); diff --git a/fuzz/fuzz_targets/h1_request_framing.rs b/fuzz/fuzz_targets/h1_request_framing.rs index 0737d95..d68daa0 100644 --- a/fuzz/fuzz_targets/h1_request_framing.rs +++ b/fuzz/fuzz_targets/h1_request_framing.rs @@ -13,83 +13,8 @@ mod codec; use libfuzzer_sys::fuzz_target; -fuzz_target!(|data: &[u8]| { - fn parse_head(buf: &[u8], max_head: usize) -> codec::RequestHeadProgress { - let mut headers = [httparse::EMPTY_HEADER; codec::MAX_REQUEST_HEADERS]; - let mut request = httparse::Request::new(&mut headers); - codec::request_head_progress(request.parse(buf), buf.len(), max_head) - } +#[path = "../h1_properties.rs"] +#[allow(dead_code)] +mod properties; - fn equivalent(a: codec::RequestHeadProgress, b: codec::RequestHeadProgress) -> bool { - use codec::RequestHeadProgress::*; - match (a, b) { - (Complete(x), Complete(y)) => x == y, - (Partial, Partial) => true, - (TooLarge | Bad, TooLarge | Bad) => true, - _ => false, - } - } - - // Exercise the exact byte cap under both a one-read parse and a split read. - // Bad and TooLarge are both terminal rejection; the on-wire status can differ - // when malformed bytes and the size boundary arrive in different reads, but a - // request must never move between accepted/partial/rejected classifications. - let max_head = data.first().copied().map(|n| n as usize + 1).unwrap_or(1); - let wire = data.get(2..).unwrap_or_default(); - let split = data - .get(1) - .copied() - .map(|n| (n as usize).min(wire.len())) - .unwrap_or(0); - let one_read = parse_head(wire, max_head); - let first = parse_head(&wire[..split], max_head); - let split_read = if first == codec::RequestHeadProgress::Partial { - parse_head(wire, max_head) - } else { - first - }; - assert!(equivalent(one_read, split_read)); - if let codec::RequestHeadProgress::Complete(head_len) = one_read { - assert!(head_len <= max_head); - } - - // Derive pseudo-headers: first byte = TE flags, remaining bytes split on NUL - // into Content-Length header values. - let (flags, rest) = data.split_first().unwrap_or((&0, &[])); - let chunked = flags & 1 != 0; - let te_other = flags & 2 != 0; - let cl_values: Vec<&[u8]> = rest.split(|&b| b == 0).collect(); - - let framing = codec::classify_framing(cl_values.iter().copied(), chunked, te_other); - let cl = codec::resolve_content_length(cl_values.iter().copied()); - - match framing { - // A Length decision implies: no other TE, not chunked, and a valid CL. - codec::BodyFraming::Length(_) => { - assert!( - !te_other, - "Length chosen with a non-chunked/compound TE present" - ); - assert!( - !chunked, - "Length chosen with chunked TE present (CL+TE smuggling)" - ); - assert!( - cl.is_ok(), - "Length chosen with a malformed/conflicting Content-Length" - ); - } - // Chunked implies: chunked TE, no other TE, and NO Content-Length present. - codec::BodyFraming::Chunked => { - assert!( - chunked && !te_other, - "Chunked chosen with conflicting TE state" - ); - assert!( - matches!(cl, Ok(None)), - "Chunked chosen with a Content-Length present (CL+TE)" - ); - } - codec::BodyFraming::Reject => {} - } -}); +fuzz_target!(|data: &[u8]| properties::h1_request_framing(data)); diff --git a/fuzz/h1_properties.rs b/fuzz/h1_properties.rs new file mode 100644 index 0000000..193959a --- /dev/null +++ b/fuzz/h1_properties.rs @@ -0,0 +1,124 @@ +//! Shared deterministic replay and libFuzzer invariants; uses the served codec. +use crate::codec; + +pub fn h1_chunked_decode(data: &[u8]) { + // One-shot. + let mut one = codec::ChunkedDecoder::new(0); + let one_body = match one.advance(data) { + codec::ChunkStep::Done(end) => { + assert!( + end <= data.len(), + "chunked Done offset {end} past buffer len {}", + data.len() + ); + Some(std::mem::take(&mut one.body)) + } + _ => None, + }; + + // Incremental: feed one byte at a time into a growing buffer (the resumable + // contract). Must agree with one-shot on the decoded body when both complete. + let mut inc = codec::ChunkedDecoder::new(0); + let mut buf = Vec::with_capacity(data.len()); + let mut inc_body = None; + for &b in data { + buf.push(b); + match inc.advance(&buf) { + codec::ChunkStep::Done(_) => { + inc_body = Some(std::mem::take(&mut inc.body)); + break; + } + codec::ChunkStep::Bad => break, + codec::ChunkStep::NeedMore => {} + } + } + + if let (Some(o), Some(i)) = (one_body, inc_body) { + assert_eq!(o, i, "one-shot vs incremental chunked body mismatch"); + } +} + +pub fn h1_request_framing(data: &[u8]) { + fn parse_head(buf: &[u8], max_head: usize) -> codec::RequestHeadProgress { + let mut headers = [httparse::EMPTY_HEADER; codec::MAX_REQUEST_HEADERS]; + let mut request = httparse::Request::new(&mut headers); + codec::request_head_progress(request.parse(buf), buf.len(), max_head) + } + + fn equivalent(a: codec::RequestHeadProgress, b: codec::RequestHeadProgress) -> bool { + use codec::RequestHeadProgress::*; + match (a, b) { + (Complete(x), Complete(y)) => x == y, + (Partial, Partial) => true, + (TooLarge | Bad, TooLarge | Bad) => true, + _ => false, + } + } + + // Exercise the exact byte cap under both a one-read parse and a split read. + // Bad and TooLarge are both terminal rejection; the on-wire status can differ + // when malformed bytes and the size boundary arrive in different reads, but a + // request must never move between accepted/partial/rejected classifications. + let max_head = data.first().copied().map(|n| n as usize + 1).unwrap_or(1); + let wire = data.get(2..).unwrap_or_default(); + let split = data + .get(1) + .copied() + .map(|n| (n as usize).min(wire.len())) + .unwrap_or(0); + let one_read = parse_head(wire, max_head); + let first = parse_head(&wire[..split], max_head); + let split_read = if first == codec::RequestHeadProgress::Partial { + parse_head(wire, max_head) + } else { + first + }; + assert!(equivalent(one_read, split_read)); + if let codec::RequestHeadProgress::Complete(head_len) = one_read { + assert!(head_len <= max_head); + } + + // Derive pseudo-headers: first byte = TE flags, remaining bytes split on NUL + // into Content-Length header values. + let (flags, rest) = data.split_first().unwrap_or((&0, &[])); + let chunked = flags & 1 != 0; + let te_other = flags & 2 != 0; + let cl_values: Vec<&[u8]> = if rest.is_empty() { + Vec::new() + } else { + rest.split(|&b| b == 0).collect() + }; + + let framing = codec::classify_framing(cl_values.iter().copied(), chunked, te_other); + let cl = codec::resolve_content_length(cl_values.iter().copied()); + + match framing { + // A Length decision implies: no other TE, not chunked, and a valid CL. + codec::BodyFraming::Length(_) => { + assert!( + !te_other, + "Length chosen with a non-chunked/compound TE present" + ); + assert!( + !chunked, + "Length chosen with chunked TE present (CL+TE smuggling)" + ); + assert!( + cl.is_ok(), + "Length chosen with a malformed/conflicting Content-Length" + ); + } + // Chunked implies: chunked TE, no other TE, and NO Content-Length present. + codec::BodyFraming::Chunked => { + assert!( + chunked && !te_other, + "Chunked chosen with conflicting TE state" + ); + assert!( + matches!(cl, Ok(None)), + "Chunked chosen with a Content-Length present (CL+TE)" + ); + } + codec::BodyFraming::Reject => {} + } +} diff --git a/fuzz/seeds/h1_chunked_decode.hex b/fuzz/seeds/h1_chunked_decode.hex new file mode 100644 index 0000000..bac4b08 --- /dev/null +++ b/fuzz/seeds/h1_chunked_decode.hex @@ -0,0 +1,10 @@ +# Hex-encoded seed inputs. A dash denotes the empty input. +- +300d0a0d0a +310d0a610d0a300d0a0d0a +343b6578743d780d0a746573740d0a300d0a582d547261696c65723a207965730d0a0d0a474554202f20485454502f312e310d0a0d0a +310d0a61580d0a300d0a0d0a +66666666666666666666666666666666660d0a +300d0a696e76616c69642d747261696c65720d0a0d0a +320d0a61 +300a0a diff --git a/fuzz/seeds/h1_request_framing.hex b/fuzz/seeds/h1_request_framing.hex new file mode 100644 index 0000000..8026011 --- /dev/null +++ b/fuzz/seeds/h1_request_framing.hex @@ -0,0 +1,13 @@ +# Hex-encoded seed inputs. A dash denotes the empty input. +- +01 +0030 +00310032 +0030310031 +0130 +03 +002d31 +003138343436373434303733373039353531363136 +ff10474554202f20485454502f312e310d0a486f73743a20746573740d0a0d0a +0a04474554202f20485454502f312e310d0a0d0a +ff00504f5354202f20485454502f312e310d0a436f6e74656e742d4c656e6774683a20310d0a5472616e736665722d456e636f64696e673a206368756e6b65640d0a0d0a diff --git a/packaging/oci/Containerfile b/packaging/oci/Containerfile new file mode 100644 index 0000000..35ddb69 --- /dev/null +++ b/packaging/oci/Containerfile @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1 + +FROM rust:1.97-bookworm AS build +WORKDIR /src +COPY . . +ENV CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS=2 +RUN cargo build --locked --release -p httpjet + +FROM debian:bookworm-slim +LABEL org.opencontainers.image.source="https://github.com/faratech/httpjet" \ + org.opencontainers.image.licenses="GPL-3.0-only" \ + org.opencontainers.image.title="httpjet hardened example" +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid 10001 httpjet \ + && useradd --uid 10001 --gid 10001 --home-dir /nonexistent \ + --no-create-home --shell /usr/sbin/nologin httpjet + +COPY --from=build /src/target/release/httpjet /usr/local/bin/httpjet +COPY packaging/oci/litespeed/ /etc/httpjet/litespeed/ + +RUN install -d -o httpjet -g httpjet -m 0750 \ + /etc/httpjet/litespeed/logs /var/lib/httpjet /var/lib/httpjet/cache \ + && chown -R root:root /etc/httpjet/litespeed/conf /etc/httpjet/litespeed/example \ + && chmod -R a-w /etc/httpjet/litespeed/conf /etc/httpjet/litespeed/example + +USER 10001:10001 +WORKDIR /var/lib/httpjet + +# HTTP, HTTPS-over-TCP, and HTTP/3-over-UDP. Publishing only TCP 8443 does not +# expose QUIC; both transport mappings are intentional. +EXPOSE 8080/tcp 8443/tcp 8443/udp + +VOLUME ["/etc/httpjet/litespeed/logs", "/var/lib/httpjet", "/run/httpjet-certs"] + +HEALTHCHECK --interval=5s --timeout=2s --start-period=10s --retries=12 \ + CMD ["curl", "--fail", "--silent", "--show-error", "--header", "Host: example.test", "http://127.0.0.1:8080/__hj_cache_ready"] + +ENTRYPOINT ["/usr/local/bin/httpjet"] +STOPSIGNAL SIGTERM +CMD ["--root", "/etc/httpjet/litespeed", "serve", "--http-addr", "0.0.0.0:8080", "--https-addr", "0.0.0.0:8443", "--workers", "1", "--no-php", "--page-cache", "--page-cache-mem", "16777216", "--page-cache-disk-mem", "67108864", "--page-cache-hot-mem", "8388608", "--page-cache-store-path", "/var/lib/httpjet/cache", "--page-cache-integrity-key", "/var/lib/httpjet/cache.key"] diff --git a/packaging/oci/MOBY-LICENSE b/packaging/oci/MOBY-LICENSE new file mode 100644 index 0000000..9a24e8d --- /dev/null +++ b/packaging/oci/MOBY-LICENSE @@ -0,0 +1,201 @@ + 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 TiKV Project Authors. + + 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/packaging/oci/README.md b/packaging/oci/README.md new file mode 100644 index 0000000..3e643c0 --- /dev/null +++ b/packaging/oci/README.md @@ -0,0 +1,103 @@ +# Hardened OCI example + +This directory is an adoption example, not a prescription for an existing +deployment. + +The image runs httpjet as numeric UID/GID `10001:10001`, binds unprivileged +ports, declares separate TCP and UDP exposure for HTTPS/HTTP3, and requires no +Linux capabilities. The compose example additionally uses a read-only root +filesystem, drops every capability, sets `no-new-privileges`, and provides a +small `noexec,nosuid,nodev` `/tmp` tmpfs. Because Moby's default seccomp +allowlist omits io_uring, `seccomp-httpjet.json` is the pinned Moby default plus +only `io_uring_setup`, `io_uring_enter`, and `io_uring_register`. + +## Required certificate and writable mounts + +The example intentionally contains no private key. Before starting it, place a +certificate for `example.test` at `packaging/oci/certs/tls.crt` and its key at +`packaging/oci/certs/tls.key`. Both must be readable by UID 10001; keep the key +mode at 0400. For a local-only certificate: + +```bash +mkdir -p packaging/oci/certs +openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ + -subj /CN=example.test -addext subjectAltName=DNS:example.test \ + -keyout packaging/oci/certs/tls.key \ + -out packaging/oci/certs/tls.crt +chown 10001:10001 packaging/oci/certs packaging/oci/certs/tls.key packaging/oci/certs/tls.crt +chmod 0400 packaging/oci/certs/tls.key +chmod 0444 packaging/oci/certs/tls.crt +``` + +The filesystem contract is explicit: + +| Path | Access | Purpose | +|---|---|---| +| `/run/httpjet-certs` | read-only | TLS certificate and private key | +| `/var/lib/httpjet` | read-write | page-cache files and integrity key | +| `/etc/httpjet/litespeed/logs` | read-write | access/error logs | +| `/etc/httpjet/litespeed/conf` and `example` | image read-only | LiteSpeed XML and static content | +| `/tmp` | bounded tmpfs | temporary runtime files | + +Do not mount the Docker socket, host root, production LSWS configuration, or an +unrelated application document root into this example. + +## Build and run + +```bash +docker compose -f packaging/oci/compose.yaml up --build +``` + +The published ports are deliberately protocol-qualified: + +- `8080/tcp`: HTTP/1.1 and h2c; +- `8443/tcp`: TLS HTTP/1.1 and HTTP/2; +- `8443/udp`: HTTP/3/QUIC. + +Publishing `8443/tcp` alone does not expose HTTP/3. Permit the UDP port through +the host and cloud firewalls too. The TLS response advertises the same UDP port +with Alt-Svc. + +The image health check calls the loopback-only `/__hj_cache_ready` endpoint on +HTTP port 8080. The example enables a small persistent page-cache solely so +that readiness represents completion of the boot scan; it does not probe or +warm a user URL. + +## io_uring requirements and failure policy + +httpjet has no epoll or privileged fallback. The container host must be Linux +with an io_uring-capable kernel, and the OCI runtime's seccomp policy must permit +the io_uring syscalls used by monoio. The Compose example and smoke gate use the +supplied profile, derived from Moby profiles commit +`61eaf32614c7c71b60bd8927d3e6a4ffc8ff1f31`; review/rebase it when changing the +container runtime. A policy denial is a startup/test failure. Do not work around +it with `--privileged`, `--cap-add`, or `seccomp=unconfined`; update the runtime +policy narrowly or use a supported runtime/host. Rootless engines may also +impose memlock, UDP, or io_uring restrictions and must be validated +independently. + +A hand-written `docker run` command must pass +`--security-opt seccomp=/absolute/path/to/seccomp-httpjet.json` explicitly; an +image cannot select its own host seccomp policy. + +The example binds only ports above 1024, needs no `CAP_NET_BIND_SERVICE`, and +does not request host networking, devices, PID/IPC namespaces, or elevated +memory-lock limits. HTTP/3 uses the ordinary published UDP socket, not a device. + +## Reproducible smoke gate + +```bash +bash scripts/oci-smoke.sh +``` + +The gate requires Python `aioquic` (the CI job pins version 1.3.0), builds the +image, generates a one-day synthetic certificate, and runs +with a read-only root filesystem, UID 10001, all capabilities dropped, and +`no-new-privileges`. It requires the readiness health check, HTTP and HTTPS +content checks, a real HTTP/3 request through the published UDP port, and the +`h3/QUIC up` signal. It +never retries with privileged mode or an unconfined seccomp profile. + +The test uses ephemeral ports, named volumes, and a temporary certificate. It +does not touch production sockets, configuration, cache, certificates, or +services. diff --git a/packaging/oci/compose.yaml b/packaging/oci/compose.yaml new file mode 100644 index 0000000..3164606 --- /dev/null +++ b/packaging/oci/compose.yaml @@ -0,0 +1,28 @@ +services: + httpjet: + build: + context: ../.. + dockerfile: packaging/oci/Containerfile + image: httpjet:example + user: "10001:10001" + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + - seccomp=./seccomp-httpjet.json + tmpfs: + - /tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777 + ports: + - "8080:8080/tcp" + - "8443:8443/tcp" + - "8443:8443/udp" + volumes: + - ./certs:/run/httpjet-certs:ro + - httpjet-data:/var/lib/httpjet + - httpjet-logs:/etc/httpjet/litespeed/logs + restart: unless-stopped + +volumes: + httpjet-data: + httpjet-logs: diff --git a/packaging/oci/litespeed/conf/httpd_config.xml b/packaging/oci/litespeed/conf/httpd_config.xml new file mode 100644 index 0000000..05535d5 --- /dev/null +++ b/packaging/oci/litespeed/conf/httpd_config.xml @@ -0,0 +1,55 @@ + + + example.test + httpjet + httpjet + index.html + + + 256 + 5 + 100 + + + + 1 + + + + + example.test + $SERVER_ROOT/example + $SERVER_ROOT/conf/vhosts/example.test.xml + 0 + 0 + + + + + + http +
0.0.0.0:8080
+ 0 + + + example.test + example.test,www.example.test + + +
+ + https +
0.0.0.0:8443
+ 1 + /run/httpjet-certs/tls.key + /run/httpjet-certs/tls.crt + 0 + + + example.test + example.test,www.example.test + + +
+
+
diff --git a/packaging/oci/litespeed/conf/mime.properties b/packaging/oci/litespeed/conf/mime.properties new file mode 100644 index 0000000..47c11e1 --- /dev/null +++ b/packaging/oci/litespeed/conf/mime.properties @@ -0,0 +1,6 @@ +text/html=html,htm +text/plain=txt +text/css=css +application/javascript=js +application/json=json +image/svg+xml=svg diff --git a/packaging/oci/litespeed/conf/vhosts/example.test.xml b/packaging/oci/litespeed/conf/vhosts/example.test.xml new file mode 100644 index 0000000..b861270 --- /dev/null +++ b/packaging/oci/litespeed/conf/vhosts/example.test.xml @@ -0,0 +1,10 @@ + + + $VH_ROOT/public + index.html + 0 + + 0 + 0 + + diff --git a/packaging/oci/litespeed/example/public/index.html b/packaging/oci/litespeed/example/public/index.html new file mode 100644 index 0000000..188783f --- /dev/null +++ b/packaging/oci/litespeed/example/public/index.html @@ -0,0 +1,5 @@ + + + +httpjet OCI example +

httpjet OCI example

diff --git a/packaging/oci/seccomp-httpjet.json b/packaging/oci/seccomp-httpjet.json new file mode 100644 index 0000000..0f2f0db --- /dev/null +++ b/packaging/oci/seccomp-httpjet.json @@ -0,0 +1,878 @@ +{ + "defaultAction": "SCMP_ACT_ERRNO", + "defaultErrnoRet": 1, + "archMap": [ + { + "architecture": "SCMP_ARCH_X86_64", + "subArchitectures": [ + "SCMP_ARCH_X86", + "SCMP_ARCH_X32" + ] + }, + { + "architecture": "SCMP_ARCH_AARCH64", + "subArchitectures": [ + "SCMP_ARCH_ARM" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64" + ] + }, + { + "architecture": "SCMP_ARCH_S390X", + "subArchitectures": [ + "SCMP_ARCH_S390" + ] + }, + { + "architecture": "SCMP_ARCH_RISCV64", + "subArchitectures": null + }, + { + "architecture": "SCMP_ARCH_LOONGARCH64", + "subArchitectures": null + } + ], + "syscalls": [ + { + "names": [ + "_llseek", + "_newselect", + "accept", + "accept4", + "access", + "adjtimex", + "alarm", + "bind", + "brk", + "cachestat", + "capget", + "capset", + "chdir", + "chmod", + "chown", + "chown32", + "clock_adjtime", + "clock_adjtime64", + "clock_getres", + "clock_getres_time64", + "clock_gettime", + "clock_gettime64", + "clock_nanosleep", + "clock_nanosleep_time64", + "close", + "close_range", + "connect", + "copy_file_range", + "creat", + "dup", + "dup2", + "dup3", + "epoll_create", + "epoll_create1", + "epoll_ctl", + "epoll_ctl_old", + "epoll_pwait", + "epoll_pwait2", + "epoll_wait", + "epoll_wait_old", + "eventfd", + "eventfd2", + "execve", + "execveat", + "exit", + "exit_group", + "faccessat", + "faccessat2", + "fadvise64", + "fadvise64_64", + "fallocate", + "fanotify_mark", + "fchdir", + "fchmod", + "fchmodat", + "fchmodat2", + "fchown", + "fchown32", + "fchownat", + "fcntl", + "fcntl64", + "fdatasync", + "fgetxattr", + "flistxattr", + "flock", + "fork", + "fremovexattr", + "fsetxattr", + "fstat", + "fstat64", + "fstatat64", + "fstatfs", + "fstatfs64", + "fsync", + "ftruncate", + "ftruncate64", + "futex", + "futex_requeue", + "futex_time64", + "futex_wait", + "futex_waitv", + "futex_wake", + "futimesat", + "get_robust_list", + "get_thread_area", + "getcpu", + "getcwd", + "getdents", + "getdents64", + "getegid", + "getegid32", + "geteuid", + "geteuid32", + "getgid", + "getgid32", + "getgroups", + "getgroups32", + "getitimer", + "getpeername", + "getpgid", + "getpgrp", + "getpid", + "getppid", + "getpriority", + "getrandom", + "getresgid", + "getresgid32", + "getresuid", + "getresuid32", + "getrlimit", + "getrusage", + "getsid", + "getsockname", + "getsockopt", + "gettid", + "gettimeofday", + "getuid", + "getuid32", + "getxattr", + "getxattrat", + "inotify_add_watch", + "inotify_init", + "inotify_init1", + "inotify_rm_watch", + "io_cancel", + "io_destroy", + "io_getevents", + "io_pgetevents", + "io_pgetevents_time64", + "io_setup", + "io_submit", + "io_uring_enter", + "io_uring_register", + "io_uring_setup", + "ioctl", + "ioprio_get", + "ioprio_set", + "ipc", + "kill", + "landlock_add_rule", + "landlock_create_ruleset", + "landlock_restrict_self", + "lchown", + "lchown32", + "lgetxattr", + "link", + "linkat", + "listen", + "listmount", + "listxattr", + "listxattrat", + "llistxattr", + "lremovexattr", + "lseek", + "lsetxattr", + "lstat", + "lstat64", + "madvise", + "map_shadow_stack", + "membarrier", + "memfd_create", + "memfd_secret", + "mincore", + "mkdir", + "mkdirat", + "mknod", + "mknodat", + "mlock", + "mlock2", + "mlockall", + "mmap", + "mmap2", + "mprotect", + "mq_getsetattr", + "mq_notify", + "mq_open", + "mq_timedreceive", + "mq_timedreceive_time64", + "mq_timedsend", + "mq_timedsend_time64", + "mq_unlink", + "mremap", + "mseal", + "msgctl", + "msgget", + "msgrcv", + "msgsnd", + "msync", + "munlock", + "munlockall", + "munmap", + "name_to_handle_at", + "nanosleep", + "newfstatat", + "open", + "openat", + "openat2", + "pause", + "pidfd_open", + "pidfd_send_signal", + "pipe", + "pipe2", + "pkey_alloc", + "pkey_free", + "pkey_mprotect", + "poll", + "ppoll", + "ppoll_time64", + "prctl", + "pread64", + "preadv", + "preadv2", + "prlimit64", + "process_mrelease", + "pselect6", + "pselect6_time64", + "pwrite64", + "pwritev", + "pwritev2", + "read", + "readahead", + "readlink", + "readlinkat", + "readv", + "recv", + "recvfrom", + "recvmmsg", + "recvmmsg_time64", + "recvmsg", + "remap_file_pages", + "removexattr", + "removexattrat", + "rename", + "renameat", + "renameat2", + "restart_syscall", + "riscv_hwprobe", + "rmdir", + "rseq", + "rt_sigaction", + "rt_sigpending", + "rt_sigprocmask", + "rt_sigqueueinfo", + "rt_sigreturn", + "rt_sigsuspend", + "rt_sigtimedwait", + "rt_sigtimedwait_time64", + "rt_tgsigqueueinfo", + "sched_get_priority_max", + "sched_get_priority_min", + "sched_getaffinity", + "sched_getattr", + "sched_getparam", + "sched_getscheduler", + "sched_rr_get_interval", + "sched_rr_get_interval_time64", + "sched_setaffinity", + "sched_setattr", + "sched_setparam", + "sched_setscheduler", + "sched_yield", + "seccomp", + "select", + "semctl", + "semget", + "semop", + "semtimedop", + "semtimedop_time64", + "send", + "sendfile", + "sendfile64", + "sendmmsg", + "sendmsg", + "sendto", + "set_robust_list", + "set_thread_area", + "set_tid_address", + "setfsgid", + "setfsgid32", + "setfsuid", + "setfsuid32", + "setgid", + "setgid32", + "setgroups", + "setgroups32", + "setitimer", + "setpgid", + "setpriority", + "setregid", + "setregid32", + "setresgid", + "setresgid32", + "setresuid", + "setresuid32", + "setreuid", + "setreuid32", + "setrlimit", + "setsid", + "setsockopt", + "setuid", + "setuid32", + "setxattr", + "setxattrat", + "shmat", + "shmctl", + "shmdt", + "shmget", + "shutdown", + "sigaltstack", + "signalfd", + "signalfd4", + "sigprocmask", + "sigreturn", + "socketcall", + "socketpair", + "splice", + "stat", + "stat64", + "statfs", + "statfs64", + "statmount", + "statx", + "symlink", + "symlinkat", + "sync", + "sync_file_range", + "syncfs", + "sysinfo", + "tee", + "tgkill", + "time", + "timer_create", + "timer_delete", + "timer_getoverrun", + "timer_gettime", + "timer_gettime64", + "timer_settime", + "timer_settime64", + "timerfd_create", + "timerfd_gettime", + "timerfd_gettime64", + "timerfd_settime", + "timerfd_settime64", + "times", + "tkill", + "truncate", + "truncate64", + "ugetrlimit", + "umask", + "uname", + "unlink", + "unlinkat", + "uretprobe", + "utime", + "utimensat", + "utimensat_time64", + "utimes", + "vfork", + "vmsplice", + "wait4", + "waitid", + "waitpid", + "write", + "writev" + ], + "action": "SCMP_ACT_ALLOW" + }, + { + "names": [ + "process_vm_readv", + "process_vm_writev", + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "minKernel": "4.8" + } + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 38, + "op": "SCMP_CMP_LT" + } + ] + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 39, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 40, + "op": "SCMP_CMP_GT" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 0, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 8, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131072, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131080, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 4294967295, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "sync_file_range2", + "swapcontext" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "ppc64le" + ] + } + }, + { + "names": [ + "arm_fadvise64_64", + "arm_sync_file_range", + "sync_file_range2", + "breakpoint", + "cacheflush", + "set_tls" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "arm", + "arm64" + ] + } + }, + { + "names": [ + "arch_prctl" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "modify_ldt" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "amd64", + "x32", + "x86" + ] + } + }, + { + "names": [ + "s390_pci_mmio_read", + "s390_pci_mmio_write", + "s390_runtime_instr" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "s390", + "s390x" + ] + } + }, + { + "names": [ + "riscv_flush_icache" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "riscv64" + ] + } + }, + { + "names": [ + "open_by_handle_at" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_DAC_READ_SEARCH" + ] + } + }, + { + "names": [ + "bpf", + "clone", + "clone3", + "fanotify_init", + "fsconfig", + "fsmount", + "fsopen", + "fspick", + "lookup_dcookie", + "lsm_get_self_attr", + "lsm_list_modules", + "lsm_set_self_attr", + "mount", + "mount_setattr", + "move_mount", + "open_tree", + "perf_event_open", + "quotactl", + "quotactl_fd", + "setdomainname", + "sethostname", + "setns", + "syslog", + "umount", + "umount2", + "unshare" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 2114060288, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ], + "arches": [ + "s390", + "s390x" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 1, + "value": 2114060288, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "comment": "s390 parameter ordering for clone is different", + "includes": { + "arches": [ + "s390", + "s390x" + ] + }, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "clone3" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 38, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "reboot" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_BOOT" + ] + } + }, + { + "names": [ + "chroot" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_CHROOT" + ] + } + }, + { + "names": [ + "delete_module", + "init_module", + "finit_module" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_MODULE" + ] + } + }, + { + "names": [ + "acct" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_PACCT" + ] + } + }, + { + "names": [ + "kcmp", + "pidfd_getfd", + "process_madvise", + "process_vm_readv", + "process_vm_writev", + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_PTRACE" + ] + } + }, + { + "names": [ + "iopl", + "ioperm" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_RAWIO" + ] + } + }, + { + "names": [ + "settimeofday", + "stime", + "clock_settime", + "clock_settime64" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_TIME" + ] + } + }, + { + "names": [ + "vhangup" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_TTY_CONFIG" + ] + } + }, + { + "names": [ + "get_mempolicy", + "mbind", + "set_mempolicy", + "set_mempolicy_home_node" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_NICE" + ] + } + }, + { + "names": [ + "syslog" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYSLOG" + ] + } + }, + { + "names": [ + "bpf" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_BPF" + ] + } + }, + { + "names": [ + "perf_event_open" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_PERFMON" + ] + } + } + ] +} diff --git a/vendor/monoio/src/driver/op.rs b/vendor/monoio/src/driver/op.rs index 68176fc..45648ed 100644 --- a/vendor/monoio/src/driver/op.rs +++ b/vendor/monoio/src/driver/op.rs @@ -228,13 +228,40 @@ impl MultiOp { &mut self, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { - self.driver.poll_multi_op(self.index, cx) + if self.index == usize::MAX { + return Poll::Ready(None); + } + let result = self.driver.poll_multi_op(self.index, cx); + if matches!(result, Poll::Ready(None)) { + // poll_multi removes the terminal slot. Never let a later poll, + // cancellation or Drop touch a new operation reusing that index. + self.index = usize::MAX; + } + result + } + + /// Request cancellation without detaching the completion consumer. The + /// caller must keep polling through the terminal CQE to prove quiescence. + pub(crate) fn cancel(&mut self) { + if self.index != usize::MAX { + // SAFETY: this MultiOp still owns the live slab index; the terminal + // polling path invalidates it before the index can be reused. + unsafe { + self.driver.cancel_op(&OpCanceller { + index: self.index, + #[cfg(feature = "legacy")] + direction: None, + }); + } + } } } impl Drop for MultiOp { fn drop(&mut self) { - self.driver.drop_multi_op(self.index); + if self.index != usize::MAX { + self.driver.drop_multi_op(self.index); + } } } diff --git a/vendor/monoio/src/net/tcp/listener.rs b/vendor/monoio/src/net/tcp/listener.rs index 0ee33f0..130aa88 100644 --- a/vendor/monoio/src/net/tcp/listener.rs +++ b/vendor/monoio/src/net/tcp/listener.rs @@ -343,6 +343,13 @@ pub struct AcceptMultiStream { #[cfg(all(target_os = "linux", feature = "iouring"))] impl AcceptMultiStream { + /// Stop accepting without discarding queued accepted connections. Continue + /// calling `next` until it returns `None` to observe kernel termination. + /// The terminal cancellation error is yielded like any other completion. + pub fn cancel(&mut self) { + self.op.cancel(); + } + pub async fn next(&mut self) -> Option> { let meta = std::future::poll_fn(|cx| self.op.poll_next_completion(cx)).await?; Some(match meta.result {