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..bddd71f 100644 --- a/CHANGES +++ b/CHANGES @@ -3,6 +3,104 @@ mod_http3 changes Changes are listed most recent first. Security-related entries always appear at the top of their release block. +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 + 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 + 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 ] + + *) 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. + 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 + 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 + 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 + 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 + 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/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/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/docs/configuration_httpd.md b/docs/configuration_httpd.md index e5b4a29..a180ef5 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` @@ -117,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` @@ -138,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 @@ -175,7 +255,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/docs/limits.md b/docs/limits.md index 5aac53b..78a940e 100644 --- a/docs/limits.md +++ b/docs/limits.md @@ -7,10 +7,14 @@ 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 | | `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 diff --git a/mod_http3/include/h3.h b/mod_http3/include/h3.h index 8d8aa25..b07af46 100644 --- a/mod_http3/include/h3.h +++ b/mod_http3/include/h3.h @@ -44,6 +44,35 @@ #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) + +/* 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 +/* + * 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 #define H3_IDLE_TIMEOUT_DEFAULT 300 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_config.h b/mod_http3/include/h3_config.h index 1ccee03..42b5d43 100644 --- a/mod_http3/include/h3_config.h +++ b/mod_http3/include/h3_config.h @@ -51,6 +51,19 @@ 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; + /// 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_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_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/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/include/h3_session.h b/mod_http3/include/h3_session.h index 6994c85..9197f47 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 cipher was not yet negotiated. + */ +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; @@ -64,6 +83,18 @@ 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 + /// 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 @@ -99,6 +130,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/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/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 */ 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/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_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_config.c b/mod_http3/src/h3_config.c index 4ef40c6..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) @@ -72,6 +73,17 @@ 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; + 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; return merged; } @@ -209,6 +221,216 @@ 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_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_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) @@ -313,6 +535,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); @@ -388,6 +626,40 @@ 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_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. */ + /* 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; @@ -404,6 +676,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; @@ -423,6 +703,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) @@ -440,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; @@ -471,5 +759,15 @@ 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_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("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_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_hooks.c b/mod_http3/src/h3_hooks.c index 9217bfa..5896186 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); @@ -119,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_io.c b/mod_http3/src/h3_io.c index ee58eca..d97d658 100644 --- a/mod_http3/src/h3_io.c +++ b/mod_http3/src/h3_io.c @@ -114,17 +114,20 @@ 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 = { .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) @@ -138,9 +141,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; @@ -401,8 +408,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) @@ -443,5 +452,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_request.c b/mod_http3/src/h3_request.c index b2a2291..3e61584 100644 --- a/mod_http3/src/h3_request.c +++ b/mod_http3/src/h3_request.c @@ -36,16 +36,29 @@ #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" 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 @@ -128,9 +141,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(); @@ -139,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; @@ -156,8 +170,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 +249,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 +294,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,15 +467,16 @@ 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); - 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_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/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_session.c b/mod_http3/src/h3_session.c index ebcd5eb..3474e4c 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) @@ -79,18 +80,56 @@ 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 + * 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"); 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); + /* 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; } @@ -200,10 +239,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)); @@ -212,6 +261,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_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/mod_http3/src/h3_stream.c b/mod_http3/src/h3_stream.c index bac0fea..59da500 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) { @@ -184,12 +190,61 @@ 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; + + /* 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) { 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 +280,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 +311,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 +333,7 @@ static int drain_one_stream(h3_session* session, h3_stream* h3s, int* data_read, } break; } - return h3s->is_bidi && h3s->headers_complete && h3s->body_complete && !h3s->dispatched; + return h3s->is_bidi && !h3s->done && h3s->headers_complete && h3s->body_complete && !h3s->dispatched; } apr_array_header_t* drain_ready_streams(h3_session* session, apr_pool_t* loop_pool, int* data_read) diff --git a/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 = { 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/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/env.py b/test/http3/env.py index 1dd1aab..42d1686 100644 --- a/test/http3/env.py +++ b/test/http3/env.py @@ -88,6 +88,16 @@ def add_vhost_test1( h3_handshake_timeout=None, h3_idle_timeout=None, h3_address_validation=None, + 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 ): self.start_vhost( @@ -124,6 +134,28 @@ 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}") + 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_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}") + 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_020_malformed.py b/test/http3/test_020_malformed.py new file mode 100644 index 0000000..d5371ca --- /dev/null +++ b/test/http3/test_020_malformed.py @@ -0,0 +1,199 @@ +"""Malformed HTTP/3 requests are stream errors, not connection errors. + +RFC 9114 4.1.2: a request that violates the header field rules of 4.x must be +rejected with a stream error of type H3_MESSAGE_ERROR while the connection +keeps serving other streams. These tests send deliberately malformed requests +(raw QPACK-encoded, bypassing client-side validation) and then verify the same +connection still answers a well-formed request. +""" + +import asyncio +import ssl + +import pytest + +from .env import H3Conf + +from aioquic.asyncio.client import connect +from aioquic.asyncio.protocol import QuicConnectionProtocol +from aioquic.buffer import encode_uint_var +from aioquic.h3.connection import H3_ALPN, H3Connection +from aioquic.h3.events import DataReceived, HeadersReceived +from aioquic.quic import events as quic_events +from aioquic.quic.configuration import QuicConfiguration + +import pylsqpack + +# RFC 9114 section 8.1. +H3_MESSAGE_ERROR = 0x010E +FRAME_TYPE_DATA = 0x0 +FRAME_TYPE_HEADERS = 0x1 + + +class _RawRequestClient(QuicConnectionProtocol): + """HTTP/3 client that can send arbitrary, even malformed, header blocks.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._http = H3Connection(self._quic) + self._encoder = pylsqpack.Encoder() + self.reset_codes = {} + self._reset_event = asyncio.Event() + self.status = None + self._response_done = asyncio.Event() + + def quic_event_received(self, event): + if isinstance(event, quic_events.StreamReset): + self.reset_codes[event.stream_id] = event.error_code + self._reset_event.set() + return + for h3_event in self._http.handle_event(event): + if isinstance(h3_event, HeadersReceived): + for k, v in h3_event.headers: + if k == b":status": + self.status = v.decode() + if h3_event.stream_ended: + self._response_done.set() + elif isinstance(h3_event, DataReceived): + if h3_event.stream_ended: + self._response_done.set() + + async def send_raw_request(self, headers, data=None, timeout=5.0): + """Encode headers verbatim and return the stream reset code.""" + stream_id = self._quic.get_next_available_stream_id() + _, payload = self._encoder.encode(stream_id, headers) + frame = encode_uint_var(FRAME_TYPE_HEADERS) + encode_uint_var(len(payload)) + payload + if data is not None: + frame += encode_uint_var(FRAME_TYPE_DATA) + encode_uint_var(len(data)) + data + self._quic.send_stream_data(stream_id, frame, end_stream=True) + self.transmit() + await asyncio.wait_for(self._reset_event.wait(), timeout=timeout) + return self.reset_codes.get(stream_id) + + async def get(self, authority, path, headers=None, timeout=5.0): + """Send a well-formed GET and return the response status.""" + stream_id = self._quic.get_next_available_stream_id() + h = [ + (b":method", b"GET"), + (b":scheme", b"https"), + (b":authority", authority.encode()), + (b":path", path.encode()), + ] + h += headers or [] + self._http.send_headers(stream_id=stream_id, headers=h, end_stream=True) + self.transmit() + await asyncio.wait_for(self._response_done.wait(), timeout=timeout) + return self.status + + +class TestMalformedRequests: + @pytest.fixture(autouse=True, scope="class") + def _class_scope(self, env): + H3Conf(env).add_vhost_test1().install() + assert env.apache_restart() == 0 + + def _authority(self, env): + return f"test1.{env.http_tld}" + + def _reject_then_serve(self, env, malformed_headers, data=None): + """Send a malformed request, then a valid one on the same connection. + + Returns (reset_code, follow_up_status). + """ + authority = self._authority(env) + + async def run(): + config = QuicConfiguration( + is_client=True, + alpn_protocols=H3_ALPN, + verify_mode=ssl.CERT_NONE, + server_name=authority, + ) + async with connect( + env.http_addr, + env.https_port, + configuration=config, + create_protocol=_RawRequestClient, + ) as client: + code = await client.send_raw_request(malformed_headers, data=data) + status = await client.get(authority, "/index.html") + return code, status + + return asyncio.run(run()) + + def _valid_headers(self, env): + return [ + (b":method", b"GET"), + (b":scheme", b"https"), + (b":authority", self._authority(env).encode()), + (b":path", b"/index.html"), + ] + + def test_001_missing_method(self, env): + headers = [h for h in self._valid_headers(env) if h[0] != b":method"] + code, status = self._reject_then_serve(env, headers) + assert code == H3_MESSAGE_ERROR, f"reset code 0x{code:X}" + assert status == "200", "connection must survive the malformed stream" + + def test_002_missing_path(self, env): + headers = [h for h in self._valid_headers(env) if h[0] != b":path"] + code, status = self._reject_then_serve(env, headers) + assert code == H3_MESSAGE_ERROR, f"reset code 0x{code:X}" + assert status == "200", "connection must survive the malformed stream" + + def test_003_missing_scheme(self, env): + headers = [h for h in self._valid_headers(env) if h[0] != b":scheme"] + code, status = self._reject_then_serve(env, headers) + assert code == H3_MESSAGE_ERROR, f"reset code 0x{code:X}" + assert status == "200", "connection must survive the malformed stream" + + def test_004_missing_authority_and_host(self, env): + headers = [h for h in self._valid_headers(env) if h[0] != b":authority"] + code, status = self._reject_then_serve(env, headers) + assert code == H3_MESSAGE_ERROR, f"reset code 0x{code:X}" + assert status == "200", "connection must survive the malformed stream" + + def test_005_duplicate_pseudo_header(self, env): + headers = self._valid_headers(env) + [(b":method", b"GET")] + code, status = self._reject_then_serve(env, headers) + assert code == H3_MESSAGE_ERROR, f"reset code 0x{code:X}" + assert status == "200", "connection must survive the malformed stream" + + def test_006_connection_specific_header(self, env): + headers = self._valid_headers(env) + [(b"connection", b"close")] + code, status = self._reject_then_serve(env, headers) + assert code == H3_MESSAGE_ERROR, f"reset code 0x{code:X}" + assert status == "200", "connection must survive the malformed stream" + + def test_007_te_other_than_trailers(self, env): + headers = self._valid_headers(env) + [(b"te", b"gzip")] + code, status = self._reject_then_serve(env, headers) + assert code == H3_MESSAGE_ERROR, f"reset code 0x{code:X}" + assert status == "200", "connection must survive the malformed stream" + + def test_008_content_length_mismatch(self, env): + headers = self._valid_headers(env) + [(b"content-length", b"10")] + code, status = self._reject_then_serve(env, headers, data=b"abc") + assert code == H3_MESSAGE_ERROR, f"reset code 0x{code:X}" + assert status == "200", "connection must survive the malformed stream" + + def test_009_te_trailers_is_allowed(self, env): + authority = self._authority(env) + + async def run(): + config = QuicConfiguration( + is_client=True, + alpn_protocols=H3_ALPN, + verify_mode=ssl.CERT_NONE, + server_name=authority, + ) + async with connect( + env.http_addr, + env.https_port, + configuration=config, + create_protocol=_RawRequestClient, + ) as client: + return await client.get(authority, "/index.html", headers=[(b"te", b"trailers")]) + + status = asyncio.run(run()) + assert status == "200" diff --git a/test/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" 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 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/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" 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" + ) 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 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")