177 lines
4.7 KiB
Go
177 lines
4.7 KiB
Go
// Package sshx runs commands on a PVE node over SSH as root, for what the API
|
|
// does not cover (the password hashes of the pve realm in /etc/pve/priv/shadow.cfg).
|
|
// The host key is pinned: the first connection records it (trust on first use),
|
|
// the next ones refuse any other key.
|
|
package sshx
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
// Options of an SSH access.
|
|
type Options struct {
|
|
Host string // node address
|
|
Port int // 22 by default
|
|
User string // root by default
|
|
Password string // never logged
|
|
HostKey string // pinned host key, authorized_keys format; empty: refused (see Scan)
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// Client runs commands on one node. Each Run opens its own connection: the
|
|
// synchronizations are rare, a connection pool would only hold sessions open.
|
|
type Client struct{ o Options }
|
|
|
|
// New checks the options.
|
|
func New(o Options) (*Client, error) {
|
|
if o.Host == "" {
|
|
return nil, errors.New("ssh: host required")
|
|
}
|
|
if o.Password == "" {
|
|
return nil, errors.New("ssh: password required")
|
|
}
|
|
if o.HostKey == "" {
|
|
return nil, errors.New("ssh: host key not recorded yet, test the cluster first")
|
|
}
|
|
if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(o.HostKey)); err != nil {
|
|
return nil, fmt.Errorf("ssh: recorded host key: %w", err)
|
|
}
|
|
if o.Port == 0 {
|
|
o.Port = 22
|
|
}
|
|
if o.User == "" {
|
|
o.User = "root"
|
|
}
|
|
if o.Timeout == 0 {
|
|
o.Timeout = 15 * time.Second
|
|
}
|
|
return &Client{o}, nil
|
|
}
|
|
|
|
func (c *Client) addr() string { return net.JoinHostPort(c.o.Host, strconv.Itoa(c.o.Port)) }
|
|
|
|
// Run executes cmd and returns its standard output. in is written on the
|
|
// standard input (secrets are never put on the command line).
|
|
func (c *Client) Run(ctx context.Context, cmd string, in []byte) ([]byte, error) {
|
|
want, _, _, _, _ := ssh.ParseAuthorizedKey([]byte(c.o.HostKey))
|
|
cfg := &ssh.ClientConfig{
|
|
User: c.o.User,
|
|
Auth: auth(c.o.Password),
|
|
HostKeyCallback: ssh.FixedHostKey(want),
|
|
Timeout: c.o.Timeout,
|
|
}
|
|
conn, err := dial(ctx, c.addr(), cfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer conn.Close()
|
|
sess, err := conn.NewSession()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ssh %s: %w", c.addr(), err)
|
|
}
|
|
defer sess.Close()
|
|
var out, stderr bytes.Buffer
|
|
sess.Stdout, sess.Stderr = &out, &stderr
|
|
if in != nil {
|
|
sess.Stdin = bytes.NewReader(in)
|
|
}
|
|
done := make(chan error, 1)
|
|
go func() { done <- sess.Run(cmd) }()
|
|
select {
|
|
case err = <-done:
|
|
case <-ctx.Done():
|
|
conn.Close()
|
|
return nil, ctx.Err()
|
|
}
|
|
if err != nil {
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
return out.Bytes(), fmt.Errorf("ssh %s: %s", c.addr(), msg)
|
|
}
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
// auth: password, and keyboard-interactive answering the password (PAM).
|
|
func auth(password string) []ssh.AuthMethod {
|
|
return []ssh.AuthMethod{
|
|
ssh.Password(password),
|
|
ssh.KeyboardInteractive(func(_, _ string, qs []string, _ []bool) ([]string, error) {
|
|
a := make([]string, len(qs))
|
|
for i := range a {
|
|
a[i] = password
|
|
}
|
|
return a, nil
|
|
}),
|
|
}
|
|
}
|
|
|
|
func dial(ctx context.Context, addr string, cfg *ssh.ClientConfig) (*ssh.Client, error) {
|
|
d := net.Dialer{Timeout: cfg.Timeout}
|
|
nc, err := d.DialContext(ctx, "tcp", addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ssh %s: %w", addr, err)
|
|
}
|
|
cc, chans, reqs, err := ssh.NewClientConn(nc, addr, cfg)
|
|
if err != nil {
|
|
nc.Close()
|
|
return nil, fmt.Errorf("ssh %s: %w", addr, err)
|
|
}
|
|
return ssh.NewClient(cc, chans, reqs), nil
|
|
}
|
|
|
|
// Scan connects without a pinned key and returns the key the node presents
|
|
// (authorized_keys format) and its SHA256 fingerprint, after checking that the
|
|
// password is accepted. The caller records the key (trust on first use).
|
|
func Scan(ctx context.Context, o Options) (key, fingerprint string, err error) {
|
|
if o.Port == 0 {
|
|
o.Port = 22
|
|
}
|
|
if o.User == "" {
|
|
o.User = "root"
|
|
}
|
|
if o.Timeout == 0 {
|
|
o.Timeout = 15 * time.Second
|
|
}
|
|
var seen ssh.PublicKey
|
|
cfg := &ssh.ClientConfig{
|
|
User: o.User,
|
|
Auth: auth(o.Password),
|
|
HostKeyCallback: func(_ string, _ net.Addr, k ssh.PublicKey) error {
|
|
seen = k
|
|
return nil
|
|
},
|
|
Timeout: o.Timeout,
|
|
}
|
|
addr := net.JoinHostPort(o.Host, strconv.Itoa(o.Port))
|
|
conn, err := dial(ctx, addr, cfg)
|
|
if seen != nil {
|
|
key = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(seen)))
|
|
fingerprint = ssh.FingerprintSHA256(seen)
|
|
}
|
|
if err != nil {
|
|
return key, fingerprint, err
|
|
}
|
|
conn.Close()
|
|
return key, fingerprint, nil
|
|
}
|
|
|
|
// Fingerprint returns the SHA256 fingerprint of a recorded key ("" if invalid).
|
|
func Fingerprint(key string) string {
|
|
k, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return ssh.FingerprintSHA256(k)
|
|
}
|