diff --git a/agent/agent.go b/agent/agent.go index b86db9a4ffd68..7bcdcbf53205f 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -593,7 +593,7 @@ func (a *agent) run(ctx context.Context) error { network := a.network a.closeMutex.Unlock() if network == nil { - network, err = a.createTailnet(ctx, manifest.DERPMap) + network, err = a.createTailnet(ctx, manifest.DERPMap, manifest.DisableDirectConnections) if err != nil { return xerrors.Errorf("create tailnet: %w", err) } @@ -611,8 +611,9 @@ func (a *agent) run(ctx context.Context) error { a.startReportingConnectionStats(ctx) } else { - // Update the DERP map! + // Update the DERP map and allow/disallow direct connections. network.SetDERPMap(manifest.DERPMap) + network.SetBlockEndpoints(manifest.DisableDirectConnections) } a.logger.Debug(ctx, "running tailnet connection coordinator") @@ -637,12 +638,13 @@ func (a *agent) trackConnGoroutine(fn func()) error { return nil } -func (a *agent) createTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) (_ *tailnet.Conn, err error) { +func (a *agent) createTailnet(ctx context.Context, derpMap *tailcfg.DERPMap, disableDirectConnections bool) (_ *tailnet.Conn, err error) { network, err := tailnet.NewConn(&tailnet.Options{ - Addresses: []netip.Prefix{netip.PrefixFrom(codersdk.WorkspaceAgentIP, 128)}, - DERPMap: derpMap, - Logger: a.logger.Named("tailnet"), - ListenPort: a.tailnetListenPort, + Addresses: []netip.Prefix{netip.PrefixFrom(codersdk.WorkspaceAgentIP, 128)}, + DERPMap: derpMap, + Logger: a.logger.Named("tailnet"), + ListenPort: a.tailnetListenPort, + BlockEndpoints: disableDirectConnections, }) if err != nil { return nil, xerrors.Errorf("create tailnet: %w", err) diff --git a/cli/netcheck_test.go b/cli/netcheck_test.go index a9b346554facf..890260c1a704e 100644 --- a/cli/netcheck_test.go +++ b/cli/netcheck_test.go @@ -25,10 +25,14 @@ func TestNetcheck(t *testing.T) { clitest.StartWithWaiter(t, inv).RequireSuccess() + b := out.Bytes() + t.Log(string(b)) var report healthcheck.DERPReport - require.NoError(t, json.Unmarshal(out.Bytes(), &report)) + require.NoError(t, json.Unmarshal(b, &report)) assert.True(t, report.Healthy) require.Len(t, report.Regions, 1) - require.Len(t, report.Regions[1].NodeReports, 1) + for _, v := range report.Regions { + require.Len(t, v.NodeReports, len(v.Region.Nodes)) + } } diff --git a/cli/server.go b/cli/server.go index e30bf5fd71fde..fe85a05114a98 100644 --- a/cli/server.go +++ b/cli/server.go @@ -413,6 +413,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. derpMap, err := tailnet.NewDERPMap( ctx, defaultRegion, cfg.DERP.Server.STUNAddresses, cfg.DERP.Config.URL.String(), cfg.DERP.Config.Path.String(), + cfg.DERP.Config.BlockDirect.Value(), ) if err != nil { return xerrors.Errorf("create derp map: %w", err) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 340f167855d88..70e81cd42c997 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -150,6 +150,14 @@ between workspaces and users are peer-to-peer. However, when Coder cannot establish a peer to peer connection, Coder uses a distributed relay network backed by Tailscale and WireGuard. + --block-direct-connections bool, $CODER_BLOCK_DIRECT + Block peer-to-peer (aka. direct) workspace connections. All workspace + connections from the CLI will be proxied through Coder (or custom + configured DERP servers) and will never be peer-to-peer when enabled. + Workspaces may still reach out to STUN servers to get their address + until they are restarted after this change has been made, but new + connections will still be proxied regardless. + --derp-config-path string, $CODER_DERP_CONFIG_PATH Path to read a DERP mapping from. See: https://tailscale.com/kb/1118/custom-derp-servers/. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 79a678e7abd2f..31c14343c9f66 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -117,6 +117,13 @@ networking: # for high availability. # (default: , type: url) relayURL: + # Block peer-to-peer (aka. direct) workspace connections. All workspace + # connections from the CLI will be proxied through Coder (or custom configured + # DERP servers) and will never be peer-to-peer when enabled. Workspaces may still + # reach out to STUN servers to get their address until they are restarted after + # this change has been made, but new connections will still be proxied regardless. + # (default: , type: bool) + blockDirect: false # URL to fetch a DERP mapping on startup. See: # https://tailscale.com/kb/1118/custom-derp-servers/. # (default: , type: string) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2f4fd2fbeffa0..323d5c7109e24 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -5784,6 +5784,9 @@ const docTemplate = `{ "directory": { "type": "string" }, + "disable_direct_connections": { + "type": "boolean" + }, "environment_variables": { "type": "object", "additionalProperties": { @@ -7065,6 +7068,9 @@ const docTemplate = `{ "codersdk.DERPConfig": { "type": "object", "properties": { + "block_direct": { + "type": "boolean" + }, "path": { "type": "string" }, @@ -9351,6 +9357,9 @@ const docTemplate = `{ "properties": { "derp_map": { "$ref": "#/definitions/tailcfg.DERPMap" + }, + "disable_direct_connections": { + "type": "boolean" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 9444a2b803661..7a9902eba816b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5095,6 +5095,9 @@ "directory": { "type": "string" }, + "disable_direct_connections": { + "type": "boolean" + }, "environment_variables": { "type": "object", "additionalProperties": { @@ -6294,6 +6297,9 @@ "codersdk.DERPConfig": { "type": "object", "properties": { + "block_direct": { + "type": "boolean" + }, "path": { "type": "string" }, @@ -8441,6 +8447,9 @@ "properties": { "derp_map": { "$ref": "#/definitions/tailcfg.DERPMap" + }, + "disable_direct_connections": { + "type": "boolean" } } }, diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 5d03b49016f28..701caacb7b718 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -293,6 +293,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can } stunAddr, stunCleanup := stuntest.ServeWithPacketListener(t, nettype.Std{}) + stunAddr.IP = net.ParseIP("127.0.0.1") t.Cleanup(stunCleanup) derpServer := derp.NewServer(key.NewNode(), tailnet.Logger(slogtest.Make(t, nil).Named("derp").Leveled(slog.LevelDebug))) @@ -310,6 +311,29 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can require.NoError(t, err) } + region := &tailcfg.DERPRegion{ + EmbeddedRelay: true, + RegionID: int(options.DeploymentValues.DERP.Server.RegionID.Value()), + RegionCode: options.DeploymentValues.DERP.Server.RegionCode.String(), + RegionName: options.DeploymentValues.DERP.Server.RegionName.String(), + Nodes: []*tailcfg.DERPNode{{ + Name: fmt.Sprintf("%db", options.DeploymentValues.DERP.Server.RegionID), + RegionID: int(options.DeploymentValues.DERP.Server.RegionID.Value()), + IPv4: "127.0.0.1", + DERPPort: derpPort, + // STUN port is added as a separate node by tailnet.NewDERPMap() if + // direct connections are enabled. + STUNPort: -1, + InsecureForTests: true, + ForceHTTP: options.TLSCertificates == nil, + }}, + } + if !options.DeploymentValues.DERP.Server.Enable.Value() { + region = nil + } + derpMap, err := tailnet.NewDERPMap(ctx, region, []string{stunAddr.String()}, "", "", options.DeploymentValues.DERP.Config.BlockDirect.Value()) + require.NoError(t, err) + return func(h http.Handler) { mutex.Lock() defer mutex.Unlock() @@ -328,42 +352,24 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can Pubsub: options.Pubsub, GitAuthConfigs: options.GitAuthConfigs, - Auditor: options.Auditor, - AWSCertificates: options.AWSCertificates, - AzureCertificates: options.AzureCertificates, - GithubOAuth2Config: options.GithubOAuth2Config, - RealIPConfig: options.RealIPConfig, - OIDCConfig: options.OIDCConfig, - GoogleTokenValidator: options.GoogleTokenValidator, - SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, - DERPServer: derpServer, - APIRateLimit: options.APIRateLimit, - LoginRateLimit: options.LoginRateLimit, - FilesRateLimit: options.FilesRateLimit, - Authorizer: options.Authorizer, - Telemetry: telemetry.NewNoop(), - TemplateScheduleStore: &templateScheduleStore, - TLSCertificates: options.TLSCertificates, - TrialGenerator: options.TrialGenerator, - DERPMap: &tailcfg.DERPMap{ - Regions: map[int]*tailcfg.DERPRegion{ - 1: { - EmbeddedRelay: true, - RegionID: 1, - RegionCode: "coder", - RegionName: "Coder", - Nodes: []*tailcfg.DERPNode{{ - Name: "1a", - RegionID: 1, - IPv4: "127.0.0.1", - DERPPort: derpPort, - STUNPort: stunAddr.Port, - InsecureForTests: true, - ForceHTTP: options.TLSCertificates == nil, - }}, - }, - }, - }, + Auditor: options.Auditor, + AWSCertificates: options.AWSCertificates, + AzureCertificates: options.AzureCertificates, + GithubOAuth2Config: options.GithubOAuth2Config, + RealIPConfig: options.RealIPConfig, + OIDCConfig: options.OIDCConfig, + GoogleTokenValidator: options.GoogleTokenValidator, + SSHKeygenAlgorithm: options.SSHKeygenAlgorithm, + DERPServer: derpServer, + APIRateLimit: options.APIRateLimit, + LoginRateLimit: options.LoginRateLimit, + FilesRateLimit: options.FilesRateLimit, + Authorizer: options.Authorizer, + Telemetry: telemetry.NewNoop(), + TemplateScheduleStore: &templateScheduleStore, + TLSCertificates: options.TLSCertificates, + TrialGenerator: options.TrialGenerator, + DERPMap: derpMap, MetricsCacheRefreshInterval: options.MetricsCacheRefreshInterval, AgentStatsRefreshInterval: options.AgentStatsRefreshInterval, DeploymentValues: options.DeploymentValues, diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index a0ccbb966f610..25c7861ce4ae4 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -161,18 +161,19 @@ func (api *API) workspaceAgentManifest(rw http.ResponseWriter, r *http.Request) } httpapi.Write(ctx, rw, http.StatusOK, agentsdk.Manifest{ - Apps: convertApps(dbApps), - DERPMap: api.DERPMap, - GitAuthConfigs: len(api.GitAuthConfigs), - EnvironmentVariables: apiAgent.EnvironmentVariables, - StartupScript: apiAgent.StartupScript, - Directory: apiAgent.Directory, - VSCodePortProxyURI: vscodeProxyURI, - MOTDFile: workspaceAgent.MOTDFile, - StartupScriptTimeout: time.Duration(apiAgent.StartupScriptTimeoutSeconds) * time.Second, - ShutdownScript: apiAgent.ShutdownScript, - ShutdownScriptTimeout: time.Duration(apiAgent.ShutdownScriptTimeoutSeconds) * time.Second, - Metadata: convertWorkspaceAgentMetadataDesc(metadata), + Apps: convertApps(dbApps), + DERPMap: api.DERPMap, + GitAuthConfigs: len(api.GitAuthConfigs), + EnvironmentVariables: apiAgent.EnvironmentVariables, + StartupScript: apiAgent.StartupScript, + Directory: apiAgent.Directory, + VSCodePortProxyURI: vscodeProxyURI, + MOTDFile: workspaceAgent.MOTDFile, + StartupScriptTimeout: time.Duration(apiAgent.StartupScriptTimeoutSeconds) * time.Second, + ShutdownScript: apiAgent.ShutdownScript, + ShutdownScriptTimeout: time.Duration(apiAgent.ShutdownScriptTimeoutSeconds) * time.Second, + DisableDirectConnections: api.DeploymentValues.DERP.Config.BlockDirect.Value(), + Metadata: convertWorkspaceAgentMetadataDesc(metadata), }) } @@ -731,9 +732,10 @@ func (api *API) workspaceAgentListeningPorts(rw http.ResponseWriter, r *http.Req func (api *API) dialWorkspaceAgentTailnet(agentID uuid.UUID) (*codersdk.WorkspaceAgentConn, error) { clientConn, serverConn := net.Pipe() conn, err := tailnet.NewConn(&tailnet.Options{ - Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)}, - DERPMap: api.DERPMap, - Logger: api.Logger.Named("tailnet"), + Addresses: []netip.Prefix{netip.PrefixFrom(tailnet.IP(), 128)}, + DERPMap: api.DERPMap, + Logger: api.Logger.Named("tailnet"), + BlockEndpoints: api.DeploymentValues.DERP.Config.BlockDirect.Value(), }) if err != nil { _ = clientConn.Close() @@ -801,7 +803,8 @@ func (api *API) workspaceAgentConnection(rw http.ResponseWriter, r *http.Request ctx := r.Context() httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceAgentConnectionInfo{ - DERPMap: api.DERPMap, + DERPMap: api.DERPMap, + DisableDirectConnections: api.DeploymentValues.DERP.Config.BlockDirect.Value(), }) } diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index eb8a8eb5252be..7fcde117fd74a 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -2,6 +2,7 @@ package coderd_test import ( "context" + "encoding/json" "fmt" "net" "net/http" @@ -575,6 +576,82 @@ func TestWorkspaceAgentTailnet(t *testing.T) { require.Equal(t, "test", strings.TrimSpace(string(output))) } +func TestWorkspaceAgentTailnetDirectDisabled(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + err := dv.DERP.Config.BlockDirect.Set("true") + require.NoError(t, err) + require.True(t, dv.DERP.Config.BlockDirect.Value()) + + client, daemonCloser := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{ + DeploymentValues: dv, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(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) + daemonCloser.Close() + + ctx := testutil.Context(t, testutil.WaitLong) + + // Verify that the manifest has DisableDirectConnections set to true. + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(authToken) + manifest, err := agentClient.Manifest(ctx) + require.NoError(t, err) + require.True(t, manifest.DisableDirectConnections) + + agentCloser := agent.New(agent.Options{ + Client: agentClient, + Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug), + }) + defer agentCloser.Close() + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + agentID := resources[0].Agents[0].ID + + // Verify that the connection data has no STUN ports and + // DisableDirectConnections set to true. + res, err := client.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaceagents/%s/connection", agentID), nil) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + var connInfo codersdk.WorkspaceAgentConnectionInfo + err = json.NewDecoder(res.Body).Decode(&connInfo) + require.NoError(t, err) + require.True(t, connInfo.DisableDirectConnections) + for _, region := range connInfo.DERPMap.Regions { + t.Logf("region %s (%v)", region.RegionCode, region.EmbeddedRelay) + for _, node := range region.Nodes { + t.Logf(" node %s (stun %d)", node.Name, node.STUNPort) + require.EqualValues(t, -1, node.STUNPort) + // tailnet.NewDERPMap() will create nodes with "stun" in the name, + // but not if direct is disabled. + require.NotContains(t, node.Name, "stun") + require.False(t, node.STUNOnly) + } + } + + conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{ + Logger: slogtest.Make(t, nil).Named("client").Leveled(slog.LevelDebug), + }) + require.NoError(t, err) + defer conn.Close() + require.True(t, conn.BlockEndpoints()) + + require.True(t, conn.AwaitReachable(ctx)) + _, p2p, _, err := conn.Ping(ctx) + require.NoError(t, err) + require.False(t, p2p) +} + func TestWorkspaceAgentListeningPorts(t *testing.T) { t.Parallel() diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index 289ee07eafd9f..ac0211cf2d37e 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -87,18 +87,19 @@ type Manifest struct { // GitAuthConfigs stores the number of Git configurations // the Coder deployment has. If this number is >0, we // set up special configuration in the workspace. - GitAuthConfigs int `json:"git_auth_configs"` - VSCodePortProxyURI string `json:"vscode_port_proxy_uri"` - Apps []codersdk.WorkspaceApp `json:"apps"` - DERPMap *tailcfg.DERPMap `json:"derpmap"` - EnvironmentVariables map[string]string `json:"environment_variables"` - StartupScript string `json:"startup_script"` - StartupScriptTimeout time.Duration `json:"startup_script_timeout"` - Directory string `json:"directory"` - MOTDFile string `json:"motd_file"` - ShutdownScript string `json:"shutdown_script"` - ShutdownScriptTimeout time.Duration `json:"shutdown_script_timeout"` - Metadata []codersdk.WorkspaceAgentMetadataDescription `json:"metadata"` + GitAuthConfigs int `json:"git_auth_configs"` + VSCodePortProxyURI string `json:"vscode_port_proxy_uri"` + Apps []codersdk.WorkspaceApp `json:"apps"` + DERPMap *tailcfg.DERPMap `json:"derpmap"` + EnvironmentVariables map[string]string `json:"environment_variables"` + StartupScript string `json:"startup_script"` + StartupScriptTimeout time.Duration `json:"startup_script_timeout"` + Directory string `json:"directory"` + MOTDFile string `json:"motd_file"` + ShutdownScript string `json:"shutdown_script"` + ShutdownScriptTimeout time.Duration `json:"shutdown_script_timeout"` + DisableDirectConnections bool `json:"disable_direct_connections"` + Metadata []codersdk.WorkspaceAgentMetadataDescription `json:"metadata"` } // Manifest fetches manifest for the currently authenticated workspace agent. diff --git a/codersdk/deployment.go b/codersdk/deployment.go index aa62aebba5cdd..18f4148edd4f8 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -221,8 +221,9 @@ type DERPServerConfig struct { } type DERPConfig struct { - URL clibase.String `json:"url" typescript:",notnull"` - Path clibase.String `json:"path" typescript:",notnull"` + BlockDirect clibase.Bool `json:"block_direct" typescript:",notnull"` + URL clibase.String `json:"url" typescript:",notnull"` + Path clibase.String `json:"path" typescript:",notnull"` } type PrometheusConfig struct { @@ -711,6 +712,18 @@ when required by your organization's security policy.`, Group: &deploymentGroupNetworkingDERP, YAML: "relayURL", }, + { + Name: "Block Direct Connections", + Description: "Block peer-to-peer (aka. direct) workspace connections. All workspace connections from the CLI will be proxied through Coder (or custom configured DERP servers) and will never be peer-to-peer when enabled. Workspaces may still reach out to STUN servers to get their address until they are restarted after this change has been made, but new connections will still be proxied regardless.", + // This cannot be called `disable-direct-connections` because that's + // already a global CLI flag for CLI connections. This is a + // deployment-wide flag. + Flag: "block-direct-connections", + Env: "CODER_BLOCK_DIRECT", + Value: &c.DERP.Config.BlockDirect, + Group: &deploymentGroupNetworkingDERP, + YAML: "blockDirect", + }, { Name: "DERP Config URL", Description: "URL to fetch a DERP mapping on startup. See: https://tailscale.com/kb/1118/custom-derp-servers/.", diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 4b5b636f11411..c6fda00cbd95c 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -167,7 +167,8 @@ type DERPRegion struct { // a connection with a workspace. // @typescript-ignore WorkspaceAgentConnectionInfo type WorkspaceAgentConnectionInfo struct { - DERPMap *tailcfg.DERPMap `json:"derp_map"` + DERPMap *tailcfg.DERPMap `json:"derp_map"` + DisableDirectConnections bool `json:"disable_direct_connections"` } func (c *Client) WorkspaceAgentConnectionInfo(ctx context.Context) (*WorkspaceAgentConnectionInfo, error) { @@ -215,6 +216,9 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, opti if err != nil { return nil, xerrors.Errorf("decode conn info: %w", err) } + if connInfo.DisableDirectConnections { + options.BlockEndpoints = true + } ip := tailnet.IP() var header http.Header diff --git a/docs/api/agents.md b/docs/api/agents.md index d1fa59cdff921..b8c73c8ceae95 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -363,6 +363,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/me/manifest \ } }, "directory": "string", + "disable_direct_connections": true, "environment_variables": { "property1": "string", "property2": "string" @@ -567,7 +568,8 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con "regionName": "string" } } - } + }, + "disable_direct_connections": true } ``` diff --git a/docs/api/general.md b/docs/api/general.md index 22be046b47e64..7af5298fc3f1e 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -167,6 +167,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ }, "derp": { "config": { + "block_direct": true, "path": "string", "url": "string" }, diff --git a/docs/api/schemas.md b/docs/api/schemas.md index caac33caea53b..cb20b734cda29 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -232,6 +232,7 @@ } }, "directory": "string", + "disable_direct_connections": true, "environment_variables": { "property1": "string", "property2": "string" @@ -257,21 +258,22 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------------- | ------------------------------------------------------------------------------------------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `apps` | array of [codersdk.WorkspaceApp](#codersdkworkspaceapp) | false | | | -| `derpmap` | [tailcfg.DERPMap](#tailcfgderpmap) | false | | | -| `directory` | string | false | | | -| `environment_variables` | object | false | | | -| » `[any property]` | string | false | | | -| `git_auth_configs` | integer | false | | Git auth configs stores the number of Git configurations the Coder deployment has. If this number is >0, we set up special configuration in the workspace. | -| `metadata` | array of [codersdk.WorkspaceAgentMetadataDescription](#codersdkworkspaceagentmetadatadescription) | false | | | -| `motd_file` | string | false | | | -| `shutdown_script` | string | false | | | -| `shutdown_script_timeout` | integer | false | | | -| `startup_script` | string | false | | | -| `startup_script_timeout` | integer | false | | | -| `vscode_port_proxy_uri` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ---------------------------- | ------------------------------------------------------------------------------------------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps` | array of [codersdk.WorkspaceApp](#codersdkworkspaceapp) | false | | | +| `derpmap` | [tailcfg.DERPMap](#tailcfgderpmap) | false | | | +| `directory` | string | false | | | +| `disable_direct_connections` | boolean | false | | | +| `environment_variables` | object | false | | | +| » `[any property]` | string | false | | | +| `git_auth_configs` | integer | false | | Git auth configs stores the number of Git configurations the Coder deployment has. If this number is >0, we set up special configuration in the workspace. | +| `metadata` | array of [codersdk.WorkspaceAgentMetadataDescription](#codersdkworkspaceagentmetadatadescription) | false | | | +| `motd_file` | string | false | | | +| `shutdown_script` | string | false | | | +| `shutdown_script_timeout` | integer | false | | | +| `startup_script` | string | false | | | +| `startup_script_timeout` | integer | false | | | +| `vscode_port_proxy_uri` | string | false | | | ## agentsdk.PatchStartupLogs @@ -1669,6 +1671,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { "config": { + "block_direct": true, "path": "string", "url": "string" }, @@ -1706,6 +1709,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ```json { + "block_direct": true, "path": "string", "url": "string" } @@ -1713,10 +1717,11 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in ### Properties -| Name | Type | Required | Restrictions | Description | -| ------ | ------ | -------- | ------------ | ----------- | -| `path` | string | false | | | -| `url` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| -------------- | ------- | -------- | ------------ | ----------- | +| `block_direct` | boolean | false | | | +| `path` | string | false | | | +| `url` | string | false | | | ## codersdk.DERPRegion @@ -1839,6 +1844,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in }, "derp": { "config": { + "block_direct": true, "path": "string", "url": "string" }, @@ -2167,6 +2173,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in }, "derp": { "config": { + "block_direct": true, "path": "string", "url": "string" }, @@ -4763,15 +4770,17 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "regionName": "string" } } - } + }, + "disable_direct_connections": true } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ---------- | ---------------------------------- | -------- | ------------ | ----------- | -| `derp_map` | [tailcfg.DERPMap](#tailcfgderpmap) | false | | | +| Name | Type | Required | Restrictions | Description | +| ---------------------------- | ---------------------------------- | -------- | ------------ | ----------- | +| `derp_map` | [tailcfg.DERPMap](#tailcfgderpmap) | false | | | +| `disable_direct_connections` | boolean | false | | | ## codersdk.WorkspaceAgentLifecycle diff --git a/docs/cli/server.md b/docs/cli/server.md index 68c79cebf8f26..61a1e052b0f10 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -30,6 +30,16 @@ coder server [flags] The URL that users will use to access the Coder deployment. +### --block-direct-connections + +| | | +| ----------- | ---------------------------------------- | +| Type | bool | +| Environment | $CODER_BLOCK_DIRECT | +| YAML | networking.derp.blockDirect | + +Block peer-to-peer (aka. direct) workspace connections. All workspace connections from the CLI will be proxied through Coder (or custom configured DERP servers) and will never be peer-to-peer when enabled. Workspaces may still reach out to STUN servers to get their address until they are restarted after this change has been made, but new connections will still be proxied regardless. + ### --browser-only | | | diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 340f167855d88..70e81cd42c997 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -150,6 +150,14 @@ between workspaces and users are peer-to-peer. However, when Coder cannot establish a peer to peer connection, Coder uses a distributed relay network backed by Tailscale and WireGuard. + --block-direct-connections bool, $CODER_BLOCK_DIRECT + Block peer-to-peer (aka. direct) workspace connections. All workspace + connections from the CLI will be proxied through Coder (or custom + configured DERP servers) and will never be peer-to-peer when enabled. + Workspaces may still reach out to STUN servers to get their address + until they are restarted after this change has been made, but new + connections will still be proxied regardless. + --derp-config-path string, $CODER_DERP_CONFIG_PATH Path to read a DERP mapping from. See: https://tailscale.com/kb/1118/custom-derp-servers/. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 77d720c56feca..55777f2c3aed6 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -281,6 +281,7 @@ export interface DERP { // From codersdk/deployment.go export interface DERPConfig { + readonly block_direct: boolean readonly url: string readonly path: string } diff --git a/tailnet/conn.go b/tailnet/conn.go index 67f9267132250..bbe0b3a531117 100644 --- a/tailnet/conn.go +++ b/tailnet/conn.go @@ -353,6 +353,14 @@ func (c *Conn) SetDERPMap(derpMap *tailcfg.DERPMap) { c.wireguardEngine.SetNetworkMap(&netMapCopy) } +// SetBlockEndpoints sets whether or not to block P2P endpoints. This setting +// will only apply to new peers. +func (c *Conn) SetBlockEndpoints(blockEndpoints bool) { + c.mutex.Lock() + defer c.mutex.Unlock() + c.blockEndpoints = blockEndpoints +} + // SetDERPRegionDialer updates the dialer to use for connecting to DERP regions. func (c *Conn) SetDERPRegionDialer(dialer func(ctx context.Context, region *tailcfg.DERPRegion) net.Conn) { c.magicConn.SetDERPRegionDialer(dialer) @@ -516,6 +524,13 @@ func (c *Conn) DERPMap() *tailcfg.DERPMap { return c.netMap.DERPMap } +// BlockEndpoints returns whether or not P2P is blocked. +func (c *Conn) BlockEndpoints() bool { + c.mutex.Lock() + defer c.mutex.Unlock() + return c.blockEndpoints +} + // AwaitReachable pings the provided IP continually until the // address is reachable. It's the callers responsibility to provide // a timeout, otherwise this function will block forever. diff --git a/tailnet/derpmap.go b/tailnet/derpmap.go index 13a998177c24f..37092886540dd 100644 --- a/tailnet/derpmap.go +++ b/tailnet/derpmap.go @@ -15,10 +15,16 @@ import ( // NewDERPMap constructs a DERPMap from a set of STUN addresses and optionally a remote // URL to fetch a mapping from e.g. https://controlplane.tailscale.com/derpmap/default. -func NewDERPMap(ctx context.Context, region *tailcfg.DERPRegion, stunAddrs []string, remoteURL, localPath string) (*tailcfg.DERPMap, error) { +// +//nolint:revive +func NewDERPMap(ctx context.Context, region *tailcfg.DERPRegion, stunAddrs []string, remoteURL, localPath string, disableSTUN bool) (*tailcfg.DERPMap, error) { if remoteURL != "" && localPath != "" { return nil, xerrors.New("a remote URL or local path must be specified, not both") } + if disableSTUN { + stunAddrs = nil + } + if region != nil { for index, stunAddr := range stunAddrs { host, rawPort, err := net.SplitHostPort(stunAddr) @@ -74,5 +80,20 @@ func NewDERPMap(ctx context.Context, region *tailcfg.DERPRegion, stunAddrs []str } derpMap.Regions[region.RegionID] = region } + // Remove all STUNPorts from DERPy nodes, and fully remove all STUNOnly + // nodes. + if disableSTUN { + for _, region := range derpMap.Regions { + newNodes := make([]*tailcfg.DERPNode, 0, len(region.Nodes)) + for _, node := range region.Nodes { + node.STUNPort = -1 + if !node.STUNOnly { + newNodes = append(newNodes, node) + } + } + region.Nodes = newNodes + } + } + return derpMap, nil } diff --git a/tailnet/derpmap_test.go b/tailnet/derpmap_test.go index 71f0da1fcc8cd..bc5205cc45cf4 100644 --- a/tailnet/derpmap_test.go +++ b/tailnet/derpmap_test.go @@ -22,7 +22,7 @@ func TestNewDERPMap(t *testing.T) { derpMap, err := tailnet.NewDERPMap(context.Background(), &tailcfg.DERPRegion{ RegionID: 1, Nodes: []*tailcfg.DERPNode{{}}, - }, []string{"stun.google.com:2345"}, "", "") + }, []string{"stun.google.com:2345"}, "", "", false) require.NoError(t, err) require.Len(t, derpMap.Regions[1].Nodes, 2) }) @@ -39,7 +39,7 @@ func TestNewDERPMap(t *testing.T) { t.Cleanup(server.Close) derpMap, err := tailnet.NewDERPMap(context.Background(), &tailcfg.DERPRegion{ RegionID: 2, - }, []string{}, server.URL, "") + }, []string{}, server.URL, "", false) require.NoError(t, err) require.Len(t, derpMap.Regions, 2) }) @@ -56,7 +56,7 @@ func TestNewDERPMap(t *testing.T) { t.Cleanup(server.Close) _, err := tailnet.NewDERPMap(context.Background(), &tailcfg.DERPRegion{ RegionID: 1, - }, []string{}, server.URL, "") + }, []string{}, server.URL, "", false) require.Error(t, err) }) t.Run("LocalPath", func(t *testing.T) { @@ -72,8 +72,61 @@ func TestNewDERPMap(t *testing.T) { require.NoError(t, err) derpMap, err := tailnet.NewDERPMap(context.Background(), &tailcfg.DERPRegion{ RegionID: 2, - }, []string{}, "", localPath) + }, []string{}, "", localPath, false) require.NoError(t, err) require.Len(t, derpMap.Regions, 2) }) + t.Run("DisableSTUN", func(t *testing.T) { + t.Parallel() + localPath := filepath.Join(t.TempDir(), "derp.json") + content, err := json.Marshal(&tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: { + Nodes: []*tailcfg.DERPNode{{ + STUNPort: 1234, + }}, + }, + 2: { + Nodes: []*tailcfg.DERPNode{ + { + STUNPort: 1234, + }, + { + STUNPort: 12345, + }, + { + STUNOnly: true, + STUNPort: 54321, + }, + }, + }, + }, + }) + require.NoError(t, err) + err = os.WriteFile(localPath, content, 0o600) + require.NoError(t, err) + region := &tailcfg.DERPRegion{ + RegionID: 3, + Nodes: []*tailcfg.DERPNode{{ + STUNPort: 1234, + }}, + } + derpMap, err := tailnet.NewDERPMap(context.Background(), region, []string{"127.0.0.1:54321"}, "", localPath, true) + require.NoError(t, err) + require.Len(t, derpMap.Regions, 3) + + require.Len(t, derpMap.Regions[1].Nodes, 1) + require.EqualValues(t, -1, derpMap.Regions[1].Nodes[0].STUNPort) + // The STUNOnly node should get removed. + require.Len(t, derpMap.Regions[2].Nodes, 2) + require.EqualValues(t, -1, derpMap.Regions[2].Nodes[0].STUNPort) + require.False(t, derpMap.Regions[2].Nodes[0].STUNOnly) + require.EqualValues(t, -1, derpMap.Regions[2].Nodes[1].STUNPort) + require.False(t, derpMap.Regions[2].Nodes[1].STUNOnly) + // We don't add any nodes ourselves if STUN is disabled. + require.Len(t, derpMap.Regions[3].Nodes, 1) + // ... but we still remove the STUN port from existing nodes in the + // region. + require.EqualValues(t, -1, derpMap.Regions[3].Nodes[0].STUNPort) + }) }