mirror of
https://github.com/fleetdm/fleet
synced 2026-05-21 07:58:31 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #33173 # Details This PR updates the "Setting up your device" page which appears in Linux and Windows (and as of https://github.com/fleetdm/fleet/issues/30117, MacOS) setup experiences. Front-end updates: * Lots of renaming of things that were software-specific to now more generically refer to "setup step" * Removed the "My Device" heading * Moved the info button inside the table header * Added status of setup script run to the table * Updated the empty state to not refer specifically to software * Added optional `setup_only` query param to the `/device` page which, if set, will always show the "setting up your device" page even if all setup is complete. Normally as soon as setup finishes, the front-end redirects to the regular My Device page. In the case of MacOS setup experience, we don't want this to happen as we expect to either 1) keep the setup experience up indefinitely if we're blocking device setup on software install failure, or 2) close the setup dialog on successful completion. This query param is also handy for testing. * Added new "Configuration complete" state to be shown when all setup steps are finished (successfully or not). This is only applicable on MacOS, since other platforms will redirect to the My Device page when finished. This PR also includes one small backend change to the `/device/{token}/setup_experience/status` API endpoint, to have it return a `scripts` array alongside the existing `software` array. This endpoint is not documented publicly. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. ## Testing - [X] Added/updated automated tests Updated existing DeviceUserPage tests that check the SettingUpYourDevice content, and added new tests for the new scripts content and the new query param. - [X] QA'd all new/changed functionality manually <img width="1028" height="867" alt="Screenshot 2025-10-02 at 7 20 28 PM" src="https://github.com/user-attachments/assets/7adab2c2-dac1-4463-96fc-13094da2c379" /> (note that as of now we'd only have at most one script, showing multiple here to demonstrate the different states) <img width="1031" height="524" alt="Screenshot 2025-10-02 at 7 22 01 PM" src="https://github.com/user-attachments/assets/bedaa840-d7ef-4b6f-8daf-6ac3b447594f" /> <img width="1222" height="760" alt="image" src="https://github.com/user-attachments/assets/42cf82d5-53e0-4c4d-b60e-9ac2cc86af68" /> --------- Co-authored-by: Ian Littman <iansltx@gmail.com>
308 lines
10 KiB
Go
308 lines
10 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"io"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/go-kit/log/level"
|
|
)
|
|
|
|
func (svc *Service) ListDevicePolicies(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) {
|
|
return svc.ds.ListPoliciesForHost(ctx, host)
|
|
}
|
|
|
|
// TriggerMigrateMDMDevice triggers the webhook associated with the MDM
|
|
// migration to Fleet configuration. It is located in the ee package instead of
|
|
// the server/webhooks one because it is a Fleet Premium only feature and for
|
|
// licensing reasons this needs to live under this package.
|
|
func (svc *Service) TriggerMigrateMDMDevice(ctx context.Context, host *fleet.Host) error {
|
|
level.Debug(svc.logger).Log("msg", "trigger migration webhook", "host_id", host.ID,
|
|
"refetch_critical_queries_until", host.RefetchCriticalQueriesUntil)
|
|
|
|
ac, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ac.MDM.EnabledAndConfigured {
|
|
return fleet.ErrMDMNotConfigured
|
|
}
|
|
|
|
if host.RefetchCriticalQueriesUntil != nil && host.RefetchCriticalQueriesUntil.After(svc.clock.Now()) {
|
|
// the webhook has already been triggered successfully recently (within the
|
|
// refetch critical queries delay), so return as if it did send it successfully
|
|
// but do not re-send.
|
|
level.Debug(svc.logger).Log("msg", "waiting for critical queries refetch, skip sending webhook",
|
|
"host_id", host.ID)
|
|
return nil
|
|
}
|
|
|
|
connected, err := svc.ds.IsHostConnectedToFleetMDM(ctx, host)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "checking if host is connected to Fleet")
|
|
}
|
|
|
|
var bre fleet.BadRequestError
|
|
switch {
|
|
case !ac.MDM.MacOSMigration.Enable:
|
|
bre.InternalErr = ctxerr.New(ctx, "macOS migration not enabled")
|
|
case ac.MDM.MacOSMigration.WebhookURL == "":
|
|
bre.InternalErr = ctxerr.New(ctx, "macOS migration webhook URL not configured")
|
|
}
|
|
|
|
mdmInfo, err := svc.ds.GetHostMDM(ctx, host.ID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "fetching host mdm info")
|
|
}
|
|
|
|
manualMigrationEligible, err := fleet.IsEligibleForManualMigration(host, mdmInfo, connected)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "checking manual migration eligibility")
|
|
}
|
|
|
|
if !fleet.IsEligibleForDEPMigration(host, mdmInfo, connected) && !manualMigrationEligible {
|
|
bre.InternalErr = ctxerr.New(ctx, "host not eligible for macOS migration")
|
|
}
|
|
|
|
if bre.InternalErr != nil {
|
|
return &bre
|
|
}
|
|
|
|
p := fleet.MigrateMDMDeviceWebhookPayload{}
|
|
p.Timestamp = time.Now().UTC()
|
|
p.Host.ID = host.ID
|
|
p.Host.UUID = host.UUID
|
|
p.Host.HardwareSerial = host.HardwareSerial
|
|
|
|
if err := server.PostJSONWithTimeout(ctx, ac.MDM.MacOSMigration.WebhookURL, p); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "posting macOS migration webhook")
|
|
}
|
|
|
|
// if the webhook was successfully triggered, we update the host to
|
|
// constantly run the query to check if it has been unenrolled from its
|
|
// existing third-party MDM.
|
|
refetchUntil := svc.clock.Now().Add(fleet.RefetchMDMUnenrollCriticalQueryDuration)
|
|
host.RefetchCriticalQueriesUntil = &refetchUntil
|
|
if err := svc.ds.UpdateHostRefetchCriticalQueriesUntil(ctx, host.ID, &refetchUntil); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "save host with refetch critical queries timestamp")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (svc *Service) GetFleetDesktopSummary(ctx context.Context) (fleet.DesktopSummary, error) {
|
|
// this is not a user-authenticated endpoint
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
var sum fleet.DesktopSummary
|
|
|
|
host, ok := hostctx.FromContext(ctx)
|
|
|
|
if !ok {
|
|
err := ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context"))
|
|
return sum, err
|
|
}
|
|
|
|
hasSelfService, err := svc.ds.HasSelfServiceSoftwareInstallers(ctx, host.Platform, host.TeamID)
|
|
if err != nil {
|
|
return sum, ctxerr.Wrap(ctx, err, "retrieving self service software installers")
|
|
}
|
|
sum.SelfService = &hasSelfService
|
|
|
|
r, err := svc.ds.FailingPoliciesCount(ctx, host)
|
|
if err != nil {
|
|
return sum, ctxerr.Wrap(ctx, err, "retrieving failing policies")
|
|
}
|
|
sum.FailingPolicies = &r
|
|
|
|
appCfg, err := svc.AppConfigObfuscated(ctx)
|
|
if err != nil {
|
|
return sum, ctxerr.Wrap(ctx, err, "retrieving app config")
|
|
}
|
|
|
|
if appCfg.MDM.EnabledAndConfigured && appCfg.MDM.MacOSMigration.Enable {
|
|
connected, err := svc.ds.IsHostConnectedToFleetMDM(ctx, host)
|
|
if err != nil {
|
|
return sum, 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 sum, ctxerr.Wrap(ctx, err, "could not retrieve mdm info")
|
|
}
|
|
|
|
needsDEPEnrollment := mdmInfo != nil && !mdmInfo.Enrolled && host.IsDEPAssignedToFleet()
|
|
|
|
if needsDEPEnrollment {
|
|
sum.Notifications.RenewEnrollmentProfile = true
|
|
}
|
|
|
|
manualMigrationEligible, err := fleet.IsEligibleForManualMigration(host, mdmInfo, connected)
|
|
if err != nil {
|
|
return sum, ctxerr.Wrap(ctx, err, "checking manual migration eligibility")
|
|
}
|
|
|
|
if fleet.IsEligibleForDEPMigration(host, mdmInfo, connected) || manualMigrationEligible {
|
|
sum.Notifications.NeedsMDMMigration = true
|
|
}
|
|
|
|
}
|
|
|
|
// organization information
|
|
sum.Config.OrgInfo.OrgName = appCfg.OrgInfo.OrgName
|
|
sum.Config.OrgInfo.OrgLogoURL = appCfg.OrgInfo.OrgLogoURL
|
|
sum.Config.OrgInfo.OrgLogoURLLightBackground = appCfg.OrgInfo.OrgLogoURLLightBackground
|
|
sum.Config.OrgInfo.ContactURL = appCfg.OrgInfo.ContactURL
|
|
|
|
// mdm information
|
|
sum.Config.MDM.MacOSMigration.Mode = appCfg.MDM.MacOSMigration.Mode
|
|
|
|
return sum, nil
|
|
}
|
|
|
|
func (svc *Service) TriggerLinuxDiskEncryptionEscrow(ctx context.Context, host *fleet.Host) error {
|
|
if svc.ds.IsHostPendingEscrow(ctx, host.ID) {
|
|
return nil
|
|
}
|
|
|
|
if err := svc.ds.AssertHasNoEncryptionKeyStored(ctx, host.ID); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := svc.validateReadyForLinuxEscrow(ctx, host); err != nil {
|
|
_ = svc.ds.ReportEscrowError(ctx, host.ID, err.Error())
|
|
return err
|
|
}
|
|
|
|
return svc.ds.QueueEscrow(ctx, host.ID)
|
|
}
|
|
|
|
func (svc *Service) validateReadyForLinuxEscrow(ctx context.Context, host *fleet.Host) error {
|
|
if !host.IsLUKSSupported() {
|
|
return &fleet.BadRequestError{Message: "Fleet does not yet support creating LUKS disk encryption keys on this platform."}
|
|
}
|
|
|
|
ac, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if host.TeamID == nil {
|
|
if !ac.MDM.EnableDiskEncryption.Value {
|
|
return &fleet.BadRequestError{Message: "Disk encryption is not enabled for hosts not assigned to a team."}
|
|
}
|
|
} else {
|
|
tc, err := svc.ds.TeamMDMConfig(ctx, *host.TeamID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !tc.EnableDiskEncryption {
|
|
return &fleet.BadRequestError{Message: "Disk encryption is not enabled for this host's team."}
|
|
}
|
|
}
|
|
|
|
if host.DiskEncryptionEnabled == nil || !*host.DiskEncryptionEnabled {
|
|
return &fleet.BadRequestError{Message: "Host's disk is not encrypted. Please encrypt your disk first."}
|
|
}
|
|
|
|
// We have to pull Orbit info because the auth context doesn't fill in host.OrbitVersion
|
|
orbitInfo, err := svc.ds.GetHostOrbitInfo(ctx, host.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if orbitInfo == nil || !fleet.IsAtLeastVersion(orbitInfo.Version, fleet.MinOrbitLUKSVersion) {
|
|
return &fleet.BadRequestError{Message: "Your version of fleetd does not support creating disk encryption keys on Linux. Please upgrade fleetd, then click Refetch, then try again."}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (svc *Service) GetDeviceSoftwareIconsTitleIcon(ctx context.Context, teamID uint, titleID uint) ([]byte, int64, string, error) {
|
|
// can't call the already made GetSoftwareTitleIcon(ctx, teamID, titleID) method
|
|
// because svc is the concrete open source service implementation despite it being in the ee/directory
|
|
var err error
|
|
|
|
icon, err := svc.ds.GetSoftwareTitleIcon(ctx, teamID, titleID)
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return nil, 0, "", ctxerr.Wrap(ctx, err, "getting software title icon")
|
|
}
|
|
if icon == nil {
|
|
vppApp, err := svc.ds.GetVPPAppMetadataByTeamAndTitleID(ctx, &teamID, titleID)
|
|
if vppApp != nil && vppApp.IconURL != nil {
|
|
return nil, 0, "", &fleet.VPPIconAvailable{IconURL: *vppApp.IconURL}
|
|
}
|
|
|
|
return nil, 0, "", ctxerr.Wrap(ctx, err, "getting software title icon")
|
|
}
|
|
|
|
iconData, size, err := svc.softwareTitleIconStore.Get(ctx, icon.StorageID)
|
|
if err != nil {
|
|
return nil, 0, "", ctxerr.Wrap(ctx, err, "getting software title icon data")
|
|
}
|
|
defer iconData.Close()
|
|
imageBytes, err := io.ReadAll(iconData)
|
|
if err != nil {
|
|
return nil, 0, "", ctxerr.Wrap(ctx, err, "reading icon data")
|
|
}
|
|
|
|
return imageBytes, size, icon.Filename, nil
|
|
}
|
|
|
|
func (svc *Service) GetDeviceSetupExperienceStatus(ctx context.Context) (*fleet.DeviceSetupExperienceStatusPayload, error) {
|
|
// This is a device endpoint, not a user-authenticated endpoint.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
host, ok := hostctx.FromContext(ctx)
|
|
if !ok {
|
|
return nil, ctxerr.New(ctx, "internal error: missing host from request context")
|
|
}
|
|
|
|
return svc.getHostSetupExperienceStatus(ctx, host)
|
|
}
|
|
|
|
func (svc *Service) getHostSetupExperienceStatus(ctx context.Context, host *fleet.Host) (*fleet.DeviceSetupExperienceStatusPayload, error) {
|
|
hostUUID, err := fleet.HostUUIDForSetupExperience(host)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failed to get host's UUID for the setup experience")
|
|
}
|
|
|
|
// Get current status of the setup experience.
|
|
results, err := svc.ds.ListSetupExperienceResultsByHostUUID(ctx, hostUUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "listing setup experience results")
|
|
}
|
|
|
|
// Mark canceled items as failed.
|
|
err = svc.failCancelledSetupExperienceInstalls(ctx, host.ID, hostUUID, host.DisplayName(), results)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "failing cancelled setup experience installs")
|
|
}
|
|
|
|
var software []*fleet.SetupExperienceStatusResult
|
|
var scripts []*fleet.SetupExperienceStatusResult
|
|
for _, result := range results {
|
|
if result.IsForSoftware() {
|
|
software = append(software, result)
|
|
}
|
|
if result.IsForScript() {
|
|
scripts = append(scripts, result)
|
|
}
|
|
}
|
|
|
|
// Continue with next step in setup experience.
|
|
if _, err = svc.SetupExperienceNextStep(ctx, host); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting next step for host setup experience")
|
|
}
|
|
|
|
return &fleet.DeviceSetupExperienceStatusPayload{
|
|
Software: software,
|
|
Scripts: scripts,
|
|
}, nil
|
|
}
|