diff --git a/orbit/changes/34501-fix-fleet-desktop-gui-sessions b/orbit/changes/34501-fix-fleet-desktop-gui-sessions new file mode 100644 index 0000000000..a9b840343f --- /dev/null +++ b/orbit/changes/34501-fix-fleet-desktop-gui-sessions @@ -0,0 +1 @@ +* Improved GUI user detection in orbit to use the correct active GUI session when starting Fleet Desktop. diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index ea3d71ac6c..e075c8f024 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -1870,20 +1870,25 @@ func (d *desktopRunner) Execute() error { for { // First retry logic to start fleet-desktop. if done := retry(30*time.Second, false, d.interruptCh, func() bool { - // On MacOS, if we attempt to run Fleet Desktop while the user is not logged in through - // the GUI, MacOS returns an error. See https://github.com/fleetdm/fleet/issues/14698 - // for more details. + // + // - On MacOS, if we attempt to run Fleet Desktop while the user is not logged in through + // the GUI, MacOS returns an error. See https://github.com/fleetdm/fleet/issues/14698 + // for more details. + // - On Linux, we also don't want to start the Fleet Desktop unless there's an active GUI session. + // - On Windows, user.UserLoggedInViaGui is a no-op, and execuser.Run will take care of + // starting Fleet Desktop with the correct GUI user. + // + loggedInUser, err := user.UserLoggedInViaGui() if err != nil { - log.Debug().Err(err).Msg("desktop.IsUserLoggedInGui") + log.Debug().Err(err).Msg("desktop.IsUserLoggedViaGui") return true } if loggedInUser == nil { log.Debug().Msg("No GUI user found, skipping fleet-desktop start") return true } - log.Debug().Msg(fmt.Sprintf("Found GUI user: %v, attempting fleet-desktop start", loggedInUser)) - + log.Debug().Msgf("found GUI user: %q, attempting fleet-desktop start", *loggedInUser) if *loggedInUser != "" { opts = append(opts, execuser.WithUser(*loggedInUser)) } @@ -2418,7 +2423,7 @@ func executeFleetDesktopWithPermanentError(desktopPath string, errorMessage stri log.Debug().Msg("No GUI user found, skipping fleet-desktop start") return nil } - log.Debug().Msg(fmt.Sprintf("Found GUI user: %v, attempting fleet-desktop start", loggedInUser)) + log.Debug().Msgf("found GUI user: %q, attempting fleet-desktop start", *loggedInUser) log.Info().Msg("killing any pre-existing fleet-desktop instances") if err := platform.SignalProcessBeforeTerminate(constant.DesktopAppExecName); err != nil && diff --git a/orbit/pkg/execuser/execuser.go b/orbit/pkg/execuser/execuser.go index 01053f824d..dc0af56e9e 100644 --- a/orbit/pkg/execuser/execuser.go +++ b/orbit/pkg/execuser/execuser.go @@ -3,7 +3,6 @@ package execuser import ( - "io" "time" ) @@ -70,11 +69,3 @@ func RunWithOutput(path string, opts ...Option) (output []byte, exitCode int, er } return runWithOutput(path, o) } - -func RunWithStdin(path string, opts ...Option) (io.WriteCloser, error) { - var o eopts - for _, fn := range opts { - fn(&o) - } - return runWithStdin(path, o) -} diff --git a/orbit/pkg/execuser/execuser_darwin.go b/orbit/pkg/execuser/execuser_darwin.go index 59d66cc1ad..8f60a47fe3 100644 --- a/orbit/pkg/execuser/execuser_darwin.go +++ b/orbit/pkg/execuser/execuser_darwin.go @@ -62,7 +62,3 @@ func run(path string, opts eopts) (lastLogs string, err error) { func runWithOutput(path string, opts eopts) (output []byte, exitCode int, err error) { return nil, 0, errors.New("not implemented") } - -func runWithStdin(path string, opts eopts) (io.WriteCloser, error) { - return nil, errors.New("not implemented") -} diff --git a/orbit/pkg/execuser/execuser_linux.go b/orbit/pkg/execuser/execuser_linux.go index 06dc6f3f34..a58c6f13c9 100644 --- a/orbit/pkg/execuser/execuser_linux.go +++ b/orbit/pkg/execuser/execuser_linux.go @@ -11,7 +11,6 @@ import ( "path/filepath" "regexp" "sort" - "strconv" "strings" userpkg "github.com/fleetdm/fleet/v4/orbit/pkg/user" @@ -20,7 +19,11 @@ import ( // base command to setup an exec.Cmd using `runuser` func baserun(path string, opts eopts) (cmd *exec.Cmd, err error) { - args, env, err := getConfigForCommand(path) + if opts.user == "" { + return nil, errors.New("missing user") + } + + args, env, err := getConfigForCommand(opts.user, path) if err != nil { return nil, fmt.Errorf("get args: %w", err) } @@ -84,7 +87,7 @@ func run(path string, opts eopts) (lastLogs string, err error) { return "", nil } -// run a command and return its output and exit code. +// runWithOutput runs a command and return its output and exit code. func runWithOutput(path string, opts eopts) (output []byte, exitCode int, err error) { cmd, err := baserun(path, opts) if err != nil { @@ -103,85 +106,80 @@ func runWithOutput(path string, opts eopts) (output []byte, exitCode int, err er return output, exitCode, nil } -// run a command that requires stdin input, returning a pipe to write to stdin. -func runWithStdin(path string, opts eopts) (io.WriteCloser, error) { - cmd, err := baserun(path, opts) +func getUserID(user string) (string, error) { + uid_, err := exec.Command("id", "-u", user).Output() if err != nil { - return nil, err + return "", fmt.Errorf("failed to execute id command for %q: %w", user, err) } - - stdin, err := cmd.StdinPipe() - if err != nil { - return nil, fmt.Errorf("stdin pipe: %w", err) + uid := strings.TrimSpace(string(uid_)) + if uid == "" { + return "", errors.New("failed to get uid") } - - if err := cmd.Start(); err != nil { - return nil, fmt.Errorf("open path %q: %w", path, err) - } - - return stdin, nil + return uid, nil } -func getConfigForCommand(path string) (args []string, env []string, err error) { - user, err := userpkg.GetLoginUser() - if err != nil { - return nil, nil, fmt.Errorf("get user: %w", err) - } - - log.Info().Str("user", user.Name).Int64("id", user.ID).Msg("attempting to get user session type and display") - - // Get user's display session type (x11 vs. wayland). - uid := strconv.FormatInt(user.ID, 10) - userDisplaySessionType, err := userpkg.GetUserDisplaySessionType(uid) - if userDisplaySessionType == userpkg.GuiSessionTypeTty { - return nil, nil, fmt.Errorf("user %q (%d) is not running a GUI session", user.Name, user.ID) - } - if err != nil { - // Wayland is the default for most distributions, thus we assume - // wayland if we couldn't determine the session type. - log.Error().Err(err).Msg("assuming wayland session") - userDisplaySessionType = userpkg.GuiSessionTypeWayland - } - - var display string - if userDisplaySessionType == userpkg.GuiSessionTypeX11 { - x11Display, err := getUserX11Display(user.Name) +func getDisplayVariableForSession(user string, userID string, displaySessionType userpkg.GuiSessionType) string { + if displaySessionType == userpkg.GuiSessionTypeX11 { + x11Display, err := getUserX11Display(user) if err != nil { log.Error().Err(err).Msg("failed to get X11 display, using default :0") // TODO(lucas): Revisit when working on multi-user/multi-session support. // Default to display ':0' if user display could not be found. // This assumes there's only one desktop session and belongs to the // user returned in `getLoginUID'. - display = ":0" - } else { - display = x11Display - } - } else { - waylandDisplay, err := getUserWaylandDisplay(uid) - if err != nil { - log.Error().Err(err).Msg("failed to get wayland display, using default wayland-0") - // TODO(lucas): Revisit when working on multi-user/multi-session support. - // Default to display 'wayland-0' if user display could not be found. - // This assumes there's only one desktop session and belongs to the - // user returned in `getLoginUID'. - display = "wayland-0" - } else { - display = waylandDisplay + return ":0" } + return x11Display } + waylandDisplay, err := getUserWaylandDisplay(userID) + if err != nil { + log.Error().Err(err).Msg("failed to get wayland display, using default wayland-0") + // TODO(lucas): Revisit when working on multi-user/multi-session support. + // Default to display 'wayland-0' if user display could not be found. + // This assumes there's only one desktop session and belongs to the + // user returned in `getLoginUID'. + return "wayland-0" + } + return waylandDisplay +} + +func getConfigForCommand(user string, path string) (args []string, env []string, err error) { + // Get user ID + userID, err := getUserID(user) + if err != nil { + return nil, nil, fmt.Errorf("get user ID: %w", err) + } + log.Info().Str("user", user).Str("id", userID).Msg("attempting to get user session type and display") + + // Get user's display session type. + userDisplaySession, err := userpkg.GetUserDisplaySessionType(userID) + if err != nil { + // Wayland is the default for most distributions, + // thus we assume wayland if we couldn't determine the session type. + log.Error().Err(err).Msg("assuming wayland session") + userDisplaySession = &userpkg.UserDisplaySession{ + Type: userpkg.GuiSessionTypeWayland, + } + } else if userDisplaySession.Type == userpkg.GuiSessionTypeTty { + return nil, nil, fmt.Errorf("user %q (%s) is not running a GUI session", user, userID) + } + + // Get user's "display" variable for the GUI session. + display := getDisplayVariableForSession(user, userID, userDisplaySession.Type) + log.Info(). Str("path", path). - Str("user", user.Name). - Int64("id", user.ID). + Str("user", user). + Str("id", userID). Str("display", display). - Str("session_type", userDisplaySessionType.String()). + Str("session_type", userDisplaySession.Type.String()). Msg("running sudo") - args = []string{"-n", "-i", "-u", user.Name, "-H"} + args = []string{"-n", "-i", "-u", user, "-H"} env = make([]string, 0) - if userDisplaySessionType == userpkg.GuiSessionTypeWayland { + if userDisplaySession.Type == userpkg.GuiSessionTypeWayland { env = append(env, "WAYLAND_DISPLAY="+display) // For xdg-open to work on a Wayland session we still need to set the DISPLAY variable. x11Display := ":" + strings.TrimPrefix(display, "wayland-") @@ -197,7 +195,7 @@ func getConfigForCommand(path string) (args []string, env []string, err error) { // // This is required for Ubuntu 18, and not required for Ubuntu 21/22 // (because it's already part of the user). - fmt.Sprintf("DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%d/bus", user.ID), + fmt.Sprintf("DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/%s/bus", userID), ) return args, env, nil diff --git a/orbit/pkg/execuser/execuser_windows.go b/orbit/pkg/execuser/execuser_windows.go index f7f799ca32..215e295ae5 100644 --- a/orbit/pkg/execuser/execuser_windows.go +++ b/orbit/pkg/execuser/execuser_windows.go @@ -9,7 +9,6 @@ package execuser import ( "errors" "fmt" - "io" "os" "strings" "unsafe" @@ -136,10 +135,6 @@ func runWithOutput(path string, opts eopts) (output []byte, exitCode int, err er return nil, 0, errors.New("not implemented") } -func runWithStdin(path string, opts eopts) (io.WriteCloser, error) { - return nil, errors.New("not implemented") -} - // getCurrentUserSessionId will attempt to resolve // the session ID of the user currently active on // the system. diff --git a/orbit/pkg/kdialog/kdialog.go b/orbit/pkg/kdialog/kdialog.go index 0462422972..5f7578a3f6 100644 --- a/orbit/pkg/kdialog/kdialog.go +++ b/orbit/pkg/kdialog/kdialog.go @@ -2,11 +2,14 @@ package kdialog import ( "errors" + "fmt" "strings" "time" "github.com/fleetdm/fleet/v4/orbit/pkg/dialog" "github.com/fleetdm/fleet/v4/orbit/pkg/execuser" + "github.com/fleetdm/fleet/v4/orbit/pkg/user" + "github.com/rs/zerolog/log" ) const kdialogProcessName = "kdialog" @@ -79,6 +82,17 @@ func execCmdWithOutput(timeout time.Duration, args ...string) ([]byte, int, erro opts = append(opts, execuser.WithTimeout(timeout)) } + // Retrieve and set active GUI user. + loggedInUser, err := user.UserLoggedInViaGui() + if err != nil { + return nil, 0, fmt.Errorf("user logged in via GUI: %w", err) + } + if loggedInUser == nil || *loggedInUser == "" { + return nil, 0, errors.New("no GUI user found") + } + log.Debug().Msgf("found GUI user: %s, attempting kdialog", *loggedInUser) + opts = append(opts, execuser.WithUser(*loggedInUser)) + output, exitCode, err := execuser.RunWithOutput(kdialogProcessName, opts...) if err != nil { return nil, exitCode, err diff --git a/orbit/pkg/luks/luks_linux.go b/orbit/pkg/luks/luks_linux.go index 0cb2d80987..d6b217d3e7 100644 --- a/orbit/pkg/luks/luks_linux.go +++ b/orbit/pkg/luks/luks_linux.go @@ -135,7 +135,7 @@ func (lr *LuksRunner) getEscrowKey(ctx context.Context, devicePath string) ([]by device := luksdevice.New(luksdevice.AESXTSPlain64Cipher) // Prompt user for existing LUKS passphrase - passphrase, err := lr.entryPrompt(ctx, entryDialogTitle, entryDialogText) + passphrase, err := lr.entryPrompt(entryDialogTitle, entryDialogText) if err != nil { return nil, nil, fmt.Errorf("Failed to show passphrase entry prompt: %w", err) } @@ -157,7 +157,7 @@ func (lr *LuksRunner) getEscrowKey(ctx context.Context, devicePath string) ([]by break } - passphrase, err = lr.entryPrompt(ctx, entryDialogTitle, retryEntryDialogText) + passphrase, err = lr.entryPrompt(entryDialogTitle, retryEntryDialogText) if err != nil { return nil, nil, fmt.Errorf("Failed re-prompting for passphrase: %w", err) } @@ -275,7 +275,7 @@ func generateRandomPassphrase() ([]byte, error) { return passphrase, nil } -func (lr *LuksRunner) entryPrompt(ctx context.Context, title, text string) ([]byte, error) { +func (lr *LuksRunner) entryPrompt(title, text string) ([]byte, error) { passphrase, err := lr.notifier.ShowEntry(dialog.EntryOptions{ Title: title, Text: text, diff --git a/orbit/pkg/user/user_linux.go b/orbit/pkg/user/user_linux.go index 889d5d3621..d812f23ad4 100644 --- a/orbit/pkg/user/user_linux.go +++ b/orbit/pkg/user/user_linux.go @@ -7,7 +7,6 @@ import ( "bytes" "errors" "fmt" - "os" "os/exec" "strconv" "strings" @@ -20,170 +19,146 @@ type User struct { ID int64 } +// UserLoggedInViaGui returns the username that has an active GUI session. +// It returns nil, nil if there's no user with an active GUI session. func UserLoggedInViaGui() (*string, error) { - user, err := GetLoginUser() + users, err := getLoginUsers() if err != nil { - return nil, fmt.Errorf("get login user: %w", err) + return nil, fmt.Errorf("get login users: %w", err) } - // Bail out if the user is gdm or root, since they aren't GUI users. - // User gdm-greeter is active during the GUI log-in prompt (GNOME 49). - if user.Name == "gdm" || user.Name == "root" || user.Name == "gdm-greeter" { - return nil, nil - } - // Check if the user has a GUI session. - displaySessionType, err := GetUserDisplaySessionType(strconv.FormatInt(user.ID, 10)) - if err != nil { - log.Debug().Err(err).Msgf("failed to get user display session type for user %s", user.Name) - return nil, nil - } - if displaySessionType == GuiSessionTypeTty { - log.Debug().Msgf("user %s is logged in via TTY, not GUI", user.Name) - return nil, nil + for _, user := range users { + // Skip system/display manager users since they aren't GUI users. + // User gdm-greeter is active during the GUI log-in prompt (GNOME 49). + if user.Name == "gdm" || user.Name == "root" || user.Name == "gdm-greeter" { + continue + } + // Check if the user has an active GUI session. + session, err := GetUserDisplaySessionType(strconv.FormatInt(user.ID, 10)) + if err != nil { + log.Debug().Err(err).Msgf("failed to get user display session for user %s", user.Name) + continue + } + if !session.Active { + log.Debug().Msgf("user %s has an inactive display session, skipping", user.Name) + continue + } + if session.Type == GuiSessionTypeTty { + log.Debug().Msgf("user %s is logged in via TTY, not GUI", user.Name) + continue + } + return &user.Name, nil } - return &user.Name, nil + // No valid user found + return nil, nil } -// GetLoginUser returns the name and uid of the first login user -// as reported by the `users' command. -// -// NOTE(lucas): It is always picking first login user as returned -// by `users', revisit when working on multi-user/multi-session support. -func GetLoginUser() (*User, error) { - out, err := exec.Command("users").CombinedOutput() +// getLoginUsers returns all logged-in users as reported by +// `loginctl list-users`. +func getLoginUsers() ([]User, error) { + out, err := exec.Command("loginctl", "list-users", "--no-legend", "--no-pager").CombinedOutput() if err != nil { - return nil, fmt.Errorf("users exec failed: %w", err) + return nil, fmt.Errorf("loginctl list-users exec failed: %w, output: %s", err, string(out)) } - usernames := parseUsersOutput(string(out)) - username := usernames[0] - if username == "" { + return parseLoginctlUsersOutput(string(out)) +} + +// parseLoginctlUsersOutput parses the output of `loginctl list-users --no-legend`. +// Each line has the format: UID USERNAME +func parseLoginctlUsersOutput(s string) ([]User, error) { + var users []User + for line := range strings.SplitSeq(strings.TrimSpace(s), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + uid, err := strconv.ParseInt(fields[0], 10, 64) + if err != nil { + log.Debug().Err(err).Msgf("failed to parse uid from loginctl output: %q", line) + continue + } + users = append(users, User{ + Name: fields[1], + ID: uid, + }) + } + if len(users) == 0 { return nil, errors.New("no user session found") } - out, err = exec.Command("id", "-u", username).CombinedOutput() - if err != nil { - return nil, fmt.Errorf("id exec failed: %w", err) - } - uid, err := parseIDOutput(string(out)) - if err != nil { - return nil, err - } - return &User{ - Name: username, - ID: uid, - }, nil + return users, nil } -// GetSELinuxUserContext returns the SELinux context for the given user. -// Example: `unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023` -// -// If SELinux is not enabled, the `runcon` command is not available, -// or context cannot be determined, it returns nil. -func GetSELinuxUserContext(user *User) *string { - // If SELinux is not enabled, return nil right away. - if _, err := os.Stat("/sys/fs/selinux/enforce"); err != nil { - return nil - } - // If runcon is not available, we won't be able to switch contexts, - // so return nil. - if _, err := exec.LookPath("runcon"); err != nil { - log.Warn().Msg("runcon not available, returning nil for user context since we can't switch contexts") - return nil - } - // Find the first systemd process for the user and read its SELinux context. - pidBytes, err := exec.Command("pgrep", "-u", strconv.FormatInt(user.ID, 10), "-nx", "systemd").Output() // #nosec G204 - if err != nil { - log.Debug().Msgf("Error finding systemd process for user %s: %v", user.Name, err) - return nil - } - pid := strings.TrimSpace(string(pidBytes)) - if pid == "" { - log.Debug().Msgf("No systemd process found for user %s", user.Name) - return nil - } - ctx, err := os.ReadFile("/proc/" + pid + "/attr/current") - if err != nil { - log.Debug().Msgf("Error reading SELinux context for user %s: %v", user.Name, err) - return nil - } - context := strings.TrimSpace(string(ctx)) - // Remove any null byte at the end - context = strings.TrimSuffix(context, "\x00") - if context == "" { - log.Debug().Msg("Empty SELinux context for user " + user.Name) - return nil - } - return &context +// UserDisplaySession holds the display session type and active status for a user. +type UserDisplaySession struct { + Type GuiSessionType + Active bool } -// parseUsersOutput parses the output of the `users' command. -// -// `users' command prints on a single line a blank-separated list of user names of -// users currently logged in to the current host. Each user name -// corresponds to a login session, so if a user has more than one login -// session, that user's name will appear the same number of times in the -// output. -// -// Returns the list of usernames. -func parseUsersOutput(s string) []string { - var users []string - users = append(users, strings.Split(strings.TrimSpace(s), " ")...) - return users -} - -// parseIDOutput parses the output of the `id' command. -// -// Returns the parsed uid. -func parseIDOutput(s string) (int64, error) { - uid, err := strconv.ParseInt(strings.TrimSpace(s), 10, 0) - if err != nil { - return 0, fmt.Errorf("failed to parse uid: %w", err) - } - return uid, nil -} - -// getUserDisplaySessionType returns the display session type (X11 or Wayland) of the given user. -func GetUserDisplaySessionType(uid string) (guiSessionType, error) { +// GetUserDisplaySessionType returns the display session type (X11 or Wayland) +// and active status of the given user. Returns an error if the user doesn't have +// a Display session. +func GetUserDisplaySessionType(uid string) (*UserDisplaySession, error) { + // Get the "Display" session ID of the user. cmd := exec.Command("loginctl", "show-user", uid, "-p", "Display", "--value") var stdout bytes.Buffer cmd.Stdout = &stdout if err := cmd.Run(); err != nil { - return 0, fmt.Errorf("run 'loginctl' to get user GUI session: %w", err) + return nil, fmt.Errorf("run 'loginctl' to get user GUI session: %w", err) } guiSessionID := strings.TrimSpace(stdout.String()) if guiSessionID == "" { - return 0, nil + return nil, errors.New("empty display session") } + + // Get the "Type" of session. cmd = exec.Command("loginctl", "show-session", guiSessionID, "-p", "Type", "--value") stdout.Reset() cmd.Stdout = &stdout if err := cmd.Run(); err != nil { - return 0, fmt.Errorf("run 'loginctl' to get user GUI session type: %w", err) + return nil, fmt.Errorf("run 'loginctl' to get user GUI session type: %w", err) } - guiSessionType := strings.TrimSpace(stdout.String()) - switch guiSessionType { + var sessionType GuiSessionType + switch t := strings.TrimSpace(stdout.String()); t { case "": - return 0, errors.New("empty GUI session type") + return nil, errors.New("empty GUI session type") case "x11": - return GuiSessionTypeX11, nil + sessionType = GuiSessionTypeX11 case "wayland": - return GuiSessionTypeWayland, nil + sessionType = GuiSessionTypeWayland case "tty": - return GuiSessionTypeTty, nil + sessionType = GuiSessionTypeTty default: - return 0, fmt.Errorf("unknown GUI session type: %q", guiSessionType) + return nil, fmt.Errorf("unknown GUI session type: %q", t) } + + // Get the "Active" property of the session. + cmd = exec.Command("loginctl", "show-session", guiSessionID, "-p", "Active", "--value") + stdout.Reset() + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("run 'loginctl' to get session active status: %w", err) + } + active := strings.TrimSpace(stdout.String()) == "yes" + return &UserDisplaySession{ + Type: sessionType, + Active: active, + }, nil } -type guiSessionType int +type GuiSessionType int const ( - GuiSessionTypeX11 guiSessionType = iota + 1 + GuiSessionTypeX11 GuiSessionType = iota + 1 GuiSessionTypeWayland GuiSessionTypeTty ) -func (s guiSessionType) String() string { +func (s GuiSessionType) String() string { if s == GuiSessionTypeX11 { return "x11" } diff --git a/orbit/pkg/user/user_linux_test.go b/orbit/pkg/user/user_linux_test.go new file mode 100644 index 0000000000..bf5e683bea --- /dev/null +++ b/orbit/pkg/user/user_linux_test.go @@ -0,0 +1,83 @@ +//go:build linux + +package user + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseLoginctlUsersOutput(t *testing.T) { + tests := []struct { + name string + input string + want []User + wantErr string + }{ + { + name: "single user", + input: " 1000 alice\n", + want: []User{{Name: "alice", ID: 1000}}, + }, + { + name: "multiple users", + input: " 1000 alice\n 1001 bob\n", + want: []User{ + {Name: "alice", ID: 1000}, + {Name: "bob", ID: 1001}, + }, + }, + { + name: "includes system user", + input: " 0 root\n 1000 alice\n 120 gdm\n", + want: []User{ + {Name: "root", ID: 0}, + {Name: "alice", ID: 1000}, + {Name: "gdm", ID: 120}, + }, + }, + { + name: "no leading whitespace", + input: "1000 alice\n1001 bob\n", + want: []User{ + {Name: "alice", ID: 1000}, + {Name: "bob", ID: 1001}, + }, + }, + { + name: "extra columns (some systemd versions)", + input: "1000 alice extra-stuff\n", + want: []User{{Name: "alice", ID: 1000}}, + }, + { + name: "empty output", + input: "", + wantErr: "no user session found", + }, + { + name: "whitespace only", + input: " \n \n", + wantErr: "no user session found", + }, + { + name: "skips malformed lines", + input: "not-a-uid alice\n1000 bob\n", + want: []User{{Name: "bob", ID: 1000}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseLoginctlUsersOutput(tt.input) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/orbit/pkg/user/user_windows.go b/orbit/pkg/user/user_windows.go index 7ecfe2f3ea..24dcfb213c 100644 --- a/orbit/pkg/user/user_windows.go +++ b/orbit/pkg/user/user_windows.go @@ -1,11 +1,9 @@ //go:build windows -// +build windows package user // UserLoggedInViaGui returns the name of the user logged into the machine via the GUI. This -// function is only relevant on MacOS, where it is used to prevent errors when launching Fleet -// Desktop. For other platforms we return an empty string which can be ignored. +// function is only relevant on MacOS and Linux, where it is used to prevent errors when launching Fleet Desktop. func UserLoggedInViaGui() (*string, error) { user := "" return &user, nil diff --git a/orbit/pkg/zenity/zenity.go b/orbit/pkg/zenity/zenity.go index 8cf9bd43c4..0069ba77b9 100644 --- a/orbit/pkg/zenity/zenity.go +++ b/orbit/pkg/zenity/zenity.go @@ -7,6 +7,8 @@ import ( "github.com/fleetdm/fleet/v4/orbit/pkg/dialog" "github.com/fleetdm/fleet/v4/orbit/pkg/execuser" + "github.com/fleetdm/fleet/v4/orbit/pkg/user" + "github.com/rs/zerolog/log" ) const zenityProcessName = "zenity" @@ -88,6 +90,18 @@ func execCmdWithOutput(args ...string) ([]byte, int, error) { opts = append(opts, execuser.WithArg(arg, "")) // Using empty value for positional args } + // Retrieve and set active GUI user. + loggedInUser, err := user.UserLoggedInViaGui() + if err != nil { + return nil, 0, fmt.Errorf("user logged in via GUI: %w", err) + } + if loggedInUser == nil || *loggedInUser == "" { + return nil, 0, errors.New("no GUI user found") + } + log.Debug().Msgf("found GUI user: %s, attempting zenity", *loggedInUser) + opts = append(opts, execuser.WithUser(*loggedInUser)) + + // Execute zenity. output, exitCode, err := execuser.RunWithOutput(zenityProcessName, opts...) // Trim the newline from zenity output