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) + }, + }, + }, +) pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy