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 -->
This commit is contained in:
@@ -910,6 +910,21 @@ func (oc *OrbitClient) SendLinuxKeyEscrowResponse(lr luks.LuksResponse) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendManagedLocalAccountPassword escrows the password of the managed local admin account that fleetd created on this
|
||||
// Windows host. A non-empty clientError reports that creating the account failed, which the server records against the
|
||||
// host and which makes it ask this host to try again.
|
||||
func (oc *OrbitClient) SendManagedLocalAccountPassword(password, clientError string) error {
|
||||
verb, path := "POST", "/api/fleet/orbit/managed_local_account"
|
||||
var resp fleet.OrbitPostManagedLocalAccountResponse
|
||||
if err := oc.authenticatedRequest(verb, path, &fleet.OrbitPostManagedLocalAccountRequest{
|
||||
Password: password,
|
||||
ClientError: clientError,
|
||||
}, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (oc *OrbitClient) InitiateSetupExperience() (fleet.SetupExperienceInitResult, error) {
|
||||
verb, path := "POST", "/api/fleet/orbit/setup_experience/init"
|
||||
var resp fleet.OrbitSetupExperienceInitResponse
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- Added Windows support for the Fleet-managed local admin account: when the setting is enabled for the host's fleet, fleetd creates the hidden `_fleetadmin` administrator account, keeps it off the sign-in screen, and escrows its password to Fleet.
|
||||
@@ -43,6 +43,7 @@ import (
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/keystore"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/logging"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/luks"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/managedaccount"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/osquery"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/osservice"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/platform"
|
||||
@@ -1235,6 +1236,9 @@ func orbitAction(c *cli.Context) error {
|
||||
// windowsMDMSyncCommandFrequency throttles on-demand OMA-DM syncs: while a command stays queued the server keeps setting
|
||||
// WindowsMDMSyncRequest on each config poll, and this bounds how often we act on it.
|
||||
windowsMDMSyncCommandFrequency = time.Minute
|
||||
// windowsManagedAccountRetryFrequency paces retries when the managed local account cannot be
|
||||
// provisioned, for instance because the host's password policy rejects the generated password.
|
||||
windowsManagedAccountRetryFrequency = time.Hour
|
||||
)
|
||||
|
||||
scriptConfigReceiver, scriptsEnabledFn := update.ApplyRunScriptsConfigFetcherMiddleware(
|
||||
@@ -1314,6 +1318,7 @@ func orbitAction(c *cli.Context) error {
|
||||
defer comWorker.Close()
|
||||
orbitClient.RegisterConfigReceiver(update.ApplyWindowsMDMBitlockerFetcherMiddleware(
|
||||
windowsMDMBitlockerCommandFrequency, orbitClient, comWorker))
|
||||
orbitClient.RegisterConfigReceiver(managedaccount.New(orbitClient, windowsManagedAccountRetryFrequency))
|
||||
case "linux":
|
||||
orbitClient.RegisterConfigReceiver(luks.New(orbitClient))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !windows
|
||||
|
||||
package managedaccount
|
||||
|
||||
import "errors"
|
||||
|
||||
// provisionAccount is a placeholder for non-Windows builds.
|
||||
func provisionAccount(username, password string) error {
|
||||
return errors.New("managed local account provisioning is only supported on Windows")
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package managedaccount
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type mockEscrower struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
password string
|
||||
clientError string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockEscrower) SendManagedLocalAccountPassword(password, clientError string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.calls++
|
||||
m.password = password
|
||||
m.clientError = clientError
|
||||
return m.err
|
||||
}
|
||||
|
||||
func (m *mockEscrower) snapshot() (calls int, password, clientError string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.calls, m.password, m.clientError
|
||||
}
|
||||
|
||||
// newTestReceiver returns a receiver whose provisioning is stubbed. Tests wait on the channel
|
||||
// attempt() returns, which closes only after the single-flight lock is released.
|
||||
func newTestReceiver(escrower Escrower, provision provisionFunc) *Receiver {
|
||||
return &Receiver{escrower: escrower, provision: provision}
|
||||
}
|
||||
|
||||
// awaitAttempt starts an attempt and waits for it to finish, failing rather than hanging if the
|
||||
// attempt was dropped or never completes.
|
||||
func awaitAttempt(t *testing.T, r *Receiver) {
|
||||
t.Helper()
|
||||
done := r.attempt()
|
||||
require.NotNil(t, done, "attempt was dropped")
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("provisioning attempt did not finish")
|
||||
}
|
||||
}
|
||||
|
||||
func notification(enabled bool) *fleet.OrbitConfig {
|
||||
return &fleet.OrbitConfig{
|
||||
Notifications: fleet.OrbitConfigNotifications{CreateWindowsManagedLocalAccount: enabled},
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiverRun(t *testing.T) {
|
||||
// Run must gate on the notification, and must start provisioning when it is set. Everything below
|
||||
// exercises attempt() directly so it can wait on completion.
|
||||
t.Run("Run gates on the notification", func(t *testing.T) {
|
||||
esc := &mockEscrower{}
|
||||
provisioned := make(chan struct{})
|
||||
r := newTestReceiver(esc, func(string, string) error {
|
||||
close(provisioned)
|
||||
return nil
|
||||
})
|
||||
|
||||
// A receiver loop that hands over no config at all must not take the process down with it.
|
||||
require.NoError(t, r.Run(nil))
|
||||
require.NoError(t, r.Run(notification(false)))
|
||||
select {
|
||||
case <-provisioned:
|
||||
t.Fatal("provisioning ran without the notification")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
|
||||
require.NoError(t, r.Run(notification(true)))
|
||||
select {
|
||||
case <-provisioned:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the notification did not start provisioning")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("provisions and escrows the password it generated", func(t *testing.T) {
|
||||
esc := &mockEscrower{}
|
||||
var gotUser, gotPassword string
|
||||
r := newTestReceiver(esc, func(username, password string) error {
|
||||
gotUser, gotPassword = username, password
|
||||
return nil
|
||||
})
|
||||
|
||||
awaitAttempt(t, r)
|
||||
|
||||
assert.Equal(t, fleet.ManagedLocalAccountUsername, gotUser)
|
||||
calls, escrowed, clientError := esc.snapshot()
|
||||
assert.Equal(t, 1, calls)
|
||||
assert.Empty(t, clientError)
|
||||
assert.NotEmpty(t, gotPassword)
|
||||
assert.Equal(t, gotPassword, escrowed)
|
||||
})
|
||||
|
||||
// A creation failure is reported rather than swallowed, so it surfaces on the host and the server
|
||||
// keeps asking. No password is escrowed, because none was successfully set.
|
||||
t.Run("reports a provisioning failure as a client error", func(t *testing.T) {
|
||||
esc := &mockEscrower{}
|
||||
r := newTestReceiver(esc, func(string, string) error {
|
||||
return errors.New("NetUserAdd failed: access denied")
|
||||
})
|
||||
|
||||
awaitAttempt(t, r)
|
||||
|
||||
calls, password, clientError := esc.snapshot()
|
||||
assert.Equal(t, 1, calls)
|
||||
assert.Empty(t, password)
|
||||
assert.Contains(t, clientError, "NetUserAdd failed: access denied")
|
||||
})
|
||||
|
||||
// The account exists with a password Fleet does not know. Nothing local records success, so the flow
|
||||
// re-runs and resets the password once the retry window has passed. A failed escrow arms the same
|
||||
// throttle as a failed creation, since neither got a password safely to the server.
|
||||
t.Run("a failed escrow leaves nothing that would block a retry", func(t *testing.T) {
|
||||
esc := &mockEscrower{err: errors.New("server unavailable")}
|
||||
var provisions int
|
||||
provision := func(string, string) error {
|
||||
provisions++
|
||||
return nil
|
||||
}
|
||||
|
||||
r := newTestReceiver(esc, provision)
|
||||
// No throttle, so pacing cannot mask the retry this is looking for.
|
||||
r.retryFrequency = 0
|
||||
awaitAttempt(t, r)
|
||||
awaitAttempt(t, r)
|
||||
|
||||
assert.Equal(t, 2, provisions, "the second notification must re-run provisioning")
|
||||
})
|
||||
|
||||
t.Run("a panic while provisioning does not escape the goroutine", func(t *testing.T) {
|
||||
esc := &mockEscrower{}
|
||||
r := newTestReceiver(esc, func(string, string) error {
|
||||
panic("simulated syscall failure")
|
||||
})
|
||||
|
||||
awaitAttempt(t, r)
|
||||
|
||||
calls, _, _ := esc.snapshot()
|
||||
assert.Zero(t, calls, "nothing should be escrowed when provisioning panics")
|
||||
|
||||
// The lock was released, so a later notification is still acted on.
|
||||
var provisioned bool
|
||||
r.provision = func(string, string) error {
|
||||
provisioned = true
|
||||
return nil
|
||||
}
|
||||
awaitAttempt(t, r)
|
||||
assert.True(t, provisioned, "a panic must not wedge the single-flight lock")
|
||||
})
|
||||
|
||||
// The server re-sends the notification every config fetch, so a host that cannot provision would
|
||||
// otherwise redo the syscalls and re-post its error every 30 seconds forever.
|
||||
t.Run("a failed attempt is not retried until the retry frequency elapses", func(t *testing.T) {
|
||||
esc := &mockEscrower{}
|
||||
var provisions int
|
||||
r := newTestReceiver(esc, func(string, string) error {
|
||||
provisions++
|
||||
return errors.New("policy rejected the password")
|
||||
})
|
||||
r.retryFrequency = time.Hour
|
||||
|
||||
awaitAttempt(t, r)
|
||||
assert.Equal(t, 1, provisions)
|
||||
|
||||
// A notification arriving right after the failure is dropped rather than redoing the work.
|
||||
assert.Nil(t, r.attempt(), "a retry inside the frequency window must be dropped")
|
||||
assert.Equal(t, 1, provisions)
|
||||
|
||||
// Once the window has passed, the host tries again.
|
||||
r.lastFailure = time.Now().Add(-2 * time.Hour)
|
||||
awaitAttempt(t, r)
|
||||
assert.Equal(t, 2, provisions)
|
||||
})
|
||||
|
||||
t.Run("a success clears the retry throttle", func(t *testing.T) {
|
||||
r := newTestReceiver(&mockEscrower{}, func(string, string) error { return nil })
|
||||
r.retryFrequency = time.Hour
|
||||
|
||||
awaitAttempt(t, r)
|
||||
// Make sure the 2nd back-to-back attempt is not dropped after a success.
|
||||
done := r.attempt()
|
||||
require.NotNil(t, done, "a success must not arm the retry throttle")
|
||||
<-done
|
||||
})
|
||||
|
||||
t.Run("only one attempt runs at a time", func(t *testing.T) {
|
||||
esc := &mockEscrower{}
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
|
||||
r := newTestReceiver(esc, func(string, string) error {
|
||||
close(started)
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
|
||||
done := r.attempt()
|
||||
require.NotNil(t, done)
|
||||
<-started
|
||||
|
||||
// A second attempt while the first is in flight is dropped, not queued.
|
||||
assert.Nil(t, r.attempt(), "a concurrent attempt must be dropped")
|
||||
|
||||
close(release)
|
||||
<-done
|
||||
|
||||
calls, _, _ := esc.snapshot()
|
||||
assert.Equal(t, 1, calls, "the dropped attempt must not escrow a second password")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
//go:build windows
|
||||
|
||||
package managedaccount
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"unsafe"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
// Windows account flags (lmaccess.h). UF_DONT_EXPIRE_PASSWD keeps the account usable as a
|
||||
// break-glass login: Fleet owns the password lifecycle, so Windows must not expire it underneath us.
|
||||
const (
|
||||
ufScript = 0x0001
|
||||
ufNormalAccount = 0x0200
|
||||
ufDontExpirePasswd = 0x10000
|
||||
ufAccountDisable = 0x0002 // UF_ACCOUNTDISABLE: the account exists but cannot log in.
|
||||
ufLockout = 0x0010 // UF_LOCKOUT: locked out by failed logons. Can be cleared, not set.
|
||||
|
||||
usePrivUser = 1 // USER_PRIV_USER: a plain user; group membership grants admin rights.
|
||||
|
||||
nerrUserNotFound = 2221 // NERR_UserNotFound
|
||||
errorMemberInAlias = 1378 // ERROR_MEMBER_IN_ALIAS: already a group member.
|
||||
userInfoPasswordOnly = 1003 // USER_INFO_1003: password-only update level.
|
||||
userInfoFlagsOnly = 1008 // USER_INFO_1008: flags-only update level.
|
||||
|
||||
// NERR_PasswordTooShort is the catch-all Windows returns for any password-policy rejection, not just length: MSDN
|
||||
// lists it for "too long, too recent in its change history, not enough unique characters, or does not meet another
|
||||
// password policy requirement", which includes a custom password filter DLL.
|
||||
nerrPasswordTooShort = 2245
|
||||
// ERROR_PASSWORD_RESTRICTION, the equivalent from the system error range.
|
||||
errorPasswordRestriction = 1325
|
||||
)
|
||||
|
||||
// logonUIHiddenAccountsKey holds a DWORD per account name; 0 hides the account from the sign-in
|
||||
// screen and from Settings > Accounts. There is no MDM CSP for this, which is why fleetd does it.
|
||||
const logonUIHiddenAccountsKey = `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList`
|
||||
|
||||
// fleetAccountComment is written as the account's description at creation: it is how a later run tells an account Fleet
|
||||
// created from an unrelated account that happens to share the name. Do not change this string.
|
||||
const fleetAccountComment = "Fleet-managed local administrator account."
|
||||
|
||||
var (
|
||||
netapi32 = windows.NewLazySystemDLL("netapi32.dll")
|
||||
procNetUserAdd = netapi32.NewProc("NetUserAdd")
|
||||
procNetUserGetInfo = netapi32.NewProc("NetUserGetInfo")
|
||||
procNetUserSetInfo = netapi32.NewProc("NetUserSetInfo")
|
||||
procNetLocalGroupAddMbrs = netapi32.NewProc("NetLocalGroupAddMembers")
|
||||
procNetAPIBufferFree = netapi32.NewProc("NetApiBufferFree")
|
||||
)
|
||||
|
||||
// userInfo1 mirrors USER_INFO_1 (lmaccess.h), the level-1 structure NetUserAdd takes.
|
||||
type userInfo1 struct {
|
||||
Name *uint16
|
||||
Password *uint16
|
||||
PasswordAge uint32
|
||||
Priv uint32
|
||||
HomeDir *uint16
|
||||
Comment *uint16
|
||||
Flags uint32
|
||||
ScriptPath *uint16
|
||||
}
|
||||
|
||||
// userInfo1003 mirrors USER_INFO_1003, which sets only the password.
|
||||
type userInfo1003 struct {
|
||||
Password *uint16
|
||||
}
|
||||
|
||||
// userInfo1008 mirrors USER_INFO_1008, which sets only the account flags.
|
||||
type userInfo1008 struct {
|
||||
Flags uint32
|
||||
}
|
||||
|
||||
// localGroupMembersInfo3 mirrors LOCALGROUP_MEMBERS_INFO_3, which identifies a member by name rather than SID.
|
||||
type localGroupMembersInfo3 struct {
|
||||
DomainAndName *uint16
|
||||
}
|
||||
|
||||
// provisionAccount creates the managed local admin account if it is missing, resets its password if it already exists,
|
||||
// ensures it is a member of the local Administrators group, and hides it from the sign-in screen. Every step is
|
||||
// idempotent so the whole function is safe to re-run, which is what makes retrying after a failed escrow safe.
|
||||
func provisionAccount(username, password string) error {
|
||||
if err := ensureUser(username, password); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addToAdministrators(username); err != nil {
|
||||
return err
|
||||
}
|
||||
return hideFromSignInScreen(username)
|
||||
}
|
||||
|
||||
// ensureUser creates the account, or resets its password when it already exists. The reset branch is what lets a retry
|
||||
// recover an account whose password Fleet never successfully escrowed.
|
||||
func ensureUser(username, password string) error {
|
||||
namePtr, err := windows.UTF16PtrFromString(username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("converting username: %w", err)
|
||||
}
|
||||
passwordPtr, err := windows.UTF16PtrFromString(password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("converting password: %w", err)
|
||||
}
|
||||
|
||||
existing, err := lookupUser(namePtr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
// Only adopt an account Fleet created. Anything else with this name belongs to someone else, and resetting its
|
||||
// password and elevating it would be destructive; report it instead so it surfaces on the host rather than
|
||||
// silently changing an account we do not own.
|
||||
if existing.comment != fleetAccountComment {
|
||||
return fmt.Errorf(
|
||||
"an account named %s already exists and was not created by Fleet, refusing to take it over", username)
|
||||
}
|
||||
|
||||
info := userInfo1003{Password: passwordPtr}
|
||||
ret, _, _ := procNetUserSetInfo.Call(
|
||||
0, // servername: NULL means the local machine
|
||||
uintptr(unsafe.Pointer(namePtr)),
|
||||
userInfoPasswordOnly,
|
||||
uintptr(unsafe.Pointer(&info)),
|
||||
0, // parm_err
|
||||
)
|
||||
if ret != 0 {
|
||||
return accountError(fmt.Sprintf("resetting password for %s", username), ret, len(password))
|
||||
}
|
||||
// Resetting the password is not enough to make the account usable again. If it was disabled, locked out, or had
|
||||
// its never-expire flag removed after we created it, Fleet would escrow a password that cannot actually log in.
|
||||
// Only the flags we own are touched, so anything else set on the account is preserved.
|
||||
return normalizeUserFlags(namePtr, username, existing.flags)
|
||||
}
|
||||
|
||||
comment, err := windows.UTF16PtrFromString(fleetAccountComment)
|
||||
if err != nil {
|
||||
return fmt.Errorf("converting comment: %w", err)
|
||||
}
|
||||
info := userInfo1{
|
||||
Name: namePtr,
|
||||
Password: passwordPtr,
|
||||
Priv: usePrivUser,
|
||||
Comment: comment,
|
||||
Flags: ufScript | ufNormalAccount | ufDontExpirePasswd,
|
||||
}
|
||||
ret, _, _ := procNetUserAdd.Call(
|
||||
0, // servername
|
||||
1, // level
|
||||
uintptr(unsafe.Pointer(&info)),
|
||||
0, // parm_err
|
||||
)
|
||||
if ret != 0 {
|
||||
return accountError(fmt.Sprintf("creating %s", username), ret, len(password))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// accountError turns a netapi32 return code into an error, spelling out password-policy rejections.
|
||||
// Windows reports every one of those as NERR_PasswordTooShort, whose text lives in netmsg.dll rather
|
||||
// than the system message table, so Go cannot format it and the admin would otherwise see the reason
|
||||
// their break-glass account never appeared as a bare "winapi error #2245" in Fleet.
|
||||
func accountError(op string, ret uintptr, passwordLen int) error {
|
||||
if ret == nerrPasswordTooShort || ret == errorPasswordRestriction {
|
||||
return fmt.Errorf(
|
||||
"%s: this device's password policy rejected the generated %d-character password; "+
|
||||
"check the minimum password length and any custom password filter on the host",
|
||||
op, passwordLen)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", op, windows.Errno(ret))
|
||||
}
|
||||
|
||||
// existingAccount is the subset of USER_INFO_1 the caller needs: the flags say whether a present
|
||||
// account is actually usable, and the comment says whether it is ours to manage.
|
||||
type existingAccount struct {
|
||||
flags uint32
|
||||
comment string
|
||||
}
|
||||
|
||||
// lookupUser returns the account, or nil when no account with that name exists.
|
||||
func lookupUser(namePtr *uint16) (*existingAccount, error) {
|
||||
// buf is a real pointer rather than a uintptr so the garbage collector tracks the buffer netapi32
|
||||
// allocates for us; converting a uintptr back into a pointer is not safe.
|
||||
var buf *byte
|
||||
ret, _, _ := procNetUserGetInfo.Call(
|
||||
0, // servername
|
||||
uintptr(unsafe.Pointer(namePtr)),
|
||||
1, // level 1: USER_INFO_1, which carries Flags
|
||||
uintptr(unsafe.Pointer(&buf)),
|
||||
)
|
||||
switch ret {
|
||||
case 0:
|
||||
if buf == nil {
|
||||
return nil, errors.New("looking up account: NetUserGetInfo returned no data")
|
||||
}
|
||||
//nolint:errcheck // freeing the buffer cannot meaningfully fail here
|
||||
defer procNetAPIBufferFree.Call(uintptr(unsafe.Pointer(buf)))
|
||||
info := (*userInfo1)(unsafe.Pointer(buf))
|
||||
return &existingAccount{
|
||||
flags: info.Flags,
|
||||
comment: windows.UTF16PtrToString(info.Comment),
|
||||
}, nil
|
||||
case nerrUserNotFound:
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("looking up account: %w", windows.Errno(ret))
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeUserFlags re-applies the flags Fleet depends on to an account that already existed:
|
||||
// enabled, not locked out, and password never expires. Other flags are left untouched.
|
||||
func normalizeUserFlags(namePtr *uint16, username string, current uint32) error {
|
||||
desired := (current &^ (ufAccountDisable | ufLockout)) | ufDontExpirePasswd
|
||||
if desired == current {
|
||||
return nil
|
||||
}
|
||||
|
||||
info := userInfo1008{Flags: desired}
|
||||
ret, _, _ := procNetUserSetInfo.Call(
|
||||
0, // servername
|
||||
uintptr(unsafe.Pointer(namePtr)),
|
||||
userInfoFlagsOnly,
|
||||
uintptr(unsafe.Pointer(&info)),
|
||||
0, // parm_err
|
||||
)
|
||||
if ret != 0 {
|
||||
return fmt.Errorf("restoring account flags for %s: %w", username, windows.Errno(ret))
|
||||
}
|
||||
log.Debug().Str("username", username).Uint32("from", current).Uint32("to", desired).
|
||||
Msg("managed local account: restored account flags")
|
||||
return nil
|
||||
}
|
||||
|
||||
// addToAdministrators adds the account to the local Administrators group. The group name is resolved
|
||||
// from its well-known SID rather than hardcoded, because it is localized on non-English Windows.
|
||||
func addToAdministrators(username string) error {
|
||||
groupName, err := administratorsGroupName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupPtr, err := windows.UTF16PtrFromString(groupName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("converting group name: %w", err)
|
||||
}
|
||||
memberPtr, err := windows.UTF16PtrFromString(username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("converting member name: %w", err)
|
||||
}
|
||||
|
||||
member := localGroupMembersInfo3{DomainAndName: memberPtr}
|
||||
ret, _, _ := procNetLocalGroupAddMbrs.Call(
|
||||
0, // servername
|
||||
uintptr(unsafe.Pointer(groupPtr)),
|
||||
3, // level
|
||||
uintptr(unsafe.Pointer(&member)),
|
||||
1, // totalentries
|
||||
)
|
||||
// Already a member is the expected outcome on every run after the first.
|
||||
if ret != 0 && ret != errorMemberInAlias {
|
||||
return fmt.Errorf("adding %s to %s: %w", username, groupName, windows.Errno(ret))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func administratorsGroupName() (string, error) {
|
||||
sid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("building Administrators SID: %w", err)
|
||||
}
|
||||
account, _, _, err := sid.LookupAccount("")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolving Administrators group name: %w", err)
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// hideFromSignInScreen keeps the account off the sign-in screen and out of Settings > Accounts. It
|
||||
// remains fully usable through "Other user" with an explicit username.
|
||||
func hideFromSignInScreen(username string) error {
|
||||
key, _, err := registry.CreateKey(registry.LOCAL_MACHINE, logonUIHiddenAccountsKey, registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening UserList registry key: %w", err)
|
||||
}
|
||||
defer key.Close()
|
||||
|
||||
if err := key.SetDWordValue(username, 0); err != nil {
|
||||
return fmt.Errorf("hiding %s from the sign-in screen: %w", username, err)
|
||||
}
|
||||
log.Debug().Str("username", username).Msg("managed local account: hidden from sign-in screen")
|
||||
return nil
|
||||
}
|
||||
@@ -1509,11 +1509,6 @@ const (
|
||||
SetAutoAdminPasswordCmdName = "SetAutoAdminPassword"
|
||||
)
|
||||
|
||||
// ManagedLocalAccountUsername is the short name Fleet provisions on macOS hosts
|
||||
// via the AccountConfiguration MDM command when the managed local account
|
||||
// feature is enabled.
|
||||
const ManagedLocalAccountUsername = "_fleetadmin"
|
||||
|
||||
// PrimaryAccountType represents the type of the primary account for MacOS going through setup experience.
|
||||
// Documented at https://developer.apple.com/documentation/devicemanagement/accountconfigurationcommand/command-data.dictionary
|
||||
// if `SetPrimarySetupAccountAsRegularUser` or `SkipPrimarySetupAccountCreation` is true, you must configure a local admin account.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ManagedLocalAccountUsername is the short name of the local admin account Fleet provisions when the managed local
|
||||
// account feature is enabled. macOS creates it via the AccountConfiguration MDM command, Windows fleetd creates it directly.
|
||||
const ManagedLocalAccountUsername = "_fleetadmin"
|
||||
|
||||
const (
|
||||
// managedAccountPasswordGroupCount is the number of character groups in a managed account password.
|
||||
managedAccountPasswordGroupCount = 6
|
||||
// managedAccountPasswordGroupLen is the number of characters per group.
|
||||
managedAccountPasswordGroupLen = 4
|
||||
// managedAccountPasswordSeparator joins the groups. Grouping matters because this password is read
|
||||
// off a screen and typed at a login prompt, not pasted.
|
||||
managedAccountPasswordSeparator = "-"
|
||||
)
|
||||
|
||||
// Character classes for the managed account password. These deliberately omit characters that are
|
||||
// easily confused when transcribed by hand: 0/O/o and 1/I/l.
|
||||
const (
|
||||
managedAccountDigits = "23456789"
|
||||
managedAccountUppercase = "ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
managedAccountLowercase = "abcdefghijkmnpqrstuvwxyz"
|
||||
)
|
||||
|
||||
// GenerateManagedLocalAccountPassword returns a cryptographically random password for the managed local admin account,
|
||||
// formatted in hyphen-separated groups so it can be read aloud and typed at a login prompt without error.
|
||||
//
|
||||
// includeLowercase controls whether lowercase letters appear.
|
||||
//
|
||||
// The password is guaranteed to contain at least one character from each enabled class, so the category count never
|
||||
// depends on chance. That guarantee is applied by discarding and redrawing a password that misses a class, rather than
|
||||
// by seeding one character per class and shuffling: seeding skews the result towards balanced class counts, while
|
||||
// redrawing stays exactly uniform over the passwords that satisfy the guarantee. Roughly one draw in 40 is discarded.
|
||||
func GenerateManagedLocalAccountPassword(includeLowercase bool) string {
|
||||
classes := []string{managedAccountDigits, managedAccountUppercase}
|
||||
if includeLowercase {
|
||||
classes = append(classes, managedAccountLowercase)
|
||||
}
|
||||
all := strings.Join(classes, "")
|
||||
total := managedAccountPasswordGroupCount * managedAccountPasswordGroupLen
|
||||
|
||||
chars := make([]byte, total)
|
||||
for {
|
||||
for i := range chars {
|
||||
chars[i] = all[randomIndex(len(all))]
|
||||
}
|
||||
if containsEachClass(chars, classes) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
groups := make([]string, 0, managedAccountPasswordGroupCount)
|
||||
for i := 0; i < total; i += managedAccountPasswordGroupLen {
|
||||
groups = append(groups, string(chars[i:i+managedAccountPasswordGroupLen]))
|
||||
}
|
||||
return strings.Join(groups, managedAccountPasswordSeparator)
|
||||
}
|
||||
|
||||
func containsEachClass(chars []byte, classes []string) bool {
|
||||
for _, class := range classes {
|
||||
if !strings.ContainsAny(string(chars), class) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// randomIndex returns a uniformly random value in [0, n), for any n that fits in an int32.
|
||||
//
|
||||
// It rejects draws in the final, short bucket rather than taking a plain modulo, which would bias
|
||||
// the result towards early characters for any alphabet size that does not divide the draw range.
|
||||
func randomIndex(n int) int {
|
||||
const span = int64(1) << 32
|
||||
// limit rounds span down to the largest exact multiple of n, discarding that remainder. Note it is
|
||||
// the size of the draw range, not of the alphabet: for n=56 it is 4294967264, only 32 short of span.
|
||||
limit := span - span%int64(n)
|
||||
var b [4]byte
|
||||
for {
|
||||
// crypto/rand.Read never returns an error; it crashes the program if the system entropy source
|
||||
// fails, so there is no failure mode for a caller to handle.
|
||||
_, _ = rand.Read(b[:])
|
||||
// Read the four bytes as one number uniform over [0, span). The redraw (v < limit) is unlikely since span and
|
||||
// limit are very close together.
|
||||
if v := int64(binary.BigEndian.Uint32(b[:])); v < limit {
|
||||
return int(v % int64(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package fleet
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateManagedLocalAccountPassword(t *testing.T) {
|
||||
t.Parallel()
|
||||
macOSAlphabet := managedAccountDigits + managedAccountUppercase
|
||||
windowsAlphabet := macOSAlphabet + managedAccountLowercase
|
||||
|
||||
const iterations = 50
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
includeLowercase bool
|
||||
alphabet string
|
||||
}{
|
||||
{"macOS, single case", false, macOSAlphabet},
|
||||
{"Windows, with lowercase", true, windowsAlphabet},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
seen := make(map[string]struct{}, iterations)
|
||||
|
||||
for range iterations {
|
||||
password := GenerateManagedLocalAccountPassword(tt.includeLowercase)
|
||||
seen[password] = struct{}{}
|
||||
|
||||
groups := strings.Split(password, managedAccountPasswordSeparator)
|
||||
require.Len(t, groups, managedAccountPasswordGroupCount, "password %q", password)
|
||||
for _, group := range groups {
|
||||
require.Len(t, group, managedAccountPasswordGroupLen, "password %q", password)
|
||||
// Membership in this variant's alphabet is what holds the lowercase switch honest, and
|
||||
// what catches an index that runs past the end of the alphabet.
|
||||
for i := range len(group) {
|
||||
require.Contains(t, tt.alphabet, string(group[i]), "character outside the alphabet in %q", password)
|
||||
}
|
||||
}
|
||||
|
||||
require.True(t, strings.ContainsAny(password, managedAccountDigits), "no digit in %q", password)
|
||||
require.True(t, strings.ContainsAny(password, managedAccountUppercase), "no uppercase in %q", password)
|
||||
if tt.includeLowercase {
|
||||
require.True(t, strings.ContainsAny(password, managedAccountLowercase), "no lowercase in %q", password)
|
||||
}
|
||||
}
|
||||
|
||||
// Repeats do not happen by chance; they mean the generator has stopped being random.
|
||||
assert.Len(t, seen, iterations, "generated a duplicate password")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2298,7 +2298,7 @@ func EnqueueManagedLocalAccountRotation(
|
||||
commander ManagedLocalAccountRotationCommander,
|
||||
hostUUID, accountUUID string,
|
||||
) (cmdUUID string, err error, rollbackErr error) {
|
||||
newPassword := GenerateManagedAccountPassword()
|
||||
newPassword := fleet.GenerateManagedLocalAccountPassword(false)
|
||||
hashPlist, hashErr := GenerateSaltedSHA512PBKDF2Hash(newPassword)
|
||||
if hashErr != nil {
|
||||
return "", hashErr, nil
|
||||
|
||||
@@ -190,10 +190,6 @@ func IsLessThanVersion(current string, target string) (bool, error) {
|
||||
}
|
||||
|
||||
const (
|
||||
// ManagedAccountPasswordGroupCount is the number of character groups in a managed account password.
|
||||
ManagedAccountPasswordGroupCount = 6
|
||||
// ManagedAccountPasswordGroupLen is the number of characters per group.
|
||||
ManagedAccountPasswordGroupLen = 4
|
||||
// pbkdf2Iterations is the number of PBKDF2 iterations for the managed account password hash.
|
||||
pbkdf2Iterations = 40000
|
||||
// pbkdf2KeyLen is the derived key length in bytes (128 bytes as required by Apple).
|
||||
@@ -202,26 +198,6 @@ const (
|
||||
pbkdf2SaltLen = 32
|
||||
)
|
||||
|
||||
// GenerateManagedAccountPassword generates a cryptographically random password
|
||||
// in the same format as recovery lock passwords (e.g., "5ADZ-HTZ8-LJJ4-B2F8-JWH3-YPBT").
|
||||
func GenerateManagedAccountPassword() string {
|
||||
groups := make([]string, ManagedAccountPasswordGroupCount)
|
||||
charsetLen := len(RecoveryLockPasswordCharset)
|
||||
|
||||
for i := range ManagedAccountPasswordGroupCount {
|
||||
randBytes := make([]byte, ManagedAccountPasswordGroupLen)
|
||||
_, _ = rand.Read(randBytes) // rand.Read never returns an error; it panics on failure
|
||||
|
||||
group := make([]byte, ManagedAccountPasswordGroupLen)
|
||||
for j := range ManagedAccountPasswordGroupLen {
|
||||
group[j] = RecoveryLockPasswordCharset[int(randBytes[j])%charsetLen]
|
||||
}
|
||||
groups[i] = string(group)
|
||||
}
|
||||
|
||||
return strings.Join(groups, "-")
|
||||
}
|
||||
|
||||
// saltedSHA512PBKDF2 is the plist structure expected by Apple's AutoSetupAdminAccountItem.passwordHash.
|
||||
type saltedSHA512PBKDF2 struct {
|
||||
PBKDF2 pbkdf2Dict `plist:"SALTED-SHA512-PBKDF2"`
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package apple_mdm
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
@@ -255,24 +254,6 @@ func TestIsRecoveryLockPasswordMismatchError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateManagedAccountPassword(t *testing.T) {
|
||||
pw := GenerateManagedAccountPassword()
|
||||
|
||||
// Format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX (6 groups of 4 chars separated by dashes)
|
||||
groups := strings.Split(pw, "-")
|
||||
require.Len(t, groups, ManagedAccountPasswordGroupCount)
|
||||
for _, g := range groups {
|
||||
require.Len(t, g, ManagedAccountPasswordGroupLen)
|
||||
for _, c := range g {
|
||||
assert.Contains(t, RecoveryLockPasswordCharset, string(c))
|
||||
}
|
||||
}
|
||||
|
||||
// Two calls should produce different passwords (with overwhelming probability).
|
||||
pw2 := GenerateManagedAccountPassword()
|
||||
require.NotEqual(t, pw, pw2)
|
||||
}
|
||||
|
||||
func TestGenerateSaltedSHA512PBKDF2Hash(t *testing.T) {
|
||||
data, err := GenerateSaltedSHA512PBKDF2Hash("test-password")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -268,7 +268,7 @@ func (a *AppleMDM) runPostDEPEnrollment(ctx context.Context, args appleMDMArgs)
|
||||
var password string
|
||||
cmdUUID := uuid.New().String()
|
||||
if managedAdminAccountEnabled {
|
||||
password = apple_mdm.GenerateManagedAccountPassword()
|
||||
password = fleet.GenerateManagedLocalAccountPassword(false)
|
||||
passwordHash, err := apple_mdm.GenerateSaltedSHA512PBKDF2Hash(password)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user