| Tim Windelschmidt | 7a1b27d | 2024-02-22 23:54:58 +0100 | [diff] [blame] | 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | _ "embed" |
| 6 | "fmt" |
| 7 | "log" |
| 8 | "net" |
| 9 | "net/netip" |
| 10 | "os" |
| 11 | "os/signal" |
| 12 | "strings" |
| 13 | "syscall" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/schollz/progressbar/v3" |
| 17 | "github.com/spf13/cobra" |
| 18 | xssh "golang.org/x/crypto/ssh" |
| 19 | "golang.org/x/crypto/ssh/agent" |
| Tim Windelschmidt | 7a1b27d | 2024-02-22 23:54:58 +0100 | [diff] [blame] | 20 | "golang.org/x/term" |
| 21 | "google.golang.org/protobuf/proto" |
| 22 | |
| 23 | "source.monogon.dev/go/net/ssh" |
| 24 | "source.monogon.dev/osbase/fat32" |
| 25 | ) |
| 26 | |
| 27 | var sshCmd = &cobra.Command{ |
| 28 | Use: "ssh --disk=<disk> <target>", |
| 29 | Short: "Installs Metropolis on a Linux system accessible via SSH.", |
| 30 | Example: "metroctl install --bundle=metropolis-v0.1.zip --takeover=takeover ssh --disk=nvme0n1 root@ssh-enabled-server.example", |
| 31 | Args: cobra.ExactArgs(1), // One positional argument: the target |
| Tim Windelschmidt | 0b4fb8c | 2024-09-18 17:34:23 +0200 | [diff] [blame] | 32 | RunE: func(cmd *cobra.Command, args []string) error { |
| 33 | user, address, err := parseSSHAddr(args[0]) |
| 34 | if err != nil { |
| 35 | return err |
| 36 | } |
| 37 | |
| 38 | diskName, err := cmd.Flags().GetString("disk") |
| 39 | if err != nil { |
| 40 | return err |
| 41 | } |
| 42 | |
| 43 | if len(diskName) == 0 { |
| 44 | return fmt.Errorf("flag disk is required") |
| 45 | } |
| 46 | |
| 47 | var authMethods []xssh.AuthMethod |
| 48 | if aconn, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")); err == nil { |
| 49 | defer aconn.Close() |
| 50 | a := agent.NewClient(aconn) |
| 51 | authMethods = append(authMethods, xssh.PublicKeysCallback(a.Signers)) |
| 52 | } else { |
| 53 | log.Printf("error while establishing ssh agent connection: %v", err) |
| 54 | log.Println("ssh agent authentication will not be available.") |
| 55 | } |
| 56 | |
| Timon Stampfli | d7c8bbb | 2024-12-15 17:26:35 +0100 | [diff] [blame] | 57 | // On Windows syscall.Stdin is a handle and needs to be cast to an |
| 58 | // int for term. |
| 59 | stdin := int(syscall.Stdin) // nolint:unconvert |
| 60 | if term.IsTerminal(stdin) { |
| Tim Windelschmidt | 0b4fb8c | 2024-09-18 17:34:23 +0200 | [diff] [blame] | 61 | authMethods = append(authMethods, |
| 62 | xssh.PasswordCallback(func() (string, error) { |
| 63 | fmt.Printf("%s@%s's password: ", user, address) |
| Timon Stampfli | d7c8bbb | 2024-12-15 17:26:35 +0100 | [diff] [blame] | 64 | b, err := term.ReadPassword(stdin) |
| Tim Windelschmidt | 0b4fb8c | 2024-09-18 17:34:23 +0200 | [diff] [blame] | 65 | if err != nil { |
| 66 | return "", err |
| 67 | } |
| 68 | fmt.Println() |
| 69 | return string(b), nil |
| 70 | }), |
| 71 | xssh.KeyboardInteractive(func(name, instruction string, questions []string, echos []bool) ([]string, error) { |
| 72 | answers := make([]string, 0, len(questions)) |
| 73 | for i, q := range questions { |
| 74 | fmt.Print(q) |
| 75 | if echos[i] { |
| 76 | if _, err := fmt.Scan(&questions[i]); err != nil { |
| 77 | return nil, err |
| 78 | } |
| 79 | } else { |
| Timon Stampfli | d7c8bbb | 2024-12-15 17:26:35 +0100 | [diff] [blame] | 80 | b, err := term.ReadPassword(stdin) |
| Tim Windelschmidt | 0b4fb8c | 2024-09-18 17:34:23 +0200 | [diff] [blame] | 81 | if err != nil { |
| 82 | return nil, err |
| 83 | } |
| 84 | fmt.Println() |
| 85 | answers = append(answers, string(b)) |
| 86 | } |
| 87 | } |
| 88 | return answers, nil |
| 89 | }), |
| 90 | ) |
| 91 | } else { |
| 92 | log.Println("stdin is not interactive. password authentication will not be available.") |
| 93 | } |
| 94 | |
| 95 | cl := ssh.DirectClient{ |
| 96 | Username: user, |
| 97 | AuthMethods: authMethods, |
| 98 | } |
| 99 | |
| 100 | ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt) |
| 101 | conn, err := cl.Dial(ctx, address, 5*time.Second) |
| 102 | if err != nil { |
| 103 | return fmt.Errorf("error while establishing ssh connection: %w", err) |
| 104 | } |
| 105 | |
| 106 | params, err := makeNodeParams() |
| 107 | if err != nil { |
| 108 | return err |
| 109 | } |
| 110 | rawParams, err := proto.Marshal(params) |
| 111 | if err != nil { |
| 112 | return fmt.Errorf("error while marshaling node params: %w", err) |
| 113 | } |
| 114 | |
| 115 | const takeoverTargetPath = "/root/takeover" |
| 116 | const bundleTargetPath = "/root/bundle.zip" |
| 117 | bundle, err := external("bundle", "_main/metropolis/node/bundle.zip", bundlePath) |
| 118 | if err != nil { |
| 119 | return err |
| 120 | } |
| 121 | takeover, err := external("takeover", "_main/metropolis/cli/takeover/takeover_bin_/takeover_bin", bundlePath) |
| 122 | if err != nil { |
| 123 | return err |
| 124 | } |
| 125 | |
| 126 | barUploader := func(r fat32.SizedReader, targetPath string) { |
| 127 | bar := progressbar.DefaultBytes( |
| 128 | r.Size(), |
| 129 | targetPath, |
| 130 | ) |
| 131 | defer bar.Close() |
| 132 | |
| 133 | proxyReader := progressbar.NewReader(r, bar) |
| 134 | defer proxyReader.Close() |
| 135 | |
| 136 | if err := conn.Upload(ctx, targetPath, &proxyReader); err != nil { |
| 137 | log.Fatalf("error while uploading %q: %v", targetPath, err) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | log.Println("Uploading required binaries to target host.") |
| 142 | barUploader(takeover, takeoverTargetPath) |
| 143 | barUploader(bundle, bundleTargetPath) |
| 144 | |
| 145 | // Start the agent and wait for the agent's output to arrive. |
| 146 | log.Printf("Starting the takeover executable at path %q.", takeoverTargetPath) |
| 147 | _, stderr, err := conn.Execute(ctx, fmt.Sprintf("%s -disk %s", takeoverTargetPath, diskName), rawParams) |
| 148 | stderrStr := strings.TrimSpace(string(stderr)) |
| 149 | if stderrStr != "" { |
| 150 | log.Printf("Agent stderr: %q", stderrStr) |
| 151 | } |
| 152 | if err != nil { |
| 153 | return fmt.Errorf("while starting the takeover executable: %w", err) |
| 154 | } |
| 155 | |
| 156 | return nil |
| 157 | }, |
| Tim Windelschmidt | 7a1b27d | 2024-02-22 23:54:58 +0100 | [diff] [blame] | 158 | } |
| 159 | |
| 160 | func parseAddrOptionalPort(addr string) (string, string, error) { |
| 161 | if addr == "" { |
| 162 | return "", "", fmt.Errorf("address is empty") |
| 163 | } |
| 164 | |
| 165 | idx := strings.LastIndex(addr, ":") |
| 166 | // IPv4, DNS without Port. |
| 167 | if idx == -1 { |
| 168 | return addr, "", nil |
| 169 | } |
| 170 | |
| 171 | // IPv4, DNS with Port. |
| 172 | if strings.Count(addr, ":") == 1 { |
| 173 | return addr[:idx], addr[idx+1:], nil |
| 174 | } |
| 175 | |
| 176 | // IPv6 with Port. |
| 177 | if addrPort, err := netip.ParseAddrPort(addr); err == nil { |
| 178 | return addrPort.Addr().String(), fmt.Sprintf("%d", addrPort.Port()), nil |
| 179 | } |
| 180 | |
| 181 | // IPv6 without Port. |
| 182 | if addr, err := netip.ParseAddr(addr); err == nil { |
| 183 | return addr.String(), "", nil |
| 184 | } |
| 185 | |
| 186 | return "", "", fmt.Errorf("failed to parse address: %q", addr) |
| 187 | } |
| 188 | |
| 189 | func parseSSHAddr(s string) (string, string, error) { |
| 190 | user, rawAddr, ok := strings.Cut(s, "@") |
| 191 | if !ok { |
| 192 | return "", "", fmt.Errorf("SSH user is mandatory") |
| 193 | } |
| 194 | |
| 195 | addr, port, err := parseAddrOptionalPort(rawAddr) |
| 196 | if err != nil { |
| 197 | return "", "", err |
| 198 | } |
| 199 | if port == "" { |
| 200 | port = "22" |
| 201 | } |
| 202 | |
| 203 | return user, net.JoinHostPort(addr, port), nil |
| 204 | } |
| 205 | |
| Tim Windelschmidt | 7a1b27d | 2024-02-22 23:54:58 +0100 | [diff] [blame] | 206 | func init() { |
| 207 | sshCmd.Flags().String("disk", "", "Which disk Metropolis should be installed to") |
| 208 | sshCmd.Flags().String("takeover", "", "Path to the Metropolis takeover binary") |
| 209 | |
| 210 | installCmd.AddCommand(sshCmd) |
| 211 | } |