aboutsummaryrefslogtreecommitdiffhomepage
path: root/internal
diff options
context:
space:
mode:
authorOphestra Umiker <cat@ophivana.moe>2024-09-04 01:20:12 +0900
committerOphestra Umiker <cat@ophivana.moe>2024-09-04 01:20:12 +0900
commitd8f76f3b2594db4687c0203c4f2be8d3e4ef7740 (patch)
treeb1d8bfb5250d71094e397f8afa87710de1f1ca2f /internal
parent7e6eb82195c650bc34829e5cca2d2296e78c0707 (diff)
rename to fortify and restructure
More sandbox features will be added and this will no longer track ego's features and behaviour. Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
Diffstat (limited to 'internal')
-rw-r--r--internal/acl/c.go99
-rw-r--r--internal/acl/export.go86
-rw-r--r--internal/app/builder.go13
-rw-r--r--internal/app/launch.go152
-rw-r--r--internal/app/setup.go124
-rw-r--r--internal/state/exit.go68
-rw-r--r--internal/state/register.go12
-rw-r--r--internal/state/track.go115
-rw-r--r--internal/state/value.go21
-rw-r--r--internal/system/retrieve.go28
-rw-r--r--internal/system/value.go17
-rw-r--r--internal/util/simple.go39
-rw-r--r--internal/util/std.go75
-rw-r--r--internal/xcb/c.go33
-rw-r--r--internal/xcb/export.go47
15 files changed, 929 insertions, 0 deletions
diff --git a/internal/acl/c.go b/internal/acl/c.go
new file mode 100644
index 00000000..89bc0469
--- /dev/null
+++ b/internal/acl/c.go
@@ -0,0 +1,99 @@
+package acl
+
+import (
+ "errors"
+ "fmt"
+ "syscall"
+ "unsafe"
+)
+
+//#include <stdlib.h>
+//#include <sys/acl.h>
+//#include <acl/libacl.h>
+//#cgo linux LDFLAGS: -lacl
+import "C"
+
+type acl struct {
+ val C.acl_t
+ freed bool
+}
+
+func aclGetFile(path string, t C.acl_type_t) (*acl, error) {
+ p := C.CString(path)
+ a, err := C.acl_get_file(p, t)
+ C.free(unsafe.Pointer(p))
+
+ if errors.Is(err, syscall.ENODATA) {
+ err = nil
+ }
+ return &acl{val: a, freed: false}, err
+}
+
+func (a *acl) setFile(path string, t C.acl_type_t) error {
+ if C.acl_valid(a.val) != 0 {
+ return fmt.Errorf("invalid acl")
+ }
+
+ p := C.CString(path)
+ _, err := C.acl_set_file(p, t, a.val)
+ C.free(unsafe.Pointer(p))
+ return err
+}
+
+func (a *acl) removeEntry(tt C.acl_tag_t, tq int) error {
+ var e C.acl_entry_t
+
+ // get first entry
+ if r, err := C.acl_get_entry(a.val, C.ACL_FIRST_ENTRY, &e); err != nil {
+ return err
+ } else if r == 0 {
+ // return on acl with no entries
+ return nil
+ }
+
+ for {
+ if r, err := C.acl_get_entry(a.val, C.ACL_NEXT_ENTRY, &e); err != nil {
+ return err
+ } else if r == 0 {
+ // return on drained acl
+ return nil
+ }
+
+ var (
+ q int
+ t C.acl_tag_t
+ )
+
+ // get current entry tag type
+ if _, err := C.acl_get_tag_type(e, &t); err != nil {
+ return err
+ }
+
+ // get current entry qualifier
+ if rq, err := C.acl_get_qualifier(e); err != nil {
+ // neither ACL_USER nor ACL_GROUP
+ if errors.Is(err, syscall.EINVAL) {
+ continue
+ }
+
+ return err
+ } else {
+ q = *(*int)(rq)
+ C.acl_free(rq)
+ }
+
+ // delete on match
+ if t == tt && q == tq {
+ _, err := C.acl_delete_entry(a.val, e)
+ return err
+ }
+ }
+}
+
+func (a *acl) free() {
+ if a.freed {
+ panic("acl already freed")
+ }
+ C.acl_free(unsafe.Pointer(a.val))
+ a.freed = true
+}
diff --git a/internal/acl/export.go b/internal/acl/export.go
new file mode 100644
index 00000000..aeb8ed96
--- /dev/null
+++ b/internal/acl/export.go
@@ -0,0 +1,86 @@
+package acl
+
+import "unsafe"
+
+//#include <stdlib.h>
+//#include <sys/acl.h>
+//#include <acl/libacl.h>
+//#cgo linux LDFLAGS: -lacl
+import "C"
+
+const (
+ Read = C.ACL_READ
+ Write = C.ACL_WRITE
+ Execute = C.ACL_EXECUTE
+
+ TypeDefault = C.ACL_TYPE_DEFAULT
+ TypeAccess = C.ACL_TYPE_ACCESS
+
+ UndefinedTag = C.ACL_UNDEFINED_TAG
+ UserObj = C.ACL_USER_OBJ
+ User = C.ACL_USER
+ GroupObj = C.ACL_GROUP_OBJ
+ Group = C.ACL_GROUP
+ Mask = C.ACL_MASK
+ Other = C.ACL_OTHER
+)
+
+func UpdatePerm(path string, uid int, perms ...C.acl_perm_t) error {
+ // read acl from file
+ a, err := aclGetFile(path, TypeAccess)
+ if err != nil {
+ return err
+ }
+ // free acl on return if get is successful
+ defer a.free()
+
+ // remove existing entry
+ if err = a.removeEntry(User, uid); err != nil {
+ return err
+ }
+
+ // create new entry if perms are passed
+ if len(perms) > 0 {
+ // create new acl entry
+ var e C.acl_entry_t
+ if _, err = C.acl_create_entry(&a.val, &e); err != nil {
+ return err
+ }
+
+ // get perm set of new entry
+ var p C.acl_permset_t
+ if _, err = C.acl_get_permset(e, &p); err != nil {
+ return err
+ }
+
+ // add target perms
+ for _, perm := range perms {
+ if _, err = C.acl_add_perm(p, perm); err != nil {
+ return err
+ }
+ }
+
+ // set perm set to new entry
+ if _, err = C.acl_set_permset(e, p); err != nil {
+ return err
+ }
+
+ // set user tag to new entry
+ if _, err = C.acl_set_tag_type(e, User); err != nil {
+ return err
+ }
+
+ // set qualifier (uid) to new entry
+ if _, err = C.acl_set_qualifier(e, unsafe.Pointer(&uid)); err != nil {
+ return err
+ }
+ }
+
+ // calculate mask after update
+ if _, err = C.acl_calc_mask(&a.val); err != nil {
+ return err
+ }
+
+ // write acl to file
+ return a.setFile(path, TypeAccess)
+}
diff --git a/internal/app/builder.go b/internal/app/builder.go
new file mode 100644
index 00000000..2bce43b4
--- /dev/null
+++ b/internal/app/builder.go
@@ -0,0 +1,13 @@
+package app
+
+func (a *App) Command() []string {
+ return a.command
+}
+
+func (a *App) UID() int {
+ return a.uid
+}
+
+func (a *App) AppendEnv(k, v string) {
+ a.env = append(a.env, k+"="+v)
+}
diff --git a/internal/app/launch.go b/internal/app/launch.go
new file mode 100644
index 00000000..c6f7b773
--- /dev/null
+++ b/internal/app/launch.go
@@ -0,0 +1,152 @@
+package app
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/gob"
+ "fmt"
+ "os"
+ "strings"
+ "syscall"
+
+ "git.ophivana.moe/cat/fortify/internal/state"
+ "git.ophivana.moe/cat/fortify/internal/system"
+ "git.ophivana.moe/cat/fortify/internal/util"
+)
+
+const (
+ sudoAskPass = "SUDO_ASKPASS"
+ 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 {
+ state.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)
+ }
+ }
+
+ 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
+ }
+ }
+}
+
+func (a *App) launchBySudo() (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 {
+ if system.V.Verbose {
+ fmt.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) launchByMachineCtl(bare bool) (args []string) {
+ args = make([]string, 0, 9+len(a.env))
+
+ // shell --uid=$USER
+ args = append(args, "shell", "--uid="+a.Username)
+
+ // --quiet
+ if !system.V.Verbose {
+ 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 {
+ state.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{}
+
+ if !bare {
+ innerCommand.WriteString("dbus-update-activation-environment --systemd")
+ for _, e := range a.env {
+ innerCommand.WriteString(" " + strings.SplitN(e, "=", 2)[0])
+ }
+ innerCommand.WriteString("; systemctl --user start xdg-desktop-portal-gtk; ")
+ }
+
+ if executable, err := os.Executable(); err != nil {
+ state.Fatal("Error reading executable path:", err)
+ } else {
+ innerCommand.WriteString("exec " + executable + " -V")
+ }
+ args = append(args, innerCommand.String())
+
+ return
+}
diff --git a/internal/app/setup.go b/internal/app/setup.go
new file mode 100644
index 00000000..7b5cd2ec
--- /dev/null
+++ b/internal/app/setup.go
@@ -0,0 +1,124 @@
+package app
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "os/user"
+ "strconv"
+
+ "git.ophivana.moe/cat/fortify/internal/state"
+ "git.ophivana.moe/cat/fortify/internal/system"
+ "git.ophivana.moe/cat/fortify/internal/util"
+)
+
+type App struct {
+ uid int
+ env []string
+ command []string
+
+ *user.User
+}
+
+func (a *App) Run() {
+ f := a.launchBySudo
+ m, b := false, false
+ switch {
+ case system.MethodFlags[0]: // sudo
+ case system.MethodFlags[1]: // bare
+ m, b = true, true
+ default: // machinectl
+ m, b = true, false
+ }
+
+ var toolPath string
+
+ // dependency checks
+ const sudoFallback = "Falling back to 'sudo', some desktop integration features may not work"
+ if m {
+ if !util.SdBooted() {
+ fmt.Println("This system was not booted through systemd")
+ fmt.Println(sudoFallback)
+ } else if tp, ok := util.Which("machinectl"); !ok {
+ fmt.Println("Did not find 'machinectl' in PATH")
+ fmt.Println(sudoFallback)
+ } else {
+ toolPath = tp
+ f = func() []string { return a.launchByMachineCtl(b) }
+ }
+ } else if tp, ok := util.Which("sudo"); !ok {
+ state.Fatal("Did not find 'sudo' in PATH")
+ } else {
+ toolPath = tp
+ }
+
+ if system.V.Verbose {
+ fmt.Printf("Selected launcher '%s' bare=%t\n", toolPath, b)
+ }
+
+ cmd := exec.Command(toolPath, f()...)
+ cmd.Env = a.env
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ cmd.Dir = system.V.RunDir
+
+ if system.V.Verbose {
+ fmt.Println("Executing:", cmd)
+ }
+
+ if err := cmd.Start(); err != nil {
+ state.Fatal("Error starting process:", err)
+ }
+
+ if err := state.SaveProcess(a.Uid, cmd); err != nil {
+ // process already started, shouldn't be fatal
+ fmt.Println("Error registering process:", err)
+ }
+
+ var r int
+ if err := cmd.Wait(); err != nil {
+ var exitError *exec.ExitError
+ if !errors.As(err, &exitError) {
+ state.Fatal("Error running process:", err)
+ }
+ }
+
+ if system.V.Verbose {
+ fmt.Println("Process exited with exit code", r)
+ }
+ state.BeforeExit()
+ os.Exit(r)
+}
+
+func New(userName string, args []string) *App {
+ a := &App{command: args}
+
+ 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
+ }
+
+ if u, err := strconv.Atoi(a.Uid); err != nil {
+ // usually unreachable
+ panic("uid parse")
+ } else {
+ a.uid = u
+ }
+
+ if system.V.Verbose {
+ fmt.Println("Running as user", a.Username, "("+a.Uid+"),", "command:", a.command)
+ }
+
+ return a
+}
diff --git a/internal/state/exit.go b/internal/state/exit.go
new file mode 100644
index 00000000..e0e2c04a
--- /dev/null
+++ b/internal/state/exit.go
@@ -0,0 +1,68 @@
+package state
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+
+ "git.ophivana.moe/cat/fortify/internal/acl"
+ "git.ophivana.moe/cat/fortify/internal/system"
+ "git.ophivana.moe/cat/fortify/internal/xcb"
+)
+
+func Fatal(msg ...any) {
+ fmt.Println(msg...)
+ BeforeExit()
+ os.Exit(1)
+}
+
+func BeforeExit() {
+ if u == nil {
+ fmt.Println("warn: beforeExit called before app init")
+ return
+ }
+
+ if statePath == "" {
+ if system.V.Verbose {
+ fmt.Println("State path is unset")
+ }
+ } else {
+ if err := os.Remove(statePath); err != nil && !errors.Is(err, fs.ErrNotExist) {
+ fmt.Println("Error removing state file:", err)
+ }
+ }
+
+ if d, err := readLaunchers(); err != nil {
+ fmt.Println("Error reading active launchers:", err)
+ os.Exit(1)
+ } else if len(d) > 0 {
+ // other launchers are still active
+ if system.V.Verbose {
+ fmt.Printf("Found %d active launchers, exiting without cleaning up\n", len(d))
+ }
+ return
+ }
+
+ if system.V.Verbose {
+ fmt.Println("No other launchers active, will clean up")
+ }
+
+ if xcbActionComplete {
+ if system.V.Verbose {
+ fmt.Printf("X11: Removing XHost entry SI:localuser:%s\n", u.Username)
+ }
+ if err := xcb.ChangeHosts(xcb.HostModeDelete, xcb.FamilyServerInterpreted, "localuser\x00"+u.Username); err != nil {
+ fmt.Println("Error removing XHost entry:", err)
+ }
+ }
+
+ for _, candidate := range cleanupCandidate {
+ if err := acl.UpdatePerm(candidate, uid); err != nil {
+ fmt.Printf("Error stripping ACL entry from '%s': %s\n", candidate, err)
+ }
+ if system.V.Verbose {
+ fmt.Printf("Stripped ACL entry for user '%s' from '%s'\n", u.Username, candidate)
+ }
+ }
+}
diff --git a/internal/state/register.go b/internal/state/register.go
new file mode 100644
index 00000000..2fff565e
--- /dev/null
+++ b/internal/state/register.go
@@ -0,0 +1,12 @@
+package state
+
+func RegisterRevertPath(p string) {
+ cleanupCandidate = append(cleanupCandidate, p)
+}
+
+func XcbActionComplete() {
+ if xcbActionComplete {
+ Fatal("xcb inserted twice")
+ }
+ xcbActionComplete = true
+}
diff --git a/internal/state/track.go b/internal/state/track.go
new file mode 100644
index 00000000..913d684d
--- /dev/null
+++ b/internal/state/track.go
@@ -0,0 +1,115 @@
+package state
+
+import (
+ "encoding/gob"
+ "errors"
+ "flag"
+ "fmt"
+ "io/fs"
+ "os"
+ "os/exec"
+ "path"
+ "strconv"
+
+ "git.ophivana.moe/cat/fortify/internal/system"
+)
+
+// we unfortunately have to assume there are never races between processes
+// this and launcher should eventually be replaced by a server process
+
+var (
+ stateActionEarly bool
+ statePath string
+ cleanupCandidate []string
+ xcbActionComplete bool
+)
+
+type launcherState struct {
+ PID int
+ Launcher string
+ Argv []string
+ Command []string
+}
+
+func init() {
+ flag.BoolVar(&stateActionEarly, "state", false, "query state value of current active launchers")
+}
+
+func Early() {
+ if !stateActionEarly {
+ return
+ }
+
+ launchers, err := readLaunchers()
+ if err != nil {
+ fmt.Println("Error reading launchers:", err)
+ os.Exit(1)
+ }
+
+ fmt.Println("\tPID\tLauncher")
+ for _, state := range launchers {
+ fmt.Printf("\t%d\t%s\nCommand: %s\nArgv: %s\n", state.PID, state.Launcher, state.Command, state.Argv)
+ }
+
+ os.Exit(0)
+}
+
+// SaveProcess called after process start, before wait
+func SaveProcess(uid string, cmd *exec.Cmd) error {
+ statePath = path.Join(system.V.RunDir, uid, strconv.Itoa(cmd.Process.Pid))
+ state := launcherState{
+ PID: cmd.Process.Pid,
+ Launcher: cmd.Path,
+ Argv: cmd.Args,
+ Command: command,
+ }
+
+ if err := os.Mkdir(path.Join(system.V.RunDir, uid), 0700); err != nil && !errors.Is(err, fs.ErrExist) {
+ return err
+ }
+
+ if f, err := os.OpenFile(statePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600); err != nil {
+ return err
+ } else {
+ defer func() {
+ if f.Close() != nil {
+ // unreachable
+ panic("state file closed prematurely")
+ }
+ }()
+ return gob.NewEncoder(f).Encode(state)
+ }
+}
+
+func readLaunchers() ([]*launcherState, error) {
+ var f *os.File
+ var r []*launcherState
+ launcherPrefix := path.Join(system.V.RunDir, u.Uid)
+
+ if pl, err := os.ReadDir(launcherPrefix); err != nil {
+ return nil, err
+ } else {
+ for _, e := range pl {
+ if err = func() error {
+ if f, err = os.Open(path.Join(launcherPrefix, e.Name())); err != nil {
+ return err
+ } else {
+ defer func() {
+ if f.Close() != nil {
+ // unreachable
+ panic("foreign state file closed prematurely")
+ }
+ }()
+
+ var s launcherState
+ r = append(r, &s)
+ return gob.NewDecoder(f).Decode(&s)
+ }
+ }(); err != nil {
+ return nil, err
+ }
+ }
+ }
+
+ return r, nil
+}
diff --git a/internal/state/value.go b/internal/state/value.go
new file mode 100644
index 00000000..cb20818c
--- /dev/null
+++ b/internal/state/value.go
@@ -0,0 +1,21 @@
+package state
+
+import (
+ "os/user"
+)
+
+var (
+ u *user.User
+ uid int
+ command []string
+)
+
+func Set(val user.User, c []string, d int) {
+ if u != nil {
+ panic("state set twice")
+ }
+
+ u = &val
+ command = c
+ uid = d
+}
diff --git a/internal/system/retrieve.go b/internal/system/retrieve.go
new file mode 100644
index 00000000..d9f0e3dd
--- /dev/null
+++ b/internal/system/retrieve.go
@@ -0,0 +1,28 @@
+package system
+
+import (
+ "fmt"
+ "os"
+ "path"
+ "strconv"
+)
+
+func Retrieve(verbose bool) {
+ if V != nil {
+ panic("system info retrieved twice")
+ }
+
+ v := &Values{Share: path.Join(os.TempDir(), "fortify."+strconv.Itoa(os.Geteuid())), Verbose: verbose}
+
+ if r, ok := os.LookupEnv(xdgRuntimeDir); !ok {
+ fmt.Println("Env variable", xdgRuntimeDir, "unset")
+
+ // too early for fatal
+ os.Exit(1)
+ } else {
+ v.Runtime = r
+ v.RunDir = path.Join(v.Runtime, "fortify")
+ }
+
+ V = v
+}
diff --git a/internal/system/value.go b/internal/system/value.go
new file mode 100644
index 00000000..aa4da518
--- /dev/null
+++ b/internal/system/value.go
@@ -0,0 +1,17 @@
+package system
+
+const (
+ xdgRuntimeDir = "XDG_RUNTIME_DIR"
+)
+
+type Values struct {
+ Share string
+ Runtime string
+ RunDir string
+ Verbose bool
+}
+
+var (
+ V *Values
+ MethodFlags [2]bool
+)
diff --git a/internal/util/simple.go b/internal/util/simple.go
new file mode 100644
index 00000000..08ce23f6
--- /dev/null
+++ b/internal/util/simple.go
@@ -0,0 +1,39 @@
+package util
+
+import (
+ "io"
+ "os"
+ "os/exec"
+)
+
+func Which(file string) (string, bool) {
+ p, err := exec.LookPath(file)
+ return p, err == nil
+}
+
+func CopyFile(dst, src string) error {
+ 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
+}
diff --git a/internal/util/std.go b/internal/util/std.go
new file mode 100644
index 00000000..5c37a0d7
--- /dev/null
+++ b/internal/util/std.go
@@ -0,0 +1,75 @@
+package util
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path"
+
+ "git.ophivana.moe/cat/fortify/internal/state"
+ "git.ophivana.moe/cat/fortify/internal/system"
+)
+
+const (
+ systemdCheckPath = "/run/systemd/system"
+
+ home = "HOME"
+ xdgConfigHome = "XDG_CONFIG_HOME"
+
+ PulseServer = "PULSE_SERVER"
+ PulseCookie = "PULSE_COOKIE"
+)
+
+// SdBooted implements https://www.freedesktop.org/software/systemd/man/sd_booted.html
+func SdBooted() bool {
+ _, err := os.Stat(systemdCheckPath)
+ if err != nil {
+ if system.V.Verbose {
+ if errors.Is(err, fs.ErrNotExist) {
+ fmt.Println("System not booted through systemd")
+ } else {
+ fmt.Println("Error accessing", systemdCheckPath+":", err.Error())
+ }
+ }
+ return false
+ }
+ return true
+}
+
+// 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) {
+ state.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) {
+ state.Fatal("Error accessing PulseAudio cookie:", err)
+ // unreachable
+ return p
+ }
+ } else if !s.IsDir() {
+ return p
+ }
+ }
+
+ state.Fatal(fmt.Sprintf("Cannot locate PulseAudio cookie (tried $%s, $%s/pulse/cookie, $%s/.pulse-cookie)",
+ PulseCookie, xdgConfigHome, home))
+ return ""
+}
diff --git a/internal/xcb/c.go b/internal/xcb/c.go
new file mode 100644
index 00000000..8fe59d8c
--- /dev/null
+++ b/internal/xcb/c.go
@@ -0,0 +1,33 @@
+package xcb
+
+import (
+ "errors"
+)
+
+//#include <stdlib.h>
+//#include <xcb/xcb.h>
+//#cgo linux LDFLAGS: -lxcb
+import "C"
+
+func xcbHandleConnectionError(c *C.xcb_connection_t) error {
+ if errno := C.xcb_connection_has_error(c); errno != 0 {
+ switch errno {
+ case C.XCB_CONN_ERROR:
+ return errors.New("connection error")
+ case C.XCB_CONN_CLOSED_EXT_NOTSUPPORTED:
+ return errors.New("extension not supported")
+ case C.XCB_CONN_CLOSED_MEM_INSUFFICIENT:
+ return errors.New("memory not available")
+ case C.XCB_CONN_CLOSED_REQ_LEN_EXCEED:
+ return errors.New("request length exceeded")
+ case C.XCB_CONN_CLOSED_PARSE_ERR:
+ return errors.New("invalid display string")
+ case C.XCB_CONN_CLOSED_INVALID_SCREEN:
+ return errors.New("server has no screen matching display")
+ default:
+ return errors.New("generic X11 failure")
+ }
+ } else {
+ return nil
+ }
+}
diff --git a/internal/xcb/export.go b/internal/xcb/export.go
new file mode 100644
index 00000000..72a74131
--- /dev/null
+++ b/internal/xcb/export.go
@@ -0,0 +1,47 @@
+package xcb
+
+//#include <stdlib.h>
+//#include <xcb/xcb.h>
+//#cgo linux LDFLAGS: -lxcb
+import "C"
+import (
+ "errors"
+ "unsafe"
+)
+
+const (
+ HostModeInsert = C.XCB_HOST_MODE_INSERT
+ HostModeDelete = C.XCB_HOST_MODE_DELETE
+
+ FamilyInternet = C.XCB_FAMILY_INTERNET
+ FamilyDecnet = C.XCB_FAMILY_DECNET
+ FamilyChaos = C.XCB_FAMILY_CHAOS
+ FamilyServerInterpreted = C.XCB_FAMILY_SERVER_INTERPRETED
+ FamilyInternet6 = C.XCB_FAMILY_INTERNET_6
+)
+
+func ChangeHosts(mode, family C.uint8_t, address string) error {
+ var c *C.xcb_connection_t
+ c = C.xcb_connect(nil, nil)
+ defer C.xcb_disconnect(c)
+
+ if err := xcbHandleConnectionError(c); err != nil {
+ return err
+ }
+
+ addr := C.CString(address)
+ cookie := C.xcb_change_hosts_checked(c, mode, family, C.ushort(len(address)), (*C.uchar)(unsafe.Pointer(addr)))
+ C.free(unsafe.Pointer(addr))
+
+ if err := xcbHandleConnectionError(c); err != nil {
+ return err
+ }
+
+ e := C.xcb_request_check(c, cookie)
+ if e != nil {
+ defer C.free(unsafe.Pointer(e))
+ return errors.New("xcb_change_hosts() failed")
+ }
+
+ return nil
+}