mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-07-04 01:07:49 +02: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
e7040bd9f9
commit
9b2e51f53a
@@ -32,9 +32,10 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ifaceResponse struct {
|
type ifaceResponse struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
All bool `json:"all"`
|
All bool `json:"all"`
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
|
InterceptMode string `json:"intercept_mode,omitempty"` // "dns", "hard", or "" (not intercepting)
|
||||||
}
|
}
|
||||||
|
|
||||||
type controlServer struct {
|
type controlServer struct {
|
||||||
@@ -220,7 +221,7 @@ func (p *prog) registerControlServerHandler() {
|
|||||||
rcReq := &controld.ResolverConfigRequest{
|
rcReq := &controld.ResolverConfigRequest{
|
||||||
RawUID: cdUID,
|
RawUID: cdUID,
|
||||||
Version: rootCmd.Version,
|
Version: rootCmd.Version,
|
||||||
Metadata: ctrld.SystemMetadata(context.Background()),
|
Metadata: ctrld.SystemMetadataRuntime(context.Background()),
|
||||||
}
|
}
|
||||||
if rc, err := controld.FetchResolverConfig(rcReq, cdDev); rc != nil {
|
if rc, err := controld.FetchResolverConfig(rcReq, cdDev); rc != nil {
|
||||||
if rc.DeactivationPin != nil {
|
if rc.DeactivationPin != nil {
|
||||||
@@ -276,6 +277,10 @@ func (p *prog) registerControlServerHandler() {
|
|||||||
res.Name = p.runningIface
|
res.Name = p.runningIface
|
||||||
res.All = p.requiredMultiNICsConfig
|
res.All = p.requiredMultiNICsConfig
|
||||||
res.OK = true
|
res.OK = true
|
||||||
|
// Report intercept mode to the start command for proper log output.
|
||||||
|
if interceptMode == "dns" || interceptMode == "hard" {
|
||||||
|
res.InterceptMode = interceptMode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := json.NewEncoder(w).Encode(res); err != nil {
|
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
//go:build darwin
|
||||||
|
|
||||||
|
package ctrld
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DiscoverMainUser attempts to find the primary user on macOS 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. Console user from stat -f %Su /dev/console
|
||||||
|
// 2. Active console session user via scutil
|
||||||
|
// 3. First user with UID >= 501 from dscl (standard macOS user range)
|
||||||
|
func DiscoverMainUser(ctx context.Context) string {
|
||||||
|
logger := ProxyLogger.Load().Debug()
|
||||||
|
|
||||||
|
// Method 1: Check console owner via stat
|
||||||
|
logger.Msg("attempting to discover user via console stat")
|
||||||
|
if user := getConsoleUser(ctx); user != "" && user != "root" {
|
||||||
|
logger.Str("method", "stat").Str("user", user).Msg("found user via console stat")
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Check active console session via scutil
|
||||||
|
logger.Msg("attempting to discover user via scutil ConsoleUser")
|
||||||
|
if user := getScutilConsoleUser(ctx); user != "" && user != "root" {
|
||||||
|
logger.Str("method", "scutil").Str("user", user).Msg("found user via scutil ConsoleUser")
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 3: Find lowest UID >= 501 from directory services
|
||||||
|
logger.Msg("attempting to discover user via dscl directory scan")
|
||||||
|
if user := getLowestRegularUser(ctx); user != "" {
|
||||||
|
logger.Str("method", "dscl").Str("user", user).Msg("found user via dscl scan")
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Msg("all user discovery methods failed")
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// getConsoleUser uses stat to find the owner of /dev/console
|
||||||
|
func getConsoleUser(ctx context.Context) string {
|
||||||
|
cmd := exec.CommandContext(ctx, "stat", "-f", "%Su", "/dev/console")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
ProxyLogger.Load().Debug().Err(err).Msg("failed to stat /dev/console")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
// getScutilConsoleUser uses scutil to get the current console user
|
||||||
|
func getScutilConsoleUser(ctx context.Context) string {
|
||||||
|
cmd := exec.CommandContext(ctx, "scutil", "-r", "ConsoleUser")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
ProxyLogger.Load().Debug().Err(err).Msg("failed to get ConsoleUser via scutil")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(out), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.Contains(line, "Name :") {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
return strings.TrimSpace(parts[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLowestRegularUser finds the user with the lowest UID >= 501
|
||||||
|
func getLowestRegularUser(ctx context.Context) string {
|
||||||
|
// Get list of all users with UID >= 501
|
||||||
|
cmd := exec.CommandContext(ctx, "dscl", ".", "list", "/Users", "UniqueID")
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
ProxyLogger.Load().Debug().Err(err).Msg("failed to list users via dscl")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
username := fields[0]
|
||||||
|
uidStr := fields[1]
|
||||||
|
|
||||||
|
uid, err := strconv.Atoi(uidStr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only consider regular users (UID >= 501 on macOS)
|
||||||
|
if uid >= 501 {
|
||||||
|
candidates = append(candidates, struct {
|
||||||
|
name string
|
||||||
|
uid int
|
||||||
|
}{username, uid})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the candidate with the 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
|
||||||
|
}
|
||||||
@@ -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 := ProxyLogger.Load().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 {
|
||||||
|
ProxyLogger.Load().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()
|
||||||
|
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()
|
||||||
|
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() []struct {
|
||||||
|
name string
|
||||||
|
uid int
|
||||||
|
} {
|
||||||
|
file, err := os.Open("/etc/passwd")
|
||||||
|
if err != nil {
|
||||||
|
ProxyLogger.Load().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
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build !windows && !linux && !darwin
|
||||||
|
|
||||||
|
package ctrld
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// DiscoverMainUser returns "unknown" for unsupported platforms.
|
||||||
|
// This is a stub implementation for platforms where username detection
|
||||||
|
// is not yet implemented.
|
||||||
|
func DiscoverMainUser(ctx context.Context) string {
|
||||||
|
ProxyLogger.Load().Debug().Msg("username discovery not implemented for this platform")
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package ctrld
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
"golang.org/x/sys/windows/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||||
|
wtsapi32 = windows.NewLazySystemDLL("wtsapi32.dll")
|
||||||
|
procGetConsoleWindow = kernel32.NewProc("GetConsoleWindow")
|
||||||
|
procWTSGetActiveConsoleSessionId = wtsapi32.NewProc("WTSGetActiveConsoleSessionId")
|
||||||
|
procWTSQuerySessionInformation = wtsapi32.NewProc("WTSQuerySessionInformationW")
|
||||||
|
procWTSFreeMemory = wtsapi32.NewProc("WTSFreeMemory")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
WTSUserName = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// DiscoverMainUser attempts to find the primary user on Windows 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 RID wins among candidates):
|
||||||
|
// 1. Active console session user via WTSGetActiveConsoleSessionId
|
||||||
|
// 2. Registry ProfileList scan for Administrators group members
|
||||||
|
// 3. Fallback to lowest RID from ProfileList
|
||||||
|
func DiscoverMainUser(ctx context.Context) string {
|
||||||
|
logger := ProxyLogger.Load().Debug()
|
||||||
|
|
||||||
|
// Method 1: Check active console session
|
||||||
|
logger.Msg("attempting to discover user via active console session")
|
||||||
|
if user := getActiveConsoleUser(ctx); user != "" {
|
||||||
|
logger.Str("method", "console").Str("user", user).Msg("found user via active console session")
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Scan registry for admin users
|
||||||
|
logger.Msg("attempting to discover user via registry with admin preference")
|
||||||
|
if user := getRegistryUserWithAdminPreference(ctx); user != "" {
|
||||||
|
logger.Str("method", "registry+admin").Str("user", user).Msg("found admin user via registry")
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 3: Fallback to lowest RID from registry
|
||||||
|
logger.Msg("attempting to discover user via registry lowest RID")
|
||||||
|
if user := getLowestRegistryUser(ctx); user != "" {
|
||||||
|
logger.Str("method", "registry").Str("user", user).Msg("found user via registry")
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Msg("all user discovery methods failed")
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// getActiveConsoleUser gets the username of the active console session
|
||||||
|
func getActiveConsoleUser(ctx context.Context) string {
|
||||||
|
// Guard against missing WTS procedures (e.g., Windows Server Core).
|
||||||
|
if err := procWTSGetActiveConsoleSessionId.Find(); err != nil {
|
||||||
|
ProxyLogger.Load().Debug().Err(err).Msg("WTSGetActiveConsoleSessionId not available, skipping console session check")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sessionId, _, _ := procWTSGetActiveConsoleSessionId.Call()
|
||||||
|
if sessionId == 0xFFFFFFFF { // Invalid session
|
||||||
|
ProxyLogger.Load().Debug().Msg("no active console session found")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var buffer uintptr
|
||||||
|
var bytesReturned uint32
|
||||||
|
|
||||||
|
if err := procWTSQuerySessionInformation.Find(); err != nil {
|
||||||
|
ProxyLogger.Load().Debug().Err(err).Msg("WTSQuerySessionInformationW not available")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
ret, _, _ := procWTSQuerySessionInformation.Call(
|
||||||
|
0, // WTS_CURRENT_SERVER_HANDLE
|
||||||
|
sessionId,
|
||||||
|
uintptr(WTSUserName),
|
||||||
|
uintptr(unsafe.Pointer(&buffer)),
|
||||||
|
uintptr(unsafe.Pointer(&bytesReturned)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if ret == 0 {
|
||||||
|
ProxyLogger.Load().Debug().Msg("failed to query session information")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer procWTSFreeMemory.Call(buffer)
|
||||||
|
|
||||||
|
// Convert buffer to string
|
||||||
|
username := windows.UTF16PtrToString((*uint16)(unsafe.Pointer(buffer)))
|
||||||
|
if username == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRegistryUserWithAdminPreference scans registry profiles and prefers admin users
|
||||||
|
func getRegistryUserWithAdminPreference(ctx context.Context) string {
|
||||||
|
profiles := getRegistryProfiles()
|
||||||
|
if len(profiles) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var adminProfiles []registryProfile
|
||||||
|
var regularProfiles []registryProfile
|
||||||
|
|
||||||
|
// Separate admin and regular users
|
||||||
|
for _, profile := range profiles {
|
||||||
|
if isUserInAdministratorsGroup(profile.username) {
|
||||||
|
adminProfiles = append(adminProfiles, profile)
|
||||||
|
} else {
|
||||||
|
regularProfiles = append(regularProfiles, profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer admin users, then regular users
|
||||||
|
candidates := adminProfiles
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
candidates = regularProfiles
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return user with lowest RID (deterministic choice)
|
||||||
|
lowestRID := candidates[0].rid
|
||||||
|
result := candidates[0].username
|
||||||
|
|
||||||
|
for _, candidate := range candidates[1:] {
|
||||||
|
if candidate.rid < lowestRID {
|
||||||
|
lowestRID = candidate.rid
|
||||||
|
result = candidate.username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLowestRegistryUser returns the user with lowest RID from registry
|
||||||
|
func getLowestRegistryUser(ctx context.Context) string {
|
||||||
|
profiles := getRegistryProfiles()
|
||||||
|
if len(profiles) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return user with lowest RID (deterministic choice)
|
||||||
|
lowestRID := profiles[0].rid
|
||||||
|
result := profiles[0].username
|
||||||
|
|
||||||
|
for _, profile := range profiles[1:] {
|
||||||
|
if profile.rid < lowestRID {
|
||||||
|
lowestRID = profile.rid
|
||||||
|
result = profile.username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
type registryProfile struct {
|
||||||
|
username string
|
||||||
|
rid uint32
|
||||||
|
sid string
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRegistryProfiles scans the registry ProfileList for user profiles
|
||||||
|
func getRegistryProfiles() []registryProfile {
|
||||||
|
key, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList`, registry.ENUMERATE_SUB_KEYS)
|
||||||
|
if err != nil {
|
||||||
|
ProxyLogger.Load().Debug().Err(err).Msg("failed to open ProfileList registry key")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer key.Close()
|
||||||
|
|
||||||
|
subkeys, err := key.ReadSubKeyNames(-1)
|
||||||
|
if err != nil {
|
||||||
|
ProxyLogger.Load().Debug().Err(err).Msg("failed to read ProfileList subkeys")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var profiles []registryProfile
|
||||||
|
|
||||||
|
for _, subkey := range subkeys {
|
||||||
|
// Only process SIDs that start with S-1-5-21 (domain/local user accounts)
|
||||||
|
if !strings.HasPrefix(subkey, "S-1-5-21-") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
profileKey, err := registry.OpenKey(key, subkey, registry.QUERY_VALUE)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
profileImagePath, _, err := profileKey.GetStringValue("ProfileImagePath")
|
||||||
|
profileKey.Close()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract username from profile path (e.g., C:\Users\username)
|
||||||
|
pathParts := strings.Split(profileImagePath, `\`)
|
||||||
|
if len(pathParts) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
username := pathParts[len(pathParts)-1]
|
||||||
|
|
||||||
|
// Extract RID from SID (last component after final hyphen)
|
||||||
|
sidParts := strings.Split(subkey, "-")
|
||||||
|
if len(sidParts) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ridStr := sidParts[len(sidParts)-1]
|
||||||
|
rid, err := strconv.ParseUint(ridStr, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only consider regular users (RID >= 1000, excludes built-in accounts).
|
||||||
|
// rid == 500 is the default Administrator account (DOMAIN_USER_RID_ADMIN).
|
||||||
|
// See: https://learn.microsoft.com/en-us/windows/win32/secauthz/well-known-sids
|
||||||
|
if rid == 500 || rid >= 1000 {
|
||||||
|
profiles = append(profiles, registryProfile{
|
||||||
|
username: username,
|
||||||
|
rid: uint32(rid),
|
||||||
|
sid: subkey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return profiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// isUserInAdministratorsGroup checks if a user is in the Administrators group
|
||||||
|
func isUserInAdministratorsGroup(username string) bool {
|
||||||
|
// Open the user account
|
||||||
|
usernamePtr, err := syscall.UTF16PtrFromString(username)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var userSID *windows.SID
|
||||||
|
var domain *uint16
|
||||||
|
var userSIDSize, domainSize uint32
|
||||||
|
var use uint32
|
||||||
|
|
||||||
|
// First call to get buffer sizes
|
||||||
|
err = windows.LookupAccountName(nil, usernamePtr, userSID, &userSIDSize, domain, &domainSize, &use)
|
||||||
|
if err != nil && err != windows.ERROR_INSUFFICIENT_BUFFER {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate buffers and make actual call
|
||||||
|
userSID = (*windows.SID)(unsafe.Pointer(&make([]byte, userSIDSize)[0]))
|
||||||
|
domain = (*uint16)(unsafe.Pointer(&make([]uint16, domainSize)[0]))
|
||||||
|
|
||||||
|
err = windows.LookupAccountName(nil, usernamePtr, userSID, &userSIDSize, domain, &domainSize, &use)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is member of Administrators group (S-1-5-32-544)
|
||||||
|
adminSID, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open user token (this is a simplified check)
|
||||||
|
var token windows.Token
|
||||||
|
err = windows.OpenProcessToken(windows.CurrentProcess(), windows.TOKEN_QUERY, &token)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer token.Close()
|
||||||
|
|
||||||
|
// Check group membership
|
||||||
|
member, err := token.IsMember(adminSID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return member
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Username Detection in ctrld
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The ctrld client needs to detect the primary user of a system for telemetry and configuration purposes. This is particularly challenging in RMM (Remote Monitoring and Management) deployments where traditional session-based detection methods fail.
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
In traditional desktop environments, username detection is straightforward using environment variables like `$USER`, `$LOGNAME`, or `$SUDO_USER`. However, RMM deployments present unique challenges:
|
||||||
|
|
||||||
|
- **No active login session**: RMM agents often run as system services without an associated user session
|
||||||
|
- **Missing environment variables**: Common user environment variables are not available in service contexts
|
||||||
|
- **Root/SYSTEM execution**: The ctrld process may run with elevated privileges, masking the actual user
|
||||||
|
|
||||||
|
## Solution Approach
|
||||||
|
|
||||||
|
ctrld implements a multi-tier, deterministic username detection system through the `DiscoverMainUser()` function with platform-specific implementations:
|
||||||
|
|
||||||
|
### Key Principles
|
||||||
|
|
||||||
|
1. **Deterministic selection**: No randomness - always returns the same result for the same system state
|
||||||
|
2. **Priority chain**: Multiple detection methods with clear fallback order
|
||||||
|
3. **Lowest UID/RID wins**: Among multiple candidates, select the user with the lowest identifier (typically the first user created)
|
||||||
|
4. **Fast execution**: All operations complete in <100ms using local system resources
|
||||||
|
5. **Debug logging**: Each decision point logs its rationale for troubleshooting
|
||||||
|
|
||||||
|
## Platform-Specific Implementation
|
||||||
|
|
||||||
|
### macOS (`discover_user_darwin.go`)
|
||||||
|
|
||||||
|
**Detection chain:**
|
||||||
|
1. **Console owner** (`stat -f %Su /dev/console`) - Most reliable for active GUI sessions
|
||||||
|
2. **scutil ConsoleUser** - Alternative session detection via System Configuration framework
|
||||||
|
3. **Directory Services scan** (`dscl . list /Users UniqueID`) - Scan all users with UID ≥ 501, select lowest
|
||||||
|
|
||||||
|
**Rationale**: macOS systems typically have a primary user who owns the console. Service contexts can still access device ownership information.
|
||||||
|
|
||||||
|
### Linux (`discover_user_linux.go`)
|
||||||
|
|
||||||
|
**Detection chain:**
|
||||||
|
1. **loginctl active users** (`loginctl list-users`) - systemd's session management
|
||||||
|
2. **Admin user preference** - Parse `/etc/passwd` for UID ≥ 1000, prefer sudo/wheel/admin group members
|
||||||
|
3. **Lowest UID fallback** - From `/etc/passwd`, select user with UID ≥ 1000 and lowest UID
|
||||||
|
|
||||||
|
**Rationale**: Linux systems may have multiple regular users. Prioritize users in administrative groups as they're more likely to be primary system users.
|
||||||
|
|
||||||
|
### Windows (`discover_user_windows.go`)
|
||||||
|
|
||||||
|
**Detection chain:**
|
||||||
|
1. **Active console session** (`WTSGetActiveConsoleSessionId` + `WTSQuerySessionInformation`) - Direct Windows API for active user
|
||||||
|
2. **Registry admin preference** - Scan `HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList`, prefer Administrators group members
|
||||||
|
3. **Lowest RID fallback** - From ProfileList, select user with RID ≥ 1000 and lowest RID
|
||||||
|
|
||||||
|
**Rationale**: Windows has well-defined APIs for session management. Registry ProfileList provides a complete view of all user accounts when no active session exists.
|
||||||
|
|
||||||
|
### Other Platforms (`discover_user_others.go`)
|
||||||
|
|
||||||
|
Returns `"unknown"` - placeholder for unsupported platforms.
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Individual detection methods log failures at Debug level and continue to next method
|
||||||
|
- Only final failure (all methods failed) is noteworthy
|
||||||
|
- Graceful degradation ensures the system continues operating with `"unknown"` user
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
|
||||||
|
- Registry/file parsing uses native Go where possible
|
||||||
|
- External command execution limited to necessary cases
|
||||||
|
- No network calls or blocking operations
|
||||||
|
- Timeout context honored for all operations
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- No privilege escalation required
|
||||||
|
- Read-only operations on system resources
|
||||||
|
- No user data collected beyond username
|
||||||
|
- Respects system access controls
|
||||||
|
|
||||||
|
## Testing Scenarios
|
||||||
|
|
||||||
|
This implementation addresses these common RMM scenarios:
|
||||||
|
|
||||||
|
1. **Windows Service context**: No interactive user session, service running as SYSTEM
|
||||||
|
2. **Linux systemd service**: No login session, running as root daemon
|
||||||
|
3. **macOS LaunchDaemon**: No GUI user context, running as root
|
||||||
|
4. **Multi-user systems**: Multiple valid candidates, deterministic selection
|
||||||
|
5. **Minimalist systems**: Limited user accounts, fallback to available options
|
||||||
|
|
||||||
|
## Metadata Submission Strategy
|
||||||
|
|
||||||
|
System metadata (OS, chassis, username, domain) is sent to the Control D API via POST `/utility`. To avoid duplicate submissions and minimize EDR-triggering user discovery, ctrld uses a tiered approach:
|
||||||
|
|
||||||
|
### When metadata is sent
|
||||||
|
|
||||||
|
| Scenario | Metadata sent? | Username included? |
|
||||||
|
|---|---|---|
|
||||||
|
| `ctrld start` with `--cd-org` (provisioning via `cdUIDFromProvToken`) | ✅ Full | ✅ Yes |
|
||||||
|
| `ctrld run` startup (config validation / processCDFlags) | ✅ Lightweight | ❌ No |
|
||||||
|
| Runtime config reload (`doReloadApiConfig`) | ✅ Lightweight | ❌ No |
|
||||||
|
| Runtime self-uninstall check | ✅ Lightweight | ❌ No |
|
||||||
|
| Runtime deactivation pin refresh | ✅ Lightweight | ❌ No |
|
||||||
|
|
||||||
|
Username is only collected and sent once — during initial provisioning via `cdUIDFromProvToken()`. All other API calls use `SystemMetadataRuntime()` which omits username discovery entirely.
|
||||||
|
|
||||||
|
### Runtime metadata (`SystemMetadataRuntime`)
|
||||||
|
|
||||||
|
Runtime API calls (config reload, self-uninstall check, deactivation pin refresh) use `SystemMetadataRuntime()` which includes OS and chassis info but **skips username discovery**. This avoids:
|
||||||
|
|
||||||
|
- **EDR false positives**: Repeated user enumeration (registry scans, WTS queries, loginctl calls) can trigger endpoint detection and response alerts
|
||||||
|
- **Unnecessary work**: Username is unlikely to change while the service is running
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
The previous `currentLoginUser()` function has been replaced by `DiscoverMainUser()` with these changes:
|
||||||
|
|
||||||
|
- **Removed dependencies**: No longer uses `logname(1)`, environment variables as primary detection
|
||||||
|
- **Added platform specificity**: Separate files for each OS with optimized detection logic
|
||||||
|
- **Improved RMM compatibility**: Designed specifically for service/daemon contexts
|
||||||
|
- **Maintained compatibility**: Returns same format (string username or "unknown")
|
||||||
|
|
||||||
|
## Future Extensions
|
||||||
|
|
||||||
|
This architecture allows easy addition of new platforms by creating additional `discover_user_<os>.go` files following the same interface pattern.
|
||||||
+17
-49
@@ -2,11 +2,6 @@ package ctrld
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"os/user"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/cuonglm/osinfo"
|
"github.com/cuonglm/osinfo"
|
||||||
|
|
||||||
@@ -27,8 +22,21 @@ var (
|
|||||||
chassisVendor string
|
chassisVendor string
|
||||||
)
|
)
|
||||||
|
|
||||||
// SystemMetadata collects system and user-related SystemMetadata and returns it as a map.
|
// SystemMetadata collects full system metadata including username discovery.
|
||||||
|
// Use for initial provisioning and first-run config validation where full
|
||||||
|
// device identification is needed.
|
||||||
func SystemMetadata(ctx context.Context) map[string]string {
|
func SystemMetadata(ctx context.Context) map[string]string {
|
||||||
|
return systemMetadata(ctx, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemMetadataRuntime collects system metadata without username discovery.
|
||||||
|
// Use for runtime API calls (config reload, self-uninstall check, deactivation
|
||||||
|
// pin refresh) to avoid repeated user enumeration that can trigger EDR alerts.
|
||||||
|
func SystemMetadataRuntime(ctx context.Context) map[string]string {
|
||||||
|
return systemMetadata(ctx, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func systemMetadata(ctx context.Context, includeUsername bool) map[string]string {
|
||||||
m := make(map[string]string)
|
m := make(map[string]string)
|
||||||
oi := osinfo.New()
|
oi := osinfo.New()
|
||||||
m[metadataOsKey] = oi.String()
|
m[metadataOsKey] = oi.String()
|
||||||
@@ -39,7 +47,9 @@ func SystemMetadata(ctx context.Context) map[string]string {
|
|||||||
}
|
}
|
||||||
m[metadataChassisTypeKey] = chassisType
|
m[metadataChassisTypeKey] = chassisType
|
||||||
m[metadataChassisVendorKey] = chassisVendor
|
m[metadataChassisVendorKey] = chassisVendor
|
||||||
m[metadataUsernameKey] = currentLoginUser(ctx)
|
if includeUsername {
|
||||||
|
m[metadataUsernameKey] = DiscoverMainUser(ctx)
|
||||||
|
}
|
||||||
m[metadataDomainOrWorkgroupKey] = partOfDomainOrWorkgroup(ctx)
|
m[metadataDomainOrWorkgroupKey] = partOfDomainOrWorkgroup(ctx)
|
||||||
domain, err := system.GetActiveDirectoryDomain()
|
domain, err := system.GetActiveDirectoryDomain()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -49,45 +59,3 @@ func SystemMetadata(ctx context.Context) map[string]string {
|
|||||||
|
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// currentLoginUser attempts to find the actual login user, even if the process is running as root.
|
|
||||||
func currentLoginUser(ctx context.Context) string {
|
|
||||||
// On Darwin 26.2+, sudo no longer preserves SUDO_USER, LOGNAME, USER etc., so we cannot
|
|
||||||
// rely on environment variables when running under sudo. See CVE-2025-43416.
|
|
||||||
// We use the logname(1) command on Unix, which reports the login name from the session
|
|
||||||
// (e.g. utmp); there is no portable syscall equivalent in Go, so we exec logname.
|
|
||||||
if runtime.GOOS != "windows" {
|
|
||||||
if name := runLogname(ctx); name != "" {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: env vars (still set on older systems or when not using sudo)
|
|
||||||
if u := os.Getenv("SUDO_USER"); u != "" {
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
if u := os.Getenv("LOGNAME"); u != "" {
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
if u := os.Getenv("USER"); u != "" {
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUser, err := user.Current()
|
|
||||||
if err != nil {
|
|
||||||
ProxyLogger.Load().Debug().Err(err).Msg("Failed to get current user")
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
return currentUser.Username
|
|
||||||
}
|
|
||||||
|
|
||||||
// runLogname runs the logname(1) command and returns the trimmed output, or "" on failure.
|
|
||||||
func runLogname(ctx context.Context) string {
|
|
||||||
cmd := exec.CommandContext(ctx, "logname")
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
ProxyLogger.Load().Debug().Err(err).Msg("Failed to run logname")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(out))
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user