From 357c0484fc5acecfd97a14f3fcdbed1836b4d949 Mon Sep 17 00:00:00 2001 From: Martin Angers Date: Tue, 24 Jan 2023 09:23:58 -0500 Subject: [PATCH] orbit: run the `profiles` command to renew the enrollment profile when signaled by fleet (#9409) --- .../issue-9278-orbit-renew-enrollment-profile | 1 + orbit/cmd/orbit/orbit.go | 13 +- orbit/pkg/update/notifications.go | 69 ++++++++++ orbit/pkg/update/notifications_darwin.go | 21 +++ orbit/pkg/update/notifications_stub.go | 7 + orbit/pkg/update/notifications_test.go | 123 ++++++++++++++++++ 6 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 orbit/changes/issue-9278-orbit-renew-enrollment-profile create mode 100644 orbit/pkg/update/notifications.go create mode 100644 orbit/pkg/update/notifications_darwin.go create mode 100644 orbit/pkg/update/notifications_stub.go create mode 100644 orbit/pkg/update/notifications_test.go diff --git a/orbit/changes/issue-9278-orbit-renew-enrollment-profile b/orbit/changes/issue-9278-orbit-renew-enrollment-profile new file mode 100644 index 0000000000..931c9778cf --- /dev/null +++ b/orbit/changes/issue-9278-orbit-renew-enrollment-profile @@ -0,0 +1 @@ +* Added support to `fleetd` to run the necessary command to renew the MDM enrollment profile on the devices that are pending automatic enrollment into Fleet MDM. diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index 60320432c4..2c3df7b632 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -505,8 +505,17 @@ func main() { if err != nil { return fmt.Errorf("error new orbit client: %w", err) } + + // create the notifications middleware that wraps the orbit client + // (must be shared by all runners that use a ConfigFetcher). + const renewEnrollmentProfileCommandFrequency = time.Hour + configFetcher := &update.RenewEnrollmentProfileConfigFetcher{ + Fetcher: orbitClient, + Frequency: renewEnrollmentProfileCommandFrequency, + } + const orbitFlagsUpdateInterval = 30 * time.Second - flagRunner := update.NewFlagRunner(orbitClient, update.FlagUpdateOptions{ + flagRunner := update.NewFlagRunner(configFetcher, update.FlagUpdateOptions{ CheckInterval: orbitFlagsUpdateInterval, RootDir: c.String("root-dir"), }) @@ -523,7 +532,7 @@ func main() { // and all relevant things for it (like certs, enroll secrets, tls proxy, etc) is configured if !c.Bool("disable-updates") || c.Bool("dev-mode") { const orbitExtensionUpdateInterval = 60 * time.Second - extRunner := update.NewExtensionConfigUpdateRunner(orbitClient, update.ExtensionUpdateOptions{ + extRunner := update.NewExtensionConfigUpdateRunner(configFetcher, update.ExtensionUpdateOptions{ CheckInterval: orbitExtensionUpdateInterval, RootDir: c.String("root-dir"), }, updateRunner) diff --git a/orbit/pkg/update/notifications.go b/orbit/pkg/update/notifications.go new file mode 100644 index 0000000000..eef890ea4a --- /dev/null +++ b/orbit/pkg/update/notifications.go @@ -0,0 +1,69 @@ +package update + +import ( + "sync" + "time" + + "github.com/fleetdm/fleet/v4/server/service" + "github.com/rs/zerolog/log" +) + +type runCmdFunc func() error + +// RenewEnrollmentProfileConfigFetcher is a kind of middleware that wraps an +// OrbitConfigFetcher and detects if the fleet server sent a notification to +// renew the enrollment profile. If so, it runs the command (as root) to +// bootstrap the renewal of the profile on the device (the user still needs to +// execute some manual steps to accept the new profile). +// +// It ensures only one renewal command is executed at any given time, and that +// it doesn't re-execute the command until a certain amount of time has passed. +type RenewEnrollmentProfileConfigFetcher struct { + // Fetcher is the OrbitConfigFetcher that will be wrapped. It is responsible + // for actually returning the orbit configuration or an error. + Fetcher OrbitConfigFetcher + // Frequency is the minimum amount of time that must pass between two + // executions of the profile renewal command. + Frequency time.Duration + + // for tests, to be able to mock command execution. If nil, will use + // runRenewEnrollmentProfile. + runCmdFn runCmdFunc + + // ensures only one command runs at a time, protects access to lastRun + cmdMu sync.Mutex + lastRun time.Time +} + +// GetConfig calls the wrapped Fetcher's GetConfig method, and if the fleet +// server set the renew enrollment profile flag to true, executes the command +// to renew the enrollment profile. +func (h *RenewEnrollmentProfileConfigFetcher) GetConfig() (*service.OrbitConfig, error) { + cfg, err := h.Fetcher.GetConfig() + if err == nil && cfg.Notifications.RenewEnrollmentProfile { + if h.cmdMu.TryLock() { + defer h.cmdMu.Unlock() + + // Note that the macOS notification popup will be shown periodically + // until the Fleet server gets notified that the device is now properly + // enrolled (after the user's manual steps, and osquery reporting the + // updated mdm enrollment). + // See https://github.com/fleetdm/fleet/pull/9409#discussion_r1084382455 + if time.Since(h.lastRun) > h.Frequency { + fn := h.runCmdFn + if fn == nil { + fn = runRenewEnrollmentProfile + } + if err := fn(); err != nil { + log.Info().Err(err).Msg("calling /usr/bin/profiles to renew enrollment profile failed") + } else { + h.lastRun = time.Now() + log.Info().Msg("successfully called /usr/bin/profiles to renew enrollment profile") + } + } else { + log.Debug().Msg("skipped calling /usr/bin/profiles to renew enrollment profile, last run was too recent") + } + } + } + return cfg, err +} diff --git a/orbit/pkg/update/notifications_darwin.go b/orbit/pkg/update/notifications_darwin.go new file mode 100644 index 0000000000..4c6467c789 --- /dev/null +++ b/orbit/pkg/update/notifications_darwin.go @@ -0,0 +1,21 @@ +//go:build darwin + +package update + +import ( + "fmt" + "os/exec" +) + +func runRenewEnrollmentProfile() error { + cmd := exec.Command("/usr/bin/profiles", "renew", "--type", "enrollment") + out, err := cmd.CombinedOutput() + if err != nil && len(out) > 0 { + // just as a precaution, limit the length of the output + if len(out) > 512 { + out = out[:512] + } + err = fmt.Errorf("%w: %s", err, string(out)) + } + return err +} diff --git a/orbit/pkg/update/notifications_stub.go b/orbit/pkg/update/notifications_stub.go new file mode 100644 index 0000000000..405f7d76cf --- /dev/null +++ b/orbit/pkg/update/notifications_stub.go @@ -0,0 +1,7 @@ +//go:build !darwin + +package update + +func runRenewEnrollmentProfile() error { + return nil +} diff --git a/orbit/pkg/update/notifications_test.go b/orbit/pkg/update/notifications_test.go new file mode 100644 index 0000000000..7ee85fd387 --- /dev/null +++ b/orbit/pkg/update/notifications_test.go @@ -0,0 +1,123 @@ +package update + +import ( + "bytes" + "io" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/service" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/require" +) + +func TestRenewEnrollmentProfile(t *testing.T) { + var logBuf bytes.Buffer + + oldLog := log.Logger + log.Logger = log.Output(&logBuf) + t.Cleanup(func() { log.Logger = oldLog }) + + cases := []struct { + desc string + renewFlag bool + cmdErr error + wantCmdCalled bool + wantLog string + }{ + {"renew=false", false, nil, false, ""}, + {"renew=true; success", true, nil, true, "successfully called /usr/bin/profiles to renew enrollment profile"}, + {"renew=true; fail", true, io.ErrUnexpectedEOF, true, "calling /usr/bin/profiles to renew enrollment profile failed"}, + } + + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + logBuf.Reset() + + fetcher := &dummyConfigFetcher{ + cfg: &service.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{RenewEnrollmentProfile: c.renewFlag}}, + } + + var cmdGotCalled bool + renewFetcher := &RenewEnrollmentProfileConfigFetcher{ + Fetcher: fetcher, + Frequency: time.Hour, // doesn't matter for this test + runCmdFn: func() error { + cmdGotCalled = true + return c.cmdErr + }, + } + + cfg, err := renewFetcher.GetConfig() + require.NoError(t, err) // the dummy fetcher never returns an error + require.Equal(t, fetcher.cfg, cfg) // the renew enrollment wrapper properly returns the expected config + + require.Equal(t, c.wantCmdCalled, cmdGotCalled) + require.Contains(t, logBuf.String(), c.wantLog) + }) + } +} + +func TestRenewEnrollmentProfilePrevented(t *testing.T) { + var logBuf bytes.Buffer + + oldLog := log.Logger + log.Logger = log.Output(&logBuf) + t.Cleanup(func() { log.Logger = oldLog }) + + fetcher := &dummyConfigFetcher{ + cfg: &service.OrbitConfig{Notifications: fleet.OrbitConfigNotifications{RenewEnrollmentProfile: true}}, + } + + var cmdCallCount int + chProceed := make(chan struct{}) + renewFetcher := &RenewEnrollmentProfileConfigFetcher{ + Fetcher: fetcher, + Frequency: 2 * time.Second, // just to be safe with slow environments (CI) + runCmdFn: func() error { + <-chProceed // will be unblocked only when allowed + cmdCallCount++ // no need for sync, single-threaded call of this func is guaranteed by the fetcher's mutex + return nil + }, + } + + assertResult := func(cfg *service.OrbitConfig, err error) { + require.NoError(t, err) + require.Equal(t, fetcher.cfg, cfg) + } + + started := make(chan struct{}) + go func() { + close(started) + + // the first call will block in runCmdFn + cfg, err := renewFetcher.GetConfig() + assertResult(cfg, err) + }() + + <-started + // this call will happen while the first call is blocked in runCmdFn, so it + // won't call the command (won't be able to lock the mutex). However it will + // still complete successfully without being blocked by the other call in + // progress. + cfg, err := renewFetcher.GetConfig() + assertResult(cfg, err) + + // unblock the first call + close(chProceed) + + // this next call won't execute the command because of the frequency + // restriction (it got called less than N seconds ago) + cfg, err = renewFetcher.GetConfig() + assertResult(cfg, err) + + // wait for the fetcher's frequency to pass + time.Sleep(renewFetcher.Frequency) + + // this call executes the command + cfg, err = renewFetcher.GetConfig() + assertResult(cfg, err) + + require.Equal(t, 2, cmdCallCount) // the initial call and the one after sleep +}