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:
52
internal/app/app.go
Normal file
52
internal/app/app.go
Normal 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)
|
||||
}
|
||||
@@ -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
34
internal/app/config.go
Normal 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"`
|
||||
}
|
||||
33
internal/app/copy.go
Normal file
33
internal/app/copy.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func copyFile(dst, src string) error {
|
||||
srcD, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if srcD.Close() != nil {
|
||||
// unreachable
|
||||
panic("src file closed prematurely")
|
||||
}
|
||||
}()
|
||||
|
||||
dstD, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if dstD.Close() != nil {
|
||||
// unreachable
|
||||
panic("dst file closed prematurely")
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(dstD, srcD)
|
||||
return err
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
51
internal/app/error.go
Normal 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
18
internal/app/id.go
Normal 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
|
||||
}
|
||||
8
internal/app/launch.bwrap.go
Normal file
8
internal/app/launch.bwrap.go
Normal 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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
67
internal/app/launch.machinectl.go
Normal file
67
internal/app/launch.machinectl.go
Normal 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
|
||||
}
|
||||
33
internal/app/launch.sudo.go
Normal file
33
internal/app/launch.sudo.go
Normal 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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
154
internal/app/seal.go
Normal 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
|
||||
}
|
||||
@@ -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
152
internal/app/share.dbus.go
Normal 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
|
||||
}
|
||||
59
internal/app/share.display.go
Normal file
59
internal/app/share.display.go
Normal 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
116
internal/app/share.pulse.go
Normal 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)))
|
||||
}
|
||||
50
internal/app/share.runtime.go
Normal file
50
internal/app/share.runtime.go
Normal 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
83
internal/app/shim.go
Normal 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
187
internal/app/start.go
Normal 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
351
internal/app/system.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user