feat: show listening ports in port forward popup (#4389)

* feat: show listening ports in port forward popup

* Move fetch logic to a machine

* feat: don't show  app ports and common non-HTTP ports

Co-authored-by: Bruno Quaresma <bruno@coder.com>
This commit is contained in:
Dean Sheather
2022-10-12 01:10:02 +10:00
committed by GitHub
co-authored by Bruno Quaresma
parent a64731eea5
commit b1a095e486
7 changed files with 458 additions and 96 deletions
+54
View File
@@ -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)
}
+202 -82
View File
@@ -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,
// 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
}
}
)
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)
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, agentID)
require.NoError(t, err)
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
}
}
expected[port.Port] = true
}
}
for port, found := range expected {
if !found {
t.Fatalf("expected to find TCP port %d in response", port)
if !sawCoderdPort {
t.Fatalf("expected to find TCP port (coderd port) %d in response", coderdPort)
}
}
// 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)
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)
}
}
})
})
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)
})
+72
View File
@@ -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
+9
View File
@@ -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<TypesGen.ListeningPortsResponse> => {
const response = await axios.get(
`/api/v2/workspaceagents/${agentID}/listening-ports`,
)
return response.data
}
@@ -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<PortForwardButtonProps> = (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 (
<Stack direction="column" spacing={1}>
<>
<HelpTooltipText>
Access ports running on the agent with the{" "}
<strong>port, agent name, workspace name</strong> and{" "}
<strong>your username</strong> URL schema, as shown below.
</HelpTooltipText>
<CodeExample code={urlExample} />
<CodeExample code={urlExample} className={styles.code} />
<HelpTooltipText>
Use the form to open applications in a new tab.
</HelpTooltipText>
<Stack direction="row" spacing={1} alignItems="center">
<Stack
direction="row"
spacing={1}
alignItems="center"
className={styles.form}
>
<TextField
label="Port"
type="number"
@@ -64,29 +78,66 @@ const EnabledView: React.FC<PortForwardButtonProps> = (props) => {
</Link>
</Stack>
<Maybe condition={Boolean(ports && ports.length > 0)}>
<HelpTooltipText>
{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 (
<Fragment key={i}>
{i > 0 && <span style={{ margin: "0 0.6em" }}>&middot;</span>}
<Link href={url} target="_blank" rel="noreferrer">
{label}
</Link>
</Fragment>
)
})}
</HelpTooltipText>
</Maybe>
<HelpTooltipLinksGroup>
<HelpTooltipLink href="https://coder.com/docs/coder-oss/latest/networking/port-forwarding#dashboard">
Learn more about port forward
Learn more about web port forwarding
</HelpTooltipLink>
</HelpTooltipLinksGroup>
</Stack>
</>
)
}
const DisabledView: React.FC<PortForwardButtonProps> = () => {
const DisabledView: React.FC<PortForwardButtonProps> = ({
workspaceName,
agentName,
}) => {
const cliExample = `coder port-forward ${workspaceName}.${agentName} --tcp 3000`
const styles = useStyles()
return (
<Stack direction="column" spacing={1}>
<>
<HelpTooltipText>
<strong>Your deployment does not have port forward enabled.</strong> See
the docs for more details.
<strong>
Your deployment does not have web port forwarding enabled.
</strong>{" "}
See the docs for more details.
</HelpTooltipText>
<HelpTooltipText>
You can use the Coder CLI to forward ports from your workspace to your
local machine, as shown below.
</HelpTooltipText>
<CodeExample code={cliExample} className={styles.code} />
<HelpTooltipLinksGroup>
<HelpTooltipLink href="https://coder.com/docs/coder-oss/latest/networking/port-forwarding#dashboard">
Learn more about port forward
Learn more about web port forwarding
</HelpTooltipLink>
</HelpTooltipLinksGroup>
</Stack>
</>
)
}
@@ -128,6 +179,7 @@ export const PortForwardButton: React.FC<PortForwardButtonProps> = (props) => {
horizontal: "left",
}}
>
<HelpTooltipTitle>Port forward</HelpTooltipTitle>
<ChooseOne>
<Cond condition={host !== ""}>
<EnabledView {...props} />
@@ -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),
},
}))
@@ -179,6 +179,7 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
<PortForwardButton
host={applicationsHost}
workspaceName={workspace.name}
agentId={agent.id}
agentName={agent.name}
username={workspace.owner_name}
/>
@@ -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,
}),
},
},
)