feat: add GPG forwarding to coder ssh (#5482)

This commit is contained in:
Dean Sheather
2023-01-06 01:52:19 -06:00
committed by GitHub
parent 59e919ab4a
commit f1fe2b5c06
12 changed files with 1051 additions and 22 deletions

View File

@ -575,14 +575,17 @@ func TestServer(t *testing.T) {
ctx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc()
httpListenAddr := ""
if c.httpListener {
httpListenAddr = ":0"
}
certPath, keyPath := generateTLSCertificate(t)
flags := []string{
"server",
"--in-memory",
"--cache-dir", t.TempDir(),
}
if c.httpListener {
flags = append(flags, "--http-address", ":0")
"--http-address", httpListenAddr,
}
if c.tlsListener {
flags = append(flags,

View File

@ -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
}

View File

@ -5,9 +5,12 @@ package cli
import (
"context"
"io"
"net"
"os"
"os/signal"
gossh "golang.org/x/crypto/ssh"
"golang.org/x/sys/unix"
)
@ -20,3 +23,26 @@ func listenWindowSize(ctx context.Context) <-chan os.Signal {
}()
return windowSize
}
func forwardGPGAgent(ctx context.Context, stderr io.Writer, sshClient *gossh.Client) (io.Closer, error) {
localSocket, err := localGPGExtraSocket(ctx)
if err != nil {
return nil, err
}
remoteSocket, err := remoteGPGAgentSocket(sshClient)
if err != nil {
return nil, err
}
localAddr := &net.UnixAddr{
Name: localSocket,
Net: "unix",
}
remoteAddr := &net.UnixAddr{
Name: remoteSocket,
Net: "unix",
}
return sshForwardRemote(ctx, stderr, sshClient, localAddr, remoteAddr)
}

View File

@ -1,15 +1,20 @@
package cli_test
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@ -27,6 +32,7 @@ import (
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisioner/echo"
"github.com/coder/coder/provisionersdk/proto"
"github.com/coder/coder/pty"
"github.com/coder/coder/pty/ptytest"
"github.com/coder/coder/testutil"
)
@ -226,7 +232,7 @@ func TestSSH(t *testing.T) {
})
// Start up ssh agent listening on unix socket.
tmpdir := t.TempDir()
tmpdir := tempDirUnixSocket(t)
agentSock := filepath.Join(tmpdir, "agent.sock")
l, err := net.Listen("unix", agentSock)
require.NoError(t, err)
@ -283,6 +289,224 @@ func TestSSH(t *testing.T) {
pty.WriteLine("exit")
<-cmdDone
})
//nolint:paralleltest // This test uses t.Setenv.
t.Run("ForwardGPG", func(t *testing.T) {
if runtime.GOOS == "windows" {
// While GPG forwarding from a Windows client works, we currently do
// not support forwarding to a Windows workspace. Our tests use the
// same platform for the "client" and "workspace" as they run in the
// same process.
t.Skip("Test not supported on windows")
}
// This key is for dean@coder.com.
const randPublicKeyFingerprint = "7BDFBA0CC7F5A96537C806C427BC6335EB5117F1"
const randPublicKey = `-----BEGIN PGP PUBLIC KEY BLOCK-----
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
p7KeSZdlk47pMBGOfnvEmoQ=
=OxHv
-----END PGP PUBLIC KEY BLOCK-----`
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
gpgPath, err := exec.LookPath("gpg")
if err != nil {
t.Skip("gpg not found")
}
gpgConfPath, err := exec.LookPath("gpgconf")
if err != nil {
t.Skip("gpgconf not found")
}
gpgAgentPath, err := exec.LookPath("gpg-agent")
if err != nil {
t.Skip("gpg-agent not found")
}
// Setup GPG home directory on the "client".
gnupgHomeClient := tempDirUnixSocket(t)
t.Setenv("GNUPGHOME", gnupgHomeClient)
// Get the agent extra socket path.
var (
stdout = bytes.NewBuffer(nil)
stderr = bytes.NewBuffer(nil)
)
c := exec.CommandContext(ctx, gpgConfPath, "--list-dir", "agent-extra-socket")
c.Stdout = stdout
c.Stderr = stderr
err = c.Run()
require.NoError(t, err, "get extra socket path failed: %s", stderr.String())
extraSocketPath := strings.TrimSpace(stdout.String())
// Generate private key non-interactively.
genKeyScript := `
Key-Type: 1
Key-Length: 2048
Subkey-Type: 1
Subkey-Length: 2048
Name-Real: Coder Test
Name-Email: test@coder.com
Expire-Date: 0
%no-protection
`
c = exec.CommandContext(ctx, gpgPath, "--batch", "--gen-key")
c.Stdin = strings.NewReader(genKeyScript)
out, err := c.CombinedOutput()
require.NoError(t, err, "generate key failed: %s", out)
// Import a random public key.
stdin := strings.NewReader(randPublicKey + "\n")
c = exec.CommandContext(ctx, gpgPath, "--import", "-")
c.Stdin = stdin
out, err = c.CombinedOutput()
require.NoError(t, err, "import key failed: %s", out)
// Set ultimate trust on imported key.
stdin = strings.NewReader(randPublicKeyFingerprint + ":6:\n")
c = exec.CommandContext(ctx, gpgPath, "--import-ownertrust")
c.Stdin = stdin
out, err = c.CombinedOutput()
require.NoError(t, err, "import ownertrust failed: %s", out)
// Start the GPG agent.
agentCmd := exec.CommandContext(ctx, gpgAgentPath, "--no-detach", "--extra-socket", extraSocketPath)
agentCmd.Env = append(agentCmd.Env, "GNUPGHOME="+gnupgHomeClient)
agentPTY, agentProc, err := pty.Start(agentCmd, pty.WithPTYOption(pty.WithGPGTTY()))
require.NoError(t, err, "launch agent failed")
defer func() {
_ = agentProc.Kill()
_ = agentPTY.Close()
}()
// Get the agent socket path in the "workspace".
gnupgHomeWorkspace := tempDirUnixSocket(t)
stdout = bytes.NewBuffer(nil)
stderr = bytes.NewBuffer(nil)
c = exec.CommandContext(ctx, gpgConfPath, "--list-dir", "agent-socket")
c.Env = append(c.Env, "GNUPGHOME="+gnupgHomeWorkspace)
c.Stdout = stdout
c.Stderr = stderr
err = c.Run()
require.NoError(t, err, "get agent socket path in workspace failed: %s", stderr.String())
workspaceAgentSocketPath := strings.TrimSpace(stdout.String())
require.NotEqual(t, extraSocketPath, workspaceAgentSocketPath, "socket path should be different")
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
agentClient := codersdk.New(client.URL)
agentClient.SetSessionToken(agentToken)
agentCloser := agent.New(agent.Options{
Client: agentClient,
EnvironmentVariables: map[string]string{
"GNUPGHOME": gnupgHomeWorkspace,
},
Logger: slogtest.Make(t, nil).Named("agent"),
})
defer agentCloser.Close()
cmd, root := clitest.New(t,
"ssh",
workspace.Name,
"--forward-gpg",
)
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
cmd.SetIn(pty.Input())
cmd.SetOut(pty.Output())
cmd.SetErr(pty.Output())
cmdDone := tGo(t, func() {
err := cmd.ExecuteContext(ctx)
assert.NoError(t, err, "ssh command failed")
})
// Prevent the test from hanging if the asserts below kill the test
// early. This will cause the command to exit with an error, which will
// let the t.Cleanup'd `<-done` inside of `tGo` exit and not hang.
// Without this, the test will hang forever on failure, preventing the
// real error from being printed.
t.Cleanup(cancel)
pty.WriteLine("echo hello 'world'")
pty.ExpectMatch("hello world")
// Check the GNUPGHOME was correctly inherited via shell.
pty.WriteLine("env && echo env-''-command-done")
match := pty.ExpectMatch("env--command-done")
require.Contains(t, match, "GNUPGHOME="+gnupgHomeWorkspace, match)
// Get the agent extra socket path in the "workspace" via shell.
pty.WriteLine("gpgconf --list-dir agent-socket && echo gpgconf-''-agentsocket-command-done")
pty.ExpectMatch(workspaceAgentSocketPath)
pty.ExpectMatch("gpgconf--agentsocket-command-done")
// List the keys in the "workspace".
pty.WriteLine("gpg --list-keys && echo gpg-''-listkeys-command-done")
listKeysOutput := pty.ExpectMatch("gpg--listkeys-command-done")
require.Contains(t, listKeysOutput, "[ultimate] Coder Test <test@coder.com>")
require.Contains(t, listKeysOutput, "[ultimate] Dean Sheather (work key) <dean@coder.com>")
// Try to sign something. This demonstrates that the forwarding is
// working as expected, since the workspace doesn't have access to the
// private key directly and must use the forwarded agent.
pty.WriteLine("echo 'hello world' | gpg --clearsign && echo gpg-''-sign-command-done")
pty.ExpectMatch("BEGIN PGP SIGNED MESSAGE")
pty.ExpectMatch("Hash:")
pty.ExpectMatch("hello world")
pty.ExpectMatch("gpg--sign-command-done")
// And we're done.
pty.WriteLine("exit")
<-cmdDone
})
}
// tGoContext runs fn in a goroutine passing a context that will be
@ -356,3 +580,26 @@ func (*stdioConn) SetReadDeadline(_ time.Time) error {
func (*stdioConn) SetWriteDeadline(_ time.Time) error {
return nil
}
// tempDirUnixSocket returns a temporary directory that can safely hold unix
// sockets (probably).
//
// During tests on darwin we hit the max path length limit for unix sockets
// pretty easily in the default location, so this function uses /tmp instead to
// get shorter paths.
func tempDirUnixSocket(t *testing.T) string {
t.Helper()
if runtime.GOOS == "darwin" {
testName := strings.ReplaceAll(t.Name(), "/", "_")
dir, err := os.MkdirTemp("/tmp", fmt.Sprintf("coder-test-%s-", testName))
require.NoError(t, err, "create temp dir for gpg test")
t.Cleanup(func() {
err := os.RemoveAll(dir)
assert.NoError(t, err, "remove temp dir", dir)
})
return dir
}
return t.TempDir()
}

View File

@ -4,9 +4,16 @@
package cli
import (
"bufio"
"context"
"io"
"net"
"os"
"strconv"
"time"
gossh "golang.org/x/crypto/ssh"
"golang.org/x/xerrors"
)
func listenWindowSize(ctx context.Context) <-chan os.Signal {
@ -25,3 +32,74 @@ func listenWindowSize(ctx context.Context) <-chan os.Signal {
}()
return windowSize
}
func forwardGPGAgent(ctx context.Context, stderr io.Writer, sshClient *gossh.Client) (io.Closer, error) {
// Read TCP port and cookie from extra socket file. A gpg-agent socket
// file looks like the following:
//
// 49955
// abcdefghijklmnop
//
// The first line is the TCP port that gpg-agent is listening on, and
// the second line is a 16 byte cookie that MUST be sent as the first
// bytes of any connection to this port (otherwise the connection is
// closed by gpg-agent).
localSocket, err := localGPGExtraSocket(ctx)
if err != nil {
return nil, err
}
f, err := os.Open(localSocket)
if err != nil {
return nil, xerrors.Errorf("open gpg-agent-extra socket file %q: %w", localSocket, err)
}
// Scan lines from file to get port and cookie.
var (
port uint16
cookie []byte
scanner = bufio.NewScanner(f)
)
for i := 0; scanner.Scan(); i++ {
switch i {
case 0:
port64, err := strconv.ParseUint(scanner.Text(), 10, 16)
if err != nil {
return nil, xerrors.Errorf("parse gpg-agent-extra socket file %q: line 1: convert string to integer: %w", localSocket, err)
}
port = uint16(port64)
case 1:
cookie = scanner.Bytes()
if len(cookie) != 16 {
return nil, xerrors.Errorf("parse gpg-agent-extra socket file %q: line 2: expected 16 bytes, got %v bytes", localSocket, len(cookie))
}
default:
return nil, xerrors.Errorf("parse gpg-agent-extra socket file %q: file contains more than 2 lines", localSocket)
}
}
err = scanner.Err()
if err != nil {
return nil, xerrors.Errorf("parse gpg-agent-extra socket file: %q: %w", localSocket, err)
}
remoteSocket, err := remoteGPGAgentSocket(sshClient)
if err != nil {
return nil, err
}
localAddr := cookieAddr{
Addr: &net.TCPAddr{
IP: net.IPv4(127, 0, 0, 1),
Port: int(port),
},
cookie: cookie,
}
remoteAddr := &net.UnixAddr{
Name: remoteSocket,
Net: "unix",
}
return sshForwardRemote(ctx, stderr, sshClient, localAddr, remoteAddr)
}

View File

@ -7,6 +7,14 @@ Flags:
-A, --forward-agent Specifies whether to forward the SSH agent
specified in $SSH_AUTH_SOCK.
Consumes $CODER_SSH_FORWARD_AGENT
-G, --forward-gpg 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.
Consumes $CODER_SSH_FORWARD_GPG
-h, --help help for ssh
--identity-agent string Specifies which identity agent to use (overrides
$SSH_AUTH_SOCK), forward agent must also be