//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 }