From 38f074254b7484c0a25f461ba364694307e0a648 Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Thu, 7 Apr 2022 17:40:27 -0500 Subject: [PATCH] feat: wrap ssh with coder key (#894) --- agent/agent.go | 5 ++ agent/agent_test.go | 75 ++++++++++++------------ cli/config/file.go | 4 ++ cli/gitssh.go | 62 ++++++++++++++++++++ cli/gitssh_test.go | 129 ++++++++++++++++++++++++++++++++++++++++++ cli/publickey.go | 29 ++++++++++ cli/publickey_test.go | 27 +++++++++ cli/root.go | 2 + cli/workspaceagent.go | 6 ++ codersdk/gitsshkey.go | 4 +- 10 files changed, 302 insertions(+), 41 deletions(-) create mode 100644 cli/gitssh.go create mode 100644 cli/gitssh_test.go create mode 100644 cli/publickey.go create mode 100644 cli/publickey_test.go diff --git a/agent/agent.go b/agent/agent.go index ed574edad2..cc4131297c 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -230,6 +230,11 @@ func (a *agent) handleSSHSession(session ssh.Session) error { cmd := exec.CommandContext(session.Context(), command, args...) cmd.Env = append(os.Environ(), session.Environ()...) + executablePath, err := os.Executable() + if err != nil { + return xerrors.Errorf("getting os executable: %w", err) + } + cmd.Env = append(session.Environ(), fmt.Sprintf(`GIT_SSH_COMMAND="%s gitssh --"`, executablePath)) sshPty, windowSize, isPty := session.Pty() if isPty { diff --git a/agent/agent_test.go b/agent/agent_test.go index 3f31ab26d4..9461da9620 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -29,24 +29,7 @@ func TestAgent(t *testing.T) { t.Parallel() t.Run("SessionExec", func(t *testing.T) { t.Parallel() - api := setup(t) - stream, err := api.NegotiateConnection(context.Background()) - require.NoError(t, err) - conn, err := peerbroker.Dial(stream, []webrtc.ICEServer{}, &peer.ConnOptions{ - Logger: slogtest.Make(t, nil), - }) - require.NoError(t, err) - t.Cleanup(func() { - _ = conn.Close() - }) - client := agent.Conn{ - Negotiator: api, - Conn: conn, - } - sshClient, err := client.SSHClient() - require.NoError(t, err) - session, err := sshClient.NewSession() - require.NoError(t, err) + session := setupSSH(t) command := "echo test" if runtime.GOOS == "windows" { command = "cmd.exe /c echo test" @@ -56,33 +39,28 @@ func TestAgent(t *testing.T) { require.Equal(t, "test", strings.TrimSpace(string(output))) }) + t.Run("GitSSH", func(t *testing.T) { + t.Parallel() + session := setupSSH(t) + command := "sh -c 'echo $GIT_SSH_COMMAND'" + if runtime.GOOS == "windows" { + command = "cmd.exe /c echo %GIT_SSH_COMMAND%" + } + output, err := session.Output(command) + require.NoError(t, err) + require.Contains(t, string(output), "gitssh --") + }) + t.Run("SessionTTY", func(t *testing.T) { t.Parallel() - api := setup(t) - stream, err := api.NegotiateConnection(context.Background()) - require.NoError(t, err) - conn, err := peerbroker.Dial(stream, []webrtc.ICEServer{}, &peer.ConnOptions{ - Logger: slogtest.Make(t, nil), - }) - require.NoError(t, err) - t.Cleanup(func() { - _ = conn.Close() - }) - client := &agent.Conn{ - Negotiator: api, - Conn: conn, - } - sshClient, err := client.SSHClient() - require.NoError(t, err) - session, err := sshClient.NewSession() - require.NoError(t, err) + session := setupSSH(t) prompt := "$" command := "bash" if runtime.GOOS == "windows" { command = "cmd.exe" prompt = ">" } - err = session.RequestPty("xterm", 128, 128, ssh.TerminalModes{}) + err := session.RequestPty("xterm", 128, 128, ssh.TerminalModes{}) require.NoError(t, err) ptty := ptytest.New(t) require.NoError(t, err) @@ -100,7 +78,7 @@ func TestAgent(t *testing.T) { }) } -func setup(t *testing.T) proto.DRPCPeerBrokerClient { +func setupSSH(t *testing.T) *ssh.Session { client, server := provisionersdk.TransportPipe() closer := agent.New(func(ctx context.Context, opts *peer.ConnOptions) (*peerbroker.Listener, error) { return peerbroker.Listen(server, nil, opts) @@ -112,5 +90,24 @@ func setup(t *testing.T) proto.DRPCPeerBrokerClient { _ = server.Close() _ = closer.Close() }) - return proto.NewDRPCPeerBrokerClient(provisionersdk.Conn(client)) + api := proto.NewDRPCPeerBrokerClient(provisionersdk.Conn(client)) + stream, err := api.NegotiateConnection(context.Background()) + require.NoError(t, err) + conn, err := peerbroker.Dial(stream, []webrtc.ICEServer{}, &peer.ConnOptions{ + Logger: slogtest.Make(t, nil), + }) + require.NoError(t, err) + t.Cleanup(func() { + _ = conn.Close() + }) + agentClient := &agent.Conn{ + Negotiator: api, + Conn: conn, + } + sshClient, err := agentClient.SSHClient() + require.NoError(t, err) + session, err := sshClient.NewSession() + require.NoError(t, err) + + return session } diff --git a/cli/config/file.go b/cli/config/file.go index 1bf3ce5bd3..2aef63c376 100644 --- a/cli/config/file.go +++ b/cli/config/file.go @@ -21,6 +21,10 @@ func (r Root) Organization() File { return File(filepath.Join(string(r), "organization")) } +func (r Root) AgentSession() File { + return File(filepath.Join(string(r), "agentsession")) +} + // File provides convenience methods for interacting with *os.File. type File string diff --git a/cli/gitssh.go b/cli/gitssh.go new file mode 100644 index 0000000000..6945c652dd --- /dev/null +++ b/cli/gitssh.go @@ -0,0 +1,62 @@ +package cli + +import ( + "os" + "os/exec" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" +) + +func gitssh() *cobra.Command { + return &cobra.Command{ + Use: "gitssh", + Hidden: true, + Short: `Wraps the "ssh" command and uses the coder gitssh key for authentication`, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := createClient(cmd) + if err != nil { + return xerrors.Errorf("create codersdk client: %w", err) + } + cfg := createConfig(cmd) + session, err := cfg.AgentSession().Read() + if err != nil { + return xerrors.Errorf("read agent session from config: %w", err) + } + client.SessionToken = session + + key, err := client.AgentGitSSHKey(cmd.Context()) + if err != nil { + return xerrors.Errorf("get agent git ssh token: %w", err) + } + + privateKeyFile, err := os.CreateTemp("", "coder-gitsshkey-*") + if err != nil { + return xerrors.Errorf("create temp gitsshkey file: %w", err) + } + defer func() { + _ = privateKeyFile.Close() + _ = os.Remove(privateKeyFile.Name()) + }() + _, err = privateKeyFile.WriteString(key.PrivateKey) + if err != nil { + return xerrors.Errorf("write to temp gitsshkey file: %w", err) + } + err = privateKeyFile.Close() + if err != nil { + return xerrors.Errorf("close temp gitsshkey file: %w", err) + } + + a := append([]string{"-i", privateKeyFile.Name()}, args...) + c := exec.CommandContext(cmd.Context(), "ssh", a...) + c.Stdout = cmd.OutOrStdout() + c.Stdin = cmd.InOrStdin() + err = c.Run() + if err != nil { + return xerrors.Errorf("run ssh command: %w", err) + } + + return nil + }, + } +} diff --git a/cli/gitssh_test.go b/cli/gitssh_test.go new file mode 100644 index 0000000000..d9b36d103f --- /dev/null +++ b/cli/gitssh_test.go @@ -0,0 +1,129 @@ +package cli_test + +import ( + "context" + "fmt" + "net" + "sync/atomic" + "testing" + "time" + + "github.com/gliderlabs/ssh" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + gossh "golang.org/x/crypto/ssh" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/cli/config" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/provisioner/echo" + "github.com/coder/coder/provisionersdk/proto" +) + +func TestGitSSH(t *testing.T) { + t.Parallel() + t.Run("Dial", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + instanceID := "instanceidentifier" + certificates, metadataClient := coderdtest.NewAWSInstanceIdentity(t, instanceID) + client := coderdtest.New(t, &coderdtest.Options{ + AWSInstanceIdentity: certificates, + }) + user := coderdtest.CreateFirstUser(t, client) + + // get user public key + keypair, err := client.GitSSHKey(ctx, codersdk.Me) + require.NoError(t, err) + publicKey, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keypair.PublicKey)) + require.NoError(t, err) + + // setup provisioner + coderdtest.NewProvisionerDaemon(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "somename", + Type: "someinstance", + Agent: &proto.Agent{ + Auth: &proto.Agent_InstanceId{ + InstanceId: instanceID, + }, + }, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, codersdk.Me, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + // start workspace agent + cmd, root := clitest.New(t, "workspaces", "agent", "--auth", "aws-instance-identity", "--url", client.URL.String()) + agentClient := &*client + clitest.SetupConfig(t, agentClient, root) + ctx, cancelFunc := context.WithCancel(context.Background()) + defer cancelFunc() + go func() { + // A linting error occurs for weakly typing the context value here, + // but it seems reasonable for a one-off test. + // nolint + ctx = context.WithValue(ctx, "aws-client", metadataClient) + err := cmd.ExecuteContext(ctx) + require.NoError(t, err) + }() + coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) + resources, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID) + require.NoError(t, err) + dialer, err := client.DialWorkspaceAgent(ctx, resources[0].ID, nil, nil) + require.NoError(t, err) + defer dialer.Close() + _, err = dialer.Ping() + require.NoError(t, err) + + // start ssh server + l, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + defer l.Close() + publicKeyOption := ssh.PublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { + return ssh.KeysEqual(publicKey, key) + }) + var inc int64 + go func() { + // as long as we get a successful session we don't care if the server errors + _ = ssh.Serve(l, func(s ssh.Session) { + atomic.AddInt64(&inc, 1) + t.Log("got authenticated sesion") + err := s.Exit(0) + require.NoError(t, err) + }, publicKeyOption) + }() + + // start ssh session + addr, ok := l.Addr().(*net.TCPAddr) + require.True(t, ok) + cfgDir := createConfig(cmd) + // set to agent config dir + cmd, root = clitest.New(t, "gitssh", "--global-config="+string(cfgDir), "--", fmt.Sprintf("-p%d", addr.Port), "-o", "StrictHostKeyChecking=no", "127.0.0.1") + clitest.SetupConfig(t, agentClient, root) + + err = cmd.ExecuteContext(ctx) + require.NoError(t, err) + require.EqualValues(t, 1, inc) + }) +} + +// createConfig consumes the global configuration flag to produce a config root. +func createConfig(cmd *cobra.Command) config.Root { + globalRoot, err := cmd.Flags().GetString("global-config") + if err != nil { + panic(err) + } + return config.Root(globalRoot) +} diff --git a/cli/publickey.go b/cli/publickey.go new file mode 100644 index 0000000000..d61542aee2 --- /dev/null +++ b/cli/publickey.go @@ -0,0 +1,29 @@ +package cli + +import ( + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "github.com/coder/coder/codersdk" +) + +func publickey() *cobra.Command { + return &cobra.Command{ + Use: "publickey", + RunE: func(cmd *cobra.Command, args []string) error { + client, err := createClient(cmd) + if err != nil { + return xerrors.Errorf("create codersdk client: %w", err) + } + + key, err := client.GitSSHKey(cmd.Context(), codersdk.Me) + if err != nil { + return xerrors.Errorf("create codersdk client: %w", err) + } + + cmd.Println(key.PublicKey) + + return nil + }, + } +} diff --git a/cli/publickey_test.go b/cli/publickey_test.go new file mode 100644 index 0000000000..cd2ab35486 --- /dev/null +++ b/cli/publickey_test.go @@ -0,0 +1,27 @@ +package cli_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/coderd/coderdtest" +) + +func TestPublicKey(t *testing.T) { + t.Parallel() + t.Run("OK", func(t *testing.T) { + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + cmd, root := clitest.New(t, "publickey") + clitest.SetupConfig(t, client, root) + buf := new(bytes.Buffer) + cmd.SetOut(buf) + err := cmd.Execute() + require.NoError(t, err) + publicKey := buf.String() + require.NotEmpty(t, publicKey) + }) +} diff --git a/cli/root.go b/cli/root.go index 23289edec8..e7b28fe3ed 100644 --- a/cli/root.go +++ b/cli/root.go @@ -70,6 +70,8 @@ func Root() *cobra.Command { workspaces(), ssh(), workspaceTunnel(), + gitssh(), + publickey(), ) cmd.PersistentFlags().String(varGlobalConfig, configdir.LocalConfig("coderv2"), "Path to the global `coder` config directory") diff --git a/cli/workspaceagent.go b/cli/workspaceagent.go index 776002116d..2bdd7bca22 100644 --- a/cli/workspaceagent.go +++ b/cli/workspaceagent.go @@ -104,6 +104,12 @@ func workspaceAgent() *cobra.Command { } } + cfg := createConfig(cmd) + err = cfg.AgentSession().Write(client.SessionToken) + if err != nil { + return xerrors.Errorf("writing agent session token to config: %w", err) + } + closer := agent.New(client.ListenWorkspaceAgent, &peer.ConnOptions{ Logger: logger, }) diff --git a/codersdk/gitsshkey.go b/codersdk/gitsshkey.go index 35e90ad402..b7944ab53e 100644 --- a/codersdk/gitsshkey.go +++ b/codersdk/gitsshkey.go @@ -24,7 +24,7 @@ type AgentGitSSHKey struct { // GitSSHKey returns the user's git SSH public key. func (c *Client) GitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) { - res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/gitsshkey", userID.String()), nil) + res, err := c.request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/gitsshkey", uuidOrMe(userID)), nil) if err != nil { return GitSSHKey{}, xerrors.Errorf("execute request: %w", err) } @@ -40,7 +40,7 @@ func (c *Client) GitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, er // RegenerateGitSSHKey will create a new SSH key pair for the user and return it. func (c *Client) RegenerateGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) { - res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/gitsshkey", userID.String()), nil) + res, err := c.request(ctx, http.MethodPut, fmt.Sprintf("/api/v2/users/%s/gitsshkey", uuidOrMe(userID)), nil) if err != nil { return GitSSHKey{}, xerrors.Errorf("execute request: %w", err) }