diff --git a/internal/daemon/controller/controller.go b/internal/daemon/controller/controller.go index 212fdcc55..f2cb3363c 100644 --- a/internal/daemon/controller/controller.go +++ b/internal/daemon/controller/controller.go @@ -27,6 +27,7 @@ import ( "github.com/hashicorp/boundary/internal/scheduler" "github.com/hashicorp/boundary/internal/scheduler/job" "github.com/hashicorp/boundary/internal/servers" + serversjob "github.com/hashicorp/boundary/internal/servers/job" "github.com/hashicorp/boundary/internal/servers/store" "github.com/hashicorp/boundary/internal/session" "github.com/hashicorp/boundary/internal/target" @@ -37,7 +38,6 @@ import ( "github.com/hashicorp/go-hclog" "github.com/hashicorp/go-secure-stdlib/mlock" "github.com/hashicorp/go-secure-stdlib/pluginutil/v2" - "github.com/hashicorp/nodeenrollment/rotation" "github.com/hashicorp/nodeenrollment/types" "github.com/mr-tron/base58" ua "go.uber.org/atomic" @@ -317,15 +317,6 @@ func (c *Controller) Start() error { c.baseContext, c.baseCancel = context.WithCancel(context.Background()) - workerAuthStorage, err := c.WorkerAuthRepoStorageFn() - if err != nil { - return fmt.Errorf("error fetching worker auth storage: %w", err) - } - _, err = rotation.RotateRootCertificates(c.baseContext, workerAuthStorage) - if err != nil { - return err - } - if err := c.registerJobs(); err != nil { return fmt.Errorf("error registering jobs: %w", err) } @@ -377,6 +368,9 @@ func (c *Controller) registerJobs() error { if err := session.RegisterJobs(c.baseContext, c.scheduler, rw, c.conf.StatusGracePeriodDuration); err != nil { return err } + if err := serversjob.RegisterJobs(c.baseContext, c.scheduler, rw, rw, c.kms); err != nil { + return err + } return nil } diff --git a/internal/servers/job/jobs.go b/internal/servers/job/jobs.go new file mode 100644 index 000000000..3c900a865 --- /dev/null +++ b/internal/servers/job/jobs.go @@ -0,0 +1,50 @@ +package servers + +import ( + "context" + "reflect" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/kms" + "github.com/hashicorp/boundary/internal/scheduler" +) + +// RegisterJobs registers the rotate roots job with the provided scheduler. +func RegisterJobs(ctx context.Context, scheduler *scheduler.Scheduler, r db.Reader, w db.Writer, kms *kms.Kms) error { + const op = "servers.(Jobs).RegisterJobs" + + if isNil(scheduler) { + return errors.New(ctx, errors.InvalidParameter, op, "missing scheduler") + } + if isNil(r) { + return errors.New(ctx, errors.InvalidParameter, op, "missing reader") + } + if isNil(w) { + return errors.New(ctx, errors.InvalidParameter, op, "missing writer") + } + if kms == nil { + return errors.New(ctx, errors.InvalidParameter, op, "missing kms") + } + + rotateRootsJob, err := newRotateRootsJob(ctx, r, w, kms) + if err != nil { + return errors.Wrap(ctx, err, op) + } + if err = scheduler.RegisterJob(ctx, rotateRootsJob); err != nil { + return errors.Wrap(ctx, err, op) + } + + return nil +} + +func isNil(i interface{}) bool { + if i == nil { + return true + } + switch reflect.TypeOf(i).Kind() { + case reflect.Ptr, reflect.Map, reflect.Array, reflect.Chan, reflect.Slice: + return reflect.ValueOf(i).IsNil() + } + return false +} diff --git a/internal/servers/job/rotate_roots_job.go b/internal/servers/job/rotate_roots_job.go new file mode 100644 index 000000000..ccdc83046 --- /dev/null +++ b/internal/servers/job/rotate_roots_job.go @@ -0,0 +1,82 @@ +package servers + +import ( + "context" + "time" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/kms" + "github.com/hashicorp/boundary/internal/scheduler" + "github.com/hashicorp/boundary/internal/servers" +) + +const rotateFrequency = time.Hour + +// rotateRootsJob defines a periodic job that initiates root certificate rotation +// It runs every hour; the root rotation function in the library is designed to not +// do anything if it's not time to rotate (roots are within their valid ranges) +type rotateRootsJob struct { + workerAuthRepo *servers.WorkerAuthRepositoryStorage + + totalRotates int +} + +// newRotateRootsJob instantiates the rotate roots job. +func newRotateRootsJob(ctx context.Context, r db.Reader, w db.Writer, kms *kms.Kms) (*rotateRootsJob, error) { + const op = "servers.newRotateRootsJob" + switch { + case isNil(r): + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing reader") + case isNil(w): + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing writer") + case kms == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing kms") + } + + workerAuthRepo, err := servers.NewRepositoryStorage(ctx, r, w, kms) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + return &rotateRootsJob{ + workerAuthRepo: workerAuthRepo, + totalRotates: 0, + }, nil +} + +// Name returns a short, unique name for the job. +func (r *rotateRootsJob) Name() string { return "rotate_roots" } + +// Description returns the description for the job. +func (r *rotateRootsJob) Description() string { + return "Rotate root certificates" +} + +// NextRunIn returns the next run time after a job is completed. +// This is represented by RotateFrequency +func (r *rotateRootsJob) NextRunIn(_ context.Context) (time.Duration, error) { + return rotateFrequency, nil +} + +// Status returns the status of the running job. +func (r *rotateRootsJob) Status() scheduler.JobStatus { + return scheduler.JobStatus{ + Completed: r.totalRotates, + Total: r.totalRotates, + } +} + +// Run executes the job by calling the rotateRoots domain function +func (r *rotateRootsJob) Run(ctx context.Context) error { + const op = "servers.(rotateRootsJob).Run" + + err := servers.RotateRoots(ctx, r.workerAuthRepo) + if err != nil { + return errors.Wrap(ctx, err, op) + } + + r.totalRotates += 1 + + return nil +} diff --git a/internal/servers/job/rotate_roots_job_test.go b/internal/servers/job/rotate_roots_job_test.go new file mode 100644 index 000000000..bf32879c7 --- /dev/null +++ b/internal/servers/job/rotate_roots_job_test.go @@ -0,0 +1,221 @@ +package servers + +import ( + "context" + "crypto/rand" + "testing" + "time" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/kms" + "github.com/hashicorp/boundary/internal/scheduler" + "github.com/hashicorp/boundary/internal/servers" + "github.com/hashicorp/boundary/internal/types/scope" + "github.com/hashicorp/nodeenrollment/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRotateRootsJob(t *testing.T) { + require, assert := require.New(t), assert.New(t) + ctx := context.Background() + wrapper := db.TestWrapper(t) + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + kmsCache := kms.TestKms(t, conn, wrapper) + err := kmsCache.CreateKeys(context.Background(), scope.Global.String(), kms.WithRandomReader(rand.Reader)) + require.NoError(err) + + workerAuthRepo, err := servers.NewRepositoryStorage(ctx, rw, rw, kmsCache) + require.NoError(err) + + type args struct { + w db.Writer + r db.Reader + kms *kms.Kms + } + tests := []struct { + name string + args args + options []servers.Option + wantLimit int + wantErr bool + wantErrCode errors.Code + }{ + { + name: "nil writer", + args: args{ + r: rw, + kms: kmsCache, + }, + wantErr: true, + wantErrCode: errors.InvalidParameter, + }, + { + name: "nil reader", + args: args{ + w: rw, + kms: kmsCache, + }, + wantErr: true, + wantErrCode: errors.InvalidParameter, + }, + { + name: "nil kms", + args: args{ + w: rw, + r: rw, + }, + wantErr: true, + wantErrCode: errors.InvalidParameter, + }, + { + name: "valid", + args: args{ + w: rw, + r: rw, + kms: kmsCache, + }, + wantLimit: db.DefaultLimit, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := newRotateRootsJob(ctx, tt.args.r, tt.args.w, tt.args.kms) + if tt.wantErr { + require.Error(err) + assert.Nil(got) + assert.Truef(errors.Match(errors.T(tt.wantErrCode), err), "Unexpected error %s", err) + return + } + require.NoError(err) + require.NotNil(got) + require.Equal(0, got.totalRotates) + rootIds, err := workerAuthRepo.List(ctx, (*types.RootCertificate)(nil)) + require.NoError(err) + assert.Len(rootIds, 0) + assert.Equal("rotate_roots", got.Name()) + assert.Equal("Rotate root certificates", got.Description()) + nextRun, err := got.NextRunIn(ctx) + require.NoError(err) + assert.Equal(time.Hour, nextRun) + + // Run job and ensure rotation was performed + err = got.Run(ctx) + require.NoError(err) + require.Equal(1, got.totalRotates) + rootIds, err = workerAuthRepo.List(ctx, (*types.RootCertificate)(nil)) + require.NoError(err) + assert.Len(rootIds, 2) + }) + } +} + +func TestRotateRootsJobFailure(t *testing.T) { + require := require.New(t) + ctx := context.Background() + wrapper := db.TestWrapper(t) + conn, _ := db.TestSetup(t, "postgres") + kmsCache := kms.TestKms(t, conn, wrapper) + err := kmsCache.CreateKeys(context.Background(), scope.Global.String(), kms.WithRandomReader(rand.Reader)) + require.NoError(err) + + got, err := newRotateRootsJob(ctx, &db.Db{}, &db.Db{}, kmsCache) + require.NoError(err) + + err = got.Run(ctx) + require.Error(err) +} + +func TestRegisterRotateRootsJob(t *testing.T) { + require, assert := require.New(t), assert.New(t) + ctx := context.Background() + wrapper := db.TestWrapper(t) + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + kmsCache := kms.TestKms(t, conn, wrapper) + err := kmsCache.CreateKeys(context.Background(), scope.Global.String(), kms.WithRandomReader(rand.Reader)) + require.NoError(err) + + sched := scheduler.TestScheduler(t, conn, wrapper) + + type args struct { + s *scheduler.Scheduler + w db.Writer + r db.Reader + kms *kms.Kms + } + tests := []struct { + name string + args args + options []servers.Option + wantLimit int + wantErr bool + wantErrCode errors.Code + }{ + { + name: "nil scheduler", + args: args{ + w: rw, + r: rw, + kms: kmsCache, + }, + wantErr: true, + wantErrCode: errors.InvalidParameter, + }, + { + name: "nil writer", + args: args{ + s: sched, + r: rw, + kms: kmsCache, + }, + wantErr: true, + wantErrCode: errors.InvalidParameter, + }, + { + name: "nil reader", + args: args{ + s: sched, + w: rw, + kms: kmsCache, + }, + wantErr: true, + wantErrCode: errors.InvalidParameter, + }, + { + name: "nil kms", + args: args{ + s: sched, + w: rw, + r: rw, + }, + wantErr: true, + wantErrCode: errors.InvalidParameter, + }, + { + name: "valid", + args: args{ + s: sched, + w: rw, + r: rw, + kms: kmsCache, + }, + wantLimit: db.DefaultLimit, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := RegisterJobs(ctx, tt.args.s, tt.args.r, tt.args.w, tt.args.kms) + if tt.wantErr { + require.Error(err) + assert.Truef(errors.Match(errors.T(tt.wantErrCode), err), "Unexpected error %s", err) + return + } + require.NoError(err) + }) + } +} diff --git a/internal/servers/service_rotate_roots.go b/internal/servers/service_rotate_roots.go new file mode 100644 index 000000000..119962b10 --- /dev/null +++ b/internal/servers/service_rotate_roots.go @@ -0,0 +1,27 @@ +package servers + +import ( + "context" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/nodeenrollment" + "github.com/hashicorp/nodeenrollment/rotation" +) + +// RotateRoots is a domain service function that initiates a rotation of root certificates +// via a call to the nodenenrollment RotateRootCertificates function +// Accepts the nodeenrollment option, WithCertificateLifetime(time.Duration) to specify the lifetime +// of the generated cert(s) +func RotateRoots(ctx context.Context, workerAuthRepo *WorkerAuthRepositoryStorage, opt ...nodeenrollment.Option) error { + const op = "servers.RotateRoots" + if workerAuthRepo == nil { + return errors.New(ctx, errors.InvalidParameter, op, "missing workerAuthRepo") + } + + _, err := rotation.RotateRootCertificates(ctx, workerAuthRepo, opt...) + if err != nil { + return errors.Wrap(ctx, err, op) + } + + return nil +} diff --git a/internal/servers/service_rotate_roots_test.go b/internal/servers/service_rotate_roots_test.go new file mode 100644 index 000000000..7df8e488b --- /dev/null +++ b/internal/servers/service_rotate_roots_test.go @@ -0,0 +1,88 @@ +package servers + +import ( + "context" + "crypto/rand" + "testing" + "time" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/kms" + "github.com/hashicorp/boundary/internal/types/scope" + "github.com/hashicorp/nodeenrollment" + "github.com/hashicorp/nodeenrollment/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRotateRoots(t *testing.T) { + require, assert := require.New(t), assert.New(t) + ctx := context.Background() + wrapper := db.TestWrapper(t) + conn, _ := db.TestSetup(t, "postgres") + kmsCache := kms.TestKms(t, conn, wrapper) + err := kmsCache.CreateKeys(context.Background(), scope.Global.String(), kms.WithRandomReader(rand.Reader)) + require.NoError(err) + + rw := db.New(conn) + workerAuthRepo, err := NewRepositoryStorage(ctx, rw, rw, kmsCache) + require.NoError(err) + + // Check that we have no roots first + rootIds, err := workerAuthRepo.List(ctx, (*types.RootCertificate)(nil)) + require.NoError(err) + assert.Len(rootIds, 0) + + // Generate roots + err = RotateRoots(ctx, workerAuthRepo, nodeenrollment.WithCertificateLifetime(time.Second*5)) + require.NoError(err) + + // Check that we have roots now + rootIds, err = workerAuthRepo.List(ctx, (*types.RootCertificate)(nil)) + require.NoError(err) + assert.Len(rootIds, 2) + certAuthority := &types.RootCertificates{Id: ca_id} + err = workerAuthRepo.Load(ctx, certAuthority) + require.NoError(err) + require.NotNil(certAuthority.GetNext()) + require.NotNil(certAuthority.GetCurrent()) + + initialNext := certAuthority.GetNext() + initialCurrent := certAuthority.GetCurrent() + + // Rotate roots and assert that they've rotated + err = RotateRoots(ctx, workerAuthRepo, nodeenrollment.WithCertificateLifetime(time.Second*5)) + require.NoError(err) + + certAuthority2 := &types.RootCertificates{Id: ca_id} + err = workerAuthRepo.Load(ctx, certAuthority2) + require.NoError(err) + require.NotNil(certAuthority2.GetNext()) + require.NotNil(certAuthority2.GetCurrent()) + rotatedNext := certAuthority2.GetNext() + rotatedCurrent := certAuthority2.GetCurrent() + + // Next and current should have changed + assert.NotEqual(initialNext, rotatedNext) + assert.NotEqual(initialCurrent, rotatedCurrent) + + // And the old next root should now be current + assert.Equal(initialNext, rotatedCurrent) +} + +func TestRotateRootsFailure(t *testing.T) { + require := require.New(t) + ctx := context.Background() + wrapper := db.TestWrapper(t) + conn, _ := db.TestSetup(t, "postgres") + + kmsCache := kms.TestKms(t, conn, wrapper) + err := kmsCache.CreateKeys(context.Background(), scope.Global.String(), kms.WithRandomReader(rand.Reader)) + require.NoError(err) + + workerAuthRepo, err := NewRepositoryStorage(ctx, &db.Db{}, &db.Db{}, kmsCache) + require.NoError(err) + + err = RotateRoots(ctx, workerAuthRepo) + require.Error(err) +}