From 7156e1e2992e29ae7511ca9888d87d5bf3687fc6 Mon Sep 17 00:00:00 2001 From: purian23 Date: Thu, 5 Mar 2026 23:08:27 -0500 Subject: [PATCH] feat: Implement immutable DMS command policy - Added pre-run checks for greeter and setup commands to enforce policy restrictions - Created cli-policy.default.json to define blocked commands and user messages for immutable environments. --- core/cmd/dms/assets/cli-policy.default.json | 10 + core/cmd/dms/commands_greeter.go | 21 +- core/cmd/dms/commands_setup.go | 7 +- core/cmd/dms/immutable_policy.go | 271 ++++++++++++++++++++ core/internal/server/network/detect_test.go | 11 +- 5 files changed, 306 insertions(+), 14 deletions(-) create mode 100644 core/cmd/dms/assets/cli-policy.default.json create mode 100644 core/cmd/dms/immutable_policy.go diff --git a/core/cmd/dms/assets/cli-policy.default.json b/core/cmd/dms/assets/cli-policy.default.json new file mode 100644 index 00000000..4d265c0c --- /dev/null +++ b/core/cmd/dms/assets/cli-policy.default.json @@ -0,0 +1,10 @@ +{ + "policy_version": 1, + "blocked_commands": [ + "greeter install", + "greeter enable", + "greeter sync", + "setup" + ], + "message": "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes." +} diff --git a/core/cmd/dms/commands_greeter.go b/core/cmd/dms/commands_greeter.go index 4377dde6..9b577d9a 100644 --- a/core/cmd/dms/commands_greeter.go +++ b/core/cmd/dms/commands_greeter.go @@ -24,9 +24,10 @@ var greeterCmd = &cobra.Command{ } var greeterInstallCmd = &cobra.Command{ - Use: "install", - Short: "Install and configure DMS greeter", - Long: "Install greetd and configure it to use DMS as the greeter interface", + Use: "install", + Short: "Install and configure DMS greeter", + Long: "Install greetd and configure it to use DMS as the greeter interface", + PreRunE: requireMutableSystemCommand, Run: func(cmd *cobra.Command, args []string) { if err := installGreeter(); err != nil { log.Fatalf("Error installing greeter: %v", err) @@ -35,9 +36,10 @@ var greeterInstallCmd = &cobra.Command{ } var greeterSyncCmd = &cobra.Command{ - Use: "sync", - Short: "Sync DMS theme and settings with greeter", - Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen", + Use: "sync", + Short: "Sync DMS theme and settings with greeter", + Long: "Synchronize your current user's DMS theme, settings, and wallpaper configuration with the login greeter screen", + PreRunE: requireMutableSystemCommand, Run: func(cmd *cobra.Command, args []string) { yes, _ := cmd.Flags().GetBool("yes") auth, _ := cmd.Flags().GetBool("auth") @@ -63,9 +65,10 @@ func init() { } var greeterEnableCmd = &cobra.Command{ - Use: "enable", - Short: "Enable DMS greeter in greetd config", - Long: "Configure greetd to use DMS as the greeter", + Use: "enable", + Short: "Enable DMS greeter in greetd config", + Long: "Configure greetd to use DMS as the greeter", + PreRunE: requireMutableSystemCommand, Run: func(cmd *cobra.Command, args []string) { if err := enableGreeter(); err != nil { log.Fatalf("Error enabling greeter: %v", err) diff --git a/core/cmd/dms/commands_setup.go b/core/cmd/dms/commands_setup.go index de36882e..0f4a58d9 100644 --- a/core/cmd/dms/commands_setup.go +++ b/core/cmd/dms/commands_setup.go @@ -16,9 +16,10 @@ import ( ) var setupCmd = &cobra.Command{ - Use: "setup", - Short: "Deploy DMS configurations", - Long: "Deploy compositor and terminal configurations with interactive prompts", + Use: "setup", + Short: "Deploy DMS configurations", + Long: "Deploy compositor and terminal configurations with interactive prompts", + PersistentPreRunE: requireMutableSystemCommand, Run: func(cmd *cobra.Command, args []string) { if err := runSetup(); err != nil { log.Fatalf("Error during setup: %v", err) diff --git a/core/cmd/dms/immutable_policy.go b/core/cmd/dms/immutable_policy.go new file mode 100644 index 00000000..82eaab48 --- /dev/null +++ b/core/cmd/dms/immutable_policy.go @@ -0,0 +1,271 @@ +package main + +import ( + "bufio" + _ "embed" + "encoding/json" + "fmt" + "os" + "strings" + "sync" + + "github.com/spf13/cobra" +) + +const ( + cliPolicyPackagedPath = "/usr/share/dms/cli-policy.json" + cliPolicyAdminPath = "/etc/dms/cli-policy.json" +) + +var ( + immutablePolicyOnce sync.Once + immutablePolicy immutableCommandPolicy + immutablePolicyErr error +) + +//go:embed assets/cli-policy.default.json +var defaultCLIPolicyJSON []byte + +type immutableCommandPolicy struct { + ImmutableSystem bool + ImmutableReason string + BlockedCommands []string + Message string +} + +type cliPolicyFile struct { + PolicyVersion int `json:"policy_version"` + ImmutableSystem *bool `json:"immutable_system"` + BlockedCommands *[]string `json:"blocked_commands"` + Message *string `json:"message"` +} + +func normalizeCommandSpec(raw string) string { + normalized := strings.ToLower(strings.TrimSpace(raw)) + normalized = strings.TrimPrefix(normalized, "dms ") + return strings.Join(strings.Fields(normalized), " ") +} + +func normalizeBlockedCommands(raw []string) []string { + normalized := make([]string, 0, len(raw)) + seen := make(map[string]bool) + + for _, cmd := range raw { + spec := normalizeCommandSpec(cmd) + if spec == "" || seen[spec] { + continue + } + seen[spec] = true + normalized = append(normalized, spec) + } + + return normalized +} + +func commandBlockedByPolicy(commandPath string, blocked []string) bool { + normalizedPath := normalizeCommandSpec(commandPath) + if normalizedPath == "" { + return false + } + + for _, entry := range blocked { + spec := normalizeCommandSpec(entry) + if spec == "" { + continue + } + if normalizedPath == spec || strings.HasPrefix(normalizedPath, spec+" ") { + return true + } + } + + return false +} + +func loadPolicyFile(path string) (*cliPolicyFile, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read %s: %w", path, err) + } + + var policy cliPolicyFile + if err := json.Unmarshal(data, &policy); err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", path, err) + } + + return &policy, nil +} + +func mergePolicyFile(base *immutableCommandPolicy, path string) error { + policyFile, err := loadPolicyFile(path) + if err != nil { + return err + } + if policyFile == nil { + return nil + } + + if policyFile.ImmutableSystem != nil { + base.ImmutableSystem = *policyFile.ImmutableSystem + } + if policyFile.BlockedCommands != nil { + base.BlockedCommands = normalizeBlockedCommands(*policyFile.BlockedCommands) + } + if policyFile.Message != nil { + msg := strings.TrimSpace(*policyFile.Message) + if msg != "" { + base.Message = msg + } + } + + return nil +} + +func readOSReleaseMap(path string) map[string]string { + values := make(map[string]string) + + file, err := os.Open(path) + if err != nil { + return values + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.ToUpper(strings.TrimSpace(parts[0])) + value := strings.Trim(strings.TrimSpace(parts[1]), "\"") + values[key] = strings.ToLower(value) + } + + return values +} + +func hasAnyToken(text string, tokens ...string) bool { + if text == "" { + return false + } + for _, token := range tokens { + if strings.Contains(text, token) { + return true + } + } + return false +} + +func detectImmutableSystem() (bool, string) { + if _, err := os.Stat("/run/ostree-booted"); err == nil { + return true, "/run/ostree-booted is present" + } + + osRelease := readOSReleaseMap("/etc/os-release") + if len(osRelease) == 0 { + return false, "" + } + + id := osRelease["ID"] + idLike := osRelease["ID_LIKE"] + variantID := osRelease["VARIANT_ID"] + name := osRelease["NAME"] + prettyName := osRelease["PRETTY_NAME"] + + immutableIDs := map[string]bool{ + "bluefin": true, + "bazzite": true, + "silverblue": true, + "kinoite": true, + "sericea": true, + "onyx": true, + "aurora": true, + "fedora-iot": true, + "fedora-coreos": true, + } + if immutableIDs[id] { + return true, "os-release ID=" + id + } + + markers := []string{"silverblue", "kinoite", "sericea", "onyx", "bazzite", "bluefin", "aurora", "ostree", "atomic"} + if hasAnyToken(variantID, markers...) { + return true, "os-release VARIANT_ID=" + variantID + } + if hasAnyToken(idLike, "ostree", "rpm-ostree") { + return true, "os-release ID_LIKE=" + idLike + } + if hasAnyToken(name, markers...) || hasAnyToken(prettyName, markers...) { + return true, "os-release identifies an atomic/ostree variant" + } + + return false, "" +} + +func getImmutablePolicy() (*immutableCommandPolicy, error) { + immutablePolicyOnce.Do(func() { + detectedImmutable, reason := detectImmutableSystem() + immutablePolicy = immutableCommandPolicy{ + ImmutableSystem: detectedImmutable, + ImmutableReason: reason, + BlockedCommands: []string{"greeter install", "greeter enable", "greeter sync", "setup"}, + Message: "This command is disabled on immutable/image-based systems. Use your distro-native workflow for system-level changes.", + } + + var defaultPolicy cliPolicyFile + if err := json.Unmarshal(defaultCLIPolicyJSON, &defaultPolicy); err != nil { + immutablePolicyErr = fmt.Errorf("failed to parse embedded default CLI policy: %w", err) + return + } + if defaultPolicy.BlockedCommands != nil { + immutablePolicy.BlockedCommands = normalizeBlockedCommands(*defaultPolicy.BlockedCommands) + } + if defaultPolicy.Message != nil { + msg := strings.TrimSpace(*defaultPolicy.Message) + if msg != "" { + immutablePolicy.Message = msg + } + } + + if err := mergePolicyFile(&immutablePolicy, cliPolicyPackagedPath); err != nil { + immutablePolicyErr = err + return + } + if err := mergePolicyFile(&immutablePolicy, cliPolicyAdminPath); err != nil { + immutablePolicyErr = err + return + } + }) + + if immutablePolicyErr != nil { + return nil, immutablePolicyErr + } + return &immutablePolicy, nil +} + +func requireMutableSystemCommand(cmd *cobra.Command, _ []string) error { + policy, err := getImmutablePolicy() + if err != nil { + return err + } + if !policy.ImmutableSystem { + return nil + } + + commandPath := normalizeCommandSpec(cmd.CommandPath()) + if !commandBlockedByPolicy(commandPath, policy.BlockedCommands) { + return nil + } + + reason := "" + if policy.ImmutableReason != "" { + reason = "Detected immutable system: " + policy.ImmutableReason + "\n" + } + + return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, cliPolicyPackagedPath, cliPolicyAdminPath) +} diff --git a/core/internal/server/network/detect_test.go b/core/internal/server/network/detect_test.go index 56788604..87e7635f 100644 --- a/core/internal/server/network/detect_test.go +++ b/core/internal/server/network/detect_test.go @@ -1,6 +1,7 @@ package network import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -28,7 +29,13 @@ func TestDetectResult_HasNetworkdField(t *testing.T) { func TestDetectNetworkStack_Integration(t *testing.T) { result, err := DetectNetworkStack() + + if err != nil && strings.Contains(err.Error(), "connect system bus") { + t.Skipf("system D-Bus unavailable: %v", err) + } + assert.NoError(t, err) - assert.NotNil(t, result) - assert.NotEmpty(t, result.ChosenReason) + if assert.NotNil(t, result) { + assert.NotEmpty(t, result.ChosenReason) + } }