// Mgmt // Copyright (C) James Shubin and the project contributors // Written by James Shubin and the project contributors // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // Additional permission under GNU GPL version 3 section 7 // // If you modify this program, or any covered work, by linking or combining it // with embedded mcl code and modules (and that the embedded mcl code and // modules which link with this program, contain a copy of their source code in // the authoritative form) containing parts covered by the terms of any other // license, the licensors of this program grant you additional permission to // convey the resulting work. Furthermore, the licensors of this program grant // the original author, James Shubin, additional permission to update this // additional permission if he deems it necessary to achieve the goals of this // additional permission. // Package ssh transports etcd traffic over SSH to provide a special World API. package ssh import ( "context" "encoding/base64" "fmt" "io" "net" "net/url" "os" "path/filepath" "sort" "strconv" "strings" "github.com/purpleidea/mgmt/engine" "github.com/purpleidea/mgmt/etcd" "github.com/purpleidea/mgmt/etcd/client" "github.com/purpleidea/mgmt/util" "github.com/purpleidea/mgmt/util/errwrap" clientv3 "go.etcd.io/etcd/client/v3" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/knownhosts" "google.golang.org/grpc" ) const ( defaultUser = "root" defaultSSHPort uint16 = 22 defaultSSHHostKeyFieldName = "hostkey" // querystring field name defaultEtcdPort uint16 = 2379 // TODO: get this from etcd pkg defaultSSHDir = "~/.ssh/" defaultKnownHostsPath = "~/.ssh/known_hosts" allowRSA = true // are big keys okay? ) // 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. 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_??? key to use for auth. If you // omit this then this will look for your private key in all possible // paths. If you specific a specific path, then only that will be used. // This will expand the ~/ and ~user/ style path expansions. SSHID string // Seeds are the list of etcd endpoints to connect to. Seeds []string // NS is the etcd namespace to use. NS string MetadataPrefix string // expected metadata prefix StoragePrefix string // storage prefix for etcdfs storage StandaloneFs engine.Fs // store an fs here for local usage GetURI func() string *etcd.World init *engine.WorldInit sshClient *ssh.Client cleanups []func() error } // keySigners gets a list of possible key signers. These are used to get the // available types of the keys, and the auth methods. func (obj *World) keySigners() ([]ssh.Signer, error) { sshDir, err := util.ExpandHome(defaultSSHDir) if err != nil { return nil, errwrap.Wrapf(err, "can't find home directory") } if sshDir == "" { return nil, fmt.Errorf("empty path found") } files, err := os.ReadDir(sshDir) if err != nil { return nil, err } signers := []ssh.Signer{} // XXX: Should we aim to pull the keys out by order of preference? for _, file := range files { p := filepath.Join(sshDir, file.Name()) if file.IsDir() || obj.isPossiblePrivateKeyFile(p) != nil { continue } signer, err := obj.keySigner(p) if err != nil { obj.init.Logf("%s", err) continue } signers = append(signers, signer) } return signers, nil } // keySigner returns a single signer from an absolute path. func (obj *World) keySigner(p string) (ssh.Signer, error) { data, err := os.ReadFile(p) if err != nil { return nil, fmt.Errorf("key file error: %s", err) } if len(data) == 0 { return nil, fmt.Errorf("empty key file at: %s", p) } // A public key may be used to authenticate against the server by using // an unencrypted PEM-encoded private key file. If you have an encrypted // private key, the crypto/x509 package can be used to decrypt it. signer, err := ssh.ParsePrivateKey(data) if err != nil { if _, ok := err.(*ssh.PassphraseMissingError); ok { return nil, fmt.Errorf("password required for key file:: %s", p) } return nil, fmt.Errorf("key file parsing error: %s", err) } obj.init.Logf("found auth option in: %s", p) // return the Signer for this private key return signer, nil } // isPossiblePrivateKeyFile determines if we've found a private key file. func (obj *World) isPossiblePrivateKeyFile(p string) error { b := filepath.Base(p) //d := filepath.Dir(p) // no trailing slash :( if !strings.HasPrefix(b, "id_") { return fmt.Errorf("keys start with id_???") } if strings.HasSuffix(b, ".pub") { return fmt.Errorf("this is a public key") } if _, err := os.Stat(p + ".pub"); err != nil { return fmt.Errorf("matching public key is inaccessible") } // TODO: should we rule out anything else? return nil } // prioritizeHostKeyAlgorithms returns the host key algorithms that we tell the // server that we support. The order matters, because this ordering will let the // server know which we can authenticate against. Once we send a list, the // server then only returns a single one, so it's important that we sort this // list properly with what we have available at the very top. func (obj *World) prioritizeHostKeyAlgorithms(allHostKeyAlgos, keyTypes []string) []string { rank := make(map[string]int, len(keyTypes)) for i, t := range keyTypes { rank[t] = i } sorted := make([]string, len(allHostKeyAlgos)) copy(sorted, allHostKeyAlgos) sort.SliceStable(sorted, func(i, j int) bool { rankI, okI := rank[sorted[i]] rankJ, okJ := rank[sorted[j]] switch { case okI && okJ: return rankI < rankJ case okI: return true case okJ: return false default: return false } }) return sorted } // 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 { // 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) } // 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 { obj.init.Logf("server host key type: %s", key.Type()) obj.init.Logf("host key fingerprint: %s", ssh.FingerprintSHA256(key)) // First try our one known key if it exists. if hostkey != nil { fn := ssh.FixedHostKey(hostkey) if fn(hostname, remote, key) == nil { obj.init.Logf("matched key") return nil // found it! } obj.init.Logf("did not match known key: %s", ssh.FingerprintSHA256(hostkey)) } // 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 } obj.init.Logf("trying known_hosts file at: %s", p) err = fn(hostname, remote, key) if err == nil { obj.init.Logf("host key matched") return nil } ke, ok := err.(*knownhosts.KeyError) // give a better error? if !ok || len(ke.Want) == 0 { return err } // Based on what we initially have in our ~/.ssh/ dir, our ssh // client offers keys to the server differently, and the server // replies with up to one of our acceptable choices. If none are // available, then this error message is weird, so we do all // this to make it clearer. types := []string{} for _, kk := range ke.Want { // known keys typ := kk.Key.Type() types = append(types, typ) // We found what the server offered, error normally... if key.Type() == typ { return err } } return fmt.Errorf("no known_hosts entry matching type, have: %s", strings.Join(types, ", ")) } } // Connect runs first. func (obj *World) Connect(ctx context.Context, init *engine.WorldInit) error { obj.init = init obj.cleanups = []func() error{} if len(obj.Seeds) == 0 { return fmt.Errorf("at least one seed is required") } seedSSH := make(map[string]string) for _, seed := range obj.Seeds { u, err := url.Parse(seed) if err != nil { return err } hostname := u.Hostname() if hostname == "" { return fmt.Errorf("empty hostname") } port := strconv.Itoa(int(defaultSSHPort)) // default if s := u.Port(); s != "" { port = s } addr := fmt.Sprintf("%s:%s", hostname, port) if s := u.Scheme; s != "http" && s != "https" { return fmt.Errorf("invalid scheme: %s", s) } seedSSH[seed] = addr // remove the scheme! } if l := len(obj.Seeds) - len(seedSSH); l != 0 { return fmt.Errorf("found %d duplicate tunnels", l) } s := obj.URL scheme := "ssh://" // the url.Parse function parses incorrectly without a scheme prefix :/ if !strings.HasPrefix(s, scheme) { s = scheme + s } u, err := url.Parse(s) if err != nil { return err } user := defaultUser if s := u.User.Username(); s != "" { user = s } hostname := u.Hostname() if hostname == "" { return fmt.Errorf("empty hostname") } port := strconv.Itoa(int(defaultSSHPort)) // default if s := u.Port(); s != "" { 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) // Preference order of keys I have available... keyTypes := []string{ //ssh.KeyAlgoED25519, // "ssh-ed25519" //ssh.KeyAlgoRSA, // "ssh-rsa" } auths := []ssh.AuthMethod{} //auths = append(auths, ssh.Password("password")) // testing if obj.SSHID != "" { p, err := util.ExpandHome(obj.SSHID) if err != nil { return errwrap.Wrapf(err, "can't find home directory") } if p == "" { return fmt.Errorf("empty path specified") } signer, err := obj.keySigner(p) if err != nil { return err } typ := signer.PublicKey().Type() keyTypes = append(keyTypes, typ) auths = append(auths, ssh.PublicKeys(signer)) // add one } if len(auths) == 0 { signers, err := obj.keySigners() if err != nil { return err } for _, signer := range signers { typ := signer.PublicKey().Type() keyTypes = append(keyTypes, typ) } // TODO: should the order of the signers matter? if len(signers) > 0 { auths = append(auths, ssh.PublicKeys(signers...)) // add all } } if len(auths) == 0 { return fmt.Errorf("no auth options available") } obj.init.Logf("found %d available key types: %s", len(keyTypes), strings.Join(keyTypes, ", ")) algorithms := ssh.SupportedAlgorithms() preferredAlgoOrder := algorithms.HostKeys // the defaults if allowRSA { preferredAlgoOrder = append(preferredAlgoOrder, ssh.KeyAlgoRSA) } obj.init.Logf("supported algos: %s", strings.Join(preferredAlgoOrder, ", ")) // SSH connection configuration sshConfig := &ssh.ClientConfig{ User: user, Auth: auths, //HostKeyCallback: ssh.InsecureIgnoreHostKey(), // testing HostKeyCallback: obj.hostKeyCallback(pubKey), // This is the list of host key algorithms that this SSH client // will offer to the SSH server when it says hello. This can be // different from what a normal terminal SSH client might do, // which means you might not get the right SSH host key algo // offered back to you, so make sure you provide what it's // asking for. Maybe we need to make this configurable by the // user. //HostKeyAlgorithms: algorithms.HostKeys, HostKeyAlgorithms: obj.prioritizeHostKeyAlgorithms(preferredAlgoOrder, keyTypes), } obj.init.Logf("ssh: %s@%s", user, addr) obj.sshClient, err = dialSSHWithContext(ctx, "tcp", addr, sshConfig) if err != nil { return err } obj.cleanups = append(obj.cleanups, func() error { e := obj.sshClient.Close() if obj.init.Debug && e != nil { obj.init.Logf("ssh client close error: %+v", e) } return e }) // This runs repeatedly when etcd tries to reconnect. grpcWithContextDialerFunc := func(ctx context.Context, addr string) (net.Conn, error) { var reterr error for _, seed := range obj.Seeds { // first successful connect wins if addr != seedSSH[seed] { continue // not what we're expecting } obj.init.Logf("tunnel: %s", addr) tunnel, err := obj.sshClient.Dial("tcp", addr) if err != nil { reterr = err obj.init.Logf("ssh dial error: %v", err) continue } // TODO: do we need a mutex around adding these? obj.cleanups = append(obj.cleanups, func() error { e := tunnel.Close() if e == io.EOF { // XXX: why does this happen? return nil // ignore } if obj.init.Debug && e != nil { obj.init.Logf("ssh client close error: %+v", e) } return e }) return tunnel, nil // connected successfully } if reterr != nil { return nil, reterr } return nil, fmt.Errorf("no ssh tunnels available") // TODO: better error message? } etcdClient, err := clientv3.New(clientv3.Config{ Endpoints: obj.Seeds, DialOptions: []grpc.DialOption{ grpc.WithContextDialer(grpcWithContextDialerFunc), }, }) if err != nil { return errwrap.Append(obj.cleanup(), err) } obj.cleanups = append(obj.cleanups, func() error { e := etcdClient.Close() if obj.init.Debug && e != nil { obj.init.Logf("etcd client close error: %+v", e) } return e }) c := client.NewClientFromNamespaceStr(etcdClient, obj.NS) obj.World = &etcd.World{ // TODO: Pass through more data if the struct for etcd changes. Client: c, MetadataPrefix: obj.MetadataPrefix, StoragePrefix: obj.StoragePrefix, StandaloneFs: obj.StandaloneFs, GetURI: obj.GetURI, } if err := obj.World.Connect(ctx, init); err != nil { return errwrap.Append(obj.cleanup(), err) } obj.cleanups = append(obj.cleanups, func() error { e := obj.World.Cleanup() if obj.init.Debug && e != nil { obj.init.Logf("world close error: %+v", e) } return e }) return nil } // cleanup performs all the "close" actions either at the very end or as we go. func (obj *World) cleanup() error { var errs error for i := len(obj.cleanups) - 1; i >= 0; i-- { // reverse f := obj.cleanups[i] if err := f(); err != nil { errs = errwrap.Append(errs, err) } } obj.cleanups = nil // clean return errs } // Cleanup runs last. func (obj *World) Cleanup() error { return obj.cleanup() } // dialSSHWithContext wraps ssh.Dial so that we can have a context to cancel. func dialSSHWithContext(ctx context.Context, network, addr string, config *ssh.ClientConfig) (*ssh.Client, error) { dialer := &net.Dialer{} conn, err := dialer.DialContext(ctx, network, addr) if err != nil { return nil, err } c, chans, reqs, err := ssh.NewClientConn(conn, addr, config) if err != nil { conn.Close() return nil, err } return ssh.NewClient(c, chans, reqs), nil }