aboutsummaryrefslogtreecommitdiffhomepage
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/app/app.go52
-rw-r--r--internal/app/builder.go13
-rw-r--r--internal/app/config.go34
-rw-r--r--internal/app/copy.go (renamed from internal/util/simple.go)10
-rw-r--r--internal/app/dbus.go123
-rw-r--r--internal/app/ensure.go63
-rw-r--r--internal/app/error.go51
-rw-r--r--internal/app/id.go18
-rw-r--r--internal/app/launch.bwrap.go8
-rw-r--r--internal/app/launch.go73
-rw-r--r--internal/app/launch.machinectl.go67
-rw-r--r--internal/app/launch.sudo.go33
-rw-r--r--internal/app/pulse.go109
-rw-r--r--internal/app/run.go163
-rw-r--r--internal/app/seal.go154
-rw-r--r--internal/app/setup.go150
-rw-r--r--internal/app/share.dbus.go152
-rw-r--r--internal/app/share.display.go59
-rw-r--r--internal/app/share.pulse.go116
-rw-r--r--internal/app/share.runtime.go50
-rw-r--r--internal/app/shim.go83
-rw-r--r--internal/app/start.go187
-rw-r--r--internal/app/system.go351
-rw-r--r--internal/app/wayland.go35
-rw-r--r--internal/app/x.go31
-rw-r--r--internal/early.go22
-rw-r--r--internal/enablement.go34
-rw-r--r--internal/environ.go59
-rw-r--r--internal/exit.go176
-rw-r--r--internal/state/data.go59
-rw-r--r--internal/state/enablement.go46
-rw-r--r--internal/state/print.go125
-rw-r--r--internal/state/simple.go219
-rw-r--r--internal/state/state.go40
-rw-r--r--internal/state/track.go41
-rw-r--r--internal/util/std.go24
-rw-r--r--internal/verbose/print.go6
37 files changed, 1894 insertions, 1142 deletions
diff --git a/internal/app/app.go b/internal/app/app.go
new file mode 100644
index 00000000..9d1165f4
--- /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 2bce43b4..00000000
--- 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 00000000..d9648740
--- /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
index 08ce23f6..bf90bfea 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 fe8039f6..00000000
--- 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 ed283a94..00000000
--- 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 00000000..7c18a89e
--- /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 00000000..e996a3a6
--- /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 00000000..f172843c
--- /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 b44c7785..00000000
--- 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 00000000..21b9d40b
--- /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 00000000..666d0b3a
--- /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 8b4921ed..00000000
--- 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 cc01f487..00000000
--- 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 00000000..162faf40
--- /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 c13468a0..00000000
--- 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 00000000..c350c21c
--- /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 00000000..c1891876
--- /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 00000000..633b859d
--- /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 00000000..e43effe2
--- /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 00000000..00f711a1
--- /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 00000000..b335b68a
--- /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 00000000..36802ddb
--- /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 33ed9914..00000000
--- 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 16b3d95c..00000000
--- 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 48dfcd54..218105f2 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 a2dd1e6a..00000000
--- 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 00000000..25f44964
--- /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 35e86629..00000000
--- 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 45493885..00000000
--- 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 00000000..9cc8f60d
--- /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 838cacb0..b4c74a90 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)
- os.Exit(1)
- }
+func (s *simpleStore) mustPrintLauncherState(w **tabwriter.Writer, now time.Time) {
+ var innerErr error
- if *w == nil {
- *w = tabwriter.NewWriter(os.Stdout, 0, 1, 4, ' ', 0)
+ if ok, err := s.Do(func(b Backend) {
+ innerErr = func() error {
+ // read launcher states
+ states, err := b.Load()
+ if err != nil {
+ return err
+ }
- if !verbose.Get() {
- _, _ = fmt.Fprintln(*w, "\tUID\tPID\tEnablements\tLauncher\tCommand")
- } else {
- _, _ = fmt.Fprintln(*w, "\tUID\tPID\tArgv")
- }
- }
+ // initialise tabwriter if nil
+ if *w == nil {
+ *w = tabwriter.NewWriter(os.Stdout, 0, 1, 4, ' ', 0)
- 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())
+ // 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")
+ }
}
- }
- 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)
+ // 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)
+ }
}
diff --git a/internal/state/simple.go b/internal/state/simple.go
new file mode 100644
index 00000000..a18d1523
--- /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 00000000..61aae2cf
--- /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 8ecad134..00000000
--- 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 b237ad73..00000000
--- 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 420df2dd..d05c9d77 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...)
}
}