From 8c47033d75cda9b2917091dd0a2b20d3d3ad4fcc Mon Sep 17 00:00:00 2001 From: Tarek Ibrahim Date: Wed, 22 Jul 2026 07:12:56 -0400 Subject: [PATCH 01/12] v0.0.42 - add static site --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .github/workflows/docs-check.yml | 54 +++ .github/workflows/pages.yml | 63 ++++ .gitignore | 2 + CHANGES | 5 + CMakeLists.txt | 2 +- CONTRIBUTING.md | 4 +- INSTALL | 6 +- LAYOUT | 2 - README.md | 4 +- docs/architecture.md | 30 ++ docs/build.md | 37 ++ CONFIGURATION.md => docs/configuration.md | 4 +- .../configuration_httpd.md | 2 +- docs/deploy.md | 40 +++ docs/limits.md | 23 ++ docs/site/.readthedocs.yaml | 25 ++ docs/site/Doxyfile | 18 + docs/site/doxybook-templates/footer.tmpl | 1 + docs/site/doxybook-templates/index.tmpl | 10 + docs/site/doxybook-templates/index_files.tmpl | 5 + docs/site/doxybook_config.json | 16 + docs/site/pages/api.md | 9 + docs/site/pages/architecture.md | 1 + docs/site/pages/browser-testing.md | 1 + docs/site/pages/build.md | 1 + docs/site/pages/coding-standards.md | 1 + docs/site/pages/configuration.md | 1 + docs/site/pages/configuration_httpd.md | 1 + docs/site/pages/contributing.md | 1 + docs/site/pages/curl-testing.md | 1 + docs/site/pages/deploy.md | 1 + docs/site/pages/examples-testing.md | 1 + docs/site/pages/examples.md | 1 + docs/site/pages/index.md | 27 ++ docs/site/pages/limits.md | 1 + docs/site/pages/releases.md | 1 + docs/site/pages/security.md | 1 + docs/site/pages/verify.md | 1 + docs/site/requirements.txt | 1 + docs/site/zensical.toml | 85 +++++ docs/verify.md | 26 ++ mod_http3/include/h3_version.h | 4 +- mod_http3/src/h3_request.c | 6 +- scripts/generate_docs.sh | 35 ++ site/CONTRIBUTING.md | 1 + site/SECURITY.md | 1 + site/index.html | 71 ++++ site/main.js | 33 ++ site/style.css | 318 ++++++++++++++++++ 50 files changed, 970 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/docs-check.yml create mode 100644 .github/workflows/pages.yml create mode 100644 docs/architecture.md create mode 100644 docs/build.md rename CONFIGURATION.md => docs/configuration.md (97%) rename CONFIGURATION_HTTPD.md => docs/configuration_httpd.md (99%) create mode 100644 docs/deploy.md create mode 100644 docs/limits.md create mode 100644 docs/site/.readthedocs.yaml create mode 100644 docs/site/Doxyfile create mode 100644 docs/site/doxybook-templates/footer.tmpl create mode 100644 docs/site/doxybook-templates/index.tmpl create mode 100644 docs/site/doxybook-templates/index_files.tmpl create mode 100644 docs/site/doxybook_config.json create mode 100644 docs/site/pages/api.md create mode 120000 docs/site/pages/architecture.md create mode 120000 docs/site/pages/browser-testing.md create mode 120000 docs/site/pages/build.md create mode 120000 docs/site/pages/coding-standards.md create mode 120000 docs/site/pages/configuration.md create mode 120000 docs/site/pages/configuration_httpd.md create mode 120000 docs/site/pages/contributing.md create mode 120000 docs/site/pages/curl-testing.md create mode 120000 docs/site/pages/deploy.md create mode 120000 docs/site/pages/examples-testing.md create mode 120000 docs/site/pages/examples.md create mode 100644 docs/site/pages/index.md create mode 120000 docs/site/pages/limits.md create mode 120000 docs/site/pages/releases.md create mode 120000 docs/site/pages/security.md create mode 120000 docs/site/pages/verify.md create mode 100644 docs/site/requirements.txt create mode 100644 docs/site/zensical.toml create mode 100644 docs/verify.md create mode 100755 scripts/generate_docs.sh create mode 120000 site/CONTRIBUTING.md create mode 120000 site/SECURITY.md create mode 100644 site/index.html create mode 100644 site/main.js create mode 100644 site/style.css diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 54e2702..d71b399 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -27,7 +27,7 @@ A clear description of what you expected to happen. **Environment (please complete the following information):** - OS: [e.g. Ubuntu 22.04] - Compiler Version: [e.g. GCC 11.3.0] - - mod_http3 Version or Commit hash: [e.g. 0.1.0 or abc1234] + - mod_http3 Version: [e.g. 0.1.0 or abc1234] - httpd Version: [e.g. 2.5.0-trunk] - OpenSSL Version: [e.g. 3.5.0] - nghttp3 Version: [e.g. 1.17.0] diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml new file mode 100644 index 0000000..33f9645 --- /dev/null +++ b/.github/workflows/docs-check.yml @@ -0,0 +1,54 @@ +name: Documentation check + +on: + pull_request: + paths: + - ".github/workflows/docs-check.yml" + - "docs/*.md" + - "docs/site/**" + - "scripts/generate_docs.sh" + - "mod_http3/include/**" + - "mod_http3/src/**" + - "CONTRIBUTING.md" + - "SECURITY.md" + push: + branches: [trunk] + paths: + - ".github/workflows/docs-check.yml" + - "docs/*.md" + - "docs/site/**" + - "scripts/generate_docs.sh" + - "mod_http3/include/**" + - "mod_http3/src/**" + - "CONTRIBUTING.md" + - "SECURITY.md" + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Install Doxygen + run: sudo apt-get update && sudo apt-get install -y doxygen + + - name: Install doxybook2 + run: | + 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 + sudo install -m 0755 "$(find /tmp/doxybook2 -type f -name doxybook2 | head -1)" /usr/local/bin/doxybook2 + doxybook2 --version + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install site generator + run: pip install -r docs/site/requirements.txt + + - name: Build documentation (strict) + run: bash scripts/generate_docs.sh diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..efa7f6f --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,63 @@ +name: Deploy documentation + +on: + push: + branches: [trunk] + paths: + - ".github/workflows/pages.yml" + - "docs/*.md" + - "docs/site/**" + - "scripts/generate_docs.sh" + - "mod_http3/include/**" + - "mod_http3/src/**" + - "CONTRIBUTING.md" + - "SECURITY.md" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Install Doxygen + run: sudo apt-get update && sudo apt-get install -y doxygen + + - name: Install doxybook2 + run: | + 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 + sudo install -m 0755 "$(find /tmp/doxybook2 -type f -name doxybook2 | head -1)" /usr/local/bin/doxybook2 + doxybook2 --version + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install site generator + run: pip install -r docs/site/requirements.txt + + - name: Build documentation + run: bash scripts/generate_docs.sh + + - uses: actions/configure-pages@v5 + with: + enablement: true + - uses: actions/upload-pages-artifact@v3 + with: + path: docs/site/build + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index af63b6b..4911404 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,5 @@ AGENTS.md certs/ keys/ config.ini +docs/site/build/ +docs/site/pages/api/ diff --git a/CHANGES b/CHANGES index e421161..92b70b1 100644 --- a/CHANGES +++ b/CHANGES @@ -3,6 +3,11 @@ mod_http3 changes Changes are listed most recent first. Security-related entries always appear at the top of their release block. +v0.0.42 (2026-07-22) +-------------------- + *) Added the static documentation site. + [Tarek Ibrahim ] + v0.0.41 (2026-07-21) -------------------- *) Streamed response bodies through a bounded per-stream queue. diff --git a/CMakeLists.txt b/CMakeLists.txt index 529ff84..2bad1c6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.26) -project(mod_http3 VERSION 0.0.41) +project(mod_http3 VERSION 0.0.42) # -- Compiler and Build Type Checks -- if(NOT CMAKE_C_COMPILER_ID STREQUAL "GNU") diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b6e5e5..9d1622d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,7 +79,7 @@ git checkout -b feature/my-new-feature - Explain *why* the change is being made and what it does. ### Coding Standards -All code must adhere to the project's [Coding Standards](docs/coding-standards.md). You can format your code using the provided `.clang-format` configuration: +All code must adhere to the project's Coding Standards (see docs/coding-standards.md). You can format your code using the provided `.clang-format` configuration: ```sh clang-format -i mod_http3/src/*.c mod_http3/include/*.h ``` @@ -104,4 +104,4 @@ When a security vulnerability is identified: ## Release and Backports -For detailed information on the versioning scheme, release candidates, testing, and voting on releases, see the [Release Process Guide](docs/release-process.md). +For detailed information on the versioning scheme, release candidates, testing, and voting on releases, see the Release Process Guide in docs/release-process.md. diff --git a/INSTALL b/INSTALL index 1a9bf04..77c43c1 100644 --- a/INSTALL +++ b/INSTALL @@ -134,7 +134,7 @@ mod_http3 injects the Alt-Svc response header automatically (H3AltSvc, on by default). That header is how TCP clients - learn HTTP/3 is available on UDP. See CONFIGURATION_HTTPD.md + learn HTTP/3 is available on UDP. See docs/configuration_httpd.md for H3AltSvc, H3AltSvcMaxAge, and the other H3* directives. 4. Open the UDP port: @@ -166,5 +166,5 @@ Further reading --------------- - CONFIGURATION.md - Advanced build options - CONFIGURATION_HTTPD.md - httpd directive reference + docs/configuration.md - Advanced build options + docs/configuration_httpd.md - httpd directive reference diff --git a/LAYOUT b/LAYOUT index 7509c0a..47af6f8 100644 --- a/LAYOUT +++ b/LAYOUT @@ -12,8 +12,6 @@ Last updated: 2026-07-13 AUTHORS .................. Copyright holders and individual contributors CHANGES .................. Chronological release changelog CMakeLists.txt ........... Top-level CMake build definition - CONFIGURATION.md ......... Advanced CMake build options and internals - CONFIGURATION_HTTPD.md ... httpd directive reference for mod_http3 CONTRIBUTING.md .......... Community governance, roles, voting, and workflow COPYING .................. License pointer INSTALL .................. Build, package, and deployment instructions diff --git a/README.md b/README.md index 3447ae5..2cf165a 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ See [INSTALL](INSTALL) for full build instructions. | `ENABLE_UBSAN` | `OFF` | UB Sanitizer (requires `Debug`) | | `ENABLE_WERROR` | `OFF` | Treat warnings as errors | -See [CONFIGURATION.md](CONFIGURATION.md) for advanced options and dependency internals. +See [Build Configuration](docs/configuration.md) for advanced options and dependency internals. ## Deploy @@ -78,7 +78,7 @@ Listen 4433 https ``` -HTTP/3 support is advertised to clients automatically via an `Alt-Svc` response header (`H3AltSvc`, on by default). See [CONFIGURATION_HTTPD.md](CONFIGURATION_HTTPD.md) for all directives and [INSTALL](INSTALL) for complete deployment steps. +HTTP/3 support is advertised to clients automatically via an `Alt-Svc` response header (`H3AltSvc`, on by default). See [httpd Configuration](docs/configuration_httpd.md) for all directives and [INSTALL](INSTALL) for complete deployment steps. ## Contributing diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..c769960 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,30 @@ +# Architecture + +`mod_http3` adds a QUIC/HTTP/3 path to Apache httpd while retaining Apache's request processing, virtual hosts, filters, and configuration model. + +```mermaid +flowchart LR + Client[HTTP/3 client] -->|UDP QUIC + TLS 1.3| OpenSSL[OpenSSL QUIC] + OpenSSL --> nghttp3[nghttp3 HTTP/3] + nghttp3 --> Module[mod_http3] + Module --> httpd[Apache httpd request pipeline] + httpd --> Module + Module --> nghttp3 + nghttp3 --> OpenSSL +``` + +## Layers + +- **OpenSSL** owns QUIC transport and TLS 1.3. +- **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. + +## 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 unavailable from the active OpenSSL integration. + +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..a16d39d --- /dev/null +++ b/docs/build.md @@ -0,0 +1,37 @@ +# 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 --recursive +cmake -B build +cmake --build build +``` + +The module is written to `build/lib/mod_http3.so`. + +## Requirements + +| Dependency | Minimum | +| --- | --- | +| OpenSSL | 3.5.0 with QUIC support | +| Apache httpd | MMN 20211221 | +| APR | 1.7.0 | +| APR-util | 1.6.0 | +| nghttp3 | 1.17.0 | + +Distribution-provided httpd packages usually have an older MMN and are rejected. Use the default source build or provide compatible custom prefixes. + +## 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 97% rename from CONFIGURATION.md rename to docs/configuration.md index 643434c..b365f4b 100644 --- a/CONFIGURATION.md +++ b/docs/configuration.md @@ -2,9 +2,9 @@ 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). ## Build Commands diff --git a/CONFIGURATION_HTTPD.md b/docs/configuration_httpd.md similarity index 99% rename from CONFIGURATION_HTTPD.md rename to docs/configuration_httpd.md index 695d8c8..1575b2d 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 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/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/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..8e2a189 --- /dev/null +++ b/docs/site/Doxyfile @@ -0,0 +1,18 @@ +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 +RECURSIVE = YES +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/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/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..38a098e --- /dev/null +++ b/docs/site/zensical.toml @@ -0,0 +1,85 @@ +[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" }, + { "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" }, + ] }, + { "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/mod_http3/include/h3_version.h b/mod_http3/include/h3_version.h index 96f2bb4..56e7377 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 42 // 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.42" #endif /* H3_VERSION_H */ diff --git a/mod_http3/src/h3_request.c b/mod_http3/src/h3_request.c index fce5105..34af09a 100644 --- a/mod_http3/src/h3_request.c +++ b/mod_http3/src/h3_request.c @@ -44,7 +44,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; 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/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; + } +} From 6fd55d68d4cb784c649b598d4dc1b46909f41e9f Mon Sep 17 00:00:00 2001 From: Tarek Ibrahim Date: Mon, 27 Jul 2026 19:49:08 -0400 Subject: [PATCH 02/12] v0.0.43 - release automation --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .github/workflows/release.yml | 44 +++++++++++++++ CHANGES | 8 +++ CMakeLists.txt | 2 +- CONTRIBUTING.md | 2 +- INSTALL | 6 +++ VERSIONING | 14 +++-- cmake/modules/apr.cmake | 10 ++-- cmake/modules/apu.cmake | 10 ++-- cmake/packaging.cmake | 12 ++++- container/README.md | 9 ++-- docs/build.md | 3 ++ docs/release-process.md | 81 +++++++++------------------- mod_http3/include/h3_version.h | 4 +- scripts/release.sh | 71 +++++++++++------------- test/CMakeLists.txt | 9 +++- 16 files changed, 158 insertions(+), 129 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d71b399..ef60d89 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -8,7 +8,7 @@ assignees: '' 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. 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 --tag 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/* - ``` + `--tag` (`-t`) reads the version from `CMakeLists.txt` and creates the matching annotated `vX.Y.Z` tag before building. + 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 [Release workflow](../.github/workflows/release.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/mod_http3/include/h3_version.h b/mod_http3/include/h3_version.h index 56e7377..e3a1f96 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 42 +#define MOD_HTTP3_VERSION_PATCH 43 // 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.42" +#define MOD_HTTP3_VERSION_STRING "0.0.43" #endif /* H3_VERSION_H */ diff --git a/scripts/release.sh b/scripts/release.sh index f07c876..e66fbcb 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,51 +1,40 @@ #!/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/ - -repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || { - echo "error: run this script from a Git worktree" >&2 - exit 1 +usage() { + echo "Usage: scripts/release.sh [-t]" + 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 " -t, --tag Tag the current commit vX.Y.Z. The tag is not pushed:" + echo " \`git push origin vX.Y.Z\` is what triggers gh release" + echo " -h, --help Show this help and exit." + echo "" + echo "See docs/release-process.md for the full workflow." } -cd "$repo_root" - -BUILD_DIR="build-release" -DIST_DIR="${BUILD_DIR}/dist" -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)" - -if [[ ! -d "$DIST_DIR" ]]; then - echo "error: build failed or dist directory not found" >&2 - exit 1 -fi - -echo "==> Generating checksums and signatures..." -cd "$DIST_DIR" +tag=0 +while [[ $# -gt 0 ]]; do + case "$1" in + -t | --tag) tag=1; shift ;; + -h | --help) usage; exit 0 ;; + *) echo "error: unknown option '$1'" >&2; usage >&2; exit 2 ;; + esac +done -# Ensure clean slate for signatures -rm -f *.asc +cd "$(git rev-parse --show-toplevel)" -GPG_OPTS=("--batch" "--yes" "--detach-sign" "--armor") -if [[ -n "${GPG_KEY:-}" ]]; then - GPG_OPTS+=("--local-user" "$GPG_KEY") +if [[ $tag -eq 1 ]]; then + version="$(sed -n 's/^project(mod_http3 VERSION \(.*\))$/\1/p' CMakeLists.txt)" + [[ -n "$version" ]] \ + || { echo "error: no version found in CMakeLists.txt" >&2; exit 1; } + git tag -a "v$version" -m "mod_http3 $version" 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 -echo "==> Done. Release artifacts are available in ${DIST_DIR}/" -ls -lh +ls -1 build-release/dist diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index db61249..f29fb47 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) 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 -- From a638c717a329d995ededf154e3fb1872029786ed Mon Sep 17 00:00:00 2001 From: t4r3k <142579274+machine-moon@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:44:42 -0400 Subject: [PATCH 03/12] v0.0.44 - H3AddressValidation directive --- CHANGES | 9 +++ CMakeLists.txt | 2 +- docs/configuration_httpd.md | 10 +++ mod_http3/include/h3_config.h | 1 + mod_http3/include/h3_version.h | 4 +- mod_http3/src/h3_config.c | 16 ++++- mod_http3/src/h3_io.c | 7 +- test/http3/env.py | 4 ++ test/http3/test_014_retry.py | 127 +++++++++++++++------------------ 9 files changed, 104 insertions(+), 76 deletions(-) diff --git a/CHANGES b/CHANGES index e65e8f8..f674c44 100644 --- a/CHANGES +++ b/CHANGES @@ -3,6 +3,15 @@ mod_http3 changes Changes are listed most recent first. Security-related entries always appear at the top of their release block. +v0.0.44 (2026-07-28) +-------------------- + *) Added the H3AddressValidation directive to turn QUIC Retry address + validation off for handshakes that must complete in one round trip. + [Tarek Ibrahim ] + + *) Reworked the Retry tests to exercise both settings of the directive. + [Tarek Ibrahim ] + v0.0.43 (2026-07-24) -------------------- *) Fixed apu/apr linking on debian systems. diff --git a/CMakeLists.txt b/CMakeLists.txt index 5e1b816..768af99 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.26) -project(mod_http3 VERSION 0.0.43) +project(mod_http3 VERSION 0.0.44) # -- Compiler and Build Type Checks -- if(NOT CMAKE_C_COMPILER_ID STREQUAL "GNU") diff --git a/docs/configuration_httpd.md b/docs/configuration_httpd.md index 1575b2d..1b85d20 100644 --- a/docs/configuration_httpd.md +++ b/docs/configuration_httpd.md @@ -117,6 +117,16 @@ 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. + ## VirtualHost Configuration ### Port Detection diff --git a/mod_http3/include/h3_config.h b/mod_http3/include/h3_config.h index 297c9c0..1ccee03 100644 --- a/mod_http3/include/h3_config.h +++ b/mod_http3/include/h3_config.h @@ -47,6 +47,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_version.h b/mod_http3/include/h3_version.h index e3a1f96..6e038ba 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 43 +#define MOD_HTTP3_VERSION_PATCH 44 // 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.43" +#define MOD_HTTP3_VERSION_STRING "0.0.44" #endif /* H3_VERSION_H */ diff --git a/mod_http3/src/h3_config.c b/mod_http3/src/h3_config.c index fa6cdf4..df8e13d 100644 --- a/mod_http3/src/h3_config.c +++ b/mod_http3/src/h3_config.c @@ -68,6 +68,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; @@ -320,6 +321,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 +400,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; @@ -456,6 +469,7 @@ 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_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_end}; diff --git a/mod_http3/src/h3_io.c b/mod_http3/src/h3_io.c index 3d84c95..635460b 100644 --- a/mod_http3/src/h3_io.c +++ b/mod_http3/src/h3_io.c @@ -297,7 +297,7 @@ int h3_io_at_connection_limit(h3_io_t* io) /* 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) +static apr_status_t build_ssl_listener(h3_io_t* io, const char* cert, const char* key, uint64_t listener_flags) { CHECK(io); CHECK(cert); @@ -324,7 +324,7 @@ static apr_status_t build_ssl_listener(h3_io_t* io, const char* cert, const char { SSL_CTX_set_keylog_callback(io->ssl_ctx, h3_keylog_cb); } - io->ssl_listener = SSL_new_listener(io->ssl_ctx, 0); + io->ssl_listener = SSL_new_listener(io->ssl_ctx, listener_flags); 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) @@ -422,7 +422,8 @@ 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) + uint64_t listener_flags = conf->h3_address_validation == H3_FLAG_OFF ? SSL_LISTENER_FLAG_NO_VALIDATE : 0; + if (build_ssl_listener(io, conf->h3_cert_path, conf->h3_key_path, listener_flags) != APR_SUCCESS) { ap_log_error(APLOG_MARK, APLOG_ERR, 0, s, "listener setup failed"); teardown(io); diff --git a/test/http3/env.py b/test/http3/env.py index 73d3b0a..1dd1aab 100644 --- a/test/http3/env.py +++ b/test/http3/env.py @@ -87,6 +87,7 @@ def add_vhost_test1( h3_alt_svc_max_age=None, h3_handshake_timeout=None, h3_idle_timeout=None, + h3_address_validation=None, extra_lines=None ): self.start_vhost( @@ -120,6 +121,9 @@ 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}") self.add("Protocols h3 http/1.1") for line in extra_lines or []: 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" From 0a722975dc9ce0a3bf1043971206c603f4fedc76 Mon Sep 17 00:00:00 2001 From: t4r3k <142579274+machine-moon@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:09:07 -0400 Subject: [PATCH 04/12] v0.0.45 - response correctness --- CHANGES | 8 +++++ CMakeLists.txt | 2 +- mod_http3/include/h3_version.h | 4 +-- mod_http3/src/h3_filter.c | 1 + mod_http3/src/h3_request.c | 1 + test/http3/test_015_large_download.py | 51 ++++++++++++++++++++++++--- test/http3/test_019_head.py | 37 +++++++++++++++++++ test/requirements.txt | 1 + 8 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 test/http3/test_019_head.py diff --git a/CHANGES b/CHANGES index f674c44..319a8de 100644 --- a/CHANGES +++ b/CHANGES @@ -3,6 +3,14 @@ mod_http3 changes Changes are listed most recent first. Security-related entries always appear at the top of their release block. +v0.0.45 (2026-07-29) +-------------------- + *) Stopped sending a body on HEAD responses. + [Tarek Ibrahim ] + + *) Fixed response truncation at 8000 bytes. + [Tarek Ibrahim ] + v0.0.44 (2026-07-28) -------------------- *) Added the H3AddressValidation directive to turn QUIC Retry address diff --git a/CMakeLists.txt b/CMakeLists.txt index 768af99..57b247b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.26) -project(mod_http3 VERSION 0.0.44) +project(mod_http3 VERSION 0.0.45) # -- Compiler and Build Type Checks -- if(NOT CMAKE_C_COMPILER_ID STREQUAL "GNU") diff --git a/mod_http3/include/h3_version.h b/mod_http3/include/h3_version.h index 6e038ba..ba93384 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 44 +#define MOD_HTTP3_VERSION_PATCH 45 // 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.44" +#define MOD_HTTP3_VERSION_STRING "0.0.45" #endif /* H3_VERSION_H */ 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_request.c b/mod_http3/src/h3_request.c index 34af09a..2ba33cb 100644 --- a/mod_http3/src/h3_request.c +++ b/mod_http3/src/h3_request.c @@ -243,6 +243,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/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_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/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 From 570c4a7f80e297663bbcf96c5f9d8ec565fc3e1b Mon Sep 17 00:00:00 2001 From: t4r3k <142579274+machine-moon@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:43:18 -0400 Subject: [PATCH 05/12] v0.0.46 - tidy the test suite and release script --- CHANGES | 9 ++++ CMakeLists.txt | 2 +- docs/release-process.md | 6 +-- mod_http3/include/h3_version.h | 4 +- scripts/release.sh | 66 ++++++++++++++++++----- test/CMakeLists.txt | 2 +- test/http3/test_012_scheme.py | 6 +-- test/http3/test_013_conn_headers.py | 4 +- test/http3/test_013_vhosts.py | 3 +- test/http3/test_016_max_response_body.py | 4 +- test/http3/test_017_streaming_response.py | 3 +- 11 files changed, 75 insertions(+), 34 deletions(-) diff --git a/CHANGES b/CHANGES index 319a8de..d8c12e9 100644 --- a/CHANGES +++ b/CHANGES @@ -3,6 +3,15 @@ mod_http3 changes Changes are listed most recent first. Security-related entries always appear at the top of their release block. +v0.0.46 (2026-07-30) +-------------------- + *) Fixed pytest to use -DCURL if provided. + curl the build was configured with. + [Tarek Ibrahim ] + + *) Reworked scripts/release.sh. + [Tarek Ibrahim ] + v0.0.45 (2026-07-29) -------------------- *) Stopped sending a body on HEAD responses. diff --git a/CMakeLists.txt b/CMakeLists.txt index 57b247b..e6c5b73 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.26) -project(mod_http3 VERSION 0.0.45) +project(mod_http3 VERSION 0.0.46) # -- Compiler and Build Type Checks -- if(NOT CMAKE_C_COMPILER_ID STREQUAL "GNU") diff --git a/docs/release-process.md b/docs/release-process.md index 9aa4e48..3c373fe 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -56,7 +56,7 @@ graph TD ./scripts/release.sh git push origin vX.Y.Z-rc1 ``` - Candidate tags carry an `-rcN` suffix, so they are tagged by hand; `scripts/release.sh` builds the artifacts locally so you can inspect them. 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`. + 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`. 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) @@ -79,10 +79,10 @@ graph TD 5. **Publish Approved Release**: Once the vote passes, create and push the final tag. The workflow rebuilds from that tag and publishes the release: ```sh - ./scripts/release.sh --tag + ./scripts/release.sh git push origin vX.Y.Z ``` - `--tag` (`-t`) reads the version from `CMakeLists.txt` and creates the matching annotated `vX.Y.Z` tag before building. + `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**: diff --git a/mod_http3/include/h3_version.h b/mod_http3/include/h3_version.h index ba93384..5fd64dc 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 45 +#define MOD_HTTP3_VERSION_PATCH 46 // 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.45" +#define MOD_HTTP3_VERSION_STRING "0.0.46" #endif /* H3_VERSION_H */ diff --git a/scripts/release.sh b/scripts/release.sh index e66fbcb..a2f4455 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -2,39 +2,79 @@ set -euo pipefail usage() { - echo "Usage: scripts/release.sh [-t]" + 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 " -t, --tag Tag the current commit vX.Y.Z. The tag is not pushed:" - echo " \`git push origin vX.Y.Z\` is what triggers gh release" + 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." } -tag=0 +die() { + echo "error: $1" >&2 + exit 1 +} + while [[ $# -gt 0 ]]; do case "$1" in - -t | --tag) tag=1; shift ;; - -h | --help) usage; exit 0 ;; - *) echo "error: unknown option '$1'" >&2; usage >&2; exit 2 ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "error: unknown option '$1'" >&2 + usage >&2 + exit 2 + ;; esac done cd "$(git rev-parse --show-toplevel)" -if [[ $tag -eq 1 ]]; then - version="$(sed -n 's/^project(mod_http3 VERSION \(.*\))$/\1/p' CMakeLists.txt)" - [[ -n "$version" ]] \ - || { echo "error: no version found in CMakeLists.txt" >&2; exit 1; } - git tag -a "v$version" -m "mod_http3 $version" +version=$(sed -n 's/^project(mod_http3 VERSION \(.*\))$/\1/p' CMakeLists.txt) + +if [[ -z $version ]]; then + die "no version found in CMakeLists.txt" +fi + +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 + + if [[ $reply != [Yy]* ]]; then + echo "aborted, v$version left as it was" >&2 + exit 1 + fi + + force=(-f) fi 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 -ls -1 build-release/dist +git tag "${force[@]}" -a "v$version" -m "mod_http3 $version" + +echo "" +echo "mod_http3 $version" + +ls -1sh build-release/dist diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index f29fb47..63c81c5 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -46,7 +46,7 @@ list(JOIN DSO_MODULES " " DSO_MODULES) list(JOIN MPM_MODULES " " MPM_MODULES) # -- curl for the pytest clients -- -find_program(CURL_BIN NAMES curl HINTS "${CURL}" "${CURL}/.." "${CURL}/bin" "/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") 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_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: From 3ab168e247a4ffade86b3ec610f3589d625f62e9 Mon Sep 17 00:00:00 2001 From: t4r3k <142579274+machine-moon@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:17:22 -0400 Subject: [PATCH 06/12] v0.0.47 - integrate quic interop runner and containers --- .containerignore | 6 +- .dockerignore | 1 + .github/workflows/build.yml | 78 ++++++++++++++ .github/workflows/ci.yml | 71 ++++++++++++ .github/workflows/docs-check.yml | 54 ---------- .github/workflows/interop.yml | 155 +++++++++++++++++++++++++++ .github/workflows/pages.yml | 61 ++++------- .github/workflows/release.yml | 102 +++++++++++++----- .github/workflows/test.yml | 51 +++++++++ CHANGES | 11 ++ CMakeLists.txt | 2 +- container/Containerfile | 47 ++++++-- container/README.md | 31 +++++- container/compose.yml | 8 +- container/entrypoint.sh | 24 +++++ container/httpd.conf | 6 +- docs/containers.md | 178 +++++++++++++++++++++++++++++++ docs/interop.md | 99 +++++++++++++++++ docs/release-process.md | 4 +- docs/site/pages/containers.md | 1 + docs/site/pages/interop.md | 1 + docs/site/zensical.toml | 2 + interop/Containerfile | 46 ++++++++ interop/README.md | 1 + interop/httpd.conf | 68 ++++++++++++ interop/run_endpoint.sh | 25 +++++ mod_http3/include/h3_version.h | 4 +- 27 files changed, 990 insertions(+), 147 deletions(-) create mode 120000 .dockerignore create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/docs-check.yml create mode 100644 .github/workflows/interop.yml create mode 100644 .github/workflows/test.yml create mode 100755 container/entrypoint.sh create mode 100644 docs/containers.md create mode 100644 docs/interop.md create mode 120000 docs/site/pages/containers.md create mode 120000 docs/site/pages/interop.md create mode 100644 interop/Containerfile create mode 120000 interop/README.md create mode 100644 interop/httpd.conf create mode 100755 interop/run_endpoint.sh diff --git a/.containerignore b/.containerignore index 49eed3d..10dc340 100644 --- a/.containerignore +++ b/.containerignore @@ -1,4 +1,6 @@ +.git/ +.ai/ dependencies/*-dist/ -build/ +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/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..afbd806 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,78 @@ +name: build + +on: + workflow_call: + outputs: + image: + description: Image this build published for the commit. + value: ${{ jobs.module.outputs.image }} + +permissions: + contents: read + packages: write + +env: + IMAGE: ghcr.io/${{ github.repository }}:${{ github.sha }} + +jobs: + module: + name: module + runs-on: ubuntu-latest + outputs: + image: ${{ steps.ref.outputs.image }} + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + submodules: recursive + + - name: Set up buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to ghcr + if: github.event_name != 'pull_request' + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Build image + uses: docker/build-push-action@v7 + with: + context: . + file: container/Containerfile + build-args: JOBS=4 + tags: ${{ env.IMAGE }} + load: true + push: ${{ github.event_name != 'pull_request' }} + cache-from: type=gha,scope=module + cache-to: type=gha,scope=module,mode=max + + - name: Check linkage + run: | + docker run --rm --entrypoint ldd "$IMAGE" \ + /src/dependencies/httpd-dist/modules/mod_http3.so | tee ldd.txt + ! grep -q "not found" ldd.txt + + - name: Build artifacts + uses: docker/build-push-action@v7 + with: + context: . + file: container/Containerfile + build-args: JOBS=4 + target: artifacts + outputs: type=local,dest=dist + cache-from: type=gha,scope=module + + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: dist + path: dist/ + if-no-files-found: error + + - name: Export image ref + id: ref + run: echo "image=$IMAGE" >>"$GITHUB_OUTPUT" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..42b165e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +name: ci + +on: + push: + branches: ["**"] + tags: ["v*"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.ref_type != 'tag' }} + +jobs: + version: + name: version + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Match tag to CMakeLists + run: | + version=${GITHUB_REF_NAME#v} + grep -q "^project(mod_http3 VERSION ${version%%-rc*})$" CMakeLists.txt + + build: + name: build + uses: ./.github/workflows/build.yml + permissions: + contents: read + packages: write + + test: + name: test + needs: build + uses: ./.github/workflows/test.yml + permissions: + contents: read + + interop: + name: interop + needs: build + if: github.event_name != 'pull_request' + uses: ./.github/workflows/interop.yml + permissions: + contents: read + packages: write + + pages: + name: pages + needs: build + uses: ./.github/workflows/pages.yml + permissions: + contents: read + + release: + name: release + needs: [version, build, test, interop, pages] + if: github.ref_type == 'tag' + uses: ./.github/workflows/release.yml + permissions: + contents: write + packages: write + pages: write + id-token: write diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml deleted file mode 100644 index 33f9645..0000000 --- a/.github/workflows/docs-check.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Documentation check - -on: - pull_request: - paths: - - ".github/workflows/docs-check.yml" - - "docs/*.md" - - "docs/site/**" - - "scripts/generate_docs.sh" - - "mod_http3/include/**" - - "mod_http3/src/**" - - "CONTRIBUTING.md" - - "SECURITY.md" - push: - branches: [trunk] - paths: - - ".github/workflows/docs-check.yml" - - "docs/*.md" - - "docs/site/**" - - "scripts/generate_docs.sh" - - "mod_http3/include/**" - - "mod_http3/src/**" - - "CONTRIBUTING.md" - - "SECURITY.md" - -permissions: - contents: read - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Doxygen - run: sudo apt-get update && sudo apt-get install -y doxygen - - - name: Install doxybook2 - run: | - 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 - sudo install -m 0755 "$(find /tmp/doxybook2 -type f -name doxybook2 | head -1)" /usr/local/bin/doxybook2 - doxybook2 --version - - - uses: actions/setup-python@v5 - with: - python-version: "3.x" - - - name: Install site generator - run: pip install -r docs/site/requirements.txt - - - name: Build documentation (strict) - run: bash scripts/generate_docs.sh diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml new file mode 100644 index 0000000..07c2b8b --- /dev/null +++ b/.github/workflows/interop.yml @@ -0,0 +1,155 @@ +name: interop + +on: + workflow_call: + +permissions: + contents: read + packages: write + +env: + IMAGE: ghcr.io/${{ github.repository }}-interop:${{ github.sha }} + RUNNER: quic-interop/quic-interop-runner + +jobs: + build: + name: build + runs-on: ubuntu-latest + outputs: + clients: ${{ steps.clients.outputs.list }} + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + submodules: recursive + + - name: Set up buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to ghcr + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Build endpoint + uses: docker/build-push-action@v7 + with: + context: . + file: interop/Containerfile + build-args: JOBS=4 + tags: ${{ env.IMAGE }} + load: true + cache-from: type=gha,scope=interop + cache-to: type=gha,scope=interop,mode=max + + - name: Check exit 127 + run: | + rc=0 + timeout 120 docker run --rm -e ROLE=server -e TESTCASE=bogus "$IMAGE" || rc=$? + test "$rc" -eq 127 + + - name: Publish endpoint + uses: docker/build-push-action@v7 + with: + context: . + file: interop/Containerfile + build-args: JOBS=4 + tags: ${{ env.IMAGE }} + push: true + cache-from: type=gha,scope=interop + + - name: List clients + id: clients + env: + GH_TOKEN: ${{ github.token }} + run: | + list=$(gh api "repos/$RUNNER/contents/implementations_quic.json" \ + -H "Accept: application/vnd.github.raw" | + jq -c '[to_entries[] | select(.value.role != "server") | .key]') + echo "list=$list" | tee -a "$GITHUB_OUTPUT" + + test: + name: test (${{ matrix.client }}) + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + strategy: + fail-fast: false + max-parallel: 4 + matrix: + client: ${{ fromJson(needs.build.outputs.clients) }} + env: + CLIENT: ${{ matrix.client }} + + steps: + - name: Checkout runner + uses: actions/checkout@v7 + with: + repository: ${{ env.RUNNER }} + + - name: Set up docker + uses: docker/setup-docker-action@v5 + with: + version: version=v28.3.0 + + - name: Log in to ghcr + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Set up python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + cache: pip + + - name: Install runner + run: pip install -r requirements.txt + + - name: Install tshark + run: | + sudo add-apt-repository -y ppa:wireshark-dev/nightly + sudo apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends tshark + sudo modprobe ip6table_filter + + - name: Register endpoint + run: | + jq --arg image "$IMAGE" --arg url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \ + '. + {mod_http3: {image: $image, role: "server", url: $url}}' \ + implementations_quic.json >impls.json + mv impls.json implementations_quic.json + + - name: Pull images + run: | + docker pull "$IMAGE" + python pull.py -i "$CLIENT" + + - name: Run matrix + continue-on-error: true + run: python run.py -s mod_http3 -c "$CLIENT" -t http3 -l logs -j results.json + + - name: Report verdict + run: | + result=$(jq -r '.results[0][0].result' results.json) + echo "### $CLIENT — \`$result\`" >>"$GITHUB_STEP_SUMMARY" + case $result in + succeeded) ;; + unsupported) echo "::warning title=$CLIENT::the client has no http3 test case" ;; + *) echo "::error title=$CLIENT::http3 $result"; exit 1 ;; + esac + + - name: Keep logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: interop-logs-${{ matrix.client }} + path: logs/ + retention-days: 14 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index efa7f6f..278d4fc 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,63 +1,44 @@ -name: Deploy documentation +name: pages on: - push: - branches: [trunk] - paths: - - ".github/workflows/pages.yml" - - "docs/*.md" - - "docs/site/**" - - "scripts/generate_docs.sh" - - "mod_http3/include/**" - - "mod_http3/src/**" - - "CONTRIBUTING.md" - - "SECURITY.md" - workflow_dispatch: + workflow_call: permissions: contents: read - pages: write - id-token: write -concurrency: - group: pages - cancel-in-progress: false +env: + DOXYBOOK2_URL: https://github.com/matusnovak/doxybook2/releases/download/v1.5.0/doxybook2-linux-amd64-v1.5.0.zip jobs: - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} + build: + name: build runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - name: Install Doxygen - run: sudo apt-get update && sudo apt-get install -y doxygen + steps: + - name: Checkout + uses: actions/checkout@v7 - - name: Install doxybook2 + - name: Install doxygen run: | - 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 - sudo install -m 0755 "$(find /tmp/doxybook2 -type f -name doxybook2 | head -1)" /usr/local/bin/doxybook2 + sudo apt-get update && sudo apt-get install -y doxygen + curl -fsSL -o /tmp/doxybook2.zip "$DOXYBOOK2_URL" + sudo unzip -oj /tmp/doxybook2.zip bin/doxybook2 -d /usr/local/bin doxybook2 --version - - uses: actions/setup-python@v5 + - name: Set up python + uses: actions/setup-python@v7 with: python-version: "3.x" + cache: pip + cache-dependency-path: docs/site/requirements.txt - - name: Install site generator + - name: Install generator run: pip install -r docs/site/requirements.txt - - name: Build documentation + - name: Build site run: bash scripts/generate_docs.sh - - uses: actions/configure-pages@v5 - with: - enablement: true - - uses: actions/upload-pages-artifact@v3 + - name: Upload site + uses: actions/upload-pages-artifact@v5 with: path: docs/site/build - - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b53d642..4d5342c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,44 +1,88 @@ -name: Release +name: release on: - push: - tags: - - "v*" + workflow_call: permissions: contents: write + packages: write + pages: write + id-token: write jobs: - release: + images: + name: images runs-on: ubuntu-latest - timeout-minutes: 120 + steps: - - uses: actions/checkout@v5 + - name: Log in to ghcr + uses: docker/login-action@v4 with: - submodules: recursive + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} - - name: Verify the tag matches the project version - run: | - version="${GITHUB_REF_NAME#v}" - grep -q "^project(mod_http3 VERSION ${version%%-rc*})$" CMakeLists.txt + - name: Collect tags + id: meta + uses: docker/metadata-action@v6 + with: + images: | + ghcr.io/${{ github.repository }} + ghcr.io/${{ github.repository }}-interop + tags: | + type=semver,pattern={{version}} + type=raw,value=latest,enable=${{ !contains(github.ref_name, '-rc') }} - - name: Install build prerequisites + - name: Retag commit builds + env: + TAGS: ${{ steps.meta.outputs.tags }} run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential cmake ninja-build pkg-config \ - autoconf automake libtool libtool-bin perl python3 \ - libpcre2-dev libxml2-dev libexpat1-dev zlib1g-dev \ - dpkg-dev rpm + while read -r tag; do + docker buildx imagetools create -t "$tag" "${tag%:*}:$GITHUB_SHA" + done <<<"$TAGS" - - name: Build the release artifacts - run: bash scripts/release.sh + pages: + name: pages + needs: images + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + concurrency: + group: pages + cancel-in-progress: false - - name: Publish the GitHub release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - tag="$GITHUB_REF_NAME" - case "$tag" in *-rc*) rc="--draft --prerelease" ;; *) rc="" ;; esac - gh release create "$tag" $rc --generate-notes \ - --title "mod_http3 ${tag#v}" build-release/dist/* + steps: + - name: Configure pages + uses: actions/configure-pages@v6 + with: + enablement: true + + - name: Deploy site + id: deploy + uses: actions/deploy-pages@v5 + + assets: + name: assets + needs: pages + runs-on: ubuntu-latest + + steps: + - name: Read version + id: version + run: echo "value=${GITHUB_REF_NAME#v}" >>"$GITHUB_OUTPUT" + + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + name: dist + path: dist + + - name: Publish release + uses: softprops/action-gh-release@v3 + with: + name: mod_http3 ${{ steps.version.outputs.value }} + files: dist/* + generate_release_notes: true + draft: ${{ contains(github.ref_name, '-rc') }} + prerelease: ${{ contains(github.ref_name, '-rc') }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..63d4e17 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,51 @@ +name: test + +on: + workflow_call: + +permissions: + contents: read + +env: + IMAGE: mod_http3-tester:${{ github.sha }} + +jobs: + suite: + name: suite + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + submodules: recursive + + - name: Set up buildx + uses: docker/setup-buildx-action@v4 + + - name: Build tester + uses: docker/build-push-action@v7 + with: + context: . + file: container/Containerfile + build-args: JOBS=4 + target: tester + tags: ${{ env.IMAGE }} + load: true + cache-from: type=gha,scope=module + + - name: Run unit + run: docker run --rm "$IMAGE" ctest --test-dir /src/build --output-on-failure + + - name: Run pytest + run: | + mkdir -p logs + docker run --rm -v "$PWD/logs:/src/build/test/gen" "$IMAGE" pytest -v + + - name: Keep logs + if: failure() + uses: actions/upload-artifact@v7 + with: + name: test-logs + path: logs/ + retention-days: 14 diff --git a/CHANGES b/CHANGES index d8c12e9..7e1d54d 100644 --- a/CHANGES +++ b/CHANGES @@ -3,6 +3,17 @@ mod_http3 changes Changes are listed most recent first. Security-related entries always appear at the top of their release block. +v0.0.47 (2026-07-31) +-------------------- + *) Added a QUIC Interop Runner endpoint and client matrix in CI. + [Tarek Ibrahim ] + + *) Updated the container image to serve HTTP/3 on its own. + [Tarek Ibrahim ] + + *) Reviewed GitHub Actions workflows. + [Tarek Ibrahim ] + v0.0.46 (2026-07-30) -------------------- *) Fixed pytest to use -DCURL if provided. diff --git a/CMakeLists.txt b/CMakeLists.txt index e6c5b73..2b59cf3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.26) -project(mod_http3 VERSION 0.0.46) +project(mod_http3 VERSION 0.0.47) # -- Compiler and Build Type Checks -- if(NOT CMAKE_C_COMPILER_ID STREQUAL "GNU") diff --git a/container/Containerfile b/container/Containerfile index ceba5aa..58d5976 100644 --- a/container/Containerfile +++ b/container/Containerfile @@ -1,28 +1,57 @@ FROM debian:trixie AS builder RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential cmake git pkg-config python3 perl \ + build-essential cmake ninja-build git pkg-config python3 perl \ libpcre2-dev libxml2-dev libexpat1-dev zlib1g-dev \ liblua5.4-dev libbrotli-dev libnghttp2-dev \ autoconf automake libtool libtool-bin ca-certificates \ + dpkg-dev rpm \ && rm -rf /var/lib/apt/lists/* +ARG JOBS + WORKDIR /src COPY dependencies/ dependencies/ COPY cmake/ cmake/ COPY CMakeLists.txt . -COPY LICENSE . +COPY LICENSE NOTICE AUTHORS CHANGES ./ COPY mod_http3/ mod_http3/ -RUN cmake -B build \ +RUN cmake -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DBUILD_MODULE=ON \ -DBUILD_EXAMPLES=OFF \ -DBUILD_TESTS=OFF \ - -DDEPENDENCIES_PARALLEL=6 + -DDEPENDENCIES_PARALLEL="${JOBS:-$(nproc)}" + +COPY . . + +RUN cmake --build build --target release -j "${JOBS:-$(nproc)}" \ + && rm -rf build/dist/_CPack_Packages + +FROM builder AS tester + +ARG JOBS + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl python3-venv \ + && rm -rf /var/lib/apt/lists/* + +RUN python3 -m venv /venv \ + && /venv/bin/pip install --no-cache-dir -r test/requirements.txt + +ENV PATH=/venv/bin:$PATH + +RUN sh scripts/mkcert.sh test/certs -RUN cmake --build build -j8 +RUN cmake -B build -DBUILD_TESTS=ON \ + && cmake --build build -j "${JOBS:-$(nproc)}" + +WORKDIR /src/test + +FROM scratch AS artifacts +COPY --from=builder /src/build/dist/ / FROM debian:trixie-slim AS runtime @@ -39,9 +68,13 @@ COPY --from=builder /src/dependencies/nghttp3-dist/ /src/dependencies/nghttp3-di COPY --from=builder /src/dependencies/httpd-dist/ /src/dependencies/httpd-dist/ COPY --from=builder /src/build/lib/mod_http3.so /src/dependencies/httpd-dist/modules/mod_http3.so -RUN mkdir -p /src/dependencies/httpd-dist/conf/certs +COPY container/httpd.conf /src/dependencies/httpd-dist/conf/httpd.conf +COPY container/static/ /src/dependencies/httpd-dist/htdocs/ +COPY container/entrypoint.sh /entrypoint.sh + +RUN mkdir -p /src/dependencies/httpd-dist/conf/certs && chmod +x /entrypoint.sh EXPOSE 8443/tcp EXPOSE 8443/udp -CMD ["/src/dependencies/httpd-dist/bin/httpd", "-D", "FOREGROUND", "-f", "/src/dependencies/httpd-dist/conf/httpd.conf"] +ENTRYPOINT ["/entrypoint.sh"] diff --git a/container/README.md b/container/README.md index d060e11..5a9a844 100644 --- a/container/README.md +++ b/container/README.md @@ -15,18 +15,41 @@ container/ compose.yml Port mapping, volume mounts, health check. Host 8443 -> container 8443 (TCP + UDP). - httpd.conf httpd configuration. - Mounts over the built-in httpd.conf at runtime. + entrypoint.sh Mints a self-signed certificate if none is mounted, + then execs httpd. + + httpd.conf httpd configuration. Baked into the image, and + mounted over at runtime by compose. certs/ TLS certificate and key. Generated with scripts/mkcert.sh. - static/ Document root (index.html). - Mounted read-only into htdocs. + static/ Document root (index.html). Baked into htdocs, + and mounted over at runtime by compose. ``` ## Quick Start +The published image carries the configuration, the demo site and a certificate +it mints on first start, so one command gives you a live HTTP/3 server: + +```sh +podman run --rm -p 8443:8443/udp ghcr.io/machine-moon/mod_http3:latest +curl --http3-only -k -sS https://localhost:8443/ -o /dev/null -w '%{http_version}\n' +``` + +HTTP/3 is UDP, so the mapping needs `/udp`; add `-p 8443:8443` as well for +HTTP/1.1 and HTTP/2 on TCP. `-e H3_PORT=8888` moves the port without mounting +anything. + +Tags are `:latest` from trunk, `:` for any trunk build, and +`:X.Y.Z` for a release. The certificate is self-signed and regenerated on every +start, so mount your own for anything that outlives a demo. + +See [docs/containers.md](../docs/containers.md) for the full guide. + +## Building it yourself + Generate certificates: ```sh diff --git a/container/compose.yml b/container/compose.yml index 433628d..e394c33 100644 --- a/container/compose.yml +++ b/container/compose.yml @@ -9,9 +9,11 @@ services: - "8443:8443/tcp" # HTTP/1.1 and HTTP/2 - "8443:8443/udp" # HTTP/3 volumes: - - ./httpd.conf:/src/dependencies/httpd-dist/conf/httpd.conf:ro - - ./certs:/src/dependencies/httpd-dist/conf/certs:ro - - ./static:/src/dependencies/httpd-dist/htdocs:ro + # Optionally override the default configuration and certificates by mounting your own files. + # + # - ./httpd.conf:/src/dependencies/httpd-dist/conf/httpd.conf:ro + # - ./certs:/src/dependencies/httpd-dist/conf/certs:ro + # - ./static:/src/dependencies/httpd-dist/htdocs:ro healthcheck: test: ["CMD", "curl", "--http3", "-ksSf", "https://localhost:8443/"] interval: 120s diff --git a/container/entrypoint.sh b/container/entrypoint.sh new file mode 100755 index 0000000..75ec6de --- /dev/null +++ b/container/entrypoint.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -eu + +H3_PORT=${H3_PORT:-8443} +export H3_PORT + +root=/src/dependencies/httpd-dist +certs=$root/conf/certs + +# If no certificate is mounted, generate a self-signed one. +if [ ! -s "$certs/server.crt" ]; then + echo "no certificate mounted at $certs, generating a self-signed one" + if ! out=$(LD_LIBRARY_PATH=/src/dependencies/openssl-dist/lib64 OPENSSL_CONF=/dev/null \ + /src/dependencies/openssl-dist/bin/openssl req -x509 -newkey rsa:2048 \ + -nodes -days 365 -subj /CN=localhost \ + -keyout "$certs/server.key" -out "$certs/server.crt" 2>&1); then + printf '%s\n' "$out" >&2 + exit 1 + fi + chown daemon "$certs/server.key" + chmod 0400 "$certs/server.key" +fi + +exec "$root/bin/httpd" -D FOREGROUND -f "$root/conf/httpd.conf" "$@" diff --git a/container/httpd.conf b/container/httpd.conf index 28060e8..8395725 100755 --- a/container/httpd.conf +++ b/container/httpd.conf @@ -25,9 +25,9 @@ PidFile /tmp/httpd.pid TypesConfig conf/mime.types -Listen 8443 https +Listen ${H3_PORT} https - + ServerName localhost SSLEngine on @@ -40,7 +40,7 @@ Listen 8443 https H3CertificatePath /src/dependencies/httpd-dist/conf/certs/server.crt H3CertificateKeyPath /src/dependencies/httpd-dist/conf/certs/server.key - H3Port 8443 + H3Port ${H3_PORT} H3MaxConnections 100 H3MaxConcurrentStreams 128 H3StreamBufferSize 65536 diff --git a/docs/containers.md b/docs/containers.md new file mode 100644 index 0000000..9a319c5 --- /dev/null +++ b/docs/containers.md @@ -0,0 +1,178 @@ +# 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). + +## 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.54 +``` + +## 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/interop.md b/docs/interop.md new file mode 100644 index 0000000..27a4131 --- /dev/null +++ b/docs/interop.md @@ -0,0 +1,99 @@ +# 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 `endpoint` 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:`. + +Each `client` 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, and each job is named after the client it +tests — the check list reads as a per-client result matrix. + +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 client 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 +`client` 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 `endpoint` job pre-checks the same thing +with a timeout before any client 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 client 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/release-process.md b/docs/release-process.md index 3c373fe..b0cd58e 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -28,7 +28,7 @@ For a release candidate to be officially published: ## 3. Release Workflow -Pushing a `v*` tag publishes the release. The [Release workflow](../.github/workflows/release.yml) checks out the tag, runs `scripts/release.sh`, and uploads `build-release/dist/` 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. +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 @@ -102,7 +102,7 @@ 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 [Release workflow](../.github/workflows/release.yml) from the tagged tree, and the run linked on the release page shows the exact commit and build log. +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. --- 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/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/zensical.toml b/docs/site/zensical.toml index 38a098e..7e294e5 100644 --- a/docs/site/zensical.toml +++ b/docs/site/zensical.toml @@ -19,6 +19,7 @@ nav = [ { "Getting Started" = [ { "Build" = "build.md" }, { "Deploy" = "deploy.md" }, + { "Containers" = "containers.md" }, { "Verify" = "verify.md" }, ] }, { "Configuration" = [ @@ -33,6 +34,7 @@ nav = [ { "Browser" = "browser-testing.md" }, { "curl" = "curl-testing.md" }, { "Examples" = "examples-testing.md" }, + { "QUIC Interop" = "interop.md" }, ] }, { "API Reference" = [ { "Overview" = "api.md" }, diff --git a/interop/Containerfile b/interop/Containerfile new file mode 100644 index 0000000..a6ccb75 --- /dev/null +++ b/interop/Containerfile @@ -0,0 +1,46 @@ +FROM martenseemann/quic-network-simulator-endpoint:latest AS builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential gcc-14 cmake ninja-build git pkg-config perl python3 \ + autoconf automake libtool libtool-bin ca-certificates \ + libpcre2-dev libxml2-dev libexpat1-dev zlib1g-dev \ + liblua5.4-dev libbrotli-dev libnghttp2-dev \ + && rm -rf /var/lib/apt/lists/* + +ENV CC=gcc-14 +ARG JOBS + +WORKDIR /src + +COPY dependencies/ dependencies/ +COPY cmake/ cmake/ +COPY CMakeLists.txt LICENSE ./ +COPY mod_http3/ mod_http3/ + +RUN cmake -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_MODULE=ON \ + -DBUILD_EXAMPLES=OFF \ + -DBUILD_TESTS=OFF \ + -DDEPENDENCIES_PARALLEL="${JOBS:-$(nproc)}" \ + && cmake --build build -j "${JOBS:-$(nproc)}" + +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=builder /src/dependencies/apr-dist/ /src/dependencies/apr-dist/ +COPY --from=builder /src/dependencies/apr-util-dist/ /src/dependencies/apr-util-dist/ +COPY --from=builder /src/dependencies/openssl-dist/ /src/dependencies/openssl-dist/ +COPY --from=builder /src/dependencies/nghttp3-dist/ /src/dependencies/nghttp3-dist/ +COPY --from=builder /src/dependencies/httpd-dist/ /src/dependencies/httpd-dist/ +COPY --from=builder /src/build/lib/mod_http3.so /src/dependencies/httpd-dist/modules/mod_http3.so + +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..40404b0 --- /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 +echo "H3AddressValidation off" >/interop/testcase.conf + +echo "TESTCASE=$TESTCASE" +"$HTTPD" -t -f "$CONF" || { echo "httpd rejected the configuration"; exit 1; } + +exec "$HTTPD" -D FOREGROUND -f "$CONF" diff --git a/mod_http3/include/h3_version.h b/mod_http3/include/h3_version.h index 5fd64dc..6daac30 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 46 +#define MOD_HTTP3_VERSION_PATCH 47 // 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.46" +#define MOD_HTTP3_VERSION_STRING "0.0.47" #endif /* H3_VERSION_H */ From dffb67e12f093715005d3ab57a66e0fc1b7dab9e Mon Sep 17 00:00:00 2001 From: t4r3k <142579274+machine-moon@users.noreply.github.com> Date: Sat, 1 Aug 2026 00:26:21 -0400 Subject: [PATCH 07/12] v0.0.48 - put the QUIC transport behind one interface --- CHANGES | 5 + CMakeLists.txt | 9 +- container/Containerfile | 1 + docs/site/Doxyfile | 4 +- interop/Containerfile | 1 + mod_http3/include/h3_io.h | 47 +- mod_http3/include/h3_session.h | 20 +- mod_http3/include/h3_ssl.h | 49 --- mod_http3/include/h3_stream.h | 10 +- mod_http3/include/h3_version.h | 4 +- mod_http3/src/h3_callbacks.c | 27 +- mod_http3/src/h3_io.c | 472 +++------------------ mod_http3/src/h3_quic.c | 34 ++ mod_http3/src/h3_request.c | 27 +- mod_http3/src/h3_session.c | 66 ++- mod_http3/src/h3_ssl.c | 59 --- mod_http3/src/h3_stream.c | 120 +++--- mod_http3/src/h3_threads.c | 56 +-- quic/CMakeLists.txt | 15 + quic/include/quic.h | 25 ++ quic/include/quic_funcs.h | 325 ++++++++++++++ quic/include/quic_types.h | 105 +++++ quic/ossl/CMakeLists.txt | 10 + quic/ossl/include/detail/quic_ossl_funcs.h | 197 +++++++++ quic/ossl/include/quic_ossl.h | 77 ++++ quic/ossl/src/detail/quic_ossl_impl.h | 111 +++++ quic/ossl/src/quic_ossl.c | 209 +++++++++ quic/ossl/src/quic_ossl_addr.c | 259 +++++++++++ quic/ossl/src/quic_ossl_conn.c | 119 ++++++ quic/ossl/src/quic_ossl_stream.c | 117 +++++ quic/src/detail/quic_check.h | 40 ++ quic/src/detail/quic_tls.h | 48 +++ quic/src/quic_tls.c | 105 +++++ 33 files changed, 2026 insertions(+), 747 deletions(-) delete mode 100644 mod_http3/include/h3_ssl.h create mode 100644 mod_http3/src/h3_quic.c delete mode 100644 mod_http3/src/h3_ssl.c create mode 100644 quic/CMakeLists.txt create mode 100644 quic/include/quic.h create mode 100644 quic/include/quic_funcs.h create mode 100644 quic/include/quic_types.h create mode 100644 quic/ossl/CMakeLists.txt create mode 100644 quic/ossl/include/detail/quic_ossl_funcs.h create mode 100644 quic/ossl/include/quic_ossl.h create mode 100644 quic/ossl/src/detail/quic_ossl_impl.h create mode 100644 quic/ossl/src/quic_ossl.c create mode 100644 quic/ossl/src/quic_ossl_addr.c create mode 100644 quic/ossl/src/quic_ossl_conn.c create mode 100644 quic/ossl/src/quic_ossl_stream.c create mode 100644 quic/src/detail/quic_check.h create mode 100644 quic/src/detail/quic_tls.h create mode 100644 quic/src/quic_tls.c diff --git a/CHANGES b/CHANGES index 7e1d54d..3db0d32 100644 --- a/CHANGES +++ b/CHANGES @@ -3,6 +3,11 @@ mod_http3 changes Changes are listed most recent first. Security-related entries always appear at the top of their release block. +v0.0.48 (2026-08-01) +-------------------- + *) Abstracted to an in house QUIC provider. + [Tarek Ibrahim ] + v0.0.47 (2026-07-31) -------------------- *) Added a QUIC Interop Runner endpoint and client matrix in CI. diff --git a/CMakeLists.txt b/CMakeLists.txt index 2b59cf3..430e4f2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.26) -project(mod_http3 VERSION 0.0.47) +project(mod_http3 VERSION 0.0.48) # -- Compiler and Build Type Checks -- if(NOT CMAKE_C_COMPILER_ID STREQUAL "GNU") @@ -60,16 +60,17 @@ include(helpers) include(flags) # -- Dependencies Library -- -add_library(${PROJECT_NAME}-deps INTERFACE) include(nghttp3) include(openssl) include(httpd) +add_subdirectory(quic) +add_library(${PROJECT_NAME}-deps INTERFACE) target_link_libraries(${PROJECT_NAME}-deps INTERFACE nghttp3 openssl httpd) # -- Core object library -- file(GLOB_RECURSE sources CONFIGURE_DEPENDS mod_http3/src/*.c) add_library(${PROJECT_NAME}-core OBJECT ${sources}) -target_link_libraries(${PROJECT_NAME}-core PUBLIC ${PROJECT_NAME}-deps) +target_link_libraries(${PROJECT_NAME}-core PUBLIC ${PROJECT_NAME}-deps ${PROJECT_NAME}-quic) set_target_properties(${PROJECT_NAME}-core PROPERTIES C_VISIBILITY_PRESET hidden VISIBILITY_INLINES_HIDDEN ON) target_include_directories(${PROJECT_NAME}-core PUBLIC mod_http3/include) apply_target_flags(${PROJECT_NAME}-core) @@ -77,7 +78,7 @@ apply_target_flags(${PROJECT_NAME}-core) # -- Dynamic Shared Object (DSO) -- if(BUILD_MODULE) add_library(${PROJECT_NAME}-lib MODULE $) - target_link_libraries(${PROJECT_NAME}-lib PRIVATE ${PROJECT_NAME}-deps) + target_link_libraries(${PROJECT_NAME}-lib PRIVATE ${PROJECT_NAME}-deps ${PROJECT_NAME}-quic) set_target_properties(${PROJECT_NAME}-lib PROPERTIES PREFIX "" OUTPUT_NAME "${PROJECT_NAME}") apply_target_flags(${PROJECT_NAME}-lib) diff --git a/container/Containerfile b/container/Containerfile index 58d5976..62b5734 100644 --- a/container/Containerfile +++ b/container/Containerfile @@ -17,6 +17,7 @@ COPY cmake/ cmake/ COPY CMakeLists.txt . COPY LICENSE NOTICE AUTHORS CHANGES ./ COPY mod_http3/ mod_http3/ +COPY quic/ quic/ RUN cmake -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ diff --git a/docs/site/Doxyfile b/docs/site/Doxyfile index 8e2a189..1a57472 100644 --- a/docs/site/Doxyfile +++ b/docs/site/Doxyfile @@ -5,7 +5,9 @@ GENERATE_LATEX = NO GENERATE_HTML = NO GENERATE_XML = YES XML_OUTPUT = xml -INPUT = mod_http3/include mod_http3/src +INPUT = mod_http3/include mod_http3/src quic/include +# Only the QUIC contract is published; engine internals stay hidden. +EXCLUDE = quic/include/detail quic/ossl RECURSIVE = YES OPTIMIZE_OUTPUT_FOR_C = YES EXTRACT_ALL = YES diff --git a/interop/Containerfile b/interop/Containerfile index a6ccb75..a04e5d3 100644 --- a/interop/Containerfile +++ b/interop/Containerfile @@ -16,6 +16,7 @@ COPY dependencies/ dependencies/ COPY cmake/ cmake/ COPY CMakeLists.txt LICENSE ./ COPY mod_http3/ mod_http3/ +COPY quic/ quic/ RUN cmake -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ diff --git a/mod_http3/include/h3_io.h b/mod_http3/include/h3_io.h index 04407aa..1e7dd88 100644 --- a/mod_http3/include/h3_io.h +++ b/mod_http3/include/h3_io.h @@ -29,26 +29,18 @@ #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; apr_pool_t* pool; server_rec* server; int udp_fd; @@ -71,14 +63,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 +84,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 +96,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 +110,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 +122,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..d5b0cc0 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). @@ -145,13 +143,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 6daac30..3f569a7 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 47 +#define MOD_HTTP3_VERSION_PATCH 48 // 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.47" +#define MOD_HTTP3_VERSION_STRING "0.0.48" #endif /* H3_VERSION_H */ diff --git a/mod_http3/src/h3_callbacks.c b/mod_http3/src/h3_callbacks.c index f311209..79f442f 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" @@ -190,19 +191,19 @@ 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_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 +214,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 +233,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_io.c b/mod_http3/src/h3_io.c index 635460b..77c916e 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,54 +57,6 @@ 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, uint64_t listener_flags) -{ - 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, listener_flags); - 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); @@ -360,7 +74,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 +85,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 +113,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,10 +125,18 @@ 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; } - uint64_t listener_flags = conf->h3_address_validation == H3_FLAG_OFF ? SSL_LISTENER_FLAG_NO_VALIDATE : 0; - if (build_ssl_listener(io, conf->h3_cert_path, conf->h3_key_path, listener_flags) != APR_SUCCESS) + + char qerr[QUIC_ERRLEN] = {0}; + quic_config qcfg = { + .cert_path = conf->h3_cert_path, + .key_path = conf->h3_key_path, + .address_validation = (conf->h3_address_validation != H3_FLAG_OFF), + .idle_timeout_secs = (uint32_t)conf->h3_idle_timeout, + }; + io->qengine = quic_engine_create(&qcfg, udp_fd, qerr, sizeof(qerr)); + if (!io->qengine) { - ap_log_error(APLOG_MARK, APLOG_ERR, 0, s, "listener setup failed"); + ap_log_error(APLOG_MARK, APLOG_ERR, 0, s, "QUIC engine initialization failed: %s", qerr); teardown(io); return APR_EGENERAL; } @@ -467,18 +178,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; @@ -490,22 +197,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; @@ -515,22 +219,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) { @@ -539,7 +237,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; @@ -550,17 +248,16 @@ 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; } @@ -569,7 +266,7 @@ static apr_status_t spawn_serviced_session(h3_io_t* io, SSL* conn) 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; @@ -601,7 +298,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) { @@ -611,59 +308,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) { @@ -672,17 +342,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; @@ -696,7 +363,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) { @@ -705,7 +372,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) { @@ -730,7 +396,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; @@ -743,13 +409,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); @@ -757,7 +423,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_quic.c b/mod_http3/src/h3_quic.c new file mode 100644 index 0000000..8501c61 --- /dev/null +++ b/mod_http3/src/h3_quic.c @@ -0,0 +1,34 @@ +/* + * 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_ossl.h" + +typedef struct quic_engine_entry +{ + const char* name; + const quic_ops* (*ops)(void); +} quic_engine_entry; + +static const quic_engine_entry quic_engines[] = { + {"openssl", quic_ossl_ops}, +}; + +const quic_ops* quic_get_ops(void) +{ + return quic_engines[0].ops(); +} diff --git a/mod_http3/src/h3_request.c b/mod_http3/src/h3_request.c index 2ba33cb..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 @@ -56,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); @@ -81,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; } diff --git a/mod_http3/src/h3_session.c b/mod_http3/src/h3_session.c index 34e4ac1..9b50e32 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) @@ -124,13 +104,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 +118,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 +143,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; } @@ -189,25 +173,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..cfb8c0f 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_ops* ops = quic_get_ops(); for (;;) { nghttp3_vec vec[16] = {0}; @@ -102,54 +102,37 @@ 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 (ops->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 (ops->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 (ops->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) @@ -160,33 +143,28 @@ void flush_nghttp3(h3_session* session) } 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 +173,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 +185,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; } @@ -244,32 +221,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; } while (*reads_remaining > 0 && *bytes_remaining > 0) { - if (!h3s->ssl_stream) + if (!h3s->qstream) { h3s->done = 1; break; @@ -277,8 +250,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; @@ -299,13 +273,17 @@ static int drain_one_stream(h3_session* session, h3_stream* h3s, int* data_read, 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); } @@ -352,7 +330,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 +360,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 +375,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..da0b809 --- /dev/null +++ b/quic/CMakeLists.txt @@ -0,0 +1,15 @@ +# -- QUIC transport engines -- + +include(openssl) + +file(GLOB_RECURSE sources CONFIGURE_DEPENDS src/*.c) + +add_library(${PROJECT_NAME}-quic STATIC ${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") +set_target_properties(${PROJECT_NAME}-quic PROPERTIES C_VISIBILITY_PRESET hidden VISIBILITY_INLINES_HIDDEN ON) +apply_target_flags(${PROJECT_NAME}-quic) + +add_subdirectory(ossl) diff --git a/quic/include/quic.h b/quic/include/quic.h new file mode 100644 index 0000000..69ef6c7 --- /dev/null +++ b/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/include/quic_funcs.h b/quic/include/quic_funcs.h new file mode 100644 index 0000000..8b887fb --- /dev/null +++ b/quic/include/quic_funcs.h @@ -0,0 +1,325 @@ +/* + * 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" + +/** + * The selected engine's operations table. + * @return Never NULL. + */ +const quic_ops* quic_get_ops(void); + +/** + * Create a QUIC engine bound to an already-open UDP socket. + * @param cfg Certificates, timeouts and callbacks the engine runs with. + * @param udp_fd Pre-opened non-blocking UDP socket. + * @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, int udp_fd, char* err, size_t errlen) +{ + return quic_get_ops()->engine.create(cfg, udp_fd, 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_get_ops()->engine.destroy) + { + quic_get_ops()->engine.destroy(engine); + } +} + +/** + * Apply any engine-specific socket options. + * @param engine Engine owning the socket; NULL is ignored. + * @param fd Socket to configure. + */ +static inline void quic_engine_socket_configure(quic_engine* engine, int fd) +{ + if (engine && quic_get_ops()->engine.socket_configure) + { + quic_get_ops()->engine.socket_configure(engine, fd); + } +} + +/** + * 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_get_ops()->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_get_ops()->engine.want) + { + quic_get_ops()->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_get_ops()->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_get_ops()->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_get_ops()->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_get_ops()->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_get_ops()->conn.set_user) + { + quic_get_ops()->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_get_ops()->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_get_ops()->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_get_ops()->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_get_ops()->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_get_ops()->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_get_ops()->conn.free) + { + quic_get_ops()->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_get_ops()->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_get_ops()->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_get_ops()->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_get_ops()->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_get_ops()->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_get_ops()->stream.stop_sending) + { + quic_get_ops()->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_get_ops()->stream.reset) + { + quic_get_ops()->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_get_ops()->stream.free) + { + quic_get_ops()->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_get_ops()->stream.consumed) + { + quic_get_ops()->stream.consumed(st, nbytes); + } +} + +#endif /* QUIC_FUNCS_H */ diff --git a/quic/include/quic_types.h b/quic/include/quic_types.h new file mode 100644 index 0000000..5b3b584 --- /dev/null +++ b/quic/include/quic_types.h @@ -0,0 +1,105 @@ +/* + * 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; + +#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; + +typedef struct quic_caps +{ + unsigned acks_are_write_offsets : 1; +} quic_caps; + +typedef struct quic_config +{ + const char* cert_path; + const char* key_path; + unsigned address_validation : 1; + uint32_t idle_timeout_secs; + void (*on_stream_acked)(void* user, int64_t stream_id, uint64_t datalen); +} quic_config; + +typedef struct quic_engine_ops +{ + quic_engine* (*create)(const quic_config* cfg, int udp_fd, char* err, size_t errlen); + void (*destroy)(quic_engine* engine); + void (*socket_configure)(quic_engine* engine, int fd); + 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); +} quic_engine_ops; + +typedef struct quic_conn_ops +{ + 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); +} quic_conn_ops; + +typedef struct quic_stream_ops +{ + 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); +} quic_stream_ops; + +typedef struct quic_ops +{ + quic_caps caps; + quic_engine_ops engine; + quic_conn_ops conn; + quic_stream_ops stream; +} quic_ops; + +#endif /* QUIC_TYPES_H */ diff --git a/quic/ossl/CMakeLists.txt b/quic/ossl/CMakeLists.txt new file mode 100644 index 0000000..1a9ac4a --- /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 + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include" + PRIVATE "${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..52ee429 --- /dev/null +++ b/quic/ossl/include/detail/quic_ossl_funcs.h @@ -0,0 +1,197 @@ +/* + * 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 Certificates and timeouts the listener runs with. + * @param udp_fd Pre-opened non-blocking UDP socket bound to the listen port. + * @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_ossl_engine_create(const quic_config* cfg, int udp_fd, 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); + +/** + * Apply the socket options OpenSSL's QUIC listener expects. + * @param engine Engine owning the socket. + * @param fd Socket to configure. + */ +void quic_ossl_engine_socket_configure(quic_engine* engine, int fd); + +/** + * 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..954bbf6 --- /dev/null +++ b/quic/ossl/include/quic_ossl.h @@ -0,0 +1,77 @@ +/* + * 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" + +/** + * Operations table 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. Ops 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. + * @return Table with static storage duration; never NULL. + */ +static inline const quic_ops* quic_ossl_ops(void) +{ + static const quic_ops ops = { + .caps = + { + .acks_are_write_offsets = 1, + }, + .engine = + { + .create = quic_ossl_engine_create, + .destroy = quic_ossl_engine_destroy, + .socket_configure = quic_ossl_engine_socket_configure, + .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 &ops; +} + +#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..a8db1b3 --- /dev/null +++ b/quic/ossl/src/detail/quic_ossl_impl.h @@ -0,0 +1,111 @@ +/* + * 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 +{ + 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..9742822 --- /dev/null +++ b/quic/ossl/src/quic_ossl.c @@ -0,0 +1,209 @@ +/* + * 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, int udp_fd, char* err, size_t errlen) +{ + QUIC_CHECK(cfg); + 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->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->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; +} + +void quic_ossl_engine_socket_configure(quic_engine* engine, int fd) +{ + (void)engine; + (void)fd; +} + +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/src/detail/quic_check.h b/quic/src/detail/quic_check.h new file mode 100644 index 0000000..e28498c --- /dev/null +++ b/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/src/detail/quic_tls.h b/quic/src/detail/quic_tls.h new file mode 100644 index 0000000..3a077f4 --- /dev/null +++ b/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/src/quic_tls.c b/quic/src/quic_tls.c new file mode 100644 index 0000000..108d840 --- /dev/null +++ b/quic/src/quic_tls.c @@ -0,0 +1,105 @@ +/* + * 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_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); + } +} + +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 (SSL_CTX_use_certificate_chain_file(ssl_ctx, cfg->cert_path) <= 0 || SSL_CTX_use_PrivateKey_file(ssl_ctx, cfg->key_path, SSL_FILETYPE_PEM) <= 0) + { + quic_tls_error(err, errlen, "loading the certificate or private key failed"); + 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; +} From c1c9a4162de23d2ce09b831b283b75379cb698a2 Mon Sep 17 00:00:00 2001 From: t4r3k <142579274+machine-moon@users.noreply.github.com> Date: Sun, 2 Aug 2026 22:25:52 -0400 Subject: [PATCH 08/12] v0.0.49 - experimental ngtcp2 support --- .containerignore | 1 + .gitignore | 1 + .gitmodules | 11 +- CHANGES | 9 + CMakeLists.txt | 13 +- NOTICE | 6 + README.md | 3 +- cmake/modules/httpd.cmake | 2 +- cmake/modules/ngtcp2.cmake | 136 ++++ cmake/modules/openssl.cmake | 4 +- container/Containerfile | 4 +- container/entrypoint.sh | 4 +- dependencies/README.md | 64 +- docs/architecture.md | 55 +- docs/build.md | 22 +- docs/configuration.md | 12 +- docs/configuration_httpd.md | 10 + interop/Containerfile | 2 +- mod_http3/include/h3_callbacks.h | 8 + mod_http3/include/h3_config.h | 1 + mod_http3/include/h3_quic.h | 45 ++ mod_http3/include/h3_session.h | 9 + mod_http3/include/h3_version.h | 4 +- mod_http3/src/h3_callbacks.c | 16 +- mod_http3/src/h3_config.c | 18 +- mod_http3/src/h3_hooks.c | 5 +- mod_http3/src/h3_io.c | 8 + mod_http3/src/h3_quic.c | 45 +- mod_http3/src/h3_session.c | 23 +- mod_http3/src/h3_stream.c | 6 +- quic/CMakeLists.txt | 1 + quic/ngtcp2/CMakeLists.txt | 15 + .../ngtcp2/include/detail/quic_ngtcp2_funcs.h | 214 +++++++ quic/ngtcp2/include/quic_ngtcp2.h | 76 +++ quic/ngtcp2/src/detail/quic_ngtcp2_impl.h | 259 ++++++++ quic/ngtcp2/src/quic_ngtcp2.c | 602 ++++++++++++++++++ quic/ngtcp2/src/quic_ngtcp2_cid.c | 78 +++ quic/ngtcp2/src/quic_ngtcp2_conn.c | 297 +++++++++ quic/ngtcp2/src/quic_ngtcp2_map.c | 145 +++++ quic/ngtcp2/src/quic_ngtcp2_stream.c | 339 ++++++++++ quic/ngtcp2/src/quic_ngtcp2_tls.c | 93 +++ quic/third-party/ngtcp2 | 1 + {dependencies => quic/third-party}/openssl | 0 test/CMakeLists.txt | 5 + test/http3/env.py | 4 + test/http3/test_006_graceful_shutdown.py | 15 +- test/http3/test_011_status.py | 2 + test/unit/dependencies/ngtcp2_test.c | 63 ++ test/unit/dependencies/suite.c | 5 + 49 files changed, 2717 insertions(+), 44 deletions(-) create mode 100644 cmake/modules/ngtcp2.cmake create mode 100644 mod_http3/include/h3_quic.h create mode 100644 quic/ngtcp2/CMakeLists.txt create mode 100644 quic/ngtcp2/include/detail/quic_ngtcp2_funcs.h create mode 100644 quic/ngtcp2/include/quic_ngtcp2.h create mode 100644 quic/ngtcp2/src/detail/quic_ngtcp2_impl.h create mode 100644 quic/ngtcp2/src/quic_ngtcp2.c create mode 100644 quic/ngtcp2/src/quic_ngtcp2_cid.c create mode 100644 quic/ngtcp2/src/quic_ngtcp2_conn.c create mode 100644 quic/ngtcp2/src/quic_ngtcp2_map.c create mode 100644 quic/ngtcp2/src/quic_ngtcp2_stream.c create mode 100644 quic/ngtcp2/src/quic_ngtcp2_tls.c create mode 160000 quic/third-party/ngtcp2 rename {dependencies => quic/third-party}/openssl (100%) create mode 100644 test/unit/dependencies/ngtcp2_test.c diff --git a/.containerignore b/.containerignore index 10dc340..546a395 100644 --- a/.containerignore +++ b/.containerignore @@ -1,6 +1,7 @@ .git/ .ai/ dependencies/*-dist/ +quic/third-party/*-dist/ build*/ keys/ certs/ diff --git a/.gitignore b/.gitignore index 4911404..72fd2c5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build-*/ builddir/ builddir-*/ dependencies/*-* +quic/third-party/*-dist CMakeFiles/ CMakeCache.txt CPackConfig.cmake diff --git a/.gitmodules b/.gitmodules index 1d428e6..34c1337 100644 --- a/.gitmodules +++ b/.gitmodules @@ -26,10 +26,17 @@ ignore = untracked update = checkout branch = 1.6.x -[submodule "dependencies/openssl"] - path = dependencies/openssl +[submodule "quic/third-party/openssl"] + path = quic/third-party/openssl url = https://github.com/openssl/openssl.git shallow = true ignore = dirty update = checkout branch = openssl-3.5 +[submodule "quic/third-party/ngtcp2"] + path = quic/third-party/ngtcp2 + url = https://github.com/ngtcp2/ngtcp2.git + shallow = true + ignore = untracked + update = checkout + #branch = main # tag: v1.25.0 diff --git a/CHANGES b/CHANGES index 3db0d32..c6d3a5e 100644 --- a/CHANGES +++ b/CHANGES @@ -3,6 +3,15 @@ mod_http3 changes Changes are listed most recent first. Security-related entries always appear at the top of their release block. +v0.0.49 (2026-08-02) +-------------------- + *) Added an ngtcp2 QUIC engine behind the QUIC abstraction, with OpenSSL for TLS. + [Tarek Ibrahim ] + + *) Chose a QUIC backend at run time with H3QuicEngine directive, refuses to start + when the build does not contain the engine named. + [Tarek Ibrahim ] + v0.0.48 (2026-08-01) -------------------- *) Abstracted to an in house QUIC provider. diff --git a/CMakeLists.txt b/CMakeLists.txt index 430e4f2..f5754d7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.26) -project(mod_http3 VERSION 0.0.48) +project(mod_http3 VERSION 0.0.49) # -- Compiler and Build Type Checks -- if(NOT CMAKE_C_COMPILER_ID STREQUAL "GNU") @@ -32,6 +32,12 @@ set(DEPENDENCIES_DIRECTORY "${CMAKE_SOURCE_DIR}/dependencies" CACHE PATH "Direct set(DEPENDENCIES_OUTPUT_DIRECTORY "${DEPENDENCIES_DIRECTORY}" CACHE PATH "Directory for placing built dependencies") set(DEPENDENCIES_PARALLEL 6 CACHE STRING "Number of parallel jobs to use when building dependencies") +# QUIC's own libraries, vendored beside the engines that need them. +set(QUIC_DEPENDENCIES_DIRECTORY "${CMAKE_SOURCE_DIR}/quic/third-party" + CACHE PATH "Directory holding the QUIC library submodules") +set(QUIC_DEPENDENCIES_OUTPUT_DIRECTORY "${QUIC_DEPENDENCIES_DIRECTORY}" + CACHE PATH "Directory the QUIC libraries are built into") + # -- C standard -- set(CMAKE_C_STANDARD 23) set(CMAKE_C_STANDARD_REQUIRED ON) @@ -42,11 +48,14 @@ option(BUILD_MODULE "Build Apache DSO module" ON) option(BUILD_EXAMPLES "Build example programs" ON) option(BUILD_TESTS "Build test suite" ON) +option(ENABLE_NGTCP2 "Include the ngtcp2 QUIC engine" OFF) + option(ENABLE_UBSAN "Undefined Behavior Sanitizer" OFF) option(ENABLE_ASAN "Address Sanitizer" OFF) option(ENABLE_WERROR "Treat warnings as errors" OFF) set(WITH_NGHTTP3 "" CACHE PATH "Path to nghttp3 installation prefix") +set(WITH_NGTCP2 "" CACHE PATH "Path to ngtcp2 installation prefix") set(WITH_SSL "" CACHE PATH "Path to OpenSSL installation prefix") set(WITH_HTTPD "" CACHE PATH "Path to httpd installation prefix (includes APR/APU)") set(WITH_APR "" CACHE PATH "Path to APR installation prefix") @@ -121,6 +130,8 @@ message(STATUS " BUILD_MODULE : ${BUILD_MODULE}") message(STATUS " BUILD_EXAMPLES : ${BUILD_EXAMPLES}") message(STATUS " BUILD_TESTS : ${BUILD_TESTS}") message(STATUS "") +message(STATUS " ENABLE_NGTCP2 : ${ENABLE_NGTCP2}") +message(STATUS "") message(STATUS " ENABLE_UBSAN : ${ENABLE_UBSAN}") message(STATUS " ENABLE_ASAN : ${ENABLE_ASAN}") message(STATUS " ENABLE_WERROR : ${ENABLE_WERROR}") diff --git a/NOTICE b/NOTICE index ba62134..8eebe11 100644 --- a/NOTICE +++ b/NOTICE @@ -30,6 +30,12 @@ This product makes use of the following third-party libraries: Copyright 2019-2026 nghttp3 contributors Licensed under the MIT License. + ngtcp2 (https://github.com/ngtcp2/ngtcp2) + Copyright 2016-2026 ngtcp2 contributors + Licensed under the MIT License. + Built only when the ngtcp2 QUIC engine is enabled (-DENABLE_NGTCP2=ON), + together with libngtcp2_crypto_ossl for its TLS integration. + OpenSSL (https://www.openssl.org/) Copyright 1998-2026 The OpenSSL Project Authors Licensed under the Apache License 2.0. diff --git a/README.md b/README.md index 2cf165a..69dc368 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ Status: **experimental**. Default build compiles all dependencies (OpenSSL, APR, APR-util, httpd) from submodules: ```sh -git submodule update --init --recursive +git submodule update --init +git submodule update --init --recursive dependencies/nghttp3 cmake -B build cmake --build build ``` diff --git a/cmake/modules/httpd.cmake b/cmake/modules/httpd.cmake index 5d14952..4d8e391 100644 --- a/cmake/modules/httpd.cmake +++ b/cmake/modules/httpd.cmake @@ -21,7 +21,7 @@ if(WITH_HTTPD) else() # httpd depends on openssl if(NOT WITH_SSL) - require_initialized_submodule("${DEPENDENCIES_DIRECTORY}/openssl") + require_initialized_submodule("${QUIC_DEPENDENCIES_DIRECTORY}/openssl") if(NOT OPENSSL_OUTPUT_DIRECTORY OR NOT EXISTS "${OPENSSL_OUTPUT_DIRECTORY}/.done" OR NOT TARGET openssl) message(FATAL_ERROR "[httpd] error: building httpd from source requires openssl to be built first") endif() diff --git a/cmake/modules/ngtcp2.cmake b/cmake/modules/ngtcp2.cmake new file mode 100644 index 0000000..a6c718a --- /dev/null +++ b/cmake/modules/ngtcp2.cmake @@ -0,0 +1,136 @@ +# -- ngtcp2 v1.25.0 -- + +if(TARGET ngtcp2) + return() +endif() + +set(NGTCP2_VERSION_MIN "1.25.0") + +if(WITH_NGTCP2) + find_library(NGTCP2_LIBRARY NAMES ngtcp2 + PATHS "${WITH_NGTCP2}/lib" "${WITH_NGTCP2}/lib64" NO_DEFAULT_PATH) + find_library(NGTCP2_CRYPTO_OSSL_LIBRARY NAMES ngtcp2_crypto_ossl + PATHS "${WITH_NGTCP2}/lib" "${WITH_NGTCP2}/lib64" NO_DEFAULT_PATH) + if(NOT NGTCP2_LIBRARY OR NOT NGTCP2_CRYPTO_OSSL_LIBRARY) + message(FATAL_ERROR + "[ngtcp2] error: ngtcp2 or ngtcp2_crypto_ossl not found at WITH_NGTCP2=${WITH_NGTCP2}. Ensure ngtcp2 was built with OpenSSL support (--with-openssl)." + ) + endif() + set(NGTCP2_OUTPUT_DIRECTORY "${WITH_NGTCP2}") +else() + + set(NGTCP2_DIRECTORY "${QUIC_DEPENDENCIES_DIRECTORY}/ngtcp2") + set(NGTCP2_OUTPUT_DIRECTORY "${QUIC_DEPENDENCIES_OUTPUT_DIRECTORY}/ngtcp2-dist") + + # Build ngtcp2 from source if not already done + if(NOT EXISTS "${NGTCP2_OUTPUT_DIRECTORY}/.done") + require_initialized_submodule("${NGTCP2_DIRECTORY}") + file(MAKE_DIRECTORY "${NGTCP2_OUTPUT_DIRECTORY}/logs") + + if(EXISTS "${NGTCP2_DIRECTORY}/Makefile") + file(REMOVE "${NGTCP2_DIRECTORY}/Makefile") + endif() + + message(STATUS "[ngtcp2] Configuring -> ${NGTCP2_OUTPUT_DIRECTORY}") + + execute_process( + COMMAND autoreconf -i + WORKING_DIRECTORY "${NGTCP2_DIRECTORY}" + RESULT_VARIABLE _NGTCP2_AUTORECONF_RESULT + OUTPUT_FILE "${NGTCP2_OUTPUT_DIRECTORY}/logs/ngtcp2-autoreconf.log" + ERROR_FILE "${NGTCP2_OUTPUT_DIRECTORY}/logs/ngtcp2-autoreconf.log") + if(NOT _NGTCP2_AUTORECONF_RESULT EQUAL 0) + message(FATAL_ERROR "[ngtcp2] error: autoreconf failed -- see ${NGTCP2_OUTPUT_DIRECTORY}/logs/ngtcp2-autoreconf.log") + endif() + + # ngtcp2 locates OpenSSL through pkg-config; point it at the one we built. + execute_process( + COMMAND ${CMAKE_COMMAND} -E env + "PKG_CONFIG_PATH=${OPENSSL_OUTPUT_DIRECTORY}/lib64/pkgconfig:${OPENSSL_OUTPUT_DIRECTORY}/lib/pkgconfig" + ./configure --prefix=${NGTCP2_OUTPUT_DIRECTORY} --enable-lib-only --with-openssl + WORKING_DIRECTORY "${NGTCP2_DIRECTORY}" + RESULT_VARIABLE _NGTCP2_CONFIGURE_RESULT + OUTPUT_FILE "${NGTCP2_OUTPUT_DIRECTORY}/logs/ngtcp2-configure.log" + ERROR_FILE "${NGTCP2_OUTPUT_DIRECTORY}/logs/ngtcp2-configure.log") + if(NOT _NGTCP2_CONFIGURE_RESULT EQUAL 0) + message(FATAL_ERROR "[ngtcp2] error: configure failed -- see ${NGTCP2_OUTPUT_DIRECTORY}/logs/ngtcp2-configure.log") + endif() + + message(STATUS "[ngtcp2] Cleaning workspace") + execute_process( + COMMAND make clean + WORKING_DIRECTORY "${NGTCP2_DIRECTORY}" + RESULT_VARIABLE _NGTCP2_CLEAN_RESULT + OUTPUT_FILE "${NGTCP2_OUTPUT_DIRECTORY}/logs/ngtcp2-clean.log" + ERROR_FILE "${NGTCP2_OUTPUT_DIRECTORY}/logs/ngtcp2-clean.log") + if(NOT _NGTCP2_CLEAN_RESULT EQUAL 0) + message(FATAL_ERROR "[ngtcp2] error: make clean failed -- see ${NGTCP2_OUTPUT_DIRECTORY}/logs/ngtcp2-clean.log") + endif() + + message(STATUS "[ngtcp2] Building (${DEPENDENCIES_PARALLEL} jobs)") + + execute_process( + COMMAND make -j${DEPENDENCIES_PARALLEL} + WORKING_DIRECTORY "${NGTCP2_DIRECTORY}" + RESULT_VARIABLE _NGTCP2_BUILD_RESULT + OUTPUT_FILE "${NGTCP2_OUTPUT_DIRECTORY}/logs/ngtcp2-build.log" + ERROR_FILE "${NGTCP2_OUTPUT_DIRECTORY}/logs/ngtcp2-build.log") + if(NOT _NGTCP2_BUILD_RESULT EQUAL 0) + message(FATAL_ERROR "[ngtcp2] error: build failed -- see ${NGTCP2_OUTPUT_DIRECTORY}/logs/ngtcp2-build.log") + endif() + + message(STATUS "[ngtcp2] Installing to ${NGTCP2_OUTPUT_DIRECTORY}") + + execute_process( + COMMAND make install + WORKING_DIRECTORY "${NGTCP2_DIRECTORY}" + RESULT_VARIABLE _NGTCP2_INSTALL_RESULT + OUTPUT_FILE "${NGTCP2_OUTPUT_DIRECTORY}/logs/ngtcp2-install.log" + ERROR_FILE "${NGTCP2_OUTPUT_DIRECTORY}/logs/ngtcp2-install.log") + if(NOT _NGTCP2_INSTALL_RESULT EQUAL 0) + message(FATAL_ERROR "[ngtcp2] error: install failed -- see ${NGTCP2_OUTPUT_DIRECTORY}/logs/ngtcp2-install.log") + endif() + + string(TIMESTAMP _NGTCP2_DONE_TIME "%Y-%b-%d_%H-%M-%S") + file(WRITE "${NGTCP2_OUTPUT_DIRECTORY}/.done" "${_NGTCP2_DONE_TIME}") + endif() + + # Find the ngtcp2 we just built + find_library(NGTCP2_LIBRARY NAMES ngtcp2 PATHS "${NGTCP2_OUTPUT_DIRECTORY}/lib" "${NGTCP2_OUTPUT_DIRECTORY}/lib64" NO_DEFAULT_PATH) + find_library(NGTCP2_CRYPTO_OSSL_LIBRARY NAMES ngtcp2_crypto_ossl PATHS "${NGTCP2_OUTPUT_DIRECTORY}/lib" "${NGTCP2_OUTPUT_DIRECTORY}/lib64" NO_DEFAULT_PATH) + if(NOT NGTCP2_LIBRARY OR NOT NGTCP2_CRYPTO_OSSL_LIBRARY) + message(FATAL_ERROR "[ngtcp2] error: ngtcp2 or ngtcp2_crypto_ossl missing after build.") + endif() +endif() + +# Verify version is >= NGTCP2_VERSION_MIN +file(READ "${NGTCP2_OUTPUT_DIRECTORY}/include/ngtcp2/version.h" _NGTCP2_VERSION_H_CONTENT) +string(REGEX MATCH "#define NGTCP2_VERSION \"([0-9]+\\.[0-9]+\\.[0-9]+)" _ "${_NGTCP2_VERSION_H_CONTENT}") +set(NGTCP2_VERSION "${CMAKE_MATCH_1}") + +if(NOT NGTCP2_VERSION OR NGTCP2_VERSION VERSION_LESS NGTCP2_VERSION_MIN) + message(FATAL_ERROR + "[ngtcp2] error: could not determine a valid version\n" + " NGTCP2_INCLUDE_DIR = ${NGTCP2_OUTPUT_DIRECTORY}/include\n" + " NGTCP2_VERSION = ${NGTCP2_VERSION}\n" + " NGTCP2_VERSION_MIN = ${NGTCP2_VERSION_MIN}") +endif() + +message(STATUS "[ngtcp2] found (${NGTCP2_VERSION}): ${NGTCP2_OUTPUT_DIRECTORY}") + +add_library(ngtcp2 INTERFACE) +target_include_directories(ngtcp2 SYSTEM INTERFACE "${NGTCP2_OUTPUT_DIRECTORY}/include") +target_link_libraries(ngtcp2 INTERFACE "${NGTCP2_CRYPTO_OSSL_LIBRARY}" "${NGTCP2_LIBRARY}" openssl) + +# Extras + +find_library(NGTCP2_STATIC_LIBRARY NAMES libngtcp2.a + PATHS "${NGTCP2_OUTPUT_DIRECTORY}/lib" "${NGTCP2_OUTPUT_DIRECTORY}/lib64" NO_DEFAULT_PATH NO_CACHE) +find_library(NGTCP2_CRYPTO_OSSL_STATIC_LIBRARY NAMES libngtcp2_crypto_ossl.a + PATHS "${NGTCP2_OUTPUT_DIRECTORY}/lib" "${NGTCP2_OUTPUT_DIRECTORY}/lib64" NO_DEFAULT_PATH NO_CACHE) + +if(NGTCP2_STATIC_LIBRARY AND NGTCP2_CRYPTO_OSSL_STATIC_LIBRARY) + add_library(ngtcp2_static INTERFACE) + target_include_directories(ngtcp2_static SYSTEM INTERFACE "${NGTCP2_OUTPUT_DIRECTORY}/include") + target_link_libraries(ngtcp2_static INTERFACE "${NGTCP2_CRYPTO_OSSL_STATIC_LIBRARY}" "${NGTCP2_STATIC_LIBRARY}" openssl_static) +endif() diff --git a/cmake/modules/openssl.cmake b/cmake/modules/openssl.cmake index 9e15fab..b79871c 100644 --- a/cmake/modules/openssl.cmake +++ b/cmake/modules/openssl.cmake @@ -19,8 +19,8 @@ if(WITH_SSL) set(OPENSSL_OUTPUT_DIRECTORY "${WITH_SSL}") else() - set(OPENSSL_DIRECTORY "${DEPENDENCIES_DIRECTORY}/openssl") - set(OPENSSL_OUTPUT_DIRECTORY "${DEPENDENCIES_OUTPUT_DIRECTORY}/openssl-dist") + set(OPENSSL_DIRECTORY "${QUIC_DEPENDENCIES_DIRECTORY}/openssl") + set(OPENSSL_OUTPUT_DIRECTORY "${QUIC_DEPENDENCIES_OUTPUT_DIRECTORY}/openssl-dist") # Build OpenSSL from source if not already done if(NOT EXISTS "${OPENSSL_OUTPUT_DIRECTORY}/.done") diff --git a/container/Containerfile b/container/Containerfile index 62b5734..3229efd 100644 --- a/container/Containerfile +++ b/container/Containerfile @@ -21,6 +21,7 @@ COPY quic/ quic/ RUN cmake -B build -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ + -DENABLE_NGTCP2=ON \ -DBUILD_MODULE=ON \ -DBUILD_EXAMPLES=OFF \ -DBUILD_TESTS=OFF \ @@ -63,8 +64,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY --from=builder /src/dependencies/apr-dist/ /src/dependencies/apr-dist/ COPY --from=builder /src/dependencies/apr-util-dist/ /src/dependencies/apr-util-dist/ -COPY --from=builder /src/dependencies/openssl-dist/ /src/dependencies/openssl-dist/ +COPY --from=builder /src/quic/third-party/openssl-dist/ /src/quic/third-party/openssl-dist/ COPY --from=builder /src/dependencies/nghttp3-dist/ /src/dependencies/nghttp3-dist/ +COPY --from=builder /src/quic/third-party/ngtcp2-dist/ /src/quic/third-party/ngtcp2-dist/ COPY --from=builder /src/dependencies/httpd-dist/ /src/dependencies/httpd-dist/ COPY --from=builder /src/build/lib/mod_http3.so /src/dependencies/httpd-dist/modules/mod_http3.so diff --git a/container/entrypoint.sh b/container/entrypoint.sh index 75ec6de..b7c3d81 100755 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -10,8 +10,8 @@ certs=$root/conf/certs # If no certificate is mounted, generate a self-signed one. if [ ! -s "$certs/server.crt" ]; then echo "no certificate mounted at $certs, generating a self-signed one" - if ! out=$(LD_LIBRARY_PATH=/src/dependencies/openssl-dist/lib64 OPENSSL_CONF=/dev/null \ - /src/dependencies/openssl-dist/bin/openssl req -x509 -newkey rsa:2048 \ + if ! out=$(LD_LIBRARY_PATH=/src/quic/third-party/openssl-dist/lib64 OPENSSL_CONF=/dev/null \ + /src/quic/third-party/openssl-dist/bin/openssl req -x509 -newkey rsa:2048 \ -nodes -days 365 -subj /CN=localhost \ -keyout "$certs/server.key" -out "$certs/server.crt" 2>&1); then printf '%s\n' "$out" >&2 diff --git a/dependencies/README.md b/dependencies/README.md index 671d06b..28bec88 100644 --- a/dependencies/README.md +++ b/dependencies/README.md @@ -2,23 +2,30 @@ mod_http3 uses **git submodules** for all dependencies. By default, all dependencies are built from source at configure time. Provide `WITH_*` CMake variables to override with system-installed versions. +This directory holds what every build needs. **Optional QUIC libraries live under +[`quic/third-party/`](../quic/third-party) instead**, beside the engines that use +them — today that is ngtcp2. OpenSSL stays here: it is always required, since it +provides TLS whichever QUIC engine runs. + --- ## Pinned dependency versions | Dependency | Submodule path | Branch | Version (current) | Notes | |-------------|-----------------------------|-----------------|-------------------|--------------------------------| -| OpenSSL | `dependencies/openssl` | `openssl-3.5` | 3.5.7-dev | QUIC support required (≥ 3.5). | +| OpenSSL | `quic/third-party/openssl` | `openssl-3.5` | 3.5.7-dev | QUIC support required (≥ 3.5). | | httpd | `dependencies/httpd` | `trunk` | 2.5.1-dev | AP25 API; requires MMN ≥ 20211221/30. | | APR | `dependencies/apr` | `1.7.x` | 1.7.7 | APR v2-dev (trunk) will subsume APR-util 1.x APIs. | | APR-util | `dependencies/apr-util` | `1.6.x` | 1.6.4 | Legacy companion library; kept for APR 1.x compatibility. | | nghttp3 | `dependencies/nghttp3` | `main` | 1.17.0 | HTTP/3 framing and QPACK. | +| ngtcp2 | `quic/third-party/ngtcp2` | `main` (v1.25.0)| 1.25.0 | QUIC transport; built only for `-DENABLE_NGTCP2=ON`. | All submodules are shallow (`shallow = true`). Initialise them once: ```sh -git submodule sync --recursive -git submodule update --init --recursive +git submodule sync +git submodule update --init +git submodule update --init --recursive dependencies/nghttp3 ``` --- @@ -47,10 +54,11 @@ CMake builds OpenSSL, APR, APR-util, httpd, and nghttp3 from their respective gi **Build order enforced by CMake:** 1. nghttp3 (`dependencies/nghttp3`) -> `dependencies/nghttp3-dist/` -2. OpenSSL (`dependencies/openssl`) -> `dependencies/openssl-dist/` +2. OpenSSL (`quic/third-party/openssl`) -> `quic/third-party/openssl-dist/` 3. APR (`dependencies/apr`) -> `dependencies/apr-dist/` 4. APR-util (`dependencies/apr-util`) -> `dependencies/apr-util-dist/` 5. httpd (`dependencies/httpd`) -> `dependencies/httpd-dist/` +6. ngtcp2 (`quic/third-party/ngtcp2`) -> `quic/third-party/ngtcp2-dist/` (only when the ngtcp2 QUIC engine is selected; it consumes the OpenSSL built above) ```sh # default: builds all dependencies from source (first configure is slow; subsequent ones are instant from cache) @@ -63,7 +71,7 @@ This is the recommended mode for development. Everything is self-contained under **To force a clean rebuild of a dependency built from source**, delete its `-dist` dir and re-configure: ```sh -rm -rf dependencies/openssl-dist # re-build OpenSSL +rm -rf quic/third-party/openssl-dist # re-build OpenSSL rm -rf dependencies/httpd-dist # re-build httpd cmake -B build ``` @@ -81,6 +89,7 @@ Provide `WITH_*` paths to use system-installed dependencies instead of building | `WITH_APR=/path` | APR source build | >= 1.7.0 | | `WITH_APU=/path` | APR-util source build | >= 1.6.0 | | `WITH_NGHTTP3=/path` | nghttp3 source build | ≥ 1.16.0 | +| `WITH_NGTCP2=/path` | ngtcp2 source build | ≥ 1.25.0 | ```sh cmake -B build -DWITH_SSL=/opt/openssl -DWITH_HTTPD=/opt/httpd @@ -126,12 +135,12 @@ cmake -B build -DWITH_HTTPD=/opt/httpd If you need to patch OpenSSL, httpd, or APR/APU, apply the patch to the corresponding submodule and **remove any existing build output** before reconfiguring. Build-from-source mode will then rebuild from the patched sources. ```sh -cd dependencies/openssl +cd quic/third-party/openssl git apply /path/to/my.patch cd ../.. # ensure the previous build output is discarded -rm -rf dependencies/openssl-dist +rm -rf quic/third-party/openssl-dist cmake -B build # External autotool builds happen at configuration time ``` @@ -149,12 +158,12 @@ The following are the exact configure/build commands CMake runs for each depende ### 1. OpenSSL (>= 3.5.0) -Submodule: `dependencies/openssl` | cmake module: `cmake/modules/openssl.cmake` +Submodule: `quic/third-party/openssl` | cmake module: `cmake/modules/openssl.cmake` Uses OpenSSL's own `./config` wrapper (not autoconf). ```sh -cd dependencies/openssl +cd quic/third-party/openssl ./config \ --prefix=$PREFIX \ @@ -277,6 +286,38 @@ make install Hook up: `-DWITH_NGHTTP3=$PREFIX` +--- + +--- + +### 6. ngtcp2 (>= 1.25.0, only for `-DENABLE_NGTCP2=ON`) + +Submodule: `quic/third-party/ngtcp2` | cmake module: `cmake/modules/ngtcp2.cmake` + +The in-tree build uses ngtcp2's autotools, so it needs `autoconf`, `automake` +and `libtool` on the host. The recipe below uses ngtcp2's own CMake instead, +which does not. Either way it consumes the OpenSSL built above, so build that +first; OpenSSL remains the TLS provider on both engines, and ngtcp2 replaces +only the transport. + +```sh +cmake -B quic/third-party/ngtcp2/build -S quic/third-party/ngtcp2 \ + -DCMAKE_INSTALL_PREFIX=$PREFIX \ + -DENABLE_OPENSSL=ON \ + -DENABLE_LIB_ONLY=ON \ + -DOPENSSL_ROOT_DIR=$OPENSSL_PREFIX + +cmake --build quic/third-party/ngtcp2/build -j$(nproc) +cmake --install quic/third-party/ngtcp2/build +``` + +Produces both `libngtcp2` and `libngtcp2_crypto_ossl`; the module needs both. A +distribution ngtcp2 built against the quictls fork ships +`libngtcp2_crypto_quictls` instead and will not work. + +Hook up: `-DWITH_NGTCP2=$PREFIX` + + --- ## Verifying a build-from-source install @@ -288,8 +329,11 @@ 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, not /usr/lib/... +# should show quic/third-party/openssl-dist/lib64/libssl.so, not /usr/lib/... # Confirm nghttp3 version grep 'NGHTTP3_VERSION ' dependencies/nghttp3-dist/include/nghttp3/version.h + +# Confirm ngtcp2 version (only when the ngtcp2 engine is selected) +grep 'NGTCP2_VERSION ' quic/third-party/ngtcp2-dist/include/ngtcp2/version.h ``` diff --git a/docs/architecture.md b/docs/architecture.md index c769960..f8e75f0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,27 +4,72 @@ ```mermaid flowchart LR - Client[HTTP/3 client] -->|UDP QUIC + TLS 1.3| OpenSSL[OpenSSL QUIC] - OpenSSL --> nghttp3[nghttp3 HTTP/3] + Client[HTTP/3 client] -->|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 --> OpenSSL + nghttp3 --> Engine ``` ## Layers -- **OpenSSL** owns QUIC transport and TLS 1.3. +- **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/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__ops()` 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 supplies certificates and callbacks through `quic_config`, engines report +failures through an error buffer rather than logging, and `quic/src/quic_tls.c` +builds the one TLS context both engines serve from. Because the transport +libraries are linked privately, ngtcp2's headers stay off the include path of +every translation unit outside `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 unavailable from the active OpenSSL integration. +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 index 6bda517..f8251e0 100644 --- a/docs/build.md +++ b/docs/build.md @@ -3,11 +3,14 @@ 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 --recursive +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 @@ -25,6 +28,23 @@ the `WITH_*` options only if they meet these minimums. 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 diff --git a/docs/configuration.md b/docs/configuration.md index b365f4b..63fb05e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -36,7 +36,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 +44,7 @@ Build order: Force a rebuild: ```sh -rm -rf dependencies/openssl-dist +rm -rf quic/third-party/openssl-dist cmake -B build ``` @@ -106,7 +106,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 +121,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 +138,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/docs/configuration_httpd.md b/docs/configuration_httpd.md index 1b85d20..acdaa0b 100644 --- a/docs/configuration_httpd.md +++ b/docs/configuration_httpd.md @@ -127,6 +127,16 @@ Whether to validate a client's source address before accepting a connection. Whe 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` +**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. + +The engine in use is reported by the `http3-status` handler as `quic_backend`. + ## VirtualHost Configuration ### Port Detection diff --git a/interop/Containerfile b/interop/Containerfile index a04e5d3..5f7818e 100644 --- a/interop/Containerfile +++ b/interop/Containerfile @@ -35,7 +35,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY --from=builder /src/dependencies/apr-dist/ /src/dependencies/apr-dist/ COPY --from=builder /src/dependencies/apr-util-dist/ /src/dependencies/apr-util-dist/ -COPY --from=builder /src/dependencies/openssl-dist/ /src/dependencies/openssl-dist/ +COPY --from=builder /src/quic/third-party/openssl-dist/ /src/quic/third-party/openssl-dist/ COPY --from=builder /src/dependencies/nghttp3-dist/ /src/dependencies/nghttp3-dist/ COPY --from=builder /src/dependencies/httpd-dist/ /src/dependencies/httpd-dist/ COPY --from=builder /src/build/lib/mod_http3.so /src/dependencies/httpd-dist/modules/mod_http3.so 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 1ccee03..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; diff --git a/mod_http3/include/h3_quic.h b/mod_http3/include/h3_quic.h new file mode 100644 index 0000000..90f2cbb --- /dev/null +++ b/mod_http3/include/h3_quic.h @@ -0,0 +1,45 @@ +/* + * 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_QUIC_H +#define H3_QUIC_H + +#include + +/** + * Make @p name the engine every later call dispatches to. + * @param name Engine name, matched case-insensitively. + * @return 1 if this build contains @p name, 0 otherwise. On 0 the previous + * selection is left untouched. + */ +int quic_select(const char* name); + +/** + * Name of the engine currently selected. + * @return Engine name; the build's default until quic_select() succeeds. + */ +const char* quic_engine_name(void); + +/** + * List the engines this build contains, for diagnostics. + * @param pool Pool the returned string is allocated from. + * @return Comma-separated names, for example "openssl, ngtcp2". + */ +const char* quic_engine_names(apr_pool_t* pool); + +#endif /* H3_QUIC_H */ diff --git a/mod_http3/include/h3_session.h b/mod_http3/include/h3_session.h index d5b0cc0..c095de9 100644 --- a/mod_http3/include/h3_session.h +++ b/mod_http3/include/h3_session.h @@ -135,6 +135,15 @@ apr_status_t h3_session_create(h3_session** psession, server_rec* s, quic_conn* */ 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. diff --git a/mod_http3/include/h3_version.h b/mod_http3/include/h3_version.h index 3f569a7..87e2c19 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 48 +#define MOD_HTTP3_VERSION_PATCH 49 // 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.48" +#define MOD_HTTP3_VERSION_STRING "0.0.49" #endif /* H3_VERSION_H */ diff --git a/mod_http3/src/h3_callbacks.c b/mod_http3/src/h3_callbacks.c index 79f442f..d5c5129 100644 --- a/mod_http3/src/h3_callbacks.c +++ b/mod_http3/src/h3_callbacks.c @@ -140,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. */ @@ -183,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; @@ -191,6 +192,19 @@ int on_acked_stream_data(nghttp3_conn* conn, int64_t stream_id, uint64_t datalen return 0; } +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) { h3_session* session = user_data; diff --git a/mod_http3/src/h3_config.c b/mod_http3/src/h3_config.c index df8e13d..3df6844 100644 --- a/mod_http3/src/h3_config.c +++ b/mod_http3/src/h3_config.c @@ -34,7 +34,9 @@ #include "h3.h" #include "h3_check.h" #include "h3_config.h" +#include "h3_quic.h" #include "mod_http3.h" +#include "quic.h" apr_port_t get_server_port(const server_rec* s) { @@ -61,6 +63,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; @@ -105,6 +108,11 @@ 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* set_h3_quic_engine(cmd_parms* cmd, void* /*dummy*/, const char* arg) +{ + 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) @@ -423,6 +431,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). Rebuild with -DENABLE_NGTCP2=ON.", conf->h3_quic_engine, quic_engine_names(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) @@ -471,5 +485,7 @@ const command_rec cmd_11 = AP_INIT_TAKE1("H3IdleTimeout", set_h3_idle_timeout, N 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_13, 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_hooks.c b/mod_http3/src/h3_hooks.c index f2e7460..a8cb401 100644 --- a/mod_http3/src/h3_hooks.c +++ b/mod_http3/src/h3_hooks.c @@ -17,6 +17,7 @@ */ #include "h3_config.h" +#include "h3_quic.h" #include #include @@ -40,6 +41,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 +174,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 77c916e..19ad7bf 100644 --- a/mod_http3/src/h3_io.c +++ b/mod_http3/src/h3_io.c @@ -63,6 +63,12 @@ static void teardown(h3_io_t* 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; @@ -132,6 +138,7 @@ apr_status_t h3_io_listen_start(apr_pool_t* pchild, server_rec* s, h3_server_con .key_path = conf->h3_key_path, .address_validation = (conf->h3_address_validation != H3_FLAG_OFF), .idle_timeout_secs = (uint32_t)conf->h3_idle_timeout, + .on_stream_acked = h3_session_on_stream_acked, }; io->qengine = quic_engine_create(&qcfg, udp_fd, qerr, sizeof(qerr)); if (!io->qengine) @@ -261,6 +268,7 @@ static apr_status_t spawn_serviced_session(h3_io_t* io, quic_conn* 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); diff --git a/mod_http3/src/h3_quic.c b/mod_http3/src/h3_quic.c index 8501c61..10677b4 100644 --- a/mod_http3/src/h3_quic.c +++ b/mod_http3/src/h3_quic.c @@ -16,8 +16,16 @@ * limitations under the License. */ +#include +#include + +#include "h3_quic.h" #include "quic_ossl.h" +#ifdef H3_ENABLE_NGTCP2 + #include "quic_ngtcp2.h" +#endif + typedef struct quic_engine_entry { const char* name; @@ -26,9 +34,44 @@ typedef struct quic_engine_entry static const quic_engine_entry quic_engines[] = { {"openssl", quic_ossl_ops}, +#ifdef H3_ENABLE_NGTCP2 + {"ngtcp2", quic_ngtcp2_ops}, +#endif }; +#define QUIC_ENGINE_COUNT (sizeof(quic_engines) / sizeof(quic_engines[0])) + +static size_t quic_active; + +int quic_select(const char* name) +{ + for (size_t i = 0; i < QUIC_ENGINE_COUNT; i++) + { + if (apr_cstr_casecmp(name, quic_engines[i].name) == 0) + { + quic_active = i; + return 1; + } + } + return 0; +} + +const char* quic_engine_name(void) +{ + return quic_engines[quic_active].name; +} + +const char* quic_engine_names(apr_pool_t* pool) +{ + const char* list = quic_engines[0].name; + for (size_t i = 1; i < QUIC_ENGINE_COUNT; i++) + { + list = apr_pstrcat(pool, list, ", ", quic_engines[i].name, NULL); + } + return list; +} + const quic_ops* quic_get_ops(void) { - return quic_engines[0].ops(); + return quic_engines[quic_active].ops(); } diff --git a/mod_http3/src/h3_session.c b/mod_http3/src/h3_session.c index 9b50e32..8ce302a 100644 --- a/mod_http3/src/h3_session.c +++ b/mod_http3/src/h3_session.c @@ -79,7 +79,15 @@ apr_status_t h3_session_create(h3_session** psession, server_rec* s, quic_conn* 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) @@ -96,6 +104,18 @@ apr_status_t h3_session_create(h3_session** psession, server_rec* s, quic_conn* 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); @@ -156,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) { diff --git a/mod_http3/src/h3_stream.c b/mod_http3/src/h3_stream.c index cfb8c0f..4c3f7cd 100644 --- a/mod_http3/src/h3_stream.c +++ b/mod_http3/src/h3_stream.c @@ -134,13 +134,17 @@ void flush_nghttp3(h3_session* session) } 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 (!ops->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; } } diff --git a/quic/CMakeLists.txt b/quic/CMakeLists.txt index da0b809..624fe9c 100644 --- a/quic/CMakeLists.txt +++ b/quic/CMakeLists.txt @@ -13,3 +13,4 @@ set_target_properties(${PROJECT_NAME}-quic PROPERTIES C_VISIBILITY_PRESET hidden apply_target_flags(${PROJECT_NAME}-quic) add_subdirectory(ossl) +add_subdirectory(ngtcp2) diff --git a/quic/ngtcp2/CMakeLists.txt b/quic/ngtcp2/CMakeLists.txt new file mode 100644 index 0000000..5582822 --- /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 PUBLIC H3_ENABLE_NGTCP2) +target_include_directories(${PROJECT_NAME}-quic + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include" + PRIVATE "${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..f0fa16a --- /dev/null +++ b/quic/ngtcp2/include/detail/quic_ngtcp2_funcs.h @@ -0,0 +1,214 @@ +/* + * 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 Certificates, timeouts and callbacks the engine runs with. + * @param udp_fd Pre-opened non-blocking UDP socket bound to the listen port. + * @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, int udp_fd, 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..295acca --- /dev/null +++ b/quic/ngtcp2/include/quic_ngtcp2.h @@ -0,0 +1,76 @@ +/* + * 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" + +/** + * Operations table 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. + * @return Table with static storage duration; never NULL. + */ +static inline const quic_ops* quic_ngtcp2_ops(void) +{ + static const quic_ops ops = { + .caps = + { + .acks_are_write_offsets = 0, + }, + .engine = + { + .create = quic_ngtcp2_engine_create, + .destroy = quic_ngtcp2_engine_destroy, + .socket_configure = NULL, + .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 &ops; +} + +#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..f8ea9c0 --- /dev/null +++ b/quic/ngtcp2/src/detail/quic_ngtcp2_impl.h @@ -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. + */ + +#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; + int udp_fd; + 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..c65aa6e --- /dev/null +++ b/quic/ngtcp2/src/quic_ngtcp2.c @@ -0,0 +1,602 @@ +/* + * 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 (sendto(conn->engine->udp_fd, buf, len, 0, 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 (sendto(engine->udp_fd, buf, len, 0, 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(); + + ngtcp2_transport_params params; + ngtcp2_transport_params_default(¶ms); + params.max_idle_timeout = engine->idle_timeout_ns; + params.initial_max_data = 1024 * 1024; + params.initial_max_stream_data_bidi_local = 256 * 1024; + params.initial_max_stream_data_bidi_remote = 256 * 1024; + params.initial_max_stream_data_uni = 256 * 1024; + params.initial_max_streams_bidi = 128; + params.initial_max_streams_uni = 8; + 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, int udp_fd, char* err, size_t errlen) +{ + QUIC_CHECK(cfg); + + quic_engine* engine = calloc(1, sizeof(*engine)); + if (!engine) + { + quic_tls_error(err, errlen, "allocating the engine failed"); + return NULL; + } + engine->cfg = *cfg; + engine->udp_fd = udp_fd; + engine->validate_addr = cfg->address_validation; + engine->idle_timeout_ns = (uint64_t)cfg->idle_timeout_secs * NGTCP2_SECONDS; + + 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 = recvfrom(engine->udp_fd, buf, buflen, 0, (struct sockaddr*)peer, peerlen); + } while (*nread < 0 && errno == EINTR); + + if (*nread < 0) + { + return 0; + } + + *locallen = sizeof(*local); + return getsockname(engine->udp_fd, (struct sockaddr*)local, locallen) == 0; +} + +int quic_ngtcp2_engine_pump(quic_engine* engine) +{ + if (!engine || engine->udp_fd < 0) + { + 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..0cf3dd3 --- /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.on_stream_acked) + { + conn->engine->cfg.on_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/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/dependencies/openssl b/quic/third-party/openssl similarity index 100% rename from dependencies/openssl rename to quic/third-party/openssl diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 63c81c5..4b8e808 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -82,6 +82,11 @@ 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/ngtcp2/, so the dependency test asks for it by name. +if(ENABLE_NGTCP2) + target_link_libraries(${PROJECT_NAME}_tests PRIVATE 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 1dd1aab..6a7d4e8 100644 --- a/test/http3/env.py +++ b/test/http3/env.py @@ -88,6 +88,7 @@ def add_vhost_test1( h3_handshake_timeout=None, h3_idle_timeout=None, h3_address_validation=None, + h3_quic_engine=None, extra_lines=None ): self.start_vhost( @@ -124,6 +125,9 @@ def add_vhost_test1( 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/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 } From 1aeafd84bce3bf94b94d4e4ec35874cb4bc52278 Mon Sep 17 00:00:00 2001 From: t4r3k <142579274+machine-moon@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:02:20 -0400 Subject: [PATCH 09/12] v0.0.50 - test both engines in ci and rebuild the interop image --- .containerignore | 1 + .github/workflows/ci.yml | 2 ++ .github/workflows/interop.yml | 36 +++++++++++++++++++----------- .github/workflows/test.yml | 12 +++++++--- CHANGES | 9 ++++++++ CMakeLists.txt | 2 +- docs/configuration.md | 7 ++++++ docs/containers.md | 6 ++++- docs/interop.md | 30 ++++++++++++++++--------- docs/site/Doxyfile | 4 ++-- interop/Containerfile | 40 ++++++---------------------------- interop/run_endpoint.sh | 4 ++-- mod_http3/include/h3_version.h | 4 ++-- quic/CMakeLists.txt | 5 +++++ 14 files changed, 95 insertions(+), 67 deletions(-) diff --git a/.containerignore b/.containerignore index 546a395..ae99225 100644 --- a/.containerignore +++ b/.containerignore @@ -2,6 +2,7 @@ .ai/ dependencies/*-dist/ quic/third-party/*-dist/ +test/certs/ build*/ keys/ certs/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42b165e..e1e6af1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,8 @@ jobs: needs: build if: github.event_name != 'pull_request' uses: ./.github/workflows/interop.yml + with: + module-image: ${{ needs.build.outputs.image }} permissions: contents: read packages: write diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index 07c2b8b..309f86c 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -2,6 +2,11 @@ name: interop on: workflow_call: + inputs: + module-image: + description: Image the module was built into, reused for the endpoint. + required: true + type: string permissions: contents: read @@ -39,7 +44,7 @@ jobs: with: context: . file: interop/Containerfile - build-args: JOBS=4 + build-args: MODULE_IMAGE=${{ inputs.module-image }} tags: ${{ env.IMAGE }} load: true cache-from: type=gha,scope=interop @@ -56,7 +61,7 @@ jobs: with: context: . file: interop/Containerfile - build-args: JOBS=4 + build-args: MODULE_IMAGE=${{ inputs.module-image }} tags: ${{ env.IMAGE }} push: true cache-from: type=gha,scope=interop @@ -72,7 +77,7 @@ jobs: echo "list=$list" | tee -a "$GITHUB_OUTPUT" test: - name: test (${{ matrix.client }}) + name: test (${{ matrix.engine }}, ${{ matrix.client }}) needs: build runs-on: ubuntu-latest permissions: @@ -82,9 +87,11 @@ jobs: fail-fast: false max-parallel: 4 matrix: + engine: [openssl, ngtcp2] client: ${{ fromJson(needs.build.outputs.clients) }} env: CLIENT: ${{ matrix.client }} + ENGINE: ${{ matrix.engine }} steps: - name: Checkout runner @@ -120,18 +127,21 @@ jobs: sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends tshark sudo modprobe ip6table_filter + - name: Pull images + run: | + docker pull "$IMAGE" + python pull.py -i "$CLIENT" + + - name: Pin the engine + run: printf 'FROM %s\nENV ENGINE=%s\n' "$IMAGE" "$ENGINE" | docker build -t "mod_http3-$ENGINE" - + - name: Register endpoint run: | - jq --arg image "$IMAGE" --arg url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \ + jq --arg image "mod_http3-$ENGINE" --arg url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" \ '. + {mod_http3: {image: $image, role: "server", url: $url}}' \ implementations_quic.json >impls.json mv impls.json implementations_quic.json - - name: Pull images - run: | - docker pull "$IMAGE" - python pull.py -i "$CLIENT" - - name: Run matrix continue-on-error: true run: python run.py -s mod_http3 -c "$CLIENT" -t http3 -l logs -j results.json @@ -139,17 +149,17 @@ jobs: - name: Report verdict run: | result=$(jq -r '.results[0][0].result' results.json) - echo "### $CLIENT — \`$result\`" >>"$GITHUB_STEP_SUMMARY" + echo "### $ENGINE / $CLIENT — \`$result\`" >>"$GITHUB_STEP_SUMMARY" case $result in succeeded) ;; - unsupported) echo "::warning title=$CLIENT::the client has no http3 test case" ;; - *) echo "::error title=$CLIENT::http3 $result"; exit 1 ;; + unsupported) echo "::warning title=$ENGINE/$CLIENT::the client has no http3 test case" ;; + *) echo "::error title=$ENGINE/$CLIENT::http3 $result"; exit 1 ;; esac - name: Keep logs if: failure() uses: actions/upload-artifact@v7 with: - name: interop-logs-${{ matrix.client }} + name: interop-logs-${{ matrix.engine }}-${{ matrix.client }} path: logs/ retention-days: 14 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 63d4e17..6479163 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,8 +11,14 @@ env: jobs: suite: - name: suite + name: suite (${{ matrix.engine }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + engine: [openssl, ngtcp2] + env: + H3_QUIC_ENGINE: ${{ matrix.engine }} steps: - name: Checkout @@ -40,12 +46,12 @@ jobs: - name: Run pytest run: | mkdir -p logs - docker run --rm -v "$PWD/logs:/src/build/test/gen" "$IMAGE" pytest -v + docker run --rm -e "H3_QUIC_ENGINE=$H3_QUIC_ENGINE" -v "$PWD/logs:/src/build/test/gen" "$IMAGE" pytest -v - name: Keep logs if: failure() uses: actions/upload-artifact@v7 with: - name: test-logs + name: test-logs-${{ matrix.engine }} path: logs/ retention-days: 14 diff --git a/CHANGES b/CHANGES index c6d3a5e..5e3a913 100644 --- a/CHANGES +++ b/CHANGES @@ -3,6 +3,15 @@ mod_http3 changes Changes are listed most recent first. Security-related entries always appear at the top of their release block. +v0.0.50 (2026-08-03) +-------------------- + *) Split the CI test and interop matrices per QUIC engine. + [Tarek Ibrahim ] + + *) Updated interop container by copying from the published module image + instead of recompiling from scratch, saves a lot of ci minutes. + [Tarek Ibrahim ] + v0.0.49 (2026-08-02) -------------------- *) Added an ngtcp2 QUIC engine behind the QUIC abstraction, with OpenSSL for TLS. diff --git a/CMakeLists.txt b/CMakeLists.txt index f5754d7..b48e5cf 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.26) -project(mod_http3 VERSION 0.0.49) +project(mod_http3 VERSION 0.0.50) # -- Compiler and Build Type Checks -- if(NOT CMAKE_C_COMPILER_ID STREQUAL "GNU") diff --git a/docs/configuration.md b/docs/configuration.md index 63fb05e..19f804e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,6 +6,13 @@ For quick start and deployment, see [INSTALL](../INSTALL). 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 | Command | Description | diff --git a/docs/containers.md b/docs/containers.md index 9a319c5..7d0c69f 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -101,6 +101,10 @@ 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 @@ -131,7 +135,7 @@ are all compiled from source. Pin a version for anything reproducible: ```sh -podman pull ghcr.io/machine-moon/mod_http3:0.0.54 +podman pull ghcr.io/machine-moon/mod_http3:0.0.50 ``` ## The interop endpoint diff --git a/docs/interop.md b/docs/interop.md index 27a4131..799f834 100644 --- a/docs/interop.md +++ b/docs/interop.md @@ -14,16 +14,26 @@ 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 `endpoint` job builds `interop/Containerfile`, checks the image answers -`127` for a test case it does not implement, and publishes it as +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:`. -Each `client` job then **pulls that tag back out of the registry** and runs the +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, and each job is named after the client it -tests — the check list reads as a per-client result matrix. +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 @@ -31,7 +41,7 @@ and reproducible. ## Reading the results -Each client job writes its verdict to the workflow summary. A client that does +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/_//`: @@ -50,7 +60,7 @@ 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 -`client` job does: +`test` job does: ```sh python run.py -s mod_http3 -c quic-go -t http3 -l logs -j results.json @@ -84,15 +94,15 @@ client in the matrix. **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 `endpoint` job pre-checks the same thing -with a timeout before any client job starts. +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 client jobs pin one. +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 diff --git a/docs/site/Doxyfile b/docs/site/Doxyfile index 1a57472..89db3f2 100644 --- a/docs/site/Doxyfile +++ b/docs/site/Doxyfile @@ -6,9 +6,9 @@ GENERATE_HTML = NO GENERATE_XML = YES XML_OUTPUT = xml INPUT = mod_http3/include mod_http3/src quic/include -# Only the QUIC contract is published; engine internals stay hidden. -EXCLUDE = quic/include/detail quic/ossl RECURSIVE = YES +# Only the QUIC contract is published; engine internals stay hidden. +EXCLUDE = quic/ossl quic/ngtcp2 OPTIMIZE_OUTPUT_FOR_C = YES EXTRACT_ALL = YES EXTRACT_PRIVATE = NO diff --git a/interop/Containerfile b/interop/Containerfile index 5f7818e..b662659 100644 --- a/interop/Containerfile +++ b/interop/Containerfile @@ -1,30 +1,5 @@ -FROM martenseemann/quic-network-simulator-endpoint:latest AS builder - -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential gcc-14 cmake ninja-build git pkg-config perl python3 \ - autoconf automake libtool libtool-bin ca-certificates \ - libpcre2-dev libxml2-dev libexpat1-dev zlib1g-dev \ - liblua5.4-dev libbrotli-dev libnghttp2-dev \ - && rm -rf /var/lib/apt/lists/* - -ENV CC=gcc-14 -ARG JOBS - -WORKDIR /src - -COPY dependencies/ dependencies/ -COPY cmake/ cmake/ -COPY CMakeLists.txt LICENSE ./ -COPY mod_http3/ mod_http3/ -COPY quic/ quic/ - -RUN cmake -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DBUILD_MODULE=ON \ - -DBUILD_EXAMPLES=OFF \ - -DBUILD_TESTS=OFF \ - -DDEPENDENCIES_PARALLEL="${JOBS:-$(nproc)}" \ - && cmake --build build -j "${JOBS:-$(nproc)}" +ARG MODULE_IMAGE +FROM ${MODULE_IMAGE} AS module FROM martenseemann/quic-network-simulator-endpoint:latest AS runtime @@ -33,12 +8,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ && rm -rf /var/lib/apt/lists/* -COPY --from=builder /src/dependencies/apr-dist/ /src/dependencies/apr-dist/ -COPY --from=builder /src/dependencies/apr-util-dist/ /src/dependencies/apr-util-dist/ -COPY --from=builder /src/quic/third-party/openssl-dist/ /src/quic/third-party/openssl-dist/ -COPY --from=builder /src/dependencies/nghttp3-dist/ /src/dependencies/nghttp3-dist/ -COPY --from=builder /src/dependencies/httpd-dist/ /src/dependencies/httpd-dist/ -COPY --from=builder /src/build/lib/mod_http3.so /src/dependencies/httpd-dist/modules/mod_http3.so +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 diff --git a/interop/run_endpoint.sh b/interop/run_endpoint.sh index 40404b0..d39cf13 100755 --- a/interop/run_endpoint.sh +++ b/interop/run_endpoint.sh @@ -17,9 +17,9 @@ esac 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 -echo "H3AddressValidation off" >/interop/testcase.conf +printf 'H3AddressValidation off\nH3QuicEngine %s\n' "${ENGINE:-openssl}" >/interop/testcase.conf -echo "TESTCASE=$TESTCASE" +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_version.h b/mod_http3/include/h3_version.h index 87e2c19..348e848 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 49 +#define MOD_HTTP3_VERSION_PATCH 50 // 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.49" +#define MOD_HTTP3_VERSION_STRING "0.0.50" #endif /* H3_VERSION_H */ diff --git a/quic/CMakeLists.txt b/quic/CMakeLists.txt index 624fe9c..e12dd89 100644 --- a/quic/CMakeLists.txt +++ b/quic/CMakeLists.txt @@ -12,5 +12,10 @@ target_include_directories(${PROJECT_NAME}-quic 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(ossl) add_subdirectory(ngtcp2) From 094121e14fea287d00bcb2da0ee55ab1c0a28646 Mon Sep 17 00:00:00 2001 From: t4r3k <142579274+machine-moon@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:11:32 -0400 Subject: [PATCH 10/12] v0.0.51 - generalise the quic interface --- CHANGES | 13 ++ CMakeLists.txt | 2 +- docs/architecture.md | 27 ++- docs/configuration_httpd.md | 17 +- docs/site/Doxyfile | 4 +- mod_http3/include/h3_io.h | 1 + mod_http3/include/h3_quic.h | 45 ---- mod_http3/include/h3_version.h | 4 +- mod_http3/src/h3_config.c | 31 ++- mod_http3/src/h3_hooks.c | 1 - mod_http3/src/h3_io.c | 14 +- mod_http3/src/h3_stream.c | 10 +- quic/CMakeLists.txt | 18 +- quic/include/quic_types.h | 105 ---------- quic/ngtcp2/CMakeLists.txt | 8 +- .../ngtcp2/include/detail/quic_ngtcp2_funcs.h | 5 +- quic/ngtcp2/include/quic_ngtcp2.h | 13 +- quic/ngtcp2/src/detail/quic_ngtcp2_impl.h | 3 +- quic/ngtcp2/src/quic_ngtcp2.c | 43 ++-- quic/ngtcp2/src/quic_ngtcp2_conn.c | 4 +- quic/null/CMakeLists.txt | 7 + quic/null/include/detail/quic_null_funcs.h | 169 +++++++++++++++ quic/null/include/quic_null.h | 79 +++++++ quic/null/src/quic_null.c | 158 ++++++++++++++ quic/ossl/CMakeLists.txt | 6 +- quic/ossl/include/detail/quic_ossl_funcs.h | 14 +- quic/ossl/include/quic_ossl.h | 14 +- quic/ossl/src/detail/quic_ossl_impl.h | 2 + quic/ossl/src/quic_ossl.c | 18 +- quic/quic/CMakeLists.txt | 10 + quic/{ => quic}/include/quic.h | 0 quic/{ => quic}/include/quic_funcs.h | 130 +++++++----- quic/quic/include/quic_types.h | 192 ++++++++++++++++++ quic/{ => quic}/src/detail/quic_check.h | 0 quic/{ => quic}/src/detail/quic_tls.h | 0 quic/quic/src/quic_io_udp.c | 68 +++++++ .../quic/src/quic_registry.c | 58 +++--- quic/quic/src/quic_settings.c | 39 ++++ quic/{ => quic}/src/quic_tls.c | 37 +++- test/CMakeLists.txt | 3 +- 40 files changed, 1047 insertions(+), 325 deletions(-) delete mode 100644 mod_http3/include/h3_quic.h delete mode 100644 quic/include/quic_types.h create mode 100644 quic/null/CMakeLists.txt create mode 100644 quic/null/include/detail/quic_null_funcs.h create mode 100644 quic/null/include/quic_null.h create mode 100644 quic/null/src/quic_null.c create mode 100644 quic/quic/CMakeLists.txt rename quic/{ => quic}/include/quic.h (100%) rename quic/{ => quic}/include/quic_funcs.h (69%) create mode 100644 quic/quic/include/quic_types.h rename quic/{ => quic}/src/detail/quic_check.h (100%) rename quic/{ => quic}/src/detail/quic_tls.h (100%) create mode 100644 quic/quic/src/quic_io_udp.c rename mod_http3/src/h3_quic.c => quic/quic/src/quic_registry.c (52%) create mode 100644 quic/quic/src/quic_settings.c rename quic/{ => quic}/src/quic_tls.c (69%) diff --git a/CHANGES b/CHANGES index 5e3a913..4d0e2b9 100644 --- a/CHANGES +++ b/CHANGES @@ -3,6 +3,19 @@ mod_http3 changes Changes are listed most recent first. Security-related entries always appear at the top of their release block. +v0.0.51 (2026-08-04) +-------------------- + *) Updated QUIC library to generalize easier. + [Tarek Ibrahim ] + + *) Added the null QUIC engine, which implements the whole contract and + carries nothing as a template. + [Tarek Ibrahim ] + + *) Rejected an unknown H3QuicEngine when the config is parsed, + so httpd -t catches it. + [Tarek Ibrahim ] + v0.0.50 (2026-08-03) -------------------- *) Split the CI test and interop matrices per QUIC engine. diff --git a/CMakeLists.txt b/CMakeLists.txt index b48e5cf..95aae86 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.26) -project(mod_http3 VERSION 0.0.50) +project(mod_http3 VERSION 0.0.51) # -- Compiler and Build Type Checks -- if(NOT CMAKE_C_COMPILER_ID STREQUAL "GNU") diff --git a/docs/architecture.md b/docs/architecture.md index f8e75f0..c9e2be4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,7 +27,7 @@ flowchart LR ## QUIC engines -The transport sits behind one internal interface, `quic/include/quic.h`. +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. @@ -45,7 +45,7 @@ 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__ops()` from +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 @@ -54,11 +54,24 @@ 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 supplies certificates and callbacks through `quic_config`, engines report -failures through an error buffer rather than logging, and `quic/src/quic_tls.c` -builds the one TLS context both engines serve from. Because the transport -libraries are linked privately, ngtcp2's headers stay off the include path of -every translation unit outside `quic/`. +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 diff --git a/docs/configuration_httpd.md b/docs/configuration_httpd.md index acdaa0b..eafbf01 100644 --- a/docs/configuration_httpd.md +++ b/docs/configuration_httpd.md @@ -129,13 +129,28 @@ Turning it off removes one round trip from every connection, at the cost of that ### H3QuicEngine -**Syntax:** `H3QuicEngine openssl|ngtcp2` +**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 diff --git a/docs/site/Doxyfile b/docs/site/Doxyfile index 89db3f2..a02d124 100644 --- a/docs/site/Doxyfile +++ b/docs/site/Doxyfile @@ -5,10 +5,10 @@ GENERATE_LATEX = NO GENERATE_HTML = NO GENERATE_XML = YES XML_OUTPUT = xml -INPUT = mod_http3/include mod_http3/src quic/include +INPUT = mod_http3/include mod_http3/src quic/quic/include RECURSIVE = YES # Only the QUIC contract is published; engine internals stay hidden. -EXCLUDE = quic/ossl quic/ngtcp2 +EXCLUDE = quic/quic/src quic/null quic/ossl quic/ngtcp2 OPTIMIZE_OUTPUT_FOR_C = YES EXTRACT_ALL = YES EXTRACT_PRIVATE = NO diff --git a/mod_http3/include/h3_io.h b/mod_http3/include/h3_io.h index 1e7dd88..e34dc60 100644 --- a/mod_http3/include/h3_io.h +++ b/mod_http3/include/h3_io.h @@ -41,6 +41,7 @@ typedef struct h3_session h3_session; typedef struct h3_io_t { quic_engine* qengine; + quic_io qio; apr_pool_t* pool; server_rec* server; int udp_fd; diff --git a/mod_http3/include/h3_quic.h b/mod_http3/include/h3_quic.h deleted file mode 100644 index 90f2cbb..0000000 --- a/mod_http3/include/h3_quic.h +++ /dev/null @@ -1,45 +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_QUIC_H -#define H3_QUIC_H - -#include - -/** - * Make @p name the engine every later call dispatches to. - * @param name Engine name, matched case-insensitively. - * @return 1 if this build contains @p name, 0 otherwise. On 0 the previous - * selection is left untouched. - */ -int quic_select(const char* name); - -/** - * Name of the engine currently selected. - * @return Engine name; the build's default until quic_select() succeeds. - */ -const char* quic_engine_name(void); - -/** - * List the engines this build contains, for diagnostics. - * @param pool Pool the returned string is allocated from. - * @return Comma-separated names, for example "openssl, ngtcp2". - */ -const char* quic_engine_names(apr_pool_t* pool); - -#endif /* H3_QUIC_H */ diff --git a/mod_http3/include/h3_version.h b/mod_http3/include/h3_version.h index 348e848..41ff281 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 50 +#define MOD_HTTP3_VERSION_PATCH 51 // 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.50" +#define MOD_HTTP3_VERSION_STRING "0.0.51" #endif /* H3_VERSION_H */ diff --git a/mod_http3/src/h3_config.c b/mod_http3/src/h3_config.c index 3df6844..b1f7777 100644 --- a/mod_http3/src/h3_config.c +++ b/mod_http3/src/h3_config.c @@ -34,7 +34,6 @@ #include "h3.h" #include "h3_check.h" #include "h3_config.h" -#include "h3_quic.h" #include "mod_http3.h" #include "quic.h" @@ -108,8 +107,36 @@ 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)); } @@ -433,7 +460,7 @@ int h3_post_config(apr_pool_t* /*p*/, apr_pool_t* /*plog*/, apr_pool_t* ptemp, s 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). Rebuild with -DENABLE_NGTCP2=ON.", conf->h3_quic_engine, quic_engine_names(ptemp)); + 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; } diff --git a/mod_http3/src/h3_hooks.c b/mod_http3/src/h3_hooks.c index a8cb401..c6bc5ac 100644 --- a/mod_http3/src/h3_hooks.c +++ b/mod_http3/src/h3_hooks.c @@ -17,7 +17,6 @@ */ #include "h3_config.h" -#include "h3_quic.h" #include #include diff --git a/mod_http3/src/h3_io.c b/mod_http3/src/h3_io.c index 19ad7bf..5e8ea9f 100644 --- a/mod_http3/src/h3_io.c +++ b/mod_http3/src/h3_io.c @@ -134,13 +134,15 @@ apr_status_t h3_io_listen_start(apr_pool_t* pchild, server_rec* s, h3_server_con char qerr[QUIC_ERRLEN] = {0}; quic_config qcfg = { - .cert_path = conf->h3_cert_path, - .key_path = conf->h3_key_path, - .address_validation = (conf->h3_address_validation != H3_FLAG_OFF), - .idle_timeout_secs = (uint32_t)conf->h3_idle_timeout, - .on_stream_acked = h3_session_on_stream_acked, + .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, }; - io->qengine = quic_engine_create(&qcfg, udp_fd, qerr, sizeof(qerr)); + 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); diff --git a/mod_http3/src/h3_stream.c b/mod_http3/src/h3_stream.c index 4c3f7cd..d6a0b71 100644 --- a/mod_http3/src/h3_stream.c +++ b/mod_http3/src/h3_stream.c @@ -80,7 +80,7 @@ void flush_nghttp3(h3_session* session) CHECK(session); CHECK(!session->ngh3_dead, return;); unblock_writable_streams(session); - const quic_ops* ops = quic_get_ops(); + const quic_api* api = quic_selected(); for (;;) { nghttp3_vec vec[16] = {0}; @@ -106,7 +106,7 @@ void flush_nghttp3(h3_session* session) { /* Stream is gone; swallow its queued bytes so the send queue keeps draining. */ nghttp3_conn_add_write_offset(session->ngh3, sid, expected); - if (ops->caps.acks_are_write_offsets) + if (api->caps.acks_are_write_offsets) { nghttp3_conn_add_ack_offset(session->ngh3, sid, expected); } @@ -121,14 +121,14 @@ void flush_nghttp3(h3_session* session) { /* Peer reset: drop the remainder; teardown happens via the nghttp3 callbacks. */ nghttp3_conn_add_write_offset(session->ngh3, sid, expected); - if (ops->caps.acks_are_write_offsets) + 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, res.accepted); - if (ops->caps.acks_are_write_offsets) + if (api->caps.acks_are_write_offsets) { nghttp3_conn_add_ack_offset(session->ngh3, sid, res.accepted); } @@ -140,7 +140,7 @@ void flush_nghttp3(h3_session* session) session->blocked_streams++; nghttp3_conn_block_stream(session->ngh3, sid); } - else if (!ops->caps.acks_are_write_offsets) + 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); diff --git a/quic/CMakeLists.txt b/quic/CMakeLists.txt index e12dd89..de7ab4e 100644 --- a/quic/CMakeLists.txt +++ b/quic/CMakeLists.txt @@ -1,14 +1,10 @@ -# -- QUIC transport engines -- +# -- 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. -include(openssl) - -file(GLOB_RECURSE sources CONFIGURE_DEPENDS src/*.c) - -add_library(${PROJECT_NAME}-quic STATIC ${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") +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) @@ -17,5 +13,7 @@ set(QUIC_DEPENDENCIES_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/dependencies" 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/include/quic_types.h b/quic/include/quic_types.h deleted file mode 100644 index 5b3b584..0000000 --- a/quic/include/quic_types.h +++ /dev/null @@ -1,105 +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 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; - -#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; - -typedef struct quic_caps -{ - unsigned acks_are_write_offsets : 1; -} quic_caps; - -typedef struct quic_config -{ - const char* cert_path; - const char* key_path; - unsigned address_validation : 1; - uint32_t idle_timeout_secs; - void (*on_stream_acked)(void* user, int64_t stream_id, uint64_t datalen); -} quic_config; - -typedef struct quic_engine_ops -{ - quic_engine* (*create)(const quic_config* cfg, int udp_fd, char* err, size_t errlen); - void (*destroy)(quic_engine* engine); - void (*socket_configure)(quic_engine* engine, int fd); - 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); -} quic_engine_ops; - -typedef struct quic_conn_ops -{ - 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); -} quic_conn_ops; - -typedef struct quic_stream_ops -{ - 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); -} quic_stream_ops; - -typedef struct quic_ops -{ - quic_caps caps; - quic_engine_ops engine; - quic_conn_ops conn; - quic_stream_ops stream; -} quic_ops; - -#endif /* QUIC_TYPES_H */ diff --git a/quic/ngtcp2/CMakeLists.txt b/quic/ngtcp2/CMakeLists.txt index 5582822..1c27fb8 100644 --- a/quic/ngtcp2/CMakeLists.txt +++ b/quic/ngtcp2/CMakeLists.txt @@ -9,7 +9,7 @@ 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 PUBLIC H3_ENABLE_NGTCP2) -target_include_directories(${PROJECT_NAME}-quic - PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include" - PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/src") +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 index f0fa16a..ecfabec 100644 --- a/quic/ngtcp2/include/detail/quic_ngtcp2_funcs.h +++ b/quic/ngtcp2/include/detail/quic_ngtcp2_funcs.h @@ -25,13 +25,12 @@ * 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 Certificates, timeouts and callbacks the engine runs with. - * @param udp_fd Pre-opened non-blocking UDP socket bound to the listen port. + * @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, int udp_fd, char* err, size_t errlen); +quic_engine* quic_ngtcp2_engine_create(const quic_config* cfg, char* err, size_t errlen); /** * Close every live connection and release the TLS context. diff --git a/quic/ngtcp2/include/quic_ngtcp2.h b/quic/ngtcp2/include/quic_ngtcp2.h index 295acca..3e17464 100644 --- a/quic/ngtcp2/include/quic_ngtcp2.h +++ b/quic/ngtcp2/include/quic_ngtcp2.h @@ -22,15 +22,19 @@ #include "detail/quic_ngtcp2_funcs.h" /** - * Operations table for the ngtcp2 QUIC engine, built only under ENABLE_NGTCP2. + * 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_ops* quic_ngtcp2_ops(void) +static inline const quic_api* quic_ngtcp2_api(void) { - static const quic_ops ops = { + static const quic_api api = { .caps = { .acks_are_write_offsets = 0, @@ -39,7 +43,6 @@ static inline const quic_ops* quic_ngtcp2_ops(void) { .create = quic_ngtcp2_engine_create, .destroy = quic_ngtcp2_engine_destroy, - .socket_configure = NULL, .pump = quic_ngtcp2_engine_pump, .want = quic_ngtcp2_engine_want, .accept_conn = quic_ngtcp2_engine_accept_conn, @@ -70,7 +73,7 @@ static inline const quic_ops* quic_ngtcp2_ops(void) .consumed = quic_ngtcp2_stream_consumed, }, }; - return &ops; + 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 index f8ea9c0..f952184 100644 --- a/quic/ngtcp2/src/detail/quic_ngtcp2_impl.h +++ b/quic/ngtcp2/src/detail/quic_ngtcp2_impl.h @@ -87,8 +87,9 @@ void quic_ngtcp2_map_free(quic_ngtcp2_map* map); struct quic_engine { quic_config cfg; + SSL_CTX* ssl_ctx; - int udp_fd; + const quic_io* io; int validate_addr; uint64_t idle_timeout_ns; uint8_t secret[32]; diff --git a/quic/ngtcp2/src/quic_ngtcp2.c b/quic/ngtcp2/src/quic_ngtcp2.c index c65aa6e..623f62c 100644 --- a/quic/ngtcp2/src/quic_ngtcp2.c +++ b/quic/ngtcp2/src/quic_ngtcp2.c @@ -42,7 +42,7 @@ ngtcp2_tstamp quic_ngtcp2_now(void) 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 (sendto(conn->engine->udp_fd, buf, len, 0, dst, (socklen_t)path->remote.addrlen) < 0 && errno == EINTR) + while (conn->engine->io->send(conn->engine->io->io_ctx, buf, len, dst, (socklen_t)path->remote.addrlen) < 0 && errno == EINTR) { } } @@ -134,7 +134,7 @@ void quic_ngtcp2_queue_accept(quic_ngtcp2_conn* conn) static void engine_send_raw(quic_engine* engine, const struct sockaddr* dst, socklen_t dstlen, const uint8_t* buf, size_t len) { - while (sendto(engine->udp_fd, buf, len, 0, dst, dstlen) < 0 && errno == EINTR) + while (engine->io->send(engine->io->io_ctx, buf, len, dst, dstlen) < 0 && errno == EINTR) { } } @@ -240,16 +240,28 @@ static quic_ngtcp2_conn* conn_new(quic_engine* engine, const ngtcp2_pkt_hd* hd, 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 = 1024 * 1024; - params.initial_max_stream_data_bidi_local = 256 * 1024; - params.initial_max_stream_data_bidi_remote = 256 * 1024; - params.initial_max_stream_data_uni = 256 * 1024; - params.initial_max_streams_bidi = 128; - params.initial_max_streams_uni = 8; + 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) @@ -356,9 +368,10 @@ static void engine_expire(quic_engine* engine) } } -quic_engine* quic_ngtcp2_engine_create(const quic_config* cfg, int udp_fd, char* err, size_t errlen) +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) @@ -367,9 +380,9 @@ quic_engine* quic_ngtcp2_engine_create(const quic_config* cfg, int udp_fd, char* return NULL; } engine->cfg = *cfg; - engine->udp_fd = udp_fd; - engine->validate_addr = cfg->address_validation; - engine->idle_timeout_ns = (uint64_t)cfg->idle_timeout_secs * NGTCP2_SECONDS; + 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) { @@ -423,7 +436,7 @@ static int recv_one(quic_engine* engine, uint8_t* buf, size_t buflen, struct soc *peerlen = sizeof(*peer); do { - *nread = recvfrom(engine->udp_fd, buf, buflen, 0, (struct sockaddr*)peer, peerlen); + *nread = engine->io->recv(engine->io->io_ctx, buf, buflen, peer, peerlen); } while (*nread < 0 && errno == EINTR); if (*nread < 0) @@ -432,12 +445,12 @@ static int recv_one(quic_engine* engine, uint8_t* buf, size_t buflen, struct soc } *locallen = sizeof(*local); - return getsockname(engine->udp_fd, (struct sockaddr*)local, locallen) == 0; + return engine->io->local_addr(engine->io->io_ctx, local, locallen); } int quic_ngtcp2_engine_pump(quic_engine* engine) { - if (!engine || engine->udp_fd < 0) + if (!engine || !engine->io) { return 0; } diff --git a/quic/ngtcp2/src/quic_ngtcp2_conn.c b/quic/ngtcp2/src/quic_ngtcp2_conn.c index 0cf3dd3..dfa3972 100644 --- a/quic/ngtcp2/src/quic_ngtcp2_conn.c +++ b/quic/ngtcp2/src/quic_ngtcp2_conn.c @@ -51,9 +51,9 @@ static int cb_acked_stream_data_offset(ngtcp2_conn* qconn, int64_t stream_id, ui (void)offset; (void)stream_user_data; quic_ngtcp2_conn* conn = user_data; - if (conn->user && conn->engine->cfg.on_stream_acked) + if (conn->user && conn->engine->cfg.callbacks.stream_acked) { - conn->engine->cfg.on_stream_acked(conn->user, stream_id, datalen); + conn->engine->cfg.callbacks.stream_acked(conn->user, stream_id, datalen); } return 0; } 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 index 1a9ac4a..da8621f 100644 --- a/quic/ossl/CMakeLists.txt +++ b/quic/ossl/CMakeLists.txt @@ -5,6 +5,6 @@ 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") +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 index 52ee429..afa2a91 100644 --- a/quic/ossl/include/detail/quic_ossl_funcs.h +++ b/quic/ossl/include/detail/quic_ossl_funcs.h @@ -24,13 +24,14 @@ /** * Build the QUIC listener on @p udp_fd, together with the filter BIO that * recovers peer addresses from OpenSSL's accept queue. - * @param cfg Certificates and timeouts the listener runs with. - * @param udp_fd Pre-opened non-blocking UDP socket bound to the listen port. + * @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, int udp_fd, char* err, size_t errlen); +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. @@ -38,13 +39,6 @@ quic_engine* quic_ossl_engine_create(const quic_config* cfg, int udp_fd, char* e */ void quic_ossl_engine_destroy(quic_engine* engine); -/** - * Apply the socket options OpenSSL's QUIC listener expects. - * @param engine Engine owning the socket. - * @param fd Socket to configure. - */ -void quic_ossl_engine_socket_configure(quic_engine* engine, int fd); - /** * Drive one round of listener work: read datagrams, run timers, send. * @param engine Engine to pump. diff --git a/quic/ossl/include/quic_ossl.h b/quic/ossl/include/quic_ossl.h index 954bbf6..4f829c8 100644 --- a/quic/ossl/include/quic_ossl.h +++ b/quic/ossl/include/quic_ossl.h @@ -22,16 +22,19 @@ #include "detail/quic_ossl_funcs.h" /** - * Operations table for the OpenSSL QUIC engine. Sets + * 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. Ops left unset are the + * 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_ops* quic_ossl_ops(void) +static inline const quic_api* quic_ossl_api(void) { - static const quic_ops ops = { + static const quic_api api = { .caps = { .acks_are_write_offsets = 1, @@ -40,7 +43,6 @@ static inline const quic_ops* quic_ossl_ops(void) { .create = quic_ossl_engine_create, .destroy = quic_ossl_engine_destroy, - .socket_configure = quic_ossl_engine_socket_configure, .pump = quic_ossl_engine_pump, .want = quic_ossl_engine_want, .accept_conn = quic_ossl_engine_accept_conn, @@ -71,7 +73,7 @@ static inline const quic_ops* quic_ossl_ops(void) .consumed = NULL, }, }; - return &ops; + 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 index a8db1b3..7b1ec4e 100644 --- a/quic/ossl/src/detail/quic_ossl_impl.h +++ b/quic/ossl/src/detail/quic_ossl_impl.h @@ -28,6 +28,8 @@ typedef struct quic_ossl_datagram quic_ossl_datagram; struct quic_engine { + quic_config cfg; + SSL_CTX* ssl_ctx; SSL* ssl_listener; diff --git a/quic/ossl/src/quic_ossl.c b/quic/ossl/src/quic_ossl.c index 9742822..bb847ec 100644 --- a/quic/ossl/src/quic_ossl.c +++ b/quic/ossl/src/quic_ossl.c @@ -27,9 +27,16 @@ #include "quic.h" #include "quic_ossl.h" -quic_engine* quic_ossl_engine_create(const quic_config* cfg, int udp_fd, char* err, size_t errlen) +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) { @@ -37,6 +44,7 @@ quic_engine* quic_ossl_engine_create(const quic_config* cfg, int udp_fd, char* e 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) @@ -69,7 +77,7 @@ quic_engine* quic_ossl_engine_create(const quic_config* cfg, int udp_fd, char* e SSL_CTX_set_new_pending_conn_cb(engine->ssl_ctx, quic_ossl_new_pending_conn_cb, engine); - uint64_t listener_flags = cfg->address_validation ? 0 : (uint64_t)SSL_LISTENER_FLAG_NO_VALIDATE; + 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) { @@ -145,12 +153,6 @@ const char* quic_ossl_engine_last_error(quic_engine* engine) return engine->err; } -void quic_ossl_engine_socket_configure(quic_engine* engine, int fd) -{ - (void)engine; - (void)fd; -} - int quic_ossl_engine_pump(quic_engine* engine) { if (!engine || !engine->ssl_listener) 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/include/quic.h b/quic/quic/include/quic.h similarity index 100% rename from quic/include/quic.h rename to quic/quic/include/quic.h diff --git a/quic/include/quic_funcs.h b/quic/quic/include/quic_funcs.h similarity index 69% rename from quic/include/quic_funcs.h rename to quic/quic/include/quic_funcs.h index 8b887fb..cff035b 100644 --- a/quic/include/quic_funcs.h +++ b/quic/quic/include/quic_funcs.h @@ -22,22 +22,63 @@ #include "quic_types.h" /** - * The selected engine's operations table. + * 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_ops* quic_get_ops(void); +const quic_api* quic_selected(void); /** - * Create a QUIC engine bound to an already-open UDP socket. - * @param cfg Certificates, timeouts and callbacks the engine runs with. - * @param udp_fd Pre-opened non-blocking UDP socket. + * 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, int udp_fd, char* err, size_t errlen) +static inline quic_engine* quic_engine_create(const quic_config* cfg, char* err, size_t errlen) { - return quic_get_ops()->engine.create(cfg, udp_fd, err, errlen); + return quic_selected()->engine.create(cfg, err, errlen); } /** @@ -46,22 +87,9 @@ static inline quic_engine* quic_engine_create(const quic_config* cfg, int udp_fd */ static inline void quic_engine_destroy(quic_engine* engine) { - if (engine && quic_get_ops()->engine.destroy) - { - quic_get_ops()->engine.destroy(engine); - } -} - -/** - * Apply any engine-specific socket options. - * @param engine Engine owning the socket; NULL is ignored. - * @param fd Socket to configure. - */ -static inline void quic_engine_socket_configure(quic_engine* engine, int fd) -{ - if (engine && quic_get_ops()->engine.socket_configure) + if (engine && quic_selected()->engine.destroy) { - quic_get_ops()->engine.socket_configure(engine, fd); + quic_selected()->engine.destroy(engine); } } @@ -72,7 +100,7 @@ static inline void quic_engine_socket_configure(quic_engine* engine, int fd) */ static inline int quic_engine_pump(quic_engine* engine) { - return engine ? quic_get_ops()->engine.pump(engine) : 0; + return engine ? quic_selected()->engine.pump(engine) : 0; } /** @@ -84,9 +112,9 @@ static inline int quic_engine_pump(quic_engine* engine) */ static inline void quic_engine_want(quic_engine* engine, int* want_read, int* want_write, int* timeout_ms) { - if (engine && quic_get_ops()->engine.want) + if (engine && quic_selected()->engine.want) { - quic_get_ops()->engine.want(engine, want_read, want_write, timeout_ms); + quic_selected()->engine.want(engine, want_read, want_write, timeout_ms); } } @@ -97,7 +125,7 @@ static inline void quic_engine_want(quic_engine* engine, int* want_read, int* wa */ static inline quic_conn* quic_engine_accept_conn(quic_engine* engine) { - return engine ? quic_get_ops()->engine.accept_conn(engine) : NULL; + return engine ? quic_selected()->engine.accept_conn(engine) : NULL; } /** @@ -110,7 +138,7 @@ static inline quic_conn* quic_engine_accept_conn(quic_engine* engine) */ static inline int quic_engine_peer_addr(quic_engine* engine, quic_conn* conn, struct sockaddr_storage* addr, socklen_t* addr_len) { - return engine ? quic_get_ops()->engine.peer_addr(engine, conn, addr, addr_len) : 0; + return engine ? quic_selected()->engine.peer_addr(engine, conn, addr, addr_len) : 0; } /** @@ -120,7 +148,7 @@ static inline int quic_engine_peer_addr(quic_engine* engine, quic_conn* conn, st */ static inline const char* quic_engine_last_error(quic_engine* engine) { - return engine ? quic_get_ops()->engine.last_error(engine) : ""; + return engine ? quic_selected()->engine.last_error(engine) : ""; } /** @@ -131,7 +159,7 @@ static inline const char* quic_engine_last_error(quic_engine* engine) */ static inline int quic_conn_prepare(quic_conn* conn, uint32_t idle_timeout_secs) { - return conn ? quic_get_ops()->conn.prepare(conn, idle_timeout_secs) : 0; + return conn ? quic_selected()->conn.prepare(conn, idle_timeout_secs) : 0; } /** @@ -141,9 +169,9 @@ static inline int quic_conn_prepare(quic_conn* conn, uint32_t idle_timeout_secs) */ static inline void quic_conn_set_user(quic_conn* conn, void* user) { - if (conn && quic_get_ops()->conn.set_user) + if (conn && quic_selected()->conn.set_user) { - quic_get_ops()->conn.set_user(conn, user); + quic_selected()->conn.set_user(conn, user); } } @@ -155,7 +183,7 @@ static inline void quic_conn_set_user(quic_conn* conn, void* user) */ static inline quic_stream* quic_conn_open_uni_stream(quic_conn* conn, int64_t* out_id) { - return quic_get_ops()->conn.open_uni_stream(conn, out_id); + return quic_selected()->conn.open_uni_stream(conn, out_id); } /** @@ -165,7 +193,7 @@ static inline quic_stream* quic_conn_open_uni_stream(quic_conn* conn, int64_t* o */ static inline quic_stream* quic_conn_accept_stream(quic_conn* conn) { - return quic_get_ops()->conn.accept_stream(conn); + return quic_selected()->conn.accept_stream(conn); } /** @@ -175,7 +203,7 @@ static inline quic_stream* quic_conn_accept_stream(quic_conn* conn) */ static inline int quic_conn_is_handshake_done(quic_conn* conn) { - return quic_get_ops()->conn.is_handshake_done(conn); + return quic_selected()->conn.is_handshake_done(conn); } /** @@ -185,7 +213,7 @@ static inline int quic_conn_is_handshake_done(quic_conn* conn) */ static inline int quic_conn_is_closed(quic_conn* conn) { - return conn ? quic_get_ops()->conn.is_closed(conn) : 1; + return conn ? quic_selected()->conn.is_closed(conn) : 1; } /** @@ -198,7 +226,7 @@ static inline int quic_conn_is_closed(quic_conn* conn) */ static inline int quic_conn_shutdown(quic_conn* conn, int is_rapid, uint64_t app_error, const char* reason) { - return conn ? quic_get_ops()->conn.shutdown(conn, is_rapid, app_error, reason) : 1; + return conn ? quic_selected()->conn.shutdown(conn, is_rapid, app_error, reason) : 1; } /** @@ -207,9 +235,9 @@ static inline int quic_conn_shutdown(quic_conn* conn, int is_rapid, uint64_t app */ static inline void quic_conn_free(quic_conn* conn) { - if (conn && quic_get_ops()->conn.free) + if (conn && quic_selected()->conn.free) { - quic_get_ops()->conn.free(conn); + quic_selected()->conn.free(conn); } } @@ -220,7 +248,7 @@ static inline void quic_conn_free(quic_conn* conn) */ static inline int64_t quic_stream_id(quic_stream* st) { - return quic_get_ops()->stream.id(st); + return quic_selected()->stream.id(st); } /** @@ -233,7 +261,7 @@ static inline int64_t quic_stream_id(quic_stream* st) */ static inline quic_write_result quic_stream_write(quic_stream* st, const quic_vec* vec, size_t nvec, int fin) { - return quic_get_ops()->stream.write(st, vec, nvec, fin); + return quic_selected()->stream.write(st, vec, nvec, fin); } /** @@ -243,7 +271,7 @@ static inline quic_write_result quic_stream_write(quic_stream* st, const quic_ve */ static inline int quic_stream_is_write_blocked(quic_stream* st) { - return quic_get_ops()->stream.is_write_blocked(st); + return quic_selected()->stream.is_write_blocked(st); } /** @@ -257,7 +285,7 @@ static inline int quic_stream_is_write_blocked(quic_stream* st) */ static inline int quic_stream_read(quic_stream* st, unsigned char* buf, size_t read_size, size_t* nread, int* fin) { - return quic_get_ops()->stream.read(st, buf, read_size, nread, fin); + return quic_selected()->stream.read(st, buf, read_size, nread, fin); } /** @@ -268,7 +296,7 @@ static inline int quic_stream_read(quic_stream* st, unsigned char* buf, size_t r */ static inline void quic_stream_is_read_finished(quic_stream* st, int* read_finished, int* write_finished) { - quic_get_ops()->stream.is_read_finished(st, read_finished, write_finished); + quic_selected()->stream.is_read_finished(st, read_finished, write_finished); } /** @@ -278,9 +306,9 @@ static inline void quic_stream_is_read_finished(quic_stream* st, int* read_finis */ static inline void quic_stream_stop_sending(quic_stream* st, uint64_t err) { - if (st && quic_get_ops()->stream.stop_sending) + if (st && quic_selected()->stream.stop_sending) { - quic_get_ops()->stream.stop_sending(st, err); + quic_selected()->stream.stop_sending(st, err); } } @@ -291,9 +319,9 @@ static inline void quic_stream_stop_sending(quic_stream* st, uint64_t err) */ static inline void quic_stream_reset(quic_stream* st, uint64_t err) { - if (st && quic_get_ops()->stream.reset) + if (st && quic_selected()->stream.reset) { - quic_get_ops()->stream.reset(st, err); + quic_selected()->stream.reset(st, err); } } @@ -303,9 +331,9 @@ static inline void quic_stream_reset(quic_stream* st, uint64_t err) */ static inline void quic_stream_free(quic_stream* st) { - if (st && quic_get_ops()->stream.free) + if (st && quic_selected()->stream.free) { - quic_get_ops()->stream.free(st); + quic_selected()->stream.free(st); } } @@ -316,9 +344,9 @@ static inline void quic_stream_free(quic_stream* st) */ static inline void quic_stream_consumed(quic_stream* st, size_t nbytes) { - if (quic_get_ops()->stream.consumed) + if (quic_selected()->stream.consumed) { - quic_get_ops()->stream.consumed(st, nbytes); + quic_selected()->stream.consumed(st, nbytes); } } 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/src/detail/quic_check.h b/quic/quic/src/detail/quic_check.h similarity index 100% rename from quic/src/detail/quic_check.h rename to quic/quic/src/detail/quic_check.h diff --git a/quic/src/detail/quic_tls.h b/quic/quic/src/detail/quic_tls.h similarity index 100% rename from quic/src/detail/quic_tls.h rename to quic/quic/src/detail/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/mod_http3/src/h3_quic.c b/quic/quic/src/quic_registry.c similarity index 52% rename from mod_http3/src/h3_quic.c rename to quic/quic/src/quic_registry.c index 10677b4..cb0672f 100644 --- a/mod_http3/src/h3_quic.c +++ b/quic/quic/src/quic_registry.c @@ -16,40 +16,54 @@ * limitations under the License. */ -#include -#include +#include -#include "h3_quic.h" +#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 quic_engine_entry +typedef struct { const char* name; - const quic_ops* (*ops)(void); -} quic_engine_entry; + const quic_api* (*api)(void); +} quic_entry; -static const quic_engine_entry quic_engines[] = { - {"openssl", quic_ossl_ops}, +/* 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_ops}, + {"ngtcp2", quic_ngtcp2_api}, #endif + {"null", quic_null_api}, }; -#define QUIC_ENGINE_COUNT (sizeof(quic_engines) / sizeof(quic_engines[0])) +#define ENGINE_COUNT (sizeof(engines) / sizeof(engines[0])) -static size_t quic_active; +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) { - for (size_t i = 0; i < QUIC_ENGINE_COUNT; i++) + QUIC_CHECK(name); + for (size_t i = 0; i < ENGINE_COUNT; i++) { - if (apr_cstr_casecmp(name, quic_engines[i].name) == 0) + if (strcasecmp(name, engines[i].name) == 0) { - quic_active = i; + active = i; return 1; } } @@ -58,20 +72,10 @@ int quic_select(const char* name) const char* quic_engine_name(void) { - return quic_engines[quic_active].name; -} - -const char* quic_engine_names(apr_pool_t* pool) -{ - const char* list = quic_engines[0].name; - for (size_t i = 1; i < QUIC_ENGINE_COUNT; i++) - { - list = apr_pstrcat(pool, list, ", ", quic_engines[i].name, NULL); - } - return list; + return engines[active].name; } -const quic_ops* quic_get_ops(void) +const quic_api* quic_selected(void) { - return quic_engines[quic_active].ops(); + 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/src/quic_tls.c b/quic/quic/src/quic_tls.c similarity index 69% rename from quic/src/quic_tls.c rename to quic/quic/src/quic_tls.c index 108d840..db0ece9 100644 --- a/quic/src/quic_tls.c +++ b/quic/quic/src/quic_tls.c @@ -21,6 +21,7 @@ #include #include +#include #include #include "detail/quic_check.h" @@ -74,6 +75,39 @@ static void quic_tls_keylog_cb(const SSL* ssl, const char* line) } } +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); @@ -89,9 +123,8 @@ SSL_CTX* quic_tls_ctx_create(const SSL_METHOD* method, const quic_config* cfg, c SSL_CTX_set_min_proto_version(ssl_ctx, TLS1_3_VERSION); SSL_CTX_set_max_proto_version(ssl_ctx, TLS1_3_VERSION); - if (SSL_CTX_use_certificate_chain_file(ssl_ctx, cfg->cert_path) <= 0 || SSL_CTX_use_PrivateKey_file(ssl_ctx, cfg->key_path, SSL_FILETYPE_PEM) <= 0) + if (!quic_tls_use_cred(ssl_ctx, &cfg->cred, err, errlen)) { - quic_tls_error(err, errlen, "loading the certificate or private key failed"); SSL_CTX_free(ssl_ctx); return NULL; } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 4b8e808..110fed7 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -83,9 +83,10 @@ 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/ngtcp2/, so the dependency test asks for it by name. +# 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) From e5c671edb5693f93d59e628a6bf8e83ea38b9c6c Mon Sep 17 00:00:00 2001 From: Tarek Ibrahim Date: Wed, 5 Aug 2026 06:45:08 -0400 Subject: [PATCH 11/12] v0.0.52 - bump dependencies --- .gitmodules | 2 +- CHANGES | 6 ++++++ CMakeLists.txt | 2 +- INSTALL | 2 +- cmake/modules/nghttp3.cmake | 4 ++-- dependencies/apr | 2 +- dependencies/apr-util | 2 +- dependencies/httpd | 2 +- dependencies/nghttp3 | 2 +- docs/build.md | 2 +- docs/configuration.md | 2 +- mod_http3/include/h3_version.h | 4 ++-- quic/third-party/openssl | 2 +- test/unit/dependencies/nghttp3_test.c | 4 ++-- 14 files changed, 22 insertions(+), 16 deletions(-) diff --git a/.gitmodules b/.gitmodules index 34c1337..48a9ec3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,7 +4,7 @@ shallow = true ignore = untracked update = checkout - #branch = main # tag: v1.17.0 + #branch = main # tag: v1.18.0 [submodule "dependencies/httpd"] path = dependencies/httpd url = https://github.com/machine-moon/httpd.git diff --git a/CHANGES b/CHANGES index 4d0e2b9..3c1bad1 100644 --- a/CHANGES +++ b/CHANGES @@ -3,6 +3,12 @@ mod_http3 changes Changes are listed most recent first. Security-related entries always appear at the top of their release block. +v0.0.52 (2026-08-05) +-------------------- + *) Updated the dependency submodules to their current upstream revisions, + nghttp3 moves to v1.18.0. + [Tarek Ibrahim ] + v0.0.51 (2026-08-04) -------------------- *) Updated QUIC library to generalize easier. diff --git a/CMakeLists.txt b/CMakeLists.txt index 95aae86..4f8f2fb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.26) -project(mod_http3 VERSION 0.0.51) +project(mod_http3 VERSION 0.0.52) # -- Compiler and Build Type Checks -- if(NOT CMAKE_C_COMPILER_ID STREQUAL "GNU") diff --git a/INSTALL b/INSTALL index 043a97c..35cb383 100644 --- a/INSTALL +++ b/INSTALL @@ -39,7 +39,7 @@ httpd MMN >= 20211221 APR >= 1.7.0 APU >= 1.6.0 - nghttp3 >= 1.17.0 + nghttp3 >= 1.18.0 NOTE: Distro-packaged httpd (Ubuntu, Fedora, etc.) ships with an older MMN and will be rejected. Use the default source build or diff --git a/cmake/modules/nghttp3.cmake b/cmake/modules/nghttp3.cmake index 88082fc..35ae41c 100644 --- a/cmake/modules/nghttp3.cmake +++ b/cmake/modules/nghttp3.cmake @@ -1,10 +1,10 @@ -# -- nghttp3 v1.17.0 -- +# -- nghttp3 v1.18.0 -- if(TARGET nghttp3) return() endif() -set(NGHTTP3_VERSION_MIN "1.17.0") +set(NGHTTP3_VERSION_MIN "1.18.0") if(WITH_NGHTTP3) find_library(NGHTTP3_LIBRARY NAMES nghttp3 diff --git a/dependencies/apr b/dependencies/apr index e017f9b..6607b11 160000 --- a/dependencies/apr +++ b/dependencies/apr @@ -1 +1 @@ -Subproject commit e017f9b5b2b13642862b3959e3e6671a56c2e92f +Subproject commit 6607b119cb9b5e0504a2dad97dd099a7368cb5a3 diff --git a/dependencies/apr-util b/dependencies/apr-util index 1642251..08fefab 160000 --- a/dependencies/apr-util +++ b/dependencies/apr-util @@ -1 +1 @@ -Subproject commit 1642251b0fed7bf6abafe40a71697e782a7e8c92 +Subproject commit 08fefab7539920098f6f0341af0c97f9fbe6bff8 diff --git a/dependencies/httpd b/dependencies/httpd index 07edab0..9eeae7a 160000 --- a/dependencies/httpd +++ b/dependencies/httpd @@ -1 +1 @@ -Subproject commit 07edab0c0bb314e140117fb357b24b9f622c0357 +Subproject commit 9eeae7a82ea6e84e3a2ce40e327a2e1dc4038dc1 diff --git a/dependencies/nghttp3 b/dependencies/nghttp3 index 06b46ec..dbfc242 160000 --- a/dependencies/nghttp3 +++ b/dependencies/nghttp3 @@ -1 +1 @@ -Subproject commit 06b46ec9189be3a49c78a283b625882d8a6e3237 +Subproject commit dbfc24286138cb0b6490160e7ca87fe1ce6722a0 diff --git a/docs/build.md b/docs/build.md index f8251e0..6b003ac 100644 --- a/docs/build.md +++ b/docs/build.md @@ -24,7 +24,7 @@ the `WITH_*` options only if they meet these minimums. | Apache httpd | MMN 20211221 | | APR | 1.7.0 | | APR-util | 1.6.0 | -| nghttp3 | 1.17.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. diff --git a/docs/configuration.md b/docs/configuration.md index 19f804e..129e048 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -65,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. diff --git a/mod_http3/include/h3_version.h b/mod_http3/include/h3_version.h index 41ff281..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 51 +#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.51" +#define MOD_HTTP3_VERSION_STRING "0.0.52" #endif /* H3_VERSION_H */ diff --git a/quic/third-party/openssl b/quic/third-party/openssl index c8167c1..88cdff9 160000 --- a/quic/third-party/openssl +++ b/quic/third-party/openssl @@ -1 +1 @@ -Subproject commit c8167c17c206a04efd5b81d52ee5a0d435178f9a +Subproject commit 88cdff90e4af0f0b6732ba4bb395b0e8a831edb7 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) From e395ae006d2bb410e6ccabe103b3fe6e34e0e094 Mon Sep 17 00:00:00 2001 From: Alexander Gerasimov Date: Thu, 6 Aug 2026 23:06:22 +0300 Subject: [PATCH 12/12] Reject malformed HTTP/3 requests with a stream error RFC 9114 4.1.2 requires a malformed request to be treated as a stream error of type H3_MESSAGE_ERROR. nghttp3 already detects every case (missing, duplicate or invalid pseudo-header fields, connection-specific fields, TE other than trailers, content-length mismatch) and reports it as a non-fatal NGHTTP3_ERR_MALFORMED_HTTP_HEADER/_MESSAGING out of nghttp3_conn_read_stream, but the module treated any negative return as fatal and closed the whole QUIC connection, ending every other request in flight on it. Translate exactly those two errors into a per-stream rejection instead: reset the request stream with the inferred H3_MESSAGE_ERROR code, stop reading it, drop it from nghttp3, and keep the connection serving. All other nghttp3 errors keep the existing connection-error path, as RFC 9114 demands for frame-layer violations. The new checks run only on the error path; the success path is unchanged except for a done-flag test that also stops rejected or already-reset streams from being dispatched to a worker. Tested by test/http3/test_020_malformed.py with a raw QPACK client: each malformed request must yield a stream reset with 0x010E and the same connection must then serve a well-formed request. --- CHANGES | 9 ++ mod_http3/src/h3_stream.c | 46 ++++++- test/http3/test_020_malformed.py | 199 +++++++++++++++++++++++++++++++ 3 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 test/http3/test_020_malformed.py diff --git a/CHANGES b/CHANGES index 3c1bad1..03f11f3 100644 --- a/CHANGES +++ b/CHANGES @@ -3,6 +3,15 @@ mod_http3 changes Changes are listed most recent first. Security-related entries always appear at the top of their release block. +v0.0.53 (unreleased) +-------------------- + *) Rejected malformed HTTP/3 requests (missing or duplicate pseudo-header + fields, connection-specific fields, content-length mismatch) with a + stream error of type H3_MESSAGE_ERROR per RFC 9114 4.1.2. Previously + one malformed request closed the whole QUIC connection, ending every + other request in flight on it. + [Alexander Gerasimov ] + v0.0.52 (2026-08-05) -------------------- *) Updated the dependency submodules to their current upstream revisions, diff --git a/mod_http3/src/h3_stream.c b/mod_http3/src/h3_stream.c index d6a0b71..1f382dd 100644 --- a/mod_http3/src/h3_stream.c +++ b/mod_http3/src/h3_stream.c @@ -200,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; } @@ -241,7 +276,7 @@ static int drain_one_stream(h3_session* session, h3_stream* h3s, int* data_read, { 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) @@ -272,6 +307,11 @@ 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; @@ -293,7 +333,7 @@ static int drain_one_stream(h3_session* session, h3_stream* h3s, int* data_read, } 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) 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"