package ssh import ( "bytes" "crypto/rand" "encoding/hex" "fmt" "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) }() content, present, err := fetch(cmd, host, options, filepath.Join(work, "authorized_keys"), ) 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, present) } // 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. The directory // is made and set to its mode only when the read found none: an .ssh // that was already there is left with the mode it had. func upload( cmd *cobra.Command, host string, options []string, work, merged string, present bool, ) 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 } var batch []string if !present { // The mkdir is allowed to fail in case the directory appeared // between the read and now; the chmod then sets its mode. batch = append(batch, "-mkdir "+directory, "chmod "+directoryMode+" "+directory, ) } batch = append(batch, "put "+quoted(local)+" "+sidecar, "chmod "+fileMode+" "+sidecar, "rename "+sidecar+" "+authorized, ) said, err := session(cmd, host, options, batch) if err != nil { // sftp echoes each command as it runs it and stops at the // first that fails, so the name is in what it said only once // the put was reached, which is where a file of that name // can be on the host. Before that there is none to name. if strings.Contains(said, sidecar) { return fmt.Errorf( "%w; %s may be left on the host", err, sidecar, ) } return err } 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. What it said is also given back: a session that // failed says there what went wrong, and the status alone does not. func session( cmd *cobra.Command, host string, options []string, batch []string, ) (string, error) { argv := slices.Concat( []string{"-b", "-"}, options, []string{host}, ) var said bytes.Buffer //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 = &said command.Stderr = &said err := command.Run() _, _ = cmd.ErrOrStderr().Write(said.Bytes()) if err != nil { return said.String(), fmt.Errorf("running sftp: %w", err) } return said.String(), 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 } // fetch brings the host's authorized_keys into the given path and // returns what is in it, and whether the .ssh directory was already // there. The one session lists .ssh and then gets the file, so the // listing settles the state of the directory before the get is read. // // The file reads as empty in just two cases: sftp reported .ssh itself // as not there, or the listing succeeded and the get then reported the // file as not there. Anything else — the listing refused, the file // there but unreadable, the connection down — fails the run and writes // nothing, because writing back over what was not read would leave the // host with the new key and nothing else. sftp cannot tell a missing // file from one in a directory it cannot enter, so the listing does: // a directory that is there but cannot be read is a failure, not an // empty file. func fetch( cmd *cobra.Command, host string, options []string, into string, ) (string, bool, error) { said, err := session(cmd, host, options, []string{ "ls -1 " + directory, "get " + authorized + " " + quoted(into), }) if err != nil { if directoryAbsent(said) { return "", false, nil } if absent(said) { return "", true, nil } return "", false, err } //nolint:gosec // the path is a temporary file of the tool's own content, err := os.ReadFile(into) if err != nil { return "", false, fmt.Errorf("reading the fetched file: %w", err) } return string(content), true, nil } // directoryAbsent says whether sftp reported .ssh itself as not being // there, which is the one listing failure read as a host that has no // authorized_keys yet. The reading is taken only from the line in which // sftp reports on that directory: any other failure of the listing, in // particular a directory that is there but cannot be entered, is left // as a failure, so that no key is written to a host whose keys were // never read. func directoryAbsent(said string) bool { for line := range strings.Lines(said) { named, is := reportedCannotList(strings.TrimSpace(line)) if is && (named == directory || strings.HasSuffix(named, "/"+directory)) { return true } } return false } // reportedCannotList returns the path an sftp line reports it cannot // list for want of the directory, and whether the line is such a // report. The client writes this one wording when the directory a // listing names is not there, giving the path the server expanded. func reportedCannotList(line string) (string, bool) { const ( before = `Can't ls: "` after = `" not found` ) if !strings.HasPrefix(line, before) || !strings.HasSuffix(line, after) { return "", false } return strings.TrimSuffix(strings.TrimPrefix(line, before), after), true } // absent says whether sftp reported the file that was asked for as // not being there, which is the one failure of the fetch that is read // as an empty authorized_keys. The reading is taken only from the // line in which sftp reports on that file, because ssh writes "no // such file" into the same output for reasons of its own — a missing // -i identity file draws that warning on a session that then // authenticates through the agent — and a real read failure on such a // session must not pass for an empty file. func absent(said string) bool { for line := range strings.Lines(said) { named, is := reportedNotFound(strings.TrimSpace(line)) if is && (named == authorized || strings.HasSuffix(named, "/"+authorized)) { return true } } return false } // reportedNotFound returns the path an sftp line reports as not being // there, and whether the line is such a report. The client writes one // wording for a remote file it cannot find, naming the path the // server expanded, which is the absolute one. func reportedNotFound(line string) (string, bool) { const ( before = `File "` after = `" not found.` ) if !strings.HasPrefix(line, before) || !strings.HasSuffix(line, after) { return "", false } return strings.TrimSuffix(strings.TrimPrefix(line, before), after), true } // 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 + `"` }