Files
ctrld/discover_user_linux.go
Codescribe 023969ff6d feat: robust username detection and CI updates
Add platform-specific username detection for Control D metadata:
- macOS: directory services (dscl) with console user fallback
- Linux: systemd loginctl, utmp, /etc/passwd traversal
- Windows: WTS session enumeration, registry, token lookup
2026-03-10 17:18:25 +07:00

238 lines
5.3 KiB
Go

//go:build linux
package ctrld
import (
"bufio"
"context"
"os"
"os/exec"
"strconv"
"strings"
)
// DiscoverMainUser attempts to find the primary user on Linux systems.
// This is designed to work reliably under RMM deployments where traditional
// environment variables and session detection may not be available.
//
// Priority chain (deterministic, lowest UID wins among candidates):
// 1. Active users from loginctl list-users
// 2. Parse /etc/passwd for users with UID >= 1000, prefer admin group members
// 3. Fallback to lowest UID >= 1000 from /etc/passwd
func DiscoverMainUser(ctx context.Context) string {
logger := LoggerFromCtx(ctx).Debug()
// Method 1: Check active users via loginctl
logger.Msg("attempting to discover user via loginctl")
if user := getLoginctlUser(ctx); user != "" {
logger.Str("method", "loginctl").Str("user", user).Msg("found user via loginctl")
return user
}
// Method 2: Parse /etc/passwd and find admin users first
logger.Msg("attempting to discover user via /etc/passwd with admin preference")
if user := getPasswdUserWithAdminPreference(ctx); user != "" {
logger.Str("method", "passwd+admin").Str("user", user).Msg("found admin user via /etc/passwd")
return user
}
// Method 3: Fallback to lowest UID >= 1000 from /etc/passwd
logger.Msg("attempting to discover user via /etc/passwd lowest UID")
if user := getLowestPasswdUser(ctx); user != "" {
logger.Str("method", "passwd").Str("user", user).Msg("found user via /etc/passwd")
return user
}
logger.Msg("all user discovery methods failed")
return "unknown"
}
// getLoginctlUser uses loginctl to find active users
func getLoginctlUser(ctx context.Context) string {
cmd := exec.CommandContext(ctx, "loginctl", "list-users", "--no-legend")
out, err := cmd.Output()
if err != nil {
LoggerFromCtx(ctx).Debug().Err(err).Msg("failed to run loginctl list-users")
return ""
}
var candidates []struct {
name string
uid int
}
lines := strings.Split(string(out), "\n")
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
uidStr := fields[0]
username := fields[1]
uid, err := strconv.Atoi(uidStr)
if err != nil {
continue
}
// Only consider regular users (UID >= 1000 on Linux)
if uid >= 1000 {
candidates = append(candidates, struct {
name string
uid int
}{username, uid})
}
}
if len(candidates) == 0 {
return ""
}
// Return user with lowest UID (deterministic choice)
lowestUID := candidates[0].uid
result := candidates[0].name
for _, candidate := range candidates[1:] {
if candidate.uid < lowestUID {
lowestUID = candidate.uid
result = candidate.name
}
}
return result
}
// getPasswdUserWithAdminPreference parses /etc/passwd and prefers admin group members
func getPasswdUserWithAdminPreference(ctx context.Context) string {
users := parsePasswdFile(ctx)
if len(users) == 0 {
return ""
}
var adminUsers []struct {
name string
uid int
}
var regularUsers []struct {
name string
uid int
}
// Separate admin and regular users
for _, user := range users {
if isUserInAdminGroups(ctx, user.name) {
adminUsers = append(adminUsers, user)
} else {
regularUsers = append(regularUsers, user)
}
}
// Prefer admin users, then regular users
candidates := adminUsers
if len(candidates) == 0 {
candidates = regularUsers
}
if len(candidates) == 0 {
return ""
}
// Return user with lowest UID (deterministic choice)
lowestUID := candidates[0].uid
result := candidates[0].name
for _, candidate := range candidates[1:] {
if candidate.uid < lowestUID {
lowestUID = candidate.uid
result = candidate.name
}
}
return result
}
// getLowestPasswdUser returns the user with lowest UID >= 1000 from /etc/passwd
func getLowestPasswdUser(ctx context.Context) string {
users := parsePasswdFile(ctx)
if len(users) == 0 {
return ""
}
// Return user with lowest UID (deterministic choice)
lowestUID := users[0].uid
result := users[0].name
for _, user := range users[1:] {
if user.uid < lowestUID {
lowestUID = user.uid
result = user.name
}
}
return result
}
// parsePasswdFile parses /etc/passwd and returns users with UID >= 1000
func parsePasswdFile(ctx context.Context) []struct {
name string
uid int
} {
file, err := os.Open("/etc/passwd")
if err != nil {
LoggerFromCtx(ctx).Debug().Err(err).Msg("failed to open /etc/passwd")
return nil
}
defer file.Close()
var users []struct {
name string
uid int
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
fields := strings.Split(line, ":")
if len(fields) < 3 {
continue
}
username := fields[0]
uidStr := fields[2]
uid, err := strconv.Atoi(uidStr)
if err != nil {
continue
}
// Only consider regular users (UID >= 1000 on Linux)
if uid >= 1000 {
users = append(users, struct {
name string
uid int
}{username, uid})
}
}
return users
}
// isUserInAdminGroups checks if a user is in common admin groups
func isUserInAdminGroups(ctx context.Context, username string) bool {
adminGroups := []string{"sudo", "wheel", "admin"}
for _, group := range adminGroups {
cmd := exec.CommandContext(ctx, "groups", username)
out, err := cmd.Output()
if err != nil {
continue
}
if strings.Contains(string(out), group) {
return true
}
}
return false
}