From da7e404bcffe49240554888b1ca665030073799b Mon Sep 17 00:00:00 2001 From: Ophestra Umiker Date: Mon, 15 Jul 2024 23:29:21 +0900 Subject: [PATCH] main: implement sudo and machinectl launcher methods This does almost exactly what github:intgr/ego does, with some minor optimisations and corrections. Signed-off-by: Ophestra Umiker --- cli.go | 22 +--- main.go | 339 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 342 insertions(+), 19 deletions(-) diff --git a/cli.go b/cli.go index e85917e..9a4d3a0 100644 --- a/cli.go +++ b/cli.go @@ -9,26 +9,17 @@ import ( ) var ( - ego *user.User - command []string - verbose bool - method = machinectl - userName string methodFlags [2]bool printVersion bool -) - -const ( - machinectl uint8 = iota - machinectlBare - sudo + mustPulse bool ) func init() { flag.StringVar(&userName, "u", "ego", "Specify a username") flag.BoolVar(&methodFlags[0], "sudo", false, "Use 'sudo' to change user") flag.BoolVar(&methodFlags[1], "bare", false, "Use 'machinectl' but skip xdg-desktop-portal setup") + flag.BoolVar(&mustPulse, "pulse", false, "Treat unavailable PulseAudio as fatal") flag.BoolVar(&verbose, "v", false, "Verbose output") flag.BoolVar(&printVersion, "V", false, "Print version") } @@ -41,13 +32,6 @@ func copyArgs() { command = flag.Args() - switch { // zero value is machinectl - case methodFlags[0]: - method = sudo - case methodFlags[1]: - method = machinectlBare - } - if u, err := user.Lookup(userName); err != nil { if errors.As(err, new(user.UnknownUserError)) { fmt.Println("unknown user", userName) @@ -62,6 +46,6 @@ func copyArgs() { } if verbose { - fmt.Println("Running command", command, "as user", ego.Username, "("+ego.Uid+")") + fmt.Println("Running as user", ego.Username, "("+ego.Uid+"),", "command:", command) } } diff --git a/main.go b/main.go index 159d6d7..aa07280 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,351 @@ package main import ( + "errors" "flag" + "fmt" + "io/fs" + "os" + "os/exec" + "os/user" + "path" + "strconv" + "strings" + "syscall" ) var Version = "impure" +var ( + ego *user.User + uid int + env []string + command []string + verbose bool + runtime string + runDir string +) + +const ( + term = "TERM" + home = "HOME" + sudoAskPass = "SUDO_ASKPASS" + xdgRuntimeDir = "XDG_RUNTIME_DIR" + xdgConfigHome = "XDG_CONFIG_HOME" + display = "DISPLAY" + pulseServer = "PULSE_SERVER" + pulseCookie = "PULSE_COOKIE" + + // https://manpages.debian.org/experimental/libwayland-doc/wl_display_connect.3.en.html + waylandDisplay = "WAYLAND_DISPLAY" +) + func main() { flag.Parse() + tryLauncher() copyArgs() + + if u, err := strconv.Atoi(ego.Uid); err != nil { + // usually unreachable + panic("ego uid parse") + } else { + uid = u + } + + if r, ok := os.LookupEnv(xdgRuntimeDir); !ok { + fatal("Env variable", xdgRuntimeDir, "unset") + } else { + runtime = r + } + + // Report warning if user home directory does not exist or has wrong ownership + if stat, err := os.Stat(ego.HomeDir); err != nil { + if verbose { + switch { + case errors.Is(err, fs.ErrPermission): + fmt.Printf("User %s home directory %s is not accessible", ego.Username, ego.HomeDir) + case errors.Is(err, fs.ErrNotExist): + fmt.Printf("User %s home directory %s does not exist", ego.Username, ego.HomeDir) + default: + fmt.Printf("Error stat user %s home directory %s: %s", ego.Username, ego.HomeDir, err) + } + } + return + } else { + // FreeBSD: not cross-platform + if u := strconv.Itoa(int(stat.Sys().(*syscall.Stat_t).Uid)); u != ego.Uid { + fmt.Printf("User %s home directory %s has incorrect ownership (expected UID %s, found %s)", ego.Username, ego.HomeDir, ego.Uid, u) + } + } + + // Add execute perm to runtime dir, e.g. `/run/user/%d` + if s, err := os.Stat(runtime); err != nil { + if errors.Is(err, fs.ErrNotExist) { + fatal("Runtime directory does not exist") + } + fatal("Error accessing runtime directory:", err) + } else if !s.IsDir() { + fatal(fmt.Sprintf("Path '%s' is not a directory", runtime)) + } else { + // Cleanup: need revert + if err = aclUpdatePerm(runtime, uid, aclExecute); err != nil { + fatal("Error preparing runtime dir:", err) + } + if verbose { + fmt.Printf("Runtime data dir '%s' configured\n", runtime) + } + } + + // Create runtime dir for Ego itself (e.g. `/run/user/%d/ego`) and make it readable for target + runDir = path.Join(runtime, "ego") + if err := os.Mkdir(runDir, 0700); err != nil && !errors.Is(err, fs.ErrExist) { + fatal("Error creating Ego runtime dir:", err) + } + // Cleanup: need revert + if err := aclUpdatePerm(runDir, uid, aclExecute); err != nil { + fatal("Error preparing Ego runtime dir:", err) + } + // Cleanup: need register control PID + + // Add rwx permissions to Wayland socket (e.g. `/run/user/%d/wayland-0`) + if w, ok := os.LookupEnv(waylandDisplay); !ok { + if verbose { + fmt.Println("Wayland: WAYLAND_DISPLAY not set, skipping") + } + } else { + // add environment variable for new process + env = append(env, waylandDisplay+"="+path.Join(runtime, w)) + // Cleanup: need revert + if err := aclUpdatePerm(path.Join(runtime, w), uid, aclRead, aclWrite, aclExecute); err != nil { + fatal(fmt.Sprintf("Error preparing Wayland '%s':", w), err) + } + if verbose { + fmt.Printf("Wayland socket '%s' configured\n", w) + } + } + + // Detect `DISPLAY` and grant permissions via X11 protocol `ChangeHosts` command + if d, ok := os.LookupEnv(display); !ok { + if verbose { + fmt.Println("X11: DISPLAY not set, skipping") + } + } else { + // add environment variable for new process + env = append(env, display+"="+d) + // Cleanup: need revert + if err := changeHosts(xcbHostModeInsert, xcbFamilyServerInterpreted, "localuser\x00"+ego.Username); err != nil { + fatal(fmt.Sprintf("Error adding XHost entry to '%s':", d), err) + } + if verbose { + fmt.Printf("X11: Adding XHost entry SI:localuser:%s to display '%s'\n", ego.Username, d) + } + } + + // Add execute permissions to PulseAudio directory (e.g. `/run/user/%d/pulse`) + pulse := path.Join(runtime, "pulse") + pulseS := path.Join(pulse, "native") + if s, err := os.Stat(pulse); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + fatal("Error accessing PulseAudio directory:", err) + } + if mustPulse { + fatal("PulseAudio is unavailable") + } + if verbose { + fmt.Printf("PulseAudio dir '%s' not found, skipping\n", pulse) + } + } else { + // add environment variable for new process + env = append(env, pulseServer+"=unix:"+pulseS) + // Cleanup: need revert + if err = aclUpdatePerm(pulse, uid, aclExecute); err != nil { + fatal("Error preparing PulseAudio:", err) + } + + // Ensure permissions of PulseAudio socket `/run/user/%d/pulse/native` + if s, err = os.Stat(pulseS); err != nil { + if errors.Is(err, fs.ErrNotExist) { + fatal("PulseAudio directory found but socket does not exist") + } + fatal("Error accessing PulseAudio socket:", err) + } else { + if m := s.Mode(); m&0o006 != 0o006 { + fatal(fmt.Sprintf("Unexpected permissions on '%s':", pulseS), m) + } + } + + // Publish current user's pulse-cookie for target user + pulseCookieSource := discoverPulseCookie() + env = append(env, pulseCookie+"="+pulseCookieSource) + pulseCookieFinal := path.Join(runDir, "pulse-cookie") + if verbose { + fmt.Printf("Publishing PulseAudio cookie '%s' to '%s'\n", pulseCookieSource, pulseCookieFinal) + } + if err = copyFile(pulseCookieFinal, pulseCookieSource); err != nil { + fatal("Error copying PulseAudio cookie:", err) + } + // Cleanup: need revert + if err = aclUpdatePerm(pulseCookieFinal, uid, aclRead); err != nil { + fatal("Error publishing PulseAudio cookie:", err) + } + + if verbose { + fmt.Printf("PulseAudio dir '%s' configured\n", pulse) + } + } + + // pass $TERM to launcher + if t, ok := os.LookupEnv(term); ok { + env = append(env, term+"="+t) + } + + f := launchBySudo + m, b := false, false + switch { + case methodFlags[0]: // sudo + case methodFlags[1]: // bare + m, b = true, true + default: // machinectl + m, b = true, false + } + + var toolPath string + + // dependency checks + const sudoFallback = "Falling back to 'sudo', some desktop integration features may not work" + if m { + if !sdBooted() { + fmt.Println("This system was not booted through systemd") + fmt.Println(sudoFallback) + } else if tp, ok := which("machinectl"); !ok { + fmt.Println("Did not find 'machinectl' in PATH") + fmt.Println(sudoFallback) + } else { + toolPath = tp + f = func() []string { return launchByMachineCtl(b) } + } + } else if tp, ok := which("sudo"); !ok { + fatal("Did not find 'sudo' in PATH") + } else { + toolPath = tp + } + + if verbose { + fmt.Printf("Selected launcher '%s' bare=%t\n", toolPath, b) + } + + cmd := exec.Command(toolPath, f()...) + cmd.Env = env + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Dir = runDir + + if verbose { + fmt.Println("Executing:", cmd) + } + + if err := cmd.Start(); err != nil { + fatal("Error starting process:", err) + } + + // Cleanup: need register + + var r int + if err := cmd.Wait(); err != nil { + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + fatal("Error running process:", err) + } + } + + // Cleanup: deregister, call revert + + if verbose { + fmt.Println("Process exited with exit code", r) + } + os.Exit(r) +} + +func launchBySudo() (args []string) { + args = make([]string, 0, 4+len(env)+len(command)) + + // -Hiu $USER + args = append(args, "-Hiu", ego.Username) + + // -A? + if _, ok := os.LookupEnv(sudoAskPass); ok { + if verbose { + fmt.Printf("%s set, adding askpass flag\n", sudoAskPass) + } + args = append(args, "-A") + } + + // environ + args = append(args, env...) + + // -- $@ + args = append(args, "--") + args = append(args, command...) + + return +} + +func launchByMachineCtl(bare bool) (args []string) { + args = make([]string, 0, 9+len(env)) + + // shell --uid=$USER + args = append(args, "shell", "--uid="+ego.Username) + + // --quiet + if !verbose { + args = append(args, "--quiet") + } + + // environ + envQ := make([]string, len(env)+1) + for i, e := range env { + envQ[i] = "-E" + e + } + envQ[len(env)] = "-E" + launcherPayloadEnv() + args = append(args, envQ...) + + // -- .host + args = append(args, "--", ".host") + + // /bin/sh -c + if sh, ok := which("sh"); !ok { + fatal("Did not find 'sh' in PATH") + } else { + args = append(args, sh, "-c") + } + + if len(command) == 0 { // execute shell if command is not provided + command = []string{"$SHELL"} + } + + innerCommand := strings.Builder{} + + if !bare { + innerCommand.WriteString("dbus-update-activation-environment --systemd") + for _, e := range env { + innerCommand.WriteString(" " + strings.SplitN(e, "=", 2)[0]) + } + innerCommand.WriteString("; systemctl --user start xdg-desktop-portal-gtk; ") + } + + if executable, err := os.Executable(); err != nil { + fatal("Error reading executable path:", err) + } else { + innerCommand.WriteString("exec " + executable + " -V") + } + args = append(args, innerCommand.String()) + + return +} + +func fatal(msg ...any) { + // Cleanup: call revert + fmt.Println(msg...) + os.Exit(1) }