Add i3, kde and more support

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-05-26 13:55:43 -07:00
parent 5c73886faf
commit c46eab5c6e
2 changed files with 729 additions and 10 deletions

View File

@@ -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 {

View File

@@ -0,0 +1,145 @@
//go:build linux
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// 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)
}