This makes it easy to pass in the expected key so that we never have to guess and risk MITM attacks.
431 lines
12 KiB
Go
431 lines
12 KiB
Go
// Mgmt
|
|
// Copyright (C) James Shubin and the project contributors
|
|
// Written by James Shubin <james@shubin.ca> 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 <https://www.gnu.org/licenses/>.
|
|
//
|
|
// 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"
|
|
"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
|
|
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. 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
|
|
// 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
|
|
|
|
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
|
|
}
|
|
|
|
// 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 {
|
|
|
|
// 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.
|
|
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)
|
|
|
|
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) // misleading
|
|
continue
|
|
}
|
|
obj.init.Logf("found auth option in: %s", p)
|
|
auths = append(auths, auth)
|
|
}
|
|
if len(auths) == 0 {
|
|
return fmt.Errorf("no auth options available")
|
|
}
|
|
|
|
// SSH connection configuration
|
|
sshConfig := &ssh.ClientConfig{
|
|
User: user,
|
|
Auth: auths,
|
|
//HostKeyCallback: ssh.InsecureIgnoreHostKey(), // testing
|
|
HostKeyCallback: obj.hostKeyCallback(pubKey),
|
|
}
|
|
|
|
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
|
|
}
|