The ssh install command works over sftp (closes #10)
All checks were successful
check / check (push) Successful in 20s

The command no longer sends a shell script to the host. It fetches
~/.ssh/authorized_keys with the system sftp in batch mode, adds the key
line here, and writes the file back in a second session: mkdir and chmod
on ~/.ssh, put to authorized_keys.keyfunc-<random>, chmod 600, then
rename over authorized_keys. A run that adds a line connects twice; one
that finds the line there connects once and stops. A failed step leaves
everything as it is and names the uploaded file.

sftp echoes the commands it runs, so all of its output goes to standard
error and the tool prints only "added" or "already present". Batch mode
cannot prompt for a password; the README says so.

Model: opus-5
This commit is contained in:
2026-09-08 04:14:46 +00:00
parent a2a0890ded
commit cac775c100
3 changed files with 325 additions and 108 deletions

View File

@@ -3,6 +3,7 @@ package cli_test
import (
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"testing"
@@ -14,7 +15,7 @@ import (
)
// The modes the host is supposed to end up with, and the mode the
// stand-in ssh needs so that it can be run at all.
// stand-ins need so that they can be run at all.
const (
directoryMode = 0o700
fileMode = 0o600
@@ -32,20 +33,45 @@ const (
keptIn = "authorized_keys"
)
// installer is a stand-in for the system ssh for the install command.
// It writes down what it was given and then runs the command meant for
// the host right here, with the home directory pointed at a directory
// standing in for the host's, so that what keyfunc sends can be
// watched doing its work.
// The key line the example mnemonic gives at index 0, as it stands in
// an authorized_keys file.
const keyLine = vectorZero + " keyfunc/ssh/0\n"
// installer is a stand-in for the system sftp for the install
// command. It writes down the arguments and every command of the
// batch it is given, and carries the commands out against a directory
// standing in for the host's home directory, so that what keyfunc
// sends can be watched doing its work. A command that begins with a
// dash may fail; any other failure ends the session, as it does in
// sftp's own batch mode.
const installer = `
while [ $# -gt 1 ]; do
printf '%s\n' "$1" >> "$KEYFUNC_TEST_ARGUMENTS"
shift
for argument in "$@"; do
printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS"
done
home="$KEYFUNC_TEST_HOME"
while IFS= read -r line; do
printf '%s\n' "$line" >> "$KEYFUNC_TEST_BATCH"
allowed=no
case "$line" in
-*)
line=${line#-}
allowed=yes
;;
esac
eval "set -- $line"
worked=yes
case "$1" in
get) cp "$home/$2" "$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 ;;
chmod) chmod "$2" "$home/$3" 2>/dev/null || worked=no ;;
rename) mv "$home/$2" "$home/$3" 2>/dev/null || worked=no ;;
esac
if [ "$worked" = no ] && [ "$allowed" = no ]; then
printf 'sftp: %s failed\n' "$1" >&2
exit 1
fi
done
printf '%s' "$1" > "$KEYFUNC_TEST_COMMAND"
HOME="$KEYFUNC_TEST_HOME"
export HOME
eval "$1"
`
// caller is a stand-in for the system ssh for the to command. It
@@ -63,19 +89,17 @@ fi
exit "$KEYFUNC_TEST_STATUS"
`
// pretended is where a stand-in ssh writes down what it was asked to
// do.
// pretended is where a stand-in writes down what it was asked to do.
type pretended struct {
// home stands in for the home directory on the host.
home string
// arguments holds what ssh was given before the command, one per
// line.
// arguments holds the arguments of every session, one per line.
arguments string
// command holds what ssh was told to run on the host.
command string
// batch holds the commands of every session, one per line.
batch string
}
func TestTheKeyIsAddedToTheHostAndThenLeftAlone(t *testing.T) {
func TestTheKeyIsAddedToAHostThatHasNoFileYet(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
pretend := pretendHost(t)
@@ -94,11 +118,32 @@ func TestTheKeyIsAddedToTheHostAndThenLeftAlone(t *testing.T) {
require.NoError(t, err)
require.Equal(t, os.FileMode(fileMode), file.Mode().Perm())
added := read(t, path)
require.Equal(t, vectorZero+" keyfunc/ssh/0\n", added)
require.Equal(t, keyLine, read(t, path))
}
require.Equal(t, "already present\n", run(t, "ssh", "install", host))
require.Equal(t, added, read(t, path))
func TestAKeyThatIsAlreadyThereIsLeftAlone(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
pretend := pretendHost(t)
path := seed(t, pretend, "somebody else\n"+keyLine)
require.Equal(t,
"already present\n", run(t, "ssh", "install", host),
)
require.Equal(t, "somebody else\n"+keyLine, read(t, path))
// The fetch and nothing after it: the tool did not connect again.
require.Len(t, recorded(t, pretend.batch), 1)
}
func TestAnEmptyFileGetsTheKeyAndNoBlankLineBeforeIt(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
pretend := pretendHost(t)
path := seed(t, pretend, "")
require.Equal(t, "added\n", run(t, "ssh", "install", host))
require.Equal(t, keyLine, read(t, path))
}
func TestTheKeyDoesNotRunIntoALineWithNoNewlineAtItsEnd(t *testing.T) {
@@ -106,22 +151,38 @@ func TestTheKeyDoesNotRunIntoALineWithNoNewlineAtItsEnd(t *testing.T) {
pretend := pretendHost(t)
already := "ssh-ed25519 AAAAsomebodyelse somebody@else"
require.NoError(t,
os.Mkdir(filepath.Join(pretend.home, keptUnder), directoryMode),
)
path := filepath.Join(pretend.home, keptUnder, keptIn)
require.NoError(t, os.WriteFile(path, []byte(already), fileMode))
path := seed(t, pretend, already)
require.Equal(t, "added\n", run(t, "ssh", "install", host))
require.Equal(t,
already+"\n"+vectorZero+" keyfunc/ssh/0\n",
read(t, path),
)
require.Equal(t, already+"\n"+keyLine, read(t, path))
}
func TestTheKeyLineIsNotOnTheCommandLine(t *testing.T) {
func TestTheFileIsUploadedBesideTheOldOneAndThenRenamedOverIt(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
pretend := pretendHost(t)
require.Equal(t, "added\n", run(t, "ssh", "install", host))
sent := recorded(t, pretend.batch)
require.Len(t, sent, 6)
// The name of the uploaded file is random, so it is read off the
// put and then looked for in the two commands that follow.
beside := strings.Fields(sent[3])[2]
require.True(t,
strings.HasPrefix(beside, ".ssh/authorized_keys.keyfunc-"),
)
require.True(t, strings.HasPrefix(sent[0], "-get .ssh/authorized_keys "))
require.Equal(t, "-mkdir .ssh", sent[1])
require.Equal(t, "chmod 700 .ssh", sent[2])
require.Equal(t, "put", strings.Fields(sent[3])[0])
require.Equal(t, "chmod 600 "+beside, sent[4])
require.Equal(t, "rename "+beside+" .ssh/authorized_keys", sent[5])
}
func TestTheKeyLineIsNotSentAsACommand(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
pretend := pretendHost(t)
@@ -129,18 +190,21 @@ func TestTheKeyLineIsNotOnTheCommandLine(t *testing.T) {
run(t, "ssh", "install", host)
require.NotContains(t, read(t, pretend.arguments), "ssh-ed25519")
require.NotContains(t, read(t, pretend.command), "ssh-ed25519")
require.NotContains(t, read(t, pretend.batch), "ssh-ed25519")
}
func TestWhatComesAfterTheDashesIsGivenToSSH(t *testing.T) {
func TestWhatComesAfterTheDashesIsGivenToSFTP(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
pretend := pretendHost(t)
run(t, "ssh", "install", host, "--", "-p", "2222")
run(t, "ssh", "install", host, "--", "-P", "2222")
// The same arguments twice over: adding a line takes two
// connections, one to fetch the file and one to write it back.
session := []string{"-b", "-", "-P", "2222", host}
require.Equal(t,
[]string{"-p", "2222", host},
slices.Concat(session, session),
recorded(t, pretend.arguments),
)
}
@@ -190,17 +254,31 @@ func pretendHost(t *testing.T) pretended {
pretend := pretended{
home: t.TempDir(),
arguments: filepath.Join(t.TempDir(), "arguments"),
command: filepath.Join(t.TempDir(), "command"),
batch: filepath.Join(t.TempDir(), "batch"),
}
t.Setenv("KEYFUNC_TEST_HOME", pretend.home)
t.Setenv("KEYFUNC_TEST_ARGUMENTS", pretend.arguments)
t.Setenv("KEYFUNC_TEST_COMMAND", pretend.command)
standIn(t, installer)
t.Setenv("KEYFUNC_TEST_BATCH", pretend.batch)
standIn(t, "sftp", installer)
return pretend
}
// seed puts an authorized_keys file on the stand-in host before the
// tool runs and gives back its path.
func seed(t *testing.T, pretend pretended, content string) string {
t.Helper()
directory := filepath.Join(pretend.home, keptUnder)
require.NoError(t, os.Mkdir(directory, directoryMode))
path := filepath.Join(directory, keptIn)
require.NoError(t, os.WriteFile(path, []byte(content), fileMode))
return path
}
// 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
// noted in.
@@ -213,20 +291,21 @@ func pretendCall(t *testing.T) (string, string) {
t.Setenv("KEYFUNC_TEST_ARGUMENTS", arguments)
t.Setenv("KEYFUNC_TEST_SOCKET", noted)
t.Setenv("KEYFUNC_TEST_STATUS", strconv.Itoa(failingStatus))
standIn(t, caller)
standIn(t, "ssh", caller)
return arguments, noted
}
// standIn writes a stand-in for the system ssh and puts it first on
// the path, so that the tool finds it instead of the real one.
func standIn(t *testing.T, body string) {
// standIn writes a stand-in for one of the system programs and puts it
// first on the path, so that the tool finds it instead of the real
// one.
func standIn(t *testing.T, name, body string) {
t.Helper()
directory := t.TempDir()
err := os.WriteFile(
filepath.Join(directory, "ssh"),
filepath.Join(directory, name),
[]byte("#!/bin/sh\n"+body), standInMode,
)
require.NoError(t, err)
@@ -247,7 +326,7 @@ func read(t *testing.T, path string) string {
return string(content)
}
// recorded returns the arguments a stand-in wrote down, one per line.
// recorded returns the lines a stand-in wrote down.
func recorded(t *testing.T, path string) []string {
t.Helper()