diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 7a11e7b1d1a..e8eaab63c36 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -179,6 +179,20 @@ jobs: TEST_PYTEST=1 PYTEST_ARGS=modules/http2 # ------------------------------------------------------------------------- + # mod_http3 needs OpenSSL >= 3.5 and libnghttp3, which Ubuntu + # 26.04 has. Its curl has no HTTP/3, so one is built from source. + # Strict mode turns a skip for a missing dependency into a failure. + - name: mod_http3 + os: ubuntu-26.04 + config: --enable-mods-shared=reallyall --enable-maintainer-mode --with-mpm=event --enable-http3 + pkgs: libnghttp3-dev + env: | + TEST_CURL_H3=8.18.0 + NO_TEST_FRAMEWORK=1 + TEST_PYTEST=1 + PYTEST_ARGS=modules/http3 + PYHTTPD_STRICT_OPTIONAL=1 + # ------------------------------------------------------------------------- - name: Default # Check the defaults expected for a default build on Linux. config-output: >- diff --git a/modules/http3/config2.m4 b/modules/http3/config2.m4 index eb155cd70b3..e84607b8c3e 100644 --- a/modules/http3/config2.m4 +++ b/modules/http3/config2.m4 @@ -139,7 +139,8 @@ AC_DEFUN([APACHE_CHECK_NGHTTP3],[ dnl Run library and function checks liberrors="" AC_CHECK_HEADERS([nghttp3/nghttp3.h]) - AC_CHECK_FUNCS([nghttp3_conn_server_new], [], [liberrors="yes"]) + dnl nghttp3_conn_server_new() is a macro around the versioned function + AC_CHECK_FUNCS([nghttp3_conn_server_new_versioned], [], [liberrors="yes"]) if test "x$liberrors" != "x"; then AC_MSG_WARN([nghttp3 library is unusable]) fi diff --git a/modules/http3/h3_io.c b/modules/http3/h3_io.c index 22f618b98ec..2ec81eeb4a5 100644 --- a/modules/http3/h3_io.c +++ b/modules/http3/h3_io.c @@ -45,6 +45,13 @@ #include "quic/h3q_conn.h" #include "quic/h3q_stream.h" +#if NGHTTP3_VERSION_NUM >= 0x011000 +#define H3_NGHTTP3_CONN_IS_DRAINED(conn) nghttp3_conn_is_drained2(conn) +#else +/* nghttp3 < 1.16.0 has the same function, without the const argument */ +#define H3_NGHTTP3_CONN_IS_DRAINED(conn) nghttp3_conn_is_drained(conn) +#endif + h3_io_t* child_h3_io = NULL; int h3_io_at_connection_limit(h3_io_t* io) @@ -155,7 +162,10 @@ apr_status_t h3_io_listen_start(apr_pool_t* pchild, server_rec* s, h3_server_con io->thread_running = 1; child_h3_io = io; + /* The event thread allocates from pchild too, so allocate everything + * from it here before the thread starts. */ apr_threadattr_t* attr = NULL; + apr_pool_pre_cleanup_register(pchild, io, listen_stop_cleanup); if (apr_threadattr_create(&attr, pchild) != APR_SUCCESS || apr_thread_create(&io->event_thread, attr, h3_event_thread, io, pchild) != APR_SUCCESS) { io->thread_running = 0; @@ -163,7 +173,6 @@ apr_status_t h3_io_listen_start(apr_pool_t* pchild, server_rec* s, h3_server_con child_h3_io = NULL; return APR_EGENERAL; } - apr_pool_pre_cleanup_register(pchild, io, listen_stop_cleanup); ap_log_error(APLOG_MARK, APLOG_INFO, 0, s, "mod_http3 loaded with version: %d (%s) on pid=%d port=%d; built against httpd %d.%d.%d MMN %d:%d (%s)", MOD_HTTP3_VERSION, MOD_HTTP3_VERSION_STRING, h3_getpid(), (int)conf->h3_port, AP_SERVER_MAJORVERSION_NUMBER, AP_SERVER_MINORVERSION_NUMBER, AP_SERVER_PATCHLEVEL_NUMBER, MODULE_MAGIC_NUMBER_MAJOR, MODULE_MAGIC_NUMBER_MINOR, H3_DEVEL ? "devel" : "stable"); return APR_SUCCESS; @@ -422,13 +431,19 @@ int service_session_pass(h3_io_t* io, h3_session* session) if (io->draining || !io->thread_running) { + int drained; apr_thread_mutex_lock(session->lock); if (!session->goaway_sent) { nghttp3_conn_submit_shutdown_notice(session->ngh3); } + if (!io->thread_running && !session->final_goaway_sent) + { + nghttp3_conn_shutdown(session->ngh3); + session->final_goaway_sent = 1; + } flush_nghttp3(session); - int drained = nghttp3_conn_is_drained2(session->ngh3); + drained = H3_NGHTTP3_CONN_IS_DRAINED(session->ngh3); apr_thread_mutex_unlock(session->lock); if (!session->goaway_sent) { @@ -443,7 +458,11 @@ int service_session_pass(h3_io_t* io, h3_session* session) if (drained || (session->goaway_deadline && apr_time_now() >= session->goaway_deadline)) { apr_thread_mutex_lock(session->lock); - nghttp3_conn_shutdown(session->ngh3); + if (!session->final_goaway_sent) + { + nghttp3_conn_shutdown(session->ngh3); + session->final_goaway_sent = 1; + } flush_nghttp3(session); apr_thread_mutex_unlock(session->lock); session->aborted = 1; diff --git a/modules/http3/h3_request.c b/modules/http3/h3_request.c index a41d49bf81d..4e2e16c0bff 100644 --- a/modules/http3/h3_request.c +++ b/modules/http3/h3_request.c @@ -342,10 +342,15 @@ static void* APR_THREAD_FUNC stream_worker(apr_thread_t* thd, void* data) ap_add_output_filter_handle(h3_proto_out_filter_handle, h3ctx, r, r->connection); - ap_update_vhost_from_headers(r); + /* Validate the request and select the vhost like HTTP/1.1 and HTTP/2 do + * (StrictHostCheck, a missing Host, Expect); on failure r->status holds + * the error. + */ + ap_check_request_header(r); r->per_dir_config = r->server->lookup_defaults; ap_log_error(APLOG_MARK, APLOG_INFO, 0, s, "before ap_process_request"); + int access_status; if (r->status != HTTP_OK) { int err = r->status; @@ -356,6 +361,14 @@ static void* APR_THREAD_FUNC stream_worker(apr_thread_t* thd, void* data) { ap_die(HTTP_REQUEST_ENTITY_TOO_LARGE, r); } + /* Run the post_read_request hooks (mod_setenvif, mod_remoteip, + * mod_headers "early", mod_proxy forward proxy detection, ...) and the + * core checks on the complete request, as HTTP/1.1 and HTTP/2 do. + */ + else if ((access_status = ap_post_read_request(r)) != OK) + { + ap_die(access_status, r); + } else { ap_process_request(r); diff --git a/modules/http3/h3_server.c b/modules/http3/h3_server.c index 9a7ef5fb121..df2ecf3dfe4 100644 --- a/modules/http3/h3_server.c +++ b/modules/http3/h3_server.c @@ -30,6 +30,7 @@ #include "h3_os.h" #include "h3_server.h" #include "h3_socket.h" +#include "h3_threads.h" #include "mod_http3.h" static volatile int child_stopping = 0; @@ -38,7 +39,7 @@ static apr_thread_t* port_acquire_thread = NULL; struct port_acquire_args { - apr_pool_t* pchild; + apr_pool_t* pool; server_rec* vhost; h3_server_conf* conf; }; @@ -46,12 +47,21 @@ struct port_acquire_args static void* APR_THREAD_FUNC port_acquire_thread_fn(apr_thread_t* thread H3_UNUSED, void* data) { struct port_acquire_args* args = data; + /* Each attempt allocates; clear them, or retrying leaks for the life + * of the child. A successful attempt's socket stays in ptry. */ + apr_pool_t* ptry = NULL; + if (apr_pool_create(&ptry, args->pool) != APR_SUCCESS) + { + ap_log_error(APLOG_MARK, APLOG_ERR, 0, args->vhost, "port acquirer: could not create a pool"); + return NULL; + } while (!child_stopping) { int udp_fd = -1; - apr_status_t rv = h3_socket_open(args->conf->h3_port, args->conf->h3_socket_buffer_size, args->pchild, &udp_fd); + apr_status_t rv = h3_socket_open(args->conf->h3_port, args->conf->h3_socket_buffer_size, ptry, &udp_fd); if (rv == APR_EAGAIN) { + apr_pool_clear(ptry); apr_sleep(apr_time_from_msec(H3_PORT_ACQUIRE_RETRY_MS)); continue; } @@ -65,7 +75,7 @@ static void* APR_THREAD_FUNC port_acquire_thread_fn(apr_thread_t* thread H3_UNUS h3_socket_close(udp_fd); break; } - if (h3_io_listen_start(args->pchild, args->vhost, args->conf, udp_fd) != APR_SUCCESS) + if (h3_io_listen_start(args->pool, args->vhost, args->conf, udp_fd) != APR_SUCCESS) { ap_log_error(APLOG_MARK, APLOG_ERR, 0, args->vhost, "port acquirer: h3_io_listen_start failed"); h3_socket_close(udp_fd); @@ -96,27 +106,45 @@ static h3_server_conf* find_h3_server(server_rec* s, server_rec** out_server) return conf; } +/* The threads must be gone before the child pool they run from is + * destroyed, and before exit() runs OpenSSL's cleanup under them. Most + * MPMs stop them through the child_stopping hook, but prefork skips that + * hook when a child exits from a signal handler. */ +static apr_status_t child_pool_cleanup(void* data H3_UNUSED) +{ + h3_c1_child_stopping(NULL, 0); + return APR_SUCCESS; +} + void h3_child_init(apr_pool_t* pchild, server_rec* s) { server_rec* vhost = NULL; h3_server_conf* conf = find_h3_server(s, &vhost); + apr_pool_t* pool = NULL; + int udp_fd = -1; + apr_status_t rv; if (!conf) { ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, s, "h3_child_init: no H3 cert/key configured, skipping"); return; } + apr_pool_pre_cleanup_register(pchild, NULL, child_pool_cleanup); + if (h3_thread_safe_pool_create(pchild, "h3_child", &pool) != APR_SUCCESS) + { + ap_log_error(APLOG_MARK, APLOG_ERR, 0, vhost, "h3_child_init: could not create the HTTP/3 pool"); + return; + } - int udp_fd = -1; - apr_status_t rv = h3_socket_open(conf->h3_port, conf->h3_socket_buffer_size, pchild, &udp_fd); + rv = h3_socket_open(conf->h3_port, conf->h3_socket_buffer_size, pool, &udp_fd); if (rv == APR_EAGAIN) { + struct port_acquire_args* args = apr_palloc(pool, sizeof(*args)); + apr_threadattr_t* attr = NULL; ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, vhost, "h3_child_init: pid=%d port already owned, will keep retrying in background", h3_getpid()); - struct port_acquire_args* args = apr_palloc(pchild, sizeof(*args)); - args->pchild = pchild; + args->pool = pool; args->vhost = vhost; args->conf = conf; - apr_threadattr_t* attr = NULL; - if (apr_threadattr_create(&attr, pchild) != APR_SUCCESS || apr_thread_create(&port_acquire_thread, attr, port_acquire_thread_fn, args, pchild) != APR_SUCCESS) + if (apr_threadattr_create(&attr, pool) != APR_SUCCESS || apr_thread_create(&port_acquire_thread, attr, port_acquire_thread_fn, args, pool) != APR_SUCCESS) { ap_log_error(APLOG_MARK, APLOG_ERR, 0, vhost, "h3_child_init: failed to start port acquirer thread"); port_acquire_thread = NULL; @@ -128,7 +156,7 @@ void h3_child_init(apr_pool_t* pchild, server_rec* s) ap_log_error(APLOG_MARK, APLOG_ERR, 0, vhost, "h3_child_init: h3_socket_open failed"); return; } - if (h3_io_listen_start(pchild, vhost, conf, udp_fd) != APR_SUCCESS) + if (h3_io_listen_start(pool, vhost, conf, udp_fd) != APR_SUCCESS) { ap_log_error(APLOG_MARK, APLOG_ERR, 0, vhost, "h3_child_init: h3_io_listen_start failed"); h3_socket_close(udp_fd); diff --git a/modules/http3/h3_threads.c b/modules/http3/h3_threads.c index 7d186e0fa39..1939fd76349 100644 --- a/modules/http3/h3_threads.c +++ b/modules/http3/h3_threads.c @@ -21,6 +21,8 @@ #include #include +#include +#include #include "h3.h" #include "h3_io.h" @@ -32,6 +34,34 @@ #define H3_PUMP_BUDGET 32 +apr_status_t h3_thread_safe_pool_create(apr_pool_t* parent, const char* tag, apr_pool_t** out) +{ + apr_allocator_t* allocator = NULL; + apr_thread_mutex_t* mutex = NULL; + apr_status_t rv = apr_allocator_create(&allocator); + if (rv != APR_SUCCESS) + { + return rv; + } + rv = apr_pool_create_ex(out, parent, NULL, allocator); + if (rv != APR_SUCCESS) + { + apr_allocator_destroy(allocator); + return rv; + } + apr_allocator_owner_set(allocator, *out); + apr_pool_tag(*out, tag); + rv = apr_thread_mutex_create(&mutex, APR_THREAD_MUTEX_DEFAULT, *out); + if (rv != APR_SUCCESS) + { + apr_pool_destroy(*out); + *out = NULL; + return rv; + } + apr_allocator_mutex_set(allocator, mutex); + return APR_SUCCESS; +} + void* APR_THREAD_FUNC h3_event_thread(apr_thread_t* thread, void* data) { (void)thread; diff --git a/modules/http3/include/h3_session.h b/modules/http3/include/h3_session.h index 02cc9d7ea89..35b0e38d441 100644 --- a/modules/http3/include/h3_session.h +++ b/modules/http3/include/h3_session.h @@ -74,6 +74,7 @@ struct h3_session int control_streams_created; int goaway_sent; + int final_goaway_sent; apr_time_t goaway_deadline; apr_time_t last_activity; diff --git a/modules/http3/include/h3_threads.h b/modules/http3/include/h3_threads.h index 90de5541a6f..c057d66c5b6 100644 --- a/modules/http3/include/h3_threads.h +++ b/modules/http3/include/h3_threads.h @@ -18,6 +18,7 @@ #define H3_THREADS_H #include "h3_io.h" +#include #include /** @@ -51,4 +52,19 @@ void* APR_THREAD_FUNC worker_thread(apr_thread_t* thread, void* data); */ void* APR_THREAD_FUNC h3_event_thread(apr_thread_t* thread, void* data); +/** + * Create a pool with an allocator of its own, which has a mutex, so that + * threads can create and destroy subpools of it concurrently. Pools such + * as the child pool may have an allocator without one; prefork shares it, + * unlocked, with the pool of every request. The mutex does not make + * allocating from the pool itself thread-safe: only one thread at a time + * may do that. + * + * @param parent The parent pool. + * @param tag The tag for the new pool. + * @param out Receives the new pool. + * @return APR_SUCCESS, or the error from APR. + */ +apr_status_t h3_thread_safe_pool_create(apr_pool_t* parent, const char* tag, apr_pool_t** out); + #endif /* H3_THREADS_H */ diff --git a/modules/http3/quic/h3q_conn.c b/modules/http3/quic/h3q_conn.c index 64ca406b72e..9319b64b1af 100644 --- a/modules/http3/quic/h3q_conn.c +++ b/modules/http3/quic/h3q_conn.c @@ -106,7 +106,11 @@ int h3q_conn_shutdown(h3q_conn* conn, int is_rapid, uint64_t app_error, const ch { return 1; } - uint64_t flags = is_rapid ? (uint64_t)SSL_SHUTDOWN_FLAG_RAPID : 0; + /* A rapid shutdown happens when the child stops, after the GOAWAY grace + * period: do not wait for unacknowledged stream data either, or a peer + * that has gone away keeps the connection, and the child, alive until + * the idle timeout. */ + uint64_t flags = is_rapid ? (uint64_t)(SSL_SHUTDOWN_FLAG_RAPID | SSL_SHUTDOWN_FLAG_NO_STREAM_FLUSH) : 0; int ret = 0; if (reason) { diff --git a/modules/http3/quic/h3q_stream.c b/modules/http3/quic/h3q_stream.c index 44ba7cb3263..fb78641eeb6 100644 --- a/modules/http3/quic/h3q_stream.c +++ b/modules/http3/quic/h3q_stream.c @@ -23,6 +23,14 @@ #include "quic/h3q_stream.h" +/* + * Marks a stream, through its SSL app data, whose sending half was + * concluded. OpenSSL before 3.5.7 frees the send buffer once the peer + * acknowledges the FIN, and SSL_get_stream_write_state() then dereferences + * it, so the write state of such a stream must not be asked for. + */ +static char concluded_marker; + h3q_write_result h3q_stream_write(h3q_stream* st, const h3q_vec* vec, size_t nvec, int fin) { SSL* ssl = (SSL*)st; @@ -57,7 +65,10 @@ h3q_write_result h3q_stream_write(h3q_stream* st, const h3q_vec* vec, size_t nve } if (fin && !res.blocked && !res.broken && res.accepted == expected) { - SSL_stream_conclude(ssl, 0); + if (SSL_stream_conclude(ssl, 0)) + { + SSL_set_app_data(ssl, &concluded_marker); + } } return res; } @@ -95,7 +106,8 @@ void h3q_stream_is_read_finished(h3q_stream* st, int* read_finished, int* write_ int rstate = SSL_get_stream_read_state(ssl); *read_finished = (rstate == SSL_STREAM_STATE_FINISHED || rstate == SSL_STREAM_STATE_RESET_REMOTE || rstate == SSL_STREAM_STATE_CONN_CLOSED); int wstate = SSL_STREAM_STATE_FINISHED; - if (rstate != SSL_STREAM_STATE_CONN_CLOSED && rstate != SSL_STREAM_STATE_RESET_REMOTE) + if (rstate != SSL_STREAM_STATE_CONN_CLOSED && rstate != SSL_STREAM_STATE_RESET_REMOTE && + SSL_get_app_data(ssl) != &concluded_marker) { wstate = SSL_get_stream_write_state(ssl); } diff --git a/test/README.ci b/test/README.ci index 30b2045088e..5b64ae798ff 100644 --- a/test/README.ci +++ b/test/README.ci @@ -80,6 +80,11 @@ The CI scripts use the following environment variables: HTTP/2 workflow to test against nghttp2 versions other than the one packaged for the runner image. +* TEST_CURL_H3 - if set to a version string (e.g. "8.18.0"), that + release of curl is built with HTTP/3 (OpenSSL QUIC and libnghttp3) + and used by the pytest suites through $CURL. Used by the mod_http3 + job, since the runner's curl has no HTTP/3. + * TEST_MD - set together with TEST_PYTEST; installs pebble first so that modules/md runs instead of skipping. See the "Python pytest test suites" job in .github/workflows/linux.yml. diff --git a/test/gha-resolve-deps.sh b/test/gha-resolve-deps.sh index 8c38ccb26ce..46bd8a85c40 100755 --- a/test/gha-resolve-deps.sh +++ b/test/gha-resolve-deps.sh @@ -65,6 +65,10 @@ if test -n "${TEST_NGHTTP2-}"; then root="${root} nghttp2-${TEST_NGHTTP2}" fi +if test -n "${TEST_CURL_H3-}"; then + root="${root} curl-h3-${TEST_CURL_H3}" +fi + if test -n "$root"; then # The compiler and every flag which reaches the build are baked into # the libraries, NOTEST_* included: APR adds those to the build too, diff --git a/test/modules/http3/__init__.py b/test/modules/http3/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/modules/http3/conftest.py b/test/modules/http3/conftest.py new file mode 100644 index 00000000000..d19c034c160 --- /dev/null +++ b/test/modules/http3/conftest.py @@ -0,0 +1,35 @@ +import logging +import os +import sys + +import pytest + +sys.path.append(os.path.join(os.path.dirname(__file__), '../..')) + +from .env import H3TestEnv + + +def pytest_report_header(config, start_path): + env = H3TestEnv() + return f"mod_http3 [apache: {env.get_httpd_version()}, mpm: {env.mpm_module}, {env.prefix}]" + + +@pytest.fixture(scope="package") +def env(pytestconfig) -> H3TestEnv: + level = logging.INFO + console = logging.StreamHandler() + console.setLevel(level) + console.setFormatter(logging.Formatter('%(levelname)s: %(message)s')) + logging.getLogger('').addHandler(console) + logging.getLogger('').setLevel(level=level) + env = H3TestEnv(pytestconfig=pytestconfig) + env.require(env.has_shared_module("http3"), "mod_http3 is not built", + dep="mod_http3") + env.require(env.has_shared_module("ssl"), "mod_ssl is not built", + dep="mod_ssl") + env.require(env.curl_has_http3(), "curl lacks HTTP/3 support", + dep="curl HTTP3", detected=env.curl_version_str()) + env.setup_httpd() + env.apache_access_log_clear() + env.httpd_error_log.clear_log() + return env diff --git a/test/modules/http3/env.py b/test/modules/http3/env.py new file mode 100644 index 00000000000..c9bc0ac41a6 --- /dev/null +++ b/test/modules/http3/env.py @@ -0,0 +1,86 @@ +import inspect +import logging +import os +import re +import subprocess +from typing import Dict, Any, List, Optional + +from pyhttpd.conf import HttpdConf +from pyhttpd.env import HttpdTestEnv, HttpdTestSetup + +log = logging.getLogger(__name__) + + +class H3TestSetup(HttpdTestSetup): + + def __init__(self, env: 'HttpdTestEnv'): + super().__init__(env=env) + self.add_source_dir(os.path.dirname(inspect.getfile(H3TestSetup))) + self.add_modules(["http3", "ssl", "setenvif", "headers"]) + self.add_cgi_module() + + +class H3TestEnv(HttpdTestEnv): + + def curl_has_http3(self) -> bool: + """Whether the curl in use can speak HTTP/3.""" + try: + p = subprocess.run([self.curl, "-V"], + capture_output=True, text=True) + except OSError: + return False + m = re.search(r'^Features:(.*)$', p.stdout, re.MULTILINE) + return m is not None and "HTTP3" in m.group(1).split() + + def __init__(self, pytestconfig=None): + super().__init__(pytestconfig=pytestconfig) + self.add_httpd_log_modules(["http3"]) + + def setup_httpd(self, setup: HttpdTestSetup = None): + super().setup_httpd(setup=H3TestSetup(env=self)) + + @property + def h3_port(self) -> int: + # the QUIC listener shares the number of the TLS port, over UDP + return self.https_port + + def h3_get(self, url: str, options: Optional[List[str]] = None): + return self.curl_raw([url], options=["--http3-only"] + (options or [])) + + +class H3Conf(HttpdConf): + """A configuration with HTTP/3 on the https port of the test1 vhost. + + mod_http3 opens one UDP listener, with the certificate of the first + virtual host that has one configured, so test1 carries the H3 + directives and every other vhost on the https port is reached through + the same QUIC connection by its Host header. The default certificate + covers all test domains. + """ + + def __init__(self, env: H3TestEnv, extras: Dict[str, Any] = None): + super().__init__(env=env, extras=extras) + + def add_h3_vhosts(self, test1: List[str] = None, test2: List[str] = None, + cgi: List[str] = None): + cred = self.env.get_credentials_for_name(f"test1.{self.env.http_tld}")[0] + self.start_vhost(domains=[f"test1.{self.env.http_tld}"], + port=self.env.https_port, doc_root="htdocs/test1") + self.add([ + "Protocols h3 http/1.1", + f"H3CertificatePath {cred.cert_file}", + f"H3CertificateKeyPath {cred.pkey_file}", + f"H3Port {self.env.h3_port}", + ]) + self.add(test1 or []) + self.end_vhost() + self.start_vhost(domains=[f"test2.{self.env.http_tld}"], + port=self.env.https_port, doc_root="htdocs/test2") + self.add(test2 or []) + self.end_vhost() + self.start_vhost(domains=[f"cgi.{self.env.http_tld}"], + port=self.env.https_port, doc_root="htdocs/cgi") + self.add(["AddHandler cgi-script .py"]) + self.add(cgi or []) + self.end_vhost() + return self diff --git a/test/modules/http3/htdocs/cgi/echo.py b/test/modules/http3/htdocs/cgi/echo.py new file mode 100644 index 00000000000..0f10967ac08 --- /dev/null +++ b/test/modules/http3/htdocs/cgi/echo.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +import json +import os +import sys + +content = sys.stdin.buffer.read() +names = ["REQUEST_METHOD", "SERVER_PROTOCOL", "SERVER_NAME", "HTTPS", + "CONTENT_TYPE", "CONTENT_LENGTH", "QUERY_STRING"] +result = {n.lower(): os.environ.get(n, "") for n in names} +result["body_len"] = len(content) +result["body_tail"] = content[-16:].decode("latin-1") +sys.stdout.write("Content-Type: application/json\n\n") +sys.stdout.write(json.dumps(result)) diff --git a/test/modules/http3/test_001_get.py b/test/modules/http3/test_001_get.py new file mode 100644 index 00000000000..91da4c1cd0b --- /dev/null +++ b/test/modules/http3/test_001_get.py @@ -0,0 +1,78 @@ +import os + +import pytest + +from .env import H3Conf + + +class TestGet: + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + env.make_data_file(os.path.join(env.server_docs_dir, "test1"), + "data-1m", 1024 * 1024) + H3Conf(env).add_h3_vhosts().install() + assert env.apache_restart() == 0 + + # a static file is served over HTTP/3 + def test_h3_001_01(self, env): + r = env.h3_get(env.mkurl("https", "test1", "/alive.json")) + assert r.exit_code == 0, f"{r}" + assert r.response["protocol"] == "HTTP/3" + assert r.response["status"] == 200 + assert r.json["host"] == "test1" + + # the TLS port advertises HTTP/3 on the same port number + def test_h3_001_02(self, env): + r = env.curl_get(env.mkurl("https", "test1", "/alive.json"), + options=["--http1.1"]) + assert r.response["status"] == 200 + alt_svc = r.response["header"].get("alt-svc", "") + assert alt_svc.startswith(f'h3=":{env.h3_port}"'), f"{r.response}" + + # a missing resource gives 404 + def test_h3_001_03(self, env): + r = env.h3_get(env.mkurl("https", "test1", "/does-not-exist")) + assert r.exit_code == 0, f"{r}" + assert r.response["protocol"] == "HTTP/3" + assert r.response["status"] == 404 + + # HEAD gives the headers of a GET without the content + def test_h3_001_04(self, env): + url = env.mkurl("https", "test1", "/index.html") + r = env.h3_get(url, options=["-I"]) + assert r.exit_code == 0, f"{r}" + assert r.response["status"] == 200 + size = os.path.getsize(os.path.join(env.server_docs_dir, + "test1", "index.html")) + assert r.response["header"]["content-length"] == str(size) + # curl -I prints the headers as the body; nothing else may follow + assert r.response["body"].startswith(b"HTTP/3 200") + assert b"', + ' ', + ' Require all granted', + ' Require not env blocked', + ' ', + '', + 'SetEnvIf Request_Protocol "^(.+)$" seen_proto=$1', + 'Header always set X-Seen-Protocol "%{seen_proto}e"', + # HTTP/3 field names are lowercase on the wire, as in HTTP/2 + 'Header echo "(?i)^x-h3-echo$"', + ]).install() + assert env.apache_restart() == 0 + + # SetEnvIf runs in post_read_request, so Require sees its variable + def test_h3_003_01(self, env): + url = env.mkurl("https", "test1", "/alive.json") + r = env.h3_get(url, options=["-A", "blocked-agent"]) + assert r.exit_code == 0, f"{r}" + assert r.response["protocol"] == "HTTP/3" + assert r.response["status"] == 403 + r = env.h3_get(url, options=["-A", "other-agent"]) + assert r.response["status"] == 200 + env.httpd_error_log.ignore_recent(lognos=["AH01630"]) + + # SetEnvIf sees the request protocol that mod_http3 sets, and + # mod_headers can use its variables and echo request headers + def test_h3_003_02(self, env): + r = env.h3_get(env.mkurl("https", "test1", "/alive.json"), + options=["-H", "X-H3-Echo: hello"]) + assert r.exit_code == 0, f"{r}" + assert r.response["header"].get("x-seen-protocol") == "HTTP/3.0" + assert r.response["header"].get("x-h3-echo") == "hello" diff --git a/test/travis_before_linux.sh b/test/travis_before_linux.sh index 7db459cb49c..05f6e8539e0 100755 --- a/test/travis_before_linux.sh +++ b/test/travis_before_linux.sh @@ -246,6 +246,28 @@ if test -v TEST_NGHTTP2; then grep -H '^Version' $HOME/root/nghttp2/lib/pkgconfig/libnghttp2.pc fi +# Build the requested version of curl with HTTP/3, over OpenSSL's QUIC +# stack and the packaged libnghttp3, if it's not already installed in +# the cached ~/root. The packaged curl has no HTTP/3. +if test -v TEST_CURL_H3; then + if ! test -x $HOME/root/curl/bin/curl; then + mkdir -p build/curl + pushd build/curl + curl -L "https://curl.se/download/curl-${TEST_CURL_H3}.tar.xz" | + tar -xJf - + cd curl-${TEST_CURL_H3} + ./configure --prefix=$HOME/root/curl --disable-shared \ + --with-openssl --with-openssl-quic --with-nghttp3 \ + --without-libpsl + make $MFLAGS + make install + popd + fi + + : -- Using curl from $HOME/root/curl -- + $HOME/root/curl/bin/curl -V +fi + if test -v APR_VERSION; then install_apx apr ${APR_VERSION} "${APR_CONFIG}" ldd $HOME/root/apr-${APR_VERSION}/lib/libapr-?.so || true diff --git a/test/travis_run_linux.sh b/test/travis_run_linux.sh index 54ee551e8d5..7b1ecef7ff8 100755 --- a/test/travis_run_linux.sh +++ b/test/travis_run_linux.sh @@ -79,6 +79,15 @@ if test -v TEST_OPENSSL3; then openssl version fi +if test -v TEST_CURL_H3; then + # pyhttpd runs $CURL in place of the curl from config.ini. + export CURL=$HOME/root/curl/bin/curl + if ! $CURL -V | grep -q '^Features:.* HTTP3'; then + : $CURL was built without HTTP/3 + exit 1 + fi +fi + if test -v TEST_NGHTTP2; then # APACHE_CHECK_NGHTTP2 only yields -L, so an RPATH is needed here or # mod_http2.so resolves to the system libnghttp2 at run-time.