feat: add endpoint for fetching workspace proxy keys (#14789)

This commit is contained in:
Jon Ayers
2024-09-26 21:01:49 +01:00
committed by GitHub
parent 5c977c6be7
commit 3fdeaf7b24
17 changed files with 547 additions and 0 deletions
+72
View File
@@ -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": {
+64
View File
@@ -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": {
+7
View File
@@ -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
+4
View File
@@ -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() {
+17
View File
@@ -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()
+7
View File
@@ -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)
+15
View File
@@ -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()
+1
View File
@@ -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)
+38
View File
@@ -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
+7
View File
@@ -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
+60
View File
@@ -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
+1
View File
@@ -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(
+41
View File
@@ -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
}
+117
View File
@@ -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,
}
}
+15
View File
@@ -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