blob: f779f43dcdb3f0da1246aad8130d563cd3d21d3d [file] [log] [blame]
Tim Windelschmidt7a1b27d2024-02-22 23:54:58 +01001package main
2
3import (
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 Windelschmidt7a1b27d2024-02-22 23:54:58 +010020 "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
27var 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 Windelschmidt0b4fb8c2024-09-18 17:34:23 +020032 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 Stampflid7c8bbb2024-12-15 17:26:35 +010057 // 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 Windelschmidt0b4fb8c2024-09-18 17:34:23 +020061 authMethods = append(authMethods,
62 xssh.PasswordCallback(func() (string, error) {
63 fmt.Printf("%s@%s's password: ", user, address)
Timon Stampflid7c8bbb2024-12-15 17:26:35 +010064 b, err := term.ReadPassword(stdin)
Tim Windelschmidt0b4fb8c2024-09-18 17:34:23 +020065 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 Stampflid7c8bbb2024-12-15 17:26:35 +010080 b, err := term.ReadPassword(stdin)
Tim Windelschmidt0b4fb8c2024-09-18 17:34:23 +020081 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 Windelschmidt7a1b27d2024-02-22 23:54:58 +0100158}
159
160func 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
189func 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 Windelschmidt7a1b27d2024-02-22 23:54:58 +0100206func 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}