Docker via Go SSH

11/16/20247:11:51 AM

Recently I've been working on a small project for my boyfriend which is a very simple minecraft server hosting portal that utilizes docker for actually running minecraft instances. I wanted to support remote hosts since if I run servers for myself I'd want them to run locally, but everything else should run on my host in FMT1. To support this I needed a system that could access the Docker Engine API remotely. Given that most of the Docker Engine tooling is written in Go we can just use the native client library. For remote access the straight-forward approach would be to implement a client/server model. But I hate running extra services and this seemed like a case where I could build something simple and fancy.

Docker Engine API

The first option I tested was enabling the Docker Engine API via this systemd override (applied via sudo -E systemctl edit docker.service):

[Service]
ExecStart=
ExecStart=/usr/bin/dockerd -H tcp://127.0.0.1:2375 -H fd:// --containerd=/run/containerd/containerd.sock

As an aside this is another reminder how awful systemd really is. Maybe I'm ignorant to whats actually going on but it seems supremely messy to require users to undef and then define things in overrides.

While this does work and gives API access on the local port, this removes all access control and wasn't the approach I wanted to use. I actually had already planned to use SSH since it's another thing Go makes super simple, but now I was wondering if it was possible to just proxy to the Docker socket. After some google searching it seemed like at least modern versions of openssh supported it, so there was a chance Go would too. Low and behold of course it does! Now we can wrap up a few Go primitives and create a very simple and clean interface to accessing the Docker client via SSH:

import (
    "context"
    "net"
    "net/http"

	"github.com/docker/docker/client"
    "golang.org/x/crypto/ssh"
)


client, err := ssh.Dial("tcp", h.target, h.clientConfig)

httpClient := http.Client{
    Transport: &http.Transport{
        DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
            // note: we could parse the arguments from dial to pull this out, but we know we only want docker
            return client.Dial("unix", "/var/run/docker.sock")
        },
    },
}

dockerClient, err := client.NewClientWithOpts(
    client.WithHTTPClient(&httpClient),
    client.WithAPIVersionNegotiation(),
)

Security

The disadvantage of this is that Docker Engine API access can be effectively root access, but for my setup this is acceptable. On the flip side this approach lets us follow the standard unix permission model. We can SSH via a user whose shell is /bin/false but is in the docker group and everything will work as we expect, limiting some attack surfaces.