package service import ( "context" "database/sql" "encoding/json" "errors" "fmt" "log/slog" "net/http" "net/url" "github.com/fleetdm/fleet/v4/ee/server/service/hostidentity/httpsig" "github.com/fleetdm/fleet/v4/server" "github.com/fleetdm/fleet/v4/server/contexts/capabilities" "github.com/fleetdm/fleet/v4/server/contexts/ctxdb" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" hostctx "github.com/fleetdm/fleet/v4/server/contexts/host" "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm" microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/service/osquery_utils" "github.com/fleetdm/fleet/v4/server/worker" ) // enrollOrbitResponse wraps the fleet type to add HijackRender. type enrollOrbitResponse struct { fleet.EnrollOrbitResponse } // HijackRender so we can add a header with the server capabilities in the // response, allowing Orbit to know what features are available without the // need to enroll. func (r enrollOrbitResponse) HijackRender(ctx context.Context, w http.ResponseWriter) { writeCapabilitiesHeader(w, fleet.GetServerOrbitCapabilities()) enc := json.NewEncoder(w) enc.SetIndent("", " ") if err := enc.Encode(r); err != nil { encodeError(ctx, newOsqueryError(fmt.Sprintf("orbit enroll failed: %s", err)), w) } } func enrollOrbitEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*fleet.EnrollOrbitRequest) nodeKey, err := svc.EnrollOrbit(ctx, fleet.OrbitHostInfo{ HardwareUUID: req.HardwareUUID, HardwareSerial: req.HardwareSerial, Hostname: req.Hostname, Platform: req.Platform, PlatformLike: req.PlatformLike, OsqueryIdentifier: req.OsqueryIdentifier, ComputerName: req.ComputerName, HardwareModel: req.HardwareModel, }, req.EnrollSecret, req.EUAToken) if err != nil { return enrollOrbitResponse{fleet.EnrollOrbitResponse{Err: err}}, nil } return enrollOrbitResponse{fleet.EnrollOrbitResponse{OrbitNodeKey: nodeKey}}, nil } func (svc *Service) AuthenticateOrbitHost(ctx context.Context, orbitNodeKey string) (*fleet.Host, bool, error) { svc.authz.SkipAuthorization(ctx) if orbitNodeKey == "" { return nil, false, ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("authentication error: missing orbit node key")) } host, err := svc.ds.LoadHostByOrbitNodeKey(ctx, orbitNodeKey) switch { case err == nil: // OK case fleet.IsNotFound(err): return nil, false, ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("authentication error: invalid orbit node key")) default: return nil, false, ctxerr.Wrap(ctx, err, "authentication error orbit") } if *host.HasHostIdentityCert { err = httpsig.VerifyHostIdentity(ctx, svc.ds, host) if err != nil { return nil, false, ctxerr.Wrap(ctx, fleet.NewAuthRequiredError(fmt.Sprintf("authentication error orbit: %s", err.Error()))) } } return host, svc.debugEnabledForHost(ctx, host.ID), nil } // processWindowsEUAToken validates a Fleet-signed EUA token from the Windows MSI // installer, links the user's IdP account to the host, and returns the UPN and // device ID for use in post-enrollment steps. func (svc *Service) processWindowsEUAToken(ctx context.Context, hostUUID string, euaToken string) (upn string, deviceID string, err error) { if svc.wstepCertManager == nil { // Windows MDM is not configured on this server so the token cannot be validated. // Fall back to prompting the user for authentication. return "", "", fleet.NewOrbitIDPAuthRequiredError() } claims, tokenErr := svc.wstepCertManager.GetEUATokenClaims(euaToken) if tokenErr != nil { svc.logger.WarnContext(ctx, "EUA token validation failed, falling back to end user auth prompt", "err", tokenErr, "host_uuid", hostUUID) return "", "", fleet.NewOrbitIDPAuthRequiredError() } upn = claims.UPN deviceID = claims.DeviceID _, err = svc.ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, deviceID) if err != nil { if fleet.IsNotFound(err) { svc.logger.WarnContext(ctx, "EUA token device_id not found in windows mdm enrollments, falling back to end user auth prompt", "device_id", deviceID, "host_uuid", hostUUID) return "", "", fleet.NewOrbitIDPAuthRequiredError() } return "", "", ctxerr.Wrap(ctx, err, "getting windows mdm enrollment for EUA token") } // Fetch or create the mdm_idp_accounts row for this email. // Fetch first so we do not overwrite existing first/last names // that may have been populated by SCIM provisioning. acct, err := svc.ds.GetMDMIdPAccountByEmail(ctx, upn) if err != nil && !fleet.IsNotFound(err) { return "", "", ctxerr.Wrap(ctx, err, "getting mdm idp account by email for EUA token") } if fleet.IsNotFound(err) { if err := svc.ds.InsertMDMIdPAccount(ctx, &fleet.MDMIdPAccount{Email: upn, Username: upn}); err != nil { return "", "", ctxerr.Wrap(ctx, err, "inserting mdm idp account for EUA token") } // Re-fetch to get the UUID assigned by the DB. acct, err = svc.ds.GetMDMIdPAccountByEmail(ctxdb.RequirePrimary(ctx, true), upn) if err != nil { return "", "", ctxerr.Wrap(ctx, err, "re-fetching mdm idp account after insert for EUA token") } } if acct == nil { return "", "", ctxerr.New(ctx, "mdm idp account not found for EUA token") } // Link the IdP account to this host UUID in host_mdm_idp_accounts. if err := svc.ds.AssociateHostMDMIdPAccountDB(ctx, hostUUID, acct.UUID); err != nil { return "", "", ctxerr.Wrap(ctx, err, "associating host with mdm idp account for EUA token") } return upn, deviceID, nil } // EnrollOrbit enrolls an Orbit instance to Fleet and returns the orbit node key. func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInfo, enrollSecret string, euaToken string) (string, error) { // this is not a user-authenticated endpoint svc.authz.SkipAuthorization(ctx) logging.WithLevel( logging.WithExtras(ctx, "hardware_uuid", hostInfo.HardwareUUID, "hardware_serial", hostInfo.HardwareSerial, "hostname", hostInfo.Hostname, "platform", hostInfo.Platform, "platform_like", hostInfo.PlatformLike, "osquery_identifier", hostInfo.OsqueryIdentifier, "computer_name", hostInfo.ComputerName, "hardware_model", hostInfo.HardwareModel, ), slog.LevelInfo, ) secret, err := svc.ds.VerifyEnrollSecret(ctx, enrollSecret) if err != nil { if fleet.IsNotFound(err) { // OK - This can happen if the following sequence of events take place: // 1. User deletes global/team enroll secret. // 2. User deletes the host in Fleet. // 3. Orbit tries to re-enroll using old secret. return "", fleet.NewAuthFailedError("invalid secret") } return "", fleet.OrbitError{Message: err.Error()} } identifier := hostInfo.OsqueryIdentifier if identifier == "" { identifier = hostInfo.HardwareUUID } identityCert, err := svc.ds.GetHostIdentityCertByName(ctx, identifier) if err != nil && !fleet.IsNotFound(err) { return "", fleet.OrbitError{Message: fmt.Sprintf("loading certificate: %s", err.Error())} } // If an identity certificate exists for this host, make sure the request had an HTTP message signature with the matching certificate. hostIdentityCert, httpSigPresent := httpsig.FromContext(ctx) if identityCert != nil { if !httpSigPresent { return "", fleet.NewAuthFailedError("authentication error: missing HTTP signature") } if identityCert.SerialNumber != hostIdentityCert.SerialNumber { return "", fleet.NewAuthFailedError("authentication error: certificate serial number mismatch") } } else if httpSigPresent { // but we couldn't find the cert in DB return "", fleet.NewAuthFailedError("authentication error: certificate matching HTTP message signature not found") } orbitNodeKey, err := server.GenerateRandomText(svc.config.Osquery.NodeKeySize) if err != nil { return "", fleet.OrbitError{Message: "failed to generate orbit node key: " + err.Error()} } appConfig, err := svc.ds.AppConfig(ctx) if err != nil { return "", fleet.OrbitError{Message: "app config load failed: " + err.Error()} } isEndUserAuthRequired := appConfig.MDM.MacOSSetup.EnableEndUserAuthentication // If the secret is for a team, get the team config as well. if secret.TeamID != nil { team, err := svc.ds.TeamLite(ctx, *secret.TeamID) if err != nil { return "", fleet.OrbitError{Message: "failed to get team config: " + err.Error()} } isEndUserAuthRequired = team.Config.MDM.MacOSSetup.EnableEndUserAuthentication } var euaDeviceID, euaUPN string if isEndUserAuthRequired { if hostInfo.HardwareUUID == "" { return "", fleet.OrbitError{Message: "failed to get IdP account: hardware uuid is empty"} } // Try to find an IdP account for this host. idpAccount, err := svc.ds.GetMDMIdPAccountByHostUUID(ctx, hostInfo.HardwareUUID) if err != nil { return "", fleet.OrbitError{Message: "failed to get IdP account: " + err.Error()} } if idpAccount == nil { // Get the host platform. h := fleet.Host{ Platform: hostInfo.Platform, PlatformLike: hostInfo.PlatformLike, } platform := h.FleetPlatform() // Orbit enrollment is only gated by end user auth for Linux and Windows hosts. // For macOS hosts the MDM enrollment process handles end user auth. if platform == "linux" || platform == "windows" { // If the Orbit client doesn't support end user auth, complain loudly and let the host enroll. mp, ok := capabilities.FromContext(ctx) switch { case !ok: svc.logger.ErrorContext(ctx, "allowing unauthenticated enrollment: could not determine orbit end-user auth capability", "host_uuid", hostInfo.HardwareUUID) case !mp.Has(fleet.CapabilityEndUserAuth): svc.logger.WarnContext(ctx, "allowing unauthenticated enrollment: orbit version does not support end-user authentication", "host_uuid", hostInfo.HardwareUUID) case platform == "windows" && euaToken != "": // A Windows host already authenticated during MDM enrollment and the // EUA token was passed by the MSI installer. upn, deviceID, err := svc.processWindowsEUAToken(ctx, hostInfo.HardwareUUID, euaToken) if err != nil { return "", err } euaUPN = upn euaDeviceID = deviceID // Continue enrollment — do not return END_USER_AUTH_REQUIRED. default: // Otherwise report the unauthenticated host and let Orbit handle it (e.g. by prompting the user to authenticate). return "", fleet.NewOrbitIDPAuthRequiredError() } } } } var stickyEnrollment *string if svc.keyValueStore != nil { // Check for sticky MDM enrollment flag. When set (e.g., after a host transfer), // this prevents enrollment-based team changes for a time window to avoid race conditions // with MDM profile delivery. stickyEnrollment, err = svc.keyValueStore.Get(ctx, fleet.StickyMDMEnrollmentKeyPrefix+hostInfo.HardwareUUID) if err != nil { // Log error but continue enrollment (fail-open approach). If Redis is unavailable, // enrollment proceeds without sticky behavior rather than blocking. svc.logger.ErrorContext(ctx, "failed to get sticky enrollment", "err", err, "host_uuid", hostInfo.HardwareUUID) } } host, err := svc.ds.EnrollOrbit(ctx, fleet.WithEnrollOrbitMDMEnabled(appConfig.MDM.EnabledAndConfigured), fleet.WithEnrollOrbitHostInfo(hostInfo), fleet.WithEnrollOrbitNodeKey(orbitNodeKey), fleet.WithEnrollOrbitTeamID(secret.TeamID), fleet.WithEnrollOrbitIdentityCert(identityCert), fleet.WithEnrollOrbitIgnoreTeamUpdate(stickyEnrollment != nil), ) if err != nil { return "", fleet.OrbitError{Message: "failed to enroll " + err.Error()} } if euaDeviceID != "" { updated, err := svc.ds.UpdateMDMWindowsEnrollmentsHostUUID(ctx, host.UUID, euaDeviceID) if err != nil { svc.logger.ErrorContext(ctx, "failed to link windows mdm enrollment to orbit host via EUA token", "err", err, "host_uuid", host.UUID, "device_id", euaDeviceID) } if updated { scimUser, err := svc.ds.ScimUserByUserNameOrEmail(ctx, euaUPN, euaUPN) //nolint:gocritic // ignore ifElseChain if err != nil && !fleet.IsNotFound(err) && err != sql.ErrNoRows { svc.logger.ErrorContext(ctx, "failed to find SCIM user for EUA token enrollment", "err", err, "host_id", host.ID) } else if err == nil && scimUser != nil { if err := svc.ds.SetOrUpdateHostSCIMUserMapping(ctx, host.ID, scimUser.ID); err != nil { svc.logger.ErrorContext(ctx, "failed to set SCIM user mapping for EUA token enrollment", "err", err, "host_id", host.ID) } } else { if err := svc.ds.DeleteHostSCIMUserMapping(ctx, host.ID); err != nil && !fleet.IsNotFound(err) { svc.logger.ErrorContext(ctx, "failed to delete SCIM user mapping for EUA token enrollment", "err", err, "host_id", host.ID) } } } } // Associate the newly-enrolled host with a SCIM user if applicable. // Do this only for linux and windows devices, as macOS devices // are associated during MDM enrollment. platform := host.FleetPlatform() if platform == "linux" || platform == "windows" { svc.logger.DebugContext(ctx, "attempting to associate enrolled host with SCIM user", "host_id", host.ID, "platform", platform) if err := svc.ds.MaybeAssociateHostWithScimUser(ctx, host.ID); err != nil { svc.logger.ErrorContext(ctx, "failed to associate enrolled host with SCIM user", "err", err, "host_id", host.ID) } } if err := svc.NewActivity( ctx, nil, fleet.ActivityTypeFleetEnrolled{ HostID: host.ID, HostSerial: hostInfo.HardwareSerial, HostDisplayName: host.DisplayName(), }, ); err != nil { svc.logger.ErrorContext(ctx, "record fleet enroll activity", "err", err) } return orbitNodeKey, nil } func getOrbitConfigEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { cfg, err := svc.GetOrbitConfig(ctx) if err != nil { return fleet.OrbitGetConfigResponse{Err: err}, nil } return fleet.OrbitGetConfigResponse{OrbitConfig: cfg}, nil } func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, error) { // this is not a user-authenticated endpoint svc.authz.SkipAuthorization(ctx) host, ok := hostctx.FromContext(ctx) if !ok { return fleet.OrbitConfig{}, fleet.OrbitError{Message: "internal error: missing host from request context"} } appConfig, err := svc.ds.AppConfig(ctx) if err != nil { return fleet.OrbitConfig{}, err } isConnectedToFleetMDM, err := svc.ds.IsHostConnectedToFleetMDM(ctx, host) if err != nil { return fleet.OrbitConfig{}, ctxerr.Wrap(ctx, err, "checking if host is connected to Fleet") } mdmInfo, err := svc.ds.GetHostMDM(ctx, host.ID) if err != nil && !errors.Is(err, sql.ErrNoRows) { return fleet.OrbitConfig{}, ctxerr.Wrap(ctx, err, "retrieving host mdm info") } // set the host's orbit notifications for macOS MDM var notifs fleet.OrbitConfigNotifications if appConfig.MDM.EnabledAndConfigured && host.IsOsqueryEnrolled() && host.Platform == "darwin" { needsDEPEnrollment := mdmInfo != nil && !mdmInfo.Enrolled && host.IsDEPAssignedToFleet() if needsDEPEnrollment { notifs.RenewEnrollmentProfile = true } manualMigrationEligible, err := fleet.IsEligibleForManualMigration(host, mdmInfo, isConnectedToFleetMDM) if err != nil { return fleet.OrbitConfig{}, ctxerr.Wrap(ctx, err, "checking manual migration eligibility") } if appConfig.MDM.MacOSMigration.Enable && (fleet.IsEligibleForDEPMigration(host, mdmInfo, isConnectedToFleetMDM) || manualMigrationEligible) { notifs.NeedsMDMMigration = true } if isConnectedToFleetMDM { // If there is no software or script configured for setup experience and this is the // first time orbit is calling the /config endpoint, then this host // will not have a row in host_mdm_apple_awaiting_configuration. // On subsequent calls to /config, the host WILL have a row in // host_mdm_apple_awaiting_configuration. inSetupAssistant, err := svc.ds.GetHostAwaitingConfiguration(ctx, host.UUID) if err != nil && !fleet.IsNotFound(err) { return fleet.OrbitConfig{}, ctxerr.Wrap(ctx, err, "checking if host is in setup experience") } if inSetupAssistant { notifs.RunSetupExperience = true } if inSetupAssistant { // If the client is running a fleetd that doesn't support setup // experience, then we should fall back to the "old way" of releasing // the device. mp, ok := capabilities.FromContext(ctx) if !ok || !mp.Has(fleet.CapabilitySetupExperience) { svc.logger.DebugContext(ctx, "host doesn't support setup experience, falling back to worker-based device release", "host_uuid", host.UUID) if err := svc.processReleaseDeviceForOldFleetd(ctx, host); err != nil { return fleet.OrbitConfig{}, err } } } } } // set the host's orbit notifications for Windows MDM if appConfig.MDM.WindowsEnabledAndConfigured && !appConfig.MDM.EnableTurnOnWindowsMDMManually { if isEligibleForWindowsMDMEnrollment(host, mdmInfo) { discoURL, err := microsoft_mdm.ResolveWindowsMDMDiscovery(appConfig.ServerSettings.ServerURL) if err != nil { return fleet.OrbitConfig{}, err } notifs.WindowsMDMDiscoveryEndpoint = discoURL notifs.NeedsProgrammaticWindowsMDMEnrollment = true } else if appConfig.MDM.WindowsMigrationEnabled && isEligibleForWindowsMDMMigration(host, mdmInfo) { notifs.NeedsMDMMigration = true // Set the host to refetch the "critical queries" quickly for some time, // to improve ingestion time of the unenroll and make the host eligible to // enroll into Fleet faster. if host.RefetchCriticalQueriesUntil == nil { refetchUntil := svc.clock.Now().Add(fleet.RefetchMDMUnenrollCriticalQueryDuration) host.RefetchCriticalQueriesUntil = &refetchUntil if err := svc.ds.UpdateHostRefetchCriticalQueriesUntil(ctx, host.ID, &refetchUntil); err != nil { return fleet.OrbitConfig{}, err } } } } if !appConfig.MDM.WindowsEnabledAndConfigured { if host.IsEligibleForWindowsMDMUnenrollment(isConnectedToFleetMDM) { notifs.NeedsProgrammaticWindowsMDMUnenrollment = true } } // load the (active, ready to execute) pending script executions for that host pending, err := svc.ds.ListReadyToExecuteScriptsForHost(ctx, host.ID, appConfig.ServerSettings.ScriptsDisabled) if err != nil { return fleet.OrbitConfig{}, err } if len(pending) > 0 { execIDs := make([]string, 0, len(pending)) for _, p := range pending { execIDs = append(execIDs, p.ExecutionID) } notifs.PendingScriptExecutionIDs = execIDs } notifs.RunDiskEncryptionEscrow = host.IsLUKSSupported() && host.DiskEncryptionEnabled != nil && *host.DiskEncryptionEnabled && svc.ds.IsHostPendingEscrow(ctx, host.ID) // load the (active, ready to execute) pending software install executions for that host pendingInstalls, err := svc.ds.ListReadyToExecuteSoftwareInstalls(ctx, host.ID) if err != nil { return fleet.OrbitConfig{}, err } if len(pendingInstalls) > 0 { notifs.PendingSoftwareInstallerIDs = pendingInstalls } // team ID is not nil, get team specific flags and options if host.TeamID != nil { teamAgentOptions, err := svc.ds.TeamAgentOptions(ctx, *host.TeamID) if err != nil { return fleet.OrbitConfig{}, err } var opts fleet.AgentOptions if teamAgentOptions != nil && len(*teamAgentOptions) > 0 { if err := json.Unmarshal(*teamAgentOptions, &opts); err != nil { return fleet.OrbitConfig{}, err } } // Fall back to the global app config's script execution timeout when the // team has no explicit override. Other agent-options fields are intentionally // not inherited from the global config. if opts.ScriptExecutionTimeout == 0 && appConfig.AgentOptions != nil { var globalOpts fleet.AgentOptions if err := json.Unmarshal(*appConfig.AgentOptions, &globalOpts); err != nil { return fleet.OrbitConfig{}, ctxerr.Wrap(ctx, err, "unmarshal global agent options for script timeout fallback") } opts.ScriptExecutionTimeout = globalOpts.ScriptExecutionTimeout } extensionsFiltered, err := svc.filterExtensionsForHost(ctx, opts.Extensions, host) if err != nil { return fleet.OrbitConfig{}, err } mdmConfig, err := svc.ds.TeamMDMConfig(ctx, *host.TeamID) if err != nil { return fleet.OrbitConfig{}, err } var nudgeConfig *fleet.NudgeConfig if appConfig.MDM.EnabledAndConfigured && mdmConfig != nil && host.IsOsqueryEnrolled() && isConnectedToFleetMDM && mdmConfig.MacOSUpdates.Configured() { hostOS, err := svc.ds.GetHostOperatingSystem(ctx, host.ID) if errors.Is(err, sql.ErrNoRows) { // host os has not been collected yet (no details query) hostOS = &fleet.OperatingSystem{} } else if err != nil { return fleet.OrbitConfig{}, err } requiresNudge, err := hostOS.RequiresNudge() if err != nil { return fleet.OrbitConfig{}, err } if requiresNudge { nudgeConfig, err = fleet.NewNudgeConfig(mdmConfig.MacOSUpdates) if err != nil { return fleet.OrbitConfig{}, err } } } err = svc.setDiskEncryptionNotifications( ctx, ¬ifs, host, appConfig, mdmConfig.EnableDiskEncryption, isConnectedToFleetMDM, mdmInfo, ) if err != nil { return fleet.OrbitConfig{}, ctxerr.Wrap(ctx, err, "setting team disk encryption notifications") } var updateChannels *fleet.OrbitUpdateChannels if len(opts.UpdateChannels) > 0 { var uc fleet.OrbitUpdateChannels if err := json.Unmarshal(opts.UpdateChannels, &uc); err != nil { return fleet.OrbitConfig{}, err } updateChannels = &uc } // only unset this flag once we know there were no errors so this notification will be picked up by the agent if notifs.RunDiskEncryptionEscrow { _ = svc.ds.ClearPendingEscrow(ctx, host.ID) } return fleet.OrbitConfig{ ScriptExeTimeout: opts.ScriptExecutionTimeout, Flags: opts.CommandLineStartUpFlags, Extensions: extensionsFiltered, Notifications: notifs, NudgeConfig: nudgeConfig, UpdateChannels: updateChannels, }, nil } // team ID is nil, get global flags and options var opts fleet.AgentOptions if appConfig.AgentOptions != nil { if err := json.Unmarshal(*appConfig.AgentOptions, &opts); err != nil { return fleet.OrbitConfig{}, err } } extensionsFiltered, err := svc.filterExtensionsForHost(ctx, opts.Extensions, host) if err != nil { return fleet.OrbitConfig{}, err } var nudgeConfig *fleet.NudgeConfig if appConfig.MDM.EnabledAndConfigured && isConnectedToFleetMDM && host.IsOsqueryEnrolled() && appConfig.MDM.MacOSUpdates.Configured() { hostOS, err := svc.ds.GetHostOperatingSystem(ctx, host.ID) if errors.Is(err, sql.ErrNoRows) { // host os has not been collected yet (no details query) hostOS = &fleet.OperatingSystem{} } else if err != nil { return fleet.OrbitConfig{}, err } requiresNudge, err := hostOS.RequiresNudge() if err != nil { return fleet.OrbitConfig{}, err } if requiresNudge { nudgeConfig, err = fleet.NewNudgeConfig(appConfig.MDM.MacOSUpdates) if err != nil { return fleet.OrbitConfig{}, err } } } err = svc.setDiskEncryptionNotifications( ctx, ¬ifs, host, appConfig, appConfig.MDM.EnableDiskEncryption.Value, isConnectedToFleetMDM, mdmInfo, ) if err != nil { return fleet.OrbitConfig{}, ctxerr.Wrap(ctx, err, "setting no-team disk encryption notifications") } var updateChannels *fleet.OrbitUpdateChannels if len(opts.UpdateChannels) > 0 { var uc fleet.OrbitUpdateChannels if err := json.Unmarshal(opts.UpdateChannels, &uc); err != nil { return fleet.OrbitConfig{}, err } updateChannels = &uc } // only unset this flag once we know there were no errors so this notification will be picked up by the agent if notifs.RunDiskEncryptionEscrow { _ = svc.ds.ClearPendingEscrow(ctx, host.ID) } return fleet.OrbitConfig{ ScriptExeTimeout: opts.ScriptExecutionTimeout, Flags: opts.CommandLineStartUpFlags, Extensions: extensionsFiltered, Notifications: notifs, NudgeConfig: nudgeConfig, UpdateChannels: updateChannels, }, nil } func (svc *Service) processReleaseDeviceForOldFleetd(ctx context.Context, host *fleet.Host) error { var manualRelease bool if host.TeamID == nil { ac, err := svc.ds.AppConfig(ctx) if err != nil { return ctxerr.Wrap(ctx, err, "get AppConfig to read apple_enable_release_device_manually") } manualRelease = ac.MDM.MacOSSetup.EnableReleaseDeviceManually.Value } else { tm, err := svc.ds.TeamLite(ctx, *host.TeamID) if err != nil { return ctxerr.Wrap(ctx, err, "get Team to read apple_enable_release_device_manually") } manualRelease = tm.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value } if !manualRelease { // For the commands to await, since we're in an orbit endpoint we know that // fleetd has already been installed, so we only need to check for the // bootstrap package install and the SSO account configuration (both are // optional). bootstrapCmdUUID, err := svc.ds.GetHostBootstrapPackageCommand(ctx, host.UUID) if err != nil && !fleet.IsNotFound(err) { return ctxerr.Wrap(ctx, err, "get bootstrap package command") } // AccountConfiguration covers the (optional) command to setup SSO. adminTeamFilter := fleet.TeamFilter{ User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, } acctCmds, _, _, err := svc.ds.ListMDMCommands(ctx, adminTeamFilter, &fleet.MDMCommandListOptions{ // PerPage 1: only acctCmds[0] is read below. ListOptions: fleet.ListOptions{PerPage: 1}, Filters: fleet.MDMCommandFilters{ HostIdentifier: host.UUID, RequestType: "AccountConfiguration", }, }) if err != nil { return ctxerr.Wrap(ctx, err, "list AccountConfiguration commands") } var acctConfigCmdUUID string if len(acctCmds) > 0 { // there may be more than one if e.g. the worker job that sends them had to // retry, but they would all be processed anyway so we can only care about // the first one. acctConfigCmdUUID = acctCmds[0].CommandUUID } // Enroll reference arg is not used in the release device task, passing empty string. if err := worker.QueueAppleMDMJob(ctx, svc.ds, svc.logger, worker.AppleMDMPostDEPReleaseDeviceTask, host.UUID, host.Platform, host.TeamID, "", false, false, bootstrapCmdUUID, acctConfigCmdUUID); err != nil { return ctxerr.Wrap(ctx, err, "queue Apple Post-DEP release device job") } } // at this point we know for sure that it will get released, but we need to // ensure we won't continually enqueue new worker jobs for that host until it // is released. To do so, we clear up the setup experience data (since anyway // this host will not go through that new flow). if err := svc.ds.SetHostAwaitingConfiguration(ctx, host.UUID, false); err != nil { return ctxerr.Wrap(ctx, err, "unset host awaiting configuration") } return nil } func (svc *Service) setDiskEncryptionNotifications( ctx context.Context, notifs *fleet.OrbitConfigNotifications, host *fleet.Host, appConfig *fleet.AppConfig, diskEncryptionConfigured bool, isConnectedToFleetMDM bool, mdmInfo *fleet.HostMDM, ) error { anyMDMConfigured := appConfig.MDM.EnabledAndConfigured || appConfig.MDM.WindowsEnabledAndConfigured if !anyMDMConfigured || !isConnectedToFleetMDM || !host.IsOsqueryEnrolled() || !diskEncryptionConfigured { return nil } encryptionKey, err := svc.ds.GetHostDiskEncryptionKey(ctx, host.ID) if err != nil { if !fleet.IsNotFound(err) { return ctxerr.Wrap(ctx, err, "fetching host disk encryption key") } } switch host.FleetPlatform() { case "darwin": mp, ok := capabilities.FromContext(ctx) if !ok { svc.logger.DebugContext(ctx, "no capabilities in context, skipping disk encryption notification") return nil } if !mp.Has(fleet.CapabilityEscrowBuddy) { svc.logger.DebugContext(ctx, "host doesn't support Escrow Buddy, skipping disk encryption notification", "host_uuid", host.UUID) return nil } notifs.RotateDiskEncryptionKey = encryptionKey != nil && encryptionKey.Decryptable != nil && !*encryptionKey.Decryptable case "windows": isServer := mdmInfo != nil && mdmInfo.IsServer needsEncryption := host.DiskEncryptionEnabled != nil && !*host.DiskEncryptionEnabled keyWasDecrypted := encryptionKey != nil && encryptionKey.Decryptable != nil && *encryptionKey.Decryptable encryptedWithoutKey := host.DiskEncryptionEnabled != nil && *host.DiskEncryptionEnabled && !keyWasDecrypted notifs.EnforceBitLockerEncryption = !isServer && mdmInfo != nil && (needsEncryption || encryptedWithoutKey) } return nil } // filterExtensionsForHost filters a extensions configuration depending on the host platform and label membership. // // If all extensions are filtered, then it returns (nil, nil) (Orbit expects empty extensions if there // are no extensions for the host.) func (svc *Service) filterExtensionsForHost(ctx context.Context, extensions json.RawMessage, host *fleet.Host) (json.RawMessage, error) { if len(extensions) == 0 { return nil, nil } var extensionsInfo fleet.Extensions if err := json.Unmarshal(extensions, &extensionsInfo); err != nil { return nil, ctxerr.Wrap(ctx, err, "unmarshal extensions config") } // Filter the extensions by platform. extensionsInfo.FilterByHostPlatform(host.Platform, host.CPUType) // Filter the extensions by labels (premium only feature). if license, _ := license.FromContext(ctx); license != nil && license.IsPremium() { for extensionName, extensionInfo := range extensionsInfo { hostIsMemberOfAllLabels, err := svc.ds.HostMemberOfAllLabels(ctx, host.ID, extensionInfo.Labels) if err != nil { return nil, ctxerr.Wrap(ctx, err, "check host labels") } if hostIsMemberOfAllLabels { // Do not filter out, but there's no need to send the label names to the devices. extensionInfo.Labels = nil extensionsInfo[extensionName] = extensionInfo } else { delete(extensionsInfo, extensionName) } } } // Orbit expects empty message if no extensions apply. if len(extensionsInfo) == 0 { return nil, nil } extensionsFiltered, err := json.Marshal(extensionsInfo) if err != nil { return nil, ctxerr.Wrap(ctx, err, "marshal extensions config") } return extensionsFiltered, nil } ///////////////////////////////////////////////////////////////////////////////// // Ping orbit endpoint ///////////////////////////////////////////////////////////////////////////////// // orbitPingResponse wraps the fleet type to add HijackRender. type orbitPingResponse struct { fleet.OrbitPingResponse } func (r orbitPingResponse) HijackRender(ctx context.Context, w http.ResponseWriter) { writeCapabilitiesHeader(w, fleet.GetServerOrbitCapabilities()) } // NOTE: we're intentionally not reading the capabilities header in this // endpoint as is unauthenticated and we don't want to trust whatever comes in // there. func orbitPingEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { svc.DisableAuthForPing(ctx) return orbitPingResponse{}, nil } ///////////////////////////////////////////////////////////////////////////////// // SetOrUpdateDeviceToken endpoint ///////////////////////////////////////////////////////////////////////////////// func setOrUpdateDeviceTokenEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*fleet.SetOrUpdateDeviceTokenRequest) if err := svc.SetOrUpdateDeviceAuthToken(ctx, req.DeviceAuthToken); err != nil { return fleet.SetOrUpdateDeviceTokenResponse{Err: err}, nil } return fleet.SetOrUpdateDeviceTokenResponse{}, nil } func (svc *Service) SetOrUpdateDeviceAuthToken(ctx context.Context, deviceAuthToken string) error { // this is not a user-authenticated endpoint svc.authz.SkipAuthorization(ctx) if len(deviceAuthToken) == 0 { return badRequest("device auth token cannot be empty") } if url.QueryEscape(deviceAuthToken) != deviceAuthToken { return badRequest("device auth token contains invalid characters") } host, ok := hostctx.FromContext(ctx) if !ok { return newOsqueryError("internal error: missing host from request context") } if err := svc.ds.SetOrUpdateDeviceAuthToken(ctx, host.ID, deviceAuthToken); err != nil { if errors.As(err, &fleet.ConflictError{}) { return err } return newOsqueryError(fmt.Sprintf("internal error: failed to set or update device auth token: %s", err)) } return nil } ///////////////////////////////////////////////////////////////////////////////// // Get Orbit pending script execution request ///////////////////////////////////////////////////////////////////////////////// func getOrbitScriptEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*fleet.OrbitGetScriptRequest) script, err := svc.GetHostScript(ctx, req.ExecutionID) if err != nil { return fleet.OrbitGetScriptResponse{Err: err}, nil } return fleet.OrbitGetScriptResponse{HostScriptResult: script}, nil } func (svc *Service) GetHostScript(ctx context.Context, execID string) (*fleet.HostScriptResult, error) { // this is not a user-authenticated endpoint svc.authz.SkipAuthorization(ctx) host, ok := hostctx.FromContext(ctx) if !ok { return nil, fleet.OrbitError{Message: "internal error: missing host from request context"} } // get the script's details script, err := svc.ds.GetHostScriptExecutionResult(ctx, execID) if err != nil { return nil, err } // ensure it cannot get access to a different host's script if script.HostID != host.ID { return nil, ctxerr.Wrap(ctx, newNotFoundError(), "no script found for this host") } // We expose secret variables in the script content to the host. The exposed secrets are only intended to go to the device and not accessible via the UI/API. script.ScriptContents, err = svc.ds.ExpandEmbeddedSecrets(ctx, script.ScriptContents) if err != nil { // This error should never occur because we validate secret variables on script upload. return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("expand embedded secrets for host %d and script %s", host.ID, execID)) } return script, nil } ///////////////////////////////////////////////////////////////////////////////// // Post Orbit script execution result ///////////////////////////////////////////////////////////////////////////////// func postOrbitScriptResultEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*fleet.OrbitPostScriptResultRequest) if err := svc.SaveHostScriptResult(ctx, req.HostScriptResultPayload); err != nil { return fleet.OrbitPostScriptResultResponse{Err: err}, nil } return fleet.OrbitPostScriptResultResponse{}, nil } func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.HostScriptResultPayload) error { // this is not a user-authenticated endpoint svc.authz.SkipAuthorization(ctx) host, ok := hostctx.FromContext(ctx) if !ok { return fleet.OrbitError{Message: "internal error: missing host from request context"} } if result == nil { return ctxerr.Wrap(ctx, &fleet.BadRequestError{Message: "missing script result"}, "save host script result") } // always use the authenticated host's ID as host_id result.HostID = host.ID // Calculate attempt_number for policy automation retries by counting existing attempts attemptNumber, err := svc.getPolicyAutomationScriptAttemptNumber(ctx, host, result.ExecutionID) if err != nil { return err } hsr, action, err := svc.ds.SetHostScriptExecutionResult(ctx, result, attemptNumber) if err != nil { return ctxerr.Wrap(ctx, err, "save host script result") } // FIXME: datastore implementation of action seems rather brittle, can it be refactored? var fromSetupExperience bool if action == "" && fleet.IsSetupExperienceSupported(host.Platform) { // this might be a setup experience script result if updated, err := maybeUpdateSetupExperienceStatus(ctx, svc.ds, fleet.SetupExperienceScriptResult{ HostUUID: host.UUID, ExecutionID: result.ExecutionID, ExitCode: result.ExitCode, }, svc.NewActivity); err != nil { return ctxerr.Wrap(ctx, err, "update setup experience status") } else if updated { svc.logger.DebugContext(ctx, "setup experience script result updated", "host_uuid", host.UUID, "execution_id", result.ExecutionID) fromSetupExperience = true _, err := svc.EnterpriseOverrides.SetupExperienceNextStep(ctx, host) if err != nil { return ctxerr.Wrap(ctx, err, "getting next step for host setup experience") } } } // don't create a "past" activity if the result was for a canceled activity if hsr != nil && !hsr.Canceled { var user *fleet.User if hsr.UserID != nil { user, err = svc.ds.UserByID(ctx, *hsr.UserID) if err != nil { return ctxerr.Wrap(ctx, err, "get host script execution user") } } var scriptName string switch { case hsr.ScriptID != nil: scr, err := svc.ds.Script(ctx, *hsr.ScriptID) if err != nil { return ctxerr.Wrap(ctx, err, "get saved script") } scriptName = scr.Name case hsr.SetupExperienceScriptID != nil: scr, err := svc.ds.GetSetupExperienceScriptByID(ctx, *hsr.SetupExperienceScriptID) if err != nil { return ctxerr.Wrap(ctx, err, "get setup experience script") } scriptName = scr.Name } switch action { case "uninstall": softwareTitleName, selfService, err := svc.ds.GetDetailsForUninstallFromExecutionID(ctx, hsr.ExecutionID) if err != nil { return ctxerr.Wrap(ctx, err, "get software title from execution ID") } activityStatus := "failed" if hsr.ExitCode != nil && *hsr.ExitCode == 0 { activityStatus = "uninstalled" } if err := svc.NewActivity( ctx, user, fleet.ActivityTypeUninstalledSoftware{ HostID: host.ID, HostDisplayName: host.DisplayName(), SoftwareTitle: softwareTitleName, ExecutionID: hsr.ExecutionID, Status: activityStatus, SelfService: selfService, }, ); err != nil { return ctxerr.Wrap(ctx, err, "create activity for script execution request") } // lastly, queue a vitals refetch so we get a proper view of inventory from osquery if activityStatus == "uninstalled" { if err := svc.ds.UpdateHostRefetchRequested(ctx, host.ID, true); err != nil { return ctxerr.Wrap(ctx, err, "queue host vitals refetch") } } case "wipe_ref": // a successful wipe means the host has been erased, so any other // upcoming activities queued behind the wipe will never run - // cancel them silently before falling through to record the // "ran script" activity for the wipe itself. if hsr.ExitCode != nil && *hsr.ExitCode == 0 { if _, err := svc.ds.BatchCancelAllHostUpcomingActivities(ctx, host.ID); err != nil { return ctxerr.Wrap(ctx, err, "cancel upcoming activities after wipe") } } fallthrough default: // TODO(sarah): We may need to special case lock/unlock script results here? var policyName *string shouldCreateActivity := true if hsr.PolicyID != nil { if policy, err := svc.ds.PolicyLite(ctx, *hsr.PolicyID); err == nil { policyName = &policy.Name // fall back to blank policy name if we can't retrieve the policy } // Suppress activity for policy automation retires if hsr.AttemptNumber != nil { scriptFailed := hsr.ExitCode == nil || *hsr.ExitCode != 0 if scriptFailed && *hsr.AttemptNumber < fleet.MaxPolicyAutomationRetries { shouldCreateActivity = false } } } if shouldCreateActivity { if err := svc.NewActivity( ctx, user, fleet.ActivityTypeRanScript{ HostID: host.ID, HostDisplayName: host.DisplayName(), ScriptExecutionID: hsr.ExecutionID, BatchExecutionID: hsr.BatchExecutionID, ScriptName: scriptName, Async: !hsr.SyncRequest, PolicyID: hsr.PolicyID, PolicyName: policyName, FromSetupExperience: fromSetupExperience, }, ); err != nil { return ctxerr.Wrap(ctx, err, "create activity for script execution request") } } } } // If this is a policy automation script that failed, maybe retry if hsr != nil && hsr.PolicyID != nil && hsr.ScriptID != nil { scriptFailed := hsr.ExitCode == nil || *hsr.ExitCode != 0 if scriptFailed { shouldRetry, err := svc.shouldRetryPolicyAutomationScript(ctx, host, hsr) if err != nil { svc.logger.ErrorContext(ctx, "failed to check if policy automation script should retry", "host_id", host.ID, "policy_id", *hsr.PolicyID, "err", err, ) } else if shouldRetry { if err := svc.retryPolicyAutomationScript(ctx, host, hsr); err != nil { svc.logger.ErrorContext(ctx, "failed to queue policy automation script retry", "host_id", host.ID, "policy_id", *hsr.PolicyID, "err", err, ) } } } } return nil } ///////////////////////////////////////////////////////////////////////////////// // Post Orbit device mapping (custom email) ///////////////////////////////////////////////////////////////////////////////// func putOrbitDeviceMappingEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*fleet.OrbitPutDeviceMappingRequest) host, ok := hostctx.FromContext(ctx) if !ok { err := newOsqueryError("internal error: missing host from request context") return fleet.OrbitPutDeviceMappingResponse{Err: err}, nil } _, err := svc.SetHostDeviceMapping(ctx, host.ID, req.Email, fleet.DeviceMappingCustomReplacement) return fleet.OrbitPutDeviceMappingResponse{Err: err}, nil } ///////////////////////////////////////////////////////////////////////////////// // Post Orbit disk encryption key ///////////////////////////////////////////////////////////////////////////////// func postOrbitDiskEncryptionKeyEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*fleet.OrbitPostDiskEncryptionKeyRequest) if err := svc.SetOrUpdateDiskEncryptionKey(ctx, string(req.EncryptionKey), req.ClientError); err != nil { return fleet.OrbitPostDiskEncryptionKeyResponse{Err: err}, nil } return fleet.OrbitPostDiskEncryptionKeyResponse{}, nil } func (svc *Service) SetOrUpdateDiskEncryptionKey(ctx context.Context, encryptionKey, clientError string) error { // this is not a user-authenticated endpoint svc.authz.SkipAuthorization(ctx) host, ok := hostctx.FromContext(ctx) if !ok { return newOsqueryError("internal error: missing host from request context") } connected, err := svc.ds.IsHostConnectedToFleetMDM(ctx, host) if err != nil { return ctxerr.Wrap(ctx, err, "checking if host is connected to Fleet") } if !connected { return badRequest("host is not enrolled with fleet") } // Only archive the key if disk encryption is enabled for this host (team/globally) if !osquery_utils.IsDiskEncryptionEnabledForHost(ctx, svc.logger, svc.ds, host) { svc.logger.DebugContext(ctx, "skipping key archival, disk encryption not enabled for host team/globally", "host_id", host.ID, ) return nil } var ( encryptedEncryptionKey string decryptable *bool ) // only set the encryption key if there was no client error if clientError == "" && encryptionKey != "" { wstepCert, _, _, err := svc.config.MDM.MicrosoftWSTEP() if err != nil { // should never return an error because the WSTEP is first parsed and // cached at the start of the fleet serve process. return ctxerr.Wrap(ctx, err, "get WSTEP certificate") } enc, err := microsoft_mdm.Encrypt(encryptionKey, wstepCert.Leaf) if err != nil { return ctxerr.Wrap(ctx, err, "encrypt the key with WSTEP certificate") } encryptedEncryptionKey = enc decryptable = ptr.Bool(true) } keyArchived, err := svc.ds.SetOrUpdateHostDiskEncryptionKey(ctx, host, encryptedEncryptionKey, clientError, decryptable) if err != nil { return ctxerr.Wrap(ctx, err, "set or update disk encryption key") } // We only want to record the activity if the key was successfully archived. if !keyArchived { return nil } if err := svc.NewActivity( ctx, nil, fleet.ActivityTypeEscrowedDiskEncryptionKey{ HostID: host.ID, HostDisplayName: host.DisplayName(), }, ); err != nil { // OK: this is not critical to the operation of the endpoint svc.logger.ErrorContext(ctx, "record fleet disk encryption key escrowed activity", "err", err, ) ctxerr.Handle(ctx, err) } return nil } ///////////////////////////////////////////////////////////////////////////////// // Post Orbit LUKS (Linux disk encryption) data ///////////////////////////////////////////////////////////////////////////////// func postOrbitLUKSEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*fleet.OrbitPostLUKSRequest) if err := svc.EscrowLUKSData(ctx, req.Passphrase, req.Salt, req.KeySlot, req.ClientError); err != nil { return fleet.OrbitPostLUKSResponse{Err: err}, nil } return fleet.OrbitPostLUKSResponse{}, nil } func (svc *Service) EscrowLUKSData(ctx context.Context, passphrase string, salt string, keySlot *uint, clientError string) error { // this is not a user-authenticated endpoint svc.authz.SkipAuthorization(ctx) host, ok := hostctx.FromContext(ctx) if !ok { return newOsqueryError("internal error: missing host from request context") } if clientError != "" { return svc.ds.ReportEscrowError(ctx, host.ID, clientError) } // Only archive the key if disk encryption is enabled for this host (team/globally) if !osquery_utils.IsDiskEncryptionEnabledForHost(ctx, svc.logger, svc.ds, host) { svc.logger.DebugContext(ctx, "skipping LUKS key archival, disk encryption not enabled for host team/globally", "host_id", host.ID, ) return nil } encryptedPassphrase, encryptedSalt, validatedKeySlot, err := svc.validateAndEncrypt(ctx, passphrase, salt, keySlot) if err != nil { _ = svc.ds.ReportEscrowError(ctx, host.ID, err.Error()) return err } keyArchived, err := svc.ds.SaveLUKSData(ctx, host, encryptedPassphrase, encryptedSalt, validatedKeySlot) if err != nil { return err } // When only want to record a new activity if the current key was archived ... if !keyArchived { return nil } if err := svc.NewActivity( ctx, nil, fleet.ActivityTypeEscrowedDiskEncryptionKey{ HostID: host.ID, HostDisplayName: host.DisplayName(), }, ); err != nil { // OK: this is not critical to the operation of the endpoint svc.logger.ErrorContext(ctx, "record fleet disk encryption key escrowed activity", "err", err, ) ctxerr.Handle(ctx, err) } return nil } func (svc *Service) validateAndEncrypt(ctx context.Context, passphrase string, salt string, keySlot *uint) (encryptedPassphrase string, encryptedSalt string, validatedKeySlot uint, err error) { if passphrase == "" || salt == "" || keySlot == nil { return "", "", 0, badRequest("passphrase, salt, and key_slot must be provided to escrow LUKS data") } if svc.config.Server.PrivateKey == "" { return "", "", 0, newOsqueryError("internal error: missing server private key") } encryptedPassphrase, err = mdm.EncryptAndEncode(passphrase, svc.config.Server.PrivateKey) if err != nil { return "", "", 0, ctxerr.Wrap(ctx, err, "internal error: could not encrypt LUKS data") } encryptedSalt, err = mdm.EncryptAndEncode(salt, svc.config.Server.PrivateKey) if err != nil { return "", "", 0, ctxerr.Wrap(ctx, err, "internal error: could not encrypt LUKS data") } return encryptedPassphrase, encryptedSalt, *keySlot, nil } ///////////////////////////////////////////////////////////////////////////////// // Get Orbit pending software installations ///////////////////////////////////////////////////////////////////////////////// func getOrbitSoftwareInstallDetails(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { req := request.(*fleet.OrbitGetSoftwareInstallRequest) details, err := svc.GetSoftwareInstallDetails(ctx, req.InstallUUID) if err != nil { return fleet.OrbitGetSoftwareInstallResponse{Err: err}, nil } return fleet.OrbitGetSoftwareInstallResponse{SoftwareInstallDetails: details}, nil } func (svc *Service) GetSoftwareInstallDetails(ctx context.Context, installUUID string) (*fleet.SoftwareInstallDetails, error) { // this is not a user-authenticated endpoint svc.authz.SkipAuthorization(ctx) host, ok := hostctx.FromContext(ctx) if !ok { return nil, fleet.OrbitError{Message: "internal error: missing host from request context"} } details, err := svc.ds.GetSoftwareInstallDetails(ctx, installUUID) if err != nil { return nil, err } // ensure it cannot get access to a different host's installers if details.HostID != host.ID { return nil, ctxerr.Wrap(ctx, newNotFoundError(), "no installer found for this host") } return details, nil } // Download Orbit software installer request ///////////////////////////////////////////////////////////////////////////////// func orbitDownloadSoftwareInstallerEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*fleet.OrbitDownloadSoftwareInstallerRequest) downloadRequested := req.Alt == "media" if !downloadRequested { // TODO: confirm error handling return orbitDownloadSoftwareInstallerResponse{Err: &fleet.BadRequestError{Message: "only alt=media is supported"}}, nil } p, err := svc.OrbitDownloadSoftwareInstaller(ctx, req.InstallerID) if err != nil { return orbitDownloadSoftwareInstallerResponse{Err: err}, nil } return orbitDownloadSoftwareInstallerResponse{payload: p}, nil } func (svc *Service) OrbitDownloadSoftwareInstaller(ctx context.Context, installerID uint) (*fleet.DownloadSoftwareInstallerPayload, error) { // skipauth: No authorization check needed due to implementation returning // only license error. svc.authz.SkipAuthorization(ctx) return nil, fleet.ErrMissingLicense } ///////////////////////////////////////////////////////////////////////////////// // Post Orbit software install result ///////////////////////////////////////////////////////////////////////////////// func postOrbitSoftwareInstallResultEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*fleet.OrbitPostSoftwareInstallResultRequest) if err := svc.SaveHostSoftwareInstallResult(ctx, req.HostSoftwareInstallResultPayload); err != nil { return fleet.OrbitPostSoftwareInstallResultResponse{Err: err}, nil } return fleet.OrbitPostSoftwareInstallResultResponse{}, nil } func (svc *Service) SaveHostSoftwareInstallResult(ctx context.Context, result *fleet.HostSoftwareInstallResultPayload) error { // this is not a user-authenticated endpoint svc.authz.SkipAuthorization(ctx) host, ok := hostctx.FromContext(ctx) if !ok { return newOsqueryError("internal error: missing host from request context") } // always use the authenticated host's ID as host_id result.HostID = host.ID // If this is an intermediate failure that will be retried, handle it specially if result.RetriesRemaining > 0 { // Create a record while keeping the original pending _, err := svc.ds.CreateIntermediateInstallFailureRecord(ctx, result) if err != nil { return ctxerr.Wrap(ctx, err, "save intermediate install failure") } // Don't create activities for intermediate failures during setup experience. // Only the final result (RetriesRemaining == 0) should create an activity. // This prevents multiple activity items from appearing when a package fails // during setup experience with retries enabled. // See https://github.com/fleetdm/fleet/issues/34818 // Don't update setup experience status for intermediate failures return nil } // Calculate attempt_number for retries by counting existing attempts attemptNumber, err := svc.getSoftwareInstallerAttemptNumber(ctx, host, result.InstallUUID) if err != nil { return err } // Check if a non-policy install failure will be retried so we can skip // updating setup experience status during intermediate retries. willRetryNonPolicyOnFailure := false if attemptNumber != nil && *attemptNumber < fleet.MaxSoftwareInstallAttempts && result.Status() == fleet.SoftwareInstallFailed { currentInstall, checkErr := svc.ds.GetSoftwareInstallResults(ctx, result.InstallUUID) if checkErr == nil && currentInstall != nil && currentInstall.PolicyID == nil { willRetryNonPolicyOnFailure = true } } var fromSetupExperience bool if fleet.IsSetupExperienceSupported(host.Platform) && !willRetryNonPolicyOnFailure { // This might be a setup experience software install result, so we attempt to update the // "Setup experience" status for that item. hostUUID, err := fleet.HostUUIDForSetupExperience(host) if err != nil { return ctxerr.Wrap(ctx, err, "failed to get host's UUID for the setup experience") } if updated, err := maybeUpdateSetupExperienceStatus(ctx, svc.ds, fleet.SetupExperienceSoftwareInstallResult{ HostUUID: hostUUID, ExecutionID: result.InstallUUID, InstallerStatus: result.Status(), }, svc.NewActivity); err != nil { return ctxerr.Wrap(ctx, err, "update setup experience status") } else if updated { svc.logger.DebugContext(ctx, "setup experience software install result updated", "host_uuid", hostUUID, "execution_id", result.InstallUUID, ) fromSetupExperience = true // We need to trigger the next step to properly support setup experience on Linux. // On Linux, users can skip the setup experience by closing the "My device" page. if _, err := svc.EnterpriseOverrides.SetupExperienceNextStep(ctx, host); err != nil { return ctxerr.Wrap(ctx, err, "getting next step for host setup experience") } } } installWasCanceled, err := svc.ds.SetHostSoftwareInstallResult(ctx, result, attemptNumber) if err != nil { return ctxerr.Wrap(ctx, err, "save host software installation result") } // do not create a "past" activity if the status is not terminal or if the activity // was canceled. if status := result.Status(); status != fleet.SoftwareInstallPending && !installWasCanceled { // Force read from primary to avoid replication lag issues where attempt_number might be NULL ctx = ctxdb.RequirePrimary(ctx, true) hsi, err := svc.ds.GetSoftwareInstallResults(ctx, result.InstallUUID) if err != nil { return ctxerr.Wrap(ctx, err, "get host software installation result information") } // Self-Service installs, and installs made by automations, will have a nil author for the activity. var user *fleet.User if !hsi.SelfService && hsi.UserID != nil { user, err = svc.ds.UserByID(ctx, *hsi.UserID) if err != nil { return ctxerr.Wrap(ctx, err, "get host software installation user") } } var policyName *string shouldCreateActivity := true if hsi.PolicyID != nil { if policy, err := svc.ds.PolicyLite(ctx, *hsi.PolicyID); err == nil && policy != nil { policyName = &policy.Name // fall back to blank policy name if we can't retrieve the policy } if status == fleet.SoftwareInstallFailed { shouldRetry, err := svc.shouldRetryPolicyAutomationSoftwareInstall(ctx, host, hsi) if err != nil { svc.logger.ErrorContext(ctx, "failed to check if policy automation software install should retry", "host_id", host.ID, "policy_id", *hsi.PolicyID, "err", err, ) } else if shouldRetry { if err := svc.retryPolicyAutomationSoftwareInstall(ctx, host, hsi); err != nil { svc.logger.ErrorContext(ctx, "failed to queue policy automation software install retry", "host_id", host.ID, "policy_id", *hsi.PolicyID, "err", err, ) } } // Only create activity on final if hsi.AttemptNumber != nil { if *hsi.AttemptNumber < fleet.MaxPolicyAutomationRetries { shouldCreateActivity = false } } } } // Non-policy install retry (host details, self-service, setup experience). // Errors here are logged but do not abort the handler. The primary // action, recording the install result from the device, must succeed // regardless of whether a retry can be scheduled. If retry scheduling // fails, the install is marked as failed (no retry) and the admin can // manually re-trigger. if hsi.PolicyID == nil && status == fleet.SoftwareInstallFailed { shouldRetry, retryErr := svc.shouldRetrySoftwareInstall(ctx, hsi) if retryErr != nil { svc.logger.ErrorContext(ctx, "failed to check if software install should retry", "host_id", host.ID, "install_uuid", result.InstallUUID, "err", retryErr, ) } else if shouldRetry { if retryErr := svc.retrySoftwareInstall(ctx, host, hsi, fromSetupExperience); retryErr != nil { svc.logger.ErrorContext(ctx, "failed to queue software install retry", "host_id", host.ID, "install_uuid", result.InstallUUID, "err", retryErr, ) } } } if shouldCreateActivity { if err := svc.NewActivity( ctx, user, fleet.ActivityTypeInstalledSoftware{ HostID: host.ID, HostDisplayName: host.DisplayName(), SoftwareTitle: hsi.SoftwareTitle, SoftwarePackage: hsi.SoftwarePackage, InstallUUID: result.InstallUUID, Status: string(status), Source: hsi.Source, SelfService: hsi.SelfService, PolicyID: hsi.PolicyID, PolicyName: policyName, FromSetupExperience: fromSetupExperience, }, ); err != nil { return ctxerr.Wrap(ctx, err, "create activity for software installation") } } // lastly, queue a vitals refetch so we get a proper view of inventory from osquery if status == fleet.SoftwareInstalled { if err := svc.ds.UpdateHostRefetchRequested(ctx, host.ID, true); err != nil { return ctxerr.Wrap(ctx, err, "queue host vitals refetch") } } } return nil } // shouldRetryPolicyAutomationSoftwareInstall checks if a failed policy automation software install should be retried. // Returns true if retry should be queued func (svc *Service) shouldRetryPolicyAutomationSoftwareInstall(ctx context.Context, host *fleet.Host, hsi *fleet.HostSoftwareInstallerResult) (bool, error) { if hsi.AttemptNumber == nil { // should not happen return false, ctxerr.New(ctx, "attempt_number is nil for policy automation install") } currentAttempt := *hsi.AttemptNumber if currentAttempt >= fleet.MaxPolicyAutomationRetries { return false, nil } // Check if policy is failing for this host policyFailing, err := svc.ds.IsPolicyFailing(ctx, *hsi.PolicyID, host.ID) if err != nil { return false, ctxerr.Wrap(ctx, err, "check if policy is failing") } return policyFailing, nil } // retryPolicyAutomationSoftwareInstall queues a retry for a policy automation software install. func (svc *Service) retryPolicyAutomationSoftwareInstall(ctx context.Context, host *fleet.Host, hsi *fleet.HostSoftwareInstallerResult) error { svc.logger.InfoContext(ctx, "queuing policy automation software install retry", "host_id", host.ID, "policy_id", *hsi.PolicyID, "software_installer_id", *hsi.SoftwareInstallerID, "current_attempt", *hsi.AttemptNumber, ) _, err := svc.ds.InsertSoftwareInstallRequest(ctx, host.ID, *hsi.SoftwareInstallerID, fleet.HostSoftwareInstallOptions{ PolicyID: hsi.PolicyID, }) return err } // shouldRetrySoftwareInstall checks if a failed non-policy software install should be retried. func (svc *Service) shouldRetrySoftwareInstall(ctx context.Context, hsi *fleet.HostSoftwareInstallerResult) (bool, error) { if hsi.AttemptNumber == nil { return false, nil } return *hsi.AttemptNumber < fleet.MaxSoftwareInstallAttempts, nil } // retrySoftwareInstall queues a retry for a non-policy software install. func (svc *Service) retrySoftwareInstall(ctx context.Context, host *fleet.Host, hsi *fleet.HostSoftwareInstallerResult, fromSetupExperience bool) error { svc.logger.InfoContext(ctx, "queuing software install retry", "host_id", host.ID, "software_installer_id", *hsi.SoftwareInstallerID, "self_service", hsi.SelfService, "current_attempt", *hsi.AttemptNumber, ) _, err := svc.ds.InsertSoftwareInstallRequest(ctx, host.ID, *hsi.SoftwareInstallerID, fleet.HostSoftwareInstallOptions{ SelfService: hsi.SelfService, UserID: hsi.UserID, ForSetupExperience: fromSetupExperience, WithRetries: true, }) return err } // shouldRetryPolicyAutomationScript checks if a failed policy automation script should be retried. // Returns true if retry should be queued func (svc *Service) shouldRetryPolicyAutomationScript(ctx context.Context, host *fleet.Host, hsr *fleet.HostScriptResult) (bool, error) { if hsr.AttemptNumber == nil { // should not happen return false, ctxerr.New(ctx, "attempt_number is nil for policy automation script") } currentAttempt := *hsr.AttemptNumber if currentAttempt >= fleet.MaxPolicyAutomationRetries { return false, nil } // Check if policy is failing for this host policyFailing, err := svc.ds.IsPolicyFailing(ctx, *hsr.PolicyID, host.ID) if err != nil { return false, ctxerr.Wrap(ctx, err, "check if policy is failing") } return policyFailing, nil } // retryPolicyAutomationScript queues a retry for a policy automation script. func (svc *Service) retryPolicyAutomationScript(ctx context.Context, host *fleet.Host, hsr *fleet.HostScriptResult) error { svc.logger.InfoContext(ctx, "queuing policy automation script retry", "host_id", host.ID, "policy_id", *hsr.PolicyID, "script_id", *hsr.ScriptID, "current_attempt", *hsr.AttemptNumber, ) _, err := svc.ds.NewHostScriptExecutionRequest(ctx, &fleet.HostScriptRequestPayload{ HostID: host.ID, ScriptID: hsr.ScriptID, PolicyID: hsr.PolicyID, ScriptContents: hsr.ScriptContents, }) return err } // getPolicyAutomationScriptAttemptNumber calculates the attempt number for a policy automation script. // Returns nil for manual script runs (not triggered by policy automation). func (svc *Service) getPolicyAutomationScriptAttemptNumber(ctx context.Context, host *fleet.Host, executionID string) (*int, error) { // First, check if this script execution already exists and has policy_id // (to know if this is a policy automation) existingResult, err := svc.ds.GetHostScriptExecutionResult(ctx, executionID) if err != nil && !fleet.IsNotFound(err) { return nil, ctxerr.Wrap(ctx, err, "get existing script result for attempt number calculation") } // Only calculate attempt_number for policy automation scripts if existingResult != nil && existingResult.PolicyID != nil && existingResult.ScriptID != nil { count, err := svc.ds.CountHostScriptAttempts(ctx, host.ID, *existingResult.ScriptID, *existingResult.PolicyID) if err != nil { return nil, ctxerr.Wrap(ctx, err, "count previous script attempts") } return &count, nil // count already includes current row since it was created when queued } return nil, nil // nil for manual runs } // getSoftwareInstallerAttemptNumber calculates the attempt number for a software install. // Returns nil for installs that don't have a software_installer_id. func (svc *Service) getSoftwareInstallerAttemptNumber(ctx context.Context, host *fleet.Host, installUUID string) (*int, error) { currentInstall, err := svc.ds.GetSoftwareInstallResults(ctx, installUUID) if err != nil && !fleet.IsNotFound(err) { return nil, ctxerr.Wrap(ctx, err, "get current install info for attempt number calculation") } if currentInstall == nil || currentInstall.SoftwareInstallerID == nil { return nil, nil } // Policy automation installs if currentInstall.PolicyID != nil { count, err := svc.ds.CountHostSoftwareInstallAttempts(ctx, host.ID, *currentInstall.SoftwareInstallerID, *currentInstall.PolicyID) if err != nil { return nil, ctxerr.Wrap(ctx, err, "count previous policy install attempts") } return &count, nil } // Non-policy installs (host details, self-service, setup experience): // attempt_number is set at activation time for retry-eligible installs // (those created with WithRetries=true). If nil, this install was not // created with retry support. return currentInstall.AttemptNumber, nil } ///////////////////////////////////////////////////////////////////////////////// // Get Orbit setup experience status ///////////////////////////////////////////////////////////////////////////////// func getOrbitSetupExperienceStatusEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { req := request.(*fleet.GetOrbitSetupExperienceStatusRequest) results, err := svc.GetOrbitSetupExperienceStatus(ctx, req.OrbitNodeKey, req.ForceRelease, req.ResetFailedSetupSteps) if err != nil { return &fleet.GetOrbitSetupExperienceStatusResponse{Err: err}, nil } return &fleet.GetOrbitSetupExperienceStatusResponse{Results: results}, nil } func (svc *Service) GetOrbitSetupExperienceStatus(ctx context.Context, orbitNodeKey string, forceRelease bool, resetFailedSetupSteps bool) (*fleet.SetupExperienceStatusPayload, error) { // skipauth: No authorization check needed due to implementation returning // only license error. svc.authz.SkipAuthorization(ctx) return nil, fleet.ErrMissingLicense } ///////////////////////////////////////////////////////////////////////////////// // Setup experience init ///////////////////////////////////////////////////////////////////////////////// func orbitSetupExperienceInitEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { _, ok := request.(*fleet.OrbitSetupExperienceInitRequest) if !ok { return nil, fmt.Errorf("internal error: invalid request type: %T", request) } result, err := svc.SetupExperienceInit(ctx) if err != nil { return fleet.OrbitSetupExperienceInitResponse{Err: err}, nil } return fleet.OrbitSetupExperienceInitResponse{ Result: *result, }, nil } func (svc *Service) SetupExperienceInit(ctx context.Context) (*fleet.SetupExperienceInitResult, error) { // skipauth: No authorization check needed due to implementation returning // only license error. svc.authz.SkipAuthorization(ctx) return nil, fleet.ErrMissingLicense }