diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java index 7b3e39c9d4..ca08839e13 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerApi.java @@ -44,6 +44,7 @@ import org.springframework.util.StringUtils; * Provides access to the limited set of Docker APIs needed by pack. * * @author Phillip Webb + * @author Scott Frederick * @since 2.3.0 */ public class DockerApi { @@ -96,7 +97,7 @@ public class DockerApi { private URI buildUrl(String path, String... params) { try { - URIBuilder builder = new URIBuilder("docker://localhost/" + API_VERSION + path); + URIBuilder builder = new URIBuilder("/" + API_VERSION + path); int param = 0; while (param < params.length) { builder.addParameter(params[param++], params[param++]); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerException.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerException.java index 2d41a5611e..8388282eea 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerException.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerException.java @@ -24,6 +24,7 @@ import org.springframework.util.Assert; * Exception throw when the Docker API fails. * * @author Phillip Webb + * @author Scott Frederick * @since 2.3.0 */ public class DockerException extends RuntimeException { @@ -34,8 +35,8 @@ public class DockerException extends RuntimeException { private final Errors errors; - DockerException(URI uri, int statusCode, String reasonPhrase, Errors errors) { - super(buildMessage(uri, statusCode, reasonPhrase, errors)); + DockerException(String host, URI uri, int statusCode, String reasonPhrase, Errors errors) { + super(buildMessage(host, uri, statusCode, reasonPhrase, errors)); this.statusCode = statusCode; this.reasonPhrase = reasonPhrase; this.errors = errors; @@ -66,10 +67,11 @@ public class DockerException extends RuntimeException { return this.errors; } - private static String buildMessage(URI uri, int statusCode, String reasonPhrase, Errors errors) { + private static String buildMessage(String host, URI uri, int statusCode, String reasonPhrase, Errors errors) { + Assert.notNull(host, "host must not be null"); Assert.notNull(uri, "URI must not be null"); StringBuilder message = new StringBuilder( - "Docker API call to '" + uri + "' failed with status code " + statusCode); + "Docker API call to '" + host + uri + "' failed with status code " + statusCode); if (reasonPhrase != null && !reasonPhrase.isEmpty()) { message.append(" \"" + reasonPhrase + "\""); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/HttpClientHttp.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/HttpClientHttp.java index 3942f60805..843a6a4175 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/HttpClientHttp.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/HttpClientHttp.java @@ -23,6 +23,7 @@ import java.net.URI; import org.apache.http.HttpEntity; import org.apache.http.HttpHeaders; +import org.apache.http.HttpHost; import org.apache.http.StatusLine; import org.apache.http.client.HttpClient; import org.apache.http.client.methods.CloseableHttpResponse; @@ -34,9 +35,9 @@ import org.apache.http.client.methods.HttpPut; import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.entity.AbstractHttpEntity; import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClientBuilder; -import org.apache.http.impl.client.HttpClients; +import org.springframework.boot.buildpack.platform.docker.httpclient.DelegatingDockerHttpClientConnection; +import org.springframework.boot.buildpack.platform.docker.httpclient.DockerHttpClientConnection; import org.springframework.boot.buildpack.platform.io.Content; import org.springframework.boot.buildpack.platform.io.IOConsumer; import org.springframework.boot.buildpack.platform.json.SharedObjectMapper; @@ -50,17 +51,14 @@ import org.springframework.boot.buildpack.platform.json.SharedObjectMapper; */ class HttpClientHttp implements Http { - private final CloseableHttpClient client; + private final DockerHttpClientConnection clientConnection; HttpClientHttp() { - HttpClientBuilder builder = HttpClients.custom(); - builder.setConnectionManager(new DockerHttpClientConnectionManager()); - builder.setSchemePortResolver(new DockerSchemePortResolver()); - this.client = builder.build(); + this.clientConnection = DelegatingDockerHttpClientConnection.create(); } - HttpClientHttp(CloseableHttpClient client) { - this.client = client; + HttpClientHttp(DockerHttpClientConnection clientConnection) { + this.clientConnection = clientConnection; } /** @@ -90,7 +88,6 @@ class HttpClientHttp implements Http { * @param writer a content writer * @return the operation response */ - @Override public Response post(URI uri, String contentType, IOConsumer writer) { return execute(new HttpPost(uri), contentType, writer); @@ -103,7 +100,6 @@ class HttpClientHttp implements Http { * @param writer a content writer * @return the operation response */ - @Override public Response put(URI uri, String contentType, IOConsumer writer) { return execute(new HttpPut(uri), contentType, writer); @@ -114,7 +110,6 @@ class HttpClientHttp implements Http { * @param uri the destination URI * @return the operation response */ - @Override public Response delete(URI uri) { return execute(new HttpDelete(uri)); @@ -128,23 +123,36 @@ class HttpClientHttp implements Http { } private Response execute(HttpUriRequest request) { + HttpHost host = this.clientConnection.getHttpHost(); + CloseableHttpClient client = this.clientConnection.getHttpClient(); + try { - CloseableHttpResponse response = this.client.execute(request); + CloseableHttpResponse response = client.execute(host, request); StatusLine statusLine = response.getStatusLine(); int statusCode = statusLine.getStatusCode(); HttpEntity entity = response.getEntity(); if (statusCode >= 400 && statusCode < 500) { - Errors errors = SharedObjectMapper.get().readValue(entity.getContent(), Errors.class); - throw new DockerException(request.getURI(), statusCode, statusLine.getReasonPhrase(), errors); + throw new DockerException(host.toHostString(), request.getURI(), statusCode, + statusLine.getReasonPhrase(), getErrorsFromResponse(entity)); } if (statusCode == 500) { - throw new DockerException(request.getURI(), statusCode, statusLine.getReasonPhrase(), null); + throw new DockerException(host.toHostString(), request.getURI(), statusCode, + statusLine.getReasonPhrase(), null); } return new HttpClientResponse(response); } catch (IOException ioe) { - throw new DockerException(request.getURI(), 500, ioe.getMessage(), null); + throw new DockerException(host.toHostString(), request.getURI(), 500, ioe.getMessage(), null); + } + } + + private Errors getErrorsFromResponse(HttpEntity entity) { + try { + return SharedObjectMapper.get().readValue(entity.getContent(), Errors.class); + } + catch (IOException ioe) { + return null; } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/DelegatingDockerHttpClientConnection.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/DelegatingDockerHttpClientConnection.java new file mode 100644 index 0000000000..228a90a395 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/DelegatingDockerHttpClientConnection.java @@ -0,0 +1,74 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.httpclient; + +import org.apache.http.HttpHost; +import org.apache.http.client.HttpClient; +import org.apache.http.impl.client.CloseableHttpClient; + +/** + * A {@code DockerHttpClientConnection} that determines an appropriate connection to a + * Docker host by detecting whether a remote Docker host is configured or if a default + * local connection should be used. + * + * @author Scott Frederick + * @since 2.3.0 + */ +public final class DelegatingDockerHttpClientConnection implements DockerHttpClientConnection { + + private static final RemoteEnvironmentDockerHttpClientConnection REMOTE_FACTORY = new RemoteEnvironmentDockerHttpClientConnection(); + + private static final LocalDockerHttpClientConnection LOCAL_FACTORY = new LocalDockerHttpClientConnection(); + + private final DockerHttpClientConnection delegate; + + private DelegatingDockerHttpClientConnection(DockerHttpClientConnection delegate) { + this.delegate = delegate; + } + + /** + * Get an {@link HttpHost} describing the Docker host connection. + * @return the {@code HttpHost} + */ + public HttpHost getHttpHost() { + return this.delegate.getHttpHost(); + } + + /** + * Get an {@link HttpClient} that can be used to communicate with the Docker host. + * @return the {@code HttpClient} + */ + public CloseableHttpClient getHttpClient() { + return this.delegate.getHttpClient(); + } + + /** + * Create a {@link DockerHttpClientConnection} by detecting the connection + * configuration. + * @return the {@code DockerHttpClientConnection} + */ + public static DockerHttpClientConnection create() { + if (REMOTE_FACTORY.accept()) { + return new DelegatingDockerHttpClientConnection(REMOTE_FACTORY); + } + if (LOCAL_FACTORY.accept()) { + return new DelegatingDockerHttpClientConnection(LOCAL_FACTORY); + } + return null; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/DockerHttpClientConnection.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/DockerHttpClientConnection.java new file mode 100644 index 0000000000..8f4bf30c90 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/DockerHttpClientConnection.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.httpclient; + +import org.apache.http.HttpHost; +import org.apache.http.client.HttpClient; +import org.apache.http.impl.client.CloseableHttpClient; + +/** + * Describes a connection to a Docker host. + * + * @author Scott Frederick + * @since 2.3.0 + */ +public interface DockerHttpClientConnection { + + /** + * Create an {@link HttpHost} describing the Docker host connection. + * @return the {@code HttpHost} + */ + HttpHost getHttpHost(); + + /** + * Create an {@link HttpClient} that can be used to communicate with the Docker host. + * @return the {@code HttpClient} + */ + CloseableHttpClient getHttpClient(); + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerConnectionSocketFactory.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/LocalDockerConnectionSocketFactory.java similarity index 90% rename from spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerConnectionSocketFactory.java rename to spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/LocalDockerConnectionSocketFactory.java index 66e6c6a4d0..377bff90fb 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerConnectionSocketFactory.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/LocalDockerConnectionSocketFactory.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.buildpack.platform.docker; +package org.springframework.boot.buildpack.platform.docker.httpclient; import java.io.IOException; import java.net.InetSocketAddress; @@ -33,8 +33,9 @@ import org.springframework.boot.buildpack.platform.socket.NamedPipeSocket; * pipe. * * @author Phillip Webb + * @author Scott Frederick */ -class DockerConnectionSocketFactory implements ConnectionSocketFactory { +class LocalDockerConnectionSocketFactory implements ConnectionSocketFactory { private static final String DOMAIN_SOCKET_PATH = "/var/run/docker.sock"; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerDnsResolver.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/LocalDockerDnsResolver.java similarity index 77% rename from spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerDnsResolver.java rename to spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/LocalDockerDnsResolver.java index 8a0421e8c2..c2b7ad23a6 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerDnsResolver.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/LocalDockerDnsResolver.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.buildpack.platform.docker; +package org.springframework.boot.buildpack.platform.docker.httpclient; import java.net.InetAddress; import java.net.UnknownHostException; @@ -22,12 +22,13 @@ import java.net.UnknownHostException; import org.apache.http.conn.DnsResolver; /** - * {@link DnsResolver} used by the {@link DockerHttpClientConnectionManager} to ensure - * only the loopback address is used. + * {@link DnsResolver} used by the {@link LocalDockerHttpClientConnectionManager} to + * ensure only the loopback address is used. * * @author Phillip Webb + * @author Scott Frederick */ -class DockerDnsResolver implements DnsResolver { +class LocalDockerDnsResolver implements DnsResolver { private static final InetAddress[] LOOPBACK = new InetAddress[] { InetAddress.getLoopbackAddress() }; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/LocalDockerHttpClientConnection.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/LocalDockerHttpClientConnection.java new file mode 100644 index 0000000000..1e02c9e0ad --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/LocalDockerHttpClientConnection.java @@ -0,0 +1,75 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.httpclient; + +import org.apache.http.HttpHost; +import org.apache.http.client.HttpClient; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.impl.client.HttpClients; + +import org.springframework.util.Assert; + +/** + * A {@link DockerHttpClientConnection} that describes a connection to a local Docker + * host. + * + * @author Scott Frederick + * @since 2.3.0 + */ +public class LocalDockerHttpClientConnection implements DockerHttpClientConnection { + + private HttpHost httpHost; + + private CloseableHttpClient httpClient; + + /** + * Indicate that this factory can be used as a default. + * @return {@code true} always + */ + public boolean accept() { + this.httpHost = HttpHost.create("docker://localhost"); + + HttpClientBuilder builder = HttpClients.custom(); + builder.setConnectionManager(new LocalDockerHttpClientConnectionManager()); + builder.setSchemePortResolver(new LocalDockerSchemePortResolver()); + this.httpClient = builder.build(); + + return true; + } + + /** + * Get an {@link HttpHost} describing a local Docker host connection. + * @return the {@code HttpHost} + */ + @Override + public HttpHost getHttpHost() { + Assert.state(this.httpHost != null, "DockerHttpClientConnection was not properly initialized"); + return this.httpHost; + } + + /** + * Get an {@link HttpClient} that can be used to communicate with a local Docker host. + * @return the {@code HttpClient} + */ + @Override + public CloseableHttpClient getHttpClient() { + Assert.state(this.httpClient != null, "DockerHttpClientConnection was not properly initialized"); + return this.httpClient; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerHttpClientConnectionManager.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/LocalDockerHttpClientConnectionManager.java similarity index 75% rename from spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerHttpClientConnectionManager.java rename to spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/LocalDockerHttpClientConnectionManager.java index 25d66996f5..693c0e1353 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerHttpClientConnectionManager.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/LocalDockerHttpClientConnectionManager.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.buildpack.platform.docker; +package org.springframework.boot.buildpack.platform.docker.httpclient; import org.apache.http.config.Registry; import org.apache.http.config.RegistryBuilder; @@ -26,16 +26,17 @@ import org.apache.http.impl.conn.BasicHttpClientConnectionManager; * {@link HttpClientConnectionManager} for Docker. * * @author Phillip Webb + * @author Scott Frederick */ -class DockerHttpClientConnectionManager extends BasicHttpClientConnectionManager { +class LocalDockerHttpClientConnectionManager extends BasicHttpClientConnectionManager { - DockerHttpClientConnectionManager() { - super(getRegistry(), null, null, new DockerDnsResolver()); + LocalDockerHttpClientConnectionManager() { + super(getRegistry(), null, null, new LocalDockerDnsResolver()); } private static Registry getRegistry() { RegistryBuilder builder = RegistryBuilder.create(); - builder.register("docker", new DockerConnectionSocketFactory()); + builder.register("docker", new LocalDockerConnectionSocketFactory()); return builder.build(); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerSchemePortResolver.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/LocalDockerSchemePortResolver.java similarity index 88% rename from spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerSchemePortResolver.java rename to spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/LocalDockerSchemePortResolver.java index 6a32c93ddf..526a6c330c 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/DockerSchemePortResolver.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/LocalDockerSchemePortResolver.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.buildpack.platform.docker; +package org.springframework.boot.buildpack.platform.docker.httpclient; import org.apache.http.HttpHost; import org.apache.http.conn.SchemePortResolver; @@ -25,8 +25,9 @@ import org.apache.http.util.Args; * {@link SchemePortResolver} for Docker. * * @author Phillip Webb + * @author Scott Frederick */ -class DockerSchemePortResolver implements SchemePortResolver { +class LocalDockerSchemePortResolver implements SchemePortResolver { private static final int DEFAULT_DOCKER_PORT = 2376; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/RemoteEnvironmentDockerHttpClientConnection.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/RemoteEnvironmentDockerHttpClientConnection.java new file mode 100644 index 0000000000..004e0e32ee --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/httpclient/RemoteEnvironmentDockerHttpClientConnection.java @@ -0,0 +1,167 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.httpclient; + +import javax.net.ssl.SSLContext; + +import org.apache.http.HttpHost; +import org.apache.http.client.HttpClient; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.impl.client.HttpClients; + +import org.springframework.boot.buildpack.platform.docker.ssl.SslContextFactory; +import org.springframework.util.Assert; + +/** + * A {@link DockerHttpClientConnection} that describes a connection to a remote Docker + * host specified by environment variables. + * + * This implementation looks for the following environment variables: + * + *

+ *

    + *
  • {@code DOCKER_HOST} - the URL to a Docker daemon host, such as + * {@code tcp://localhost:2376}
  • + *
  • {@code DOCKER_TLS_VERIFY} - set to {@code 1} to enable secure connection to the + * Docker host via TLS (optional)
  • + *
  • {@code DOCKER_CERT_PATH} - the path to certificate and key files needed for TLS + * verification (required if {@code DOCKER_TLS_VERIFY=1})
  • + *
+ * + * @author Scott Frederick + * @since 2.3.0 + */ +public class RemoteEnvironmentDockerHttpClientConnection implements DockerHttpClientConnection { + + private static final String DOCKER_HOST_KEY = "DOCKER_HOST"; + + private static final String DOCKER_TLS_VERIFY_KEY = "DOCKER_TLS_VERIFY"; + + private static final String DOCKER_CERT_PATH_KEY = "DOCKER_CERT_PATH"; + + private final EnvironmentAccessor environment; + + private final SslContextFactory sslContextFactory; + + private HttpHost httpHost; + + private CloseableHttpClient httpClient; + + RemoteEnvironmentDockerHttpClientConnection() { + this.environment = new SystemEnvironmentAccessor(); + this.sslContextFactory = new SslContextFactory(); + } + + RemoteEnvironmentDockerHttpClientConnection(EnvironmentAccessor environmentAccessor, + SslContextFactory sslContextFactory) { + this.environment = environmentAccessor; + this.sslContextFactory = sslContextFactory; + } + + /** + * Indicate whether this factory can create be used to create a connection. + * @return {@code true} if the environment variable {@code DOCKER_HOST} is set, + * {@code false} otherwise + */ + public boolean accept() { + if (this.environment.getProperty("DOCKER_HOST") != null) { + initHttpHost(); + initHttpClient(); + return true; + } + return false; + } + + /** + * Get an {@link HttpHost} from the Docker host specified in the environment. + * @return the {@code HttpHost} + */ + @Override + public HttpHost getHttpHost() { + Assert.state(this.httpHost != null, "DockerHttpClientConnection was not properly initialized"); + return this.httpHost; + } + + /** + * Get an {@link HttpClient} from the Docker connection information specified in the + * environment. + * @return the {@code HttpClient} + */ + @Override + public CloseableHttpClient getHttpClient() { + Assert.state(this.httpClient != null, "DockerHttpClientConnection was not properly initialized"); + return this.httpClient; + } + + private void initHttpHost() { + String dockerHost = this.environment.getProperty(DOCKER_HOST_KEY); + Assert.hasText(dockerHost, "DOCKER_HOST must be set"); + + this.httpHost = HttpHost.create(dockerHost); + if ("tcp".equals(this.httpHost.getSchemeName())) { + String scheme = (isSecure()) ? "https" : "http"; + this.httpHost = new HttpHost(this.httpHost.getHostName(), this.httpHost.getPort(), scheme); + } + } + + private void initHttpClient() { + HttpClientBuilder builder = HttpClients.custom(); + + if (isSecure()) { + String certPath = this.environment.getProperty(DOCKER_CERT_PATH_KEY); + Assert.hasText(certPath, DOCKER_TLS_VERIFY_KEY + " requires trust material location to be specified with " + + DOCKER_CERT_PATH_KEY); + + SSLContext sslContext = this.sslContextFactory.forPath(certPath); + SSLConnectionSocketFactory sslSocketFactory = new SSLConnectionSocketFactory(sslContext); + + builder.setSSLSocketFactory(sslSocketFactory).setSSLContext(sslContext); + } + + this.httpClient = builder.build(); + } + + private boolean isSecure() { + String tlsVerify = this.environment.getProperty(DOCKER_TLS_VERIFY_KEY); + if (tlsVerify != null) { + try { + return Integer.parseInt(tlsVerify) == 1; + } + catch (NumberFormatException ex) { + return false; + } + } + return false; + } + + interface EnvironmentAccessor { + + String getProperty(String key); + + } + + public static class SystemEnvironmentAccessor implements EnvironmentAccessor { + + public String getProperty(String key) { + return System.getenv(key); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/CertificateParser.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/CertificateParser.java new file mode 100644 index 0000000000..c4c55852da --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/CertificateParser.java @@ -0,0 +1,94 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.ssl; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.util.Base64Utils; + +/** + * Parser for X.509 certificates in PEM format. + * + * @author Scott Frederick + */ +final class CertificateParser { + + private static final Pattern CERTIFICATE_PATTERN = Pattern + .compile("-+BEGIN\\s+.*CERTIFICATE[^-]*-+(?:\\s|\\r|\\n)+" + // Header + "([a-z0-9+/=\\r\\n]+)" + // Base64 text + "-+END\\s+.*CERTIFICATE[^-]*-+", // Footer + Pattern.CASE_INSENSITIVE); + + private CertificateParser() { + } + + /** + * Load certificates from the specified file paths. + * @param certPaths one or more paths to certificate files + * @return certificates parsed from specified file paths + */ + static X509Certificate[] parse(Path... certPaths) { + List certs = new ArrayList<>(); + for (Path certFile : certPaths) { + certs.addAll(generateCertificates(certFile)); + } + return certs.toArray(new X509Certificate[0]); + } + + private static List generateCertificates(Path certPath) { + try { + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + List certs = new ArrayList<>(); + + byte[] certBytes = Files.readAllBytes(certPath); + String certString = new String(certBytes, StandardCharsets.UTF_8); + + Matcher matcher = CERTIFICATE_PATTERN.matcher(certString); + + while (matcher.find()) { + byte[] content = decodeContent(matcher.group(1)); + ByteArrayInputStream contentStream = new ByteArrayInputStream(content); + while (contentStream.available() > 0) { + certs.add((X509Certificate) certificateFactory.generateCertificate(contentStream)); + } + } + + return certs; + } + catch (CertificateException | IOException ex) { + throw new IllegalStateException("Error reading certificate from file " + certPath + ": " + ex.getMessage(), + ex); + } + } + + private static byte[] decodeContent(String content) { + byte[] contentBytes = content.replaceAll("\r", "").replaceAll("\n", "").getBytes(); + return Base64Utils.decode(contentBytes); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/KeyStoreFactory.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/KeyStoreFactory.java new file mode 100644 index 0000000000..73b1f1ccf9 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/KeyStoreFactory.java @@ -0,0 +1,90 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.ssl; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; + +/** + * Utility methods for creating Java trust material from key and certificate files. + * + * @author Scott Frederick + */ +final class KeyStoreFactory { + + private KeyStoreFactory() { + } + + /** + * Create a new {@link KeyStore} populated with the certificate stored at the + * specified file path and an optional private key. + * @param certPath the path to the certificate authority file + * @param keyPath the path to the private file + * @param alias the alias to use for KeyStore entries + * @return the {@code KeyStore} + */ + static KeyStore create(Path certPath, Path keyPath, String alias) { + try { + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null); + + X509Certificate[] certificates = CertificateParser.parse(certPath); + + if (keyPath != null && Files.exists(keyPath)) { + PrivateKey privateKey = PrivateKeyParser.parse(keyPath); + addCertsToStore(keyStore, certificates, privateKey, alias); + } + else { + addCertsToStore(keyStore, certificates, alias); + } + + return keyStore; + } + catch (GeneralSecurityException | IOException ex) { + throw new IllegalStateException("Error creating KeyStore: " + ex.getMessage(), ex); + } + } + + private static void addCertsToStore(KeyStore keyStore, X509Certificate[] certificates, PrivateKey privateKey, + String alias) { + try { + keyStore.setKeyEntry(alias, privateKey, new char[] {}, certificates); + } + catch (KeyStoreException ex) { + throw new IllegalStateException("Error adding certificates to KeyStore: " + ex.getMessage(), ex); + } + } + + private static void addCertsToStore(KeyStore keyStore, X509Certificate[] certs, String alias) { + try { + for (int index = 0; index < certs.length; index++) { + String indexedAlias = alias + "-" + index; + keyStore.setCertificateEntry(indexedAlias, certs[index]); + } + } + catch (KeyStoreException ex) { + throw new IllegalStateException("Error adding certificates to KeyStore: " + ex.getMessage(), ex); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParser.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParser.java new file mode 100644 index 0000000000..a4e1c8eaf3 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParser.java @@ -0,0 +1,125 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.ssl; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.util.Base64Utils; + +/** + * Parser for PKCS private key files in PEM format. + * + * @author Scott Frederick + */ +final class PrivateKeyParser { + + private static final Pattern PKCS_1_KEY_PATTERN = Pattern + .compile("-+BEGIN\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+" + // Header + "([a-z0-9+/=\\r\\n]+)" + // Base64 text + "-+END\\s+RSA\\s+PRIVATE\\s+KEY[^-]*-+", // Footer + Pattern.CASE_INSENSITIVE); + + private static final Pattern PKCS_8_KEY_PATTERN = Pattern + .compile("-+BEGIN\\s+PRIVATE\\s+KEY[^-]*-+(?:\\s|\\r|\\n)+" + // Header + "([a-z0-9+/=\\r\\n]+)" + // Base64 text + "-+END\\s+PRIVATE\\s+KEY[^-]*-+", // Footer + Pattern.CASE_INSENSITIVE); + + private PrivateKeyParser() { + } + + /** + * Load a private key from the specified file paths. + * @param keyPath the path to the private key file + * @return private key from specified file path + */ + static PrivateKey parse(Path keyPath) { + try { + byte[] keyBytes = Files.readAllBytes(keyPath); + String keyString = new String(keyBytes, StandardCharsets.UTF_8); + + Matcher matcher = PKCS_1_KEY_PATTERN.matcher(keyString); + if (matcher.find()) { + return parsePkcs1PrivateKey(decodeContent(matcher.group(1))); + } + + matcher = PKCS_8_KEY_PATTERN.matcher(keyString); + if (matcher.find()) { + return parsePkcs8PrivateKey(decodeContent(matcher.group(1))); + } + + throw new IllegalStateException("Unrecognized private key format in " + keyPath); + } + catch (GeneralSecurityException | IOException ex) { + throw new IllegalStateException("Error loading private key file " + keyPath, ex); + } + } + + private static byte[] decodeContent(String content) { + byte[] contentBytes = content.replaceAll("\r", "").replaceAll("\n", "").getBytes(); + return Base64Utils.decode(contentBytes); + } + + private static PrivateKey parsePkcs1PrivateKey(byte[] privateKeyBytes) throws GeneralSecurityException { + byte[] pkcs8Bytes = convertPkcs1ToPkcs8(privateKeyBytes); + return parsePkcs8PrivateKey(pkcs8Bytes); + } + + private static PrivateKey parsePkcs8PrivateKey(byte[] privateKeyBytes) throws GeneralSecurityException { + try { + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePrivate(keySpec); + } + catch (InvalidKeySpecException ex) { + throw new IllegalArgumentException("Unexpected key format", ex); + } + } + + private static byte[] convertPkcs1ToPkcs8(byte[] privateKeyBytes) { + int pkcs1Length = privateKeyBytes.length; + int totalLength = pkcs1Length + 22; + byte[] pkcs8Header = new byte[] { 0x30, (byte) 0x82, (byte) ((totalLength >> 8) & 0xff), + // Sequence + total length + (byte) (totalLength & 0xff), + // Integer (0) + 0x2, 0x1, 0x0, + // Sequence: 1.2.840.113549.1.1.1, NULL + 0x30, 0xD, 0x6, 0x9, 0x2A, (byte) 0x86, 0x48, (byte) 0x86, (byte) 0xF7, 0xD, 0x1, 0x1, 0x1, 0x5, 0x0, + // Octet string + length + 0x4, (byte) 0x82, (byte) ((pkcs1Length >> 8) & 0xff), (byte) (pkcs1Length & 0xff) }; + return join(pkcs8Header, privateKeyBytes); + } + + private static byte[] join(byte[] byteArray1, byte[] byteArray2) { + byte[] bytes = new byte[byteArray1.length + byteArray2.length]; + System.arraycopy(byteArray1, 0, bytes, 0, byteArray1.length); + System.arraycopy(byteArray2, 0, bytes, byteArray1.length, byteArray2.length); + return bytes; + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/SslContextFactory.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/SslContextFactory.java new file mode 100644 index 0000000000..a41701e713 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/main/java/org/springframework/boot/buildpack/platform/docker/ssl/SslContextFactory.java @@ -0,0 +1,91 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.ssl; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.KeyStore; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; + +/** + * Builds an {@link SSLContext} for use with an HTTP connection. + * + * @author Scott Frederick + * @since 2.3.0 + */ +public class SslContextFactory { + + private static final String KEY_STORE_ALIAS = "spring-boot-docker"; + + public SslContextFactory() { + } + + /** + * Create an {@link SSLContext} from files in the specified directory. + * + * The directory must contain files with the names 'key.pem', 'cert.pem', and + * 'ca.pem'. + * @param certificatePath the path to a directory containing certificate and key files + * @return the {@code SSLContext} + */ + public SSLContext forPath(String certificatePath) { + + try { + + Path keyPath = Paths.get(certificatePath, "key.pem"); + Path certPath = Paths.get(certificatePath, "cert.pem"); + Path certAuthorityPath = Paths.get(certificatePath, "ca.pem"); + Path certAuthorityKeyPath = Paths.get(certificatePath, "ca-key.pem"); + + verifyCertificateFiles(keyPath, certPath, certAuthorityPath); + + KeyStore keyStore = KeyStoreFactory.create(certPath, keyPath, KEY_STORE_ALIAS); + KeyManagerFactory keyManagerFactory = KeyManagerFactory + .getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(keyStore, new char[] {}); + + KeyStore trustStore = KeyStoreFactory.create(certAuthorityPath, certAuthorityKeyPath, KEY_STORE_ALIAS); + TrustManagerFactory trustManagerFactory = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(trustStore); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null); + + return sslContext; + + } + catch (Exception ex) { + throw new RuntimeException(ex.getMessage(), ex); + } + + } + + private static void verifyCertificateFiles(Path... certificateFilePaths) { + for (Path path : certificateFilePaths) { + if (!Files.exists(path)) { + throw new RuntimeException( + "Certificate path must contain the files 'ca.pem', 'cert.pem', and 'key.pem'"); + } + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java index 08a2773cc8..80577d7486 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerApiTests.java @@ -61,10 +61,11 @@ import static org.mockito.Mockito.verify; * Tests for {@link DockerApi}. * * @author Phillip Webb + * @author Scott Frederick */ class DockerApiTests { - private static final String API_URL = "docker://localhost/" + DockerApi.API_VERSION; + private static final String API_URL = "/" + DockerApi.API_VERSION; private static final String IMAGES_URL = API_URL + "/images"; diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerExceptionTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerExceptionTests.java index f0b794ee18..9ebdce22a8 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerExceptionTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/DockerExceptionTests.java @@ -29,13 +29,16 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException * Tests for {@link DockerException}. * * @author Phillip Webb + * @author Scott Frederick */ class DockerExceptionTests { + private static final String HOST = "docker://localhost/"; + private static final URI URI; static { try { - URI = new URI("docker://localhost"); + URI = new URI("example"); } catch (URISyntaxException ex) { throw new IllegalStateException(ex); @@ -46,17 +49,24 @@ class DockerExceptionTests { private static final Errors ERRORS = new Errors(Collections.singletonList(new Errors.Error("code", "message"))); + @Test + void createWhenHostIsNullThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> new DockerException(null, null, 404, null, NO_ERRORS)) + .withMessage("host must not be null"); + } + @Test void createWhenUriIsNullThrowsException() { - assertThatIllegalArgumentException().isThrownBy(() -> new DockerException(null, 404, null, NO_ERRORS)) + assertThatIllegalArgumentException() + .isThrownBy(() -> new DockerException(this.HOST, null, 404, null, NO_ERRORS)) .withMessage("URI must not be null"); } @Test void create() { - DockerException exception = new DockerException(URI, 404, "missing", ERRORS); + DockerException exception = new DockerException(HOST, URI, 404, "missing", ERRORS); assertThat(exception.getMessage()).isEqualTo( - "Docker API call to 'docker://localhost' failed with status code 404 \"missing\" [code: message]"); + "Docker API call to 'docker://localhost/example' failed with status code 404 \"missing\" [code: message]"); assertThat(exception.getStatusCode()).isEqualTo(404); assertThat(exception.getReasonPhrase()).isEqualTo("missing"); assertThat(exception.getErrors()).isSameAs(ERRORS); @@ -64,9 +74,9 @@ class DockerExceptionTests { @Test void createWhenReasonPhraseIsNull() { - DockerException exception = new DockerException(URI, 404, null, ERRORS); - assertThat(exception.getMessage()) - .isEqualTo("Docker API call to 'docker://localhost' failed with status code 404 [code: message]"); + DockerException exception = new DockerException(HOST, URI, 404, null, ERRORS); + assertThat(exception.getMessage()).isEqualTo( + "Docker API call to 'docker://localhost/example' failed with status code 404 [code: message]"); assertThat(exception.getStatusCode()).isEqualTo(404); assertThat(exception.getReasonPhrase()).isNull(); assertThat(exception.getErrors()).isSameAs(ERRORS); @@ -74,15 +84,15 @@ class DockerExceptionTests { @Test void createWhenErrorsIsNull() { - DockerException exception = new DockerException(URI, 404, "missing", null); + DockerException exception = new DockerException(HOST, URI, 404, "missing", null); assertThat(exception.getErrors()).isNull(); } @Test void createWhenErrorsIsEmpty() { - DockerException exception = new DockerException(URI, 404, "missing", NO_ERRORS); + DockerException exception = new DockerException(HOST, URI, 404, "missing", NO_ERRORS); assertThat(exception.getMessage()) - .isEqualTo("Docker API call to 'docker://localhost' failed with status code 404 \"missing\""); + .isEqualTo("Docker API call to 'docker://localhost/example' failed with status code 404 \"missing\""); assertThat(exception.getStatusCode()).isEqualTo(404); assertThat(exception.getReasonPhrase()).isEqualTo("missing"); assertThat(exception.getErrors()).isSameAs(NO_ERRORS); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/HttpClientHttpTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/HttpClientHttpTests.java index d23b867787..7ed7c12418 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/HttpClientHttpTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/HttpClientHttpTests.java @@ -25,6 +25,8 @@ import java.nio.charset.StandardCharsets; import org.apache.http.HttpEntity; import org.apache.http.HttpEntityEnclosingRequest; import org.apache.http.HttpHeaders; +import org.apache.http.HttpHost; +import org.apache.http.HttpRequest; import org.apache.http.StatusLine; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpDelete; @@ -41,6 +43,7 @@ import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.springframework.boot.buildpack.platform.docker.Http.Response; +import org.springframework.boot.buildpack.platform.docker.httpclient.DockerHttpClientConnection; import org.springframework.util.StreamUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -75,6 +78,9 @@ class HttpClientHttpTests { @Mock private InputStream content; + @Captor + private ArgumentCaptor hostCaptor; + @Captor private ArgumentCaptor requestCaptor; @@ -85,11 +91,11 @@ class HttpClientHttpTests { @BeforeEach void setup() throws Exception { MockitoAnnotations.initMocks(this); - given(this.client.execute(any())).willReturn(this.response); + given(this.client.execute(any(HttpHost.class), any(HttpRequest.class))).willReturn(this.response); given(this.response.getEntity()).willReturn(this.entity); given(this.response.getStatusLine()).willReturn(this.statusLine); - this.http = new HttpClientHttp(this.client); - this.uri = new URI("docker://localhost/example"); + this.http = new HttpClientHttp(new TestClientConnection(this.client)); + this.uri = new URI("example"); } @Test @@ -97,7 +103,7 @@ class HttpClientHttpTests { given(this.entity.getContent()).willReturn(this.content); given(this.statusLine.getStatusCode()).willReturn(200); Response response = this.http.get(this.uri); - verify(this.client).execute(this.requestCaptor.capture()); + verify(this.client).execute(this.hostCaptor.capture(), this.requestCaptor.capture()); HttpUriRequest request = this.requestCaptor.getValue(); assertThat(request).isInstanceOf(HttpGet.class); assertThat(request.getURI()).isEqualTo(this.uri); @@ -110,7 +116,7 @@ class HttpClientHttpTests { given(this.entity.getContent()).willReturn(this.content); given(this.statusLine.getStatusCode()).willReturn(200); Response response = this.http.post(this.uri); - verify(this.client).execute(this.requestCaptor.capture()); + verify(this.client).execute(this.hostCaptor.capture(), this.requestCaptor.capture()); HttpUriRequest request = this.requestCaptor.getValue(); assertThat(request).isInstanceOf(HttpPost.class); assertThat(request.getURI()).isEqualTo(this.uri); @@ -124,7 +130,7 @@ class HttpClientHttpTests { given(this.statusLine.getStatusCode()).willReturn(200); Response response = this.http.post(this.uri, APPLICATION_JSON, (out) -> StreamUtils.copy("test", StandardCharsets.UTF_8, out)); - verify(this.client).execute(this.requestCaptor.capture()); + verify(this.client).execute(this.hostCaptor.capture(), this.requestCaptor.capture()); HttpUriRequest request = this.requestCaptor.getValue(); HttpEntity entity = ((HttpEntityEnclosingRequest) request).getEntity(); assertThat(request).isInstanceOf(HttpPost.class); @@ -144,7 +150,7 @@ class HttpClientHttpTests { given(this.statusLine.getStatusCode()).willReturn(200); Response response = this.http.put(this.uri, APPLICATION_JSON, (out) -> StreamUtils.copy("test", StandardCharsets.UTF_8, out)); - verify(this.client).execute(this.requestCaptor.capture()); + verify(this.client).execute(this.hostCaptor.capture(), this.requestCaptor.capture()); HttpUriRequest request = this.requestCaptor.getValue(); HttpEntity entity = ((HttpEntityEnclosingRequest) request).getEntity(); assertThat(request).isInstanceOf(HttpPut.class); @@ -163,7 +169,7 @@ class HttpClientHttpTests { given(this.entity.getContent()).willReturn(this.content); given(this.statusLine.getStatusCode()).willReturn(200); Response response = this.http.delete(this.uri); - verify(this.client).execute(this.requestCaptor.capture()); + verify(this.client).execute(this.hostCaptor.capture(), this.requestCaptor.capture()); HttpUriRequest request = this.requestCaptor.getValue(); assertThat(request).isInstanceOf(HttpDelete.class); assertThat(request.getURI()).isEqualTo(this.uri); @@ -188,7 +194,8 @@ class HttpClientHttpTests { @Test void executeWhenClientThrowsIOExceptionRethrowsAsDockerException() throws IOException { - given(this.client.execute(any())).willThrow(new IOException("test IO exception")); + given(this.client.execute(any(HttpHost.class), any(HttpRequest.class))) + .willThrow(new IOException("test IO exception")); assertThatExceptionOfType(DockerException.class).isThrownBy(() -> this.http.get(this.uri)) .satisfies((ex) -> assertThat(ex.getErrors()).isNull()).satisfies(DockerException::getStatusCode) .withMessageContaining("500") @@ -201,4 +208,24 @@ class HttpClientHttpTests { return new String(out.toByteArray(), StandardCharsets.UTF_8); } + private static final class TestClientConnection implements DockerHttpClientConnection { + + private final CloseableHttpClient client; + + private TestClientConnection(CloseableHttpClient client) { + this.client = client; + } + + @Override + public HttpHost getHttpHost() { + return HttpHost.create("docker://localhost"); + } + + @Override + public CloseableHttpClient getHttpClient() { + return this.client; + } + + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/httpclient/RemoteEnvironmentDockerHttpClientConnectionTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/httpclient/RemoteEnvironmentDockerHttpClientConnectionTests.java new file mode 100644 index 0000000000..bb1ee42c6b --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/httpclient/RemoteEnvironmentDockerHttpClientConnectionTests.java @@ -0,0 +1,102 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.httpclient; + +import java.security.NoSuchAlgorithmException; + +import javax.net.ssl.SSLContext; + +import org.apache.http.HttpHost; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.buildpack.platform.docker.httpclient.RemoteEnvironmentDockerHttpClientConnection.EnvironmentAccessor; +import org.springframework.boot.buildpack.platform.docker.ssl.SslContextFactory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link RemoteEnvironmentDockerHttpClientConnection}. + * + * @author Scott Frederick + */ +class RemoteEnvironmentDockerHttpClientConnectionTests { + + private EnvironmentAccessor environment; + + private RemoteEnvironmentDockerHttpClientConnection connection; + + private SslContextFactory sslContextFactory; + + @BeforeEach + void setUp() { + this.environment = mock(EnvironmentAccessor.class); + this.sslContextFactory = mock(SslContextFactory.class); + this.connection = new RemoteEnvironmentDockerHttpClientConnection(this.environment, this.sslContextFactory); + } + + @Test + void notAcceptedWhenDockerHostNotSet() { + assertThat(this.connection.accept()).isFalse(); + assertThatIllegalStateException().isThrownBy(() -> this.connection.getHttpHost()); + assertThatIllegalStateException().isThrownBy(() -> this.connection.getHttpClient()); + } + + @Test + void acceptedWhenDockerHostIsSet() { + given(this.environment.getProperty("DOCKER_HOST")).willReturn("tcp://192.168.1.2:2376"); + assertThat(this.connection.accept()).isTrue(); + } + + @Test + void invalidTlsConfigurationThrowsException() { + given(this.environment.getProperty("DOCKER_HOST")).willReturn("tcp://192.168.1.2:2376"); + given(this.environment.getProperty("DOCKER_TLS_VERIFY")).willReturn("1"); + assertThatIllegalArgumentException().isThrownBy(() -> this.connection.accept()) + .withMessageContaining("DOCKER_CERT_PATH"); + } + + @Test + void hostProtocolIsHttpWhenNotSecure() { + given(this.environment.getProperty("DOCKER_HOST")).willReturn("tcp://192.168.1.2:2376"); + assertThat(this.connection.accept()).isTrue(); + HttpHost host = this.connection.getHttpHost(); + assertThat(host).isNotNull(); + assertThat(host.getSchemeName()).isEqualTo("http"); + assertThat(host.getHostName()).isEqualTo("192.168.1.2"); + assertThat(host.getPort()).isEqualTo(2376); + } + + @Test + void hostProtocolIsHttpsWhenSecure() throws NoSuchAlgorithmException { + given(this.environment.getProperty("DOCKER_HOST")).willReturn("tcp://192.168.1.2:2376"); + given(this.environment.getProperty("DOCKER_TLS_VERIFY")).willReturn("1"); + given(this.environment.getProperty("DOCKER_CERT_PATH")).willReturn("/test-cert-path"); + given(this.sslContextFactory.forPath("/test-cert-path")).willReturn(SSLContext.getDefault()); + assertThat(this.connection.accept()).isTrue(); + HttpHost host = this.connection.getHttpHost(); + assertThat(host).isNotNull(); + assertThat(host.getSchemeName()).isEqualTo("https"); + assertThat(host.getHostName()).isEqualTo("192.168.1.2"); + assertThat(host.getPort()).isEqualTo(2376); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/CertificateParserTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/CertificateParserTests.java new file mode 100644 index 0000000000..479817196c --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/CertificateParserTests.java @@ -0,0 +1,80 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.ssl; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.cert.X509Certificate; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link CertificateParser}. + * + * @author Scott Frederick + */ +class CertificateParserTests { + + private PemFileWriter fileWriter; + + @BeforeEach + void setUp() throws IOException { + this.fileWriter = new PemFileWriter(); + } + + @AfterEach + void tearDown() throws IOException { + this.fileWriter.cleanup(); + } + + @Test + void parseCertificates() throws IOException { + Path caPath = this.fileWriter.writeFile("ca.pem", PemFileWriter.CA_CERTIFICATE); + Path certPath = this.fileWriter.writeFile("cert.pem", PemFileWriter.CERTIFICATE); + X509Certificate[] certificates = CertificateParser.parse(caPath, certPath); + assertThat(certificates).isNotNull(); + assertThat(certificates.length).isEqualTo(2); + assertThat(certificates[0].getType()).isEqualTo("X.509"); + assertThat(certificates[1].getType()).isEqualTo("X.509"); + } + + @Test + void parseCertificateChain() throws IOException { + Path path = this.fileWriter.writeFile("ca.pem", PemFileWriter.CA_CERTIFICATE, PemFileWriter.CERTIFICATE); + X509Certificate[] certificates = CertificateParser.parse(path); + assertThat(certificates).isNotNull(); + assertThat(certificates.length).isEqualTo(2); + assertThat(certificates[0].getType()).isEqualTo("X.509"); + assertThat(certificates[1].getType()).isEqualTo("X.509"); + } + + @Test + void parseWithInvalidPathWillThrowException() throws URISyntaxException { + Path path = Paths.get(new URI("file:///bad/path/cert.pem")); + assertThatIllegalStateException().isThrownBy(() -> CertificateParser.parse(path)) + .withMessageContaining(path.toString()); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/KeyStoreFactoryTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/KeyStoreFactoryTests.java new file mode 100644 index 0000000000..bc5c9810d0 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/KeyStoreFactoryTests.java @@ -0,0 +1,84 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.ssl; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link KeyStoreFactory}. + * + * @author Scott Frederick + */ +class KeyStoreFactoryTests { + + private PemFileWriter fileWriter; + + @BeforeEach + void setUp() throws IOException { + this.fileWriter = new PemFileWriter(); + } + + @AfterEach + void tearDown() throws IOException { + this.fileWriter.cleanup(); + } + + @Test + void createKeyStoreWithCertChain() + throws IOException, KeyStoreException, UnrecoverableKeyException, NoSuchAlgorithmException { + Path certPath = this.fileWriter.writeFile("cert.pem", PemFileWriter.CA_CERTIFICATE, PemFileWriter.CERTIFICATE); + KeyStore keyStore = KeyStoreFactory.create(certPath, null, "test-alias"); + + assertThat(keyStore.containsAlias("test-alias-0")).isTrue(); + assertThat(keyStore.getCertificate("test-alias-0")).isNotNull(); + assertThat(keyStore.getKey("test-alias-0", new char[] {})).isNull(); + + assertThat(keyStore.containsAlias("test-alias-1")).isTrue(); + assertThat(keyStore.getCertificate("test-alias-1")).isNotNull(); + assertThat(keyStore.getKey("test-alias-1", new char[] {})).isNull(); + + Files.delete(certPath); + } + + @Test + void createKeyStoreWithCertChainAndPrivateKey() + throws IOException, KeyStoreException, UnrecoverableKeyException, NoSuchAlgorithmException { + Path certPath = this.fileWriter.writeFile("cert.pem", PemFileWriter.CA_CERTIFICATE, PemFileWriter.CERTIFICATE); + Path keyPath = this.fileWriter.writeFile("key.pem", PemFileWriter.PRIVATE_KEY); + KeyStore keyStore = KeyStoreFactory.create(certPath, keyPath, "test-alias"); + + assertThat(keyStore.containsAlias("test-alias")).isTrue(); + assertThat(keyStore.getCertificate("test-alias")).isNotNull(); + assertThat(keyStore.getKey("test-alias", new char[] {})).isNotNull(); + + Files.delete(certPath); + Files.delete(keyPath); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PemFileWriter.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PemFileWriter.java new file mode 100644 index 0000000000..39f3c19ccc --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PemFileWriter.java @@ -0,0 +1,122 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.ssl; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; + +import org.springframework.util.FileSystemUtils; + +/** + * Utility to write certificate and key PEM files for testing. + * + * @author Scott Frederick + */ +public class PemFileWriter { + + private static final String EXAMPLE_SECRET_QUALIFIER = "example"; + + public static final String CA_CERTIFICATE = "-----BEGIN TRUSTED CERTIFICATE-----\n" + + "MIIClzCCAgACCQCPbjkRoMVEQDANBgkqhkiG9w0BAQUFADCBjzELMAkGA1UEBhMC\n" + + "VVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28x\n" + + "DTALBgNVBAoMBFRlc3QxDTALBgNVBAsMBFRlc3QxFDASBgNVBAMMC2V4YW1wbGUu\n" + + "Y29tMR8wHQYJKoZIhvcNAQkBFhB0ZXN0QGV4YW1wbGUuY29tMB4XDTIwMDMyNzIx\n" + + "NTgwNFoXDTIxMDMyNzIxNTgwNFowgY8xCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApD\n" + + "YWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMQ0wCwYDVQQKDARUZXN0\n" + + "MQ0wCwYDVQQLDARUZXN0MRQwEgYDVQQDDAtleGFtcGxlLmNvbTEfMB0GCSqGSIb3\n" + + "DQEJARYQdGVzdEBleGFtcGxlLmNvbTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkC\n" + + "gYEA1YzixWEoyzrd20C2R1gjyPCoPfFLlG6UYTyT0tueNy6yjv6qbJ8lcZg7616O\n" + + "3I9LuOHhZh9U+fCDCgPfiDdyJfDEW/P+dsOMFyMUXPrJPze2yPpOnvV8iJ5DM93u\n" + + "fEVhCCyzLdYu0P2P3hU2W+T3/Im9DA7FOPA2vF1SrIJ2qtUCAwEAATANBgkqhkiG\n" + + "9w0BAQUFAAOBgQBdShkwUv78vkn1jAdtfbB+7mpV9tufVdo29j7pmotTCz3ny5fc\n" + + "zLEfeu6JPugAR71JYbc2CqGrMneSk1zT91EH6ohIz8OR5VNvzB7N7q65Ci7OFMPl\n" + + "ly6k3rHpMCBtHoyNFhNVfPLxGJ9VlWFKLgIAbCmL4OIQm1l6Fr1MSM38Zw==\n" + "-----END TRUSTED CERTIFICATE-----\n"; + + public static final String CA_PRIVATE_KEY = EXAMPLE_SECRET_QUALIFIER + "-----BEGIN PRIVATE KEY-----\n" + + "MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBANWM4sVhKMs63dtA\n" + + "tkdYI8jwqD3xS5RulGE8k9Lbnjcuso7+qmyfJXGYO+tejtyPS7jh4WYfVPnwgwoD\n" + + "34g3ciXwxFvz/nbDjBcjFFz6yT83tsj6Tp71fIieQzPd7nxFYQgssy3WLtD9j94V\n" + + "Nlvk9/yJvQwOxTjwNrxdUqyCdqrVAgMBAAECgYEAyJTlZ8nj3Eg1nLxCue6C5jmN\n" + + "fWkIuanH+zFAE/0utdxJ4WA4yYAOVo1MMr8FZwu9bzHTWe2yDnWnT5/ltPeHYX2X\n" + + "9Pg5cY0tjq07utaMwLKWgJ0Xoh2UpVM799t/rSvMWmLaZ2c8nipX+gQfYJFpX8Vg\n" + + "mR3QPxwdmNyFo13qif0CQQD4z2SqCfARuxscTCJDZ6wReikMQxaJvq74lPEtT26L\n" + + "rBr/bN+mG7+rMEHxs5wtU47aNjUKuVVC0Qfhsf95ahvHAkEA27inSlxrwGvhvFsD\n" + + "FWdgDsfYpPZdL4YgpVSEvcoypRGg2suJw2omcKcY56XpkmWUqZc06QirumtnEC0P\n" + + "HfnsgwJBAMVhEURrOc13FxytsQiz96atuF6H4htH79o3ndQKDXI0B/7VSd6maLjP\n" + + "QaESkTTL8qldE1r8h4zH8m6zHC4fZQUCQFWJ+8bdWC2fUlBr9jVc+26Fqvf92aVo\n" + + "yEjVMKBamYDd7gt/9fAX4UM2KmH0m4wc89VaQoT+lSyMJ6GKiToYVFUCQEXcyoeO\n" + + "zWqtSgEX/eXQXzmMKxYnjv1O//ba3Q7UiHd/XO5j4QXAJpcB6h0h00uC5KY2d0Zy\n" + "JQ1kB1C2l6l9tyc=\n" + + "-----END PRIVATE KEY-----"; + + public static final String CERTIFICATE = "-----BEGIN CERTIFICATE-----\n" + + "MIICjzCCAfgCAQEwDQYJKoZIhvcNAQEFBQAwgY8xCzAJBgNVBAYTAlVTMRMwEQYD\n" + + "VQQIDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMQ0wCwYDVQQK\n" + + "DARUZXN0MQ0wCwYDVQQLDARUZXN0MRQwEgYDVQQDDAtleGFtcGxlLmNvbTEfMB0G\n" + + "CSqGSIb3DQEJARYQdGVzdEBleGFtcGxlLmNvbTAeFw0yMDAzMjcyMjAxNDZaFw0y\n" + + "MTAzMjcyMjAxNDZaMIGPMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5p\n" + + "YTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwEVGVzdDENMAsGA1UE\n" + + "CwwEVGVzdDEUMBIGA1UEAwwLZXhhbXBsZS5jb20xHzAdBgkqhkiG9w0BCQEWEHRl\n" + + "c3RAZXhhbXBsZS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAM7kd2cj\n" + + "F49wm1+OQ7Q5GE96cXueWNPr/Nwei71tf6G4BmE0B+suXHEvnLpHTj9pdX/ZzBIK\n" + + "8jIZ/x8RnSduK/Ky+zm1QMYUWZtWCAgCW8WzgB69Cn/hQG8KSX3S9bqODuQAvP54\n" + + "GQJD7+4kVuNBGjFb4DaD4nvMmPtALSZf8ZCZAgMBAAEwDQYJKoZIhvcNAQEFBQAD\n" + + "gYEAOn6X8+0VVlDjF+TvTgI0KIasA6nDm+KXe7LVtfvqWqQZH4qyd2uiwcDM3Aux\n" + + "a/OsPdOw0j+NqFDBd3mSMhSVgfvXdK6j9WaxY1VGXyaidLARgvn63wfzgr857sQW\n" + + "c8eSxbwEQxwlMvVxW6Os4VhCfUQr8VrBrvPa2zs+6IlK+Ug=\n" + "-----END CERTIFICATE-----\n"; + + public static final String PRIVATE_KEY = EXAMPLE_SECRET_QUALIFIER + "-----BEGIN RSA PRIVATE KEY-----\n" + + "MIICXAIBAAKBgQDO5HdnIxePcJtfjkO0ORhPenF7nljT6/zcHou9bX+huAZhNAfr\n" + + "LlxxL5y6R04/aXV/2cwSCvIyGf8fEZ0nbivysvs5tUDGFFmbVggIAlvFs4AevQp/\n" + + "4UBvCkl90vW6jg7kALz+eBkCQ+/uJFbjQRoxW+A2g+J7zJj7QC0mX/GQmQIDAQAB\n" + + "AoGAIWPsBWA7gDHrUYuzT5XbX5BiWlIfAezXPWtMoEDY1W/Oz8dG8+TilH3brJCv\n" + + "hzps9TpgXhUYK4/Yhdog4+k6/EEY80RvcObOnflazTCVS041B0Ipm27uZjIq2+1F\n" + + "ZfbWP+B3crpzh8wvIYA+6BCcZV9zi8Od32NEs39CtrOrFPUCQQDxnt9+JlWjtteR\n" + + "VttRSKjtzKIF08BzNuZlRP9HNWveLhphIvdwBfjASwqgtuslqziEnGG8kniWzyYB\n" + + "a/ZZVoT3AkEA2zSBMpvGPDkGbOMqbnR8UL3uijkOj+blQe1gsyu3dUa9T42O1u9h\n" + + "Iz5SdCYlSFHbDNRFrwuW2QnhippqIQqC7wJAbVeyWEpM0yu5XiJqWdyB5iuG3xA2\n" + + "tW0Q0p9ozvbT+9XtRiwmweFR8uOCybw9qexURV7ntAis3cKctmP/Neq7fQJBAKGa\n" + + "59UjutYTRIVqRJICFtR/8ii9P9sfYs1j7/KnvC0d5duMhU44VOjivW8b4Eic8F1Y\n" + + "8bbHWILSIhFJHg0V7skCQDa8/YkRWF/3pwIZNWQr4ce4OzvYsFMkRvGRdX8B2a0p\n" + + "wSKcVTdEdO2DhBlYddN0zG0rjq4vDMtdmldEl4BdldQ=\n" + "-----END RSA PRIVATE KEY-----\n"; + + private final Path tempDir; + + public PemFileWriter() throws IOException { + this.tempDir = Files.createTempDirectory("buildpack-platform-docker-ssl-tests"); + } + + Path writeFile(String name, String... contents) throws IOException { + Path path = Paths.get(this.tempDir.toString(), name); + for (String content : contents) { + Files.write(path, content.replaceAll(EXAMPLE_SECRET_QUALIFIER, "").getBytes(), StandardOpenOption.CREATE, + StandardOpenOption.APPEND); + } + return path; + } + + public Path getTempDir() { + return this.tempDir; + } + + void cleanup() throws IOException { + FileSystemUtils.deleteRecursively(this.tempDir); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParserTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParserTests.java new file mode 100644 index 0000000000..648b1c7d6e --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/PrivateKeyParserTests.java @@ -0,0 +1,87 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.ssl; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.PrivateKey; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link PrivateKeyParser}. + * + * @author Scott Frederick + */ +class PrivateKeyParserTests { + + private PemFileWriter fileWriter; + + @BeforeEach + void setUp() throws IOException { + this.fileWriter = new PemFileWriter(); + } + + @AfterEach + void tearDown() throws IOException { + this.fileWriter.cleanup(); + } + + @Test + void parsePkcs8KeyFile() throws IOException { + Path path = this.fileWriter.writeFile("key.pem", PemFileWriter.CA_PRIVATE_KEY); + PrivateKey privateKey = PrivateKeyParser.parse(path); + assertThat(privateKey).isNotNull(); + assertThat(privateKey.getFormat()).isEqualTo("PKCS#8"); + Files.delete(path); + } + + @Test + void parsePkcs1KeyFile() throws IOException { + Path path = this.fileWriter.writeFile("key.pem", PemFileWriter.PRIVATE_KEY); + PrivateKey privateKey = PrivateKeyParser.parse(path); + assertThat(privateKey).isNotNull(); + // keys in PKCS#1 format are converted to PKCS#8 for parsing + assertThat(privateKey.getFormat()).isEqualTo("PKCS#8"); + Files.delete(path); + } + + @Test + void parseWithNonKeyFileWillThrowException() throws IOException { + Path path = this.fileWriter.writeFile("text.pem", "plain text"); + assertThatIllegalStateException().isThrownBy(() -> PrivateKeyParser.parse(path)) + .withMessageContaining(path.toString()); + Files.delete(path); + } + + @Test + void parseWithInvalidPathWillThrowException() throws URISyntaxException { + URI privateKeyPath = new URI("file:///bad/path/key.pem"); + assertThatIllegalStateException().isThrownBy(() -> PrivateKeyParser.parse(Paths.get(privateKeyPath))) + .withMessageContaining(privateKeyPath.getPath()); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/SslContextFactoryTests.java b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/SslContextFactoryTests.java new file mode 100644 index 0000000000..0d19c47b41 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-buildpack-platform/src/test/java/org/springframework/boot/buildpack/platform/docker/ssl/SslContextFactoryTests.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-2020 the original author or authors. + * + * 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 + * + * https://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 org.springframework.boot.buildpack.platform.docker.ssl; + +import java.io.IOException; + +import javax.net.ssl.SSLContext; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link SslContextFactory}. + * + * @author Scott Frederick + */ +class SslContextFactoryTests { + + private PemFileWriter fileWriter; + + @BeforeEach + void setUp() throws IOException { + this.fileWriter = new PemFileWriter(); + } + + @AfterEach + void tearDown() throws IOException { + this.fileWriter.cleanup(); + } + + @Test + void createKeyStoreWithCertChain() throws IOException { + this.fileWriter.writeFile("cert.pem", PemFileWriter.CERTIFICATE); + this.fileWriter.writeFile("key.pem", PemFileWriter.PRIVATE_KEY); + this.fileWriter.writeFile("ca.pem", PemFileWriter.CA_CERTIFICATE); + + SSLContext sslContext = new SslContextFactory().forPath(this.fileWriter.getTempDir().toString()); + assertThat(sslContext).isNotNull(); + } + +}