diff --git a/cli/deploy.go b/cli/deploy.go index d1bebe24..5afab0fc 100644 --- a/cli/deploy.go +++ b/cli/deploy.go @@ -40,6 +40,7 @@ import ( "github.com/purpleidea/mgmt/etcd" "github.com/purpleidea/mgmt/etcd/client" etcdfs "github.com/purpleidea/mgmt/etcd/fs" + etcdSSH "github.com/purpleidea/mgmt/etcd/ssh" "github.com/purpleidea/mgmt/gapi" "github.com/purpleidea/mgmt/lib" "github.com/purpleidea/mgmt/util" @@ -53,6 +54,15 @@ import ( // particular one contains all the common flags for the `deploy` subcommand // which all frontends can use. type DeployArgs struct { + // SshUrl can be specified if we want to transport the SSH client + // connection over SSH. If this is specified, the second hop is made + // with the Seeds values, but they connect from this destination. You + // can specify this in the standard james@server:22 format. This will + // use your ~/.ssh/ directory for public key authentication and + // verifying the host key in the known_hosts file. This must already be + // setup for things to work. + SshUrl string `arg:"--ssh-url" help:"transport the etcd client connection over SSH to this server"` + Seeds []string `arg:"--seeds,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"` @@ -197,6 +207,22 @@ func (obj *DeployArgs) Run(ctx context.Context, data *cliUtil.Data) (bool, error //GetURI: func() string { //}, } + if obj.SshUrl != "" { // alternate world implementation over SSH + world = &etcdSSH.World{ + URL: obj.SshUrl, + //Hostname: hostname, + //Client: client, + NS: lib.NS, + //MetadataPrefix: lib.MetadataPrefix, + //StoragePrefix: lib.StoragePrefix, + //StandaloneFs: ???.DeployFs, // used for static deploys + //GetURI: func() string { + //}, + } + // XXX: We need to first get rid of the standalone etcd client, + // and then pull the etcdfs stuff in so it uses that client. + return false, fmt.Errorf("--ssh-url is not implemented yet") + } worldInit := &engine.WorldInit{ Debug: data.Flags.Debug, Logf: func(format string, v ...interface{}) { diff --git a/etcd/ssh/ssh.go b/etcd/ssh/ssh.go new file mode 100644 index 00000000..b10105a1 --- /dev/null +++ b/etcd/ssh/ssh.go @@ -0,0 +1,345 @@ +// 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" + "fmt" + "io" + "net" + "net/url" + "os" + "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 + 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. + URL 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 + // that will only 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 + + Hostname string // uuid for the consumer of these + 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 +} + +// sshKeyAuth is a helper function to get the ssh key auth struct needed. +func (obj *World) sshKeyAuth(sshID string) (ssh.AuthMethod, error) { + if sshID == "" { + return nil, fmt.Errorf("empty path specified") + } + + // expand strings of the form: ~james/.ssh/id_rsa + p, err := util.ExpandHome(sshID) + if err != nil { + return nil, errwrap.Wrapf(err, "can't find home directory") + } + if p == "" { + return nil, fmt.Errorf("empty path specified") + } + // 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. + key, err := os.ReadFile(p) + if err != nil { + return nil, err + } + + // create the Signer for this private key + signer, err := ssh.ParsePrivateKey(key) + if err != nil { + return nil, err + } + + 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) + if err != nil { + return nil, errwrap.Wrapf(err, "can't find home directory") + } + if p == "" { + return nil, fmt.Errorf("empty path specified") + } + + return knownhosts.New(p) +} + +// Init runs first. +func (obj *World) Init(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 + } + + addr := fmt.Sprintf("%s:%s", hostname, port) + + auths := []ssh.AuthMethod{} + //auths = append(auths, ssh.Password("password")) // testing + choices := []string{ + //obj.SshID, + defaultIDEd25519Path, + defaultIDRsaPath, // "~/.ssh/id_rsa" + } + if obj.SshID != "" { + choices = []string{ + obj.SshID, + } + } + for _, p := range choices { + if p == "" { + continue + } + auth, err := obj.sshKeyAuth(p) + if err != nil { + obj.init.Logf("can't get auth from: %s", p) + continue + } + auths = append(auths, auth) + } + if len(auths) == 0 { + 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, + } + + obj.init.Logf("ssh: %s@%s", user, addr) + obj.sshClient, err = ssh.Dial("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 + }) + + tunnels := make(map[string]net.Conn) + for _, seed := range obj.Seeds { + addr := seedSSH[seed] + obj.init.Logf("tunnel: %s", addr) + tunnel, err := obj.sshClient.Dial("tcp", addr) + if err != nil { + return errwrap.Append(obj.cleanup(), err) + } + 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 + }) + tunnels[addr] = tunnel + } + + etcdClient, err := clientv3.New(clientv3.Config{ + Endpoints: obj.Seeds, + DialOptions: []grpc.DialOption{ + grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) { + tunnel, exists := tunnels[addr] + if !exists { + obj.init.Logf("can't find tunnel: %s", addr) // tell user early... + return nil, fmt.Errorf("can't find tunnel: %s", addr) + } + // TODO: print the scheme here on this log msg + obj.init.Logf("etcd dial: %s", addr) + return tunnel, nil + }), + }, + }) + 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. + Hostname: obj.Hostname, + Client: c, + MetadataPrefix: obj.MetadataPrefix, + StoragePrefix: obj.StoragePrefix, + StandaloneFs: obj.StandaloneFs, + GetURI: obj.GetURI, + } + if err := obj.World.Init(init); err != nil { + return errwrap.Append(obj.cleanup(), err) + } + obj.cleanups = append(obj.cleanups, func() error { + e := obj.World.Close() + 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 +} + +// Close runs last. +func (obj *World) Close() error { + return obj.cleanup() +} diff --git a/etcd/world.go b/etcd/world.go index 60a76982..90a0befa 100644 --- a/etcd/world.go +++ b/etcd/world.go @@ -52,7 +52,9 @@ import ( // World is an etcd backed implementation of the World interface. type World struct { - Hostname string // uuid for the consumer of these + // NOTE: Update the etcd/ssh/ World struct if this one changes. + Hostname string // uuid for the consumer of these + // XXX: build your own etcd client... Client interfaces.Client MetadataPrefix string // expected metadata prefix StoragePrefix string // storage prefix for etcdfs storage diff --git a/lib/main.go b/lib/main.go index b47d99ef..6b199940 100644 --- a/lib/main.go +++ b/lib/main.go @@ -52,6 +52,7 @@ import ( "github.com/purpleidea/mgmt/etcd/chooser" etcdClient "github.com/purpleidea/mgmt/etcd/client" etcdInterfaces "github.com/purpleidea/mgmt/etcd/interfaces" + etcdSSH "github.com/purpleidea/mgmt/etcd/ssh" "github.com/purpleidea/mgmt/gapi" "github.com/purpleidea/mgmt/gapi/empty" "github.com/purpleidea/mgmt/pgp" @@ -148,6 +149,15 @@ type Config struct { // this many seconds. Use 0 to disable this. MaxRuntime uint `arg:"--max-runtime,env:MGMT_MAX_RUNTIME" help:"exit after a maximum of approximately this many seconds"` + // SshUrl can be specified if we want to transport the SSH client + // connection over SSH. If this is specified, the second hop is made + // with the Seeds values, but they connect from this destination. You + // can specify this in the standard james@server:22 format. This will + // use your ~/.ssh/ directory for public key authentication and + // verifying the host key in the known_hosts file. This must already be + // setup for things to work. + SshUrl string `arg:"--ssh-url" help:"transport the etcd client connection over SSH to this server"` + // Seeds are the list of default etcd client endpoints. If empty, it // will startup a new server. Seeds []string `arg:"--seeds,env:MGMT_SEEDS" help:"default etcd client endpoints"` @@ -611,8 +621,9 @@ func (obj *Main) Run() error { // an etcd component from the etcd package added in. var world engine.World world = &etcd.World{ - Hostname: hostname, - Client: client, + Hostname: hostname, + Client: client, + //NS: NS, MetadataPrefix: MetadataPrefix, StoragePrefix: StoragePrefix, StandaloneFs: obj.DeployFs, // used for static deploys @@ -623,6 +634,23 @@ func (obj *Main) Run() error { return gapiInfoResult.URI }, } + if obj.SshUrl != "" { // alternate world implementation over SSH + world = &etcdSSH.World{ + URL: obj.SshUrl, + Seeds: obj.Seeds, + Hostname: hostname, + NS: NS, + MetadataPrefix: MetadataPrefix, + StoragePrefix: StoragePrefix, + StandaloneFs: obj.DeployFs, // used for static deploys + GetURI: func() string { + if gapiInfoResult == nil { + return "" + } + return gapiInfoResult.URI + }, + } + } worldInit := &engine.WorldInit{ Debug: obj.Debug, Logf: func(format string, v ...interface{}) {