Files
keyfunc/internal/cli/ssh/install.go
T
clawbot f6663e4df2
check / check (push) Failing after 1s
ssh install tells a missing .ssh from one it cannot enter (closes #10)
The first sftp connection now lists ~/.ssh before it fetches
authorized_keys. The file reads as empty in just two cases: sftp reports
~/.ssh itself as not there, or the listing succeeds and the fetch then
reports the file as not there. A directory that is there but cannot be
entered, or a file that cannot be read, fails the run and writes nothing,
so a ~/.ssh whose mode shuts the user out is no longer read as a host
with no file and replaced by one holding the new key alone. The write
connection makes ~/.ssh and sets 0700 only when the read found none; an
existing directory keeps its mode.

Model: opus-4-8
2026-09-21 07:38:26 +00:00

334 lines
10 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, 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 + `"`
}