Skip to content

[Feature Request] SSH Docker connection support #999

@Frederick888

Description

@Frederick888

I currently connect to a remote Docker server to run Testcontainers over plain HTTP (with env var DOCKER_HOST=tcp://docker.lan:2375).

Docker is going to remove this option and users either have to use HTTPS or SSH. Since I only do this within my home network, (properly) manage a TLS certificate chain seems to be an overkill and it doesn't offer much security benefit either as long as I still run Docker in root-ful mode. So SSH is a better option for me.

However DOCKER_HOST=ssh://[email protected] did not work out of the box. Thankfully dockerode already supports SSH and it wasn't too much hassle to get it running:

diff --git a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts
index 900c6af..e3c68f1 100644
--- a/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts
+++ b/packages/testcontainers/src/container-runtime/clients/container/docker-container-client.ts
@@ -176,9 +176,11 @@ export class DockerContainerClient implements ContainerClient {
         since: opts?.since ?? 0,
       })
       .then(async (stream) => {
         const actualLogStream = stream as IncomingMessage;
-        actualLogStream.socket?.unref();
+        if (typeof actualLogStream.socket?.unref === "function") {
+          actualLogStream.socket.unref();
+        }
 
         const demuxedStream = await this.demuxStream(container.id, actualLogStream);
         demuxedStream.pipe(proxyStream);
         demuxedStream.on("error", (err) => proxyStream.emit("error", err));
diff --git a/packages/testcontainers/src/container-runtime/strategies/configuration-strategy.ts b/packages/testcontainers/src/container-runtime/strategies/configuration-strategy.ts
index 874a313..2b6f1a0 100644
--- a/packages/testcontainers/src/container-runtime/strategies/configuration-strategy.ts
+++ b/packages/testcontainers/src/container-runtime/strategies/configuration-strategy.ts
@@ -27,12 +27,20 @@ export class ConfigurationStrategy implements ContainerRuntimeClientStrategy {
     this.dockerCertPath = dockerCertPath;
 
     const dockerOptions: DockerOptions = {};
 
-    const { pathname, hostname, port } = new URL(this.dockerHost);
+    const { protocol, pathname, hostname, port } = new URL(this.dockerHost);
     if (hostname !== "") {
+      dockerOptions.protocol = protocol === "ssh:" ? "ssh" : undefined;
       dockerOptions.host = hostname;
       dockerOptions.port = port;
+      if (dockerOptions.protocol === "ssh") {
+        const key = await fs.readFile("/path/to/key");
+        dockerOptions.sshOptions = {
+          privateKey: key,
+          keepaliveInterval: 0,
+        };
+      }
     } else {
       dockerOptions.socketPath = pathname;
     }
 
diff --git a/packages/testcontainers/src/container-runtime/utils/resolve-host.ts b/packages/testcontainers/src/container-runtime/utils/resolve-host.ts
index 2dc18e6..da148b4 100644
--- a/packages/testcontainers/src/container-runtime/utils/resolve-host.ts
+++ b/packages/testcontainers/src/container-runtime/utils/resolve-host.ts
@@ -22,8 +22,9 @@ export const resolveHost = async (
   switch (protocol) {
     case "http:":
     case "https:":
     case "tcp:":
+    case "ssh:":
       return hostname;
     case "unix:":
     case "npipe:": {
       if (isInContainer()) {
diff --git a/packages/testcontainers/src/generic-container/started-generic-container.ts b/packages/testcontainers/src/generic-container/started-generic-container.ts
index 7bb052d..2e4d014 100644
--- a/packages/testcontainers/src/generic-container/started-generic-container.ts
+++ b/packages/testcontainers/src/generic-container/started-generic-container.ts
@@ -116,8 +116,13 @@ export class StartedGenericContainer implements StartedTestContainer {
     if (this.containerIsStopped) {
       await this.containerIsStopped();
     }
 
+    // @ts-expect-error yeah
+    log.info("active res: " + JSON.stringify(process.getActiveResourcesInfo()));
+    // @ts-expect-error yeah
+    log.info("active handles: " + JSON.stringify(process._getActiveHandles()));
+
     return new StoppedGenericContainer(this.container);
   }
 
   public getHost(): string {

However since the ssh2 Channel does not offer an unref() method, now my tests won't exit cleanly due to dangling TCP connections. Then I found mscdex/ssh2#217 and tried patching docker-modem with

diff --git a/lib/ssh.js b/lib/ssh.js
index c4a14f7..025fbbe 100644
--- a/lib/ssh.js
+++ b/lib/ssh.js
@@ -7,8 +7,9 @@ module.exports = function (opt) {
 
   agent.createConnection = function (options, fn) {
     try {
       conn.once('ready', function () {
+        conn._sock.unref()
         conn.exec('docker system dial-stdio', function (err, stream) {
           if (err) {
             handleError(err , fn);
           }

...but this actually broke the setup where it exited even before tests started.

I'm not familiar with the code base here. Should I try closing dockerode somewhere else?

Btw apart from basic SSH support, I'm also interested in offering options to bypass SSH agent, specifying SSH key path, etc., as I'd like to use a different user with a different key file for all Docker connections (my regular user's on GPG agent with a YubiKey).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions