cloud/shepherd: move ssh client to own package

Change-Id: I56ad16f8f2f355243c5c0414656bbfbbff1faef5
Reviewed-on: https://review.monogon.dev/c/monogon/+/2791
Reviewed-by: Serge Bazanski <serge@monogon.tech>
Tested-by: Jenkins CI
diff --git a/cloud/shepherd/manager/BUILD.bazel b/cloud/shepherd/manager/BUILD.bazel
index 4119ff7..aa61caa 100644
--- a/cloud/shepherd/manager/BUILD.bazel
+++ b/cloud/shepherd/manager/BUILD.bazel
@@ -9,7 +9,6 @@
         "manager.go",
         "provisioner.go",
         "recoverer.go",
-        "ssh_client.go",
         "ssh_key_signer.go",
     ],
     importpath = "source.monogon.dev/cloud/shepherd/manager",
@@ -21,8 +20,8 @@
         "//cloud/bmaas/bmdb/model",
         "//cloud/shepherd",
         "//go/mflags",
+        "//go/net/ssh",
         "@com_github_google_uuid//:uuid",
-        "@com_github_pkg_sftp//:sftp",
         "@io_k8s_klog_v2//:klog",
         "@org_golang_google_protobuf//proto",
         "@org_golang_x_crypto//ssh",
@@ -47,6 +46,7 @@
         "//cloud/bmaas/bmdb/model",
         "//cloud/lib/component",
         "//cloud/shepherd",
+        "//go/net/ssh",
         "@com_github_google_uuid//:uuid",
         "@io_k8s_klog_v2//:klog",
         "@org_golang_x_time//rate",
diff --git a/cloud/shepherd/manager/fake_ssh_client.go b/cloud/shepherd/manager/fake_ssh_client.go
index 1d9d371..db13b5b 100644
--- a/cloud/shepherd/manager/fake_ssh_client.go
+++ b/cloud/shepherd/manager/fake_ssh_client.go
@@ -5,20 +5,23 @@
 	"crypto/ed25519"
 	"crypto/rand"
 	"fmt"
+	"io"
 	"time"
 
 	"google.golang.org/protobuf/proto"
 
 	apb "source.monogon.dev/cloud/agent/api"
+
+	"source.monogon.dev/go/net/ssh"
 )
 
-// FakeSSHClient is an SSHClient that pretends to start an agent, but in reality
+// FakeSSHClient is an Client that pretends to start an agent, but in reality
 // just responds with what an agent would respond on every execution attempt.
 type FakeSSHClient struct{}
 
 type fakeSSHConnection struct{}
 
-func (f *FakeSSHClient) Dial(ctx context.Context, address string, timeout time.Duration) (SSHConnection, error) {
+func (f *FakeSSHClient) Dial(ctx context.Context, address string, timeout time.Duration) (ssh.Connection, error) {
 	return &fakeSSHConnection{}, nil
 }
 
@@ -46,7 +49,7 @@
 	return arspb, nil, nil
 }
 
-func (f *fakeSSHConnection) Upload(ctx context.Context, targetPath string, data []byte) error {
+func (f *fakeSSHConnection) Upload(ctx context.Context, targetPath string, _ io.Reader) error {
 	if targetPath != "/fake/path" {
 		return fmt.Errorf("unexpected target path in test")
 	}
diff --git a/cloud/shepherd/manager/initializer.go b/cloud/shepherd/manager/initializer.go
index 5abbc68..2c04c43 100644
--- a/cloud/shepherd/manager/initializer.go
+++ b/cloud/shepherd/manager/initializer.go
@@ -1,6 +1,7 @@
 package manager
 
 import (
+	"bytes"
 	"context"
 	"crypto/ed25519"
 	"crypto/x509"
@@ -23,6 +24,7 @@
 	"source.monogon.dev/cloud/bmaas/bmdb/metrics"
 	"source.monogon.dev/cloud/bmaas/bmdb/model"
 	"source.monogon.dev/cloud/shepherd"
+	"source.monogon.dev/go/net/ssh"
 )
 
 // InitializerConfig configures how the Initializer will deploy Agents on
@@ -126,13 +128,13 @@
 type Initializer struct {
 	InitializerConfig
 
-	sshClient SSHClient
+	sshClient ssh.Client
 	p         shepherd.Provider
 }
 
 // NewInitializer creates an Initializer instance, checking the
 // InitializerConfig, SharedConfig and AgentConfig for errors.
-func NewInitializer(p shepherd.Provider, sshClient SSHClient, ic InitializerConfig) (*Initializer, error) {
+func NewInitializer(p shepherd.Provider, sshClient ssh.Client, ic InitializerConfig) (*Initializer, error) {
 	if err := ic.Check(); err != nil {
 		return nil, err
 	}
@@ -219,7 +221,7 @@
 	// Upload the agent executable.
 
 	klog.Infof("Uploading the agent executable (machine ID: %s, addr: %s).", mid, addr)
-	if err := conn.Upload(sctx, i.TargetPath, i.Executable); err != nil {
+	if err := conn.Upload(sctx, i.TargetPath, bytes.NewReader(i.Executable)); err != nil {
 		return nil, fmt.Errorf("while uploading agent executable: %w", err)
 	}
 	klog.V(1).Infof("Upload successful (machine ID: %s, addr: %s).", mid, addr)
diff --git a/cloud/shepherd/manager/provider_test.go b/cloud/shepherd/manager/provider_test.go
index b18dc45..4cdfb18 100644
--- a/cloud/shepherd/manager/provider_test.go
+++ b/cloud/shepherd/manager/provider_test.go
@@ -12,6 +12,7 @@
 	"source.monogon.dev/cloud/bmaas/bmdb"
 	"source.monogon.dev/cloud/bmaas/bmdb/model"
 	"source.monogon.dev/cloud/shepherd"
+	"source.monogon.dev/go/net/ssh"
 )
 
 type dummyMachine struct {
@@ -38,17 +39,17 @@
 }
 
 type dummySSHClient struct {
-	SSHClient
+	ssh.Client
 	dp *dummyProvider
 }
 
 type dummySSHConnection struct {
-	SSHConnection
+	ssh.Connection
 	m *dummyMachine
 }
 
 func (dsc *dummySSHConnection) Execute(ctx context.Context, command string, stdin []byte) ([]byte, []byte, error) {
-	stdout, stderr, err := dsc.SSHConnection.Execute(ctx, command, stdin)
+	stdout, stderr, err := dsc.Connection.Execute(ctx, command, stdin)
 	if err != nil {
 		return nil, nil, err
 	}
@@ -57,8 +58,8 @@
 	return stdout, stderr, nil
 }
 
-func (dsc *dummySSHClient) Dial(ctx context.Context, address string, timeout time.Duration) (SSHConnection, error) {
-	conn, err := dsc.SSHClient.Dial(ctx, address, timeout)
+func (dsc *dummySSHClient) Dial(ctx context.Context, address string, timeout time.Duration) (ssh.Connection, error) {
+	conn, err := dsc.Client.Dial(ctx, address, timeout)
 	if err != nil {
 		return nil, err
 	}
@@ -77,10 +78,10 @@
 	return &dummySSHConnection{conn, m}, nil
 }
 
-func (dp *dummyProvider) sshClient() SSHClient {
+func (dp *dummyProvider) sshClient() ssh.Client {
 	return &dummySSHClient{
-		SSHClient: &FakeSSHClient{},
-		dp:        dp,
+		Client: &FakeSSHClient{},
+		dp:     dp,
 	}
 }
 
diff --git a/cloud/shepherd/manager/ssh_client.go b/cloud/shepherd/manager/ssh_client.go
deleted file mode 100644
index a1a305a..0000000
--- a/cloud/shepherd/manager/ssh_client.go
+++ /dev/null
@@ -1,143 +0,0 @@
-package manager
-
-import (
-	"bytes"
-	"context"
-	"fmt"
-	"io"
-	"net"
-	"time"
-
-	"github.com/pkg/sftp"
-	"golang.org/x/crypto/ssh"
-)
-
-// SSHClient defines a simple interface to an abstract SSH client. Usually this
-// would be PlainSSHClient, but tests can use this interface to dependency-inject
-// fake SSH connections.
-type SSHClient interface {
-	// Dial returns an SSHConnection to a given address (host:port pair) with
-	// a timeout for connection.
-	Dial(ctx context.Context, address string, connectTimeout time.Duration) (SSHConnection, error)
-}
-
-type SSHConnection interface {
-	// Execute a given command on a remote host synchronously, passing in stdin as
-	// input, and returning a captured stdout/stderr. The returned data might be
-	// valid even when err != nil, which might happen if the remote side returned a
-	// non-zero exit code.
-	Execute(ctx context.Context, command string, stdin []byte) (stdout []byte, stderr []byte, err error)
-	// Upload a given blob to a targetPath on the system and make executable.
-	Upload(ctx context.Context, targetPath string, data []byte) error
-	// Close this connection.
-	Close() error
-}
-
-// PlainSSHClient implements SSHClient (and SSHConnection) using
-// golang.org/x/crypto/ssh.
-type PlainSSHClient struct {
-	AuthMethod ssh.AuthMethod
-	Username   string
-}
-
-type plainSSHConn struct {
-	cl *ssh.Client
-}
-
-func (p *PlainSSHClient) Dial(ctx context.Context, address string, connectTimeout time.Duration) (SSHConnection, error) {
-	d := net.Dialer{
-		Timeout: connectTimeout,
-	}
-	conn, err := d.DialContext(ctx, "tcp", address)
-	if err != nil {
-		return nil, err
-	}
-	conf := &ssh.ClientConfig{
-		User: p.Username,
-		Auth: []ssh.AuthMethod{
-			p.AuthMethod,
-		},
-		// Ignore the host key, since it's likely the first time anything logs into
-		// this device, and also because there's no way of knowing its fingerprint.
-		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
-		// Timeout sets a bound on the time it takes to set up the connection, but
-		// not on total session time.
-		Timeout: connectTimeout,
-	}
-	conn2, chanC, reqC, err := ssh.NewClientConn(conn, address, conf)
-	if err != nil {
-		return nil, err
-	}
-	cl := ssh.NewClient(conn2, chanC, reqC)
-	return &plainSSHConn{
-		cl: cl,
-	}, nil
-}
-
-func (p *plainSSHConn) Execute(ctx context.Context, command string, stdin []byte) (stdout []byte, stderr []byte, err error) {
-	sess, err := p.cl.NewSession()
-	if err != nil {
-		return nil, nil, fmt.Errorf("while creating SSH session: %w", err)
-	}
-	stdoutBuf := bytes.NewBuffer(nil)
-	stderrBuf := bytes.NewBuffer(nil)
-	sess.Stdin = bytes.NewBuffer(stdin)
-	sess.Stdout = stdoutBuf
-	sess.Stderr = stderrBuf
-	defer sess.Close()
-
-	if err := sess.Start(command); err != nil {
-		return nil, nil, err
-	}
-	doneC := make(chan error, 1)
-	go func() {
-		doneC <- sess.Wait()
-	}()
-	select {
-	case <-ctx.Done():
-		return nil, nil, ctx.Err()
-	case err := <-doneC:
-		return stdoutBuf.Bytes(), stderrBuf.Bytes(), err
-	}
-}
-
-func (p *plainSSHConn) Upload(ctx context.Context, targetPath string, data []byte) error {
-	sc, err := sftp.NewClient(p.cl)
-	if err != nil {
-		return fmt.Errorf("while building sftp client: %w", err)
-	}
-	defer sc.Close()
-
-	acrdr := bytes.NewReader(data)
-	df, err := sc.Create(targetPath)
-	if err != nil {
-		return fmt.Errorf("while creating file on the host: %w", err)
-	}
-
-	doneC := make(chan error, 1)
-
-	go func() {
-		_, err := io.Copy(df, acrdr)
-		df.Close()
-		doneC <- err
-	}()
-
-	select {
-	case err := <-doneC:
-		if err != nil {
-			return fmt.Errorf("while copying file: %w", err)
-		}
-	case <-ctx.Done():
-		df.Close()
-		return ctx.Err()
-	}
-
-	if err := sc.Chmod(targetPath, 0755); err != nil {
-		return fmt.Errorf("while setting file permissions: %w", err)
-	}
-	return nil
-}
-
-func (p *plainSSHConn) Close() error {
-	return p.cl.Close()
-}
diff --git a/cloud/shepherd/mini/BUILD.bazel b/cloud/shepherd/mini/BUILD.bazel
index 63ad885..5fad3c2 100644
--- a/cloud/shepherd/mini/BUILD.bazel
+++ b/cloud/shepherd/mini/BUILD.bazel
@@ -16,6 +16,7 @@
         "//cloud/lib/component",
         "//cloud/shepherd",
         "//cloud/shepherd/manager",
+        "//go/net/ssh",
         "//metropolis/cli/pkg/context",
         "@io_k8s_klog_v2//:klog",
         "@org_golang_x_crypto//ssh",
diff --git a/cloud/shepherd/mini/ssh.go b/cloud/shepherd/mini/ssh.go
index 99f3e90..1a2d23c 100644
--- a/cloud/shepherd/mini/ssh.go
+++ b/cloud/shepherd/mini/ssh.go
@@ -4,10 +4,11 @@
 	"flag"
 	"fmt"
 
-	"golang.org/x/crypto/ssh"
+	xssh "golang.org/x/crypto/ssh"
 	"k8s.io/klog/v2"
 
 	"source.monogon.dev/cloud/shepherd/manager"
+	"source.monogon.dev/go/net/ssh"
 )
 
 type sshConfig struct {
@@ -36,18 +37,18 @@
 	sc.SSHKey.RegisterFlags()
 }
 
-func (sc *sshConfig) NewClient() (*manager.PlainSSHClient, error) {
+func (sc *sshConfig) NewClient() (*ssh.DirectClient, error) {
 	if err := sc.check(); err != nil {
 		return nil, err
 	}
 
-	c := manager.PlainSSHClient{
+	c := ssh.DirectClient{
 		Username: sc.User,
 	}
 
 	switch {
 	case sc.Pass != "":
-		c.AuthMethod = ssh.Password(sc.Pass)
+		c.AuthMethod = xssh.Password(sc.Pass)
 	case sc.SSHKey.KeyPersistPath != "":
 		signer, err := sc.SSHKey.Signer()
 		if err != nil {
@@ -61,7 +62,7 @@
 
 		klog.Infof("Using ssh key auth with public key: %s", pubKey)
 
-		c.AuthMethod = ssh.PublicKeys(signer)
+		c.AuthMethod = xssh.PublicKeys(signer)
 	}
 	return &c, nil
 }
diff --git a/cloud/shepherd/provider/equinix/BUILD.bazel b/cloud/shepherd/provider/equinix/BUILD.bazel
index 727fbcf..4214f16 100644
--- a/cloud/shepherd/provider/equinix/BUILD.bazel
+++ b/cloud/shepherd/provider/equinix/BUILD.bazel
@@ -20,6 +20,7 @@
         "//cloud/lib/sinbin",
         "//cloud/shepherd",
         "//cloud/shepherd/manager",
+        "//go/net/ssh",
         "//metropolis/cli/pkg/context",
         "@com_github_packethost_packngo//:packngo",
         "@io_k8s_klog_v2//:klog",
diff --git a/cloud/shepherd/provider/equinix/main.go b/cloud/shepherd/provider/equinix/main.go
index 3a402e8..0060502 100644
--- a/cloud/shepherd/provider/equinix/main.go
+++ b/cloud/shepherd/provider/equinix/main.go
@@ -14,6 +14,7 @@
 	"source.monogon.dev/cloud/equinix/wrapngo"
 	"source.monogon.dev/cloud/lib/component"
 	"source.monogon.dev/cloud/shepherd/manager"
+	ssh2 "source.monogon.dev/go/net/ssh"
 	clicontext "source.monogon.dev/metropolis/cli/pkg/context"
 )
 
@@ -89,7 +90,7 @@
 		klog.Exitf("%v", err)
 	}
 
-	sshClient := &manager.PlainSSHClient{
+	sshClient := &ssh2.DirectClient{
 		AuthMethod: ssh.PublicKeys(sshSigner),
 		// Equinix OS installations always use root.
 		Username: "root",