Don't attempt to start fleet desktop if no user is logged in on Linux (#30261)

for #29942 

# Details

This PR addresses an issue on Linux where Orbit repeatedly attempts to
launch Fleet Desktop even though no GUI user is logged in. The fix is
similar to one implemented for MacOS, where we have Orbit check for the
presence of a real user (not a system user like `gdm` or `root`) before
trying to launch the desktop app.

Part of this work involved moving some functionality from the `execuser`
package to the `user` package, to avoid duplicating functionality.

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [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.
- [x] Manual QA for all new/changed functionality
- For Orbit and Fleet Desktop changes:
- [x] Make sure fleetd is compatible with the latest released version of
Fleet (see [Must
rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md)).
- [x] Orbit runs on macOS, Linux and Windows. Check if the orbit
feature/bugfix should only apply to one platform (`runtime.GOOS`).
- [x] Manual QA must be performed in the three main OSs, macOS, Windows
and Linux.
The changed code is only executed on Linux, so I tested on Ubuntu,
Fedora and Debian. Also verified that it still works on MacOS and
Windows.

---------

Co-authored-by: Lucas Manuel Rodriguez <lucas@fleetdm.com>
This commit is contained in:
Scott Gress
2025-06-27 08:41:13 -05:00
committed by GitHub
co-authored by Lucas Manuel Rodriguez
parent bd2f22ca2e
commit 43e4207fef
5 changed files with 171 additions and 121 deletions
@@ -0,0 +1 @@
- Fixed an issue where Orbit would attempt to launch Fleet Desktop on Linux systems without a logged-in GUI user
+2 -1
View File
@@ -1640,10 +1640,11 @@ func (d *desktopRunner) Execute() error {
log.Debug().Err(err).Msg("desktop.IsUserLoggedInGui")
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))
if *loggedInUser != "" {
opts = append(opts, execuser.WithUser(*loggedInUser))
+17 -118
View File
@@ -14,6 +14,7 @@ import (
"strconv"
"strings"
userpkg "github.com/fleetdm/fleet/v4/orbit/pkg/user"
"github.com/rs/zerolog/log"
)
@@ -119,26 +120,29 @@ func runWithStdin(path string, opts eopts) (io.WriteCloser, error) {
}
func getUserAndDisplayArgs(path string, opts eopts) ([]string, error) {
user, err := getLoginUID()
user, err := userpkg.GetLoginUser()
if err != nil {
return 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")
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 := getUserDisplaySessionType(uid)
uid := strconv.FormatInt(user.ID, 10)
userDisplaySessionType, err := userpkg.GetUserDisplaySessionType(uid)
if userDisplaySessionType == userpkg.GuiSessionTypeTty {
return 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 = guiSessionTypeWayland
userDisplaySessionType = userpkg.GuiSessionTypeWayland
}
var display string
if userDisplaySessionType == guiSessionTypeX11 {
x11Display, err := getUserX11Display(user.name)
if userDisplaySessionType == userpkg.GuiSessionTypeX11 {
x11Display, err := getUserX11Display(user.Name)
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.
@@ -165,15 +169,15 @@ func getUserAndDisplayArgs(path string, opts eopts) ([]string, error) {
log.Info().
Str("path", path).
Str("user", user.name).
Int64("id", user.id).
Str("user", user.Name).
Int64("id", user.ID).
Str("display", display).
Str("session_type", userDisplaySessionType.String()).
Msg("running sudo")
args := argsForSudo(user, opts)
if userDisplaySessionType == guiSessionTypeWayland {
if userDisplaySessionType == userpkg.GuiSessionTypeWayland {
args = append(args, "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-")
@@ -189,131 +193,26 @@ func getUserAndDisplayArgs(path string, opts eopts) ([]string, 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/%d/bus", user.ID),
)
return args, nil
}
type user struct {
name string
id int64
}
func argsForSudo(u *user, opts eopts) []string {
func argsForSudo(u *userpkg.User, opts eopts) []string {
// -H: "[...] to set HOME environment to what's specified in the target's user password database entry."
// -i: needed to run the command with the user's context, from `man sudo`:
// "The command is run with an environment similar to the one a user would receive at log in"
// -u: "[..]Run the command as a user other than the default target user (usually root)."
args := []string{"-i", "-u", u.name, "-H"}
args := []string{"-i", "-u", u.Name, "-H"}
for _, nv := range opts.env {
args = append(args, fmt.Sprintf("%s=%s", nv[0], nv[1]))
}
return args
}
// getLoginUID 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 getLoginUID() (*user, error) {
out, err := exec.Command("users").CombinedOutput()
if err != nil {
return nil, fmt.Errorf("users exec failed: %w", err)
}
usernames := parseUsersOutput(string(out))
username := usernames[0]
if username == "" {
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
}
// 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
}
var whoLineRegexp = regexp.MustCompile(`(\w+)\s+(:\d+)\s+`)
type guiSessionType int
const (
guiSessionTypeX11 guiSessionType = iota + 1
guiSessionTypeWayland
)
func (s guiSessionType) String() string {
if s == guiSessionTypeX11 {
return "x11"
}
return "wayland"
}
// getUserDisplaySessionType returns the display session type (X11 or Wayland) of the given user.
func getUserDisplaySessionType(uid string) (guiSessionType, error) {
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)
}
guiSessionID := strings.TrimSpace(stdout.String())
if guiSessionID == "" {
return 0, errors.New("empty GUI 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)
}
guiSessionType := strings.TrimSpace(stdout.String())
switch guiSessionType {
case "":
return 0, errors.New("empty GUI session type")
case "x11":
return guiSessionTypeX11, nil
case "wayland":
return guiSessionTypeWayland, nil
default:
return 0, fmt.Errorf("unknown GUI session type: %q", guiSessionType)
}
}
// getUserWaylandDisplay returns the value to set on WAYLAND_DISPLAY for the given user.
func getUserWaylandDisplay(uid string) (string, error) {
matches, err := filepath.Glob("/run/user/" + uid + "/wayland-*")
+149
View File
@@ -0,0 +1,149 @@
//go:build linux
// +build linux
package user
import (
"bytes"
"errors"
"fmt"
"os/exec"
"strconv"
"strings"
"github.com/rs/zerolog/log"
)
type User struct {
Name string
ID int64
}
func UserLoggedInViaGui() (*string, error) {
user, err := GetLoginUser()
if err != nil {
return nil, fmt.Errorf("get login user: %w", err)
}
// Bail out if the user is gdm or root, since they aren't GUI users.
if user.Name == "gdm" || user.Name == "root" {
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
}
return &user.Name, 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()
if err != nil {
return nil, fmt.Errorf("users exec failed: %w", err)
}
usernames := parseUsersOutput(string(out))
username := usernames[0]
if username == "" {
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
}
// 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) {
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)
}
guiSessionID := strings.TrimSpace(stdout.String())
if guiSessionID == "" {
return 0, nil
}
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)
}
guiSessionType := strings.TrimSpace(stdout.String())
switch guiSessionType {
case "":
return 0, errors.New("empty GUI session type")
case "x11":
return GuiSessionTypeX11, nil
case "wayland":
return GuiSessionTypeWayland, nil
case "tty":
return GuiSessionTypeTty, nil
default:
return 0, fmt.Errorf("unknown GUI session type: %q", guiSessionType)
}
}
type guiSessionType int
const (
GuiSessionTypeX11 guiSessionType = iota + 1
GuiSessionTypeWayland
GuiSessionTypeTty
)
func (s guiSessionType) String() string {
if s == GuiSessionTypeX11 {
return "x11"
}
if s == GuiSessionTypeTty {
return "tty"
}
return "wayland"
}
@@ -1,5 +1,5 @@
//go:build !darwin
// +build !darwin
//go:build windows
// +build windows
package user