cli, etcd, lib, setup: Support ssh hostkey logic

This makes it easy to pass in the expected key so that we never have to
guess and risk MITM attacks.
This commit is contained in:
James Shubin
2025-06-07 17:55:41 -04:00
parent 55eeb50fb4
commit 1ccec72a7c
5 changed files with 113 additions and 32 deletions

View File

@@ -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"`
@@ -211,6 +216,7 @@ 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,
HostKey: obj.SSHHostKey,
Seeds: obj.Seeds,
NS: lib.NS,
//MetadataPrefix: lib.MetadataPrefix,

View File

@@ -165,6 +165,8 @@ type SetupPkgArgs struct {
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"`
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)"`

View File

@@ -32,6 +32,7 @@ package ssh
import (
"context"
"encoding/base64"
"fmt"
"io"
"net"
@@ -55,6 +56,7 @@ import (
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"
@@ -65,9 +67,21 @@ const (
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
}
// 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() (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 nil, errwrap.Wrapf(err, "can't find home directory")
return errwrap.Wrapf(err, "can't find home directory for known_hosts file")
}
if p == "" {
return nil, fmt.Errorf("empty path specified")
return fmt.Errorf("empty known_hosts path specified")
}
return knownhosts.New(p)
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)

View File

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

View File

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