70 lines
1.8 KiB
Go
70 lines
1.8 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
// sshRun executes one command on a lab VM as the harness user (key from Secret Manager, design 6.11).
|
|
// Host resolution: IP as-is, "legacy:N" or pet VM short name via the compute API.
|
|
func (a *App) sshRun(ctx context.Context, host, command string) (string, error) {
|
|
ip := host
|
|
if net.ParseIP(host) == nil {
|
|
in, err := a.resolveInstance(ctx, host)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
ip = in.IP
|
|
}
|
|
key, err := os.ReadFile(a.cfg.SSHKeyFile)
|
|
if err != nil {
|
|
return "", fmt.Errorf("ssh key: %w", err)
|
|
}
|
|
signer, err := ssh.ParsePrivateKey(key)
|
|
if err != nil {
|
|
return "", fmt.Errorf("ssh key parse: %w", err)
|
|
}
|
|
cfg := &ssh.ClientConfig{
|
|
User: a.cfg.SSHUser,
|
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // lab VMs are recreated by autohealing; host keys are not pinned
|
|
Timeout: 15 * time.Second,
|
|
}
|
|
d := net.Dialer{Timeout: 15 * time.Second}
|
|
conn, err := d.DialContext(ctx, "tcp", ip+":22")
|
|
if err != nil {
|
|
return "", fmt.Errorf("dial %s: %w", ip, err)
|
|
}
|
|
c, chans, reqs, err := ssh.NewClientConn(conn, ip+":22", cfg)
|
|
if err != nil {
|
|
conn.Close()
|
|
return "", fmt.Errorf("ssh %s: %w", ip, err)
|
|
}
|
|
client := ssh.NewClient(c, chans, reqs)
|
|
defer client.Close()
|
|
sess, err := client.NewSession()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer sess.Close()
|
|
var out bytes.Buffer
|
|
sess.Stdout, sess.Stderr = &out, &out
|
|
done := make(chan error, 1)
|
|
go func() { done <- sess.Run(command) }()
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
return out.String(), fmt.Errorf("%s: %w: %s", command, err, out.String())
|
|
}
|
|
return out.String(), nil
|
|
case <-ctx.Done():
|
|
return out.String(), ctx.Err()
|
|
}
|
|
}
|