feat: support ssh:// scheme for docker client host

Check for the presence of environment variable `DOCKER_HOST`. If nonempty
and of scheme `ssh://`, open an SSH tunnel to the specified remote host,
forwarding the remote docker socket to a local socket in /tmp. Then,
construct the lazydocker client from the local socket.

Example usage: `DOCKER_HOST=ssh://my-server lazydocker`

Fixes #244, #228, #255
This commit is contained in:
Charlie Moog 2021-09-05 18:53:57 -05:00
parent c88fba546f
commit 4e201bb8f4

View file

@ -5,10 +5,16 @@ import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net"
"net/url"
"os"
"os/exec"
"path"
"sort"
"strings"
"sync"
"syscall"
"time"
"github.com/acarl005/stripansi"
@ -66,9 +72,113 @@ func (c *DockerCommand) NewCommandObject(obj CommandObject) CommandObject {
return defaultObj
}
func handleSSHHosts(c *client.Client) error {
ctx := context.Background()
u, err := url.Parse(os.Getenv("DOCKER_HOST"))
if err != nil {
// if no or an invalid docker host is specified, continue nominally
return nil
}
// if the docker host scheme is "ssh", forward the docker socket before creating the client
if u.Scheme == "ssh" {
newDockerHost, err := tunneledDockerHost(ctx, u.Host)
if err != nil {
return fmt.Errorf("tunnel ssh docker host: %w", err)
}
return client.WithHost(newDockerHost)(c)
}
return nil
}
func tunneledDockerHost(ctx context.Context, remoteHost string) (string, error) {
socketDir, err := ioutil.TempDir("/tmp", "lazydocker-sshtunnel-")
if err != nil {
return "", fmt.Errorf("create ssh tunnel tmp file: %w", err)
}
localSocket := path.Join(socketDir, "dockerhost.sock")
err = tunnelSSH(ctx, remoteHost, localSocket)
if err != nil {
return "", fmt.Errorf("tunnel docker host over ssh: %w", err)
}
// set a reasonable timeout, then wait for the socket to dial successfully
// before attempting to create a new docker client
const socketTunnelTimeout = 8 * time.Second
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
err = retrySocketDial(ctx, localSocket)
if err != nil {
return "", fmt.Errorf("ssh tunneled socket never became available: %w", err)
}
// construct the new DOCKER_HOST url with the proper scheme
newDockerHostURL := url.URL{Scheme: "unix", Path: localSocket}
return newDockerHostURL.String(), nil
}
// Attempt to dial the socket until it becomes available.
// The retry loop will continue until the parent context is canceled.
func retrySocketDial(ctx context.Context, socketPath string) error {
t := time.NewTicker(1 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
}
// attempt to dial the socket, exit on success
err := tryDial(ctx, socketPath)
if err != nil {
continue
}
return nil
}
}
// Try to dial the specified unix socket, immediately close the connection if successfully created.
func tryDial(ctx context.Context, socketPath string) error {
var dialer net.Dialer
conn, err := dialer.DialContext(ctx, "unix", socketPath)
if err != nil {
return err
}
defer conn.Close()
return nil
}
func tunnelSSH(ctx context.Context, host, localSocket string) error {
cmd := exec.CommandContext(ctx, "ssh", "-L", localSocket+":/var/run/docker.sock", host, "-N")
// when lazydocker exits, kill the SSH tunnel
cmd.SysProcAttr = &syscall.SysProcAttr{
// TODO: ensure we get this behavior on all platforms
Pdeathsig: syscall.SIGKILL,
}
err := cmd.Start()
if err != nil {
return err
}
return nil
}
// Build a new docker client from the enviornment.
//
// Handle special cases including `ssh://` host schemes.
func clientBuilder(c *client.Client) error {
err := client.FromEnv(c)
if err != nil {
return err
}
return handleSSHHosts(c)
}
// NewDockerCommand it runs docker commands
func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.TranslationSet, config *config.AppConfig, errorChan chan error) (*DockerCommand, error) {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithVersion(APIVersion))
cli, err := client.NewClientWithOpts(clientBuilder, client.WithVersion(APIVersion))
if err != nil {
return nil, err
}