diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ce87cba7..14437542 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -85,6 +85,7 @@ jobs: bash publish.sh lib-lang bash publish.sh lib-lock bash publish.sh lib-lock-redis + bash publish.sh lib-util-net bash publish.sh lib-httpx bash publish.sh lib-mail bash publish.sh lib-node-id-redis @@ -94,7 +95,6 @@ jobs: bash publish.sh lib-util-string bash publish.sh lib-util-time bash publish.sh lib-util-tsid - bash publish.sh lib-util-net bash publish.sh lib-pool bash publish.sh lib-random bash publish.sh lib-retry diff --git a/lib-httpx/README.md b/lib-httpx/README.md index 48244c11..52f52a79 100644 --- a/lib-httpx/README.md +++ b/lib-httpx/README.md @@ -10,7 +10,7 @@ Add the dependency to your `build.gradle`: ```gradle dependencies { - implementation 'io.seqera:lib-httpx:2.6.0' + implementation 'io.seqera:lib-httpx:2.7.0' } ``` @@ -25,7 +25,7 @@ dependencies { - **Custom Token Storage**: Pluggable token store interface for distributed deployments (Redis, database, etc.) - **WWW-Authenticate Support**: Automatic handling of HTTP authentication challenges (Basic and Bearer schemes) - **Anonymous Authentication**: Fallback to anonymous authentication when credentials aren't provided -- **Proxy Support**: Authenticated forward-proxy support via `.proxy(...)`/`.authenticator(...)`, or an `HxProxyConfig` value applied with `.withProxyConfig(...)` +- **Proxy Support**: Authenticated forward-proxy support via `.proxy(...)`/`.authenticator(...)`, or an `io.seqera.util.net.ProxyConfig` (from `io.seqera:lib-util-net`) applied with `.withProxyConfig(...)` - **Configurable**: Customizable retry policies, timeouts, token refresh, authentication settings, and cookie policies - **Generic Integration**: Compatible with any `Retryable.Config` for flexible retry configuration - **Thread-safe**: Safe for concurrent use with atomic token refresh coordination @@ -403,16 +403,17 @@ HxClient client = HxClient.newBuilder() .build(); ``` -For callers that resolve proxy settings themselves (host, port, optional credentials per protocol and -`NO_PROXY` entries), `HxProxyConfig` bundles them into a single value and produces the matching selector -and a proxy-only authenticator (credentials are supplied only for proxy authentication challenges, never -to origin servers). Apply it in one call with `.withProxyConfig(...)`: +For callers that resolve proxy settings themselves, `io.seqera.util.net.ProxyConfig` (from +`io.seqera:lib-util-net`, an `api` dependency of this library) bundles a proxy URI or the +`HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` environment variables into a single value and produces the matching +selector and a proxy-only authenticator (credentials are supplied only for proxy authentication +challenges, never to origin servers). Apply it in one call with `.withProxyConfig(...)`: ```java -HxProxyConfig proxy = HxProxyConfig.newBuilder() - .httpsProxy("proxy.example.com", 8080, "user", "pass") - .noProxy(List.of("internal.example.com")) - .build(); +// prefer the explicit-credentials overload - it avoids having to percent-encode a password +// containing '@', ':' or '%' that would otherwise be embedded in the URI +ProxyConfig proxy = ProxyConfig.fromUri("http://proxy.example.com:8080", "user", "pass", + List.of("internal.example.com")); HxClient client = HxClient.newBuilder() .withProxyConfig(proxy) // no-op if proxy is null .build(); @@ -469,7 +470,7 @@ HxClient client = HxClient.newBuilder() - **`HxClient`**: Main HTTP client with retry, JWT, and WWW-Authenticate functionality - **`HxConfig`**: Configuration builder with all available options -- **`HxProxyConfig`**: Forward-proxy settings (selector + proxy-only authenticator) assembled from explicit values via its builder +- **`io.seqera.util.net.ProxyConfig`** (from `io.seqera:lib-util-net`): Forward-proxy settings (selector + proxy-only authenticator) resolved from a URI or the environment, applied via `HxClient.Builder.withProxyConfig(...)` - **`HxAuth`**: Interface for authentication credentials with stable identity across refreshes - **`HxTokenStore`**: Interface for pluggable token storage (default: in-memory ConcurrentHashMap) - **`HxTokenManager`**: Thread-safe JWT token lifecycle management with multi-session support diff --git a/lib-httpx/VERSION b/lib-httpx/VERSION index e70b4523..9aa34646 100644 --- a/lib-httpx/VERSION +++ b/lib-httpx/VERSION @@ -1 +1 @@ -2.6.0 +2.7.0 \ No newline at end of file diff --git a/lib-httpx/build.gradle b/lib-httpx/build.gradle index 76c51b45..deb95b04 100644 --- a/lib-httpx/build.gradle +++ b/lib-httpx/build.gradle @@ -24,6 +24,7 @@ version = "${project.file('VERSION').text.trim()}" dependencies { api project(':lib-retry') + api project(':lib-util-net') implementation 'com.google.code.gson:gson:2.10.1' testImplementation 'com.github.tomakehurst:wiremock:3.0.1' diff --git a/lib-httpx/changelog.txt b/lib-httpx/changelog.txt index d8a86b04..739452af 100644 --- a/lib-httpx/changelog.txt +++ b/lib-httpx/changelog.txt @@ -1,5 +1,22 @@ # lib-httpx changelog +2.7.0 +- BREAKING (see note below): remove HxProxyConfig in favour of io.seqera.util.net.ProxyConfig + (io.seqera:lib-util-net), so the proxy selector / authenticator / no-proxy semantics live in a + single place shared with Wave and Nextflow +- HxClient.Builder.withProxyConfig now accepts an io.seqera.util.net.ProxyConfig; callers that built an + HxProxyConfig should resolve a ProxyConfig instead - its toProxySelector()/toAuthenticator() are + unchanged. For raw credentials prefer the explicit overload + ProxyConfig.fromUri(uri, user, pass, noProxy) over embedding them in the URI, so a password with + '@'/':'/'%' does not need percent-encoding; fromEnvironment(env) reads HTTP_PROXY/HTTPS_PROXY/NO_PROXY +- Note: HxProxyConfig could hold an http proxy without an https one (or vice versa); fromUri applies one + endpoint to both protocols, so asymmetric per-protocol config from explicit values is now only + expressible via fromEnvironment +- lib-httpx now depends on io.seqera:lib-util-net +- NOTE: this removes a public type, so it is source-incompatible for any caller of HxProxyConfig. + It is released as a minor (2.7.0) rather than a major because the only affected consumer is + Nextflow, which is migrated in lock-step; callers pinning <= 2.6.0 are unaffected. + 2.6.0 - 12 Aug 2026 - Add HxClient.shouldRetryOnException(HttpRequest, Throwable) - the hook the retry policy now consults - so a subclass can decide on the exception per request diff --git a/lib-httpx/src/main/java/io/seqera/http/HxClient.java b/lib-httpx/src/main/java/io/seqera/http/HxClient.java index 9b66919d..9ae56228 100644 --- a/lib-httpx/src/main/java/io/seqera/http/HxClient.java +++ b/lib-httpx/src/main/java/io/seqera/http/HxClient.java @@ -40,6 +40,7 @@ import io.seqera.http.auth.AuthenticationChallenge; import io.seqera.http.auth.AuthenticationScheme; import io.seqera.http.auth.WwwAuthenticateParser; +import io.seqera.util.net.ProxyConfig; import io.seqera.util.retry.Retryable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -1195,12 +1196,12 @@ public Builder version(HttpClient.Version version) { *

The selector is forwarded to the inner {@link HttpClient.Builder} and is also * inherited by the internal HTTP clients used for JWT token refresh and anonymous * Bearer token retrieval. Proxy settings are never resolved from the environment - * automatically; supply them explicitly here (or via {@link #withProxyConfig(HxProxyConfig)}). + * automatically; supply them explicitly here (or via {@link #withProxyConfig(ProxyConfig)}). * * @param proxySelector the proxy selector to use * @return this Builder instance * @see HttpClient.Builder#proxy(ProxySelector) - * @see HxProxyConfig + * @see ProxyConfig */ public Builder proxy(ProxySelector proxySelector) { this.proxySelector = proxySelector; @@ -1220,12 +1221,12 @@ public Builder proxy(ProxySelector proxySelector) { * {@link Authenticator#setDefault(Authenticator)} - proxy credentials only take effect * when supplied via this method. For Basic proxy authentication of HTTPS traffic the JDK's * {@code jdk.http.auth.tunneling.disabledSchemes} property must also be cleared - see - * {@link HxProxyConfig} for details. + * {@link ProxyConfig} for details. * * @param authenticator the authenticator providing credentials * @return this Builder instance * @see HttpClient.Builder#authenticator(Authenticator) - * @see HxProxyConfig#toAuthenticator() + * @see ProxyConfig#toAuthenticator() */ public Builder authenticator(Authenticator authenticator) { this.proxyAuthenticator = authenticator; @@ -1233,16 +1234,17 @@ public Builder authenticator(Authenticator authenticator) { } /** - * Applies the proxy selector and authenticator carried by the given {@link HxProxyConfig}, - * a convenience over calling {@link #proxy(ProxySelector)} and - * {@link #authenticator(Authenticator)} separately. A {@code null} config is a no-op, so - * callers can pass an optionally-resolved configuration directly. + * Applies the proxy selector and authenticator carried by the given + * {@link ProxyConfig} (from {@code io.seqera:lib-util-net}), a convenience over calling + * {@link #proxy(ProxySelector)} and {@link #authenticator(Authenticator)} separately. A + * {@code null} config is a no-op, so callers can pass an optionally-resolved configuration + * directly. * * @param config the proxy configuration to apply, or null for none * @return this Builder instance - * @see HxProxyConfig + * @see ProxyConfig */ - public Builder withProxyConfig(HxProxyConfig config) { + public Builder withProxyConfig(ProxyConfig config) { if( config == null ) return this; if( config.getHttpProxy() != null || config.getHttpsProxy() != null ) diff --git a/lib-httpx/src/main/java/io/seqera/http/HxConfig.java b/lib-httpx/src/main/java/io/seqera/http/HxConfig.java index 6ba1e414..cfeb87ba 100644 --- a/lib-httpx/src/main/java/io/seqera/http/HxConfig.java +++ b/lib-httpx/src/main/java/io/seqera/http/HxConfig.java @@ -701,7 +701,7 @@ public Builder withRefreshCookiePolicy(CookiePolicy policy) { * * @param proxySelector the proxy selector, or null to use the JVM default behaviour * @return this builder instance for method chaining - * @see HxProxyConfig + * @see io.seqera.util.net.ProxyConfig */ public Builder proxySelector(ProxySelector proxySelector) { this.proxySelector = proxySelector; @@ -711,12 +711,12 @@ public Builder proxySelector(ProxySelector proxySelector) { /** * Sets the authenticator used to supply credentials to an authenticating forward proxy, * applied to the main HTTP client and the internal token refresh clients. See - * {@link HxProxyConfig} for the {@code Authenticator.setDefault} and Basic-over-HTTPS - * tunnelling caveats. + * {@link io.seqera.util.net.ProxyConfig} for the {@code Authenticator.setDefault} and + * Basic-over-HTTPS tunnelling caveats. * * @param proxyAuthenticator the authenticator providing proxy credentials, or null for none * @return this builder instance for method chaining - * @see HxProxyConfig#toAuthenticator() + * @see io.seqera.util.net.ProxyConfig#toAuthenticator() */ public Builder proxyAuthenticator(Authenticator proxyAuthenticator) { this.proxyAuthenticator = proxyAuthenticator; diff --git a/lib-httpx/src/main/java/io/seqera/http/HxProxyConfig.java b/lib-httpx/src/main/java/io/seqera/http/HxProxyConfig.java deleted file mode 100644 index 6e75fa70..00000000 --- a/lib-httpx/src/main/java/io/seqera/http/HxProxyConfig.java +++ /dev/null @@ -1,258 +0,0 @@ -/* - * Copyright 2026, Seqera Labs - * - * 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.seqera.http; - -import java.io.IOException; -import java.net.Authenticator; -import java.net.InetSocketAddress; -import java.net.PasswordAuthentication; -import java.net.Proxy; -import java.net.ProxySelector; -import java.net.SocketAddress; -import java.net.URI; -import java.util.List; -import java.util.Locale; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Holds HTTP/HTTPS forward-proxy settings supplied explicitly (host, port and optional - * credentials per protocol, plus {@code NO_PROXY} entries) and exposes them as a - * {@link ProxySelector} and a proxy-only {@link Authenticator} suitable for - * {@link java.net.http.HttpClient}. Build one with {@link #newBuilder()}. - * - *

The library never reads the environment or system properties on its own; the caller - * resolves proxy settings however it wishes and passes the values to {@link Builder}. - * - *

NO_PROXY entries are honoured as a list of host names or domain suffixes - * (optionally prefixed with . or *.); the single entry * - * disables proxying entirely. Loopback targets (localhost, 127.*, - * [::1]) always bypass the proxy, mirroring the JDK default - * http.nonProxyHosts behaviour. CIDR notation entries are not supported. - * - *

Why an explicit Authenticator?
- * {@link java.net.http.HttpClient} ignores {@link Authenticator#setDefault(Authenticator)}; - * proxy credentials only take effect when supplied via - * {@link java.net.http.HttpClient.Builder#authenticator(Authenticator)}. The authenticator - * returned by {@link #toAuthenticator()} releases credentials only for - * {@link Authenticator.RequestorType#PROXY} requests that match the configured proxy - * host and port, never for origin-server challenges. - * - *

Basic auth over HTTPS tunnels:
- * The JDK disables the Basic scheme for HTTPS CONNECT tunnelling by default via - * jdk.http.auth.tunneling.disabledSchemes=Basic (see - * $JAVA_HOME/conf/net.properties). For proxy credentials to be sent on the - * CONNECT request of HTTPS traffic, that property must be cleared, e.g. with - * -Djdk.http.auth.tunneling.disabledSchemes=. - * - * @see HxClient.Builder#proxy(ProxySelector) - * @see HxClient.Builder#authenticator(Authenticator) - */ -public class HxProxyConfig { - - private static final Logger log = LoggerFactory.getLogger(HxProxyConfig.class); - - /** - * Connection details for a single proxy server, with optional credentials. - */ - static final class ProxyEntry { - final String host; - final int port; - final String username; - final String password; - - ProxyEntry(String host, int port, String username, String password) { - this.host = host; - this.port = port; - this.username = username; - this.password = password; - } - - boolean hasCredentials() { - return username != null && !username.isEmpty(); - } - - InetSocketAddress address() { - return InetSocketAddress.createUnresolved(host, port); - } - } - - private final ProxyEntry httpProxy; - private final ProxyEntry httpsProxy; - private final List noProxyHosts; - - private HxProxyConfig(ProxyEntry httpProxy, ProxyEntry httpsProxy, List noProxyHosts) { - this.httpProxy = httpProxy; - this.httpsProxy = httpsProxy; - this.noProxyHosts = noProxyHosts; - } - - /** - * Creates a builder to assemble a proxy configuration from explicit values, for callers that - * resolve proxy settings themselves rather than from the environment. - * - * @return a new {@link Builder} - */ - public static Builder newBuilder() { - return new Builder(); - } - - /** - * Builder for {@link HxProxyConfig}: the proxy details are supplied explicitly - * (host, port and optional credentials per protocol, plus {@code NO_PROXY} entries). - */ - public static final class Builder { - private ProxyEntry httpProxy; - private ProxyEntry httpsProxy; - private List noProxyHosts = List.of(); - - public Builder httpProxy(String host, int port, String username, String password) { - this.httpProxy = host != null ? new ProxyEntry(host, port, username, password) : null; - return this; - } - - public Builder httpsProxy(String host, int port, String username, String password) { - this.httpsProxy = host != null ? new ProxyEntry(host, port, username, password) : null; - return this; - } - - public Builder noProxy(List hosts) { - this.noProxyHosts = hosts == null - ? List.of() - : hosts.stream() - .filter(h -> h != null) - .map(h -> h.trim().toLowerCase(Locale.ROOT)) - .filter(h -> !h.isEmpty()) - .toList(); - return this; - } - - public HxProxyConfig build() { - return new HxProxyConfig(httpProxy, httpsProxy, noProxyHosts); - } - } - - /** - * Creates a {@link ProxySelector} that routes requests through the configured proxies, - * bypassing hosts matched by {@code NO_PROXY} and loopback addresses. - * - * @return a new ProxySelector reflecting this configuration - */ - public ProxySelector toProxySelector() { - // precompute the proxy lists - select() runs once per outbound request - final List direct = List.of(Proxy.NO_PROXY); - final List viaHttpProxy = httpProxy != null - ? List.of(new Proxy(Proxy.Type.HTTP, httpProxy.address())) - : direct; - final List viaHttpsProxy = httpsProxy != null - ? List.of(new Proxy(Proxy.Type.HTTP, httpsProxy.address())) - : direct; - return new ProxySelector() { - @Override - public List select(URI uri) { - if (uri == null || isBypassed(uri.getHost())) - return direct; - return "https".equalsIgnoreCase(uri.getScheme()) ? viaHttpsProxy : viaHttpProxy; - } - - @Override - public void connectFailed(URI uri, SocketAddress sa, IOException ioe) { - log.debug("Failed to connect to proxy {} for {}: {}", sa, uri, ioe.getMessage()); - } - }; - } - - /** - * Creates an {@link Authenticator} that supplies the configured proxy credentials. - * - *

Credentials are released only for {@link Authenticator.RequestorType#PROXY} - * requests whose host and port match the configured proxy, never for origin-server - * authentication challenges. - * - * @return a new Authenticator, or null when no proxy credentials are configured - */ - public Authenticator toAuthenticator() { - if (!hasCredentials()) - return null; - return new Authenticator() { - @Override - protected PasswordAuthentication getPasswordAuthentication() { - if (getRequestorType() != RequestorType.PROXY) - return null; - final ProxyEntry entry = credentialsFor(getRequestingHost(), getRequestingPort()); - if (entry == null) - return null; - final String password = entry.password != null ? entry.password : ""; - return new PasswordAuthentication(entry.username, password.toCharArray()); - } - }; - } - - /** - * @return true when at least one configured proxy carries credentials - */ - public boolean hasCredentials() { - return (httpProxy != null && httpProxy.hasCredentials()) - || (httpsProxy != null && httpsProxy.hasCredentials()); - } - - ProxyEntry getHttpProxy() { - return httpProxy; - } - - ProxyEntry getHttpsProxy() { - return httpsProxy; - } - - /** - * Determines whether the given target host must bypass the proxy, either because it is - * a loopback address or because it matches a {@code NO_PROXY} entry. - */ - boolean isBypassed(String host) { - if (host == null) - return true; - final String target = host.toLowerCase(Locale.ROOT); - // always bypass loopback targets, consistent with the JDK default `http.nonProxyHosts` - if (target.equals("localhost") || target.startsWith("127.") || target.equals("::1") || target.equals("[::1]")) - return true; - for (String entry : noProxyHosts) { - if (entry.equals("*")) - return true; - // "*.example.com" and ".example.com" match sub-domains only; - // "example.com" matches the host itself and any sub-domain - final String suffix = entry.startsWith("*.") ? entry.substring(1) : entry; - if (suffix.startsWith(".")) { - if (target.endsWith(suffix)) - return true; - } - else if (target.equals(suffix) || target.endsWith("." + suffix)) { - return true; - } - } - return false; - } - - private ProxyEntry credentialsFor(String host, int port) { - if (httpsProxy != null && httpsProxy.hasCredentials() && httpsProxy.host.equalsIgnoreCase(host) && httpsProxy.port == port) - return httpsProxy; - if (httpProxy != null && httpProxy.hasCredentials() && httpProxy.host.equalsIgnoreCase(host) && httpProxy.port == port) - return httpProxy; - return null; - } -} diff --git a/lib-httpx/src/test/groovy/io/seqera/http/HxClientProxyAuthIntegrationTest.groovy b/lib-httpx/src/test/groovy/io/seqera/http/HxClientProxyAuthIntegrationTest.groovy index b7dbe930..dc95ffe6 100644 --- a/lib-httpx/src/test/groovy/io/seqera/http/HxClientProxyAuthIntegrationTest.groovy +++ b/lib-httpx/src/test/groovy/io/seqera/http/HxClientProxyAuthIntegrationTest.groovy @@ -24,6 +24,7 @@ import java.net.http.HttpClient import java.net.http.HttpRequest import java.net.http.HttpResponse +import io.seqera.util.net.ProxyConfig import spock.lang.Specification /** @@ -202,11 +203,9 @@ class HxClientProxyAuthIntegrationTest extends Specification { client.getConfig().getProxyAuthenticator().is(authenticator) } - def 'should route through the proxy via withProxyConfig and the HxProxyConfig builder'() { - given: 'a proxy config assembled from explicit values' - def proxyConfig = HxProxyConfig.newBuilder() - .httpProxy('127.0.0.1', proxyPort, 'alice', 's3cret') - .build() + def 'should route through the proxy via withProxyConfig and a lib-util-net ProxyConfig'() { + given: 'a proxy config resolved from an explicit uri' + def proxyConfig = ProxyConfig.fromUri("http://alice:s3cret@127.0.0.1:${proxyPort}".toString()) def client = HxClient.newBuilder() .withProxyConfig(proxyConfig) .build() diff --git a/lib-httpx/src/test/groovy/io/seqera/http/HxProxyConfigTest.groovy b/lib-httpx/src/test/groovy/io/seqera/http/HxProxyConfigTest.groovy deleted file mode 100644 index b5d40d90..00000000 --- a/lib-httpx/src/test/groovy/io/seqera/http/HxProxyConfigTest.groovy +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright 2026, Seqera Labs - * - * 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.seqera.http - -import java.net.Authenticator -import java.net.PasswordAuthentication -import java.net.Proxy - -import spock.lang.Specification - -/** - * Test cases for {@link HxProxyConfig} proxy selection and proxy authentication behaviour. - */ -class HxProxyConfigTest extends Specification { - - private static PasswordAuthentication invokeAuth(Authenticator authenticator, Authenticator.RequestorType type, String host, int port) { - // drive the protected Authenticator API the same way the JDK HTTP client does - return authenticator.requestPasswordAuthenticationInstance( - host, null, port, 'http', 'proxy auth', 'basic', new URL('http://example.com'), type ) - } - - def 'builder should assemble a proxy config from explicit values'() { - when: - def config = HxProxyConfig.newBuilder() - .httpsProxy('proxy.example.com', 8080, 'foo', 'bar') - .noProxy(['internal.example.com']) - .build() - - then: - config.httpsProxy.host == 'proxy.example.com' - config.httpsProxy.port == 8080 - config.httpsProxy.username == 'foo' - config.httpsProxy.password == 'bar' - config.httpProxy == null - config.hasCredentials() - and: 'the selector honours the explicit NO_PROXY entry' - config.isBypassed('internal.example.com') - !config.isBypassed('api.example.com') - and: - config.toAuthenticator() != null - } - - def 'builder without credentials yields no authenticator'() { - when: - def config = HxProxyConfig.newBuilder() - .httpProxy('proxy.local', 3128, null, null) - .build() - - then: - config.httpProxy.host == 'proxy.local' - config.httpProxy.port == 3128 - !config.hasCredentials() - config.toAuthenticator() == null - } - - def 'should select the proxy matching the target scheme'() { - given: - def config = HxProxyConfig.newBuilder() - .httpProxy('http-proxy', 3128, null, null) - .httpsProxy('https-proxy', 3129, null, null) - .build() - def selector = config.toProxySelector() - - when: - def httpResult = selector.select(URI.create('http://api.example.com/foo')) - def httpsResult = selector.select(URI.create('https://api.example.com/foo')) - - then: - (httpResult[0].address() as InetSocketAddress).hostString == 'http-proxy' - (httpResult[0].address() as InetSocketAddress).port == 3128 - (httpsResult[0].address() as InetSocketAddress).hostString == 'https-proxy' - (httpsResult[0].address() as InetSocketAddress).port == 3129 - } - - def 'should connect directly when no proxy matches the target scheme'() { - given: - def config = HxProxyConfig.newBuilder().httpsProxy('proxy', 3128, null, null).build() - - expect: - config.toProxySelector().select(URI.create('http://api.example.com/foo')) == [Proxy.NO_PROXY] - } - - def 'should bypass proxy for NO_PROXY entries'() { - given: - def config = HxProxyConfig.newBuilder() - .httpProxy('proxy', 3128, null, null) - .httpsProxy('proxy', 3128, null, null) - .noProxy(['internal.example.com', '.corp.example.org', '*.svc.cluster.local']) - .build() - - expect: - config.isBypassed(host) == bypassed - - where: - host | bypassed - 'internal.example.com' | true - 'sub.internal.example.com' | true - 'other.example.com' | false - 'internal.example.com.evil.io'| false - 'foo.corp.example.org' | true - 'corp.example.org' | false - 'db.default.svc.cluster.local'| true - } - - def 'should bypass everything when NO_PROXY is a wildcard'() { - given: - def config = HxProxyConfig.newBuilder().httpsProxy('proxy', 3128, null, null).noProxy(['*']).build() - - expect: - config.toProxySelector().select(URI.create('https://api.example.com')) == [Proxy.NO_PROXY] - } - - def 'should always bypass loopback targets'() { - given: - def config = HxProxyConfig.newBuilder().httpsProxy('proxy', 3128, null, null).build() - - expect: - config.isBypassed('localhost') - config.isBypassed('127.0.0.1') - !config.isBypassed('api.example.com') - } - - def 'should provide credentials only for matching proxy requests'() { - given: - def config = HxProxyConfig.newBuilder().httpsProxy('proxy.example.com', 8080, 'foo', 'bar').build() - def authenticator = config.toAuthenticator() - - when: 'the proxy itself requests authentication' - def auth = invokeAuth(authenticator, Authenticator.RequestorType.PROXY, 'proxy.example.com', 8080) - - then: - auth.userName == 'foo' - new String(auth.password) == 'bar' - - when: 'an origin server requests authentication' - auth = invokeAuth(authenticator, Authenticator.RequestorType.SERVER, 'proxy.example.com', 8080) - - then: - auth == null - - when: 'a different host requests proxy authentication' - auth = invokeAuth(authenticator, Authenticator.RequestorType.PROXY, 'other-proxy.example.com', 8080) - - then: - auth == null - - when: 'the right host but a different port requests proxy authentication' - auth = invokeAuth(authenticator, Authenticator.RequestorType.PROXY, 'proxy.example.com', 9999) - - then: - auth == null - } -} diff --git a/lib-util-net/README.md b/lib-util-net/README.md index 575480fd..d62185c4 100644 --- a/lib-util-net/README.md +++ b/lib-util-net/README.md @@ -37,8 +37,46 @@ when the host: - resolves to a cloud metadata service IP (AWS `169.254.169.254`, ECS `169.254.170.2`, IMDSv2 IPv6) - cannot be resolved (fail closed) +### Egress proxy configuration + +`io.seqera.util.net.ProxyConfig` resolves an HTTP/HTTPS forward (egress) proxy — +including an authenticating one — from a proxy URI or from the +`HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` environment variables, and exposes it as a +`java.net.ProxySelector` and a proxy-scoped `java.net.Authenticator` for a +`java.net.http.HttpClient`. Parsing, no-proxy and Basic-over-`CONNECT` semantics +mirror Nextflow's `nextflow.util.ProxyConfig`. + +```java +import io.seqera.util.net.ProxyConfig; +import java.net.http.HttpClient; + +// from an explicit URI (applied to both http and https), or from the environment +ProxyConfig proxy = ProxyConfig.fromUri("http://user:pass@proxy.example.com:3128"); +// ProxyConfig proxy = ProxyConfig.fromEnvironment(System.getenv()); + +if (proxy != null) { + var builder = HttpClient.newBuilder().proxy(proxy.toProxySelector()); + var auth = proxy.toAuthenticator(); // null when no credentials + if (auth != null) builder.authenticator(auth); + // for authenticating proxies on https targets, clear the JDK's default block + // on Basic-over-CONNECT (no-op when the operator already set the property): + if (proxy.hasCredentials()) ProxyConfig.enableBasicProxyTunneling(); + HttpClient client = builder.build(); +} +``` + +`NO_PROXY` entries match host names or domain suffixes (optionally prefixed with +`.` or `*.`); `*` disables proxying entirely, and loopback targets always bypass +the proxy. CIDR notation is not supported. + +To honour the proxy JVM-wide — installing the per-protocol `.proxyHost`/ +`.proxyPort` and `http.nonProxyHosts` system properties and a default +`Authenticator` (also covering `FTP_PROXY`), the way a CLI launcher would — use +`ProxyConfig.setupFromEnvironment(System.getenv())`, which returns the same +http/https config for wiring `java.net.http` clients explicitly. + ## Limitations -Validation resolves DNS at call time; a caller that later opens a connection +`SsrfValidator` resolves DNS at call time; a caller that later opens a connection resolves DNS again, leaving a TOCTOU / DNS-rebinding window. Pin the resolved address if that gap matters for your use case. diff --git a/lib-util-net/VERSION b/lib-util-net/VERSION index 6e8bf73a..341cf11f 100644 --- a/lib-util-net/VERSION +++ b/lib-util-net/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 \ No newline at end of file diff --git a/lib-util-net/changelog.txt b/lib-util-net/changelog.txt index a5f8684e..376a0c6d 100644 --- a/lib-util-net/changelog.txt +++ b/lib-util-net/changelog.txt @@ -1,4 +1,9 @@ # lib-util-net changelog +0.2.0 +- Add ProxyConfig: resolve an HTTP/HTTPS forward (egress) proxy - including an authenticating one - from a proxy URI or the HTTP_PROXY/HTTPS_PROXY/NO_PROXY environment variables, exposed as a java.net ProxySelector and proxy-scoped Authenticator (parsing and no-proxy semantics mirror Nextflow's nextflow.util.ProxyConfig) +- ProxyConfig.parse is public so callers can reuse the component-level parsing +- Add ProxyConfig.setupFromEnvironment: install http/https/ftp proxies into the JVM (per-protocol proxyHost/proxyPort + http.nonProxyHosts system properties, default proxy Authenticator and Basic-over-CONNECT tunnelling), capturing the setup previously hand-rolled in Nextflow's launcher + 0.1.0 - Initial release with SsrfValidator providing SSRF host validation (rejects localhost, loopback, link-local, private and cloud-metadata addresses) diff --git a/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java b/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java new file mode 100644 index 00000000..9a341373 --- /dev/null +++ b/lib-util-net/src/main/java/io/seqera/util/net/ProxyConfig.java @@ -0,0 +1,534 @@ +/* + * Copyright 2026, Seqera Labs + * + * 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.seqera.util.net; + +import java.io.IOException; +import java.net.Authenticator; +import java.net.InetSocketAddress; +import java.net.MalformedURLException; +import java.net.PasswordAuthentication; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.SocketAddress; +import java.net.URI; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Immutable HTTP/HTTPS forward (egress) proxy configuration resolved from a proxy URI or from the + * {@code HTTP_PROXY}/{@code HTTPS_PROXY}/{@code NO_PROXY} environment variables, exposed as a + * {@link ProxySelector} and a proxy-scoped {@link Authenticator} suitable for a + * {@link java.net.http.HttpClient}. + * + *

Parsing, credential-decoding, no-proxy and Basic-over-CONNECT semantics mirror Nextflow's + * {@code nextflow.util.ProxyConfig} (the source of truth), so products sharing this class share the + * same proxy behaviour. This class depends only on the JDK (and the slf4j-api logging facade); it + * never reads {@link System#getenv()} on its own — the caller passes the URI or environment map in. + * + *

{@code NO_PROXY} entries are matched as host names or domain suffixes (optionally prefixed with + * {@code .} or {@code *.}); the single entry {@code *} disables proxying entirely, and loopback + * targets always bypass the proxy. CIDR notation is not supported. + * + * @author Paolo Di Tommaso + */ +public final class ProxyConfig { + + private static final Logger log = LoggerFactory.getLogger(ProxyConfig.class); + + /** A single proxy endpoint with optional Basic credentials. */ + public record Endpoint(String host, int port, String username, String password) { + public boolean hasCredentials() { + return username != null && !username.isEmpty(); + } + public InetSocketAddress address() { + return InetSocketAddress.createUnresolved(host, port); + } + @Override + public String toString() { + // never render the password (Endpoint is public, returned by getHttpProxy()/getHttpsProxy()) + return "Endpoint[host=" + host + ", port=" + port + ", username=" + username + + ", password=" + (password != null ? "****" : null) + "]"; + } + } + + private final Endpoint httpProxy; // nullable + private final Endpoint httpsProxy; // nullable + private final List noProxyHosts; + + private ProxyConfig(Endpoint httpProxy, Endpoint httpsProxy, List noProxyHosts) { + this.httpProxy = httpProxy; + this.httpsProxy = httpsProxy; + this.noProxyHosts = normalizeNoProxy(noProxyHosts); + } + + // ------------------------------------------------------------------ factories + + /** + * Resolve a proxy applied to both http and https destinations from a single URI. + * + * @param uri A proxy URI e.g. {@code http://user:pass@proxy:3128}, or {@code null}/empty for none + * @return The corresponding {@link ProxyConfig}, or {@code null} when {@code uri} is empty + */ + public static ProxyConfig fromUri(String uri) { + return fromUri(uri, null, null, null); + } + + /** + * Resolve a proxy applied to both http and https destinations from a single URI. When an explicit + * {@code username} is provided it (with {@code password}) takes precedence over any credentials + * embedded in the URI. + * + * @param uri A proxy URI e.g. {@code http://user:pass@proxy:3128}, or {@code null}/empty for none + * @param username Proxy username overriding the URI user-info; may be {@code null} + * @param password Proxy password used together with an explicit {@code username}; may be {@code null} + * @param noProxy Hosts that must bypass the proxy; may be {@code null} + * @return The corresponding {@link ProxyConfig}, or {@code null} when {@code uri} is empty + */ + public static ProxyConfig fromUri(String uri, String username, String password, List noProxy) { + final Parsed p = warnIfTlsProxy(parse(uri)); + if( p == null ) + return null; + final boolean explicit = username != null && !username.isEmpty(); + final String user = explicit ? username : p.username(); + final String pass = explicit ? password : p.password(); + final Endpoint ep = new Endpoint(p.host(), portAsInt(p.port(), defaultPort(p)), user, pass); + final ProxyConfig cfg = new ProxyConfig(ep, ep, noProxy); + log.debug("Proxy config from uri: {}", cfg); + return cfg; + } + + /** + * Resolve per-protocol proxies from a {@code HTTP_PROXY}/{@code HTTPS_PROXY}/{@code NO_PROXY} + * environment map (upper- and lower-case names, with an {@code ALL_PROXY} fallback). The caller + * supplies the map — this method never reads {@link System#getenv()} itself. + * + * @param env The environment variables map + * @return The corresponding {@link ProxyConfig}, or {@code null} when no proxy variable is present + */ + public static ProxyConfig fromEnvironment(Map env) { + if( env == null ) + return null; + // ambient environment may carry an unsupported scheme (e.g. socks5://) or a malformed value - + // treat it as "no proxy" rather than failing the process (unlike the explicit fromUri path) + final Parsed http = warnIfTlsProxy(parseLenient(firstNonEmpty(env, "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"))); + final Parsed https = warnIfTlsProxy(parseLenient(firstNonEmpty(env, "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"))); + if( http == null && https == null ) + return null; + // the default port follows the proxy scheme (http->80, https->443), not the traffic protocol, + // so HTTPS_PROXY=http://proxy (no port) reaches the proxy on 80 - consistent with fromUri + final Endpoint httpEp = http != null + ? new Endpoint(http.host(), portAsInt(http.port(), defaultPort(http)), http.username(), http.password()) + : null; + final Endpoint httpsEp = https != null + ? new Endpoint(https.host(), portAsInt(https.port(), defaultPort(https)), https.username(), https.password()) + : null; + final ProxyConfig cfg = new ProxyConfig(httpEp, httpsEp, split(firstNonEmpty(env, "NO_PROXY", "no_proxy"))); + log.debug("Proxy config from environment: {}", cfg); + return cfg; + } + + // ------------------------------------------------------------------ java.net views + + /** + * @return {@code true} when at least one configured proxy carries credentials + */ + public boolean hasCredentials() { + return (httpProxy != null && httpProxy.hasCredentials()) + || (httpsProxy != null && httpsProxy.hasCredentials()); + } + + /** @return The resolved HTTP proxy endpoint, or {@code null} when none is configured */ + public Endpoint getHttpProxy() { + return httpProxy; + } + + /** @return The resolved HTTPS proxy endpoint, or {@code null} when none is configured */ + public Endpoint getHttpsProxy() { + return httpsProxy; + } + + /** @return The {@code NO_PROXY} host entries (normalized to lower-case), never {@code null} */ + public List getNoProxyHosts() { + return noProxyHosts; + } + + /** + * @return A {@link ProxySelector} routing per scheme, bypassing loopback and {@code NO_PROXY} targets + */ + public ProxySelector toProxySelector() { + // precompute the proxy lists - select() runs once per outbound request + final List direct = List.of(Proxy.NO_PROXY); + final List viaHttp = httpProxy != null + ? List.of(new Proxy(Proxy.Type.HTTP, httpProxy.address())) + : direct; + final List viaHttps = httpsProxy != null + ? List.of(new Proxy(Proxy.Type.HTTP, httpsProxy.address())) + : direct; + return new ProxySelector() { + @Override + public List select(URI uri) { + if( uri == null || isBypassed(uri.getHost()) ) + return direct; + return "https".equalsIgnoreCase(uri.getScheme()) ? viaHttps : viaHttp; + } + @Override + public void connectFailed(URI uri, SocketAddress sa, IOException e) { + log.debug("Failed to connect to proxy {} for {}: {}", sa, uri, e.getMessage()); + } + }; + } + + /** + * Creates a proxy-scoped {@link Authenticator}, or {@code null} when no credentials are configured. + * + *

Credentials are released only for {@link Authenticator.RequestorType#PROXY} challenges whose + * host and port match a configured proxy — never for origin-server challenges. Matching on host+port + * (not protocol) inherently covers the HTTPS {@code CONNECT} tunnel, where the JDK reports the + * requesting protocol as {@code http} even for an https destination. + * + * @return A new {@link Authenticator}, or {@code null} when no proxy credentials are configured + */ + public Authenticator toAuthenticator() { + if( !hasCredentials() ) + return null; + return new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + if( getRequestorType() != RequestorType.PROXY ) + return null; + final Endpoint ep = credentialsFor(getRequestingHost(), getRequestingPort()); + if( ep == null ) + return null; + final String pass = ep.password() != null ? ep.password() : ""; + return new PasswordAuthentication(ep.username(), pass.toCharArray()); + } + }; + } + + /** + * Determines whether the given target host must bypass the proxy, because it is a loopback address + * or matches a {@code NO_PROXY} entry. + * + * @param host The target host name + * @return {@code true} when the target must be reached directly + */ + public boolean isBypassed(String host) { + if( host == null ) + return true; + final String target = host.toLowerCase(Locale.ROOT); + // always bypass loopback targets, consistent with the JDK default `http.nonProxyHosts` + if( target.equals("localhost") || target.startsWith("127.") || target.equals("::1") || target.equals("[::1]") ) + return true; + for( String entry : noProxyHosts ) { + if( entry.equals("*") ) + return true; + // "*.example.com" and ".example.com" match sub-domains only; + // "example.com" matches the host itself and any sub-domain + final String suffix = entry.startsWith("*.") ? entry.substring(1) : entry; + if( suffix.startsWith(".") ) { + if( target.endsWith(suffix) ) + return true; + } + else if( target.equals(suffix) || target.endsWith("." + suffix) ) { + return true; + } + } + return false; + } + + private Endpoint credentialsFor(String host, int port) { + if( httpsProxy != null && httpsProxy.hasCredentials() && httpsProxy.host().equalsIgnoreCase(host) && httpsProxy.port() == port ) + return httpsProxy; + if( httpProxy != null && httpProxy.hasCredentials() && httpProxy.host().equalsIgnoreCase(host) && httpProxy.port() == port ) + return httpProxy; + return null; + } + + // ------------------------------------------------------------------ Basic-over-CONNECT toggle + + /** + * The JDK disables the Basic scheme for proxy authentication over HTTPS {@code CONNECT} tunnelling + * by default ({@code jdk.http.auth.tunneling.disabledSchemes=Basic}), which blocks authenticating + * proxies for {@code https} targets. This clears that property so Basic credentials reach the proxy, + * but only when it is unset — an operator's explicit value (e.g. via {@code JAVA_TOOL_OPTIONS}) wins. + * + *

Call once at startup, before the first outbound request: the JDK reads this property lazily and + * only once. + * + * @return {@code true} when the property was changed, {@code false} when it was already set + */ + public static boolean enableBasicProxyTunneling() { + final String key = "jdk.http.auth.tunneling.disabledSchemes"; + if( System.getProperty(key) == null ) { + System.setProperty(key, ""); + log.debug("Cleared '{}' to allow Basic proxy authentication over HTTPS tunnelling", key); + return true; + } + return false; + } + + // ------------------------------------------------------------------ JVM-global setup from the environment + + /** + * Resolve the http/https/ftp proxies from the environment and install them into the JVM, mirroring + * the setup Nextflow's launcher performs so JVM-global HTTP/FTP code (e.g. {@code URLConnection}) + * honours the proxy: + *

+ * The caller supplies the map — this method never reads {@link System#getenv()} itself. A malformed + * proxy value is logged and skipped rather than raised. + * + * @param env The environment variables map + * @return The resolved http/https {@link ProxyConfig} for wiring {@code java.net.http} clients + * explicitly (ftp has no {@code java.net.http} representation - it is applied via system + * properties only, so this can return {@code null} even after {@code FTP_PROXY} installed + * ftp system properties), or {@code null} when no http/https proxy variable is present + */ + public static ProxyConfig setupFromEnvironment(Map env) { + if( env == null ) + return null; + // per-protocol JVM system properties (honoured by URLConnection, FTP and other JVM-global code) + applyProxySystemProperty(env, "http"); + applyProxySystemProperty(env, "https"); + applyProxySystemProperty(env, "ftp"); + final String noProxy = firstNonEmpty(env, "NO_PROXY", "no_proxy"); + if( noProxy != null && !noProxy.isBlank() ) { + final String nonProxyHosts = toNonProxyHosts(split(noProxy)); + // http.nonProxyHosts covers both http and https; ftp has its own property + System.setProperty("http.nonProxyHosts", nonProxyHosts); + System.setProperty("ftp.nonProxyHosts", nonProxyHosts); + } + // http/https config for java.net.http clients (ftp is not an HttpClient scheme) + final ProxyConfig cfg = fromEnvironment(env); + if( cfg != null && cfg.hasCredentials() ) { + Authenticator.setDefault(cfg.toAuthenticator()); + enableBasicProxyTunneling(); + } + return cfg; + } + + private static void applyProxySystemProperty(Map env, String proto) { + final Parsed p = parseLenient(firstNonEmpty(env, proto.toUpperCase(Locale.ROOT) + "_PROXY", proto + "_proxy", "ALL_PROXY", "all_proxy")); + if( p == null ) + return; + System.setProperty(proto + ".proxyHost", p.host()); + if( p.port() != null && !p.port().isBlank() ) + System.setProperty(proto + ".proxyPort", p.port()); + } + + /** + * Translate {@code NO_PROXY} entries to the JDK {@code http.nonProxyHosts} grammar (exact host or + * {@code *} wildcard, {@code |}-separated). Setting the property replaces the JDK default, so the + * loopback bypass ({@code localhost|127.*|[::1]|0.0.0.0|[::0]}) is prepended; and because a bare + * {@code example.com} means "host and sub-domains" here (see {@link #isBypassed(String)}) it is + * expanded to {@code example.com|*.example.com}, while {@code .x}/{@code *.x} become {@code *.x}. + */ + private static String toNonProxyHosts(List entries) { + final StringBuilder sb = new StringBuilder("localhost|127.*|[::1]|0.0.0.0|[::0]"); + for( String entry : entries ) { + if( entry.equals("*") ) + sb.append("|*"); + else if( entry.startsWith("*.") ) + sb.append('|').append(entry); + else if( entry.startsWith(".") ) + sb.append("|*").append(entry); // ".corp" -> "*.corp" + else + sb.append('|').append(entry).append("|*.").append(entry); // "x" -> "x|*.x" + } + return sb.toString(); + } + + // ------------------------------------------------------------------ parsing (source of truth: nextflow.util.ProxyConfig) + + /** + * The components of a parsed proxy URI. Percent-encoded {@code username}/{@code password} are + * decoded; any path/query in the URI is ignored. Fields not present in the input are {@code null}. + */ + public record Parsed(String protocol, String host, String port, String username, String password) { + @Override + public String toString() { + // never render the password + return "Parsed[protocol=" + protocol + ", host=" + host + ", port=" + port + + ", username=" + username + ", password=" + (password != null ? "****" : null) + "]"; + } + } + + /** + * Like {@link #parse(String)} but lenient: an unsupported scheme (e.g. {@code socks5://}) or an + * otherwise malformed value is logged and treated as absent rather than raised. Used for the + * ambient environment, which the caller does not control. + */ + private static Parsed parseLenient(String value) { + try { + return parse(value); + } + catch( IllegalArgumentException e ) { + log.warn("Ignoring unsupported or invalid proxy value '{}': {}", redactUserInfo(value), e.getMessage()); + return null; + } + } + + /** + * Warn when a proxy is addressed over {@code https}: the JDK {@link java.net.http.HttpClient} has + * no TLS-to-proxy support, so it will speak plaintext to the proxy. Returns the argument unchanged. + */ + private static Parsed warnIfTlsProxy(Parsed p) { + if( p != null && "https".equalsIgnoreCase(p.protocol()) ) + log.warn("Proxy '{}' is addressed over https, but connecting to a proxy over TLS is not supported - the connection to the proxy will use plaintext", p.host()); + return p; + } + + /** + * Parse a proxy string retrieving its protocol, host, port, username and password components. + * Exposed so callers that need the individual components (e.g. to set {@code -Dhttp.proxyHost} + * system properties) can reuse the same parsing instead of duplicating it. + * + *

Limitations (unchanged, mirroring Nextflow): a bracketed IPv6 literal without a scheme + * ({@code [::1]:3128}) is not parsed correctly - give it a scheme ({@code http://[::1]:3128}); + * and {@code NO_PROXY} entries do not carry ports. + * + * @param value A proxy string e.g. {@code host}, {@code host:port}, {@code scheme://host:port} + * or {@code scheme://user:pass@host:port} + * @return The parsed components, or {@code null} when {@code value} is empty + * @throws IllegalArgumentException when {@code value} is not a valid proxy URL + */ + public static Parsed parse(String value) { + if( value == null || value.isEmpty() ) + return null; + try { + if( value.contains("://") ) { + final URL url = new URL(value); + String user = null, pass = null; + final String info = url.getUserInfo(); + if( info != null && !info.isEmpty() ) { + final int p = info.indexOf(':'); + if( p == -1 ) { + user = decodeUserInfo(info); // username-only (e.g. a token proxy), no password + } + else { + user = decodeUserInfo(info.substring(0, p)); + pass = decodeUserInfo(info.substring(p + 1)); + } + } + final String port = url.getPort() > 0 ? String.valueOf(url.getPort()) : null; + return new Parsed(url.getProtocol(), url.getHost(), port, user, pass); + } + final int p = value.indexOf(':'); + if( p != -1 ) + return new Parsed(null, value.substring(0, p), value.substring(p + 1), null, null); + return new Parsed(null, value, null, null, null); + } + catch( MalformedURLException e ) { + // never include the raw value verbatim - it may carry the proxy password in its user-info + throw new IllegalArgumentException("Invalid proxy URL: " + redactUserInfo(value), e); + } + } + + /** + * Replace the user-info of a proxy URI with {@code ****} so credentials are never logged or + * surfaced in an exception message, e.g. {@code http://user:pass@host} → {@code http://****@host}. + */ + private static String redactUserInfo(String value) { + return value != null ? value.replaceAll("://[^@/]+@", "://****@") : null; + } + + /** + * Percent-decode a userinfo component (username or password) per RFC 3986, so proxy credentials + * carrying special characters (e.g. {@code @}, {@code :}) work. A literal {@code +} is preserved — + * userinfo is not form-encoded — so it is shielded from the {@code +}→space rule of + * {@link URLDecoder}. + */ + private static String decodeUserInfo(String s) { + return s != null ? URLDecoder.decode(s.replace("+", "%2B"), StandardCharsets.UTF_8) : null; + } + + /** @return the default proxy port for the parsed scheme: 443 for https, 80 otherwise */ + private static int defaultPort(Parsed p) { + return "https".equalsIgnoreCase(p.protocol()) ? 443 : 80; + } + + private static int portAsInt(String port, int defaultPort) { + if( port == null || port.isBlank() ) + return defaultPort; + try { + return Integer.parseInt(port.trim()); + } + catch( NumberFormatException e ) { + log.warn("Ignoring invalid proxy port '{}' - using default {}", port, defaultPort); + return defaultPort; + } + } + + // ------------------------------------------------------------------ helpers + + private static List normalizeNoProxy(List hosts) { + if( hosts == null ) + return List.of(); + final List result = new ArrayList<>(); + for( String h : hosts ) { + if( h == null ) + continue; + final String t = h.trim().toLowerCase(Locale.ROOT); + if( !t.isEmpty() ) + result.add(t); + } + return List.copyOf(result); + } + + private static List split(String csv) { + if( csv == null || csv.isBlank() ) + return List.of(); + final List result = new ArrayList<>(); + for( String s : csv.split(",") ) + if( !s.isBlank() ) + result.add(s.trim()); + return result; + } + + private static String firstNonEmpty(Map env, String... keys) { + for( String k : keys ) { + final String v = env.get(k); + if( v != null && !v.isEmpty() ) + return v; + } + return null; + } + + @Override + public String toString() { + return "ProxyConfig[http=" + describe(httpProxy) + "; https=" + describe(httpsProxy) + "; noProxy=" + noProxyHosts + "]"; + } + + private static String describe(Endpoint ep) { + // credentials are never rendered + return ep == null ? "-" : ep.host() + ":" + ep.port() + (ep.hasCredentials() ? " (auth)" : ""); + } +} diff --git a/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy b/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy new file mode 100644 index 00000000..ea846dbd --- /dev/null +++ b/lib-util-net/src/test/groovy/io/seqera/util/net/ProxyConfigTest.groovy @@ -0,0 +1,409 @@ +/* + * Copyright 2026, Seqera Labs + * + * 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.seqera.util.net + +import spock.lang.ResourceLock +import spock.lang.Specification +import spock.lang.Unroll + +/** + * Tests for the {@link ProxyConfig} egress-proxy resolver. + * + *

Parsing, no-proxy and proxy-authentication semantics mirror Nextflow's + * {@code nextflow.util.ProxyConfig} (the source of truth). + * + * @author Paolo Di Tommaso + */ +class ProxyConfigTest extends Specification { + + static final List DIRECT = List.of(Proxy.NO_PROXY) + + static List proxied(String host, int port) { + return List.of(new Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port))) + } + + @Unroll + def 'should parse proxy uri #PROXY_URI' () { + when: + def config = ProxyConfig.parse(PROXY_URI) + then: + config?.protocol() == PROTOCOL + config?.host() == HOST + config?.port() == PORT + config?.username() == USER + config?.password() == PASS + + where: + PROXY_URI | PROTOCOL | HOST | PORT | USER | PASS + null | null | null | null | null | null + '' | null | null | null | null | null + 'proxy.example.com' | null | 'proxy.example.com' | null | null | null + 'proxy.example.com:3128' | null | 'proxy.example.com' | '3128' | null | null + 'http://proxy.example.com' | 'http' | 'proxy.example.com' | null | null | null + 'https://proxy.example.com:8080' | 'https' | 'proxy.example.com' | '8080' | null | null + 'http://foo:bar@proxy.example.com:8080' | 'http' | 'proxy.example.com' | '8080' | 'foo' | 'bar' + 'http://foo:p%40ss@proxy.example.com' | 'http' | 'proxy.example.com' | null | 'foo' | 'p@ss' + 'http://foo:p+ss@proxy.example.com' | 'http' | 'proxy.example.com' | null | 'foo' | 'p+ss' + 'http://foo:b:ar@proxy.example.com:1234' | 'http' | 'proxy.example.com' | '1234' | 'foo' | 'b:ar' + // a path/query in the URI is ignored + 'http://10.20.30.40:333/some/path' | 'http' | '10.20.30.40' | '333' | null | null + 'http://user:pass@10.20.30.40:333/some/path'| 'http' | '10.20.30.40' | '333' | 'user' | 'pass' + // username-only user-info (e.g. a token proxy) keeps the username, no password + 'http://token@proxy.example.com:3128' | 'http' | 'proxy.example.com' | '3128' | 'token' | null + } + + def 'should treat an unsupported or malformed proxy scheme in the environment as no proxy' () { + expect: 'socks5 (and other unsupported schemes) are ignored, not raised' + ProxyConfig.fromEnvironment([ALL_PROXY: 'socks5://proxy:1080']) == null + and: 'a bad entry for one protocol does not sink a valid one' + ProxyConfig.fromEnvironment([HTTP_PROXY: 'http://ok:3128', HTTPS_PROXY: 'socks5://bad:1']) + .toProxySelector().select(new URI('http://x/')) == proxied('ok', 3128) + } + + def 'should throw for an unsupported scheme on the explicit fromUri path' () { + when: + ProxyConfig.fromUri('socks5://proxy:1080') + then: + thrown(IllegalArgumentException) + } + + def 'should return null for a null environment map' () { + expect: + ProxyConfig.fromEnvironment(null) == null + ProxyConfig.setupFromEnvironment(null) == null + } + + def 'a username-only proxy releases credentials with an empty password' () { + given: + def auth = ProxyConfig.fromUri('http://token@proxy.example.com:3128').toAuthenticator() + when: + def result = auth.requestPasswordAuthenticationInstance('proxy.example.com', null, 3128, 'http', 'auth', 'basic', null, Authenticator.RequestorType.PROXY) + then: + result.userName == 'token' + result.password == ''.toCharArray() + } + + def 'should resolve a proxy uri applying it to both http and https destinations' () { + when: + def config = ProxyConfig.fromUri('http://proxy.example.com:3128') + def selector = config.toProxySelector() + then: + selector.select(new URI('http://quay.io/v2/')) == proxied('proxy.example.com', 3128) + selector.select(new URI('https://quay.io/v2/')) == proxied('proxy.example.com', 3128) + and: + !config.hasCredentials() + config.toAuthenticator() == null + } + + @Unroll + def 'should default the proxy port by protocol for #PROXY_URI' () { + when: + def selector = ProxyConfig.fromUri(PROXY_URI).toProxySelector() + then: + selector.select(new URI('https://quay.io/v2/')) == proxied('proxy.example.com', PORT) + + where: + PROXY_URI | PORT + 'proxy.example.com' | 80 + 'http://proxy.example.com' | 80 + 'https://proxy.example.com' | 443 + } + + def 'should return no proxy config when the uri is empty' () { + expect: + ProxyConfig.fromUri(null) == null + ProxyConfig.fromUri('') == null + } + + def 'should give precedence to explicit credentials over uri user-info' () { + given: + def config = ProxyConfig.fromUri('http://foo:bar@proxy.example.com:8080', 'this', 'that', null) + + when: + def result = config.toAuthenticator().requestPasswordAuthenticationInstance( + 'proxy.example.com', null, 8080, 'http', 'auth required', 'basic', null, Authenticator.RequestorType.PROXY) + then: + result.userName == 'this' + result.password == 'that'.toCharArray() + } + + @Unroll + def 'should resolve proxy from environment #ENV' () { + when: + def config = ProxyConfig.fromEnvironment(ENV) + def selector = config?.toProxySelector() + then: + (selector?.select(new URI('http://foo.com/'))) == HTTP_RESULT + (selector?.select(new URI('https://foo.com/'))) == HTTPS_RESULT + + where: + ENV | HTTP_RESULT | HTTPS_RESULT + [:] | null | null + [HTTPS_PROXY: 'http://proxy1:3128'] | DIRECT | proxied('proxy1', 3128) + [https_proxy: 'http://proxy1:3128'] | DIRECT | proxied('proxy1', 3128) + [HTTP_PROXY: 'http://proxy2:8080'] | proxied('proxy2', 8080) | DIRECT + [HTTPS_PROXY: 'http://proxy1:3128', HTTP_PROXY: 'http://x:1'] | proxied('x', 1) | proxied('proxy1', 3128) + [HTTPS_PROXY: 'https://proxy1'] | DIRECT | proxied('proxy1', 443) + } + + def 'should resolve proxy credentials and no-proxy from environment' () { + when: + def config = ProxyConfig.fromEnvironment([HTTPS_PROXY: 'http://foo:bar@proxy1:3128', NO_PROXY: 'a.com']) + then: + config.hasCredentials() + config.toProxySelector().select(new URI('https://a.com/')) == DIRECT + and: + def result = config.toAuthenticator().requestPasswordAuthenticationInstance( + 'proxy1', null, 3128, 'http', 'auth required', 'basic', null, Authenticator.RequestorType.PROXY) + result.userName == 'foo' + result.password == 'bar'.toCharArray() + } + + def 'should fall back to ALL_PROXY when protocol-specific vars are absent' () { + when: + def config = ProxyConfig.fromEnvironment([ALL_PROXY: 'http://proxy1:3128']) + def selector = config.toProxySelector() + then: + selector.select(new URI('http://foo.com/')) == proxied('proxy1', 3128) + selector.select(new URI('https://foo.com/')) == proxied('proxy1', 3128) + } + + @Unroll + def 'should bypass=#EXPECTED the proxy for host #TARGET with no-proxy #NO_PROXY' () { + given: + def selector = ProxyConfig.fromUri('proxy.example.com:3128', null, null, NO_PROXY).toProxySelector() + expect: + (selector.select(new URI("https://${TARGET}/")) == DIRECT) == 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 + // a bare host name entry also matches its sub-domains + 'registry.docker.io'| ['docker.io'] | true + // a `.` or `*.` suffix entry matches sub-domains only + 'reg.example.com' | ['.example.com'] | true + 'reg.example.com' | ['*.example.com'] | true + 'example.com' | ['.example.com'] | false + 'notexample.com' | ['.example.com'] | false + 'anything.io' | ['*'] | true + // loopback addresses always bypass the proxy + 'localhost' | null | true + '127.0.0.1' | null | true + } + + def 'should create authenticator scoped to the proxy host and requestor type' () { + given: + def auth = ProxyConfig.fromUri('http://foo:bar@proxy.example.com:3128').toAuthenticator() + + 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 + + when: 'a different port asks for proxy authentication' + result = auth.requestPasswordAuthenticationInstance('proxy.example.com', null, 8080, 'http', 'auth required', 'basic', null, Authenticator.RequestorType.PROXY) + then: + result == null + } + + def 'should release credentials for the https CONNECT tunnel where the JDK reports protocol http' () { + given: 'an https-only proxy with credentials, resolved from the environment' + def auth = ProxyConfig.fromEnvironment([HTTPS_PROXY: 'http://foo:bar@proxy.example.com:3128']).toAuthenticator() + + when: 'the JDK challenges for the CONNECT tunnel reporting the requesting protocol as http' + def result = auth.requestPasswordAuthenticationInstance('proxy.example.com', null, 3128, 'http', 'auth required', 'basic', null, Authenticator.RequestorType.PROXY) + then: 'credentials are released because matching is on host+port, not protocol' + result.userName == 'foo' + result.password == 'bar'.toCharArray() + } + + def 'should expose the resolved per-protocol endpoints and no-proxy hosts' () { + when: + def cfg = ProxyConfig.fromEnvironment([HTTPS_PROXY: 'http://foo:bar@https-proxy:3129', HTTP_PROXY: 'http://http-proxy:3128', NO_PROXY: 'a.com,b.com']) + then: + cfg.httpProxy.host() == 'http-proxy' + cfg.httpProxy.port() == 3128 + cfg.httpProxy.username() == null + cfg.httpsProxy.host() == 'https-proxy' + cfg.httpsProxy.port() == 3129 + cfg.httpsProxy.username() == 'foo' + cfg.httpsProxy.password() == 'bar' + cfg.noProxyHosts == ['a.com','b.com'] + and: 'fromUri applies the same endpoint to both protocols' + with(ProxyConfig.fromUri('proxy:8080')) { + httpProxy.host() == 'proxy' && httpProxy.port() == 8080 + httpsProxy.host() == 'proxy' && httpsProxy.port() == 8080 + } + } + + def 'should redact password in string representation' () { + expect: + !ProxyConfig.fromUri('http://foo:secret1234@proxy.example.com').toString().contains('secret1234') + } + + def 'Endpoint toString should redact the password' () { + given: + def ep = ProxyConfig.fromUri('http://foo:secret1234@proxy.example.com:3128').httpProxy + expect: + !ep.toString().contains('secret1234') + ep.toString().contains('****') + } + + def 'should not leak the proxy password in the error for an invalid proxy uri' () { + when: 'an unsupported scheme carrying credentials' + ProxyConfig.fromUri('socks5://foo:secret1234@proxy.example.com:1080') + then: + def e = thrown(IllegalArgumentException) + !e.message.contains('secret1234') + e.message.contains('****') + } + + def 'fromEnvironment should default the proxy port from the proxy scheme, not the traffic protocol' () { + expect: 'HTTPS_PROXY reached over http with no port -> 80 (proxy scheme), consistent with fromUri' + (ProxyConfig.fromEnvironment([HTTPS_PROXY: 'http://proxy.corp']).toProxySelector().select(new URI('https://x/'))[0].address() as InetSocketAddress).port == 80 + and: 'an https-scheme proxy still defaults to 443' + (ProxyConfig.fromEnvironment([HTTPS_PROXY: 'https://proxy.corp']).toProxySelector().select(new URI('https://x/'))[0].address() as InetSocketAddress).port == 443 + } + + @ResourceLock('proxy-jvm-globals') + def 'setupFromEnvironment should install per-protocol system properties and return the http/https config' () { + given: + def keys = ['http.proxyHost','http.proxyPort','https.proxyHost','https.proxyPort', + 'ftp.proxyHost','ftp.proxyPort','http.nonProxyHosts','ftp.nonProxyHosts', + 'jdk.http.auth.tunneling.disabledSchemes'] + def saved = keys.collectEntries { [(it): System.getProperty(it)] } + keys.each { System.clearProperty(it) } + def savedAuth = Authenticator.default + + when: + def cfg = ProxyConfig.setupFromEnvironment([ + HTTP_PROXY : 'http://alice:secret@http-proxy:3128', + HTTPS_PROXY: 'http://https-proxy:8080', + FTP_PROXY : 'ftp-proxy:2121', + NO_PROXY : 'internal.example.com, .corp' ]) + + then: 'per-protocol system properties are set (ftp too)' + System.getProperty('http.proxyHost') == 'http-proxy' + System.getProperty('http.proxyPort') == '3128' + System.getProperty('https.proxyHost') == 'https-proxy' + System.getProperty('https.proxyPort') == '8080' + System.getProperty('ftp.proxyHost') == 'ftp-proxy' + System.getProperty('ftp.proxyPort') == '2121' + and: 'NO_PROXY -> http.nonProxyHosts in JDK grammar, keeping the loopback defaults and http+ftp' + System.getProperty('http.nonProxyHosts') == 'localhost|127.*|[::1]|0.0.0.0|[::0]|internal.example.com|*.internal.example.com|*.corp' + System.getProperty('ftp.nonProxyHosts') == System.getProperty('http.nonProxyHosts') + and: 'credentials present -> tunnelling Basic scheme is enabled' + System.getProperty('jdk.http.auth.tunneling.disabledSchemes') == '' + and: 'the returned config carries the http/https proxies and no-proxy for java.net.http clients' + cfg.hasCredentials() + cfg.toProxySelector().select(new URI('http://x/')) == proxied('http-proxy', 3128) + cfg.toProxySelector().select(new URI('https://x/')) == proxied('https-proxy', 8080) + cfg.toProxySelector().select(new URI('https://internal.example.com/')) == DIRECT + + cleanup: + keys.each { saved[it] != null ? System.setProperty(it, saved[it]) : System.clearProperty(it) } + Authenticator.setDefault(savedAuth) + } + + def 'setupFromEnvironment should return null and touch nothing when no proxy var is set' () { + expect: + ProxyConfig.setupFromEnvironment([:]) == null + } + + @ResourceLock('proxy-jvm-globals') + def 'setupFromEnvironment should fall back to ALL_PROXY for the system properties' () { + given: + def keys = ['http.proxyHost','http.proxyPort','https.proxyHost','https.proxyPort'] + def saved = keys.collectEntries { [(it): System.getProperty(it)] } + keys.each { System.clearProperty(it) } + + when: 'ALL_PROXY is the only variable set' + ProxyConfig.setupFromEnvironment([ALL_PROXY: 'all.example.com:9090']) + then: 'it feeds both http and https system properties' + System.getProperty('http.proxyHost') == 'all.example.com' + System.getProperty('http.proxyPort') == '9090' + System.getProperty('https.proxyHost') == 'all.example.com' + System.getProperty('https.proxyPort') == '9090' + + cleanup: + keys.each { saved[it] != null ? System.setProperty(it, saved[it]) : System.clearProperty(it) } + } + + @Unroll + @ResourceLock('proxy-jvm-globals') + def 'setupFromEnvironment disabledSchemes handling: preset=#PRESET creds=#CREDS -> #EXPECTED' () { + given: + def key = 'jdk.http.auth.tunneling.disabledSchemes' + def previous = System.getProperty(key) + PRESET != null ? System.setProperty(key, PRESET) : System.clearProperty(key) + def savedAuth = Authenticator.default + + when: + ProxyConfig.setupFromEnvironment([HTTPS_PROXY: CREDS ? 'http://user:pass@proxy:8080' : 'http://proxy:8080']) + then: + System.getProperty(key) == EXPECTED + + cleanup: + previous != null ? System.setProperty(key, previous) : System.clearProperty(key) + Authenticator.setDefault(savedAuth) + + where: + PRESET | CREDS || EXPECTED + null | true || '' // credentials + unset -> cleared to enable Basic over CONNECT + null | false || null // no credentials -> left untouched + 'NTLM' | true || 'NTLM' // operator value always wins + } + + @ResourceLock('proxy-jvm-globals') + def 'should clear the tunnelling disabledSchemes property only when unset' () { + given: + def key = 'jdk.http.auth.tunneling.disabledSchemes' + def previous = System.getProperty(key) + + when: 'the property is not set' + System.clearProperty(key) + def changed = ProxyConfig.enableBasicProxyTunneling() + then: + changed + System.getProperty(key) == '' + + when: 'the property is already set by the operator' + System.setProperty(key, 'Basic') + changed = ProxyConfig.enableBasicProxyTunneling() + then: 'the operator value wins and nothing changes' + !changed + System.getProperty(key) == 'Basic' + + cleanup: + if( previous != null ) System.setProperty(key, previous) else System.clearProperty(key) + } +}