curl/tests/http/test_60_h3_proxy.py
Aritra Basu e78b1b3ecc
HTTP/3: add proxy CONNECT and MASQUE CONNECT-UDP support (ngtcp2 QUIC)
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
2026-05-27 08:49:53 +02:00

689 lines
25 KiB
Python

#!/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)