Compare commits
1 Commits
cac775c100
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
| 8aeed7b901 |
24
README.md
24
README.md
@@ -94,10 +94,20 @@ Adds the `pub` line to `~/.ssh/authorized_keys` on the host. No command is run
|
|||||||
on the host: the file is fetched, changed here, and written back with the
|
on the host: the file is fetched, changed here, and written back with the
|
||||||
system `sftp` client in batch mode.
|
system `sftp` client in batch mode.
|
||||||
|
|
||||||
The first connection fetches `~/.ssh/authorized_keys`; a host that has no such
|
The first connection fetches `~/.ssh/authorized_keys`. The file reads as empty
|
||||||
file yet reads as empty. If an identical line is already in the file, the tool
|
only when `sftp` reported that file as not being there — the one line naming
|
||||||
prints `already present` and connects no further. Otherwise the line is added
|
that path. The same wording anywhere else in the session does not count: `ssh`
|
||||||
(after a newline, if the file did not end with one) and a second connection:
|
writes `No such file or directory` about an `-i` it cannot find, on a session
|
||||||
|
that then authenticates through the agent. When `sftp` failed for any other
|
||||||
|
reason — the file is there and cannot be read, the connection did not come up —
|
||||||
|
the tool prints what `sftp` said and exits with status 1 without writing
|
||||||
|
anything, rather than put a file back holding the new key alone. What `sftp`
|
||||||
|
cannot tell apart is a missing file and one in a directory it cannot enter, so a
|
||||||
|
`~/.ssh` whose mode shuts the user out reads as a host with no file; the second
|
||||||
|
connection sets that mode to `0700` and writes, as on a host that has none. If
|
||||||
|
an identical line is already in the file, the tool prints
|
||||||
|
`already present` and connects no further. Otherwise the line is added (after a
|
||||||
|
newline, if the file did not end with one) and a second connection:
|
||||||
|
|
||||||
- creates `~/.ssh` and sets it to mode `0700`;
|
- creates `~/.ssh` and sets it to mode `0700`;
|
||||||
- uploads the new file as `~/.ssh/authorized_keys.keyfunc-<random>` and sets it
|
- uploads the new file as `~/.ssh/authorized_keys.keyfunc-<random>` and sets it
|
||||||
@@ -110,8 +120,10 @@ never half-written. `sftp` does it in one step against servers that offer
|
|||||||
OpenSSH's POSIX rename extension, as OpenSSH's own server does; a server
|
OpenSSH's POSIX rename extension, as OpenSSH's own server does; a server
|
||||||
without it may refuse to rename onto a file that is already there.
|
without it may refuse to rename onto a file that is already there.
|
||||||
|
|
||||||
If a step fails, the tool prints what `sftp` said, names the uploaded file if
|
If a step fails, the tool prints what `sftp` said, removes nothing, and exits
|
||||||
there was one, removes nothing, and exits with status 1. Everything `sftp`
|
with status 1. It names the uploaded file only when the step that failed was
|
||||||
|
the upload or one after it, which is where a file of that name can be on the
|
||||||
|
host; a failure before the upload names none. Everything `sftp`
|
||||||
writes goes to standard error, so the tool's own standard output is only
|
writes goes to standard error, so the tool's own standard output is only
|
||||||
`added` or `already present`.
|
`added` or `already present`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
package ssh
|
package ssh
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -77,18 +76,9 @@ func add(cmd *cobra.Command, host string, options []string, line string) error {
|
|||||||
|
|
||||||
defer func() { _ = os.RemoveAll(work) }()
|
defer func() { _ = os.RemoveAll(work) }()
|
||||||
|
|
||||||
fetched := filepath.Join(work, "authorized_keys")
|
content, err := fetch(cmd, host, options,
|
||||||
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -122,7 +112,7 @@ func upload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The mkdir may fail: the directory is usually there already.
|
// The mkdir may fail: the directory is usually there already.
|
||||||
err = session(cmd, host, options, []string{
|
said, err := session(cmd, host, options, []string{
|
||||||
"-mkdir " + directory,
|
"-mkdir " + directory,
|
||||||
"chmod " + directoryMode + " " + directory,
|
"chmod " + directoryMode + " " + directory,
|
||||||
"put " + quoted(local) + " " + sidecar,
|
"put " + quoted(local) + " " + sidecar,
|
||||||
@@ -130,7 +120,17 @@ func upload(
|
|||||||
"rename " + sidecar + " " + authorized,
|
"rename " + sidecar + " " + authorized,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w; %s may be left on the host", err, sidecar)
|
// 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")
|
return write(cmd, "added\n")
|
||||||
@@ -141,26 +141,32 @@ func upload(
|
|||||||
// stops at the first of which that fails, unless it begins with a
|
// 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
|
// 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
|
// says goes to the error output and the tool's own output stays the
|
||||||
// one word it prints.
|
// 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(
|
func session(
|
||||||
cmd *cobra.Command, host string, options []string, batch []string,
|
cmd *cobra.Command, host string, options []string, batch []string,
|
||||||
) error {
|
) (string, error) {
|
||||||
argv := slices.Concat(
|
argv := slices.Concat(
|
||||||
[]string{"-b", "-"}, options, []string{host},
|
[]string{"-b", "-"}, options, []string{host},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var said bytes.Buffer
|
||||||
|
|
||||||
//nolint:gosec // the options are the user's own, meant for sftp
|
//nolint:gosec // the options are the user's own, meant for sftp
|
||||||
command := exec.CommandContext(cmd.Context(), "sftp", argv...)
|
command := exec.CommandContext(cmd.Context(), "sftp", argv...)
|
||||||
command.Stdin = strings.NewReader(strings.Join(batch, "\n") + "\n")
|
command.Stdin = strings.NewReader(strings.Join(batch, "\n") + "\n")
|
||||||
command.Stdout = cmd.ErrOrStderr()
|
command.Stdout = &said
|
||||||
command.Stderr = cmd.ErrOrStderr()
|
command.Stderr = &said
|
||||||
|
|
||||||
err := command.Run()
|
err := command.Run()
|
||||||
|
|
||||||
|
_, _ = cmd.ErrOrStderr().Write(said.Bytes())
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("running sftp: %w", err)
|
return said.String(), fmt.Errorf("running sftp: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return said.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// merge returns the file with the key line on the end, and whether it
|
// merge returns the file with the key line on the end, and whether it
|
||||||
@@ -178,15 +184,27 @@ func merge(content, line string) (string, bool) {
|
|||||||
return content + line + "\n", true
|
return content + line + "\n", true
|
||||||
}
|
}
|
||||||
|
|
||||||
// arrived returns what is in the fetched file, and nothing at all when
|
// fetch brings the host's authorized_keys into the given path and
|
||||||
// no file arrived because the host has none.
|
// returns what is in it. A host that has no such file reads as empty,
|
||||||
func arrived(path string) (string, error) {
|
// but only when that is what sftp said about it: a file that is there
|
||||||
//nolint:gosec // the path is a temporary file of the tool's own
|
// and cannot be read fails the run, because writing back over it
|
||||||
content, err := os.ReadFile(path)
|
// would leave the host with the new key and nothing else.
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
func fetch(
|
||||||
return "", nil
|
cmd *cobra.Command, host string, options []string, into string,
|
||||||
|
) (string, error) {
|
||||||
|
said, err := session(cmd, host, options, []string{
|
||||||
|
"get " + authorized + " " + quoted(into),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if absent(said) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:gosec // the path is a temporary file of the tool's own
|
||||||
|
content, err := os.ReadFile(into)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("reading the fetched file: %w", err)
|
return "", fmt.Errorf("reading the fetched file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -194,6 +212,44 @@ func arrived(path string) (string, error) {
|
|||||||
return string(content), nil
|
return string(content), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// sidecarName returns the name the new file is uploaded under.
|
||||||
func sidecarName() (string, error) {
|
func sidecarName() (string, error) {
|
||||||
random := make([]byte, sidecarBytes)
|
random := make([]byte, sidecarBytes)
|
||||||
|
|||||||
78
internal/cli/ssh/install_test.go
Normal file
78
internal/cli/ssh/install_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
//nolint:testpackage // absent is what these wordings are read by
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// What a session says besides its report on the file that was asked
|
||||||
|
// for: sftp echoes the command it is running, and ssh warns about an
|
||||||
|
// identity file it cannot find in the words of a missing file even
|
||||||
|
// though the session goes on to authenticate.
|
||||||
|
const (
|
||||||
|
echoed = `sftp> get .ssh/authorized_keys "/tmp/keyfunc/authorized_keys"
|
||||||
|
`
|
||||||
|
warning = `Warning: Identity file /gone not accessible: ` +
|
||||||
|
"No such file or directory.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAbsenceIsReadOnlyFromWhatSFTPSaidAboutAuthorizedKeys holds the
|
||||||
|
// wordings the OpenSSH client was seen to use against a real server:
|
||||||
|
// a file it cannot find is reported one way, naming the path the
|
||||||
|
// server expanded, and everything else it says is a failure.
|
||||||
|
func TestAbsenceIsReadOnlyFromWhatSFTPSaidAboutAuthorizedKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sessions := map[string]struct {
|
||||||
|
said string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
"the file is not there": {
|
||||||
|
said: echoed +
|
||||||
|
`File "/home/someone/.ssh/authorized_keys" not found.` + "\n",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
"the file is not there, named as it was asked for": {
|
||||||
|
said: echoed + `File ".ssh/authorized_keys" not found.` + "\n",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
"the file is not there and an identity file is not either": {
|
||||||
|
said: warning + echoed +
|
||||||
|
`File "/home/someone/.ssh/authorized_keys" not found.` + "\n",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
"the file is there and cannot be read": {
|
||||||
|
said: echoed +
|
||||||
|
`remote open "/home/someone/.ssh/authorized_keys": ` +
|
||||||
|
"Permission denied\n",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
"only an identity file is not there": {
|
||||||
|
said: warning + echoed +
|
||||||
|
`remote open "/home/someone/.ssh/authorized_keys": ` +
|
||||||
|
"Permission denied\n",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
"some other file is not there": {
|
||||||
|
said: echoed + `File "/home/someone/.ssh/known_hosts" not found.` +
|
||||||
|
"\n",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
"the connection did not come up": {
|
||||||
|
said: "ssh: connect to host example.com port 22: " +
|
||||||
|
"Connection refused\nConnection closed\n",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, session := range sessions {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if absent(session.said) != session.want {
|
||||||
|
t.Errorf(
|
||||||
|
"read as absent: %t, wanted %t, from:\n%s",
|
||||||
|
!session.want, session.want, session.said,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package cli_test
|
package cli_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -23,8 +24,21 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// failingStatus is the status the stand-in ssh ends with when a test
|
// failingStatus is the status the stand-in ssh ends with when a test
|
||||||
// wants to see a status handed on.
|
// wants to see a status handed on, and failedStatus is the status the
|
||||||
const failingStatus = 7
|
// tool itself ends with when something went wrong.
|
||||||
|
const (
|
||||||
|
failingStatus = 7
|
||||||
|
failedStatus = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// notADirectory is what a test puts where the .ssh directory belongs
|
||||||
|
// to make a step of the write session fail.
|
||||||
|
const notADirectory = "a file where the directory belongs\n"
|
||||||
|
|
||||||
|
// missingIdentity is a path with no file at it, handed to sftp after
|
||||||
|
// the dashes so that ssh warns about it in the words of a missing
|
||||||
|
// file.
|
||||||
|
const missingIdentity = "/nonexistent/keyfunc-test-identity"
|
||||||
|
|
||||||
// The host, and where on it the key ends up.
|
// The host, and where on it the key ends up.
|
||||||
const (
|
const (
|
||||||
@@ -33,23 +47,47 @@ const (
|
|||||||
keptIn = "authorized_keys"
|
keptIn = "authorized_keys"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The tool's own name, as it stands in the arguments a test hands to
|
||||||
|
// Main, the ssh subcommand both commands the tests here drive live
|
||||||
|
// under, and the one of those two these tests name most.
|
||||||
|
const (
|
||||||
|
tool = "keyfunc"
|
||||||
|
subcommand = "ssh"
|
||||||
|
installing = "install"
|
||||||
|
)
|
||||||
|
|
||||||
// The key line the example mnemonic gives at index 0, as it stands in
|
// The key line the example mnemonic gives at index 0, as it stands in
|
||||||
// an authorized_keys file.
|
// an authorized_keys file.
|
||||||
const keyLine = vectorZero + " keyfunc/ssh/0\n"
|
const keyLine = vectorZero + " keyfunc/ssh/0\n"
|
||||||
|
|
||||||
// installer is a stand-in for the system sftp for the install
|
// installer is a stand-in for the system sftp for the install
|
||||||
// command. It writes down the arguments and every command of the
|
// command. It writes down the arguments and every command of the
|
||||||
// batch it is given, and carries the commands out against a directory
|
// batch it is given, echoes each command as sftp does, and carries
|
||||||
// standing in for the host's home directory, so that what keyfunc
|
// the commands out against a directory standing in for the host's
|
||||||
// sends can be watched doing its work. A command that begins with a
|
// home directory, so that what keyfunc sends can be watched doing its
|
||||||
// dash may fail; any other failure ends the session, as it does in
|
// work. A command that begins with a dash may fail; any other failure
|
||||||
// sftp's own batch mode.
|
// ends the session, as it does in sftp's own batch mode.
|
||||||
|
//
|
||||||
|
// The two ways a get can fail are worded as the OpenSSH client words
|
||||||
|
// them, both naming the path the server expanded: a file that is not
|
||||||
|
// there, which is the one failure the tool reads as an empty file, and
|
||||||
|
// a file that is there and cannot be read, which is not. An -i naming
|
||||||
|
// a file that is not here draws the warning ssh writes for it, which
|
||||||
|
// carries the wording of a missing file into a session that goes on to
|
||||||
|
// authenticate.
|
||||||
const installer = `
|
const installer = `
|
||||||
|
previous=
|
||||||
for argument in "$@"; do
|
for argument in "$@"; do
|
||||||
printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS"
|
printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS"
|
||||||
|
if [ "$previous" = -i ] && [ ! -e "$argument" ]; then
|
||||||
|
printf 'Warning: Identity file %s not accessible: %s.\n' \
|
||||||
|
"$argument" "No such file or directory" >&2
|
||||||
|
fi
|
||||||
|
previous=$argument
|
||||||
done
|
done
|
||||||
home="$KEYFUNC_TEST_HOME"
|
home="$KEYFUNC_TEST_HOME"
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
|
printf 'sftp> %s\n' "$line"
|
||||||
printf '%s\n' "$line" >> "$KEYFUNC_TEST_BATCH"
|
printf '%s\n' "$line" >> "$KEYFUNC_TEST_BATCH"
|
||||||
allowed=no
|
allowed=no
|
||||||
case "$line" in
|
case "$line" in
|
||||||
@@ -61,7 +99,15 @@ while IFS= read -r line; do
|
|||||||
eval "set -- $line"
|
eval "set -- $line"
|
||||||
worked=yes
|
worked=yes
|
||||||
case "$1" in
|
case "$1" in
|
||||||
get) cp "$home/$2" "$3" 2>/dev/null || worked=no ;;
|
get)
|
||||||
|
if [ ! -e "$home/$2" ]; then
|
||||||
|
worked=no
|
||||||
|
printf 'File "%s" not found.\n' "$home/$2" >&2
|
||||||
|
elif ! cp "$home/$2" "$3" 2>/dev/null; then
|
||||||
|
worked=no
|
||||||
|
printf 'remote open "%s": Permission denied\n' "$home/$2" >&2
|
||||||
|
fi
|
||||||
|
;;
|
||||||
put) cp "$2" "$home/$3" 2>/dev/null || worked=no ;;
|
put) cp "$2" "$home/$3" 2>/dev/null || worked=no ;;
|
||||||
mkdir) mkdir "$home/$2" 2>/dev/null || worked=no ;;
|
mkdir) mkdir "$home/$2" 2>/dev/null || worked=no ;;
|
||||||
chmod) chmod "$2" "$home/$3" 2>/dev/null || worked=no ;;
|
chmod) chmod "$2" "$home/$3" 2>/dev/null || worked=no ;;
|
||||||
@@ -104,7 +150,7 @@ func TestTheKeyIsAddedToAHostThatHasNoFileYet(t *testing.T) {
|
|||||||
|
|
||||||
pretend := pretendHost(t)
|
pretend := pretendHost(t)
|
||||||
|
|
||||||
require.Equal(t, "added\n", run(t, "ssh", "install", host))
|
require.Equal(t, "added\n", install(t, host))
|
||||||
|
|
||||||
directory, err := os.Stat(filepath.Join(pretend.home, keptUnder))
|
directory, err := os.Stat(filepath.Join(pretend.home, keptUnder))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -127,9 +173,7 @@ func TestAKeyThatIsAlreadyThereIsLeftAlone(t *testing.T) {
|
|||||||
pretend := pretendHost(t)
|
pretend := pretendHost(t)
|
||||||
path := seed(t, pretend, "somebody else\n"+keyLine)
|
path := seed(t, pretend, "somebody else\n"+keyLine)
|
||||||
|
|
||||||
require.Equal(t,
|
require.Equal(t, "already present\n", install(t, host))
|
||||||
"already present\n", run(t, "ssh", "install", host),
|
|
||||||
)
|
|
||||||
require.Equal(t, "somebody else\n"+keyLine, read(t, path))
|
require.Equal(t, "somebody else\n"+keyLine, read(t, path))
|
||||||
|
|
||||||
// The fetch and nothing after it: the tool did not connect again.
|
// The fetch and nothing after it: the tool did not connect again.
|
||||||
@@ -142,7 +186,7 @@ func TestAnEmptyFileGetsTheKeyAndNoBlankLineBeforeIt(t *testing.T) {
|
|||||||
pretend := pretendHost(t)
|
pretend := pretendHost(t)
|
||||||
path := seed(t, pretend, "")
|
path := seed(t, pretend, "")
|
||||||
|
|
||||||
require.Equal(t, "added\n", run(t, "ssh", "install", host))
|
require.Equal(t, "added\n", install(t, host))
|
||||||
require.Equal(t, keyLine, read(t, path))
|
require.Equal(t, keyLine, read(t, path))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +197,7 @@ func TestTheKeyDoesNotRunIntoALineWithNoNewlineAtItsEnd(t *testing.T) {
|
|||||||
already := "ssh-ed25519 AAAAsomebodyelse somebody@else"
|
already := "ssh-ed25519 AAAAsomebodyelse somebody@else"
|
||||||
path := seed(t, pretend, already)
|
path := seed(t, pretend, already)
|
||||||
|
|
||||||
require.Equal(t, "added\n", run(t, "ssh", "install", host))
|
require.Equal(t, "added\n", install(t, host))
|
||||||
require.Equal(t, already+"\n"+keyLine, read(t, path))
|
require.Equal(t, already+"\n"+keyLine, read(t, path))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +206,7 @@ func TestTheFileIsUploadedBesideTheOldOneAndThenRenamedOverIt(t *testing.T) {
|
|||||||
|
|
||||||
pretend := pretendHost(t)
|
pretend := pretendHost(t)
|
||||||
|
|
||||||
require.Equal(t, "added\n", run(t, "ssh", "install", host))
|
require.Equal(t, "added\n", install(t, host))
|
||||||
|
|
||||||
sent := recorded(t, pretend.batch)
|
sent := recorded(t, pretend.batch)
|
||||||
require.Len(t, sent, 6)
|
require.Len(t, sent, 6)
|
||||||
@@ -174,7 +218,7 @@ func TestTheFileIsUploadedBesideTheOldOneAndThenRenamedOverIt(t *testing.T) {
|
|||||||
strings.HasPrefix(beside, ".ssh/authorized_keys.keyfunc-"),
|
strings.HasPrefix(beside, ".ssh/authorized_keys.keyfunc-"),
|
||||||
)
|
)
|
||||||
|
|
||||||
require.True(t, strings.HasPrefix(sent[0], "-get .ssh/authorized_keys "))
|
require.True(t, strings.HasPrefix(sent[0], "get .ssh/authorized_keys "))
|
||||||
require.Equal(t, "-mkdir .ssh", sent[1])
|
require.Equal(t, "-mkdir .ssh", sent[1])
|
||||||
require.Equal(t, "chmod 700 .ssh", sent[2])
|
require.Equal(t, "chmod 700 .ssh", sent[2])
|
||||||
require.Equal(t, "put", strings.Fields(sent[3])[0])
|
require.Equal(t, "put", strings.Fields(sent[3])[0])
|
||||||
@@ -182,12 +226,97 @@ func TestTheFileIsUploadedBesideTheOldOneAndThenRenamedOverIt(t *testing.T) {
|
|||||||
require.Equal(t, "rename "+beside+" .ssh/authorized_keys", sent[5])
|
require.Equal(t, "rename "+beside+" .ssh/authorized_keys", sent[5])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAFileThatCannotBeReadIsNotWrittenOver(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
pretend := pretendHost(t)
|
||||||
|
unreadable := unfetchable(t, pretend)
|
||||||
|
|
||||||
|
printed, said, err := attempt(t, host)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, printed)
|
||||||
|
require.Contains(t, said, "Permission denied")
|
||||||
|
|
||||||
|
// The fetch and nothing after it, and what was on the host is
|
||||||
|
// still what is on the host.
|
||||||
|
require.Len(t, recorded(t, pretend.batch), 1)
|
||||||
|
require.DirExists(t, unreadable)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAWarningAboutAnotherFileIsNotTakenForTheOneAskedFor(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
pretend := pretendHost(t)
|
||||||
|
unreadable := unfetchable(t, pretend)
|
||||||
|
|
||||||
|
// ssh warns about an -i it cannot find in the words of a missing
|
||||||
|
// file, on a session that then authenticates perfectly well. That
|
||||||
|
// warning is not sftp reporting on authorized_keys, so the fetch
|
||||||
|
// failure is still a failure.
|
||||||
|
printed, said, err := attempt(t, host, "--", "-i", missingIdentity)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, printed)
|
||||||
|
require.Contains(t, said, "No such file or directory")
|
||||||
|
require.Contains(t, said, "Permission denied")
|
||||||
|
|
||||||
|
require.Len(t, recorded(t, pretend.batch), 1)
|
||||||
|
require.DirExists(t, unreadable)
|
||||||
|
|
||||||
|
// The same run again, this way for the status it ends with.
|
||||||
|
given := os.Args
|
||||||
|
|
||||||
|
t.Cleanup(func() { os.Args = given })
|
||||||
|
|
||||||
|
os.Args = []string{
|
||||||
|
tool, subcommand, installing, host, "--", "-i", missingIdentity,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Equal(t, failedStatus, cli.Main())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFailedStepNamesTheUploadedFileAndChangesNothing(t *testing.T) {
|
||||||
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
|
pretend := pretendHost(t)
|
||||||
|
|
||||||
|
// A file where the .ssh directory belongs: nothing is there to
|
||||||
|
// fetch, and then the put has nowhere to put anything, so the
|
||||||
|
// write session ends at the put.
|
||||||
|
inTheWay := filepath.Join(pretend.home, keptUnder)
|
||||||
|
require.NoError(t,
|
||||||
|
os.WriteFile(inTheWay, []byte(notADirectory), fileMode),
|
||||||
|
)
|
||||||
|
|
||||||
|
printed, said, err := attempt(t, host)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Empty(t, printed)
|
||||||
|
require.Contains(t, said, "put failed")
|
||||||
|
|
||||||
|
// The put is the last command the session got to, and the file it
|
||||||
|
// was uploading is the one the message names.
|
||||||
|
sent := recorded(t, pretend.batch)
|
||||||
|
require.Len(t, sent, 4)
|
||||||
|
require.Equal(t, "put", strings.Fields(sent[3])[0])
|
||||||
|
require.Contains(t, err.Error(), strings.Fields(sent[3])[2])
|
||||||
|
|
||||||
|
require.Equal(t, notADirectory, read(t, inTheWay))
|
||||||
|
|
||||||
|
// The same run again, this way for the status it ends with.
|
||||||
|
given := os.Args
|
||||||
|
|
||||||
|
t.Cleanup(func() { os.Args = given })
|
||||||
|
|
||||||
|
os.Args = []string{tool, subcommand, installing, host}
|
||||||
|
|
||||||
|
require.Equal(t, failedStatus, cli.Main())
|
||||||
|
}
|
||||||
|
|
||||||
func TestTheKeyLineIsNotSentAsACommand(t *testing.T) {
|
func TestTheKeyLineIsNotSentAsACommand(t *testing.T) {
|
||||||
t.Setenv(mnemonic.Variable, example())
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
pretend := pretendHost(t)
|
pretend := pretendHost(t)
|
||||||
|
|
||||||
run(t, "ssh", "install", host)
|
install(t, host)
|
||||||
|
|
||||||
require.NotContains(t, read(t, pretend.arguments), "ssh-ed25519")
|
require.NotContains(t, read(t, pretend.arguments), "ssh-ed25519")
|
||||||
require.NotContains(t, read(t, pretend.batch), "ssh-ed25519")
|
require.NotContains(t, read(t, pretend.batch), "ssh-ed25519")
|
||||||
@@ -198,7 +327,7 @@ func TestWhatComesAfterTheDashesIsGivenToSFTP(t *testing.T) {
|
|||||||
|
|
||||||
pretend := pretendHost(t)
|
pretend := pretendHost(t)
|
||||||
|
|
||||||
run(t, "ssh", "install", host, "--", "-P", "2222")
|
install(t, host, "--", "-P", "2222")
|
||||||
|
|
||||||
// The same arguments twice over: adding a line takes two
|
// The same arguments twice over: adding a line takes two
|
||||||
// connections, one to fetch the file and one to write it back.
|
// connections, one to fetch the file and one to write it back.
|
||||||
@@ -214,7 +343,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) {
|
|||||||
|
|
||||||
arguments, noted := pretendCall(t)
|
arguments, noted := pretendCall(t)
|
||||||
|
|
||||||
_, err := execute(t, "ssh", "to", host, "uptime")
|
_, err := execute(t, subcommand, "to", host, "uptime")
|
||||||
|
|
||||||
var passed ssh.StatusError
|
var passed ssh.StatusError
|
||||||
|
|
||||||
@@ -241,7 +370,7 @@ func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) {
|
|||||||
|
|
||||||
t.Cleanup(func() { os.Args = given })
|
t.Cleanup(func() { os.Args = given })
|
||||||
|
|
||||||
os.Args = []string{"keyfunc", "ssh", "to", host, "uptime"}
|
os.Args = []string{tool, subcommand, "to", host, "uptime"}
|
||||||
|
|
||||||
require.Equal(t, failingStatus, cli.Main())
|
require.Equal(t, failingStatus, cli.Main())
|
||||||
}
|
}
|
||||||
@@ -265,6 +394,36 @@ func pretendHost(t *testing.T) pretended {
|
|||||||
return pretend
|
return pretend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// install runs the install command, requires it to have worked, and
|
||||||
|
// gives back what the tool itself printed.
|
||||||
|
func install(t *testing.T, args ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
printed, _, err := attempt(t, args...)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return printed
|
||||||
|
}
|
||||||
|
|
||||||
|
// attempt runs the install command with the tool's own output kept
|
||||||
|
// apart from what the stand-in said, since the stand-in echoes its
|
||||||
|
// batch as sftp does. It gives back what the tool printed, what the
|
||||||
|
// stand-in said, and how the run ended.
|
||||||
|
func attempt(t *testing.T, args ...string) (string, string, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var printed, said bytes.Buffer
|
||||||
|
|
||||||
|
root := cli.Root()
|
||||||
|
root.SetOut(&printed)
|
||||||
|
root.SetErr(&said)
|
||||||
|
root.SetArgs(slices.Concat([]string{subcommand, installing}, args))
|
||||||
|
|
||||||
|
err := root.ExecuteContext(t.Context())
|
||||||
|
|
||||||
|
return printed.String(), said.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
// seed puts an authorized_keys file on the stand-in host before the
|
// seed puts an authorized_keys file on the stand-in host before the
|
||||||
// tool runs and gives back its path.
|
// tool runs and gives back its path.
|
||||||
func seed(t *testing.T, pretend pretended, content string) string {
|
func seed(t *testing.T, pretend pretended, content string) string {
|
||||||
@@ -279,6 +438,23 @@ func seed(t *testing.T, pretend pretended, content string) string {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unfetchable puts a directory where authorized_keys belongs on the
|
||||||
|
// stand-in host, which the stand-in can see but cannot fetch: that is
|
||||||
|
// how a file that is there and cannot be read looks from here. It
|
||||||
|
// gives back the path.
|
||||||
|
func unfetchable(t *testing.T, pretend pretended) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
os.Mkdir(filepath.Join(pretend.home, keptUnder), directoryMode),
|
||||||
|
)
|
||||||
|
|
||||||
|
path := filepath.Join(pretend.home, keptUnder, keptIn)
|
||||||
|
require.NoError(t, os.Mkdir(path, directoryMode))
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
// pretendCall puts the to stand-in on the path and gives back the file
|
// pretendCall puts the to stand-in on the path and gives back the file
|
||||||
// the arguments are written down in and the file the agent socket is
|
// the arguments are written down in and the file the agent socket is
|
||||||
// noted in.
|
// noted in.
|
||||||
|
|||||||
Reference in New Issue
Block a user