feat: add endpoint to get listening ports in agent (#4260)

This commit is contained in:
Dean Sheather
2022-10-06 22:38:22 +10:00
committed by GitHub
parent bbe2baf3f6
commit 1386465631
15 changed files with 501 additions and 1 deletions
+94
View File
@@ -4,7 +4,10 @@ import (
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"strconv"
"time"
@@ -26,6 +29,20 @@ 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.
//
// 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
)
// ReconnectingPTYRequest is sent from the client to the server
@@ -153,3 +170,80 @@ 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) {
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)
}
return conn, nil
},
},
}
}
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
// 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"` // may be empty
Network ListeningPortNetwork `json:"network"` // only "tcp" at the moment
Port uint16 `json:"port"`
}
func (c *AgentConn) ListeningPorts(ctx context.Context) (ListeningPortsResponse, error) {
res, err := c.doStatisticsRequest(ctx, http.MethodGet, "/api/v0/listening-ports", nil)
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)
}
+1 -1
View File
@@ -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"
+15
View File
@@ -520,6 +520,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.