]> git.ipfire.org Git - thirdparty/curl.git/commitdiff
alt-svc: more flexibility on same destination
authorStefan Eissing <stefan@eissing.org>
Mon, 8 Dec 2025 12:36:19 +0000 (13:36 +0100)
committerDaniel Stenberg <daniel@haxx.se>
Tue, 9 Dec 2025 14:59:09 +0000 (15:59 +0100)
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
lib/altsvc.h
lib/cf-https-connect.c
lib/http.h
lib/url.c
lib/vtls/vtls.c
tests/http/test_06_eyeballs.py
tests/http/test_12_reuse.py
tests/http/testenv/curl.py

index 31f6b515bec24187475945aa75b340679df1a05a..c422736fd230f5f865b5c6cb5e88d468f02b31a8 100644 (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;
     }
   }
index d370b4e4b195d98944782d9d67b3290d2319f7d6..c6f1b902c78ad9d9a138c414b518c1b0520b3bbe 100644 (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)
index acb3bcdd3a7a6119718ad88af74e12bbd0cef5eb..47555fed94f291e956106483fa2d3f9828850d58 100644 (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)) {
index 1c7ebdf8d7f10839867546f03302f7ee50e15ed6..c0f13ce1b0e3f4ed1d84c627a7a13b52c9f645fa 100644 (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 */
index 0643ceea7f8cb546589efb07af036f53ee6bfe1f..98fc8ce44f87556fba406048f091ac99af51ea07 100644 (file)
--- 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;
index e342953778b04395364902b15770374b956117e6..696c7fbaf8609f54aa72ad17844f25208d3662f1 100644 (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;
index 7d267744767190c00c718927d96d2ee78217b42f..3b374e8158e3ffbc4de08d2325ab5cc0fce6f071 100644 (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'
index 1d4a4a0ae2f8f78b43a2071b69c37c53b1708d23..522df92708cf485b95ac58ccaa03191ddc440d13 100644 (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'
index f54170baba584a3e612cc0d713cf467ac4ebb169..d9fe0706ac43f13d03bfc443a2667fbd1859beab 100644 (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