diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index cc93963975..b73456f203 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -10926,6 +10926,9 @@ const docTemplate = `{
"derp_enabled": {
"type": "boolean"
},
+ "derp_only": {
+ "type": "boolean"
+ },
"display_name": {
"type": "string"
},
@@ -11654,6 +11657,10 @@ const docTemplate = `{
"description": "DerpEnabled indicates whether the proxy should be included in the DERP\nmap or not.",
"type": "boolean"
},
+ "derp_only": {
+ "description": "DerpOnly indicates whether the proxy should only be included in the DERP\nmap and should not be used for serving apps.",
+ "type": "boolean"
+ },
"hostname": {
"description": "ReplicaHostname is the OS hostname of the machine that the proxy is running\non. This is only used for tracking purposes in the replicas table.",
"type": "string"
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 04d819204a..7c3a647075 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -9918,6 +9918,9 @@
"derp_enabled": {
"type": "boolean"
},
+ "derp_only": {
+ "type": "boolean"
+ },
"display_name": {
"type": "string"
},
@@ -10634,6 +10637,10 @@
"description": "DerpEnabled indicates whether the proxy should be included in the DERP\nmap or not.",
"type": "boolean"
},
+ "derp_only": {
+ "description": "DerpOnly indicates whether the proxy should only be included in the DERP\nmap and should not be used for serving apps.",
+ "type": "boolean"
+ },
"hostname": {
"description": "ReplicaHostname is the OS hostname of the machine that the proxy is running\non. This is only used for tracking purposes in the replicas table.",
"type": "string"
diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go
index 5766e7b841..cbada3d221 100644
--- a/coderd/database/dbfake/dbfake.go
+++ b/coderd/database/dbfake/dbfake.go
@@ -4164,6 +4164,7 @@ func (q *FakeQuerier) InsertWorkspaceProxy(_ context.Context, arg database.Inser
DisplayName: arg.DisplayName,
Icon: arg.Icon,
DerpEnabled: arg.DerpEnabled,
+ DerpOnly: arg.DerpOnly,
TokenHashedSecret: arg.TokenHashedSecret,
RegionID: lastRegionID + 1,
CreatedAt: arg.CreatedAt,
@@ -4238,6 +4239,7 @@ func (q *FakeQuerier) RegisterWorkspaceProxy(_ context.Context, arg database.Reg
p.Url = arg.Url
p.WildcardHostname = arg.WildcardHostname
p.DerpEnabled = arg.DerpEnabled
+ p.DerpOnly = arg.DerpOnly
p.UpdatedAt = database.Now()
q.workspaceProxies[i] = p
return p, nil
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index 32d74edec7..f7bbb2cc1a 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -878,7 +878,8 @@ CREATE TABLE workspace_proxies (
deleted boolean NOT NULL,
token_hashed_secret bytea NOT NULL,
region_id integer NOT NULL,
- derp_enabled boolean DEFAULT true NOT NULL
+ derp_enabled boolean DEFAULT true NOT NULL,
+ derp_only boolean DEFAULT false NOT NULL
);
COMMENT ON COLUMN workspace_proxies.icon IS 'Expects an emoji character. (/emojis/1f1fa-1f1f8.png)';
@@ -891,6 +892,8 @@ COMMENT ON COLUMN workspace_proxies.deleted IS 'Boolean indicator of a deleted w
COMMENT ON COLUMN workspace_proxies.token_hashed_secret IS 'Hashed secret is used to authenticate the workspace proxy using a session token.';
+COMMENT ON COLUMN workspace_proxies.derp_only IS 'Disables app/terminal proxying for this proxy and only acts as a DERP relay.';
+
CREATE SEQUENCE workspace_proxies_region_id_seq
AS integer
START WITH 1
diff --git a/coderd/database/migrations/000144_proxy_derp_only.down.sql b/coderd/database/migrations/000144_proxy_derp_only.down.sql
new file mode 100644
index 0000000000..37eebeb8f2
--- /dev/null
+++ b/coderd/database/migrations/000144_proxy_derp_only.down.sql
@@ -0,0 +1 @@
+ALTER TABLE workspace_proxies DROP COLUMN derp_only;
diff --git a/coderd/database/migrations/000144_proxy_derp_only.up.sql b/coderd/database/migrations/000144_proxy_derp_only.up.sql
new file mode 100644
index 0000000000..d63c602f78
--- /dev/null
+++ b/coderd/database/migrations/000144_proxy_derp_only.up.sql
@@ -0,0 +1,8 @@
+BEGIN;
+
+ALTER TABLE workspace_proxies
+ ADD COLUMN "derp_only" BOOLEAN NOT NULL DEFAULT false;
+
+COMMENT ON COLUMN workspace_proxies.derp_only IS 'Disables app/terminal proxying for this proxy and only acts as a DERP relay.';
+
+COMMIT;
diff --git a/coderd/database/models.go b/coderd/database/models.go
index 48e7e80c64..d577d6c182 100644
--- a/coderd/database/models.go
+++ b/coderd/database/models.go
@@ -2014,6 +2014,8 @@ type WorkspaceProxy struct {
TokenHashedSecret []byte `db:"token_hashed_secret" json:"token_hashed_secret"`
RegionID int32 `db:"region_id" json:"region_id"`
DerpEnabled bool `db:"derp_enabled" json:"derp_enabled"`
+ // Disables app/terminal proxying for this proxy and only acts as a DERP relay.
+ DerpOnly bool `db:"derp_only" json:"derp_only"`
}
type WorkspaceResource struct {
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index 19c3b087ea..f86b413225 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -2848,7 +2848,7 @@ func (q *sqlQuerier) UpdateProvisionerJobWithCompleteByID(ctx context.Context, a
const getWorkspaceProxies = `-- name: GetWorkspaceProxies :many
SELECT
- id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret, region_id, derp_enabled
+ id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret, region_id, derp_enabled, derp_only
FROM
workspace_proxies
WHERE
@@ -2877,6 +2877,7 @@ func (q *sqlQuerier) GetWorkspaceProxies(ctx context.Context) ([]WorkspaceProxy,
&i.TokenHashedSecret,
&i.RegionID,
&i.DerpEnabled,
+ &i.DerpOnly,
); err != nil {
return nil, err
}
@@ -2893,7 +2894,7 @@ func (q *sqlQuerier) GetWorkspaceProxies(ctx context.Context) ([]WorkspaceProxy,
const getWorkspaceProxyByHostname = `-- name: GetWorkspaceProxyByHostname :one
SELECT
- id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret, region_id, derp_enabled
+ id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret, region_id, derp_enabled, derp_only
FROM
workspace_proxies
WHERE
@@ -2951,13 +2952,14 @@ func (q *sqlQuerier) GetWorkspaceProxyByHostname(ctx context.Context, arg GetWor
&i.TokenHashedSecret,
&i.RegionID,
&i.DerpEnabled,
+ &i.DerpOnly,
)
return i, err
}
const getWorkspaceProxyByID = `-- name: GetWorkspaceProxyByID :one
SELECT
- id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret, region_id, derp_enabled
+ id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret, region_id, derp_enabled, derp_only
FROM
workspace_proxies
WHERE
@@ -2982,13 +2984,14 @@ func (q *sqlQuerier) GetWorkspaceProxyByID(ctx context.Context, id uuid.UUID) (W
&i.TokenHashedSecret,
&i.RegionID,
&i.DerpEnabled,
+ &i.DerpOnly,
)
return i, err
}
const getWorkspaceProxyByName = `-- name: GetWorkspaceProxyByName :one
SELECT
- id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret, region_id, derp_enabled
+ id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret, region_id, derp_enabled, derp_only
FROM
workspace_proxies
WHERE
@@ -3014,6 +3017,7 @@ func (q *sqlQuerier) GetWorkspaceProxyByName(ctx context.Context, name string) (
&i.TokenHashedSecret,
&i.RegionID,
&i.DerpEnabled,
+ &i.DerpOnly,
)
return i, err
}
@@ -3028,13 +3032,14 @@ INSERT INTO
display_name,
icon,
derp_enabled,
+ derp_only,
token_hashed_secret,
created_at,
updated_at,
deleted
)
VALUES
- ($1, '', '', $2, $3, $4, $5, $6, $7, $8, false) RETURNING id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret, region_id, derp_enabled
+ ($1, '', '', $2, $3, $4, $5, $6, $7, $8, $9, false) RETURNING id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret, region_id, derp_enabled, derp_only
`
type InsertWorkspaceProxyParams struct {
@@ -3043,6 +3048,7 @@ type InsertWorkspaceProxyParams struct {
DisplayName string `db:"display_name" json:"display_name"`
Icon string `db:"icon" json:"icon"`
DerpEnabled bool `db:"derp_enabled" json:"derp_enabled"`
+ DerpOnly bool `db:"derp_only" json:"derp_only"`
TokenHashedSecret []byte `db:"token_hashed_secret" json:"token_hashed_secret"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
@@ -3055,6 +3061,7 @@ func (q *sqlQuerier) InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspa
arg.DisplayName,
arg.Icon,
arg.DerpEnabled,
+ arg.DerpOnly,
arg.TokenHashedSecret,
arg.CreatedAt,
arg.UpdatedAt,
@@ -3073,6 +3080,7 @@ func (q *sqlQuerier) InsertWorkspaceProxy(ctx context.Context, arg InsertWorkspa
&i.TokenHashedSecret,
&i.RegionID,
&i.DerpEnabled,
+ &i.DerpOnly,
)
return i, err
}
@@ -3084,16 +3092,18 @@ SET
url = $1 :: text,
wildcard_hostname = $2 :: text,
derp_enabled = $3 :: boolean,
+ derp_only = $4 :: boolean,
updated_at = Now()
WHERE
- id = $4
-RETURNING id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret, region_id, derp_enabled
+ id = $5
+RETURNING id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret, region_id, derp_enabled, derp_only
`
type RegisterWorkspaceProxyParams struct {
Url string `db:"url" json:"url"`
WildcardHostname string `db:"wildcard_hostname" json:"wildcard_hostname"`
DerpEnabled bool `db:"derp_enabled" json:"derp_enabled"`
+ DerpOnly bool `db:"derp_only" json:"derp_only"`
ID uuid.UUID `db:"id" json:"id"`
}
@@ -3102,6 +3112,7 @@ func (q *sqlQuerier) RegisterWorkspaceProxy(ctx context.Context, arg RegisterWor
arg.Url,
arg.WildcardHostname,
arg.DerpEnabled,
+ arg.DerpOnly,
arg.ID,
)
var i WorkspaceProxy
@@ -3118,6 +3129,7 @@ func (q *sqlQuerier) RegisterWorkspaceProxy(ctx context.Context, arg RegisterWor
&i.TokenHashedSecret,
&i.RegionID,
&i.DerpEnabled,
+ &i.DerpOnly,
)
return i, err
}
@@ -3140,7 +3152,7 @@ SET
updated_at = Now()
WHERE
id = $5
-RETURNING id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret, region_id, derp_enabled
+RETURNING id, name, display_name, icon, url, wildcard_hostname, created_at, updated_at, deleted, token_hashed_secret, region_id, derp_enabled, derp_only
`
type UpdateWorkspaceProxyParams struct {
@@ -3174,6 +3186,7 @@ func (q *sqlQuerier) UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspa
&i.TokenHashedSecret,
&i.RegionID,
&i.DerpEnabled,
+ &i.DerpOnly,
)
return i, err
}
diff --git a/coderd/database/queries/proxies.sql b/coderd/database/queries/proxies.sql
index d283ef87fe..f43ac6465c 100644
--- a/coderd/database/queries/proxies.sql
+++ b/coderd/database/queries/proxies.sql
@@ -8,13 +8,14 @@ INSERT INTO
display_name,
icon,
derp_enabled,
+ derp_only,
token_hashed_secret,
created_at,
updated_at,
deleted
)
VALUES
- ($1, '', '', $2, $3, $4, $5, $6, $7, $8, false) RETURNING *;
+ ($1, '', '', $2, $3, $4, $5, $6, $7, $8, $9, false) RETURNING *;
-- name: RegisterWorkspaceProxy :one
UPDATE
@@ -23,6 +24,7 @@ SET
url = @url :: text,
wildcard_hostname = @wildcard_hostname :: text,
derp_enabled = @derp_enabled :: boolean,
+ derp_only = @derp_only :: boolean,
updated_at = Now()
WHERE
id = @id
diff --git a/codersdk/workspaceproxy.go b/codersdk/workspaceproxy.go
index ef2ccd638c..efdc3cc93c 100644
--- a/codersdk/workspaceproxy.go
+++ b/codersdk/workspaceproxy.go
@@ -49,6 +49,7 @@ type WorkspaceProxy struct {
// Extends Region with extra information
Region `table:"region,recursive_inline"`
DerpEnabled bool `json:"derp_enabled" table:"derp_enabled"`
+ DerpOnly bool `json:"derp_only" table:"derp_only"`
// Status is the latest status check of the proxy. This will be empty for deleted
// proxies. This value can be used to determine if a workspace proxy is healthy
diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md
index 27ccbb763a..1dc32df727 100644
--- a/docs/admin/audit-logs.md
+++ b/docs/admin/audit-logs.md
@@ -21,7 +21,7 @@ We track the following resources:
| User
create, write, delete |
| Field | Tracked |
|---|
| avatar_url | false |
| created_at | false |
| deleted | true |
| email | true |
| hashed_password | true |
| id | true |
| last_seen_at | false |
| login_type | true |
| quiet_hours_schedule | true |
| rbac_roles | true |
| status | true |
| updated_at | false |
| username | true |
|
| Workspace
create, write, delete | | Field | Tracked |
|---|
| autostart_schedule | true |
| created_at | false |
| deleted | false |
| deleting_at | true |
| id | true |
| last_used_at | false |
| locked_at | true |
| name | true |
| organization_id | false |
| owner_id | true |
| template_id | true |
| ttl | true |
| updated_at | false |
|
| WorkspaceBuild
start, stop | | Field | Tracked |
|---|
| build_number | false |
| created_at | false |
| daily_cost | false |
| deadline | false |
| id | false |
| initiator_by_avatar_url | false |
| initiator_by_username | false |
| initiator_id | false |
| job_id | false |
| max_deadline | false |
| provisioner_state | false |
| reason | false |
| template_version_id | true |
| transition | false |
| updated_at | false |
| workspace_id | false |
|
-| WorkspaceProxy
| | Field | Tracked |
|---|
| created_at | true |
| deleted | false |
| derp_enabled | true |
| display_name | true |
| icon | true |
| id | true |
| name | true |
| region_id | true |
| token_hashed_secret | true |
| updated_at | false |
| url | true |
| wildcard_hostname | true |
|
+| WorkspaceProxy
| | Field | Tracked |
|---|
| created_at | true |
| deleted | false |
| derp_enabled | true |
| derp_only | true |
| display_name | true |
| icon | true |
| id | true |
| name | true |
| region_id | true |
| token_hashed_secret | true |
| updated_at | false |
| url | true |
| wildcard_hostname | true |
|
diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md
index 03610ba536..5e1aa9864a 100644
--- a/docs/api/enterprise.md
+++ b/docs/api/enterprise.md
@@ -1452,6 +1452,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceproxies \
"created_at": "2019-08-24T14:15:22Z",
"deleted": true,
"derp_enabled": true,
+ "derp_only": true,
"display_name": "string",
"healthy": true,
"icon_url": "string",
@@ -1491,6 +1492,7 @@ Status Code **200**
| `»» created_at` | string(date-time) | false | | |
| `»» deleted` | boolean | false | | |
| `»» derp_enabled` | boolean | false | | |
+| `»» derp_only` | boolean | false | | |
| `»» display_name` | string | false | | |
| `»» healthy` | boolean | false | | |
| `»» icon_url` | string | false | | |
@@ -1556,6 +1558,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaceproxies \
"created_at": "2019-08-24T14:15:22Z",
"deleted": true,
"derp_enabled": true,
+ "derp_only": true,
"display_name": "string",
"healthy": true,
"icon_url": "string",
@@ -1611,6 +1614,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceproxies/{workspaceproxy} \
"created_at": "2019-08-24T14:15:22Z",
"deleted": true,
"derp_enabled": true,
+ "derp_only": true,
"display_name": "string",
"healthy": true,
"icon_url": "string",
@@ -1724,6 +1728,7 @@ curl -X PATCH http://coder-server:8080/api/v2/workspaceproxies/{workspaceproxy}
"created_at": "2019-08-24T14:15:22Z",
"deleted": true,
"derp_enabled": true,
+ "derp_only": true,
"display_name": "string",
"healthy": true,
"icon_url": "string",
diff --git a/docs/api/schemas.md b/docs/api/schemas.md
index 5df5d4bab5..e9a0910ef9 100644
--- a/docs/api/schemas.md
+++ b/docs/api/schemas.md
@@ -3820,6 +3820,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
"created_at": "2019-08-24T14:15:22Z",
"deleted": true,
"derp_enabled": true,
+ "derp_only": true,
"display_name": "string",
"healthy": true,
"icon_url": "string",
@@ -6021,6 +6022,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"created_at": "2019-08-24T14:15:22Z",
"deleted": true,
"derp_enabled": true,
+ "derp_only": true,
"display_name": "string",
"healthy": true,
"icon_url": "string",
@@ -6047,6 +6049,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `created_at` | string | false | | |
| `deleted` | boolean | false | | |
| `derp_enabled` | boolean | false | | |
+| `derp_only` | boolean | false | | |
| `display_name` | string | false | | |
| `healthy` | boolean | false | | |
| `icon_url` | string | false | | |
@@ -7418,6 +7421,7 @@ _None_
{
"access_url": "string",
"derp_enabled": true,
+ "derp_only": true,
"hostname": "string",
"replica_error": "string",
"replica_id": "string",
@@ -7433,6 +7437,7 @@ _None_
| ------------------------------------------------------------------------------------------------- | ------- | -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `access_url` | string | false | | Access URL that hits the workspace proxy api. |
| `derp_enabled` | boolean | false | | Derp enabled indicates whether the proxy should be included in the DERP map or not. |
+| `derp_only` | boolean | false | | Derp only indicates whether the proxy should only be included in the DERP map and should not be used for serving apps. |
| `hostname` | string | false | | Hostname is the OS hostname of the machine that the proxy is running on. This is only used for tracking purposes in the replicas table. |
| `replica_error` | string | false | | Replica error is the error that the replica encountered when trying to dial it's peers. This is stored in the replicas table for debugging purposes but does not affect the proxy's ability to register. |
| This value is only stored on subsequent requests to the register endpoint, not the first request. |
diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go
index cea72f7c70..7eb93f241e 100644
--- a/enterprise/audit/table.go
+++ b/enterprise/audit/table.go
@@ -198,6 +198,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
"deleted": ActionIgnore,
"token_hashed_secret": ActionSecret,
"derp_enabled": ActionTrack,
+ "derp_only": ActionTrack,
"region_id": ActionTrack,
},
}
diff --git a/enterprise/cli/proxyserver.go b/enterprise/cli/proxyserver.go
index 3fb706cb84..f40d12d6d4 100644
--- a/enterprise/cli/proxyserver.go
+++ b/enterprise/cli/proxyserver.go
@@ -56,6 +56,7 @@ func (*RootCmd) proxyServer() *clibase.Cmd {
}
proxySessionToken clibase.String
primaryAccessURL clibase.URL
+ derpOnly clibase.Bool
)
opts.Add(
// Options only for external workspace proxies
@@ -88,6 +89,17 @@ func (*RootCmd) proxyServer() *clibase.Cmd {
Group: &externalProxyOptionGroup,
Hidden: false,
},
+ clibase.Option{
+ Name: "DERP-only proxy",
+ Description: "Run a proxy server that only supports DERP connections and does not proxy workspace app/terminal traffic.",
+ Flag: "derp-only",
+ Env: "CODER_PROXY_DERP_ONLY",
+ YAML: "derpOnly",
+ Required: false,
+ Value: &derpOnly,
+ Group: &externalProxyOptionGroup,
+ Hidden: false,
+ },
)
cmd := &clibase.Cmd{
@@ -163,6 +175,10 @@ func (*RootCmd) proxyServer() *clibase.Cmd {
}
}
+ if derpOnly.Value() && !cfg.DERP.Server.Enable.Value() {
+ return xerrors.Errorf("cannot use --derp-only with DERP server disabled")
+ }
+
// TODO: @emyrk I find this strange that we add this to the context
// at the root here.
ctx, httpClient, err := cli.ConfigureHTTPClient(
@@ -236,6 +252,7 @@ func (*RootCmd) proxyServer() *clibase.Cmd {
ProxySessionToken: proxySessionToken.Value(),
AllowAllCors: cfg.Dangerous.AllowAllCors.Value(),
DERPEnabled: cfg.DERP.Server.Enable.Value(),
+ DERPOnly: derpOnly.Value(),
DERPServerRelayAddress: cfg.DERP.Server.RelayURL.String(),
})
if err != nil {
diff --git a/enterprise/coderd/coderdenttest/proxytest.go b/enterprise/coderd/coderdenttest/proxytest.go
index 2918659ac5..c544e14b44 100644
--- a/enterprise/coderd/coderdenttest/proxytest.go
+++ b/enterprise/coderd/coderdenttest/proxytest.go
@@ -32,6 +32,8 @@ type ProxyOptions struct {
TLSCertificates []tls.Certificate
AppHostname string
DisablePathApps bool
+ DerpDisabled bool
+ DerpOnly bool
// ProxyURL is optional
ProxyURL *url.URL
@@ -91,16 +93,6 @@ func NewWorkspaceProxy(t *testing.T, coderdAPI *coderd.API, owner *codersdk.Clie
accessURL = serverURL
}
- // TODO: Stun and derp stuff
- // derpPort, err := strconv.Atoi(serverURL.Port())
- // require.NoError(t, err)
- //
- // stunAddr, stunCleanup := stuntest.ServeWithPacketListener(t, nettype.Std{})
- // t.Cleanup(stunCleanup)
- //
- // derpServer := derp.NewServer(key.NewNode(), tailnet.Logger(slogtest.Make(t, nil).Named("derp").Leveled(slog.LevelDebug)))
- // derpServer.SetMeshKey("test-key")
-
var appHostnameRegex *regexp.Regexp
if options.AppHostname != "" {
var err error
@@ -134,7 +126,8 @@ func NewWorkspaceProxy(t *testing.T, coderdAPI *coderd.API, owner *codersdk.Clie
// We need a new registry to not conflict with the coderd internal
// proxy metrics.
PrometheusRegistry: prometheus.NewRegistry(),
- DERPEnabled: true,
+ DERPEnabled: !options.DerpDisabled,
+ DERPOnly: options.DerpOnly,
DERPServerRelayAddress: accessURL.String(),
})
require.NoError(t, err)
diff --git a/enterprise/coderd/workspaceproxy.go b/enterprise/coderd/workspaceproxy.go
index 1842302cb4..591f2fa333 100644
--- a/enterprise/coderd/workspaceproxy.go
+++ b/enterprise/coderd/workspaceproxy.go
@@ -63,8 +63,8 @@ func (api *API) fetchRegions(ctx context.Context) (codersdk.RegionsResponse[code
regions := make([]codersdk.Region, 0, len(proxies.Regions))
for i := range proxies.Regions {
- // Ignore deleted proxies.
- if proxies.Regions[i].Deleted {
+ // Ignore deleted and DERP-only proxies.
+ if proxies.Regions[i].Deleted || proxies.Regions[i].DerpOnly {
continue
}
// Append the inner region data.
@@ -353,8 +353,10 @@ func (api *API) postWorkspaceProxy(rw http.ResponseWriter, r *http.Request) {
// Enabled by default, but will be disabled on register if the proxy has
// it disabled.
DerpEnabled: true,
- CreatedAt: database.Now(),
- UpdatedAt: database.Now(),
+ // Disabled by default, but blah blah blah.
+ DerpOnly: false,
+ CreatedAt: database.Now(),
+ UpdatedAt: database.Now(),
})
if database.IsUniqueViolation(err, database.UniqueWorkspaceProxiesLowerNameIndex) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
@@ -569,6 +571,13 @@ func (api *API) workspaceProxyRegister(rw http.ResponseWriter, r *http.Request)
return
}
+ if req.DerpOnly && !req.DerpEnabled {
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "DerpOnly cannot be true when DerpEnabled is false.",
+ })
+ return
+ }
+
startingRegionID, _ := getProxyDERPStartingRegionID(api.Options.BaseDERPMap)
regionID := int32(startingRegionID) + proxy.RegionID
@@ -578,6 +587,7 @@ func (api *API) workspaceProxyRegister(rw http.ResponseWriter, r *http.Request)
ID: proxy.ID,
Url: req.AccessURL,
DerpEnabled: req.DerpEnabled,
+ DerpOnly: req.DerpOnly,
WildcardHostname: req.WildcardHostname,
})
if err != nil {
@@ -899,6 +909,7 @@ func convertProxy(p database.WorkspaceProxy, status proxyhealth.ProxyStatus) cod
return codersdk.WorkspaceProxy{
Region: convertRegion(p, status),
DerpEnabled: p.DerpEnabled,
+ DerpOnly: p.DerpOnly,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
Deleted: p.Deleted,
diff --git a/enterprise/wsproxy/wsproxy.go b/enterprise/wsproxy/wsproxy.go
index 1532e38914..843b79ee39 100644
--- a/enterprise/wsproxy/wsproxy.go
+++ b/enterprise/wsproxy/wsproxy.go
@@ -70,6 +70,9 @@ type Options struct {
DisablePathApps bool
DERPEnabled bool
DERPServerRelayAddress string
+ // DERPOnly determines whether this proxy only provides DERP and does not
+ // provide access to workspace apps/terminal.
+ DERPOnly bool
ProxySessionToken string
// AllowAllCors will set all CORs headers to '*'.
@@ -208,6 +211,7 @@ func New(ctx context.Context, opts *Options) (*Server, error) {
AccessURL: opts.AccessURL.String(),
WildcardHostname: opts.AppHostname,
DerpEnabled: opts.DERPEnabled,
+ DerpOnly: opts.DERPOnly,
ReplicaID: replicaID,
ReplicaHostname: osHostname,
ReplicaError: "",
@@ -327,18 +331,51 @@ func New(ctx context.Context, opts *Options) (*Server, error) {
)
// Attach workspace apps routes.
- r.Group(func(r chi.Router) {
- r.Use(apiRateLimiter)
- s.AppServer.Attach(r)
- })
-
- r.Route("/derp", func(r chi.Router) {
- r.Get("/", derpHandler.ServeHTTP)
- // This is used when UDP is blocked, and latency must be checked via HTTP(s).
- r.Get("/latency-check", func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
+ if !opts.DERPOnly {
+ r.Group(func(r chi.Router) {
+ r.Use(apiRateLimiter)
+ s.AppServer.Attach(r)
})
- })
+ } else {
+ r.Group(func(r chi.Router) {
+ derpOnlyHandler := func(rw http.ResponseWriter, r *http.Request) {
+ site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
+ Title: "Head to the Dashboard",
+ Status: http.StatusBadRequest,
+ HideStatus: true,
+ Description: "This workspace proxy is DERP-only and cannot be used for browser connections. " +
+ "Please use a different region directly from the dashboard. Click to be redirected!",
+ RetryEnabled: false,
+ DashboardURL: opts.DashboardURL.String(),
+ })
+ }
+ serveDerpOnlyHandler := func(r chi.Router) {
+ r.HandleFunc("/*", derpOnlyHandler)
+ }
+
+ r.Route("/%40{user}/{workspace_and_agent}/apps/{workspaceapp}", serveDerpOnlyHandler)
+ r.Route("/@{user}/{workspace_and_agent}/apps/{workspaceapp}", serveDerpOnlyHandler)
+ r.Get("/api/v2/workspaceagents/{workspaceagent}/pty", derpOnlyHandler)
+ })
+ }
+
+ if opts.DERPEnabled {
+ r.Route("/derp", func(r chi.Router) {
+ r.Get("/", derpHandler.ServeHTTP)
+ // This is used when UDP is blocked, and latency must be checked via HTTP(s).
+ r.Get("/latency-check", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+ })
+ } else {
+ r.Route("/derp", func(r chi.Router) {
+ r.HandleFunc("/*", func(rw http.ResponseWriter, r *http.Request) {
+ httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
+ Message: "DERP is disabled on this proxy.",
+ })
+ })
+ })
+ }
r.Get("/api/v2/buildinfo", s.buildInfo)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("OK")) })
diff --git a/enterprise/wsproxy/wsproxy_test.go b/enterprise/wsproxy/wsproxy_test.go
index 137b432666..afcb3d1f16 100644
--- a/enterprise/wsproxy/wsproxy_test.go
+++ b/enterprise/wsproxy/wsproxy_test.go
@@ -9,7 +9,9 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "tailscale.com/derp/derphttp"
"tailscale.com/tailcfg"
+ "tailscale.com/types/key"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
@@ -29,6 +31,54 @@ import (
"github.com/coder/coder/testutil"
)
+func TestDERPOnly(t *testing.T) {
+ t.Parallel()
+
+ deploymentValues := coderdtest.DeploymentValues(t)
+ deploymentValues.Experiments = []string{
+ string(codersdk.ExperimentMoons),
+ "*",
+ }
+
+ client, closer, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
+ Options: &coderdtest.Options{
+ DeploymentValues: deploymentValues,
+ AppHostname: "*.primary.test.coder.com",
+ IncludeProvisionerDaemon: true,
+ RealIPConfig: &httpmw.RealIPConfig{
+ TrustedOrigins: []*net.IPNet{{
+ IP: net.ParseIP("127.0.0.1"),
+ Mask: net.CIDRMask(8, 32),
+ }},
+ TrustedHeaders: []string{
+ "CF-Connecting-IP",
+ },
+ },
+ },
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureWorkspaceProxy: 1,
+ },
+ },
+ })
+ t.Cleanup(func() {
+ _ = closer.Close()
+ })
+
+ // Create an external proxy.
+ _ = coderdenttest.NewWorkspaceProxy(t, api, client, &coderdenttest.ProxyOptions{
+ Name: "best-proxy",
+ DerpOnly: true,
+ })
+
+ // Should not show up in the regions list.
+ ctx := testutil.Context(t, testutil.WaitLong)
+ regions, err := client.Regions(ctx)
+ require.NoError(t, err)
+ require.Len(t, regions, 1)
+ require.Equal(t, api.Options.AccessURL.String(), regions[0].PathAppURL)
+}
+
func TestDERP(t *testing.T) {
t.Parallel()
@@ -71,6 +121,12 @@ func TestDERP(t *testing.T) {
Name: "worst-proxy",
})
+ // Create a running external proxy with DERP disabled.
+ proxyAPI3 := coderdenttest.NewWorkspaceProxy(t, api, client, &coderdenttest.ProxyOptions{
+ Name: "no-derp-proxy",
+ DerpDisabled: true,
+ })
+
// Create a proxy that is never started.
createProxyCtx := testutil.Context(t, testutil.WaitLong)
_, err := client.CreateWorkspaceProxy(createProxyCtx, codersdk.CreateWorkspaceProxyRequest{
@@ -90,19 +146,19 @@ func TestDERP(t *testing.T) {
if !assert.NoError(t, err) {
return false
}
- if !assert.Len(t, regions, 4) {
+ if !assert.Len(t, regions, 5) {
return false
}
// The first 3 regions should be healthy.
- for _, r := range regions[:3] {
+ for _, r := range regions[:4] {
if !r.Healthy {
return false
}
}
// The last region should never be healthy.
- assert.False(t, regions[3].Healthy)
+ assert.False(t, regions[4].Healthy)
return true
}, testutil.WaitLong, testutil.IntervalMedium)
@@ -170,6 +226,9 @@ resourceLoop:
proxy2Region = r
continue
}
+ // The no-derp-proxy shouldn't show up in the map.
+ // The last region is never started, which means it's never healthy,
+ // which means it's never added to the DERP map.
t.Fatalf("unexpected region: %+v", r)
}
@@ -238,6 +297,18 @@ resourceLoop:
})
}
})
+
+ t.Run("DERPDisabled", func(t *testing.T) {
+ t.Parallel()
+
+ // Try to connect to the DERP server on the no-derp-proxy region.
+ client, err := derphttp.NewClient(key.NewNode(), proxyAPI3.Options.AccessURL.String(), func(format string, args ...any) {})
+ require.NoError(t, err)
+
+ ctx := testutil.Context(t, testutil.WaitLong)
+ err = client.Connect(ctx)
+ require.Error(t, err)
+ })
}
func TestDERPMapStunNodes(t *testing.T) {
diff --git a/enterprise/wsproxy/wsproxysdk/wsproxysdk.go b/enterprise/wsproxy/wsproxysdk/wsproxysdk.go
index f98ab3673e..6d1a4b1227 100644
--- a/enterprise/wsproxy/wsproxysdk/wsproxysdk.go
+++ b/enterprise/wsproxy/wsproxysdk/wsproxysdk.go
@@ -160,6 +160,9 @@ type RegisterWorkspaceProxyRequest struct {
// DerpEnabled indicates whether the proxy should be included in the DERP
// map or not.
DerpEnabled bool `json:"derp_enabled"`
+ // DerpOnly indicates whether the proxy should only be included in the DERP
+ // map and should not be used for serving apps.
+ DerpOnly bool `json:"derp_only"`
// ReplicaID is a unique identifier for the replica of the proxy that is
// registering. It should be generated by the client on startup and
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index ec2eabdade..4f5b5ea8b5 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -1454,6 +1454,7 @@ export interface WorkspaceOptions {
// From codersdk/workspaceproxy.go
export interface WorkspaceProxy extends Region {
readonly derp_enabled: boolean
+ readonly derp_only: boolean
readonly status?: WorkspaceProxyStatus
readonly created_at: string
readonly updated_at: string
diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts
index 930263fe20..6fffb71e76 100644
--- a/site/src/testHelpers/entities.ts
+++ b/site/src/testHelpers/entities.ts
@@ -80,6 +80,7 @@ export const MockPrimaryWorkspaceProxy: TypesGen.WorkspaceProxy = {
path_app_url: "https://coder.com",
wildcard_hostname: "*.coder.com",
derp_enabled: true,
+ derp_only: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
deleted: false,
@@ -98,6 +99,7 @@ export const MockHealthyWildWorkspaceProxy: TypesGen.WorkspaceProxy = {
path_app_url: "https://external.com",
wildcard_hostname: "*.external.com",
derp_enabled: true,
+ derp_only: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
deleted: false,
@@ -116,6 +118,7 @@ export const MockUnhealthyWildWorkspaceProxy: TypesGen.WorkspaceProxy = {
path_app_url: "https://unhealthy.coder.com",
wildcard_hostname: "*unhealthy..coder.com",
derp_enabled: true,
+ derp_only: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
deleted: false,
@@ -142,6 +145,7 @@ export const MockWorkspaceProxies: TypesGen.WorkspaceProxy[] = [
path_app_url: "https://cowboy.coder.com",
wildcard_hostname: "",
derp_enabled: false,
+ derp_only: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
deleted: false,