app: clean up interactions and handle all application state and setup/teardown

There was an earlier attempt of cleaning up the app package however it ended up creating even more of a mess and the code structure largely still looked like Ego with state setup scattered everywhere and a bunch of ugly hacks had to be implemented to keep track of all of them. In this commit the entire app package is rewritten to track everything that has to do with an app in one thread safe value.

In anticipation of the client/server split also made changes:
- Console messages are cleaned up to be consistent
- State tracking is fully rewritten to be cleaner and usable for multiple process and client/server
- Encapsulate errors to easier identify type of action causing the error as well as additional info
- System-level setup operations is grouped in a way that can be collectively committed/reverted
  and gracefully handles errors returned by each operation
- Resource sharing is made more fine-grained with PID-scoped resources whenever possible,
  a few remnants (X11, Wayland, PulseAudio) will be addressed when a generic proxy is available
- Application setup takes a JSON-friendly config struct and deterministically generates system setup operations

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
This commit is contained in:
Ophestra Umiker 2024-09-22 00:29:36 +09:00
parent 11832a9379
commit 62cb8a91b6
Signed by: cat
SSH Key Fingerprint: SHA256:gQ67O0enBZ7UdZypgtspB2FDM1g3GVw8nX0XSdcFw8Q
41 changed files with 2065 additions and 1247 deletions

View File

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

52
internal/app/app.go Normal file
View File

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

View File

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

34
internal/app/config.go Normal file
View File

@ -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"`
}

View File

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

View File

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

View File

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

51
internal/app/error.go Normal file
View File

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

18
internal/app/id.go Normal file
View File

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

View File

@ -0,0 +1,8 @@
package app
// TODO: launch dbus proxy via bwrap
func (a *app) commandBuilderBwrap() (args []string) {
// TODO: build bwrap command
panic("bwrap")
}

View File

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

View File

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

View File

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

View File

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

View File

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

154
internal/app/seal.go Normal file
View File

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

View File

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

152
internal/app/share.dbus.go Normal file
View File

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

View File

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

116
internal/app/share.pulse.go Normal file
View File

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

View File

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

83
internal/app/shim.go Normal file
View File

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

187
internal/app/start.go Normal file
View File

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

351
internal/app/system.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

59
internal/environ.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -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)
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 {
fmt.Println("Error reading launchers:", err)
os.Exit(1)
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\tEnablements\tLauncher\tCommand")
_, _ = fmt.Fprintln(*w, "\tUID\tPID\tUptime\tEnablements\tLauncher\tCommand")
} else {
// argv is emitted in body when verbose
_, _ = fmt.Fprintln(*w, "\tUID\tPID\tArgv")
}
}
for _, state := range launchers {
enablementsDescription := strings.Builder{}
for i := internal.Enablement(0); i < internal.EnableLength; i++ {
// 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) {
enablementsDescription.WriteString(", " + i.String())
ets.WriteString(", " + i.String())
}
}
if enablementsDescription.Len() == 0 {
enablementsDescription.WriteString("none")
// 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\n",
uid, state.PID, strings.TrimPrefix(enablementsDescription.String(), ", "), state.Launcher,
_, _ = 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",
uid, state.PID, state.Argv)
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)
}
}

219
internal/state/simple.go Normal file
View File

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

40
internal/state/state.go Normal file
View File

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

View File

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

View File

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

View File

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

197
main.go
View File

@ -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)
if dbusVerbose {
config.Confinement.SessionBus.Log = true
config.Confinement.SystemBus.Log = true
}
}
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)
}
}
// 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()
func fatalf(format string, a ...any) {
fmt.Printf("fortify: "+format, a...)
os.Exit(1)
}

View File

@ -3,33 +3,25 @@ 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() {
if stateActionEarly {
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
}
state.MustPrintLauncherStateSimpleGlobal(&w)
if w != nil {
if err := w.Flush(); err != nil {
fmt.Println("warn: error formatting output:", err)
@ -40,3 +32,4 @@ func tryState() {
os.Exit(0)
}
}

View File

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