Skip to content

Commit d1496ed

Browse files
committed
feat: add endpoint for netstat web socket
1 parent e9a28be commit d1496ed

File tree

5 files changed

+149
-0
lines changed

5 files changed

+149
-0
lines changed

coderd/coderd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ func New(options *Options) *API {
285285
r.Get("/", api.workspaceAgent)
286286
r.Get("/dial", api.workspaceAgentDial)
287287
r.Get("/turn", api.workspaceAgentTurn)
288+
r.Get("/netstat", api.workspaceAgentNetstat)
288289
r.Get("/pty", api.workspaceAgentPTY)
289290
r.Get("/iceservers", api.workspaceAgentICEServers)
290291
})

coderd/coderd_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
139139
"GET:/api/v2/workspaceagents/{workspaceagent}": {NoAuthorize: true},
140140
"GET:/api/v2/workspaceagents/{workspaceagent}/dial": {NoAuthorize: true},
141141
"GET:/api/v2/workspaceagents/{workspaceagent}/iceservers": {NoAuthorize: true},
142+
"GET:/api/v2/workspaceagents/{workspaceagent}/netstat": {NoAuthorize: true},
142143
"GET:/api/v2/workspaceagents/{workspaceagent}/pty": {NoAuthorize: true},
143144
"GET:/api/v2/workspaceagents/{workspaceagent}/turn": {NoAuthorize: true},
144145

coderd/workspaceagents.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,3 +501,55 @@ func convertWorkspaceAgent(dbAgent database.WorkspaceAgent, agentUpdateFrequency
501501

502502
return workspaceAgent, nil
503503
}
504+
505+
// workspaceAgentNetstat sends listening ports as `agent.NetstatResponse` on an
506+
// interval.
507+
func (api *API) workspaceAgentNetstat(rw http.ResponseWriter, r *http.Request) {
508+
api.websocketWaitMutex.Lock()
509+
api.websocketWaitGroup.Add(1)
510+
api.websocketWaitMutex.Unlock()
511+
defer api.websocketWaitGroup.Done()
512+
513+
workspaceAgent := httpmw.WorkspaceAgentParam(r)
514+
apiAgent, err := convertWorkspaceAgent(workspaceAgent, api.AgentConnectionUpdateFrequency)
515+
if err != nil {
516+
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
517+
Message: fmt.Sprintf("convert workspace agent: %s", err),
518+
})
519+
return
520+
}
521+
if apiAgent.Status != codersdk.WorkspaceAgentConnected {
522+
httpapi.Write(rw, http.StatusPreconditionRequired, httpapi.Response{
523+
Message: fmt.Sprintf("agent must be in the connected state: %s", apiAgent.Status),
524+
})
525+
return
526+
}
527+
528+
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
529+
CompressionMode: websocket.CompressionDisabled,
530+
})
531+
if err != nil {
532+
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
533+
Message: fmt.Sprintf("accept websocket: %s", err),
534+
})
535+
return
536+
}
537+
defer func() {
538+
_ = conn.Close(websocket.StatusNormalClosure, "ended")
539+
}()
540+
wsNetConn := websocket.NetConn(r.Context(), conn, websocket.MessageBinary)
541+
agentConn, err := api.dialWorkspaceAgent(r, workspaceAgent.ID)
542+
if err != nil {
543+
_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial workspace agent: %s", err))
544+
return
545+
}
546+
defer agentConn.Close()
547+
ptNetConn, err := agentConn.Netstat(r.Context())
548+
if err != nil {
549+
_ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("dial: %s", err))
550+
return
551+
}
552+
defer ptNetConn.Close()
553+
554+
agent.Bicopy(r.Context(), wsNetConn, ptNetConn)
555+
}

coderd/workspaceagents_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bufio"
55
"context"
66
"encoding/json"
7+
"fmt"
78
"runtime"
89
"strings"
910
"testing"
@@ -264,3 +265,67 @@ func TestWorkspaceAgentPTY(t *testing.T) {
264265
expectLine(matchEchoCommand)
265266
expectLine(matchEchoOutput)
266267
}
268+
269+
func TestWorkspaceAgentNetstat(t *testing.T) {
270+
t.Parallel()
271+
272+
client, coderAPI := coderdtest.NewWithAPI(t, nil)
273+
user := coderdtest.CreateFirstUser(t, client)
274+
daemonCloser := coderdtest.NewProvisionerDaemon(t, coderAPI)
275+
authToken := uuid.NewString()
276+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
277+
Parse: echo.ParseComplete,
278+
ProvisionDryRun: echo.ProvisionComplete,
279+
Provision: []*proto.Provision_Response{{
280+
Type: &proto.Provision_Response_Complete{
281+
Complete: &proto.Provision_Complete{
282+
Resources: []*proto.Resource{{
283+
Name: "example",
284+
Type: "aws_instance",
285+
Agents: []*proto.Agent{{
286+
Id: uuid.NewString(),
287+
Auth: &proto.Agent_Token{
288+
Token: authToken,
289+
},
290+
}},
291+
}},
292+
},
293+
},
294+
}},
295+
})
296+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
297+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
298+
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
299+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
300+
daemonCloser.Close()
301+
302+
agentClient := codersdk.New(client.URL)
303+
agentClient.SessionToken = authToken
304+
agentCloser := agent.New(agentClient.ListenWorkspaceAgent, &agent.Options{
305+
Logger: slogtest.Make(t, nil),
306+
})
307+
t.Cleanup(func() {
308+
_ = agentCloser.Close()
309+
})
310+
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
311+
312+
conn, err := client.WorkspaceAgentNetstat(context.Background(), resources[0].Agents[0].ID)
313+
require.NoError(t, err)
314+
defer conn.Close()
315+
316+
decoder := json.NewDecoder(conn)
317+
318+
expectNetstat := func() {
319+
var res agent.NetstatResponse
320+
err = decoder.Decode(&res)
321+
require.NoError(t, err)
322+
323+
if runtime.GOOS == "linux" || runtime.GOOS == "windows" {
324+
require.NotNil(t, res.Ports)
325+
} else {
326+
require.Equal(t, fmt.Sprintf("Port scanning is not supported on %s", runtime.GOOS), res.Error)
327+
}
328+
}
329+
330+
expectNetstat()
331+
}

codersdk/workspaceagents.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,36 @@ func (c *Client) WorkspaceAgentReconnectingPTY(ctx context.Context, agentID, rec
369369
return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil
370370
}
371371

372+
// WorkspaceAgentNetstat sends listening ports as `agent.NetstatResponse` on an
373+
// interval.
374+
func (c *Client) WorkspaceAgentNetstat(ctx context.Context, agentID uuid.UUID) (net.Conn, error) {
375+
serverURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/netstat", agentID))
376+
if err != nil {
377+
return nil, xerrors.Errorf("parse url: %w", err)
378+
}
379+
jar, err := cookiejar.New(nil)
380+
if err != nil {
381+
return nil, xerrors.Errorf("create cookie jar: %w", err)
382+
}
383+
jar.SetCookies(serverURL, []*http.Cookie{{
384+
Name: httpmw.SessionTokenKey,
385+
Value: c.SessionToken,
386+
}})
387+
httpClient := &http.Client{
388+
Jar: jar,
389+
}
390+
conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{
391+
HTTPClient: httpClient,
392+
})
393+
if err != nil {
394+
if res == nil {
395+
return nil, err
396+
}
397+
return nil, readBodyAsError(res)
398+
}
399+
return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil
400+
}
401+
372402
func (c *Client) turnProxyDialer(ctx context.Context, httpClient *http.Client, path string) proxy.Dialer {
373403
return turnconn.ProxyDialer(func() (net.Conn, error) {
374404
turnURL, err := c.URL.Parse(path)

0 commit comments

Comments
 (0)