mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add resume support to coordinator connections (#14234)
This commit is contained in:
@@ -182,6 +182,9 @@ type Options struct {
|
||||
// AppSecurityKey is the crypto key used to sign and encrypt tokens related to
|
||||
// workspace applications. It consists of both a signing and encryption key.
|
||||
AppSecurityKey workspaceapps.SecurityKey
|
||||
// CoordinatorResumeTokenProvider is used to provide and validate resume
|
||||
// tokens issued by and passed to the coordinator DRPC API.
|
||||
CoordinatorResumeTokenProvider tailnet.ResumeTokenProvider
|
||||
|
||||
HealthcheckFunc func(ctx context.Context, apiKey string) *healthsdk.HealthcheckReport
|
||||
HealthcheckTimeout time.Duration
|
||||
@@ -584,12 +587,16 @@ func New(options *Options) *API {
|
||||
api.Options.NetworkTelemetryBatchMaxSize,
|
||||
api.handleNetworkTelemetry,
|
||||
)
|
||||
if options.CoordinatorResumeTokenProvider == nil {
|
||||
panic("CoordinatorResumeTokenProvider is nil")
|
||||
}
|
||||
api.TailnetClientService, err = tailnet.NewClientService(tailnet.ClientServiceOptions{
|
||||
Logger: api.Logger.Named("tailnetclient"),
|
||||
CoordPtr: &api.TailnetCoordinator,
|
||||
DERPMapUpdateFrequency: api.Options.DERPMapUpdateFrequency,
|
||||
DERPMapFn: api.DERPMap,
|
||||
NetworkTelemetryHandler: api.NetworkTelemetryBatcher.Handler,
|
||||
ResumeTokenProvider: api.Options.CoordinatorResumeTokenProvider,
|
||||
})
|
||||
if err != nil {
|
||||
api.Logger.Fatal(api.ctx, "failed to initialize tailnet client service", slog.Error(err))
|
||||
@@ -614,6 +621,9 @@ func New(options *Options) *API {
|
||||
options.WorkspaceAppsStatsCollectorOptions.Reporter = api.statsReporter
|
||||
}
|
||||
|
||||
if options.AppSecurityKey.IsZero() {
|
||||
api.Logger.Fatal(api.ctx, "app security key cannot be zero")
|
||||
}
|
||||
api.workspaceAppServer = &workspaceapps.Server{
|
||||
Logger: workspaceAppsLogger,
|
||||
|
||||
|
||||
@@ -96,25 +96,26 @@ type Options struct {
|
||||
// AccessURL denotes a custom access URL. By default we use the httptest
|
||||
// server's URL. Setting this may result in unexpected behavior (especially
|
||||
// with running agents).
|
||||
AccessURL *url.URL
|
||||
AppHostname string
|
||||
AWSCertificates awsidentity.Certificates
|
||||
Authorizer rbac.Authorizer
|
||||
AzureCertificates x509.VerifyOptions
|
||||
GithubOAuth2Config *coderd.GithubOAuth2Config
|
||||
RealIPConfig *httpmw.RealIPConfig
|
||||
OIDCConfig *coderd.OIDCConfig
|
||||
GoogleTokenValidator *idtoken.Validator
|
||||
SSHKeygenAlgorithm gitsshkey.Algorithm
|
||||
AutobuildTicker <-chan time.Time
|
||||
AutobuildStats chan<- autobuild.Stats
|
||||
Auditor audit.Auditor
|
||||
TLSCertificates []tls.Certificate
|
||||
ExternalAuthConfigs []*externalauth.Config
|
||||
TrialGenerator func(ctx context.Context, body codersdk.LicensorTrialRequest) error
|
||||
RefreshEntitlements func(ctx context.Context) error
|
||||
TemplateScheduleStore schedule.TemplateScheduleStore
|
||||
Coordinator tailnet.Coordinator
|
||||
AccessURL *url.URL
|
||||
AppHostname string
|
||||
AWSCertificates awsidentity.Certificates
|
||||
Authorizer rbac.Authorizer
|
||||
AzureCertificates x509.VerifyOptions
|
||||
GithubOAuth2Config *coderd.GithubOAuth2Config
|
||||
RealIPConfig *httpmw.RealIPConfig
|
||||
OIDCConfig *coderd.OIDCConfig
|
||||
GoogleTokenValidator *idtoken.Validator
|
||||
SSHKeygenAlgorithm gitsshkey.Algorithm
|
||||
AutobuildTicker <-chan time.Time
|
||||
AutobuildStats chan<- autobuild.Stats
|
||||
Auditor audit.Auditor
|
||||
TLSCertificates []tls.Certificate
|
||||
ExternalAuthConfigs []*externalauth.Config
|
||||
TrialGenerator func(ctx context.Context, body codersdk.LicensorTrialRequest) error
|
||||
RefreshEntitlements func(ctx context.Context) error
|
||||
TemplateScheduleStore schedule.TemplateScheduleStore
|
||||
Coordinator tailnet.Coordinator
|
||||
CoordinatorResumeTokenProvider tailnet.ResumeTokenProvider
|
||||
|
||||
HealthcheckFunc func(ctx context.Context, apiKey string) *healthsdk.HealthcheckReport
|
||||
HealthcheckTimeout time.Duration
|
||||
@@ -240,6 +241,9 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
|
||||
if options.Database == nil {
|
||||
options.Database, options.Pubsub = dbtestutil.NewDB(t)
|
||||
}
|
||||
if options.CoordinatorResumeTokenProvider == nil {
|
||||
options.CoordinatorResumeTokenProvider = tailnet.NewInsecureTestResumeTokenProvider()
|
||||
}
|
||||
|
||||
if options.NotificationsEnqueuer == nil {
|
||||
options.NotificationsEnqueuer = new(testutil.FakeNotificationsEnqueuer)
|
||||
@@ -492,6 +496,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can
|
||||
TailnetCoordinator: options.Coordinator,
|
||||
BaseDERPMap: derpMap,
|
||||
DERPMapUpdateFrequency: 150 * time.Millisecond,
|
||||
CoordinatorResumeTokenProvider: options.CoordinatorResumeTokenProvider,
|
||||
MetricsCacheRefreshInterval: options.MetricsCacheRefreshInterval,
|
||||
AgentStatsRefreshInterval: options.AgentStatsRefreshInterval,
|
||||
DeploymentValues: options.DeploymentValues,
|
||||
|
||||
@@ -1332,7 +1332,9 @@ func (q *querier) GetAnnouncementBanners(ctx context.Context) (string, error) {
|
||||
}
|
||||
|
||||
func (q *querier) GetAppSecurityKey(ctx context.Context) (string, error) {
|
||||
// No authz checks
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return q.db.GetAppSecurityKey(ctx)
|
||||
}
|
||||
|
||||
@@ -1364,6 +1366,13 @@ func (q *querier) GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUI
|
||||
return q.db.GetAuthorizationUserRoles(ctx, userID)
|
||||
}
|
||||
|
||||
func (q *querier) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return q.db.GetCoordinatorResumeTokenSigningKey(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) GetDBCryptKeys(ctx context.Context) ([]database.DBCryptKey, error) {
|
||||
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
return nil, err
|
||||
@@ -3792,7 +3801,9 @@ func (q *querier) UpsertAnnouncementBanners(ctx context.Context, value string) e
|
||||
}
|
||||
|
||||
func (q *querier) UpsertAppSecurityKey(ctx context.Context, data string) error {
|
||||
// No authz checks as this is done during startup
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.db.UpsertAppSecurityKey(ctx, data)
|
||||
}
|
||||
|
||||
@@ -3803,6 +3814,13 @@ func (q *querier) UpsertApplicationName(ctx context.Context, value string) error
|
||||
return q.db.UpsertApplicationName(ctx, value)
|
||||
}
|
||||
|
||||
func (q *querier) UpsertCoordinatorResumeTokenSigningKey(ctx context.Context, value string) error {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
|
||||
return err
|
||||
}
|
||||
return q.db.UpsertCoordinatorResumeTokenSigningKey(ctx, value)
|
||||
}
|
||||
|
||||
func (q *querier) UpsertDefaultProxy(ctx context.Context, arg database.UpsertDefaultProxyParams) error {
|
||||
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
|
||||
return err
|
||||
|
||||
@@ -2531,10 +2531,10 @@ func (s *MethodTestSuite) TestSystemFunctions() {
|
||||
check.Args(int32(0)).Asserts(rbac.ResourceSystem, policy.ActionRead)
|
||||
}))
|
||||
s.Run("GetAppSecurityKey", s.Subtest(func(db database.Store, check *expects) {
|
||||
check.Args().Asserts()
|
||||
check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead)
|
||||
}))
|
||||
s.Run("UpsertAppSecurityKey", s.Subtest(func(db database.Store, check *expects) {
|
||||
check.Args("").Asserts()
|
||||
check.Args("foo").Asserts(rbac.ResourceSystem, policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("GetApplicationName", s.Subtest(func(db database.Store, check *expects) {
|
||||
db.UpsertApplicationName(context.Background(), "foo")
|
||||
@@ -2574,6 +2574,13 @@ func (s *MethodTestSuite) TestSystemFunctions() {
|
||||
db.UpsertOAuthSigningKey(context.Background(), "foo")
|
||||
check.Args().Asserts(rbac.ResourceSystem, policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("UpsertCoordinatorResumeTokenSigningKey", s.Subtest(func(db database.Store, check *expects) {
|
||||
check.Args("foo").Asserts(rbac.ResourceSystem, policy.ActionUpdate)
|
||||
}))
|
||||
s.Run("GetCoordinatorResumeTokenSigningKey", s.Subtest(func(db database.Store, check *expects) {
|
||||
db.UpsertCoordinatorResumeTokenSigningKey(context.Background(), "foo")
|
||||
check.Args().Asserts(rbac.ResourceSystem, policy.ActionRead)
|
||||
}))
|
||||
s.Run("InsertMissingGroups", s.Subtest(func(db database.Store, check *expects) {
|
||||
check.Args(database.InsertMissingGroupsParams{}).Asserts(rbac.ResourceSystem, policy.ActionCreate).Errors(errMatchAny)
|
||||
}))
|
||||
|
||||
@@ -196,20 +196,21 @@ type data struct {
|
||||
customRoles []database.CustomRole
|
||||
// Locks is a map of lock names. Any keys within the map are currently
|
||||
// locked.
|
||||
locks map[int64]struct{}
|
||||
deploymentID string
|
||||
derpMeshKey string
|
||||
lastUpdateCheck []byte
|
||||
announcementBanners []byte
|
||||
healthSettings []byte
|
||||
notificationsSettings []byte
|
||||
applicationName string
|
||||
logoURL string
|
||||
appSecurityKey string
|
||||
oauthSigningKey string
|
||||
lastLicenseID int32
|
||||
defaultProxyDisplayName string
|
||||
defaultProxyIconURL string
|
||||
locks map[int64]struct{}
|
||||
deploymentID string
|
||||
derpMeshKey string
|
||||
lastUpdateCheck []byte
|
||||
announcementBanners []byte
|
||||
healthSettings []byte
|
||||
notificationsSettings []byte
|
||||
applicationName string
|
||||
logoURL string
|
||||
appSecurityKey string
|
||||
oauthSigningKey string
|
||||
coordinatorResumeTokenSigningKey string
|
||||
lastLicenseID int32
|
||||
defaultProxyDisplayName string
|
||||
defaultProxyIconURL string
|
||||
}
|
||||
|
||||
func validateDatabaseTypeWithValid(v reflect.Value) (handled bool, err error) {
|
||||
@@ -2222,6 +2223,15 @@ func (q *FakeQuerier) GetAuthorizationUserRoles(_ context.Context, userID uuid.U
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetCoordinatorResumeTokenSigningKey(_ context.Context) (string, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
if q.coordinatorResumeTokenSigningKey == "" {
|
||||
return "", sql.ErrNoRows
|
||||
}
|
||||
return q.coordinatorResumeTokenSigningKey, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetDBCryptKeys(_ context.Context) ([]database.DBCryptKey, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
@@ -8942,6 +8952,14 @@ func (q *FakeQuerier) UpsertApplicationName(_ context.Context, data string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) UpsertCoordinatorResumeTokenSigningKey(_ context.Context, value string) error {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
q.coordinatorResumeTokenSigningKey = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) UpsertDefaultProxy(_ context.Context, arg database.UpsertDefaultProxyParams) error {
|
||||
q.defaultProxyDisplayName = arg.DisplayName
|
||||
q.defaultProxyIconURL = arg.IconUrl
|
||||
|
||||
@@ -529,6 +529,13 @@ func (m metricsStore) GetAuthorizationUserRoles(ctx context.Context, userID uuid
|
||||
return row, err
|
||||
}
|
||||
|
||||
func (m metricsStore) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetCoordinatorResumeTokenSigningKey(ctx)
|
||||
m.queryLatencies.WithLabelValues("GetCoordinatorResumeTokenSigningKey").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)
|
||||
@@ -2377,6 +2384,13 @@ func (m metricsStore) UpsertApplicationName(ctx context.Context, value string) e
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m metricsStore) UpsertCoordinatorResumeTokenSigningKey(ctx context.Context, value string) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.UpsertCoordinatorResumeTokenSigningKey(ctx, value)
|
||||
m.queryLatencies.WithLabelValues("UpsertCoordinatorResumeTokenSigningKey").Observe(time.Since(start).Seconds())
|
||||
return r0
|
||||
}
|
||||
|
||||
func (m metricsStore) UpsertDefaultProxy(ctx context.Context, arg database.UpsertDefaultProxyParams) error {
|
||||
start := time.Now()
|
||||
r0 := m.s.UpsertDefaultProxy(ctx, arg)
|
||||
|
||||
@@ -1029,6 +1029,21 @@ func (mr *MockStoreMockRecorder) GetAuthorizedWorkspaces(arg0, arg1, arg2 any) *
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthorizedWorkspaces", reflect.TypeOf((*MockStore)(nil).GetAuthorizedWorkspaces), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// GetCoordinatorResumeTokenSigningKey mocks base method.
|
||||
func (m *MockStore) GetCoordinatorResumeTokenSigningKey(arg0 context.Context) (string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetCoordinatorResumeTokenSigningKey", arg0)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetCoordinatorResumeTokenSigningKey indicates an expected call of GetCoordinatorResumeTokenSigningKey.
|
||||
func (mr *MockStoreMockRecorder) GetCoordinatorResumeTokenSigningKey(arg0 any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCoordinatorResumeTokenSigningKey", reflect.TypeOf((*MockStore)(nil).GetCoordinatorResumeTokenSigningKey), arg0)
|
||||
}
|
||||
|
||||
// GetDBCryptKeys mocks base method.
|
||||
func (m *MockStore) GetDBCryptKeys(arg0 context.Context) ([]database.DBCryptKey, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -4994,6 +5009,20 @@ func (mr *MockStoreMockRecorder) UpsertApplicationName(arg0, arg1 any) *gomock.C
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertApplicationName", reflect.TypeOf((*MockStore)(nil).UpsertApplicationName), arg0, arg1)
|
||||
}
|
||||
|
||||
// UpsertCoordinatorResumeTokenSigningKey mocks base method.
|
||||
func (m *MockStore) UpsertCoordinatorResumeTokenSigningKey(arg0 context.Context, arg1 string) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpsertCoordinatorResumeTokenSigningKey", arg0, arg1)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UpsertCoordinatorResumeTokenSigningKey indicates an expected call of UpsertCoordinatorResumeTokenSigningKey.
|
||||
func (mr *MockStoreMockRecorder) UpsertCoordinatorResumeTokenSigningKey(arg0, arg1 any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertCoordinatorResumeTokenSigningKey", reflect.TypeOf((*MockStore)(nil).UpsertCoordinatorResumeTokenSigningKey), arg0, arg1)
|
||||
}
|
||||
|
||||
// UpsertDefaultProxy mocks base method.
|
||||
func (m *MockStore) UpsertDefaultProxy(arg0 context.Context, arg1 database.UpsertDefaultProxyParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -128,6 +128,7 @@ type sqlcQuerier interface {
|
||||
// This function returns roles for authorization purposes. Implied member roles
|
||||
// are included.
|
||||
GetAuthorizationUserRoles(ctx context.Context, userID uuid.UUID) (GetAuthorizationUserRolesRow, error)
|
||||
GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error)
|
||||
GetDBCryptKeys(ctx context.Context) ([]DBCryptKey, error)
|
||||
GetDERPMeshKey(ctx context.Context) (string, error)
|
||||
GetDefaultOrganization(ctx context.Context) (Organization, error)
|
||||
@@ -463,6 +464,7 @@ type sqlcQuerier interface {
|
||||
UpsertAnnouncementBanners(ctx context.Context, value string) error
|
||||
UpsertAppSecurityKey(ctx context.Context, value string) error
|
||||
UpsertApplicationName(ctx context.Context, value string) error
|
||||
UpsertCoordinatorResumeTokenSigningKey(ctx context.Context, value string) error
|
||||
// The default proxy is implied and not actually stored in the database.
|
||||
// So we need to store it's configuration here for display purposes.
|
||||
// The functional values are immutable and controlled implicitly.
|
||||
|
||||
@@ -6624,6 +6624,17 @@ func (q *sqlQuerier) GetApplicationName(ctx context.Context) (string, error) {
|
||||
return value, err
|
||||
}
|
||||
|
||||
const getCoordinatorResumeTokenSigningKey = `-- name: GetCoordinatorResumeTokenSigningKey :one
|
||||
SELECT value FROM site_configs WHERE key = 'coordinator_resume_token_signing_key'
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) GetCoordinatorResumeTokenSigningKey(ctx context.Context) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getCoordinatorResumeTokenSigningKey)
|
||||
var value string
|
||||
err := row.Scan(&value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
const getDERPMeshKey = `-- name: GetDERPMeshKey :one
|
||||
SELECT value FROM site_configs WHERE key = 'derp_mesh_key'
|
||||
`
|
||||
@@ -6769,6 +6780,16 @@ func (q *sqlQuerier) UpsertApplicationName(ctx context.Context, value string) er
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertCoordinatorResumeTokenSigningKey = `-- name: UpsertCoordinatorResumeTokenSigningKey :exec
|
||||
INSERT INTO site_configs (key, value) VALUES ('coordinator_resume_token_signing_key', $1)
|
||||
ON CONFLICT (key) DO UPDATE set value = $1 WHERE site_configs.key = 'coordinator_resume_token_signing_key'
|
||||
`
|
||||
|
||||
func (q *sqlQuerier) UpsertCoordinatorResumeTokenSigningKey(ctx context.Context, value string) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertCoordinatorResumeTokenSigningKey, value)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertDefaultProxy = `-- name: UpsertDefaultProxy :exec
|
||||
INSERT INTO site_configs (key, value)
|
||||
VALUES
|
||||
|
||||
@@ -71,6 +71,13 @@ SELECT value FROM site_configs WHERE key = 'oauth_signing_key';
|
||||
INSERT INTO site_configs (key, value) VALUES ('oauth_signing_key', $1)
|
||||
ON CONFLICT (key) DO UPDATE set value = $1 WHERE site_configs.key = 'oauth_signing_key';
|
||||
|
||||
-- name: GetCoordinatorResumeTokenSigningKey :one
|
||||
SELECT value FROM site_configs WHERE key = 'coordinator_resume_token_signing_key';
|
||||
|
||||
-- name: UpsertCoordinatorResumeTokenSigningKey :exec
|
||||
INSERT INTO site_configs (key, value) VALUES ('coordinator_resume_token_signing_key', $1)
|
||||
ON CONFLICT (key) DO UPDATE set value = $1 WHERE site_configs.key = 'coordinator_resume_token_signing_key';
|
||||
|
||||
-- name: GetHealthSettings :one
|
||||
SELECT
|
||||
COALESCE((SELECT value FROM site_configs WHERE key = 'health_settings'), '{}') :: text AS health_settings
|
||||
|
||||
@@ -846,6 +846,26 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
|
||||
// Accept a resume_token query parameter to use the same peer ID.
|
||||
var (
|
||||
peerID = uuid.New()
|
||||
resumeToken = r.URL.Query().Get("resume_token")
|
||||
)
|
||||
if resumeToken != "" {
|
||||
var err error
|
||||
peerID, err = api.Options.CoordinatorResumeTokenProvider.VerifyResumeToken(resumeToken)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
|
||||
Message: workspacesdk.CoordinateAPIInvalidResumeToken,
|
||||
Detail: err.Error(),
|
||||
Validations: []codersdk.ValidationError{
|
||||
{Field: "resume_token", Detail: workspacesdk.CoordinateAPIInvalidResumeToken},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
api.WebsocketWaitMutex.Lock()
|
||||
api.WebsocketWaitGroup.Add(1)
|
||||
api.WebsocketWaitMutex.Unlock()
|
||||
@@ -866,7 +886,7 @@ func (api *API) workspaceAgentClientCoordinate(rw http.ResponseWriter, r *http.R
|
||||
go httpapi.Heartbeat(ctx, conn)
|
||||
|
||||
defer conn.Close(websocket.StatusNormalClosure, "")
|
||||
err = api.TailnetClientService.ServeClient(ctx, version, wsNetConn, uuid.New(), workspaceAgent.ID)
|
||||
err = api.TailnetClientService.ServeClient(ctx, version, wsNetConn, peerID, workspaceAgent.ID)
|
||||
if err != nil && !xerrors.Is(err, io.EOF) && !xerrors.Is(err, context.Canceled) {
|
||||
_ = conn.Close(websocket.StatusInternalError, err.Error())
|
||||
return
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/xerrors"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
"nhooyr.io/websocket"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"cdr.dev/slog"
|
||||
@@ -40,8 +41,11 @@ import (
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/provisioner/echo"
|
||||
"github.com/coder/coder/v2/provisionersdk/proto"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
tailnetproto "github.com/coder/coder/v2/tailnet/proto"
|
||||
"github.com/coder/coder/v2/tailnet/tailnettest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
func TestWorkspaceAgent(t *testing.T) {
|
||||
@@ -509,6 +513,147 @@ func TestWorkspaceAgentClientCoordinate_BadVersion(t *testing.T) {
|
||||
require.Equal(t, "version", sdkErr.Validations[0].Field)
|
||||
}
|
||||
|
||||
type resumeTokenTestFakeCoordinator struct {
|
||||
tailnet.Coordinator
|
||||
lastPeerID uuid.UUID
|
||||
}
|
||||
|
||||
var _ tailnet.Coordinator = &resumeTokenTestFakeCoordinator{}
|
||||
|
||||
func (c *resumeTokenTestFakeCoordinator) ServeClient(conn net.Conn, id uuid.UUID, agentID uuid.UUID) error {
|
||||
c.lastPeerID = id
|
||||
return c.Coordinator.ServeClient(conn, id, agentID)
|
||||
}
|
||||
|
||||
func (c *resumeTokenTestFakeCoordinator) Coordinate(ctx context.Context, id uuid.UUID, name string, a tailnet.CoordinateeAuth) (chan<- *tailnetproto.CoordinateRequest, <-chan *tailnetproto.CoordinateResponse) {
|
||||
c.lastPeerID = id
|
||||
return c.Coordinator.Coordinate(ctx, id, name, a)
|
||||
}
|
||||
|
||||
func TestWorkspaceAgentClientCoordinate_ResumeToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
||||
clock := quartz.NewMock(t)
|
||||
resumeTokenSigningKey, err := tailnet.GenerateResumeTokenSigningKey()
|
||||
require.NoError(t, err)
|
||||
resumeTokenProvider := tailnet.NewResumeTokenKeyProvider(resumeTokenSigningKey, clock, time.Hour)
|
||||
coordinator := &resumeTokenTestFakeCoordinator{
|
||||
Coordinator: tailnet.NewCoordinator(logger),
|
||||
}
|
||||
client, closer, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
||||
Coordinator: coordinator,
|
||||
CoordinatorResumeTokenProvider: resumeTokenProvider,
|
||||
})
|
||||
defer closer.Close()
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create a workspace with an agent. No need to connect it since clients can
|
||||
// still connect to the coordinator while the agent isn't connected.
|
||||
r := dbfake.WorkspaceBuild(t, api.Database, database.Workspace{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
}).WithAgent().Do()
|
||||
agentTokenUUID, err := uuid.Parse(r.AgentToken)
|
||||
require.NoError(t, err)
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
agentAndBuild, err := api.Database.GetWorkspaceAgentAndLatestBuildByAuthToken(dbauthz.AsSystemRestricted(ctx), agentTokenUUID) //nolint
|
||||
require.NoError(t, err)
|
||||
|
||||
// Connect with no resume token, and ensure that the peer ID is set to a
|
||||
// random value.
|
||||
coordinator.lastPeerID = uuid.Nil
|
||||
originalResumeToken, err := connectToCoordinatorAndFetchResumeToken(ctx, logger, client, agentAndBuild.WorkspaceAgent.ID, "")
|
||||
require.NoError(t, err)
|
||||
originalPeerID := coordinator.lastPeerID
|
||||
require.NotEqual(t, originalPeerID, uuid.Nil)
|
||||
|
||||
// Connect with a valid resume token, and ensure that the peer ID is set to
|
||||
// the stored value.
|
||||
clock.Advance(time.Second)
|
||||
coordinator.lastPeerID = uuid.Nil
|
||||
newResumeToken, err := connectToCoordinatorAndFetchResumeToken(ctx, logger, client, agentAndBuild.WorkspaceAgent.ID, originalResumeToken)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, originalPeerID, coordinator.lastPeerID)
|
||||
require.NotEqual(t, originalResumeToken, newResumeToken)
|
||||
|
||||
// Connect with an invalid resume token, and ensure that the request is
|
||||
// rejected.
|
||||
clock.Advance(time.Second)
|
||||
coordinator.lastPeerID = uuid.Nil
|
||||
_, err = connectToCoordinatorAndFetchResumeToken(ctx, logger, client, agentAndBuild.WorkspaceAgent.ID, "invalid")
|
||||
require.Error(t, err)
|
||||
var sdkErr *codersdk.Error
|
||||
require.ErrorAs(t, err, &sdkErr)
|
||||
require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode())
|
||||
require.Len(t, sdkErr.Validations, 1)
|
||||
require.Equal(t, "resume_token", sdkErr.Validations[0].Field)
|
||||
require.Equal(t, uuid.Nil, coordinator.lastPeerID)
|
||||
}
|
||||
|
||||
// connectToCoordinatorAndFetchResumeToken connects to the tailnet coordinator
|
||||
// with a given resume token. It returns an error if the connection is rejected.
|
||||
// If the connection is accepted, it is immediately closed and no error is
|
||||
// returned.
|
||||
func connectToCoordinatorAndFetchResumeToken(ctx context.Context, logger slog.Logger, sdkClient *codersdk.Client, agentID uuid.UUID, resumeToken string) (string, error) {
|
||||
u, err := sdkClient.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/coordinate", agentID))
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("parse URL: %w", err)
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("version", "2.0")
|
||||
if resumeToken != "" {
|
||||
q.Set("resume_token", resumeToken)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
//nolint:bodyclose
|
||||
wsConn, resp, err := websocket.Dial(ctx, u.String(), &websocket.DialOptions{
|
||||
HTTPHeader: http.Header{
|
||||
"Coder-Session-Token": []string{sdkClient.SessionToken()},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
err = codersdk.ReadBodyAsError(resp)
|
||||
}
|
||||
return "", xerrors.Errorf("websocket dial: %w", err)
|
||||
}
|
||||
defer wsConn.Close(websocket.StatusNormalClosure, "done")
|
||||
|
||||
// Send a request to the server to ensure that we're plumbed all the way
|
||||
// through.
|
||||
rpcClient, err := tailnet.NewDRPCClient(
|
||||
websocket.NetConn(ctx, wsConn, websocket.MessageBinary),
|
||||
logger,
|
||||
)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("new dRPC client: %w", err)
|
||||
}
|
||||
|
||||
// Send an empty coordination request. This will do nothing on the server,
|
||||
// but ensures our wrapped coordinator can record the peer ID.
|
||||
coordinateClient, err := rpcClient.Coordinate(ctx)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("coordinate: %w", err)
|
||||
}
|
||||
err = coordinateClient.Send(&tailnetproto.CoordinateRequest{})
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("send empty coordination request: %w", err)
|
||||
}
|
||||
err = coordinateClient.Close()
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("close coordination request: %w", err)
|
||||
}
|
||||
|
||||
// Fetch a resume token.
|
||||
newResumeToken, err := rpcClient.RefreshResumeToken(ctx, &tailnetproto.RefreshResumeTokenRequest{})
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("fetch resume token: %w", err)
|
||||
}
|
||||
return newResumeToken.Token, nil
|
||||
}
|
||||
|
||||
func TestWorkspaceAgentTailnetDirectDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -65,6 +65,10 @@ func (t SignedToken) MatchesRequest(req Request) bool {
|
||||
// two keys.
|
||||
type SecurityKey [96]byte
|
||||
|
||||
func (k SecurityKey) IsZero() bool {
|
||||
return k == SecurityKey{}
|
||||
}
|
||||
|
||||
func (k SecurityKey) String() string {
|
||||
return hex.EncodeToString(k[:])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user