The ssh install and ssh to commands (closes #2)
All checks were successful
check / check (push) Successful in 19s
All checks were successful
check / check (push) Successful in 19s
install runs the system ssh and hands the host a short shell script. The public key line reaches it on standard input, never on a command line others on the host could read. The script makes ~/.ssh and authorized_keys if missing, adds the line unless it is already there, and says which of the two it did. to serves the key from an agent inside the tool, on a unix socket in a temporary directory only its owner can enter, and points ssh at it with -o IdentityAgent. Socket and directory go when the command ends and the private key is never written to disk. Only this command ends with the status ssh ended with rather than status 1. Model: opus-5
This commit is contained in:
96
internal/cli/ssh/install.go
Normal file
96
internal/cli/ssh/install.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// script is what runs on the host. It reads the key line from its own
|
||||
// standard input, so the line never appears on a command line, where
|
||||
// anyone else on the host could read it out of the process list. It
|
||||
// contains no single quote, so the whole of it travels through ssh
|
||||
// inside one pair of them. The umask keeps anything it makes to the
|
||||
// owner from the start; the modes are then set outright, whatever the
|
||||
// umask on the host turns out to be. A file whose last line has no
|
||||
// newline at its end gets one before the key line goes on, so that the
|
||||
// two do not run into each other.
|
||||
const script = `
|
||||
set -e
|
||||
umask 077
|
||||
directory="$HOME/.ssh"
|
||||
file="$directory/authorized_keys"
|
||||
if [ ! -d "$directory" ]; then
|
||||
mkdir -p "$directory"
|
||||
chmod 700 "$directory"
|
||||
fi
|
||||
if [ ! -f "$file" ]; then
|
||||
: > "$file"
|
||||
chmod 600 "$file"
|
||||
fi
|
||||
IFS= read -r line
|
||||
if grep -q -x -F -e "$line" "$file"; then
|
||||
echo "already present"
|
||||
else
|
||||
if [ -s "$file" ] && [ -n "$(tail -c 1 "$file")" ]; then
|
||||
printf "\n" >> "$file"
|
||||
fi
|
||||
printf "%s\n" "$line" >> "$file"
|
||||
echo "added"
|
||||
fi
|
||||
`
|
||||
|
||||
// install returns the command that adds the public key to a host.
|
||||
func install() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "install <[user@]host> [-- ssh options...]",
|
||||
Short: "add the public key to a host's authorized_keys",
|
||||
Long: "Runs the system ssh to the host, which makes ~/.ssh and " +
|
||||
"~/.ssh/authorized_keys there if they are missing and adds " +
|
||||
"the public key unless the same line is already in the " +
|
||||
"file. Anything after -- is given to ssh unchanged.",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
key, comment, err := derived(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
line, err := key.Line(comment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return send(cmd, args[0], args[1:], line)
|
||||
},
|
||||
}
|
||||
|
||||
addComment(cmd)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// send runs ssh to the host with the user's options, gives it the
|
||||
// script to run there, and writes the key line to its standard input.
|
||||
// What the host says, added or already present, is passed straight on.
|
||||
func send(cmd *cobra.Command, host string, options []string, line string) error {
|
||||
argv := slices.Concat(options, []string{
|
||||
host, "/bin/sh -c '" + script + "'",
|
||||
})
|
||||
|
||||
//nolint:gosec // the options are the user's own, meant for ssh
|
||||
command := exec.CommandContext(cmd.Context(), "ssh", argv...)
|
||||
command.Stdin = strings.NewReader(line + "\n")
|
||||
command.Stdout = cmd.OutOrStdout()
|
||||
command.Stderr = cmd.ErrOrStderr()
|
||||
|
||||
err := command.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("running ssh: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -17,7 +17,7 @@ func Command() *cobra.Command {
|
||||
Short: "derive ed25519 SSH keys",
|
||||
}
|
||||
|
||||
group.AddCommand(public(), private())
|
||||
group.AddCommand(public(), private(), install(), to())
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
95
internal/cli/ssh/to.go
Normal file
95
internal/cli/ssh/to.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
served, err := key.Serve(cmd.Context(), comment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer served.Stop()
|
||||
|
||||
argv := slices.Concat([]string{
|
||||
"-o", "IdentityAgent=" + served.Socket(),
|
||||
}, args)
|
||||
|
||||
return connect(cmd.Context(), 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.Stdin = os.Stdin
|
||||
command.Stdout = os.Stdout
|
||||
command.Stderr = os.Stderr
|
||||
|
||||
err := command.Run()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ended *exec.ExitError
|
||||
if errors.As(err, &ended) {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user