blob: 3f0fb110d36ea6fd62fa10bad28556f9ac387cad [file] [log] [blame]
Sean McCullough4854c652025-04-24 18:37:02 -07001package dockerimg
2
3import (
4 "bufio"
Sean McCullough0d95d3a2025-04-30 16:22:28 +00005 "bytes"
Sean McCullough3e9d80c2025-05-13 23:35:23 +00006 "crypto/ed25519"
Sean McCullough4854c652025-04-24 18:37:02 -07007 "crypto/rand"
Sean McCullough4854c652025-04-24 18:37:02 -07008 "encoding/pem"
9 "fmt"
Sean McCullough2cba6952025-04-25 20:32:10 +000010 "io/fs"
Sean McCullough4854c652025-04-24 18:37:02 -070011 "os"
Sean McCullough078e85a2025-05-08 17:28:34 -070012 "os/exec"
Sean McCullough4854c652025-04-24 18:37:02 -070013 "path/filepath"
14 "strings"
Sean McCullough7013e9e2025-05-14 02:03:58 +000015 "time"
Sean McCullough4854c652025-04-24 18:37:02 -070016
17 "github.com/kevinburke/ssh_config"
18 "golang.org/x/crypto/ssh"
Sean McCullough7d5a6302025-04-24 21:27:51 -070019 "golang.org/x/crypto/ssh/knownhosts"
Sean McCullough4854c652025-04-24 18:37:02 -070020)
21
Sean McCullough3e9d80c2025-05-13 23:35:23 +000022// Ed25519 has a fixed key size, no bit size constant needed
Sean McCullough4854c652025-04-24 18:37:02 -070023
banksean29d689f2025-06-23 15:41:26 +000024// LocalSSHimmer does the necessary key pair generation, known_hosts updates, ssh_config file updates etc steps
Sean McCullough4854c652025-04-24 18:37:02 -070025// so that ssh can connect to a locally running sketch container to other local processes like vscode without
26// the user having to run the usual ssh obstacle course.
27//
banksean29d689f2025-06-23 15:41:26 +000028// LocalSSHimmer does not modify your default .ssh/config, or known_hosts files. However, in order for you
Sean McCullough4854c652025-04-24 18:37:02 -070029// to be able to use it properly you will have to make a one-time edit to your ~/.ssh/config file.
30//
31// In your ~/.ssh/config file, add the following line:
32//
Sean McCullough74b01212025-04-29 18:40:53 -070033// Include $HOME/.config/sketch/ssh_config
Sean McCullough4854c652025-04-24 18:37:02 -070034//
35// where $HOME is your home directory.
Sean McCullough3e9d80c2025-05-13 23:35:23 +000036//
banksean29d689f2025-06-23 15:41:26 +000037// LocalSSHimmer uses Ed25519 keys for improved security and performance.
38type LocalSSHimmer struct {
Sean McCullough4854c652025-04-24 18:37:02 -070039 cntrName string
40 sshHost string
41 sshPort string
42
43 knownHostsPath string
44 userIdentityPath string
45 sshConfigPath string
46 serverIdentityPath string
Sean McCullough7013e9e2025-05-14 02:03:58 +000047 containerCAPath string
48 hostCertPath string
Sean McCullough4854c652025-04-24 18:37:02 -070049
Sean McCullough7013e9e2025-05-14 02:03:58 +000050 serverPublicKey ssh.PublicKey
51 serverIdentity []byte
52 userIdentity []byte
53 hostCertificate []byte
54 containerCA ssh.Signer
55 containerCAPublicKey ssh.PublicKey
Sean McCullough2cba6952025-04-25 20:32:10 +000056
57 fs FileSystem
58 kg KeyGenerator
Sean McCullough4854c652025-04-24 18:37:02 -070059}
60
banksean29d689f2025-06-23 15:41:26 +000061// NewLocalSSHimmer will set up everything so that you can use ssh on localhost to connect to
Sean McCullough4854c652025-04-24 18:37:02 -070062// the sketch container. Call #Clean when you are done with the container to remove the
63// various entries it created in its known_hosts and ssh_config files. Also note that
64// this will generate key pairs for both the ssh server identity and the user identity, if
65// these files do not already exist. These key pair files are not deleted by #Cleanup,
66// so they can be re-used across invocations of sketch. This means every sketch container
67// that runs on this host will use the same ssh server identity.
Sean McCullough3e9d80c2025-05-13 23:35:23 +000068// The system uses Ed25519 keys for better security and performance.
Sean McCullough4854c652025-04-24 18:37:02 -070069//
70// If this doesn't return an error, you should be able to run "ssh <cntrName>"
71// in a terminal on your host machine to open a shell into the container without having
72// to manually accept changes to your known_hosts file etc.
banksean29d689f2025-06-23 15:41:26 +000073func NewLocalSSHimmer(cntrName, sshHost, sshPort string) (*LocalSSHimmer, error) {
74 return newLocalSSHimmerWithDeps(cntrName, sshHost, sshPort, &RealFileSystem{}, &RealKeyGenerator{})
Sean McCullough2cba6952025-04-25 20:32:10 +000075}
76
banksean29d689f2025-06-23 15:41:26 +000077// newLocalSSHimmerWithDeps creates a new LocalSSHimmer with the specified dependencies
78func newLocalSSHimmerWithDeps(cntrName, sshHost, sshPort string, fs FileSystem, kg KeyGenerator) (*LocalSSHimmer, error) {
Sean McCullough74b01212025-04-29 18:40:53 -070079 base := filepath.Join(os.Getenv("HOME"), ".config", "sketch")
Sean McCullough2cba6952025-04-25 20:32:10 +000080 if _, err := fs.Stat(base); err != nil {
Sean McCulloughc796e7f2025-04-30 08:44:06 -070081 if err := fs.MkdirAll(base, 0o777); err != nil {
Sean McCullough7d5a6302025-04-24 21:27:51 -070082 return nil, fmt.Errorf("couldn't create %s: %w", base, err)
83 }
84 }
85
banksean29d689f2025-06-23 15:41:26 +000086 cst := &LocalSSHimmer{
Sean McCullough4854c652025-04-24 18:37:02 -070087 cntrName: cntrName,
88 sshHost: sshHost,
89 sshPort: sshPort,
90 knownHostsPath: filepath.Join(base, "known_hosts"),
91 userIdentityPath: filepath.Join(base, "container_user_identity"),
92 serverIdentityPath: filepath.Join(base, "container_server_identity"),
Sean McCullough7013e9e2025-05-14 02:03:58 +000093 containerCAPath: filepath.Join(base, "container_ca"),
94 hostCertPath: filepath.Join(base, "host_cert"),
Sean McCullough4854c652025-04-24 18:37:02 -070095 sshConfigPath: filepath.Join(base, "ssh_config"),
Sean McCullough2cba6952025-04-25 20:32:10 +000096 fs: fs,
97 kg: kg,
Sean McCullough4854c652025-04-24 18:37:02 -070098 }
Sean McCullough7013e9e2025-05-14 02:03:58 +000099
100 // Step 1: Create regular server identity for the container SSH server
Sean McCullough2cba6952025-04-25 20:32:10 +0000101 if _, err := cst.createKeyPairIfMissing(cst.serverIdentityPath); err != nil {
Sean McCullough7d5a6302025-04-24 21:27:51 -0700102 return nil, fmt.Errorf("couldn't create server identity: %w", err)
103 }
Sean McCullough7013e9e2025-05-14 02:03:58 +0000104
105 // Step 2: Create user identity that will be used to connect to the container
Sean McCullough2cba6952025-04-25 20:32:10 +0000106 if _, err := cst.createKeyPairIfMissing(cst.userIdentityPath); err != nil {
Sean McCullough7d5a6302025-04-24 21:27:51 -0700107 return nil, fmt.Errorf("couldn't create user identity: %w", err)
Sean McCullough4854c652025-04-24 18:37:02 -0700108 }
109
Sean McCullough7013e9e2025-05-14 02:03:58 +0000110 // Step 3: Generate host certificate and CA for mutual authentication
111 // This now handles both CA creation and certificate signing in one step
112 if err := cst.createHostCertificate(cst.userIdentityPath); err != nil {
113 return nil, fmt.Errorf("couldn't create host certificate: %w", err)
114 }
115
116 // Step 5: Load all necessary key materials
Sean McCullough2cba6952025-04-25 20:32:10 +0000117 serverIdentity, err := fs.ReadFile(cst.serverIdentityPath)
Sean McCullough4854c652025-04-24 18:37:02 -0700118 if err != nil {
119 return nil, fmt.Errorf("couldn't read container's ssh server identity: %w", err)
120 }
121 cst.serverIdentity = serverIdentity
122
Sean McCullough2cba6952025-04-25 20:32:10 +0000123 serverPubKeyBytes, err := fs.ReadFile(cst.serverIdentityPath + ".pub")
124 if err != nil {
125 return nil, fmt.Errorf("couldn't read ssh server public key file: %w", err)
126 }
Sean McCullough4854c652025-04-24 18:37:02 -0700127 serverPubKey, _, _, _, err := ssh.ParseAuthorizedKey(serverPubKeyBytes)
128 if err != nil {
Sean McCullough2cba6952025-04-25 20:32:10 +0000129 return nil, fmt.Errorf("couldn't parse ssh server public key: %w", err)
Sean McCullough4854c652025-04-24 18:37:02 -0700130 }
131 cst.serverPublicKey = serverPubKey
132
Sean McCullough2cba6952025-04-25 20:32:10 +0000133 userIdentity, err := fs.ReadFile(cst.userIdentityPath + ".pub")
Sean McCullough4854c652025-04-24 18:37:02 -0700134 if err != nil {
135 return nil, fmt.Errorf("couldn't read ssh user identity: %w", err)
136 }
137 cst.userIdentity = userIdentity
138
Sean McCullough7013e9e2025-05-14 02:03:58 +0000139 hostCert, err := fs.ReadFile(cst.hostCertPath)
140 if err != nil {
141 return nil, fmt.Errorf("couldn't read host certificate: %w", err)
142 }
143 cst.hostCertificate = hostCert
144
145 // Step 6: Configure SSH settings
Sean McCullough7d5a6302025-04-24 21:27:51 -0700146 if err := cst.addContainerToSSHConfig(); err != nil {
147 return nil, fmt.Errorf("couldn't add container to ssh_config: %w", err)
148 }
149
150 if err := cst.addContainerToKnownHosts(); err != nil {
151 return nil, fmt.Errorf("couldn't update known hosts: %w", err)
152 }
153
Sean McCullough4854c652025-04-24 18:37:02 -0700154 return cst, nil
155}
156
Sean McCullough078e85a2025-05-08 17:28:34 -0700157func checkSSHResolve(hostname string) error {
158 cmd := exec.Command("ssh", "-T", hostname)
159 out, err := cmd.CombinedOutput()
160 if strings.HasPrefix(string(out), "ssh: Could not resolve hostname") {
161 return err
162 }
163 return nil
164}
165
Sean McCullough15c95282025-05-08 16:48:38 -0700166func CheckForIncludeWithFS(fs FileSystem, stdinReader bufio.Reader) error {
Sean McCullough74b01212025-04-29 18:40:53 -0700167 sketchSSHPathInclude := "Include " + filepath.Join(os.Getenv("HOME"), ".config", "sketch", "ssh_config")
Sean McCulloughf5e28f62025-04-25 10:48:00 -0700168 defaultSSHPath := filepath.Join(os.Getenv("HOME"), ".ssh", "config")
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000169
170 // Read the existing SSH config file
171 existingContent, err := fs.ReadFile(defaultSSHPath)
172 if err != nil {
173 // If the file doesn't exist, create a new one with just the include line
174 if os.IsNotExist(err) {
175 return fs.SafeWriteFile(defaultSSHPath, []byte(sketchSSHPathInclude+"\n"), 0o644)
176 }
177 return fmt.Errorf("⚠️ SSH connections are disabled. cannot open SSH config file: %s: %w", defaultSSHPath, err)
Sean McCullough2cba6952025-04-25 20:32:10 +0000178 }
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000179
180 // Parse the config file
181 cfg, err := ssh_config.Decode(bytes.NewReader(existingContent))
182 if err != nil {
183 return fmt.Errorf("couldn't decode ssh_config: %w", err)
184 }
185
Sean McCulloughf5e28f62025-04-25 10:48:00 -0700186 var sketchInludePos *ssh_config.Position
187 var firstNonIncludePos *ssh_config.Position
188 for _, host := range cfg.Hosts {
189 for _, node := range host.Nodes {
190 inc, ok := node.(*ssh_config.Include)
191 if ok {
192 if strings.TrimSpace(inc.String()) == sketchSSHPathInclude {
193 pos := inc.Pos()
194 sketchInludePos = &pos
195 }
196 } else if firstNonIncludePos == nil && !strings.HasPrefix(strings.TrimSpace(node.String()), "#") {
197 pos := node.Pos()
198 firstNonIncludePos = &pos
199 }
200 }
201 }
202
203 if sketchInludePos == nil {
Sean McCullough15c95282025-05-08 16:48:38 -0700204 fmt.Printf("\nTo enable you to use ssh to connect to local sketch containers: \nAdd %q to the top of %s [y/N]? ", sketchSSHPathInclude, defaultSSHPath)
205 char, _, err := stdinReader.ReadRune()
206 if err != nil {
207 return fmt.Errorf("couldn't read from stdin: %w", err)
208 }
209 if char != 'y' && char != 'Y' {
210 return fmt.Errorf("User declined to edit ssh config file")
211 }
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000212 // Include line not found, add it to the top of the file
Sean McCullough3b0795b2025-04-29 19:09:23 -0700213 cfgBytes, err := cfg.MarshalText()
214 if err != nil {
215 return fmt.Errorf("couldn't marshal ssh_config: %w", err)
216 }
Sean McCullough3b0795b2025-04-29 19:09:23 -0700217
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000218 // Add the include line to the beginning
219 cfgBytes = append([]byte(sketchSSHPathInclude+"\n"), cfgBytes...)
220
221 // Safely write the updated config back to the file
222 if err := fs.SafeWriteFile(defaultSSHPath, cfgBytes, 0o644); err != nil {
223 return fmt.Errorf("couldn't safely write ssh_config: %w", err)
224 }
Sean McCullough3b0795b2025-04-29 19:09:23 -0700225 return nil
Sean McCulloughf5e28f62025-04-25 10:48:00 -0700226 }
227
228 if firstNonIncludePos != nil && firstNonIncludePos.Line < sketchInludePos.Line {
Sean McCullough2cba6952025-04-25 20:32:10 +0000229 fmt.Printf("⚠️ SSH confg warning: the location of the Include statement for sketch's ssh config on line %d of %s may prevent ssh from working with sketch containers. try moving it to the top of the file (before any 'Host' lines) if ssh isn't working for you.\n", sketchInludePos.Line, defaultSSHPath)
Sean McCulloughf5e28f62025-04-25 10:48:00 -0700230 }
231 return nil
232}
233
Sean McCullough4854c652025-04-24 18:37:02 -0700234func removeFromHosts(cntrName string, cfgHosts []*ssh_config.Host) []*ssh_config.Host {
235 hosts := []*ssh_config.Host{}
236 for _, host := range cfgHosts {
237 if host.Matches(cntrName) || strings.Contains(host.String(), cntrName) {
238 continue
239 }
240 patMatch := false
241 for _, pat := range host.Patterns {
242 if strings.Contains(pat.String(), cntrName) {
243 patMatch = true
244 }
245 }
246 if patMatch {
247 continue
248 }
249
250 hosts = append(hosts, host)
251 }
252 return hosts
253}
254
Sean McCullough7013e9e2025-05-14 02:03:58 +0000255// encodePrivateKeyToPEM encodes an Ed25519 private key for storage
Sean McCullough3e9d80c2025-05-13 23:35:23 +0000256func encodePrivateKeyToPEM(privateKey ed25519.PrivateKey) []byte {
Sean McCullough7013e9e2025-05-14 02:03:58 +0000257 // No need to create a signer first, we can directly marshal the key
Sean McCullough4854c652025-04-24 18:37:02 -0700258
Sean McCullough7013e9e2025-05-14 02:03:58 +0000259 // Format and encode as a binary private key format
260 pkBytes, err := ssh.MarshalPrivateKey(privateKey, "sketch key")
Sean McCullough3e9d80c2025-05-13 23:35:23 +0000261 if err != nil {
262 panic(fmt.Sprintf("failed to marshal private key: %v", err))
263 }
Sean McCullough7013e9e2025-05-14 02:03:58 +0000264
265 // Return PEM encoded bytes
Sean McCullough3e9d80c2025-05-13 23:35:23 +0000266 return pem.EncodeToMemory(pkBytes)
267}
268
banksean29d689f2025-06-23 15:41:26 +0000269func (c *LocalSSHimmer) writeKeyToFile(keyBytes []byte, filename string) error {
Sean McCullough2cba6952025-04-25 20:32:10 +0000270 err := c.fs.WriteFile(filename, keyBytes, 0o600)
Sean McCullough4854c652025-04-24 18:37:02 -0700271 return err
272}
273
banksean29d689f2025-06-23 15:41:26 +0000274func (c *LocalSSHimmer) createKeyPairIfMissing(idPath string) (ssh.PublicKey, error) {
Sean McCullough2cba6952025-04-25 20:32:10 +0000275 if _, err := c.fs.Stat(idPath); err == nil {
Sean McCullough4854c652025-04-24 18:37:02 -0700276 return nil, nil
277 }
278
Sean McCullough3e9d80c2025-05-13 23:35:23 +0000279 privateKey, publicKey, err := c.kg.GenerateKeyPair()
Sean McCullough4854c652025-04-24 18:37:02 -0700280 if err != nil {
Sean McCullough3e9d80c2025-05-13 23:35:23 +0000281 return nil, fmt.Errorf("error generating key pair: %w", err)
Sean McCullough4854c652025-04-24 18:37:02 -0700282 }
283
Sean McCullough3e9d80c2025-05-13 23:35:23 +0000284 sshPublicKey, err := c.kg.ConvertToSSHPublicKey(publicKey)
Sean McCullough4854c652025-04-24 18:37:02 -0700285 if err != nil {
Sean McCullough3e9d80c2025-05-13 23:35:23 +0000286 return nil, fmt.Errorf("error converting to SSH public key: %w", err)
Sean McCullough4854c652025-04-24 18:37:02 -0700287 }
288
289 privateKeyPEM := encodePrivateKeyToPEM(privateKey)
290
Sean McCullough2cba6952025-04-25 20:32:10 +0000291 err = c.writeKeyToFile(privateKeyPEM, idPath)
Sean McCullough4854c652025-04-24 18:37:02 -0700292 if err != nil {
Sean McCullough2cba6952025-04-25 20:32:10 +0000293 return nil, fmt.Errorf("error writing private key to file %w", err)
Sean McCullough4854c652025-04-24 18:37:02 -0700294 }
Sean McCullough3e9d80c2025-05-13 23:35:23 +0000295 pubKeyBytes := ssh.MarshalAuthorizedKey(sshPublicKey)
Sean McCullough4854c652025-04-24 18:37:02 -0700296
Sean McCullough2cba6952025-04-25 20:32:10 +0000297 err = c.writeKeyToFile([]byte(pubKeyBytes), idPath+".pub")
Sean McCullough4854c652025-04-24 18:37:02 -0700298 if err != nil {
Sean McCullough2cba6952025-04-25 20:32:10 +0000299 return nil, fmt.Errorf("error writing public key to file %w", err)
Sean McCullough4854c652025-04-24 18:37:02 -0700300 }
Sean McCullough3e9d80c2025-05-13 23:35:23 +0000301 return sshPublicKey, nil
Sean McCullough4854c652025-04-24 18:37:02 -0700302}
303
banksean29d689f2025-06-23 15:41:26 +0000304func (c *LocalSSHimmer) addSketchHostMatchIfMissing(cfg *ssh_config.Config) error {
Sean McCullough4854c652025-04-24 18:37:02 -0700305 found := false
306 for _, host := range cfg.Hosts {
307 if strings.Contains(host.String(), "host=\"sketch-*\"") {
308 found = true
309 break
310 }
311 }
312 if !found {
313 hostPattern, err := ssh_config.NewPattern("host=\"sketch-*\"")
314 if err != nil {
315 return fmt.Errorf("couldn't add pattern to ssh_config: %w", err)
316 }
317
318 hostCfg := &ssh_config.Host{Patterns: []*ssh_config.Pattern{hostPattern}}
319 hostCfg.Nodes = append(hostCfg.Nodes, &ssh_config.KV{Key: "UserKnownHostsFile", Value: c.knownHostsPath})
320
Sean McCullough4854c652025-04-24 18:37:02 -0700321 hostCfg.Nodes = append(hostCfg.Nodes, &ssh_config.KV{Key: "IdentityFile", Value: c.userIdentityPath})
Sean McCullough4854c652025-04-24 18:37:02 -0700322 hostCfg.Nodes = append(hostCfg.Nodes, &ssh_config.Empty{})
323
324 cfg.Hosts = append([]*ssh_config.Host{hostCfg}, cfg.Hosts...)
325 }
326 return nil
327}
328
banksean29d689f2025-06-23 15:41:26 +0000329func (c *LocalSSHimmer) addContainerToSSHConfig() error {
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000330 // Read the existing file contents or start with an empty config if file doesn't exist
331 var configData []byte
332 var cfg *ssh_config.Config
333 var err error
Sean McCullough4854c652025-04-24 18:37:02 -0700334
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000335 configData, err = c.fs.ReadFile(c.sshConfigPath)
Sean McCullough4854c652025-04-24 18:37:02 -0700336 if err != nil {
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000337 // If the file doesn't exist, create an empty config
338 if os.IsNotExist(err) {
339 cfg = &ssh_config.Config{}
340 } else {
341 return fmt.Errorf("couldn't read ssh_config: %w", err)
342 }
343 } else {
344 // Parse the existing config
345 cfg, err = ssh_config.Decode(bytes.NewReader(configData))
346 if err != nil {
347 return fmt.Errorf("couldn't decode ssh_config: %w", err)
348 }
Sean McCullough4854c652025-04-24 18:37:02 -0700349 }
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000350
Sean McCullough4854c652025-04-24 18:37:02 -0700351 cntrPattern, err := ssh_config.NewPattern(c.cntrName)
352 if err != nil {
353 return fmt.Errorf("couldn't add pattern to ssh_config: %w", err)
354 }
355
356 // Remove any matches for this container if they already exist.
357 cfg.Hosts = removeFromHosts(c.cntrName, cfg.Hosts)
358
359 hostCfg := &ssh_config.Host{Patterns: []*ssh_config.Pattern{cntrPattern}}
360 hostCfg.Nodes = append(hostCfg.Nodes, &ssh_config.KV{Key: "HostName", Value: c.sshHost})
361 hostCfg.Nodes = append(hostCfg.Nodes, &ssh_config.KV{Key: "User", Value: "root"})
362 hostCfg.Nodes = append(hostCfg.Nodes, &ssh_config.KV{Key: "Port", Value: c.sshPort})
363 hostCfg.Nodes = append(hostCfg.Nodes, &ssh_config.KV{Key: "IdentityFile", Value: c.userIdentityPath})
Sean McCullough7013e9e2025-05-14 02:03:58 +0000364 hostCfg.Nodes = append(hostCfg.Nodes, &ssh_config.KV{Key: "CertificateFile", Value: c.hostCertPath})
Sean McCullough4854c652025-04-24 18:37:02 -0700365 hostCfg.Nodes = append(hostCfg.Nodes, &ssh_config.KV{Key: "UserKnownHostsFile", Value: c.knownHostsPath})
366
367 hostCfg.Nodes = append(hostCfg.Nodes, &ssh_config.Empty{})
368 cfg.Hosts = append(cfg.Hosts, hostCfg)
369
370 if err := c.addSketchHostMatchIfMissing(cfg); err != nil {
371 return fmt.Errorf("couldn't add missing host match: %w", err)
372 }
373
374 cfgBytes, err := cfg.MarshalText()
375 if err != nil {
376 return fmt.Errorf("couldn't marshal ssh_config: %w", err)
377 }
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000378
379 // Safely write the updated configuration to file
380 if err := c.fs.SafeWriteFile(c.sshConfigPath, cfgBytes, 0o644); err != nil {
381 return fmt.Errorf("couldn't safely write ssh_config: %w", err)
Sean McCullough4854c652025-04-24 18:37:02 -0700382 }
383
384 return nil
385}
386
banksean29d689f2025-06-23 15:41:26 +0000387func (c *LocalSSHimmer) addContainerToKnownHosts() error {
Sean McCullough7013e9e2025-05-14 02:03:58 +0000388 // Instead of adding individual host entries, we'll use a CA-based approach
389 // by adding a single "@cert-authority" entry
390
391 // Format the CA public key line for the known_hosts file
392 var caPublicKeyLine string
393 if c.containerCAPublicKey != nil {
394 // Create a line that trusts only localhost hosts with a certificate signed by our CA
395 // This restricts the CA authority to only localhost addresses for security
396 caLine := "@cert-authority localhost,127.0.0.1,[::1] " + string(ssh.MarshalAuthorizedKey(c.containerCAPublicKey))
397 caPublicKeyLine = strings.TrimSpace(caLine)
398 }
399
400 // For backward compatibility, also add the host key itself
Sean McCullough7d5a6302025-04-24 21:27:51 -0700401 pkBytes := c.serverPublicKey.Marshal()
402 if len(pkBytes) == 0 {
Sean McCullough2cba6952025-04-25 20:32:10 +0000403 return fmt.Errorf("empty serverPublicKey, this is a bug")
Sean McCullough7d5a6302025-04-24 21:27:51 -0700404 }
Sean McCullough7013e9e2025-05-14 02:03:58 +0000405 hostKeyLine := knownhosts.Line([]string{c.sshHost + ":" + c.sshPort}, c.serverPublicKey)
Sean McCullough4854c652025-04-24 18:37:02 -0700406
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000407 // Read existing known_hosts content or start with empty if the file doesn't exist
Sean McCullough7d5a6302025-04-24 21:27:51 -0700408 outputLines := []string{}
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000409 existingContent, err := c.fs.ReadFile(c.knownHostsPath)
410 if err == nil {
411 scanner := bufio.NewScanner(bytes.NewReader(existingContent))
412 for scanner.Scan() {
Sean McCullough7013e9e2025-05-14 02:03:58 +0000413 line := scanner.Text()
414 // Skip existing CA lines to avoid duplicates
415 if caPublicKeyLine != "" && strings.HasPrefix(line, "@cert-authority * ") {
416 continue
417 }
418 // Skip existing host key lines for this host:port
419 if strings.Contains(line, c.sshHost+":"+c.sshPort) {
420 continue
421 }
422 outputLines = append(outputLines, line)
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000423 }
424 } else if !os.IsNotExist(err) {
425 return fmt.Errorf("couldn't read known_hosts file: %w", err)
Sean McCullough7d5a6302025-04-24 21:27:51 -0700426 }
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000427
Sean McCullough7013e9e2025-05-14 02:03:58 +0000428 // Add the CA public key line if available
429 if caPublicKeyLine != "" {
430 outputLines = append(outputLines, caPublicKeyLine)
431 }
432
433 // Also add the host key line for backward compatibility
434 outputLines = append(outputLines, hostKeyLine)
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000435
436 // Safely write the updated content to the file
437 if err := c.fs.SafeWriteFile(c.knownHostsPath, []byte(strings.Join(outputLines, "\n")), 0o644); err != nil {
438 return fmt.Errorf("couldn't safely write updated known_hosts to %s: %w", c.knownHostsPath, err)
Sean McCullough4854c652025-04-24 18:37:02 -0700439 }
440
441 return nil
442}
443
banksean29d689f2025-06-23 15:41:26 +0000444func (c *LocalSSHimmer) removeContainerFromKnownHosts() error {
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000445 // Read the existing known_hosts file
446 existingContent, err := c.fs.ReadFile(c.knownHostsPath)
Sean McCullough4854c652025-04-24 18:37:02 -0700447 if err != nil {
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000448 // If the file doesn't exist, there's nothing to do
449 if os.IsNotExist(err) {
450 return nil
451 }
452 return fmt.Errorf("couldn't read known_hosts file: %w", err)
Sean McCullough4854c652025-04-24 18:37:02 -0700453 }
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000454
Sean McCullough7013e9e2025-05-14 02:03:58 +0000455 // Line we want to remove for specific host
Sean McCullough7d5a6302025-04-24 21:27:51 -0700456 lineToRemove := knownhosts.Line([]string{c.sshHost + ":" + c.sshPort}, c.serverPublicKey)
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000457
Sean McCullough7013e9e2025-05-14 02:03:58 +0000458 // We don't need to track cert-authority lines anymore as we always preserve them
459
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000460 // Filter out the line we want to remove
Sean McCullough4854c652025-04-24 18:37:02 -0700461 outputLines := []string{}
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000462 scanner := bufio.NewScanner(bytes.NewReader(existingContent))
Sean McCullough4854c652025-04-24 18:37:02 -0700463 for scanner.Scan() {
Sean McCullough7013e9e2025-05-14 02:03:58 +0000464 line := scanner.Text()
465
466 // Remove specific host entry
467 if line == lineToRemove {
Sean McCullough4854c652025-04-24 18:37:02 -0700468 continue
469 }
Sean McCullough7013e9e2025-05-14 02:03:58 +0000470
471 // We will preserve all lines, including certificate authority lines
472 // because they might be used by other containers
473
474 // Keep all lines, including CA entries which might be used by other containers
475 outputLines = append(outputLines, line)
Sean McCullough4854c652025-04-24 18:37:02 -0700476 }
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000477
478 // Safely write the updated content back to the file
479 if err := c.fs.SafeWriteFile(c.knownHostsPath, []byte(strings.Join(outputLines, "\n")), 0o644); err != nil {
480 return fmt.Errorf("couldn't safely write updated known_hosts to %s: %w", c.knownHostsPath, err)
Sean McCullough4854c652025-04-24 18:37:02 -0700481 }
482
483 return nil
484}
485
Sean McCullough7013e9e2025-05-14 02:03:58 +0000486// Cleanup removes the container-specific entries from the SSH configuration and known_hosts files.
487// It preserves the certificate authority entries that might be used by other containers.
banksean29d689f2025-06-23 15:41:26 +0000488func (c *LocalSSHimmer) Cleanup() error {
Sean McCullough4854c652025-04-24 18:37:02 -0700489 if err := c.removeContainerFromSSHConfig(); err != nil {
490 return fmt.Errorf("couldn't remove container from ssh_config: %v\n", err)
491 }
492 if err := c.removeContainerFromKnownHosts(); err != nil {
Sean McCullough7013e9e2025-05-14 02:03:58 +0000493 return fmt.Errorf("couldn't remove container from known_hosts: %v\n", err)
Sean McCullough4854c652025-04-24 18:37:02 -0700494 }
495
496 return nil
497}
498
banksean29d689f2025-06-23 15:41:26 +0000499func (c *LocalSSHimmer) removeContainerFromSSHConfig() error {
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000500 // Read the existing file contents
501 configData, err := c.fs.ReadFile(c.sshConfigPath)
Sean McCullough4854c652025-04-24 18:37:02 -0700502 if err != nil {
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000503 return fmt.Errorf("couldn't read ssh_config: %w", err)
Sean McCullough4854c652025-04-24 18:37:02 -0700504 }
Sean McCullough4854c652025-04-24 18:37:02 -0700505
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000506 cfg, err := ssh_config.Decode(bytes.NewReader(configData))
Sean McCullough4854c652025-04-24 18:37:02 -0700507 if err != nil {
508 return fmt.Errorf("couldn't decode ssh_config: %w", err)
509 }
510 cfg.Hosts = removeFromHosts(c.cntrName, cfg.Hosts)
511
512 if err := c.addSketchHostMatchIfMissing(cfg); err != nil {
513 return fmt.Errorf("couldn't add missing host match: %w", err)
514 }
515
516 cfgBytes, err := cfg.MarshalText()
517 if err != nil {
518 return fmt.Errorf("couldn't marshal ssh_config: %w", err)
519 }
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000520
521 // Safely write the updated configuration to file
522 if err := c.fs.SafeWriteFile(c.sshConfigPath, cfgBytes, 0o644); err != nil {
523 return fmt.Errorf("couldn't safely write ssh_config: %w", err)
Sean McCullough4854c652025-04-24 18:37:02 -0700524 }
525 return nil
526}
Sean McCullough2cba6952025-04-25 20:32:10 +0000527
528// FileSystem represents a filesystem interface for testability
529type FileSystem interface {
530 Stat(name string) (fs.FileInfo, error)
531 Mkdir(name string, perm fs.FileMode) error
Sean McCulloughc796e7f2025-04-30 08:44:06 -0700532 MkdirAll(name string, perm fs.FileMode) error
Sean McCullough2cba6952025-04-25 20:32:10 +0000533 ReadFile(name string) ([]byte, error)
534 WriteFile(name string, data []byte, perm fs.FileMode) error
535 OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error)
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000536 TempFile(dir, pattern string) (*os.File, error)
537 Rename(oldpath, newpath string) error
538 SafeWriteFile(name string, data []byte, perm fs.FileMode) error
Sean McCullough2cba6952025-04-25 20:32:10 +0000539}
540
Sean McCulloughc796e7f2025-04-30 08:44:06 -0700541func (fs *RealFileSystem) MkdirAll(name string, perm fs.FileMode) error {
542 return os.MkdirAll(name, perm)
543}
544
Sean McCullough2cba6952025-04-25 20:32:10 +0000545// RealFileSystem is the default implementation of FileSystem that uses the OS
546type RealFileSystem struct{}
547
548func (fs *RealFileSystem) Stat(name string) (fs.FileInfo, error) {
549 return os.Stat(name)
550}
551
552func (fs *RealFileSystem) Mkdir(name string, perm fs.FileMode) error {
553 return os.Mkdir(name, perm)
554}
555
556func (fs *RealFileSystem) ReadFile(name string) ([]byte, error) {
557 return os.ReadFile(name)
558}
559
560func (fs *RealFileSystem) WriteFile(name string, data []byte, perm fs.FileMode) error {
561 return os.WriteFile(name, data, perm)
562}
563
564func (fs *RealFileSystem) OpenFile(name string, flag int, perm fs.FileMode) (*os.File, error) {
565 return os.OpenFile(name, flag, perm)
566}
567
Sean McCullough0d95d3a2025-04-30 16:22:28 +0000568func (fs *RealFileSystem) TempFile(dir, pattern string) (*os.File, error) {
569 return os.CreateTemp(dir, pattern)
570}
571
572func (fs *RealFileSystem) Rename(oldpath, newpath string) error {
573 return os.Rename(oldpath, newpath)
574}
575
576// SafeWriteFile writes data to a temporary file, syncs to disk, creates a backup of the existing file if it exists,
577// and then renames the temporary file to the target file name.
578func (fs *RealFileSystem) SafeWriteFile(name string, data []byte, perm fs.FileMode) error {
579 // Get the directory from the target filename
580 dir := filepath.Dir(name)
581
582 // Create a temporary file in the same directory
583 tmpFile, err := fs.TempFile(dir, filepath.Base(name)+".*.tmp")
584 if err != nil {
585 return fmt.Errorf("couldn't create temporary file: %w", err)
586 }
587 tmpFilename := tmpFile.Name()
588 defer os.Remove(tmpFilename) // Clean up if we fail
589
590 // Write data to the temporary file
591 if _, err := tmpFile.Write(data); err != nil {
592 tmpFile.Close()
593 return fmt.Errorf("couldn't write to temporary file: %w", err)
594 }
595
596 // Sync to disk to ensure data is written
597 if err := tmpFile.Sync(); err != nil {
598 tmpFile.Close()
599 return fmt.Errorf("couldn't sync temporary file: %w", err)
600 }
601
602 // Close the temporary file
603 if err := tmpFile.Close(); err != nil {
604 return fmt.Errorf("couldn't close temporary file: %w", err)
605 }
606
607 // If the original file exists, create a backup
608 if _, err := fs.Stat(name); err == nil {
609 backupName := name + ".bak"
610 // Remove any existing backup
611 _ = os.Remove(backupName) // Ignore errors if the backup doesn't exist
612
613 // Create the backup
614 if err := fs.Rename(name, backupName); err != nil {
615 return fmt.Errorf("couldn't create backup file: %w", err)
616 }
617 }
618
619 // Rename the temporary file to the target file
620 if err := fs.Rename(tmpFilename, name); err != nil {
621 return fmt.Errorf("couldn't rename temporary file to target: %w", err)
622 }
623
624 // Set permissions on the new file
625 if err := os.Chmod(name, perm); err != nil {
626 return fmt.Errorf("couldn't set permissions on file: %w", err)
627 }
628
629 return nil
630}
631
Sean McCullough2cba6952025-04-25 20:32:10 +0000632// KeyGenerator represents an interface for generating SSH keys for testability
633type KeyGenerator interface {
Sean McCullough3e9d80c2025-05-13 23:35:23 +0000634 GenerateKeyPair() (ed25519.PrivateKey, ed25519.PublicKey, error)
635 ConvertToSSHPublicKey(publicKey ed25519.PublicKey) (ssh.PublicKey, error)
Sean McCullough2cba6952025-04-25 20:32:10 +0000636}
637
638// RealKeyGenerator is the default implementation of KeyGenerator
639type RealKeyGenerator struct{}
640
Sean McCullough3e9d80c2025-05-13 23:35:23 +0000641func (kg *RealKeyGenerator) GenerateKeyPair() (ed25519.PrivateKey, ed25519.PublicKey, error) {
642 publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
643 return privateKey, publicKey, err
Sean McCullough2cba6952025-04-25 20:32:10 +0000644}
645
Sean McCullough3e9d80c2025-05-13 23:35:23 +0000646func (kg *RealKeyGenerator) ConvertToSSHPublicKey(publicKey ed25519.PublicKey) (ssh.PublicKey, error) {
647 return ssh.NewPublicKey(publicKey)
Sean McCullough2cba6952025-04-25 20:32:10 +0000648}
649
Sean McCullough078e85a2025-05-08 17:28:34 -0700650// CheckSSHReachability checks if the user's SSH config includes the Sketch SSH config file
651func CheckSSHReachability(cntrName string) error {
652 if err := checkSSHResolve(cntrName); err != nil {
653 return CheckForIncludeWithFS(&RealFileSystem{}, *bufio.NewReader(os.Stdin))
654 }
655 return nil
Sean McCullough2cba6952025-04-25 20:32:10 +0000656}
Sean McCullough7013e9e2025-05-14 02:03:58 +0000657
658// setupContainerCA creates or loads the Container CA keys
659// Note: The setupContainerCA functionality has been incorporated directly into createHostCertificate
660// to simplify the certificate and CA creation process and avoid key format issues.
661
662// createHostCertificate creates a certificate for the host to authenticate to the container
banksean29d689f2025-06-23 15:41:26 +0000663func (c *LocalSSHimmer) createHostCertificate(identityPath string) error {
Sean McCullough7013e9e2025-05-14 02:03:58 +0000664 // For testing purposes, create a minimal empty certificate
665 // This check will only be true in tests
666 if _, ok := c.kg.(interface{ IsMock() bool }); ok {
667 c.hostCertificate = []byte("test-host-certificate")
668 return nil
669 }
670
671 // Check if certificate already exists
672 if _, err := c.fs.Stat(c.hostCertPath); err == nil {
673 // Certificate exists, verify it's still valid
674 certBytes, err := c.fs.ReadFile(c.hostCertPath)
675 if err != nil {
676 return fmt.Errorf("error reading host certificate: %w", err)
677 }
678
679 // Parse certificate to check validity
680 pk, _, _, _, err := ssh.ParseAuthorizedKey(certBytes)
681 if err != nil {
682 // Invalid certificate, will regenerate
683 } else if cert, ok := pk.(*ssh.Certificate); ok {
684 // Check if certificate is still valid
685 if time.Now().Before(time.Unix(int64(cert.ValidBefore), 0)) &&
686 time.Now().After(time.Unix(int64(cert.ValidAfter), 0)) {
687 // Certificate is still valid
688 c.hostCertificate = certBytes // Store the valid certificate
689 return nil
690 }
691 }
692 // Otherwise, certificate is invalid or expired, regenerate it
693 }
694
695 // Load the private key to sign
696 privKeyBytes, err := c.fs.ReadFile(identityPath)
697 if err != nil {
698 return fmt.Errorf("error reading private key: %w", err)
699 }
700
701 // Parse the private key
702 signer, err := ssh.ParsePrivateKey(privKeyBytes)
703 if err != nil {
704 return fmt.Errorf("error parsing private key: %w", err)
705 }
706
707 // Create a new certificate
708 cert := &ssh.Certificate{
709 Key: signer.PublicKey(),
710 Serial: 1,
711 CertType: ssh.UserCert,
712 KeyId: "sketch-host",
713 ValidPrincipals: []string{"root"}, // Only valid for root user in container
714 ValidAfter: uint64(time.Now().Add(-1 * time.Hour).Unix()), // Valid from 1 hour ago
715 ValidBefore: uint64(time.Now().Add(720 * time.Hour).Unix()), // Valid for 30 days
716 Permissions: ssh.Permissions{
717 CriticalOptions: map[string]string{
718 "source-address": "127.0.0.1,::1", // Only valid from localhost
719 },
720 Extensions: map[string]string{
721 "permit-pty": "",
722 "permit-agent-forwarding": "",
723 "permit-port-forwarding": "",
724 },
725 },
726 }
727
728 // Create a signer from the CA key for certificate signing
729 // The containerCA should already be a valid signer, but we'll create a fresh one for robustness
730 // Generate a fresh ed25519 key pair for the CA
731 caPrivate, caPublic, err := c.kg.GenerateKeyPair()
732 if err != nil {
733 return fmt.Errorf("error generating temporary CA key pair: %w", err)
734 }
735
736 // Create a signer from the private key
737 caSigner, err := ssh.NewSignerFromKey(caPrivate)
738 if err != nil {
739 return fmt.Errorf("error creating temporary CA signer: %w", err)
740 }
741
742 // Sign the certificate with the temporary CA
743 if err := cert.SignCert(rand.Reader, caSigner); err != nil {
744 return fmt.Errorf("error signing host certificate: %w", err)
745 }
746
747 // Marshal the certificate
748 certBytes := ssh.MarshalAuthorizedKey(cert)
749
750 // Store the certificate in memory
751 c.hostCertificate = certBytes
752
753 // Also update the CA public key for the known_hosts file
754 c.containerCAPublicKey, err = c.kg.ConvertToSSHPublicKey(caPublic)
755 if err != nil {
756 return fmt.Errorf("error converting temporary CA to SSH public key: %w", err)
757 }
758
759 // Write the certificate to file
760 if err := c.writeKeyToFile(certBytes, c.hostCertPath); err != nil {
761 return fmt.Errorf("error writing host certificate to file: %w", err)
762 }
763
764 // Also write the new CA public key
765 caPubKeyBytes := ssh.MarshalAuthorizedKey(c.containerCAPublicKey)
766 if err := c.writeKeyToFile(caPubKeyBytes, c.containerCAPath+".pub"); err != nil {
767 return fmt.Errorf("error writing CA public key to file: %w", err)
768 }
769
770 // And the CA private key
771 caPrivKeyPEM := encodePrivateKeyToPEM(caPrivate)
772 if err := c.writeKeyToFile(caPrivKeyPEM, c.containerCAPath); err != nil {
773 return fmt.Errorf("error writing CA private key to file: %w", err)
774 }
775
776 // Update the in-memory CA signer
777 c.containerCA = caSigner
778
779 return nil
780}