Files
2026-10-23 17:43:20 +02:00

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