mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat: add endpoint for fetching workspace proxy keys (#14789)
This commit is contained in:
Generated
+72
@@ -7540,6 +7540,34 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceproxies/me/crypto-keys": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Enterprise"
|
||||
],
|
||||
"summary": "Get workspace proxy crypto keys",
|
||||
"operationId": "get-workspace-proxy-crypto-keys",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/wsproxysdk.CryptoKeysResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceproxies/me/deregister": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -15955,6 +15983,50 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsproxysdk.CryptoKey": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deletes_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"feature": {
|
||||
"$ref": "#/definitions/wsproxysdk.CryptoKeyFeature"
|
||||
},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"sequence": {
|
||||
"type": "integer"
|
||||
},
|
||||
"starts_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsproxysdk.CryptoKeyFeature": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"workspace_apps",
|
||||
"oidc_convert",
|
||||
"tailnet_resume"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"CryptoKeyFeatureWorkspaceApp",
|
||||
"CryptoKeyFeatureOIDCConvert",
|
||||
"CryptoKeyFeatureTailnetResume"
|
||||
]
|
||||
},
|
||||
"wsproxysdk.CryptoKeysResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"crypto_keys": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/wsproxysdk.CryptoKey"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsproxysdk.DeregisterWorkspaceProxyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Generated
+64
@@ -6668,6 +6668,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceproxies/me/crypto-keys": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Get workspace proxy crypto keys",
|
||||
"operationId": "get-workspace-proxy-crypto-keys",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/wsproxysdk.CryptoKeysResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-apidocgen": {
|
||||
"skip": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaceproxies/me/deregister": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -14590,6 +14614,46 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsproxysdk.CryptoKey": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"deletes_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"feature": {
|
||||
"$ref": "#/definitions/wsproxysdk.CryptoKeyFeature"
|
||||
},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
},
|
||||
"sequence": {
|
||||
"type": "integer"
|
||||
},
|
||||
"starts_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsproxysdk.CryptoKeyFeature": {
|
||||
"type": "string",
|
||||
"enum": ["workspace_apps", "oidc_convert", "tailnet_resume"],
|
||||
"x-enum-varnames": [
|
||||
"CryptoKeyFeatureWorkspaceApp",
|
||||
"CryptoKeyFeatureOIDCConvert",
|
||||
"CryptoKeyFeatureTailnetResume"
|
||||
]
|
||||
},
|
||||
"wsproxysdk.CryptoKeysResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"crypto_keys": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/wsproxysdk.CryptoKey"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"wsproxysdk.DeregisterWorkspaceProxyRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1405,6 +1405,13 @@ func (q *querier) GetCryptoKeys(ctx context.Context) ([]database.CryptoKey, erro
|
||||
return q.db.GetCryptoKeys(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) GetCryptoKeysByFeature(ctx context.Context, feature database.CryptoKeyFeature) ([]database.CryptoKey, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceCryptoKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetCryptoKeysByFeature(ctx, feature)
|
||||
}
|
||||
|
||||
func (q *querier) GetDBCryptKeys(ctx context.Context) ([]database.DBCryptKey, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -2302,6 +2302,10 @@ func (s *MethodTestSuite) TestCryptoKeys() {
|
||||
DeletesAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
}).Asserts(rbac.ResourceCryptoKey, policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("GetCryptoKeysByFeature", s.Subtest(func(db database.Store, check *expects) {
|
||||
check.Args(database.CryptoKeyFeatureWorkspaceApps).
|
||||
Asserts(rbac.ResourceCryptoKey, policy.ActionRead)
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *MethodTestSuite) TestSystemFunctions() {
|
||||
|
||||
@@ -2429,6 +2429,23 @@ func (q *FakeQuerier) GetCryptoKeys(_ context.Context) ([]database.CryptoKey, er
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetCryptoKeysByFeature(_ context.Context, feature database.CryptoKeyFeature) ([]database.CryptoKey, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
keys := make([]database.CryptoKey, 0)
|
||||
for _, key := range q.cryptoKeys {
|
||||
if key.Feature == feature && key.Secret.Valid {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
// We want to return the highest sequence number first.
|
||||
slices.SortFunc(keys, func(i, j database.CryptoKey) int {
|
||||
return int(j.Sequence - i.Sequence)
|
||||
})
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetDBCryptKeys(_ context.Context) ([]database.DBCryptKey, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
@@ -564,6 +564,13 @@ func (m metricsStore) GetCryptoKeys(ctx context.Context) ([]database.CryptoKey,
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m metricsStore) GetCryptoKeysByFeature(ctx context.Context, feature database.CryptoKeyFeature) ([]database.CryptoKey, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetCryptoKeysByFeature(ctx, feature)
|
||||
m.queryLatencies.WithLabelValues("GetCryptoKeysByFeature").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m metricsStore) GetDBCryptKeys(ctx context.Context) ([]database.DBCryptKey, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetDBCryptKeys(ctx)
|
||||
|
||||
@@ -1103,6 +1103,21 @@ func (mr *MockStoreMockRecorder) GetCryptoKeys(arg0 any) *gomock.Call {
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCryptoKeys", reflect.TypeOf((*MockStore)(nil).GetCryptoKeys), arg0)
|
||||
}
|
||||
|
||||
// GetCryptoKeysByFeature mocks base method.
|
||||
func (m *MockStore) GetCryptoKeysByFeature(arg0 context.Context, arg1 database.CryptoKeyFeature) ([]database.CryptoKey, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetCryptoKeysByFeature", arg0, arg1)
|
||||
ret0, _ := ret[0].([]database.CryptoKey)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetCryptoKeysByFeature indicates an expected call of GetCryptoKeysByFeature.
|
||||
func (mr *MockStoreMockRecorder) GetCryptoKeysByFeature(arg0, arg1 any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCryptoKeysByFeature", reflect.TypeOf((*MockStore)(nil).GetCryptoKeysByFeature), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetDBCryptKeys mocks base method.
|
||||
func (m *MockStore) GetDBCryptKeys(arg0 context.Context) ([]database.DBCryptKey, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -134,6 +134,7 @@ type sqlcQuerier interface {
|
||||
GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error)
|
||||
GetCryptoKeyByFeatureAndSequence(ctx context.Context, arg GetCryptoKeyByFeatureAndSequenceParams) (CryptoKey, error)
|
||||
GetCryptoKeys(ctx context.Context) ([]CryptoKey, error)
|
||||
GetCryptoKeysByFeature(ctx context.Context, feature CryptoKeyFeature) ([]CryptoKey, error)
|
||||
GetDBCryptKeys(ctx context.Context) ([]DBCryptKey, error)
|
||||
GetDERPMeshKey(ctx context.Context) (string, error)
|
||||
GetDefaultOrganization(ctx context.Context) (Organization, error)
|
||||
|
||||
@@ -849,6 +849,44 @@ func (q *sqlQuerier) GetCryptoKeys(ctx context.Context) ([]CryptoKey, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getCryptoKeysByFeature = `-- name: GetCryptoKeysByFeature :many
|
||||
SELECT feature, sequence, secret, secret_key_id, starts_at, deletes_at
|
||||
FROM crypto_keys
|
||||
WHERE feature = $1
|
||||
AND secret IS NOT NULL
|
||||
ORDER BY sequence DESC
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetCryptoKeysByFeature(ctx context.Context, feature CryptoKeyFeature) ([]CryptoKey, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getCryptoKeysByFeature, feature)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []CryptoKey
|
||||
for rows.Next() {
|
||||
var i CryptoKey
|
||||
if err := rows.Scan(
|
||||
&i.Feature,
|
||||
&i.Sequence,
|
||||
&i.Secret,
|
||||
&i.SecretKeyID,
|
||||
&i.StartsAt,
|
||||
&i.DeletesAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getLatestCryptoKeyByFeature = `-- name: GetLatestCryptoKeyByFeature :one
|
||||
SELECT feature, sequence, secret, secret_key_id, starts_at, deletes_at
|
||||
FROM crypto_keys
|
||||
|
||||
@@ -3,6 +3,13 @@ SELECT *
|
||||
FROM crypto_keys
|
||||
WHERE secret IS NOT NULL;
|
||||
|
||||
-- name: GetCryptoKeysByFeature :many
|
||||
SELECT *
|
||||
FROM crypto_keys
|
||||
WHERE feature = $1
|
||||
AND secret IS NOT NULL
|
||||
ORDER BY sequence DESC;
|
||||
|
||||
-- name: GetLatestCryptoKeyByFeature :one
|
||||
SELECT *
|
||||
FROM crypto_keys
|
||||
|
||||
Generated
+60
@@ -9768,6 +9768,66 @@ _None_
|
||||
| `derp_map` | [tailcfg.DERPMap](#tailcfgderpmap) | false | | |
|
||||
| `disable_direct_connections` | boolean | false | | |
|
||||
|
||||
## wsproxysdk.CryptoKey
|
||||
|
||||
```json
|
||||
{
|
||||
"deletes_at": "string",
|
||||
"feature": "workspace_apps",
|
||||
"secret": "string",
|
||||
"sequence": 0,
|
||||
"starts_at": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ------------ | ---------------------------------------------------------- | -------- | ------------ | ----------- |
|
||||
| `deletes_at` | string | false | | |
|
||||
| `feature` | [wsproxysdk.CryptoKeyFeature](#wsproxysdkcryptokeyfeature) | false | | |
|
||||
| `secret` | string | false | | |
|
||||
| `sequence` | integer | false | | |
|
||||
| `starts_at` | string | false | | |
|
||||
|
||||
## wsproxysdk.CryptoKeyFeature
|
||||
|
||||
```json
|
||||
"workspace_apps"
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Value |
|
||||
| ---------------- |
|
||||
| `workspace_apps` |
|
||||
| `oidc_convert` |
|
||||
| `tailnet_resume` |
|
||||
|
||||
## wsproxysdk.CryptoKeysResponse
|
||||
|
||||
```json
|
||||
{
|
||||
"crypto_keys": [
|
||||
{
|
||||
"deletes_at": "string",
|
||||
"feature": "workspace_apps",
|
||||
"secret": "string",
|
||||
"sequence": 0,
|
||||
"starts_at": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ------------- | ----------------------------------------------------- | -------- | ------------ | ----------- |
|
||||
| `crypto_keys` | array of [wsproxysdk.CryptoKey](#wsproxysdkcryptokey) | false | | |
|
||||
|
||||
## wsproxysdk.DeregisterWorkspaceProxyRequest
|
||||
|
||||
```json
|
||||
|
||||
@@ -243,6 +243,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
|
||||
r.Post("/app-stats", api.workspaceProxyReportAppStats)
|
||||
r.Post("/register", api.workspaceProxyRegister)
|
||||
r.Post("/deregister", api.workspaceProxyDeregister)
|
||||
r.Get("/crypto-keys", api.workspaceProxyCryptoKeys)
|
||||
})
|
||||
r.Route("/{workspaceproxy}", func(r chi.Router) {
|
||||
r.Use(
|
||||
|
||||
@@ -710,6 +710,33 @@ func (api *API) workspaceProxyRegister(rw http.ResponseWriter, r *http.Request)
|
||||
go api.forceWorkspaceProxyHealthUpdate(api.ctx)
|
||||
}
|
||||
|
||||
// workspaceProxyCryptoKeys is used to fetch signing keys for the workspace proxy.
|
||||
//
|
||||
// This is called periodically by the proxy in the background (every 10m per
|
||||
// replica) to ensure that the proxy has the latest signing keys.
|
||||
//
|
||||
// @Summary Get workspace proxy crypto keys
|
||||
// @ID get-workspace-proxy-crypto-keys
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Enterprise
|
||||
// @Success 200 {object} wsproxysdk.CryptoKeysResponse
|
||||
// @Router /workspaceproxies/me/crypto-keys [get]
|
||||
// @x-apidocgen {"skip": true}
|
||||
func (api *API) workspaceProxyCryptoKeys(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
keys, err := api.Database.GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureWorkspaceApps)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, wsproxysdk.CryptoKeysResponse{
|
||||
CryptoKeys: fromDBCryptoKeys(keys),
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Deregister workspace proxy
|
||||
// @ID deregister-workspace-proxy
|
||||
// @Security CoderSessionToken
|
||||
@@ -967,3 +994,17 @@ func (w *workspaceProxiesFetchUpdater) Fetch(ctx context.Context) (codersdk.Regi
|
||||
func (w *workspaceProxiesFetchUpdater) Update(ctx context.Context) error {
|
||||
return w.updateFunc(ctx)
|
||||
}
|
||||
|
||||
func fromDBCryptoKeys(keys []database.CryptoKey) []wsproxysdk.CryptoKey {
|
||||
wskeys := make([]wsproxysdk.CryptoKey, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
wskeys = append(wskeys, wsproxysdk.CryptoKey{
|
||||
Feature: wsproxysdk.CryptoKeyFeature(key.Feature),
|
||||
Sequence: key.Sequence,
|
||||
StartsAt: key.StartsAt.UTC(),
|
||||
DeletesAt: key.DeletesAt.Time.UTC(),
|
||||
Secret: key.Secret.String,
|
||||
})
|
||||
}
|
||||
return wskeys
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package coderd_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -17,6 +18,7 @@ import (
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/workspaceapps"
|
||||
@@ -887,3 +889,118 @@ func TestReconnectingPTYSignedToken(t *testing.T) {
|
||||
// validate it here.
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetCryptoKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
db, pubsub := dbtestutil.NewDB(t)
|
||||
cclient, _, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
Database: db,
|
||||
Pubsub: pubsub,
|
||||
IncludeProvisionerDaemon: true,
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureWorkspaceProxy: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
expectedKey1 := dbgen.CryptoKey(t, db, database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
StartsAt: now.Add(-time.Hour),
|
||||
Sequence: 2,
|
||||
})
|
||||
key1 := fromDBCryptoKeys(expectedKey1)
|
||||
|
||||
expectedKey2 := dbgen.CryptoKey(t, db, database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
StartsAt: now,
|
||||
Sequence: 3,
|
||||
})
|
||||
key2 := fromDBCryptoKeys(expectedKey2)
|
||||
|
||||
// Create a deleted key.
|
||||
_ = dbgen.CryptoKey(t, db, database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
StartsAt: now.Add(-time.Hour),
|
||||
Secret: sql.NullString{
|
||||
String: "secret1",
|
||||
Valid: false,
|
||||
},
|
||||
Sequence: 1,
|
||||
})
|
||||
|
||||
// Create a key with different features.
|
||||
_ = dbgen.CryptoKey(t, db, database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureTailnetResume,
|
||||
StartsAt: now.Add(-time.Hour),
|
||||
Sequence: 1,
|
||||
})
|
||||
_ = dbgen.CryptoKey(t, db, database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureOidcConvert,
|
||||
StartsAt: now.Add(-time.Hour),
|
||||
Sequence: 1,
|
||||
})
|
||||
|
||||
proxy := coderdenttest.NewWorkspaceProxyReplica(t, api, cclient, &coderdenttest.ProxyOptions{
|
||||
Name: testutil.GetRandomName(t),
|
||||
})
|
||||
|
||||
keys, err := proxy.SDKClient.CryptoKeys(ctx)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, keys)
|
||||
require.Equal(t, 2, len(keys.CryptoKeys))
|
||||
require.Contains(t, keys.CryptoKeys, key1)
|
||||
require.Contains(t, keys.CryptoKeys, key2)
|
||||
})
|
||||
|
||||
t.Run("Unauthorized", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
db, pubsub := dbtestutil.NewDB(t)
|
||||
cclient, _, api, _ := coderdenttest.NewWithAPI(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
Database: db,
|
||||
Pubsub: pubsub,
|
||||
IncludeProvisionerDaemon: true,
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureWorkspaceProxy: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
_ = coderdenttest.NewWorkspaceProxyReplica(t, api, cclient, &coderdenttest.ProxyOptions{
|
||||
Name: testutil.GetRandomName(t),
|
||||
})
|
||||
|
||||
client := wsproxysdk.New(cclient.URL)
|
||||
client.SetSessionToken(cclient.SessionToken())
|
||||
|
||||
_, err := client.CryptoKeys(ctx)
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode())
|
||||
})
|
||||
}
|
||||
|
||||
func fromDBCryptoKeys(key database.CryptoKey) wsproxysdk.CryptoKey {
|
||||
return wsproxysdk.CryptoKey{
|
||||
Feature: wsproxysdk.CryptoKeyFeature(key.Feature),
|
||||
Sequence: key.Sequence,
|
||||
StartsAt: key.StartsAt.UTC(),
|
||||
DeletesAt: key.DeletesAt.Time.UTC(),
|
||||
Secret: key.Secret.String,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,6 +321,21 @@ func (db *dbCrypt) UpdateCryptoKeyDeletesAt(ctx context.Context, arg database.Up
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (db *dbCrypt) GetCryptoKeysByFeature(ctx context.Context, feature database.CryptoKeyFeature) ([]database.CryptoKey, error) {
|
||||
keys, err := db.Store.GetCryptoKeysByFeature(ctx, feature)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range keys {
|
||||
if err := db.decryptField(&keys[i].Secret.String, keys[i].SecretKeyID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func (db *dbCrypt) encryptField(field *string, digest *sql.NullString) error {
|
||||
// If no cipher is loaded, then we can't encrypt anything!
|
||||
if db.ciphers == nil || db.primaryCipherDigest == "" {
|
||||
|
||||
@@ -450,6 +450,35 @@ func TestCryptoKeys(t *testing.T) {
|
||||
require.Equal(t, ciphers[0].HexDigest(), key.SecretKeyID.String)
|
||||
})
|
||||
|
||||
t.Run("GetCryptoKeysByFeature", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, crypt, ciphers := setup(t)
|
||||
expected := dbgen.CryptoKey(t, crypt, database.CryptoKey{
|
||||
Sequence: 2,
|
||||
Feature: database.CryptoKeyFeatureTailnetResume,
|
||||
Secret: sql.NullString{String: "test", Valid: true},
|
||||
})
|
||||
_ = dbgen.CryptoKey(t, crypt, database.CryptoKey{
|
||||
Feature: database.CryptoKeyFeatureWorkspaceApps,
|
||||
Sequence: 43,
|
||||
})
|
||||
keys, err := crypt.GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureTailnetResume)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, keys, 1)
|
||||
require.Equal(t, "test", keys[0].Secret.String)
|
||||
require.Equal(t, ciphers[0].HexDigest(), keys[0].SecretKeyID.String)
|
||||
require.Equal(t, expected.Sequence, keys[0].Sequence)
|
||||
require.Equal(t, expected.Feature, keys[0].Feature)
|
||||
|
||||
keys, err = db.GetCryptoKeysByFeature(ctx, database.CryptoKeyFeatureTailnetResume)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, keys, 1)
|
||||
requireEncryptedEquals(t, ciphers[0], keys[0].Secret.String, "test")
|
||||
require.Equal(t, ciphers[0].HexDigest(), keys[0].SecretKeyID.String)
|
||||
require.Equal(t, expected.Sequence, keys[0].Sequence)
|
||||
require.Equal(t, expected.Feature, keys[0].Feature)
|
||||
})
|
||||
|
||||
t.Run("DecryptErr", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
db, crypt, ciphers := setup(t)
|
||||
|
||||
@@ -204,6 +204,35 @@ type RegisterWorkspaceProxyRequest struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type CryptoKeyFeature string
|
||||
|
||||
const (
|
||||
CryptoKeyFeatureWorkspaceApp CryptoKeyFeature = "workspace_apps"
|
||||
CryptoKeyFeatureOIDCConvert CryptoKeyFeature = "oidc_convert"
|
||||
CryptoKeyFeatureTailnetResume CryptoKeyFeature = "tailnet_resume"
|
||||
)
|
||||
|
||||
type CryptoKey struct {
|
||||
Feature CryptoKeyFeature `json:"feature"`
|
||||
Secret string `json:"secret"`
|
||||
DeletesAt time.Time `json:"deletes_at"`
|
||||
Sequence int32 `json:"sequence"`
|
||||
StartsAt time.Time `json:"starts_at"`
|
||||
}
|
||||
|
||||
func (c CryptoKey) CanSign(now time.Time) bool {
|
||||
now = now.UTC()
|
||||
isAfterStartsAt := !c.StartsAt.IsZero() && !now.Before(c.StartsAt)
|
||||
return isAfterStartsAt && c.CanVerify(now)
|
||||
}
|
||||
|
||||
func (c CryptoKey) CanVerify(now time.Time) bool {
|
||||
now = now.UTC()
|
||||
hasSecret := c.Secret != ""
|
||||
beforeDelete := c.DeletesAt.IsZero() || now.Before(c.DeletesAt)
|
||||
return hasSecret && beforeDelete
|
||||
}
|
||||
|
||||
type RegisterWorkspaceProxyResponse struct {
|
||||
AppSecurityKey string `json:"app_security_key"`
|
||||
DERPMeshKey string `json:"derp_mesh_key"`
|
||||
@@ -334,6 +363,7 @@ func (l *RegisterWorkspaceProxyLoop) Start(ctx context.Context) (RegisterWorkspa
|
||||
failedAttempts = 0
|
||||
ticker = time.NewTicker(l.opts.Interval)
|
||||
)
|
||||
|
||||
for {
|
||||
var respCh chan RegisterWorkspaceProxyResponse
|
||||
select {
|
||||
@@ -376,6 +406,7 @@ func (l *RegisterWorkspaceProxyLoop) Start(ctx context.Context) (RegisterWorkspa
|
||||
l.failureFn(xerrors.New("app security key has changed, proxy must be restarted"))
|
||||
return
|
||||
}
|
||||
|
||||
if originalRes.DERPMeshKey != resp.DERPMeshKey {
|
||||
l.failureFn(xerrors.New("DERP mesh key has changed, proxy must be restarted"))
|
||||
return
|
||||
@@ -580,6 +611,27 @@ func (c *Client) DialCoordinator(ctx context.Context) (agpl.MultiAgentConn, erro
|
||||
return ma, nil
|
||||
}
|
||||
|
||||
type CryptoKeysResponse struct {
|
||||
CryptoKeys []CryptoKey `json:"crypto_keys"`
|
||||
}
|
||||
|
||||
func (c *Client) CryptoKeys(ctx context.Context) (CryptoKeysResponse, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet,
|
||||
"/api/v2/workspaceproxies/me/crypto-keys",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return CryptoKeysResponse{}, xerrors.Errorf("make request: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return CryptoKeysResponse{}, codersdk.ReadBodyAsError(res)
|
||||
}
|
||||
var resp CryptoKeysResponse
|
||||
return resp, json.NewDecoder(res.Body).Decode(&resp)
|
||||
}
|
||||
|
||||
type remoteMultiAgentHandler struct {
|
||||
sdk *Client
|
||||
logger slog.Logger
|
||||
|
||||
Reference in New Issue
Block a user