diff --git a/cli/deploy.go b/cli/deploy.go index aed933f8..b94a3d6b 100644 --- a/cli/deploy.go +++ b/cli/deploy.go @@ -63,6 +63,11 @@ type DeployArgs struct { // setup for things to work. SSHURL string `arg:"--ssh-url" help:"transport the etcd client connection over SSH to this server"` + // SSHHostKey is the key part (which is already base64 encoded) from a + // known_hosts file, representing the host we're connecting to. If this + // is specified, then it overrides looking for it in the URL. + SSHHostKey string `arg:"--ssh-hostkey" help:"use this ssh known hosts key when connecting over SSH"` + Seeds []string `arg:"--seeds,separate,env:MGMT_SEEDS" help:"default etcd client endpoints"` Noop bool `arg:"--noop" help:"globally force all resources into no-op mode"` Sema int `arg:"--sema" default:"-1" help:"globally add a semaphore to all resources with this lock count"` @@ -210,9 +215,10 @@ func (obj *DeployArgs) Run(ctx context.Context, data *cliUtil.Data) (bool, error } if obj.SSHURL != "" { // alternate world implementation over SSH world = &etcdSSH.World{ - URL: obj.SSHURL, - Seeds: obj.Seeds, - NS: lib.NS, + URL: obj.SSHURL, + HostKey: obj.SSHHostKey, + Seeds: obj.Seeds, + NS: lib.NS, //MetadataPrefix: lib.MetadataPrefix, //StoragePrefix: lib.StoragePrefix, //StandaloneFs: ???.DeployFs, // used for static deploys diff --git a/cli/util/args.go b/cli/util/args.go index bdc98cb5..99b4a3fd 100644 --- a/cli/util/args.go +++ b/cli/util/args.go @@ -163,10 +163,12 @@ type SetupPkgArgs struct { // SetupSvcArgs is the setup service CLI parsing structure and type of the // parsed result. type SetupSvcArgs struct { - BinaryPath string `arg:"--binary-path" help:"path to the binary"` - SSHURL string `arg:"--ssh-url" help:"transport the etcd client connection over SSH to this server"` - Seeds []string `arg:"--seeds,separate,env:MGMT_SEEDS" help:"default etcd client endpoints"` - NoServer bool `arg:"--no-server" help:"do not start embedded etcd server (do not promote from client to peer)"` + BinaryPath string `arg:"--binary-path" help:"path to the binary"` + SSHURL string `arg:"--ssh-url" help:"transport the etcd client connection over SSH to this server"` + SSHHostKey string `arg:"--ssh-hostkey" help:"use this ssh known hosts key when connecting over SSH"` + + Seeds []string `arg:"--seeds,separate,env:MGMT_SEEDS" help:"default etcd client endpoints"` + NoServer bool `arg:"--no-server" help:"do not start embedded etcd server (do not promote from client to peer)"` Install bool `arg:"--install" help:"install the systemd mgmt service"` Start bool `arg:"--start" help:"start the mgmt service"` diff --git a/etcd/ssh/ssh.go b/etcd/ssh/ssh.go index 9e4b9a9f..60e3a094 100644 --- a/etcd/ssh/ssh.go +++ b/etcd/ssh/ssh.go @@ -32,6 +32,7 @@ package ssh import ( "context" + "encoding/base64" "fmt" "io" "net" @@ -53,21 +54,34 @@ import ( ) const ( - defaultUser = "root" - defaultSSHPort uint16 = 22 - defaultEtcdPort uint16 = 2379 // TODO: get this from etcd pkg - defaultIDRsaPath = "~/.ssh/id_rsa" - defaultIDEd25519Path = "~/.ssh/id_ed25519" - defaultKnownHostsPath = "~/.ssh/known_hosts" + defaultUser = "root" + defaultSSHPort uint16 = 22 + defaultSSHHostKeyFieldName = "hostkey" // querystring field name + defaultEtcdPort uint16 = 2379 // TODO: get this from etcd pkg + defaultIDRsaPath = "~/.ssh/id_rsa" + defaultIDEd25519Path = "~/.ssh/id_ed25519" + defaultKnownHostsPath = "~/.ssh/known_hosts" ) // World is an implementation of the world API for etcd over SSH. type World struct { // URL is the ssh server to connect to. Use the format, james@server:22 // or similar. From there, we connect to each of the etcd Seeds, so the - // ip's should be relative to this server. + // ip's should be relative to this server. If you pass in a ?hostkey= + // query string parameter, you can specify a base64, known_hosts key to + // use for confirmation that you're connecting to the right host. + // Without this, it will look in your ~/.ssh/known_hosts file which may + // not necessarily exist yet, and without it connection is impossible. + // You can find the key by running the ssh-keyscan command. It can also + // be read from the HostKey parameter, which avoids you needing to + // urlencode it here. URL string + // HostKey is the key part (which is already base64 encoded) from a + // known_hosts file, representing the host we're connecting to. If this + // is specified, then it overrides looking for it in the URL. + HostKey string + // SSHID is the path to the ~/.ssh/id_rsa or ~/.ssh/id_ed25519 to use // for auth. If you omit this then this will look for your private key // in both of those default paths. If you specific a specific path, then @@ -126,21 +140,60 @@ func (obj *World) sshKeyAuth(sshID string) (ssh.AuthMethod, error) { return ssh.PublicKeys(signer), nil } -// hostKeyCallback is a helper function to get the ssh callback function needed. -func (obj *World) hostKeyCallback() (ssh.HostKeyCallback, error) { - // TODO: consider allowing a user-specified path in the future - s := defaultKnownHostsPath // "~/.ssh/known_hosts" - - // expand strings of the form: ~james/.ssh/known_hosts - p, err := util.ExpandHome(s) +// knownHostsKey takes a known_hosts key entry (just the base64 key part) and +// turns it into the ssh.PublicKey needed for hostKeyCallback. This excerpt was +// taken from: x/crypto/ssh:keys.go:func parseAuthorizedKey +func (obj *World) knownHostsKey(hostkey string) (ssh.PublicKey, error) { + key := make([]byte, base64.StdEncoding.DecodedLen(len(hostkey))) + n, err := base64.StdEncoding.Decode(key, []byte(hostkey)) if err != nil { - return nil, errwrap.Wrapf(err, "can't find home directory") - } - if p == "" { - return nil, fmt.Errorf("empty path specified") + // Make it easier to spot this common error... + s := err.Error() + m := "illegal base64 data at input byte " + if strings.HasPrefix(s, m) { + if d, e := strconv.Atoi(s[len(m):]); e == nil { + obj.init.Logf("error: %v", err) + obj.init.Logf("host key: %s", hostkey) + obj.init.Logf("location: %s^", strings.Repeat(" ", d)) + } + } + return nil, err } + key = key[:n] + return ssh.ParsePublicKey(key) +} - return knownhosts.New(p) +// hostKeyCallback is a helper function to get the ssh callback function needed. +// func (obj *World) hostKeyCallback() (ssh.HostKeyCallback, error) { +func (obj *World) hostKeyCallback(hostkey ssh.PublicKey) ssh.HostKeyCallback { + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + + // First try our one known key if it exists. + if hostkey != nil { + fn := ssh.FixedHostKey(hostkey) + if fn(hostname, remote, key) == nil { + return nil // found it! + } + } + + // TODO: consider allowing a user-specified path in the future + s := defaultKnownHostsPath // "~/.ssh/known_hosts" + + // expand strings of the form: ~james/.ssh/known_hosts + p, err := util.ExpandHome(s) + if err != nil { + return errwrap.Wrapf(err, "can't find home directory for known_hosts file") + } + if p == "" { + return fmt.Errorf("empty known_hosts path specified") + } + + fn, err := knownhosts.New(p) + if err != nil { + return err + } + return fn(hostname, remote, key) + } } // Connect runs first. @@ -198,6 +251,20 @@ func (obj *World) Connect(ctx context.Context, init *engine.WorldInit) error { port = s } + // TODO: Should we read out a list of these, one for each key type? + base64Key := u.Query().Get(defaultSSHHostKeyFieldName) // urlencode me! + if obj.HostKey != "" { // override + base64Key = obj.HostKey + } + var pubKey ssh.PublicKey // known hosts key + if base64Key != "" { + k, err := obj.knownHostsKey(base64Key) + if err != nil { + return errwrap.Wrapf(err, "invalid known_hosts key") + } + pubKey = k + } + addr := fmt.Sprintf("%s:%s", hostname, port) auths := []ssh.AuthMethod{} @@ -228,17 +295,12 @@ func (obj *World) Connect(ctx context.Context, init *engine.WorldInit) error { return fmt.Errorf("no auth options available") } - hostKeyCallback, err := obj.hostKeyCallback() - if err != nil { - return err - } - // SSH connection configuration sshConfig := &ssh.ClientConfig{ User: user, Auth: auths, //HostKeyCallback: ssh.InsecureIgnoreHostKey(), // testing - HostKeyCallback: hostKeyCallback, + HostKeyCallback: obj.hostKeyCallback(pubKey), } obj.init.Logf("ssh: %s@%s", user, addr) diff --git a/lib/main.go b/lib/main.go index 73435d52..371a2fba 100644 --- a/lib/main.go +++ b/lib/main.go @@ -157,6 +157,11 @@ type Config struct { // setup for things to work. SSHURL string `arg:"--ssh-url" help:"transport the etcd client connection over SSH to this server"` + // SSHHostKey is the key part (which is already base64 encoded) from a + // known_hosts file, representing the host we're connecting to. If this + // is specified, then it overrides looking for it in the URL. + SSHHostKey string `arg:"--ssh-hostkey" help:"use this ssh known hosts key when connecting over SSH"` + // Seeds are the list of default etcd client endpoints. If empty, it // will startup a new server. Seeds []string `arg:"--seeds,separate,env:MGMT_SEEDS" help:"default etcd client endpoints"` @@ -620,6 +625,7 @@ func (obj *Main) Run() error { if obj.SSHURL != "" { // alternate world implementation over SSH world = &etcdSSH.World{ URL: obj.SSHURL, + HostKey: obj.SSHHostKey, Seeds: obj.Seeds, NS: NS, MetadataPrefix: MetadataPrefix, diff --git a/setup/svc.go b/setup/svc.go index c15d659b..cc66d5ac 100644 --- a/setup/svc.go +++ b/setup/svc.go @@ -115,6 +115,11 @@ func (obj *Svc) Run(ctx context.Context) error { argv = append(argv, fmt.Sprintf("--ssh-url=%s", s)) } + if s := obj.SetupSvcArgs.SSHHostKey; s != "" { + // TODO: validate ssh url? Should be user@server:port + argv = append(argv, fmt.Sprintf("--ssh-hostkey=%s", s)) + } + for _, seed := range obj.SetupSvcArgs.Seeds { // TODO: validate each seed? s := fmt.Sprintf("--seeds=%s", seed)