Files
keyfunc/internal/cli/ssh/to.go
T
clawbot 44cb5215b0
check / check (push) Waiting to run
Turn IdentitiesOnly off for ssh to (closes #74)
ssh to now gives ssh -o IdentitiesOnly=no before the host, next to
-o IdentityAgent. With IdentitiesOnly yes in the user's ssh
configuration, ssh listed the agent's key but never offered it, and
the login failed with Permission denied. An option on the command
line wins over the configuration, and the tool's agent holds only the
derived key, so turning the option off adds no other key. A test
against the stand-in ssh requires the option before the host, and the
README's ssh to section names both options.

Judgement call: a user's own -o IdentitiesOnly=yes after the host is
now overridden.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-14 21:12:58 +02:00

118 lines
3.1 KiB
Go

package ssh
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"slices"
"syscall"
"github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/cli/signals"
"sneak.berlin/go/keyfunc/internal/mnemonic"
)
// StatusError says the tool should end with the status ssh ended with.
// Only "ssh to" gives one back; every other error ends the tool with
// status 1.
type StatusError struct {
Status int
}
// Error says which status ssh ended with.
func (e StatusError) Error() string {
return fmt.Sprintf("ssh exited with status %d", e.Status)
}
// to returns the command that runs ssh with the derived key held by an
// agent of the tool's own.
func to() *cobra.Command {
cmd := &cobra.Command{
Use: "to <host> [ssh arguments...]",
Short: "run ssh with the derived key served from its own agent",
Long: "Serves the derived key from an SSH agent that runs " +
"inside the tool and points the system ssh at it. The host " +
"and everything after it are given to ssh unchanged, the " +
"tool ends with the status ssh ended with, and the key is " +
"never written to disk.",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
key, comment, err := derived(cmd)
if err != nil {
return err
}
// From here until the agent is taken down, a signal
// cancels the context instead of ending the tool, so
// ssh ends and the socket and its directory are still
// removed.
ctx, stop := signals.Context(cmd.Context())
defer stop()
served, err := key.Serve(ctx, comment)
if err != nil {
return err
}
defer served.Stop()
// IdentitiesOnly yes in the user's ssh configuration
// would keep the agent's key from being offered. The
// agent holds only the derived key, so turning it off
// adds no other key.
argv := slices.Concat([]string{
"-o", "IdentityAgent=" + served.Socket(),
"-o", "IdentitiesOnly=no",
}, args)
return connect(ctx, argv)
},
}
// Everything from the host onwards belongs to ssh, so flag
// reading stops at the first argument that is not a flag.
cmd.Flags().SetInterspersed(false)
addComment(cmd)
return cmd
}
// connect runs ssh on the terminal the tool was given and turns the
// status it ended with into the status the tool ends with.
func connect(ctx context.Context, argv []string) error {
//nolint:gosec // the arguments are the user's own, meant for ssh
command := exec.CommandContext(ctx, "ssh", argv...)
command.Env = mnemonic.ChildEnv()
command.Stdin = os.Stdin
command.Stdout = os.Stdout
command.Stderr = os.Stderr
// A cancelled context means a signal arrived. 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()
if err == nil {
return nil
}
if ended, ok := errors.AsType[*exec.ExitError](err); ok {
status := ended.ExitCode()
if status < 0 {
// A signal ended ssh, and a signal has no status of its
// own to pass on.
status = 1
}
return StatusError{Status: status}
}
return fmt.Errorf("running ssh: %w", err)
}