diff --git a/agent/agent.go b/agent/agent.go index b86db9a4ff..7bcdcbf532 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 a9b346554f..890260c1a7 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 e30bf5fd71..fe85a05114 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 340f167855..70e81cd42c 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 79a678e7ab..31c14343c9 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 2f4fd2fbef..323d5c7109 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 9444a2b803..7a9902eba8 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 5d03b49016..701caacb7b 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 a0ccbb966f..25c7861ce4 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 eb8a8eb525..7fcde117fd 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 289ee07eaf..ac0211cf2d 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 aa62aebba5..18f4148edd 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 4b5b636f11..c6fda00cbd 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 d1fa59cdff..b8c73c8cea 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 22be046b47..7af5298fc3 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 caac33caea..cb20b734cd 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 68c79cebf8..61a1e052b0 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 340f167855..70e81cd42c 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 77d720c56f..55777f2c3a 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 67f9267132..bbe0b3a531 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 13a998177c..3709288654 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 71f0da1fcc..bc5205cc45 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) + }) }