From 2460c8b8f19b703e8c4dad81a24eb5e978544866 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 21:39:24 +0000 Subject: [PATCH] feat: support authenticating egress proxy for outbound HTTP clients Allow Wave to work behind a corporate egress proxy that requires authentication. The Java HttpClient instances created by HttpClientFactory now accept a proxy configuration resolved from the wave.httpclient.proxy.* settings, falling back to the HTTPS_PROXY/HTTP_PROXY/NO_PROXY environment variables, and send the proxy credentials via an Authenticator scoped to proxy requests originating from the configured proxy host. When an authenticating proxy is configured, the jdk.http.auth.tunneling.disabledSchemes and jdk.http.auth.proxying.disabledSchemes system properties are defaulted to empty at bootstrap - unless already set by the operator - since the JDK disables Basic authentication on HTTPS CONNECT tunnelling by default. No proxy configured means unchanged behaviour. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018zJyj9FkzUpMNuH51ScqAQ Signed-off-by: Claude --- build.gradle | 3 + docs/configuration.md | 34 +++ .../configuration/HttpClientConfig.groovy | 61 +++- .../seqera/wave/http/HttpClientFactory.groovy | 66 ++-- .../seqera/wave/http/HttpProxyConfig.groovy | 283 ++++++++++++++++++ .../seqera/wave/http/FakeProxyServer.groovy | 176 +++++++++++ .../http/HttpClientFactoryProxyTest.groovy | 233 ++++++++++++++ .../wave/http/HttpProxyConfigTest.groovy | 179 +++++++++++ 8 files changed, 1015 insertions(+), 20 deletions(-) create mode 100644 src/main/groovy/io/seqera/wave/http/HttpProxyConfig.groovy create mode 100644 src/test/groovy/io/seqera/wave/http/FakeProxyServer.groovy create mode 100644 src/test/groovy/io/seqera/wave/http/HttpClientFactoryProxyTest.groovy create mode 100644 src/test/groovy/io/seqera/wave/http/HttpProxyConfigTest.groovy diff --git a/build.gradle b/build.gradle index 8f66c07620..686fd60eeb 100644 --- a/build.gradle +++ b/build.gradle @@ -194,6 +194,9 @@ test { environment 'QUAY_PAT', project.findProperty('QUAY_PAT') ?: environment['QUAY_PAT'] systemProperty 'logback.configurationFile', 'src/test/resources/logback-test.xml' + // enable Basic authentication for HTTPS proxy tunnelling (CONNECT requests), + // required by `HttpClientFactoryProxyTest` - see also `HttpClientConfig.enableProxyAuthSchemes` + systemProperty 'jdk.http.auth.tunneling.disabledSchemes', '' } diff --git a/docs/configuration.md b/docs/configuration.md index 75eef17b57..ef4f434339 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -167,6 +167,40 @@ Configure the HTTP client with the following options: `wave.httpclient.retry.multiplier` *(optional)* : Multiplier for HTTP client retries (default: `1.75`). +### Egress proxy + +When Wave is deployed behind a corporate egress proxy, configure the proxy used by Wave's internal HTTP clients — container registry requests (authentication, manifests, blobs served by the Wave process) and Seqera Platform API requests — with the following options. + +These settings apply to the connections made by the Wave service process itself. They do not apply to the Micronaut declarative HTTP clients (configurable with the standard `micronaut.http.client.*`/`micronaut.http.services.*` proxy settings), to AWS SDK clients such as ECR and S3 (configurable with the AWS SDK proxy system properties), or to build, scan, and blob-cache jobs that run as separate containers. + +`wave.httpclient.proxy.uri` *(optional)* +: URI of the egress proxy in the form `[http://][username:password@]host[:port]`, for example `http://proxy.example.com:3128`. + Credentials can be embedded in the URI or provided separately with the options below. + When this option is not set, Wave falls back to the `HTTPS_PROXY`/`HTTP_PROXY` (and `NO_PROXY`) environment variables, if defined. + +`wave.httpclient.proxy.username` *(optional)* +: Username to authenticate against the proxy. Takes precedence over credentials embedded in the proxy URI. + +`wave.httpclient.proxy.password` *(optional)* +: Password to authenticate against the proxy. + +`wave.httpclient.proxy.no-proxy` *(optional)* +: Comma-separated list of hosts that must be accessed directly, bypassing the proxy. + Entries can be exact host names (`registry.example.com`), domain suffixes (`.example.com` or `*.example.com`), IPv4 CIDR blocks (`10.0.0.0/8`), or `*` to bypass the proxy for all hosts. + Loopback addresses such as `localhost` and `127.0.0.1` always bypass the proxy, unless the proxy itself is a loopback address. + +When no proxy is configured — neither via the settings above nor via environment variables — the behavior is unchanged and outbound connections are made directly. + +:::note +By default, the JVM disables the `Basic` scheme for proxy authentication on HTTPS tunneling (`CONNECT`) requests via the `jdk.http.auth.tunneling.disabledSchemes` system property. +When a proxy with credentials is configured, Wave automatically defaults `jdk.http.auth.tunneling.disabledSchemes` and `jdk.http.auth.proxying.disabledSchemes` to an empty value at startup, unless they are already set. +Alternatively — for example, to guarantee that the properties are set before any outbound connection is made — set them explicitly on the JVM. For containerized deployments this can be done with the `JAVA_TOOL_OPTIONS` environment variable: + +```bash +JAVA_TOOL_OPTIONS="-Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes=" +``` +::: + ## Container build process Configure how Wave builds container images and manages associated logs for monitoring, troubleshooting, and delivery with the following options: diff --git a/src/main/groovy/io/seqera/wave/configuration/HttpClientConfig.groovy b/src/main/groovy/io/seqera/wave/configuration/HttpClientConfig.groovy index a2194b18cd..b7254cb624 100644 --- a/src/main/groovy/io/seqera/wave/configuration/HttpClientConfig.groovy +++ b/src/main/groovy/io/seqera/wave/configuration/HttpClientConfig.groovy @@ -23,17 +23,19 @@ import javax.annotation.PostConstruct import groovy.transform.CompileStatic import groovy.util.logging.Slf4j +import io.micronaut.context.annotation.Context import io.micronaut.context.annotation.Value import io.micronaut.core.annotation.Nullable import io.seqera.util.retry.Retryable -import jakarta.inject.Singleton +import io.seqera.wave.http.HttpClientFactory +import io.seqera.wave.http.HttpProxyConfig /** * Model Http Client settings * * @author Paolo Di Tommaso */ @CompileStatic -@Singleton +@Context @Slf4j class HttpClientConfig implements Retryable.Config { @@ -59,9 +61,64 @@ class HttpClientConfig implements Retryable.Config { @Value('${wave.httpclient.streamThreshold:65536}') private int streamThreshold + @Value('${wave.httpclient.proxy.uri}') + @Nullable + private String proxyUri + + @Value('${wave.httpclient.proxy.username}') + @Nullable + private String proxyUsername + + @Value('${wave.httpclient.proxy.password}') + @Nullable + private String proxyPassword + + @Value('${wave.httpclient.proxy.no-proxy}') + @Nullable + private String proxyNoProxy + + /** + * Resolve the egress proxy settings from the {@code wave.httpclient.proxy.*} configuration, + * falling back to the {@code HTTPS_PROXY}/{@code HTTP_PROXY}/{@code NO_PROXY} environment + * variables when no explicit setting is provided + * + * @return The resolved {@link HttpProxyConfig} or {@code null} when no proxy is defined + */ + HttpProxyConfig proxyConfig() { + return proxyUri + ? HttpProxyConfig.parse(proxyUri, proxyUsername, proxyPassword, proxyNoProxy) + : HttpProxyConfig.fromEnvironment() + } + @PostConstruct private void init() { log.info "Http client config: connectTimeout=$connectTimeout; retryAttempts=$retryAttempts; retryDelay=$retryDelay; retryMaxDelay=$retryMaxDelay; retryMultiplier=$retryMultiplier; streamThreshold=$streamThreshold" + final proxy = proxyConfig() + HttpClientFactory.setProxyConfig(proxy) + if( proxy ) { + log.info "Http client proxy config: $proxy" + if( proxy.username ) + enableProxyAuthSchemes() + } + } + + /** + * By default the JDK disables the Basic scheme for proxy authentication over HTTPS + * tunnelling (CONNECT requests) via the {@code jdk.http.auth.tunneling.disabledSchemes} + * system property. When an authenticating proxy is configured, default these properties + * to empty so that Basic credentials can be sent to the proxy, unless the operator has + * already set them e.g. via {@code JAVA_TOOL_OPTIONS} + */ + static protected void enableProxyAuthSchemes() { + for( String name : List.of('jdk.http.auth.tunneling.disabledSchemes', 'jdk.http.auth.proxying.disabledSchemes') ) { + if( System.getProperty(name) == null ) { + System.setProperty(name, '') + log.info "Setting system property '$name' to empty string to enable Basic proxy authentication" + } + else { + log.debug "System property '$name' already set to '${System.getProperty(name)}'" + } + } } Duration getDelay() { retryDelay } diff --git a/src/main/groovy/io/seqera/wave/http/HttpClientFactory.groovy b/src/main/groovy/io/seqera/wave/http/HttpClientFactory.groovy index 46c9730d24..f00db53c4b 100644 --- a/src/main/groovy/io/seqera/wave/http/HttpClientFactory.groovy +++ b/src/main/groovy/io/seqera/wave/http/HttpClientFactory.groovy @@ -48,6 +48,47 @@ class HttpClientFactory { private static HttpClient client2 + private static volatile HttpProxyConfig proxyConfig + + + /** + * Set the egress proxy configuration to be used by the clients created by this factory. + * The proxy is resolved at bootstrap by {@link io.seqera.wave.configuration.HttpClientConfig}. + * Cached client instances are discarded so that the new settings are applied to clients + * obtained after this call + * + * @param config The {@link HttpProxyConfig} to be applied, or {@code null} to use no proxy + */ + static void setProxyConfig(HttpProxyConfig config) { + if( config == null && proxyConfig == null ) + return + l1.lock() + try { + proxyConfig = config + client1 = null + } + finally { + l1.unlock() + } + l2.lock() + try { + client2 = null + } + finally { + l2.unlock() + } + } + + static private HttpClient.Builder applyProxyConfig(HttpClient.Builder builder) { + final proxy = proxyConfig + if( proxy ) { + builder.proxy(proxy.proxySelector()) + final auth = proxy.authenticator() + if( auth ) + builder.authenticator(auth) + } + return builder + } static HttpClient followRedirectsHttpClient() { if( client1!=null ) @@ -56,7 +97,7 @@ class HttpClientFactory { try { if( client1!=null ) return client1 - return client1=followRedirectsHttpClient0() + return client1=newHttpClient0(HttpClient.Redirect.NORMAL) } finally { l1.unlock() } @@ -69,7 +110,7 @@ class HttpClientFactory { try { if( client2!=null ) return client2 - return client2=neverRedirectsHttpClient0() + return client2=newHttpClient0(HttpClient.Redirect.NEVER) } finally { l2.unlock() } @@ -79,25 +120,14 @@ class HttpClientFactory { return followRedirectsHttpClient() } - static private HttpClient followRedirectsHttpClient0() { - final result = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_1_1) - .followRedirects(HttpClient.Redirect.NORMAL) - .connectTimeout(timeout) - .executor(threadPool) - .build() - log.debug "Creating new followRedirectsHttpClient: $result" - return result - } - - static private HttpClient neverRedirectsHttpClient0() { - final result = HttpClient.newBuilder() + static private HttpClient newHttpClient0(HttpClient.Redirect redirect) { + final builder = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_1_1) - .followRedirects(HttpClient.Redirect.NEVER) + .followRedirects(redirect) .connectTimeout(timeout) .executor(threadPool) - .build() - log.debug "Creating new neverRedirectsHttpClient: $result" + final result = applyProxyConfig(builder).build() + log.debug "Creating new httpClient with $redirect redirects policy: $result" return result } diff --git a/src/main/groovy/io/seqera/wave/http/HttpProxyConfig.groovy b/src/main/groovy/io/seqera/wave/http/HttpProxyConfig.groovy new file mode 100644 index 0000000000..ee34dde8cb --- /dev/null +++ b/src/main/groovy/io/seqera/wave/http/HttpProxyConfig.groovy @@ -0,0 +1,283 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.http + +import java.nio.charset.StandardCharsets + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import io.seqera.wave.util.StringUtils +/** + * Model the configuration of an (authenticating) HTTP egress proxy used + * by {@link HttpClientFactory} to create {@link java.net.http.HttpClient} instances + * + * The proxy can be specified either via the {@code wave.httpclient.proxy.*} configuration + * settings or the {@code HTTPS_PROXY}/{@code HTTP_PROXY}/{@code NO_PROXY} environment variables + * + * @author Paolo Di Tommaso + */ +@Slf4j +@CompileStatic +class HttpProxyConfig { + + final String host + + final int port + + final String username + + final String password + + final List noProxy + + // no-proxy entries pre-compiled at construction time since `shouldBypass` + // runs on every outbound request via the proxy selector + private final boolean proxyLoopback + private final boolean bypassAll + private final Set exactHosts + private final List suffixPatterns + private final List cidrPatterns + + protected HttpProxyConfig(String host, int port, String username, String password, List noProxy) { + this.host = host + this.port = port + this.username = username + this.password = password + this.noProxy = noProxy ?: List.of() + this.proxyLoopback = isLoopback(host.toLowerCase()) + this.exactHosts = new HashSet<>() + this.suffixPatterns = new ArrayList<>() + this.cidrPatterns = new ArrayList<>() + boolean all = false + for( String pattern : this.noProxy ) { + if( pattern == '*' ) { + all = true + } + else if( pattern.contains('/') ) { + final cidr = Cidr.parse(pattern) + if( cidr != null ) + cidrPatterns.add(cidr) + } + else { + // a domain suffix e.g. `.example.com` or `*.example.com` matches + // any sub-domain as well as the bare domain itself + final suffix = pattern.startsWith('*.') ? pattern.substring(1) : pattern + if( suffix.startsWith('.') ) { + suffixPatterns.add(suffix) + exactHosts.add(suffix.substring(1)) + } + else + exactHosts.add(suffix) + } + } + this.bypassAll = all + } + + /** + * Parse a proxy URI in the form {@code [http[s]://][user:password@]host[:port]} + * + * @param uri The proxy URI string + * @param username The proxy username; when provided it takes precedence over the URI user-info + * @param password The proxy password; when provided it takes precedence over the URI user-info + * @param noProxy Comma separated list of hosts that should bypass the proxy + * @return The corresponding {@link HttpProxyConfig} object or {@code null} when the URI is empty + */ + static HttpProxyConfig parse(String uri, String username=null, String password=null, String noProxy=null) { + if( !uri ) + return null + final URI parsed + try { + parsed = new URI(uri.contains('://') ? uri : 'http://' + uri) + } + catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid proxy URI - offending value: '$uri'", e) + } + if( !parsed.host ) + throw new IllegalArgumentException("Invalid proxy URI - missing host name - offending value: '$uri'") + final port = parsed.port > 0 + ? parsed.port + : (parsed.scheme == 'https' ? 443 : 80) + String user = username + String pass = password + if( !user && parsed.userInfo ) { + final p = parsed.userInfo.indexOf(':') + user = decode(p >= 0 ? parsed.userInfo.substring(0, p) : parsed.userInfo) + if( p >= 0 ) + pass = decode(parsed.userInfo.substring(p + 1)) + } + return new HttpProxyConfig(parsed.host, port, user, pass, splitNoProxy(noProxy)) + } + + /** + * Create the proxy configuration from the {@code HTTPS_PROXY}/{@code HTTP_PROXY} and + * {@code NO_PROXY} environment variables (upper and lower case variants are supported) + * + * @param env The environment map, defaults to {@link System#getenv()} + * @return The corresponding {@link HttpProxyConfig} or {@code null} when no proxy is defined + */ + static HttpProxyConfig fromEnvironment(Map env = System.getenv()) { + final uri = env.get('HTTPS_PROXY') ?: env.get('https_proxy') ?: env.get('HTTP_PROXY') ?: env.get('http_proxy') + if( !uri ) + return null + final noProxy = env.get('NO_PROXY') ?: env.get('no_proxy') + return parse(uri, null, null, noProxy) + } + + static private String decode(String value) { + return URLDecoder.decode(value, StandardCharsets.UTF_8) + } + + static private List splitNoProxy(String noProxy) { + return noProxy + ? noProxy.tokenize(',').collect(it -> it.trim().toLowerCase()).findAll(it -> it.size()>0) + : List.of() + } + + /** + * @return A {@link ProxySelector} routing all requests via this proxy, except the + * hosts matching the no-proxy list + */ + ProxySelector proxySelector() { + final proxied = List.of(new Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port))) + final direct = List.of(Proxy.NO_PROXY) + return new ProxySelector() { + @Override + List select(URI uri) { + return shouldBypass(uri.host) ? direct : proxied + } + @Override + void connectFailed(URI uri, SocketAddress sa, IOException ioe) { + log.warn "Unable to connect proxy ${sa} for request ${uri} - cause: ${ioe.message}" + } + } + } + + /** + * @return An {@link Authenticator} providing the proxy credentials, restricted to + * proxy authentication requests originating from this proxy host and port, or + * {@code null} when no credentials are defined + */ + Authenticator authenticator() { + if( !username ) + return null + final auth = new PasswordAuthentication(username, (password ?: '').toCharArray()) + final proxyHost = host + final proxyPort = port + return new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + if( getRequestorType() == Authenticator.RequestorType.PROXY + && proxyHost.equalsIgnoreCase(getRequestingHost()) + && proxyPort == getRequestingPort() ) + return auth + return null + } + } + } + + /** + * Determine whether a target host should bypass the proxy. Entries in the no-proxy list + * can be exact host names, domain suffixes e.g. {@code .example.com} or {@code *.example.com}, + * IPv4 CIDR blocks e.g. {@code 10.0.0.0/8} or the {@code *} wildcard. Loopback addresses + * always bypass the proxy, unless the proxy itself is a loopback address + * + * @param targetHost The host name of the request to be evaluated + * @return {@code true} when the request should be made directly, {@code false} when it should go via the proxy + */ + boolean shouldBypass(String targetHost) { + if( !targetHost ) + return false + final target = targetHost.toLowerCase() + if( isLoopback(target) && !proxyLoopback ) + return true + if( bypassAll ) + return true + if( exactHosts.contains(target) ) + return true + for( String suffix : suffixPatterns ) { + if( target.endsWith(suffix) ) + return true + } + if( cidrPatterns ) { + final address = ipv4ToLong(target) + if( address >= 0 ) { + for( Cidr cidr : cidrPatterns ) { + if( cidr.matches(address) ) + return true + } + } + } + return false + } + + static private boolean isLoopback(String host) { + return host == 'localhost' || host.startsWith('127.') || host == '::1' || host == '[::1]' + } + + /** + * Model an IPv4 CIDR block e.g. {@code 10.0.0.0/8} as a masked base address + */ + static private class Cidr { + final long base + final long mask + + private Cidr(long base, long mask) { + this.base = base + this.mask = mask + } + + boolean matches(long address) { + return (address & mask) == base + } + + static Cidr parse(String cidr) { + final p = cidr.indexOf('/') + final address = ipv4ToLong(cidr.substring(0, p)) + final bits = cidr.substring(p + 1) + if( address < 0 || !bits.isInteger() ) + return null + final len = bits.toInteger() + if( len < 0 || len > 32 ) + return null + final mask = len == 0 ? 0L : (0xFFFFFFFFL << (32 - len)) & 0xFFFFFFFFL + return new Cidr(address & mask, mask) + } + } + + static private long ipv4ToLong(String address) { + final parts = address.tokenize('.') + if( parts.size() != 4 ) + return -1 + long result = 0 + for( String it : parts ) { + if( !it.isInteger() ) + return -1 + final octet = it.toInteger() + if( octet < 0 || octet > 255 ) + return -1 + result = (result << 8) | octet + } + return result + } + + @Override + String toString() { + return "HttpProxyConfig[host=$host; port=$port; username=${username ?: '-'}; password=${StringUtils.redact(password)}; noProxy=${noProxy.join(',') ?: '-'}]" + } +} diff --git a/src/test/groovy/io/seqera/wave/http/FakeProxyServer.groovy b/src/test/groovy/io/seqera/wave/http/FakeProxyServer.groovy new file mode 100644 index 0000000000..1747f29e16 --- /dev/null +++ b/src/test/groovy/io/seqera/wave/http/FakeProxyServer.groovy @@ -0,0 +1,176 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.http + +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicInteger + +import groovy.transform.CompileStatic +/** + * Minimal HTTP forward proxy supporting Basic proxy authentication, plain HTTP + * forwarding and HTTPS {@code CONNECT} tunnelling - for testing purposes only + * + * @author Paolo Di Tommaso + */ +@CompileStatic +class FakeProxyServer implements Closeable { + + final String username + final String password + + final AtomicInteger authorized = new AtomicInteger() + final AtomicInteger rejected = new AtomicInteger() + final List connectRequests = new CopyOnWriteArrayList<>() + + private final ServerSocket server + private final String expectedAuthorization + private volatile boolean closed + + FakeProxyServer(String username=null, String password=null) { + this.username = username + this.password = password + this.expectedAuthorization = username + ? 'Basic ' + Base64.getEncoder().encodeToString("$username:$password".toString().getBytes('ISO-8859-1')) + : null + this.server = new ServerSocket(0, 50, InetAddress.getByName('127.0.0.1')) + Thread.startDaemon("FakeProxyServer-acceptor") { + while( !closed ) { + try { + final socket = server.accept() + Thread.startDaemon("FakeProxyServer-worker") { handle(socket) } + } + catch (IOException e) { + if( !closed ) + e.printStackTrace() + } + } + } + } + + int getPort() { + return server.localPort + } + + @Override + void close() { + closed = true + server.close() + } + + private void handle(Socket socket) { + try { + socket.soTimeout = 15_000 + final input = new BufferedInputStream(socket.inputStream) + final output = socket.outputStream + while( true ) { + final head = readHead(input) + if( !head ) + break + final lines = head.split('\r\n') as List + final request = lines[0] + final headers = new HashMap() + for( String line : lines.drop(1) ) { + final p = line.indexOf(':') + if( p > 0 ) + headers.put(line.substring(0, p).trim().toLowerCase(), line.substring(p + 1).trim()) + } + if( username && headers.get('proxy-authorization') != expectedAuthorization ) { + rejected.incrementAndGet() + output.write('HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="test"\r\nContent-Length: 0\r\n\r\n'.getBytes('ISO-8859-1')) + output.flush() + // keep the connection open - the client may retry with credentials + continue + } + authorized.incrementAndGet() + if( request.startsWith('CONNECT ') ) { + connectRequests.add(request) + tunnel(request, input, output) + } + else { + forward(request, headers, output) + } + break + } + } + catch (Exception e) { + // ignore - connection closed + } + finally { + try { socket.close() } catch (IOException e) { } + } + } + + private static void tunnel(String request, InputStream input, OutputStream output) { + final target = request.tokenize(' ')[1] + final p = target.lastIndexOf(':') + final upstream = new Socket(target.substring(0, p), target.substring(p + 1) as int) + try { + output.write('HTTP/1.1 200 Connection established\r\n\r\n'.getBytes('ISO-8859-1')) + output.flush() + Thread.startDaemon("FakeProxyServer-tunnel") { pump(upstream.inputStream, output) } + pump(input, upstream.outputStream) + } + finally { + try { upstream.close() } catch (IOException e) { } + } + } + + private static void forward(String request, Map headers, OutputStream output) { + final parts = request.tokenize(' ') + final uri = new URI(parts[1]) + final port = uri.port > 0 ? uri.port : 80 + final path = (uri.rawPath ?: '/') + (uri.rawQuery ? '?' + uri.rawQuery : '') + new Socket(uri.host, port).withCloseable { upstream -> + final writer = upstream.outputStream + writer.write("${parts[0]} ${path} HTTP/1.1\r\n".toString().getBytes('ISO-8859-1')) + writer.write("Host: ${uri.host}:${port}\r\n".toString().getBytes('ISO-8859-1')) + writer.write('Connection: close\r\n'.getBytes('ISO-8859-1')) + for( Map.Entry entry : headers ) { + if( entry.key in ['host', 'connection', 'proxy-authorization', 'proxy-connection'] ) + continue + writer.write("${entry.key}: ${entry.value}\r\n".toString().getBytes('ISO-8859-1')) + } + writer.write('\r\n'.getBytes('ISO-8859-1')) + writer.flush() + pump(upstream.inputStream, output) + } + } + + private static String readHead(InputStream input) { + final buffer = new ByteArrayOutputStream() + int window = 0 + int ch + while( (ch = input.read()) != -1 ) { + buffer.write(ch) + window = (window << 8) | (ch & 0xff) + if( window == 0x0d0a0d0a ) + return new String(buffer.toByteArray(), 0, buffer.size() - 4, 'ISO-8859-1') + } + return null + } + + private static void pump(InputStream input, OutputStream output) { + try { + input.transferTo(output) + } + catch (IOException e) { + // ignore - connection closed + } + } +} diff --git a/src/test/groovy/io/seqera/wave/http/HttpClientFactoryProxyTest.groovy b/src/test/groovy/io/seqera/wave/http/HttpClientFactoryProxyTest.groovy new file mode 100644 index 0000000000..d1ea3bbf79 --- /dev/null +++ b/src/test/groovy/io/seqera/wave/http/HttpClientFactoryProxyTest.groovy @@ -0,0 +1,233 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.http + +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.security.KeyStore +import java.time.Duration +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import com.sun.net.httpserver.HttpsConfigurator +import com.sun.net.httpserver.HttpsServer +import spock.lang.Shared +import spock.lang.Specification +/** + * Verify the {@link HttpClientFactory} clients authenticate against an egress + * proxy, for both plain HTTP forwarding and HTTPS CONNECT tunnelling. + * + * Note: the HTTPS tunnelling test requires the {@code jdk.http.auth.tunneling.disabledSchemes} + * system property to be set to empty on the test JVM (see the `test` task in `build.gradle`), + * since by default the JDK disables Basic authentication over CONNECT requests + * + * @author Paolo Di Tommaso + */ +class HttpClientFactoryProxyTest extends Specification { + + static final String USERNAME = 'proxy-user' + static final String PASSWORD = 'proxy-secret' + + @Shared HttpServer httpTarget + @Shared HttpsServer httpsTarget + @Shared SSLContext clientSslContext + + private static File generateKeystore() { + final result = File.createTempFile('wave-proxy-test', '.p12') + result.delete() + result.deleteOnExit() + final keytool = System.getProperty('java.home') + File.separator + 'bin' + File.separator + 'keytool' + final command = [keytool, '-genkeypair', '-alias', 'wave-test', '-keyalg', 'EC', '-groupname', 'secp256r1', + '-storetype', 'PKCS12', '-keystore', result.absolutePath, '-storepass', 'changeit', + '-dname', 'CN=localhost, O=Wave test', '-ext', 'SAN=dns:localhost,ip:127.0.0.1', '-validity', '7'] + final process = new ProcessBuilder(command).redirectErrorStream(true).start() + final output = process.inputStream.text + assert process.waitFor() == 0, "keytool failed: $output" + return result + } + + def setupSpec() { + // plain http target server + httpTarget = HttpServer.create(new InetSocketAddress('127.0.0.1', 0), 0) + httpTarget.createContext('/hello', (HttpExchange exchange) -> { + final body = 'Hello world!'.bytes + exchange.sendResponseHeaders(200, body.length) + exchange.responseBody.withCloseable { it.write(body) } + }) + httpTarget.start() + // https target server using a self-signed certificate generated on the fly + final keystore = KeyStore.getInstance('PKCS12') + generateKeystore().withInputStream { + keystore.load(it, 'changeit'.toCharArray()) + } + final kmf = KeyManagerFactory.getInstance(KeyManagerFactory.defaultAlgorithm) + kmf.init(keystore, 'changeit'.toCharArray()) + final serverContext = SSLContext.getInstance('TLS') + serverContext.init(kmf.keyManagers, null, null) + httpsTarget = HttpsServer.create(new InetSocketAddress('127.0.0.1', 0), 0) + httpsTarget.httpsConfigurator = new HttpsConfigurator(serverContext) + httpsTarget.createContext('/hello', (HttpExchange exchange) -> { + final body = 'Hello secure world!'.bytes + exchange.sendResponseHeaders(200, body.length) + exchange.responseBody.withCloseable { it.write(body) } + }) + httpsTarget.start() + // client ssl context trusting the self-signed test certificate + final trustStore = KeyStore.getInstance('PKCS12') + trustStore.load(null, null) + trustStore.setCertificateEntry('wave-test', keystore.getCertificate('wave-test')) + final tmf = TrustManagerFactory.getInstance(TrustManagerFactory.defaultAlgorithm) + tmf.init(trustStore) + clientSslContext = SSLContext.getInstance('TLS') + clientSslContext.init(null, tmf.trustManagers, null) + } + + def cleanupSpec() { + httpTarget?.stop(0) + httpsTarget?.stop(0) + } + + def cleanup() { + // restore the default no-proxy behaviour for the tests run after this spec + HttpClientFactory.setProxyConfig(null) + } + + private static HttpRequest getRequest(String uri) { + return HttpRequest.newBuilder(new URI(uri)) + .timeout(Duration.ofSeconds(30)) + .GET() + .build() + } + + /** + * Create a client using the given proxy settings and trusting the test tls certificate + */ + private HttpClient newTlsClient(HttpProxyConfig config) { + final builder = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) + .proxy(config.proxySelector()) + .sslContext(clientSslContext) + final auth = config.authenticator() + if( auth ) + builder.authenticator(auth) + return builder.build() + } + + def 'should get 407 response from the #POLICY redirects client when the proxy requires authentication and no credentials are configured' () { + given: + def proxy = new FakeProxyServer(USERNAME, PASSWORD) + and: + HttpClientFactory.setProxyConfig(HttpProxyConfig.parse("127.0.0.1:${proxy.port}")) + + when: + def client = POLICY == 'follow' ? HttpClientFactory.followRedirectsHttpClient() : HttpClientFactory.neverRedirectsHttpClient() + def response = client.send(getRequest("http://127.0.0.1:${httpTarget.address.port}/hello"), HttpResponse.BodyHandlers.ofString()) + then: + response.statusCode() == 407 + proxy.rejected.get() >= 1 + proxy.authorized.get() == 0 + + cleanup: + proxy.close() + + where: + POLICY << ['follow', 'never'] + } + + def 'should authenticate the #POLICY redirects client against the proxy when credentials are configured' () { + given: + def proxy = new FakeProxyServer(USERNAME, PASSWORD) + and: + HttpClientFactory.setProxyConfig(HttpProxyConfig.parse("http://${USERNAME}:${PASSWORD}@127.0.0.1:${proxy.port}")) + + when: + def client = POLICY == 'follow' ? HttpClientFactory.followRedirectsHttpClient() : HttpClientFactory.neverRedirectsHttpClient() + def response = client.send(getRequest("http://127.0.0.1:${httpTarget.address.port}/hello"), HttpResponse.BodyHandlers.ofString()) + then: + response.statusCode() == 200 + response.body() == 'Hello world!' + proxy.authorized.get() >= 1 + + cleanup: + proxy.close() + + where: + POLICY << ['follow', 'never'] + } + + def 'should bypass the proxy for hosts matching the no-proxy list' () { + given: + def proxy = new FakeProxyServer(USERNAME, PASSWORD) + and: + HttpClientFactory.setProxyConfig(HttpProxyConfig.parse("http://${USERNAME}:${PASSWORD}@127.0.0.1:${proxy.port}", null, null, '127.0.0.1')) + + when: + def client = HttpClientFactory.followRedirectsHttpClient() + def response = client.send(getRequest("http://127.0.0.1:${httpTarget.address.port}/hello"), HttpResponse.BodyHandlers.ofString()) + then: + response.statusCode() == 200 + response.body() == 'Hello world!' + and: 'the request was made directly, not via the proxy' + proxy.authorized.get() == 0 + proxy.rejected.get() == 0 + + cleanup: + proxy.close() + } + + def 'should authenticate the https CONNECT tunnel when credentials are configured' () { + given: + def proxy = new FakeProxyServer(USERNAME, PASSWORD) + and: + def client = newTlsClient(HttpProxyConfig.parse("http://${USERNAME}:${PASSWORD}@127.0.0.1:${proxy.port}")) + + when: + def response = client.send(getRequest("https://127.0.0.1:${httpsTarget.address.port}/hello"), HttpResponse.BodyHandlers.ofString()) + then: + response.statusCode() == 200 + response.body() == 'Hello secure world!' + and: 'the request was tunnelled via an authenticated CONNECT request' + proxy.connectRequests.size() >= 1 + proxy.authorized.get() >= 1 + + cleanup: + proxy.close() + } + + def 'should fail the https CONNECT tunnel when the proxy requires authentication and no credentials are configured' () { + given: + def proxy = new FakeProxyServer(USERNAME, PASSWORD) + and: + def client = newTlsClient(HttpProxyConfig.parse("127.0.0.1:${proxy.port}")) + + when: + def response = client.send(getRequest("https://127.0.0.1:${httpsTarget.address.port}/hello"), HttpResponse.BodyHandlers.ofString()) + then: 'the proxy 407 response is surfaced and no tunnel is established' + response.statusCode() == 407 + proxy.rejected.get() >= 1 + proxy.connectRequests.size() == 0 + + cleanup: + proxy.close() + } +} diff --git a/src/test/groovy/io/seqera/wave/http/HttpProxyConfigTest.groovy b/src/test/groovy/io/seqera/wave/http/HttpProxyConfigTest.groovy new file mode 100644 index 0000000000..63a0945b1b --- /dev/null +++ b/src/test/groovy/io/seqera/wave/http/HttpProxyConfigTest.groovy @@ -0,0 +1,179 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2024, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.http + +import spock.lang.Specification +import spock.lang.Unroll + +/** + * + * @author Paolo Di Tommaso + */ +class HttpProxyConfigTest extends Specification { + + @Unroll + def 'should parse proxy uri #PROXY_URI' () { + when: + def config = HttpProxyConfig.parse(PROXY_URI) + then: + config?.host == HOST + config?.port == PORT + config?.username == USER + config?.password == PASS + + where: + PROXY_URI | HOST | PORT | USER | PASS + null | null | null | null | null + '' | null | null | null | null + 'proxy.example.com:3128' | 'proxy.example.com' | 3128 | null | null + 'http://proxy.example.com' | 'proxy.example.com' | 80 | null | null + 'https://proxy.example.com' | 'proxy.example.com' | 443 | null | null + 'http://foo:bar@proxy.example.com:8080' | 'proxy.example.com' | 8080 | 'foo' | 'bar' + 'http://foo@proxy.example.com:8080' | 'proxy.example.com' | 8080 | 'foo' | null + 'http://foo:p%40ss@proxy.example.com' | 'proxy.example.com' | 80 | 'foo' | 'p@ss' + 'foo:b:ar@proxy.example.com:1234' | 'proxy.example.com' | 1234 | 'foo' | 'b:ar' + } + + def 'should give precedence to explicit credentials over uri user-info' () { + when: + def config = HttpProxyConfig.parse('http://foo:bar@proxy.example.com:8080', 'this', 'that', null) + then: + config.username == 'this' + config.password == 'that' + } + + def 'should report invalid proxy uri' () { + when: + HttpProxyConfig.parse('http://') + then: + thrown(IllegalArgumentException) + } + + def 'should parse no-proxy list' () { + when: + def config = HttpProxyConfig.parse('proxy.example.com:3128', null, null, 'foo.com , .Bar.com,, 10.0.0.0/8') + then: + config.noProxy == ['foo.com', '.bar.com', '10.0.0.0/8'] + } + + @Unroll + def 'should resolve proxy from environment #ENV' () { + when: + def config = HttpProxyConfig.fromEnvironment(ENV) + then: + config?.host == HOST + config?.port == PORT + config?.username == USER + config?.noProxy == NO_PROXY + + where: + ENV | HOST | PORT | USER | NO_PROXY + [:] | null | null | null | null + [HTTPS_PROXY: 'http://proxy1:3128'] | 'proxy1' | 3128 | null | [] + [https_proxy: 'http://proxy1:3128'] | 'proxy1' | 3128 | null | [] + [HTTP_PROXY: 'http://proxy2:8080'] | 'proxy2' | 8080 | null | [] + [HTTPS_PROXY: 'http://proxy1:3128', HTTP_PROXY: 'http://x:1'] | 'proxy1' | 3128 | null | [] + [HTTPS_PROXY: 'http://foo:bar@proxy1:3128', NO_PROXY: 'a.com'] | 'proxy1' | 3128 | 'foo' | ['a.com'] + } + + @Unroll + def 'should bypass=#EXPECTED proxy for host #TARGET with no-proxy #NO_PROXY' () { + given: + def config = HttpProxyConfig.parse('proxy.example.com:3128', null, null, NO_PROXY) + expect: + config.shouldBypass(TARGET) == EXPECTED + + where: + TARGET | NO_PROXY | EXPECTED + 'quay.io' | null | false + 'quay.io' | 'docker.io' | false + 'docker.io' | 'docker.io' | true + 'DOCKER.IO' | 'docker.io' | true + 'reg.example.com' | '.example.com' | true + 'example.com' | '.example.com' | true + 'reg.example.com' | '*.example.com' | true + 'notexample.com' | '.example.com' | false + 'anything.io' | '*' | true + '10.1.2.3' | '10.0.0.0/8' | true + '11.1.2.3' | '10.0.0.0/8' | false + '192.168.1.10' | '10.0.0.0/8,192.168.0.0/16' | true + // loopback addresses are never proxied when the proxy is a remote host + 'localhost' | null | true + '127.0.0.1' | null | true + '::1' | null | true + } + + def 'should not bypass loopback host when the proxy itself is a loopback address' () { + given: + def config = HttpProxyConfig.parse('127.0.0.1:3128') + expect: + !config.shouldBypass('localhost') + !config.shouldBypass('127.0.0.1') + } + + def 'should select proxy honouring no-proxy list' () { + given: + def config = HttpProxyConfig.parse('proxy.example.com:3128', null, null, 'internal.example.com') + def selector = config.proxySelector() + + when: + def result = selector.select(new URI('https://quay.io/v2/')) + then: + result.size() == 1 + result[0].type() == Proxy.Type.HTTP + result[0].address() == InetSocketAddress.createUnresolved('proxy.example.com', 3128) + + when: + result = selector.select(new URI('https://internal.example.com/v2/')) + then: + result == [Proxy.NO_PROXY] + } + + def 'should create authenticator scoped to the proxy host and requestor type' () { + given: + def config = HttpProxyConfig.parse('http://foo:bar@proxy.example.com:3128') + def auth = config.authenticator() + + when: 'the proxy asks for authentication' + def result = auth.requestPasswordAuthenticationInstance('proxy.example.com', null, 3128, 'http', 'auth required', 'basic', null, Authenticator.RequestorType.PROXY) + then: + result.userName == 'foo' + result.password == 'bar'.toCharArray() + + when: 'a server (not the proxy) asks for authentication' + result = auth.requestPasswordAuthenticationInstance('proxy.example.com', null, 3128, 'http', 'auth required', 'basic', null, Authenticator.RequestorType.SERVER) + then: + result == null + + when: 'a different host asks for proxy authentication' + result = auth.requestPasswordAuthenticationInstance('other.example.com', null, 3128, 'http', 'auth required', 'basic', null, Authenticator.RequestorType.PROXY) + then: + result == null + } + + def 'should not create authenticator when no credentials are given' () { + expect: + HttpProxyConfig.parse('proxy.example.com:3128').authenticator() == null + } + + def 'should redact password in string representation' () { + expect: + !HttpProxyConfig.parse('http://foo:secret1234@proxy.example.com').toString().contains('secret1234') + } +}