From ed21d4aed42232f82f8dd3e3159e3855e0cf0c06 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 29 Sep 2022 11:44:44 +0000 Subject: [PATCH 01/12] feat: add endpoint to get listening ports in agent --- agent/agent.go | 27 +++++++++ agent/statsendpoint.go | 95 +++++++++++++++++++++++++++++++ coderd/coderd.go | 1 + coderd/workspaceagents.go | 31 ++++++++++ coderd/workspaceagents_test.go | 100 +++++++++++++++++++++++++++++++++ coderd/workspaceapps.go | 22 ++++++++ codersdk/agentconn.go | 67 ++++++++++++++++++++++ codersdk/client.go | 2 +- codersdk/workspaceagents.go | 15 +++++ go.mod | 2 + go.sum | 2 + 11 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 agent/statsendpoint.go diff --git a/agent/agent.go b/agent/agent.go index bb55c94eecde0..e010e9614082b 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "net" + "net/http" "net/netip" "os" "os/exec" @@ -206,6 +207,7 @@ func (a *agent) runTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) { go a.sshServer.HandleConn(a.stats.wrapConn(conn)) } }() + reconnectingPTYListener, err := a.network.Listen("tcp", ":"+strconv.Itoa(codersdk.TailnetReconnectingPTYPort)) if err != nil { a.logger.Critical(ctx, "listen for reconnecting pty", slog.Error(err)) @@ -240,6 +242,7 @@ func (a *agent) runTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) { go a.handleReconnectingPTY(ctx, msg, conn) } }() + speedtestListener, err := a.network.Listen("tcp", ":"+strconv.Itoa(codersdk.TailnetSpeedtestPort)) if err != nil { a.logger.Critical(ctx, "listen for speedtest", slog.Error(err)) @@ -261,6 +264,30 @@ func (a *agent) runTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) { }() } }() + + statisticsListener, err := a.network.Listen("tcp", ":"+strconv.Itoa(codersdk.TailnetStatisticsPort)) + if err != nil { + a.logger.Critical(ctx, "listen for statistics", slog.Error(err)) + return + } + go func() { + defer statisticsListener.Close() + server := &http.Server{ + Handler: a.statisticsHandler(), + ReadTimeout: 20 * time.Second, + WriteTimeout: 20 * time.Second, + ErrorLog: slog.Stdlib(ctx, a.logger.Named("statistics_http_server"), slog.LevelInfo), + } + go func() { + <-ctx.Done() + _ = server.Close() + }() + + err = server.Serve(statisticsListener) + if err != nil && !xerrors.Is(err, http.ErrServerClosed) && !strings.Contains(err.Error(), "use of closed network connection") { + a.logger.Critical(ctx, "serve statistics HTTP server", slog.Error(err)) + } + }() } // runCoordinator listens for nodes and updates the self-node as it changes. diff --git a/agent/statsendpoint.go b/agent/statsendpoint.go new file mode 100644 index 0000000000000..939d613ed5c92 --- /dev/null +++ b/agent/statsendpoint.go @@ -0,0 +1,95 @@ +package agent + +import ( + "net/http" + "runtime" + "sync" + "time" + + "github.com/cakturk/go-netstat/netstat" + "github.com/go-chi/chi" + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/codersdk" +) + +func (*agent) statisticsHandler() http.Handler { + r := chi.NewRouter() + r.Get("/", func(rw http.ResponseWriter, r *http.Request) { + httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{ + Message: "Hello from the agent!", + }) + }) + + lp := &listeningPortsHandler{} + r.Get("/api/v0/listening-ports", lp.handler) + + return r +} + +type listeningPortsHandler struct { + mut sync.Mutex + ports []codersdk.ListeningPort + mtime time.Time +} + +func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort, error) { + lp.mut.Lock() + defer lp.mut.Unlock() + + if runtime.GOOS != "linux" && runtime.GOOS != "windows" { + // Can't scan for ports on non-linux or non-windows systems at the + // moment. The UI will not show any "no ports found" message to the + // user, so the user won't suspect a thing. + return []codersdk.ListeningPort{}, nil + } + + if time.Since(lp.mtime) < time.Second { + // copy + ports := make([]codersdk.ListeningPort, len(lp.ports)) + copy(ports, lp.ports) + return ports, nil + } + + tabs, err := netstat.TCPSocks(func(s *netstat.SockTabEntry) bool { + return s.State == netstat.Listen + }) + if err != nil { + return nil, xerrors.Errorf("scan listening ports: %w", err) + } + + ports := []codersdk.ListeningPort{} + for _, tab := range tabs { + ports = append(ports, codersdk.ListeningPort{ + ProcessName: tab.Process.Name, + Network: codersdk.ListeningPortNetworkTCP, + Port: tab.LocalAddr.Port, + }) + } + + lp.ports = ports + lp.mtime = time.Now() + + // copy + ports = make([]codersdk.ListeningPort, len(lp.ports)) + copy(ports, lp.ports) + return ports, nil +} + +// handler returns a list of listening ports. This is tested by coderd's +// TestWorkspaceAgentListeningPorts test. +func (lp *listeningPortsHandler) handler(rw http.ResponseWriter, r *http.Request) { + ports, err := lp.getListeningPorts() + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Could not scan for listening ports.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.ListeningPortsResponse{ + Ports: ports, + }) +} diff --git a/coderd/coderd.go b/coderd/coderd.go index 0ac4a7c68dc5f..e9aecc4f4fa3f 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -430,6 +430,7 @@ func New(options *Options) *API { ) r.Get("/", api.workspaceAgent) r.Get("/pty", api.workspaceAgentPTY) + r.Get("/listening-ports", api.workspaceAgentListeningPorts) r.Get("/connection", api.workspaceAgentConnection) r.Get("/coordinate", api.workspaceAgentClientCoordinate) // TODO: This can be removed in October. It allows for a friendly diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 180de3654e94e..b0faaa5cfa508 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -218,6 +218,37 @@ func (api *API) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) { _, _ = io.Copy(ptNetConn, wsNetConn) } +func (api *API) workspaceAgentListeningPorts(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + workspace := httpmw.WorkspaceParam(r) + workspaceAgent := httpmw.WorkspaceAgentParam(r) + if !api.Authorize(r, rbac.ActionRead, workspace) { + httpapi.ResourceNotFound(rw) + return + } + + agentConn, release, err := api.workspaceAgentCache.Acquire(r, workspaceAgent.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error dialing workspace agent.", + Detail: err.Error(), + }) + return + } + defer release() + + portsResponse, err := agentConn.ListeningPorts(ctx) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching listening ports.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, portsResponse) +} + func (api *API) dialWorkspaceAgentTailnet(r *http.Request, agentID uuid.UUID) (*codersdk.AgentConn, error) { clientConn, serverConn := net.Pipe() go func() { diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index d0ca0e897781a..6c3566f9d8863 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -4,7 +4,9 @@ import ( "bufio" "context" "encoding/json" + "net" "runtime" + "strconv" "strings" "testing" "time" @@ -364,6 +366,104 @@ func TestWorkspaceAgentPTY(t *testing.T) { expectLine(matchEchoOutput) } +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, + }, + }}, + }}, + }, + }, + }}, + }) + 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), + }) + defer func() { + _ = agentCloser.Close() + }() + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // 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) + + // 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) + + 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) + } + } + expected[port.Port] = true + } + } + 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) + + 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) + } + } +} + func TestWorkspaceAgentAppHealth(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{ diff --git a/coderd/workspaceapps.go b/coderd/workspaceapps.go index 9799966a6b562..97472b1d71c6a 100644 --- a/coderd/workspaceapps.go +++ b/coderd/workspaceapps.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/httputil" "net/url" + "strconv" "strings" "time" @@ -465,6 +466,27 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res return } + // Verify that the port is allowed. See `codersdk.MinimumListeningPort` for + // more details. + port := appURL.Port() + if port != "" { + portInt, err := strconv.Atoi(port) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: fmt.Sprintf("App URL %q has an invalid port %q. Named ports are currently not supported.", internalURL, port), + Detail: err.Error(), + }) + return + } + + if portInt < codersdk.MinimumListeningPort { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Application port %d is not permitted. Coder reserves ports less than %d for internal use.", portInt, codersdk.MinimumListeningPort), + }) + return + } + } + // Ensure path and query parameter correctness. if proxyApp.Path == "" { // Web applications typically request paths relative to the diff --git a/codersdk/agentconn.go b/codersdk/agentconn.go index f6c4da47b166c..dd53c4beb6ffe 100644 --- a/codersdk/agentconn.go +++ b/codersdk/agentconn.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "encoding/json" "net" + "net/http" "net/netip" "strconv" "time" @@ -26,6 +27,16 @@ var ( TailnetSSHPort = 1 TailnetReconnectingPTYPort = 2 TailnetSpeedtestPort = 3 + // TailnetStatisticsPort serves a HTTP server with endpoints for gathering + // agent statistics. + TailnetStatisticsPort = 4 + + // MinimumListeningPort is the minimum port that the listening-ports + // endpoint will return to the client, and the minimum port that is accepted + // by the proxy applications endpoint. Coder consumes ports 1-4 at the + // moment, and we reserve some extra ports for future use. Port 9 and up are + // available for the user. + MinimumListeningPort = 9 ) // ReconnectingPTYRequest is sent from the client to the server @@ -153,3 +164,59 @@ func (c *AgentConn) DialContext(ctx context.Context, network string, addr string } return c.Conn.DialContextTCP(ctx, ipp) } + +func (c *AgentConn) statisticsClient() *http.Client { + return &http.Client{ + Transport: &http.Transport{ + // Disable keep alives as we're usually only making a single + // request, and this triggers goleak in tests + DisableKeepAlives: true, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := c.DialContextTCP(context.Background(), netip.AddrPortFrom(TailnetIP, uint16(TailnetStatisticsPort))) + if err != nil { + return nil, xerrors.Errorf("dial statistics: %w", err) + } + + return conn, nil + }, + }, + } +} + +type ListeningPortsResponse struct { + // If there are no ports in the list, nothing should be displayed in the UI. + // There must not be a "no ports available" message or anything similar, as + // there will always be no ports displayed on platforms where our port + // detection logic is unsupported. + Ports []ListeningPort `json:"ports"` +} + +type ListeningPortNetwork string + +const ( + ListeningPortNetworkTCP ListeningPortNetwork = "tcp" +) + +type ListeningPort struct { + ProcessName string `json:"process_name"` + Network ListeningPortNetwork `json:"network"` // only "tcp" at the moment + Port uint16 `json:"port"` +} + +func (c *AgentConn) ListeningPorts(ctx context.Context) (ListeningPortsResponse, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://agent-stats/api/v0/listening-ports", nil) + if err != nil { + return ListeningPortsResponse{}, xerrors.Errorf("new request: %w", err) + } + res, err := c.statisticsClient().Do(req) + if err != nil { + return ListeningPortsResponse{}, xerrors.Errorf("do request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ListeningPortsResponse{}, readBodyAsError(res) + } + + var resp ListeningPortsResponse + return resp, json.NewDecoder(res.Body).Decode(&resp) +} diff --git a/codersdk/client.go b/codersdk/client.go index 3c0e6a7b0d3ce..63c6304721316 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -16,7 +16,7 @@ import ( // These cookies are Coder-specific. If a new one is added or changed, the name // shouldn't be likely to conflict with any user-application set cookies. -// Be sure to strip additional cookies in httpapi.StripCoder Cookies! +// Be sure to strip additional cookies in httpapi.StripCoderCookies! const ( // SessionTokenKey represents the name of the cookie or query parameter the API key is stored in. SessionTokenKey = "coder_session_token" diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 48c5743b7f894..e29a564d281b0 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -469,6 +469,21 @@ func (c *Client) WorkspaceAgentReconnectingPTY(ctx context.Context, agentID, rec return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil } +// WorkspaceAgentListeningPorts returns a list of ports that are currently being +// listened on inside the workspace agent's network namespace. +func (c *Client) WorkspaceAgentListeningPorts(ctx context.Context, agentID uuid.UUID) (ListeningPortsResponse, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/listening-ports", agentID), nil) + if err != nil { + return ListeningPortsResponse{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return ListeningPortsResponse{}, readBodyAsError(res) + } + var listeningPorts ListeningPortsResponse + return listeningPorts, json.NewDecoder(res.Body).Decode(&listeningPorts) +} + // Stats records the Agent's network connection statistics for use in // user-facing metrics and debugging. // Each member value must be written and read with atomic. diff --git a/go.mod b/go.mod index 0870322f9c4d0..546b211e473b7 100644 --- a/go.mod +++ b/go.mod @@ -156,6 +156,8 @@ require ( tailscale.com v1.30.0 ) +require github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 // indirect + require ( filippo.io/edwards25519 v1.0.0-rc.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect diff --git a/go.sum b/go.sum index a0cd57f00ff08..2752b974cda4a 100644 --- a/go.sum +++ b/go.sum @@ -282,6 +282,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/butuzov/ireturn v0.1.1/go.mod h1:Wh6Zl3IMtTpaIKbmwzqi6olnM9ptYQxxVacMsOEFPoc= github.com/bytecodealliance/wasmtime-go v0.36.0 h1:B6thr7RMM9xQmouBtUqm1RpkJjuLS37m6nxX+iwsQSc= +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 d4663fad33fb3e987115f9e8fb82ef53fbd7e100 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 29 Sep 2022 12:01:45 +0000 Subject: [PATCH 02/12] fixup! feat: add endpoint to get listening ports in agent --- agent/statsendpoint.go | 4 ++++ coderd/workspaceapps.go | 8 ++++---- coderd/workspaceapps_test.go | 19 +++++++++++++++++++ codersdk/agentconn.go | 4 ++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/agent/statsendpoint.go b/agent/statsendpoint.go index 939d613ed5c92..c2ca275f199a7 100644 --- a/agent/statsendpoint.go +++ b/agent/statsendpoint.go @@ -61,6 +61,10 @@ func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort, ports := []codersdk.ListeningPort{} for _, tab := range tabs { + if tab.LocalAddr.Port < uint16(codersdk.MinimumListeningPort) { + continue + } + ports = append(ports, codersdk.ListeningPort{ ProcessName: tab.Process.Name, Network: codersdk.ListeningPortNetworkTCP, diff --git a/coderd/workspaceapps.go b/coderd/workspaceapps.go index 97472b1d71c6a..10f73dcd6112f 100644 --- a/coderd/workspaceapps.go +++ b/coderd/workspaceapps.go @@ -466,14 +466,14 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res return } - // Verify that the port is allowed. See `codersdk.MinimumListeningPort` for - // more details. + // Verify that the port is allowed. See the docs above + // `codersdk.MinimumListeningPort` for more details. port := appURL.Port() if port != "" { portInt, err := strconv.Atoi(port) if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: fmt.Sprintf("App URL %q has an invalid port %q. Named ports are currently not supported.", internalURL, port), + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("App URL %q has an invalid port %q.", internalURL, port), Detail: err.Error(), }) return diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index e111863f578df..b80865e6323a5 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -695,4 +695,23 @@ func TestWorkspaceAppsProxySubdomain(t *testing.T) { defer resp.Body.Close() require.Equal(t, http.StatusBadGateway, resp.StatusCode) }) + + t.Run("ProxyPortMinimumError", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + port := uint16(codersdk.MinimumListeningPort - 1) + resp, err := client.Request(ctx, http.MethodGet, proxyURL(t, port, "/", proxyTestAppQuery), nil) + require.NoError(t, err) + defer resp.Body.Close() + + // Should have an error response. + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + var resBody codersdk.Response + err = json.NewDecoder(resp.Body).Decode(&resBody) + require.NoError(t, err) + require.Contains(t, resBody.Message, "Coder reserves ports less than") + }) } diff --git a/codersdk/agentconn.go b/codersdk/agentconn.go index dd53c4beb6ffe..7d76eb1fdc69a 100644 --- a/codersdk/agentconn.go +++ b/codersdk/agentconn.go @@ -36,6 +36,10 @@ var ( // by the proxy applications endpoint. Coder consumes ports 1-4 at the // moment, and we reserve some extra ports for future use. Port 9 and up are // available for the user. + // + // This is not enforced in the CLI intentionally as we don't really care + // *that* much. The user could bypass this in the CLI by using SSH instead + // anyways. MinimumListeningPort = 9 ) From 3f741c6b7b5aee421ed334dfc89bd0d299862189 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 29 Sep 2022 12:07:45 +0000 Subject: [PATCH 03/12] fixup! feat: add endpoint to get listening ports in agent --- site/src/api/typesGenerated.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index fdacbc6dacadf..7c3f2f08335d8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -287,6 +287,18 @@ export interface License { readonly claims: Record } +// From codersdk/agentconn.go +export interface ListeningPort { + readonly process_name: string + readonly network: ListeningPortNetwork + readonly port: number +} + +// From codersdk/agentconn.go +export interface ListeningPortsResponse { + readonly ports: ListeningPort[] +} + // From codersdk/users.go export interface LoginWithPasswordRequest { readonly email: string @@ -671,6 +683,9 @@ export type BuildReason = "autostart" | "autostop" | "initiator" // From codersdk/features.go export type Entitlement = "entitled" | "grace_period" | "not_entitled" +// From codersdk/agentconn.go +export type ListeningPortNetwork = "tcp" + // From codersdk/provisionerdaemons.go export type LogLevel = "debug" | "error" | "info" | "trace" | "warn" From 4fbc0ffeaaefaefa9c321cf59ab7b0c31f9f97dd Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 4 Oct 2022 15:38:51 +0000 Subject: [PATCH 04/12] fixup! feat: add endpoint to get listening ports in agent --- codersdk/agentconn.go | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/codersdk/agentconn.go b/codersdk/agentconn.go index 7d76eb1fdc69a..2279cc52b22be 100644 --- a/codersdk/agentconn.go +++ b/codersdk/agentconn.go @@ -4,6 +4,8 @@ import ( "context" "encoding/binary" "encoding/json" + "fmt" + "io" "net" "net/http" "net/netip" @@ -176,6 +178,19 @@ func (c *AgentConn) statisticsClient() *http.Client { // request, and this triggers goleak in tests DisableKeepAlives: true, DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + if network != "tcp" { + return nil, xerrors.Errorf("network must be tcp") + } + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, xerrors.Errorf("split host port %q: %w", addr, err) + } + // Verify that host is TailnetIP and port is + // TailnetStatisticsPort. + if host != TailnetIP.String() || port != strconv.Itoa(TailnetStatisticsPort) { + return nil, xerrors.Errorf("request %q does not appear to be for statistics server", addr) + } + conn, err := c.DialContextTCP(context.Background(), netip.AddrPortFrom(TailnetIP, uint16(TailnetStatisticsPort))) if err != nil { return nil, xerrors.Errorf("dial statistics: %w", err) @@ -187,6 +202,18 @@ func (c *AgentConn) statisticsClient() *http.Client { } } +func (c *AgentConn) doStatisticsRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) { + host := net.JoinHostPort(TailnetIP.String(), strconv.Itoa(TailnetStatisticsPort)) + url := fmt.Sprintf("http://%s%s", host, path) + + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, xerrors.Errorf("new statistics server request to %q: %w", url, err) + } + + return c.statisticsClient().Do(req) +} + type ListeningPortsResponse struct { // If there are no ports in the list, nothing should be displayed in the UI. // There must not be a "no ports available" message or anything similar, as @@ -208,11 +235,7 @@ type ListeningPort struct { } func (c *AgentConn) ListeningPorts(ctx context.Context) (ListeningPortsResponse, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://agent-stats/api/v0/listening-ports", nil) - if err != nil { - return ListeningPortsResponse{}, xerrors.Errorf("new request: %w", err) - } - res, err := c.statisticsClient().Do(req) + res, err := c.doStatisticsRequest(ctx, http.MethodGet, "/api/v0/listening-ports", nil) if err != nil { return ListeningPortsResponse{}, xerrors.Errorf("do request: %w", err) } From 0f7b8dc553116572576994e6f5f768df4d65fc1d Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 4 Oct 2022 15:42:18 +0000 Subject: [PATCH 05/12] fixup! feat: add endpoint to get listening ports in agent --- agent/agent.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index e010e9614082b..6d0a9a952f44b 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -273,10 +273,11 @@ func (a *agent) runTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) { go func() { defer statisticsListener.Close() server := &http.Server{ - Handler: a.statisticsHandler(), - ReadTimeout: 20 * time.Second, - WriteTimeout: 20 * time.Second, - ErrorLog: slog.Stdlib(ctx, a.logger.Named("statistics_http_server"), slog.LevelInfo), + Handler: a.statisticsHandler(), + ReadTimeout: 20 * time.Second, + ReadHeaderTimeout: 20 * time.Second, + WriteTimeout: 20 * time.Second, + ErrorLog: slog.Stdlib(ctx, a.logger.Named("statistics_http_server"), slog.LevelInfo), } go func() { <-ctx.Done() From ea2027e9539f38664cdea341b8af375417ae8ff5 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 4 Oct 2022 16:14:58 +0000 Subject: [PATCH 06/12] fixup! feat: add endpoint to get listening ports in agent --- agent/ports_supported.go | 61 ++++++++++++++++++ agent/ports_unsupported.go | 13 ++++ agent/statsendpoint.go | 50 --------------- coderd/workspaceagents_test.go | 111 +++++++++++++++++++++------------ 4 files changed, 144 insertions(+), 91 deletions(-) create mode 100644 agent/ports_supported.go create mode 100644 agent/ports_unsupported.go diff --git a/agent/ports_supported.go b/agent/ports_supported.go new file mode 100644 index 0000000000000..bf7051205cb2c --- /dev/null +++ b/agent/ports_supported.go @@ -0,0 +1,61 @@ +//go:build linux || windows +// +build linux windows + +package agent + +import ( + "runtime" + "time" + + "github.com/cakturk/go-netstat/netstat" + "golang.org/x/xerrors" + + "github.com/coder/coder/codersdk" +) + +func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort, error) { + lp.mut.Lock() + defer lp.mut.Unlock() + + if runtime.GOOS != "linux" && runtime.GOOS != "windows" { + // Can't scan for ports on non-linux or non-windows systems at the + // moment. The UI will not show any "no ports found" message to the + // user, so the user won't suspect a thing. + return []codersdk.ListeningPort{}, nil + } + + if time.Since(lp.mtime) < time.Second { + // copy + ports := make([]codersdk.ListeningPort, len(lp.ports)) + copy(ports, lp.ports) + return ports, nil + } + + tabs, err := netstat.TCPSocks(func(s *netstat.SockTabEntry) bool { + return s.State == netstat.Listen + }) + if err != nil { + return nil, xerrors.Errorf("scan listening ports: %w", err) + } + + ports := []codersdk.ListeningPort{} + for _, tab := range tabs { + if tab.LocalAddr.Port < uint16(codersdk.MinimumListeningPort) { + continue + } + + ports = append(ports, codersdk.ListeningPort{ + ProcessName: tab.Process.Name, + Network: codersdk.ListeningPortNetworkTCP, + Port: tab.LocalAddr.Port, + }) + } + + lp.ports = ports + lp.mtime = time.Now() + + // copy + ports = make([]codersdk.ListeningPort, len(lp.ports)) + copy(ports, lp.ports) + return ports, nil +} diff --git a/agent/ports_unsupported.go b/agent/ports_unsupported.go new file mode 100644 index 0000000000000..2eabdaca330ac --- /dev/null +++ b/agent/ports_unsupported.go @@ -0,0 +1,13 @@ +//go:build !linux && !windows +// +build !linux,!windows + +package agent + +import "github.com/coder/coder/codersdk" + +func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort, error) { + // Can't scan for ports on non-linux or non-windows systems at the moment. + // The UI will not show any "no ports found" message to the user, so the + // user won't suspect a thing. + return []codersdk.ListeningPort{}, nil +} diff --git a/agent/statsendpoint.go b/agent/statsendpoint.go index c2ca275f199a7..0ddc01f70ddb5 100644 --- a/agent/statsendpoint.go +++ b/agent/statsendpoint.go @@ -2,13 +2,10 @@ package agent import ( "net/http" - "runtime" "sync" "time" - "github.com/cakturk/go-netstat/netstat" "github.com/go-chi/chi" - "golang.org/x/xerrors" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/codersdk" @@ -34,53 +31,6 @@ type listeningPortsHandler struct { mtime time.Time } -func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort, error) { - lp.mut.Lock() - defer lp.mut.Unlock() - - if runtime.GOOS != "linux" && runtime.GOOS != "windows" { - // Can't scan for ports on non-linux or non-windows systems at the - // moment. The UI will not show any "no ports found" message to the - // user, so the user won't suspect a thing. - return []codersdk.ListeningPort{}, nil - } - - if time.Since(lp.mtime) < time.Second { - // copy - ports := make([]codersdk.ListeningPort, len(lp.ports)) - copy(ports, lp.ports) - return ports, nil - } - - tabs, err := netstat.TCPSocks(func(s *netstat.SockTabEntry) bool { - return s.State == netstat.Listen - }) - if err != nil { - return nil, xerrors.Errorf("scan listening ports: %w", err) - } - - ports := []codersdk.ListeningPort{} - for _, tab := range tabs { - if tab.LocalAddr.Port < uint16(codersdk.MinimumListeningPort) { - continue - } - - ports = append(ports, codersdk.ListeningPort{ - ProcessName: tab.Process.Name, - Network: codersdk.ListeningPortNetworkTCP, - Port: tab.LocalAddr.Port, - }) - } - - lp.ports = ports - lp.mtime = time.Now() - - // copy - ports = make([]codersdk.ListeningPort, len(lp.ports)) - copy(ports, lp.ports) - return ports, nil -} - // handler returns a list of listening ports. This is tested by coderd's // TestWorkspaceAgentListeningPorts test. func (lp *listeningPortsHandler) handler(rw http.ResponseWriter, r *http.Request) { diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 6c3566f9d8863..7cfebdf5166e0 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -408,60 +408,89 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet, Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug), }) - defer func() { + t.Cleanup(func() { _ = agentCloser.Close() - }() + }) resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + t.Run("LinuxAndWindows", func(t *testing.T) { + t.Parallel() + if runtime.GOOS != "linux" && runtime.GOOS != "windows" { + t.Skip("only runs on linux and windows") + return + } - // 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) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() - // 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) + // 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) - 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) + // 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) + + 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) + } } + 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, 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) + 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) { + t.Parallel() + if runtime.GOOS != "darwin" { + t.Skip("only runs on darwin") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Create a TCP listener on a random port. + l, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + 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) + require.NoError(t, err) + require.Len(t, res.Ports, 0) + }) } func TestWorkspaceAgentAppHealth(t *testing.T) { From e2973bad6b262d10860bbc993b3fa7b26738e604 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Tue, 4 Oct 2022 17:51:02 +0000 Subject: [PATCH 07/12] fixup! feat: add endpoint to get listening ports in agent --- agent/ports_supported.go | 8 -------- coderd/workspaceagents.go | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/agent/ports_supported.go b/agent/ports_supported.go index bf7051205cb2c..ba7fa7aa374da 100644 --- a/agent/ports_supported.go +++ b/agent/ports_supported.go @@ -4,7 +4,6 @@ package agent import ( - "runtime" "time" "github.com/cakturk/go-netstat/netstat" @@ -17,13 +16,6 @@ func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort, lp.mut.Lock() defer lp.mut.Unlock() - if runtime.GOOS != "linux" && runtime.GOOS != "windows" { - // Can't scan for ports on non-linux or non-windows systems at the - // moment. The UI will not show any "no ports found" message to the - // user, so the user won't suspect a thing. - return []codersdk.ListeningPort{}, nil - } - if time.Since(lp.mtime) < time.Second { // copy ports := make([]codersdk.ListeningPort, len(lp.ports)) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index b0faaa5cfa508..055f02802d388 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -227,6 +227,21 @@ func (api *API) workspaceAgentListeningPorts(rw http.ResponseWriter, r *http.Req return } + apiAgent, err := convertWorkspaceAgent(api.DERPMap, api.TailnetCoordinator, workspaceAgent, nil, api.AgentInactiveDisconnectTimeout) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error reading workspace agent.", + Detail: err.Error(), + }) + return + } + if apiAgent.Status != codersdk.WorkspaceAgentConnected { + httpapi.Write(ctx, rw, http.StatusPreconditionRequired, codersdk.Response{ + Message: fmt.Sprintf("Agent state is %q, it must be in the %q state.", apiAgent.Status, codersdk.WorkspaceAgentConnected), + }) + return + } + agentConn, release, err := api.workspaceAgentCache.Acquire(r, workspaceAgent.ID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ From d9635a88dc9363c2611d57b53df88d6976b73125 Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 6 Oct 2022 11:40:38 +0000 Subject: [PATCH 08/12] please work tests --- coderd/workspaceagents_test.go | 97 +++++++++++++++++++--------------- 1 file changed, 53 insertions(+), 44 deletions(-) diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 7cfebdf5166e0..d7018201714fc 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -368,50 +368,55 @@ 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) (client *codersdk.Client, agentID uuid.UUID, coderdPort uint16) { + client = coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + coderdPortInt, 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, + }, + }}, }}, - }}, + }, }, - }, - }}, - }) - 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.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.LatestBuild.ID) + + return client, resources[0].Agents[0].ID, uint16(coderdPortInt) + } t.Run("LinuxAndWindows", func(t *testing.T) { t.Parallel() @@ -420,6 +425,8 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { return } + client, agentID, coderdPort := setup(t) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -431,7 +438,7 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { tcpAddr, _ := l.Addr().(*net.TCPAddr) // List ports and ensure that the port we expect to see is there. - res, err := client.WorkspaceAgentListeningPorts(ctx, resources[0].Agents[0].ID) + res, err := client.WorkspaceAgentListeningPorts(ctx, agentID) require.NoError(t, err) var ( @@ -439,7 +446,7 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { // expect the listener we made uint16(tcpAddr.Port): false, // expect the coderdtest server - uint16(coderdPort): false, + coderdPort: false, } ) for _, port := range res.Ports { @@ -461,7 +468,7 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { // 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) + res, err = client.WorkspaceAgentListeningPorts(ctx, agentID) require.NoError(t, err) for _, port := range res.Ports { @@ -478,6 +485,8 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { return } + client, agentID, _ := setup(t) + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -487,7 +496,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) }) From 39626354913c7e97003f04ee8bbb8ad89d9a0cba Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 6 Oct 2022 11:47:01 +0000 Subject: [PATCH 09/12] Revert "please work tests" This reverts commit d9635a88dc9363c2611d57b53df88d6976b73125. --- coderd/workspaceagents_test.go | 97 +++++++++++++++------------------- 1 file changed, 44 insertions(+), 53 deletions(-) diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index d7018201714fc..7cfebdf5166e0 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -368,55 +368,50 @@ 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) - setup := func(t *testing.T) (client *codersdk.Client, agentID uuid.UUID, coderdPort uint16) { - client = coderdtest.New(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, - }) - coderdPortInt, 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, - }, - }}, + 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, + }, }}, - }, + }}, }, - }}, - }) - 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.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) - return client, resources[0].Agents[0].ID, uint16(coderdPortInt) - } + 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.LatestBuild.ID) t.Run("LinuxAndWindows", func(t *testing.T) { t.Parallel() @@ -425,8 +420,6 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { return } - client, agentID, coderdPort := setup(t) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -438,7 +431,7 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { tcpAddr, _ := l.Addr().(*net.TCPAddr) // List ports and ensure that the port we expect to see is there. - res, err := client.WorkspaceAgentListeningPorts(ctx, agentID) + res, err := client.WorkspaceAgentListeningPorts(ctx, resources[0].Agents[0].ID) require.NoError(t, err) var ( @@ -446,7 +439,7 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { // expect the listener we made uint16(tcpAddr.Port): false, // expect the coderdtest server - coderdPort: false, + uint16(coderdPort): false, } ) for _, port := range res.Ports { @@ -468,7 +461,7 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { // 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) + res, err = client.WorkspaceAgentListeningPorts(ctx, resources[0].Agents[0].ID) require.NoError(t, err) for _, port := range res.Ports { @@ -485,8 +478,6 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { return } - client, agentID, _ := setup(t) - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() @@ -496,7 +487,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, agentID) + res, err := client.WorkspaceAgentListeningPorts(ctx, resources[0].Agents[0].ID) require.NoError(t, err) require.Len(t, res.Ports, 0) }) From d4b7ce957c8b4dfcf10fc9ee5db7873696251fdf Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 6 Oct 2022 11:48:10 +0000 Subject: [PATCH 10/12] fixup! Merge branch 'main' into dean/listening-ports --- coderd/workspaceagents_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index dc47fb37d6a53..e4bbe42a5af6d 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -410,7 +410,7 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) { t.Cleanup(func() { _ = agentCloser.Close() }) - resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) t.Run("LinuxAndWindows", func(t *testing.T) { t.Parallel() From b084277d37637e67bb99f406d9d0ba590123ecfe Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 6 Oct 2022 12:02:16 +0000 Subject: [PATCH 11/12] fixup! Merge branch 'main' into dean/listening-ports --- agent/ports_supported.go | 8 ++++++-- codersdk/agentconn.go | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/agent/ports_supported.go b/agent/ports_supported.go index ba7fa7aa374da..4d3351996a7f7 100644 --- a/agent/ports_supported.go +++ b/agent/ports_supported.go @@ -32,12 +32,16 @@ func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort, ports := []codersdk.ListeningPort{} for _, tab := range tabs { - if tab.LocalAddr.Port < uint16(codersdk.MinimumListeningPort) { + if tab.LocalAddr == nil || tab.LocalAddr.Port < uint16(codersdk.MinimumListeningPort) { continue } + procName := "" + if tab.Process != nil { + procName = tab.Process.Name + } ports = append(ports, codersdk.ListeningPort{ - ProcessName: tab.Process.Name, + ProcessName: procName, Network: codersdk.ListeningPortNetworkTCP, Port: tab.LocalAddr.Port, }) diff --git a/codersdk/agentconn.go b/codersdk/agentconn.go index 2279cc52b22be..02d9f89d1a407 100644 --- a/codersdk/agentconn.go +++ b/codersdk/agentconn.go @@ -229,8 +229,8 @@ const ( ) type ListeningPort struct { - ProcessName string `json:"process_name"` - Network ListeningPortNetwork `json:"network"` // only "tcp" at the moment + ProcessName string `json:"process_name"` // may be empty + Network ListeningPortNetwork `json:"network"` // only "tcp" at the moment Port uint16 `json:"port"` } From 0960944e1d69a5de9caa3192d5425dbe42c0262d Mon Sep 17 00:00:00 2001 From: Dean Sheather Date: Thu, 6 Oct 2022 12:20:08 +0000 Subject: [PATCH 12/12] fixup! Merge branch 'main' into dean/listening-ports --- agent/ports_supported.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/agent/ports_supported.go b/agent/ports_supported.go index 4d3351996a7f7..e405aa6c1bbc1 100644 --- a/agent/ports_supported.go +++ b/agent/ports_supported.go @@ -30,12 +30,20 @@ func (lp *listeningPortsHandler) getListeningPorts() ([]codersdk.ListeningPort, return nil, xerrors.Errorf("scan listening ports: %w", err) } + seen := make(map[uint16]struct{}, len(tabs)) ports := []codersdk.ListeningPort{} for _, tab := range tabs { if tab.LocalAddr == nil || tab.LocalAddr.Port < uint16(codersdk.MinimumListeningPort) { continue } + // Don't include ports that we've already seen. This can happen on + // Windows, and maybe on Linux if you're using a shared listener socket. + if _, ok := seen[tab.LocalAddr.Port]; ok { + continue + } + seen[tab.LocalAddr.Port] = struct{}{} + procName := "" if tab.Process != nil { procName = tab.Process.Name