mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-03-13 10:26:06 +00:00
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
238 lines
5.3 KiB
Go
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
|
|
} |