alt-svc: more flexibility on same destination

When the Alt-Svc points to the same host and port, add the destination
ALPN to the `wanted` versions and set it also as the `preferred` version
in negotiations.

This allows Alt-Svc for h3 to point to h2 and have it tried first. Also,
this allows Alt-Svc to say http/1.1 is preferred and changes the ALPN
protocol ordering for the TLS handshake.

Add tests in various combination to verify this works.

Reported-by: yushicheng7788 on github
Fixes #19740
Closes #19874
This commit is contained in:
Stefan Eissing 2025-12-08 13:36:19 +01:00 committed by Daniel Stenberg
parent f450f3801b
commit 5ed7b5b01b
No known key found for this signature in database
GPG key ID: 5CC908FDB71E12C2
9 changed files with 135 additions and 49 deletions

View file

@ -133,3 +133,19 @@ class TestEyeballs:
he_timers_set = [line for line in r.trace_lines
if re.match(r'.*\[TIMER] \[HAPPY_EYEBALLS] set for', line)]
assert len(he_timers_set) == 2, f'found: {"".join(he_timers_set)}\n{r.dump_logs()}'
# download using HTTP/3 on missing server with alt-svc pointing there
@pytest.mark.skipif(condition=not Env.have_h3(), reason="missing HTTP/3 support")
def test_06_20_h2_altsvc_h3_fallback(self, env: Env, httpd, nghttpx):
curl = CurlClient(env=env)
urln = f'https://{env.domain1}:{env.https_only_tcp_port}/data.json'
altsvc_file = curl.mk_altsvc_file('test_06',
'h2', env.domain1, env.https_only_tcp_port,
'h3', env.domain1, env.https_only_tcp_port)
r = curl.http_download(urls=[urln], extra_args=[
'--alt-svc', altsvc_file
])
# Should try a QUIC connection that fails and fallback to h2
r.check_exit_code(0)
r.check_response(count=1, http_status=200)
assert r.stats[0]['http_version'] == '2'

View file

@ -26,6 +26,7 @@
#
import logging
import os
import re
from datetime import datetime, timedelta
import pytest
@ -77,10 +78,11 @@ class TestReuse:
@pytest.mark.skipif(condition=not Env.have_h3(), reason="h3 not supported")
def test_12_03_as_follow_h2h3(self, env: Env, httpd, configures_httpd, nghttpx):
# write an alt-svc file that advises h3 instead of h2
asfile = os.path.join(env.gen_dir, 'alt-svc-12_03.txt')
self.create_asfile(asfile, f'h2 {env.domain1} {env.https_port} h3 {env.domain1} {env.h3_port}')
curl = CurlClient(env=env)
# write an alt-svc file that advises h3 instead of h2
asfile = curl.mk_altsvc_file('test_12',
'h2', env.domain1, env.https_port,
'h3', env.domain1, env.h3_port)
urln = f'https://{env.authority_for(env.domain1, "h2")}/data.json'
r = curl.http_download(urls=[urln], with_stats=True, extra_args=[
'--alt-svc', f'{asfile}',
@ -112,54 +114,47 @@ class TestReuse:
@pytest.mark.skipif(condition=not Env.have_h3(), reason="h3 not supported")
def test_12_05_as_follow_h3h1(self, env: Env, httpd, configures_httpd, nghttpx):
# With '--http3` an Alt-Svc redirection from h3 to h1 is allowed
count = 2
# write an alt-svc file the advises h1 instead of h3
asfile = os.path.join(env.gen_dir, 'alt-svc-12_05.txt')
ts = datetime.now() + timedelta(hours=24)
expires = f'{ts.year:04}{ts.month:02}{ts.day:02} {ts.hour:02}:{ts.minute:02}:{ts.second:02}'
with open(asfile, 'w') as fd:
fd.write(f'h3 {env.domain1} {env.https_port} http/1.1 {env.domain1} {env.https_port} "{expires}" 0 0')
log.info(f'altscv: {open(asfile).readlines()}')
curl = CurlClient(env=env)
urln = f'https://{env.authority_for(env.domain1, "h3")}/data.json?[0-{count-1}]'
asfile = curl.mk_altsvc_file('test_12',
'h3', env.domain1, env.https_port,
'http/1.1', env.domain1, env.https_port)
urln = f'https://{env.authority_for(env.domain1, "h3")}/data.json'
r = curl.http_download(urls=[urln], with_stats=True, extra_args=[
'--alt-svc', f'{asfile}', '--http3'
])
r.check_response(count=count, http_status=200)
# We expect the connection to be reused and use HTTP/1.1
r.check_response(count=1, http_status=200)
# We expect the connection to be preferring HTTP/1.1 in the ALPN
assert r.total_connects == 1
for s in r.stats:
assert s['http_version'] == '1.1', f'{s}'
re_m = re.compile(r'.* ALPN: curl offers http/1.1,h2')
lines = [line for line in r.trace_lines if re_m.match(line)]
assert len(lines), f'{r.dump_logs()}'
@pytest.mark.skipif(condition=not Env.have_h3(), reason="h3 not supported")
def test_12_06_as_ignore_h3h1(self, env: Env, httpd, configures_httpd, nghttpx):
# With '--http3-only` an Alt-Svc redirection from h3 to h1 is ignored
count = 2
# write an alt-svc file the advises h1 instead of h3
asfile = os.path.join(env.gen_dir, 'alt-svc-12_05.txt')
ts = datetime.now() + timedelta(hours=24)
expires = f'{ts.year:04}{ts.month:02}{ts.day:02} {ts.hour:02}:{ts.minute:02}:{ts.second:02}'
with open(asfile, 'w') as fd:
fd.write(f'h3 {env.domain1} {env.https_port} http/1.1 {env.domain1} {env.https_port} "{expires}" 0 0')
log.info(f'altscv: {open(asfile).readlines()}')
curl = CurlClient(env=env)
urln = f'https://{env.authority_for(env.domain1, "h3")}/data.json?[0-{count-1}]'
asfile = curl.mk_altsvc_file('test_12',
'h3', env.domain1, env.https_port,
'http/1.1', env.domain1, env.https_port)
urln = f'https://{env.authority_for(env.domain1, "h3")}/data.json'
r = curl.http_download(urls=[urln], with_stats=True, extra_args=[
'--alt-svc', f'{asfile}', '--http3-only'
])
r.check_response(count=count, http_status=200)
r.check_response(count=1, http_status=200)
# We expect the connection to be stay on h3, since we used --http3-only
assert r.total_connects == 1
for s in r.stats:
assert s['http_version'] == '3', f'{s}'
assert r.stats[0]['http_version'] == '3', f'{r.stats}'
@pytest.mark.skipif(condition=not Env.have_h3(), reason="h3 not supported")
def test_12_07_as_ignore_h2h3(self, env: Env, httpd, configures_httpd, nghttpx):
# With '--http2` an Alt-Svc redirection from h2 to h3 is ignored
# write an alt-svc file that advises h3 instead of h2
asfile = os.path.join(env.gen_dir, 'alt-svc-12_03.txt')
self.create_asfile(asfile, f'h2 {env.domain1} {env.https_port} h3 {env.domain1} {env.h3_port}')
curl = CurlClient(env=env)
asfile = curl.mk_altsvc_file('test_12',
'h2', env.domain1, env.https_port,
'h3', env.domain1, env.h3_port)
urln = f'https://{env.authority_for(env.domain1, "h2")}/data.json'
r = curl.http_download(urls=[urln], with_stats=True, extra_args=[
'--alt-svc', f'{asfile}', '--http2'
@ -167,9 +162,17 @@ class TestReuse:
r.check_response(count=1, http_status=200)
assert r.stats[0]['http_version'] == '2', f'{r.stats}'
def create_asfile(self, fpath, line):
ts = datetime.now() + timedelta(hours=24)
expires = f'{ts.year:04}{ts.month:02}{ts.day:02} {ts.hour:02}:{ts.minute:02}:{ts.second:02}'
with open(fpath, 'w') as fd:
fd.write(f'{line} "{expires}" 0 0')
log.info(f'altscv: {open(fpath).readlines()}')
# download using HTTP/3 on available server with alt-svc to h2, use h2
@pytest.mark.skipif(condition=not Env.have_h3(), reason="missing HTTP/3 support")
def test_12_08_h3_altsvc_h2_used(self, env: Env, httpd, nghttpx):
curl = CurlClient(env=env)
urln = f'https://{env.domain1}:{env.https_port}/data.json'
altsvc_file = curl.mk_altsvc_file('test_12',
'h3', env.domain1, env.https_port,
'h2', env.domain1, env.https_port)
r = curl.http_download(urls=[urln], extra_args=[
'--http3', '--alt-svc', altsvc_file
])
r.check_exit_code(0)
r.check_response(count=1, http_status=200)
assert r.stats[0]['http_version'] == '2'

View file

@ -36,7 +36,7 @@ import re
import shutil
import subprocess
from statistics import mean, fmean
from datetime import timedelta, datetime
from datetime import timedelta, datetime, timezone
from typing import List, Optional, Dict, Union, Any
from urllib.parse import urlparse
@ -1211,3 +1211,12 @@ class CurlClient:
rc = p.returncode
if rc != 0:
raise Exception(f'{fg_gen_flame} returned error {rc}')
def mk_altsvc_file(self, name, src_alpn, src_host, src_port,
dest_alpn, dest_host, dest_port):
fpath = os.path.join(self.run_dir, f'{name}.altsvc')
ts = datetime.now(timezone.utc) + timedelta(hours=1)
ts = ts.strftime('%Y%m%d %H:%M:%S')
with open(fpath, 'w') as fd:
fd.write(f'{src_alpn} {src_host} {src_port} {dest_alpn} {dest_host} {dest_port} "{ts}" 1 0\n')
return fpath