diff --git a/cli/server/add.go b/cli/server/add.go index f82763e9a..a6fe3de75 100644 --- a/cli/server/add.go +++ b/cli/server/add.go @@ -17,13 +17,13 @@ import ( "coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/config" + contextPkg "coopcloud.tech/abra/pkg/context" "coopcloud.tech/abra/pkg/server" "coopcloud.tech/abra/pkg/ssh" "github.com/AlecAivazis/survey/v2" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/swarm" dockerClient "github.com/docker/docker/client" - "github.com/sfreiberg/simplessh" "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" ) @@ -166,7 +166,7 @@ func newLocalServer(c *cli.Context, domainName string) error { } func newContext(c *cli.Context, domainName, username, port string) error { - store := client.NewDefaultDockerContextStore() + store := contextPkg.NewDefaultDockerContextStore() contexts, err := store.Store.List() if err != nil { return err @@ -196,7 +196,7 @@ func newClient(c *cli.Context, domainName string) (*dockerClient.Client, error) return cl, nil } -func installDocker(c *cli.Context, cl *dockerClient.Client, sshCl *simplessh.Client, domainName string) error { +func installDocker(c *cli.Context, cl *dockerClient.Client, sshCl *ssh.Client, domainName string) error { result, err := sshCl.Exec("which docker") if err != nil && string(result) != "" { return err diff --git a/cli/server/list.go b/cli/server/list.go index 42d89630e..054a24b85 100644 --- a/cli/server/list.go +++ b/cli/server/list.go @@ -4,8 +4,8 @@ import ( "strings" "coopcloud.tech/abra/cli/formatter" - "coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/config" + "coopcloud.tech/abra/pkg/context" "github.com/docker/cli/cli/connhelper/ssh" "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" @@ -18,7 +18,7 @@ var serverListCommand = &cli.Command{ ArgsUsage: " ", HideHelp: true, Action: func(c *cli.Context) error { - dockerContextStore := client.NewDefaultDockerContextStore() + dockerContextStore := context.NewDefaultDockerContextStore() contexts, err := dockerContextStore.Store.List() if err != nil { logrus.Fatal(err) @@ -36,7 +36,7 @@ var serverListCommand = &cli.Command{ for _, serverName := range serverNames { var row []string for _, ctx := range contexts { - endpoint, err := client.GetContextEndpoint(ctx) + endpoint, err := context.GetContextEndpoint(ctx) if err != nil && strings.Contains(err.Error(), "does not exist") { // No local context found, we can continue safely continue diff --git a/go.mod b/go.mod index b00031461..21cd63b53 100644 --- a/go.mod +++ b/go.mod @@ -28,10 +28,10 @@ require ( coopcloud.tech/libcapsul v0.0.0-20211022074848-c35e78fe3f3e github.com/Microsoft/hcsshim v0.8.21 // indirect github.com/containerd/containerd v1.5.5 // indirect - github.com/davidmz/go-pageant v1.0.2 // indirect github.com/docker/docker-credential-helpers v0.6.4 // indirect github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect github.com/fvbommel/sortorder v1.0.2 // indirect + github.com/gliderlabs/ssh v0.2.2 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/gorilla/mux v1.8.0 // indirect github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 @@ -40,9 +40,8 @@ require ( github.com/moby/sys/mount v0.2.0 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/opencontainers/runc v1.0.2 // indirect - github.com/pkg/sftp v1.13.4 // indirect - github.com/sfreiberg/simplessh v0.0.0-20180301191542-495cbb862a9c github.com/theupdateframework/notary v0.7.0 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect + golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0 ) diff --git a/go.sum b/go.sum index f0269a38a..55616334c 100644 --- a/go.sum +++ b/go.sum @@ -253,8 +253,6 @@ github.com/danieljoos/wincred v1.1.0/go.mod h1:XYlo+eRTsVA9aHGp7NGjFkPla4m+DCL7h github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= -github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/denisenkom/go-mssqldb v0.0.0-20191128021309-1d7a30a10f73/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0= github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= @@ -304,6 +302,7 @@ github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLi github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A= github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= @@ -501,8 +500,6 @@ github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdY github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -643,8 +640,6 @@ github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/sftp v1.13.4 h1:Lb0RYJCmgUcBgZosfoi9Y9sbl6+LJgOIgk/2Y4YjMFg= -github.com/pkg/sftp v1.13.4/go.mod h1:LzqnAvaD5TWeNBsZpfKxSYn1MbjWwOsCIAFFJbpIsK8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= @@ -699,8 +694,6 @@ github.com/schultz-is/passgen v1.0.1/go.mod h1:NnqzT2aSfvyheNQvBtlLUa0YlPFLDj60J github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/sfreiberg/simplessh v0.0.0-20180301191542-495cbb862a9c h1:7Q+2oF0uBoLEV+j13E3/xUkPkI7f+sFNPZOPo2jmrWk= -github.com/sfreiberg/simplessh v0.0.0-20180301191542-495cbb862a9c/go.mod h1:sB7d6wQapoRM+qx5MgQYB6JVHtel4YHRr0NXXCkXiwQ= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.0.4-0.20170822132746-89742aefa4b2/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= @@ -827,7 +820,6 @@ golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ= golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -974,7 +966,6 @@ golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/pkg/client/client.go b/pkg/client/client.go index 99ace904b..08672f9fb 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -6,6 +6,7 @@ import ( "os" "time" + contextPkg "coopcloud.tech/abra/pkg/context" commandconnPkg "coopcloud.tech/abra/pkg/upstream/commandconn" "github.com/docker/docker/client" "github.com/sirupsen/logrus" @@ -21,7 +22,7 @@ func New(contextName string) (*client.Client, error) { return nil, err } - ctxEndpoint, err := GetContextEndpoint(context) + ctxEndpoint, err := contextPkg.GetContextEndpoint(context) if err != nil { return nil, err } diff --git a/pkg/client/context.go b/pkg/client/context.go index 0a56420f4..a9eac62c2 100644 --- a/pkg/client/context.go +++ b/pkg/client/context.go @@ -4,14 +4,11 @@ import ( "errors" "fmt" + "coopcloud.tech/abra/pkg/context" commandconnPkg "coopcloud.tech/abra/pkg/upstream/commandconn" - command "github.com/docker/cli/cli/command" dConfig "github.com/docker/cli/cli/config" - context "github.com/docker/cli/cli/context" "github.com/docker/cli/cli/context/docker" contextStore "github.com/docker/cli/cli/context/store" - cliflags "github.com/docker/cli/cli/flags" - "github.com/moby/term" "github.com/sirupsen/logrus" ) @@ -35,7 +32,7 @@ func CreateContext(contextName string, user string, port string) error { // createContext interacts with Docker Context to create a Docker context config func createContext(name string, host string) error { - s := NewDefaultDockerContextStore() + s := context.NewDefaultDockerContextStore() contextMetadata := contextStore.Metadata{ Endpoints: make(map[string]interface{}), Name: name, @@ -83,46 +80,14 @@ func DeleteContext(name string) error { return err } - return NewDefaultDockerContextStore().Remove(name) + return context.NewDefaultDockerContextStore().Remove(name) } func GetContext(contextName string) (contextStore.Metadata, error) { - ctx, err := NewDefaultDockerContextStore().GetMetadata(contextName) + ctx, err := context.NewDefaultDockerContextStore().GetMetadata(contextName) if err != nil { return contextStore.Metadata{}, err } return ctx, nil } - -func GetContextEndpoint(ctx contextStore.Metadata) (string, error) { - endpointmeta, ok := ctx.Endpoints["docker"].(context.EndpointMetaBase) - if !ok { - err := errors.New("context lacks Docker endpoint") - return "", err - } - return endpointmeta.Host, nil -} - -func newContextStore(dir string, config contextStore.Config) contextStore.Store { - return contextStore.New(dir, config) -} - -func NewDefaultDockerContextStore() *command.ContextStoreWithDefault { - _, _, stderr := term.StdStreams() - dockerConfig := dConfig.LoadDefaultConfigFile(stderr) - contextDir := dConfig.ContextStoreDir() - storeConfig := command.DefaultContextStoreConfig() - store := newContextStore(contextDir, storeConfig) - - opts := &cliflags.CommonOptions{Context: "default"} - - dockerContextStore := &command.ContextStoreWithDefault{ - Store: store, - Resolver: func() (*command.DefaultContext, error) { - return command.ResolveDefaultContext(opts, dockerConfig, storeConfig, stderr) - }, - } - - return dockerContextStore -} diff --git a/pkg/client/context_test.go b/pkg/client/context_test.go index 1ec36c641..a8df8077b 100644 --- a/pkg/client/context_test.go +++ b/pkg/client/context_test.go @@ -4,6 +4,7 @@ import ( "testing" "coopcloud.tech/abra/pkg/client" + contextPkg "coopcloud.tech/abra/pkg/context" dContext "github.com/docker/cli/cli/context" dCliContextStore "github.com/docker/cli/cli/context/store" ) @@ -64,7 +65,7 @@ func TestGetContextEndpoint(t *testing.T) { dockerContext("ssh://foobar", "k8"), } for _, context := range testDockerContexts { - endpoint, err := client.GetContextEndpoint(context.context) + endpoint, err := contextPkg.GetContextEndpoint(context.context) if err != nil { if err.Error() != "context lacks Docker endpoint" { t.Error(err) diff --git a/pkg/config/app.go b/pkg/config/app.go index c767b400c..e528da66c 100644 --- a/pkg/config/app.go +++ b/pkg/config/app.go @@ -9,6 +9,7 @@ import ( "strings" "coopcloud.tech/abra/cli/formatter" + "coopcloud.tech/abra/pkg/ssh" "coopcloud.tech/abra/pkg/upstream/convert" loader "coopcloud.tech/abra/pkg/upstream/stack" stack "coopcloud.tech/abra/pkg/upstream/stack" @@ -145,6 +146,10 @@ func LoadAppFiles(servers ...string) (AppFiles, error) { logrus.Debugf("collecting metadata from '%v' servers: '%s'", len(servers), strings.Join(servers, ", ")) + if err := EnsureHostKeysAllServers(servers...); err != nil { + return nil, err + } + for _, server := range servers { serverDir := path.Join(ABRA_SERVER_FOLDER, server) files, err := getAllFilesInDirectory(serverDir) @@ -368,3 +373,15 @@ func GetAppComposeConfig(recipe string, opts stack.Deploy, appEnv AppEnv) (*comp return compose, nil } + +// EnsureHostKeysAllServers ensures all configured servers have server SSH host keys validated +func EnsureHostKeysAllServers(servers ...string) error { + for _, serverName := range servers { + logrus.Debugf("ensuring server SSH host key available for %s", serverName) + if err := ssh.EnsureHostKey(serverName); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/context/context.go b/pkg/context/context.go new file mode 100644 index 000000000..75417aeec --- /dev/null +++ b/pkg/context/context.go @@ -0,0 +1,44 @@ +package context + +import ( + "errors" + + "github.com/docker/cli/cli/command" + dConfig "github.com/docker/cli/cli/config" + "github.com/docker/cli/cli/context" + contextStore "github.com/docker/cli/cli/context/store" + cliflags "github.com/docker/cli/cli/flags" + "github.com/moby/term" +) + +func NewDefaultDockerContextStore() *command.ContextStoreWithDefault { + _, _, stderr := term.StdStreams() + dockerConfig := dConfig.LoadDefaultConfigFile(stderr) + contextDir := dConfig.ContextStoreDir() + storeConfig := command.DefaultContextStoreConfig() + store := newContextStore(contextDir, storeConfig) + + opts := &cliflags.CommonOptions{Context: "default"} + + dockerContextStore := &command.ContextStoreWithDefault{ + Store: store, + Resolver: func() (*command.DefaultContext, error) { + return command.ResolveDefaultContext(opts, dockerConfig, storeConfig, stderr) + }, + } + + return dockerContextStore +} + +func GetContextEndpoint(ctx contextStore.Metadata) (string, error) { + endpointmeta, ok := ctx.Endpoints["docker"].(context.EndpointMetaBase) + if !ok { + err := errors.New("context lacks Docker endpoint") + return "", err + } + return endpointmeta.Host, nil +} + +func newContextStore(dir string, config contextStore.Config) contextStore.Store { + return contextStore.New(dir, config) +} diff --git a/pkg/ssh/ssh.go b/pkg/ssh/ssh.go index e5aefa7e4..1e53360e2 100644 --- a/pkg/ssh/ssh.go +++ b/pkg/ssh/ssh.go @@ -3,18 +3,35 @@ package ssh import ( "bufio" "bytes" + "crypto/sha256" + "encoding/base64" "fmt" "io" + "net" + "os" "os/user" + "path/filepath" + "strings" "sync" "time" + "coopcloud.tech/abra/pkg/context" "github.com/AlecAivazis/survey/v2" + dockerSSHPkg "github.com/docker/cli/cli/connhelper/ssh" + sshPkg "github.com/gliderlabs/ssh" "github.com/kevinburke/ssh_config" - "github.com/sfreiberg/simplessh" "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + "golang.org/x/crypto/ssh/knownhosts" ) +var KnownHostsPath = filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts") + +type Client struct { + SSHClient *ssh.Client +} + // HostConfig is a SSH host config. type HostConfig struct { Host string @@ -23,61 +40,39 @@ type HostConfig struct { User string } -// GetHostConfig retrieves a ~/.ssh/config config for a host. -func GetHostConfig(hostname, username, port string) (HostConfig, error) { - var hostConfig HostConfig - - var host, idf string - - if host = ssh_config.Get(hostname, "Hostname"); host == "" { - logrus.Debugf("no hostname found in SSH config, assuming %s", hostname) - host = hostname +// Exec cmd on the remote host and return stderr and stdout +func (c *Client) Exec(cmd string) ([]byte, error) { + session, err := c.SSHClient.NewSession() + if err != nil { + return nil, err } + defer session.Close() - if username == "" { - if username = ssh_config.Get(hostname, "User"); username == "" { - systemUser, err := user.Current() - if err != nil { - return hostConfig, err - } - logrus.Debugf("no username found in SSH config or passed on command-line, assuming %s", username) - username = systemUser.Username - } - } + return session.CombinedOutput(cmd) +} - if port == "" { - if port = ssh_config.Get(hostname, "Port"); port == "" { - logrus.Debugf("no port found in SSH config or passed on command-line, assuming 22") - port = "22" - } - } - - idf = ssh_config.Get(hostname, "IdentityFile") - - hostConfig.Host = host - if idf != "" { - hostConfig.IdentityFile = idf - } - hostConfig.Port = port - hostConfig.User = username - - logrus.Debugf("constructed SSH config %s for %s", hostConfig, hostname) - - return hostConfig, nil +// Close the underlying SSH connection +func (c *Client) Close() error { + return c.SSHClient.Close() } // New creates a new SSH client connection. -func New(domainName, sshAuth, username, port string) (*simplessh.Client, error) { - var client *simplessh.Client +func New(domainName, sshAuth, username, port string) (*Client, error) { + var client *Client - hostConfig, err := GetHostConfig(domainName, username, port) + ctxConnDetails, err := GetContextConnDetails(domainName) if err != nil { - return client, err + return client, nil } if sshAuth == "identity-file" { var err error - client, err = simplessh.ConnectWithAgentTimeout(hostConfig.Host, hostConfig.User, 5*time.Second) + client, err = connectWithAgentTimeout( + ctxConnDetails.Host, + ctxConnDetails.User, + ctxConnDetails.Port, + 5*time.Second, + ) if err != nil { return client, err } @@ -91,7 +86,13 @@ func New(domainName, sshAuth, username, port string) (*simplessh.Client, error) } var err error - client, err = simplessh.ConnectWithPasswordTimeout(hostConfig.Host, hostConfig.User, password, 5*time.Second) + client, err = connectWithPasswordTimeout( + ctxConnDetails.Host, + ctxConnDetails.User, + ctxConnDetails.Port, + password, + 5*time.Second, + ) if err != nil { return client, err } @@ -100,8 +101,7 @@ func New(domainName, sshAuth, username, port string) (*simplessh.Client, error) return client, nil } -// sudoWriter supports sudo command handling. -// https://github.com/sfreiberg/simplessh/blob/master/simplessh.go +// sudoWriter supports sudo command handling type sudoWriter struct { b bytes.Buffer pw string @@ -109,8 +109,7 @@ type sudoWriter struct { m sync.Mutex } -// Write satisfies the write interface for sudoWriter. -// https://github.com/sfreiberg/simplessh/blob/master/simplessh.go +// Write satisfies the write interface for sudoWriter func (w *sudoWriter) Write(p []byte) (int, error) { if string(p) == "sudo_password" { w.stdin.Write([]byte(w.pw + "\n")) @@ -124,9 +123,8 @@ func (w *sudoWriter) Write(p []byte) (int, error) { return w.b.Write(p) } -// RunSudoCmd runs SSH commands and streams output. -// https://github.com/sfreiberg/simplessh/blob/master/simplessh.go -func RunSudoCmd(cmd, passwd string, cl *simplessh.Client) error { +// RunSudoCmd runs SSH commands and streams output +func RunSudoCmd(cmd, passwd string, cl *Client) error { session, err := cl.SSHClient.NewSession() if err != nil { return err @@ -170,9 +168,8 @@ func RunSudoCmd(cmd, passwd string, cl *simplessh.Client) error { return err } -// Exec runs a command on a remote and streams output. -// https://github.com/sfreiberg/simplessh/blob/master/simplessh.go -func Exec(cmd string, cl *simplessh.Client) error { +// Exec runs a command on a remote and streams output +func Exec(cmd string, cl *Client) error { session, err := cl.SSHClient.NewSession() if err != nil { return err @@ -224,3 +221,333 @@ func Exec(cmd string, cl *simplessh.Client) error { return nil } + +// EnsureKnowHostsFiles ensures that ~/.ssh/known_hosts is created +func EnsureKnowHostsFiles() error { + if _, err := os.Stat(KnownHostsPath); os.IsNotExist(err) { + logrus.Debugf("missing %s, creating now", KnownHostsPath) + file, err := os.OpenFile(KnownHostsPath, os.O_CREATE, 0600) + if err != nil { + return err + } + file.Close() + } + + return nil +} + +// GetHostKey checks if a host key is registered in the ~/.ssh/known_hosts file +func GetHostKey(hostname string) (bool, sshPkg.PublicKey, error) { + var hostKey sshPkg.PublicKey + + ctxConnDetails, err := GetContextConnDetails(hostname) + if err != nil { + return false, hostKey, err + } + + if err := EnsureKnowHostsFiles(); err != nil { + return false, hostKey, err + } + + file, err := os.Open(KnownHostsPath) + if err != nil { + return false, hostKey, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + fields := strings.Split(scanner.Text(), " ") + if len(fields) != 3 { + continue + } + + hostnameAndPort := fmt.Sprintf("%s:%s", ctxConnDetails.Host, ctxConnDetails.Port) + hashed := knownhosts.Normalize(hostnameAndPort) + + if strings.Contains(fields[0], hashed) { + var err error + hostKey, _, _, _, err = ssh.ParseAuthorizedKey(scanner.Bytes()) + if err != nil { + return false, hostKey, fmt.Errorf("error parsing server SSH host key %q: %v", fields[2], err) + } + break + } + } + + if hostKey != nil { + logrus.Debugf("server SSH host key present in ~/.ssh/known_hosts for %s", hostname) + return true, hostKey, nil + } + + return false, hostKey, nil +} + +// InsertHostKey adds a new host key to the ~/.ssh/known_hosts file +func InsertHostKey(hostname string, remote net.Addr, pubKey ssh.PublicKey) error { + file, err := os.OpenFile(KnownHostsPath, os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer file.Close() + + hashedHostname := knownhosts.Normalize(hostname) + lineHostname := knownhosts.Line([]string{hashedHostname}, pubKey) + _, err = file.WriteString(fmt.Sprintf("%s\n", lineHostname)) + if err != nil { + return err + } + + hashedRemote := knownhosts.Normalize(remote.String()) + lineRemote := knownhosts.Line([]string{hashedRemote}, pubKey) + _, err = file.WriteString(fmt.Sprintf("%s\n", lineRemote)) + if err != nil { + return err + } + + logrus.Debugf("SSH host key generated: %s", lineHostname) + logrus.Debugf("SSH host key generated: %s", lineRemote) + + return nil +} + +// HostKeyAddCallback ensures server ssh host keys are handled +func HostKeyAddCallback(hostnameAndPort string, remote net.Addr, pubKey ssh.PublicKey) error { + exists, _, err := GetHostKey(hostnameAndPort) + if err != nil { + return err + } + + if exists { + hostname := strings.Split(hostnameAndPort, ":")[0] + logrus.Debugf("server SSH host key found for %s, moving on", hostname) + return nil + } + + if !exists { + hostname := strings.Split(hostnameAndPort, ":")[0] + parsedPubKey := FingerprintSHA256(pubKey) + + fmt.Printf(fmt.Sprintf(` +You are attempting to make an SSH connection to a server but there is no entry +in your ~/.ssh/known_hosts file which confirms that this is indeed the server +you want to connect to. Please take a moment to validate the following SSH host +key, it is important. + + Host: %s + Fingerprint: %s + +If this is confusing to you, you can read the article below and learn how to +validate this fingerprint safely. Thanks to the comrades at cyberia.club for +writing this extensive guide <3 + + https://sequentialread.com/understanding-the-secure-shell-protocol-ssh/ + +`, hostname, parsedPubKey)) + + response := false + prompt := &survey.Confirm{ + Message: "are you sure you trust this host key?", + } + + if err := survey.AskOne(prompt, &response); err != nil { + return err + } + + if !response { + logrus.Fatal("exiting as requested") + } + + logrus.Debugf("attempting to insert server SSH host key for %s, %s", hostnameAndPort, remote) + + if err := InsertHostKey(hostnameAndPort, remote, pubKey); err != nil { + return err + } + + logrus.Infof("successfully added server SSH host key for %s", hostname) + } + + return nil +} + +// connect makes the SSH connection +func connect(username, host, port string, authMethod ssh.AuthMethod, timeout time.Duration) (*Client, error) { + config := &ssh.ClientConfig{ + User: username, + Auth: []ssh.AuthMethod{authMethod}, + HostKeyCallback: HostKeyAddCallback, // the main reason why we fork + } + + hostnameAndPort := fmt.Sprintf("%s:%s", host, port) + + logrus.Debugf("tcp dialing %s", hostnameAndPort) + + var conn net.Conn + var err error + conn, err = net.DialTimeout("tcp", hostnameAndPort, timeout) + if err != nil { + logrus.Debugf("tcp dialing %s failed, trying via ~/.ssh/config", hostnameAndPort) + hostConfig, err := GetHostConfig(host, username, port) + if err != nil { + return nil, err + } + conn, err = net.DialTimeout("tcp", fmt.Sprintf("%s:%s", hostConfig.Host, hostConfig.Port), timeout) + if err != nil { + return nil, err + } + } + + sshConn, chans, reqs, err := ssh.NewClientConn(conn, hostnameAndPort, config) + if err != nil { + return nil, err + } + + client := ssh.NewClient(sshConn, chans, reqs) + c := &Client{SSHClient: client} + + return c, nil +} + +func connectWithAgentTimeout(host, username, port string, timeout time.Duration) (*Client, error) { + sshAgent, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")) + if err != nil { + return nil, err + } + + authMethod := ssh.PublicKeysCallback(agent.NewClient(sshAgent).Signers) + + return connect(username, host, port, authMethod, timeout) +} + +func connectWithPasswordTimeout(host, username, port, pass string, timeout time.Duration) (*Client, error) { + authMethod := ssh.Password(pass) + + return connect(username, host, port, authMethod, timeout) +} + +// EnsureHostKey ensures that a host key trusted and added to the ~/.ssh/known_hosts file +func EnsureHostKey(hostname string) error { + exists, _, err := GetHostKey(hostname) + if err != nil { + return err + } + if exists { + return nil + } + + ctxConnDetails, err := GetContextConnDetails(hostname) + if err != nil { + return err + } + + _, err = connectWithAgentTimeout( + ctxConnDetails.Host, + ctxConnDetails.User, + ctxConnDetails.Port, + 5*time.Second, + ) + + if err != nil { + return err + } + + return nil +} + +// FingerprintSHA256 generates the SHA256 fingerprint for a server SSH host key +func FingerprintSHA256(key ssh.PublicKey) string { + hash := sha256.Sum256(key.Marshal()) + b64hash := base64.StdEncoding.EncodeToString(hash[:]) + trimmed := strings.TrimRight(b64hash, "=") + return fmt.Sprintf("SHA256:%s", trimmed) +} + +// GetContextConnDetails retrieves SSH connection details from a docker context endpoint +func GetContextConnDetails(serverName string) (*dockerSSHPkg.Spec, error) { + dockerContextStore := context.NewDefaultDockerContextStore() + contexts, err := dockerContextStore.Store.List() + if err != nil { + return &dockerSSHPkg.Spec{}, err + } + + if strings.Contains(serverName, ":") { + serverName = strings.Split(serverName, ":")[0] + } + + for _, ctx := range contexts { + endpoint, err := context.GetContextEndpoint(ctx) + if err != nil && strings.Contains(err.Error(), "does not exist") { + // No local context found, we can continue safely + continue + } + if ctx.Name == serverName { + ctxConnDetails, err := dockerSSHPkg.ParseURL(endpoint) + if err != nil { + return &dockerSSHPkg.Spec{}, err + } + logrus.Debugf("found context connection details %v for %s", ctxConnDetails, serverName) + return ctxConnDetails, nil + } + } + + hostConfig, err := GetHostConfig(serverName, "", "") + if err != nil { + return &dockerSSHPkg.Spec{}, err + } + + logrus.Debugf("couldn't find a docker context matching %s", serverName) + logrus.Debugf("searching ~/.ssh/config for a Host entry for %s", serverName) + + connDetails := &dockerSSHPkg.Spec{ + Host: hostConfig.Host, + User: hostConfig.User, + Port: hostConfig.Port, + } + + logrus.Debugf("using %v from ~/.ssh/config for connection details", connDetails) + + return connDetails, nil +} + +// GetHostConfig retrieves a ~/.ssh/config config for a host. +func GetHostConfig(hostname, username, port string) (HostConfig, error) { + var hostConfig HostConfig + + var host, idf string + + if host = ssh_config.Get(hostname, "Hostname"); host == "" { + logrus.Debugf("no hostname found in SSH config, assuming %s", hostname) + host = hostname + } + + if username == "" { + if username = ssh_config.Get(hostname, "User"); username == "" { + systemUser, err := user.Current() + if err != nil { + return hostConfig, err + } + logrus.Debugf("no username found in SSH config or passed on command-line, assuming %s", username) + username = systemUser.Username + } + } + + if port == "" { + if port = ssh_config.Get(hostname, "Port"); port == "" { + logrus.Debugf("no port found in SSH config or passed on command-line, assuming 22") + port = "22" + } + } + + idf = ssh_config.Get(hostname, "IdentityFile") + + hostConfig.Host = host + if idf != "" { + hostConfig.IdentityFile = idf + } + hostConfig.Port = port + hostConfig.User = username + + logrus.Debugf("constructed SSH config %s for %s", hostConfig, hostname) + + return hostConfig, nil +} diff --git a/pkg/upstream/commandconn/connection.go b/pkg/upstream/commandconn/connection.go index ecbc5d6d2..d97e9e44e 100644 --- a/pkg/upstream/commandconn/connection.go +++ b/pkg/upstream/commandconn/connection.go @@ -5,6 +5,7 @@ import ( "net" "net/url" + sshPkg "coopcloud.tech/abra/pkg/ssh" "github.com/docker/cli/cli/connhelper" "github.com/docker/cli/cli/connhelper/ssh" "github.com/docker/cli/cli/context/docker" @@ -19,23 +20,26 @@ import ( // // ssh://@ URL requires Docker 18.09 or later on the remote host. func GetConnectionHelper(daemonURL string) (*connhelper.ConnectionHelper, error) { - return getConnectionHelper(daemonURL, []string{"-o ConnectTimeout=5", "-o StrictHostKeyChecking=no"}) + return getConnectionHelper(daemonURL, []string{"-o ConnectTimeout=5"}) } func getConnectionHelper(daemonURL string, sshFlags []string) (*connhelper.ConnectionHelper, error) { - u, err := url.Parse(daemonURL) + url, err := url.Parse(daemonURL) if err != nil { return nil, err } - switch scheme := u.Scheme; scheme { + switch scheme := url.Scheme; scheme { case "ssh": - sp, err := ssh.ParseURL(daemonURL) + ctxConnDetails, err := ssh.ParseURL(daemonURL) if err != nil { return nil, errors.Wrap(err, "ssh host connection is not valid") } + if err := sshPkg.EnsureHostKey(ctxConnDetails.Host); err != nil { + return nil, err + } return &connhelper.ConnectionHelper{ Dialer: func(ctx context.Context, network, addr string) (net.Conn, error) { - return New(ctx, "ssh", append(sshFlags, sp.Args("docker", "system", "dial-stdio")...)...) + return New(ctx, "ssh", append(sshFlags, ctxConnDetails.Args("docker", "system", "dial-stdio")...)...) }, Host: "http://docker.example.com", }, nil