The ssh install command works over sftp (closes #10)
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, then
rename over authorized_keys. A run that adds a line connects twice; one
that finds the line there connects once and stops. A failed step leaves
everything as it is and names the uploaded file.

sftp echoes the commands it runs, so all of its output goes to standard
error and the tool prints only "added" or "already present". Batch mode
cannot prompt for a password; the README says so.

Model: opus-5
This commit is contained in:
2026-09-08 04:14:46 +00:00
parent a2a0890ded
commit cac775c100
3 changed files with 325 additions and 108 deletions

View File

@@ -1,57 +1,49 @@
package ssh
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"github.com/spf13/cobra"
)
// script is what runs on the host. It reads the key line from its own
// standard input, so the line never appears on a command line, where
// anyone else on the host could read it out of the process list. It
// contains no single quote, so the whole of it travels through ssh
// inside one pair of them. The umask keeps anything it makes to the
// owner from the start; the modes are then set outright, whatever the
// umask on the host turns out to be. A file whose last line has no
// newline at its end gets one before the key line goes on, so that the
// two do not run into each other.
const script = `
set -e
umask 077
directory="$HOME/.ssh"
file="$directory/authorized_keys"
if [ ! -d "$directory" ]; then
mkdir -p "$directory"
chmod 700 "$directory"
fi
if [ ! -f "$file" ]; then
: > "$file"
chmod 600 "$file"
fi
IFS= read -r line
if grep -q -x -F -e "$line" "$file"; then
echo "already present"
else
if [ -s "$file" ] && [ -n "$(tail -c 1 "$file")" ]; then
printf "\n" >> "$file"
fi
printf "%s\n" "$line" >> "$file"
echo "added"
fi
`
// 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> [-- ssh options...]",
Use: "install <[user@]host> [-- sftp options...]",
Short: "add the public key to a host's authorized_keys",
Long: "Runs the system ssh to the host, which makes ~/.ssh and " +
"~/.ssh/authorized_keys there if they are missing and adds " +
"the public key unless the same line is already in the " +
"file. Anything after -- is given to ssh unchanged.",
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)
@@ -64,7 +56,7 @@ func install() *cobra.Command {
return err
}
return send(cmd, args[0], args[1:], line)
return add(cmd, args[0], args[1:], line)
},
}
@@ -73,24 +65,150 @@ func install() *cobra.Command {
return cmd
}
// send runs ssh to the host with the user's options, gives it the
// script to run there, and writes the key line to its standard input.
// What the host says, added or already present, is passed straight on.
func send(cmd *cobra.Command, host string, options []string, line string) error {
argv := slices.Concat(options, []string{
host, "/bin/sh -c '" + script + "'",
})
// 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)
}
//nolint:gosec // the options are the user's own, meant for ssh
command := exec.CommandContext(cmd.Context(), "ssh", argv...)
command.Stdin = strings.NewReader(line + "\n")
command.Stdout = cmd.OutOrStdout()
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 ssh: %w", err)
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 + `"`
}