<!-- 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 -->
225 lines
6.8 KiB
Go
225 lines
6.8 KiB
Go
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")
|
|
})
|
|
}
|