From 7c32ea16ff1a20a6e02dfc26c65c7acaa6045190 Mon Sep 17 00:00:00 2001 From: Santiago Pericasgeertsen Date: Wed, 18 Oct 2023 10:07:02 -0400 Subject: [PATCH] Initial support for proxy protocol data V1 and V2. Minor changes to API to make protocol data available to application handlers. --- .../common/socket/SocketOptionsBlueprint.java | 9 + .../webserver/http2/Http2ServerRequest.java | 7 + .../helidon/webserver/ConnectionContext.java | 11 + .../helidon/webserver/ConnectionHandler.java | 18 +- .../helidon/webserver/ProxyProtocolData.java | 79 +++++++ .../webserver/ProxyProtocolHandler.java | 194 ++++++++++++++++++ .../io/helidon/webserver/ServerListener.java | 3 +- .../helidon/webserver/http/ServerRequest.java | 11 + .../webserver/http1/Http1ServerRequest.java | 7 + .../webserver/ProxyProtocolHandlerTest.java | 76 +++++++ 10 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 webserver/webserver/src/main/java/io/helidon/webserver/ProxyProtocolData.java create mode 100644 webserver/webserver/src/main/java/io/helidon/webserver/ProxyProtocolHandler.java create mode 100644 webserver/webserver/src/test/java/io/helidon/webserver/ProxyProtocolHandlerTest.java diff --git a/common/socket/src/main/java/io/helidon/common/socket/SocketOptionsBlueprint.java b/common/socket/src/main/java/io/helidon/common/socket/SocketOptionsBlueprint.java index c487c778a29..f0e74e9b246 100644 --- a/common/socket/src/main/java/io/helidon/common/socket/SocketOptionsBlueprint.java +++ b/common/socket/src/main/java/io/helidon/common/socket/SocketOptionsBlueprint.java @@ -109,6 +109,15 @@ interface SocketOptionsBlueprint { @ConfiguredOption("false") boolean tcpNoDelay(); + /** + * Enable support for proxy protocol for this socket. + * Default is {@code false}. + * + * @return proxy support status + */ + @ConfiguredOption("false") + boolean enableProxyProtocol(); + /** * Configure socket with defined socket options. * diff --git a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerRequest.java b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerRequest.java index cc556612d88..97fac2aefeb 100644 --- a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerRequest.java +++ b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerRequest.java @@ -18,6 +18,7 @@ import java.io.InputStream; import java.util.Objects; +import java.util.Optional; import java.util.function.Supplier; import java.util.function.UnaryOperator; @@ -38,6 +39,7 @@ import io.helidon.http.media.ReadableEntity; import io.helidon.webserver.ConnectionContext; import io.helidon.webserver.ListenerContext; +import io.helidon.webserver.ProxyProtocolData; import io.helidon.webserver.http.HttpSecurity; import io.helidon.webserver.http.RoutingRequest; @@ -220,6 +222,11 @@ public void streamFilter(UnaryOperator filterFunction) { this.streamFilter = it -> filterFunction.apply(current.apply(it)); } + @Override + public Optional proxyProtocolData() { + return Optional.ofNullable(ctx.proxyProtocolData()); + } + private UriInfo createUriInfo() { return ctx.listenerContext().config().requestedUriDiscoveryContext().uriInfo(remotePeer().address().toString(), localPeer().address().toString(), diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/ConnectionContext.java b/webserver/webserver/src/main/java/io/helidon/webserver/ConnectionContext.java index bbca8281788..681553144bb 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/ConnectionContext.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/ConnectionContext.java @@ -21,6 +21,7 @@ import io.helidon.common.buffers.DataReader; import io.helidon.common.buffers.DataWriter; import io.helidon.common.socket.SocketContext; +import io.helidon.common.socket.SocketOptions; /** * Server connection context. @@ -60,4 +61,14 @@ public interface ConnectionContext extends SocketContext { * @return rouer */ Router router(); + + /** + * Proxy protocol header data. + * + * @return header data or {@code null} if proxy protocol not enabled on socket + * @see SocketOptions#enableProxyProtocol() + */ + default ProxyProtocolData proxyProtocolData() { + return null; + } } diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/ConnectionHandler.java b/webserver/webserver/src/main/java/io/helidon/webserver/ConnectionHandler.java index c4bb7ab11b0..3d2027568c8 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/ConnectionHandler.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/ConnectionHandler.java @@ -33,6 +33,7 @@ import io.helidon.common.socket.HelidonSocket; import io.helidon.common.socket.PeerInfo; import io.helidon.common.socket.PlainSocket; +import io.helidon.common.socket.SocketOptions; import io.helidon.common.socket.SocketWriter; import io.helidon.common.socket.TlsSocket; import io.helidon.common.task.InterruptableTask; @@ -64,11 +65,13 @@ class ConnectionHandler implements InterruptableTask, ConnectionContext { private final String serverChannelId; private final Router router; private final Tls tls; + private final SocketOptions connectionOptions; private ServerConnection connection; private HelidonSocket helidonSocket; private DataReader reader; private SocketWriter writer; + private ProxyProtocolData proxyProtocolData; ConnectionHandler(ListenerContext listenerContext, Semaphore connectionSemaphore, @@ -78,7 +81,8 @@ class ConnectionHandler implements InterruptableTask, ConnectionContext { Socket socket, String serverChannelId, Router router, - Tls tls) { + Tls tls, + SocketOptions connectionOptions) { this.listenerContext = listenerContext; this.connectionSemaphore = connectionSemaphore; this.requestSemaphore = requestSemaphore; @@ -89,6 +93,7 @@ class ConnectionHandler implements InterruptableTask, ConnectionContext { this.serverChannelId = serverChannelId; this.router = router; this.tls = tls; + this.connectionOptions = connectionOptions; } @Override @@ -100,6 +105,12 @@ public boolean canInterrupt() { public final void run() { String channelId = "0x" + HexFormat.of().toHexDigits(System.identityHashCode(socket)); + // proxy protocol before SSL handshake + if (connectionOptions.enableProxyProtocol()) { + ProxyProtocolHandler handler = new ProxyProtocolHandler(socket, channelId); + proxyProtocolData = handler.get(); + } + // handle SSL and init helidonSocket, reader and writer try { if (tls.enabled()) { @@ -226,6 +237,11 @@ public Router router() { return router; } + @Override + public ProxyProtocolData proxyProtocolData() { + return proxyProtocolData; + } + private ServerConnection identifyConnection() { try { reader.ensureAvailable(); diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/ProxyProtocolData.java b/webserver/webserver/src/main/java/io/helidon/webserver/ProxyProtocolData.java new file mode 100644 index 00000000000..25201ede9b2 --- /dev/null +++ b/webserver/webserver/src/main/java/io/helidon/webserver/ProxyProtocolData.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.webserver; + +/** + * Proxy protocol data parsed by {@link ProxyProtocolHandler}. + */ +public interface ProxyProtocolData { + + /** + * The protocol family options. + */ + enum ProtocolFamily { + /** + * TCP version 4. + */ + TCP4, + + /** + * TCP version 6. + */ + TCP6, + + /** + * Protocol family is unknown. + */ + UNKNOWN + } + + /** + * Protocol family from protocol header. + * + * @return protocol family + */ + ProtocolFamily protocolFamily(); + + /** + * Source address that is either IPv4 or IPv6 depending on {@link #protocolFamily()}}. + * + * @return source address + */ + String sourceAddress(); + + /** + * Destination address that is either IPv4 or IPv6 depending on {@link #protocolFamily()}}. + * + * @return source address + */ + String destAddress(); + + /** + * Source port number. + * + * @return source port. + */ + int sourcePort(); + + /** + * Destination port number. + * + * @return port number. + */ + int destPort(); +} + + diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/ProxyProtocolHandler.java b/webserver/webserver/src/main/java/io/helidon/webserver/ProxyProtocolHandler.java new file mode 100644 index 00000000000..138bd8115a2 --- /dev/null +++ b/webserver/webserver/src/main/java/io/helidon/webserver/ProxyProtocolHandler.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.webserver; + +import java.io.IOException; +import java.io.PushbackInputStream; +import java.io.UncheckedIOException; +import java.lang.System.Logger.Level; +import java.net.Socket; +import java.util.Arrays; +import java.util.function.Supplier; + +import io.helidon.http.DirectHandler; +import io.helidon.http.RequestException; + +class ProxyProtocolHandler implements Supplier { + private static final System.Logger LOGGER = System.getLogger(ProxyProtocolHandler.class.getName()); + + private static final int MAX_V1_FIELD_LENGTH = 40; + + static final byte[] V1_PREFIX = { + (byte) 'P', + (byte) 'R', + (byte) 'O', + (byte) 'X', + (byte) 'Y', + }; + + static final byte[] V2_PREFIX = { + (byte) 0x0D, + (byte) 0x0A, + (byte) 0x0D, + (byte) 0x0A, + (byte) 0x00, + (byte) 0x0D, + (byte) 0x0A, + (byte) 0x51, + (byte) 0x55, + (byte) 0x49, + (byte) 0x54, + (byte) 0x0A + }; + + static final RequestException BAD_PROTOCOL_EXCEPTION = RequestException.builder() + .type(DirectHandler.EventType.OTHER) + .message("Unable to parse proxy protocol header") + .build(); + + private final Socket socket; + private final String channelId; + + ProxyProtocolHandler(Socket socket, String channelId) { + this.socket = socket; + this.channelId = channelId; + } + + @Override + public ProxyProtocolData get() { + LOGGER.log(Level.DEBUG, "Reading proxy protocol data for channel %s", channelId); + + try { + byte[] prefix = new byte[V1_PREFIX.length]; + PushbackInputStream inputStream = new PushbackInputStream(socket.getInputStream(), 1); + int n = inputStream.read(prefix); + if (n < V1_PREFIX.length) { + throw BAD_PROTOCOL_EXCEPTION; + } + if (arrayEquals(prefix, V1_PREFIX, V1_PREFIX.length)) { + return handleV1Protocol(inputStream); + } else if (arrayEquals(prefix, V2_PREFIX, V1_PREFIX.length)) { + return handleV2Protocol(inputStream); + } else { + throw BAD_PROTOCOL_EXCEPTION; + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + static ProxyProtocolData handleV1Protocol(PushbackInputStream inputStream) throws IOException { + try { + int n; + byte[] buffer = new byte[MAX_V1_FIELD_LENGTH]; + + match(inputStream, (byte) ' '); + + // protocol and family + n = readUntil(inputStream, buffer, (byte) ' ', (byte) '\r'); + var family = ProxyProtocolData.ProtocolFamily.valueOf(new String(buffer, 0, n)); + byte b = (byte) inputStream.read(); + if (b == (byte) '\r') { + // special case for just UNKNOWN family + if (family == ProxyProtocolData.ProtocolFamily.UNKNOWN) { + return new ProxyProtocolDataImpl(ProxyProtocolData.ProtocolFamily.UNKNOWN, + null, null, -1, -1); + } + } + + match(b, (byte) ' '); + + // source address + n = readUntil(inputStream, buffer, (byte) ' '); + var sourceAddress = new String(buffer, 0, n); + match(inputStream, (byte) ' '); + + // destination address + n = readUntil(inputStream, buffer, (byte) ' '); + var destAddress = new String(buffer, 0, n); + match(inputStream, (byte) ' '); + + // source port + n = readUntil(inputStream, buffer, (byte) ' '); + int sourcePort = Integer.parseInt(new String(buffer, 0, n)); + match(inputStream, (byte) ' '); + + // destination port + n = readUntil(inputStream, buffer, (byte) '\r'); + int destPort = Integer.parseInt(new String(buffer, 0, n)); + match(inputStream, (byte) '\r'); + match(inputStream, (byte) '\n'); + + return new ProxyProtocolDataImpl(family, sourceAddress, destAddress, sourcePort, destPort); + } catch (IllegalArgumentException e) { + throw BAD_PROTOCOL_EXCEPTION; + } + } + + private static void match(byte a, byte b) { + if (a != b) { + throw BAD_PROTOCOL_EXCEPTION; + } + } + + private static void match(PushbackInputStream inputStream, byte b) throws IOException { + if (inputStream.read() != b) { + throw BAD_PROTOCOL_EXCEPTION; + } + } + + private static int readUntil(PushbackInputStream inputStream, byte[] buffer, byte... delims) throws IOException { + int n = 0; + do { + int b = inputStream.read(); + if (b < 0) { + throw BAD_PROTOCOL_EXCEPTION; + } + if (arrayContains(delims, (byte) b)) { + inputStream.unread(b); + return n; + } + buffer[n++] = (byte) b; + if (n >= buffer.length) { + throw BAD_PROTOCOL_EXCEPTION; + } + } while (true); + } + + static ProxyProtocolData handleV2Protocol(PushbackInputStream inputStream) throws IOException { + return null; + } + + private static boolean arrayEquals(byte[] array1, byte[] array2, int prefix) { + return Arrays.equals(array1, 0, prefix, array2, 0, prefix); + } + + private static boolean arrayContains(byte[] array, byte b) { + for (byte a : array) { + if (a == b) { + return true; + } + } + return false; + } + + record ProxyProtocolDataImpl(ProtocolFamily protocolFamily, + String sourceAddress, + String destAddress, + int sourcePort, + int destPort) implements ProxyProtocolData { + } +} diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/ServerListener.java b/webserver/webserver/src/main/java/io/helidon/webserver/ServerListener.java index c5e0761f7a1..7de4a5b6088 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/ServerListener.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/ServerListener.java @@ -346,7 +346,8 @@ private void listen() { socket, serverChannelId, router, - tls); + tls, + connectionOptions); readerExecutor.execute(handler); } catch (RejectedExecutionException e) { LOGGER.log(ERROR, "Executor rejected handler for new connection"); diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerRequest.java b/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerRequest.java index e9f6138e567..cb3b84bc455 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerRequest.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/http/ServerRequest.java @@ -17,12 +17,15 @@ package io.helidon.webserver.http; import java.io.InputStream; +import java.util.Optional; import java.util.function.UnaryOperator; import io.helidon.common.context.Context; +import io.helidon.common.socket.SocketOptions; import io.helidon.http.RoutedPath; import io.helidon.http.media.ReadableEntity; import io.helidon.webserver.ListenerContext; +import io.helidon.webserver.ProxyProtocolData; /** * HTTP server request. @@ -110,4 +113,12 @@ public interface ServerRequest extends HttpRequest { * @param filterFunction the function to replace input stream of this request with a user provided one */ void streamFilter(UnaryOperator filterFunction); + + /** + * Access proxy protocol data for the connection on which this request was sent. + * + * @return proxy protocol data, if available + * @see SocketOptions#enableProxyProtocol() + */ + Optional proxyProtocolData(); } diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerRequest.java b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerRequest.java index 0058084cdc2..9153b610bb1 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerRequest.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/http1/Http1ServerRequest.java @@ -16,6 +16,7 @@ package io.helidon.webserver.http1; +import java.util.Optional; import java.util.concurrent.CountDownLatch; import java.util.function.Supplier; @@ -36,6 +37,7 @@ import io.helidon.http.encoding.ContentDecoder; import io.helidon.webserver.ConnectionContext; import io.helidon.webserver.ListenerContext; +import io.helidon.webserver.ProxyProtocolData; import io.helidon.webserver.http.HttpSecurity; import io.helidon.webserver.http.RoutingRequest; @@ -207,6 +209,11 @@ public UriInfo requestedUri() { return uriInfo.get(); } + @Override + public Optional proxyProtocolData() { + return Optional.ofNullable(ctx.proxyProtocolData()); + } + private UriInfo createUriInfo() { return ctx.listenerContext().config().requestedUriDiscoveryContext().uriInfo(remotePeer().address().toString(), localPeer().address().toString(), diff --git a/webserver/webserver/src/test/java/io/helidon/webserver/ProxyProtocolHandlerTest.java b/webserver/webserver/src/test/java/io/helidon/webserver/ProxyProtocolHandlerTest.java new file mode 100644 index 00000000000..c3f889de27c --- /dev/null +++ b/webserver/webserver/src/test/java/io/helidon/webserver/ProxyProtocolHandlerTest.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.webserver; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.PushbackInputStream; +import java.nio.charset.StandardCharsets; + +import io.helidon.http.RequestException; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProxyProtocolHandlerTest { + + @Test + void basicV1Test() throws IOException { + String header = " TCP4 192.168.0.1 192.168.0.11 56324 443\r\n"; // excludes PROXY prefix + ProxyProtocolData data = ProxyProtocolHandler.handleV1Protocol(new PushbackInputStream( + new ByteArrayInputStream(header.getBytes(StandardCharsets.US_ASCII)))); + assertThat(data.protocolFamily(), is(ProxyProtocolData.ProtocolFamily.TCP4)); + assertThat(data.sourceAddress(), is("192.168.0.1")); + assertThat(data.destAddress(), is("192.168.0.11")); + assertThat(data.sourcePort(), is(56324)); + assertThat(data.destPort(), is(443)); + } + + @Test + void unknownV1Test() throws IOException { + String header = " UNKNOWN\r\n"; // excludes PROXY prefix + ProxyProtocolData data = ProxyProtocolHandler.handleV1Protocol(new PushbackInputStream( + new ByteArrayInputStream(header.getBytes(StandardCharsets.US_ASCII)))); + assertThat(data.protocolFamily(), is(ProxyProtocolData.ProtocolFamily.UNKNOWN)); + assertThat(data.sourceAddress(), nullValue()); + assertThat(data.destAddress(), nullValue()); + assertThat(data.sourcePort(), is(-1)); + assertThat(data.destPort(), is(-1)); + } + + @Test + void badV1Test() { + String header1 = " MYPROTOCOL 192.168.0.1 192.168.0.11 56324 443\r\n"; + assertThrows(RequestException.class, () -> + ProxyProtocolHandler.handleV1Protocol(new PushbackInputStream( + new ByteArrayInputStream(header1.getBytes(StandardCharsets.US_ASCII))))); + String header2 = " TCP4 192.168.0.1 192.168.0.11 56324\r\n"; + assertThrows(RequestException.class, () -> + ProxyProtocolHandler.handleV1Protocol(new PushbackInputStream( + new ByteArrayInputStream(header2.getBytes(StandardCharsets.US_ASCII))))); + String header3 = " TCP4 192.168.0.1 192.168.0.11 56324 443"; + assertThrows(RequestException.class, () -> + ProxyProtocolHandler.handleV1Protocol(new PushbackInputStream( + new ByteArrayInputStream(header3.getBytes(StandardCharsets.US_ASCII))))); + String header4 = " TCP4 192.168.0.1 56324 443\r\n"; + assertThrows(RequestException.class, () -> + ProxyProtocolHandler.handleV1Protocol(new PushbackInputStream( + new ByteArrayInputStream(header4.getBytes(StandardCharsets.US_ASCII))))); + } +}