From 4e201bb8f44342c3a5900cf286bbbc5343bc4da0 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Sun, 5 Sep 2021 18:53:57 -0500 Subject: [PATCH 1/7] 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 --- pkg/commands/docker.go | 112 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go index 2dc5e6ae..738f8f10 100644 --- a/pkg/commands/docker.go +++ b/pkg/commands/docker.go @@ -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 } From a2958c782042833a42cc8df9cb51a466acb9c25b Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Fri, 26 Nov 2021 10:10:02 -0600 Subject: [PATCH 2/7] code review changes --- pkg/commands/docker.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go index 738f8f10..d710ff9c 100644 --- a/pkg/commands/docker.go +++ b/pkg/commands/docker.go @@ -14,7 +14,6 @@ import ( "sort" "strings" "sync" - "syscall" "time" "github.com/acarl005/stripansi" @@ -106,7 +105,7 @@ func tunneledDockerHost(ctx context.Context, remoteHost string) (string, error) // 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) + ctx, cancel := context.WithTimeout(ctx, socketTunnelTimeout) defer cancel() err = retrySocketDial(ctx, localSocket) @@ -153,11 +152,6 @@ func tryDial(ctx context.Context, socketPath string) error { 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 From 892fc090b62bbd9f11156abc5da8201cfe540a1b Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Sun, 26 Dec 2021 09:41:43 -0600 Subject: [PATCH 3/7] set pgid and send kill signal on exit --- main.go | 2 ++ pkg/commands/docker.go | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 6b256b66..34e651ba 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,7 @@ import ( "github.com/go-errors/errors" "github.com/integrii/flaggy" "github.com/jesseduffield/lazydocker/pkg/app" + "github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/yaml" ) @@ -73,6 +74,7 @@ func main() { if err == nil { err = app.Run() } + commands.CloseDockerSocketConnection() if err != nil { if errMessage, known := app.KnownError(err); known { diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go index 3f1087f6..4045852d 100644 --- a/pkg/commands/docker.go +++ b/pkg/commands/docker.go @@ -15,6 +15,7 @@ import ( "sort" "strings" "sync" + "syscall" "time" "github.com/acarl005/stripansi" @@ -151,16 +152,30 @@ func tryDial(ctx context.Context, socketPath string) error { return nil } +// CloseDockerSocketConnection kills the docker socket SSH forwarding process, if it exists. +// +// If will exist when DOCKER_HOST has the protocol scheme `ssh://`. +func CloseDockerSocketConnection() { + if dockerSSHConnection != nil { + syscall.Kill(-dockerSSHConnection.Process.Pid, syscall.SIGKILL) + } +} + +// dockerSSHConnection holds package-level state for the last-opened SSH tunnel to a remote docker socket. +var dockerSSHConnection *exec.Cmd + func tunnelSSH(ctx context.Context, host, localSocket string) error { cmd := exec.CommandContext(ctx, "ssh", "-L", localSocket+":/var/run/docker.sock", host, "-N") + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} err := cmd.Start() if err != nil { return err } + dockerSSHConnection = cmd return nil } -// Build a new docker client from the enviornment. +// Build a new docker client from the environment. // // Handle special cases including `ssh://` host schemes. func clientBuilder(c *client.Client) error { From 0bd4168dc9fbb21cf27f4275d924af4a315a43c9 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Mon, 27 Dec 2021 23:58:42 -0600 Subject: [PATCH 4/7] override DOCKER_HOST environment variable directly Rather than only overriding the Go docker client configuration, we now override the process DOCKER_HOST so as to properly configure docker-cli child processes. --- pkg/commands/docker.go | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go index 4045852d..9a7a8f22 100644 --- a/pkg/commands/docker.go +++ b/pkg/commands/docker.go @@ -73,9 +73,12 @@ func (c *DockerCommand) NewCommandObject(obj CommandObject) CommandObject { return defaultObj } -func handleSSHHosts(c *client.Client) error { +// handleSSHDockerHost overrides the DOCKER_HOST environment variable +// to point towards a local unix socket tunneled over SSH to the specified ssh host. +func handleSSHDockerHost() error { + const key = "DOCKER_HOST" ctx := context.Background() - u, err := url.Parse(os.Getenv("DOCKER_HOST")) + u, err := url.Parse(os.Getenv(key)) if err != nil { // if no or an invalid docker host is specified, continue nominally return nil @@ -87,7 +90,12 @@ func handleSSHHosts(c *client.Client) error { if err != nil { return fmt.Errorf("tunnel ssh docker host: %w", err) } - return client.WithHost(newDockerHost)(c) + err = os.Setenv(key, newDockerHost) + if err != nil { + return fmt.Errorf("override DOCKER_HOST to tunneled socket: %w", err) + } + + return nil } return nil } @@ -179,11 +187,15 @@ func tunnelSSH(ctx context.Context, host, localSocket string) error { // // Handle special cases including `ssh://` host schemes. func clientBuilder(c *client.Client) error { - err := client.FromEnv(c) + err := handleSSHDockerHost() if err != nil { return err } - return handleSSHHosts(c) + err = client.FromEnv(c) + if err != nil { + return err + } + return nil } // NewDockerCommand it runs docker commands From afe3d23cbd0345dfe786f4efa29283338dff5e79 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Sat, 8 Jan 2022 15:09:08 -0600 Subject: [PATCH 5/7] refactor: kill tunnel cmd on Close --- main.go | 3 +- pkg/app/app.go | 6 +++ pkg/commands/docker.go | 88 +++++++++++++++++++++++------------------- pkg/utils/utils.go | 24 ++++++++++++ 4 files changed, 79 insertions(+), 42 deletions(-) diff --git a/main.go b/main.go index 34e651ba..fa687aa3 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,6 @@ import ( "github.com/go-errors/errors" "github.com/integrii/flaggy" "github.com/jesseduffield/lazydocker/pkg/app" - "github.com/jesseduffield/lazydocker/pkg/commands" "github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/yaml" ) @@ -74,7 +73,7 @@ func main() { if err == nil { err = app.Run() } - commands.CloseDockerSocketConnection() + app.Close() if err != nil { if errMessage, known := app.KnownError(err); known { diff --git a/pkg/app/app.go b/pkg/app/app.go index c1b426b8..a920f440 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -9,6 +9,7 @@ import ( "github.com/jesseduffield/lazydocker/pkg/gui" "github.com/jesseduffield/lazydocker/pkg/i18n" "github.com/jesseduffield/lazydocker/pkg/log" + "github.com/jesseduffield/lazydocker/pkg/utils" "github.com/sirupsen/logrus" ) @@ -46,6 +47,7 @@ func NewApp(config *config.AppConfig) (*App, error) { if err != nil { return app, err } + app.closers = append(app.closers, app.DockerCommand) app.Gui, err = gui.NewGui(app.Log, app.DockerCommand, app.OSCommand, app.Tr, config, app.ErrorChan) if err != nil { return app, err @@ -58,6 +60,10 @@ func (app *App) Run() error { return err } +func (app *App) Close() error { + return utils.CloseMany(app.closers) +} + type errorMapping struct { originalError string newError string diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go index 9a7a8f22..5702a80a 100644 --- a/pkg/commands/docker.go +++ b/pkg/commands/docker.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "io" "io/ioutil" ogLog "log" "net" @@ -50,6 +51,7 @@ type DockerCommand struct { DisplayContainers []*Container Images []*Image Volumes []*Volume + Closers []io.Closer } // LimitedDockerCommand is a stripped-down DockerCommand with just the methods the container/service/image might need @@ -75,41 +77,56 @@ func (c *DockerCommand) NewCommandObject(obj CommandObject) CommandObject { // handleSSHDockerHost overrides the DOCKER_HOST environment variable // to point towards a local unix socket tunneled over SSH to the specified ssh host. -func handleSSHDockerHost() error { +func handleSSHDockerHost() (io.Closer, error) { const key = "DOCKER_HOST" ctx := context.Background() u, err := url.Parse(os.Getenv(key)) if err != nil { // if no or an invalid docker host is specified, continue nominally - return nil + return noopCloser{}, 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) + tunnel, err := createDockerHostTunnel(ctx, u.Host) if err != nil { - return fmt.Errorf("tunnel ssh docker host: %w", err) + return noopCloser{}, fmt.Errorf("tunnel ssh docker host: %w", err) } - err = os.Setenv(key, newDockerHost) + err = os.Setenv(key, tunnel.SocketPath) if err != nil { - return fmt.Errorf("override DOCKER_HOST to tunneled socket: %w", err) + return noopCloser{}, fmt.Errorf("override DOCKER_HOST to tunneled socket: %w", err) } - return nil + return tunnel, nil } - return nil + return noopCloser{}, nil } -func tunneledDockerHost(ctx context.Context, remoteHost string) (string, error) { +type noopCloser struct{} + +func (noopCloser) Close() error { return nil } + +type TunneledDockerHost struct { + SocketPath string + cmd *exec.Cmd +} + +var _ io.Closer = (*TunneledDockerHost)(nil) + +func (t *TunneledDockerHost) Close() error { + return syscall.Kill(-t.cmd.Process.Pid, syscall.SIGKILL) +} + +func createDockerHostTunnel(ctx context.Context, remoteHost string) (*TunneledDockerHost, error) { socketDir, err := ioutil.TempDir("/tmp", "lazydocker-sshtunnel-") if err != nil { - return "", fmt.Errorf("create ssh tunnel tmp file: %w", err) + return nil, fmt.Errorf("create ssh tunnel tmp file: %w", err) } localSocket := path.Join(socketDir, "dockerhost.sock") - err = tunnelSSH(ctx, remoteHost, localSocket) + cmd, err := tunnelSSH(ctx, remoteHost, localSocket) if err != nil { - return "", fmt.Errorf("tunnel docker host over ssh: %w", err) + return nil, fmt.Errorf("tunnel docker host over ssh: %w", err) } // set a reasonable timeout, then wait for the socket to dial successfully @@ -120,12 +137,15 @@ func tunneledDockerHost(ctx context.Context, remoteHost string) (string, error) err = retrySocketDial(ctx, localSocket) if err != nil { - return "", fmt.Errorf("ssh tunneled socket never became available: %w", err) + return nil, 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 + return &TunneledDockerHost{ + SocketPath: newDockerHostURL.String(), + cmd: cmd, + }, nil } // Attempt to dial the socket until it becomes available. @@ -159,48 +179,31 @@ func tryDial(ctx context.Context, socketPath string) error { defer conn.Close() return nil } - -// CloseDockerSocketConnection kills the docker socket SSH forwarding process, if it exists. -// -// If will exist when DOCKER_HOST has the protocol scheme `ssh://`. -func CloseDockerSocketConnection() { - if dockerSSHConnection != nil { - syscall.Kill(-dockerSSHConnection.Process.Pid, syscall.SIGKILL) - } -} - -// dockerSSHConnection holds package-level state for the last-opened SSH tunnel to a remote docker socket. -var dockerSSHConnection *exec.Cmd - -func tunnelSSH(ctx context.Context, host, localSocket string) error { +func tunnelSSH(ctx context.Context, host, localSocket string) (*exec.Cmd, error) { cmd := exec.CommandContext(ctx, "ssh", "-L", localSocket+":/var/run/docker.sock", host, "-N") cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} err := cmd.Start() if err != nil { - return err + return nil, err } - dockerSSHConnection = cmd - return nil + return cmd, nil } // Build a new docker client from the environment. // // Handle special cases including `ssh://` host schemes. func clientBuilder(c *client.Client) error { - err := handleSSHDockerHost() - if err != nil { - return err - } - err = client.FromEnv(c) - if err != nil { - return err - } return nil } // 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(clientBuilder, client.WithVersion(APIVersion)) + tunnelCloser, err := handleSSHDockerHost() + if err != nil { + ogLog.Fatal(err) + } + + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithVersion(APIVersion)) if err != nil { ogLog.Fatal(err) } @@ -214,6 +217,7 @@ func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.Translat ErrorChan: errorChan, ShowExited: true, InDockerComposeProject: true, + Closers: []io.Closer{tunnelCloser}, } command := utils.ApplyTemplate( @@ -237,6 +241,10 @@ func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.Translat return dockerCommand, nil } +func (c *DockerCommand) Close() error { + return utils.CloseMany(c.Closers) +} + // MonitorContainerStats is a function func (c *DockerCommand) MonitorContainerStats() { // TODO: pass in a stop channel to these so we don't restart every time we come back from a subprocess diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 5a68b1f0..4a33f999 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "html/template" + "io" "math" "reflect" "regexp" @@ -350,3 +351,26 @@ func FormatMap(padding int, m map[string]string) string { return output } + +type multiErr []error + +func (m multiErr) Error() string { + var b bytes.Buffer + b.WriteString("encountered multiple errors:") + for _, err := range m { + b.WriteString("\n\t... " + err.Error()) + } + return b.String() +} + +func CloseMany(closers []io.Closer) error { + errs := make([]error, 0, len(closers)) + for _, c := range closers { + err := c.Close() + if err != nil { + errs = append(errs, err) + } + } + return multiErr(errs) +} + From e29e11047668a5e5557be074c3c55383651aa1ff Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Sun, 9 Jan 2022 17:58:51 +1100 Subject: [PATCH 6/7] refactor and add test --- pkg/commands/docker.go | 132 +---------------------------- pkg/commands/ssh/ssh.go | 156 +++++++++++++++++++++++++++++++++++ pkg/commands/ssh/ssh_test.go | 102 +++++++++++++++++++++++ pkg/utils/utils.go | 1 - 4 files changed, 262 insertions(+), 129 deletions(-) create mode 100644 pkg/commands/ssh/ssh.go create mode 100644 pkg/commands/ssh/ssh_test.go diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go index 5702a80a..9b4e825f 100644 --- a/pkg/commands/docker.go +++ b/pkg/commands/docker.go @@ -6,23 +6,18 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" ogLog "log" - "net" - "net/url" - "os" "os/exec" - "path" "sort" "strings" "sync" - "syscall" "time" "github.com/acarl005/stripansi" "github.com/docker/docker/api/types" "github.com/docker/docker/client" "github.com/imdario/mergo" + "github.com/jesseduffield/lazydocker/pkg/commands/ssh" "github.com/jesseduffield/lazydocker/pkg/config" "github.com/jesseduffield/lazydocker/pkg/i18n" "github.com/jesseduffield/lazydocker/pkg/utils" @@ -54,6 +49,8 @@ type DockerCommand struct { Closers []io.Closer } +var _ io.Closer = &DockerCommand{} + // LimitedDockerCommand is a stripped-down DockerCommand with just the methods the container/service/image might need type LimitedDockerCommand interface { NewCommandObject(CommandObject) CommandObject @@ -75,130 +72,9 @@ func (c *DockerCommand) NewCommandObject(obj CommandObject) CommandObject { return defaultObj } -// handleSSHDockerHost overrides the DOCKER_HOST environment variable -// to point towards a local unix socket tunneled over SSH to the specified ssh host. -func handleSSHDockerHost() (io.Closer, error) { - const key = "DOCKER_HOST" - ctx := context.Background() - u, err := url.Parse(os.Getenv(key)) - if err != nil { - // if no or an invalid docker host is specified, continue nominally - return noopCloser{}, nil - } - - // if the docker host scheme is "ssh", forward the docker socket before creating the client - if u.Scheme == "ssh" { - tunnel, err := createDockerHostTunnel(ctx, u.Host) - if err != nil { - return noopCloser{}, fmt.Errorf("tunnel ssh docker host: %w", err) - } - err = os.Setenv(key, tunnel.SocketPath) - if err != nil { - return noopCloser{}, fmt.Errorf("override DOCKER_HOST to tunneled socket: %w", err) - } - - return tunnel, nil - } - return noopCloser{}, nil -} - -type noopCloser struct{} - -func (noopCloser) Close() error { return nil } - -type TunneledDockerHost struct { - SocketPath string - cmd *exec.Cmd -} - -var _ io.Closer = (*TunneledDockerHost)(nil) - -func (t *TunneledDockerHost) Close() error { - return syscall.Kill(-t.cmd.Process.Pid, syscall.SIGKILL) -} - -func createDockerHostTunnel(ctx context.Context, remoteHost string) (*TunneledDockerHost, error) { - socketDir, err := ioutil.TempDir("/tmp", "lazydocker-sshtunnel-") - if err != nil { - return nil, fmt.Errorf("create ssh tunnel tmp file: %w", err) - } - localSocket := path.Join(socketDir, "dockerhost.sock") - - cmd, err := tunnelSSH(ctx, remoteHost, localSocket) - if err != nil { - return nil, 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, socketTunnelTimeout) - defer cancel() - - err = retrySocketDial(ctx, localSocket) - if err != nil { - return nil, 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 &TunneledDockerHost{ - SocketPath: newDockerHostURL.String(), - cmd: cmd, - }, 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) (*exec.Cmd, error) { - cmd := exec.CommandContext(ctx, "ssh", "-L", localSocket+":/var/run/docker.sock", host, "-N") - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - err := cmd.Start() - if err != nil { - return nil, err - } - return cmd, nil -} - -// Build a new docker client from the environment. -// -// Handle special cases including `ssh://` host schemes. -func clientBuilder(c *client.Client) error { - return nil -} - // NewDockerCommand it runs docker commands func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.TranslationSet, config *config.AppConfig, errorChan chan error) (*DockerCommand, error) { - tunnelCloser, err := handleSSHDockerHost() + tunnelCloser, err := ssh.NewSSHHandler().HandleSSHDockerHost() if err != nil { ogLog.Fatal(err) } diff --git a/pkg/commands/ssh/ssh.go b/pkg/commands/ssh/ssh.go new file mode 100644 index 00000000..474affa6 --- /dev/null +++ b/pkg/commands/ssh/ssh.go @@ -0,0 +1,156 @@ +package ssh + +import ( + "context" + "fmt" + "io" + "io/ioutil" + "net" + "net/url" + "os" + "os/exec" + "path" + "syscall" + "time" +) + +type dependencies struct { + // storing all these dependencies as fields for the sake of testing + dialContext func(ctx context.Context, network, addr string) (io.Closer, error) + startCmd func(*exec.Cmd) error + tempDir func(dir string, pattern string) (name string, err error) + getenv func(key string) string + setenv func(key, value string) error +} + +type SSHHandler struct { + deps dependencies +} + +func NewSSHHandler() *SSHHandler { + return &SSHHandler{ + deps: dependencies{ + dialContext: func(ctx context.Context, network, addr string) (io.Closer, error) { + return (&net.Dialer{}).DialContext(ctx, network, addr) + }, + startCmd: func(cmd *exec.Cmd) error { return cmd.Start() }, + tempDir: ioutil.TempDir, + getenv: os.Getenv, + setenv: os.Setenv, + }, + } +} + +// HandleSSHDockerHost overrides the DOCKER_HOST environment variable +// to point towards a local unix socket tunneled over SSH to the specified ssh host. +func (self *SSHHandler) HandleSSHDockerHost() (io.Closer, error) { + const key = "DOCKER_HOST" + ctx := context.Background() + u, err := url.Parse(self.deps.getenv(key)) + if err != nil { + // if no or an invalid docker host is specified, continue nominally + return noopCloser{}, nil + } + + // if the docker host scheme is "ssh", forward the docker socket before creating the client + if u.Scheme == "ssh" { + tunnel, err := self.createDockerHostTunnel(ctx, u.Host) + if err != nil { + return noopCloser{}, fmt.Errorf("tunnel ssh docker host: %w", err) + } + err = self.deps.setenv(key, tunnel.socketPath) + if err != nil { + return noopCloser{}, fmt.Errorf("override DOCKER_HOST to tunneled socket: %w", err) + } + + return tunnel, nil + } + return noopCloser{}, nil +} + +type noopCloser struct{} + +func (noopCloser) Close() error { return nil } + +type tunneledDockerHost struct { + socketPath string + cmd *exec.Cmd +} + +var _ io.Closer = (*tunneledDockerHost)(nil) + +func (t *tunneledDockerHost) Close() error { + return syscall.Kill(-t.cmd.Process.Pid, syscall.SIGKILL) +} + +func (self *SSHHandler) createDockerHostTunnel(ctx context.Context, remoteHost string) (*tunneledDockerHost, error) { + socketDir, err := self.deps.tempDir("/tmp", "lazydocker-sshtunnel-") + if err != nil { + return nil, fmt.Errorf("create ssh tunnel tmp file: %w", err) + } + localSocket := path.Join(socketDir, "dockerhost.sock") + + cmd, err := self.tunnelSSH(ctx, remoteHost, localSocket) + if err != nil { + return nil, 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, socketTunnelTimeout) + defer cancel() + + err = self.retrySocketDial(ctx, localSocket) + if err != nil { + return nil, 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 &tunneledDockerHost{ + socketPath: newDockerHostURL.String(), + cmd: cmd, + }, nil +} + +// Attempt to dial the socket until it becomes available. +// The retry loop will continue until the parent context is canceled. +func (self *SSHHandler) 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 := self.tryDial(ctx, socketPath) + if err != nil { + continue + } + return nil + } +} + +// Try to dial the specified unix socket, immediately close the connection if successfully created. +func (self *SSHHandler) tryDial(ctx context.Context, socketPath string) error { + conn, err := self.deps.dialContext(ctx, "unix", socketPath) + if err != nil { + return err + } + defer conn.Close() + return nil +} + +func (self *SSHHandler) tunnelSSH(ctx context.Context, host, localSocket string) (*exec.Cmd, error) { + cmd := exec.CommandContext(ctx, "ssh", "-L", localSocket+":/var/run/docker.sock", host, "-N") + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + err := self.deps.startCmd(cmd) + if err != nil { + return nil, err + } + return cmd, nil +} diff --git a/pkg/commands/ssh/ssh_test.go b/pkg/commands/ssh/ssh_test.go new file mode 100644 index 00000000..5c16e224 --- /dev/null +++ b/pkg/commands/ssh/ssh_test.go @@ -0,0 +1,102 @@ +package ssh + +import ( + "context" + "io" + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSSHHandlerHandleSSHDockerHost(t *testing.T) { + type scenario struct { + testName string + envVarValue string + expectedDialContextCount int + expectedStartCmdCount int + } + + scenarios := []scenario{ + { + testName: "No env var set", + envVarValue: "", + expectedDialContextCount: 0, + expectedStartCmdCount: 0, + }, + { + testName: "Env var set with https scheme", + envVarValue: "https://myhost.com", + expectedStartCmdCount: 0, + expectedDialContextCount: 0, + }, + { + testName: "Env var set with ssh scheme", + envVarValue: "ssh://myhost@192.168.5.178", + expectedStartCmdCount: 1, + expectedDialContextCount: 1, + }, + } + + for _, s := range scenarios { + s := s + t.Run(s.testName, func(t *testing.T) { + getenv := func(key string) string { + if key != "DOCKER_HOST" { + t.Errorf("Expected key to be DOCKER_HOST, got %s", key) + } + + return s.envVarValue + } + + tempDir := func(dir string, pattern string) (string, error) { + assert.Equal(t, "/tmp", dir) + assert.Equal(t, "lazydocker-sshtunnel-", pattern) + + return "/tmp/lazydocker-ssh-tunnel-12345", nil + } + + setenv := func(key, value string) error { + assert.Equal(t, "DOCKER_HOST", key) + assert.Equal(t, "unix:///tmp/lazydocker-ssh-tunnel-12345/dockerhost.sock", value) + return nil + } + + startCmdCount := 0 + startCmd := func(cmd *exec.Cmd) error { + assert.EqualValues(t, []string{"ssh", "-L", "/tmp/lazydocker-ssh-tunnel-12345/dockerhost.sock:/var/run/docker.sock", "192.168.5.178", "-N"}, cmd.Args) + assert.Equal(t, true, cmd.SysProcAttr.Setpgid) + + startCmdCount++ + + return nil + } + + dialContextCount := 0 + dialContext := func(ctx context.Context, network string, address string) (io.Closer, error) { + assert.Equal(t, "unix", network) + assert.Equal(t, "/tmp/lazydocker-ssh-tunnel-12345/dockerhost.sock", address) + + dialContextCount++ + + return noopCloser{}, nil + } + + handler := &SSHHandler{ + deps: dependencies{ + dialContext: dialContext, + startCmd: startCmd, + tempDir: tempDir, + getenv: getenv, + setenv: setenv, + }, + } + + _, err := handler.HandleSSHDockerHost() + assert.NoError(t, err) + + assert.Equal(t, s.expectedDialContextCount, dialContextCount) + assert.Equal(t, s.expectedStartCmdCount, startCmdCount) + }) + } +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 4a33f999..f005c5b9 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -373,4 +373,3 @@ func CloseMany(closers []io.Closer) error { } return multiErr(errs) } - From 7105cc67aec19b1ab2183037e4f2d9bb79e7d1fb Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Sun, 9 Jan 2022 10:46:29 -0600 Subject: [PATCH 7/7] fix: success case in utils.CloseMany --- pkg/utils/utils.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index f005c5b9..e05da371 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -371,5 +371,8 @@ func CloseMany(closers []io.Closer) error { errs = append(errs, err) } } - return multiErr(errs) + if len(errs) > 0 { + return multiErr(errs) + } + return nil }