diff --git a/pkg/deviceagent/checks/checks_linux.go b/pkg/deviceagent/checks/checks_linux.go index 50b845165..f01e60138 100644 --- a/pkg/deviceagent/checks/checks_linux.go +++ b/pkg/deviceagent/checks/checks_linux.go @@ -17,6 +17,7 @@ package checks import ( "context" "os" + "path/filepath" "strconv" "strings" ) @@ -75,31 +76,604 @@ func linuxDiskEncryption(ctx context.Context) Result { } func linuxScreenLock(ctx context.Context) Result { - if !CommandExists("gsettings") { + user := linuxConsoleUser(ctx) + desktop := linuxDesktopSession() + + probes := linuxScreenLockProbes(desktop) + + var ( + anyTool bool + lastResult Result + ) + + for _, probe := range probes { + result, tried := probe(ctx, user) + if !tried { + continue + } + + anyTool = true + switch result.Status { + case StatusPass, StatusFail: + return result + case StatusUnknown: + lastResult = result + } + } + + if !anyTool { return notApplicable( map[string]any{ - "note": "gsettings not installed (likely headless host)", + "note": "no known desktop screen lock tool found (likely headless host)", }, ) } - idle := RunCommand(ctx, "gsettings", "get", "org.gnome.desktop.screensaver", "lock-enabled") - if idle.Err != nil { + if lastResult.Status == StatusUnknown { + return lastResult + } + + return unknown( + map[string]any{ + "note": "desktop screen lock tools present but policy could not be read", + }, + ) +} + +var linuxGsettingsLockSchemas = []struct { + schema string + backend string +}{ + {"org.gnome.desktop.screensaver", "gnome"}, + {"org.cinnamon.desktop.screensaver", "cinnamon"}, + {"org.mate.screensaver", "mate"}, + {"org.ukui.screensaver", "ukui"}, +} + +func linuxScreenLockProbes(desktop string) []func(context.Context, string) (Result, bool) { + gsettings := linuxScreenLockGsettings + kde := linuxScreenLockKDE + xfce := linuxScreenLockXFCE + i3 := linuxScreenLockI3 + + switch { + case linuxDesktopPrefersKDE(desktop): + return []func(context.Context, string) (Result, bool){kde, i3, gsettings, xfce} + case linuxDesktopPrefersXFCE(desktop): + return []func(context.Context, string) (Result, bool){xfce, i3, gsettings, kde} + case linuxDesktopPrefersI3(desktop): + return []func(context.Context, string) (Result, bool){i3, gsettings, kde, xfce} + default: + return []func(context.Context, string) (Result, bool){gsettings, i3, kde, xfce} + } +} + +func linuxScreenLockGsettings(ctx context.Context, user string) (Result, bool) { + if !CommandExists("gsettings") { + return Result{}, false + } + + for _, schema := range linuxOrderGsettingsSchemas(linuxDesktopSession()) { + out := linuxRunAsUser( + ctx, + user, + "gsettings", + "get", + schema.schema, + "lock-enabled", + ) + if out.Err != nil { + continue + } + + combined := strings.ToLower(out.Stderr + "\n" + out.Stdout) + if strings.Contains(combined, "no such key") || + strings.Contains(combined, "no such schema") { + continue + } + + val := strings.TrimSpace(out.Stdout) + ev := map[string]any{ + "backend": schema.backend, + "schema": schema.schema, + "lock_enabled": val, + } + if user != "" { + ev["console_user"] = user + } + + if val == "true" { + return pass(ev), true + } + + return fail(ev), true + } + + return Result{}, false +} + +func linuxScreenLockKDE(ctx context.Context, user string) (Result, bool) { + cmd := linuxKReadConfigCommand() + if cmd == "" { + return Result{}, false + } + + out := linuxRunAsUser( + ctx, + user, + cmd, + "--file", + "kscreenlockerrc", + "--group", + "Daemon", + "--key", + "Autolock", + ) + if out.Err != nil { return unknown( map[string]any{ - "error": idle.Err.Error(), + "backend": "kde", + "error": out.Err.Error(), }, + ), true + } + + val := strings.TrimSpace(out.Stdout) + if val == "" { + return Result{}, false + } + + ev := map[string]any{ + "backend": "kde", + "autolock": val, + } + if user != "" { + ev["console_user"] = user + } + + if strings.EqualFold(val, "true") { + return pass(ev), true + } + + return fail(ev), true +} + +func linuxScreenLockXFCE(ctx context.Context, user string) (Result, bool) { + if !CommandExists("xfconf-query") { + return Result{}, false + } + + paths := []string{"/lock/enabled", "/saver/enabled"} + for _, path := range paths { + out := linuxRunAsUser( + ctx, + user, + "xfconf-query", + "-c", + "xfce4-screensaver", + "-p", + path, ) + if out.Err != nil { + continue + } + + val := strings.TrimSpace(out.Stdout) + if val == "" { + continue + } + + ev := map[string]any{ + "backend": "xfce", + "path": path, + "enabled": val, + } + if user != "" { + ev["console_user"] = user + } + + switch strings.ToLower(val) { + case "true", "1", "yes": + return pass(ev), true + case "false", "0", "no": + return fail(ev), true + } } - on := strings.TrimSpace(idle.Stdout) == "true" + return Result{}, false +} - ev := map[string]any{"lock_enabled": idle.Stdout} - if on { - return pass(ev) +func linuxScreenLockI3(ctx context.Context, user string) (Result, bool) { + configPath := linuxI3ConfigPath(ctx, user) + if configPath == "" { + return Result{}, false } - return fail(ev) + if _, err := os.Stat(configPath); err != nil { + return Result{}, false + } + + body := linuxReadI3Config(ctx, user, configPath, 0) + if body == "" { + return unknown( + map[string]any{ + "backend": "i3", + "config": configPath, + "error": "cannot read i3 config", + }, + ), true + } + + idleMinutes, locker, mechanism, ok := parseI3IdleLock(body) + ev := map[string]any{ + "backend": "i3", + "config": configPath, + } + if user != "" { + ev["console_user"] = user + } + + if !ok { + ev["note"] = "i3 config present but no idle screen lock command found" + return fail(ev), true + } + + ev["mechanism"] = mechanism + ev["locker"] = locker + if idleMinutes >= 0 { + ev["idle_minutes"] = idleMinutes + } + + return pass(ev), true +} + +func linuxI3ConfigPath(ctx context.Context, user string) string { + home := linuxUserHome(ctx, user) + if home == "" { + return "" + } + + return filepath.Join(home, ".config", "i3", "config") +} + +func linuxUserHome(ctx context.Context, user string) string { + if user == "" { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + return home + } + + if CommandExists("getent") { + out := RunCommand(ctx, "getent", "passwd", user) + if out.Err == nil { + fields := strings.Split(out.Stdout, ":") + if len(fields) >= 6 && fields[5] != "" { + return fields[5] + } + } + } + + return filepath.Join("/home", user) +} + +func linuxReadI3Config(ctx context.Context, user, path string, depth int) string { + if depth > 4 || path == "" { + return "" + } + + data, err := os.ReadFile(path) + if err != nil { + return "" + } + + body := string(data) + var merged strings.Builder + + merged.WriteString(body) + + home := linuxUserHome(ctx, user) + for line := range strings.SplitSeq(body, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + + lower := strings.ToLower(trimmed) + if !strings.HasPrefix(lower, "include ") { + continue + } + + inc := strings.TrimSpace(trimmed[len("include "):]) + inc = strings.Trim(inc, `"`) + inc = linuxExpandHome(inc, home) + if nested := linuxReadI3Config(ctx, user, inc, depth+1); nested != "" { + merged.WriteString("\n") + merged.WriteString(nested) + } + } + + return merged.String() +} + +func linuxExpandHome(path, home string) string { + if home == "" { + return path + } + + switch { + case strings.HasPrefix(path, "~/"): + return filepath.Join(home, strings.TrimPrefix(path, "~/")) + case path == "~": + return home + default: + return path + } +} + +// parseI3IdleLock scans an i3 config for idle screen lock via xautolock or xss-lock. +func parseI3IdleLock(config string) (idleMinutes int, locker, mechanism string, ok bool) { + idleMinutes = -1 + + for line := range strings.SplitSeq(config, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + + lower := strings.ToLower(trimmed) + if !strings.Contains(lower, "exec") { + continue + } + + if minutes, lockCmd, found := parseXautolockIdleLock(trimmed); found { + return minutes, lockCmd, "xautolock", true + } + + if lockCmd, found := parseXssLockIdleLock(trimmed); found { + return -1, lockCmd, "xss-lock", true + } + } + + return idleMinutes, "", "", false +} + +func parseXautolockIdleLock(line string) (minutes int, locker string, ok bool) { + if !strings.Contains(strings.ToLower(line), "xautolock") { + return 0, "", false + } + + timeValue, hasTime := linuxParseFlagValue(line, "-time") + if !hasTime { + return 0, "", false + } + + minutes, validTime := parseXautolockTime(timeValue) + if !validTime || minutes <= 0 { + return 0, "", false + } + + locker, hasLocker := linuxParseFlagValue(line, "-locker") + if !hasLocker || !linuxLooksLikeLockCommand(locker) { + return 0, "", false + } + + return minutes, locker, true +} + +func parseXssLockIdleLock(line string) (locker string, ok bool) { + if !strings.Contains(strings.ToLower(line), "xss-lock") { + return "", false + } + + idx := strings.Index(line, "--") + if idx < 0 { + return "", false + } + + locker = strings.TrimSpace(line[idx+2:]) + locker = strings.Trim(locker, `"`) + if locker == "" || !linuxLooksLikeLockCommand(locker) { + return "", false + } + + return locker, true +} + +func parseXautolockTime(value string) (minutes int, ok bool) { + value = strings.TrimSpace(value) + if strings.Contains(value, ":") { + parts := strings.Split(value, ":") + if len(parts) != 2 { + return 0, false + } + + hours, err1 := strconv.Atoi(strings.TrimSpace(parts[0])) + mins, err2 := strconv.Atoi(strings.TrimSpace(parts[1])) + if err1 != nil || err2 != nil { + return 0, false + } + + return hours*60 + mins, true + } + + minutes, err := strconv.Atoi(value) + if err != nil { + return 0, false + } + + return minutes, true +} + +func linuxParseFlagValue(line, flag string) (string, bool) { + idx := strings.Index(line, flag) + if idx < 0 { + return "", false + } + + rest := strings.TrimSpace(line[idx+len(flag):]) + if rest == "" { + return "", false + } + + if rest[0] == '"' { + end := strings.Index(rest[1:], `"`) + if end < 0 { + return "", false + } + + return rest[1 : end+1], true + } + + fields := strings.Fields(rest) + if len(fields) == 0 { + return "", false + } + + return fields[0], true +} + +func linuxLooksLikeLockCommand(cmd string) bool { + lower := strings.ToLower(cmd) + + for _, bin := range []string{ + "i3lock", + "i3lock-color", + "swaylock", + "xlock", + "slock", + "gnome-screensaver-command", + "loginctl", + } { + if strings.Contains(lower, bin) { + return true + } + } + + return false +} + +func linuxOrderGsettingsSchemas(desktop string) []struct { + schema string + backend string +} { + ordered := make([]struct { + schema string + backend string + }, 0, len(linuxGsettingsLockSchemas)) + seen := make(map[string]struct{}, len(linuxGsettingsLockSchemas)) + + preferred := "" + switch { + case strings.Contains(desktop, "cinnamon"): + preferred = "cinnamon" + case strings.Contains(desktop, "mate"): + preferred = "mate" + case strings.Contains(desktop, "ukui"): + preferred = "ukui" + default: + preferred = "gnome" + } + + for _, schema := range linuxGsettingsLockSchemas { + if schema.backend == preferred { + ordered = append(ordered, schema) + seen[schema.schema] = struct{}{} + } + } + + for _, schema := range linuxGsettingsLockSchemas { + if _, ok := seen[schema.schema]; ok { + continue + } + + ordered = append(ordered, schema) + } + + return ordered +} + +func linuxKReadConfigCommand() string { + switch { + case CommandExists("kreadconfig6"): + return "kreadconfig6" + case CommandExists("kreadconfig5"): + return "kreadconfig5" + default: + return "" + } +} + +func linuxDesktopSession() string { + for _, key := range []string{"XDG_CURRENT_DESKTOP", "DESKTOP_SESSION", "GDMSESSION"} { + if v := strings.ToLower(strings.TrimSpace(os.Getenv(key))); v != "" { + return v + } + } + + return "" +} + +func linuxDesktopPrefersKDE(desktop string) bool { + return strings.Contains(desktop, "kde") || strings.Contains(desktop, "plasma") +} + +func linuxDesktopPrefersXFCE(desktop string) bool { + return strings.Contains(desktop, "xfce") +} + +func linuxDesktopPrefersI3(desktop string) bool { + return desktop == "i3" || strings.Contains(desktop, "i3") +} + +func linuxConsoleUser(ctx context.Context) string { + if sudoUser := strings.TrimSpace(os.Getenv("SUDO_USER")); sudoUser != "" && sudoUser != "root" { + return sudoUser + } + + if CommandExists("loginctl") { + seat := RunCommand(ctx, "loginctl", "show-seat", "seat0", "-p", "ActiveSession", "--value") + sessionID := strings.TrimSpace(seat.Stdout) + if sessionID != "" { + name := RunCommand(ctx, "loginctl", "show-session", sessionID, "-p", "Name", "--value") + user := strings.TrimSpace(name.Stdout) + if user != "" && user != "root" { + return user + } + } + } + + out := RunCommand(ctx, "stat", "-c", "%U", "/dev/console") + if out.Err != nil { + return "" + } + + user := strings.TrimSpace(out.Stdout) + if user == "" || user == "root" { + return "" + } + + return user +} + +func linuxRunAsUser(ctx context.Context, user, name string, args ...string) CmdResult { + if user != "" && os.Geteuid() == 0 { + if CommandExists("runuser") { + runArgs := append([]string{"-u", user, "--", name}, args...) + + return RunCommand(ctx, "runuser", runArgs...) + } + + if CommandExists("sudo") { + runArgs := append([]string{"-u", user, "-H", name}, args...) + + return RunCommand(ctx, "sudo", runArgs...) + } + } + + return RunCommand(ctx, name, args...) } func linuxFirewall(ctx context.Context) Result { diff --git a/pkg/deviceagent/checks/checks_linux_screenlock_test.go b/pkg/deviceagent/checks/checks_linux_screenlock_test.go new file mode 100644 index 000000000..64ef0ec03 --- /dev/null +++ b/pkg/deviceagent/checks/checks_linux_screenlock_test.go @@ -0,0 +1,145 @@ +//go:build linux + +// Copyright (c) 2025-2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package checks + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLinuxOrderGsettingsSchemas(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + desktop string + wantBack []string + }{ + { + name: "cinnamon first", + desktop: "cinnamon", + wantBack: []string{"cinnamon", "gnome", "mate", "ukui"}, + }, + { + name: "mate first", + desktop: "mate", + wantBack: []string{"mate", "gnome", "cinnamon", "ukui"}, + }, + { + name: "default gnome first", + desktop: "ubuntu:gnome", + wantBack: []string{"gnome", "cinnamon", "mate", "ukui"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := linuxOrderGsettingsSchemas(tt.desktop) + require.Len(t, got, len(tt.wantBack)) + + for i, backend := range tt.wantBack { + require.Equal(t, backend, got[i].backend) + } + }) + } +} + +func TestLinuxDesktopPrefers(t *testing.T) { + t.Parallel() + + require.True(t, linuxDesktopPrefersKDE("KDE")) + require.True(t, linuxDesktopPrefersKDE("plasma")) + require.False(t, linuxDesktopPrefersKDE("gnome")) + + require.True(t, linuxDesktopPrefersXFCE("XFCE")) + require.False(t, linuxDesktopPrefersXFCE("kde")) + + require.True(t, linuxDesktopPrefersI3("i3")) + require.True(t, linuxDesktopPrefersI3("i3-wm")) + require.False(t, linuxDesktopPrefersI3("gnome")) +} + +func TestParseI3IdleLock(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config string + wantOK bool + mechanism string + idleMins int + }{ + { + name: "xautolock with i3lock", + config: `exec --no-startup-id xautolock -time 10 -locker "i3lock -c 000000"`, + wantOK: true, + mechanism: "xautolock", + idleMins: 10, + }, + { + name: "xss-lock with i3lock", + config: `exec --no-startup-id xss-lock --transfer-sleep-lock -- i3lock -c 000000`, + wantOK: true, + mechanism: "xss-lock", + idleMins: -1, + }, + { + name: "manual i3lock bind only", + config: `bindsym $mod+Shift+x exec i3lock`, + wantOK: false, + }, + { + name: "xautolock without locker", + config: `exec xautolock -time 10`, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + idleMins, _, mechanism, ok := parseI3IdleLock(tt.config) + require.Equal(t, tt.wantOK, ok) + if !tt.wantOK { + return + } + + require.Equal(t, tt.mechanism, mechanism) + require.Equal(t, tt.idleMins, idleMins) + }) + } +} + +func TestLinuxScreenLockProbesOrder(t *testing.T) { + t.Parallel() + + kdeFirst := linuxScreenLockProbes("KDE") + require.Len(t, kdeFirst, 4) + + xfceFirst := linuxScreenLockProbes("xfce") + require.Len(t, xfceFirst, 4) + + i3First := linuxScreenLockProbes("i3") + require.Len(t, i3First, 4) + + defaultFirst := linuxScreenLockProbes("ubuntu") + require.Len(t, defaultFirst, 4) +}