Files
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

230 lines
12 KiB
Go

package microsoft_mdm
import (
"context"
"errors"
"fmt"
"log/slog"
"regexp"
"slices"
"strings"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/profiles"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/variables"
)
// PreprocessWindowsProfileContentsForDeployment processes Windows configuration profiles to replace Fleet variables
// with their actual values for each host during profile deployment.
func PreprocessWindowsProfileContentsForDeployment(deps ProfilePreprocessDependencies, params ProfilePreprocessParams, profileContents string) (string, error) {
return preprocessWindowsProfileContents(deps, params, profileContents)
}
// windowsSCEPChallengeRegexp matches challenges made up entirely of characters valid in an ASN.1 PrintableString: letters,
// digits, space, and ' ( ) + , - . / : = ?. Windows encodes the SCEP challenge password as a PrintableString, so a challenge with
// any other character (most commonly "_") makes enrollment fail on-device with "The string contains a non-printable character."
// The space is allowed anywhere, including leading and trailing, and was verified to enroll fine on Windows 11.
var windowsSCEPChallengeRegexp = regexp.MustCompile(`^[A-Za-z0-9 '()+,./:=?-]*$`)
// scepChallengeInvalidCharsDetail is the host profile failure detail shown on the Host details page when a custom SCEP proxy
// challenge contains characters Windows can't encode as a PrintableString.
const scepChallengeInvalidCharsDetail = `Couldn't install certificate. The "%s" certificate authority challenge includes characters Windows doesn't support. Allowed: letters, numbers, spaces, and ' ( ) + , - . / : = ?`
// MicrosoftProfileProcessingError is used to indicate errors during Microsoft profile processing, such as variable replacement failures.
// It should not break the entire deployment flow, but rather be handled gracefully at the profile level, setting it to failed and detail = Error()
type MicrosoftProfileProcessingError struct {
message string
}
func (e *MicrosoftProfileProcessingError) Error() string {
return e.message
}
type ProfilePreprocessDependencies struct {
Context context.Context
Logger *slog.Logger
DataStore fleet.Datastore
HostIDForUUIDCache map[string]uint
AppConfig *fleet.AppConfig
CustomSCEPCAs map[string]*fleet.CustomSCEPProxyCA
ManagedCertificatePayloads *[]*fleet.MDMManagedCertificate
NDESConfig *fleet.NDESSCEPProxyCA
GetNDESSCEPChallenge func(ctx context.Context, proxy fleet.NDESSCEPProxyCA) (string, error)
NDESChallengeErrorToDetail func(err error) string
}
type ProfilePreprocessParams struct {
HostUUID string
ProfileUUID string
}
// preprocessWindowsProfileContents processes Windows configuration profiles to replace Fleet variables
// with their actual values for each host. This function is used both during profile deployment
// and during profile verification to ensure consistency.
//
// The function handles XML escaping to prevent injection attacks.
//
// Currently supported variables:
// - $FLEET_VAR_HOST_UUID or ${FLEET_VAR_HOST_UUID}: Replaced with the host's UUID
// - $FLEET_VAR_HOST_END_USER_EMAIL_IDP or ${FLEET_VAR_HOST_END_USER_EMAIL_IDP}: Replaced with the host's end user email from the IDP
// - $FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID or ${FLEET_VAR_SCEP_WINDOWS_CERTIFICATE_ID}: Replaced with the profile UUID for SCEP certificate
// - $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME> or ${FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>}: Replaced with the challenge for the specified custom SCEP CA
// - $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME> or ${FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>}: Replaced with the proxy URL for the specified custom SCEP CA
// - $FLEET_VAR_NDES_SCEP_CHALLENGE or ${FLEET_VAR_NDES_SCEP_CHALLENGE}: Replaced with the one-time NDES challenge password
// - $FLEET_VAR_NDES_SCEP_PROXY_URL or ${FLEET_VAR_NDES_SCEP_PROXY_URL}: Replaced with the Fleet SCEP proxy URL for NDES
//
// Why we don't use Go templates here:
// 1. Error handling: Go templates don't provide fine-grained error handling for individual variable
// replacements. We need to handle failures per-host and per-variable gracefully.
// 2. Variable dependencies: Some variables may be related or have dependencies on each other. With
// manual processing, we can control the order of variable replacement precisely.
// 3. Performance: Templates must be compiled every time they're used, adding overhead when processing
// thousands of host profiles. Direct string replacement is more efficient for our use case.
// 4. XML escaping: We need XML-specific escaping for values, which is simpler to control with direct
// string replacement rather than template functions.
//
// If you need another dependency that should be reused across profiles, add it to a ProfilePreprocessDependencies
// implementation and to the interface if it's required for both verification and deployment. For new dependencies that
// vary profile-to-profile, add them to ProfilePreprocessParams.
func preprocessWindowsProfileContents(deps ProfilePreprocessDependencies, params ProfilePreprocessParams, profileContents string) (string, error) {
// Check if Fleet variables or custom host vitals are present.
fleetVars := variables.Find(profileContents)
hasHostVitals := len(fleet.FindCustomHostVitalIDs(profileContents)) > 0
if len(fleetVars) == 0 && !hasHostVitals {
// No variables to replace, return original content
return profileContents, nil
}
// Process each Fleet variable
result := profileContents
for _, fleetVar := range fleetVars {
switch {
case fleetVar == string(fleet.FleetVarHostUUID):
result = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostUUIDRegexp, result, params.HostUUID)
case fleetVar == string(fleet.FleetVarHostPlatform):
result = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostPlatformRegexp, result, "windows")
case fleetVar == string(fleet.FleetVarHostHardwareSerial):
hostLite, _, err := profiles.HydrateHost(deps.Context, deps.DataStore, fleet.Host{UUID: params.HostUUID}, func(hostCount int) error {
return &MicrosoftProfileProcessingError{message: fmt.Sprintf("Found %d hosts with UUID %s. Profile variable substitution for %s requires exactly one host", hostCount, params.HostUUID, fleet.FleetVarHostHardwareSerial.WithPrefix())}
})
if err != nil {
return profileContents, err
}
if hostLite.HardwareSerial == "" {
return profileContents, &MicrosoftProfileProcessingError{message: fmt.Sprintf("There is no serial number for this host. Fleet couldn't populate %s.", fleet.FleetVarHostHardwareSerial.WithPrefix())}
}
result = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostHardwareSerialRegexp, result, hostLite.HardwareSerial)
case slices.Contains(fleet.IDPFleetVariables, fleet.FleetVarName(fleetVar)):
replacedContents, replacedVariable, err := profiles.ReplaceHostEndUserIDPVariables(deps.Context, deps.DataStore, fleetVar, result, params.HostUUID, deps.HostIDForUUIDCache, func(errMsg string) error {
return &MicrosoftProfileProcessingError{message: errMsg}
})
if err != nil {
return profileContents, err
}
if !replacedVariable {
return profileContents, ctxerr.Wrap(deps.Context, err, "host end user IDP variable replacement failed for variable")
}
result = replacedContents
}
switch {
case fleetVar == string(fleet.FleetVarSCEPWindowsCertificateID):
result = profiles.ReplaceFleetVariableInXML(fleet.FleetVarSCEPWindowsCertificateIDRegexp, result, params.ProfileUUID)
case fleetVar == string(fleet.FleetVarSCEPRenewalID), fleetVar == string(fleet.FleetVarCertificateRenewalID):
// Both legacy SCEP_RENEWAL_ID and the preferred CERTIFICATE_RENEWAL_ID
// substitute to the same value.
result = profiles.ReplaceFleetVariableInXML(fleet.FleetVarRenewalIDRegexp, result, "fleet-"+params.ProfileUUID)
case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)):
caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix))
err := profiles.IsCustomSCEPConfigured(deps.Context, deps.CustomSCEPCAs, caName, fleetVar, func(errMsg string) error {
return &MicrosoftProfileProcessingError{message: errMsg}
})
if err != nil {
return profileContents, err
}
if ca := deps.CustomSCEPCAs[caName]; ca != nil && !windowsSCEPChallengeRegexp.MatchString(ca.Challenge) {
return profileContents, &MicrosoftProfileProcessingError{
message: fmt.Sprintf(scepChallengeInvalidCharsDetail, caName),
}
}
replacedContents, replacedVariable, err := profiles.ReplaceCustomSCEPChallengeVariable(deps.Context, deps.Logger, fleetVar, deps.CustomSCEPCAs, result)
if err != nil {
return profileContents, ctxerr.Wrap(deps.Context, err, "replacing custom SCEP challenge variable")
}
if !replacedVariable {
return profileContents, &MicrosoftProfileProcessingError{message: fmt.Sprintf("Custom SCEP challenge variable replacement failed for variable %s", fleetVar)}
}
result = replacedContents
case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)):
caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix))
err := profiles.IsCustomSCEPConfigured(deps.Context, deps.CustomSCEPCAs, caName, fleetVar, func(errMsg string) error {
return &MicrosoftProfileProcessingError{message: errMsg}
})
if err != nil {
return profileContents, err
}
replacedContents, managedCertificate, replacedVariable, err := profiles.ReplaceCustomSCEPProxyURLVariable(deps.Context, deps.Logger, deps.DataStore, deps.AppConfig, fleetVar, deps.CustomSCEPCAs, result, params.HostUUID, params.ProfileUUID)
if err != nil {
return profileContents, ctxerr.Wrap(deps.Context, err, "replacing custom SCEP challenge variable")
}
if !replacedVariable {
return profileContents, &MicrosoftProfileProcessingError{message: fmt.Sprintf("Custom SCEP challenge variable replacement failed for variable %s", fleetVar)}
}
result = replacedContents
*deps.ManagedCertificatePayloads = append(*deps.ManagedCertificatePayloads, managedCertificate)
case fleetVar == string(fleet.FleetVarNDESSCEPChallenge):
if deps.NDESConfig == nil {
return profileContents, &MicrosoftProfileProcessingError{
message: fmt.Sprintf("NDES is not configured. Fleet couldn't populate %s.", fleet.FleetVarNDESSCEPChallenge.WithPrefix()),
}
}
deps.Logger.DebugContext(deps.Context, "fetching NDES challenge", "host_uuid", params.HostUUID, "profile_uuid", params.ProfileUUID)
challenge, err := deps.GetNDESSCEPChallenge(deps.Context, *deps.NDESConfig)
if err != nil {
return profileContents, &MicrosoftProfileProcessingError{message: deps.NDESChallengeErrorToDetail(err)}
}
payload := &fleet.MDMManagedCertificate{
HostUUID: params.HostUUID,
ProfileUUID: params.ProfileUUID,
ChallengeRetrievedAt: ptr.Time(time.Now()),
Type: fleet.CAConfigNDES,
CAName: "NDES",
}
*deps.ManagedCertificatePayloads = append(*deps.ManagedCertificatePayloads, payload)
result = profiles.ReplaceFleetVariableInXML(fleet.FleetVarNDESSCEPChallengeRegexp, result, challenge)
case fleetVar == string(fleet.FleetVarNDESSCEPProxyURL):
result = profiles.ReplaceNDESSCEPProxyURLVariable(deps.AppConfig.MDMUrl(), params.HostUUID, params.ProfileUUID, result)
}
}
// Expand per-host custom host vitals. On a missing/empty value the datastore
// returns a MissingCustomHostVitalValueError, which we surface as a
// MicrosoftProfileProcessingError so the caller marks the profile failed with
// this detail rather than shipping a blank substitution.
if hasHostVitals {
hostLite, _, err := profiles.HydrateHost(deps.Context, deps.DataStore, fleet.Host{UUID: params.HostUUID}, func(hostCount int) error {
return &MicrosoftProfileProcessingError{message: fmt.Sprintf("Found %d hosts with UUID %s. Custom host vital substitution requires exactly one host.", hostCount, params.HostUUID)}
})
if err != nil {
return profileContents, err
}
expanded, err := deps.DataStore.ExpandCustomHostVitals(deps.Context, hostLite.ID, result)
if err != nil {
if missing, ok := errors.AsType[*fleet.MissingCustomHostVitalValueError](err); ok {
return profileContents, &MicrosoftProfileProcessingError{message: missing.Error()}
}
return profileContents, err
}
result = expanded
}
return result, nil
}