mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-03-13 10:26:06 +00:00
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
This commit is contained in:
committed by
Cuong Manh Le
parent
0a7bbb99e8
commit
023969ff6d
238
discover_user_linux.go
Normal file
238
discover_user_linux.go
Normal file
@@ -0,0 +1,238 @@
|
||||
//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
|
||||
}
|
||||
Reference in New Issue
Block a user