From 5ed7b5b01bb1a5645a9937573fddbf34782b5c83 Mon Sep 17 00:00:00 2001 From: Stefan Eissing Date: Mon, 8 Dec 2025 13:36:19 +0100 Subject: [PATCH] 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 --- lib/altsvc.c | 6 ++- lib/altsvc.h | 3 +- lib/cf-https-connect.c | 25 ++++++++++++ lib/http.h | 1 + lib/url.c | 28 ++++++++++++-- lib/vtls/vtls.c | 23 +++++++---- tests/http/test_06_eyeballs.py | 16 ++++++++ tests/http/test_12_reuse.py | 71 ++++++++++++++++++---------------- tests/http/testenv/curl.py | 11 +++++- 9 files changed, 135 insertions(+), 49 deletions(-) diff --git a/lib/altsvc.c b/lib/altsvc.c index 31f6b515be..c422736fd2 100644 --- a/lib/altsvc.c +++ b/lib/altsvc.c @@ -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; } } diff --git a/lib/altsvc.h b/lib/altsvc.h index d370b4e4b1..c6f1b902c7 100644 --- a/lib/altsvc.h +++ b/lib/altsvc.h @@ -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) diff --git a/lib/cf-https-connect.c b/lib/cf-https-connect.c index acb3bcdd3a..47555fed94 100644 --- a/lib/cf-https-connect.c +++ b/lib/cf-https-connect.c @@ -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)) { diff --git a/lib/http.h b/lib/http.h index 1c7ebdf8d7..c0f13ce1b0 100644 --- a/lib/http.h +++ b/lib/http.h @@ -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 */ diff --git a/lib/url.c b/lib/url.c index 0643ceea7f..98fc8ce44f 100644 --- a/lib/url.c +++ b/lib/url.c @@ -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; diff --git a/lib/vtls/vtls.c b/lib/vtls/vtls.c index e342953778..696c7fbaf8 100644 --- a/lib/vtls/vtls.c +++ b/lib/vtls/vtls.c @@ -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; diff --git a/tests/http/test_06_eyeballs.py b/tests/http/test_06_eyeballs.py index 7d26774476..3b374e8158 100644 --- a/tests/http/test_06_eyeballs.py +++ b/tests/http/test_06_eyeballs.py @@ -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' diff --git a/tests/http/test_12_reuse.py b/tests/http/test_12_reuse.py index 1d4a4a0ae2..522df92708 100644 --- a/tests/http/test_12_reuse.py +++ b/tests/http/test_12_reuse.py @@ -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' diff --git a/tests/http/testenv/curl.py b/tests/http/testenv/curl.py index f54170baba..d9fe0706ac 100644 --- a/tests/http/testenv/curl.py +++ b/tests/http/testenv/curl.py @@ -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 -- 2.47.3