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