]> git.ipfire.org Git - thirdparty/curl.git/commitdiff
HTTP/3: add proxy CONNECT and MASQUE CONNECT-UDP support (ngtcp2 QUIC)
authorAritra Basu <aritrbas+gh@cisco.com>
Mon, 27 Apr 2026 23:35:38 +0000 (19:35 -0400)
committerDaniel Stenberg <daniel@haxx.se>
Wed, 27 May 2026 06:49:53 +0000 (08:49 +0200)
This patch adds two major proxy capabilities to curl (ngtcp2 QUIC):
- HTTP/3 Proxy CONNECT: Tunnel HTTP/1.1 or HTTP/2 traffic through an
  HTTPS proxy that speaks HTTP/3 (QUIC) using the standard CONNECT
  method over an HTTP/3 connection.
- MASQUE CONNECT-UDP: Tunnel HTTP/3 (QUIC) traffic through an HTTP
  proxy (speaking HTTP/1.1, HTTP/2, or HTTP/3) using the extended
  CONNECT method with the CONNECT-UDP protocol (RFC9297 & RFC9298).

Public API additions:
- `CURLPROXY_HTTPS3`: new proxy type constant for HTTP/3 proxy
- `--proxy-http3`: new CLI flag to negotiate HTTP/3 with HTTPS proxy

The implementation adds two new filters:
- `H3-PROXY` - enables negotiating HTTP/3 (QUIC) to the proxy and
  running CONNECT/CONNECT-UDP through that proxy transport.
- `CAPSULE` - dedicated filter inserted between QUIC transport and
  HTTP-PROXY to handle datagram capsule encapsulation/decapsulation.

Here is how the curl filter chaining looks in different scenarios:
- HTTP/3 Proxy CONNECT (tunneling TCP protocols over QUIC proxy):
  conn -> HTTP/1.1 or HTTP/2  -> SSL -> HTTP-PROXY ->
                                 H3-PROXY -> HAPPY-EYEBALLS -> UDP
- MASQUE CONNECT-UDP (tunneling QUIC over any proxy):
  conn -> HTTP/3 -> CAPSULE -> HTTP-PROXY -> H3-PROXY ->
                               HAPPY-EYEBALLS -> UDP
  conn -> HTTP/3 -> CAPSULE -> HTTP-PROXY -> H1-PROXY or H2-PROXY ->
                               SSL -> HAPPY-EYEBALLS -> TCP

- Both features currently require the ngtcp2 QUIC backend.
- Both features are experimental (disabled by default). Enable with
  `--enable-proxy-http3`(autotools) or `-DUSE_PROXY_HTTP3=ON`(CMake).

Tests:
- tests/unit/unit3400.c: Unit tests for capsule protocol encode/decode
- tests/http/test_60_h3_proxy.py: Comprehensive pytest integration suite
- tests/http/testenv/h2o.py: Managing h2o instances with HTTP/1.1, HTTP/2,
  and HTTP/3 (QUIC) listeners, proxy.connect and proxy.connect-udp enabled.

References:
  RFC 9297 - HTTP Datagrams and the Capsule Protocol
  RFC 9298 - Proxying UDP in HTTP
  RFC 9000 §16 — Variable-Length Integer Encoding

Signed-off-by: Aritra Basu <aritrbas+gh@cisco.com>
Closes #21153

66 files changed:
.github/scripts/pyspelling.words
CMakeLists.txt
configure.ac
docs/EXPERIMENTAL.md
docs/INSTALL-CMAKE.md
docs/cmdline-opts/Makefile.inc
docs/cmdline-opts/proxy-http2.md
docs/cmdline-opts/proxy-http3.md [new file with mode: 0644]
docs/internals/CONNECTION-FILTERS.md
docs/libcurl/curl_version_info.md
docs/libcurl/opts/CURLOPT_PROXY.md
docs/libcurl/opts/CURLOPT_PROXYTYPE.md
docs/libcurl/symbols-in-versions
docs/options-in-versions
docs/tests/HTTP.md
include/curl/curl.h
lib/Makefile.inc
lib/capsule.c [new file with mode: 0644]
lib/capsule.h [new file with mode: 0644]
lib/cf-capsule.c [new file with mode: 0644]
lib/cf-capsule.h [new file with mode: 0644]
lib/cf-h1-proxy.c
lib/cf-h1-proxy.h
lib/cf-h2-proxy.c
lib/cf-h2-proxy.h
lib/cf-h3-proxy.c [new file with mode: 0644]
lib/cf-h3-proxy.h [new file with mode: 0644]
lib/cf-ip-happy.c
lib/cf-ip-happy.h
lib/connect.c
lib/curl_config-cmake.h.in
lib/curl_trc.c
lib/http.c
lib/http.h
lib/http2.c
lib/http_proxy.c
lib/http_proxy.h
lib/peer.c
lib/peer.h
lib/setopt.c
lib/url.c
lib/version.c
lib/vquic/curl_ngtcp2.c
lib/vquic/curl_ngtcp2.h
lib/vquic/curl_quiche.c
lib/vquic/vquic-tls.c
lib/vquic/vquic.c
lib/vquic/vquic.h
lib/vtls/openssl.c
lib/vtls/vtls.c
lib/vtls/vtls_int.h
src/tool_getparam.c
src/tool_getparam.h
src/tool_listhelp.c
tests/data/Makefile.am
tests/data/test3400 [new file with mode: 0644]
tests/http/CMakeLists.txt
tests/http/Makefile.am
tests/http/config.ini.in
tests/http/conftest.py
tests/http/test_60_h3_proxy.py [new file with mode: 0644]
tests/http/testenv/curl.py
tests/http/testenv/env.py
tests/http/testenv/h2o.py [new file with mode: 0644]
tests/unit/Makefile.inc
tests/unit/unit3400.c [new file with mode: 0644]

index 7d9f6ffb36c5540a773fa7af9ce0f2d5481f8319..63e5143191b3a10c657fd5d9bd164e44b9071637 100644 (file)
@@ -167,8 +167,10 @@ CWE
 cyassl
 Cygwin
 daniel
+datagrams
 datatracker
 dbg
+decapsulation
 Debian
 DEBUGBUILD
 decrypt
@@ -234,6 +236,7 @@ EGD
 EHLO
 EINTR
 else's
+encapsulation
 encodings
 enctype
 endianness
index 331c22dc4f61a901171a67d9b835ae4f1848b4b2..4a34f8524ef64674b79ce070f602f28f6a43e1bd 100644 (file)
@@ -1118,6 +1118,8 @@ if(USE_SSLS_EXPORT)
   endif()
 endif()
 
+option(USE_PROXY_HTTP3 "Enable experimental HTTP/3 proxy support" OFF)
+
 option(USE_NGHTTP2 "Use nghttp2 library" ON)
 if(USE_NGHTTP2)
   find_package(NGHTTP2 MODULE)
@@ -1186,6 +1188,20 @@ if(USE_QUICHE)
   endif()
 endif()
 
+if(USE_PROXY_HTTP3)
+  if(CURL_DISABLE_PROXY)
+    message(FATAL_ERROR "USE_PROXY_HTTP3 requires proxy support")
+  elseif(CURL_DISABLE_HTTP)
+    message(FATAL_ERROR "USE_PROXY_HTTP3 requires HTTP support")
+  elseif(NOT USE_NGTCP2 OR NOT USE_NGHTTP3)
+    message(FATAL_ERROR "USE_PROXY_HTTP3 requires ngtcp2 + nghttp3")
+  elseif(NOT USE_OPENSSL)
+    message(FATAL_ERROR "USE_PROXY_HTTP3 currently requires OpenSSL")
+  else()
+    message(STATUS "HTTP/3 proxy support enabled (experimental)")
+  endif()
+endif()
+
 if(NOT CURL_DISABLE_SRP AND (HAVE_GNUTLS_SRP OR HAVE_OPENSSL_SRP))
   set(USE_TLS_SRP 1)
 endif()
@@ -2046,6 +2062,7 @@ curl_add_if("NTLM"          CURL_ENABLE_NTLM AND
 curl_add_if("TLS-SRP"       USE_TLS_SRP)
 curl_add_if("HTTP2"         USE_NGHTTP2)
 curl_add_if("HTTP3"         USE_NGTCP2 OR USE_QUICHE)
+curl_add_if("PROXY-HTTP3"   USE_PROXY_HTTP3)
 curl_add_if("MultiSSL"      CURL_WITH_MULTI_SSL)
 curl_add_if("HTTPS-proxy"   NOT CURL_DISABLE_PROXY AND _ssl_enabled AND (USE_OPENSSL OR USE_GNUTLS
                             OR USE_SCHANNEL OR USE_RUSTLS OR USE_MBEDTLS OR
index 31a29cd601645bf3d6fafa055d1c2b4e3f958654..0601371baacc5066292d34d492f9d8f6e6545e69 100644 (file)
@@ -54,6 +54,30 @@ CURL_CHECK_OPTION_RT
 CURL_CHECK_OPTION_HTTPSRR
 CURL_CHECK_OPTION_ECH
 CURL_CHECK_OPTION_SSLS_EXPORT
+AC_MSG_CHECKING([whether to enable HTTP/3 proxy support])
+OPT_PROXY_HTTP3="default"
+AC_ARG_ENABLE(proxy-http3,
+AS_HELP_STRING([--enable-proxy-http3],[Enable experimental HTTP/3 proxy support])
+AS_HELP_STRING([--disable-proxy-http3],[Disable experimental HTTP/3 proxy support]),
+  OPT_PROXY_HTTP3=$enableval)
+case "$OPT_PROXY_HTTP3" in
+  no)
+    want_proxy_http3="no"
+    curl_proxy_http3_msg="no      (--enable-proxy-http3)"
+    AC_MSG_RESULT([no])
+    ;;
+  default)
+    want_proxy_http3="no"
+    curl_proxy_http3_msg="no      (--enable-proxy-http3)"
+    AC_MSG_RESULT([no])
+    ;;
+  *)
+    want_proxy_http3="yes"
+    curl_proxy_http3_msg="enabled (--disable-proxy-http3)"
+    AC_MSG_RESULT([yes])
+    ;;
+esac
+USE_PROXY_HTTP3=0
 
 XC_CHECK_PATH_SEPARATOR
 
@@ -318,6 +342,22 @@ AS_HELP_STRING([--with-test-caddy=PATH],[where to find caddy for testing]),
 )
 AC_SUBST(CADDY)
 
+if test -x /usr/local/bin/h2o; then
+  H2O=/usr/local/bin/h2o
+elif test -x /usr/bin/h2o; then
+  H2O=/usr/bin/h2o
+elif test -x "`brew --prefix 2>/dev/null`/bin/h2o"; then
+  H2O=`brew --prefix`/bin/h2o
+fi
+AC_ARG_WITH(test-h2o,dnl
+AS_HELP_STRING([--with-test-h2o=PATH],[where to find h2o for testing]),
+  H2O=$withval
+  if test "x$H2O" = "xno"; then
+    H2O=""
+  fi
+)
+AC_SUBST(H2O)
+
 if test -x /usr/sbin/vsftpd; then
   VSFTPD=/usr/sbin/vsftpd
 elif test -x /usr/local/sbin/vsftpd; then
@@ -5028,6 +5068,28 @@ if test "$want_ssls_export" != "no"; then
   fi
 fi
 
+dnl *************************************************************
+dnl check whether experimental HTTP/3 proxy support is enabled
+dnl
+if test "$want_proxy_http3" = "yes"; then
+  AC_MSG_CHECKING([whether HTTP/3 proxy support is available])
+
+  if test "$CURL_DISABLE_PROXY" = "1"; then
+    AC_MSG_ERROR([--enable-proxy-http3 requires proxy support])
+  elif test "$CURL_DISABLE_HTTP" = "1"; then
+    AC_MSG_ERROR([--enable-proxy-http3 requires HTTP support])
+  elif test "$USE_NGTCP2_H3" != "1"; then
+    AC_MSG_ERROR([--enable-proxy-http3 requires ngtcp2 + nghttp3])
+  elif test "x$OPENSSL_ENABLED" != "x1"; then
+    AC_MSG_ERROR([--enable-proxy-http3 currently requires OpenSSL])
+  else
+    AC_DEFINE(USE_PROXY_HTTP3, 1, [if HTTP/3 proxy support is available])
+    USE_PROXY_HTTP3=1
+    AC_MSG_RESULT([yes])
+    experimental="$experimental PROXY-HTTP3"
+  fi
+fi
+
 dnl ************************************************************
 dnl hiding of library internal symbols
 dnl
@@ -5141,6 +5203,10 @@ if test "$curl_psl_msg" = "enabled"; then
   SUPPORT_FEATURES="$SUPPORT_FEATURES PSL"
 fi
 
+if test "$USE_PROXY_HTTP3" = "1"; then
+  SUPPORT_FEATURES="$SUPPORT_FEATURES PROXY-HTTP3"
+fi
+
 if test "$curl_gsasl_msg" = "enabled"; then
   SUPPORT_FEATURES="$SUPPORT_FEATURES gsasl"
 fi
@@ -5485,6 +5551,7 @@ AC_MSG_NOTICE([Configured to build curl/libcurl:
   HTTP1:            ${curl_h1_msg}
   HTTP2:            ${curl_h2_msg}
   HTTP3:            ${curl_h3_msg}
+  Proxy-HTTP3:      ${curl_proxy_http3_msg}
   ECH:              ${curl_ech_msg}
   HTTPS RR:         ${curl_httpsrr_msg}
   SSLS-EXPORT:      ${curl_ssls_export_msg}
index 43fc0fdeed885dcf1d0bcc789fa9f86d6f0f596c..ca8277fa14ca0ca00029d204b85652f4f4d2f552 100644 (file)
@@ -43,6 +43,16 @@ Graduation requirements:
 
 - Using HTTP/3 with the given build should perform without risking busy-loops
 
+### HTTP/3 proxy and CONNECT-UDP support
+
+Support for HTTP/3 proxy and CONNECT-UDP tunneling is experimental and
+requires an explicit build-time opt-in (`--enable-proxy-http3` for
+autotools, `-DUSE_PROXY_HTTP3=ON` for CMake).
+
+Graduation requirements:
+
+- implementation stability over time with no known severe regressions
+
 ### The Rustls backend
 
 Graduation requirements:
index e07ec455dad8506a6fdf0f61fe7e58874ef04c03..83eb9df68e8f1fedc06363c6f4eefed1752cf525 100644 (file)
@@ -254,6 +254,7 @@ target_link_libraries(my_target PRIVATE CURL::libcurl)
 - `USE_SSLS_EXPORT`:                        Enable experimental SSL session import/export. Default: `OFF`
 - `USE_WIN32_IDN`:                          Use WinIDN for IDN support. Default: `OFF`
 - `USE_WIN32_LDAP`:                         Use Windows LDAP implementation. Default: `ON`
+- `USE_PROXY_HTTP3`:                        Enable experimental HTTP/3 proxy support. Default: `OFF`
 
 ## Disabling features
 
index f7236af1b12707ac8242cfd30a34bcbf0e9e8b66..f8fd01ccd63e11d6ce9a75df2a78daf44b047cc7 100644 (file)
@@ -212,6 +212,7 @@ DPAGES = \
   proxy-digest.md \
   proxy-header.md \
   proxy-http2.md \
+  proxy-http3.md \
   proxy-insecure.md \
   proxy-key-type.md \
   proxy-key.md \
index ca6a091f328ecd17df3415b3779dfaf0aaf1e38b..a38da9e87ed874f1bd9b91003256f8bac90d7afe 100644 (file)
@@ -5,7 +5,7 @@ Long: proxy-http2
 Tags: Versions HTTP/2
 Protocols: HTTP
 Added: 8.1.0
-Mutexed:
+Mutexed: proxy-http3
 Requires: HTTP/2
 Help: Use HTTP/2 with HTTPS proxy
 Category: http proxy
@@ -22,3 +22,5 @@ Negotiate HTTP/2 with an HTTPS proxy. The proxy might still only offer HTTP/1
 and then curl sticks to using that version.
 
 This has no effect for any other kinds of proxies.
+
+This option is mutually exclusive with `--proxy-http3`.
diff --git a/docs/cmdline-opts/proxy-http3.md b/docs/cmdline-opts/proxy-http3.md
new file mode 100644 (file)
index 0000000..6533b98
--- /dev/null
@@ -0,0 +1,31 @@
+---
+c: Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
+SPDX-License-Identifier: curl
+Long: proxy-http3
+Tags: Versions HTTP/3
+Protocols: HTTP
+Added: 8.21.0
+Mutexed: proxy-http2
+Requires: HTTP/3
+Help: Use HTTP/3 with HTTPS proxy
+Category: http proxy
+Multi: boolean
+See-also:
+  - proxy
+  - proxy-http2
+Example:
+  - --proxy-http3 -x proxy $URL
+---
+
+# `--proxy-http3`
+
+Negotiate HTTP/3 with an HTTPS proxy.
+Fails to perform the transfer if the given proxy does not support HTTP/3.
+
+This has no effect for any other kinds of proxies.
+
+This option is mutually exclusive with `--proxy-http2`.
+
+This feature is experimental and requires a build with HTTP/3 proxy support
+enabled. For autotools builds, use `--enable-proxy-http3`. For CMake builds,
+use `-DUSE_PROXY_HTTP3=ON`.
index 619ca0e3407d0dfd18ba705fce5c71e69d51c9e6..1a817a15672a94f8b9e595cf71d2a3f8c81b5ec1 100644 (file)
@@ -156,9 +156,9 @@ The currently existing filter types (curl 8.5.0) are:
   `accept()`ed in a `listen()`
 * `SSL`: filter that applies TLS en-/decryption and handshake. Manages the
   underlying TLS backend implementation.
-* `HTTP-PROXY`, `H1-PROXY`, `H2-PROXY`: the first manages the connection to an
-  HTTP proxy server and uses the other depending on which ALPN protocol has
-  been negotiated.
+* `HTTP-PROXY`, `H1-PROXY`, `H2-PROXY`, `H3-PROXY`: the first manages the
+   connection to an HTTP proxy server and uses the other depending on which
+   ALPN protocol has been negotiated.
 * `SOCKS-PROXY`: filter for the various SOCKS proxy protocol variations
 * `HAPROXY`: filter for the protocol of the same name, providing client IP
   information to a server.
@@ -166,7 +166,7 @@ The currently existing filter types (curl 8.5.0) are:
   connection
 * `HTTP/3`: filter for handling multiplexed transfers over an HTTP/3+QUIC
   connection
-* `HAPPY-EYEBALLS`: meta filter that implements IPv4/IPv6 "happy eyeballing".
+* `HAPPY-EYEBALLS`: meta filter that implements IPv4/IPv6 "happy eyeballs".
   It creates up to 2 sub-filters that race each other for a connection.
 * `SETUP`: meta filter that manages the creation of sub-filter chains for a
   specific transport (e.g. TCP or QUIC).
@@ -220,6 +220,37 @@ as an `SSL` flagged filter is seen first. `conn3` is also encrypted as the
 
 Similar checks can determine if a connection is multiplexed or not.
 
+## Adding CONNECT-UDP support
+HTTP/3 on top of HTTP/1.1 (MASQUE CONNECT-UDP):
+```
+conn --> HTTP/3 --> CAPSULE --> HTTP-PROXY --> H1-PROXY --> SSL --> HAPPY-EYEBALLS --> TCP
+```
+
+HTTP/3 on top of HTTP/2 (MASQUE CONNECT-UDP):
+```
+conn --> HTTP/3 --> CAPSULE --> HTTP-PROXY --> H2-PROXY --> SSL --> HAPPY-EYEBALLS --> TCP
+```
+
+The CAPSULE filter handles RFC 9297 capsule protocol encapsulation and
+decapsulation of UDP datagrams. It is inserted automatically when the
+HTTP-PROXY filter completes a successful CONNECT-UDP tunnel.
+
+## Adding H3-PROXY support
+HTTP/1.1 on top of HTTP/3 (CONNECT over QUIC):
+```
+conn --> HTTP/1.1 --> SSL --> HTTP-PROXY --> H3-PROXY --> HAPPY-EYEBALLS --> UDP
+```
+
+HTTP/2 on top of HTTP/3 (CONNECT over QUIC):
+```
+conn --> HTTP/2 --> SSL --> HTTP-PROXY --> H3-PROXY --> HAPPY-EYEBALLS --> UDP
+```
+
+HTTP/3 on top of HTTP/3 (MASQUE CONNECT-UDP over QUIC):
+```
+conn --> HTTP/3 --> CAPSULE --> HTTP-PROXY --> H3-PROXY --> HAPPY-EYEBALLS --> UDP
+```
+
 ## Filter Tracing
 
 Filters may make use of special trace macros like `CURL_TRC_CF(data, cf, msg,
index fd589a834cc30d32014b49ae4ffe831b6b5c27f7..ec29fa66e7785bac3ef8e440aefb3430f00b4950 100644 (file)
@@ -298,6 +298,13 @@ supports HTTP NTLM
 libcurl was built with support for NTLM delegation to a winbind helper. This
 feature was removed from curl in 8.8.0.
 
+## `PROXY-HTTP3`
+
+*features* mask bit: non-existent
+
+libcurl was built with EXPERIMENTAL support for HTTP/3 proxy tunneling
+(Added in 8.21.0)
+
 ## `PSL`
 
 *features* mask bit: CURL_VERSION_PSL
index 7be874d73332d446d2e3b82aa8a372200160eda3..072dfd48093ea0155783b92c6211a38092beb7dc 100644 (file)
@@ -58,7 +58,11 @@ HTTPS Proxy. (with OpenSSL, GnuTLS, mbedTLS, Rustls, Schannel or wolfSSL.)
 This uses HTTP/1 by default. Setting CURLOPT_PROXYTYPE(3) to
 **CURLPROXY_HTTPS2** allows libcurl to negotiate using HTTP/2 with proxy.
 
-## `socks4://`
+Setting CURLOPT_PROXYTYPE(3) to **CURLPROXY_HTTPS3** allows libcurl to
+negotiate using HTTP/3 with proxy. This feature is experimental and requires
+a build with HTTP/3 proxy support enabled.
+
+## socks4://
 
 SOCKS4 Proxy.
 
index 6000d10b00bceaa73d08504963b0c419b5b3e736..1dc1a1328a0115b3a7603263e12a0e44ed45a2dd 100644 (file)
@@ -41,6 +41,12 @@ HTTPS Proxy using HTTP/1. (Added in 7.52.0 for OpenSSL and GnuTLS. Since
 
 HTTPS Proxy and attempt to speak HTTP/2 over it. (Added in 8.1.0)
 
+## CURLPROXY_HTTPS3
+
+HTTPS Proxy and attempt to speak HTTP/3 over it. (Added in 8.21.0)
+This feature is experimental and requires a build with HTTP/3 proxy support
+enabled.
+
 ## CURLPROXY_HTTP_1_0
 
 HTTP 1.0 Proxy. This is similar to CURLPROXY_HTTP except it uses HTTP/1.0 for
index 4dc670da6eee59dd4abb1bf67165f756e55063e6..5bad9a98425df5bff5314c8cfdbcce12b972cac6 100644 (file)
@@ -993,6 +993,7 @@ CURLPROXY_HTTP                  7.10
 CURLPROXY_HTTP_1_0              7.19.4
 CURLPROXY_HTTPS                 7.52.0
 CURLPROXY_HTTPS2                8.1.0
+CURLPROXY_HTTPS3                8.21.0
 CURLPROXY_SOCKS4                7.10
 CURLPROXY_SOCKS4A               7.18.0
 CURLPROXY_SOCKS5                7.10
index 95d84a4bfec21093a7a6886e3fd23a8482d57ab1..fa20b2dd29b2844b41d2b85c0927679f154338e8 100644 (file)
 --proxy-digest                       7.12.0
 --proxy-header                       7.37.0
 --proxy-http2                        8.1.0
+--proxy-http3                        8.21.0
 --proxy-insecure                     7.52.0
 --proxy-key                          7.52.0
 --proxy-key-type                     7.52.0
index 88fb9a0c4b4ea35cb97bc49ed2d9080967135e8c..79f3fac200547e57891b18f8cf3489b8cd95623b 100644 (file)
@@ -62,6 +62,9 @@ Via curl's `configure` script you may specify:
   * `--with-test-nghttpx=<path-of-nghttpx>` if you have nghttpx to use
    somewhere outside your `$PATH`.
 
+  * `--with-test-h2o=<path-of-h2o>` if you have h2o to use somewhere
+   outside your `$PATH`.
+
   * `--with-test-httpd=<httpd-install-path>` if you have an Apache httpd
    installed somewhere else. On Debian/Ubuntu it otherwise looks into
    `/usr/bin` and `/usr/sbin` to find those.
index cb36eefad4639d4f12b8e1f4b6612e2b4787034f..c790760b88bd563323475da8eec9634855b824b5 100644 (file)
@@ -802,9 +802,11 @@ typedef CURLcode (*curl_ssl_ctx_callback)(CURL *curl,    /* easy handle */
 #define CURLPROXY_SOCKS5_HOSTNAME 7L /* Use the SOCKS5 protocol but pass along
                                         the hostname rather than the IP
                                         address. added in 7.18.0 */
+#define CURLPROXY_HTTPS3          8L /* HTTPS and attempt HTTP/3
+                                        added in 8.21.0 */
 
 typedef enum {
-  CURLPROXY_LAST = 8 /* never use */
+  CURLPROXY_LAST = 9 /* never use */
 } curl_proxytype;  /* this enum was added in 7.10 */
 
 /*
@@ -1494,8 +1496,8 @@ typedef enum {
   CURLOPT(CURLOPT_SHARE, CURLOPTTYPE_OBJECTPOINT, 100),
 
   /* indicates type of proxy. accepted values are CURLPROXY_HTTP (default),
-     CURLPROXY_HTTPS, CURLPROXY_SOCKS4, CURLPROXY_SOCKS4A and
-     CURLPROXY_SOCKS5. */
+     CURLPROXY_HTTPS, CURLPROXY_HTTPS2, CURLPROXY_HTTPS3, CURLPROXY_SOCKS4,
+     CURLPROXY_SOCKS4A and CURLPROXY_SOCKS5. */
   CURLOPT(CURLOPT_PROXYTYPE, CURLOPTTYPE_VALUES, 101),
 
   /* Set the Accept-Encoding string. Use this to tell a server you would like
index 2c7259af0dd4cd55cd7933335f8ba1afdea260ba..0a9e6ce3114304edb50920a25563fdb70dc1cb9f 100644 (file)
@@ -150,8 +150,11 @@ LIB_CFILES =         \
   bufq.c             \
   bufref.c           \
   cf-dns.c           \
+  capsule.c          \
+  cf-capsule.c       \
   cf-h1-proxy.c      \
   cf-h2-proxy.c      \
+  cf-h3-proxy.c      \
   cf-haproxy.c       \
   cf-https-connect.c \
   cf-ip-happy.c      \
@@ -282,8 +285,11 @@ LIB_HFILES =         \
   bufq.h             \
   bufref.h           \
   cf-dns.h           \
+  capsule.h          \
+  cf-capsule.h       \
   cf-h1-proxy.h      \
   cf-h2-proxy.h      \
+  cf-h3-proxy.h      \
   cf-haproxy.h       \
   cf-https-connect.h \
   cf-ip-happy.h      \
diff --git a/lib/capsule.c b/lib/capsule.c
new file mode 100644 (file)
index 0000000..698cdcd
--- /dev/null
@@ -0,0 +1,281 @@
+/***************************************************************************
+ *                                  _   _ ____  _
+ *  Project                     ___| | | |  _ \| |
+ *                             / __| | | | |_) | |
+ *                            | (__| |_| |  _ <| |___
+ *                             \___|\___/|_| \_\_____|
+ *
+ * Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
+ *
+ * This software is licensed as described in the file COPYING, which
+ * you should have received as part of this distribution. The terms
+ * are also available at https://curl.se/docs/copyright.html.
+ *
+ * You may opt to use, copy, modify, merge, publish, distribute and/or sell
+ * copies of the Software, and permit persons to whom the Software is
+ * furnished to do so, under the terms of the COPYING file.
+ *
+ * This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
+ * KIND, either express or implied.
+ *
+ * SPDX-License-Identifier: curl
+ *
+ ***************************************************************************/
+
+#include "curl_setup.h"
+
+#if !defined(CURL_DISABLE_PROXY) && !defined(CURL_DISABLE_HTTP)
+
+#include <curl/curl.h>
+#include "urldata.h"
+#include "curlx/dynbuf.h"
+#include "cfilters.h"
+#include "curl_trc.h"
+#include "bufq.h"
+#include "capsule.h"
+
+
+/**
+ * Convert 64-bit value from network byte order to host byte order
+ */
+static uint64_t capsule_ntohll(uint64_t value)
+{
+#if defined(__BYTE_ORDER__) && (__BYTE_ORDER__ == __ORDER_BIG_ENDIAN__)
+  return value;
+#elif (defined(__GNUC__) || defined(__clang__)) && \
+      defined(__BYTE_ORDER__) && (__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__)
+  return __builtin_bswap64(value);
+#else
+  union {
+      uint64_t u64;
+      uint32_t u32[2];
+  } src, dst;
+
+  src.u64 = value;
+  dst.u32[0] = ntohl(src.u32[1]);
+  dst.u32[1] = ntohl(src.u32[0]);
+  return dst.u64;
+#endif
+}
+
+/**
+ * Encode a variable-length integer into a plain buffer.
+ * @param buf   Output buffer (must have at least 8 bytes)
+ * @param value Value to encode (must be <= 0x3FFFFFFFFFFFFFFF)
+ * @return Number of bytes written
+ */
+static size_t capsule_encode_varint_buf(uint8_t *buf, uint64_t value)
+{
+  DEBUGASSERT(value <= 0x3FFFFFFFFFFFFFFF);
+
+  if(value <= 0x3F) {
+    buf[0] = (uint8_t)value;
+    return 1;
+  }
+  else if(value <= 0x3FFF) {
+    uint16_t encoded = (uint16_t)value & 0x3FFF;
+    encoded = ntohs(encoded | 0x4000);
+    memcpy(buf, &encoded, 2);
+    return 2;
+  }
+  else if(value <= 0x3FFFFFFF) {
+    uint32_t encoded = (uint32_t)value & 0x3FFFFFFF;
+    encoded = ntohl(encoded | 0x80000000);
+    memcpy(buf, &encoded, 4);
+    return 4;
+  }
+  else {
+    uint64_t encoded = (uint64_t)value & 0x3FFFFFFFFFFFFFFF;
+    encoded = capsule_ntohll(encoded | 0xC000000000000000);
+    memcpy(buf, &encoded, 8);
+    return 8;
+  }
+}
+
+static CURLcode capsule_peek_u8(struct bufq *recvbufq,
+                                size_t offset,
+                                uint8_t *pbyte)
+{
+  const unsigned char *peek = NULL;
+  size_t peeklen = 0;
+
+  if(!Curl_bufq_peek_at(recvbufq, offset, &peek, &peeklen) || !peeklen)
+    return CURLE_AGAIN;
+  *pbyte = peek[0];
+  return CURLE_OK;
+}
+
+static CURLcode capsule_decode_varint_at(struct bufq *recvbufq,
+                                         size_t offset,
+                                         uint64_t *pvalue,
+                                         size_t *pconsumed)
+{
+  uint8_t first_byte, byte;
+  uint64_t value;
+  size_t nbytes;
+  size_t i;
+  CURLcode result;
+
+  result = capsule_peek_u8(recvbufq, offset, &first_byte);
+  if(result)
+    return result;
+
+  nbytes = (size_t)1 << (first_byte >> 6); /* 1, 2, 4 or 8 bytes */
+  value = first_byte & 0x3F;
+
+  for(i = 1; i < nbytes; ++i) {
+    result = capsule_peek_u8(recvbufq, offset + i, &byte);
+    if(result)
+      return result;
+    value = (value << 8) | byte;
+  }
+
+  *pvalue = value;
+  *pconsumed = nbytes;
+  return CURLE_OK;
+}
+
+size_t Curl_capsule_encap_udp_hdr(uint8_t *hdr, size_t hdrlen,
+                                  size_t payload_len)
+{
+  size_t off = 0;
+  DEBUGASSERT(hdrlen >= HTTP_CAPSULE_HEADER_MAX_SIZE);
+  if(hdrlen < HTTP_CAPSULE_HEADER_MAX_SIZE)
+    return 0;
+  hdr[off++] = 0; /* capsule type: HTTP Datagram */
+  off += capsule_encode_varint_buf(hdr + off, (uint64_t)payload_len + 1);
+  hdr[off++] = 0; /* context ID */
+  return off;
+}
+
+CURLcode Curl_capsule_encap_udp_datagram(struct dynbuf *dyn,
+                                         const void *buf, size_t blen)
+{
+  CURLcode result;
+  uint8_t hdr[HTTP_CAPSULE_HEADER_MAX_SIZE];
+  size_t hdr_len;
+
+  curlx_dyn_init(dyn, HTTP_CAPSULE_HEADER_MAX_SIZE + blen);
+  hdr_len = Curl_capsule_encap_udp_hdr(hdr, sizeof(hdr), blen);
+  DEBUGASSERT(hdr_len);
+  if(!hdr_len)
+    return CURLE_FAILED_INIT;
+
+  result = curlx_dyn_addn(dyn, hdr, hdr_len);
+  if(result)
+    return result;
+
+  return curlx_dyn_addn(dyn, buf, blen);
+}
+
+size_t Curl_capsule_process_udp_raw(struct Curl_cfilter *cf,
+                                    struct Curl_easy *data,
+                                    struct bufq *recvbufq,
+                                    unsigned char *buf, size_t len,
+                                    CURLcode *err)
+{
+  const unsigned char *context_id, *capsule_type;
+  size_t read_size, varint_len;
+  uint64_t capsule_length;
+  size_t offset, payload_len;
+  size_t bytes_read = 0;
+  CURLcode result = CURLE_OK;
+
+  if(!len) {
+    *err = CURLE_BAD_FUNCTION_ARGUMENT;
+    return 0;
+  }
+
+  if(Curl_bufq_is_empty(recvbufq)) {
+    *err = CURLE_AGAIN;
+    return 0;
+  }
+
+  if(!Curl_bufq_peek(recvbufq, &capsule_type, &read_size) || !read_size) {
+    *err = CURLE_AGAIN;
+    return 0;
+  }
+
+  if(capsule_type[0]) {
+    infof(data, "Error! Invalid capsule type: %d", capsule_type[0]);
+    Curl_bufq_skip(recvbufq, 1);
+    *err = CURLE_RECV_ERROR;
+    return 0;
+  }
+
+  offset = 1;
+  result = capsule_decode_varint_at(recvbufq, offset, &capsule_length,
+                                    &varint_len);
+  if(result == CURLE_AGAIN) {
+    *err = CURLE_AGAIN;
+    return 0;
+  }
+  else if(result) {
+    *err = CURLE_RECV_ERROR;
+    return 0;
+  }
+  offset += varint_len;
+
+  if(!Curl_bufq_peek_at(recvbufq, offset, &context_id, &read_size) ||
+     !read_size) {
+    *err = CURLE_AGAIN;
+    return 0;
+  }
+
+  if(*context_id) {
+    infof(data, "Error! Invalid context ID: %02x", *context_id);
+    Curl_bufq_skip(recvbufq, offset + 1);
+    *err = CURLE_RECV_ERROR;
+    return 0;
+  }
+  offset += 1;
+
+  if(!capsule_length) {
+    infof(data, "Error! Invalid capsule length: 0");
+    Curl_bufq_skip(recvbufq, offset);
+    *err = CURLE_RECV_ERROR;
+    return 0;
+  }
+  if(capsule_length - 1 >= (uint64_t)SIZE_MAX) {
+    infof(data, "Error! Capsule length too large: %" CURL_FORMAT_CURL_OFF_T,
+          (curl_off_t)capsule_length);
+    *err = CURLE_RECV_ERROR;
+    return 0;
+  }
+  payload_len = (size_t)(capsule_length - 1);
+
+  if(Curl_bufq_len(recvbufq) < offset + payload_len) {
+    *err = CURLE_AGAIN;
+    return 0;
+  }
+
+  if(payload_len > len) {
+    infof(data, "UDP payload does not fit destination buffer: %zu > %zu",
+          payload_len, len);
+    Curl_bufq_skip(recvbufq, offset + payload_len);
+    *err = CURLE_RECV_ERROR;
+    return 0;
+  }
+
+  Curl_bufq_skip(recvbufq, offset);
+  if(!payload_len) {
+    *err = CURLE_OK;
+    return 0;
+  }
+  result = Curl_bufq_read(recvbufq, buf, payload_len, &bytes_read);
+  if(result || (bytes_read != payload_len)) {
+    infof(data, "Error! Read less than expected %zu %zu",
+          payload_len, bytes_read);
+    *err = CURLE_RECV_ERROR;
+    return 0;
+  }
+
+  if(cf && data) {
+    CURL_TRC_CF(data, cf, "Processed UDP capsule raw: size=%zu "
+                "length_left %zu", payload_len, Curl_bufq_len(recvbufq));
+  }
+  *err = CURLE_OK;
+  return bytes_read;
+}
+
+#endif /* !CURL_DISABLE_PROXY && !CURL_DISABLE_HTTP */
diff --git a/lib/capsule.h b/lib/capsule.h
new file mode 100644 (file)
index 0000000..fa7dec1
--- /dev/null
@@ -0,0 +1,77 @@
+#ifndef HEADER_CURL_CAPSULE_H
+#define HEADER_CURL_CAPSULE_H
+/***************************************************************************
+ *                                  _   _ ____  _
+ *  Project                     ___| | | |  _ \| |
+ *                             / __| | | | |_) | |
+ *                            | (__| |_| |  _ <| |___
+ *                             \___|\___/|_| \_\_____|
+ *
+ * Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
+ *
+ * This software is licensed as described in the file COPYING, which
+ * you should have received as part of this distribution. The terms
+ * are also available at https://curl.se/docs/copyright.html.
+ *
+ * You may opt to use, copy, modify, merge, publish, distribute and/or sell
+ * copies of the Software, and permit persons to whom the Software is
+ * furnished to do so, under the terms of the COPYING file.
+ *
+ * This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
+ * KIND, either express or implied.
+ *
+ * SPDX-License-Identifier: curl
+ *
+ ***************************************************************************/
+
+#include "curl_setup.h"
+
+#if !defined(CURL_DISABLE_PROXY) && !defined(CURL_DISABLE_HTTP)
+
+#include "curlx/dynbuf.h"
+#include "bufq.h"
+
+/* HTTP Capsule constants */
+#define HTTP_CAPSULE_HEADER_MAX_SIZE    10
+
+/* HTTP Capsule function prototypes */
+
+/**
+ * Write the capsule header (type + varint length + context ID) into `hdr`.
+ * @param hdr         Output buffer (must be >= HTTP_CAPSULE_HEADER_MAX_SIZE)
+ * @param hdrlen      Size of `hdr` in bytes
+ * @param payload_len Length of the UDP payload that follows
+ * @return Number of header bytes written, or 0 on error
+ */
+size_t Curl_capsule_encap_udp_hdr(uint8_t *hdr, size_t hdrlen,
+                                  size_t payload_len);
+
+/**
+ * Encapsulate UDP payload into HTTP Datagram capsule format
+ * @param dyn   Dynamic buffer to write capsule to
+ * @param buf   Payload buffer
+ * @param blen  Payload buffer length
+ * @return CURLE_OK on success, error code on failure
+ */
+CURLcode Curl_capsule_encap_udp_datagram(struct dynbuf *dyn,
+                                         const void *buf, size_t blen);
+
+/**
+ * Process one UDP capsule from buffer into raw datagram payload bytes.
+ * @param cf        Connection filter
+ * @param data      Easy handle
+ * @param recvbufq  Buffer queue containing capsule data
+ * @param buf       Output buffer for one datagram payload
+ * @param len       Size of output buffer in bytes
+ * @param err       Error code output
+ * @return Number of payload bytes written. Check `err` for status.
+ */
+size_t Curl_capsule_process_udp_raw(struct Curl_cfilter *cf,
+                                    struct Curl_easy *data,
+                                    struct bufq *recvbufq,
+                                    unsigned char *buf, size_t len,
+                                    CURLcode *err);
+
+#endif /* !CURL_DISABLE_PROXY && !CURL_DISABLE_HTTP */
+
+#endif /* HEADER_CURL_CAPSULE_H */
diff --git a/lib/cf-capsule.c b/lib/cf-capsule.c
new file mode 100644 (file)
index 0000000..dd740c0
--- /dev/null
@@ -0,0 +1,253 @@
+/***************************************************************************
+ *                                  _   _ ____  _
+ *  Project                     ___| | | |  _ \| |
+ *                             / __| | | | |_) | |
+ *                            | (__| |_| |  _ <| |___
+ *                             \___|\___/|_| \_\_____|
+ *
+ * Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
+ *
+ * This software is licensed as described in the file COPYING, which
+ * you should have received as part of this distribution. The terms
+ * are also available at https://curl.se/docs/copyright.html.
+ *
+ * You may opt to use, copy, modify, merge, publish, distribute and/or sell
+ * copies of the Software, and permit persons to whom the Software is
+ * furnished to do so, under the terms of the COPYING file.
+ *
+ * This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
+ * KIND, either express or implied.
+ *
+ * SPDX-License-Identifier: curl
+ *
+ ***************************************************************************/
+#include "curl_setup.h"
+
+#if !defined(CURL_DISABLE_PROXY) && !defined(CURL_DISABLE_HTTP)
+
+#include <curl/curl.h>
+#include "urldata.h"
+#include "cfilters.h"
+#include "curl_trc.h"
+#include "curlx/dynbuf.h"
+#include "bufq.h"
+#include "capsule.h"
+#include "cf-capsule.h"
+
+/* recv buffer: 4 chunks of 16KB = 64KB, enough for large datagrams */
+#define CAPSULE_RECV_CHUNKS    4
+#define CAPSULE_CHUNK_SIZE     (16 * 1024)
+
+struct cf_capsule_ctx {
+  struct bufq recvbuf;
+  struct cf_call_data call_data;
+  unsigned char *pending;       /* unsent capsule bytes from partial write */
+  size_t pending_len;           /* total length of pending buffer */
+  size_t pending_offset;        /* bytes already sent from pending */
+  size_t pending_payload;       /* original payload len for pending capsule */
+};
+
+static void capsule_cf_destroy(struct Curl_cfilter *cf,
+                               struct Curl_easy *data)
+{
+  struct cf_capsule_ctx *ctx = cf->ctx;
+  (void)data;
+  if(ctx) {
+    Curl_bufq_free(&ctx->recvbuf);
+    curlx_free(ctx->pending);
+    curlx_safefree(ctx);
+  }
+}
+
+static void capsule_cf_close(struct Curl_cfilter *cf,
+                             struct Curl_easy *data)
+{
+  struct cf_capsule_ctx *ctx = cf->ctx;
+
+  CURL_TRC_CF(data, cf, "close");
+  cf->connected = FALSE;
+  if(ctx) {
+    Curl_bufq_reset(&ctx->recvbuf);
+    curlx_safefree(ctx->pending);
+    ctx->pending_len = 0;
+    ctx->pending_offset = 0;
+    ctx->pending_payload = 0;
+  }
+  if(cf->next)
+    cf->next->cft->do_close(cf->next, data);
+}
+
+static CURLcode capsule_cf_connect(struct Curl_cfilter *cf,
+                                   struct Curl_easy *data,
+                                   bool *done)
+{
+  if(cf->connected) {
+    *done = TRUE;
+    return CURLE_OK;
+  }
+  if(cf->next) {
+    CURLcode result = cf->next->cft->do_connect(cf->next, data, done);
+    if(!result && *done)
+      cf->connected = TRUE;
+    return result;
+  }
+  *done = FALSE;
+  return CURLE_OK;
+}
+
+static CURLcode capsule_cf_send(struct Curl_cfilter *cf,
+                                struct Curl_easy *data,
+                                const uint8_t *buf, size_t len,
+                                bool eos, size_t *pnwritten)
+{
+  struct cf_capsule_ctx *ctx = cf->ctx;
+  struct dynbuf dyn;
+  size_t nwritten = 0;
+  size_t capsule_len;
+  size_t remaining;
+  CURLcode result;
+
+  (void)eos;
+  *pnwritten = 0;
+
+  if(ctx->pending) {
+    /* flush remaining bytes from a partially sent capsule */
+    remaining = ctx->pending_len - ctx->pending_offset;
+    result = Curl_conn_cf_send(cf->next, data,
+                               ctx->pending + ctx->pending_offset,
+                               remaining, FALSE, &nwritten);
+    if(result && result != CURLE_AGAIN) {
+      curlx_safefree(ctx->pending);
+      return result;
+    }
+    ctx->pending_offset += nwritten;
+    if(ctx->pending_offset < ctx->pending_len)
+      return CURLE_AGAIN;
+    /* pending capsule has been fully flusehd */
+    *pnwritten = ctx->pending_payload;
+    curlx_safefree(ctx->pending);
+    return CURLE_OK;
+  }
+
+  /* encapsulate new payload into a capsule */
+  result = Curl_capsule_encap_udp_datagram(&dyn, buf, len);
+  if(result) {
+    curlx_dyn_free(&dyn);
+    return result;
+  }
+  capsule_len = curlx_dyn_len(&dyn);
+
+  result = Curl_conn_cf_send(cf->next, data,
+                             (const uint8_t *)curlx_dyn_ptr(&dyn),
+                             capsule_len, FALSE, &nwritten);
+  if(result && result != CURLE_AGAIN) {
+    curlx_dyn_free(&dyn);
+    return result;
+  }
+
+  if(nwritten < capsule_len) {
+    /* partial or zero write - save unsent capsule bytes as pending */
+    remaining = capsule_len - nwritten;
+    ctx->pending = curlx_malloc(remaining);
+    if(!ctx->pending) {
+      curlx_dyn_free(&dyn);
+      return CURLE_OUT_OF_MEMORY;
+    }
+    memcpy(ctx->pending,
+           curlx_dyn_ptr(&dyn) + nwritten, remaining);
+    ctx->pending_len = remaining;
+    ctx->pending_offset = 0;
+    ctx->pending_payload = len;
+    curlx_dyn_free(&dyn);
+    return CURLE_AGAIN;
+  }
+
+  /* entire capsule sent */
+  curlx_dyn_free(&dyn);
+  *pnwritten = len;
+  return CURLE_OK;
+}
+
+static CURLcode capsule_cf_recv(struct Curl_cfilter *cf,
+                                struct Curl_easy *data,
+                                char *buf, size_t len,
+                                size_t *pnread)
+{
+  struct cf_capsule_ctx *ctx = cf->ctx;
+  CURLcode result;
+  size_t nread;
+
+  *pnread = 0;
+
+  /* fill our receive buffer from the filter below */
+  while(!Curl_bufq_is_full(&ctx->recvbuf)) {
+    result = Curl_cf_recv_bufq(cf->next, data, &ctx->recvbuf, 0, &nread);
+    if(result == CURLE_AGAIN)
+      break;
+    if(result)
+      return result;
+    if(!nread)
+      break;
+  }
+
+  /* try to extract a complete capsule datagram */
+  *pnread = Curl_capsule_process_udp_raw(cf, data, &ctx->recvbuf,
+                                         (unsigned char *)buf, len,
+                                         &result);
+  return result;
+}
+
+static bool capsule_cf_data_pending(struct Curl_cfilter *cf,
+                                    const struct Curl_easy *data)
+{
+  struct cf_capsule_ctx *ctx = cf->ctx;
+
+  if(ctx && !Curl_bufq_is_empty(&ctx->recvbuf))
+    return TRUE;
+  return cf->next ? cf->next->cft->has_data_pending(cf->next, data) : FALSE;
+}
+
+struct Curl_cftype Curl_cft_capsule = {
+  "CAPSULE",
+  0,
+  0,
+  capsule_cf_destroy,
+  capsule_cf_connect,
+  capsule_cf_close,
+  Curl_cf_def_shutdown,
+  Curl_cf_def_adjust_pollset,
+  capsule_cf_data_pending,
+  capsule_cf_send,
+  capsule_cf_recv,
+  Curl_cf_def_cntrl,
+  Curl_cf_def_conn_is_alive,
+  Curl_cf_def_conn_keep_alive,
+  Curl_cf_def_query,
+};
+
+CURLcode Curl_cf_capsule_insert_after(struct Curl_cfilter *cf_at,
+                                      struct Curl_easy *data)
+{
+  struct Curl_cfilter *cf;
+  struct cf_capsule_ctx *ctx;
+  CURLcode result;
+
+  (void)data;
+  ctx = curlx_calloc(1, sizeof(*ctx));
+  if(!ctx)
+    return CURLE_OUT_OF_MEMORY;
+
+  Curl_bufq_init2(&ctx->recvbuf, CAPSULE_CHUNK_SIZE, CAPSULE_RECV_CHUNKS,
+                   BUFQ_OPT_SOFT_LIMIT);
+
+  result = Curl_cf_create(&cf, &Curl_cft_capsule, ctx);
+  if(result) {
+    Curl_bufq_free(&ctx->recvbuf);
+    curlx_free(ctx);
+    return result;
+  }
+  Curl_conn_cf_insert_after(cf_at, cf);
+  return CURLE_OK;
+}
+
+#endif /* !CURL_DISABLE_PROXY && !CURL_DISABLE_HTTP */
diff --git a/lib/cf-capsule.h b/lib/cf-capsule.h
new file mode 100644 (file)
index 0000000..437c968
--- /dev/null
@@ -0,0 +1,40 @@
+#ifndef HEADER_CURL_CF_CAPSULE_H
+#define HEADER_CURL_CF_CAPSULE_H
+/***************************************************************************
+ *                                  _   _ ____  _
+ *  Project                     ___| | | |  _ \| |
+ *                             / __| | | | |_) | |
+ *                            | (__| |_| |  _ <| |___
+ *                             \___|\___/|_| \_\_____|
+ *
+ * Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
+ *
+ * This software is licensed as described in the file COPYING, which
+ * you should have received as part of this distribution. The terms
+ * are also available at https://curl.se/docs/copyright.html.
+ *
+ * You may opt to use, copy, modify, merge, publish, distribute and/or sell
+ * copies of the Software, and permit persons to whom the Software is
+ * furnished to do so, under the terms of the COPYING file.
+ *
+ * This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
+ * KIND, either express or implied.
+ *
+ * SPDX-License-Identifier: curl
+ *
+ ***************************************************************************/
+#include "curl_setup.h"
+
+#if !defined(CURL_DISABLE_PROXY) && !defined(CURL_DISABLE_HTTP)
+
+/* Insert a capsule protocol filter after `cf_at` in the filter chain.
+ * The capsule filter encapsulates/decapsulates UDP datagrams using
+ * the HTTP Datagram capsule format (RFC 9297). */
+CURLcode Curl_cf_capsule_insert_after(struct Curl_cfilter *cf_at,
+                                      struct Curl_easy *data);
+
+extern struct Curl_cftype Curl_cft_capsule;
+
+#endif /* !CURL_DISABLE_PROXY && !CURL_DISABLE_HTTP */
+
+#endif /* HEADER_CURL_CF_CAPSULE_H */
index 0f1c392d484794fa5996dfa8c399c7d72e201dfe..5dd02b2b0a0649931b8864d9ff8a5ffe993b79e1 100644 (file)
@@ -25,6 +25,8 @@
 
 #if !defined(CURL_DISABLE_PROXY) && !defined(CURL_DISABLE_HTTP)
 
+
+#include <curl/curl.h>
 #include "urldata.h"
 #include "curlx/dynbuf.h"
 #include "sendf.h"
@@ -33,6 +35,7 @@
 #include "http_proxy.h"
 #include "select.h"
 #include "progress.h"
+#include "multiif.h"
 #include "cfilters.h"
 #include "cf-h1-proxy.h"
 #include "connect.h"
@@ -40,7 +43,6 @@
 #include "strcase.h"
 #include "curlx/strparse.h"
 
-
 typedef enum {
   H1_TUNNEL_INIT,     /* init/default/no tunnel state */
   H1_TUNNEL_CONNECT,  /* CONNECT request is being send */
@@ -72,6 +74,12 @@ struct h1_tunnel_state {
   BIT(leading_unfold);
 };
 
+/* Persistent context for the H1-PROXY filter */
+struct cf_h1_proxy_ctx {
+  struct h1_tunnel_state *ts;
+  BIT(udp_tunnel);
+};
+
 static bool tunnel_is_established(struct h1_tunnel_state *ts)
 {
   return ts && (ts->tunnel_state == H1_TUNNEL_ESTABLISHED);
@@ -82,6 +90,12 @@ static bool tunnel_is_failed(struct h1_tunnel_state *ts)
   return ts && (ts->tunnel_state == H1_TUNNEL_FAILED);
 }
 
+static bool h1_proxy_is_udp(struct Curl_cfilter *cf)
+{
+  struct cf_h1_proxy_ctx *pctx = cf->ctx;
+  return (pctx->udp_tunnel ? TRUE : FALSE);
+}
+
 static CURLcode tunnel_reinit(struct Curl_cfilter *cf,
                               struct Curl_easy *data,
                               struct h1_tunnel_state *ts)
@@ -97,6 +111,8 @@ static CURLcode tunnel_reinit(struct Curl_cfilter *cf,
   ts->close_connection = FALSE;
   ts->maybe_folded = FALSE;
   ts->leading_unfold = FALSE;
+  ts->nsent = 0;
+  ts->headerlines = 0;
   return CURLE_OK;
 }
 
@@ -158,7 +174,9 @@ static void h1_tunnel_go_state(struct Curl_cfilter *cf,
 
   case H1_TUNNEL_ESTABLISHED:
     CURL_TRC_CF(data, cf, "new tunnel state 'established'");
-    infof(data, "CONNECT phase completed");
+    infof(data, "CONNECT%s phase completed for HTTP proxy",
+          h1_proxy_is_udp(cf) ? "-UDP" : "");
+
     data->state.authproxy.done = TRUE;
     data->state.authproxy.multipass = FALSE;
     FALLTHROUGH();
@@ -195,11 +213,12 @@ static void cf_tunnel_free(struct Curl_cfilter *cf,
                            struct Curl_easy *data)
 {
   if(cf) {
-    struct h1_tunnel_state *ts = cf->ctx;
+    struct cf_h1_proxy_ctx *pctx = cf->ctx;
+    struct h1_tunnel_state *ts = pctx ? pctx->ts : NULL;
     if(ts) {
       h1_tunnel_go_state(cf, ts, H1_TUNNEL_FAILED, data);
       tunnel_free(ts, data);
-      cf->ctx = NULL;
+      pctx->ts = NULL;
     }
   }
 }
@@ -217,17 +236,17 @@ static CURLcode start_CONNECT(struct Curl_cfilter *cf,
   int http_minor;
   CURLcode result;
 
+  DEBUGASSERT(data);
   /* This only happens if we have looped here due to authentication reasons,
      and we do not really use the newly cloned URL here then. Free it. */
   curlx_safefree(data->req.newurl);
 
-  result = Curl_http_proxy_create_CONNECT(&req, cf, data,
-                                          ts->dest, ts->httpversion);
+  result = Curl_http_proxy_create_tunnel_request(&req, cf, data, ts->dest,
+                                                  PROXY_HTTP_V1,
+                                                  h1_proxy_is_udp(cf));
   if(result)
     goto out;
 
-  infof(data, "Establish HTTP proxy tunnel to %s", req->authority);
-
   curlx_dyn_reset(&ts->request_data);
   ts->nsent = 0;
   ts->headerlines = 0;
@@ -280,6 +299,92 @@ out:
   return result;
 }
 
+static CURLcode on_resp_header_udp(struct Curl_cfilter *cf,
+                                   struct Curl_easy *data,
+                                   struct h1_tunnel_state *ts,
+                                   const char *header)
+{
+  CURLcode result = CURLE_OK;
+  struct SingleRequest *k = &data->req;
+
+  if((checkprefix("WWW-Authenticate:", header) && (401 == k->httpcode)) ||
+     (checkprefix("Proxy-authenticate:", header) && (407 == k->httpcode))) {
+
+    bool proxy = (k->httpcode == 407);
+    char *auth = Curl_copy_header_value(header);
+    if(!auth)
+      return CURLE_OUT_OF_MEMORY;
+
+    CURL_TRC_CF(data, cf, "CONNECT-UDP: fwd auth header '%s'", header);
+    result = Curl_http_input_auth(data, proxy, auth);
+
+    curlx_free(auth);
+
+    if(result)
+      return result;
+  }
+  else if(checkprefix("Content-Length:", header)) {
+    if(k->httpcode / 100 == 2 || k->httpcode == 101) {
+      infof(data, "Ignoring Content-Length in CONNECT-UDP %03d response",
+            k->httpcode);
+    }
+    else {
+      const char *p = header + strlen("Content-Length:");
+      if(curlx_str_numblanks(&p, &ts->cl)) {
+        failf(data, "Unsupported Content-Length value");
+        return CURLE_WEIRD_SERVER_REPLY;
+      }
+    }
+  }
+  else if(checkprefix("Transfer-Encoding:", header)) {
+    if(k->httpcode / 100 == 2 || k->httpcode == 101) {
+      infof(data, "Ignoring Transfer-Encoding in "
+            "CONNECT-UDP %03d response", k->httpcode);
+    }
+    else if(Curl_compareheader(header,
+                               STRCONST("Transfer-Encoding:"),
+                               STRCONST("chunked"))) {
+      CURL_TRC_CF(data, cf, "CONNECT-UDP Response --> "
+                  "Transfer-Encoding: chunked");
+      ts->chunked_encoding = TRUE;
+      /* reset our chunky engine */
+      Curl_httpchunk_reset(data, &ts->ch, TRUE);
+    }
+  }
+  else if(checkprefix("Capsule-protocol:", header)) {
+    if(Curl_compareheader(header,
+                           STRCONST("Capsule-protocol:"),
+                           STRCONST("?1"))) {
+      CURL_TRC_CF(data, cf, "CONNECT-UDP Response --> Capsule-protocol: ?1");
+    }
+  }
+  else if(Curl_compareheader(header,
+                              STRCONST("Connection:"), STRCONST("close"))) {
+    ts->close_connection = TRUE;
+    CURL_TRC_CF(data, cf, "CONNECT-UDP Response --> Connection: close");
+  }
+  else if(Curl_compareheader(header,
+                             STRCONST("Proxy-Connection:"),
+                             STRCONST("close"))) {
+    ts->close_connection = TRUE;
+    CURL_TRC_CF(data, cf,
+                "CONNECT-UDP Response --> Proxy-Connection: close");
+  }
+  else if(!strncmp(header, "HTTP/1.", 7) &&
+           ((header[7] == '0') || (header[7] == '1')) &&
+           (header[8] == ' ') &&
+           ISDIGIT(header[9]) && ISDIGIT(header[10]) && ISDIGIT(header[11]) &&
+           !ISDIGIT(header[12])) {
+    /* store the HTTP code from the proxy */
+    data->info.httpproxycode = k->httpcode =
+      ((header[9] - '0') * 100) +
+      ((header[10] - '0') * 10) +
+      (header[11] - '0');
+    CURL_TRC_CF(data, cf, "CONNECT-UDP Response --> %d", k->httpcode);
+  }
+  return result;
+}
+
 static CURLcode on_resp_header(struct Curl_cfilter *cf,
                                struct Curl_easy *data,
                                struct h1_tunnel_state *ts,
@@ -418,7 +523,13 @@ static CURLcode single_header(struct Curl_cfilter *cf,
     return result;
   }
 
-  result = on_resp_header(cf, data, ts, linep);
+  if(h1_proxy_is_udp(cf)) {
+    result = on_resp_header_udp(cf, data, ts, linep);
+  }
+  else {
+    result = on_resp_header(cf, data, ts, linep);
+  }
+
   if(result)
     return result;
 
@@ -460,6 +571,13 @@ static CURLcode recv_CONNECT_resp(struct Curl_cfilter *cf,
     }
 
     if(!nread) {
+      if(ts->maybe_folded) {
+        /* EOF right after LF: finalize the pending header line. */
+        result = single_header(cf, data, ts);
+        if(result)
+          return result;
+        ts->maybe_folded = FALSE;
+      }
       if(data->set.proxyauth && data->state.authproxy.avail &&
          data->req.hd_proxy_auth) {
         /* proxy auth was requested and there was proxy auth available,
@@ -551,12 +669,16 @@ static CURLcode recv_CONNECT_resp(struct Curl_cfilter *cf,
         ts->maybe_folded = TRUE;
     }
 
+    if(result)
+      return result;
   } /* while there is buffer left and loop is requested */
 
   if(error)
     result = CURLE_RECV_ERROR;
   *done = (ts->keepon == KEEPON_DONE);
-  if(!result && *done && data->info.httpproxycode / 100 != 2) {
+  if(!result && *done &&
+     data->info.httpproxycode / 100 != 2 &&
+     !(h1_proxy_is_udp(cf) && data->info.httpproxycode == 101)) {
     /* Deal with the possibly already received authenticate
        headers. 'newurl' is set to a new URL if we must loop. */
     result = Curl_http_auth_act(data);
@@ -637,7 +759,7 @@ static CURLcode H1_CONNECT(struct Curl_cfilter *cf,
           infof(data, "Connect me again please");
           Curl_conn_cf_close(cf, data);
           result = Curl_conn_cf_connect(cf->next, data, &done);
-          goto out;
+          return result;
         }
         else {
           /* staying on this connection, reset state */
@@ -653,17 +775,36 @@ static CURLcode H1_CONNECT(struct Curl_cfilter *cf,
   } while(data->req.newurl);
 
   DEBUGASSERT(ts->tunnel_state == H1_TUNNEL_RESPONSE);
-  if(data->info.httpproxycode / 100 != 2) {
-    /* a non-2xx response and we have no next URL to try. */
-    curlx_safefree(data->req.newurl);
-    h1_tunnel_go_state(cf, ts, H1_TUNNEL_FAILED, data);
-    failf(data, "CONNECT tunnel failed, response %d", data->req.httpcode);
-    return CURLE_COULDNT_CONNECT;
+  if(h1_proxy_is_udp(cf)) {
+    /* RFC 9298: Accept 101 Upgrade for HTTP/1.1 and
+     * 2xx responses for HTTP/2 and HTTP/3 proxies. */
+    if(data->info.httpproxycode / 100 != 2 &&
+       data->info.httpproxycode != 101) {
+      curlx_safefree(data->req.newurl);
+      h1_tunnel_go_state(cf, ts, H1_TUNNEL_FAILED, data);
+      failf(data, "CONNECT-UDP tunnel failed, response %d",
+            data->req.httpcode);
+      return CURLE_COULDNT_CONNECT;
+    }
+  }
+  else {
+    if(data->info.httpproxycode / 100 != 2) {
+      /* a non-2xx response and we have no next URL to try. */
+      curlx_safefree(data->req.newurl);
+      h1_tunnel_go_state(cf, ts, H1_TUNNEL_FAILED, data);
+      failf(data, "CONNECT tunnel failed, response %d", data->req.httpcode);
+      return CURLE_COULDNT_CONNECT;
+    }
   }
   /* 2xx response, SUCCESS! */
+  /* 101 Switching Protocol for CONNECT-UDP */
   h1_tunnel_go_state(cf, ts, H1_TUNNEL_ESTABLISHED, data);
-  infof(data, "CONNECT tunnel established, response %d",
-        data->info.httpproxycode);
+  if(h1_proxy_is_udp(cf))
+    infof(data, "CONNECT-UDP tunnel established, response %d",
+                                    data->info.httpproxycode);
+  else
+    infof(data, "CONNECT tunnel established, response %d",
+                                    data->info.httpproxycode);
   result = CURLE_OK;
 
 out:
@@ -677,7 +818,8 @@ static CURLcode cf_h1_proxy_connect(struct Curl_cfilter *cf,
                                     bool *done)
 {
   CURLcode result;
-  struct h1_tunnel_state *ts = cf->ctx;
+  struct cf_h1_proxy_ctx *pctx = cf->ctx;
+  struct h1_tunnel_state *ts = pctx->ts;
 
   if(cf->connected) {
     *done = TRUE;
@@ -694,7 +836,7 @@ static CURLcode cf_h1_proxy_connect(struct Curl_cfilter *cf,
     result = tunnel_init(cf, data, &ts);
     if(result)
       return result;
-    cf->ctx = ts;
+    pctx->ts = ts;
   }
 
   /* We want "seamless" operations through HTTP proxy tunnel */
@@ -705,14 +847,13 @@ static CURLcode cf_h1_proxy_connect(struct Curl_cfilter *cf,
   curlx_safefree(data->req.hd_proxy_auth);
 
 out:
-  *done = (result == CURLE_OK) && tunnel_is_established(cf->ctx);
+  *done = (result == CURLE_OK) && tunnel_is_established(pctx->ts);
   if(*done) {
     cf->connected = TRUE;
     /* The real request will follow the CONNECT, reset request partially */
     Curl_req_soft_reset(&data->req, data);
     Curl_client_reset(data);
     Curl_pgrsReset(data);
-
     cf_tunnel_free(cf, data);
   }
   return result;
@@ -722,7 +863,8 @@ static CURLcode cf_h1_proxy_adjust_pollset(struct Curl_cfilter *cf,
                                            struct Curl_easy *data,
                                            struct easy_pollset *ps)
 {
-  struct h1_tunnel_state *ts = cf->ctx;
+  struct cf_h1_proxy_ctx *pctx = cf->ctx;
+  struct h1_tunnel_state *ts = pctx->ts;
   CURLcode result = CURLE_OK;
 
   if(!cf->connected) {
@@ -742,37 +884,49 @@ static CURLcode cf_h1_proxy_adjust_pollset(struct Curl_cfilter *cf,
     else
       result = Curl_pollset_set_out_only(data, ps, sock);
   }
+  else {
+    if(cf->next)
+      result = cf->next->cft->adjust_pollset(cf->next, data, ps);
+  }
   return result;
 }
 
+static bool cf_h1_proxy_data_pending(struct Curl_cfilter *cf,
+                                     const struct Curl_easy *data)
+{
+  return cf->next ? cf->next->cft->has_data_pending(cf->next, data) : FALSE;
+}
+
 static void cf_h1_proxy_destroy(struct Curl_cfilter *cf,
                                 struct Curl_easy *data)
 {
   CURL_TRC_CF(data, cf, "destroy");
   cf_tunnel_free(cf, data);
+  curlx_safefree(cf->ctx);
 }
 
 static void cf_h1_proxy_close(struct Curl_cfilter *cf,
                               struct Curl_easy *data)
 {
+  struct cf_h1_proxy_ctx *pctx = cf->ctx;
   CURL_TRC_CF(data, cf, "close");
-  if(cf) {
-    cf->connected = FALSE;
-    if(cf->ctx) {
-      h1_tunnel_go_state(cf, cf->ctx, H1_TUNNEL_INIT, data);
-    }
-    if(cf->next)
-      cf->next->cft->do_close(cf->next, data);
-  }
+  cf->connected = FALSE;
+  if(pctx && pctx->ts)
+    h1_tunnel_go_state(cf, pctx->ts, H1_TUNNEL_INIT, data);
+  if(cf->next)
+    cf->next->cft->do_close(cf->next, data);
 }
 
 static CURLcode cf_h1_proxy_query(struct Curl_cfilter *cf,
                                   struct Curl_easy *data,
                                   int query, int *pres1, void *pres2)
 {
-  struct h1_tunnel_state *ts = cf->ctx;
+  struct cf_h1_proxy_ctx *pctx = cf->ctx;
+  struct h1_tunnel_state *ts = pctx ? pctx->ts : NULL;
   switch(query) {
   case CF_QUERY_HOST_PORT:
+    if(!ts || !ts->dest)
+      break;
     *pres1 = (int)ts->dest->port;
     *((const char **)pres2) = ts->dest->hostname;
     return CURLE_OK;
@@ -799,7 +953,7 @@ struct Curl_cftype Curl_cft_h1_proxy = {
   cf_h1_proxy_close,
   Curl_cf_def_shutdown,
   cf_h1_proxy_adjust_pollset,
-  Curl_cf_def_data_pending,
+  cf_h1_proxy_data_pending,
   Curl_cf_def_send,
   Curl_cf_def_recv,
   Curl_cf_def_cntrl,
@@ -811,9 +965,11 @@ struct Curl_cftype Curl_cft_h1_proxy = {
 CURLcode Curl_cf_h1_proxy_insert_after(struct Curl_cfilter *cf_at,
                                        struct Curl_easy *data,
                                        struct Curl_peer *dest,
-                                       int httpversion)
+                                       int httpversion,
+                                       bool udp_tunnel)
 {
   struct Curl_cfilter *cf;
+  struct cf_h1_proxy_ctx *pctx;
   struct h1_tunnel_state *ts;
   CURLcode result;
 
@@ -834,9 +990,18 @@ CURLcode Curl_cf_h1_proxy_insert_after(struct Curl_cfilter *cf_at,
   curlx_dyn_init(&ts->request_data, DYN_HTTP_REQUEST);
   Curl_httpchunk_init(data, &ts->ch, TRUE);
 
-  result = Curl_cf_create(&cf, &Curl_cft_h1_proxy, ts);
-  if(result)
+  pctx = curlx_calloc(1, sizeof(*pctx));
+  if(!pctx) {
+    result = CURLE_OUT_OF_MEMORY;
+    goto out;
+  }
+  pctx->udp_tunnel = udp_tunnel;
+  pctx->ts = ts;
+  result = Curl_cf_create(&cf, &Curl_cft_h1_proxy, pctx);
+  if(result) {
+    curlx_free(pctx);
     goto out;
+  }
   ts = NULL;
   Curl_conn_cf_insert_after(cf_at, cf);
 
index 10adcdfb4fdb4f7f70ac6c6df48e02b0792c45f2..3255bf79f240d2a2b5b955c61db5f65f969dc0f1 100644 (file)
@@ -32,7 +32,8 @@ struct Curl_peer;
 CURLcode Curl_cf_h1_proxy_insert_after(struct Curl_cfilter *cf_at,
                                        struct Curl_easy *data,
                                        struct Curl_peer *dest,
-                                       int httpversion);
+                                       int httpversion,
+                                       bool udp_tunnel);
 
 extern struct Curl_cftype Curl_cft_h1_proxy;
 
index 297afb3c8cb15f542c81d3012ecd274cfbb57127..b2cc49896fb9e7bc14fd31468908e594bfb709d0 100644 (file)
@@ -42,6 +42,7 @@
 #include "sendf.h"
 #include "select.h"
 #include "cf-h2-proxy.h"
+#include "capsule.h"
 
 #define PROXY_H2_CHUNK_SIZE  (16 * 1024)
 
@@ -96,6 +97,20 @@ static CURLcode tunnel_stream_init(struct tunnel_stream *ts,
   return CURLE_OK;
 }
 
+static void tunnel_stream_reset(struct tunnel_stream *ts)
+{
+  Curl_http_resp_free(ts->resp);
+  ts->resp = NULL;
+  Curl_bufq_reset(&ts->recvbuf);
+  Curl_bufq_reset(&ts->sendbuf);
+  ts->stream_id = -1;
+  ts->error = 0;
+  ts->has_final_response = FALSE;
+  ts->closed = FALSE;
+  ts->reset = FALSE;
+  ts->state = H2_TUNNEL_INIT;
+}
+
 static void tunnel_stream_clear(struct tunnel_stream *ts)
 {
   Curl_http_resp_free(ts->resp);
@@ -109,9 +124,11 @@ static void tunnel_stream_clear(struct tunnel_stream *ts)
 static void h2_tunnel_go_state(struct Curl_cfilter *cf,
                                struct tunnel_stream *ts,
                                h2_tunnel_state new_state,
-                               struct Curl_easy *data)
+                               struct Curl_easy *data,
+                               bool udp_tunnel)
 {
   (void)cf;
+  (void)udp_tunnel;
 
   if(ts->state == new_state)
     return;
@@ -127,7 +144,7 @@ static void h2_tunnel_go_state(struct Curl_cfilter *cf,
   switch(new_state) {
   case H2_TUNNEL_INIT:
     CURL_TRC_CF(data, cf, "[%d] new tunnel state 'init'", ts->stream_id);
-    tunnel_stream_clear(ts);
+    tunnel_stream_reset(ts);
     break;
 
   case H2_TUNNEL_CONNECT:
@@ -143,7 +160,8 @@ static void h2_tunnel_go_state(struct Curl_cfilter *cf,
   case H2_TUNNEL_ESTABLISHED:
     CURL_TRC_CF(data, cf, "[%d] new tunnel state 'established'",
                 ts->stream_id);
-    infof(data, "CONNECT phase completed");
+    infof(data, "CONNECT%s phase completed for HTTP/2 proxy",
+          udp_tunnel ? "-UDP" : "");
     data->state.authproxy.done = TRUE;
     data->state.authproxy.multipass = FALSE;
     FALLTHROUGH();
@@ -175,6 +193,7 @@ struct cf_h2_proxy_ctx {
   BIT(rcvd_goaway);
   BIT(sent_goaway);
   BIT(nw_out_blocked);
+  BIT(udp_tunnel);
 };
 
 /* How to access `call_data` from a cf_h2 filter */
@@ -211,7 +230,8 @@ static void drain_tunnel(struct Curl_cfilter *cf,
   struct cf_h2_proxy_ctx *ctx = cf->ctx;
   (void)cf;
   if(!tunnel->closed && !tunnel->reset &&
-     !Curl_bufq_is_empty(&ctx->tunnel.sendbuf))
+     (!Curl_bufq_is_empty(&ctx->tunnel.sendbuf) ||
+      !Curl_bufq_is_empty(&ctx->tunnel.recvbuf)))
     Curl_multi_mark_dirty(data);
 }
 
@@ -749,15 +769,15 @@ static CURLcode submit_CONNECT(struct Curl_cfilter *cf,
   CURLcode result;
   struct httpreq *req = NULL;
 
-  result = Curl_http_proxy_create_CONNECT(&req, cf, data, ctx->dest, 20);
+  result = Curl_http_proxy_create_tunnel_request(&req, cf, data, ctx->dest,
+                                                  PROXY_HTTP_V2,
+                                                  (bool)ctx->udp_tunnel);
   if(result)
     goto out;
   result = Curl_creader_set_null(data);
   if(result)
     goto out;
 
-  infof(data, "Establish HTTP/2 proxy tunnel to %s", req->authority);
-
   result = proxy_h2_submit(&ts->stream_id, cf, data, ctx->h2, req,
                            NULL, ts, tunnel_send_callback, cf);
   if(result) {
@@ -777,41 +797,30 @@ static CURLcode inspect_response(struct Curl_cfilter *cf,
                                  struct Curl_easy *data,
                                  struct tunnel_stream *ts)
 {
-  CURLcode result = CURLE_OK;
-  struct dynhds_entry *auth_reply = NULL;
-  (void)cf;
-
-  DEBUGASSERT(ts->resp);
-  if(ts->resp->status / 100 == 2) {
-    infof(data, "CONNECT tunnel established, response %d", ts->resp->status);
-    h2_tunnel_go_state(cf, ts, H2_TUNNEL_ESTABLISHED, data);
-    return CURLE_OK;
-  }
-
-  if(ts->resp->status == 401) {
-    auth_reply = Curl_dynhds_cget(&ts->resp->headers, "WWW-Authenticate");
-  }
-  else if(ts->resp->status == 407) {
-    auth_reply = Curl_dynhds_cget(&ts->resp->headers, "Proxy-Authenticate");
-  }
+  struct cf_h2_proxy_ctx *ctx = cf->ctx;
+  proxy_inspect_result res;
+  CURLcode result;
 
-  if(auth_reply) {
-    CURL_TRC_CF(data, cf, "[0] CONNECT: fwd auth header '%s'",
-                auth_reply->value);
-    result = Curl_http_input_auth(data, ts->resp->status == 407,
-                                  auth_reply->value);
-    if(result)
-      return result;
-    if(data->req.newurl) {
-      /* Indicator that we should try again */
-      curlx_safefree(data->req.newurl);
-      h2_tunnel_go_state(cf, ts, H2_TUNNEL_INIT, data);
-      return CURLE_OK;
-    }
+  result = Curl_http_proxy_inspect_tunnel_response(
+      cf, data, ts->resp, (bool)ctx->udp_tunnel, &res);
+  if(result)
+    return result;
+  switch(res) {
+  case PROXY_INSPECT_OK:
+    h2_tunnel_go_state(cf, ts, H2_TUNNEL_ESTABLISHED, data,
+                       (bool)ctx->udp_tunnel);
+    break;
+  case PROXY_INSPECT_FAILED:
+    h2_tunnel_go_state(cf, ts, H2_TUNNEL_FAILED, data,
+                       (bool)ctx->udp_tunnel);
+    result = CURLE_COULDNT_CONNECT;
+    break;
+  case PROXY_INSPECT_AUTH_RETRY:
+    h2_tunnel_go_state(cf, ts, H2_TUNNEL_INIT, data,
+                       (bool)ctx->udp_tunnel);
+    break;
   }
-
-  /* Seems to have failed */
-  return CURLE_COULDNT_CONNECT;
+  return result;
 }
 
 static CURLcode H2_CONNECT(struct Curl_cfilter *cf,
@@ -831,7 +840,8 @@ static CURLcode H2_CONNECT(struct Curl_cfilter *cf,
       result = submit_CONNECT(cf, data, ts);
       if(result)
         goto out;
-      h2_tunnel_go_state(cf, ts, H2_TUNNEL_CONNECT, data);
+      h2_tunnel_go_state(cf, ts, H2_TUNNEL_CONNECT, data,
+                         (bool)ctx->udp_tunnel);
       FALLTHROUGH();
 
     case H2_TUNNEL_CONNECT:
@@ -840,12 +850,14 @@ static CURLcode H2_CONNECT(struct Curl_cfilter *cf,
       if(!result)
         result = proxy_h2_progress_egress(cf, data);
       if(result && result != CURLE_AGAIN) {
-        h2_tunnel_go_state(cf, ts, H2_TUNNEL_FAILED, data);
+        h2_tunnel_go_state(cf, ts, H2_TUNNEL_FAILED, data,
+                           (bool)ctx->udp_tunnel);
         break;
       }
 
       if(ts->has_final_response) {
-        h2_tunnel_go_state(cf, ts, H2_TUNNEL_RESPONSE, data);
+        h2_tunnel_go_state(cf, ts, H2_TUNNEL_RESPONSE, data,
+                           (bool)ctx->udp_tunnel);
       }
       else {
         result = CURLE_OK;
@@ -874,7 +886,8 @@ static CURLcode H2_CONNECT(struct Curl_cfilter *cf,
 
 out:
   if((result && (result != CURLE_AGAIN)) || ctx->tunnel.closed)
-    h2_tunnel_go_state(cf, ts, H2_TUNNEL_FAILED, data);
+    h2_tunnel_go_state(cf, ts, H2_TUNNEL_FAILED, data,
+                       (bool)ctx->udp_tunnel);
   return result;
 }
 
@@ -1231,7 +1244,8 @@ static CURLcode cf_h2_proxy_recv(struct Curl_cfilter *cf,
   result = Curl_1st_fatal(result, proxy_h2_progress_egress(cf, data));
 
 out:
-  if(!Curl_bufq_is_empty(&ctx->tunnel.recvbuf) &&
+  if((!Curl_bufq_is_empty(&ctx->tunnel.recvbuf) ||
+      !Curl_bufq_is_empty(&ctx->tunnel.sendbuf)) &&
      (!result || (result == CURLE_AGAIN))) {
     /* data pending and no fatal error to report. Need to trigger
      * draining to avoid stalling when no socket events happen. */
@@ -1297,7 +1311,8 @@ static CURLcode cf_h2_proxy_send(struct Curl_cfilter *cf,
   }
 
 out:
-  if(!Curl_bufq_is_empty(&ctx->tunnel.recvbuf) &&
+  if((!Curl_bufq_is_empty(&ctx->tunnel.recvbuf) ||
+      !Curl_bufq_is_empty(&ctx->tunnel.sendbuf)) &&
      (!result || (result == CURLE_AGAIN))) {
     /* data pending and no fatal error to report. Need to trigger
      * draining to avoid stalling when no socket events happen. */
@@ -1477,7 +1492,8 @@ struct Curl_cftype Curl_cft_h2_proxy = {
 
 CURLcode Curl_cf_h2_proxy_insert_after(struct Curl_cfilter *cf,
                                        struct Curl_easy *data,
-                                       struct Curl_peer *dest)
+                                       struct Curl_peer *dest,
+                                       bool udp_tunnel)
 {
   struct Curl_cfilter *cf_h2_proxy = NULL;
   struct cf_h2_proxy_ctx *ctx;
@@ -1488,6 +1504,7 @@ CURLcode Curl_cf_h2_proxy_insert_after(struct Curl_cfilter *cf,
   if(!ctx)
     goto out;
   Curl_peer_link(&ctx->dest, dest);
+  ctx->udp_tunnel = udp_tunnel;
 
   result = Curl_cf_create(&cf_h2_proxy, &Curl_cft_h2_proxy, ctx);
   if(result)
@@ -1501,3 +1518,6 @@ out:
 }
 
 #endif /* !CURL_DISABLE_HTTP && !CURL_DISABLE_PROXY && USE_NGHTTP2 */
+
+/* Do not leak this filter's call_data accessor in unity builds. */
+#undef CF_CTX_CALL_DATA
index 1056a329076c4ce60192eca97a9e88a3c9097704..07e3c9aedf1a58f75745ca05808c37cd8a702f5c 100644 (file)
@@ -29,7 +29,8 @@
 
 CURLcode Curl_cf_h2_proxy_insert_after(struct Curl_cfilter *cf,
                                        struct Curl_easy *data,
-                                       struct Curl_peer *dest);
+                                       struct Curl_peer *dest,
+                                       bool udp_tunnel);
 
 extern struct Curl_cftype Curl_cft_h2_proxy;
 
diff --git a/lib/cf-h3-proxy.c b/lib/cf-h3-proxy.c
new file mode 100644 (file)
index 0000000..1896ba6
--- /dev/null
@@ -0,0 +1,3478 @@
+/***************************************************************************
+ *                                  _   _ ____  _
+ *  Project                     ___| | | |  _ \| |
+ *                             / __| | | | |_) | |
+ *                            | (__| |_| |  _ <| |___
+ *                             \___|\___/|_| \_\_____|
+ *
+ * Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
+ *
+ * This software is licensed as described in the file COPYING, which
+ * you should have received as part of this distribution. The terms
+ * are also available at https://curl.se/docs/copyright.html.
+ *
+ * You may opt to use, copy, modify, merge, publish, distribute and/or sell
+ * copies of the Software, and permit persons to whom the Software is
+ * furnished to do so, under the terms of the COPYING file.
+ *
+ * This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
+ * KIND, either express or implied.
+ *
+ * SPDX-License-Identifier: curl
+ *
+ ***************************************************************************/
+
+#include "curl_setup.h"
+
+#if !defined(CURL_DISABLE_HTTP) && !defined(CURL_DISABLE_PROXY) && \
+    defined(USE_PROXY_HTTP3) && defined(USE_NGHTTP3) && \
+    defined(USE_NGTCP2) && defined(USE_OPENSSL)
+
+#include <ngtcp2/ngtcp2.h>
+#include <ngtcp2/ngtcp2_crypto.h>
+#ifdef USE_OPENSSL
+#include <openssl/err.h>
+#if defined(OPENSSL_IS_BORINGSSL) || defined(OPENSSL_IS_AWSLC)
+#include <ngtcp2/ngtcp2_crypto_boringssl.h>
+#elif defined(OPENSSL_QUIC_API2)
+#include <ngtcp2/ngtcp2_crypto_ossl.h>
+#else
+#include <ngtcp2/ngtcp2_crypto_quictls.h>
+#endif
+#include "vtls/openssl.h"
+#endif /* USE_OPENSSL */
+
+#include <nghttp3/nghttp3.h>
+
+#include "urldata.h"
+#include "hash.h"
+#include "sendf.h"
+#include "multiif.h"
+#include "cfilters.h"
+#include "cf-socket.h"
+#include "connect.h"
+#include "progress.h"
+#include "curlx/fopen.h"
+#include "curlx/dynbuf.h"
+#include "dynhds.h"
+#include "http_proxy.h"
+#include "select.h"
+#include "uint-hash.h"
+#include "vquic/vquic.h"
+#include "vquic/vquic_int.h"
+#include "vquic/vquic-tls.h"
+#include "vtls/vtls.h"
+#include "vtls/vtls_scache.h"
+#include "curl_trc.h"
+#include "cf-h3-proxy.h"
+#include "url.h"
+#include "capsule.h"
+#include "rand.h"
+
+/* A stream window is the maximum amount we need to buffer for
+ * each active transfer. We use HTTP/3 flow control and only ACK
+ * when we take things out of the buffer.
+ * Chunk size is large enough to take a full DATA frame */
+#define PROXY_H3_STREAM_WINDOW_SIZE (128 * 1024)
+#define PROXY_H3_STREAM_WINDOW_SIZE_MAX (10 * 1024 * 1024)
+#define PROXY_H3_STREAM_CHUNK_SIZE (16 * 1024)
+
+/* The pool keeps spares around and half of a full stream window
+ * seems good. More does not seem to improve performance.
+ * The benefit of the pool is that stream buffer to not keep
+ * spares. Memory consumption goes down when streams run empty,
+ * have a large upload done, etc. */
+#define PROXY_H3_STREAM_POOL_SPARES \
+  ((PROXY_H3_STREAM_WINDOW_SIZE / PROXY_H3_STREAM_CHUNK_SIZE) / 2)
+
+#define PROXY_H3_STREAM_RECV_CHUNKS \
+  (PROXY_H3_STREAM_WINDOW_SIZE / PROXY_H3_STREAM_CHUNK_SIZE)
+#define PROXY_H3_STREAM_SEND_CHUNKS \
+  (PROXY_H3_STREAM_WINDOW_SIZE / PROXY_H3_STREAM_CHUNK_SIZE)
+
+#define PROXY_QUIC_MAX_STREAMS (256*1024)
+#define PROXY_QUIC_HANDSHAKE_TIMEOUT (10*NGTCP2_SECONDS)
+
+typedef enum
+{
+  H3_TUNNEL_INIT,     /* init/default/no tunnel state */
+  H3_TUNNEL_CONNECT,  /* CONNECT request is being sent */
+  H3_TUNNEL_RESPONSE, /* CONNECT response received completely */
+  H3_TUNNEL_ESTABLISHED,
+  H3_TUNNEL_FAILED
+} h3_tunnel_state;
+
+struct h3_proxy_stream_ctx;
+
+struct h3_tunnel_stream
+{
+  struct http_resp *resp;
+  char *authority;
+  struct h3_proxy_stream_ctx *stream;
+  int64_t stream_id;
+  h3_tunnel_state state;
+  BIT(has_final_response);
+  BIT(closed);
+};
+
+static CURLcode h3_tunnel_stream_init(struct h3_tunnel_stream *ts,
+                                      struct Curl_peer *dest)
+{
+  ts->state = H3_TUNNEL_INIT;
+  ts->stream_id = -1;
+  ts->has_final_response = FALSE;
+
+  /* host:port with IPv6 support */
+  ts->authority = curl_maprintf("%s%s%s:%u", dest->ipv6 ? "[" : "",
+                                dest->hostname,
+                                dest->ipv6 ? "]" : "",
+                                dest->port);
+  if(!ts->authority)
+    return CURLE_OUT_OF_MEMORY;
+
+  return CURLE_OK;
+}
+
+static void h3_tunnel_stream_reset(struct h3_tunnel_stream *ts)
+{
+  Curl_http_resp_free(ts->resp);
+  ts->resp = NULL;
+  ts->stream = NULL;
+  ts->stream_id = -1;
+  ts->has_final_response = FALSE;
+  ts->closed = FALSE;
+  ts->state = H3_TUNNEL_INIT;
+}
+
+static void h3_tunnel_stream_clear(struct h3_tunnel_stream *ts)
+{
+  Curl_http_resp_free(ts->resp);
+  curlx_safefree(ts->authority);
+  memset(ts, 0, sizeof(*ts));
+  ts->state = H3_TUNNEL_INIT;
+}
+
+static void h3_tunnel_go_state(struct Curl_cfilter *cf,
+                               struct h3_tunnel_stream *ts,
+                               h3_tunnel_state new_state,
+                               struct Curl_easy *data,
+                               bool udp_tunnel)
+{
+  (void)cf;
+  (void)udp_tunnel;
+
+  if(ts->state == new_state)
+    return;
+  /* leaving this one */
+  switch(ts->state) {
+  case H3_TUNNEL_CONNECT:
+    data->req.ignorebody = FALSE;
+    break;
+  default:
+    break;
+  }
+  /* entering this one */
+  switch(new_state) {
+  case H3_TUNNEL_INIT:
+    CURL_TRC_CF(data, cf, "[%" PRId64 "] new tunnel state 'init'",
+                ts->stream_id);
+    h3_tunnel_stream_reset(ts);
+    break;
+
+  case H3_TUNNEL_CONNECT:
+    CURL_TRC_CF(data, cf, "[%" PRId64 "] new tunnel state 'connect'",
+                ts->stream_id);
+    ts->state = H3_TUNNEL_CONNECT;
+    break;
+
+  case H3_TUNNEL_RESPONSE:
+    CURL_TRC_CF(data, cf, "[%" PRId64 "] new tunnel state 'response'",
+                ts->stream_id);
+    ts->state = H3_TUNNEL_RESPONSE;
+    break;
+
+  case H3_TUNNEL_ESTABLISHED:
+    CURL_TRC_CF(data, cf, "[%" PRId64 "] new tunnel state 'established'",
+                ts->stream_id);
+    infof(data, "CONNECT%s phase completed for HTTP/3 proxy",
+          udp_tunnel ? "-UDP" : "");
+    data->state.authproxy.done = TRUE;
+    data->state.authproxy.multipass = FALSE;
+    FALLTHROUGH();
+  case H3_TUNNEL_FAILED:
+    if(new_state == H3_TUNNEL_FAILED)
+      CURL_TRC_CF(data, cf, "[%" PRId64 "] new tunnel state 'failed'",
+                  ts->stream_id);
+    ts->state = new_state;
+    /* If a proxy-authorization header was used for the proxy, then we should
+       make sure that it is not accidentally used for the document request
+       after we have connected. So let's free and clear it here. */
+    curlx_safefree(data->req.hd_proxy_auth);
+    break;
+  }
+}
+
+struct cf_ngtcp2_proxy_ctx {
+  struct cf_quic_ctx q;
+  struct ssl_peer peer;
+  struct curl_tls_ctx tls;
+#ifdef OPENSSL_QUIC_API2
+  ngtcp2_crypto_ossl_ctx *ossl_ctx;
+#endif /* OPENSSL_QUIC_API2 */
+  ngtcp2_path connected_path;
+  ngtcp2_conn *qconn;
+  ngtcp2_cid dcid;
+  ngtcp2_cid scid;
+  uint32_t version;
+  ngtcp2_settings settings;
+  ngtcp2_transport_params transport_params;
+  ngtcp2_ccerr last_error;
+  ngtcp2_crypto_conn_ref conn_ref;
+  struct cf_call_data call_data;
+  nghttp3_conn *h3conn;
+  nghttp3_settings h3settings;
+  struct curltime started_at;        /* time the current attempt started */
+  struct curltime handshake_at;      /* time connect handshake finished */
+  struct bufc_pool stream_bufcp;     /* chunk pool for streams */
+  struct dynbuf scratch;             /* temp buffer for header construction */
+  struct uint_hash streams;
+                            /* hash `data->mid` to `h3_proxy_stream_ctx` */
+  uint64_t used_bidi_streams;        /* bidi streams we have opened */
+  uint64_t max_bidi_streams;         /* max bidi streams we can open */
+  size_t earlydata_max;              /* max amount of early data supported by
+                                        server on session reuse */
+  size_t earlydata_skip;             /* sending bytes to skip when earlydata
+                                        is accepted by peer */
+  CURLcode tls_vrfy_result;          /* result of TLS peer verification */
+  int qlogfd;
+  BIT(initialized);
+  BIT(tls_handshake_complete);       /* TLS handshake is done */
+  BIT(use_earlydata);                /* Using 0RTT data */
+  BIT(earlydata_accepted);           /* 0RTT was accepted by server */
+  BIT(shutdown_started);             /* queued shutdown packets */
+};
+
+struct cf_h3_proxy_ctx
+{
+  struct cf_ngtcp2_proxy_ctx *ngtcp2_ctx;
+  struct cf_call_data call_data; /* fallback before backend ctx exists */
+  struct bufq inbufq;          /* network receive buffer */
+  struct Curl_peer *dest; /* where to tunnel to */
+  struct h3_tunnel_stream tunnel; /* our tunnel CONNECT stream */
+  BIT(connected);
+  BIT(udp_tunnel);
+};
+
+/**
+ * All about the H3 internals of a stream
+ */
+struct h3_proxy_stream_ctx
+{
+  int64_t id;              /* HTTP/3 stream identifier */
+  struct bufq sendbuf;          /* h3 request body */
+  size_t sendbuf_len_in_flight; /* sendbuf amount "in flight" */
+  uint64_t error3;         /* HTTP/3 stream error code */
+  curl_off_t upload_left;       /* number of request bytes left to upload */
+  curl_off_t tun_data_recvd;    /* number of bytes received over tunnel */
+  uint64_t rx_offset;           /* current receive offset */
+  uint64_t rx_offset_max;       /* allowed receive offset */
+  uint64_t window_size_max;     /* max flow control window set for stream */
+  int status_code;              /* HTTP status code */
+  CURLcode xfer_result;         /* result from xfer_resp_write(_hd) */
+  BIT(resp_hds_complete);       /* we have a complete, final response */
+  BIT(closed);                  /* TRUE on stream close */
+  BIT(reset);                   /* TRUE on stream reset */
+  BIT(send_closed);             /* stream is local closed */
+  BIT(quic_flow_blocked);       /* stream is blocked by QUIC flow control */
+};
+
+#define H3_PROXY_STREAM_CTX(ctx, data)                                     \
+  ((data) ? Curl_uint32_hash_get(&(ctx)->streams, (data)->mid) : NULL)
+
+#define H3_STREAM_ID(stream) ((stream)->id)
+
+static void h3_proxy_stream_ctx_free(struct h3_proxy_stream_ctx *stream)
+{
+  Curl_bufq_free(&stream->sendbuf);
+  curlx_free(stream);
+}
+
+static void h3_proxy_stream_hash_free(unsigned int id, void *stream)
+{
+  (void)id;
+  DEBUGASSERT(stream);
+  h3_proxy_stream_ctx_free((struct h3_proxy_stream_ctx *)stream);
+}
+
+static void cf_ngtcp2_proxy_ctx_init(struct cf_ngtcp2_proxy_ctx *ctx)
+{
+  DEBUGASSERT(!ctx->initialized);
+  ctx->q.sockfd = CURL_SOCKET_BAD;
+  ctx->qlogfd = -1;
+  ctx->version = NGTCP2_PROTO_VER_MAX;
+  Curl_bufcp_init(&ctx->stream_bufcp, PROXY_H3_STREAM_CHUNK_SIZE,
+                  PROXY_H3_STREAM_POOL_SPARES);
+  curlx_dyn_init(&ctx->scratch, CURL_MAX_HTTP_HEADER);
+  Curl_uint32_hash_init(&ctx->streams, 63, h3_proxy_stream_hash_free);
+  ctx->initialized = TRUE;
+}
+
+static void cf_ngtcp2_proxy_ctx_free(struct cf_ngtcp2_proxy_ctx *ctx)
+{
+  if(ctx && ctx->initialized) {
+    Curl_vquic_tls_cleanup(&ctx->tls);
+    vquic_ctx_free(&ctx->q);
+    Curl_bufcp_free(&ctx->stream_bufcp);
+    curlx_dyn_free(&ctx->scratch);
+    Curl_uint32_hash_destroy(&ctx->streams);
+    Curl_ssl_peer_cleanup(&ctx->peer);
+  }
+  curlx_free(ctx);
+}
+
+static void cf_ngtcp2_proxy_ctx_close(struct cf_ngtcp2_proxy_ctx *ctx)
+{
+  struct cf_call_data save = ctx->call_data;
+
+  if(!ctx->initialized)
+    return;
+  if(ctx->qlogfd != -1) {
+    curlx_close(ctx->qlogfd);
+  }
+  ctx->qlogfd = -1;
+  Curl_vquic_tls_cleanup(&ctx->tls);
+  Curl_ssl_peer_cleanup(&ctx->peer);
+  vquic_ctx_free(&ctx->q);
+  if(ctx->h3conn) {
+    nghttp3_conn_del(ctx->h3conn);
+    ctx->h3conn = NULL;
+  }
+  if(ctx->qconn) {
+    ngtcp2_conn_del(ctx->qconn);
+    ctx->qconn = NULL;
+  }
+#ifdef OPENSSL_QUIC_API2
+  if(ctx->ossl_ctx) {
+    ngtcp2_crypto_ossl_ctx_del(ctx->ossl_ctx);
+    ctx->ossl_ctx = NULL;
+  }
+#endif /* OPENSSL_QUIC_API2 */
+  ctx->call_data = save;
+}
+
+static void cf_ngtcp2_proxy_setup_keep_alive(struct Curl_cfilter *cf,
+                                       struct Curl_easy *data)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  const ngtcp2_transport_params *rp;
+  /* Peer should have sent us its transport parameters. If it
+  * announces a positive `max_idle_timeout` it will close the
+  * connection when it does not hear from us for that time.
+  *
+  * Some servers use this as a keep-alive timer at a rather low
+  * value. We are doing HTTP/3 here and waiting for the response
+  * to a request may take a considerable amount of time. We need
+  * to prevent the peer's QUIC stack from closing in this case.
+  */
+  if(!ctx->qconn)
+    return;
+
+  rp = ngtcp2_conn_get_remote_transport_params(ctx->qconn);
+  if(!rp || !rp->max_idle_timeout) {
+    ngtcp2_conn_set_keep_alive_timeout(ctx->qconn, UINT64_MAX);
+    CURL_TRC_CF(data, cf, "no peer idle timeout, unset keep-alive");
+  }
+  else if(!Curl_uint32_hash_count(&ctx->streams)) {
+    ngtcp2_conn_set_keep_alive_timeout(ctx->qconn, UINT64_MAX);
+    CURL_TRC_CF(data, cf, "no active streams, unset keep-alive");
+  }
+  else {
+    ngtcp2_duration keep_ns;
+    keep_ns = (rp->max_idle_timeout > 1) ? (rp->max_idle_timeout / 2) : 1;
+    ngtcp2_conn_set_keep_alive_timeout(ctx->qconn, keep_ns);
+    CURL_TRC_CF(data, cf, "peer idle timeout is %" PRIu64 "ms, "
+                "set keep-alive to %" PRIu64 " ms.",
+                (uint64_t)(rp->max_idle_timeout / NGTCP2_MILLISECONDS),
+                (uint64_t)(keep_ns / NGTCP2_MILLISECONDS));
+  }
+}
+
+struct proxy_pkt_io_ctx {
+  struct Curl_cfilter *cf;
+  struct Curl_easy *data;
+  ngtcp2_tstamp ts;
+  ngtcp2_path_storage ps;
+};
+
+static void proxy_pktx_update_time(struct proxy_pkt_io_ctx *pktx,
+                                   struct Curl_cfilter *cf)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  const struct curltime *pnow = Curl_pgrs_now(pktx->data);
+
+  vquic_ctx_update_time(&ctx->q, pnow);
+  pktx->ts = ((ngtcp2_tstamp)pnow->tv_sec * NGTCP2_SECONDS) +
+             ((ngtcp2_tstamp)pnow->tv_usec * NGTCP2_MICROSECONDS);
+}
+
+static void proxy_pktx_init(struct proxy_pkt_io_ctx *pktx,
+                            struct Curl_cfilter *cf,
+                            struct Curl_easy *data)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  const struct curltime *pnow = Curl_pgrs_now(data);
+
+  pktx->cf = cf;
+  pktx->data = data;
+  ngtcp2_path_storage_zero(&pktx->ps);
+  vquic_ctx_set_time(&ctx->q, pnow);
+  pktx->ts = ((ngtcp2_tstamp)pnow->tv_sec * NGTCP2_SECONDS) +
+             ((ngtcp2_tstamp)pnow->tv_usec * NGTCP2_MICROSECONDS);
+}
+
+static ngtcp2_conn *proxy_get_conn(ngtcp2_crypto_conn_ref *conn_ref)
+{
+  struct Curl_cfilter *cf = conn_ref->user_data;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  return ctx->qconn;
+}
+
+#ifdef DEBUG_NGTCP2
+static void proxy_quic_printf(void *user_data, const char *fmt, ...)
+{
+  va_list ap;
+  (void)user_data;
+  va_start(ap, fmt);
+  curl_mvfprintf(stderr, fmt, ap);
+  va_end(ap);
+  curl_mfprintf(stderr, "\n");
+}
+#endif /* DEBUG_NGTCP2 */
+
+static void proxy_qlog_callback(void *user_data, uint32_t flags,
+                          const void *data, size_t datalen)
+{
+  struct Curl_cfilter *cf = user_data;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  (void)flags;
+  if(ctx->qlogfd != -1) {
+    ssize_t rc = write(ctx->qlogfd, data, datalen);
+    if(rc == -1) {
+      /* on write error, stop further write attempts */
+      curlx_close(ctx->qlogfd);
+      ctx->qlogfd = -1;
+    }
+  }
+}
+
+static void quic_settings_proxy(struct cf_ngtcp2_proxy_ctx *ctx,
+                                struct Curl_easy *data,
+                                struct proxy_pkt_io_ctx *pktx)
+{
+  ngtcp2_settings *s = &ctx->settings;
+  ngtcp2_transport_params *t = &ctx->transport_params;
+
+  ngtcp2_settings_default(s);
+  ngtcp2_transport_params_default(t);
+#ifdef DEBUG_NGTCP2
+  s->log_printf = proxy_quic_printf;
+#else
+  s->log_printf = NULL;
+#endif /* DEBUG_NGTCP2 */
+
+  s->initial_ts = pktx->ts;
+  s->handshake_timeout = (data->set.connecttimeout > 0) ?
+    data->set.connecttimeout * NGTCP2_MILLISECONDS :
+    PROXY_QUIC_HANDSHAKE_TIMEOUT;
+  s->max_window = 100 * PROXY_H3_STREAM_WINDOW_SIZE;
+  s->max_stream_window = 10 * PROXY_H3_STREAM_WINDOW_SIZE;
+  s->no_pmtud = FALSE;
+#ifdef NGTCP2_SETTINGS_V3
+  /* try ten times the ngtcp2 defaults here for problems with Caddy */
+  s->glitch_ratelim_burst = 1000 * 10;
+  s->glitch_ratelim_rate = 33 * 10;
+#endif /* NGTCP2_SETTINGS_V3 */
+  t->initial_max_data = 10 * PROXY_H3_STREAM_WINDOW_SIZE;
+  t->initial_max_stream_data_bidi_local = PROXY_H3_STREAM_WINDOW_SIZE;
+  t->initial_max_stream_data_bidi_remote = PROXY_H3_STREAM_WINDOW_SIZE;
+  t->initial_max_stream_data_uni = PROXY_H3_STREAM_WINDOW_SIZE;
+  t->initial_max_streams_bidi = PROXY_QUIC_MAX_STREAMS;
+  t->initial_max_streams_uni = PROXY_QUIC_MAX_STREAMS;
+  t->max_idle_timeout = 0; /* no idle timeout from our side */
+  if(ctx->qlogfd != -1) {
+    s->qlog_write = proxy_qlog_callback;
+  }
+}
+
+static void cf_ngtcp2_proxy_conn_close(struct Curl_cfilter *cf,
+                                 struct Curl_easy *data);
+
+static bool cf_ngtcp2_proxy_err_is_fatal(int code)
+{
+  return (NGTCP2_ERR_FATAL >= code) ||
+         (NGTCP2_ERR_DROP_CONN == code) ||
+         (NGTCP2_ERR_IDLE_CLOSE == code);
+}
+
+static void cf_ngtcp2_proxy_err_set(struct Curl_cfilter *cf,
+                              struct Curl_easy *data, int code)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  if(!ctx->last_error.error_code) {
+    if(NGTCP2_ERR_CRYPTO == code) {
+      ngtcp2_ccerr_set_tls_alert(&ctx->last_error,
+                                 ngtcp2_conn_get_tls_alert(ctx->qconn),
+                                 NULL, 0);
+    }
+    else {
+      ngtcp2_ccerr_set_liberr(&ctx->last_error, code, NULL, 0);
+    }
+  }
+  if(cf_ngtcp2_proxy_err_is_fatal(code))
+    cf_ngtcp2_proxy_conn_close(cf, data);
+}
+
+static bool cf_ngtcp2_proxy_h3_err_is_fatal(int code)
+{
+  return (NGHTTP3_ERR_FATAL >= code) ||
+         (NGHTTP3_ERR_H3_CLOSED_CRITICAL_STREAM == code);
+}
+
+static void cf_ngtcp2_proxy_h3_err_set(struct Curl_cfilter *cf,
+                                 struct Curl_easy *data, int code)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  if(!ctx->last_error.error_code) {
+    ngtcp2_ccerr_set_application_error(&ctx->last_error,
+      nghttp3_err_infer_quic_app_error_code(code), NULL, 0);
+  }
+  if(cf_ngtcp2_proxy_h3_err_is_fatal(code))
+    cf_ngtcp2_proxy_conn_close(cf, data);
+}
+
+/* How to access `call_data` from a cf_h3_proxy filter */
+#undef CF_CTX_CALL_DATA
+static struct cf_call_data *cf_h3_proxy_call_data(struct Curl_cfilter *cf)
+{
+  struct cf_h3_proxy_ctx *ctx = cf ? cf->ctx : NULL;
+  static struct cf_call_data no_ctx;
+
+  if(!ctx)
+    return &no_ctx;
+  if(ctx->ngtcp2_ctx)
+    return &ctx->ngtcp2_ctx->call_data;
+  return &ctx->call_data;
+}
+
+#define CF_CTX_CALL_DATA(cf) (*cf_h3_proxy_call_data(cf))
+
+static void cf_h3_proxy_ctx_clear(struct cf_h3_proxy_ctx *ctx)
+{
+  Curl_bufq_free(&ctx->inbufq);
+  Curl_peer_unlink(&ctx->dest);
+  h3_tunnel_stream_clear(&ctx->tunnel);
+  memset(ctx, 0, sizeof(*ctx));
+}
+
+static void cf_h3_proxy_ctx_free(struct cf_h3_proxy_ctx *ctx)
+{
+  if(ctx) {
+    cf_h3_proxy_ctx_clear(ctx);
+    curlx_free(ctx);
+  }
+}
+
+static CURLcode h3_proxy_data_setup(struct Curl_cfilter *cf,
+                              struct Curl_easy *data)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct h3_proxy_stream_ctx *stream = NULL;
+
+  if(!data)
+    return CURLE_FAILED_INIT;
+
+  if(!ctx)
+    return CURLE_FAILED_INIT;
+
+  stream = H3_PROXY_STREAM_CTX(ctx, data);
+  if(stream)
+    return CURLE_OK;
+
+  stream = curlx_calloc(1, sizeof(*stream));
+  if(!stream)
+    return CURLE_OUT_OF_MEMORY;
+
+  stream->id = -1;
+  stream->rx_offset = 0;
+  stream->rx_offset_max = PROXY_H3_STREAM_WINDOW_SIZE;
+  /* on send, we control how much we put into the buffer */
+  Curl_bufq_initp(&stream->sendbuf, &ctx->stream_bufcp,
+                  PROXY_H3_STREAM_SEND_CHUNKS, BUFQ_OPT_NONE);
+  stream->sendbuf_len_in_flight = 0;
+  stream->window_size_max = PROXY_H3_STREAM_WINDOW_SIZE;
+
+  if(!Curl_uint32_hash_set(&ctx->streams, data->mid, stream)) {
+    h3_proxy_stream_ctx_free(stream);
+    return CURLE_OUT_OF_MEMORY;
+  }
+
+  if(Curl_uint32_hash_count(&ctx->streams) == 1)
+    cf_ngtcp2_proxy_setup_keep_alive(cf, data);
+
+  return CURLE_OK;
+}
+
+static int cb_h3_proxy_acked_req_body(nghttp3_conn *conn, int64_t stream_id,
+                                uint64_t datalen, void *user_data,
+                                void *stream_user_data)
+{
+  struct Curl_cfilter *cf = user_data;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct Curl_easy *data = stream_user_data;
+  struct h3_proxy_stream_ctx *stream;
+  size_t skiplen;
+
+  if(!ctx)
+    return 0;
+  stream = H3_PROXY_STREAM_CTX(ctx, data);
+  if(!stream)
+    return 0;
+  /* The server acknowledged `datalen` of bytes from our request body.
+   * This is a delta. We have kept this data in `sendbuf` for
+   * re-transmissions and can free it now. */
+  if(datalen >= (uint64_t)stream->sendbuf_len_in_flight)
+    skiplen = stream->sendbuf_len_in_flight;
+  else
+    skiplen = (size_t)datalen;
+  Curl_bufq_skip(&stream->sendbuf, skiplen);
+  stream->sendbuf_len_in_flight -= skiplen;
+
+  /* Resume upload processing if we have more data to send */
+  if(stream->sendbuf_len_in_flight < Curl_bufq_len(&stream->sendbuf)) {
+    int rv = nghttp3_conn_resume_stream(conn, stream_id);
+    if(rv && rv != NGHTTP3_ERR_STREAM_NOT_FOUND) {
+      return NGHTTP3_ERR_CALLBACK_FAILURE;
+    }
+  }
+  return 0;
+}
+
+static int cb_h3_proxy_stream_close(nghttp3_conn *conn, int64_t stream_id,
+                              uint64_t app_error_code, void *user_data,
+                              void *stream_user_data)
+{
+  struct Curl_cfilter *cf = user_data;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct Curl_easy *data = stream_user_data;
+  struct h3_proxy_stream_ctx *stream;
+  bool tunnel_stream = FALSE;
+  (void)conn;
+
+  if(!ctx)
+    return 0;
+  stream = H3_PROXY_STREAM_CTX(ctx, data);
+  tunnel_stream = (stream_id == proxy_ctx->tunnel.stream_id);
+  /* we might be called by nghttp3 after we already cleaned up */
+  if(!stream) {
+    if(tunnel_stream) {
+      proxy_ctx->tunnel.stream = NULL;
+      proxy_ctx->tunnel.closed = TRUE;
+    }
+    return 0;
+  }
+
+  stream->closed = TRUE;
+  stream->error3 = app_error_code;
+  if(stream->error3 != NGHTTP3_H3_NO_ERROR) {
+    stream->reset = TRUE;
+    stream->send_closed = TRUE;
+    CURL_TRC_CF(data, cf, "[%" PRId64 "] RESET: error %" PRIu64,
+                H3_STREAM_ID(stream), stream->error3);
+  }
+  else {
+    CURL_TRC_CF(data, cf, "[%" PRId64 "] CLOSED", H3_STREAM_ID(stream));
+  }
+  if(tunnel_stream) {
+    proxy_ctx->tunnel.stream = NULL;
+    proxy_ctx->tunnel.closed = TRUE;
+  }
+  Curl_multi_mark_dirty(data);
+  return 0;
+}
+
+static void cf_h3_proxy_upd_rx_win(struct Curl_cfilter *cf,
+                                   struct Curl_easy *data,
+                                   struct h3_proxy_stream_ctx *stream)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  uint64_t cur_win, wanted_win = PROXY_H3_STREAM_WINDOW_SIZE_MAX;
+
+  /* how much does rate limiting allow us to acknowledge? */
+  if(Curl_rlimit_active(&data->progress.dl.rlimit)) {
+    int64_t avail;
+
+    /* start rate limit updates only after first bytes arrived */
+    if(!stream->rx_offset)
+      return;
+
+    avail = Curl_rlimit_avail(&data->progress.dl.rlimit,
+                              Curl_pgrs_now(data));
+    if(avail <= 0) {
+      /* nothing available, do not extend the rx offset */
+      CURL_TRC_CF(data, cf, "[%" PRId64 "] dl rate limit exhausted (%" PRId64
+                  " tokens)", stream->id, avail);
+      return;
+    }
+    wanted_win = CURLMIN((uint64_t)avail, PROXY_H3_STREAM_WINDOW_SIZE_MAX);
+  }
+
+  if(stream->rx_offset_max < stream->rx_offset) {
+    DEBUGASSERT(0);
+    return;
+  }
+  cur_win = stream->rx_offset_max - stream->rx_offset;
+  if(cur_win < wanted_win) {
+    /* We have exhausted the credit we gave the QUIC peer for DATA.
+     * We extend it with the amount we can give (rate limit) */
+    uint64_t ext = wanted_win - cur_win;
+
+    ngtcp2_conn_extend_max_stream_offset(ctx->qconn, stream->id, ext);
+    ngtcp2_conn_extend_max_offset(ctx->qconn, ext);
+    stream->rx_offset_max += ext;
+    if(stream->rx_offset_max > stream->window_size_max) {
+      stream->window_size_max = stream->rx_offset_max;
+      CURL_TRC_CF(data, cf, "[%" PRId64 "] max window now -> %" PRIu64,
+                  stream->id, stream->window_size_max);
+    }
+    CURL_TRC_CF(data, cf, "[%" PRId64 "] rx_offset_max -> %" PRIu64
+                " (ext %" PRIu64 ", win %" PRIu64 ")",
+                stream->id, stream->rx_offset_max, ext, wanted_win);
+  }
+}
+
+static int cb_h3_proxy_recv_data(nghttp3_conn *conn, int64_t stream3_id,
+                           const uint8_t *buf, size_t buflen,
+                           void *user_data, void *stream_user_data)
+{
+  struct Curl_cfilter *cf = user_data;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct Curl_easy *data = stream_user_data;
+  struct h3_proxy_stream_ctx *stream;
+  size_t nwritten;
+  CURLcode result = CURLE_OK;
+  (void)conn;
+  (void)stream3_id;
+
+  if(!ctx)
+    return NGHTTP3_ERR_CALLBACK_FAILURE;
+  stream = H3_PROXY_STREAM_CTX(ctx, data);
+  if(!stream) {
+    return NGHTTP3_ERR_CALLBACK_FAILURE;
+  }
+
+  stream->tun_data_recvd += (curl_off_t)buflen;
+  CURL_TRC_CF(data, cf, "[cb_h3_proxy_recv_data] "
+              "[%" PRIu64 "] DATA len=%zu, total=%zd",
+              H3_STREAM_ID(stream), buflen, stream->tun_data_recvd);
+
+  result = Curl_bufq_write(&proxy_ctx->inbufq, buf, buflen, &nwritten);
+  if(result || (nwritten < buflen)) {
+    return NGHTTP3_ERR_CALLBACK_FAILURE;
+  }
+
+  /* DATA has been moved into our local recv buffer. Update stream offsets
+   * and give QUIC read credit back so long transfers over proxy tunnels
+   * do not stall on stream/connection flow-control limits. */
+  stream->rx_offset += buflen;
+  if(stream->rx_offset_max < stream->rx_offset)
+    stream->rx_offset_max = stream->rx_offset;
+
+  CURL_TRC_CF(data, cf, "[%" PRId64 "] DATA len=%zu, rx win=%" PRIu64,
+              stream->id, buflen, stream->rx_offset_max - stream->rx_offset);
+  cf_h3_proxy_upd_rx_win(cf, data, stream);
+
+  Curl_multi_mark_dirty(data);
+  return 0;
+}
+
+static int cb_h3_proxy_deferred_consume(nghttp3_conn *conn, int64_t stream_id,
+                                  size_t consumed, void *user_data,
+                                  void *stream_user_data)
+{
+  struct Curl_cfilter *cf = user_data;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  (void)conn;
+  (void)stream_user_data;
+
+  if(!ctx)
+    return 0;
+
+  /* nghttp3 has consumed bytes on the QUIC stream and we need to
+   * tell the QUIC connection to increase its flow control */
+  ngtcp2_conn_extend_max_stream_offset(ctx->qconn, stream_id, consumed);
+  ngtcp2_conn_extend_max_offset(ctx->qconn, consumed);
+
+  return 0;
+}
+
+static int cb_h3_proxy_recv_header(nghttp3_conn *conn, int64_t sid,
+                             int32_t token, nghttp3_rcbuf *name,
+                             nghttp3_rcbuf *value, uint8_t flags,
+                             void *user_data, void *stream_user_data)
+{
+  struct Curl_cfilter *cf = user_data;
+  int64_t stream_id = sid;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  nghttp3_vec h3name = nghttp3_rcbuf_get_buf(name);
+  nghttp3_vec h3val = nghttp3_rcbuf_get_buf(value);
+  struct Curl_easy *data = stream_user_data;
+  struct h3_proxy_stream_ctx *stream;
+  CURLcode result = CURLE_OK;
+  int http_status;
+  struct http_resp *resp;
+  (void)conn;
+  (void)stream_id;
+  (void)token;
+  (void)flags;
+
+  /* stream_user_data might be NULL for control streams */
+  if(!data) {
+    /* Silently ignore headers on streams without user data (control, etc) */
+    return 0;
+  }
+
+  if(!ctx)
+    return 0;
+  stream = H3_PROXY_STREAM_CTX(ctx, data);
+  if(!stream) {
+    CURL_TRC_CF(data, cf, "[%" PRId64 "] recv_header: stream lookup "
+                "failed for data=%p mid=%u",
+                stream_id, (void *)data, data ? data->mid : 0);
+  }
+
+  /* we might have cleaned up this transfer already */
+  if(!stream)
+    return 0;
+
+  if(proxy_ctx->tunnel.has_final_response) {
+    /* we do not do anything with trailers for tunnel streams */
+    return 0;
+  }
+
+  if(token == NGHTTP3_QPACK_TOKEN__STATUS) {
+    result = Curl_http_decode_status(&stream->status_code,
+                                     (const char *)h3val.base, h3val.len);
+    if(result)
+      return NGHTTP3_ERR_CALLBACK_FAILURE;
+    http_status = stream->status_code;
+    result = Curl_http_resp_make(&resp, http_status, NULL);
+    if(result)
+      return NGHTTP3_ERR_CALLBACK_FAILURE;
+    if(proxy_ctx->tunnel.resp)
+      Curl_http_resp_free(proxy_ctx->tunnel.resp);
+    proxy_ctx->tunnel.resp = resp;
+  }
+  else {
+    /* store as an HTTP1-style header */
+    CURL_TRC_CF(data, cf, "[%" PRId64 "] header: %.*s: %.*s",
+                stream_id, (int)h3name.len, h3name.base,
+                (int)h3val.len, h3val.base);
+    result = Curl_dynhds_add(&proxy_ctx->tunnel.resp->headers,
+      (const char *)h3name.base, h3name.len,
+      (const char *)h3val.base, h3val.len);
+    if(result) {
+      return -1;
+    }
+  }
+  return 0;
+}
+
+static int cb_h3_proxy_end_headers(nghttp3_conn *conn, int64_t sid,
+                             int fin, void *user_data, void *stream_user_data)
+{
+  struct Curl_cfilter *cf = user_data;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct Curl_easy *data = stream_user_data;
+  int64_t stream_id = sid;
+  struct h3_proxy_stream_ctx *stream;
+  (void)conn;
+  (void)stream_id;
+  (void)fin;
+
+  /* stream_user_data might be NULL for control streams */
+  if(!data) {
+    /* Silently ignore for streams without user data */
+    return 0;
+  }
+
+  if(!ctx)
+    return 0;
+  stream = H3_PROXY_STREAM_CTX(ctx, data);
+  if(!stream) {
+    CURL_TRC_CF(data, cf, "[%" PRId64 "] end_headers: stream lookup "
+                "failed for data=%p mid=%u",
+                stream_id, (void *)data, data ? data->mid : 0);
+  }
+
+  if(!stream)
+    return 0;
+
+  CURL_TRC_CF(data, cf, "[%" PRId64 "] end_headers, status=%d",
+              stream_id, stream->status_code);
+
+  if(!proxy_ctx->tunnel.has_final_response) {
+    if(stream->status_code / 100 != 1) {
+      proxy_ctx->tunnel.has_final_response = TRUE;
+    }
+  }
+
+  if(stream->status_code / 100 != 1) {
+    stream->resp_hds_complete = TRUE;
+  }
+
+  Curl_multi_mark_dirty(data);
+  return 0;
+}
+
+static int cb_h3_proxy_stop_sending(nghttp3_conn *conn, int64_t sid,
+                              uint64_t app_error_code, void *user_data,
+                              void *stream_user_data)
+{
+  struct Curl_cfilter *cf = user_data;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  (void)conn;
+
+  (void)stream_user_data;
+
+  if(ctx) {
+    int rv = ngtcp2_conn_shutdown_stream_read(ctx->qconn, 0, sid,
+                                              app_error_code);
+
+    if(rv && rv != NGTCP2_ERR_STREAM_NOT_FOUND) {
+      return NGHTTP3_ERR_CALLBACK_FAILURE;
+    }
+  }
+
+  return 0;
+}
+
+static int cb_h3_proxy_reset_stream(nghttp3_conn *conn, int64_t sid,
+                              uint64_t app_error_code, void *user_data,
+                              void *stream_user_data)
+{
+  struct Curl_cfilter *cf = user_data;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct Curl_easy *data = stream_user_data;
+  int64_t stream_id = sid;
+  int rv;
+  (void)conn;
+
+  if(!ctx)
+    return 0;
+
+  rv = ngtcp2_conn_shutdown_stream_write(ctx->qconn, 0, stream_id,
+                                         app_error_code);
+  CURL_TRC_CF(data, cf, "[%" PRId64 "] reset -> %d", stream_id, rv);
+  if(stream_id == proxy_ctx->tunnel.stream_id) {
+    proxy_ctx->tunnel.stream = NULL;
+    proxy_ctx->tunnel.closed = TRUE;
+  }
+  if(rv && rv != NGTCP2_ERR_STREAM_NOT_FOUND) {
+    return NGHTTP3_ERR_CALLBACK_FAILURE;
+  }
+
+  return 0;
+}
+
+static nghttp3_ssize
+cb_h3_read_data_for_tunnel_stream(nghttp3_conn *conn, int64_t stream_id,
+                                  nghttp3_vec *vec, size_t veccnt,
+                                  uint32_t *pflags, void *user_data,
+                                  void *stream_user_data)
+{
+  struct Curl_cfilter *cf = user_data;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct Curl_easy *data = stream_user_data;
+  struct h3_proxy_stream_ctx *stream;
+  size_t nwritten = 0;
+  size_t nvecs = 0;
+  const unsigned char *buf_base;
+  (void)conn;
+  (void)stream_id;
+  (void)veccnt;
+
+  if(!ctx)
+    return NGHTTP3_ERR_CALLBACK_FAILURE;
+  stream = H3_PROXY_STREAM_CTX(ctx, data);
+
+  if(!stream)
+    return NGHTTP3_ERR_CALLBACK_FAILURE;
+  /* nghttp3 keeps references to the sendbuf data until it is ACKed
+   * by the server (see `cb_h3_proxy_acked_req_body()` for updates).
+   * `sendbuf_len_in_flight` is the amount of bytes in `sendbuf`
+   * that we have already passed to nghttp3, but which have not been
+   * ACKed yet.
+   * Any amount beyond `sendbuf_len_in_flight` we need still to pass
+   * to nghttp3. Do that now, if we can. */
+  if(stream->sendbuf_len_in_flight < Curl_bufq_len(&stream->sendbuf)) {
+    nvecs = 0;
+    while(nvecs < veccnt) {
+      if(!Curl_bufq_peek_at(&stream->sendbuf,
+                           stream->sendbuf_len_in_flight,
+                           &buf_base,
+                           &vec[nvecs].len))
+        break;
+      vec[nvecs].base = (uint8_t *)(uintptr_t)buf_base;
+      stream->sendbuf_len_in_flight += vec[nvecs].len;
+      nwritten += vec[nvecs].len;
+      ++nvecs;
+    }
+    DEBUGASSERT(nvecs > 0); /* we SHOULD have been be able to peek */
+  }
+
+  if(nwritten > 0 &&
+     stream->upload_left != -1 &&
+     (H3_STREAM_ID(stream) != proxy_ctx->tunnel.stream_id))
+    stream->upload_left -= nwritten;
+
+  /* When we stopped sending and everything in `sendbuf` is "in flight",
+   * we are at the end of the request body. */
+  /* We should NOT set send_closed = TRUE for tunnel stream */
+  if(stream->upload_left == 0 &&
+     (H3_STREAM_ID(stream) != proxy_ctx->tunnel.stream_id)) {
+    *pflags = NGHTTP3_DATA_FLAG_EOF;
+    stream->send_closed = TRUE;
+  }
+
+  else if(!nwritten) {
+    /* Not EOF, and nothing to give, we signal WOULDBLOCK. */
+    CURL_TRC_CF(data, cf, "[%" PRId64 "] read req body -> AGAIN",
+                H3_STREAM_ID(stream));
+    return NGHTTP3_ERR_WOULDBLOCK;
+  }
+
+  CURL_TRC_CF(data, cf, "[%" PRId64 "] read req body -> "
+              "%d vecs%s with %zd (buffered=%zu, left=%" FMT_OFF_T ")",
+              H3_STREAM_ID(stream), (int)nvecs,
+              *pflags == NGHTTP3_DATA_FLAG_EOF ? " EOF" : "",
+              nwritten, Curl_bufq_len(&stream->sendbuf),
+              stream->upload_left);
+  return (nghttp3_ssize)nvecs;
+}
+
+static nghttp3_callbacks ngh3_proxy_callbacks = {
+  cb_h3_proxy_acked_req_body, /* acked_stream_data */
+  cb_h3_proxy_stream_close,
+  cb_h3_proxy_recv_data,
+  cb_h3_proxy_deferred_consume,
+  NULL, /* begin_headers */
+  cb_h3_proxy_recv_header,
+  cb_h3_proxy_end_headers,
+  NULL, /* begin_trailers */
+  cb_h3_proxy_recv_header,
+  NULL, /* end_trailers */
+  cb_h3_proxy_stop_sending,
+  NULL, /* end_stream */
+  cb_h3_proxy_reset_stream,
+  NULL, /* shutdown */
+  NULL, /* recv_settings (deprecated) */
+#ifdef NGHTTP3_CALLBACKS_V2 /* nghttp3 v1.11.0+ */
+  NULL, /* recv_origin */
+  NULL, /* end_origin */
+  NULL, /* rand */
+#endif /* NGHTTP3_CALLBACKS_V2 */
+#ifdef NGHTTP3_CALLBACKS_V3  /* nghttp3 v1.14.0+ */
+  NULL, /* recv_settings2 */
+#endif /* NGHTTP3_CALLBACKS_V3 */
+};
+
+#if NGTCP2_VERSION_NUM < 0x011100
+struct cf_ngtcp2_proxy_sfind_ctx {
+  int64_t stream_id;
+  struct h3_proxy_stream_ctx *stream;
+  uint32_t mid;
+};
+
+static bool cf_ngtcp2_proxy_sfind(uint32_t mid, void *value,
+                                  void *user_data)
+{
+  struct cf_ngtcp2_proxy_sfind_ctx *fctx = user_data;
+  struct h3_proxy_stream_ctx *stream = value;
+
+  if(fctx->stream_id == H3_STREAM_ID(stream)) {
+    fctx->mid = mid;
+    fctx->stream = stream;
+    return FALSE;
+  }
+  return TRUE; /* continue */
+}
+
+static struct h3_proxy_stream_ctx *
+cf_ngtcp2_proxy_get_stream(struct cf_ngtcp2_proxy_ctx *ctx, int64_t stream_id)
+{
+  struct cf_ngtcp2_proxy_sfind_ctx fctx;
+  fctx.stream_id = stream_id;
+  fctx.stream = NULL;
+  Curl_uint32_hash_visit(&ctx->streams, cf_ngtcp2_proxy_sfind, &fctx);
+  return fctx.stream;
+}
+#else
+static struct h3_proxy_stream_ctx *
+cf_ngtcp2_proxy_get_stream(struct cf_ngtcp2_proxy_ctx *ctx, int64_t stream_id)
+{
+  struct Curl_easy *data =
+    ngtcp2_conn_get_stream_user_data(ctx->qconn, stream_id);
+
+  if(!data) {
+    return NULL;
+  }
+  return H3_PROXY_STREAM_CTX(ctx, data);
+}
+#endif /* NGTCP2_VERSION_NUM < 0x011100 */
+
+static CURLcode cf_ngtcp2_h3conn_init(struct Curl_cfilter *cf,
+                                      struct Curl_easy *data)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  int64_t ctrl_stream_id, qpack_enc_stream_id, qpack_dec_stream_id;
+  int rc;
+
+  if(ngtcp2_conn_get_streams_uni_left(ctx->qconn) < 3) {
+    failf(data, "QUIC connection lacks 3 uni streams to run HTTP/3");
+    return CURLE_QUIC_CONNECT_ERROR;
+  }
+
+  nghttp3_settings_default(&ctx->h3settings);
+
+  rc = nghttp3_conn_client_new(&ctx->h3conn,
+                               &ngh3_proxy_callbacks,
+                               &ctx->h3settings,
+                               Curl_nghttp3_mem(),
+                               cf);
+  if(rc) {
+    failf(data, "error creating nghttp3 connection instance");
+    return CURLE_OUT_OF_MEMORY;
+  }
+
+  rc = ngtcp2_conn_open_uni_stream(ctx->qconn, &ctrl_stream_id, NULL);
+  if(rc) {
+    failf(data, "error creating HTTP/3 control stream: %s",
+          ngtcp2_strerror(rc));
+    return CURLE_QUIC_CONNECT_ERROR;
+  }
+
+  rc = nghttp3_conn_bind_control_stream(ctx->h3conn, ctrl_stream_id);
+  if(rc) {
+    failf(data, "error binding HTTP/3 control stream: %s",
+          ngtcp2_strerror(rc));
+    return CURLE_QUIC_CONNECT_ERROR;
+  }
+
+  rc = ngtcp2_conn_open_uni_stream(ctx->qconn, &qpack_enc_stream_id, NULL);
+  if(rc) {
+    failf(data, "error creating HTTP/3 qpack encoding stream: %s",
+          ngtcp2_strerror(rc));
+    return CURLE_QUIC_CONNECT_ERROR;
+  }
+
+  rc = ngtcp2_conn_open_uni_stream(ctx->qconn, &qpack_dec_stream_id, NULL);
+  if(rc) {
+    failf(data, "error creating HTTP/3 qpack decoding stream: %s",
+          ngtcp2_strerror(rc));
+    return CURLE_QUIC_CONNECT_ERROR;
+  }
+
+  rc = nghttp3_conn_bind_qpack_streams(ctx->h3conn, qpack_enc_stream_id,
+                                       qpack_dec_stream_id);
+  if(rc) {
+    failf(data, "error binding HTTP/3 qpack streams: %s",
+          ngtcp2_strerror(rc));
+    return CURLE_QUIC_CONNECT_ERROR;
+  }
+
+  CURL_TRC_CF(data, cf, "HTTP/3 connection initialized");
+  return CURLE_OK;
+}
+
+static int cb_ngtcp2_proxy_handshake_completed(ngtcp2_conn *tconn,
+                                               void *user_data)
+{
+  struct Curl_cfilter *cf = user_data;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct Curl_easy *data;
+
+  (void)tconn;
+  DEBUGASSERT(ctx);
+  data = CF_DATA_CURRENT(cf);
+  DEBUGASSERT(data);
+  if(!ctx || !data)
+    return NGHTTP3_ERR_CALLBACK_FAILURE;
+
+  ctx->handshake_at = *Curl_pgrs_now(data);
+  ctx->tls_handshake_complete = TRUE;
+  Curl_vquic_report_handshake(&ctx->tls, cf, data);
+
+  ctx->tls_vrfy_result = Curl_vquic_tls_verify_peer(&ctx->tls, cf,
+                                                    data, &ctx->peer);
+#ifdef CURLVERBOSE
+  if(Curl_trc_is_verbose(data)) {
+    const ngtcp2_transport_params *rp;
+    rp = ngtcp2_conn_get_remote_transport_params(ctx->qconn);
+    CURL_TRC_CF(data, cf, "handshake complete after %" FMT_TIMEDIFF_T
+                "ms, remote transport[max_udp_payload=%" PRIu64
+                ", initial_max_data=%" PRIu64
+                "]",
+               curlx_ptimediff_ms(&ctx->handshake_at, &ctx->started_at),
+               rp->max_udp_payload_size, rp->initial_max_data);
+  }
+#endif
+
+  /* In case of earlydata, where we simulate being connected, update
+   * the handshake time when we really did connect */
+  if(ctx->use_earlydata)
+    Curl_pgrsTimeWas(data, TIMER_APPCONNECT, ctx->handshake_at);
+  if(ctx->use_earlydata) {
+#if defined(USE_OPENSSL) && defined(HAVE_OPENSSL_EARLYDATA)
+    ctx->earlydata_accepted =
+      (SSL_get_early_data_status(ctx->tls.ossl.ssl) !=
+       SSL_EARLY_DATA_REJECTED);
+#endif
+#ifdef USE_GNUTLS
+    int flags = gnutls_session_get_flags(ctx->tls.gtls.session);
+    ctx->earlydata_accepted = !!(flags & GNUTLS_SFLAGS_EARLY_DATA);
+#endif /* USE_GNUTLS */
+#ifdef USE_WOLFSSL
+#ifdef WOLFSSL_EARLY_DATA
+    ctx->earlydata_accepted =
+      (wolfSSL_get_early_data_status(ctx->tls.wssl.ssl) !=
+       WOLFSSL_EARLY_DATA_REJECTED);
+#else
+    DEBUGASSERT(0); /* should not come here if ED is disabled. */
+    ctx->earlydata_accepted = FALSE;
+#endif /* WOLFSSL_EARLY_DATA */
+#endif /* USE_WOLFSSL */
+    CURL_TRC_CF(data, cf, "server did%s accept %zu bytes of early data",
+                ctx->earlydata_accepted ? "" : " not", ctx->earlydata_skip);
+    Curl_pgrsEarlyData(data, ctx->earlydata_accepted ?
+                              (curl_off_t)ctx->earlydata_skip :
+                             -(curl_off_t)ctx->earlydata_skip);
+  }
+
+  /* Initialize HTTP/3 connection after successful handshake */
+  if(!ctx->h3conn) {
+    CURLcode result = cf_ngtcp2_h3conn_init(cf, data);
+    if(result) {
+      CURL_TRC_CF(data, cf, "HTTP/3 initialization failed: %d", result);
+      return NGHTTP3_ERR_CALLBACK_FAILURE;
+    }
+  }
+
+  return 0;
+}
+
+static int cb_ngtcp2_recv_stream_data(ngtcp2_conn *tconn, uint32_t flags,
+                                      int64_t sid, uint64_t offset,
+                                      const uint8_t *buf, size_t buflen,
+                                      void *user_data, void *stream_user_data)
+{
+  struct Curl_cfilter *cf = user_data;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  int64_t stream_id = (int64_t)sid;
+  nghttp3_ssize nconsumed;
+  int fin = (flags & NGTCP2_STREAM_DATA_FLAG_FIN) ? 1 : 0;
+  struct Curl_easy *data = stream_user_data;
+  (void)offset;
+  (void)data;
+
+  nconsumed =
+    nghttp3_conn_read_stream(ctx->h3conn, stream_id, buf, buflen, fin);
+  if(!data)
+    data = CF_DATA_CURRENT(cf);
+  if(data)
+    CURL_TRC_CF(data, cf, "[%" PRId64 "] read_stream(len=%zu) -> %zd",
+                stream_id, buflen, nconsumed);
+  if(nconsumed < 0) {
+    struct h3_proxy_stream_ctx *stream = H3_PROXY_STREAM_CTX(ctx, data);
+    if(data && stream) {
+      CURL_TRC_CF(data, cf, "[%" PRId64 "] error on known stream, "
+                  "reset=%d, closed=%d",
+                  stream_id, stream->reset, stream->closed);
+    }
+    return NGTCP2_ERR_CALLBACK_FAILURE;
+  }
+
+  /* number of bytes inside buflen which consists of framing overhead
+   * including QPACK HEADERS. In other words, it does not consume payload of
+   * DATA frame. */
+  if(nconsumed) {
+    ngtcp2_conn_extend_max_stream_offset(tconn, stream_id,
+                                         (uint64_t)nconsumed);
+    ngtcp2_conn_extend_max_offset(tconn, (uint64_t)nconsumed);
+  }
+
+  return 0;
+}
+
+static int cb_ngtcp2_acked_stream_data_offset(ngtcp2_conn *tconn,
+                                              int64_t stream_id,
+                                              uint64_t offset,
+                                              uint64_t datalen,
+                                              void *user_data,
+                                              void *stream_user_data)
+{
+  struct Curl_cfilter *cf = user_data;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  int rv;
+  (void)stream_id;
+  (void)tconn;
+  (void)offset;
+  (void)datalen;
+  (void)stream_user_data;
+
+  rv = nghttp3_conn_add_ack_offset(ctx->h3conn, stream_id, datalen);
+  if(rv && rv != NGHTTP3_ERR_STREAM_NOT_FOUND) {
+    return NGTCP2_ERR_CALLBACK_FAILURE;
+  }
+  return 0;
+}
+
+static int cb_ngtcp2_stream_close(ngtcp2_conn *tconn, uint32_t flags,
+                                  int64_t sid, uint64_t app_error_code,
+                                  void *user_data, void *stream_user_data)
+{
+  struct Curl_cfilter *cf = user_data;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct Curl_easy *data = stream_user_data;
+  int64_t stream_id = (int64_t)sid;
+  int rv;
+
+  (void)tconn;
+  /* stream is closed... */
+  if(!data)
+    data = CF_DATA_CURRENT(cf);
+  if(!data)
+    return NGTCP2_ERR_CALLBACK_FAILURE;
+
+  if(!(flags & NGTCP2_STREAM_CLOSE_FLAG_APP_ERROR_CODE_SET)) {
+    app_error_code = NGHTTP3_H3_NO_ERROR;
+  }
+
+  rv = nghttp3_conn_close_stream(ctx->h3conn, stream_id, app_error_code);
+  CURL_TRC_CF(data, cf, "[%" PRId64 "] quic close(app_error=%"
+              PRIu64 ") -> %d", stream_id, (uint64_t)app_error_code,
+              rv);
+  if(rv && rv != NGHTTP3_ERR_STREAM_NOT_FOUND) {
+    cf_ngtcp2_proxy_h3_err_set(cf, data, rv);
+    return NGTCP2_ERR_CALLBACK_FAILURE;
+  }
+  return 0;
+}
+
+static int cb_ngtcp2_extend_max_local_streams_bidi(ngtcp2_conn *tconn,
+                                                   uint64_t max_streams,
+                                                   void *user_data)
+{
+  struct Curl_cfilter *cf = user_data;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct Curl_easy *data = CF_DATA_CURRENT(cf);
+
+  (void)tconn;
+  ctx->max_bidi_streams = max_streams;
+  if(data)
+    CURL_TRC_CF(data, cf, "max bidi streams now %" PRIu64
+                ", used %" PRIu64, (uint64_t)ctx->max_bidi_streams,
+                (uint64_t)ctx->used_bidi_streams);
+  return 0;
+}
+
+static void cb_ngtcp2_rand(uint8_t *dest, size_t destlen,
+                           const ngtcp2_rand_ctx *rand_ctx)
+{
+  CURLcode result;
+  (void)rand_ctx;
+
+  result = Curl_rand(NULL, dest, destlen);
+  if(result) {
+    /* cb_rand is only used for non-cryptographic context. If Curl_rand
+       failed, just fill 0 and call it *random*. */
+    memset(dest, 0, destlen);
+  }
+}
+
+/* for ngtcp2 <v1.22.0 */
+static int cb_ngtcp2_get_new_connection_id(ngtcp2_conn *tconn, ngtcp2_cid *cid,
+                                           uint8_t *token, size_t cidlen,
+                                           void *user_data)
+{
+  CURLcode result;
+  (void)tconn;
+  (void)user_data;
+
+  result = Curl_rand(NULL, cid->data, cidlen);
+  if(result)
+    return NGTCP2_ERR_CALLBACK_FAILURE;
+  cid->datalen = cidlen;
+
+  result = Curl_rand(NULL, token, NGTCP2_STATELESS_RESET_TOKENLEN);
+  if(result)
+    return NGTCP2_ERR_CALLBACK_FAILURE;
+
+  return 0;
+}
+
+#ifdef NGTCP2_CALLBACKS_V3  /* ngtcp2 v1.22.0+ */
+static int cb_ngtcp2_get_new_connection_id2(ngtcp2_conn *tconn,
+  ngtcp2_cid *cid, struct ngtcp2_stateless_reset_token *token,
+  size_t cidlen, void *user_data)
+{
+  CURLcode result;
+  (void)tconn;
+  (void)user_data;
+
+  result = Curl_rand(NULL, cid->data, cidlen);
+  if(result)
+    return NGTCP2_ERR_CALLBACK_FAILURE;
+  cid->datalen = cidlen;
+
+  result = Curl_rand(NULL, token->data, sizeof(token->data));
+  if(result)
+    return NGTCP2_ERR_CALLBACK_FAILURE;
+
+  return 0;
+}
+#endif
+
+static int cb_ngtcp2_stream_reset(ngtcp2_conn *tconn, int64_t sid,
+                                  uint64_t final_size, uint64_t app_error_code,
+                                  void *user_data, void *stream_user_data)
+{
+  struct Curl_cfilter *cf = user_data;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  int64_t stream_id = (int64_t)sid;
+  struct Curl_easy *data = stream_user_data;
+  int rv;
+  (void)tconn;
+  (void)final_size;
+  (void)app_error_code;
+  (void)data;
+
+  rv = nghttp3_conn_shutdown_stream_read(ctx->h3conn, stream_id);
+  CURL_TRC_CF(data, cf, "[%" PRId64 "] reset -> %d", stream_id, rv);
+  if(stream_id == proxy_ctx->tunnel.stream_id) {
+    proxy_ctx->tunnel.stream = NULL;
+    proxy_ctx->tunnel.closed = TRUE;
+  }
+  if(rv && rv != NGHTTP3_ERR_STREAM_NOT_FOUND) {
+    return NGTCP2_ERR_CALLBACK_FAILURE;
+  }
+  return 0;
+}
+
+static int cb_ngtcp2_extend_max_stream_data(ngtcp2_conn *tconn,
+                                            int64_t stream_id,
+                                            uint64_t max_data, void *user_data,
+                                            void *stream_user_data)
+{
+  struct Curl_cfilter *cf = user_data;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct Curl_easy *s_data = stream_user_data;
+  struct h3_proxy_stream_ctx *stream = NULL;
+  int rv;
+  (void)tconn;
+  (void)max_data;
+
+  rv = nghttp3_conn_unblock_stream(ctx->h3conn, stream_id);
+  if(rv && rv != NGHTTP3_ERR_STREAM_NOT_FOUND) {
+    return NGTCP2_ERR_CALLBACK_FAILURE;
+  }
+  stream = H3_PROXY_STREAM_CTX(ctx, s_data);
+  if(stream && stream->quic_flow_blocked) {
+    CURL_TRC_CF(s_data, cf, "[%" PRId64 "] unblock quic flow",
+                (int64_t)stream_id);
+    stream->quic_flow_blocked = FALSE;
+    Curl_multi_mark_dirty(s_data);
+  }
+  return 0;
+}
+
+static int cb_ngtcp2_stream_stop_sending(ngtcp2_conn *tconn, int64_t stream_id,
+                                         uint64_t app_error_code,
+                                         void *user_data,
+                                         void *stream_user_data)
+{
+  struct Curl_cfilter *cf = user_data;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  int rv;
+  (void)tconn;
+  (void)app_error_code;
+  (void)stream_user_data;
+
+  rv = nghttp3_conn_shutdown_stream_read(ctx->h3conn, stream_id);
+  if(rv && rv != NGHTTP3_ERR_STREAM_NOT_FOUND) {
+    return NGTCP2_ERR_CALLBACK_FAILURE;
+  }
+  return 0;
+}
+
+static int cb_ngtcp2_recv_rx_key(ngtcp2_conn *tconn,
+                                 ngtcp2_encryption_level level,
+                                 void *user_data)
+{
+  struct Curl_cfilter *cf = user_data;
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct Curl_easy *data = CF_DATA_CURRENT(cf);
+  (void)tconn;
+
+  if(level != NGTCP2_ENCRYPTION_LEVEL_1RTT)
+    return 0;
+
+  DEBUGASSERT(ctx);
+  DEBUGASSERT(data);
+  if(ctx && data && !ctx->h3conn) {
+    if(cf_ngtcp2_h3conn_init(cf, data))
+      return NGTCP2_ERR_CALLBACK_FAILURE;
+  }
+  return 0;
+}
+
+#if defined(_MSC_VER) && defined(_DLL)
+#pragma warning(push)
+#pragma warning(disable:4232) /* MSVC extension, dllimport identity */
+#endif
+
+static ngtcp2_callbacks ngtcp2_proxy_callbacks = {
+  ngtcp2_crypto_client_initial_cb,
+  NULL, /* recv_client_initial */
+  ngtcp2_crypto_recv_crypto_data_cb,
+  cb_ngtcp2_proxy_handshake_completed,
+  NULL, /* recv_version_negotiation */
+  ngtcp2_crypto_encrypt_cb,
+  ngtcp2_crypto_decrypt_cb,
+  ngtcp2_crypto_hp_mask_cb,
+  cb_ngtcp2_recv_stream_data,
+  cb_ngtcp2_acked_stream_data_offset,
+  NULL, /* stream_open */
+  cb_ngtcp2_stream_close,
+  NULL, /* recv_stateless_reset */
+  ngtcp2_crypto_recv_retry_cb,
+  cb_ngtcp2_extend_max_local_streams_bidi,
+  NULL, /* extend_max_local_streams_uni */
+  cb_ngtcp2_rand,
+  cb_ngtcp2_get_new_connection_id, /* for ngtcp2 <v1.22.0 */
+  NULL, /* remove_connection_id */
+  ngtcp2_crypto_update_key_cb,
+  NULL, /* path_validation */
+  NULL, /* select_preferred_addr */
+  cb_ngtcp2_stream_reset,
+  NULL, /* extend_max_remote_streams_bidi */
+  NULL, /* extend_max_remote_streams_uni */
+  cb_ngtcp2_extend_max_stream_data,
+  NULL, /* dcid_status */
+  NULL, /* handshake_confirmed */
+  NULL, /* recv_new_token */
+  ngtcp2_crypto_delete_crypto_aead_ctx_cb,
+  ngtcp2_crypto_delete_crypto_cipher_ctx_cb,
+  NULL, /* recv_datagram */
+  NULL, /* ack_datagram */
+  NULL, /* lost_datagram */
+  ngtcp2_crypto_get_path_challenge_data_cb,
+  cb_ngtcp2_stream_stop_sending,
+  NULL, /* version_negotiation */
+  cb_ngtcp2_recv_rx_key, /* recv_rx_key */
+  NULL, /* recv_tx_key */
+  NULL, /* early_data_rejected */
+#ifdef NGTCP2_CALLBACKS_V2  /* ngtcp2 v1.14.0+ */
+  NULL, /* begin_path_validation */
+#endif /* NGTCP2_CALLBACKS_V2 */
+#ifdef NGTCP2_CALLBACKS_V3  /* ngtcp2 v1.22.0+ */
+  NULL, /* recv_stateless_reset2 */
+  cb_ngtcp2_get_new_connection_id2, /* get_new_connection_id2 */
+  NULL, /* dcid_status2 */
+  ngtcp2_crypto_get_path_challenge_data2_cb, /* get_path_challenge_data2 */
+#endif /* NGTCP2_CALLBACKS_V3 */
+};
+
+#if defined(_MSC_VER) && defined(_DLL)
+#pragma warning(pop)
+#endif
+
+static CURLcode cf_ngtcp2_recv_pkts_proxy(const unsigned char *buf,
+                                          size_t buflen, size_t gso_size,
+                                          struct sockaddr_storage *remote_addr,
+                                          socklen_t remote_addrlen, int ecn,
+                                          void *userp)
+{
+  struct proxy_pkt_io_ctx *pktx = userp;
+  struct cf_h3_proxy_ctx *proxy_ctx = pktx->cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  ngtcp2_pkt_info pi;
+  ngtcp2_path path;
+  size_t offset, pktlen;
+  int rv;
+
+  if(ecn)
+    CURL_TRC_CF(pktx->data, pktx->cf, "vquic_recv(len=%zu, gso=%zu, ecn=%x)",
+                buflen, gso_size, ecn);
+  ngtcp2_addr_init(&path.local, (struct sockaddr *)&ctx->q.local_addr,
+                   (socklen_t)ctx->q.local_addrlen);
+  ngtcp2_addr_init(&path.remote, (struct sockaddr *)remote_addr,
+                   remote_addrlen);
+  pi.ecn = (uint8_t)ecn;
+
+  for(offset = 0; offset < buflen; offset += gso_size) {
+    pktlen = ((offset + gso_size) <= buflen) ? gso_size : (buflen - offset);
+    rv = ngtcp2_conn_read_pkt(ctx->qconn, &path, &pi,
+                              buf + offset, pktlen, pktx->ts);
+    if(rv) {
+      CURL_TRC_CF(pktx->data, pktx->cf, "ingress, read_pkt -> %s (%d)",
+                  ngtcp2_strerror(rv), rv);
+      cf_ngtcp2_proxy_err_set(pktx->cf, pktx->data, rv);
+
+      if(rv == NGTCP2_ERR_CRYPTO)
+        /* this is a "TLS problem", but a failed certificate verification
+           is a common reason for this */
+        return CURLE_PEER_FAILED_VERIFICATION;
+      return CURLE_RECV_ERROR;
+    }
+  }
+  return CURLE_OK;
+}
+
+static CURLcode proxy_h3_progress_ingress_ngtcp2(struct Curl_cfilter *cf,
+                                                 struct Curl_easy *data,
+                                                 struct proxy_pkt_io_ctx *pktx)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct proxy_pkt_io_ctx local_pktx;
+  CURLcode result = CURLE_OK;
+
+  if(!ctx)
+    return CURLE_RECV_ERROR;
+  if(!data || !data->multi)
+    return CURLE_RECV_ERROR;
+
+  if(!pktx) {
+    proxy_pktx_init(&local_pktx, cf, data);
+    pktx = &local_pktx;
+  }
+  else {
+    proxy_pktx_update_time(pktx, cf);
+    ngtcp2_path_storage_zero(&pktx->ps);
+  }
+
+  result = Curl_vquic_tls_before_recv(&ctx->tls, cf, data);
+  if(result)
+    return result;
+
+  if(ctx->q.sockfd == CURL_SOCKET_BAD)
+    return CURLE_RECV_ERROR;
+
+  return vquic_recv_packets(cf, data, &ctx->q, 1000,
+                            cf_ngtcp2_recv_pkts_proxy, pktx);
+}
+
+/**
+ * Read a network packet to send from ngtcp2 into `buf`.
+ * Return number of bytes written or -1 with *err set.
+ */
+static CURLcode proxy_read_pkt_to_send(void *userp,
+                                 unsigned char *buf, size_t buflen,
+                                 size_t *pnread)
+{
+  struct proxy_pkt_io_ctx *x = userp;
+  struct cf_h3_proxy_ctx *proxy_ctx = x->cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  nghttp3_vec vec[16];
+  nghttp3_ssize veccnt;
+  ngtcp2_ssize ndatalen;
+  uint32_t flags;
+  int64_t stream_id;
+  int fin;
+  ssize_t n;
+
+  *pnread = 0;
+  veccnt = 0;
+  stream_id = -1;
+  fin = 0;
+
+  /* ngtcp2 may want to put several frames from different streams into
+   * this packet. `NGTCP2_WRITE_STREAM_FLAG_MORE` tells it to do so.
+   * When `NGTCP2_ERR_WRITE_MORE` is returned, we *need* to make
+   * another iteration.
+   * When ngtcp2 is happy (because it has no other frame that would fit
+   * or it has nothing more to send), it returns the total length
+   * of the assembled packet. This may be 0 if there was nothing to send. */
+  for(;;) {
+
+    if(ctx->h3conn && ngtcp2_conn_get_max_data_left(ctx->qconn)) {
+      veccnt = nghttp3_conn_writev_stream(ctx->h3conn, &stream_id, &fin, vec,
+                                          CURL_ARRAYSIZE(vec));
+      if(veccnt < 0) {
+        failf(x->data, "nghttp3_conn_writev_stream returned error: %s",
+              nghttp3_strerror((int)veccnt));
+        cf_ngtcp2_proxy_h3_err_set(x->cf, x->data, (int)veccnt);
+        return CURLE_SEND_ERROR;
+      }
+    }
+
+    flags = NGTCP2_WRITE_STREAM_FLAG_MORE |
+            (fin ? NGTCP2_WRITE_STREAM_FLAG_FIN : 0);
+    n = ngtcp2_conn_writev_stream(ctx->qconn, &x->ps.path,
+                                  NULL, buf, buflen,
+                                  &ndatalen, flags, stream_id,
+                                  (const ngtcp2_vec *)vec, veccnt, x->ts);
+    if(n == 0) {
+      /* nothing to send */
+      return CURLE_AGAIN;
+    }
+    else if(n < 0) {
+      switch(n) {
+      case NGTCP2_ERR_STREAM_DATA_BLOCKED: {
+        struct h3_proxy_stream_ctx *stream = NULL;
+        DEBUGASSERT(ndatalen == -1);
+        nghttp3_conn_block_stream(ctx->h3conn, stream_id);
+        CURL_TRC_CF(x->data, x->cf, "[%" PRId64 "] block quic flow",
+                    (int64_t)stream_id);
+        stream = cf_ngtcp2_proxy_get_stream(ctx, stream_id);
+        if(stream) /* it might be not one of our h3 streams? */
+          stream->quic_flow_blocked = TRUE;
+        n = 0;
+        break;
+      }
+      case NGTCP2_ERR_STREAM_SHUT_WR:
+        DEBUGASSERT(ndatalen == -1);
+        nghttp3_conn_shutdown_stream_write(ctx->h3conn, stream_id);
+        n = 0;
+        break;
+      case NGTCP2_ERR_WRITE_MORE:
+        /* ngtcp2 wants to send more. update the flow of the stream whose data
+         * is in the buffer and continue */
+        DEBUGASSERT(ndatalen >= 0);
+        n = 0;
+        break;
+      default:
+        DEBUGASSERT(ndatalen == -1);
+        failf(x->data, "ngtcp2_conn_writev_stream returned error: %s",
+              ngtcp2_strerror((int)n));
+        cf_ngtcp2_proxy_err_set(x->cf, x->data, (int)n);
+        return CURLE_SEND_ERROR;
+      }
+    }
+
+    if(ndatalen >= 0) {
+      /* we add the amount of data bytes to the flow windows */
+      int rv = nghttp3_conn_add_write_offset(ctx->h3conn, stream_id, ndatalen);
+      if(rv) {
+        failf(x->data, "nghttp3_conn_add_write_offset returned error: %s",
+              nghttp3_strerror(rv));
+        return CURLE_SEND_ERROR;
+      }
+    }
+
+    if(n > 0) {
+      /* packet assembled, leave */
+      *pnread = (size_t)n;
+      return CURLE_OK;
+    }
+  }
+}
+
+static CURLcode proxy_h3_progress_egress_ngtcp2(struct Curl_cfilter *cf,
+                                                struct Curl_easy *data,
+                                                struct proxy_pkt_io_ctx *pktx)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  size_t nread;
+  size_t max_payload_size, path_max_payload_size;
+  size_t pktcnt = 0;
+  size_t gsolen = 0;  /* this disables gso until we have a clue */
+  size_t send_quantum;
+  CURLcode result;
+  struct proxy_pkt_io_ctx local_pktx;
+
+  if(!pktx) {
+    proxy_pktx_init(&local_pktx, cf, data);
+    pktx = &local_pktx;
+  }
+  else {
+    proxy_pktx_update_time(pktx, cf);
+    ngtcp2_path_storage_zero(&pktx->ps);
+  }
+
+  result = vquic_flush(cf, data, &ctx->q);
+  if(result) {
+    if(result == CURLE_AGAIN) {
+      Curl_expire(data, 1, EXPIRE_QUIC);
+      return CURLE_OK;
+    }
+    return result;
+  }
+
+  /* In UDP, there is a maximum theoretical packet payload length and
+   * a minimum payload length that is "guaranteed" to work.
+   * To detect if this minimum payload can be increased, ngtcp2 sends
+   * now and then a packet payload larger than the minimum. It that
+   * is ACKed by the peer, both parties know that it works and
+   * the subsequent packets can use a larger one.
+   * This is called PMTUD (Path Maximum Transmission Unit Discovery).
+   * Since a PMTUD might be rejected right on send, we do not want it
+   * be followed by other packets of lesser size. Because those would
+   * also fail then. If we detect a PMTUD while buffering, we flush.
+   */
+  max_payload_size = ngtcp2_conn_get_max_tx_udp_payload_size(ctx->qconn);
+  path_max_payload_size =
+      ngtcp2_conn_get_path_max_tx_udp_payload_size(ctx->qconn);
+  send_quantum = ngtcp2_conn_get_send_quantum(ctx->qconn);
+  CURL_TRC_CF(data, cf, "egress, collect and send packets, quantum=%zu",
+              send_quantum);
+  for(;;) {
+    /* add the next packet to send, if any, to our buffer */
+    result = Curl_bufq_sipn(&ctx->q.sendbuf, max_payload_size,
+                            proxy_read_pkt_to_send, pktx, &nread);
+    if(result == CURLE_AGAIN)
+      break;
+    else if(result)
+      return result;
+    else {
+      size_t buflen = Curl_bufq_len(&ctx->q.sendbuf);
+      if((buflen >= send_quantum) ||
+         ((buflen + gsolen) >= ctx->q.sendbuf.chunk_size))
+         break;
+      DEBUGASSERT(nread > 0);
+      ++pktcnt;
+      if(pktcnt == 1) {
+        /* first packet in buffer. This is either of a known, "good"
+         * payload size or it is a PMTUD. We shall see. */
+        gsolen = nread;
+      }
+      else if(nread > gsolen ||
+              (gsolen > path_max_payload_size && nread != gsolen)) {
+        /* The added packet is a PMTUD *or* the one(s) before the
+         * added were PMTUD and the last one is smaller.
+         * Flush the buffer before the last add. */
+        result = vquic_send_tail_split(cf, data, &ctx->q,
+                                       gsolen, nread, nread);
+        if(result) {
+          if(result == CURLE_AGAIN) {
+            Curl_expire(data, 1, EXPIRE_QUIC);
+            return CURLE_OK;
+          }
+          return result;
+        }
+        pktcnt = 0;
+      }
+      else if(nread < gsolen) {
+        /* Reached capacity of our buffer *or*
+         * last add was shorter than the previous ones, flush */
+        break;
+      }
+    }
+  }
+
+  if(!Curl_bufq_is_empty(&ctx->q.sendbuf)) {
+    /* time to send */
+    CURL_TRC_CF(data, cf, "egress, send collected %zu packets in %zu bytes",
+                pktcnt, Curl_bufq_len(&ctx->q.sendbuf));
+    result = vquic_send(cf, data, &ctx->q, gsolen);
+    if(result) {
+      if(result == CURLE_AGAIN) {
+        Curl_expire(data, 1, EXPIRE_QUIC);
+        return CURLE_OK;
+      }
+      return result;
+    }
+    proxy_pktx_update_time(pktx, cf);
+    ngtcp2_conn_update_pkt_tx_time(ctx->qconn, pktx->ts);
+  }
+  return CURLE_OK;
+}
+
+static CURLcode cf_ngtcp2_proxy_shutdown(struct Curl_cfilter *cf,
+                                   struct Curl_easy *data, bool *done)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct cf_call_data save;
+  struct proxy_pkt_io_ctx pktx;
+  CURLcode result = CURLE_OK;
+
+  if(cf->shutdown || !ctx->qconn) {
+    *done = TRUE;
+    return CURLE_OK;
+  }
+
+  if(!cf->next) {
+    Curl_bufq_reset(&ctx->q.sendbuf);
+    *done = TRUE;
+    return CURLE_OK;
+  }
+
+  CF_DATA_SAVE(save, cf, data);
+  *done = FALSE;
+  proxy_pktx_init(&pktx, cf, data);
+
+  if(!ctx->shutdown_started) {
+    char buffer[NGTCP2_MAX_UDP_PAYLOAD_SIZE];
+    ngtcp2_ssize nwritten;
+
+    if(!Curl_bufq_is_empty(&ctx->q.sendbuf)) {
+      CURL_TRC_CF(data, cf, "shutdown, flushing sendbuf");
+      result = proxy_h3_progress_egress_ngtcp2(cf, data, &pktx);
+      if(!Curl_bufq_is_empty(&ctx->q.sendbuf)) {
+        CURL_TRC_CF(data, cf, "sending shutdown packets blocked");
+        result = CURLE_OK;
+        goto out;
+      }
+      else if(result) {
+        CURL_TRC_CF(data, cf, "shutdown, error %d flushing sendbuf", result);
+        *done = TRUE;
+        goto out;
+      }
+    }
+
+    DEBUGASSERT(Curl_bufq_is_empty(&ctx->q.sendbuf));
+    ctx->shutdown_started = TRUE;
+    nwritten = ngtcp2_conn_write_connection_close(
+      ctx->qconn, NULL, /* path */
+      NULL, /* pkt_info */
+      (uint8_t *)buffer, sizeof(buffer),
+      &ctx->last_error, pktx.ts);
+    CURL_TRC_CF(data, cf, "start shutdown(err_type=%d, err_code=%"
+                PRIu64 ") -> %zd", ctx->last_error.type,
+                (uint64_t)ctx->last_error.error_code, (ssize_t)nwritten);
+    /* there are cases listed in ngtcp2 documentation where this call
+     * may fail. Since we are doing a connection shutdown as graceful
+     * as we can, such an error is ignored here. */
+    if(nwritten > 0) {
+      /* Ignore amount written. sendbuf was empty and has always room for
+       * NGTCP2_MAX_UDP_PAYLOAD_SIZE. It can only completely fail, in which
+       * case `result` is set non zero. */
+      size_t n;
+      result = Curl_bufq_write(&ctx->q.sendbuf, (const unsigned char *)buffer,
+                               (size_t)nwritten, &n);
+      if(result) {
+        CURL_TRC_CF(data, cf, "error %d adding shutdown packets to sendbuf, "
+                    "aborting shutdown", result);
+        goto out;
+      }
+
+      ctx->q.no_gso = TRUE;
+      ctx->q.gsolen = (size_t)nwritten;
+      ctx->q.split_len = 0;
+    }
+  }
+
+  if(!Curl_bufq_is_empty(&ctx->q.sendbuf)) {
+    CURL_TRC_CF(data, cf, "shutdown, flushing egress");
+    result = vquic_flush(cf, data, &ctx->q);
+    if(result == CURLE_AGAIN) {
+      CURL_TRC_CF(data, cf, "sending shutdown packets blocked");
+      result = CURLE_OK;
+      goto out;
+    }
+    else if(result) {
+      CURL_TRC_CF(data, cf, "shutdown, error %d flushing sendbuf", result);
+      *done = TRUE;
+      goto out;
+    }
+  }
+
+  if(Curl_bufq_is_empty(&ctx->q.sendbuf)) {
+    /* Sent everything off. ngtcp2 seems to have no support for graceful
+     * shutdowns. We are done. */
+    CURL_TRC_CF(data, cf, "shutdown completely sent off, done");
+    *done = TRUE;
+    result = CURLE_OK;
+  }
+out:
+  CF_DATA_RESTORE(cf, save);
+  return result;
+}
+
+static void cf_ngtcp2_proxy_conn_close(struct Curl_cfilter *cf,
+                                 struct Curl_easy *data)
+{
+  bool done;
+  cf_ngtcp2_proxy_shutdown(cf, data, &done);
+}
+
+static void cf_ngtcp2_proxy_close(struct Curl_cfilter *cf,
+                                  struct Curl_easy *data)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct cf_call_data save;
+
+  CF_DATA_SAVE(save, cf, data);
+  if(ctx && ctx->qconn) {
+    cf_ngtcp2_proxy_conn_close(cf, data);
+    cf_ngtcp2_proxy_ctx_close(ctx);
+    CURL_TRC_CF(data, cf, "close");
+  }
+  cf->connected = FALSE;
+  CF_DATA_RESTORE(cf, save);
+}
+
+static void cf_ngtcp2_proxy_stream_close(struct Curl_cfilter *cf,
+                                   struct Curl_easy *data,
+                                   struct h3_proxy_stream_ctx  *stream)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  DEBUGASSERT(data);
+  DEBUGASSERT(stream);
+
+  if(stream->id == proxy_ctx->tunnel.stream_id) {
+    proxy_ctx->tunnel.stream = NULL;
+    proxy_ctx->tunnel.closed = TRUE;
+  }
+
+  if(ctx->h3conn)
+    nghttp3_conn_set_stream_user_data(ctx->h3conn, stream->id, NULL);
+  if(ctx->qconn)
+    ngtcp2_conn_set_stream_user_data(ctx->qconn, stream->id, NULL);
+
+  if(!stream->closed && ctx->qconn && ctx->h3conn) {
+    CURLcode result;
+
+    stream->closed = TRUE;
+    (void)ngtcp2_conn_shutdown_stream(ctx->qconn, 0, stream->id,
+                                      NGHTTP3_H3_REQUEST_CANCELLED);
+    result = proxy_h3_progress_egress_ngtcp2(cf, data, NULL);
+    if(result)
+      CURL_TRC_CF(data, cf, "[%" PRId64 "] cancel stream -> %d",
+                  stream->id, result);
+  }
+}
+
+/**
+ * Connection maintenance like timeouts on packet ACKs etc. are done by us, not
+ * the OS like for TCP. POLL events on the socket therefore are not
+ * sufficient.
+ * ngtcp2 tells us when it wants to be invoked again. We handle that via
+ * the `Curl_expire()` mechanisms.
+ */
+static CURLcode check_and_set_expiry_ngtcp2(struct Curl_cfilter *cf,
+                                            struct Curl_easy *data,
+                                            struct proxy_pkt_io_ctx *pktx)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct proxy_pkt_io_ctx local_pktx;
+  ngtcp2_tstamp expiry;
+
+  if(!ctx)
+    return CURLE_OK;
+
+  if(!pktx) {
+    proxy_pktx_init(&local_pktx, cf, data);
+    pktx = &local_pktx;
+  }
+  else {
+    proxy_pktx_update_time(pktx, cf);
+  }
+
+  expiry = ngtcp2_conn_get_expiry(ctx->qconn);
+  if(expiry != UINT64_MAX) {
+    if(expiry <= pktx->ts) {
+      CURLcode result;
+      int rv = ngtcp2_conn_handle_expiry(ctx->qconn, pktx->ts);
+      if(rv) {
+        failf(data, "ngtcp2_conn_handle_expiry returned error: %s",
+              ngtcp2_strerror(rv));
+        cf_ngtcp2_proxy_err_set(cf, data, rv);
+        return CURLE_SEND_ERROR;
+      }
+      result = proxy_h3_progress_ingress_ngtcp2(cf, data, pktx);
+      if(result)
+        return result;
+      result = proxy_h3_progress_egress_ngtcp2(cf, data, pktx);
+      if(result)
+        return result;
+      /* ask again, things might have changed */
+      expiry = ngtcp2_conn_get_expiry(ctx->qconn);
+    }
+
+    if(expiry > pktx->ts) {
+      ngtcp2_duration timeout = expiry - pktx->ts;
+      if(timeout % NGTCP2_MILLISECONDS) {
+        timeout += NGTCP2_MILLISECONDS;
+      }
+      Curl_expire(data, (timediff_t)(timeout / NGTCP2_MILLISECONDS),
+                  EXPIRE_QUIC);
+    }
+  }
+  return CURLE_OK;
+}
+
+static ssize_t proxy_recv_closed_stream(struct Curl_cfilter *cf,
+                                  struct Curl_easy *data,
+                                  struct h3_proxy_stream_ctx *stream,
+                                  CURLcode *err)
+{
+  ssize_t nread = -1;
+  *err = CURLE_OK;
+
+  if(stream->reset) {
+    if(stream->error3 == CURL_H3_ERR_REQUEST_REJECTED) {
+      infof(data, "HTTP/3 stream %" PRId64 " refused by server, try again "
+            "on a new connection", stream->id);
+      connclose(cf->conn, "REFUSED_STREAM");
+      data->state.refused_stream = TRUE;
+      *err = CURLE_RECV_ERROR;
+      goto out;
+    }
+    else if(stream->resp_hds_complete && data->req.no_body) {
+      CURL_TRC_CF(data, cf, "[%" PRId64 "] error after response headers, "
+                  "but we did not want a body anyway, ignore error 0x%"
+                  PRIx64 " %s", stream->id, stream->error3,
+                  vquic_h3_err_str(stream->error3));
+      nread = 0;
+      goto out;
+    }
+    failf(data, "HTTP/3 stream %" PRId64 " reset by server (error 0x%" PRIx64
+          " %s)", stream->id, stream->error3,
+          vquic_h3_err_str(stream->error3));
+    *err = data->req.bytecount ? CURLE_PARTIAL_FILE : CURLE_HTTP3;
+    goto out;
+  }
+  else if(!stream->resp_hds_complete) {
+    failf(data,
+          "HTTP/3 stream %" PRId64 " was closed cleanly, but before "
+          "getting all response header fields, treated as error",
+          stream->id);
+    *err = CURLE_HTTP3;
+    goto out;
+  }
+  nread = 0;
+
+out:
+  return nread;
+}
+
+static struct h3_proxy_stream_ctx *
+h3_proxy_resolve_send_stream(struct cf_h3_proxy_ctx *proxy_ctx,
+                             struct cf_ngtcp2_proxy_ctx *ctx,
+                             struct Curl_easy *data)
+{
+  struct h3_proxy_stream_ctx *stream = H3_PROXY_STREAM_CTX(ctx, data);
+
+  if(stream)
+    return stream;
+
+  /* send can be driven by a different easy handle during shutdown */
+  if(proxy_ctx->tunnel.stream && !proxy_ctx->tunnel.closed) {
+    return proxy_ctx->tunnel.stream;
+  }
+  return NULL;
+}
+
+static CURLcode h3_proxy_sendbuf_add(struct Curl_easy *data,
+                                     struct h3_proxy_stream_ctx *stream,
+                                     const uint8_t *buf, size_t len,
+                                     size_t *pnwritten)
+{
+  CURLcode result;
+  *pnwritten = 0;
+  (void)data;
+
+  result = Curl_bufq_write(&stream->sendbuf, buf, len, pnwritten);
+  return result;
+}
+
+static CURLcode cf_h3_proxy_send(struct Curl_cfilter *cf,
+                                 struct Curl_easy *data,
+                                 const uint8_t *buf, size_t len,
+                                 bool eos, size_t *pnwritten)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct h3_proxy_stream_ctx *stream = NULL;
+  struct cf_call_data save;
+  struct proxy_pkt_io_ctx pktx;
+  CURLcode result = CURLE_OK;
+
+  CF_DATA_SAVE(save, cf, data);
+  DEBUGASSERT(cf->connected);
+  DEBUGASSERT(ctx->qconn);
+  DEBUGASSERT(ctx->h3conn);
+  proxy_pktx_init(&pktx, cf, data);
+  *pnwritten = 0;
+
+  /* handshake verification failed in callback, do not send anything */
+  if(ctx->tls_vrfy_result) {
+    result = ctx->tls_vrfy_result;
+    goto denied;
+  }
+
+  (void)eos; /* use for stream EOF and block handling */
+  result = proxy_h3_progress_ingress_ngtcp2(cf, data, &pktx);
+  if(result)
+    goto out;
+
+  stream = h3_proxy_resolve_send_stream(proxy_ctx, ctx, data);
+  if(!stream) {
+    result = CURLE_SEND_ERROR;
+    goto denied;
+  }
+
+  if(proxy_ctx->tunnel.closed) {
+    result = CURLE_SEND_ERROR;
+    goto denied;
+  }
+
+  if(stream->closed) {
+    if(stream->resp_hds_complete) {
+      /* Server decided to close the stream after having sent us a final
+       * response. This is valid if it is not interested in the request
+       * body. This happens on 30x or 40x responses.
+       * We silently discard the data sent, since this is not a transport
+       * error situation. */
+      CURL_TRC_CF(data, cf, "[%" PRId64 "] discarding data"
+                  "on closed stream with response", stream->id);
+      result = CURLE_OK;
+      *pnwritten = len;
+      goto out;
+    }
+    CURL_TRC_CF(data, cf, "[%" PRId64 "] send_body(len=%zu) "
+                "-> stream closed", stream->id, len);
+    result = CURLE_HTTP3;
+    goto out;
+  }
+  else {
+    result = h3_proxy_sendbuf_add(data, stream, buf, len, pnwritten);
+    CURL_TRC_CF(data, cf, "[%" PRId64 "] cf_send, add to "
+                "sendbuf(len=%zu) -> %d, %zu",
+                stream->id, len, result, *pnwritten);
+    if(result)
+      goto out;
+    (void)nghttp3_conn_resume_stream(ctx->h3conn, stream->id);
+  }
+
+  if(*pnwritten > 0 && !ctx->tls_handshake_complete && ctx->use_earlydata)
+    ctx->earlydata_skip += *pnwritten;
+
+  DEBUGASSERT(!result);
+  result = proxy_h3_progress_egress_ngtcp2(cf, data, &pktx);
+
+out:
+  result = Curl_1st_fatal(result,
+                          check_and_set_expiry_ngtcp2(cf, data, &pktx));
+denied:
+  CURL_TRC_CF(data, cf, "[%" PRId64 "] cf_send(len=%zu) -> %d, %zu",
+              stream ? stream->id : -1, len, result, *pnwritten);
+  CF_DATA_RESTORE(cf, save);
+  return result;
+}
+
+/* incoming data frames on the h3 stream */
+static CURLcode cf_h3_proxy_recv(struct Curl_cfilter *cf,
+                                 struct Curl_easy *data,
+                                 char *buf, size_t len, size_t *pnread)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct h3_proxy_stream_ctx *stream = H3_PROXY_STREAM_CTX(ctx, data);
+  struct cf_call_data save;
+  struct proxy_pkt_io_ctx pktx;
+  CURLcode result = CURLE_OK;
+
+  CF_DATA_SAVE(save, cf, data);
+  DEBUGASSERT(cf->connected);
+  DEBUGASSERT(ctx);
+  DEBUGASSERT(ctx->qconn);
+  DEBUGASSERT(ctx->h3conn);
+  *pnread = 0;
+
+  /* handshake verification failed in callback, do not recv anything */
+  if(ctx->tls_vrfy_result) {
+    result = ctx->tls_vrfy_result;
+    goto denied;
+  }
+
+  proxy_pktx_init(&pktx, cf, data);
+
+  if(!stream || ctx->shutdown_started) {
+    result = CURLE_RECV_ERROR;
+    goto out;
+  }
+
+  if(!Curl_bufq_is_empty(&proxy_ctx->inbufq)) {
+    result = Curl_bufq_cread(&proxy_ctx->inbufq,
+                            buf, len, pnread);
+    if(result)
+      goto out;
+  }
+
+  result = proxy_h3_progress_ingress_ngtcp2(cf, data, &pktx);
+  if(result)
+    goto out;
+
+  /* inbufq had nothing before, maybe after progressing ingress? */
+  if(!*pnread && !Curl_bufq_is_empty(&proxy_ctx->inbufq)) {
+    result = Curl_bufq_cread(&proxy_ctx->inbufq,
+                             buf, len, pnread);
+    if(result) {
+      CURL_TRC_CF(data, cf, "[%" PRId64 "] read inbufq(len=%zu) "
+                            "-> %zd, %d",
+                  stream->id, len, *pnread, result);
+      goto out;
+    }
+  }
+
+  if(*pnread) {
+    Curl_multi_mark_dirty(data);
+  }
+  else {
+    if(stream->xfer_result) {
+      CURL_TRC_CF(data, cf, "[%" PRId64 "] xfer write failed",
+                  stream->id);
+      cf_ngtcp2_proxy_stream_close(cf, data, stream);
+      result = stream->xfer_result;
+      goto out;
+    }
+    else if(stream->closed) {
+      ssize_t nread = proxy_recv_closed_stream(cf, data, stream, &result);
+      if(nread > 0)
+        *pnread = (size_t)nread;
+      goto out;
+    }
+    result = CURLE_AGAIN;
+  }
+
+out:
+  result = Curl_1st_fatal(result,
+                          proxy_h3_progress_egress_ngtcp2(cf, data, &pktx));
+  result = Curl_1st_fatal(result,
+                          check_and_set_expiry_ngtcp2(cf, data, &pktx));
+denied:
+  CURL_TRC_CF(data, cf, "[%" PRId64 "] cf_recv(len=%zu) -> %d, %zu",
+              stream ? stream->id : -1, len, result, *pnread);
+  CF_DATA_RESTORE(cf, save);
+  return result;
+}
+
+static void proxy_h3_submit(int64_t *pstream_id,
+                            struct Curl_cfilter *cf,
+                            struct Curl_easy *data,
+                            struct httpreq *req,
+                            CURLcode *err)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct h3_proxy_stream_ctx *stream = NULL;
+
+  struct dynhds h2_headers;
+  nghttp3_nv *nva = NULL;
+  size_t nheader;
+
+  int rc = 0;
+  unsigned int i;
+  nghttp3_data_reader reader;
+  nghttp3_data_reader *preader = NULL;
+
+  Curl_dynhds_init(&h2_headers, 0, DYN_HTTP_REQUEST);
+  *err = Curl_http_req_to_h2(&h2_headers, req, data);
+  if(*err)
+    goto out;
+
+  *err = h3_proxy_data_setup(cf, data);
+  if(*err)
+    goto out;
+
+  if(!ctx) {
+    *err = CURLE_FAILED_INIT;
+    goto out;
+  }
+
+  stream = H3_PROXY_STREAM_CTX(ctx, data);
+
+  DEBUGASSERT(stream);
+  if(!stream) {
+    *err = CURLE_FAILED_INIT;
+    goto out;
+  }
+
+  nheader = Curl_dynhds_count(&h2_headers);
+  nva = curlx_malloc(sizeof(nghttp3_nv) * nheader);
+  if(!nva) {
+    *err = CURLE_OUT_OF_MEMORY;
+    goto out;
+  }
+
+  for(i = 0; i < nheader; ++i) {
+    struct dynhds_entry *e = Curl_dynhds_getn(&h2_headers, i);
+    nva[i].name = (unsigned char *)e->name;
+    nva[i].namelen = e->namelen;
+    nva[i].value = (unsigned char *)e->value;
+    nva[i].valuelen = e->valuelen;
+    nva[i].flags = NGHTTP3_NV_FLAG_NONE;
+  }
+
+  /* Open a bidirectional stream */
+  {
+    int64_t sid;
+    int rv;
+
+    DEBUGASSERT(stream->id == -1);
+    rv = ngtcp2_conn_open_bidi_stream(ctx->qconn, &sid, data);
+    if(rv) {
+      failf(data, "cannot get bidi streams: %s", ngtcp2_strerror(rv));
+      *err = CURLE_SEND_ERROR;
+      goto out;
+    }
+    stream->id = (int64_t)sid;
+    ++ctx->used_bidi_streams;
+
+    /* Set stream user data in ngtcp2 connection for callbacks */
+    rv = ngtcp2_conn_set_stream_user_data(ctx->qconn, sid, data);
+    if(rv) {
+      failf(data, "cannot set stream user data: %s", ngtcp2_strerror(rv));
+      *err = CURLE_SEND_ERROR;
+      goto out;
+    }
+    proxy_ctx->tunnel.stream = stream;
+    CURL_TRC_CF(data, cf, "[%" PRId64 "] opened bidi stream", sid);
+  }
+
+  /* CONNECT-UDP request stream remains open for capsules, no fixed EOF. */
+  stream->upload_left = -1;
+  stream->send_closed = 0;
+  reader.read_data = cb_h3_read_data_for_tunnel_stream;
+  preader = &reader;
+
+  rc = nghttp3_conn_submit_request(ctx->h3conn, H3_STREAM_ID(stream),
+                                   nva, nheader, preader, data);
+
+  if(rc) {
+    switch(rc) {
+    case NGHTTP3_ERR_CONN_CLOSING:
+      CURL_TRC_CF(data, cf, "h3sid[%" PRId64 "] failed to send, "
+                            "connection is closing",
+                  H3_STREAM_ID(stream));
+      break;
+    default:
+      CURL_TRC_CF(data, cf, "h3sid[%" PRId64 "] failed to send -> %d (%s)",
+                  H3_STREAM_ID(stream), rc, nghttp3_strerror(rc));
+      break;
+    }
+    *err = CURLE_SEND_ERROR;
+    goto out;
+  }
+
+  if(Curl_trc_is_verbose(data)) {
+    CURL_TRC_CF(data, cf, "[H3-PROXY] [%" PRId64 "] OPENED stream "
+                "for %s", H3_STREAM_ID(stream),
+                Curl_bufref_ptr(&data->state.url));
+  }
+
+out:
+  curlx_free(nva);
+  Curl_dynhds_free(&h2_headers);
+  if(*err == CURLE_OK) {
+    *pstream_id = H3_STREAM_ID(stream);
+  }
+}
+
+static bool cf_h3_proxy_is_alive(struct Curl_cfilter *cf,
+                                 struct Curl_easy *data,
+                                 bool *input_pending)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  bool alive = FALSE;
+  const ngtcp2_transport_params *rp;
+  struct cf_call_data save;
+
+  CF_DATA_SAVE(save, cf, data);
+  *input_pending = FALSE;
+
+  if(!ctx || !ctx->qconn || ctx->shutdown_started)
+    goto out;
+  if(proxy_ctx->tunnel.closed)
+    goto out;
+
+  /* We do not announce a max idle timeout, but when the peer does
+   * it closes the connection when it expires. */
+  rp = ngtcp2_conn_get_remote_transport_params(ctx->qconn);
+  if(rp && rp->max_idle_timeout) {
+    timediff_t idletime_ms =
+      curlx_ptimediff_ms(Curl_pgrs_now(data), &ctx->q.last_io);
+    if(idletime_ms > 0) {
+      uint64_t max_idle_ms =
+        (uint64_t)(rp->max_idle_timeout / NGTCP2_MILLISECONDS);
+      if((uint64_t)idletime_ms > max_idle_ms)
+        goto out;
+    }
+  }
+
+  if(!cf->next || !cf->next->cft->is_alive(cf->next, data, input_pending))
+    goto out;
+
+  alive = TRUE;
+  if(*input_pending) {
+    CURLcode result;
+    /* This happens before we have sent off a request and the connection is
+       not in use by any other transfer, there should not be any data here,
+       only "protocol frames" */
+    *input_pending = FALSE;
+    if(!data || !data->multi) {
+      alive = FALSE;
+      goto out;
+    }
+    result = proxy_h3_progress_ingress_ngtcp2(cf, data, NULL);
+    CURL_TRC_CF(data, cf, "is_alive, progress ingress -> %d", result);
+    alive = result ? FALSE : TRUE;
+  }
+
+out:
+  CF_DATA_RESTORE(cf, save);
+  return alive;
+}
+
+static CURLcode cf_ngtcp2_proxy_query(struct Curl_cfilter *cf,
+                                      struct Curl_easy *data,
+                                      int query, int *pres1, void *pres2)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct cf_call_data save;
+
+  if(!ctx)
+    return cf->next ?
+      cf->next->cft->query(cf->next, data, query, pres1, pres2) :
+      CURLE_UNKNOWN_OPTION;
+
+  switch(query) {
+  case CF_QUERY_MAX_CONCURRENT: {
+    DEBUGASSERT(pres1);
+    CF_DATA_SAVE(save, cf, data);
+    /* Set after transport params arrived and continually updated
+     * by callback. QUIC counts the number over the lifetime of the
+     * connection, ever increasing.
+     * We count the *open* transfers plus the budget for new ones. */
+    if(!ctx->qconn || ctx->shutdown_started) {
+      *pres1 = 0;
+    }
+    else if(ctx->max_bidi_streams) {
+      uint64_t avail_bidi_streams = 0;
+      uint64_t max_streams = cf->conn->attached_xfers;
+      if(ctx->max_bidi_streams > ctx->used_bidi_streams)
+        avail_bidi_streams = ctx->max_bidi_streams - ctx->used_bidi_streams;
+      max_streams += avail_bidi_streams;
+      *pres1 = (max_streams > INT_MAX) ? INT_MAX : (int)max_streams;
+    }
+    else  /* transport params not arrived yet? take our default. */
+      *pres1 = (int)Curl_multi_max_concurrent_streams(data->multi);
+    CURL_TRC_CF(data, cf, "query conn[%" FMT_OFF_T "]: "
+                "MAX_CONCURRENT -> %d (%u in use)",
+                cf->conn->connection_id, *pres1, cf->conn->attached_xfers);
+    CF_DATA_RESTORE(cf, save);
+    return CURLE_OK;
+  }
+  case CF_QUERY_CONNECT_REPLY_MS:
+    if(ctx->q.got_first_byte) {
+      timediff_t ms = curlx_ptimediff_ms(&ctx->q.first_byte_at,
+                                         &ctx->started_at);
+      *pres1 = (ms < INT_MAX) ? (int)ms : INT_MAX;
+    }
+    else
+      *pres1 = -1;
+    return CURLE_OK;
+  case CF_QUERY_TIMER_CONNECT: {
+    struct curltime *when = pres2;
+    if(ctx->q.got_first_byte)
+      *when = ctx->q.first_byte_at;
+    return CURLE_OK;
+  }
+  case CF_QUERY_TIMER_APPCONNECT: {
+    struct curltime *when = pres2;
+    if(cf->connected)
+      *when = ctx->handshake_at;
+    return CURLE_OK;
+  }
+  case CF_QUERY_HTTP_VERSION:
+    *pres1 = 30;
+    return CURLE_OK;
+  case CF_QUERY_SSL_INFO:
+  case CF_QUERY_SSL_CTX_INFO: {
+    struct curl_tlssessioninfo *info = pres2;
+    if(Curl_vquic_tls_get_ssl_info(&ctx->tls,
+                                   (query == CF_QUERY_SSL_CTX_INFO), info))
+      return CURLE_OK;
+    break;
+  }
+  case CF_QUERY_ALPN_NEGOTIATED: {
+    const char **palpn = pres2;
+    DEBUGASSERT(palpn);
+    *palpn = cf->connected ? "h3" : NULL;
+    return CURLE_OK;
+  }
+  default:
+    break;
+  }
+  return cf->next ?
+    cf->next->cft->query(cf->next, data, query, pres1, pres2) :
+    CURLE_UNKNOWN_OPTION;
+}
+
+static CURLcode cf_ngtcp2_proxy_adjust_pollset(struct Curl_cfilter *cf,
+                                               struct Curl_easy *data,
+                                               struct easy_pollset *ps)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  bool want_recv, want_send;
+  CURLcode result = CURLE_OK;
+
+  if(!ctx->qconn)
+    return CURLE_OK;
+
+  Curl_pollset_check(data, ps, ctx->q.sockfd, &want_recv, &want_send);
+  if(!want_send && !Curl_bufq_is_empty(&ctx->q.sendbuf))
+    want_send = TRUE;
+
+  if(want_recv || want_send) {
+    struct h3_proxy_stream_ctx *stream = H3_PROXY_STREAM_CTX(ctx, data);
+    struct cf_call_data save;
+    bool c_exhaust, s_exhaust;
+
+    CF_DATA_SAVE(save, cf, data);
+    c_exhaust = want_send && (!ngtcp2_conn_get_cwnd_left(ctx->qconn) ||
+                !ngtcp2_conn_get_max_data_left(ctx->qconn));
+    s_exhaust = want_send && stream && H3_STREAM_ID(stream) >= 0 &&
+                stream->quic_flow_blocked;
+    want_recv = (want_recv || c_exhaust || s_exhaust);
+    want_send = (!s_exhaust && want_send) ||
+                 !Curl_bufq_is_empty(&ctx->q.sendbuf);
+
+    result = Curl_pollset_set(data, ps, ctx->q.sockfd, want_recv, want_send);
+    CF_DATA_RESTORE(cf, save);
+  }
+  return result;
+}
+
+static CURLcode cf_h3_proxy_query(struct Curl_cfilter *cf,
+                                   struct Curl_easy *data,
+                                   int query, int *pres1, void *pres2)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+
+  if(!proxy_ctx)
+    return cf->next ?
+      cf->next->cft->query(cf->next, data, query, pres1, pres2) :
+      CURLE_UNKNOWN_OPTION;
+  return cf_ngtcp2_proxy_query(cf, data, query, pres1, pres2);
+}
+
+static CURLcode cf_h3_proxy_adjust_pollset(struct Curl_cfilter *cf,
+                                           struct Curl_easy *data,
+                                           struct easy_pollset *ps)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+
+  if(!proxy_ctx)
+    return cf->next ?
+      cf->next->cft->adjust_pollset(cf->next, data, ps) :
+      CURLE_OK;
+  return cf_ngtcp2_proxy_adjust_pollset(cf, data, ps);
+}
+
+static bool cf_h3_proxy_data_pending(struct Curl_cfilter *cf,
+                                     const struct Curl_easy *data)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  if(!proxy_ctx)
+    return cf->next ?
+      cf->next->cft->has_data_pending(cf->next, data) : FALSE;
+  if(!Curl_bufq_is_empty(&proxy_ctx->inbufq))
+    return TRUE;
+  return cf->next ?
+    cf->next->cft->has_data_pending(cf->next, data) : FALSE;
+}
+
+#ifdef USE_OPENSSL
+static int proxy_quic_ossl_new_session_cb(SSL *ssl, SSL_SESSION *ssl_sessionid)
+{
+  ngtcp2_crypto_conn_ref *cref;
+  struct Curl_cfilter *cf;
+  struct cf_h3_proxy_ctx *proxy_ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx;
+  struct Curl_easy *data;
+
+  cref = (ngtcp2_crypto_conn_ref *)SSL_get_app_data(ssl);
+  cf = cref ? cref->user_data : NULL;
+  proxy_ctx = cf ? cf->ctx : NULL;
+  ctx = proxy_ctx ? proxy_ctx->ngtcp2_ctx : NULL;
+  data = cf ? CF_DATA_CURRENT(cf) : NULL;
+  if(cf && data && ctx) {
+    unsigned char *quic_tp = NULL;
+    size_t quic_tp_len = 0;
+#ifdef HAVE_OPENSSL_EARLYDATA
+    ngtcp2_ssize tplen;
+    uint8_t tpbuf[256];
+
+    tplen = ngtcp2_conn_encode_0rtt_transport_params(ctx->qconn, tpbuf,
+                                                     sizeof(tpbuf));
+    if(tplen < 0)
+      CURL_TRC_CF(data, cf, "error encoding 0RTT transport data: %s",
+                  ngtcp2_strerror((int)tplen));
+    else {
+      quic_tp = (unsigned char *)tpbuf;
+      quic_tp_len = (size_t)tplen;
+    }
+#endif /* HAVE_OPENSSL_EARLYDATA */
+    Curl_ossl_add_session(cf, data, ctx->peer.scache_key, ssl_sessionid,
+                          SSL_version(ssl), "h3", quic_tp, quic_tp_len);
+  }
+  return 0;
+}
+#endif /* USE_OPENSSL */
+
+static CURLcode cf_ngtcp2_proxy_tls_ctx_setup(struct Curl_cfilter *cf,
+                                              struct Curl_easy *data,
+                                              void *user_data)
+{
+  struct curl_tls_ctx *ctx = user_data;
+
+#ifdef USE_OPENSSL
+#if defined(OPENSSL_IS_BORINGSSL) || defined(OPENSSL_IS_AWSLC)
+  if(ngtcp2_crypto_boringssl_configure_client_context(ctx->ossl.ssl_ctx)
+     != 0) {
+    failf(data, "ngtcp2_crypto_boringssl_configure_client_context failed");
+    return CURLE_FAILED_INIT;
+  }
+#elif defined(OPENSSL_QUIC_API2)
+  /* nothing to do */
+#else
+  if(ngtcp2_crypto_quictls_configure_client_context(ctx->ossl.ssl_ctx) != 0) {
+    failf(data, "ngtcp2_crypto_quictls_configure_client_context failed");
+    return CURLE_FAILED_INIT;
+  }
+#endif
+  if(Curl_ssl_scache_use(cf, data)) {
+    SSL_CTX_set_session_cache_mode(ctx->ossl.ssl_ctx,
+                                   SSL_SESS_CACHE_CLIENT |
+                                   SSL_SESS_CACHE_NO_INTERNAL);
+    SSL_CTX_sess_set_new_cb(ctx->ossl.ssl_ctx, proxy_quic_ossl_new_session_cb);
+  }
+
+#else
+#error "ngtcp2 TLS backend not configured"
+#endif /* USE_OPENSSL */
+
+  return CURLE_OK;
+}
+
+static CURLcode cf_ngtcp2_proxy_on_session_reuse(struct Curl_cfilter *cf,
+                                           struct Curl_easy *data,
+                                           struct alpn_spec *alpns,
+                                           struct Curl_ssl_session *scs,
+                                           bool *do_early_data)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  CURLcode result = CURLE_OK;
+
+  *do_early_data = FALSE;
+#if defined(USE_OPENSSL) && defined(HAVE_OPENSSL_EARLYDATA)
+  ctx->earlydata_max = scs->earlydata_max;
+#endif
+#ifdef USE_GNUTLS
+  ctx->earlydata_max =
+    gnutls_record_get_max_early_data_size(ctx->tls.gtls.session);
+#endif /* USE_GNUTLS */
+#ifdef USE_WOLFSSL
+#ifdef WOLFSSL_EARLY_DATA
+  ctx->earlydata_max = scs->earlydata_max;
+#else
+  ctx->earlydata_max = 0;
+#endif /* WOLFSSL_EARLY_DATA */
+#endif /* USE_WOLFSSL */
+#if defined(USE_GNUTLS) || defined(USE_WOLFSSL) || \
+    (defined(USE_OPENSSL) && defined(HAVE_OPENSSL_EARLYDATA))
+  if((!ctx->earlydata_max)) {
+    CURL_TRC_CF(data, cf, "SSL session does not allow earlydata");
+  }
+  else if(!Curl_alpn_contains_proto(alpns, scs->alpn)) {
+    CURL_TRC_CF(data, cf, "SSL session from different ALPN, no early data");
+  }
+  else if(!scs->quic_tp || !scs->quic_tp_len) {
+    CURL_TRC_CF(data, cf, "no 0RTT transport parameters, no early data, ");
+  }
+  else {
+    int rv;
+    rv = ngtcp2_conn_decode_and_set_0rtt_transport_params(
+      ctx->qconn, (const uint8_t *)scs->quic_tp, scs->quic_tp_len);
+    if(rv)
+      CURL_TRC_CF(data, cf, "no early data, failed to set 0RTT transport "
+                  "parameters: %s", ngtcp2_strerror(rv));
+    else {
+      infof(data, "SSL session allows %zu bytes of early data, "
+            "reusing ALPN '%s'", ctx->earlydata_max, scs->alpn);
+      result = cf_ngtcp2_h3conn_init(cf, data);
+      if(!result) {
+        ctx->use_earlydata = TRUE;
+        proxy_ctx->connected = TRUE;
+        *do_early_data = TRUE;
+      }
+    }
+  }
+#else /* not supported in the TLS backend */
+  (void)data;
+  (void)ctx;
+  (void)scs;
+  (void)alpns;
+#endif
+  return result;
+}
+
+static CURLcode cf_h3_proxy_ctx_init(struct Curl_cfilter *cf,
+                                     struct Curl_easy *data)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = NULL;
+  int rc;
+  int rv;
+  CURLcode result = CURLE_OK;
+  const struct Curl_sockaddr_ex *sockaddr = NULL;
+  int qfd;
+  static const struct alpn_spec ALPN_SPEC_H3 = {{ "h3", "h3-29" }, 2};
+  struct proxy_pkt_io_ctx pktx;
+
+  ctx = curlx_calloc(1, sizeof(struct cf_ngtcp2_proxy_ctx));
+  if(!ctx) {
+    result = CURLE_OUT_OF_MEMORY;
+    goto out;
+  }
+  cf_ngtcp2_proxy_ctx_init(ctx);
+
+  memset(&proxy_ctx->tunnel, 0, sizeof(proxy_ctx->tunnel));
+
+  Curl_bufq_init2(&proxy_ctx->inbufq, PROXY_H3_STREAM_CHUNK_SIZE,
+                  PROXY_H3_STREAM_RECV_CHUNKS, BUFQ_OPT_SOFT_LIMIT);
+
+  result = h3_tunnel_stream_init(&proxy_ctx->tunnel, proxy_ctx->dest);
+  if(result)
+    goto out;
+
+  DEBUGASSERT(ctx->initialized);
+  ctx->started_at = *Curl_pgrs_now(data);
+
+  /* Initialize connection IDs BEFORE creating the connection */
+  ctx->dcid.datalen = NGTCP2_MAX_CIDLEN;
+  result = Curl_rand(data, ctx->dcid.data, NGTCP2_MAX_CIDLEN);
+  if(result)
+    goto out;
+
+  ctx->scid.datalen = NGTCP2_MAX_CIDLEN;
+  result = Curl_rand(data, ctx->scid.data, NGTCP2_MAX_CIDLEN);
+  if(result)
+    goto out;
+
+  (void)Curl_qlogdir(data, ctx->scid.data, NGTCP2_MAX_CIDLEN, &qfd);
+  ctx->qlogfd = qfd; /* -1 if failure above */
+
+  result = CURLE_QUIC_CONNECT_ERROR;
+  if(!cf->next) {
+    CURL_TRC_CF(data, cf, "h3_proxy_ctx_init: no lower filter");
+    goto out;
+  }
+  ctx->q.sockfd = Curl_conn_cf_get_socket(cf->next, data);
+  if(ctx->q.sockfd == CURL_SOCKET_BAD)
+    goto out;
+  /* Get remote address from the socket filter below */
+  if(cf->next->cft->query(cf->next, data, CF_QUERY_REMOTE_ADDR, NULL,
+                           CURL_UNCONST(&sockaddr)))
+    goto out;
+  if(!sockaddr)
+    goto out;
+  ctx->q.local_addrlen = sizeof(ctx->q.local_addr);
+  rv = getsockname(ctx->q.sockfd, (struct sockaddr *)&ctx->q.local_addr,
+                   &ctx->q.local_addrlen);
+  if(rv == -1)
+    goto out;
+
+  /* Initialize vquic context BEFORE proxy_pktx_init which needs it */
+  result = vquic_ctx_init(data, &ctx->q);
+  if(result)
+    goto out;
+
+  /* Set ngtcp2_ctx in proxy_ctx BEFORE proxy_pktx_init which accesses it */
+  proxy_ctx->ngtcp2_ctx = ctx;
+
+  /* Now we can safely initialize pktx and settings */
+  proxy_pktx_init(&pktx, cf, data);
+  quic_settings_proxy(ctx, data, &pktx);
+
+  ngtcp2_addr_init(&ctx->connected_path.local,
+                   (struct sockaddr *)&ctx->q.local_addr,
+                   ctx->q.local_addrlen);
+  ngtcp2_addr_init(&ctx->connected_path.remote,
+                   &sockaddr->curl_sa_addr, (socklen_t)sockaddr->addrlen);
+
+  rc = ngtcp2_conn_client_new(&ctx->qconn, &ctx->dcid, &ctx->scid,
+                              &ctx->connected_path,
+                              NGTCP2_PROTO_VER_V1, &ngtcp2_proxy_callbacks,
+                              &ctx->settings, &ctx->transport_params,
+                              Curl_ngtcp2_mem(), cf);
+  if(rc) {
+    result = CURLE_QUIC_CONNECT_ERROR;
+    goto out;
+  }
+
+  ctx->conn_ref.get_conn = proxy_get_conn;
+  ctx->conn_ref.user_data = cf;
+
+  result = Curl_vquic_tls_init(&ctx->tls, cf, data, &ctx->peer, &ALPN_SPEC_H3,
+                               cf_ngtcp2_proxy_tls_ctx_setup, &ctx->tls,
+                               &ctx->conn_ref,
+                               cf_ngtcp2_proxy_on_session_reuse);
+  if(result)
+    goto out;
+
+#if defined(USE_OPENSSL) && defined(OPENSSL_QUIC_API2)
+  if(ngtcp2_crypto_ossl_ctx_new(&ctx->ossl_ctx, ctx->tls.ossl.ssl) != 0) {
+    failf(data, "ngtcp2_crypto_ossl_ctx_new failed");
+    result = CURLE_FAILED_INIT;
+    goto out;
+  }
+  ngtcp2_conn_set_tls_native_handle(ctx->qconn, ctx->ossl_ctx);
+  if(ngtcp2_crypto_ossl_configure_client_session(ctx->tls.ossl.ssl) != 0) {
+    failf(data, "ngtcp2_crypto_ossl_configure_client_session failed");
+    result = CURLE_FAILED_INIT;
+    goto out;
+  }
+#elif defined(USE_OPENSSL)
+  SSL_set_quic_use_legacy_codepoint(ctx->tls.ossl.ssl, 0);
+  ngtcp2_conn_set_tls_native_handle(ctx->qconn, ctx->tls.ossl.ssl);
+#else
+#error "ngtcp2 TLS backend not defined"
+#endif /* USE_OPENSSL */
+
+  ngtcp2_ccerr_default(&ctx->last_error);
+
+  proxy_ctx->connected = FALSE;
+
+out:
+  if(result) {
+    if(ctx) {
+      proxy_ctx->ngtcp2_ctx = NULL; /* Clear before freeing on error */
+      cf_ngtcp2_proxy_ctx_free(ctx);
+    }
+  }
+  CURL_TRC_CF(data, cf, "QUIC tls init -> %d", result);
+  return result;
+}
+
+static CURLcode h3_submit_CONNECT(struct Curl_cfilter *cf,
+                               struct Curl_easy *data,
+                               struct h3_tunnel_stream *ts)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  CURLcode result;
+  struct httpreq *req = NULL;
+
+  result = Curl_http_proxy_create_tunnel_request(&req, cf, data,
+                                                  proxy_ctx->dest,
+                                                  PROXY_HTTP_V3,
+                                                  (bool)proxy_ctx->udp_tunnel);
+  if(result)
+    goto out;
+  result = Curl_creader_set_null(data);
+  if(result)
+    goto out;
+
+  proxy_h3_submit(&ts->stream_id, cf, data, req, &result);
+
+out:
+  if(req)
+    Curl_http_req_free(req);
+  if(result)
+    failf(data, "Failed sending CONNECT to proxy");
+  return result;
+}
+
+static CURLcode
+h3_proxy_inspect_response(struct Curl_cfilter *cf,
+                 struct Curl_easy *data,
+                 struct h3_tunnel_stream *ts)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  proxy_inspect_result res;
+  CURLcode result;
+
+  result = Curl_http_proxy_inspect_tunnel_response(
+      cf, data, ts->resp, (bool)proxy_ctx->udp_tunnel, &res);
+  if(result)
+    return result;
+  switch(res) {
+  case PROXY_INSPECT_OK:
+    h3_tunnel_go_state(cf, ts, H3_TUNNEL_ESTABLISHED, data,
+                       (bool)proxy_ctx->udp_tunnel);
+    break;
+  case PROXY_INSPECT_FAILED:
+    h3_tunnel_go_state(cf, ts, H3_TUNNEL_FAILED, data,
+                       (bool)proxy_ctx->udp_tunnel);
+    result = CURLE_COULDNT_CONNECT;
+    break;
+  case PROXY_INSPECT_AUTH_RETRY:
+    h3_tunnel_go_state(cf, ts, H3_TUNNEL_INIT, data,
+                       (bool)proxy_ctx->udp_tunnel);
+    break;
+  }
+  return result;
+}
+
+static CURLcode cf_h3_proxy_quic_connect(struct Curl_cfilter *cf,
+                                         struct Curl_easy *data,
+                                         bool *done)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_call_data save;
+  CURLcode result = CURLE_OK;
+  struct proxy_pkt_io_ctx pktx;
+
+  if(proxy_ctx->connected) {
+    *done = TRUE;
+    return CURLE_OK;
+  }
+
+  /* Connect the sub-chain (UDP via happy eyeballs) */
+  if(cf->next && !cf->next->connected) {
+    result = Curl_conn_cf_connect(cf->next, data, done);
+    if(result || !*done)
+      return result;
+  }
+
+  *done = FALSE;
+
+  if(!proxy_ctx->ngtcp2_ctx) {
+    result = cf_h3_proxy_ctx_init(cf, data);
+    if(result)
+      return result;
+  }
+
+  /* Initialize pktx AFTER ensuring ngtcp2_ctx exists */
+  proxy_pktx_init(&pktx, cf, data);
+
+  CF_DATA_SAVE(save, cf, data);
+
+  if(!proxy_ctx->ngtcp2_ctx->qconn) {
+    proxy_ctx->ngtcp2_ctx->started_at = *Curl_pgrs_now(data);
+    if(proxy_ctx->connected) {
+      *done = TRUE;
+      goto out;
+    }
+    result = proxy_h3_progress_egress_ngtcp2(cf, data, &pktx);
+    /* we do not expect to be able to recv anything yet */
+    goto out;
+  }
+
+  result = proxy_h3_progress_ingress_ngtcp2(cf, data, &pktx);
+  if(result)
+    goto out;
+
+  result = proxy_h3_progress_egress_ngtcp2(cf, data, &pktx);
+  if(result)
+    goto out;
+
+  if(ngtcp2_conn_get_handshake_completed(proxy_ctx->ngtcp2_ctx->qconn)) {
+    result = proxy_ctx->ngtcp2_ctx->tls_vrfy_result;
+    if(!result) {
+      CURL_TRC_CF(data, cf, "peer verified");
+      proxy_ctx->connected = TRUE;
+      *done = TRUE;
+      connkeep(cf->conn, "HTTP/3 default");
+    }
+  }
+
+out:
+  if(proxy_ctx->ngtcp2_ctx->qconn &&
+     ((result == CURLE_RECV_ERROR) || (result == CURLE_SEND_ERROR)) &&
+     ngtcp2_conn_in_draining_period(proxy_ctx->ngtcp2_ctx->qconn)) {
+    const ngtcp2_ccerr *cerr =
+      ngtcp2_conn_get_ccerr(proxy_ctx->ngtcp2_ctx->qconn);
+
+    result = CURLE_COULDNT_CONNECT;
+    if(cerr) {
+      CURL_TRC_CF(data, cf, "connect error, type=%d, code=%"
+                  PRIu64,
+                  cerr->type, (uint64_t)cerr->error_code);
+      switch(cerr->type) {
+      case NGTCP2_CCERR_TYPE_VERSION_NEGOTIATION:
+        CURL_TRC_CF(data, cf, "error in version negotiation");
+        break;
+      default:
+        if(cerr->error_code >= NGTCP2_CRYPTO_ERROR) {
+          CURL_TRC_CF(data, cf, "crypto error, tls alert=%u",
+                      (unsigned int)(cerr->error_code & 0xffU));
+        }
+        else if(cerr->error_code == NGTCP2_CONNECTION_REFUSED) {
+          CURL_TRC_CF(data, cf, "connection refused by server");
+          /* When a QUIC server instance is shutting down, it may send us a
+           * CONNECTION_CLOSE with this code right away. We want
+           * to keep on trying in this case. */
+          result = CURLE_WEIRD_SERVER_REPLY;
+        }
+      }
+    }
+  }
+
+#ifdef CURLVERBOSE
+  if(result) {
+    bool is_ipv6;
+    struct ip_quadruple ip;
+    if(!Curl_conn_cf_get_ip_info(cf->next, data, &is_ipv6, &ip))
+      infof(data, "QUIC connect to %s port %u failed: %s",
+            ip.remote_ip, ip.remote_port, curl_easy_strerror(result));
+  }
+#endif
+  if(!result && proxy_ctx->ngtcp2_ctx->qconn) {
+    result = check_and_set_expiry_ngtcp2(cf, data, &pktx);
+  }
+  if(result || *done)
+    CURL_TRC_CF(data, cf, "connect -> %d, done=%d", result, *done);
+  CF_DATA_RESTORE(cf, save);
+  return result;
+}
+
+static CURLcode H3_CONNECT(struct Curl_cfilter *cf,
+                           struct Curl_easy *data,
+                           struct h3_tunnel_stream *ts)
+{
+  struct cf_h3_proxy_ctx *ctx = cf->ctx;
+  CURLcode result = CURLE_OK;
+
+  DEBUGASSERT(ts);
+  DEBUGASSERT(ts->authority);
+
+  do {
+    switch(ts->state) {
+    case H3_TUNNEL_INIT:
+      CURL_TRC_CF(data, cf, "[0] CONNECT start for %s", ts->authority);
+      result = h3_submit_CONNECT(cf, data, ts);
+      if(result)
+        goto out;
+      h3_tunnel_go_state(cf, ts, H3_TUNNEL_CONNECT, data,
+                         (bool)ctx->udp_tunnel);
+
+      result = proxy_h3_progress_egress_ngtcp2(cf, data, NULL);
+      if(result)
+        goto out;
+      FALLTHROUGH();
+
+    case H3_TUNNEL_CONNECT:
+      /* Non-blocking: call ingress/egress once and return.
+       * The multi interface will call us again when ready. */
+      result = proxy_h3_progress_ingress_ngtcp2(cf, data, NULL);
+      if(result)
+        goto out;
+      result = proxy_h3_progress_egress_ngtcp2(cf, data, NULL);
+      if(result && result != CURLE_AGAIN) {
+        h3_tunnel_go_state(cf, ts, H3_TUNNEL_FAILED, data,
+                           (bool)ctx->udp_tunnel);
+        goto out;
+      }
+
+      if(ts->has_final_response) {
+        h3_tunnel_go_state(cf, ts, H3_TUNNEL_RESPONSE, data,
+                           (bool)ctx->udp_tunnel);
+      }
+      else {
+        /* Not done yet, return and let multi interface call us again */
+        result = CURLE_OK;
+        goto out;
+      }
+      FALLTHROUGH();
+
+    case H3_TUNNEL_RESPONSE:
+      DEBUGASSERT(ts->has_final_response);
+      result = h3_proxy_inspect_response(cf, data, ts);
+      if(result)
+        goto out;
+      ctx->connected = TRUE;
+      break;
+
+    case H3_TUNNEL_ESTABLISHED:
+      return CURLE_OK;
+
+    case H3_TUNNEL_FAILED:
+      return CURLE_RECV_ERROR;
+
+    default:
+      break;
+    }
+
+  } while(ts->state == H3_TUNNEL_INIT);
+
+out:
+  if((result && (result != CURLE_AGAIN)) || ctx->tunnel.closed)
+    h3_tunnel_go_state(cf, ts, H3_TUNNEL_FAILED, data, (bool)ctx->udp_tunnel);
+  return result;
+}
+
+static CURLcode
+cf_h3_proxy_connect(struct Curl_cfilter *cf,
+                    struct Curl_easy *data,
+                    bool *done)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_call_data save = {0};
+  CURLcode result = CURLE_OK;
+  timediff_t check;
+  struct h3_tunnel_stream *ts = &proxy_ctx->tunnel;
+  bool data_saved = FALSE;
+
+  /* Curl_cft_http_proxy --> Curl_cft_h3_proxy --> HAPPY-EYEBALLS --> UDP */
+  if(cf->connected) {
+    *done = TRUE;
+    return CURLE_OK;
+  }
+
+  *done = FALSE;
+
+  check = Curl_timeleft_ms(data);
+  if(check <= 0) {
+    failf(data, "Proxy CONNECT aborted due to timeout");
+    result = CURLE_OPERATION_TIMEDOUT;
+    goto out;
+  }
+
+  result = cf_h3_proxy_quic_connect(cf, data, done);
+  if(*done != TRUE)
+    goto out;
+
+  CF_DATA_SAVE(save, cf, data);
+  data_saved = TRUE;
+
+  /* At this point the QUIC is connected, but the proxy isn't connected */
+  *done = FALSE;
+
+  result = H3_CONNECT(cf, data, ts);
+
+out:
+  *done = (result == CURLE_OK) && (ts->state == H3_TUNNEL_ESTABLISHED);
+  if(*done) {
+    cf->connected = TRUE;
+    /* The real request will follow the CONNECT, reset request partially */
+    Curl_req_soft_reset(&data->req, data);
+    Curl_client_reset(data);
+  }
+
+  if(data_saved)
+    CF_DATA_RESTORE(cf, save);
+  return result;
+}
+
+static CURLcode h3_proxy_data_pause(struct Curl_cfilter *cf,
+                              struct Curl_easy *data,
+                              bool pause)
+{
+  (void)cf;
+  if(!pause) {
+    /* unpaused. make it run again right away */
+    Curl_multi_mark_dirty(data);
+  }
+  return CURLE_OK;
+}
+
+static void h3_proxy_data_done(struct Curl_cfilter *cf, struct Curl_easy *data)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+  struct h3_proxy_stream_ctx *stream;
+
+  if(!ctx)
+    return;
+
+  stream = H3_PROXY_STREAM_CTX(ctx, data);
+  if(stream) {
+    CURL_TRC_CF(data, cf, "[%" PRId64 "] easy handle is done",
+                stream->id);
+    cf_ngtcp2_proxy_stream_close(cf, data, stream);
+    Curl_uint32_hash_remove(&ctx->streams, data->mid);
+    if(!Curl_uint32_hash_count(&ctx->streams))
+      cf_ngtcp2_proxy_setup_keep_alive(cf, data);
+  }
+}
+
+static CURLcode cf_h3_proxy_cntrl(struct Curl_cfilter *cf,
+                                  struct Curl_easy *data,
+                                  int event, int arg1, void *arg2)
+{
+  struct cf_h3_proxy_ctx *proxy_ctx = cf->ctx;
+  struct cf_call_data save;
+  CURLcode result = CURLE_OK;
+
+  CF_DATA_SAVE(save, cf, data);
+
+  (void)arg1;
+  (void)arg2;
+  switch(event) {
+  case CF_CTRL_DATA_SETUP:
+    break;
+  case CF_CTRL_DATA_PAUSE:
+    result = h3_proxy_data_pause(cf, data, (arg1 != 0));
+    break;
+  case CF_CTRL_DATA_DONE:
+    h3_proxy_data_done(cf, data);
+    break;
+  case CF_CTRL_DATA_DONE_SEND: {
+    struct cf_ngtcp2_proxy_ctx *ctx = proxy_ctx->ngtcp2_ctx;
+    struct h3_proxy_stream_ctx *stream = NULL;
+    if(ctx) {
+      stream = H3_PROXY_STREAM_CTX(ctx, data);
+      if(stream && !stream->send_closed &&
+         (H3_STREAM_ID(stream) != proxy_ctx->tunnel.stream_id)) {
+        stream->send_closed = TRUE;
+        stream->upload_left = Curl_bufq_len(&stream->sendbuf) -
+          stream->sendbuf_len_in_flight;
+        (void)nghttp3_conn_resume_stream(ctx->h3conn, H3_STREAM_ID(stream));
+      }
+    }
+    break;
+  }
+  case CF_CTRL_CONN_INFO_UPDATE:
+    if(!cf->sockindex && cf->connected) {
+      cf->conn->httpversion_seen = 30;
+      Curl_conn_set_multiplex(cf->conn);
+    }
+    break;
+  default:
+    break;
+  }
+
+  CF_DATA_RESTORE(cf, save);
+  return result;
+}
+
+static void cf_h3_proxy_destroy(struct Curl_cfilter *cf,
+                                struct Curl_easy *data)
+{
+  struct cf_h3_proxy_ctx *ctx = cf->ctx;
+
+  if(ctx) {
+    /* Clean up the ngtcp2 context properly */
+    if(ctx->ngtcp2_ctx) {
+      CURL_TRC_CF(data, cf, "cf_ngtcp2_proxy_ctx_close()");
+      cf_ngtcp2_proxy_close(cf, data);
+      cf_ngtcp2_proxy_ctx_free(ctx->ngtcp2_ctx);
+      ctx->ngtcp2_ctx = NULL;
+    }
+    cf_h3_proxy_ctx_free(ctx);
+    cf->ctx = NULL;
+  }
+}
+
+static void cf_h3_proxy_close(struct Curl_cfilter *cf, struct Curl_easy *data)
+{
+  struct cf_h3_proxy_ctx *ctx = cf->ctx;
+
+  if(ctx) {
+    if(ctx->ngtcp2_ctx) {
+      cf_ngtcp2_proxy_close(cf, data);
+      cf_ngtcp2_proxy_ctx_free(ctx->ngtcp2_ctx);
+      ctx->ngtcp2_ctx = NULL;
+    }
+    cf_h3_proxy_ctx_clear(ctx);
+    cf->connected = FALSE;
+  }
+
+  if(cf->next)
+    cf->next->cft->do_close(cf->next, data);
+}
+
+static CURLcode cf_h3_proxy_shutdown(struct Curl_cfilter *cf,
+                                     struct Curl_easy *data, bool *done)
+{
+  return cf_ngtcp2_proxy_shutdown(cf, data, done);
+}
+
+struct Curl_cftype Curl_cft_h3_proxy = {
+    "H3-PROXY",
+    CF_TYPE_IP_CONNECT | CF_TYPE_PROXY,
+    CURL_LOG_LVL_NONE,
+    cf_h3_proxy_destroy,
+    cf_h3_proxy_connect,
+    cf_h3_proxy_close,
+    cf_h3_proxy_shutdown,
+    cf_h3_proxy_adjust_pollset,
+    cf_h3_proxy_data_pending,
+    cf_h3_proxy_send,
+    cf_h3_proxy_recv,
+    cf_h3_proxy_cntrl,
+    cf_h3_proxy_is_alive,
+    Curl_cf_def_conn_keep_alive,
+    cf_h3_proxy_query,
+};
+
+CURLcode Curl_cf_h3_proxy_insert_after(struct Curl_cfilter *cf_at,
+                                       struct Curl_easy *data,
+                                       struct Curl_peer *dest,
+                                       bool udp_tunnel)
+{
+  struct Curl_cfilter *cf = NULL;
+  struct cf_h3_proxy_ctx *ctx;
+  CURLcode result = CURLE_OUT_OF_MEMORY;
+  (void)data;
+
+  ctx = curlx_calloc(1, sizeof(*ctx));
+  if(!ctx)
+    goto out;
+  Curl_peer_link(&ctx->dest, dest);
+  ctx->udp_tunnel = udp_tunnel;
+
+  result = Curl_cf_create(&cf, &Curl_cft_h3_proxy, ctx);
+  if(result)
+    goto out;
+
+  /* H3-PROXY uses the UDP socket created by happy eyeballs below it.
+     Curl_conn_cf_insert_after chains the existing sub-filters, i.e.
+     "HAPPY-EYEBALLS -> UDP" as cf->next of H3-PROXY. */
+  Curl_conn_cf_insert_after(cf_at, cf);
+
+out:
+  if(result) {
+    if(cf)
+      Curl_conn_cf_discard_chain(&cf, data);
+    else if(ctx)
+      cf_h3_proxy_ctx_free(ctx);
+  }
+  return result;
+}
+
+#endif
+
+/* Do not leak this filter's call_data accessor in unity builds. */
+#undef CF_CTX_CALL_DATA
diff --git a/lib/cf-h3-proxy.h b/lib/cf-h3-proxy.h
new file mode 100644 (file)
index 0000000..c1d5dd1
--- /dev/null
@@ -0,0 +1,42 @@
+#ifndef HEADER_CURL_H3_PROXY_H
+#define HEADER_CURL_H3_PROXY_H
+/***************************************************************************
+ *                                  _   _ ____  _
+ *  Project                     ___| | | |  _ \| |
+ *                             / __| | | | |_) | |
+ *                            | (__| |_| |  _ <| |___
+ *                             \___|\___/|_| \_\_____|
+ *
+ * Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
+ *
+ * This software is licensed as described in the file COPYING, which
+ * you should have received as part of this distribution. The terms
+ * are also available at https://curl.se/docs/copyright.html.
+ *
+ * You may opt to use, copy, modify, merge, publish, distribute and/or sell
+ * copies of the Software, and permit persons to whom the Software is
+ * furnished to do so, under the terms of the COPYING file.
+ *
+ * This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
+ * KIND, either express or implied.
+ *
+ * SPDX-License-Identifier: curl
+ *
+ ***************************************************************************/
+
+#include "curl_setup.h"
+
+#if !defined(CURL_DISABLE_HTTP) && !defined(CURL_DISABLE_PROXY) && \
+    defined(USE_PROXY_HTTP3) && defined(USE_NGHTTP3) && \
+    defined(USE_NGTCP2) && defined(USE_OPENSSL)
+
+CURLcode Curl_cf_h3_proxy_insert_after(struct Curl_cfilter *cf_at,
+                                       struct Curl_easy *data,
+                                       struct Curl_peer *dest,
+                                       bool udp_tunnel);
+
+extern struct Curl_cftype Curl_cft_h3_proxy;
+
+#endif
+
+#endif /* HEADER_CURL_H3_PROXY_H */
index 17b2821b087f9ff250267f04abafe05150ecbfd2..965415d4585051517118aee2a1343d69d1118a3b 100644 (file)
@@ -1023,3 +1023,28 @@ CURLcode cf_ip_happy_insert_after(struct Curl_cfilter *cf_at,
   Curl_conn_cf_insert_after(cf_at, cf);
   return CURLE_OK;
 }
+
+#if !defined(CURL_DISABLE_HTTP) && defined(USE_HTTP3) && \
+    defined(USE_PROXY_HTTP3)
+CURLcode cf_ip_happy_quic_udp_insert_after(struct Curl_cfilter *cf_at,
+                                           struct Curl_easy *data)
+{
+  /* For H3 proxy: create happy eyeballs that races IPv4/IPv6 using raw
+     UDP sockets with TRNSPRT_QUIC transport. Using TRNSPRT_QUIC causes
+     cf_udp_connect() to call cf_udp_setup_quic() which connects the
+     socket to the peer address, making send() work without an explicit
+     destination. We use Curl_cf_udp_create (not Curl_cf_quic_create)
+     because H3-PROXY manages its own ngtcp2 QUIC stack on top. */
+  struct Curl_cfilter *cf;
+  CURLcode result;
+
+  DEBUGASSERT(cf_at);
+  result = cf_ip_happy_create(&cf, data, cf_at->conn,
+                              Curl_cf_udp_create, TRNSPRT_QUIC);
+  if(result)
+    return result;
+
+  Curl_conn_cf_insert_after(cf_at, cf);
+  return CURLE_OK;
+}
+#endif /* !CURL_DISABLE_HTTP && USE_HTTP3 && USE_PROXY_HTTP3 */
index 547ee4b4ac904e6f1b8845a96a787eca5eb35217..5805d6397c595f12c44fd07061153a576af1264f 100644 (file)
@@ -52,6 +52,15 @@ CURLcode cf_ip_happy_insert_after(struct Curl_cfilter *cf_at,
                                   struct Curl_easy *data,
                                   uint8_t transport);
 
+#if !defined(CURL_DISABLE_HTTP) && defined(USE_HTTP3) && \
+    defined(USE_PROXY_HTTP3)
+/* For H3 proxy: create happy eyeballs that races IPv4/IPv6 using raw UDP
+   sockets with TRNSPRT_QUIC transport so the socket is connected to the
+   proxy peer. H3-PROXY manages its own ngtcp2 QUIC stack on top. */
+CURLcode cf_ip_happy_quic_udp_insert_after(struct Curl_cfilter *cf_at,
+                                           struct Curl_easy *data);
+#endif /* !CURL_DISABLE_HTTP && USE_HTTP3 && USE_PROXY_HTTP3 */
+
 extern struct Curl_cftype Curl_cft_ip_happy;
 
 #endif /* HEADER_CURL_IP_HAPPY_H */
index e74bda5dfb8feadfee0d2f1807850f93e94c2179..64ec2ff941f60d14f71f50d5875a12fc76aaee4e 100644 (file)
@@ -63,6 +63,7 @@
 #include "curlx/inet_ntop.h"
 #include "curlx/strparse.h"
 #include "vtls/vtls.h" /* for vtls cfilters */
+#include "vquic/vquic.h" /* for QUIC cfilters */
 #include "progress.h"
 #include "conncache.h"
 #include "multihandle.h"
@@ -341,6 +342,66 @@ struct cf_setup_ctx {
   uint8_t transport;
 };
 
+#ifndef CURL_DISABLE_PROXY
+static CURLcode cf_setup_add_http_proxy(struct Curl_cfilter *cf,
+                                        struct Curl_easy *data,
+                                        struct cf_setup_ctx *ctx)
+{
+  CURLcode result = CURLE_OK;
+#ifndef USE_SSL
+  (void)cf;
+  (void)data;
+  (void)ctx;
+#else
+  /* Skipping the Curl_conn_is_ssl check because SSL is a part of QUIC
+     For CURLPROXY_HTTPS and CURLPROXY_HTTPS2:
+     Curl_cft_setup --> Curl_cft_ssl --> Curl_cft_http_proxy --> ...
+     For CURLPROXY_HTTPS3:
+     Curl_cft_setup --> Curl_cft_http3 --> Curl_cft_http_proxy --> ... */
+  if(ctx->transport == TRNSPRT_QUIC && cf->conn->bits.httpproxy) {
+    if(!IS_QUIC_PROXY(cf->conn->http_proxy.proxytype)) {
+      result = Curl_cf_ssl_proxy_insert_after(cf, data);
+      if(result)
+        return result;
+    }
+  }
+  else {
+    if(IS_HTTPS_PROXY(cf->conn->http_proxy.proxytype)
+      && !Curl_conn_is_ssl(cf->conn, cf->sockindex)
+      && !IS_QUIC_PROXY(cf->conn->http_proxy.proxytype)) {
+      result = Curl_cf_ssl_proxy_insert_after(cf, data);
+      if(result)
+        return result;
+    }
+  }
+#endif /* USE_SSL */
+
+#ifndef CURL_DISABLE_HTTP
+  if(cf->conn->bits.tunnel_proxy) {
+    struct Curl_peer *dest; /* where HTTP should tunnel to */
+    bool udp_tun = false;
+    dest = Curl_conn_get_destination(cf->conn, cf->sockindex);
+    /* Use CONNECT-UDP only for explicit HTTP/3-only target tunnels.
+       Do not derive this from proxy transport (for example HTTPS3 proxy). */
+    if(data->state.http_neg.wanted == CURL_HTTP_V3x) {
+#ifdef USE_PROXY_HTTP3
+      udp_tun = TRUE;
+#else
+      failf(data, "HTTP/3 proxy tunnel support not built-in");
+      return CURLE_NOT_BUILT_IN;
+#endif /* USE_PROXY_HTTP3 */
+    }
+    result = Curl_cf_http_proxy_insert_after(cf, data, dest,
+                                             cf->conn->http_proxy.proxytype,
+                                             udp_tun);
+    if(result)
+      return result;
+  }
+#endif /* !CURL_DISABLE_HTTP */
+  return result;
+}
+#endif /* !CURL_DISABLE_PROXY */
+
 static CURLcode cf_setup_connect(struct Curl_cfilter *cf,
                                  struct Curl_easy *data,
                                  bool *done)
@@ -364,7 +425,35 @@ connect_sub_chain:
   }
 
   if(ctx->state < CF_SETUP_CNNCT_EYEBALLS) {
-    result = cf_ip_happy_insert_after(cf, data, ctx->transport);
+#ifndef CURL_DISABLE_PROXY
+#if !defined(CURL_DISABLE_HTTP) && defined(USE_HTTP3) && \
+    defined(USE_PROXY_HTTP3)
+    if(IS_QUIC_PROXY(cf->conn->http_proxy.proxytype) &&
+       cf->conn->bits.tunnel_proxy) {
+      /* For HTTPS3 proxy tunnels, H3-PROXY manages the QUIC connection
+         on top of the UDP socket. Let happy eyeballs race IPv4/IPv6 using
+         QUIC-transport UDP sockets so the socket is connected to the
+         proxy peer and H3-PROXY can send directly via send().
+         Filter chains:
+         H1/H2 target (CONNECT over QUIC):
+           SETUP --> HTTP/1.1 or HTTP/2 --> SSL --> HTTP-PROXY -->
+             H3-PROXY --> HAPPY-EYEBALLS --> UDP
+         H3 target (MASQUE CONNECT-UDP over QUIC):
+           SETUP --> HTTP/3 --> CAPSULE --> HTTP-PROXY -->
+             H3-PROXY --> HAPPY-EYEBALLS --> UDP */
+      result = cf_ip_happy_quic_udp_insert_after(cf, data);
+    }
+    /* When tunneling QUIC through an HTTP proxy (CONNECT-UDP),
+       the underlying conn to the proxy is TCP. */
+    else
+#endif /* !CURL_DISABLE_HTTP && USE_HTTP3 && USE_PROXY_HTTP3 */
+    if(ctx->transport == TRNSPRT_QUIC && cf->conn->bits.httpproxy
+       && !IS_QUIC_PROXY(cf->conn->http_proxy.proxytype))
+      result = cf_ip_happy_insert_after(cf, data, TRNSPRT_TCP);
+    else
+#endif /* !CURL_DISABLE_PROXY */
+      result = cf_ip_happy_insert_after(cf, data, ctx->transport);
+
     if(result)
       return result;
     ctx->state = CF_SETUP_CNNCT_EYEBALLS;
@@ -402,25 +491,9 @@ connect_sub_chain:
   }
 
   if(ctx->state < CF_SETUP_CNNCT_HTTP_PROXY && cf->conn->bits.httpproxy) {
-#ifdef USE_SSL
-    if(IS_HTTPS_PROXY(cf->conn->http_proxy.proxytype) &&
-       !Curl_conn_is_ssl(cf->conn, cf->sockindex)) {
-      result = Curl_cf_ssl_proxy_insert_after(cf, data);
-      if(result)
-        return result;
-    }
-#endif /* USE_SSL */
-
-#ifndef CURL_DISABLE_HTTP
-    if(cf->conn->bits.tunnel_proxy) {
-      struct Curl_peer *dest; /* where HTTP should tunnel to */
-      dest = Curl_conn_get_destination(cf->conn, cf->sockindex);
-      result = Curl_cf_http_proxy_insert_after(
-        cf, data, dest, cf->conn->http_proxy.proxytype);
-      if(result)
-        return result;
-    }
-#endif /* !CURL_DISABLE_HTTP */
+    result = cf_setup_add_http_proxy(cf, data, ctx);
+    if(result)
+      return result;
     ctx->state = CF_SETUP_CNNCT_HTTP_PROXY;
     if(!cf->next || !cf->next->connected)
       goto connect_sub_chain;
@@ -445,21 +518,41 @@ connect_sub_chain:
       goto connect_sub_chain;
   }
 
-  if(ctx->state < CF_SETUP_CNNCT_SSL) {
-#ifdef USE_SSL
-    if((ctx->ssl_mode == CURL_CF_SSL_ENABLE ||
-        (ctx->ssl_mode != CURL_CF_SSL_DISABLE &&
-         cf->conn->scheme->flags & PROTOPT_SSL)) &&  /* we want SSL */
-       !Curl_conn_is_ssl(cf->conn, cf->sockindex)) { /* it is missing */
-      result = Curl_cf_ssl_insert_after(cf, data);
+  /* Adding Curl_cf_quic_insert_after() because now we
+     need the next filter to be QUIC/HTTP/3 (which has SSL) */
+#if !defined(CURL_DISABLE_HTTP) && defined(USE_HTTP3) && \
+    defined(USE_PROXY_HTTP3)
+  if(ctx->transport == TRNSPRT_QUIC && cf->conn->bits.httpproxy &&
+     cf->conn->bits.tunnel_proxy &&
+     (data->state.http_neg.wanted == CURL_HTTP_V3x)) {
+    if(ctx->state < CF_SETUP_CNNCT_SSL) {
+      result = Curl_cf_quic_insert_after(cf);
       if(result)
         return result;
+      ctx->state = CF_SETUP_CNNCT_SSL;
     }
-#endif /* USE_SSL */
-    ctx->state = CF_SETUP_CNNCT_SSL;
     if(!cf->next || !cf->next->connected)
       goto connect_sub_chain;
   }
+  else
+#endif /* !CURL_DISABLE_HTTP && USE_HTTP3 && USE_PROXY_HTTP3 */
+  {
+    if(ctx->state < CF_SETUP_CNNCT_SSL) {
+#ifdef USE_SSL
+      if((ctx->ssl_mode == CURL_CF_SSL_ENABLE ||
+         (ctx->ssl_mode != CURL_CF_SSL_DISABLE &&
+          cf->conn->scheme->flags & PROTOPT_SSL)) /* we want SSL */
+          && !Curl_conn_is_ssl(cf->conn, cf->sockindex)) { /* it is missing */
+        result = Curl_cf_ssl_insert_after(cf, data);
+        if(result)
+          return result;
+      }
+#endif /* USE_SSL */
+      ctx->state = CF_SETUP_CNNCT_SSL;
+      if(!cf->next || !cf->next->connected)
+        goto connect_sub_chain;
+    }
+  }
 
   ctx->state = CF_SETUP_DONE;
   cf->connected = TRUE;
index 31e94d0691e77a3869598803d15510242aa53e9f..5c7fdd670be1681f86dc8caa2a4633037fd1bc6c 100644 (file)
@@ -712,6 +712,9 @@ ${SIZEOF_TIME_T_CODE}
 /* if libuv is in use */
 #cmakedefine USE_LIBUV 1
 
+/* if HTTP/3 proxy support is available */
+#cmakedefine USE_PROXY_HTTP3 1
+
 /* Define to 1 if you have the <uv.h> header file. */
 #cmakedefine HAVE_UV_H 1
 
index c6115cf7f6367e378326eaf50ed93f8a20001ea1..d54c171a549d6ea5ebffa72d2a747743be573f3c 100644 (file)
@@ -35,6 +35,7 @@
 #include "http_proxy.h"
 #include "cf-h1-proxy.h"
 #include "cf-h2-proxy.h"
+#include "cf-h3-proxy.h"
 #include "cf-haproxy.h"
 #include "cf-https-connect.h"
 #include "cf-ip-happy.h"
@@ -578,6 +579,9 @@ static struct trc_cft_def trc_cfts[] = {
   { &Curl_cft_h1_proxy,       TRC_CT_PROXY },
 #ifdef USE_NGHTTP2
   { &Curl_cft_h2_proxy,       TRC_CT_PROXY },
+#endif
+#if defined(USE_PROXY_HTTP3) && defined(USE_NGHTTP3)
+  { &Curl_cft_h3_proxy,       TRC_CT_PROXY },
 #endif
   { &Curl_cft_http_proxy,     TRC_CT_PROXY },
 #endif /* !CURL_DISABLE_HTTP */
index 5d98aab9d7c385243f881cab72a6ca99e75271b0..c935d4f69f209ad83861a7ce00367ce2852f0633 100644 (file)
@@ -1757,6 +1757,12 @@ CURLcode Curl_add_custom_headers(struct Curl_easy *data,
     else
       h[0] = data->set.headers;
     break;
+  case HEADER_CONNECT_UDP:
+    if(data->set.sep_headers)
+      h[0] = data->set.proxyheaders;
+    else
+      h[0] = data->set.headers;
+    break;
   }
 #else
   (void)is_connect;
@@ -2721,7 +2727,11 @@ static CURLcode http_check_new_conn(struct Curl_easy *data)
 
   alpn = Curl_conn_get_alpn_negotiated(data, conn);
   if(alpn && !strcmp("h3", alpn)) {
-    DEBUGASSERT(Curl_conn_http_version(data, conn) == 30);
+#ifndef CURL_DISABLE_PROXY
+    if((Curl_conn_http_version(data, conn) == 30) || !conn->bits.proxy ||
+       conn->bits.tunnel_proxy)
+#endif
+      DEBUGASSERT(Curl_conn_http_version(data, conn) == 30);
     info_version = "HTTP/3";
   }
   else if(alpn && !strcmp("h2", alpn)) {
@@ -4847,7 +4857,6 @@ struct name_const {
   size_t namelen;
 };
 
-/* keep them sorted by length! */
 static const struct name_const H2_NON_FIELD[] = {
   { STRCONST("Host") },
   { STRCONST("Upgrade") },
@@ -4861,10 +4870,8 @@ static bool h2_permissible_field(struct dynhds_entry *e)
 {
   size_t i;
   for(i = 0; i < CURL_ARRAYSIZE(H2_NON_FIELD); ++i) {
-    if(e->namelen < H2_NON_FIELD[i].namelen)
-      return TRUE;
     if(e->namelen == H2_NON_FIELD[i].namelen &&
-       curl_strequal(H2_NON_FIELD[i].name, e->name))
+       curl_strnequal(H2_NON_FIELD[i].name, e->name, e->namelen))
       return FALSE;
   }
   return TRUE;
index 9c25471d3330ae24b7808d2b697f3f80f7d4a342..ed93d265e30827cbf24969ef2436277529516a9e 100644 (file)
@@ -83,8 +83,6 @@ char *Curl_checkProxyheaders(struct Curl_easy *data,
 CURLcode Curl_add_timecondition(struct Curl_easy *data, struct dynbuf *req);
 CURLcode Curl_add_custom_headers(struct Curl_easy *data, bool is_connect,
                                  int httpversion, struct dynbuf *req);
-CURLcode Curl_dynhds_add_custom(struct Curl_easy *data, bool is_connect,
-                                struct dynhds *hds);
 
 void Curl_http_to_fold(struct dynbuf *bf);
 
index 9eb1e0aeaa412e1faac711f2a12d76b287b6281a..9e755a0e1da579f4b16ccf5a46d4e6d7a84756a0 100644 (file)
@@ -3021,3 +3021,6 @@ char *curl_pushheader_byname(struct curl_pushheaders *h, const char *name)
 }
 
 #endif /* !CURL_DISABLE_HTTP && USE_NGHTTP2 */
+
+/* Do not leak this filter's call_data accessor in unity builds. */
+#undef CF_CTX_CALL_DATA
index fd87c1db191849a2f8d849a71da9a70fde848608..373865272bd7915b39383a0a84dffa2100f789af 100644 (file)
 #include "cfilters.h"
 #include "cf-h1-proxy.h"
 #include "cf-h2-proxy.h"
+#include "cf-h3-proxy.h"
+#include "cf-capsule.h"
 #include "connect.h"
 #include "vauth/vauth.h"
 #include "curlx/strparse.h"
 
 static CURLcode dynhds_add_custom(struct Curl_easy *data,
                                   bool is_connect, int httpversion,
-                                  struct dynhds *hds)
+                                  bool is_udp, struct dynhds *hds)
 {
   struct connectdata *conn = data->conn;
   struct curl_slist *h[2];
@@ -49,10 +51,12 @@ static CURLcode dynhds_add_custom(struct Curl_easy *data,
 
   enum Curl_proxy_use proxy;
 
-  if(is_connect)
+  if(is_connect && !is_udp)
     proxy = HEADER_CONNECT;
+  else if(is_connect && is_udp)
+    proxy = HEADER_CONNECT_UDP;
   else
-    proxy = conn->bits.httpproxy && !conn->bits.tunnel_proxy ?
+    proxy = (conn->bits.httpproxy && !conn->bits.tunnel_proxy) ?
       HEADER_PROXY : HEADER_SERVER;
 
   switch(proxy) {
@@ -72,6 +76,12 @@ static CURLcode dynhds_add_custom(struct Curl_easy *data,
     else
       h[0] = data->set.headers;
     break;
+  case HEADER_CONNECT_UDP:
+    if(data->set.sep_headers)
+      h[0] = data->set.proxyheaders;
+    else
+      h[0] = data->set.headers;
+    break;
   }
 
   /* loop through one or two lists */
@@ -166,15 +176,30 @@ struct cf_proxy_ctx {
   struct Curl_peer *dest; /* tunnel destination */
   uint8_t proxytype;
   BIT(sub_filter_installed);
+  BIT(udp_tunnel);
 };
 
+static int proxy_http_ver_major(proxy_http_ver ver)
+{
+  switch(ver) {
+  case PROXY_HTTP_V1:
+    return 11;
+  case PROXY_HTTP_V2:
+    return 20;
+  case PROXY_HTTP_V3:
+    return 30;
+  }
+  return 0;
+}
+
 CURLcode Curl_http_proxy_create_CONNECT(struct httpreq **preq,
                                         struct Curl_cfilter *cf,
                                         struct Curl_easy *data,
                                         struct Curl_peer *dest,
-                                        int httpversion)
+                                        proxy_http_ver ver)
 {
   char *authority = NULL;
+  int httpversion = proxy_http_ver_major(ver);
   CURLcode result;
   struct httpreq *req = NULL;
 
@@ -201,7 +226,7 @@ CURLcode Curl_http_proxy_create_CONNECT(struct httpreq **preq,
     goto out;
 
   /* If user is not overriding Host: header, we add for HTTP/1.x */
-  if(httpversion < 20 &&
+  if(ver == PROXY_HTTP_V1 &&
      !Curl_checkProxyheaders(data, cf->conn, STRCONST("Host"))) {
     result = Curl_dynhds_cadd(&req->headers, "Host", authority);
     if(result)
@@ -223,14 +248,180 @@ CURLcode Curl_http_proxy_create_CONNECT(struct httpreq **preq,
       goto out;
   }
 
-  if(httpversion < 20 &&
+  if(ver == PROXY_HTTP_V1 &&
      !Curl_checkProxyheaders(data, cf->conn, STRCONST("Proxy-Connection"))) {
     result = Curl_dynhds_cadd(&req->headers, "Proxy-Connection", "Keep-Alive");
     if(result)
       goto out;
   }
 
-  result = dynhds_add_custom(data, TRUE, httpversion, &req->headers);
+  result = dynhds_add_custom(data, TRUE, httpversion,
+                             FALSE, &req->headers);
+
+out:
+  if(result && req) {
+    Curl_http_req_free(req);
+    req = NULL;
+  }
+  curlx_free(authority);
+  *preq = req;
+  return result;
+}
+
+CURLcode Curl_http_proxy_create_CONNECTUDP(struct httpreq **preq,
+                                           struct Curl_cfilter *cf,
+                                           struct Curl_easy *data,
+                                           struct Curl_peer *dest,
+                                           proxy_http_ver ver)
+{
+  const char *proxy_scheme = "http";
+  const char *proxy_host = cf->conn->http_proxy.peer->hostname;
+  int httpversion = proxy_http_ver_major(ver);
+  char *authority = NULL;
+  char *path = NULL;
+  char *encoded_host = NULL;
+  struct httpreq *req = NULL;
+  bool proxy_ipv6_ip;
+  CURLcode result;
+
+  if(cf->conn->http_proxy.proxytype == CURLPROXY_HTTPS ||
+     cf->conn->http_proxy.proxytype == CURLPROXY_HTTPS2 ||
+     cf->conn->http_proxy.proxytype == CURLPROXY_HTTPS3)
+    proxy_scheme = "https";
+
+  proxy_ipv6_ip = cf->conn->http_proxy.peer->ipv6 != 0;
+
+  authority = curl_maprintf("%s%s%s:%d",
+                            proxy_ipv6_ip ? "[" : "",
+                            proxy_host,
+                            proxy_ipv6_ip ? "]" : "",
+                            cf->conn->http_proxy.peer->port);
+  if(!authority) {
+    result = CURLE_OUT_OF_MEMORY;
+    goto out;
+  }
+
+  if(dest->ipv6) {
+    /* RFC 9298: colons in IPv6 addresses MUST be percent-encoded
+     * in the URI template (e.g. "2001:db8::1" -> "2001%3Adb8%3A%3A1") */
+    const char *s = dest->hostname;
+    char *d;
+    size_t hlen = strlen(s);
+    encoded_host = curlx_malloc(hlen * 3 + 1);
+    if(!encoded_host) {
+      result = CURLE_OUT_OF_MEMORY;
+      goto out;
+    }
+    d = encoded_host;
+    while(*s) {
+      if(*s == ':') {
+        *d++ = '%';
+        *d++ = '3';
+        *d++ = 'A';
+      }
+      else
+        *d++ = *s;
+      s++;
+    }
+    *d = '\0';
+    path = curl_maprintf("/.well-known/masque/udp/%s/%u/",
+                         encoded_host, (unsigned int)dest->port);
+  }
+  else {
+    path = curl_maprintf("/.well-known/masque/udp/%s/%u/",
+                         dest->hostname, (unsigned int)dest->port);
+  }
+
+  if(!path) {
+    result = CURLE_OUT_OF_MEMORY;
+    goto out;
+  }
+
+  if(ver == PROXY_HTTP_V1) {
+    result = Curl_http_req_make(&req, "GET", sizeof("GET")-1,
+                                proxy_scheme, strlen(proxy_scheme),
+                                authority, strlen(authority),
+                                path, strlen(path));
+    if(result)
+      goto out;
+  }
+  else if(ver == PROXY_HTTP_V2 || ver == PROXY_HTTP_V3) {
+    result = Curl_http_req_make(&req, "CONNECT", sizeof("CONNECT") - 1,
+                                proxy_scheme, strlen(proxy_scheme),
+                                authority, strlen(authority),
+                                path, strlen(path));
+    if(result)
+      goto out;
+  }
+  else {
+    result = CURLE_FAILED_INIT;
+    goto out;
+  }
+
+  /* Setup the proxy-authorization header, if any */
+  result = Curl_http_output_auth(data, cf->conn, req->method, HTTPREQ_GET,
+                                 req->authority, NULL, TRUE);
+  if(result)
+    goto out;
+
+  /* If user is not overriding Host: header, we add for HTTP/1.x */
+  if(ver == PROXY_HTTP_V1 &&
+      !Curl_checkProxyheaders(data, cf->conn, STRCONST("Host"))) {
+    result = Curl_dynhds_cadd(&req->headers, "Host", authority);
+    if(result)
+      goto out;
+  }
+
+  if(data->req.hd_proxy_auth) {
+    result = Curl_dynhds_h1_cadd_line(&req->headers,
+                                      data->req.hd_proxy_auth);
+    if(result)
+      goto out;
+  }
+
+  if(ver == PROXY_HTTP_V1 &&
+    !Curl_checkProxyheaders(data, cf->conn, STRCONST("User-Agent")) &&
+      data->set.str[STRING_USERAGENT] && *data->set.str[STRING_USERAGENT]) {
+    result = Curl_dynhds_cadd(&req->headers, "User-Agent",
+                              data->set.str[STRING_USERAGENT]);
+    if(result)
+      goto out;
+  }
+
+  if(ver == PROXY_HTTP_V1 &&
+      !Curl_checkProxyheaders(data, cf->conn, STRCONST("Proxy-Connection"))) {
+    result = Curl_dynhds_cadd(&req->headers, "Proxy-Connection", "Keep-Alive");
+    if(result)
+      goto out;
+  }
+
+  if(ver == PROXY_HTTP_V1) {
+    result = Curl_dynhds_cadd(&req->headers, "Connection", "Upgrade");
+    if(result)
+      goto out;
+
+    result = Curl_dynhds_cadd(&req->headers, "Upgrade", "connect-udp");
+    if(result)
+      goto out;
+
+    result = Curl_dynhds_cadd(&req->headers, "Capsule-Protocol", "?1");
+    if(result)
+      goto out;
+  }
+  else {
+    result = Curl_dynhds_cadd(&req->headers, ":Protocol", "connect-udp");
+    if(result)
+      goto out;
+
+    if(ver >= PROXY_HTTP_V2) {
+      result = Curl_dynhds_cadd(&req->headers, "Capsule-Protocol", "?1");
+      if(result)
+        goto out;
+    }
+  }
+
+  result = dynhds_add_custom(data, TRUE, httpversion,
+                             TRUE, &req->headers);
 
 out:
   if(result && req) {
@@ -238,37 +429,166 @@ out:
     req = NULL;
   }
   curlx_free(authority);
+  curlx_free(path);
+  curlx_free(encoded_host);
   *preq = req;
   return result;
 }
 
+CURLcode Curl_http_proxy_create_tunnel_request(
+    struct httpreq **preq, struct Curl_cfilter *cf,
+    struct Curl_easy *data, struct Curl_peer *dest,
+    proxy_http_ver ver, bool udp_tunnel)
+{
+  CURLcode result;
+
+  if(udp_tunnel)
+    result = Curl_http_proxy_create_CONNECTUDP(preq, cf, data, dest, ver);
+  else
+    result = Curl_http_proxy_create_CONNECT(preq, cf, data, dest, ver);
+  if(result)
+    return result;
+
+  if(udp_tunnel)
+    infof(data, "Establishing %s proxy UDP tunnel to %s:%s",
+          (ver == PROXY_HTTP_V2) ? "HTTP/2" :
+          (ver == PROXY_HTTP_V3) ? "HTTP/3" : "HTTP",
+          data->state.up.hostname, data->state.up.port);
+  else
+    infof(data, "Establishing %s proxy tunnel to %s",
+          (ver == PROXY_HTTP_V2) ? "HTTP/2" :
+          (ver == PROXY_HTTP_V3) ? "HTTP/3" : "HTTP",
+          (*preq)->authority);
+  return CURLE_OK;
+}
+
+CURLcode Curl_http_proxy_inspect_tunnel_response(
+    struct Curl_cfilter *cf, struct Curl_easy *data,
+    struct http_resp *resp, bool udp_tunnel,
+    proxy_inspect_result *presult)
+{
+  struct dynhds_entry *capsule_protocol = NULL;
+  struct dynhds_entry *auth_reply = NULL;
+  size_t i, header_count;
+  CURLcode result = CURLE_OK;
+
+  DEBUGASSERT(resp);
+
+  header_count = Curl_dynhds_count(&resp->headers);
+  if(udp_tunnel)
+    infof(data, "CONNECT-UDP Response Status %d", resp->status);
+  else
+    infof(data, "CONNECT Response Status %d", resp->status);
+  infof(data, "Response Headers (%zu total):", header_count);
+  for(i = 0; i < header_count; i++) {
+    struct dynhds_entry *entry = Curl_dynhds_getn(&resp->headers, i);
+    if(entry)
+      infof(data, "  %s: %s", entry->name, entry->value);
+  }
+
+  if(resp->status == 401) {
+    auth_reply = Curl_dynhds_cget(&resp->headers, "WWW-Authenticate");
+  }
+  else if(resp->status == 407) {
+    auth_reply = Curl_dynhds_cget(&resp->headers, "Proxy-Authenticate");
+  }
+
+  if(auth_reply) {
+    CURL_TRC_CF(data, cf, "[0] CONNECT%s: fwd auth header '%s'",
+                udp_tunnel ? "-UDP" : "", auth_reply->value);
+    result = Curl_http_input_auth(data, resp->status == 407,
+                                  auth_reply->value);
+    if(result)
+      return result;
+    if(data->req.newurl) {
+      curlx_safefree(data->req.newurl);
+      *presult = PROXY_INSPECT_AUTH_RETRY;
+      return CURLE_OK;
+    }
+  }
+
+  if(udp_tunnel) {
+    if(resp->status / 100 == 2) {
+      capsule_protocol = Curl_dynhds_cget(&resp->headers,
+                                           "capsule-protocol");
+      if(capsule_protocol) {
+        if(strncmp(capsule_protocol->value, "?1", 2) == 0 &&
+           !capsule_protocol->value[2]) {
+          infof(data, "CONNECT-UDP tunnel established, response %d",
+                resp->status);
+          *presult = PROXY_INSPECT_OK;
+          return CURLE_OK;
+        }
+        failf(data, "Failed to establish CONNECT-UDP tunnel, response %d, "
+              "unsupported capsule-protocol value '%s'",
+              resp->status, capsule_protocol->value);
+        *presult = PROXY_INSPECT_FAILED;
+        return CURLE_COULDNT_CONNECT;
+      }
+      else {
+        /* NOTE proxies may not set capsule protocol in the headers */
+        infof(data, "CONNECT-UDP tunnel established, response %d "
+                    "but no capsule-protocol header found", resp->status);
+        *presult = PROXY_INSPECT_OK;
+        return CURLE_OK;
+      }
+    }
+    else {
+      failf(data, "Failed to establish CONNECT-UDP tunnel, "
+                  "response %d", resp->status);
+      *presult = PROXY_INSPECT_FAILED;
+      return CURLE_COULDNT_CONNECT;
+    }
+  }
+
+  if(resp->status / 100 == 2) {
+    infof(data, "CONNECT tunnel established, response %d", resp->status);
+    *presult = PROXY_INSPECT_OK;
+    return CURLE_OK;
+  }
+
+  *presult = PROXY_INSPECT_FAILED;
+  return CURLE_COULDNT_CONNECT;
+}
+
 static CURLcode http_proxy_cf_connect(struct Curl_cfilter *cf,
                                       struct Curl_easy *data,
                                       bool *done)
 {
   struct cf_proxy_ctx *ctx = cf->ctx;
   CURLcode result;
+  const char *tunnel_type;  /* Determine tunnel type once and reuse */
+
+  tunnel_type = ctx->udp_tunnel ? "CONNECT-UDP" : "CONNECT";
 
   if(cf->connected) {
     *done = TRUE;
     return CURLE_OK;
   }
 
-  CURL_TRC_CF(data, cf, "connect");
+  CURL_TRC_CF(data, cf, "%s", tunnel_type);
 connect_sub:
-  result = cf->next->cft->do_connect(cf->next, data, done);
-  if(result || !*done)
-    return result;
+  /* in case of h3_proxy, cf->next will be NULL initially */
+  if(cf->next) {
+    result = cf->next->cft->do_connect(cf->next, data, done);
+    if(result || !*done)
+      return result;
+  }
 
   *done = FALSE;
   if(!ctx->sub_filter_installed) {
-    const char *alpn = Curl_conn_cf_get_alpn_negotiated(cf->next, data);
+    const char *alpn = NULL;
+
+    /* in case of h3_proxy, cf->next will be NULL initially */
+    if(cf->next) {
+      alpn = Curl_conn_cf_get_alpn_negotiated(cf->next, data);
+    }
 
     if(alpn)
-      infof(data, "CONNECT: '%s' negotiated", alpn);
+      infof(data, "%s: '%s' negotiated", tunnel_type, alpn);
     else if(!alpn) {
       /* No ALPN, proxytype rules. Fake ALPN */
-      infof(data, "CONNECT: no ALPN negotiated");
+      infof(data, "%s: no ALPN negotiated", tunnel_type);
       switch(ctx->proxytype) {
       case CURLPROXY_HTTP_1_0:
         alpn = "http/1.0";
@@ -276,6 +596,9 @@ connect_sub:
       case CURLPROXY_HTTPS2:
         alpn = "h2";
         break;
+      case CURLPROXY_HTTPS3:
+        alpn = "h3";
+        break;
       default:
         alpn = "http/1.1";
         break;
@@ -284,7 +607,8 @@ connect_sub:
 
     if(!strcmp(alpn, "http/1.0")) {
       CURL_TRC_CF(data, cf, "installing subfilter for HTTP/1.0");
-      result = Curl_cf_h1_proxy_insert_after(cf, data, ctx->dest, 10);
+      result = Curl_cf_h1_proxy_insert_after(cf, data, ctx->dest, 10,
+                                              (bool)ctx->udp_tunnel);
       if(result)
         goto out;
     }
@@ -292,20 +616,32 @@ connect_sub:
       int httpversion = (ctx->proxytype == CURLPROXY_HTTP_1_0) ? 10 : 11;
       CURL_TRC_CF(data, cf, "installing subfilter for HTTP/1.%d",
                   httpversion % 10);
-      result = Curl_cf_h1_proxy_insert_after(cf, data, ctx->dest, httpversion);
+      result = Curl_cf_h1_proxy_insert_after(cf, data, ctx->dest, httpversion,
+                                              (bool)ctx->udp_tunnel);
       if(result)
         goto out;
     }
 #ifdef USE_NGHTTP2
     else if(!strcmp(alpn, "h2")) {
       CURL_TRC_CF(data, cf, "installing subfilter for HTTP/2");
-      result = Curl_cf_h2_proxy_insert_after(cf, data, ctx->dest);
+      result = Curl_cf_h2_proxy_insert_after(cf, data, ctx->dest,
+                                              (bool)ctx->udp_tunnel);
+      if(result)
+        goto out;
+    }
+#endif /* USE_NGHTTP2 */
+#if defined(USE_PROXY_HTTP3) && defined(USE_NGHTTP3) && \
+    defined(USE_NGTCP2) && defined(USE_OPENSSL)
+    else if(!strcmp(alpn, "h3")) {
+      CURL_TRC_CF(data, cf, "installing subfilter for HTTP/3");
+      result = Curl_cf_h3_proxy_insert_after(cf, data, ctx->dest,
+                                             (bool)ctx->udp_tunnel);
       if(result)
         goto out;
     }
-#endif
+#endif /* USE_PROXY_HTTP3 && USE_NGHTTP3 && USE_NGTCP2 && USE_OPENSSL */
     else {
-      failf(data, "CONNECT: negotiated ALPN '%s' not supported", alpn);
+      failf(data, "%s: negotiated ALPN '%s' not supported", tunnel_type, alpn);
       result = CURLE_COULDNT_CONNECT;
       goto out;
     }
@@ -321,6 +657,19 @@ connect_sub:
      * This means the protocol tunnel is established, we are done.
      */
     DEBUGASSERT(ctx->sub_filter_installed);
+    if(ctx->udp_tunnel) {
+#ifdef USE_PROXY_HTTP3
+      /* Insert capsule filter between us and the protocol sub-filter.
+       * This handles encap/decap of UDP datagrams in capsule format. */
+      result = Curl_cf_capsule_insert_after(cf, data);
+      if(result)
+        goto out;
+      CURL_TRC_CF(data, cf, "installed capsule filter for UDP tunnel");
+#else
+      result = CURLE_NOT_BUILT_IN;
+      goto out;
+#endif /* USE_PROXY_HTTP3 */
+    }
     result = CURLE_OK;
   }
 
@@ -404,7 +753,8 @@ struct Curl_cftype Curl_cft_http_proxy = {
 CURLcode Curl_cf_http_proxy_insert_after(struct Curl_cfilter *cf_at,
                                          struct Curl_easy *data,
                                          struct Curl_peer *dest,
-                                         uint8_t proxytype)
+                                         uint8_t proxytype,
+                                         bool udp_tunnel)
 {
   struct Curl_cfilter *cf;
   struct cf_proxy_ctx *ctx = NULL;
@@ -421,6 +771,7 @@ CURLcode Curl_cf_http_proxy_insert_after(struct Curl_cfilter *cf_at,
   }
   Curl_peer_link(&ctx->dest, dest);
   ctx->proxytype = proxytype;
+  ctx->udp_tunnel = udp_tunnel;
 
   result = Curl_cf_create(&cf, &Curl_cft_http_proxy, ctx);
   if(result)
index c122aa6dd87fb3c80aa8a0ebec2fa25ac4598968..b0becedf03f813201a06d9ab502c8f1b4b0dd25d 100644 (file)
 enum Curl_proxy_use {
   HEADER_SERVER,  /* direct to server */
   HEADER_PROXY,   /* regular request to proxy */
-  HEADER_CONNECT  /* sending CONNECT to a proxy */
+  HEADER_CONNECT, /* sending CONNECT to a proxy */
+  HEADER_CONNECT_UDP /* sending CONNECT-UDP to a proxy */
 };
 
+/* HTTP version for proxy tunnel request creation */
+typedef enum {
+  PROXY_HTTP_V1 = 1,
+  PROXY_HTTP_V2 = 2,
+  PROXY_HTTP_V3 = 3
+} proxy_http_ver;
+
+/* Result from inspecting a proxy tunnel response */
+typedef enum {
+  PROXY_INSPECT_OK,         /* Tunnel established */
+  PROXY_INSPECT_FAILED,     /* Tunnel failed */
+  PROXY_INSPECT_AUTH_RETRY  /* Retry with auth */
+} proxy_inspect_result;
+
 CURLcode Curl_http_proxy_create_CONNECT(struct httpreq **preq,
                                         struct Curl_cfilter *cf,
                                         struct Curl_easy *data,
                                         struct Curl_peer *dest,
-                                        int httpversion);
+                                        proxy_http_ver ver);
+CURLcode Curl_http_proxy_create_CONNECTUDP(struct httpreq **preq,
+                                        struct Curl_cfilter *cf,
+                                        struct Curl_easy *data,
+                                        struct Curl_peer *dest,
+                                        proxy_http_ver ver);
+
+/* Create CONNECT or CONNECT-UDP request */
+CURLcode Curl_http_proxy_create_tunnel_request(
+    struct httpreq **preq, struct Curl_cfilter *cf,
+    struct Curl_easy *data, struct Curl_peer *dest,
+    proxy_http_ver ver, bool udp_tunnel);
+
+/* Inspect tunnel response for H2/H3 proxy (capsule-protocol, auth) */
+struct http_resp;
+CURLcode Curl_http_proxy_inspect_tunnel_response(
+    struct Curl_cfilter *cf, struct Curl_easy *data,
+    struct http_resp *resp, bool udp_tunnel,
+    proxy_inspect_result *presult);
 
 /* Default proxy timeout in milliseconds */
 #define PROXY_TIMEOUT (3600 * 1000)
@@ -47,13 +80,17 @@ CURLcode Curl_http_proxy_create_CONNECT(struct httpreq **preq,
 CURLcode Curl_cf_http_proxy_insert_after(struct Curl_cfilter *cf_at,
                                          struct Curl_easy *data,
                                          struct Curl_peer *dest,
-                                         uint8_t proxytype);
+                                         uint8_t proxytype,
+                                         bool udp_tunnel);
 
 extern struct Curl_cftype Curl_cft_http_proxy;
 
 #endif /* !CURL_DISABLE_PROXY && !CURL_DISABLE_HTTP */
 
 #define IS_HTTPS_PROXY(t) (((t) == CURLPROXY_HTTPS) ||  \
-                           ((t) == CURLPROXY_HTTPS2))
+                           ((t) == CURLPROXY_HTTPS2) || \
+                           ((t) == CURLPROXY_HTTPS3))
+
+#define IS_QUIC_PROXY(t) ((t) == CURLPROXY_HTTPS3)
 
 #endif /* HEADER_CURL_HTTP_PROXY_H */
index 43e5aef0f04035c2b9b2034b489ea25ac1dc9c53..5dd3aad372fafee0b411da6cd678dd08d054892a 100644 (file)
@@ -536,6 +536,37 @@ out:
 #define UNIX_SOCKET_PREFIX "localhost"
 #endif
 
+CURLcode Curl_scheme_to_proxytype(struct Curl_easy *data,
+                                  const char *scheme,
+                                  uint8_t *proxytype, const char *url)
+{
+  if(!scheme)
+    return CURLE_OK;
+
+  if(curl_strequal("https", scheme)) {
+    if(*proxytype != CURLPROXY_HTTPS2 && *proxytype != CURLPROXY_HTTPS3)
+      *proxytype = CURLPROXY_HTTPS;
+  }
+  else if(curl_strequal("socks5h", scheme))
+    *proxytype = CURLPROXY_SOCKS5_HOSTNAME;
+  else if(curl_strequal("socks5", scheme))
+    *proxytype = CURLPROXY_SOCKS5;
+  else if(curl_strequal("socks4a", scheme))
+    *proxytype = CURLPROXY_SOCKS4A;
+  else if(curl_strequal("socks4", scheme) || curl_strequal("socks", scheme))
+    *proxytype = CURLPROXY_SOCKS4;
+  else if(curl_strequal("http", scheme)) {
+    if(*proxytype != CURLPROXY_HTTP_1_0)
+      *proxytype = CURLPROXY_HTTP;
+  }
+  else {
+    /* Any other xxx:// reject! */
+    failf(data, "Unsupported proxy scheme for \'%s\'", url);
+    return CURLE_COULDNT_CONNECT;
+  }
+  return CURLE_OK;
+}
+
 CURLcode Curl_peer_from_proxy_url(CURLU *uh,
                                   struct Curl_easy *data,
                                   const char *url,
@@ -570,6 +601,7 @@ CURLcode Curl_peer_from_proxy_url(CURLU *uh,
       break;
     case CURLPROXY_HTTPS:
     case CURLPROXY_HTTPS2:
+    case CURLPROXY_HTTPS3:
       pp.scheme = &Curl_scheme_https;
       break;
     case CURLPROXY_SOCKS4:
@@ -592,29 +624,9 @@ CURLcode Curl_peer_from_proxy_url(CURLU *uh,
   }
   else {
     pp.scheme = Curl_get_scheme(scheme);
-    if(pp.scheme == &Curl_scheme_https) {
-      proxytype = (proxytype != CURLPROXY_HTTPS2) ?
-        CURLPROXY_HTTPS : CURLPROXY_HTTPS2;
-    }
-    else if(pp.scheme == &Curl_scheme_socks5h)
-      proxytype = CURLPROXY_SOCKS5_HOSTNAME;
-    else if(pp.scheme == &Curl_scheme_socks5)
-      proxytype = CURLPROXY_SOCKS5;
-    else if(pp.scheme == &Curl_scheme_socks4a)
-      proxytype = CURLPROXY_SOCKS4A;
-    else if((pp.scheme == &Curl_scheme_socks4) ||
-            (pp.scheme == &Curl_scheme_socks))
-      proxytype = CURLPROXY_SOCKS4;
-    else if(pp.scheme == &Curl_scheme_http) {
-      proxytype = (uint8_t)((proxytype != CURLPROXY_HTTP_1_0) ?
-        CURLPROXY_HTTP : CURLPROXY_HTTP_1_0);
-    }
-    else {
-      /* Any other xxx:// reject! */
-      failf(data, "Unsupported proxy scheme for \'%s\'", url);
-      result = CURLE_COULDNT_CONNECT;
+    result = Curl_scheme_to_proxytype(data, scheme, &proxytype, url);
+    if(result)
       goto out;
-    }
   }
   DEBUGASSERT(pp.scheme);
 
index daa01db8ff6537f56a8810afb9c633b6fac2c041..7946735a23e5593642044834052ef467b708b9c3 100644 (file)
@@ -94,6 +94,11 @@ CURLcode Curl_peer_from_connect_to(struct Curl_easy *data,
 
 #ifndef CURL_DISABLE_PROXY
 
+CURLcode Curl_scheme_to_proxytype(struct Curl_easy *data,
+                                  const char *scheme,
+                                  uint8_t *proxytype,
+                                  const char *url);
+
 CURLcode Curl_peer_from_proxy_url(CURLU *uh,
                                   struct Curl_easy *data,
                                   const char *url,
index 2e08a310ebdcfbec59ca223101f32a628eee9ed9..e67a3c8beb64ddb8dc642c933fa2a154472c5ad2 100644 (file)
@@ -1042,8 +1042,12 @@ static CURLcode setopt_long_proxy(struct Curl_easy *data, CURLoption option,
   case CURLOPT_PROXYAUTH:
     return httpauth(data, TRUE, (unsigned long)arg);
   case CURLOPT_PROXYTYPE:
-    if((arg < CURLPROXY_HTTP) || (arg > CURLPROXY_SOCKS5_HOSTNAME))
+    if((arg < CURLPROXY_HTTP) || (arg > CURLPROXY_HTTPS3))
       return CURLE_BAD_FUNCTION_ARGUMENT;
+#ifndef USE_PROXY_HTTP3
+    if(arg == CURLPROXY_HTTPS3)
+      return CURLE_NOT_BUILT_IN;
+#endif
     s->proxytype = (unsigned char)arg;
     break;
   case CURLOPT_SOCKS5_AUTH:
index 796d35e2296acda970e65087d8970c405d46bed2..93a5f14f07e489490a7976d1719c1086f50dba3a 100644 (file)
--- a/lib/url.c
+++ b/lib/url.c
@@ -99,6 +99,7 @@
 #include "headers.h"
 #include "curlx/strerr.h"
 #include "curlx/strparse.h"
+#include "peer.h"
 
 /* Now for the protocols */
 #include "ftp.h"
@@ -1316,7 +1317,12 @@ static struct connectdata *allocate_conn(struct Curl_easy *data)
 #endif
   conn->ip_version = data->set.ipver;
   conn->bits.connect_only = (bool)data->set.connect_only;
-  conn->transport_wanted = TRNSPRT_TCP; /* most of them are TCP streams */
+#ifndef CURL_DISABLE_PROXY
+  if(conn->http_proxy.proxytype == CURLPROXY_HTTPS3)
+    conn->transport_wanted = TRNSPRT_QUIC;
+  else
+#endif
+    conn->transport_wanted = TRNSPRT_TCP; /* most of them are TCP streams */
 
   /* Store the local bind parameters that will be used for this connection */
   if(data->set.str[STRING_DEVICE]) {
@@ -1793,6 +1799,7 @@ static CURLcode parse_proxy(struct Curl_easy *data,
 {
   char *proxyuser = NULL;
   char *proxypasswd = NULL;
+  char *scheme = NULL;
   CURLcode result = CURLE_OK;
   /* Set the start proxy type for url scheme guessing */
   uint8_t proxytype = for_pre_proxy ? CURLPROXY_SOCKS4 : data->set.proxytype;
@@ -1807,7 +1814,21 @@ static CURLcode parse_proxy(struct Curl_easy *data,
      these made up ones for proxies. Guess scheme for URLs without it. */
   uc = curl_url_set(uhp, CURLUPART_URL, proxy,
                     CURLU_NON_SUPPORT_SCHEME | CURLU_GUESS_SCHEME);
-  if(uc) {
+  if(!uc) {
+    /* parsed okay as a URL - only update proxytype when scheme was explicit */
+    uc = curl_url_get(uhp, CURLUPART_SCHEME, &scheme, CURLU_NO_GUESS_SCHEME);
+    if(!uc) {
+      result = Curl_scheme_to_proxytype(data, scheme, &proxytype, proxy);
+      if(result)
+        goto error;
+    }
+    else if(uc != CURLUE_NO_SCHEME) {
+      result = CURLE_OUT_OF_MEMORY;
+      goto error;
+    }
+    /* else: no explicit scheme, keep the configured proxytype */
+  }
+  else {
     failf(data, "Unsupported proxy syntax in \'%s\': %s", proxy,
           curl_url_strerror(uc));
     result = CURLE_COULDNT_RESOLVE_PROXY;
@@ -1824,6 +1845,7 @@ static CURLcode parse_proxy(struct Curl_easy *data,
     case CURLPROXY_HTTP_1_0:
     case CURLPROXY_HTTPS:
     case CURLPROXY_HTTPS2:
+    case CURLPROXY_HTTPS3:
       if(for_pre_proxy) {
         failf(data, "Unsupported pre-proxy type for \'%s\'", proxy);
         result = CURLE_COULDNT_RESOLVE_PROXY;
@@ -1878,6 +1900,7 @@ static CURLcode parse_proxy(struct Curl_easy *data,
   proxyinfo->proxytype = proxytype;
 
 error:
+  curlx_free(scheme);
   curlx_free(proxyuser);
   curlx_free(proxypasswd);
   curl_url_cleanup(uhp);
index b3b0a46abbb1d2039392188848597e1ccf9015e2..d5870333abf196dedf98c300a8e50590c74dd484 100644 (file)
@@ -491,6 +491,9 @@ static const struct feat features_table[] = {
 #ifdef USE_NTLM
   FEATURE("NTLM",        NULL,                CURL_VERSION_NTLM),
 #endif
+#ifdef USE_PROXY_HTTP3
+  FEATURE("PROXY-HTTP3", NULL,                0),
+#endif
 #ifdef USE_LIBPSL
   FEATURE("PSL",         NULL,                CURL_VERSION_PSL),
 #endif
index fb7fd618893de63ef62bed59fd4574c732b3357d..6cafda2da05c40a3c983a9363d3077aa2a1a4a53 100644 (file)
@@ -72,6 +72,7 @@
 
 #define QUIC_MAX_STREAMS       (256 * 1024)
 #define QUIC_HANDSHAKE_TIMEOUT (10 * NGTCP2_SECONDS)
+#define QUIC_TUNNEL_INBUF_SIZE (64 * 1024)
 
 /* We announce a small window size in transport param to the server,
  * and grow that immediately to max when no rate limit is in place.
@@ -95,6 +96,7 @@
 #define H3_STREAM_SEND_BUFFER_MAX      (10 * 1024 * 1024)
 #define H3_STREAM_SEND_CHUNKS \
   (H3_STREAM_SEND_BUFFER_MAX / H3_STREAM_CHUNK_SIZE)
+#define QUIC_TUNNEL_INGRESS_PKT_LIMIT 1000
 
 /*
  * Store ngtcp2 version info in this buffer.
@@ -139,6 +141,8 @@ struct cf_ngtcp2_ctx {
                                         is accepted by peer */
   CURLcode tls_vrfy_result;          /* result of TLS peer verification */
   int qlogfd;
+  unsigned char *tunnel_inbuf;       /* ingress buffer for tunneled packets */
+  size_t tunnel_inbuf_len;
   BIT(initialized);
   BIT(tls_handshake_complete);       /* TLS handshake is done */
   BIT(use_earlydata);                /* Using 0RTT data */
@@ -156,6 +160,8 @@ static void cf_ngtcp2_ctx_init(struct cf_ngtcp2_ctx *ctx)
 {
   DEBUGASSERT(!ctx->initialized);
   ctx->qlogfd = -1;
+  ctx->tunnel_inbuf = NULL;
+  ctx->tunnel_inbuf_len = 0;
   ctx->version = NGTCP2_PROTO_VER_MAX;
   Curl_bufcp_init(&ctx->stream_bufcp, H3_STREAM_CHUNK_SIZE,
                   H3_STREAM_POOL_SPARES);
@@ -173,6 +179,8 @@ static void cf_ngtcp2_ctx_free(struct cf_ngtcp2_ctx *ctx)
     curlx_dyn_free(&ctx->scratch);
     Curl_uint32_hash_destroy(&ctx->streams);
     Curl_ssl_peer_cleanup(&ctx->peer);
+    curlx_safefree(ctx->tunnel_inbuf);
+    ctx->tunnel_inbuf_len = 0;
   }
   curlx_free(ctx);
 }
@@ -493,7 +501,7 @@ static void quic_settings(struct cf_ngtcp2_ctx *ctx,
 static CURLcode init_ngh3_conn(struct Curl_cfilter *cf,
                                struct Curl_easy *data);
 
-static int cf_ngtcp2_handshake_completed(ngtcp2_conn *tconn, void *user_data)
+static int cb_ngtcp2_handshake_completed(ngtcp2_conn *tconn, void *user_data)
 {
   struct Curl_cfilter *cf = user_data;
   struct cf_ngtcp2_ctx *ctx = cf ? cf->ctx : NULL;
@@ -863,7 +871,7 @@ static ngtcp2_callbacks ng_callbacks = {
   ngtcp2_crypto_client_initial_cb,
   NULL, /* recv_client_initial */
   ngtcp2_crypto_recv_crypto_data_cb,
-  cf_ngtcp2_handshake_completed,
+  cb_ngtcp2_handshake_completed,
   NULL, /* recv_version_negotiation */
   ngtcp2_crypto_encrypt_cb,
   ngtcp2_crypto_decrypt_cb,
@@ -982,6 +990,11 @@ static CURLcode cf_ngtcp2_adjust_pollset(struct Curl_cfilter *cf,
   if(!ctx->qconn)
     return CURLE_OK;
 
+  if(ctx->q.sockfd == CURL_SOCKET_BAD) {
+    /* Tunneled QUIC, no direct socket - delegate to next filter */
+    return cf->next->cft->adjust_pollset(cf->next, data, ps);
+  }
+
   Curl_pollset_check(data, ps, ctx->q.sockfd, &want_recv, &want_send);
   if(!want_send && !Curl_bufq_is_empty(&ctx->q.sendbuf))
     want_send = TRUE;
@@ -1904,8 +1917,72 @@ static CURLcode cf_progress_ingress(struct Curl_cfilter *cf,
 
   rctx.pktx = pktx;
   rctx.pkt_count = 0;
-  return vquic_recv_packets(cf, data, &ctx->q, 1000,
+
+  if(ctx->q.sockfd != CURL_SOCKET_BAD) {
+    /* Direct UDP socket (via happy eyeballs) */
+    return vquic_recv_packets(cf, data, &ctx->q, 1000,
                             cf_ngtcp2_recv_pkts, &rctx);
+  }
+  else {
+    /* Tunneled QUIC (CONNECT-UDP through proxy) */
+    unsigned char *buf;
+    size_t max_udp_payload = QUIC_TUNNEL_INBUF_SIZE;
+    size_t pkt_limit = QUIC_TUNNEL_INGRESS_PKT_LIMIT;
+    size_t nread;
+    struct sockaddr_storage remote_addr;
+    socklen_t remote_addrlen;
+
+    if(ctx->qconn) {
+      size_t max_path_payload;
+      max_path_payload =
+        ngtcp2_conn_get_path_max_tx_udp_payload_size(ctx->qconn);
+      if(max_path_payload > max_udp_payload)
+        max_udp_payload = max_path_payload;
+    }
+
+    if(ctx->tunnel_inbuf_len < max_udp_payload) {
+      unsigned char *newbuf =
+        (unsigned char *)curlx_realloc(ctx->tunnel_inbuf, max_udp_payload);
+      if(!newbuf)
+        return CURLE_OUT_OF_MEMORY;
+      ctx->tunnel_inbuf = newbuf;
+      ctx->tunnel_inbuf_len = max_udp_payload;
+    }
+    buf = ctx->tunnel_inbuf;
+
+    while(pkt_limit--) {
+      result = Curl_conn_cf_recv(cf->next, data, (char *)buf,
+                                 ctx->tunnel_inbuf_len, &nread);
+      if(result == CURLE_AGAIN) {
+        /* no more data available at the moment */
+        return CURLE_OK;
+      }
+      if(result) {
+        CURL_TRC_CF(data, cf, "ingress, recv from tunnel failed: %d",
+                    result);
+        return result;
+      }
+      if(nread == 0) {
+        /* tunnel closed */
+        return CURLE_OK;
+      }
+
+      memcpy(&remote_addr, ctx->connected_path.remote.addr,
+             ctx->connected_path.remote.addrlen);
+      remote_addrlen = (socklen_t)ctx->connected_path.remote.addrlen;
+      result = cf_ngtcp2_recv_pkts(buf, nread, nread, &remote_addr,
+                                   remote_addrlen, 0, &rctx);
+      if(result)
+        return result;
+
+      if(!ctx->q.got_first_byte) {
+        ctx->q.got_first_byte = TRUE;
+        ctx->q.first_byte_at = ctx->q.last_op;
+      }
+      ctx->q.last_io = ctx->q.last_op;
+    }
+    return CURLE_OK;
+  }
 }
 
 /**
@@ -2189,6 +2266,7 @@ static void cf_ngtcp2_ctx_close(struct cf_ngtcp2_ctx *ctx)
   }
   ctx->qlogfd = -1;
   Curl_vquic_tls_cleanup(&ctx->tls);
+  Curl_ssl_peer_cleanup(&ctx->peer);
   vquic_ctx_free(&ctx->q);
   if(ctx->h3conn) {
     nghttp3_conn_del(ctx->h3conn);
@@ -2220,6 +2298,12 @@ static CURLcode cf_ngtcp2_shutdown(struct Curl_cfilter *cf,
     return CURLE_OK;
   }
 
+  if(!cf->next) {
+    Curl_bufq_reset(&ctx->q.sendbuf);
+    *done = TRUE;
+    return CURLE_OK;
+  }
+
   CF_DATA_SAVE(save, cf, data);
   *done = FALSE;
   pktx_init(&pktx, cf, data);
@@ -2648,30 +2732,81 @@ static CURLcode cf_connect_start(struct Curl_cfilter *cf,
   if(result)
     return result;
 
-  if(Curl_cf_socket_peek(cf->next, data, &ctx->q.sockfd, &sockaddr, NULL))
-    return CURLE_QUIC_CONNECT_ERROR;
-  ctx->q.local_addrlen = sizeof(ctx->q.local_addr);
-  rv = getsockname(ctx->q.sockfd, (struct sockaddr *)&ctx->q.local_addr,
-                   &ctx->q.local_addrlen);
-  if(rv == -1)
-    return CURLE_QUIC_CONNECT_ERROR;
+  /* Query socket and remote address from sub-chain */
+  if(Curl_cf_socket_peek(cf->next, data, &ctx->q.sockfd, &sockaddr, NULL)) {
+    /* No direct socket - must be tunneled QUIC (CONNECT-UDP through proxy) */
+    ctx->q.sockfd = CURL_SOCKET_BAD;
+  }
+
+  if(ctx->q.sockfd != CURL_SOCKET_BAD) {
+    /* Direct UDP socket - get local address for ngtcp2 */
+    ctx->q.local_addrlen = sizeof(ctx->q.local_addr);
+    rv = getsockname(ctx->q.sockfd, (struct sockaddr *)&ctx->q.local_addr,
+                    &ctx->q.local_addrlen);
+    if(rv == -1)
+      return CURLE_QUIC_CONNECT_ERROR;
+
+    ngtcp2_addr_init(&ctx->connected_path.local,
+                    (struct sockaddr *)&ctx->q.local_addr,
+                    ctx->q.local_addrlen);
+    ngtcp2_addr_init(&ctx->connected_path.remote,
+                    &sockaddr->curl_sa_addr, (socklen_t)sockaddr->addrlen);
+
+    rc = ngtcp2_conn_client_new(&ctx->qconn, &ctx->dcid, &ctx->scid,
+                                &ctx->connected_path,
+                                NGTCP2_PROTO_VER_V1, &ng_callbacks,
+                                &ctx->settings, &ctx->transport_params,
+                                Curl_ngtcp2_mem(), cf);
+    if(rc)
+      return CURLE_QUIC_CONNECT_ERROR;
+
+    ctx->conn_ref.get_conn = get_conn;
+    ctx->conn_ref.user_data = cf;
+  }
+  else {
+    /* Tunneled QUIC (e.g. CONNECT-UDP): get remote address
+       from the connected filter below */
+    const struct Curl_sockaddr_ex *remote = NULL;
+    if(cf->next->cft->query(cf->next, data, CF_QUERY_REMOTE_ADDR, NULL,
+                            CURL_UNCONST(&remote)))
+      return CURLE_QUIC_CONNECT_ERROR;
+    if(!remote)
+      return CURLE_QUIC_CONNECT_ERROR;
+
+    memset(&ctx->q.local_addr, 0, sizeof(ctx->q.local_addr));
+    switch(remote->family) {
+    case AF_INET:
+      ((struct sockaddr_in *)&ctx->q.local_addr)->sin_family = AF_INET;
+      ctx->q.local_addrlen = sizeof(struct sockaddr_in);
+      break;
+#ifdef USE_IPV6
+    case AF_INET6:
+      ((struct sockaddr_in6 *)&ctx->q.local_addr)->sin6_family = AF_INET6;
+      ctx->q.local_addrlen = sizeof(struct sockaddr_in6);
+      break;
+#endif
+    default:
+      return CURLE_QUIC_CONNECT_ERROR;
+    }
 
-  ngtcp2_addr_init(&ctx->connected_path.local,
-                   (struct sockaddr *)&ctx->q.local_addr,
-                   ctx->q.local_addrlen);
-  ngtcp2_addr_init(&ctx->connected_path.remote,
-                   &sockaddr->curl_sa_addr, (socklen_t)sockaddr->addrlen);
-
-  rc = ngtcp2_conn_client_new(&ctx->qconn, &ctx->dcid, &ctx->scid,
-                              &ctx->connected_path,
-                              NGTCP2_PROTO_VER_V1, &ng_callbacks,
-                              &ctx->settings, &ctx->transport_params,
-                              Curl_ngtcp2_mem(), cf);
-  if(rc)
-    return CURLE_QUIC_CONNECT_ERROR;
+    ngtcp2_addr_init(&ctx->connected_path.local,
+                     (struct sockaddr *)&ctx->q.local_addr,
+                     ctx->q.local_addrlen);
+    ngtcp2_addr_init(&ctx->connected_path.remote,
+                     &remote->curl_sa_addr,
+                     (socklen_t)remote->addrlen);
 
-  ctx->conn_ref.get_conn = get_conn;
-  ctx->conn_ref.user_data = cf;
+    rc = ngtcp2_conn_client_new(&ctx->qconn, &ctx->dcid, &ctx->scid,
+                                &ctx->connected_path,
+                                NGTCP2_PROTO_VER_V1, &ng_callbacks,
+                                &ctx->settings, &ctx->transport_params,
+                                Curl_ngtcp2_mem(), cf);
+    if(rc)
+      return CURLE_QUIC_CONNECT_ERROR;
+
+    ctx->conn_ref.get_conn = get_conn;
+    ctx->conn_ref.user_data = cf;
+  }
 
   result = Curl_vquic_tls_init(&ctx->tls, cf, data, &ctx->peer, &ALPN_SPEC_H3,
                                cf_ngtcp2_tls_ctx_setup, &ctx->tls,
@@ -2720,8 +2855,8 @@ static CURLcode cf_ngtcp2_connect(struct Curl_cfilter *cf,
     return CURLE_OK;
   }
 
-  /* Connect the UDP filter first */
-  if(!cf->next->connected) {
+  /* Connect the sub-chain */
+  if(cf->next && !cf->next->connected) {
     result = Curl_conn_cf_connect(cf->next, data, done);
     if(result || !*done)
       return result;
@@ -2803,11 +2938,14 @@ out:
 
 #ifdef CURLVERBOSE
   if(result) {
-    struct ip_quadruple ip;
+    if(ctx->q.sockfd != CURL_SOCKET_BAD) {
+      /* Direct UDP socket - get IP info for error reporting */
+      struct ip_quadruple ip;
 
-    if(!Curl_cf_socket_peek(cf->next, data, NULL, NULL, &ip))
-      infof(data, "QUIC connect to %s port %u failed: %s",
-            ip.remote_ip, ip.remote_port, curl_easy_strerror(result));
+      if(!Curl_cf_socket_peek(cf->next, data, NULL, NULL, &ip))
+        infof(data, "QUIC connect to %s port %u failed: %s",
+              ip.remote_ip, ip.remote_port, curl_easy_strerror(result));
+    }
   }
 #endif
   if(!result && ctx->qconn) {
@@ -3003,4 +3141,33 @@ out:
   return result;
 }
 
+CURLcode Curl_cf_ngtcp2_insert_after(struct Curl_cfilter *cf_at)
+{
+  struct cf_ngtcp2_ctx *ctx = NULL;
+  struct Curl_cfilter *cf = NULL;
+  CURLcode result;
+
+  ctx = curlx_calloc(1, sizeof(*ctx));
+  if(!ctx) {
+    result = CURLE_OUT_OF_MEMORY;
+    goto out;
+  }
+  cf_ngtcp2_ctx_init(ctx);
+
+  result = Curl_cf_create(&cf, &Curl_cft_http3, ctx);
+  if(result)
+    goto out;
+  Curl_conn_cf_insert_after(cf_at, cf);
+  cf->conn = cf_at->conn;
+out:
+  if(result) {
+    curlx_safefree(cf);
+    cf_ngtcp2_ctx_free(ctx);
+  }
+  return result;
+}
+
 #endif
+
+/* Do not leak this filter's call_data accessor in unity builds. */
+#undef CF_CTX_CALL_DATA
index 185272ace030a5190e42bdc17da1e7ae607b5412..d69ae08eaec55a52e0743c4373b02b69dad62cb9 100644 (file)
@@ -54,6 +54,8 @@ CURLcode Curl_cf_ngtcp2_create(struct Curl_cfilter **pcf,
                                struct Curl_easy *data,
                                struct connectdata *conn,
                                struct Curl_sockaddr_ex *addr);
+
+CURLcode Curl_cf_ngtcp2_insert_after(struct Curl_cfilter *cf_at);
 #endif
 
 #endif /* HEADER_CURL_VQUIC_CURL_NGTCP2_H */
index 73f664a65344bb11f0cdabd10703f4f145cf35e7..43a16958a6ffb5fd0f48a3a068a7592afaecf1e6 100644 (file)
@@ -156,6 +156,7 @@ static void cf_quiche_ctx_close(struct cf_quiche_ctx *ctx)
     quiche_config_free(ctx->cfg);
     ctx->cfg = NULL;
   }
+  Curl_ssl_peer_cleanup(&ctx->peer);
 }
 
 static CURLcode cf_flush_egress(struct Curl_cfilter *cf,
index ad4c713fa9b166a2dd44ddf98fa80e9ff1ca4cbf..00366b7d309a8096f67766f87a472ecdb97c42cb 100644 (file)
@@ -72,6 +72,8 @@ CURLcode Curl_vquic_tls_init(struct curl_tls_ctx *ctx,
   return CURLE_FAILED_INIT;
 #endif
   (void)session_reuse_cb;
+  if(peer->dest)
+    Curl_ssl_peer_cleanup(peer);
   result = Curl_ssl_peer_init(peer, cf, tls_id, TRNSPRT_QUIC);
   if(result)
     return result;
index cf4bc5a65fe8a19014f3ae835ef4ebf9ca01dd78..9ac657c2910b5d2c4f427461305f29e1695ba9d6 100644 (file)
@@ -261,6 +261,44 @@ out:
   return result;
 }
 
+/* Split QUIC payload by datagram (gso) boundaries when sending over a
+ * non-UDP lower filter (for example CONNECT-UDP proxy tunnel). */
+static CURLcode send_packet_no_gso_cf(struct Curl_cfilter *cf,
+                                      struct Curl_easy *data,
+                                      const uint8_t *pkt, size_t pktlen,
+                                      size_t gsolen, size_t *psent)
+{
+  const uint8_t *p, *end = pkt + pktlen;
+  size_t sent, len;
+  CURLcode result = CURLE_OK;
+  VERBOSE(size_t calls = 0);
+
+  *psent = 0;
+
+  /* Send one datagram-sized chunk per call into the lower filter. */
+  for(p = pkt; p < end; p += len) {
+    len = CURLMIN(gsolen, (size_t)(end - p));
+    result = Curl_conn_cf_send(cf->next, data, p, len, FALSE, &sent);
+    /* Report forward progress even if we return CURLE_AGAIN later. */
+    *psent += sent;
+    VERBOSE(++calls);
+    /* Preserve lower-filter errors (including CURLE_AGAIN). */
+    if(result)
+      goto out;
+    if(sent < len) {
+      /* We need whole datagrams here. Partial accept means blocked. */
+      result = CURLE_AGAIN;
+      goto out;
+    }
+  }
+
+out:
+  CURL_TRC_CF(data, cf, "vquic_cf_send(len=%zu, gso=%zu, calls=%zu)"
+              " -> %d, sent=%zu",
+              pktlen, gsolen, calls, result, *psent);
+  return result;
+}
+
 static CURLcode vquic_send_packets(struct Curl_cfilter *cf,
                                    struct Curl_easy *data,
                                    struct cf_quic_ctx *qctx,
@@ -310,7 +348,22 @@ CURLcode vquic_flush(struct Curl_cfilter *cf, struct Curl_easy *data,
         blen = qctx->split_len;
     }
 
-    result = vquic_send_packets(cf, data, qctx, buf, blen, gsolen, &sent);
+    if(qctx->sockfd != CURL_SOCKET_BAD) {
+      /* Direct UDP socket (via happy eyeballs) */
+      result = vquic_send_packets(cf, data, qctx, buf, blen, gsolen, &sent);
+    }
+    else {
+      /* Tunneled QUIC (CONNECT-UDP through proxy) */
+      if(gsolen && (blen > gsolen)) {
+        /* Send one datagram at a time to preserve packet boundaries. */
+        result = send_packet_no_gso_cf(cf, data, buf, blen, gsolen, &sent);
+      }
+      else {
+        /* No GSO aggregate to split, regular lower-filter send is enough. */
+        result = Curl_conn_cf_send(cf->next, data, buf, blen, FALSE, &sent);
+      }
+    }
+
     if(result) {
       if(result == CURLE_AGAIN) {
         Curl_bufq_skip(&qctx->sendbuf, sent);
@@ -699,6 +752,16 @@ CURLcode Curl_qlogdir(struct Curl_easy *data,
   return CURLE_OK;
 }
 
+CURLcode Curl_cf_quic_insert_after(struct Curl_cfilter *cf_at)
+{
+#if defined(USE_NGTCP2) && defined(USE_NGHTTP3)
+  return Curl_cf_ngtcp2_insert_after(cf_at);
+#else
+  (void)cf_at;
+  return CURLE_NOT_BUILT_IN;
+#endif
+}
+
 CURLcode Curl_cf_quic_create(struct Curl_cfilter **pcf,
                              struct Curl_easy *data,
                              struct connectdata *conn,
@@ -737,10 +800,6 @@ CURLcode Curl_conn_may_http3(struct Curl_easy *data,
     failf(data, "HTTP/3 is not supported over a SOCKS proxy");
     return CURLE_URL_MALFORMAT;
   }
-  if(conn->bits.httpproxy && conn->bits.tunnel_proxy) {
-    failf(data, "HTTP/3 is not supported over an HTTP proxy");
-    return CURLE_URL_MALFORMAT;
-  }
 #endif
 
   return CURLE_OK;
index 1f0a1ab5e51b785696fd27090aa8ba21cc34845f..59178acd940572bcdd2c015bd24275fec21ad2a5 100644 (file)
@@ -39,6 +39,8 @@ CURLcode Curl_qlogdir(struct Curl_easy *data,
                       size_t scidlen,
                       int *qlogfdp);
 
+CURLcode Curl_cf_quic_insert_after(struct Curl_cfilter *cf_at);
+
 CURLcode Curl_cf_quic_create(struct Curl_cfilter **pcf,
                              struct Curl_easy *data,
                              struct connectdata *conn,
index b4a0f9684f01e0664c4f590846a29a639b01c65f..fde151590b9374a704d3aad352df3f4eb158dca5 100644 (file)
@@ -3713,8 +3713,11 @@ CURLcode Curl_ossl_ctx_init(struct ossl_ctx *octx,
       return result;
   }
 
-  if(data->set.fdebug && data->set.verbose) {
-    /* the SSL trace callback is only used for verbose logging */
+  if(data->set.fdebug && data->set.verbose &&
+     (peer->transport != TRNSPRT_QUIC)) {
+    /* the SSL trace callback is only used for verbose logging;
+     * QUIC connections use a different TLS record format that
+     * ossl_trace cannot handle */
     SSL_CTX_set_msg_callback(octx->ssl_ctx, ossl_trace);
     SSL_CTX_set_msg_callback_arg(octx->ssl_ctx, cf);
   }
@@ -4007,12 +4010,20 @@ static CURLcode ossl_connect_step1(struct Curl_cfilter *cf,
 {
   struct ssl_connect_data *connssl = cf->ctx;
   struct ossl_ctx *octx = (struct ossl_ctx *)connssl->backend;
+  char tls_id[80];
   BIO *bio;
   CURLcode result;
 
   DEBUGASSERT(ssl_connect_1 == connssl->connecting_state);
   DEBUGASSERT(octx);
 
+  if(!connssl->peer.dest) {
+    Curl_ossl_version(tls_id, sizeof(tls_id));
+    result = Curl_ssl_peer_init(&connssl->peer, cf, tls_id, TRNSPRT_TCP);
+    if(result)
+      return result;
+  }
+
   result = Curl_ossl_ctx_init(octx, cf, data, &connssl->peer,
                               connssl->alpn, NULL, NULL,
                               ossl_new_session_cb, cf,
index e7dbad09c618cfcf56ac65f7daa1c788f56635ae..d640df4f0311cce720734202b3c6c91f81198d02 100644 (file)
@@ -1197,6 +1197,7 @@ void Curl_ssl_peer_cleanup(struct ssl_peer *peer)
   Curl_peer_unlink(&peer->dest);
   curlx_safefree(peer->sni);
   curlx_safefree(peer->scache_key);
+  peer->transport = TRNSPRT_NONE;
   peer->type = CURL_SSL_PEER_DNS;
 }
 
@@ -1206,6 +1207,8 @@ static void cf_close(struct Curl_cfilter *cf, struct Curl_easy *data)
   if(connssl) {
     connssl->ssl_impl->close(cf, data);
     connssl->state = ssl_connection_none;
+    connssl->connecting_state = ssl_connect_1;
+    connssl->prefs_checked = FALSE;
     Curl_ssl_peer_cleanup(&connssl->peer);
   }
   cf->connected = FALSE;
index 6700ee74cbe163fbf45d378e898cabe0b1e6a6b4..a0d8159a5879ad1435259ab7622fe0f0c68a9dc5 100644 (file)
@@ -133,9 +133,6 @@ struct ssl_connect_data {
   BIT(input_pending);               /* data for SSL_read() may be available */
 };
 
-#undef CF_CTX_CALL_DATA
-#define CF_CTX_CALL_DATA(cf) ((struct ssl_connect_data *)(cf)->ctx)->call_data
-
 /* Definitions for SSL Implementations */
 
 struct Curl_ssl {
@@ -209,3 +206,9 @@ CURLcode Curl_on_session_reuse(struct Curl_cfilter *cf,
 #endif /* USE_SSL */
 
 #endif /* HEADER_CURL_VTLS_INT_H */
+
+#ifdef USE_SSL
+/* Restore the default SSL filter call_data accessor for unity builds. */
+#undef CF_CTX_CALL_DATA
+#define CF_CTX_CALL_DATA(cf) ((struct ssl_connect_data *)(cf)->ctx)->call_data
+#endif
index a7458a3b5fcf3dc756a77909a0c07f1c83c5c95e..7e776ea6b6456280574e2163277ed1481065f0b3 100644 (file)
@@ -250,6 +250,7 @@ static const struct LongShort aliases[]= {
   {"proxy-digest",               ARG_BOOL, ' ', C_PROXY_DIGEST},
   {"proxy-header",               ARG_STRG, ' ', C_PROXY_HEADER},
   {"proxy-http2",                ARG_BOOL, ' ', C_PROXY_HTTP2},
+  {"proxy-http3",                ARG_BOOL, ' ', C_PROXY_HTTP3},
   {"proxy-insecure",             ARG_BOOL, ' ', C_PROXY_INSECURE},
   {"proxy-key",                  ARG_FILE|ARG_TLS, ' ', C_PROXY_KEY},
   {"proxy-key-type",             ARG_STRG|ARG_TLS, ' ', C_PROXY_KEY_TYPE},
@@ -2024,6 +2025,18 @@ static ParameterError opt_bool(struct OperationConfig *config,
 
     config->proxyver = toggle ? CURLPROXY_HTTPS2 : CURLPROXY_HTTPS;
     break;
+  case C_PROXY_HTTP3: /* --proxy-http3 */
+#ifndef USE_PROXY_HTTP3
+    if(toggle)
+      return PARAM_LIBCURL_DOESNT_SUPPORT;
+    config->proxyver = CURLPROXY_HTTPS;
+#else
+    if(!feature_httpsproxy || !feature_http3)
+      return PARAM_LIBCURL_DOESNT_SUPPORT;
+
+    config->proxyver = toggle ? CURLPROXY_HTTPS3 : CURLPROXY_HTTPS;
+#endif
+    break;
   case C_APPEND: /* --append */
     config->ftp_append = toggle;
     break;
@@ -2895,7 +2908,8 @@ static ParameterError opt_string(struct OperationConfig *config,
   case C_PROXY: /* --proxy */
     /* --proxy */
     err = getstr(&config->proxy, nextarg, ALLOW_BLANK);
-    if(config->proxyver != CURLPROXY_HTTPS2)
+    if(config->proxyver != CURLPROXY_HTTPS2 &&
+                config->proxyver != CURLPROXY_HTTPS3)
       config->proxyver = CURLPROXY_HTTP;
     break;
   case C_REQUEST: /* --request */
index e137cc322f98d5edc064f66af0e9ecfebd510180..32476d377691cd847ab7f55e59660d67ca9711ec 100644 (file)
@@ -201,6 +201,7 @@ typedef enum {
   C_PROXY_DIGEST,
   C_PROXY_HEADER,
   C_PROXY_HTTP2,
+  C_PROXY_HTTP3,
   C_PROXY_INSECURE,
   C_PROXY_KEY,
   C_PROXY_KEY_TYPE,
index 864771bfba883579d2bd69f16811784b16012676..c0b0af792f9e51154e459b80813dc4c18e50a3ad 100644 (file)
@@ -542,6 +542,9 @@ const struct helptxt helptext[] = {
   { "    --proxy-http2",
     "Use HTTP/2 with HTTPS proxy",
     CURLHELP_HTTP | CURLHELP_PROXY },
+  { "    --proxy-http3",
+    "Use HTTP/3 with HTTPS proxy",
+    CURLHELP_HTTP | CURLHELP_PROXY },
   { "    --proxy-insecure",
     "Skip HTTPS proxy cert verification",
     CURLHELP_PROXY | CURLHELP_TLS },
index 78779a55188eedf8d68d9c76b38eea77c15dbd36..4887a3594a38c88b1a2899e6be4ce9818d4224f2 100644 (file)
@@ -289,6 +289,8 @@ test3216 test3217 test3218 test3219 test3220 \
 \
 test3300 test3301 test3302 test3303 test3304 \
 \
+test3400 \
+\
 test4000 test4001
 
 EXTRA_DIST = $(TESTCASES) DISABLED data-xml1 data320.html \
diff --git a/tests/data/test3400 b/tests/data/test3400
new file mode 100644 (file)
index 0000000..12d014b
--- /dev/null
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="US-ASCII"?>
+<testcase>
+<info>
+<keywords>
+unittest
+capsule
+</keywords>
+</info>
+
+<client>
+<features>
+unittest
+</features>
+<name>
+capsule protocol encode and decode unit tests
+</name>
+</client>
+
+</testcase>
index 3373f8af2b39cbc7e25967aab967613e9c5e1624..9801d51907c5c217a9e84aa6a742b33f7be4d637 100644 (file)
@@ -28,6 +28,12 @@ if(NOT CADDY)
 endif()
 mark_as_advanced(CADDY)
 
+find_program(H2O "h2o")  # /usr/local/bin/h2o
+if(NOT H2O)
+  set(H2O "")
+endif()
+mark_as_advanced(H2O)
+
 find_program(VSFTPD "vsftpd")  # /usr/sbin/vsftpd
 if(NOT VSFTPD)
   set(VSFTPD "")
index f4dc92f61b06c9ce2ea2b9ac773e29a33c950ab8..7232c1e8aa15dff831c523aaead6d688e4e29d4e 100644 (file)
@@ -31,6 +31,7 @@ TESTENV =                               \
   testenv/dnsd.py                       \
   testenv/dante.py                      \
   testenv/env.py                        \
+  testenv/h2o.py                        \
   testenv/httpd.py                      \
   testenv/mod_curltest/mod_curltest.c   \
   testenv/nghttpx.py                    \
@@ -72,6 +73,7 @@ EXTRA_DIST =             \
   test_40_socks.py       \
   test_50_scp.py         \
   test_51_sftp.py        \
+  test_60_h3_proxy.py    \
   $(TESTENV)
 
 clean-local:
index 78808e966db9f54e3818c65b219ceb12cffce3cb..daf9869b7cda3dc3a1b61101df872d0fa41c5e3a 100644 (file)
@@ -44,3 +44,6 @@ danted = @DANTED@
 [sshd]
 sshd = @SSHD@
 sftpd = @SFTPD@
+
+[h2o]
+h2o = @H2O@
index 08da73ac0fdfad5e7d6115aba07b407c00644b85..0de5c1a8b9e06e20ef18f3bd4d8720b293db6141 100644 (file)
@@ -1,4 +1,4 @@
-#***************************************************************************
+# ***************************************************************************
 #                                  _   _ ____  _
 #  Project                     ___| | | |  _ \| |
 #                             / __| | | | |_) | |
@@ -31,9 +31,10 @@ from typing import Generator, Union
 import pytest
 from testenv.env import EnvConfig
 
-sys.path.append(os.path.join(os.path.dirname(__file__), '.'))
+sys.path.append(os.path.join(os.path.dirname(__file__), "."))
 
 from testenv import Env, Httpd, Nghttpx, NghttpxFwd, NghttpxQuic, Sshd
+from testenv.h2o import H2oProxy, H2oServer
 
 log = logging.getLogger(__name__)
 
@@ -42,51 +43,47 @@ def pytest_report_header(config):
     # Env inits its base properties only once, we can report them here
     env = Env()
     report = [
-        f'Testing curl {env.curl_version()}',
-        f'  platform: {platform.platform()}',
-        f'  curl: Version: {env.curl_version_string()}',
-        f'  curl: Features: {env.curl_features_string()}',
-        f'  curl: Protocols: {env.curl_protocols_string()}',
-        f'  httpd: {env.httpd_version()}',
-        f'  httpd-proxy: {env.httpd_version()}'
+        f"Testing curl {env.curl_version()}",
+        f"  platform: {platform.platform()}",
+        f"  curl: Version: {env.curl_version_string()}",
+        f"  curl: Features: {env.curl_features_string()}",
+        f"  curl: Protocols: {env.curl_protocols_string()}",
+        f"  httpd: {env.httpd_version()}",
+        f"  httpd-proxy: {env.httpd_version()}",
     ]
     if env.have_h3():
-        report.extend([
-            f'  nghttpx: {env.nghttpx_version()}'
-        ])
+        report.extend([f"  nghttpx: {env.nghttpx_version()}"])
+    if env.have_h2o():
+        report.extend([f"  h2o: {env.h2o_version()}"])
     if env.has_caddy():
-        report.extend([
-            f'  Caddy: {env.caddy_version()}'
-        ])
+        report.extend([f"  Caddy: {env.caddy_version()}"])
     if env.has_vsftpd():
-        report.extend([
-            f'  VsFTPD: {env.vsftpd_version()}'
-        ])
-    buildinfo_fn = os.path.join(env.build_dir, 'buildinfo.txt')
+        report.extend([f"  VsFTPD: {env.vsftpd_version()}"])
+    buildinfo_fn = os.path.join(env.build_dir, "buildinfo.txt")
     if os.path.exists(buildinfo_fn):
-        with open(buildinfo_fn, 'r') as file_in:
+        with open(buildinfo_fn, "r") as file_in:
             for line in file_in:
                 line = line.strip()
-                if line and not line.startswith('#'):
+                if line and not line.startswith("#"):
                     report.extend([line])
-    return '\n'.join(report)
+    return "\n".join(report)
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def env_config(pytestconfig, testrun_uid, worker_id) -> EnvConfig:
-    return EnvConfig(pytestconfig=pytestconfig,
-                     testrun_uid=testrun_uid,
-                     worker_id=worker_id)
+    return EnvConfig(
+        pytestconfig=pytestconfig, testrun_uid=testrun_uid, worker_id=worker_id
+    )
 
 
-@pytest.fixture(scope='session', autouse=True)
+@pytest.fixture(scope="session", autouse=True)
 def env(pytestconfig, env_config) -> Env:
     env = Env(pytestconfig=pytestconfig, env_config=env_config)
     level = logging.DEBUG if env.verbose > 0 else logging.INFO
-    logging.getLogger('').setLevel(level=level)
-    if not env.curl_has_protocol('http'):
+    logging.getLogger("").setLevel(level=level)
+    if not env.curl_has_protocol("http"):
         pytest.skip("curl built without HTTP support")
-    if not env.curl_has_protocol('https'):
+    if not env.curl_has_protocol("https"):
         pytest.skip("curl built without HTTPS support")
     if env.setup_incomplete():
         pytest.skip(env.incomplete_reason())
@@ -95,23 +92,23 @@ def env(pytestconfig, env_config) -> Env:
     return env
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def httpd(env) -> Generator[Httpd, None, None]:
     httpd = Httpd(env=env)
     if not httpd.exists():
-        pytest.skip(f'httpd not found: {env.httpd}')
+        pytest.skip(f"httpd not found: {env.httpd}")
     httpd.clear_logs()
     assert httpd.initial_start()
     yield httpd
     httpd.stop()
 
 
-@pytest.fixture(scope='session')
-def nghttpx(env, httpd) -> Generator[Union[Nghttpx,bool], None, None]:
+@pytest.fixture(scope="session")
+def nghttpx(env, httpd) -> Generator[Union[Nghttpx, bool], None, None]:
     nghttpx = NghttpxQuic(env=env)
     if nghttpx.exists():
         if not nghttpx.supports_h3() and env.have_h3_curl():
-            log.warning('nghttpx does not support QUIC, but curl does')
+            log.warning("nghttpx does not support QUIC, but curl does")
         nghttpx.clear_logs()
         assert nghttpx.initial_start()
         yield nghttpx
@@ -120,8 +117,8 @@ def nghttpx(env, httpd) -> Generator[Union[Nghttpx,bool], None, None]:
         yield False
 
 
-@pytest.fixture(scope='session')
-def nghttpx_fwd(env, httpd) -> Generator[Union[Nghttpx,bool], None, None]:
+@pytest.fixture(scope="session")
+def nghttpx_fwd(env, httpd) -> Generator[Union[Nghttpx, bool], None, None]:
     nghttpx = NghttpxFwd(env=env)
     if nghttpx.exists():
         nghttpx.clear_logs()
@@ -132,37 +129,63 @@ def nghttpx_fwd(env, httpd) -> Generator[Union[Nghttpx,bool], None, None]:
         yield False
 
 
-@pytest.fixture(scope='session')
-def sshd(env: Env) -> Generator[Union[Sshd,bool], None, None]:
+@pytest.fixture(scope="session")
+def sshd(env: Env) -> Generator[Union[Sshd, bool], None, None]:
     if env.has_sshd():
         sshd = Sshd(env=env)
-        assert sshd.initial_start(), f'{sshd.dump_log()}'
+        assert sshd.initial_start(), f"{sshd.dump_log()}"
         yield sshd
         sshd.stop()
     else:
         yield False
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def configures_httpd(env, httpd) -> Generator[bool, None, None]:
     # include this fixture as test parameter if the test configures httpd itself
     yield True
 
 
-@pytest.fixture(scope='session')
+@pytest.fixture(scope="session")
 def configures_nghttpx(env, httpd) -> Generator[bool, None, None]:
     # include this fixture as test parameter if the test configures nghttpx itself
     yield True
 
 
-@pytest.fixture(autouse=True, scope='function')
+@pytest.fixture(autouse=True, scope="function")
 def server_reset(request, env, httpd, nghttpx):
     # make sure httpd is in default configuration when a test starts
-    if 'configures_httpd' not in request.node._fixtureinfo.argnames:
+    if "configures_httpd" not in request.node._fixtureinfo.argnames:
         httpd.reset_config()
         httpd.reload_if_config_changed()
-    if env.have_h3() and \
-            'nghttpx' in request.node._fixtureinfo.argnames and \
-            'configures_nghttpx' not in request.node._fixtureinfo.argnames:
+    if (
+        env.have_h3()
+        and "nghttpx" in request.node._fixtureinfo.argnames
+        and "configures_nghttpx" not in request.node._fixtureinfo.argnames
+    ):
         nghttpx.reset_config()
         nghttpx.reload_if_config_changed()
+
+
+@pytest.fixture(scope="session")
+def h2o_server(env) -> Generator[Union[H2oServer, bool], None, None]:
+    h2o = H2oServer(env=env)
+    if env.have_h2o():
+        h2o.clear_logs()
+        assert h2o.initial_start()
+        yield h2o
+        h2o.stop()
+    else:
+        yield False
+
+
+@pytest.fixture(scope="session")
+def h2o_proxy(env) -> Generator[Union[H2oProxy, bool], None, None]:
+    h2o = H2oProxy(env=env)
+    if env.have_h2o():
+        h2o.clear_logs()
+        assert h2o.initial_start()
+        yield h2o
+        h2o.stop()
+    else:
+        yield False
diff --git a/tests/http/test_60_h3_proxy.py b/tests/http/test_60_h3_proxy.py
new file mode 100644 (file)
index 0000000..def32a6
--- /dev/null
@@ -0,0 +1,689 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# ***************************************************************************
+#                                  _   _ ____  _
+#  Project                     ___| | | |  _ \| |
+#                             / __| | | | |_) | |
+#                            | (__| |_| |  _ <| |___
+#                             \___|\___/|_| \_\_____|
+#
+# Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at https://curl.se/docs/copyright.html.
+#
+# You may opt to use, copy, modify, merge, publish, distribute and/or sell
+# copies of the Software, and permit persons to whom the Software is
+# furnished to do so, under the terms of the COPYING file.
+#
+# This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
+# KIND, either express or implied.
+#
+# SPDX-License-Identifier: curl
+#
+###########################################################################
+#
+import os
+import subprocess
+import time
+
+import pytest
+from testenv import CurlClient, Env
+
+MARK_NEEDS_HTTPS_PROXY = pytest.mark.skipif(
+    condition=not Env.curl_has_feature("HTTPS-proxy"),
+    reason="curl lacks HTTPS-proxy support"
+)
+MARK_NEEDS_HTTP3 = pytest.mark.skipif(
+    condition=not Env.curl_has_feature("HTTP3"), reason="curl lacks HTTP/3 support"
+)
+MARK_NEEDS_PROXY_HTTP3 = pytest.mark.skipif(
+    condition=not Env.curl_has_feature("PROXY-HTTP3"),
+    reason="curl lacks experimental HTTP/3 proxy support"
+)
+MARK_NEEDS_NGHTTP3 = pytest.mark.skipif(
+    condition=not Env.curl_uses_lib("nghttp3"), reason="only supported with nghttp3"
+)
+MARK_NEEDS_NGHTTP2 = pytest.mark.skipif(
+    condition=not Env.curl_uses_lib("nghttp2"), reason="only supported with nghttp2"
+)
+MARK_NEEDS_H2O = pytest.mark.skipif(
+    condition=not Env.have_h2o(), reason="no h2o available"
+)
+MARK_NEEDS_NGHTTPX = pytest.mark.skipif(
+    condition=not Env.have_nghttpx(), reason="no nghttpx available"
+)
+
+H3_PROXY_COMMON_MARKS = [
+    MARK_NEEDS_HTTPS_PROXY,
+    MARK_NEEDS_HTTP3,
+    MARK_NEEDS_PROXY_HTTP3,
+    MARK_NEEDS_NGHTTP3,
+]
+
+NGTCP2_ONLY_MSG = "only supported with the ngtcp2 quic stack"
+UNSUPPORTED_OPT_MSG = "does not support this"
+H2O_HELLO_MSG = '"message": "Hello from h2o HTTP/3 server"'
+
+
+def _require_available(**items):
+    missing = [name for name, value in items.items() if not value]
+    if missing:
+        pytest.skip(f"{' or '.join(missing)} not available")
+
+
+def _download_path(curl: CurlClient) -> str:
+    return os.path.join(curl.run_dir, "download_#1.data")
+
+
+def _check_download_message(curl: CurlClient, expected: str):
+    dpath = _download_path(curl)
+    assert os.path.exists(dpath), f"Download file not found: {dpath}"
+    with open(dpath, "r") as fd:
+        content = fd.read()
+    assert expected in content, f"Unexpected response content: {content}"
+
+
+def _check_download_size(curl: CurlClient, expected_size: int):
+    dpath = _download_path(curl)
+    assert os.path.exists(dpath), f"Download file not found: {dpath}"
+    actual = os.path.getsize(dpath)
+    assert actual == expected_size, f"expected {expected_size}B download, got {actual}B"
+
+
+def _nghttpx_proxy_args(
+    env: Env,
+    nghttpx,
+    proxy_proto: str,
+    tunnel: bool,
+    insecure: bool = False,
+):
+    xargs = [
+        "--proxy",
+        f"https://{env.proxy_domain}:{nghttpx._port}/",
+        "--resolve",
+        f"{env.proxy_domain}:{nghttpx._port}:127.0.0.1",
+        "--proxy-cacert",
+        env.ca.cert_file,
+    ]
+    if proxy_proto == "h3":
+        xargs.append("--proxy-http3")
+    elif proxy_proto == "h2":
+        xargs.append("--proxy-http2")
+
+    if tunnel:
+        xargs.append("--proxytunnel")
+
+    xargs.extend(["--cacert", env.ca.cert_file, "--proxy-insecure"])
+    if insecure:
+        xargs.append("--insecure")
+    return xargs
+
+
+def _h2o_proxy_args(
+    env: Env,
+    h2o_proxy,
+    proxy_proto: str,
+    tunnel: bool,
+    insecure: bool = False,
+):
+    if proxy_proto == "h3":
+        pport = h2o_proxy.port
+    elif proxy_proto == "h2":
+        pport = h2o_proxy.h2_port
+    else:
+        pport = h2o_proxy.h1_port
+
+    xargs = [
+        "--proxy",
+        f"https://{env.proxy_domain}:{pport}/",
+        "--resolve",
+        f"{env.proxy_domain}:{pport}:127.0.0.1",
+        "--proxy-cacert",
+        env.ca.cert_file,
+    ]
+    if proxy_proto == "h2":
+        xargs.append("--proxy-http2")
+    elif proxy_proto == "h3":
+        xargs.append("--proxy-http3")
+
+    if tunnel:
+        xargs.append("--proxytunnel")
+
+    xargs.extend(["--cacert", env.ca.cert_file, "--proxy-insecure"])
+    if insecure:
+        xargs.append("--insecure")
+    return xargs
+
+
+class TestH3ProxySuccess:
+    """Success matrix for HTTP/3 proxy CONNECT / CONNECT-UDP."""
+
+    pytestmark = H3_PROXY_COMMON_MARKS + [MARK_NEEDS_H2O]
+
+    @pytest.mark.parametrize(
+        ["alpn_proto", "proxy_proto"],
+        [
+            pytest.param("http/1.1", "h3", id="h1_over_h3_proxytunnel"),
+            pytest.param(
+                "h2",
+                "h3",
+                marks=MARK_NEEDS_NGHTTP2,
+                id="h2_over_h3_proxytunnel",
+            ),
+            pytest.param("h3", "h3", id="h3_over_h3_proxytunnel"),
+            pytest.param(
+                "h3",
+                "h2",
+                marks=MARK_NEEDS_NGHTTP2,
+                id="h3_over_h2_proxytunnel",
+            ),
+            pytest.param("h3", "http/1.1", id="h3_over_h1_proxytunnel"),
+        ],
+    )
+    def test_60_01_connect_tunnel(
+        self,
+        env: Env,
+        h2o_server,
+        h2o_proxy,
+        alpn_proto,
+        proxy_proto,
+    ):
+        _require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
+
+        curl = CurlClient(env=env)
+        url = f"https://localhost:{h2o_server.port}/data.json"
+        proxy_args = _h2o_proxy_args(
+            env, h2o_proxy, proxy_proto, tunnel=True, insecure=True
+        )
+
+        r = curl.http_download(
+            urls=[url], alpn_proto=alpn_proto, with_stats=True, extra_args=proxy_args
+        )
+        r.check_response(count=1, http_status=200)
+        _check_download_message(curl, H2O_HELLO_MSG)
+
+
+class TestH3ProxyFailure:
+    """Failure matrix when proxy side does not support requested mode."""
+
+    pytestmark = H3_PROXY_COMMON_MARKS + [MARK_NEEDS_NGHTTPX]
+
+    @pytest.mark.parametrize(
+        ["alpn_proto", "proxy_proto", "exp_err"],
+        [
+            pytest.param(
+                "http/1.1",
+                "h3",
+                "could not connect to server",
+                id="fail_h1_over_h3_proxytunnel",
+            ),
+            pytest.param(
+                "h2",
+                "h3",
+                "could not connect to server",
+                marks=MARK_NEEDS_NGHTTP2,
+                id="fail_h2_over_h3_proxytunnel",
+            ),
+            pytest.param(
+                "h3",
+                "h3",
+                "could not connect to server",
+                id="fail_h3_over_h3_proxytunnel",
+            ),
+            pytest.param(
+                "h3",
+                "h2",
+                "connect-udp response status 400",
+                marks=MARK_NEEDS_NGHTTP2,
+                id="fail_h3_over_h2_proxytunnel",
+            ),
+            pytest.param(
+                "h3",
+                "http/1.1",
+                "connect-udp tunnel failed, response 404",
+                id="fail_h3_over_h1_proxytunnel",
+            ),
+        ],
+    )
+    def test_60_02_connect_tunnel_fail(
+        self,
+        env: Env,
+        httpd,
+        nghttpx,
+        alpn_proto,
+        proxy_proto,
+        exp_err,
+    ):
+        _require_available(httpd=httpd, nghttpx=nghttpx)
+
+        curl = CurlClient(env=env)
+        url = f"https://localhost:{httpd.ports['https']}/data.json"
+        proxy_args = _nghttpx_proxy_args(env, nghttpx, proxy_proto, tunnel=True)
+        r = curl.http_download(
+            urls=[url], alpn_proto=alpn_proto, with_stats=True, extra_args=proxy_args
+        )
+        assert r.exit_code != 0, f"Expected failure but curl succeeded: {r}"
+        assert exp_err in r.stderr.lower(), (
+            f"Expected protocol/proxy error but got: {r.stderr}"
+        )
+
+
+class TestH3ProxyModeSelection:
+    """Behavior checks for tunnel vs non-tunnel proxy mode selection."""
+
+    pytestmark = H3_PROXY_COMMON_MARKS + [MARK_NEEDS_NGHTTPX]
+
+    @pytest.mark.parametrize(
+        ["proxy_proto"],
+        [
+            pytest.param("h3", id="proxy_h3"),
+            pytest.param("h2", marks=MARK_NEEDS_NGHTTP2, id="proxy_h2"),
+            pytest.param("http/1.1", id="proxy_h1"),
+        ],
+    )
+    def test_60_03_h3_target_auto_connect_udp(
+        self, env: Env, httpd, nghttpx, proxy_proto
+    ):
+        _require_available(httpd=httpd, nghttpx=nghttpx)
+
+        curl = CurlClient(env=env)
+        url = f"https://localhost:{httpd.ports['https']}/data.json"
+        proxy_args = _nghttpx_proxy_args(
+            env, nghttpx, proxy_proto, tunnel=False
+        )
+        r = curl.http_download(
+            urls=[url], alpn_proto="h3", with_stats=True, extra_args=proxy_args
+        )
+
+        # An HTTP/3 target auto-triggers CONNECT-UDP even without --proxytunnel,
+        # just as HTTPS targets auto-trigger CONNECT. nghttpx does not support
+        # CONNECT-UDP so this fails, which confirms auto-CONNECT-UDP is active.
+        assert r.exit_code != 0, (
+            "expected failure: h3 target auto-triggers CONNECT-UDP "
+            "which nghttpx does not support"
+        )
+        assert "connect-udp" in r.stderr.lower(), (
+            f"expected CONNECT-UDP attempt in output, got: {r.stderr}"
+        )
+
+
+class TestH3ProxyRuntimeGuards:
+    """Guard checks for unsupported HTTP/3 proxy options."""
+
+    pytestmark = [
+        MARK_NEEDS_HTTPS_PROXY,
+        MARK_NEEDS_PROXY_HTTP3,
+        pytest.mark.skipif(
+            condition=Env.curl_uses_lib("ngtcp2"),
+            reason="guard only applies to non-ngtcp2 builds",
+        ),
+    ]
+
+    @pytest.mark.skipif(
+        condition=not Env.curl_has_feature("HTTP3"), reason="curl lacks HTTP/3 support"
+    )
+    def test_60_04_guard_proxy_http3_unsupported(self, env: Env, httpd):
+        curl = CurlClient(env=env)
+        url = f"https://localhost:{httpd.ports['https']}/data.json"
+        proxy_args = [
+            "--proxy",
+            "https://127.0.0.1:1/",
+            "--proxy-http3",
+            "--proxytunnel",
+            "--proxy-insecure",
+            "--cacert",
+            env.ca.cert_file,
+        ]
+
+        r = curl.http_download(
+            urls=[url], alpn_proto="http/1.1", with_stats=True, extra_args=proxy_args
+        )
+        if not env.curl_has_feature("PROXY-HTTP3"):
+            r.check_exit_code(2)
+            assert UNSUPPORTED_OPT_MSG in r.stderr.lower(), (
+                f"Expected unsupported option failure but got: {r.stderr}"
+            )
+            return
+
+        r.check_exit_code(1)
+        assert NGTCP2_ONLY_MSG in r.stderr.lower(), (
+            f"Expected ngtcp2 guard failure but got: {r.stderr}"
+        )
+
+
+class TestH3ProxyRobustness:
+    """Robustness checks for shutdown and proxy loss during transfer."""
+
+    pytestmark = H3_PROXY_COMMON_MARKS + [MARK_NEEDS_H2O]
+
+    @pytest.fixture(autouse=True, scope="class")
+    def _class_scope(self, env):
+        doc_root = os.path.join(env.gen_dir, "docs")
+        env.make_data_file(
+            indir=doc_root, fname="proxy-drop-20m", fsize=20 * 1024 * 1024
+        )
+
+    def test_60_05_graceful_shutdown(
+        self, env: Env, h2o_server, h2o_proxy
+    ):
+        if not env.curl_is_debug():
+            pytest.skip("needs debug curl for shutdown trace lines")
+        if not env.curl_is_verbose():
+            pytest.skip("needs verbose-strings curl build")
+
+        curl = CurlClient(env=env, run_env={"CURL_DEBUG": "all"})
+        url = f"https://localhost:{h2o_server.port}/data.json"
+        proxy_args = curl.get_proxy_args(proto="h3", tunnel=True)
+        proxy_args.extend(["--cacert", env.ca.cert_file, "--insecure"])
+
+        r = curl.http_download(
+            urls=[url], alpn_proto="h3", with_stats=True, extra_args=proxy_args
+        )
+        r.check_response(count=1, http_status=200)
+
+        shutdown_lines = [
+            line
+            for line in r.trace_lines
+            if ("start shutdown(" in line.lower())
+            or ("shutdown completely sent off" in line.lower())
+        ]
+        assert shutdown_lines, f"No shutdown trace lines found:\n{r.stderr}"
+
+    def test_60_06_proxy_drop_mid_transfer(self, env: Env, h2o_server, h2o_proxy):
+        _require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
+
+        proxy_port = h2o_proxy.port
+        url = f"https://localhost:{h2o_server.port}/proxy-drop-20m"
+        out_path = os.path.join(env.gen_dir, "proxy-drop.out")
+        args = [
+            env.curl,
+            "--http1.1",
+            "--proxy",
+            f"https://{env.proxy_domain}:{proxy_port}/",
+            "--resolve",
+            f"{env.proxy_domain}:{proxy_port}:127.0.0.1",
+            "--proxy-cacert",
+            env.ca.cert_file,
+            "--proxy-http3",
+            "--proxytunnel",
+            "--proxy-insecure",
+            "--cacert",
+            env.ca.cert_file,
+            "--limit-rate",
+            "100k",
+            "--max-time",
+            "20",
+            "-o",
+            out_path,
+            url,
+        ]
+
+        proc = None
+        try:
+            proc = subprocess.Popen(
+                args=args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
+            )
+            time.sleep(1.0)
+            assert h2o_proxy.stop(), "failed to stop h2o proxy"
+            _, stderr = proc.communicate(timeout=30)
+            assert proc.returncode != 0, (
+                "curl should fail when proxy is terminated mid-transfer"
+            )
+            serr = stderr.lower()
+            assert (
+                "failed" in serr
+                or "transfer closed" in serr
+                or "recv failure" in serr
+                or "connection" in serr
+            ), f"Unexpected error output: {stderr}"
+        finally:
+            if proc and (proc.poll() is None):
+                proc.kill()
+                proc.wait(timeout=5)
+            assert h2o_proxy.start(), "failed to restart h2o proxy"
+
+
+class TestH3ProxyDataTransfer:
+    """Large file transfers and multiplexing through HTTP/3 proxy."""
+
+    pytestmark = H3_PROXY_COMMON_MARKS + [MARK_NEEDS_H2O]
+
+    @pytest.fixture(autouse=True, scope="class")
+    def _class_scope(self, env):
+        doc_root = os.path.join(env.gen_dir, "docs")
+        env.make_data_file(indir=doc_root, fname="download-1m", fsize=1 * 1024 * 1024)
+        env.make_data_file(indir=doc_root, fname="download-10m", fsize=10 * 1024 * 1024)
+        env.make_data_file(indir=env.gen_dir, fname="upload-2m", fsize=2 * 1024 * 1024)
+
+    def test_60_07_large_download(self, env: Env, h2o_server, h2o_proxy):
+        _require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
+        curl = CurlClient(env=env)
+        url = f"https://localhost:{h2o_server.port}/download-10m"
+        proxy_args = _h2o_proxy_args(env, h2o_proxy, "h3", tunnel=True, insecure=True)
+        r = curl.http_download(
+            urls=[url], alpn_proto="http/1.1", with_stats=True, extra_args=proxy_args
+        )
+        r.check_response(count=1, http_status=200)
+        _check_download_size(curl, 10 * 1024 * 1024)
+
+    def test_60_08_large_upload(self, env: Env, httpd, h2o_server, h2o_proxy):
+        _require_available(h2o_proxy=h2o_proxy)
+        fdata = os.path.join(env.gen_dir, "upload-2m")
+        curl = CurlClient(env=env)
+        url = f"https://localhost:{httpd.ports['https']}/curltest/echo?id=[0-0]"
+        proxy_args = _h2o_proxy_args(env, h2o_proxy, "h3", tunnel=True, insecure=True)
+        r = curl.http_upload(
+            urls=[url],
+            data=f"@{fdata}",
+            alpn_proto="http/1.1",
+            with_stats=True,
+            extra_args=proxy_args,
+        )
+        r.check_response(count=1, http_status=200)
+
+    def test_60_09_parallel_downloads(self, env: Env, h2o_server, h2o_proxy):
+        _require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
+        count = 5
+        curl = CurlClient(env=env)
+        urln = f"https://localhost:{h2o_server.port}/download-1m?[0-{count - 1}]"
+        proxy_args = _h2o_proxy_args(env, h2o_proxy, "h3", tunnel=True, insecure=True)
+        proxy_args.extend(["--parallel", "--parallel-max", f"{count}"])
+        r = curl.http_download(
+            urls=[urln], alpn_proto="http/1.1", with_stats=True, extra_args=proxy_args
+        )
+        r.check_response(count=count, http_status=200)
+
+
+class TestH3ProxyConnectionManagement:
+    """Proxy authentication, connection reuse, and session resumption."""
+
+    pytestmark = H3_PROXY_COMMON_MARKS + [MARK_NEEDS_H2O]
+
+    def test_60_10_proxy_basic_auth(self, env: Env, h2o_server, h2o_proxy):
+        _require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
+        curl = CurlClient(env=env)
+        url = f"https://localhost:{h2o_server.port}/data.json"
+        proxy_args = _h2o_proxy_args(env, h2o_proxy, "h3", tunnel=True, insecure=True)
+        proxy_args.extend(["--proxy-user", "testuser:testpass"])
+        r = curl.http_download(
+            urls=[url], alpn_proto="http/1.1", with_stats=True, extra_args=proxy_args
+        )
+        r.check_response(count=1, http_status=200)
+        _check_download_message(curl, H2O_HELLO_MSG)
+
+    def test_60_11_connection_reuse(self, env: Env, h2o_server, h2o_proxy):
+        _require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
+        curl = CurlClient(env=env)
+        urln = f"https://localhost:{h2o_server.port}/data.json?[0-2]"
+        proxy_args = _h2o_proxy_args(env, h2o_proxy, "h3", tunnel=True, insecure=True)
+        r = curl.http_download(
+            urls=[urln], alpn_proto="http/1.1", with_stats=True, extra_args=proxy_args
+        )
+        r.check_response(count=3, http_status=200)
+        assert r.total_connects <= 3, (
+            f"expected proxy connection reuse, got {r.total_connects} connects"
+        )
+
+    def test_60_12_quic_session_resumption(self, env: Env, h2o_server, h2o_proxy):
+        _require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
+        # First request establishes QUIC session
+        curl1 = CurlClient(env=env)
+        url = f"https://localhost:{h2o_server.port}/data.json"
+        proxy_args = _h2o_proxy_args(env, h2o_proxy, "h3", tunnel=True, insecure=True)
+        r1 = curl1.http_download(
+            urls=[url], alpn_proto="http/1.1", with_stats=True, extra_args=proxy_args
+        )
+        r1.check_response(count=1, http_status=200)
+        # Second request from a fresh CurlClient; session may be reused
+        # by the TLS session cache if supported
+        curl2 = CurlClient(env=env)
+        r2 = curl2.http_download(
+            urls=[url], alpn_proto="http/1.1", with_stats=True, extra_args=proxy_args
+        )
+        r2.check_response(count=1, http_status=200)
+        # Third request from a fresh CurlClient; session may be reused
+        # by the TLS session cache if supported
+        curl3 = CurlClient(env=env)
+        r3 = curl3.http_download(
+            urls=[url], alpn_proto="http/1.1", with_stats=True, extra_args=proxy_args
+        )
+        r3.check_response(count=1, http_status=200)
+
+
+class TestH3ProxyUdpTunnel:
+    """CONNECT-UDP tunnel payload size and capsule-protocol tests."""
+
+    pytestmark = H3_PROXY_COMMON_MARKS
+
+    @pytest.fixture(autouse=True, scope="class")
+    def _class_scope(self, env):
+        doc_root = os.path.join(env.gen_dir, "docs")
+        env.make_data_file(indir=doc_root, fname="download-1400", fsize=1400)
+        env.make_data_file(indir=doc_root, fname="download-1m", fsize=1 * 1024 * 1024)
+        env.make_data_file(indir=doc_root, fname="download-10m", fsize=10 * 1024 * 1024)
+
+    @MARK_NEEDS_H2O
+    @pytest.mark.parametrize(
+        "fname,fsize",
+        [
+            ("download-1400", 1400),
+            ("download-1m", 1 * 1024 * 1024),
+            ("download-10m", 10 * 1024 * 1024),
+        ],
+    )
+    def test_60_13_udp_tunnel_payload_sizes(
+        self, env: Env, h2o_server, h2o_proxy, fname, fsize
+    ):
+        _require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
+        curl = CurlClient(env=env)
+        url = f"https://localhost:{h2o_server.port}/{fname}"
+        proxy_args = _h2o_proxy_args(env, h2o_proxy, "h3", tunnel=True, insecure=True)
+        r = curl.http_download(
+            urls=[url], alpn_proto="h3", with_stats=True, extra_args=proxy_args
+        )
+        r.check_response(count=1, http_status=200)
+        _check_download_size(curl, fsize)
+
+    @MARK_NEEDS_NGHTTPX
+    def test_60_14_udp_tunnel_capsule_absent(self, env: Env, httpd, nghttpx):
+        _require_available(httpd=httpd, nghttpx=nghttpx)
+        curl = CurlClient(env=env)
+        url = f"https://localhost:{httpd.ports['https']}/data.json"
+        proxy_args = _nghttpx_proxy_args(env, nghttpx, "h3", tunnel=True)
+        r = curl.http_download(
+            urls=[url], alpn_proto="h3", with_stats=True, extra_args=proxy_args
+        )
+        assert r.exit_code != 0, (
+            "expected failure: nghttpx does not support CONNECT-UDP / Capsule-Protocol"
+        )
+
+
+class TestH3ProxyEdgeCases:
+    """Timeout and protocol-mismatch edge cases."""
+
+    pytestmark = H3_PROXY_COMMON_MARKS + [MARK_NEEDS_H2O]
+
+    def test_60_15_connect_timeout(self, env: Env, h2o_server):
+        _require_available(h2o_server=h2o_server)
+        curl = CurlClient(env=env, timeout=15)
+        url = f"https://localhost:{h2o_server.port}/data.json"
+        proxy_args = [
+            "--proxy",
+            "https://192.0.2.1:1/",
+            "--proxy-http3",
+            "--proxytunnel",
+            "--proxy-insecure",
+            "--connect-timeout",
+            "3",
+            "--cacert",
+            env.ca.cert_file,
+        ]
+        r = curl.http_download(
+            urls=[url], alpn_proto="http/1.1", with_stats=True, extra_args=proxy_args
+        )
+        assert r.exit_code != 0, "expected timeout connecting to unreachable proxy"
+        assert r.duration.total_seconds() < 10, (
+            f"timeout not respected: took {r.duration.total_seconds():.1f}s"
+        )
+
+    @MARK_NEEDS_NGHTTP2
+    def test_60_16_h2_uses_connect_tcp_not_udp(self, env: Env, httpd, h2o_proxy):
+        _require_available(httpd=httpd, h2o_proxy=h2o_proxy)
+        curl = CurlClient(env=env)
+        url = f"https://localhost:{httpd.ports['https']}/data.json"
+        proxy_args = _h2o_proxy_args(env, h2o_proxy, "h3", tunnel=True, insecure=True)
+        # h2 inner traffic always uses CONNECT (TCP), never CONNECT-UDP,
+        # even through an HTTP/3 proxy with --proxytunnel. h2o supports
+        # CONNECT TCP tunneling, so this request succeeds.
+        r = curl.http_download(
+            urls=[url], alpn_proto="h2", with_stats=True, extra_args=proxy_args
+        )
+        r.check_response(count=1, http_status=200)
+
+
+class TestH3ProxyHappyEyeballs:
+    """
+    Verify that happy eyeballs is active for HTTP/3 proxy connections.
+
+    With the H3-PROXY filter sitting above HAPPY-EYEBALLS -> UDP, address
+    family selection to the proxy is done by happy eyeballs.
+    """
+
+    pytestmark = H3_PROXY_COMMON_MARKS + [MARK_NEEDS_H2O]
+
+    def test_60_17_h3_proxy_happy_eyeballs_filter_present(self, env: Env, h2o_server, h2o_proxy):
+        """Verbose trace confirms HAPPY-EYEBALLS filter is in the H3 proxy chain."""
+        if not env.curl_is_debug():
+            pytest.skip("needs debug curl for filter trace")
+        _require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
+        curl = CurlClient(env=env, run_env={"CURL_DEBUG": "HAPPY-EYEBALLS,H3-PROXY"})
+        url = f"https://localhost:{h2o_server.port}/data.json"
+        proxy_args = _h2o_proxy_args(env, h2o_proxy, "h3", tunnel=True, insecure=True)
+        r = curl.http_download(
+            urls=[url], alpn_proto="http/1.1", with_stats=True, extra_args=proxy_args
+        )
+        r.check_response(count=1, http_status=200)
+        assert "happy-eyeballs" in r.stderr.lower(), (
+            f"expected HAPPY-EYEBALLS trace for H3 proxy, got: {r.stderr}"
+        )
+
+    @MARK_NEEDS_NGHTTP2
+    def test_60_18_h3_proxy_ipv4_all_proto(self, env: Env, h2o_server, h2o_proxy):
+        """IPv4-forced H3 proxy works for h1/h2/h3 inner protocols."""
+        _require_available(h2o_server=h2o_server, h2o_proxy=h2o_proxy)
+        for alpn_proto in ["http/1.1", "h2", "h3"]:
+            curl = CurlClient(env=env)
+            url = f"https://localhost:{h2o_server.port}/data.json"
+            proxy_args = _h2o_proxy_args(
+                env, h2o_proxy, "h3", tunnel=True, insecure=True
+            )
+            proxy_args.append("--ipv4")
+            r = curl.http_download(
+                urls=[url],
+                alpn_proto=alpn_proto,
+                with_stats=True,
+                extra_args=proxy_args,
+            )
+            r.check_response(count=1, http_status=200)
index 99aa649bc0fd994811a6d8380f42c2ff14f210c2..272b6045cbf80e523a8d2c77f626fd6adb655e22 100644 (file)
@@ -688,7 +688,12 @@ class CurlClient:
         proxy_name = '[::1]' if use_ipv6 else \
             self._server_addr if use_ip else self.env.proxy_domain
         if proxys:
-            pport = self.env.pts_port(proto) if tunnel else self.env.proxys_port
+            if tunnel:
+                pport = self.env.pts_port(proto)
+            elif proto == 'h3':
+                pport = self.env.h3proxys_port
+            else:
+                pport = self.env.proxys_port
             xargs = [
                 '--proxy', f'https://{proxy_name}:{pport}/',
                 '--proxy-cacert', self.env.ca.cert_file,
@@ -697,6 +702,8 @@ class CurlClient:
                 xargs.extend(['--resolve', f'{proxy_name}:{pport}:{self._server_addr}'])
             if proto == 'h2':
                 xargs.append('--proxy-http2')
+            elif proto == 'h3':
+                xargs.append('--proxy-http3')
         else:
             xargs = [
                 '--proxy', f'http://{proxy_name}:{self.env.proxy_port}/',
index c7bbfc4c54619f00c0dc592f217ee7e0590eaa5f..a2032f82ce5f75151f1aad67cb1e282e394cbb1d 100644 (file)
@@ -1,6 +1,6 @@
 #!/usr/bin/env python3
 # -*- coding: utf-8 -*-
-#***************************************************************************
+# ***************************************************************************
 #                                  _   _ ____  _
 #  Project                     ___| | | |  _ \| |
 #                             / __| | | | |_) | |
@@ -54,20 +54,21 @@ def init_config_from(conf_path):
 TESTS_HTTPD_PATH = os.path.dirname(os.path.dirname(__file__))
 PROJ_PATH = os.path.dirname(os.path.dirname(TESTS_HTTPD_PATH))
 TOP_PATH = os.path.join(os.getcwd(), os.path.pardir)
-CONFIG_PATH = os.path.join(TOP_PATH, 'tests', 'http', 'config.ini')
+CONFIG_PATH = os.path.join(TOP_PATH, "tests", "http", "config.ini")
 if not os.path.exists(CONFIG_PATH):
-    ALT_CONFIG_PATH = os.path.join(PROJ_PATH, 'tests', 'http', 'config.ini')
+    ALT_CONFIG_PATH = os.path.join(PROJ_PATH, "tests", "http", "config.ini")
     if not os.path.exists(ALT_CONFIG_PATH):
-        raise Exception(f'unable to find config.ini in {CONFIG_PATH} nor {ALT_CONFIG_PATH}')
+        raise Exception(
+            f"unable to find config.ini in {CONFIG_PATH} nor {ALT_CONFIG_PATH}"
+        )
     TOP_PATH = PROJ_PATH
     CONFIG_PATH = ALT_CONFIG_PATH
 DEF_CONFIG = init_config_from(CONFIG_PATH)
-CURL = os.path.join(TOP_PATH, 'src', 'curl')
-CURLINFO = os.path.join(TOP_PATH, 'src', 'curlinfo')
+CURL = os.path.join(TOP_PATH, "src", "curl")
+CURLINFO = os.path.join(TOP_PATH, "src", "curlinfo")
 
 
 class NghttpxUtil:
-
     CMD = None
     VERSION_FULL = None
 
@@ -76,34 +77,37 @@ class NghttpxUtil:
         if cmd is None:
             return None
         if cls.VERSION_FULL is None or cmd != cls.CMD:
-            p = subprocess.run(args=[cmd, '--version'],
-                               capture_output=True, text=True)
+            p = subprocess.run(args=[cmd, "--version"], capture_output=True, text=True)
             if p.returncode != 0:
-                raise RuntimeError(f'{cmd} --version failed with exit code: {p.returncode}')
+                raise RuntimeError(
+                    f"{cmd} --version failed with exit code: {p.returncode}"
+                )
             cls.CMD = cmd
             for line in p.stdout.splitlines(keepends=False):
-                if line.startswith('nghttpx '):
+                if line.startswith("nghttpx "):
                     cls.VERSION_FULL = line
             if cls.VERSION_FULL is None:
-                raise RuntimeError(f'{cmd}: unable to determine version')
+                raise RuntimeError(f"{cmd}: unable to determine version")
         return cls.VERSION_FULL
 
     @staticmethod
     def version_with_h3(version):
-        return re.match(r'.* ngtcp2/\d+\.\d+\.\d+.*', version) is not None
+        return re.match(r".* ngtcp2/\d+\.\d+\.\d+.*", version) is not None
 
 
 class EnvConfig:
-
-    def __init__(self, pytestconfig: Optional[pytest.Config] = None,
-                 testrun_uid=None,
-                 worker_id=None):
+    def __init__(
+        self,
+        pytestconfig: Optional[pytest.Config] = None,
+        testrun_uid=None,
+        worker_id=None,
+    ):
         self.pytestconfig = pytestconfig
         self.testrun_uid = testrun_uid
-        self.worker_id = worker_id if worker_id is not None else 'master'
+        self.worker_id = worker_id if worker_id is not None else "master"
         self.tests_dir = TESTS_HTTPD_PATH
-        self.gen_root = self.gen_dir = os.path.join(self.tests_dir, 'gen')
-        if self.worker_id != 'master':
+        self.gen_root = self.gen_dir = os.path.join(self.tests_dir, "gen")
+        if self.worker_id != "master":
             self.gen_dir = os.path.join(self.gen_dir, self.worker_id)
         self.project_dir = os.path.dirname(os.path.dirname(self.tests_dir))
         self.build_dir = TOP_PATH
@@ -111,57 +115,56 @@ class EnvConfig:
         # check cur and its features
         self.curl = CURL
         self.curlinfo = CURLINFO
-        if 'CURL' in os.environ:
-            self.curl = os.environ['CURL']
+        if "CURL" in os.environ:
+            self.curl = os.environ["CURL"]
         self.curl_props = {
-            'version_string': '',
-            'version': '',
-            'os': '',
-            'fullname': '',
-            'features_string': '',
-            'features': set(),
-            'protocols_string': '',
-            'protocols': set(),
-            'libs': set(),
-            'lib_versions': set(),
+            "version_string": "",
+            "version": "",
+            "os": "",
+            "fullname": "",
+            "features_string": "",
+            "features": set(),
+            "protocols_string": "",
+            "protocols": set(),
+            "libs": set(),
+            "lib_versions": set(),
         }
         self.curl_is_debug = False
         self.curl_protos = []
-        p = subprocess.run(args=[self.curl, '-V'],
-                           capture_output=True, text=True)
+        p = subprocess.run(args=[self.curl, "-V"], capture_output=True, text=True)
         if p.returncode != 0:
-            raise RuntimeError(f'{self.curl} -V failed with exit code: {p.returncode}')
-        if p.stderr.startswith('WARNING:'):
+            raise RuntimeError(f"{self.curl} -V failed with exit code: {p.returncode}")
+        if p.stderr.startswith("WARNING:"):
             self.curl_is_debug = True
         for line in p.stdout.splitlines(keepends=False):
-            if line.startswith('curl '):
-                self.curl_props['version_string'] = line
-                m = re.match(r'^curl (?P<version>\S+) (?P<os>\S+) (?P<libs>.*)$', line)
+            if line.startswith("curl "):
+                self.curl_props["version_string"] = line
+                m = re.match(r"^curl (?P<version>\S+) (?P<os>\S+) (?P<libs>.*)$", line)
                 if m:
-                    self.curl_props['fullname'] = m.group(0)
-                    self.curl_props['version'] = m.group('version')
-                    self.curl_props['os'] = m.group('os')
-                    self.curl_props['lib_versions'] = {
-                        lib.lower() for lib in m.group('libs').split(' ')
+                    self.curl_props["fullname"] = m.group(0)
+                    self.curl_props["version"] = m.group("version")
+                    self.curl_props["os"] = m.group("os")
+                    self.curl_props["lib_versions"] = {
+                        lib.lower() for lib in m.group("libs").split(" ")
                     }
-                    self.curl_props['libs'] = {
-                        re.sub(r'/[a-z0-9.-]*', '', lib) for lib in self.curl_props['lib_versions']
+                    self.curl_props["libs"] = {
+                        re.sub(r"/[a-z0-9.-]*", "", lib)
+                        for lib in self.curl_props["lib_versions"]
                     }
-            if line.startswith('Features: '):
-                self.curl_props['features_string'] = line[10:]
-                self.curl_props['features'] = {
-                    feat.lower() for feat in line[10:].split(' ')
+            if line.startswith("Features: "):
+                self.curl_props["features_string"] = line[10:]
+                self.curl_props["features"] = {
+                    feat.lower() for feat in line[10:].split(" ")
                 }
-            if line.startswith('Protocols: '):
-                self.curl_props['protocols_string'] = line[11:]
-                self.curl_props['protocols'] = {
-                    prot.lower() for prot in line[11:].split(' ')
+            if line.startswith("Protocols: "):
+                self.curl_props["protocols_string"] = line[11:]
+                self.curl_props["protocols"] = {
+                    prot.lower() for prot in line[11:].split(" ")
                 }
 
-        p = subprocess.run(args=[self.curlinfo],
-                           capture_output=True, text=True)
+        p = subprocess.run(args=[self.curlinfo], capture_output=True, text=True)
         if p.returncode != 0:
-            raise RuntimeError(f'{self.curlinfo} failed with exit code: {p.returncode}')
+            raise RuntimeError(f"{self.curlinfo} failed with exit code: {p.returncode}")
         self.curl_is_verbose = 'verbose-strings: ON' in p.stdout
         self.curl_can_cert_status = 'cert-status: ON' in p.stdout
         self.curl_override_dns = 'override-dns: ON' in p.stdout
@@ -169,18 +172,18 @@ class EnvConfig:
 
         self.ports = {}
 
-        self.httpd = self.config['httpd']['httpd']
-        self.apxs = self.config['httpd']['apxs']
+        self.httpd = self.config["httpd"]["httpd"]
+        self.apxs = self.config["httpd"]["apxs"]
         if len(self.apxs) == 0:
             self.apxs = None
         self._httpd_version = None
 
         self.examples_pem = {
-            'key': 'xxx',
-            'cert': 'xxx',
+            "key": "xxx",
+            "cert": "xxx",
         }
-        self.htdocs_dir = os.path.join(self.gen_dir, 'htdocs')
-        self.tld = 'http.curl.se'
+        self.htdocs_dir = os.path.join(self.gen_dir, "htdocs")
+        self.tld = "http.curl.se"
         self.domain1 = f"one.{self.tld}"
         self.domain1brotli = f"brotli.one.{self.tld}"
         self.domain2 = f"two.{self.tld}"
@@ -188,22 +191,43 @@ class EnvConfig:
         self.proxy_domain = f"proxy.{self.tld}"
         self.expired_domain = f"expired.{self.tld}"
         self.cert_specs = [
-            CertificateSpec(domains=[self.domain1, self.domain1brotli, 'localhost', '127.0.0.1'], key_type='rsa2048'),
-            CertificateSpec(name='domain1-no-ip', domains=[self.domain1, self.domain1brotli], key_type='rsa2048'),
-            CertificateSpec(name='domain1-very-bad', domains=[self.domain1, 'dns:127.0.0.1'], key_type='rsa2048'),
-            CertificateSpec(domains=[self.domain2], key_type='rsa2048'),
-            CertificateSpec(domains=[self.ftp_domain], key_type='rsa2048'),
-            CertificateSpec(domains=[self.proxy_domain, '127.0.0.1'], key_type='rsa2048'),
-            CertificateSpec(domains=[self.expired_domain], key_type='rsa2048',
-                            valid_from=timedelta(days=-100), valid_to=timedelta(days=-10)),
-            CertificateSpec(name="clientsX", sub_specs=[
-               CertificateSpec(name="user1", client=True),
-            ]),
+            CertificateSpec(
+                domains=[self.domain1, self.domain1brotli, "localhost", "127.0.0.1"],
+                key_type="rsa2048",
+            ),
+            CertificateSpec(
+                name="domain1-no-ip",
+                domains=[self.domain1, self.domain1brotli],
+                key_type="rsa2048",
+            ),
+            CertificateSpec(
+                name="domain1-very-bad",
+                domains=[self.domain1, "dns:127.0.0.1"],
+                key_type="rsa2048",
+            ),
+            CertificateSpec(domains=[self.domain2], key_type="rsa2048"),
+            CertificateSpec(domains=[self.ftp_domain], key_type="rsa2048"),
+            CertificateSpec(
+                domains=[self.proxy_domain, "127.0.0.1"], key_type="rsa2048"
+            ),
+            CertificateSpec(
+                domains=[self.expired_domain],
+                key_type="rsa2048",
+                valid_from=timedelta(days=-100),
+                valid_to=timedelta(days=-10),
+            ),
+            CertificateSpec(
+                name="clientsX",
+                sub_specs=[
+                    CertificateSpec(name="user1", client=True),
+                ],
+            ),
         ]
 
-        self.openssl = 'openssl'
-        p = subprocess.run(args=[self.openssl, 'version'],
-                           capture_output=True, text=True)
+        self.openssl = "openssl"
+        p = subprocess.run(
+            args=[self.openssl, "version"], capture_output=True, text=True
+        )
         if p.returncode != 0:
             # no openssl in path
             self.openssl = None
@@ -211,7 +235,7 @@ class EnvConfig:
         else:
             self.openssl_version = p.stdout.strip()
 
-        self.nghttpx = self.config['nghttpx']['nghttpx']
+        self.nghttpx = self.config["nghttpx"]["nghttpx"]
         if len(self.nghttpx.strip()) == 0:
             self.nghttpx = None
         self._nghttpx_version = None
@@ -220,30 +244,58 @@ class EnvConfig:
             self._nghttpx_version = NghttpxUtil.version(self.nghttpx)
             self.nghttpx_with_h3 = NghttpxUtil.version_with_h3(self._nghttpx_version)
 
-        self.caddy = self.config['caddy']['caddy']
+        self.caddy = self.config["caddy"]["caddy"]
         self._caddy_version = None
         if len(self.caddy.strip()) == 0:
             self.caddy = None
+
+        self.h2o = self.config["h2o"]["h2o"]
+        if len(self.h2o.strip()) == 0:
+            self.h2o = None
+        self._h2o_version = None
+        if self.h2o is not None:
+            try:
+                p = subprocess.run(
+                    args=[self.h2o, "--version"], capture_output=True, text=True
+                )
+                if p.returncode != 0:
+                    # not a working h2o
+                    self.h2o = None
+                else:
+                    # h2o --version output format: "h2o version 2.3.0"
+                    m = re.search(r"h2o version (\S+)", p.stdout)
+                    if m:
+                        self._h2o_version = m.group(1)
+                    else:
+                        self.h2o = None
+            except Exception:
+                log.exception("checking h2o version")
+                self.h2o = None
+
         if self.caddy is not None:
-            p = subprocess.run(args=[self.caddy, 'version'],
-                               capture_output=True, text=True)
+            p = subprocess.run(
+                args=[self.caddy, "version"], capture_output=True, text=True
+            )
             if p.returncode != 0:
                 # not a working caddy
                 self.caddy = None
-            m = re.match(r'v?(\d+\.\d+\.\d+).*', p.stdout)
+            m = re.match(r"v?(\d+\.\d+\.\d+).*", p.stdout)
             if m:
                 self._caddy_version = m.group(1)
             else:
-                raise RuntimeError(f'Unable to determine caddy version from: {p.stdout}')
+                raise RuntimeError(
+                    f"Unable to determine caddy version from: {p.stdout}"
+                )
 
-        self.vsftpd = self.config['vsftpd']['vsftpd']
-        if self.vsftpd == '':
+        self.vsftpd = self.config["vsftpd"]["vsftpd"]
+        if self.vsftpd == "":
             self.vsftpd = None
         self._vsftpd_version = None
         if self.vsftpd is not None:
-            with tempfile.TemporaryFile('w+') as tmp:
-                p = subprocess.run(args=[self.vsftpd, '-v'],
-                                   capture_output=True, text=True, stdin=tmp)
+            with tempfile.TemporaryFile("w+") as tmp:
+                p = subprocess.run(
+                    args=[self.vsftpd, "-v"], capture_output=True, text=True, stdin=tmp
+                )
                 if p.returncode != 0:
                     # not a working vsftpd
                     self.vsftpd = None
@@ -256,80 +308,83 @@ class EnvConfig:
                     # any data there instead.
                     tmp.seek(0)
                     ver_text = tmp.read()
-            m = re.match(r'vsftpd: version (\d+\.\d+\.\d+)', ver_text)
+            m = re.match(r"vsftpd: version (\d+\.\d+\.\d+)", ver_text)
             if m:
                 self._vsftpd_version = m.group(1)
             elif len(p.stderr) == 0:
                 # vsftp does not use stdout or stderr for printing its version... -.-
-                self._vsftpd_version = 'unknown'
+                self._vsftpd_version = "unknown"
             else:
-                raise Exception(f'Unable to determine VsFTPD version from: {p.stderr}')
+                raise Exception(f"Unable to determine VsFTPD version from: {p.stderr}")
 
-        self.danted = self.config['danted']['danted']
-        if self.danted == '':
+        self.danted = self.config["danted"]["danted"]
+        if self.danted == "":
             self.danted = None
         self._danted_version = None
         if self.danted is not None:
-            p = subprocess.run(args=[self.danted, '-v'],
-                               capture_output=True, text=True)
+            p = subprocess.run(args=[self.danted, "-v"], capture_output=True, text=True)
             assert p.returncode == 0
             if p.returncode != 0:
                 # not a working vsftpd
                 self.danted = None
-            m = re.match(r'^Dante v(\d+\.\d+\.\d+).*', p.stdout)
+            m = re.match(r"^Dante v(\d+\.\d+\.\d+).*", p.stdout)
             if not m:
-                m = re.match(r'^Dante v(\d+\.\d+\.\d+).*', p.stderr)
+                m = re.match(r"^Dante v(\d+\.\d+\.\d+).*", p.stderr)
             if m:
                 self._danted_version = m.group(1)
             else:
                 self.danted = None
-                raise Exception(f'Unable to determine danted version from: {p.stderr}')
+                raise Exception(f"Unable to determine danted version from: {p.stderr}")
 
-        self.sshd = self.config['sshd']['sshd']
-        if self.sshd == '':
+        self.sshd = self.config["sshd"]["sshd"]
+        if self.sshd == "":
             self.sshd = None
         self._sshd_version = None
         if self.sshd is not None:
-            p = subprocess.run(args=[self.sshd, '-V'],
-                               capture_output=True, text=True)
+            p = subprocess.run(args=[self.sshd, "-V"], capture_output=True, text=True)
             assert p.returncode == 0
             if p.returncode != 0:
                 self.sshd = None
             else:
-                m = re.match(r'^OpenSSH_(\d+\.\d+.*),.*', p.stderr)
-                assert m, f'version: {p.stderr}'
+                m = re.match(r"^OpenSSH_(\d+\.\d+.*),.*", p.stderr)
+                assert m, f"version: {p.stderr}"
                 if m:
                     self._sshd_version = m.group(1)
                 else:
                     self.sshd = None
-                    raise Exception(f'Unable to determine sshd version from: {p.stderr}')
+                    raise Exception(
+                        f"Unable to determine sshd version from: {p.stderr}"
+                    )
 
         if self.sshd:
-            self.sftpd = self.config['sshd']['sftpd']
-            if self.sftpd == '':
+            self.sftpd = self.config["sshd"]["sftpd"]
+            if self.sftpd == "":
                 self.sftpd = None
         else:
             self.sftpd = None
 
-        self._tcpdump = shutil.which('tcpdump')
+        self._tcpdump = shutil.which("tcpdump")
 
     @property
     def httpd_version(self):
         if self._httpd_version is None and self.apxs is not None:
             try:
-                p = subprocess.run(args=[self.apxs, '-q', 'HTTPD_VERSION'],
-                                   capture_output=True, text=True)
+                p = subprocess.run(
+                    args=[self.apxs, "-q", "HTTPD_VERSION"],
+                    capture_output=True,
+                    text=True,
+                )
                 if p.returncode != 0:
-                    log.error(f'{self.apxs} failed to query HTTPD_VERSION: {p}')
+                    log.error(f"{self.apxs} failed to query HTTPD_VERSION: {p}")
                 else:
                     self._httpd_version = p.stdout.strip()
             except Exception:
-                log.exception(f'{self.apxs} failed to run')
+                log.exception(f"{self.apxs} failed to run")
         return self._httpd_version
 
     def versiontuple(self, v):
-        v = re.sub(r'(\d+\.\d+(\.\d+)?)(-\S+)?', r'\1', v)
-        return tuple(map(int, v.split('.')))
+        v = re.sub(r"(\d+\.\d+(\.\d+)?)(-\S+)?", r"\1", v)
+        return tuple(map(int, v.split(".")))
 
     def httpd_is_at_least(self, minv):
         if self.httpd_version is None:
@@ -344,15 +399,17 @@ class EnvConfig:
         return hv >= self.versiontuple(minv)
 
     def is_complete(self) -> bool:
-        return os.path.isfile(self.httpd) and \
-               self.apxs is not None and \
-               os.path.isfile(self.apxs)
+        return (
+            os.path.isfile(self.httpd)
+            and self.apxs is not None
+            and os.path.isfile(self.apxs)
+        )
 
     def get_incomplete_reason(self) -> Optional[str]:
         if self.httpd is None or len(self.httpd.strip()) == 0:
-            return 'httpd not configured, see `--with-test-httpd=<path>`'
+            return "httpd not configured, see `--with-test-httpd=<path>`"
         if not os.path.isfile(self.httpd):
-            return f'httpd ({self.httpd}) not found'
+            return f"httpd ({self.httpd}) not found"
         if self.apxs is None:
             return "command apxs not found (commonly provided in apache2-dev)"
         if not os.path.isfile(self.apxs):
@@ -371,18 +428,21 @@ class EnvConfig:
     def vsftpd_version(self):
         return self._vsftpd_version
 
+    @property
+    def h2o_version(self):
+        return self._h2o_version
+
     @property
     def tcpdmp(self) -> Optional[str]:
         return self._tcpdump
 
     def clear_locks(self):
-        ca_lock = os.path.join(self.gen_root, 'ca/ca.lock')
+        ca_lock = os.path.join(self.gen_root, "ca/ca.lock")
         if os.path.exists(ca_lock):
             os.remove(ca_lock)
 
 
 class Env:
-
     SERVER_TIMEOUT = 30  # seconds to wait for server to come up/reload
 
     CONFIG = EnvConfig()
@@ -407,98 +467,106 @@ class Env:
     def have_h3_server() -> bool:
         return Env.CONFIG.nghttpx_with_h3
 
+    @staticmethod
+    def have_h2o() -> bool:
+        return Env.CONFIG.h2o is not None
+
     @staticmethod
     def have_ssl_curl() -> bool:
-        return Env.curl_has_feature('ssl') or Env.curl_has_feature('multissl')
+        return Env.curl_has_feature("ssl") or Env.curl_has_feature("multissl")
 
     @staticmethod
     def have_h2_curl() -> bool:
-        return 'http2' in Env.CONFIG.curl_props['features']
+        return "http2" in Env.CONFIG.curl_props["features"]
 
     @staticmethod
     def have_h3_curl() -> bool:
-        return 'http3' in Env.CONFIG.curl_props['features']
+        return "http3" in Env.CONFIG.curl_props["features"]
 
     @staticmethod
     def have_compressed_curl() -> bool:
-        return 'brotli' in Env.CONFIG.curl_props['libs'] or \
-               'zlib' in Env.CONFIG.curl_props['libs'] or \
-               'zstd' in Env.CONFIG.curl_props['libs']
+        return (
+            "brotli" in Env.CONFIG.curl_props["libs"]
+            or "zlib" in Env.CONFIG.curl_props["libs"]
+            or "zstd" in Env.CONFIG.curl_props["libs"]
+        )
 
     @staticmethod
     def curl_uses_lib(libname: str) -> bool:
-        return libname.lower() in Env.CONFIG.curl_props['libs']
+        return libname.lower() in Env.CONFIG.curl_props["libs"]
 
     @staticmethod
     def curl_uses_any_libs(libs: List[str]) -> bool:
         for libname in libs:
-            if libname.lower() in Env.CONFIG.curl_props['libs']:
+            if libname.lower() in Env.CONFIG.curl_props["libs"]:
                 return True
         return False
 
     @staticmethod
     def curl_uses_ossl_quic() -> bool:
         if Env.have_h3_curl():
-            return not Env.curl_uses_lib('ngtcp2') and Env.curl_uses_lib('nghttp3')
+            return not Env.curl_uses_lib("ngtcp2") and Env.curl_uses_lib("nghttp3")
         return False
 
     @staticmethod
     def curl_version_string() -> str:
-        return Env.CONFIG.curl_props['version_string']
+        return Env.CONFIG.curl_props["version_string"]
 
     @staticmethod
     def curl_features_string() -> str:
-        return Env.CONFIG.curl_props['features_string']
+        return Env.CONFIG.curl_props["features_string"]
 
     @staticmethod
     def curl_has_feature(feature: str) -> bool:
-        return feature.lower() in Env.CONFIG.curl_props['features']
+        return feature.lower() in Env.CONFIG.curl_props["features"]
 
     @staticmethod
     def curl_protocols_string() -> str:
-        return Env.CONFIG.curl_props['protocols_string']
+        return Env.CONFIG.curl_props["protocols_string"]
 
     @staticmethod
     def curl_has_protocol(protocol: str) -> bool:
-        return protocol.lower() in Env.CONFIG.curl_props['protocols']
+        return protocol.lower() in Env.CONFIG.curl_props["protocols"]
 
     @staticmethod
     def curl_lib_version(libname: str) -> str:
-        prefix = f'{libname.lower()}/'
-        for lversion in Env.CONFIG.curl_props['lib_versions']:
+        prefix = f"{libname.lower()}/"
+        for lversion in Env.CONFIG.curl_props["lib_versions"]:
             if lversion.startswith(prefix):
-                return lversion[len(prefix):]
-        return 'unknown'
+                return lversion[len(prefix) :]
+        return "unknown"
 
     @staticmethod
     def curl_lib_version_at_least(libname: str, min_version) -> bool:
         lversion = Env.curl_lib_version(libname)
-        if lversion != 'unknown':
-            return Env.CONFIG.versiontuple(min_version) <= \
-                   Env.CONFIG.versiontuple(lversion)
+        if lversion != "unknown":
+            return Env.CONFIG.versiontuple(min_version) <= Env.CONFIG.versiontuple(
+                lversion
+            )
         return False
 
     @staticmethod
     def curl_lib_version_before(libname: str, lib_version) -> bool:
         lversion = Env.curl_lib_version(libname)
-        if lversion != 'unknown':
-            if m := re.match(r'(\d+\.\d+\.\d+).*', lversion):
+        if lversion != "unknown":
+            if m := re.match(r"(\d+\.\d+\.\d+).*", lversion):
                 lversion = m.group(1)
-            return Env.CONFIG.versiontuple(lib_version) > \
-                Env.CONFIG.versiontuple(lversion)
+            return Env.CONFIG.versiontuple(lib_version) > Env.CONFIG.versiontuple(
+                lversion
+            )
         return False
 
     @staticmethod
     def curl_os() -> str:
-        return Env.CONFIG.curl_props['os']
+        return Env.CONFIG.curl_props["os"]
 
     @staticmethod
     def curl_fullname() -> str:
-        return Env.CONFIG.curl_props['fullname']
+        return Env.CONFIG.curl_props["fullname"]
 
     @staticmethod
     def curl_version() -> str:
-        return Env.CONFIG.curl_props['version']
+        return Env.CONFIG.curl_props["version"]
 
     @staticmethod
     def curl_is_debug() -> bool:
@@ -528,32 +596,31 @@ class Env:
 
     @staticmethod
     def curl_can_h3_early_data() -> bool:
-        return Env.curl_can_early_data() and \
-            Env.curl_uses_lib('ngtcp2')
+        return Env.curl_can_early_data() and Env.curl_uses_lib("ngtcp2")
 
     @staticmethod
     def http_protos() -> List[str]:
         # http protocols we can test
         if Env.have_h2_curl():
             if Env.have_h3():
-                return ['http/1.1', 'h2', 'h3']
-            return ['http/1.1', 'h2']
-        return ['http/1.1']
+                return ["http/1.1", "h2", "h3"]
+            return ["http/1.1", "h2"]
+        return ["http/1.1"]
 
     @staticmethod
     def http_h1_h2_protos() -> List[str]:
         # http 1+2 protocols we can test
         if Env.have_h2_curl():
-            return ['http/1.1', 'h2']
-        return ['http/1.1']
+            return ["http/1.1", "h2"]
+        return ["http/1.1"]
 
     @staticmethod
     def http_mplx_protos() -> List[str]:
         # http multiplexing protocols we can test
         if Env.have_h2_curl():
             if Env.have_h3():
-                return ['h2', 'h3']
-            return ['h2']
+                return ["h2", "h3"]
+            return ["h2"]
         return []
 
     @staticmethod
@@ -572,6 +639,10 @@ class Env:
     def caddy_version() -> str:
         return Env.CONFIG.caddy_version
 
+    @staticmethod
+    def h2o_version() -> str:
+        return Env.CONFIG.h2o_version
+
     @staticmethod
     def caddy_is_at_least(minv) -> bool:
         return Env.CONFIG.caddy_is_at_least(minv)
@@ -611,21 +682,20 @@ class Env:
     def __init__(self, pytestconfig=None, env_config=None):
         if env_config:
             Env.CONFIG = env_config
-        self._verbose = pytestconfig.option.verbose \
-            if pytestconfig is not None else 0
+        self._verbose = pytestconfig.option.verbose if pytestconfig is not None else 0
         self._ca = None
         self._test_timeout = 300.0 if self._verbose > 1 else 60.0  # seconds
 
     def issue_certs(self):
         if self._ca is None:
             # ca_dir = os.path.join(self.CONFIG.gen_root, 'ca')
-            ca_dir = os.path.join(self.gen_dir, 'ca')
+            ca_dir = os.path.join(self.gen_dir, "ca")
             os.makedirs(ca_dir, exist_ok=True)
-            lock_file = os.path.join(ca_dir, 'ca.lock')
+            lock_file = os.path.join(ca_dir, "ca.lock")
             with FileLock(lock_file):
-                self._ca = TestCA.create_root(name=self.CONFIG.tld,
-                                              store_dir=ca_dir,
-                                              key_type="rsa2048")
+                self._ca = TestCA.create_root(
+                    name=self.CONFIG.tld, store_dir=ca_dir, key_type="rsa2048"
+                )
                 self._ca.issue_certs(self.CONFIG.cert_specs)
                 if self.have_openssl():
                     self._ca.create_hashdir(self.openssl)
@@ -714,19 +784,19 @@ class Env:
 
     @property
     def http_port(self) -> int:
-        return self.CONFIG.ports.get('http', 0)
+        return self.CONFIG.ports.get("http", 0)
 
     @property
     def https_port(self) -> int:
-        return self.CONFIG.ports['https']
+        return self.CONFIG.ports["https"]
 
     @property
     def https_only_tcp_port(self) -> int:
-        return self.CONFIG.ports['https-tcp-only']
+        return self.CONFIG.ports["https-tcp-only"]
 
     @property
     def nghttpx_https_port(self) -> int:
-        return self.CONFIG.ports['nghttpx_https']
+        return self.CONFIG.ports["nghttpx_https"]
 
     @property
     def h3_port(self) -> int:
@@ -734,27 +804,35 @@ class Env:
 
     @property
     def proxy_port(self) -> int:
-        return self.CONFIG.ports['proxy']
+        return self.CONFIG.ports["proxy"]
 
     @property
     def proxys_port(self) -> int:
-        return self.CONFIG.ports['proxys']
+        return self.CONFIG.ports["proxys"]
 
     @property
     def ftp_port(self) -> int:
-        return self.CONFIG.ports['ftp']
+        return self.CONFIG.ports["ftp"]
 
     @property
     def ftps_port(self) -> int:
-        return self.CONFIG.ports['ftps']
+        return self.CONFIG.ports["ftps"]
 
     @property
     def h2proxys_port(self) -> int:
-        return self.CONFIG.ports['h2proxys']
+        return self.CONFIG.ports["h2proxys"]
+
+    @property
+    def h3proxys_port(self) -> int:
+        return self.CONFIG.ports["h3proxys"]
 
-    def pts_port(self, proto: str = 'http/1.1') -> int:
+    def pts_port(self, proto: str = "http/1.1") -> int:
         # proxy tunnel port
-        return self.CONFIG.ports['h2proxys' if proto == 'h2' else 'proxys']
+        if proto == "h3":
+            return self.CONFIG.ports["h3proxys"]
+        if proto == "h2":
+            return self.CONFIG.ports["h2proxys"]
+        return self.CONFIG.ports["proxys"]
 
     @property
     def caddy(self) -> str:
@@ -762,11 +840,11 @@ class Env:
 
     @property
     def caddy_https_port(self) -> int:
-        return self.CONFIG.ports['caddys']
+        return self.CONFIG.ports["caddys"]
 
     @property
     def caddy_http_port(self) -> int:
-        return self.CONFIG.ports['caddy']
+        return self.CONFIG.ports["caddy"]
 
     @property
     def danted(self) -> str:
@@ -778,7 +856,7 @@ class Env:
 
     @property
     def ws_port(self) -> int:
-        return self.CONFIG.ports['ws']
+        return self.CONFIG.ports["ws"]
 
     @property
     def curl(self) -> str:
@@ -802,58 +880,65 @@ class Env:
 
     @property
     def slow_network(self) -> bool:
-        return "CURL_DBG_SOCK_WBLOCK" in os.environ or \
-               "CURL_DBG_SOCK_WPARTIAL" in os.environ
+        return (
+            "CURL_DBG_SOCK_WBLOCK" in os.environ
+            or "CURL_DBG_SOCK_WPARTIAL" in os.environ
+        )
 
     @property
     def ci_run(self) -> bool:
         return "CURL_CI" in os.environ
 
     def port_for(self, alpn_proto: Optional[str] = None):
-        if alpn_proto is None or \
-                alpn_proto in ['h2', 'http/1.1', 'http/1.0', 'http/0.9']:
+        if alpn_proto is None or alpn_proto in [
+            "h2",
+            "http/1.1",
+            "http/1.0",
+            "http/0.9",
+        ]:
             return self.https_port
-        if alpn_proto in ['h3']:
+        if alpn_proto in ["h3"]:
             return self.h3_port
         return self.http_port
 
     def authority_for(self, domain: str, alpn_proto: Optional[str] = None):
-        return f'{domain}:{self.port_for(alpn_proto=alpn_proto)}'
+        return f"{domain}:{self.port_for(alpn_proto=alpn_proto)}"
 
-    def make_data_file(self, indir: str, fname: str, fsize: int,
-                       line_length: int = 1024) -> str:
+    def make_data_file(
+        self, indir: str, fname: str, fsize: int, line_length: int = 1024
+    ) -> str:
         if line_length < 11:
-            raise RuntimeError('line_length less than 11 not supported')
+            raise RuntimeError("line_length less than 11 not supported")
         fpath = os.path.join(indir, fname)
         s10 = "0123456789"
         s = round((line_length / 10) + 1) * s10
-        s = s[0:line_length-11]
-        with open(fpath, 'w') as fd:
+        s = s[0 : line_length - 11]
+        with open(fpath, "w") as fd:
             for i in range(int(fsize / line_length)):
                 fd.write(f"{i:09d}-{s}\n")
             remain = int(fsize % line_length)
             if remain != 0:
                 i = int(fsize / line_length) + 1
-                fd.write(f"{i:09d}-{s}"[0:remain-1] + "\n")
+                fd.write(f"{i:09d}-{s}"[0 : remain - 1] + "\n")
         return fpath
 
     def make_data_gzipbomb(self, indir: str, fname: str, fsize: int) -> str:
         fpath = os.path.join(indir, fname)
-        gzpath = f'{fpath}.gz'
-        varpath = f'{fpath}.var'
+        gzpath = f"{fpath}.gz"
+        varpath = f"{fpath}.var"
 
-        with open(fpath, 'w') as fd:
-            fd.write('not what we are looking for!\n')
+        with open(fpath, "w") as fd:
+            fd.write("not what we are looking for!\n")
         count = int(fsize / 1024)
         zero1k = bytearray(1024)
-        with gzip.open(gzpath, 'wb') as fd:
+        with gzip.open(gzpath, "wb") as fd:
             for _ in range(count):
                 fd.write(zero1k)
-        with open(varpath, 'w') as fd:
-            fd.write(f'URI: {fname}\n')
-            fd.write('\n')
-            fd.write(f'URI: {fname}.gz\n')
-            fd.write('Content-Type: text/plain\n')
-            fd.write('Content-Encoding: x-gzip\n')
-            fd.write('\n')
+        with open(varpath, "w") as fd:
+            fd.write(f"URI: {fname}\n")
+            fd.write("\n")
+            fd.write(f"URI: {fname}.gz\n")
+            fd.write("Content-Type: text/plain\n")
+            fd.write("Content-Encoding: x-gzip\n")
+            fd.write("\n")
         return fpath
diff --git a/tests/http/testenv/h2o.py b/tests/http/testenv/h2o.py
new file mode 100644 (file)
index 0000000..6a55f48
--- /dev/null
@@ -0,0 +1,428 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# ***************************************************************************
+#                                  _   _ ____  _
+#  Project                     ___| | | |  _ \| |
+#                             / __| | | | |_) | |
+#                            | (__| |_| |  _ <| |___
+#                             \___|\___/|_| \_\_____|
+#
+# Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at https://curl.se/docs/copyright.html.
+#
+# You may opt to use, copy, modify, merge, publish, distribute and/or sell
+# copies of the Software, and permit persons to whom the Software is
+# furnished to do so, under the terms of the COPYING file.
+#
+# This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
+# KIND, either express or implied.
+#
+# SPDX-License-Identifier: curl
+#
+###########################################################################
+#
+import logging
+import os
+import signal
+import socket
+import subprocess
+import time
+from datetime import datetime, timedelta
+from typing import Dict, Optional
+
+from .curl import CurlClient
+from .env import Env
+from .ports import alloc_ports_and_do
+
+log = logging.getLogger(__name__)
+
+
+class H2o:
+    def __init__(self, env: Env, name: str, domain: str, cred_name: str):
+        self.env = env
+        self._name = name
+        self._domain = domain
+        self._port = 0  # defaults to h3_port
+        self._cred_name = cred_name
+        self._loaded_cred_name = None
+        self._process = None
+        self._tmp_dir = os.path.join(self.env.gen_dir, self._name)
+        self._run_dir = os.path.join(self._tmp_dir, "run")
+        self._conf_file = os.path.join(self._run_dir, "h2o.conf")
+        self._error_log = os.path.join(self._run_dir, "h2o.log")
+        self._pid_file = os.path.join(self._run_dir, "h2o.pid")
+        self._stderr = os.path.join(self._run_dir, "h2o.stderr")
+        self._cmd = env.CONFIG.h2o
+        # For proxy subclasses
+        self._h1_port = None
+        self._h2_port = None
+
+    @property
+    def port(self) -> int:
+        return self._port
+
+    @property
+    def h1_port(self) -> Optional[int]:
+        return getattr(self, "_h1_port", None)
+
+    @property
+    def h2_port(self) -> Optional[int]:
+        return getattr(self, "_h2_port", None)
+
+    def clear_logs(self):
+        self._rmf(self._error_log)
+        self._rmf(self._stderr)
+
+    def dump_logs(self):
+        lines = []
+        lines.append(f"stderr of {self._name}")
+        lines.append("-------------------------------------------")
+        self._dump_file(self._stderr, lines)
+        lines.append("")
+        lines.append(f"errorlog of {self._name}")
+        lines.append("-------------------------------------------")
+        self._dump_file(self._error_log, lines)
+        lines.append("")
+        return lines
+
+    def _rmf(self, path):
+        if os.path.isfile(path):
+            os.remove(path)
+        return
+
+    def _dump_file(self, path, lines):
+        if os.path.isfile(path):
+            with open(path) as fd:
+                for line in fd:
+                    lines.append(line.rstrip())
+
+    def _mkpath(self, path):
+        if not os.path.exists(path):
+            os.makedirs(path)
+        return
+
+    def _log(self, level, msg):
+        getattr(log, level)(f"[{self._name}] {msg}")
+
+    def is_running(self):
+        if self._process:
+            self._process.poll()
+            return self._process.returncode is None
+        return False
+
+    def initial_start(self):
+        self._rmf(self._pid_file)
+        self._rmf(self._error_log)
+        self._mkpath(self._run_dir)
+        self.write_config()
+
+    def start(self, wait_live=True):
+        self._mkpath(self._tmp_dir)
+        self._mkpath(self._run_dir)
+        if self._process:
+            self.stop()
+        self._loaded_cred_name = self._cred_name
+        self.write_config()
+        args = [self._cmd, "-c", self._conf_file]
+        ngerr = open(self._stderr, "a")
+        self._process = subprocess.Popen(args=args, stderr=ngerr)
+        if self._process.returncode is not None:
+            return False
+        if wait_live:
+            time.sleep(1)
+            # fail fast if h2o rejected the config and already exited
+            self._process.poll()
+            if self._process.returncode is not None:
+                self._log("error",
+                          f"h2o exited early (rc={self._process.returncode})"
+                          f" - check {self._stderr} for details")
+                self._process = None
+                return False
+        return not wait_live or self.wait_for_state(
+            live=True, timeout=timedelta(seconds=Env.SERVER_TIMEOUT)
+        )
+
+    def stop(self, wait_dead=True):
+        self._mkpath(self._tmp_dir)
+        if self._process:
+            self._process.terminate()
+            try:
+                self._process.wait(timeout=5)
+            except subprocess.TimeoutExpired:
+                self._process.kill()
+                self._process.wait(timeout=2)
+            self._process = None
+            return not wait_dead or self.wait_for_state(
+                live=False, timeout=timedelta(seconds=5)
+            )
+        return True
+
+    def restart(self):
+        self.stop()
+        return self.start()
+
+    def reload(self, timeout: timedelta = timedelta(seconds=Env.SERVER_TIMEOUT)):
+        if self._process:
+            running = self._process
+            self._process = None
+            os.kill(running.pid, signal.SIGQUIT)
+            end_wait = datetime.now() + timedelta(seconds=5)
+            exited = False
+            if not self.start(wait_live=False):
+                self._process = running
+                return False
+            while datetime.now() < end_wait:
+                try:
+                    self._log("debug", f"waiting for h2o({running.pid}) to exit.")
+                    running.wait(1)
+                    self._log(
+                        "debug",
+                        f"h2o({running.pid}) terminated -> {running.returncode}",
+                    )
+                    exited = True
+                    break
+                except subprocess.TimeoutExpired:
+                    self._log("warning", f"h2o({running.pid}), not shut down yet.")
+                    os.kill(running.pid, signal.SIGQUIT)
+            if not exited and datetime.now() >= end_wait:
+                self._log("error", f"h2o({running.pid}), terminate forcefully.")
+                os.kill(running.pid, signal.SIGKILL)
+                running.terminate()
+                running.wait(1)
+            return self.wait_for_state(live=True, timeout=timeout)
+        return False
+
+    def wait_for_state(
+        self,
+        live: bool,
+        timeout: timedelta,
+        url: Optional[str] = None,
+        log_prefix: str = "h2o",
+    ):
+        curl = CurlClient(env=self.env, run_dir=self._tmp_dir)
+        try_until = datetime.now() + timeout
+        if url is None:
+            url = f"https://{self._domain}:{self._port}/"
+        while datetime.now() < try_until:
+            if live:
+                r = curl.http_get(
+                    url=url, extra_args=["--trace", "curl.trace", "--trace-time"]
+                )
+                if r.exit_code == 0:
+                    return True
+            else:
+                r = curl.http_get(url=url)
+                if r.exit_code != 0:
+                    return True
+            time.sleep(0.1)
+        if live:
+            self._log("error", f"Server still not responding after {timeout}")
+        else:
+            self._log("debug", f"Server still responding after {timeout}")
+        return False
+
+    def write_config(self):
+        # To be overridden by subclasses
+        with open(self._conf_file, "w") as fd:
+            fd.write("# h2o test config\n")
+
+
+class H2oServer(H2o):
+    """h2o HTTP/3 server for testing."""
+
+    PORT_SPECS = {
+        "h2o_https": socket.SOCK_STREAM,
+    }
+
+    def __init__(self, env: Env):
+        super().__init__(
+            env=env, name="h2o-server", domain=env.domain1, cred_name=env.domain1
+        )
+
+    def initial_start(self):
+        super().initial_start()
+
+        def startup(ports: Dict[str, int]) -> bool:
+            self._port = ports["h2o_https"]
+            if self.start():
+                self.env.update_ports(ports)
+                return True
+            self.stop()
+            self._port = 0
+            return False
+
+        return alloc_ports_and_do(
+            H2oServer.PORT_SPECS, startup, self.env.gen_root, max_tries=3
+        )
+
+    def write_config(self):
+        creds = self.env.get_credentials(self._cred_name)
+        assert creds  # convince pytype this is not None
+        doc_root = os.path.join(self.env.gen_dir, "docs")
+        self._mkpath(doc_root)
+        self._mkpath(self._run_dir)
+        # Create a simple test file
+        with open(os.path.join(doc_root, "data.json"), "w") as f:
+            f.write('{"message": "Hello from h2o HTTP/3 server"}\n')
+        with open(self._conf_file, "w") as fd:
+            fd.write(f"""# h2o HTTP/3 server configuration
+server-name: "h2o-test-server"
+num-threads: 1
+
+listen: &ssl_listen
+  port: {self._port}
+  ssl:
+    certificate-file: {creds.cert_file}
+    key-file: {creds.pkey_file}
+    neverbleed: OFF
+    minimum-version: TLSv1.2
+    ocsp-update-interval: 0
+
+listen:
+  <<: *ssl_listen
+  type: quic
+
+hosts:
+  "{self._domain}":
+    paths:
+      "/":
+        file.dir: {doc_root}
+
+http2-reprioritize-blocking-assets: ON
+
+access-log: {self._run_dir}/access.log
+error-log: {self._error_log}
+""")
+
+
+class H2oProxy(H2o):
+    """h2o MASQUE proxy for testing."""
+
+    def __init__(self, env: Env):
+        super().__init__(
+            env=env,
+            name="h2o-proxy",
+            domain=env.proxy_domain,
+            cred_name=env.proxy_domain,
+        )
+
+    def initial_start(self):
+        super().initial_start()
+
+        def startup(ports: Dict[str, int]) -> bool:
+            self._port = ports["h3proxys"]
+            self._h2_port = ports["h2proxys"]
+            self._h1_port = ports["proxys"]
+            if self.start():
+                self.env.update_ports(ports)
+                return True
+            self.stop()
+            self._port = 0
+            self._h2_port = 0
+            self._h1_port = 0
+            return False
+
+        return alloc_ports_and_do(
+            {
+                "h3proxys": socket.SOCK_DGRAM,
+                "h2proxys": socket.SOCK_STREAM,
+                "proxys": socket.SOCK_STREAM,
+            },
+            startup,
+            self.env.gen_root,
+            max_tries=3,
+        )
+
+    def write_config(self):
+        creds = self.env.get_credentials(self._cred_name)
+        assert creds  # convince pytype this is not None
+        self._mkpath(self._run_dir)
+        with open(self._conf_file, "w") as fd:
+            fd.write(f"""# h2o MASQUE proxy configuration
+server-name: "h2o-test-proxy"
+num-threads: 1
+
+proxy.tunnel: ON
+
+# HTTP/1.1 proxy listener
+listen: &h1_listen
+  port: {getattr(self, "_h1_port", self._port)}
+  ssl:
+    certificate-file: {creds.cert_file}
+    key-file: {creds.pkey_file}
+    neverbleed: OFF
+    minimum-version: TLSv1.2
+    ocsp-update-interval: 0
+
+# HTTP/2 proxy listener
+listen: &h2_listen
+  port: {getattr(self, "_h2_port", self._port)}
+  ssl:
+    certificate-file: {creds.cert_file}
+    key-file: {creds.pkey_file}
+    neverbleed: OFF
+    minimum-version: TLSv1.2
+    ocsp-update-interval: 0
+
+# HTTP/3 proxy listener (main port)
+listen: &h3_listen
+  port: {self._port}
+  ssl:
+    certificate-file: {creds.cert_file}
+    key-file: {creds.pkey_file}
+    neverbleed: OFF
+    minimum-version: TLSv1.2
+    ocsp-update-interval: 0
+
+# QUIC listener for HTTP/3
+listen:
+  <<: *h3_listen
+  type: quic
+
+hosts:
+  "{self._domain}":
+    paths:
+      "/":
+        proxy.connect: [+*]
+        proxy.ssl.verify-peer: OFF
+      "/.well-known/masque/udp":
+        proxy.connect-udp: [+*]
+        proxy.ssl.verify-peer: OFF
+
+http2-reprioritize-blocking-assets: ON
+
+access-log: {self._run_dir}/access.log
+error-log: {self._error_log}
+""")
+
+    def wait_for_state(
+        self,
+        live: bool,
+        timeout: timedelta,
+        url: Optional[str] = None,
+        log_prefix: str = "h2o",
+    ):
+        curl = CurlClient(env=self.env, run_dir=self._tmp_dir)
+        try_until = datetime.now() + timeout
+        if url is None:
+            url = f"https://{self.env.proxy_domain}:{self._port}/"
+        while datetime.now() < try_until:
+            if live:
+                r = curl.http_get(
+                    url=url, extra_args=["--trace", "curl.trace", "--trace-time"]
+                )
+                if r.exit_code == 0:
+                    return True
+            else:
+                r = curl.http_get(url=url)
+                if r.exit_code != 0:
+                    return True
+            time.sleep(0.1)
+        if live:
+            self._log("error", f"Proxy still not responding after {timeout}")
+        else:
+            self._log("debug", f"Proxy still responding after {timeout}")
+        return False
index c8eccd27ad4d2cdf722924012acc3c259aeb8409..c6c75c781d00ac7f5c37c4d499f3b2d1f8b12aa9 100644 (file)
@@ -47,4 +47,4 @@ TESTS_C = \
   unit2600.c unit2601.c unit2602.c unit2603.c unit2604.c unit2605.c \
   unit3200.c                                             unit3205.c \
   unit3211.c unit3212.c unit3213.c unit3214.c            unit3216.c unit3219.c \
-  unit3300.c unit3301.c unit3302.c unit3303.c unit3304.c
+  unit3300.c unit3301.c unit3302.c unit3303.c unit3304.c unit3400.c
diff --git a/tests/unit/unit3400.c b/tests/unit/unit3400.c
new file mode 100644 (file)
index 0000000..e48dd3c
--- /dev/null
@@ -0,0 +1,268 @@
+/***************************************************************************
+ *                                  _   _ ____  _
+ *  Project                     ___| | | |  _ \| |
+ *                             / __| | | | |_) | |
+ *                            | (__| |_| |  _ <| |___
+ *                             \___|\___/|_| \_\_____|
+ *
+ * Copyright (C) Daniel Stenberg, <daniel@haxx.se>, et al.
+ *
+ * This software is licensed as described in the file COPYING, which
+ * you should have received as part of this distribution. The terms
+ * are also available at https://curl.se/docs/copyright.html.
+ *
+ * You may opt to use, copy, modify, merge, publish, distribute and/or sell
+ * copies of the Software, and permit persons to whom the Software is
+ * furnished to do so, under the terms of the COPYING file.
+ *
+ * This software is distributed on an "AS IS" basis, WITHOUT WARRANTY OF ANY
+ * KIND, either express or implied.
+ *
+ * SPDX-License-Identifier: curl
+ *
+ ***************************************************************************/
+
+#include "unitcheck.h"
+
+#include "bufq.h"
+#include "capsule.h"
+
+#if defined(USE_PROXY_HTTP3) && defined(USE_NGTCP2) && \
+  !defined(CURL_DISABLE_PROXY) && !defined(CURL_DISABLE_HTTP)
+static void queue_bytes(struct bufq *q, const unsigned char *src, size_t len)
+{
+  size_t nwritten = 0;
+  CURLcode result = Curl_bufq_write(q, src, len, &nwritten);
+  fail_unless(result == CURLE_OK, "queue failed");
+  fail_unless(nwritten == len, "queue short write");
+}
+#endif
+
+#if defined(USE_PROXY_HTTP3) && \
+  !defined(CURL_DISABLE_PROXY) && !defined(CURL_DISABLE_HTTP)
+static void check_capsule_hdr(size_t payload_len,
+                              const unsigned char *expected,
+                              size_t expected_len)
+{
+  unsigned char hdr[HTTP_CAPSULE_HEADER_MAX_SIZE];
+  size_t hdr_len;
+
+  memset(hdr, 0xA5, sizeof(hdr));
+  hdr_len = Curl_capsule_encap_udp_hdr(hdr, sizeof(hdr), payload_len);
+  fail_unless(hdr_len == expected_len, "capsule header length mismatch");
+  fail_unless(!memcmp(hdr, expected, expected_len),
+              "capsule header bytes mismatch");
+}
+
+static void test_capsule_encap_udp_hdr_boundaries(void)
+{
+  const unsigned char p0[] = { 0x00, 0x01, 0x00 };
+  const unsigned char p62[] = { 0x00, 0x3F, 0x00 };
+  const unsigned char p63[] = { 0x00, 0x40, 0x40, 0x00 };
+  const unsigned char p64[] = { 0x00, 0x40, 0x41, 0x00 };
+  const unsigned char p16382[] = { 0x00, 0x7F, 0xFF, 0x00 };
+  const unsigned char p16383[] = { 0x00, 0x80, 0x00, 0x40, 0x00, 0x00 };
+  const unsigned char p16384[] = { 0x00, 0x80, 0x00, 0x40, 0x01, 0x00 };
+
+  check_capsule_hdr(0, p0, sizeof(p0));
+  check_capsule_hdr(62, p62, sizeof(p62));
+  check_capsule_hdr(63, p63, sizeof(p63));
+  check_capsule_hdr(64, p64, sizeof(p64));
+  check_capsule_hdr(16382, p16382, sizeof(p16382));
+  check_capsule_hdr(16383, p16383, sizeof(p16383));
+  check_capsule_hdr(16384, p16384, sizeof(p16384));
+}
+
+#endif /* !CURL_DISABLE_PROXY && !CURL_DISABLE_HTTP */
+
+#if defined(USE_PROXY_HTTP3) && defined(USE_NGTCP2) && \
+  !defined(CURL_DISABLE_PROXY) && !defined(CURL_DISABLE_HTTP)
+static void check_capsule_result(struct bufq *q,
+                                 const unsigned char *capsule, size_t capslen,
+                                 size_t outlen, CURLcode expect_err,
+                                 size_t expect_nread)
+{
+  unsigned char out[32];
+  CURLcode err = CURLE_OK;
+  size_t nread;
+
+  memset(out, 0, sizeof(out));
+  Curl_bufq_reset(q);
+  if(capsule && capslen)
+    queue_bytes(q, capsule, capslen);
+
+  nread = Curl_capsule_process_udp_raw(NULL, NULL, q, out, outlen, &err);
+  fail_unless(err == expect_err, "unexpected capsule error");
+  fail_unless(nread == expect_nread, "unexpected capsule read size");
+}
+
+static void test_capsule_encode_decode_roundtrip(void)
+{
+  struct dynbuf dyn;
+  struct bufq q;
+  unsigned char payload[128];
+  unsigned char out[128];
+  CURLcode result, err;
+  size_t payload_len;
+  size_t i, nread;
+
+  for(i = 0; i < sizeof(payload); ++i)
+    payload[i] = (unsigned char)i;
+
+  for(i = 0; i < 2; ++i) {
+    payload_len = i ? 64 : 7;
+    memset(out, 0, sizeof(out));
+
+    result = Curl_capsule_encap_udp_datagram(&dyn, payload, payload_len);
+    fail_unless(result == CURLE_OK, "failed to encapsulate UDP datagram");
+
+    Curl_bufq_init2(&q, 32, 8, BUFQ_OPT_NONE);
+    queue_bytes(&q, (const unsigned char *)curlx_dyn_ptr(&dyn),
+                curlx_dyn_len(&dyn));
+
+    err = CURLE_OK;
+    nread = Curl_capsule_process_udp_raw(NULL, NULL, &q, out, sizeof(out),
+                                         &err);
+    fail_unless(err == CURLE_OK, "failed to decode UDP datagram");
+    fail_unless(nread == payload_len, "decoded payload length mismatch");
+    fail_unless(!memcmp(out, payload, payload_len),
+                "decoded payload bytes mismatch");
+    fail_unless(Curl_bufq_is_empty(&q), "decoded capsule must be consumed");
+
+    Curl_bufq_free(&q);
+    curlx_dyn_free(&dyn);
+  }
+}
+
+static void test_capsule_sequential_decode(void)
+{
+  /* Verify that multiple back-to-back capsules in the same bufq are
+     each decoded in turn and the buffer is fully consumed. */
+  struct bufq q;
+  unsigned char out[8];
+  CURLcode err;
+  size_t nread;
+  /* Two back-to-back 3-byte UDP capsules */
+  const unsigned char two_caps[] = {
+    0x00, 0x04, 0x00, 0x11, 0x22, 0x33,   /* capsule 1: [0x11,0x22,0x33] */
+    0x00, 0x04, 0x00, 0xAA, 0xBB, 0xCC    /* capsule 2: [0xAA,0xBB,0xCC] */
+  };
+
+  Curl_bufq_init2(&q, 32, 4, BUFQ_OPT_NONE);
+
+  queue_bytes(&q, two_caps, sizeof(two_caps));
+
+  /* First capsule */
+  memset(out, 0, sizeof(out));
+  err = CURLE_OK;
+  nread = Curl_capsule_process_udp_raw(NULL, NULL, &q, out, sizeof(out), &err);
+  fail_unless(err == CURLE_OK, "sequential: first capsule decode failed");
+  fail_unless(nread == 3, "sequential: first capsule size mismatch");
+  fail_unless(out[0] == 0x11 && out[1] == 0x22 && out[2] == 0x33,
+              "sequential: first capsule bytes mismatch");
+
+  /* Second capsule */
+  memset(out, 0, sizeof(out));
+  err = CURLE_OK;
+  nread = Curl_capsule_process_udp_raw(NULL, NULL, &q, out, sizeof(out), &err);
+  fail_unless(err == CURLE_OK, "sequential: second capsule decode failed");
+  fail_unless(nread == 3, "sequential: second capsule size mismatch");
+  fail_unless(out[0] == 0xAA && out[1] == 0xBB && out[2] == 0xCC,
+              "sequential: second capsule bytes mismatch");
+
+  /* Buffer must be empty after both capsules */
+  fail_unless(Curl_bufq_is_empty(&q),
+              "sequential: buffer must be empty after two capsules");
+
+  /* No more data - CURLE_AGAIN expected */
+  err = CURLE_OK;
+  nread = Curl_capsule_process_udp_raw(NULL, NULL, &q, out, sizeof(out), &err);
+  fail_unless(err == CURLE_AGAIN,
+              "sequential: empty queue should return AGAIN");
+  fail_unless(nread == 0, "sequential: empty queue should read zero bytes");
+
+  Curl_bufq_free(&q);
+}
+
+static void test_capsule_decode_paths(void)
+{
+  struct bufq q;
+  unsigned char out[8];
+  CURLcode err = CURLE_OK;
+  size_t nread;
+  const unsigned char invalid_type[] = { 0x01 };
+  const unsigned char partial_len[] = { 0x00, 0x40 };
+  const unsigned char invalid_context[] = { 0x00, 0x01, 0x01 };
+  const unsigned char invalid_caps_len[] = { 0x00, 0x00, 0x00 };
+  const unsigned char partial_payload[] = { 0x00, 0x04, 0x00, 0x11, 0x22 };
+  const unsigned char payload_3b[] = { 0x00, 0x04, 0x00, 0x11, 0x22, 0x33 };
+  const unsigned char payload_empty[] = { 0x00, 0x01, 0x00 };
+
+  Curl_bufq_init2(&q, 32, 4, BUFQ_OPT_NONE);
+
+  check_capsule_result(&q, NULL, 0, 0, CURLE_BAD_FUNCTION_ARGUMENT, 0);
+  check_capsule_result(&q, NULL, 0, sizeof(out), CURLE_AGAIN, 0);
+  check_capsule_result(&q, invalid_type, sizeof(invalid_type), sizeof(out),
+                       CURLE_RECV_ERROR, 0);
+  check_capsule_result(&q, partial_len, sizeof(partial_len), sizeof(out),
+                       CURLE_AGAIN, 0);
+  check_capsule_result(&q, invalid_context, sizeof(invalid_context),
+                       sizeof(out), CURLE_RECV_ERROR, 0);
+  check_capsule_result(&q, invalid_caps_len, sizeof(invalid_caps_len),
+                       sizeof(out), CURLE_RECV_ERROR, 0);
+  check_capsule_result(&q, partial_payload, sizeof(partial_payload),
+                       sizeof(out), CURLE_AGAIN, 0);
+
+  /* oversized payload is rejected and discarded */
+  Curl_bufq_reset(&q);
+  queue_bytes(&q, payload_3b, sizeof(payload_3b));
+  nread = Curl_capsule_process_udp_raw(NULL, NULL, &q, out, 2, &err);
+  fail_unless(err == CURLE_RECV_ERROR,
+              "expected RECV_ERROR for short output buffer");
+  fail_unless(nread == 0, "expected zero read on short output buffer");
+  fail_unless(Curl_bufq_is_empty(&q),
+              "oversized capsule must be discarded");
+
+  /* zero-length UDP payload is accepted and consumed */
+  Curl_bufq_reset(&q);
+  queue_bytes(&q, payload_empty, sizeof(payload_empty));
+  nread = Curl_capsule_process_udp_raw(NULL, NULL, &q, out, sizeof(out), &err);
+  fail_unless(err == CURLE_OK, "zero-length UDP payload should succeed");
+  fail_unless(nread == 0, "zero-length UDP payload should read zero");
+  fail_unless(Curl_bufq_is_empty(&q), "zero-length capsule must be consumed");
+
+  /* normal payload decode */
+  Curl_bufq_reset(&q);
+  queue_bytes(&q, payload_3b, sizeof(payload_3b));
+  memset(out, 0, sizeof(out));
+  nread = Curl_capsule_process_udp_raw(NULL, NULL, &q, out, sizeof(out), &err);
+  fail_unless(err == CURLE_OK, "payload decode should succeed");
+  fail_unless(nread == 3, "payload decode size mismatch");
+  fail_unless(out[0] == 0x11 && out[1] == 0x22 && out[2] == 0x33,
+              "payload decode bytes mismatch");
+  fail_unless(Curl_bufq_is_empty(&q), "payload capsule must be consumed");
+
+  Curl_bufq_free(&q);
+}
+#endif /* USE_NGTCP2 && !CURL_DISABLE_PROXY && !CURL_DISABLE_HTTP */
+
+static CURLcode test_unit3400(const char *arg)
+{
+  UNITTEST_BEGIN_SIMPLE
+
+  (void)arg;
+
+#if defined(USE_PROXY_HTTP3) && \
+  !defined(CURL_DISABLE_PROXY) && !defined(CURL_DISABLE_HTTP)
+  test_capsule_encap_udp_hdr_boundaries();
+#endif
+
+#if defined(USE_PROXY_HTTP3) && defined(USE_NGTCP2) && \
+  !defined(CURL_DISABLE_PROXY) && !defined(CURL_DISABLE_HTTP)
+  test_capsule_encode_decode_roundtrip();
+  test_capsule_decode_paths();
+  test_capsule_sequential_decode();
+#endif
+
+  UNITTEST_END_SIMPLE
+}