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 <cat@ophivana.moe>
This commit is contained in:
Ophestra 2024-07-15 23:29:21 +09:00
parent 1cd0846dc9
commit da7e404bcf
Signed by: cat
SSH Key Fingerprint: SHA256:gQ67O0enBZ7UdZypgtspB2FDM1g3GVw8nX0XSdcFw8Q
2 changed files with 342 additions and 19 deletions

22
cli.go
View File

@ -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)
}
}

339
main.go
View File

@ -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)
}