mirror of
https://github.com/fleetdm/fleet
synced 2026-05-04 22:08:41 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** For #39344 # Details As a first step to deprecating API params like `team_id` in favor of `fleet_id` and `query_id` in favor of `report_id`, this PR adds `renameto` tags to all deprecated keys. There is no logic in this PR to actually use these tags in any way. The logic and test fixes will be in the next PR, but in the interest of keeping things manageable I'm pushing this out first. There were definitely params with "query" in them that we don't want to change (mainly osquery-related), and I think I kept them all out but it's worth double-checking here. The team -> fleet changes are pretty safe in comparison. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] 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. Deferring changelog to PR with logic changes ## Testing - [ ] Added/updated automated tests This should be a no-op. All existing tests shoud pass. - [X] QA'd all new/changed functionality manually
7322 lines
267 KiB
Go
7322 lines
267 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/md5" // nolint:gosec // used for declarative management token
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
eeservice "github.com/fleetdm/fleet/v4/ee/server/service"
|
|
"github.com/fleetdm/fleet/v4/ee/server/service/digicert"
|
|
"github.com/fleetdm/fleet/v4/pkg/file"
|
|
shared_mdm "github.com/fleetdm/fleet/v4/pkg/mdm"
|
|
"github.com/fleetdm/fleet/v4/pkg/optjson"
|
|
"github.com/fleetdm/fleet/v4/server"
|
|
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
|
|
platformlogging "github.com/fleetdm/fleet/v4/server/platform/logging"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/authz"
|
|
"github.com/fleetdm/fleet/v4/server/config"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/license"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/logging"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
mdm_types "github.com/fleetdm/fleet/v4/server/mdm"
|
|
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/apple/appmanifest"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/apple/gdmf"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/assets"
|
|
mdmcrypto "github.com/fleetdm/fleet/v4/server/mdm/crypto"
|
|
mdmlifecycle "github.com/fleetdm/fleet/v4/server/mdm/lifecycle"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
|
|
|
nano_service "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/profiles"
|
|
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
"github.com/fleetdm/fleet/v4/server/variables"
|
|
"github.com/go-kit/log/level"
|
|
"github.com/google/uuid"
|
|
"github.com/micromdm/plist"
|
|
"github.com/smallstep/pkcs7"
|
|
)
|
|
|
|
const (
|
|
maxValueCharsInError = 100
|
|
SameProfileNameUploadErrorMsg = "Couldn't add. A configuration profile with this name already exists (PayloadDisplayName for .mobileconfig and file name for .json and .xml)."
|
|
limit10KiB = 10 * 1024
|
|
)
|
|
|
|
var (
|
|
fleetVarHostEndUserEmailIDPRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarHostEndUserEmailIDP))
|
|
fleetVarNDESSCEPChallengeRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarNDESSCEPChallenge))
|
|
fleetVarNDESSCEPProxyURLRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarNDESSCEPProxyURL))
|
|
fleetVarSCEPRenewalIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarSCEPRenewalID))
|
|
fleetVarHostUUIDRegexp = regexp.MustCompile(fmt.Sprintf(`(\$FLEET_VAR_%s)|(\${FLEET_VAR_%[1]s})`, fleet.FleetVarHostUUID))
|
|
|
|
// TODO(HCA): Can we come up with a clearer name? This looks like any variables not in this slice is not supported,
|
|
// but that is not the case, digicert, custom scep, hydrant and smallstep are totally supported just in a different way (multiple CA's)
|
|
fleetVarsSupportedInAppleConfigProfiles = []fleet.FleetVarName{
|
|
fleet.FleetVarNDESSCEPChallenge, fleet.FleetVarNDESSCEPProxyURL, fleet.FleetVarHostEndUserEmailIDP,
|
|
fleet.FleetVarHostHardwareSerial, fleet.FleetVarHostEndUserIDPUsername, fleet.FleetVarHostEndUserIDPUsernameLocalPart,
|
|
fleet.FleetVarHostEndUserIDPGroups, fleet.FleetVarHostEndUserIDPDepartment, fleet.FleetVarHostEndUserIDPFullname, fleet.FleetVarSCEPRenewalID,
|
|
fleet.FleetVarHostUUID, fleet.FleetVarHostPlatform,
|
|
}
|
|
)
|
|
|
|
type hostProfileUUID struct {
|
|
HostUUID string
|
|
ProfileUUID string
|
|
}
|
|
|
|
type getMDMAppleCommandResultsRequest struct {
|
|
CommandUUID string `query:"command_uuid,optional"`
|
|
}
|
|
|
|
type getMDMAppleCommandResultsResponse struct {
|
|
Results []*fleet.MDMCommandResult `json:"results,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getMDMAppleCommandResultsResponse) Error() error { return r.Err }
|
|
|
|
func getMDMAppleCommandResultsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getMDMAppleCommandResultsRequest)
|
|
results, err := svc.GetMDMAppleCommandResults(ctx, req.CommandUUID)
|
|
if err != nil {
|
|
return getMDMAppleCommandResultsResponse{
|
|
Err: err,
|
|
}, nil
|
|
}
|
|
|
|
return getMDMAppleCommandResultsResponse{
|
|
Results: results,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleCommandResults(ctx context.Context, commandUUID string) ([]*fleet.MDMCommandResult, error) {
|
|
// first, authorize that the user has the right to list hosts
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return nil, fleet.ErrNoContext
|
|
}
|
|
|
|
// check that command exists first, to return 404 on invalid commands
|
|
// (the command may exist but have no results yet).
|
|
if _, err := svc.ds.GetMDMAppleCommandRequestType(ctx, commandUUID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// next, we need to read the command results before we know what hosts (and
|
|
// therefore what teams) we're dealing with.
|
|
results, err := svc.ds.GetMDMAppleCommandResults(ctx, commandUUID, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// now we can load the hosts (lite) corresponding to those command results,
|
|
// and do the final authorization check with the proper team(s). Include observers,
|
|
// as they are able to view command results for their teams' hosts.
|
|
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true}
|
|
hostUUIDs := make([]string, len(results))
|
|
for i, res := range results {
|
|
hostUUIDs[i] = res.HostUUID
|
|
}
|
|
hosts, err := svc.ds.ListHostsLiteByUUIDs(ctx, filter, hostUUIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(hosts) == 0 {
|
|
// do not return 404 here, as it's possible for a command to not have
|
|
// results yet
|
|
return nil, nil
|
|
}
|
|
|
|
// collect the team IDs and verify that the user has access to view commands
|
|
// on all affected teams. Index the hosts by uuid for easly lookup as
|
|
// afterwards we'll want to store the hostname on the returned results.
|
|
hostsByUUID := make(map[string]*fleet.Host, len(hosts))
|
|
teamIDs := make(map[uint]bool)
|
|
for _, h := range hosts {
|
|
var id uint
|
|
if h.TeamID != nil {
|
|
id = *h.TeamID
|
|
}
|
|
teamIDs[id] = true
|
|
hostsByUUID[h.UUID] = h
|
|
}
|
|
|
|
var commandAuthz fleet.MDMCommandAuthz
|
|
for tmID := range teamIDs {
|
|
commandAuthz.TeamID = &tmID
|
|
if tmID == 0 {
|
|
commandAuthz.TeamID = nil
|
|
}
|
|
|
|
if err := svc.authz.Authorize(ctx, commandAuthz, fleet.ActionRead); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
}
|
|
|
|
// add the hostnames to the results
|
|
for _, res := range results {
|
|
if h := hostsByUUID[res.HostUUID]; h != nil {
|
|
res.Hostname = hostsByUUID[res.HostUUID].Hostname
|
|
}
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
type listMDMAppleCommandsRequest struct {
|
|
ListOptions fleet.ListOptions `url:"list_options"`
|
|
}
|
|
|
|
type listMDMAppleCommandsResponse struct {
|
|
Results []*fleet.MDMAppleCommand `json:"results"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r listMDMAppleCommandsResponse) Error() error { return r.Err }
|
|
|
|
func listMDMAppleCommandsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*listMDMAppleCommandsRequest)
|
|
results, err := svc.ListMDMAppleCommands(ctx, &fleet.MDMCommandListOptions{
|
|
ListOptions: req.ListOptions,
|
|
})
|
|
if err != nil {
|
|
return listMDMAppleCommandsResponse{
|
|
Err: err,
|
|
}, nil
|
|
}
|
|
|
|
return listMDMAppleCommandsResponse{
|
|
Results: results,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) ListMDMAppleCommands(ctx context.Context, opts *fleet.MDMCommandListOptions) ([]*fleet.MDMAppleCommand, error) {
|
|
// first, authorize that the user has the right to list hosts
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return nil, fleet.ErrNoContext
|
|
}
|
|
|
|
// get the list of commands so we know what hosts (and therefore what teams)
|
|
// we're dealing with. Including the observers as they are allowed to view
|
|
// MDM Apple commands.
|
|
results, err := svc.ds.ListMDMAppleCommands(ctx, fleet.TeamFilter{
|
|
User: vc.User,
|
|
IncludeObserver: true,
|
|
}, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// collect the different team IDs and verify that the user has access to view
|
|
// commands on all affected teams, do not assume that ListMDMAppleCommands
|
|
// only returned hosts that the user is authorized to view the command
|
|
// results of (that is, always verify with our rego authz policy).
|
|
teamIDs := make(map[uint]bool)
|
|
for _, res := range results {
|
|
var id uint
|
|
if res.TeamID != nil {
|
|
id = *res.TeamID
|
|
}
|
|
teamIDs[id] = true
|
|
}
|
|
|
|
// instead of returning an authz error if the user is not authorized for a
|
|
// team, we remove those commands from the results (as we want to return
|
|
// whatever the user is allowed to see). Since this can only be done after
|
|
// retrieving the list of commands, this may result in returning less results
|
|
// than requested, but it's ok - it's expected that the results retrieved
|
|
// from the datastore will all be authorized for the user.
|
|
var commandAuthz fleet.MDMCommandAuthz
|
|
var authzErr error
|
|
for tmID := range teamIDs {
|
|
commandAuthz.TeamID = &tmID
|
|
if tmID == 0 {
|
|
commandAuthz.TeamID = nil
|
|
}
|
|
if err := svc.authz.Authorize(ctx, commandAuthz, fleet.ActionRead); err != nil {
|
|
if authzErr == nil {
|
|
authzErr = err
|
|
}
|
|
teamIDs[tmID] = false
|
|
}
|
|
}
|
|
|
|
if authzErr != nil {
|
|
level.Error(svc.logger).Log("err", "unauthorized to view some team commands", "details", authzErr)
|
|
|
|
// filter-out the teams that the user is not allowed to view
|
|
allowedResults := make([]*fleet.MDMAppleCommand, 0, len(results))
|
|
for _, res := range results {
|
|
var id uint
|
|
if res.TeamID != nil {
|
|
id = *res.TeamID
|
|
}
|
|
if teamIDs[id] {
|
|
allowedResults = append(allowedResults, res)
|
|
}
|
|
}
|
|
results = allowedResults
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
type newMDMAppleConfigProfileRequest struct {
|
|
TeamID uint
|
|
Profile *multipart.FileHeader
|
|
}
|
|
|
|
type newMDMAppleConfigProfileResponse struct {
|
|
ProfileID uint `json:"profile_id"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
// TODO(lucas): We parse the whole body before running svc.authz.Authorize.
|
|
// An authenticated but unauthorized user could abuse this.
|
|
func (newMDMAppleConfigProfileRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
decoded := newMDMAppleConfigProfileRequest{}
|
|
|
|
err := r.ParseMultipartForm(platform_http.MaxMultipartFormSize)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "failed to parse multipart form",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
val, ok := r.MultipartForm.Value["team_id"]
|
|
if !ok || len(val) < 1 {
|
|
// default is no team
|
|
decoded.TeamID = 0
|
|
} else {
|
|
teamID, err := strconv.Atoi(val[0])
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode team_id in multipart form: %s", err.Error())}
|
|
}
|
|
decoded.TeamID = uint(teamID) //nolint:gosec // dismiss G115
|
|
}
|
|
|
|
fhs, ok := r.MultipartForm.File["profile"]
|
|
if !ok || len(fhs) < 1 {
|
|
return nil, &fleet.BadRequestError{Message: "no file headers for profile"}
|
|
}
|
|
decoded.Profile = fhs[0]
|
|
|
|
return &decoded, nil
|
|
}
|
|
|
|
func (r newMDMAppleConfigProfileResponse) Error() error { return r.Err }
|
|
|
|
func newMDMAppleConfigProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*newMDMAppleConfigProfileRequest)
|
|
|
|
ff, err := req.Profile.Open()
|
|
if err != nil {
|
|
return &newMDMAppleConfigProfileResponse{Err: err}, nil
|
|
}
|
|
defer ff.Close()
|
|
data, err := io.ReadAll(ff)
|
|
if err != nil {
|
|
return &newMDMConfigProfileResponse{Err: err}, nil
|
|
}
|
|
// providing an empty set of labels since this endpoint is only maintained for backwards compat
|
|
cp, err := svc.NewMDMAppleConfigProfile(ctx, req.TeamID, data, nil, fleet.LabelsIncludeAll)
|
|
if err != nil {
|
|
return &newMDMAppleConfigProfileResponse{Err: err}, nil
|
|
}
|
|
return &newMDMAppleConfigProfileResponse{
|
|
ProfileID: cp.ProfileID,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) NewMDMAppleConfigProfile(ctx context.Context, teamID uint, data []byte, labels []string, labelsMembershipMode fleet.MDMLabelsMode) (*fleet.MDMAppleConfigProfile, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: &teamID}, fleet.ActionWrite); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
// check that Apple MDM is enabled - the middleware of that endpoint checks
|
|
// only that any MDM is enabled, maybe it's just Windows
|
|
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
|
|
err := fleet.NewInvalidArgumentError("profile", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
|
|
return nil, ctxerr.Wrap(ctx, err, "check macOS MDM enabled")
|
|
}
|
|
|
|
err := CheckProfileIsNotSigned(data)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
var teamName string
|
|
if teamID >= 1 {
|
|
tm, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, &teamID, nil)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
teamName = tm.Name
|
|
}
|
|
|
|
// Check for secrets in profile name before expansion
|
|
if err := fleet.ValidateNoSecretsInProfileName(data); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile", err.Error()))
|
|
}
|
|
|
|
// Expand and validate secrets in profile
|
|
expanded, secretsUpdatedAt, err := svc.ds.ExpandEmbeddedSecretsAndUpdatedAt(ctx, string(data))
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("profile", err.Error()))
|
|
}
|
|
|
|
// Get license for validation
|
|
lic, err := svc.License(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "checking license")
|
|
}
|
|
|
|
groupedCAs, err := svc.ds.GetGroupedCertificateAuthorities(ctx, true)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting grouped certificate authorities")
|
|
}
|
|
|
|
profileVars, err := validateConfigProfileFleetVariables(expanded, lic, groupedCAs)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "validating fleet variables")
|
|
}
|
|
|
|
cp, err := fleet.NewMDMAppleConfigProfile([]byte(expanded), &teamID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
|
|
Message: fmt.Sprintf("failed to parse config profile: %s", err.Error()),
|
|
})
|
|
}
|
|
|
|
if err := cp.ValidateUserProvided(svc.config.MDM.EnableCustomOSUpdatesAndFileVault); err != nil {
|
|
if strings.Contains(err.Error(), mobileconfig.DiskEncryptionProfileRestrictionErrMsg) {
|
|
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: err.Error() + ` To control these settings use disk encryption endpoint.`})
|
|
}
|
|
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: err.Error()})
|
|
}
|
|
|
|
// Save the original unexpanded profile
|
|
cp.Mobileconfig = data
|
|
cp.SecretsUpdatedAt = secretsUpdatedAt
|
|
|
|
labelMap, err := svc.validateProfileLabels(ctx, &teamID, labels)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "validating labels")
|
|
}
|
|
switch labelsMembershipMode {
|
|
case fleet.LabelsIncludeAll:
|
|
cp.LabelsIncludeAll = labelMap
|
|
case fleet.LabelsIncludeAny:
|
|
cp.LabelsIncludeAny = labelMap
|
|
case fleet.LabelsExcludeAny:
|
|
cp.LabelsExcludeAny = labelMap
|
|
default:
|
|
// TODO what happens if mode is not set?s
|
|
}
|
|
|
|
// Convert profile variable names to FleetVarName type
|
|
varNames := make([]fleet.FleetVarName, 0, len(profileVars))
|
|
for _, varName := range profileVars {
|
|
varNames = append(varNames, fleet.FleetVarName(varName))
|
|
}
|
|
newCP, err := svc.ds.NewMDMAppleConfigProfile(ctx, *cp, varNames)
|
|
if err != nil {
|
|
var existsErr endpointer.ExistsErrorInterface
|
|
if errors.As(err, &existsErr) {
|
|
msg := SameProfileNameUploadErrorMsg
|
|
if re, ok := existsErr.(interface{ Resource() string }); ok {
|
|
if re.Resource() == "MDMAppleConfigProfile.PayloadIdentifier" {
|
|
msg = "Couldn't add. A configuration profile with this identifier (PayloadIdentifier) already exists."
|
|
}
|
|
}
|
|
err = fleet.NewInvalidArgumentError("profile", msg).
|
|
WithStatus(http.StatusConflict)
|
|
}
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{newCP.ProfileUUID}, nil); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "bulk set pending host profiles")
|
|
}
|
|
|
|
var (
|
|
actTeamID *uint
|
|
actTeamName *string
|
|
)
|
|
if teamID > 0 {
|
|
actTeamID = &teamID
|
|
actTeamName = &teamName
|
|
}
|
|
if err := svc.NewActivity(
|
|
ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeCreatedMacosProfile{
|
|
TeamID: actTeamID,
|
|
TeamName: actTeamName,
|
|
ProfileName: newCP.Name,
|
|
ProfileIdentifier: newCP.Identifier,
|
|
}); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "logging activity for create mdm apple config profile")
|
|
}
|
|
|
|
return newCP, nil
|
|
}
|
|
|
|
// CheckProfileIsNotSigned checks if the provided profile data is a signed profile.
|
|
// If it is signed, it returns a BadRequestError indicating that signed profiles
|
|
// are not allowed. If the profile is not signed, it returns nil.
|
|
func CheckProfileIsNotSigned(data []byte) error {
|
|
mc := mobileconfig.Mobileconfig(data)
|
|
if mc.IsSignedProfile() {
|
|
return &fleet.BadRequestError{
|
|
Message: "Couldn't add. Configuration profiles can't be signed. Fleet will sign the profile for you. Learn more: https://fleetdm.com/learn-more-about/unsigning-configuration-profiles",
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateConfigProfileFleetVariables(contents string, lic *fleet.LicenseInfo, groupedCAs *fleet.GroupedCertificateAuthorities) ([]string, error) {
|
|
fleetVars := variables.Find(contents)
|
|
if len(fleetVars) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Validate against all valid Fleet variables in configuration profiles
|
|
for _, fleetVar := range fleetVars {
|
|
if !slices.Contains(fleetVarsSupportedInAppleConfigProfiles, fleet.FleetVarName(fleetVar)) &&
|
|
!strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)) &&
|
|
!strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) &&
|
|
!strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)) &&
|
|
!strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)) &&
|
|
!strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)) &&
|
|
!strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) {
|
|
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("Fleet variable $FLEET_VAR_%s is not supported in configuration profiles.", fleetVar)}
|
|
}
|
|
}
|
|
|
|
err := validateProfileCertificateAuthorityVariables(contents, lic, groupedCAs,
|
|
additionalDigiCertValidation, additionalCustomSCEPValidation, additionalNDESValidation, additionalSmallstepValidation)
|
|
// We avoid checking for all nil here (due to no variables, as we ran our own variable check above.)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return fleetVars, nil
|
|
}
|
|
|
|
// additionalDigiCertValidation checks that Password/ContentType fields match DigiCert Fleet variables exactly,
|
|
// and that these variables are only present in a "com.apple.security.pkcs12" payload
|
|
func additionalDigiCertValidation(contents string, digiCertVars *DigiCertVarsFound) error {
|
|
// Find and replace matches in base64 encoded data contents so we can unmarshal the plist and keep the Fleet vars.
|
|
contents = variables.ProfileDataVariableRegex.ReplaceAllStringFunc(contents, func(match string) string {
|
|
return base64.StdEncoding.EncodeToString([]byte(match))
|
|
})
|
|
|
|
var pkcs12Prof PKCS12ProfileContent
|
|
err := plist.Unmarshal([]byte(contents), &pkcs12Prof)
|
|
if err != nil {
|
|
return &fleet.BadRequestError{Message: fmt.Sprintf("Failed to parse PKCS12 payload with Fleet variables: %s", err.Error())}
|
|
}
|
|
var foundCAs []string
|
|
passwordPrefix := "FLEET_VAR_" + string(fleet.FleetVarDigiCertPasswordPrefix)
|
|
dataPrefix := "FLEET_VAR_" + string(fleet.FleetVarDigiCertDataPrefix)
|
|
for _, payload := range pkcs12Prof.PayloadContent {
|
|
if payload.PayloadType == "com.apple.security.pkcs12" {
|
|
for _, ca := range digiCertVars.CAs() {
|
|
// Check for exact match on password and data
|
|
if payload.Password == "$"+passwordPrefix+ca || payload.Password == "${"+passwordPrefix+ca+"}" {
|
|
if string(payload.PayloadContent) == "$"+dataPrefix+ca || string(payload.PayloadContent) == "${"+dataPrefix+ca+"}" {
|
|
foundCAs = append(foundCAs, ca)
|
|
break
|
|
}
|
|
payloadContent := string(payload.PayloadContent)
|
|
if len(payloadContent) > maxValueCharsInError {
|
|
payloadContent = payloadContent[:maxValueCharsInError] + "..."
|
|
}
|
|
return &fleet.BadRequestError{Message: "CA name mismatch between $" + passwordPrefix + ca + " and " +
|
|
payloadContent + " in PKCS12 payload."}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if len(foundCAs) < len(digiCertVars.CAs()) {
|
|
for _, ca := range digiCertVars.CAs() {
|
|
if !slices.Contains(foundCAs, ca) {
|
|
return &fleet.BadRequestError{Message: fmt.Sprintf("Variables $%s and $%s can only be included in the 'com.apple.security.pkcs12' payload under Password and PayloadContent, respectively.",
|
|
passwordPrefix+ca, dataPrefix+ca)}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type PKCS12ProfileContent struct {
|
|
PayloadContent []PKCS12Payload `plist:"PayloadContent"`
|
|
}
|
|
type PKCS12Payload struct {
|
|
Password string `plist:"Password"`
|
|
PayloadContent PKCS12PayloadContent `plist:"PayloadContent"`
|
|
PayloadType string `plist:"PayloadType"`
|
|
}
|
|
|
|
type PKCS12PayloadContent []byte
|
|
|
|
func (p *PKCS12PayloadContent) UnmarshalPlist(f func(interface{}) error) error {
|
|
var val []byte
|
|
err := f(&val)
|
|
if err != nil {
|
|
// Ignore unmarshalling issues
|
|
return nil
|
|
}
|
|
*p = val
|
|
return nil
|
|
}
|
|
|
|
// additionalCustomSCEPValidation checks that Challenge/URL fields march Custom SCEP Fleet variables
|
|
// exactly, that the SCEP renewal ID variable is present in the CN and that these variables are only
|
|
// present in a "com.apple.security.scep" payload
|
|
func additionalCustomSCEPValidation(contents string, customSCEPVars *CustomSCEPVarsFound) error {
|
|
scepProf, err := unmarshalSCEPProfile(contents)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
scepPayloadContent, err := checkThatOnlyOneSCEPPayloadIsPresent(scepProf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var foundCAs []string
|
|
for _, ca := range customSCEPVars.CAs() {
|
|
// Although this is a loop, we know that we can only have 1 set of SCEP vars because Apple only allows 1 SCEP payload in a profile.
|
|
// Check for the exact match on challenge and URL
|
|
challengePrefix := "FLEET_VAR_" + string(fleet.FleetVarCustomSCEPChallengePrefix)
|
|
if scepPayloadContent.Challenge != "$"+challengePrefix+ca && scepPayloadContent.Challenge != "${"+challengePrefix+ca+"}" {
|
|
payloadChallenge := scepPayloadContent.Challenge
|
|
if len(payloadChallenge) > maxValueCharsInError {
|
|
payloadChallenge = payloadChallenge[:maxValueCharsInError] + "..."
|
|
}
|
|
return &fleet.BadRequestError{
|
|
Message: "Variable \"$FLEET_VAR_" +
|
|
string(fleet.FleetVarCustomSCEPChallengePrefix) + ca + "\" must be in the SCEP certificate's \"Challenge\" field.",
|
|
InternalErr: fmt.Errorf("Challenge: %s", payloadChallenge),
|
|
}
|
|
}
|
|
urlPrefix := "FLEET_VAR_" + string(fleet.FleetVarCustomSCEPProxyURLPrefix)
|
|
if scepPayloadContent.URL != "$"+urlPrefix+ca && scepPayloadContent.URL != "${"+urlPrefix+ca+"}" {
|
|
payloadURL := scepPayloadContent.URL
|
|
if len(payloadURL) > maxValueCharsInError {
|
|
payloadURL = payloadURL[:maxValueCharsInError] + "..."
|
|
}
|
|
return &fleet.BadRequestError{
|
|
Message: "Variable \"$FLEET_VAR_" +
|
|
string(fleet.FleetVarCustomSCEPProxyURLPrefix) + ca + "\" must be in the SCEP certificate's \"URL\" field.",
|
|
InternalErr: fmt.Errorf("URL: %s", payloadURL),
|
|
}
|
|
}
|
|
foundCAs = append(foundCAs, ca)
|
|
}
|
|
if !fleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) {
|
|
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarSCEPRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
|
|
}
|
|
if len(foundCAs) < len(customSCEPVars.CAs()) {
|
|
for _, ca := range customSCEPVars.CAs() {
|
|
if !slices.Contains(foundCAs, ca) {
|
|
return &fleet.BadRequestError{Message: fleet.SCEPVariablesNotInSCEPPayloadErrMsg}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func additionalSmallstepValidation(contents string, smallstepVars *SmallstepVarsFound) error {
|
|
scepProf, err := unmarshalSCEPProfile(contents)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
scepPayloadContent, err := checkThatOnlyOneSCEPPayloadIsPresent(scepProf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var foundCAs []string
|
|
for _, ca := range smallstepVars.CAs() {
|
|
// Although this is a loop, we know that we can only have 1 set of SCEP vars because Apple only allows 1 SCEP payload in a profile.
|
|
// Check for the exact match on challenge and URL
|
|
challengePrefix := "FLEET_VAR_" + string(fleet.FleetVarSmallstepSCEPChallengePrefix)
|
|
if scepPayloadContent.Challenge != "$"+challengePrefix+ca && scepPayloadContent.Challenge != "${"+challengePrefix+ca+"}" {
|
|
payloadChallenge := scepPayloadContent.Challenge
|
|
if len(payloadChallenge) > maxValueCharsInError {
|
|
payloadChallenge = payloadChallenge[:maxValueCharsInError] + "..."
|
|
}
|
|
return &fleet.BadRequestError{
|
|
Message: "Variable \"$FLEET_VAR_" +
|
|
string(fleet.FleetVarSmallstepSCEPChallengePrefix) + ca + "\" must be in the SCEP certificate's \"Challenge\" field.",
|
|
InternalErr: fmt.Errorf("Challenge: %s", payloadChallenge),
|
|
}
|
|
}
|
|
urlPrefix := "FLEET_VAR_" + string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)
|
|
if scepPayloadContent.URL != "$"+urlPrefix+ca && scepPayloadContent.URL != "${"+urlPrefix+ca+"}" {
|
|
payloadURL := scepPayloadContent.URL
|
|
if len(payloadURL) > maxValueCharsInError {
|
|
payloadURL = payloadURL[:maxValueCharsInError] + "..."
|
|
}
|
|
return &fleet.BadRequestError{
|
|
Message: "Variable \"$FLEET_VAR_" +
|
|
string(fleet.FleetVarSmallstepSCEPProxyURLPrefix) + ca + "\" must be in the SCEP certificate's \"URL\" field.",
|
|
InternalErr: fmt.Errorf("URL: %s", payloadURL),
|
|
}
|
|
}
|
|
foundCAs = append(foundCAs, ca)
|
|
}
|
|
if !fleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) {
|
|
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarSCEPRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
|
|
}
|
|
if len(foundCAs) < len(smallstepVars.CAs()) {
|
|
for _, ca := range smallstepVars.CAs() {
|
|
if !slices.Contains(foundCAs, ca) {
|
|
return &fleet.BadRequestError{Message: fleet.SCEPVariablesNotInSCEPPayloadErrMsg}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkThatOnlyOneSCEPPayloadIsPresent(scepProf SCEPProfileContent) (SCEPPayloadContent, error) {
|
|
scepPayloadsFound := 0
|
|
var scepPayloadContent SCEPPayloadContent
|
|
for _, payload := range scepProf.PayloadContent {
|
|
if payload.PayloadType == "com.apple.security.scep" {
|
|
scepPayloadContent = payload.PayloadContent
|
|
scepPayloadsFound++
|
|
}
|
|
}
|
|
if scepPayloadsFound > 1 {
|
|
return SCEPPayloadContent{}, &fleet.BadRequestError{Message: fleet.MultipleSCEPPayloadsErrMsg}
|
|
}
|
|
if scepPayloadsFound == 0 {
|
|
return SCEPPayloadContent{}, &fleet.BadRequestError{Message: fleet.SCEPVariablesNotInSCEPPayloadErrMsg}
|
|
}
|
|
return scepPayloadContent, nil
|
|
}
|
|
|
|
func unmarshalSCEPProfile(contents string) (SCEPProfileContent, error) {
|
|
// Replace any Fleet variables in data fields. SCEP payload does not need them and we cannot unmarshal if they are present.
|
|
contents = variables.ProfileDataVariableRegex.ReplaceAllString(contents, "")
|
|
var scepProf SCEPProfileContent
|
|
err := plist.Unmarshal([]byte(contents), &scepProf)
|
|
if err != nil {
|
|
return SCEPProfileContent{}, &fleet.BadRequestError{Message: fmt.Sprintf("Failed to parse SCEP payload with Fleet variables: %s",
|
|
err.Error())}
|
|
}
|
|
return scepProf, nil
|
|
}
|
|
|
|
type SCEPProfileContent struct {
|
|
PayloadContent []SCEPPayload `plist:"PayloadContent"`
|
|
}
|
|
type SCEPPayload struct {
|
|
PayloadContent SCEPPayloadContent `plist:"PayloadContent"`
|
|
PayloadType string `plist:"PayloadType"`
|
|
}
|
|
type SCEPPayloadContent struct {
|
|
Challenge string
|
|
URL string
|
|
CommonName string
|
|
OrganizationalUnit string
|
|
}
|
|
|
|
func (p *SCEPPayloadContent) UnmarshalPlist(f func(interface{}) error) error {
|
|
val := &struct {
|
|
Challenge string `plist:"Challenge"`
|
|
URL string `plist:"URL"`
|
|
// Subject is an RDN Sequence which is ultimately a nested key-value pair structure with a
|
|
// shape like the one shown below. We just need to extract the CN and OU values from it. While
|
|
// uncommon it is possible for multiple CNs or OUs to be present so we should account for that.
|
|
// Subject: [
|
|
// [
|
|
// [ "CN", "Fleet" ]
|
|
// ],
|
|
// [
|
|
// [ "OU", "Fleet Device Management"]
|
|
// ]
|
|
// ]
|
|
Subject [][][]string
|
|
}{}
|
|
err := f(&val)
|
|
if err != nil {
|
|
// Ignore unmarshalling issues
|
|
*p = SCEPPayloadContent{}
|
|
return nil
|
|
}
|
|
commonName := ""
|
|
organizationalUnit := ""
|
|
for i := 0; i < len(val.Subject); i++ {
|
|
for j := 0; j < len(val.Subject[i]); j++ {
|
|
if len(val.Subject[i][j]) == 2 && val.Subject[i][j][0] == "CN" {
|
|
// adding a separator here in the case of multiple CNs so someting silly like the required var split over
|
|
// multiple CNs gets caught
|
|
if commonName != "" {
|
|
commonName += ","
|
|
}
|
|
commonName += val.Subject[i][j][1]
|
|
}
|
|
if len(val.Subject[i][j]) == 2 && val.Subject[i][j][0] == "OU" {
|
|
if organizationalUnit != "" {
|
|
organizationalUnit += ","
|
|
}
|
|
organizationalUnit += val.Subject[i][j][1]
|
|
}
|
|
}
|
|
}
|
|
*p = SCEPPayloadContent{
|
|
Challenge: val.Challenge,
|
|
URL: val.URL,
|
|
CommonName: commonName,
|
|
OrganizationalUnit: organizationalUnit,
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// additionalNDESValidation checks that Challenge/URL fields match NDES Fleet variables
|
|
// exactly, that the SCEP renewal ID variable is present in the CN, and that these variables are only
|
|
// present in a "com.apple.security.scep" payload
|
|
func additionalNDESValidation(contents string, ndesVars *NDESVarsFound) error {
|
|
scepProf, err := unmarshalSCEPProfile(contents)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
scepPayloadContent, err := checkThatOnlyOneSCEPPayloadIsPresent(scepProf)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !fleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.CommonName) && !fleetVarSCEPRenewalIDRegexp.MatchString(scepPayloadContent.OrganizationalUnit) {
|
|
return &fleet.BadRequestError{Message: "Variable $FLEET_VAR_" + string(fleet.FleetVarSCEPRenewalID) + " must be in the SCEP certificate's organizational unit (OU)."}
|
|
}
|
|
|
|
// Check for the exact match on challenge and URL
|
|
challenge := "FLEET_VAR_" + string(fleet.FleetVarNDESSCEPChallenge)
|
|
if scepPayloadContent.Challenge != "$"+challenge && scepPayloadContent.Challenge != "${"+challenge+"}" {
|
|
payloadChallenge := scepPayloadContent.Challenge
|
|
if len(payloadChallenge) > maxValueCharsInError {
|
|
payloadChallenge = payloadChallenge[:maxValueCharsInError] + "..."
|
|
}
|
|
return &fleet.BadRequestError{
|
|
Message: "Variable \"$FLEET_VAR_" +
|
|
string(fleet.FleetVarNDESSCEPChallenge) + "\" must be in the SCEP certificate's \"Challenge\" field.",
|
|
InternalErr: fmt.Errorf("Challenge: %s", payloadChallenge),
|
|
}
|
|
}
|
|
ndesURL := "FLEET_VAR_" + string(fleet.FleetVarNDESSCEPProxyURL)
|
|
if scepPayloadContent.URL != "$"+ndesURL && scepPayloadContent.URL != "${"+ndesURL+"}" {
|
|
payloadURL := scepPayloadContent.URL
|
|
if len(payloadURL) > maxValueCharsInError {
|
|
payloadURL = payloadURL[:maxValueCharsInError] + "..."
|
|
}
|
|
return &fleet.BadRequestError{
|
|
Message: "Variable \"$FLEET_VAR_" +
|
|
string(fleet.FleetVarNDESSCEPProxyURL) + "\" must be in the SCEP certificate's \"URL\" field.",
|
|
InternalErr: fmt.Errorf("URL: %s", payloadURL),
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *Service) NewMDMAppleDeclaration(ctx context.Context, teamID uint, data []byte, labels []string, name string, labelsMembershipMode fleet.MDMLabelsMode) (*fleet.MDMAppleDeclaration, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: &teamID}, fleet.ActionWrite); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
// check that Apple MDM is enabled - the middleware of that endpoint checks
|
|
// only that any MDM is enabled, maybe it's just Windows
|
|
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
|
|
err := fleet.NewInvalidArgumentError("declaration", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
|
|
return nil, ctxerr.Wrap(ctx, err, "check macOS MDM enabled")
|
|
}
|
|
|
|
fleetNames := mdm_types.FleetReservedProfileNames()
|
|
if _, ok := fleetNames[name]; ok {
|
|
err := fleet.NewInvalidArgumentError("declaration", fmt.Sprintf("Profile name %q is not allowed.", name)).WithStatus(http.StatusBadRequest)
|
|
return nil, err
|
|
}
|
|
|
|
var teamName string
|
|
if teamID >= 1 {
|
|
tm, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, &teamID, nil)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
teamName = tm.Name
|
|
}
|
|
|
|
var tmID *uint
|
|
if teamID >= 1 {
|
|
tmID = &teamID
|
|
}
|
|
|
|
validatedLabels, err := svc.validateDeclarationLabels(ctx, labels, teamID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
dataWithSecrets, secretsUpdatedAt, err := svc.ds.ExpandEmbeddedSecretsAndUpdatedAt(ctx, string(data))
|
|
if err != nil {
|
|
return nil, fleet.NewInvalidArgumentError("profile", err.Error())
|
|
}
|
|
|
|
if err := validateDeclarationFleetVariables(dataWithSecrets); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// TODO(roberto): Maybe GetRawDeclarationValues belongs inside NewMDMAppleDeclaration? We can refactor this in a follow up.
|
|
rawDecl, err := fleet.GetRawDeclarationValues([]byte(dataWithSecrets))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// After validation, we should no longer need to keep the expanded secrets.
|
|
|
|
if err := rawDecl.ValidateUserProvided(svc.config.MDM.EnableCustomOSUpdatesAndFileVault); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
d := fleet.NewMDMAppleDeclaration(data, tmID, name, rawDecl.Type, rawDecl.Identifier)
|
|
d.SecretsUpdatedAt = secretsUpdatedAt
|
|
|
|
switch labelsMembershipMode {
|
|
case fleet.LabelsIncludeAny:
|
|
d.LabelsIncludeAny = validatedLabels
|
|
case fleet.LabelsExcludeAny:
|
|
d.LabelsExcludeAny = validatedLabels
|
|
default:
|
|
// default to include all
|
|
d.LabelsIncludeAll = validatedLabels
|
|
}
|
|
|
|
decl, err := svc.ds.NewMDMAppleDeclaration(ctx, d)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, nil, []string{decl.DeclarationUUID}, nil); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "bulk set pending host declarations")
|
|
}
|
|
|
|
var (
|
|
actTeamID *uint
|
|
actTeamName *string
|
|
)
|
|
if teamID > 0 {
|
|
actTeamID = &teamID
|
|
actTeamName = &teamName
|
|
}
|
|
if err := svc.NewActivity(
|
|
ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeCreatedDeclarationProfile{
|
|
TeamID: actTeamID,
|
|
TeamName: actTeamName,
|
|
ProfileName: decl.Name,
|
|
Identifier: decl.Identifier,
|
|
}); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "logging activity for create mdm apple declaration")
|
|
}
|
|
|
|
return decl, nil
|
|
}
|
|
|
|
func validateDeclarationFleetVariables(contents string) error {
|
|
if variables.Contains(contents) {
|
|
return &fleet.BadRequestError{Message: "Fleet variables ($FLEET_VAR_*) are not currently supported in DDM profiles"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *Service) batchValidateDeclarationLabels(ctx context.Context, labelNames []string, teamID uint) (map[string]fleet.ConfigurationProfileLabel, error) {
|
|
if len(labelNames) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
uniqueNames := server.RemoveDuplicatesFromSlice(labelNames)
|
|
|
|
labels, err := svc.ds.LabelIDsByName(ctx, uniqueNames, fleet.TeamFilter{User: authz.UserFromContext(ctx), TeamID: &teamID})
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting label IDs by name")
|
|
}
|
|
|
|
if len(labels) != len(uniqueNames) {
|
|
labelError := fleet.NewMissingLabelError(uniqueNames, labels)
|
|
return nil, &fleet.BadRequestError{
|
|
InternalErr: labelError,
|
|
Message: fmt.Sprintf("Couldn't update. Label %q doesn't exist. Please remove the label from the configuration profile.", labelError.MissingLabelName),
|
|
}
|
|
}
|
|
|
|
profLabels := make(map[string]fleet.ConfigurationProfileLabel)
|
|
for labelName, labelID := range labels {
|
|
profLabels[labelName] = fleet.ConfigurationProfileLabel{
|
|
LabelName: labelName,
|
|
LabelID: labelID,
|
|
}
|
|
}
|
|
return profLabels, nil
|
|
}
|
|
|
|
func (svc *Service) validateDeclarationLabels(ctx context.Context, labelNames []string, teamID uint) ([]fleet.ConfigurationProfileLabel, error) {
|
|
labelMap, err := svc.batchValidateDeclarationLabels(ctx, labelNames, teamID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "validating declaration labels")
|
|
}
|
|
|
|
var declLabels []fleet.ConfigurationProfileLabel
|
|
for _, label := range labelMap {
|
|
declLabels = append(declLabels, label)
|
|
}
|
|
return declLabels, nil
|
|
}
|
|
|
|
type listMDMAppleConfigProfilesRequest struct {
|
|
TeamID uint `query:"team_id,optional" renameto:"fleet_id"`
|
|
}
|
|
|
|
type listMDMAppleConfigProfilesResponse struct {
|
|
ConfigProfiles []*fleet.MDMAppleConfigProfile `json:"profiles"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r listMDMAppleConfigProfilesResponse) Error() error { return r.Err }
|
|
|
|
func listMDMAppleConfigProfilesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*listMDMAppleConfigProfilesRequest)
|
|
|
|
cps, err := svc.ListMDMAppleConfigProfiles(ctx, req.TeamID)
|
|
if err != nil {
|
|
return &listMDMAppleConfigProfilesResponse{Err: err}, nil
|
|
}
|
|
|
|
res := listMDMAppleConfigProfilesResponse{ConfigProfiles: cps}
|
|
if cps == nil {
|
|
res.ConfigProfiles = []*fleet.MDMAppleConfigProfile{} // return empty json array instead of json null
|
|
}
|
|
return &res, nil
|
|
}
|
|
|
|
func (svc *Service) ListMDMAppleConfigProfiles(ctx context.Context, teamID uint) ([]*fleet.MDMAppleConfigProfile, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: &teamID}, fleet.ActionRead); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
if teamID >= 1 {
|
|
// confirm that team exists
|
|
if _, err := svc.ds.TeamLite(ctx, teamID); err != nil { // TODO see if we can use TeamExists here instead
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
}
|
|
|
|
cps, err := svc.ds.ListMDMAppleConfigProfiles(ctx, &teamID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
return cps, nil
|
|
}
|
|
|
|
type getMDMAppleConfigProfileRequest struct {
|
|
ProfileID uint `url:"profile_id"`
|
|
}
|
|
|
|
type getMDMAppleConfigProfileResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
|
|
// file fields below are used in hijackRender for the response
|
|
fileReader io.ReadCloser
|
|
fileLength int64
|
|
fileName string
|
|
}
|
|
|
|
func (r getMDMAppleConfigProfileResponse) Error() error { return r.Err }
|
|
|
|
func (r getMDMAppleConfigProfileResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
w.Header().Set("Content-Length", strconv.FormatInt(r.fileLength, 10))
|
|
w.Header().Set("Content-Type", "application/x-apple-aspen-config")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename="%s.mobileconfig"`, r.fileName))
|
|
|
|
// OK to just log the error here as writing anything on
|
|
// `http.ResponseWriter` sets the status code to 200 (and it can't be
|
|
// changed.) Clients should rely on matching content-length with the
|
|
// header provided
|
|
wl, err := io.Copy(w, r.fileReader)
|
|
if err != nil {
|
|
logging.WithExtras(ctx, "mobileconfig_copy_error", err, "bytes_copied", wl)
|
|
}
|
|
r.fileReader.Close()
|
|
}
|
|
|
|
func getMDMAppleConfigProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getMDMAppleConfigProfileRequest)
|
|
|
|
cp, err := svc.GetMDMAppleConfigProfileByDeprecatedID(ctx, req.ProfileID)
|
|
if err != nil {
|
|
return getMDMAppleConfigProfileResponse{Err: err}, nil
|
|
}
|
|
reader := bytes.NewReader(cp.Mobileconfig)
|
|
fileName := fmt.Sprintf("%s_%s", time.Now().Format("2006-01-02"), strings.ReplaceAll(cp.Name, " ", "_"))
|
|
|
|
return getMDMAppleConfigProfileResponse{fileReader: io.NopCloser(reader), fileLength: reader.Size(), fileName: fileName}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleConfigProfileByDeprecatedID(ctx context.Context, profileID uint) (*fleet.MDMAppleConfigProfile, error) {
|
|
// first we perform a perform basic authz check
|
|
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cp, err := svc.ds.GetMDMAppleConfigProfileByDeprecatedID(ctx, profileID)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
// call the standard service method with a profile UUID that will not be
|
|
// found, just to ensure the same sequence of validations are applied.
|
|
return svc.GetMDMAppleConfigProfile(ctx, "-")
|
|
}
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
return svc.GetMDMAppleConfigProfile(ctx, cp.ProfileUUID)
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleConfigProfile(ctx context.Context, profileUUID string) (*fleet.MDMAppleConfigProfile, error) {
|
|
// first we perform a perform basic authz check
|
|
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cp, err := svc.ds.GetMDMAppleConfigProfile(ctx, profileUUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
// now we can do a specific authz check based on team id of profile before we return the profile
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: cp.TeamID}, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return cp, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleDeclaration(ctx context.Context, profileUUID string) (*fleet.MDMAppleDeclaration, error) {
|
|
// first we perform a perform basic authz check
|
|
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cp, err := svc.ds.GetMDMAppleDeclaration(ctx, profileUUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
// now we can do a specific authz check based on team id of profile before we return the profile
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: cp.TeamID}, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return cp, nil
|
|
}
|
|
|
|
type deleteMDMAppleConfigProfileRequest struct {
|
|
ProfileID uint `url:"profile_id"`
|
|
}
|
|
|
|
type deleteMDMAppleConfigProfileResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deleteMDMAppleConfigProfileResponse) Error() error { return r.Err }
|
|
|
|
func deleteMDMAppleConfigProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*deleteMDMAppleConfigProfileRequest)
|
|
|
|
if err := svc.DeleteMDMAppleConfigProfileByDeprecatedID(ctx, req.ProfileID); err != nil {
|
|
return &deleteMDMAppleConfigProfileResponse{Err: err}, nil
|
|
}
|
|
|
|
return &deleteMDMAppleConfigProfileResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) DeleteMDMAppleConfigProfileByDeprecatedID(ctx context.Context, profileID uint) error {
|
|
// first we perform a perform basic authz check
|
|
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
// get the profile by ID and call the standard delete function
|
|
cp, err := svc.ds.GetMDMAppleConfigProfileByDeprecatedID(ctx, profileID)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
// call the standard service method with a profile UUID that will not be
|
|
// found, just to ensure the same sequence of validations are applied.
|
|
return svc.DeleteMDMAppleConfigProfile(ctx, "-")
|
|
}
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
return svc.DeleteMDMAppleConfigProfile(ctx, cp.ProfileUUID)
|
|
}
|
|
|
|
func (svc *Service) DeleteMDMAppleConfigProfile(ctx context.Context, profileUUID string) error {
|
|
// first we perform a perform basic authz check
|
|
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
cp, err := svc.ds.GetMDMAppleConfigProfile(ctx, profileUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
var teamName string
|
|
teamID := *cp.TeamID
|
|
if teamID >= 1 {
|
|
tm, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, &teamID, nil)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
teamName = tm.Name
|
|
}
|
|
|
|
// now we can do a specific authz check based on team id of profile before we delete the profile
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: cp.TeamID}, fleet.ActionWrite); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
// prevent deleting profiles that are managed by Fleet
|
|
if _, ok := mobileconfig.FleetPayloadIdentifiers()[cp.Identifier]; ok {
|
|
return &fleet.BadRequestError{
|
|
Message: "profiles managed by Fleet can't be deleted using this endpoint.",
|
|
InternalErr: fmt.Errorf("deleting profile %s for team %s not allowed because it's managed by Fleet", cp.Identifier, teamName),
|
|
}
|
|
}
|
|
|
|
// This call will also delete host_mdm_apple_profiles references IFF the profile has not been sent to
|
|
// the host yet.
|
|
if err := svc.ds.DeleteMDMAppleConfigProfile(ctx, profileUUID); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
var (
|
|
actTeamID *uint
|
|
actTeamName *string
|
|
)
|
|
if teamID > 0 {
|
|
actTeamID = &teamID
|
|
actTeamName = &teamName
|
|
}
|
|
if err := svc.NewActivity(
|
|
ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeDeletedMacosProfile{
|
|
TeamID: actTeamID,
|
|
TeamName: actTeamName,
|
|
ProfileName: cp.Name,
|
|
ProfileIdentifier: cp.Identifier,
|
|
}); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "logging activity for delete mdm apple config profile")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (svc *Service) DeleteMDMAppleDeclaration(ctx context.Context, declUUID string) error {
|
|
// first we perform a perform basic authz check
|
|
if err := svc.authz.Authorize(ctx, &fleet.Team{}, fleet.ActionRead); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
decl, err := svc.ds.GetMDMAppleDeclaration(ctx, declUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
// Check if the declaration contains a secret variable. If it does, this means that the declaration
|
|
// has been provided by the user and can be deleted. We don't need to validate that it is a Fleet declaration.
|
|
hasSecretVariable := len(fleet.ContainsPrefixVars(string(decl.RawJSON), fleet.ServerSecretPrefix)) > 0
|
|
if !hasSecretVariable {
|
|
if _, ok := mdm_types.FleetReservedProfileNames()[decl.Name]; ok {
|
|
return &fleet.BadRequestError{
|
|
Message: "profiles managed by Fleet can't be deleted using this endpoint.",
|
|
InternalErr: fmt.Errorf("deleting profile %s is not allowed because it's managed by Fleet", decl.Name),
|
|
}
|
|
}
|
|
|
|
// TODO: refine our approach to deleting restricted/forbidden types of declarations so that we
|
|
// can check that Fleet-managed aren't being deleted; this can be addressed once we add support
|
|
// for more types of declarations
|
|
var d fleet.MDMAppleRawDeclaration
|
|
if err := json.Unmarshal(decl.RawJSON, &d); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "unmarshalling declaration")
|
|
}
|
|
if err := d.ValidateUserProvided(svc.config.MDM.EnableCustomOSUpdatesAndFileVault); err != nil {
|
|
return ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: err.Error()})
|
|
}
|
|
}
|
|
|
|
var teamName string
|
|
teamID := *decl.TeamID
|
|
if teamID >= 1 {
|
|
tm, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, &teamID, nil)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
teamName = tm.Name
|
|
}
|
|
|
|
// now we can do a specific authz check based on team id of profile before we delete the profile
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: decl.TeamID}, fleet.ActionWrite); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
if err := svc.ds.DeleteMDMAppleDeclaration(ctx, declUUID); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
var (
|
|
actTeamID *uint
|
|
actTeamName *string
|
|
)
|
|
if teamID > 0 {
|
|
actTeamID = &teamID
|
|
actTeamName = &teamName
|
|
}
|
|
if err := svc.NewActivity(
|
|
ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeDeletedDeclarationProfile{
|
|
TeamID: actTeamID,
|
|
TeamName: actTeamName,
|
|
ProfileName: decl.Name,
|
|
Identifier: decl.Identifier,
|
|
}); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "logging activity for delete mdm apple declaration")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type getMDMAppleFileVaultSummaryRequest struct {
|
|
TeamID *uint `query:"team_id,optional" renameto:"fleet_id"`
|
|
}
|
|
|
|
type getMDMAppleFileVaultSummaryResponse struct {
|
|
*fleet.MDMAppleFileVaultSummary
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getMDMAppleFileVaultSummaryResponse) Error() error { return r.Err }
|
|
|
|
func getMdmAppleFileVaultSummaryEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getMDMAppleFileVaultSummaryRequest)
|
|
|
|
fvs, err := svc.GetMDMAppleFileVaultSummary(ctx, req.TeamID)
|
|
if err != nil {
|
|
return &getMDMAppleFileVaultSummaryResponse{Err: err}, nil
|
|
}
|
|
|
|
return &getMDMAppleFileVaultSummaryResponse{
|
|
MDMAppleFileVaultSummary: fvs,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleFileVaultSummary(ctx context.Context, teamID *uint) (*fleet.MDMAppleFileVaultSummary, error) {
|
|
if err := svc.authz.Authorize(ctx, fleet.MDMConfigProfileAuthz{TeamID: teamID}, fleet.ActionRead); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
fvs, err := svc.ds.GetMDMAppleFileVaultSummary(ctx, teamID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
return fvs, nil
|
|
}
|
|
|
|
type getMDMAppleProfilesSummaryRequest struct {
|
|
TeamID *uint `query:"team_id,optional" renameto:"fleet_id"`
|
|
}
|
|
|
|
type getMDMAppleProfilesSummaryResponse struct {
|
|
fleet.MDMProfilesSummary
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getMDMAppleProfilesSummaryResponse) Error() error { return r.Err }
|
|
|
|
func getMDMAppleProfilesSummaryEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getMDMAppleProfilesSummaryRequest)
|
|
res := getMDMAppleProfilesSummaryResponse{}
|
|
|
|
ps, err := svc.GetMDMAppleProfilesSummary(ctx, req.TeamID)
|
|
if err != nil {
|
|
return &getMDMAppleProfilesSummaryResponse{Err: err}, nil
|
|
}
|
|
|
|
res.Verified = ps.Verified
|
|
res.Verifying = ps.Verifying
|
|
res.Failed = ps.Failed
|
|
res.Pending = ps.Pending
|
|
|
|
return &res, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleProfilesSummary(ctx context.Context, teamID *uint) (*fleet.MDMProfilesSummary, error) {
|
|
if err := svc.authz.Authorize(ctx, fleet.MDMConfigProfileAuthz{TeamID: teamID}, fleet.ActionRead); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
|
|
return &fleet.MDMProfilesSummary{}, nil
|
|
}
|
|
|
|
ps, err := svc.ds.GetMDMAppleProfilesSummary(ctx, teamID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
return ps, nil
|
|
}
|
|
|
|
type uploadAppleInstallerRequest struct {
|
|
Installer *multipart.FileHeader
|
|
}
|
|
|
|
type uploadAppleInstallerResponse struct {
|
|
ID uint `json:"installer_id"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (uploadAppleInstallerRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
err := r.ParseMultipartForm(platform_http.MaxMultipartFormSize)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "failed to parse multipart form",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
installer := r.MultipartForm.File["installer"][0]
|
|
return &uploadAppleInstallerRequest{
|
|
Installer: installer,
|
|
}, nil
|
|
}
|
|
|
|
func (r uploadAppleInstallerResponse) Error() error { return r.Err }
|
|
|
|
// Deprecated: Not in Use
|
|
func uploadAppleInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*uploadAppleInstallerRequest)
|
|
ff, err := req.Installer.Open()
|
|
if err != nil {
|
|
return uploadAppleInstallerResponse{Err: err}, nil
|
|
}
|
|
defer ff.Close()
|
|
installer, err := svc.UploadMDMAppleInstaller(ctx, req.Installer.Filename, req.Installer.Size, ff)
|
|
if err != nil {
|
|
return uploadAppleInstallerResponse{Err: err}, nil
|
|
}
|
|
return &uploadAppleInstallerResponse{
|
|
ID: installer.ID,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) UploadMDMAppleInstaller(ctx context.Context, name string, size int64, installer io.Reader) (*fleet.MDMAppleInstaller, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMAppleInstaller{}, fleet.ActionWrite); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
appConfig, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
token := uuid.New().String()
|
|
|
|
url := svc.installerURL(token, appConfig)
|
|
|
|
var installerBuf bytes.Buffer
|
|
manifest, err := createManifest(size, io.TeeReader(installer, &installerBuf), url)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
inst, err := svc.ds.NewMDMAppleInstaller(ctx, name, size, manifest, installerBuf.Bytes(), token)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
return inst, nil
|
|
}
|
|
|
|
func (svc *Service) installerURL(token string, appConfig *fleet.AppConfig) string {
|
|
return fmt.Sprintf("%s%s?token=%s", appConfig.ServerSettings.ServerURL, apple_mdm.InstallerPath, token)
|
|
}
|
|
|
|
func createManifest(size int64, installer io.Reader, url string) (string, error) {
|
|
manifest, err := appmanifest.New(&readerWithSize{
|
|
Reader: installer,
|
|
size: size,
|
|
}, url)
|
|
if err != nil {
|
|
return "", fmt.Errorf("create manifest file: %w", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
enc := plist.NewEncoder(&buf)
|
|
enc.Indent(" ")
|
|
if err := enc.Encode(manifest); err != nil {
|
|
return "", fmt.Errorf("encode manifest: %w", err)
|
|
}
|
|
return buf.String(), nil
|
|
}
|
|
|
|
type readerWithSize struct {
|
|
io.Reader
|
|
size int64
|
|
}
|
|
|
|
func (r *readerWithSize) Size() int64 {
|
|
return r.size
|
|
}
|
|
|
|
type getAppleInstallerDetailsRequest struct {
|
|
ID uint `url:"installer_id"`
|
|
}
|
|
|
|
type getAppleInstallerDetailsResponse struct {
|
|
Installer *fleet.MDMAppleInstaller
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getAppleInstallerDetailsResponse) Error() error { return r.Err }
|
|
|
|
func getAppleInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getAppleInstallerDetailsRequest)
|
|
installer, err := svc.GetMDMAppleInstallerByID(ctx, req.ID)
|
|
if err != nil {
|
|
return getAppleInstallerDetailsResponse{Err: err}, nil
|
|
}
|
|
return &getAppleInstallerDetailsResponse{
|
|
Installer: installer,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleInstallerByID(ctx context.Context, id uint) (*fleet.MDMAppleInstaller, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMAppleInstaller{}, fleet.ActionWrite); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
inst, err := svc.ds.MDMAppleInstallerDetailsByID(ctx, id)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
return inst, nil
|
|
}
|
|
|
|
type deleteAppleInstallerDetailsRequest struct {
|
|
ID uint `url:"installer_id"`
|
|
}
|
|
|
|
type deleteAppleInstallerDetailsResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deleteAppleInstallerDetailsResponse) Error() error { return r.Err }
|
|
|
|
func deleteAppleInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*deleteAppleInstallerDetailsRequest)
|
|
if err := svc.DeleteMDMAppleInstaller(ctx, req.ID); err != nil {
|
|
return deleteAppleInstallerDetailsResponse{Err: err}, nil
|
|
}
|
|
return &deleteAppleInstallerDetailsResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) DeleteMDMAppleInstaller(ctx context.Context, id uint) error {
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMAppleInstaller{}, fleet.ActionWrite); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
if err := svc.ds.DeleteMDMAppleInstaller(ctx, id); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type listMDMAppleDevicesRequest struct{}
|
|
|
|
type listMDMAppleDevicesResponse struct {
|
|
Devices []fleet.MDMAppleDevice `json:"devices"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r listMDMAppleDevicesResponse) Error() error { return r.Err }
|
|
|
|
func listMDMAppleDevicesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
devices, err := svc.ListMDMAppleDevices(ctx)
|
|
if err != nil {
|
|
return listMDMAppleDevicesResponse{Err: err}, nil
|
|
}
|
|
return &listMDMAppleDevicesResponse{
|
|
Devices: devices,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) ListMDMAppleDevices(ctx context.Context) ([]fleet.MDMAppleDevice, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMAppleDevice{}, fleet.ActionWrite); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
return svc.ds.MDMAppleListDevices(ctx)
|
|
}
|
|
|
|
type newMDMAppleDEPKeyPairResponse struct {
|
|
PublicKey []byte `json:"public_key,omitempty"`
|
|
PrivateKey []byte `json:"private_key,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r newMDMAppleDEPKeyPairResponse) Error() error { return r.Err }
|
|
|
|
func newMDMAppleDEPKeyPairEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
keyPair, err := svc.NewMDMAppleDEPKeyPair(ctx)
|
|
if err != nil {
|
|
return newMDMAppleDEPKeyPairResponse{
|
|
Err: err,
|
|
}, nil
|
|
}
|
|
|
|
return newMDMAppleDEPKeyPairResponse{
|
|
PublicKey: keyPair.PublicKey,
|
|
PrivateKey: keyPair.PrivateKey,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) NewMDMAppleDEPKeyPair(ctx context.Context) (*fleet.MDMAppleDEPKeyPair, error) {
|
|
// skipauth: Generating a new key pair does not actually make any changes to fleet, or expose any
|
|
// information. The user must configure fleet with the new key pair and restart the server.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
publicKeyPEM, privateKeyPEM, err := apple_mdm.NewDEPKeyPairPEM()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate key pair: %w", err)
|
|
}
|
|
|
|
return &fleet.MDMAppleDEPKeyPair{
|
|
PublicKey: publicKeyPEM,
|
|
PrivateKey: privateKeyPEM,
|
|
}, nil
|
|
}
|
|
|
|
type enqueueMDMAppleCommandRequest struct {
|
|
Command string `json:"command"`
|
|
DeviceIDs []string `json:"device_ids"`
|
|
}
|
|
|
|
type enqueueMDMAppleCommandResponse struct {
|
|
*fleet.CommandEnqueueResult
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r enqueueMDMAppleCommandResponse) Error() error { return r.Err }
|
|
|
|
// Deprecated: enqueueMDMAppleCommandEndpoint is now deprecated, replaced by
|
|
// the platform-agnostic runMDMCommandEndpoint. It is still supported
|
|
// indefinitely for backwards compatibility.
|
|
func enqueueMDMAppleCommandEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*enqueueMDMAppleCommandRequest)
|
|
result, err := svc.EnqueueMDMAppleCommand(ctx, req.Command, req.DeviceIDs)
|
|
if err != nil {
|
|
return enqueueMDMAppleCommandResponse{Err: err}, nil
|
|
}
|
|
return enqueueMDMAppleCommandResponse{
|
|
CommandEnqueueResult: result,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) EnqueueMDMAppleCommand(
|
|
ctx context.Context,
|
|
rawBase64Cmd string,
|
|
deviceIDs []string,
|
|
) (result *fleet.CommandEnqueueResult, err error) {
|
|
hosts, err := svc.authorizeAllHostsTeams(ctx, deviceIDs, fleet.ActionWrite, &fleet.MDMCommandAuthz{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(hosts) == 0 {
|
|
return nil, newNotFoundError()
|
|
}
|
|
|
|
// using a padding agnostic decoder because we released this using
|
|
// base64.RawStdEncoding, but it was causing problems as many standard
|
|
// libraries default to padded strings. We're now supporting both for
|
|
// backwards compatibility.
|
|
rawXMLCmd, err := server.Base64DecodePaddingAgnostic(rawBase64Cmd)
|
|
if err != nil {
|
|
err = fleet.NewInvalidArgumentError("command", "unable to decode base64 command").WithStatus(http.StatusBadRequest)
|
|
|
|
return nil, ctxerr.Wrap(ctx, err, "decode base64 command")
|
|
}
|
|
|
|
return svc.enqueueAppleMDMCommand(ctx, rawXMLCmd, deviceIDs)
|
|
}
|
|
|
|
type mdmAppleEnrollRequest struct {
|
|
// Token is expected to be a UUID string that identifies a template MDM Apple enrollment profile.
|
|
Token string `query:"token"`
|
|
// EnrollmentReference is expected to be a UUID string that identifies the MDM IdP account used
|
|
// to authenticate the end user as part of the MDM IdP flow.
|
|
EnrollmentReference string `query:"enrollment_reference,optional"`
|
|
// DeviceInfo is expected to be a base64 encoded string extracted during MDM IdP enrollment from the
|
|
// x-apple-aspen-deviceinfo header of the original configuration web view request and
|
|
// persisted by the client in local storage for inclusion in a subsequent enrollment request as
|
|
// part of the MDM IdP flow.
|
|
// See https://developer.apple.com/documentation/devicemanagement/device_assignment/authenticating_through_web_views
|
|
DeviceInfo string `query:"deviceinfo,optional"`
|
|
// MachineInfo is the decoded deviceinfo URL query param for MDM IdP enrollments or the decoded
|
|
// x-apple-aspen-deviceinfo header for non-IdP enrollments.
|
|
MachineInfo *fleet.MDMAppleMachineInfo
|
|
}
|
|
|
|
func (mdmAppleEnrollRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
decoded := mdmAppleEnrollRequest{}
|
|
|
|
tok := r.URL.Query().Get("token")
|
|
if tok == "" {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "token is required",
|
|
}
|
|
}
|
|
decoded.Token = tok
|
|
|
|
er := r.URL.Query().Get("enrollment_reference")
|
|
decoded.EnrollmentReference = er
|
|
|
|
// Parse the machine info from the request header or URL query param.
|
|
di := r.Header.Get("x-apple-aspen-deviceinfo")
|
|
if di == "" {
|
|
vals, err := url.ParseQuery(r.URL.RawQuery)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "unable to parse query string",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
di = vals.Get("deviceinfo")
|
|
decoded.DeviceInfo = di
|
|
}
|
|
|
|
if di != "" {
|
|
// parse the base64 encoded deviceinfo
|
|
parsed, err := apple_mdm.ParseDeviceinfo(di, false) // FIXME: use verify=true when we have better parsing for various Apple certs (https://github.com/fleetdm/fleet/issues/20879)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "unable to parse deviceinfo header",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
decoded.MachineInfo = parsed
|
|
}
|
|
|
|
if decoded.MachineInfo == nil && r.Header.Get("Content-Type") == "application/pkcs7-signature" {
|
|
defer r.Body.Close()
|
|
// We limit the amount we read since this is an untrusted HTTP request -- a potential DoS attack from huge payloads.
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, limit10KiB))
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "unable to read request body",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
// FIXME: use verify=true when we have better parsing for various Apple certs (https://github.com/fleetdm/fleet/issues/20879)
|
|
decoded.MachineInfo, err = apple_mdm.ParseMachineInfoFromPKCS7(body, false)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "unable to parse machine info",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
}
|
|
|
|
return &decoded, nil
|
|
}
|
|
|
|
func (r mdmAppleEnrollResponse) Error() error { return r.Err }
|
|
|
|
type mdmAppleEnrollResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
|
|
// Profile field is used in HijackRender for the response.
|
|
Profile []byte
|
|
|
|
SoftwareUpdateRequired *fleet.MDMAppleSoftwareUpdateRequired
|
|
}
|
|
|
|
func (r mdmAppleEnrollResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
if r.SoftwareUpdateRequired != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
if err := json.NewEncoder(w).Encode(r.SoftwareUpdateRequired); err != nil {
|
|
encodeError(ctx, ctxerr.New(ctx, "failed to encode software update required"), w)
|
|
}
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(r.Profile)), 10))
|
|
w.Header().Set("Content-Type", "application/x-apple-aspen-config")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("Content-Disposition", "attachment;fleet-enrollment-profile.mobileconfig")
|
|
|
|
// OK to just log the error here as writing anything on
|
|
// `http.ResponseWriter` sets the status code to 200 (and it can't be
|
|
// changed.) Clients should rely on matching content-length with the
|
|
// header provided.
|
|
if n, err := w.Write(r.Profile); err != nil {
|
|
logging.WithExtras(ctx, "err", err, "written", n)
|
|
}
|
|
}
|
|
|
|
func mdmAppleEnrollEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*mdmAppleEnrollRequest)
|
|
|
|
if req.DeviceInfo == "" {
|
|
// This is a non-IdP enrollment, so we need to check the OS version here. For IdP enrollments
|
|
// os version checks is performed by the frontend MDM enrollment handler.
|
|
sur, err := svc.CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx, req.MachineInfo)
|
|
if err != nil {
|
|
return mdmAppleEnrollResponse{Err: err}, nil
|
|
}
|
|
if sur != nil {
|
|
return mdmAppleEnrollResponse{
|
|
SoftwareUpdateRequired: sur,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
legacyRef, err := svc.ReconcileMDMAppleEnrollRef(ctx, req.EnrollmentReference, req.MachineInfo)
|
|
if err != nil {
|
|
return mdmAppleEnrollResponse{Err: err}, nil
|
|
}
|
|
|
|
profile, err := svc.GetMDMAppleEnrollmentProfileByToken(ctx, req.Token, legacyRef)
|
|
if err != nil {
|
|
return mdmAppleEnrollResponse{Err: err}, nil
|
|
}
|
|
return mdmAppleEnrollResponse{
|
|
Profile: profile,
|
|
}, nil
|
|
}
|
|
|
|
// This endpoint gets called twice by the Apple account driven enrollment flow. The first time it
|
|
// is called without a bearer token which results in a 401 Unauthorized response where we tell it
|
|
// to go through MDM SSO End User Authentication. The second time it is called with a bearer token,
|
|
// in this case an enrollment reference which is used to fetch the enrollment profile. The device
|
|
// then has the user sign in with the Apple ID specified in the enrollment profile
|
|
func mdmAppleAccountEnrollEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*mdmAppleAccountEnrollRequest)
|
|
svc.SkipAuth(ctx)
|
|
deviceProduct := strings.ToLower(req.DeviceInfo.Product)
|
|
if !(strings.HasPrefix(deviceProduct, "ipad") || strings.HasPrefix(deviceProduct, "iphone") || strings.HasPrefix(deviceProduct, "ipod")) {
|
|
// There is unfortunately no good way to get the client to show this error, they will see a
|
|
// generic error about a failure to get an enrollment profile.
|
|
return mdmAppleEnrollResponse{
|
|
Err: &fleet.BadRequestError{
|
|
Message: "only iOS and iPadOS devices are supported for account driven user enrollment",
|
|
},
|
|
}, nil
|
|
}
|
|
if req.EnrollReference == nil {
|
|
mdmSSOUrl, err := svc.GetMDMAccountDrivenEnrollmentSSOURL(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
return mdmAppleAccountEnrollAuthenticateResponse{mdmSSOUrl: mdmSSOUrl}, nil
|
|
}
|
|
|
|
// Fetch the enrollment reference
|
|
profile, err := svc.GetMDMAppleAccountEnrollmentProfile(ctx, *req.EnrollReference)
|
|
if err != nil {
|
|
return mdmAppleEnrollResponse{Err: err}, nil
|
|
}
|
|
return mdmAppleEnrollResponse{Profile: profile}, nil
|
|
}
|
|
|
|
type mdmAppleAccountEnrollRequest struct {
|
|
EnrollReference *string
|
|
DeviceInfo fleet.MDMAppleAccountDrivenUserEnrollDeviceInfo
|
|
}
|
|
|
|
func (mdmAppleAccountEnrollRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
decoded := mdmAppleAccountEnrollRequest{}
|
|
|
|
rawData, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "reading body from request")
|
|
}
|
|
|
|
p7, err := pkcs7.Parse(rawData)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "invalid request body",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
deviceInfo := fleet.MDMAppleAccountDrivenUserEnrollDeviceInfo{}
|
|
|
|
err = plist.Unmarshal(p7.Content, &deviceInfo)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "invalid request body",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
decoded.DeviceInfo = deviceInfo
|
|
|
|
auth := r.Header.Get("Authorization")
|
|
if strings.HasPrefix(auth, "Bearer ") {
|
|
decoded.EnrollReference = ptr.String(strings.Split(auth, "Bearer ")[1])
|
|
}
|
|
|
|
return &decoded, nil
|
|
}
|
|
|
|
type mdmAppleAccountEnrollAuthenticateResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
mdmSSOUrl string
|
|
}
|
|
|
|
func (r mdmAppleAccountEnrollAuthenticateResponse) Error() error { return r.Err }
|
|
|
|
func (r mdmAppleAccountEnrollAuthenticateResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
w.Header().Set("WWW-Authenticate",
|
|
`Bearer method="apple-as-web" `+
|
|
`url="`+r.mdmSSOUrl+`"`,
|
|
)
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
}
|
|
|
|
func (svc *Service) SkipAuth(ctx context.Context) {
|
|
svc.authz.SkipAuthorization(ctx)
|
|
}
|
|
|
|
func (svc *Service) GetMDMAccountDrivenEnrollmentSSOURL(ctx context.Context) (string, error) {
|
|
// skipauth: The enroll profile endpoint is unauthenticated.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
appConfig, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return "", ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
return appConfig.MDMUrl() + "/mdm/apple/account_driven_enroll/sso", nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleAccountEnrollmentProfile(ctx context.Context, enrollRef string) (profile []byte, err error) {
|
|
// skipauth: This enrollment endpoint is authenticated only by the enrollment reference.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
idpAccount, err := svc.ds.GetMDMIdPAccountByUUID(ctx, enrollRef)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting MDM IdP account by UUID")
|
|
}
|
|
|
|
appConfig, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
topic, err := svc.mdmPushCertTopic(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "extracting topic from APNs cert")
|
|
}
|
|
|
|
assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
|
|
fleet.MDMAssetSCEPChallenge,
|
|
}, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading SCEP challenge from the database: %w", err)
|
|
}
|
|
enrollURL := appConfig.MDMUrl()
|
|
|
|
enrollmentProf, err := apple_mdm.GenerateAccountDrivenEnrollmentProfileMobileconfig(
|
|
appConfig.OrgInfo.OrgName,
|
|
enrollURL,
|
|
string(assets[fleet.MDMAssetSCEPChallenge].Value),
|
|
topic,
|
|
idpAccount.Email,
|
|
)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "generating enrollment profile")
|
|
}
|
|
|
|
signed, err := mdmcrypto.Sign(ctx, enrollmentProf, svc.ds)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "signing profile")
|
|
}
|
|
|
|
return signed, nil
|
|
}
|
|
|
|
func (svc *Service) ReconcileMDMAppleEnrollRef(ctx context.Context, enrollRef string, machineInfo *fleet.MDMAppleMachineInfo) (string, error) {
|
|
if machineInfo == nil {
|
|
// TODO: what to do here? We can't reconcile the enroll ref without machine info
|
|
level.Info(svc.logger).Log("msg", "missing machine info, failing enroll ref check", "enroll_ref", enrollRef)
|
|
return "", &fleet.BadRequestError{
|
|
Message: "missing deviceinfo",
|
|
}
|
|
}
|
|
|
|
legacyRef, err := svc.ds.ReconcileMDMAppleEnrollRef(ctx, enrollRef, machineInfo)
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return "", ctxerr.Wrap(ctx, err, "check legacy enroll ref")
|
|
}
|
|
level.Info(svc.logger).Log("msg", "check legacy enroll ref", "host_uuid", machineInfo.UDID, "legacy_enroll_ref", legacyRef)
|
|
|
|
return legacyRef, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleEnrollmentProfileByToken(ctx context.Context, token string, ref string) (profile []byte, err error) {
|
|
// skipauth: The enroll profile endpoint is unauthenticated.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
_, err = svc.ds.GetMDMAppleEnrollmentProfileByToken(ctx, token)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
return nil, fleet.NewAuthFailedError("enrollment profile not found")
|
|
}
|
|
return nil, ctxerr.Wrap(ctx, err, "get enrollment profile")
|
|
}
|
|
|
|
appConfig, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
enrollURL, err := apple_mdm.AddEnrollmentRefToFleetURL(appConfig.MDMUrl(), ref)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "adding reference to fleet URL")
|
|
}
|
|
|
|
topic, err := svc.mdmPushCertTopic(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "extracting topic from APNs cert")
|
|
}
|
|
|
|
assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
|
|
fleet.MDMAssetSCEPChallenge,
|
|
}, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading SCEP challenge from the database: %w", err)
|
|
}
|
|
enrollmentProf, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
|
|
appConfig.OrgInfo.OrgName,
|
|
enrollURL,
|
|
string(assets[fleet.MDMAssetSCEPChallenge].Value),
|
|
topic,
|
|
)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "generating enrollment profile")
|
|
}
|
|
|
|
signed, err := mdmcrypto.Sign(ctx, enrollmentProf, svc.ds)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "signing profile")
|
|
}
|
|
|
|
return signed, nil
|
|
}
|
|
|
|
func (svc *Service) CheckMDMAppleEnrollmentWithMinimumOSVersion(ctx context.Context, m *fleet.MDMAppleMachineInfo) (*fleet.MDMAppleSoftwareUpdateRequired, error) {
|
|
// skipauth: The enroll profile endpoint is unauthenticated.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
if m == nil {
|
|
level.Debug(svc.logger).Log("msg", "no machine info, skipping os version check")
|
|
return nil, nil
|
|
}
|
|
|
|
level.Debug(svc.logger).Log("msg", "checking os version", "serial", m.Serial, "current_version", m.OSVersion)
|
|
|
|
if !m.MDMCanRequestSoftwareUpdate {
|
|
level.Debug(svc.logger).Log("msg", "mdm cannot request software update, skipping os version check", "serial", m.Serial)
|
|
return nil, nil
|
|
}
|
|
|
|
needsUpdate, err := svc.needsOSUpdateForDEPEnrollment(ctx, *m)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "checking os updates settings", "serial", m.Serial)
|
|
}
|
|
|
|
if !needsUpdate {
|
|
level.Debug(svc.logger).Log("msg", "device is above minimum or update new host not checked, skipping os version check", "serial", m.Serial)
|
|
return nil, nil
|
|
}
|
|
|
|
sur, err := svc.getAppleSoftwareUpdateRequiredForDEPEnrollment(*m)
|
|
if err != nil {
|
|
// log for debugging but allow enrollment to proceed
|
|
level.Info(svc.logger).Log("msg", "getting apple software update required", "serial", m.Serial, "err", err)
|
|
return nil, nil
|
|
}
|
|
|
|
return sur, nil
|
|
}
|
|
|
|
func (svc *Service) needsOSUpdateForDEPEnrollment(ctx context.Context, m fleet.MDMAppleMachineInfo) (bool, error) {
|
|
// NOTE: Under the hood, the datastore is joining host_dep_assignments to the hosts table to
|
|
// look up DEP hosts by serial number. It grabs the team id and platform from the
|
|
// hosts table. Then it uses the team id to get either the global config or team config.
|
|
// Finally, it uses the platform to get os updates settings from the config for
|
|
// one of ios, ipados, or darwin, as applicable. There's a lot of assumptions going on here, not
|
|
// least of which is that the platform is correct in the hosts table. If the platform is wrong,
|
|
// we'll end up with a meaningless comparison of unrelated versions. We could potentially add
|
|
// some cross-check against the machine info to ensure that the platform of the host aligns with
|
|
// what we expect from the machine info. But that would involve work to derive the platform from
|
|
// the machine info (presumably from the product name, but that's not a 1:1 mapping).
|
|
platform, settings, err := svc.ds.GetMDMAppleOSUpdatesSettingsByHostSerial(ctx, m.Serial)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
level.Info(svc.logger).Log(
|
|
"msg", "checking os updates settings, settings not found",
|
|
"serial", m.Serial,
|
|
)
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
|
|
minVersion := settings.MinimumVersion.Value
|
|
hasMinVersion := settings.MinimumVersion.Set && settings.MinimumVersion.Valid && minVersion != ""
|
|
|
|
// For macOS hosts, whether to update new hosts during DEP enrollment is determined solely by UpdateNewHosts
|
|
if platform == "darwin" {
|
|
updateNewHosts := settings.UpdateNewHosts.Set && settings.UpdateNewHosts.Valid && settings.UpdateNewHosts.Value
|
|
|
|
level.Info(svc.logger).Log(
|
|
"msg", "checking os updates settings for macos, update will be forced if UpdateNewHosts is set",
|
|
"update_new_hosts", updateNewHosts,
|
|
"serial", m.Serial,
|
|
)
|
|
return updateNewHosts, nil
|
|
}
|
|
|
|
// TODO: confirm what this check should do
|
|
if !hasMinVersion {
|
|
level.Info(svc.logger).Log(
|
|
"msg", "checking os updates settings, minimum version not set",
|
|
"serial", m.Serial,
|
|
"current_version", m.OSVersion,
|
|
"minimum_version", minVersion,
|
|
)
|
|
}
|
|
|
|
needsUpdate, err := apple_mdm.IsLessThanVersion(m.OSVersion, minVersion)
|
|
if err != nil {
|
|
level.Info(svc.logger).Log(
|
|
"msg", "checking os updates settings, cannot compare versions",
|
|
"serial", m.Serial,
|
|
"current_version", m.OSVersion,
|
|
"minimum_version", minVersion,
|
|
)
|
|
return false, nil
|
|
}
|
|
|
|
return needsUpdate, nil
|
|
}
|
|
|
|
func (svc *Service) getAppleSoftwareUpdateRequiredForDEPEnrollment(m fleet.MDMAppleMachineInfo) (*fleet.MDMAppleSoftwareUpdateRequired, error) {
|
|
latest, err := gdmf.GetLatestOSVersion(m)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
needsUpdate, err := apple_mdm.IsLessThanVersion(m.OSVersion, latest.ProductVersion)
|
|
if err != nil {
|
|
return nil, err
|
|
} else if !needsUpdate {
|
|
return nil, nil
|
|
}
|
|
|
|
return fleet.NewMDMAppleSoftwareUpdateRequired(fleet.MDMAppleSoftwareUpdateAsset{
|
|
ProductVersion: latest.ProductVersion,
|
|
Build: latest.Build,
|
|
}), nil
|
|
}
|
|
|
|
func (svc *Service) mdmPushCertTopic(ctx context.Context) (string, error) {
|
|
assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
|
|
fleet.MDMAssetAPNSCert,
|
|
}, nil)
|
|
if err != nil {
|
|
return "", ctxerr.Wrap(ctx, err, "loading SCEP keypair from the database")
|
|
}
|
|
|
|
block, _ := pem.Decode(assets[fleet.MDMAssetAPNSCert].Value)
|
|
if block == nil || block.Type != "CERTIFICATE" {
|
|
return "", ctxerr.Wrap(ctx, err, "decoding PEM data")
|
|
}
|
|
|
|
apnsCert, err := x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
return "", ctxerr.Wrap(ctx, err, "parsing APNs certificate")
|
|
}
|
|
|
|
mdmPushCertTopic, err := cryptoutil.TopicFromCert(apnsCert)
|
|
if err != nil {
|
|
return "", ctxerr.Wrap(ctx, err, "extracting topic from APNs certificate")
|
|
}
|
|
|
|
return mdmPushCertTopic, nil
|
|
}
|
|
|
|
// enqueueMDMAppleCommandRemoveEnrollmentProfile enqueues a RemoveProfile MDM command for the given host.
|
|
// It is a no-op for non-Apple hosts.
|
|
func (svc *Service) enqueueMDMAppleCommandRemoveEnrollmentProfile(ctx context.Context, host *fleet.Host) error {
|
|
if !fleet.IsApplePlatform(host.Platform) {
|
|
level.Debug(svc.logger).Log("msg", "Skipping mdm apple remove profile command for non-Apple host", "host_id", host.ID, "platform", host.Platform)
|
|
return nil // no-op for non-Apple hosts
|
|
}
|
|
|
|
nanoEnroll, err := svc.ds.GetNanoMDMEnrollment(ctx, host.UUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting mdm enrollment status for mdm apple remove profile command")
|
|
}
|
|
if nanoEnroll == nil || !nanoEnroll.Enabled {
|
|
return fleet.NewUserMessageError(ctxerr.New(ctx, fmt.Sprintf("mdm is not enabled for host %d", host.ID)), http.StatusConflict)
|
|
}
|
|
|
|
cmdUUID := uuid.New().String()
|
|
err = svc.mdmAppleCommander.RemoveProfile(ctx, []string{nanoEnroll.ID}, apple_mdm.FleetPayloadIdentifier, cmdUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "enqueuing mdm apple remove profile command")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type mdmAppleGetInstallerRequest struct {
|
|
Token string `query:"token"`
|
|
}
|
|
|
|
func (r mdmAppleGetInstallerResponse) Error() error { return r.Err }
|
|
|
|
type mdmAppleGetInstallerResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
|
|
// head is used by hijackRender for the response.
|
|
head bool
|
|
// Name field is used in hijackRender for the response.
|
|
name string
|
|
// Size field is used in hijackRender for the response.
|
|
size int64
|
|
// Installer field is used in hijackRender for the response.
|
|
installer []byte
|
|
}
|
|
|
|
func (r mdmAppleGetInstallerResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
w.Header().Set("Content-Length", strconv.FormatInt(r.size, 10))
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename="%s"`, r.name))
|
|
|
|
if r.head {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
// OK to just log the error here as writing anything on
|
|
// `http.ResponseWriter` sets the status code to 200 (and it can't be
|
|
// changed.) Clients should rely on matching content-length with the
|
|
// header provided
|
|
if n, err := w.Write(r.installer); err != nil {
|
|
logging.WithExtras(ctx, "err", err, "bytes_copied", n)
|
|
}
|
|
}
|
|
|
|
func mdmAppleGetInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*mdmAppleGetInstallerRequest)
|
|
installer, err := svc.GetMDMAppleInstallerByToken(ctx, req.Token)
|
|
if err != nil {
|
|
return mdmAppleGetInstallerResponse{Err: err}, nil
|
|
}
|
|
return mdmAppleGetInstallerResponse{
|
|
head: false,
|
|
name: installer.Name,
|
|
size: installer.Size,
|
|
installer: installer.Installer,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleInstallerByToken(ctx context.Context, token string) (*fleet.MDMAppleInstaller, error) {
|
|
// skipauth: The installer endpoint uses token authentication.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
installer, err := svc.ds.MDMAppleInstaller(ctx, token)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
return installer, nil
|
|
}
|
|
|
|
type mdmAppleHeadInstallerRequest struct {
|
|
Token string `query:"token"`
|
|
}
|
|
|
|
func mdmAppleHeadInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*mdmAppleHeadInstallerRequest)
|
|
installer, err := svc.GetMDMAppleInstallerDetailsByToken(ctx, req.Token)
|
|
if err != nil {
|
|
return mdmAppleGetInstallerResponse{Err: err}, nil
|
|
}
|
|
return mdmAppleGetInstallerResponse{
|
|
head: true,
|
|
name: installer.Name,
|
|
size: installer.Size,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleInstallerDetailsByToken(ctx context.Context, token string) (*fleet.MDMAppleInstaller, error) {
|
|
// skipauth: The installer endpoint uses token authentication.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
installer, err := svc.ds.MDMAppleInstallerDetailsByToken(ctx, token)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
return installer, nil
|
|
}
|
|
|
|
type listMDMAppleInstallersRequest struct{}
|
|
|
|
type listMDMAppleInstallersResponse struct {
|
|
Installers []fleet.MDMAppleInstaller `json:"installers"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r listMDMAppleInstallersResponse) Error() error { return r.Err }
|
|
|
|
func listMDMAppleInstallersEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
installers, err := svc.ListMDMAppleInstallers(ctx)
|
|
if err != nil {
|
|
return listMDMAppleInstallersResponse{
|
|
Err: err,
|
|
}, nil
|
|
}
|
|
return listMDMAppleInstallersResponse{
|
|
Installers: installers,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) ListMDMAppleInstallers(ctx context.Context) ([]fleet.MDMAppleInstaller, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.MDMAppleInstaller{}, fleet.ActionWrite); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
appConfig, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
installers, err := svc.ds.ListMDMAppleInstallers(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err)
|
|
}
|
|
for i := range installers {
|
|
installers[i].URL = svc.installerURL(installers[i].URLToken, appConfig)
|
|
}
|
|
return installers, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Lock a device
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type deviceLockRequest struct {
|
|
HostID uint `url:"id"`
|
|
}
|
|
|
|
type deviceLockResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deviceLockResponse) Error() error { return r.Err }
|
|
|
|
func (r deviceLockResponse) Status() int { return http.StatusNoContent }
|
|
|
|
func deviceLockEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*deviceLockRequest)
|
|
err := svc.MDMAppleDeviceLock(ctx, req.HostID)
|
|
if err != nil {
|
|
return deviceLockResponse{Err: err}, nil
|
|
}
|
|
return deviceLockResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) MDMAppleDeviceLock(ctx context.Context, hostID uint) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Wipe a device
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type deviceWipeRequest struct {
|
|
HostID uint `url:"id"`
|
|
}
|
|
|
|
type deviceWipeResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deviceWipeResponse) Error() error { return r.Err }
|
|
|
|
func (r deviceWipeResponse) Status() int { return http.StatusNoContent }
|
|
|
|
func deviceWipeEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*deviceWipeRequest)
|
|
err := svc.MDMAppleEraseDevice(ctx, req.HostID)
|
|
if err != nil {
|
|
return deviceWipeResponse{Err: err}, nil
|
|
}
|
|
return deviceWipeResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) MDMAppleEraseDevice(ctx context.Context, hostID uint) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Get profiles assigned to a host
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getHostProfilesRequest struct {
|
|
ID uint `url:"id"`
|
|
}
|
|
|
|
type getHostProfilesResponse struct {
|
|
HostID uint `json:"host_id"`
|
|
Profiles []*fleet.MDMAppleConfigProfile `json:"profiles"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getHostProfilesResponse) Error() error { return r.Err }
|
|
|
|
func getHostProfilesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getHostProfilesRequest)
|
|
sums, err := svc.MDMListHostConfigurationProfiles(ctx, req.ID)
|
|
if err != nil {
|
|
return getHostProfilesResponse{Err: err}, nil
|
|
}
|
|
res := getHostProfilesResponse{Profiles: sums, HostID: req.ID}
|
|
if res.Profiles == nil {
|
|
res.Profiles = []*fleet.MDMAppleConfigProfile{} // return empty json array instead of json null
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func (svc *Service) MDMListHostConfigurationProfiles(ctx context.Context, hostID uint) ([]*fleet.MDMAppleConfigProfile, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Batch Replace MDM Apple Profiles
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type batchSetMDMAppleProfilesRequest struct {
|
|
TeamID *uint `json:"-" query:"team_id,optional" renameto:"fleet_id"`
|
|
TeamName *string `json:"-" query:"team_name,optional" renameto:"fleet_name"`
|
|
DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
|
|
Profiles [][]byte `json:"profiles"`
|
|
}
|
|
|
|
type batchSetMDMAppleProfilesResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r batchSetMDMAppleProfilesResponse) Error() error { return r.Err }
|
|
|
|
func (r batchSetMDMAppleProfilesResponse) Status() int { return http.StatusNoContent }
|
|
|
|
func batchSetMDMAppleProfilesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*batchSetMDMAppleProfilesRequest)
|
|
if err := svc.BatchSetMDMAppleProfiles(ctx, req.TeamID, req.TeamName, req.Profiles, req.DryRun, false); err != nil {
|
|
return batchSetMDMAppleProfilesResponse{Err: err}, nil
|
|
}
|
|
return batchSetMDMAppleProfilesResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) BatchSetMDMAppleProfiles(ctx context.Context, tmID *uint, tmName *string, profiles [][]byte, dryRun, skipBulkPending bool) error {
|
|
var err error
|
|
tmID, tmName, err = svc.authorizeBatchProfiles(ctx, tmID, tmName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
appCfg, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
if !appCfg.MDM.EnabledAndConfigured {
|
|
// NOTE: in order to prevent an error when Fleet MDM is not enabled but no
|
|
// profile is provided, which can happen if a user runs `fleetctl get
|
|
// config` and tries to apply that YAML, as it will contain an empty/null
|
|
// custom_settings key, we just return a success response in this
|
|
// situation.
|
|
if len(profiles) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("mdm", "cannot set custom settings: "+fleet.ErrMDMNotConfigured.Error()))
|
|
}
|
|
|
|
// any duplicate identifier or name in the provided set results in an error
|
|
profs := make([]*fleet.MDMAppleConfigProfile, 0, len(profiles))
|
|
byName, byIdent := make(map[string]bool, len(profiles)), make(map[string]bool, len(profiles))
|
|
for i, prof := range profiles {
|
|
if len(prof) > 1024*1024 {
|
|
return ctxerr.Wrap(ctx,
|
|
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), "maximum configuration profile file size is 1 MB"),
|
|
)
|
|
}
|
|
|
|
err := CheckProfileIsNotSigned(prof)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
// Check for secrets in profile name before expansion
|
|
if err := fleet.ValidateNoSecretsInProfileName(prof); err != nil {
|
|
return ctxerr.Wrap(ctx,
|
|
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), err.Error()))
|
|
}
|
|
// Expand profile for validation
|
|
expanded, secretsUpdatedAt, err := svc.ds.ExpandEmbeddedSecretsAndUpdatedAt(ctx, string(prof))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx,
|
|
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), err.Error()),
|
|
"missing fleet secrets")
|
|
}
|
|
mdmProf, err := fleet.NewMDMAppleConfigProfile([]byte(expanded), tmID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx,
|
|
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), err.Error()),
|
|
"invalid mobileconfig profile")
|
|
}
|
|
|
|
if err := mdmProf.ValidateUserProvided(svc.config.MDM.EnableCustomOSUpdatesAndFileVault); err != nil {
|
|
return ctxerr.Wrap(ctx,
|
|
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), err.Error()))
|
|
}
|
|
|
|
// check if the profile has any fleet variable, not supported by this deprecated endpoint
|
|
if vars := variables.FindKeepDuplicates(expanded); len(vars) > 0 {
|
|
return ctxerr.Wrap(ctx,
|
|
fleet.NewInvalidArgumentError(
|
|
fmt.Sprintf("profiles[%d]", i), "profile variables are not supported by this deprecated endpoint, use POST /api/latest/fleet/mdm/profiles/batch"))
|
|
}
|
|
|
|
// Store original unexpanded profile
|
|
mdmProf.Mobileconfig = prof
|
|
mdmProf.SecretsUpdatedAt = secretsUpdatedAt
|
|
|
|
if byName[mdmProf.Name] {
|
|
return ctxerr.Wrap(ctx,
|
|
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), fmt.Sprintf("Couldn't edit custom_settings. More than one configuration profile have the same name (PayloadDisplayName): %q", mdmProf.Name)),
|
|
"duplicate mobileconfig profile by name")
|
|
}
|
|
byName[mdmProf.Name] = true
|
|
|
|
if byIdent[mdmProf.Identifier] {
|
|
return ctxerr.Wrap(ctx,
|
|
fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%d]", i), fmt.Sprintf("Couldn't edit custom_settings. More than one configuration profile have the same identifier (PayloadIdentifier): %q", mdmProf.Identifier)),
|
|
"duplicate mobileconfig profile by identifier")
|
|
}
|
|
byIdent[mdmProf.Identifier] = true
|
|
|
|
profs = append(profs, mdmProf)
|
|
}
|
|
|
|
if !skipBulkPending {
|
|
// check for duplicates with existing profiles, skipBulkPending signals that the caller
|
|
// is responsible for ensuring that the profiles names are unique (e.g., MDMAppleMatchPreassignment)
|
|
allProfs, _, err := svc.ds.ListMDMConfigProfiles(ctx, tmID, fleet.ListOptions{PerPage: 0})
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "list mdm config profiles")
|
|
}
|
|
for _, p := range allProfs {
|
|
if byName[p.Name] {
|
|
switch {
|
|
case strings.HasPrefix(p.ProfileUUID, "a"):
|
|
// do nothing, all existing mobileconfigs will be replaced and we've already checked
|
|
// the new mobileconfigs for duplicates
|
|
continue
|
|
case strings.HasPrefix(p.ProfileUUID, "w"):
|
|
err := fleet.NewInvalidArgumentError("PayloadDisplayName", fmt.Sprintf(
|
|
"Couldn't edit custom_settings. A Windows configuration profile shares the same name as a macOS configuration profile (PayloadDisplayName): %q", p.Name))
|
|
return ctxerr.Wrap(ctx, err, "duplicate xml and mobileconfig by name")
|
|
default:
|
|
err := fleet.NewInvalidArgumentError("PayloadDisplayName", fmt.Sprintf(
|
|
"Couldn't edit custom_settings. More than one configuration profile have the same name (PayloadDisplayName): %q", p.Name))
|
|
return ctxerr.Wrap(ctx, err, "duplicate json and mobileconfig by name")
|
|
}
|
|
}
|
|
byName[p.Name] = true
|
|
}
|
|
}
|
|
|
|
if dryRun {
|
|
return nil
|
|
}
|
|
if err := svc.ds.BatchSetMDMAppleProfiles(ctx, tmID, profs); err != nil {
|
|
return err
|
|
}
|
|
var bulkTeamID uint
|
|
if tmID != nil {
|
|
bulkTeamID = *tmID
|
|
}
|
|
|
|
if !skipBulkPending {
|
|
if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, nil, []uint{bulkTeamID}, nil, nil); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "bulk set pending host profiles")
|
|
}
|
|
}
|
|
|
|
if err := svc.NewActivity(
|
|
ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeEditedMacosProfile{
|
|
TeamID: tmID,
|
|
TeamName: tmName,
|
|
}); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "logging activity for edited macos profile")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Preassign a profile to a host
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type preassignMDMAppleProfileRequest struct {
|
|
fleet.MDMApplePreassignProfilePayload
|
|
}
|
|
|
|
type preassignMDMAppleProfileResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r preassignMDMAppleProfileResponse) Error() error { return r.Err }
|
|
|
|
func (r preassignMDMAppleProfileResponse) Status() int { return http.StatusNoContent }
|
|
|
|
func preassignMDMAppleProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*preassignMDMAppleProfileRequest)
|
|
if err := svc.MDMApplePreassignProfile(ctx, req.MDMApplePreassignProfilePayload); err != nil {
|
|
return preassignMDMAppleProfileResponse{Err: err}, nil
|
|
}
|
|
return preassignMDMAppleProfileResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) MDMApplePreassignProfile(ctx context.Context, payload fleet.MDMApplePreassignProfilePayload) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Match a set of pre-assigned profiles with a team
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type matchMDMApplePreassignmentRequest struct {
|
|
ExternalHostIdentifier string `json:"external_host_identifier"`
|
|
}
|
|
|
|
type matchMDMApplePreassignmentResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r matchMDMApplePreassignmentResponse) Error() error { return r.Err }
|
|
|
|
func (r matchMDMApplePreassignmentResponse) Status() int { return http.StatusNoContent }
|
|
|
|
func matchMDMApplePreassignmentEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*matchMDMApplePreassignmentRequest)
|
|
if err := svc.MDMAppleMatchPreassignment(ctx, req.ExternalHostIdentifier); err != nil {
|
|
return matchMDMApplePreassignmentResponse{Err: err}, nil
|
|
}
|
|
return matchMDMApplePreassignmentResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) MDMAppleMatchPreassignment(ctx context.Context, ref string) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Update MDM Apple Settings
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type updateMDMAppleSettingsRequest struct {
|
|
fleet.MDMAppleSettingsPayload
|
|
}
|
|
|
|
type updateMDMAppleSettingsResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r updateMDMAppleSettingsResponse) Error() error { return r.Err }
|
|
|
|
func (r updateMDMAppleSettingsResponse) Status() int { return http.StatusNoContent }
|
|
|
|
// This endpoint is required because the UI must allow maintainers (in addition
|
|
// to admins) to update some MDM Apple settings, while the update config/update
|
|
// team endpoints only allow write access to admins.
|
|
func updateMDMAppleSettingsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*updateMDMAppleSettingsRequest)
|
|
if err := svc.UpdateMDMDiskEncryption(ctx, req.MDMAppleSettingsPayload.TeamID, req.MDMAppleSettingsPayload.EnableDiskEncryption, nil); err != nil {
|
|
return updateMDMAppleSettingsResponse{Err: err}, nil
|
|
}
|
|
return updateMDMAppleSettingsResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) updateAppConfigMDMDiskEncryption(ctx context.Context, enabled *bool) error {
|
|
// appconfig is only used internally, it's fine to read it unobfuscated
|
|
// (svc.AppConfigObfuscated must not be used because the write-only users
|
|
// such as gitops will fail to access it).
|
|
ac, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var didUpdate bool
|
|
if enabled != nil {
|
|
if ac.MDM.EnableDiskEncryption.Value != *enabled {
|
|
if *enabled && svc.config.Server.PrivateKey == "" {
|
|
return ctxerr.New(ctx, "Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key")
|
|
}
|
|
|
|
ac.MDM.EnableDiskEncryption = optjson.SetBool(*enabled)
|
|
didUpdate = true
|
|
}
|
|
}
|
|
if didUpdate {
|
|
if err := svc.ds.SaveAppConfig(ctx, ac); err != nil {
|
|
return err
|
|
}
|
|
if ac.MDM.EnabledAndConfigured { // if macOS MDM is configured, set up FileVault escrow
|
|
var act fleet.ActivityDetails
|
|
if ac.MDM.EnableDiskEncryption.Value {
|
|
act = fleet.ActivityTypeEnabledMacosDiskEncryption{}
|
|
if err := svc.EnterpriseOverrides.MDMAppleEnableFileVaultAndEscrow(ctx, nil); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "enable no-team filevault and escrow")
|
|
}
|
|
} else {
|
|
act = fleet.ActivityTypeDisabledMacosDiskEncryption{}
|
|
if err := svc.EnterpriseOverrides.MDMAppleDisableFileVaultAndEscrow(ctx, nil); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "disable no-team filevault and escrow")
|
|
}
|
|
}
|
|
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "create activity for app config macos disk encryption")
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Upload a bootstrap package
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type uploadBootstrapPackageRequest struct {
|
|
Package *multipart.FileHeader
|
|
DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
|
|
TeamID uint
|
|
}
|
|
|
|
type uploadBootstrapPackageResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
// TODO: We parse the whole body before running svc.authz.Authorize.
|
|
// An authenticated but unauthorized user could abuse this.
|
|
func (uploadBootstrapPackageRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
decoded := uploadBootstrapPackageRequest{}
|
|
err := r.ParseMultipartForm(platform_http.MaxMultipartFormSize)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "failed to parse multipart form",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
if r.MultipartForm.File["package"] == nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "package multipart field is required",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
decoded.Package = r.MultipartForm.File["package"][0]
|
|
if !file.IsValidMacOSName(decoded.Package.Filename) {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "package name contains invalid characters",
|
|
InternalErr: ctxerr.New(ctx, "package name contains invalid characters"),
|
|
}
|
|
}
|
|
|
|
// default is no team
|
|
decoded.TeamID = 0
|
|
val, ok := r.MultipartForm.Value["team_id"]
|
|
if ok && len(val) > 0 {
|
|
teamID, err := strconv.Atoi(val[0])
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{Message: fmt.Sprintf("failed to decode team_id in multipart form: %s", err.Error())}
|
|
}
|
|
decoded.TeamID = uint(teamID) //nolint:gosec // dismiss G115
|
|
}
|
|
|
|
// Dry run
|
|
decoded.DryRun = r.URL.Query().Get("dry_run") == "true"
|
|
|
|
return &decoded, nil
|
|
}
|
|
|
|
func (r uploadBootstrapPackageResponse) Error() error { return r.Err }
|
|
|
|
func uploadBootstrapPackageEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*uploadBootstrapPackageRequest)
|
|
ff, err := req.Package.Open()
|
|
if err != nil {
|
|
return uploadBootstrapPackageResponse{Err: err}, nil
|
|
}
|
|
defer ff.Close()
|
|
|
|
if err := svc.MDMAppleUploadBootstrapPackage(ctx, req.Package.Filename, ff, req.TeamID, req.DryRun); err != nil {
|
|
return uploadBootstrapPackageResponse{Err: err}, nil
|
|
}
|
|
return &uploadBootstrapPackageResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) MDMAppleUploadBootstrapPackage(ctx context.Context, name string, pkg io.Reader, teamID uint, dryRun bool) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Download a bootstrap package
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type downloadBootstrapPackageRequest struct {
|
|
Token string `query:"token"`
|
|
}
|
|
|
|
type downloadBootstrapPackageResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
|
|
// fields used by hijackRender for the response.
|
|
pkg *fleet.MDMAppleBootstrapPackage
|
|
}
|
|
|
|
func (r downloadBootstrapPackageResponse) Error() error { return r.Err }
|
|
|
|
func (r downloadBootstrapPackageResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(r.pkg.Bytes)))
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename="%s"`, r.pkg.Name))
|
|
|
|
// OK to just log the error here as writing anything on
|
|
// `http.ResponseWriter` sets the status code to 200 (and it can't be
|
|
// changed.) Clients should rely on matching content-length with the
|
|
// header provided
|
|
if n, err := w.Write(r.pkg.Bytes); err != nil {
|
|
logging.WithExtras(ctx, "err", err, "bytes_copied", n)
|
|
}
|
|
}
|
|
|
|
func downloadBootstrapPackageEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*downloadBootstrapPackageRequest)
|
|
pkg, err := svc.GetMDMAppleBootstrapPackageBytes(ctx, req.Token)
|
|
if err != nil {
|
|
return downloadBootstrapPackageResponse{Err: err}, nil
|
|
}
|
|
return downloadBootstrapPackageResponse{pkg: pkg}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleBootstrapPackageBytes(ctx context.Context, token string) (*fleet.MDMAppleBootstrapPackage, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Get metadata about a bootstrap package
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type bootstrapPackageMetadataRequest struct {
|
|
TeamID uint `url:"fleet_id"`
|
|
|
|
// ForUpdate is used to indicate that the authorization should be for a
|
|
// "write" instead of a "read", this is needed specifically for the gitops
|
|
// user which is a write-only user, but needs to call this endpoint to check
|
|
// if it needs to upload the bootstrap package (if the hashes are different).
|
|
//
|
|
// NOTE: this parameter is going to be removed in a future version.
|
|
// Prefer other ways to allow gitops read access.
|
|
// For context, see: https://github.com/fleetdm/fleet/issues/15337#issuecomment-1932878997
|
|
ForUpdate bool `query:"for_update,optional"`
|
|
}
|
|
|
|
type bootstrapPackageMetadataResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
*fleet.MDMAppleBootstrapPackage `json:",omitempty"`
|
|
}
|
|
|
|
func (r bootstrapPackageMetadataResponse) Error() error { return r.Err }
|
|
|
|
func bootstrapPackageMetadataEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*bootstrapPackageMetadataRequest)
|
|
meta, err := svc.GetMDMAppleBootstrapPackageMetadata(ctx, req.TeamID, req.ForUpdate)
|
|
switch {
|
|
case fleet.IsNotFound(err):
|
|
return bootstrapPackageMetadataResponse{Err: fleet.NewInvalidArgumentError("team_id",
|
|
"bootstrap package for this team does not exist").WithStatus(http.StatusNotFound)}, nil
|
|
case err != nil:
|
|
return bootstrapPackageMetadataResponse{Err: err}, nil
|
|
}
|
|
return bootstrapPackageMetadataResponse{MDMAppleBootstrapPackage: meta}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleBootstrapPackageMetadata(ctx context.Context, teamID uint, forUpdate bool) (*fleet.MDMAppleBootstrapPackage, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Delete a bootstrap package
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type deleteBootstrapPackageRequest struct {
|
|
TeamID uint `url:"fleet_id"`
|
|
DryRun bool `query:"dry_run,optional"` // if true, apply validation but do not delete
|
|
}
|
|
|
|
type deleteBootstrapPackageResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deleteBootstrapPackageResponse) Error() error { return r.Err }
|
|
|
|
func deleteBootstrapPackageEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*deleteBootstrapPackageRequest)
|
|
if err := svc.DeleteMDMAppleBootstrapPackage(ctx, &req.TeamID, req.DryRun); err != nil {
|
|
return deleteBootstrapPackageResponse{Err: err}, nil
|
|
}
|
|
return deleteBootstrapPackageResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) DeleteMDMAppleBootstrapPackage(ctx context.Context, teamID *uint, dryRun bool) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Get aggregated summary about a team's bootstrap package
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getMDMAppleBootstrapPackageSummaryRequest struct {
|
|
TeamID *uint `query:"team_id,optional" renameto:"fleet_id"`
|
|
}
|
|
|
|
type getMDMAppleBootstrapPackageSummaryResponse struct {
|
|
fleet.MDMAppleBootstrapPackageSummary
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getMDMAppleBootstrapPackageSummaryResponse) Error() error { return r.Err }
|
|
|
|
func getMDMAppleBootstrapPackageSummaryEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getMDMAppleBootstrapPackageSummaryRequest)
|
|
summary, err := svc.GetMDMAppleBootstrapPackageSummary(ctx, req.TeamID)
|
|
if err != nil {
|
|
return getMDMAppleBootstrapPackageSummaryResponse{Err: err}, nil
|
|
}
|
|
return getMDMAppleBootstrapPackageSummaryResponse{MDMAppleBootstrapPackageSummary: *summary}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleBootstrapPackageSummary(ctx context.Context, teamID *uint) (*fleet.MDMAppleBootstrapPackageSummary, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return &fleet.MDMAppleBootstrapPackageSummary{}, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Create or update an MDM Apple Setup Assistant
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type createMDMAppleSetupAssistantRequest struct {
|
|
TeamID *uint `json:"team_id" renameto:"fleet_id"`
|
|
Name string `json:"name"`
|
|
EnrollmentProfile json.RawMessage `json:"enrollment_profile"`
|
|
}
|
|
|
|
type createMDMAppleSetupAssistantResponse struct {
|
|
fleet.MDMAppleSetupAssistant
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r createMDMAppleSetupAssistantResponse) Error() error { return r.Err }
|
|
|
|
func createMDMAppleSetupAssistantEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*createMDMAppleSetupAssistantRequest)
|
|
asst, err := svc.SetOrUpdateMDMAppleSetupAssistant(ctx, &fleet.MDMAppleSetupAssistant{
|
|
TeamID: req.TeamID,
|
|
Name: req.Name,
|
|
Profile: req.EnrollmentProfile,
|
|
})
|
|
if err != nil {
|
|
return createMDMAppleSetupAssistantResponse{Err: err}, nil
|
|
}
|
|
return createMDMAppleSetupAssistantResponse{MDMAppleSetupAssistant: *asst}, nil
|
|
}
|
|
|
|
func (svc *Service) SetOrUpdateMDMAppleSetupAssistant(ctx context.Context, asst *fleet.MDMAppleSetupAssistant) (*fleet.MDMAppleSetupAssistant, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Get the MDM Apple Setup Assistant
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getMDMAppleSetupAssistantRequest struct {
|
|
TeamID *uint `query:"team_id,optional" renameto:"fleet_id"`
|
|
}
|
|
|
|
type getMDMAppleSetupAssistantResponse struct {
|
|
fleet.MDMAppleSetupAssistant
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getMDMAppleSetupAssistantResponse) Error() error { return r.Err }
|
|
|
|
func getMDMAppleSetupAssistantEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getMDMAppleSetupAssistantRequest)
|
|
asst, err := svc.GetMDMAppleSetupAssistant(ctx, req.TeamID)
|
|
if err != nil {
|
|
return getMDMAppleSetupAssistantResponse{Err: err}, nil
|
|
}
|
|
return getMDMAppleSetupAssistantResponse{MDMAppleSetupAssistant: *asst}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMAppleSetupAssistant(ctx context.Context, teamID *uint) (*fleet.MDMAppleSetupAssistant, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Delete an MDM Apple Setup Assistant
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type deleteMDMAppleSetupAssistantRequest struct {
|
|
TeamID *uint `query:"team_id,optional" renameto:"fleet_id"`
|
|
}
|
|
|
|
type deleteMDMAppleSetupAssistantResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deleteMDMAppleSetupAssistantResponse) Error() error { return r.Err }
|
|
func (r deleteMDMAppleSetupAssistantResponse) Status() int { return http.StatusNoContent }
|
|
|
|
func deleteMDMAppleSetupAssistantEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*deleteMDMAppleSetupAssistantRequest)
|
|
if err := svc.DeleteMDMAppleSetupAssistant(ctx, req.TeamID); err != nil {
|
|
return deleteMDMAppleSetupAssistantResponse{Err: err}, nil
|
|
}
|
|
return deleteMDMAppleSetupAssistantResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) DeleteMDMAppleSetupAssistant(ctx context.Context, teamID *uint) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Update MDM Apple Setup
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type updateMDMAppleSetupRequest struct {
|
|
fleet.MDMAppleSetupPayload
|
|
}
|
|
|
|
type updateMDMAppleSetupResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r updateMDMAppleSetupResponse) Error() error { return r.Err }
|
|
|
|
func (r updateMDMAppleSetupResponse) Status() int { return http.StatusNoContent }
|
|
|
|
// This endpoint is required because the UI must allow maintainers (in addition
|
|
// to admins) to update some MDM Apple settings, while the update config/update
|
|
// team endpoints only allow write access to admins.
|
|
func updateMDMAppleSetupEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*updateMDMAppleSetupRequest)
|
|
if err := svc.UpdateMDMAppleSetup(ctx, req.MDMAppleSetupPayload); err != nil {
|
|
return updateMDMAppleSetupResponse{Err: err}, nil
|
|
}
|
|
return updateMDMAppleSetupResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) UpdateMDMAppleSetup(ctx context.Context, payload fleet.MDMAppleSetupPayload) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// POST /mdm/sso
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type initiateMDMSSORequest struct {
|
|
Initiator string `json:"initiator,omitempty"` // optional, passed by the UI during account-driven enrollment, or by Orbit for non-Apple IdP auth.
|
|
UserIdentifier string `json:"user_identifier,omitempty"` // optional, passed by Apple for account-driven enrollment
|
|
HostUUID string `json:"host_uuid,omitempty"` // optional, passed by Orbit for non-Apple IdP auth
|
|
}
|
|
|
|
type initiateMDMSSOResponse struct {
|
|
URL string `json:"url,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
|
|
sessionID string
|
|
sessionDurationSeconds int
|
|
}
|
|
|
|
func (r initiateMDMSSOResponse) Error() error { return r.Err }
|
|
|
|
func (r initiateMDMSSOResponse) SetCookies(_ context.Context, w http.ResponseWriter) {
|
|
setSSOCookie(w, r.sessionID, r.sessionDurationSeconds)
|
|
}
|
|
|
|
func initiateMDMSSOEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*initiateMDMSSORequest)
|
|
sessionID, sessionDurationSeconds, idpProviderURL, err := svc.InitiateMDMSSO(ctx, req.Initiator, "", req.HostUUID)
|
|
if err != nil {
|
|
return initiateMDMSSOResponse{Err: err}, nil
|
|
}
|
|
|
|
return initiateMDMSSOResponse{
|
|
URL: idpProviderURL,
|
|
|
|
sessionID: sessionID,
|
|
sessionDurationSeconds: sessionDurationSeconds,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) InitiateMDMSSO(ctx context.Context, initiator, customOriginalURL string, hostUUID string) (sessionID string, sessionDurationSeconds int, idpURL string, err error) {
|
|
// skipauth: No authorization check needed due to implementation
|
|
// returning only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return "", 0, "", fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// POST /mdm/sso/callback
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type callbackMDMSSORequest struct {
|
|
sessionID string
|
|
samlResponse []byte
|
|
}
|
|
|
|
// TODO: these errors will result in JSON being returned, but we should
|
|
// redirect to the UI and let the UI display an error instead. The errors are
|
|
// rare enough (malformed data coming from the SSO provider) so they shouldn't
|
|
// affect many users.
|
|
func (callbackMDMSSORequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
sessionID, samlResponse, err := decodeCallbackRequest(ctx, r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &callbackMDMSSORequest{
|
|
sessionID: sessionID,
|
|
samlResponse: samlResponse,
|
|
}, nil
|
|
}
|
|
|
|
type callbackMDMSSOResponse struct {
|
|
redirectURL string
|
|
byodEnrollCookieValue string
|
|
}
|
|
|
|
func (r callbackMDMSSOResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
w.Header().Set("Location", r.redirectURL)
|
|
w.WriteHeader(http.StatusSeeOther)
|
|
}
|
|
|
|
func (r callbackMDMSSOResponse) SetCookies(_ context.Context, w http.ResponseWriter) {
|
|
deleteSSOCookie(w)
|
|
if r.byodEnrollCookieValue != "" {
|
|
setBYODCookie(w, r.byodEnrollCookieValue, 30*60) // valid for 30 minutes
|
|
}
|
|
}
|
|
|
|
// Error will always be nil because errors are handled by sending a query
|
|
// parameter in the URL response, this way the UI is able to display an error
|
|
// message.
|
|
func (r callbackMDMSSOResponse) Error() error { return nil }
|
|
|
|
func callbackMDMSSOEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
callbackRequest := request.(*callbackMDMSSORequest)
|
|
redirectURL, byodCookieValue := svc.MDMSSOCallback(ctx, callbackRequest.sessionID, callbackRequest.samlResponse)
|
|
return callbackMDMSSOResponse{
|
|
redirectURL: redirectURL,
|
|
byodEnrollCookieValue: byodCookieValue,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) MDMSSOCallback(ctx context.Context, sessionID string, samlResponse []byte) (redirectURL, byodCookieValue string) {
|
|
// skipauth: No authorization check needed due to implementation
|
|
// returning only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return apple_mdm.FleetUISSOCallbackPath + "?error=true", ""
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// GET /mdm/manual_enrollment_profile
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getManualEnrollmentProfileRequest struct{}
|
|
|
|
type getManualEnrollmentProfileResponse struct {
|
|
// Profile field is used in HijackRender for the response.
|
|
Profile []byte
|
|
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getManualEnrollmentProfileResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
// make the browser download the content to a file
|
|
w.Header().Add("Content-Disposition", `attachment; filename="fleet-mdm-enrollment-profile.mobileconfig"`)
|
|
// explicitly set the content length before the write, so the caller can
|
|
// detect short writes (if it fails to send the full content properly)
|
|
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(r.Profile)), 10))
|
|
// this content type will make macos open the profile with the proper application
|
|
w.Header().Set("Content-Type", "application/x-apple-aspen-config; charset=utf-8")
|
|
// prevent detection of content, obey the provided content-type
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
|
|
if n, err := w.Write(r.Profile); err != nil {
|
|
logging.WithExtras(ctx, "err", err, "written", n)
|
|
}
|
|
}
|
|
|
|
func (r getManualEnrollmentProfileResponse) Error() error { return r.Err }
|
|
|
|
func getManualEnrollmentProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
profile, err := svc.GetMDMManualEnrollmentProfile(ctx)
|
|
if err != nil {
|
|
return getManualEnrollmentProfileResponse{Err: err}, nil
|
|
}
|
|
|
|
return getManualEnrollmentProfileResponse{Profile: profile}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMManualEnrollmentProfile(ctx context.Context) ([]byte, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// FileVault-related free version implementation
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
func (svc *Service) MDMAppleEnableFileVaultAndEscrow(ctx context.Context, teamID *uint) error {
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
func (svc *Service) MDMAppleDisableFileVaultAndEscrow(ctx context.Context, teamID *uint) error {
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Implementation of nanomdm's CheckinAndCommandService interface
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type MDMAppleCheckinAndCommandService struct {
|
|
ds fleet.Datastore
|
|
logger *platformlogging.Logger
|
|
commander *apple_mdm.MDMAppleCommander
|
|
vppInstaller fleet.AppleMDMVPPInstaller
|
|
mdmLifecycle *mdmlifecycle.HostLifecycle
|
|
commandHandlers map[string][]fleet.MDMCommandResultsHandler
|
|
keyValueStore fleet.KeyValueStore
|
|
isPremium bool
|
|
}
|
|
|
|
func NewMDMAppleCheckinAndCommandService(
|
|
ds fleet.Datastore,
|
|
commander *apple_mdm.MDMAppleCommander,
|
|
vppInstaller fleet.AppleMDMVPPInstaller,
|
|
isPremium bool,
|
|
logger *platformlogging.Logger,
|
|
keyValueStore fleet.KeyValueStore,
|
|
) *MDMAppleCheckinAndCommandService {
|
|
mdmLifecycle := mdmlifecycle.New(ds, logger, newActivity)
|
|
return &MDMAppleCheckinAndCommandService{
|
|
ds: ds,
|
|
commander: commander,
|
|
logger: logger,
|
|
mdmLifecycle: mdmLifecycle,
|
|
vppInstaller: vppInstaller,
|
|
isPremium: isPremium,
|
|
commandHandlers: map[string][]fleet.MDMCommandResultsHandler{},
|
|
keyValueStore: keyValueStore,
|
|
}
|
|
}
|
|
|
|
func (svc *MDMAppleCheckinAndCommandService) RegisterResultsHandler(commandType string, handler fleet.MDMCommandResultsHandler) {
|
|
svc.commandHandlers[commandType] = append(svc.commandHandlers[commandType], handler)
|
|
}
|
|
|
|
// Authenticate handles MDM [Authenticate][1] requests.
|
|
//
|
|
// This method is executed after the request has been handled by nanomdm, note
|
|
// that at this point you can't send any commands to the device yet because we
|
|
// haven't received a token, nor a PushMagic.
|
|
//
|
|
// We use it to perform post-enrollment tasks such as creating a host record,
|
|
// adding activities to the log, etc.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/authenticate
|
|
func (svc *MDMAppleCheckinAndCommandService) Authenticate(r *mdm.Request, m *mdm.Authenticate) error {
|
|
var scepRenewalInProgress bool
|
|
existingDeviceInfo, err := svc.ds.GetHostMDMCheckinInfo(r.Context, r.ID)
|
|
if err != nil {
|
|
var nfe fleet.NotFoundError
|
|
if !errors.As(err, &nfe) {
|
|
return ctxerr.Wrap(r.Context, err, "getting checkin info")
|
|
}
|
|
}
|
|
if existingDeviceInfo != nil {
|
|
scepRenewalInProgress = existingDeviceInfo.SCEPRenewalInProgress
|
|
}
|
|
|
|
// iPhones, iPads, and iPods send ProductName but not Model/ModelName,
|
|
// thus we use this field as the device's Model (which is required on lifecycle stages).
|
|
platform := "darwin"
|
|
iPhone := strings.HasPrefix(m.ProductName, "iPhone") || strings.HasPrefix(m.ProductName, "iPod")
|
|
iPad := strings.HasPrefix(m.ProductName, "iPad")
|
|
if iPhone || iPad {
|
|
m.Model = m.ProductName
|
|
if iPhone {
|
|
platform = "ios"
|
|
} else {
|
|
platform = "ipados"
|
|
}
|
|
}
|
|
|
|
if m.Model == "" {
|
|
m.Model = m.ProductName
|
|
}
|
|
|
|
if err := svc.mdmLifecycle.Do(r.Context, mdmlifecycle.HostOptions{
|
|
Action: mdmlifecycle.HostActionReset,
|
|
Platform: platform,
|
|
UUID: m.UDID,
|
|
HardwareSerial: m.SerialNumber,
|
|
HardwareModel: m.Model,
|
|
SCEPRenewalInProgress: scepRenewalInProgress,
|
|
UserEnrollmentID: m.EnrollmentID,
|
|
}); err != nil {
|
|
level.Warn(svc.logger).Log("msg", "could not reset Apple mdm information", "UDID", m.UDID, "EnrollmentID", m.EnrollmentID, "err", err)
|
|
return err
|
|
}
|
|
|
|
if !scepRenewalInProgress {
|
|
if svc.keyValueStore != nil {
|
|
// Set sticky key for MDM enrollments to avoid updating team id on orbit enrollments
|
|
err = svc.keyValueStore.Set(r.Context, fleet.StickyMDMEnrollmentKeyPrefix+r.ID, "1", fleet.StickyMDMEnrollmentTTL)
|
|
if err != nil {
|
|
// We do not want to fail here, just log the error to notify
|
|
level.Error(svc.logger).Log("msg", "failed to set sticky mdm enrollment key", "err", err, "host_uuid", r.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// TokenUpdate handles MDM [TokenUpdate][1] requests.
|
|
//
|
|
// This method is executed after the request has been handled by nanomdm.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/token_update
|
|
func (svc *MDMAppleCheckinAndCommandService) TokenUpdate(r *mdm.Request, m *mdm.TokenUpdate) error {
|
|
svc.logger.Log("info", "received token update", "host_uuid", r.ID)
|
|
info, err := svc.ds.GetHostMDMCheckinInfo(r.Context, r.ID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "getting checkin info")
|
|
}
|
|
|
|
// FIXME: We need to revisit this flow. Short-circuiting in random places means it is
|
|
// much more difficult to reason about the state of the host. We should try instead
|
|
// to centralize the flow control in the lifecycle methods.
|
|
if info.SCEPRenewalInProgress {
|
|
svc.logger.Log("info", "token update received for a SCEP renewal in process, cleaning SCEP refs", "host_uuid", r.ID)
|
|
if err := svc.ds.CleanSCEPRenewRefs(r.Context, r.ID); err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "cleaning SCEP refs")
|
|
}
|
|
svc.logger.Log("info", "cleaned SCEP refs, skipping setup experience and mdm lifecycle turn on action", "host_uuid", r.ID)
|
|
return nil
|
|
}
|
|
|
|
var hasSetupExpItems bool
|
|
enqueueSetupExperienceItems := false
|
|
|
|
if m.AwaitingConfiguration {
|
|
// Note that Setup Experience is only skipped for macOS during DEP migration. iOS and iPadOS will still get VPP apps
|
|
if info.MigrationInProgress && info.Platform == "darwin" {
|
|
svc.logger.Log("info", "skipping setup experience enqueueing because DEP migration is in progress", "host_uuid", r.ID)
|
|
} else {
|
|
enqueueSetupExperienceItems = true
|
|
}
|
|
} else if info.Platform != "darwin" && r.Type == mdm.Device && !info.InstalledFromDEP {
|
|
// For manual iOS/iPadOS device enrollments, check the `TokenUpdateTally` so that
|
|
// we only run the setup experience enqueueing once per device.
|
|
nanoEnroll, err := svc.ds.GetNanoMDMEnrollment(r.Context, r.ID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "getting nanomdm enrollment")
|
|
}
|
|
if nanoEnroll != nil && nanoEnroll.TokenUpdateTally == 1 {
|
|
enqueueSetupExperienceItems = true
|
|
}
|
|
}
|
|
|
|
// TODO -- See if there's a way to check license here to avoid unnecessary work.
|
|
// We do check the license before actually _running_ setup experience items.
|
|
if enqueueSetupExperienceItems {
|
|
// Enqueue setup experience items and mark the host as being in setup experience
|
|
hasSetupExpItems, err = svc.ds.EnqueueSetupExperienceItems(r.Context, info.Platform, r.ID, info.TeamID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "queueing setup experience tasks")
|
|
}
|
|
}
|
|
|
|
if info.MigrationInProgress {
|
|
// If the checkin info says a migration is in progress, mark the migration as completed even if
|
|
// the device doesn't report awaiting configuration(basically a device already enrolled and checking in
|
|
// with fleet has logically always completed any migration that might be in progress)
|
|
err = svc.ds.SetHostMDMMigrationCompleted(r.Context, info.HostID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "setting mdm migration completed")
|
|
}
|
|
}
|
|
|
|
var acctUUID string
|
|
idp, err := svc.ds.GetMDMIdPAccountByHostUUID(r.Context, r.ID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "getting idp account")
|
|
}
|
|
if idp != nil {
|
|
acctUUID = idp.UUID
|
|
}
|
|
|
|
// User (Device) enrollments, also known as Account Driven enrollments or BYOD enrollments,
|
|
// are a special case where the bearer token is used to link the enrollment to the IDP account.
|
|
if r.Type == mdm.UserEnrollmentDevice && idp == nil && strings.HasPrefix(r.Authorization, "Bearer ") {
|
|
// Split off the Bearer prefix
|
|
accountUUID := strings.TrimPrefix(r.Authorization, "Bearer ")
|
|
idpAccount, err := svc.ds.GetMDMIdPAccountByUUID(r.Context, accountUUID)
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return ctxerr.Wrap(r.Context, err, "getting idp account by UUID")
|
|
}
|
|
if fleet.IsNotFound(err) || idpAccount == nil {
|
|
// This should never happen but we still want to process the token update
|
|
level.Error(svc.logger).Log("msg", "no IDP account found for User (Device) enrollment even though a bearer token was passed", "host_uuid", r.ID, "account_uuid", accountUUID)
|
|
} else {
|
|
acctUUID = idpAccount.UUID
|
|
err = svc.ds.AssociateHostMDMIdPAccount(r.Context, r.ID, acctUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(r.Context, err, "associating host with idp account")
|
|
}
|
|
}
|
|
}
|
|
|
|
return svc.mdmLifecycle.Do(r.Context, mdmlifecycle.HostOptions{
|
|
Action: mdmlifecycle.HostActionTurnOn,
|
|
Platform: info.Platform,
|
|
UUID: r.ID,
|
|
EnrollReference: acctUUID,
|
|
HasSetupExperienceItems: hasSetupExpItems,
|
|
UserEnrollmentID: m.EnrollmentID,
|
|
FromMDMMigration: info.MigrationInProgress || (info.DEPAssignedToFleet && !m.AwaitingConfiguration),
|
|
})
|
|
}
|
|
|
|
// CheckOut handles MDM [CheckOut][1] requests.
|
|
//
|
|
// This method is executed after the request has been handled by nanomdm, note
|
|
// that this message is sent on a best-effort basis, don't rely exclusively on
|
|
// it.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/check_out
|
|
func (svc *MDMAppleCheckinAndCommandService) CheckOut(r *mdm.Request, m *mdm.CheckOut) error {
|
|
info, err := svc.ds.GetHostMDMCheckinInfo(r.Context, m.Enrollment.Identifier())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = svc.mdmLifecycle.Do(r.Context, mdmlifecycle.HostOptions{
|
|
Action: mdmlifecycle.HostActionTurnOff,
|
|
Platform: info.Platform,
|
|
UUID: r.ID,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return newActivity(
|
|
r.Context, nil, &fleet.ActivityTypeMDMUnenrolled{
|
|
HostSerial: info.HardwareSerial,
|
|
HostDisplayName: info.DisplayName,
|
|
InstalledFromDEP: info.InstalledFromDEP,
|
|
Platform: info.Platform,
|
|
}, svc.ds, svc.logger,
|
|
)
|
|
}
|
|
|
|
// SetBootstrapToken handles MDM [SetBootstrapToken][1] requests.
|
|
//
|
|
// This method is executed after the request has been handled by nanomdm.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/set_bootstrap_token
|
|
func (svc *MDMAppleCheckinAndCommandService) SetBootstrapToken(*mdm.Request, *mdm.SetBootstrapToken) error {
|
|
return nil
|
|
}
|
|
|
|
// GetBootstrapToken handles MDM [GetBootstrapToken][1] requests.
|
|
//
|
|
// This method is executed after the request has been handled by nanomdm.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/get_bootstrap_token
|
|
func (svc *MDMAppleCheckinAndCommandService) GetBootstrapToken(*mdm.Request, *mdm.GetBootstrapToken) (*mdm.BootstrapToken, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// UserAuthenticate handles MDM [UserAuthenticate][1] requests.
|
|
//
|
|
// This method is executed after the request has been handled by nanomdm.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/userauthenticate
|
|
func (svc *MDMAppleCheckinAndCommandService) UserAuthenticate(r *mdm.Request, ua *mdm.UserAuthenticate) ([]byte, error) {
|
|
level.Debug(svc.logger).Log("msg", "declining management of network user", "host_uuid", r.ID, "host_user_uuid", ua.UserID)
|
|
return nil, nano_service.NewHTTPStatusError(http.StatusGone, ctxerr.New(r.Context, "userAuthenticate not supported"))
|
|
}
|
|
|
|
// DeclarativeManagement handles MDM [DeclarativeManagement][1] requests.
|
|
//
|
|
// This method is executed after the request has been handled by nanomdm.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/declarative_management_checkin
|
|
func (svc *MDMAppleCheckinAndCommandService) DeclarativeManagement(r *mdm.Request, dm *mdm.DeclarativeManagement) ([]byte, error) {
|
|
// DeclarativeManagement is handled by the MDMAppleDDMService.
|
|
return nil, nil
|
|
}
|
|
|
|
// GetToken handles MDM [GetToken][1] requests.
|
|
//
|
|
// This method is executed after the request has been handled by nanomdm.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/get_token
|
|
func (svc *MDMAppleCheckinAndCommandService) GetToken(_ *mdm.Request, _ *mdm.GetToken) (*mdm.GetTokenResponse, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (svc *MDMAppleCheckinAndCommandService) runCommandHandlers(ctx context.Context, cmdName string, result fleet.MDMCommandResults) error {
|
|
handlers, ok := svc.commandHandlers[cmdName]
|
|
if ok {
|
|
for _, f := range handlers {
|
|
if err := f(ctx, result); err != nil {
|
|
// TODO: should we run as many as we can? if so we have to collect into a multierror
|
|
return ctxerr.Wrapf(ctx, err, "%s handler failed", cmdName)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CommandAndReportResults handles MDM [Commands and Queries][1].
|
|
//
|
|
// This method is executed after the request has been handled by nanomdm.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/commands_and_queries
|
|
func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Request, cmdResult *mdm.CommandResults) (*mdm.Command, error) {
|
|
if cmdResult.Status == "Idle" {
|
|
// NOTE: iPhone/iPod/iPad devices that are still enroled in Fleet's MDM but have
|
|
// been deleted from Fleet (no host entry) will still send checkin
|
|
// requests from time to time. Those should be Idle requests without a
|
|
// CommandUUID. As stated in tickets #22941 and #22391, Fleet iDevices
|
|
// should be re-created when they checkin with MDM.
|
|
deletedDevice, err := svc.ds.GetMDMAppleEnrolledDeviceDeletedFromFleet(r.Context, cmdResult.Identifier())
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return nil, ctxerr.Wrap(r.Context, err, "lookup enrolled but deleted device info")
|
|
}
|
|
|
|
// only re-create iPhone/iPod/iPad devices, macOS are recreated via the fleetd checkin
|
|
if deletedDevice != nil && (deletedDevice.Platform == "ios" || deletedDevice.Platform == "ipados") {
|
|
msg, err := mdm.DecodeCheckin([]byte(deletedDevice.Authenticate))
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "decode authenticate enrollment message to re-create a deleted host")
|
|
}
|
|
authMsg, ok := msg.(*mdm.Authenticate)
|
|
if !ok {
|
|
return nil, ctxerr.Errorf(r.Context, "authenticate enrollment message to re-create a deleted host is not of the expected type: %T", msg)
|
|
}
|
|
|
|
err = svc.mdmLifecycle.Do(r.Context, mdmlifecycle.HostOptions{
|
|
Action: mdmlifecycle.HostActionReset,
|
|
Platform: deletedDevice.Platform,
|
|
UUID: deletedDevice.ID,
|
|
HardwareSerial: deletedDevice.SerialNumber,
|
|
HardwareModel: authMsg.ProductName,
|
|
})
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "trigger mdm reset lifecycle to re-create a deleted host")
|
|
}
|
|
|
|
if deletedDevice.EnrollTeamID != nil {
|
|
host, err := svc.ds.HostLiteByIdentifier(r.Context, deletedDevice.ID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "load re-created host by identifier")
|
|
}
|
|
if err := svc.ds.AddHostsToTeam(r.Context, fleet.NewAddHostsToTeamParams(deletedDevice.EnrollTeamID, []uint{host.ID})); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "transfer re-created host to enrollment team")
|
|
}
|
|
}
|
|
}
|
|
|
|
// macOS hosts are considered unlocked if they are online any time
|
|
// after they have been unlocked. If the host has been seen after a
|
|
// successful unlock, take the opportunity and update the value in the
|
|
// db as well.
|
|
//
|
|
// TODO: sanity check if this approach is still valid after we implement wipe
|
|
|
|
// if there is a deleted device, it means there is no hosts entry so no need to clean the lock
|
|
if deletedDevice == nil {
|
|
if err := svc.ds.CleanAppleMDMLock(r.Context, cmdResult.UDID); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "cleaning macOS host lock/wipe status")
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// Check if this is a result of a "refetch" command sent to iPhones/iPads
|
|
// to fetch their device information periodically.
|
|
if strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchBaseCommandUUIDPrefix) && !strings.HasPrefix(cmdResult.CommandUUID, fleet.VerifySoftwareInstallVPPPrefix) {
|
|
return svc.handleRefetch(r, cmdResult)
|
|
}
|
|
|
|
// We explicitly get the request type because it comes empty. There's a
|
|
// RequestType field in the struct, but it's used when a mdm.Command is
|
|
// issued.
|
|
requestType, err := svc.ds.GetMDMAppleCommandRequestType(r.Context, cmdResult.CommandUUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "command service")
|
|
}
|
|
|
|
switch requestType {
|
|
case "InstallProfile":
|
|
return nil, apple_mdm.HandleHostMDMProfileInstallResult(
|
|
r.Context,
|
|
svc.ds,
|
|
cmdResult.Identifier(),
|
|
cmdResult.CommandUUID,
|
|
mdmAppleDeliveryStatusFromCommandStatus(cmdResult.Status),
|
|
apple_mdm.FmtErrorChain(cmdResult.ErrorChain),
|
|
)
|
|
case "RemoveProfile":
|
|
return nil, svc.ds.UpdateOrDeleteHostMDMAppleProfile(r.Context, &fleet.HostMDMAppleProfile{
|
|
CommandUUID: cmdResult.CommandUUID,
|
|
HostUUID: cmdResult.Identifier(),
|
|
Status: mdmAppleDeliveryStatusFromCommandStatus(cmdResult.Status),
|
|
Detail: apple_mdm.FmtErrorChain(cmdResult.ErrorChain),
|
|
OperationType: fleet.MDMOperationTypeRemove,
|
|
})
|
|
case "DeviceLock", "EraseDevice":
|
|
// these commands will always fail if sent to a User Enrolled device as of iOS/iPadOS 18
|
|
if cmdResult.Status == fleet.MDMAppleStatusAcknowledged ||
|
|
cmdResult.Status == fleet.MDMAppleStatusError ||
|
|
cmdResult.Status == fleet.MDMAppleStatusCommandFormatError {
|
|
return nil, svc.ds.UpdateHostLockWipeStatusFromAppleMDMResult(r.Context, cmdResult.Identifier(), cmdResult.CommandUUID, requestType,
|
|
cmdResult.Status == fleet.MDMAppleStatusAcknowledged)
|
|
}
|
|
|
|
case fleet.DisableLostModeCmdName:
|
|
if cmdResult.Status == fleet.MDMAppleStatusAcknowledged ||
|
|
cmdResult.Status == fleet.MDMAppleStatusError ||
|
|
cmdResult.Status == fleet.MDMAppleStatusCommandFormatError {
|
|
|
|
host, err := svc.ds.HostByIdentifier(r.Context, cmdResult.Identifier())
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "DisableLostMode: get host by identifier")
|
|
}
|
|
|
|
if err := svc.ds.DeleteHostLocationData(r.Context, host.ID); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "DisableLostMode: delete host location data")
|
|
}
|
|
|
|
return nil, svc.ds.UpdateHostLockWipeStatusFromAppleMDMResult(r.Context, cmdResult.Identifier(), cmdResult.CommandUUID, requestType,
|
|
cmdResult.Status == fleet.MDMAppleStatusAcknowledged)
|
|
}
|
|
|
|
case fleet.EnableLostModeCmdName:
|
|
|
|
// these commands will always fail if sent to a User Enrolled device as of iOS/iPadOS 18
|
|
if cmdResult.Status == fleet.MDMAppleStatusAcknowledged ||
|
|
cmdResult.Status == fleet.MDMAppleStatusError ||
|
|
cmdResult.Status == fleet.MDMAppleStatusCommandFormatError {
|
|
|
|
err := svc.commander.DeviceLocation(r.Context, []string{cmdResult.Identifier()}, uuid.NewString())
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "EnableLostMode: enqueue DeviceLocation command")
|
|
}
|
|
|
|
return nil, svc.ds.UpdateHostLockWipeStatusFromAppleMDMResult(r.Context, cmdResult.Identifier(), cmdResult.CommandUUID, requestType,
|
|
cmdResult.Status == fleet.MDMAppleStatusAcknowledged)
|
|
}
|
|
case "DeclarativeManagement":
|
|
// set "pending-install" profiles to "verifying" or "failed"
|
|
// depending on the status of the DeviceManagement command
|
|
status := mdmAppleDeliveryStatusFromCommandStatus(cmdResult.Status)
|
|
detail := fmt.Sprintf("%s. Make sure the host is on macOS 13+, iOS 17+, iPadOS 17+.", apple_mdm.FmtErrorChain(cmdResult.ErrorChain))
|
|
err := svc.ds.MDMAppleSetPendingDeclarationsAs(r.Context, cmdResult.Identifier(), status, detail)
|
|
return nil, ctxerr.Wrap(r.Context, err, "update declaration status on DeclarativeManagement ack")
|
|
case "InstallApplication":
|
|
// create an activity for installing only if we're in a terminal error state
|
|
if cmdResult.Status == fleet.MDMAppleStatusError ||
|
|
cmdResult.Status == fleet.MDMAppleStatusCommandFormatError {
|
|
|
|
for _, errorChain := range cmdResult.ErrorChain {
|
|
if errorChain.ErrorCode != apple_mdm.VPPLicenseNotFound {
|
|
// We only want to retry on license not found errors
|
|
continue
|
|
}
|
|
|
|
// Fetch the host vpp install info
|
|
vppInstall, err := svc.ds.GetHostVPPInstallByCommandUUID(r.Context, cmdResult.CommandUUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "fetching host vpp install by command uuid")
|
|
}
|
|
if vppInstall.RetryCount < 3 {
|
|
// Requeue the app for installation
|
|
if err := svc.ds.RetryVPPInstall(r.Context, vppInstall); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "retrying VPP install for host")
|
|
}
|
|
level.Info(svc.logger).Log("msg", "re-queued VPP app installation due to missing license", "host_id", vppInstall.HostID, "command_uuid", cmdResult.CommandUUID, "retry_count", vppInstall.RetryCount+1)
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
// this might be a setup experience VPP install, so we'll try to update setup experience status
|
|
var fromSetupExperience bool
|
|
if updated, err := maybeUpdateSetupExperienceStatus(r.Context, svc.ds, fleet.SetupExperienceVPPInstallResult{
|
|
HostUUID: cmdResult.Identifier(),
|
|
CommandUUID: cmdResult.CommandUUID,
|
|
CommandStatus: cmdResult.Status,
|
|
}, true); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "updating setup experience status from VPP install result")
|
|
} else if updated {
|
|
// TODO: call next step of setup experience?
|
|
fromSetupExperience = true
|
|
level.Debug(svc.logger).Log("msg", "setup experience VPP install result updated", "host_uuid", cmdResult.Identifier(), "execution_id", cmdResult.CommandUUID)
|
|
}
|
|
user, act, err := svc.ds.GetPastActivityDataForVPPAppInstall(r.Context, cmdResult)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
// Then this isn't a VPP install, so no activity generated
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, ctxerr.Wrap(r.Context, err, "fetching data for installed app store app activity")
|
|
}
|
|
act.FromSetupExperience = fromSetupExperience
|
|
if err := newActivity(r.Context, user, act, svc.ds, svc.logger); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "creating activity for installed app store app")
|
|
}
|
|
}
|
|
|
|
// If the command succeeded, then start the install verification process.
|
|
if cmdResult.Status == fleet.MDMAppleStatusAcknowledged {
|
|
// Only send a new InstalledApplicationList command if there's not one in flight
|
|
commandsPending, err := svc.ds.IsHostPendingMDMInstallVerification(r.Context, cmdResult.Identifier())
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "get pending mdm commands by host")
|
|
}
|
|
if !commandsPending {
|
|
cmdUUID := fleet.VerifySoftwareInstallCommandUUID()
|
|
// for app verification, we always request only managed apps
|
|
if err := svc.commander.InstalledApplicationList(r.Context, []string{cmdResult.Identifier()}, cmdUUID, true); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "sending list app command to verify install")
|
|
}
|
|
|
|
// update the install record
|
|
if err := svc.ds.AssociateMDMInstallToVerificationUUID(r.Context, cmdResult.CommandUUID, cmdUUID, cmdResult.Identifier()); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "update install record")
|
|
}
|
|
|
|
}
|
|
}
|
|
case "DeviceConfigured":
|
|
if err := svc.ds.SetHostAwaitingConfiguration(r.Context, r.ID, false); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "failed to mark host as non longer awaiting configuration")
|
|
}
|
|
case "InstalledApplicationList":
|
|
level.Debug(svc.logger).Log("msg", "calling handlers for InstalledApplicationList")
|
|
host, err := svc.ds.HostByIdentifier(r.Context, cmdResult.Identifier())
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "get host by identifier")
|
|
}
|
|
res, err := NewInstalledApplicationListResult(r.Context, cmdResult.Raw, cmdResult.CommandUUID, cmdResult.Identifier(), host.Platform)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "new installed application list result")
|
|
}
|
|
|
|
for _, f := range svc.commandHandlers["InstalledApplicationList"] {
|
|
if err := f(r.Context, res); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "InstalledApplicationList handler failed")
|
|
}
|
|
}
|
|
|
|
case fleet.DeviceLocationCmdName:
|
|
if cmdResult.Status == fleet.MDMAppleStatusAcknowledged {
|
|
host, err := svc.ds.HostByIdentifier(r.Context, cmdResult.Identifier())
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "device location command result: get host by identifier")
|
|
}
|
|
|
|
res, err := NewDeviceLocationResult(cmdResult, host.ID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "build device location command result")
|
|
}
|
|
err = svc.runCommandHandlers(r.Context, fleet.DeviceLocationCmdName, res)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "DeviceLocation: calling handlers")
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func (svc *MDMAppleCheckinAndCommandService) handleRefetch(r *mdm.Request, cmdResult *mdm.CommandResults) (*mdm.Command, error) {
|
|
ctx := r.Context
|
|
host, err := svc.ds.HostByIdentifier(ctx, cmdResult.Identifier())
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to get host by identifier")
|
|
}
|
|
|
|
switch {
|
|
case strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchAppsCommandUUIDPrefix):
|
|
return svc.handleRefetchAppsResults(ctx, host, cmdResult)
|
|
|
|
case strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchCertsCommandUUIDPrefix):
|
|
return svc.handleRefetchCertsResults(ctx, host, cmdResult)
|
|
|
|
case strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchDeviceCommandUUIDPrefix):
|
|
// for devices added via legacy enrollment flows, we may need to set an enroll reference
|
|
// we don't expect this info to change often so we're only checking on device info refetches
|
|
if r.Params != nil {
|
|
if _, err := svc.maybeUpdateIDeviceEnrollRef(ctx, host, r.Params["enroll_reference"]); err != nil {
|
|
// TODO: consider if we want to return an error here, for now we just log and continue
|
|
level.Error(svc.logger).Log("msg", "maybe update enroll reference", "host_uuid", host.UUID, "enroll_reference", r.Params["enroll_reference"], "err", err)
|
|
}
|
|
}
|
|
return svc.handleRefetchDeviceResults(ctx, host, cmdResult)
|
|
|
|
default:
|
|
// This should never happen, but just in case we'll return an error.
|
|
return nil, ctxerr.New(ctx, fmt.Sprintf("unknown refetch command type %s", cmdResult.CommandUUID))
|
|
}
|
|
}
|
|
|
|
func (svc *MDMAppleCheckinAndCommandService) maybeUpdateIDeviceEnrollRef(ctx context.Context, host *fleet.Host, enrollRef string) (bool, error) {
|
|
if host.Platform != "ios" && host.Platform != "ipados" {
|
|
// caller should ensure this doesn't happen, but just in case we'll log it and return false
|
|
level.Debug(svc.logger).Log("msg", "unexpected usage of maybeUpdateIDeviceEnrollRef for non-iOS/non-iPadOS host", "host_id", host.ID, "host_uuid", host.UUID, "platform", host.Platform)
|
|
return false, nil
|
|
}
|
|
hmer, err := svc.ds.GetMDMAppleHostMDMEnrollRef(ctx, host.ID)
|
|
if err != nil {
|
|
return false, ctxerr.Wrap(ctx, err, "checking enroll reference")
|
|
}
|
|
if hmer == enrollRef {
|
|
// no change so return early
|
|
return false, nil
|
|
}
|
|
|
|
level.Info(svc.logger).Log("msg", "updating enroll reference for host", "host_id", host.ID, "host_uuid", host.UUID, "old_enroll_ref", hmer, "new_enroll_ref", enrollRef)
|
|
didUpdate, err := svc.ds.UpdateMDMAppleHostMDMEnrollRef(ctx, host.ID, enrollRef)
|
|
if err != nil {
|
|
return false, ctxerr.Wrap(ctx, err, "updating enroll reference")
|
|
}
|
|
|
|
if !didUpdate {
|
|
level.Debug(svc.logger).Log("msg", "unexpected enroll reference update no-op", "host_id", host.ID, "host_uuid", host.UUID)
|
|
}
|
|
|
|
// clear SCEP renew refs if any
|
|
if err := svc.ds.DeactivateMDMAppleHostSCEPRenewCommands(ctx, host.UUID); err != nil {
|
|
return didUpdate, ctxerr.Wrap(ctx, err, "updating enroll reference: deactivate renew commands")
|
|
}
|
|
|
|
return didUpdate, nil
|
|
}
|
|
|
|
func (svc *MDMAppleCheckinAndCommandService) handleRefetchAppsResults(ctx context.Context, host *fleet.Host, cmdResult *mdm.CommandResults) (*mdm.Command, error) {
|
|
if !strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchAppsCommandUUIDPrefix) {
|
|
// Caller should have checked this, but just in case we'll return an error.
|
|
return nil, ctxerr.New(ctx, fmt.Sprintf("expected REFETCH-APPS- prefix but got %s", cmdResult.CommandUUID))
|
|
}
|
|
|
|
// We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch.
|
|
if err := svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{
|
|
HostID: host.ID,
|
|
CommandType: fleet.RefetchAppsCommandUUIDPrefix,
|
|
}); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "remove refetch apps command")
|
|
}
|
|
|
|
if host.Platform != "ios" && host.Platform != "ipados" {
|
|
return nil, ctxerr.New(ctx, "refetch apps command sent to non-iOS/non-iPadOS host")
|
|
}
|
|
source := "ios_apps"
|
|
if host.Platform == "ipados" {
|
|
source = "ipados_apps"
|
|
}
|
|
|
|
response := cmdResult.Raw
|
|
software, err := unmarshalAppList(ctx, response, source)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "unmarshal app list")
|
|
}
|
|
|
|
if _, err := svc.ds.UpdateHostSoftware(ctx, host.ID, software); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "update host software")
|
|
}
|
|
|
|
if svc.isPremium {
|
|
if err := svc.handleScheduledUpdates(ctx, host, software); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "handle scheduled updates")
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
var versionPattern = regexp.MustCompile(
|
|
`^v?\s*(\d+(?:\.\d+)*)\s*$`,
|
|
)
|
|
|
|
// trimLeadingZeros converts "00123" → "123", "000" → "0", "0" → "0"
|
|
func trimLeadingZeros(s string) string {
|
|
s = strings.TrimLeft(s, "0")
|
|
if s == "" {
|
|
return "0"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// toValidSemVer is a best effort transformation to make `version` a valid semantic version.
|
|
// Currently doesn't support fixing versions that have non-numerical pre-release strings (because
|
|
// we haven't seen those in the wild for the apps where this method is used, currently VPP apps).
|
|
func toValidSemVer(version string) string {
|
|
// Cleanup spaces.
|
|
version = strings.TrimSpace(version)
|
|
if version == "" {
|
|
// Empty version, nothing to clean up.
|
|
return version
|
|
}
|
|
|
|
versionModified := strings.ReplaceAll(version, "-", ".")
|
|
matches := versionPattern.FindStringSubmatch(versionModified)
|
|
if matches == nil {
|
|
// May not be a valid version string, nothing we can do.
|
|
return version
|
|
}
|
|
|
|
partsStr := matches[1]
|
|
parts := strings.Split(partsStr, ".")
|
|
|
|
// Clean each numeric part (remove leading zeros)
|
|
// Leading zeros are not valid in semantic versioning.
|
|
cleanParts := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
clean := trimLeadingZeros(p)
|
|
cleanParts = append(cleanParts, clean)
|
|
}
|
|
|
|
switch len(cleanParts) {
|
|
case 1: // major
|
|
version = cleanParts[0]
|
|
case 2: // major.minor
|
|
version = fmt.Sprintf("%s.%s", cleanParts[0], cleanParts[1])
|
|
case 3: // major.minor.patch
|
|
version = fmt.Sprintf("%s.%s.%s", cleanParts[0], cleanParts[1], cleanParts[2])
|
|
case 4: // major.minor.patch.build
|
|
build := cleanParts[3]
|
|
if build == "0" {
|
|
version = fmt.Sprintf("%s.%s.%s", cleanParts[0], cleanParts[1], cleanParts[2])
|
|
} else {
|
|
version = fmt.Sprintf("%s.%s.%s-%s", cleanParts[0], cleanParts[1], cleanParts[2], build)
|
|
}
|
|
default: // For safety: more than 4 parts, take first 3 + rest as pre-release.
|
|
version = fmt.Sprintf("%s.%s.%s-%s",
|
|
cleanParts[0],
|
|
cleanParts[1],
|
|
cleanParts[2],
|
|
strings.Join(cleanParts[3:], "."))
|
|
}
|
|
|
|
return version
|
|
}
|
|
|
|
func (svc *MDMAppleCheckinAndCommandService) handleScheduledUpdates(
|
|
ctx context.Context,
|
|
host *fleet.Host,
|
|
softwares []fleet.Software,
|
|
) error {
|
|
logger := svc.logger.With(
|
|
"method", "handle_scheduled_updates",
|
|
"host_id", host.ID,
|
|
)
|
|
|
|
if host.TimeZone == nil || *host.TimeZone == "" {
|
|
// We cannot determine if it's safe to schedule an update on this host.
|
|
level.Debug(logger).Log("msg", "skipping updates, host has no timezone")
|
|
return nil
|
|
}
|
|
|
|
// Get VPP token, fail early if we cannot get it
|
|
// (e.g. not configured, or not configured for the host's team).
|
|
vppToken, err := svc.ds.GetVPPTokenByTeamID(ctx, host.TeamID)
|
|
switch {
|
|
case err == nil:
|
|
// OK
|
|
case fleet.IsNotFound(err):
|
|
level.Debug(logger).Log("msg", "no VPP token configured for this host's team")
|
|
return nil
|
|
default:
|
|
return ctxerr.Wrap(ctx, err, "get VPP token if can install VPP apps")
|
|
}
|
|
|
|
// Check if the device is managed or BYOD.
|
|
enrollment, err := svc.ds.GetNanoMDMEnrollment(ctx, host.UUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting nano mdm enrollment")
|
|
}
|
|
if enrollment == nil {
|
|
level.Debug(logger).Log("msg", "skipping updates, missing nano enrollment type")
|
|
return nil
|
|
}
|
|
if enrollment.Type == mdm.EnrollType(mdm.UserEnrollmentDevice).String() {
|
|
level.Debug(logger).Log("msg", "skipping updates, software install isn't supported on personal (BYOD) iOS and iPadOS hosts")
|
|
return nil
|
|
}
|
|
|
|
var teamID uint
|
|
if host.TeamID != nil {
|
|
teamID = *host.TeamID
|
|
}
|
|
source := "ios_apps"
|
|
if host.Platform == string(fleet.IPadOSPlatform) {
|
|
source = "ipados_apps"
|
|
}
|
|
softwaresWithAutoUpdateSchedule, err := svc.ds.ListSoftwareAutoUpdateSchedules(ctx,
|
|
teamID,
|
|
source,
|
|
fleet.SoftwareAutoUpdateScheduleFilter{
|
|
Enabled: ptr.Bool(true),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "list software auto update schedules")
|
|
}
|
|
|
|
// Code below assumes svc.ds.ListSoftwareAutoUpdateSchedules with Enabled=true returns:
|
|
// - all entries with non-nil AutoUpdateStartTime and AutoUpdateEndTime
|
|
// - returned title IDs are VPP applications (currently the only entities that can have update window configured).
|
|
|
|
if len(softwaresWithAutoUpdateSchedule) == 0 {
|
|
// Nothing else to do.
|
|
return nil
|
|
}
|
|
level.Debug(logger).Log(
|
|
"msg", "found software with auto update scheduled",
|
|
"count", len(softwaresWithAutoUpdateSchedule),
|
|
)
|
|
|
|
// Create map of installed software title versions by bundle identifier and source.
|
|
installedVersionByBundleIdentifierAndSource := make(map[string]string, len(softwares))
|
|
for _, software := range softwares {
|
|
installedVersionByBundleIdentifierAndSource[software.BundleIdentifier+software.Source] = software.Version
|
|
}
|
|
|
|
// 1. Filter out software that is not within the configured update window in the host timezone.
|
|
var softwaresWithinUpdateSchedule []fleet.SoftwareAutoUpdateSchedule
|
|
for _, softwareWithAutoUpdateSchedule := range softwaresWithAutoUpdateSchedule {
|
|
logger := logger.With(
|
|
"software_title_id", softwareWithAutoUpdateSchedule.TitleID,
|
|
"team_id", softwareWithAutoUpdateSchedule.TeamID,
|
|
"update_window_start", softwareWithAutoUpdateSchedule.AutoUpdateStartTime,
|
|
"update_window_end", softwareWithAutoUpdateSchedule.AutoUpdateEndTime,
|
|
"host_timezone", *host.TimeZone,
|
|
)
|
|
ok, err := isTimezoneInWindow(ctx,
|
|
*host.TimeZone,
|
|
*softwareWithAutoUpdateSchedule.AutoUpdateStartTime,
|
|
*softwareWithAutoUpdateSchedule.AutoUpdateEndTime,
|
|
)
|
|
if err != nil {
|
|
level.Error(logger).Log(
|
|
"msg", "skipping software, failed to check if timezone is in window",
|
|
"err", err,
|
|
)
|
|
continue
|
|
}
|
|
if !ok {
|
|
level.Debug(logger).Log(
|
|
"msg", "host's local time is not within update window",
|
|
)
|
|
continue
|
|
}
|
|
softwaresWithinUpdateSchedule = append(softwaresWithinUpdateSchedule, softwareWithAutoUpdateSchedule)
|
|
}
|
|
if len(softwaresWithinUpdateSchedule) == 0 {
|
|
// Nothing else to do.
|
|
return nil
|
|
}
|
|
level.Debug(logger).Log(
|
|
"msg", "found software with auto update scheduled, with host local time currently in window",
|
|
"count", len(softwaresWithinUpdateSchedule),
|
|
)
|
|
|
|
// 2. Filter out software that is already at the latest version or higher.
|
|
var (
|
|
softwaresWithinUpdateWindowThatNeedUpdate []fleet.SoftwareAutoUpdateSchedule
|
|
softwareTitles = make(map[uint]*fleet.SoftwareTitle)
|
|
)
|
|
for _, softwareWithAutoUpdateSchedule := range softwaresWithinUpdateSchedule {
|
|
// Load software title.
|
|
teamID := host.TeamID
|
|
if teamID == nil {
|
|
teamID = ptr.Uint(0)
|
|
}
|
|
softwareTitle, err := svc.ds.SoftwareTitleByID(ctx, softwareWithAutoUpdateSchedule.TitleID, teamID, fleet.TeamFilter{})
|
|
if err != nil {
|
|
level.Error(logger).Log(
|
|
"msg", "software title by id",
|
|
"software_title_id", softwareWithAutoUpdateSchedule.TitleID,
|
|
"team_id", host.TeamID,
|
|
"err", err,
|
|
)
|
|
continue
|
|
}
|
|
logger := logger.With(
|
|
"name", softwareTitle.Name,
|
|
"bundle_identifier", softwareTitle.BundleIdentifier,
|
|
"source", softwareTitle.Source,
|
|
)
|
|
|
|
// Load VPP metadata for the software title.
|
|
vppAppMetadata, err := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, host.TeamID, softwareTitle.ID)
|
|
switch {
|
|
case err == nil:
|
|
// OK
|
|
case fleet.IsNotFound(err):
|
|
level.Error(logger).Log(
|
|
"msg", "title should be VPP app",
|
|
"software_title_id", softwareTitle.ID,
|
|
"team_id", host.TeamID,
|
|
)
|
|
continue
|
|
default:
|
|
level.Error(logger).Log(
|
|
"msg", "get VPP app metadata by team and title",
|
|
"software_title_id", softwareTitle.ID,
|
|
"team_id", host.TeamID,
|
|
"err", err,
|
|
)
|
|
continue
|
|
}
|
|
softwareTitle.AppStoreApp = vppAppMetadata
|
|
|
|
// Incoming softwares have Name, BundleIdentifier, Source and Version.
|
|
var bundleIdentifier string
|
|
if softwareTitle.BundleIdentifier != nil {
|
|
bundleIdentifier = *softwareTitle.BundleIdentifier
|
|
}
|
|
installedVersion, ok := installedVersionByBundleIdentifierAndSource[bundleIdentifier+softwareTitle.Source]
|
|
if !ok {
|
|
// There are some cases where InstalledApplicationList skips the software from the list
|
|
// when the update is ocurring. It seems the software is probably being skipped because
|
|
// it's on a temporary state of installation/replacement.
|
|
level.Debug(logger).Log(
|
|
"msg", "software title not installed on device or currently in the process of updating, skipping from update",
|
|
"name", softwareTitle.Name,
|
|
"bundle_identifier", bundleIdentifier,
|
|
"source", softwareTitle.Source,
|
|
)
|
|
continue
|
|
}
|
|
installedVersion = toValidSemVer(installedVersion)
|
|
if installedVersion == "" {
|
|
// software.Version is empty when !software.Installed, which means the software is installing (see unmarshalAppList).
|
|
// Here's a sample:
|
|
//
|
|
// <dict>
|
|
// <key>Identifier</key>
|
|
// <string>foo.bar.app</string>
|
|
// <key>Installing</key>
|
|
// <true/>
|
|
// <key>Name</key>
|
|
// <string>Foobar</string>
|
|
// </dict>
|
|
//
|
|
// Note that "Installing" is true and there's no "ShortVersion":
|
|
level.Error(logger).Log(
|
|
"msg", "skipping software, currently installing",
|
|
)
|
|
continue
|
|
}
|
|
if _, err := fleet.VersionToSemverVersion(installedVersion); err != nil {
|
|
level.Error(logger).Log(
|
|
"msg", "invalid installed version",
|
|
"version", installedVersion,
|
|
)
|
|
continue
|
|
}
|
|
latestVersion := toValidSemVer(softwareTitle.AppStoreApp.LatestVersion)
|
|
if _, err := fleet.VersionToSemverVersion(latestVersion); err != nil {
|
|
level.Error(logger).Log(
|
|
"msg", "invalid latest version",
|
|
"version", latestVersion,
|
|
)
|
|
continue
|
|
}
|
|
if fleet.CompareVersions(latestVersion, installedVersion) != 1 {
|
|
// Installed version is equal or higher than latest version, so nothing to do here.
|
|
level.Debug(logger).Log(
|
|
"msg", "skipping software version",
|
|
"latest_version", latestVersion,
|
|
"installed_version", installedVersion,
|
|
)
|
|
continue
|
|
}
|
|
softwaresWithinUpdateWindowThatNeedUpdate = append(softwaresWithinUpdateWindowThatNeedUpdate, softwareWithAutoUpdateSchedule)
|
|
softwareTitles[softwareTitle.ID] = softwareTitle
|
|
}
|
|
if len(softwaresWithinUpdateWindowThatNeedUpdate) == 0 {
|
|
// Nothing else to do.
|
|
return nil
|
|
}
|
|
level.Debug(logger).Log(
|
|
"msg", "found software with auto update scheduled, with host local time currently in window, that need update",
|
|
"count", len(softwaresWithinUpdateWindowThatNeedUpdate),
|
|
)
|
|
|
|
// 3. Filter out software that has been issued an install on this host in the last hour.
|
|
//
|
|
// The main reason we must do this filtering is because if the target application is currently in use
|
|
// by the end-user, then the app installation has been acknowledged and verified, but the reported version
|
|
// by InstalledApplicationList is still the old version until the user closes the app or the device goes to
|
|
// sleep and the app is closed and reopened automatically.
|
|
//
|
|
adamIDsRecentInstallForHost, err := svc.ds.MapAdamIDsRecentInstalls(ctx, host.ID, 3600)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "get Adam IDs recent installs for host")
|
|
}
|
|
var softwaresWithinUpdateScheduleNoRecentInstalls []fleet.SoftwareAutoUpdateSchedule
|
|
for _, softwareWithinUpdateSchedule := range softwaresWithinUpdateWindowThatNeedUpdate {
|
|
softwareTitle, ok := softwareTitles[softwareWithinUpdateSchedule.TitleID]
|
|
if !ok {
|
|
// "Should not happen", so we log it just in case.
|
|
level.Error(logger).Log(
|
|
"msg", "missing title ID from map",
|
|
"software_title_id", softwareWithinUpdateSchedule.TitleID,
|
|
)
|
|
continue
|
|
}
|
|
if _, ok := adamIDsRecentInstallForHost[softwareTitle.AppStoreApp.AdamID]; ok {
|
|
level.Debug(logger).Log(
|
|
"msg", "skipping software, recent install for title",
|
|
"software_title_id", softwareTitle.ID,
|
|
"adam_id", softwareTitle.AppStoreApp.AdamID,
|
|
)
|
|
continue
|
|
}
|
|
softwaresWithinUpdateScheduleNoRecentInstalls = append(softwaresWithinUpdateScheduleNoRecentInstalls, softwareWithinUpdateSchedule)
|
|
}
|
|
if len(softwaresWithinUpdateScheduleNoRecentInstalls) == 0 {
|
|
// Nothing else to do.
|
|
return nil
|
|
}
|
|
level.Debug(logger).Log(
|
|
"msg", "found software with auto update scheduled, with host local time currently in window, that need update, no recent install",
|
|
"count", len(softwaresWithinUpdateScheduleNoRecentInstalls),
|
|
)
|
|
|
|
// 4. Filter out software that already has a pending installation.
|
|
adamIDsPendingInstallForHost, err := svc.ds.MapAdamIDsPendingInstallVerification(ctx, host.ID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "get Adam IDs pending install for host")
|
|
}
|
|
var softwaresWithinUpdateScheduleToInstall []*fleet.SoftwareTitle
|
|
for _, softwareWithinUpdateSchedule := range softwaresWithinUpdateScheduleNoRecentInstalls {
|
|
softwareTitle, ok := softwareTitles[softwareWithinUpdateSchedule.TitleID]
|
|
if !ok {
|
|
// "Should not happen", so we log it just in case.
|
|
level.Error(logger).Log(
|
|
"msg", "missing title ID from map",
|
|
"software_title_id", softwareWithinUpdateSchedule.TitleID,
|
|
)
|
|
continue
|
|
}
|
|
if _, ok := adamIDsPendingInstallForHost[softwareTitle.AppStoreApp.AdamID]; ok {
|
|
level.Debug(logger).Log(
|
|
"msg", "skipping software, pending install for title",
|
|
"software_title_id", softwareTitle.ID,
|
|
"adam_id", softwareTitle.AppStoreApp.AdamID,
|
|
)
|
|
continue
|
|
}
|
|
softwaresWithinUpdateScheduleToInstall = append(softwaresWithinUpdateScheduleToInstall, softwareTitle)
|
|
}
|
|
if len(softwaresWithinUpdateScheduleToInstall) == 0 {
|
|
// Nothing else to do.
|
|
return nil
|
|
}
|
|
level.Debug(logger).Log(
|
|
"msg", "found software with auto update scheduled, with host local time currently in window, that need update, no recent install, no pending installation",
|
|
"count", len(softwaresWithinUpdateScheduleToInstall),
|
|
)
|
|
|
|
// 5. Issue installation of the software titles to update.
|
|
for _, softwareTitle := range softwaresWithinUpdateScheduleToInstall {
|
|
var bundleIdentifier string
|
|
if softwareTitle.BundleIdentifier != nil {
|
|
bundleIdentifier = *softwareTitle.BundleIdentifier
|
|
}
|
|
logger := logger.With(
|
|
"software_title_id", softwareTitle.ID,
|
|
"team_id", host.TeamID,
|
|
"adam_id", softwareTitle.AppStoreApp.AdamID,
|
|
"latest_version", softwareTitle.AppStoreApp.LatestVersion,
|
|
"installed_version", installedVersionByBundleIdentifierAndSource[bundleIdentifier+softwareTitle.Source],
|
|
)
|
|
|
|
vppApp, err := svc.ds.GetVPPAppByTeamAndTitleID(ctx, host.TeamID, softwareTitle.ID)
|
|
if err != nil {
|
|
level.Error(logger).Log(
|
|
"msg", "get VPP app by team and title",
|
|
"err", err,
|
|
)
|
|
continue
|
|
}
|
|
|
|
// Check the label scoping for this VPP app and host.
|
|
scoped, err := svc.ds.IsVPPAppLabelScoped(ctx, vppApp.VPPAppTeam.AppTeamID, host.ID)
|
|
if err != nil {
|
|
level.Error(logger).Log(
|
|
"msg", "get VPP app by team and title",
|
|
"err", err,
|
|
)
|
|
continue
|
|
}
|
|
if !scoped {
|
|
level.Debug(logger).Log(
|
|
"msg", "skipping host because it's not scoped by the configured labels",
|
|
)
|
|
continue
|
|
}
|
|
|
|
commandUUID, err := svc.vppInstaller.InstallVPPAppPostValidation(ctx, host, vppApp, vppToken.Token, fleet.HostSoftwareInstallOptions{
|
|
ForScheduledUpdates: true,
|
|
})
|
|
if err != nil {
|
|
level.Error(logger).Log(
|
|
"msg", "install VPP app post validation",
|
|
"err", err,
|
|
)
|
|
continue
|
|
}
|
|
|
|
level.Debug(logger).Log(
|
|
"msg", "update scheduled",
|
|
"command_uuid", commandUUID,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// nowFunc is to be used in tests.
|
|
var nowFunc = time.Now
|
|
|
|
// getCurrentLocalTimeInHostTimeZone returns the current time of the given IANA time zone string.
|
|
func getCurrentLocalTimeInHostTimeZone(ctx context.Context, timeZone string) (time.Time, error) {
|
|
loc, err := time.LoadLocation(timeZone)
|
|
if err != nil {
|
|
return time.Time{}, ctxerr.Wrap(ctx, err, "load location")
|
|
}
|
|
|
|
// Convert now to the specified location using the In() method
|
|
localTime := nowFunc().In(loc)
|
|
return localTime, nil
|
|
}
|
|
|
|
// isTimezoneInWindow checks if the given timezone is currently within
|
|
// the time window defined by start and end.
|
|
// Arguments start and end should be in "HH:MM" format (24-hour clock).
|
|
// Returns true if the timezone current time is within [start, end], inclusive.
|
|
// Handles windows that cross midnight (e.g., "22:00" to "06:00").
|
|
func isTimezoneInWindow(ctx context.Context, timezone string, start string, end string) (bool, error) {
|
|
t, err := getCurrentLocalTimeInHostTimeZone(ctx, timezone)
|
|
if err != nil {
|
|
return false, ctxerr.Wrap(ctx, err, "get current local time in host timezone")
|
|
}
|
|
|
|
// Parse hour and minute from start and end strings
|
|
startHour, startMin, err := parseHHMM(start)
|
|
if err != nil {
|
|
return false, fmt.Errorf("invalid start time: %w", err)
|
|
}
|
|
endHour, endMin, err := parseHHMM(end)
|
|
if err != nil {
|
|
return false, fmt.Errorf("invalid end time: %w", err)
|
|
}
|
|
|
|
// Get the clock time from t (in its own location)
|
|
currentHour := t.Hour()
|
|
currentMin := t.Minute()
|
|
|
|
// Convert everything to minutes since midnight for easy comparison
|
|
currentMins := currentHour*60 + currentMin
|
|
startMins := startHour*60 + startMin
|
|
endMins := endHour*60 + endMin
|
|
|
|
// Normal case: window does not cross midnight
|
|
if startMins <= endMins {
|
|
return currentMins >= startMins && currentMins <= endMins, nil
|
|
}
|
|
|
|
// Window crosses midnight (e.g., 22:00 to 06:00)
|
|
// True if time is after start OR before end
|
|
return currentMins >= startMins || currentMins <= endMins, nil
|
|
}
|
|
|
|
// parseHHMM parses "HH:MM" into hour and minute.
|
|
func parseHHMM(s string) (hour, min_ int, err error) {
|
|
var h, m int
|
|
n, err := fmt.Sscanf(s, "%d:%d", &h, &m)
|
|
if err != nil || n != 2 {
|
|
return 0, 0, fmt.Errorf("expected HH:MM format, got %q", s)
|
|
}
|
|
if h < 0 || h > 23 {
|
|
return 0, 0, fmt.Errorf("hour must be 0-23, got %d", h)
|
|
}
|
|
if m < 0 || m > 59 {
|
|
return 0, 0, fmt.Errorf("minute must be 0-59, got %d", m)
|
|
}
|
|
return h, m, nil
|
|
}
|
|
|
|
func (svc *MDMAppleCheckinAndCommandService) handleRefetchCertsResults(ctx context.Context, host *fleet.Host, cmdResult *mdm.CommandResults) (*mdm.Command, error) {
|
|
if !strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchCertsCommandUUIDPrefix) {
|
|
// Caller should have checked this, but just in case we'll return an error.
|
|
return nil, ctxerr.New(ctx, fmt.Sprintf("expected REFETCH-CERTS- prefix but got %s", cmdResult.CommandUUID))
|
|
}
|
|
|
|
// We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch.
|
|
if err := svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{
|
|
HostID: host.ID,
|
|
CommandType: fleet.RefetchCertsCommandUUIDPrefix,
|
|
}); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "refetch certs: remove refetch command")
|
|
}
|
|
|
|
// TODO(mna): when we add iOS/iPadOS support for https://github.com/fleetdm/fleet/issues/26913,
|
|
// this is where we'll need to identify user-keychain certs for iPad/iPhone. For now we set
|
|
// them all as "system" certificates.
|
|
var listResp fleet.MDMAppleCertificateListResponse
|
|
if err := plist.Unmarshal(cmdResult.Raw, &listResp); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "refetch certs: unmarshal certificate list command result")
|
|
}
|
|
payload := make([]*fleet.HostCertificateRecord, 0, len(listResp.CertificateList))
|
|
for _, cert := range listResp.CertificateList {
|
|
parsed, err := cert.Parse(host.ID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "refetch certs: parse certificate")
|
|
}
|
|
payload = append(payload, parsed)
|
|
}
|
|
|
|
if err := svc.ds.UpdateHostCertificates(ctx, host.ID, host.UUID, payload); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "refetch certs: update host certificates")
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func (svc *MDMAppleCheckinAndCommandService) handleRefetchDeviceResults(ctx context.Context, host *fleet.Host, cmdResult *mdm.CommandResults) (*mdm.Command, error) {
|
|
if !strings.HasPrefix(cmdResult.CommandUUID, fleet.RefetchDeviceCommandUUIDPrefix) {
|
|
// Caller should have checked this, but just in case we'll return an error.
|
|
return nil, ctxerr.New(ctx, fmt.Sprintf("expected REFETCH-DEVICE- prefix but got %s", cmdResult.CommandUUID))
|
|
}
|
|
|
|
// We remove pending command first in case there is an error processing the results, so that we don't prevent another refetch.
|
|
if err := svc.ds.RemoveHostMDMCommand(ctx, fleet.HostMDMCommand{
|
|
HostID: host.ID,
|
|
CommandType: fleet.RefetchDeviceCommandUUIDPrefix,
|
|
}); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "remove refetch device command")
|
|
}
|
|
|
|
var deviceInformationResponse struct {
|
|
QueryResponses map[string]interface{} `plist:"QueryResponses"`
|
|
}
|
|
if err := plist.Unmarshal(cmdResult.Raw, &deviceInformationResponse); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to unmarshal device information command result")
|
|
}
|
|
deviceName := deviceInformationResponse.QueryResponses["DeviceName"].(string)
|
|
deviceCapacity := deviceInformationResponse.QueryResponses["DeviceCapacity"].(float64)
|
|
availableDeviceCapacity := deviceInformationResponse.QueryResponses["AvailableDeviceCapacity"].(float64)
|
|
osVersion := deviceInformationResponse.QueryResponses["OSVersion"].(string)
|
|
var wifiMac string
|
|
wifiMacVal, ok := deviceInformationResponse.QueryResponses["WiFiMAC"]
|
|
if ok {
|
|
// WiFiMAC info is not present for user-enrolled devices
|
|
wifiMac = wifiMacVal.(string)
|
|
}
|
|
productName := deviceInformationResponse.QueryResponses["ProductName"].(string)
|
|
isLostModeEnabled := false
|
|
isLostModeEnabledVal, ok := deviceInformationResponse.QueryResponses["IsMDMLostModeEnabled"]
|
|
if ok {
|
|
isLostModeEnabled = isLostModeEnabledVal.(bool)
|
|
}
|
|
host.ComputerName = deviceName
|
|
host.Hostname = deviceName
|
|
host.GigsDiskSpaceAvailable = availableDeviceCapacity
|
|
host.GigsTotalDiskSpace = deviceCapacity
|
|
var (
|
|
osVersionPrefix string
|
|
platform string
|
|
)
|
|
if strings.HasPrefix(productName, "iPhone") || strings.HasPrefix(productName, "iPod") {
|
|
osVersionPrefix = "iOS"
|
|
platform = "ios"
|
|
} else { // iPad
|
|
osVersionPrefix = "iPadOS"
|
|
platform = "ipados"
|
|
}
|
|
host.OSVersion = osVersionPrefix + " " + osVersion
|
|
host.PrimaryMac = wifiMac
|
|
host.HardwareModel = productName
|
|
host.DetailUpdatedAt = time.Now()
|
|
// iOS/iPadOS devices do not support dynamic labels at this time so we should update their LabelUpdatedAt timestamp
|
|
// on refetch similar to other platforms to simplify exclusion logic with dynamic labels
|
|
host.LabelUpdatedAt = time.Now()
|
|
host.RefetchRequested = false
|
|
|
|
timeZone, _ := deviceInformationResponse.QueryResponses["TimeZone"].(string)
|
|
host.TimeZone = &timeZone
|
|
|
|
if err := svc.ds.UpdateHost(ctx, host); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to update host")
|
|
}
|
|
if err := svc.ds.SetOrUpdateHostDisksSpace(ctx, host.ID, availableDeviceCapacity, 100*availableDeviceCapacity/deviceCapacity,
|
|
deviceCapacity, nil); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to update host storage")
|
|
}
|
|
if err := svc.ds.UpdateHostOperatingSystem(ctx, host.ID, fleet.OperatingSystem{
|
|
Name: osVersionPrefix,
|
|
Version: osVersion,
|
|
Platform: platform,
|
|
}); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to update host operating system")
|
|
}
|
|
|
|
if host.MDM.EnrollmentStatus != nil && *host.MDM.EnrollmentStatus == "Pending" {
|
|
// Since the device has been refetched, we can assume it's enrolled.
|
|
if err := svc.ds.UpdateMDMData(ctx, host.ID, true); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to update MDM data")
|
|
}
|
|
|
|
// We run this check here as we only want to run it on re-check ins for deleted hosts.
|
|
if (platform == "ios" || platform == "ipados") && isLostModeEnabled {
|
|
cmd, err := svc.ds.GetLatestAppleMDMCommandOfType(ctx, host.UUID, "EnableLostMode")
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return nil, ctxerr.Wrap(ctx, err, "check for existing EnableLostMode command")
|
|
}
|
|
if fleet.IsNotFound(err) {
|
|
// Device is in lost mode, but we do not have a lock command record for it.
|
|
// Lost mode was enabled outside of Fleet?
|
|
return nil, ctxerr.NewWithData(ctx, "device is in lost mode but no EnableLostMode command record found", map[string]interface{}{"host_uuid": host.UUID})
|
|
}
|
|
|
|
level.Debug(svc.logger).Log("msg", "device is in lost mode and EnableLostMode command record found, updating host lock/wipe status", "host_uuid", host.UUID)
|
|
err = svc.ds.SetLockCommandForLostModeCheckin(ctx, host.ID, cmd.CommandUUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "update host lost mode status on refetch")
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
func unmarshalAppList(ctx context.Context, response []byte, source string) ([]fleet.Software,
|
|
error,
|
|
) {
|
|
var appsResponse struct {
|
|
InstalledApplicationList []map[string]interface{} `plist:"InstalledApplicationList"`
|
|
}
|
|
if err := plist.Unmarshal(response, &appsResponse); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to unmarshal installed application list command result")
|
|
}
|
|
|
|
truncateString := func(item interface{}, length int) string {
|
|
str, ok := item.(string)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
runes := []rune(str)
|
|
if len(runes) > length {
|
|
return string(runes[:length])
|
|
}
|
|
return str
|
|
}
|
|
|
|
var software []fleet.Software
|
|
for _, app := range appsResponse.InstalledApplicationList {
|
|
sw := fleet.Software{
|
|
Name: truncateString(app["Name"], fleet.SoftwareNameMaxLength),
|
|
Version: truncateString(app["ShortVersion"], fleet.SoftwareVersionMaxLength),
|
|
BundleIdentifier: truncateString(app["Identifier"], fleet.SoftwareBundleIdentifierMaxLength),
|
|
Source: source,
|
|
}
|
|
if val, ok := app["Installing"]; ok {
|
|
installing, ok := val.(bool)
|
|
if !ok {
|
|
return nil, ctxerr.New(ctx, "parsing Installing key")
|
|
}
|
|
|
|
sw.Installed = !installing
|
|
}
|
|
software = append(software, sw)
|
|
}
|
|
|
|
return software, nil
|
|
}
|
|
|
|
// mdmAppleDeliveryStatusFromCommandStatus converts a MDM command status to a
|
|
// fleet.MDMAppleDeliveryStatus.
|
|
//
|
|
// NOTE: this mapping does not include all
|
|
// possible delivery statuses (e.g., verified status is not included) is intended to
|
|
// only be used in the context of CommandAndReportResults in the MDMAppleCheckinAndCommandService.
|
|
// Extra care should be taken before using this function in other contexts.
|
|
func mdmAppleDeliveryStatusFromCommandStatus(cmdStatus string) *fleet.MDMDeliveryStatus {
|
|
switch cmdStatus {
|
|
case fleet.MDMAppleStatusAcknowledged:
|
|
return &fleet.MDMDeliveryVerifying
|
|
case fleet.MDMAppleStatusError, fleet.MDMAppleStatusCommandFormatError:
|
|
return &fleet.MDMDeliveryFailed
|
|
case fleet.MDMAppleStatusIdle, fleet.MDMAppleStatusNotNow:
|
|
return &fleet.MDMDeliveryPending
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// ensureFleetProfiles ensures there's a fleetd configuration profile in
|
|
// mdm_apple_configuration_profiles for each team and for "no team"
|
|
//
|
|
// We try our best to use each team's secret but we default to creating a
|
|
// profile with the global enroll secret if the team doesn't have any enroll
|
|
// secrets.
|
|
//
|
|
// This profile will be installed to all hosts in the team (or "no team",) but it
|
|
// will only be used by hosts that have a fleetd installation without an enroll
|
|
// secret and fleet URL (mainly DEP enrolled hosts).
|
|
func ensureFleetProfiles(ctx context.Context, ds fleet.Datastore, logger *platformlogging.Logger, signingCertDER []byte) error {
|
|
appCfg, err := ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "fetching app config")
|
|
}
|
|
|
|
var rootCAProfContents bytes.Buffer
|
|
params := mobileconfig.FleetCARootTemplateOptions{
|
|
PayloadIdentifier: mobileconfig.FleetCARootConfigPayloadIdentifier,
|
|
PayloadName: mdm_types.FleetCAConfigProfileName,
|
|
Certificate: base64.StdEncoding.EncodeToString(signingCertDER),
|
|
}
|
|
|
|
if err := mobileconfig.FleetCARootTemplate.Execute(&rootCAProfContents, params); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "executing fleet root CA config template")
|
|
}
|
|
|
|
b := rootCAProfContents.Bytes()
|
|
|
|
enrollSecrets, err := ds.AggregateEnrollSecretPerTeam(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting enroll secrets aggregates")
|
|
}
|
|
|
|
globalSecret := ""
|
|
for _, es := range enrollSecrets {
|
|
if es.TeamID == nil {
|
|
globalSecret = es.Secret
|
|
}
|
|
}
|
|
|
|
var profiles []*fleet.MDMAppleConfigProfile
|
|
for _, es := range enrollSecrets {
|
|
if es.Secret == "" {
|
|
var msg string
|
|
if es.TeamID != nil {
|
|
msg += fmt.Sprintf("team_id %d doesn't have an enroll secret, ", *es.TeamID)
|
|
}
|
|
if globalSecret == "" {
|
|
logger.Log("err", msg+"no global enroll secret found, skipping the creation of a com.fleetdm.fleetd.config profile")
|
|
continue
|
|
}
|
|
logger.Log("err", msg+"using a global enroll secret for com.fleetdm.fleetd.config profile")
|
|
es.Secret = globalSecret
|
|
}
|
|
|
|
var contents bytes.Buffer
|
|
params := mobileconfig.FleetdProfileOptions{
|
|
EnrollSecret: es.Secret,
|
|
ServerURL: appCfg.ServerSettings.ServerURL, // ServerURL must be set to the Fleet URL. Do not use appCfg.MDMUrl() here.
|
|
PayloadType: mobileconfig.FleetdConfigPayloadIdentifier,
|
|
PayloadName: mdm_types.FleetdConfigProfileName,
|
|
}
|
|
|
|
if err := mobileconfig.FleetdProfileTemplate.Execute(&contents, params); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "executing fleetd config template")
|
|
}
|
|
|
|
cp, err := fleet.NewMDMAppleConfigProfile(contents.Bytes(), es.TeamID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "building fleetd configuration profile")
|
|
}
|
|
profiles = append(profiles, cp)
|
|
|
|
rootCAProf, err := fleet.NewMDMAppleConfigProfile(b, es.TeamID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "building root CA configuration profile")
|
|
}
|
|
profiles = append(profiles, rootCAProf)
|
|
}
|
|
|
|
if err := ds.BulkUpsertMDMAppleConfigProfiles(ctx, profiles); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "bulk-upserting configuration profiles")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func SendPushesToPendingDevices(
|
|
ctx context.Context,
|
|
ds fleet.Datastore,
|
|
commander *apple_mdm.MDMAppleCommander,
|
|
logger *platformlogging.Logger,
|
|
) error {
|
|
enrollmentIDs, err := ds.GetEnrollmentIDsWithPendingMDMAppleCommands(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting host uuids with pending commands")
|
|
}
|
|
|
|
if len(enrollmentIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if err := commander.SendNotifications(ctx, enrollmentIDs); err != nil {
|
|
var apnsErr *apple_mdm.APNSDeliveryError
|
|
if errors.As(err, &apnsErr) {
|
|
level.Info(logger).Log("msg", "failed to send APNs notification to some hosts", "error", apnsErr.Error())
|
|
return nil
|
|
}
|
|
|
|
return ctxerr.Wrap(ctx, err, "sending push notifications")
|
|
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func ReconcileAppleDeclarations(
|
|
ctx context.Context,
|
|
ds fleet.Datastore,
|
|
commander *apple_mdm.MDMAppleCommander,
|
|
logger *platformlogging.Logger,
|
|
) error {
|
|
appConfig, err := ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("reading app config: %w", err)
|
|
}
|
|
if !appConfig.MDM.EnabledAndConfigured {
|
|
return nil
|
|
}
|
|
|
|
// batch set declarations as pending
|
|
changedHosts, err := ds.MDMAppleBatchSetHostDeclarationState(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "updating host declaration state")
|
|
}
|
|
|
|
// Find any hosts that requested a resync. This is used to cover special cases where we're not
|
|
// 100% certain of the declarations on the device.
|
|
resyncHosts, err := ds.MDMAppleHostDeclarationsGetAndClearResync(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting and clearing resync hosts")
|
|
}
|
|
if len(resyncHosts) > 0 {
|
|
changedHosts = append(changedHosts, resyncHosts...)
|
|
// Deduplicate changedHosts
|
|
uniqueHosts := make(map[string]struct{})
|
|
deduplicatedHosts := make([]string, 0, len(changedHosts))
|
|
for _, id := range changedHosts {
|
|
if _, exists := uniqueHosts[id]; !exists {
|
|
uniqueHosts[id] = struct{}{}
|
|
deduplicatedHosts = append(deduplicatedHosts, id)
|
|
}
|
|
}
|
|
changedHosts = deduplicatedHosts
|
|
}
|
|
|
|
if len(changedHosts) == 0 {
|
|
level.Info(logger).Log("msg", "no hosts with changed declarations")
|
|
return nil
|
|
}
|
|
|
|
// send a DeclarativeManagement command to start a sync
|
|
if err := commander.DeclarativeManagement(ctx, changedHosts, uuid.NewString()); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "issuing DeclarativeManagement command")
|
|
}
|
|
|
|
level.Info(logger).Log("msg", "sent DeclarativeManagement command", "host_number", len(changedHosts))
|
|
|
|
return nil
|
|
}
|
|
|
|
// install/removeTargets are maps from profileUUID -> command uuid and host
|
|
// UUIDs as the underlying MDM services are optimized to send one command to
|
|
// multiple hosts at the same time. Note that the same command uuid is used
|
|
// for all hosts in a given install/remove target operation.
|
|
type cmdTarget struct {
|
|
cmdUUID string
|
|
profIdent string
|
|
enrollmentIDs []string
|
|
}
|
|
|
|
// Number of hours to wait for a user enrollment to exist for a host after its
|
|
// device enrollment. After that duration, the user-scoped profiles will be
|
|
// delivered to the device-channel.
|
|
const hoursToWaitForUserEnrollmentAfterDeviceEnrollment = 2
|
|
|
|
func ReconcileAppleProfiles(
|
|
ctx context.Context,
|
|
ds fleet.Datastore,
|
|
commander *apple_mdm.MDMAppleCommander,
|
|
logger *platformlogging.Logger,
|
|
) error {
|
|
appConfig, err := ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("reading app config: %w", err)
|
|
}
|
|
if !appConfig.MDM.EnabledAndConfigured {
|
|
return nil
|
|
}
|
|
|
|
// Map of host UUID->User Channel enrollment ID so that we can cache them per-device
|
|
userEnrollmentMap := make(map[string]string)
|
|
userEnrollmentsToHostUUIDsMap := make(map[string]string) // the same thing in reverse
|
|
|
|
assets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
|
|
fleet.MDMAssetCACert,
|
|
}, nil)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting Apple SCEP")
|
|
}
|
|
|
|
block, _ := pem.Decode(assets[fleet.MDMAssetCACert].Value)
|
|
if block == nil || block.Type != "CERTIFICATE" {
|
|
return ctxerr.Wrap(ctx, err, "failed to decode PEM block from SCEP certificate")
|
|
}
|
|
|
|
if err := ensureFleetProfiles(ctx, ds, logger, block.Bytes); err != nil {
|
|
logger.Log("err", "unable to ensure a fleetd configuration profiles are in place", "details", err)
|
|
}
|
|
|
|
// retrieve the profiles to install/remove.
|
|
toInstall, toRemove, err := ds.ListMDMAppleProfilesToInstallAndRemove(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting profiles to install and remove")
|
|
}
|
|
|
|
// Exclude macOS only profiles from iPhones/iPads.
|
|
toInstall = fleet.FilterMacOSOnlyProfilesFromIOSIPadOS(toInstall)
|
|
|
|
getHostUserEnrollmentID := func(hostUUID string) (string, error) {
|
|
userEnrollmentID, ok := userEnrollmentMap[hostUUID]
|
|
if !ok {
|
|
userNanoEnrollment, err := ds.GetNanoMDMUserEnrollment(ctx, hostUUID)
|
|
if err != nil {
|
|
return "", ctxerr.Wrap(ctx, err, "getting user enrollment for host")
|
|
}
|
|
if userNanoEnrollment != nil {
|
|
userEnrollmentID = userNanoEnrollment.ID
|
|
}
|
|
userEnrollmentMap[hostUUID] = userEnrollmentID
|
|
if userEnrollmentID != "" {
|
|
userEnrollmentsToHostUUIDsMap[userEnrollmentID] = hostUUID
|
|
}
|
|
}
|
|
return userEnrollmentID, nil
|
|
}
|
|
|
|
isAwaitingUserEnrollment := func(prof *fleet.MDMAppleProfilePayload) (bool, error) {
|
|
if prof.Scope != fleet.PayloadScopeUser {
|
|
return false, nil
|
|
}
|
|
|
|
userEnrollmentID, err := getHostUserEnrollmentID(prof.HostUUID)
|
|
if userEnrollmentID != "" || err != nil {
|
|
// there is a user enrollment (so it is not waiting for one), or it failed looking for one
|
|
return false, err
|
|
}
|
|
|
|
if prof.DeviceEnrolledAt != nil && time.Since(*prof.DeviceEnrolledAt) < hoursToWaitForUserEnrollmentAfterDeviceEnrollment*time.Hour {
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// Perform aggregations to support all the operations we need to do
|
|
|
|
// toGetContents contains the UUIDs of all the profiles from which we
|
|
// need to retrieve contents. Since the previous query returns one row
|
|
// per host, it would be too expensive to retrieve the profile contents
|
|
// there, so we make another request. Using a map to deduplicate.
|
|
toGetContents := make(map[string]bool)
|
|
|
|
// hostProfiles tracks each host_mdm_apple_profile we need to upsert
|
|
// with the new status, operation_type, etc.
|
|
hostProfiles := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(toInstall)+len(toRemove))
|
|
|
|
// profileIntersection tracks profilesToAdd ∩ profilesToRemove, this is used to avoid:
|
|
//
|
|
// - Sending a RemoveProfile followed by an InstallProfile for a
|
|
// profile with an identifier that's already installed, which can cause
|
|
// racy behaviors.
|
|
// - Sending a InstallProfile command for a profile that's exactly the
|
|
// same as the one installed. Customers have reported that sending the
|
|
// command causes unwanted behavior.
|
|
profileIntersection := apple_mdm.NewProfileBimap()
|
|
profileIntersection.IntersectByIdentifierAndHostUUID(toInstall, toRemove)
|
|
|
|
// hostProfilesToCleanup is used to track profiles that should be removed
|
|
// from the database directly without having to issue a RemoveProfile
|
|
// command.
|
|
hostProfilesToCleanup := []*fleet.MDMAppleProfilePayload{}
|
|
|
|
// Index host profiles to install by host and profile UUID, for easier bulk error processing
|
|
hostProfilesToInstallMap := make(map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, len(toInstall))
|
|
|
|
installTargets, removeTargets := make(map[string]*cmdTarget), make(map[string]*cmdTarget)
|
|
for _, p := range toInstall {
|
|
if pp, ok := profileIntersection.GetMatchingProfileInCurrentState(p); ok {
|
|
// if the profile was in any other status than `failed`
|
|
// and the checksums match (the profiles are exactly
|
|
// the same) we don't send another InstallProfile
|
|
// command.
|
|
|
|
if pp.Status != &fleet.MDMDeliveryFailed && bytes.Equal(pp.Checksum, p.Checksum) {
|
|
hostProfile := &fleet.MDMAppleBulkUpsertHostProfilePayload{
|
|
ProfileUUID: p.ProfileUUID,
|
|
HostUUID: p.HostUUID,
|
|
ProfileIdentifier: p.ProfileIdentifier,
|
|
ProfileName: p.ProfileName,
|
|
Checksum: p.Checksum,
|
|
SecretsUpdatedAt: p.SecretsUpdatedAt,
|
|
OperationType: pp.OperationType,
|
|
Status: pp.Status,
|
|
CommandUUID: pp.CommandUUID,
|
|
Detail: pp.Detail,
|
|
Scope: pp.Scope,
|
|
}
|
|
hostProfiles = append(hostProfiles, hostProfile)
|
|
hostProfilesToInstallMap[hostProfileUUID{HostUUID: p.HostUUID, ProfileUUID: p.ProfileUUID}] = hostProfile
|
|
continue
|
|
}
|
|
}
|
|
|
|
wait, err := isAwaitingUserEnrollment(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if wait {
|
|
// user-scoped profile still waiting for a user enrollment, leave the
|
|
// profile in NULL status
|
|
hostProfile := &fleet.MDMAppleBulkUpsertHostProfilePayload{
|
|
ProfileUUID: p.ProfileUUID,
|
|
HostUUID: p.HostUUID,
|
|
ProfileIdentifier: p.ProfileIdentifier,
|
|
ProfileName: p.ProfileName,
|
|
Checksum: p.Checksum,
|
|
SecretsUpdatedAt: p.SecretsUpdatedAt,
|
|
OperationType: fleet.MDMOperationTypeInstall,
|
|
Status: nil,
|
|
Scope: p.Scope,
|
|
}
|
|
hostProfiles = append(hostProfiles, hostProfile)
|
|
hostProfilesToInstallMap[hostProfileUUID{HostUUID: p.HostUUID, ProfileUUID: p.ProfileUUID}] = hostProfile
|
|
continue
|
|
}
|
|
|
|
toGetContents[p.ProfileUUID] = true
|
|
|
|
target := installTargets[p.ProfileUUID]
|
|
if target == nil {
|
|
target = &cmdTarget{
|
|
cmdUUID: uuid.New().String(),
|
|
profIdent: p.ProfileIdentifier,
|
|
}
|
|
installTargets[p.ProfileUUID] = target
|
|
}
|
|
|
|
if p.Scope == fleet.PayloadScopeUser {
|
|
userEnrollmentID, err := getHostUserEnrollmentID(p.HostUUID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if userEnrollmentID == "" {
|
|
var errorDetail string
|
|
if fleet.IsAppleMobilePlatform(p.HostPlatform) {
|
|
errorDetail = "This setting couldn't be enforced because the user channel isn't available on iOS and iPadOS hosts."
|
|
} else {
|
|
errorDetail = "This setting couldn't be enforced because the user channel doesn't exist for this host. Currently, Fleet creates the user channel for hosts that automatically enroll."
|
|
level.Warn(logger).Log("msg", "host does not have a user enrollment, failing profile installation",
|
|
"host_uuid", p.HostUUID, "profile_uuid", p.ProfileUUID, "profile_identifier", p.ProfileIdentifier)
|
|
}
|
|
|
|
hostProfile := &fleet.MDMAppleBulkUpsertHostProfilePayload{
|
|
ProfileUUID: p.ProfileUUID,
|
|
HostUUID: p.HostUUID,
|
|
OperationType: fleet.MDMOperationTypeInstall,
|
|
Status: &fleet.MDMDeliveryFailed,
|
|
Detail: errorDetail,
|
|
CommandUUID: "",
|
|
ProfileIdentifier: p.ProfileIdentifier,
|
|
ProfileName: p.ProfileName,
|
|
Checksum: p.Checksum,
|
|
SecretsUpdatedAt: p.SecretsUpdatedAt,
|
|
Scope: p.Scope,
|
|
}
|
|
hostProfiles = append(hostProfiles, hostProfile)
|
|
continue
|
|
}
|
|
|
|
target.enrollmentIDs = append(target.enrollmentIDs, userEnrollmentID)
|
|
} else {
|
|
target.enrollmentIDs = append(target.enrollmentIDs, p.HostUUID)
|
|
}
|
|
toGetContents[p.ProfileUUID] = true
|
|
|
|
hostProfile := &fleet.MDMAppleBulkUpsertHostProfilePayload{
|
|
ProfileUUID: p.ProfileUUID,
|
|
HostUUID: p.HostUUID,
|
|
OperationType: fleet.MDMOperationTypeInstall,
|
|
Status: &fleet.MDMDeliveryPending,
|
|
CommandUUID: target.cmdUUID,
|
|
ProfileIdentifier: p.ProfileIdentifier,
|
|
ProfileName: p.ProfileName,
|
|
Checksum: p.Checksum,
|
|
SecretsUpdatedAt: p.SecretsUpdatedAt,
|
|
Scope: p.Scope,
|
|
}
|
|
hostProfiles = append(hostProfiles, hostProfile)
|
|
hostProfilesToInstallMap[hostProfileUUID{HostUUID: p.HostUUID, ProfileUUID: p.ProfileUUID}] = hostProfile
|
|
}
|
|
|
|
for _, p := range toRemove {
|
|
// Exclude profiles that are also marked for installation.
|
|
if _, ok := profileIntersection.GetMatchingProfileInDesiredState(p); ok {
|
|
hostProfilesToCleanup = append(hostProfilesToCleanup, p)
|
|
continue
|
|
}
|
|
|
|
if p.FailedInstallOnHost() {
|
|
// then we shouldn't send an additional remove command since it failed to install on the host.
|
|
hostProfilesToCleanup = append(hostProfilesToCleanup, p)
|
|
continue
|
|
}
|
|
if p.PendingInstallOnHost() {
|
|
// The profile most likely did not install on host. However, it is possible that the profile
|
|
// is currently being installed. So, we clean up the profile from the database, but also send
|
|
// a remove command to the host.
|
|
hostProfilesToCleanup = append(hostProfilesToCleanup, p)
|
|
// IgnoreError is set since the removal command is likely to fail.
|
|
p.IgnoreError = true
|
|
}
|
|
|
|
target := removeTargets[p.ProfileUUID]
|
|
if target == nil {
|
|
target = &cmdTarget{
|
|
cmdUUID: uuid.New().String(),
|
|
profIdent: p.ProfileIdentifier,
|
|
}
|
|
removeTargets[p.ProfileUUID] = target
|
|
}
|
|
|
|
if p.Scope == fleet.PayloadScopeUser {
|
|
userEnrollmentID, err := getHostUserEnrollmentID(p.HostUUID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if userEnrollmentID == "" {
|
|
level.Warn(logger).Log("msg", "host does not have a user enrollment, cannot remove user scoped profile",
|
|
"host_uuid", p.HostUUID, "profile_uuid", p.ProfileUUID, "profile_identifier", p.ProfileIdentifier)
|
|
hostProfilesToCleanup = append(hostProfilesToCleanup, p)
|
|
continue
|
|
}
|
|
|
|
target.enrollmentIDs = append(target.enrollmentIDs, userEnrollmentID)
|
|
} else {
|
|
target.enrollmentIDs = append(target.enrollmentIDs, p.HostUUID)
|
|
}
|
|
|
|
hostProfiles = append(hostProfiles, &fleet.MDMAppleBulkUpsertHostProfilePayload{
|
|
ProfileUUID: p.ProfileUUID,
|
|
HostUUID: p.HostUUID,
|
|
OperationType: fleet.MDMOperationTypeRemove,
|
|
Status: &fleet.MDMDeliveryPending,
|
|
CommandUUID: target.cmdUUID,
|
|
ProfileIdentifier: p.ProfileIdentifier,
|
|
ProfileName: p.ProfileName,
|
|
Checksum: p.Checksum,
|
|
SecretsUpdatedAt: p.SecretsUpdatedAt,
|
|
IgnoreError: p.IgnoreError,
|
|
Scope: p.Scope,
|
|
})
|
|
}
|
|
|
|
// delete all profiles that have a matching identifier to be installed.
|
|
// This is to prevent sending both a `RemoveProfile` and an
|
|
// `InstallProfile` for the same identifier, which can cause race
|
|
// conditions. It's better to "update" the profile by sending a single
|
|
// `InstallProfile` command.
|
|
//
|
|
// Create a map of command UUIDs to host IDs
|
|
commandUUIDToHostIDsCleanupMap := make(map[string][]string)
|
|
for _, hp := range hostProfilesToCleanup {
|
|
// Certain failure scenarios may leave the profile without a command UUID, so skip those
|
|
if hp.CommandUUID != "" {
|
|
commandUUIDToHostIDsCleanupMap[hp.CommandUUID] = append(commandUUIDToHostIDsCleanupMap[hp.CommandUUID], hp.HostUUID)
|
|
}
|
|
}
|
|
// We need to delete commands from the nano queue so they don't get sent to device.
|
|
if len(commandUUIDToHostIDsCleanupMap) > 0 {
|
|
if err := commander.BulkDeleteHostUserCommandsWithoutResults(ctx, commandUUIDToHostIDsCleanupMap); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "deleting nano commands without results")
|
|
}
|
|
}
|
|
if err := ds.BulkDeleteMDMAppleHostsConfigProfiles(ctx, hostProfilesToCleanup); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "deleting profiles that didn't change")
|
|
}
|
|
|
|
// FIXME: How does this impact variable profiles? This happens before pre-processing, doesn't
|
|
// this potentially race with the command uuid and variable substitution?
|
|
//
|
|
// First update all the profiles in the database before sending the
|
|
// commands, this prevents race conditions where we could get a
|
|
// response from the device before we set its status as 'pending'
|
|
//
|
|
// We'll do another pass at the end to revert any changes for failed
|
|
// deliveries.
|
|
if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, hostProfiles); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "updating host profiles")
|
|
}
|
|
|
|
// Grab the contents of all the profiles we need to install
|
|
profileUUIDs := make([]string, 0, len(toGetContents))
|
|
for pUUID := range toGetContents {
|
|
profileUUIDs = append(profileUUIDs, pUUID)
|
|
}
|
|
profileContents, err := ds.GetMDMAppleProfilesContents(ctx, profileUUIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "get profile contents")
|
|
}
|
|
|
|
groupedCAs, err := ds.GetGroupedCertificateAuthorities(ctx, true)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting grouped certificate authorities")
|
|
}
|
|
|
|
// Insert variables into profile contents of install targets. Variables may be host-specific.
|
|
err = preprocessProfileContents(ctx, appConfig, ds,
|
|
eeservice.NewSCEPConfigService(logger, nil),
|
|
digicert.NewService(digicert.WithLogger(logger)),
|
|
logger, installTargets, profileContents, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, groupedCAs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Find the profiles containing secret variables.
|
|
profilesWithSecrets, err := findProfilesWithSecrets(logger, installTargets, profileContents)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
type remoteResult struct {
|
|
Err error
|
|
CmdUUID string
|
|
}
|
|
|
|
// Send the install/remove commands for each profile.
|
|
var wgProd, wgCons sync.WaitGroup
|
|
ch := make(chan remoteResult)
|
|
|
|
execCmd := func(profUUID string, target *cmdTarget, op fleet.MDMOperationType) {
|
|
defer wgProd.Done()
|
|
|
|
var err error
|
|
switch op {
|
|
case fleet.MDMOperationTypeInstall:
|
|
if _, ok := profilesWithSecrets[profUUID]; ok {
|
|
err = commander.EnqueueCommandInstallProfileWithSecrets(ctx, target.enrollmentIDs, profileContents[profUUID], target.cmdUUID)
|
|
} else {
|
|
err = commander.InstallProfile(ctx, target.enrollmentIDs, profileContents[profUUID], target.cmdUUID)
|
|
}
|
|
case fleet.MDMOperationTypeRemove:
|
|
err = commander.RemoveProfile(ctx, target.enrollmentIDs, target.profIdent, target.cmdUUID)
|
|
}
|
|
|
|
var e *apple_mdm.APNSDeliveryError
|
|
switch {
|
|
case errors.As(err, &e):
|
|
level.Debug(logger).Log("err", "sending push notifications, profiles still enqueued", "details", err)
|
|
case err != nil:
|
|
level.Error(logger).Log("err", fmt.Sprintf("enqueue command to %s profiles", op), "details", err)
|
|
ch <- remoteResult{err, target.cmdUUID}
|
|
}
|
|
}
|
|
for profUUID, target := range installTargets {
|
|
wgProd.Add(1)
|
|
go execCmd(profUUID, target, fleet.MDMOperationTypeInstall)
|
|
}
|
|
for profUUID, target := range removeTargets {
|
|
wgProd.Add(1)
|
|
go execCmd(profUUID, target, fleet.MDMOperationTypeRemove)
|
|
}
|
|
|
|
// index the host profiles by cmdUUID, for ease of error processing in the
|
|
// consumer goroutine below.
|
|
hostProfsByCmdUUID := make(map[string][]*fleet.MDMAppleBulkUpsertHostProfilePayload, len(installTargets)+len(removeTargets))
|
|
for _, hp := range hostProfiles {
|
|
hostProfsByCmdUUID[hp.CommandUUID] = append(hostProfsByCmdUUID[hp.CommandUUID], hp)
|
|
}
|
|
|
|
// Grab all the failed deliveries and update the status so they're picked up
|
|
// again in the next run.
|
|
//
|
|
// Note that if the APNs push failed we won't try again, as the command was
|
|
// successfully enqueued, this is only to account for internal errors like DB
|
|
// failures.
|
|
failed := []*fleet.MDMAppleBulkUpsertHostProfilePayload{}
|
|
wgCons.Add(1)
|
|
go func() {
|
|
defer wgCons.Done()
|
|
|
|
for resp := range ch {
|
|
hostProfs := hostProfsByCmdUUID[resp.CmdUUID]
|
|
for _, hp := range hostProfs {
|
|
// clear the command as it failed to enqueue, will need to emit a new command
|
|
hp.CommandUUID = ""
|
|
// set status to nil so it is retried on the next cron run
|
|
hp.Status = nil
|
|
failed = append(failed, hp)
|
|
}
|
|
}
|
|
}()
|
|
|
|
wgProd.Wait()
|
|
close(ch) // done sending at this point, this triggers end of for loop in consumer
|
|
wgCons.Wait()
|
|
|
|
if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, failed); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "reverting status of failed profiles")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func findProfilesWithSecrets(
|
|
logger *platformlogging.Logger,
|
|
installTargets map[string]*cmdTarget,
|
|
profileContents map[string]mobileconfig.Mobileconfig,
|
|
) (map[string]struct{}, error) {
|
|
profilesWithSecrets := make(map[string]struct{})
|
|
for profUUID := range installTargets {
|
|
p, ok := profileContents[profUUID]
|
|
if !ok { // Should never happen
|
|
level.Error(logger).Log("msg", "profile content not found in ReconcileAppleProfiles", "profile_uuid", profUUID)
|
|
continue
|
|
}
|
|
profileStr := string(p)
|
|
vars := fleet.ContainsPrefixVars(profileStr, fleet.ServerSecretPrefix)
|
|
if len(vars) > 0 {
|
|
profilesWithSecrets[profUUID] = struct{}{}
|
|
}
|
|
}
|
|
return profilesWithSecrets, nil
|
|
}
|
|
|
|
func preprocessProfileContents(
|
|
ctx context.Context,
|
|
appConfig *fleet.AppConfig,
|
|
ds fleet.Datastore,
|
|
scepConfig fleet.SCEPConfigService,
|
|
digiCertService fleet.DigiCertService,
|
|
logger *platformlogging.Logger,
|
|
targets map[string]*cmdTarget,
|
|
profileContents map[string]mobileconfig.Mobileconfig,
|
|
hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload,
|
|
userEnrollmentsToHostUUIDsMap map[string]string,
|
|
groupedCAs *fleet.GroupedCertificateAuthorities,
|
|
) error {
|
|
// This method replaces Fleet variables ($FLEET_VAR_<NAME>) in the profile
|
|
// contents, generating a unique profile for each host. For a 2KB profile and
|
|
// 30K hosts, this method may generate ~60MB of profile data in memory.
|
|
|
|
var (
|
|
// Copy of NDES SCEP config which will contain unencrypted password, if needed
|
|
ndesConfig *fleet.NDESSCEPProxyCA
|
|
digiCertCAs map[string]*fleet.DigiCertCA
|
|
customSCEPCAs map[string]*fleet.CustomSCEPProxyCA
|
|
smallstepCAs map[string]*fleet.SmallstepSCEPProxyCA
|
|
)
|
|
|
|
// this is used to cache the host ID corresponding to the UUID, so we don't
|
|
// need to look it up more than once per host.
|
|
hostIDForUUIDCache := make(map[string]uint)
|
|
|
|
var addedTargets map[string]*cmdTarget
|
|
for profUUID, target := range targets {
|
|
contents, ok := profileContents[profUUID]
|
|
if !ok {
|
|
// This should never happen
|
|
continue
|
|
}
|
|
|
|
// Check if Fleet variables are present.
|
|
contentsStr := string(contents)
|
|
fleetVars := variables.Find(contentsStr)
|
|
if len(fleetVars) == 0 {
|
|
continue
|
|
}
|
|
|
|
var variablesUpdatedAt *time.Time
|
|
|
|
// Do common validation that applies to all hosts in the target
|
|
valid := true
|
|
// Check if there are any CA variables first so that if a non-CA variable causes
|
|
// preprocessing to fail, we still set the variablesUpdatedAt timestamp so that
|
|
// validation works as expected
|
|
// In the future we should expand variablesUpdatedAt logic to include non-CA variables as
|
|
// well
|
|
for _, fleetVar := range fleetVars {
|
|
if fleetVar == string(fleet.FleetVarSCEPRenewalID) ||
|
|
fleetVar == string(fleet.FleetVarNDESSCEPChallenge) || fleetVar == string(fleet.FleetVarNDESSCEPProxyURL) || fleetVar == string(fleet.FleetVarHostUUID) ||
|
|
strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)) ||
|
|
strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)) ||
|
|
strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)) {
|
|
// Give a few minutes leeway to account for clock skew
|
|
variablesUpdatedAt = ptr.Time(time.Now().UTC().Add(-3 * time.Minute))
|
|
break
|
|
}
|
|
}
|
|
|
|
initialFleetVarLoop:
|
|
for _, fleetVar := range fleetVars {
|
|
switch {
|
|
case fleetVar == string(fleet.FleetVarNDESSCEPChallenge) || fleetVar == string(fleet.FleetVarNDESSCEPProxyURL):
|
|
configured, err := isNDESSCEPConfigured(ctx, groupedCAs, ds, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, target)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "checking NDES SCEP configuration")
|
|
}
|
|
if !configured {
|
|
valid = false
|
|
break initialFleetVarLoop
|
|
}
|
|
|
|
case fleetVar == string(fleet.FleetVarHostEndUserEmailIDP) || fleetVar == string(fleet.FleetVarHostHardwareSerial) || fleetVar == string(fleet.FleetVarHostPlatform) ||
|
|
fleetVar == string(fleet.FleetVarHostEndUserIDPUsername) || fleetVar == string(fleet.FleetVarHostEndUserIDPUsernameLocalPart) ||
|
|
fleetVar == string(fleet.FleetVarHostEndUserIDPGroups) || fleetVar == string(fleet.FleetVarHostEndUserIDPDepartment) || fleetVar == string(fleet.FleetVarSCEPRenewalID) ||
|
|
fleetVar == string(fleet.FleetVarHostEndUserIDPFullname) || fleetVar == string(fleet.FleetVarHostUUID):
|
|
// No extra validation needed for these variables
|
|
|
|
case strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)):
|
|
var caName string
|
|
if strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)) {
|
|
caName = strings.TrimPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix))
|
|
} else {
|
|
caName = strings.TrimPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix))
|
|
}
|
|
if digiCertCAs == nil {
|
|
digiCertCAs = make(map[string]*fleet.DigiCertCA)
|
|
}
|
|
configured, err := isDigiCertConfigured(ctx, groupedCAs, ds, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, digiCertCAs, profUUID, target, caName, fleetVar)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "checking DigiCert configuration")
|
|
}
|
|
if !configured {
|
|
valid = false
|
|
break initialFleetVarLoop
|
|
}
|
|
|
|
case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)):
|
|
var caName string
|
|
if strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)) {
|
|
caName = strings.TrimPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix))
|
|
} else {
|
|
caName = strings.TrimPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix))
|
|
}
|
|
if customSCEPCAs == nil {
|
|
customSCEPCAs = make(map[string]*fleet.CustomSCEPProxyCA)
|
|
for _, ca := range groupedCAs.CustomScepProxy {
|
|
customSCEPCAs[ca.Name] = &ca
|
|
}
|
|
}
|
|
err := profiles.IsCustomSCEPConfigured(ctx, customSCEPCAs, caName, fleetVar, func(errMsg string) error {
|
|
_, err := markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, errMsg, ptr.Time(time.Now().UTC()))
|
|
return err
|
|
})
|
|
if err != nil {
|
|
valid = false
|
|
break initialFleetVarLoop
|
|
}
|
|
|
|
case strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) || strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)):
|
|
if smallstepCAs == nil {
|
|
smallstepCAs = make(map[string]*fleet.SmallstepSCEPProxyCA)
|
|
}
|
|
var caName string
|
|
if strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)) {
|
|
caName = strings.TrimPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix))
|
|
} else {
|
|
caName = strings.TrimPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix))
|
|
}
|
|
configured, err := isSmallstepSCEPConfigured(ctx, groupedCAs, ds, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, smallstepCAs, profUUID, target, caName,
|
|
fleetVar)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "checking Smallstep SCEP configuration")
|
|
}
|
|
if !configured {
|
|
valid = false
|
|
break initialFleetVarLoop
|
|
}
|
|
|
|
default:
|
|
// Otherwise, error out since this variable is unknown
|
|
detail := fmt.Sprintf("Unknown Fleet variable $FLEET_VAR_%s found in profile. Please update or remove.",
|
|
fleetVar)
|
|
_, err := markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, detail, variablesUpdatedAt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
valid = false
|
|
}
|
|
}
|
|
if !valid {
|
|
// We marked the profile as failed, so we will not do any additional processing on it
|
|
delete(targets, profUUID)
|
|
continue
|
|
}
|
|
|
|
// Currently, all supported Fleet variables are unique per host, so we split the profile into multiple profiles.
|
|
// We generate a new temporary profileUUID which is currently only used to install the profile.
|
|
// The profileUUID in host_mdm_apple_profiles is still the original profileUUID.
|
|
// We also generate a new commandUUID which is used to install the profile via nano_commands table.
|
|
if addedTargets == nil {
|
|
addedTargets = make(map[string]*cmdTarget, 1)
|
|
}
|
|
// We store the timestamp when the challenge was retrieved to know if it has expired.
|
|
var managedCertificatePayloads []*fleet.MDMManagedCertificate
|
|
// We need to update the profiles of each host with the new command UUID
|
|
profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.enrollmentIDs))
|
|
for _, enrollmentID := range target.enrollmentIDs {
|
|
tempProfUUID := uuid.NewString()
|
|
// Use the same UUID for command UUID, which will be the primary key for nano_commands
|
|
tempCmdUUID := tempProfUUID
|
|
profile, ok := getHostProfileToInstallByEnrollmentID(hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, enrollmentID, profUUID)
|
|
if !ok { // Should never happen
|
|
continue
|
|
}
|
|
// Fetch the host UUID, which may not be the same as the Enrollment ID, from the profile
|
|
hostUUID := profile.HostUUID
|
|
|
|
// some variables need more information about the host; build a skeleton host and hydrate if we need more info
|
|
hostLite := fleet.Host{UUID: hostUUID}
|
|
onMismatchedHostCount := func(hostCount int) error {
|
|
return ctxerr.Wrap(ctx, ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{
|
|
CommandUUID: target.cmdUUID,
|
|
HostUUID: hostLite.UUID,
|
|
Status: &fleet.MDMDeliveryFailed,
|
|
Detail: fmt.Sprintf("Unexpected number of hosts (%d) for UUID %s.", hostCount, hostLite.UUID),
|
|
OperationType: fleet.MDMOperationTypeInstall,
|
|
}), "could not retrieve host by UUID for profile variable substitution")
|
|
}
|
|
|
|
profile.CommandUUID = tempCmdUUID
|
|
profile.VariablesUpdatedAt = variablesUpdatedAt
|
|
|
|
hostContents := contentsStr
|
|
failed := false
|
|
|
|
fleetVarLoop:
|
|
for _, fleetVar := range fleetVars {
|
|
var err error
|
|
switch {
|
|
case fleetVar == string(fleet.FleetVarNDESSCEPChallenge):
|
|
if ndesConfig == nil {
|
|
ndesConfig = groupedCAs.NDESSCEP
|
|
}
|
|
level.Debug(logger).Log("msg", "fetching NDES challenge", "host_uuid", hostUUID, "profile_uuid", profUUID)
|
|
// Insert the SCEP challenge into the profile contents
|
|
challenge, err := scepConfig.GetNDESSCEPChallenge(ctx, *ndesConfig)
|
|
if err != nil {
|
|
detail := ""
|
|
switch {
|
|
case errors.As(err, &eeservice.NDESInvalidError{}):
|
|
detail = fmt.Sprintf("Invalid NDES admin credentials. "+
|
|
"Fleet couldn't populate $FLEET_VAR_%s. "+
|
|
"Please update credentials in Settings > Integrations > Mobile Device Management > Simple Certificate Enrollment Protocol.",
|
|
fleet.FleetVarNDESSCEPChallenge)
|
|
case errors.As(err, &eeservice.NDESPasswordCacheFullError{}):
|
|
detail = fmt.Sprintf("The NDES password cache is full. "+
|
|
"Fleet couldn't populate $FLEET_VAR_%s. "+
|
|
"Please increase the number of cached passwords in NDES and try again.",
|
|
fleet.FleetVarNDESSCEPChallenge)
|
|
case errors.As(err, &eeservice.NDESInsufficientPermissionsError{}):
|
|
detail = fmt.Sprintf("This account does not have sufficient permissions to enroll with SCEP. "+
|
|
"Fleet couldn't populate $FLEET_VAR_%s. "+
|
|
"Please update the account with NDES SCEP enroll permissions and try again.",
|
|
fleet.FleetVarNDESSCEPChallenge)
|
|
default:
|
|
detail = fmt.Sprintf("Fleet couldn't populate $FLEET_VAR_%s. %s", fleet.FleetVarNDESSCEPChallenge, err.Error())
|
|
}
|
|
err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{
|
|
CommandUUID: target.cmdUUID,
|
|
HostUUID: hostUUID,
|
|
Status: &fleet.MDMDeliveryFailed,
|
|
Detail: detail,
|
|
OperationType: fleet.MDMOperationTypeInstall,
|
|
VariablesUpdatedAt: variablesUpdatedAt,
|
|
})
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for NDES SCEP challenge")
|
|
}
|
|
failed = true
|
|
break fleetVarLoop
|
|
}
|
|
payload := &fleet.MDMManagedCertificate{
|
|
HostUUID: hostUUID,
|
|
ProfileUUID: profUUID,
|
|
ChallengeRetrievedAt: ptr.Time(time.Now()),
|
|
Type: fleet.CAConfigNDES,
|
|
CAName: "NDES",
|
|
}
|
|
managedCertificatePayloads = append(managedCertificatePayloads, payload)
|
|
|
|
hostContents = profiles.ReplaceFleetVariableInXML(fleetVarNDESSCEPChallengeRegexp, hostContents, challenge)
|
|
|
|
case fleetVar == string(fleet.FleetVarNDESSCEPProxyURL):
|
|
// Insert the SCEP URL into the profile contents
|
|
proxyURL := fmt.Sprintf("%s%s%s", appConfig.MDMUrl(), apple_mdm.SCEPProxyPath,
|
|
url.PathEscape(fmt.Sprintf("%s,%s,NDES", hostUUID, profUUID)))
|
|
hostContents = profiles.ReplaceFleetVariableInXML(fleetVarNDESSCEPProxyURLRegexp, hostContents, proxyURL)
|
|
|
|
case fleetVar == string(fleet.FleetVarSCEPRenewalID):
|
|
// Insert the SCEP renewal ID into the SCEP Payload CN or OU
|
|
fleetRenewalID := "fleet-" + profUUID
|
|
hostContents = profiles.ReplaceFleetVariableInXML(fleetVarSCEPRenewalIDRegexp, hostContents, fleetRenewalID)
|
|
|
|
case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPChallengePrefix)):
|
|
replacedContents, replacedVariable, err := profiles.ReplaceCustomSCEPChallengeVariable(ctx, logger, fleetVar, customSCEPCAs, hostContents)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "replacing custom SCEP challenge variable")
|
|
}
|
|
if !replacedVariable {
|
|
continue
|
|
}
|
|
hostContents = replacedContents
|
|
|
|
case strings.HasPrefix(fleetVar, string(fleet.FleetVarCustomSCEPProxyURLPrefix)):
|
|
replacedContents, managedCertificate, replacedVariable, err := profiles.ReplaceCustomSCEPProxyURLVariable(ctx, logger, ds, appConfig, fleetVar, customSCEPCAs, hostContents, hostUUID, profUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "replacing custom SCEP proxy URL variable")
|
|
}
|
|
if !replacedVariable {
|
|
continue
|
|
}
|
|
hostContents = replacedContents
|
|
managedCertificatePayloads = append(managedCertificatePayloads, managedCertificate)
|
|
|
|
case strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix)):
|
|
caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPChallengePrefix))
|
|
ca, ok := smallstepCAs[caName]
|
|
if !ok {
|
|
level.Error(logger).Log("msg", "Smallstep SCEP CA not found. "+
|
|
"This error should never happen since we validated/populated CAs earlier", "ca_name", caName)
|
|
continue
|
|
}
|
|
level.Debug(logger).Log("msg", "fetching Smallstep SCEP challenge", "host_uuid", hostUUID, "profile_uuid", profUUID)
|
|
challenge, err := scepConfig.GetSmallstepSCEPChallenge(ctx, *ca)
|
|
if err != nil {
|
|
detail := fmt.Sprintf("Fleet couldn't populate $FLEET_VAR_%s. %s", fleet.FleetVarSmallstepSCEPChallengePrefix, err.Error())
|
|
err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{
|
|
CommandUUID: target.cmdUUID,
|
|
HostUUID: hostUUID,
|
|
Status: &fleet.MDMDeliveryFailed,
|
|
Detail: detail,
|
|
OperationType: fleet.MDMOperationTypeInstall,
|
|
VariablesUpdatedAt: variablesUpdatedAt,
|
|
})
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for Smallstep SCEP challenge")
|
|
}
|
|
failed = true
|
|
break fleetVarLoop
|
|
}
|
|
level.Info(logger).Log("msg", "retrieved SCEP challenge from Smallstep", "host_uuid", hostUUID, "profile_uuid", profUUID)
|
|
|
|
payload := &fleet.MDMManagedCertificate{
|
|
HostUUID: hostUUID,
|
|
ProfileUUID: profUUID,
|
|
ChallengeRetrievedAt: ptr.Time(time.Now()),
|
|
Type: fleet.CAConfigSmallstep,
|
|
CAName: caName,
|
|
}
|
|
managedCertificatePayloads = append(managedCertificatePayloads, payload)
|
|
hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarSmallstepSCEPChallengePrefix), ca.Name, hostContents, challenge)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "replacing Smallstep SCEP challenge variable")
|
|
}
|
|
|
|
case strings.HasPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix)):
|
|
// Insert the SCEP URL into the profile contents
|
|
caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarSmallstepSCEPProxyURLPrefix))
|
|
proxyURL := fmt.Sprintf("%s%s%s", appConfig.MDMUrl(), apple_mdm.SCEPProxyPath,
|
|
url.PathEscape(fmt.Sprintf("%s,%s,%s", hostUUID, profUUID, caName)))
|
|
hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarSmallstepSCEPProxyURLPrefix), caName, hostContents, proxyURL)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "replacing Smallstep SCEP URL variable")
|
|
}
|
|
|
|
case fleetVar == string(fleet.FleetVarHostEndUserEmailIDP):
|
|
email, ok, err := getFirstIDPEmail(ctx, ds, target, hostUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting IDP email")
|
|
}
|
|
if !ok {
|
|
failed = true
|
|
break fleetVarLoop
|
|
}
|
|
hostContents = profiles.ReplaceFleetVariableInXML(fleetVarHostEndUserEmailIDPRegexp, hostContents, email)
|
|
|
|
case fleetVar == string(fleet.FleetVarHostHardwareSerial):
|
|
hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting host hardware serial")
|
|
}
|
|
if !ok {
|
|
failed = true
|
|
break fleetVarLoop
|
|
}
|
|
hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostHardwareSerialRegexp, hostContents, hostLite.HardwareSerial)
|
|
case fleetVar == string(fleet.FleetVarHostPlatform):
|
|
hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting host platform")
|
|
}
|
|
if !ok {
|
|
failed = true
|
|
break fleetVarLoop
|
|
}
|
|
platform := hostLite.Platform
|
|
if platform == "darwin" {
|
|
platform = "macos"
|
|
}
|
|
|
|
hostContents = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostPlatformRegexp, hostContents, platform)
|
|
case fleetVar == string(fleet.FleetVarHostEndUserIDPUsername) || fleetVar == string(fleet.FleetVarHostEndUserIDPUsernameLocalPart) ||
|
|
fleetVar == string(fleet.FleetVarHostEndUserIDPGroups) || fleetVar == string(fleet.FleetVarHostEndUserIDPDepartment) ||
|
|
fleetVar == string(fleet.FleetVarHostEndUserIDPFullname):
|
|
replacedContents, replacedVariable, err := profiles.ReplaceHostEndUserIDPVariables(ctx, ds, fleetVar, hostContents, hostUUID, hostIDForUUIDCache, func(errMsg string) error {
|
|
err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{
|
|
CommandUUID: target.cmdUUID,
|
|
HostUUID: hostUUID,
|
|
Status: &fleet.MDMDeliveryFailed,
|
|
Detail: errMsg,
|
|
OperationType: fleet.MDMOperationTypeInstall,
|
|
})
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "replacing host end user IDP variables")
|
|
}
|
|
if !replacedVariable {
|
|
failed = true
|
|
break fleetVarLoop
|
|
}
|
|
|
|
hostContents = replacedContents
|
|
|
|
case strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertPasswordPrefix)):
|
|
// We will replace the password when we populate the certificate data
|
|
|
|
case fleetVar == string(fleet.FleetVarHostUUID):
|
|
hostContents = profiles.ReplaceFleetVariableInXML(fleetVarHostUUIDRegexp, hostContents, hostUUID)
|
|
|
|
case strings.HasPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix)):
|
|
caName := strings.TrimPrefix(fleetVar, string(fleet.FleetVarDigiCertDataPrefix))
|
|
ca, ok := digiCertCAs[caName]
|
|
if !ok {
|
|
level.Error(logger).Log("msg", "Custom DigiCert CA not found. "+
|
|
"This error should never happen since we validated/populated CAs earlier", "ca_name", caName)
|
|
continue
|
|
}
|
|
caCopy := *ca
|
|
|
|
// Populate Fleet vars in the CA fields
|
|
caVarsCache := make(map[string]string)
|
|
|
|
ok, err := replaceFleetVarInItem(ctx, ds, target, hostLite, caVarsCache, &caCopy.CertificateCommonName, onMismatchedHostCount)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "populating Fleet variables in DigiCert CA common name")
|
|
}
|
|
if !ok {
|
|
failed = true
|
|
break fleetVarLoop
|
|
}
|
|
ok, err = replaceFleetVarInItem(ctx, ds, target, hostLite, caVarsCache, &caCopy.CertificateSeatID, onMismatchedHostCount)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "populating Fleet variables in DigiCert CA common name")
|
|
}
|
|
if !ok {
|
|
failed = true
|
|
break fleetVarLoop
|
|
}
|
|
if len(caCopy.CertificateUserPrincipalNames) > 0 {
|
|
for i := range caCopy.CertificateUserPrincipalNames {
|
|
ok, err = replaceFleetVarInItem(ctx, ds, target, hostLite, caVarsCache, &caCopy.CertificateUserPrincipalNames[i], onMismatchedHostCount)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "populating Fleet variables in DigiCert CA common name")
|
|
}
|
|
if !ok {
|
|
failed = true
|
|
break fleetVarLoop
|
|
}
|
|
}
|
|
}
|
|
|
|
cert, err := digiCertService.GetCertificate(ctx, caCopy)
|
|
if err != nil {
|
|
detail := fmt.Sprintf("Couldn't get certificate from DigiCert for %s. %s", caCopy.Name, err)
|
|
err = ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{
|
|
CommandUUID: target.cmdUUID,
|
|
HostUUID: hostUUID,
|
|
Status: &fleet.MDMDeliveryFailed,
|
|
Detail: detail,
|
|
OperationType: fleet.MDMOperationTypeInstall,
|
|
VariablesUpdatedAt: variablesUpdatedAt,
|
|
})
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for DigiCert")
|
|
}
|
|
failed = true
|
|
break fleetVarLoop
|
|
}
|
|
hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarDigiCertDataPrefix), caName, hostContents,
|
|
base64.StdEncoding.EncodeToString(cert.PfxData))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "replacing Fleet variable for DigiCert data")
|
|
}
|
|
hostContents, err = profiles.ReplaceExactFleetPrefixVariableInXML(string(fleet.FleetVarDigiCertPasswordPrefix), caName, hostContents, cert.Password)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "replacing Fleet variable for DigiCert password")
|
|
}
|
|
managedCertificatePayloads = append(managedCertificatePayloads, &fleet.MDMManagedCertificate{
|
|
HostUUID: hostUUID,
|
|
ProfileUUID: profUUID,
|
|
NotValidBefore: &cert.NotValidBefore,
|
|
NotValidAfter: &cert.NotValidAfter,
|
|
Type: fleet.CAConfigDigiCert,
|
|
CAName: caName,
|
|
Serial: &cert.SerialNumber,
|
|
})
|
|
|
|
default:
|
|
// This was handled in the above switch statement, so we should never reach this case
|
|
}
|
|
}
|
|
if !failed {
|
|
addedTargets[tempProfUUID] = &cmdTarget{
|
|
cmdUUID: tempCmdUUID,
|
|
profIdent: target.profIdent,
|
|
enrollmentIDs: []string{enrollmentID},
|
|
}
|
|
profileContents[tempProfUUID] = mobileconfig.Mobileconfig(hostContents)
|
|
profilesToUpdate = append(profilesToUpdate, profile)
|
|
}
|
|
}
|
|
// Update profiles with the new command UUID
|
|
if len(profilesToUpdate) > 0 {
|
|
if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "updating host profiles")
|
|
}
|
|
}
|
|
if len(managedCertificatePayloads) != 0 {
|
|
// TODO: We could filter out failed profiles, but at the moment we don't, see Windows impl. for how it's done there.
|
|
err := ds.BulkUpsertMDMManagedCertificates(ctx, managedCertificatePayloads)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "updating managed certificates")
|
|
}
|
|
}
|
|
// Remove the parent target, since we will use host-specific targets
|
|
delete(targets, profUUID)
|
|
}
|
|
if len(addedTargets) > 0 {
|
|
// Add the new host-specific targets to the original targets map
|
|
for profUUID, target := range addedTargets {
|
|
targets[profUUID] = target
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func getFirstIDPEmail(ctx context.Context, ds fleet.Datastore, target *cmdTarget, hostUUID string) (string, bool, error) {
|
|
// Insert the end user email IDP into the profile contents
|
|
emails, err := ds.GetHostEmails(ctx, hostUUID, fleet.DeviceMappingMDMIdpAccounts)
|
|
if err != nil {
|
|
// This is a server error, so we exit.
|
|
return "", false, ctxerr.Wrap(ctx, err, "getting host emails")
|
|
}
|
|
if len(emails) == 0 {
|
|
// We couldn't retrieve the end user email IDP, so mark the profile as failed with additional detail.
|
|
err := ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{
|
|
CommandUUID: target.cmdUUID,
|
|
HostUUID: hostUUID,
|
|
Status: &fleet.MDMDeliveryFailed,
|
|
Detail: fmt.Sprintf("There is no IdP email for this host. "+
|
|
"Fleet couldn't populate $FLEET_VAR_%s. "+
|
|
"[Learn more](https://fleetdm.com/learn-more-about/idp-email)",
|
|
fleet.FleetVarHostEndUserEmailIDP),
|
|
OperationType: fleet.MDMOperationTypeInstall,
|
|
})
|
|
if err != nil {
|
|
return "", false, ctxerr.Wrap(ctx, err, "updating host MDM Apple profile for end user email IdP")
|
|
}
|
|
return "", false, nil
|
|
}
|
|
return emails[0], true, nil
|
|
}
|
|
|
|
func replaceFleetVarInItem(ctx context.Context, ds fleet.Datastore, target *cmdTarget, hostLite fleet.Host, caVarsCache map[string]string, item *string, onMismatchedHostCount func(int) error) (bool, error) {
|
|
caFleetVars := variables.Find(*item)
|
|
for _, caVar := range caFleetVars {
|
|
switch caVar {
|
|
case string(fleet.FleetVarHostEndUserEmailIDP):
|
|
email, ok := caVarsCache[string(fleet.FleetVarHostEndUserEmailIDP)]
|
|
if !ok {
|
|
var err error
|
|
email, ok, err = getFirstIDPEmail(ctx, ds, target, hostLite.UUID)
|
|
if err != nil {
|
|
return false, ctxerr.Wrap(ctx, err, "getting IDP email")
|
|
}
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
caVarsCache[string(fleet.FleetVarHostEndUserEmailIDP)] = email
|
|
}
|
|
*item = profiles.ReplaceFleetVariableInXML(fleetVarHostEndUserEmailIDPRegexp, *item, email)
|
|
case string(fleet.FleetVarHostHardwareSerial):
|
|
hardwareSerial, ok := caVarsCache[string(fleet.FleetVarHostHardwareSerial)]
|
|
if !ok {
|
|
var err error
|
|
hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount)
|
|
if err != nil {
|
|
return false, ctxerr.Wrap(ctx, err, "getting host hardware serial")
|
|
}
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
hardwareSerial = hostLite.HardwareSerial
|
|
caVarsCache[string(fleet.FleetVarHostHardwareSerial)] = hostLite.HardwareSerial
|
|
}
|
|
*item = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostHardwareSerialRegexp, *item, hardwareSerial)
|
|
case string(fleet.FleetVarHostPlatform):
|
|
platform, ok := caVarsCache[string(fleet.FleetVarHostPlatform)]
|
|
if !ok {
|
|
var err error
|
|
hostLite, ok, err = profiles.HydrateHost(ctx, ds, hostLite, onMismatchedHostCount)
|
|
if err != nil {
|
|
return false, ctxerr.Wrap(ctx, err, "getting host hardware serial")
|
|
}
|
|
if !ok {
|
|
return false, nil
|
|
}
|
|
platform = hostLite.Platform
|
|
if platform == "darwin" {
|
|
platform = "macos"
|
|
}
|
|
|
|
caVarsCache[string(fleet.FleetVarHostPlatform)] = platform
|
|
}
|
|
*item = profiles.ReplaceFleetVariableInXML(fleet.FleetVarHostPlatformRegexp, *item, platform)
|
|
default:
|
|
// We should not reach this since we validated the variables when saving app config
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func isDigiCertConfigured(ctx context.Context, groupedCAs *fleet.GroupedCertificateAuthorities, ds fleet.Datastore,
|
|
hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload,
|
|
userEnrollmentsToHostUUIDsMap map[string]string,
|
|
existingDigiCertCAs map[string]*fleet.DigiCertCA, profUUID string, target *cmdTarget, caName string, fleetVar string,
|
|
) (bool, error) {
|
|
if !license.IsPremium(ctx) {
|
|
return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, "DigiCert integration requires a Fleet Premium license.", ptr.Time(time.Now().UTC()))
|
|
}
|
|
if _, ok := existingDigiCertCAs[caName]; ok {
|
|
return true, nil
|
|
}
|
|
configured := false
|
|
var digiCertCA *fleet.DigiCertCA
|
|
if len(groupedCAs.DigiCert) > 0 {
|
|
for _, ca := range groupedCAs.DigiCert {
|
|
if ca.Name == caName {
|
|
digiCertCA = &ca
|
|
configured = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !configured || digiCertCA == nil {
|
|
return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID,
|
|
fmt.Sprintf("Fleet couldn't populate $%s because %s certificate authority doesn't exist.", fleetVar, caName), ptr.Time(time.Now().UTC()))
|
|
}
|
|
|
|
existingDigiCertCAs[caName] = digiCertCA
|
|
return true, nil
|
|
}
|
|
|
|
func isNDESSCEPConfigured(ctx context.Context, groupedCAs *fleet.GroupedCertificateAuthorities, ds fleet.Datastore,
|
|
hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload, userEnrollmentsToHostUUIDsMap map[string]string, profUUID string, target *cmdTarget,
|
|
) (bool, error) {
|
|
if !license.IsPremium(ctx) {
|
|
return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, "NDES SCEP Proxy requires a Fleet Premium license.", ptr.Time(time.Now().UTC()))
|
|
}
|
|
if groupedCAs.NDESSCEP == nil {
|
|
return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID,
|
|
"NDES SCEP Proxy is not configured. Please configure in Settings > Integrations > Certificates.", ptr.Time(time.Now().UTC()))
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func isSmallstepSCEPConfigured(ctx context.Context, groupedCAs *fleet.GroupedCertificateAuthorities, ds fleet.Datastore,
|
|
hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload,
|
|
userEnrollmentsToHostUUIDsMap map[string]string,
|
|
existingSmallstepSCEPCAs map[string]*fleet.SmallstepSCEPProxyCA, profUUID string, target *cmdTarget, caName string, fleetVar string,
|
|
) (bool, error) {
|
|
if !license.IsPremium(ctx) {
|
|
return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID, "Smallstep SCEP integration requires a Fleet Premium license.", ptr.Time(time.Now().UTC()))
|
|
}
|
|
if _, ok := existingSmallstepSCEPCAs[caName]; ok {
|
|
return true, nil
|
|
}
|
|
configured := false
|
|
var scepCA *fleet.SmallstepSCEPProxyCA
|
|
if len(groupedCAs.Smallstep) > 0 {
|
|
for _, ca := range groupedCAs.Smallstep {
|
|
if ca.Name == caName {
|
|
scepCA = &ca
|
|
configured = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !configured || scepCA == nil {
|
|
return markProfilesFailed(ctx, ds, target, hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, profUUID,
|
|
fmt.Sprintf("Fleet couldn't populate $%s because %s certificate authority doesn't exist.", fleetVar, caName), ptr.Time(time.Now().UTC()))
|
|
}
|
|
|
|
existingSmallstepSCEPCAs[caName] = scepCA
|
|
return true, nil
|
|
}
|
|
|
|
func getHostProfileToInstallByEnrollmentID(hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload,
|
|
userEnrollmentsToHostUUIDsMap map[string]string,
|
|
enrollmentID,
|
|
profUUID string,
|
|
) (*fleet.MDMAppleBulkUpsertHostProfilePayload, bool) {
|
|
profile, ok := hostProfilesToInstallMap[hostProfileUUID{HostUUID: enrollmentID, ProfileUUID: profUUID}]
|
|
if !ok {
|
|
var hostUUID string
|
|
// If sending to the user channel the enrollmentID will have to be mapped back to the host UUID.
|
|
hostUUID, ok = userEnrollmentsToHostUUIDsMap[enrollmentID]
|
|
if ok {
|
|
profile, ok = hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}]
|
|
}
|
|
}
|
|
return profile, ok
|
|
}
|
|
|
|
func markProfilesFailed(
|
|
ctx context.Context,
|
|
ds fleet.Datastore,
|
|
target *cmdTarget,
|
|
hostProfilesToInstallMap map[hostProfileUUID]*fleet.MDMAppleBulkUpsertHostProfilePayload,
|
|
userEnrollmentsToHostUUIDsMap map[string]string,
|
|
profUUID string,
|
|
detail string,
|
|
variablesUpdatedAt *time.Time,
|
|
) (bool, error) {
|
|
profilesToUpdate := make([]*fleet.MDMAppleBulkUpsertHostProfilePayload, 0, len(target.enrollmentIDs))
|
|
for _, enrollmentID := range target.enrollmentIDs {
|
|
profile, ok := getHostProfileToInstallByEnrollmentID(hostProfilesToInstallMap, userEnrollmentsToHostUUIDsMap, enrollmentID, profUUID)
|
|
if !ok {
|
|
// If sending to the user channel the enrollmentID will have to be mapped back to the host UUID.
|
|
hostUUID, ok := userEnrollmentsToHostUUIDsMap[enrollmentID]
|
|
if ok {
|
|
profile, ok = hostProfilesToInstallMap[hostProfileUUID{HostUUID: hostUUID, ProfileUUID: profUUID}]
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
}
|
|
profile.Status = &fleet.MDMDeliveryFailed
|
|
profile.Detail = detail
|
|
profile.VariablesUpdatedAt = variablesUpdatedAt
|
|
profilesToUpdate = append(profilesToUpdate, profile)
|
|
}
|
|
if err := ds.BulkUpsertMDMAppleHostProfiles(ctx, profilesToUpdate); err != nil {
|
|
return false, ctxerr.Wrap(ctx, err, "marking host profiles failed")
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// scepCertRenewalThresholdDays defines the number of days before a SCEP
|
|
// certificate must be renewed.
|
|
const scepCertRenewalThresholdDays = 180
|
|
|
|
// maxCertsRenewalPerRun specifies the maximum number of certificates to renew
|
|
// in a single cron run.
|
|
//
|
|
// Assuming that the cron runs every hour, we'll enqueue 24,000 renewals per
|
|
// day, and we have room for 24,000 * scepCertRenewalThresholdDays total
|
|
// renewals.
|
|
//
|
|
// For a default of 180 days as a threshold this gives us room for a fleet of
|
|
// ~4 million devices expiring at the same time.
|
|
const maxCertsRenewalPerRun = 100
|
|
|
|
func RenewSCEPCertificates(
|
|
ctx context.Context,
|
|
logger *platformlogging.Logger,
|
|
ds fleet.Datastore,
|
|
config *config.FleetConfig,
|
|
commander *apple_mdm.MDMAppleCommander,
|
|
) error {
|
|
renewalDisable, exists := os.LookupEnv("FLEET_MDM_APPLE_SCEP_RENEWAL_DISABLE")
|
|
if exists && (strings.EqualFold(renewalDisable, "true") || renewalDisable == "1") {
|
|
level.Info(logger).Log("msg", "skipping renewal of macOS SCEP certificates as FLEET_MDM_APPLE_SCEP_RENEWAL_DISABLE is set to true")
|
|
return nil
|
|
}
|
|
|
|
appConfig, err := ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("reading app config: %w", err)
|
|
}
|
|
if !appConfig.MDM.EnabledAndConfigured {
|
|
level.Debug(logger).Log("msg", "skipping renewal of macOS SCEP certificates as MDM is not fully configured")
|
|
return nil
|
|
}
|
|
|
|
if commander == nil {
|
|
level.Debug(logger).Log("msg", "skipping renewal of macOS SCEP certificates as apple_mdm.MDMAppleCommander was not provided")
|
|
return nil
|
|
}
|
|
|
|
// for each hash, grab the host that uses it as its identity certificate
|
|
certAssociations, err := ds.GetHostCertAssociationsToExpire(ctx, scepCertRenewalThresholdDays, maxCertsRenewalPerRun)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting host cert associations")
|
|
}
|
|
|
|
if len(certAssociations) == 0 {
|
|
level.Debug(logger).Log("msg", "no certs to renew")
|
|
return nil
|
|
}
|
|
|
|
// assocsWithRefs stores hosts that have enrollment references on their
|
|
// enrollment profiles. This is the case for ADE-enrolled hosts using
|
|
// SSO to authenticate.
|
|
assocsWithRefs := []fleet.SCEPIdentityAssociation{}
|
|
// assocsWithoutRefs stores hosts that don't have an enrollment
|
|
// reference in their enrollment profile.
|
|
assocsWithoutRefs := []fleet.SCEPIdentityAssociation{}
|
|
// assocsFromMigration stores hosts that were migrated from another MDM
|
|
// using the process described in
|
|
// https://github.com/fleetdm/fleet/issues/19387
|
|
assocsFromMigration := []fleet.SCEPIdentityAssociation{}
|
|
// userDeviceAssocs stores hosts enrolled using Account Driven User Enrollment
|
|
// which results in a "User Enrollment (Device)" enrollment type and requires
|
|
// a different type of enrollment profile sent to the host.
|
|
userDeviceAssocs := []fleet.SCEPIdentityAssociation{}
|
|
for _, assoc := range certAssociations {
|
|
if assoc.EnrolledFromMigration {
|
|
assocsFromMigration = append(assocsFromMigration, assoc)
|
|
continue
|
|
}
|
|
|
|
if assoc.EnrollmentType == "User Enrollment (Device)" {
|
|
userDeviceAssocs = append(userDeviceAssocs, assoc)
|
|
continue
|
|
}
|
|
|
|
if assoc.EnrollReference != "" {
|
|
assocsWithRefs = append(assocsWithRefs, assoc)
|
|
continue
|
|
}
|
|
assocsWithoutRefs = append(assocsWithoutRefs, assoc)
|
|
}
|
|
|
|
mdmPushCertTopic, err := assets.APNSTopic(ctx, ds)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "extracting topic from APNs certificate")
|
|
}
|
|
|
|
assets, err := ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
|
|
fleet.MDMAssetSCEPChallenge,
|
|
}, nil)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "loading SCEP challenge from the database")
|
|
}
|
|
scepChallenge := string(assets[fleet.MDMAssetSCEPChallenge].Value)
|
|
|
|
// send a single command for all the hosts without references.
|
|
if len(assocsWithoutRefs) > 0 {
|
|
profile, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
|
|
appConfig.OrgInfo.OrgName,
|
|
appConfig.MDMUrl(),
|
|
scepChallenge,
|
|
mdmPushCertTopic,
|
|
)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "generating enrollment profile for hosts without enroll reference")
|
|
}
|
|
|
|
if err := renewSCEPWithProfile(ctx, ds, commander, logger, assocsWithoutRefs, profile); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "sending profile to hosts without associations")
|
|
}
|
|
}
|
|
|
|
if len(userDeviceAssocs) > 0 {
|
|
hostUUIDs := make([]string, 0, len(userDeviceAssocs))
|
|
for i := 0; i < len(userDeviceAssocs); i++ {
|
|
hostUUIDs = append(hostUUIDs, userDeviceAssocs[i].HostUUID)
|
|
}
|
|
idpAccountsByHostUUID, err := ds.GetMDMIdPAccountsByHostUUIDs(ctx, hostUUIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "getting IDP accounts for user device associations")
|
|
}
|
|
for _, assoc := range userDeviceAssocs {
|
|
idpAccount := idpAccountsByHostUUID[assoc.HostUUID]
|
|
|
|
// This will end up not passing an email which is not idea, Apple says it is required
|
|
// and cannot change however in testing an iOS 18 device still renewed in this case so
|
|
// it is probably our best option for now.
|
|
email := ""
|
|
if idpAccount != nil {
|
|
email = idpAccount.Email
|
|
} else {
|
|
level.Error(logger).Log("msg", "no IDP account associated with account driven user enrollment host, sending renewal without email", "host_uuid", assoc.HostUUID)
|
|
}
|
|
profile, err := apple_mdm.GenerateAccountDrivenEnrollmentProfileMobileconfig(
|
|
appConfig.OrgInfo.OrgName,
|
|
appConfig.MDMUrl(),
|
|
scepChallenge,
|
|
mdmPushCertTopic,
|
|
email,
|
|
)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "generating enrollment profile for hosts with enroll reference")
|
|
}
|
|
|
|
// each host with association needs a different enrollment profile, and thus a different command.
|
|
if err := renewSCEPWithProfile(ctx, ds, commander, logger, []fleet.SCEPIdentityAssociation{assoc}, profile); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "sending account driven enrollment profile renewal to hosts")
|
|
}
|
|
}
|
|
}
|
|
|
|
// send individual commands for each host with a reference
|
|
for _, assoc := range assocsWithRefs {
|
|
enrollURL, err := apple_mdm.AddEnrollmentRefToFleetURL(appConfig.MDMUrl(), assoc.EnrollReference)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "adding reference to fleet URL")
|
|
}
|
|
|
|
profile, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
|
|
appConfig.OrgInfo.OrgName,
|
|
enrollURL,
|
|
scepChallenge,
|
|
mdmPushCertTopic,
|
|
)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "generating enrollment profile for hosts with enroll reference")
|
|
}
|
|
|
|
// each host with association needs a different enrollment profile, and thus a different command.
|
|
if err := renewSCEPWithProfile(ctx, ds, commander, logger, []fleet.SCEPIdentityAssociation{assoc}, profile); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "sending profile to hosts without associations")
|
|
}
|
|
}
|
|
|
|
decodedMigrationEnrollmentProfile, err := base64.StdEncoding.DecodeString(os.Getenv("FLEET_SILENT_MIGRATION_ENROLLMENT_PROFILE"))
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "failed to decode silent migration enrollment profile")
|
|
}
|
|
hasAssocsFromMigration := len(assocsFromMigration) > 0
|
|
|
|
migrationEnrollmentProfile := string(decodedMigrationEnrollmentProfile)
|
|
if migrationEnrollmentProfile == "" && hasAssocsFromMigration {
|
|
level.Debug(logger).Log("msg", "found devices from migration that need SCEP renewals but FLEET_SILENT_MIGRATION_ENROLLMENT_PROFILE is empty")
|
|
}
|
|
if migrationEnrollmentProfile != "" && hasAssocsFromMigration {
|
|
profileBytes := []byte(migrationEnrollmentProfile)
|
|
if err := renewSCEPWithProfile(ctx, ds, commander, logger, assocsFromMigration, profileBytes); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "sending profile to hosts from migration")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func renewSCEPWithProfile(
|
|
ctx context.Context,
|
|
ds fleet.Datastore,
|
|
commander *apple_mdm.MDMAppleCommander,
|
|
logger *platformlogging.Logger,
|
|
assocs []fleet.SCEPIdentityAssociation,
|
|
profile []byte,
|
|
) error {
|
|
cmdUUID := uuid.NewString()
|
|
var uuids []string
|
|
duplicateUUIDCheck := map[string]struct{}{}
|
|
for _, assoc := range assocs {
|
|
// this should never happen if our DB logic is on point.
|
|
// This sanity check is in place to prevent issues like
|
|
// https://github.com/fleetdm/fleet/issues/19311 where a
|
|
// single duplicated UUID prevents _all_ the commands from
|
|
// being enqueued.
|
|
if _, ok := duplicateUUIDCheck[assoc.HostUUID]; ok {
|
|
logger.Log("inf", "duplicated host UUID while renewing associations", "host_uuid", assoc.HostUUID)
|
|
continue
|
|
}
|
|
|
|
duplicateUUIDCheck[assoc.HostUUID] = struct{}{}
|
|
uuids = append(uuids, assoc.HostUUID)
|
|
}
|
|
|
|
if err := commander.InstallProfile(ctx, uuids, profile, cmdUUID); err != nil {
|
|
return ctxerr.Wrapf(ctx, err, "sending InstallProfile command for hosts %s", uuids)
|
|
}
|
|
|
|
if err := ds.SetCommandForPendingSCEPRenewal(ctx, assocs, cmdUUID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "setting pending command associations")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MDMAppleDDMService is the service that handles MDM [DeclarativeManagement][1] requests.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/declarative_management_checkin
|
|
type MDMAppleDDMService struct {
|
|
ds fleet.Datastore
|
|
logger *platformlogging.Logger
|
|
}
|
|
|
|
func NewMDMAppleDDMService(ds fleet.Datastore, logger *platformlogging.Logger) *MDMAppleDDMService {
|
|
return &MDMAppleDDMService{
|
|
ds: ds,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// DeclarativeManagement handles MDM [DeclarativeManagement][1] requests.
|
|
//
|
|
// This method is when the request has been handled by nanomdm.
|
|
//
|
|
// [1]: https://developer.apple.com/documentation/devicemanagement/declarative_management_checkin
|
|
func (svc *MDMAppleDDMService) DeclarativeManagement(r *mdm.Request, dm *mdm.DeclarativeManagement) ([]byte, error) {
|
|
if dm == nil {
|
|
level.Debug(svc.logger).Log("msg", "ddm request received with nil payload")
|
|
return nil, nil
|
|
}
|
|
level.Debug(svc.logger).Log("msg", "ddm request received", "endpoint", dm.Endpoint)
|
|
|
|
if err := svc.ds.InsertMDMAppleDDMRequest(r.Context, dm.Identifier(), dm.Endpoint, dm.Data); err != nil {
|
|
return nil, ctxerr.Wrap(r.Context, err, "insert ddm request history")
|
|
}
|
|
|
|
if dm.Identifier() == "" {
|
|
return nil, nano_service.NewHTTPStatusError(http.StatusBadRequest, ctxerr.New(r.Context, "missing UDID/EnrollmentID in request"))
|
|
}
|
|
|
|
switch {
|
|
case dm.Endpoint == "tokens":
|
|
level.Debug(svc.logger).Log("msg", "received tokens request")
|
|
return svc.handleTokens(r.Context, dm.Identifier())
|
|
|
|
case dm.Endpoint == "declaration-items":
|
|
level.Debug(svc.logger).Log("msg", "received declaration-items request")
|
|
return svc.handleDeclarationItems(r.Context, dm.Identifier())
|
|
|
|
case dm.Endpoint == "status":
|
|
level.Debug(svc.logger).Log("msg", "received status request")
|
|
return nil, svc.handleDeclarationStatus(r.Context, dm)
|
|
|
|
case strings.HasPrefix(dm.Endpoint, "declaration/"):
|
|
level.Debug(svc.logger).Log("msg", "received declarations request")
|
|
return svc.handleDeclarationsResponse(r.Context, dm.Endpoint, dm.Identifier())
|
|
|
|
default:
|
|
return nil, nano_service.NewHTTPStatusError(http.StatusBadRequest, ctxerr.New(r.Context, fmt.Sprintf("unrecognized declarations endpoint: %s", dm.Endpoint)))
|
|
}
|
|
}
|
|
|
|
func (svc *MDMAppleDDMService) handleTokens(ctx context.Context, hostUUID string) ([]byte, error) {
|
|
tok, err := svc.ds.MDMAppleDDMDeclarationsToken(ctx, hostUUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting synchronization tokens")
|
|
}
|
|
|
|
// Important: Timestamp must use format YYYY-mm-ddTHH:MM:SSZ (no milliseconds)
|
|
// Source: https://developer.apple.com/documentation/devicemanagement/synchronizationtokens?language=objc
|
|
tok.Timestamp = tok.Timestamp.Truncate(time.Second)
|
|
b, err := json.Marshal(fleet.MDMAppleDDMTokensResponse{
|
|
SyncTokens: *tok,
|
|
})
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "marshaling synchronization tokens")
|
|
}
|
|
|
|
return b, nil
|
|
}
|
|
|
|
// handleDeclarationItems retrieves the declaration items to send back to the client to update
|
|
func (svc *MDMAppleDDMService) handleDeclarationItems(ctx context.Context, hostUUID string) ([]byte, error) {
|
|
di, err := svc.ds.MDMAppleDDMDeclarationItems(ctx, hostUUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting synchronization tokens")
|
|
}
|
|
|
|
activations := []fleet.MDMAppleDDMManifest{}
|
|
configurations := []fleet.MDMAppleDDMManifest{}
|
|
var removeDeclarationUUIDsToUpdateToPending []string
|
|
for _, d := range di {
|
|
if d.OperationType == nil {
|
|
continue
|
|
}
|
|
if *d.OperationType != string(fleet.MDMOperationTypeInstall) {
|
|
if d.Status == nil && *d.OperationType == string(fleet.MDMOperationTypeRemove) {
|
|
removeDeclarationUUIDsToUpdateToPending = append(removeDeclarationUUIDsToUpdateToPending, d.DeclarationUUID)
|
|
}
|
|
continue
|
|
}
|
|
configurations = append(configurations, fleet.MDMAppleDDMManifest{
|
|
Identifier: d.Identifier,
|
|
ServerToken: d.ServerToken,
|
|
})
|
|
activations = append(activations, fleet.MDMAppleDDMManifest{
|
|
Identifier: fmt.Sprintf("%s.activation", d.Identifier),
|
|
ServerToken: d.ServerToken,
|
|
})
|
|
}
|
|
|
|
// Calculate token based on count and concatenated tokens for install items
|
|
var count int
|
|
type tokenSorting struct {
|
|
token string
|
|
uploadedAt time.Time
|
|
declarationUUID string
|
|
}
|
|
var tokens []tokenSorting
|
|
for _, d := range di {
|
|
if d.OperationType != nil && *d.OperationType == string(fleet.MDMOperationTypeInstall) {
|
|
// Extract d.ServerToken and order by d.UploadedAt descending and then by d.DeclarationUUID ascending
|
|
sorting := tokenSorting{
|
|
token: d.ServerToken,
|
|
uploadedAt: d.UploadedAt,
|
|
declarationUUID: d.DeclarationUUID,
|
|
}
|
|
tokens = append(tokens, sorting)
|
|
count++
|
|
}
|
|
}
|
|
|
|
sort.SliceStable(tokens, func(i, j int) bool {
|
|
if tokens[i].uploadedAt.Equal(tokens[j].uploadedAt) {
|
|
return tokens[i].declarationUUID < tokens[j].declarationUUID
|
|
}
|
|
return tokens[i].uploadedAt.After(tokens[j].uploadedAt)
|
|
})
|
|
var tokenBuilder strings.Builder
|
|
for _, t := range tokens {
|
|
tokenBuilder.WriteString(t.token)
|
|
}
|
|
|
|
var token string
|
|
if count > 0 {
|
|
// Generate MD5 hash token. It must match the token generated by MDMAppleDDMDeclarationsToken
|
|
hasher := md5.New() // nolint:gosec // used for declarative management token
|
|
hasher.Write([]byte(fmt.Sprintf("%d%s", count, tokenBuilder.String())))
|
|
token = hex.EncodeToString(hasher.Sum(nil))
|
|
}
|
|
|
|
b, err := json.Marshal(fleet.MDMAppleDDMDeclarationItemsResponse{
|
|
Declarations: fleet.MDMAppleDDMManifestItems{
|
|
Activations: activations,
|
|
Configurations: configurations,
|
|
Assets: []fleet.MDMAppleDDMManifest{},
|
|
Management: []fleet.MDMAppleDDMManifest{},
|
|
},
|
|
DeclarationsToken: token,
|
|
})
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "marshaling synchronization tokens")
|
|
}
|
|
|
|
// If any "remove" declarations have a NULL status, update them to a "pending" status
|
|
// so they can be cleared when the host sends back a status report.
|
|
// Otherwise they may get stuck in "pending" -- host already cleared them, but Fleet doesn't think so.
|
|
if len(removeDeclarationUUIDsToUpdateToPending) > 0 {
|
|
err = svc.ds.MDMAppleSetRemoveDeclarationsAsPending(ctx, hostUUID, removeDeclarationUUIDsToUpdateToPending)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "updating remove declarations to pending")
|
|
}
|
|
}
|
|
|
|
return b, nil
|
|
}
|
|
|
|
func (svc *MDMAppleDDMService) handleDeclarationsResponse(ctx context.Context, endpoint string, hostUUID string) ([]byte, error) {
|
|
parts := strings.Split(endpoint, "/")
|
|
if len(parts) != 3 {
|
|
return nil, nano_service.NewHTTPStatusError(http.StatusBadRequest, ctxerr.Errorf(ctx, "unrecognized declarations endpoint: %s", endpoint))
|
|
}
|
|
level.Debug(svc.logger).Log("msg", "parsed declarations request", "type", parts[1], "identifier", parts[2])
|
|
|
|
switch parts[1] {
|
|
case "activation":
|
|
return svc.handleActivationDeclaration(ctx, parts, hostUUID)
|
|
case "configuration":
|
|
return svc.handleConfigurationDeclaration(ctx, parts, hostUUID)
|
|
default:
|
|
return nil, nano_service.NewHTTPStatusError(http.StatusNotFound, ctxerr.Errorf(ctx, "declaration type not supported: %s", parts[1]))
|
|
}
|
|
}
|
|
|
|
func (svc *MDMAppleDDMService) handleActivationDeclaration(ctx context.Context, parts []string, hostUUID string) ([]byte, error) {
|
|
references := strings.TrimSuffix(parts[2], ".activation")
|
|
|
|
// ensure the declaration for the requested activation still exists
|
|
d, err := svc.ds.MDMAppleDDMDeclarationsResponse(ctx, references, hostUUID)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
return nil, nano_service.NewHTTPStatusError(http.StatusNotFound, err)
|
|
}
|
|
return nil, ctxerr.Wrap(ctx, err, "getting linked configuration for activation declaration")
|
|
}
|
|
|
|
response := fmt.Sprintf(`
|
|
{
|
|
"Identifier": "%s",
|
|
"Payload": {
|
|
"StandardConfigurations": ["%s"]
|
|
},
|
|
"ServerToken": "%s",
|
|
"Type": "com.apple.activation.simple"
|
|
}`, parts[2], references, d.Token)
|
|
|
|
return []byte(response), nil
|
|
}
|
|
|
|
func (svc *MDMAppleDDMService) handleConfigurationDeclaration(ctx context.Context, parts []string, hostUUID string) ([]byte, error) {
|
|
d, err := svc.ds.MDMAppleDDMDeclarationsResponse(ctx, parts[2], hostUUID)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
return nil, nano_service.NewHTTPStatusError(http.StatusNotFound, err)
|
|
}
|
|
return nil, ctxerr.Wrap(ctx, err, "getting declaration response")
|
|
}
|
|
|
|
expanded, err := svc.ds.ExpandEmbeddedSecrets(ctx, string(d.RawJSON))
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("expanding embedded secrets for identifier:%s hostUUID:%s", parts[2], hostUUID))
|
|
}
|
|
|
|
var tempd map[string]any
|
|
if err := json.Unmarshal([]byte(expanded), &tempd); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "unmarshaling stored declaration")
|
|
}
|
|
tempd["ServerToken"] = d.Token
|
|
|
|
b, err := json.Marshal(tempd)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "marshaling declaration")
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func (svc *MDMAppleDDMService) handleDeclarationStatus(ctx context.Context, dm *mdm.DeclarativeManagement) error {
|
|
var statusReport fleet.MDMAppleDDMStatusReport
|
|
if err := json.Unmarshal(dm.Data, &statusReport); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "unmarshalling response")
|
|
}
|
|
|
|
configurationReports := statusReport.StatusItems.Management.Declarations.Configurations
|
|
updates := make([]*fleet.MDMAppleHostDeclaration, len(configurationReports))
|
|
for i, r := range configurationReports {
|
|
var status fleet.MDMDeliveryStatus
|
|
var detail string
|
|
switch {
|
|
case r.Active && r.Valid == fleet.MDMAppleDeclarationValid:
|
|
status = fleet.MDMDeliveryVerified
|
|
case r.Valid == fleet.MDMAppleDeclarationInvalid || isUnknownDeclarationType(r):
|
|
status = fleet.MDMDeliveryFailed
|
|
detail = apple_mdm.FmtDDMError(r.Reasons)
|
|
case r.Valid == fleet.MDMAppleDeclarationValid: // should be rare/never
|
|
// The debug messages here can be used to figure out why a DDM profile is stuck in a certain state on a device.
|
|
level.Debug(svc.logger).Log("msg", "valid but inactive declaration status", "status", r.Valid, "active", r.Active, "host",
|
|
dm.Identifier(), "declaration", r.Identifier)
|
|
status = fleet.MDMDeliveryVerifying
|
|
case r.Valid == fleet.MDMAppleDeclarationUnknown: // should be rare
|
|
level.Debug(svc.logger).Log("msg", "unknown declaration status", "status", r.Valid, "active", r.Active, "host", dm.Identifier(),
|
|
"declaration", r.Identifier)
|
|
status = fleet.MDMDeliveryVerifying
|
|
default:
|
|
// This should never happen. If we see this happening, we should handle it.
|
|
level.Error(svc.logger).Log("msg", "undefined declaration status", "status", r.Valid, "active", r.Active, "host", dm.Identifier(),
|
|
"declaration", r.Identifier)
|
|
status = fleet.MDMDeliveryFailed
|
|
detail = fmt.Sprintf("undefined declaration status: %s; %s", r.Valid, apple_mdm.FmtDDMError(r.Reasons))
|
|
}
|
|
|
|
updates[i] = &fleet.MDMAppleHostDeclaration{
|
|
Status: &status,
|
|
OperationType: fleet.MDMOperationTypeInstall,
|
|
Detail: detail,
|
|
Token: r.ServerToken,
|
|
}
|
|
}
|
|
|
|
// MDMAppleStoreDDMStatusReport takes care of cleaning ("pending", "remove")
|
|
// pairs for the host.
|
|
//
|
|
// TODO(roberto): in the DDM documentation, it's mentioned that status
|
|
// report will give you a "remove" status so the server can track
|
|
// removals. In my testing, I never saw this (after spending
|
|
// considerable time trying to make it work.)
|
|
//
|
|
// My current guess is that the documentation is implicitly referring
|
|
// to asset declarations (which deliver tangible "assets" to the host)
|
|
//
|
|
// The best indication I found so far, is that if the declaration is
|
|
// not in the report, then it's implicitly removed.
|
|
if err := svc.ds.MDMAppleStoreDDMStatusReport(ctx, dm.Identifier(), updates); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "updating host declaration status with reports")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Checks the active, valid and first reason to verify if it is an unknown declaration type error
|
|
func isUnknownDeclarationType(declarationResponse fleet.MDMAppleDDMStatusDeclaration) bool {
|
|
return !declarationResponse.Active &&
|
|
declarationResponse.Valid == fleet.MDMAppleDeclarationUnknown &&
|
|
len(declarationResponse.Reasons) > 0 &&
|
|
declarationResponse.Reasons[0].Code == "Error.UnknownDeclarationType"
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Generate ABM keypair endpoint
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type generateABMKeyPairResponse struct {
|
|
PublicKey []byte `json:"public_key,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r generateABMKeyPairResponse) Error() error { return r.Err }
|
|
|
|
func generateABMKeyPairEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
keyPair, err := svc.GenerateABMKeyPair(ctx)
|
|
if err != nil {
|
|
return generateABMKeyPairResponse{
|
|
Err: err,
|
|
}, nil
|
|
}
|
|
|
|
return generateABMKeyPairResponse{
|
|
PublicKey: keyPair.PublicKey,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) GenerateABMKeyPair(ctx context.Context) (*fleet.MDMAppleDEPKeyPair, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.AppleBM{}, fleet.ActionWrite); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
privateKey := svc.config.Server.PrivateKey
|
|
if testSetEmptyPrivateKey {
|
|
privateKey = ""
|
|
}
|
|
|
|
if len(privateKey) == 0 {
|
|
return nil, ctxerr.New(ctx, "Couldn't download public key. Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key")
|
|
}
|
|
|
|
var publicKeyPEM, privateKeyPEM []byte
|
|
assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
|
|
fleet.MDMAssetABMCert,
|
|
fleet.MDMAssetABMKey,
|
|
}, nil)
|
|
if err != nil {
|
|
// allow not found errors as it means that we're generating the
|
|
// keypair for the first time
|
|
if !fleet.IsNotFound(err) {
|
|
return nil, ctxerr.Wrap(ctx, err, "loading ABM keys from the database")
|
|
}
|
|
}
|
|
|
|
// if we don't have any certificates, create a new keypair, otherwise
|
|
// return the already stored values to allow for the renewal flow.
|
|
if len(assets) == 0 {
|
|
publicKeyPEM, privateKeyPEM, err = apple_mdm.NewDEPKeyPairPEM()
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "generate key pair")
|
|
}
|
|
|
|
err = svc.ds.InsertMDMConfigAssets(ctx, []fleet.MDMConfigAsset{
|
|
{Name: fleet.MDMAssetABMCert, Value: publicKeyPEM},
|
|
{Name: fleet.MDMAssetABMKey, Value: privateKeyPEM},
|
|
}, nil)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "saving ABM keypair in database")
|
|
}
|
|
} else {
|
|
// we can trust that the keys exist due to the contract specified by
|
|
// the datastore method
|
|
publicKeyPEM = assets[fleet.MDMAssetABMCert].Value
|
|
privateKeyPEM = assets[fleet.MDMAssetABMKey].Value
|
|
}
|
|
|
|
return &fleet.MDMAppleDEPKeyPair{
|
|
PublicKey: publicKeyPEM,
|
|
PrivateKey: privateKeyPEM,
|
|
}, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Upload ABM token endpoint
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type uploadABMTokenRequest struct {
|
|
Token *multipart.FileHeader
|
|
}
|
|
|
|
func (uploadABMTokenRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
err := r.ParseMultipartForm(platform_http.MaxMultipartFormSize)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "failed to parse multipart form",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
token, ok := r.MultipartForm.File["token"]
|
|
if !ok || len(token) < 1 {
|
|
return nil, &fleet.BadRequestError{Message: "no file headers for token"}
|
|
}
|
|
|
|
return &uploadABMTokenRequest{
|
|
Token: token[0],
|
|
}, nil
|
|
}
|
|
|
|
type uploadABMTokenResponse struct {
|
|
Token *fleet.ABMToken `json:"abm_token,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r uploadABMTokenResponse) Error() error { return r.Err }
|
|
|
|
func uploadABMTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*uploadABMTokenRequest)
|
|
ff, err := req.Token.Open()
|
|
if err != nil {
|
|
return uploadABMTokenResponse{Err: err}, nil
|
|
}
|
|
defer ff.Close()
|
|
|
|
token, err := svc.UploadABMToken(ctx, ff)
|
|
if err != nil {
|
|
return uploadABMTokenResponse{
|
|
Err: err,
|
|
}, nil
|
|
}
|
|
|
|
return uploadABMTokenResponse{Token: token}, nil
|
|
}
|
|
|
|
func (svc *Service) UploadABMToken(ctx context.Context, token io.Reader) (*fleet.ABMToken, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Disable ABM endpoint
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type deleteABMTokenRequest struct {
|
|
TokenID uint `url:"id"`
|
|
}
|
|
|
|
type deleteABMTokenResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deleteABMTokenResponse) Error() error { return r.Err }
|
|
func (r deleteABMTokenResponse) Status() int { return http.StatusNoContent }
|
|
|
|
func deleteABMTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*deleteABMTokenRequest)
|
|
if err := svc.DeleteABMToken(ctx, req.TokenID); err != nil {
|
|
return deleteABMTokenResponse{Err: err}, nil
|
|
}
|
|
|
|
return deleteABMTokenResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) DeleteABMToken(ctx context.Context, tokenID uint) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// List ABM tokens endpoint
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type listABMTokensResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
Tokens []*fleet.ABMToken `json:"abm_tokens"`
|
|
}
|
|
|
|
func (r listABMTokensResponse) Error() error { return r.Err }
|
|
|
|
func listABMTokensEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
tokens, err := svc.ListABMTokens(ctx)
|
|
if err != nil {
|
|
return &listABMTokensResponse{Err: err}, nil
|
|
}
|
|
|
|
if tokens == nil {
|
|
tokens = []*fleet.ABMToken{}
|
|
}
|
|
|
|
return &listABMTokensResponse{Tokens: tokens}, nil
|
|
}
|
|
|
|
func (svc *Service) ListABMTokens(ctx context.Context) ([]*fleet.ABMToken, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
// //////////////////////////////////////////////////////////////////////////////
|
|
// Count ABM tokens endpoint
|
|
// //////////////////////////////////////////////////////////////////////////////
|
|
|
|
type countABMTokensResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
func (r countABMTokensResponse) Error() error { return r.Err }
|
|
|
|
func countABMTokensEndpoint(ctx context.Context, _ interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
tokenCount, err := svc.CountABMTokens(ctx)
|
|
if err != nil {
|
|
return &countABMTokensResponse{Err: err}, nil
|
|
}
|
|
|
|
return &countABMTokensResponse{Count: tokenCount}, nil
|
|
}
|
|
|
|
func (svc *Service) CountABMTokens(ctx context.Context) (int, error) {
|
|
// Automatic enrollment (ABM/ADE/DEP) is a feature that requires a license.
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return 0, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Update ABM token teams endpoint
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type updateABMTokenTeamsRequest struct {
|
|
TokenID uint `url:"id"`
|
|
MacOSTeamID *uint `json:"macos_team_id" renameto:"macos_fleet_id"`
|
|
IOSTeamID *uint `json:"ios_team_id" renameto:"ios_fleet_id"`
|
|
IPadOSTeamID *uint `json:"ipados_team_id" renameto:"ipados_fleet_id"`
|
|
}
|
|
|
|
type updateABMTokenTeamsResponse struct {
|
|
ABMToken *fleet.ABMToken `json:"abm_token,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r updateABMTokenTeamsResponse) Error() error { return r.Err }
|
|
|
|
func updateABMTokenTeamsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*updateABMTokenTeamsRequest)
|
|
|
|
tok, err := svc.UpdateABMTokenTeams(ctx, req.TokenID, req.MacOSTeamID, req.IOSTeamID, req.IPadOSTeamID)
|
|
if err != nil {
|
|
return &updateABMTokenTeamsResponse{Err: err}, nil
|
|
}
|
|
|
|
return &updateABMTokenTeamsResponse{ABMToken: tok}, nil
|
|
}
|
|
|
|
func (svc *Service) UpdateABMTokenTeams(ctx context.Context, tokenID uint, macOSTeamID, iOSTeamID, iPadOSTeamID *uint) (*fleet.ABMToken, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Renew ABM token endpoint
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type renewABMTokenRequest struct {
|
|
TokenID uint `url:"id"`
|
|
Token *multipart.FileHeader
|
|
}
|
|
|
|
func (renewABMTokenRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
err := r.ParseMultipartForm(platform_http.MaxMultipartFormSize)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "failed to parse multipart form",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
token, ok := r.MultipartForm.File["token"]
|
|
if !ok || len(token) < 1 {
|
|
return nil, &fleet.BadRequestError{Message: "no file headers for token"}
|
|
}
|
|
|
|
// because we are in this method, we know that the path has 7 parts, e.g:
|
|
// /api/latest/fleet/abm_tokens/19/renew
|
|
|
|
id, err := endpointer.IntFromRequest(r, "id")
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to parse abm token id")
|
|
}
|
|
|
|
return &renewABMTokenRequest{
|
|
Token: token[0],
|
|
TokenID: uint(id), //nolint:gosec // dismiss G115
|
|
}, nil
|
|
}
|
|
|
|
type renewABMTokenResponse struct {
|
|
ABMToken *fleet.ABMToken `json:"abm_token,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r renewABMTokenResponse) Error() error { return r.Err }
|
|
|
|
func renewABMTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*renewABMTokenRequest)
|
|
ff, err := req.Token.Open()
|
|
if err != nil {
|
|
return &renewABMTokenResponse{Err: err}, nil
|
|
}
|
|
defer ff.Close()
|
|
|
|
tok, err := svc.RenewABMToken(ctx, ff, req.TokenID)
|
|
if err != nil {
|
|
return &renewABMTokenResponse{Err: err}, nil
|
|
}
|
|
|
|
return &renewABMTokenResponse{ABMToken: tok}, nil
|
|
}
|
|
|
|
func (svc *Service) RenewABMToken(ctx context.Context, token io.Reader, tokenID uint) (*fleet.ABMToken, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// GET /enrollment_profiles/ota
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getOTAProfileRequest struct {
|
|
EnrollSecret string `query:"enroll_secret"`
|
|
IdpUUID string // The UUID of the mdm_idp_account that was used if any, can be empty, will be taken from cookies
|
|
}
|
|
|
|
func (getOTAProfileRequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
enrollSecret := r.URL.Query().Get("enroll_secret")
|
|
if enrollSecret == "" {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "enroll_secret is required",
|
|
}
|
|
}
|
|
|
|
boydIdpCookie, err := r.Cookie(shared_mdm.BYODIdpCookieName)
|
|
if err != nil {
|
|
// r.Cookie only return ErrNoCookie and no other errors.
|
|
|
|
// We do not fail here if no cookie is found, we validate later down the line if it's required
|
|
return &getOTAProfileRequest{
|
|
EnrollSecret: enrollSecret,
|
|
IdpUUID: "",
|
|
}, nil
|
|
}
|
|
|
|
if err = boydIdpCookie.Valid(); err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "boyd idp cookie is not valid",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
return &getOTAProfileRequest{
|
|
EnrollSecret: enrollSecret,
|
|
IdpUUID: boydIdpCookie.Value,
|
|
}, nil
|
|
}
|
|
|
|
func getOTAProfileEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getOTAProfileRequest)
|
|
profile, err := svc.GetOTAProfile(ctx, req.EnrollSecret, req.IdpUUID)
|
|
if err != nil {
|
|
return &getMDMAppleConfigProfileResponse{Err: err}, err
|
|
}
|
|
|
|
reader := bytes.NewReader(profile)
|
|
return &getMDMAppleConfigProfileResponse{fileReader: io.NopCloser(reader), fileLength: reader.Size(), fileName: "fleet-mdm-enrollment-profile"}, nil
|
|
}
|
|
|
|
func (svc *Service) GetOTAProfile(ctx context.Context, enrollSecret, idpUUID string) ([]byte, error) {
|
|
// Skip authz as this endpoint is used by end users from their iPhones or iPads; authz is done
|
|
// by the enroll secret verification below
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
cfg, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting app config to get org name")
|
|
}
|
|
|
|
// TODO(IB): Validate that the IdpUUID should be populated based on the criteria for showing the SSO in the first place
|
|
// Should be added with the work of #30660 or afterwars.
|
|
|
|
profBytes, err := apple_mdm.GenerateOTAEnrollmentProfileMobileconfig(cfg.OrgInfo.OrgName, cfg.MDMUrl(), enrollSecret, idpUUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "generating ota mobileconfig file")
|
|
}
|
|
|
|
signed, err := mdmcrypto.Sign(ctx, profBytes, svc.ds)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "signing profile")
|
|
}
|
|
|
|
return signed, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// POST /ota_enrollment?enroll_secret=xyz
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type mdmAppleOTARequest struct {
|
|
EnrollSecret string `query:"enroll_secret"`
|
|
IdpUUID string `query:"idp_uuid"`
|
|
Certificates []*x509.Certificate
|
|
RootSigner *x509.Certificate
|
|
DeviceInfo fleet.MDMAppleMachineInfo
|
|
}
|
|
|
|
func (mdmAppleOTARequest) DecodeRequest(ctx context.Context, r *http.Request) (interface{}, error) {
|
|
enrollSecret := r.URL.Query().Get("enroll_secret")
|
|
if enrollSecret == "" {
|
|
return nil, &fleet.OTAForbiddenError{
|
|
InternalErr: errors.New("enroll_secret query parameter was empty"),
|
|
}
|
|
}
|
|
|
|
idpUUID := r.URL.Query().Get("idp_uuid") // Can be empty.
|
|
|
|
rawData, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "reading body from request")
|
|
}
|
|
|
|
p7, err := pkcs7.Parse(rawData)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "invalid request body",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
var request mdmAppleOTARequest
|
|
err = plist.Unmarshal(p7.Content, &request.DeviceInfo)
|
|
if err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "invalid request body",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
if request.DeviceInfo.Serial == "" {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "SERIAL is required",
|
|
}
|
|
}
|
|
|
|
request.EnrollSecret = enrollSecret
|
|
request.IdpUUID = idpUUID
|
|
request.Certificates = p7.Certificates
|
|
request.RootSigner = p7.GetOnlySigner()
|
|
return &request, nil
|
|
}
|
|
|
|
type mdmAppleOTAResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
xml []byte
|
|
}
|
|
|
|
func (r mdmAppleOTAResponse) Error() error { return r.Err }
|
|
|
|
func (r mdmAppleOTAResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(r.xml)))
|
|
w.Header().Set("Content-Type", "application/x-apple-aspen-config")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
if _, err := w.Write(r.xml); err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
func mdmAppleOTAEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*mdmAppleOTARequest)
|
|
xml, err := svc.MDMAppleProcessOTAEnrollment(ctx, req.Certificates, req.RootSigner, req.EnrollSecret, req.IdpUUID, req.DeviceInfo)
|
|
if err != nil {
|
|
return mdmAppleGetInstallerResponse{Err: err}, nil
|
|
}
|
|
return mdmAppleOTAResponse{xml: xml}, nil
|
|
}
|
|
|
|
// NOTE: this method and how OTA works is documented in full in the interface definition.
|
|
func (svc *Service) MDMAppleProcessOTAEnrollment(
|
|
ctx context.Context,
|
|
certificates []*x509.Certificate,
|
|
rootSigner *x509.Certificate,
|
|
enrollSecret string,
|
|
idpUUID string,
|
|
deviceInfo fleet.MDMAppleMachineInfo,
|
|
) ([]byte, error) {
|
|
// authorization is performed via the enroll secret and the provided certificates
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
if len(certificates) == 0 {
|
|
return nil, authz.ForbiddenWithInternal("no certificates provided", nil, nil, nil)
|
|
}
|
|
|
|
// first check is for the enroll secret, we'll only let the host
|
|
// through if it has a valid secret.
|
|
enrollSecretInfo, err := svc.ds.VerifyEnrollSecret(ctx, enrollSecret)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
return nil, &fleet.OTAForbiddenError{
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
return nil, ctxerr.Wrap(ctx, err, "validating enroll secret")
|
|
}
|
|
|
|
assets, err := svc.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{
|
|
fleet.MDMAssetSCEPChallenge,
|
|
}, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading SCEP challenge from the database: %w", err)
|
|
}
|
|
scepChallenge := string(assets[fleet.MDMAssetSCEPChallenge].Value)
|
|
|
|
appCfg, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "reading app config")
|
|
}
|
|
|
|
mdmURL := appCfg.MDMUrl()
|
|
|
|
// if the root signer was issued by Apple's CA, it means we're in the
|
|
// first phase and we should return a SCEP payload.
|
|
if err := apple_mdm.VerifyFromAppleIphoneDeviceCA(rootSigner); err == nil {
|
|
scepURL, err := apple_mdm.ResolveAppleSCEPURL(mdmURL)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "resolve Apple SCEP url")
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := apple_mdm.OTASCEPTemplate.Execute(&buf, struct {
|
|
SCEPURL string
|
|
SCEPChallenge string
|
|
}{
|
|
SCEPURL: scepURL,
|
|
SCEPChallenge: scepChallenge,
|
|
}); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "execute template")
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// otherwise we might be in the second phase, check if the signing cert
|
|
// was issued by Fleet, only let the enrollment through if so.
|
|
certVerifier := mdmcrypto.NewSCEPVerifier(svc.ds)
|
|
if err := certVerifier.Verify(ctx, rootSigner); err != nil {
|
|
return nil, authz.ForbiddenWithInternal(fmt.Sprintf("payload signed with invalid certificate: %s", err), nil, nil, nil)
|
|
}
|
|
|
|
topic, err := svc.mdmPushCertTopic(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "extracting topic from APNs cert")
|
|
}
|
|
|
|
enrollmentProf, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
|
|
appCfg.OrgInfo.OrgName,
|
|
mdmURL,
|
|
string(assets[fleet.MDMAssetSCEPChallenge].Value),
|
|
topic,
|
|
)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "generating manual enrollment profile")
|
|
}
|
|
|
|
requiresIdPUUID, err := shared_mdm.RequiresEnrollOTAAuthentication(ctx, svc.ds, enrollSecret, appCfg.MDM.MacOSSetup.EnableEndUserAuthentication)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "checking requirement of ota enrollment authentication")
|
|
}
|
|
|
|
if requiresIdPUUID && idpUUID == "" {
|
|
return nil, ctxerr.Wrap(
|
|
ctx,
|
|
authz.ForbiddenWithInternal("required idp uuid to be set, but none found", nil, nil, nil),
|
|
"missing required idp uuid",
|
|
)
|
|
}
|
|
|
|
if idpUUID != "" {
|
|
_, err := svc.ds.GetMDMIdPAccountByUUID(ctx, idpUUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "validating idp account existence")
|
|
}
|
|
}
|
|
|
|
// before responding, create a host record, and assign the host to the
|
|
// team that matches the enroll secret provided.
|
|
err = svc.ds.IngestMDMAppleDeviceFromOTAEnrollment(ctx, enrollSecretInfo.TeamID, idpUUID, deviceInfo)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "creating new host record")
|
|
}
|
|
|
|
// at this point we know the device can be enrolled, so we respond with
|
|
// a signed enrollment profile
|
|
signed, err := mdmcrypto.Sign(ctx, enrollmentProf, svc.ds)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "signing profile")
|
|
}
|
|
|
|
return signed, nil
|
|
}
|
|
|
|
// EnsureMDMAppleServiceDiscovery checks if the service discovery URL is set up correctly with Apple
|
|
// and assigns it if necessary.
|
|
func EnsureMDMAppleServiceDiscovery(ctx context.Context, ds fleet.Datastore, depStorage storage.AllDEPStorage, logger *platformlogging.Logger,
|
|
urlPrefix string) error {
|
|
depSvc := apple_mdm.NewDEPService(ds, depStorage, logger)
|
|
|
|
ac, err := ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "checking account driven enrollment service discovery")
|
|
}
|
|
sdURL := ac.MDMUrl() + urlPrefix + apple_mdm.ServiceDiscoveryPath
|
|
|
|
tokens, err := ds.ListABMTokens(ctx)
|
|
switch {
|
|
case err != nil:
|
|
return ctxerr.Wrap(ctx, err, "listing ABM tokens")
|
|
case len(tokens) == 0:
|
|
level.Info(logger).Log("msg", "no ABM tokens found, skipping account driven enrollment service discovery")
|
|
return nil
|
|
case len(tokens) > 1:
|
|
level.Debug(logger).Log("msg", "multiple ABM tokens found, using the first one for account driven enrollment service discovery")
|
|
}
|
|
orgName := tokens[0].OrganizationName
|
|
|
|
details, err := depSvc.GetMDMAppleServiceDiscoveryDetails(ctx, orgName)
|
|
if err != nil {
|
|
switch {
|
|
case godep.IsServiceDiscoveryNotFound(err):
|
|
level.Info(logger).Log("msg", "account driven enrollment profile not found") // proceed to assignment
|
|
case godep.IsServiceDiscoveryNotSupported(err):
|
|
level.Info(logger).Log("msg", "account driven enrollment org not supported, skipping assignment")
|
|
return nil // skip assignment
|
|
default:
|
|
return ctxerr.Wrap(ctx, err, "fetching account driven enrollment profile") // skip assignment
|
|
}
|
|
}
|
|
|
|
var gotURL string
|
|
var lastUpdated time.Time
|
|
if details != nil {
|
|
gotURL = details.MDMServiceDiscoveryURL
|
|
lastUpdated = details.LastUpdatedTimestamp
|
|
}
|
|
level.Info(logger).Log("msg", "account driven enrollment service discovery url confirmed", "service_discovery_url", gotURL, "last_updated", lastUpdated)
|
|
|
|
if gotURL != sdURL {
|
|
// proced to assignment
|
|
return ctxerr.Wrap(ctx, depSvc.AssignMDMAppleServiceDiscoveryURL(ctx, orgName, sdURL),
|
|
"assigning account driven enrollment service discovery URL")
|
|
}
|
|
|
|
return nil
|
|
}
|