fleet/orbit/pkg/user/user_linux.go
Scott Gress f4cc1a2e5f
Run fleet desktop with user SELinux context when applicable (#30882)
For #29793 

# Details

This PR changes the way that Orbit launches processes such as the
desktop app on Linux, in order to ensure that on SELinux-enabled systems
the correct user context is set when running the command.

Previously, `sudo -u` was used to launch commands on Linux. This PR
switches to use `runuser` instead, which is recommended in situations
where the root user wants to execute a command as a user with reduced
privileges (see [the blog post by one of the creators of
runuser](https://danwalsh.livejournal.com/55588.html)). This avoids
certain errors that can come from interacting with PAM modules as the
system user.

Additionally, if we detect that SELinux is set up on a system, we now
use `runcon` to force the command to run using the logged-in user's
SELinux context. It's possible that on some systems they may have
configuration where `sudo` will switch to the user's SELinux context
automatically, but this is not guaranteed. Using `runuser` + `runcon` is
our best bet for ensuring that the desktop app (and anything that it
spawns) runs under the correct context.

This PR also does some refactoring so that the three `run` methods for
Linux (`run`, `runWithOutput` and `runWithStdin`) all use the same base
code to create the command with the correct args and env vars, and
differ only in how they handle the i/o.

# 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.
- 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.~ (n/a, code is linux only)
- [x] ~Auto-update manual QA, from released version of component to new
version (see [tools/tuf/test](../tools/tuf/test/README.md)).~ n/a

# Testing

- [x] Ubuntu with SELinux on
- [x] Ubuntu with SELinux off
- [ ] Fedora with SELinux on
- [ ] Fedora with SELinux off
- [ ] Debian with SELinux on
- [x] Debian with SELinux off
- [x] `runWithOutput` still works (tested with `go run
./tools/dialog/main.go --dialog=zenity`)
- [ ] ~`runWithStdin` still works~ (this isn't currently used by Linux)

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

## Summary by CodeRabbit

* **Bug Fixes**
* Improved security and user context handling when launching the fleet
desktop application on Linux systems.

* **Refactor**
* Enhanced process launch mechanism to use proper SELinux context and
user session, ensuring processes start under the correct user and
security environment.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-07-25 08:44:13 -05:00

192 lines
5.4 KiB
Go

//go:build linux
// +build linux
package user
import (
"bytes"
"errors"
"fmt"
"os"
"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
}
// 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
}
// 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"
}