1 Commits
Author SHA1 Message Date
sneak 06e725831f README: policy sections and age and child mnemonic vectors (closes #21)
check / check (push) Successful in 41s
Add the sections REPO_POLICIES.md requires that the README lacked: a
first line naming category, license and author; Getting Started;
Entrypoints (one line per script/ file); Rationale; Design (the
internal/ packages, with the existing "Adding a key type" note moved
under it); TODO (the open issues between the tree and 1.0, as links);
License; and Author. Add the age recipients and identity vectors for
the eleven-abandon example mnemonic, copied from the agekey test, and
the BIP-85 specification's own 12-word child mnemonic vector, copied
from the childmnemonic test.

Documentation only; no code changes.

Model: opus-4-8
2026-09-21 14:00:37 +00:00
5 changed files with 8 additions and 155 deletions
-1
View File
@@ -16,7 +16,6 @@ linters:
- depguard # Dependency allow/block lists - depguard # Dependency allow/block lists
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
settings: settings:
+3 -3
View File
@@ -204,9 +204,7 @@ Derives the key, serves it from an SSH agent that runs inside the tool on a unix
socket in a new private `0700` temporary directory, then runs the system `ssh` socket in a new private `0700` temporary directory, then runs the system `ssh`
with `-o IdentityAgent=<that socket>` followed by the host and all remaining with `-o IdentityAgent=<that socket>` followed by the host and all remaining
arguments unchanged. The tool exits with `ssh`'s exit status and removes the arguments unchanged. The tool exits with `ssh`'s exit status and removes the
socket and directory on the way out. The private key is never written to disk. A socket and directory on the way out. The private key is never written to disk.
SIGINT, SIGTERM or SIGHUP ends `ssh` and still removes the socket and directory,
and the tool then exits with status 1 unless `ssh` reported one of its own.
## age identities: `keyfunc age` ## age identities: `keyfunc age`
@@ -308,6 +306,8 @@ The open issues that stand between the tree and a 1.0 release:
- [#14 Choose a license and add LICENSE](https://git.eeqj.de/sneak/keyfunc/issues/14) - [#14 Choose a license and add LICENSE](https://git.eeqj.de/sneak/keyfunc/issues/14)
- [#15 Decide the Go module path before 1.0](https://git.eeqj.de/sneak/keyfunc/issues/15) - [#15 Decide the Go module path before 1.0](https://git.eeqj.de/sneak/keyfunc/issues/15)
- [#17 Clean up the agent socket and working files when a signal ends the tool](https://git.eeqj.de/sneak/keyfunc/issues/17)
- [#22 1.0 release readiness](https://git.eeqj.de/sneak/keyfunc/issues/22)
## License ## License
+1 -15
View File
@@ -2,13 +2,10 @@
package cli package cli
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/signal"
"runtime/debug" "runtime/debug"
"syscall"
"git.eeqj.de/sneak/keyfunc/internal/cli/age" "git.eeqj.de/sneak/keyfunc/internal/cli/age"
"git.eeqj.de/sneak/keyfunc/internal/cli/mnemonic" "git.eeqj.de/sneak/keyfunc/internal/cli/mnemonic"
@@ -69,19 +66,8 @@ func Root() *cobra.Command {
// status of its own, which "ssh to" uses to hand on the status ssh // status of its own, which "ssh to" uses to hand on the status ssh
// ended with. ssh has already said whatever it had to say in that // ended with. ssh has already said whatever it had to say in that
// case, so nothing more is printed. // case, so nothing more is printed.
//
// SIGINT, SIGTERM and SIGHUP cancel the command's context instead of
// killing the process outright, so the child ssh or sftp ends and the
// deferred cleanup that removes the agent socket and the install
// working directory still runs.
func Main() int { func Main() int {
ctx, stop := signal.NotifyContext( err := Root().Execute()
context.Background(),
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
)
defer stop()
err := Root().ExecuteContext(ctx)
if err == nil { if err == nil {
return 0 return 0
} }
-8
View File
@@ -7,7 +7,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"slices" "slices"
"syscall"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -76,13 +75,6 @@ func connect(ctx context.Context, argv []string) error {
command.Stdout = os.Stdout command.Stdout = os.Stdout
command.Stderr = os.Stderr command.Stderr = os.Stderr
// A cancelled context means a signal ended the tool. Send ssh a
// SIGTERM rather than the default kill, so it puts the terminal
// back the way it found it before it goes.
command.Cancel = func() error {
return command.Process.Signal(syscall.SIGTERM)
}
err := command.Run() err := command.Run()
if err == nil { if err == nil {
return nil return nil
+4 -128
View File
@@ -3,14 +3,11 @@ package cli_test
import ( import (
"bytes" "bytes"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"syscall"
"testing" "testing"
"time"
"git.eeqj.de/sneak/keyfunc/internal/cli" "git.eeqj.de/sneak/keyfunc/internal/cli"
"git.eeqj.de/sneak/keyfunc/internal/cli/ssh" "git.eeqj.de/sneak/keyfunc/internal/cli/ssh"
@@ -18,23 +15,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// runAsTool, set in the environment of a re-executed test binary, tells
// TestMain to run the tool through Main rather than the suite, so the
// signal test can drive the real signal path in a process it can send a
// signal to.
const runAsTool = "KEYFUNC_TEST_RUN_AS_TOOL"
// TestMain re-executes the test binary as the tool when runAsTool is
// set, and otherwise runs the suite. The signal test starts the tool
// this way, as a subprocess it can signal and watch clean up.
func TestMain(m *testing.M) {
if os.Getenv(runAsTool) == "1" {
os.Exit(cli.Main())
}
os.Exit(m.Run())
}
// The modes the host is supposed to end up with, and the mode the // The modes the host is supposed to end up with, and the mode the
// stand-ins need so that they can be run at all. // stand-ins need so that they can be run at all.
const ( const (
@@ -67,9 +47,6 @@ const (
keptIn = "authorized_keys" keptIn = "authorized_keys"
) )
// remoteCommand is the command the "to" tests hand ssh after the host.
const remoteCommand = "uptime"
// The tool's own name, as it stands in the arguments a test hands to // 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 // Main, the ssh subcommand both commands the tests here drive live
// under, and the one of those two these tests name most. // under, and the one of those two these tests name most.
@@ -189,18 +166,6 @@ fi
exit "$KEYFUNC_TEST_STATUS" exit "$KEYFUNC_TEST_STATUS"
` `
// sleeper is a stand-in for the system ssh that notes the agent socket
// and then blocks, so a test can cancel the context while it is running
// and watch the tool take the agent down. The wait ends on its own only
// as a backstop, well after the test has cancelled and looked.
const sleeper = `
socket=${2#IdentityAgent=}
if [ -S "$socket" ]; then
printf '%s\n' "$socket" > "$KEYFUNC_TEST_SOCKET"
fi
sleep 5
`
// pretended is where a stand-in 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 { type pretended struct {
// home stands in for the home directory on the host. // home stands in for the home directory on the host.
@@ -456,7 +421,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) {
arguments, noted := pretendCall(t) arguments, noted := pretendCall(t)
_, err := execute(t, subcommand, "to", host, remoteCommand) _, err := execute(t, subcommand, "to", host, "uptime")
var passed ssh.StatusError var passed ssh.StatusError
@@ -465,7 +430,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) {
given := recorded(t, arguments) given := recorded(t, arguments)
require.Equal(t, "-o", given[0]) require.Equal(t, "-o", given[0])
require.Equal(t, []string{host, remoteCommand}, given[2:]) require.Equal(t, []string{host, "uptime"}, given[2:])
// The stand-in wrote the path down only because there really was // The stand-in wrote the path down only because there really was
// a socket there while it ran. // a socket there while it ran.
@@ -483,100 +448,11 @@ func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) {
t.Cleanup(func() { os.Args = given }) t.Cleanup(func() { os.Args = given })
os.Args = []string{tool, subcommand, "to", host, remoteCommand} os.Args = []string{tool, subcommand, "to", host, "uptime"}
require.Equal(t, failingStatus, cli.Main()) require.Equal(t, failingStatus, cli.Main())
} }
func TestASignalTakesTheAgentDirectoryDown(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
// The three signals the tool handles, checked one after another.
signals := []struct {
name string
signal os.Signal
}{
{"SIGTERM", syscall.SIGTERM},
{"SIGINT", syscall.SIGINT},
{"SIGHUP", syscall.SIGHUP},
}
for _, ending := range signals {
signalEndsTheTool(t, ending.name, ending.signal)
}
}
// signalEndsTheTool runs the tool as a subprocess against a stand-in
// ssh that blocks, waits until the agent is up and ssh is running
// against it, sends the tool the signal, and requires the agent socket
// and its directory to be gone once the tool has ended. The subprocess
// goes through Main and its signal handling, so with that handling
// removed the signal kills the tool outright, no deferred cleanup runs,
// the directory is left behind, and the check fails.
func signalEndsTheTool(t *testing.T, name string, signal os.Signal) {
t.Helper()
noted := filepath.Join(t.TempDir(), "socket")
t.Setenv("KEYFUNC_TEST_SOCKET", noted)
standIn(t, "ssh", sleeper)
//nolint:gosec // the binary is this test's own, re-run as the tool
command := exec.CommandContext(
t.Context(), os.Args[0], subcommand, "to", host, remoteCommand,
)
command.Env = append(os.Environ(), runAsTool+"=1")
require.NoError(t, command.Start())
// The stand-in notes the socket only once the agent is up and ssh
// is running against it, so this is where the signal lands.
socket := waitForSocket(t, noted)
require.NoError(t, command.Process.Signal(signal))
waitForTool(t, name, command)
// The signal ended the tool, and its deferred cleanup still ran:
// the agent socket and its directory are gone.
require.NoDirExists(t, filepath.Dir(socket), name)
}
// waitForTool waits for the subprocess to end, and fails the test if it
// does not end in time.
func waitForTool(t *testing.T, name string, command *exec.Cmd) {
t.Helper()
done := make(chan error, 1)
go func() { done <- command.Wait() }()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatalf("the tool did not end after %s", name)
}
}
// waitForSocket waits for the stand-in to write down the agent socket
// and gives back the path, which means the agent is up and ssh is
// running against it.
func waitForSocket(t *testing.T, noted string) string {
t.Helper()
var socket string
require.Eventually(t, func() bool {
content, err := os.ReadFile(noted) //nolint:gosec // test path
if err != nil {
return false
}
socket = strings.TrimSpace(string(content))
return socket != ""
}, 5*time.Second, 5*time.Millisecond)
return socket
}
func TestTheMnemonicIsNotHandedToSFTP(t *testing.T) { func TestTheMnemonicIsNotHandedToSFTP(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, "echo "+example()) t.Setenv(mnemonic.CommandVariable, "echo "+example())
t.Setenv(mnemonic.Variable, example()) t.Setenv(mnemonic.Variable, example())
@@ -598,7 +474,7 @@ func TestTheMnemonicIsNotHandedToSSH(t *testing.T) {
pretendCall(t) pretendCall(t)
environment := recordEnvironment(t) environment := recordEnvironment(t)
_, err := execute(t, subcommand, "to", host, remoteCommand) _, err := execute(t, subcommand, "to", host, "uptime")
var passed ssh.StatusError var passed ssh.StatusError