diff --git a/flag.go b/flag.go index 04bc329..f348cbf 100644 --- a/flag.go +++ b/flag.go @@ -8,6 +8,7 @@ import ( var ( userName string + confPath string dbusConfigSession string dbusConfigSystem string @@ -22,10 +23,13 @@ var ( flagVerbose bool printVersion bool + + launchMethodText string ) func init() { flag.StringVar(&userName, "u", "chronos", "Passwd name of user to run as") + flag.StringVar(&confPath, "c", "nil", "Path to full app configuration, or \"nil\" to configure from flags") flag.StringVar(&dbusConfigSession, "dbus-config", "builtin", "Path to D-Bus proxy config file, or \"builtin\" for defaults") flag.StringVar(&dbusConfigSystem, "dbus-system", "nil", "Path to system D-Bus proxy config file, or \"nil\" to disable") @@ -48,5 +52,5 @@ func init() { methodHelpString += ", \"systemd\"" } - flag.StringVar(&launchOptionText, "method", "sudo", methodHelpString) + flag.StringVar(&launchMethodText, "method", "sudo", methodHelpString) } diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..9d1165f --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,52 @@ +package app + +import ( + "os/exec" + "sync" +) + +type App interface { + Seal(config *Config) error + Start() error + Wait() (int, error) + WaitErr() error + String() string +} + +type app struct { + // child process related information + seal *appSeal + // underlying fortified child process + cmd *exec.Cmd + // error returned waiting for process + wait error + + lock sync.RWMutex +} + +func (a *app) String() string { + if a == nil { + return "(invalid fortified app)" + } + + a.lock.RLock() + defer a.lock.RUnlock() + + if a.cmd != nil { + return a.cmd.String() + } + + if a.seal != nil { + return "(sealed fortified app as uid " + a.seal.sys.Uid + ")" + } + + return "(unsealed fortified app)" +} + +func (a *app) WaitErr() error { + return a.wait +} + +func New() App { + return new(app) +} diff --git a/internal/app/builder.go b/internal/app/builder.go deleted file mode 100644 index 2bce43b..0000000 --- a/internal/app/builder.go +++ /dev/null @@ -1,13 +0,0 @@ -package app - -func (a *App) Command() []string { - return a.command -} - -func (a *App) UID() int { - return a.uid -} - -func (a *App) AppendEnv(k, v string) { - a.env = append(a.env, k+"="+v) -} diff --git a/internal/app/config.go b/internal/app/config.go new file mode 100644 index 0000000..d964874 --- /dev/null +++ b/internal/app/config.go @@ -0,0 +1,34 @@ +package app + +import ( + "git.ophivana.moe/cat/fortify/dbus" + "git.ophivana.moe/cat/fortify/internal/state" +) + +// Config is used to seal an *App +type Config struct { + // D-Bus application ID + ID string `json:"id"` + // username of the target user to switch to + User string `json:"user"` + // value passed through to the child process as its argv + Command []string `json:"command"` + // string representation of the child's launch method + Method string `json:"method"` + + // child confinement configuration + Confinement ConfinementConfig `json:"confinement"` +} + +// ConfinementConfig defines fortified child's confinement +type ConfinementConfig struct { + // reference to a system D-Bus proxy configuration, + // nil value disables system bus proxy + SystemBus *dbus.Config `json:"system_bus,omitempty"` + // reference to a session D-Bus proxy configuration, + // nil value makes session bus proxy assume built-in defaults + SessionBus *dbus.Config `json:"session_bus,omitempty"` + + // child capability enablements + Enablements state.Enablements `json:"enablements"` +} diff --git a/internal/util/simple.go b/internal/app/copy.go similarity index 74% rename from internal/util/simple.go rename to internal/app/copy.go index 08ce23f..bf90bfe 100644 --- a/internal/util/simple.go +++ b/internal/app/copy.go @@ -1,17 +1,11 @@ -package util +package app import ( "io" "os" - "os/exec" ) -func Which(file string) (string, bool) { - p, err := exec.LookPath(file) - return p, err == nil -} - -func CopyFile(dst, src string) error { +func copyFile(dst, src string) error { srcD, err := os.Open(src) if err != nil { return err diff --git a/internal/app/dbus.go b/internal/app/dbus.go deleted file mode 100644 index fe8039f..0000000 --- a/internal/app/dbus.go +++ /dev/null @@ -1,123 +0,0 @@ -package app - -import ( - "errors" - "fmt" - "os" - "path" - "strconv" - - "git.ophivana.moe/cat/fortify/acl" - "git.ophivana.moe/cat/fortify/dbus" - "git.ophivana.moe/cat/fortify/internal" - "git.ophivana.moe/cat/fortify/internal/util" - "git.ophivana.moe/cat/fortify/internal/verbose" -) - -const ( - dbusSessionBusAddress = "DBUS_SESSION_BUS_ADDRESS" - dbusSystemBusAddress = "DBUS_SYSTEM_BUS_ADDRESS" -) - -var ( - dbusAddress [2]string - dbusSystem bool -) - -func (a *App) ShareDBus(dse, dsg *dbus.Config, log bool) { - a.setEnablement(internal.EnableDBus) - - dbusSystem = dsg != nil - var binPath string - var sessionBus, systemBus [2]string - - target := path.Join(a.sharePath, strconv.Itoa(os.Getpid())) - sessionBus[1] = target + ".bus" - systemBus[1] = target + ".system-bus" - dbusAddress = [2]string{ - "unix:path=" + sessionBus[1], - "unix:path=" + systemBus[1], - } - - if b, ok := util.Which("xdg-dbus-proxy"); !ok { - internal.Fatal("D-Bus: Did not find 'xdg-dbus-proxy' in PATH") - } else { - binPath = b - } - - if addr, ok := os.LookupEnv(dbusSessionBusAddress); !ok { - verbose.Println("D-Bus: DBUS_SESSION_BUS_ADDRESS not set, assuming default format") - sessionBus[0] = fmt.Sprintf("unix:path=/run/user/%d/bus", os.Getuid()) - } else { - sessionBus[0] = addr - } - - if addr, ok := os.LookupEnv(dbusSystemBusAddress); !ok { - verbose.Println("D-Bus: DBUS_SYSTEM_BUS_ADDRESS not set, assuming default format") - systemBus[0] = "unix:path=/run/dbus/system_bus_socket" - } else { - systemBus[0] = addr - } - - p := dbus.New(binPath, sessionBus, systemBus) - - dse.Log = log - verbose.Println("D-Bus: sealing session proxy", dse.Args(sessionBus)) - if dsg != nil { - dsg.Log = log - verbose.Println("D-Bus: sealing system proxy", dsg.Args(systemBus)) - } - if err := p.Seal(dse, dsg); err != nil { - internal.Fatal("D-Bus: invalid config when sealing proxy,", err) - } - - ready := make(chan bool, 1) - done := make(chan struct{}) - - verbose.Printf("Starting session bus proxy '%s' for address '%s'\n", dbusAddress[0], sessionBus[0]) - if dsg != nil { - verbose.Printf("Starting system bus proxy '%s' for address '%s'\n", dbusAddress[1], systemBus[0]) - } - if err := p.Start(&ready); err != nil { - internal.Fatal("D-Bus: error starting proxy,", err) - } - verbose.Println("D-Bus proxy launch:", p) - - go func() { - if err := p.Wait(); err != nil { - fmt.Println("warn: D-Bus proxy returned error,", err) - } else { - verbose.Println("D-Bus proxy uneventful wait") - } - if err := os.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) { - fmt.Println("Error removing dangling D-Bus socket:", err) - } - done <- struct{}{} - }() - - // register early to enable Fatal cleanup - a.exit.SealDBus(p, &done) - - if !<-ready { - internal.Fatal("D-Bus: proxy did not start correctly") - } - - a.AppendEnv(dbusSessionBusAddress, dbusAddress[0]) - if err := acl.UpdatePerm(sessionBus[1], a.UID(), acl.Read, acl.Write); err != nil { - internal.Fatal(fmt.Sprintf("Error preparing D-Bus session proxy '%s':", dbusAddress[0]), err) - } else { - a.exit.RegisterRevertPath(sessionBus[1]) - } - if dsg != nil { - a.AppendEnv(dbusSystemBusAddress, dbusAddress[1]) - if err := acl.UpdatePerm(systemBus[1], a.UID(), acl.Read, acl.Write); err != nil { - internal.Fatal(fmt.Sprintf("Error preparing D-Bus system proxy '%s':", dbusAddress[1]), err) - } else { - a.exit.RegisterRevertPath(systemBus[1]) - } - } - verbose.Printf("Session bus proxy '%s' for address '%s' configured\n", dbusAddress[0], sessionBus[0]) - if dsg != nil { - verbose.Printf("System bus proxy '%s' for address '%s' configured\n", dbusAddress[1], systemBus[0]) - } -} diff --git a/internal/app/ensure.go b/internal/app/ensure.go deleted file mode 100644 index ed283a9..0000000 --- a/internal/app/ensure.go +++ /dev/null @@ -1,63 +0,0 @@ -package app - -import ( - "errors" - "fmt" - "io/fs" - "os" - "path" - - "git.ophivana.moe/cat/fortify/acl" - "git.ophivana.moe/cat/fortify/internal" - "git.ophivana.moe/cat/fortify/internal/verbose" -) - -func (a *App) EnsureRunDir() { - if err := os.Mkdir(a.runDirPath, 0700); err != nil && !errors.Is(err, fs.ErrExist) { - internal.Fatal("Error creating runtime directory:", err) - } -} - -func (a *App) EnsureRuntime() { - if s, err := os.Stat(a.runtimePath); err != nil { - if errors.Is(err, fs.ErrNotExist) { - internal.Fatal("Runtime directory does not exist") - } - internal.Fatal("Error accessing runtime directory:", err) - } else if !s.IsDir() { - internal.Fatal(fmt.Sprintf("Path '%s' is not a directory", a.runtimePath)) - } else { - if err = acl.UpdatePerm(a.runtimePath, a.UID(), acl.Execute); err != nil { - internal.Fatal("Error preparing runtime directory:", err) - } else { - a.exit.RegisterRevertPath(a.runtimePath) - } - verbose.Printf("Runtime data dir '%s' configured\n", a.runtimePath) - } -} - -func (a *App) EnsureShare() { - // acl is unnecessary as this directory is world executable - if err := os.Mkdir(a.sharePath, 0701); err != nil && !errors.Is(err, fs.ErrExist) { - internal.Fatal("Error creating shared directory:", err) - } - - // workaround for launch method sudo - if a.LaunchOption() == LaunchMethodSudo { - // ensure child runtime directory (e.g. `/tmp/fortify.%d/%d.share`) - cr := path.Join(a.sharePath, a.Uid+".share") - if err := os.Mkdir(cr, 0700); err != nil && !errors.Is(err, fs.ErrExist) { - internal.Fatal("Error creating child runtime directory:", err) - } else { - if err = acl.UpdatePerm(cr, a.UID(), acl.Read, acl.Write, acl.Execute); err != nil { - internal.Fatal("Error preparing child runtime directory:", err) - } else { - a.exit.RegisterRevertPath(cr) - } - a.AppendEnv("XDG_RUNTIME_DIR", cr) - a.AppendEnv("XDG_SESSION_CLASS", "user") - a.AppendEnv("XDG_SESSION_TYPE", "tty") - verbose.Printf("Child runtime data dir '%s' configured\n", cr) - } - } -} diff --git a/internal/app/error.go b/internal/app/error.go new file mode 100644 index 0000000..7c18a89 --- /dev/null +++ b/internal/app/error.go @@ -0,0 +1,51 @@ +package app + +import ( + "fmt" + "reflect" +) + +// baseError implements a basic error container +type baseError struct { + Err error +} + +func (e *baseError) Error() string { + return e.Err.Error() +} + +func (e *baseError) Unwrap() error { + return e.Err +} + +// BaseError implements an error container with a user-facing message +type BaseError struct { + message string + baseError +} + +// Message returns a user-facing error message +func (e *BaseError) Message() string { + return e.message +} + +func wrapError(err error, a ...any) *BaseError { + return &BaseError{ + message: fmt.Sprintln(a...), + baseError: baseError{err}, + } +} + +var ( + baseErrorType = reflect.TypeFor[*BaseError]() +) + +func AsBaseError(err error, target **BaseError) bool { + v := reflect.ValueOf(err) + if !v.CanConvert(baseErrorType) { + return false + } + + *target = v.Convert(baseErrorType).Interface().(*BaseError) + return true +} diff --git a/internal/app/id.go b/internal/app/id.go new file mode 100644 index 0000000..e996a3a --- /dev/null +++ b/internal/app/id.go @@ -0,0 +1,18 @@ +package app + +import ( + "crypto/rand" + "encoding/hex" +) + +type appID [16]byte + +func (a *appID) String() string { + return hex.EncodeToString(a[:]) +} + +func newAppID() (*appID, error) { + a := &appID{} + _, err := rand.Read(a[:]) + return a, err +} diff --git a/internal/app/launch.bwrap.go b/internal/app/launch.bwrap.go new file mode 100644 index 0000000..f172843 --- /dev/null +++ b/internal/app/launch.bwrap.go @@ -0,0 +1,8 @@ +package app + +// TODO: launch dbus proxy via bwrap + +func (a *app) commandBuilderBwrap() (args []string) { + // TODO: build bwrap command + panic("bwrap") +} diff --git a/internal/app/launch.go b/internal/app/launch.go deleted file mode 100644 index b44c778..0000000 --- a/internal/app/launch.go +++ /dev/null @@ -1,73 +0,0 @@ -package app - -import ( - "bytes" - "encoding/base64" - "encoding/gob" - "fmt" - "os" - "strings" - "syscall" - - "git.ophivana.moe/cat/fortify/internal" - "git.ophivana.moe/cat/fortify/internal/util" -) - -const launcherPayload = "FORTIFY_LAUNCHER_PAYLOAD" - -func (a *App) launcherPayloadEnv() string { - r := &bytes.Buffer{} - enc := base64.NewEncoder(base64.StdEncoding, r) - - if err := gob.NewEncoder(enc).Encode(a.command); err != nil { - internal.Fatal("Error encoding launcher payload:", err) - } - - _ = enc.Close() - return launcherPayload + "=" + r.String() -} - -// Early hidden launcher path -func Early(printVersion bool) { - if printVersion { - if r, ok := os.LookupEnv(launcherPayload); ok { - dec := base64.NewDecoder(base64.StdEncoding, strings.NewReader(r)) - - var argv []string - if err := gob.NewDecoder(dec).Decode(&argv); err != nil { - fmt.Println("Error decoding launcher payload:", err) - os.Exit(1) - } - - if err := os.Unsetenv(launcherPayload); err != nil { - fmt.Println("Error unsetting launcher payload:", err) - // not fatal, do not fail - } - - var p string - - if len(argv) > 0 { - if p, ok = util.Which(argv[0]); !ok { - fmt.Printf("Did not find '%s' in PATH\n", argv[0]) - os.Exit(1) - } - } else { - if p, ok = os.LookupEnv("SHELL"); !ok { - fmt.Println("No command was specified and $SHELL was unset") - os.Exit(1) - } - - argv = []string{p} - } - - if err := syscall.Exec(p, argv, os.Environ()); err != nil { - fmt.Println("Error executing launcher payload:", err) - os.Exit(1) - } - - // unreachable - os.Exit(1) - return - } - } -} diff --git a/internal/app/launch.machinectl.go b/internal/app/launch.machinectl.go new file mode 100644 index 0000000..21b9d40 --- /dev/null +++ b/internal/app/launch.machinectl.go @@ -0,0 +1,67 @@ +package app + +import ( + "os/exec" + "strings" + + "git.ophivana.moe/cat/fortify/internal/state" + "git.ophivana.moe/cat/fortify/internal/verbose" +) + +func (a *app) commandBuilderMachineCtl() (args []string) { + args = make([]string, 0, 9+len(a.seal.env)) + + // shell --uid=$USER + args = append(args, "shell", "--uid="+a.seal.sys.Username) + + // --quiet + if !verbose.Get() { + args = append(args, "--quiet") + } + + // environ + envQ := make([]string, len(a.seal.env)+1) + for i, e := range a.seal.env { + envQ[i] = "-E" + e + } + // add shim payload to environment for shim path + envQ[len(a.seal.env)] = "-E" + a.shimPayloadEnv() + args = append(args, envQ...) + + // -- .host + args = append(args, "--", ".host") + + // /bin/sh -c + if sh, err := exec.LookPath("sh"); err != nil { + // hardcode /bin/sh path since it exists more often than not + args = append(args, "/bin/sh", "-c") + } else { + args = append(args, sh, "-c") + } + + // build inner command expression ran as target user + innerCommand := strings.Builder{} + + // apply custom environment variables to activation environment + innerCommand.WriteString("dbus-update-activation-environment --systemd") + for _, e := range a.seal.env { + innerCommand.WriteString(" " + strings.SplitN(e, "=", 2)[0]) + } + innerCommand.WriteString("; ") + + // override message bus address if enabled + if a.seal.et.Has(state.EnableDBus) { + innerCommand.WriteString(dbusSessionBusAddress + "=" + "'" + "unix:path=" + a.seal.sys.dbusAddr[0][1] + "' ") + if a.seal.sys.dbusSystem { + innerCommand.WriteString(dbusSystemBusAddress + "=" + "'" + "unix:path=" + a.seal.sys.dbusAddr[1][1] + "' ") + } + } + + // both license and version flags need to be set to activate shim path + innerCommand.WriteString("exec " + a.seal.sys.executable + " -V -license") + + // append inner command + args = append(args, innerCommand.String()) + + return +} diff --git a/internal/app/launch.sudo.go b/internal/app/launch.sudo.go new file mode 100644 index 0000000..666d0b3 --- /dev/null +++ b/internal/app/launch.sudo.go @@ -0,0 +1,33 @@ +package app + +import ( + "os" + + "git.ophivana.moe/cat/fortify/internal/verbose" +) + +const ( + sudoAskPass = "SUDO_ASKPASS" +) + +func (a *app) commandBuilderSudo() (args []string) { + args = make([]string, 0, 4+len(a.seal.env)+len(a.seal.command)) + + // -Hiu $USER + args = append(args, "-Hiu", a.seal.sys.Username) + + // -A? + if _, ok := os.LookupEnv(sudoAskPass); ok { + verbose.Printf("%s set, adding askpass flag\n", sudoAskPass) + args = append(args, "-A") + } + + // environ + args = append(args, a.seal.env...) + + // -- $@ + args = append(args, "--") + args = append(args, a.seal.command...) + + return +} diff --git a/internal/app/pulse.go b/internal/app/pulse.go deleted file mode 100644 index 8b4921e..0000000 --- a/internal/app/pulse.go +++ /dev/null @@ -1,109 +0,0 @@ -package app - -import ( - "errors" - "fmt" - "io/fs" - "os" - "path" - - "git.ophivana.moe/cat/fortify/acl" - "git.ophivana.moe/cat/fortify/internal" - "git.ophivana.moe/cat/fortify/internal/util" - "git.ophivana.moe/cat/fortify/internal/verbose" -) - -const ( - pulseServer = "PULSE_SERVER" - pulseCookie = "PULSE_COOKIE" - - home = "HOME" - xdgConfigHome = "XDG_CONFIG_HOME" -) - -func (a *App) SharePulse() { - a.setEnablement(internal.EnablePulse) - - // ensure PulseAudio directory ACL (e.g. `/run/user/%d/pulse`) - pulse := path.Join(a.runtimePath, "pulse") - pulseS := path.Join(pulse, "native") - if s, err := os.Stat(pulse); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - internal.Fatal("Error accessing PulseAudio directory:", err) - } - internal.Fatal(fmt.Sprintf("PulseAudio dir '%s' not found", pulse)) - } else { - // add environment variable for new process - a.AppendEnv(pulseServer, "unix:"+pulseS) - if err = acl.UpdatePerm(pulse, a.UID(), acl.Execute); err != nil { - internal.Fatal("Error preparing PulseAudio:", err) - } else { - a.exit.RegisterRevertPath(pulse) - } - - // ensure PulseAudio socket permission (e.g. `/run/user/%d/pulse/native`) - if s, err = os.Stat(pulseS); err != nil { - if errors.Is(err, fs.ErrNotExist) { - internal.Fatal("PulseAudio directory found but socket does not exist") - } - internal.Fatal("Error accessing PulseAudio socket:", err) - } else { - if m := s.Mode(); m&0o006 != 0o006 { - internal.Fatal(fmt.Sprintf("Unexpected permissions on '%s':", pulseS), m) - } - } - - // Publish current user's pulse-cookie for target user - pulseCookieSource := discoverPulseCookie() - pulseCookieFinal := path.Join(a.sharePath, "pulse-cookie") - a.AppendEnv(pulseCookie, pulseCookieFinal) - verbose.Printf("Publishing PulseAudio cookie '%s' to '%s'\n", pulseCookieSource, pulseCookieFinal) - if err = util.CopyFile(pulseCookieFinal, pulseCookieSource); err != nil { - internal.Fatal("Error copying PulseAudio cookie:", err) - } - if err = acl.UpdatePerm(pulseCookieFinal, a.UID(), acl.Read); err != nil { - internal.Fatal("Error publishing PulseAudio cookie:", err) - } else { - a.exit.RegisterRevertPath(pulseCookieFinal) - } - - verbose.Printf("PulseAudio dir '%s' configured\n", pulse) - } -} - -// discoverPulseCookie try various standard methods to discover the current user's PulseAudio authentication cookie -func discoverPulseCookie() string { - if p, ok := os.LookupEnv(pulseCookie); ok { - return p - } - - if p, ok := os.LookupEnv(home); ok { - p = path.Join(p, ".pulse-cookie") - if s, err := os.Stat(p); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - internal.Fatal("Error accessing PulseAudio cookie:", err) - // unreachable - return p - } - } else if !s.IsDir() { - return p - } - } - - if p, ok := os.LookupEnv(xdgConfigHome); ok { - p = path.Join(p, "pulse", "cookie") - if s, err := os.Stat(p); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - internal.Fatal("Error accessing PulseAudio cookie:", err) - // unreachable - return p - } - } else if !s.IsDir() { - return p - } - } - - internal.Fatal(fmt.Sprintf("Cannot locate PulseAudio cookie (tried $%s, $%s/pulse/cookie, $%s/.pulse-cookie)", - pulseCookie, xdgConfigHome, home)) - return "" -} diff --git a/internal/app/run.go b/internal/app/run.go deleted file mode 100644 index cc01f48..0000000 --- a/internal/app/run.go +++ /dev/null @@ -1,163 +0,0 @@ -package app - -import ( - "errors" - "fmt" - "os" - "os/exec" - "strings" - - "git.ophivana.moe/cat/fortify/internal" - "git.ophivana.moe/cat/fortify/internal/state" - "git.ophivana.moe/cat/fortify/internal/util" - "git.ophivana.moe/cat/fortify/internal/verbose" -) - -const ( - term = "TERM" - sudoAskPass = "SUDO_ASKPASS" -) -const ( - LaunchMethodSudo uint8 = iota - LaunchMethodBwrap - LaunchMethodMachineCtl -) - -func (a *App) Run() { - // pass $TERM to launcher - if t, ok := os.LookupEnv(term); ok { - a.AppendEnv(term, t) - } - - var commandBuilder func() (args []string) - - switch a.launchOption { - case LaunchMethodSudo: - commandBuilder = a.commandBuilderSudo - case LaunchMethodBwrap: - commandBuilder = a.commandBuilderBwrap - case LaunchMethodMachineCtl: - commandBuilder = a.commandBuilderMachineCtl - default: - panic("unreachable") - } - - cmd := exec.Command(a.toolPath, commandBuilder()...) - cmd.Env = []string{} - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Dir = a.runDirPath - - verbose.Println("Executing:", cmd) - - if err := cmd.Start(); err != nil { - internal.Fatal("Error starting process:", err) - } - - a.exit.SealEnablements(a.enablements) - - if statePath, err := state.SaveProcess(a.Uid, cmd, a.runDirPath, a.command, a.enablements); err != nil { - // process already started, shouldn't be fatal - fmt.Println("Error registering process:", err) - } else { - a.exit.SealStatePath(statePath) - } - - var r int - if err := cmd.Wait(); err != nil { - var exitError *exec.ExitError - if !errors.As(err, &exitError) { - internal.Fatal("Error running process:", err) - } - } - - verbose.Println("Process exited with exit code", r) - internal.BeforeExit() - os.Exit(r) -} - -func (a *App) commandBuilderSudo() (args []string) { - args = make([]string, 0, 4+len(a.env)+len(a.command)) - - // -Hiu $USER - args = append(args, "-Hiu", a.Username) - - // -A? - if _, ok := os.LookupEnv(sudoAskPass); ok { - verbose.Printf("%s set, adding askpass flag\n", sudoAskPass) - args = append(args, "-A") - } - - // environ - args = append(args, a.env...) - - // -- $@ - args = append(args, "--") - args = append(args, a.command...) - - return -} - -func (a *App) commandBuilderBwrap() (args []string) { - // TODO: build bwrap command - internal.Fatal("bwrap") - panic("unreachable") -} - -func (a *App) commandBuilderMachineCtl() (args []string) { - args = make([]string, 0, 9+len(a.env)) - - // shell --uid=$USER - args = append(args, "shell", "--uid="+a.Username) - - // --quiet - if !verbose.Get() { - args = append(args, "--quiet") - } - - // environ - envQ := make([]string, len(a.env)+1) - for i, e := range a.env { - envQ[i] = "-E" + e - } - envQ[len(a.env)] = "-E" + a.launcherPayloadEnv() - args = append(args, envQ...) - - // -- .host - args = append(args, "--", ".host") - - // /bin/sh -c - if sh, ok := util.Which("sh"); !ok { - internal.Fatal("Did not find 'sh' in PATH") - } else { - args = append(args, sh, "-c") - } - - if len(a.command) == 0 { // execute shell if command is not provided - a.command = []string{"$SHELL"} - } - - innerCommand := strings.Builder{} - - innerCommand.WriteString("dbus-update-activation-environment --systemd") - for _, e := range a.env { - innerCommand.WriteString(" " + strings.SplitN(e, "=", 2)[0]) - } - innerCommand.WriteString("; ") - - if executable, err := os.Executable(); err != nil { - internal.Fatal("Error reading executable path:", err) - } else { - if a.enablements.Has(internal.EnableDBus) { - innerCommand.WriteString(dbusSessionBusAddress + "=" + "'" + dbusAddress[0] + "' ") - if dbusSystem { - innerCommand.WriteString(dbusSystemBusAddress + "=" + "'" + dbusAddress[1] + "' ") - } - } - innerCommand.WriteString("exec " + executable + " -V") - } - args = append(args, innerCommand.String()) - - return -} diff --git a/internal/app/seal.go b/internal/app/seal.go new file mode 100644 index 0000000..162faf4 --- /dev/null +++ b/internal/app/seal.go @@ -0,0 +1,154 @@ +package app + +import ( + "errors" + "os" + "os/exec" + "os/user" + "strconv" + + "git.ophivana.moe/cat/fortify/dbus" + "git.ophivana.moe/cat/fortify/internal" + "git.ophivana.moe/cat/fortify/internal/state" + "git.ophivana.moe/cat/fortify/internal/verbose" +) + +const ( + LaunchMethodSudo uint8 = iota + LaunchMethodBwrap + LaunchMethodMachineCtl +) + +var ( + ErrConfig = errors.New("no configuration to seal") + ErrUser = errors.New("unknown user") + ErrLaunch = errors.New("invalid launch method") + + ErrSudo = errors.New("sudo not available") + ErrBwrap = errors.New("bwrap not available") + ErrSystemd = errors.New("systemd not available") + ErrMachineCtl = errors.New("machinectl not available") +) + +type ( + SealConfigError BaseError + LauncherLookupError BaseError + SecurityError BaseError +) + +// Seal seals the app launch context +func (a *app) Seal(config *Config) error { + a.lock.Lock() + defer a.lock.Unlock() + + if a.seal != nil { + panic("app sealed twice") + } + + if config == nil { + return (*SealConfigError)(wrapError(ErrConfig, "attempted to seal app with nil config")) + } + + // create seal + seal := new(appSeal) + + // generate application ID + if id, err := newAppID(); err != nil { + return (*SecurityError)(wrapError(err, "cannot generate application ID:", err)) + } else { + seal.id = id + } + + // fetch system constants + seal.SystemConstants = internal.GetSC() + + // pass through config values + seal.fid = config.ID + seal.command = config.Command + + // parses launch method text and looks up tool path + switch config.Method { + case "sudo": + seal.launchOption = LaunchMethodSudo + if sudoPath, err := exec.LookPath("sudo"); err != nil { + return (*LauncherLookupError)(wrapError(ErrSudo, "sudo not found")) + } else { + seal.toolPath = sudoPath + } + case "bubblewrap": + seal.launchOption = LaunchMethodBwrap + if bwrapPath, err := exec.LookPath("bwrap"); err != nil { + return (*LauncherLookupError)(wrapError(ErrBwrap, "bwrap not found")) + } else { + seal.toolPath = bwrapPath + } + case "systemd": + seal.launchOption = LaunchMethodMachineCtl + if !internal.SdBootedV { + return (*LauncherLookupError)(wrapError(ErrSystemd, + "system has not been booted with systemd as init system")) + } + + if machineCtlPath, err := exec.LookPath("machinectl"); err != nil { + return (*LauncherLookupError)(wrapError(ErrMachineCtl, "machinectl not found")) + } else { + seal.toolPath = machineCtlPath + } + default: + return (*SealConfigError)(wrapError(ErrLaunch, "invalid launch method")) + } + + // create seal system component + seal.sys = new(appSealTx) + + // look up fortify executable path + if p, err := os.Executable(); err != nil { + return (*LauncherLookupError)(wrapError(err, "cannot look up fortify executable path:", err)) + } else { + seal.sys.executable = p + } + + // look up user from system + if u, err := user.Lookup(config.User); err != nil { + if errors.As(err, new(user.UnknownUserError)) { + return (*SealConfigError)(wrapError(ErrUser, "unknown user", config.User)) + } else { + // unreachable + panic(err) + } + } else { + seal.sys.User = u + } + + // open process state store + // the simple store only starts holding an open file after first action + // store activity begins after Start is called and must end before Wait + seal.store = state.NewSimple(seal.SystemConstants.RunDirPath, seal.sys.Uid) + + // parse string UID + if u, err := strconv.Atoi(seal.sys.Uid); err != nil { + // unreachable unless kernel bug + panic("uid parse") + } else { + seal.sys.uid = u + } + + // pass through enablements + seal.et = config.Confinement.Enablements + + // this method calls all share methods in sequence + if err := seal.shareAll([2]*dbus.Config{config.Confinement.SessionBus, config.Confinement.SystemBus}); err != nil { + return err + } + + // verbose log seal information + verbose.Println("created application seal as user", + seal.sys.Username, "("+seal.sys.Uid+"),", + "method:", config.Method+",", + "launcher:", seal.toolPath+",", + "command:", config.Command) + + // seal app and release lock + a.seal = seal + return nil +} diff --git a/internal/app/setup.go b/internal/app/setup.go deleted file mode 100644 index c13468a..0000000 --- a/internal/app/setup.go +++ /dev/null @@ -1,150 +0,0 @@ -package app - -import ( - "errors" - "fmt" - "os" - "os/user" - "path" - "strconv" - - "git.ophivana.moe/cat/fortify/internal" - "git.ophivana.moe/cat/fortify/internal/util" - "git.ophivana.moe/cat/fortify/internal/verbose" -) - -const ( - xdgRuntimeDir = "XDG_RUNTIME_DIR" -) - -type App struct { - uid int // assigned - env []string // modified via AppendEnv - command []string // set on initialisation - - exit *internal.ExitState // assigned - - launchOptionText string // set on initialisation - launchOption uint8 // assigned - - sharePath string // set on initialisation - runtimePath string // assigned - runDirPath string // assigned - toolPath string // assigned - - enablements internal.Enablements // set via setEnablement - *user.User // assigned - - // absolutely *no* method of this type is thread-safe - // so don't treat it as if it is -} - -func (a *App) LaunchOption() uint8 { - return a.launchOption -} - -func (a *App) RunDir() string { - return a.runDirPath -} - -func (a *App) setEnablement(e internal.Enablement) { - if a.enablements.Has(e) { - panic("enablement " + e.String() + " set twice") - } - - a.enablements |= e.Mask() -} - -func (a *App) SealExit(exit *internal.ExitState) { - if a.exit != nil { - panic("application exit state sealed twice") - } - a.exit = exit -} - -func New(userName string, args []string, launchOptionText string) *App { - a := &App{ - command: args, - launchOptionText: launchOptionText, - sharePath: path.Join(os.TempDir(), "fortify."+strconv.Itoa(os.Geteuid())), - } - - // runtimePath, runDirPath - if r, ok := os.LookupEnv(xdgRuntimeDir); !ok { - fmt.Println("Env variable", xdgRuntimeDir, "unset") - - // too early for fatal - os.Exit(1) - } else { - a.runtimePath = r - a.runDirPath = path.Join(a.runtimePath, "fortify") - verbose.Println("Runtime directory at", a.runDirPath) - } - - // *user.User - if u, err := user.Lookup(userName); err != nil { - if errors.As(err, new(user.UnknownUserError)) { - fmt.Println("unknown user", userName) - } else { - // unreachable - panic(err) - } - - // too early for fatal - os.Exit(1) - } else { - a.User = u - } - - // uid - if u, err := strconv.Atoi(a.Uid); err != nil { - // usually unreachable - panic("uid parse") - } else { - a.uid = u - } - - verbose.Println("Running as user", a.Username, "("+a.Uid+"),", "command:", a.command) - if internal.SdBootedV { - verbose.Println("System booted with systemd as init system (PID 1).") - } - - // launchOption, toolPath - switch a.launchOptionText { - case "sudo": - a.launchOption = LaunchMethodSudo - if sudoPath, ok := util.Which("sudo"); !ok { - fmt.Println("Did not find 'sudo' in PATH") - os.Exit(1) - } else { - a.toolPath = sudoPath - } - case "bubblewrap": - a.launchOption = LaunchMethodBwrap - if bwrapPath, ok := util.Which("bwrap"); !ok { - fmt.Println("Did not find 'bwrap' in PATH") - os.Exit(1) - } else { - a.toolPath = bwrapPath - } - case "systemd": - a.launchOption = LaunchMethodMachineCtl - if !internal.SdBootedV { - fmt.Println("System has not been booted with systemd as init system (PID 1).") - os.Exit(1) - } - - if machineCtlPath, ok := util.Which("machinectl"); !ok { - fmt.Println("Did not find 'machinectl' in PATH") - } else { - a.toolPath = machineCtlPath - } - default: - fmt.Println("invalid launch method") - os.Exit(1) - } - - verbose.Println("Determined launch method to be", a.launchOptionText, "with tool at", a.toolPath) - - return a -} diff --git a/internal/app/share.dbus.go b/internal/app/share.dbus.go new file mode 100644 index 0000000..c350c21 --- /dev/null +++ b/internal/app/share.dbus.go @@ -0,0 +1,152 @@ +package app + +import ( + "errors" + "fmt" + "os" + "os/exec" + "path" + + "git.ophivana.moe/cat/fortify/acl" + "git.ophivana.moe/cat/fortify/dbus" + "git.ophivana.moe/cat/fortify/internal/state" + "git.ophivana.moe/cat/fortify/internal/verbose" +) + +const ( + dbusSessionBusAddress = "DBUS_SESSION_BUS_ADDRESS" + dbusSystemBusAddress = "DBUS_SYSTEM_BUS_ADDRESS" + + xdgDBusProxy = "xdg-dbus-proxy" +) + +var ( + ErrDBusConfig = errors.New("dbus config not supplied") + ErrDBusProxy = errors.New(xdgDBusProxy + " not found") + ErrDBusFault = errors.New(xdgDBusProxy + " did not start correctly") +) + +type ( + SealDBusError BaseError + LookupDBusError BaseError + StartDBusError BaseError + CloseDBusError BaseError +) + +func (seal *appSeal) shareDBus(config [2]*dbus.Config) error { + if !seal.et.Has(state.EnableDBus) { + return nil + } + + // session bus is mandatory + if config[0] == nil { + return (*SealDBusError)(wrapError(ErrDBusConfig, "attempted to seal session bus proxy with nil config")) + } + + // system bus is optional + seal.sys.dbusSystem = config[1] != nil + + // upstream address, downstream socket path + var sessionBus, systemBus [2]string + + // downstream socket paths + sessionBus[1] = path.Join(seal.share, "bus") + systemBus[1] = path.Join(seal.share, "system_bus_socket") + + // resolve upstream session bus address + if addr, ok := os.LookupEnv(dbusSessionBusAddress); !ok { + // fall back to default format + sessionBus[0] = fmt.Sprintf("unix:path=/run/user/%d/bus", os.Getuid()) + } else { + sessionBus[0] = addr + } + + // resolve upstream system bus address + if addr, ok := os.LookupEnv(dbusSystemBusAddress); !ok { + // fall back to default hardcoded value + systemBus[0] = "unix:path=/run/dbus/system_bus_socket" + } else { + systemBus[0] = addr + } + + // look up proxy program path for dbus.New + if b, err := exec.LookPath(xdgDBusProxy); err != nil { + return (*LookupDBusError)(wrapError(ErrDBusProxy, xdgDBusProxy, "not found")) + } else { + // create proxy instance + seal.sys.dbus = dbus.New(b, sessionBus, systemBus) + } + + // seal dbus proxy + if err := seal.sys.dbus.Seal(config[0], config[1]); err != nil { + return (*SealDBusError)(wrapError(err, "cannot seal message bus proxy:", err)) + } + + // store addresses for cleanup and logging + seal.sys.dbusAddr = &[2][2]string{sessionBus, systemBus} + + // share proxy sockets + seal.appendEnv(dbusSessionBusAddress, "unix:path="+sessionBus[1]) + seal.sys.updatePerm(sessionBus[1], acl.Read, acl.Write) + if seal.sys.dbusSystem { + seal.appendEnv(dbusSystemBusAddress, "unix:path="+systemBus[1]) + seal.sys.updatePerm(systemBus[1], acl.Read, acl.Write) + } + + return nil +} + +func (tx *appSealTx) startDBus() error { + // ready channel passed to dbus package + ready := make(chan bool, 1) + // used by waiting goroutine to notify process return + tx.dbusWait = make(chan struct{}) + + // background dbus proxy start + if err := tx.dbus.Start(&ready); err != nil { + return (*StartDBusError)(wrapError(err, "cannot start message bus proxy:", err)) + } + + // background wait for proxy instance and notify completion + go func() { + if err := tx.dbus.Wait(); err != nil { + fmt.Println("fortify: warn: message bus proxy returned error:", err) + } else { + verbose.Println("message bus proxy uneventful wait") + } + + // ensure socket removal so ephemeral directory is empty at revert + if err := os.Remove(tx.dbusAddr[0][1]); err != nil && !errors.Is(err, os.ErrNotExist) { + fmt.Println("fortify: cannot remove dangling session bus socket:", err) + } + if tx.dbusSystem { + if err := os.Remove(tx.dbusAddr[1][1]); err != nil && !errors.Is(err, os.ErrNotExist) { + fmt.Println("fortify: cannot remove dangling system bus socket:", err) + } + } + + // notify proxy completion + tx.dbusWait <- struct{}{} + }() + + // ready is false if the proxy process faulted + if !<-ready { + return (*StartDBusError)(wrapError(ErrDBusFault, "message bus proxy failed")) + } + + return nil +} + +func (tx *appSealTx) stopDBus() error { + if err := tx.dbus.Close(); err != nil { + if errors.Is(err, os.ErrClosed) { + return (*CloseDBusError)(wrapError(err, "message bus proxy already closed")) + } else { + return (*CloseDBusError)(wrapError(err, "cannot close message bus proxy:", err)) + } + } + + // block until proxy wait returns + <-tx.dbusWait + return nil +} diff --git a/internal/app/share.display.go b/internal/app/share.display.go new file mode 100644 index 0000000..c189187 --- /dev/null +++ b/internal/app/share.display.go @@ -0,0 +1,59 @@ +package app + +import ( + "errors" + "os" + "path" + + "git.ophivana.moe/cat/fortify/acl" + "git.ophivana.moe/cat/fortify/internal/state" +) + +const ( + term = "TERM" + display = "DISPLAY" + + // https://manpages.debian.org/experimental/libwayland-doc/wl_display_connect.3.en.html + waylandDisplay = "WAYLAND_DISPLAY" +) + +var ( + ErrWayland = errors.New(waylandDisplay + " unset") + ErrXDisplay = errors.New(display + " unset") +) + +type ErrDisplayEnv BaseError + +func (seal *appSeal) shareDisplay() error { + // pass $TERM to launcher + if t, ok := os.LookupEnv(term); ok { + seal.appendEnv(term, t) + } + + // set up wayland + if seal.et.Has(state.EnableWayland) { + if wd, ok := os.LookupEnv(waylandDisplay); !ok { + return (*ErrDisplayEnv)(wrapError(ErrWayland, "WAYLAND_DISPLAY is not set")) + } else { + // wayland socket path + wp := path.Join(seal.RuntimePath, wd) + seal.appendEnv(waylandDisplay, wp) + + // ensure Wayland socket ACL (e.g. `/run/user/%d/wayland-%d`) + seal.sys.updatePerm(wp, acl.Read, acl.Write, acl.Execute) + } + } + + // set up X11 + if seal.et.Has(state.EnableX) { + // discover X11 and grant user permission via the `ChangeHosts` command + if d, ok := os.LookupEnv(display); !ok { + return (*ErrDisplayEnv)(wrapError(ErrXDisplay, "DISPLAY is not set")) + } else { + seal.sys.changeHosts(seal.sys.Username) + seal.appendEnv(display, d) + } + } + + return nil +} diff --git a/internal/app/share.pulse.go b/internal/app/share.pulse.go new file mode 100644 index 0000000..633b859 --- /dev/null +++ b/internal/app/share.pulse.go @@ -0,0 +1,116 @@ +package app + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path" + + "git.ophivana.moe/cat/fortify/acl" + "git.ophivana.moe/cat/fortify/internal/state" +) + +const ( + pulseServer = "PULSE_SERVER" + pulseCookie = "PULSE_COOKIE" + + home = "HOME" + xdgConfigHome = "XDG_CONFIG_HOME" +) + +var ( + ErrPulseCookie = errors.New("pulse cookie not present") + ErrPulseSocket = errors.New("pulse socket not present") + ErrPulseMode = errors.New("unexpected pulse socket mode") +) + +type ( + PulseCookieAccessError BaseError + PulseSocketAccessError BaseError +) + +func (seal *appSeal) sharePulse() error { + if !seal.et.Has(state.EnablePulse) { + return nil + } + + // ensure PulseAudio directory ACL (e.g. `/run/user/%d/pulse`) + pd := path.Join(seal.RuntimePath, "pulse") + ps := path.Join(pd, "native") + if _, err := os.Stat(pd); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return (*PulseSocketAccessError)(wrapError(err, + fmt.Sprintf("cannot access PulseAudio directory '%s':", pd), err)) + } + return (*PulseSocketAccessError)(wrapError(ErrPulseSocket, + fmt.Sprintf("PulseAudio directory '%s' not found", pd))) + } + + seal.appendEnv(pulseServer, "unix:"+ps) + seal.sys.updatePerm(pd, acl.Execute) + + // ensure PulseAudio socket permission (e.g. `/run/user/%d/pulse/native`) + if s, err := os.Stat(ps); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return (*PulseSocketAccessError)(wrapError(err, + fmt.Sprintf("cannot access PulseAudio socket '%s':", ps), err)) + } + return (*PulseSocketAccessError)(wrapError(ErrPulseSocket, + fmt.Sprintf("PulseAudio directory '%s' found but socket does not exist", pd))) + } else { + if m := s.Mode(); m&0o006 != 0o006 { + return (*PulseSocketAccessError)(wrapError(ErrPulseMode, + fmt.Sprintf("unexpected permissions on '%s':", ps), m)) + } + } + + // publish current user's pulse cookie for target user + if src, err := discoverPulseCookie(); err != nil { + return err + } else { + dst := path.Join(seal.share, "pulse-cookie") + seal.appendEnv(pulseCookie, dst) + seal.sys.copyFile(dst, src) + } + + return nil +} + +// discoverPulseCookie attempts various standard methods to discover the current user's PulseAudio authentication cookie +func discoverPulseCookie() (string, error) { + if p, ok := os.LookupEnv(pulseCookie); ok { + return p, nil + } + + // dotfile $HOME/.pulse-cookie + if p, ok := os.LookupEnv(home); ok { + p = path.Join(p, ".pulse-cookie") + if s, err := os.Stat(p); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return p, (*PulseCookieAccessError)(wrapError(err, + fmt.Sprintf("cannot access PulseAudio cookie '%s':", p), err)) + } + // not found, try next method + } else if !s.IsDir() { + return p, nil + } + } + + // $XDG_CONFIG_HOME/pulse/cookie + if p, ok := os.LookupEnv(xdgConfigHome); ok { + p = path.Join(p, "pulse", "cookie") + if s, err := os.Stat(p); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return p, (*PulseCookieAccessError)(wrapError(err, "cannot access PulseAudio cookie", p+":", err)) + } + // not found, try next method + } else if !s.IsDir() { + return p, nil + } + } + + return "", (*PulseCookieAccessError)(wrapError(ErrPulseCookie, + fmt.Sprintf("cannot locate PulseAudio cookie (tried $%s, $%s/pulse/cookie, $%s/.pulse-cookie)", + pulseCookie, xdgConfigHome, home))) +} diff --git a/internal/app/share.runtime.go b/internal/app/share.runtime.go new file mode 100644 index 0000000..e43effe --- /dev/null +++ b/internal/app/share.runtime.go @@ -0,0 +1,50 @@ +package app + +import ( + "path" + + "git.ophivana.moe/cat/fortify/acl" +) + +const ( + xdgRuntimeDir = "XDG_RUNTIME_DIR" + xdgSessionClass = "XDG_SESSION_CLASS" + xdgSessionType = "XDG_SESSION_TYPE" +) + +// shareRuntime queues actions for sharing/ensuring the runtime and share directories +func (seal *appSeal) shareRuntime() { + // ensure RunDir (e.g. `/run/user/%d/fortify`) + seal.sys.ensure(seal.RunDirPath, 0700) + + // ensure runtime directory ACL (e.g. `/run/user/%d`) + seal.sys.updatePerm(seal.RuntimePath, acl.Execute) + + // ensure Share (e.g. `/tmp/fortify.%d`) + // acl is unnecessary as this directory is world executable + seal.sys.ensure(seal.SharePath, 0701) + + // ensure process-specific share (e.g. `/tmp/fortify.%d/%s`) + // acl is unnecessary as this directory is world executable + seal.share = path.Join(seal.SharePath, seal.id.String()) + seal.sys.ensureEphemeral(seal.share, 0701) +} + +func (seal *appSeal) shareRuntimeChild() string { + // ensure child runtime parent directory (e.g. `/tmp/fortify.%d/runtime`) + targetRuntimeParent := path.Join(seal.SharePath, "runtime") + seal.sys.ensure(targetRuntimeParent, 0700) + seal.sys.updatePerm(targetRuntimeParent, acl.Execute) + + // ensure child runtime directory (e.g. `/tmp/fortify.%d/runtime/%d`) + targetRuntime := path.Join(targetRuntimeParent, seal.sys.Uid) + seal.sys.ensure(targetRuntime, 0700) + seal.sys.updatePerm(targetRuntime, acl.Read, acl.Write, acl.Execute) + + // point to ensured runtime path + seal.appendEnv(xdgRuntimeDir, targetRuntime) + seal.appendEnv(xdgSessionClass, "user") + seal.appendEnv(xdgSessionType, "tty") + + return targetRuntime +} diff --git a/internal/app/shim.go b/internal/app/shim.go new file mode 100644 index 0000000..00f711a --- /dev/null +++ b/internal/app/shim.go @@ -0,0 +1,83 @@ +package app + +import ( + "bytes" + "encoding/base64" + "encoding/gob" + "fmt" + "os" + "os/exec" + "strings" + "syscall" +) + +const shimPayload = "FORTIFY_SHIM_PAYLOAD" + +func (a *app) shimPayloadEnv() string { + r := &bytes.Buffer{} + enc := base64.NewEncoder(base64.StdEncoding, r) + + if err := gob.NewEncoder(enc).Encode(a.seal.command); err != nil { + // should be unreachable + panic(err) + } + + _ = enc.Close() + return shimPayload + "=" + r.String() +} + +// TryShim attempts the early hidden launcher shim path +func TryShim() { + // environment variable contains encoded argv + if r, ok := os.LookupEnv(shimPayload); ok { + // everything beyond this point runs as target user + // proceed with caution! + + // parse base64 revealing underlying gob stream + dec := base64.NewDecoder(base64.StdEncoding, strings.NewReader(r)) + + // decode argv gob stream + var argv []string + if err := gob.NewDecoder(dec).Decode(&argv); err != nil { + fmt.Println("fortify-shim: cannot decode shim payload:", err) + os.Exit(1) + } + + // remove payload variable since the child does not need to see it + if err := os.Unsetenv(shimPayload); err != nil { + fmt.Println("fortify-shim: cannot unset shim payload:", err) + // not fatal, do not fail + } + + // look up argv0 + var argv0 string + + if len(argv) > 0 { + // look up program from $PATH + if p, err := exec.LookPath(argv[0]); err != nil { + fmt.Printf("%s not found: %s\n", argv[0], err) + os.Exit(1) + } else { + argv0 = p + } + } else { + // no argv, look up shell instead + if argv0, ok = os.LookupEnv("SHELL"); !ok { + fmt.Println("fortify-shim: no command was specified and $SHELL was unset") + os.Exit(1) + } + + argv = []string{argv0} + } + + // exec target process + if err := syscall.Exec(argv0, argv, os.Environ()); err != nil { + fmt.Println("fortify-shim: cannot execute shim payload:", err) + os.Exit(1) + } + + // unreachable + os.Exit(1) + return + } +} diff --git a/internal/app/start.go b/internal/app/start.go new file mode 100644 index 0000000..b335b68 --- /dev/null +++ b/internal/app/start.go @@ -0,0 +1,187 @@ +package app + +import ( + "errors" + "os" + "os/exec" + "strconv" + "time" + + "git.ophivana.moe/cat/fortify/internal/state" + "git.ophivana.moe/cat/fortify/internal/verbose" +) + +type ( + // ProcessError encapsulates errors returned by starting *exec.Cmd + ProcessError BaseError +) + +// Start starts the fortified child +func (a *app) Start() error { + a.lock.Lock() + defer a.lock.Unlock() + + if err := a.seal.sys.commit(); err != nil { + return err + } + + // select command builder + var commandBuilder func() (args []string) + switch a.seal.launchOption { + case LaunchMethodSudo: + commandBuilder = a.commandBuilderSudo + case LaunchMethodBwrap: + commandBuilder = a.commandBuilderBwrap + case LaunchMethodMachineCtl: + commandBuilder = a.commandBuilderMachineCtl + default: + panic("unreachable") + } + + // configure child process + a.cmd = exec.Command(a.seal.toolPath, commandBuilder()...) + a.cmd.Env = []string{} + a.cmd.Stdin = os.Stdin + a.cmd.Stdout = os.Stdout + a.cmd.Stderr = os.Stderr + a.cmd.Dir = a.seal.RunDirPath + + // start child process + verbose.Println("starting main process:", a.cmd) + if err := a.cmd.Start(); err != nil { + return (*ProcessError)(wrapError(err, "cannot start process:", err)) + } + startTime := time.Now().UTC() + + // create process state + sd := state.State{ + PID: a.cmd.Process.Pid, + Command: a.seal.command, + Capability: a.seal.et, + Launcher: a.seal.toolPath, + Argv: a.cmd.Args, + Time: startTime, + } + + // register process state + var e = new(StateStoreError) + e.Inner, e.DoErr = a.seal.store.Do(func(b state.Backend) { + e.InnerErr = b.Save(&sd) + }) + return e.equiv("cannot save process state:", e) +} + +// StateStoreError is returned for a failed state save +type StateStoreError struct { + // whether inner function was called + Inner bool + // error returned by state.Store Do method + DoErr error + // error returned by state.Backend Save method + InnerErr error + // any other errors needing to be tracked + Err error +} + +func (e *StateStoreError) equiv(a ...any) error { + if e.Inner == true && e.DoErr == nil && e.InnerErr == nil && e.Err == nil { + return nil + } else { + return wrapError(e, a...) + } +} + +func (e *StateStoreError) Error() string { + if e.Inner && e.InnerErr != nil { + return e.InnerErr.Error() + } + + if e.DoErr != nil { + return e.DoErr.Error() + } + + if e.Err != nil { + return e.Err.Error() + } + + return "(nil)" +} + +func (e *StateStoreError) Unwrap() (errs []error) { + errs = make([]error, 0, 3) + if e.DoErr != nil { + errs = append(errs, e.DoErr) + } + if e.InnerErr != nil { + errs = append(errs, e.InnerErr) + } + if e.Err != nil { + errs = append(errs, e.Err) + } + return +} + +type RevertCompoundError interface { + Error() string + Unwrap() []error +} + +func (a *app) Wait() (int, error) { + a.lock.Lock() + defer a.lock.Unlock() + + var r int + + // wait for process and resolve exit code + if err := a.cmd.Wait(); err != nil { + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + // should be unreachable + a.wait = err + } + + // store non-zero return code + r = exitError.ExitCode() + } else { + r = a.cmd.ProcessState.ExitCode() + } + + verbose.Println("process", strconv.Itoa(a.cmd.Process.Pid), "exited with exit code", r) + + // update store and revert app setup transaction + e := new(StateStoreError) + e.Inner, e.DoErr = a.seal.store.Do(func(b state.Backend) { + e.InnerErr = func() error { + // destroy defunct state entry + if err := b.Destroy(a.cmd.Process.Pid); err != nil { + return err + } + + var global bool + + // measure remaining state entries + if l, err := b.Len(); err != nil { + return err + } else { + // clean up global modifications if we're the last launcher alive + global = l == 0 + + if !global { + verbose.Printf("found %d active launchers, cleaning up without globals\n", l) + } else { + verbose.Println("no other launchers active, will clean up globals") + } + } + + // FIXME: depending on exit sequence, some parts of the transaction never gets reverted + if err := a.seal.sys.revert(global); err != nil { + return err.(RevertCompoundError) + } + + return nil + }() + }) + + e.Err = a.seal.store.Close() + return r, e.equiv("error returned during cleanup:", e) +} diff --git a/internal/app/system.go b/internal/app/system.go new file mode 100644 index 0000000..36802dd --- /dev/null +++ b/internal/app/system.go @@ -0,0 +1,351 @@ +package app + +import ( + "errors" + "fmt" + "io/fs" + "os" + "os/user" + + "git.ophivana.moe/cat/fortify/acl" + "git.ophivana.moe/cat/fortify/dbus" + "git.ophivana.moe/cat/fortify/internal" + "git.ophivana.moe/cat/fortify/internal/state" + "git.ophivana.moe/cat/fortify/internal/verbose" + "git.ophivana.moe/cat/fortify/xcb" +) + +// appSeal seals the application with child-related information +type appSeal struct { + // application unique identifier + id *appID + + // freedesktop application ID + fid string + // argv to start process with in the final confined environment + command []string + // environment variables of fortified process + env []string + // persistent process state store + store state.Store + + // uint8 representation of launch method sealed from config + launchOption uint8 + // process-specific share directory path + share string + + // path to launcher program + toolPath string + // pass-through enablement tracking from config + et state.Enablements + + // prevents sharing from happening twice + shared bool + // seal system-level component + sys *appSealTx + + // used in various sealing operations + internal.SystemConstants + + // protected by upstream mutex +} + +// appendEnv appends an environment variable for the child process +func (seal *appSeal) appendEnv(k, v string) { + seal.env = append(seal.env, k+"="+v) +} + +// appSealTx contains the system-level component of the app seal +type appSealTx struct { + // reference to D-Bus proxy instance, nil if disabled + dbus *dbus.Proxy + // notification from goroutine waiting for dbus.Proxy + dbusWait chan struct{} + // upstream address/downstream path used to initialise dbus.Proxy + dbusAddr *[2][2]string + // whether system bus proxy is enabled + dbusSystem bool + + // paths to append/strip ACLs (of target user) from + acl []*appACLEntry + // X11 ChangeHosts commands to perform + xhost []string + // paths of directories to ensure + mkdir []appEnsureEntry + // dst, src pairs of temporarily shared files + tmpfiles [][2]string + + // sealed path to fortify executable, used by shim + executable string + // target user UID as an integer + uid int + // target user sealed from config + *user.User + + // prevents commit from happening twice + complete bool + // prevents cleanup from happening twice + closed bool + + // protected by upstream mutex +} + +type appEnsureEntry struct { + path string + perm os.FileMode + remove bool +} + +// ensure appends a directory ensure action +func (tx *appSealTx) ensure(path string, perm os.FileMode) { + tx.mkdir = append(tx.mkdir, appEnsureEntry{path, perm, false}) +} + +// ensureEphemeral appends a directory ensure action with removal in rollback +func (tx *appSealTx) ensureEphemeral(path string, perm os.FileMode) { + tx.mkdir = append(tx.mkdir, appEnsureEntry{path, perm, true}) +} + +// appACLEntry contains information for applying/reverting an ACL entry +type appACLEntry struct { + path string + perms []acl.Perm +} + +func (e *appACLEntry) String() string { + var s = []byte("---") + for _, p := range e.perms { + switch p { + case acl.Read: + s[0] = 'r' + case acl.Write: + s[1] = 'w' + case acl.Execute: + s[2] = 'x' + } + } + return string(s) +} + +// updatePerm appends an acl update action +func (tx *appSealTx) updatePerm(path string, perms ...acl.Perm) { + tx.acl = append(tx.acl, &appACLEntry{path, perms}) +} + +// changeHosts appends target username of an X11 ChangeHosts action +func (tx *appSealTx) changeHosts(username string) { + tx.xhost = append(tx.xhost, username) +} + +// copyFile appends a tmpfiles action +func (tx *appSealTx) copyFile(dst, src string) { + tx.tmpfiles = append(tx.tmpfiles, [2]string{dst, src}) + tx.updatePerm(dst, acl.Read) +} + +type ( + ChangeHostsError BaseError + EnsureDirError BaseError + TmpfileError BaseError + DBusStartError BaseError + ACLUpdateError BaseError +) + +// commit applies recorded actions +// order: xhost, mkdir, tmpfiles, dbus, acl +func (tx *appSealTx) commit() error { + if tx.complete { + panic("seal transaction committed twice") + } + tx.complete = true + + txp := &appSealTx{} + defer func() { + // rollback partial commit + if txp != nil { + // global changes (x11, ACLs) are always repeated and check for other launchers cannot happen here + // attempting cleanup here will cause other fortified processes to lose access to them + // a better (and more secure) fix is to proxy access to these resources and eliminate the ACLs altogether + if err := txp.revert(false); err != nil { + fmt.Println("fortify: errors returned reverting partial commit:", err) + } + } + }() + + // insert xhost entries + for _, username := range tx.xhost { + verbose.Printf("inserting XHost entry SI:localuser:%s\n", username) + if err := xcb.ChangeHosts(xcb.HostModeInsert, xcb.FamilyServerInterpreted, "localuser\x00"+username); err != nil { + return (*ChangeHostsError)(wrapError(err, + fmt.Sprintf("cannot insert XHost entry SI:localuser:%s, %s", username, err))) + } else { + // register partial commit + txp.changeHosts(username) + } + } + + // ensure directories + for _, dir := range tx.mkdir { + verbose.Println("ensuring directory mode:", dir.perm.String(), "path:", dir.path) + if err := os.Mkdir(dir.path, dir.perm); err != nil && !errors.Is(err, fs.ErrExist) { + return (*EnsureDirError)(wrapError(err, + fmt.Sprintf("cannot create directory '%s': %s", dir.path, err))) + } else { + // only ephemeral dirs require rollback + if dir.remove { + // register partial commit + txp.ensureEphemeral(dir.path, dir.perm) + } + } + } + + // publish tmpfiles + for _, tmpfile := range tx.tmpfiles { + verbose.Println("publishing tmpfile", tmpfile[0], "from", tmpfile[1]) + if err := copyFile(tmpfile[0], tmpfile[1]); err != nil { + return (*TmpfileError)(wrapError(err, + fmt.Sprintf("cannot publish tmpfile '%s' from '%s': %s", tmpfile[0], tmpfile[1], err))) + } else { + // register partial commit + txp.copyFile(tmpfile[0], tmpfile[1]) + } + } + + if tx.dbus != nil { + // start dbus proxy + verbose.Printf("starting session bus proxy on '%s' for upstream '%s'\n", tx.dbusAddr[0][1], tx.dbusAddr[0][0]) + if tx.dbusSystem { + verbose.Printf("starting system bus proxy on '%s' for upstream '%s'\n", tx.dbusAddr[1][1], tx.dbusAddr[1][0]) + } + if err := tx.startDBus(); err != nil { + return (*DBusStartError)(wrapError(err, "cannot start message bus proxy:", err)) + } else { + txp.dbus = tx.dbus + txp.dbusAddr = tx.dbusAddr + txp.dbusSystem = tx.dbusSystem + txp.dbusWait = tx.dbusWait + + verbose.Println(xdgDBusProxy, "launch:", tx.dbus) + } + } + + // apply ACLs + for _, e := range tx.acl { + verbose.Println("applying ACL", e, "uid:", tx.Uid, "path:", e.path) + if err := acl.UpdatePerm(e.path, tx.uid, e.perms...); err != nil { + return (*ACLUpdateError)(wrapError(err, + fmt.Sprintf("cannot apply ACL to '%s': %s", e.path, err))) + } else { + // register partial commit + txp.updatePerm(e.path, e.perms...) + } + } + + // disarm partial commit rollback + txp = nil + return nil +} + +// revert rolls back recorded actions +// order: acl, dbus, tmpfiles, mkdir, xhost +// errors are printed but not treated as fatal +func (tx *appSealTx) revert(global bool) error { + if tx.closed { + panic("seal transaction reverted twice") + } + tx.closed = true + + // will be slightly over-sized with ephemeral dirs + errs := make([]error, 0, len(tx.acl)+1+len(tx.tmpfiles)+len(tx.mkdir)+len(tx.xhost)) + joinError := func(err error, a ...any) { + var e error + if err != nil { + e = wrapError(err, a...) + } + errs = append(errs, e) + } + + if global { + // revert ACLs + for _, e := range tx.acl { + verbose.Println("stripping ACL", e, "uid:", tx.Uid, "path:", e.path) + err := acl.UpdatePerm(e.path, tx.uid) + joinError(err, fmt.Sprintf("cannot strip ACL entry from '%s': %s", e.path, err)) + } + } + + if tx.dbus != nil { + // stop dbus proxy + verbose.Println("terminating message bus proxy") + err := tx.stopDBus() + joinError(err, "cannot stop message bus proxy:", err) + } + + // remove tmpfiles + for _, tmpfile := range tx.tmpfiles { + verbose.Println("removing tmpfile", tmpfile[0]) + err := os.Remove(tmpfile[0]) + joinError(err, fmt.Sprintf("cannot remove tmpfile '%s': %s", tmpfile[0], err)) + } + + // remove (empty) ephemeral directories + for i := len(tx.mkdir); i > 0; i-- { + dir := tx.mkdir[i-1] + if !dir.remove { + continue + } + + verbose.Println("destroying ephemeral directory mode:", dir.perm.String(), "path:", dir.path) + err := os.Remove(dir.path) + joinError(err, fmt.Sprintf("cannot remove ephemeral directory '%s': %s", dir.path, err)) + } + + if global { + // rollback xhost insertions + for _, username := range tx.xhost { + verbose.Printf("deleting XHost entry SI:localuser:%s\n", username) + err := xcb.ChangeHosts(xcb.HostModeDelete, xcb.FamilyServerInterpreted, "localuser\x00"+username) + joinError(err, "cannot remove XHost entry:", err) + } + } + + return errors.Join(errs...) +} + +// shareAll calls all share methods in sequence +func (seal *appSeal) shareAll(bus [2]*dbus.Config) error { + if seal.shared { + panic("seal shared twice") + } + seal.shared = true + + seal.shareRuntime() + if err := seal.shareDisplay(); err != nil { + return err + } + if err := seal.sharePulse(); err != nil { + return err + } + + // ensure dbus session bus defaults + if bus[0] == nil { + bus[0] = dbus.NewConfig(seal.fid, true, true) + } + + if err := seal.shareDBus(bus); err != nil { + return err + } else if seal.sys.dbusAddr != nil { // set if D-Bus enabled and share successful + verbose.Println("sealed session proxy", bus[0].Args(seal.sys.dbusAddr[0])) + if bus[1] != nil { + verbose.Println("sealed system proxy", bus[1].Args(seal.sys.dbusAddr[1])) + } + } + + // workaround for launch method sudo + if seal.launchOption == LaunchMethodSudo { + targetRuntime := seal.shareRuntimeChild() + verbose.Printf("child runtime data dir '%s' configured\n", targetRuntime) + } + + return nil +} diff --git a/internal/app/wayland.go b/internal/app/wayland.go deleted file mode 100644 index 33ed991..0000000 --- a/internal/app/wayland.go +++ /dev/null @@ -1,35 +0,0 @@ -package app - -import ( - "fmt" - "os" - "path" - - "git.ophivana.moe/cat/fortify/acl" - "git.ophivana.moe/cat/fortify/internal" - "git.ophivana.moe/cat/fortify/internal/verbose" -) - -const ( - // https://manpages.debian.org/experimental/libwayland-doc/wl_display_connect.3.en.html - waylandDisplay = "WAYLAND_DISPLAY" -) - -func (a *App) ShareWayland() { - a.setEnablement(internal.EnableWayland) - - // ensure Wayland socket ACL (e.g. `/run/user/%d/wayland-%d`) - if w, ok := os.LookupEnv(waylandDisplay); !ok { - internal.Fatal("Wayland: WAYLAND_DISPLAY not set") - } else { - // add environment variable for new process - wp := path.Join(a.runtimePath, w) - a.AppendEnv(waylandDisplay, wp) - if err := acl.UpdatePerm(wp, a.UID(), acl.Read, acl.Write, acl.Execute); err != nil { - internal.Fatal(fmt.Sprintf("Error preparing Wayland '%s':", w), err) - } else { - a.exit.RegisterRevertPath(wp) - } - verbose.Printf("Wayland socket '%s' configured\n", w) - } -} diff --git a/internal/app/x.go b/internal/app/x.go deleted file mode 100644 index 16b3d95..0000000 --- a/internal/app/x.go +++ /dev/null @@ -1,31 +0,0 @@ -package app - -import ( - "fmt" - "os" - - "git.ophivana.moe/cat/fortify/internal" - "git.ophivana.moe/cat/fortify/internal/verbose" - "git.ophivana.moe/cat/fortify/xcb" -) - -const display = "DISPLAY" - -func (a *App) ShareX() { - a.setEnablement(internal.EnableX) - - // discovery X11 and grant user permission via the `ChangeHosts` command - if d, ok := os.LookupEnv(display); !ok { - internal.Fatal("X11: DISPLAY not set") - } else { - // add environment variable for new process - a.AppendEnv(display, d) - - verbose.Printf("X11: Adding XHost entry SI:localuser:%s to display '%s'\n", a.Username, d) - if err := xcb.ChangeHosts(xcb.HostModeInsert, xcb.FamilyServerInterpreted, "localuser\x00"+a.Username); err != nil { - internal.Fatal(fmt.Sprintf("Error adding XHost entry to '%s':", d), err) - } else { - a.exit.XcbActionComplete() - } - } -} diff --git a/internal/early.go b/internal/early.go index 48dfcd5..218105f 100644 --- a/internal/early.go +++ b/internal/early.go @@ -1,16 +1,34 @@ package internal import ( + "errors" "fmt" + "io/fs" + "os" +) - "git.ophivana.moe/cat/fortify/internal/util" +const ( + systemdCheckPath = "/run/systemd/system" ) var SdBootedV = func() bool { - if v, err := util.SdBooted(); err != nil { + if v, err := SdBooted(); err != nil { fmt.Println("warn: read systemd marker:", err) return false } else { return v } }() + +// SdBooted implements https://www.freedesktop.org/software/systemd/man/sd_booted.html +func SdBooted() (bool, error) { + _, err := os.Stat(systemdCheckPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + err = nil + } + return false, err + } + + return true, nil +} diff --git a/internal/enablement.go b/internal/enablement.go deleted file mode 100644 index a2dd1e6..0000000 --- a/internal/enablement.go +++ /dev/null @@ -1,34 +0,0 @@ -package internal - -type ( - Enablement uint8 - Enablements uint64 -) - -const ( - EnableWayland Enablement = iota - EnableX - EnableDBus - EnablePulse - - EnableLength -) - -var enablementString = [EnableLength]string{ - "Wayland", - "X11", - "D-Bus", - "PulseAudio", -} - -func (e Enablement) String() string { - return enablementString[e] -} - -func (e Enablement) Mask() Enablements { - return 1 << e -} - -func (es Enablements) Has(e Enablement) bool { - return es&e.Mask() != 0 -} diff --git a/internal/environ.go b/internal/environ.go new file mode 100644 index 0000000..25f4496 --- /dev/null +++ b/internal/environ.go @@ -0,0 +1,59 @@ +package internal + +import ( + "fmt" + "os" + "path" + "strconv" + "sync" + + "git.ophivana.moe/cat/fortify/internal/verbose" +) + +// state that remain constant for the lifetime of the process +// fetched and cached here + +const ( + xdgRuntimeDir = "XDG_RUNTIME_DIR" +) + +// SystemConstants contains state from the operating system +type SystemConstants struct { + // path to shared directory e.g. /tmp/fortify.%d + SharePath string `json:"share_path"` + // XDG_RUNTIME_DIR value e.g. /run/user/%d + RuntimePath string `json:"runtime_path"` + // application runtime directory e.g. /run/user/%d/fortify + RunDirPath string `json:"run_dir_path"` +} + +var ( + scVal SystemConstants + scOnce sync.Once +) + +func copySC() { + sc := SystemConstants{ + SharePath: path.Join(os.TempDir(), "fortify."+strconv.Itoa(os.Geteuid())), + } + + verbose.Println("process share directory at", sc.SharePath) + + // runtimePath, runDirPath + if r, ok := os.LookupEnv(xdgRuntimeDir); !ok { + fmt.Println("Env variable", xdgRuntimeDir, "unset") + os.Exit(1) + } else { + sc.RuntimePath = r + sc.RunDirPath = path.Join(sc.RuntimePath, "fortify") + verbose.Println("XDG runtime directory at", sc.RunDirPath) + } + + scVal = sc +} + +// GetSC returns a populated SystemConstants value +func GetSC() SystemConstants { + scOnce.Do(copySC) + return scVal +} diff --git a/internal/exit.go b/internal/exit.go deleted file mode 100644 index 35e8662..0000000 --- a/internal/exit.go +++ /dev/null @@ -1,176 +0,0 @@ -package internal - -import ( - "errors" - "fmt" - "io/fs" - "os" - "os/user" - - "git.ophivana.moe/cat/fortify/acl" - "git.ophivana.moe/cat/fortify/dbus" - "git.ophivana.moe/cat/fortify/internal/verbose" - "git.ophivana.moe/cat/fortify/xcb" -) - -// ExitState keeps track of various changes fortify made to the system -// as well as other resources that need to be manually released. -// NOT thread safe. -type ExitState struct { - // target fortified user inherited from app.App - user *user.User - // integer UID of targeted user - uid int - // returns amount of launcher states read - launcherStateCount func() (int, error) - - // paths to strip ACLs (of target user) from - aclCleanupCandidate []string - // target process capability enablements - enablements *Enablements - // whether the xcb.ChangeHosts action was complete - xcbActionComplete bool - - // reference to D-Bus proxy instance, nil if disabled - dbusProxy *dbus.Proxy - // D-Bus wait complete notification - dbusDone *chan struct{} - - // path to fortify process state information - statePath string - - // prevents cleanup from happening twice - complete bool -} - -// RegisterRevertPath registers a path with ACLs added by fortify -func (s *ExitState) RegisterRevertPath(p string) { - s.aclCleanupCandidate = append(s.aclCleanupCandidate, p) -} - -// SealEnablements submits the child process enablements -func (s *ExitState) SealEnablements(e Enablements) { - if s.enablements != nil { - panic("enablement exit state set twice") - } - s.enablements = &e -} - -// XcbActionComplete submits xcb.ChangeHosts action completion -func (s *ExitState) XcbActionComplete() { - if s.xcbActionComplete { - Fatal("xcb inserted twice") - } - s.xcbActionComplete = true -} - -// SealDBus submits the child's D-Bus proxy instance -func (s *ExitState) SealDBus(p *dbus.Proxy, done *chan struct{}) { - if p == nil { - Fatal("unexpected nil dbus proxy exit state submitted") - } - if s.dbusProxy != nil { - Fatal("dbus proxy exit state set twice") - } - s.dbusProxy = p - s.dbusDone = done -} - -// SealStatePath submits filesystem path to the fortify process's state file -func (s *ExitState) SealStatePath(v string) { - if s.statePath != "" { - panic("statePath set twice") - } - - s.statePath = v -} - -// NewExit initialises a new ExitState containing basic, unchanging information -// about the fortify process required during cleanup -func NewExit(u *user.User, uid int, f func() (int, error)) *ExitState { - return &ExitState{ - uid: uid, - user: u, - - launcherStateCount: f, - } -} - -func Fatal(msg ...any) { - fmt.Println(msg...) - BeforeExit() - os.Exit(1) -} - -var exitState *ExitState - -func SealExit(s *ExitState) { - if exitState != nil { - panic("exit state submitted twice") - } - - exitState = s -} - -func BeforeExit() { - if exitState == nil { - fmt.Println("warn: cleanup attempted before exit state submission") - return - } - - exitState.beforeExit() -} - -func (s *ExitState) beforeExit() { - if s.complete { - panic("beforeExit called twice") - } - - if s.statePath == "" { - verbose.Println("State path is unset") - } else { - if err := os.Remove(s.statePath); err != nil && !errors.Is(err, fs.ErrNotExist) { - fmt.Println("Error removing state file:", err) - } - } - - if count, err := s.launcherStateCount(); err != nil { - fmt.Println("Error reading active launchers:", err) - os.Exit(1) - } else if count > 0 { - // other launchers are still active - verbose.Printf("Found %d active launchers, exiting without cleaning up\n", count) - return - } - - verbose.Println("No other launchers active, will clean up") - - if s.xcbActionComplete { - verbose.Printf("X11: Removing XHost entry SI:localuser:%s\n", s.user.Username) - if err := xcb.ChangeHosts(xcb.HostModeDelete, xcb.FamilyServerInterpreted, "localuser\x00"+s.user.Username); err != nil { - fmt.Println("Error removing XHost entry:", err) - } - } - - for _, candidate := range s.aclCleanupCandidate { - if err := acl.UpdatePerm(candidate, s.uid); err != nil { - fmt.Printf("Error stripping ACL entry from '%s': %s\n", candidate, err) - } - verbose.Printf("Stripped ACL entry for user '%s' from '%s'\n", s.user.Username, candidate) - } - - if s.dbusProxy != nil { - verbose.Println("D-Bus proxy registered, cleaning up") - - if err := s.dbusProxy.Close(); err != nil { - if errors.Is(err, os.ErrClosed) { - verbose.Println("D-Bus proxy already closed") - } else { - fmt.Println("Error closing D-Bus proxy:", err) - } - } - - // wait for Proxy.Wait to return - <-*s.dbusDone - } -} diff --git a/internal/state/data.go b/internal/state/data.go deleted file mode 100644 index 4549388..0000000 --- a/internal/state/data.go +++ /dev/null @@ -1,59 +0,0 @@ -package state - -import ( - "encoding/gob" - "os" - "path" - - "git.ophivana.moe/cat/fortify/internal" -) - -// we unfortunately have to assume there are never races between processes -// this and launcher should eventually be replaced by a server process - -type launcherState struct { - PID int - Launcher string - Argv []string - Command []string - Capability internal.Enablements -} - -// ReadLaunchers reads all launcher state file entries for the requested user -// and if decode is true decodes these launchers as well. -func ReadLaunchers(runDirPath, uid string, decode bool) ([]*launcherState, error) { - var f *os.File - var r []*launcherState - launcherPrefix := path.Join(runDirPath, uid) - - if pl, err := os.ReadDir(launcherPrefix); err != nil { - return nil, err - } else { - for _, e := range pl { - if err = func() error { - if f, err = os.Open(path.Join(launcherPrefix, e.Name())); err != nil { - return err - } else { - defer func() { - if f.Close() != nil { - // unreachable - panic("foreign state file closed prematurely") - } - }() - - var s launcherState - r = append(r, &s) - if decode { - return gob.NewDecoder(f).Decode(&s) - } else { - return nil - } - } - }(); err != nil { - return nil, err - } - } - } - - return r, nil -} diff --git a/internal/state/enablement.go b/internal/state/enablement.go new file mode 100644 index 0000000..9cc8f60 --- /dev/null +++ b/internal/state/enablement.go @@ -0,0 +1,46 @@ +package state + +type ( + // Enablement represents an optional system resource + Enablement uint8 + // Enablements represents optional system resources to share + Enablements uint64 +) + +const ( + EnableWayland Enablement = iota + EnableX + EnableDBus + EnablePulse + + EnableLength +) + +var enablementString = [EnableLength]string{ + "Wayland", + "X11", + "D-Bus", + "PulseAudio", +} + +func (e Enablement) String() string { + return enablementString[e] +} + +func (e Enablement) Mask() Enablements { + return 1 << e +} + +// Has returns whether a feature is enabled +func (es *Enablements) Has(e Enablement) bool { + return *es&e.Mask() != 0 +} + +// Set enables a feature +func (es *Enablements) Set(e Enablement) { + if es.Has(e) { + panic("enablement " + e.String() + " set twice") + } + + *es |= e.Mask() +} diff --git a/internal/state/print.go b/internal/state/print.go index 838cacb..b4c74a9 100644 --- a/internal/state/print.go +++ b/internal/state/print.go @@ -3,69 +3,122 @@ package state import ( "fmt" "os" + "path" "strconv" "strings" "text/tabwriter" + "time" "git.ophivana.moe/cat/fortify/internal" "git.ophivana.moe/cat/fortify/internal/verbose" ) -func MustPrintLauncherStateGlobal(w **tabwriter.Writer, runDirPath string) { - if dirs, err := os.ReadDir(runDirPath); err != nil { - fmt.Println("Error reading runtime directory:", err) +// MustPrintLauncherStateSimpleGlobal prints active launcher states of all simple stores +// in an implementation-specific way. +func MustPrintLauncherStateSimpleGlobal(w **tabwriter.Writer) { + sc := internal.GetSC() + now := time.Now().UTC() + + // read runtime directory to get all UIDs + if dirs, err := os.ReadDir(sc.RunDirPath); err != nil { + fmt.Println("cannot read runtime directory:", err) + os.Exit(1) } else { for _, e := range dirs { + // skip non-directories if !e.IsDir() { - verbose.Println("Skipped non-directory entry", e.Name()) + verbose.Println("skipped non-directory entry", e.Name()) continue } + // skip non-numerical names if _, err = strconv.Atoi(e.Name()); err != nil { - verbose.Println("Skipped non-uid entry", e.Name()) + verbose.Println("skipped non-uid entry", e.Name()) continue } - MustPrintLauncherState(w, runDirPath, e.Name()) + // obtain temporary store + s := NewSimple(sc.RunDirPath, e.Name()).(*simpleStore) + + // print states belonging to this store + s.mustPrintLauncherState(w, now) + + // mustPrintLauncherState causes store activity so store needs to be closed + if err = s.Close(); err != nil { + fmt.Printf("warn: error closing store for user %s: %s\n", e.Name(), err) + } } } } -func MustPrintLauncherState(w **tabwriter.Writer, runDirPath, uid string) { - launchers, err := ReadLaunchers(runDirPath, uid, true) - if err != nil { - fmt.Println("Error reading launchers:", err) +func (s *simpleStore) mustPrintLauncherState(w **tabwriter.Writer, now time.Time) { + var innerErr error + + if ok, err := s.Do(func(b Backend) { + innerErr = func() error { + // read launcher states + states, err := b.Load() + if err != nil { + return err + } + + // initialise tabwriter if nil + if *w == nil { + *w = tabwriter.NewWriter(os.Stdout, 0, 1, 4, ' ', 0) + + // write header when initialising + if !verbose.Get() { + _, _ = fmt.Fprintln(*w, "\tUID\tPID\tUptime\tEnablements\tLauncher\tCommand") + } else { + // argv is emitted in body when verbose + _, _ = fmt.Fprintln(*w, "\tUID\tPID\tArgv") + } + } + + // print each state + for _, state := range states { + // skip nil states + if state == nil { + _, _ = fmt.Fprintln(*w, "\tnil state entry") + continue + } + + // build enablements string + ets := strings.Builder{} + // append enablement strings in order + for i := Enablement(0); i < EnableLength; i++ { + if state.Capability.Has(i) { + ets.WriteString(", " + i.String()) + } + } + // prevent an empty string when + if ets.Len() == 0 { + ets.WriteString("(No enablements)") + } + + if !verbose.Get() { + _, _ = fmt.Fprintf(*w, "\t%s\t%d\t%s\t%s\t%s\t%s\n", + s.path[len(s.path)-1], state.PID, now.Sub(state.Time).String(), strings.TrimPrefix(ets.String(), ", "), state.Launcher, + state.Command) + } else { + // emit argv instead when verbose + _, _ = fmt.Fprintf(*w, "\t%s\t%d\t%s\n", + s.path[len(s.path)-1], state.PID, state.Argv) + } + } + + return nil + }() + }); err != nil { + fmt.Printf("cannot perform action on store '%s': %s\n", path.Join(s.path...), err) + if !ok { + fmt.Println("warn: store faulted before printing") + os.Exit(1) + } + } + + if innerErr != nil { + fmt.Printf("cannot print launcher state for store '%s': %s\n", path.Join(s.path...), innerErr) os.Exit(1) } - - if *w == nil { - *w = tabwriter.NewWriter(os.Stdout, 0, 1, 4, ' ', 0) - - if !verbose.Get() { - _, _ = fmt.Fprintln(*w, "\tUID\tPID\tEnablements\tLauncher\tCommand") - } else { - _, _ = fmt.Fprintln(*w, "\tUID\tPID\tArgv") - } - } - - for _, state := range launchers { - enablementsDescription := strings.Builder{} - for i := internal.Enablement(0); i < internal.EnableLength; i++ { - if state.Capability.Has(i) { - enablementsDescription.WriteString(", " + i.String()) - } - } - if enablementsDescription.Len() == 0 { - enablementsDescription.WriteString("none") - } - - if !verbose.Get() { - _, _ = fmt.Fprintf(*w, "\t%s\t%d\t%s\t%s\t%s\n", - uid, state.PID, strings.TrimPrefix(enablementsDescription.String(), ", "), state.Launcher, - state.Command) - } else { - _, _ = fmt.Fprintf(*w, "\t%s\t%d\t%s\n", - uid, state.PID, state.Argv) - } - } } diff --git a/internal/state/simple.go b/internal/state/simple.go new file mode 100644 index 0000000..a18d152 --- /dev/null +++ b/internal/state/simple.go @@ -0,0 +1,219 @@ +package state + +import ( + "encoding/gob" + "errors" + "io/fs" + "os" + "path" + "strconv" + "sync" + "syscall" +) + +// file-based locking +type simpleStore struct { + path []string + + // created/opened by prepare + lockfile *os.File + // enforce prepare method + init sync.Once + // error returned by prepare + initErr error + + lock sync.Mutex +} + +func (s *simpleStore) Do(f func(b Backend)) (bool, error) { + s.init.Do(s.prepare) + if s.initErr != nil { + return false, s.initErr + } + + s.lock.Lock() + defer s.lock.Unlock() + + // lock store + if err := s.lockFile(); err != nil { + return false, err + } + + // initialise new backend for caller + b := new(simpleBackend) + b.path = path.Join(s.path...) + f(b) + // disable backend + b.lock.Lock() + + // unlock store + return true, s.unlockFile() +} + +func (s *simpleStore) lockFileAct(lt int) (err error) { + op := "LockAct" + switch lt { + case syscall.LOCK_EX: + op = "Lock" + case syscall.LOCK_UN: + op = "Unlock" + } + + for { + err = syscall.Flock(int(s.lockfile.Fd()), lt) + if !errors.Is(err, syscall.EINTR) { + break + } + } + if err != nil { + return &fs.PathError{ + Op: op, + Path: s.lockfile.Name(), + Err: err, + } + } + return nil +} + +func (s *simpleStore) lockFile() error { + return s.lockFileAct(syscall.LOCK_EX) +} + +func (s *simpleStore) unlockFile() error { + return s.lockFileAct(syscall.LOCK_UN) +} + +func (s *simpleStore) prepare() { + s.initErr = func() error { + prefix := path.Join(s.path...) + // ensure directory + if err := os.MkdirAll(prefix, 0700); err != nil && !errors.Is(err, fs.ErrExist) { + return err + } + + // open locker file + if f, err := os.OpenFile(prefix+".lock", os.O_RDWR|os.O_CREATE, 0600); err != nil { + return err + } else { + s.lockfile = f + } + + return nil + }() +} + +func (s *simpleStore) Close() error { + s.lock.Lock() + defer s.lock.Unlock() + + err := s.lockfile.Close() + if err == nil || errors.Is(err, os.ErrInvalid) || errors.Is(err, os.ErrClosed) { + return nil + } + return err +} + +type simpleBackend struct { + path string + + lock sync.RWMutex +} + +func (b *simpleBackend) filename(pid int) string { + return path.Join(b.path, strconv.Itoa(pid)) +} + +// reads all launchers in simpleBackend +// file contents are ignored if decode is false +func (b *simpleBackend) load(decode bool) ([]*State, error) { + b.lock.RLock() + defer b.lock.RUnlock() + + var ( + r []*State + f *os.File + ) + + // read directory contents, should only contain files named after PIDs + if pl, err := os.ReadDir(b.path); err != nil { + return nil, err + } else { + for _, e := range pl { + // run in a function to better handle file closing + if err = func() error { + // open state file for reading + if f, err = os.Open(path.Join(b.path, e.Name())); err != nil { + return err + } else { + defer func() { + if f.Close() != nil { + // unreachable + panic("foreign state file closed prematurely") + } + }() + + var s State + r = append(r, &s) + + // append regardless, but only parse if required, used to implement Len + if decode { + return gob.NewDecoder(f).Decode(&s) + } else { + return nil + } + } + }(); err != nil { + return nil, err + } + } + } + + return r, nil +} + +// Save writes process state to filesystem +func (b *simpleBackend) Save(state *State) error { + b.lock.Lock() + defer b.lock.Unlock() + + statePath := b.filename(state.PID) + + // create and open state data file + if f, err := os.OpenFile(statePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600); err != nil { + return err + } else { + defer func() { + if f.Close() != nil { + // unreachable + panic("state file closed prematurely") + } + }() + // encode into state file + return gob.NewEncoder(f).Encode(state) + } +} + +func (b *simpleBackend) Destroy(pid int) error { + b.lock.Lock() + defer b.lock.Unlock() + + return os.Remove(b.filename(pid)) +} + +func (b *simpleBackend) Load() ([]*State, error) { + return b.load(true) +} + +func (b *simpleBackend) Len() (int, error) { + // rn consists of only nil entries but has the correct length + rn, err := b.load(false) + return len(rn), err +} + +// NewSimple returns an instance of a file-based store. +// Store prefix is typically (runDir, uid). +func NewSimple(prefix ...string) Store { + b := new(simpleStore) + b.path = prefix + return b +} diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..61aae2c --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,40 @@ +package state + +import ( + "time" +) + +type Store interface { + // Do calls f exactly once and ensures store exclusivity until f returns. + // Returns whether f is called and any errors during the locking process. + // Backend provided to f becomes invalid as soon as f returns. + Do(f func(b Backend)) (bool, error) + + // Close releases any resources held by Store. + Close() error +} + +// Backend provides access to the store +type Backend interface { + Save(state *State) error + Destroy(pid int) error + Load() ([]*State, error) + Len() (int, error) +} + +// State is the on-disk format for a fortified process's state information +type State struct { + // child process PID value + PID int + // command used to seal the app + Command []string + // capability enablements applied to child + Capability Enablements + + // resolved launcher path + Launcher string + // full argv whe launching + Argv []string + // process start time + Time time.Time +} diff --git a/internal/state/track.go b/internal/state/track.go deleted file mode 100644 index 8ecad13..0000000 --- a/internal/state/track.go +++ /dev/null @@ -1,41 +0,0 @@ -package state - -import ( - "encoding/gob" - "errors" - "io/fs" - "os" - "os/exec" - "path" - "strconv" - - "git.ophivana.moe/cat/fortify/internal" -) - -// SaveProcess called after process start, before wait -func SaveProcess(uid string, cmd *exec.Cmd, runDirPath string, command []string, enablements internal.Enablements) (string, error) { - statePath := path.Join(runDirPath, uid, strconv.Itoa(cmd.Process.Pid)) - state := launcherState{ - PID: cmd.Process.Pid, - Launcher: cmd.Path, - Argv: cmd.Args, - Command: command, - Capability: enablements, - } - - if err := os.Mkdir(path.Join(runDirPath, uid), 0700); err != nil && !errors.Is(err, fs.ErrExist) { - return statePath, err - } - - if f, err := os.OpenFile(statePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600); err != nil { - return statePath, err - } else { - defer func() { - if f.Close() != nil { - // unreachable - panic("state file closed prematurely") - } - }() - return statePath, gob.NewEncoder(f).Encode(state) - } -} diff --git a/internal/util/std.go b/internal/util/std.go deleted file mode 100644 index b237ad7..0000000 --- a/internal/util/std.go +++ /dev/null @@ -1,24 +0,0 @@ -package util - -import ( - "errors" - "io/fs" - "os" -) - -const ( - systemdCheckPath = "/run/systemd/system" -) - -// SdBooted implements https://www.freedesktop.org/software/systemd/man/sd_booted.html -func SdBooted() (bool, error) { - _, err := os.Stat(systemdCheckPath) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - err = nil - } - return false, err - } - - return true, nil -} diff --git a/internal/verbose/print.go b/internal/verbose/print.go index 420df2d..d05c9d7 100644 --- a/internal/verbose/print.go +++ b/internal/verbose/print.go @@ -2,14 +2,16 @@ package verbose import "fmt" +const prefix = "fortify:" + func Println(a ...any) { if verbose.Load() { - fmt.Println(a...) + fmt.Println(append([]any{prefix}, a...)...) } } func Printf(format string, a ...any) { if verbose.Load() { - fmt.Printf(format, a...) + fmt.Printf(prefix+" "+format, a...) } } diff --git a/main.go b/main.go index c35e425..cc1809f 100644 --- a/main.go +++ b/main.go @@ -5,10 +5,7 @@ import ( "errors" "flag" "fmt" - "io/fs" "os" - "strconv" - "syscall" "git.ophivana.moe/cat/fortify/dbus" "git.ophivana.moe/cat/fortify/internal" @@ -19,14 +16,6 @@ import ( var ( Version = "impure" - - a *app.App - s *internal.ExitState - - dbusSession *dbus.Config - dbusSystem *dbus.Config - - launchOptionText string ) func tryVersion() { @@ -40,31 +29,133 @@ func main() { flag.Parse() verbose.Set(flagVerbose) + if internal.SdBootedV { + verbose.Println("system booted with systemd as init system") + } + // launcher payload early exit - app.Early(printVersion) + if printVersion && printLicense { + app.TryShim() + } // version/license command early exit tryVersion() tryLicense() - a = app.New(userName, flag.Args(), launchOptionText) - s = internal.NewExit(a.User, a.UID(), func() (int, error) { - d, err := state.ReadLaunchers(a.RunDir(), a.Uid, false) - return len(d), err - }) - a.SealExit(s) - internal.SealExit(s) + // state query command early exit + tryState() - // parse D-Bus config file if applicable + // prepare config + var config *app.Config + + if confPath == "nil" { + // config from flags + config = configFromFlags() + } else { + // config from file + if f, err := os.Open(confPath); err != nil { + fatalf("cannot access config file '%s': %s\n", confPath, err) + } else { + if err = json.NewDecoder(f).Decode(&config); err != nil { + fatalf("cannot parse config file '%s': %s\n", confPath, err) + } + } + } + + // invoke app + r := 1 + a := app.New() + if err := a.Seal(config); err != nil { + logBaseError(err, "fortify: cannot seal app:") + } else if err = a.Start(); err != nil { + logBaseError(err, "fortify: cannot start app:") + } else if r, err = a.Wait(); err != nil { + r = 1 + + var e *app.BaseError + if !app.AsBaseError(err, &e) { + fmt.Println("fortify: wait failed:", err) + } else { + // Wait only returns either *app.ProcessError or *app.StateStoreError wrapped in a *app.BaseError + var se *app.StateStoreError + if !errors.As(err, &se) { + // does not need special handling + fmt.Print("fortify: " + e.Message()) + } else { + // inner error are either unwrapped store errors + // or joined errors returned by *appSealTx revert + // wrapped in *app.BaseError + var ej app.RevertCompoundError + if !errors.As(se.InnerErr, &ej) { + // does not require special handling + fmt.Print("fortify: " + e.Message()) + } else { + errs := ej.Unwrap() + + // every error here is wrapped in *app.BaseError + for _, ei := range errs { + var eb *app.BaseError + if !errors.As(ei, &eb) { + // unreachable + fmt.Println("fortify: invalid error type returned by revert:", ei) + } else { + // print inner *app.BaseError message + fmt.Print("fortify: " + eb.Message()) + } + } + } + } + } + } + if err := a.WaitErr(); err != nil { + fmt.Println("fortify: inner wait failed:", err) + } + os.Exit(r) +} + +func logBaseError(err error, message string) { + var e *app.BaseError + + if app.AsBaseError(err, &e) { + fmt.Print("fortify: " + e.Message()) + } else { + fmt.Println(message, err) + } +} + +func configFromFlags() (config *app.Config) { + // initialise config from flags + config = &app.Config{ + ID: dbusID, + User: userName, + Command: flag.Args(), + Method: launchMethodText, + } + + // enablements from flags + if mustWayland { + config.Confinement.Enablements.Set(state.EnableWayland) + } + if mustX { + config.Confinement.Enablements.Set(state.EnableX) + } + if mustDBus { + config.Confinement.Enablements.Set(state.EnableDBus) + } + if mustPulse { + config.Confinement.Enablements.Set(state.EnablePulse) + } + + // parse D-Bus config file from flags if applicable if mustDBus { if dbusConfigSession == "builtin" { - dbusSession = dbus.NewConfig(dbusID, true, mpris) + config.Confinement.SessionBus = dbus.NewConfig(dbusID, true, mpris) } else { if f, err := os.Open(dbusConfigSession); err != nil { - internal.Fatal("Error opening D-Bus proxy config file:", err) + fatalf("cannot access session bus proxy config file '%s': %s\n", dbusConfigSession, err) } else { - if err = json.NewDecoder(f).Decode(&dbusSession); err != nil { - internal.Fatal("Error parsing D-Bus proxy config file:", err) + if err = json.NewDecoder(f).Decode(&config.Confinement.SessionBus); err != nil { + fatalf("cannot parse session bus proxy config file '%s': %s\n", dbusConfigSession, err) } } } @@ -72,62 +163,24 @@ func main() { // system bus proxy is optional if dbusConfigSystem != "nil" { if f, err := os.Open(dbusConfigSystem); err != nil { - internal.Fatal("Error opening D-Bus proxy config file:", err) + fatalf("cannot access system bus proxy config file '%s': %s\n", dbusConfigSystem, err) } else { - if err = json.NewDecoder(f).Decode(&dbusSystem); err != nil { - internal.Fatal("Error parsing D-Bus proxy config file:", err) + if err = json.NewDecoder(f).Decode(&config.Confinement.SystemBus); err != nil { + fatalf("cannot parse system bus proxy config file '%s': %s\n", dbusConfigSystem, err) } } } - } - // ensure RunDir (e.g. `/run/user/%d/fortify`) - a.EnsureRunDir() - - // state query command early exit - tryState() - - // ensure Share (e.g. `/tmp/fortify.%d`) - a.EnsureShare() - - // warn about target user home directory ownership - if stat, err := os.Stat(a.HomeDir); err != nil { - if verbose.Get() { - switch { - case errors.Is(err, fs.ErrPermission): - fmt.Printf("User %s home directory %s is not accessible\n", a.Username, a.HomeDir) - case errors.Is(err, fs.ErrNotExist): - fmt.Printf("User %s home directory %s does not exis\n", a.Username, a.HomeDir) - default: - fmt.Printf("Error stat user %s home directory %s: %s\n", a.Username, a.HomeDir, err) - } - } - return - } else { - // FreeBSD: not cross-platform - if u := strconv.Itoa(int(stat.Sys().(*syscall.Stat_t).Uid)); u != a.Uid { - fmt.Printf("User %s home directory %s has incorrect ownership (expected UID %s, found %s)", a.Username, a.HomeDir, a.Uid, u) + if dbusVerbose { + config.Confinement.SessionBus.Log = true + config.Confinement.SystemBus.Log = true } } - // ensure runtime directory ACL (e.g. `/run/user/%d`) - a.EnsureRuntime() - - if mustWayland { - a.ShareWayland() - } - - if mustX { - a.ShareX() - } - - if mustDBus { - a.ShareDBus(dbusSession, dbusSystem, dbusVerbose) - } - - if mustPulse { - a.SharePulse() - } - - a.Run() + return +} + +func fatalf(format string, a ...any) { + fmt.Printf("fortify: "+format, a...) + os.Exit(1) } diff --git a/state.go b/state.go index 2e68ddc..96bddff 100644 --- a/state.go +++ b/state.go @@ -3,40 +3,33 @@ package main import ( "flag" "fmt" - "git.ophivana.moe/cat/fortify/internal/state" "os" "text/tabwriter" + + "git.ophivana.moe/cat/fortify/internal/state" ) var ( - stateActionEarly [2]bool + stateActionEarly bool ) func init() { - flag.BoolVar(&stateActionEarly[0], "state", false, "print state information of active launchers") - flag.BoolVar(&stateActionEarly[1], "state-current", false, "print state information of active launchers for the specified user") + flag.BoolVar(&stateActionEarly, "state", false, "print state information of active launchers") } // tryState is called after app initialisation func tryState() { - var w *tabwriter.Writer - - switch { - case stateActionEarly[0]: - state.MustPrintLauncherStateGlobal(&w, a.RunDir()) - case stateActionEarly[1]: - state.MustPrintLauncherState(&w, a.RunDir(), a.Uid) - default: - return - } - - if w != nil { - if err := w.Flush(); err != nil { - fmt.Println("warn: error formatting output:", err) + if stateActionEarly { + var w *tabwriter.Writer + state.MustPrintLauncherStateSimpleGlobal(&w) + if w != nil { + if err := w.Flush(); err != nil { + fmt.Println("warn: error formatting output:", err) + } + } else { + fmt.Println("No information available") } - } else { - fmt.Println("No information available") - } - os.Exit(0) + os.Exit(0) + } } diff --git a/xcb/export.go b/xcb/export.go index 72a7413..6181093 100644 --- a/xcb/export.go +++ b/xcb/export.go @@ -20,13 +20,29 @@ const ( FamilyInternet6 = C.XCB_FAMILY_INTERNET_6 ) +type ConnectionError struct { + err error +} + +func (e *ConnectionError) Error() string { + return e.err.Error() +} + +func (e *ConnectionError) Unwrap() error { + return e.err +} + +var ( + ErrChangeHosts = errors.New("xcb_change_hosts() failed") +) + func ChangeHosts(mode, family C.uint8_t, address string) error { var c *C.xcb_connection_t c = C.xcb_connect(nil, nil) defer C.xcb_disconnect(c) if err := xcbHandleConnectionError(c); err != nil { - return err + return &ConnectionError{err} } addr := C.CString(address) @@ -34,13 +50,13 @@ func ChangeHosts(mode, family C.uint8_t, address string) error { C.free(unsafe.Pointer(addr)) if err := xcbHandleConnectionError(c); err != nil { - return err + return &ConnectionError{err} } e := C.xcb_request_check(c, cookie) if e != nil { defer C.free(unsafe.Pointer(e)) - return errors.New("xcb_change_hosts() failed") + return ErrChangeHosts } return nil