mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
chore(codersdk): move all tailscale imports out of codersdk (#12735)
Currently, importing `codersdk` just to interact with the API requires importing tailscale, which causes builds to fail unless manually using our fork.
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
package workspacesdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/xerrors"
|
||||
"gvisor.dev/gvisor/pkg/tcpip/adapters/gonet"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/net/speedtest"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/tracing"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
)
|
||||
|
||||
// NewAgentConn creates a new WorkspaceAgentConn. `conn` may be unique
|
||||
// to the WorkspaceAgentConn, or it may be shared in the case of coderd. If the
|
||||
// conn is shared and closing it is undesirable, you may return ErrNoClose from
|
||||
// opts.CloseFunc. This will ensure the underlying conn is not closed.
|
||||
func NewAgentConn(conn *tailnet.Conn, opts AgentConnOptions) *AgentConn {
|
||||
return &AgentConn{
|
||||
Conn: conn,
|
||||
opts: opts,
|
||||
}
|
||||
}
|
||||
|
||||
// AgentConn represents a connection to a workspace agent.
|
||||
// @typescript-ignore AgentConn
|
||||
type AgentConn struct {
|
||||
*tailnet.Conn
|
||||
opts AgentConnOptions
|
||||
}
|
||||
|
||||
// @typescript-ignore AgentConnOptions
|
||||
type AgentConnOptions struct {
|
||||
AgentID uuid.UUID
|
||||
CloseFunc func() error
|
||||
}
|
||||
|
||||
func (c *AgentConn) agentAddress() netip.Addr {
|
||||
return tailnet.IPFromUUID(c.opts.AgentID)
|
||||
}
|
||||
|
||||
// AwaitReachable waits for the agent to be reachable.
|
||||
func (c *AgentConn) AwaitReachable(ctx context.Context) bool {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
return c.Conn.AwaitReachable(ctx, c.agentAddress())
|
||||
}
|
||||
|
||||
// Ping pings the agent and returns the round-trip time.
|
||||
// The bool returns true if the ping was made P2P.
|
||||
func (c *AgentConn) Ping(ctx context.Context) (time.Duration, bool, *ipnstate.PingResult, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
return c.Conn.Ping(ctx, c.agentAddress())
|
||||
}
|
||||
|
||||
// Close ends the connection to the workspace agent.
|
||||
func (c *AgentConn) Close() error {
|
||||
var cerr error
|
||||
if c.opts.CloseFunc != nil {
|
||||
cerr = c.opts.CloseFunc()
|
||||
if xerrors.Is(cerr, ErrSkipClose) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if cerr != nil {
|
||||
return multierror.Append(cerr, c.Conn.Close())
|
||||
}
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
// AgentReconnectingPTYInit initializes a new reconnecting PTY session.
|
||||
// @typescript-ignore AgentReconnectingPTYInit
|
||||
type AgentReconnectingPTYInit struct {
|
||||
ID uuid.UUID
|
||||
Height uint16
|
||||
Width uint16
|
||||
Command string
|
||||
}
|
||||
|
||||
// ReconnectingPTYRequest is sent from the client to the server
|
||||
// to pipe data to a PTY.
|
||||
// @typescript-ignore ReconnectingPTYRequest
|
||||
type ReconnectingPTYRequest struct {
|
||||
Data string `json:"data,omitempty"`
|
||||
Height uint16 `json:"height,omitempty"`
|
||||
Width uint16 `json:"width,omitempty"`
|
||||
}
|
||||
|
||||
// ReconnectingPTY spawns a new reconnecting terminal session.
|
||||
// `ReconnectingPTYRequest` should be JSON marshaled and written to the returned net.Conn.
|
||||
// Raw terminal output will be read from the returned net.Conn.
|
||||
func (c *AgentConn) ReconnectingPTY(ctx context.Context, id uuid.UUID, height, width uint16, command string) (net.Conn, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
if !c.AwaitReachable(ctx) {
|
||||
return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err())
|
||||
}
|
||||
|
||||
conn, err := c.Conn.DialContextTCP(ctx, netip.AddrPortFrom(c.agentAddress(), AgentReconnectingPTYPort))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := json.Marshal(AgentReconnectingPTYInit{
|
||||
ID: id,
|
||||
Height: height,
|
||||
Width: width,
|
||||
Command: command,
|
||||
})
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
data = append(make([]byte, 2), data...)
|
||||
binary.LittleEndian.PutUint16(data, uint16(len(data)-2))
|
||||
|
||||
_, err = conn.Write(data)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// SSH pipes the SSH protocol over the returned net.Conn.
|
||||
// This connects to the built-in SSH server in the workspace agent.
|
||||
func (c *AgentConn) SSH(ctx context.Context) (*gonet.TCPConn, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
if !c.AwaitReachable(ctx) {
|
||||
return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err())
|
||||
}
|
||||
|
||||
return c.Conn.DialContextTCP(ctx, netip.AddrPortFrom(c.agentAddress(), AgentSSHPort))
|
||||
}
|
||||
|
||||
// SSHClient calls SSH to create a client that uses a weak cipher
|
||||
// to improve throughput.
|
||||
func (c *AgentConn) SSHClient(ctx context.Context) (*ssh.Client, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
netConn, err := c.SSH(ctx)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("ssh: %w", err)
|
||||
}
|
||||
|
||||
sshConn, channels, requests, err := ssh.NewClientConn(netConn, "localhost:22", &ssh.ClientConfig{
|
||||
// SSH host validation isn't helpful, because obtaining a peer
|
||||
// connection already signifies user-intent to dial a workspace.
|
||||
// #nosec
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("ssh conn: %w", err)
|
||||
}
|
||||
|
||||
return ssh.NewClient(sshConn, channels, requests), nil
|
||||
}
|
||||
|
||||
// Speedtest runs a speedtest against the workspace agent.
|
||||
func (c *AgentConn) Speedtest(ctx context.Context, direction speedtest.Direction, duration time.Duration) ([]speedtest.Result, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
if !c.AwaitReachable(ctx) {
|
||||
return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err())
|
||||
}
|
||||
|
||||
speedConn, err := c.Conn.DialContextTCP(ctx, netip.AddrPortFrom(c.agentAddress(), AgentSpeedtestPort))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("dial speedtest: %w", err)
|
||||
}
|
||||
|
||||
results, err := speedtest.RunClientWithConn(direction, duration, speedConn)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("run speedtest: %w", err)
|
||||
}
|
||||
|
||||
return results, err
|
||||
}
|
||||
|
||||
// DialContext dials the address provided in the workspace agent.
|
||||
// The network must be "tcp" or "udp".
|
||||
func (c *AgentConn) DialContext(ctx context.Context, network string, addr string) (net.Conn, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
if !c.AwaitReachable(ctx) {
|
||||
return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err())
|
||||
}
|
||||
|
||||
_, rawPort, _ := net.SplitHostPort(addr)
|
||||
port, _ := strconv.ParseUint(rawPort, 10, 16)
|
||||
ipp := netip.AddrPortFrom(c.agentAddress(), uint16(port))
|
||||
|
||||
switch network {
|
||||
case "tcp":
|
||||
return c.Conn.DialContextTCP(ctx, ipp)
|
||||
case "udp":
|
||||
return c.Conn.DialContextUDP(ctx, ipp)
|
||||
default:
|
||||
return nil, xerrors.Errorf("unknown network %q", network)
|
||||
}
|
||||
}
|
||||
|
||||
// ListeningPorts lists the ports that are currently in use by the workspace.
|
||||
func (c *AgentConn) ListeningPorts(ctx context.Context) (codersdk.WorkspaceAgentListeningPortsResponse, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
res, err := c.apiRequest(ctx, http.MethodGet, "/api/v0/listening-ports", nil)
|
||||
if err != nil {
|
||||
return codersdk.WorkspaceAgentListeningPortsResponse{}, xerrors.Errorf("do request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return codersdk.WorkspaceAgentListeningPortsResponse{}, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
|
||||
var resp codersdk.WorkspaceAgentListeningPortsResponse
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
// DebugMagicsock makes a request to the workspace agent's magicsock debug endpoint.
|
||||
func (c *AgentConn) DebugMagicsock(ctx context.Context) ([]byte, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
res, err := c.apiRequest(ctx, http.MethodGet, "/debug/magicsock", nil)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("do request: %w", err)
|
||||
}
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
bs, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("read response body: %w", err)
|
||||
}
|
||||
return bs, nil
|
||||
}
|
||||
|
||||
// DebugManifest returns the agent's in-memory manifest. Unfortunately this must
|
||||
// be returns as a []byte to avoid an import cycle.
|
||||
func (c *AgentConn) DebugManifest(ctx context.Context) ([]byte, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
res, err := c.apiRequest(ctx, http.MethodGet, "/debug/manifest", nil)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("do request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
bs, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("read response body: %w", err)
|
||||
}
|
||||
return bs, nil
|
||||
}
|
||||
|
||||
// DebugLogs returns up to the last 10MB of `/tmp/coder-agent.log`
|
||||
func (c *AgentConn) DebugLogs(ctx context.Context) ([]byte, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
res, err := c.apiRequest(ctx, http.MethodGet, "/debug/logs", nil)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("do request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
bs, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("read response body: %w", err)
|
||||
}
|
||||
return bs, nil
|
||||
}
|
||||
|
||||
// PrometheusMetrics returns a response from the agent's prometheus metrics endpoint
|
||||
func (c *AgentConn) PrometheusMetrics(ctx context.Context) ([]byte, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
res, err := c.apiRequest(ctx, http.MethodGet, "/debug/prometheus", nil)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("do request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
bs, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("read response body: %w", err)
|
||||
}
|
||||
return bs, nil
|
||||
}
|
||||
|
||||
// apiRequest makes a request to the workspace agent's HTTP API server.
|
||||
func (c *AgentConn) apiRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
host := net.JoinHostPort(c.agentAddress().String(), strconv.Itoa(AgentHTTPAPIServerPort))
|
||||
url := fmt.Sprintf("http://%s%s", host, path)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("new http api request to %q: %w", url, err)
|
||||
}
|
||||
|
||||
return c.apiClient().Do(req)
|
||||
}
|
||||
|
||||
// apiClient returns an HTTP client that can be used to make
|
||||
// requests to the workspace agent's HTTP API server.
|
||||
func (c *AgentConn) apiClient() *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 the port is TailnetStatisticsPort.
|
||||
if port != strconv.Itoa(AgentHTTPAPIServerPort) {
|
||||
return nil, xerrors.Errorf("request %q does not appear to be for http api", addr)
|
||||
}
|
||||
|
||||
if !c.AwaitReachable(ctx) {
|
||||
return nil, xerrors.Errorf("workspace agent not reachable in time: %v", ctx.Err())
|
||||
}
|
||||
|
||||
ipAddr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse host addr: %w", err)
|
||||
}
|
||||
|
||||
conn, err := c.Conn.DialContextTCP(ctx, netip.AddrPortFrom(ipAddr, AgentHTTPAPIServerPort))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("dial http api: %w", err)
|
||||
}
|
||||
|
||||
return conn, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AgentConn) GetPeerDiagnostics() tailnet.PeerDiagnostics {
|
||||
return c.Conn.GetPeerDiagnostics(c.opts.AgentID)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package workspacesdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
"nhooyr.io/websocket"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
"github.com/coder/coder/v2/tailnet/proto"
|
||||
"github.com/coder/retry"
|
||||
)
|
||||
|
||||
// tailnetConn is the subset of the tailnet.Conn methods that tailnetAPIConnector uses. It is
|
||||
// included so that we can fake it in testing.
|
||||
//
|
||||
// @typescript-ignore tailnetConn
|
||||
type tailnetConn interface {
|
||||
tailnet.Coordinatee
|
||||
SetDERPMap(derpMap *tailcfg.DERPMap)
|
||||
}
|
||||
|
||||
// tailnetAPIConnector dials the tailnet API (v2+) and then uses the API with a tailnet.Conn to
|
||||
//
|
||||
// 1) run the Coordinate API and pass node information back and forth
|
||||
// 2) stream DERPMap updates and program the Conn
|
||||
//
|
||||
// These functions share the same websocket, and so are combined here so that if we hit a problem
|
||||
// we tear the whole thing down and start over with a new websocket.
|
||||
//
|
||||
// @typescript-ignore tailnetAPIConnector
|
||||
type tailnetAPIConnector struct {
|
||||
// We keep track of two contexts: the main context from the caller, and a "graceful" context
|
||||
// that we keep open slightly longer than the main context to give a chance to send the
|
||||
// Disconnect message to the coordinator. That tells the coordinator that we really meant to
|
||||
// disconnect instead of just losing network connectivity.
|
||||
ctx context.Context
|
||||
gracefulCtx context.Context
|
||||
cancelGracefulCtx context.CancelFunc
|
||||
|
||||
logger slog.Logger
|
||||
|
||||
agentID uuid.UUID
|
||||
coordinateURL string
|
||||
dialOptions *websocket.DialOptions
|
||||
conn tailnetConn
|
||||
|
||||
connected chan error
|
||||
isFirst bool
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
// runTailnetAPIConnector creates and runs a tailnetAPIConnector
|
||||
func runTailnetAPIConnector(
|
||||
ctx context.Context, logger slog.Logger,
|
||||
agentID uuid.UUID, coordinateURL string, dialOptions *websocket.DialOptions,
|
||||
conn tailnetConn,
|
||||
) *tailnetAPIConnector {
|
||||
tac := &tailnetAPIConnector{
|
||||
ctx: ctx,
|
||||
logger: logger,
|
||||
agentID: agentID,
|
||||
coordinateURL: coordinateURL,
|
||||
dialOptions: dialOptions,
|
||||
conn: conn,
|
||||
connected: make(chan error, 1),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
tac.gracefulCtx, tac.cancelGracefulCtx = context.WithCancel(context.Background())
|
||||
go tac.manageGracefulTimeout()
|
||||
go tac.run()
|
||||
return tac
|
||||
}
|
||||
|
||||
// manageGracefulTimeout allows the gracefulContext to last 1 second longer than the main context
|
||||
// to allow a graceful disconnect.
|
||||
func (tac *tailnetAPIConnector) manageGracefulTimeout() {
|
||||
defer tac.cancelGracefulCtx()
|
||||
<-tac.ctx.Done()
|
||||
select {
|
||||
case <-tac.closed:
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
func (tac *tailnetAPIConnector) run() {
|
||||
tac.isFirst = true
|
||||
defer close(tac.closed)
|
||||
for retrier := retry.New(50*time.Millisecond, 10*time.Second); retrier.Wait(tac.ctx); {
|
||||
tailnetClient, err := tac.dial()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tac.logger.Debug(tac.ctx, "obtained tailnet API v2+ client")
|
||||
tac.coordinateAndDERPMap(tailnetClient)
|
||||
tac.logger.Debug(tac.ctx, "tailnet API v2+ connection lost")
|
||||
}
|
||||
}
|
||||
|
||||
func (tac *tailnetAPIConnector) dial() (proto.DRPCTailnetClient, error) {
|
||||
tac.logger.Debug(tac.ctx, "dialing Coder tailnet v2+ API")
|
||||
// nolint:bodyclose
|
||||
ws, res, err := websocket.Dial(tac.ctx, tac.coordinateURL, tac.dialOptions)
|
||||
if tac.isFirst {
|
||||
if res != nil && res.StatusCode == http.StatusConflict {
|
||||
err = codersdk.ReadBodyAsError(res)
|
||||
tac.connected <- err
|
||||
return nil, err
|
||||
}
|
||||
tac.isFirst = false
|
||||
close(tac.connected)
|
||||
}
|
||||
if err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
tac.logger.Error(tac.ctx, "failed to dial tailnet v2+ API", slog.Error(err))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
client, err := tailnet.NewDRPCClient(
|
||||
websocket.NetConn(tac.gracefulCtx, ws, websocket.MessageBinary),
|
||||
tac.logger,
|
||||
)
|
||||
if err != nil {
|
||||
tac.logger.Debug(tac.ctx, "failed to create DRPCClient", slog.Error(err))
|
||||
_ = ws.Close(websocket.StatusInternalError, "")
|
||||
return nil, err
|
||||
}
|
||||
return client, err
|
||||
}
|
||||
|
||||
// coordinateAndDERPMap uses the provided client to coordinate and stream DERP Maps. It is combined
|
||||
// into one function so that a problem with one tears down the other and triggers a retry (if
|
||||
// appropriate). We multiplex both RPCs over the same websocket, so we want them to share the same
|
||||
// fate.
|
||||
func (tac *tailnetAPIConnector) coordinateAndDERPMap(client proto.DRPCTailnetClient) {
|
||||
defer func() {
|
||||
conn := client.DRPCConn()
|
||||
closeErr := conn.Close()
|
||||
if closeErr != nil &&
|
||||
!xerrors.Is(closeErr, io.EOF) &&
|
||||
!xerrors.Is(closeErr, context.Canceled) &&
|
||||
!xerrors.Is(closeErr, context.DeadlineExceeded) {
|
||||
tac.logger.Error(tac.ctx, "error closing DRPC connection", slog.Error(closeErr))
|
||||
<-conn.Closed()
|
||||
}
|
||||
}()
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
tac.coordinate(client)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
dErr := tac.derpMap(client)
|
||||
if dErr != nil && tac.ctx.Err() == nil {
|
||||
// The main context is still active, meaning that we want the tailnet data plane to stay
|
||||
// up, even though we hit some error getting DERP maps on the control plane. That means
|
||||
// we do NOT want to gracefully disconnect on the coordinate() routine. So, we'll just
|
||||
// close the underlying connection. This will trigger a retry of the control plane in
|
||||
// run().
|
||||
client.DRPCConn().Close()
|
||||
// Note that derpMap() logs it own errors, we don't bother here.
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (tac *tailnetAPIConnector) coordinate(client proto.DRPCTailnetClient) {
|
||||
// we use the gracefulCtx here so that we'll have time to send the graceful disconnect
|
||||
coord, err := client.Coordinate(tac.gracefulCtx)
|
||||
if err != nil {
|
||||
tac.logger.Error(tac.ctx, "failed to connect to Coordinate RPC", slog.Error(err))
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
cErr := coord.Close()
|
||||
if cErr != nil {
|
||||
tac.logger.Debug(tac.ctx, "error closing Coordinate RPC", slog.Error(cErr))
|
||||
}
|
||||
}()
|
||||
coordination := tailnet.NewRemoteCoordination(tac.logger, coord, tac.conn, tac.agentID)
|
||||
tac.logger.Debug(tac.ctx, "serving coordinator")
|
||||
select {
|
||||
case <-tac.ctx.Done():
|
||||
tac.logger.Debug(tac.ctx, "main context canceled; do graceful disconnect")
|
||||
crdErr := coordination.Close()
|
||||
if crdErr != nil {
|
||||
tac.logger.Warn(tac.ctx, "failed to close remote coordination", slog.Error(err))
|
||||
}
|
||||
case err = <-coordination.Error():
|
||||
if err != nil &&
|
||||
!xerrors.Is(err, io.EOF) &&
|
||||
!xerrors.Is(err, context.Canceled) &&
|
||||
!xerrors.Is(err, context.DeadlineExceeded) {
|
||||
tac.logger.Error(tac.ctx, "remote coordination error", slog.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (tac *tailnetAPIConnector) derpMap(client proto.DRPCTailnetClient) error {
|
||||
s, err := client.StreamDERPMaps(tac.ctx, &proto.StreamDERPMapsRequest{})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to connect to StreamDERPMaps RPC: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
cErr := s.Close()
|
||||
if cErr != nil {
|
||||
tac.logger.Debug(tac.ctx, "error closing StreamDERPMaps RPC", slog.Error(cErr))
|
||||
}
|
||||
}()
|
||||
for {
|
||||
dmp, err := s.Recv()
|
||||
if err != nil {
|
||||
if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
|
||||
return nil
|
||||
}
|
||||
tac.logger.Error(tac.ctx, "error receiving DERP Map", slog.Error(err))
|
||||
return err
|
||||
}
|
||||
tac.logger.Debug(tac.ctx, "got new DERP Map", slog.F("derp_map", dmp))
|
||||
dm := tailnet.DERPMapFromProto(dmp)
|
||||
tac.conn.SetDERPMap(dm)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package workspacesdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
"nhooyr.io/websocket"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
"github.com/coder/coder/v2/tailnet/proto"
|
||||
)
|
||||
|
||||
// AgentIP is a static IPv6 address with the Tailscale prefix that is used to route
|
||||
// connections from clients to this node. A dynamic address is not required because a Tailnet
|
||||
// client only dials a single agent at a time.
|
||||
//
|
||||
// Deprecated: use tailnet.IP() instead. This is kept for backwards
|
||||
// compatibility with outdated CLI clients and Workspace Proxies that dial it.
|
||||
// See: https://github.com/coder/coder/issues/11819
|
||||
var AgentIP = netip.MustParseAddr("fd7a:115c:a1e0:49d6:b259:b7ac:b1b2:48f4")
|
||||
|
||||
var ErrSkipClose = xerrors.New("skip tailnet close")
|
||||
|
||||
const (
|
||||
AgentSSHPort = tailnet.WorkspaceAgentSSHPort
|
||||
AgentReconnectingPTYPort = tailnet.WorkspaceAgentReconnectingPTYPort
|
||||
AgentSpeedtestPort = tailnet.WorkspaceAgentSpeedtestPort
|
||||
// AgentHTTPAPIServerPort serves a HTTP server with endpoints for e.g.
|
||||
// gathering agent statistics.
|
||||
AgentHTTPAPIServerPort = 4
|
||||
|
||||
// AgentMinimumListeningPort 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.
|
||||
AgentMinimumListeningPort = 9
|
||||
)
|
||||
|
||||
// AgentIgnoredListeningPorts contains a list of ports to ignore when looking for
|
||||
// running applications inside a workspace. We want to ignore non-HTTP servers,
|
||||
// so we pre-populate this list with common ports that are not HTTP servers.
|
||||
//
|
||||
// This is implemented as a map for fast lookup.
|
||||
var AgentIgnoredListeningPorts = 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() {
|
||||
if !strings.HasSuffix(os.Args[0], ".test") {
|
||||
return
|
||||
}
|
||||
// Add a thousand more ports to the ignore list during tests so it's easier
|
||||
// to find an available port.
|
||||
for i := 63000; i < 64000; i++ {
|
||||
AgentIgnoredListeningPorts[uint16(i)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
client *codersdk.Client
|
||||
}
|
||||
|
||||
func New(c *codersdk.Client) *Client {
|
||||
return &Client{client: c}
|
||||
}
|
||||
|
||||
// AgentConnectionInfo returns required information for establishing
|
||||
// a connection with a workspace.
|
||||
// @typescript-ignore AgentConnectionInfo
|
||||
type AgentConnectionInfo struct {
|
||||
DERPMap *tailcfg.DERPMap `json:"derp_map"`
|
||||
DERPForceWebSockets bool `json:"derp_force_websockets"`
|
||||
DisableDirectConnections bool `json:"disable_direct_connections"`
|
||||
}
|
||||
|
||||
func (c *Client) AgentConnectionInfoGeneric(ctx context.Context) (AgentConnectionInfo, error) {
|
||||
res, err := c.client.Request(ctx, http.MethodGet, "/api/v2/workspaceagents/connection", nil)
|
||||
if err != nil {
|
||||
return AgentConnectionInfo{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return AgentConnectionInfo{}, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
|
||||
var connInfo AgentConnectionInfo
|
||||
return connInfo, json.NewDecoder(res.Body).Decode(&connInfo)
|
||||
}
|
||||
|
||||
func (c *Client) AgentConnectionInfo(ctx context.Context, agentID uuid.UUID) (AgentConnectionInfo, error) {
|
||||
res, err := c.client.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/connection", agentID), nil)
|
||||
if err != nil {
|
||||
return AgentConnectionInfo{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return AgentConnectionInfo{}, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
|
||||
var connInfo AgentConnectionInfo
|
||||
return connInfo, json.NewDecoder(res.Body).Decode(&connInfo)
|
||||
}
|
||||
|
||||
// @typescript-ignore DialAgentOptions
|
||||
type DialAgentOptions struct {
|
||||
Logger slog.Logger
|
||||
// BlockEndpoints forced a direct connection through DERP. The Client may
|
||||
// have DisableDirect set which will override this value.
|
||||
BlockEndpoints bool
|
||||
}
|
||||
|
||||
func (c *Client) DialAgent(dialCtx context.Context, agentID uuid.UUID, options *DialAgentOptions) (agentConn *AgentConn, err error) {
|
||||
if options == nil {
|
||||
options = &DialAgentOptions{}
|
||||
}
|
||||
|
||||
connInfo, err := c.AgentConnectionInfo(dialCtx, agentID)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get connection info: %w", err)
|
||||
}
|
||||
if connInfo.DisableDirectConnections {
|
||||
options.BlockEndpoints = true
|
||||
}
|
||||
|
||||
ip := tailnet.IP()
|
||||
var header http.Header
|
||||
if headerTransport, ok := c.client.HTTPClient.Transport.(*codersdk.HeaderTransport); ok {
|
||||
header = headerTransport.Header
|
||||
}
|
||||
conn, err := tailnet.NewConn(&tailnet.Options{
|
||||
Addresses: []netip.Prefix{netip.PrefixFrom(ip, 128)},
|
||||
DERPMap: connInfo.DERPMap,
|
||||
DERPHeader: &header,
|
||||
DERPForceWebSockets: connInfo.DERPForceWebSockets,
|
||||
Logger: options.Logger,
|
||||
BlockEndpoints: c.client.DisableDirectConnections || options.BlockEndpoints,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create tailnet: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
headers := make(http.Header)
|
||||
tokenHeader := codersdk.SessionTokenHeader
|
||||
if c.client.SessionTokenHeader != "" {
|
||||
tokenHeader = c.client.SessionTokenHeader
|
||||
}
|
||||
headers.Set(tokenHeader, c.client.SessionToken())
|
||||
|
||||
// New context, separate from dialCtx. We don't want to cancel the
|
||||
// connection if dialCtx is canceled.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer func() {
|
||||
if err != nil {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
coordinateURL, err := c.client.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/coordinate", agentID))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse url: %w", err)
|
||||
}
|
||||
q := coordinateURL.Query()
|
||||
q.Add("version", proto.CurrentVersion.String())
|
||||
coordinateURL.RawQuery = q.Encode()
|
||||
|
||||
connector := runTailnetAPIConnector(ctx, options.Logger,
|
||||
agentID, coordinateURL.String(),
|
||||
&websocket.DialOptions{
|
||||
HTTPClient: c.client.HTTPClient,
|
||||
HTTPHeader: headers,
|
||||
// Need to disable compression to avoid a data-race.
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
},
|
||||
conn,
|
||||
)
|
||||
options.Logger.Debug(ctx, "running tailnet API v2+ connector")
|
||||
|
||||
select {
|
||||
case <-dialCtx.Done():
|
||||
return nil, xerrors.Errorf("timed out waiting for coordinator and derp map: %w", dialCtx.Err())
|
||||
case err = <-connector.connected:
|
||||
if err != nil {
|
||||
options.Logger.Error(ctx, "failed to connect to tailnet v2+ API", slog.Error(err))
|
||||
return nil, xerrors.Errorf("start connector: %w", err)
|
||||
}
|
||||
options.Logger.Debug(ctx, "connected to tailnet v2+ API")
|
||||
}
|
||||
|
||||
agentConn = NewAgentConn(conn, AgentConnOptions{
|
||||
AgentID: agentID,
|
||||
CloseFunc: func() error {
|
||||
cancel()
|
||||
<-connector.closed
|
||||
return conn.Close()
|
||||
},
|
||||
})
|
||||
|
||||
if !agentConn.AwaitReachable(dialCtx) {
|
||||
_ = agentConn.Close()
|
||||
return nil, xerrors.Errorf("timed out waiting for agent to become reachable: %w", dialCtx.Err())
|
||||
}
|
||||
|
||||
return agentConn, nil
|
||||
}
|
||||
|
||||
// @typescript-ignore:WorkspaceAgentReconnectingPTYOpts
|
||||
type WorkspaceAgentReconnectingPTYOpts struct {
|
||||
AgentID uuid.UUID
|
||||
Reconnect uuid.UUID
|
||||
Width uint16
|
||||
Height uint16
|
||||
Command string
|
||||
|
||||
// SignedToken is an optional signed token from the
|
||||
// issue-reconnecting-pty-signed-token endpoint. If set, the session token
|
||||
// on the client will not be sent.
|
||||
SignedToken string
|
||||
}
|
||||
|
||||
// AgentReconnectingPTY spawns a PTY that reconnects using the token provided.
|
||||
// It communicates using `agent.ReconnectingPTYRequest` marshaled as JSON.
|
||||
// Responses are PTY output that can be rendered.
|
||||
func (c *Client) AgentReconnectingPTY(ctx context.Context, opts WorkspaceAgentReconnectingPTYOpts) (net.Conn, error) {
|
||||
serverURL, err := c.client.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/pty", opts.AgentID))
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse url: %w", err)
|
||||
}
|
||||
q := serverURL.Query()
|
||||
q.Set("reconnect", opts.Reconnect.String())
|
||||
q.Set("width", strconv.Itoa(int(opts.Width)))
|
||||
q.Set("height", strconv.Itoa(int(opts.Height)))
|
||||
q.Set("command", opts.Command)
|
||||
// If we're using a signed token, set the query parameter.
|
||||
if opts.SignedToken != "" {
|
||||
q.Set(codersdk.SignedAppTokenQueryParameter, opts.SignedToken)
|
||||
}
|
||||
serverURL.RawQuery = q.Encode()
|
||||
|
||||
// If we're not using a signed token, we need to set the session token as a
|
||||
// cookie.
|
||||
httpClient := c.client.HTTPClient
|
||||
if opts.SignedToken == "" {
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create cookie jar: %w", err)
|
||||
}
|
||||
jar.SetCookies(serverURL, []*http.Cookie{{
|
||||
Name: codersdk.SessionTokenCookie,
|
||||
Value: c.client.SessionToken(),
|
||||
}})
|
||||
httpClient = &http.Client{
|
||||
Jar: jar,
|
||||
Transport: c.client.HTTPClient.Transport,
|
||||
}
|
||||
}
|
||||
//nolint:bodyclose
|
||||
conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{
|
||||
HTTPClient: httpClient,
|
||||
})
|
||||
if err != nil {
|
||||
if res == nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
return websocket.NetConn(context.Background(), conn, websocket.MessageBinary), nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package workspacesdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"nhooyr.io/websocket"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
"github.com/coder/coder/v2/tailnet/proto"
|
||||
"github.com/coder/coder/v2/tailnet/tailnettest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestTailnetAPIConnector_Disconnects(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCtx := testutil.Context(t, testutil.WaitShort)
|
||||
ctx, cancel := context.WithCancel(testCtx)
|
||||
logger := slogtest.Make(t, &slogtest.Options{
|
||||
// we get EOF when we simulate a DERPMap error
|
||||
IgnoredErrorIs: append(slogtest.DefaultIgnoredErrorIs, io.EOF),
|
||||
}).Leveled(slog.LevelDebug)
|
||||
agentID := uuid.UUID{0x55}
|
||||
clientID := uuid.UUID{0x66}
|
||||
fCoord := tailnettest.NewFakeCoordinator()
|
||||
var coord tailnet.Coordinator = fCoord
|
||||
coordPtr := atomic.Pointer[tailnet.Coordinator]{}
|
||||
coordPtr.Store(&coord)
|
||||
derpMapCh := make(chan *tailcfg.DERPMap)
|
||||
defer close(derpMapCh)
|
||||
svc, err := tailnet.NewClientService(
|
||||
logger, &coordPtr,
|
||||
time.Millisecond, func() *tailcfg.DERPMap { return <-derpMapCh },
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sws, err := websocket.Accept(w, r, nil)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
ctx, nc := codersdk.WebsocketNetConn(r.Context(), sws, websocket.MessageBinary)
|
||||
err = svc.ServeConnV2(ctx, nc, tailnet.StreamID{
|
||||
Name: "client",
|
||||
ID: clientID,
|
||||
Auth: tailnet.ClientCoordinateeAuth{AgentID: agentID},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
}))
|
||||
|
||||
fConn := newFakeTailnetConn()
|
||||
|
||||
uut := runTailnetAPIConnector(ctx, logger, agentID, svr.URL, &websocket.DialOptions{}, fConn)
|
||||
|
||||
call := testutil.RequireRecvCtx(ctx, t, fCoord.CoordinateCalls)
|
||||
reqTun := testutil.RequireRecvCtx(ctx, t, call.Reqs)
|
||||
require.NotNil(t, reqTun.AddTunnel)
|
||||
|
||||
_ = testutil.RequireRecvCtx(ctx, t, uut.connected)
|
||||
|
||||
// simulate a problem with DERPMaps by sending nil
|
||||
testutil.RequireSendCtx(ctx, t, derpMapCh, nil)
|
||||
|
||||
// this should cause the coordinate call to hang up WITHOUT disconnecting
|
||||
reqNil := testutil.RequireRecvCtx(ctx, t, call.Reqs)
|
||||
require.Nil(t, reqNil)
|
||||
|
||||
// ...and then reconnect
|
||||
call = testutil.RequireRecvCtx(ctx, t, fCoord.CoordinateCalls)
|
||||
reqTun = testutil.RequireRecvCtx(ctx, t, call.Reqs)
|
||||
require.NotNil(t, reqTun.AddTunnel)
|
||||
|
||||
// canceling the context should trigger the disconnect message
|
||||
cancel()
|
||||
reqDisc := testutil.RequireRecvCtx(testCtx, t, call.Reqs)
|
||||
require.NotNil(t, reqDisc)
|
||||
require.NotNil(t, reqDisc.Disconnect)
|
||||
}
|
||||
|
||||
type fakeTailnetConn struct{}
|
||||
|
||||
func (*fakeTailnetConn) UpdatePeers([]*proto.CoordinateResponse_PeerUpdate) error {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (*fakeTailnetConn) SetAllPeersLost() {}
|
||||
|
||||
func (*fakeTailnetConn) SetNodeCallback(func(*tailnet.Node)) {}
|
||||
|
||||
func (*fakeTailnetConn) SetDERPMap(*tailcfg.DERPMap) {}
|
||||
|
||||
func newFakeTailnetConn() *fakeTailnetConn {
|
||||
return &fakeTailnetConn{}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package workspacesdk_test
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
)
|
||||
|
||||
func TestWorkspaceRewriteDERPMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
// This test ensures that RewriteDERPMap mutates built-in DERPs with the
|
||||
// client access URL.
|
||||
dm := &tailcfg.DERPMap{
|
||||
Regions: map[int]*tailcfg.DERPRegion{
|
||||
1: {
|
||||
EmbeddedRelay: true,
|
||||
RegionID: 1,
|
||||
Nodes: []*tailcfg.DERPNode{{
|
||||
HostName: "bananas.org",
|
||||
DERPPort: 1,
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
parsed, err := url.Parse("https://coconuts.org:44558")
|
||||
require.NoError(t, err)
|
||||
client := agentsdk.New(parsed)
|
||||
client.RewriteDERPMap(dm)
|
||||
region := dm.Regions[1]
|
||||
require.True(t, region.EmbeddedRelay)
|
||||
require.Len(t, region.Nodes, 1)
|
||||
node := region.Nodes[0]
|
||||
require.Equal(t, "coconuts.org", node.HostName)
|
||||
require.Equal(t, 44558, node.DERPPort)
|
||||
}
|
||||
Reference in New Issue
Block a user