diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 41d39938d9e28..f854fc1c29aca 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "net/netip" + "net/url" "reflect" "strconv" "strings" @@ -262,6 +263,59 @@ func (api *API) workspaceAgentListeningPorts(rw http.ResponseWriter, r *http.Req return } + // Get a list of ports that are in-use by applications. + apps, err := api.Database.GetWorkspaceAppsByAgentID(ctx, workspaceAgent.ID) + if xerrors.Is(err, sql.ErrNoRows) { + apps = []database.WorkspaceApp{} + err = nil + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace apps.", + Detail: err.Error(), + }) + return + } + appPorts := make(map[uint16]struct{}, len(apps)) + for _, app := range apps { + if !app.Url.Valid || app.Url.String == "" { + continue + } + u, err := url.Parse(app.Url.String) + if err != nil { + continue + } + port := u.Port() + if port == "" { + continue + } + portNum, err := strconv.Atoi(port) + if err != nil { + continue + } + if portNum < 1 || portNum > 65535 { + continue + } + appPorts[uint16(portNum)] = struct{}{} + } + + // Filter out ports that are globally blocked, in-use by applications, or + // common non-HTTP ports such as databases, FTP, SSH, etc. + filteredPorts := make([]codersdk.ListeningPort, 0, len(portsResponse.Ports)) + for _, port := range portsResponse.Ports { + if port.Port < uint16(codersdk.MinimumListeningPort) { + continue + } + if _, ok := appPorts[port.Port]; ok { + continue + } + if _, ok := codersdk.IgnoredListeningPorts[port.Port]; ok { + continue + } + filteredPorts = append(filteredPorts, port) + } + + portsResponse.Ports = filteredPorts httpapi.Write(ctx, rw, http.StatusOK, portsResponse) } diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index e4bbe42a5af6d..6bd569dde9f71 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -4,6 +4,7 @@ import ( "bufio" "context" "encoding/json" + "fmt" "net" "runtime" "strconv" @@ -367,50 +368,124 @@ func TestWorkspaceAgentPTY(t *testing.T) { func TestWorkspaceAgentListeningPorts(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, - }) - coderdPort, err := strconv.Atoi(client.URL.Port()) - require.NoError(t, err) - user := coderdtest.CreateFirstUser(t, client) - 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, - }, + setup := func(t *testing.T, apps []*proto.App) (*codersdk.Client, uint16, uuid.UUID) { + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + coderdPort, err := strconv.Atoi(client.URL.Port()) + require.NoError(t, err) + + user := coderdtest.CreateFirstUser(t, client) + 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, + }, + Apps: apps, + }}, }}, - }}, + }, }, - }, - }}, - }) - 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) + }}, + }) + 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) - agentClient := codersdk.New(client.URL) - agentClient.SessionToken = authToken - agentCloser := agent.New(agent.Options{ - FetchMetadata: agentClient.WorkspaceAgentMetadata, - CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet, - Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug), - }) - t.Cleanup(func() { - _ = agentCloser.Close() - }) - resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + agentCloser := agent.New(agent.Options{ + FetchMetadata: agentClient.WorkspaceAgentMetadata, + CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet, + Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug), + }) + t.Cleanup(func() { + _ = agentCloser.Close() + }) + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + + return client, uint16(coderdPort), resources[0].Agents[0].ID + } + + willFilterPort := func(port int) bool { + if port < codersdk.MinimumListeningPort || port > 65535 { + return true + } + if _, ok := codersdk.IgnoredListeningPorts[uint16(port)]; ok { + return true + } + + return false + } + + generateUnfilteredPort := func(t *testing.T) (net.Listener, uint16) { + var ( + l net.Listener + port uint16 + ) + require.Eventually(t, func() bool { + var err error + l, err = net.Listen("tcp", "localhost:0") + if err != nil { + return false + } + tcpAddr, _ := l.Addr().(*net.TCPAddr) + if willFilterPort(tcpAddr.Port) { + _ = l.Close() + return false + } + t.Cleanup(func() { + _ = l.Close() + }) + + port = uint16(tcpAddr.Port) + return true + }, testutil.WaitShort, testutil.IntervalFast) + + return l, port + } + + generateFilteredPort := func(t *testing.T) (net.Listener, uint16) { + var ( + l net.Listener + port uint16 + ) + require.Eventually(t, func() bool { + for ignoredPort := range codersdk.IgnoredListeningPorts { + if ignoredPort < 1024 || ignoredPort == 5432 { + continue + } + + var err error + l, err = net.Listen("tcp", fmt.Sprintf("localhost:%d", ignoredPort)) + if err != nil { + continue + } + t.Cleanup(func() { + _ = l.Close() + }) + + port = ignoredPort + return true + } + + return false + }, testutil.WaitShort, testutil.IntervalFast) + + return l, port + } t.Run("LinuxAndWindows", func(t *testing.T) { t.Parallel() @@ -419,55 +494,98 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { return } - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + t.Run("OK", func(t *testing.T) { + t.Parallel() - // Create a TCP listener on a random port that we expect to see in the - // response. - l, err := net.Listen("tcp", "localhost:0") - require.NoError(t, err) - defer l.Close() - tcpAddr, _ := l.Addr().(*net.TCPAddr) + client, coderdPort, agentID := setup(t, nil) - // List ports and ensure that the port we expect to see is there. - res, err := client.WorkspaceAgentListeningPorts(ctx, resources[0].Agents[0].ID) - require.NoError(t, err) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() - var ( - expected = map[uint16]bool{ - // expect the listener we made - uint16(tcpAddr.Port): false, - // expect the coderdtest server - uint16(coderdPort): false, - } - ) - for _, port := range res.Ports { - if port.Network == codersdk.ListeningPortNetworkTCP { - if val, ok := expected[port.Port]; ok { - if val { - t.Fatalf("expected to find TCP port %d only once in response", port.Port) + // Generate a random unfiltered port. + l, lPort := generateUnfilteredPort(t) + + // List ports and ensure that the port we expect to see is there. + res, err := client.WorkspaceAgentListeningPorts(ctx, agentID) + require.NoError(t, err) + + var ( + expected = map[uint16]bool{ + // expect the listener we made + lPort: false, + // expect the coderdtest server + coderdPort: false, + } + ) + for _, port := range res.Ports { + if port.Network == codersdk.ListeningPortNetworkTCP { + if val, ok := expected[port.Port]; ok { + if val { + t.Fatalf("expected to find TCP port %d only once in response", port.Port) + } } + expected[port.Port] = true } - expected[port.Port] = true } - } - for port, found := range expected { - if !found { - t.Fatalf("expected to find TCP port %d in response", port) + for port, found := range expected { + if !found { + t.Fatalf("expected to find TCP port %d in response", port) + } } - } - // Close the listener and check that the port is no longer in the response. - require.NoError(t, l.Close()) - time.Sleep(2 * time.Second) // avoid cache - res, err = client.WorkspaceAgentListeningPorts(ctx, resources[0].Agents[0].ID) - require.NoError(t, err) + // Close the listener and check that the port is no longer in the response. + require.NoError(t, l.Close()) + time.Sleep(2 * time.Second) // avoid cache + res, err = client.WorkspaceAgentListeningPorts(ctx, agentID) + require.NoError(t, err) - for _, port := range res.Ports { - if port.Network == codersdk.ListeningPortNetworkTCP && port.Port == uint16(tcpAddr.Port) { - t.Fatalf("expected to not find TCP port %d in response", tcpAddr.Port) + for _, port := range res.Ports { + if port.Network == codersdk.ListeningPortNetworkTCP && port.Port == lPort { + t.Fatalf("expected to not find TCP port %d in response", lPort) + } } - } + }) + + t.Run("Filter", func(t *testing.T) { + t.Parallel() + + // Generate an unfiltered port that we will create an app for and + // should not exist in the response. + _, appLPort := generateUnfilteredPort(t) + app := &proto.App{ + Name: "test-app", + Url: fmt.Sprintf("http://localhost:%d", appLPort), + } + + // Generate a filtered port that should not exist in the response. + _, filteredLPort := generateFilteredPort(t) + + client, coderdPort, agentID := setup(t, []*proto.App{app}) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + res, err := client.WorkspaceAgentListeningPorts(ctx, agentID) + require.NoError(t, err) + + sawCoderdPort := false + for _, port := range res.Ports { + if port.Network == codersdk.ListeningPortNetworkTCP { + if port.Port == appLPort { + t.Fatalf("expected to not find TCP port (app port) %d in response", appLPort) + } + if port.Port == filteredLPort { + t.Fatalf("expected to not find TCP port (filtered port) %d in response", filteredLPort) + } + if port.Port == coderdPort { + sawCoderdPort = true + } + } + } + if !sawCoderdPort { + t.Fatalf("expected to find TCP port (coderd port) %d in response", coderdPort) + } + }) }) t.Run("Darwin", func(t *testing.T) { @@ -477,6 +595,8 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { return } + client, _, agentID := setup(t, nil) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -486,7 +606,7 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { defer l.Close() // List ports and ensure that the list is empty because we're on darwin. - res, err := client.WorkspaceAgentListeningPorts(ctx, resources[0].Agents[0].ID) + res, err := client.WorkspaceAgentListeningPorts(ctx, agentID) require.NoError(t, err) require.Len(t, res.Ports, 0) }) diff --git a/codersdk/agentconn.go b/codersdk/agentconn.go index 02d9f89d1a407..b11c440ce3a65 100644 --- a/codersdk/agentconn.go +++ b/codersdk/agentconn.go @@ -9,7 +9,9 @@ import ( "net" "net/http" "net/netip" + "os" "strconv" + "strings" "time" "golang.org/x/crypto/ssh" @@ -45,6 +47,76 @@ var ( MinimumListeningPort = 9 ) +// IgnoredListeningPorts contains a list of ports in the global ignore list. +// This list contains common TCP ports that are not HTTP servers, such as +// databases, SSH, FTP, etc. +// +// This is implemented as a map for fast lookup. +var IgnoredListeningPorts = map[uint16]struct{}{ + 0: {}, + // Ports 1-8 are reserved for future use by the Coder agent. + 1: {}, + 2: {}, + 3: {}, + 4: {}, + 5: {}, + 6: {}, + 7: {}, + 8: {}, + // ftp + 20: {}, + 21: {}, + // ssh + 22: {}, + // telnet + 23: {}, + // smtp + 25: {}, + // dns over TCP + 53: {}, + // pop3 + 110: {}, + // imap + 143: {}, + // bgp + 179: {}, + // ldap + 389: {}, + 636: {}, + // smtps + 465: {}, + // smtp + 587: {}, + // ftps + 989: {}, + 990: {}, + // imaps + 993: {}, + // pop3s + 995: {}, + // mysql + 3306: {}, + // rdp + 3389: {}, + // postgres + 5432: {}, + // mongodb + 27017: {}, + 27018: {}, + 27019: {}, + 28017: {}, +} + +func init() { + // Add a thousand more ports to the ignore list during tests so it's easier + // to find an available port. + if strings.HasSuffix(os.Args[0], ".test") { + for i := 63000; i < 64000; i++ { + IgnoredListeningPorts[uint16(i)] = struct{}{} + } + } +} + // ReconnectingPTYRequest is sent from the client to the server // to pipe data to a PTY. // @typescript-ignore ReconnectingPTYRequest diff --git a/site/src/api/api.ts b/site/src/api/api.ts index c77ef154ea6a2..2e60a88b8469c 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -631,3 +631,12 @@ export const getWorkspaceQuota = async ( const response = await axios.get(`/api/v2/workspace-quota/${userID}`) return response.data } + +export const getAgentListeningPorts = async ( + agentID: string, +): Promise => { + const response = await axios.get( + `/api/v2/workspaceagents/${agentID}/listening-ports`, + ) + return response.data +} diff --git a/site/src/components/PortForwardButton/PortForwardButton.tsx b/site/src/components/PortForwardButton/PortForwardButton.tsx index 9c1898630dab9..eef1d65b27d0d 100644 --- a/site/src/components/PortForwardButton/PortForwardButton.tsx +++ b/site/src/components/PortForwardButton/PortForwardButton.tsx @@ -6,44 +6,58 @@ import TextField from "@material-ui/core/TextField" import OpenInNewOutlined from "@material-ui/icons/OpenInNewOutlined" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" import { Stack } from "components/Stack/Stack" -import { useRef, useState } from "react" +import { useRef, useState, Fragment } from "react" import { colors } from "theme/colors" import { CodeExample } from "../CodeExample/CodeExample" import { HelpTooltipLink, HelpTooltipLinksGroup, HelpTooltipText, + HelpTooltipTitle, } from "../Tooltips/HelpTooltip" +import { Maybe } from "components/Conditionals/Maybe" +import { useMachine } from "@xstate/react" +import { portForwardMachine } from "xServices/portForward/portForwardXService" export interface PortForwardButtonProps { host: string username: string workspaceName: string agentName: string + agentId: string } const EnabledView: React.FC = (props) => { - const { host, workspaceName, agentName, username } = props + const { host, workspaceName, agentName, agentId, username } = props const styles = useStyles() const [port, setPort] = useState("3000") const { location } = window const urlExample = `${location.protocol}//${port}--${agentName}--${workspaceName}--${username}.${host}` + const [state] = useMachine(portForwardMachine, { + context: { agentId: agentId }, + }) + const ports = state.context.listeningPorts?.ports return ( - + <> Access ports running on the agent with the{" "} port, agent name, workspace name and{" "} your username URL schema, as shown below. - + Use the form to open applications in a new tab. - + = (props) => { + 0)}> + + {ports && + ports.map((p, i) => { + const url = `${location.protocol}//${p.port}--${agentName}--${workspaceName}--${username}.${host}` + let label = `${p.port}` + if (p.process_name) { + label = `${p.process_name} - ${p.port}` + } + + return ( + + {i > 0 && ·} + + {label} + + + ) + })} + + + - Learn more about port forward + Learn more about web port forwarding - + ) } -const DisabledView: React.FC = () => { +const DisabledView: React.FC = ({ + workspaceName, + agentName, +}) => { + const cliExample = `coder port-forward ${workspaceName}.${agentName} --tcp 3000` + const styles = useStyles() + return ( - + <> - Your deployment does not have port forward enabled. See - the docs for more details. + + Your deployment does not have web port forwarding enabled. + {" "} + See the docs for more details. + + You can use the Coder CLI to forward ports from your workspace to your + local machine, as shown below. + + + + - Learn more about port forward + Learn more about web port forwarding - + ) } @@ -128,6 +179,7 @@ export const PortForwardButton: React.FC = (props) => { horizontal: "left", }} > + Port forward @@ -146,7 +198,7 @@ const useStyles = makeStyles((theme) => ({ padding: `${theme.spacing(2.5)}px ${theme.spacing(3.5)}px ${theme.spacing( 3.5, )}px`, - width: theme.spacing(46), + width: theme.spacing(52), color: theme.palette.text.secondary, marginTop: theme.spacing(0.25), }, @@ -161,4 +213,12 @@ const useStyles = makeStyles((theme) => ({ borderColor: colors.gray[10], }, }, + + code: { + margin: theme.spacing(2, 0), + }, + + form: { + margin: theme.spacing(1.5, 0, 0), + }, })) diff --git a/site/src/components/Resources/Resources.tsx b/site/src/components/Resources/Resources.tsx index 27f187a23f4ec..f8833fdc11a8c 100644 --- a/site/src/components/Resources/Resources.tsx +++ b/site/src/components/Resources/Resources.tsx @@ -179,6 +179,7 @@ export const Resources: FC> = ({ diff --git a/site/src/xServices/portForward/portForwardXService.ts b/site/src/xServices/portForward/portForwardXService.ts new file mode 100644 index 0000000000000..27fa2bfd4cc56 --- /dev/null +++ b/site/src/xServices/portForward/portForwardXService.ts @@ -0,0 +1,46 @@ +import { getAgentListeningPorts } from "api/api" +import { ListeningPortsResponse } from "api/typesGenerated" +import { createMachine, assign } from "xstate" + +export const portForwardMachine = createMachine( + { + id: "portForwardMachine", + schema: { + context: {} as { + agentId: string + listeningPorts?: ListeningPortsResponse + }, + services: {} as { + getListeningPorts: { + data: ListeningPortsResponse + } + }, + }, + tsTypes: {} as import("./portForwardXService.typegen").Typegen0, + initial: "loading", + states: { + loading: { + invoke: { + src: "getListeningPorts", + onDone: { + target: "success", + actions: ["assignListeningPorts"], + }, + }, + }, + success: { + type: "final", + }, + }, + }, + { + services: { + getListeningPorts: ({ agentId }) => getAgentListeningPorts(agentId), + }, + actions: { + assignListeningPorts: assign({ + listeningPorts: (_, { data }) => data, + }), + }, + }, +)