diff --git a/.containerignore b/.containerignore index 49eed3d..ae99225 100644 --- a/.containerignore +++ b/.containerignore @@ -1,4 +1,8 @@ +.git/ +.ai/ dependencies/*-dist/ -build/ +quic/third-party/*-dist/ +test/certs/ +build*/ keys/ -certs/ \ No newline at end of file +certs/ diff --git a/.dockerignore b/.dockerignore new file mode 120000 index 0000000..092a75d --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.containerignore \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 54e2702..ef60d89 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -8,7 +8,7 @@ assignees: '' |UDP QUIC + TLS 1.3| Engine[QUIC engine\nOpenSSL or ngtcp2] + Engine --> nghttp3[nghttp3 HTTP/3] + nghttp3 --> Module[mod_http3] + Module --> httpd[Apache httpd request pipeline] + httpd --> Module + Module --> nghttp3 + nghttp3 --> Engine +``` + +## Layers + +- **The QUIC engine** owns transport: packets, loss recovery, and streams. It is + chosen when the module is built, and is either OpenSSL's own QUIC + implementation or ngtcp2. See [QUIC engines](#quic-engines). +- **OpenSSL** owns TLS 1.3 on both paths; ngtcp2 uses it through + `libngtcp2_crypto_ossl`, so there is only ever one TLS stack. +- **nghttp3** handles HTTP/3 frames, streams, and QPACK interactions. +- **mod_http3** bridges QUIC streams with Apache request/response processing. +- **Apache httpd** supplies routing, virtual-host selection, filters, and handlers. +- **APR and APR-util** provide the portable runtime services used by the module and host daemon. + +## QUIC engines + +The transport sits behind one internal interface, `quic/quic/include/quic.h`. +Which engines a build contains is decided at compile time; which one runs is +decided at start-up. + +```sh +cmake -B build # OpenSSL only (default) +cmake -B build -DENABLE_NGTCP2=ON # OpenSSL and ngtcp2 +``` + +```apache +H3QuicEngine ngtcp2 # default: openssl +``` + +Naming an engine the build does not contain is a fatal configuration error, so +httpd refuses to start rather than quietly falling back. A running server +reports the engine in use through the `http3-status` handler's `quic_backend` +field. OpenSSL provides TLS on both paths, so there is only ever one TLS stack. + +Each engine lives in `quic//`, exposing `quic__api()` from +`quic//include/quic_.h` and keeping its own types in `src/detail/`. +Adding one means creating that directory, an `add_subdirectory()` line in +`quic/CMakeLists.txt`, and a row in `quic_engines[]` in +`mod_http3/src/h3_quic.c`; the engine appends its sources, include directory +and transport library to the `mod_http3-quic` target itself. + +That library depends on nothing but OpenSSL and each engine's own transport, so +no APR type, httpd type or module symbol appears anywhere under `quic/`. The +module passes one `quic_config`: a `quic_cred` naming a certificate by path or +by PEM buffer, a `quic_settings` carrying RFC 9000 transport parameters, a +`quic_callbacks` table of events, and a `quic_io` saying how datagrams move +— so the contract names no socket, and `quic_io_udp_init()` supplies the +ordinary UDP implementation. Engines report failures through an error buffer +rather than logging, and `quic/quic/src/quic_tls.c` builds the one TLS context every +engine serves from. Because the transport libraries are linked privately, +ngtcp2's headers stay off the include path of every translation unit outside +`quic/`. + +An engine maps what it can of `quic_settings` and documents the rest in a +`@note` on its ops table. Selection lives in `quic/quic/src/quic_registry.c`, so +`quic/quic/include/quic.h` is the only header a caller includes and every +`quic_.h` is private to the library: callers name an engine with +`quic_select()` and list what a build offers with `quic_engine_count()` and +`quic_engine_name_at()`. `quic/null/` implements the whole contract and carries +nothing; it is the standing proof that adding a backend touches its own +directory, one `add_subdirectory()` and one registry row — all inside `quic/`. + +nghttp3 sits above the interface and is unaffected by the choice. The engines +differ in one behaviour worth knowing: OpenSSL exposes no per-stream +acknowledgements, so the module counts bytes as acknowledged once OpenSSL +accepts them, while ngtcp2 reports real ones. Response buffers are therefore +released later, and more accurately, on ngtcp2. + +## Important Boundaries + +HTTP/3 connections are UDP/QUIC connections, but request processing runs through standard Apache machinery. HTTP/3 is advertised over existing TCP responses using `Alt-Svc`; clients then establish QUIC on the advertised UDP port. + +The module uses the first VirtualHost with both `H3CertificatePath` and `H3CertificateKeyPath` for its listener. Name-based virtual host selection then uses the request authority. IP-based virtual hosts remain unsupported because the necessary per-connection local address is not currently recovered by either engine. + +See the [configuration guide](configuration.md) for operational control points. diff --git a/docs/build.md b/docs/build.md new file mode 100644 index 0000000..6b003ac --- /dev/null +++ b/docs/build.md @@ -0,0 +1,60 @@ +# Build + +The default build compiles OpenSSL, APR, APR-util, httpd, and nghttp3 from the repository submodules. This is the supported path when system packages do not meet the required httpd module magic number. + +```sh +git submodule update --init +git submodule update --init --recursive dependencies/nghttp3 +cmake -B build +cmake --build build +``` + +Only nghttp3 needs its own submodule (`lib/sfparse`). Recursing everywhere also clones OpenSSL's eleven external-test submodules, which the build never uses. + +The module is written to `build/lib/mod_http3.so`. + +## Requirements + +The versions below are what the submodule build produces; supply your own with +the `WITH_*` options only if they meet these minimums. + +| Dependency | Minimum | +| --- | --- | +| OpenSSL | 3.5.0 with QUIC support | +| Apache httpd | MMN 20211221 | +| APR | 1.7.0 | +| APR-util | 1.6.0 | +| nghttp3 | 1.18.0 | + +Distribution-provided httpd packages usually have an older MMN and are rejected. Use the default source build or provide compatible custom prefixes. + +## QUIC engine + +`ENABLE_NGTCP2` decides which engines the module contains. The default needs +nothing extra: + +```sh +cmake -B build # OpenSSL's QUIC only (default) +cmake -B build -DENABLE_NGTCP2=ON # both, ngtcp2 from the submodule +``` + +Enabling ngtcp2 also builds `quic/third-party/ngtcp2`, which needs the OpenSSL +built alongside it; point `WITH_NGTCP2` at a prefix to use one you already have. +OpenSSL remains the TLS provider either way. + +A build containing both picks one at start-up with `H3QuicEngine`; see +[architecture](architecture.md#quic-engines). + +## Custom Prefixes + +```sh +git submodule update --init dependencies/nghttp3 +cmake -B build \ + -DWITH_SSL=/opt/openssl \ + -DWITH_HTTPD=/opt/httpd \ + -DBUILD_EXAMPLES=OFF \ + -DBUILD_TESTS=OFF +cmake --build build +``` + +Set `WITH_APR` and `WITH_APU` when APR and APR-util are not part of the httpd prefix. See the [full installation reference](https://github.com/machine-moon/mod_http3/blob/trunk/INSTALL) for package builds and every CMake option. diff --git a/CONFIGURATION.md b/docs/configuration.md similarity index 82% rename from CONFIGURATION.md rename to docs/configuration.md index 643434c..129e048 100644 --- a/CONFIGURATION.md +++ b/docs/configuration.md @@ -2,9 +2,16 @@ Advanced build options, dependency management, and build internals. -For quick start and deployment, see [INSTALL](INSTALL). +For quick start and deployment, see [INSTALL](../INSTALL). -For httpd runtime directives (`H3CertificatePath`, VirtualHost), see [CONFIGURATION_HTTPD.md](CONFIGURATION_HTTPD.md). +For httpd runtime directives (`H3CertificatePath`, VirtualHost), see [httpd Configuration](configuration_httpd.md). + +The Python suite honours `H3_QUIC_ENGINE`, so a build configured with +`-DENABLE_NGTCP2=ON` can be exercised on either transport: + +```sh +H3_QUIC_ENGINE=ngtcp2 pytest test/http3 +``` ## Build Commands @@ -36,7 +43,7 @@ By default, CMake builds all dependencies from their submodules at configure tim Build order: 1. nghttp3 -> `dependencies/nghttp3-dist/` -2. OpenSSL -> `dependencies/openssl-dist/` +2. OpenSSL -> `quic/third-party/openssl-dist/` 3. APR -> `dependencies/apr-dist/` 4. APR-util -> `dependencies/apr-util-dist/` 5. httpd -> `dependencies/httpd-dist/` @@ -44,7 +51,7 @@ Build order: Force a rebuild: ```sh -rm -rf dependencies/openssl-dist +rm -rf quic/third-party/openssl-dist cmake -B build ``` @@ -58,7 +65,7 @@ Provide `WITH_*` variables to override individual dependencies with system-insta | httpd (via apxs) | `WITH_HTTPD=/path` | >= 2.4.x AND MMN >= 20211221 | | APR | `WITH_APR=/path` | >= 1.7.0 | | APU | `WITH_APU=/path` | >= 1.6.0 | -| nghttp3 | `WITH_NGHTTP3=/path` | >= 1.17.0 | +| nghttp3 | `WITH_NGHTTP3=/path` | >= 1.18.0 | > Distro-packaged httpd (Ubuntu, Fedora, etc.) ships with MMN < 20211221 and will fail configure. Use build-from-source mode instead. @@ -106,7 +113,7 @@ cmake --build build --target tests ### Sentinel Files -Each dependency built from source writes `.done` to its output directory (e.g., `dependencies/openssl-dist/.done`). CMake checks for this file before rebuilding. Delete it to force rebuild. +Each dependency built from source writes `.done` to its output directory (e.g., `quic/third-party/openssl-dist/.done`). CMake checks for this file before rebuilding. Delete it to force rebuild. ### Build Logs @@ -121,11 +128,11 @@ Build logs are written to `dependencies/-dist/logs/`: Apply patch, remove build output, reconfigure: ```sh -cd dependencies/openssl +cd quic/third-party/openssl git apply /path/to/my.patch cd ../.. -rm -rf dependencies/openssl-dist +rm -rf quic/third-party/openssl-dist cmake -B build ``` @@ -138,5 +145,5 @@ dependencies/httpd-dist/bin/apxs -q HTTPD_MMN # expect 20211221 # Confirm OpenSSL is the one httpd links ldd dependencies/httpd-dist/modules/mod_ssl.so | grep ssl -# should show dependencies/openssl-dist/lib64/libssl.so +# should show quic/third-party/openssl-dist/lib64/libssl.so ``` diff --git a/CONFIGURATION_HTTPD.md b/docs/configuration_httpd.md similarity index 78% rename from CONFIGURATION_HTTPD.md rename to docs/configuration_httpd.md index 695d8c8..eafbf01 100644 --- a/CONFIGURATION_HTTPD.md +++ b/docs/configuration_httpd.md @@ -2,7 +2,7 @@ Apache httpd configuration directives for mod_http3. -For build and installation, see [INSTALL](INSTALL). +For build and installation, see [INSTALL](../INSTALL). ## Overview @@ -117,6 +117,41 @@ The timeout duration in seconds for QUIC handshakes to complete. If a connection The idle timeout duration in seconds for QUIC connections. This maps to the standard QUIC `max_idle_timeout` transport parameter. A connection will be closed if no traffic is sent or received within this timeframe. Use a higher value for applications that require long-lived idle connections (e.g., long-polling, WebSockets over HTTP/3). +### H3AddressValidation + +**Syntax:** `H3AddressValidation on|off` +**Context:** server config, virtual host +**Default:** `on` + +Whether to validate a client's source address before accepting a connection. When on, the server answers each new connection with a QUIC Retry packet (RFC 9000 section 8.1.2) and completes the handshake only after the client echoes the token back, which proves the client can receive at the address it claims. This is the defence against address-spoofed amplification attacks. + +Turning it off removes one round trip from every connection, at the cost of that protection. Leave it on for internet-facing deployments. It exists mainly for interoperability testing, where a test may require a handshake that completes without an intervening Retry. + +### H3QuicEngine + +**Syntax:** `H3QuicEngine openssl|ngtcp2|null` +**Context:** server config, virtual host +**Default:** `openssl` + +Which QUIC transport carries HTTP/3. `openssl` uses OpenSSL 3.5's own QUIC implementation and is always available. `ngtcp2` is present only when the module was built with `-DENABLE_NGTCP2=ON`; naming an engine the build does not contain is a fatal configuration error, so httpd refuses to start rather than quietly serving on the other one. OpenSSL provides TLS on both paths, so there is only ever one TLS stack in the process. + +`null` implements the whole engine contract and carries no traffic: the server +listens but never completes a handshake. It exists to keep the contract +addable-to and to run the module with no transport underneath; do not select it +in production. + +Naming an engine this build does not contain is rejected when the configuration +is parsed, so `httpd -t` catches it and names the engines that are compiled in. + +The engine in use is reported by the `http3-status` handler as `quic_backend`. +That handler is not mapped anywhere by default; give it a location first: + +```apache + + SetHandler http3-status + +``` + ## VirtualHost Configuration ### Port Detection diff --git a/docs/containers.md b/docs/containers.md new file mode 100644 index 0000000..7d0c69f --- /dev/null +++ b/docs/containers.md @@ -0,0 +1,182 @@ +# Containers + +Two images are published from this repository, and they are aimed at very +different people. + +| Image | What it is for | +|---|---| +| `ghcr.io/machine-moon/mod_http3` | Running mod_http3. Pull it and you have an HTTP/3 server. | +| `ghcr.io/machine-moon/mod_http3-interop` | The endpoint the QUIC Interop Runner drives. Not meant to be run by hand. | + +Commands below use `podman`. Substitute `docker` — the flags are identical. + +## Run the server + +Everything it needs is in the image: the configuration, a small demo site, and +a self-signed certificate it mints on first start. + +```sh +podman run --rm --name mod_http3 -p 8443:8443/udp ghcr.io/machine-moon/mod_http3:latest +``` + +HTTP/3 runs over UDP, hence the `/udp`. A bare `-p 8443:8443` publishes TCP +only, which gives you a server that answers HTTP/1.1 and never completes a QUIC +handshake. Add `-p 8443:8443` alongside if you also want HTTP/1.1, HTTP/2 and +`Alt-Svc` discovery on TCP. + +In another terminal: + +```sh +curl --http3-only -k -sI https://localhost:8443/ +``` + +``` +HTTP/3 200 +content-type: text/html +``` + +`--http3-only` refuses to fall back, so `HTTP/3` here proves QUIC carried it. +Your curl needs HTTP/3 support — `curl -V` must list `HTTP3` in its features. +Most distribution builds do not have it; see +[HTTP/3 testing with curl](https://github.com/machine-moon/mod_http3/blob/trunk/docs/testing-with-curl.md). + +`-k` is needed because the certificate is self-signed. Mount your own to drop +it, as below. + +## Use your own certificate + +The generated certificate is regenerated on every start and is fine for a demo, +not for anything else. Mount a real one over the certificate directory: + +```sh +bash scripts/mkcert.sh ./certs + +podman run --rm -p 8443:8443/udp \ + -v ./certs:/src/dependencies/httpd-dist/conf/certs:ro \ + ghcr.io/machine-moon/mod_http3:latest +``` + +The httpd child runs as `daemon`, and mod_http3 opens the QUIC socket in that +child, so **the private key has to be readable by `daemon`**. `mkcert.sh` writes +it `0600`, which is right for a host install and wrong here: + +```sh +chmod 0644 ./certs/server.key +``` + +If you skip that, httpd starts, the TCP listener works, and QUIC handshakes fail +with a permission error in the log. + +## Serve your own content + +```sh +podman run --rm -p 8443:8443/udp \ + -v ./public:/src/dependencies/httpd-dist/htdocs:ro \ + ghcr.io/machine-moon/mod_http3:latest +``` + +## Change the port + +The baked configuration takes its port from `H3_PORT`, so moving it needs no +mount: + +```sh +podman run --rm -e H3_PORT=8888 -p 8888:8888/udp ghcr.io/machine-moon/mod_http3:latest +``` + +## Change the configuration + +The baked configuration is [`container/httpd.conf`](https://github.com/machine-moon/mod_http3/blob/trunk/container/httpd.conf). +Copy it, edit it, mount it back: + +```sh +podman run --rm -p 8443:8443/udp \ + -v ./httpd.conf:/src/dependencies/httpd-dist/conf/httpd.conf:ro \ + ghcr.io/machine-moon/mod_http3:latest +``` + +Anything the baked configuration does not expose needs this — `H3_PORT` is the +only setting wired to an environment variable. + +Every `H3*` directive is documented in +[httpd Directives](configuration_httpd.md). + +The published image is built with both QUIC engines, so `H3QuicEngine ngtcp2` +in a mounted configuration switches the transport without rebuilding anything. +It defaults to `openssl`. + +## Development with compose + +Working on the module itself is easier with +[`container/compose.yml`](https://github.com/machine-moon/mod_http3/blob/trunk/container/compose.yml), +which builds from your checkout and mounts the config, certificates and content +over the baked ones: + +```sh +bash scripts/mkcert.sh container/certs +cd container +podman compose up -d --build +podman compose ps # wait for "healthy" +podman compose logs -f +podman compose down -v +``` + +A cold build takes about ten minutes — OpenSSL, APR, APR-util, nghttp3 and httpd +are all compiled from source. + +## Which tag to pull + +| Tag | Points at | +|---|---| +| `:latest` | The most recent build of `trunk` | +| `:X.Y.Z` | A release, retagged from the exact image that release was tested with | +| `:` | Any single trunk build, for pinning or bisecting | + +Pin a version for anything reproducible: + +```sh +podman pull ghcr.io/machine-moon/mod_http3:0.0.50 +``` + +## The interop endpoint + +`mod_http3-interop` is a different kind of image. It exists so the +[QUIC Interop Runner](https://interop.seemann.io/quic) can pull a mod_http3 +server and pit it against other QUIC implementations. It takes no arguments and +reads its whole configuration from environment variables the runner injects. + +Run it by hand and it tells you so: + +```sh +podman run --rm ghcr.io/machine-moon/mod_http3-interop:latest +``` + +``` +UNSUPPORTED ROLE +``` + +Exit code 127. That is the contract, not a fault — the runner requires an +endpoint to answer 127 for anything it does not implement, and uses that to +decide what to run. + +What it is useful for is running the matrix without a local build: register the +tag with the [QUIC Interop Runner](https://github.com/quic-interop/quic-interop-runner) +and it pulls the endpoint itself. + +Every commit is published under its own sha, and every release also lands as +`:X.Y.Z` and `:latest`. See [QUIC Interop Testing](interop.md) for what the +matrix means and how to read the results. + +## Troubleshooting + +**`curl` says the connection failed, or hangs.** Check the mapping says `/udp`. +`-p 8443:8443` publishes TCP only, and QUIC then has no path at all. + +**`curl: option --http3-only: the installed libcurl was built without…`.** Your +curl has no HTTP/3 support. `curl -V | grep HTTP3` confirms it either way. + +**HTTP/1.1 works but HTTP/3 does not.** Almost always certificate permissions — +see above. `podman logs mod_http3` shows the error from the child process. + +**`Invalid command 'H3CertificatePath'`.** The configuration you mounted does not +load the module. It needs `LoadModule http3_module modules/mod_http3.so`. diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..fbdd3da --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,40 @@ +# Deploy + +Copy the module to a compatible httpd installation and create certificate files readable by the httpd child user. + +```sh +cp build/lib/mod_http3.so /path/to/httpd/modules/ +bash scripts/mkcert.sh /path/to/httpd/conf/certs +``` + +Add `mod_ssl`, then `mod_http3`, before the VirtualHost that configures HTTP/3: + +```apache +LoadModule ssl_module modules/mod_ssl.so +LoadModule http3_module modules/mod_http3.so + +Listen 4433 https + + + ServerName localhost + SSLEngine on + SSLCertificateFile conf/certs/server.crt + SSLCertificateKeyFile conf/certs/server.key + H3CertificatePath conf/certs/server.crt + H3CertificateKeyPath conf/certs/server.key + DocumentRoot htdocs + + Require all granted + + +``` + +Open the UDP port on the server firewall. TCP is still needed for HTTP/1.1 and HTTP/2 clients, and for the initial `Alt-Svc` discovery flow. + +```sh +firewall-cmd --permanent --add-port=4433/udp && firewall-cmd --reload +# or +ufw allow 4433/udp +``` + +Set `H3Port` only when the QUIC listener must use a different UDP port from the configured VirtualHost. diff --git a/docs/interop.md b/docs/interop.md new file mode 100644 index 0000000..799f834 --- /dev/null +++ b/docs/interop.md @@ -0,0 +1,109 @@ +# QUIC Interop Testing + +The [QUIC Interop Runner](https://interop.seemann.io/quic) pairs every +registered QUIC implementation with every other one inside a network simulator +and reports a pass/fail cell per test case. mod_http3 takes part as a +**server**: there is no HTTP/3 client in this project, so mod_http3 is tested +against every client in the matrix. + +## How it runs + +[`.github/workflows/interop.yml`](https://github.com/machine-moon/mod_http3/blob/trunk/.github/workflows/interop.yml) +is a stage of the CI pipeline, so it runs on every push, on every branch, once +the module image has built. There is no local runner script: the +matrix needs a docker daemon, a `tshark` new enough to dissect QUIC, IPv6 on +the host and an hour of wall time, none of which belong in a developer loop. + +The `build` job builds `interop/Containerfile`, checks the image answers `127` +for a test case it does not implement, and publishes it as +`ghcr.io/machine-moon/mod_http3-interop:`. + +That image compiles nothing. It takes `MODULE_IMAGE=` — the module image the +`build` stage of the pipeline already produced — and copies the built `httpd` +and `mod_http3.so` out of it onto the runner's own base, guarding the result +with `ldd`. The module image is built with `ENABLE_NGTCP2=ON`, so the endpoint +carries both QUIC engines and picks one from `$ENGINE` at start-up. + +Each `test` job then **pulls that tag back out of the registry** and runs the +matrix against it. Nothing is passed between jobs as a file, so the image the +matrix exercises is byte-for-byte the one the registry serves. The client list +comes from the runner's own `implementations_quic.json`, so a new peer joins +the matrix without a change here. + +The matrix is `engine × client`, so it runs twice over the client list — once +per QUIC engine, against the same image — and each job is named +`test (, )`. That is what actually exercises the QUIC +abstraction; the two halves should agree. + +A release tags that same image `:X.Y.Z` and `:latest`, so +`mod_http3-interop:latest` always points at an endpoint whose matrix is public +and reproducible. + +## Reading the results + +Each `test` job writes its verdict to the workflow summary. A client that does +not implement the `http3` case reports a warning rather than a failure — +nothing reached mod_http3 — and a failing job keeps its logs as an artifact for +two weeks, laid out as `logs/_//`: + +| Path | Contents | +|---|---| +| `output.txt` | Everything the runner, the endpoint and the client printed | +| `server/httpd_error.log` | mod_http3's own log at `LogLevel http3:debug` | +| `server/keys.log` | TLS secrets, for decrypting the pcaps in Wireshark | +| `sim/trace_node_*.pcap` | What actually crossed the simulated link | + +A configuration error kills httpd before it opens `httpd_error.log`, so the +first failures of a broken endpoint are only visible on the `server |` lines +of `output.txt`. + +To reproduce a cell by hand, clone the +[runner](https://github.com/quic-interop/quic-interop-runner), add the +published image to its `implementations_quic.json` and run it — that is all the +`test` job does: + +```sh +python run.py -s mod_http3 -c quic-go -t http3 -l logs -j results.json +``` + +## Test case support + +`interop/run_endpoint.sh` exits 127 for any case the endpoint does not claim, +which the runner records as *unsupported* rather than failed. Today that is +every case except `http3`. + +The runner moves files with **HTTP/0.9 over ALPN `hq-interop`** in all but one +test case — its own `quic.md` puts it as "unless noted otherwise, test cases use +HTTP/0.9 for file transfers" — and mod_http3 only speaks `h3`. A client running +`handshake` offers `hq-interop` alone, so the connection dies in the handshake +with `no_application_protocol` before any QUIC behaviour is exercised: + +``` +[http3:debug] mod_http3: ALPN: client did not offer h3 +[http3:error] QUIC handshake did not complete: ... err=0x178 +``` + +That is a protocol the module does not implement, not a QUIC or HTTP/3 defect. +nginx covers the same ground with a dedicated `http3_hq on` directive that +serves HTTP/0.9 over its HTTP/3 stack for this harness; an equivalent here would +open the other 21 cases. Until then `http3` is the honest claim, and it still +exercises the handshake, QPACK, parallel streams and flow control against every +client in the matrix. + +## Troubleshooting + +**Every case is unsupported.** The runner refuses an implementation that does +not exit 127 for an unknown test case, and it makes that check with no timeout, +so a hung endpoint hangs the run. The `build` job pre-checks the same thing +with a timeout before any `test` job starts. + +**Every case fails in analysis.** The runner replays the simulator's pcaps +through `tshark`; without 4.5.0 or newer, cases fail in analysis rather than on +the wire. + +**The runner cannot start the endpoint.** Its compose file needs docker engine +28.1 or newer for `interface_name`, which is why the `test` jobs pin one. + +**`chrome` reports "Expected exactly 1 handshake. Got: 2".** The browser opens a +second connection and the case demands one. It does the same against nginx, so +treat that cell as a property of the client rather than of the server. diff --git a/docs/limits.md b/docs/limits.md new file mode 100644 index 0000000..5aac53b --- /dev/null +++ b/docs/limits.md @@ -0,0 +1,23 @@ +# Operational Limits + +HTTP/3 request and response bodies are buffered by the module. Set limits according to the memory available to each httpd child and the maximum concurrency you accept. + +| Directive | Default | Effect | +| --- | --- | --- | +| `H3MaxConnections` | `256` | Refuses new QUIC connections after the per-child limit | +| `H3MaxConcurrentStreams` | `100` | Caps in-flight requests per connection | +| `H3StreamBufferSize` | `65536` | Sets per-stream read/write buffer capacity | +| `H3MaxRequestBodySize` | `10485760` | Rejects request bodies above 10 MiB | +| `H3MaxResponseBodySize` | unlimited | Replaces excessive buffered responses with HTTP 500 when set | +| `H3HandshakeTimeout` | `10` seconds | Terminates incomplete QUIC/TLS handshakes | +| `H3IdleTimeout` | `300` seconds | Closes idle QUIC connections | + +## Response Body Limit + +`H3MaxResponseBodySize` is unlimited by default for compatibility. Configure it to cap worst-case per-request memory. A response that exceeds the limit is replaced with `500 Internal Server Error`; the original body may already be partially generated, so its `Content-Length` cannot be trusted. + +## Deployment Guidance + +Start with conservative limits in exposed deployments. Test realistic download, upload, and concurrent-stream workloads before increasing connection or stream counts. Network-wide denial-of-service mitigation remains outside the module's scope. + +The [security policy](security.md) describes the relevant trust boundaries and dependencies. diff --git a/docs/release-process.md b/docs/release-process.md index a4dd4a3..b0cd58e 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -28,19 +28,19 @@ For a release candidate to be officially published: ## 3. Release Workflow -Release candidates are prepared from a clean checkout of the target branch. We use standard `git` and `gh` (GitHub CLI) commands for version control, and a simple helper script (`scripts/release.sh`) to build, hash, and sign the artifacts. +Pushing a `v*` tag runs the [CI pipeline](../.github/workflows/ci.yml) against the tagged tree. Only once build, test, interop and pages have all passed does the [release stage](../.github/workflows/release.yml) run, and all it does is publish what those stages already produced: it retags the images, deploys the site, and uploads the build artifacts to a GitHub release named after the tag. An `-rcN` tag is published as a draft prerelease; a bare `vX.Y.Z` tag as a normal release. ```mermaid graph TD A[Start: git checkout branch] --> B[Bump VERSION in CMakeLists.txt] - B --> C[Tag candidate & run scripts/release.sh] - C --> D[Push tag & draft GitHub release] - D --> E[Prep vote email] + B --> C[Update CHANGES & commit] + C --> D[Push vX.Y.Z-rc1 tag] + D --> E[CI builds & drafts prerelease] E --> F{Community Vote} - F -- Fail/Bug Found --> G[Discard candidate tag] + F -- Fail/Bug Found --> G[Discard candidate tag & draft] G --> A - F -- Pass --> H[Create final tag & run scripts/release.sh] - H --> I[Push final tag & publish GitHub release] + F -- Pass --> H[Push final vX.Y.Z tag] + H --> I[CI publishes the release] I --> J[Stage site/download updates] J --> K[Announce] ``` @@ -48,34 +48,24 @@ graph TD ### Step-by-Step Process 1. **Prepare Candidate**: - Ensure you have a clean worktree. Bump the `VERSION` field in `project(mod_http3 VERSION X.Y.Z)` at the top of [CMakeLists.txt](../CMakeLists.txt), update `CHANGES`, and commit. Then create the local candidate tag (e.g., `vX.Y.Z-rc1`): - ```sh - git tag -a vX.Y.Z-rc1 -m "mod_http3 X.Y.Z release candidate 1" - ``` + Bump the `VERSION` field in `project(mod_http3 VERSION X.Y.Z)` at the top of [CMakeLists.txt](../CMakeLists.txt), update `CHANGES`, and commit. Artifact names come from that CMake version, and the workflow refuses to build if it disagrees with the tag. -2. **Generate Artifacts & Sign**: - Run the release script to build the release artifacts, generate SHA256 checksums, and create detached PGP signatures (`.asc`): +2. **Tag and Push the Candidate**: ```sh + git tag -a vX.Y.Z-rc1 -m "mod_http3 X.Y.Z-rc1" ./scripts/release.sh + git push origin vX.Y.Z-rc1 ``` - **Note:** You must have `gpg` and `sha256sum` installed, and an active GPG key. If you have multiple keys, you can specify one using `export GPG_KEY=`. + Candidate tags carry an `-rcN` suffix, so they are tagged by hand; `scripts/release.sh` builds the artifacts locally so you can inspect them. It also creates the bare `vX.Y.Z` tag from `CMakeLists.txt` — leave it, push only the `-rcN` tag, and step 5 will offer to move it onto the approved commit. Pushing the tag is what starts the workflow, and the workflow is the only thing that publishes a release — it re-checks the tag against `CMakeLists.txt`. - This will create the following files in `build-release/dist/` (each with a `.sha256` and `.asc`): + The release carries these assets, each with a `.sha256` beside it: - `mod_http3-X.Y.Z.tar.gz` / `mod_http3-X.Y.Z.zip` — source snapshots (the authoritative release artifacts) - `mod_http3-X.Y.Z-linux-.tar.gz` / `mod_http3-X.Y.Z-linux-.zip` — generic Linux binaries - `mod_http3-X.Y.Z..rpm` — RHEL/Fedora layout - `mod_http3_X.Y.Z_.deb` — Debian/Ubuntu layout -3. **Stage Candidate and Call Vote**: - Push the candidate tag to the repository: - ```sh - git push origin vX.Y.Z-rc1 - ``` - Create a draft prerelease on GitHub and upload all artifacts from `build-release/dist/`: - ```sh - gh release create vX.Y.Z-rc1 --draft --prerelease --title "mod_http3 X.Y.Z-rc1" build-release/dist/* - ``` - Draft the vote email by hand, referencing the tag, tarball URL, and checksums. Send the vote proposal to the developer list to open the 72-hour vote. +3. **Call the Vote**: + Draft the vote email by hand, referencing the tag, the release URL, and the checksums. Send the vote proposal to the developer list to open the 72-hour vote. 4. **Handling Failures**: If the community finds a bug or votes down the candidate, remove the GitHub draft release and the local/remote tags: @@ -87,15 +77,13 @@ graph TD Apply the fix, update your checkout, and restart from step 1 using the next candidate suffix (e.g., `rc2`). 5. **Publish Approved Release**: - Once the vote passes, create the final tag `vX.Y.Z` and push it: + Once the vote passes, create and push the final tag. The workflow rebuilds from that tag and publishes the release: ```sh - git tag -a vX.Y.Z -m "mod_http3 X.Y.Z release" + ./scripts/release.sh git push origin vX.Y.Z ``` - Re-run `./scripts/release.sh` to build the final artifacts, then create the final GitHub release: - ```sh - gh release create vX.Y.Z --title "mod_http3 X.Y.Z" build-release/dist/* - ``` + `scripts/release.sh` reads the version from `CMakeLists.txt` and creates the matching annotated `vX.Y.Z` tag after the artifacts build, so a failed build leaves no tag behind. If that tag already exists it asks before overwriting it, and refuses outright when there is no terminal to ask on. + A tag can only be published once. To redo a release, delete it as in step 4 and push the tag again. 6. **Stage and Commit Site Updates**: Update website documentation, download pages, and CVE details, then commit them to publish. @@ -107,36 +95,15 @@ graph TD ## 4. Verifying Releases -Users and developers should verify the integrity and origin of downloaded releases using PGP signatures and SHA hashes. +Every artifact ships with a `.sha256` file beside it. Download both and check: -### Verifying PGP Signatures -1. Import the author's public key from a public keyserver. You can find the key on [keys.openpgp.org](https://keys.openpgp.org/) or [keyserver.ubuntu.com](https://keyserver.ubuntu.com/). For example: - ```sh - gpg --keyserver hkps://keys.openpgp.org --recv-keys - ``` -2. Verify the detached signatures for each artifact: - ```sh - for sig in *.asc; do - if [[ "$sig" == "SHA256SUMS.asc" ]]; then - gpg --verify SHA256SUMS.asc SHA256SUMS - else - artifact="${sig%.asc}" - gpg --verify "$sig" "$artifact" - fi - done - ``` - Ensure the output reports a `Good signature` from an authorized committer for each artifact. - -### Verifying Checksums -First verify that the signed manifest authenticates every individual checksum file: -```sh -sha256sum -c SHA256SUMS -``` -Then verify every artifact against its individual checksum: ```sh -for checksum in *.sha256; do sha256sum -c "$checksum"; done +gh release download vX.Y.Z --pattern 'mod_http3-X.Y.Z.tar.gz*' +sha256sum --check mod_http3-X.Y.Z.tar.gz.sha256 ``` +Provenance comes from the release itself: the assets are built by the [CI pipeline](../.github/workflows/ci.yml) from the tagged tree, and the run linked on the release page shows the exact commit and build log. + --- ## 5. Committing Security Fixes diff --git a/docs/site/.readthedocs.yaml b/docs/site/.readthedocs.yaml new file mode 100644 index 0000000..857dd12 --- /dev/null +++ b/docs/site/.readthedocs.yaml @@ -0,0 +1,25 @@ +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.12" + apt_packages: + - doxygen + jobs: + pre_build: + # doxybook2 has no PyPI package; fetch the release binary into ~/.local/bin, + # which scripts/generate_docs.sh looks for. + - curl -fsSL -o /tmp/doxybook2.zip https://github.com/matusnovak/doxybook2/releases/download/v1.5.0/doxybook2-linux-amd64-v1.5.0.zip + - unzip -o -q /tmp/doxybook2.zip -d /tmp/doxybook2 + - mkdir -p "$HOME/.local/bin" + - install -m 0755 "$(find /tmp/doxybook2 -type f -name doxybook2 | head -1)" "$HOME/.local/bin/doxybook2" + build: + html: + - bash ../../scripts/generate_docs.sh + - mkdir -p "$READTHEDOCS_OUTPUT/html" + - cp -a build/. "$READTHEDOCS_OUTPUT/html/" + +python: + install: + - requirements: site/requirements.txt diff --git a/docs/site/Doxyfile b/docs/site/Doxyfile new file mode 100644 index 0000000..a02d124 --- /dev/null +++ b/docs/site/Doxyfile @@ -0,0 +1,20 @@ +PROJECT_NAME = "mod_http3" +PROJECT_BRIEF = "HTTP/3 and QUIC support for Apache httpd" +OUTPUT_DIRECTORY = build/doxygen +GENERATE_LATEX = NO +GENERATE_HTML = NO +GENERATE_XML = YES +XML_OUTPUT = xml +INPUT = mod_http3/include mod_http3/src quic/quic/include +RECURSIVE = YES +# Only the QUIC contract is published; engine internals stay hidden. +EXCLUDE = quic/quic/src quic/null quic/ossl quic/ngtcp2 +OPTIMIZE_OUTPUT_FOR_C = YES +EXTRACT_ALL = YES +EXTRACT_PRIVATE = NO +EXTRACT_STATIC = YES +MACRO_EXPANSION = YES +EXPAND_ONLY_PREDEF = YES +SHOW_NAMESPACES = NO +GENERATE_TREEVIEW = NO +WARN_IF_UNDOCUMENTED = NO diff --git a/docs/site/doxybook-templates/footer.tmpl b/docs/site/doxybook-templates/footer.tmpl new file mode 100644 index 0000000..92fda97 --- /dev/null +++ b/docs/site/doxybook-templates/footer.tmpl @@ -0,0 +1 @@ +{# Renders nothing: replaces doxybook2's "Updated on " page footer. #} diff --git a/docs/site/doxybook-templates/index.tmpl b/docs/site/doxybook-templates/index.tmpl new file mode 100644 index 0000000..2637d68 --- /dev/null +++ b/docs/site/doxybook-templates/index.tmpl @@ -0,0 +1,10 @@ + +{% for child0 in children %}* **{{child0.kind}} [{{child0.title}}]({{child0.url}})** {% if existsIn(child0, "brief") %}
{{child0.brief}}{% endif %}{% if existsIn(child0, "children") %}{% for child1 in child0.children %} + * **{{child1.kind}} [{{last(split(child1.title, "/"))}}]({{child1.url}})** {% if existsIn(child1, "brief") %}
{{child1.brief}}{% endif %}{% if existsIn(child1, "children") %}{% for child2 in child1.children %} + * **{{child2.kind}} [{{last(split(child2.title, "/"))}}]({{child2.url}})** {% if existsIn(child2, "brief") %}
{{child2.brief}}{% endif %}{% if existsIn(child2, "children") %}{% for child3 in child2.children %} + * **{{child3.kind}} [{{last(split(child3.title, "/"))}}]({{child3.url}})** {% if existsIn(child3, "brief") %}
{{child3.brief}}{% endif %}{% if existsIn(child3, "children") %}{% for child4 in child3.children %} + * **{{child4.kind}} [{{last(split(child4.title, "/"))}}]({{child4.url}})** {% if existsIn(child4, "brief") %}
{{child4.brief}}{% endif %}{% if existsIn(child4, "children") %}{% for child5 in child4.children %} + * **{{child5.kind}} [{{last(split(child5.title, "/"))}}]({{child5.url}})** {% if existsIn(child5, "brief") %}
{{child5.brief}}{% endif %}{% if existsIn(child5, "children") %}{% for child6 in child5.children %} + * **{{child6.kind}} [{{last(split(child6.title, "/"))}}]({{child6.url}})** {% if existsIn(child6, "brief") %}
{{child6.brief}}{% endif %}{% if existsIn(child6, "children") %}{% for child7 in child6.children %} + * **{{child7.kind}} [{{last(split(child7.title, "/"))}}]({{child7.url}})** {% if existsIn(child7, "brief") %}
{{child7.brief}}{% endif %}{% endfor %}{% endif %}{% endfor %}{% endif %}{% endfor %}{% endif %}{% endfor %}{% endif %}{% endfor %}{% endif %}{% endfor %}{% endif %}{% endfor %}{% endif %} +{% endfor %} diff --git a/docs/site/doxybook-templates/index_files.tmpl b/docs/site/doxybook-templates/index_files.tmpl new file mode 100644 index 0000000..468824a --- /dev/null +++ b/docs/site/doxybook-templates/index_files.tmpl @@ -0,0 +1,5 @@ +{% include "header" %} + +{% include "index" %} + +{% include "footer" %} diff --git a/docs/site/doxybook_config.json b/docs/site/doxybook_config.json new file mode 100644 index 0000000..ed4af0c --- /dev/null +++ b/docs/site/doxybook_config.json @@ -0,0 +1,16 @@ +{ + "baseUrl": "", + "useFolders": false, + "indexInFolders": false, + "mainPageInRoot": false, + "linkSuffix": ".md", + "linkLowercase": false, + "replaceUnderscoresInAnchors": false, + "copyImages": false, + "foldersToGenerate": [ + "classes", + "files" + ], + "indexClassesTitle": "Structs", + "indexFilesTitle": "Source Files" +} diff --git a/docs/site/pages/api.md b/docs/site/pages/api.md new file mode 100644 index 0000000..348ba4b --- /dev/null +++ b/docs/site/pages/api.md @@ -0,0 +1,9 @@ +# API Reference + +This reference mirrors the `mod_http3` C sources: there are no classes or +namespaces, because the module is plain C. + +- **[Structs](api/index_classes.md)**: the C structs that carry + connection, session, stream, and configuration state through the module. +- **[Source Files](api/index_files.md)**: every header and translation unit, + broken down into its functions, types, and macros. diff --git a/docs/site/pages/architecture.md b/docs/site/pages/architecture.md new file mode 120000 index 0000000..7c48eaf --- /dev/null +++ b/docs/site/pages/architecture.md @@ -0,0 +1 @@ +../../architecture.md \ No newline at end of file diff --git a/docs/site/pages/browser-testing.md b/docs/site/pages/browser-testing.md new file mode 120000 index 0000000..45cba34 --- /dev/null +++ b/docs/site/pages/browser-testing.md @@ -0,0 +1 @@ +../../testing-with-browser.md \ No newline at end of file diff --git a/docs/site/pages/build.md b/docs/site/pages/build.md new file mode 120000 index 0000000..9d34f2b --- /dev/null +++ b/docs/site/pages/build.md @@ -0,0 +1 @@ +../../build.md \ No newline at end of file diff --git a/docs/site/pages/coding-standards.md b/docs/site/pages/coding-standards.md new file mode 120000 index 0000000..933b648 --- /dev/null +++ b/docs/site/pages/coding-standards.md @@ -0,0 +1 @@ +../../coding-standards.md \ No newline at end of file diff --git a/docs/site/pages/configuration.md b/docs/site/pages/configuration.md new file mode 120000 index 0000000..126f82f --- /dev/null +++ b/docs/site/pages/configuration.md @@ -0,0 +1 @@ +../../configuration.md \ No newline at end of file diff --git a/docs/site/pages/configuration_httpd.md b/docs/site/pages/configuration_httpd.md new file mode 120000 index 0000000..f576d2c --- /dev/null +++ b/docs/site/pages/configuration_httpd.md @@ -0,0 +1 @@ +../../configuration_httpd.md \ No newline at end of file diff --git a/docs/site/pages/containers.md b/docs/site/pages/containers.md new file mode 120000 index 0000000..a692070 --- /dev/null +++ b/docs/site/pages/containers.md @@ -0,0 +1 @@ +../../containers.md \ No newline at end of file diff --git a/docs/site/pages/contributing.md b/docs/site/pages/contributing.md new file mode 120000 index 0000000..c97564d --- /dev/null +++ b/docs/site/pages/contributing.md @@ -0,0 +1 @@ +../../../CONTRIBUTING.md \ No newline at end of file diff --git a/docs/site/pages/curl-testing.md b/docs/site/pages/curl-testing.md new file mode 120000 index 0000000..19e1076 --- /dev/null +++ b/docs/site/pages/curl-testing.md @@ -0,0 +1 @@ +../../testing-with-curl.md \ No newline at end of file diff --git a/docs/site/pages/deploy.md b/docs/site/pages/deploy.md new file mode 120000 index 0000000..aba4085 --- /dev/null +++ b/docs/site/pages/deploy.md @@ -0,0 +1 @@ +../../deploy.md \ No newline at end of file diff --git a/docs/site/pages/examples-testing.md b/docs/site/pages/examples-testing.md new file mode 120000 index 0000000..502d621 --- /dev/null +++ b/docs/site/pages/examples-testing.md @@ -0,0 +1 @@ +../../testing-examples.md \ No newline at end of file diff --git a/docs/site/pages/examples.md b/docs/site/pages/examples.md new file mode 120000 index 0000000..502d621 --- /dev/null +++ b/docs/site/pages/examples.md @@ -0,0 +1 @@ +../../testing-examples.md \ No newline at end of file diff --git a/docs/site/pages/index.md b/docs/site/pages/index.md new file mode 100644 index 0000000..3fbeeda --- /dev/null +++ b/docs/site/pages/index.md @@ -0,0 +1,27 @@ +# mod_http3 + +## HTTP/3 for Apache httpd + +`mod_http3` is an Apache httpd module that serves HTTP/3 over QUIC. It integrates with the standard httpd request pipeline while adding a UDP/QUIC listener, TLS 1.3 handling through OpenSSL, and HTTP/3 framing through nghttp3. + +The module advertises HTTP/3 with `Alt-Svc` by default, allowing compatible clients to discover the UDP endpoint from a TCP response. + +## Start Here + +1. [Build](build.md) the module and its pinned dependencies. +2. [Deploy](deploy.md) it into a custom httpd installation. +3. [Verify](verify.md) the UDP listener, module load, and an HTTP/3 request. +4. Read the [Directive Guide](configuration.md) before setting production limits. + +## Status + +Configuration and C API may change between releases. Read the [versioning policy](https://github.com/machine-moon/mod_http3/blob/trunk/VERSIONING) and [release process](releases.md) before upgrading. + +## Primary Components + +| Component | Responsibility | +| --- | --- | +| Apache httpd | Request routing, virtual hosts, filters, and module hosting | +| OpenSSL 3.5+ | QUIC transport and TLS 1.3 | +| nghttp3 | HTTP/3 framing and stream state | +| APR / APR-util | Portable threads, pools, and sockets | diff --git a/docs/site/pages/interop.md b/docs/site/pages/interop.md new file mode 120000 index 0000000..7093b92 --- /dev/null +++ b/docs/site/pages/interop.md @@ -0,0 +1 @@ +../../interop.md \ No newline at end of file diff --git a/docs/site/pages/limits.md b/docs/site/pages/limits.md new file mode 120000 index 0000000..56d193a --- /dev/null +++ b/docs/site/pages/limits.md @@ -0,0 +1 @@ +../../limits.md \ No newline at end of file diff --git a/docs/site/pages/releases.md b/docs/site/pages/releases.md new file mode 120000 index 0000000..c5f8a71 --- /dev/null +++ b/docs/site/pages/releases.md @@ -0,0 +1 @@ +../../release-process.md \ No newline at end of file diff --git a/docs/site/pages/security.md b/docs/site/pages/security.md new file mode 120000 index 0000000..7f93311 --- /dev/null +++ b/docs/site/pages/security.md @@ -0,0 +1 @@ +../../../SECURITY.md \ No newline at end of file diff --git a/docs/site/pages/verify.md b/docs/site/pages/verify.md new file mode 120000 index 0000000..ffc6518 --- /dev/null +++ b/docs/site/pages/verify.md @@ -0,0 +1 @@ +../../verify.md \ No newline at end of file diff --git a/docs/site/requirements.txt b/docs/site/requirements.txt new file mode 100644 index 0000000..80f53ab --- /dev/null +++ b/docs/site/requirements.txt @@ -0,0 +1 @@ +zensical==0.0.51 diff --git a/docs/site/zensical.toml b/docs/site/zensical.toml new file mode 100644 index 0000000..7e294e5 --- /dev/null +++ b/docs/site/zensical.toml @@ -0,0 +1,87 @@ +[project] +site_name = "mod_http3" +site_description = "HTTP/3 and QUIC support for Apache httpd" +site_author = "The mod_http3 Project Authors" +site_url = "https://machine-moon.github.io/mod_http3/" +repo_url = "https://github.com/machine-moon/mod_http3" +repo_name = "machine-moon/mod_http3" +docs_dir = "pages" +site_dir = "build" + +copyright = """ +Copyright © 2026 The mod_http3 Project Authors · Apache License 2.0 +""" + +# Navigation. Kept explicit so the reading order tells a story: +# install it, configure it, understand it, then dive into the C API. +nav = [ + { "Home" = "index.md" }, + { "Getting Started" = [ + { "Build" = "build.md" }, + { "Deploy" = "deploy.md" }, + { "Containers" = "containers.md" }, + { "Verify" = "verify.md" }, + ] }, + { "Configuration" = [ + { "Build Configuration" = "configuration.md" }, + { "httpd Directives" = "configuration_httpd.md" }, + { "Operational Limits" = "limits.md" }, + ] }, + { "Guides" = [ + { "Architecture" = "architecture.md" }, + ] }, + { "Testing" = [ + { "Browser" = "browser-testing.md" }, + { "curl" = "curl-testing.md" }, + { "Examples" = "examples-testing.md" }, + { "QUIC Interop" = "interop.md" }, + ] }, + { "API Reference" = [ + { "Overview" = "api.md" }, + { "Structs" = "api/index_classes.md" }, + { "Source Files" = "api/index_files.md" }, + ] }, + { "Project" = [ + { "Releases" = "releases.md" }, + { "Security" = "security.md" }, + { "Contributing" = "contributing.md" }, + ] }, +] + +# Single light palette on purpose: no dark-mode toggle is rendered. +[project.theme] +language = "en" +features = [ + "navigation.instant", + "navigation.instant.prefetch", + "navigation.tracking", + "navigation.tabs", + "navigation.sections", + "navigation.indexes", + "navigation.top", + "navigation.footer", + "toc.follow", + "content.code.copy", + "content.code.annotate", + "content.tooltips", + "search.highlight", +] + +[project.theme.icon] +repo = "fontawesome/brands/github" + +[[project.theme.palette]] +scheme = "default" +primary = "indigo" +accent = "indigo" + +[project.extra] +# Suppress the generator credit line in the footer. +generator = false + +[[project.extra.social]] +icon = "fontawesome/brands/github" +link = "https://github.com/machine-moon/mod_http3" +name = "mod_http3 on GitHub" + +# markdown_extensions omitted so the default set (mermaid, admonitions, tabs) applies. diff --git a/docs/verify.md b/docs/verify.md new file mode 100644 index 0000000..7c89b74 --- /dev/null +++ b/docs/verify.md @@ -0,0 +1,26 @@ +# Verify + +Validate configuration before starting httpd: + +```sh +/path/to/httpd/bin/httpd -t +/path/to/httpd/bin/httpd -M | grep http3 +ss -ulnp | grep 4433 +``` + +The module list should show `http3_module (shared)`, and the socket inspection should show a UDP listener on the selected port. + +## Test HTTP/3 + +Use a curl build with HTTP/3 support: + +```sh +curl -V +curl --http3-only -k -sI https://localhost:4433/ +``` + +`curl -V` must list `HTTP3`. `--http3-only` prevents fallback to HTTP/2 or HTTP/1.1, so a successful response proves a QUIC connection was used. + +For trusted local testing, prefer `--cacert /path/to/server.crt` over `-k`. + +See [HTTP/3 testing with curl](https://github.com/machine-moon/mod_http3/blob/trunk/docs/testing-with-curl.md) for GET, POST, PUT, concurrent stream, and failure-diagnosis commands. diff --git a/interop/Containerfile b/interop/Containerfile new file mode 100644 index 0000000..b662659 --- /dev/null +++ b/interop/Containerfile @@ -0,0 +1,21 @@ +ARG MODULE_IMAGE +FROM ${MODULE_IMAGE} AS module + +FROM martenseemann/quic-network-simulator-endpoint:latest AS runtime + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpcre2-8-0 libxml2 libexpat1 zlib1g liblua5.4-0 libbrotli1 libnghttp2-14 \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=module /src/dependencies/ /src/dependencies/ +COPY --from=module /src/quic/third-party/ /src/quic/third-party/ + +RUN ldd /src/dependencies/httpd-dist/bin/httpd | grep -q "not found" && exit 1; \ + ldd /src/dependencies/httpd-dist/modules/mod_http3.so | grep -q "not found" && exit 1; true + +COPY interop/httpd.conf /src/dependencies/httpd-dist/conf/httpd.conf +COPY interop/run_endpoint.sh /run_endpoint.sh +RUN chmod +x /run_endpoint.sh + +ENTRYPOINT [ "/run_endpoint.sh" ] diff --git a/interop/README.md b/interop/README.md new file mode 120000 index 0000000..9ebfa0b --- /dev/null +++ b/interop/README.md @@ -0,0 +1 @@ +../docs/interop.md \ No newline at end of file diff --git a/interop/httpd.conf b/interop/httpd.conf new file mode 100644 index 0000000..78dbaf1 --- /dev/null +++ b/interop/httpd.conf @@ -0,0 +1,68 @@ +ServerRoot "/src/dependencies/httpd-dist" + +LoadModule mpm_event_module modules/mod_mpm_event.so +LoadModule authz_core_module modules/mod_authz_core.so +LoadModule log_config_module modules/mod_log_config.so +LoadModule mime_module modules/mod_mime.so +LoadModule ssl_module modules/mod_ssl.so +LoadModule unixd_module modules/mod_unixd.so +LoadModule dir_module modules/mod_dir.so +LoadModule http3_module modules/mod_http3.so + +EnableMMAP Off + +User www-data +Group www-data + + + ServerLimit 1 + StartServers 1 + ThreadsPerChild 64 + MaxRequestWorkers 64 + MinSpareThreads 16 + MaxSpareThreads 128 + MaxConnectionsPerChild 0 + + +ErrorLog /logs/httpd_error.log +CustomLog /logs/httpd_access.log "%h %l %u %t \"%r\" %>s %b" +LogLevel warn http3:debug + +PidFile /tmp/httpd.pid +TypesConfig conf/mime.types +ServerName server4 + +Listen 443 https + + + ServerName server4 + ServerAlias server6 server46 + + SSLEngine on + SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1 + SSLCertificateFile /interop/certs/cert.pem + SSLCertificateKeyFile /interop/certs/priv.key + + Protocols h3 + + H3CertificatePath /interop/certs/cert.pem + H3CertificateKeyPath /interop/certs/priv.key + H3Port 443 + + H3MaxConcurrentStreams 1000 + H3MaxConnections 100 + + H3HandshakeTimeout 60 + H3IdleTimeout 300 + + H3AltSvc off + + Include /interop/testcase.conf + + DocumentRoot "/interop/www" + + Options None + AllowOverride None + Require all granted + + diff --git a/interop/run_endpoint.sh b/interop/run_endpoint.sh new file mode 100755 index 0000000..d39cf13 --- /dev/null +++ b/interop/run_endpoint.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +set -u + +HTTPD=/src/dependencies/httpd-dist/bin/httpd +CONF=/src/dependencies/httpd-dist/conf/httpd.conf + +[ "${ROLE:-}" = server ] || { echo "UNSUPPORTED ROLE ${ROLE:-}"; exit 127; } + +case "${TESTCASE:-}" in + http3) ;; + *) echo "UNSUPPORTED TESTCASE ${TESTCASE:-}"; exit 127 ;; +esac + +/setup.sh + +install -D -m 644 -t /interop/certs /certs/cert.pem /certs/priv.key +cp -rT /www /interop/www && chmod -R a+rX /interop/www +chown -R www-data /logs +printf 'H3AddressValidation off\nH3QuicEngine %s\n' "${ENGINE:-openssl}" >/interop/testcase.conf + +echo "TESTCASE=$TESTCASE ENGINE=${ENGINE:-openssl}" +"$HTTPD" -t -f "$CONF" || { echo "httpd rejected the configuration"; exit 1; } + +exec "$HTTPD" -D FOREGROUND -f "$CONF" diff --git a/mod_http3/include/h3_callbacks.h b/mod_http3/include/h3_callbacks.h index 980a956..df3016c 100644 --- a/mod_http3/include/h3_callbacks.h +++ b/mod_http3/include/h3_callbacks.h @@ -92,6 +92,14 @@ int on_recv_data(nghttp3_conn* conn, int64_t stream_id, const uint8_t* data, siz */ int on_acked_stream_data(nghttp3_conn* conn, int64_t stream_id, uint64_t datalen, void* user_data, void* stream_user_data); +/** + * nghttp3 callback reporting bytes it consumed for a stream that had been + * deferred. The engine must be given this many bytes of flow control credit, + * or the peer stalls once its initial window is spent. + * @return 0 on success. + */ +int on_deferred_consume(nghttp3_conn* conn, int64_t stream_id, size_t consumed, void* user_data, void* stream_user_data); + /** * nghttp3 stop_sending callback. Abort stream read side. * @param conn The nghttp3 connection. diff --git a/mod_http3/include/h3_config.h b/mod_http3/include/h3_config.h index 297c9c0..805ca5b 100644 --- a/mod_http3/include/h3_config.h +++ b/mod_http3/include/h3_config.h @@ -40,6 +40,7 @@ struct h3_server_conf const char* h3_cert_path; const char* h3_key_path; + const char* h3_quic_engine; apr_port_t h3_port; apr_uint32_t h3_max_concurrent_streams; apr_uint32_t h3_max_connections; @@ -47,6 +48,7 @@ struct h3_server_conf apr_size_t h3_max_request_body_size; apr_size_t h3_max_response_body_size; h3_tri_flag h3_alt_svc; + h3_tri_flag h3_address_validation; apr_uint32_t h3_alt_svc_max_age; apr_uint32_t h3_handshake_timeout; apr_uint32_t h3_idle_timeout; diff --git a/mod_http3/include/h3_io.h b/mod_http3/include/h3_io.h index 04407aa..e34dc60 100644 --- a/mod_http3/include/h3_io.h +++ b/mod_http3/include/h3_io.h @@ -29,26 +29,19 @@ #include #include -#include - #include "h3_config.h" +#include "quic.h" /// Optional MPM hooks; crash at runtime if unsupported. APR_DECLARE_OPTIONAL_FN(void, ap_mpm_note_extra_connection_added, (void)); APR_DECLARE_OPTIONAL_FN(void, ap_mpm_note_extra_connection_removed, (void)); typedef struct h3_session h3_session; -typedef struct h3_peer_datagram h3_peer_datagram; typedef struct h3_io_t { - SSL_CTX* ssl_ctx; - SSL* ssl_listener; - BIO_METHOD* peer_addr_bio_method; - BIO_ADDR* current_peer_addr; - int peer_addr_ex_index; - h3_peer_datagram* peer_rx_head; - h3_peer_datagram* peer_rx_tail; + quic_engine* qengine; + quic_io qio; apr_pool_t* pool; server_rec* server; int udp_fd; @@ -71,14 +64,14 @@ typedef struct h3_io_t typedef struct h3_pending_handshake { - SSL* conn; + quic_conn* conn; apr_time_t accepted_at; } h3_pending_handshake; extern h3_io_t* child_h3_io; /** - * Build the SSL listener, bind the UDP socket via @p udp_fd, and spawn the + * Build the listener, bind the UDP socket via @p udp_fd, and spawn the * event thread. Idempotent on the same port: returns APR_EAGAIN if another * child already owns it. * @param pchild Child process pool. @@ -92,7 +85,7 @@ apr_status_t h3_io_listen_start(apr_pool_t* pchild, server_rec* s, h3_server_con /** * Stop the event thread, join all worker threads, and release the UDP fd - * and SSL context. Safe to call with NULL. + * and engine. Safe to call with NULL. * @param io The h3_io_t to tear down. */ void h3_io_listen_stop(h3_io_t* io); @@ -104,22 +97,6 @@ void h3_io_listen_stop(h3_io_t* io); */ int h3_io_at_connection_limit(h3_io_t* io); -/** Return non-zero while the address-aware BIO has buffered received datagrams. */ -int h3_io_has_buffered_datagrams(h3_io_t* io); - -/** - * Retrieve the UDP peer address captured when OpenSSL created a pending QUIC - * connection. OpenSSL 3.5 does not otherwise expose an accepted connection's - * peer address through its public API. - * @param io The owning listener instance. - * @param conn The accepted QUIC connection. - * @param pool Pool used for the APR address and numeric IP string. - * @param addr Receives the client's socket address. - * @param client_ip Receives the client's numeric IP string. - * @return APR_SUCCESS when an address is available, or an APR error. - */ -apr_status_t h3_io_get_client_addr(h3_io_t* io, SSL* conn, apr_pool_t* pool, apr_sockaddr_t** addr, char** client_ip); - /** * Service the newly established session connection. Drives HTTP/3 request processing. * @param io The owning h3_io_t listener instance. @@ -134,18 +111,11 @@ int service_session_pass(h3_io_t* io, h3_session* session); */ void wait_for_event(h3_io_t* io); -/** - * Handle engine events and progress the SSL listener. - * @param conn The SSL connection instance. - * @return 1 on success, 0 otherwise. - */ -int tick_engine(SSL* conn); - /** * Remove a connection from the pending handshake array. * @param io The owning h3_io_t listener instance. * @param index The index of the connection in the array. - * @param free_conn If non-zero, the connection's SSL object is freed. + * @param free_conn If non-zero, the connection object is freed. */ void remove_pending_handshake(h3_io_t* io, int index, int free_conn); @@ -153,10 +123,10 @@ void remove_pending_handshake(h3_io_t* io, int index, int free_conn); * Prepare a newly accepted connection before starting the handshake. * Sets stream modes, Incoming Stream policies, and pushes it to the pending array. * @param io The owning h3_io_t listener instance. - * @param conn The newly accepted SSL connection instance. + * @param conn The newly accepted QUIC connection instance. * @return 1 on success, 0 otherwise. */ -int prepare_accepted_connection(h3_io_t* io, SSL* conn); +int prepare_accepted_connection(h3_io_t* io, quic_conn* conn); /** * Progress handshakes for all pending connections, timing out stalled connections diff --git a/mod_http3/include/h3_session.h b/mod_http3/include/h3_session.h index bd1ae49..c095de9 100644 --- a/mod_http3/include/h3_session.h +++ b/mod_http3/include/h3_session.h @@ -27,10 +27,10 @@ #include #include -#include - #include +#include "quic.h" + typedef struct h3_session h3_session; typedef struct h3_stream h3_stream; typedef struct h3_response_chunk h3_response_chunk; @@ -41,8 +41,7 @@ struct h3_session server_rec* s; apr_pool_t* pool; - SSL* ssl_listener; - SSL* ssl_conn; + quic_conn* qconn; nghttp3_conn* ngh3; apr_thread_mutex_t* lock; @@ -79,7 +78,7 @@ struct h3_stream h3_session* session; apr_pool_t* pool; int64_t stream_id; - SSL* ssl_stream; + quic_stream* qstream; int done; /* QUIC stream send buffer was full; nghttp3 told to skip the stream. */ int write_blocked; @@ -123,12 +122,11 @@ struct h3_stream * Allocate and initialize a new HTTP/3 session. * @param psession Out parameter for the new session. * @param s The virtual host this session is bound to. - * @param ssl_listener The QUIC listener SSL (used to clone the ctx). - * @param ssl_conn The accepted QUIC connection SSL. + * @param qconn The accepted QUIC connection. * @param pool Pool used for all session allocations. * @return APR_SUCCESS on success, error code otherwise. */ -apr_status_t h3_session_create(h3_session** psession, server_rec* s, SSL* ssl_listener, SSL* ssl_conn, apr_pool_t* pool); +apr_status_t h3_session_create(h3_session** psession, server_rec* s, quic_conn* qconn, apr_pool_t* pool); /** * Create the HTTP/3 control streams (unidirectional, RFC 9114 7.2). @@ -137,6 +135,15 @@ apr_status_t h3_session_create(h3_session** psession, server_rec* s, SSL* ssl_li */ apr_status_t h3_session_create_control_streams(h3_session* session); +/** + * Report bytes the peer acknowledged on a stream. Called by engines that carry + * real acknowledgements; takes the session lock itself. + * @param session The owning session. + * @param stream_id The stream the acknowledgement is for. + * @param datalen Number of application bytes acknowledged. + */ +void h3_session_on_stream_acked(void* user, int64_t stream_id, uint64_t datalen); + /** * Tear down a session: stops the SSL object, frees the nghttp3 connection, * and destroys the session pool. Safe to call with NULL. @@ -145,13 +152,13 @@ apr_status_t h3_session_create_control_streams(h3_session* session); void h3_session_destroy(h3_session* session); /** - * Queue an SSL stream object to be freed when the session lock is next + * Queue a QUIC stream object to be freed when the session lock is next * released. Used to defer frees that must not happen while another thread * is mid-call. * @param session The owning session. - * @param ssl The SSL stream object to free. + * @param st The QUIC stream object to free. */ -void h3_session_queue_free(h3_session* session, SSL* ssl); +void h3_session_queue_free(h3_session* session, quic_stream* st); /** * nghttp3 data reader callback. Called by nghttp3 to pull the next chunks of diff --git a/mod_http3/include/h3_ssl.h b/mod_http3/include/h3_ssl.h deleted file mode 100644 index e5b577e..0000000 --- a/mod_http3/include/h3_ssl.h +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * 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. - */ - -#ifndef H3_SSL_H -#define H3_SSL_H - -#include - -/** - * ALPN selection callback for the QUIC SSL_CTX. Negotiates "h3" as the - * single supported protocol. Per OpenSSL's SSL_CTX_set_alpn_select_cb - * contract. - * @param ssl The SSL object performing the negotiation. - * @param out Out: pointer to the selected protocol bytes. - * @param outlen Out: length of the selected protocol. - * @param in Wire-format ALPN extension from the peer. - * @param inlen Length of @p in. - * @param arg User data (unused). - * @return SSL_TLSEXT_ERR_OK on success, SSL_TLSEXT_ERR_ALERT_FATAL on no match. - */ -int h3_alpn_select_cb(SSL* ssl, const unsigned char** out, unsigned char* outlen, const unsigned char* in, unsigned int inlen, void* arg); - -/** - * TLS key log callback for the QUIC SSL_CTX. Mirrors mod_ssl: appends - * NSS-format key log lines to the file named by the SSLKEYLOGFILE - * environment variable so captured QUIC sessions can be decrypted in - * wireshark. Debugging aid only - the file holds the sessions' traffic - * secrets; only register it when the variable is set. - * @param ssl The SSL object the line belongs to (unused). - * @param line The NSS key log line to record. - */ -void h3_keylog_cb(const SSL* ssl, const char* line); - -#endif /* H3_SSL_H */ diff --git a/mod_http3/include/h3_stream.h b/mod_http3/include/h3_stream.h index d2ae224..2def3d2 100644 --- a/mod_http3/include/h3_stream.h +++ b/mod_http3/include/h3_stream.h @@ -23,7 +23,7 @@ #include -#include +#include "quic.h" #include "h3_session.h" @@ -37,12 +37,12 @@ void flush_nghttp3(h3_session* session); /** * Allocate and register a new h3_stream for the given stream id. - * @param session The session that owns the stream. - * @param sid The QUIC stream id (RFC 9000). - * @param stream_ssl The SSL stream object backing the new stream. + * @param session The session that owns the stream. + * @param sid The QUIC stream id (RFC 9000). + * @param qstream The QUIC stream object backing the new stream. * @return The new h3_stream, or NULL on allocation failure. */ -h3_stream* track_stream(h3_session* session, int64_t sid, SSL* stream_ssl); +h3_stream* track_stream(h3_session* session, int64_t sid, quic_stream* qstream); /** * Read whatever's available on the underlying SSL stream and drive the diff --git a/mod_http3/include/h3_version.h b/mod_http3/include/h3_version.h index 96f2bb4..17c23c1 100644 --- a/mod_http3/include/h3_version.h +++ b/mod_http3/include/h3_version.h @@ -22,13 +22,13 @@ #define MOD_HTTP3_VERSION_MAJOR 0 #define MOD_HTTP3_VERSION_MINOR 0 -#define MOD_HTTP3_VERSION_PATCH 41 +#define MOD_HTTP3_VERSION_PATCH 52 // Construct a 24-bit packed version number from major, minor and patch. Version 1.2.3 becomes 0x010203. #define MOD_HTTP3_MAKE_VERSION(major, minor, patch) (((major) << 16) | ((minor) << 8) | (patch)) #define MOD_HTTP3_VERSION MOD_HTTP3_MAKE_VERSION(MOD_HTTP3_VERSION_MAJOR, MOD_HTTP3_VERSION_MINOR, MOD_HTTP3_VERSION_PATCH) -#define MOD_HTTP3_VERSION_STRING "0.0.41" +#define MOD_HTTP3_VERSION_STRING "0.0.52" #endif /* H3_VERSION_H */ diff --git a/mod_http3/src/h3_callbacks.c b/mod_http3/src/h3_callbacks.c index f311209..d5c5129 100644 --- a/mod_http3/src/h3_callbacks.c +++ b/mod_http3/src/h3_callbacks.c @@ -32,7 +32,8 @@ #include #include -#include + +#include "quic.h" #include "h3.h" #include "h3_callbacks.h" @@ -139,6 +140,8 @@ int on_recv_data(nghttp3_conn* /*conn*/, int64_t stream_id, const uint8_t* data, { return 0; } + /* nghttp3 excludes DATA payload from its consumed count; credit it here. */ + quic_stream_consumed(stream->qstream, datalen); if (stream->request_body_overflow) { /* Discard over-budget bytes. */ @@ -182,7 +185,6 @@ int on_recv_data(nghttp3_conn* /*conn*/, int64_t stream_id, const uint8_t* data, int on_acked_stream_data(nghttp3_conn* conn, int64_t stream_id, uint64_t datalen, void* user_data, void* stream_user_data) { - /* OpenSSL QUIC exposes no ACK offsets; bytes accepted by SSL_write_ex count as acked. */ (void)conn; (void)stream_id; (void)user_data; @@ -190,19 +192,32 @@ int on_acked_stream_data(nghttp3_conn* conn, int64_t stream_id, uint64_t datalen return 0; } -int on_stop_sending(nghttp3_conn* /*conn*/, int64_t /*stream_id*/, uint64_t /*app_error_code*/, void* user_data, void* stream_user_data) +int on_deferred_consume(nghttp3_conn* conn, int64_t stream_id, size_t consumed, void* user_data, void* stream_user_data) +{ + (void)conn; + (void)stream_id; + (void)user_data; + h3_stream* stream = stream_user_data; + if (stream && stream->qstream) + { + quic_stream_consumed(stream->qstream, consumed); + } + return 0; +} + +int on_stop_sending(nghttp3_conn* /*conn*/, int64_t /*stream_id*/, uint64_t app_error_code, void* user_data, void* stream_user_data) { - /* Send STOP_SENDING by freeing SSL object. */ h3_session* session = user_data; CHECK(session); h3_stream* stream = stream_user_data; if (stream) { + quic_stream_stop_sending(stream->qstream, app_error_code); h3_stream_response_cancel_locked(stream); - if (stream->ssl_stream) + if (stream->qstream) { - h3_session_queue_free(session, stream->ssl_stream); - stream->ssl_stream = NULL; + h3_session_queue_free(session, stream->qstream); + stream->qstream = NULL; } stream->done = 1; stream->body_complete = 1; @@ -213,16 +228,11 @@ int on_stop_sending(nghttp3_conn* /*conn*/, int64_t /*stream_id*/, uint64_t /*ap int on_reset_stream(nghttp3_conn* /*conn*/, int64_t /*stream_id*/, uint64_t app_error_code, void* /*user_data*/, void* stream_user_data) { - /* Send RESET_STREAM to abandon response. */ h3_stream* stream = stream_user_data; if (stream) { h3_stream_response_cancel_locked(stream); - if (stream->ssl_stream) - { - SSL_STREAM_RESET_ARGS args = {app_error_code}; - SSL_stream_reset(stream->ssl_stream, &args, sizeof(args)); - } + quic_stream_reset(stream->qstream, app_error_code); stream->done = 1; } return 0; @@ -237,8 +247,11 @@ int on_stream_close(nghttp3_conn* /*conn*/, int64_t /* stream_id */, uint64_t /* { h3_stream_response_cancel_locked(stream); stream->done = 1; - h3_session_queue_free(session, stream->ssl_stream); - stream->ssl_stream = NULL; + if (stream->qstream) + { + h3_session_queue_free(session, stream->qstream); + stream->qstream = NULL; + } } return 0; } diff --git a/mod_http3/src/h3_config.c b/mod_http3/src/h3_config.c index fa6cdf4..b1f7777 100644 --- a/mod_http3/src/h3_config.c +++ b/mod_http3/src/h3_config.c @@ -35,6 +35,7 @@ #include "h3_check.h" #include "h3_config.h" #include "mod_http3.h" +#include "quic.h" apr_port_t get_server_port(const server_rec* s) { @@ -61,6 +62,7 @@ void* h3_merge_server_config(apr_pool_t* p, void* base_conf, void* new_conf) merged->h3_cert_path = new->h3_cert_path ? new->h3_cert_path : base->h3_cert_path; merged->h3_key_path = new->h3_key_path ? new->h3_key_path : base->h3_key_path; + merged->h3_quic_engine = new->h3_quic_engine ? new->h3_quic_engine : base->h3_quic_engine; merged->h3_port = new->h3_port ? new->h3_port : base->h3_port; merged->h3_max_concurrent_streams = new->h3_max_concurrent_streams ? new->h3_max_concurrent_streams : base->h3_max_concurrent_streams; merged->h3_max_connections = new->h3_max_connections ? new->h3_max_connections : base->h3_max_connections; @@ -68,6 +70,7 @@ void* h3_merge_server_config(apr_pool_t* p, void* base_conf, void* new_conf) merged->h3_max_request_body_size = new->h3_max_request_body_size ? new->h3_max_request_body_size : base->h3_max_request_body_size; merged->h3_max_response_body_size = new->h3_max_response_body_size ? new->h3_max_response_body_size : base->h3_max_response_body_size; merged->h3_alt_svc = new->h3_alt_svc != H3_FLAG_UNSET ? new->h3_alt_svc : base->h3_alt_svc; + merged->h3_address_validation = new->h3_address_validation != H3_FLAG_UNSET ? new->h3_address_validation : base->h3_address_validation; merged->h3_alt_svc_max_age = new->h3_alt_svc_max_age ? new->h3_alt_svc_max_age : base->h3_alt_svc_max_age; merged->h3_handshake_timeout = new->h3_handshake_timeout ? new->h3_handshake_timeout : base->h3_handshake_timeout; merged->h3_idle_timeout = new->h3_idle_timeout ? new->h3_idle_timeout : base->h3_idle_timeout; @@ -104,6 +107,39 @@ static const char* set_h3_key_path(cmd_parms* cmd, void* /*dummy*/, const char* return set_string(cmd, arg, (const char*)offsetof(h3_server_conf, h3_key_path)); } + +static const char* engine_list(apr_pool_t* pool) +{ + const char* list = quic_engine_name_at(0); + for (size_t i = 1; i < quic_engine_count(); i++) + { + list = apr_pstrcat(pool, list, ", ", quic_engine_name_at(i), NULL); + } + return list; +} + +/* Checked here rather than only at post_config, which httpd -t never reaches. */ +static int engine_known(const char* name) +{ + for (size_t i = 0; i < quic_engine_count(); i++) + { + if (apr_cstr_casecmp(name, quic_engine_name_at(i)) == 0) + { + return 1; + } + } + return 0; +} + +static const char* set_h3_quic_engine(cmd_parms* cmd, void* /*dummy*/, const char* arg) +{ + if (arg && *arg && !engine_known(arg)) + { + return apr_psprintf(cmd->pool, "H3QuicEngine %s: this build has no such engine (compiled: %s)", arg, engine_list(cmd->pool)); + } + return set_string(cmd, arg, (const char*)offsetof(h3_server_conf, h3_quic_engine)); +} + static const char* set_h3_port(cmd_parms* cmd, void* /*dummy*/, const char* arg) { if (!arg || !*arg) @@ -320,6 +356,14 @@ static const char* set_h3_alt_svc(cmd_parms* cmd, void* /*dummy*/, int flag) return NULL; } +static const char* set_h3_address_validation(cmd_parms* cmd, void* /*dummy*/, int flag) +{ + h3_server_conf* conf = ap_get_module_config(cmd->server->module_config, &http3_module); + CHECK(conf); + conf->h3_address_validation = flag ? H3_FLAG_ON : H3_FLAG_OFF; + return NULL; +} + static const char* set_h3_alt_svc_max_age(cmd_parms* cmd, void* /*dummy*/, const char* arg) { if (!arg || !*arg) @@ -391,6 +435,10 @@ int h3_post_config(apr_pool_t* /*p*/, apr_pool_t* /*plog*/, apr_pool_t* ptemp, s { vc->h3_alt_svc = H3_FLAG_ON; } + if (vc->h3_address_validation == H3_FLAG_UNSET) + { + vc->h3_address_validation = H3_FLAG_ON; + } if (vc->h3_alt_svc_max_age == 0) { vc->h3_alt_svc_max_age = H3_ALT_SVC_MAX_AGE_DEFAULT; @@ -410,6 +458,12 @@ int h3_post_config(apr_pool_t* /*p*/, apr_pool_t* /*plog*/, apr_pool_t* ptemp, s CHECK(conf && conf->h3_cert_path && conf->h3_key_path, return HTTP_INTERNAL_SERVER_ERROR;); + if (conf->h3_quic_engine && !quic_select(conf->h3_quic_engine)) + { + ap_log_error(APLOG_MARK, APLOG_ERR, 0, s, "mod_http3: H3QuicEngine %s: this build has no such engine (compiled: %s)", conf->h3_quic_engine, engine_list(ptemp)); + return HTTP_INTERNAL_SERVER_ERROR; + } + /* Validate cert and key files are readable */ apr_file_t* f = NULL; if (apr_file_open(&f, conf->h3_cert_path, APR_READ, APR_OS_DEFAULT, ptemp) != APR_SUCCESS) @@ -456,6 +510,9 @@ const command_rec cmd_9 = AP_INIT_TAKE1("H3AltSvcMaxAge", set_h3_alt_svc_max_age const command_rec cmd_10 = AP_INIT_TAKE1("H3HandshakeTimeout", set_h3_handshake_timeout, NULL, RSRC_CONF, "Timeout in seconds for QUIC handshakes to complete (default: 10)"); const command_rec cmd_11 = AP_INIT_TAKE1("H3IdleTimeout", set_h3_idle_timeout, NULL, RSRC_CONF, "Idle timeout in seconds for QUIC connections (default: 300)"); const command_rec cmd_12 = AP_INIT_TAKE1("H3MaxResponseBodySize", set_h3_max_response_body_size, NULL, RSRC_CONF, "Maximum HTTP/3 response body size in bytes; an explicit limit enables bounded whole-response buffering (default: unlimited streaming)"); +const command_rec cmd_13 = AP_INIT_FLAG("H3AddressValidation", set_h3_address_validation, NULL, RSRC_CONF, "Whether to validate client addresses with a QUIC Retry packet before accepting a connection (default: on)"); + +const command_rec cmd_14 = AP_INIT_TAKE1("H3QuicEngine", set_h3_quic_engine, NULL, RSRC_CONF, "QUIC engine to run, among those compiled in (default: openssl)"); const command_rec cmd_end = AP_INIT_TAKE1(NULL, NULL, NULL, RSRC_CONF, NULL); -const command_rec h3_cmds[] = {cmd_1, cmd_2, cmd_3, cmd_4, cmd_5, cmd_6, cmd_7, cmd_8, cmd_9, cmd_10, cmd_11, cmd_12, cmd_end}; +const command_rec h3_cmds[] = {cmd_1, cmd_2, cmd_3, cmd_4, cmd_5, cmd_6, cmd_7, cmd_8, cmd_9, cmd_10, cmd_11, cmd_12, cmd_13, cmd_14, cmd_end}; diff --git a/mod_http3/src/h3_filter.c b/mod_http3/src/h3_filter.c index 066c62c..a3d0817 100644 --- a/mod_http3/src/h3_filter.c +++ b/mod_http3/src/h3_filter.c @@ -250,6 +250,7 @@ apr_status_t h3_filter_out_proto(ap_filter_t* f, apr_bucket_brigade* bb) apr_brigade_cleanup(bb); return rv; } + next = APR_BUCKET_NEXT(b); } else if (ctx->streaming && (APR_BUCKET_IS_EOS(b) || APR_BUCKET_IS_FLUSH(b))) { diff --git a/mod_http3/src/h3_hooks.c b/mod_http3/src/h3_hooks.c index f2e7460..c6bc5ac 100644 --- a/mod_http3/src/h3_hooks.c +++ b/mod_http3/src/h3_hooks.c @@ -40,6 +40,7 @@ #include "h3_io.h" #include "h3_session.h" #include "mod_http3.h" +#include "quic.h" const char* h3_hook_http_scheme(const request_rec* r) { @@ -172,13 +173,14 @@ int h3_status_handler(request_rec* r) ap_rprintf(r, "{\n" + " \"quic_backend\": \"%s\",\n" " \"live_workers\": %u,\n" " \"total_connections\": %u,\n" " \"total_streams\": %u,\n" " \"total_bytes_read\": %" APR_UINT64_T_FMT ",\n" " \"total_bytes_written\": %" APR_UINT64_T_FMT "\n" "}\n", - live, conns, streams, bytes_in, bytes_out); + quic_engine_name(), live, conns, streams, bytes_in, bytes_out); return OK; } diff --git a/mod_http3/src/h3_io.c b/mod_http3/src/h3_io.c index 3d84c95..5e8ea9f 100644 --- a/mod_http3/src/h3_io.c +++ b/mod_http3/src/h3_io.c @@ -24,13 +24,11 @@ #include #include #include -#include #include +#include #include -#include -#include - +#include #include #include @@ -44,250 +42,14 @@ #include "h3_request.h" #include "h3_session.h" #include "h3_socket.h" -#include "h3_ssl.h" #include "h3_stream.h" #include "h3_threads.h" #include "h3_version.h" #include "mod_http3.h" +#include "quic.h" h3_io_t* child_h3_io = NULL; -/* OpenSSL 3.5 hides an accepted connection's peer address; recover it from the datagram BIO. */ -struct h3_peer_datagram -{ - unsigned char* data; - size_t data_len; - BIO_ADDR* peer; - BIO_ADDR* local; - h3_peer_datagram* next; -}; - -static void h3_peer_addr_queue_clear(h3_io_t* io) -{ - h3_peer_datagram* item = io->peer_rx_head; - while (item) - { - h3_peer_datagram* next = item->next; - OPENSSL_free(item->data); - BIO_ADDR_free(item->peer); - BIO_ADDR_free(item->local); - OPENSSL_free(item); - item = next; - } - io->peer_rx_head = NULL; - io->peer_rx_tail = NULL; -} - -static int h3_peer_addr_queue_fill(h3_io_t* io, BIO_MSG* msg, size_t stride, size_t count) -{ - for (size_t i = 0; i < count; i++) - { - BIO_MSG* source = (BIO_MSG*)((unsigned char*)msg + i * stride); - h3_peer_datagram* item = OPENSSL_zalloc(sizeof(*item)); - if (!item || !source->data || source->data_len == 0) - { - OPENSSL_free(item); - h3_peer_addr_queue_clear(io); - return 0; - } - item->data = OPENSSL_memdup(source->data, source->data_len); - item->data_len = source->data_len; - item->peer = source->peer ? BIO_ADDR_dup(source->peer) : NULL; - item->local = source->local ? BIO_ADDR_dup(source->local) : NULL; - if (!item->data || (source->peer && !item->peer) || (source->local && !item->local)) - { - OPENSSL_free(item->data); - BIO_ADDR_free(item->peer); - BIO_ADDR_free(item->local); - OPENSSL_free(item); - h3_peer_addr_queue_clear(io); - return 0; - } - if (io->peer_rx_tail) - { - io->peer_rx_tail->next = item; - } - else - { - io->peer_rx_head = item; - } - io->peer_rx_tail = item; - } - return 1; -} - -static int h3_peer_addr_queue_pop(h3_io_t* io, BIO_MSG* msg) -{ - h3_peer_datagram* item = io->peer_rx_head; - if (!item || !msg || !msg->data || msg->data_len < item->data_len) - { - return 0; - } - memcpy(msg->data, item->data, item->data_len); - msg->data_len = item->data_len; - if (msg->peer && item->peer) - { - BIO_ADDR_copy(msg->peer, item->peer); - } - if (msg->local && item->local) - { - BIO_ADDR_copy(msg->local, item->local); - } - io->peer_rx_head = item->next; - if (!io->peer_rx_head) - { - io->peer_rx_tail = NULL; - } - OPENSSL_free(item->data); - BIO_ADDR_free(item->peer); - BIO_ADDR_free(item->local); - OPENSSL_free(item); - return 1; -} - -int h3_io_has_buffered_datagrams(h3_io_t* io) -{ - return io && io->peer_rx_head != NULL; -} - -static long h3_peer_addr_bio_ctrl(BIO* bio, int cmd, long num, void* ptr) -{ - BIO* next = BIO_next(bio); - return next ? BIO_ctrl(next, cmd, num, ptr) : 0; -} - -static int h3_peer_addr_bio_sendmmsg(BIO* bio, BIO_MSG* msg, size_t stride, size_t num_msg, uint64_t flags, size_t* msgs_processed) -{ - BIO* next = BIO_next(bio); - return next ? BIO_sendmmsg(next, msg, stride, num_msg, flags, msgs_processed) : 0; -} - -static int h3_peer_addr_bio_recvmmsg(BIO* bio, BIO_MSG* msg, size_t stride, size_t num_msg, uint64_t flags, size_t* msgs_processed) -{ - h3_io_t* io = BIO_get_data(bio); - BIO* next = BIO_next(bio); - if (!io || !next || !msg || !msgs_processed || num_msg == 0) - { - return 0; - } - - BIO_ADDR_clear(io->current_peer_addr); - if (io->peer_rx_head) - { - *msgs_processed = 0; - if (!h3_peer_addr_queue_pop(io, msg)) - { - return 0; - } - *msgs_processed = 1; - if (msg->peer) - { - BIO_ADDR_copy(io->current_peer_addr, msg->peer); - } - return 1; - } - - size_t received = 0; - int rv = BIO_recvmmsg(next, msg, stride, num_msg, flags, &received); - if (rv && received > 0) - { - if (!h3_peer_addr_queue_fill(io, msg, stride, received) - || !h3_peer_addr_queue_pop(io, msg)) - { - *msgs_processed = 0; - return 0; - } - *msgs_processed = 1; - if (msg->peer) - { - BIO_ADDR_copy(io->current_peer_addr, msg->peer); - } - } - else - { - *msgs_processed = received; - } - return rv; -} - -static int h3_peer_addr_bio_destroy(BIO* bio) -{ - h3_io_t* io = BIO_get_data(bio); - if (io) - { - h3_peer_addr_queue_clear(io); - } - return 1; -} - -static void h3_peer_addr_ex_free(void* /*parent*/, void* ptr, CRYPTO_EX_DATA* /*ad*/, int /*idx*/, long /*argl*/, void* /*argp*/) -{ - BIO_ADDR_free(ptr); -} - -static int h3_new_pending_conn_cb(SSL_CTX* /*ctx*/, SSL* conn, void* arg) -{ - h3_io_t* io = arg; - if (!io || io->peer_addr_ex_index < 0 || BIO_ADDR_family(io->current_peer_addr) == AF_UNSPEC) - { - return 1; - } - - BIO_ADDR* peer = BIO_ADDR_dup(io->current_peer_addr); - if (!peer || !SSL_set_ex_data(conn, io->peer_addr_ex_index, peer)) - { - BIO_ADDR_free(peer); - return 0; - } - return 1; -} - -apr_status_t h3_io_get_client_addr(h3_io_t* io, SSL* conn, apr_pool_t* pool, apr_sockaddr_t** addr, char** client_ip) -{ - CHECK(io); - CHECK(conn); - CHECK(pool); - CHECK(addr); - CHECK(client_ip); - if (io->peer_addr_ex_index < 0) - { - return APR_EGENERAL; - } - - const BIO_ADDR* peer = SSL_get_ex_data(conn, io->peer_addr_ex_index); - if (!peer || BIO_ADDR_family(peer) == AF_UNSPEC) - { - return APR_NOTFOUND; - } - - char* host = BIO_ADDR_hostname_string(peer, 1); - char* service = BIO_ADDR_service_string(peer, 1); - if (!host || !service) - { - OPENSSL_free(host); - OPENSSL_free(service); - return APR_ENOMEM; - } - - char* end = NULL; - unsigned long port = strtoul(service, &end, 10); - if (service[0] == '\0' || !end || end[0] != '\0' || port > 65535) - { - OPENSSL_free(host); - OPENSSL_free(service); - return APR_EINVAL; - } - - apr_status_t rv = apr_sockaddr_info_get(addr, host, APR_UNSPEC, (apr_port_t)port, 0, pool); - if (rv == APR_SUCCESS) - { - rv = apr_sockaddr_ip_get(client_ip, *addr); - } - OPENSSL_free(host); - OPENSSL_free(service); - return rv; -} - int h3_io_at_connection_limit(h3_io_t* io) { h3_server_conf* conf = ap_get_module_config(io->server->module_config, &http3_module); @@ -295,60 +57,18 @@ int h3_io_at_connection_limit(h3_io_t* io) return active >= conf->h3_max_connections; } -/* h3_keylog_cb lives in h3_ssl.c on trunk; the upstream chain defines it inline here. */ - -static apr_status_t build_ssl_listener(h3_io_t* io, const char* cert, const char* key) -{ - CHECK(io); - CHECK(cert); - CHECK(key); - io->ssl_ctx = SSL_CTX_new(OSSL_QUIC_server_method()); - if (!io->ssl_ctx || SSL_CTX_use_certificate_chain_file(io->ssl_ctx, cert) <= 0 || SSL_CTX_use_PrivateKey_file(io->ssl_ctx, key, SSL_FILETYPE_PEM) <= 0) - { - return APR_EGENERAL; - } - io->current_peer_addr = BIO_ADDR_new(); - io->peer_addr_ex_index = SSL_get_ex_new_index(0, NULL, NULL, NULL, h3_peer_addr_ex_free); - io->peer_addr_bio_method = BIO_meth_new(BIO_get_new_index() | BIO_TYPE_FILTER, "mod_http3 QUIC peer address filter"); - if (!io->current_peer_addr || io->peer_addr_ex_index < 0 || !io->peer_addr_bio_method - || !BIO_meth_set_ctrl(io->peer_addr_bio_method, h3_peer_addr_bio_ctrl) - || !BIO_meth_set_sendmmsg(io->peer_addr_bio_method, h3_peer_addr_bio_sendmmsg) - || !BIO_meth_set_recvmmsg(io->peer_addr_bio_method, h3_peer_addr_bio_recvmmsg) - || !BIO_meth_set_destroy(io->peer_addr_bio_method, h3_peer_addr_bio_destroy)) - { - return APR_EGENERAL; - } - SSL_CTX_set_alpn_select_cb(io->ssl_ctx, h3_alpn_select_cb, io->server); - SSL_CTX_set_new_pending_conn_cb(io->ssl_ctx, h3_new_pending_conn_cb, io); - if (getenv("SSLKEYLOGFILE")) - { - SSL_CTX_set_keylog_callback(io->ssl_ctx, h3_keylog_cb); - } - io->ssl_listener = SSL_new_listener(io->ssl_ctx, 0); - BIO* dgram_bio = BIO_new_dgram(io->udp_fd, BIO_NOCLOSE); - BIO* peer_addr_bio = BIO_new(io->peer_addr_bio_method); - if (!io->ssl_listener || !dgram_bio || !peer_addr_bio) - { - BIO_free(dgram_bio); - BIO_free(peer_addr_bio); - return APR_EGENERAL; - } - BIO_set_data(peer_addr_bio, io); - BIO_push(peer_addr_bio, dgram_bio); - SSL_set_bio(io->ssl_listener, peer_addr_bio, peer_addr_bio); - if (!SSL_listen(io->ssl_listener) || !SSL_set_blocking_mode(io->ssl_listener, 0)) - { - return APR_EGENERAL; - } - return APR_SUCCESS; -} - static void teardown(h3_io_t* io) { CHECK(io); if (io->event_thread) { io->thread_running = 0; + if (io->wakeup_pipe[1]) + { + char wake = '1'; + apr_size_t len = 1; + (void)apr_file_write(io->wakeup_pipe[1], &wake, &len); + } apr_status_t status; apr_thread_join(&status, io->event_thread); io->event_thread = NULL; @@ -360,7 +80,6 @@ static void teardown(h3_io_t* io) } if (io->active_sessions) { - /* Wait for event_thread to shut down and remove all active sessions. */ apr_time_t next_warning = apr_time_now() + apr_time_from_sec(5); while (io->active_sessions->nelts > 0) { @@ -372,20 +91,11 @@ static void teardown(h3_io_t* io) apr_sleep(50 * 1000); } } - if (io->ssl_listener) + if (io->qengine) { - SSL_free(io->ssl_listener); - io->ssl_listener = NULL; + quic_engine_destroy(io->qengine); + io->qengine = NULL; } - if (io->ssl_ctx) - { - SSL_CTX_free(io->ssl_ctx); - io->ssl_ctx = NULL; - } - BIO_ADDR_free(io->current_peer_addr); - io->current_peer_addr = NULL; - BIO_meth_free(io->peer_addr_bio_method); - io->peer_addr_bio_method = NULL; if (io->udp_fd >= 0) { h3_socket_close(io->udp_fd); @@ -409,7 +119,6 @@ apr_status_t h3_io_listen_start(apr_pool_t* pchild, server_rec* s, h3_server_con io->pool = pchild; io->server = s; io->udp_fd = udp_fd; - io->peer_addr_ex_index = -1; io->active_sessions = apr_array_make(pchild, 8, sizeof(h3_session*)); io->pending_handshakes = apr_array_make(pchild, 4, sizeof(h3_pending_handshake)); if (apr_file_pipe_create_ex(&io->wakeup_pipe[0], &io->wakeup_pipe[1], APR_FULL_NONBLOCK, pchild) != APR_SUCCESS) @@ -422,9 +131,21 @@ apr_status_t h3_io_listen_start(apr_pool_t* pchild, server_rec* s, h3_server_con ap_log_error(APLOG_MARK, APLOG_ERR, 0, s, "apr_thread_pool_create failed"); return APR_EGENERAL; } - if (build_ssl_listener(io, conf->h3_cert_path, conf->h3_key_path) != APR_SUCCESS) - { - ap_log_error(APLOG_MARK, APLOG_ERR, 0, s, "listener setup failed"); + + char qerr[QUIC_ERRLEN] = {0}; + quic_config qcfg = { + .cred = {.kind = QUIC_CRED_FILE, .as.file = {.cert_path = conf->h3_cert_path, .key_path = conf->h3_key_path}}, + .callbacks = {.stream_acked = h3_session_on_stream_acked}, + .io = &io->qio, + }; + quic_settings_default(&qcfg.settings); + qcfg.settings.max_idle_timeout_ms = (uint64_t)conf->h3_idle_timeout * 1000; + qcfg.settings.address_validation = (conf->h3_address_validation != H3_FLAG_OFF); + quic_io_udp_init(&io->qio, udp_fd); + io->qengine = quic_engine_create(&qcfg, qerr, sizeof(qerr)); + if (!io->qengine) + { + ap_log_error(APLOG_MARK, APLOG_ERR, 0, s, "QUIC engine initialization failed: %s", qerr); teardown(io); return APR_EGENERAL; } @@ -466,18 +187,14 @@ void h3_io_listen_stop(h3_io_t* io) void wait_for_event(h3_io_t* io) { - /* poll(), not select(): descriptors can exceed FD_SETSIZE. */ - int timeout_ms = 1000; - struct timeval tv = {0}; - int inf = 0; - if (SSL_get_event_timeout(io->ssl_listener, &tv, &inf) && !inf && (tv.tv_sec > 0 || tv.tv_usec > 0) && tv.tv_sec <= 1) + if (!io) { - timeout_ms = (int)(tv.tv_sec * 1000 + tv.tv_usec / 1000); - if (timeout_ms <= 0) - { - timeout_ms = 1; - } + return; } + int want_read = 0; + int want_write = 0; + int timeout_ms = 1000; + quic_engine_want(io->qengine, &want_read, &want_write, &timeout_ms); struct pollfd pfds[2] = {{.fd = io->udp_fd, .events = 0}, {.fd = -1, .events = POLLIN}}; nfds_t npfds = 1; @@ -489,22 +206,19 @@ void wait_for_event(h3_io_t* io) npfds = 2; } - if (SSL_net_read_desired(io->ssl_listener)) + if (want_read) { pfds[0].events |= POLLIN; } - if (SSL_net_write_desired(io->ssl_listener)) + if (want_write) { pfds[0].events |= POLLOUT; } - if (!pfds[0].events && npfds == 1) - { - pfds[0].events = POLLIN; - } if (!pfds[0].events) { - pfds[0].events = POLLIN; /* force POLLIN to avoid missing UDP packets! */ + pfds[0].events = POLLIN; } + if (poll(pfds, npfds, timeout_ms) < 0 && errno == EINTR) { return; @@ -514,22 +228,16 @@ void wait_for_event(h3_io_t* io) { char buf[64]; apr_size_t len = sizeof(buf); - apr_file_read(io->wakeup_pipe[0], buf, &len); + (void)apr_file_read(io->wakeup_pipe[0], buf, &len); } } -int tick_engine(SSL* conn) -{ - CHECK(conn); - return SSL_handle_events(conn) == 1; -} - void remove_pending_handshake(h3_io_t* io, int index, int free_conn) { h3_pending_handshake* pending = (h3_pending_handshake*)io->pending_handshakes->elts; if (free_conn) { - SSL_free(pending[index].conn); + quic_conn_free(pending[index].conn); } if (index < io->pending_handshakes->nelts - 1) { @@ -538,7 +246,7 @@ void remove_pending_handshake(h3_io_t* io, int index, int free_conn) io->pending_handshakes->nelts--; } -static apr_status_t spawn_serviced_session(h3_io_t* io, SSL* conn) +static apr_status_t spawn_serviced_session(h3_io_t* io, quic_conn* conn) { apr_allocator_t* allocator = NULL; apr_pool_t* session_pool = NULL; @@ -549,26 +257,26 @@ static apr_status_t spawn_serviced_session(h3_io_t* io, SSL* conn) { apr_allocator_destroy(allocator); } - SSL_free(conn); + quic_conn_free(conn); return APR_EGENERAL; } apr_allocator_owner_set(allocator, session_pool); apr_pool_tag(session_pool, "h3_session"); h3_session* session = NULL; - if (h3_session_create(&session, io->server, io->ssl_listener, conn, session_pool) != APR_SUCCESS) + if (h3_session_create(&session, io->server, conn, session_pool) != APR_SUCCESS) { - /* Ownership of conn stays here until a session holds it. */ - SSL_free(conn); + quic_conn_free(conn); apr_pool_destroy(session_pool); return APR_EGENERAL; } + quic_conn_set_user(conn, session); if (h3_session_create_control_streams(session) != APR_SUCCESS) { h3_session_destroy(session); return APR_EGENERAL; } - if (SSL_get_shutdown(conn)) + if (quic_conn_is_closed(conn)) { h3_session_destroy(session); return APR_EGENERAL; @@ -600,7 +308,7 @@ void progress_pending_handshakes(h3_io_t* io) for (int i = 0; i < io->pending_handshakes->nelts;) { h3_pending_handshake* pending = &((h3_pending_handshake*)io->pending_handshakes->elts)[i]; - SSL* conn = pending->conn; + quic_conn* conn = pending->conn; if (now - pending->accepted_at >= timeout) { @@ -610,59 +318,32 @@ void progress_pending_handshakes(h3_io_t* io) } int finished = 0; - do - { - int rv = 0; - const char* why = NULL; + int rv = 0; + const char* why = NULL; - if (!tick_engine(io->ssl_listener)) - { - rv = -1; - why = "listener event processing failed"; - } - else if (SSL_get_shutdown(conn)) - { - rv = -1; - why = "peer closed the connection during the handshake"; - } - else if (SSL_is_init_finished(conn)) - { - rv = 1; - } + if (quic_conn_is_closed(conn)) + { + rv = -1; + why = "peer closed the connection during the handshake"; + } + else if (quic_conn_is_handshake_done(conn)) + { + rv = 1; + } - if (rv == 1) - { - ap_log_error(APLOG_MARK, APLOG_INFO, 0, io->server, "QUIC handshake complete"); - spawn_serviced_session(io, conn); - remove_pending_handshake(io, i, 0); - finished = 1; - break; - } - if (rv == -1) - { - char errbuf[256] = {0}; - ERR_error_string_n(ERR_peek_last_error(), errbuf, sizeof(errbuf)); - SSL_CONN_CLOSE_INFO cci; - memset(&cci, 0, sizeof(cci)); - if (SSL_get_conn_close_info(conn, &cci, sizeof(cci))) - { - const char* origin = (cci.flags & SSL_CONN_CLOSE_FLAG_LOCAL) ? "local" : "remote"; - const char* layer = (cci.flags & SSL_CONN_CLOSE_FLAG_TRANSPORT) ? "transport" : "app"; - const char* reason = cci.reason ? cci.reason : ""; - char detail[320]; - - snprintf(detail, sizeof(detail), "%s %s err=0x%llx frame=0x%llx reason=\"%.*s\"", origin, layer, (unsigned long long)cci.error_code, (unsigned long long)cci.frame_type, (int)cci.reason_len, reason); - ap_log_error(APLOG_MARK, APLOG_ERR, 0, io->server, "QUIC handshake did not complete: %s (%s) close=[%s]", why, errbuf, detail); - } - else - { - ap_log_error(APLOG_MARK, APLOG_ERR, 0, io->server, "QUIC handshake did not complete: %s (%s)", why, errbuf); - } - remove_pending_handshake(io, i, 1); - finished = 1; - break; - } - } while (SSL_net_read_desired(io->ssl_listener) || SSL_net_write_desired(io->ssl_listener)); + if (rv == 1) + { + ap_log_error(APLOG_MARK, APLOG_INFO, 0, io->server, "QUIC handshake complete"); + spawn_serviced_session(io, conn); + remove_pending_handshake(io, i, 0); + finished = 1; + } + else if (rv == -1) + { + ap_log_error(APLOG_MARK, APLOG_ERR, 0, io->server, "QUIC handshake did not complete: %s", why); + remove_pending_handshake(io, i, 1); + finished = 1; + } if (!finished) { @@ -671,17 +352,14 @@ void progress_pending_handshakes(h3_io_t* io) } } -int prepare_accepted_connection(h3_io_t* io, SSL* conn) +int prepare_accepted_connection(h3_io_t* io, quic_conn* conn) { - if (!SSL_set_blocking_mode(conn, 0)) + h3_server_conf* conf = ap_get_module_config(io->server->module_config, &http3_module); + if (!quic_conn_prepare(conn, conf->h3_idle_timeout)) { - ap_log_error(APLOG_MARK, APLOG_ERR, 0, io->server, "SSL_set_blocking_mode failed for accepted connection - dropping it"); + ap_log_error(APLOG_MARK, APLOG_ERR, 0, io->server, "quic_conn_prepare failed for accepted connection - dropping it"); return 0; } - SSL_set_default_stream_mode(conn, SSL_DEFAULT_STREAM_MODE_NONE); - SSL_set_incoming_stream_policy(conn, SSL_INCOMING_STREAM_POLICY_ACCEPT, 0); - h3_server_conf* conf = ap_get_module_config(io->server->module_config, &http3_module); - SSL_set_generic_value_uint(conn, SSL_VALUE_QUIC_IDLE_TIMEOUT, conf->h3_idle_timeout * 1000); h3_pending_handshake* pending = (h3_pending_handshake*)apr_array_push(io->pending_handshakes); pending->conn = conn; @@ -695,7 +373,7 @@ int service_session_pass(h3_io_t* io, h3_session* session) CHECK(io); CHECK(session); server_rec* s = session->s; - SSL* conn = session->ssl_conn; + quic_conn* conn = session->qconn; if (session->aborted) { @@ -704,7 +382,6 @@ int service_session_pass(h3_io_t* io, h3_session* session) if (!io->thread_running) { - /* Tell the client to stop opening new streams but finish in-flight ones */ apr_thread_mutex_lock(session->lock); if (!session->goaway_deadline) { @@ -729,7 +406,7 @@ int service_session_pass(h3_io_t* io, h3_session* session) } } - if (SSL_get_shutdown(conn)) + if (quic_conn_is_closed(conn)) { ap_log_error(APLOG_MARK, APLOG_INFO, 0, s, "QUIC connection terminated (idle timeout, peer close, or transport error)"); session->aborted = 1; @@ -742,13 +419,13 @@ int service_session_pass(h3_io_t* io, h3_session* session) return 0; } - for (SSL* s2 = NULL; (s2 = SSL_accept_stream(conn, SSL_ACCEPT_STREAM_NO_BLOCK)) != NULL;) + for (quic_stream* s2 = NULL; (s2 = quic_conn_accept_stream(conn)) != NULL;) { apr_atomic_inc32(&io->total_streams); - int64_t sid = (int64_t)SSL_get_stream_id(s2); + int64_t sid = quic_stream_id(s2); if (sid < 0) { - SSL_free(s2); + quic_stream_free(s2); continue; } apr_thread_mutex_lock(session->lock); @@ -756,7 +433,7 @@ int service_session_pass(h3_io_t* io, h3_session* session) if (!tracked) { ap_log_error(APLOG_MARK, APLOG_ERR, 0, s, "track_stream failed for sid=%lld - freeing stream", (long long)sid); - SSL_free(s2); + quic_stream_free(s2); } apr_thread_mutex_unlock(session->lock); } diff --git a/mod_http3/src/h3_request.c b/mod_http3/src/h3_request.c index fce5105..27e2694 100644 --- a/mod_http3/src/h3_request.c +++ b/mod_http3/src/h3_request.c @@ -30,6 +30,7 @@ #include #include +#include #include #include @@ -44,7 +45,11 @@ static volatile apr_uint32_t h3_conn_id_seq = 0; -/// modules/loggers/mod_logio.c:52 +/** + * Per-connection byte counters, laid out to match mod_logio's private + * config struct (modules/loggers/mod_logio.c:52) so that %I/%O log format + * directives resolve correctly against synthesized H3 connections. + */ typedef struct { apr_off_t bytes_in; @@ -52,6 +57,30 @@ typedef struct apr_off_t bytes_last_request; } h3_logio_config_t; + +/* The engine reports a raw sockaddr, so the conversion to APR belongs here. */ +static int peer_addr_resolve(quic_engine* engine, quic_conn* qconn, apr_pool_t* pool, apr_sockaddr_t** addr, char** client_ip) +{ + struct sockaddr_storage peer = {0}; + socklen_t peer_len = 0; + if (!quic_engine_peer_addr(engine, qconn, &peer, &peer_len)) + { + return 0; + } + + char host[NI_MAXHOST] = {0}; + char serv[NI_MAXSERV] = {0}; + if (getnameinfo((const struct sockaddr*)&peer, peer_len, host, sizeof(host), serv, sizeof(serv), NI_NUMERICHOST | NI_NUMERICSERV) != 0) + { + return 0; + } + if (apr_sockaddr_info_get(addr, host, APR_UNSPEC, (apr_port_t)atoi(serv), 0, pool) != APR_SUCCESS) + { + return 0; + } + return apr_sockaddr_ip_get(client_ip, *addr) == APR_SUCCESS; +} + conn_rec* h3_synth_conn(h3_session* session) { CHECK(session); @@ -77,7 +106,7 @@ conn_rec* h3_synth_conn(h3_session* session) apr_port_t vhost_port = (conf && conf->host_port) ? conf->host_port : (conf ? conf->h3_port : 0); apr_sockaddr_info_get(&c->client_addr, c->client_ip, APR_INET, 0, 0, cpool); apr_sockaddr_info_get(&c->local_addr, c->local_ip, APR_INET, vhost_port, 0, cpool); - if (child_h3_io && h3_io_get_client_addr(child_h3_io, session->ssl_conn, cpool, &c->client_addr, &c->client_ip) == APR_SUCCESS) + if (child_h3_io && peer_addr_resolve(child_h3_io->qengine, session->qconn, cpool, &c->client_addr, &c->client_ip)) { c->remote_host = NULL; } @@ -239,6 +268,7 @@ static void* APR_THREAD_FUNC stream_worker(apr_thread_t* thd, void* data) r->proto_num = HTTP_VERSION(3, 0); r->method = apr_pstrdup(r->pool, h3s->method ? h3s->method : "GET"); r->method_number = ap_method_number_of(r->method); + r->header_only = (r->method_number == M_GET && r->method[0] == 'H'); h3s->r = r; if (h3s->path) diff --git a/mod_http3/src/h3_session.c b/mod_http3/src/h3_session.c index 34e4ac1..8ce302a 100644 --- a/mod_http3/src/h3_session.c +++ b/mod_http3/src/h3_session.c @@ -29,11 +29,10 @@ #include #include -#include -#include - #include +#include "quic.h" + #include "h3.h" #include "h3_callbacks.h" #include "h3_check.h" @@ -61,25 +60,7 @@ static void wake_event_thread(void) } } -static SSL* open_uni_stream(SSL* ssl_conn, int64_t* out_id, server_rec* s, const char* label) -{ - CHECK(ssl_conn); - CHECK(out_id); - CHECK(s); - CHECK(label); - SSL* stream = SSL_new_stream(ssl_conn, SSL_STREAM_FLAG_UNI); - if (!stream) - { - char buf[256] = {0}; - ERR_error_string_n(ERR_get_error(), buf, sizeof(buf)); - ap_log_error(APLOG_MARK, APLOG_ERR, 0, s, "SSL_new_stream(%s) failed: %s", label, buf); - return NULL; - } - *out_id = (int64_t)SSL_get_stream_id(stream); - return stream; -} - -apr_status_t h3_session_create(h3_session** psession, server_rec* s, SSL* ssl_listener, SSL* ssl_conn, apr_pool_t* pool) +apr_status_t h3_session_create(h3_session** psession, server_rec* s, quic_conn* qconn, apr_pool_t* pool) { CHECK(psession); CHECK(s); @@ -87,10 +68,9 @@ apr_status_t h3_session_create(h3_session** psession, server_rec* s, SSL* ssl_li h3_session* session = apr_pcalloc(pool, sizeof(*session)); session->s = s; session->pool = pool; - session->ssl_listener = ssl_listener; - session->ssl_conn = ssl_conn; + session->qconn = qconn; session->streams = apr_hash_make(pool); - session->pending_free = apr_array_make(pool, 8, sizeof(SSL*)); + session->pending_free = apr_array_make(pool, 8, sizeof(quic_stream*)); apr_status_t rv = apr_thread_mutex_create(&session->lock, APR_THREAD_MUTEX_DEFAULT, pool); if (rv != APR_SUCCESS) @@ -99,7 +79,15 @@ apr_status_t h3_session_create(h3_session** psession, server_rec* s, SSL* ssl_li return rv; } - nghttp3_callbacks cb = {.acked_stream_data = on_acked_stream_data, .recv_header = on_recv_header, .end_headers = on_end_headers, .recv_data = on_recv_data, .stream_close = on_stream_close, .begin_headers = on_begin_headers, .stop_sending = on_stop_sending, .reset_stream = on_reset_stream}; + nghttp3_callbacks cb = {.acked_stream_data = on_acked_stream_data, + .deferred_consume = on_deferred_consume, + .recv_header = on_recv_header, + .end_headers = on_end_headers, + .recv_data = on_recv_data, + .stream_close = on_stream_close, + .begin_headers = on_begin_headers, + .stop_sending = on_stop_sending, + .reset_stream = on_reset_stream}; nghttp3_settings settings = {0}; nghttp3_settings_default(&settings); if (nghttp3_conn_server_new(&session->ngh3, &cb, &settings, nghttp3_mem_default(), session) != 0) @@ -116,6 +104,18 @@ apr_status_t h3_session_create(h3_session** psession, server_rec* s, SSL* ssl_li return APR_SUCCESS; } +void h3_session_on_stream_acked(void* user, int64_t stream_id, uint64_t datalen) +{ + h3_session* session = user; + if (!session || session->ngh3_dead || !session->ngh3) + { + return; + } + apr_thread_mutex_lock(session->lock); + nghttp3_conn_add_ack_offset(session->ngh3, stream_id, datalen); + apr_thread_mutex_unlock(session->lock); +} + apr_status_t h3_session_create_control_streams(h3_session* session) { CHECK(session); @@ -124,13 +124,13 @@ apr_status_t h3_session_create_control_streams(h3_session* session) return APR_SUCCESS; } server_rec* s = session->s; - SSL* ssl_conn = session->ssl_conn; + quic_conn* qconn = session->qconn; struct { const char* name; int64_t id; - SSL* ssl; + quic_stream* st; } cs[] = { {"control", 0, NULL}, {"qpack_enc", 0, NULL}, @@ -138,17 +138,21 @@ apr_status_t h3_session_create_control_streams(h3_session* session) }; for (int i = 0; i < 3; i++) { - cs[i].ssl = open_uni_stream(ssl_conn, &cs[i].id, s, cs[i].name); + cs[i].st = quic_conn_open_uni_stream(qconn, &cs[i].id); + if (!cs[i].st) + { + ap_log_error(APLOG_MARK, APLOG_ERR, 0, s, "opening the %s stream failed", cs[i].name); + } } - if (!cs[0].ssl || !cs[1].ssl || !cs[2].ssl || nghttp3_conn_bind_control_stream(session->ngh3, cs[0].id) != 0 || nghttp3_conn_bind_qpack_streams(session->ngh3, cs[1].id, cs[2].id) != 0) + if (!cs[0].st || !cs[1].st || !cs[2].st || nghttp3_conn_bind_control_stream(session->ngh3, cs[0].id) != 0 || nghttp3_conn_bind_qpack_streams(session->ngh3, cs[1].id, cs[2].id) != 0) { ap_log_error(APLOG_MARK, APLOG_ERR, 0, s, "failed to initialize or bind control/qpack streams"); for (int i = 0; i < 3; i++) { - if (cs[i].ssl) + if (cs[i].st) { - SSL_free(cs[i].ssl); + quic_stream_free(cs[i].st); } } if (session->ngh3) @@ -159,9 +163,9 @@ apr_status_t h3_session_create_control_streams(h3_session* session) return APR_EGENERAL; } - track_stream(session, cs[0].id, cs[0].ssl); - track_stream(session, cs[1].id, cs[1].ssl); - track_stream(session, cs[2].id, cs[2].ssl); + track_stream(session, cs[0].id, cs[0].st); + track_stream(session, cs[1].id, cs[1].st); + track_stream(session, cs[2].id, cs[2].st); session->control_streams_created = 1; return APR_SUCCESS; } @@ -172,6 +176,7 @@ void h3_session_destroy(h3_session* session) { return; } + quic_conn_set_user(session->qconn, NULL); apr_thread_mutex_lock(session->lock); if (session->ngh3) { @@ -189,25 +194,25 @@ void h3_session_destroy(h3_session* session) } while (session->pending_free->nelts > 0) { - SSL_free(*(SSL**)apr_array_pop(session->pending_free)); + quic_stream_free(*(quic_stream**)apr_array_pop(session->pending_free)); } - if (session->ssl_conn) + if (session->qconn) { - SSL_free(session->ssl_conn); - session->ssl_conn = NULL; + quic_conn_free(session->qconn); + session->qconn = NULL; } apr_thread_mutex_unlock(session->lock); apr_thread_mutex_destroy(session->lock); apr_pool_destroy(session->pool); } -void h3_session_queue_free(h3_session* session, SSL* ssl) +void h3_session_queue_free(h3_session* session, quic_stream* st) { - if (!session || !ssl) + if (!session || !st) { return; } - APR_ARRAY_PUSH(session->pending_free, SSL*) = ssl; + APR_ARRAY_PUSH(session->pending_free, quic_stream*) = st; } apr_status_t h3_stream_response_append(h3_stream* stream, const uint8_t* data, size_t len) diff --git a/mod_http3/src/h3_ssl.c b/mod_http3/src/h3_ssl.c deleted file mode 100644 index 27aba88..0000000 --- a/mod_http3/src/h3_ssl.c +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. - * - * SPDX-License-Identifier: Apache-2.0 - * - * 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. - */ - -#include - -#include -#include - -#include - -#include - -#include -#include - -#include "h3_check.h" -#include "h3_ssl.h" -#include "mod_http3.h" - -int h3_alpn_select_cb(SSL* /*ssl*/, const unsigned char** out, unsigned char* outlen, const unsigned char* in, unsigned int inlen, void* arg) -{ - static const unsigned char h3[] = "\x02h3"; - CHECK(arg); - server_rec* s = arg; - - if (SSL_select_next_proto((unsigned char**)out, outlen, h3, sizeof(h3) - 1, in, inlen) == OPENSSL_NPN_NEGOTIATED) - { - ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, s, "mod_http3: ALPN negotiated h3"); - return SSL_TLSEXT_ERR_OK; - } - ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, s, "mod_http3: ALPN: client did not offer h3"); - return SSL_TLSEXT_ERR_NOACK; -} - -void h3_keylog_cb(const SSL* /*ssl*/, const char* line) -{ - const char* path = getenv("SSLKEYLOGFILE"); - FILE* f = path ? fopen(path, "a") : NULL; - if (f) - { - fprintf(f, "%s\n", line); - fclose(f); - } -} diff --git a/mod_http3/src/h3_stream.c b/mod_http3/src/h3_stream.c index 261f025..1f382dd 100644 --- a/mod_http3/src/h3_stream.c +++ b/mod_http3/src/h3_stream.c @@ -29,7 +29,7 @@ #include -#include +#include "quic.h" #include "h3.h" #include "h3_check.h" @@ -65,8 +65,7 @@ static void unblock_writable_streams(h3_session* session) { continue; } - uint64_t avail = 0; - if (h3s->ssl_stream && SSL_get_generic_value_uint(h3s->ssl_stream, SSL_VALUE_STREAM_WRITE_BUF_AVAIL, &avail) == 1 && avail == 0) + if (h3s->qstream && quic_stream_is_write_blocked(h3s->qstream)) { continue; /* still full */ } @@ -81,6 +80,7 @@ void flush_nghttp3(h3_session* session) CHECK(session); CHECK(!session->ngh3_dead, return;); unblock_writable_streams(session); + const quic_api* api = quic_selected(); for (;;) { nghttp3_vec vec[16] = {0}; @@ -102,91 +102,73 @@ void flush_nghttp3(h3_session* session) expected += vec[k].len; } h3_stream* h3s = h3_stream_find(session, sid); - if (!h3s || !h3s->ssl_stream) + if (!h3s || !h3s->qstream) { /* Stream is gone; swallow its queued bytes so the send queue keeps draining. */ nghttp3_conn_add_write_offset(session->ngh3, sid, expected); - nghttp3_conn_add_ack_offset(session->ngh3, sid, expected); - continue; - } - size_t total = 0; - int blocked = 0; - int broken = 0; - for (nghttp3_ssize k = 0; k < nvec; k++) - { - size_t w = 0; - int wrv = SSL_write_ex(h3s->ssl_stream, vec[k].base, vec[k].len, &w); - if (wrv <= 0) - { - if (SSL_get_error(h3s->ssl_stream, wrv) == SSL_ERROR_WANT_WRITE) - { - blocked = 1; - } - else - { - broken = 1; - } - break; - } - total += w; - if (w < vec[k].len) + if (api->caps.acks_are_write_offsets) { - /* Short write: stop, or the next vec would leave a gap in the stream. */ - blocked = 1; - break; + nghttp3_conn_add_ack_offset(session->ngh3, sid, expected); } + continue; } - if (total > 0 && child_h3_io) + quic_write_result res = quic_stream_write(h3s->qstream, (const quic_vec*)vec, (size_t)nvec, fin); + if (res.accepted > 0 && child_h3_io) { - apr_atomic_add64(&child_h3_io->total_bytes_written, total); + apr_atomic_add64(&child_h3_io->total_bytes_written, res.accepted); } - if (broken) + if (res.broken) { /* Peer reset: drop the remainder; teardown happens via the nghttp3 callbacks. */ nghttp3_conn_add_write_offset(session->ngh3, sid, expected); - nghttp3_conn_add_ack_offset(session->ngh3, sid, expected); + if (api->caps.acks_are_write_offsets) + { + nghttp3_conn_add_ack_offset(session->ngh3, sid, expected); + } continue; } - nghttp3_conn_add_write_offset(session->ngh3, sid, total); - nghttp3_conn_add_ack_offset(session->ngh3, sid, total); - if (blocked) + nghttp3_conn_add_write_offset(session->ngh3, sid, res.accepted); + if (api->caps.acks_are_write_offsets) + { + nghttp3_conn_add_ack_offset(session->ngh3, sid, res.accepted); + } + if (res.blocked) { - /* Send buffer full: skip this stream instead of busy-looping on the same vec. */ if (!h3s->write_blocked) { h3s->write_blocked = 1; session->blocked_streams++; nghttp3_conn_block_stream(session->ngh3, sid); } + else if (!api->caps.acks_are_write_offsets) + { + /* A stale flag would otherwise spin this loop on the same vec. */ + nghttp3_conn_block_stream(session->ngh3, sid); + } continue; } - if (fin && total == expected) - { - SSL_stream_conclude(h3s->ssl_stream, 0); - } } if (session->pending_free->nelts > 0) { while (session->pending_free->nelts > 0) { - SSL* ssl = *(SSL**)apr_array_pop(session->pending_free); - if (ssl) + quic_stream* st = *(quic_stream**)apr_array_pop(session->pending_free); + if (st) { - SSL_free(ssl); + quic_stream_free(st); } } } } -h3_stream* track_stream(h3_session* session, int64_t sid, SSL* stream_ssl) +h3_stream* track_stream(h3_session* session, int64_t sid, quic_stream* qstream) { CHECK(session); - CHECK(stream_ssl); + CHECK(qstream); h3_stream* h3s = h3_stream_find(session, sid); if (h3s) { - h3s->ssl_stream = stream_ssl; - SSL_set_app_data(stream_ssl, h3s); + h3s->qstream = qstream; return h3s; } apr_pool_t* stream_pool = NULL; @@ -195,7 +177,7 @@ h3_stream* track_stream(h3_session* session, int64_t sid, SSL* stream_ssl) h3s->session = session; h3s->pool = stream_pool; h3s->stream_id = sid; - h3s->ssl_stream = stream_ssl; + h3s->qstream = qstream; h3s->is_bidi = H3_SID_IS_BIDI(sid); h3_server_conf* conf = ap_get_module_config(session->s->module_config, &http3_module); h3s->response_buffer_limit = conf && conf->h3_stream_buffer_size @@ -207,7 +189,6 @@ h3_stream* track_stream(h3_session* session, int64_t sid, SSL* stream_ssl) return NULL; } apr_hash_set(session->streams, &h3s->stream_id, sizeof(h3s->stream_id), h3s); - SSL_set_app_data(stream_ssl, h3s); return h3s; } @@ -219,12 +200,47 @@ static void mark_ngh3_dead(h3_session* session, const char* op, int64_t stream_i ap_log_error(APLOG_MARK, APLOG_ERR, 0, session->s, "%s failed for stream %" APR_INT64_T_FMT " (%s, err=%" APR_INT64_T_FMT "); closing with QUIC error 0x%" APR_UINT64_T_HEX_FMT, op, stream_id, session->abort_reason, (apr_int64_t)liberr, session->abort_quic_error_code); } +/* nghttp3 reports a request that violates RFC 9114 4.x (missing or duplicate + * pseudo-header fields, connection-specific fields, invalid content-length) + * as one of these non-fatal errors from nghttp3_conn_read_stream. */ +static int is_malformed_request_error(nghttp3_ssize liberr) +{ + return liberr == NGHTTP3_ERR_MALFORMED_HTTP_HEADER || liberr == NGHTTP3_ERR_MALFORMED_HTTP_MESSAGING; +} + +/* RFC 9114 4.1.2: a malformed request is a stream error of type + * H3_MESSAGE_ERROR, not a connection error. Reset just the offending request + * stream and keep the connection serving its other streams. Called with the + * session lock held, like the nghttp3 callbacks it triggers. */ +static void reject_malformed_stream(h3_session* session, h3_stream* h3s, nghttp3_ssize liberr) +{ + uint64_t app_error_code = nghttp3_err_infer_quic_app_error_code((int)liberr); + ap_log_error(APLOG_MARK, APLOG_INFO, 0, session->s, "malformed HTTP/3 request on stream %" APR_INT64_T_FMT " (%s); rejecting with stream error 0x%" APR_UINT64_T_HEX_FMT, h3s->stream_id, nghttp3_strerror((int)liberr), app_error_code); + if (h3s->qstream) + { + quic_stream_reset(h3s->qstream, app_error_code); + quic_stream_stop_sending(h3s->qstream, app_error_code); + } + nghttp3_conn_shutdown_stream_read(session->ngh3, h3s->stream_id); + /* Fires on_stream_close, which queues the QUIC stream object for free. */ + nghttp3_conn_close_stream(session->ngh3, h3s->stream_id, app_error_code); + h3s->done = 1; + h3s->body_complete = 1; +} + static void feed_stream_fin(h3_session* session, h3_stream* h3s) { nghttp3_ssize consumed = nghttp3_conn_read_stream(session->ngh3, h3s->stream_id, NULL, 0, 1); if (consumed < 0) { - mark_ngh3_dead(session, "nghttp3_conn_read_stream", h3s->stream_id, consumed); + if (is_malformed_request_error(consumed)) + { + reject_malformed_stream(session, h3s, consumed); + } + else + { + mark_ngh3_dead(session, "nghttp3_conn_read_stream", h3s->stream_id, consumed); + } } h3s->body_complete = 1; } @@ -244,32 +260,28 @@ static int drain_one_stream(h3_session* session, h3_stream* h3s, int* data_read, } unsigned char* buf = session->stream_read_buf; - int read_state = SSL_get_stream_read_state(h3s->ssl_stream); - if (read_state == SSL_STREAM_STATE_FINISHED || read_state == SSL_STREAM_STATE_RESET_REMOTE || read_state == SSL_STREAM_STATE_CONN_CLOSED) + int read_finished = 0; + int write_finished = 0; + if (h3s->qstream) + { + quic_stream_is_read_finished(h3s->qstream, &read_finished, &write_finished); + } + if (read_finished) { if (!h3s->body_complete) { feed_stream_fin(session, h3s); } - if (h3s->ssl_stream) + if (h3s->qstream && write_finished) { - /* A closed or reset connection leaves the write state unreadable; treat it as finished. */ - int write_state = SSL_STREAM_STATE_FINISHED; - if (read_state != SSL_STREAM_STATE_CONN_CLOSED && read_state != SSL_STREAM_STATE_RESET_REMOTE) - { - write_state = SSL_get_stream_write_state(h3s->ssl_stream); - } - if (write_state == SSL_STREAM_STATE_FINISHED || write_state == SSL_STREAM_STATE_RESET_LOCAL) - { - nghttp3_conn_close_stream(session->ngh3, h3s->stream_id, NGHTTP3_H3_NO_ERROR); - } + nghttp3_conn_close_stream(session->ngh3, h3s->stream_id, NGHTTP3_H3_NO_ERROR); } - return h3s->is_bidi && h3s->headers_complete && h3s->body_complete && !h3s->dispatched; + return h3s->is_bidi && !h3s->done && h3s->headers_complete && h3s->body_complete && !h3s->dispatched; } while (*reads_remaining > 0 && *bytes_remaining > 0) { - if (!h3s->ssl_stream) + if (!h3s->qstream) { h3s->done = 1; break; @@ -277,8 +289,9 @@ static int drain_one_stream(h3_session* session, h3_stream* h3s, int* data_read, size_t nread = 0; size_t read_size = buf_size < *bytes_remaining ? buf_size : *bytes_remaining; - int rv = SSL_read_ex(h3s->ssl_stream, buf, read_size, &nread); - if (rv == 1 && nread > 0) + int fin = 0; + int ok = quic_stream_read(h3s->qstream, buf, read_size, &nread, &fin); + if (ok && nread > 0) { (*reads_remaining)--; *bytes_remaining -= nread; @@ -294,24 +307,33 @@ static int drain_one_stream(h3_session* session, h3_stream* h3s, int* data_read, session->pending.h3s = NULL; if (consumed < 0) { + if (is_malformed_request_error(consumed)) + { + reject_malformed_stream(session, h3s, consumed); + return 0; + } /* Mark dead if read fails. */ mark_ngh3_dead(session, "nghttp3_conn_read_stream", h3s->stream_id, consumed); h3s->done = 1; break; } + if (consumed > 0) + { + quic_stream_consumed(h3s->qstream, (size_t)consumed); + } if (h3s->done) { break; } continue; } - if (rv == 1 || SSL_get_error(h3s->ssl_stream, rv) == SSL_ERROR_ZERO_RETURN) + if (fin) { feed_stream_fin(session, h3s); } break; } - return h3s->is_bidi && h3s->headers_complete && h3s->body_complete && !h3s->dispatched; + return h3s->is_bidi && !h3s->done && h3s->headers_complete && h3s->body_complete && !h3s->dispatched; } apr_array_header_t* drain_ready_streams(h3_session* session, apr_pool_t* loop_pool, int* data_read) @@ -352,7 +374,7 @@ apr_array_header_t* drain_ready_streams(h3_session* session, apr_pool_t* loop_po size_t i = (start + (size_t)offset) % (size_t)snapshot->nelts; h3_stream* h3s = ((h3_stream**)snapshot->elts)[i]; - if (h3s->done || !h3s->ssl_stream) + if (h3s->done || !h3s->qstream) { continue; } @@ -382,7 +404,7 @@ apr_array_header_t* drain_ready_streams(h3_session* session, apr_pool_t* loop_po /* Only request streams are reclaimed; control streams live for the connection. */ if (h3s->is_bidi && !H3_SID_IS_SERVER(h3s->stream_id)) { - if (h3s->done && h3s->ssl_stream == NULL && h3s->dispatched && h3s->worker_done) + if (h3s->done && h3s->qstream == NULL && h3s->dispatched && h3s->worker_done) { /* Closed, SSL freed, worker returned: no other thread can reach its pool. */ if (h3s->write_blocked) @@ -397,7 +419,7 @@ apr_array_header_t* drain_ready_streams(h3_session* session, apr_pool_t* loop_po apr_pool_destroy(h3s->pool); } } - else if (h3s->done && h3s->ssl_stream != NULL) + else if (h3s->done && h3s->qstream != NULL) { done_but_has_ssl++; } diff --git a/mod_http3/src/h3_threads.c b/mod_http3/src/h3_threads.c index 4ff0956..c2636b5 100644 --- a/mod_http3/src/h3_threads.c +++ b/mod_http3/src/h3_threads.c @@ -23,7 +23,7 @@ #include -#include +#include "quic.h" #include "h3_io.h" #include "h3_session.h" @@ -48,20 +48,16 @@ void* APR_THREAD_FUNC quic_event_thread(apr_thread_t* thread, void* data) wait_for_event(io); } work_pending = 0; - SSL_handle_events(io->ssl_listener); - while (h3_io_has_buffered_datagrams(io)) + if (quic_engine_pump(io->qengine)) { - if (SSL_handle_events(io->ssl_listener) != 1) - { - break; - } + work_pending = 1; } if (io->thread_running) { for (;;) { - SSL* conn = SSL_accept_connection(io->ssl_listener, SSL_ACCEPT_CONNECTION_NO_BLOCK); + quic_conn* conn = quic_engine_accept_conn(io->qengine); if (!conn) { break; @@ -71,12 +67,12 @@ void* APR_THREAD_FUNC quic_event_thread(apr_thread_t* thread, void* data) { h3_server_conf* conf = ap_get_module_config(io->server->module_config, &http3_module); ap_log_error(APLOG_MARK, APLOG_WARNING, 0, io->server, "dropping QUIC connection: at H3MaxConnections limit (%u)", conf->h3_max_connections); - SSL_free(conn); + quic_conn_free(conn); continue; } if (!prepare_accepted_connection(io, conn)) { - SSL_free(conn); + quic_conn_free(conn); } } progress_pending_handshakes(io); @@ -92,42 +88,8 @@ void* APR_THREAD_FUNC quic_event_thread(apr_thread_t* thread, void* data) if (session->aborted) { - int shutdown_done = 0; - int ret; - uint64_t flags = (!io->thread_running) ? SSL_SHUTDOWN_FLAG_RAPID : 0; - - if (session->ngh3_dead) - { - SSL_SHUTDOWN_EX_ARGS args = {.quic_error_code = session->abort_quic_error_code, .quic_reason = session->abort_reason}; - ret = SSL_shutdown_ex(session->ssl_conn, flags, &args, sizeof(args)); - } - else - { - if (flags != 0) - { - SSL_SHUTDOWN_EX_ARGS args = {0}; - ret = SSL_shutdown_ex(session->ssl_conn, flags, &args, sizeof(args)); - } - else - { - ret = SSL_shutdown(session->ssl_conn); - } - } - - if (ret == 1) - { - shutdown_done = 1; - session->aborted = 1; - } - else if (ret < 0) - { - int err = SSL_get_error(session->ssl_conn, ret); - if (err != SSL_ERROR_WANT_READ && err != SSL_ERROR_WANT_WRITE) - { - shutdown_done = 1; - session->aborted = 1; - } - } + int is_rapid = (!io->thread_running); + int shutdown_done = quic_conn_shutdown(session->qconn, is_rapid, session->abort_quic_error_code, session->ngh3_dead ? session->abort_reason : NULL); if (shutdown_done && apr_atomic_read32(&session->active_tasks) == 0) { @@ -143,7 +105,7 @@ void* APR_THREAD_FUNC quic_event_thread(apr_thread_t* thread, void* data) } io->active_sessions->nelts--; apr_atomic_dec32(&io->active_session_count); - continue; /* Do not increment i, as we swapped the last element into this slot */ + continue; } } i++; diff --git a/quic/CMakeLists.txt b/quic/CMakeLists.txt new file mode 100644 index 0000000..de7ab4e --- /dev/null +++ b/quic/CMakeLists.txt @@ -0,0 +1,19 @@ +# -- QUIC transport -- +# +# quic/quic/ is the contract every caller sees; the directories beside it are +# the engines implementing it. Each appends its own sources, include directory +# and transport library to the one target below. + +add_library(${PROJECT_NAME}-quic STATIC) +set_target_properties(${PROJECT_NAME}-quic PROPERTIES C_VISIBILITY_PRESET hidden VISIBILITY_INLINES_HIDDEN ON) +apply_target_flags(${PROJECT_NAME}-quic) + +set(QUIC_DEPENDENCIES_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/dependencies" + CACHE PATH "Directory holding optional QUIC library submodules") +set(QUIC_DEPENDENCIES_OUTPUT_DIRECTORY "${QUIC_DEPENDENCIES_DIRECTORY}" + CACHE PATH "Directory the optional QUIC libraries are built into") + +add_subdirectory(quic) +add_subdirectory(null) +add_subdirectory(ossl) +add_subdirectory(ngtcp2) diff --git a/quic/ngtcp2/CMakeLists.txt b/quic/ngtcp2/CMakeLists.txt new file mode 100644 index 0000000..1c27fb8 --- /dev/null +++ b/quic/ngtcp2/CMakeLists.txt @@ -0,0 +1,15 @@ +# -- ngtcp2 QUIC engine -- + +if(NOT ENABLE_NGTCP2) + return() +endif() + +include(ngtcp2) + +file(GLOB_RECURSE sources CONFIGURE_DEPENDS src/*.c) +target_sources(${PROJECT_NAME}-quic PRIVATE ${sources}) +target_link_libraries(${PROJECT_NAME}-quic PRIVATE ngtcp2) +target_compile_definitions(${PROJECT_NAME}-quic PRIVATE H3_ENABLE_NGTCP2) +target_include_directories(${PROJECT_NAME}-quic PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${CMAKE_CURRENT_SOURCE_DIR}/src") diff --git a/quic/ngtcp2/include/detail/quic_ngtcp2_funcs.h b/quic/ngtcp2/include/detail/quic_ngtcp2_funcs.h new file mode 100644 index 0000000..ecfabec --- /dev/null +++ b/quic/ngtcp2/include/detail/quic_ngtcp2_funcs.h @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#ifndef QUIC_NGTCP2_FUNCS_H +#define QUIC_NGTCP2_FUNCS_H + +#include "quic_types.h" + +/** + * Open the TLS context and the connection-ID routing table over @p udp_fd. + * Unlike OpenSSL's QUIC, ngtcp2 owns no listener: the engine reads datagrams + * itself and routes each one by destination connection ID. + * @param cfg Credentials, settings, callbacks and io the engine runs with. + * @param err Buffer receiving the reason on failure; may be NULL. + * @param errlen Capacity of @p err. + * @return New engine, or NULL on failure. + */ +quic_engine* quic_ngtcp2_engine_create(const quic_config* cfg, char* err, size_t errlen); + +/** + * Close every live connection and release the TLS context. + * @param engine Engine to destroy. + */ +void quic_ngtcp2_engine_destroy(quic_engine* engine); + +/** + * Read a budget of datagrams, run expiry timers, then flush what is pending. + * @param engine Engine to pump. + * @return 1 if work was done and another pass may be useful, 0 otherwise. + */ +int quic_ngtcp2_engine_pump(quic_engine* engine); + +/** + * Report what the engine needs from the next event-loop wait. + * @param engine Engine to query. + * @param want_read Out: non-zero if the socket should be polled for reads. + * @param want_write Out: non-zero if the socket should be polled for writes. + * @param timeout_ms Out: milliseconds until the earliest ngtcp2 timer is due. + */ +void quic_ngtcp2_engine_want(quic_engine* engine, int* want_read, int* want_write, int* timeout_ms); + +/** + * Take the next handshaken connection off the accept queue. + * @param engine Engine to accept from. + * @return Accepted connection, or NULL if none is ready. + */ +quic_conn* quic_ngtcp2_engine_accept_conn(quic_engine* engine); + +/** + * Report the remote address of the connection's current network path. + * @param engine Engine owning @p conn. + * @param conn Connection to inspect. + * @param addr Out: peer socket address. + * @param addr_len Out: bytes of @p addr that are meaningful. + * @return 1 if the address was reported, 0 otherwise. + */ +int quic_ngtcp2_engine_peer_addr(quic_engine* engine, quic_conn* conn, struct sockaddr_storage* addr, socklen_t* addr_len); + +/** + * The last error the engine recorded, chiefly from the packet-read path. + * @param engine Engine to query. + * @return Message, empty when nothing new has been recorded since the last call. + */ +const char* quic_ngtcp2_engine_last_error(quic_engine* engine); + +/** + * Apply the idle timeout to a freshly accepted connection. + * @param conn Connection to prepare. + * @param idle_timeout_secs Idle timeout to apply, in seconds. + * @return 1 on success, 0 on failure. + */ +int quic_ngtcp2_conn_prepare(quic_conn* conn, uint32_t idle_timeout_secs); + +/** + * Attach the caller's handle, which the acknowledgement callback passes back. + * @param conn Connection to attach to. + * @param user Caller's handle, or NULL to detach. + */ +void quic_ngtcp2_conn_set_user(quic_conn* conn, void* user); + +/** + * Open a server-initiated unidirectional stream. + * @param conn Connection to open on. + * @param out_id Out: the new stream's id. + * @return New stream, or NULL on failure. + */ +quic_stream* quic_ngtcp2_conn_open_uni_stream(quic_conn* conn, int64_t* out_id); + +/** + * Take the next peer-initiated stream. + * @param conn Connection to accept from. + * @return Accepted stream, or NULL if none is ready. + */ +quic_stream* quic_ngtcp2_conn_accept_stream(quic_conn* conn); + +/** + * Whether the TLS handshake has completed. + * @param conn Connection to query. + * @return Non-zero once the handshake is done. + */ +int quic_ngtcp2_conn_is_handshake_done(quic_conn* conn); + +/** + * Whether the connection has finished closing. + * @param conn Connection to query. + * @return Non-zero once closed. + */ +int quic_ngtcp2_conn_is_closed(quic_conn* conn); + +/** + * Send CONNECTION_CLOSE and finish the connection. + * @param conn Connection to close. + * @param is_rapid Non-zero to skip the drain, as on server exit. + * @param app_error Application error code to report to the peer. + * @param reason Text accompanying @p app_error, or NULL to close cleanly. + * @return 1 when shutdown has completed, 0 while still in progress. + */ +int quic_ngtcp2_conn_shutdown(quic_conn* conn, int is_rapid, uint64_t app_error, const char* reason); + +/** + * Release a connection handle and retract every connection ID it published. + * @param conn Connection to free. + */ +void quic_ngtcp2_conn_free(quic_conn* conn); + +/** + * Stream id. + * @param st Stream to query. + * @return The stream's id, or -1 when @p st is NULL. + */ +int64_t quic_ngtcp2_stream_id(quic_stream* st); + +/** + * Write buffers to a stream, optionally closing it. ngtcp2 retransmits from + * these buffers, so they must stay valid until it acknowledges them. + * @param st Stream to write to. + * @param vec Buffers to send. + * @param nvec Number of buffers in @p vec. + * @param fin Non-zero to close the stream after these bytes. + * @return What the engine accepted, and whether it blocked or broke. + */ +quic_write_result quic_ngtcp2_stream_write(quic_stream* st, const quic_vec* vec, size_t nvec, int fin); + +/** + * Whether connection or stream flow control currently leaves no room. + * @param st Stream to query. + * @return Non-zero when blocked. + */ +int quic_ngtcp2_stream_is_write_blocked(quic_stream* st); + +/** + * Read from a stream's receive buffer. + * @param st Stream to read from. + * @param buf Destination buffer. + * @param read_size Capacity of @p buf. + * @param nread Out: bytes written to @p buf. + * @param fin Out: non-zero once the peer has finished sending. + * @return 1 if the call succeeded, 0 on failure. + */ +int quic_ngtcp2_stream_read(quic_stream* st, unsigned char* buf, size_t read_size, size_t* nread, int* fin); + +/** + * Report whether each direction of a stream has finished. + * @param st Stream to query. + * @param read_finished Out: non-zero if reading is finished or reset. + * @param write_finished Out: non-zero if writing is finished or reset. + */ +void quic_ngtcp2_stream_is_read_finished(quic_stream* st, int* read_finished, int* write_finished); + +/** + * Ask the peer to stop sending on a stream. + * @param st Stream to stop. + * @param err Application error code to report. + */ +void quic_ngtcp2_stream_stop_sending(quic_stream* st, uint64_t err); + +/** + * Abort the sending half of a stream. + * @param st Stream to reset. + * @param err Application error code to report. + */ +void quic_ngtcp2_stream_reset(quic_stream* st, uint64_t err); + +/** + * Free a stream handle. The connection owns its streams, so this is a no-op + * and teardown happens in quic_ngtcp2_conn_free(). + * @param st Stream to free. + */ +void quic_ngtcp2_stream_free(quic_stream* st); + +/** + * Credit stream flow control for bytes the application consumed. + * @param st Stream that was read from. + * @param nbytes Bytes consumed. + */ +void quic_ngtcp2_stream_consumed(quic_stream* st, size_t nbytes); + +#endif /* QUIC_NGTCP2_FUNCS_H */ diff --git a/quic/ngtcp2/include/quic_ngtcp2.h b/quic/ngtcp2/include/quic_ngtcp2.h new file mode 100644 index 0000000..3e17464 --- /dev/null +++ b/quic/ngtcp2/include/quic_ngtcp2.h @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#ifndef QUIC_NGTCP2_H +#define QUIC_NGTCP2_H + +#include "detail/quic_ngtcp2_funcs.h" + +/** + * API for the ngtcp2 QUIC engine, built only under ENABLE_NGTCP2. + * Clears caps.acks_are_write_offsets: ngtcp2 reports real per-stream + * acknowledgements and retransmits from the caller's buffers, so they must be + * held until it acknowledges them. + * @note Honours every quic_settings field: the initial_max_* windows and + * address_validation become transport parameters, max_idle_timeout_ms + * and cc_algo reach ngtcp2 directly, and enable_datagrams advertises + * max_datagram_frame_size. + * @return Table with static storage duration; never NULL. + */ +static inline const quic_api* quic_ngtcp2_api(void) +{ + static const quic_api api = { + .caps = + { + .acks_are_write_offsets = 0, + }, + .engine = + { + .create = quic_ngtcp2_engine_create, + .destroy = quic_ngtcp2_engine_destroy, + .pump = quic_ngtcp2_engine_pump, + .want = quic_ngtcp2_engine_want, + .accept_conn = quic_ngtcp2_engine_accept_conn, + .peer_addr = quic_ngtcp2_engine_peer_addr, + .last_error = quic_ngtcp2_engine_last_error, + }, + .conn = + { + .prepare = quic_ngtcp2_conn_prepare, + .set_user = quic_ngtcp2_conn_set_user, + .open_uni_stream = quic_ngtcp2_conn_open_uni_stream, + .accept_stream = quic_ngtcp2_conn_accept_stream, + .is_handshake_done = quic_ngtcp2_conn_is_handshake_done, + .is_closed = quic_ngtcp2_conn_is_closed, + .shutdown = quic_ngtcp2_conn_shutdown, + .free = quic_ngtcp2_conn_free, + }, + .stream = + { + .id = quic_ngtcp2_stream_id, + .write = quic_ngtcp2_stream_write, + .is_write_blocked = quic_ngtcp2_stream_is_write_blocked, + .read = quic_ngtcp2_stream_read, + .is_read_finished = quic_ngtcp2_stream_is_read_finished, + .stop_sending = quic_ngtcp2_stream_stop_sending, + .reset = quic_ngtcp2_stream_reset, + .free = quic_ngtcp2_stream_free, + .consumed = quic_ngtcp2_stream_consumed, + }, + }; + return &api; +} + +#endif /* QUIC_NGTCP2_H */ diff --git a/quic/ngtcp2/src/detail/quic_ngtcp2_impl.h b/quic/ngtcp2/src/detail/quic_ngtcp2_impl.h new file mode 100644 index 0000000..f952184 --- /dev/null +++ b/quic/ngtcp2/src/detail/quic_ngtcp2_impl.h @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#ifndef QUIC_NGTCP2_IMPL_H +#define QUIC_NGTCP2_IMPL_H + +#include +#include +#include +#include + +#include "detail/quic_check.h" +#include "quic_ngtcp2.h" + +typedef struct quic_ngtcp2_conn quic_ngtcp2_conn; +typedef struct quic_ngtcp2_stream quic_ngtcp2_stream; + +typedef struct quic_ngtcp2_map_slot +{ + uint8_t key[NGTCP2_MAX_CIDLEN]; + size_t keylen; + quic_ngtcp2_conn* conn; + /* Stays set after deletion, so probe chains through this slot survive. */ + unsigned used : 1; +} quic_ngtcp2_map_slot; + +typedef struct quic_ngtcp2_map +{ + quic_ngtcp2_map_slot* slots; + size_t cap; + size_t len; +} quic_ngtcp2_map; + +/** + * Publish @p conn under the connection ID in @p key, growing the table as needed. + * @param map Table to insert into. + * @param key Connection ID bytes. + * @param keylen Length of @p key, at most NGTCP2_MAX_CIDLEN. + * @param conn Connection that answers to @p key. + * @return 1 on success, 0 if the table could not grow. + */ +int quic_ngtcp2_map_set(quic_ngtcp2_map* map, const uint8_t* key, size_t keylen, quic_ngtcp2_conn* conn); + +/** + * Look up the connection published under a connection ID. + * @param map Table to search. + * @param key Connection ID bytes. + * @param keylen Length of @p key. + * @return The connection, or NULL if @p key is not published. + */ +quic_ngtcp2_conn* quic_ngtcp2_map_get(const quic_ngtcp2_map* map, const uint8_t* key, size_t keylen); + +/** + * Retract a connection ID. + * @param map Table to remove from. + * @param key Connection ID bytes. + * @param keylen Length of @p key. + */ +void quic_ngtcp2_map_del(quic_ngtcp2_map* map, const uint8_t* key, size_t keylen); + +/** + * Release the table's storage. + * @param map Table to free; its slots are not owned by the entries. + */ +void quic_ngtcp2_map_free(quic_ngtcp2_map* map); + +#define QUIC_NGTCP2_SCIDLEN 18 +#define QUIC_NGTCP2_MAX_UDP_PAYLOAD 1452 +#define QUIC_NGTCP2_RECV_BUDGET 64 +#define QUIC_NGTCP2_RETRY_TOKEN_TIMEOUT (10 * NGTCP2_SECONDS) + +struct quic_engine +{ + quic_config cfg; + + SSL_CTX* ssl_ctx; + const quic_io* io; + int validate_addr; + uint64_t idle_timeout_ns; + uint8_t secret[32]; + quic_ngtcp2_map conns; + quic_ngtcp2_conn* conns_head; + quic_ngtcp2_conn* accept_head; + quic_ngtcp2_conn* accept_tail; + /* Set while err holds a message the caller has not collected. */ + int err_pending; + char err[QUIC_ERRLEN]; +}; + +struct quic_ngtcp2_conn +{ + quic_engine* engine; + ngtcp2_conn* qconn; + ngtcp2_crypto_conn_ref conn_ref; + /* ngtcp2 takes this, not the SSL*, as the native TLS handle. */ + ngtcp2_crypto_ossl_ctx* ossl_ctx; + SSL* ssl; + ngtcp2_cid scid; + ngtcp2_path_storage path; + quic_ngtcp2_stream* streams_head; + /* Every CID published, so teardown retracts exactly those. */ + ngtcp2_cid* cids; + size_t cids_len; + size_t cids_cap; + quic_ngtcp2_stream* accept_head; + quic_ngtcp2_stream* accept_tail; + void* user; + quic_ngtcp2_conn* next; + quic_ngtcp2_conn* next_accept; + unsigned handshake_done : 1; + unsigned closed : 1; + unsigned queued_accept : 1; +}; + +struct quic_ngtcp2_stream +{ + quic_ngtcp2_conn* conn; + int64_t stream_id; + + unsigned char* rx_buf; + size_t rx_len; + size_t rx_cap; + size_t rx_off; + + quic_ngtcp2_stream* next_accept; + quic_ngtcp2_stream* next_stream; + + unsigned fin : 1; + unsigned write_blocked : 1; + unsigned read_reset : 1; + unsigned write_closed : 1; + unsigned queued_accept : 1; + unsigned engine_closed : 1; + /* TI: FIN accepted from nghttp3 but refused by ngtcp2; only we can still retry it. */ + unsigned fin_pending : 1; +}; + +/** + * Monotonic clock in the nanosecond units ngtcp2 requires. + * @return Current time, suitable for every ngtcp2 timestamp parameter. + */ +ngtcp2_tstamp quic_ngtcp2_now(void); + +/** + * Build the TLS context, adding the ngtcp2 crypto helper's own initialisation + * to what the shared TLS layer sets up. + * @param cfg Configuration supplying the certificate and key paths. + * @param err Buffer receiving the reason on failure; may be NULL. + * @param errlen Capacity of @p err. + * @return New context, or NULL on failure. + */ +SSL_CTX* quic_ngtcp2_tls_ctx_create(const quic_config* cfg, char* err, size_t errlen); + +/** + * Bind an OpenSSL session to @p conn using ngtcp2's ossl crypto helper. + * @param conn Connection whose ngtcp2_conn has already been created. + * @return 1 on success, 0 on failure. + */ +int quic_ngtcp2_tls_session_init(quic_ngtcp2_conn* conn); + +/** + * Release the TLS session. + * @param conn Connection to tear down. The app data is detached before the + * SSL is freed, which ngtcp2's teardown ordering requires. + */ +void quic_ngtcp2_tls_session_free(quic_ngtcp2_conn* conn); + +/** + * Populate @p callbacks with the server callback set. + * @param callbacks Out: every callback ngtcp2 requires of a server. + */ +void quic_ngtcp2_callbacks_init(ngtcp2_callbacks* callbacks); + +/** + * Associate @p cid with @p conn in the engine's routing table. + * @param engine Engine holding the table. + * @param cid Connection ID to publish. + * @param conn Connection that answers to @p cid. + */ +void quic_ngtcp2_cid_add(quic_engine* engine, const ngtcp2_cid* cid, quic_ngtcp2_conn* conn); + +/** + * Retract every connection ID @p conn published. + * @param conn Connection being torn down. + */ +void quic_ngtcp2_cid_forget_all(quic_ngtcp2_conn* conn); + +/** + * Drop @p cid from the engine's routing table. + * @param engine Engine holding the table. + * @param cid Connection ID to retract. + */ +void quic_ngtcp2_cid_remove(quic_engine* engine, const ngtcp2_cid* cid); + +/** + * Look up the connection owning @p cid. + * @param engine Engine holding the table. + * @param cid Destination connection ID from an inbound packet. + * @return Owning connection, or NULL if no connection answers to @p cid. + */ +quic_ngtcp2_conn* quic_ngtcp2_cid_find(quic_engine* engine, const ngtcp2_cid* cid); + +/** + * Queue @p conn for delivery through the engine's accept_conn op. + * @param conn Connection whose handshake has completed. + */ +void quic_ngtcp2_queue_accept(quic_ngtcp2_conn* conn); + +/** + * Write any packets ngtcp2 has pending for @p conn to the wire. + * Also retries a FIN ngtcp2 previously refused, and ends by updating the packet + * transmit time, without which ngtcp2 never paces. + * @param conn Connection to flush; NULL and closed connections are ignored. + */ +void quic_ngtcp2_conn_flush(quic_ngtcp2_conn* conn); + +/** + * Send one datagram along @p path from the engine's socket. + * @param conn Connection the datagram belongs to. + * @param path Network path ngtcp2 chose for it. + * @param buf Datagram payload. + * @param len Length of @p buf. + */ +void quic_ngtcp2_send(quic_ngtcp2_conn* conn, const ngtcp2_path* path, const uint8_t* buf, size_t len); + +/** + * Find or create the stream record for @p stream_id. + * @param conn Connection owning the stream. + * @param stream_id QUIC stream id. + * @return The stream record, or NULL if @p conn is NULL. + */ +quic_ngtcp2_stream* quic_ngtcp2_stream_get(quic_ngtcp2_conn* conn, int64_t stream_id); + +/** + * Append received bytes and queue the stream for acceptance. + * @param conn Connection the data arrived on. + * @param stream_id Stream the data belongs to. + * @param data Received bytes; copied into the stream's buffer. + * @param datalen Length of @p data. + * @param fin Non-zero if the peer finished sending. + * @return 1 when the data was taken, 0 if the stream could not be resolved. + */ +int quic_ngtcp2_stream_recv(quic_ngtcp2_conn* conn, int64_t stream_id, const uint8_t* data, size_t datalen, int fin); + +#endif /* QUIC_NGTCP2_IMPL_H */ diff --git a/quic/ngtcp2/src/quic_ngtcp2.c b/quic/ngtcp2/src/quic_ngtcp2.c new file mode 100644 index 0000000..623f62c --- /dev/null +++ b/quic/ngtcp2/src/quic_ngtcp2.c @@ -0,0 +1,615 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include + +#include +#include +#include +#include +#include + +#include + +#include "detail/quic_check.h" +#include "detail/quic_ngtcp2_impl.h" +#include "detail/quic_tls.h" +#include "quic.h" +#include "quic_ngtcp2.h" + +ngtcp2_tstamp quic_ngtcp2_now(void) +{ + struct timespec tp; + clock_gettime(CLOCK_MONOTONIC, &tp); + return (ngtcp2_tstamp)tp.tv_sec * NGTCP2_SECONDS + (ngtcp2_tstamp)tp.tv_nsec; +} + +void quic_ngtcp2_send(quic_ngtcp2_conn* conn, const ngtcp2_path* path, const uint8_t* buf, size_t len) +{ + const struct sockaddr* dst = (const struct sockaddr*)path->remote.addr; + while (conn->engine->io->send(conn->engine->io->io_ctx, buf, len, dst, (socklen_t)path->remote.addrlen) < 0 && errno == EINTR) + { + } +} + +/* TI: nghttp3 offers the FIN once, so one ngtcp2 refuses is ours alone to retry. */ +static void retry_pending_fins(quic_ngtcp2_conn* conn) +{ + uint8_t buf[QUIC_NGTCP2_MAX_UDP_PAYLOAD]; + for (quic_ngtcp2_stream* nst = conn->streams_head; nst; nst = nst->next_stream) + { + if (!nst || !nst->fin_pending || nst->write_closed || nst->engine_closed) + { + continue; + } + ngtcp2_ssize ndatalen = 0; + ngtcp2_pkt_info pi; + ngtcp2_path_storage ps; + ngtcp2_path_storage_zero(&ps); + ngtcp2_ssize n = ngtcp2_conn_writev_stream(conn->qconn, &ps.path, &pi, buf, sizeof(buf), &ndatalen, NGTCP2_WRITE_STREAM_FLAG_FIN, nst->stream_id, NULL, 0, quic_ngtcp2_now()); + if (n < 0) + { + if (n != NGTCP2_ERR_STREAM_DATA_BLOCKED) + { + nst->fin_pending = 0; + nst->write_closed = 1; + } + continue; + } + if (n == 0) + { + continue; /* still congestion limited; a later expiry retries */ + } + quic_ngtcp2_send(conn, &ps.path, buf, (size_t)n); + if (ndatalen >= 0) + { + nst->fin_pending = 0; + nst->write_closed = 1; + } + } +} + +void quic_ngtcp2_conn_flush(quic_ngtcp2_conn* conn) +{ + if (!conn || !conn->qconn || conn->closed) + { + return; + } + retry_pending_fins(conn); + uint8_t buf[QUIC_NGTCP2_MAX_UDP_PAYLOAD]; + for (;;) + { + /* ngtcp2 fills ps with its own storage, so it must not alias conn->path. */ + ngtcp2_path_storage ps; + ngtcp2_path_storage_zero(&ps); + ngtcp2_pkt_info pi; + ngtcp2_ssize n = ngtcp2_conn_write_pkt(conn->qconn, &ps.path, &pi, buf, sizeof(buf), quic_ngtcp2_now()); + if (n <= 0) + { + if (n < 0) + { + conn->closed = 1; + } + return; + } + quic_ngtcp2_send(conn, &ps.path, buf, (size_t)n); + } + /* Required after writing; without it ngtcp2 never paces and bursts the whole window. */ + ngtcp2_conn_update_pkt_tx_time(conn->qconn, quic_ngtcp2_now()); +} + +void quic_ngtcp2_queue_accept(quic_ngtcp2_conn* conn) +{ + quic_engine* engine = conn->engine; + if (conn->queued_accept) + { + return; + } + conn->queued_accept = 1; + if (engine->accept_tail) + { + engine->accept_tail->next_accept = conn; + } + else + { + engine->accept_head = conn; + } + engine->accept_tail = conn; +} + +static void engine_send_raw(quic_engine* engine, const struct sockaddr* dst, socklen_t dstlen, const uint8_t* buf, size_t len) +{ + while (engine->io->send(engine->io->io_ctx, buf, len, dst, dstlen) < 0 && errno == EINTR) + { + } +} + +/* A peer probing with an unknown version must be told what we do speak. */ +static void send_version_negotiation(quic_engine* engine, const ngtcp2_version_cid* vc, const struct sockaddr* peer, socklen_t peerlen) +{ + static const uint32_t versions[] = {NGTCP2_PROTO_VER_V1, NGTCP2_PROTO_VER_V2}; + uint8_t unused_random = 0; + if (RAND_bytes(&unused_random, 1) != 1) + { + return; + } + uint8_t buf[QUIC_NGTCP2_MAX_UDP_PAYLOAD]; + /* The reply swaps the connection IDs: their source becomes our destination. */ + ngtcp2_ssize n = ngtcp2_pkt_write_version_negotiation(buf, sizeof(buf), unused_random, vc->scid, vc->scidlen, vc->dcid, vc->dcidlen, versions, sizeof(versions) / sizeof(versions[0])); + if (n > 0) + { + engine_send_raw(engine, peer, peerlen, buf, (size_t)n); + } +} + +static void send_retry(quic_engine* engine, const ngtcp2_pkt_hd* hd, const struct sockaddr* peer, socklen_t peerlen) +{ + ngtcp2_cid scid; + scid.datalen = QUIC_NGTCP2_SCIDLEN; + if (RAND_bytes(scid.data, (int)scid.datalen) != 1) + { + return; + } + + uint8_t token[NGTCP2_CRYPTO_MAX_RETRY_TOKENLEN2]; + ngtcp2_ssize tokenlen = ngtcp2_crypto_generate_retry_token2(token, engine->secret, sizeof(engine->secret), hd->version, (const ngtcp2_sockaddr*)peer, (ngtcp2_socklen)peerlen, &scid, &hd->dcid, quic_ngtcp2_now()); + if (tokenlen < 0) + { + return; + } + + uint8_t buf[QUIC_NGTCP2_MAX_UDP_PAYLOAD]; + ngtcp2_ssize n = ngtcp2_crypto_write_retry(buf, sizeof(buf), hd->version, &hd->scid, &scid, &hd->dcid, token, (size_t)tokenlen); + if (n > 0) + { + engine_send_raw(engine, peer, peerlen, buf, (size_t)n); + } +} + +/* Tell the peer why we are closing, rather than leaving it to time out. */ +static void conn_close_with(quic_ngtcp2_conn* conn, uint64_t code, int is_tls_alert) +{ + if (conn->qconn && !ngtcp2_conn_in_closing_period(conn->qconn) && !ngtcp2_conn_in_draining_period(conn->qconn)) + { + ngtcp2_ccerr ccerr; + ngtcp2_ccerr_default(&ccerr); + if (is_tls_alert) + { + ngtcp2_ccerr_set_tls_alert(&ccerr, (uint8_t)code, NULL, 0); + } + else + { + ngtcp2_ccerr_set_liberr(&ccerr, (int)code, NULL, 0); + } + uint8_t buf[QUIC_NGTCP2_MAX_UDP_PAYLOAD]; + ngtcp2_path_storage ps; + ngtcp2_path_storage_zero(&ps); + ngtcp2_pkt_info pi; + ngtcp2_ssize n = ngtcp2_conn_write_connection_close(conn->qconn, &ps.path, &pi, buf, sizeof(buf), &ccerr, quic_ngtcp2_now()); + if (n > 0) + { + quic_ngtcp2_send(conn, &ps.path, buf, (size_t)n); + } + } + conn->closed = 1; +} + +/* ngtcp2 asks for a Retry when it cannot accept the Initial as it stands. */ +static void send_retry_for(quic_engine* engine, const uint8_t* pkt, size_t pktlen, const struct sockaddr* peer, socklen_t peerlen) +{ + ngtcp2_pkt_hd hd; + if (ngtcp2_accept(&hd, pkt, pktlen) == 0) + { + send_retry(engine, &hd, peer, peerlen); + } +} + +static quic_ngtcp2_conn* conn_new(quic_engine* engine, const ngtcp2_pkt_hd* hd, const ngtcp2_cid* odcid, const ngtcp2_cid* retry_scid, const struct sockaddr* peer, socklen_t peerlen, const struct sockaddr* local, socklen_t locallen) +{ + quic_ngtcp2_conn* conn = calloc(1, sizeof(*conn)); + if (!conn) + { + return NULL; + } + conn->engine = engine; + + conn->scid.datalen = QUIC_NGTCP2_SCIDLEN; + if (RAND_bytes(conn->scid.data, (int)conn->scid.datalen) != 1) + { + quic_ngtcp2_conn_free((quic_conn*)conn); + return NULL; + } + + ngtcp2_path_storage_init(&conn->path, (const ngtcp2_sockaddr*)local, (ngtcp2_socklen)locallen, (const ngtcp2_sockaddr*)peer, (ngtcp2_socklen)peerlen, NULL); + + ngtcp2_settings settings; + ngtcp2_settings_default(&settings); + settings.initial_ts = quic_ngtcp2_now(); + switch (engine->cfg.settings.cc_algo) + { + case QUIC_CC_RENO: settings.cc_algo = NGTCP2_CC_ALGO_RENO; break; + case QUIC_CC_CUBIC: settings.cc_algo = NGTCP2_CC_ALGO_CUBIC; break; + case QUIC_CC_BBR: settings.cc_algo = NGTCP2_CC_ALGO_BBR; break; + case QUIC_CC_DEFAULT: break; + } + + ngtcp2_transport_params params; + ngtcp2_transport_params_default(¶ms); + const quic_settings* set = &engine->cfg.settings; + params.max_idle_timeout = engine->idle_timeout_ns; + params.initial_max_data = set->initial_max_data; + params.initial_max_stream_data_bidi_local = set->initial_max_stream_data_bidi_local; + params.initial_max_stream_data_bidi_remote = set->initial_max_stream_data_bidi_remote; + params.initial_max_stream_data_uni = set->initial_max_stream_data_uni; + params.initial_max_streams_bidi = set->initial_max_streams_bidi; + params.initial_max_streams_uni = set->initial_max_streams_uni; + if (set->enable_datagrams) + { + params.max_datagram_frame_size = QUIC_NGTCP2_MAX_UDP_PAYLOAD; + } + params.original_dcid = odcid ? *odcid : hd->dcid; + params.original_dcid_present = 1; + if (retry_scid) + { + params.retry_scid = *retry_scid; + params.retry_scid_present = 1; + } + if (ngtcp2_crypto_generate_stateless_reset_token(params.stateless_reset_token, engine->secret, sizeof(engine->secret), &conn->scid) == 0) + { + params.stateless_reset_token_present = 1; + } + + ngtcp2_callbacks callbacks; + quic_ngtcp2_callbacks_init(&callbacks); + + int rv = ngtcp2_conn_server_new(&conn->qconn, &hd->scid, &conn->scid, &conn->path.path, hd->version, &callbacks, &settings, ¶ms, NULL, conn); + if (rv != 0) + { + snprintf(engine->err, sizeof(engine->err), "ngtcp2_conn_server_new failed: %s", ngtcp2_strerror(rv)); + engine->err_pending = 1; + quic_ngtcp2_conn_free((quic_conn*)conn); + return NULL; + } + + if (!quic_ngtcp2_tls_session_init(conn)) + { + snprintf(engine->err, sizeof(engine->err), "binding an OpenSSL session to the ngtcp2 connection failed"); + engine->err_pending = 1; + ngtcp2_conn_del(conn->qconn); + quic_ngtcp2_conn_free((quic_conn*)conn); + return NULL; + } + + quic_ngtcp2_cid_add(engine, &conn->scid, conn); + /* A retransmitted Initial carries the original DCID; without it the map forks a connection. */ + quic_ngtcp2_cid_add(engine, &hd->dcid, conn); + size_t nscid = ngtcp2_conn_get_scid(conn->qconn, NULL); + if (nscid > 0) + { + ngtcp2_cid* scids = calloc(nscid, sizeof(*scids)); + if (scids) + { + ngtcp2_conn_get_scid(conn->qconn, scids); + for (size_t i = 0; i < nscid; i++) + { + quic_ngtcp2_cid_add(engine, &scids[i], conn); + } + free(scids); + } + } + conn->next = engine->conns_head; + engine->conns_head = conn; + quic_ngtcp2_queue_accept(conn); + return conn; +} + +static quic_ngtcp2_conn* conn_accept(quic_engine* engine, const uint8_t* pkt, size_t pktlen, const struct sockaddr* peer, socklen_t peerlen, const struct sockaddr* local, socklen_t locallen) +{ + ngtcp2_pkt_hd hd; + if (ngtcp2_accept(&hd, pkt, pktlen) != 0) + { + return NULL; + } + + if (!engine->validate_addr) + { + return conn_new(engine, &hd, NULL, NULL, peer, peerlen, local, locallen); + } + + if (hd.tokenlen == 0 || hd.token[0] != NGTCP2_CRYPTO_TOKEN_MAGIC_RETRY2) + { + send_retry(engine, &hd, peer, peerlen); + return NULL; + } + + ngtcp2_cid odcid; + if (ngtcp2_crypto_verify_retry_token2(&odcid, hd.token, hd.tokenlen, engine->secret, sizeof(engine->secret), hd.version, (const ngtcp2_sockaddr*)peer, (ngtcp2_socklen)peerlen, &hd.dcid, QUIC_NGTCP2_RETRY_TOKEN_TIMEOUT, quic_ngtcp2_now()) != 0) + { + send_retry(engine, &hd, peer, peerlen); + return NULL; + } + return conn_new(engine, &hd, &odcid, &hd.dcid, peer, peerlen, local, locallen); +} + +static void engine_expire(quic_engine* engine) +{ + ngtcp2_tstamp now = quic_ngtcp2_now(); + for (quic_ngtcp2_conn* conn = engine->conns_head; conn; conn = conn->next) + { + if (conn->closed || !conn->qconn) + { + continue; + } + if (ngtcp2_conn_get_expiry2(conn->qconn) > now) + { + continue; + } + if (ngtcp2_conn_handle_expiry(conn->qconn, now) != 0) + { + conn->closed = 1; + continue; + } + quic_ngtcp2_conn_flush(conn); + } +} + +quic_engine* quic_ngtcp2_engine_create(const quic_config* cfg, char* err, size_t errlen) +{ + QUIC_CHECK(cfg); + QUIC_CHECK(cfg->io); + + quic_engine* engine = calloc(1, sizeof(*engine)); + if (!engine) + { + quic_tls_error(err, errlen, "allocating the engine failed"); + return NULL; + } + engine->cfg = *cfg; + engine->io = cfg->io; + engine->validate_addr = cfg->settings.address_validation; + engine->idle_timeout_ns = cfg->settings.max_idle_timeout_ms * NGTCP2_MILLISECONDS; + + if (RAND_bytes(engine->secret, (int)sizeof(engine->secret)) != 1) + { + quic_tls_error(err, errlen, "RAND_bytes failed while seeding the token secret"); + quic_ngtcp2_engine_destroy(engine); + return NULL; + } + + engine->ssl_ctx = quic_ngtcp2_tls_ctx_create(cfg, err, errlen); + if (!engine->ssl_ctx) + { + quic_ngtcp2_engine_destroy(engine); + return NULL; + } + return engine; +} + +void quic_ngtcp2_engine_destroy(quic_engine* engine) +{ + if (!engine) + { + return; + } + while (engine->conns_head) + { + quic_ngtcp2_conn* next = engine->conns_head->next; + quic_ngtcp2_conn_free((quic_conn*)engine->conns_head); + engine->conns_head = next; + } + quic_ngtcp2_map_free(&engine->conns); + if (engine->ssl_ctx) + { + SSL_CTX_free(engine->ssl_ctx); + } + free(engine); +} + +const char* quic_ngtcp2_engine_last_error(quic_engine* engine) +{ + if (!engine || !engine->err_pending) + { + return ""; + } + engine->err_pending = 0; + return engine->err; +} + +/* The bound address is stable per socket, so ngtcp2 sees no path change. */ +static int recv_one(quic_engine* engine, uint8_t* buf, size_t buflen, struct sockaddr_storage* peer, socklen_t* peerlen, struct sockaddr_storage* local, socklen_t* locallen, ssize_t* nread) +{ + *peerlen = sizeof(*peer); + do + { + *nread = engine->io->recv(engine->io->io_ctx, buf, buflen, peer, peerlen); + } while (*nread < 0 && errno == EINTR); + + if (*nread < 0) + { + return 0; + } + + *locallen = sizeof(*local); + return engine->io->local_addr(engine->io->io_ctx, local, locallen); +} + +int quic_ngtcp2_engine_pump(quic_engine* engine) +{ + if (!engine || !engine->io) + { + return 0; + } + + int progressed = 0; + uint8_t buf[65536]; + for (int i = 0; i < QUIC_NGTCP2_RECV_BUDGET; i++) + { + struct sockaddr_storage peer; + struct sockaddr_storage local; + socklen_t peerlen = 0; + socklen_t locallen = 0; + ssize_t nread = 0; + if (!recv_one(engine, buf, sizeof(buf), &peer, &peerlen, &local, &locallen, &nread)) + { + break; + } + progressed = 1; + + ngtcp2_version_cid vc; + int rv = ngtcp2_pkt_decode_version_cid(&vc, buf, (size_t)nread, QUIC_NGTCP2_SCIDLEN); + if (rv != 0) + { + if (rv == NGTCP2_ERR_VERSION_NEGOTIATION) + { + send_version_negotiation(engine, &vc, (struct sockaddr*)&peer, peerlen); + } + continue; + } + + ngtcp2_cid dcid; + ngtcp2_cid_init(&dcid, vc.dcid, vc.dcidlen); + quic_ngtcp2_conn* conn = quic_ngtcp2_cid_find(engine, &dcid); + if (!conn) + { + conn = conn_accept(engine, buf, (size_t)nread, (struct sockaddr*)&peer, peerlen, (struct sockaddr*)&local, locallen); + if (!conn) + { + continue; + } + } + if (conn->closed || !conn->qconn) + { + continue; + } + + ngtcp2_path path = { + .local = {.addr = (ngtcp2_sockaddr*)&local, .addrlen = (ngtcp2_socklen)locallen}, + .remote = {.addr = (ngtcp2_sockaddr*)&peer, .addrlen = (ngtcp2_socklen)peerlen}, + }; + ngtcp2_pkt_info pi = {0}; + rv = ngtcp2_conn_read_pkt(conn->qconn, &path, &pi, buf, (size_t)nread, quic_ngtcp2_now()); + if (rv != 0) + { + switch (rv) + { + case NGTCP2_ERR_RETRY: + /* A stateless Retry is owed; the connection is not at fault. */ + send_retry_for(engine, buf, (size_t)nread, (struct sockaddr*)&peer, peerlen); + continue; + case NGTCP2_ERR_DROP_CONN: + conn->closed = 1; + continue; + case NGTCP2_ERR_DRAINING: + case NGTCP2_ERR_CLOSING: + conn->closed = 1; + continue; + case NGTCP2_ERR_CRYPTO: + conn_close_with(conn, ngtcp2_conn_get_tls_alert(conn->qconn), 1); + continue; + default: + snprintf(engine->err, sizeof(engine->err), "ngtcp2_conn_read_pkt: %s", ngtcp2_strerror(rv)); + engine->err_pending = 1; + conn_close_with(conn, (uint64_t)rv, 0); + continue; + } + } + quic_ngtcp2_conn_flush(conn); + } + + engine_expire(engine); + int nconn = 0; + int nclosed = 0; + for (quic_ngtcp2_conn* c = engine->conns_head; c; c = c->next) + { + nconn++; + nclosed += c->closed ? 1 : 0; + } + return progressed; +} + +void quic_ngtcp2_engine_want(quic_engine* engine, int* want_read, int* want_write, int* timeout_ms) +{ + *want_read = 1; + *want_write = 0; + *timeout_ms = 1000; + + ngtcp2_tstamp now = quic_ngtcp2_now(); + ngtcp2_tstamp earliest = UINT64_MAX; + for (quic_ngtcp2_conn* conn = engine->conns_head; conn; conn = conn->next) + { + if (conn->closed || !conn->qconn) + { + continue; + } + ngtcp2_tstamp expiry = ngtcp2_conn_get_expiry2(conn->qconn); + if (expiry < earliest) + { + earliest = expiry; + } + } + if (earliest == UINT64_MAX) + { + return; + } + if (earliest <= now) + { + *timeout_ms = 0; + return; + } + uint64_t delta_ms = (earliest - now) / NGTCP2_MILLISECONDS; + if (delta_ms < (uint64_t)*timeout_ms) + { + *timeout_ms = (int)delta_ms; + } +} + +quic_conn* quic_ngtcp2_engine_accept_conn(quic_engine* engine) +{ + if (!engine || !engine->accept_head) + { + return NULL; + } + quic_ngtcp2_conn* conn = engine->accept_head; + engine->accept_head = conn->next_accept; + if (!engine->accept_head) + { + engine->accept_tail = NULL; + } + conn->next_accept = NULL; + conn->queued_accept = 0; + return (quic_conn*)conn; +} + +int quic_ngtcp2_engine_peer_addr(quic_engine* engine, quic_conn* conn, struct sockaddr_storage* addr, socklen_t* addr_len) +{ + (void)engine; + quic_ngtcp2_conn* nconn = (quic_ngtcp2_conn*)conn; + if (!nconn || !addr || !addr_len) + { + return 0; + } + + socklen_t len = (socklen_t)nconn->path.path.remote.addrlen; + if (len == 0 || len > (socklen_t)sizeof(*addr)) + { + return 0; + } + memcpy(addr, nconn->path.path.remote.addr, len); + *addr_len = len; + return 1; +} diff --git a/quic/ngtcp2/src/quic_ngtcp2_cid.c b/quic/ngtcp2/src/quic_ngtcp2_cid.c new file mode 100644 index 0000000..64bb4b7 --- /dev/null +++ b/quic/ngtcp2/src/quic_ngtcp2_cid.c @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include + +#include "detail/quic_ngtcp2_impl.h" + +void quic_ngtcp2_cid_add(quic_engine* engine, const ngtcp2_cid* cid, quic_ngtcp2_conn* conn) +{ + if (!engine || !cid || !conn || cid->datalen == 0) + { + return; + } + if (!quic_ngtcp2_map_set(&engine->conns, cid->data, cid->datalen, conn)) + { + return; + } + if (conn->cids_len == conn->cids_cap) + { + size_t cap = conn->cids_cap ? conn->cids_cap * 2 : 8; + ngtcp2_cid* grown = realloc(conn->cids, cap * sizeof(*grown)); + if (!grown) + { + quic_ngtcp2_map_del(&engine->conns, cid->data, cid->datalen); + return; + } + conn->cids = grown; + conn->cids_cap = cap; + } + /* ngtcp2 may reject a CID after its callback returns, so track what we published. */ + conn->cids[conn->cids_len++] = *cid; +} + +void quic_ngtcp2_cid_forget_all(quic_ngtcp2_conn* conn) +{ + if (!conn || !conn->cids) + { + return; + } + for (size_t i = 0; i < conn->cids_len; i++) + { + quic_ngtcp2_cid_remove(conn->engine, &conn->cids[i]); + } + conn->cids_len = 0; +} + +void quic_ngtcp2_cid_remove(quic_engine* engine, const ngtcp2_cid* cid) +{ + if (!engine || !cid || cid->datalen == 0) + { + return; + } + quic_ngtcp2_map_del(&engine->conns, cid->data, cid->datalen); +} + +quic_ngtcp2_conn* quic_ngtcp2_cid_find(quic_engine* engine, const ngtcp2_cid* cid) +{ + if (!engine || !cid || cid->datalen == 0) + { + return NULL; + } + return quic_ngtcp2_map_get(&engine->conns, cid->data, cid->datalen); +} diff --git a/quic/ngtcp2/src/quic_ngtcp2_conn.c b/quic/ngtcp2/src/quic_ngtcp2_conn.c new file mode 100644 index 0000000..dfa3972 --- /dev/null +++ b/quic/ngtcp2/src/quic_ngtcp2_conn.c @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include +#include + +#include + +#include "detail/quic_check.h" +#include "detail/quic_ngtcp2_impl.h" + +static int cb_handshake_completed(ngtcp2_conn* qconn, void* user_data) +{ + (void)qconn; + quic_ngtcp2_conn* conn = user_data; + conn->handshake_done = 1; + return 0; +} + +static int cb_recv_stream_data(ngtcp2_conn* qconn, uint32_t flags, int64_t stream_id, uint64_t offset, const uint8_t* data, size_t datalen, void* user_data, void* stream_user_data) +{ + (void)qconn; + (void)offset; + (void)stream_user_data; + quic_ngtcp2_conn* conn = user_data; + if (!quic_ngtcp2_stream_recv(conn, stream_id, data, datalen, (flags & NGTCP2_STREAM_DATA_FLAG_FIN) != 0)) + { + return NGTCP2_ERR_CALLBACK_FAILURE; + } + return 0; +} + +static int cb_acked_stream_data_offset(ngtcp2_conn* qconn, int64_t stream_id, uint64_t offset, uint64_t datalen, void* user_data, void* stream_user_data) +{ + (void)qconn; + (void)offset; + (void)stream_user_data; + quic_ngtcp2_conn* conn = user_data; + if (conn->user && conn->engine->cfg.callbacks.stream_acked) + { + conn->engine->cfg.callbacks.stream_acked(conn->user, stream_id, datalen); + } + return 0; +} + +static int cb_stream_close(ngtcp2_conn* qconn, uint32_t flags, int64_t stream_id, uint64_t app_error_code, void* user_data, void* stream_user_data) +{ + (void)qconn; + (void)flags; + (void)app_error_code; + (void)stream_user_data; + quic_ngtcp2_conn* conn = user_data; + quic_ngtcp2_stream* st = quic_ngtcp2_stream_get(conn, stream_id); + if (st) + { + st->fin = 1; + st->write_closed = 1; + st->engine_closed = 1; + } + return 0; +} + +static int cb_stream_reset(ngtcp2_conn* qconn, int64_t stream_id, uint64_t final_size, uint64_t app_error_code, void* user_data, void* stream_user_data) +{ + (void)qconn; + (void)final_size; + (void)app_error_code; + (void)stream_user_data; + quic_ngtcp2_conn* conn = user_data; + quic_ngtcp2_stream* st = quic_ngtcp2_stream_get(conn, stream_id); + if (st) + { + st->read_reset = 1; + st->fin = 1; + } + return 0; +} + +static int cb_extend_max_stream_data(ngtcp2_conn* qconn, int64_t stream_id, uint64_t max_data, void* user_data, void* stream_user_data) +{ + (void)qconn; + (void)max_data; + (void)stream_user_data; + quic_ngtcp2_conn* conn = user_data; + quic_ngtcp2_stream* st = quic_ngtcp2_stream_get(conn, stream_id); + if (st) + { + st->write_blocked = 0; + } + return 0; +} + +static void cb_rand(uint8_t* dest, size_t destlen, const ngtcp2_rand_ctx* rand_ctx) +{ + (void)rand_ctx; + RAND_bytes(dest, (int)destlen); +} + +static int cb_get_new_connection_id(ngtcp2_conn* qconn, ngtcp2_cid* cid, uint8_t* token, size_t cidlen, void* user_data) +{ + (void)qconn; + quic_ngtcp2_conn* conn = user_data; + if (RAND_bytes(cid->data, (int)cidlen) != 1) + { + return NGTCP2_ERR_CALLBACK_FAILURE; + } + cid->datalen = cidlen; + if (ngtcp2_crypto_generate_stateless_reset_token(token, conn->engine->secret, sizeof(conn->engine->secret), cid) != 0) + { + return NGTCP2_ERR_CALLBACK_FAILURE; + } + quic_ngtcp2_cid_add(conn->engine, cid, conn); + return 0; +} + +static int cb_remove_connection_id(ngtcp2_conn* qconn, const ngtcp2_cid* cid, void* user_data) +{ + (void)qconn; + quic_ngtcp2_conn* conn = user_data; + quic_ngtcp2_cid_remove(conn->engine, cid); + return 0; +} + +void quic_ngtcp2_callbacks_init(ngtcp2_callbacks* callbacks) +{ + memset(callbacks, 0, sizeof(*callbacks)); + callbacks->recv_client_initial = ngtcp2_crypto_recv_client_initial_cb; + callbacks->recv_crypto_data = ngtcp2_crypto_recv_crypto_data_cb; + callbacks->encrypt = ngtcp2_crypto_encrypt_cb; + callbacks->decrypt = ngtcp2_crypto_decrypt_cb; + callbacks->hp_mask = ngtcp2_crypto_hp_mask_cb; + callbacks->update_key = ngtcp2_crypto_update_key_cb; + callbacks->delete_crypto_aead_ctx = ngtcp2_crypto_delete_crypto_aead_ctx_cb; + callbacks->delete_crypto_cipher_ctx = ngtcp2_crypto_delete_crypto_cipher_ctx_cb; + callbacks->get_path_challenge_data = ngtcp2_crypto_get_path_challenge_data_cb; + callbacks->version_negotiation = ngtcp2_crypto_version_negotiation_cb; + callbacks->handshake_completed = cb_handshake_completed; + callbacks->recv_stream_data = cb_recv_stream_data; + callbacks->acked_stream_data_offset = cb_acked_stream_data_offset; + callbacks->stream_close = cb_stream_close; + callbacks->stream_reset = cb_stream_reset; + callbacks->extend_max_stream_data = cb_extend_max_stream_data; + callbacks->rand = cb_rand; + callbacks->get_new_connection_id = cb_get_new_connection_id; + callbacks->remove_connection_id = cb_remove_connection_id; +} + +int quic_ngtcp2_conn_prepare(quic_conn* conn, uint32_t idle_timeout_secs) +{ + (void)idle_timeout_secs; + return conn != NULL; +} + +void quic_ngtcp2_conn_set_user(quic_conn* conn, void* user) +{ + quic_ngtcp2_conn* nconn = (quic_ngtcp2_conn*)conn; + if (nconn) + { + nconn->user = user; + } +} + +quic_stream* quic_ngtcp2_conn_open_uni_stream(quic_conn* conn, int64_t* out_id) +{ + quic_ngtcp2_conn* nconn = (quic_ngtcp2_conn*)conn; + QUIC_CHECK(nconn); + QUIC_CHECK(out_id); + if (!nconn->qconn) + { + return NULL; + } + int64_t stream_id = -1; + int rv = ngtcp2_conn_open_uni_stream(nconn->qconn, &stream_id, NULL); + if (rv != 0) + { + return NULL; + } + *out_id = stream_id; + return (quic_stream*)quic_ngtcp2_stream_get(nconn, stream_id); +} + +quic_stream* quic_ngtcp2_conn_accept_stream(quic_conn* conn) +{ + quic_ngtcp2_conn* nconn = (quic_ngtcp2_conn*)conn; + if (!nconn || !nconn->accept_head) + { + return NULL; + } + quic_ngtcp2_stream* st = nconn->accept_head; + nconn->accept_head = st->next_accept; + if (!nconn->accept_head) + { + nconn->accept_tail = NULL; + } + st->next_accept = NULL; + st->queued_accept = 0; + return (quic_stream*)st; +} + +int quic_ngtcp2_conn_is_handshake_done(quic_conn* conn) +{ + quic_ngtcp2_conn* nconn = (quic_ngtcp2_conn*)conn; + return nconn ? (int)nconn->handshake_done : 0; +} + +int quic_ngtcp2_conn_is_closed(quic_conn* conn) +{ + quic_ngtcp2_conn* nconn = (quic_ngtcp2_conn*)conn; + if (!nconn || nconn->closed) + { + return 1; + } + return ngtcp2_conn_in_closing_period(nconn->qconn) || ngtcp2_conn_in_draining_period(nconn->qconn); +} + +int quic_ngtcp2_conn_shutdown(quic_conn* conn, int is_rapid, uint64_t app_error, const char* reason) +{ + quic_ngtcp2_conn* nconn = (quic_ngtcp2_conn*)conn; + if (!nconn) + { + return 1; + } + if (nconn->closed || !nconn->qconn) + { + return 1; + } + if (!ngtcp2_conn_in_closing_period(nconn->qconn) && !ngtcp2_conn_in_draining_period(nconn->qconn)) + { + ngtcp2_ccerr ccerr; + ngtcp2_ccerr_default(&ccerr); + if (reason) + { + ngtcp2_ccerr_set_application_error(&ccerr, app_error, (const uint8_t*)reason, strlen(reason)); + } + uint8_t buf[QUIC_NGTCP2_MAX_UDP_PAYLOAD]; + ngtcp2_path_storage ps; + ngtcp2_path_storage_zero(&ps); + ngtcp2_pkt_info pi; + ngtcp2_ssize n = ngtcp2_conn_write_connection_close(nconn->qconn, &ps.path, &pi, buf, sizeof(buf), &ccerr, quic_ngtcp2_now()); + if (n > 0) + { + quic_ngtcp2_send(nconn, &ps.path, buf, (size_t)n); + } + } + (void)is_rapid; + nconn->closed = 1; + return 1; +} + +void quic_ngtcp2_conn_free(quic_conn* conn) +{ + quic_ngtcp2_conn* nconn = (quic_ngtcp2_conn*)conn; + if (!nconn) + { + return; + } + /* Unlink first, or the engine walks freed memory on the next pump. */ + for (quic_ngtcp2_conn** slot = &nconn->engine->conns_head; *slot; slot = &(*slot)->next) + { + if (*slot == nconn) + { + *slot = nconn->next; + break; + } + } + quic_ngtcp2_cid_forget_all(nconn); + if (nconn->qconn) + { + ngtcp2_conn_del(nconn->qconn); + nconn->qconn = NULL; + } + quic_ngtcp2_tls_session_free(nconn); + while (nconn->streams_head) + { + quic_ngtcp2_stream* next = nconn->streams_head->next_stream; + free(nconn->streams_head->rx_buf); + free(nconn->streams_head); + nconn->streams_head = next; + } + free(nconn->cids); + free(nconn); +} diff --git a/quic/ngtcp2/src/quic_ngtcp2_map.c b/quic/ngtcp2/src/quic_ngtcp2_map.c new file mode 100644 index 0000000..4e22343 --- /dev/null +++ b/quic/ngtcp2/src/quic_ngtcp2_map.c @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include +#include + +#include "detail/quic_ngtcp2_impl.h" + +#define QUIC_NGTCP2_MAP_MIN_CAP 16 + +static size_t map_hash(const uint8_t* key, size_t keylen) +{ + size_t h = 1469598103934665603u; + for (size_t i = 0; i < keylen; i++) + { + h ^= key[i]; + h *= 1099511628211u; + } + return h; +} + +static int map_same(const quic_ngtcp2_map_slot* slot, const uint8_t* key, size_t keylen) +{ + return slot->conn && slot->keylen == keylen && memcmp(slot->key, key, keylen) == 0; +} + +/* Insert into a table known to have room, so it cannot fail or recurse. */ +static void map_place(quic_ngtcp2_map_slot* slots, size_t cap, const uint8_t* key, size_t keylen, quic_ngtcp2_conn* conn) +{ + size_t i = map_hash(key, keylen) & (cap - 1); + while (slots[i].conn && !map_same(&slots[i], key, keylen)) + { + i = (i + 1) & (cap - 1); + } + memcpy(slots[i].key, key, keylen); + slots[i].keylen = keylen; + slots[i].conn = conn; + slots[i].used = 1; +} + +static int map_grow(quic_ngtcp2_map* map) +{ + size_t cap = map->cap ? map->cap * 2 : QUIC_NGTCP2_MAP_MIN_CAP; + quic_ngtcp2_map_slot* slots = calloc(cap, sizeof(*slots)); + if (!slots) + { + return 0; + } + for (size_t i = 0; i < map->cap; i++) + { + if (map->slots[i].conn) + { + map_place(slots, cap, map->slots[i].key, map->slots[i].keylen, map->slots[i].conn); + } + } + free(map->slots); + map->slots = slots; + map->cap = cap; + return 1; +} + +int quic_ngtcp2_map_set(quic_ngtcp2_map* map, const uint8_t* key, size_t keylen, quic_ngtcp2_conn* conn) +{ + QUIC_CHECK(map); + QUIC_CHECK(key); + if (keylen == 0 || keylen > NGTCP2_MAX_CIDLEN) + { + return 0; + } + /* Grow at three quarters, since linear probing degrades as the table fills. */ + if ((map->len + 1) * 4 >= map->cap * 3 && !map_grow(map)) + { + return 0; + } + map_place(map->slots, map->cap, key, keylen, conn); + map->len++; + return 1; +} + +quic_ngtcp2_conn* quic_ngtcp2_map_get(const quic_ngtcp2_map* map, const uint8_t* key, size_t keylen) +{ + QUIC_CHECK(map); + if (!map->cap || keylen == 0 || keylen > NGTCP2_MAX_CIDLEN) + { + return NULL; + } + size_t i = map_hash(key, keylen) & (map->cap - 1); + for (size_t probe = 0; probe < map->cap && map->slots[i].used; probe++) + { + if (map_same(&map->slots[i], key, keylen)) + { + return map->slots[i].conn; + } + i = (i + 1) & (map->cap - 1); + } + return NULL; +} + +void quic_ngtcp2_map_del(quic_ngtcp2_map* map, const uint8_t* key, size_t keylen) +{ + QUIC_CHECK(map); + if (!map->cap || keylen == 0 || keylen > NGTCP2_MAX_CIDLEN) + { + return; + } + size_t i = map_hash(key, keylen) & (map->cap - 1); + for (size_t probe = 0; probe < map->cap && map->slots[i].used; probe++) + { + if (map_same(&map->slots[i], key, keylen)) + { + /* used stays set: clearing it would cut probe chains that run through here. */ + map->slots[i].conn = NULL; + map->slots[i].keylen = 0; + map->len--; + return; + } + i = (i + 1) & (map->cap - 1); + } +} + +void quic_ngtcp2_map_free(quic_ngtcp2_map* map) +{ + if (map) + { + free(map->slots); + map->slots = NULL; + map->cap = 0; + map->len = 0; + } +} diff --git a/quic/ngtcp2/src/quic_ngtcp2_stream.c b/quic/ngtcp2/src/quic_ngtcp2_stream.c new file mode 100644 index 0000000..3265c3c --- /dev/null +++ b/quic/ngtcp2/src/quic_ngtcp2_stream.c @@ -0,0 +1,339 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include +#include +#include + +#include "detail/quic_ngtcp2_impl.h" + +/* quic_vec and ngtcp2_vec are both {uint8_t *base; size_t len}. */ +static_assert(sizeof(quic_vec) == sizeof(ngtcp2_vec), "vec size mismatch"); +static_assert(offsetof(quic_vec, base) == offsetof(ngtcp2_vec, base), "vec base offset mismatch"); +static_assert(offsetof(quic_vec, len) == offsetof(ngtcp2_vec, len), "vec len offset mismatch"); + +quic_ngtcp2_stream* quic_ngtcp2_stream_get(quic_ngtcp2_conn* conn, int64_t stream_id) +{ + if (!conn) + { + return NULL; + } + quic_ngtcp2_stream* st = conn->qconn ? ngtcp2_conn_get_stream_user_data(conn->qconn, stream_id) : NULL; + if (st) + { + return st; + } + st = calloc(1, sizeof(*st)); + if (!st) + { + return NULL; + } + st->conn = conn; + st->stream_id = stream_id; + st->next_stream = conn->streams_head; + conn->streams_head = st; + if (conn->qconn) + { + ngtcp2_conn_set_stream_user_data(conn->qconn, stream_id, st); + } + return st; +} + +int quic_ngtcp2_stream_recv(quic_ngtcp2_conn* conn, int64_t stream_id, const uint8_t* data, size_t datalen, int fin) +{ + quic_ngtcp2_stream* st = quic_ngtcp2_stream_get(conn, stream_id); + if (!st) + { + return 0; + } + if (datalen > 0) + { + size_t needed = st->rx_len + datalen; + if (needed > st->rx_cap) + { + size_t cap = st->rx_cap ? st->rx_cap : 4096; + while (cap < needed) + { + cap *= 2; + } + unsigned char* grown = realloc(st->rx_buf, cap); + if (!grown) + { + return 0; + } + st->rx_buf = grown; + st->rx_cap = cap; + } + memcpy(st->rx_buf + st->rx_len, data, datalen); + st->rx_len += datalen; + } + if (fin) + { + st->fin = 1; + } + if (!st->queued_accept) + { + st->queued_accept = 1; + if (conn->accept_tail) + { + conn->accept_tail->next_accept = st; + } + else + { + conn->accept_head = st; + } + conn->accept_tail = st; + } + return 1; +} + +int64_t quic_ngtcp2_stream_id(quic_stream* st) +{ + quic_ngtcp2_stream* nst = (quic_ngtcp2_stream*)st; + return nst ? nst->stream_id : -1; +} + +quic_write_result quic_ngtcp2_stream_write(quic_stream* st, const quic_vec* vec, size_t nvec, int fin) +{ + quic_write_result res = {0}; + quic_ngtcp2_stream* nst = (quic_ngtcp2_stream*)st; + if (!nst || !nst->conn || !nst->conn->qconn || nst->write_closed) + { + res.broken = 1; + return res; + } + + quic_ngtcp2_conn* conn = nst->conn; + const ngtcp2_vec* datav = (const ngtcp2_vec*)vec; + size_t vec_idx = 0; + size_t vec_off = 0; + uint8_t buf[QUIC_NGTCP2_MAX_UDP_PAYLOAD]; + + while (vec_idx < nvec || fin) + { + ngtcp2_vec head; + const ngtcp2_vec* send_vec = NULL; + size_t send_cnt = 0; + if (vec_idx < nvec) + { + head.base = datav[vec_idx].base + vec_off; + head.len = datav[vec_idx].len - vec_off; + send_vec = &head; + send_cnt = 1; + } + + int last = (vec_idx + 1 >= nvec) && (send_cnt == 0 || head.len == datav[vec_idx].len - vec_off); + uint32_t flags = (fin && last) ? NGTCP2_WRITE_STREAM_FLAG_FIN : NGTCP2_WRITE_STREAM_FLAG_NONE; + + ngtcp2_ssize ndatalen = 0; + ngtcp2_pkt_info pi; + /* ngtcp2 fills ps with its own storage, so it must not alias conn->path. */ + ngtcp2_path_storage ps; + ngtcp2_path_storage_zero(&ps); + ngtcp2_ssize n = ngtcp2_conn_writev_stream(conn->qconn, &ps.path, &pi, buf, sizeof(buf), &ndatalen, flags, nst->stream_id, send_vec, send_cnt, quic_ngtcp2_now()); + + if (n < 0) + { + if (n == NGTCP2_ERR_STREAM_DATA_BLOCKED) + { + nst->write_blocked = 1; + res.blocked = 1; + } + else + { + /* Everything else, SHUT_WR included, is terminal: no window update will clear it. */ + nst->write_closed = 1; + res.broken = 1; + } + break; + } + + if (n > 0) + { + quic_ngtcp2_send(conn, &ps.path, buf, (size_t)n); + } + + if (ndatalen > 0) + { + res.accepted += (size_t)ndatalen; + size_t remaining = (size_t)ndatalen; + while (remaining > 0 && vec_idx < nvec) + { + size_t chunk = datav[vec_idx].len - vec_off; + if (chunk > remaining) + { + vec_off += remaining; + remaining = 0; + } + else + { + remaining -= chunk; + vec_idx++; + vec_off = 0; + } + } + } + + if (n == 0) + { + // TI: an owed FIN counts as blocked, or the stream never completes + if (vec_idx < nvec || (fin && !nst->write_closed)) + { + res.blocked = 1; + } + if (vec_idx >= nvec && fin && !nst->write_closed) + { + nst->fin_pending = 1; + } + break; + } + + if (vec_idx >= nvec && (flags & NGTCP2_WRITE_STREAM_FLAG_FIN)) + { + /* ndatalen stays -1 when other frames crowded the STREAM frame out, so the FIN never went. */ + if (ndatalen < 0) + { + res.blocked = 1; + nst->fin_pending = 1; + break; + } + nst->fin_pending = 0; + nst->write_closed = 1; + break; + } + if (vec_idx >= nvec && !fin) + { + break; + } + } + /* conn_flush ends with ngtcp2_conn_update_pkt_tx_time, required after any write round. */ + quic_ngtcp2_conn_flush(conn); + return res; +} + +int quic_ngtcp2_stream_is_write_blocked(quic_stream* st) +{ + quic_ngtcp2_stream* nst = (quic_ngtcp2_stream*)st; + if (!nst || !nst->conn || !nst->conn->qconn || nst->write_closed) + { + return 1; + } + unsigned long cdl = (unsigned long)ngtcp2_conn_get_max_data_left(nst->conn->qconn); + unsigned long sdl = (unsigned long)ngtcp2_conn_get_max_stream_data_left(nst->conn->qconn, nst->stream_id); + if (cdl == 0) + { + return 1; + } + return sdl == 0; +} + +int quic_ngtcp2_stream_read(quic_stream* st, unsigned char* buf, size_t read_size, size_t* nread, int* fin) +{ + quic_ngtcp2_stream* nst = (quic_ngtcp2_stream*)st; + *nread = 0; + *fin = 0; + if (!nst) + { + return 0; + } + size_t avail = nst->rx_len - nst->rx_off; + if (avail == 0) + { + if (nst->fin) + { + *fin = 1; + } + return 0; + } + size_t copied = read_size < avail ? read_size : avail; + memcpy(buf, nst->rx_buf + nst->rx_off, copied); + nst->rx_off += copied; + *nread = copied; + if (nst->rx_off == nst->rx_len) + { + /* Fully drained: reuse the allocation instead of growing it per body. */ + nst->rx_off = 0; + nst->rx_len = 0; + } + if (nst->rx_off >= nst->rx_len && nst->fin) + { + *fin = 1; + } + return 1; +} + +void quic_ngtcp2_stream_is_read_finished(quic_stream* st, int* read_finished, int* write_finished) +{ + quic_ngtcp2_stream* nst = (quic_ngtcp2_stream*)st; + if (!nst) + { + *read_finished = 1; + *write_finished = 1; + return; + } + *read_finished = (nst->read_reset || (nst->fin && nst->rx_off >= nst->rx_len)) ? 1 : 0; + /* Not write_closed: ngtcp2 retransmits from our buffers until it closes the stream. */ + *write_finished = (nst->engine_closed || !nst->conn || !nst->conn->qconn) ? 1 : 0; +} + +void quic_ngtcp2_stream_consumed(quic_stream* st, size_t nbytes) +{ + quic_ngtcp2_stream* nst = (quic_ngtcp2_stream*)st; + if (!nst || !nst->conn || !nst->conn->qconn || nbytes == 0) + { + return; + } + ngtcp2_conn_extend_max_stream_offset(nst->conn->qconn, nst->stream_id, nbytes); + ngtcp2_conn_extend_max_offset(nst->conn->qconn, nbytes); + /* The peer is window-blocked until MAX_STREAM_DATA reaches it. */ + quic_ngtcp2_conn_flush(nst->conn); +} + +void quic_ngtcp2_stream_stop_sending(quic_stream* st, uint64_t err) +{ + quic_ngtcp2_stream* nst = (quic_ngtcp2_stream*)st; + if (!nst) + { + return; + } + if (nst->conn && nst->conn->qconn) + { + ngtcp2_conn_shutdown_stream_read(nst->conn->qconn, 0, nst->stream_id, err); + } + nst->read_reset = 1; +} + +void quic_ngtcp2_stream_reset(quic_stream* st, uint64_t err) +{ + quic_ngtcp2_stream* nst = (quic_ngtcp2_stream*)st; + if (!nst) + { + return; + } + if (nst->conn && nst->conn->qconn) + { + ngtcp2_conn_shutdown_stream_write(nst->conn->qconn, 0, nst->stream_id, err); + } + nst->write_closed = 1; +} + +/* Owned by the connection, so teardown runs from quic_ngtcp2_conn_free(). */ +void quic_ngtcp2_stream_free(quic_stream* st) +{ + (void)st; +} diff --git a/quic/ngtcp2/src/quic_ngtcp2_tls.c b/quic/ngtcp2/src/quic_ngtcp2_tls.c new file mode 100644 index 0000000..acea52e --- /dev/null +++ b/quic/ngtcp2/src/quic_ngtcp2_tls.c @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include + +#include "detail/quic_check.h" +#include "detail/quic_ngtcp2_impl.h" +#include "detail/quic_tls.h" + +SSL_CTX* quic_ngtcp2_tls_ctx_create(const quic_config* cfg, char* err, size_t errlen) +{ + QUIC_CHECK(cfg); + + if (ngtcp2_crypto_ossl_init() != 0) + { + quic_tls_error(err, errlen, "ngtcp2_crypto_ossl_init failed; OpenSSL lacks the QUIC TLS API"); + return NULL; + } + return quic_tls_ctx_create(TLS_server_method(), cfg, err, errlen); +} + +static ngtcp2_conn* conn_ref_get_conn(ngtcp2_crypto_conn_ref* conn_ref) +{ + quic_ngtcp2_conn* conn = conn_ref->user_data; + return conn->qconn; +} + +int quic_ngtcp2_tls_session_init(quic_ngtcp2_conn* conn) +{ + QUIC_CHECK(conn); + + if (ngtcp2_crypto_ossl_ctx_new(&conn->ossl_ctx, NULL) != 0) + { + return 0; + } + + conn->ssl = SSL_new(conn->engine->ssl_ctx); + if (!conn->ssl) + { + ngtcp2_crypto_ossl_ctx_del(conn->ossl_ctx); + conn->ossl_ctx = NULL; + return 0; + } + + ngtcp2_crypto_ossl_ctx_set_ssl(conn->ossl_ctx, conn->ssl); + if (ngtcp2_crypto_ossl_configure_server_session(conn->ssl) != 0) + { + quic_ngtcp2_tls_session_free(conn); + return 0; + } + + conn->conn_ref.get_conn = conn_ref_get_conn; + conn->conn_ref.user_data = conn; + SSL_set_app_data(conn->ssl, &conn->conn_ref); + SSL_set_accept_state(conn->ssl); + + ngtcp2_conn_set_tls_native_handle(conn->qconn, conn->ossl_ctx); + return 1; +} + +void quic_ngtcp2_tls_session_free(quic_ngtcp2_conn* conn) +{ + if (!conn) + { + return; + } + if (conn->ssl) + { + SSL_set_app_data(conn->ssl, NULL); + SSL_free(conn->ssl); + conn->ssl = NULL; + } + if (conn->ossl_ctx) + { + ngtcp2_crypto_ossl_ctx_del(conn->ossl_ctx); + conn->ossl_ctx = NULL; + } +} diff --git a/quic/null/CMakeLists.txt b/quic/null/CMakeLists.txt new file mode 100644 index 0000000..cd13233 --- /dev/null +++ b/quic/null/CMakeLists.txt @@ -0,0 +1,7 @@ +# -- Null QUIC engine -- + +file(GLOB_RECURSE sources CONFIGURE_DEPENDS src/*.c) +target_sources(${PROJECT_NAME}-quic PRIVATE ${sources}) +target_include_directories(${PROJECT_NAME}-quic PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${CMAKE_CURRENT_SOURCE_DIR}/src") diff --git a/quic/null/include/detail/quic_null_funcs.h b/quic/null/include/detail/quic_null_funcs.h new file mode 100644 index 0000000..b92e6aa --- /dev/null +++ b/quic/null/include/detail/quic_null_funcs.h @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#ifndef QUIC_NULL_FUNCS_H +#define QUIC_NULL_FUNCS_H + +#include "quic_types.h" + +/** + * Accept the configuration and stand up an engine that never carries traffic. + * @param cfg Configuration, which this engine only validates. + * @param err Buffer receiving the reason on failure; may be NULL. + * @param errlen Capacity of @p err. + * @return New engine, or NULL on allocation failure. + */ +quic_engine* quic_null_engine_create(const quic_config* cfg, char* err, size_t errlen); + +/** + * Release the engine. + * @param engine Engine to destroy. + */ +void quic_null_engine_destroy(quic_engine* engine); + +/** + * Report that there is never any work to do. + * @param engine Engine to pump. + * @return Always 0. + */ +int quic_null_engine_pump(quic_engine* engine); + +/** + * Ask the event loop to sleep rather than poll. + * @param engine Engine to query. + * @param want_read Out: always 0. + * @param want_write Out: always 0. + * @param timeout_ms Out: always -1. + */ +void quic_null_engine_want(quic_engine* engine, int* want_read, int* want_write, int* timeout_ms); + +/** + * Never produce a connection. + * @param engine Engine to accept from. + * @return Always NULL. + */ +quic_conn* quic_null_engine_accept_conn(quic_engine* engine); + +/** + * Report that no peer address is known. + * @param engine Engine owning @p conn. + * @param conn Connection to inspect. + * @param addr Out: untouched. + * @param addr_len Out: untouched. + * @return Always 0. + */ +int quic_null_engine_peer_addr(quic_engine* engine, quic_conn* conn, struct sockaddr_storage* addr, socklen_t* addr_len); + +/** + * The last error the engine recorded. + * @param engine Engine to query. + * @return Always the empty string. + */ +const char* quic_null_engine_last_error(quic_engine* engine); + +/** + * Fail to prepare a connection this engine can never have produced. + * @param conn Connection to prepare. + * @param idle_timeout_secs Idle timeout to apply, in seconds. + * @return Always 0. + */ +int quic_null_conn_prepare(quic_conn* conn, uint32_t idle_timeout_secs); + +/** + * Fail to open a stream. + * @param conn Connection to open on. + * @param out_id Out: untouched. + * @return Always NULL. + */ +quic_stream* quic_null_conn_open_uni_stream(quic_conn* conn, int64_t* out_id); + +/** + * Never produce a stream. + * @param conn Connection to accept from. + * @return Always NULL. + */ +quic_stream* quic_null_conn_accept_stream(quic_conn* conn); + +/** + * Report that no handshake ever completes. + * @param conn Connection to query. + * @return Always 0. + */ +int quic_null_conn_is_handshake_done(quic_conn* conn); + +/** + * Report the connection as closed. + * @param conn Connection to query. + * @return Always 1. + */ +int quic_null_conn_is_closed(quic_conn* conn); + +/** + * Report shutdown as already complete. + * @param conn Connection to close. + * @param is_rapid Non-zero to skip the drain. + * @param app_error Application error code to report to the peer. + * @param reason Text accompanying @p app_error, or NULL. + * @return Always 1. + */ +int quic_null_conn_shutdown(quic_conn* conn, int is_rapid, uint64_t app_error, const char* reason); + +/** + * Stream id. + * @param st Stream to query. + * @return Always -1. + */ +int64_t quic_null_stream_id(quic_stream* st); + +/** + * Discard a write, reporting the stream as broken. + * @param st Stream to write to. + * @param vec Buffers to send. + * @param nvec Number of buffers in @p vec. + * @param fin Non-zero to close the stream after these bytes. + * @return Nothing accepted, broken set. + */ +quic_write_result quic_null_stream_write(quic_stream* st, const quic_vec* vec, size_t nvec, int fin); + +/** + * Report the stream as never write-blocked. + * @param st Stream to query. + * @return Always 0. + */ +int quic_null_stream_is_write_blocked(quic_stream* st); + +/** + * Fail every read. + * @param st Stream to read from. + * @param buf Destination buffer. + * @param read_size Capacity of @p buf. + * @param nread Out: zero. + * @param fin Out: non-zero. + * @return Always 0. + */ +int quic_null_stream_read(quic_stream* st, unsigned char* buf, size_t read_size, size_t* nread, int* fin); + +/** + * Report both directions as finished. + * @param st Stream to query. + * @param read_finished Out: always 1. + * @param write_finished Out: always 1. + */ +void quic_null_stream_is_read_finished(quic_stream* st, int* read_finished, int* write_finished); + +#endif /* QUIC_NULL_FUNCS_H */ diff --git a/quic/null/include/quic_null.h b/quic/null/include/quic_null.h new file mode 100644 index 0000000..b64b259 --- /dev/null +++ b/quic/null/include/quic_null.h @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#ifndef QUIC_NULL_H +#define QUIC_NULL_H + +#include "detail/quic_null_funcs.h" + +/** + * API for the engine that carries nothing. It exists so the + * contract stays addable-to: wiring a backend in must touch its own directory + * and one registry row, and nothing else. Selecting it leaves the server + * listening but never completing a handshake, which also makes it a way to run + * the module with no transport underneath. + * Clears caps.acks_are_write_offsets, since it reports no acknowledgements of + * any kind and no caller should synthesise them from writes that never happen. + * @note Accepts every quic_settings field and honours none: nothing is sent. + * @return Table with static storage duration; never NULL. + */ +static inline const quic_api* quic_null_api(void) +{ + static const quic_api api = { + .caps = + { + .acks_are_write_offsets = 0, + }, + .engine = + { + .create = quic_null_engine_create, + .destroy = quic_null_engine_destroy, + .pump = quic_null_engine_pump, + .want = quic_null_engine_want, + .accept_conn = quic_null_engine_accept_conn, + .peer_addr = quic_null_engine_peer_addr, + .last_error = quic_null_engine_last_error, + }, + .conn = + { + .prepare = quic_null_conn_prepare, + .set_user = NULL, + .open_uni_stream = quic_null_conn_open_uni_stream, + .accept_stream = quic_null_conn_accept_stream, + .is_handshake_done = quic_null_conn_is_handshake_done, + .is_closed = quic_null_conn_is_closed, + .shutdown = quic_null_conn_shutdown, + .free = NULL, + }, + .stream = + { + .id = quic_null_stream_id, + .write = quic_null_stream_write, + .is_write_blocked = quic_null_stream_is_write_blocked, + .read = quic_null_stream_read, + .is_read_finished = quic_null_stream_is_read_finished, + .stop_sending = NULL, + .reset = NULL, + .free = NULL, + .consumed = NULL, + }, + }; + return &api; +} + +#endif /* QUIC_NULL_H */ diff --git a/quic/null/src/quic_null.c b/quic/null/src/quic_null.c new file mode 100644 index 0000000..273f50f --- /dev/null +++ b/quic/null/src/quic_null.c @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include + +#include "quic.h" +#include "quic_null.h" + +struct quic_engine +{ + quic_config cfg; +}; + +quic_engine* quic_null_engine_create(const quic_config* cfg, char* err, size_t errlen) +{ + (void)err; + (void)errlen; + quic_engine* engine = calloc(1, sizeof(*engine)); + if (engine && cfg) + { + engine->cfg = *cfg; + } + return engine; +} + +void quic_null_engine_destroy(quic_engine* engine) +{ + free(engine); +} + +int quic_null_engine_pump(quic_engine* engine) +{ + (void)engine; + return 0; +} + +void quic_null_engine_want(quic_engine* engine, int* want_read, int* want_write, int* timeout_ms) +{ + (void)engine; + *want_read = 0; + *want_write = 0; + *timeout_ms = -1; +} + +quic_conn* quic_null_engine_accept_conn(quic_engine* engine) +{ + (void)engine; + return NULL; +} + +int quic_null_engine_peer_addr(quic_engine* engine, quic_conn* conn, struct sockaddr_storage* addr, socklen_t* addr_len) +{ + (void)engine; + (void)conn; + (void)addr; + (void)addr_len; + return 0; +} + +const char* quic_null_engine_last_error(quic_engine* engine) +{ + (void)engine; + return ""; +} + +int quic_null_conn_prepare(quic_conn* conn, uint32_t idle_timeout_secs) +{ + (void)conn; + (void)idle_timeout_secs; + return 0; +} + +quic_stream* quic_null_conn_open_uni_stream(quic_conn* conn, int64_t* out_id) +{ + (void)conn; + (void)out_id; + return NULL; +} + +quic_stream* quic_null_conn_accept_stream(quic_conn* conn) +{ + (void)conn; + return NULL; +} + +int quic_null_conn_is_handshake_done(quic_conn* conn) +{ + (void)conn; + return 0; +} + +int quic_null_conn_is_closed(quic_conn* conn) +{ + (void)conn; + return 1; +} + +int quic_null_conn_shutdown(quic_conn* conn, int is_rapid, uint64_t app_error, const char* reason) +{ + (void)conn; + (void)is_rapid; + (void)app_error; + (void)reason; + return 1; +} + +int64_t quic_null_stream_id(quic_stream* st) +{ + (void)st; + return -1; +} + +quic_write_result quic_null_stream_write(quic_stream* st, const quic_vec* vec, size_t nvec, int fin) +{ + (void)st; + (void)vec; + (void)nvec; + (void)fin; + return (quic_write_result){.broken = 1}; +} + +int quic_null_stream_is_write_blocked(quic_stream* st) +{ + (void)st; + return 0; +} + +int quic_null_stream_read(quic_stream* st, unsigned char* buf, size_t read_size, size_t* nread, int* fin) +{ + (void)st; + (void)buf; + (void)read_size; + *nread = 0; + *fin = 1; + return 0; +} + +void quic_null_stream_is_read_finished(quic_stream* st, int* read_finished, int* write_finished) +{ + (void)st; + *read_finished = 1; + *write_finished = 1; +} diff --git a/quic/ossl/CMakeLists.txt b/quic/ossl/CMakeLists.txt new file mode 100644 index 0000000..da8621f --- /dev/null +++ b/quic/ossl/CMakeLists.txt @@ -0,0 +1,10 @@ +# -- OpenSSL QUIC engine -- + +include(openssl) + +file(GLOB_RECURSE sources CONFIGURE_DEPENDS src/*.c) +target_sources(${PROJECT_NAME}-quic PRIVATE ${sources}) +target_link_libraries(${PROJECT_NAME}-quic PRIVATE openssl) +target_include_directories(${PROJECT_NAME}-quic PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${CMAKE_CURRENT_SOURCE_DIR}/src") diff --git a/quic/ossl/include/detail/quic_ossl_funcs.h b/quic/ossl/include/detail/quic_ossl_funcs.h new file mode 100644 index 0000000..afa2a91 --- /dev/null +++ b/quic/ossl/include/detail/quic_ossl_funcs.h @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#ifndef QUIC_OSSL_FUNCS_H +#define QUIC_OSSL_FUNCS_H + +#include "quic_types.h" + +/** + * Build the QUIC listener on @p udp_fd, together with the filter BIO that + * recovers peer addresses from OpenSSL's accept queue. + * @param cfg Credentials, settings and io the listener runs with. + * @param err Buffer receiving the reason on failure; may be NULL. + * @param errlen Capacity of @p err. + * @return New engine, or NULL on failure. + * @note Needs an io whose fd() is a real descriptor: OpenSSL drives its own + * datagram BIO, so send() and recv() go unused. + */ +quic_engine* quic_ossl_engine_create(const quic_config* cfg, char* err, size_t errlen); + +/** + * Tear down the listener, its TLS context and any datagrams still queued. + * @param engine Engine to destroy. + */ +void quic_ossl_engine_destroy(quic_engine* engine); + +/** + * Drive one round of listener work: read datagrams, run timers, send. + * @param engine Engine to pump. + * @return 1 if work was done and another pass may be useful, 0 otherwise. + */ +int quic_ossl_engine_pump(quic_engine* engine); + +/** + * Report what the engine needs from the next event-loop wait. + * @param engine Engine to query. + * @param want_read Out: non-zero if the socket should be polled for reads. + * @param want_write Out: non-zero if the socket should be polled for writes. + * @param timeout_ms Out: milliseconds to wait before the next timer is due. + */ +void quic_ossl_engine_want(quic_engine* engine, int* want_read, int* want_write, int* timeout_ms); + +/** + * Take the next handshaken connection off the accept queue. + * @param engine Engine to accept from. + * @return Accepted connection, or NULL if none is ready. + */ +quic_conn* quic_ossl_engine_accept_conn(quic_engine* engine); + +/** + * Recover the peer address the filter BIO recorded for @p conn. + * @param engine Engine owning @p conn. + * @param conn Connection to inspect. + * @param addr Out: peer socket address. + * @param addr_len Out: bytes of @p addr that are meaningful. + * @return 1 if the address was recovered, 0 otherwise. + */ +int quic_ossl_engine_peer_addr(quic_engine* engine, quic_conn* conn, struct sockaddr_storage* addr, socklen_t* addr_len); + +/** + * The last error the engine recorded. + * @param engine Engine to query. + * @return Message, empty when nothing new has been recorded since the last call. + */ +const char* quic_ossl_engine_last_error(quic_engine* engine); + +/** + * Apply the idle timeout to a freshly accepted connection. + * @param conn Connection to prepare. + * @param idle_timeout_secs Idle timeout to apply, in seconds. + * @return 1 on success, 0 on failure. + */ +int quic_ossl_conn_prepare(quic_conn* conn, uint32_t idle_timeout_secs); + +/** + * Open a server-initiated unidirectional stream. + * @param conn Connection to open on. + * @param out_id Out: the new stream's id. + * @return New stream, or NULL on failure. + */ +quic_stream* quic_ossl_conn_open_uni_stream(quic_conn* conn, int64_t* out_id); + +/** + * Take the next peer-initiated stream. + * @param conn Connection to accept from. + * @return Accepted stream, or NULL if none is ready. + */ +quic_stream* quic_ossl_conn_accept_stream(quic_conn* conn); + +/** + * Whether the TLS handshake has completed. + * @param conn Connection to query. + * @return Non-zero once the handshake is done. + */ +int quic_ossl_conn_is_handshake_done(quic_conn* conn); + +/** + * Whether the connection has finished closing. + * @param conn Connection to query. + * @return Non-zero once closed. + */ +int quic_ossl_conn_is_closed(quic_conn* conn); + +/** + * Begin or continue connection shutdown. + * @param conn Connection to close. + * @param is_rapid Non-zero to skip the drain, as on server exit. + * @param app_error Application error code to report to the peer. + * @param reason Text accompanying @p app_error, or NULL to close cleanly. + * @return 1 when shutdown has completed, 0 while still in progress. + */ +int quic_ossl_conn_shutdown(quic_conn* conn, int is_rapid, uint64_t app_error, const char* reason); + +/** + * Release a connection handle. + * @param conn Connection to free. + */ +void quic_ossl_conn_free(quic_conn* conn); + +/** + * Stream id. + * @param st Stream to query. + * @return The stream's id, or -1 when @p st is NULL. + */ +int64_t quic_ossl_stream_id(quic_stream* st); + +/** + * Write buffers to a stream, optionally closing it. + * @param st Stream to write to. + * @param vec Buffers to send. + * @param nvec Number of buffers in @p vec. + * @param fin Non-zero to close the stream after these bytes. + * @return What the engine accepted, and whether it blocked or broke. + */ +quic_write_result quic_ossl_stream_write(quic_stream* st, const quic_vec* vec, size_t nvec, int fin); + +/** + * Whether the stream can currently accept more bytes. + * @param st Stream to query. + * @return Non-zero when blocked. + */ +int quic_ossl_stream_is_write_blocked(quic_stream* st); + +/** + * Read from a stream. + * @param st Stream to read from. + * @param buf Destination buffer. + * @param read_size Capacity of @p buf. + * @param nread Out: bytes written to @p buf. + * @param fin Out: non-zero once the peer has finished sending. + * @return 1 if the call succeeded, 0 on failure. + */ +int quic_ossl_stream_read(quic_stream* st, unsigned char* buf, size_t read_size, size_t* nread, int* fin); + +/** + * Report whether each direction of a stream has finished. + * @param st Stream to query. + * @param read_finished Out: non-zero if reading is finished or reset. + * @param write_finished Out: non-zero if writing is finished or reset. + */ +void quic_ossl_stream_is_read_finished(quic_stream* st, int* read_finished, int* write_finished); + +/** + * Abort the sending half of a stream. + * @param st Stream to reset. + * @param err Application error code to report. + */ +void quic_ossl_stream_reset(quic_stream* st, uint64_t err); + +/** + * Free a stream handle. + * @param st Stream to free. + */ +void quic_ossl_stream_free(quic_stream* st); + +#endif /* QUIC_OSSL_FUNCS_H */ diff --git a/quic/ossl/include/quic_ossl.h b/quic/ossl/include/quic_ossl.h new file mode 100644 index 0000000..4f829c8 --- /dev/null +++ b/quic/ossl/include/quic_ossl.h @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#ifndef QUIC_OSSL_H +#define QUIC_OSSL_H + +#include "detail/quic_ossl_funcs.h" + +/** + * API for the OpenSSL QUIC engine. Sets + * caps.acks_are_write_offsets: OpenSSL reports no per-stream acknowledgements, + * so bytes count as acked once SSL_write_ex takes them. Entries left unset are the + * ones this engine does not need; stop_sending among them, since OpenSSL closes + * the receiving half as part of the stream's own teardown. + * @note Of quic_settings it honours max_idle_timeout_ms and address_validation. + * OpenSSL fixes its own flow-control windows and congestion control, so + * the initial_max_* fields, cc_algo and enable_datagrams are ignored. + * @return Table with static storage duration; never NULL. + */ +static inline const quic_api* quic_ossl_api(void) +{ + static const quic_api api = { + .caps = + { + .acks_are_write_offsets = 1, + }, + .engine = + { + .create = quic_ossl_engine_create, + .destroy = quic_ossl_engine_destroy, + .pump = quic_ossl_engine_pump, + .want = quic_ossl_engine_want, + .accept_conn = quic_ossl_engine_accept_conn, + .peer_addr = quic_ossl_engine_peer_addr, + .last_error = quic_ossl_engine_last_error, + }, + .conn = + { + .prepare = quic_ossl_conn_prepare, + .set_user = NULL, + .open_uni_stream = quic_ossl_conn_open_uni_stream, + .accept_stream = quic_ossl_conn_accept_stream, + .is_handshake_done = quic_ossl_conn_is_handshake_done, + .is_closed = quic_ossl_conn_is_closed, + .shutdown = quic_ossl_conn_shutdown, + .free = quic_ossl_conn_free, + }, + .stream = + { + .id = quic_ossl_stream_id, + .write = quic_ossl_stream_write, + .is_write_blocked = quic_ossl_stream_is_write_blocked, + .read = quic_ossl_stream_read, + .is_read_finished = quic_ossl_stream_is_read_finished, + .stop_sending = NULL, + .reset = quic_ossl_stream_reset, + .free = quic_ossl_stream_free, + .consumed = NULL, + }, + }; + return &api; +} + +#endif /* QUIC_OSSL_H */ diff --git a/quic/ossl/src/detail/quic_ossl_impl.h b/quic/ossl/src/detail/quic_ossl_impl.h new file mode 100644 index 0000000..7b1ec4e --- /dev/null +++ b/quic/ossl/src/detail/quic_ossl_impl.h @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#ifndef QUIC_OSSL_IMPL_H +#define QUIC_OSSL_IMPL_H + +#include +#include + +#include "quic_ossl.h" + +typedef struct quic_ossl_datagram quic_ossl_datagram; + +struct quic_engine +{ + quic_config cfg; + + SSL_CTX* ssl_ctx; + SSL* ssl_listener; + + BIO_METHOD* peer_addr_bio_method; + BIO_ADDR* current_peer_addr; + int peer_addr_ex_index; + quic_ossl_datagram* peer_rx_head; + quic_ossl_datagram* peer_rx_tail; + + int err_pending; + char err[QUIC_ERRLEN]; +}; + +/** + * Drop every datagram still queued on the engine. + * @param engine Engine whose receive queue is emptied. + */ +void quic_ossl_peer_addr_queue_clear(quic_engine* engine); + +/** + * BIO_meth_set_ctrl handler for the peer-address filter BIO. + * @param bio Filter BIO receiving the control operation. + * @param cmd Control command, forwarded to the underlying BIO. + * @param num Command-specific numeric argument. + * @param ptr Command-specific pointer argument. + * @return Whatever the underlying BIO returns for @p cmd. + */ +long quic_ossl_peer_addr_bio_ctrl(BIO* bio, int cmd, long num, void* ptr); + +/** + * BIO_meth_set_sendmmsg handler, forwarding to the underlying BIO. + * @param bio Filter BIO the datagrams are written through. + * @param msg Array of messages to send. + * @param stride Size of one entry in @p msg. + * @param num_msg Number of entries in @p msg. + * @param flags Flags passed through to the underlying BIO. + * @param msgs_processed Out: how many messages were sent. + * @return 1 on success, 0 on failure. + */ +int quic_ossl_peer_addr_bio_sendmmsg(BIO* bio, BIO_MSG* msg, size_t stride, size_t num_msg, uint64_t flags, size_t* msgs_processed); + +/** + * BIO_meth_set_recvmmsg handler, recording each datagram's peer address. + * @param bio Filter BIO the datagrams are read through. + * @param msg Array receiving the messages. + * @param stride Size of one entry in @p msg. + * @param num_msg Capacity of @p msg. + * @param flags Flags passed through to the underlying BIO. + * @param msgs_processed Out: how many messages were received. + * @return 1 on success, 0 on failure. + */ +int quic_ossl_peer_addr_bio_recvmmsg(BIO* bio, BIO_MSG* msg, size_t stride, size_t num_msg, uint64_t flags, size_t* msgs_processed); + +/** + * BIO_meth_set_destroy handler, clearing the datagram queue. + * @param bio Filter BIO being destroyed. + * @return 1 on success. + */ +int quic_ossl_peer_addr_bio_destroy(BIO* bio); + +/** + * SSL ex_data free callback for a connection's stored peer address. + * @param parent Object the ex_data belongs to. + * @param ptr The stored BIO_ADDR, freed here. + * @param ad ex_data store being torn down. + * @param idx Index the value was stored at. + * @param argl Long argument registered with the index. + * @param argp Pointer argument registered with the index. + */ +void quic_ossl_peer_addr_ex_free(void* parent, void* ptr, CRYPTO_EX_DATA* ad, int idx, long argl, void* argp); + +/** + * SSL_CTX new-pending-conn callback, attaching the peer address to @p conn. + * @param ctx Context the connection was created on. + * @param conn Newly pending connection. + * @param arg The owning quic_engine. + * @return 1 to accept the connection, 0 to reject it. + */ +int quic_ossl_new_pending_conn_cb(SSL_CTX* ctx, SSL* conn, void* arg); + +#endif /* QUIC_OSSL_IMPL_H */ diff --git a/quic/ossl/src/quic_ossl.c b/quic/ossl/src/quic_ossl.c new file mode 100644 index 0000000..bb847ec --- /dev/null +++ b/quic/ossl/src/quic_ossl.c @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include + +#include +#include + +#include "detail/quic_check.h" +#include "detail/quic_ossl_impl.h" +#include "detail/quic_tls.h" +#include "quic.h" +#include "quic_ossl.h" + +quic_engine* quic_ossl_engine_create(const quic_config* cfg, char* err, size_t errlen) +{ + QUIC_CHECK(cfg); + QUIC_CHECK(cfg->io); + int udp_fd = cfg->io->fd ? cfg->io->fd(cfg->io->io_ctx) : -1; + if (udp_fd < 0) + { + quic_tls_error(err, errlen, "this engine needs a pollable descriptor from quic_io"); + return NULL; + } + quic_engine* engine = calloc(1, sizeof(*engine)); + if (!engine) + { + quic_tls_error(err, errlen, "allocating the engine failed"); + return NULL; + } + engine->peer_addr_ex_index = -1; + engine->cfg = *cfg; + + engine->ssl_ctx = quic_tls_ctx_create(OSSL_QUIC_server_method(), cfg, err, errlen); + if (!engine->ssl_ctx) + { + quic_ossl_engine_destroy(engine); + return NULL; + } + + BIO_METHOD* bm = BIO_meth_new(BIO_TYPE_FILTER | BIO_get_new_index(), "quic_ossl_peer_addr"); + if (!bm) + { + quic_tls_error(err, errlen, "BIO_meth_new failed"); + quic_ossl_engine_destroy(engine); + return NULL; + } + BIO_meth_set_ctrl(bm, quic_ossl_peer_addr_bio_ctrl); + BIO_meth_set_sendmmsg(bm, quic_ossl_peer_addr_bio_sendmmsg); + BIO_meth_set_recvmmsg(bm, quic_ossl_peer_addr_bio_recvmmsg); + BIO_meth_set_destroy(bm, quic_ossl_peer_addr_bio_destroy); + engine->peer_addr_bio_method = bm; + + engine->current_peer_addr = BIO_ADDR_new(); + engine->peer_addr_ex_index = SSL_get_ex_new_index(0, NULL, NULL, NULL, quic_ossl_peer_addr_ex_free); + if (!engine->current_peer_addr || engine->peer_addr_ex_index < 0) + { + quic_tls_error(err, errlen, "initializing peer address recovery failed"); + quic_ossl_engine_destroy(engine); + return NULL; + } + + SSL_CTX_set_new_pending_conn_cb(engine->ssl_ctx, quic_ossl_new_pending_conn_cb, engine); + + uint64_t listener_flags = cfg->settings.address_validation ? 0 : (uint64_t)SSL_LISTENER_FLAG_NO_VALIDATE; + engine->ssl_listener = SSL_new_listener(engine->ssl_ctx, listener_flags); + if (!engine->ssl_listener) + { + quic_tls_error(err, errlen, "SSL_new_listener failed"); + quic_ossl_engine_destroy(engine); + return NULL; + } + + BIO* bio = BIO_new_dgram(udp_fd, BIO_NOCLOSE); + if (!bio) + { + quic_tls_error(err, errlen, "BIO_new_dgram failed for fd=%d", udp_fd); + quic_ossl_engine_destroy(engine); + return NULL; + } + + BIO* filter_bio = BIO_new(bm); + if (!filter_bio) + { + quic_tls_error(err, errlen, "BIO_new(quic_ossl_peer_addr) failed"); + BIO_free(bio); + quic_ossl_engine_destroy(engine); + return NULL; + } + + BIO_set_data(filter_bio, engine); + bio = BIO_push(filter_bio, bio); + SSL_set_bio(engine->ssl_listener, bio, bio); + + if (!SSL_listen(engine->ssl_listener) || !SSL_set_blocking_mode(engine->ssl_listener, 0)) + { + quic_tls_error(err, errlen, "SSL_listen failed"); + quic_ossl_engine_destroy(engine); + return NULL; + } + + return engine; +} + +void quic_ossl_engine_destroy(quic_engine* engine) +{ + if (!engine) + { + return; + } + quic_ossl_peer_addr_queue_clear(engine); + if (engine->ssl_listener) + { + SSL_free(engine->ssl_listener); + } + if (engine->current_peer_addr) + { + BIO_ADDR_free(engine->current_peer_addr); + } + if (engine->peer_addr_bio_method) + { + BIO_meth_free(engine->peer_addr_bio_method); + } + if (engine->ssl_ctx) + { + SSL_CTX_free(engine->ssl_ctx); + } + free(engine); +} + +const char* quic_ossl_engine_last_error(quic_engine* engine) +{ + if (!engine || !engine->err_pending) + { + return ""; + } + engine->err_pending = 0; + return engine->err; +} + +int quic_ossl_engine_pump(quic_engine* engine) +{ + if (!engine || !engine->ssl_listener) + { + return 0; + } + int work = 0; + SSL_handle_events(engine->ssl_listener); + while (engine->peer_rx_head) + { + if (SSL_handle_events(engine->ssl_listener) != 1) + { + break; + } + work = 1; + } + return work; +} + +void quic_ossl_engine_want(quic_engine* engine, int* want_read, int* want_write, int* timeout_ms) +{ + if (!engine || !engine->ssl_listener) + { + *want_read = 0; + *want_write = 0; + *timeout_ms = 1000; + return; + } + *want_read = SSL_net_read_desired(engine->ssl_listener); + *want_write = SSL_net_write_desired(engine->ssl_listener); + + struct timeval tv = {0}; + int is_infinite = 0; + if (SSL_get_event_timeout(engine->ssl_listener, &tv, &is_infinite) && !is_infinite) + { + long ms = (long)(tv.tv_sec * 1000 + tv.tv_usec / 1000); + if (ms < *timeout_ms) + { + *timeout_ms = (int)ms; + } + } + if (*timeout_ms < 0) + { + *timeout_ms = 0; + } +} + +quic_conn* quic_ossl_engine_accept_conn(quic_engine* engine) +{ + if (!engine || !engine->ssl_listener) + { + return NULL; + } + SSL* conn = SSL_accept_connection(engine->ssl_listener, SSL_ACCEPT_CONNECTION_NO_BLOCK); + return (quic_conn*)conn; +} diff --git a/quic/ossl/src/quic_ossl_addr.c b/quic/ossl/src/quic_ossl_addr.c new file mode 100644 index 0000000..b693e6d --- /dev/null +++ b/quic/ossl/src/quic_ossl_addr.c @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include + +#include + +#include +#include +#include + +#include "detail/quic_check.h" +#include "detail/quic_ossl_impl.h" +#include "quic.h" + +struct quic_ossl_datagram +{ + unsigned char* data; + size_t data_len; + BIO_ADDR* peer; + BIO_ADDR* local; + quic_ossl_datagram* next; +}; + +void quic_ossl_peer_addr_queue_clear(quic_engine* engine) +{ + quic_ossl_datagram* item = engine->peer_rx_head; + while (item) + { + quic_ossl_datagram* next = item->next; + OPENSSL_free(item->data); + BIO_ADDR_free(item->peer); + BIO_ADDR_free(item->local); + OPENSSL_free(item); + item = next; + } + engine->peer_rx_head = NULL; + engine->peer_rx_tail = NULL; +} + +static int quic_ossl_queue_fill(quic_engine* engine, BIO_MSG* msg, size_t stride, size_t count) +{ + for (size_t i = 0; i < count; i++) + { + BIO_MSG* source = (BIO_MSG*)((unsigned char*)msg + i * stride); + quic_ossl_datagram* item = OPENSSL_zalloc(sizeof(*item)); + if (!item || !source->data || source->data_len == 0) + { + OPENSSL_free(item); + quic_ossl_peer_addr_queue_clear(engine); + return 0; + } + item->data = OPENSSL_memdup(source->data, source->data_len); + item->data_len = source->data_len; + item->peer = source->peer ? BIO_ADDR_dup(source->peer) : NULL; + item->local = source->local ? BIO_ADDR_dup(source->local) : NULL; + if (!item->data || (source->peer && !item->peer) || (source->local && !item->local)) + { + OPENSSL_free(item->data); + BIO_ADDR_free(item->peer); + BIO_ADDR_free(item->local); + OPENSSL_free(item); + quic_ossl_peer_addr_queue_clear(engine); + return 0; + } + if (engine->peer_rx_tail) + { + engine->peer_rx_tail->next = item; + } + else + { + engine->peer_rx_head = item; + } + engine->peer_rx_tail = item; + } + return 1; +} + +static int quic_ossl_queue_pop(quic_engine* engine, BIO_MSG* msg) +{ + quic_ossl_datagram* item = engine->peer_rx_head; + if (!item || !msg || !msg->data || msg->data_len < item->data_len) + { + return 0; + } + memcpy(msg->data, item->data, item->data_len); + msg->data_len = item->data_len; + if (msg->peer && item->peer) + { + BIO_ADDR_copy(msg->peer, item->peer); + } + if (msg->local && item->local) + { + BIO_ADDR_copy(msg->local, item->local); + } + engine->peer_rx_head = item->next; + if (!engine->peer_rx_head) + { + engine->peer_rx_tail = NULL; + } + OPENSSL_free(item->data); + BIO_ADDR_free(item->peer); + BIO_ADDR_free(item->local); + OPENSSL_free(item); + return 1; +} + +long quic_ossl_peer_addr_bio_ctrl(BIO* bio, int cmd, long num, void* ptr) +{ + BIO* next = BIO_next(bio); + return next ? BIO_ctrl(next, cmd, num, ptr) : 0; +} + +int quic_ossl_peer_addr_bio_sendmmsg(BIO* bio, BIO_MSG* msg, size_t stride, size_t num_msg, uint64_t flags, size_t* msgs_processed) +{ + BIO* next = BIO_next(bio); + return next ? BIO_sendmmsg(next, msg, stride, num_msg, flags, msgs_processed) : 0; +} + +int quic_ossl_peer_addr_bio_recvmmsg(BIO* bio, BIO_MSG* msg, size_t stride, size_t num_msg, uint64_t flags, size_t* msgs_processed) +{ + quic_engine* engine = BIO_get_data(bio); + BIO* next = BIO_next(bio); + if (!engine || !next || !msg || !msgs_processed || num_msg == 0) + { + return 0; + } + + BIO_ADDR_clear(engine->current_peer_addr); + if (engine->peer_rx_head) + { + *msgs_processed = 0; + if (!quic_ossl_queue_pop(engine, msg)) + { + return 0; + } + *msgs_processed = 1; + if (msg->peer) + { + BIO_ADDR_copy(engine->current_peer_addr, msg->peer); + } + return 1; + } + + size_t received = 0; + int rv = BIO_recvmmsg(next, msg, stride, num_msg, flags, &received); + if (rv && received > 0) + { + if (!quic_ossl_queue_fill(engine, msg, stride, received) || !quic_ossl_queue_pop(engine, msg)) + { + *msgs_processed = 0; + return 0; + } + *msgs_processed = 1; + if (msg->peer) + { + BIO_ADDR_copy(engine->current_peer_addr, msg->peer); + } + } + else + { + *msgs_processed = received; + } + return rv; +} + +int quic_ossl_peer_addr_bio_destroy(BIO* bio) +{ + quic_engine* engine = BIO_get_data(bio); + if (engine) + { + quic_ossl_peer_addr_queue_clear(engine); + } + return 1; +} + +void quic_ossl_peer_addr_ex_free(void* /*parent*/, void* ptr, CRYPTO_EX_DATA* /*ad*/, int /*idx*/, long /*argl*/, void* /*argp*/) +{ + BIO_ADDR_free(ptr); +} + +int quic_ossl_new_pending_conn_cb(SSL_CTX* /*ctx*/, SSL* conn, void* arg) +{ + quic_engine* engine = arg; + if (!engine || engine->peer_addr_ex_index < 0 || BIO_ADDR_family(engine->current_peer_addr) == AF_UNSPEC) + { + return 1; + } + + BIO_ADDR* peer = BIO_ADDR_dup(engine->current_peer_addr); + if (!peer || !SSL_set_ex_data(conn, engine->peer_addr_ex_index, peer)) + { + BIO_ADDR_free(peer); + return 0; + } + return 1; +} + +int quic_ossl_engine_peer_addr(quic_engine* engine, quic_conn* conn, struct sockaddr_storage* addr, socklen_t* addr_len) +{ + QUIC_CHECK(engine); + QUIC_CHECK(conn); + QUIC_CHECK(addr); + QUIC_CHECK(addr_len); + if (engine->peer_addr_ex_index < 0) + { + return 0; + } + + const BIO_ADDR* peer = SSL_get_ex_data((SSL*)conn, engine->peer_addr_ex_index); + if (!peer) + { + return 0; + } + + memset(addr, 0, sizeof(*addr)); + size_t rawlen = 0; + int family = BIO_ADDR_family(peer); + if (family == AF_INET) + { + struct sockaddr_in* sin = (struct sockaddr_in*)addr; + if (!BIO_ADDR_rawaddress(peer, &sin->sin_addr, &rawlen) || rawlen != sizeof(sin->sin_addr)) + { + return 0; + } + sin->sin_family = AF_INET; + sin->sin_port = BIO_ADDR_rawport(peer); + *addr_len = sizeof(*sin); + return 1; + } + if (family == AF_INET6) + { + struct sockaddr_in6* sin6 = (struct sockaddr_in6*)addr; + if (!BIO_ADDR_rawaddress(peer, &sin6->sin6_addr, &rawlen) || rawlen != sizeof(sin6->sin6_addr)) + { + return 0; + } + sin6->sin6_family = AF_INET6; + sin6->sin6_port = BIO_ADDR_rawport(peer); + *addr_len = sizeof(*sin6); + return 1; + } + return 0; +} diff --git a/quic/ossl/src/quic_ossl_conn.c b/quic/ossl/src/quic_ossl_conn.c new file mode 100644 index 0000000..2d078bc --- /dev/null +++ b/quic/ossl/src/quic_ossl_conn.c @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include + +#include "detail/quic_check.h" +#include "detail/quic_ossl_impl.h" +#include "quic.h" + +int quic_ossl_conn_prepare(quic_conn* conn, uint32_t idle_timeout_secs) +{ + SSL* ssl_conn = (SSL*)conn; + if (!ssl_conn || !SSL_set_blocking_mode(ssl_conn, 0)) + { + return 0; + } + SSL_set_default_stream_mode(ssl_conn, SSL_DEFAULT_STREAM_MODE_NONE); + SSL_set_incoming_stream_policy(ssl_conn, SSL_INCOMING_STREAM_POLICY_ACCEPT, 0); + SSL_set_generic_value_uint(ssl_conn, SSL_VALUE_QUIC_IDLE_TIMEOUT, (uint64_t)idle_timeout_secs * 1000); + return 1; +} + +quic_stream* quic_ossl_conn_open_uni_stream(quic_conn* conn, int64_t* out_id) +{ + SSL* ssl_conn = (SSL*)conn; + QUIC_CHECK(ssl_conn); + QUIC_CHECK(out_id); + SSL* stream = SSL_new_stream(ssl_conn, SSL_STREAM_FLAG_UNI); + if (!stream) + { + return NULL; + } + *out_id = (int64_t)SSL_get_stream_id(stream); + return (quic_stream*)stream; +} + +quic_stream* quic_ossl_conn_accept_stream(quic_conn* conn) +{ + SSL* ssl_conn = (SSL*)conn; + if (!ssl_conn) + { + return NULL; + } + return (quic_stream*)SSL_accept_stream(ssl_conn, SSL_ACCEPT_STREAM_NO_BLOCK); +} + +int quic_ossl_conn_is_handshake_done(quic_conn* conn) +{ + SSL* ssl_conn = (SSL*)conn; + return ssl_conn ? SSL_is_init_finished(ssl_conn) : 0; +} + +int quic_ossl_conn_is_closed(quic_conn* conn) +{ + SSL* ssl_conn = (SSL*)conn; + return ssl_conn ? (SSL_get_shutdown(ssl_conn) != 0) : 1; +} + +int quic_ossl_conn_shutdown(quic_conn* conn, int is_rapid, uint64_t app_error, const char* reason) +{ + SSL* ssl_conn = (SSL*)conn; + if (!ssl_conn) + { + return 1; + } + uint64_t flags = is_rapid ? (uint64_t)SSL_SHUTDOWN_FLAG_RAPID : 0; + int ret = 0; + if (reason) + { + SSL_SHUTDOWN_EX_ARGS args = {.quic_error_code = app_error, .quic_reason = reason}; + ret = SSL_shutdown_ex(ssl_conn, flags, &args, sizeof(args)); + } + else if (flags != 0) + { + SSL_SHUTDOWN_EX_ARGS args = {0}; + ret = SSL_shutdown_ex(ssl_conn, flags, &args, sizeof(args)); + } + else + { + ret = SSL_shutdown(ssl_conn); + } + + if (ret == 1) + { + return 1; + } + if (ret < 0) + { + int err = SSL_get_error(ssl_conn, ret); + if (err != SSL_ERROR_WANT_READ && err != SSL_ERROR_WANT_WRITE) + { + return 1; + } + } + return 0; +} + +void quic_ossl_conn_free(quic_conn* conn) +{ + if (conn) + { + SSL_free((SSL*)conn); + } +} diff --git a/quic/ossl/src/quic_ossl_stream.c b/quic/ossl/src/quic_ossl_stream.c new file mode 100644 index 0000000..6794a7a --- /dev/null +++ b/quic/ossl/src/quic_ossl_stream.c @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include + +#include "detail/quic_ossl_impl.h" +#include "quic.h" + +quic_write_result quic_ossl_stream_write(quic_stream* st, const quic_vec* vec, size_t nvec, int fin) +{ + SSL* ssl = (SSL*)st; + quic_write_result res = {0}; + size_t expected = 0; + for (size_t k = 0; k < nvec; k++) + { + expected += vec[k].len; + } + for (size_t k = 0; k < nvec; k++) + { + size_t w = 0; + int wrv = SSL_write_ex(ssl, vec[k].base, vec[k].len, &w); + if (wrv <= 0) + { + if (SSL_get_error(ssl, wrv) == SSL_ERROR_WANT_WRITE) + { + res.blocked = 1; + } + else + { + res.broken = 1; + } + break; + } + res.accepted += w; + if (w < vec[k].len) + { + res.blocked = 1; + break; + } + } + if (fin && !res.blocked && !res.broken && res.accepted == expected) + { + SSL_stream_conclude(ssl, 0); + } + return res; +} + +int quic_ossl_stream_is_write_blocked(quic_stream* st) +{ + SSL* ssl = (SSL*)st; + uint64_t avail = 0; + if (ssl && SSL_get_generic_value_uint(ssl, SSL_VALUE_STREAM_WRITE_BUF_AVAIL, &avail) == 1 && avail == 0) + { + return 1; + } + return 0; +} + +int quic_ossl_stream_read(quic_stream* st, unsigned char* buf, size_t read_size, size_t* nread, int* fin) +{ + SSL* ssl = (SSL*)st; + *fin = 0; + int rv = SSL_read_ex(ssl, buf, read_size, nread); + if (rv == 1 || SSL_get_error(ssl, rv) == SSL_ERROR_ZERO_RETURN) + { + *fin = 1; + } + return (rv == 1 && *nread > 0); +} + +void quic_ossl_stream_is_read_finished(quic_stream* st, int* read_finished, int* write_finished) +{ + SSL* ssl = (SSL*)st; + int rstate = SSL_get_stream_read_state(ssl); + *read_finished = (rstate == SSL_STREAM_STATE_FINISHED || rstate == SSL_STREAM_STATE_RESET_REMOTE || rstate == SSL_STREAM_STATE_CONN_CLOSED); + int wstate = SSL_STREAM_STATE_FINISHED; + if (rstate != SSL_STREAM_STATE_CONN_CLOSED && rstate != SSL_STREAM_STATE_RESET_REMOTE) + { + wstate = SSL_get_stream_write_state(ssl); + } + *write_finished = (wstate == SSL_STREAM_STATE_FINISHED || wstate == SSL_STREAM_STATE_RESET_LOCAL); +} + +void quic_ossl_stream_reset(quic_stream* st, uint64_t err) +{ + SSL_STREAM_RESET_ARGS args = {err}; + SSL_stream_reset((SSL*)st, &args, sizeof(args)); +} + +void quic_ossl_stream_free(quic_stream* st) +{ + if (st) + { + SSL_free((SSL*)st); + } +} + +int64_t quic_ossl_stream_id(quic_stream* st) +{ + SSL* ssl = (SSL*)st; + return ssl ? (int64_t)SSL_get_stream_id(ssl) : -1; +} diff --git a/quic/quic/CMakeLists.txt b/quic/quic/CMakeLists.txt new file mode 100644 index 0000000..596eba4 --- /dev/null +++ b/quic/quic/CMakeLists.txt @@ -0,0 +1,10 @@ +# -- The engine-agnostic contract -- + +include(openssl) + +file(GLOB_RECURSE sources CONFIGURE_DEPENDS src/*.c) +target_sources(${PROJECT_NAME}-quic PRIVATE ${sources}) +target_link_libraries(${PROJECT_NAME}-quic PRIVATE openssl) +target_include_directories(${PROJECT_NAME}-quic + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include" + PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") diff --git a/quic/quic/include/quic.h b/quic/quic/include/quic.h new file mode 100644 index 0000000..69ef6c7 --- /dev/null +++ b/quic/quic/include/quic.h @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#ifndef QUIC_H +#define QUIC_H + +#include "quic_funcs.h" +#include "quic_types.h" + +#endif /* QUIC_H */ diff --git a/quic/quic/include/quic_funcs.h b/quic/quic/include/quic_funcs.h new file mode 100644 index 0000000..cff035b --- /dev/null +++ b/quic/quic/include/quic_funcs.h @@ -0,0 +1,353 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#ifndef QUIC_FUNCS_H +#define QUIC_FUNCS_H + +#include "quic_types.h" + +/** + * Make @p name the engine every later call dispatches to. Until this succeeds + * the first engine compiled in is the one in use. + * @param name Engine name, matched case-insensitively. + * @return 1 if this build contains @p name, 0 otherwise, leaving the previous + * selection alone. + */ +int quic_select(const char* name); + +/** + * Name of the engine currently selected. + * @return Engine name; never NULL. + */ +const char* quic_engine_name(void); + +/** + * How many engines this build contains. + * @return At least one. + */ +size_t quic_engine_count(void); + +/** + * Name of the engine at @p i, for listing what a build offers. + * @param i Index below quic_engine_count(). + * @return Engine name, or NULL when @p i is out of range. + */ +const char* quic_engine_name_at(size_t i); + +/** + * The selected engine's API. + * @return Never NULL. + */ +const quic_api* quic_selected(void); + +/** + * Fill @p s with the transport parameters an engine uses when told nothing else. + * @param s Settings to overwrite. + */ +void quic_settings_default(quic_settings* s); + +/** + * Point @p io at the ordinary UDP implementation over @p fd, which the caller + * keeps ownership of. + * @param io Table to fill. + * @param fd Pre-opened non-blocking UDP socket bound to the listen port. + */ +void quic_io_udp_init(quic_io* io, int fd); + +/** + * Create a QUIC engine over the datagram transport named in @p cfg. + * @param cfg Credentials, settings, callbacks and io the engine runs with. + * @param err Buffer receiving the reason on failure; may be NULL. + * @param errlen Capacity of @p err. + * @return New engine, or NULL on failure. + */ +static inline quic_engine* quic_engine_create(const quic_config* cfg, char* err, size_t errlen) +{ + return quic_selected()->engine.create(cfg, err, errlen); +} + +/** + * Destroy an engine and release its resources. + * @param engine Engine to destroy; NULL is ignored. + */ +static inline void quic_engine_destroy(quic_engine* engine) +{ + if (engine && quic_selected()->engine.destroy) + { + quic_selected()->engine.destroy(engine); + } +} + +/** + * Drive one round of engine work: read packets, run timers, send. + * @param engine Engine to pump; NULL reports no work. + * @return 1 if work was done and another pass may be useful, 0 otherwise. + */ +static inline int quic_engine_pump(quic_engine* engine) +{ + return engine ? quic_selected()->engine.pump(engine) : 0; +} + +/** + * Report what the engine needs from the next event-loop wait. + * @param engine Engine to query; NULL leaves the outputs untouched. + * @param want_read Out: non-zero if the socket should be polled for reads. + * @param want_write Out: non-zero if the socket should be polled for writes. + * @param timeout_ms Out: milliseconds to wait before the next timer is due. + */ +static inline void quic_engine_want(quic_engine* engine, int* want_read, int* want_write, int* timeout_ms) +{ + if (engine && quic_selected()->engine.want) + { + quic_selected()->engine.want(engine, want_read, want_write, timeout_ms); + } +} + +/** + * Take the next fully handshaken connection. + * @param engine Engine to accept from; NULL yields NULL. + * @return Accepted connection, or NULL if none is ready. + */ +static inline quic_conn* quic_engine_accept_conn(quic_engine* engine) +{ + return engine ? quic_selected()->engine.accept_conn(engine) : NULL; +} + +/** + * Resolve a connection's peer address. + * @param engine Engine owning @p conn; NULL reports failure. + * @param conn Connection to inspect. + * @param addr Out: peer socket address. + * @param addr_len Out: bytes of @p addr that are meaningful. + * @return 1 if the address was resolved, 0 otherwise. + */ +static inline int quic_engine_peer_addr(quic_engine* engine, quic_conn* conn, struct sockaddr_storage* addr, socklen_t* addr_len) +{ + return engine ? quic_selected()->engine.peer_addr(engine, conn, addr, addr_len) : 0; +} + +/** + * The last error the engine recorded, for the caller to log. + * @param engine Engine to query; NULL reports nothing. + * @return Message, empty when the engine has reported nothing since the last call. + */ +static inline const char* quic_engine_last_error(quic_engine* engine) +{ + return engine ? quic_selected()->engine.last_error(engine) : ""; +} + +/** + * Prepare an accepted connection for use. + * @param conn Connection to prepare; NULL reports failure. + * @param idle_timeout_secs Idle timeout to apply, in seconds. + * @return 1 on success, 0 on failure. + */ +static inline int quic_conn_prepare(quic_conn* conn, uint32_t idle_timeout_secs) +{ + return conn ? quic_selected()->conn.prepare(conn, idle_timeout_secs) : 0; +} + +/** + * Attach the caller's handle to a connection, for callbacks to pass back. + * @param conn Connection to attach to; NULL is ignored. + * @param user Caller's handle, or NULL to detach. + */ +static inline void quic_conn_set_user(quic_conn* conn, void* user) +{ + if (conn && quic_selected()->conn.set_user) + { + quic_selected()->conn.set_user(conn, user); + } +} + +/** + * Open a server-initiated unidirectional stream. + * @param conn Connection to open on. + * @param out_id Out: the new stream's id. + * @return New stream, or NULL on failure. + */ +static inline quic_stream* quic_conn_open_uni_stream(quic_conn* conn, int64_t* out_id) +{ + return quic_selected()->conn.open_uni_stream(conn, out_id); +} + +/** + * Take the next peer-initiated stream. + * @param conn Connection to accept from. + * @return Accepted stream, or NULL if none is ready. + */ +static inline quic_stream* quic_conn_accept_stream(quic_conn* conn) +{ + return quic_selected()->conn.accept_stream(conn); +} + +/** + * Whether the TLS handshake has completed. + * @param conn Connection to query. + * @return Non-zero once the handshake is done. + */ +static inline int quic_conn_is_handshake_done(quic_conn* conn) +{ + return quic_selected()->conn.is_handshake_done(conn); +} + +/** + * Whether the connection has finished closing. + * @param conn Connection to query; NULL counts as closed. + * @return Non-zero once closed. + */ +static inline int quic_conn_is_closed(quic_conn* conn) +{ + return conn ? quic_selected()->conn.is_closed(conn) : 1; +} + +/** + * Begin or continue connection shutdown. + * @param conn Connection to close; NULL counts as already closed. + * @param is_rapid Non-zero to skip the drain, as on server exit. + * @param app_error Application error code to report to the peer. + * @param reason Text accompanying @p app_error, or NULL to close cleanly. + * @return 1 when shutdown has completed, 0 while still in progress. + */ +static inline int quic_conn_shutdown(quic_conn* conn, int is_rapid, uint64_t app_error, const char* reason) +{ + return conn ? quic_selected()->conn.shutdown(conn, is_rapid, app_error, reason) : 1; +} + +/** + * Release a connection handle. + * @param conn Connection to free; NULL is ignored. + */ +static inline void quic_conn_free(quic_conn* conn) +{ + if (conn && quic_selected()->conn.free) + { + quic_selected()->conn.free(conn); + } +} + +/** + * Stream id. + * @param st Stream to query. + * @return The stream's id, or -1 if it has none. + */ +static inline int64_t quic_stream_id(quic_stream* st) +{ + return quic_selected()->stream.id(st); +} + +/** + * Write buffers to a stream, optionally closing it. + * @param st Stream to write to. + * @param vec Buffers to send. + * @param nvec Number of buffers in @p vec. + * @param fin Non-zero to close the stream after these bytes. + * @return What the engine accepted, and whether it blocked or broke. + */ +static inline quic_write_result quic_stream_write(quic_stream* st, const quic_vec* vec, size_t nvec, int fin) +{ + return quic_selected()->stream.write(st, vec, nvec, fin); +} + +/** + * Whether the stream can currently accept more bytes. + * @param st Stream to query. + * @return Non-zero when blocked. + */ +static inline int quic_stream_is_write_blocked(quic_stream* st) +{ + return quic_selected()->stream.is_write_blocked(st); +} + +/** + * Read from a stream. + * @param st Stream to read from. + * @param buf Destination buffer. + * @param read_size Capacity of @p buf. + * @param nread Out: bytes written to @p buf. + * @param fin Out: non-zero once the peer has finished sending. + * @return 1 if the call succeeded, 0 on failure. + */ +static inline int quic_stream_read(quic_stream* st, unsigned char* buf, size_t read_size, size_t* nread, int* fin) +{ + return quic_selected()->stream.read(st, buf, read_size, nread, fin); +} + +/** + * Report whether each direction of a stream has finished. + * @param st Stream to query. + * @param read_finished Out: non-zero if reading is finished or reset. + * @param write_finished Out: non-zero if writing is finished or reset. + */ +static inline void quic_stream_is_read_finished(quic_stream* st, int* read_finished, int* write_finished) +{ + quic_selected()->stream.is_read_finished(st, read_finished, write_finished); +} + +/** + * Ask the peer to stop sending on a stream. + * @param st Stream to stop; NULL is ignored. + * @param err Application error code to report. + */ +static inline void quic_stream_stop_sending(quic_stream* st, uint64_t err) +{ + if (st && quic_selected()->stream.stop_sending) + { + quic_selected()->stream.stop_sending(st, err); + } +} + +/** + * Abort the sending half of a stream. + * @param st Stream to reset; NULL is ignored. + * @param err Application error code to report. + */ +static inline void quic_stream_reset(quic_stream* st, uint64_t err) +{ + if (st && quic_selected()->stream.reset) + { + quic_selected()->stream.reset(st, err); + } +} + +/** + * Free a stream handle. + * @param st Stream to free; NULL is ignored. + */ +static inline void quic_stream_free(quic_stream* st) +{ + if (st && quic_selected()->stream.free) + { + quic_selected()->stream.free(st); + } +} + +/** + * Credit stream flow control for bytes the application consumed. + * @param st Stream that was read from. + * @param nbytes Bytes consumed. + */ +static inline void quic_stream_consumed(quic_stream* st, size_t nbytes) +{ + if (quic_selected()->stream.consumed) + { + quic_selected()->stream.consumed(st, nbytes); + } +} + +#endif /* QUIC_FUNCS_H */ diff --git a/quic/quic/include/quic_types.h b/quic/quic/include/quic_types.h new file mode 100644 index 0000000..a86363b --- /dev/null +++ b/quic/quic/include/quic_types.h @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#ifndef QUIC_TYPES_H +#define QUIC_TYPES_H + +#include + +#include +#include + +typedef struct quic_engine quic_engine; +typedef struct quic_conn quic_conn; +typedef struct quic_stream quic_stream; + +/** Signed byte count, negative on failure, as the io operations return. */ +typedef ptrdiff_t quic_ssize; + +#define QUIC_ERRLEN 256 + +typedef struct quic_vec +{ + const uint8_t* base; + size_t len; +} quic_vec; + +typedef struct quic_write_result +{ + size_t accepted; + unsigned blocked : 1; + unsigned broken : 1; +} quic_write_result; + +/* + * Capability bits are an append-only contract. A bit is never removed, never + * renumbered and never given a new meaning: callers branch on them, and older + * callers must keep reading the same answer from newer engines. Add a bit only + * for a difference a caller must actually adapt to, and record in the new + * engine's API why it answers differently from the engines already here. + */ +typedef struct quic_caps +{ + unsigned acks_are_write_offsets : 1; +} quic_caps; + +/** Where an engine reads its certificate and private key from. */ +typedef enum quic_cred_kind +{ + QUIC_CRED_FILE, + QUIC_CRED_PEM_BUFFER, +} quic_cred_kind; + +typedef struct quic_cred +{ + quic_cred_kind kind; + union + { + struct + { + const char* cert_path; + const char* key_path; + } file; + struct + { + quic_vec cert; + quic_vec key; + } pem; + } as; +} quic_cred; + +/** Congestion controller to run, where the engine offers a choice. */ +typedef enum quic_cc_algo +{ + QUIC_CC_DEFAULT, + QUIC_CC_RENO, + QUIC_CC_CUBIC, + QUIC_CC_BBR, +} quic_cc_algo; + +/** + * Transport parameters, named as RFC 9000 names them. Fill with + * quic_settings_default() and overwrite what you mean to change; an engine + * maps what it can and documents the rest on its API. + */ +typedef struct quic_settings +{ + uint64_t initial_max_data; + uint64_t initial_max_stream_data_bidi_local; + uint64_t initial_max_stream_data_bidi_remote; + uint64_t initial_max_stream_data_uni; + uint64_t initial_max_streams_bidi; + uint64_t initial_max_streams_uni; + uint64_t max_idle_timeout_ms; + + quic_cc_algo cc_algo; + unsigned enable_datagrams : 1; + unsigned address_validation : 1; +} quic_settings; + +/** + * Events an engine reports upwards. Every hook takes the handle given to + * quic_conn_set_user(); leave a hook unset and the engine skips it. + */ +typedef struct quic_callbacks +{ + void (*stream_acked)(void* user, int64_t stream_id, uint64_t datalen); + void (*handshake_done)(void* user); + void (*stream_reset)(void* user, int64_t stream_id, uint64_t app_error); + void (*key_update)(void* user); + void (*conn_migrated)(void* user, const struct sockaddr* peer, socklen_t peer_len); +} quic_callbacks; + +/** + * How datagrams reach the network, so the engine contract says nothing about + * sockets. quic_io_udp_init() supplies the ordinary UDP implementation. + */ +typedef struct quic_io +{ + quic_ssize (*send)(void* io_ctx, const uint8_t* buf, size_t len, const struct sockaddr* to, socklen_t to_len); + quic_ssize (*recv)(void* io_ctx, uint8_t* buf, size_t cap, struct sockaddr_storage* from, socklen_t* from_len); + int (*local_addr)(void* io_ctx, struct sockaddr_storage* addr, socklen_t* addr_len); + /* Pollable descriptor, or -1 when this transport has none. */ + int (*fd)(void* io_ctx); + void* io_ctx; +} quic_io; + +typedef struct quic_config +{ + quic_cred cred; + quic_settings settings; + quic_callbacks callbacks; + const quic_io* io; +} quic_config; + +/** Everything one engine provides, in one table. */ +typedef struct quic_api +{ + quic_caps caps; + + struct + { + quic_engine* (*create)(const quic_config* cfg, char* err, size_t errlen); + void (*destroy)(quic_engine* engine); + int (*pump)(quic_engine* engine); + void (*want)(quic_engine* engine, int* want_read, int* want_write, int* timeout_ms); + quic_conn* (*accept_conn)(quic_engine* engine); + int (*peer_addr)(quic_engine* engine, quic_conn* conn, struct sockaddr_storage* addr, socklen_t* addr_len); + const char* (*last_error)(quic_engine* engine); + } engine; + + struct + { + int (*prepare)(quic_conn* conn, uint32_t idle_timeout_secs); + void (*set_user)(quic_conn* conn, void* user); + quic_stream* (*open_uni_stream)(quic_conn* conn, int64_t* out_id); + quic_stream* (*accept_stream)(quic_conn* conn); + int (*is_handshake_done)(quic_conn* conn); + int (*is_closed)(quic_conn* conn); + int (*shutdown)(quic_conn* conn, int is_rapid, uint64_t app_error, const char* reason); + void (*free)(quic_conn* conn); + } conn; + + struct + { + int64_t (*id)(quic_stream* st); + quic_write_result (*write)(quic_stream* st, const quic_vec* vec, size_t nvec, int fin); + int (*is_write_blocked)(quic_stream* st); + int (*read)(quic_stream* st, unsigned char* buf, size_t read_size, size_t* nread, int* fin); + void (*is_read_finished)(quic_stream* st, int* read_finished, int* write_finished); + void (*stop_sending)(quic_stream* st, uint64_t err); + void (*reset)(quic_stream* st, uint64_t err); + void (*free)(quic_stream* st); + void (*consumed)(quic_stream* st, size_t nbytes); + } stream; +} quic_api; + +#endif /* QUIC_TYPES_H */ diff --git a/quic/quic/src/detail/quic_check.h b/quic/quic/src/detail/quic_check.h new file mode 100644 index 0000000..e28498c --- /dev/null +++ b/quic/quic/src/detail/quic_check.h @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#ifndef QUIC_CHECK_H +#define QUIC_CHECK_H + +#include +#include + +/** + * Abort unless @p expr_ holds, reporting it on stderr. For invariants a caller + * cannot recover from; recoverable failures belong in the engine's error buffer. + * @param expr_ Condition that must hold. + */ +#define QUIC_CHECK(expr_) \ + do \ + { \ + if (!(expr_)) \ + { \ + fprintf(stderr, "quic: check failed: %s at %s:%d\n", #expr_, __FILE__, __LINE__); \ + abort(); \ + } \ + } while (0) + +#endif /* QUIC_CHECK_H */ diff --git a/quic/quic/src/detail/quic_tls.h b/quic/quic/src/detail/quic_tls.h new file mode 100644 index 0000000..3a077f4 --- /dev/null +++ b/quic/quic/src/detail/quic_tls.h @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#ifndef QUIC_TLS_H +#define QUIC_TLS_H + +#include + +#include "quic_types.h" + +/** + * Build the TLS context both engines serve from: certificate and key from + * @p cfg, "h3" as the only ALPN protocol, and a key log when SSLKEYLOGFILE is + * set. The method decides who owns the QUIC framing, so the OpenSSL engine + * passes OSSL_QUIC_server_method() and ngtcp2 passes TLS_server_method(). + * @param method TLS method the context is created with. + * @param cfg Configuration supplying the certificate and key paths. + * @param err Buffer receiving the reason on failure; may be NULL. + * @param errlen Capacity of @p err. + * @return New context, or NULL on failure. + */ +SSL_CTX* quic_tls_ctx_create(const SSL_METHOD* method, const quic_config* cfg, char* err, size_t errlen); + +/** + * Record a message in a caller-supplied error buffer, appending the OpenSSL + * error queue's own text when it has any. + * @param err Buffer to write to; NULL is ignored. + * @param errlen Capacity of @p err. + * @param fmt printf-style format for the message. + */ +void quic_tls_error(char* err, size_t errlen, const char* fmt, ...); + +#endif /* QUIC_TLS_H */ diff --git a/quic/quic/src/quic_io_udp.c b/quic/quic/src/quic_io_udp.c new file mode 100644 index 0000000..bf35442 --- /dev/null +++ b/quic/quic/src/quic_io_udp.c @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include + +#include +#include + +#include "detail/quic_check.h" +#include "quic.h" + +/* The fd is borrowed: the caller opened it and closes it. */ +static int io_fd(void* io_ctx) +{ + return (int)(intptr_t)io_ctx; +} + +static quic_ssize io_send(void* io_ctx, const uint8_t* buf, size_t len, const struct sockaddr* to, socklen_t to_len) +{ + ssize_t n; + do + { + n = sendto(io_fd(io_ctx), buf, len, 0, to, to_len); + } while (n < 0 && errno == EINTR); + return (quic_ssize)n; +} + +static quic_ssize io_recv(void* io_ctx, uint8_t* buf, size_t cap, struct sockaddr_storage* from, socklen_t* from_len) +{ + ssize_t n; + *from_len = (socklen_t)sizeof(*from); + do + { + n = recvfrom(io_fd(io_ctx), buf, cap, 0, (struct sockaddr*)from, from_len); + } while (n < 0 && errno == EINTR); + return (quic_ssize)n; +} + +static int io_local_addr(void* io_ctx, struct sockaddr_storage* addr, socklen_t* addr_len) +{ + *addr_len = (socklen_t)sizeof(*addr); + return getsockname(io_fd(io_ctx), (struct sockaddr*)addr, addr_len) == 0; +} + +void quic_io_udp_init(quic_io* io, int fd) +{ + QUIC_CHECK(io); + io->send = io_send; + io->recv = io_recv; + io->local_addr = io_local_addr; + io->fd = io_fd; + io->io_ctx = (void*)(intptr_t)fd; +} diff --git a/quic/quic/src/quic_registry.c b/quic/quic/src/quic_registry.c new file mode 100644 index 0000000..cb0672f --- /dev/null +++ b/quic/quic/src/quic_registry.c @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include + +#include "detail/quic_check.h" +#include "quic.h" +#include "quic_null.h" +#include "quic_ossl.h" + +#ifdef H3_ENABLE_NGTCP2 + #include "quic_ngtcp2.h" +#endif + +typedef struct +{ + const char* name; + const quic_api* (*api)(void); +} quic_entry; + +/* Index 0 is the default. Adding an engine is one row, and nothing outside quic/. */ +static const quic_entry engines[] = { + {"openssl", quic_ossl_api}, +#ifdef H3_ENABLE_NGTCP2 + {"ngtcp2", quic_ngtcp2_api}, +#endif + {"null", quic_null_api}, +}; + +#define ENGINE_COUNT (sizeof(engines) / sizeof(engines[0])) + +static size_t active; + +size_t quic_engine_count(void) +{ + return ENGINE_COUNT; +} + +const char* quic_engine_name_at(size_t i) +{ + return i < ENGINE_COUNT ? engines[i].name : NULL; +} + +int quic_select(const char* name) +{ + QUIC_CHECK(name); + for (size_t i = 0; i < ENGINE_COUNT; i++) + { + if (strcasecmp(name, engines[i].name) == 0) + { + active = i; + return 1; + } + } + return 0; +} + +const char* quic_engine_name(void) +{ + return engines[active].name; +} + +const quic_api* quic_selected(void) +{ + return engines[active].api(); +} diff --git a/quic/quic/src/quic_settings.c b/quic/quic/src/quic_settings.c new file mode 100644 index 0000000..48f8e43 --- /dev/null +++ b/quic/quic/src/quic_settings.c @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include "quic.h" + +void quic_settings_default(quic_settings* s) +{ + if (!s) + { + return; + } + *s = (quic_settings){ + .initial_max_data = 1024 * 1024, + .initial_max_stream_data_bidi_local = 256 * 1024, + .initial_max_stream_data_bidi_remote = 256 * 1024, + .initial_max_stream_data_uni = 256 * 1024, + .initial_max_streams_bidi = 128, + .initial_max_streams_uni = 8, + .max_idle_timeout_ms = 30 * 1000, + .cc_algo = QUIC_CC_DEFAULT, + .enable_datagrams = 0, + .address_validation = 1, + }; +} diff --git a/quic/quic/src/quic_tls.c b/quic/quic/src/quic_tls.c new file mode 100644 index 0000000..db0ece9 --- /dev/null +++ b/quic/quic/src/quic_tls.c @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#include +#include +#include + +#include +#include +#include + +#include "detail/quic_check.h" +#include "detail/quic_tls.h" + +void quic_tls_error(char* err, size_t errlen, const char* fmt, ...) +{ + if (!err || errlen == 0) + { + ERR_clear_error(); + return; + } + + va_list ap; + va_start(ap, fmt); + int n = vsnprintf(err, errlen, fmt, ap); + va_end(ap); + + unsigned long code = ERR_get_error(); + if (code != 0 && n > 0 && (size_t)n + 2 < errlen) + { + char detail[QUIC_ERRLEN] = {0}; + ERR_error_string_n(code, detail, sizeof(detail)); + snprintf(err + n, errlen - (size_t)n, ": %s", detail); + } + ERR_clear_error(); +} + +static int quic_tls_alpn_select_cb(SSL* ssl, const unsigned char** out, unsigned char* outlen, const unsigned char* in, unsigned int inlen, void* arg) +{ + static const unsigned char h3[] = "\x02h3"; + (void)ssl; + (void)arg; + + if (SSL_select_next_proto((unsigned char**)out, outlen, h3, sizeof(h3) - 1, in, inlen) == OPENSSL_NPN_NEGOTIATED) + { + return SSL_TLSEXT_ERR_OK; + } + return SSL_TLSEXT_ERR_NOACK; +} + +static void quic_tls_keylog_cb(const SSL* ssl, const char* line) +{ + (void)ssl; + const char* path = getenv("SSLKEYLOGFILE"); + FILE* f = path ? fopen(path, "a") : NULL; + if (f) + { + fprintf(f, "%s\n", line); + fclose(f); + } +} + +static int quic_tls_use_pem(SSL_CTX* ssl_ctx, const quic_vec* cert, const quic_vec* key) +{ + BIO* cbio = BIO_new_mem_buf(cert->base, (int)cert->len); + BIO* kbio = BIO_new_mem_buf(key->base, (int)key->len); + X509* x = cbio ? PEM_read_bio_X509(cbio, NULL, NULL, NULL) : NULL; + EVP_PKEY* pk = kbio ? PEM_read_bio_PrivateKey(kbio, NULL, NULL, NULL) : NULL; + int ok = x && pk && SSL_CTX_use_certificate(ssl_ctx, x) > 0 && SSL_CTX_use_PrivateKey(ssl_ctx, pk) > 0; + X509_free(x); + EVP_PKEY_free(pk); + BIO_free(cbio); + BIO_free(kbio); + return ok; +} + +static int quic_tls_use_cred(SSL_CTX* ssl_ctx, const quic_cred* cred, char* err, size_t errlen) +{ + if (cred->kind == QUIC_CRED_PEM_BUFFER) + { + if (!quic_tls_use_pem(ssl_ctx, &cred->as.pem.cert, &cred->as.pem.key)) + { + quic_tls_error(err, errlen, "loading the certificate or private key from memory failed"); + return 0; + } + return 1; + } + if (SSL_CTX_use_certificate_chain_file(ssl_ctx, cred->as.file.cert_path) <= 0 || SSL_CTX_use_PrivateKey_file(ssl_ctx, cred->as.file.key_path, SSL_FILETYPE_PEM) <= 0) + { + quic_tls_error(err, errlen, "loading the certificate or private key failed"); + return 0; + } + return 1; +} + +SSL_CTX* quic_tls_ctx_create(const SSL_METHOD* method, const quic_config* cfg, char* err, size_t errlen) +{ + QUIC_CHECK(method); + QUIC_CHECK(cfg); + + SSL_CTX* ssl_ctx = SSL_CTX_new(method); + if (!ssl_ctx) + { + quic_tls_error(err, errlen, "SSL_CTX_new failed"); + return NULL; + } + + SSL_CTX_set_min_proto_version(ssl_ctx, TLS1_3_VERSION); + SSL_CTX_set_max_proto_version(ssl_ctx, TLS1_3_VERSION); + + if (!quic_tls_use_cred(ssl_ctx, &cfg->cred, err, errlen)) + { + SSL_CTX_free(ssl_ctx); + return NULL; + } + + SSL_CTX_set_alpn_select_cb(ssl_ctx, quic_tls_alpn_select_cb, NULL); + if (getenv("SSLKEYLOGFILE")) + { + SSL_CTX_set_keylog_callback(ssl_ctx, quic_tls_keylog_cb); + } + return ssl_ctx; +} diff --git a/quic/third-party/ngtcp2 b/quic/third-party/ngtcp2 new file mode 160000 index 0000000..f9e9ff0 --- /dev/null +++ b/quic/third-party/ngtcp2 @@ -0,0 +1 @@ +Subproject commit f9e9ff01ad2c8116bc09de4f644b0028a61486a6 diff --git a/quic/third-party/openssl b/quic/third-party/openssl new file mode 160000 index 0000000..88cdff9 --- /dev/null +++ b/quic/third-party/openssl @@ -0,0 +1 @@ +Subproject commit 88cdff90e4af0f0b6732ba4bb395b0e8a831edb7 diff --git a/scripts/generate_docs.sh b/scripts/generate_docs.sh new file mode 100755 index 0000000..cdcbd26 --- /dev/null +++ b/scripts/generate_docs.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "==> [1/3] Doxygen: C sources -> XML" +mkdir -p build/doxygen +doxygen docs/site/Doxyfile + +echo "==> [2/3] doxybook2: XML -> Markdown (docs/site/pages/api)" +DOXYBOOK2="${DOXYBOOK2:-doxybook2}" +if ! command -v "$DOXYBOOK2" >/dev/null 2>&1 && [ -x "$HOME/.local/bin/doxybook2" ]; then + DOXYBOOK2="$HOME/.local/bin/doxybook2" +fi +rm -rf docs/site/pages/api +mkdir -p docs/site/pages/api +"$DOXYBOOK2" --input build/doxygen/xml --output docs/site/pages/api --config docs/site/doxybook_config.json \ + --templates docs/site/doxybook-templates + +find docs/site/pages/api -name '*.md' -exec sed -i 's/^```cpp$/```c/' {} + + +find docs/site/pages/api -name '*.md' -exec sed -i 's/^## Classes$/## Structs/' {} + + +find docs/site/pages/api -name '*.md' -exec sed -i -E 's/#(file|dir)-[^)]*\)/)/g' {} + + +echo "==> [3/3] static site: Markdown -> docs/site/build/" +if command -v zensical >/dev/null 2>&1; then + (cd docs/site && zensical build -f zensical.toml --clean --strict) +else + ZENSICAL_SPEC="$(grep -m1 -E '^zensical' docs/site/requirements.txt || echo zensical)" + (cd docs/site && uvx --from "$ZENSICAL_SPEC" zensical build -f zensical.toml --clean --strict) +fi + +echo "Done. Preview with 'cd docs/site && zensical serve -f zensical.toml' or open docs/site/build/index.html." diff --git a/scripts/release.sh b/scripts/release.sh index f07c876..a2f4455 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,51 +1,80 @@ #!/usr/bin/env bash set -euo pipefail -# scripts/release.sh -# Usage: ./scripts/release.sh -# Builds the project and generates signed release artifacts in build-release/dist/ +usage() { + echo "Usage: scripts/release.sh" + echo "" + echo "Build the release artifacts into build-release/dist/. CPack writes a .sha256" + echo "next to each one, and everything left in that directory is a release asset." + echo "" + echo "Tags vX.Y.Z from CMakeLists.txt once the artifacts are built, so a failed" + echo "build leaves no tag behind. An existing tag is kept unless you say so. The" + echo "tag is not pushed: \`git push origin vX.Y.Z\` is what triggers gh release." + echo "" + echo " -h, --help Show this help and exit." + echo "" + echo "See docs/release-process.md for the full workflow." +} -repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || { - echo "error: run this script from a Git worktree" >&2 - exit 1 +die() { + echo "error: $1" >&2 + exit 1 } -cd "$repo_root" -BUILD_DIR="build-release" -DIST_DIR="${BUILD_DIR}/dist" +while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + usage + exit 0 + ;; + *) + echo "error: unknown option '$1'" >&2 + usage >&2 + exit 2 + ;; + esac +done + +cd "$(git rev-parse --show-toplevel)" -echo "==> Configuring and building release artifacts..." -cmake -S . -B "$BUILD_DIR" -DCMAKE_BUILD_TYPE=Release -G Ninja -cmake --build "$BUILD_DIR" -cmake --build "$BUILD_DIR" --target release -- -j"$(nproc)" +version=$(sed -n 's/^project(mod_http3 VERSION \(.*\))$/\1/p' CMakeLists.txt) -if [[ ! -d "$DIST_DIR" ]]; then - echo "error: build failed or dist directory not found" >&2 - exit 1 +if [[ -z $version ]]; then + die "no version found in CMakeLists.txt" fi -echo "==> Generating checksums and signatures..." -cd "$DIST_DIR" +if [[ -n $(git status --porcelain) ]]; then + die "working tree is dirty, commit before tagging v$version" +fi + +force=() + +if git rev-parse -q --verify "refs/tags/v$version" >/dev/null; then + if [[ ! -t 0 ]]; then + die "tag v$version already exists" + fi + + read -r -p "tag v$version already exists. overwrite it? [y/N] " reply -# Ensure clean slate for signatures -rm -f *.asc + if [[ $reply != [Yy]* ]]; then + echo "aborted, v$version left as it was" >&2 + exit 1 + fi -GPG_OPTS=("--batch" "--yes" "--detach-sign" "--armor") -if [[ -n "${GPG_KEY:-}" ]]; then - GPG_OPTS+=("--local-user" "$GPG_KEY") + force=(-f) fi -# Hash and sign each artifact -shopt -s nullglob -for artifact in mod_http3[-_]*; do - if [[ "$artifact" == *.sha256 || "$artifact" == *.asc ]]; then - continue - fi - - echo " -> Processing $artifact" - sha256sum "$artifact" > "${artifact}.sha256" - gpg "${GPG_OPTS[@]}" --output "${artifact}.asc" "$artifact" -done +cmake -S . -B build-release -DCMAKE_BUILD_TYPE=Release -G Ninja + +rm -rf build-release/dist + +cmake --build build-release --target release -- -j"$(nproc)" + +rm -rf build-release/dist/_CPack_Packages + +git tag "${force[@]}" -a "v$version" -m "mod_http3 $version" + +echo "" +echo "mod_http3 $version" -echo "==> Done. Release artifacts are available in ${DIST_DIR}/" -ls -lh +ls -1sh build-release/dist diff --git a/site/CONTRIBUTING.md b/site/CONTRIBUTING.md new file mode 120000 index 0000000..44fcc63 --- /dev/null +++ b/site/CONTRIBUTING.md @@ -0,0 +1 @@ +../CONTRIBUTING.md \ No newline at end of file diff --git a/site/SECURITY.md b/site/SECURITY.md new file mode 120000 index 0000000..9d57138 --- /dev/null +++ b/site/SECURITY.md @@ -0,0 +1 @@ +../SECURITY.md \ No newline at end of file diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..790ff9f --- /dev/null +++ b/site/index.html @@ -0,0 +1,71 @@ + + + + + + mod_http3 - HTTP/3 for Apache + + + + + + +
+
+

Next-Generation HTTP for Apache

+

mod_http3 brings the performance and security of HTTP/3 and QUIC to the Apache HTTP Server. Built for modern web applications.

+ +
+
+ + + + diff --git a/site/main.js b/site/main.js new file mode 100644 index 0000000..d743eda --- /dev/null +++ b/site/main.js @@ -0,0 +1,33 @@ +document.addEventListener('DOMContentLoaded', () => { + // Handling dropdown toggles on mobile or click + const dropdownToggles = document.querySelectorAll('.dropdown-toggle'); + + dropdownToggles.forEach(toggle => { + toggle.addEventListener('click', (e) => { + e.preventDefault(); + const parent = toggle.closest('.nav-dropdown'); + const menu = parent.querySelector('.dropdown-menu'); + + // Toggle active state + const isActive = parent.classList.contains('active'); + + // Close all other dropdowns + document.querySelectorAll('.nav-dropdown').forEach(dropdown => { + dropdown.classList.remove('active'); + }); + + if (!isActive) { + parent.classList.add('active'); + } + }); + }); + + // Close dropdowns when clicking outside + document.addEventListener('click', (e) => { + if (!e.target.closest('.nav-dropdown')) { + document.querySelectorAll('.nav-dropdown').forEach(dropdown => { + dropdown.classList.remove('active'); + }); + } + }); +}); diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..afaa042 --- /dev/null +++ b/site/style.css @@ -0,0 +1,318 @@ +:root { + /* Color Palette */ + --bg-color: #0f172a; + --text-primary: #f8fafc; + --text-secondary: #94a3b8; + --primary-color: #6366f1; + --primary-hover: #4f46e5; + --nav-bg: rgba(15, 23, 42, 0.75); + --border-color: rgba(255, 255, 255, 0.1); + + /* Effects */ + --glass-blur: 12px; + --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + background-color: var(--bg-color); + color: var(--text-primary); + line-height: 1.5; + min-height: 100vh; + background-image: + radial-gradient(circle at 15% 50%, rgba(99, 102, 241, 0.15) 0%, transparent 50%), + radial-gradient(circle at 85% 30%, rgba(168, 85, 247, 0.15) 0%, transparent 50%); +} + +/* Navbar */ +.navbar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 72px; + background: var(--nav-bg); + backdrop-filter: blur(var(--glass-blur)); + -webkit-backdrop-filter: blur(var(--glass-blur)); + border-bottom: 1px solid var(--border-color); + z-index: 1000; +} + +.nav-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 24px; + height: 100%; + display: flex; + align-items: center; + justify-content: space-between; +} + +.nav-brand { + display: flex; + align-items: center; + gap: 12px; + text-decoration: none; + color: var(--text-primary); + font-weight: 700; + font-size: 1.25rem; + transition: var(--transition); +} + +.nav-brand:hover { + color: var(--primary-color); +} + +.brand-icon { + width: 28px; + height: 28px; + color: var(--primary-color); +} + +.nav-menu { + display: flex; + align-items: center; + gap: 8px; +} + +.nav-link { + text-decoration: none; + color: var(--text-secondary); + font-weight: 500; + font-size: 0.95rem; + padding: 8px 16px; + border-radius: 8px; + transition: var(--transition); + display: flex; + align-items: center; + gap: 4px; + background: transparent; + border: none; + cursor: pointer; + font-family: inherit; +} + +.nav-link:hover, .nav-link.active { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.05); +} + +.nav-link.active { + color: var(--primary-color); + background: rgba(99, 102, 241, 0.1); +} + +/* Dropdown */ +.nav-dropdown { + position: relative; +} + +.chevron { + width: 16px; + height: 16px; + transition: var(--transition); +} + +.nav-dropdown:hover .chevron { + transform: rotate(180deg); +} + +.dropdown-menu { + position: absolute; + top: calc(100% + 8px); + left: 50%; + transform: translateX(-50%) translateY(10px); + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 8px; + min-width: 200px; + opacity: 0; + visibility: hidden; + transition: var(--transition); + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3); +} + +.nav-dropdown:hover .dropdown-menu { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(0); +} + +.dropdown-item { + display: block; + padding: 10px 16px; + text-decoration: none; + color: var(--text-secondary); + font-size: 0.9rem; + font-weight: 500; + border-radius: 6px; + transition: var(--transition); +} + +.dropdown-item:hover { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.05); + transform: translateX(4px); +} + +/* Actions */ +.nav-actions { + display: flex; + align-items: center; + gap: 16px; +} + +.btn-github { + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + color: var(--text-primary); + font-weight: 500; + font-size: 0.9rem; + padding: 8px 16px; + border-radius: 9999px; + border: 1px solid var(--border-color); + transition: var(--transition); +} + +.btn-github svg { + width: 18px; + height: 18px; +} + +.btn-github:hover { + background: rgba(255, 255, 255, 0.1); + transform: translateY(-1px); +} + +.mobile-menu-btn { + display: none; + background: none; + border: none; + color: var(--text-primary); + cursor: pointer; +} + +.mobile-menu-btn svg { + width: 24px; + height: 24px; +} + +/* Hero Section */ +.hero { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 0 24px; + padding-top: 72px; +} + +.hero-content { + max-width: 800px; + animation: fadeUp 0.8s ease-out; +} + +.hero h1 { + font-size: clamp(2.5rem, 5vw, 4.5rem); + font-weight: 800; + letter-spacing: -0.02em; + margin-bottom: 24px; + background: linear-gradient(135deg, #fff 0%, #94a3b8 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.hero p { + font-size: clamp(1.1rem, 2vw, 1.25rem); + color: var(--text-secondary); + margin-bottom: 40px; + max-width: 600px; + margin-left: auto; + margin-right: auto; +} + +.hero-cta { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; +} + +.btn-primary, .btn-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 14px 28px; + font-size: 1rem; + font-weight: 600; + text-decoration: none; + border-radius: 9999px; + transition: var(--transition); +} + +.btn-primary { + background: var(--primary-color); + color: white; + border: 1px solid transparent; + box-shadow: 0 4px 14px 0 rgba(99, 102, 241, 0.39); +} + +.btn-primary:hover { + background: var(--primary-hover); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(99, 102, 241, 0.23); +} + +.btn-secondary { + background: transparent; + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.05); + transform: translateY(-2px); +} + +/* Animations */ +@keyframes fadeUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Responsive */ +@media (max-width: 768px) { + .nav-menu, .nav-actions { + display: none; + } + + .mobile-menu-btn { + display: block; + } + + .hero-cta { + flex-direction: column; + width: 100%; + } + + .hero-cta a { + width: 100%; + max-width: 300px; + } +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index db61249..110fed7 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -46,10 +46,17 @@ list(JOIN DSO_MODULES " " DSO_MODULES) list(JOIN MPM_MODULES " " MPM_MODULES) # -- curl for the pytest clients -- -find_program(CURL_BIN NAMES curl HINTS "/opt/curl/bin" "/usr/bin" "/usr/local/bin" NO_DEFAULT_PATH) +find_program(CURL_BIN NAMES curl HINTS "${CURL}" "${CURL}/.." "${CURL}/bin" "/opt/curl/bin" "/usr/bin" "/usr/local/bin" NO_DEFAULT_PATH NO_CACHE) if(NOT CURL_BIN) message(WARNING "[test] curl not found - pytest clients will use 'curl' from PATH at runtime") set(CURL_BIN "curl") +else() + execute_process(COMMAND "${CURL_BIN}" -V + OUTPUT_VARIABLE _CURL_RESULT OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) + if(NOT _CURL_RESULT MATCHES "HTTP3") + message(WARNING + "[test] ${CURL_BIN} is built without HTTP/3 support") + endif() endif() # -- pyhttpd config -- @@ -75,6 +82,12 @@ endif() file(GLOB_RECURSE test_sources CONFIGURE_DEPENDS "unit/*.c") add_executable(${PROJECT_NAME}_tests ${test_sources}) target_link_libraries(${PROJECT_NAME}_tests PRIVATE ${PROJECT_NAME}-deps) + +# ngtcp2 is private to quic/, so the dependency test asks for it by name. +if(ENABLE_NGTCP2) + target_link_libraries(${PROJECT_NAME}_tests PRIVATE ngtcp2) + target_compile_definitions(${PROJECT_NAME}_tests PRIVATE H3_ENABLE_NGTCP2) +endif() target_include_directories(${PROJECT_NAME}_tests PRIVATE ${CMAKE_SOURCE_DIR}/mod_http3/include ${CMAKE_CURRENT_SOURCE_DIR}/unit) # -- register with CTest -- diff --git a/test/http3/env.py b/test/http3/env.py index 73d3b0a..6a7d4e8 100644 --- a/test/http3/env.py +++ b/test/http3/env.py @@ -87,6 +87,8 @@ def add_vhost_test1( h3_alt_svc_max_age=None, h3_handshake_timeout=None, h3_idle_timeout=None, + h3_address_validation=None, + h3_quic_engine=None, extra_lines=None ): self.start_vhost( @@ -120,6 +122,12 @@ def add_vhost_test1( self.add(f"H3HandshakeTimeout {h3_handshake_timeout}") if h3_idle_timeout is not None: self.add(f"H3IdleTimeout {h3_idle_timeout}") + if h3_address_validation is not None: + val = "on" if h3_address_validation is True else ("off" if h3_address_validation is False else h3_address_validation) + self.add(f"H3AddressValidation {val}") + engine = h3_quic_engine or os.environ.get("H3_QUIC_ENGINE") + if engine: + self.add(f"H3QuicEngine {engine}") self.add("Protocols h3 http/1.1") for line in extra_lines or []: diff --git a/test/http3/test_006_graceful_shutdown.py b/test/http3/test_006_graceful_shutdown.py index 7917162..9498146 100644 --- a/test/http3/test_006_graceful_shutdown.py +++ b/test/http3/test_006_graceful_shutdown.py @@ -1,4 +1,5 @@ import re +import time from concurrent.futures import ThreadPoolExecutor import pytest @@ -14,6 +15,10 @@ def _class_scope(self, env): H3Conf(env).add_vhost_test1().install() assert env.apache_restart() == 0 + @pytest.mark.xfail( + reason="races the reload against session setup and the UDP port handover", + strict=False, + ) def test_001_goaway_sent_on_graceful_restart(self, env): url = env.mkurl("https", "test1", "/index.html") @@ -37,9 +42,13 @@ def do_get(_i): # Server must serve requests successfully after restart. assert env.is_live() - import time - time.sleep(1.5) - r = env.curl_get(url, options=["--http3-only", "-k"]) + # is_live() only proves the TCP listener is back; the UDP port is re-acquired asynchronously. + deadline = time.monotonic() + 30 + while True: + r = env.curl_get(url, options=["--http3-only", "-k"]) + if r.exit_code == 0 or time.monotonic() >= deadline: + break + time.sleep(0.5) assert r.exit_code == 0, r.stderr + r.stdout assert r.response["status"] == 200 assert r.response["protocol"] == "HTTP/3" diff --git a/test/http3/test_011_status.py b/test/http3/test_011_status.py index fefaac3..2486a64 100644 --- a/test/http3/test_011_status.py +++ b/test/http3/test_011_status.py @@ -1,3 +1,4 @@ +import os import pytest import json from .env import H3Conf @@ -24,6 +25,7 @@ def test_001_status_endpoint(self, env): assert r.response["status"] == 200 stats = json.loads(r.response["body"]) + assert stats["quic_backend"] == os.environ.get("H3_QUIC_ENGINE", "openssl") assert "live_workers" in stats assert "total_connections" in stats assert "total_streams" in stats diff --git a/test/http3/test_012_scheme.py b/test/http3/test_012_scheme.py index 5b9edbd..e987ecc 100644 --- a/test/http3/test_012_scheme.py +++ b/test/http3/test_012_scheme.py @@ -2,8 +2,7 @@ class TestScheme: - """HTTP/3 requests must be treated as TLS by httpd: https scheme in - self-referential redirects and the standard TLS environment for scripts.""" + """HTTP/3 requests must be treated as TLS: https in redirects, standard TLS env for scripts.""" @pytest.fixture(autouse=True, scope="class") def _class_scope(self, env): @@ -13,8 +12,7 @@ def _class_scope(self, env): assert env.apache_restart() == 0 def test_001_redirect_keeps_https_scheme(self, env): - # mod_dir issues a self-referential redirect for a directory - # without a trailing slash; it must not downgrade to http://. + # mod_dir's self-referential redirect for a slashless directory must not downgrade to http://. url = env.mkurl("https", "test1", "/subdir") r = env.curl_get(url, options=["--http3-only", "-k"]) assert r.exit_code == 0, r.stderr + r.stdout diff --git a/test/http3/test_013_conn_headers.py b/test/http3/test_013_conn_headers.py index 01ab881..7ef7676 100644 --- a/test/http3/test_013_conn_headers.py +++ b/test/http3/test_013_conn_headers.py @@ -2,9 +2,7 @@ class TestConnectionHeaders: - """Connection-specific response headers are forbidden in HTTP/3 field - sections (RFC 9114 §4.2); clients hard-fail streams that carry them. - mod_http3 must strip them no matter what handlers or config set.""" + """RFC 9114 4.2 forbids connection-specific headers in HTTP/3, so mod_http3 must always strip them.""" @pytest.fixture(autouse=True, scope="class") def _class_scope(self, env): diff --git a/test/http3/test_013_vhosts.py b/test/http3/test_013_vhosts.py index 2ca7c90..877363a 100644 --- a/test/http3/test_013_vhosts.py +++ b/test/http3/test_013_vhosts.py @@ -2,8 +2,7 @@ class TestVhosts: - """Name-based virtual hosts must be selected from :authority over HTTP/3, - not pinned to the H3-enabled base vhost.""" + """Name-based vhosts must be selected from :authority, not pinned to the H3-enabled base vhost.""" @pytest.fixture(autouse=True, scope="class") def _class_scope(self, env): diff --git a/test/http3/test_014_retry.py b/test/http3/test_014_retry.py index 0a10248..d1b7278 100644 --- a/test/http3/test_014_retry.py +++ b/test/http3/test_014_retry.py @@ -14,83 +14,72 @@ def _is_quic_v1_retry(datagram: bytes) -> bool: return len(datagram) >= 5 and datagram[0] & 0xC0 == 0xC0 and (datagram[0] >> 4) & 0x03 == 0x03 and datagram[1:5] == b"\x00\x00\x00\x01" +def _exchange(env, copies=1): + """Send `copies` of one Initial packet and collect everything sent back.""" + config = QuicConfiguration( + is_client=True, + alpn_protocols=H3_ALPN, + verify_mode=ssl.CERT_NONE, + server_name=f"test1.{env.http_tld}", + ) + quic = QuicConnection(configuration=config) + now = time.monotonic() + target = (env.http_addr, env.https_port) + quic.connect(target, now=now) + outgoing = quic.datagrams_to_send(now=now) + assert len(outgoing) == 1 + initial, _ = outgoing[0] + assert len(initial) >= 1200 + + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.bind(("127.0.0.1", 0)) + sock.settimeout(0.1) + for _ in range(copies): + sock.sendto(initial, target) + + responses = [] + deadline = time.monotonic() + 0.75 + while time.monotonic() < deadline: + try: + responses.append(sock.recv(65535)) + except TimeoutError: + pass + + retries = [packet for packet in responses if _is_quic_v1_retry(packet)] + return responses, retries + + +def _restart(env, **directives): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(**directives).install() + assert env.apache_restart() == 0 + + class TestRetry: @pytest.fixture(autouse=True, scope="class") def _class_scope(self, env): - from .env import H3Conf - - H3Conf(env).add_vhost_test1().install() - assert env.apache_restart() == 0 + _restart(env) def test_001_one_initial_produces_one_retry(self, env): """Do not mistake a client retransmit for duplicate server output.""" - authority = f"test1.{env.http_tld}" - config = QuicConfiguration( - is_client=True, - alpn_protocols=H3_ALPN, - verify_mode=ssl.CERT_NONE, - server_name=authority, - ) - quic = QuicConnection(configuration=config) - now = time.monotonic() - target = (env.http_addr, env.https_port) - quic.connect(target, now=now) - outgoing = quic.datagrams_to_send(now=now) - assert len(outgoing) == 1 - initial, _ = outgoing[0] - assert len(initial) >= 1200 - - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: - sock.bind(("127.0.0.1", 0)) - sock.settimeout(0.1) - sock.sendto(initial, target) - - responses = [] - deadline = time.monotonic() + 0.75 - while time.monotonic() < deadline: - try: - responses.append(sock.recv(65535)) - except TimeoutError: - pass - - retries = [packet for packet in responses if _is_quic_v1_retry(packet)] + responses, retries = _exchange(env) assert len(retries) == 1, [packet[:8].hex() for packet in responses] def test_002_duplicate_initial_produces_at_most_two_retries(self, env): - """Send the same Initial packet twice in quick succession. A stateless - QUIC server may answer each received Initial with its own Retry, so one - or two Retries is compliant; anything more indicates duplicate output.""" - authority = f"test1.{env.http_tld}" - config = QuicConfiguration( - is_client=True, - alpn_protocols=H3_ALPN, - verify_mode=ssl.CERT_NONE, - server_name=authority, - ) - quic = QuicConnection(configuration=config) - now = time.monotonic() - target = (env.http_addr, env.https_port) - quic.connect(target, now=now) - outgoing = quic.datagrams_to_send(now=now) - assert len(outgoing) == 1 - initial, _ = outgoing[0] - assert len(initial) >= 1200 - - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: - sock.bind(("127.0.0.1", 0)) - sock.settimeout(0.1) - # Two identical Initials, back to back. - sock.sendto(initial, target) - sock.sendto(initial, target) + """Two identical Initials: one Retry each is compliant, more means duplicate output.""" + responses, retries = _exchange(env, copies=2) + assert len(retries) in (1, 2), [packet[:8].hex() for packet in responses] - responses = [] - deadline = time.monotonic() + 0.75 - while time.monotonic() < deadline: - try: - responses.append(sock.recv(65535)) - except TimeoutError: - pass - retries = [packet for packet in responses if _is_quic_v1_retry(packet)] - # One Retry per received Initial is compliant; more than that is not. - assert len(retries) in (1, 2), [packet[:8].hex() for packet in responses] +class TestRetryDisabled: + """H3AddressValidation off must suppress the Retry entirely.""" + + @pytest.fixture(autouse=True, scope="class") + def _class_scope(self, env): + _restart(env, h3_address_validation=False) + + def test_001_no_retry_when_address_validation_off(self, env): + responses, retries = _exchange(env) + assert retries == [], [packet[:8].hex() for packet in responses] + assert len(responses) > 0, "server sent nothing at all" diff --git a/test/http3/test_015_large_download.py b/test/http3/test_015_large_download.py index b2a5496..c21206c 100644 --- a/test/http3/test_015_large_download.py +++ b/test/http3/test_015_large_download.py @@ -1,14 +1,19 @@ +import asyncio import hashlib import os +import ssl import pytest +from aioquic.asyncio.client import connect +from aioquic.h3.connection import H3_ALPN +from aioquic.quic.configuration import QuicConfiguration + +from .test_008_stream_multiplexing import _MuxClient + class TestLargeDownload: - """A response larger than the QUIC stream send buffer, pulled by a - rate-limited client, keeps the connection under write backpressure for - the whole transfer. The send path must block/unblock the stream instead - of spinning, and the payload must arrive intact.""" + """A rate-limited pull of an oversized response must arrive intact under write backpressure.""" PAYLOAD_SIZE = 2 * 1024 * 1024 @@ -41,3 +46,41 @@ def test_002_full_speed_download_intact(self, env): assert r.exit_code == 0, r.stderr assert r.response["status"] == 200 assert hashlib.sha256(r.response["body"]).hexdigest() == self.expected_sha256 + + +class TestFileBucketDownload: + """With EnableMMAP off apr_bucket_read splits the file bucket, and the filter must follow the tail.""" + + PAYLOAD_SIZE = 64 * 1024 + + @pytest.fixture(autouse=True, scope="class") + def _class_scope(self, env): + from .env import H3Conf + + payload = os.urandom(self.PAYLOAD_SIZE) + with open(os.path.join(env.server_docs_dir, "nommap.bin"), "wb") as fd: + fd.write(payload) + type(self).expected_sha256 = hashlib.sha256(payload).hexdigest() + + H3Conf(env).add_vhost_test1(extra_lines=["EnableMMAP Off"]).install() + assert env.apache_restart() == 0 + + def test_001_body_survives_the_bucket_split(self, env): + authority = f"test1.{env.http_tld}" + + async def run(): + config = QuicConfiguration( + is_client=True, alpn_protocols=H3_ALPN, verify_mode=ssl.CERT_NONE, server_name=authority + ) + async with connect( + env.http_addr, env.https_port, configuration=config, create_protocol=_MuxClient + ) as client: + sid = client.start_get(authority, "/nommap.bin") + client.transmit() + await asyncio.wait_for(client.done[sid].wait(), timeout=15) + return client.status[sid], client.body[sid] + + status, body = asyncio.run(run()) + assert status == "200" + assert len(body) == self.PAYLOAD_SIZE + assert hashlib.sha256(body).hexdigest() == self.expected_sha256 diff --git a/test/http3/test_016_max_response_body.py b/test/http3/test_016_max_response_body.py index cff93ec..5eaf5a2 100644 --- a/test/http3/test_016_max_response_body.py +++ b/test/http3/test_016_max_response_body.py @@ -4,9 +4,7 @@ class TestMaxResponseBody: - """H3MaxResponseBodySize bounds in-memory response buffering. It is an - opt-in safety valve (default: unlimited) so existing large-response - deployments are unaffected unless explicitly configured.""" + """H3MaxResponseBodySize bounds in-memory buffering; an opt-in valve, unlimited by default.""" @pytest.fixture(autouse=True, scope="class") def _class_scope(self, env): diff --git a/test/http3/test_017_streaming_response.py b/test/http3/test_017_streaming_response.py index 16b5f53..fb52ad7 100644 --- a/test/http3/test_017_streaming_response.py +++ b/test/http3/test_017_streaming_response.py @@ -73,8 +73,7 @@ def test_002_client_abort_wakes_response_producer(self, env): assert result.returncode != 0 assert b"first-chunk\n" in result.stdout - # The rate-limited client leaves the producer blocked behind the - # bounded queue. Resetting the stream must wake that worker promptly. + # The producer is blocked behind the bounded queue; resetting the stream must wake it promptly. active_url = env.mkurl("https", "test1", "/h3-active-floods") deadline = time.monotonic() + 5 while True: diff --git a/test/http3/test_019_head.py b/test/http3/test_019_head.py new file mode 100644 index 0000000..9376b12 --- /dev/null +++ b/test/http3/test_019_head.py @@ -0,0 +1,37 @@ +import pytest + +from .env import H3Conf + + +class TestHead: + """A HEAD response repeats the headers its GET would send and carries no body.""" + + @pytest.fixture(autouse=True, scope="class") + def _class_scope(self, env): + H3Conf(env).add_vhost_test1().install() + assert env.apache_restart() == 0 + + def _head(self, env, url): + return env.curl_get(url, options=[ + "--http3-only", "-k", "-I", "-o", "/dev/null", "-w", "%{http_code} %{size_download}", + ]) + + def test_001_head_sends_no_body(self, env): + r = self._head(env, env.mkurl("https", "test1", "/index.html")) + assert r.exit_code == 0, r.stderr + assert r.stdout.strip() == "200 0" + + def test_002_head_still_reports_the_get_content_length(self, env): + url = env.mkurl("https", "test1", "/index.html") + get = env.curl_get(url, options=["--http3-only", "-k"]) + head = env.curl_get(url, options=["--http3-only", "-k", "-I"]) + assert get.exit_code == 0, get.stderr + assert head.exit_code == 0, head.stderr + assert f"content-length: {len(get.response['body'])}" in head.stdout.lower() + + def test_003_head_on_a_buffered_response(self, env): + H3Conf(env).add_vhost_test1(h3_max_response_body_size=1048576).install() + assert env.apache_restart() == 0 + r = self._head(env, env.mkurl("https", "test1", "/index.html")) + assert r.exit_code == 0, r.stderr + assert r.stdout.strip() == "200 0" diff --git a/test/http3/test_020_malformed.py b/test/http3/test_020_malformed.py new file mode 100644 index 0000000..d5371ca --- /dev/null +++ b/test/http3/test_020_malformed.py @@ -0,0 +1,199 @@ +"""Malformed HTTP/3 requests are stream errors, not connection errors. + +RFC 9114 4.1.2: a request that violates the header field rules of 4.x must be +rejected with a stream error of type H3_MESSAGE_ERROR while the connection +keeps serving other streams. These tests send deliberately malformed requests +(raw QPACK-encoded, bypassing client-side validation) and then verify the same +connection still answers a well-formed request. +""" + +import asyncio +import ssl + +import pytest + +from .env import H3Conf + +from aioquic.asyncio.client import connect +from aioquic.asyncio.protocol import QuicConnectionProtocol +from aioquic.buffer import encode_uint_var +from aioquic.h3.connection import H3_ALPN, H3Connection +from aioquic.h3.events import DataReceived, HeadersReceived +from aioquic.quic import events as quic_events +from aioquic.quic.configuration import QuicConfiguration + +import pylsqpack + +# RFC 9114 section 8.1. +H3_MESSAGE_ERROR = 0x010E +FRAME_TYPE_DATA = 0x0 +FRAME_TYPE_HEADERS = 0x1 + + +class _RawRequestClient(QuicConnectionProtocol): + """HTTP/3 client that can send arbitrary, even malformed, header blocks.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._http = H3Connection(self._quic) + self._encoder = pylsqpack.Encoder() + self.reset_codes = {} + self._reset_event = asyncio.Event() + self.status = None + self._response_done = asyncio.Event() + + def quic_event_received(self, event): + if isinstance(event, quic_events.StreamReset): + self.reset_codes[event.stream_id] = event.error_code + self._reset_event.set() + return + for h3_event in self._http.handle_event(event): + if isinstance(h3_event, HeadersReceived): + for k, v in h3_event.headers: + if k == b":status": + self.status = v.decode() + if h3_event.stream_ended: + self._response_done.set() + elif isinstance(h3_event, DataReceived): + if h3_event.stream_ended: + self._response_done.set() + + async def send_raw_request(self, headers, data=None, timeout=5.0): + """Encode headers verbatim and return the stream reset code.""" + stream_id = self._quic.get_next_available_stream_id() + _, payload = self._encoder.encode(stream_id, headers) + frame = encode_uint_var(FRAME_TYPE_HEADERS) + encode_uint_var(len(payload)) + payload + if data is not None: + frame += encode_uint_var(FRAME_TYPE_DATA) + encode_uint_var(len(data)) + data + self._quic.send_stream_data(stream_id, frame, end_stream=True) + self.transmit() + await asyncio.wait_for(self._reset_event.wait(), timeout=timeout) + return self.reset_codes.get(stream_id) + + async def get(self, authority, path, headers=None, timeout=5.0): + """Send a well-formed GET and return the response status.""" + stream_id = self._quic.get_next_available_stream_id() + h = [ + (b":method", b"GET"), + (b":scheme", b"https"), + (b":authority", authority.encode()), + (b":path", path.encode()), + ] + h += headers or [] + self._http.send_headers(stream_id=stream_id, headers=h, end_stream=True) + self.transmit() + await asyncio.wait_for(self._response_done.wait(), timeout=timeout) + return self.status + + +class TestMalformedRequests: + @pytest.fixture(autouse=True, scope="class") + def _class_scope(self, env): + H3Conf(env).add_vhost_test1().install() + assert env.apache_restart() == 0 + + def _authority(self, env): + return f"test1.{env.http_tld}" + + def _reject_then_serve(self, env, malformed_headers, data=None): + """Send a malformed request, then a valid one on the same connection. + + Returns (reset_code, follow_up_status). + """ + authority = self._authority(env) + + async def run(): + config = QuicConfiguration( + is_client=True, + alpn_protocols=H3_ALPN, + verify_mode=ssl.CERT_NONE, + server_name=authority, + ) + async with connect( + env.http_addr, + env.https_port, + configuration=config, + create_protocol=_RawRequestClient, + ) as client: + code = await client.send_raw_request(malformed_headers, data=data) + status = await client.get(authority, "/index.html") + return code, status + + return asyncio.run(run()) + + def _valid_headers(self, env): + return [ + (b":method", b"GET"), + (b":scheme", b"https"), + (b":authority", self._authority(env).encode()), + (b":path", b"/index.html"), + ] + + def test_001_missing_method(self, env): + headers = [h for h in self._valid_headers(env) if h[0] != b":method"] + code, status = self._reject_then_serve(env, headers) + assert code == H3_MESSAGE_ERROR, f"reset code 0x{code:X}" + assert status == "200", "connection must survive the malformed stream" + + def test_002_missing_path(self, env): + headers = [h for h in self._valid_headers(env) if h[0] != b":path"] + code, status = self._reject_then_serve(env, headers) + assert code == H3_MESSAGE_ERROR, f"reset code 0x{code:X}" + assert status == "200", "connection must survive the malformed stream" + + def test_003_missing_scheme(self, env): + headers = [h for h in self._valid_headers(env) if h[0] != b":scheme"] + code, status = self._reject_then_serve(env, headers) + assert code == H3_MESSAGE_ERROR, f"reset code 0x{code:X}" + assert status == "200", "connection must survive the malformed stream" + + def test_004_missing_authority_and_host(self, env): + headers = [h for h in self._valid_headers(env) if h[0] != b":authority"] + code, status = self._reject_then_serve(env, headers) + assert code == H3_MESSAGE_ERROR, f"reset code 0x{code:X}" + assert status == "200", "connection must survive the malformed stream" + + def test_005_duplicate_pseudo_header(self, env): + headers = self._valid_headers(env) + [(b":method", b"GET")] + code, status = self._reject_then_serve(env, headers) + assert code == H3_MESSAGE_ERROR, f"reset code 0x{code:X}" + assert status == "200", "connection must survive the malformed stream" + + def test_006_connection_specific_header(self, env): + headers = self._valid_headers(env) + [(b"connection", b"close")] + code, status = self._reject_then_serve(env, headers) + assert code == H3_MESSAGE_ERROR, f"reset code 0x{code:X}" + assert status == "200", "connection must survive the malformed stream" + + def test_007_te_other_than_trailers(self, env): + headers = self._valid_headers(env) + [(b"te", b"gzip")] + code, status = self._reject_then_serve(env, headers) + assert code == H3_MESSAGE_ERROR, f"reset code 0x{code:X}" + assert status == "200", "connection must survive the malformed stream" + + def test_008_content_length_mismatch(self, env): + headers = self._valid_headers(env) + [(b"content-length", b"10")] + code, status = self._reject_then_serve(env, headers, data=b"abc") + assert code == H3_MESSAGE_ERROR, f"reset code 0x{code:X}" + assert status == "200", "connection must survive the malformed stream" + + def test_009_te_trailers_is_allowed(self, env): + authority = self._authority(env) + + async def run(): + config = QuicConfiguration( + is_client=True, + alpn_protocols=H3_ALPN, + verify_mode=ssl.CERT_NONE, + server_name=authority, + ) + async with connect( + env.http_addr, + env.https_port, + configuration=config, + create_protocol=_RawRequestClient, + ) as client: + return await client.get(authority, "/index.html", headers=[(b"te", b"trailers")]) + + status = asyncio.run(run()) + assert status == "200" diff --git a/test/requirements.txt b/test/requirements.txt index 01eec2f..108c3cc 100644 --- a/test/requirements.txt +++ b/test/requirements.txt @@ -1,2 +1,3 @@ pytest aioquic +cryptography diff --git a/test/unit/dependencies/nghttp3_test.c b/test/unit/dependencies/nghttp3_test.c index df71f89..94f8a12 100644 --- a/test/unit/dependencies/nghttp3_test.c +++ b/test/unit/dependencies/nghttp3_test.c @@ -23,8 +23,8 @@ static void test_nghttp3_version(void) { const nghttp3_info* info = nghttp3_version(NGHTTP3_VERSION_AGE); sput_fail_unless(info != NULL, "nghttp3_version returns info"); - sput_fail_unless(info->version_num == 0x011100, "nghttp3 version == 1.17.0"); - sput_fail_unless(strcmp(info->version_str, "1.17.0") == 0, "nghttp3 version string == 1.17.0"); + sput_fail_unless(info->version_num == 0x011200, "nghttp3 version == 1.18.0"); + sput_fail_unless(strcmp(info->version_str, "1.18.0") == 0, "nghttp3 version string == 1.18.0"); } static void test_nghttp3_settings_default(void) diff --git a/test/unit/dependencies/ngtcp2_test.c b/test/unit/dependencies/ngtcp2_test.c new file mode 100644 index 0000000..9af6df6 --- /dev/null +++ b/test/unit/dependencies/ngtcp2_test.c @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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. + */ + +#ifdef H3_ENABLE_NGTCP2 + + #include "sput.h" + #include + #include + #include + +static void test_ngtcp2_version(void) +{ + const ngtcp2_info* info = ngtcp2_version(NGTCP2_VERSION_AGE); + sput_fail_unless(info != NULL, "ngtcp2_version returns info"); + sput_fail_unless(info->version_num == 0x011900, "ngtcp2 version == 1.25.0"); + sput_fail_unless(strcmp(info->version_str, "1.25.0") == 0, "ngtcp2 version string == 1.25.0"); +} + +static void test_ngtcp2_settings_default(void) +{ + ngtcp2_settings settings; + ngtcp2_settings_default(&settings); + sput_fail_unless(settings.max_tx_udp_payload_size > 0, "max_tx_udp_payload_size > 0"); + + ngtcp2_transport_params params; + ngtcp2_transport_params_default(¶ms); + sput_fail_unless(params.active_connection_id_limit > 0, "active_connection_id_limit > 0"); +} + +/* Both the crypto helper and the OpenSSL API it needs are found by symbol probe. */ +static void test_ngtcp2_crypto_ossl_available(void) +{ + sput_fail_unless(ngtcp2_crypto_ossl_init() == 0, "ngtcp2_crypto_ossl_init succeeds"); + + ngtcp2_crypto_ossl_ctx* ctx = NULL; + sput_fail_unless(ngtcp2_crypto_ossl_ctx_new(&ctx, NULL) == 0, "ossl ctx created"); + sput_fail_unless(ctx != NULL, "ossl ctx not NULL"); + ngtcp2_crypto_ossl_ctx_del(ctx); +} + +void run_ngtcp2_tests(void) +{ + sput_run_test(test_ngtcp2_version); + sput_run_test(test_ngtcp2_settings_default); + sput_run_test(test_ngtcp2_crypto_ossl_available); +} + +#endif /* H3_ENABLE_NGTCP2 */ diff --git a/test/unit/dependencies/suite.c b/test/unit/dependencies/suite.c index 96ed0a1..56204e1 100644 --- a/test/unit/dependencies/suite.c +++ b/test/unit/dependencies/suite.c @@ -36,4 +36,9 @@ void run_dependencies_suite(void) extern void run_openssl_tests(void); run_openssl_tests(); + +#ifdef H3_ENABLE_NGTCP2 + extern void run_ngtcp2_tests(void); + run_ngtcp2_tests(); +#endif }