Compare commits

...

13 Commits

Author SHA1 Message Date
Ophestra Umiker c1bfe2cd74
release: 1.1.0
release / release (push) Has been cancelled Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-09 05:14:53 +09:00
Ophestra Umiker d813f8e44e
update README document
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-09 05:14:14 +09:00
Ophestra Umiker 0e5b85fd42
nix: implement new dbus options in nixos module
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-09 04:58:25 +09:00
Ophestra Umiker cdc08817a7
nix: add xdg-dbus-proxy to PATH via wrapProgram
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-09 04:37:12 +09:00
Ophestra Umiker e5b3fa02f9
flag: rename cli to flag
Yet another leftover from Ego. The cli name made no sense and this file only contains flag declarations now hence the rename.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-09 04:21:13 +09:00
Ophestra Umiker 8e848366cd
app/dbus: set dbusAddress early
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-09 03:46:46 +09:00
Ophestra Umiker 38ef2b4d0c
app/dbus: manage dbus proxy and pass address to child
This commit adds code that starts and registers the D-Bus proxy, as well as cleanup code that tracks and closes the daemon once our child exits. A few more flags were added to pass D-Bus config to xdg-dbus-proxy.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-09 03:16:54 +09:00
Ophestra Umiker 357cc4ce4d
dbus: implement xdg-dbus-proxy wrapper
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-09 03:11:50 +09:00
Ophestra Umiker 3242ce3406
app: treat display server variable unset as fatal
This is yet another remnant of Ego, as Ego unconditionally shares these resources and the absence of them are ignored and warned about in verbose logging. In our case they are individually opt-in so silently dropping them while the enablement is still set makes very little sense.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-09 00:35:16 +09:00
Ophestra Umiker 7450b0b0bb
app/run: remove bare launch option
This flag serves no use and is only a leftover from Ego.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-09 00:32:17 +09:00
Ophestra Umiker 83af555c97
state/print: collect and output state information of all users
The -state flag now outputs state of all users. The old behaviour can be accessed via the -state-current flag, user is selected via -u.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-08 13:19:48 +09:00
Ophestra Umiker 60e4846542
nix: provide options for capability flags
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-08 02:45:00 +09:00
Ophestra Umiker 1906853382
clean up setup/launcher code and enable better control over shares
In the past Wayland, X and PulseAudio are shared unconditionally. This can unnecessarily increase attack surface as some of these resources might not be needed at all. This commit moves all environment preparation code to the internal app package and selectively call them based on flags.

An "enablements" bitfield is introduced tracking all enabled shares. This value is registered after successful child process launch and stored in launcher states.

Code responsible for running the child process is isolated to its own app/run file and cleaned up. Launch method selection is also extensively cleaned up.

The internal state/track readLaunchers function now takes uid as an argument. Launcher state is now printed using text/tabwriter and argv is only emitted when verbose.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-08 02:24:01 +09:00
22 changed files with 1063 additions and 333 deletions

View File

@ -75,16 +75,32 @@ This adds the `environment.fortify` option:
chronos = { chronos = {
launchers = { launchers = {
weechat.method = "sudo"; weechat.method = "sudo";
claws-mail.pulse = false; claws-mail.capability.pulse = false;
discord = { discord = {
command = "vesktop --ozone-platform-hint=wayland"; command = "vesktop --ozone-platform-hint=wayland";
share = pkgs.vesktop; share = pkgs.vesktop;
}; };
chromium.dbus.config = {
talk = [
"org.freedesktop.DBus"
"org.freedesktop.portal.*"
"org.freedesktop.FileManager1"
"org.freedesktop.Notifications"
"org.freedesktop.ScreenSaver"
];
own = [
"org.chromium.Chromium"
"org.mpris.MediaPlayer2.chromium.*"
];
};
}; };
packages = with pkgs; [ packages = with pkgs; [
weechat weechat
claws-mail claws-mail
vesktop vesktop
chromium
]; ];
persistence.directories = [ persistence.directories = [
".config/weechat" ".config/weechat"
@ -125,7 +141,19 @@ This adds the `environment.fortify` option:
* `command`, the command to run as the target user. Defaults to launcher name. * `command`, the command to run as the target user. Defaults to launcher name.
* `pulse`, whether to share the PulseAudio socket and cookie. * `dbus.config`, D-Bus proxy custom configuration.
* `dbus.id`, D-Bus application id, has no effect if `dbus.config` is set.
* `dbus.mpris`, whether to enable MPRIS defaults, has no effect if `dbus.config` is set.
* `capability.wayland`, whether to share the Wayland socket.
* `capability.x11`, whether to share the X11 socket and allow connection.
* `capability.dbus`, whether to proxy D-Bus.
* `capability.pulse`, whether to share the PulseAudio socket and cookie.
* `share`, package containing desktop/icon files. Defaults to launcher name. * `share`, package containing desktop/icon files. Defaults to launcher name.

23
cli.go
View File

@ -1,23 +0,0 @@
package main
import (
"flag"
"git.ophivana.moe/cat/fortify/internal/system"
)
var (
userName string
printVersion bool
mustPulse bool
flagVerbose bool
)
func init() {
flag.StringVar(&userName, "u", "chronos", "Specify a username")
flag.BoolVar(&system.MethodFlags[0], "sudo", false, "Use 'sudo' to change user")
flag.BoolVar(&system.MethodFlags[1], "bare", false, "Use 'machinectl' but skip xdg-desktop-portal setup")
flag.BoolVar(&mustPulse, "pulse", false, "Treat unavailable PulseAudio as fatal")
flag.BoolVar(&flagVerbose, "v", false, "Verbose output")
flag.BoolVar(&printVersion, "V", false, "Print version")
}

59
dbus/config.go Normal file
View File

@ -0,0 +1,59 @@
package dbus
type Config struct {
See []string `json:"see"`
Talk []string `json:"talk"`
Own []string `json:"own"`
Log bool `json:"log,omitempty"`
Filter bool `json:"filter"`
}
func (c *Config) Args(address, path string) (args []string) {
argc := 2 + len(c.See) + len(c.Talk) + len(c.Own)
if c.Log {
argc++
}
if c.Filter {
argc++
}
args = make([]string, 0, argc)
args = append(args, address, path)
for _, name := range c.See {
args = append(args, "--see="+name)
}
for _, name := range c.Talk {
args = append(args, "--talk="+name)
}
for _, name := range c.Own {
args = append(args, "--own="+name)
}
if c.Log {
args = append(args, "--log")
}
if c.Filter {
args = append(args, "--filter")
}
return
}
// NewConfig returns a reference to a Config struct with optional defaults.
// If id is an empty string own defaults are omitted.
func NewConfig(id string, defaults, mpris bool) (c *Config) {
c = &Config{Filter: true}
if defaults {
c.Talk = []string{"org.freedesktop.DBus", "org.freedesktop.portal.*", "org.freedesktop.Notifications"}
if id != "" {
c.Own = []string{id}
if mpris {
c.Own = append(c.Own, "org.mpris.MediaPlayer2."+id)
}
}
}
return
}

134
dbus/run.go Normal file
View File

@ -0,0 +1,134 @@
package dbus
import (
"errors"
"os"
"os/exec"
)
// Start launches the D-Bus proxy and sets up the Wait method.
// ready should be buffered and should only be received from once.
func (p *Proxy) Start(ready *chan bool) error {
p.lock.Lock()
defer p.lock.Unlock()
if p.seal == nil {
return errors.New("proxy not sealed")
}
// acquire pipes
if pr, pw, err := os.Pipe(); err != nil {
return err
} else {
p.statP[0], p.statP[1] = pr, pw
}
if pr, pw, err := os.Pipe(); err != nil {
return err
} else {
p.argsP[0], p.argsP[1] = pr, pw
}
p.cmd = exec.Command(p.path,
// ExtraFiles: If non-nil, entry i becomes file descriptor 3+i.
"--fd=3",
"--args=4",
)
p.cmd.Env = []string{}
p.cmd.ExtraFiles = []*os.File{p.statP[1], p.argsP[0]}
p.cmd.Stdout = os.Stdout
p.cmd.Stderr = os.Stderr
if err := p.cmd.Start(); err != nil {
return err
}
statsP, argsP := p.statP[0], p.argsP[1]
if _, err := argsP.Write([]byte(*p.seal)); err != nil {
if err1 := p.cmd.Process.Kill(); err1 != nil {
panic(err1)
}
return err
} else {
if err = argsP.Close(); err != nil {
if err1 := p.cmd.Process.Kill(); err1 != nil {
panic(err1)
}
return err
}
}
wait := make(chan error)
go func() {
// live out the lifespan of the process
wait <- p.cmd.Wait()
}()
read := make(chan error)
go func() {
n, err := statsP.Read(make([]byte, 1))
switch n {
case -1:
if err1 := p.cmd.Process.Kill(); err1 != nil {
panic(err1)
}
read <- err
case 0:
read <- err
case 1:
*ready <- true
read <- nil
default:
panic("unreachable") // unexpected read count
}
}()
p.wait = &wait
p.read = &read
p.ready = ready
return nil
}
// Wait waits for xdg-dbus-proxy to exit or fault.
func (p *Proxy) Wait() error {
p.lock.RLock()
defer p.lock.RUnlock()
if p.wait == nil || p.read == nil {
return errors.New("proxy not running")
}
defer func() {
if err1 := p.statP[0].Close(); err1 != nil && !errors.Is(err1, os.ErrClosed) {
panic(err1)
}
if err1 := p.statP[1].Close(); err1 != nil && !errors.Is(err1, os.ErrClosed) {
panic(err1)
}
if err1 := p.argsP[0].Close(); err1 != nil && !errors.Is(err1, os.ErrClosed) {
panic(err1)
}
if err1 := p.argsP[1].Close(); err1 != nil && !errors.Is(err1, os.ErrClosed) {
panic(err1)
}
}()
select {
case err := <-*p.wait:
*p.ready <- false
return err
case err := <-*p.read:
if err != nil {
*p.ready <- false
return err
}
return <-*p.wait
}
}
// Close closes the status file descriptor passed to xdg-dbus-proxy, causing it to stop.
func (p *Proxy) Close() error {
return p.statP[0].Close()
}

73
dbus/setup.go Normal file
View File

@ -0,0 +1,73 @@
package dbus
import (
"errors"
"os"
"os/exec"
"strings"
"sync"
)
// Proxy holds references to a xdg-dbus-proxy process, and should never be copied.
// Once sealed, configuration changes will no longer be possible and attempting to do so will result in a panic.
type Proxy struct {
cmd *exec.Cmd
statP [2]*os.File
argsP [2]*os.File
address [2]string
path string
wait *chan error
read *chan error
ready *chan bool
seal *string
lock sync.RWMutex
}
func (p *Proxy) String() string {
if p.cmd != nil {
return p.cmd.String()
}
if p.seal != nil {
return *p.seal
}
return "(unsealed dbus proxy)"
}
// Seal seals the Proxy instance.
func (p *Proxy) Seal(c *Config) error {
p.lock.Lock()
defer p.lock.Unlock()
if p.seal != nil {
panic("dbus proxy sealed twice")
}
args := c.Args(p.address[0], p.address[1])
seal := strings.Builder{}
for _, arg := range args {
// reject argument strings containing null
for _, b := range arg {
if b == '\x00' {
return errors.New("argument contains null")
}
}
// write null terminated argument
seal.WriteString(arg)
seal.WriteByte('\x00')
}
v := seal.String()
p.seal = &v
return nil
}
// New returns a reference to a new unsealed Proxy.
func New(binPath, address, path string) *Proxy {
return &Proxy{path: binPath, address: [2]string{address, path}}
}

40
flag.go Normal file
View File

@ -0,0 +1,40 @@
package main
import (
"flag"
"git.ophivana.moe/cat/fortify/internal/app"
)
var (
userName string
dbusConfig string
dbusID string
mpris bool
mustWayland bool
mustX bool
mustDBus bool
mustPulse bool
flagVerbose bool
printVersion bool
)
func init() {
flag.StringVar(&userName, "u", "chronos", "Passwd name of user to run as")
flag.StringVar(&dbusConfig, "dbus-config", "builtin", "Path to D-Bus proxy config file, or \"builtin\" for defaults")
flag.StringVar(&dbusID, "dbus-id", "", "D-Bus ID of application, leave empty to disable own paths, has no effect if custom config is available")
flag.BoolVar(&mpris, "mpris", false, "Allow owning MPRIS D-Bus path, has no effect if custom config is available")
flag.BoolVar(&mustWayland, "wayland", false, "Share Wayland socket")
flag.BoolVar(&mustX, "X", false, "Share X11 socket and allow connection")
flag.BoolVar(&mustDBus, "dbus", false, "Proxy D-Bus connection")
flag.BoolVar(&mustPulse, "pulse", false, "Share PulseAudio socket and cookie")
flag.BoolVar(&app.LaunchOptions[app.LaunchMethodSudo], "sudo", false, "Use 'sudo' to switch user")
flag.BoolVar(&app.LaunchOptions[app.LaunchMethodMachineCtl], "machinectl", true, "Use 'machinectl' to switch user")
flag.BoolVar(&flagVerbose, "v", false, "Verbose output")
flag.BoolVar(&printVersion, "V", false, "Print version")
}

92
internal/app/dbus.go Normal file
View File

@ -0,0 +1,92 @@
package app
import (
"errors"
"fmt"
"os"
"path"
"strconv"
"git.ophivana.moe/cat/fortify/dbus"
"git.ophivana.moe/cat/fortify/internal/acl"
"git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/system"
"git.ophivana.moe/cat/fortify/internal/util"
)
const dbusSessionBusAddress = "DBUS_SESSION_BUS_ADDRESS"
var dbusAddress string
func (a *App) ShareDBus(c *dbus.Config) {
a.setEnablement(state.EnableDBus)
var binPath, address string
target := path.Join(system.V.Share, strconv.Itoa(os.Getpid()))
dbusAddress = "unix:path=" + target
if b, ok := util.Which("xdg-dbus-proxy"); !ok {
state.Fatal("D-Bus: Did not find 'xdg-dbus-proxy' in PATH")
} else {
binPath = b
}
if addr, ok := os.LookupEnv(dbusSessionBusAddress); !ok {
state.Fatal("D-Bus: DBUS_SESSION_BUS_ADDRESS not set")
} else {
address = addr
}
c.Log = system.V.Verbose
p := dbus.New(binPath, address, target)
if system.V.Verbose {
fmt.Println("D-Bus: sealing proxy", c.Args(address, target))
}
if err := p.Seal(c); err != nil {
state.Fatal("D-Bus: invalid config when sealing proxy,", err)
}
ready := make(chan bool, 1)
done := make(chan struct{})
if system.V.Verbose {
fmt.Printf("Starting session bus proxy '%s' for address '%s'\n", dbusAddress, address)
}
if err := p.Start(&ready); err != nil {
state.Fatal("D-Bus: error starting proxy,", err)
}
if system.V.Verbose {
fmt.Println("D-Bus proxy launch:", p)
}
go func() {
if err := p.Wait(); err != nil {
fmt.Println("warn: D-Bus proxy returned error,", err)
} else {
if system.V.Verbose {
fmt.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
state.RegisterDBus(p, &done)
if !<-ready {
state.Fatal("D-Bus: proxy did not start correctly")
}
a.AppendEnv(dbusSessionBusAddress, dbusAddress)
if err := acl.UpdatePerm(target, a.UID(), acl.Read, acl.Write); err != nil {
state.Fatal(fmt.Sprintf("Error preparing D-Bus proxy '%s':", dbusAddress), err)
} else {
state.RegisterRevertPath(target)
}
if system.V.Verbose {
fmt.Printf("Session bus proxy '%s' for address '%s' configured\n", dbusAddress, address)
}
}

View File

@ -10,14 +10,10 @@ import (
"syscall" "syscall"
"git.ophivana.moe/cat/fortify/internal/state" "git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/system"
"git.ophivana.moe/cat/fortify/internal/util" "git.ophivana.moe/cat/fortify/internal/util"
) )
const ( const launcherPayload = "FORTIFY_LAUNCHER_PAYLOAD"
sudoAskPass = "SUDO_ASKPASS"
launcherPayload = "FORTIFY_LAUNCHER_PAYLOAD"
)
func (a *App) launcherPayloadEnv() string { func (a *App) launcherPayloadEnv() string {
r := &bytes.Buffer{} r := &bytes.Buffer{}
@ -75,80 +71,3 @@ func Early(printVersion bool) {
} }
} }
} }
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
}

68
internal/app/pulse.go Normal file
View File

@ -0,0 +1,68 @@
package app
import (
"errors"
"fmt"
"io/fs"
"os"
"path"
"git.ophivana.moe/cat/fortify/internal/acl"
"git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/system"
"git.ophivana.moe/cat/fortify/internal/util"
)
func (a *App) SharePulse() {
a.setEnablement(state.EnablePulse)
// ensure PulseAudio directory ACL (e.g. `/run/user/%d/pulse`)
pulse := path.Join(system.V.Runtime, "pulse")
pulseS := path.Join(pulse, "native")
if s, err := os.Stat(pulse); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
state.Fatal("Error accessing PulseAudio directory:", err)
}
state.Fatal(fmt.Sprintf("PulseAudio dir '%s' not found", pulse))
} else {
// add environment variable for new process
a.AppendEnv(util.PulseServer, "unix:"+pulseS)
if err = acl.UpdatePerm(pulse, a.UID(), acl.Execute); err != nil {
state.Fatal("Error preparing PulseAudio:", err)
} else {
state.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) {
state.Fatal("PulseAudio directory found but socket does not exist")
}
state.Fatal("Error accessing PulseAudio socket:", err)
} else {
if m := s.Mode(); m&0o006 != 0o006 {
state.Fatal(fmt.Sprintf("Unexpected permissions on '%s':", pulseS), m)
}
}
// Publish current user's pulse-cookie for target user
pulseCookieSource := util.DiscoverPulseCookie()
pulseCookieFinal := path.Join(system.V.Share, "pulse-cookie")
a.AppendEnv(util.PulseCookie, pulseCookieFinal)
if system.V.Verbose {
fmt.Printf("Publishing PulseAudio cookie '%s' to '%s'\n", pulseCookieSource, pulseCookieFinal)
}
if err = util.CopyFile(pulseCookieFinal, pulseCookieSource); err != nil {
state.Fatal("Error copying PulseAudio cookie:", err)
}
if err = acl.UpdatePerm(pulseCookieFinal, a.UID(), acl.Read); err != nil {
state.Fatal("Error publishing PulseAudio cookie:", err)
} else {
state.RegisterRevertPath(pulseCookieFinal)
}
if system.V.Verbose {
fmt.Printf("PulseAudio dir '%s' configured\n", pulse)
}
}
}

177
internal/app/run.go Normal file
View File

@ -0,0 +1,177 @@
package app
import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/system"
"git.ophivana.moe/cat/fortify/internal/util"
)
const (
term = "TERM"
sudoAskPass = "SUDO_ASKPASS"
)
const (
LaunchMethodSudo = iota
LaunchMethodMachineCtl
launchOptionLength
)
var (
// LaunchOptions is set in main's cli.go
LaunchOptions [launchOptionLength]bool
)
func (a *App) Run() {
// pass $TERM to launcher
if t, ok := os.LookupEnv(term); ok {
a.AppendEnv(term, t)
}
commandBuilder := a.commandBuilderSudo
var toolPath string
// dependency checks
const sudoFallback = "Falling back to 'sudo', some desktop integration features may not work"
if LaunchOptions[LaunchMethodMachineCtl] && !LaunchOptions[LaunchMethodSudo] { // sudo argument takes priority
if !util.SdBooted() {
fmt.Println("This system was not booted through systemd")
fmt.Println(sudoFallback)
} else if machineCtlPath, ok := util.Which("machinectl"); !ok {
fmt.Println("Did not find 'machinectl' in PATH")
fmt.Println(sudoFallback)
} else {
toolPath = machineCtlPath
commandBuilder = a.commandBuilderMachineCtl
}
} else if sudoPath, ok := util.Which("sudo"); !ok {
state.Fatal("Did not find 'sudo' in PATH")
} else {
toolPath = sudoPath
}
if system.V.Verbose {
fmt.Printf("Selected launcher '%s'\n", toolPath)
}
cmd := exec.Command(toolPath, commandBuilder()...)
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)
}
state.RegisterEnablement(a.enablements)
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 (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 {
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) commandBuilderMachineCtl() (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{}
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 {
state.Fatal("Error reading executable path:", err)
} else {
if a.enablements.Has(state.EnableDBus) {
innerCommand.WriteString(dbusSessionBusAddress + "=" + "'" + dbusAddress + "' ")
}
innerCommand.WriteString("exec " + executable + " -V")
}
args = append(args, innerCommand.String())
return
}

View File

@ -4,13 +4,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/exec"
"os/user" "os/user"
"strconv" "strconv"
"git.ophivana.moe/cat/fortify/internal/state" "git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/system" "git.ophivana.moe/cat/fortify/internal/system"
"git.ophivana.moe/cat/fortify/internal/util"
) )
type App struct { type App struct {
@ -18,78 +16,19 @@ type App struct {
env []string env []string
command []string command []string
enablements state.Enablements
*user.User *user.User
// absolutely *no* method of this type is thread-safe
// so don't treat it as if it is
} }
func (a *App) Run() { func (a *App) setEnablement(e state.Enablement) {
f := a.launchBySudo if a.enablements.Has(e) {
m, b := false, false panic("enablement " + e.String() + " set twice")
switch {
case system.MethodFlags[0]: // sudo
case system.MethodFlags[1]: // bare
m, b = true, true
default: // machinectl
m, b = true, false
} }
var toolPath string a.enablements |= e.Mask()
// 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 { func New(userName string, args []string) *App {

37
internal/app/wayland.go Normal file
View File

@ -0,0 +1,37 @@
package app
import (
"fmt"
"os"
"path"
"git.ophivana.moe/cat/fortify/internal/acl"
"git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/system"
)
const (
// https://manpages.debian.org/experimental/libwayland-doc/wl_display_connect.3.en.html
waylandDisplay = "WAYLAND_DISPLAY"
)
func (a *App) ShareWayland() {
a.setEnablement(state.EnableWayland)
// ensure Wayland socket ACL (e.g. `/run/user/%d/wayland-%d`)
if w, ok := os.LookupEnv(waylandDisplay); !ok {
state.Fatal("Wayland: WAYLAND_DISPLAY not set")
} else {
// add environment variable for new process
wp := path.Join(system.V.Runtime, w)
a.AppendEnv(waylandDisplay, wp)
if err := acl.UpdatePerm(wp, a.UID(), acl.Read, acl.Write, acl.Execute); err != nil {
state.Fatal(fmt.Sprintf("Error preparing Wayland '%s':", w), err)
} else {
state.RegisterRevertPath(wp)
}
if system.V.Verbose {
fmt.Printf("Wayland socket '%s' configured\n", w)
}
}
}

33
internal/app/x.go Normal file
View File

@ -0,0 +1,33 @@
package app
import (
"fmt"
"os"
"git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/system"
"git.ophivana.moe/cat/fortify/internal/xcb"
)
const display = "DISPLAY"
func (a *App) ShareX() {
a.setEnablement(state.EnableX)
// discovery X11 and grant user permission via the `ChangeHosts` command
if d, ok := os.LookupEnv(display); !ok {
state.Fatal("X11: DISPLAY not set")
} else {
// add environment variable for new process
a.AppendEnv(display, d)
if system.V.Verbose {
fmt.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 {
state.Fatal(fmt.Sprintf("Error adding XHost entry to '%s':", d), err)
} else {
state.XcbActionComplete()
}
}
}

View File

@ -0,0 +1,34 @@
package state
type (
Enablement uint8
Enablements uint64
)
const (
EnableWayland Enablement = iota
EnableX
EnableDBus
EnablePulse
enableLength
)
var enablementString = [enableLength]string{
"Wayland",
"X11",
"D-Bus",
"PulseAudio",
}
func (e Enablement) String() string {
return enablementString[e]
}
func (e Enablement) Mask() Enablements {
return 1 << e
}
func (es Enablements) Has(e Enablement) bool {
return es&e.Mask() != 0
}

View File

@ -33,7 +33,7 @@ func BeforeExit() {
} }
} }
if d, err := readLaunchers(); err != nil { if d, err := readLaunchers(u.Uid); err != nil {
fmt.Println("Error reading active launchers:", err) fmt.Println("Error reading active launchers:", err)
os.Exit(1) os.Exit(1)
} else if len(d) > 0 { } else if len(d) > 0 {
@ -65,4 +65,23 @@ func BeforeExit() {
fmt.Printf("Stripped ACL entry for user '%s' from '%s'\n", u.Username, candidate) fmt.Printf("Stripped ACL entry for user '%s' from '%s'\n", u.Username, candidate)
} }
} }
if dbusProxy != nil {
if system.V.Verbose {
fmt.Println("D-Bus proxy registered, cleaning up")
}
if err := dbusProxy.Close(); err != nil {
if errors.Is(err, os.ErrClosed) {
if system.V.Verbose {
fmt.Println("D-Bus proxy already closed")
}
} else {
fmt.Println("Error closing D-Bus proxy:", err)
}
}
// wait for Proxy.Wait to return
<-*dbusDone
}
} }

104
internal/state/print.go Normal file
View File

@ -0,0 +1,104 @@
package state
import (
"flag"
"fmt"
"os"
"strconv"
"strings"
"text/tabwriter"
"git.ophivana.moe/cat/fortify/internal/system"
)
var (
stateActionEarly bool
stateActionEarlyC bool
)
func init() {
flag.BoolVar(&stateActionEarly, "state", false, "print state information of active launchers")
flag.BoolVar(&stateActionEarlyC, "state-current", false, "print state information of active launchers for the specified user")
}
func Early() {
var w *tabwriter.Writer
switch {
case stateActionEarly:
if runDir, err := os.ReadDir(system.V.RunDir); err != nil {
fmt.Println("Error reading runtime directory:", err)
} else {
for _, e := range runDir {
if !e.IsDir() {
if system.V.Verbose {
fmt.Println("Skipped non-directory entry", e.Name())
}
continue
}
if _, err = strconv.Atoi(e.Name()); err != nil {
if system.V.Verbose {
fmt.Println("Skipped non-uid entry", e.Name())
}
continue
}
printLauncherState(e.Name(), &w)
}
}
case stateActionEarlyC:
printLauncherState(u.Uid, &w)
default:
return
}
if w != nil {
if err := w.Flush(); err != nil {
fmt.Println("warn: error formatting output:", err)
}
} else {
fmt.Println("No information available.")
}
os.Exit(0)
}
func printLauncherState(uid string, w **tabwriter.Writer) {
launchers, err := readLaunchers(uid)
if err != nil {
fmt.Println("Error reading launchers:", err)
os.Exit(1)
}
if *w == nil {
*w = tabwriter.NewWriter(os.Stdout, 0, 1, 4, ' ', 0)
if !system.V.Verbose {
_, _ = fmt.Fprintln(*w, "\tUID\tPID\tEnablements\tLauncher\tCommand")
} else {
_, _ = fmt.Fprintln(*w, "\tUID\tPID\tArgv")
}
}
for _, state := range launchers {
enablementsDescription := strings.Builder{}
for i := Enablement(0); i < enableLength; i++ {
if state.Capability.Has(i) {
enablementsDescription.WriteString(", " + i.String())
}
}
if enablementsDescription.Len() == 0 {
enablementsDescription.WriteString("none")
}
if !system.V.Verbose {
_, _ = fmt.Fprintf(*w, "\t%s\t%d\t%s\t%s\t%s\n",
uid, state.PID, strings.TrimPrefix(enablementsDescription.String(), ", "), state.Launcher,
state.Command)
} else {
_, _ = fmt.Fprintf(*w, "\t%s\t%d\t%s\n",
uid, state.PID, state.Argv)
}
}
}

View File

@ -1,12 +1,35 @@
package state package state
import "git.ophivana.moe/cat/fortify/dbus"
var (
cleanupCandidate []string
enablements *Enablements
xcbActionComplete bool
dbusProxy *dbus.Proxy
dbusDone *chan struct{}
)
func RegisterRevertPath(p string) { func RegisterRevertPath(p string) {
cleanupCandidate = append(cleanupCandidate, p) cleanupCandidate = append(cleanupCandidate, p)
} }
func RegisterEnablement(e Enablements) {
if enablements != nil {
panic("enablement state set twice")
}
enablements = &e
}
func XcbActionComplete() { func XcbActionComplete() {
if xcbActionComplete { if xcbActionComplete {
Fatal("xcb inserted twice") Fatal("xcb inserted twice")
} }
xcbActionComplete = true xcbActionComplete = true
} }
func RegisterDBus(p *dbus.Proxy, done *chan struct{}) {
dbusProxy = p
dbusDone = done
}

View File

@ -3,8 +3,6 @@ package state
import ( import (
"encoding/gob" "encoding/gob"
"errors" "errors"
"flag"
"fmt"
"io/fs" "io/fs"
"os" "os"
"os/exec" "os/exec"
@ -18,10 +16,7 @@ import (
// this and launcher should eventually be replaced by a server process // this and launcher should eventually be replaced by a server process
var ( var (
stateActionEarly bool
statePath string statePath string
cleanupCandidate []string
xcbActionComplete bool
) )
type launcherState struct { type launcherState struct {
@ -29,29 +24,7 @@ type launcherState struct {
Launcher string Launcher string
Argv []string Argv []string
Command []string Command []string
} Capability Enablements
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 // SaveProcess called after process start, before wait
@ -62,6 +35,7 @@ func SaveProcess(uid string, cmd *exec.Cmd) error {
Launcher: cmd.Path, Launcher: cmd.Path,
Argv: cmd.Args, Argv: cmd.Args,
Command: command, Command: command,
Capability: *enablements,
} }
if err := os.Mkdir(path.Join(system.V.RunDir, uid), 0700); err != nil && !errors.Is(err, fs.ErrExist) { if err := os.Mkdir(path.Join(system.V.RunDir, uid), 0700); err != nil && !errors.Is(err, fs.ErrExist) {
@ -81,10 +55,10 @@ func SaveProcess(uid string, cmd *exec.Cmd) error {
} }
} }
func readLaunchers() ([]*launcherState, error) { func readLaunchers(uid string) ([]*launcherState, error) {
var f *os.File var f *os.File
var r []*launcherState var r []*launcherState
launcherPrefix := path.Join(system.V.RunDir, u.Uid) launcherPrefix := path.Join(system.V.RunDir, uid)
if pl, err := os.ReadDir(launcherPrefix); err != nil { if pl, err := os.ReadDir(launcherPrefix); err != nil {
return nil, err return nil, err

View File

@ -11,7 +11,4 @@ type Values struct {
Verbose bool Verbose bool
} }
var ( var V *Values
V *Values
MethodFlags [2]bool
)

128
main.go
View File

@ -1,26 +1,27 @@
package main package main
import ( import (
"encoding/json"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"io/fs" "io/fs"
"os" "os"
"path"
"strconv" "strconv"
"syscall" "syscall"
"git.ophivana.moe/cat/fortify/dbus"
"git.ophivana.moe/cat/fortify/internal/acl" "git.ophivana.moe/cat/fortify/internal/acl"
"git.ophivana.moe/cat/fortify/internal/app" "git.ophivana.moe/cat/fortify/internal/app"
"git.ophivana.moe/cat/fortify/internal/state" "git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/system" "git.ophivana.moe/cat/fortify/internal/system"
"git.ophivana.moe/cat/fortify/internal/util"
"git.ophivana.moe/cat/fortify/internal/xcb"
) )
var ( var (
Version = "impure" Version = "impure"
a *app.App a *app.App
c *dbus.Config
) )
func tryVersion() { func tryVersion() {
@ -30,14 +31,6 @@ func tryVersion() {
} }
} }
const (
term = "TERM"
display = "DISPLAY"
// https://manpages.debian.org/experimental/libwayland-doc/wl_display_connect.3.en.html
waylandDisplay = "WAYLAND_DISPLAY"
)
func main() { func main() {
flag.Parse() flag.Parse()
@ -52,6 +45,21 @@ func main() {
a = app.New(userName, flag.Args()) a = app.New(userName, flag.Args())
state.Set(*a.User, a.Command(), a.UID()) state.Set(*a.User, a.Command(), a.UID())
// parse D-Bus config file if applicable
if mustDBus {
if dbusConfig == "builtin" {
c = dbus.NewConfig(dbusID, true, mpris)
} else {
if f, err := os.Open(dbusConfig); err != nil {
state.Fatal("Error opening D-Bus proxy config file:", err)
} else {
if err = json.NewDecoder(f).Decode(&c); err != nil {
state.Fatal("Error parsing D-Bus proxy config file:", err)
}
}
}
}
// ensure RunDir (e.g. `/run/user/%d/fortify`) // ensure RunDir (e.g. `/run/user/%d/fortify`)
if err := os.Mkdir(system.V.RunDir, 0700); err != nil && !errors.Is(err, fs.ErrExist) { if err := os.Mkdir(system.V.RunDir, 0700); err != nil && !errors.Is(err, fs.ErrExist) {
state.Fatal("Error creating runtime directory:", err) state.Fatal("Error creating runtime directory:", err)
@ -105,102 +113,20 @@ func main() {
} }
} }
// ensure Wayland socket ACL (e.g. `/run/user/%d/wayland-%d`) if mustWayland {
if w, ok := os.LookupEnv(waylandDisplay); !ok { a.ShareWayland()
if system.V.Verbose {
fmt.Println("Wayland: WAYLAND_DISPLAY not set, skipping")
}
} else {
// add environment variable for new process
wp := path.Join(system.V.Runtime, w)
a.AppendEnv(waylandDisplay, wp)
if err := acl.UpdatePerm(wp, a.UID(), acl.Read, acl.Write, acl.Execute); err != nil {
state.Fatal(fmt.Sprintf("Error preparing Wayland '%s':", w), err)
} else {
state.RegisterRevertPath(wp)
}
if system.V.Verbose {
fmt.Printf("Wayland socket '%s' configured\n", w)
}
} }
// discovery X11 and grant user permission via the `ChangeHosts` command if mustX {
if d, ok := os.LookupEnv(display); !ok { a.ShareX()
if system.V.Verbose {
fmt.Println("X11: DISPLAY not set, skipping")
}
} else {
// add environment variable for new process
a.AppendEnv(display, d)
if system.V.Verbose {
fmt.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 {
state.Fatal(fmt.Sprintf("Error adding XHost entry to '%s':", d), err)
} else {
state.XcbActionComplete()
}
} }
// ensure PulseAudio directory ACL (e.g. `/run/user/%d/pulse`) if mustDBus {
pulse := path.Join(system.V.Runtime, "pulse") a.ShareDBus(c)
pulseS := path.Join(pulse, "native")
if s, err := os.Stat(pulse); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
state.Fatal("Error accessing PulseAudio directory:", err)
} }
if mustPulse { if mustPulse {
state.Fatal("PulseAudio is unavailable") a.SharePulse()
}
if system.V.Verbose {
fmt.Printf("PulseAudio dir '%s' not found, skipping\n", pulse)
}
} else {
// add environment variable for new process
a.AppendEnv(util.PulseServer, "unix:"+pulseS)
if err = acl.UpdatePerm(pulse, a.UID(), acl.Execute); err != nil {
state.Fatal("Error preparing PulseAudio:", err)
} else {
state.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) {
state.Fatal("PulseAudio directory found but socket does not exist")
}
state.Fatal("Error accessing PulseAudio socket:", err)
} else {
if m := s.Mode(); m&0o006 != 0o006 {
state.Fatal(fmt.Sprintf("Unexpected permissions on '%s':", pulseS), m)
}
}
// Publish current user's pulse-cookie for target user
pulseCookieSource := util.DiscoverPulseCookie()
pulseCookieFinal := path.Join(system.V.Share, "pulse-cookie")
a.AppendEnv(util.PulseCookie, pulseCookieFinal)
if system.V.Verbose {
fmt.Printf("Publishing PulseAudio cookie '%s' to '%s'\n", pulseCookieSource, pulseCookieFinal)
}
if err = util.CopyFile(pulseCookieFinal, pulseCookieSource); err != nil {
state.Fatal("Error copying PulseAudio cookie:", err)
}
if err = acl.UpdatePerm(pulseCookieFinal, a.UID(), acl.Read); err != nil {
state.Fatal("Error publishing PulseAudio cookie:", err)
} else {
state.RegisterRevertPath(pulseCookieFinal)
}
if system.V.Verbose {
fmt.Printf("PulseAudio dir '%s' configured\n", pulse)
}
}
// pass $TERM to launcher
if t, ok := os.LookupEnv(term); ok {
a.AppendEnv(term, t)
} }
a.Run() a.Run()

View File

@ -63,6 +63,60 @@ in
''; '';
}; };
dbus = {
config = mkOption {
type = nullOr anything;
default = null;
description = ''
D-Bus custom configuration.
Setting this to null will enable built-in defaults.
'';
};
id = mkOption {
type = nullOr str;
default = null;
description = ''
D-Bus application id.
Setting this to null will disable own path in defaults.
Has no effect if custom configuration is set.
'';
};
mpris = mkOption {
type = bool;
default = false;
description = ''
Whether to enable MPRIS in D-Bus defaults.
'';
};
};
capability = {
wayland = mkOption {
type = bool;
default = true;
description = ''
Whether to share the Wayland socket.
'';
};
x11 = mkOption {
type = bool;
default = false;
description = ''
Whether to share the X11 socket and allow connection.
'';
};
dbus = mkOption {
type = bool;
default = true;
description = ''
Whether to proxy D-Bus.
'';
};
pulse = mkOption { pulse = mkOption {
type = bool; type = bool;
default = true; default = true;
@ -70,6 +124,7 @@ in
Whether to share the PulseAudio socket and cookie. Whether to share the PulseAudio socket and cookie.
''; '';
}; };
};
share = mkOption { share = mkOption {
type = nullOr package; type = nullOr package;
@ -164,8 +219,23 @@ in
user: launchers: user: launchers:
mapAttrsToList ( mapAttrsToList (
name: launcher: name: launcher:
with launcher.capability;
let let
command = if launcher.command == null then name else launcher.command; command = if launcher.command == null then name else launcher.command;
dbusConfig =
if launcher.dbus.config != null then
pkgs.writeText "${name}-dbus.json" (builtins.toJSON launcher.dbus.config)
else
null;
capArgs =
(if wayland then " -wayland" else "")
+ (if x11 then " -X" else "")
+ (if dbus then " -dbus" else "")
+ (if pulse then " -pulse" else "")
+ (if launcher.dbus.mpris then " -mpris" else "")
+ (if launcher.dbus.id != null then " -dbus-id ${dbus.id}" else "")
+ (if dbusConfig != null then " -dbus-config ${dbusConfig}" else "")
+ (if launcher.method == "fortify-sudo" then " -sudo" else "");
in in
pkgs.writeShellScriptBin name ( pkgs.writeShellScriptBin name (
if launcher.method == "sudo" then if launcher.method == "sudo" then
@ -174,9 +244,7 @@ in
'' ''
else else
'' ''
exec fortify${if launcher.pulse then " -pulse" else ""} -u ${user}${ exec fortify${capArgs} -u ${user} ${cfg.shell} -c "exec ${command} $@"
if launcher.method == "fortify-sudo" then " -sudo" else ""
} ${cfg.shell} -c "exec ${command} $@"
'' ''
) )
) launchers; ) launchers;

View File

@ -1,12 +1,15 @@
{ {
lib,
buildGoModule,
makeBinaryWrapper,
xdg-dbus-proxy,
acl, acl,
xorg, xorg,
buildGoModule,
}: }:
buildGoModule rec { buildGoModule rec {
pname = "fortify"; pname = "fortify";
version = "1.0.4"; version = "1.1.0";
src = ./.; src = ./.;
vendorHash = null; vendorHash = null;
@ -22,4 +25,10 @@ buildGoModule rec {
acl acl
xorg.libxcb xorg.libxcb
]; ];
nativeBuildInputs = [ makeBinaryWrapper ];
postInstall = ''
wrapProgram $out/bin/${pname} --prefix PATH : ${lib.makeBinPath [ xdg-dbus-proxy ]}
'';
} }