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