159 lines
6.7 KiB
Go
159 lines
6.7 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/android/service/androidmgmt"
|
|
)
|
|
|
|
const (
|
|
// androidReconcileMaxPages bounds the AMAPI device pagination loop so a malformed
|
|
// or cycling NextPageToken can't spin it forever. AMAPI returns 100 devices/page,
|
|
// so this permits up to ~1,000,000 devices — a safety net well above any realistic
|
|
// enterprise size, not a functional limit.
|
|
androidReconcileMaxPages = 10000
|
|
// androidReconcilePageLogInterval controls how often pagination progress is logged
|
|
// so a slow or stuck reconcile can be diagnosed.
|
|
androidReconcilePageLogInterval = 100
|
|
)
|
|
|
|
// ReconcileAndroidDevices polls AMAPI for devices that Fleet still considers enrolled
|
|
// and flips them to unenrolled if Google reports them missing (404).
|
|
// This complements (does not replace) Pub/Sub DELETED handling.
|
|
func ReconcileAndroidDevices(ctx context.Context, ds fleet.Datastore, logger *slog.Logger, licenseKey string, newActivityFn fleet.NewActivityFunc) error {
|
|
appConfig, err := ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "get app config")
|
|
}
|
|
if !appConfig.MDM.AndroidEnabledAndConfigured {
|
|
return nil
|
|
}
|
|
|
|
// Ensure an enterprise exists, otherwise nothing to do, and keep its ID to build device resource names.
|
|
enterprise, err := ds.GetEnterprise(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "get android enterprise")
|
|
}
|
|
|
|
client := newAMAPIClient(ctx, logger, licenseKey)
|
|
|
|
// Best-effort set authentication secret for proxy client usage (no-op for Google client).
|
|
if assets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetAndroidFleetServerSecret}, nil); err == nil {
|
|
if asset, ok := assets[fleet.MDMAssetAndroidFleetServerSecret]; ok && len(asset.Value) > 0 {
|
|
_ = client.SetAuthenticationSecret(string(asset.Value))
|
|
}
|
|
}
|
|
|
|
devices, err := ds.ListAndroidEnrolledDevicesForReconcile(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "list enrolled android devices for reconcile")
|
|
}
|
|
if len(devices) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Make a list of all devices in Google
|
|
deviceNameMap, err := listAllAndroidDeviceNames(ctx, client, logger, enterprise.Name())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
checked := 0
|
|
unenrolled := 0
|
|
for _, dev := range devices {
|
|
if dev == nil || dev.DeviceID == "" {
|
|
continue
|
|
}
|
|
checked++
|
|
deviceName := fmt.Sprintf("%s/devices/%s", enterprise.Name(), dev.DeviceID)
|
|
_, ok := deviceNameMap[deviceName]
|
|
switch {
|
|
case ok:
|
|
// Device exists, no-op.
|
|
continue
|
|
case !ok:
|
|
// BYO unenroll wipes only the work profile; clear host_mdm_actions before flipping host_mdm.enrolled so the post-ack "Wiped"
|
|
// badge clears.
|
|
if cerr := clearAndroidBYOWipeRef(ctx, ds, dev.HostID); cerr != nil {
|
|
logger.ErrorContext(ctx, "failed to clear android byo wipe-ref during reconcile", "host_id", dev.HostID, "err", cerr)
|
|
ctxerr.Handle(ctx, cerr)
|
|
continue
|
|
}
|
|
|
|
if _, derr := ds.SetAndroidHostUnenrolled(ctx, dev.HostID); derr != nil {
|
|
logger.ErrorContext(ctx, "failed to mark android host unenrolled during reconcile", "host_id", dev.HostID, "err", derr)
|
|
continue
|
|
}
|
|
// Advance the dedup event time so a STATUS_REPORT that was already in the Pub/Sub
|
|
// queue before the AMAPI deletion, delivered afterwards, is dropped as stale by
|
|
// handlePubSubStatusReport instead of reverting this unenroll (SetAndroidHostEnrolled
|
|
// would otherwise re-enroll the host, causing a flip-flop and a duplicate
|
|
// mdm_unenrolled activity on the next reconcile). Best-effort: a missed record only
|
|
// weakens dedup for the redelivery window, so log and continue.
|
|
now := time.Now().UTC()
|
|
if derr := ds.SetAndroidPubSubDedupState(ctx, dev.HostID, "", &now); derr != nil {
|
|
logger.WarnContext(ctx, "failed to record android pubsub dedup state during reconcile", "host_id", dev.HostID, "err", derr)
|
|
}
|
|
// Emit system activity to mirror Pub/Sub DELETED handling.
|
|
var displayName, serial string
|
|
if hosts, herr := ds.ListHostsLiteByIDs(ctx, []uint{dev.HostID}); herr == nil && len(hosts) == 1 && hosts[0] != nil {
|
|
displayName = hosts[0].DisplayName()
|
|
serial = hosts[0].HardwareSerial
|
|
}
|
|
if aerr := newActivityFn(ctx, nil, fleet.ActivityTypeMDMUnenrolled{
|
|
HostID: dev.HostID,
|
|
HostSerial: serial,
|
|
HostDisplayName: displayName,
|
|
InstalledFromDEP: false,
|
|
Platform: "android",
|
|
}); aerr != nil {
|
|
logger.DebugContext(ctx, "failed to create mdm_unenrolled activity during android reconcile", "host_id", dev.HostID, "err", aerr)
|
|
}
|
|
unenrolled++
|
|
logger.DebugContext(ctx, "android device missing in Google; marked unenrolled", "host_id", dev.HostID, "device", deviceName)
|
|
}
|
|
}
|
|
|
|
logger.DebugContext(ctx, "android reconcile complete", "checked", checked, "unenrolled", unenrolled)
|
|
return nil
|
|
}
|
|
|
|
// listAllAndroidDeviceNames pages through AMAPI and returns the set of device resource names
|
|
// Google reports for the enterprise. The pagination loop is bounded by androidReconcileMaxPages
|
|
// so a malformed or cycling NextPageToken can't spin it forever; hitting the bound returns an
|
|
// error rather than a partial set, because a partial set would make present devices look missing
|
|
// and wrongly flip them to unenrolled.
|
|
func listAllAndroidDeviceNames(ctx context.Context, client androidmgmt.Client, logger *slog.Logger, enterpriseName string) (map[string]struct{}, error) {
|
|
deviceNameMap := make(map[string]struct{})
|
|
pageToken := ""
|
|
for page := 1; ; page++ {
|
|
// We use the partial call here, to avoid getting all data for a device when we only need a subset (name).
|
|
// should help with request speeds, and also cost for website in terms of network egress.
|
|
resp, err := client.EnterprisesDevicesListPartial(ctx, enterpriseName, pageToken)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "listing android devices from AMAPI")
|
|
}
|
|
for _, dev := range resp.Devices {
|
|
deviceNameMap[dev.Name] = struct{}{}
|
|
}
|
|
if resp.NextPageToken == "" {
|
|
return deviceNameMap, nil
|
|
}
|
|
if page >= androidReconcileMaxPages {
|
|
logger.ErrorContext(ctx, "android reconcile pagination exceeded max pages; aborting to avoid unbounded loop",
|
|
"enterprise", enterpriseName, "max_pages", androidReconcileMaxPages, "page", page,
|
|
"page_token", pageToken, "next_page_token", resp.NextPageToken, "devices_seen", len(deviceNameMap))
|
|
return nil, ctxerr.Errorf(ctx, "android reconcile pagination exceeded max pages (%d)", androidReconcileMaxPages)
|
|
}
|
|
if page%androidReconcilePageLogInterval == 0 {
|
|
logger.InfoContext(ctx, "android reconcile pagination progress", "pages", page, "devices_seen", len(deviceNameMap))
|
|
}
|
|
pageToken = resp.NextPageToken
|
|
}
|
|
}
|