The ssh install and ssh to commands (closes #2)
All checks were successful
check / check (push) Successful in 2m30s
All checks were successful
check / check (push) Successful in 2m30s
keyfunc ssh install appends the public line on a host through the system ssh, only when absent, feeding the line on standard input; keyfunc ssh to serves the derived key from an in-process agent on a private socket and runs the system ssh with it, the private key never on disk. Two review rounds; the second passed with no findings. Model: opus-5 (implementation and review); fable-5-1 (landing)
This commit was merged in pull request #8.
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