check / check (push) Failing after 1s
SIGINT, SIGTERM and SIGHUP are no longer caught for the whole run, so they end any command at once, the mnemonic prompt included. One package, internal/cli/signals, catches them only where cleanup is needed, and only those not ignored at start, so nohup still works: ssh to and ssh install while their child runs, and age encrypt -o and age decrypt -o while they write. A signal received by the time the input ends leaves no new file and exits 1; otherwise the whole file is put in place, never an unfinished one. Judgement call: the guarantee is stated for a signal keyfunc has received, as Go cannot promise more; the main goroutine stays on the main thread so a Ctrl-C on a pipeline is seen first on Linux. Unverified on macOS. Model: opus-5-5 (implementation); fable-5-1 (design)
360 lines
11 KiB
Go
360 lines
11 KiB
Go
package ssh
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
|
|
"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
|
|
)
|
|
|
|
// 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",
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
|
|
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, 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.
|
|
func fetch(
|
|
cmd *cobra.Command, host string, options []string, into string,
|
|
) (string, bool, error) {
|
|
said, err := session(cmd, host, options, []string{
|
|
"ls -1 " + directory,
|
|
"ls -1 " + directory + "/.",
|
|
"get " + authorized + " " + quoted(into),
|
|
})
|
|
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
|
|
}
|
|
|
|
// 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 + `"`
|
|
}
|