check / check (push) Failing after 3s
ssh install now takes the host alone before --: any other word there, or a second argument without --, is refused before the mnemonic is read or sftp runs, so keyfunc ssh install alice@host frank@host no longer installs the key for frank@host. The first listing of ~/.ssh is now ls -n, which shows the file type, so a symlinked authorized_keys is refused before any upload instead of being replaced by a regular file; the README says so. Judgement calls: install -- host is refused; a symlinked authorized_keys is refused even when its target already holds the key. Model: opus-5-5 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
439 lines
14 KiB
Go
439 lines
14 KiB
Go
package ssh
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"sneak.berlin/go/keyfunc/internal/cli/signals"
|
|
)
|
|
|
|
// 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
|
|
)
|
|
|
|
// waitDelay is the WaitDelay sftp runs with: from a signal, or from sftp
|
|
// ending, how long the tool waits for sftp to end and its output to
|
|
// close before it kills sftp and stops reading. That is ample for sftp
|
|
// to stop the ssh it started, and short enough that a signal still ends
|
|
// the tool within a second.
|
|
const waitDelay = 250 * time.Millisecond
|
|
|
|
// ErrCannotEnter is the refusal of a host whose .ssh is there but
|
|
// cannot be entered, so that nothing in it can be read or written.
|
|
var ErrCannotEnter = errors.New(
|
|
"~/.ssh is there on the host but cannot be entered",
|
|
)
|
|
|
|
// ErrSymlink is the refusal of a host whose authorized_keys is a
|
|
// symlink: the rename that puts the new file in place would replace the
|
|
// link itself, and the file it points at would never get the key.
|
|
var ErrSymlink = errors.New(
|
|
"~/.ssh/authorized_keys on the host is a symlink, which the tool " +
|
|
"leaves alone",
|
|
)
|
|
|
|
// ErrStrayArgument is the refusal of anything but the host before --,
|
|
// which would otherwise be handed to sftp in front of the host.
|
|
var ErrStrayArgument = errors.New(
|
|
"only the host goes before --; options for sftp go after --",
|
|
)
|
|
|
|
// 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.MatchAll(
|
|
cobra.MinimumNArgs(1),
|
|
func(cmd *cobra.Command, args []string) error {
|
|
// ArgsLenAtDash is -1 when there is no --.
|
|
before := cmd.ArgsLenAtDash()
|
|
if before == -1 {
|
|
before = len(args)
|
|
}
|
|
|
|
if before != 1 {
|
|
return ErrStrayArgument
|
|
}
|
|
|
|
return nil
|
|
},
|
|
),
|
|
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
|
|
}
|
|
|
|
// From here on a signal cancels the context, which
|
|
// sftp runs under, instead of ending the tool, so sftp
|
|
// ends and the working directory is still removed.
|
|
ctx, stop := signals.Context(cmd.Context())
|
|
defer stop()
|
|
|
|
cmd.SetContext(ctx)
|
|
|
|
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
|
|
|
|
// A cancelled context means a signal arrived. Send sftp a SIGTERM
|
|
// rather than the default kill, so it stops the ssh it started
|
|
// before it goes. Anything sftp started that still holds its output
|
|
// keeps the tool waiting no longer than waitDelay.
|
|
command.Cancel = func() error {
|
|
return command.Process.Signal(syscall.SIGTERM)
|
|
}
|
|
command.WaitDelay = waitDelay
|
|
|
|
err := command.Run()
|
|
|
|
// sftp ended well and only something it started, such as the
|
|
// master ssh leaves running for ControlPersist under -v, still held
|
|
// its output: the session worked.
|
|
if errors.Is(err, exec.ErrWaitDelay) {
|
|
err = nil
|
|
}
|
|
|
|
_, _ = 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, then .ssh/., and then gets the
|
|
// file, so the listings settle 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 both listings succeeded and the get then reported
|
|
// the file as not there. Anything else — a 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 listings do: a
|
|
// directory that is there but cannot be read fails the first, and one
|
|
// that can be read but not entered fails the second, because nothing in
|
|
// it can be looked up, not even ".". The first listing of such a
|
|
// directory comes up empty, as the server leaves out every name it
|
|
// cannot look up.
|
|
//
|
|
// The first listing is a long one, which shows an authorized_keys that
|
|
// is a symlink as one. That is refused before anything else sftp said
|
|
// is read, so a link the get could not follow is refused in the same
|
|
// words.
|
|
func fetch(
|
|
cmd *cobra.Command, host string, options []string, into string,
|
|
) (string, bool, error) {
|
|
said, err := session(cmd, host, options, []string{
|
|
"ls -n " + directory,
|
|
"ls -1 " + directory + "/.",
|
|
"get " + authorized + " " + quoted(into),
|
|
})
|
|
if symlinked(said) {
|
|
return "", false, ErrSymlink
|
|
}
|
|
|
|
if err != nil {
|
|
if listingNotFound(said, directory) {
|
|
return "", false, nil
|
|
}
|
|
|
|
if listingNotFound(said, directory+"/.") {
|
|
return "", false, ErrCannotEnter
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// listingNotFound says whether sftp reported the path it was asked to
|
|
// list as not being there. For .ssh that is the one listing failure
|
|
// read as a host that has no authorized_keys yet; for .ssh/., once .ssh
|
|
// itself has been listed, it is a .ssh that is there but cannot be
|
|
// entered. The reading is taken only from the line in which sftp
|
|
// reports on that path: any other failure of a listing, in particular a
|
|
// directory that is there but cannot be read, is left as a failure, so
|
|
// that no key is written to a host whose keys were never read.
|
|
func listingNotFound(said, path string) bool {
|
|
for line := range strings.Lines(said) {
|
|
named, is := reportedCannotList(strings.TrimSpace(line))
|
|
if is && (named == path || strings.HasSuffix(named, "/"+path)) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// reportedCannotList returns the path an sftp line reports it cannot
|
|
// list for want of it, and whether the line is such a report. The
|
|
// client writes this one wording when it cannot look up the path a
|
|
// listing names, 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
|
|
}
|
|
|
|
// symlinked says whether the long listing of .ssh shows authorized_keys
|
|
// as a symlink. With -n the client writes each line itself, as ls -l
|
|
// does, whatever the server: the type comes first, "l" for a symlink,
|
|
// and the path as the listing named it comes last.
|
|
func symlinked(said string) bool {
|
|
for line := range strings.Lines(said) {
|
|
fields := strings.Fields(line)
|
|
if len(fields) > 0 && strings.HasPrefix(fields[0], "l") &&
|
|
fields[len(fields)-1] == authorized {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// 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 + `"`
|
|
}
|