Compare commits
1
Commits
next
..
7279329be9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7279329be9 |
@@ -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:
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
# keyfunc
|
# keyfunc
|
||||||
|
|
||||||
`keyfunc` is a Go command-line tool by [@sneak](https://sneak.berlin) — its
|
`keyfunc` is an MIT-licensed Go command-line tool by
|
||||||
license is not yet chosen
|
[@sneak](https://sneak.berlin) that turns a BIP-39 mnemonic into SSH keys, age
|
||||||
([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)) — that turns a BIP-39
|
identities and child mnemonics, each of which can be recreated from that
|
||||||
mnemonic into SSH keys, age identities and child mnemonics, each of which can be
|
mnemonic at any time. The same mnemonic, key type and index always give the same
|
||||||
recreated from that mnemonic at any time. The same mnemonic, key type and index
|
key.
|
||||||
always give the same key.
|
|
||||||
|
|
||||||
It uses the BIP-85 entropy deriver from `git.eeqj.de/sneak/secret/pkg/bip85` and
|
It uses the BIP-85 entropy deriver from `git.eeqj.de/sneak/secret/pkg/bip85` and
|
||||||
takes the same steps as that repository's `agehd` package.
|
takes the same steps as that repository's `agehd` package.
|
||||||
@@ -204,9 +203,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,12 +305,15 @@ 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
|
||||||
|
|
||||||
Not yet chosen. The license is the owner's decision, still open on the tracker
|
MIT. The license is not yet settled on the tracker
|
||||||
([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)); the `LICENSE` file is added
|
([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)); MIT is the recommended
|
||||||
when that issue is answered.
|
option there, so this README names it and the `LICENSE` file is added when that
|
||||||
|
issue is answered.
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
|
|||||||
+1
-15
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user