diff --git a/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java b/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java index 3fff5c916391..5963885467e9 100644 --- a/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java +++ b/documentation/jetty/modules/code/examples/src/main/java/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java @@ -614,6 +614,22 @@ public void h3() throws Exception // end::h3[] } + public void h2altsvc() + { + // tag::h2altsvc[] + HttpConfiguration httpConfig = new HttpConfiguration(); + HTTP2ServerConnectionFactory h2 = new HTTP2ServerConnectionFactory(httpConfig); + + // Configure AltSvcCustomizer after connection factory creation. + HTTP2ServerConnectionFactory.AltSvcCustomizer h2AltSvc = httpConfig.getCustomizer(HTTP2ServerConnectionFactory.AltSvcCustomizer.class); + if (h2AltSvc != null) + { + h2AltSvc.setMaxAge(Duration.ofHours(24)); + h2AltSvc.setPersist(true); + } + // end::h2altsvc[] + } + public void conscrypt() { // tag::conscrypt[] diff --git a/documentation/jetty/modules/programming-guide/pages/server/http.adoc b/documentation/jetty/modules/programming-guide/pages/server/http.adoc index bb659efc92a1..8a97756fe36f 100644 --- a/documentation/jetty/modules/programming-guide/pages/server/http.adoc +++ b/documentation/jetty/modules/programming-guide/pages/server/http.adoc @@ -533,10 +533,22 @@ Alt-Svc: h3=":843" ---- The presence of this header indicates that protocol `h3` is available on the same host (since no host is defined before the port), but on port `843` (although it may be the same port `443`). +The `ma` (max-age) attribute specifies how long (in seconds) the client should cache this information. The HTTP/3 client may now initiate a QUIC connection on port `843` and make HTTP/3 requests. NOTE: It is nowadays common to use the same port `443` for both HTTP/2 and HTTP/3. This does not cause problems because HTTP/2 listens on the TCP port `443`, while QUIC listens on the UDP port `443`. +NOTE: The `Alt-Svc` header is automatically added to HTTP/2 responses if an HTTP/3 connector is present, advertising the HTTP/3 connector's port. +By default, only the port is advertised without additional attributes. +The `ma` (max-age) attribute can be configured via `AltSvcCustomizer.setMaxAge(Duration)` and the `persist` attribute via `AltSvcCustomizer.setPersist(boolean)`. + +To configure the `AltSvcCustomizer`, retrieve it from the `HttpConfiguration` after creating the `HTTP2ServerConnectionFactory`: + +[,java,indent=0,options=nowrap] +---- +include::code:example$src/main/java/org/eclipse/jetty/docs/programming/server/http/HTTPServerDocs.java[tags=h2altsvc] +---- + It is therefore common for HTTP/3 clients to initiate connections using the HTTP/2 protocol over TCP, and if the server supports HTTP/3 switch to HTTP/3 as indicated by the server. [plantuml] diff --git a/jetty-core/jetty-http2/jetty-http2-server/src/main/java/org/eclipse/jetty/http2/server/HTTP2ServerConnectionFactory.java b/jetty-core/jetty-http2/jetty-http2-server/src/main/java/org/eclipse/jetty/http2/server/HTTP2ServerConnectionFactory.java index a821eacaa2d3..40da1dd4d7f0 100644 --- a/jetty-core/jetty-http2/jetty-http2-server/src/main/java/org/eclipse/jetty/http2/server/HTTP2ServerConnectionFactory.java +++ b/jetty-core/jetty-http2/jetty-http2-server/src/main/java/org/eclipse/jetty/http2/server/HTTP2ServerConnectionFactory.java @@ -14,9 +14,13 @@ package org.eclipse.jetty.http2.server; import java.io.EOFException; +import java.time.Duration; import java.util.Map; import java.util.concurrent.TimeoutException; +import org.eclipse.jetty.http.HttpFields; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpVersion; import org.eclipse.jetty.http2.ErrorCode; import org.eclipse.jetty.http2.HTTP2Cipher; import org.eclipse.jetty.http2.HTTP2Stream; @@ -35,6 +39,9 @@ import org.eclipse.jetty.server.Connector; import org.eclipse.jetty.server.HttpConfiguration; import org.eclipse.jetty.server.NegotiatingServerConnection.CipherDiscriminator; +import org.eclipse.jetty.server.NetworkConnector; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Server; import org.eclipse.jetty.util.Callback; import org.eclipse.jetty.util.Promise; import org.eclipse.jetty.util.StringUtil; @@ -54,11 +61,13 @@ public HTTP2ServerConnectionFactory() public HTTP2ServerConnectionFactory(@Name("config") HttpConfiguration httpConfiguration) { super(httpConfiguration); + httpConfiguration.addCustomizer(new AltSvcCustomizer()); } public HTTP2ServerConnectionFactory(@Name("config") HttpConfiguration httpConfiguration, @Name("protocols") String... protocols) { super(httpConfiguration, protocols); + httpConfiguration.addCustomizer(new AltSvcCustomizer()); } @Override @@ -191,4 +200,83 @@ private void close(Stream stream, String reason) stream.getSession().close(ErrorCode.PROTOCOL_ERROR.code, reason, Callback.NOOP); } } + + /** + *
An {@link HttpConfiguration.Customizer} that adds the {@code Alt-Svc} + * header to HTTP/2 responses, advertising HTTP/3 support if an HTTP/3 + * connector is available on the server.
+ */ + public static class AltSvcCustomizer implements HttpConfiguration.Customizer + { + private Duration _maxAge; + private boolean _persist; + + /** + * @return The max age for the Alt-Svc response header, or null if no max-age attribute should be sent. + */ + public Duration getMaxAge() + { + return _maxAge; + } + + /** + * Sets the Alt-Svc max age. + * + * @param maxAge the max age for the Alt-Svc response header, or null if no max-age attribute should be sent. + */ + public void setMaxAge(Duration maxAge) + { + _maxAge = maxAge; + } + + /** + * @return whether the persist parameter should be included in the Alt-Svc header. + */ + public boolean isPersist() + { + return _persist; + } + + /** + * Sets whether to include the persist parameter in the Alt-Svc header. + * When true, adds {@code persist=1} to indicate the alternative service + * should be persisted across network changes. + * + * @param persist true to include the persist parameter, false otherwise. + * @see Alt-Svc persist parameter + */ + public void setPersist(boolean persist) + { + _persist = persist; + } + + @Override + public Request customize(Request request, HttpFields.Mutable responseHeaders) + { + if (HttpVersion.HTTP_2 != request.getConnectionMetaData().getHttpVersion()) + return request; + + Server server = request.getConnectionMetaData().getConnector().getServer(); + for (Connector connector : server.getConnectors()) + { + if (connector instanceof NetworkConnector nc && + connector.getProtocols().contains("h3")) + { + int port = nc.getLocalPort(); + if (port > 0) + { + StringBuilder altSvc = new StringBuilder(); + altSvc.append(String.format("h3=\":%d\"", port)); + if (_maxAge != null) + altSvc.append(String.format("; ma=%d", _maxAge.toSeconds())); + if (_persist) + altSvc.append("; persist=1"); + responseHeaders.add(HttpHeader.ALT_SVC, altSvc.toString()); + } + break; + } + } + return request; + } + } } diff --git a/jetty-core/jetty-http3/jetty-http3-server/src/main/java/org/eclipse/jetty/http3/server/HTTP3ServerConnectionFactory.java b/jetty-core/jetty-http3/jetty-http3-server/src/main/java/org/eclipse/jetty/http3/server/HTTP3ServerConnectionFactory.java index be6c2f5fb777..b00c065e7711 100644 --- a/jetty-core/jetty-http3/jetty-http3-server/src/main/java/org/eclipse/jetty/http3/server/HTTP3ServerConnectionFactory.java +++ b/jetty-core/jetty-http3/jetty-http3-server/src/main/java/org/eclipse/jetty/http3/server/HTTP3ServerConnectionFactory.java @@ -17,9 +17,6 @@ import java.util.concurrent.Executor; import java.util.concurrent.TimeoutException; -import org.eclipse.jetty.http.HttpFields; -import org.eclipse.jetty.http.HttpHeader; -import org.eclipse.jetty.http.HttpVersion; import org.eclipse.jetty.http3.HTTP3Stream; import org.eclipse.jetty.http3.api.Session; import org.eclipse.jetty.http3.api.Stream; @@ -28,11 +25,7 @@ import org.eclipse.jetty.http3.server.internal.HTTP3StreamServer; import org.eclipse.jetty.http3.server.internal.HttpStreamOverHTTP3; import org.eclipse.jetty.http3.server.internal.ServerHTTP3StreamConnection; -import org.eclipse.jetty.server.ConnectionMetaData; -import org.eclipse.jetty.server.Connector; import org.eclipse.jetty.server.HttpConfiguration; -import org.eclipse.jetty.server.NetworkConnector; -import org.eclipse.jetty.server.Request; import org.eclipse.jetty.util.Promise; import org.eclipse.jetty.util.thread.Invocable; import org.eclipse.jetty.util.thread.ThreadPool; @@ -49,20 +42,6 @@ public HTTP3ServerConnectionFactory() public HTTP3ServerConnectionFactory(HttpConfiguration configuration) { super(configuration, new HTTP3SessionListener()); - configuration.addCustomizer(new AltSvcCustomizer()); - } - - private static class AltSvcCustomizer implements HttpConfiguration.Customizer - { - @Override - public Request customize(Request request, HttpFields.Mutable responseHeaders) - { - ConnectionMetaData connectionMetaData = request.getConnectionMetaData(); - Connector connector = connectionMetaData.getConnector(); - if (connector instanceof NetworkConnector networkConnector && HttpVersion.HTTP_2 == connectionMetaData.getHttpVersion()) - responseHeaders.add(HttpHeader.ALT_SVC, String.format("h3=\":%d\"", networkConnector.getLocalPort())); - return request; - } } private static class HTTP3SessionListener implements HTTP3SessionServer.Listener diff --git a/jetty-core/jetty-tests/jetty-test-client-transports/src/test/java/org/eclipse/jetty/test/client/transport/AltSvcTest.java b/jetty-core/jetty-tests/jetty-test-client-transports/src/test/java/org/eclipse/jetty/test/client/transport/AltSvcTest.java new file mode 100644 index 000000000000..da7268a6c2be --- /dev/null +++ b/jetty-core/jetty-tests/jetty-test-client-transports/src/test/java/org/eclipse/jetty/test/client/transport/AltSvcTest.java @@ -0,0 +1,228 @@ +// +// ======================================================================== +// Copyright (c) 1995 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0, or the Apache License, Version 2.0 +// which is available at https://www.apache.org/licenses/LICENSE-2.0. +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package org.eclipse.jetty.test.client.transport; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory; +import org.eclipse.jetty.client.ContentResponse; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http2.HTTP2Cipher; +import org.eclipse.jetty.http2.client.HTTP2Client; +import org.eclipse.jetty.http2.client.transport.HttpClientTransportOverHTTP2; +import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory; +import org.eclipse.jetty.http3.server.HTTP3ServerConnectionFactory; +import org.eclipse.jetty.http3.server.HTTP3ServerQuicConfiguration; +import org.eclipse.jetty.io.ClientConnector; +import org.eclipse.jetty.quic.quiche.server.QuicheServerConnector; +import org.eclipse.jetty.quic.quiche.server.QuicheServerQuicConfiguration; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Response; +import org.eclipse.jetty.server.SecureRequestCustomizer; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.server.SslConnectionFactory; +import org.eclipse.jetty.toolchain.test.MavenPaths; +import org.eclipse.jetty.toolchain.test.jupiter.WorkDir; +import org.eclipse.jetty.toolchain.test.jupiter.WorkDirExtension; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.component.LifeCycle; +import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@ExtendWith(WorkDirExtension.class) +public class AltSvcTest +{ + public WorkDir workDir; + private Server server; + private HttpClient client; + + @AfterEach + public void dispose() + { + LifeCycle.stop(client); + LifeCycle.stop(server); + } + + @Test + public void testAltSvcHeaderContainsHTTP3Port() throws Exception + { + // Setup server with HTTP/2 (TLS) and HTTP/3 on different ports + server = new Server(); + + HttpConfiguration httpConfigH2 = new HttpConfiguration(); + httpConfigH2.addCustomizer(new SecureRequestCustomizer()); + + HttpConfiguration httpConfigH3 = new HttpConfiguration(); + httpConfigH3.addCustomizer(new SecureRequestCustomizer()); + + // SSL context factory for server + SslContextFactory.Server sslContextFactory = new SslContextFactory.Server(); + sslContextFactory.setKeyStorePath(MavenPaths.findTestResourceFile("keystore.p12").toString()); + sslContextFactory.setKeyStorePassword("storepwd"); + sslContextFactory.setCipherComparator(HTTP2Cipher.COMPARATOR); + sslContextFactory.setUseCipherSuitesOrder(true); + + // HTTP/2 connector with TLS + HTTP2ServerConnectionFactory h2Factory = new HTTP2ServerConnectionFactory(httpConfigH2); + ALPNServerConnectionFactory alpn = new ALPNServerConnectionFactory(); + alpn.setDefaultProtocol(h2Factory.getProtocol()); + SslConnectionFactory ssl = new SslConnectionFactory(sslContextFactory, alpn.getProtocol()); + ServerConnector h2Connector = new ServerConnector(server, ssl, alpn, h2Factory); + h2Connector.setPort(0); + server.addConnector(h2Connector); + + // HTTP/3 connector on a different port + QuicheServerQuicConfiguration serverQuicConfig = HTTP3ServerQuicConfiguration.configure(new QuicheServerQuicConfiguration(workDir.getEmptyPathDir())); + HTTP3ServerConnectionFactory h3Factory = new HTTP3ServerConnectionFactory(httpConfigH3); + QuicheServerConnector h3Connector = new QuicheServerConnector(server, sslContextFactory, serverQuicConfig, h3Factory); + h3Connector.setPort(0); + server.addConnector(h3Connector); + + server.setHandler(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) + { + response.setStatus(HttpStatus.OK_200); + callback.succeeded(); + return true; + } + }); + + server.start(); + + int h2Port = h2Connector.getLocalPort(); + int h3Port = h3Connector.getLocalPort(); + + // Create HTTP/2 client with TLS + SslContextFactory.Client sslContextFactoryClient = new SslContextFactory.Client(); + sslContextFactoryClient.setTrustAll(true); + + ClientConnector clientConnector = new ClientConnector(); + clientConnector.setSslContextFactory(sslContextFactoryClient); + + HTTP2Client http2Client = new HTTP2Client(clientConnector); + client = new HttpClient(new HttpClientTransportOverHTTP2(http2Client)); + client.start(); + + // Make HTTP/2 request + ContentResponse response = client.newRequest("localhost", h2Port) + .scheme("https") + .timeout(5, TimeUnit.SECONDS) + .send(); + + assertEquals(HttpStatus.OK_200, response.getStatus()); + + // Verify Alt-Svc header contains the HTTP/3 port (no ma attribute by default) + String altSvc = response.getHeaders().get(HttpHeader.ALT_SVC); + assertNotNull(altSvc, "Alt-Svc header should be present"); + assertEquals(String.format("h3=\":%d\"", h3Port), altSvc, + "Alt-Svc header should contain HTTP/3 port without ma attribute by default"); + } + + @Test + public void testAltSvcHeaderWithCustomMaxAge() throws Exception + { + // Setup server with HTTP/2 (TLS) and HTTP/3 on different ports + server = new Server(); + + HttpConfiguration httpConfigH2 = new HttpConfiguration(); + httpConfigH2.addCustomizer(new SecureRequestCustomizer()); + + HttpConfiguration httpConfigH3 = new HttpConfiguration(); + httpConfigH3.addCustomizer(new SecureRequestCustomizer()); + + // SSL context factory for server + SslContextFactory.Server sslContextFactory = new SslContextFactory.Server(); + sslContextFactory.setKeyStorePath(MavenPaths.findTestResourceFile("keystore.p12").toString()); + sslContextFactory.setKeyStorePassword("storepwd"); + sslContextFactory.setCipherComparator(HTTP2Cipher.COMPARATOR); + sslContextFactory.setUseCipherSuitesOrder(true); + + // HTTP/2 connector with TLS and custom AltSvcCustomizer maxAge + HTTP2ServerConnectionFactory h2Factory = new HTTP2ServerConnectionFactory(httpConfigH2); + // Find and configure the AltSvcCustomizer + HTTP2ServerConnectionFactory.AltSvcCustomizer h2AltSvc = httpConfigH2.getCustomizer(HTTP2ServerConnectionFactory.AltSvcCustomizer.class); + if (h2AltSvc != null) + { + h2AltSvc.setMaxAge(Duration.ofHours(24)); + } + + ALPNServerConnectionFactory alpn = new ALPNServerConnectionFactory(); + alpn.setDefaultProtocol(h2Factory.getProtocol()); + SslConnectionFactory ssl = new SslConnectionFactory(sslContextFactory, alpn.getProtocol()); + ServerConnector h2Connector = new ServerConnector(server, ssl, alpn, h2Factory); + h2Connector.setPort(0); + server.addConnector(h2Connector); + + // HTTP/3 connector on a different port + QuicheServerQuicConfiguration serverQuicConfig = HTTP3ServerQuicConfiguration.configure(new QuicheServerQuicConfiguration(workDir.getEmptyPathDir())); + HTTP3ServerConnectionFactory h3Factory = new HTTP3ServerConnectionFactory(httpConfigH3); + QuicheServerConnector h3Connector = new QuicheServerConnector(server, sslContextFactory, serverQuicConfig, h3Factory); + h3Connector.setPort(0); + server.addConnector(h3Connector); + + server.setHandler(new Handler.Abstract() + { + @Override + public boolean handle(Request request, Response response, Callback callback) + { + response.setStatus(HttpStatus.OK_200); + callback.succeeded(); + return true; + } + }); + + server.start(); + + int h2Port = h2Connector.getLocalPort(); + int h3Port = h3Connector.getLocalPort(); + + // Create HTTP/2 client with TLS + SslContextFactory.Client sslContextFactoryClient = new SslContextFactory.Client(); + sslContextFactoryClient.setTrustAll(true); + + ClientConnector clientConnector = new ClientConnector(); + clientConnector.setSslContextFactory(sslContextFactoryClient); + + HTTP2Client http2Client = new HTTP2Client(clientConnector); + client = new HttpClient(new HttpClientTransportOverHTTP2(http2Client)); + client.start(); + + // Make HTTP/2 request + ContentResponse response = client.newRequest("localhost", h2Port) + .scheme("https") + .timeout(5, TimeUnit.SECONDS) + .send(); + + assertEquals(HttpStatus.OK_200, response.getStatus()); + + // Verify Alt-Svc header contains port with ma attribute (since maxAge is set to 24 hours) + String altSvc = response.getHeaders().get(HttpHeader.ALT_SVC); + assertNotNull(altSvc, "Alt-Svc header should be present"); + assertEquals(String.format("h3=\":%d\"; ma=86400", h3Port), altSvc, + "Alt-Svc header should contain ma attribute when maxAge is set"); + } +}