mirror of
https://github.com/coder/coder.git
synced 2025-07-18 14:17:22 +00:00
feat: add GPG forwarding to coder ssh (#5482)
This commit is contained in:
211
cli/ssh.go
211
cli/ssh.go
@ -1,12 +1,15 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@ -21,6 +24,7 @@ import (
|
||||
"golang.org/x/term"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/autobuild/notify"
|
||||
@ -39,6 +43,7 @@ func ssh() *cobra.Command {
|
||||
stdio bool
|
||||
shuffle bool
|
||||
forwardAgent bool
|
||||
forwardGPG bool
|
||||
identityAgent string
|
||||
wsPollInterval time.Duration
|
||||
)
|
||||
@ -138,7 +143,7 @@ func ssh() *cobra.Command {
|
||||
if forwardAgent && identityAgent != "" {
|
||||
err = gosshagent.ForwardToRemote(sshClient, identityAgent)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("forward agent failed: %w", err)
|
||||
return xerrors.Errorf("forward agent: %w", err)
|
||||
}
|
||||
err = gosshagent.RequestAgentForwarding(sshSession)
|
||||
if err != nil {
|
||||
@ -146,6 +151,22 @@ func ssh() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
if forwardGPG {
|
||||
if workspaceAgent.OperatingSystem == "windows" {
|
||||
return xerrors.New("GPG forwarding is not supported for Windows workspaces")
|
||||
}
|
||||
|
||||
err = uploadGPGKeys(ctx, sshClient)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("upload GPG public keys and ownertrust to workspace: %w", err)
|
||||
}
|
||||
closer, err := forwardGPGAgent(ctx, cmd.ErrOrStderr(), sshClient)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("forward GPG socket: %w", err)
|
||||
}
|
||||
defer closer.Close()
|
||||
}
|
||||
|
||||
stdoutFile, validOut := cmd.OutOrStdout().(*os.File)
|
||||
stdinFile, validIn := cmd.InOrStdin().(*os.File)
|
||||
if validOut && validIn && isatty.IsTerminal(stdoutFile.Fd()) {
|
||||
@ -199,10 +220,12 @@ func ssh() *cobra.Command {
|
||||
_ = sshSession.WindowChange(height, width)
|
||||
}
|
||||
}
|
||||
|
||||
err = sshSession.Wait()
|
||||
if err != nil {
|
||||
// If the connection drops unexpectedly, we get an ExitMissingError but no other
|
||||
// error details, so try to at least give the user a better message
|
||||
// If the connection drops unexpectedly, we get an
|
||||
// ExitMissingError but no other error details, so try to at
|
||||
// least give the user a better message
|
||||
if errors.Is(err, &gossh.ExitMissingError{}) {
|
||||
return xerrors.New("SSH connection ended unexpectedly")
|
||||
}
|
||||
@ -216,6 +239,7 @@ func ssh() *cobra.Command {
|
||||
cliflag.BoolVarP(cmd.Flags(), &shuffle, "shuffle", "", "CODER_SSH_SHUFFLE", false, "Specifies whether to choose a random workspace")
|
||||
_ = cmd.Flags().MarkHidden("shuffle")
|
||||
cliflag.BoolVarP(cmd.Flags(), &forwardAgent, "forward-agent", "A", "CODER_SSH_FORWARD_AGENT", false, "Specifies whether to forward the SSH agent specified in $SSH_AUTH_SOCK")
|
||||
cliflag.BoolVarP(cmd.Flags(), &forwardGPG, "forward-gpg", "G", "CODER_SSH_FORWARD_GPG", false, "Specifies whether to forward the GPG agent. Unsupported on Windows workspaces, but supports all clients. Requires gnupg (gpg, gpgconf) on both the client and workspace. The GPG agent must already be running locally and will not be started for you. If a GPG agent is already running in the workspace, it will be attempted to be killed.")
|
||||
cliflag.StringVarP(cmd.Flags(), &identityAgent, "identity-agent", "", "CODER_SSH_IDENTITY_AGENT", "", "Specifies which identity agent to use (overrides $SSH_AUTH_SOCK), forward agent must also be enabled")
|
||||
cliflag.DurationVarP(cmd.Flags(), &wsPollInterval, "workspace-poll-interval", "", "CODER_WORKSPACE_POLL_INTERVAL", workspacePollInterval, "Specifies how often to poll for workspace automated shutdown.")
|
||||
return cmd
|
||||
@ -364,3 +388,184 @@ func verifyWorkspaceOutdated(client *codersdk.Client, workspace codersdk.Workspa
|
||||
func buildWorkspaceLink(serverURL *url.URL, workspace codersdk.Workspace) *url.URL {
|
||||
return serverURL.ResolveReference(&url.URL{Path: fmt.Sprintf("@%s/%s", workspace.OwnerName, workspace.Name)})
|
||||
}
|
||||
|
||||
// runLocal runs a command on the local machine.
|
||||
func runLocal(ctx context.Context, stdin io.Reader, name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Stdin = stdin
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
var stderr []byte
|
||||
if exitErr := new(exec.ExitError); errors.As(err, &exitErr) {
|
||||
stderr = exitErr.Stderr
|
||||
}
|
||||
|
||||
return out, xerrors.Errorf(
|
||||
"`%s %s` failed: stderr: %s\n\nstdout: %s\n\n%w",
|
||||
name,
|
||||
strings.Join(args, " "),
|
||||
bytes.TrimSpace(stderr),
|
||||
bytes.TrimSpace(out),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// runRemoteSSH runs a command on a remote machine/workspace via SSH.
|
||||
func runRemoteSSH(sshClient *gossh.Client, stdin io.Reader, cmd string) ([]byte, error) {
|
||||
sess, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create SSH session")
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
stderr := bytes.NewBuffer(nil)
|
||||
sess.Stdin = stdin
|
||||
sess.Stderr = stderr
|
||||
|
||||
out, err := sess.Output(cmd)
|
||||
if err != nil {
|
||||
return out, xerrors.Errorf(
|
||||
"`%s` failed: stderr: %s\n\nstdout: %s:\n\n%w",
|
||||
cmd,
|
||||
bytes.TrimSpace(stderr.Bytes()),
|
||||
bytes.TrimSpace(out),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func uploadGPGKeys(ctx context.Context, sshClient *gossh.Client) error {
|
||||
// Check if the agent is running in the workspace already.
|
||||
//
|
||||
// Note: we don't support windows in the workspace for GPG forwarding so
|
||||
// using shell commands is fine.
|
||||
//
|
||||
// Note: we sleep after killing the agent because it doesn't always die
|
||||
// immediately.
|
||||
agentSocketBytes, err := runRemoteSSH(sshClient, nil, `
|
||||
set -eux
|
||||
agent_socket=$(gpgconf --list-dir agent-socket)
|
||||
echo "$agent_socket"
|
||||
if [ -S "$agent_socket" ]; then
|
||||
echo "agent socket exists, attempting to kill it" >&2
|
||||
gpgconf --kill gpg-agent
|
||||
rm -f "$agent_socket"
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
test ! -S "$agent_socket"
|
||||
`)
|
||||
agentSocket := strings.TrimSpace(string(agentSocketBytes))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("check if agent socket is running (check if %q exists): %w", agentSocket, err)
|
||||
}
|
||||
if agentSocket == "" {
|
||||
return xerrors.Errorf("agent socket path is empty, check the output of `gpgconf --list-dir agent-socket`")
|
||||
}
|
||||
|
||||
// Read the user's public keys and ownertrust from GPG.
|
||||
pubKeyExport, err := runLocal(ctx, nil, "gpg", "--armor", "--export")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("export local public keys from GPG: %w", err)
|
||||
}
|
||||
ownerTrustExport, err := runLocal(ctx, nil, "gpg", "--export-ownertrust")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("export local ownertrust from GPG: %w", err)
|
||||
}
|
||||
|
||||
// Import the public keys and ownertrust into the workspace.
|
||||
_, err = runRemoteSSH(sshClient, bytes.NewReader(pubKeyExport), "gpg --import")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("import public keys into workspace: %w", err)
|
||||
}
|
||||
_, err = runRemoteSSH(sshClient, bytes.NewReader(ownerTrustExport), "gpg --import-ownertrust")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("import ownertrust into workspace: %w", err)
|
||||
}
|
||||
|
||||
// Kill the agent in the workspace if it was started by one of the above
|
||||
// commands.
|
||||
_, err = runRemoteSSH(sshClient, nil, fmt.Sprintf("gpgconf --kill gpg-agent && rm -f %q", agentSocket))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("kill existing agent in workspace: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func localGPGExtraSocket(ctx context.Context) (string, error) {
|
||||
localSocket, err := runLocal(ctx, nil, "gpgconf", "--list-dir", "agent-extra-socket")
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("get local GPG agent socket: %w", err)
|
||||
}
|
||||
|
||||
return string(bytes.TrimSpace(localSocket)), nil
|
||||
}
|
||||
|
||||
func remoteGPGAgentSocket(sshClient *gossh.Client) (string, error) {
|
||||
remoteSocket, err := runRemoteSSH(sshClient, nil, "gpgconf --list-dir agent-socket")
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("get remote GPG agent socket: %w", err)
|
||||
}
|
||||
|
||||
return string(bytes.TrimSpace(remoteSocket)), nil
|
||||
}
|
||||
|
||||
// cookieAddr is a special net.Addr accepted by sshForward() which includes a
|
||||
// cookie which is written to the connection before forwarding.
|
||||
type cookieAddr struct {
|
||||
net.Addr
|
||||
cookie []byte
|
||||
}
|
||||
|
||||
// sshForwardRemote starts forwarding connections from a remote listener to a
|
||||
// local address via SSH in a goroutine.
|
||||
//
|
||||
// Accepts a `cookieAddr` as the local address.
|
||||
func sshForwardRemote(ctx context.Context, stderr io.Writer, sshClient *gossh.Client, localAddr, remoteAddr net.Addr) (io.Closer, error) {
|
||||
listener, err := sshClient.Listen(remoteAddr.Network(), remoteAddr.String())
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("listen on remote SSH address %s: %w", remoteAddr.String(), err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
remoteConn, err := listener.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
_, _ = fmt.Fprintf(stderr, "Accept SSH listener connection: %+v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer remoteConn.Close()
|
||||
|
||||
localConn, err := net.Dial(localAddr.Network(), localAddr.String())
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "Dial local address %s: %+v\n", localAddr.String(), err)
|
||||
return
|
||||
}
|
||||
defer localConn.Close()
|
||||
|
||||
if c, ok := localAddr.(cookieAddr); ok {
|
||||
_, err = localConn.Write(c.cookie)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "Write cookie to local connection: %+v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
agent.Bicopy(ctx, localConn, remoteConn)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
|
||||
return listener, nil
|
||||
}
|
||||
|
Reference in New Issue
Block a user