Every go-libvirt call runs under its own budget and a cancelled or overlong call kills the connection; credentials are read on every dial; one span and one log line per RPC come from a decorator applied where connections are made. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
217 lines
6.4 KiB
Go
217 lines
6.4 KiB
Go
package kvm2
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"net"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
"golang.org/x/crypto/ssh/knownhosts"
|
|
|
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
|
)
|
|
|
|
// dialStage names the step of a dial that failed, so the Checker (spec
|
|
// entry 16) can pick a reason without parsing messages.
|
|
type dialStage string
|
|
|
|
const (
|
|
// stageCredentials: the key or known_hosts file is missing or unparsable.
|
|
stageCredentials dialStage = "credentials"
|
|
// stageTCP: the TCP connection to host.Address failed.
|
|
stageTCP dialStage = "tcp"
|
|
// stageSSH: the SSH handshake failed, including host key and auth.
|
|
stageSSH dialStage = "ssh"
|
|
// stageSocket: the forwarded channel to the libvirt socket was refused.
|
|
stageSocket dialStage = "socket"
|
|
)
|
|
|
|
// dialError tags a dial failure with its stage and host. Unwrap keeps the
|
|
// cause visible, so classify still sees *knownhosts.KeyError, fs.ErrNotExist,
|
|
// the "unable to authenticate" text and *net.OpError.
|
|
type dialError struct {
|
|
stage dialStage
|
|
host string
|
|
err error
|
|
}
|
|
|
|
func (e *dialError) Error() string {
|
|
return fmt.Sprintf("dialing host %s (%s): %v", e.host, e.stage, e.err)
|
|
}
|
|
|
|
func (e *dialError) Unwrap() error { return e.err }
|
|
|
|
// stageOf returns the failed stage of a dial error, if err is one.
|
|
func stageOf(err error) (dialStage, bool) {
|
|
var de *dialError
|
|
if !errors.As(err, &de) {
|
|
return "", false
|
|
}
|
|
return de.stage, true
|
|
}
|
|
|
|
const (
|
|
defaultKeepaliveInterval = 15 * time.Second
|
|
keepaliveRequest = "keepalive@openssh.com"
|
|
)
|
|
|
|
// credentials is what one dial needs from the mounted files. They are read
|
|
// on every dial and never cached (spec entry 14), so rotating the Secret
|
|
// takes effect on the next connection.
|
|
type credentials struct {
|
|
signer ssh.Signer
|
|
hostKey ssh.HostKeyCallback
|
|
}
|
|
|
|
// loadCredentials reads and parses the key and known_hosts files. A missing
|
|
// file keeps fs.ErrNotExist (Transient: the Secret has not landed); a file
|
|
// that does not parse is Permanent. Messages name the path and carry the
|
|
// parser's own error, never the file's content.
|
|
func loadCredentials(cfg provider.KVMConfig) (credentials, error) {
|
|
keyBytes, err := os.ReadFile(cfg.SSHKeyFile)
|
|
if err != nil {
|
|
return credentials{}, fmt.Errorf("reading ssh key file %s: %w", cfg.SSHKeyFile, err)
|
|
}
|
|
signer, err := ssh.ParsePrivateKey(keyBytes)
|
|
if err != nil {
|
|
return credentials{}, permanentf("parsing ssh key file %s: %v", cfg.SSHKeyFile, err)
|
|
}
|
|
if cfg.InsecureIgnoreHostKey {
|
|
return credentials{signer: signer, hostKey: ssh.InsecureIgnoreHostKey()}, nil
|
|
}
|
|
hostKey, err := knownhosts.New(cfg.KnownHostsFile)
|
|
if err != nil {
|
|
var pathErr *fs.PathError
|
|
if errors.As(err, &pathErr) {
|
|
return credentials{}, fmt.Errorf("reading known_hosts file %s: %w", cfg.KnownHostsFile, err)
|
|
}
|
|
return credentials{}, permanentf("parsing known_hosts file %s: %v", cfg.KnownHostsFile, err)
|
|
}
|
|
return credentials{signer: signer, hostKey: hostKey}, nil
|
|
}
|
|
|
|
// sshSocket is the libvirt socket forwarded through one SSH connection. The
|
|
// embedded Conn is the channel; Close also closes the SSH client, which is
|
|
// what unblocks anything reading the channel.
|
|
type sshSocket struct {
|
|
net.Conn
|
|
client *ssh.Client
|
|
|
|
closeOnce sync.Once
|
|
closeErr error
|
|
}
|
|
|
|
func (s *sshSocket) Close() error {
|
|
s.closeOnce.Do(func() {
|
|
s.closeErr = errors.Join(s.Conn.Close(), s.client.Close())
|
|
})
|
|
return s.closeErr
|
|
}
|
|
|
|
// keepalive sends keepalive@openssh.com every interval until stop is closed
|
|
// or a request fails or takes longer than the interval, and returns why it
|
|
// stopped (nil for stop). The caller marks its link dead on an error.
|
|
func (s *sshSocket) keepalive(interval time.Duration, stop <-chan struct{}) error {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return nil
|
|
case <-ticker.C:
|
|
}
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, _, err := s.client.SendRequest(keepaliveRequest, true, nil)
|
|
done <- err
|
|
}()
|
|
timer := time.NewTimer(interval)
|
|
select {
|
|
case err := <-done:
|
|
timer.Stop()
|
|
if err != nil {
|
|
return fmt.Errorf("keepalive: %w", err)
|
|
}
|
|
case <-timer.C:
|
|
return fmt.Errorf("keepalive: no reply within %s", interval)
|
|
case <-stop:
|
|
timer.Stop()
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// dialSSHSocket opens host's libvirt socket through SSH: credentials, TCP,
|
|
// handshake, channel. timeout bounds the whole sequence on top of ctx. Each
|
|
// failure is a *dialError naming its stage. Nothing here logs.
|
|
func dialSSHSocket(ctx context.Context, host provider.KVMHost, cfg provider.KVMConfig, timeout time.Duration) (*sshSocket, error) {
|
|
if timeout > 0 {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
}
|
|
creds, err := loadCredentials(cfg)
|
|
if err != nil {
|
|
return nil, &dialError{stage: stageCredentials, host: host.Name, err: err}
|
|
}
|
|
var nd net.Dialer
|
|
tcp, err := nd.DialContext(ctx, "tcp", host.Address)
|
|
if err != nil {
|
|
return nil, &dialError{stage: stageTCP, host: host.Name, err: err}
|
|
}
|
|
clientCfg := &ssh.ClientConfig{
|
|
User: host.User,
|
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(creds.signer)},
|
|
HostKeyCallback: creds.hostKey,
|
|
Timeout: timeout,
|
|
}
|
|
client, err := await(ctx, tcp.Close, func() (*ssh.Client, error) {
|
|
c, chans, reqs, err := ssh.NewClientConn(tcp, host.Address, clientCfg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ssh.NewClient(c, chans, reqs), nil
|
|
})
|
|
if err != nil {
|
|
_ = tcp.Close()
|
|
return nil, &dialError{stage: stageSSH, host: host.Name, err: err}
|
|
}
|
|
conn, err := await(ctx, func() error { return client.Close() }, func() (net.Conn, error) {
|
|
return client.Dial("unix", host.Socket)
|
|
})
|
|
if err != nil {
|
|
_ = client.Close()
|
|
return nil, &dialError{stage: stageSocket, host: host.Name, err: fmt.Errorf("opening %s: %w", host.Socket, err)}
|
|
}
|
|
return &sshSocket{Conn: conn, client: client}, nil
|
|
}
|
|
|
|
// await runs a blocking step in a goroutine. When ctx ends first it calls
|
|
// abort, which closes the transport the step is blocked on, waits for the
|
|
// step to give up, and returns ctx's error instead.
|
|
func await[T any](ctx context.Context, abort func() error, step func() (T, error)) (T, error) {
|
|
type result struct {
|
|
v T
|
|
err error
|
|
}
|
|
done := make(chan result, 1)
|
|
go func() {
|
|
v, err := step()
|
|
done <- result{v, err}
|
|
}()
|
|
select {
|
|
case r := <-done:
|
|
return r.v, r.err
|
|
case <-ctx.Done():
|
|
_ = abort()
|
|
<-done
|
|
var zero T
|
|
return zero, ctx.Err()
|
|
}
|
|
}
|