From 31b415b086ad9dbb021ab69727f727a6d1d2373e Mon Sep 17 00:00:00 2001 From: purian23 Date: Sat, 7 Mar 2026 20:44:19 -0500 Subject: [PATCH] feat(Greeter): Add install/uninstall/activate cli commands & new UI opts - AppArmor profile management - Introduced `dms greeter uninstall` command to remove DMS greeter configuration and restore previous display manager. - Implemented AppArmor profile installation and uninstallation for enhanced security. --- core/cmd/dms/assets/cli-policy.default.json | 1 + core/cmd/dms/commands_greeter.go | 531 +++++++++++++++++- core/cmd/dms/main.go | 11 +- core/cmd/dms/main_distro.go | 11 +- .../assets/apparmor/usr.bin.dms-greeter | 91 +++ core/internal/greeter/installer.go | 137 ++++- quickshell/Modules/Settings/GreeterTab.qml | 135 ++++- 7 files changed, 865 insertions(+), 52 deletions(-) create mode 100644 core/internal/greeter/assets/apparmor/usr.bin.dms-greeter diff --git a/core/cmd/dms/assets/cli-policy.default.json b/core/cmd/dms/assets/cli-policy.default.json index 4d265c0c..527944bf 100644 --- a/core/cmd/dms/assets/cli-policy.default.json +++ b/core/cmd/dms/assets/cli-policy.default.json @@ -4,6 +4,7 @@ "greeter install", "greeter enable", "greeter sync", + "greeter uninstall", "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 9b577d9a..10bf643c 100644 --- a/core/cmd/dms/commands_greeter.go +++ b/core/cmd/dms/commands_greeter.go @@ -1,11 +1,13 @@ package main import ( + "bufio" "fmt" "os" "os/exec" "os/user" "path/filepath" + "strconv" "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" @@ -29,7 +31,20 @@ var greeterInstallCmd = &cobra.Command{ 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 { + yes, _ := cmd.Flags().GetBool("yes") + term, _ := cmd.Flags().GetBool("terminal") + if term { + installCmd := "dms greeter install" + if yes { + installCmd += " --yes" + } + installCmd += "; echo; echo \"Install finished. Closing in 3 seconds...\"; sleep 3" + if err := runCommandInTerminal(installCmd); err != nil { + log.Fatalf("Error launching install in terminal: %v", err) + } + return + } + if err := installGreeter(yes); err != nil { log.Fatalf("Error installing greeter: %v", err) } }, @@ -70,7 +85,20 @@ var greeterEnableCmd = &cobra.Command{ Long: "Configure greetd to use DMS as the greeter", PreRunE: requireMutableSystemCommand, Run: func(cmd *cobra.Command, args []string) { - if err := enableGreeter(); err != nil { + yes, _ := cmd.Flags().GetBool("yes") + term, _ := cmd.Flags().GetBool("terminal") + if term { + enableCmd := "dms greeter enable" + if yes { + enableCmd += " --yes" + } + enableCmd += "; echo; echo \"Enable finished. Closing in 3 seconds...\"; sleep 3" + if err := runCommandInTerminal(enableCmd); err != nil { + log.Fatalf("Error launching enable in terminal: %v", err) + } + return + } + if err := enableGreeter(yes); err != nil { log.Fatalf("Error enabling greeter: %v", err) } }, @@ -87,18 +115,62 @@ var greeterStatusCmd = &cobra.Command{ }, } -func installGreeter() error { +var greeterUninstallCmd = &cobra.Command{ + Use: "uninstall", + Short: "Remove DMS greeter configuration and restore previous display manager", + Long: "Disable greetd, remove DMS managed configs, and restore the system to its pre-DMS-greeter state", + PreRunE: requireMutableSystemCommand, + Run: func(cmd *cobra.Command, args []string) { + yes, _ := cmd.Flags().GetBool("yes") + term, _ := cmd.Flags().GetBool("terminal") + if term { + uninstallCmd := "dms greeter uninstall" + if yes { + uninstallCmd += " --yes" + } + uninstallCmd += "; echo; echo \"Uninstall finished. Closing in 3 seconds...\"; sleep 3" + if err := runCommandInTerminal(uninstallCmd); err != nil { + log.Fatalf("Error launching uninstall in terminal: %v", err) + } + return + } + if err := uninstallGreeter(yes); err != nil { + log.Fatalf("Error uninstalling greeter: %v", err) + } + }, +} + +func init() { + greeterInstallCmd.Flags().BoolP("yes", "y", false, "Non-interactive: skip confirmation prompt") + greeterInstallCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)") + greeterEnableCmd.Flags().BoolP("yes", "y", false, "Non-interactive: skip confirmation prompt") + greeterEnableCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)") + greeterUninstallCmd.Flags().BoolP("yes", "y", false, "Non-interactive: skip confirmation prompt") + greeterUninstallCmd.Flags().BoolP("terminal", "t", false, "Run in a new terminal (for entering sudo password)") +} + +func installGreeter(nonInteractive bool) error { fmt.Println("=== DMS Greeter Installation ===") logFunc := func(msg string) { fmt.Println(msg) } + if !nonInteractive { + fmt.Print("\nThis will install greetd (if needed), configure the DMS greeter, and enable it. Continue? [Y/n]: ") + var response string + fmt.Scanln(&response) + if strings.ToLower(strings.TrimSpace(response)) == "n" || strings.ToLower(strings.TrimSpace(response)) == "no" { + fmt.Println("Aborted.") + return nil + } + fmt.Println() + } + if err := greeter.EnsureGreetdInstalled(logFunc, ""); err != nil { return err } - // Debian/openSUSE greeter.TryInstallGreeterPackage(logFunc, "") if isPackageOnlyGreeterDistro() && !greeter.IsGreeterPackaged() { return fmt.Errorf("dms-greeter must be installed from distro packages on this distribution. %s", packageInstallHint()) @@ -107,7 +179,6 @@ func installGreeter() error { return fmt.Errorf("legacy manual wrapper detected at /usr/local/bin/dms-greeter; remove it before using packaged dms-greeter: sudo rm -f /usr/local/bin/dms-greeter") } - // If already fully configured, prompt the user if isGreeterEnabled() { fmt.Print("\nGreeter is already installed and configured. Re-run to re-sync settings and permissions? [Y/n]: ") var response string @@ -156,8 +227,12 @@ func installGreeter() error { return err } + fmt.Println("\nConfiguring AppArmor profile...") + if err := greeter.InstallAppArmorProfile(logFunc, ""); err != nil { + logFunc(fmt.Sprintf("⚠ AppArmor profile setup failed: %v", err)) + } + fmt.Println("\nConfiguring greetd...") - // Use empty path when packaged (greeter finds /usr/share/quickshell/dms-greeter); else use user's DMS path greeterPathForConfig := "" if !greeter.IsGreeterPackaged() { greeterPathForConfig = dmsPath @@ -191,22 +266,225 @@ func installGreeter() error { return nil } -func syncInTerminal(nonInteractive bool, forceAuth bool, local bool) error { - syncFlags := make([]string, 0, 3) - if nonInteractive { - syncFlags = append(syncFlags, "--yes") +func uninstallGreeter(nonInteractive bool) error { + fmt.Println("=== DMS Greeter Uninstall ===") + + logFunc := func(msg string) { fmt.Println(msg) } + + if !isGreeterEnabled() { + fmt.Println("ℹ DMS greeter is not currently configured in /etc/greetd/config.toml.") + fmt.Println(" Nothing to undo for greetd configuration.") } - if forceAuth { - syncFlags = append(syncFlags, "--auth") + + if !nonInteractive { + fmt.Print("\nThis will:\n • Stop and disable greetd\n • Remove the DMS PAM managed block\n • Remove the DMS AppArmor profile\n • Restore the most recent pre-DMS greetd config (if available)\n\nContinue? [y/N]: ") + var response string + fmt.Scanln(&response) + if strings.ToLower(strings.TrimSpace(response)) != "y" { + fmt.Println("Aborted.") + return nil + } } - if local { - syncFlags = append(syncFlags, "--local") + + fmt.Println("\nStopping and disabling greetd...") + stopCmd := exec.Command("sudo", "systemctl", "stop", "greetd") + stopCmd.Stdout = os.Stdout + stopCmd.Stderr = os.Stderr + _ = stopCmd.Run() // not fatal — service may already be stopped + + disableCmd := exec.Command("sudo", "systemctl", "disable", "greetd") + disableCmd.Stdout = os.Stdout + disableCmd.Stderr = os.Stderr + if err := disableCmd.Run(); err != nil { + fmt.Printf(" ⚠ Could not disable greetd: %v\n", err) + } else { + fmt.Println(" ✓ greetd stopped and disabled") } - shellSyncCmd := "dms greeter sync" - if len(syncFlags) > 0 { - shellSyncCmd += " " + strings.Join(syncFlags, " ") + + fmt.Println("\nRemoving DMS PAM configuration...") + if err := greeter.RemoveGreeterPamManagedBlock(logFunc, ""); err != nil { + fmt.Printf(" ⚠ PAM cleanup failed: %v\n", err) } - shellCmd := shellSyncCmd + `; echo; echo "Sync finished. Closing in 3 seconds..."; sleep 3` + + fmt.Println("\nRemoving DMS AppArmor profile...") + if err := greeter.UninstallAppArmorProfile(logFunc, ""); err != nil { + fmt.Printf(" ⚠ AppArmor cleanup failed: %v\n", err) + } + + fmt.Println("\nRestoring greetd configuration...") + if err := restorePreDMSGreetdConfig(""); err != nil { + fmt.Printf(" ⚠ Could not restore previous greetd config: %v\n", err) + fmt.Println(" You may need to manually edit /etc/greetd/config.toml.") + } + + fmt.Println("\nChecking for other display managers to re-enable...") + suggestDisplayManagerRestore(nonInteractive) + + fmt.Println("\n=== Uninstall Complete ===") + fmt.Println("\nTo start a display manager, run e.g.:") + fmt.Println(" sudo systemctl enable --now gdm (or lightdm, sddm, etc.)") + fmt.Println("\nTo re-enable DMS greeter at any time, run: dms greeter install") + + return nil +} + +// restorePreDMSGreetdConfig finds the most recent timestamped backup of +// /etc/greetd/config.toml that does not reference dms-greeter and restores it. +// If no such backup exists, a minimal passthrough config is written so greetd +// can at least be started without error (users must configure it themselves). +func restorePreDMSGreetdConfig(sudoPassword string) error { + const configPath = "/etc/greetd/config.toml" + const backupGlob = "/etc/greetd/config.toml.backup-*" + + matches, _ := filepath.Glob(backupGlob) + + for i := 0; i < len(matches)-1; i++ { + for j := i + 1; j < len(matches); j++ { + if matches[j] > matches[i] { + matches[i], matches[j] = matches[j], matches[i] + } + } + } + + for _, candidate := range matches { + data, err := os.ReadFile(candidate) + if err != nil { + continue + } + if strings.Contains(string(data), "dms-greeter") { + continue + } + tmp, err := os.CreateTemp("", "greetd-restore-*") + if err != nil { + return fmt.Errorf("could not create temp file: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + tmp.Close() + + if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil { + return fmt.Errorf("failed to restore %s: %w", candidate, err) + } + if err := runSudoCommand(sudoPassword, "chmod", "644", configPath); err != nil { + return err + } + fmt.Printf(" ✓ Restored greetd config from %s\n", candidate) + return nil + } + + minimal := `[terminal] +vt = 1 + +# DMS greeter has been uninstalled. +# Configure a greeter command here or re-enable a display manager. +[default_session] +user = "greeter" +command = "agreety --cmd /bin/bash" +` + tmp, err := os.CreateTemp("", "greetd-minimal-*") + if err != nil { + return fmt.Errorf("could not create temp file: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if _, err := tmp.WriteString(minimal); err != nil { + tmp.Close() + return err + } + tmp.Close() + + if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil { + return fmt.Errorf("failed to write fallback greetd config: %w", err) + } + _ = runSudoCommand(sudoPassword, "chmod", "644", configPath) + fmt.Println(" ✓ Wrote minimal fallback greetd config (configure a greeter command manually if needed)") + return nil +} + +func runSudoCommand(_ string, command string, args ...string) error { + cmd := exec.Command("sudo", append([]string{command}, args...)...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// suggestDisplayManagerRestore scans for installed DMs and re-enables one +func suggestDisplayManagerRestore(nonInteractive bool) { + knownDMs := []string{"gdm", "gdm3", "lightdm", "sddm", "lxdm", "xdm", "cosmic-greeter"} + var found []string + for _, dm := range knownDMs { + if utils.CommandExists(dm) || isSystemdUnitInstalled(dm) { + found = append(found, dm) + } + } + if len(found) == 0 { + fmt.Println(" ℹ No other display managers detected.") + fmt.Println(" You can install one (e.g. gdm, lightdm, sddm) and then run:") + fmt.Println(" sudo systemctl enable --now ") + return + } + + enableDM := func(dm string) { + fmt.Printf(" Enabling %s...\n", dm) + cmd := exec.Command("sudo", "systemctl", "enable", "--force", dm) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Printf(" ⚠ Failed to enable %s: %v\n", dm, err) + } else { + fmt.Printf(" ✓ %s enabled (will take effect on next boot).\n", dm) + } + } + + if len(found) == 1 || nonInteractive { + chosen := found[0] + if len(found) > 1 { + fmt.Printf(" ℹ Multiple display managers found (%s); enabling %s automatically.\n", + strings.Join(found, ", "), chosen) + } else { + fmt.Printf(" ℹ Found display manager: %s\n", chosen) + } + enableDM(chosen) + return + } + + fmt.Println(" ℹ Found the following display managers:") + for i, dm := range found { + fmt.Printf(" %d) %s\n", i+1, dm) + } + fmt.Print(" Choose a number to re-enable (or press Enter to skip): ") + + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + return + } + input := strings.TrimSpace(scanner.Text()) + if input == "" { + fmt.Println(" Skipped. You can re-enable a display manager later with:") + fmt.Println(" sudo systemctl enable --now ") + return + } + + n, err := strconv.Atoi(input) + if err != nil || n < 1 || n > len(found) { + fmt.Printf(" Invalid selection %q — skipping.\n", input) + return + } + + enableDM(found[n-1]) +} + +func isSystemdUnitInstalled(unit string) bool { + cmd := exec.Command("systemctl", "list-unit-files", unit+".service", "--no-legend", "--no-pager") + out, err := cmd.Output() + return err == nil && strings.Contains(string(out), unit) +} + +func runCommandInTerminal(shellCmd string) error { terminals := []struct { name string args []string @@ -227,15 +505,33 @@ func syncInTerminal(nonInteractive bool, forceAuth bool, local bool) error { cmd := exec.Command(t.name, t.args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - if err := cmd.Start(); err != nil { - continue + if err := cmd.Run(); err != nil { + return err } - _ = cmd.Process.Release() return nil } return fmt.Errorf("no terminal emulator found (tried: gnome-terminal, konsole, xfce4-terminal, ghostty, wezterm, alacritty, kitty, xterm)") } +func syncInTerminal(nonInteractive bool, forceAuth bool, local bool) error { + syncFlags := make([]string, 0, 3) + if nonInteractive { + syncFlags = append(syncFlags, "--yes") + } + if forceAuth { + syncFlags = append(syncFlags, "--auth") + } + if local { + syncFlags = append(syncFlags, "--local") + } + shellSyncCmd := "dms greeter sync" + if len(syncFlags) > 0 { + shellSyncCmd += " " + strings.Join(syncFlags, " ") + } + shellCmd := shellSyncCmd + `; echo; echo "Sync finished. Closing in 3 seconds..."; sleep 3` + return runCommandInTerminal(shellCmd) +} + func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error { if !nonInteractive { fmt.Println("=== DMS Greeter Theme Sync ===") @@ -281,7 +577,7 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error { response = strings.ToLower(strings.TrimSpace(response)) if response != "n" && response != "no" { - if err := enableGreeter(); err != nil { + if err := enableGreeter(false); err != nil { return err } } else { @@ -417,6 +713,11 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error { return err } + fmt.Println("\nConfiguring AppArmor profile...") + if err := greeter.InstallAppArmorProfile(logFunc, ""); err != nil { + logFunc(fmt.Sprintf("⚠ AppArmor profile setup failed: %v", err)) + } + fmt.Println("\n=== Sync Complete ===") fmt.Println("\nYour theme, settings, and wallpaper configuration have been synced with the greeter.") if forceAuth { @@ -709,7 +1010,7 @@ func handleConflictingDisplayManagers() error { return nil } -func enableGreeter() error { +func enableGreeter(nonInteractive bool) error { fmt.Println("=== DMS Greeter Enable ===") fmt.Println() @@ -762,6 +1063,17 @@ func enableGreeter() error { return nil } + if !nonInteractive { + fmt.Print("\nThis will configure greetd to use the DMS greeter and may disable other display managers. Continue? [Y/n]: ") + var response string + fmt.Scanln(&response) + if strings.ToLower(strings.TrimSpace(response)) == "n" || strings.ToLower(strings.TrimSpace(response)) == "no" { + fmt.Println("Aborted.") + return nil + } + fmt.Println() + } + fmt.Println("Detecting installed compositors...") compositors := greeter.DetectCompositors() @@ -802,6 +1114,10 @@ func enableGreeter() error { fmt.Printf("⚠ Could not create cache directory: %v\n Run: sudo mkdir -p %s && sudo chown greeter:greeter %s\n", err, greeter.GreeterCacheDir, greeter.GreeterCacheDir) } + if err := greeter.InstallAppArmorProfile(logFunc, ""); err != nil { + logFunc(fmt.Sprintf("⚠ AppArmor profile setup failed: %v", err)) + } + if err := ensureGraphicalTarget(); err != nil { return err } @@ -1086,7 +1402,7 @@ func checkGreeterStatus() error { } } else { fmt.Println(" ✗ Greeter is NOT enabled") - fmt.Println(" Run 'dms greeter enable' to enable it") + fmt.Println(" Run 'dms greeter enable' to enable it, or use the Activate button in Settings → Greeter, then Sync.") allGood = false } } else { @@ -1253,6 +1569,47 @@ func checkGreeterStatus() error { } } + fmt.Println("\nSecurity (AppArmor):") + appArmorEnabled, appArmorErr := isAppArmorEnabled() + if appArmorErr != nil { + fmt.Printf(" ℹ Could not determine AppArmor status: %v\n", appArmorErr) + } else if !appArmorEnabled { + fmt.Println(" ℹ AppArmor not enabled") + } else { + fmt.Println(" ℹ AppArmor is enabled") + + const appArmorProfilePath = "/etc/apparmor.d/usr.bin.dms-greeter" + if _, err := os.Stat(appArmorProfilePath); os.IsNotExist(err) { + fmt.Println(" ⚠ DMS AppArmor profile not installed") + fmt.Println(" Run 'dms greeter sync' to install it and prevent potential TTY fallback") + allGood = false + } else { + mode := appArmorProfileMode("dms-greeter") + if mode != "" { + fmt.Printf(" ✓ DMS AppArmor profile installed (%s mode)\n", mode) + } else { + fmt.Println(" ✓ DMS AppArmor profile installed") + } + } + + denialCount, denialSamples, denialErr := recentAppArmorGreeterDenials(3) + if denialErr != nil { + fmt.Printf(" ℹ Could not inspect AppArmor denials automatically: %v\n", denialErr) + fmt.Println(" If greetd falls back to TTY, run: sudo journalctl -b -k | grep 'apparmor.*DENIED'") + } else if denialCount > 0 { + fmt.Printf(" ⚠ Found %d recent AppArmor denial(s) related to greeter runtime.\n", denialCount) + fmt.Println(" This can cause greetd fallback to TTY (for example: 'Failed to create stream fd: Permission denied').") + fmt.Println(" Review denials with: sudo journalctl -b -k | grep 'apparmor.*DENIED'") + fmt.Println(" Then refine the profile with: sudo aa-logprof") + for i, sample := range denialSamples { + fmt.Printf(" %d) %s\n", i+1, sample) + } + allGood = false + } else { + fmt.Println(" ✓ No recent AppArmor denials detected for common greeter components") + } + } + fmt.Println() if allGood && inGreeterGroup { fmt.Println("✓ All checks passed! Greeter is properly configured.") @@ -1264,3 +1621,129 @@ func checkGreeterStatus() error { return nil } + +func isAppArmorEnabled() (bool, error) { + data, err := os.ReadFile("/sys/module/apparmor/parameters/enabled") + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + value := strings.TrimSpace(strings.ToLower(string(data))) + return strings.HasPrefix(value, "y"), nil +} + +func recentAppArmorGreeterDenials(sampleLimit int) (int, []string, error) { + if sampleLimit <= 0 { + sampleLimit = 3 + } + if !utils.CommandExists("journalctl") { + return 0, nil, fmt.Errorf("journalctl not found") + } + + queries := [][]string{ + {"-b", "-k", "--no-pager", "-n", "2000", "-o", "cat"}, + {"-b", "--no-pager", "-n", "2000", "-o", "cat"}, + } + + seen := make(map[string]bool) + samples := make([]string, 0, sampleLimit) + total := 0 + var lastErr error + successfulQuery := false + + for _, query := range queries { + cmd := exec.Command("journalctl", query...) + output, err := cmd.CombinedOutput() + if err != nil { + lastErr = err + continue + } + successfulQuery = true + total += collectGreeterAppArmorDenials(string(output), seen, &samples, sampleLimit) + } + + if !successfulQuery && lastErr != nil { + return 0, nil, lastErr + } + + return total, samples, nil +} + +func collectGreeterAppArmorDenials(text string, seen map[string]bool, samples *[]string, sampleLimit int) int { + count := 0 + for _, rawLine := range strings.Split(text, "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || !isGreeterRelatedAppArmorDenial(line) { + continue + } + if seen[line] { + continue + } + seen[line] = true + count++ + if len(*samples) < sampleLimit { + *samples = append(*samples, line) + } + } + return count +} + +func isGreeterRelatedAppArmorDenial(line string) bool { + lower := strings.ToLower(line) + if !strings.Contains(lower, "apparmor") || !strings.Contains(lower, "denied") { + return false + } + + greeterTokens := []string{ + "dms-greeter", + "/usr/bin/dms-greeter", + "greetd", + "quickshell", + "/usr/bin/qs", + "/usr/bin/quickshell", + "niri", + "hyprland", + "sway", + "mango", + "miracle", + "labwc", + "pipewire", + "wireplumber", + "stream fd", + } + + for _, token := range greeterTokens { + if strings.Contains(lower, token) { + return true + } + } + return false +} + +// appArmorProfileMode returns "complain", "enforce", or "" (unknown) for a named AppArmor +// profile by reading /sys/kernel/security/apparmor/profiles. +func appArmorProfileMode(profileName string) string { + data, err := os.ReadFile("/sys/kernel/security/apparmor/profiles") + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if !strings.Contains(line, profileName) { + continue + } + lower := strings.ToLower(line) + if strings.Contains(lower, "(complain)") { + return "complain" + } + if strings.Contains(lower, "(enforce)") { + return "enforce" + } + if strings.Contains(lower, "(kill)") { + return "kill" + } + } + return "" +} diff --git a/core/cmd/dms/main.go b/core/cmd/dms/main.go index 6b62c070..d6fe9b8d 100644 --- a/core/cmd/dms/main.go +++ b/core/cmd/dms/main.go @@ -16,19 +16,10 @@ func init() { runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)") runCmd.Flags().MarkHidden("daemon-child") - // Add subcommands to greeter - greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd) - - // Add subcommands to setup + greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) - - // Add subcommands to update updateCmd.AddCommand(updateCheckCmd) - - // Add subcommands to plugins pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) - - // Add common commands to root rootCmd.AddCommand(getCommonCommands()...) rootCmd.AddCommand(updateCmd) diff --git a/core/cmd/dms/main_distro.go b/core/cmd/dms/main_distro.go index a67026cf..339bf40a 100644 --- a/core/cmd/dms/main_distro.go +++ b/core/cmd/dms/main_distro.go @@ -11,29 +11,20 @@ import ( var Version = "dev" func init() { - // Add flags runCmd.Flags().BoolP("daemon", "d", false, "Run in daemon mode") runCmd.Flags().Bool("daemon-child", false, "Internal flag for daemon child process") runCmd.Flags().Bool("session", false, "Session managed (like as a systemd unit)") runCmd.Flags().MarkHidden("daemon-child") - // Add subcommands to greeter - greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd) - - // Add subcommands to setup + greeterCmd.AddCommand(greeterInstallCmd, greeterSyncCmd, greeterEnableCmd, greeterStatusCmd, greeterUninstallCmd) setupCmd.AddCommand(setupBindsCmd, setupLayoutCmd, setupColorsCmd, setupAlttabCmd, setupOutputsCmd, setupCursorCmd, setupWindowrulesCmd) - - // Add subcommands to plugins pluginsCmd.AddCommand(pluginsBrowseCmd, pluginsListCmd, pluginsInstallCmd, pluginsUninstallCmd, pluginsUpdateCmd) - - // Add common commands to root rootCmd.AddCommand(getCommonCommands()...) rootCmd.SetHelpTemplate(getHelpTemplate()) } func main() { - // Block root if os.Geteuid() == 0 { log.Fatal("This program should not be run as root. Exiting.") } diff --git a/core/internal/greeter/assets/apparmor/usr.bin.dms-greeter b/core/internal/greeter/assets/apparmor/usr.bin.dms-greeter new file mode 100644 index 00000000..7c42f9ec --- /dev/null +++ b/core/internal/greeter/assets/apparmor/usr.bin.dms-greeter @@ -0,0 +1,91 @@ +# AppArmor profile for dms-greeter +# +# Managed by DMS — regenerated on every `dms greeter install` / `dms greeter sync`. +# Manual edits will be overwritten on next sync. +# +# Mode: complain (denials are logged, nothing is blocked) +# To switch to enforce after validating with `aa-logprof`: +# sudo aa-enforce /etc/apparmor.d/usr.bin.dms-greeter +# +#include + +profile dms-greeter /usr/bin/dms-greeter flags=(complain) { + #include + #include + + # The launcher script itself + /usr/bin/dms-greeter r, + + # Cache directory — created by dms greeter sync/enable with greeter:greeter ownership + /var/cache/dms-greeter/ rw, + /var/cache/dms-greeter/** rwlk, + + # DMS config — packaged path + /usr/share/quickshell/dms-greeter/ r, + /usr/share/quickshell/dms-greeter/** r, + /usr/share/quickshell/ r, + /usr/share/quickshell/** r, + + # DMS config — system and user overrides + /etc/dms/ r, + /etc/dms/** r, + /usr/share/dms/ r, + /usr/share/dms/** r, + /home/*/.config/quickshell/ r, + /home/*/.config/quickshell/** r, + /root/.config/quickshell/ r, + /root/.config/quickshell/** r, + + # greetd / PAM — read-only for session setup + /etc/greetd/ r, + /etc/greetd/** r, + /etc/pam.d/ r, + /etc/pam.d/** r, + /usr/lib/pam.d/ r, + /usr/lib/pam.d/** r, + + # Compositor binaries — run unconfined so each compositor uses its own profile + /usr/bin/niri Ux, + /usr/bin/hyprland Ux, + /usr/bin/Hyprland Ux, + /usr/bin/sway Ux, + /usr/bin/labwc Ux, + /usr/bin/scroll Ux, + /usr/bin/miracle-wm Ux, + /usr/bin/mango Ux, + + # Quickshell — run unconfined (has its own compositor profile on some distros) + /usr/bin/qs Ux, + /usr/bin/quickshell Ux, + + # Wayland / XDG runtime (pipewire, wireplumber, wayland socket) + /run/user/[0-9]*/ rw, + /run/user/[0-9]*/** rw, + + # DRM / GPU devices (required for Wayland compositor startup) + /dev/dri/ r, + /dev/dri/* rw, + /dev/udmabuf rw, + + # Input devices + /dev/input/ r, + /dev/input/* r, + + # Systemd journal / logging + /run/systemd/journal/socket rw, + /dev/log rw, + + # Shell helper binaries invoked by the launcher script + /usr/bin/env ix, + /usr/bin/mkdir ix, + /usr/bin/cat ix, + /usr/bin/grep ix, + /usr/bin/dirname ix, + /usr/bin/basename ix, + /usr/bin/command ix, + /bin/env ix, + /bin/mkdir ix, + + # Signal management (compositor lifecycle) + signal (send, receive) set=("term", "int", "hup", "kill"), +} diff --git a/core/internal/greeter/installer.go b/core/internal/greeter/installer.go index 8a269087..0e99bdf2 100644 --- a/core/internal/greeter/installer.go +++ b/core/internal/greeter/installer.go @@ -3,6 +3,7 @@ package greeter import ( "bufio" "context" + _ "embed" "encoding/json" "fmt" "os" @@ -18,6 +19,10 @@ import ( "github.com/sblinch/kdl-go/document" ) +var appArmorProfileData []byte + +const appArmorProfileDest = "/etc/apparmor.d/usr.bin.dms-greeter" + const ( GreeterCacheDir = "/var/cache/dms-greeter" @@ -527,7 +532,6 @@ func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPass return fmt.Errorf("failed to make wrapper executable: %w", err) } - // Set SELinux context on Fedora and openSUSE osInfo, err := distros.GetOSInfo() if err == nil { if config, exists := distros.Registry[osInfo.Distribution.ID]; exists && (config.Family == distros.FamilyFedora || config.Family == distros.FamilySUSE) { @@ -579,6 +583,134 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error { return nil } +// InstallAppArmorProfile writes the bundled AppArmor profile for dms-greeter and reloads +// it with apparmor_parser. It is safe to call multiple times (idempotent reload). +// +// Skipped silently when: +// - AppArmor kernel module is absent (/sys/module/apparmor does not exist) +// - Running on NixOS (profiles are managed via security.apparmor.policies) +// - SELinux is active (/sys/fs/selinux/enforce exists and equals "1") — Fedora/RHEL +func InstallAppArmorProfile(logFunc func(string), sudoPassword string) error { + if IsNixOS() { + logFunc(" ℹ Skipping AppArmor profile on NixOS (manage via security.apparmor.policies)") + return nil + } + + if _, err := os.Stat("/sys/module/apparmor"); os.IsNotExist(err) { + return nil + } + + if data, err := os.ReadFile("/sys/fs/selinux/enforce"); err == nil { + if strings.TrimSpace(string(data)) == "1" { + return nil + } + } + + if err := runSudoCmd(sudoPassword, "mkdir", "-p", "/etc/apparmor.d"); err != nil { + return fmt.Errorf("failed to create /etc/apparmor.d: %w", err) + } + + tmp, err := os.CreateTemp("", "dms-apparmor-*") + if err != nil { + return fmt.Errorf("failed to create temp file for AppArmor profile: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + if _, err := tmp.Write(appArmorProfileData); err != nil { + tmp.Close() + return fmt.Errorf("failed to write AppArmor profile: %w", err) + } + tmp.Close() + + if err := runSudoCmd(sudoPassword, "cp", tmpPath, appArmorProfileDest); err != nil { + return fmt.Errorf("failed to install AppArmor profile to %s: %w", appArmorProfileDest, err) + } + if err := runSudoCmd(sudoPassword, "chmod", "644", appArmorProfileDest); err != nil { + return fmt.Errorf("failed to set AppArmor profile permissions: %w", err) + } + + if utils.CommandExists("apparmor_parser") { + if err := runSudoCmd(sudoPassword, "apparmor_parser", "-r", appArmorProfileDest); err != nil { + logFunc(fmt.Sprintf(" ⚠ AppArmor profile installed but reload failed: %v", err)) + logFunc(" Run: sudo apparmor_parser -r " + appArmorProfileDest) + } else { + logFunc(" ✓ AppArmor profile installed and loaded (complain mode)") + } + } else { + logFunc(" ✓ AppArmor profile installed at " + appArmorProfileDest) + logFunc(" apparmor_parser not found — profile will be loaded on next boot") + } + + return nil +} + +// RemoveGreeterPamManagedBlock strips the DMS managed auth block from /etc/pam.d/greetd +func RemoveGreeterPamManagedBlock(logFunc func(string), sudoPassword string) error { + if IsNixOS() { + return nil + } + const greetdPamPath = "/etc/pam.d/greetd" + data, err := os.ReadFile(greetdPamPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("failed to read %s: %w", greetdPamPath, err) + } + + stripped, removed := stripManagedGreeterPamBlock(string(data)) + strippedAgain, removedLegacy := stripLegacyGreeterPamLines(stripped) + if !removed && !removedLegacy { + return nil + } + + tmp, err := os.CreateTemp("", "dms-pam-greetd-*") + if err != nil { + return fmt.Errorf("failed to create temp PAM file: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + if _, err := tmp.WriteString(strippedAgain); err != nil { + tmp.Close() + return fmt.Errorf("failed to write temp PAM file: %w", err) + } + tmp.Close() + + if err := runSudoCmd(sudoPassword, "cp", tmpPath, greetdPamPath); err != nil { + return fmt.Errorf("failed to write PAM config: %w", err) + } + if err := runSudoCmd(sudoPassword, "chmod", "644", greetdPamPath); err != nil { + return fmt.Errorf("failed to set PAM config permissions: %w", err) + } + logFunc(" ✓ Removed DMS managed PAM block from " + greetdPamPath) + return nil +} + +// UninstallAppArmorProfile removes the DMS AppArmor profile and reloads AppArmor. +// It is a no-op when AppArmor is not active or the profile does not exist. +func UninstallAppArmorProfile(logFunc func(string), sudoPassword string) error { + if IsNixOS() { + return nil + } + if _, err := os.Stat("/sys/module/apparmor"); os.IsNotExist(err) { + return nil + } + if _, err := os.Stat(appArmorProfileDest); os.IsNotExist(err) { + return nil + } + + if utils.CommandExists("apparmor_parser") { + _ = runSudoCmd(sudoPassword, "apparmor_parser", "--remove", appArmorProfileDest) + } + if err := runSudoCmd(sudoPassword, "rm", "-f", appArmorProfileDest); err != nil { + return fmt.Errorf("failed to remove AppArmor profile: %w", err) + } + logFunc(" ✓ Removed DMS AppArmor profile") + return nil +} + // EnsureACLInstalled installs the acl package (setfacl/getfacl) if not already present func EnsureACLInstalled(logFunc func(string), sudoPassword string) error { if utils.CommandExists("setfacl") { @@ -661,7 +793,6 @@ func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error { return nil } if !utils.CommandExists("setfacl") { - // setfacl still not found after install attempt (e.g. unsupported filesystem) logFunc("⚠ Warning: setfacl still not available after install attempt; skipping ACL setup.") return nil } @@ -723,7 +854,6 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error { group := DetectGreeterGroup() - // Check if user is already in greeter group groupsCmd := exec.Command("groups", currentUser) groupsOutput, err := groupsCmd.Output() if err == nil && strings.Contains(string(groupsOutput), group) { @@ -1273,7 +1403,6 @@ func ensureGreetdNiriConfig(logFunc func(string), sudoPassword string, niriConfi if !strings.Contains(command, "--command niri") { continue } - // Strip existing -C or --config and their arguments command = stripConfigFlag(command) command = stripCacheDirFlag(command) command = strings.TrimSpace(command + " --cache-dir " + GreeterCacheDir) diff --git a/quickshell/Modules/Settings/GreeterTab.qml b/quickshell/Modules/Settings/GreeterTab.qml index a220682e..72654836 100644 --- a/quickshell/Modules/Settings/GreeterTab.qml +++ b/quickshell/Modules/Settings/GreeterTab.qml @@ -1,9 +1,11 @@ pragma ComponentBehavior: Bound import QtQuick +import QtQuick.Layouts import Quickshell import Quickshell.Io import qs.Common +import qs.Modals.Common import qs.Modals.FileBrowser import qs.Services import qs.Widgets @@ -12,6 +14,10 @@ import qs.Modules.Settings.Widgets Item { id: root + ConfirmModal { + id: greeterActionConfirm + } + FileBrowserModal { id: greeterWallpaperBrowserModal browserTitle: I18n.tr("Select greeter background image") @@ -28,6 +34,7 @@ Item { property string greeterStatusText: "" property bool greeterStatusRunning: false property bool greeterSyncRunning: false + property bool greeterInstallActionRunning: false property string greeterStatusStdout: "" property string greeterStatusStderr: "" property string greeterSyncStdout: "" @@ -37,6 +44,31 @@ Item { property bool greeterTerminalFallbackFromPrecheck: false property var cachedFontFamilies: [] property bool fontsEnumerated: false + property bool greeterBinaryExists: false + property bool greeterEnabled: false + readonly property bool greeterInstalled: greeterBinaryExists || greeterEnabled + + readonly property string greeterActionLabel: { + if (!root.greeterInstalled) return I18n.tr("Install"); + if (!root.greeterEnabled) return I18n.tr("Activate"); + return I18n.tr("Uninstall"); + } + readonly property string greeterActionIcon: { + if (!root.greeterInstalled) return "download"; + if (!root.greeterEnabled) return "login"; + return "delete"; + } + readonly property var greeterActionCommand: { + if (!root.greeterInstalled) return ["dms", "greeter", "install", "--terminal"]; + if (!root.greeterEnabled) return ["dms", "greeter", "enable", "--terminal"]; + return ["dms", "greeter", "uninstall", "--terminal", "--yes"]; + } + property string greeterPendingAction: "" + + function checkGreeterInstallState() { + greetdEnabledCheckProcess.running = true; + greeterBinaryCheckProcess.running = true; + } function runGreeterStatus() { greeterStatusText = ""; @@ -46,6 +78,41 @@ Item { greeterStatusProcess.running = true; } + function runGreeterInstallAction() { + root.greeterPendingAction = !root.greeterInstalled ? "install" + : !root.greeterEnabled ? "activate" + : "uninstall"; + greeterStatusText = I18n.tr("Opening terminal: ") + root.greeterActionLabel + "…"; + greeterInstallActionRunning = true; + greeterInstallActionProcess.running = true; + } + + function promptGreeterActionConfirm() { + var title, message, confirmText; + if (!root.greeterInstalled) { + title = I18n.tr("Install Greeter", "greeter action confirmation"); + message = I18n.tr("Install the DMS greeter? A terminal will open for sudo authentication."); + confirmText = I18n.tr("Install"); + } else if (!root.greeterEnabled) { + title = I18n.tr("Activate Greeter", "greeter action confirmation"); + message = I18n.tr("Activate the DMS greeter? A terminal will open for sudo authentication. Run Sync after activation to apply your settings."); + confirmText = I18n.tr("Activate"); + } else { + title = I18n.tr("Uninstall Greeter", "greeter action confirmation"); + message = I18n.tr("Uninstall the DMS greeter? This will remove configuration and restore your previous display manager. A terminal will open for sudo authentication."); + confirmText = I18n.tr("Uninstall"); + } + greeterActionConfirm.showWithOptions({ + "title": title, + "message": message, + "confirmText": confirmText, + "cancelText": I18n.tr("Cancel"), + "confirmColor": Theme.primary, + "onConfirm": () => root.runGreeterInstallAction(), + "onCancel": () => {} + }); + } + function runGreeterSync() { greeterSyncStdout = ""; greeterSyncStderr = ""; @@ -82,7 +149,30 @@ Item { fontsEnumerated = true; } - Component.onCompleted: Qt.callLater(enumerateFonts) + Component.onCompleted: { + Qt.callLater(enumerateFonts); + Qt.callLater(checkGreeterInstallState); + } + + Process { + id: greetdEnabledCheckProcess + command: ["systemctl", "is-enabled", "greetd"] + running: false + + stdout: StdioCollector { + onStreamFinished: root.greeterEnabled = text.trim() === "enabled" + } + } + + Process { + id: greeterBinaryCheckProcess + command: ["sh", "-c", "test -f /usr/bin/dms-greeter || test -f /usr/local/bin/dms-greeter"] + running: false + + onExited: exitCode => { + root.greeterBinaryExists = (exitCode === 0); + } + } Process { id: greeterStatusProcess @@ -202,6 +292,29 @@ Item { } } + Process { + id: greeterInstallActionProcess + command: root.greeterActionCommand + running: false + + onExited: exitCode => { + root.greeterInstallActionRunning = false; + const pending = root.greeterPendingAction; + root.greeterPendingAction = ""; + if (exitCode === 0) { + if (pending === "install") + root.greeterStatusText = I18n.tr("Install complete. Greeter has been installed."); + else if (pending === "activate") + root.greeterStatusText = I18n.tr("Greeter activated. greetd is now enabled."); + else + root.greeterStatusText = I18n.tr("Uninstall complete. Greeter has been removed."); + } else { + root.greeterStatusText = I18n.tr("Action failed or terminal was closed.") + " (exit " + exitCode + ")"; + } + root.checkGreeterInstallState(); + } + } + readonly property var _lockDateFormatPresets: [ { format: "", @@ -293,14 +406,26 @@ Item { } } - Row { + Item { width: 1; height: Theme.spacingM } + + RowLayout { width: parent.width spacing: Theme.spacingS - topPadding: Theme.spacingM + + DankButton { + text: root.greeterActionLabel + iconName: root.greeterActionIcon + horizontalPadding: Theme.spacingL + onClicked: root.promptGreeterActionConfirm() + enabled: !root.greeterInstallActionRunning && !root.greeterSyncRunning + } + + Item { Layout.fillWidth: true } DankButton { text: I18n.tr("Refresh") iconName: "refresh" + horizontalPadding: Theme.spacingL onClicked: root.runGreeterStatus() enabled: !root.greeterStatusRunning } @@ -308,8 +433,9 @@ Item { DankButton { text: I18n.tr("Sync") iconName: "sync" + horizontalPadding: Theme.spacingL onClicked: root.runGreeterSync() - enabled: !root.greeterSyncRunning + enabled: root.greeterInstalled && !root.greeterSyncRunning && !root.greeterInstallActionRunning } } } @@ -485,6 +611,7 @@ Item { DankButton { id: browseGreeterWallpaperButton text: I18n.tr("Browse") + horizontalPadding: Theme.spacingL onClicked: greeterWallpaperBrowserModal.open() } }