diff --git a/.ci/setup_ssh_config.sh b/.ci/setup_ssh_config.sh new file mode 100755 index 000000000..2ca423453 --- /dev/null +++ b/.ci/setup_ssh_config.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +set -exu + +mkdir -p ~/.ssh +cd ~/.ssh +ssh-keygen -q -t rsa -N "" -f jsch +cat jsch.pub >> authorized_keys + +cat <> config +Host junit-host + HostName localhost + StrictHostKeyChecking no + IdentityFile ~/.ssh/jsch + PreferredAuthentications publickey +EOT + +chmod go-w $HOME $HOME/.ssh +chmod 600 $HOME/.ssh/authorized_keys + +ssh -q junit-host exit + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73c35f57a..aeb33565b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,8 @@ jobs: - { name: "default" } - { name: "over TCP", dockerHost: "tcp://127.0.0.1:2375" } - { name: "Docker 18.06.3", dockerVersion: "18.06.3~ce~3-0~ubuntu" } + - { name: "ssh-default" , clientDockerHost: "ssh://junit-host"} + - { name: "ssh-19.03.9" , dockerVersion: "5:19.03.12~3-0~ubuntu-bionic", clientDockerHost: "ssh://junit-host"} steps: - uses: actions/checkout@v2 @@ -26,10 +28,19 @@ jobs: DOCKER_VERSION: ${{matrix.dockerVersion}} DOCKER_HOST: ${{matrix.dockerHost}} run: .ci/setup_docker.sh + - name: Create ssh config + run: .ci/setup_ssh_config.sh + if: startsWith(matrix.name,'ssh') + - name: Build with Maven (SSH) + env: + DOCKER_HOST: ${{matrix.clientDockerHost}} + run: ./mvnw --no-transfer-progress -fae verify + if: startsWith(matrix.name,'ssh') - name: Build with Maven env: DOCKER_HOST: ${{matrix.dockerHost}} run: ./mvnw --no-transfer-progress verify + if: startsWith(matrix.name,'ssh')!=true - name: Aggregate test reports with ciMate if: always() continue-on-error: true diff --git a/docker-java-core/src/main/java/com/github/dockerjava/core/DefaultDockerClientConfig.java b/docker-java-core/src/main/java/com/github/dockerjava/core/DefaultDockerClientConfig.java index 274363fac..5091bc219 100644 --- a/docker-java-core/src/main/java/com/github/dockerjava/core/DefaultDockerClientConfig.java +++ b/docker-java-core/src/main/java/com/github/dockerjava/core/DefaultDockerClientConfig.java @@ -99,6 +99,7 @@ public class DefaultDockerClientConfig implements Serializable, DockerClientConf private URI checkDockerHostScheme(URI dockerHost) { switch (dockerHost.getScheme()) { + case "ssh": case "tcp": case "unix": case "npipe": diff --git a/docker-java-transport-ssh/Readme.md b/docker-java-transport-ssh/Readme.md new file mode 100644 index 000000000..46c2953ec --- /dev/null +++ b/docker-java-transport-ssh/Readme.md @@ -0,0 +1,47 @@ +# docker-java-transport-ssh + +Docker client implementation which uses [jsch](http://www.jcraft.com/jsch/) library, a java ssh implementation, to connect to the remote +docker host via ssh. + +While native docker cli supports ssh connections since Host docker version 18.09 [1](#1), with different options we can also make +it work for older versions. This library opens the ssh connection and then forwards the docker daemon socket to make it available to the http client. + +The ssh connection configuration relies on basic [ssh config file](https://www.ssh.com/ssh/config/) in ~/.ssh/config. + +## dockerd configurations + +On the remote host, one can connect to the docker daemon in several ways: + +* `docker system dial-stdio` +* `unix:///var/run/docker.sock` (default on linux) https://docs.docker.com/engine/reference/commandline/dockerd/#daemon-socket-option +* `npipe:////./pipe/docker_engine` (default on Windows) https://docs.docker.com/docker-for-windows/faqs/#how-do-i-connect-to-the-remote-docker-engine-api +* `unix:///var/run/docker.sock` (default on macos) https://docs.docker.com/docker-for-mac/faqs/#how-do-i-connect-to-the-remote-docker-engine-api +* tcp 2375 +* tcp with TLS + +## limitations + +__jsch__ + +Since jsch libary from jcraft does not support socket forwarding, a [fork of jsch](https://github.com/mwiede/jsch) is used. + +__windows__ + +Since forwarding socket of windows host is not supported, there is the workaround of starting socat to forward the docker socket to a local tcp port. + +Compare OpenSSH tickets: + * https://github.com/PowerShell/Win32-OpenSSH/issues/435 + * https://github.com/PowerShell/openssh-portable/pull/433 + +## connection variants: + +By setting flags in [SSHDockerConfig](src\main\java\com\github\dockerjava\jsch\SSHDockerConfig.java), one can control how the connection is made. + +* docker system dial-stdio (default) +* direct-streamlocal +* direct-tcpip +* socat + +## references + +[1] docker ssh support https://github.com/docker/cli/pull/1014 diff --git a/docker-java-transport-ssh/alternativeSSHImplementations.md b/docker-java-transport-ssh/alternativeSSHImplementations.md new file mode 100644 index 000000000..8e4adff7d --- /dev/null +++ b/docker-java-transport-ssh/alternativeSSHImplementations.md @@ -0,0 +1,4 @@ +alternative Java ssh implementations: +* https://github.com/apache/mina-sshd +* https://github.com/hierynomus/sshj +* https://github.com/jcabi/jcabi-ssh diff --git a/docker-java-transport-ssh/pom.xml b/docker-java-transport-ssh/pom.xml new file mode 100644 index 000000000..ad5fb13e9 --- /dev/null +++ b/docker-java-transport-ssh/pom.xml @@ -0,0 +1,68 @@ + + + + docker-java-parent + com.github.docker-java + 3.2.2-SNAPSHOT + + 4.0.0 + + docker-java-transport-ssh + + + + ${project.groupId} + docker-java-core + ${project.version} + + + + com.squareup.okhttp3 + okhttp + 3.14.4 + + + + com.github.mwiede + jsch + 0.1.59 + + + + + org.junit.jupiter + junit-jupiter + 5.6.2 + test + + + + org.slf4j + slf4j-simple + 1.7.30 + test + + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + ${maven-failsafe-plugin.version} + + + + integration-test + verify + + + + + + + + diff --git a/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/HijackingInterceptor.java b/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/HijackingInterceptor.java new file mode 100644 index 000000000..12650dcc5 --- /dev/null +++ b/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/HijackingInterceptor.java @@ -0,0 +1,61 @@ +package com.github.dockerjava.jsch; + +import com.github.dockerjava.transport.DockerHttpClient; +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.internal.connection.Exchange; +import okhttp3.internal.http.RealInterceptorChain; +import okhttp3.internal.ws.RealWebSocket; +import okio.BufferedSink; + +import java.io.IOException; +import java.io.InputStream; + +class HijackingInterceptor implements Interceptor { + + @Override + public Response intercept(Chain chain) throws IOException { + Request request = chain.request(); + Response response = chain.proceed(request); + if (!response.isSuccessful()) { + return response; + } + + DockerHttpClient.Request originalRequest = request.tag(DockerHttpClient.Request.class); + + if (originalRequest == null) { + // WTF? + return response; + } + + InputStream stdin = originalRequest.hijackedInput(); + + if (stdin == null) { + return response; + } + + chain.call().timeout().clearTimeout().clearDeadline(); + + Exchange exchange = ((RealInterceptorChain) chain).exchange(); + RealWebSocket.Streams streams = exchange.newWebSocketStreams(); + Thread thread = new Thread(() -> { + try (BufferedSink sink = streams.sink) { + while (sink.isOpen()) { + int aByte = stdin.read(); + if (aByte < 0) { + break; + } + sink.writeByte(aByte); + sink.flush(); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + thread.setName("jsch-hijack-streaming-" + System.identityHashCode(request)); + thread.setDaemon(true); + thread.start(); + return response; + } +} diff --git a/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/JSchSocketFactory.java b/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/JSchSocketFactory.java new file mode 100644 index 000000000..a893cf2ff --- /dev/null +++ b/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/JSchSocketFactory.java @@ -0,0 +1,44 @@ +package com.github.dockerjava.jsch; + +import com.jcraft.jsch.Session; + +import javax.net.SocketFactory; +import java.net.InetAddress; +import java.net.Socket; + +public class JSchSocketFactory extends SocketFactory { + + private final Session session; + private final JschDockerConfig config; + + JSchSocketFactory(Session session, JschDockerConfig config) { + this.session = session; + this.config = config; + } + + @Override + public Socket createSocket() { + return new JschSocket(session, config); + } + + @Override + public Socket createSocket(String s, int i) { + throw new UnsupportedOperationException(); + } + + @Override + public Socket createSocket(String s, int i, InetAddress inetAddress, int i1) { + throw new UnsupportedOperationException(); + } + + @Override + public Socket createSocket(InetAddress inetAddress, int i) { + throw new UnsupportedOperationException(); + } + + @Override + public Socket createSocket(InetAddress inetAddress, int i, InetAddress inetAddress1, int i1) { + throw new UnsupportedOperationException(); + } + +} diff --git a/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/JschDockerConfig.java b/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/JschDockerConfig.java new file mode 100644 index 000000000..7863d39f1 --- /dev/null +++ b/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/JschDockerConfig.java @@ -0,0 +1,103 @@ +package com.github.dockerjava.jsch; + +import com.jcraft.jsch.Session; +import okhttp3.Interceptor; + +import java.io.File; +import java.util.Hashtable; + +class JschDockerConfig { + + static final String VAR_RUN_DOCKER_SOCK = "/var/run/docker.sock"; + + private String socketPath = VAR_RUN_DOCKER_SOCK; + private Session session; + private File identityFile; + private Interceptor interceptor; + private boolean useSocat; + private boolean useTcp; + private boolean useSocket; + private Integer tcpPort; + private Hashtable jschConfig; + private String socatFlags; + + public Integer getTcpPort() { + return tcpPort; + } + + public void setTcpPort(Integer tcpPort) { + this.tcpPort = tcpPort; + } + + public String getSocketPath() { + return socketPath; + } + + public void setSocketPath(String socketPath) { + this.socketPath = socketPath; + } + + public Session getSession() { + return session; + } + + public void setSession(Session session) { + this.session = session; + } + + public File getIdentityFile() { + return identityFile; + } + + public void setIdentityFile(File identityFile) { + this.identityFile = identityFile; + } + + public Interceptor getInterceptor() { + return interceptor; + } + + public void setInterceptor(Interceptor interceptor) { + this.interceptor = interceptor; + } + + public boolean isUseSocat() { + return useSocat; + } + + public void setUseSocat(boolean useSocat) { + this.useSocat = useSocat; + } + + public void setUseTcp(boolean useTcp) { + this.useTcp = useTcp; + } + + public boolean isUseTcp() { + return useTcp; + } + + public boolean isUseSocket() { + return useSocket; + } + + public void setUseSocket(boolean useSocket) { + this.useSocket = useSocket; + } + + public void setJschConfig(Hashtable jschConfig) { + this.jschConfig = jschConfig; + } + + public Hashtable getJschConfig() { + return jschConfig; + } + + public String getSocatFlags() { + return socatFlags != null ? socatFlags : ""; + } + + public void setSocatFlags(String socatFlags) { + this.socatFlags = socatFlags; + } +} diff --git a/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/JschDockerHttpClient.java b/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/JschDockerHttpClient.java new file mode 100644 index 000000000..8ab4712a2 --- /dev/null +++ b/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/JschDockerHttpClient.java @@ -0,0 +1,440 @@ +package com.github.dockerjava.jsch; + +import com.github.dockerjava.transport.DockerHttpClient; +import com.github.dockerjava.transport.SSLConfig; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.JSchException; +import com.jcraft.jsch.OpenSSHConfig; +import com.jcraft.jsch.Session; +import okhttp3.Call; +import okhttp3.ConnectionPool; +import okhttp3.Dns; +import okhttp3.HttpUrl; +import okhttp3.Interceptor; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.RequestBody; +import okhttp3.ResponseBody; +import okio.BufferedSink; +import okio.Okio; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.X509TrustManager; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.InetAddress; +import java.net.URI; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.Hashtable; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +public final class JschDockerHttpClient implements DockerHttpClient { + + private static final Logger LOGGER = LoggerFactory.getLogger(JschDockerHttpClient.class); + + public static final class Builder { + + private URI dockerHost = null; + + private SSLConfig sslConfig = null; + + private Integer readTimeout = null; + + private Integer connectTimeout = null; + + private Boolean retryOnConnectionFailure = null; + + final JschDockerConfig jschDockerConfig = new JschDockerConfig(); + + public Builder dockerHost(URI value) { + this.dockerHost = Objects.requireNonNull(value, "dockerHost"); + return this; + } + + public Builder sslConfig(SSLConfig sslConfig) { + this.sslConfig = sslConfig; + return this; + } + + public Builder readTimeout(Integer value) { + this.readTimeout = value; + return this; + } + + public Builder connectTimeout(Integer value) { + this.connectTimeout = value; + return this; + } + + Builder retryOnConnectionFailure(Boolean value) { + this.retryOnConnectionFailure = value; + return this; + } + + /** + * use socket and overwrite default socket path {@link JschDockerConfig#VAR_RUN_DOCKER_SOCK} + * + * @param socketPath + * @return + */ + public Builder useSocket(String socketPath) { + this.jschDockerConfig.setUseSocket(true); + this.jschDockerConfig.setSocketPath(socketPath); + return this; + } + + /** + * pass {@link Session} if already connected + * + * @param session + * @return + */ + public Builder sshSession(Session session) { + this.jschDockerConfig.setSession(session); + return this; + } + + /** + * set identityFile for public key authentication + * + * @param identityFile + * @return + */ + public Builder identityFile(File identityFile) { + this.jschDockerConfig.setIdentityFile(identityFile); + return this; + } + + /** + * set identityFile from ~/.ssh/ folder for public key authentication + * + * @param privateKey private key filename + * @return + */ + public Builder identity(String privateKey) { + return identityFile(new File(System.getProperty("user.home") + File.separator + ".ssh" + File.separator + privateKey)); + } + + /** + * pass config which is used for {@link Session#setConfig(Hashtable)} + * + * @param jschConfig + * @return + */ + public Builder jschConfig(Hashtable jschConfig) { + this.jschDockerConfig.setJschConfig(jschConfig); + return this; + } + + public Builder interceptor(Interceptor interceptor) { + this.jschDockerConfig.setInterceptor(interceptor); + return this; + } + + public Builder useSocket() { + this.jschDockerConfig.setUseSocket(true); + return this; + } + + public Builder useSocat() { + this.jschDockerConfig.setUseSocat(true); + return this; + } + + /** + * allows to set additional flags for the socat call, i.e. -v + * + * @param socatFlags + * @return + */ + public Builder socatFlags(String socatFlags) { + this.jschDockerConfig.setSocatFlags(socatFlags); + return this; + } + + public Builder useTcp() { + this.jschDockerConfig.setUseTcp(true); + return this; + } + + public Builder useTcp(int port) { + this.jschDockerConfig.setUseTcp(true); + this.jschDockerConfig.setTcpPort(port); + return this; + } + + public JschDockerHttpClient build() throws IOException, JSchException { + Objects.requireNonNull(dockerHost, "dockerHost not provided"); + return new JschDockerHttpClient( + dockerHost, + sslConfig, + readTimeout, + connectTimeout, + retryOnConnectionFailure, + jschDockerConfig); + } + } + + private static final String SOCKET_SUFFIX = ".socket"; + + final OkHttpClient client; + + final OkHttpClient streamingClient; + + private HttpUrl baseUrl; + + private Session session; + private boolean externalSession = false; + + private JschDockerHttpClient( + URI dockerHostUri, + SSLConfig sslConfig, + Integer readTimeout, + Integer connectTimeout, + Boolean retryOnConnectionFailure, + JschDockerConfig jschDockerConfig) throws IOException, JSchException { + + OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder() + .addNetworkInterceptor(new HijackingInterceptor()) + .readTimeout(0, TimeUnit.MILLISECONDS) + .retryOnConnectionFailure(true); + + if (jschDockerConfig.getInterceptor() != null) { + clientBuilder.addInterceptor(jschDockerConfig.getInterceptor()); + } + + if (readTimeout != null) { + clientBuilder.readTimeout(readTimeout, TimeUnit.MILLISECONDS); + } + + if (connectTimeout != null) { + clientBuilder.connectTimeout(connectTimeout, TimeUnit.MILLISECONDS); + } + + if (retryOnConnectionFailure != null) { + clientBuilder.retryOnConnectionFailure(retryOnConnectionFailure); + } + + this.session = jschDockerConfig.getSession(); + this.externalSession = jschDockerConfig.getSession() != null; + + if ("ssh".equals(dockerHostUri.getScheme())) { + + this.session = connectSSH(dockerHostUri, connectTimeout != null ? connectTimeout : 0, jschDockerConfig); + + final JSchSocketFactory socketFactory = new JSchSocketFactory(session, jschDockerConfig); + + clientBuilder.socketFactory(socketFactory); + + clientBuilder + .connectionPool(new ConnectionPool(0, 1, TimeUnit.SECONDS)) + .dns(hostname -> { + if (hostname.endsWith(SOCKET_SUFFIX)) { + return Collections.singletonList(InetAddress.getByAddress(hostname, new byte[]{0, 0, 0, 0})); + } else { + return Dns.SYSTEM.lookup(hostname); + } + }); + } else { + throw new IllegalArgumentException("this implementation only supports ssh connection scheme."); + } + + if (sslConfig != null) { + try { + SSLContext sslContext = sslConfig.getSSLContext(); + if (sslContext != null) { + clientBuilder.sslSocketFactory(sslContext.getSocketFactory(), new TrustAllX509TrustManager()); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + client = clientBuilder.build(); + + streamingClient = client.newBuilder().build(); + + // we always use socketFactory, therefore we only need: + baseUrl = new HttpUrl.Builder().scheme("http").host("127.0.0.1").build(); + } + + private RequestBody toRequestBody(Request request) { + InputStream body = request.body(); + if (body != null) { + return new RequestBody() { + @Override + public MediaType contentType() { + return null; + } + + @Override + public void writeTo(BufferedSink sink) throws IOException { + sink.writeAll(Okio.source(body)); + } + }; + } + if ("POST".equals(request.method())) { + return RequestBody.create(null, ""); + } + return null; + } + + @Override + public Response execute(Request request) { + + String url = baseUrl.toString(); + if (url.endsWith("/") && request.path().startsWith("/")) { + url = url.substring(0, url.length() - 1); + } + okhttp3.Request.Builder requestBuilder = new okhttp3.Request.Builder() + .url(url + request.path()) + .tag(Request.class, request) + .method(request.method(), toRequestBody(request)); + + request.headers().forEach(requestBuilder::header); + + final OkHttpClient clientToUse; + + if (request.hijackedInput() == null) { + clientToUse = client; + } else { + clientToUse = streamingClient; + } + + Call call = clientToUse.newCall(requestBuilder.build()); + try { + return new OkResponse(call); + } catch (IOException e) { + call.cancel(); + throw new UncheckedIOException("Error while executing " + request, e); + } + } + + @Override + public void close() { + try { + disconnectSSH(); + } finally { + for (OkHttpClient clientToClose : new OkHttpClient[]{client, streamingClient}) { + clientToClose.dispatcher().cancelAll(); + clientToClose.dispatcher().executorService().shutdown(); + clientToClose.connectionPool().evictAll(); + } + } + } + + static class OkResponse implements Response { + + static final ThreadLocal CLOSING = ThreadLocal.withInitial(() -> false); + + private final Call call; + + private final okhttp3.Response response; + + OkResponse(Call call) throws IOException { + this.call = call; + this.response = call.execute(); + } + + @Override + public int getStatusCode() { + return response.code(); + } + + @Override + public Map> getHeaders() { + return response.headers().toMultimap(); + } + + @Override + public InputStream getBody() { + ResponseBody body = response.body(); + if (body == null) { + return null; + } + + return body.source().inputStream(); + } + + @Override + public void close() { + boolean previous = CLOSING.get(); + CLOSING.set(true); + try { + call.cancel(); + response.close(); + } catch (Exception | AssertionError e) { + LOGGER.debug("Failed to close the response", e); + } finally { + CLOSING.set(previous); + } + } + } + + static class TrustAllX509TrustManager implements X509TrustManager { + @Override + public void checkClientTrusted(X509Certificate[] x509Certificates, String s) { + throw new UnsupportedOperationException(); + } + + @Override + public void checkServerTrusted(X509Certificate[] x509Certificates, String s) { + throw new UnsupportedOperationException(); + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } + + private Session connectSSH(URI connectionString, int connectTimeout, JschDockerConfig jschDockerConfig) + throws IOException, JSchException { + + if (session != null && session.isConnected()) { + return session; + } + + final JSch jSch = new JSch(); + JSch.setLogger(new JschLogger()); + + final String configFile = System.getProperty("user.home") + File.separator + ".ssh" + File.separator + "config"; + final File file = new File(configFile); + if (file.exists()) { + final OpenSSHConfig openSSHConfig = OpenSSHConfig.parseFile(file.getAbsolutePath()); + jSch.setConfigRepository(openSSHConfig); + } + + final int port = connectionString.getPort() > 0 ? connectionString.getPort() : 22; + final Session newSession = jSch.getSession(connectionString.getUserInfo(), connectionString.getHost(), port); + + // https://stackoverflow.com/questions/10881981/sftp-connection-through-java-asking-for-weird-authentication + newSession.setConfig("PreferredAuthentications", "publickey"); + + if (jschDockerConfig.getJschConfig() != null) { + newSession.setConfig(jschDockerConfig.getJschConfig()); + } + + if (jschDockerConfig.getIdentityFile() != null) { + jSch.addIdentity(jschDockerConfig.getIdentityFile().getAbsolutePath()); + } + + newSession.connect(connectTimeout); + + return newSession; + } + + private void disconnectSSH() { + if (!externalSession) { + session.disconnect(); + } + } +} diff --git a/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/JschLogger.java b/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/JschLogger.java new file mode 100644 index 000000000..0a6c3f72b --- /dev/null +++ b/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/JschLogger.java @@ -0,0 +1,50 @@ +package com.github.dockerjava.jsch; + +import org.slf4j.LoggerFactory; + +public class JschLogger implements com.jcraft.jsch.Logger { + + private static final org.slf4j.Logger LOGGER = LoggerFactory.getLogger(JschLogger.class); + + @Override + public boolean isEnabled(int level) { + switch (level) { + case DEBUG: + return LOGGER.isDebugEnabled() || LOGGER.isTraceEnabled(); + case INFO: + return LOGGER.isDebugEnabled(); + case WARN: + return LOGGER.isWarnEnabled(); + case ERROR: + case FATAL: + return LOGGER.isErrorEnabled(); + default: + throw new IllegalArgumentException("Unknown log level: " + level); + } + } + + @Override + public void log(int level, String message) { + switch (level) { + case DEBUG: + LOGGER.debug(message); + break; + case INFO: + LOGGER.info(message); + break; + case WARN: + LOGGER.warn(message); + break; + case ERROR: + LOGGER.error(message); + break; + case FATAL: + LOGGER.error("FATAL: {}", message); + break; + default: + throw new IllegalArgumentException("Unknown log level: " + level); + } + } + + +} diff --git a/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/JschSocket.java b/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/JschSocket.java new file mode 100644 index 000000000..18d4f9481 --- /dev/null +++ b/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/JschSocket.java @@ -0,0 +1,137 @@ +package com.github.dockerjava.jsch; + +import com.github.dockerjava.api.model.Container; +import com.github.dockerjava.api.model.ContainerPort; +import com.jcraft.jsch.Channel; +import com.jcraft.jsch.ChannelDirectStreamLocal; +import com.jcraft.jsch.ChannelExec; +import com.jcraft.jsch.JSchException; +import com.jcraft.jsch.Session; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.net.SocketAddress; +import java.util.Locale; +import java.util.Objects; + +import static com.github.dockerjava.jsch.JschDockerHttpClient.OkResponse; + +class JschSocket extends Socket { + + private static Logger logger = LoggerFactory.getLogger(JschSocket.class); + + private final JschDockerConfig config; + private final Session session; + + private Channel channel; + private InputStream inputStream; + private OutputStream outputStream; + private Container socatContainer; + + JschSocket(Session session, JschDockerConfig config) { + this.session = session; + this.config = config; + } + + @Override + public void connect(SocketAddress endpoint) throws IOException { + connect(0); + } + + @Override + public void connect(SocketAddress endpoint, int timeout) throws IOException { + connect(timeout); + } + + @Override + public boolean isConnected() { + return channel.isConnected(); + } + + @Override + public boolean isClosed() { + return channel != null && channel.isClosed(); + } + + private void connect(int timeout) throws IOException { + try { + if (config.isUseTcp()) { + final int port = config.getTcpPort() != null ? config.getTcpPort() : 2375; + channel = session.getStreamForwarder("127.0.0.1", port); + logger.debug("Using channel direct-tcpip with 127.0.0.1:{}", port); + } else if (config.isUseSocat() || unixSocketOnWindows()) { + // forward docker socket via socat + socatContainer = SocatHandler.startSocat(session, config.getSocatFlags()); + final ContainerPort containerPort = socatContainer.getPorts()[0]; + Objects.requireNonNull(containerPort); + channel = session.getStreamForwarder(containerPort.getIp(), containerPort.getPublicPort()); + logger.debug("Using channel direct-tcpip with socat on port {}", containerPort.getPublicPort()); + } else if (config.isUseSocket()) { + // directly forward docker socket + channel = session.openChannel("direct-streamlocal@openssh.com"); + ((ChannelDirectStreamLocal) channel).setSocketPath(config.getSocketPath()); + logger.debug("Using channel direct-streamlocal on {}", config.getSocketPath()); + } else { + // only 18.09 and up + channel = session.openChannel("exec"); + ((ChannelExec) channel).setCommand("docker system dial-stdio"); + logger.debug("Using dialer command"); + } + + inputStream = channel.getInputStream(); + outputStream = channel.getOutputStream(); + + channel.connect(timeout); + + } catch (JSchException e) { + throw new IOException(e); + } + } + + @Override + public synchronized void close() throws IOException { + if (socatContainer != null) { + try { + SocatHandler.stopSocat(session, socatContainer.getId()); + } catch (JSchException e) { + throw new IOException(e); + } + } + channel.disconnect(); + } + + @Override + public InputStream getInputStream() { + return new FilterInputStream(inputStream) { + + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (Boolean.TRUE.equals(OkResponse.CLOSING.get())) { + return 0; + } + + return super.read(b, off, len); + + } + }; + } + + @Override + public OutputStream getOutputStream() { + return outputStream; + } + + private boolean unixSocketOnWindows() { + return config.isUseSocket() && config.getSocketPath().equalsIgnoreCase(JschDockerConfig.VAR_RUN_DOCKER_SOCK) && isWindowsHost(); + } + + private boolean isWindowsHost() { + final String serverVersion = session.getServerVersion(); + return serverVersion.toLowerCase(Locale.getDefault()).contains("windows"); + } +} diff --git a/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/SocatHandler.java b/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/SocatHandler.java new file mode 100644 index 000000000..2e1c6f624 --- /dev/null +++ b/docker-java-transport-ssh/src/main/java/com/github/dockerjava/jsch/SocatHandler.java @@ -0,0 +1,125 @@ +package com.github.dockerjava.jsch; + +import com.github.dockerjava.api.model.Container; +import com.github.dockerjava.api.model.ContainerPort; +import com.jcraft.jsch.ChannelExec; +import com.jcraft.jsch.JSchException; +import com.jcraft.jsch.Session; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.nio.charset.Charset; + +public class SocatHandler { + + public static final int INTERNAL_SOCAT_PORT = 2377; + private static Logger logger = LoggerFactory.getLogger(SocatHandler.class); + + private SocatHandler() { + } + + public static Container startSocat(Session session, String socatFlags) throws JSchException, IOException { + + final String command = " docker run -d " + + " -p 127.0.0.1:0:" + INTERNAL_SOCAT_PORT + + " -v /var/run/docker.sock:/var/run/docker.sock " + + " alpine/socat " + socatFlags + + " tcp-listen:" + INTERNAL_SOCAT_PORT + ",fork,reuseaddr unix-connect:/var/run/docker.sock"; + + final String containerId = runCommand(session, command); + + try { + final Container container = new Container(); + final Field id; + id = container.getClass().getDeclaredField("id"); + id.setAccessible(true); + id.set(container, containerId); + + /* + final String inspectionCommand = String.format("docker inspect " + + "--format=\"{{(index (index .NetworkSettings.Ports \\\"" + INTERNAL_SOCAT_PORT + "/tcp\\\") 0).HostPort}}\" %s", + containerId); + final String publishedPort = runCommand(session, inspectionCommand); + */ + + String portCommand = String.format("docker port %s", containerId); + final String portResult = runCommand(session, portCommand).trim(); + final String publishedPort = portResult.substring(portResult.lastIndexOf(':') + 1); + + final Field ports = container.getClass().getField("ports"); + final ContainerPort containerPort = new ContainerPort() + .withIp("127.0.0.1") + .withPrivatePort(INTERNAL_SOCAT_PORT) + .withPublicPort(Integer.valueOf(publishedPort.trim())); + ports.set(container, new ContainerPort[]{containerPort}); + return container; + + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + + } + + private static String runCommand(Session session, String command) throws JSchException, IOException { + + ChannelExec channel = (ChannelExec) session.openChannel("exec"); + try { + channel.setCommand(command); + logger.debug("running command: {}", command); + + final InputStream in = channel.getInputStream(); + final InputStream errStream = channel.getErrStream(); + + channel.connect(); + + StringBuilder outputBuffer = new StringBuilder(); + StringBuilder errorBuffer = new StringBuilder(); + + byte[] tmp = new byte[1024]; + while (true) { + + while (in.available() > 0) { + int i = in.read(tmp, 0, 1024); + if (i < 0) break; + outputBuffer.append(new String(tmp, 0, i, Charset.defaultCharset())); + } + + while (errStream.available() > 0) { + int i = errStream.read(tmp, 0, 1024); + if (i < 0) break; + errorBuffer.append(new String(tmp, Charset.defaultCharset())); + } + + if (channel.isClosed()) { + // https://stackoverflow.com/a/47554723/2290153 + if ((in.available() > 0) || (errStream.available() > 0)) continue; + + logger.debug("exit-status: {}", channel.getExitStatus()); + logger.debug("stderr:{}", errorBuffer); + logger.debug("stdout: {}", outputBuffer); + + if (channel.getExitStatus() == 0) { + return outputBuffer.toString(); + } else { + throw new RuntimeException("command ended in exit-status:" + channel.getExitStatus() + + " with error message: " + errorBuffer.toString()); + } + } + Thread.sleep(50); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } finally { + channel.disconnect(); + } + } + + public static void stopSocat(Session session, String containerId) throws JSchException, IOException { + final String command = " docker stop " + containerId; + runCommand(session, command); + } +} diff --git a/docker-java-transport-ssh/src/test/java/com/github/dockerjava/jsch/JschDockerHttpClientIT.java b/docker-java-transport-ssh/src/test/java/com/github/dockerjava/jsch/JschDockerHttpClientIT.java new file mode 100644 index 000000000..7045fbab3 --- /dev/null +++ b/docker-java-transport-ssh/src/test/java/com/github/dockerjava/jsch/JschDockerHttpClientIT.java @@ -0,0 +1,63 @@ +package com.github.dockerjava.jsch; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.core.DefaultDockerClientConfig; +import com.github.dockerjava.core.DockerClientImpl; +import com.github.dockerjava.transport.DockerHttpClient; +import com.jcraft.jsch.JSchException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +@EnabledIfEnvironmentVariable(named = "DOCKER_HOST", matches = "ssh://.*") +class JschDockerHttpClientIT { + + @Test + void pingViaDialer() throws IOException, JSchException { + + final DefaultDockerClientConfig dockerClientConfig = DefaultDockerClientConfig.createDefaultConfigBuilder().build(); + + try (final DockerHttpClient dockerHttpClient = new JschDockerHttpClient.Builder() + .dockerHost(dockerClientConfig.getDockerHost()).build()) { + + final DockerClient dockerClient = DockerClientImpl.getInstance(dockerClientConfig, dockerHttpClient); + + assertDoesNotThrow(() -> dockerClient.pingCmd().exec()); + } + } + + @Test + void pingViaSocket() throws IOException, JSchException { + + final DefaultDockerClientConfig dockerClientConfig = DefaultDockerClientConfig.createDefaultConfigBuilder().build(); + + try (final DockerHttpClient dockerHttpClient = new JschDockerHttpClient.Builder() + .useSocket() + .dockerHost(dockerClientConfig.getDockerHost()) + .build()) { + + final DockerClient dockerClient = DockerClientImpl.getInstance(dockerClientConfig, dockerHttpClient); + + assertDoesNotThrow(() -> dockerClient.pingCmd().exec()); + } + } + + @Test + void pingViaSocat() throws IOException, JSchException { + + final DefaultDockerClientConfig dockerClientConfig = DefaultDockerClientConfig.createDefaultConfigBuilder().build(); + + try (final DockerHttpClient dockerHttpClient = new JschDockerHttpClient.Builder() + .useSocat() + .dockerHost(dockerClientConfig.getDockerHost()) + .build()) { + + final DockerClient dockerClient = DockerClientImpl.getInstance(dockerClientConfig, dockerHttpClient); + + assertDoesNotThrow(() -> dockerClient.pingCmd().exec()); + } + } +} diff --git a/docker-java-transport-ssh/src/test/java/com/github/dockerjava/jsch/SocatHandlerIT.java b/docker-java-transport-ssh/src/test/java/com/github/dockerjava/jsch/SocatHandlerIT.java new file mode 100644 index 000000000..e428dff45 --- /dev/null +++ b/docker-java-transport-ssh/src/test/java/com/github/dockerjava/jsch/SocatHandlerIT.java @@ -0,0 +1,100 @@ +package com.github.dockerjava.jsch; + +import com.github.dockerjava.api.model.Container; +import com.jcraft.jsch.Channel; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.JSchException; +import com.jcraft.jsch.OpenSSHConfig; +import com.jcraft.jsch.Session; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * This test relies on ~/.ssh/config which needs an entry for Host setup in the env variable DOCKER_HOST + *

+ * config could look like: + *

+ *

+ * Host junit-host
+ * HostName foo
+ * StrictHostKeyChecking no
+ * User bar
+ * IdentityFile ~/.ssh/some_private_key
+ * PreferredAuthentications publickey
+ * 
+ */ +@EnabledIfEnvironmentVariable(named = "DOCKER_HOST", matches = "ssh://.*") +class SocatHandlerIT { + + private static Session session; + private Container container; + + @BeforeAll + static void init() throws JSchException, IOException, URISyntaxException { + final JSch jSch = new JSch(); + JSch.setLogger(new JschLogger()); + final String configFile = System.getProperty("user.home") + File.separator + ".ssh" + File.separator + "config"; + final File file = new File(configFile); + if (file.exists()) { + final OpenSSHConfig openSSHConfig = OpenSSHConfig.parseFile(file.getAbsolutePath()); + jSch.setConfigRepository(openSSHConfig); + } + session = jSch.getSession(new URI(System.getenv("DOCKER_HOST")).getHost()); + session.connect(500); + } + + @AfterAll + static void close() { + session.disconnect(); + } + + @AfterEach + void stopSocat() throws IOException, JSchException { + if (container != null) { + SocatHandler.stopSocat(session, container.getId()); + } + } + + @org.junit.jupiter.api.Test + @Timeout(value = 20) + void startSocatAndPing() throws IOException, JSchException { + container = SocatHandler.startSocat(session, ""); + assertNotNull(container); + assertEquals("200", ping(container)); + } + + private String ping(Container container) throws JSchException, IOException { + final Channel streamForwarder = session.getStreamForwarder(container.getPorts()[0].getIp(), container.getPorts()[0].getPublicPort()); + streamForwarder.connect(100); + String cmd = "GET /_ping HTTP/1.0\r\n\r\n"; + final PrintWriter printWriter = new PrintWriter(streamForwarder.getOutputStream()); + printWriter.println(cmd); + printWriter.flush(); + BufferedReader reader = new BufferedReader(new InputStreamReader(streamForwarder.getInputStream())); + for (String line; (line = reader.readLine()) != null; ) { + final Matcher matcher = Pattern.compile("HTTP/\\d\\.\\d (\\d+) \\w+").matcher(line); + if (matcher.find()) { + return matcher.group(1); + } + } + fail("could not find response code"); + return null; + } +} diff --git a/docker-java-transport-ssh/src/test/resources/Dockerfile b/docker-java-transport-ssh/src/test/resources/Dockerfile new file mode 100644 index 000000000..36f155a78 --- /dev/null +++ b/docker-java-transport-ssh/src/test/resources/Dockerfile @@ -0,0 +1,5 @@ +FROM busybox +RUN mkdir /files +WORKDIR /files +ADD simplelogger.properties simplelogger.properties +ADD dummy.txt aLargeFile.txt diff --git a/docker-java-transport-ssh/src/test/resources/createDummyFile.bat b/docker-java-transport-ssh/src/test/resources/createDummyFile.bat new file mode 100644 index 000000000..4e06975ae --- /dev/null +++ b/docker-java-transport-ssh/src/test/resources/createDummyFile.bat @@ -0,0 +1,6 @@ +@echo off +Setlocal EnableDelayedExpansion +echo %random% > %1 + +for /l %%i in (1,1,1000) do echo !random! >> %1 + diff --git a/docker-java-transport-ssh/src/test/resources/simplelogger.properties b/docker-java-transport-ssh/src/test/resources/simplelogger.properties new file mode 100644 index 000000000..16e5516e3 --- /dev/null +++ b/docker-java-transport-ssh/src/test/resources/simplelogger.properties @@ -0,0 +1,7 @@ +# SLF4J's SimpleLogger configuration file +# Simple implementation of Logger that sends all enabled log messages, for all defined loggers, to System.err. + +# Default logging detail level for all instances of SimpleLogger. +# Must be one of ("trace", "debug", "info", "warn", or "error"). +# If not specified, defaults to "info". +org.slf4j.simpleLogger.defaultLogLevel=debug diff --git a/docker-java/pom.xml b/docker-java/pom.xml index 578bee865..706d93446 100644 --- a/docker-java/pom.xml +++ b/docker-java/pom.xml @@ -51,6 +51,13 @@ ${project.version} test + + ${project.groupId} + docker-java-transport-ssh + ${project.version} + test + + ch.qos.logback logback-core diff --git a/docker-java/src/test/java/com/github/dockerjava/cmd/BuildImageCmdIT.java b/docker-java/src/test/java/com/github/dockerjava/cmd/BuildImageCmdIT.java index cd9b1ef9c..5ca3ac178 100644 --- a/docker-java/src/test/java/com/github/dockerjava/cmd/BuildImageCmdIT.java +++ b/docker-java/src/test/java/com/github/dockerjava/cmd/BuildImageCmdIT.java @@ -11,7 +11,6 @@ import net.jcip.annotations.NotThreadSafe; import org.apache.commons.io.FileUtils; import org.apache.commons.io.filefilter.TrueFileFilter; -import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -38,9 +37,9 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.emptyString; import static org.hamcrest.Matchers.endsWith; import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.emptyString; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.nullValue; import static org.junit.Assume.assumeThat; @@ -52,8 +51,8 @@ public class BuildImageCmdIT extends CmdIT { public static final Logger LOG = LoggerFactory.getLogger(BuildImageCmd.class); - @ClassRule - public static PrivateRegistryRule REGISTRY = new PrivateRegistryRule(); + @Rule + public PrivateRegistryRule REGISTRY = new PrivateRegistryRule(this); @Rule public TemporaryFolder folder = new TemporaryFolder(new File("target/")); diff --git a/docker-java/src/test/java/com/github/dockerjava/cmd/CmdIT.java b/docker-java/src/test/java/com/github/dockerjava/cmd/CmdIT.java index 12664c4e5..1c7e34101 100644 --- a/docker-java/src/test/java/com/github/dockerjava/cmd/CmdIT.java +++ b/docker-java/src/test/java/com/github/dockerjava/cmd/CmdIT.java @@ -14,7 +14,8 @@ import org.junit.runner.RunWith; import org.junit.runners.Parameterized; -import java.util.Arrays; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** * @author Kanstantsin Shautsou @@ -22,8 +23,17 @@ @Category(Integration.class) @RunWith(Parameterized.class) public abstract class CmdIT { + public enum FactoryType { - NETTY(true) { + SSH(true, true) { + @Override + public DockerClientImpl createDockerClient(DockerClientConfig config) { + return (DockerClientImpl) new SSHClientFactory() + .withDockerClientConfig(config) + .build(); + } + }, + NETTY(true, false) { @Override public DockerClientImpl createDockerClient(DockerClientConfig config) { return (DockerClientImpl) DockerClientBuilder.getInstance(config) @@ -34,7 +44,7 @@ public DockerClientImpl createDockerClient(DockerClientConfig config) { .build(); } }, - JERSEY(false) { + JERSEY(false, false) { @Override public DockerClientImpl createDockerClient(DockerClientConfig config) { return (DockerClientImpl) DockerClientBuilder.getInstance(config) @@ -50,7 +60,7 @@ public DockerClientImpl createDockerClient(DockerClientConfig config) { .build(); } }, - OKHTTP(true) { + OKHTTP(true, false) { @Override public DockerClientImpl createDockerClient(DockerClientConfig config) { return (DockerClientImpl) DockerClientBuilder.getInstance(config) @@ -66,7 +76,7 @@ public DockerClientImpl createDockerClient(DockerClientConfig config) { .build(); } }, - HTTPCLIENT5(true) { + HTTPCLIENT5(true, false) { @Override public DockerClientImpl createDockerClient(DockerClientConfig config) { return (DockerClientImpl) DockerClientBuilder.getInstance(config) @@ -84,10 +94,12 @@ public DockerClientImpl createDockerClient(DockerClientConfig config) { private final String subnetPrefix; private final boolean supportsStdinAttach; + private final boolean supportsSSH; - FactoryType(boolean supportsStdinAttach) { + FactoryType(boolean supportsStdinAttach, boolean supportsSSH) { this.subnetPrefix = "10." + (100 + ordinal()) + "."; this.supportsStdinAttach = supportsStdinAttach; + this.supportsSSH = supportsSSH; } public String getSubnetPrefix() { @@ -103,7 +115,11 @@ public boolean supportsStdinAttach() { @Parameterized.Parameters(name = "{index}:{0}") public static Iterable data() { - return Arrays.asList(FactoryType.values()); + if (System.getenv("DOCKER_HOST").matches("ssh://.*")) { + return Stream.of(FactoryType.values()).filter(f -> f.supportsSSH).collect(Collectors.toList()); + } else { + return Stream.of(FactoryType.values()).filter(f -> !f.supportsSSH).collect(Collectors.toList()); + } } @Parameterized.Parameter @@ -114,7 +130,7 @@ public FactoryType getFactoryType() { } @Rule - public DockerRule dockerRule = new DockerRule( this); + public DockerRule dockerRule = new DockerRule(this); @Rule public DockerHttpClientLeakDetector leakDetector = new DockerHttpClientLeakDetector(); diff --git a/docker-java/src/test/java/com/github/dockerjava/cmd/CreateContainerCmdIT.java b/docker-java/src/test/java/com/github/dockerjava/cmd/CreateContainerCmdIT.java index 365b56275..8138e1025 100644 --- a/docker-java/src/test/java/com/github/dockerjava/cmd/CreateContainerCmdIT.java +++ b/docker-java/src/test/java/com/github/dockerjava/cmd/CreateContainerCmdIT.java @@ -30,7 +30,6 @@ import com.github.dockerjava.utils.TestUtils; import net.jcip.annotations.NotThreadSafe; import org.apache.commons.io.FileUtils; -import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; @@ -50,23 +49,23 @@ import static com.github.dockerjava.api.model.Capability.MKNOD; import static com.github.dockerjava.api.model.Capability.NET_ADMIN; import static com.github.dockerjava.api.model.HostConfig.newHostConfig; +import static com.github.dockerjava.core.DockerRule.DEFAULT_IMAGE; import static com.github.dockerjava.core.RemoteApiVersion.VERSION_1_23; import static com.github.dockerjava.core.RemoteApiVersion.VERSION_1_24; import static com.github.dockerjava.junit.DockerMatchers.isGreaterOrEqual; import static com.github.dockerjava.junit.DockerMatchers.mountedVolumes; -import static com.github.dockerjava.core.DockerRule.DEFAULT_IMAGE; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.emptyString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasEntry; import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.hasItemInArray; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.emptyString; import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.startsWith; @@ -78,8 +77,8 @@ public class CreateContainerCmdIT extends CmdIT { public static final Logger LOG = LoggerFactory.getLogger(CreateContainerCmdIT.class); - @ClassRule - public static PrivateRegistryRule REGISTRY = new PrivateRegistryRule(); + @Rule + public PrivateRegistryRule REGISTRY = new PrivateRegistryRule(this); @Rule public TemporaryFolder tempDir = new TemporaryFolder(new File("target/")); diff --git a/docker-java/src/test/java/com/github/dockerjava/cmd/PullImageCmdIT.java b/docker-java/src/test/java/com/github/dockerjava/cmd/PullImageCmdIT.java index 539a2b606..d6ae16fd9 100644 --- a/docker-java/src/test/java/com/github/dockerjava/cmd/PullImageCmdIT.java +++ b/docker-java/src/test/java/com/github/dockerjava/cmd/PullImageCmdIT.java @@ -8,7 +8,6 @@ import com.github.dockerjava.api.model.Info; import com.github.dockerjava.core.RemoteApiVersion; import com.github.dockerjava.junit.PrivateRegistryRule; -import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; @@ -26,8 +25,8 @@ public class PullImageCmdIT extends CmdIT { private static final Logger LOG = LoggerFactory.getLogger(PullImageCmdIT.class); - @ClassRule - public static PrivateRegistryRule REGISTRY = new PrivateRegistryRule(); + @Rule + public PrivateRegistryRule REGISTRY = new PrivateRegistryRule(this); @Rule public ExpectedException exception = ExpectedException.none(); diff --git a/docker-java/src/test/java/com/github/dockerjava/cmd/PushImageCmdIT.java b/docker-java/src/test/java/com/github/dockerjava/cmd/PushImageCmdIT.java index 7f55d5f9e..535ce8ea4 100644 --- a/docker-java/src/test/java/com/github/dockerjava/cmd/PushImageCmdIT.java +++ b/docker-java/src/test/java/com/github/dockerjava/cmd/PushImageCmdIT.java @@ -7,7 +7,6 @@ import com.github.dockerjava.core.RemoteApiVersion; import com.github.dockerjava.junit.PrivateRegistryRule; import org.junit.Before; -import org.junit.ClassRule; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; @@ -28,8 +27,8 @@ public class PushImageCmdIT extends CmdIT { public static final Logger LOG = LoggerFactory.getLogger(PushImageCmdIT.class); - @ClassRule - public static PrivateRegistryRule REGISTRY = new PrivateRegistryRule(); + @Rule + public PrivateRegistryRule REGISTRY = new PrivateRegistryRule(this); @Rule public ExpectedException exception = ExpectedException.none(); diff --git a/docker-java/src/test/java/com/github/dockerjava/cmd/SSHClientFactory.java b/docker-java/src/test/java/com/github/dockerjava/cmd/SSHClientFactory.java new file mode 100644 index 000000000..4809f47b4 --- /dev/null +++ b/docker-java/src/test/java/com/github/dockerjava/cmd/SSHClientFactory.java @@ -0,0 +1,73 @@ +package com.github.dockerjava.cmd; + +import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.core.DefaultDockerClientConfig; +import com.github.dockerjava.core.DockerClientBuilder; +import com.github.dockerjava.core.DockerClientConfig; +import com.github.dockerjava.core.DockerClientConfigAware; +import com.github.dockerjava.jsch.JschDockerHttpClient; +import com.github.dockerjava.transport.DockerHttpClient; +import com.jcraft.jsch.JSchException; + +import java.io.IOException; + +public class SSHClientFactory implements DockerClientConfigAware { + + private DockerHttpClient httpClient; + + @Override + public void init(DockerClientConfig dockerClientConfig) { + + final JschDockerHttpClient.Builder builder = new JschDockerHttpClient.Builder() + .connectTimeout(30 * 1000) + .dockerHost(dockerClientConfig.getDockerHost()); + + final DefaultDockerClientConfig defaultDockerClientConfig = DefaultDockerClientConfig + .createDefaultConfigBuilder() + .withRegistryUrl(dockerClientConfig.getRegistryUrl()) + .build(); + + if (!"ssh".equalsIgnoreCase(defaultDockerClientConfig.getDockerHost().getScheme())) { + throw new RuntimeException("This FactoryType is supposed to test ssh connections."); + } + + if (!dockerClientConfig.getDockerHost().equals(defaultDockerClientConfig.getDockerHost())) { + // Docker Host was overwritten i.e. from com.github.dockerjava.cmd.swarm.SwarmCmdIT.initializeDockerClient + // so we still use ssh connection and use inner binding to tcp + if ("tcp".equalsIgnoreCase(dockerClientConfig.getDockerHost().getScheme())) { + builder + .dockerHost(defaultDockerClientConfig.getDockerHost()) + .useTcp(dockerClientConfig.getDockerHost().getPort()); + } + } + + try { + httpClient = builder.build(); + } catch (IOException | JSchException e) { + throw new RuntimeException(e); + } + } + + SSHClientFactory withDockerClientConfig(DockerClientConfig config) { + init(config); + return this; + } + + public DockerClient build() { + + final DefaultDockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder() + .withRegistryUrl("https://index.docker.io/v1/") + .build(); + + if (httpClient == null) { + init(config); + } + + return DockerClientBuilder.getInstance() + .withDockerHttpClient( + new TrackingDockerHttpClient( + httpClient + ) + ).build(); + } +} diff --git a/docker-java/src/test/java/com/github/dockerjava/cmd/swarm/CreateServiceCmdExecIT.java b/docker-java/src/test/java/com/github/dockerjava/cmd/swarm/CreateServiceCmdExecIT.java index e50f35dd3..056f49284 100644 --- a/docker-java/src/test/java/com/github/dockerjava/cmd/swarm/CreateServiceCmdExecIT.java +++ b/docker-java/src/test/java/com/github/dockerjava/cmd/swarm/CreateServiceCmdExecIT.java @@ -22,7 +22,6 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.Lists; import org.junit.Before; -import org.junit.ClassRule; import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; @@ -43,8 +42,8 @@ public class CreateServiceCmdExecIT extends SwarmCmdIT { public static final Logger LOG = LoggerFactory.getLogger(CreateServiceCmdExecIT.class); private static final String SERVICE_NAME = "theservice"; - @ClassRule - public static PrivateRegistryRule REGISTRY = new PrivateRegistryRule(); + @Rule + public PrivateRegistryRule REGISTRY = new PrivateRegistryRule(this); @Rule public ExpectedException exception = ExpectedException.none(); diff --git a/docker-java/src/test/java/com/github/dockerjava/core/command/DockerfileFixture.java b/docker-java/src/test/java/com/github/dockerjava/core/command/DockerfileFixture.java index 913d1758b..aa86c80dc 100644 --- a/docker-java/src/test/java/com/github/dockerjava/core/command/DockerfileFixture.java +++ b/docker-java/src/test/java/com/github/dockerjava/core/command/DockerfileFixture.java @@ -39,7 +39,7 @@ public void open() throws Exception { Image lastCreatedImage = client.listImagesCmd().exec().get(0); - repository = lastCreatedImage.getRepoTags()[0]; + repository = lastCreatedImage.getId(); LOGGER.info("created {} {}", lastCreatedImage.getId(), repository); diff --git a/docker-java/src/test/java/com/github/dockerjava/core/command/FrameReaderITest.java b/docker-java/src/test/java/com/github/dockerjava/core/command/FrameReaderITest.java index 16c456164..b3268ebd2 100644 --- a/docker-java/src/test/java/com/github/dockerjava/core/command/FrameReaderITest.java +++ b/docker-java/src/test/java/com/github/dockerjava/core/command/FrameReaderITest.java @@ -4,6 +4,8 @@ import com.github.dockerjava.api.async.ResultCallback; import com.github.dockerjava.api.model.Frame; import com.github.dockerjava.api.model.StreamType; +import com.github.dockerjava.cmd.SSHClientFactory; +import com.github.dockerjava.core.DefaultDockerClientConfig; import com.github.dockerjava.core.DockerClientBuilder; import com.github.dockerjava.junit.category.Integration; import org.junit.After; @@ -30,11 +32,18 @@ public class FrameReaderITest { @Before public void beforeTest() throws Exception { - dockerClient = DockerClientBuilder.getInstance().build(); + dockerClient = getDockerClient(); dockerfileFixture = new DockerfileFixture(dockerClient, "frameReaderDockerfile"); dockerfileFixture.open(); } + private DockerClient getDockerClient() { + if ("ssh".equalsIgnoreCase(DefaultDockerClientConfig.createDefaultConfigBuilder().build().getDockerHost().getScheme())) { + return new SSHClientFactory().build(); + } + return DockerClientBuilder.getInstance().build(); + } + @After public void deleteDockerContainerImage() throws Exception { dockerfileFixture.close(); @@ -47,13 +56,13 @@ public void canCloseFrameReaderAndReadExpectedLines() throws Exception { // wait for the container to be successfully executed int exitCode = dockerClient.waitContainerCmd(dockerfileFixture.getContainerId()) - .start().awaitStatusCode(); + .start().awaitStatusCode(); assertEquals(0, exitCode); final List loggingFrames = getLoggingFrames(); final Frame outFrame = new Frame(StreamType.STDOUT, "to stdout\n".getBytes()); final Frame errFrame = new Frame(StreamType.STDERR, "to stderr\n".getBytes()); - + assertThat(loggingFrames, containsInAnyOrder(outFrame, errFrame)); assertThat(loggingFrames, hasSize(2)); } @@ -63,10 +72,10 @@ private List getLoggingFrames() throws Exception { FrameReaderITestCallback collectFramesCallback = new FrameReaderITestCallback(); dockerClient.logContainerCmd(dockerfileFixture.getContainerId()).withStdOut(true).withStdErr(true) - .withTailAll() - // we can't follow stream here as it blocks reading from resulting InputStream infinitely - // .withFollowStream() - .exec(collectFramesCallback).awaitCompletion(); + .withTailAll() + // we can't follow stream here as it blocks reading from resulting InputStream infinitely + // .withFollowStream() + .exec(collectFramesCallback).awaitCompletion(); return collectFramesCallback.frames; } diff --git a/docker-java/src/test/java/com/github/dockerjava/junit/PrivateRegistryRule.java b/docker-java/src/test/java/com/github/dockerjava/junit/PrivateRegistryRule.java index 7aae924f9..60131ea86 100644 --- a/docker-java/src/test/java/com/github/dockerjava/junit/PrivateRegistryRule.java +++ b/docker-java/src/test/java/com/github/dockerjava/junit/PrivateRegistryRule.java @@ -7,8 +7,9 @@ import com.github.dockerjava.api.model.ExposedPort; import com.github.dockerjava.api.model.PortBinding; import com.github.dockerjava.api.model.Ports; +import com.github.dockerjava.cmd.CmdIT; +import com.github.dockerjava.core.DefaultDockerClientConfig; import com.github.dockerjava.core.DockerRule; -import com.github.dockerjava.core.DockerClientBuilder; import org.junit.rules.ExternalResource; import java.io.File; @@ -22,14 +23,15 @@ public class PrivateRegistryRule extends ExternalResource { - private final DockerClient dockerClient; + private final CmdIT testInstance; + private DockerClient dockerClient; private AuthConfig authConfig; private String containerId; - public PrivateRegistryRule() { - this.dockerClient = DockerClientBuilder.getInstance().build(); + public PrivateRegistryRule(CmdIT test) { + this.testInstance = test; } public AuthConfig getAuthConfig() { @@ -40,9 +42,9 @@ public String createPrivateImage(String tagName) throws InterruptedException { String imgNameWithTag = createTestImage(tagName); dockerClient.pushImageCmd(imgNameWithTag) - .withAuthConfig(authConfig) - .start() - .awaitCompletion(30, TimeUnit.SECONDS); + .withAuthConfig(authConfig) + .start() + .awaitCompletion(30, TimeUnit.SECONDS); dockerClient.removeImageCmd(imgNameWithTag).exec(); @@ -66,6 +68,8 @@ public String createTestImage(String tagName) { @Override protected void before() throws Throwable { + this.dockerClient = testInstance.getFactoryType().createDockerClient(DefaultDockerClientConfig.createDefaultConfigBuilder().build()); + int port = 5050; String imageName = "private-registry-image"; @@ -73,26 +77,26 @@ protected void before() throws Throwable { File baseDir = new File(DockerRule.class.getResource("/privateRegistry").getFile()); String registryImageId = dockerClient.buildImageCmd(baseDir) - .withNoCache(true) - .start() - .awaitImageId(); + .withNoCache(true) + .start() + .awaitImageId(); InspectImageResponse inspectImageResponse = dockerClient.inspectImageCmd(registryImageId).exec(); assertThat(inspectImageResponse, not(nullValue())); DockerRule.LOG.info("Image Inspect: {}", inspectImageResponse.toString()); dockerClient.tagImageCmd(registryImageId, imageName, "2") - .withForce().exec(); + .withForce().exec(); // see https://github.com/docker/distribution/blob/master/docs/deploying.md#native-basic-auth CreateContainerResponse testregistry = dockerClient - .createContainerCmd(imageName + ":2") - .withHostConfig(newHostConfig() - .withPortBindings(new PortBinding(Ports.Binding.bindPort(port), ExposedPort.tcp(5000)))) - .withEnv("REGISTRY_AUTH=htpasswd", "REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm", - "REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd", "REGISTRY_LOG_LEVEL=debug", - "REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt", "REGISTRY_HTTP_TLS_KEY=/certs/domain.key") - .exec(); + .createContainerCmd(imageName + ":2") + .withHostConfig(newHostConfig() + .withPortBindings(new PortBinding(Ports.Binding.bindPort(port), ExposedPort.tcp(5000)))) + .withEnv("REGISTRY_AUTH=htpasswd", "REGISTRY_AUTH_HTPASSWD_REALM=Registry Realm", + "REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd", "REGISTRY_LOG_LEVEL=debug", + "REGISTRY_HTTP_TLS_CERTIFICATE=/certs/domain.crt", "REGISTRY_HTTP_TLS_KEY=/certs/domain.key") + .exec(); containerId = testregistry.getId(); dockerClient.startContainerCmd(containerId).exec(); @@ -102,18 +106,18 @@ protected void before() throws Throwable { // credentials as configured in /auth/htpasswd authConfig = new AuthConfig() - .withUsername("testuser") - .withPassword("testpassword") - .withRegistryAddress("localhost:" + port); + .withUsername("testuser") + .withPassword("testpassword") + .withRegistryAddress("localhost:" + port); } @Override protected void after() { if (containerId != null) { dockerClient.removeContainerCmd(containerId) - .withForce(true) - .withRemoveVolumes(true) - .exec(); + .withForce(true) + .withRemoveVolumes(true) + .exec(); } } } diff --git a/docker-java/src/test/resources/logback.xml b/docker-java/src/test/resources/logback.xml index b4309b868..d2c2090ad 100644 --- a/docker-java/src/test/resources/logback.xml +++ b/docker-java/src/test/resources/logback.xml @@ -9,6 +9,7 @@ + @@ -16,4 +17,4 @@ - \ No newline at end of file + diff --git a/pom.xml b/pom.xml index 57809069a..1e90aed05 100644 --- a/pom.xml +++ b/pom.xml @@ -100,6 +100,7 @@ docker-java-transport-okhttp docker-java-transport-httpclient5 docker-java-transport-zerodep + docker-java-transport-ssh docker-java