From 6c258b7478d9d059f69572138590a78d299e279d Mon Sep 17 00:00:00 2001 From: Stanislav Zhuk Date: Wed, 13 Nov 2024 17:05:40 +0200 Subject: [PATCH] feat: allow files and symlinks in `ddev auth ssh`, fixes #5465, fixes #6677 (#6678) --- cmd/ddev/cmd/auth-ssh.go | 161 ++++++++++++++++++++++----- docs/content/users/usage/commands.md | 7 ++ 2 files changed, 140 insertions(+), 28 deletions(-) diff --git a/cmd/ddev/cmd/auth-ssh.go b/cmd/ddev/cmd/auth-ssh.go index 5ba8da219e2..d2959495954 100644 --- a/cmd/ddev/cmd/auth-ssh.go +++ b/cmd/ddev/cmd/auth-ssh.go @@ -1,27 +1,35 @@ package cmd import ( - "os" - "path/filepath" - + "fmt" "github.com/ddev/ddev/pkg/ddevapp" + "github.com/ddev/ddev/pkg/docker" "github.com/ddev/ddev/pkg/exec" + "github.com/ddev/ddev/pkg/fileutil" "github.com/ddev/ddev/pkg/globalconfig" + "github.com/ddev/ddev/pkg/heredoc" "github.com/ddev/ddev/pkg/nodeps" "github.com/ddev/ddev/pkg/util" - "github.com/ddev/ddev/pkg/versionconstants" "github.com/spf13/cobra" + "os" + "path/filepath" + "slices" + "strings" ) -// sshKeyPath is the full path to the *directory* containing SSH keys. -var sshKeyPath string +var sshKeyFiles, sshKeyDirs []string // AuthSSHCommand implements the "ddev auth ssh" command var AuthSSHCommand = &cobra.Command{ - Use: "ssh", - Short: "Add SSH key authentication to the ddev-ssh-agent container", - Long: `Use this command to provide the password to your SSH key to the ddev-ssh-agent container, where it can be used by other containers. Normal usage is "ddev auth ssh", or if your key is not in ~/.ssh, ddev auth ssh --ssh-key-path=/some/path/.ssh"`, - Example: `ddev auth ssh`, + Use: "ssh", + Short: "Add SSH key authentication to the ddev-ssh-agent container", + Long: `Use this command to provide the password to your SSH key to the ddev-ssh-agent container, where it can be used by other containers.`, + Example: heredoc.DocI2S(` + ddev auth ssh + ddev auth ssh -d ~/custom/path/to/ssh + ddev auth ssh -f ~/.ssh/id_ed25519 -f ~/.ssh/id_rsa + ddev auth ssh -d ~/.ssh -f ~/custom/path/to/ssh/id_ed25519 + `), Run: func(_ *cobra.Command, args []string) { var err error if len(args) > 0 { @@ -30,28 +38,32 @@ var AuthSSHCommand = &cobra.Command{ uidStr, _, _ := util.GetContainerUIDGid() - if sshKeyPath == "" { + // Use ~/.ssh if nothing is provided + if sshKeyFiles == nil && sshKeyDirs == nil { homeDir, err := os.UserHomeDir() if err != nil { util.Failed("Unable to determine home directory: %v", err) } - sshKeyPath = filepath.Join(homeDir, ".ssh") + sshKeyDirs = append(sshKeyDirs, filepath.Join(homeDir, ".ssh")) } - if !filepath.IsAbs(sshKeyPath) { - sshKeyPath, err = filepath.Abs(sshKeyPath) + + files := getSSHKeyPaths(sshKeyDirs, true, false) + files = append(files, getSSHKeyPaths(sshKeyFiles, false, true)...) + + var keys []string + // Get real paths to key files in case they are symlinks + for _, file := range files { + key, err := filepath.EvalSymlinks(file) if err != nil { - util.Failed("Failed to derive absolute path for SSH key path %s: %v", sshKeyPath, err) + util.Warning("Unable to read %s file: %v", file, err) + continue + } + if !slices.Contains(keys, key) && fileIsPrivateKey(key) { + keys = append(keys, key) } } - fi, err := os.Stat(sshKeyPath) - if os.IsNotExist(err) { - util.Failed("The SSH key directory %s was not found", sshKeyPath) - } - if err != nil { - util.Failed("Failed to check status of SSH key directory %s: %v", sshKeyPath, err) - } - if !fi.IsDir() { - util.Failed("The SSH key directory (%s) must be a directory", sshKeyPath) + if len(keys) == 0 { + util.Failed("No SSH keys found in %s", strings.Join(append(sshKeyDirs, sshKeyFiles...), ", ")) } app, err := ddevapp.GetActiveApp("") @@ -68,20 +80,113 @@ var AuthSSHCommand = &cobra.Command{ if err != nil { util.Failed("Failed to start ddev-ssh-agent container: %v", err) } - sshKeyPath = util.WindowsPathToCygwinPath(sshKeyPath) - dockerCmd := []string{"run", "-it", "--rm", "--volumes-from=" + ddevapp.SSHAuthName, "--user=" + uidStr, "--entrypoint=", "--mount=type=bind,src=" + sshKeyPath + ",dst=/tmp/sshtmp", versionconstants.SSHAuthImage + ":" + versionconstants.SSHAuthTag + "-built", "bash", "-c", `cp -r /tmp/sshtmp ~/.ssh && chmod -R go-rwx ~/.ssh && cd ~/.ssh && grep -l '^-----BEGIN .* PRIVATE KEY-----' * | xargs -d '\n' ssh-add`} + var mounts []string + // Map to track already added keys + addedKeys := make(map[string]struct{}) + for i, keyPath := range keys { + filename := filepath.Base(keyPath) + // If it has the same name, change it to avoid conflicts + // This can happen if you have symlinks to the same key + if _, exists := addedKeys[filename]; exists { + filename = fmt.Sprintf("%s_%d", filename, i) + } + addedKeys[filename] = struct{}{} + keyPath = util.WindowsPathToCygwinPath(keyPath) + mounts = append(mounts, "--mount=type=bind,src="+keyPath+",dst=/tmp/sshtmp/"+filename) + } + + dockerCmd := []string{"run", "-it", "--rm", "--volumes-from=" + ddevapp.SSHAuthName, "--user=" + uidStr, "--entrypoint="} + dockerCmd = append(dockerCmd, mounts...) + dockerCmd = append(dockerCmd, docker.GetSSHAuthImage()+"-built", "bash", "-c", `cp -r /tmp/sshtmp ~/.ssh && chmod -R go-rwx ~/.ssh && cd ~/.ssh && grep -l '^-----BEGIN .* PRIVATE KEY-----' * | xargs -d '\n' ssh-add`) err = exec.RunInteractiveCommand("docker", dockerCmd) if err != nil { - util.Failed("Docker command 'docker %v' failed: %v", dockerCmd, err) + helpMessage := "" + // Add more helpful message to the obscure error from Docker + // Can be triggered if the key is in /tmp on macOS + if strings.Contains(err.Error(), "bind source path does not exist") { + helpMessage = "\n\nThe specified SSH key path is not shared with your Docker provider." + } + util.Failed("Docker command 'docker %v' failed: %v %v", echoDockerCmd(dockerCmd), err, helpMessage) } }, } +// getSSHKeyPaths returns an array of full paths to SSH keys +// with checks to ensure they are valid. +func getSSHKeyPaths(sshKeyPathArray []string, acceptsDirsOnly bool, acceptsFilesOnly bool) []string { + var files []string + for _, sshKeyPath := range sshKeyPathArray { + if !filepath.IsAbs(sshKeyPath) { + cwd, err := os.Getwd() + if err != nil { + util.Failed("Failed to get current working directory: %v", err) + } + fullPath, err := filepath.Abs(filepath.Join(cwd, sshKeyPath)) + if err != nil { + util.Failed("Failed to derive absolute path for SSH key path %s: %v", sshKeyPath, err) + } + sshKeyPath = fullPath + } + fi, err := os.Stat(sshKeyPath) + if os.IsNotExist(err) { + util.Failed("The SSH key path %s was not found", sshKeyPath) + } + if err != nil { + util.Failed("Failed to check status of SSH key path %s: %v", sshKeyPath, err) + } + if !fi.IsDir() { + if acceptsDirsOnly { + util.Failed("SSH key path %s is not a directory", sshKeyPath) + } + files = append(files, sshKeyPath) + } else { + if acceptsFilesOnly { + util.Failed("SSH key path %s is not a file", sshKeyPath) + } + files, err = fileutil.ListFilesInDirFullPath(sshKeyPath, true) + if err != nil { + util.Failed("Failed to list files in %s: %v", sshKeyPath, err) + } + } + } + return files +} + +// fileIsPrivateKey checks if a file is readable and that it is a private key. +// Regex isn't used here because files can be huge. +// The full check if it's really a private key is done with grep -l '^-----BEGIN .* PRIVATE KEY-----'. +func fileIsPrivateKey(filePath string) bool { + file, err := os.Open(filePath) + if err != nil { + return false + } + // nolint: errcheck + defer file.Close() + prefix := []byte("-----BEGIN") + buffer := make([]byte, len(prefix)) + _, err = file.Read(buffer) + if err != nil { + return false + } + return string(buffer) == string(prefix) +} + +// echoDockerCmd formats the Docker command to be more readable. +func echoDockerCmd(dockerCmd []string) string { + for i, arg := range dockerCmd { + if strings.Contains(arg, " ") { + dockerCmd[i] = `"` + arg + `"` + } + } + return strings.Join(dockerCmd, " ") +} + func init() { - AuthSSHCommand.Flags().StringVarP(&sshKeyPath, "ssh-key-path", "d", "", "full path to SSH key directory") + AuthSSHCommand.Flags().StringArrayVarP(&sshKeyFiles, "ssh-key-file", "f", nil, "full path to SSH key file") + AuthSSHCommand.Flags().StringArrayVarP(&sshKeyDirs, "ssh-key-path", "d", nil, "full path to SSH key directory") AuthCmd.AddCommand(AuthSSHCommand) } diff --git a/docs/content/users/usage/commands.md b/docs/content/users/usage/commands.md index b96e7586037..1ecd079771b 100644 --- a/docs/content/users/usage/commands.md +++ b/docs/content/users/usage/commands.md @@ -191,10 +191,17 @@ Example: ```shell # Add your SSH keys to the SSH agent container ddev auth ssh +# Add your SSH keys from ~/custom/path/to/ssh +ddev auth ssh -d ~/custom/path/to/ssh +# Add your SSH keys from ~/.ssh/id_ed25519 and ~/.ssh/id_rsa +ddev auth ssh -f ~/.ssh/id_ed25519 -f ~/.ssh/id_rsa +# Add your SSH keys from ~/.ssh and ~/custom/path/to/ssh/id_ed25519 +ddev auth ssh -d ~/.ssh -f ~/custom/path/to/ssh/id_ed25519 ``` Flags: +* `--ssh-key-file`, `-f`: Full path to SSH key file. * `--ssh-key-path`, `-d`: Full path to SSH key directory. ## `blackfire`