system: isolate app/system into generic implementation

This improves maintainability and extensibility of system operations, makes writing tests for them possible, and operations now apply and revert in order, instead of being bunched up into their own categories.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
This commit is contained in:
Ophestra Umiker 2024-10-16 01:31:23 +09:00
parent 0fd63e85e7
commit 430f1a5b4e
Signed by: cat
SSH Key Fingerprint: SHA256:gQ67O0enBZ7UdZypgtspB2FDM1g3GVw8nX0XSdcFw8Q
6 changed files with 640 additions and 0 deletions

78
internal/system/acl.go Normal file
View File

@ -0,0 +1,78 @@
package system
import (
"fmt"
"slices"
"git.ophivana.moe/cat/fortify/acl"
"git.ophivana.moe/cat/fortify/internal/fmsg"
"git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
// UpdatePerm appends an ephemeral acl update Op.
func (sys *I) UpdatePerm(path string, perms ...acl.Perm) {
sys.UpdatePermType(Process, path, perms...)
}
// UpdatePermType appends an acl update Op.
func (sys *I) UpdatePermType(et state.Enablement, path string, perms ...acl.Perm) {
sys.lock.Lock()
defer sys.lock.Unlock()
sys.ops = append(sys.ops, &ACL{et, path, perms})
}
type ACL struct {
et state.Enablement
path string
perms []acl.Perm
}
func (a *ACL) Type() state.Enablement {
return a.et
}
func (a *ACL) apply(sys *I) error {
verbose.Println("applying ACL", a, "uid:", sys.uid, "type:", TypeString(a.et), "path:", a.path)
return fmsg.WrapErrorSuffix(acl.UpdatePerm(a.path, sys.uid, a.perms...),
fmt.Sprintf("cannot apply ACL entry to %q:", a.path))
}
func (a *ACL) revert(sys *I, ec *Criteria) error {
if ec.hasType(a) {
verbose.Println("stripping ACL", a, "uid:", sys.uid, "type:", TypeString(a.et), "path:", a.path)
return fmsg.WrapErrorSuffix(acl.UpdatePerm(a.path, sys.uid),
fmt.Sprintf("cannot strip ACL entry from %q:", a.path))
} else {
verbose.Println("skipping ACL", a, "uid:", sys.uid, "tag:", TypeString(a.et), "path:", a.path)
return nil
}
}
func (a *ACL) Is(o Op) bool {
a0, ok := o.(*ACL)
return ok && a0 != nil &&
a.et == a0.et &&
a.path == a0.path &&
slices.Equal(a.perms, a0.perms)
}
func (a *ACL) Path() string {
return a.path
}
func (a *ACL) String() string {
var s = []byte("---")
for _, p := range a.perms {
switch p {
case acl.Read:
s[0] = 'r'
case acl.Write:
s[1] = 'w'
case acl.Execute:
s[2] = 'x'
}
}
return string(s)
}

159
internal/system/dbus.go Normal file
View File

@ -0,0 +1,159 @@
package system
import (
"errors"
"fmt"
"os"
"git.ophivana.moe/cat/fortify/dbus"
"git.ophivana.moe/cat/fortify/internal/fmsg"
"git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
var (
ErrDBusConfig = errors.New("dbus config not supplied")
)
func (sys *I) ProxyDBus(session, system *dbus.Config, sessionPath, systemPath string) error {
d := new(DBus)
// used by waiting goroutine to notify process exit
d.done = make(chan struct{})
// session bus is mandatory
if session == nil {
return fmsg.WrapError(ErrDBusConfig,
"attempted to seal message bus proxy without session bus config")
}
// system bus is optional
d.system = system == nil
// upstream address, downstream socket path
var sessionBus, systemBus [2]string
// resolve upstream bus addresses
sessionBus[0], systemBus[0] = dbus.Address()
// set paths from caller
sessionBus[1], systemBus[1] = sessionPath, systemPath
// create proxy instance
d.proxy = dbus.New(sessionBus, systemBus)
defer func() {
if verbose.Get() && d.proxy.Sealed() {
verbose.Println("sealed session proxy", session.Args(sessionBus))
if system != nil {
verbose.Println("sealed system proxy", system.Args(systemBus))
}
verbose.Println("message bus proxy final args:", d.proxy)
}
}()
// queue operation
sys.ops = append(sys.ops, d)
// seal dbus proxy
return fmsg.WrapErrorSuffix(d.proxy.Seal(session, system),
"cannot seal message bus proxy:")
}
type DBus struct {
proxy *dbus.Proxy
// whether system bus proxy is enabled
system bool
// notification from goroutine waiting for dbus.Proxy
done chan struct{}
}
func (d *DBus) Type() state.Enablement {
return Process
}
func (d *DBus) apply(_ *I) error {
verbose.Printf("session bus proxy on %q for upstream %q\n", d.proxy.Session()[1], d.proxy.Session()[0])
if d.system {
verbose.Printf("system bus proxy on %q for upstream %q\n", d.proxy.System()[1], d.proxy.System()[0])
}
// ready channel passed to dbus package
ready := make(chan error, 1)
// background dbus proxy start
if err := d.proxy.Start(ready, os.Stderr, true); err != nil {
return fmsg.WrapErrorSuffix(err,
"cannot start message bus proxy:")
}
verbose.Println("starting message bus proxy:", d.proxy)
if verbose.Get() { // save the extra bwrap arg build when verbose logging is off
verbose.Println("message bus proxy bwrap args:", d.proxy.Bwrap())
}
// background wait for proxy instance and notify completion
go func() {
if err := d.proxy.Wait(); err != nil {
fmt.Println("fortify: message bus proxy exited with error:", err)
go func() { ready <- err }()
} else {
verbose.Println("message bus proxy exit")
}
// ensure socket removal so ephemeral directory is empty at revert
if err := os.Remove(d.proxy.Session()[1]); err != nil && !errors.Is(err, os.ErrNotExist) {
fmt.Println("fortify: cannot remove dangling session bus socket:", err)
}
if d.system {
if err := os.Remove(d.proxy.System()[1]); err != nil && !errors.Is(err, os.ErrNotExist) {
fmt.Println("fortify: cannot remove dangling system bus socket:", err)
}
}
// notify proxy completion
close(d.done)
}()
// ready is not nil if the proxy process faulted
if err := <-ready; err != nil {
// note that err here is either an I/O error or a predetermined unexpected behaviour error
return fmsg.WrapErrorSuffix(err,
"message bus proxy fault after start:")
}
verbose.Println("message bus proxy ready")
return nil
}
func (d *DBus) revert(_ *I, _ *Criteria) error {
// criteria ignored here since dbus is always process-scoped
verbose.Println("terminating message bus proxy")
if err := d.proxy.Close(); err != nil {
if errors.Is(err, os.ErrClosed) {
return fmsg.WrapError(err,
"message bus proxy already closed")
} else {
return fmsg.WrapErrorSuffix(err,
"cannot stop message bus proxy:")
}
}
// block until proxy wait returns
<-d.done
return nil
}
func (d *DBus) Is(o Op) bool {
d0, ok := o.(*DBus)
return ok && d0 != nil && *d == *d0
}
func (d *DBus) Path() string {
return "(dbus proxy)"
}
func (d *DBus) String() string {
return d.proxy.String()
}

82
internal/system/mkdir.go Normal file
View File

@ -0,0 +1,82 @@
package system
import (
"errors"
"fmt"
"os"
"git.ophivana.moe/cat/fortify/internal/fmsg"
"git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
// Ensure the existence and mode of a directory.
func (sys *I) Ensure(name string, perm os.FileMode) {
sys.lock.Lock()
defer sys.lock.Unlock()
sys.ops = append(sys.ops, &Mkdir{User, name, perm, false})
}
// Ephemeral ensures the temporary existence and mode of a directory through the life of et.
func (sys *I) Ephemeral(et state.Enablement, name string, perm os.FileMode) {
sys.lock.Lock()
defer sys.lock.Unlock()
sys.ops = append(sys.ops, &Mkdir{et, name, perm, true})
}
type Mkdir struct {
et state.Enablement
path string
perm os.FileMode
ephemeral bool
}
func (m *Mkdir) Type() state.Enablement {
return m.et
}
func (m *Mkdir) apply(_ *I) error {
verbose.Println("ensuring directory", m)
// create directory
err := os.Mkdir(m.path, m.perm)
if !errors.Is(err, os.ErrExist) {
return fmsg.WrapErrorSuffix(err,
fmt.Sprintf("cannot create directory %q:", m.path))
}
// directory exists, ensure mode
return fmsg.WrapErrorSuffix(os.Chmod(m.path, m.perm),
fmt.Sprintf("cannot change mode of %q to %s:", m.path, m.perm))
}
func (m *Mkdir) revert(_ *I, ec *Criteria) error {
if !m.ephemeral {
// skip non-ephemeral dir and do not log anything
return nil
}
if ec.hasType(m) {
verbose.Println("destroying ephemeral directory", m)
return fmsg.WrapErrorSuffix(os.Remove(m.path),
fmt.Sprintf("cannot remove ephemeral directory %q:", m.path))
} else {
verbose.Println("skipping ephemeral directory", m)
return nil
}
}
func (m *Mkdir) Is(o Op) bool {
m0, ok := o.(*Mkdir)
return ok && m0 != nil && *m == *m0
}
func (m *Mkdir) Path() string {
return m.path
}
func (m *Mkdir) String() string {
return fmt.Sprintf("mode: %s path: %q", m.perm.String(), m.path)
}

126
internal/system/op.go Normal file
View File

@ -0,0 +1,126 @@
package system
import (
"errors"
"fmt"
"sync"
"git.ophivana.moe/cat/fortify/internal/state"
)
const (
// Process type is unconditionally reverted on exit.
Process = state.EnableLength + 1
// User type is reverted at final launcher exit.
User = state.EnableLength
)
type Criteria struct {
*state.Enablements
}
func (ec *Criteria) hasType(o Op) bool {
// nil criteria: revert everything except User
if ec.Enablements == nil {
return o.Type() != User
}
return ec.Has(o.Type())
}
// Op is a reversible system operation.
type Op interface {
// Type returns Op's enablement type.
Type() state.Enablement
// apply the Op
apply(sys *I) error
// revert reverses the Op if criteria is met
revert(sys *I, ec *Criteria) error
Is(o Op) bool
Path() string
String() string
}
func TypeString(e state.Enablement) string {
switch e {
case User:
return "User"
case Process:
return "Process"
default:
return e.String()
}
}
type I struct {
uid int
ops []Op
state [2]bool
lock sync.Mutex
}
func (sys *I) UID() int {
return sys.uid
}
func (sys *I) Commit() error {
sys.lock.Lock()
defer sys.lock.Unlock()
if sys.state[0] {
panic("sys instance committed twice")
}
sys.state[0] = true
sp := New(sys.uid)
sp.ops = make([]Op, 0, len(sys.ops)) // prevent copies during commits
defer func() {
// sp is set to nil when all ops are applied
if sp != nil {
// rollback partial commit
if err := sp.Revert(&Criteria{nil}); err != nil {
fmt.Println("fortify: errors returned reverting partial commit:", err)
}
}
}()
for _, o := range sys.ops {
if err := o.apply(sys); err != nil {
return err
} else {
// register partial commit
sp.ops = append(sp.ops, o)
}
}
// disarm partial commit rollback
sp = nil
return nil
}
func (sys *I) Revert(ec *Criteria) error {
sys.lock.Lock()
defer sys.lock.Unlock()
if sys.state[1] {
panic("sys instance reverted twice")
}
sys.state[1] = true
// collect errors
errs := make([]error, len(sys.ops))
for i := range sys.ops {
errs[i] = sys.ops[len(sys.ops)-i-1].revert(sys, ec)
}
// errors.Join filters nils
return errors.Join(errs...)
}
func New(uid int) *I {
return &I{uid: uid}
}

141
internal/system/tmpfiles.go Normal file
View File

@ -0,0 +1,141 @@
package system
import (
"errors"
"fmt"
"io"
"os"
"strconv"
"git.ophivana.moe/cat/fortify/acl"
"git.ophivana.moe/cat/fortify/internal/fmsg"
"git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
// CopyFile registers an Op that copies path dst from src.
func (sys *I) CopyFile(dst, src string) {
sys.CopyFileType(Process, dst, src)
}
// CopyFileType registers a file copying Op labelled with type et.
func (sys *I) CopyFileType(et state.Enablement, dst, src string) {
sys.lock.Lock()
sys.ops = append(sys.ops, &Tmpfile{et, tmpfileCopy, dst, src})
sys.lock.Unlock()
sys.UpdatePermType(et, dst, acl.Read)
}
// Link registers an Op that links dst to src.
func (sys *I) Link(oldname, newname string) {
sys.LinkFileType(Process, oldname, newname)
}
// LinkFileType registers a file linking Op labelled with type et.
func (sys *I) LinkFileType(et state.Enablement, oldname, newname string) {
sys.lock.Lock()
defer sys.lock.Unlock()
sys.ops = append(sys.ops, &Tmpfile{et, tmpfileLink, newname, oldname})
}
// Write registers an Op that writes dst with the contents of src.
func (sys *I) Write(dst, src string) {
sys.WriteType(Process, dst, src)
}
// WriteType registers a file writing Op labelled with type et.
func (sys *I) WriteType(et state.Enablement, dst, src string) {
sys.lock.Lock()
sys.ops = append(sys.ops, &Tmpfile{et, tmpfileWrite, dst, src})
sys.lock.Unlock()
sys.UpdatePermType(et, dst, acl.Read)
}
const (
tmpfileCopy uint8 = iota
tmpfileLink
tmpfileWrite
)
type Tmpfile struct {
et state.Enablement
method uint8
dst, src string
}
func (t *Tmpfile) Type() state.Enablement {
return t.et
}
func (t *Tmpfile) apply(_ *I) error {
switch t.method {
case tmpfileCopy:
verbose.Printf("publishing tmpfile %s\n", t)
return fmsg.WrapErrorSuffix(copyFile(t.dst, t.src),
fmt.Sprintf("cannot copy tmpfile %q:", t.dst))
case tmpfileLink:
verbose.Printf("linking tmpfile %s\n", t)
return fmsg.WrapErrorSuffix(os.Link(t.src, t.dst),
fmt.Sprintf("cannot link tmpfile %q:", t.dst))
case tmpfileWrite:
verbose.Printf("writing %s\n", t)
return fmsg.WrapErrorSuffix(os.WriteFile(t.dst, []byte(t.src), 0600),
fmt.Sprintf("cannot write tmpfile %q:", t.dst))
default:
panic("invalid tmpfile method " + strconv.Itoa(int(t.method)))
}
}
func (t *Tmpfile) revert(_ *I, ec *Criteria) error {
if ec.hasType(t) {
verbose.Printf("removing tmpfile %q\n", t.dst)
return fmsg.WrapErrorSuffix(os.Remove(t.dst),
fmt.Sprintf("cannot remove tmpfile %q:", t.dst))
} else {
verbose.Printf("skipping tmpfile %q\n", t.dst)
return nil
}
}
func (t *Tmpfile) Is(o Op) bool {
t0, ok := o.(*Tmpfile)
return ok && t0 != nil && *t == *t0
}
func (t *Tmpfile) Path() string {
if t.method == tmpfileWrite {
return fmt.Sprintf("(%d bytes of data)", len(t.src))
}
return t.src
}
func (t *Tmpfile) String() string {
switch t.method {
case tmpfileCopy:
return fmt.Sprintf("%q from %q", t.dst, t.src)
case tmpfileLink:
return fmt.Sprintf("%q from %q", t.dst, t.src)
case tmpfileWrite:
return fmt.Sprintf("%d bytes of data to %q", len(t.src), t.dst)
default:
panic("invalid tmpfile method " + strconv.Itoa(int(t.method)))
}
}
func copyFile(dst, src string) error {
dstD, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
srcD, err := os.Open(src)
if err != nil {
return errors.Join(err, dstD.Close())
}
_, err = io.Copy(dstD, srcD)
return errors.Join(err, dstD.Close(), srcD.Close())
}

54
internal/system/xhost.go Normal file
View File

@ -0,0 +1,54 @@
package system
import (
"fmt"
"git.ophivana.moe/cat/fortify/internal/fmsg"
"git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/verbose"
"git.ophivana.moe/cat/fortify/xcb"
)
// ChangeHosts appends an X11 ChangeHosts command Op.
func (sys *I) ChangeHosts(username string) {
sys.lock.Lock()
defer sys.lock.Unlock()
sys.ops = append(sys.ops, XHost(username))
}
type XHost string
func (x XHost) Type() state.Enablement {
return state.EnableX
}
func (x XHost) apply(_ *I) error {
verbose.Printf("inserting entry %s to X11\n", x)
return fmsg.WrapErrorSuffix(xcb.ChangeHosts(xcb.HostModeInsert, xcb.FamilyServerInterpreted, "localuser\x00"+string(x)),
fmt.Sprintf("cannot insert entry %s to X11:", x))
}
func (x XHost) revert(_ *I, ec *Criteria) error {
if ec.hasType(x) {
verbose.Printf("deleting entry %s from X11\n", x)
return fmsg.WrapErrorSuffix(xcb.ChangeHosts(xcb.HostModeDelete, xcb.FamilyServerInterpreted, "localuser\x00"+string(x)),
fmt.Sprintf("cannot delete entry %s from X11:", x))
} else {
verbose.Printf("skipping entry %s in X11\n", x)
return nil
}
}
func (x XHost) Is(o Op) bool {
x0, ok := o.(XHost)
return ok && x == x0
}
func (x XHost) Path() string {
return string(x)
}
func (x XHost) String() string {
return string("SI:localuser:" + x)
}