diff --git a/internal/system/acl.go b/internal/system/acl.go new file mode 100644 index 0000000..9c89752 --- /dev/null +++ b/internal/system/acl.go @@ -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) +} diff --git a/internal/system/dbus.go b/internal/system/dbus.go new file mode 100644 index 0000000..3e4a6e6 --- /dev/null +++ b/internal/system/dbus.go @@ -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() +} diff --git a/internal/system/mkdir.go b/internal/system/mkdir.go new file mode 100644 index 0000000..2c30363 --- /dev/null +++ b/internal/system/mkdir.go @@ -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) +} diff --git a/internal/system/op.go b/internal/system/op.go new file mode 100644 index 0000000..c3f93cd --- /dev/null +++ b/internal/system/op.go @@ -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} +} diff --git a/internal/system/tmpfiles.go b/internal/system/tmpfiles.go new file mode 100644 index 0000000..2a9f724 --- /dev/null +++ b/internal/system/tmpfiles.go @@ -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()) +} diff --git a/internal/system/xhost.go b/internal/system/xhost.go new file mode 100644 index 0000000..2cdddd5 --- /dev/null +++ b/internal/system/xhost.go @@ -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) +}