metropolis/cli/metroctl: implement install ssh

This implements another way of installing metropolis via ssh. It does
this by uploading the files to the target machine and then doing a kexec
into the install environment. If it fails at any point it will print the
error and reboot.

Change-Id: I1ac6538896709c386b053a84903fa04940c1f012
Reviewed-on: https://review.monogon.dev/c/monogon/+/2079
Tested-by: Jenkins CI
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
diff --git a/metropolis/cli/metroctl/BUILD.bazel b/metropolis/cli/metroctl/BUILD.bazel
index 6e75400..50dc1eb 100644
--- a/metropolis/cli/metroctl/BUILD.bazel
+++ b/metropolis/cli/metroctl/BUILD.bazel
@@ -19,6 +19,7 @@
     srcs = [
         "cmd_certs.go",
         "cmd_install.go",
+        "cmd_install_ssh.go",
         "cmd_install_usb.go",
         "cmd_k8s_configure.go",
         "cmd_k8scredplugin.go",
@@ -37,6 +38,7 @@
     deps = [
         "//go/clitable",
         "//go/logging",
+        "//go/net/ssh",
         "//metropolis/cli/flagdefs",
         "//metropolis/cli/metroctl/core",
         "//metropolis/node",
@@ -51,13 +53,19 @@
         "//osbase/logtree/proto",
         "//version",
         "@com_github_adrg_xdg//:xdg",
+        "@com_github_schollz_progressbar_v3//:progressbar",
         "@com_github_spf13_cobra//:cobra",
         "@io_bazel_rules_go//go/runfiles:go_default_library",
         "@io_k8s_apimachinery//pkg/apis/meta/v1:meta",
         "@io_k8s_client_go//pkg/apis/clientauthentication/v1:clientauthentication",
         "@org_golang_google_grpc//:grpc",
+        "@org_golang_google_protobuf//proto",
+        "@org_golang_x_crypto//ssh",
+        "@org_golang_x_crypto//ssh/agent",
+        "@org_golang_x_crypto//ssh/terminal",
         "@org_golang_x_net//proxy",
         "@org_golang_x_sync//semaphore",
+        "@org_golang_x_term//:term",
     ],
 )
 
@@ -66,6 +74,7 @@
     data = select({
         ":buildkind_lite": [],
         "//conditions:default": [
+            "//metropolis/cli/takeover",
             "//metropolis/installer:kernel",
             "//metropolis/node:bundle",
         ],
diff --git a/metropolis/cli/metroctl/cmd_install_ssh.go b/metropolis/cli/metroctl/cmd_install_ssh.go
new file mode 100644
index 0000000..15ced4a
--- /dev/null
+++ b/metropolis/cli/metroctl/cmd_install_ssh.go
@@ -0,0 +1,202 @@
+package main
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+	"log"
+	"net"
+	"net/netip"
+	"os"
+	"os/signal"
+	"strings"
+	"syscall"
+	"time"
+
+	"github.com/schollz/progressbar/v3"
+	"github.com/spf13/cobra"
+	xssh "golang.org/x/crypto/ssh"
+	"golang.org/x/crypto/ssh/agent"
+	"golang.org/x/crypto/ssh/terminal"
+	"golang.org/x/term"
+	"google.golang.org/protobuf/proto"
+
+	"source.monogon.dev/go/net/ssh"
+	"source.monogon.dev/osbase/fat32"
+)
+
+var sshCmd = &cobra.Command{
+	Use:     "ssh --disk=<disk> <target>",
+	Short:   "Installs Metropolis on a Linux system accessible via SSH.",
+	Example: "metroctl install --bundle=metropolis-v0.1.zip --takeover=takeover ssh --disk=nvme0n1 root@ssh-enabled-server.example",
+	Args:    cobra.ExactArgs(1), // One positional argument: the target
+	RunE:    doSSH,
+}
+
+func parseAddrOptionalPort(addr string) (string, string, error) {
+	if addr == "" {
+		return "", "", fmt.Errorf("address is empty")
+	}
+
+	idx := strings.LastIndex(addr, ":")
+	// IPv4, DNS without Port.
+	if idx == -1 {
+		return addr, "", nil
+	}
+
+	// IPv4, DNS with Port.
+	if strings.Count(addr, ":") == 1 {
+		return addr[:idx], addr[idx+1:], nil
+	}
+
+	// IPv6 with Port.
+	if addrPort, err := netip.ParseAddrPort(addr); err == nil {
+		return addrPort.Addr().String(), fmt.Sprintf("%d", addrPort.Port()), nil
+	}
+
+	// IPv6 without Port.
+	if addr, err := netip.ParseAddr(addr); err == nil {
+		return addr.String(), "", nil
+	}
+
+	return "", "", fmt.Errorf("failed to parse address: %q", addr)
+}
+
+func parseSSHAddr(s string) (string, string, error) {
+	user, rawAddr, ok := strings.Cut(s, "@")
+	if !ok {
+		return "", "", fmt.Errorf("SSH user is mandatory")
+	}
+
+	addr, port, err := parseAddrOptionalPort(rawAddr)
+	if err != nil {
+		return "", "", err
+	}
+	if port == "" {
+		port = "22"
+	}
+
+	return user, net.JoinHostPort(addr, port), nil
+}
+
+func doSSH(cmd *cobra.Command, args []string) error {
+	user, address, err := parseSSHAddr(args[0])
+	if err != nil {
+		return err
+	}
+
+	diskName, err := cmd.Flags().GetString("disk")
+	if err != nil {
+		return err
+	}
+
+	if len(diskName) == 0 {
+		return fmt.Errorf("flag disk is required")
+	}
+
+	var authMethods []xssh.AuthMethod
+	if aconn, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")); err == nil {
+		defer aconn.Close()
+		a := agent.NewClient(aconn)
+		authMethods = append(authMethods, xssh.PublicKeysCallback(a.Signers))
+	} else {
+		log.Printf("error while establishing ssh agent connection: %v", err)
+		log.Println("ssh agent authentication will not be available.")
+	}
+
+	if term.IsTerminal(int(os.Stdin.Fd())) {
+		authMethods = append(authMethods,
+			xssh.PasswordCallback(func() (string, error) {
+				fmt.Printf("%s@%s's password: ", user, address)
+				b, err := terminal.ReadPassword(syscall.Stdin)
+				if err != nil {
+					return "", err
+				}
+				fmt.Println()
+				return string(b), nil
+			}),
+			xssh.KeyboardInteractive(func(name, instruction string, questions []string, echos []bool) ([]string, error) {
+				answers := make([]string, 0, len(questions))
+				for i, q := range questions {
+					fmt.Print(q)
+					if echos[i] {
+						if _, err := fmt.Scan(&questions[i]); err != nil {
+							return nil, err
+						}
+					} else {
+						b, err := terminal.ReadPassword(syscall.Stdin)
+						if err != nil {
+							return nil, err
+						}
+						fmt.Println()
+						answers = append(answers, string(b))
+					}
+				}
+				return answers, nil
+			}),
+		)
+	} else {
+		log.Println("stdin is not interactive. password authentication will not be available.")
+	}
+
+	cl := ssh.DirectClient{
+		Username:    user,
+		AuthMethods: authMethods,
+	}
+
+	ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt)
+	conn, err := cl.Dial(ctx, address, 5*time.Second)
+	if err != nil {
+		return fmt.Errorf("error while establishing ssh connection: %v", err)
+	}
+
+	params := makeNodeParams()
+	rawParams, err := proto.Marshal(params)
+	if err != nil {
+		return fmt.Errorf("error while marshaling node params: %v", err)
+	}
+
+	const takeoverTargetPath = "/root/takeover"
+	const bundleTargetPath = "/root/bundle.zip"
+	bundle := external("bundle", "_main/metropolis/node/bundle.zip", bundlePath)
+	takeover := external("takeover", "_main/metropolis/cli/takeover/takeover_bin_/takeover_bin", bundlePath)
+
+	barUploader := func(r fat32.SizedReader, targetPath string) {
+		bar := progressbar.DefaultBytes(
+			r.Size(),
+			targetPath,
+		)
+		defer bar.Close()
+
+		proxyReader := progressbar.NewReader(r, bar)
+		defer proxyReader.Close()
+
+		if err := conn.Upload(ctx, targetPath, &proxyReader); err != nil {
+			log.Fatalf("error while uploading %q: %v", targetPath, err)
+		}
+	}
+
+	log.Println("Uploading required binaries to target host.")
+	barUploader(takeover, takeoverTargetPath)
+	barUploader(bundle, bundleTargetPath)
+
+	// Start the agent and wait for the agent's output to arrive.
+	log.Printf("Starting the takeover executable at path %q.", takeoverTargetPath)
+	_, stderr, err := conn.Execute(ctx, fmt.Sprintf("%s -disk %s", takeoverTargetPath, diskName), rawParams)
+	stderrStr := strings.TrimSpace(string(stderr))
+	if stderrStr != "" {
+		log.Printf("Agent stderr: %q", stderrStr)
+	}
+	if err != nil {
+		return fmt.Errorf("while starting the takeover executable: %v", err)
+	}
+
+	return nil
+}
+
+func init() {
+	sshCmd.Flags().String("disk", "", "Which disk Metropolis should be installed to")
+	sshCmd.Flags().String("takeover", "", "Path to the Metropolis takeover binary")
+
+	installCmd.AddCommand(sshCmd)
+}