Files
Victor Lyuboslavsky 05bc7be89e Add Windows managed local admin account support to fleetd (#50088)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #48723 

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [x] Timeouts are implemented and retries are limited to avoid infinite
loops

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

## fleetd/orbit/Fleet Desktop

- [x] Verified compatibility with the latest released version of Fleet
(see [Must
rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md))
- [x] If the change applies to only one platform, confirmed that
`runtime.GOOS` is used as needed to isolate changes
- [x] Verified that fleetd runs on macOS, Linux and Windows (did not do
macOS, but should be the same as Linux)
- [x] Verified auto-update works from the released version of component
to the new version (see [tools/tuf/test](../tools/tuf/test/README.md))


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for Fleet-managed local administrator accounts on
Windows.
* When enabled, creates or updates a hidden `_fleetadmin` account,
securely generates a password, and escrows it to Fleet.
* Reports provisioning errors and supports safe retries without blocking
other configuration updates.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 11:15:20 -05:00

135 lines
5.9 KiB
Go

// Package managedaccount creates and maintains the Fleet-managed local admin account on Windows hosts, and escrows its
// password to the Fleet server.
//
// The server asks for the account by setting the CreateWindowsManagedLocalAccount notification on the orbit config
// response, and stops asking once this host escrows a password for its current MDM enrollment. Every step is idempotent,
// so being asked again is always safe.
package managedaccount
import (
"sync"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/rs/zerolog/log"
)
// Escrower sends the managed local account password to the Fleet server.
type Escrower interface {
SendManagedLocalAccountPassword(password, clientError string) error
}
// provisionFunc creates or updates the managed local admin account and hides it from the sign-in screen.
type provisionFunc func(username, password string) error
// Receiver reacts to the CreateWindowsManagedLocalAccount notification.
type Receiver struct {
escrower Escrower
// provision is indirected so tests can exercise the flow without touching Windows APIs. nil means
// use the platform implementation.
provision provisionFunc
// retryFrequency is the minimum time between attempts after a failure. The success path needs no throttle because
// the server stops sending the notification once a password is escrowed, so this only ever paces a host that fails.
retryFrequency time.Duration
// mu keeps a single provisioning attempt in flight. Held for the duration of the background
// goroutine, so a notification that arrives again while work is running is dropped rather than
// starting a second account reset. It also guards lastFailure.
mu sync.Mutex
// lastFailure is when the most recent attempt failed, zero after a success.
lastFailure time.Time
}
// New returns a Receiver that escrows through the given Escrower, retrying at most once every
// retryFrequency after a failure.
func New(escrower Escrower, retryFrequency time.Duration) *Receiver {
return &Receiver{escrower: escrower, retryFrequency: retryFrequency}
}
// Run implements fleet.OrbitConfigReceiver. It returns immediately; provisioning happens in the
// background so a slow Windows API call or HTTP request never gates the config receiver loop.
func (r *Receiver) Run(cfg *fleet.OrbitConfig) error {
if cfg == nil || !cfg.Notifications.CreateWindowsManagedLocalAccount {
return nil
}
r.attempt()
return nil
}
// attempt starts provisioning in the background. The returned channel is closed once the attempt has
// finished and released the single-flight lock, or nil when another attempt was already running.
// Run discards it; it exists so callers that need to know an attempt is fully done, notably tests,
// observe a point where the lock is guaranteed free rather than one merely inside the work.
func (r *Receiver) attempt() <-chan struct{} {
// TryLock rather than Lock: if an attempt is already running, drop this one instead of queueing a second account
// reset behind it. The server keeps asking until an escrow succeeds, so nothing is lost by skipping.
if !r.mu.TryLock() {
log.Debug().Msg("managed local account: provisioning already in progress, skipping")
return nil
}
// The server re-sends the notification on every config fetch, so without this a host that cannot
// provision would redo the syscalls and re-post its error every 30 seconds, indefinitely.
if !r.lastFailure.IsZero() && time.Since(r.lastFailure) <= r.retryFrequency {
log.Debug().Msg("managed local account: last attempt failed too recently, skipping")
r.mu.Unlock()
return nil
}
done := make(chan struct{})
go func() {
// Deferred LIFO, so the mutex is released first, then the panic is contained, then completion is signaled.
defer close(done)
defer func() {
// A panic in a goroutine takes down the whole process, and this one drives raw Windows syscalls.
// Provisioning a local account must not be able to kill orbit/osquery. The next poll retries.
if p := recover(); p != nil {
log.Error().Interface("panic", p).Msg("managed local account: recovered from panic while provisioning")
}
}()
defer r.mu.Unlock()
// Assume failure, so an early return or a panic still paces the next attempt; cleared on success.
// Both writes happen while the lock is held.
r.lastFailure = time.Now()
if r.createAndEscrow() {
r.lastFailure = time.Time{} // clear time
}
}()
return done
}
// createAndEscrow generates a password, provisions the account, and escrows the password. Any failure before the escrow
// returns without recording success, so the next config fetch retries the whole flow; the provisioning step resets the
// password of an existing account, which is what makes that retry safe.
// It reports whether the password was successfully escrowed.
func (r *Receiver) createAndEscrow() bool {
password := fleet.GenerateManagedLocalAccountPassword(true)
provision := r.provision
if provision == nil {
provision = provisionAccount
}
if err := provision(fleet.ManagedLocalAccountUsername, password); err != nil {
log.Error().Err(err).Msg("managed local account: creating account")
// Tell the server why, so it surfaces on the host instead of only in this log. The server
// records the failure and keeps asking, so this is a report, not a terminal state.
if escrowErr := r.escrower.SendManagedLocalAccountPassword("", err.Error()); escrowErr != nil {
log.Error().Err(escrowErr).Msg("managed local account: reporting creation failure")
}
return false
}
if err := r.escrower.SendManagedLocalAccountPassword(password, ""); err != nil {
// The account now exists with a password Fleet does not know. That is recovered by the next
// notification: provisioning resets the password and escrows the new one.
log.Error().Err(err).Msg("managed local account: escrowing password")
return false
}
log.Info().Msg("managed local account: account created; password escrowed")
return true
}