From dde5aedbd8d45aff2f4ca62700c2638b76922f62 Mon Sep 17 00:00:00 2001 From: Dante Catalfamo <43040593+dantecatalfamo@users.noreply.github.com> Date: Fri, 5 Sep 2025 10:07:03 -0400 Subject: [PATCH] Linux setup experience agent (#32172) #32053 --- changes/32053-linux-setup-experience-agent | 1 + cmd/fleetctl/fleetctl/package.go | 5 + orbit/cmd/orbit/orbit.go | 133 +++++++++++++++++++++ orbit/pkg/constant/constant.go | 2 + orbit/pkg/packaging/linux_shared.go | 1 + orbit/pkg/packaging/packaging.go | 2 + server/fleet/capabilities.go | 4 + server/service/orbit_client.go | 10 ++ 8 files changed, 158 insertions(+) create mode 100644 changes/32053-linux-setup-experience-agent diff --git a/changes/32053-linux-setup-experience-agent b/changes/32053-linux-setup-experience-agent new file mode 100644 index 0000000000..be3d409854 --- /dev/null +++ b/changes/32053-linux-setup-experience-agent @@ -0,0 +1 @@ +- Added agent support for setup experience on Linux diff --git a/cmd/fleetctl/fleetctl/package.go b/cmd/fleetctl/fleetctl/package.go index ada106d5f3..d0f8c60b01 100644 --- a/cmd/fleetctl/fleetctl/package.go +++ b/cmd/fleetctl/fleetctl/package.go @@ -125,6 +125,11 @@ func packageCommand() *cli.Command { Usage: "Disable auto updates on the generated package", Destination: &opt.DisableUpdates, }, + &cli.BoolFlag{ + Name: "disable-setup-experience", + Usage: "Disable setup experience for Linux hosts", + Destination: &opt.DisableSetupExperience, + }, &cli.StringFlag{ Name: "update-url", Usage: "URL for update server", diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index 608bcafed3..d3c2d095e0 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -241,6 +241,11 @@ func main() { Usage: "Configures fleetd to use TPM-backed key to sign HTTP requests. This functionality is licensed under the Fleet EE License. Usage requires a current Fleet EE subscription.", EnvVars: []string{"ORBIT_FLEET_MANAGED_HOST_IDENTITY_CERTIFICATE"}, }, + &cli.BoolFlag{ + Name: "disable-setup-experience", + Usage: "Disables checking for setup experience on Linux hosts", + EnvVars: []string{"ORBIT_DISABLE_SETUP_EXPERIENCE"}, + }, } app.Before = func(c *cli.Context) error { // handle old installations, which had default root dir set to /var/lib/orbit @@ -1514,6 +1519,60 @@ func main() { go sigusrListener(c.String("root-dir")) + isLinux := runtime.GOOS == "linux" + serverHasWebSetup := orbitClient.GetServerCapabilities().Has(fleet.CapabilityWebSetupExperience) + setupExperienceNotDisabled := !c.Bool("disable-setup-experience") + runSetupExperience := isLinux && serverHasWebSetup && setupExperienceNotDisabled + log.Debug(). + Bool("isLinux", isLinux). + Bool("serverHasSetup", serverHasWebSetup). + Bool("notDisabled", setupExperienceNotDisabled). + Msg("checking setup experience preflight values") + + openMyDevicePage := func() error { + log.Debug().Msg("launching browser for my device page") + token, err := trw.Read() + if err != nil { + return fmt.Errorf("getting device token: %w", err) + } + // My Device page + browserURL := deviceClient.BrowserDeviceURL(token) + + switch runtime.GOOS { + case "linux": + loggedInUser, err := user.UserLoggedInViaGui() + if err != nil { + return fmt.Errorf("get logged in user: %w", err) + } + + if loggedInUser == nil { + return errors.New("no user logged in") + } + + var opts []execuser.Option + opts = append(opts, execuser.WithUser(*loggedInUser)) + opts = append(opts, execuser.WithArg(browserURL, "")) + if _, err := execuser.Run("/usr/bin/xdg-open", opts...); err != nil { + return fmt.Errorf("opening browser with xdg-open: %w", err) + } + default: + log.Debug().Msg("could not open browser, unsupported OS: " + runtime.GOOS) + return errors.New("opening setup experience browser page not supported on " + runtime.GOOS) + } + + return nil + } + + if runSetupExperience { + log.Debug().Msg("web setup experience enabled") + setupExpPath := path.Join(c.String("root-dir"), constant.SetupExperienceFilename) + if err := processSetupExperience(orbitClient, setupExpPath, openMyDevicePage); err != nil { + log.Error().Err(err).Msg("initiating setup experience") + } + } else { + log.Debug().Msg("not running setup experience") + } + if err := g.Run(); err != nil { log.Error().Err(err).Msg("unexpected exit") } @@ -1531,6 +1590,80 @@ func main() { } } +func processSetupExperience(oc *service.OrbitClient, setupExperienceStatusPath string, openMyDevicePage func() error) error { + log.Debug().Msg("checking setup experience file") + exp, err := readSetupExperienceStatusFile(setupExperienceStatusPath) + if err != nil { + return fmt.Errorf("read setup experience file: %w", err) + } + + // Setup experience has been completed + if exp != nil && exp.TimeInitiated != nil { + log.Debug().Msg("setup experience already completed") + return nil + } + + log.Debug().Msg("initiating setup experience") + resp, err := oc.InitiateSetupExperience() + if err != nil { + return fmt.Errorf("initializing server-side setup experience: %w", err) + } + + // Setup experience enabled for us and is now kicked off, open a browser + if resp.Enabled { + if err := openMyDevicePage(); err != nil { + return fmt.Errorf("opening my device page: %w", err) + } + } else { + log.Debug().Msg("setup experience not enabled on team") + } + + // Even if it wasn't enabled, mark it as complete so we don't start it again later + log.Debug().Msg("writing setup experience file") + initTime := time.Now() + if err := writeSetupExperienceStatusFile(setupExperienceStatusPath, &SetupExperienceInfo{ + TimeInitiated: &initTime, + }); err != nil { + return fmt.Errorf("writing setup experience file: %w", err) + } + + return nil +} + +type SetupExperienceInfo struct { + TimeInitiated *time.Time `json:"time_initiated,omitempty"` +} + +// Returns the time setup experience was completed, or nil if it hasn't +func readSetupExperienceStatusFile(experienceCompletedPath string) (*SetupExperienceInfo, error) { + f, err := os.Open(experienceCompletedPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read setup experience file: %w", err) + } + var exp *SetupExperienceInfo + if err := json.NewDecoder(f).Decode(&exp); err != nil { + return nil, fmt.Errorf("decoding setup experience file: %w", err) + } + + return exp, nil +} + +func writeSetupExperienceStatusFile(experienceCompletedPath string, exp *SetupExperienceInfo) error { + f, err := os.Create(experienceCompletedPath) + if err != nil { + return fmt.Errorf("create setup experience completed file: %w", err) + } + + if err := json.NewEncoder(f).Encode(exp); err != nil { + return fmt.Errorf("write setup experience completed file: %w", err) + } + + return nil +} + func deleteSecretPathIfExists(enrollSecretPath string) { // Since the secret is in the keystore, we can delete the original secret file if it exists if _, err := os.Stat(enrollSecretPath); err == nil { diff --git a/orbit/pkg/constant/constant.go b/orbit/pkg/constant/constant.go index 89f05c6269..4ce7cc39ab 100644 --- a/orbit/pkg/constant/constant.go +++ b/orbit/pkg/constant/constant.go @@ -75,6 +75,8 @@ const ( DesktopTUFTargetName = "desktop" // FleetURLFileName is the file where Fleet URL is stored after being read from Apple config profile. FleetURLFileName = "fleet_url.txt" + // SetupExperienceComplete is a file created when Linux (and soon Windows) completes setup experience + SetupExperienceFilename = "setup_experience.json" FleetHTTPSignatureCertificateFileName = "host_identity.crt" // FleetHTTPSignatureTPMKeyFileName is the filename for the TPM key used for HTTP signature authentication diff --git a/orbit/pkg/packaging/linux_shared.go b/orbit/pkg/packaging/linux_shared.go index 606c6494b5..f3a2b7b550 100644 --- a/orbit/pkg/packaging/linux_shared.go +++ b/orbit/pkg/packaging/linux_shared.go @@ -353,6 +353,7 @@ ORBIT_FLEET_DESKTOP_ALTERNATIVE_BROWSER_HOST={{ .FleetDesktopAlternativeBrowserH {{ if .OsqueryDB }}ORBIT_OSQUERY_DB={{.OsqueryDB}}{{ end }} {{ if .EndUserEmail }}ORBIT_END_USER_EMAIL={{.EndUserEmail}}{{ end }} {{ if .FleetManagedHostIdentityCertificate }}ORBIT_FLEET_MANAGED_HOST_IDENTITY_CERTIFICATE=true{{ end }} +{{ if .DisableSetupExperience }}ORBIT_DISABLE_SETUP_EXPERIENCE=true{{ end }} `)) func writeEnvFile(opt Options, rootPath string) error { diff --git a/orbit/pkg/packaging/packaging.go b/orbit/pkg/packaging/packaging.go index 0c29911b17..6e5a2644dc 100644 --- a/orbit/pkg/packaging/packaging.go +++ b/orbit/pkg/packaging/packaging.go @@ -61,6 +61,8 @@ type Options struct { FleetDesktopAlternativeBrowserHost string // DisableUpdates disables auto updates on the generated package. DisableUpdates bool + // DisableSetupExperience disables setup experience for Linux hosts + DisableSetupExperience bool // OrbitChannel is the update channel to use for Orbit. OrbitChannel string // OsquerydChannel is the update channel to use for Osquery (osqueryd). diff --git a/server/fleet/capabilities.go b/server/fleet/capabilities.go index d8abea7731..ddf479ad45 100644 --- a/server/fleet/capabilities.go +++ b/server/fleet/capabilities.go @@ -88,6 +88,9 @@ const ( // the ability of the client to show the corresponding UI to support that // flow. CapabilitySetupExperience Capability = "setup_experience" + // CapabilityWebSetupExperience denotes the ability of the server to support installing software + // as part of a non-blocking setup experience for Linux and Windows + CapabilityWebSetupExperience Capability = "web_setup_experience" ) func GetServerOrbitCapabilities() CapabilityMap { @@ -98,6 +101,7 @@ func GetServerOrbitCapabilities() CapabilityMap { CapabilityEscrowBuddy: {}, CapabilityLinuxDiskEncryptionEscrow: {}, CapabilitySetupExperience: {}, + CapabilityWebSetupExperience: {}, } } diff --git a/server/service/orbit_client.go b/server/service/orbit_client.go index 452903470b..05379f8a59 100644 --- a/server/service/orbit_client.go +++ b/server/service/orbit_client.go @@ -737,3 +737,13 @@ func (oc *OrbitClient) SendLinuxKeyEscrowResponse(lr luks.LuksResponse) error { return nil } + +func (oc *OrbitClient) InitiateSetupExperience() (fleet.SetupExperienceInitResult, error) { + verb, path := "POST", "/api/fleet/orbit/setup_experience/init" + var resp orbitSetupExperienceInitResponse + if err := oc.authenticatedRequest(verb, path, &orbitSetupExperienceInitRequest{}, &resp); err != nil { + return fleet.SetupExperienceInitResult{}, err + } + + return resp.Result, nil +}