From e9a28be956641b468d8f497b0fb29db8983baf79 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 26 May 2022 14:53:44 -0500 Subject: [PATCH 1/5] feat: add port scanning to agent --- agent/agent.go | 98 +++++++++++++++++++++++++++++++++++++++++++-- agent/agent_test.go | 52 ++++++++++++++++++++++++ agent/conn.go | 11 +++++ go.mod | 2 + go.sum | 2 + 5 files changed, 161 insertions(+), 4 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index 4859e35f08395..40dd706b0b4e0 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -21,6 +21,7 @@ import ( "time" "github.com/armon/circbuf" + "github.com/cakturk/go-netstat/netstat" "github.com/gliderlabs/ssh" "github.com/google/uuid" "github.com/pkg/sftp" @@ -37,6 +38,7 @@ import ( ) const ( + ProtocolNetstat = "netstat" ProtocolReconnectingPTY = "reconnecting-pty" ProtocolSSH = "ssh" ProtocolDial = "dial" @@ -44,6 +46,7 @@ const ( type Options struct { ReconnectingPTYTimeout time.Duration + NetstatInterval time.Duration EnvironmentVariables map[string]string Logger slog.Logger } @@ -65,10 +68,14 @@ func New(dialer Dialer, options *Options) io.Closer { if options.ReconnectingPTYTimeout == 0 { options.ReconnectingPTYTimeout = 5 * time.Minute } + if options.NetstatInterval == 0 { + options.NetstatInterval = 5 * time.Second + } ctx, cancelFunc := context.WithCancel(context.Background()) server := &agent{ dialer: dialer, reconnectingPTYTimeout: options.ReconnectingPTYTimeout, + netstatInterval: options.NetstatInterval, logger: options.Logger, closeCancel: cancelFunc, closed: make(chan struct{}), @@ -85,6 +92,8 @@ type agent struct { reconnectingPTYs sync.Map reconnectingPTYTimeout time.Duration + netstatInterval time.Duration + connCloseWait sync.WaitGroup closeCancel context.CancelFunc closeMutex sync.Mutex @@ -225,6 +234,8 @@ func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) { go a.handleReconnectingPTY(ctx, channel.Label(), channel.NetConn()) case ProtocolDial: go a.handleDial(ctx, channel.Label(), channel.NetConn()) + case ProtocolNetstat: + go a.handleNetstat(ctx, channel.Label(), channel.NetConn()) default: a.logger.Warn(ctx, "unhandled protocol from channel", slog.F("protocol", channel.Protocol()), @@ -359,12 +370,10 @@ func (a *agent) createCommand(ctx context.Context, rawCommand string, env []stri if err != nil { return nil, xerrors.Errorf("getting os executable: %w", err) } - cmd.Env = append(cmd.Env, fmt.Sprintf("USER=%s", username)) - cmd.Env = append(cmd.Env, fmt.Sprintf(`PATH=%s%c%s`, os.Getenv("PATH"), filepath.ListSeparator, filepath.Dir(executablePath))) // Git on Windows resolves with UNIX-style paths. // If using backslashes, it's unable to find the executable. - unixExecutablePath := strings.ReplaceAll(executablePath, "\\", "/") - cmd.Env = append(cmd.Env, fmt.Sprintf(`GIT_SSH_COMMAND=%s gitssh --`, unixExecutablePath)) + executablePath = strings.ReplaceAll(executablePath, "\\", "/") + cmd.Env = append(cmd.Env, fmt.Sprintf(`GIT_SSH_COMMAND=%s gitssh --`, executablePath)) // These prevent the user from having to specify _anything_ to successfully commit. // Both author and committer must be set! cmd.Env = append(cmd.Env, fmt.Sprintf(`GIT_AUTHOR_EMAIL=%s`, metadata.OwnerEmail)) @@ -707,6 +716,87 @@ func (a *agent) handleDial(ctx context.Context, label string, conn net.Conn) { Bicopy(ctx, conn, nconn) } +type NetstatPort struct { + Name string `json:"name"` + Port uint16 `json:"port"` +} + +type NetstatResponse struct { + Ports []NetstatPort `json:"ports"` + Error string `json:"error,omitempty"` + Took time.Duration `json:"took"` +} + +func (a *agent) handleNetstat(ctx context.Context, label string, conn net.Conn) { + write := func(resp NetstatResponse) error { + b, err := json.Marshal(resp) + if err != nil { + a.logger.Warn(ctx, "write netstat response", slog.F("label", label), slog.Error(err)) + return xerrors.Errorf("marshal agent netstat response: %w", err) + } + _, err = conn.Write(b) + if err != nil { + a.logger.Warn(ctx, "write netstat response", slog.F("label", label), slog.Error(err)) + } + return err + } + + scan := func() ([]NetstatPort, error) { + if runtime.GOOS != "linux" && runtime.GOOS != "windows" { + return nil, xerrors.New(fmt.Sprintf("Port scanning is not supported on %s", runtime.GOOS)) + } + + tabs, err := netstat.TCPSocks(func(s *netstat.SockTabEntry) bool { + return s.State == netstat.Listen + }) + if err != nil { + return nil, err + } + + ports := []NetstatPort{} + for _, tab := range tabs { + ports = append(ports, NetstatPort{ + Name: tab.Process.Name, + Port: tab.LocalAddr.Port, + }) + } + return ports, nil + } + + scanAndWrite := func() { + start := time.Now() + ports, err := scan() + response := NetstatResponse{ + Ports: ports, + Took: time.Since(start), + } + if err != nil { + response.Error = err.Error() + } + _ = write(response) + } + + scanAndWrite() + + // Using a timer instead of a ticker to ensure delay between calls otherwise + // if nestat took longer than the interval we would constantly run it. + timer := time.NewTimer(a.netstatInterval) + go func() { + defer conn.Close() + defer timer.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + scanAndWrite() + timer.Reset(a.netstatInterval) + } + } + }() +} + // isClosed returns whether the API is closed or not. func (a *agent) isClosed() bool { select { diff --git a/agent/agent_test.go b/agent/agent_test.go index 923ce46290b5d..f772b4f19a98b 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -373,6 +373,57 @@ func TestAgent(t *testing.T) { require.ErrorContains(t, err, "no such file") require.Nil(t, netConn) }) + + t.Run("Netstat", func(t *testing.T) { + t.Parallel() + + var ports []agent.NetstatPort + listen := func() { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { + _ = listener.Close() + }) + + tcpAddr, valid := listener.Addr().(*net.TCPAddr) + require.True(t, valid) + + name, err := os.Executable() + require.NoError(t, err) + + ports = append(ports, agent.NetstatPort{ + Name: filepath.Base(name), + Port: uint16(tcpAddr.Port), + }) + } + + conn := setupAgent(t, agent.Metadata{}, 0) + netConn, err := conn.Netstat(context.Background()) + require.NoError(t, err) + t.Cleanup(func() { + _ = netConn.Close() + }) + + decoder := json.NewDecoder(netConn) + + expectNetstat := func() { + var res agent.NetstatResponse + err = decoder.Decode(&res) + require.NoError(t, err) + + if runtime.GOOS == "linux" || runtime.GOOS == "windows" { + require.Subset(t, res.Ports, ports) + } else { + require.Equal(t, fmt.Sprintf("Port scanning is not supported on %s", runtime.GOOS), res.Error) + } + } + + listen() + expectNetstat() + + listen() + expectNetstat() + }) } func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd { @@ -420,6 +471,7 @@ func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration) }, &agent.Options{ Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), ReconnectingPTYTimeout: ptyTimeout, + NetstatInterval: 100 * time.Millisecond, }) t.Cleanup(func() { _ = client.Close() diff --git a/agent/conn.go b/agent/conn.go index d44d6d0c0b0d8..4844f094c2182 100644 --- a/agent/conn.go +++ b/agent/conn.go @@ -112,6 +112,17 @@ func (c *Conn) DialContext(ctx context.Context, network string, addr string) (ne return channel.NetConn(), nil } +// Netstat returns a connection that serves a list of listening ports. +func (c *Conn) Netstat(ctx context.Context) (net.Conn, error) { + channel, err := c.CreateChannel(ctx, "netstat", &peer.ChannelOptions{ + Protocol: ProtocolNetstat, + }) + if err != nil { + return nil, xerrors.Errorf("netsat: %w", err) + } + return channel.NetConn(), nil +} + func (c *Conn) Close() error { _ = c.Negotiator.DRPCConn().Close() return c.Conn.Close() diff --git a/go.mod b/go.mod index da981e9874691..55e982c8ea7c0 100644 --- a/go.mod +++ b/go.mod @@ -126,6 +126,8 @@ require ( storj.io/drpc v0.0.30 ) +require github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 + require ( github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c // indirect diff --git a/go.sum b/go.sum index 0a6da880d8cd5..8d46e72014a3d 100644 --- a/go.sum +++ b/go.sum @@ -240,6 +240,8 @@ github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0Bsq github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= github.com/bytecodealliance/wasmtime-go v0.35.0 h1:VZjaZ0XOY0qp9TQfh0CQj9zl/AbdeXePVTALy8V1sKs= github.com/bytecodealliance/wasmtime-go v0.35.0/go.mod h1:q320gUxqyI8yB+ZqRuaJOEnGkAnHh6WtJjMaT2CW4wI= +github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 h1:BjkPE3785EwPhhyuFkbINB+2a1xATwk8SNDWnJiD41g= +github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5/go.mod h1:jtAfVaU/2cu1+wdSRPWE2c1N2qeAA3K4RH9pYgqwets= github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/cenkalti/backoff/v4 v4.1.2/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4= From d1496edda09d914adc4fd2b21d3ca0dc76c2632f Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 26 May 2022 15:36:33 -0500 Subject: [PATCH 2/5] feat: add endpoint for netstat web socket --- coderd/coderd.go | 1 + coderd/coderd_test.go | 1 + coderd/workspaceagents.go | 52 +++++++++++++++++++++++++++ coderd/workspaceagents_test.go | 65 ++++++++++++++++++++++++++++++++++ codersdk/workspaceagents.go | 30 ++++++++++++++++ 5 files changed, 149 insertions(+) diff --git a/coderd/coderd.go b/coderd/coderd.go index 4513b2c86360a..999fa7f93eb28 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -285,6 +285,7 @@ func New(options *Options) *API { r.Get("/", api.workspaceAgent) r.Get("/dial", api.workspaceAgentDial) r.Get("/turn", api.workspaceAgentTurn) + r.Get("/netstat", api.workspaceAgentNetstat) r.Get("/pty", api.workspaceAgentPTY) r.Get("/iceservers", api.workspaceAgentICEServers) }) diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index d0aabb7a74758..5a1421fcc050f 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -139,6 +139,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { "GET:/api/v2/workspaceagents/{workspaceagent}": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/{workspaceagent}/dial": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/{workspaceagent}/iceservers": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/{workspaceagent}/netstat": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/{workspaceagent}/pty": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/{workspaceagent}/turn": {NoAuthorize: true}, diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index cfcdea0404683..a40ab147694d2 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -501,3 +501,55 @@ func convertWorkspaceAgent(dbAgent database.WorkspaceAgent, agentUpdateFrequency return workspaceAgent, nil } + +// workspaceAgentNetstat sends listening ports as `agent.NetstatResponse` on an +// interval. +func (api *API) workspaceAgentNetstat(rw http.ResponseWriter, r *http.Request) { + api.websocketWaitMutex.Lock() + api.websocketWaitGroup.Add(1) + api.websocketWaitMutex.Unlock() + defer api.websocketWaitGroup.Done() + + workspaceAgent := httpmw.WorkspaceAgentParam(r) + apiAgent, err := convertWorkspaceAgent(workspaceAgent, api.AgentConnectionUpdateFrequency) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("convert workspace agent: %s", err), + }) + return + } + if apiAgent.Status != codersdk.WorkspaceAgentConnected { + httpapi.Write(rw, http.StatusPreconditionRequired, httpapi.Response{ + Message: fmt.Sprintf("agent must be in the connected state: %s", apiAgent.Status), + }) + return + } + + conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ + Message: fmt.Sprintf("accept websocket: %s", err), + }) + return + } + defer func() { + _ = conn.Close(websocket.StatusNormalClosure, "ended") + }() + wsNetConn := websocket.NetConn(r.Context(), conn, websocket.MessageBinary) + agentConn, err := api.dialWorkspaceAgent(r, workspaceAgent.ID) + if err != nil { + _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial workspace agent: %s", err)) + return + } + defer agentConn.Close() + ptNetConn, err := agentConn.Netstat(r.Context()) + if err != nil { + _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial: %s", err)) + return + } + defer ptNetConn.Close() + + agent.Bicopy(r.Context(), wsNetConn, ptNetConn) +} diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index b14ac43bac4ce..ed8fdc87477ac 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/json" + "fmt" "runtime" "strings" "testing" @@ -264,3 +265,67 @@ func TestWorkspaceAgentPTY(t *testing.T) { expectLine(matchEchoCommand) expectLine(matchEchoOutput) } + +func TestWorkspaceAgentNetstat(t *testing.T) { + t.Parallel() + + client, coderAPI := coderdtest.NewWithAPI(t, nil) + user := coderdtest.CreateFirstUser(t, client) + daemonCloser := coderdtest.NewProvisionerDaemon(t, coderAPI) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + daemonCloser.Close() + + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{ + Logger: slogtest.Make(t, nil), + }) + t.Cleanup(func() { + _ = agentCloser.Close() + }) + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) + + conn, err := client.WorkspaceAgentNetstat(context.Background(), resources[0].Agents[0].ID) + require.NoError(t, err) + defer conn.Close() + + decoder := json.NewDecoder(conn) + + expectNetstat := func() { + var res agent.NetstatResponse + err = decoder.Decode(&res) + require.NoError(t, err) + + if runtime.GOOS == "linux" || runtime.GOOS == "windows" { + require.NotNil(t, res.Ports) + } else { + require.Equal(t, fmt.Sprintf("Port scanning is not supported on %s", runtime.GOOS), res.Error) + } + } + + expectNetstat() +} diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index c634b1de7ea2a..fc3a10e6e1867 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -369,6 +369,36 @@ func (c *Client) WorkspaceAgentReconnectingPTY(ctx context.Context, agentID, rec return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil } +// WorkspaceAgentNetstat sends listening ports as `agent.NetstatResponse` on an +// interval. +func (c *Client) WorkspaceAgentNetstat(ctx context.Context, agentID uuid.UUID) (net.Conn, error) { + serverURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/netstat", agentID)) + if err != nil { + return nil, xerrors.Errorf("parse url: %w", err) + } + jar, err := cookiejar.New(nil) + if err != nil { + return nil, xerrors.Errorf("create cookie jar: %w", err) + } + jar.SetCookies(serverURL, []*http.Cookie{{ + Name: httpmw.SessionTokenKey, + Value: c.SessionToken, + }}) + httpClient := &http.Client{ + Jar: jar, + } + conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{ + HTTPClient: httpClient, + }) + if err != nil { + if res == nil { + return nil, err + } + return nil, readBodyAsError(res) + } + return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil +} + func (c *Client) turnProxyDialer(ctx context.Context, httpClient *http.Client, path string) proxy.Dialer { return turnconn.ProxyDialer(func() (net.Conn, error) { turnURL, err := c.URL.Parse(path) From d50d77f67847ca32e64f4bc3927965e406bd35ef Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 26 May 2022 17:43:54 -0500 Subject: [PATCH 3/5] feat: add port forward dropdown component --- site/src/api/types.ts | 11 ++ .../PortForwardDropdown.stories.tsx | 85 +++++++++ .../PortForwardDropdown.test.tsx | 33 ++++ .../PortForwardDropdown.tsx | 162 ++++++++++++++++++ 4 files changed, 291 insertions(+) create mode 100644 site/src/components/PortForwardDropdown/PortForwardDropdown.stories.tsx create mode 100644 site/src/components/PortForwardDropdown/PortForwardDropdown.test.tsx create mode 100644 site/src/components/PortForwardDropdown/PortForwardDropdown.tsx diff --git a/site/src/api/types.ts b/site/src/api/types.ts index daf4e451ac5e8..8a9a740ee0755 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -14,3 +14,14 @@ export interface ReconnectingPTYRequest { export type WorkspaceBuildTransition = "start" | "stop" | "delete" export type Message = { message: string } + +export interface NetstatPort { + name: string + port: number +} + +export interface NetstatResponse { + readonly ports?: NetstatPort[] + readonly error?: string + readonly took?: number +} diff --git a/site/src/components/PortForwardDropdown/PortForwardDropdown.stories.tsx b/site/src/components/PortForwardDropdown/PortForwardDropdown.stories.tsx new file mode 100644 index 0000000000000..6347f21e54aa6 --- /dev/null +++ b/site/src/components/PortForwardDropdown/PortForwardDropdown.stories.tsx @@ -0,0 +1,85 @@ +import { Story } from "@storybook/react" +import React from "react" +import { PortForwardDropdown, PortForwardDropdownProps } from "./PortForwardDropdown" + +export default { + title: "components/PortForwardDropdown", + component: PortForwardDropdown, +} + +const Template: Story = (args: PortForwardDropdownProps) => ( + +) + +const urlFormatter = (port: number | string): string => { + return `https://${port}--user--workspace.coder.com` +} + +export const Error = Template.bind({}) +Error.args = { + netstat: { + error: "Unable to get listening ports", + }, +} + +export const Loading = Template.bind({}) +Loading.args = {} + +export const None = Template.bind({}) +None.args = { + netstat: { + ports: [], + }, +} + +export const Excluded = Template.bind({}) +Excluded.args = { + netstat: { + ports: [ + { + name: "sshd", + port: 22, + }, + ], + }, +} + +export const Single = Template.bind({}) +Single.args = { + netstat: { + ports: [ + { + name: "code-server", + port: 8080, + }, + ], + }, +} + +export const Multiple = Template.bind({}) +Multiple.args = { + netstat: { + ports: [ + { + name: "code-server", + port: 8080, + }, + { + name: "coder", + port: 8000, + }, + { + name: "coder", + port: 3000, + }, + { + name: "node", + port: 8001, + }, + { + name: "sshd", + port: 22, + }, + ], + }, +} diff --git a/site/src/components/PortForwardDropdown/PortForwardDropdown.test.tsx b/site/src/components/PortForwardDropdown/PortForwardDropdown.test.tsx new file mode 100644 index 0000000000000..eb55ce64ad0c0 --- /dev/null +++ b/site/src/components/PortForwardDropdown/PortForwardDropdown.test.tsx @@ -0,0 +1,33 @@ +import { screen } from "@testing-library/react" +import React from "react" +import { render } from "../../testHelpers/renderHelpers" +import { Language, PortForwardDropdown } from "./PortForwardDropdown" + +const urlFormatter = (port: number | string): string => { + return `https://${port}--user--workspace.coder.com` +} + +describe("PortForwardDropdown", () => { + it("skips known non-http ports", async () => { + // When + const netstat = { + ports: [ + { + name: "sshd", + port: 22, + }, + { + name: "code-server", + port: 8080, + }, + ], + } + render() + + // Then + let portNameElement = await screen.queryByText(Language.portListing(22, "sshd")) + expect(portNameElement).toBeNull() + portNameElement = await screen.findByText(Language.portListing(8080, "code-server")) + expect(portNameElement).toBeDefined() + }) +}) diff --git a/site/src/components/PortForwardDropdown/PortForwardDropdown.tsx b/site/src/components/PortForwardDropdown/PortForwardDropdown.tsx new file mode 100644 index 0000000000000..114ae7d097f5a --- /dev/null +++ b/site/src/components/PortForwardDropdown/PortForwardDropdown.tsx @@ -0,0 +1,162 @@ +import Button from "@material-ui/core/Button" +import CircularProgress from "@material-ui/core/CircularProgress" +import Link from "@material-ui/core/Link" +import Popover, { PopoverProps } from "@material-ui/core/Popover" +import { makeStyles } from "@material-ui/core/styles" +import TextField from "@material-ui/core/TextField" +import Typography from "@material-ui/core/Typography" +import OpenInNewIcon from "@material-ui/icons/OpenInNew" +import Alert from "@material-ui/lab/Alert" +import React, { useState } from "react" +import { NetstatPort, NetstatResponse } from "../../api/types" +import { CodeExample } from "../CodeExample/CodeExample" +import { Stack } from "../Stack/Stack" + +export const Language = { + title: "Port forward", + automaticPortText: + "Here are the applications we detected are listening on ports in this resource. Click to open them in a new tab.", + manualPortText: + "You can manually port forward this resource by typing the port and your username in the URL like below.", + formPortText: "Or you can use the following form to open the port in a new tab.", + portListing: (port: number, name: string): string => `${port} (${name})`, + portInputLabel: "Port", + formButtonText: "Open URL", +} + +export type PortForwardDropdownProps = Pick & { + /** + * The netstat response to render. Undefined is taken to mean "loading". + */ + netstat?: NetstatResponse + /** + * Given a port return the URL for accessing that port. + */ + urlFormatter: (port: number | string) => string +} + +const portFilter = ({ port }: NetstatPort): boolean => { + if (port === 443 || port === 80) { + // These are standard HTTP ports. + return true + } else if (port <= 1023) { + // Assume a privileged port is probably not being used for HTTP. This will + // catch things like sshd. + return false + } + return true +} + +export const PortForwardDropdown: React.FC = ({ netstat, open, urlFormatter, ...rest }) => { + const styles = useStyles() + const [port, setPort] = useState(3000) + const ports = netstat?.ports?.filter(portFilter) + + return ( + +
+ + {Language.title} + + + {Language.automaticPortText} + + {typeof netstat === "undefined" && ( +
+ +
+ )} + + {netstat?.error && {netstat.error}} + + {ports && ports.length > 0 && ( +
+ {ports.map(({ port, name }) => ( + + + {Language.portListing(port, name)} + + ))} +
+ )} + + {ports && ports.length === 0 && No HTTP ports were detected.} + + {Language.manualPortText} + + + + {Language.formPortText} + + + setPort(event.target.value)} + value={port} + autoFocus + label={Language.portInputLabel} + variant="outlined" + /> + + +
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + root: { + padding: `${theme.spacing(3)}px`, + maxWidth: 500, + }, + title: { + fontWeight: 600, + }, + ports: { + margin: `${theme.spacing(2)}px 0`, + }, + portLink: { + alignItems: "center", + color: theme.palette.text.secondary, + display: "flex", + + "& svg": { + width: 16, + height: 16, + marginRight: theme.spacing(1.5), + }, + }, + loader: { + margin: `${theme.spacing(2)}px 0`, + textAlign: "center", + }, + paragraph: { + color: theme.palette.text.secondary, + margin: `${theme.spacing(2)}px 0`, + }, + textField: { + flex: 1, + margin: 0, + }, + linkButton: { + color: "inherit", + flex: 1, + + "&:hover": { + textDecoration: "none", + }, + }, +})) From c2a185150b2a02af07b89afef423f68d6c2718cb Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 26 May 2022 20:09:41 -0500 Subject: [PATCH 4/5] feat: add utility for extracting error message --- site/src/util/error.test.ts | 20 ++++++++++++++++++++ site/src/util/error.ts | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 site/src/util/error.test.ts create mode 100644 site/src/util/error.ts diff --git a/site/src/util/error.test.ts b/site/src/util/error.test.ts new file mode 100644 index 0000000000000..8a1a3ab98bf00 --- /dev/null +++ b/site/src/util/error.test.ts @@ -0,0 +1,20 @@ +import { errorString, Language } from "./error" + +describe("error", () => { + describe("errorStr", () => { + it("returns message if error", () => { + expect(errorString(new Error("foobar"))).toBe("foobar") + }) + it("returns message if string", () => { + expect(errorString("bazzle")).toBe("bazzle") + }) + it("returns message if undefined or empty", () => { + expect(errorString(undefined)).toBe(Language.noError) + expect(errorString("")).toBe(Language.noError) + }) + it("returns message if anything else", () => { + expect(errorString({ qux: "fred" })).toBe(Language.unexpectedError) + expect(errorString({ qux: 1 })).toBe(Language.unexpectedError) + }) + }) +}) diff --git a/site/src/util/error.ts b/site/src/util/error.ts new file mode 100644 index 0000000000000..ac116f61b6832 --- /dev/null +++ b/site/src/util/error.ts @@ -0,0 +1,20 @@ +export const Language = { + unexpectedError: "Unexpected error: see console for details", + noError: "No error provided", +} + +/** + * Best effort to get a string from what could be an error or anything else. + */ +export const errorString = (error: Error | unknown): string | undefined => { + if (error instanceof Error) { + return error.message + } else if (typeof error === "string") { + return error || Language.noError + } else if (typeof error !== "undefined") { + console.warn(error) + return Language.unexpectedError + } else { + return Language.noError + } +} From 7a5eace5e3f7df77fd89e9a4f9277a4d8c6d943c Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 26 May 2022 21:07:59 -0500 Subject: [PATCH 5/5] feat: hook up port dropdown to workspace page --- site/src/components/Resources/Resources.tsx | 44 ++++-- site/src/components/Workspace/Workspace.tsx | 9 +- .../src/pages/WorkspacePage/WorkspacePage.tsx | 22 ++- site/src/xServices/agent/agentXService.ts | 133 ++++++++++++++++++ 4 files changed, 197 insertions(+), 11 deletions(-) create mode 100644 site/src/xServices/agent/agentXService.ts diff --git a/site/src/components/Resources/Resources.tsx b/site/src/components/Resources/Resources.tsx index 1a4c6aa24e74d..1b4f872b943d9 100644 --- a/site/src/components/Resources/Resources.tsx +++ b/site/src/components/Resources/Resources.tsx @@ -1,18 +1,21 @@ +import Button from "@material-ui/core/Button" import { makeStyles, Theme } from "@material-ui/core/styles" import Table from "@material-ui/core/Table" import TableBody from "@material-ui/core/TableBody" import TableCell from "@material-ui/core/TableCell" import TableHead from "@material-ui/core/TableHead" import TableRow from "@material-ui/core/TableRow" +import CompareArrowsIcon from "@material-ui/icons/CompareArrows" import useTheme from "@material-ui/styles/useTheme" import React from "react" -import { Workspace, WorkspaceResource } from "../../api/typesGenerated" +import { Workspace, WorkspaceAgent, WorkspaceResource } from "../../api/typesGenerated" import { getDisplayAgentStatus } from "../../util/workspace" import { TableHeaderRow } from "../TableHeaders/TableHeaders" import { TerminalLink } from "../TerminalLink/TerminalLink" import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection" const Language = { + portForwardLabel: "Port forward", resources: "Resources", resourceLabel: "Resource", agentsLabel: "Agents", @@ -22,12 +25,18 @@ const Language = { } interface ResourcesProps { + handleOpenPortForward: (agent: WorkspaceAgent, anchorEl: HTMLElement) => void resources?: WorkspaceResource[] getResourcesError?: Error workspace: Workspace } -export const Resources: React.FC = ({ resources, getResourcesError, workspace }) => { +export const Resources: React.FC = ({ + handleOpenPortForward, + resources, + getResourcesError, + workspace, +}) => { const styles = useStyles() const theme: Theme = useTheme() @@ -89,12 +98,22 @@ export const Resources: React.FC = ({ resources, getResourcesErr {agent.status === "connected" && ( - + <> + + + )} @@ -134,9 +153,16 @@ const useStyles = makeStyles((theme) => ({ }, accessLink: { + alignItems: "center", color: theme.palette.text.secondary, display: "flex", - alignItems: "center", + border: 0, + padding: 0, + + "&:hover": { + backgroundColor: "unset", + textDecoration: "underline", + }, "& svg": { width: 16, diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index ec9343d5e18e7..f4d76051cae01 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -17,6 +17,7 @@ export interface WorkspaceProps { handleStop: () => void handleUpdate: () => void handleCancel: () => void + handleOpenPortForward: (agent: TypesGen.WorkspaceAgent, anchorEl: HTMLElement) => void workspace: TypesGen.Workspace resources?: TypesGen.WorkspaceResource[] getResourcesError?: Error @@ -31,6 +32,7 @@ export const Workspace: React.FC = ({ handleStop, handleUpdate, handleCancel, + handleOpenPortForward, workspace, resources, getResourcesError, @@ -68,7 +70,12 @@ export const Workspace: React.FC = ({ - + diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index dcb0068d4301c..eb5cb141e737a 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -1,12 +1,14 @@ import { useMachine } from "@xstate/react" -import React, { useEffect } from "react" +import React, { useEffect, useState } from "react" import { useParams } from "react-router-dom" import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary" import { FullScreenLoader } from "../../components/Loader/FullScreenLoader" import { Margins } from "../../components/Margins/Margins" +import { PortForwardDropdown } from "../../components/PortForwardDropdown/PortForwardDropdown" import { Stack } from "../../components/Stack/Stack" import { Workspace } from "../../components/Workspace/Workspace" import { firstOrItem } from "../../util/array" +import { agentMachine } from "../../xServices/agent/agentXService" import { workspaceMachine } from "../../xServices/workspace/workspaceXService" export const WorkspacePage: React.FC = () => { @@ -16,6 +18,10 @@ export const WorkspacePage: React.FC = () => { const [workspaceState, workspaceSend] = useMachine(workspaceMachine) const { workspace, resources, getWorkspaceError, getResourcesError, builds } = workspaceState.context + const [agentState, agentSend] = useMachine(agentMachine) + const { netstat } = agentState.context + const [portForwardAnchorEl, setPortForwardAnchorEl] = useState() + /** * Get workspace, template, and organization on mount and whenever workspaceId changes. * workspaceSend should not change. @@ -38,10 +44,24 @@ export const WorkspacePage: React.FC = () => { handleStop={() => workspaceSend("STOP")} handleUpdate={() => workspaceSend("UPDATE")} handleCancel={() => workspaceSend("CANCEL")} + handleOpenPortForward={(agent, anchorEl) => { + agentSend("CONNECT", { agentId: agent.id }) + setPortForwardAnchorEl(anchorEl) + }} resources={resources} getResourcesError={getResourcesError instanceof Error ? getResourcesError : undefined} builds={builds} /> + { + agentSend("DISCONNECT") + setPortForwardAnchorEl(undefined) + }} + urlFormatter={(port) => `${location.protocol}//${port}--${workspace.owner_name}--${location.host}`} + /> ) diff --git a/site/src/xServices/agent/agentXService.ts b/site/src/xServices/agent/agentXService.ts new file mode 100644 index 0000000000000..6b525ff9d383e --- /dev/null +++ b/site/src/xServices/agent/agentXService.ts @@ -0,0 +1,133 @@ +import { assign, createMachine } from "xstate" +import * as Types from "../../api/types" +import { errorString } from "../../util/error" + +export interface AgentContext { + agentId?: string + netstat?: Types.NetstatResponse + websocket?: WebSocket +} + +export type AgentEvent = + | { type: "CONNECT"; agentId: string } + | { type: "STAT"; data: Types.NetstatResponse } + | { type: "DISCONNECT" } + +export const agentMachine = createMachine( + { + tsTypes: {} as import("./agentXService.typegen").Typegen0, + schema: { + context: {} as AgentContext, + events: {} as AgentEvent, + services: {} as { + connect: { + data: WebSocket + } + }, + }, + id: "agentState", + initial: "disconnected", + states: { + connecting: { + invoke: { + src: "connect", + id: "connect", + onDone: [ + { + actions: ["assignWebsocket", "clearNetstat"], + target: "connected", + }, + ], + onError: [ + { + actions: "assignWebsocketError", + target: "disconnected", + }, + ], + }, + }, + connected: { + on: { + STAT: { + actions: "assignNetstat", + }, + DISCONNECT: { + actions: ["disconnect", "clearNetstat"], + target: "disconnected", + }, + }, + }, + disconnected: { + on: { + CONNECT: { + actions: "assignConnection", + target: "connecting", + }, + }, + }, + }, + }, + { + services: { + connect: (context) => (send) => { + return new Promise((resolve, reject) => { + if (!context.agentId) { + return reject("agent ID is not set") + } + const proto = location.protocol === "https:" ? "wss:" : "ws:" + const socket = new WebSocket(`${proto}//${location.host}/api/v2/workspaceagents/${context.agentId}/netstat`) + socket.binaryType = "arraybuffer" + socket.addEventListener("open", () => { + resolve(socket) + }) + socket.addEventListener("error", (error) => { + reject(error) + }) + socket.addEventListener("close", () => { + send({ + type: "DISCONNECT", + }) + }) + socket.addEventListener("message", (event) => { + try { + send({ + type: "STAT", + data: JSON.parse(new TextDecoder().decode(event.data)), + }) + } catch (error) { + send({ + type: "STAT", + data: { + error: errorString(error), + }, + }) + } + }) + }) + }, + }, + actions: { + assignConnection: assign((context, event) => ({ + ...context, + agentId: event.agentId, + })), + assignWebsocket: assign({ + websocket: (_, event) => event.data, + }), + assignWebsocketError: assign({ + netstat: (_, event) => ({ error: errorString(event.data) }), + }), + clearNetstat: assign((context: AgentContext) => ({ + ...context, + netstat: undefined, + })), + assignNetstat: assign({ + netstat: (_, event) => event.data, + }), + disconnect: (context: AgentContext) => { + // Code 1000 is a successful exit! + context.websocket?.close(1000) + }, + }, + }, +)