cli, etcd, lib: Add an etcd client over ssh world backend

This provides a new kind of "world" backend, one that runs etcd over an
SSH connection. This is useful for situations where you want to run an
etcd cluster somewhere for clients across the net, but where you don't
want to expose the ports publicly.

If SSH authentication is setup correctly (using public keys) this will
tunnel over SSH for etcd to connect.

This patch does not yet support deploys over SSH, but that should be
fixed in the future as the world code gets cleaned up more.
This commit is contained in:
James Shubin
2025-03-19 05:33:07 -04:00
parent a7a5237b07
commit 02fca6409a
4 changed files with 404 additions and 3 deletions

345
etcd/ssh/ssh.go Normal file
View File

@@ -0,0 +1,345 @@
// 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"
"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()
}

View File

@@ -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