From 215d601a46c62a280d7458fc786f9227636442f0 Mon Sep 17 00:00:00 2001 From: Alexander Gerasimov Date: Thu, 6 Aug 2026 21:38:08 +0300 Subject: [PATCH 01/11] httpd 2.4.x compatibility Build against httpd 2.4.52+ as well as trunk. - h3_compat.h keys H3_HAS_RESPONSE_BUCKETS on the same MMN boundary mod_http2 uses (20211221.6). - Without response buckets, remove the core HTTP_HEADER filter from synthesized requests and snapshot response status and headers via h3_response_finalize(), a port of ap_http_header_filter by way of mod_http2's h2_c2_filter.c, minus the HTTP/1.x transport concerns. - Capture status and headers into h3_conn_ctx_t on both paths instead of holding an ap_bucket_response. - Treat the MPM connection-count notifications as optional: stock 2.4.x MPMs lack them, so warn and run in a degraded mode where a graceful child stop does not wait for active QUIC connections to drain, instead of refusing to start. .patches/httpd-2.4.66-pr699.patch adds the notifications to 2.4.x. - Lower the build floor to httpd 2.4.52: ap_create_request (2.4.49), the child_stopping hook (2.4.49) and ap_thread_current (2.4.52) must exist. --- .patches/httpd-2.4.66-pr699.patch | 239 +++++++++++++++++++++++++ CHANGES | 17 ++ cmake/modules/httpd.cmake | 11 +- docs/build.md | 6 +- mod_http3/include/h3_compat.h | 42 +++++ mod_http3/include/h3_filter.h | 9 +- mod_http3/include/h3_response_compat.h | 44 +++++ mod_http3/src/h3_filter.c | 29 ++- mod_http3/src/h3_io.c | 10 +- mod_http3/src/h3_request.c | 21 ++- mod_http3/src/h3_response_compat.c | 211 ++++++++++++++++++++++ mod_http3/src/mod_http3.c | 3 - 12 files changed, 621 insertions(+), 21 deletions(-) create mode 100644 .patches/httpd-2.4.66-pr699.patch create mode 100644 mod_http3/include/h3_compat.h create mode 100644 mod_http3/include/h3_response_compat.h create mode 100644 mod_http3/src/h3_response_compat.c diff --git a/.patches/httpd-2.4.66-pr699.patch b/.patches/httpd-2.4.66-pr699.patch new file mode 100644 index 0000000..f74829c --- /dev/null +++ b/.patches/httpd-2.4.66-pr699.patch @@ -0,0 +1,239 @@ +diff --git a/include/mpm_common.h b/include/mpm_common.h +index 6b3d1f5..154df9a 100644 +--- a/include/mpm_common.h ++++ b/include/mpm_common.h +@@ -40,6 +40,7 @@ + #include "ap_config.h" + #include "ap_mpm.h" + #include "scoreboard.h" ++#include "apr_optional.h" + + #if APR_HAVE_NETINET_TCP_H + #include /* for TCP_NODELAY */ +@@ -479,6 +480,20 @@ AP_DECLARE_HOOK(void, child_stopping, + */ + void mpm_common_pre_config(apr_pool_t *pconf); + ++/** ++ * Hooks for modules to report connections the MPM did not accept itself. ++ * ++ * MPMs that wait for their connection count to drain before stopping a child ++ * need this so externally accepted connections keep the child alive until ++ * they finish. ++ * ++ * Call ap_mpm_note_extra_connection_added() when such a connection starts, ++ * and ap_mpm_note_extra_connection_removed() when it ends. These functions ++ * may be NULL if the active MPM does not implement them. ++ */ ++APR_DECLARE_OPTIONAL_FN(void, ap_mpm_note_extra_connection_added, (void)); ++APR_DECLARE_OPTIONAL_FN(void, ap_mpm_note_extra_connection_removed, (void)); ++ + #ifdef __cplusplus + } + #endif +diff --git a/server/mpm/event/event.c b/server/mpm/event/event.c +index 050d823..48119ca 100644 +--- a/server/mpm/event/event.c ++++ b/server/mpm/event/event.c +@@ -828,6 +828,21 @@ static apr_status_t decrement_connection_count(void *cs_) + return APR_SUCCESS; + } + ++static void ap_mpm_note_extra_connection_added(void) ++{ ++ apr_atomic_inc32(&connection_count); ++} ++ ++static void ap_mpm_note_extra_connection_removed(void) ++{ ++ int is_last_connection = !apr_atomic_dec32(&connection_count); ++ ++ /* Wake a listener blocked waiting for connection_count to drain. */ ++ if (listener_is_wakeable && is_last_connection && listener_may_exit) { ++ apr_pollset_wakeup(event_pollset); ++ } ++} ++ + static void notify_suspend(event_conn_state_t *cs) + { + ap_run_suspend_connection(cs->c, cs->r); +@@ -3466,6 +3481,10 @@ static void setup_slave_conn(conn_rec *c, void *csd) + event_conn_state_t *cs; + + mcs = ap_get_module_config(c->master->conn_config, &mpm_event_module); ++ if (!mcs) { ++ /* Master connection is not managed by this MPM; nothing to inherit. */ ++ return; ++ } + + cs = apr_pcalloc(c->pool, sizeof(*cs)); + cs->c = c; +@@ -3607,6 +3626,9 @@ static int event_pre_config(apr_pool_t * pconf, apr_pool_t * plog, + const char *userdata_key = "mpm_event_module"; + int test_atomics = 0; + ++ APR_REGISTER_OPTIONAL_FN(ap_mpm_note_extra_connection_added); ++ APR_REGISTER_OPTIONAL_FN(ap_mpm_note_extra_connection_removed); ++ + debug = ap_exists_config_define("DEBUG"); + + if (debug) { +diff --git a/server/mpm/prefork/prefork.c b/server/mpm/prefork/prefork.c +index b5adb57..2640972 100644 +--- a/server/mpm/prefork/prefork.c ++++ b/server/mpm/prefork/prefork.c +@@ -18,6 +18,7 @@ + #include "apr_portable.h" + #include "apr_strings.h" + #include "apr_thread_proc.h" ++#include "apr_atomic.h" + #include "apr_signal.h" + + #define APR_WANT_STDIO +@@ -88,6 +89,7 @@ + + /* config globals */ + ++static apr_uint32_t connection_count = 0; /* Number of open connections */ + static int ap_daemons_to_start=0; + static int ap_daemons_min_free=0; + static int ap_daemons_max_free=0; +@@ -215,19 +217,24 @@ static void prefork_note_child_started(int slot, pid_t pid) + } + + /* a clean exit from a child with proper cleanup */ +-static void clean_child_exit(int code) __attribute__ ((noreturn)); +-static void clean_child_exit(int code) ++static void clean_child_exit_ex(int code, int from_signal) ++ __attribute__ ((noreturn)); ++static void clean_child_exit_ex(int code, int from_signal) + { + retained->mpm->mpm_state = AP_MPMQ_STOPPING; + + apr_signal(SIGHUP, SIG_IGN); + apr_signal(SIGTERM, SIG_IGN); + +- if (code == 0) { +- ap_run_child_stopping(pchild, 0); +- } +- + if (pchild) { ++ if (!code && !from_signal) { ++ ap_run_child_stopping(pchild, !retained->mpm->is_ungraceful); ++ if (!retained->mpm->is_ungraceful) { ++ while (apr_atomic_read32(&connection_count) > 0) { ++ apr_sleep(apr_time_from_msec(100)); ++ } ++ } ++ } + apr_pool_destroy(pchild); + } + +@@ -240,6 +247,13 @@ static void clean_child_exit(int code) + exit(code); + } + ++/* a clean exit from a child with proper cleanup */ ++static void clean_child_exit(int code) __attribute__ ((noreturn)); ++static void clean_child_exit(int code) ++{ ++ clean_child_exit_ex(code, 0); ++} ++ + static apr_status_t accept_mutex_on(void) + { + apr_status_t rv = apr_proc_mutex_lock(my_bucket->mutex); +@@ -356,12 +370,22 @@ static const char *prefork_get_name(void) + + static void just_die(int sig) + { +- clean_child_exit(0); ++ clean_child_exit_ex(0, 1); + } + + /* volatile because it's updated from a signal handler */ + static int volatile die_now = 0; + ++static void ap_mpm_note_extra_connection_added(void) ++{ ++ apr_atomic_inc32(&connection_count); ++} ++ ++static void ap_mpm_note_extra_connection_removed(void) ++{ ++ apr_atomic_dec32(&connection_count); ++} ++ + static void stop_listening(int sig) + { + retained->mpm->mpm_state = AP_MPMQ_STOPPING; +@@ -1286,6 +1310,9 @@ static int prefork_pre_config(apr_pool_t *p, apr_pool_t *plog, apr_pool_t *ptemp + apr_status_t rv; + const char *userdata_key = "mpm_prefork_module"; + ++ APR_REGISTER_OPTIONAL_FN(ap_mpm_note_extra_connection_added); ++ APR_REGISTER_OPTIONAL_FN(ap_mpm_note_extra_connection_removed); ++ + debug = ap_exists_config_define("DEBUG"); + + if (debug) { +diff --git a/server/mpm/worker/worker.c b/server/mpm/worker/worker.c +index 315371d..0d0a3d0 100644 +--- a/server/mpm/worker/worker.c ++++ b/server/mpm/worker/worker.c +@@ -30,6 +30,7 @@ + #include "apr_thread_mutex.h" + #include "apr_proc_mutex.h" + #include "apr_poll.h" ++#include "apr_atomic.h" + + #include + +@@ -116,6 +117,7 @@ + * Actual definitions of config globals + */ + ++static apr_uint32_t connection_count = 0; /* Number of open connections */ + static int threads_per_child = 0; /* Worker threads per child */ + static int ap_daemons_to_start = 0; + static int min_spare_threads = 0; +@@ -506,6 +508,16 @@ static void check_infinite_requests(void) + } + } + ++static void ap_mpm_note_extra_connection_added(void) ++{ ++ apr_atomic_inc32(&connection_count); ++} ++ ++static void ap_mpm_note_extra_connection_removed(void) ++{ ++ apr_atomic_dec32(&connection_count); ++} ++ + static void unblock_signal(int sig) + { + sigset_t sig_mask; +@@ -1301,6 +1313,12 @@ static void child_main(int child_num_arg, int child_bucket) + rv == AP_MPM_PODX_GRACEFUL ? ST_GRACEFUL : ST_UNGRACEFUL); + } + ++ if (terminate_mode == ST_GRACEFUL) { ++ while (apr_atomic_read32(&connection_count) > 0) { ++ apr_sleep(apr_time_from_msec(100)); ++ } ++ } ++ + free(threads); + + clean_child_exit(resource_shortage ? APEXIT_CHILDSICK : 0); +@@ -2059,6 +2077,9 @@ static int worker_pre_config(apr_pool_t *pconf, apr_pool_t *plog, + apr_status_t rv; + const char *userdata_key = "mpm_worker_module"; + ++ APR_REGISTER_OPTIONAL_FN(ap_mpm_note_extra_connection_added); ++ APR_REGISTER_OPTIONAL_FN(ap_mpm_note_extra_connection_removed); ++ + debug = ap_exists_config_define("DEBUG"); + + if (debug) { diff --git a/CHANGES b/CHANGES index 7ba01c6..552ea4f 100644 --- a/CHANGES +++ b/CHANGES @@ -3,6 +3,23 @@ mod_http3 changes Changes are listed most recent first. Security-related entries always appear at the top of their release block. +v0.0.60 (unreleased) +-------------------- + *) Added support for building against httpd 2.4.52+. Without response + buckets the module removes the core HTTP_HEADER filter from its + requests and snapshots status and headers itself, mirroring + mod_http2's !AP_HAS_RESPONSE_BUCKETS path. This replaces the + "#error Not supported for the moment." guard on AP_HAS_RESPONSE_BUCKETS, + so trunk and 2.4.x now build from the same source. + [Alexander Gerasimov ] + + *) Made the MPM connection-count notifications optional. Stock 2.4.x + MPMs do not provide them; the module then runs in a degraded mode + where a graceful child stop does not wait for active QUIC + connections to drain. The patch in .patches/httpd-2.4.66-pr699.patch + adds the notifications to httpd 2.4.x. + [Alexander Gerasimov ] + v0.0.59 (2026-08-12) -------------------- *) Disabled vcpkg applocal deployment in the Windows sub-builds; parallel diff --git a/cmake/modules/httpd.cmake b/cmake/modules/httpd.cmake index 8914ecf..c20767f 100644 --- a/cmake/modules/httpd.cmake +++ b/cmake/modules/httpd.cmake @@ -1,11 +1,16 @@ -# -- Apache httpd v2.4.x (20211221) -- +# -- Apache httpd (trunk MMN 20211221+ preferred; 2.4.52+ via compat layer) -- if(TARGET httpd) return() endif() -set(HTTPD_VERSION_MIN "2.4.x") -set(HTTPD_MMN_MIN "20211221") +# 2.4.52 is the floor: ap_create_request (2.4.49), the child_stopping hook +# (2.4.49) and ap_thread_current (2.4.52) must exist. Against a 2.4.x server +# mod_http3 uses its response compat path (see mod_http3/include/h3_compat.h). +# The MMN floor stays on the 2.4.x major (20120211); trunk reports 20211221 and +# compares greater, so both satisfy it. +set(HTTPD_VERSION_MIN "2.4.52") +set(HTTPD_MMN_MIN "20120211") if(WIN32) include(windows/httpd) diff --git a/docs/build.md b/docs/build.md index c5fb3a0..c3d2c64 100644 --- a/docs/build.md +++ b/docs/build.md @@ -20,13 +20,15 @@ clones OpenSSL's external test submodules, which the build never uses. | Dependency | Minimum | | --- | --- | | OpenSSL | 3.5.0 with QUIC support | -| Apache httpd | MMN 20211221 | +| Apache httpd | trunk (MMN 20211221) or 2.4.52+ | | APR | 1.7.0 | | APR-util | 1.6.0 | | nghttp3 | 1.18.0 | The submodule build produces these. Supply your own with the `WITH_*` options -only if they meet the minimums; a distribution httpd is usually rejected on MMN. +only if they meet the minimums; a distribution httpd is accepted from 2.4.52 on. + +Against httpd trunk the module consumes response buckets directly; against 2.4.x it uses a built-in compatibility path (see `mod_http3/include/h3_compat.h`) that captures the response the way the core `HTTP_HEADER` filter would. On stock MPMs, which lack the optional `ap_mpm_note_extra_connection_added`/`_removed` functions, the module runs in a degraded mode where a graceful child stop does not wait for active QUIC connections to drain; the MPM patch in `.patches/httpd-2.4.66-pr699.patch` restores that. ## Custom Prefixes diff --git a/mod_http3/include/h3_compat.h b/mod_http3/include/h3_compat.h new file mode 100644 index 0000000..16111bb --- /dev/null +++ b/mod_http3/include/h3_compat.h @@ -0,0 +1,42 @@ +/* + * 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_COMPAT_H +#define H3_COMPAT_H + +#include + +/** + * httpd trunk (MMN 20211221.6+, same boundary mod_http2 uses) hands response + * meta data to protocol modules as ap_bucket_response buckets and keeps the + * HTTP/1.x serialization filters off slave connections. It also carries the + * extra conn_rec fields (slaves, requests, async_filter). + * + * On httpd 2.4.x none of that exists: the core HTTP_HEADER filter serializes + * the response as HTTP/1.x text instead. There mod_http3 removes that filter + * from its synthesized requests and snapshots status and headers itself via + * h3_response_finalize() (see h3_response_compat.c), mirroring mod_http2's + * !AP_HAS_RESPONSE_BUCKETS code path. + */ +#if AP_MODULE_MAGIC_AT_LEAST(20211221, 6) + #define H3_HAS_RESPONSE_BUCKETS 1 +#else + #define H3_HAS_RESPONSE_BUCKETS 0 +#endif + +#endif /* H3_COMPAT_H */ diff --git a/mod_http3/include/h3_filter.h b/mod_http3/include/h3_filter.h index 5741830..b7b5092 100644 --- a/mod_http3/include/h3_filter.h +++ b/mod_http3/include/h3_filter.h @@ -25,12 +25,19 @@ #include #include +#include + +#include "h3_compat.h" struct h3_stream; typedef struct h3_conn_ctx_t { - ap_bucket_response* resp; + /// Final response status captured from the handler, or 0 until captured. + int resp_status; + /// Final response headers captured from the handler, or NULL. Filled from + /// the ap_bucket_response on trunk, or by h3_response_finalize on 2.4.x. + apr_table_t* resp_headers; char* dataheap; apr_size_t dataheaplen; apr_size_t dataheapcap; diff --git a/mod_http3/include/h3_response_compat.h b/mod_http3/include/h3_response_compat.h new file mode 100644 index 0000000..82daf2a --- /dev/null +++ b/mod_http3/include/h3_response_compat.h @@ -0,0 +1,44 @@ +/* + * 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_RESPONSE_COMPAT_H +#define H3_RESPONSE_COMPAT_H + +#include + +#include "h3_compat.h" +#include "h3_filter.h" + +#if !H3_HAS_RESPONSE_BUCKETS + +/** + * Finalize the response of @p r the way the core HTTP_HEADER filter would + * (merge err_headers_out, materialize Content-Type/-Encoding/-Language, + * dedup Vary, add Date/Server, honor no-cache and header-only statuses) and + * snapshot the resulting status and headers into @p h3ctx. On servers with + * response buckets this arrives as an ap_bucket_response instead; on 2.4.x + * the HTTP_HEADER filter is removed from H3 requests and this port supplies + * the same data. Idempotent: does nothing once h3ctx->resp_headers is set. + * @param r The request whose response is being started. + * @param h3ctx The per-request H3 context receiving the snapshot. + */ +void h3_response_finalize(request_rec* r, h3_conn_ctx_t* h3ctx); + +#endif /* !H3_HAS_RESPONSE_BUCKETS */ + +#endif /* H3_RESPONSE_COMPAT_H */ diff --git a/mod_http3/src/h3_filter.c b/mod_http3/src/h3_filter.c index 2f5fd15..cd29baf 100644 --- a/mod_http3/src/h3_filter.c +++ b/mod_http3/src/h3_filter.c @@ -36,10 +36,12 @@ #include "h3.h" #include "h3_check.h" +#include "h3_compat.h" #include "h3_config.h" #include "h3_filter.h" #include "h3_os.h" #include "h3_request.h" +#include "h3_response_compat.h" #include "h3_session.h" #include "mod_http3.h" @@ -196,13 +198,15 @@ apr_status_t h3_filter_out_proto(ap_filter_t* f, apr_bucket_brigade* bb) ap_send_error_response(f->r, 0); return OK; } +#if H3_HAS_RESPONSE_BUCKETS if (AP_BUCKET_IS_RESPONSE(b)) { - ctx->resp = b->data; - if (ctx->resp->headers) + ap_bucket_response* resp = b->data; + ctx->resp_status = resp->status; + if (resp->headers) { - apr_table_t* dup = apr_table_make(ctx->c3reqpool, apr_table_elts(ctx->resp->headers)->nelts); - const apr_array_header_t* src_arr = apr_table_elts(ctx->resp->headers); + apr_table_t* dup = apr_table_make(ctx->c3reqpool, apr_table_elts(resp->headers)->nelts); + const apr_array_header_t* src_arr = apr_table_elts(resp->headers); const apr_table_entry_t* src = (const apr_table_entry_t*)src_arr->elts; for (int i = 0; i < src_arr->nelts; i++) { @@ -211,10 +215,10 @@ apr_status_t h3_filter_out_proto(ap_filter_t* f, apr_bucket_brigade* bb) apr_table_add(dup, apr_pstrdup(ctx->c3reqpool, src[i].key), apr_pstrdup(ctx->c3reqpool, src[i].val)); } } - ctx->resp->headers = dup; + ctx->resp_headers = dup; } - ctx->resp->pool = ctx->c3reqpool; APR_BUCKET_REMOVE(b); + apr_bucket_destroy(b); if (ctx->streaming) { apr_status_t rv = h3_response_start(f->r, ctx); @@ -224,8 +228,19 @@ apr_status_t h3_filter_out_proto(ap_filter_t* f, apr_bucket_brigade* bb) return rv; } } + b = next; + continue; } - else if (!APR_BUCKET_IS_METADATA(b)) +#else + /* No response buckets on this httpd: freeze status and headers the + * way the (removed) core HTTP_HEADER filter would, at the first body + * byte, flush or EOS. */ + if (!ctx->resp_headers && (!APR_BUCKET_IS_METADATA(b) || APR_BUCKET_IS_EOS(b) || APR_BUCKET_IS_FLUSH(b))) + { + h3_response_finalize(f->r, ctx); + } +#endif + if (!APR_BUCKET_IS_METADATA(b)) { apr_status_t rv; if (ctx->streaming) diff --git a/mod_http3/src/h3_io.c b/mod_http3/src/h3_io.c index ee58eca..2792174 100644 --- a/mod_http3/src/h3_io.c +++ b/mod_http3/src/h3_io.c @@ -138,9 +138,13 @@ apr_status_t h3_io_listen_start(apr_pool_t* pchild, server_rec* s, h3_server_con io->note_conn_removed = APR_RETRIEVE_OPTIONAL_FN(ap_mpm_note_extra_connection_removed); if (!io->note_conn_added || !io->note_conn_removed) { - ap_log_error(APLOG_MARK, APLOG_EMERG, 0, s, "active MPM '%s' lacks connection-count notifications; upgrade your httpd to a version that supports mod_http3", ap_show_mpm()); - teardown(io); - return APR_EGENERAL; + /* Both or neither: asymmetric counting would corrupt the MPM's + * connection count. Stock MPMs lack these notifications; run in a + * degraded mode where a graceful child stop does not wait for + * active QUIC connections to drain. */ + io->note_conn_added = NULL; + io->note_conn_removed = NULL; + ap_log_error(APLOG_MARK, APLOG_WARNING, 0, s, "active MPM '%s' does not report externally accepted connections; graceful child shutdown may end active HTTP/3 connections early (an MPM with ap_mpm_note_extra_connection_added/_removed avoids this)", ap_show_mpm()); } io->thread_running = 1; diff --git a/mod_http3/src/h3_request.c b/mod_http3/src/h3_request.c index b2a2291..ef34399 100644 --- a/mod_http3/src/h3_request.c +++ b/mod_http3/src/h3_request.c @@ -36,10 +36,12 @@ #include "h3.h" #include "h3_check.h" +#include "h3_compat.h" #include "h3_filter.h" #include "h3_io.h" #include "h3_os.h" #include "h3_request.h" +#include "h3_response_compat.h" #include "h3_session.h" #include "mod_http3.h" #include "quic/h3q.h" @@ -128,9 +130,11 @@ conn_rec* h3_synth_conn(h3_session* session) } c->bucket_alloc = apr_bucket_alloc_create(cpool); c->log = &s->log; +#if H3_HAS_RESPONSE_BUCKETS c->slaves = apr_array_make(cpool, 4, sizeof(void*)); c->requests = apr_array_make(cpool, 4, sizeof(void*)); c->async_filter = -1; +#endif c->clogging_input_filters = 1; #if APR_HAS_THREADS c->current_thread = ap_thread_current(); @@ -156,8 +160,8 @@ static int is_connection_specific(const char* k) static size_t build_response_nva(nghttp3_nv* nva, size_t nva_cap, request_rec* r, h3_conn_ctx_t* h3ctx, apr_pool_t* dst_pool) { - apr_table_t* hdrs = (h3ctx->resp && h3ctx->resp->headers) ? h3ctx->resp->headers : r->headers_out; - int status = (h3ctx->resp && h3ctx->resp->status) ? h3ctx->resp->status : r->status; + apr_table_t* hdrs = h3ctx->resp_headers ? h3ctx->resp_headers : r->headers_out; + int status = h3ctx->resp_status ? h3ctx->resp_status : r->status; char* status_str = apr_psprintf(dst_pool, "%d", status); NV_SET(nva, 0, ":status", status_str); size_t nvlen = 1; @@ -235,6 +239,11 @@ apr_status_t h3_response_start(request_rec* r, h3_conn_ctx_t* h3ctx) return APR_EINVAL; } h3_stream* h3s = h3ctx->stream; +#if !H3_HAS_RESPONSE_BUCKETS + /* No response bucket ever arrives on this httpd; snapshot the response + * now if the output filter has not done so yet. */ + h3_response_finalize(r, h3ctx); +#endif nghttp3_nv nva[64] = {0}; size_t nvlen = build_response_nva(nva, OSSL_NELEM(nva), r, h3ctx, h3s->pool); return submit_response_nva(h3s, nva, nvlen); @@ -275,6 +284,12 @@ static void* APR_THREAD_FUNC stream_worker(apr_thread_t* thd, void* data) apr_pool_destroy(c->pool); return NULL; } +#if !H3_HAS_RESPONSE_BUCKETS + /* This httpd serializes responses as HTTP/1.x text through the core + * HTTP_HEADER filter; drop it so h3_filter_out_proto captures headers + * via h3_response_finalize instead of header text in the body. */ + ap_remove_output_filter_byhandle(r->output_filters, "HTTP_HEADER"); +#endif r->log = c->log ? c->log : &s->log; r->request_time = apr_time_now(); r->connection->keepalive = AP_CONN_KEEPALIVE; @@ -442,7 +457,9 @@ void h3_process_request(h3_session* session, h3_stream* h3s) c->pool = cpool; c->master = session->c; c->sbh = NULL; +#if H3_HAS_RESPONSE_BUCKETS c->requests = apr_array_make(cpool, 4, sizeof(void*)); +#endif c->notes = apr_table_copy(cpool, session->c->notes); c->conn_config = ap_create_conn_config(cpool); c->bucket_alloc = apr_bucket_alloc_create(cpool); diff --git a/mod_http3/src/h3_response_compat.c b/mod_http3/src/h3_response_compat.c new file mode 100644 index 0000000..a8f17df --- /dev/null +++ b/mod_http3/src/h3_response_compat.c @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2026 The mod_http3 Project Authors. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Response finalization for httpd 2.4.x, where responses have no + * ap_bucket_response representation. Adapted from Apache httpd's + * ap_http_header_filter (modules/http/http_filters.c) by way of + * mod_http2's h2_c2_filter.c create_response(), minus the HTTP/1.x + * transport concerns (keepalive, chunked transfer encoding). + */ + +#include "h3_response_compat.h" + +#if !H3_HAS_RESPONSE_BUCKETS + + #include + #include + #include + + #include + #include + #include + + #include + + #include "h3_check.h" + +static int uniq_field_values(void* d, const char* /*key*/, const char* val) +{ + apr_array_header_t* values = d; + char* start; + char* e = apr_pstrdup(values->pool, val); + + do + { + while (*e == ',' || apr_isspace(*e)) + { + ++e; + } + if (*e == '\0') + { + break; + } + start = e; + while (*e != '\0' && *e != ',' && !apr_isspace(*e)) + { + ++e; + } + if (*e != '\0') + { + *e++ = '\0'; + } + + int i; + char** strpp; + for (i = 0, strpp = (char**)values->elts; i < values->nelts; ++i, ++strpp) + { + if (*strpp && ap_cstr_casecmp(*strpp, start) == 0) + { + break; + } + } + if (i == values->nelts) + { + *(char**)apr_array_push(values) = start; + } + } while (*e != '\0'); + + return 1; +} + +/* Some clients choke on multiple Vary fields or duplicate tokens; combine + * multiples and drop duplicates. */ +static void fix_vary(request_rec* r) +{ + apr_array_header_t* varies = apr_array_make(r->pool, 5, sizeof(char*)); + apr_table_do(uniq_field_values, varies, r->headers_out, "Vary", NULL); + if (varies->nelts > 0) + { + apr_table_setn(r->headers_out, "Vary", apr_array_pstrcat(r->pool, varies, ',')); + } +} + +void h3_response_finalize(request_rec* r, h3_conn_ctx_t* h3ctx) +{ + CHECK(r); + CHECK(h3ctx); + if (h3ctx->resp_headers) + { + return; + } + + /* Combine the two header field tables so later set/unset operations are + * not bypassed. */ + if (!apr_is_empty_table(r->err_headers_out)) + { + r->headers_out = apr_table_overlay(r->pool, r->err_headers_out, r->headers_out); + apr_table_clear(r->err_headers_out); + } + + if (apr_table_get(r->subprocess_env, "force-no-vary") != NULL) + { + apr_table_unset(r->headers_out, "Vary"); + } + else + { + fix_vary(r); + } + + /* Remove any ETag response header field if earlier processing says so + * (such as a 'FileETag None' directive). */ + if (apr_table_get(r->notes, "no-etag") != NULL) + { + apr_table_unset(r->headers_out, "ETag"); + } + + if (AP_STATUS_IS_HEADER_ONLY(r->status)) + { + apr_table_unset(r->headers_out, "Transfer-Encoding"); + apr_table_unset(r->headers_out, "Content-Length"); + r->content_type = r->content_encoding = NULL; + r->content_languages = NULL; + r->clength = r->chunked = 0; + } + + const char* ctype = ap_make_content_type(r, r->content_type); + if (ctype) + { + apr_table_setn(r->headers_out, "Content-Type", ctype); + } + + if (r->content_encoding) + { + apr_table_setn(r->headers_out, "Content-Encoding", r->content_encoding); + } + + if (!apr_is_empty_array(r->content_languages)) + { + const char* field = apr_table_get(r->headers_out, "Content-Language"); + char* token; + while (field && (token = ap_get_list_item(r->pool, &field)) != NULL) + { + int i; + char** languages = (char**)r->content_languages->elts; + for (i = 0; i < r->content_languages->nelts; ++i) + { + if (!ap_cstr_casecmp(token, languages[i])) + { + break; + } + } + if (i == r->content_languages->nelts) + { + *((char**)apr_array_push(r->content_languages)) = token; + } + } + apr_table_setn(r->headers_out, "Content-Language", apr_array_pstrcat(r->pool, r->content_languages, ',')); + } + + /* Control cachability for non-cachable responses if not already set by + * some other part of the server configuration. */ + if (r->no_cache && !apr_table_get(r->headers_out, "Expires")) + { + char* date = apr_palloc(r->pool, APR_RFC822_DATE_LEN); + ap_recent_rfc822_date(date, r->request_time); + apr_table_add(r->headers_out, "Expires", date); + } + + /* Handlers that shortcut HEAD requests leave the content-length filter + * computing a spurious zero Content-Length; suppress it. */ + const char* clheader = apr_table_get(r->headers_out, "Content-Length"); + if (r->header_only && clheader && !strcmp(clheader, "0")) + { + apr_table_unset(r->headers_out, "Content-Length"); + } + + /* Keep the set-by-proxy Date and Server headers, otherwise generate + * fresh ones. */ + if (r->proxyreq == PROXYREQ_NONE || !apr_table_get(r->headers_out, "Date")) + { + char* date = apr_palloc(r->pool, APR_RFC822_DATE_LEN); + ap_recent_rfc822_date(date, r->request_time); + apr_table_setn(r->headers_out, "Date", date); + } + if (r->proxyreq == PROXYREQ_NONE || !apr_table_get(r->headers_out, "Server")) + { + const char* us = ap_get_server_banner(); + if (us && *us) + { + apr_table_setn(r->headers_out, "Server", us); + } + } + + h3ctx->resp_status = r->status; + h3ctx->resp_headers = r->headers_out; +} + +#endif /* !H3_HAS_RESPONSE_BUCKETS */ diff --git a/mod_http3/src/mod_http3.c b/mod_http3/src/mod_http3.c index 729ce40..959a3da 100644 --- a/mod_http3/src/mod_http3.c +++ b/mod_http3/src/mod_http3.c @@ -58,9 +58,6 @@ static void register_hooks(apr_pool_t* p H3_UNUSED) ap_hook_child_init(h3_child_init, NULL, NULL, APR_HOOK_MIDDLE); ap_hook_child_stopping(h3_c1_child_stopping, NULL, NULL, APR_HOOK_MIDDLE); -#ifdef AP_HAS_RESPONSE_BUCKETS - #error Not supported for the moment. -#endif } HTTP3_PUBLIC module http3_module = { From 19b1e63791e2fcf874caa9ae815fc72df7192d86 Mon Sep 17 00:00:00 2001 From: Alexander Gerasimov Date: Thu, 6 Aug 2026 23:06:22 +0300 Subject: [PATCH 02/11] 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 | 7 ++ mod_http3/src/h3_stream.c | 47 +++++++- test/http3/test_020_malformed.py | 199 +++++++++++++++++++++++++++++++ 3 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 test/http3/test_020_malformed.py diff --git a/CHANGES b/CHANGES index 552ea4f..02c1882 100644 --- a/CHANGES +++ b/CHANGES @@ -5,6 +5,13 @@ Security-related entries always appear at the top of their release block. v0.0.60 (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 ] + *) Added support for building against httpd 2.4.52+. Without response buckets the module removes the core HTTP_HEADER filter from its requests and snapshots status and headers itself, mirroring diff --git a/mod_http3/src/h3_stream.c b/mod_http3/src/h3_stream.c index bac0fea..582855f 100644 --- a/mod_http3/src/h3_stream.c +++ b/mod_http3/src/h3_stream.c @@ -184,12 +184,48 @@ 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) + { + /* No STOP_SENDING counterpart: OpenSSL closes the receiving half as + * part of the stream's own teardown. */ + h3q_stream_reset(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; } @@ -225,7 +261,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) @@ -256,6 +292,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; @@ -273,7 +314,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" From 09f8b2e4595e2cc1954730c4c8a56048658c5ed5 Mon Sep 17 00:00:00 2001 From: Alexander Gerasimov Date: Fri, 7 Aug 2026 00:55:12 +0300 Subject: [PATCH 03/11] Enforce H3IdleTimeout in the module event loop The QUIC stacks keep an otherwise idle connection alive on their own: OpenSSL sends a keepalive PING at half the idle interval to hold NAT state open (RFC 9000 s. 10.1.2), which resets both endpoints' idle timers. The transport-level max_idle_timeout therefore never fires and every finished connection stayed on the active list until the peer went away, holding its session pool, its H3MaxConnections slot and, on a patched MPM, its connection count. Track the last application-level progress per session -- a stream accepted, request data read, a request dispatched, response bytes acknowledged -- and close a session with H3_NO_ERROR once it has no worker running and no such progress for H3IdleTimeout seconds. Transport chatter deliberately does not count as progress. --- mod_http3/include/h3_session.h | 7 +++++++ mod_http3/src/h3_io.c | 24 ++++++++++++++++++++++++ mod_http3/src/h3_session.c | 1 + mod_http3/src/h3_stream.c | 10 ++++++++-- 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/mod_http3/include/h3_session.h b/mod_http3/include/h3_session.h index 6994c85..6c91906 100644 --- a/mod_http3/include/h3_session.h +++ b/mod_http3/include/h3_session.h @@ -64,6 +64,13 @@ struct h3_session int control_streams_created; apr_time_t goaway_deadline; + /// Time of the last application-level progress (stream opened, request + /// data read, request dispatched, response data acknowledged). Drives the + /// module's own idle enforcement: transport-level idle timeouts never + /// fire when the QUIC stack keepalive-pings the peer (OpenSSL pings at + /// half the idle interval, RFC 9000 s. 10.1.2). + apr_time_t last_activity; + volatile apr_uint32_t active_tasks; struct diff --git a/mod_http3/src/h3_io.c b/mod_http3/src/h3_io.c index 2792174..fb97487 100644 --- a/mod_http3/src/h3_io.c +++ b/mod_http3/src/h3_io.c @@ -405,8 +405,10 @@ int service_session_pass(h3_io_t* io, h3_session* session) return 0; } + int had_activity = 0; for (h3q_stream* s2 = NULL; (s2 = h3q_conn_accept_stream(conn)) != NULL;) { + had_activity = 1; apr_atomic_inc32(&io->total_streams); int64_t sid = h3q_stream_id(s2); if (sid < 0) @@ -447,5 +449,27 @@ int service_session_pass(h3_io_t* io, h3_session* session) flush_nghttp3(session); apr_thread_mutex_unlock(session->lock); apr_pool_destroy(scratch); + + if (data_read || completed->nelts > 0) + { + had_activity = 1; + } + if (had_activity) + { + session->last_activity = apr_time_now(); + } + else if (io->thread_running && apr_atomic_read32(&session->active_tasks) == 0) + { + /* The QUIC stack defeats the transport-level idle timeout by + * keepalive-pinging the peer, so enforce H3IdleTimeout here once the + * session has no application progress (see h3_session::last_activity). */ + h3_server_conf* conf = ap_get_module_config(s->module_config, &http3_module); + if (conf && apr_time_now() - session->last_activity >= apr_time_from_sec(conf->h3_idle_timeout)) + { + ap_log_error(APLOG_MARK, APLOG_INFO, 0, s, "closing HTTP/3 connection idle for %u second(s)", (unsigned)conf->h3_idle_timeout); + session->abort_quic_error_code = NGHTTP3_H3_NO_ERROR; + session->aborted = 1; + } + } return data_read; } diff --git a/mod_http3/src/h3_session.c b/mod_http3/src/h3_session.c index ebcd5eb..7c80278 100644 --- a/mod_http3/src/h3_session.c +++ b/mod_http3/src/h3_session.c @@ -70,6 +70,7 @@ apr_status_t h3_session_create(h3_session** psession, server_rec* s, h3q_conn* q session->qconn = qconn; session->streams = apr_hash_make(pool); session->pending_free = apr_array_make(pool, 8, sizeof(h3q_stream*)); + session->last_activity = apr_time_now(); apr_status_t rv = apr_thread_mutex_create(&session->lock, APR_THREAD_MUTEX_DEFAULT, pool); if (rv != APR_SUCCESS) diff --git a/mod_http3/src/h3_stream.c b/mod_http3/src/h3_stream.c index 582855f..ff822cb 100644 --- a/mod_http3/src/h3_stream.c +++ b/mod_http3/src/h3_stream.c @@ -109,9 +109,15 @@ void flush_nghttp3(h3_session* session) continue; } h3q_write_result res = h3q_stream_write(h3s->qstream, (const h3q_vec*)vec, (size_t)nvec, fin); - if (res.accepted > 0 && child_h3_io) + if (res.accepted > 0) { - apr_atomic_add64(&child_h3_io->total_bytes_written, res.accepted); + /* Response bytes handed to the transport count as application progress; + * transport chatter deliberately does not (see h3_session::last_activity). */ + session->last_activity = apr_time_now(); + if (child_h3_io) + { + apr_atomic_add64(&child_h3_io->total_bytes_written, res.accepted); + } } if (res.broken) { From 6a688807e236900256fd3a0de14e0e944008f6b5 Mon Sep 17 00:00:00 2001 From: Alexander Gerasimov Date: Fri, 14 Aug 2026 16:28:59 +0300 Subject: [PATCH 04/11] Size the QUIC socket buffers with H3SocketBufferSize Add H3SocketBufferSize. A UDP receive buffer at the OS default (commonly 208KB) overflows as soon as one connection runs at speed, and each dropped datagram costs a retransmit and a congestion-window cut. The OS caps what it grants, so the module asks, reports a capped or refused grant at info level, and never fails the socket over it. Ported from the pre-h3q tree. The original change also added an optional recv_batch hook to quic_io, implemented with recvmmsg(), to spare the ngtcp2 engine one syscall per datagram. That half is dropped: the ngtcp2 engine no longer exists, and the OpenSSL path drives its own datagram BIO through BIO_recvmmsg() already (see h3q_peer_addr_bio_recvmmsg), so it never used the hook. The unit tests for quic_io_udp go with it. --- CHANGES | 7 +++ docs/configuration_httpd.md | 8 +++ docs/limits.md | 1 + mod_http3/include/h3.h | 4 ++ mod_http3/include/h3_config.h | 1 + mod_http3/include/h3_socket.h | 6 +- mod_http3/src/h3_config.c | 32 ++++++++++ mod_http3/src/h3_server.c | 4 +- mod_http3/src/h3_socket.c | 33 +++++++++- test/http3/env.py | 3 + test/http3/test_021_socket_buffer.py | 91 ++++++++++++++++++++++++++++ 11 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 test/http3/test_021_socket_buffer.py diff --git a/CHANGES b/CHANGES index 02c1882..97e56a4 100644 --- a/CHANGES +++ b/CHANGES @@ -5,6 +5,13 @@ Security-related entries always appear at the top of their release block. v0.0.60 (unreleased) -------------------- + *) Added H3SocketBufferSize, which asks for the QUIC socket send and receive + buffer size. A receive buffer left at the OS default overflows once a + single connection runs at speed, and each dropped datagram costs a + retransmit; the OS still caps what it grants, and a capped grant is + logged rather than fatal. + [Alexander Gerasimov ] + *) 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 diff --git a/docs/configuration_httpd.md b/docs/configuration_httpd.md index e5b4a29..ffeea62 100644 --- a/docs/configuration_httpd.md +++ b/docs/configuration_httpd.md @@ -55,6 +55,14 @@ Maximum number of concurrent HTTP/3 streams (in-flight requests) per QUIC connec Maximum number of concurrent QUIC/HTTP/3 connections per child process. New connection attempts beyond the limit are refused. +### H3SocketBufferSize + +**Syntax:** `H3SocketBufferSize bytes` +**Context:** server config, virtual host +**Default:** `2097152` + +Bytes requested for the QUIC socket's send and receive buffers (`SO_SNDBUF` and `SO_RCVBUF`). The operating system caps what it grants -- on Linux through `net.core.wmem_max` and `net.core.rmem_max` -- and a refused or capped request is logged at `info` level rather than treated as an error, so raising this alone may not take effect. A receive buffer left at the OS default overflows once a single connection runs at speed, and every dropped datagram costs a retransmit and a congestion-window reduction. + ### H3StreamBufferSize **Syntax:** `H3StreamBufferSize bytes` diff --git a/docs/limits.md b/docs/limits.md index 5aac53b..4c963b6 100644 --- a/docs/limits.md +++ b/docs/limits.md @@ -7,6 +7,7 @@ HTTP/3 request and response bodies are buffered by the module. Set limits accord | `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 | +| `H3SocketBufferSize` | `2097152` | Requests QUIC socket send/receive buffer size, capped by the OS | | `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 | diff --git a/mod_http3/include/h3.h b/mod_http3/include/h3.h index 8d8aa25..6dd319c 100644 --- a/mod_http3/include/h3.h +++ b/mod_http3/include/h3.h @@ -44,6 +44,10 @@ #define H3_ALT_SVC_MAX_AGE_DEFAULT 86400 #define H3_ALT_SVC_MAX_AGE_MAX (7UL * 24 * 3600) +/* Asked of SO_RCVBUF/SO_SNDBUF on the QUIC socket; the OS may grant less. */ +#define H3_SOCKET_BUFFER_SIZE_DEFAULT (2 * 1024 * 1024) +#define H3_SOCKET_BUFFER_SIZE_MAX (64UL * 1024 * 1024) + #define H3_HANDSHAKE_TIMEOUT_DEFAULT 10 #define H3_HANDSHAKE_TIMEOUT_MAX 600 #define H3_IDLE_TIMEOUT_DEFAULT 300 diff --git a/mod_http3/include/h3_config.h b/mod_http3/include/h3_config.h index 1ccee03..1b9ab15 100644 --- a/mod_http3/include/h3_config.h +++ b/mod_http3/include/h3_config.h @@ -51,6 +51,7 @@ struct h3_server_conf apr_uint32_t h3_alt_svc_max_age; apr_uint32_t h3_handshake_timeout; apr_uint32_t h3_idle_timeout; + apr_size_t h3_socket_buffer_size; }; /** diff --git a/mod_http3/include/h3_socket.h b/mod_http3/include/h3_socket.h index 0be002b..ed2ec97 100644 --- a/mod_http3/include/h3_socket.h +++ b/mod_http3/include/h3_socket.h @@ -28,14 +28,18 @@ * Open a non-blocking IPv6 dual-stack UDP socket and bind it to the given port. * Sets SO_REUSEADDR; on non-Windows also tries SO_REUSEPORT so multiple * children can share the port. + * Also asks for @p buffer_size on the send and receive buffers, which the OS + * may cap; a refused or capped buffer is logged, never fatal. * @param port Port to bind; 0 lets the OS pick. + * @param buffer_size Bytes requested for SO_RCVBUF and SO_SNDBUF; 0 keeps the + * OS default. * @param pool Pool used for the underlying apr_socket_t lifetime. * @param out_fd Out parameter: the resulting OS-level fd (suitable for * SSL_set_fd on OpenSSL QUIC). * @return APR_SUCCESS, APR_EAGAIN if the port is already bound, or another * APR error code. */ -apr_status_t h3_socket_open(apr_port_t port, apr_pool_t* pool, int* out_fd); +apr_status_t h3_socket_open(apr_port_t port, apr_size_t buffer_size, apr_pool_t* pool, int* out_fd); /** * Close a UDP socket previously returned by h3_socket_open. diff --git a/mod_http3/src/h3_config.c b/mod_http3/src/h3_config.c index 4ef40c6..211931a 100644 --- a/mod_http3/src/h3_config.c +++ b/mod_http3/src/h3_config.c @@ -72,6 +72,7 @@ void* h3_merge_server_config(apr_pool_t* p, void* base_conf, void* new_conf) 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; + merged->h3_socket_buffer_size = new->h3_socket_buffer_size ? new->h3_socket_buffer_size : base->h3_socket_buffer_size; return merged; } @@ -209,6 +210,32 @@ static const char* set_h3_stream_buffer_size(cmd_parms* cmd, void* dummy H3_UNUS return NULL; } +static const char* set_h3_socket_buffer_size(cmd_parms* cmd, void* dummy H3_UNUSED, const char* arg) +{ + if (!arg || !*arg) + { + return "H3SocketBufferSize: empty value"; + } + apr_uint64_t val = 0; + apr_status_t rv = apr_cstr_atoui64(&val, arg); + if (rv == APR_EINVAL) + { + return apr_psprintf(cmd->pool, "H3SocketBufferSize: '%s' is not a number", arg); + } + if (rv == APR_ERANGE) + { + return apr_psprintf(cmd->pool, "H3SocketBufferSize: '%s' is out of representable range", arg); + } + if (val == 0 || val > H3_SOCKET_BUFFER_SIZE_MAX) + { + return apr_psprintf(cmd->pool, "H3SocketBufferSize: '%s' is out of allowed range (1-%lu)", arg, (unsigned long)H3_SOCKET_BUFFER_SIZE_MAX); + } + h3_server_conf* conf = ap_get_module_config(cmd->server->module_config, &http3_module); + CHECK(conf); + conf->h3_socket_buffer_size = (apr_size_t)val; + return NULL; +} + static const char* set_h3_max_request_body_size(cmd_parms* cmd, void* dummy H3_UNUSED, const char* arg) { if (!arg || !*arg) @@ -388,6 +415,10 @@ int h3_post_config(apr_pool_t* p H3_UNUSED, apr_pool_t* plog H3_UNUSED, apr_pool { vc->h3_stream_buffer_size = H3_STREAM_BUFFER_SIZE_DEFAULT; } + if (vc->h3_socket_buffer_size == 0) + { + vc->h3_socket_buffer_size = H3_SOCKET_BUFFER_SIZE_DEFAULT; + } if (vc->h3_max_request_body_size == 0) { vc->h3_max_request_body_size = H3_MAX_REQUEST_BODY_SIZE_DEFAULT; @@ -471,5 +502,6 @@ const command_rec h3_cmds[] = { AP_INIT_TAKE1("H3IdleTimeout", set_h3_idle_timeout, NULL, RSRC_CONF, "Idle timeout in seconds for QUIC connections (default: 300)"), 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)"), 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)"), + AP_INIT_TAKE1("H3SocketBufferSize", set_h3_socket_buffer_size, NULL, RSRC_CONF, "Bytes requested for the QUIC socket send and receive buffers; the OS may grant less (default: 2097152)"), AP_INIT_TAKE1(NULL, NULL, NULL, RSRC_CONF, NULL), }; diff --git a/mod_http3/src/h3_server.c b/mod_http3/src/h3_server.c index ca1d419..95c55af 100644 --- a/mod_http3/src/h3_server.c +++ b/mod_http3/src/h3_server.c @@ -51,7 +51,7 @@ static void* APR_THREAD_FUNC port_acquire_thread_fn(apr_thread_t* thread H3_UNUS while (!child_stopping) { int udp_fd = -1; - apr_status_t rv = h3_socket_open(args->conf->h3_port, args->pchild, &udp_fd); + apr_status_t rv = h3_socket_open(args->conf->h3_port, args->conf->h3_socket_buffer_size, args->pchild, &udp_fd); if (rv == APR_EAGAIN) { apr_sleep(apr_time_from_msec(H3_PORT_ACQUIRE_RETRY_MS)); @@ -109,7 +109,7 @@ void h3_child_init(apr_pool_t* pchild, server_rec* s) } int udp_fd = -1; - apr_status_t rv = h3_socket_open(conf->h3_port, pchild, &udp_fd); + apr_status_t rv = h3_socket_open(conf->h3_port, conf->h3_socket_buffer_size, pchild, &udp_fd); if (rv == APR_EAGAIN) { ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, vhost, "h3_child_init: pid=%d port already owned, will keep retrying in background", h3_getpid()); diff --git a/mod_http3/src/h3_socket.c b/mod_http3/src/h3_socket.c index ee4a097..ab51f7e 100644 --- a/mod_http3/src/h3_socket.c +++ b/mod_http3/src/h3_socket.c @@ -24,7 +24,37 @@ #include "h3_os.h" #include "h3_socket.h" -apr_status_t h3_socket_open(apr_port_t port, apr_pool_t* pool, int* out_fd) +/* + * A UDP socket left at the OS default receive buffer (commonly 208KB) starts + * dropping datagrams as soon as one QUIC connection runs at speed, and every + * drop costs a retransmit and a congestion-window cut. The kernel caps what it + * grants (net.core.rmem_max / wmem_max on Linux), so this asks and reports + * what it got; it never fails the socket over a buffer size. + */ +static void tune_buffers(apr_socket_t* sock, apr_size_t want, apr_port_t port, apr_pool_t* pool) +{ + if (want == 0 || want > (apr_size_t)APR_INT32_MAX) + { + return; + } + static const apr_int32_t opts[] = {APR_SO_RCVBUF, APR_SO_SNDBUF}; + static const char* const names[] = {"SO_RCVBUF", "SO_SNDBUF"}; + for (int i = 0; i < 2; i++) + { + if (apr_socket_opt_set(sock, opts[i], (apr_int32_t)want) != APR_SUCCESS) + { + ap_log_perror(APLOG_MARK, APLOG_INFO, 0, pool, "h3_socket_open(%d): %s could not be set to %" APR_SIZE_T_FMT " bytes, keeping the OS default", (int)port, names[i], want); + continue; + } + apr_int32_t got = 0; + if (apr_socket_opt_get(sock, opts[i], &got) == APR_SUCCESS && (apr_size_t)got < want) + { + ap_log_perror(APLOG_MARK, APLOG_INFO, 0, pool, "h3_socket_open(%d): %s capped at %d bytes of the %" APR_SIZE_T_FMT " requested; raise the OS limit to grant more", (int)port, names[i], (int)got, want); + } + } +} + +apr_status_t h3_socket_open(apr_port_t port, apr_size_t buffer_size, apr_pool_t* pool, int* out_fd) { CHECK(pool); CHECK(out_fd); @@ -36,6 +66,7 @@ apr_status_t h3_socket_open(apr_port_t port, apr_pool_t* pool, int* out_fd) return rv; } apr_socket_opt_set(sock, APR_IPV6_V6ONLY, 0); + tune_buffers(sock, buffer_size, port, pool); apr_sockaddr_t* addr = NULL; rv = apr_sockaddr_info_get(&addr, NULL, APR_INET6, port, 0, pool); if (rv != APR_SUCCESS) diff --git a/test/http3/env.py b/test/http3/env.py index 1dd1aab..c488336 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_socket_buffer_size=None, extra_lines=None ): self.start_vhost( @@ -124,6 +125,8 @@ 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}") + if h3_socket_buffer_size is not None: + self.add(f"H3SocketBufferSize {h3_socket_buffer_size}") self.add("Protocols h3 http/1.1") for line in extra_lines or []: diff --git a/test/http3/test_021_socket_buffer.py b/test/http3/test_021_socket_buffer.py new file mode 100644 index 0000000..9cb3aa6 --- /dev/null +++ b/test/http3/test_021_socket_buffer.py @@ -0,0 +1,91 @@ +import os + +import pytest + + +def _read_test_conf(env): + return open(os.path.join(env.server_dir, "conf", "test.conf")).read() + + +class TestSocketBuffer: + """H3SocketBufferSize sizes the QUIC socket buffers and never blocks startup.""" + + @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 + + def test_001_default_serves_requests(self, env): + # The default buffer request must not stop the socket from being usable. + url = env.mkurl("https", "test1", "/") + r = env.curl_get(url, options=["--http3-only", "-k"]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response is not None + assert r.response["status"] == 200 + assert r.response["protocol"] == "HTTP/3" + + def test_002_explicit_size_in_vhost(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_socket_buffer_size=4194304).install() + assert env.apache_restart() == 0 + assert "H3SocketBufferSize 4194304" in _read_test_conf(env) + + def test_003_explicit_size_serves_requests(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_socket_buffer_size=4194304).install() + assert env.apache_restart() == 0 + url = env.mkurl("https", "test1", "/") + r = env.curl_get(url, options=["--http3-only", "-k"]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response is not None + assert r.response["status"] == 200 + assert r.response["protocol"] == "HTTP/3" + + def test_004_size_the_os_will_cap_still_starts(self, env): + # The OS caps SO_RCVBUF at rmem_max; a capped grant is logged, not fatal. + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_socket_buffer_size=67108864).install() + assert env.apache_restart() == 0 + url = env.mkurl("https", "test1", "/") + r = env.curl_get(url, options=["--http3-only", "-k"]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response["status"] == 200 + + def test_005_invalid_value(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_socket_buffer_size="invalid").install() + assert env.apache_restart() != 0 + H3Conf(env).add_vhost_test1(h3_socket_buffer_size=0).install() + assert env.apache_restart() != 0 + H3Conf(env).add_vhost_test1(h3_socket_buffer_size=67108865).install() + assert env.apache_restart() != 0 + + def test_006_many_concurrent_requests(self, env): + # A batched read path must deliver every datagram of a burst, not just + # the first of each batch. + from concurrent.futures import ThreadPoolExecutor + + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_socket_buffer_size=4194304).install() + assert env.apache_restart() == 0 + + url = env.mkurl("https", "test1", "/") + + def fetch(_): + return env.curl_get(url, options=["--http3-only", "-k"]) + + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(fetch, range(24))) + + for r in results: + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response is not None + assert r.response["status"] == 200 + assert r.response["protocol"] == "HTTP/3" From b067ad1ac6237fb53120046b044d04082fb4d2d0 Mon Sep 17 00:00:00 2001 From: Alexander Gerasimov Date: Fri, 14 Aug 2026 16:32:27 +0300 Subject: [PATCH 05/11] Expose TLS session resumption, and answer honestly about 0-RTT Session tickets were left entirely to OpenSSL's defaults, so an operator had no way to see or change whether the server hands out resumable sessions, and no answer at all about 0-RTT. Add H3SessionTickets (default on, matching the previous behaviour) which maps to SSL_CTX_set_num_tickets, and set a session id context so tickets this server issues are only resumed against its own sessions. Worth knowing when reading the docs: each worker process keeps its own ticket keys, so a client resumes only when it returns to the process that issued its ticket, and otherwise falls back to a full handshake. 0-RTT is the harder question. OpenSSL's QUIC stack does not accept early data on the server side, so wiring a directive straight to SSL_CTX_set_max_early_data would produce a setting that reads as enabled and does nothing. H3EarlyData therefore defaults to off -- 0-RTT data is replayable by design -- and post_config warns when it is on. Ported from the pre-h3q tree. The original expressed this as an early_data bit in quic_caps that each engine answered for itself. With only the OpenSSL engine left that bit is a constant, so the warning names the OpenSSL stack directly; h3q_config still carries the flag through to the SSL_CTX so an OpenSSL that gains server-side 0-RTT needs no further plumbing here. --- CHANGES | 12 ++++ docs/configuration_httpd.md | 16 +++++ mod_http3/include/h3_config.h | 2 + mod_http3/include/quic/h3q.h | 15 +++++ mod_http3/src/h3_config.c | 34 ++++++++++ mod_http3/src/h3_io.c | 2 + mod_http3/src/quic/detail/h3q_tls.c | 19 ++++++ test/http3/env.py | 8 +++ test/http3/test_022_resumption.py | 96 +++++++++++++++++++++++++++++ 9 files changed, 204 insertions(+) create mode 100644 test/http3/test_022_resumption.py diff --git a/CHANGES b/CHANGES index 97e56a4..758f665 100644 --- a/CHANGES +++ b/CHANGES @@ -5,6 +5,18 @@ Security-related entries always appear at the top of their release block. v0.0.60 (unreleased) -------------------- + *) Added H3SessionTickets, which controls whether TLS 1.3 session tickets are + issued so a returning client can resume instead of running a full + handshake. Tickets stay on by default, as before, and each worker process + keeps its own ticket keys, so a client resumes only when it returns to the + process that issued its ticket. + [Alexander Gerasimov ] + + *) Added H3EarlyData, off by default. The OpenSSL QUIC stack has no + server-side 0-RTT, so turning early data on now logs a warning saying so + instead of silently doing nothing. + [Alexander Gerasimov ] + *) Added H3SocketBufferSize, which asks for the QUIC socket send and receive buffer size. A receive buffer left at the OS default overflows once a single connection runs at speed, and each dropped datagram costs a diff --git a/docs/configuration_httpd.md b/docs/configuration_httpd.md index ffeea62..be45030 100644 --- a/docs/configuration_httpd.md +++ b/docs/configuration_httpd.md @@ -125,6 +125,22 @@ 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). +### H3SessionTickets + +**Syntax:** `H3SessionTickets on|off` +**Context:** server config, virtual host +**Default:** `on` + +Whether to issue TLS 1.3 session tickets. A returning client that presents a ticket resumes its session and skips a certificate verification, which is the difference between a two-round-trip and a one-round-trip reconnect. Each worker process holds its own ticket keys, so a client resumes only when it returns to the process that issued its ticket; otherwise the server transparently falls back to a full handshake. Turn this off to force a full handshake on every connection. + +### H3EarlyData + +**Syntax:** `H3EarlyData on|off` +**Context:** server config, virtual host +**Default:** `off` + +Whether to accept 0-RTT application data on a resumed connection. The OpenSSL QUIC stack does not accept early data on the server side, so with `H3EarlyData on` the module logs a warning at startup saying so and keeps completing the handshake before it reads a request. Note that 0-RTT data is replayable by design (RFC 9001 section 9.2), so it is only ever appropriate for requests that are safe to repeat. + ### H3AddressValidation **Syntax:** `H3AddressValidation on|off` diff --git a/mod_http3/include/h3_config.h b/mod_http3/include/h3_config.h index 1b9ab15..8533b3c 100644 --- a/mod_http3/include/h3_config.h +++ b/mod_http3/include/h3_config.h @@ -52,6 +52,8 @@ struct h3_server_conf apr_uint32_t h3_handshake_timeout; apr_uint32_t h3_idle_timeout; apr_size_t h3_socket_buffer_size; + h3_tri_flag h3_session_tickets; + h3_tri_flag h3_early_data; }; /** diff --git a/mod_http3/include/quic/h3q.h b/mod_http3/include/quic/h3q.h index 8226079..563feab 100644 --- a/mod_http3/include/quic/h3q.h +++ b/mod_http3/include/quic/h3q.h @@ -37,6 +37,21 @@ typedef struct h3q_config const char* cert_path; const char* key_path; unsigned address_validation : 1; + /* + * Issue TLS 1.3 session tickets, so a returning client can resume instead + * of running a full handshake with another certificate verification. Each + * worker process keeps its own ticket keys, so a client resumes only when + * it returns to the process that issued the ticket; otherwise the server + * falls back to a full handshake. + */ + unsigned session_tickets : 1; + /* + * Ask to accept 0-RTT data on resumed connections. OpenSSL's QUIC stack + * does not accept early data on the server side, so this currently only + * primes the SSL_CTX; h3_post_config warns when it is set. Kept so an + * OpenSSL that gains server-side 0-RTT needs no further plumbing here. + */ + unsigned early_data : 1; } h3q_config; /** diff --git a/mod_http3/src/h3_config.c b/mod_http3/src/h3_config.c index 211931a..58a5f05 100644 --- a/mod_http3/src/h3_config.c +++ b/mod_http3/src/h3_config.c @@ -73,6 +73,8 @@ void* h3_merge_server_config(apr_pool_t* p, void* base_conf, void* new_conf) 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; merged->h3_socket_buffer_size = new->h3_socket_buffer_size ? new->h3_socket_buffer_size : base->h3_socket_buffer_size; + merged->h3_session_tickets = new->h3_session_tickets != H3_FLAG_UNSET ? new->h3_session_tickets : base->h3_session_tickets; + merged->h3_early_data = new->h3_early_data != H3_FLAG_UNSET ? new->h3_early_data : base->h3_early_data; return merged; } @@ -340,6 +342,22 @@ static const char* set_h3_idle_timeout(cmd_parms* cmd, void* dummy H3_UNUSED, co return NULL; } +static const char* set_h3_session_tickets(cmd_parms* cmd, void* dummy H3_UNUSED, int flag) +{ + h3_server_conf* conf = ap_get_module_config(cmd->server->module_config, &http3_module); + CHECK(conf); + conf->h3_session_tickets = flag ? H3_FLAG_ON : H3_FLAG_OFF; + return NULL; +} + +static const char* set_h3_early_data(cmd_parms* cmd, void* dummy H3_UNUSED, int flag) +{ + h3_server_conf* conf = ap_get_module_config(cmd->server->module_config, &http3_module); + CHECK(conf); + conf->h3_early_data = flag ? H3_FLAG_ON : H3_FLAG_OFF; + return NULL; +} + static const char* set_h3_alt_svc(cmd_parms* cmd, void* dummy H3_UNUSED, int flag) { h3_server_conf* conf = ap_get_module_config(cmd->server->module_config, &http3_module); @@ -435,6 +453,14 @@ int h3_post_config(apr_pool_t* p H3_UNUSED, apr_pool_t* plog H3_UNUSED, apr_pool { vc->h3_address_validation = H3_FLAG_ON; } + if (vc->h3_session_tickets == H3_FLAG_UNSET) + { + vc->h3_session_tickets = H3_FLAG_ON; + } + if (vc->h3_early_data == H3_FLAG_UNSET) + { + vc->h3_early_data = H3_FLAG_OFF; + } if (vc->h3_alt_svc_max_age == 0) { vc->h3_alt_svc_max_age = H3_ALT_SVC_MAX_AGE_DEFAULT; @@ -454,6 +480,12 @@ int h3_post_config(apr_pool_t* p H3_UNUSED, apr_pool_t* plog H3_UNUSED, apr_pool CHECK(conf && conf->h3_cert_path && conf->h3_key_path, return HTTP_INTERNAL_SERVER_ERROR;); + /* Say so rather than let an operator believe 0-RTT is running. */ + if (conf->h3_early_data == H3_FLAG_ON) + { + ap_log_error(APLOG_MARK, APLOG_WARNING, 0, s, "mod_http3: H3EarlyData is on but the OpenSSL QUIC stack does not accept 0-RTT data on the server side; connections will keep completing the handshake before any request is read"); + } + /* 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) @@ -503,5 +535,7 @@ const command_rec h3_cmds[] = { 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)"), 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)"), AP_INIT_TAKE1("H3SocketBufferSize", set_h3_socket_buffer_size, NULL, RSRC_CONF, "Bytes requested for the QUIC socket send and receive buffers; the OS may grant less (default: 2097152)"), + AP_INIT_FLAG("H3SessionTickets", set_h3_session_tickets, NULL, RSRC_CONF, "Whether to issue TLS session tickets so returning clients can resume instead of running a full handshake (default: on)"), + AP_INIT_FLAG("H3EarlyData", set_h3_early_data, NULL, RSRC_CONF, "Whether to accept 0-RTT data on resumed connections; the OpenSSL QUIC stack cannot, so this currently only warns (default: off)"), AP_INIT_TAKE1(NULL, NULL, NULL, RSRC_CONF, NULL), }; diff --git a/mod_http3/src/h3_io.c b/mod_http3/src/h3_io.c index fb97487..e0103ef 100644 --- a/mod_http3/src/h3_io.c +++ b/mod_http3/src/h3_io.c @@ -125,6 +125,8 @@ apr_status_t h3_io_listen_start(apr_pool_t* pchild, server_rec* s, h3_server_con .cert_path = conf->h3_cert_path, .key_path = conf->h3_key_path, .address_validation = (conf->h3_address_validation != H3_FLAG_OFF), + .session_tickets = (conf->h3_session_tickets != H3_FLAG_OFF), + .early_data = (conf->h3_early_data == H3_FLAG_ON), }; io->qengine = h3q_engine_create(&qcfg, udp_fd, qerr, sizeof(qerr)); if (!io->qengine) diff --git a/mod_http3/src/quic/detail/h3q_tls.c b/mod_http3/src/quic/detail/h3q_tls.c index 34763af..f3d860e 100644 --- a/mod_http3/src/quic/detail/h3q_tls.c +++ b/mod_http3/src/quic/detail/h3q_tls.c @@ -95,6 +95,25 @@ SSL_CTX* h3q_tls_ctx_create(const h3q_config* cfg, char* err, size_t errlen) return NULL; } + /* Named so tickets this server issues are only resumed against this + * server's sessions. */ + static const unsigned char sid_ctx[] = "mod_http3"; + SSL_CTX_set_session_id_context(ssl_ctx, sid_ctx, sizeof(sid_ctx) - 1); + + if (!cfg->session_tickets) + { + /* TLS 1.3 resumption travels in tickets, so issuing none turns it off. */ + SSL_CTX_set_num_tickets(ssl_ctx, 0); + } + + if (cfg->early_data) + { + /* RFC 9001 s. 4.6.1: over QUIC the early_data extension carries + * max_early_data_size 0xffffffff and nothing else; the byte limit lives + * in the transport parameters. */ + SSL_CTX_set_max_early_data(ssl_ctx, 0xffffffffu); + } + SSL_CTX_set_alpn_select_cb(ssl_ctx, h3q_tls_alpn_select_cb, NULL); if (getenv("SSLKEYLOGFILE")) { diff --git a/test/http3/env.py b/test/http3/env.py index c488336..10ff868 100644 --- a/test/http3/env.py +++ b/test/http3/env.py @@ -89,6 +89,8 @@ def add_vhost_test1( h3_idle_timeout=None, h3_address_validation=None, h3_socket_buffer_size=None, + h3_session_tickets=None, + h3_early_data=None, extra_lines=None ): self.start_vhost( @@ -127,6 +129,12 @@ def add_vhost_test1( self.add(f"H3AddressValidation {val}") if h3_socket_buffer_size is not None: self.add(f"H3SocketBufferSize {h3_socket_buffer_size}") + if h3_session_tickets is not None: + val = "on" if h3_session_tickets is True else ("off" if h3_session_tickets is False else h3_session_tickets) + self.add(f"H3SessionTickets {val}") + if h3_early_data is not None: + val = "on" if h3_early_data is True else ("off" if h3_early_data is False else h3_early_data) + self.add(f"H3EarlyData {val}") self.add("Protocols h3 http/1.1") for line in extra_lines or []: diff --git a/test/http3/test_022_resumption.py b/test/http3/test_022_resumption.py new file mode 100644 index 0000000..c8ecbe7 --- /dev/null +++ b/test/http3/test_022_resumption.py @@ -0,0 +1,96 @@ +import os +import re + +import pytest + + +def _read_test_conf(env): + return open(os.path.join(env.server_dir, "conf", "test.conf")).read() + + +class TestSessionResumption: + """H3SessionTickets controls TLS ticket issuance; H3EarlyData only warns, as OpenSSL QUIC has no server-side 0-RTT.""" + + @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 + + def test_001_tickets_on_by_default_and_serve(self, env): + url = env.mkurl("https", "test1", "/") + r = env.curl_get(url, options=["--http3-only", "-k"]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response is not None + assert r.response["status"] == 200 + assert r.response["protocol"] == "HTTP/3" + + def test_002_tickets_off_in_vhost(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_session_tickets=False).install() + assert env.apache_restart() == 0 + assert "H3SessionTickets off" in _read_test_conf(env) + + def test_003_requests_still_work_without_tickets(self, env): + # Turning resumption off must cost a round trip, never correctness. + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_session_tickets=False).install() + assert env.apache_restart() == 0 + url = env.mkurl("https", "test1", "/") + for _ in range(2): + r = env.curl_get(url, options=["--http3-only", "-k"]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response["status"] == 200 + assert r.response["protocol"] == "HTTP/3" + + def test_004_tickets_on_explicitly(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_session_tickets=True).install() + assert env.apache_restart() == 0 + assert "H3SessionTickets on" in _read_test_conf(env) + url = env.mkurl("https", "test1", "/") + r = env.curl_get(url, options=["--http3-only", "-k"]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response["status"] == 200 + + def test_005_early_data_off_by_default(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1().install() + assert env.apache_restart() == 0 + assert "H3EarlyData" not in _read_test_conf(env) + + def test_006_early_data_on_warns(self, env): + # The OpenSSL QUIC stack has no server-side 0-RTT. Enabling early data + # must start the server and say plainly that it has no effect, rather + # than leave an operator believing 0-RTT is running. + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_early_data=True).install() + assert env.apache_restart() == 0 + assert "H3EarlyData on" in _read_test_conf(env) + + url = env.mkurl("https", "test1", "/") + r = env.curl_get(url, options=["--http3-only", "-k"]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response["status"] == 200 + + pattern = re.compile(r".*does not accept 0-RTT data.*") + assert env.httpd_error_log.scan_recent(pattern, timeout=10), ( + "expected a warning that H3EarlyData has no effect" + ) + + def test_007_early_data_off_explicitly(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_early_data=False).install() + assert env.apache_restart() == 0 + assert "H3EarlyData off" in _read_test_conf(env) + url = env.mkurl("https", "test1", "/") + r = env.curl_get(url, options=["--http3-only", "-k"]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response["status"] == 200 From 88da462d5dd41769cbcdaf915c0042b36c8239d7 Mon Sep 17 00:00:00 2001 From: Alexander Gerasimov Date: Fri, 14 Aug 2026 16:34:20 +0300 Subject: [PATCH 06/11] Publish the mod_ssl TLS environment for HTTP/3 requests mod_ssl does not manage these connections, so a CGI script or a rewrite condition reading SSL_PROTOCOL or SSL_CIPHER over HTTP/3 saw nothing, while the same request over HTTP/1.1 or HTTP/2 answered normally. Only HTTPS=on was set. Add h3q_conn_tls_info(), which reads the negotiated parameters from the connection's SSL object, and set SSL_PROTOCOL, SSL_CIPHER, SSL_CIPHER_USEKEYSIZE, SSL_CIPHER_ALGKEYSIZE, SSL_CIPHER_EXPORT and SSL_SESSION_RESUMED beside the existing HTTPS=on, spelled as mod_ssl spells them, including its "Initial"/"Resumed" wording. The handshake is finished before a session exists, so these values are final at that point: they are formatted once per connection into the session pool, and each request only sets table entries pointing at them. Certificate-derived variables (SSL_SERVER_*, SSL_CLIENT_*) and SSL_SESSION_ID are deliberately not published; they would need X509 parsing and client certificates, which this module does not support. Ported from the pre-h3q tree, where this was an optional conn.tls_info entry in the engine vtable that each engine filled in. With one engine left it is a plain function on h3q_conn. --- CHANGES | 9 ++++ docs/configuration_httpd.md | 18 +++++++- mod_http3/include/h3_session.h | 19 ++++++++ mod_http3/include/quic/h3q_conn.h | 25 +++++++++++ mod_http3/src/h3_hooks.c | 16 ++++++- mod_http3/src/h3_session.c | 15 +++++++ mod_http3/src/quic/h3q_conn.c | 21 +++++++++ test/http3/test_023_ssl_env.py | 72 +++++++++++++++++++++++++++++++ test/pyhttpd/htdocs/cgi/env.py | 6 +++ 9 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 test/http3/test_023_ssl_env.py diff --git a/CHANGES b/CHANGES index 758f665..d6468be 100644 --- a/CHANGES +++ b/CHANGES @@ -5,6 +5,15 @@ Security-related entries always appear at the top of their release block. v0.0.60 (unreleased) -------------------- + *) Published the mod_ssl TLS environment for HTTP/3 requests: SSL_PROTOCOL, + SSL_CIPHER, SSL_CIPHER_USEKEYSIZE, SSL_CIPHER_ALGKEYSIZE, + SSL_CIPHER_EXPORT and SSL_SESSION_RESUMED, under the names mod_ssl uses. + mod_ssl does not manage these connections, so scripts and rewrite + conditions that read them saw nothing but HTTPS=on over HTTP/3. The values + are read through the new h3q_conn_tls_info() and formatted once per + connection, not per request. + [Alexander Gerasimov ] + *) Added H3SessionTickets, which controls whether TLS 1.3 session tickets are issued so a returning client can resume instead of running a full handshake. Tickets stay on by default, as before, and each worker process diff --git a/docs/configuration_httpd.md b/docs/configuration_httpd.md index be45030..3eac175 100644 --- a/docs/configuration_httpd.md +++ b/docs/configuration_httpd.md @@ -199,7 +199,23 @@ The module uses the **first VirtualHost** that has both `H3CertificatePath` and ``` -### Alt-Svc Header +### TLS Environment Variables + +mod_ssl does not manage HTTP/3 connections, so it publishes no TLS environment for them. The module supplies the following under the same names mod_ssl uses, so existing CGI scripts, `mod_rewrite` conditions and log formats keep working over HTTP/3: + +| Variable | Example | Notes | +| --- | --- | --- | +| `HTTPS` | `on` | Always set for HTTP/3 requests | +| `SSL_PROTOCOL` | `TLSv1.3` | QUIC requires TLS 1.3, so this is always `TLSv1.3` | +| `SSL_CIPHER` | `TLS_AES_128_GCM_SHA256` | Negotiated cipher suite | +| `SSL_CIPHER_USEKEYSIZE` | `128` | Key bits actually used | +| `SSL_CIPHER_ALGKEYSIZE` | `128` | The algorithm's full strength | +| `SSL_CIPHER_EXPORT` | `false` | Always `false`; TLS 1.3 has no export ciphers | +| `SSL_SESSION_RESUMED` | `Initial` | `Resumed` when the client presented a session ticket | + +These are set for every HTTP/3 request, without needing `SSLOptions +StdEnvVars`, and are computed once per connection. Certificate-derived variables (`SSL_SERVER_*`, `SSL_CLIENT_*`) and `SSL_SESSION_ID` are not published; HTTP/3 requests do not use client certificates in this module. + +## Alt-Svc Header mod_http3 injects the `Alt-Svc` response header automatically when HTTP/3 is configured (controlled by [`H3AltSvc`](#h3altsvc), on by default): diff --git a/mod_http3/include/h3_session.h b/mod_http3/include/h3_session.h index 6c91906..1f0b76d 100644 --- a/mod_http3/include/h3_session.h +++ b/mod_http3/include/h3_session.h @@ -35,12 +35,31 @@ typedef struct h3_session h3_session; typedef struct h3_stream h3_stream; typedef struct h3_response_chunk h3_response_chunk; +/** + * mod_ssl-compatible TLS environment for one connection, in the spelling + * mod_ssl uses so existing scripts and rewrite conditions keep working. + * Computed once per connection, since none of it changes between requests, and + * held in the session pool so every request on the connection can point at it. + * All members are NULL when the engine runs no TLS of its own. + */ +typedef struct h3_tls_env +{ + const char* protocol; + const char* cipher; + const char* cipher_usekeysize; + const char* cipher_algkeysize; + const char* cipher_export; + const char* session_resumed; +} h3_tls_env; + struct h3_session { conn_rec* c; server_rec* s; apr_pool_t* pool; + h3_tls_env tls_env; + h3q_conn* qconn; nghttp3_conn* ngh3; diff --git a/mod_http3/include/quic/h3q_conn.h b/mod_http3/include/quic/h3q_conn.h index ad8c72d..7b8ad9b 100644 --- a/mod_http3/include/quic/h3q_conn.h +++ b/mod_http3/include/quic/h3q_conn.h @@ -24,6 +24,31 @@ #include "quic/h3q.h" +/** + * Negotiated TLS parameters of one connection, as a caller needs them to + * describe the connection to an application (mod_ssl's SSL_PROTOCOL, + * SSL_CIPHER and friends). The strings point into storage OpenSSL owns and + * stay valid as long as the connection does; callers copy what they keep + * longer. + */ +typedef struct h3q_tls_info +{ + const char* protocol; + const char* cipher; + /* Key bits actually used, and the algorithm's full strength. */ + int cipher_bits; + int cipher_alg_bits; + unsigned resumed : 1; +} h3q_tls_info; + +/** + * Read the negotiated TLS parameters of a connection. + * @param conn Connection to query; NULL reports failure. + * @param out Filled in on success; untouched otherwise. + * @return 1 when @p out was filled, 0 when the cipher is not yet known. + */ +int h3q_conn_tls_info(h3q_conn* conn, h3q_tls_info* out); + /** * Apply the stream modes, incoming-stream policy and idle timeout a freshly * accepted connection needs before its handshake is driven. diff --git a/mod_http3/src/h3_hooks.c b/mod_http3/src/h3_hooks.c index 9217bfa..4b24ecf 100644 --- a/mod_http3/src/h3_hooks.c +++ b/mod_http3/src/h3_hooks.c @@ -66,8 +66,22 @@ int h3_hook_fixups(request_rec* r) if (IS_H3_REQUEST(r)) { - /* mod_ssl does not manage this connection, so it sets no TLS environment. */ + /* mod_ssl does not manage this connection, so it sets no TLS + * environment; supply what it would under the same names. The values + * were formatted once when the session was created. */ apr_table_setn(r->subprocess_env, "HTTPS", "on"); + + h3_conn_ctx_t* ctx = ap_get_module_config(r->request_config, &http3_module); + const h3_tls_env* tls = (ctx && ctx->stream && ctx->stream->session) ? &ctx->stream->session->tls_env : NULL; + if (tls && tls->protocol) + { + apr_table_setn(r->subprocess_env, "SSL_PROTOCOL", tls->protocol); + apr_table_setn(r->subprocess_env, "SSL_CIPHER", tls->cipher); + apr_table_setn(r->subprocess_env, "SSL_CIPHER_USEKEYSIZE", tls->cipher_usekeysize); + apr_table_setn(r->subprocess_env, "SSL_CIPHER_ALGKEYSIZE", tls->cipher_algkeysize); + apr_table_setn(r->subprocess_env, "SSL_CIPHER_EXPORT", tls->cipher_export); + apr_table_setn(r->subprocess_env, "SSL_SESSION_RESUMED", tls->session_resumed); + } } h3_server_conf* conf = ap_get_module_config(r->server->module_config, &http3_module); diff --git a/mod_http3/src/h3_session.c b/mod_http3/src/h3_session.c index 7c80278..19f8104 100644 --- a/mod_http3/src/h3_session.c +++ b/mod_http3/src/h3_session.c @@ -92,6 +92,21 @@ apr_status_t h3_session_create(h3_session** psession, server_rec* s, h3q_conn* q nghttp3_conn_set_max_concurrent_streams(session->ngh3, conf->h3_max_concurrent_streams); nghttp3_conn_set_max_client_streams_bidi(session->ngh3, conf->h3_max_concurrent_streams); + /* The handshake is complete before a session is created, so the negotiated + * parameters are final and worth formatting once for every request that + * follows rather than per request. */ + h3q_tls_info tls = {0}; + if (h3q_conn_tls_info(qconn, &tls)) + { + session->tls_env.protocol = tls.protocol ? apr_pstrdup(pool, tls.protocol) : NULL; + session->tls_env.cipher = tls.cipher ? apr_pstrdup(pool, tls.cipher) : NULL; + session->tls_env.cipher_usekeysize = apr_psprintf(pool, "%d", tls.cipher_bits); + session->tls_env.cipher_algkeysize = apr_psprintf(pool, "%d", tls.cipher_alg_bits); + /* Only TLS 1.3 is offered, which has no export-grade ciphers. */ + session->tls_env.cipher_export = "false"; + session->tls_env.session_resumed = tls.resumed ? "Resumed" : "Initial"; + } + *psession = session; return APR_SUCCESS; } diff --git a/mod_http3/src/quic/h3q_conn.c b/mod_http3/src/quic/h3q_conn.c index 42c67d0..c2b6289 100644 --- a/mod_http3/src/quic/h3q_conn.c +++ b/mod_http3/src/quic/h3q_conn.c @@ -67,6 +67,27 @@ int h3q_conn_is_handshake_done(h3q_conn* conn) return ssl_conn ? SSL_is_init_finished(ssl_conn) : 0; } +int h3q_conn_tls_info(h3q_conn* conn, h3q_tls_info* out) +{ + SSL* ssl_conn = (SSL*)conn; + if (!ssl_conn || !out) + { + return 0; + } + const SSL_CIPHER* cipher = SSL_get_current_cipher(ssl_conn); + if (!cipher) + { + return 0; + } + int alg_bits = 0; + out->cipher_bits = SSL_CIPHER_get_bits(cipher, &alg_bits); + out->cipher_alg_bits = alg_bits; + out->cipher = SSL_CIPHER_get_name(cipher); + out->protocol = SSL_get_version(ssl_conn); + out->resumed = SSL_session_reused(ssl_conn) ? 1u : 0u; + return 1; +} + int h3q_conn_is_closed(h3q_conn* conn) { SSL* ssl_conn = (SSL*)conn; diff --git a/test/http3/test_023_ssl_env.py b/test/http3/test_023_ssl_env.py new file mode 100644 index 0000000..c3cc010 --- /dev/null +++ b/test/http3/test_023_ssl_env.py @@ -0,0 +1,72 @@ +import os + +import pytest + + +class TestSSLEnv: + """HTTP/3 requests get the mod_ssl SSL_* environment, since mod_ssl does not manage the connection.""" + + @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 + + def _env_over_h3(self, env): + url = env.mkurl("https", "test1", "/cgi/env.py") + r = env.curl_get(url, options=["--http3-only", "-k"]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response is not None + assert r.response["status"] == 200 + assert r.response["protocol"] == "HTTP/3" + assert r.response["json"] is not None, r.response["body"] + return r.response["json"] + + def test_001_https_on(self, env): + assert self._env_over_h3(env)["https"] == "on" + + def test_002_ssl_protocol_is_tls13(self, env): + # Only TLS 1.3 is offered, and QUIC requires it. + assert self._env_over_h3(env)["ssl_protocol"] == "TLSv1.3" + + def test_003_ssl_cipher_is_a_tls13_suite(self, env): + cipher = self._env_over_h3(env)["ssl_cipher"] + assert cipher.startswith("TLS_"), cipher + + def test_004_cipher_key_sizes_are_numbers(self, env): + data = self._env_over_h3(env) + for key in ("ssl_cipher_usekeysize", "ssl_cipher_algkeysize"): + assert data[key].isdigit(), f"{key}={data[key]!r}" + assert int(data[key]) >= 128, f"{key}={data[key]!r}" + + def test_005_cipher_export_is_false(self, env): + # TLS 1.3 has no export-grade ciphers. + assert self._env_over_h3(env)["ssl_cipher_export"] == "false" + + def test_006_session_resumed_reported(self, env): + # mod_ssl spells these exactly "Initial" and "Resumed". + assert self._env_over_h3(env)["ssl_session_resumed"] in ("Initial", "Resumed") + + def test_007_env_is_stable_across_requests_on_a_connection(self, env): + # The values are computed once per connection; every request must still + # see them, not just the first. + url = env.mkurl("https", "test1", "/cgi/env.py") + seen = [] + for _ in range(3): + r = env.curl_get(url, options=["--http3-only", "-k"]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response["status"] == 200 + seen.append((r.response["json"]["ssl_protocol"], r.response["json"]["ssl_cipher"])) + assert all(s == seen[0] for s in seen), seen + assert seen[0][0] == "TLSv1.3" + + def test_008_no_ssl_env_over_http11(self, env): + # A plain HTTP/1.1 request on this vhost is handled by mod_ssl, so the + # module must not be the one answering for it. + url = env.mkurl("https", "test1", "/cgi/env.py") + r = env.curl_get(url, options=["--http1.1", "-k"]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response is not None + assert r.response["status"] == 200 + assert r.response["json"]["server_protocol"] == "HTTP/1.1" diff --git a/test/pyhttpd/htdocs/cgi/env.py b/test/pyhttpd/htdocs/cgi/env.py index 9965f20..59e8cd0 100644 --- a/test/pyhttpd/htdocs/cgi/env.py +++ b/test/pyhttpd/htdocs/cgi/env.py @@ -10,6 +10,12 @@ def main(): "server_protocol": os.environ.get("SERVER_PROTOCOL", ""), "remote_addr": os.environ.get("REMOTE_ADDR", ""), "remote_port": os.environ.get("REMOTE_PORT", ""), + "ssl_protocol": os.environ.get("SSL_PROTOCOL", ""), + "ssl_cipher": os.environ.get("SSL_CIPHER", ""), + "ssl_cipher_usekeysize": os.environ.get("SSL_CIPHER_USEKEYSIZE", ""), + "ssl_cipher_algkeysize": os.environ.get("SSL_CIPHER_ALGKEYSIZE", ""), + "ssl_cipher_export": os.environ.get("SSL_CIPHER_EXPORT", ""), + "ssl_session_resumed": os.environ.get("SSL_SESSION_RESUMED", ""), } print("Content-Type: application/json") From 02bd12ded63d957a0d7237ceae4e4763cf02b5ce Mon Sep 17 00:00:00 2001 From: Alexander Gerasimov Date: Fri, 7 Aug 2026 18:57:48 +0300 Subject: [PATCH 07/11] Apply the core request header limits to HTTP/3 httpd enforces LimitRequestFields and LimitRequestFieldSize while it parses an HTTP/1 message. HTTP/3 fields arrive here already decoded, so nothing applied either limit: the module advertised nghttp3's default SETTINGS_MAX_FIELD_SECTION_SIZE of (1<<62)-1, and on_recv_header added every field to the stream table with no cap on count or size. A client could grow a stream pool until the child ran out of memory, and the same request over HTTP/1.1 or HTTP/2 was rejected while HTTP/3 accepted it. Count fields and measure each one the way httpd does -- a field sized as "name: value", 0 still meaning unlimited -- and stop storing once either limit is passed, so refusing costs no further memory. The request is answered 431 from the access checker, beside the existing 413 and 400 cases, which keeps it a per-request answer rather than a connection error. Also derive SETTINGS_MAX_FIELD_SECTION_SIZE from the two limits, sizing a field as RFC 9114 4.2.2 does (name + value + 32), so a conforming client stops before it reaches the limit instead of having its request rejected after the fact. --- CHANGES | 9 ++++ mod_http3/include/h3_session.h | 5 ++ mod_http3/src/h3_callbacks.c | 29 ++++++++++- mod_http3/src/h3_hooks.c | 4 ++ mod_http3/src/h3_session.c | 12 +++++ test/http3/test_024_header_limits.py | 78 ++++++++++++++++++++++++++++ 6 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 test/http3/test_024_header_limits.py diff --git a/CHANGES b/CHANGES index d6468be..f3405f9 100644 --- a/CHANGES +++ b/CHANGES @@ -5,6 +5,15 @@ Security-related entries always appear at the top of their release block. v0.0.60 (unreleased) -------------------- + *) Applied the core LimitRequestFields and LimitRequestFieldSize limits to + HTTP/3 requests, and advertised the resulting bound as the HTTP/3 + SETTINGS_MAX_FIELD_SECTION_SIZE. httpd enforces these while parsing an + HTTP/1 message, so nothing applied them to fields that arrive already + decoded: the server advertised nghttp3's default of (1<<62)-1 and header + fields accumulated in the stream pool without any bound. An over-limit + request is now answered with 431 and the connection keeps serving. + [Alexander Gerasimov ] + *) Published the mod_ssl TLS environment for HTTP/3 requests: SSL_PROTOCOL, SSL_CIPHER, SSL_CIPHER_USEKEYSIZE, SSL_CIPHER_ALGKEYSIZE, SSL_CIPHER_EXPORT and SSL_SESSION_RESUMED, under the names mod_ssl uses. diff --git a/mod_http3/include/h3_session.h b/mod_http3/include/h3_session.h index 1f0b76d..50e3c30 100644 --- a/mod_http3/include/h3_session.h +++ b/mod_http3/include/h3_session.h @@ -125,6 +125,11 @@ struct h3_stream size_t request_body_capacity; int request_body_overflow; + /// Regular header fields accepted so far, against LimitRequestFields. + int header_count; + /// Set once the field count or one field's size exceeds the core limits. + int headers_too_large; + const char* method; const char* scheme; const char* authority; diff --git a/mod_http3/src/h3_callbacks.c b/mod_http3/src/h3_callbacks.c index 66596d8..f896756 100644 --- a/mod_http3/src/h3_callbacks.c +++ b/mod_http3/src/h3_callbacks.c @@ -110,11 +110,36 @@ int on_recv_header(nghttp3_conn* conn H3_UNUSED, int64_t stream_id H3_UNUSED, in { return set_pseudo(stream, session, token, &nv); } - if (!stream->headers) + if (!stream->headers || stream->headers_too_large) { return 0; } - apr_table_addn(stream->headers, apr_pstrndup(stream->pool, (const char*)nghttp3_rcbuf_get_buf(name).base, nghttp3_rcbuf_get_buf(name).len), apr_pstrndup(stream->pool, (const char*)nv.base, nv.len)); + + /* mod_ssl aside, nothing else applies the core request limits to an HTTP/3 + * request: httpd enforces them while parsing an HTTP/1 message, and the + * fields arrive here already decoded. Apply them with the same meaning -- + * LimitRequestFields counts fields, LimitRequestFieldSize bounds one field + * -- and stop storing once either is exceeded, so a client cannot grow the + * stream pool by continuing to send. h3_hook_access_checker turns the flag + * into a 431 before the request reaches a handler. A value of 0 means + * unlimited, as it does in httpd. */ + nghttp3_vec nk = nghttp3_rcbuf_get_buf(name); + const server_rec* s = session->s; + if (s->limit_req_fields > 0 && ++stream->header_count > s->limit_req_fields) + { + ap_log_error(APLOG_MARK, APLOG_INFO, 0, session->s, "HTTP/3 stream %" APR_INT64_T_FMT ": more than LimitRequestFields (%d) header fields; rejecting with 431", stream->stream_id, s->limit_req_fields); + stream->headers_too_large = 1; + return 0; + } + /* Sized as httpd sizes an HTTP/1 field line, "name: value". */ + if (s->limit_req_fieldsize > 0 && nk.len + nv.len + 2 > (size_t)s->limit_req_fieldsize) + { + ap_log_error(APLOG_MARK, APLOG_INFO, 0, session->s, "HTTP/3 stream %" APR_INT64_T_FMT ": header field '%.*s' exceeds LimitRequestFieldSize (%d); rejecting with 431", stream->stream_id, (int)(nk.len > 32 ? 32 : nk.len), (const char*)nk.base, s->limit_req_fieldsize); + stream->headers_too_large = 1; + return 0; + } + + apr_table_addn(stream->headers, apr_pstrndup(stream->pool, (const char*)nk.base, nk.len), apr_pstrndup(stream->pool, (const char*)nv.base, nv.len)); return 0; } diff --git a/mod_http3/src/h3_hooks.c b/mod_http3/src/h3_hooks.c index 4b24ecf..5896186 100644 --- a/mod_http3/src/h3_hooks.c +++ b/mod_http3/src/h3_hooks.c @@ -133,6 +133,10 @@ int h3_hook_access_checker(request_rec* r) /* Reject unprocessable bodies early. */ h3_conn_ctx_t* ctx = ap_get_module_config(r->request_config, &http3_module); h3_stream* stream = ctx ? ctx->stream : NULL; + if (stream && stream->headers_too_large) + { + return HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE; + } if (stream && stream->request_body_overflow) { return HTTP_REQUEST_ENTITY_TOO_LARGE; diff --git a/mod_http3/src/h3_session.c b/mod_http3/src/h3_session.c index 19f8104..ffa78f3 100644 --- a/mod_http3/src/h3_session.c +++ b/mod_http3/src/h3_session.c @@ -82,6 +82,18 @@ apr_status_t h3_session_create(h3_session** psession, server_rec* s, h3q_conn* q 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_settings settings = {0}; nghttp3_settings_default(&settings); + + /* nghttp3 defaults max_field_section_size to (1<<62)-1, so without this the + * server advertises no bound on request header size at all and a client is + * entitled to send an arbitrarily large field section. Advertise what the + * core limits already permit -- RFC 9114 4.2.2 sizes a field as + * name + value + 32 -- so a conforming client stops before it gets there + * and nghttp3 rejects one that does not. Either limit set to 0 means + * unlimited in httpd, and then the nghttp3 default stands. */ + if (s->limit_req_fields > 0 && s->limit_req_fieldsize > 0) + { + settings.max_field_section_size = (uint64_t)s->limit_req_fields * ((uint64_t)s->limit_req_fieldsize + 32); + } if (nghttp3_conn_server_new(&session->ngh3, &cb, &settings, nghttp3_mem_default(), session) != 0) { ap_log_error(APLOG_MARK, APLOG_ERR, 0, s, "nghttp3_conn_server_new failed"); diff --git a/test/http3/test_024_header_limits.py b/test/http3/test_024_header_limits.py new file mode 100644 index 0000000..598ac10 --- /dev/null +++ b/test/http3/test_024_header_limits.py @@ -0,0 +1,78 @@ +import pytest + + +class TestHeaderLimits: + """The core LimitRequest* directives must bound HTTP/3 request headers too.""" + + @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 + + def _get(self, env, headers): + url = env.mkurl("https", "test1", "/") + options = ["--http3-only", "-k"] + for name, value in headers: + options += ["-H", f"{name}: {value}"] + return env.curl_get(url, options=options) + + def test_001_normal_headers_pass(self, env): + r = self._get(env, [("X-Small", "value")]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response is not None + assert r.response["status"] == 200 + assert r.response["protocol"] == "HTTP/3" + + def test_002_field_over_limitrequestfieldsize_gets_431(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(extra_lines=["LimitRequestFieldSize 1024"]).install() + assert env.apache_restart() == 0 + r = self._get(env, [("X-Big", "z" * 4096)]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response is not None + assert r.response["status"] == 431, r.response["status"] + + def test_003_field_under_limit_still_passes(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(extra_lines=["LimitRequestFieldSize 1024"]).install() + assert env.apache_restart() == 0 + r = self._get(env, [("X-Ok", "z" * 512)]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response["status"] == 200 + + def test_004_too_many_fields_gets_431(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(extra_lines=["LimitRequestFields 20"]).install() + assert env.apache_restart() == 0 + r = self._get(env, [(f"X-H{i}", "v") for i in range(40)]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response is not None + assert r.response["status"] == 431, r.response["status"] + + def test_005_field_count_under_limit_passes(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(extra_lines=["LimitRequestFields 40"]).install() + assert env.apache_restart() == 0 + r = self._get(env, [(f"X-H{i}", "v") for i in range(10)]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response["status"] == 200 + + def test_006_connection_survives_a_rejected_request(self, env): + # A 431 is a per-request answer, so the connection must keep serving. + from .env import H3Conf + + H3Conf(env).add_vhost_test1(extra_lines=["LimitRequestFieldSize 1024"]).install() + assert env.apache_restart() == 0 + + bad = self._get(env, [("X-Big", "z" * 4096)]) + assert bad.response["status"] == 431 + good = self._get(env, [("X-Small", "v")]) + assert good.exit_code == 0, good.stderr + good.stdout + assert good.response["status"] == 200 + assert good.response["protocol"] == "HTTP/3" From ea95a39ccaa7f7172f5463e355c8d29454e6cb47 Mon Sep 17 00:00:00 2001 From: Alexander Gerasimov Date: Fri, 7 Aug 2026 19:01:55 +0300 Subject: [PATCH 08/11] Bound a stalled response and an error-spamming client Two ways a client could tie up a child process indefinitely. A client that opened a stream and then stopped reading left its worker thread parked in h3_stream_response_append: the wait had no deadline, so it ended only when the stream was cancelled or the session aborted, and neither happens on its own. The transport stays alive on keepalive PINGs and the idle reaper deliberately skips a session that still has a task running, so with the pool capped at 64 threads, 64 such clients stalled HTTP/3 for that child. Add H3StreamTimeout, defaulting to the server's Timeout the way mod_http2's H2StreamTimeout does, and measure it per chunk of progress rather than over the whole response, so a slow but advancing transfer is untouched while a stalled one is abandoned. Separately, rejecting a malformed request as a stream error -- correct per RFC 9114 4.1.2, and what keeps the connection serving its other streams -- also meant a client could send malformed requests forever, at no cost to itself. Count them per connection and close it past H3MaxStreamErrors (default 8, matching H2MaxStreamErrors) with H3_EXCESSIVE_LOAD. --- CHANGES | 17 +++ mod_http3/include/h3.h | 7 ++ mod_http3/include/h3_config.h | 3 + mod_http3/include/h3_session.h | 5 + mod_http3/src/h3_config.c | 62 ++++++++++ mod_http3/src/h3_session.c | 17 +++ mod_http3/src/h3_stream.c | 13 ++ test/http3/env.py | 6 + test/http3/test_025_stream_limits.py | 172 +++++++++++++++++++++++++++ 9 files changed, 302 insertions(+) create mode 100644 test/http3/test_025_stream_limits.py diff --git a/CHANGES b/CHANGES index f3405f9..8ab31d4 100644 --- a/CHANGES +++ b/CHANGES @@ -5,6 +5,23 @@ Security-related entries always appear at the top of their release block. v0.0.60 (unreleased) -------------------- + *) Added H3StreamTimeout, bounding how long a response may make no progress. + A client that opened a stream and then stopped reading left its worker + thread blocked on the response queue with nothing to release it: the + transport stays alive on keepalives and the idle reaper skips a session + that still has a task running, so a handful of such clients could occupy + every worker in the pool. Defaults to the server's Timeout, as + mod_http2's H2StreamTimeout does, and the window is measured per chunk of + progress so a slow but advancing transfer is unaffected. + [Alexander Gerasimov ] + + *) Added H3MaxStreamErrors, closing a connection whose client has caused more + than that many stream errors (default 8). Answering a malformed request + per stream keeps the connection serving, which also let a client send + malformed requests indefinitely; the connection is now closed with + H3_EXCESSIVE_LOAD. + [Alexander Gerasimov ] + *) Applied the core LimitRequestFields and LimitRequestFieldSize limits to HTTP/3 requests, and advertised the resulting bound as the HTTP/3 SETTINGS_MAX_FIELD_SECTION_SIZE. httpd enforces these while parsing an diff --git a/mod_http3/include/h3.h b/mod_http3/include/h3.h index 6dd319c..b022109 100644 --- a/mod_http3/include/h3.h +++ b/mod_http3/include/h3.h @@ -48,6 +48,13 @@ #define H3_SOCKET_BUFFER_SIZE_DEFAULT (2 * 1024 * 1024) #define H3_SOCKET_BUFFER_SIZE_MAX (64UL * 1024 * 1024) +/* 0 means "inherit the server's Timeout", as mod_http2's H2StreamTimeout does. */ +#define H3_STREAM_TIMEOUT_MAX 86400 + +/* Client-caused stream errors tolerated before the connection is closed. */ +#define H3_MAX_STREAM_ERRORS_DEFAULT 8 +#define H3_MAX_STREAM_ERRORS_MAX 10000 + #define H3_HANDSHAKE_TIMEOUT_DEFAULT 10 #define H3_HANDSHAKE_TIMEOUT_MAX 600 #define H3_IDLE_TIMEOUT_DEFAULT 300 diff --git a/mod_http3/include/h3_config.h b/mod_http3/include/h3_config.h index 8533b3c..f3bd32a 100644 --- a/mod_http3/include/h3_config.h +++ b/mod_http3/include/h3_config.h @@ -52,6 +52,9 @@ struct h3_server_conf apr_uint32_t h3_handshake_timeout; apr_uint32_t h3_idle_timeout; apr_size_t h3_socket_buffer_size; + /// Seconds a response may make no progress; 0 inherits the server Timeout. + apr_uint32_t h3_stream_timeout; + apr_uint32_t h3_max_stream_errors; h3_tri_flag h3_session_tickets; h3_tri_flag h3_early_data; }; diff --git a/mod_http3/include/h3_session.h b/mod_http3/include/h3_session.h index 50e3c30..b8a3b44 100644 --- a/mod_http3/include/h3_session.h +++ b/mod_http3/include/h3_session.h @@ -83,6 +83,11 @@ struct h3_session int control_streams_created; apr_time_t goaway_deadline; + /// Client-caused stream errors on this connection, against + /// H3MaxStreamErrors. A client that keeps sending malformed requests is + /// answered per stream, so without this it could do so indefinitely. + apr_uint32_t stream_errors; + /// Time of the last application-level progress (stream opened, request /// data read, request dispatched, response data acknowledged). Drives the /// module's own idle enforcement: transport-level idle timeouts never diff --git a/mod_http3/src/h3_config.c b/mod_http3/src/h3_config.c index 58a5f05..ad76d5d 100644 --- a/mod_http3/src/h3_config.c +++ b/mod_http3/src/h3_config.c @@ -73,6 +73,8 @@ void* h3_merge_server_config(apr_pool_t* p, void* base_conf, void* new_conf) 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; merged->h3_socket_buffer_size = new->h3_socket_buffer_size ? new->h3_socket_buffer_size : base->h3_socket_buffer_size; + merged->h3_stream_timeout = new->h3_stream_timeout ? new->h3_stream_timeout : base->h3_stream_timeout; + merged->h3_max_stream_errors = new->h3_max_stream_errors ? new->h3_max_stream_errors : base->h3_max_stream_errors; merged->h3_session_tickets = new->h3_session_tickets != H3_FLAG_UNSET ? new->h3_session_tickets : base->h3_session_tickets; merged->h3_early_data = new->h3_early_data != H3_FLAG_UNSET ? new->h3_early_data : base->h3_early_data; @@ -238,6 +240,58 @@ static const char* set_h3_socket_buffer_size(cmd_parms* cmd, void* dummy H3_UNUS return NULL; } +static const char* set_h3_stream_timeout(cmd_parms* cmd, void* dummy H3_UNUSED, const char* arg) +{ + if (!arg || !*arg) + { + return "H3StreamTimeout: empty value"; + } + apr_uint64_t val = 0; + apr_status_t rv = apr_cstr_atoui64(&val, arg); + if (rv == APR_EINVAL) + { + return apr_psprintf(cmd->pool, "H3StreamTimeout: '%s' is not a number", arg); + } + if (rv == APR_ERANGE) + { + return apr_psprintf(cmd->pool, "H3StreamTimeout: '%s' is out of representable range", arg); + } + if (val == 0 || val > H3_STREAM_TIMEOUT_MAX) + { + return apr_psprintf(cmd->pool, "H3StreamTimeout: '%s' is out of allowed range (1-%u)", arg, (unsigned)H3_STREAM_TIMEOUT_MAX); + } + h3_server_conf* conf = ap_get_module_config(cmd->server->module_config, &http3_module); + CHECK(conf); + conf->h3_stream_timeout = (apr_uint32_t)val; + return NULL; +} + +static const char* set_h3_max_stream_errors(cmd_parms* cmd, void* dummy H3_UNUSED, const char* arg) +{ + if (!arg || !*arg) + { + return "H3MaxStreamErrors: empty value"; + } + apr_uint64_t val = 0; + apr_status_t rv = apr_cstr_atoui64(&val, arg); + if (rv == APR_EINVAL) + { + return apr_psprintf(cmd->pool, "H3MaxStreamErrors: '%s' is not a number", arg); + } + if (rv == APR_ERANGE) + { + return apr_psprintf(cmd->pool, "H3MaxStreamErrors: '%s' is out of representable range", arg); + } + if (val == 0 || val > H3_MAX_STREAM_ERRORS_MAX) + { + return apr_psprintf(cmd->pool, "H3MaxStreamErrors: '%s' is out of allowed range (1-%u)", arg, (unsigned)H3_MAX_STREAM_ERRORS_MAX); + } + h3_server_conf* conf = ap_get_module_config(cmd->server->module_config, &http3_module); + CHECK(conf); + conf->h3_max_stream_errors = (apr_uint32_t)val; + return NULL; +} + static const char* set_h3_max_request_body_size(cmd_parms* cmd, void* dummy H3_UNUSED, const char* arg) { if (!arg || !*arg) @@ -437,6 +491,12 @@ int h3_post_config(apr_pool_t* p H3_UNUSED, apr_pool_t* plog H3_UNUSED, apr_pool { vc->h3_socket_buffer_size = H3_SOCKET_BUFFER_SIZE_DEFAULT; } + if (vc->h3_max_stream_errors == 0) + { + vc->h3_max_stream_errors = H3_MAX_STREAM_ERRORS_DEFAULT; + } + /* h3_stream_timeout is deliberately left at 0, which means "use the + * server's Timeout" where it is read. */ if (vc->h3_max_request_body_size == 0) { vc->h3_max_request_body_size = H3_MAX_REQUEST_BODY_SIZE_DEFAULT; @@ -537,5 +597,7 @@ const command_rec h3_cmds[] = { AP_INIT_TAKE1("H3SocketBufferSize", set_h3_socket_buffer_size, NULL, RSRC_CONF, "Bytes requested for the QUIC socket send and receive buffers; the OS may grant less (default: 2097152)"), AP_INIT_FLAG("H3SessionTickets", set_h3_session_tickets, NULL, RSRC_CONF, "Whether to issue TLS session tickets so returning clients can resume instead of running a full handshake (default: on)"), AP_INIT_FLAG("H3EarlyData", set_h3_early_data, NULL, RSRC_CONF, "Whether to accept 0-RTT data on resumed connections; the OpenSSL QUIC stack cannot, so this currently only warns (default: off)"), + AP_INIT_TAKE1("H3StreamTimeout", set_h3_stream_timeout, NULL, RSRC_CONF, "Seconds a response may make no progress before the stream is aborted (default: the server Timeout)"), + AP_INIT_TAKE1("H3MaxStreamErrors", set_h3_max_stream_errors, NULL, RSRC_CONF, "Stream errors one connection may cause before it is closed with H3_EXCESSIVE_LOAD (default: 8)"), AP_INIT_TAKE1(NULL, NULL, NULL, RSRC_CONF, NULL), }; diff --git a/mod_http3/src/h3_session.c b/mod_http3/src/h3_session.c index ffa78f3..aed2b51 100644 --- a/mod_http3/src/h3_session.c +++ b/mod_http3/src/h3_session.c @@ -228,10 +228,20 @@ apr_status_t h3_stream_response_append(h3_stream* stream, const uint8_t* data, s return APR_EINVAL; } h3_session* session = stream->session; + /* A client that opens a stream and then stops reading leaves this worker + * blocked on the queue with nothing to abort it: the transport stays alive + * on keepalives and the idle reaper skips sessions that still have a task + * running. Bound the wait so a handful of such clients cannot occupy every + * worker in the pool. */ + h3_server_conf* conf = ap_get_module_config(session->s->module_config, &http3_module); + apr_interval_time_t stall_timeout = (conf && conf->h3_stream_timeout) ? apr_time_from_sec(conf->h3_stream_timeout) : session->s->timeout; size_t offset = 0; while (offset < len) { apr_thread_mutex_lock(session->lock); + /* Reset per chunk, so this bounds time without progress rather than the + * total time a large response is allowed to take. */ + apr_time_t stall_deadline = apr_time_now() + stall_timeout; while (stream->response_buffered >= stream->response_buffer_limit && !stream->response_cancelled && !session->aborted && !session->ngh3_dead) { apr_status_t rv = apr_thread_cond_timedwait(stream->response_cond, session->lock, apr_time_from_msec(100)); @@ -240,6 +250,13 @@ apr_status_t h3_stream_response_append(h3_stream* stream, const uint8_t* data, s apr_thread_mutex_unlock(session->lock); return rv; } + if (stall_timeout > 0 && apr_time_now() >= stall_deadline) + { + ap_log_error(APLOG_MARK, APLOG_INFO, 0, session->s, "HTTP/3 stream %" APR_INT64_T_FMT " made no progress for %" APR_TIME_T_FMT " seconds; abandoning the response", stream->stream_id, apr_time_sec(stall_timeout)); + h3_stream_response_cancel_locked(stream); + apr_thread_mutex_unlock(session->lock); + return APR_TIMEUP; + } } if (stream->response_cancelled || session->aborted || session->ngh3_dead || !session->ngh3) { diff --git a/mod_http3/src/h3_stream.c b/mod_http3/src/h3_stream.c index ff822cb..59da500 100644 --- a/mod_http3/src/h3_stream.c +++ b/mod_http3/src/h3_stream.c @@ -217,6 +217,19 @@ static void reject_malformed_stream(h3_session* session, h3_stream* h3s, nghttp3 nghttp3_conn_close_stream(session->ngh3, h3s->stream_id, app_error_code); h3s->done = 1; h3s->body_complete = 1; + + /* Rejecting per stream keeps the connection serving, which also means a + * client can go on sending malformed requests for as long as it likes. + * Stop answering one that makes a habit of it. */ + h3_server_conf* conf = ap_get_module_config(session->s->module_config, &http3_module); + apr_uint32_t limit = conf ? conf->h3_max_stream_errors : H3_MAX_STREAM_ERRORS_DEFAULT; + if (++session->stream_errors > limit) + { + ap_log_error(APLOG_MARK, APLOG_WARNING, 0, session->s, "closing HTTP/3 connection after %u client-caused stream errors (H3MaxStreamErrors %u)", (unsigned)session->stream_errors, (unsigned)limit); + session->abort_quic_error_code = NGHTTP3_H3_EXCESSIVE_LOAD; + session->abort_reason = "too many malformed requests"; + session->ngh3_dead = 1; + } } static void feed_stream_fin(h3_session* session, h3_stream* h3s) diff --git a/test/http3/env.py b/test/http3/env.py index 10ff868..7b2e641 100644 --- a/test/http3/env.py +++ b/test/http3/env.py @@ -89,6 +89,8 @@ def add_vhost_test1( h3_idle_timeout=None, h3_address_validation=None, h3_socket_buffer_size=None, + h3_stream_timeout=None, + h3_max_stream_errors=None, h3_session_tickets=None, h3_early_data=None, extra_lines=None @@ -129,6 +131,10 @@ def add_vhost_test1( self.add(f"H3AddressValidation {val}") if h3_socket_buffer_size is not None: self.add(f"H3SocketBufferSize {h3_socket_buffer_size}") + if h3_stream_timeout is not None: + self.add(f"H3StreamTimeout {h3_stream_timeout}") + if h3_max_stream_errors is not None: + self.add(f"H3MaxStreamErrors {h3_max_stream_errors}") if h3_session_tickets is not None: val = "on" if h3_session_tickets is True else ("off" if h3_session_tickets is False else h3_session_tickets) self.add(f"H3SessionTickets {val}") diff --git a/test/http3/test_025_stream_limits.py b/test/http3/test_025_stream_limits.py new file mode 100644 index 0000000..a193aa7 --- /dev/null +++ b/test/http3/test_025_stream_limits.py @@ -0,0 +1,172 @@ +import asyncio +import os +import re +import ssl + +import pytest + +from aioquic.asyncio.client import connect +from aioquic.h3.connection import H3_ALPN +from aioquic.quic import events as quic_events +from aioquic.quic.configuration import QuicConfiguration + +from .test_020_malformed import _RawRequestClient + +# RFC 9114 section 8.1. +H3_EXCESSIVE_LOAD = 0x0107 + + +def _read_test_conf(env): + return open(os.path.join(env.server_dir, "conf", "test.conf")).read() + + +class _TerminationWatchingClient(_RawRequestClient): + """Raw client that also records the connection being closed by the server.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.terminated = None + self.terminated_event = asyncio.Event() + + def quic_event_received(self, event): + if isinstance(event, quic_events.ConnectionTerminated): + self.terminated = event + self.terminated_event.set() + return + super().quic_event_received(event) + + +class TestStreamLimits: + """H3StreamTimeout bounds a stalled response; H3MaxStreamErrors bounds an abusive client.""" + + @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 + + def test_001_defaults_serve_normally(self, env): + url = env.mkurl("https", "test1", "/") + r = env.curl_get(url, options=["--http3-only", "-k"]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response is not None + assert r.response["status"] == 200 + assert r.response["protocol"] == "HTTP/3" + + def test_002_stream_timeout_in_vhost(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_stream_timeout=30).install() + assert env.apache_restart() == 0 + assert "H3StreamTimeout 30" in _read_test_conf(env) + + def test_003_stream_timeout_does_not_break_normal_responses(self, env): + # A response that keeps making progress must never hit the timeout. + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_stream_timeout=30).install() + assert env.apache_restart() == 0 + url = env.mkurl("https", "test1", "/") + r = env.curl_get(url, options=["--http3-only", "-k"]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response["status"] == 200 + + def test_004_large_download_under_timeout(self, env): + # A big body takes many queue round trips; each one resets the no-progress + # window, so a slow-but-progressing transfer must still complete. + from .env import H3Conf + + fpath = os.path.join(env.server_docs_dir, "timeout-big.bin") + with open(fpath, "wb") as fd: + fd.write(b"x" * (2 * 1024 * 1024)) + + H3Conf(env).add_vhost_test1(h3_stream_timeout=10).install() + assert env.apache_restart() == 0 + url = env.mkurl("https", "test1", "/timeout-big.bin") + r = env.curl_get(url, options=["--http3-only", "-k"]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response["status"] == 200 + assert len(r.response["body"]) == 2 * 1024 * 1024 + + def test_005_stream_timeout_invalid_value(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_stream_timeout="invalid").install() + assert env.apache_restart() != 0 + H3Conf(env).add_vhost_test1(h3_stream_timeout=0).install() + assert env.apache_restart() != 0 + H3Conf(env).add_vhost_test1(h3_stream_timeout=86401).install() + assert env.apache_restart() != 0 + + def test_006_max_stream_errors_in_vhost(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_max_stream_errors=3).install() + assert env.apache_restart() == 0 + assert "H3MaxStreamErrors 3" in _read_test_conf(env) + + def test_007_max_stream_errors_invalid_value(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_max_stream_errors="invalid").install() + assert env.apache_restart() != 0 + H3Conf(env).add_vhost_test1(h3_max_stream_errors=0).install() + assert env.apache_restart() != 0 + H3Conf(env).add_vhost_test1(h3_max_stream_errors=10001).install() + assert env.apache_restart() != 0 + + def test_008_repeated_malformed_requests_close_the_connection(self, env): + # Each malformed request is answered as a stream error and the connection + # keeps serving (test_020); past H3MaxStreamErrors it must be closed, so a + # client cannot keep doing it forever. + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_max_stream_errors=2).install() + assert env.apache_restart() == 0 + + authority = f"test1.{env.http_tld}" + # No :method, which RFC 9114 4.1.2 makes malformed. + malformed = [ + (b":scheme", b"https"), + (b":authority", authority.encode()), + (b":path", b"/index.html"), + ] + + 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=_TerminationWatchingClient, + ) as client: + resets = 0 + for _ in range(6): + if client.terminated is not None: + break + client._reset_event.clear() + try: + if await client.send_raw_request(malformed, timeout=5.0) is not None: + resets += 1 + except asyncio.TimeoutError: + break + try: + await asyncio.wait_for(client.terminated_event.wait(), timeout=5.0) + except asyncio.TimeoutError: + pass + return resets, client.terminated + + resets, terminated = asyncio.run(run()) + assert terminated is not None, f"connection still open after {resets} stream errors" + assert terminated.error_code == H3_EXCESSIVE_LOAD, hex(terminated.error_code) + + pattern = re.compile(r".*client-caused stream errors.*") + assert env.httpd_error_log.scan_recent(pattern, timeout=10), ( + "expected a log line naming H3MaxStreamErrors" + ) From 6bc1718ae3568bea917b7b44e391aff00557e3e3 Mon Sep 17 00:00:00 2001 From: Alexander Gerasimov Date: Fri, 7 Aug 2026 19:30:32 +0300 Subject: [PATCH 09/11] Advertise a QPACK dynamic table, and make the worker pool tunable nghttp3 defaults the QPACK decoder capacity to 0, and the module never overrode it, so the server told every client it may not use the dynamic table when encoding request headers. Each request therefore re-sent its cookies and user-agent literally -- measurably worse header compression than the same server already gives over HTTP/2, where HPACK's table is 4096 by default. Advertise 4096 with 16 blocked streams, configurable through H3QpackTableCapacity and H3QpackBlockedStreams; the encoder and decoder streams this needs were already bound. A capacity of 0 is a meaningful setting rather than "unset", so it keeps the old behaviour and the pair tracks whether it was configured separately. The request worker pool was created with 16 threads growing to 64, hardcoded, so a large machine could not use more and a small one could not use fewer. Expose H3MinWorkers, H3MaxWorkers and H3MaxWorkerIdleSeconds -- the last one had no equivalent at all, and idle workers were kept forever. Defaults are the previous fixed values, and a max below min is corrected with a warning rather than being fatal. Finally, ap_find_linked_module("mod_logio.c") walked the whole loaded module list comparing names once per connection and again for every request, to answer a question that cannot change after startup. Resolve it once from post_config. --- CHANGES | 20 ++++ mod_http3/include/h3.h | 18 ++++ mod_http3/include/h3_config.h | 7 ++ mod_http3/include/h3_request.h | 7 ++ mod_http3/src/h3_config.c | 170 +++++++++++++++++++++++++++++++++ mod_http3/src/h3_io.c | 3 +- mod_http3/src/h3_request.c | 21 ++-- mod_http3/src/h3_session.c | 13 ++- test/http3/env.py | 15 +++ test/http3/test_026_tuning.py | 131 +++++++++++++++++++++++++ 10 files changed, 397 insertions(+), 8 deletions(-) create mode 100644 test/http3/test_026_tuning.py diff --git a/CHANGES b/CHANGES index 8ab31d4..cf7fd71 100644 --- a/CHANGES +++ b/CHANGES @@ -31,6 +31,26 @@ v0.0.60 (unreleased) request is now answered with 431 and the connection keeps serving. [Alexander Gerasimov ] + *) Advertised a QPACK dynamic table, with H3QpackTableCapacity (default 4096) + and H3QpackBlockedStreams (default 16). nghttp3 defaults the decoder + capacity to 0, which tells a client it may not use the dynamic table at + all, so every request re-sent its cookies and user-agent literally -- worse + header compression than the same server gives over HTTP/2, whose HPACK + table is 4096 by default. Setting the capacity to 0 keeps the old + behaviour. + [Alexander Gerasimov ] + + *) Added H3MinWorkers, H3MaxWorkers and H3MaxWorkerIdleSeconds. The request + worker pool was fixed at 16 threads growing to 64 with no way to tune it, + so a large machine could not use more and a small one could not use fewer. + The defaults are the previous fixed values, so nothing changes unless + configured. + [Alexander Gerasimov ] + + *) Resolved mod_logio once at post_config instead of walking the loaded module + list, comparing names, on every connection and again on every request. + [Alexander Gerasimov ] + *) Published the mod_ssl TLS environment for HTTP/3 requests: SSL_PROTOCOL, SSL_CIPHER, SSL_CIPHER_USEKEYSIZE, SSL_CIPHER_ALGKEYSIZE, SSL_CIPHER_EXPORT and SSL_SESSION_RESUMED, under the names mod_ssl uses. diff --git a/mod_http3/include/h3.h b/mod_http3/include/h3.h index b022109..b07af46 100644 --- a/mod_http3/include/h3.h +++ b/mod_http3/include/h3.h @@ -54,6 +54,24 @@ /* Client-caused stream errors tolerated before the connection is closed. */ #define H3_MAX_STREAM_ERRORS_DEFAULT 8 #define H3_MAX_STREAM_ERRORS_MAX 10000 +/* + * QPACK decoder capacity we advertise. nghttp3 defaults this to 0, which tells + * a client it may not use the dynamic table at all, so every request re-sends + * its cookies and user-agent literally -- worse than HPACK over HTTP/2, whose + * table is 4096 by default. Blocked streams bound how many requests may wait on + * a table insert that has not arrived yet. + */ +#define H3_QPACK_TABLE_CAPACITY_DEFAULT 4096 +#define H3_QPACK_TABLE_CAPACITY_MAX (1024UL * 1024) +#define H3_QPACK_BLOCKED_STREAMS_DEFAULT 16 +#define H3_QPACK_BLOCKED_STREAMS_MAX 1000 + +/* Request worker threads per child process. */ +#define H3_MIN_WORKERS_DEFAULT 16 +#define H3_MAX_WORKERS_DEFAULT 64 +#define H3_WORKERS_MAX 4096 +#define H3_MAX_WORKER_IDLE_SECONDS_DEFAULT 600 +#define H3_MAX_WORKER_IDLE_SECONDS_MAX 86400 #define H3_HANDSHAKE_TIMEOUT_DEFAULT 10 #define H3_HANDSHAKE_TIMEOUT_MAX 600 diff --git a/mod_http3/include/h3_config.h b/mod_http3/include/h3_config.h index f3bd32a..42b5d43 100644 --- a/mod_http3/include/h3_config.h +++ b/mod_http3/include/h3_config.h @@ -55,6 +55,13 @@ struct h3_server_conf /// Seconds a response may make no progress; 0 inherits the server Timeout. apr_uint32_t h3_stream_timeout; apr_uint32_t h3_max_stream_errors; + /// 0 is a meaningful capacity (no dynamic table), so track "set" separately. + int h3_qpack_configured; + apr_uint32_t h3_qpack_table_capacity; + apr_uint32_t h3_qpack_blocked_streams; + apr_uint32_t h3_min_workers; + apr_uint32_t h3_max_workers; + apr_uint32_t h3_max_worker_idle_seconds; h3_tri_flag h3_session_tickets; h3_tri_flag h3_early_data; }; diff --git a/mod_http3/include/h3_request.h b/mod_http3/include/h3_request.h index 01ba537..47e6976 100644 --- a/mod_http3/include/h3_request.h +++ b/mod_http3/include/h3_request.h @@ -25,6 +25,13 @@ typedef struct h3_conn_ctx_t h3_conn_ctx_t; +/** + * Resolve what the request path needs from other modules, once, at post_config. + * The answers cannot change afterwards, so nothing on the per-request path has + * to look them up again. + */ +void h3_request_init(void); + /** * Build a synthetic conn_rec for a freshly accepted QUIC session. The * returned conn_rec has no underlying socket; it's used as a parent for diff --git a/mod_http3/src/h3_config.c b/mod_http3/src/h3_config.c index ad76d5d..13b169f 100644 --- a/mod_http3/src/h3_config.c +++ b/mod_http3/src/h3_config.c @@ -34,6 +34,7 @@ #include "h3_check.h" #include "h3_config.h" #include "h3_os.h" +#include "h3_request.h" #include "mod_http3.h" apr_port_t get_server_port(const server_rec* s) @@ -75,6 +76,12 @@ void* h3_merge_server_config(apr_pool_t* p, void* base_conf, void* new_conf) merged->h3_socket_buffer_size = new->h3_socket_buffer_size ? new->h3_socket_buffer_size : base->h3_socket_buffer_size; merged->h3_stream_timeout = new->h3_stream_timeout ? new->h3_stream_timeout : base->h3_stream_timeout; merged->h3_max_stream_errors = new->h3_max_stream_errors ? new->h3_max_stream_errors : base->h3_max_stream_errors; + merged->h3_qpack_configured = new->h3_qpack_configured ? new->h3_qpack_configured : base->h3_qpack_configured; + merged->h3_qpack_table_capacity = new->h3_qpack_configured ? new->h3_qpack_table_capacity : base->h3_qpack_table_capacity; + merged->h3_qpack_blocked_streams = new->h3_qpack_configured ? new->h3_qpack_blocked_streams : base->h3_qpack_blocked_streams; + merged->h3_min_workers = new->h3_min_workers ? new->h3_min_workers : base->h3_min_workers; + merged->h3_max_workers = new->h3_max_workers ? new->h3_max_workers : base->h3_max_workers; + merged->h3_max_worker_idle_seconds = new->h3_max_worker_idle_seconds ? new->h3_max_worker_idle_seconds : base->h3_max_worker_idle_seconds; merged->h3_session_tickets = new->h3_session_tickets != H3_FLAG_UNSET ? new->h3_session_tickets : base->h3_session_tickets; merged->h3_early_data = new->h3_early_data != H3_FLAG_UNSET ? new->h3_early_data : base->h3_early_data; @@ -292,6 +299,138 @@ static const char* set_h3_max_stream_errors(cmd_parms* cmd, void* dummy H3_UNUSE return NULL; } +static const char* set_h3_qpack_table_capacity(cmd_parms* cmd, void* dummy H3_UNUSED, const char* arg) +{ + if (!arg || !*arg) + { + return "H3QpackTableCapacity: empty value"; + } + apr_uint64_t val = 0; + apr_status_t rv = apr_cstr_atoui64(&val, arg); + if (rv == APR_EINVAL) + { + return apr_psprintf(cmd->pool, "H3QpackTableCapacity: '%s' is not a number", arg); + } + if (rv == APR_ERANGE) + { + return apr_psprintf(cmd->pool, "H3QpackTableCapacity: '%s' is out of representable range", arg); + } + if (val > H3_QPACK_TABLE_CAPACITY_MAX) + { + return apr_psprintf(cmd->pool, "H3QpackTableCapacity: '%s' is out of allowed range (0-%lu)", arg, (unsigned long)H3_QPACK_TABLE_CAPACITY_MAX); + } + h3_server_conf* conf = ap_get_module_config(cmd->server->module_config, &http3_module); + CHECK(conf); + conf->h3_qpack_table_capacity = (apr_uint32_t)val; + conf->h3_qpack_configured = 1; + return NULL; +} + +static const char* set_h3_qpack_blocked_streams(cmd_parms* cmd, void* dummy H3_UNUSED, const char* arg) +{ + if (!arg || !*arg) + { + return "H3QpackBlockedStreams: empty value"; + } + apr_uint64_t val = 0; + apr_status_t rv = apr_cstr_atoui64(&val, arg); + if (rv == APR_EINVAL) + { + return apr_psprintf(cmd->pool, "H3QpackBlockedStreams: '%s' is not a number", arg); + } + if (rv == APR_ERANGE) + { + return apr_psprintf(cmd->pool, "H3QpackBlockedStreams: '%s' is out of representable range", arg); + } + if (val > H3_QPACK_BLOCKED_STREAMS_MAX) + { + return apr_psprintf(cmd->pool, "H3QpackBlockedStreams: '%s' is out of allowed range (0-%lu)", arg, (unsigned long)H3_QPACK_BLOCKED_STREAMS_MAX); + } + h3_server_conf* conf = ap_get_module_config(cmd->server->module_config, &http3_module); + CHECK(conf); + conf->h3_qpack_blocked_streams = (apr_uint32_t)val; + conf->h3_qpack_configured = 1; + return NULL; +} + +static const char* set_h3_min_workers(cmd_parms* cmd, void* dummy H3_UNUSED, const char* arg) +{ + if (!arg || !*arg) + { + return "H3MinWorkers: empty value"; + } + apr_uint64_t val = 0; + apr_status_t rv = apr_cstr_atoui64(&val, arg); + if (rv == APR_EINVAL) + { + return apr_psprintf(cmd->pool, "H3MinWorkers: '%s' is not a number", arg); + } + if (rv == APR_ERANGE) + { + return apr_psprintf(cmd->pool, "H3MinWorkers: '%s' is out of representable range", arg); + } + if (val == 0 || val > H3_WORKERS_MAX) + { + return apr_psprintf(cmd->pool, "H3MinWorkers: '%s' is out of allowed range (1-%lu)", arg, (unsigned long)H3_WORKERS_MAX); + } + h3_server_conf* conf = ap_get_module_config(cmd->server->module_config, &http3_module); + CHECK(conf); + conf->h3_min_workers = (apr_uint32_t)val; + return NULL; +} + +static const char* set_h3_max_workers(cmd_parms* cmd, void* dummy H3_UNUSED, const char* arg) +{ + if (!arg || !*arg) + { + return "H3MaxWorkers: empty value"; + } + apr_uint64_t val = 0; + apr_status_t rv = apr_cstr_atoui64(&val, arg); + if (rv == APR_EINVAL) + { + return apr_psprintf(cmd->pool, "H3MaxWorkers: '%s' is not a number", arg); + } + if (rv == APR_ERANGE) + { + return apr_psprintf(cmd->pool, "H3MaxWorkers: '%s' is out of representable range", arg); + } + if (val == 0 || val > H3_WORKERS_MAX) + { + return apr_psprintf(cmd->pool, "H3MaxWorkers: '%s' is out of allowed range (1-%lu)", arg, (unsigned long)H3_WORKERS_MAX); + } + h3_server_conf* conf = ap_get_module_config(cmd->server->module_config, &http3_module); + CHECK(conf); + conf->h3_max_workers = (apr_uint32_t)val; + return NULL; +} + +static const char* set_h3_max_worker_idle_seconds(cmd_parms* cmd, void* dummy H3_UNUSED, const char* arg) +{ + if (!arg || !*arg) + { + return "H3MaxWorkerIdleSeconds: empty value"; + } + apr_uint64_t val = 0; + apr_status_t rv = apr_cstr_atoui64(&val, arg); + if (rv == APR_EINVAL) + { + return apr_psprintf(cmd->pool, "H3MaxWorkerIdleSeconds: '%s' is not a number", arg); + } + if (rv == APR_ERANGE) + { + return apr_psprintf(cmd->pool, "H3MaxWorkerIdleSeconds: '%s' is out of representable range", arg); + } + if (val == 0 || val > H3_MAX_WORKER_IDLE_SECONDS_MAX) + { + return apr_psprintf(cmd->pool, "H3MaxWorkerIdleSeconds: '%s' is out of allowed range (1-%lu)", arg, (unsigned long)H3_MAX_WORKER_IDLE_SECONDS_MAX); + } + h3_server_conf* conf = ap_get_module_config(cmd->server->module_config, &http3_module); + CHECK(conf); + conf->h3_max_worker_idle_seconds = (apr_uint32_t)val; + return NULL; +} + static const char* set_h3_max_request_body_size(cmd_parms* cmd, void* dummy H3_UNUSED, const char* arg) { if (!arg || !*arg) @@ -497,6 +636,30 @@ int h3_post_config(apr_pool_t* p H3_UNUSED, apr_pool_t* plog H3_UNUSED, apr_pool } /* h3_stream_timeout is deliberately left at 0, which means "use the * server's Timeout" where it is read. */ + /* 0 is a meaningful QPACK setting -- it disables the dynamic table -- + * so these two use a separate "was it set" flag rather than 0. */ + if (!vc->h3_qpack_configured) + { + vc->h3_qpack_table_capacity = H3_QPACK_TABLE_CAPACITY_DEFAULT; + vc->h3_qpack_blocked_streams = H3_QPACK_BLOCKED_STREAMS_DEFAULT; + } + if (vc->h3_min_workers == 0) + { + vc->h3_min_workers = H3_MIN_WORKERS_DEFAULT; + } + if (vc->h3_max_workers == 0) + { + vc->h3_max_workers = H3_MAX_WORKERS_DEFAULT; + } + if (vc->h3_max_workers < vc->h3_min_workers) + { + ap_log_error(APLOG_MARK, APLOG_WARNING, 0, s, "mod_http3: H3MaxWorkers (%u) is below H3MinWorkers (%u); raising it to match", (unsigned)vc->h3_max_workers, (unsigned)vc->h3_min_workers); + vc->h3_max_workers = vc->h3_min_workers; + } + if (vc->h3_max_worker_idle_seconds == 0) + { + vc->h3_max_worker_idle_seconds = H3_MAX_WORKER_IDLE_SECONDS_DEFAULT; + } if (vc->h3_max_request_body_size == 0) { vc->h3_max_request_body_size = H3_MAX_REQUEST_BODY_SIZE_DEFAULT; @@ -563,6 +726,8 @@ int h3_post_config(apr_pool_t* p H3_UNUSED, apr_pool_t* plog H3_UNUSED, apr_pool } apr_file_close(f); + h3_request_init(); + ap_log_error(APLOG_MARK, APLOG_INFO, 0, s, "h3_post_config: pid=%d cert=%s key=%s h3_port=%d mpm=%s threaded=%d forked=%d max_threads=%d", h3_getpid(), conf->h3_cert_path, conf->h3_key_path, (int)conf->h3_port, ap_show_mpm(), mpm_query(AP_MPMQ_IS_THREADED), mpm_query(AP_MPMQ_IS_FORKED), mpm_query(AP_MPMQ_MAX_THREADS)); return OK; @@ -599,5 +764,10 @@ const command_rec h3_cmds[] = { AP_INIT_FLAG("H3EarlyData", set_h3_early_data, NULL, RSRC_CONF, "Whether to accept 0-RTT data on resumed connections; the OpenSSL QUIC stack cannot, so this currently only warns (default: off)"), AP_INIT_TAKE1("H3StreamTimeout", set_h3_stream_timeout, NULL, RSRC_CONF, "Seconds a response may make no progress before the stream is aborted (default: the server Timeout)"), AP_INIT_TAKE1("H3MaxStreamErrors", set_h3_max_stream_errors, NULL, RSRC_CONF, "Stream errors one connection may cause before it is closed with H3_EXCESSIVE_LOAD (default: 8)"), + AP_INIT_TAKE1("H3QpackTableCapacity", set_h3_qpack_table_capacity, NULL, RSRC_CONF, "QPACK dynamic table capacity in bytes advertised to clients; 0 disables the dynamic table (default: 4096)"), + AP_INIT_TAKE1("H3QpackBlockedStreams", set_h3_qpack_blocked_streams, NULL, RSRC_CONF, "Requests that may wait on a QPACK table insert that has not arrived yet (default: 16)"), + AP_INIT_TAKE1("H3MinWorkers", set_h3_min_workers, NULL, RSRC_CONF, "Request worker threads kept per child process (default: 16)"), + AP_INIT_TAKE1("H3MaxWorkers", set_h3_max_workers, NULL, RSRC_CONF, "Maximum request worker threads per child process (default: 64)"), + AP_INIT_TAKE1("H3MaxWorkerIdleSeconds", set_h3_max_worker_idle_seconds, NULL, RSRC_CONF, "Seconds an idle request worker is kept before it is reaped (default: 600)"), AP_INIT_TAKE1(NULL, NULL, NULL, RSRC_CONF, NULL), }; diff --git a/mod_http3/src/h3_io.c b/mod_http3/src/h3_io.c index e0103ef..d97d658 100644 --- a/mod_http3/src/h3_io.c +++ b/mod_http3/src/h3_io.c @@ -114,11 +114,12 @@ 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, "h3_wakeup_create failed"); return APR_EGENERAL; } - if (apr_thread_pool_create(&io->h3_worker_pool, 16, 64, pchild) != APR_SUCCESS) + if (apr_thread_pool_create(&io->h3_worker_pool, conf->h3_min_workers, conf->h3_max_workers, pchild) != APR_SUCCESS) { ap_log_error(APLOG_MARK, APLOG_ERR, 0, s, "apr_thread_pool_create failed"); return APR_EGENERAL; } + apr_thread_pool_idle_wait_set(io->h3_worker_pool, apr_time_from_sec(conf->h3_max_worker_idle_seconds)); char qerr[H3Q_ERRLEN] = {0}; h3q_config qcfg = { diff --git a/mod_http3/src/h3_request.c b/mod_http3/src/h3_request.c index ef34399..3e61584 100644 --- a/mod_http3/src/h3_request.c +++ b/mod_http3/src/h3_request.c @@ -48,6 +48,17 @@ static volatile apr_uint32_t h3_conn_id_seq = 0; +/* ap_find_linked_module walks every loaded module comparing names, and this was + * called once per connection and again for every request. The answer cannot + * change after startup, so resolve it once. */ +static module* h3_logio_module = NULL; + +void h3_request_init(void) +{ + h3_logio_module = ap_find_linked_module("mod_logio.c"); +} + + /** * 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 @@ -143,10 +154,9 @@ conn_rec* h3_synth_conn(h3_session* session) apr_table_setn(c->notes, "ssl-bypass", "1"); ap_update_vhost_given_ip(c); - module* logio = ap_find_linked_module("mod_logio.c"); - if (logio) + if (h3_logio_module) { - ap_set_module_config(c->conn_config, logio, apr_pcalloc(cpool, sizeof(h3_logio_config_t))); + ap_set_module_config(c->conn_config, h3_logio_module, apr_pcalloc(cpool, sizeof(h3_logio_config_t))); } session->c = c; @@ -464,10 +474,9 @@ void h3_process_request(h3_session* session, h3_stream* h3s) c->conn_config = ap_create_conn_config(cpool); c->bucket_alloc = apr_bucket_alloc_create(cpool); - module* logio = ap_find_linked_module("mod_logio.c"); - if (logio) + if (h3_logio_module) { - ap_set_module_config(c->conn_config, logio, apr_pcalloc(cpool, sizeof(h3_logio_config_t))); + ap_set_module_config(c->conn_config, h3_logio_module, apr_pcalloc(cpool, sizeof(h3_logio_config_t))); } h3_stream_task* task = apr_pcalloc(cpool, sizeof(*task)); diff --git a/mod_http3/src/h3_session.c b/mod_http3/src/h3_session.c index aed2b51..3474e4c 100644 --- a/mod_http3/src/h3_session.c +++ b/mod_http3/src/h3_session.c @@ -80,9 +80,21 @@ apr_status_t h3_session_create(h3_session** psession, server_rec* s, h3q_conn* q } 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}; + h3_server_conf* conf = ap_get_module_config(s->module_config, &http3_module); nghttp3_settings settings = {0}; nghttp3_settings_default(&settings); + /* nghttp3 defaults the decoder capacity to 0, which tells the client it may + * not use the QPACK dynamic table at all: every request then re-sends its + * cookies and user-agent literally, which is worse than HPACK gives the same + * server over HTTP/2. The encoder streams this needs are already bound in + * h3_session_create_control_streams. */ + if (conf) + { + settings.qpack_max_dtable_capacity = conf->h3_qpack_table_capacity; + settings.qpack_blocked_streams = conf->h3_qpack_blocked_streams; + } + /* nghttp3 defaults max_field_section_size to (1<<62)-1, so without this the * server advertises no bound on request header size at all and a client is * entitled to send an arbitrarily large field section. Advertise what the @@ -100,7 +112,6 @@ apr_status_t h3_session_create(h3_session** psession, server_rec* s, h3q_conn* q return APR_EGENERAL; } - h3_server_conf* conf = ap_get_module_config(s->module_config, &http3_module); nghttp3_conn_set_max_concurrent_streams(session->ngh3, conf->h3_max_concurrent_streams); nghttp3_conn_set_max_client_streams_bidi(session->ngh3, conf->h3_max_concurrent_streams); diff --git a/test/http3/env.py b/test/http3/env.py index 7b2e641..42d1686 100644 --- a/test/http3/env.py +++ b/test/http3/env.py @@ -91,6 +91,11 @@ def add_vhost_test1( h3_socket_buffer_size=None, h3_stream_timeout=None, h3_max_stream_errors=None, + h3_qpack_table_capacity=None, + h3_qpack_blocked_streams=None, + h3_min_workers=None, + h3_max_workers=None, + h3_max_worker_idle_seconds=None, h3_session_tickets=None, h3_early_data=None, extra_lines=None @@ -135,6 +140,16 @@ def add_vhost_test1( self.add(f"H3StreamTimeout {h3_stream_timeout}") if h3_max_stream_errors is not None: self.add(f"H3MaxStreamErrors {h3_max_stream_errors}") + if h3_qpack_table_capacity is not None: + self.add(f"H3QpackTableCapacity {h3_qpack_table_capacity}") + if h3_qpack_blocked_streams is not None: + self.add(f"H3QpackBlockedStreams {h3_qpack_blocked_streams}") + if h3_min_workers is not None: + self.add(f"H3MinWorkers {h3_min_workers}") + if h3_max_workers is not None: + self.add(f"H3MaxWorkers {h3_max_workers}") + if h3_max_worker_idle_seconds is not None: + self.add(f"H3MaxWorkerIdleSeconds {h3_max_worker_idle_seconds}") if h3_session_tickets is not None: val = "on" if h3_session_tickets is True else ("off" if h3_session_tickets is False else h3_session_tickets) self.add(f"H3SessionTickets {val}") diff --git a/test/http3/test_026_tuning.py b/test/http3/test_026_tuning.py new file mode 100644 index 0000000..bdf53d7 --- /dev/null +++ b/test/http3/test_026_tuning.py @@ -0,0 +1,131 @@ +import os + +import pytest + + +def _read_test_conf(env): + return open(os.path.join(env.server_dir, "conf", "test.conf")).read() + + +class TestQpackAndWorkers: + """QPACK dynamic table and worker pool sizing are configurable and serve correctly.""" + + @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 + + def _get(self, env, path="/"): + url = env.mkurl("https", "test1", path) + return env.curl_get(url, options=["--http3-only", "-k"]) + + def test_001_defaults_serve(self, env): + r = self._get(env) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response is not None + assert r.response["status"] == 200 + assert r.response["protocol"] == "HTTP/3" + + def test_002_qpack_table_in_vhost(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_qpack_table_capacity=65536, h3_qpack_blocked_streams=32).install() + assert env.apache_restart() == 0 + conf = _read_test_conf(env) + assert "H3QpackTableCapacity 65536" in conf + assert "H3QpackBlockedStreams 32" in conf + + def test_003_larger_qpack_table_serves(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_qpack_table_capacity=65536, h3_qpack_blocked_streams=32).install() + assert env.apache_restart() == 0 + r = self._get(env) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response["status"] == 200 + + def test_004_qpack_table_can_be_disabled(self, env): + # 0 is meaningful: it tells the client not to use the dynamic table. + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_qpack_table_capacity=0, h3_qpack_blocked_streams=0).install() + assert env.apache_restart() == 0 + assert "H3QpackTableCapacity 0" in _read_test_conf(env) + r = self._get(env) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response["status"] == 200 + + def test_005_repeated_requests_with_dynamic_table(self, env): + # With a table advertised, a client may reference earlier field values; + # every response must still be correct. + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_qpack_table_capacity=4096).install() + assert env.apache_restart() == 0 + url = env.mkurl("https", "test1", "/") + for _ in range(5): + r = env.curl_get(url, options=["--http3-only", "-k", "-H", "Cookie: a=" + "x" * 200]) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response["status"] == 200 + + def test_006_qpack_invalid_values(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_qpack_table_capacity="invalid").install() + assert env.apache_restart() != 0 + H3Conf(env).add_vhost_test1(h3_qpack_table_capacity=1048577).install() + assert env.apache_restart() != 0 + H3Conf(env).add_vhost_test1(h3_qpack_blocked_streams=1001).install() + assert env.apache_restart() != 0 + + def test_007_worker_directives_in_vhost(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_min_workers=4, h3_max_workers=8, h3_max_worker_idle_seconds=30).install() + assert env.apache_restart() == 0 + conf = _read_test_conf(env) + assert "H3MinWorkers 4" in conf + assert "H3MaxWorkers 8" in conf + assert "H3MaxWorkerIdleSeconds 30" in conf + + def test_008_small_worker_pool_still_serves_concurrent_requests(self, env): + from concurrent.futures import ThreadPoolExecutor + + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_min_workers=2, h3_max_workers=4).install() + assert env.apache_restart() == 0 + + url = env.mkurl("https", "test1", "/") + + def fetch(_): + return env.curl_get(url, options=["--http3-only", "-k"]) + + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(fetch, range(16))) + for r in results: + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response["status"] == 200 + + def test_009_worker_invalid_values(self, env): + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_min_workers=0).install() + assert env.apache_restart() != 0 + H3Conf(env).add_vhost_test1(h3_max_workers="invalid").install() + assert env.apache_restart() != 0 + H3Conf(env).add_vhost_test1(h3_max_worker_idle_seconds=86401).install() + assert env.apache_restart() != 0 + + def test_010_max_below_min_is_corrected_not_fatal(self, env): + # post_config raises H3MaxWorkers to H3MinWorkers and warns, so a + # contradictory pair must not stop the server. + from .env import H3Conf + + H3Conf(env).add_vhost_test1(h3_min_workers=8, h3_max_workers=2).install() + assert env.apache_restart() == 0 + r = self._get(env) + assert r.exit_code == 0, r.stderr + r.stdout + assert r.response["status"] == 200 From 8d5a6e8aff5876c31659cd86daa4f7c2c94c4653 Mon Sep 17 00:00:00 2001 From: Alexander Gerasimov Date: Fri, 7 Aug 2026 19:35:15 +0300 Subject: [PATCH 10/11] Document the directives added for this release Adds reference entries for H3StreamTimeout, H3MaxStreamErrors, H3QpackTableCapacity, H3QpackBlockedStreams, H3MinWorkers, H3MaxWorkers and H3MaxWorkerIdleSeconds, and lists the three that bound resources in the operational limits table. Every registered directive is documented again. --- docs/configuration_httpd.md | 58 ++++++++++++++++++++++++++++++++++++- docs/limits.md | 3 ++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/docs/configuration_httpd.md b/docs/configuration_httpd.md index 3eac175..a180ef5 100644 --- a/docs/configuration_httpd.md +++ b/docs/configuration_httpd.md @@ -162,6 +162,62 @@ it a location first: ``` +### H3StreamTimeout + +**Syntax:** `H3StreamTimeout seconds` +**Context:** server config, virtual host +**Default:** the server's `Timeout` + +How long a response may make no progress before it is abandoned. A client that opens a stream and then stops reading otherwise leaves the request worker blocked on the per-stream response queue indefinitely: the QUIC connection stays alive on keepalives and the idle enforcement in [`H3IdleTimeout`](#h3idletimeout) deliberately skips a connection that still has a request running. The window is measured per chunk of progress, not over the whole response, so a slow but advancing transfer is never cut off. Equivalent to mod_http2's `H2StreamTimeout`. + +### H3MaxStreamErrors + +**Syntax:** `H3MaxStreamErrors n` +**Context:** server config, virtual host +**Default:** `8` + +How many client-caused stream errors one connection may produce before it is closed with `H3_EXCESSIVE_LOAD`. A malformed request is answered as a stream error so the connection keeps serving its other streams (RFC 9114 section 4.1.2), which on its own would let a client send malformed requests indefinitely at no cost. Equivalent to mod_http2's `H2MaxStreamErrors`. + +### H3QpackTableCapacity + +**Syntax:** `H3QpackTableCapacity bytes` +**Context:** server config, virtual host +**Default:** `4096` + +Bytes of QPACK dynamic table the server allows a client to use when encoding request header fields, advertised as `SETTINGS_QPACK_MAX_TABLE_CAPACITY`. With `0` the client may not use the dynamic table at all and must send every field literally, so repeated requests re-send their cookies and `User-Agent` in full. A larger table trades memory per connection for smaller requests. + +### H3QpackBlockedStreams + +**Syntax:** `H3QpackBlockedStreams n` +**Context:** server config, virtual host +**Default:** `16` + +How many requests may wait on a QPACK dynamic table insert that has not arrived yet, advertised as `SETTINGS_QPACK_BLOCKED_STREAMS`. `0` forbids blocking, which limits how aggressively a client can compress. Only meaningful when [`H3QpackTableCapacity`](#h3qpacktablecapacity) is non-zero. + +### H3MinWorkers + +**Syntax:** `H3MinWorkers n` +**Context:** server config, virtual host +**Default:** `16` + +Request worker threads started per child process. HTTP/3 requests are dispatched to this pool rather than handled on the QUIC event thread. + +### H3MaxWorkers + +**Syntax:** `H3MaxWorkers n` +**Context:** server config, virtual host +**Default:** `64` + +Maximum request worker threads per child process. This caps how many HTTP/3 requests one child can process at once, independently of the MPM's own thread settings. A value below [`H3MinWorkers`](#h3minworkers) is raised to match it, with a warning. + +### H3MaxWorkerIdleSeconds + +**Syntax:** `H3MaxWorkerIdleSeconds seconds` +**Context:** server config, virtual host +**Default:** `600` + +How long an idle request worker is kept before it exits, letting the pool shrink back towards [`H3MinWorkers`](#h3minworkers) after a burst. Equivalent to mod_http2's `H2MaxWorkerIdleSeconds`. + ## VirtualHost Configuration ### Port Detection @@ -199,7 +255,7 @@ The module uses the **first VirtualHost** that has both `H3CertificatePath` and ``` -### TLS Environment Variables +## TLS Environment Variables mod_ssl does not manage HTTP/3 connections, so it publishes no TLS environment for them. The module supplies the following under the same names mod_ssl uses, so existing CGI scripts, `mod_rewrite` conditions and log formats keep working over HTTP/3: diff --git a/docs/limits.md b/docs/limits.md index 4c963b6..78a940e 100644 --- a/docs/limits.md +++ b/docs/limits.md @@ -12,6 +12,9 @@ HTTP/3 request and response bodies are buffered by the module. Set limits accord | `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 | +| `H3StreamTimeout` | the server `Timeout` | Abandons a response that makes no progress | +| `H3MaxStreamErrors` | `8` | Closes a connection whose client keeps causing stream errors | +| `H3MaxWorkers` | `64` | Caps concurrent HTTP/3 requests per child process | ## Response Body Limit From 0db7f9fbf9e1cf900419a49d776f01ce019506bb Mon Sep 17 00:00:00 2001 From: Alexander Gerasimov Date: Fri, 14 Aug 2026 16:40:43 +0300 Subject: [PATCH 11/11] v0.0.60 - httpd 2.4.x support, request limits, TLS environment, QPACK Collapses what were two releases against the pre-h3q tree (v0.0.53 and v0.0.54) into one against upstream v0.0.59. The old numbers are not reused: upstream's own v0.0.52-v0.0.59 are different releases. Two changes from those releases did not come across. Both were ngtcp2-only: the recvmmsg() batch-read hook on quic_io, and resolving the socket's local address once per engine instead of once per pass. Neither has an OpenSSL counterpart -- h3q drives its own datagram BIO through BIO_recvmmsg() and never calls getsockname() -- so nothing was lost with the ngtcp2 engine. --- CHANGES | 2 +- CMakeLists.txt | 2 +- mod_http3/include/h3_session.h | 2 +- mod_http3/include/h3_version.h | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGES b/CHANGES index cf7fd71..bddd71f 100644 --- a/CHANGES +++ b/CHANGES @@ -3,7 +3,7 @@ mod_http3 changes Changes are listed most recent first. Security-related entries always appear at the top of their release block. -v0.0.60 (unreleased) +v0.0.60 (2026-08-14) -------------------- *) Added H3StreamTimeout, bounding how long a response may make no progress. A client that opened a stream and then stopped reading left its worker diff --git a/CMakeLists.txt b/CMakeLists.txt index a903899..726052c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.26) -project(mod_http3 VERSION 0.0.59) +project(mod_http3 VERSION 0.0.60) # -- Compiler and Build Type Checks -- if(NOT CMAKE_C_COMPILER_ID MATCHES "^(GNU|MSVC)$") diff --git a/mod_http3/include/h3_session.h b/mod_http3/include/h3_session.h index b8a3b44..9197f47 100644 --- a/mod_http3/include/h3_session.h +++ b/mod_http3/include/h3_session.h @@ -40,7 +40,7 @@ typedef struct h3_response_chunk h3_response_chunk; * mod_ssl uses so existing scripts and rewrite conditions keep working. * Computed once per connection, since none of it changes between requests, and * held in the session pool so every request on the connection can point at it. - * All members are NULL when the engine runs no TLS of its own. + * All members are NULL when the cipher was not yet negotiated. */ typedef struct h3_tls_env { diff --git a/mod_http3/include/h3_version.h b/mod_http3/include/h3_version.h index 4c8d0b6..bd043c9 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 59 +#define MOD_HTTP3_VERSION_PATCH 60 // 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.59" +#define MOD_HTTP3_VERSION_STRING "0.0.60" #endif /* H3_VERSION_H */