blob: 410a88dc621eca21bcbe44c7f7194599bbd5775f [file] [log] [blame]
Tim Windelschmidt6d33a432025-02-04 14:34:25 +01001// Copyright The Monogon Project Authors.
2// SPDX-License-Identifier: Apache-2.0
3
Tim Windelschmidt7a1b27d2024-02-22 23:54:58 +01004package main
5
6import (
7 "context"
8 _ "embed"
9 "fmt"
10 "log"
11 "net"
12 "net/netip"
13 "os"
14 "os/signal"
15 "strings"
16 "syscall"
17 "time"
18
19 "github.com/schollz/progressbar/v3"
20 "github.com/spf13/cobra"
Jan Schär0175d7a2025-03-26 12:57:23 +000021 "golang.org/x/crypto/ssh"
Tim Windelschmidt7a1b27d2024-02-22 23:54:58 +010022 "golang.org/x/crypto/ssh/agent"
Tim Windelschmidt7a1b27d2024-02-22 23:54:58 +010023 "golang.org/x/term"
24 "google.golang.org/protobuf/proto"
25
Jan Schär0175d7a2025-03-26 12:57:23 +000026 "source.monogon.dev/osbase/net/sshtakeover"
Jan Schärc1b6df42025-03-20 08:52:18 +000027 "source.monogon.dev/osbase/structfs"
Tim Windelschmidt7a1b27d2024-02-22 23:54:58 +010028)
29
30var sshCmd = &cobra.Command{
31 Use: "ssh --disk=<disk> <target>",
32 Short: "Installs Metropolis on a Linux system accessible via SSH.",
33 Example: "metroctl install --bundle=metropolis-v0.1.zip --takeover=takeover ssh --disk=nvme0n1 root@ssh-enabled-server.example",
34 Args: cobra.ExactArgs(1), // One positional argument: the target
Tim Windelschmidt0b4fb8c2024-09-18 17:34:23 +020035 RunE: func(cmd *cobra.Command, args []string) error {
36 user, address, err := parseSSHAddr(args[0])
37 if err != nil {
38 return err
39 }
40
41 diskName, err := cmd.Flags().GetString("disk")
42 if err != nil {
43 return err
44 }
45
46 if len(diskName) == 0 {
47 return fmt.Errorf("flag disk is required")
48 }
49
Jan Schär0175d7a2025-03-26 12:57:23 +000050 var authMethods []ssh.AuthMethod
Tim Windelschmidt0b4fb8c2024-09-18 17:34:23 +020051 if aconn, err := net.Dial("unix", os.Getenv("SSH_AUTH_SOCK")); err == nil {
52 defer aconn.Close()
53 a := agent.NewClient(aconn)
Jan Schär0175d7a2025-03-26 12:57:23 +000054 authMethods = append(authMethods, ssh.PublicKeysCallback(a.Signers))
Tim Windelschmidt0b4fb8c2024-09-18 17:34:23 +020055 } else {
56 log.Printf("error while establishing ssh agent connection: %v", err)
57 log.Println("ssh agent authentication will not be available.")
58 }
59
Timon Stampflid7c8bbb2024-12-15 17:26:35 +010060 // On Windows syscall.Stdin is a handle and needs to be cast to an
61 // int for term.
62 stdin := int(syscall.Stdin) // nolint:unconvert
63 if term.IsTerminal(stdin) {
Tim Windelschmidt0b4fb8c2024-09-18 17:34:23 +020064 authMethods = append(authMethods,
Jan Schär0175d7a2025-03-26 12:57:23 +000065 ssh.PasswordCallback(func() (string, error) {
Tim Windelschmidt0b4fb8c2024-09-18 17:34:23 +020066 fmt.Printf("%s@%s's password: ", user, address)
Timon Stampflid7c8bbb2024-12-15 17:26:35 +010067 b, err := term.ReadPassword(stdin)
Tim Windelschmidt0b4fb8c2024-09-18 17:34:23 +020068 if err != nil {
69 return "", err
70 }
71 fmt.Println()
72 return string(b), nil
73 }),
Jan Schär0175d7a2025-03-26 12:57:23 +000074 ssh.KeyboardInteractive(func(name, instruction string, questions []string, echos []bool) ([]string, error) {
Tim Windelschmidt0b4fb8c2024-09-18 17:34:23 +020075 answers := make([]string, 0, len(questions))
76 for i, q := range questions {
77 fmt.Print(q)
78 if echos[i] {
79 if _, err := fmt.Scan(&questions[i]); err != nil {
80 return nil, err
81 }
82 } else {
Timon Stampflid7c8bbb2024-12-15 17:26:35 +010083 b, err := term.ReadPassword(stdin)
Tim Windelschmidt0b4fb8c2024-09-18 17:34:23 +020084 if err != nil {
85 return nil, err
86 }
87 fmt.Println()
88 answers = append(answers, string(b))
89 }
90 }
91 return answers, nil
92 }),
93 )
94 } else {
95 log.Println("stdin is not interactive. password authentication will not be available.")
96 }
97
Jan Schär0175d7a2025-03-26 12:57:23 +000098 conf := &ssh.ClientConfig{
99 User: user,
100 Auth: authMethods,
101 // Ignore the host key, since it's likely the first time anything logs into
102 // this device, and also because there's no way of knowing its fingerprint.
103 HostKeyCallback: ssh.InsecureIgnoreHostKey(),
104 // Timeout sets a bound on the time it takes to set up the connection, but
105 // not on total session time.
106 Timeout: 5 * time.Second,
Tim Windelschmidt0b4fb8c2024-09-18 17:34:23 +0200107 }
108
109 ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt)
Jan Schär0175d7a2025-03-26 12:57:23 +0000110 conn, err := sshtakeover.Dial(ctx, address, conf)
Tim Windelschmidt0b4fb8c2024-09-18 17:34:23 +0200111 if err != nil {
112 return fmt.Errorf("error while establishing ssh connection: %w", err)
113 }
114
115 params, err := makeNodeParams()
116 if err != nil {
117 return err
118 }
119 rawParams, err := proto.Marshal(params)
120 if err != nil {
121 return fmt.Errorf("error while marshaling node params: %w", err)
122 }
123
124 const takeoverTargetPath = "/root/takeover"
125 const bundleTargetPath = "/root/bundle.zip"
126 bundle, err := external("bundle", "_main/metropolis/node/bundle.zip", bundlePath)
127 if err != nil {
128 return err
129 }
Jan Schärf07d1b32025-03-24 18:36:06 +0000130 takeoverPath, err := cmd.Flags().GetString("takeover")
131 if err != nil {
132 return err
133 }
134 takeover, err := external("takeover", "_main/metropolis/cli/takeover/takeover_bin_/takeover_bin", &takeoverPath)
Tim Windelschmidt0b4fb8c2024-09-18 17:34:23 +0200135 if err != nil {
136 return err
137 }
138
Jan Schärc1b6df42025-03-20 08:52:18 +0000139 barUploader := func(blob structfs.Blob, targetPath string) {
140 content, err := blob.Open()
141 if err != nil {
142 log.Fatalf("error while uploading %q: %v", targetPath, err)
143 }
144 defer content.Close()
145
Tim Windelschmidt0b4fb8c2024-09-18 17:34:23 +0200146 bar := progressbar.DefaultBytes(
Jan Schärc1b6df42025-03-20 08:52:18 +0000147 blob.Size(),
Tim Windelschmidt0b4fb8c2024-09-18 17:34:23 +0200148 targetPath,
149 )
150 defer bar.Close()
151
Jan Schärc1b6df42025-03-20 08:52:18 +0000152 proxyReader := progressbar.NewReader(content, bar)
Tim Windelschmidt0b4fb8c2024-09-18 17:34:23 +0200153 defer proxyReader.Close()
154
Jan Schär0175d7a2025-03-26 12:57:23 +0000155 if err := conn.UploadExecutable(ctx, targetPath, &proxyReader); err != nil {
Tim Windelschmidt0b4fb8c2024-09-18 17:34:23 +0200156 log.Fatalf("error while uploading %q: %v", targetPath, err)
157 }
158 }
159
160 log.Println("Uploading required binaries to target host.")
161 barUploader(takeover, takeoverTargetPath)
162 barUploader(bundle, bundleTargetPath)
163
164 // Start the agent and wait for the agent's output to arrive.
165 log.Printf("Starting the takeover executable at path %q.", takeoverTargetPath)
166 _, stderr, err := conn.Execute(ctx, fmt.Sprintf("%s -disk %s", takeoverTargetPath, diskName), rawParams)
167 stderrStr := strings.TrimSpace(string(stderr))
168 if stderrStr != "" {
169 log.Printf("Agent stderr: %q", stderrStr)
170 }
171 if err != nil {
172 return fmt.Errorf("while starting the takeover executable: %w", err)
173 }
174
175 return nil
176 },
Tim Windelschmidt7a1b27d2024-02-22 23:54:58 +0100177}
178
179func parseAddrOptionalPort(addr string) (string, string, error) {
180 if addr == "" {
181 return "", "", fmt.Errorf("address is empty")
182 }
183
184 idx := strings.LastIndex(addr, ":")
185 // IPv4, DNS without Port.
186 if idx == -1 {
187 return addr, "", nil
188 }
189
190 // IPv4, DNS with Port.
191 if strings.Count(addr, ":") == 1 {
192 return addr[:idx], addr[idx+1:], nil
193 }
194
195 // IPv6 with Port.
196 if addrPort, err := netip.ParseAddrPort(addr); err == nil {
197 return addrPort.Addr().String(), fmt.Sprintf("%d", addrPort.Port()), nil
198 }
199
200 // IPv6 without Port.
201 if addr, err := netip.ParseAddr(addr); err == nil {
202 return addr.String(), "", nil
203 }
204
205 return "", "", fmt.Errorf("failed to parse address: %q", addr)
206}
207
208func parseSSHAddr(s string) (string, string, error) {
209 user, rawAddr, ok := strings.Cut(s, "@")
210 if !ok {
211 return "", "", fmt.Errorf("SSH user is mandatory")
212 }
213
214 addr, port, err := parseAddrOptionalPort(rawAddr)
215 if err != nil {
216 return "", "", err
217 }
218 if port == "" {
219 port = "22"
220 }
221
222 return user, net.JoinHostPort(addr, port), nil
223}
224
Tim Windelschmidt7a1b27d2024-02-22 23:54:58 +0100225func init() {
226 sshCmd.Flags().String("disk", "", "Which disk Metropolis should be installed to")
227 sshCmd.Flags().String("takeover", "", "Path to the Metropolis takeover binary")
228
229 installCmd.AddCommand(sshCmd)
230}