orbit: run the profiles command to renew the enrollment profile when signaled by fleet (#9409)

This commit is contained in:
Martin Angers 2023-01-24 09:23:58 -05:00 committed by GitHub
parent 1b4e8e692a
commit 357c0484fc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 232 additions and 2 deletions

View file

@ -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.

View file

@ -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)

View file

@ -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
}

View file

@ -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
}

View file

@ -0,0 +1,7 @@
//go:build !darwin
package update
func runRenewEnrollmentProfile() error {
return nil
}

View file

@ -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
}