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

@ -622,7 +622,8 @@ bool Curl_altsvc_lookup(struct altsvcinfo *asi,
enum alpnid srcalpnid, const char *srchost,
int srcport,
struct altsvc **dstentry,
const int versions) /* one or more bits */
const int versions, /* one or more bits */
bool *psame_destination)
{
struct Curl_llist_node *e;
struct Curl_llist_node *n;
@ -631,6 +632,7 @@ bool Curl_altsvc_lookup(struct altsvcinfo *asi,
DEBUGASSERT(srchost);
DEBUGASSERT(dstentry);
*psame_destination = FALSE;
for(e = Curl_llist_head(&asi->list); e; e = n) {
struct altsvc *as = Curl_node_elem(e);
n = Curl_node_next(e);
@ -646,6 +648,8 @@ bool Curl_altsvc_lookup(struct altsvcinfo *asi,
(versions & (int)as->dst.alpnid)) {
/* match */
*dstentry = as;
*psame_destination = (srcport == as->dst.port) &&
hostcompare(srchost, as->dst.host);
return TRUE;
}
}

View file

@ -65,7 +65,8 @@ bool Curl_altsvc_lookup(struct altsvcinfo *asi,
enum alpnid srcalpnid, const char *srchost,
int srcport,
struct altsvc **dstentry,
const int versions); /* CURLALTSVC_H* bits */
const int versions, /* CURLALTSVC_H* bits */
bool *psame_destination);
#else
/* disabled */
#define Curl_altsvc_save(a, b, c)

View file

@ -710,6 +710,31 @@ CURLcode Curl_cf_https_setup(struct Curl_easy *data,
}
#endif
/* Add preferred HTTP version ALPN first */
if(data->state.http_neg.preferred &&
(alpn_count < CURL_ARRAYSIZE(alpn_ids)) &&
(data->state.http_neg.preferred & data->state.http_neg.allowed)) {
enum alpnid alpn_pref = ALPN_none;
switch(data->state.http_neg.preferred) {
case CURL_HTTP_V3x:
if(!Curl_conn_may_http3(data, conn, conn->transport_wanted))
alpn_pref = ALPN_h3;
break;
case CURL_HTTP_V2x:
alpn_pref = ALPN_h2;
break;
case CURL_HTTP_V1x:
alpn_pref = ALPN_h1;
break;
default:
break;
}
if(alpn_pref &&
!cf_https_alpns_contain(alpn_pref, alpn_ids, alpn_count)) {
alpn_ids[alpn_count++] = alpn_pref;
}
}
if((alpn_count < CURL_ARRAYSIZE(alpn_ids)) &&
(data->state.http_neg.wanted & CURL_HTTP_V3x) &&
!cf_https_alpns_contain(ALPN_h3, alpn_ids, alpn_count)) {

View file

@ -72,6 +72,7 @@ struct http_negotiation {
unsigned char rcvd_min; /* minimum version seen in responses, 09, 10, 11 */
http_majors wanted; /* wanted major versions when talking to server */
http_majors allowed; /* allowed major versions when talking to server */
http_majors preferred; /* preferred major version when talking to server */
BIT(h2_upgrade); /* Do HTTP Upgrade from 1.1 to 2 */
BIT(h2_prior_knowledge); /* Directly do HTTP/2 without ALPN/SSL */
BIT(accept_09); /* Accept an HTTP/0.9 response */

View file

@ -3060,6 +3060,7 @@ static CURLcode parse_connect_to_slist(struct Curl_easy *data,
struct altsvc *as = NULL;
int allowed_alpns = ALPN_none;
struct http_negotiation *neg = &data->state.http_neg;
bool same_dest = FALSE;
DEBUGF(infof(data, "Alt-svc check wanted=%x, allowed=%x",
neg->wanted, neg->allowed));
@ -3083,7 +3084,7 @@ static CURLcode parse_connect_to_slist(struct Curl_easy *data,
hit = Curl_altsvc_lookup(data->asi,
ALPN_h3, host, conn->remote_port, /* from */
&as /* to */,
allowed_alpns);
allowed_alpns, &same_dest);
}
#endif
#ifdef USE_HTTP2
@ -3093,7 +3094,7 @@ static CURLcode parse_connect_to_slist(struct Curl_easy *data,
hit = Curl_altsvc_lookup(data->asi,
ALPN_h2, host, conn->remote_port, /* from */
&as /* to */,
allowed_alpns);
allowed_alpns, &same_dest);
}
#endif
if(!hit && (neg->wanted & CURL_HTTP_V1x) &&
@ -3102,10 +3103,29 @@ static CURLcode parse_connect_to_slist(struct Curl_easy *data,
hit = Curl_altsvc_lookup(data->asi,
ALPN_h1, host, conn->remote_port, /* from */
&as /* to */,
allowed_alpns);
allowed_alpns, &same_dest);
}
if(hit) {
if(hit && same_dest) {
/* same destination, but more HTTPS version options */
switch(as->dst.alpnid) {
case ALPN_h1:
neg->wanted |= CURL_HTTP_V1x;
neg->preferred = CURL_HTTP_V1x;
break;
case ALPN_h2:
neg->wanted |= CURL_HTTP_V2x;
neg->preferred = CURL_HTTP_V2x;
break;
case ALPN_h3:
neg->wanted |= CURL_HTTP_V3x;
neg->preferred = CURL_HTTP_V3x;
break;
default: /* should not be possible */
break;
}
}
else if(hit) {
char *hostd = curlx_strdup((char *)as->dst.host);
if(!hostd)
return CURLE_OUT_OF_MEMORY;

View file

@ -145,22 +145,28 @@ static const struct alpn_spec ALPN_SPEC_H2 = {
static const struct alpn_spec ALPN_SPEC_H2_H11 = {
{ ALPN_H2, ALPN_HTTP_1_1 }, 2
};
static const struct alpn_spec ALPN_SPEC_H11_H2 = {
{ ALPN_HTTP_1_1, ALPN_H2 }, 2
};
#endif
#if !defined(CURL_DISABLE_HTTP) || !defined(CURL_DISABLE_PROXY)
static const struct alpn_spec *alpn_get_spec(http_majors allowed,
static const struct alpn_spec *alpn_get_spec(http_majors wanted,
http_majors preferred,
bool use_alpn)
{
if(!use_alpn)
return NULL;
#ifdef USE_HTTP2
if(allowed & CURL_HTTP_V2x) {
if(allowed & CURL_HTTP_V1x)
return &ALPN_SPEC_H2_H11;
if(wanted & CURL_HTTP_V2x) {
if(wanted & CURL_HTTP_V1x)
return (preferred == CURL_HTTP_V1x) ?
&ALPN_SPEC_H11_H2 : &ALPN_SPEC_H2_H11;
return &ALPN_SPEC_H2;
}
#else
(void)allowed;
(void)wanted;
(void)preferred;
#endif
/* Use the ALPN protocol "http/1.1" for HTTP/1.x.
Avoid "http/1.0" because some servers do not support it. */
@ -1718,6 +1724,7 @@ static CURLcode cf_ssl_create(struct Curl_cfilter **pcf,
ctx = cf_ctx_new(data, NULL);
#else
ctx = cf_ctx_new(data, alpn_get_spec(data->state.http_neg.wanted,
data->state.http_neg.preferred,
conn->bits.tls_enable_alpn));
#endif
if(!ctx) {
@ -1770,17 +1777,17 @@ static CURLcode cf_ssl_proxy_create(struct Curl_cfilter **pcf,
CURLcode result;
/* ALPN is default, but if user explicitly disables it, obey */
bool use_alpn = data->set.ssl_enable_alpn;
http_majors allowed = CURL_HTTP_V1x;
http_majors wanted = CURL_HTTP_V1x;
(void)conn;
#ifdef USE_HTTP2
if(conn->http_proxy.proxytype == CURLPROXY_HTTPS2) {
use_alpn = TRUE;
allowed = (CURL_HTTP_V1x | CURL_HTTP_V2x);
wanted = (CURL_HTTP_V1x | CURL_HTTP_V2x);
}
#endif
ctx = cf_ctx_new(data, alpn_get_spec(allowed, use_alpn));
ctx = cf_ctx_new(data, alpn_get_spec(wanted, 0, use_alpn));
if(!ctx) {
result = CURLE_OUT_OF_MEMORY;
goto out;

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