diff --git a/README.md b/README.md index 0128a64..212ba4a 100644 --- a/README.md +++ b/README.md @@ -27,22 +27,22 @@ sudo mv declpac /usr/local/bin/ - pacman - makepkg (for AUR support) - git (for AUR support) -- Root privileges +- sudo/doas (root privileges) ## Usage ```bash # Single state file -sudo declpac --state packages.txt +declpac --state packages.txt # Multiple state files -sudo declpac --state base.txt --state apps.txt +declpac --state base.txt --state apps.txt # From stdin -cat packages.txt | sudo declpac +cat packages.txt | declpac # Preview changes without applying -sudo declpac --dry-run --state packages.txt +declpac --dry-run --state packages.txt ``` ### State File Format @@ -94,13 +94,6 @@ Operation are logged to `$XDG_STATE_HOME/declpac.log` ## Troubleshooting -### Permission denied - -Use sudo: -```bash -sudo declpac --state packages.txt -``` - ### Package not found Check if the package exists: diff --git a/cmd/declpac/main.go b/cmd/declpac/main.go index ddabb6b..469f240 100644 --- a/cmd/declpac/main.go +++ b/cmd/declpac/main.go @@ -8,6 +8,7 @@ import ( "github.com/urfave/cli/v3" + "github.com/Riyyi/declpac/pkg/auth" "github.com/Riyyi/declpac/pkg/input" "github.com/Riyyi/declpac/pkg/log" "github.com/Riyyi/declpac/pkg/output" @@ -97,6 +98,8 @@ func run(cfg *Config) error { return nil } + auth.Start() + if err := log.OpenLog(); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) return err diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go new file mode 100644 index 0000000..53e4b09 --- /dev/null +++ b/pkg/auth/auth.go @@ -0,0 +1,104 @@ +package auth + +import ( + "fmt" + "os/exec" + "regexp" + "strconv" + "time" + + "github.com/Riyyi/declpac/pkg/log" +) + +var tool string +var timeout time.Duration = 5 * time.Minute +var refreshCommand []string = []string{"-n", "true"} + +// ----------------------------------------- +// public + +func Command(name string, args ...string) *exec.Cmd { + if tool == "" { + return log.Command(name, args...) + } + args = append([]string{name}, args...) + return log.Command(tool, args...) +} + +func Run() { + exec.Command(tool, refreshCommand...).Run() +} + +func Start() error { + err := detect() + if err != nil { + return err + } + + // Automatically refresh privilege elevation to prevent user prompts + go func() { + for { + Run() + time.Sleep(timeout) + } + }() + + return nil +} + +// ----------------------------------------- +// private + +func detect() error { + tool = getTool() + if tool == "" { + return fmt.Errorf("no privilege elevation tool detected in PATH") + } + + parseTimeout() + // We have to be a little faster than the actual timeout + timeout -= 30 * time.Second + + return nil +} + +func execLookPath(name string) string { + path, err := exec.LookPath(name) + if err != nil { + return "" + } + return path +} + +func getTool() string { + sudo := execLookPath("sudo") + doas := execLookPath("doas") + + if sudo != "" { + return "sudo" + } + if doas != "" { + return "doas" + } + + return "" +} + +func parseTimeout() { + switch tool { + case "sudo": + out, err := exec.Command("sudo", "sudo", "-V").CombinedOutput() + if err != nil { + return + } + re := regexp.MustCompile(`Authentication timestamp timeout: (\d+)\..*`) + matches := re.FindStringSubmatch(string(out)) + if len(matches) == 2 { + if minutes, err := strconv.Atoi(matches[1]); err == nil { + timeout = time.Duration(minutes) * time.Minute + } + } + case "doas": + exec.Command("doas", "true").Run() + } +} diff --git a/pkg/log/log.go b/pkg/log/log.go index 13ded55..deb636c 100644 --- a/pkg/log/log.go +++ b/pkg/log/log.go @@ -31,7 +31,7 @@ func Command(name string, args ...string) *exec.Cmd { return exec.Command(name, args...) } -func Debug(format string, args ...interface{}) { +func Debug(format string, args ...any) { if !Verbose { return } diff --git a/pkg/pacman/sync/sync.go b/pkg/pacman/sync/sync.go index 4c4434a..eabd3a9 100644 --- a/pkg/pacman/sync/sync.go +++ b/pkg/pacman/sync/sync.go @@ -4,18 +4,16 @@ import ( "fmt" "io" "os" + "os/user" "strings" - "sync" "time" + "github.com/Riyyi/declpac/pkg/auth" "github.com/Riyyi/declpac/pkg/fetch" "github.com/Riyyi/declpac/pkg/fetch/aur" "github.com/Riyyi/declpac/pkg/log" ) -var sudoUser string -var sudoUserOnce sync.Once - type Result struct { Installed int Removed int @@ -37,19 +35,18 @@ func InstallAUR(f *fetch.Fetcher, pkgName string, packageBase string, asDeps boo return err } - sudoUser := getSudoUser() - tmpDir := "/tmp/declpac/" + pkgName - if err := createTempDir(sudoUser, tmpDir); err != nil { + tmpDir := getTempDirName() + "/" + pkgName + if err := createTempDir(tmpDir); err != nil { return err } defer os.RemoveAll(tmpDir) - if err := cloneRepo(sudoUser, packageBase, tmpDir, logWriter); err != nil { + if err := cloneRepo(packageBase, tmpDir, logWriter); err != nil { return err } log.Debug("InstallAUR: cloned (%.2fs)", time.Since(start).Seconds()) - if err := buildPackage(sudoUser, tmpDir, asDeps, logWriter); err != nil { + if err := buildPackage(tmpDir, asDeps, logWriter); err != nil { return err } log.Debug("InstallAUR: built (%.2fs)", time.Since(start).Seconds()) @@ -80,7 +77,7 @@ func MarkAs(packages []string, flag string, logWriter io.Writer) error { } args := append([]string{"-D", "--" + flagName}, packages...) - cmd := log.Command("pacman", args...) + cmd := auth.Command("pacman", args...) cmd.Stdout = logWriter cmd.Stderr = logWriter err := cmd.Run() @@ -100,7 +97,7 @@ func RefreshDB(logWriter io.Writer) error { logWriter = os.Stderr } - cmd := log.Command("pacman", "-Syy") + cmd := auth.Command("pacman", "-Syy") cmd.Stdout = logWriter cmd.Stderr = logWriter if err := cmd.Run(); err != nil { @@ -127,7 +124,7 @@ func RemoveOrphans(orphans []string, logWriter io.Writer) (int, error) { args := make([]string, 0, 3+len(orphans)) args = append(args, "pacman", "-Rns", "--noconfirm") args = append(args, orphans...) - removeCmd := log.Command(args[0], args[1:]...) + removeCmd := auth.Command(args[0], args[1:]...) removeCmd.Stdout = logWriter removeCmd.Stderr = logWriter err := removeCmd.Run() @@ -150,7 +147,7 @@ func SyncPackages(packages []string, logWriter io.Writer) error { } args := append([]string{"-S", "--needed", "--noconfirm"}, packages...) - cmd := log.Command("pacman", args...) + cmd := auth.Command("pacman", args...) cmd.Stdout = logWriter cmd.Stderr = logWriter err := cmd.Run() @@ -165,12 +162,12 @@ func SyncPackages(packages []string, logWriter io.Writer) error { // ----------------------------------------- // private -func buildPackage(sudoUser string, tmpDir string, asDeps bool, logWriter io.Writer) error { - makepkgArgs := []string{"makepkg", "-s", "--noconfirm"} +func buildPackage(tmpDir string, asDeps bool, logWriter io.Writer) error { + makepkgArgs := []string{"-D", tmpDir, "-s", "--noconfirm"} if asDeps { makepkgArgs = append(makepkgArgs, "--asdeps") } - makepkgCmd := log.Command("su", "-", sudoUser, "-c", "cd "+tmpDir+" && "+strings.Join(makepkgArgs, " ")) + makepkgCmd := log.Command("makepkg", makepkgArgs...) makepkgCmd.Stdout = logWriter makepkgCmd.Stderr = logWriter if err := makepkgCmd.Run(); err != nil { @@ -179,9 +176,9 @@ func buildPackage(sudoUser string, tmpDir string, asDeps bool, logWriter io.Writ return nil } -func cloneRepo(sudoUser string, packageBase string, tmpDir string, logWriter io.Writer) error { +func cloneRepo(packageBase string, tmpDir string, logWriter io.Writer) error { cloneURL := "https://aur.archlinux.org/" + packageBase + ".git" - cloneCmd := log.Command("su", "-", sudoUser, "-c", "git clone "+cloneURL+" "+tmpDir) + cloneCmd := log.Command("git", "clone", cloneURL, tmpDir) cloneCmd.Stdout = logWriter cloneCmd.Stderr = logWriter if err := cloneCmd.Run(); err != nil { @@ -190,15 +187,21 @@ func cloneRepo(sudoUser string, packageBase string, tmpDir string, logWriter io. return nil } -func createTempDir(sudoUser string, tmpDir string) error { +func createTempDir(tmpDir string) error { if tmpDir == "" || tmpDir == "/" || !strings.HasPrefix(tmpDir, "/tmp") { return fmt.Errorf("safety check: prevented malformed rm -rf call") } - mkdirCmd := log.Command("su", "-", sudoUser, "-c", "rm -rf "+tmpDir+" && mkdir -p "+tmpDir) + rmdirCmd := log.Command("rm", "-rf", tmpDir) + if err := rmdirCmd.Run(); err != nil { + return fmt.Errorf("failed to remove temp directory: %w", err) + } + + mkdirCmd := log.Command("mkdir", "-p", tmpDir) if err := mkdirCmd.Run(); err != nil { return fmt.Errorf("failed to create temp directory: %w", err) } + return nil } @@ -231,21 +234,17 @@ func getAURInfo(f *fetch.Fetcher, pkgName string, packageBase string) *aur.Packa return &info } -func getSudoUser() string { - sudoUserOnce.Do(func() { - sudoUser = os.Getenv("SUDO_USER") - if sudoUser == "" { - sudoUser = os.Getenv("USER") - if sudoUser == "" { - sudoUser = "root" - } - } - }) - return sudoUser +func getTempDirName() string { + user, err := user.Current() + if err != nil { + return "/tmp/declpac" + } + + return "/tmp/declpac-" + user.Username } func installBuiltPackage(pkgFile string, logWriter io.Writer) error { - installCmd := log.Command("pacman", "-U", "--noconfirm", pkgFile) + installCmd := auth.Command("pacman", "-U", "--noconfirm", pkgFile) installCmd.Stdout = logWriter installCmd.Stderr = logWriter if err := installCmd.Run(); err != nil {