fleet/orbit/pkg/user/user_linux.go
Lucas Manuel Rodriguez 0823cc7e76
Fix orbit active GUI session detection to start Fleet Desktop and key escrowing on Linux (#39777)
Resolves #36024 and #34501.

Main change is about stop using the first user in the `users` command
output [*] and instead use `loginctl` commands to pick the correct
current active GUI user.

[*] `users` was returning empty on some new distributions, and in
multi-sessions we were always picking the first one (even if it wasn't
active).

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.

## Testing

- [x] QA'd all new/changed functionality manually

## fleetd/orbit/Fleet Desktop

- [x] Verified compatibility 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] If the change applies to only one platform, confirmed that
`runtime.GOOS` is used as needed to isolate changes
- [x] Verified that fleetd runs on macOS, Linux and Windows
- [x] Verified auto-update works from the released version of component
to the new version (see [tools/tuf/test](../tools/tuf/test/README.md))


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **Bug Fixes**
* Fixed Fleet Desktop startup to correctly detect and use the active GUI
session on Linux systems.
* Improved GUI user detection for dialog prompts, ensuring system
dialogs run in the proper user context.

* **Improvements**
* Enhanced error reporting and logging clarity for GUI session detection
failures.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-16 11:41:16 -03:00

169 lines
4.5 KiB
Go

//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
}
// 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) {
users, err := getLoginUsers()
if err != nil {
return nil, fmt.Errorf("get login users: %w", err)
}
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
}
// No valid user found
return nil, nil
}
// 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("loginctl list-users exec failed: %w, output: %s", err, string(out))
}
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")
}
return users, nil
}
// UserDisplaySession holds the display session type and active status for a user.
type UserDisplaySession struct {
Type GuiSessionType
Active bool
}
// 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 nil, fmt.Errorf("run 'loginctl' to get user GUI session: %w", err)
}
guiSessionID := strings.TrimSpace(stdout.String())
if guiSessionID == "" {
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 nil, fmt.Errorf("run 'loginctl' to get user GUI session type: %w", err)
}
var sessionType GuiSessionType
switch t := strings.TrimSpace(stdout.String()); t {
case "":
return nil, errors.New("empty GUI session type")
case "x11":
sessionType = GuiSessionTypeX11
case "wayland":
sessionType = GuiSessionTypeWayland
case "tty":
sessionType = GuiSessionTypeTty
default:
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
const (
GuiSessionTypeX11 GuiSessionType = iota + 1
GuiSessionTypeWayland
GuiSessionTypeTty
)
func (s GuiSessionType) String() string {
if s == GuiSessionTypeX11 {
return "x11"
}
if s == GuiSessionTypeTty {
return "tty"
}
return "wayland"
}