All checks were successful
check / check (push) Successful in 20s
The command no longer sends a shell script to the host. It fetches ~/.ssh/authorized_keys with the system sftp in batch mode, adds the key line here, and writes the file back in a second session: mkdir and chmod on ~/.ssh, put to authorized_keys.keyfunc-<random>, chmod 600, rename over authorized_keys. Adding the line connects twice. The file reads as empty only when sftp reported it as not there, in the line naming that path; the same wording elsewhere -- ssh writes it about an identity file it cannot find -- does not count, so a file that cannot be read is never written over. A failed step removes nothing and names the uploaded file once sftp's echo shows the put was reached. Model: opus-5
271 lines
7.8 KiB
Go
271 lines
7.8 KiB
Go
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, 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)
|
|
}
|
|
|
|
// 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.
|
|
said, err := session(cmd, host, options, []string{
|
|
"-mkdir " + directory,
|
|
"chmod " + directoryMode + " " + directory,
|
|
"put " + quoted(local) + " " + sidecar,
|
|
"chmod " + fileMode + " " + sidecar,
|
|
"rename " + sidecar + " " + authorized,
|
|
})
|
|
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. A host that has no such file reads as empty,
|
|
// but only when that is what sftp said about it: a file that is there
|
|
// and cannot be read fails the run, because writing back over it
|
|
// would leave the host with the new key and nothing else.
|
|
func fetch(
|
|
cmd *cobra.Command, host string, options []string, into string,
|
|
) (string, error) {
|
|
said, err := session(cmd, host, options, []string{
|
|
"get " + authorized + " " + quoted(into),
|
|
})
|
|
if err != nil {
|
|
if absent(said) {
|
|
return "", nil
|
|
}
|
|
|
|
return "", err
|
|
}
|
|
|
|
//nolint:gosec // the path is a temporary file of the tool's own
|
|
content, err := os.ReadFile(into)
|
|
if err != nil {
|
|
return "", fmt.Errorf("reading the fetched file: %w", err)
|
|
}
|
|
|
|
return string(content), nil
|
|
}
|
|
|
|
// 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 + `"`
|
|
}
|