diff --git a/pkg/commands/docker.go b/pkg/commands/docker.go index b055441a8..6cf9d817b 100644 --- a/pkg/commands/docker.go +++ b/pkg/commands/docker.go @@ -16,12 +16,12 @@ import ( "time" cliconfig "github.com/docker/cli/cli/config" + "github.com/docker/cli/cli/connhelper" ddocker "github.com/docker/cli/cli/context/docker" ctxstore "github.com/docker/cli/cli/context/store" "github.com/docker/docker/api/types/container" "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" @@ -91,12 +91,34 @@ func (c *DockerCommand) NewCommandObject(obj CommandObject) CommandObject { // Instead, we explicitly configure only what we need, and rely on proper // API version negotiation to support older Docker daemons. // See https://github.com/jesseduffield/lazydocker/issues/715 +// +// For ssh:// hosts we use the official Docker CLI connhelper (docker system +// dial-stdio over SSH) rather than streamlocal-forwarding /var/run/docker.sock. +// That matches the docker CLI and works with restricted SSH servers such as +// Tailscale SSH that block unix socket forwards to the docker socket. func newDockerClient(dockerHost string) (*client.Client, error) { - return client.NewClientWithOpts( + opts := []client.Opt{ client.WithTLSClientConfigFromEnv(), client.WithAPIVersionNegotiation(), - client.WithHost(dockerHost), - ) + } + + if strings.HasPrefix(dockerHost, "ssh://") { + helper, err := connhelper.GetConnectionHelper(dockerHost) + if err != nil { + return nil, fmt.Errorf("ssh docker host: %w", err) + } + if helper == nil { + return nil, fmt.Errorf("ssh docker host: no connection helper for %q", dockerHost) + } + opts = append(opts, + client.WithHost(helper.Host), + client.WithDialContext(helper.Dialer), + ) + } else { + opts = append(opts, client.WithHost(dockerHost)) + } + + return client.NewClientWithOpts(opts...) } // NewDockerCommand it runs docker commands @@ -106,23 +128,9 @@ func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.Translat ogLog.Printf("> could not determine host %v", err) } - // NOTE: Inject the determined docker host to the environment. This allows the - // `SSHHandler.HandleSSHDockerHost()` to create a local unix socket tunneled - // over SSH to the specified ssh host. - if strings.HasPrefix(dockerHost, "ssh://") { - os.Setenv(dockerHostEnvKey, dockerHost) - } - - tunnelCloser, err := ssh.NewSSHHandler(osCommand).HandleSSHDockerHost() - if err != nil { - ogLog.Fatal(err) - } - - // Retrieve the docker host from the environment which could have been set by - // the `SSHHandler.HandleSSHDockerHost()` and override `dockerHost`. - dockerHostFromEnv := os.Getenv(dockerHostEnvKey) - if dockerHostFromEnv != "" { - dockerHost = dockerHostFromEnv + // Ensure subprocess docker/compose invocations use the same host (incl. ssh://). + if dockerHost != "" && os.Getenv(dockerHostEnvKey) == "" { + _ = os.Setenv(dockerHostEnvKey, dockerHost) } cli, err := newDockerClient(dockerHost) @@ -138,7 +146,7 @@ func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.Translat Client: cli, ErrorChan: errorChan, InDockerComposeProject: true, - Closers: []io.Closer{tunnelCloser}, + Closers: []io.Closer{cli}, } dockerCommand.setDockerComposeCommand(config) diff --git a/pkg/commands/ssh/ssh.go b/pkg/commands/ssh/ssh.go deleted file mode 100644 index ecc840459..000000000 --- a/pkg/commands/ssh/ssh.go +++ /dev/null @@ -1,159 +0,0 @@ -package ssh - -import ( - "context" - "fmt" - "io" - "net" - "net/url" - "os" - "os/exec" - "path" - "time" -) - -// we only need these two methods from our OSCommand struct, for killing commands -type CmdKiller interface { - Kill(cmd *exec.Cmd) error - PrepareForChildren(cmd *exec.Cmd) -} - -type SSHHandler struct { - oSCommand CmdKiller - - 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 -} - -func NewSSHHandler(oSCommand CmdKiller) *SSHHandler { - return &SSHHandler{ - oSCommand: oSCommand, - - 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: os.MkdirTemp, - 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.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.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 - oSCommand CmdKiller -} - -var _ io.Closer = (*tunneledDockerHost)(nil) - -func (t *tunneledDockerHost) Close() error { - return t.oSCommand.Kill(t.cmd) -} - -func (self *SSHHandler) createDockerHostTunnel(ctx context.Context, remoteHost string) (*tunneledDockerHost, error) { - socketDir, err := self.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, - oSCommand: self.oSCommand, - }, 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.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") - self.oSCommand.PrepareForChildren(cmd) - err := self.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 deleted file mode 100644 index f1ea1b90d..000000000 --- a/pkg/commands/ssh/ssh_test.go +++ /dev/null @@ -1,109 +0,0 @@ -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) - - 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{ - oSCommand: &fakeCmdKiller{}, - - 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) - }) - } -} - -type fakeCmdKiller struct{} - -func (self *fakeCmdKiller) Kill(cmd *exec.Cmd) error { - return nil -} - -func (self *fakeCmdKiller) PrepareForChildren(cmd *exec.Cmd) {}