From f050424587272bb89868aded19c5b6225d235348 Mon Sep 17 00:00:00 2001 From: Simon Brandhof Date: Tue, 8 Nov 2016 15:34:18 +0100 Subject: [PATCH] SONAR-8351 share OkHttpClient utilities sonar-ws correctly configures OkHttpClient for the support of HTTPS, proxy (incl. authentication), timeouts and user agent. This code should be reused by web server and compute engine, for example when sending webhook payloads. The new class OkHttpClientProvider allows web server/CE to instantiate and configure a single instance of OkHttpClient. --- .../container/ComputeEngineContainerImpl.java | 3 + .../ComputeEngineContainerImplTest.java | 2 +- .../platformlevel/PlatformLevel1.java | 2 + .../server/util/OkHttpClientProvider.java | 69 +++++ .../server/util/OkHttpClientProviderTest.java | 86 ++++++ .../bootstrap/BatchWsClientProviderTest.java | 13 +- .../sonarqube/ws/client/HttpConnector.java | 163 +----------- .../ws/client/OkHttpClientBuilder.java | 248 ++++++++++++++++++ .../ws/client/HttpConnectorTest.java | 7 +- .../ws/client/OkHttpClientBuilderTest.java | 60 +++++ 10 files changed, 485 insertions(+), 168 deletions(-) create mode 100644 server/sonar-server/src/main/java/org/sonar/server/util/OkHttpClientProvider.java create mode 100644 server/sonar-server/src/test/java/org/sonar/server/util/OkHttpClientProviderTest.java create mode 100644 sonar-ws/src/main/java/org/sonarqube/ws/client/OkHttpClientBuilder.java create mode 100644 sonar-ws/src/test/java/org/sonarqube/ws/client/OkHttpClientBuilderTest.java diff --git a/server/sonar-ce/src/main/java/org/sonar/ce/container/ComputeEngineContainerImpl.java b/server/sonar-ce/src/main/java/org/sonar/ce/container/ComputeEngineContainerImpl.java index 6b2d0fdf938..4bcdb4b567f 100644 --- a/server/sonar-ce/src/main/java/org/sonar/ce/container/ComputeEngineContainerImpl.java +++ b/server/sonar-ce/src/main/java/org/sonar/ce/container/ComputeEngineContainerImpl.java @@ -137,6 +137,7 @@ import org.sonar.server.user.DefaultUserFinder; import org.sonar.server.user.DeprecatedUserFinder; import org.sonar.server.user.index.UserIndex; import org.sonar.server.user.index.UserIndexer; +import org.sonar.server.util.OkHttpClientProvider; import org.sonar.server.view.index.ViewIndex; import org.sonar.server.view.index.ViewIndexer; import org.sonarqube.ws.Rules; @@ -241,6 +242,8 @@ public class ComputeEngineContainerImpl implements ComputeEngineContainer { // issues IssueIndex.class, + + new OkHttpClientProvider() }; } diff --git a/server/sonar-ce/src/test/java/org/sonar/ce/container/ComputeEngineContainerImplTest.java b/server/sonar-ce/src/test/java/org/sonar/ce/container/ComputeEngineContainerImplTest.java index 4f323c24de0..b9b59c7dc48 100644 --- a/server/sonar-ce/src/test/java/org/sonar/ce/container/ComputeEngineContainerImplTest.java +++ b/server/sonar-ce/src/test/java/org/sonar/ce/container/ComputeEngineContainerImplTest.java @@ -105,7 +105,7 @@ public class ComputeEngineContainerImplTest { ); assertThat(picoContainer.getParent().getParent().getParent().getComponentAdapters()).hasSize( COMPONENTS_IN_LEVEL_1_AT_CONSTRUCTION - + 24 // level 1 + + 25 // level 1 + 46 // content of DaoModule + 2 // content of EsSearchModule + 62 // content of CorePropertyDefinitions diff --git a/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel1.java b/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel1.java index 036f37de562..2bbe67023bb 100644 --- a/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel1.java +++ b/server/sonar-server/src/main/java/org/sonar/server/platform/platformlevel/PlatformLevel1.java @@ -55,6 +55,7 @@ import org.sonar.server.rule.index.RuleIndex; import org.sonar.server.search.EsSearchModule; import org.sonar.server.setting.ThreadLocalSettings; import org.sonar.server.user.ThreadLocalUserSession; +import org.sonar.server.util.OkHttpClientProvider; public class PlatformLevel1 extends PlatformLevel { private final Platform platform; @@ -118,6 +119,7 @@ public class PlatformLevel1 extends PlatformLevel { // issues IssueIndex.class, + new OkHttpClientProvider(), // Classes kept for backward compatibility of plugins/libs (like sonar-license) that are directly calling classes from the core org.sonar.core.properties.PropertiesDao.class); addAll(CorePropertyDefinitions.all()); diff --git a/server/sonar-server/src/main/java/org/sonar/server/util/OkHttpClientProvider.java b/server/sonar-server/src/main/java/org/sonar/server/util/OkHttpClientProvider.java new file mode 100644 index 00000000000..da1e4072753 --- /dev/null +++ b/server/sonar-server/src/main/java/org/sonar/server/util/OkHttpClientProvider.java @@ -0,0 +1,69 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.util; + +import okhttp3.OkHttpClient; +import org.picocontainer.injectors.ProviderAdapter; +import org.sonar.api.SonarRuntime; +import org.sonar.api.ce.ComputeEngineSide; +import org.sonar.api.config.Settings; +import org.sonar.api.server.ServerSide; +import org.sonar.process.ProcessProperties; +import org.sonarqube.ws.client.OkHttpClientBuilder; + +import static java.lang.String.format; + +/** + * Provide a unique instance of {@link OkHttpClient} which configuration: + * + */ +@ServerSide +@ComputeEngineSide +public class OkHttpClientProvider extends ProviderAdapter { + + private static final int DEFAULT_CONNECT_TIMEOUT_IN_MS = 10_000; + private static final int DEFAULT_READ_TIMEOUT_IN_MS = 10_000; + + private okhttp3.OkHttpClient okHttpClient; + + /** + * @return a {@link OkHttpClient} singleton + */ + public OkHttpClient provide(Settings settings, SonarRuntime runtime) { + if (okHttpClient == null) { + OkHttpClientBuilder builder = new OkHttpClientBuilder(); + builder.setConnectTimeoutMs(DEFAULT_CONNECT_TIMEOUT_IN_MS); + builder.setReadTimeoutMs(DEFAULT_READ_TIMEOUT_IN_MS); + // no need to define proxy URL as system-wide proxy is used and properly + // configured by bootstrap process. + builder.setProxyLogin(settings.getString(ProcessProperties.HTTP_PROXY_USER)); + builder.setProxyPassword(settings.getString(ProcessProperties.HTTP_PROXY_PASSWORD)); + builder.setUserAgent(format("SonarQube/%s", runtime.getApiVersion().toString())); + okHttpClient = builder.build(); + } + return okHttpClient; + } +} diff --git a/server/sonar-server/src/test/java/org/sonar/server/util/OkHttpClientProviderTest.java b/server/sonar-server/src/test/java/org/sonar/server/util/OkHttpClientProviderTest.java new file mode 100644 index 00000000000..e3f3f4b92f3 --- /dev/null +++ b/server/sonar-server/src/test/java/org/sonar/server/util/OkHttpClientProviderTest.java @@ -0,0 +1,86 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonar.server.util; + +import java.io.IOException; +import java.util.Base64; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.Rule; +import org.junit.Test; +import org.sonar.api.SonarQubeSide; +import org.sonar.api.SonarRuntime; +import org.sonar.api.config.MapSettings; +import org.sonar.api.config.Settings; +import org.sonar.api.internal.SonarRuntimeImpl; +import org.sonar.api.utils.Version; + +import static org.assertj.core.api.Assertions.assertThat; + +public class OkHttpClientProviderTest { + + private Settings settings = new MapSettings(); + private SonarRuntime runtime = SonarRuntimeImpl.forSonarQube(Version.parse("6.2"), SonarQubeSide.SERVER); + private final OkHttpClientProvider underTest = new OkHttpClientProvider(); + + @Rule + public MockWebServer server = new MockWebServer(); + + @Test + public void get_returns_a_OkHttpClient_with_default_configuration() throws Exception { + OkHttpClient client = underTest.provide(settings, runtime); + + assertThat(client.connectTimeoutMillis()).isEqualTo(10_000); + assertThat(client.readTimeoutMillis()).isEqualTo(10_000); + assertThat(client.proxy()).isNull(); + + RecordedRequest recordedRequest = call(client); + assertThat(recordedRequest.getHeader("User-Agent")).isEqualTo("SonarQube/6.2"); + assertThat(recordedRequest.getHeader("Proxy-Authorization")).isNull(); + } + + @Test + public void get_returns_a_OkHttpClient_with_proxy_authentication() throws Exception { + settings.setProperty("http.proxyUser", "the-login"); + settings.setProperty("http.proxyPassword", "the-password"); + + OkHttpClient client = underTest.provide(settings, runtime); + RecordedRequest recordedRequest = call(client); + + assertThat(recordedRequest.getHeader("Proxy-Authorization")).isEqualTo("Basic " + Base64.getEncoder().encodeToString("the-login:the-password".getBytes())); + } + + @Test + public void get_returns_a_singleton() { + OkHttpClient client1 = underTest.provide(settings, runtime); + OkHttpClient client2 = underTest.provide(settings, runtime); + assertThat(client2).isNotNull().isSameAs(client1); + } + + private RecordedRequest call(OkHttpClient client) throws IOException, InterruptedException { + server.enqueue(new MockResponse().setBody("pong")); + client.newCall(new Request.Builder().url(server.url("/ping")).build()).execute(); + + return server.takeRequest(); + } +} diff --git a/sonar-scanner-engine/src/test/java/org/sonar/scanner/bootstrap/BatchWsClientProviderTest.java b/sonar-scanner-engine/src/test/java/org/sonar/scanner/bootstrap/BatchWsClientProviderTest.java index 8fb927c6205..5719c8b65a7 100644 --- a/sonar-scanner-engine/src/test/java/org/sonar/scanner/bootstrap/BatchWsClientProviderTest.java +++ b/sonar-scanner-engine/src/test/java/org/sonar/scanner/bootstrap/BatchWsClientProviderTest.java @@ -23,21 +23,18 @@ import java.util.HashMap; import java.util.Map; import org.junit.Test; import org.sonar.batch.bootstrapper.EnvironmentInformation; -import org.sonar.scanner.bootstrap.BatchWsClient; -import org.sonar.scanner.bootstrap.BatchWsClientProvider; -import org.sonar.scanner.bootstrap.GlobalProperties; import org.sonarqube.ws.client.HttpConnector; import static org.assertj.core.api.Assertions.assertThat; public class BatchWsClientProviderTest { - BatchWsClientProvider underTest = new BatchWsClientProvider(); - EnvironmentInformation env = new EnvironmentInformation("Maven Plugin", "2.3"); + private BatchWsClientProvider underTest = new BatchWsClientProvider(); + private EnvironmentInformation env = new EnvironmentInformation("Maven Plugin", "2.3"); @Test public void provide_client_with_default_settings() { - GlobalProperties settings = new GlobalProperties(new HashMap()); + GlobalProperties settings = new GlobalProperties(new HashMap<>()); BatchWsClient client = underTest.provide(settings, env); @@ -48,7 +45,6 @@ public class BatchWsClientProviderTest { assertThat(httpConnector.okHttpClient().proxy()).isNull(); assertThat(httpConnector.okHttpClient().connectTimeoutMillis()).isEqualTo(5_000); assertThat(httpConnector.okHttpClient().readTimeoutMillis()).isEqualTo(60_000); - assertThat(httpConnector.userAgent()).isEqualTo("Maven Plugin/2.3"); } @Test @@ -66,12 +62,11 @@ public class BatchWsClientProviderTest { HttpConnector httpConnector = (HttpConnector) client.wsConnector(); assertThat(httpConnector.baseUrl()).isEqualTo("https://here/sonarqube/"); assertThat(httpConnector.okHttpClient().proxy()).isNull(); - assertThat(httpConnector.userAgent()).isEqualTo("Maven Plugin/2.3"); } @Test public void build_singleton() { - GlobalProperties settings = new GlobalProperties(new HashMap()); + GlobalProperties settings = new GlobalProperties(new HashMap<>()); BatchWsClient first = underTest.provide(settings, env); BatchWsClient second = underTest.provide(settings, env); assertThat(first).isSameAs(second); diff --git a/sonar-ws/src/main/java/org/sonarqube/ws/client/HttpConnector.java b/sonar-ws/src/main/java/org/sonarqube/ws/client/HttpConnector.java index 3091c40faf8..47e756defc3 100644 --- a/sonar-ws/src/main/java/org/sonarqube/ws/client/HttpConnector.java +++ b/sonar-ws/src/main/java/org/sonarqube/ws/client/HttpConnector.java @@ -19,25 +19,11 @@ */ package org.sonarqube.ws.client; -import java.io.FileInputStream; import java.io.IOException; import java.net.Proxy; -import java.security.GeneralSecurityException; -import java.security.KeyStore; -import java.util.Arrays; import java.util.Map; -import java.util.concurrent.TimeUnit; -import javax.annotation.CheckForNull; import javax.annotation.Nullable; -import javax.net.ssl.KeyManager; -import javax.net.ssl.KeyManagerFactory; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; -import javax.net.ssl.X509TrustManager; import okhttp3.Call; -import okhttp3.ConnectionSpec; import okhttp3.Credentials; import okhttp3.Headers; import okhttp3.HttpUrl; @@ -52,7 +38,6 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Strings.isNullOrEmpty; import static com.google.common.base.Strings.nullToEmpty; import static java.lang.String.format; -import static java.util.Arrays.asList; /** * Connect to any SonarQube server available through HTTP or HTTPS. @@ -63,23 +48,21 @@ public class HttpConnector implements WsConnector { public static final int DEFAULT_CONNECT_TIMEOUT_MILLISECONDS = 30_000; public static final int DEFAULT_READ_TIMEOUT_MILLISECONDS = 60_000; - private static final String NONE = "NONE"; - private static final String P11KEYSTORE = "PKCS11"; /** * Base URL with trailing slash, for instance "https://localhost/sonarqube/". * It is required for further usage of {@link HttpUrl#resolve(String)}. */ private final HttpUrl baseUrl; - private final String userAgent; private final String credentials; - private final String proxyCredentials; private final OkHttpClient okHttpClient; private HttpConnector(Builder builder) { this.baseUrl = HttpUrl.parse(builder.url.endsWith("/") ? builder.url : format("%s/", builder.url)); checkArgument(this.baseUrl != null, "Malformed URL: '%s'", builder.url); - this.userAgent = builder.userAgent; + + OkHttpClientBuilder okHttpClientBuilder = new OkHttpClientBuilder(); + okHttpClientBuilder.setUserAgent(builder.userAgent); if (isNullOrEmpty(builder.login)) { // no login nor access token @@ -89,129 +72,12 @@ public class HttpConnector implements WsConnector { // the Basic credentials consider an empty password. this.credentials = Credentials.basic(builder.login, nullToEmpty(builder.password)); } - // proxy credentials can be used on system-wide proxies, so even if builder.proxy is null - if (isNullOrEmpty(builder.proxyLogin)) { - this.proxyCredentials = null; - } else { - this.proxyCredentials = Credentials.basic(builder.proxyLogin, nullToEmpty(builder.proxyPassword)); - } - this.okHttpClient = buildClient(builder); - } - - private static OkHttpClient buildClient(Builder builder) { - OkHttpClient.Builder okHttpClientBuilder = new OkHttpClient.Builder(); - if (builder.proxy != null) { - okHttpClientBuilder.proxy(builder.proxy); - } - - okHttpClientBuilder.connectTimeout(builder.connectTimeoutMs, TimeUnit.MILLISECONDS); - okHttpClientBuilder.readTimeout(builder.readTimeoutMs, TimeUnit.MILLISECONDS); - - ConnectionSpec tls = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) - .allEnabledTlsVersions() - .allEnabledCipherSuites() - .supportsTlsExtensions(true) - .build(); - okHttpClientBuilder.connectionSpecs(asList(tls, ConnectionSpec.CLEARTEXT)); - X509TrustManager systemDefaultTrustManager = systemDefaultTrustManager(); - okHttpClientBuilder.sslSocketFactory(systemDefaultSslSocketFactory(systemDefaultTrustManager), systemDefaultTrustManager); - - return okHttpClientBuilder.build(); - } - - private static X509TrustManager systemDefaultTrustManager() { - try { - TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - trustManagerFactory.init((KeyStore) null); - TrustManager[] trustManagers = trustManagerFactory.getTrustManagers(); - if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) { - throw new IllegalStateException("Unexpected default trust managers:" + Arrays.toString(trustManagers)); - } - return (X509TrustManager) trustManagers[0]; - } catch (GeneralSecurityException e) { - // The system has no TLS. Just give up. - throw new AssertionError(e); - } - } - - private static SSLSocketFactory systemDefaultSslSocketFactory(X509TrustManager trustManager) { - KeyManager[] defaultKeyManager; - try { - defaultKeyManager = getDefaultKeyManager(); - } catch (Exception e) { - throw new IllegalStateException("Unable to get default key manager", e); - } - try { - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(defaultKeyManager, new TrustManager[] {trustManager}, null); - return sslContext.getSocketFactory(); - } catch (GeneralSecurityException e) { - // The system has no TLS. Just give up. - throw new AssertionError(e); - } - } - - private static void logDebug(String msg) { - boolean debugEnabled = "all".equals(System.getProperty("javax.net.debug")); - if (debugEnabled) { - System.out.println(msg); - } - } - - /** - * Inspired from sun.security.ssl.SSLContextImpl#getDefaultKeyManager() - */ - private static synchronized KeyManager[] getDefaultKeyManager() throws Exception { - - final String defaultKeyStore = System.getProperty("javax.net.ssl.keyStore", ""); - String defaultKeyStoreType = System.getProperty("javax.net.ssl.keyStoreType", KeyStore.getDefaultType()); - String defaultKeyStoreProvider = System.getProperty("javax.net.ssl.keyStoreProvider", ""); - - logDebug("keyStore is : " + defaultKeyStore); - logDebug("keyStore type is : " + defaultKeyStoreType); - logDebug("keyStore provider is : " + defaultKeyStoreProvider); - - if (P11KEYSTORE.equals(defaultKeyStoreType) && !NONE.equals(defaultKeyStore)) { - throw new IllegalArgumentException("if keyStoreType is " + P11KEYSTORE + ", then keyStore must be " + NONE); - } - - KeyStore ks = null; - String defaultKeyStorePassword = System.getProperty("javax.net.ssl.keyStorePassword", ""); - char[] passwd = defaultKeyStorePassword.isEmpty() ? null : defaultKeyStorePassword.toCharArray(); - - /** - * Try to initialize key store. - */ - if (!defaultKeyStoreType.isEmpty()) { - logDebug("init keystore"); - if (defaultKeyStoreProvider.isEmpty()) { - ks = KeyStore.getInstance(defaultKeyStoreType); - } else { - ks = KeyStore.getInstance(defaultKeyStoreType, defaultKeyStoreProvider); - } - if (!defaultKeyStore.isEmpty() && !NONE.equals(defaultKeyStore)) { - try (FileInputStream fs = new FileInputStream(defaultKeyStore)) { - ks.load(fs, passwd); - } - } else { - ks.load(null, passwd); - } - } - - /* - * Try to initialize key manager. - */ - logDebug("init keymanager of type " + KeyManagerFactory.getDefaultAlgorithm()); - KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - - if (P11KEYSTORE.equals(defaultKeyStoreType)) { - // do not pass key passwd if using token - kmf.init(ks, null); - } else { - kmf.init(ks, passwd); - } - - return kmf.getKeyManagers(); + okHttpClientBuilder.setProxy(builder.proxy); + okHttpClientBuilder.setProxyLogin(builder.proxyLogin); + okHttpClientBuilder.setProxyPassword(builder.proxyPassword); + okHttpClientBuilder.setConnectTimeoutMs(builder.connectTimeoutMs); + okHttpClientBuilder.setReadTimeoutMs(builder.readTimeoutMs); + this.okHttpClient = okHttpClientBuilder.build(); } @Override @@ -219,11 +85,6 @@ public class HttpConnector implements WsConnector { return baseUrl.url().toExternalForm(); } - @CheckForNull - public String userAgent() { - return userAgent; - } - public OkHttpClient okHttpClient() { return okHttpClient; } @@ -287,12 +148,6 @@ public class HttpConnector implements WsConnector { if (credentials != null) { okHttpRequestBuilder.header("Authorization", credentials); } - if (proxyCredentials != null) { - okHttpRequestBuilder.header("Proxy-Authorization", proxyCredentials); - } - if (userAgent != null) { - okHttpRequestBuilder.addHeader("User-Agent", userAgent); - } return okHttpRequestBuilder; } diff --git a/sonar-ws/src/main/java/org/sonarqube/ws/client/OkHttpClientBuilder.java b/sonar-ws/src/main/java/org/sonarqube/ws/client/OkHttpClientBuilder.java new file mode 100644 index 00000000000..961d9e0da62 --- /dev/null +++ b/sonar-ws/src/main/java/org/sonarqube/ws/client/OkHttpClientBuilder.java @@ -0,0 +1,248 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonarqube.ws.client; + +import java.io.FileInputStream; +import java.io.IOException; +import java.net.Proxy; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nullable; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import okhttp3.ConnectionSpec; +import okhttp3.Credentials; +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +import static com.google.common.base.Strings.nullToEmpty; +import static java.util.Arrays.asList; + +/** + * Helper to build an instance of {@link okhttp3.OkHttpClient} that + * correctly supports HTTPS and proxy authentication. It also handles + * sending of User-Agent header. + */ +public class OkHttpClientBuilder { + + private static final String NONE = "NONE"; + private static final String P11KEYSTORE = "PKCS11"; + + private String userAgent; + private Proxy proxy; + private String proxyLogin; + private String proxyPassword; + private long connectTimeoutMs = -1; + private long readTimeoutMs = -1; + + /** + * Optional User-Agent. If set, then all the requests sent by the + * {@link OkHttpClient} will include the header "User-Agent". + */ + public OkHttpClientBuilder setUserAgent(@Nullable String s) { + this.userAgent = s; + return this; + } + + /** + * Optional proxy. If set, then all the requests sent by the + * {@link OkHttpClient} will reach the proxy. If not set, + * then the system-wide proxy is used. + */ + public OkHttpClientBuilder setProxy(@Nullable Proxy proxy) { + this.proxy = proxy; + return this; + } + + /** + * Login required for proxy authentication. + */ + public OkHttpClientBuilder setProxyLogin(@Nullable String s) { + this.proxyLogin = s; + return this; + } + + /** + * Password used for proxy authentication. It is ignored if + * proxy login is not defined (see {@link #setProxyLogin(String)}). + * It can be null or empty when login is defined. + */ + public OkHttpClientBuilder setProxyPassword(@Nullable String s) { + this.proxyPassword = s; + return this; + } + + /** + * Sets the default connect timeout for new connections. A value of 0 means no timeout. + * Default is defined by OkHttp (10 seconds in OkHttp 3.3). + */ + public OkHttpClientBuilder setConnectTimeoutMs(long l) { + if (l < 0) { + throw new IllegalArgumentException("Connect timeout must be positive. Got " + l); + } + this.connectTimeoutMs = l; + return this; + } + + /** + * Sets the default read timeout for new connections. A value of 0 means no timeout. + * Default is defined by OkHttp (10 seconds in OkHttp 3.3). + */ + public OkHttpClientBuilder setReadTimeoutMs(long l) { + if (l < 0) { + throw new IllegalArgumentException("Read timeout must be positive. Got " + l); + } + this.readTimeoutMs = l; + return this; + } + + public OkHttpClient build() { + OkHttpClient.Builder builder = new OkHttpClient.Builder(); + builder.proxy(proxy); + if (connectTimeoutMs >= 0) { + builder.connectTimeout(connectTimeoutMs, TimeUnit.MILLISECONDS); + } + if (readTimeoutMs >= 0) { + builder.readTimeout(readTimeoutMs, TimeUnit.MILLISECONDS); + } + builder.addInterceptor(this::completeHeaders); + + ConnectionSpec tls = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) + .allEnabledTlsVersions() + .allEnabledCipherSuites() + .supportsTlsExtensions(true) + .build(); + builder.connectionSpecs(asList(tls, ConnectionSpec.CLEARTEXT)); + X509TrustManager systemDefaultTrustManager = systemDefaultTrustManager(); + builder.sslSocketFactory(systemDefaultSslSocketFactory(systemDefaultTrustManager), systemDefaultTrustManager); + + return builder.build(); + } + + private Response completeHeaders(Interceptor.Chain chain) throws IOException { + Request.Builder newRequest = chain.request().newBuilder(); + if (userAgent != null) { + newRequest.header("User-Agent", userAgent); + } + if (proxyLogin != null) { + newRequest.header("Proxy-Authorization", Credentials.basic(proxyLogin, nullToEmpty(proxyPassword))); + } + return chain.proceed(newRequest.build()); + } + + private static X509TrustManager systemDefaultTrustManager() { + try { + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init((KeyStore) null); + TrustManager[] trustManagers = trustManagerFactory.getTrustManagers(); + if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) { + throw new IllegalStateException("Unexpected default trust managers:" + Arrays.toString(trustManagers)); + } + return (X509TrustManager) trustManagers[0]; + } catch (GeneralSecurityException e) { + // The system has no TLS. Just give up. + throw new AssertionError(e); + } + } + + private static SSLSocketFactory systemDefaultSslSocketFactory(X509TrustManager trustManager) { + KeyManager[] defaultKeyManager; + try { + defaultKeyManager = getDefaultKeyManager(); + } catch (Exception e) { + throw new IllegalStateException("Unable to get default key manager", e); + } + try { + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(defaultKeyManager, new TrustManager[] {trustManager}, null); + return sslContext.getSocketFactory(); + } catch (GeneralSecurityException e) { + // The system has no TLS. Just give up. + throw new AssertionError(e); + } + } + + private static void logDebug(String msg) { + boolean debugEnabled = "all".equals(System.getProperty("javax.net.debug")); + if (debugEnabled) { + System.out.println(msg); + } + } + + /** + * Inspired from sun.security.ssl.SSLContextImpl#getDefaultKeyManager() + */ + private static synchronized KeyManager[] getDefaultKeyManager() throws Exception { + final String defaultKeyStore = System.getProperty("javax.net.ssl.keyStore", ""); + String defaultKeyStoreType = System.getProperty("javax.net.ssl.keyStoreType", KeyStore.getDefaultType()); + String defaultKeyStoreProvider = System.getProperty("javax.net.ssl.keyStoreProvider", ""); + + logDebug("keyStore is : " + defaultKeyStore); + logDebug("keyStore type is : " + defaultKeyStoreType); + logDebug("keyStore provider is : " + defaultKeyStoreProvider); + + if (P11KEYSTORE.equals(defaultKeyStoreType) && !NONE.equals(defaultKeyStore)) { + throw new IllegalArgumentException("if keyStoreType is " + P11KEYSTORE + ", then keyStore must be " + NONE); + } + + KeyStore ks = null; + String defaultKeyStorePassword = System.getProperty("javax.net.ssl.keyStorePassword", ""); + char[] passwd = defaultKeyStorePassword.isEmpty() ? null : defaultKeyStorePassword.toCharArray(); + + // Try to initialize key store. + if (!defaultKeyStoreType.isEmpty()) { + logDebug("init keystore"); + if (defaultKeyStoreProvider.isEmpty()) { + ks = KeyStore.getInstance(defaultKeyStoreType); + } else { + ks = KeyStore.getInstance(defaultKeyStoreType, defaultKeyStoreProvider); + } + if (!defaultKeyStore.isEmpty() && !NONE.equals(defaultKeyStore)) { + try (FileInputStream fs = new FileInputStream(defaultKeyStore)) { + ks.load(fs, passwd); + } + } else { + ks.load(null, passwd); + } + } + + // Try to initialize key manager. + logDebug("init keymanager of type " + KeyManagerFactory.getDefaultAlgorithm()); + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + + if (P11KEYSTORE.equals(defaultKeyStoreType)) { + // do not pass key passwd if using token + kmf.init(ks, null); + } else { + kmf.init(ks, passwd); + } + + return kmf.getKeyManagers(); + } +} diff --git a/sonar-ws/src/test/java/org/sonarqube/ws/client/HttpConnectorTest.java b/sonar-ws/src/test/java/org/sonarqube/ws/client/HttpConnectorTest.java index 527fcc5bf27..7cc937b6233 100644 --- a/sonar-ws/src/test/java/org/sonarqube/ws/client/HttpConnectorTest.java +++ b/sonar-ws/src/test/java/org/sonarqube/ws/client/HttpConnectorTest.java @@ -49,10 +49,9 @@ public class HttpConnectorTest { @Rule public ExpectedException expectedException = ExpectedException.none(); - MockWebServer server; - String serverUrl; - - HttpConnector underTest; + private MockWebServer server; + private String serverUrl; + private HttpConnector underTest; @Before public void setUp() throws Exception { diff --git a/sonar-ws/src/test/java/org/sonarqube/ws/client/OkHttpClientBuilderTest.java b/sonar-ws/src/test/java/org/sonarqube/ws/client/OkHttpClientBuilderTest.java new file mode 100644 index 00000000000..a96e7a35888 --- /dev/null +++ b/sonar-ws/src/test/java/org/sonarqube/ws/client/OkHttpClientBuilderTest.java @@ -0,0 +1,60 @@ +/* + * SonarQube + * Copyright (C) 2009-2016 SonarSource SA + * mailto:contact AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonarqube.ws.client; + +import okhttp3.OkHttpClient; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import static org.assertj.core.api.Assertions.assertThat; + +public class OkHttpClientBuilderTest { + + @Rule + public ExpectedException expectedException = ExpectedException.none(); + + private OkHttpClientBuilder underTest = new OkHttpClientBuilder(); + + @Test + public void build_default_instance_of_OkHttpClient() { + OkHttpClient okHttpClient = underTest.build(); + + assertThat(okHttpClient.proxy()).isNull(); + assertThat(okHttpClient.interceptors()).hasSize(1); + assertThat(okHttpClient.sslSocketFactory()).isNotNull(); + } + + @Test + public void build_throws_IAE_if_connect_timeout_is_negative() { + expectedException.expect(IllegalArgumentException.class); + expectedException.expectMessage("Connect timeout must be positive. Got -10"); + + underTest.setConnectTimeoutMs(-10); + } + + @Test + public void build_throws_IAE_if_read_timeout_is_negative() { + expectedException.expect(IllegalArgumentException.class); + expectedException.expectMessage("Read timeout must be positive. Got -10"); + + underTest.setReadTimeoutMs(-10); + } +} -- 2.39.5