Files
fleet/server/service/apple_device_names.go
Nico f5ca4b5b0d Add Android support for custom host vitals (#49696)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #49421

Custom host vitals (`$FLEET_HOST_VITAL_<id>`) already worked in scripts
and Apple/Windows configuration profiles, but Android configuration
profiles and managed app configuration explicitly rejected them at
upload to keep parity with `$FLEET_SECRET_*`. This left admins unable to
inject per-host vitals (e.g. an asset tag) into Android MDM
configuration the same way they can for every other platform.

For more context, prior PRs:
- https://github.com/fleetdm/fleet/pull/49334
- https://github.com/fleetdm/fleet/pull/49586

# Checklist for submitter

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

## Testing

- [x] Added/updated automated tests

- [x] QA'd all new/changed functionality manually

- Created an "Asset tag" host vital.
- Enrolled an Android device.
- Initially the test profile showed as "Failed" because no value was set
for the vital.
- Set a value for the vital, saw that it went from Enforcing to
Verified.

<img width="1446" height="510" alt="Screenshot 2026-07-24 at 8 57 46 AM"
src="https://github.com/user-attachments/assets/c0e2348c-e521-48f3-85cd-6f884689b2cd"
/>
<img width="1520" height="936" alt="Screenshot 2026-07-24 at 8 56 56 AM"
src="https://github.com/user-attachments/assets/169b9545-ec7a-429b-8f45-0e2740f61c77"
/>
<img width="1607" height="1136" alt="Screenshot 2026-07-24 at 8 57
30 AM"
src="https://github.com/user-attachments/assets/a8213745-b224-4a36-a54d-32152a15c377"
/>

Also tested the rejection cases:
- trying to upload a profile with an invalid custom host vital id
(either a non-numeric value, a numeric but non-existent ID, and
referencing a vital as a JSON key instead of a value)
- deleting a vital referenced in a profile



https://github.com/user-attachments/assets/e8b4acde-ddf4-41c0-b00a-5ab4945d0bc2



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

## Summary by CodeRabbit

* **New Features**
* Android app configurations and profiles now support custom host vital
placeholders (`$FLEET_HOST_VITAL_<id>`).
* Custom host vital values are expanded per device during Android
delivery.
* Managed Android profiles/configurations are automatically resent when
a referenced vital value changes.

* **Bug Fixes**
* Added validation for malformed, missing, or undefined vital references
during Android app association and profile/config uploads.
  * Prevented deletion of vitals referenced by Android profiles.
* Improved error handling and delivery failure details when a device
lacks a required vital value.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-29 08:22:57 -03:00

349 lines
15 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"log/slog"
"regexp"
"strings"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/variables"
"github.com/google/uuid"
)
// reconcileHostDeviceNamesBatchSize bounds how many queued rename rows a
// single cron tick processes; remaining rows are picked up on subsequent
// ticks, amortizing command delivery for large teams.
//
// var (not const) so tests can override it.
var reconcileHostDeviceNamesBatchSize = 500
// secretExpansion memoizes the result of expanding the custom (secret) variables
// in a host name template. err is set (e.g. fleet.MissingSecretsError) when a
// referenced secret is undefined.
type secretExpansion struct {
value string
err error
}
// ReconcileHostDeviceNames runs one pass of host-name template enforcement:
// for each host whose enforcement row is queued (status NULL), it resolves
// the host's team name template and either enqueues a Settings/DeviceName
// command or records the outcome directly (name already matching → verified;
// resolved name unusable → failed).
func ReconcileHostDeviceNames(
ctx context.Context,
ds fleet.Datastore,
commander *apple_mdm.MDMAppleCommander,
logger *slog.Logger,
) error {
appConfig, err := ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "reading app config")
}
if !appConfig.MDM.EnabledAndConfigured {
return nil
}
pending, err := ds.ListHostsPendingDeviceNameCommand(ctx, reconcileHostDeviceNamesBatchSize)
if err != nil {
return ctxerr.Wrap(ctx, err, "list hosts pending device name command")
}
if len(pending) == 0 {
return nil
}
// Every host in this batch is queued (status NULL), which for a host that
// previously received a command means the command was reset (resend, template
// change, transfer/enrollment reconcile) without having executed. Deactivate
// any such lingering command before enqueuing fresh ones so an out-of-order
// NotNow retry of a stale command can't rename the device to an old name.
pendingUUIDs := make([]string, 0, len(pending))
for _, host := range pending {
pendingUUIDs = append(pendingUUIDs, host.HostUUID)
}
if err := ds.DeactivateHostDeviceNameCommands(ctx, pendingUUIDs); err != nil {
return ctxerr.Wrap(ctx, err, "deactivate stale device name commands")
}
noTeamTemplate := appConfig.MDM.HostNameTemplate.Value
templates := make(map[uint]string) // team ID → name template
// secretsExpanded caches the result of expanding $FLEET_SECRET_* custom
// variables for a given raw template. Secret values are global (name-keyed,
// host-independent), so the same template expands to the same string for
// every host — expand once per distinct template rather than per host.
secretsExpanded := make(map[string]secretExpansion)
var notify []string // hosts with a freshly-enqueued command to push
for _, host := range pending {
var tmpl string
if host.TeamID == nil {
tmpl = noTeamTemplate
} else {
var ok bool
tmpl, ok = templates[*host.TeamID]
if !ok {
mdmConfig, err := ds.TeamMDMConfig(ctx, *host.TeamID)
if err != nil {
if fleet.IsNotFound(err) {
// team deleted between cron runs
templates[*host.TeamID] = ""
continue
}
return ctxerr.Wrap(ctx, err, "get team mdm config for device name")
}
tmpl = mdmConfig.HostNameTemplate
templates[*host.TeamID] = tmpl
}
}
if tmpl == "" {
// Template cleared between cron runs, or the team was deleted (cached
// as "" above), or a No-team template was cleared. Either way the
// clear/delete/transfer path removes the rows, so there's nothing to
// enforce here.
continue
}
// Expand any custom (secret, $FLEET_SECRET_*) variables before resolving
// the built-in host variables. Secret values are global and
// host-independent, so this is memoized per distinct template.
expandedTmpl := tmpl
if len(fleet.ContainsPrefixVars(tmpl, fleet.ServerSecretPrefix)) > 0 {
exp, ok := secretsExpanded[tmpl]
if !ok {
value, expandErr := ds.ExpandEmbeddedSecrets(ctx, tmpl)
exp = secretExpansion{value: value, err: expandErr}
secretsExpanded[tmpl] = exp
}
if exp.err != nil {
if !fleet.IsMissingSecretsError(exp.err) {
// A transient failure (e.g. a DB error while fetching/decrypting
// the secret). Abort the batch so the next cron tick retries,
// exactly like the team-config lookup error above — don't
// permanently fail rows that a retry would resolve (failed rows
// aren't re-picked until a manual resend).
return ctxerr.Wrap(ctx, exp.err, "expand host name template secrets")
}
// A referenced secret is genuinely undefined (e.g. deleted). Save-time
// validation and the delete guard normally prevent this, so it's a
// defensive path: fail the row with the reason and don't send a
// command. On a write error, log and move on rather than aborting the
// batch, consistent with the other outcomes below.
if err := ds.SetHostDeviceNameStatus(ctx, host.HostUUID, fleet.MDMDeliveryFailed, nil, "",
exp.err.Error()); err != nil {
logger.ErrorContext(ctx, "mark device name row failed for missing secret", "host_uuid", host.HostUUID, "err", err)
}
continue
}
expandedTmpl = exp.value
}
// Expand any custom host vital ($FLEET_HOST_VITAL_<id>) references with this
// host's stored value. Unlike secrets, vital values are per-host, so this
// can't be memoized across hosts sharing a template.
if len(fleet.FindCustomHostVitalIDs(expandedTmpl)) > 0 {
withVitals, vitalErr := ds.ExpandCustomHostVitals(ctx, host.HostID, expandedTmpl)
if vitalErr != nil {
if _, ok := errors.AsType[*fleet.MissingCustomHostVitalValueError](vitalErr); !ok {
return ctxerr.Wrap(ctx, vitalErr, "expand host name template custom host vitals")
}
// A referenced vital exists but has no value set for this host.
if err := ds.SetHostDeviceNameStatus(ctx, host.HostUUID, fleet.MDMDeliveryFailed, nil, "",
vitalErr.Error()); err != nil {
logger.ErrorContext(ctx, "mark device name row failed for missing custom host vital value", "host_uuid", host.HostUUID, "err", err)
}
continue
}
expandedTmpl = withVitals
}
resolved := fleet.ResolveHostNameTemplate(expandedTmpl, &fleet.Host{
UUID: host.HostUUID,
HardwareSerial: host.HardwareSerial,
Platform: host.Platform,
})
// Resolve IdP end-user variables (if any). These need a per-host datastore
// lookup and fail the same way configuration profiles do when the data is
// missing.
resolvedWithIDP, idpFailDetail, idpErr := resolveHostNameIDPVars(ctx, ds, resolved, host.HostID)
if idpErr != nil {
// A datastore failure; abort the batch so the next cron tick retries
// rather than permanently failing rows a retry would resolve.
return idpErr
}
if idpFailDetail != "" {
// The host is missing IdP data the template needs; fail the row with the
// profile-style detail. On a write error, log and move on rather than
// aborting the batch, consistent with the other outcomes below.
if err := ds.SetHostDeviceNameStatus(ctx, host.HostUUID, fleet.MDMDeliveryFailed, nil, "",
idpFailDetail); err != nil {
logger.ErrorContext(ctx, "mark device name row failed for idp resolution", "host_uuid", host.HostUUID, "err", err)
}
continue
}
resolved = resolvedWithIDP
switch {
case len(resolved) > fleet.MaxResolvedHostNameBytes:
// The resolved name is not stored: it can exceed the column width,
// and failed rows are never compared against reported names. On a
// write error, log and move on so one bad host doesn't abort the
// batch (matches the enqueue branch below); the row stays queued and
// a later cron run retries it.
if err := ds.SetHostDeviceNameStatus(ctx, host.HostUUID, fleet.MDMDeliveryFailed, nil, "",
"Resolved name exceeds 63 bytes."); err != nil {
logger.ErrorContext(ctx, "mark device name row failed for too-long name", "host_uuid", host.HostUUID, "err", err)
continue
}
logger.InfoContext(ctx, "host name template resolves past the device name limit, not sending command",
"host_uuid", host.HostUUID, "resolved_bytes", len(resolved))
case resolved == host.ComputerName:
// The device already carries the resolved name; no command needed.
// On a write error, log and move on rather than aborting the batch.
if err := ds.SetHostDeviceNameStatus(ctx, host.HostUUID, fleet.MDMDeliveryVerified, nil, resolved, ""); err != nil {
logger.ErrorContext(ctx, "mark device name row verified for matching name", "host_uuid", host.HostUUID, "err", err)
continue
}
default:
cmdUUID := fleet.DeviceNameCommandUUIDPrefix + uuid.NewString()
if err := commander.DeviceNameSettingWithoutNotifications(ctx, host.HostUUID, cmdUUID, resolved); err != nil {
// The command was not persisted; leave the row queued so the
// next cron run retries this host, and move on so one bad
// host doesn't starve the rest of the batch.
logger.ErrorContext(ctx, "enqueue device name command", "host_uuid", host.HostUUID, "err", err)
continue
}
// The command is persisted; the device will apply it on its next
// check-in even if the batched push below never reaches it. Collect
// the UUID so every device in this batch is woken with a single APNs
// push instead of one request per host.
notify = append(notify, host.HostUUID)
if err := ds.SetHostDeviceNameStatus(ctx, host.HostUUID, fleet.MDMDeliveryPending, &cmdUUID, resolved, ""); err != nil {
// The command was sent but recording it failed; log and move on
// rather than aborting the batch, consistent with the
// enqueue-failure handling above. The row stays queued and a
// later cron run re-sends; the device resolves to the latest
// command, and the superseded one's result is dropped as stale.
logger.ErrorContext(ctx, "mark device name command sent", "host_uuid", host.HostUUID, "command_uuid", cmdUUID, "err", err)
continue
}
}
}
if len(notify) > 0 {
// One batched push wakes every device whose command was just enqueued.
// Same handling as the iOS/iPadOS revive cron: a per-device APNs failure
// is tolerable (the command is already persisted, so the device applies
// it on its next check-in) so it's logged and the run succeeds — retrying
// would enqueue duplicates. Any other error means the push subsystem
// itself failed, so surface it.
if err := commander.SendNotifications(ctx, notify); err != nil {
var apnsErr *apple_mdm.APNSDeliveryError
if !errors.As(err, &apnsErr) {
return ctxerr.Wrap(ctx, err, "push device name commands")
}
logger.InfoContext(ctx, "failed to push device name command to some hosts", "err", apnsErr.Error())
}
}
return nil
}
// resolveHostNameIDPVars substitutes the IdP end-user built-in variables in name
// for the given host, with the same value mapping and fail-hard messages as
// configuration profiles (server/mdm/profiles.ResolveHostEndUserIDPValue). It
// fetches the host's end user once (all IdP variables in a template resolve from
// the same user), so a template using several IdP variables needs a single
// GetEndUsers call. It returns:
// - the resolved name, when every IdP variable is populated;
// - a non-empty failDetail (and empty name) when an IdP variable can't be
// populated for this host — the caller marks the host's row Failed with it,
// exactly as a profile install would fail;
// - an error only for a datastore failure, which the caller treats as transient.
func resolveHostNameIDPVars(ctx context.Context, ds fleet.Datastore, name string, hostID uint) (resolved string, failDetail string, err error) {
// Collect the IdP variables used, longest-first (variables.Find sorts that way)
// so ..._IDP_USERNAME_LOCAL_PART is substituted before ..._IDP_USERNAME, whose
// regexps have no trailing boundary — matching the profile processor's order.
var idpVars []string
for _, v := range variables.Find(name) {
if fleet.IsHostNameTemplateIDPVar(v) {
idpVars = append(idpVars, v)
}
}
if len(idpVars) == 0 {
return name, "", nil
}
users, err := fleet.GetEndUsers(ctx, ds, hostID)
if err != nil {
return "", "", ctxerr.Wrap(ctx, err, "get end users for device name")
}
var user *fleet.HostEndUser
if len(users) > 0 && users[0].IdpUserName != "" {
user = &users[0]
}
for _, v := range idpVars {
value, rx, ok, detail := resolveHostNameIDPValue(user, v)
if !ok {
return "", detail, nil
}
name = rx.ReplaceAllLiteralString(name, value)
}
return name, "", nil
}
// resolveHostNameIDPValue mirrors server/mdm/profiles.ResolveHostEndUserIDPValue's
// value mapping and fail-hard detail messages, but works from an already-fetched
// end user (nil when the host has no IdP user) so the caller can resolve every IdP
// variable in a template from a single GetEndUsers call. On success it returns the
// value and the variable's regexp; otherwise ok is false and detail carries the
// profile-style failure message.
func resolveHostNameIDPValue(user *fleet.HostEndUser, fleetVar string) (value string, rx *regexp.Regexp, ok bool, detail string) {
noGroupsErr := fmt.Sprintf("There are no IdP groups for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleet.FleetVarHostEndUserIDPGroups)
noDepartmentErr := fmt.Sprintf("There is no IdP department for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleet.FleetVarHostEndUserIDPDepartment)
noFullnameErr := fmt.Sprintf("There is no IdP full name for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleet.FleetVarHostEndUserIDPFullname)
if user == nil {
switch fleetVar {
case string(fleet.FleetVarHostEndUserIDPGroups):
return "", nil, false, noGroupsErr
case string(fleet.FleetVarHostEndUserIDPDepartment):
return "", nil, false, noDepartmentErr
case string(fleet.FleetVarHostEndUserIDPFullname):
return "", nil, false, noFullnameErr
default:
return "", nil, false, fmt.Sprintf("There is no IdP username for this host. Fleet couldn't populate $FLEET_VAR_%s.", fleetVar)
}
}
switch fleetVar {
case string(fleet.FleetVarHostEndUserIDPUsername):
return user.IdpUserName, fleet.FleetVarHostEndUserIDPUsernameRegexp, true, ""
case string(fleet.FleetVarHostEndUserIDPUsernameLocalPart):
localPart, _, _ := strings.Cut(user.IdpUserName, "@")
return localPart, fleet.FleetVarHostEndUserIDPUsernameLocalPartRegexp, true, ""
case string(fleet.FleetVarHostEndUserIDPGroups):
if len(user.IdpGroups) == 0 {
return "", nil, false, noGroupsErr
}
return strings.Join(user.IdpGroups, ","), fleet.FleetVarHostEndUserIDPGroupsRegexp, true, ""
case string(fleet.FleetVarHostEndUserIDPDepartment):
if user.Department == "" {
return "", nil, false, noDepartmentErr
}
return user.Department, fleet.FleetVarHostEndUserIDPDepartmentRegexp, true, ""
case string(fleet.FleetVarHostEndUserIDPFullname):
fullName := strings.TrimSpace(user.IdpFullName)
if fullName == "" {
return "", nil, false, noFullnameErr
}
return fullName, fleet.FleetVarHostEndUserIDPFullnameRegexp, true, ""
default:
return "", nil, false, fmt.Sprintf("Fleet couldn't populate $FLEET_VAR_%s.", fleetVar)
}
}