check / check (push) Failing after 2s
SIGINT, SIGTERM and SIGHUP were caught for the whole run, so the mnemonic prompt waited for Enter and an interrupted `age encrypt -o` put the encryption of the cut-off input in place. Now they end the tool at once, except where a command cleans up first: `ssh to` and `ssh install` while ssh or sftp runs, and `age encrypt -o` and `age decrypt -o` while they write. A signal those two have received when their input ends removes the unfinished file and exits 1; a later one leaves the whole file in place. The tool stays on the main thread, where Linux delivers the signal first. Signals the tool was started ignoring stay ignored, so a run under nohup survives a hangup. Model: opus-5-5
112 lines
2.8 KiB
Go
112 lines
2.8 KiB
Go
package ssh
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"slices"
|
|
"syscall"
|
|
|
|
"github.com/spf13/cobra"
|
|
"sneak.berlin/go/keyfunc/internal/cli/signals"
|
|
)
|
|
|
|
// 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()
|
|
|
|
argv := slices.Concat([]string{
|
|
"-o", "IdentityAgent=" + served.Socket(),
|
|
}, 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 = 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)
|
|
}
|