From c6e8067a22d4c8c95b1678e053b430102a4d333e Mon Sep 17 00:00:00 2001 From: bbedward Date: Thu, 16 Apr 2026 13:02:46 -0400 Subject: [PATCH] core: add privesc package for privilege escalation - Adds support for run0 and doas fixes #998 --- core/cmd/dms/commands_auth.go | 7 +- core/cmd/dms/commands_features.go | 16 +- core/cmd/dms/commands_greeter.go | 77 ++-- core/cmd/dms/commands_setup.go | 2 +- core/cmd/dms/immutable_policy.go | 14 + core/internal/distros/arch.go | 9 +- core/internal/distros/base.go | 24 +- core/internal/distros/debian.go | 23 +- core/internal/distros/fedora.go | 9 +- core/internal/distros/gentoo.go | 29 +- core/internal/distros/manual_packages.go | 28 +- core/internal/distros/opensuse.go | 15 +- core/internal/distros/ubuntu.go | 25 +- core/internal/greeter/installer.go | 252 ++++--------- core/internal/headless/runner.go | 45 ++- core/internal/pam/pam.go | 44 +-- core/internal/privesc/privesc.go | 385 ++++++++++++++++++++ core/internal/tui/app.go | 8 + core/internal/tui/states.go | 1 + core/internal/tui/views_dependencies.go | 11 +- core/internal/tui/views_gentoo_use_flags.go | 18 +- core/internal/tui/views_password.go | 37 +- core/internal/tui/views_privesc.go | 133 +++++++ 23 files changed, 780 insertions(+), 432 deletions(-) create mode 100644 core/internal/privesc/privesc.go create mode 100644 core/internal/tui/views_privesc.go diff --git a/core/cmd/dms/commands_auth.go b/core/cmd/dms/commands_auth.go index 727700e7..308d03be 100644 --- a/core/cmd/dms/commands_auth.go +++ b/core/cmd/dms/commands_auth.go @@ -16,9 +16,10 @@ var authCmd = &cobra.Command{ } var authSyncCmd = &cobra.Command{ - Use: "sync", - Short: "Sync DMS authentication configuration", - Long: "Apply shared PAM/authentication changes for the lock screen and greeter based on current DMS settings", + Use: "sync", + Short: "Sync DMS authentication configuration", + Long: "Apply shared PAM/authentication changes for the lock screen and greeter based on current DMS settings", + PreRunE: preRunPrivileged, Run: func(cmd *cobra.Command, args []string) { yes, _ := cmd.Flags().GetBool("yes") term, _ := cmd.Flags().GetBool("terminal") diff --git a/core/cmd/dms/commands_features.go b/core/cmd/dms/commands_features.go index 7ec54375..73a832b6 100644 --- a/core/cmd/dms/commands_features.go +++ b/core/cmd/dms/commands_features.go @@ -4,6 +4,7 @@ package main import ( "bufio" + "context" "errors" "fmt" "os" @@ -15,6 +16,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/DankMaterialShell/core/internal/version" "github.com/spf13/cobra" @@ -130,12 +132,8 @@ func updateArchLinux() error { return errdefs.ErrUpdateCancelled } - fmt.Printf("\nRunning: sudo pacman -S %s\n", packageName) - cmd := exec.Command("sudo", "pacman", "-S", "--noconfirm", packageName) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { + fmt.Printf("\nRunning: pacman -S %s\n", packageName) + if err := privesc.Run(context.Background(), "", "pacman", "-S", "--noconfirm", packageName); err != nil { fmt.Printf("Error: Failed to update using pacman: %v\n", err) return err } @@ -479,11 +477,7 @@ func updateDMSBinary() error { fmt.Printf("Installing to %s...\n", currentPath) - replaceCmd := exec.Command("sudo", "install", "-m", "0755", decompressedPath, currentPath) - replaceCmd.Stdin = os.Stdin - replaceCmd.Stdout = os.Stdout - replaceCmd.Stderr = os.Stderr - if err := replaceCmd.Run(); err != nil { + if err := privesc.Run(context.Background(), "", "install", "-m", "0755", decompressedPath, currentPath); err != nil { return fmt.Errorf("failed to replace binary: %w", err) } diff --git a/core/cmd/dms/commands_greeter.go b/core/cmd/dms/commands_greeter.go index 1efa545c..4a54bee9 100644 --- a/core/cmd/dms/commands_greeter.go +++ b/core/cmd/dms/commands_greeter.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "context" "fmt" "os" "os/exec" @@ -14,6 +15,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/greeter" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam" + "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/spf13/cobra" "golang.org/x/text/cases" @@ -35,7 +37,7 @@ 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", - PreRunE: requireMutableSystemCommand, + PreRunE: preRunPrivileged, Run: func(cmd *cobra.Command, args []string) { yes, _ := cmd.Flags().GetBool("yes") term, _ := cmd.Flags().GetBool("terminal") @@ -57,9 +59,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: preRunPrivileged, Run: func(cmd *cobra.Command, args []string) { yes, _ := cmd.Flags().GetBool("yes") auth, _ := cmd.Flags().GetBool("auth") @@ -88,7 +91,7 @@ var greeterEnableCmd = &cobra.Command{ Use: "enable", Short: "Enable DMS greeter in greetd config", Long: "Configure greetd to use DMS as the greeter", - PreRunE: requireMutableSystemCommand, + PreRunE: preRunPrivileged, Run: func(cmd *cobra.Command, args []string) { yes, _ := cmd.Flags().GetBool("yes") term, _ := cmd.Flags().GetBool("terminal") @@ -124,7 +127,7 @@ 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, + PreRunE: preRunPrivileged, Run: func(cmd *cobra.Command, args []string) { yes, _ := cmd.Flags().GetBool("yes") term, _ := cmd.Flags().GetBool("terminal") @@ -306,10 +309,7 @@ func uninstallGreeter(nonInteractive bool) error { } fmt.Println("\nDisabling greetd...") - disableCmd := exec.Command("sudo", "systemctl", "disable", "greetd") - disableCmd.Stdout = os.Stdout - disableCmd.Stderr = os.Stderr - if err := disableCmd.Run(); err != nil { + if err := privesc.Run(context.Background(), "", "systemctl", "disable", "greetd"); err != nil { fmt.Printf(" ⚠ Could not disable greetd: %v\n", err) } else { fmt.Println(" ✓ greetd disabled") @@ -375,10 +375,10 @@ func restorePreDMSGreetdConfig(sudoPassword string) error { } tmp.Close() - if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil { + if err := privesc.Run(context.Background(), 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 { + if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", configPath); err != nil { return err } fmt.Printf(" ✓ Restored greetd config from %s\n", candidate) @@ -406,21 +406,14 @@ command = "agreety --cmd /bin/bash" } tmp.Close() - if err := runSudoCommand(sudoPassword, "cp", tmpPath, configPath); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "cp", tmpPath, configPath); err != nil { return fmt.Errorf("failed to write fallback greetd config: %w", err) } - _ = runSudoCommand(sudoPassword, "chmod", "644", configPath) + _ = privesc.Run(context.Background(), 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"} @@ -439,10 +432,7 @@ func suggestDisplayManagerRestore(nonInteractive bool) { 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 { + if err := privesc.Run(context.Background(), "", "systemctl", "enable", "--force", dm); 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) @@ -641,10 +631,7 @@ func syncGreeter(nonInteractive bool, forceAuth bool, local bool) error { if response != "n" && response != "no" { fmt.Printf("\nAdding user to %s group...\n", greeterGroup) - addUserCmd := exec.Command("sudo", "usermod", "-aG", greeterGroup, currentUser.Username) - addUserCmd.Stdout = os.Stdout - addUserCmd.Stderr = os.Stderr - if err := addUserCmd.Run(); err != nil { + if err := privesc.Run(context.Background(), "", "usermod", "-aG", greeterGroup, currentUser.Username); err != nil { return fmt.Errorf("failed to add user to %s group: %w", greeterGroup, err) } fmt.Printf("✓ User added to %s group\n", greeterGroup) @@ -869,22 +856,19 @@ func disableDisplayManager(dmName string) (bool, error) { actionTaken := false if state.NeedsDisable { - var disableCmd *exec.Cmd - var actionVerb string - - if state.EnabledState == "static" { + var action, actionVerb string + switch state.EnabledState { + case "static": fmt.Printf(" Masking %s (static service cannot be disabled)...\n", dmName) - disableCmd = exec.Command("sudo", "systemctl", "mask", dmName) + action = "mask" actionVerb = "masked" - } else { + default: fmt.Printf(" Disabling %s...\n", dmName) - disableCmd = exec.Command("sudo", "systemctl", "disable", dmName) + action = "disable" actionVerb = "disabled" } - disableCmd.Stdout = os.Stdout - disableCmd.Stderr = os.Stderr - if err := disableCmd.Run(); err != nil { + if err := privesc.Run(context.Background(), "", "systemctl", action, dmName); err != nil { return actionTaken, fmt.Errorf("failed to disable/mask %s: %w", dmName, err) } @@ -925,10 +909,7 @@ func ensureGreetdEnabled() error { if state.EnabledState == "masked" || state.EnabledState == "masked-runtime" { fmt.Println(" Unmasking greetd...") - unmaskCmd := exec.Command("sudo", "systemctl", "unmask", "greetd") - unmaskCmd.Stdout = os.Stdout - unmaskCmd.Stderr = os.Stderr - if err := unmaskCmd.Run(); err != nil { + if err := privesc.Run(context.Background(), "", "systemctl", "unmask", "greetd"); err != nil { return fmt.Errorf("failed to unmask greetd: %w", err) } fmt.Println(" ✓ Unmasked greetd") @@ -940,10 +921,7 @@ func ensureGreetdEnabled() error { fmt.Println(" Enabling greetd service...") } - enableCmd := exec.Command("sudo", "systemctl", "enable", "--force", "greetd") - enableCmd.Stdout = os.Stdout - enableCmd.Stderr = os.Stderr - if err := enableCmd.Run(); err != nil { + if err := privesc.Run(context.Background(), "", "systemctl", "enable", "--force", "greetd"); err != nil { return fmt.Errorf("failed to enable greetd: %w", err) } @@ -973,10 +951,7 @@ func ensureGraphicalTarget() error { currentTargetStr := strings.TrimSpace(string(currentTarget)) if currentTargetStr != "graphical.target" { fmt.Printf("\nSetting graphical.target as default (current: %s)...\n", currentTargetStr) - setDefaultCmd := exec.Command("sudo", "systemctl", "set-default", "graphical.target") - setDefaultCmd.Stdout = os.Stdout - setDefaultCmd.Stderr = os.Stderr - if err := setDefaultCmd.Run(); err != nil { + if err := privesc.Run(context.Background(), "", "systemctl", "set-default", "graphical.target"); err != nil { fmt.Println("⚠ Warning: Failed to set graphical.target as default") fmt.Println(" Greeter may not start on boot. Run manually:") fmt.Println(" sudo systemctl set-default graphical.target") diff --git a/core/cmd/dms/commands_setup.go b/core/cmd/dms/commands_setup.go index 0f4a58d9..71292629 100644 --- a/core/cmd/dms/commands_setup.go +++ b/core/cmd/dms/commands_setup.go @@ -19,7 +19,7 @@ var setupCmd = &cobra.Command{ Use: "setup", Short: "Deploy DMS configurations", Long: "Deploy compositor and terminal configurations with interactive prompts", - PersistentPreRunE: requireMutableSystemCommand, + PersistentPreRunE: preRunPrivileged, 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 index 5529c9fb..921d797b 100644 --- a/core/cmd/dms/immutable_policy.go +++ b/core/cmd/dms/immutable_policy.go @@ -9,6 +9,7 @@ import ( "strings" "sync" + "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" "github.com/spf13/cobra" ) @@ -269,3 +270,16 @@ func requireMutableSystemCommand(cmd *cobra.Command, _ []string) error { return fmt.Errorf("%s%s\nCommand: dms %s\nPolicy files:\n %s\n %s", reason, policy.Message, commandPath, cliPolicyPackagedPath, cliPolicyAdminPath) } + +// preRunPrivileged combines the immutable-system check with a privesc tool +// selection prompt (shown only when multiple tools are available and the +// $DMS_PRIVESC env var isn't set). +func preRunPrivileged(cmd *cobra.Command, args []string) error { + if err := requireMutableSystemCommand(cmd, args); err != nil { + return err + } + if _, err := privesc.PromptCLI(os.Stdout, os.Stdin); err != nil { + return err + } + return nil +} diff --git a/core/internal/distros/arch.go b/core/internal/distros/arch.go index b412f88e..7388b339 100644 --- a/core/internal/distros/arch.go +++ b/core/internal/distros/arch.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" ) func init() { @@ -292,7 +293,7 @@ func (a *ArchDistribution) InstallPrerequisites(ctx context.Context, sudoPasswor LogOutput: "Installing base-devel development tools", } - cmd := ExecSudoCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm base-devel") + cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm base-devel") if err := a.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.10); err != nil { return fmt.Errorf("failed to install base-devel: %w", err) } @@ -463,7 +464,7 @@ func (a *ArchDistribution) preinstallQuickshellGit(ctx context.Context, sudoPass CommandInfo: "sudo pacman -Rdd --noconfirm quickshell", LogOutput: "Removing stable quickshell so quickshell-git can be installed", } - cmd := ExecSudoCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell") + cmd := privesc.ExecCommand(ctx, sudoPassword, "pacman -Rdd --noconfirm quickshell") if err := a.runWithProgress(cmd, progressChan, PhaseAURPackages, 0.15, 0.18); err != nil { return fmt.Errorf("failed to remove stable quickshell: %w", err) } @@ -501,7 +502,7 @@ func (a *ArchDistribution) installSystemPackages(ctx context.Context, packages [ CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), } - cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " ")) return a.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60) } @@ -779,7 +780,7 @@ func (a *ArchDistribution) installSingleAURPackageInternal(ctx context.Context, installArgs := []string{"pacman", "-U", "--noconfirm"} installArgs = append(installArgs, files...) - installCmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(installArgs, " ")) + installCmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(installArgs, " ")) fileNames := make([]string, len(files)) for i, f := range files { diff --git a/core/internal/distros/base.go b/core/internal/distros/base.go index 63555c89..140a68b3 100644 --- a/core/internal/distros/base.go +++ b/core/internal/distros/base.go @@ -14,6 +14,7 @@ import ( "time" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" "github.com/AvengeMedia/DankMaterialShell/core/internal/version" ) @@ -55,27 +56,6 @@ func (b *BaseDistribution) logError(message string, err error) { b.log(errorMsg) } -// escapeSingleQuotes escapes single quotes in a string for safe use in bash single-quoted strings. -// It replaces each ' with '\” which closes the quote, adds an escaped quote, and reopens the quote. -// This prevents shell injection and syntax errors when passwords contain single quotes or apostrophes. -func escapeSingleQuotes(s string) string { - return strings.ReplaceAll(s, "'", "'\\''") -} - -// MakeSudoCommand creates a command string that safely passes password to sudo. -// This helper escapes special characters in the password to prevent shell injection -// and syntax errors when passwords contain single quotes, apostrophes, or other special chars. -func MakeSudoCommand(sudoPassword string, command string) string { - return fmt.Sprintf("echo '%s' | sudo -S %s", escapeSingleQuotes(sudoPassword), command) -} - -// ExecSudoCommand creates an exec.Cmd that runs a command with sudo using the provided password. -// The password is properly escaped to prevent shell injection and syntax errors. -func ExecSudoCommand(ctx context.Context, sudoPassword string, command string) *exec.Cmd { - cmdStr := MakeSudoCommand(sudoPassword, command) - return exec.CommandContext(ctx, "bash", "-c", cmdStr) -} - func (b *BaseDistribution) detectCommand(name, description string) deps.Dependency { status := deps.StatusMissing if b.commandExists(name) { @@ -710,7 +690,7 @@ func (b *BaseDistribution) installDMSBinary(ctx context.Context, sudoPassword st } // Install to /usr/local/bin - installCmd := ExecSudoCommand(ctx, sudoPassword, + installCmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("cp %s /usr/local/bin/dms", binaryPath)) if err := installCmd.Run(); err != nil { return fmt.Errorf("failed to install DMS binary: %w", err) diff --git a/core/internal/distros/debian.go b/core/internal/distros/debian.go index a3537ff4..d5c00aa8 100644 --- a/core/internal/distros/debian.go +++ b/core/internal/distros/debian.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" ) func init() { @@ -182,7 +183,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw LogOutput: "Updating APT package lists", } - updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update") + updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update") if err := d.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil { return fmt.Errorf("failed to update package lists: %w", err) } @@ -199,7 +200,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential") if err := checkCmd.Run(); err != nil { - cmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential") + cmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential") if err := d.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil { return fmt.Errorf("failed to install build-essential: %w", err) } @@ -215,7 +216,7 @@ func (d *DebianDistribution) InstallPrerequisites(ctx context.Context, sudoPassw LogOutput: "Installing additional development tools", } - devToolsCmd := ExecSudoCommand(ctx, sudoPassword, + devToolsCmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y curl wget git cmake ninja-build pkg-config gnupg libxcb-cursor-dev libglib2.0-dev libpolkit-agent-1-dev libjpeg-dev libpugixml-dev") if err := d.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil { return fmt.Errorf("failed to install development tools: %w", err) @@ -441,7 +442,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa keyringPath := fmt.Sprintf("/etc/apt/keyrings/%s.gpg", repoName) // Create keyrings directory if it doesn't exist - mkdirCmd := ExecSudoCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings") + mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings") if err := mkdirCmd.Run(); err != nil { d.log(fmt.Sprintf("Warning: failed to create keyrings directory: %v", err)) } @@ -455,7 +456,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa } keyCmd := fmt.Sprintf("bash -c 'rm -f %s && curl -fsSL %s/Release.key | gpg --batch --dearmor -o %s'", keyringPath, baseURL, keyringPath) - cmd := ExecSudoCommand(ctx, sudoPassword, keyCmd) + cmd := privesc.ExecCommand(ctx, sudoPassword, keyCmd) if err := d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.18, 0.20); err != nil { return fmt.Errorf("failed to add OBS GPG key for %s: %w", pkg.RepoURL, err) } @@ -471,7 +472,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa CommandInfo: fmt.Sprintf("echo '%s' | sudo tee %s", repoLine, listFile), } - addRepoCmd := ExecSudoCommand(ctx, sudoPassword, + addRepoCmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("bash -c \"echo '%s' | tee %s\"", repoLine, listFile)) if err := d.runWithProgress(addRepoCmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil { return fmt.Errorf("failed to add OBS repo %s: %w", pkg.RepoURL, err) @@ -491,7 +492,7 @@ func (d *DebianDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Packa CommandInfo: "sudo apt-get update", } - updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update") + updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update") if err := d.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil { return fmt.Errorf("failed to update package lists after adding OBS repos: %w", err) } @@ -537,7 +538,7 @@ func (d *DebianDistribution) installAPTPackages(ctx context.Context, packages [] CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), } - cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " ")) return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, startProgress, endProgress) } @@ -625,7 +626,7 @@ func (d *DebianDistribution) installBuildDependencies(ctx context.Context, manua args := []string{"apt-get", "install", "-y"} args = append(args, depList...) - cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " ")) return d.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82) } @@ -643,7 +644,7 @@ func (d *DebianDistribution) installRust(ctx context.Context, sudoPassword strin CommandInfo: "sudo apt-get install rustup", } - rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y rustup") + rustupInstallCmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y rustup") if err := d.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil { return fmt.Errorf("failed to install rustup: %w", err) } @@ -682,7 +683,7 @@ func (d *DebianDistribution) installGo(ctx context.Context, sudoPassword string, CommandInfo: "sudo apt-get install golang-go", } - installCmd := ExecSudoCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y golang-go") + installCmd := privesc.ExecCommand(ctx, sudoPassword, "DEBIAN_FRONTEND=noninteractive apt-get install -y golang-go") return d.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.87, 0.90) } diff --git a/core/internal/distros/fedora.go b/core/internal/distros/fedora.go index 4c16d4ea..f3c440d9 100644 --- a/core/internal/distros/fedora.go +++ b/core/internal/distros/fedora.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" ) func init() { @@ -254,7 +255,7 @@ func (f *FedoraDistribution) InstallPrerequisites(ctx context.Context, sudoPassw args := []string{"dnf", "install", "-y"} args = append(args, missingPkgs...) - cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " ")) output, err := cmd.CombinedOutput() if err != nil { f.logError("failed to install prerequisites", err) @@ -437,7 +438,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac CommandInfo: fmt.Sprintf("sudo dnf copr enable -y %s", pkg.RepoURL), } - cmd := ExecSudoCommand(ctx, sudoPassword, + cmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("dnf copr enable -y %s 2>&1", pkg.RepoURL)) output, err := cmd.CombinedOutput() if err != nil { @@ -461,7 +462,7 @@ func (f *FedoraDistribution) enableCOPRRepos(ctx context.Context, coprPkgs []Pac CommandInfo: fmt.Sprintf("echo \"priority=1\" | sudo tee -a %s", repoFile), } - priorityCmd := ExecSudoCommand(ctx, sudoPassword, + priorityCmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("bash -c 'echo \"priority=1\" | tee -a %s'", repoFile)) priorityOutput, err := priorityCmd.CombinedOutput() if err != nil { @@ -537,7 +538,7 @@ func (f *FedoraDistribution) installDNFGroups(ctx context.Context, packages []st CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), } - cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " ")) return f.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd) } diff --git a/core/internal/distros/gentoo.go b/core/internal/distros/gentoo.go index e0a2a9c2..8e97ccf6 100644 --- a/core/internal/distros/gentoo.go +++ b/core/internal/distros/gentoo.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" ) var GentooGlobalUseFlags = []string{ @@ -201,9 +202,9 @@ func (g *GentooDistribution) setGlobalUseFlags(ctx context.Context, sudoPassword var cmd *exec.Cmd if hasUse { - cmd = ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("sed -i 's/^USE=\"\\(.*\\)\"/USE=\"\\1 %s\"/' /etc/portage/make.conf", useFlags)) + cmd = privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("sed -i 's/^USE=\"\\(.*\\)\"/USE=\"\\1 %s\"/' /etc/portage/make.conf", useFlags)) } else { - cmd = ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("bash -c \"echo 'USE=\\\"%s\\\"' >> /etc/portage/make.conf\"", useFlags)) + cmd = privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("bash -c \"echo 'USE=\\\"%s\\\"' >> /etc/portage/make.conf\"", useFlags)) } output, err := cmd.CombinedOutput() @@ -281,7 +282,7 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw LogOutput: "Syncing Portage tree with emerge --sync", } - syncCmd := ExecSudoCommand(ctx, sudoPassword, "emerge --sync --quiet") + syncCmd := privesc.ExecCommand(ctx, sudoPassword, "emerge --sync --quiet") syncOutput, syncErr := syncCmd.CombinedOutput() if syncErr != nil { g.log(fmt.Sprintf("emerge --sync output: %s", string(syncOutput))) @@ -302,7 +303,7 @@ func (g *GentooDistribution) InstallPrerequisites(ctx context.Context, sudoPassw args := []string{"emerge", "--ask=n", "--quiet"} args = append(args, missingPkgs...) - cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " ")) output, err := cmd.CombinedOutput() if err != nil { g.logError("failed to install prerequisites", err) @@ -503,14 +504,14 @@ func (g *GentooDistribution) installPortagePackages(ctx context.Context, package CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), } - cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " ")) return g.runWithProgressTimeout(cmd, progressChan, PhaseSystemPackages, 0.40, 0.60, 0) } func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName, useFlags, sudoPassword string) error { packageUseDir := "/etc/portage/package.use" - mkdirCmd := ExecSudoCommand(ctx, sudoPassword, + mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("mkdir -p %s", packageUseDir)) if output, err := mkdirCmd.CombinedOutput(); err != nil { g.log(fmt.Sprintf("mkdir output: %s", string(output))) @@ -524,7 +525,7 @@ func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName if checkExistingCmd.Run() == nil { g.log(fmt.Sprintf("Updating USE flags for %s from existing entry", packageName)) escapedPkg := strings.ReplaceAll(packageName, "/", "\\/") - replaceCmd := ExecSudoCommand(ctx, sudoPassword, + replaceCmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, packageUseDir)) if output, err := replaceCmd.CombinedOutput(); err != nil { g.log(fmt.Sprintf("sed delete output: %s", string(output))) @@ -532,7 +533,7 @@ func (g *GentooDistribution) setPackageUseFlags(ctx context.Context, packageName } } - appendCmd := ExecSudoCommand(ctx, sudoPassword, + appendCmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", useFlagLine, packageUseDir)) output, err := appendCmd.CombinedOutput() @@ -557,7 +558,7 @@ func (g *GentooDistribution) syncGURURepo(ctx context.Context, sudoPassword stri } // Enable GURU repository - enableCmd := ExecSudoCommand(ctx, sudoPassword, + enableCmd := privesc.ExecCommand(ctx, sudoPassword, "eselect repository enable guru 2>&1; exit_code=$?; exit $exit_code") output, err := enableCmd.CombinedOutput() @@ -589,7 +590,7 @@ func (g *GentooDistribution) syncGURURepo(ctx context.Context, sudoPassword stri LogOutput: "Syncing GURU repository", } - syncCmd := ExecSudoCommand(ctx, sudoPassword, + syncCmd := privesc.ExecCommand(ctx, sudoPassword, "emaint sync --repo guru 2>&1; exit_code=$?; exit $exit_code") syncOutput, syncErr := syncCmd.CombinedOutput() @@ -622,7 +623,7 @@ func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packa acceptKeywordsDir := "/etc/portage/package.accept_keywords" - mkdirCmd := ExecSudoCommand(ctx, sudoPassword, + mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("mkdir -p %s", acceptKeywordsDir)) if output, err := mkdirCmd.CombinedOutput(); err != nil { g.log(fmt.Sprintf("mkdir output: %s", string(output))) @@ -636,7 +637,7 @@ func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packa if checkExistingCmd.Run() == nil { g.log(fmt.Sprintf("Updating accept keywords for %s from existing entry", packageName)) escapedPkg := strings.ReplaceAll(packageName, "/", "\\/") - replaceCmd := ExecSudoCommand(ctx, sudoPassword, + replaceCmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("sed -i '/^%s /d' %s/danklinux; exit_code=$?; exit $exit_code", escapedPkg, acceptKeywordsDir)) if output, err := replaceCmd.CombinedOutput(); err != nil { g.log(fmt.Sprintf("sed delete output: %s", string(output))) @@ -644,7 +645,7 @@ func (g *GentooDistribution) setPackageAcceptKeywords(ctx context.Context, packa } } - appendCmd := ExecSudoCommand(ctx, sudoPassword, + appendCmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("bash -c \"echo '%s' >> %s/danklinux\"", keywordLine, acceptKeywordsDir)) output, err := appendCmd.CombinedOutput() @@ -695,6 +696,6 @@ func (g *GentooDistribution) installGURUPackages(ctx context.Context, packages [ CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), } - cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " ")) return g.runWithProgressTimeout(cmd, progressChan, PhaseAURPackages, 0.70, 0.85, 0) } diff --git a/core/internal/distros/manual_packages.go b/core/internal/distros/manual_packages.go index 2f38161a..e548f593 100644 --- a/core/internal/distros/manual_packages.go +++ b/core/internal/distros/manual_packages.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" ) // ManualPackageInstaller provides methods for installing packages from source @@ -143,7 +144,7 @@ func (m *ManualPackageInstaller) installDgop(ctx context.Context, sudoPassword s CommandInfo: "sudo make install", } - installCmd := ExecSudoCommand(ctx, sudoPassword, "make install") + installCmd := privesc.ExecCommand(ctx, sudoPassword, "make install") installCmd.Dir = tmpDir if err := installCmd.Run(); err != nil { m.logError("failed to install dgop", err) @@ -213,7 +214,7 @@ func (m *ManualPackageInstaller) installNiri(ctx context.Context, sudoPassword s CommandInfo: "dpkg -i niri.deb", } - installDebCmd := ExecSudoCommand(ctx, sudoPassword, + installDebCmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("dpkg -i %s/target/debian/niri_*.deb", buildDir)) output, err := installDebCmd.CombinedOutput() @@ -324,7 +325,7 @@ func (m *ManualPackageInstaller) installQuickshell(ctx context.Context, variant CommandInfo: "sudo cmake --install build", } - installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build") + installCmd := privesc.ExecCommand(ctx, sudoPassword, "cmake --install build") installCmd.Dir = tmpDir if err := installCmd.Run(); err != nil { return fmt.Errorf("failed to install quickshell: %w", err) @@ -387,7 +388,7 @@ func (m *ManualPackageInstaller) installHyprland(ctx context.Context, sudoPasswo CommandInfo: "sudo make install", } - installCmd := ExecSudoCommand(ctx, sudoPassword, "make install") + installCmd := privesc.ExecCommand(ctx, sudoPassword, "make install") installCmd.Dir = tmpDir if err := installCmd.Run(); err != nil { return fmt.Errorf("failed to install Hyprland: %w", err) @@ -453,7 +454,7 @@ func (m *ManualPackageInstaller) installGhostty(ctx context.Context, sudoPasswor CommandInfo: "sudo cp zig-out/bin/ghostty /usr/local/bin/", } - installCmd := ExecSudoCommand(ctx, sudoPassword, + installCmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("cp %s/zig-out/bin/ghostty /usr/local/bin/", tmpDir)) if err := installCmd.Run(); err != nil { return fmt.Errorf("failed to install Ghostty: %w", err) @@ -492,16 +493,11 @@ func (m *ManualPackageInstaller) installMatugen(ctx context.Context, sudoPasswor CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath), } - copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath) - copyCmd.Stdin = strings.NewReader(sudoPassword + "\n") - if err := copyCmd.Run(); err != nil { + if err := privesc.Run(ctx, sudoPassword, "cp", sourcePath, targetPath); err != nil { return fmt.Errorf("failed to copy matugen to /usr/local/bin: %w", err) } - // Make it executable - chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath) - chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n") - if err := chmodCmd.Run(); err != nil { + if err := privesc.Run(ctx, sudoPassword, "chmod", "+x", targetPath); err != nil { return fmt.Errorf("failed to make matugen executable: %w", err) } @@ -646,15 +642,11 @@ func (m *ManualPackageInstaller) installXwaylandSatellite(ctx context.Context, s CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath), } - copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath) - copyCmd.Stdin = strings.NewReader(sudoPassword + "\n") - if err := copyCmd.Run(); err != nil { + if err := privesc.Run(ctx, sudoPassword, "cp", sourcePath, targetPath); err != nil { return fmt.Errorf("failed to copy xwayland-satellite to /usr/local/bin: %w", err) } - chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath) - chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n") - if err := chmodCmd.Run(); err != nil { + if err := privesc.Run(ctx, sudoPassword, "chmod", "+x", targetPath); err != nil { return fmt.Errorf("failed to make xwayland-satellite executable: %w", err) } diff --git a/core/internal/distros/opensuse.go b/core/internal/distros/opensuse.go index 62f40cd8..a9b29068 100644 --- a/core/internal/distros/opensuse.go +++ b/core/internal/distros/opensuse.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" ) func init() { @@ -250,7 +251,7 @@ func (o *OpenSUSEDistribution) InstallPrerequisites(ctx context.Context, sudoPas args := []string{"zypper", "install", "-y"} args = append(args, missingPkgs...) - cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " ")) output, err := cmd.CombinedOutput() if err != nil { o.logError("failed to install prerequisites", err) @@ -486,7 +487,7 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac CommandInfo: fmt.Sprintf("sudo zypper addrepo %s", repoURL), } - cmd := ExecSudoCommand(ctx, sudoPassword, + cmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("zypper addrepo -f %s", repoURL)) if err := o.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil { o.log(fmt.Sprintf("OBS repo %s add failed (may already exist): %v", pkg.RepoURL, err)) @@ -507,7 +508,7 @@ func (o *OpenSUSEDistribution) enableOBSRepos(ctx context.Context, obsPkgs []Pac CommandInfo: "sudo zypper --gpg-auto-import-keys refresh", } - refreshCmd := ExecSudoCommand(ctx, sudoPassword, "zypper --gpg-auto-import-keys refresh") + refreshCmd := privesc.ExecCommand(ctx, sudoPassword, "zypper --gpg-auto-import-keys refresh") if err := o.runWithProgress(refreshCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil { return fmt.Errorf("failed to refresh repositories: %w", err) } @@ -588,7 +589,7 @@ func (o *OpenSUSEDistribution) disableInstallMediaRepos(ctx context.Context, sud } for _, alias := range aliases { - cmd := ExecSudoCommand(ctx, sudoPassword, fmt.Sprintf("zypper modifyrepo -d '%s'", escapeSingleQuotes(alias))) + cmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("zypper modifyrepo -d '%s'", privesc.EscapeSingleQuotes(alias))) repoOutput, err := cmd.CombinedOutput() if err != nil { o.log(fmt.Sprintf("Failed to disable install media repo %s: %s", alias, strings.TrimSpace(string(repoOutput)))) @@ -646,7 +647,7 @@ func (o *OpenSUSEDistribution) installZypperPackages(ctx context.Context, packag CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), } - cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " ")) return o.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd) } @@ -774,7 +775,7 @@ func (o *OpenSUSEDistribution) installQuickshell(ctx context.Context, variant de CommandInfo: "sudo cmake --install build", } - installCmd := ExecSudoCommand(ctx, sudoPassword, "cmake --install build") + installCmd := privesc.ExecCommand(ctx, sudoPassword, "cmake --install build") installCmd.Dir = tmpDir if err := installCmd.Run(); err != nil { return fmt.Errorf("failed to install quickshell: %w", err) @@ -798,7 +799,7 @@ func (o *OpenSUSEDistribution) installRust(ctx context.Context, sudoPassword str CommandInfo: "sudo zypper install rustup", } - rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "zypper install -y rustup") + rustupInstallCmd := privesc.ExecCommand(ctx, sudoPassword, "zypper install -y rustup") if err := o.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil { return fmt.Errorf("failed to install rustup: %w", err) } diff --git a/core/internal/distros/ubuntu.go b/core/internal/distros/ubuntu.go index 37911f04..d99ec6b1 100644 --- a/core/internal/distros/ubuntu.go +++ b/core/internal/distros/ubuntu.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" + "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" ) func init() { @@ -177,7 +178,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw LogOutput: "Updating APT package lists", } - updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update") + updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update") if err := u.runWithProgress(updateCmd, progressChan, PhasePrerequisites, 0.06, 0.07); err != nil { return fmt.Errorf("failed to update package lists: %w", err) } @@ -195,7 +196,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw checkCmd := exec.CommandContext(ctx, "dpkg", "-l", "build-essential") if err := checkCmd.Run(); err != nil { // Not installed, install it - cmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y build-essential") + cmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y build-essential") if err := u.runWithProgress(cmd, progressChan, PhasePrerequisites, 0.08, 0.09); err != nil { return fmt.Errorf("failed to install build-essential: %w", err) } @@ -211,7 +212,7 @@ func (u *UbuntuDistribution) InstallPrerequisites(ctx context.Context, sudoPassw LogOutput: "Installing additional development tools", } - devToolsCmd := ExecSudoCommand(ctx, sudoPassword, + devToolsCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y curl wget git cmake ninja-build pkg-config libglib2.0-dev libpolkit-agent-1-dev") if err := u.runWithProgress(devToolsCmd, progressChan, PhasePrerequisites, 0.10, 0.12); err != nil { return fmt.Errorf("failed to install development tools: %w", err) @@ -398,7 +399,7 @@ func (u *UbuntuDistribution) extractPackageNames(packages []PackageMapping) []st func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []PackageMapping, sudoPassword string, progressChan chan<- InstallProgressMsg) error { enabledRepos := make(map[string]bool) - installPPACmd := ExecSudoCommand(ctx, sudoPassword, + installPPACmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y software-properties-common") if err := u.runWithProgress(installPPACmd, progressChan, PhaseSystemPackages, 0.15, 0.17); err != nil { return fmt.Errorf("failed to install software-properties-common: %w", err) @@ -416,7 +417,7 @@ func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []Packa CommandInfo: fmt.Sprintf("sudo add-apt-repository -y %s", pkg.RepoURL), } - cmd := ExecSudoCommand(ctx, sudoPassword, + cmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("add-apt-repository -y %s", pkg.RepoURL)) if err := u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.20, 0.22); err != nil { u.logError(fmt.Sprintf("failed to enable PPA repo %s", pkg.RepoURL), err) @@ -437,7 +438,7 @@ func (u *UbuntuDistribution) enablePPARepos(ctx context.Context, ppaPkgs []Packa CommandInfo: "sudo apt-get update", } - updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update") + updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update") if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.25, 0.27); err != nil { return fmt.Errorf("failed to update package lists after adding PPAs: %w", err) } @@ -504,7 +505,7 @@ func (u *UbuntuDistribution) installAPTGroups(ctx context.Context, packages []st CommandInfo: fmt.Sprintf("sudo %s", strings.Join(args, " ")), } - cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " ")) return u.runWithProgress(cmd, progressChan, phase, groupStart, groupEnd) } @@ -591,7 +592,7 @@ func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manua args := []string{"apt-get", "install", "-y"} args = append(args, depList...) - cmd := ExecSudoCommand(ctx, sudoPassword, strings.Join(args, " ")) + cmd := privesc.ExecCommand(ctx, sudoPassword, strings.Join(args, " ")) return u.runWithProgress(cmd, progressChan, PhaseSystemPackages, 0.80, 0.82) } @@ -609,7 +610,7 @@ func (u *UbuntuDistribution) installRust(ctx context.Context, sudoPassword strin CommandInfo: "sudo apt-get install rustup", } - rustupInstallCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y rustup") + rustupInstallCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y rustup") if err := u.runWithProgress(rustupInstallCmd, progressChan, PhaseSystemPackages, 0.82, 0.83); err != nil { return fmt.Errorf("failed to install rustup: %w", err) } @@ -649,7 +650,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string, CommandInfo: "sudo add-apt-repository ppa:longsleep/golang-backports", } - addPPACmd := ExecSudoCommand(ctx, sudoPassword, + addPPACmd := privesc.ExecCommand(ctx, sudoPassword, "add-apt-repository -y ppa:longsleep/golang-backports") if err := u.runWithProgress(addPPACmd, progressChan, PhaseSystemPackages, 0.87, 0.88); err != nil { return fmt.Errorf("failed to add Go PPA: %w", err) @@ -664,7 +665,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string, CommandInfo: "sudo apt-get update", } - updateCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get update") + updateCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get update") if err := u.runWithProgress(updateCmd, progressChan, PhaseSystemPackages, 0.88, 0.89); err != nil { return fmt.Errorf("failed to update package lists after adding Go PPA: %w", err) } @@ -678,7 +679,7 @@ func (u *UbuntuDistribution) installGo(ctx context.Context, sudoPassword string, CommandInfo: "sudo apt-get install golang-go", } - installCmd := ExecSudoCommand(ctx, sudoPassword, "apt-get install -y golang-go") + installCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y golang-go") return u.runWithProgress(installCmd, progressChan, PhaseSystemPackages, 0.89, 0.90) } diff --git a/core/internal/greeter/installer.go b/core/internal/greeter/installer.go index ace72ce2..a95f388a 100644 --- a/core/internal/greeter/installer.go +++ b/core/internal/greeter/installer.go @@ -17,6 +17,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/matugen" sharedpam "github.com/AvengeMedia/DankMaterialShell/core/internal/pam" + "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/sblinch/kdl-go" "github.com/sblinch/kdl-go/document" @@ -327,56 +328,17 @@ func EnsureGreetdInstalled(logFunc func(string), sudoPassword string) error { switch config.Family { case distros.FamilyArch: - if sudoPassword != "" { - installCmd = distros.ExecSudoCommand(ctx, sudoPassword, - "pacman -S --needed --noconfirm greetd") - } else { - installCmd = exec.CommandContext(ctx, "sudo", "pacman", "-S", "--needed", "--noconfirm", "greetd") - } - + installCmd = privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm greetd") case distros.FamilyFedora: - if sudoPassword != "" { - installCmd = distros.ExecSudoCommand(ctx, sudoPassword, - "dnf install -y greetd") - } else { - installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "greetd") - } - + installCmd = privesc.ExecCommand(ctx, sudoPassword, "dnf install -y greetd") case distros.FamilySUSE: - if sudoPassword != "" { - installCmd = distros.ExecSudoCommand(ctx, sudoPassword, - "zypper install -y greetd") - } else { - installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "greetd") - } - - case distros.FamilyUbuntu: - if sudoPassword != "" { - installCmd = distros.ExecSudoCommand(ctx, sudoPassword, - "apt-get install -y greetd") - } else { - installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "greetd") - } - - case distros.FamilyDebian: - if sudoPassword != "" { - installCmd = distros.ExecSudoCommand(ctx, sudoPassword, - "apt-get install -y greetd") - } else { - installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "greetd") - } - + installCmd = privesc.ExecCommand(ctx, sudoPassword, "zypper install -y greetd") + case distros.FamilyUbuntu, distros.FamilyDebian: + installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y greetd") case distros.FamilyGentoo: - if sudoPassword != "" { - installCmd = distros.ExecSudoCommand(ctx, sudoPassword, - "emerge --ask n sys-apps/greetd") - } else { - installCmd = exec.CommandContext(ctx, "sudo", "emerge", "--ask", "n", "sys-apps/greetd") - } - + installCmd = privesc.ExecCommand(ctx, sudoPassword, "emerge --ask n sys-apps/greetd") case distros.FamilyNix: return fmt.Errorf("on NixOS, please add greetd to your configuration.nix") - default: return fmt.Errorf("unsupported distribution family for automatic greetd installation: %s", config.Family) } @@ -437,56 +399,56 @@ func TryInstallGreeterPackage(logFunc func(string), sudoPassword string) bool { logFunc(fmt.Sprintf("Adding DankLinux OBS repository (%s)...", obsSlug)) if _, err := exec.LookPath("gpg"); err != nil { logFunc("Installing gnupg for OBS repository key import...") - installGPGCmd := exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "gnupg") + installGPGCmd := privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y gnupg") installGPGCmd.Stdout = os.Stdout installGPGCmd.Stderr = os.Stderr if err := installGPGCmd.Run(); err != nil { logFunc(fmt.Sprintf("⚠ Failed to install gnupg: %v", err)) } } - mkdirCmd := exec.CommandContext(ctx, "sudo", "mkdir", "-p", "/etc/apt/keyrings") + mkdirCmd := privesc.ExecCommand(ctx, sudoPassword, "mkdir -p /etc/apt/keyrings") mkdirCmd.Stdout = os.Stdout mkdirCmd.Stderr = os.Stderr mkdirCmd.Run() - addKeyCmd := exec.CommandContext(ctx, "bash", "-c", - fmt.Sprintf(`curl -fsSL %s | sudo gpg --dearmor -o /etc/apt/keyrings/danklinux.gpg`, keyURL)) + addKeyCmd := privesc.ExecCommand(ctx, sudoPassword, + fmt.Sprintf(`bash -c "curl -fsSL %s | gpg --dearmor -o /etc/apt/keyrings/danklinux.gpg"`, keyURL)) addKeyCmd.Stdout = os.Stdout addKeyCmd.Stderr = os.Stderr addKeyCmd.Run() - addRepoCmd := exec.CommandContext(ctx, "bash", "-c", - fmt.Sprintf(`echo '%s' | sudo tee /etc/apt/sources.list.d/danklinux.list`, repoLine)) + addRepoCmd := privesc.ExecCommand(ctx, sudoPassword, + fmt.Sprintf(`bash -c "echo '%s' > /etc/apt/sources.list.d/danklinux.list"`, repoLine)) addRepoCmd.Stdout = os.Stdout addRepoCmd.Stderr = os.Stderr addRepoCmd.Run() - exec.CommandContext(ctx, "sudo", "apt-get", "update").Run() - installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "dms-greeter") + privesc.ExecCommand(ctx, sudoPassword, "apt-get update").Run() + installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y dms-greeter") case distros.FamilySUSE: repoURL := getOpenSUSEOBSRepoURL(osInfo) failHint = fmt.Sprintf("⚠ dms-greeter install failed. Add OBS repo manually:\nsudo zypper addrepo %s\nsudo zypper refresh && sudo zypper install dms-greeter", repoURL) logFunc("Adding DankLinux OBS repository...") - addRepoCmd := exec.CommandContext(ctx, "sudo", "zypper", "addrepo", repoURL) + addRepoCmd := privesc.ExecCommand(ctx, sudoPassword, fmt.Sprintf("zypper addrepo %s", repoURL)) addRepoCmd.Stdout = os.Stdout addRepoCmd.Stderr = os.Stderr addRepoCmd.Run() - exec.CommandContext(ctx, "sudo", "zypper", "refresh").Run() - installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "dms-greeter") + privesc.ExecCommand(ctx, sudoPassword, "zypper refresh").Run() + installCmd = privesc.ExecCommand(ctx, sudoPassword, "zypper install -y dms-greeter") case distros.FamilyUbuntu: failHint = "⚠ dms-greeter install failed. Add PPA manually: sudo add-apt-repository ppa:avengemedia/danklinux && sudo apt-get update && sudo apt-get install -y dms-greeter" logFunc("Enabling PPA ppa:avengemedia/danklinux...") - ppacmd := exec.CommandContext(ctx, "sudo", "add-apt-repository", "-y", "ppa:avengemedia/danklinux") + ppacmd := privesc.ExecCommand(ctx, sudoPassword, "add-apt-repository -y ppa:avengemedia/danklinux") ppacmd.Stdout = os.Stdout ppacmd.Stderr = os.Stderr ppacmd.Run() - exec.CommandContext(ctx, "sudo", "apt-get", "update").Run() - installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "dms-greeter") + privesc.ExecCommand(ctx, sudoPassword, "apt-get update").Run() + installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y dms-greeter") case distros.FamilyFedora: failHint = "⚠ dms-greeter install failed. Enable COPR manually: sudo dnf copr enable avengemedia/danklinux && sudo dnf install dms-greeter" logFunc("Enabling COPR avengemedia/danklinux...") - coprcmd := exec.CommandContext(ctx, "sudo", "dnf", "copr", "enable", "-y", "avengemedia/danklinux") + coprcmd := privesc.ExecCommand(ctx, sudoPassword, "dnf copr enable -y avengemedia/danklinux") coprcmd.Stdout = os.Stdout coprcmd.Stderr = os.Stderr coprcmd.Run() - installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "dms-greeter") + installCmd = privesc.ExecCommand(ctx, sudoPassword, "dnf install -y dms-greeter") case distros.FamilyArch: aurHelper := "" for _, helper := range []string{"paru", "yay"} { @@ -539,25 +501,25 @@ func CopyGreeterFiles(dmsPath, compositor string, logFunc func(string), sudoPass if info, err := os.Stat(wrapperDst); err == nil && !info.IsDir() { action = "Updated" } - if err := runSudoCmd(sudoPassword, "cp", wrapperSrc, wrapperDst); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "cp", wrapperSrc, wrapperDst); err != nil { return fmt.Errorf("failed to copy dms-greeter wrapper: %w", err) } logFunc(fmt.Sprintf("✓ %s dms-greeter wrapper at %s", action, wrapperDst)) - if err := runSudoCmd(sudoPassword, "chmod", "+x", wrapperDst); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "chmod", "+x", wrapperDst); err != nil { return fmt.Errorf("failed to make wrapper executable: %w", err) } osInfo, err := distros.GetOSInfo() if err == nil { if config, exists := distros.Registry[osInfo.Distribution.ID]; exists && (config.Family == distros.FamilyFedora || config.Family == distros.FamilySUSE) { - if err := runSudoCmd(sudoPassword, "semanage", "fcontext", "-a", "-t", "bin_t", wrapperDst); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "semanage", "fcontext", "-a", "-t", "bin_t", wrapperDst); err != nil { logFunc(fmt.Sprintf("⚠ Warning: Failed to set SELinux fcontext: %v", err)) } else { logFunc("✓ Set SELinux fcontext for dms-greeter") } - if err := runSudoCmd(sudoPassword, "restorecon", "-v", wrapperDst); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "restorecon", "-v", wrapperDst); err != nil { logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context: %v", err)) } else { logFunc("✓ Restored SELinux context for dms-greeter") @@ -583,7 +545,7 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error { if !os.IsNotExist(err) { return fmt.Errorf("failed to stat cache directory: %w", err) } - if err := runSudoCmd(sudoPassword, "mkdir", "-p", cacheDir); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", cacheDir); err != nil { return fmt.Errorf("failed to create cache directory: %w", err) } created = true @@ -595,17 +557,17 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error { daemonUser := DetectGreeterUser() preferredOwner := fmt.Sprintf("%s:%s", daemonUser, group) owner := preferredOwner - if err := runSudoCmd(sudoPassword, "chown", owner, cacheDir); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "chown", owner, cacheDir); err != nil { // Some setups may not have a matching daemon user at this moment; fall back // to root: while still allowing group-writable greeter runtime access. fallbackOwner := fmt.Sprintf("root:%s", group) - if fallbackErr := runSudoCmd(sudoPassword, "chown", fallbackOwner, cacheDir); fallbackErr != nil { + if fallbackErr := privesc.Run(context.Background(), sudoPassword, "chown", fallbackOwner, cacheDir); fallbackErr != nil { return fmt.Errorf("failed to set cache directory owner (preferred %s: %v; fallback %s: %w)", preferredOwner, err, fallbackOwner, fallbackErr) } owner = fallbackOwner } - if err := runSudoCmd(sudoPassword, "chmod", "2770", cacheDir); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "chmod", "2770", cacheDir); err != nil { return fmt.Errorf("failed to set cache directory permissions: %w", err) } @@ -616,13 +578,13 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error { filepath.Join(cacheDir, ".cache"), } for _, dir := range runtimeDirs { - if err := runSudoCmd(sudoPassword, "mkdir", "-p", dir); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", dir); err != nil { return fmt.Errorf("failed to create cache runtime directory %s: %w", dir, err) } - if err := runSudoCmd(sudoPassword, "chown", owner, dir); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "chown", owner, dir); err != nil { return fmt.Errorf("failed to set owner for cache runtime directory %s: %w", dir, err) } - if err := runSudoCmd(sudoPassword, "chmod", "2770", dir); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "chmod", "2770", dir); err != nil { return fmt.Errorf("failed to set permissions for cache runtime directory %s: %w", dir, err) } } @@ -634,7 +596,7 @@ func EnsureGreeterCacheDir(logFunc func(string), sudoPassword string) error { } if isSELinuxEnforcing() && utils.CommandExists("restorecon") { - if err := runSudoCmd(sudoPassword, "restorecon", "-Rv", cacheDir); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "restorecon", "-Rv", cacheDir); err != nil { logFunc(fmt.Sprintf("⚠ Warning: Failed to restore SELinux context for %s: %v", cacheDir, err)) } } @@ -659,13 +621,13 @@ func ensureGreeterMemoryCompatLink(logFunc func(string), sudoPassword, legacyPat info, err := os.Lstat(legacyPath) if err == nil && info.Mode().IsRegular() { if _, stateErr := os.Stat(statePath); os.IsNotExist(stateErr) { - if copyErr := runSudoCmd(sudoPassword, "cp", "-f", legacyPath, statePath); copyErr != nil { + if copyErr := privesc.Run(context.Background(), sudoPassword, "cp", "-f", legacyPath, statePath); copyErr != nil { logFunc(fmt.Sprintf("⚠ Warning: Failed to migrate legacy greeter memory file to %s: %v", statePath, copyErr)) } } } - if err := runSudoCmd(sudoPassword, "ln", "-sfn", statePath, legacyPath); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "ln", "-sfn", statePath, legacyPath); err != nil { return fmt.Errorf("failed to create greeter memory compatibility symlink %s -> %s: %w", legacyPath, statePath, err) } @@ -692,7 +654,7 @@ func InstallAppArmorProfile(logFunc func(string), sudoPassword string) error { return nil } - if err := runSudoCmd(sudoPassword, "mkdir", "-p", "/etc/apparmor.d"); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/apparmor.d"); err != nil { return fmt.Errorf("failed to create /etc/apparmor.d: %w", err) } @@ -709,15 +671,15 @@ func InstallAppArmorProfile(logFunc func(string), sudoPassword string) error { } tmp.Close() - if err := runSudoCmd(sudoPassword, "cp", tmpPath, appArmorProfileDest); err != nil { + if err := privesc.Run(context.Background(), 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 { + if err := privesc.Run(context.Background(), 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 { + if err := privesc.Run(context.Background(), 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 { @@ -745,9 +707,9 @@ func UninstallAppArmorProfile(logFunc func(string), sudoPassword string) error { } if utils.CommandExists("apparmor_parser") { - _ = runSudoCmd(sudoPassword, "apparmor_parser", "--remove", appArmorProfileDest) + _ = privesc.Run(context.Background(), sudoPassword, "apparmor_parser", "--remove", appArmorProfileDest) } - if err := runSudoCmd(sudoPassword, "rm", "-f", appArmorProfileDest); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "rm", "-f", appArmorProfileDest); err != nil { return fmt.Errorf("failed to remove AppArmor profile: %w", err) } logFunc(" ✓ Removed DMS AppArmor profile") @@ -777,50 +739,17 @@ func EnsureACLInstalled(logFunc func(string), sudoPassword string) error { switch config.Family { case distros.FamilyArch: - if sudoPassword != "" { - installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm acl") - } else { - installCmd = exec.CommandContext(ctx, "sudo", "pacman", "-S", "--needed", "--noconfirm", "acl") - } - + installCmd = privesc.ExecCommand(ctx, sudoPassword, "pacman -S --needed --noconfirm acl") case distros.FamilyFedora: - if sudoPassword != "" { - installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "dnf install -y acl") - } else { - installCmd = exec.CommandContext(ctx, "sudo", "dnf", "install", "-y", "acl") - } - + installCmd = privesc.ExecCommand(ctx, sudoPassword, "dnf install -y acl") case distros.FamilySUSE: - if sudoPassword != "" { - installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "zypper install -y acl") - } else { - installCmd = exec.CommandContext(ctx, "sudo", "zypper", "install", "-y", "acl") - } - - case distros.FamilyUbuntu: - if sudoPassword != "" { - installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "apt-get install -y acl") - } else { - installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "acl") - } - - case distros.FamilyDebian: - if sudoPassword != "" { - installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "apt-get install -y acl") - } else { - installCmd = exec.CommandContext(ctx, "sudo", "apt-get", "install", "-y", "acl") - } - + installCmd = privesc.ExecCommand(ctx, sudoPassword, "zypper install -y acl") + case distros.FamilyUbuntu, distros.FamilyDebian: + installCmd = privesc.ExecCommand(ctx, sudoPassword, "apt-get install -y acl") case distros.FamilyGentoo: - if sudoPassword != "" { - installCmd = distros.ExecSudoCommand(ctx, sudoPassword, "emerge --ask n sys-fs/acl") - } else { - installCmd = exec.CommandContext(ctx, "sudo", "emerge", "--ask", "n", "sys-fs/acl") - } - + installCmd = privesc.ExecCommand(ctx, sudoPassword, "emerge --ask n sys-fs/acl") case distros.FamilyNix: return fmt.Errorf("on NixOS, please add pkgs.acl to your configuration.nix") - default: return fmt.Errorf("unsupported distribution family for automatic acl installation: %s", config.Family) } @@ -877,7 +806,7 @@ func SetupParentDirectoryACLs(logFunc func(string), sudoPassword string) error { } // Group ACL covers daemon users regardless of username (e.g. greetd ≠ greeter on Fedora). - if err := runSudoCmd(sudoPassword, "setfacl", "-m", fmt.Sprintf("g:%s:rX", group), dir.path); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "setfacl", "-m", fmt.Sprintf("g:%s:rX", group), dir.path); err != nil { logFunc(fmt.Sprintf("⚠ Warning: Failed to set ACL on %s: %v", dir.desc, err)) logFunc(fmt.Sprintf(" You may need to run manually: setfacl -m g:%s:rX %s", group, dir.path)) continue @@ -934,7 +863,7 @@ func RemediateStaleACLs(logFunc func(string), sudoPassword string) { continue } for _, user := range existingUsers { - _ = runSudoCmd(sudoPassword, "setfacl", "-x", fmt.Sprintf("u:%s", user), dir) + _ = privesc.Run(context.Background(), sudoPassword, "setfacl", "-x", fmt.Sprintf("u:%s", user), dir) cleaned = true } } @@ -974,7 +903,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error { // Create the group if it doesn't exist yet (e.g. before greetd package is installed). if !utils.HasGroup(group) { - if err := runSudoCmd(sudoPassword, "groupadd", "-r", group); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "groupadd", "-r", group); err != nil { return fmt.Errorf("failed to create %s group: %w", group, err) } logFunc(fmt.Sprintf("✓ Created system group %s", group)) @@ -985,7 +914,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error { if err == nil && strings.Contains(string(groupsOutput), group) { logFunc(fmt.Sprintf("✓ %s is already in %s group", currentUser, group)) } else { - if err := runSudoCmd(sudoPassword, "usermod", "-aG", group, currentUser); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "usermod", "-aG", group, currentUser); err != nil { return fmt.Errorf("failed to add %s to %s group: %w", currentUser, group, err) } logFunc(fmt.Sprintf("✓ Added %s to %s group (logout/login required for changes to take effect)", currentUser, group)) @@ -1000,7 +929,7 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error { if strings.Contains(string(daemonGroupsOutput), group) { logFunc(fmt.Sprintf("✓ Greeter daemon user %s is already in %s group", daemonUser, group)) } else { - if err := runSudoCmd(sudoPassword, "usermod", "-aG", group, daemonUser); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "usermod", "-aG", group, daemonUser); err != nil { logFunc(fmt.Sprintf("⚠ Warning: could not add %s to %s group: %v", daemonUser, group, err)) } else { logFunc(fmt.Sprintf("✓ Added greeter daemon user %s to %s group", daemonUser, group)) @@ -1030,12 +959,12 @@ func SetupDMSGroup(logFunc func(string), sudoPassword string) error { } } - if err := runSudoCmd(sudoPassword, "chgrp", "-R", group, dir.path); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "chgrp", "-R", group, dir.path); err != nil { logFunc(fmt.Sprintf("⚠ Warning: Failed to set group for %s: %v", dir.desc, err)) continue } - if err := runSudoCmd(sudoPassword, "chmod", "-R", "g+rX", dir.path); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "chmod", "-R", "g+rX", dir.path); err != nil { logFunc(fmt.Sprintf("⚠ Warning: Failed to set permissions for %s: %v", dir.desc, err)) continue } @@ -1247,8 +1176,8 @@ func syncGreeterColorSource(homeDir, cacheDir string, state greeterThemeSyncStat } target := filepath.Join(cacheDir, "colors.json") - _ = runSudoCmd(sudoPassword, "rm", "-f", target) - if err := runSudoCmd(sudoPassword, "ln", "-sf", source, target); err != nil { + _ = privesc.Run(context.Background(), sudoPassword, "rm", "-f", target) + if err := privesc.Run(context.Background(), sudoPassword, "ln", "-sf", source, target); err != nil { return fmt.Errorf("failed to create symlink for wallpaper based theming (%s -> %s): %w", target, source, err) } @@ -1300,9 +1229,9 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo } } - _ = runSudoCmd(sudoPassword, "rm", "-f", link.target) + _ = privesc.Run(context.Background(), sudoPassword, "rm", "-f", link.target) - if err := runSudoCmd(sudoPassword, "ln", "-sf", link.source, link.target); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "ln", "-sf", link.source, link.target); err != nil { return fmt.Errorf("failed to create symlink for %s (%s -> %s): %w", link.desc, link.target, link.source, err) } @@ -1340,13 +1269,13 @@ func SyncDMSConfigs(dmsPath, compositor string, logFunc func(string), sudoPasswo func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPassword string, state greeterThemeSyncState) error { destPath := filepath.Join(cacheDir, "greeter_wallpaper_override.jpg") if state.ResolvedGreeterWallpaperPath == "" { - if err := runSudoCmd(sudoPassword, "rm", "-f", destPath); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "rm", "-f", destPath); err != nil { return fmt.Errorf("failed to clear override file %s: %w", destPath, err) } logFunc("✓ Cleared greeter wallpaper override") return nil } - if err := runSudoCmd(sudoPassword, "rm", "-f", destPath); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "rm", "-f", destPath); err != nil { return fmt.Errorf("failed to remove old override file %s: %w", destPath, err) } src := state.ResolvedGreeterWallpaperPath @@ -1357,17 +1286,17 @@ func syncGreeterWallpaperOverride(cacheDir string, logFunc func(string), sudoPas if st.IsDir() { return fmt.Errorf("configured greeter wallpaper path points to a directory: %s", src) } - if err := runSudoCmd(sudoPassword, "cp", src, destPath); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "cp", src, destPath); err != nil { return fmt.Errorf("failed to copy override wallpaper to %s: %w", destPath, err) } greeterGroup := DetectGreeterGroup() daemonUser := DetectGreeterUser() - if err := runSudoCmd(sudoPassword, "chown", daemonUser+":"+greeterGroup, destPath); err != nil { - if fallbackErr := runSudoCmd(sudoPassword, "chown", "root:"+greeterGroup, destPath); fallbackErr != nil { + if err := privesc.Run(context.Background(), sudoPassword, "chown", daemonUser+":"+greeterGroup, destPath); err != nil { + if fallbackErr := privesc.Run(context.Background(), sudoPassword, "chown", "root:"+greeterGroup, destPath); fallbackErr != nil { return fmt.Errorf("failed to set override ownership on %s: %w", destPath, err) } } - if err := runSudoCmd(sudoPassword, "chmod", "644", destPath); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "chmod", "644", destPath); err != nil { return fmt.Errorf("failed to set override permissions on %s: %w", destPath, err) } logFunc("✓ Synced greeter wallpaper override") @@ -1422,13 +1351,13 @@ func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error { greeterDir := "/etc/greetd/niri" greeterGroup := DetectGreeterGroup() - if err := runSudoCmd(sudoPassword, "mkdir", "-p", greeterDir); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", greeterDir); err != nil { return fmt.Errorf("failed to create greetd niri directory: %w", err) } - if err := runSudoCmd(sudoPassword, "chown", fmt.Sprintf("root:%s", greeterGroup), greeterDir); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "chown", fmt.Sprintf("root:%s", greeterGroup), greeterDir); err != nil { return fmt.Errorf("failed to set greetd niri directory ownership: %w", err) } - if err := runSudoCmd(sudoPassword, "chmod", "755", greeterDir); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "chmod", "755", greeterDir); err != nil { return fmt.Errorf("failed to set greetd niri directory permissions: %w", err) } @@ -1450,7 +1379,7 @@ func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error { if err := backupFileIfExists(sudoPassword, dmsPath, ".backup"); err != nil { return fmt.Errorf("failed to backup %s: %w", dmsPath, err) } - if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", dmsTemp.Name(), dmsPath); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", dmsTemp.Name(), dmsPath); err != nil { return fmt.Errorf("failed to install greetd niri dms config: %w", err) } @@ -1473,7 +1402,7 @@ func syncNiriGreeterConfig(logFunc func(string), sudoPassword string) error { if err := backupFileIfExists(sudoPassword, mainPath, ".backup"); err != nil { return fmt.Errorf("failed to backup %s: %w", mainPath, err) } - if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", mainTemp.Name(), mainPath); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", greeterGroup, "-m", "0644", mainTemp.Name(), mainPath); err != nil { return fmt.Errorf("failed to install greetd niri main config: %w", err) } @@ -1549,7 +1478,7 @@ func ensureGreetdNiriConfig(logFunc func(string), sudoPassword string, niriConfi return fmt.Errorf("failed to close temp greetd config: %w", err) } - if err := runSudoCmd(sudoPassword, "mv", tmpFile.Name(), configPath); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "mv", tmpFile.Name(), configPath); err != nil { return fmt.Errorf("failed to update greetd config: %w", err) } @@ -1565,10 +1494,10 @@ func backupFileIfExists(sudoPassword string, path string, suffix string) error { } backupPath := fmt.Sprintf("%s%s-%s", path, suffix, time.Now().Format("20060102-150405")) - if err := runSudoCmd(sudoPassword, "cp", path, backupPath); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "cp", path, backupPath); err != nil { return err } - return runSudoCmd(sudoPassword, "chmod", "644", backupPath) + return privesc.Run(context.Background(), sudoPassword, "chmod", "644", backupPath) } func (s *niriGreeterSync) processFile(filePath string) error { @@ -1804,11 +1733,11 @@ vt = 1 return fmt.Errorf("failed to close temp greetd config: %w", err) } - if err := runSudoCmd(sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "mkdir", "-p", "/etc/greetd"); err != nil { return fmt.Errorf("failed to create /etc/greetd: %w", err) } - if err := runSudoCmd(sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "install", "-o", "root", "-g", "root", "-m", "0644", tmpFile.Name(), configPath); err != nil { return fmt.Errorf("failed to install greetd config: %w", err) } @@ -1912,27 +1841,6 @@ func getOpenSUSEOBSRepoURL(osInfo *distros.OSInfo) string { return fmt.Sprintf("%s/%s/home:AvengeMedia:danklinux.repo", base, slug) } -func runSudoCmd(sudoPassword string, command string, args ...string) error { - var cmd *exec.Cmd - - if sudoPassword != "" { - fullArgs := append([]string{command}, args...) - quotedArgs := make([]string, len(fullArgs)) - for i, arg := range fullArgs { - quotedArgs[i] = "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'" - } - cmdStr := strings.Join(quotedArgs, " ") - - cmd = distros.ExecSudoCommand(context.Background(), sudoPassword, cmdStr) - } else { - cmd = exec.Command("sudo", append([]string{command}, args...)...) - } - - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - func checkSystemdEnabled(service string) (string, error) { cmd := exec.Command("systemctl", "is-enabled", service) output, _ := cmd.Output() @@ -1949,7 +1857,7 @@ func DisableConflictingDisplayManagers(sudoPassword string, logFunc func(string) switch state { case "enabled", "enabled-runtime", "static", "indirect", "alias": logFunc(fmt.Sprintf("Disabling conflicting display manager: %s", dm)) - if err := runSudoCmd(sudoPassword, "systemctl", "disable", dm); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "disable", dm); err != nil { logFunc(fmt.Sprintf("⚠ Warning: Failed to disable %s: %v", dm, err)) } else { logFunc(fmt.Sprintf("✓ Disabled %s", dm)) @@ -1970,13 +1878,13 @@ func EnableGreetd(sudoPassword string, logFunc func(string)) error { } if state == "masked" || state == "masked-runtime" { logFunc(" Unmasking greetd...") - if err := runSudoCmd(sudoPassword, "systemctl", "unmask", "greetd"); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "unmask", "greetd"); err != nil { return fmt.Errorf("failed to unmask greetd: %w", err) } logFunc(" ✓ Unmasked greetd") } logFunc(" Enabling greetd service (--force)...") - if err := runSudoCmd(sudoPassword, "systemctl", "enable", "--force", "greetd"); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "enable", "--force", "greetd"); err != nil { return fmt.Errorf("failed to enable greetd: %w", err) } logFunc("✓ greetd enabled") @@ -1996,7 +1904,7 @@ func EnsureGraphicalTarget(sudoPassword string, logFunc func(string)) error { return nil } logFunc(fmt.Sprintf(" Setting default target to graphical.target (was: %s)...", current)) - if err := runSudoCmd(sudoPassword, "systemctl", "set-default", "graphical.target"); err != nil { + if err := privesc.Run(context.Background(), sudoPassword, "systemctl", "set-default", "graphical.target"); err != nil { return fmt.Errorf("failed to set graphical target: %w", err) } logFunc("✓ Default target set to graphical.target") diff --git a/core/internal/headless/runner.go b/core/internal/headless/runner.go index 7dae9fcb..33c57528 100644 --- a/core/internal/headless/runner.go +++ b/core/internal/headless/runner.go @@ -4,13 +4,13 @@ import ( "context" "fmt" "os" - "os/exec" "strings" "github.com/AvengeMedia/DankMaterialShell/core/internal/config" "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" "github.com/AvengeMedia/DankMaterialShell/core/internal/greeter" + "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" ) // ErrConfirmationRequired is returned when --yes is not set and the user @@ -383,20 +383,41 @@ func (r *Runner) parseTerminal() (deps.Terminal, error) { } func (r *Runner) resolveSudoPassword() (string, error) { - // Check if sudo credentials are cached (via sudo -v or NOPASSWD) - cmd := exec.Command("sudo", "-n", "true") - if err := cmd.Run(); err == nil { - r.log("sudo cache is valid, no password needed") - fmt.Fprintln(os.Stdout, "sudo: using cached credentials") + tool, err := privesc.Detect() + if err != nil { + return "", err + } + + if err := privesc.CheckCached(context.Background()); err == nil { + r.log(fmt.Sprintf("%s cache is valid, no password needed", tool.Name())) + fmt.Fprintf(os.Stdout, "%s: using cached credentials\n", tool.Name()) return "", nil } - return "", fmt.Errorf( - "sudo authentication required but no cached credentials found\n" + - "Options:\n" + - " 1. Run 'sudo -v' before dankinstall to cache credentials\n" + - " 2. Configure passwordless sudo for your user", - ) + switch tool { + case privesc.ToolSudo: + return "", fmt.Errorf( + "sudo authentication required but no cached credentials found\n" + + "Options:\n" + + " 1. Run 'sudo -v' before dankinstall to cache credentials\n" + + " 2. Configure passwordless sudo for your user", + ) + case privesc.ToolDoas: + return "", fmt.Errorf( + "doas authentication required but no cached credentials found\n" + + "Options:\n" + + " 1. Run 'doas true' before dankinstall to cache credentials (requires 'persist' in /etc/doas.conf)\n" + + " 2. Configure a 'nopass' rule in /etc/doas.conf for your user", + ) + case privesc.ToolRun0: + return "", fmt.Errorf( + "run0 authentication required but no cached credentials found\n" + + "Configure a polkit rule granting your user passwordless privilege\n" + + "(see `man polkit` for details on rules in /etc/polkit-1/rules.d/)", + ) + default: + return "", fmt.Errorf("unsupported privilege tool: %s", tool) + } } func (r *Runner) anyConfigEnabled(m map[string]bool) bool { diff --git a/core/internal/pam/pam.go b/core/internal/pam/pam.go index 16ca3f95..398d987c 100644 --- a/core/internal/pam/pam.go +++ b/core/internal/pam/pam.go @@ -11,6 +11,7 @@ import ( "time" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" + "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" ) const ( @@ -80,16 +81,18 @@ type lockscreenPamResolver struct { func defaultSyncDeps() syncDeps { return syncDeps{ - pamDir: "/etc/pam.d", - greetdPath: GreetdPamPath, - dankshellPath: DankshellPamPath, - dankshellU2fPath: DankshellU2FPamPath, - isNixOS: IsNixOS, - readFile: os.ReadFile, - stat: os.Stat, - createTemp: os.CreateTemp, - removeFile: os.Remove, - runSudoCmd: runSudoCmd, + pamDir: "/etc/pam.d", + greetdPath: GreetdPamPath, + dankshellPath: DankshellPamPath, + dankshellU2fPath: DankshellU2FPamPath, + isNixOS: IsNixOS, + readFile: os.ReadFile, + stat: os.Stat, + createTemp: os.CreateTemp, + removeFile: os.Remove, + runSudoCmd: func(password, command string, args ...string) error { + return privesc.Run(context.Background(), password, append([]string{command}, args...)...) + }, pamModuleExists: pamModuleExists, fingerprintAvailableForCurrentUser: FingerprintAuthAvailableForCurrentUser, } @@ -869,24 +872,3 @@ func fingerprintAuthAvailableForUser(username string) bool { } return hasEnrolledFingerprintOutput(string(out)) } - -func runSudoCmd(sudoPassword string, command string, args ...string) error { - var cmd *exec.Cmd - - if sudoPassword != "" { - fullArgs := append([]string{command}, args...) - quotedArgs := make([]string, len(fullArgs)) - for i, arg := range fullArgs { - quotedArgs[i] = "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'" - } - cmdStr := strings.Join(quotedArgs, " ") - - cmd = distros.ExecSudoCommand(context.Background(), sudoPassword, cmdStr) - } else { - cmd = exec.Command("sudo", append([]string{command}, args...)...) - } - - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} diff --git a/core/internal/privesc/privesc.go b/core/internal/privesc/privesc.go new file mode 100644 index 00000000..2c463002 --- /dev/null +++ b/core/internal/privesc/privesc.go @@ -0,0 +1,385 @@ +package privesc + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "os/exec" + "strconv" + "strings" + "sync" +) + +// Tool identifies a privilege-escalation binary. +type Tool string + +const ( + ToolSudo Tool = "sudo" + ToolDoas Tool = "doas" + ToolRun0 Tool = "run0" +) + +// EnvVar selects a specific tool when set to one of: sudo, doas, run0. +const EnvVar = "DMS_PRIVESC" + +var detectionOrder = []Tool{ToolSudo, ToolDoas, ToolRun0} + +var ( + detectOnce sync.Once + detected Tool + detectErr error + userSelected bool +) + +// Detect returns the tool that should be used for privilege escalation. +// The result is cached after the first call. +func Detect() (Tool, error) { + detectOnce.Do(func() { + detected, detectErr = detectTool() + }) + return detected, detectErr +} + +// ResetForTesting clears cached detection state. +func ResetForTesting() { + detectOnce = sync.Once{} + detected = "" + detectErr = nil + userSelected = false +} + +// AvailableTools returns the set of supported tools that are installed on +// PATH, in detection-precedence order. +func AvailableTools() []Tool { + var out []Tool + for _, t := range detectionOrder { + if t.Available() { + out = append(out, t) + } + } + return out +} + +// EnvOverride returns the tool selected by the $DMS_PRIVESC env var (if any) +// along with ok=true when the variable is set. An empty or unset variable +// returns ok=false. +func EnvOverride() (Tool, bool) { + v := strings.ToLower(strings.TrimSpace(os.Getenv(EnvVar))) + if v == "" { + return "", false + } + return Tool(v), true +} + +// SetTool forces the detected tool to t, bypassing autodetection. Intended +// for use after the caller has prompted the user for a selection. +func SetTool(t Tool) error { + if !t.Available() { + return fmt.Errorf("%q is not installed", t.Name()) + } + detectOnce = sync.Once{} + detectOnce.Do(func() { + detected = t + detectErr = nil + }) + userSelected = true + return nil +} + +func detectTool() (Tool, error) { + switch override := strings.ToLower(strings.TrimSpace(os.Getenv(EnvVar))); override { + case "": + // fall through to autodetect + case string(ToolSudo), string(ToolDoas), string(ToolRun0): + t := Tool(override) + if !t.Available() { + return "", fmt.Errorf("%s=%s but %q is not installed", EnvVar, override, t.Name()) + } + return t, nil + default: + return "", fmt.Errorf("invalid %s=%q: must be one of sudo, doas, run0", EnvVar, override) + } + + for _, t := range detectionOrder { + if t.Available() { + return t, nil + } + } + return "", fmt.Errorf("no supported privilege escalation tool found (tried: sudo, doas, run0)") +} + +// Name returns the binary name. +func (t Tool) Name() string { return string(t) } + +// Available reports whether this tool's binary is on PATH. +func (t Tool) Available() bool { + if t == "" { + return false + } + _, err := exec.LookPath(string(t)) + return err == nil +} + +// SupportsStdinPassword reports whether the tool can accept a password via +// stdin. Only sudo (-S) supports this. +func (t Tool) SupportsStdinPassword() bool { + return t == ToolSudo +} + +// EscapeSingleQuotes escapes single quotes for safe inclusion inside a +// bash single-quoted string. +func EscapeSingleQuotes(s string) string { + return strings.ReplaceAll(s, "'", "'\\''") +} + +// MakeCommand returns a bash command string that runs `command` with the +// detected tool. When the tool supports stdin passwords and password is +// non-empty, the password is piped in. Otherwise the tool is invoked with +// no non-interactive flag so that an interactive TTY prompt is still +// possible for CLI callers. +// +// If detection fails, the returned shell string exits 1 with an error +// message so callers that treat the *exec.Cmd as infallible still fail +// deterministically. +func MakeCommand(password, command string) string { + t, err := Detect() + if err != nil { + return failingShell(err) + } + + switch t { + case ToolSudo: + if password != "" { + return fmt.Sprintf("echo '%s' | sudo -S %s", EscapeSingleQuotes(password), command) + } + return fmt.Sprintf("sudo %s", command) + case ToolDoas: + return fmt.Sprintf("doas sh -c '%s'", EscapeSingleQuotes(command)) + case ToolRun0: + return fmt.Sprintf("run0 sh -c '%s'", EscapeSingleQuotes(command)) + default: + return failingShell(fmt.Errorf("unsupported privilege tool: %q", t)) + } +} + +// ExecCommand builds an exec.Cmd that runs `command` as root via the +// detected tool. Detection errors surface at Run() time as a failing +// command writing a clear error to stderr. +func ExecCommand(ctx context.Context, password, command string) *exec.Cmd { + return exec.CommandContext(ctx, "bash", "-c", MakeCommand(password, command)) +} + +// ExecArgv builds an exec.Cmd that runs argv as root via the detected tool. +// No stdin password is supplied; callers relying on non-interactive success +// should ensure cached credentials are present (see CheckCached). +func ExecArgv(ctx context.Context, argv ...string) *exec.Cmd { + if len(argv) == 0 { + return exec.CommandContext(ctx, "bash", "-c", failingShell(fmt.Errorf("privesc.ExecArgv: argv must not be empty"))) + } + t, err := Detect() + if err != nil { + return exec.CommandContext(ctx, "bash", "-c", failingShell(err)) + } + + switch t { + case ToolSudo, ToolDoas: + return exec.CommandContext(ctx, string(t), argv...) + case ToolRun0: + return exec.CommandContext(ctx, "run0", argv...) + default: + return exec.CommandContext(ctx, "bash", "-c", failingShell(fmt.Errorf("unsupported privilege tool: %q", t))) + } +} + +func failingShell(err error) string { + return fmt.Sprintf("printf 'privesc: %%s\\n' '%s' >&2; exit 1", EscapeSingleQuotes(err.Error())) +} + +// CheckCached runs a non-interactive credential probe. Returns nil if the +// tool will run commands without prompting (cached credentials, nopass, or +// polkit rule). +func CheckCached(ctx context.Context) error { + t, err := Detect() + if err != nil { + return err + } + + var cmd *exec.Cmd + switch t { + case ToolSudo: + cmd = exec.CommandContext(ctx, "sudo", "-n", "true") + case ToolDoas: + cmd = exec.CommandContext(ctx, "doas", "-n", "true") + case ToolRun0: + cmd = exec.CommandContext(ctx, "run0", "--no-ask-password", "true") + default: + return fmt.Errorf("unsupported privilege tool: %q", t) + } + return cmd.Run() +} + +// ClearCache invalidates any cached credentials. No-op for tools that do +// not expose a cache-clear operation. +func ClearCache(ctx context.Context) error { + t, err := Detect() + if err != nil { + return err + } + switch t { + case ToolSudo: + return exec.CommandContext(ctx, "sudo", "-k").Run() + default: + return nil + } +} + +// ValidateWithAskpass validates cached credentials using an askpass helper +// script. Only sudo supports this mechanism; the TUI uses it to trigger +// fingerprint authentication via PAM. +func ValidateWithAskpass(ctx context.Context, askpassScript string) error { + t, err := Detect() + if err != nil { + return err + } + if t != ToolSudo { + return fmt.Errorf("askpass validation requires sudo (detected: %s)", t) + } + cmd := exec.CommandContext(ctx, "sudo", "-A", "-v") + cmd.Env = append(os.Environ(), fmt.Sprintf("SUDO_ASKPASS=%s", askpassScript)) + return cmd.Run() +} + +// ValidatePassword validates the given password. Only sudo supports this +// (via `sudo -S -v`); for other tools the caller should fall back to +// CheckCached. +func ValidatePassword(ctx context.Context, password string) error { + t, err := Detect() + if err != nil { + return err + } + if t != ToolSudo { + return fmt.Errorf("password validation requires sudo (detected: %s)", t) + } + + cmd := exec.CommandContext(ctx, "sudo", "-S", "-v") + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return err + } + if _, err := fmt.Fprintf(stdin, "%s\n", password); err != nil { + stdin.Close() + _ = cmd.Wait() + return err + } + stdin.Close() + return cmd.Wait() +} + +// QuoteArgsForShell wraps each argv element in single quotes so the result +// can be safely passed to bash -c. +func QuoteArgsForShell(argv []string) string { + parts := make([]string, len(argv)) + for i, a := range argv { + parts[i] = "'" + EscapeSingleQuotes(a) + "'" + } + return strings.Join(parts, " ") +} + +// Run invokes argv with privilege escalation. When the tool supports stdin +// passwords and password is non-empty, the password is piped in. Otherwise +// argv is invoked directly, which may prompt on a TTY. +// Stdout and Stderr are inherited from the current process. +func Run(ctx context.Context, password string, argv ...string) error { + if len(argv) == 0 { + return fmt.Errorf("privesc.Run: argv must not be empty") + } + t, err := Detect() + if err != nil { + return err + } + + var cmd *exec.Cmd + switch { + case t == ToolSudo && password != "": + cmd = ExecCommand(ctx, password, QuoteArgsForShell(argv)) + default: + cmd = ExecArgv(ctx, argv...) + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// stdinIsTTY reports whether stdin is a character device (interactive +// terminal) rather than a pipe or file. +func stdinIsTTY() bool { + fi, err := os.Stdin.Stat() + if err != nil { + return false + } + return (fi.Mode() & os.ModeCharDevice) != 0 +} + +// PromptCLI interactively prompts the user to pick a privilege tool when more +// than one is installed and $DMS_PRIVESC is not set. If stdin is not a TTY, +// or only one tool is available, or the env var is set, the detected tool is +// returned without any prompt. +// +// The prompt is written to out (typically os.Stdout/os.Stderr) and input is +// read from in. EOF or empty input selects the first option. +func PromptCLI(out io.Writer, in io.Reader) (Tool, error) { + if userSelected { + return Detect() + } + if _, envSet := EnvOverride(); envSet { + return Detect() + } + + tools := AvailableTools() + switch len(tools) { + case 0: + return "", fmt.Errorf("no supported privilege tool (sudo/doas/run0) found on PATH") + case 1: + if err := SetTool(tools[0]); err != nil { + return "", err + } + return tools[0], nil + } + + if !stdinIsTTY() { + return Detect() + } + + fmt.Fprintln(out, "Multiple privilege escalation tools detected:") + for i, t := range tools { + fmt.Fprintf(out, " [%d] %s\n", i+1, t.Name()) + } + fmt.Fprintf(out, "Choose one [1-%d] (default 1, or set %s= to skip): ", len(tools), EnvVar) + + reader := bufio.NewReader(in) + line, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + return "", fmt.Errorf("failed to read selection: %w", err) + } + line = strings.TrimSpace(line) + + idx := 1 + if line != "" { + n, convErr := strconv.Atoi(line) + if convErr != nil || n < 1 || n > len(tools) { + return "", fmt.Errorf("invalid selection %q", line) + } + idx = n + } + + chosen := tools[idx-1] + if err := SetTool(chosen); err != nil { + return "", err + } + return chosen, nil +} diff --git a/core/internal/tui/app.go b/core/internal/tui/app.go index d07430a2..61874b9a 100644 --- a/core/internal/tui/app.go +++ b/core/internal/tui/app.go @@ -3,6 +3,7 @@ package tui import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/deps" "github.com/AvengeMedia/DankMaterialShell/core/internal/distros" + "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" "github.com/charmbracelet/bubbles/spinner" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -42,6 +43,9 @@ type Model struct { sudoPassword string existingConfigs []ExistingConfigInfo fingerprintFailed bool + + availablePrivesc []privesc.Tool + selectedPrivesc int } func NewModel(version string, logFilePath string) Model { @@ -147,6 +151,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateGentooUseFlagsState(msg) case StateGentooGCCCheck: return m.updateGentooGCCCheckState(msg) + case StateSelectPrivesc: + return m.updateSelectPrivescState(msg) case StateAuthMethodChoice: return m.updateAuthMethodChoiceState(msg) case StateFingerprintAuth: @@ -189,6 +195,8 @@ func (m Model) View() string { return m.viewGentooUseFlags() case StateGentooGCCCheck: return m.viewGentooGCCCheck() + case StateSelectPrivesc: + return m.viewSelectPrivesc() case StateAuthMethodChoice: return m.viewAuthMethodChoice() case StateFingerprintAuth: diff --git a/core/internal/tui/states.go b/core/internal/tui/states.go index 6e9e1940..bde1635f 100644 --- a/core/internal/tui/states.go +++ b/core/internal/tui/states.go @@ -10,6 +10,7 @@ const ( StateDependencyReview StateGentooUseFlags StateGentooGCCCheck + StateSelectPrivesc StateAuthMethodChoice StateFingerprintAuth StatePasswordPrompt diff --git a/core/internal/tui/views_dependencies.go b/core/internal/tui/views_dependencies.go index 13f03357..46597814 100644 --- a/core/internal/tui/views_dependencies.go +++ b/core/internal/tui/views_dependencies.go @@ -180,16 +180,7 @@ func (m Model) updateDependencyReviewState(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } } - // Check if fingerprint is enabled - if checkFingerprintEnabled() { - m.state = StateAuthMethodChoice - m.selectedConfig = 0 // Default to fingerprint - return m, nil - } else { - m.state = StatePasswordPrompt - m.passwordInput.Focus() - return m, nil - } + return m.enterAuthPhase() case "esc": m.state = StateSelectWindowManager return m, nil diff --git a/core/internal/tui/views_gentoo_use_flags.go b/core/internal/tui/views_gentoo_use_flags.go index eb259ef9..440287af 100644 --- a/core/internal/tui/views_gentoo_use_flags.go +++ b/core/internal/tui/views_gentoo_use_flags.go @@ -56,14 +56,7 @@ func (m Model) updateGentooUseFlagsState(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = StateGentooGCCCheck return m, nil } - if checkFingerprintEnabled() { - m.state = StateAuthMethodChoice - m.selectedConfig = 0 - } else { - m.state = StatePasswordPrompt - m.passwordInput.Focus() - } - return m, nil + return m.enterAuthPhase() } if keyMsg, ok := msg.(tea.KeyMsg); ok { @@ -75,14 +68,7 @@ func (m Model) updateGentooUseFlagsState(msg tea.Msg) (tea.Model, tea.Cmd) { if m.selectedWM == 1 { return m, m.checkGCCVersion() } - if checkFingerprintEnabled() { - m.state = StateAuthMethodChoice - m.selectedConfig = 0 - } else { - m.state = StatePasswordPrompt - m.passwordInput.Focus() - } - return m, nil + return m.enterAuthPhase() case "esc": m.state = StateDependencyReview return m, nil diff --git a/core/internal/tui/views_password.go b/core/internal/tui/views_password.go index deec0610..03d6c13b 100644 --- a/core/internal/tui/views_password.go +++ b/core/internal/tui/views_password.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" tea "github.com/charmbracelet/bubbletea" ) @@ -274,8 +275,7 @@ func (m Model) delayThenReturn() tea.Cmd { func (m Model) tryFingerprint() tea.Cmd { return func() tea.Msg { - clearCmd := exec.Command("sudo", "-k") - clearCmd.Run() + _ = privesc.ClearCache(context.Background()) tmpDir := os.TempDir() askpassScript := filepath.Join(tmpDir, fmt.Sprintf("danklinux-fp-%d.sh", time.Now().UnixNano())) @@ -289,15 +289,9 @@ func (m Model) tryFingerprint() tea.Cmd { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, "sudo", "-A", "-v") - cmd.Env = append(os.Environ(), fmt.Sprintf("SUDO_ASKPASS=%s", askpassScript)) - - err := cmd.Run() - - if err != nil { + if err := privesc.ValidateWithAskpass(ctx, askpassScript); err != nil { return passwordValidMsg{password: "", valid: false} } - return passwordValidMsg{password: "", valid: true} } } @@ -307,32 +301,9 @@ func (m Model) validatePassword(password string) tea.Cmd { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, "sudo", "-S", "-v") - - stdin, err := cmd.StdinPipe() - if err != nil { + if err := privesc.ValidatePassword(ctx, password); err != nil { return passwordValidMsg{password: "", valid: false} } - - if err := cmd.Start(); err != nil { - return passwordValidMsg{password: "", valid: false} - } - - _, err = fmt.Fprintf(stdin, "%s\n", password) - stdin.Close() - if err != nil { - return passwordValidMsg{password: "", valid: false} - } - - err = cmd.Wait() - - if err != nil { - if ctx.Err() == context.DeadlineExceeded { - return passwordValidMsg{password: "", valid: false} - } - return passwordValidMsg{password: "", valid: false} - } - return passwordValidMsg{password: password, valid: true} } } diff --git a/core/internal/tui/views_privesc.go b/core/internal/tui/views_privesc.go new file mode 100644 index 00000000..51a23733 --- /dev/null +++ b/core/internal/tui/views_privesc.go @@ -0,0 +1,133 @@ +package tui + +import ( + "fmt" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/privesc" + tea "github.com/charmbracelet/bubbletea" +) + +func (m Model) viewSelectPrivesc() string { + var b strings.Builder + + b.WriteString(m.renderBanner()) + b.WriteString("\n") + b.WriteString(m.styles.Title.Render("Privilege Escalation Tool")) + b.WriteString("\n\n") + b.WriteString(m.styles.Normal.Render("Multiple privilege tools are available. Choose one for installation:")) + b.WriteString("\n\n") + + for i, t := range m.availablePrivesc { + label := fmt.Sprintf("%s — %s", t.Name(), privescToolDescription(t)) + switch i { + case m.selectedPrivesc: + b.WriteString(m.styles.SelectedOption.Render("▶ " + label)) + default: + b.WriteString(m.styles.Normal.Render(" " + label)) + } + b.WriteString("\n") + } + + b.WriteString("\n") + b.WriteString(m.styles.Subtle.Render(fmt.Sprintf("Set %s= to skip this prompt in future runs.", privesc.EnvVar))) + b.WriteString("\n\n") + b.WriteString(m.styles.Subtle.Render("↑/↓: Navigate, Enter: Select, Esc: Back")) + return b.String() +} + +func (m Model) updateSelectPrivescState(msg tea.Msg) (tea.Model, tea.Cmd) { + keyMsg, ok := msg.(tea.KeyMsg) + if !ok { + return m, m.listenForLogs() + } + + switch keyMsg.String() { + case "up": + if m.selectedPrivesc > 0 { + m.selectedPrivesc-- + } + case "down": + if m.selectedPrivesc < len(m.availablePrivesc)-1 { + m.selectedPrivesc++ + } + case "enter": + chosen := m.availablePrivesc[m.selectedPrivesc] + if err := privesc.SetTool(chosen); err != nil { + m.err = fmt.Errorf("failed to select %s: %w", chosen.Name(), err) + m.state = StateError + return m, nil + } + return m.routeToAuthAfterPrivesc() + case "esc": + m.state = StateDependencyReview + return m, nil + } + return m, nil +} + +func privescToolDescription(t privesc.Tool) string { + switch t { + case privesc.ToolSudo: + return "classic sudo (supports password prompt in this installer)" + case privesc.ToolDoas: + return "OpenBSD-style doas (requires persist or nopass in /etc/doas.conf)" + case privesc.ToolRun0: + return "systemd run0 (authenticated via polkit)" + default: + return string(t) + } +} + +// routeToAuthAfterPrivesc advances from the privesc-selection screen to the +// right auth flow. Sudo goes through the fingerprint/password path; doas and +// run0 skip password entry and proceed to install. +func (m Model) routeToAuthAfterPrivesc() (tea.Model, tea.Cmd) { + tool, err := privesc.Detect() + if err != nil { + m.err = err + m.state = StateError + return m, nil + } + + if tool == privesc.ToolSudo { + if checkFingerprintEnabled() { + m.state = StateAuthMethodChoice + m.selectedConfig = 0 + return m, nil + } + m.state = StatePasswordPrompt + m.passwordInput.Focus() + return m, nil + } + + m.sudoPassword = "" + m.packageProgress = packageInstallProgressMsg{} + m.state = StateInstallingPackages + m.isLoading = true + return m, tea.Batch(m.spinner.Tick, m.installPackages()) +} + +// enterAuthPhase is called when dependency review (or the Gentoo screens) +// finish. It either routes directly to the sudo/fingerprint flow or shows +// the privesc-tool selection screen when multiple tools are available and +// no $DMS_PRIVESC override is set. +func (m Model) enterAuthPhase() (tea.Model, tea.Cmd) { + tools := privesc.AvailableTools() + _, envSet := privesc.EnvOverride() + + if len(tools) == 0 { + m.err = fmt.Errorf("no supported privilege tool (sudo/doas/run0) found on PATH") + m.state = StateError + return m, nil + } + + if envSet || len(tools) == 1 { + return m.routeToAuthAfterPrivesc() + } + + m.availablePrivesc = tools + m.selectedPrivesc = 0 + m.state = StateSelectPrivesc + return m, nil +}