package ssh import ( "crypto/rand" "encoding/hex" "errors" "fmt" "io/fs" "os" "os/exec" "path/filepath" "slices" "strings" "github.com/spf13/cobra" ) // Where the key goes on the host and what the file it arrives in is // called before it is renamed into place. The random end of that name // keeps two runs at once from writing to the same file. const ( directory = ".ssh" authorized = ".ssh/authorized_keys" sidecarPrefix = ".ssh/authorized_keys.keyfunc-" sidecarBytes = 8 ) // The modes the host is left with, as sftp's chmod spells them, and // the mode of the copy made here on the way. const ( directoryMode = "700" fileMode = "600" localMode = 0o600 ) // install returns the command that adds the public key to a host. func install() *cobra.Command { cmd := &cobra.Command{ Use: "install <[user@]host> [-- sftp options...]", Short: "add the public key to a host's authorized_keys", Long: "Downloads the host's authorized_keys with the system " + "sftp, adds the public key to it here unless the same " + "line is already there, and uploads the result as a file " + "beside it which is then renamed over it. Nothing is run " + "on the host. Anything after -- is given to sftp " + "unchanged, which is where the port goes (-P).", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { key, comment, err := derived(cmd) if err != nil { return err } line, err := key.Line(comment) if err != nil { return err } return add(cmd, args[0], args[1:], line) }, } addComment(cmd) return cmd } // add puts the key line in the host's authorized_keys. The file is // fetched in one sftp session and written back in another, so a run // that adds a line connects twice; a run that finds the line already // there connects once and stops. func add(cmd *cobra.Command, host string, options []string, line string) error { work, err := os.MkdirTemp("", "keyfunc-install-") if err != nil { return fmt.Errorf("making a temporary directory: %w", err) } defer func() { _ = os.RemoveAll(work) }() fetched := filepath.Join(work, "authorized_keys") // The get may fail: a host with no authorized_keys yet is not an // error, and nothing arrives. err = session(cmd, host, options, []string{ "-get " + authorized + " " + quoted(fetched), }) if err != nil { return err } content, err := arrived(fetched) if err != nil { return err } merged, added := merge(content, line) if !added { return write(cmd, "already present\n") } return upload(cmd, host, options, work, merged) } // upload writes the new file to the host and renames it over // authorized_keys, which is the step that either happens or does not. // Nothing is removed when a step fails: the file left behind is named // so that it can be looked at and cleared away by hand. func upload( cmd *cobra.Command, host string, options []string, work, merged string, ) error { local := filepath.Join(work, "authorized_keys.merged") err := os.WriteFile(local, []byte(merged), localMode) if err != nil { return fmt.Errorf("writing the new file: %w", err) } sidecar, err := sidecarName() if err != nil { return err } // The mkdir may fail: the directory is usually there already. err = session(cmd, host, options, []string{ "-mkdir " + directory, "chmod " + directoryMode + " " + directory, "put " + quoted(local) + " " + sidecar, "chmod " + fileMode + " " + sidecar, "rename " + sidecar + " " + authorized, }) if err != nil { return fmt.Errorf("%w; %s may be left on the host", err, sidecar) } return write(cmd, "added\n") } // session runs one sftp session with the user's own options and the // batch of commands, which sftp reads from its standard input and // stops at the first of which that fails, unless it begins with a // dash. sftp echoes the commands as it runs them, so everything it // says goes to the error output and the tool's own output stays the // one word it prints. func session( cmd *cobra.Command, host string, options []string, batch []string, ) error { argv := slices.Concat( []string{"-b", "-"}, options, []string{host}, ) //nolint:gosec // the options are the user's own, meant for sftp command := exec.CommandContext(cmd.Context(), "sftp", argv...) command.Stdin = strings.NewReader(strings.Join(batch, "\n") + "\n") command.Stdout = cmd.ErrOrStderr() command.Stderr = cmd.ErrOrStderr() err := command.Run() if err != nil { return fmt.Errorf("running sftp: %w", err) } return nil } // merge returns the file with the key line on the end, and whether it // had to be added. A file whose last line has no newline at its end // gets one first, so that the two lines do not run into each other. func merge(content, line string) (string, bool) { if slices.Contains(strings.Split(content, "\n"), line) { return content, false } if content != "" && !strings.HasSuffix(content, "\n") { content += "\n" } return content + line + "\n", true } // arrived returns what is in the fetched file, and nothing at all when // no file arrived because the host has none. func arrived(path string) (string, error) { //nolint:gosec // the path is a temporary file of the tool's own content, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { return "", nil } if err != nil { return "", fmt.Errorf("reading the fetched file: %w", err) } return string(content), nil } // sidecarName returns the name the new file is uploaded under. func sidecarName() (string, error) { random := make([]byte, sidecarBytes) _, err := rand.Read(random) if err != nil { return "", fmt.Errorf("making a name for the new file: %w", err) } return sidecarPrefix + hex.EncodeToString(random), nil } // quoted puts the double quotes around a path that sftp needs when the // path has a space in it. Only paths of the tool's own making are // given to it, and they hold no quote of their own. func quoted(path string) string { return `"` + path + `"` }