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 closes the SSH client, whose TCP // connection carries it, and that alone unblocks the channel's readers and // writers. Closing the channel as well would be worse than useless: its // Close writes a close message under the channel's write lock, so it would // queue behind a transfer stuck in a TCP write, which is the case Close // exists to survive. type sshSocket struct { net.Conn client *ssh.Client closeOnce sync.Once closeErr error } func (s *sshSocket) Close() error { s.closeOnce.Do(func() { s.closeErr = 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() } }