check / check (push) Failing after 0s
`keyfunc ssh to` and `keyfunc ssh install` started the system `ssh` and `sftp` with the tool's whole environment, so a mnemonic given in `KEYFUNC_MNEMONIC` stayed readable in the child's environment and could be forwarded to the host by a `SendEnv` line. Both children now get the environment with `KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` removed, through one helper, `childEnv`, in the ssh cli package. The mnemonic command still runs with the full environment. Two tests drive the real commands against the stand-in `ssh` and `sftp` and check that a third variable still arrives. Model: opus-4-8 (implementation, review); fable-5-1 (merge message)
335 lines
10 KiB
Go
335 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.Env = childEnv()
|
|
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 + `"`
|
|
}
|