mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add endpoint to get listening ports in agent (#4260)
This commit is contained in:
@@ -438,6 +438,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
|
||||
|
||||
@@ -219,6 +219,52 @@ 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
|
||||
}
|
||||
|
||||
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{
|
||||
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() {
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -363,6 +365,133 @@ 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),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = agentCloser.Close()
|
||||
})
|
||||
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -486,6 +487,27 @@ func (api *API) proxyWorkspaceApplication(proxyApp proxyApplication, rw http.Res
|
||||
return
|
||||
}
|
||||
|
||||
// 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.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("App URL %q has an invalid port %q.", 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
|
||||
|
||||
@@ -692,4 +692,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")
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user