Compare commits

..

No commits in common. "master" and "v0.0.0-beta.1" have entirely different histories.

42 changed files with 816 additions and 2443 deletions

View File

@ -27,8 +27,8 @@ jobs:
if: ${{ runner.os == 'Linux' }}
- name: Build for Linux
run: >-
sh -c "go build -v -ldflags '-s -w -X main.Version=${{ github.ref_name }}' -o bin/fortify &&
sha256sum --tag -b bin/fortify > bin/fortify.sha256"
sh -c "go build -v -ldflags '-s -w -X main.Version=${{ github.ref_name }}' -o bin/ego &&
sha256sum --tag -b bin/ego > bin/ego.sha256"
- name: Release
id: use-go-action
uses: https://gitea.com/actions/release-action@main

2
.gitignore vendored
View File

@ -4,7 +4,7 @@
*.dll
*.so
*.dylib
/fortify
/ego
# Test binary, built with `go test -c`
*.test

View File

@ -1,7 +1,9 @@
Copyright (c) 2024 Ophestra Umiker
Copyright (C) 2024 Ophestra Umiker
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL OPHESTRA UMIKER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Except as contained in this notice, the name of Ophestra Umiker shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Software without prior written authorization from Ophestra Umiker.

182
README.md
View File

@ -1,182 +0,0 @@
Fortify
=======
[![Go Reference](https://pkg.go.dev/badge/git.ophivana.moe/cat/fortify.svg)](https://pkg.go.dev/git.ophivana.moe/cat/fortify)
Lets you run graphical applications as another user ~~in an Android-like sandbox environment~~ (WIP) with a nice NixOS
module to configure target users and provide launchers and desktop files for your privileged user.
Why would you want this?
- It protects the desktop environment from applications.
- It protects applications from each other.
- It provides UID isolation on top of ~~the standard application sandbox~~ (WIP).
There are a few different things to set up for this to work:
- A set of users, each for a group of applications that should be allowed access to each other
- A tool to switch users, currently sudo and machinectl are supported.
- If you are running NixOS, the module in this repository can take care of launchers and desktop files in the privileged
user's environment, as well as packages and extra home-manager configuration for target users.
If you have a flakes-enabled nix environment, you can try out the tool by running:
```shell
nix run git+https://git.ophivana.moe/cat/fortify -- -h
```
## Module usage
The NixOS module currently requires home-manager and impermanence to function correctly.
To use the module, import it into your configuration with
```nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
fortify = {
url = "git+https://git.ophivana.moe/cat/fortify";
# Optional but recommended to limit the size of your system closure.
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, fortify, ... }:
{
nixosConfigurations.fortify = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
fortify.nixosModules.fortify
];
};
};
}
```
This adds the `environment.fortify` option:
```nix
{ pkgs, ... }:
{
environment.fortify = {
enable = true;
user = "nixos";
shell = "zsh";
stateDir = "/var/lib/persist/module";
target = {
chronos = {
launchers = {
weechat.method = "sudo";
claws-mail.capability.pulse = false;
discord = {
command = "vesktop --ozone-platform-hint=wayland";
share = pkgs.vesktop;
};
chromium.dbus = {
configSystem = {
filter = true;
talk = [
"org.bluez"
"org.freedesktop.Avahi"
"org.freedesktop.UPower"
];
};
config = {
filter = true;
talk = [
"org.freedesktop.DBus"
"org.freedesktop.FileManager1"
"org.freedesktop.Notifications"
"org.freedesktop.ScreenSaver"
"org.freedesktop.secrets"
"org.kde.kwalletd5"
"org.kde.kwalletd6"
];
own = [
"org.chromium.Chromium.*"
"org.mpris.MediaPlayer2.org.chromium.Chromium.*"
"org.mpris.MediaPlayer2.chromium.*"
];
call = {
"org.freedesktop.portal.*" = "*";
};
broadcast = {
"org.freedesktop.portal.*" = "@/org/freedesktop/portal/*";
};
};
};
};
packages = with pkgs; [
weechat
claws-mail
vesktop
chromium
];
persistence.directories = [
".config/weechat"
".claws-mail"
".config/vesktop"
];
extraConfig = {
programs.looking-glass-client.enable = true;
};
};
};
};
}
```
* `enable` determines whether the module should be enabled or not. Useful when sharing configurations between graphical
and headless systems. Defaults to `false`.
* `user` specifies the privileged user with access to fortified applications.
* `shell` is the shell used to run the launch command, required for sourcing the home-manager environment.
* `stateDir` is the path to your persistent storage location. It is directly passed through to the impermanence module.
* `target` is an attribute set of submodules, where the attribute name is the username of the unprivileged target user.
The available options are:
* `packages`, the list of packages to make available in the target user's environment.
* `persistence`, user persistence attribute set passed to impermanence.
* `extraConfig`, extra home-manager configuration for the target user.
* `launchers`, attribute set where the attribute name is the name of the launcher.
The available options are:
* `command`, the command to run as the target user. Defaults to launcher name.
* `dbus.config`, D-Bus proxy custom configuration.
* `dbus.configSystem`, D-Bus system bus custom configuration, null to disable.
* `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.
* `method`, the launch method for the sandboxed program, can be `"fortify"`, `"fortify-sudo"`, `"sudo"`.

View File

@ -1,4 +1,4 @@
package acl
package main
import (
"errors"
@ -13,11 +13,88 @@ import (
//#cgo linux LDFLAGS: -lacl
import "C"
const (
aclRead = C.ACL_READ
aclWrite = C.ACL_WRITE
aclExecute = C.ACL_EXECUTE
aclTypeDefault = C.ACL_TYPE_DEFAULT
aclTypeAccess = C.ACL_TYPE_ACCESS
aclUndefinedTag = C.ACL_UNDEFINED_TAG
aclUserObj = C.ACL_USER_OBJ
aclUser = C.ACL_USER
aclGroupObj = C.ACL_GROUP_OBJ
aclGroup = C.ACL_GROUP
aclMask = C.ACL_MASK
aclOther = C.ACL_OTHER
)
type acl struct {
val C.acl_t
freed bool
}
func aclUpdatePerm(path string, uid int, perms ...C.acl_perm_t) error {
// read acl from file
a, err := aclGetFile(path, aclTypeAccess)
if err != nil {
return err
}
// free acl on return if get is successful
defer a.free()
// remove existing entry
if err = a.removeEntry(aclUser, 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, aclUser); 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, aclTypeAccess)
}
func aclGetFile(path string, t C.acl_type_t) (*acl, error) {
p := C.CString(path)
a, err := C.acl_get_file(p, t)

View File

@ -1,86 +0,0 @@
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)
}

51
cli.go Normal file
View File

@ -0,0 +1,51 @@
package main
import (
"errors"
"flag"
"fmt"
"os"
"os/user"
)
var (
userName string
methodFlags [2]bool
printVersion bool
mustPulse bool
)
func init() {
flag.StringVar(&userName, "u", "ego", "Specify a username")
flag.BoolVar(&methodFlags[0], "sudo", false, "Use 'sudo' to change user")
flag.BoolVar(&methodFlags[1], "bare", false, "Use 'machinectl' but skip xdg-desktop-portal setup")
flag.BoolVar(&mustPulse, "pulse", false, "Treat unavailable PulseAudio as fatal")
flag.BoolVar(&verbose, "v", false, "Verbose output")
flag.BoolVar(&printVersion, "V", false, "Print version")
}
func copyArgs() {
if printVersion {
fmt.Println(Version)
os.Exit(0)
}
command = flag.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)
}
os.Exit(1)
} else {
ego = u
}
if verbose {
fmt.Println("Running as user", ego.Username, "("+ego.Uid+"),", "command:", command)
}
}

View File

@ -1,103 +0,0 @@
package dbus
import (
"errors"
"strings"
)
type Config struct {
// See set 'see' policy for NAME (--see=NAME)
See []string `json:"see"`
// Talk set 'talk' policy for NAME (--talk=NAME)
Talk []string `json:"talk"`
// Own set 'own' policy for NAME (--own=NAME)
Own []string `json:"own"`
// Call set RULE for calls on NAME (--call=NAME=RULE)
Call map[string]string `json:"call"`
// Broadcast set RULE for broadcasts from NAME (--broadcast=NAME=RULE)
Broadcast map[string]string `json:"broadcast"`
Log bool `json:"log,omitempty"`
Filter bool `json:"filter"`
}
func (c *Config) Args(bus [2]string) (args []string) {
argc := 2 + len(c.See) + len(c.Talk) + len(c.Own) + len(c.Call) + len(c.Broadcast)
if c.Log {
argc++
}
if c.Filter {
argc++
}
args = make([]string, 0, argc)
args = append(args, bus[0], bus[1])
if c.Filter {
args = append(args, "--filter")
}
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)
}
for name, rule := range c.Call {
args = append(args, "--call="+name+"="+rule)
}
for name, rule := range c.Broadcast {
args = append(args, "--broadcast="+name+"="+rule)
}
if c.Log {
args = append(args, "--log")
}
return
}
func (c *Config) buildSeal(seal *strings.Builder, bus [2]string) error {
for _, arg := range c.Args(bus) {
// 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')
}
return nil
}
// 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{
Call: make(map[string]string),
Broadcast: make(map[string]string),
Filter: true,
}
if defaults {
c.Talk = []string{"org.freedesktop.DBus", "org.freedesktop.Notifications"}
c.Call["org.freedesktop.portal.*"] = "*"
c.Broadcast["org.freedesktop.portal.*"] = "@/org/freedesktop/portal/*"
if id != "" {
c.Own = []string{id + ".*"}
if mpris {
c.Own = append(c.Own, "org.mpris.MediaPlayer2."+id+".*")
}
}
}
return
}

View File

@ -1,134 +0,0 @@
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()
}

View File

@ -1,84 +0,0 @@
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
path string
session [2]string
system [2]string
wait *chan error
read *chan error
ready *chan bool
seal *string
lock sync.RWMutex
}
func (p *Proxy) String() string {
if p == nil {
return "(invalid dbus proxy)"
}
p.lock.RLock()
defer p.lock.RUnlock()
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(session, system *Config) error {
p.lock.Lock()
defer p.lock.Unlock()
if p.seal != nil {
panic("dbus proxy sealed twice")
}
if session == nil && system == nil {
return errors.New("no configuration to seal")
}
seal := strings.Builder{}
if session != nil {
if err := session.buildSeal(&seal, p.session); err != nil {
return err
}
}
if system != nil {
if err := system.buildSeal(&seal, p.system); err != nil {
return err
}
}
v := seal.String()
p.seal = &v
return nil
}
// New returns a reference to a new unsealed Proxy.
func New(binPath string, session, system [2]string) *Proxy {
return &Proxy{path: binPath, session: session, system: system}
}

52
flag.go
View File

@ -1,52 +0,0 @@
package main
import (
"flag"
"git.ophivana.moe/cat/fortify/internal"
)
var (
userName string
dbusConfigSession string
dbusConfigSystem string
dbusVerbose bool
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(&dbusConfigSession, "dbus-config", "builtin", "Path to D-Bus proxy config file, or \"builtin\" for defaults")
flag.StringVar(&dbusConfigSystem, "dbus-system", "nil", "Path to system D-Bus proxy config file, or \"nil\" to disable")
flag.BoolVar(&dbusVerbose, "dbus-log", false, "Enable logging in the D-Bus proxy")
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(&flagVerbose, "v", false, "Verbose output")
flag.BoolVar(&printVersion, "V", false, "Print version")
}
func init() {
methodHelpString := "Method of launching the child process, can be one of \"sudo\", \"bubblewrap\""
if internal.SdBootedV {
methodHelpString += ", \"systemd\""
}
flag.StringVar(&launchOptionText, "method", "sudo", methodHelpString)
}

View File

@ -2,16 +2,16 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1725361206,
"narHash": "sha256-/HTUg+kMaqBPGrcQBYboAMsQHIWIkuKRDldss/035Hc=",
"lastModified": 1717179513,
"narHash": "sha256-vboIEwIQojofItm2xGCdZCzW96U85l9nDW3ifMuAIdM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "2830c7c930311397d94c0b86a359c865c081c875",
"rev": "63dacb46bf939521bdc93981b4cbb7ecb58427a0",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable-small",
"ref": "24.05",
"repo": "nixpkgs",
"type": "github"
}

View File

@ -1,47 +1,36 @@
{
description = "fortify sandbox tool and nixos module";
description = "ego development environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable-small";
nixpkgs.url = "github:NixOS/nixpkgs/24.05";
};
outputs =
{ self, nixpkgs }:
outputs = { self, nixpkgs }:
let
supportedSystems = [
"aarch64-linux"
"i686-linux"
"x86_64-linux"
];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; });
supportedSystems = [ "x86_64-linux" ];
forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system);
in
{
nixosModules.fortify = import ./nixos.nix;
packages = forAllSystems (
system:
let
pkgs = nixpkgsFor.${system};
in
{
default = self.packages.${system}.fortify;
fortify = pkgs.callPackage ./package.nix { };
}
);
devShells = forAllSystems (system: {
default = nixpkgsFor.${system}.mkShell {
buildInputs =
with nixpkgsFor.${system};
[ self.packages.${system}.fortify ] ++ self.packages.${system}.fortify.buildInputs;
shellHook = ''
which fortify
'';
};
});
devShells = forAllSystems
(system:
let
pkgs = import nixpkgs {
inherit system;
};
in
{
default = with pkgs; mkShell
{
packages = [
clang
acl
xorg.libxcb
(pkgs.writeShellScriptBin "build" ''
go build -v -ldflags '-s -w -X main.Version=flake'
'')
];
};
}
);
};
}

2
go.mod
View File

@ -1,3 +1,3 @@
module git.ophivana.moe/cat/fortify
module git.ophivana.moe/cat/ego
go 1.22

View File

@ -1,13 +0,0 @@
package app
func (a *App) Command() []string {
return a.command
}
func (a *App) UID() int {
return a.uid
}
func (a *App) AppendEnv(k, v string) {
a.env = append(a.env, k+"="+v)
}

View File

@ -1,123 +0,0 @@
package app
import (
"errors"
"fmt"
"os"
"path"
"strconv"
"git.ophivana.moe/cat/fortify/acl"
"git.ophivana.moe/cat/fortify/dbus"
"git.ophivana.moe/cat/fortify/internal"
"git.ophivana.moe/cat/fortify/internal/util"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
const (
dbusSessionBusAddress = "DBUS_SESSION_BUS_ADDRESS"
dbusSystemBusAddress = "DBUS_SYSTEM_BUS_ADDRESS"
)
var (
dbusAddress [2]string
dbusSystem bool
)
func (a *App) ShareDBus(dse, dsg *dbus.Config, log bool) {
a.setEnablement(internal.EnableDBus)
dbusSystem = dsg != nil
var binPath string
var sessionBus, systemBus [2]string
target := path.Join(a.sharePath, strconv.Itoa(os.Getpid()))
sessionBus[1] = target + ".bus"
systemBus[1] = target + ".system-bus"
dbusAddress = [2]string{
"unix:path=" + sessionBus[1],
"unix:path=" + systemBus[1],
}
if b, ok := util.Which("xdg-dbus-proxy"); !ok {
internal.Fatal("D-Bus: Did not find 'xdg-dbus-proxy' in PATH")
} else {
binPath = b
}
if addr, ok := os.LookupEnv(dbusSessionBusAddress); !ok {
verbose.Println("D-Bus: DBUS_SESSION_BUS_ADDRESS not set, assuming default format")
sessionBus[0] = fmt.Sprintf("unix:path=/run/user/%d/bus", os.Getuid())
} else {
sessionBus[0] = addr
}
if addr, ok := os.LookupEnv(dbusSystemBusAddress); !ok {
verbose.Println("D-Bus: DBUS_SYSTEM_BUS_ADDRESS not set, assuming default format")
systemBus[0] = "unix:path=/run/dbus/system_bus_socket"
} else {
systemBus[0] = addr
}
p := dbus.New(binPath, sessionBus, systemBus)
dse.Log = log
verbose.Println("D-Bus: sealing session proxy", dse.Args(sessionBus))
if dsg != nil {
dsg.Log = log
verbose.Println("D-Bus: sealing system proxy", dsg.Args(systemBus))
}
if err := p.Seal(dse, dsg); err != nil {
internal.Fatal("D-Bus: invalid config when sealing proxy,", err)
}
ready := make(chan bool, 1)
done := make(chan struct{})
verbose.Printf("Starting session bus proxy '%s' for address '%s'\n", dbusAddress[0], sessionBus[0])
if dsg != nil {
verbose.Printf("Starting system bus proxy '%s' for address '%s'\n", dbusAddress[1], systemBus[0])
}
if err := p.Start(&ready); err != nil {
internal.Fatal("D-Bus: error starting proxy,", err)
}
verbose.Println("D-Bus proxy launch:", p)
go func() {
if err := p.Wait(); err != nil {
fmt.Println("warn: D-Bus proxy returned error,", err)
} else {
verbose.Println("D-Bus proxy uneventful wait")
}
if err := os.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) {
fmt.Println("Error removing dangling D-Bus socket:", err)
}
done <- struct{}{}
}()
// register early to enable Fatal cleanup
a.exit.SealDBus(p, &done)
if !<-ready {
internal.Fatal("D-Bus: proxy did not start correctly")
}
a.AppendEnv(dbusSessionBusAddress, dbusAddress[0])
if err := acl.UpdatePerm(sessionBus[1], a.UID(), acl.Read, acl.Write); err != nil {
internal.Fatal(fmt.Sprintf("Error preparing D-Bus session proxy '%s':", dbusAddress[0]), err)
} else {
a.exit.RegisterRevertPath(sessionBus[1])
}
if dsg != nil {
a.AppendEnv(dbusSystemBusAddress, dbusAddress[1])
if err := acl.UpdatePerm(systemBus[1], a.UID(), acl.Read, acl.Write); err != nil {
internal.Fatal(fmt.Sprintf("Error preparing D-Bus system proxy '%s':", dbusAddress[1]), err)
} else {
a.exit.RegisterRevertPath(systemBus[1])
}
}
verbose.Printf("Session bus proxy '%s' for address '%s' configured\n", dbusAddress[0], sessionBus[0])
if dsg != nil {
verbose.Printf("System bus proxy '%s' for address '%s' configured\n", dbusAddress[1], systemBus[0])
}
}

View File

@ -1,63 +0,0 @@
package app
import (
"errors"
"fmt"
"io/fs"
"os"
"path"
"git.ophivana.moe/cat/fortify/acl"
"git.ophivana.moe/cat/fortify/internal"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
func (a *App) EnsureRunDir() {
if err := os.Mkdir(a.runDirPath, 0700); err != nil && !errors.Is(err, fs.ErrExist) {
internal.Fatal("Error creating runtime directory:", err)
}
}
func (a *App) EnsureRuntime() {
if s, err := os.Stat(a.runtimePath); err != nil {
if errors.Is(err, fs.ErrNotExist) {
internal.Fatal("Runtime directory does not exist")
}
internal.Fatal("Error accessing runtime directory:", err)
} else if !s.IsDir() {
internal.Fatal(fmt.Sprintf("Path '%s' is not a directory", a.runtimePath))
} else {
if err = acl.UpdatePerm(a.runtimePath, a.UID(), acl.Execute); err != nil {
internal.Fatal("Error preparing runtime directory:", err)
} else {
a.exit.RegisterRevertPath(a.runtimePath)
}
verbose.Printf("Runtime data dir '%s' configured\n", a.runtimePath)
}
}
func (a *App) EnsureShare() {
// acl is unnecessary as this directory is world executable
if err := os.Mkdir(a.sharePath, 0701); err != nil && !errors.Is(err, fs.ErrExist) {
internal.Fatal("Error creating shared directory:", err)
}
// workaround for launch method sudo
if a.LaunchOption() == LaunchMethodSudo {
// ensure child runtime directory (e.g. `/tmp/fortify.%d/%d.share`)
cr := path.Join(a.sharePath, a.Uid+".share")
if err := os.Mkdir(cr, 0700); err != nil && !errors.Is(err, fs.ErrExist) {
internal.Fatal("Error creating child runtime directory:", err)
} else {
if err = acl.UpdatePerm(cr, a.UID(), acl.Read, acl.Write, acl.Execute); err != nil {
internal.Fatal("Error preparing child runtime directory:", err)
} else {
a.exit.RegisterRevertPath(cr)
}
a.AppendEnv("XDG_RUNTIME_DIR", cr)
a.AppendEnv("XDG_SESSION_CLASS", "user")
a.AppendEnv("XDG_SESSION_TYPE", "tty")
verbose.Printf("Child runtime data dir '%s' configured\n", cr)
}
}
}

View File

@ -1,109 +0,0 @@
package app
import (
"errors"
"fmt"
"io/fs"
"os"
"path"
"git.ophivana.moe/cat/fortify/acl"
"git.ophivana.moe/cat/fortify/internal"
"git.ophivana.moe/cat/fortify/internal/util"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
const (
pulseServer = "PULSE_SERVER"
pulseCookie = "PULSE_COOKIE"
home = "HOME"
xdgConfigHome = "XDG_CONFIG_HOME"
)
func (a *App) SharePulse() {
a.setEnablement(internal.EnablePulse)
// ensure PulseAudio directory ACL (e.g. `/run/user/%d/pulse`)
pulse := path.Join(a.runtimePath, "pulse")
pulseS := path.Join(pulse, "native")
if s, err := os.Stat(pulse); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
internal.Fatal("Error accessing PulseAudio directory:", err)
}
internal.Fatal(fmt.Sprintf("PulseAudio dir '%s' not found", pulse))
} else {
// add environment variable for new process
a.AppendEnv(pulseServer, "unix:"+pulseS)
if err = acl.UpdatePerm(pulse, a.UID(), acl.Execute); err != nil {
internal.Fatal("Error preparing PulseAudio:", err)
} else {
a.exit.RegisterRevertPath(pulse)
}
// ensure PulseAudio socket permission (e.g. `/run/user/%d/pulse/native`)
if s, err = os.Stat(pulseS); err != nil {
if errors.Is(err, fs.ErrNotExist) {
internal.Fatal("PulseAudio directory found but socket does not exist")
}
internal.Fatal("Error accessing PulseAudio socket:", err)
} else {
if m := s.Mode(); m&0o006 != 0o006 {
internal.Fatal(fmt.Sprintf("Unexpected permissions on '%s':", pulseS), m)
}
}
// Publish current user's pulse-cookie for target user
pulseCookieSource := discoverPulseCookie()
pulseCookieFinal := path.Join(a.sharePath, "pulse-cookie")
a.AppendEnv(pulseCookie, pulseCookieFinal)
verbose.Printf("Publishing PulseAudio cookie '%s' to '%s'\n", pulseCookieSource, pulseCookieFinal)
if err = util.CopyFile(pulseCookieFinal, pulseCookieSource); err != nil {
internal.Fatal("Error copying PulseAudio cookie:", err)
}
if err = acl.UpdatePerm(pulseCookieFinal, a.UID(), acl.Read); err != nil {
internal.Fatal("Error publishing PulseAudio cookie:", err)
} else {
a.exit.RegisterRevertPath(pulseCookieFinal)
}
verbose.Printf("PulseAudio dir '%s' configured\n", pulse)
}
}
// discoverPulseCookie try various standard methods to discover the current user's PulseAudio authentication cookie
func discoverPulseCookie() string {
if p, ok := os.LookupEnv(pulseCookie); ok {
return p
}
if p, ok := os.LookupEnv(home); ok {
p = path.Join(p, ".pulse-cookie")
if s, err := os.Stat(p); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
internal.Fatal("Error accessing PulseAudio cookie:", err)
// unreachable
return p
}
} else if !s.IsDir() {
return p
}
}
if p, ok := os.LookupEnv(xdgConfigHome); ok {
p = path.Join(p, "pulse", "cookie")
if s, err := os.Stat(p); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
internal.Fatal("Error accessing PulseAudio cookie:", err)
// unreachable
return p
}
} else if !s.IsDir() {
return p
}
}
internal.Fatal(fmt.Sprintf("Cannot locate PulseAudio cookie (tried $%s, $%s/pulse/cookie, $%s/.pulse-cookie)",
pulseCookie, xdgConfigHome, home))
return ""
}

View File

@ -1,163 +0,0 @@
package app
import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"git.ophivana.moe/cat/fortify/internal"
"git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/util"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
const (
term = "TERM"
sudoAskPass = "SUDO_ASKPASS"
)
const (
LaunchMethodSudo uint8 = iota
LaunchMethodBwrap
LaunchMethodMachineCtl
)
func (a *App) Run() {
// pass $TERM to launcher
if t, ok := os.LookupEnv(term); ok {
a.AppendEnv(term, t)
}
var commandBuilder func() (args []string)
switch a.launchOption {
case LaunchMethodSudo:
commandBuilder = a.commandBuilderSudo
case LaunchMethodBwrap:
commandBuilder = a.commandBuilderBwrap
case LaunchMethodMachineCtl:
commandBuilder = a.commandBuilderMachineCtl
default:
panic("unreachable")
}
cmd := exec.Command(a.toolPath, commandBuilder()...)
cmd.Env = []string{}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Dir = a.runDirPath
verbose.Println("Executing:", cmd)
if err := cmd.Start(); err != nil {
internal.Fatal("Error starting process:", err)
}
a.exit.SealEnablements(a.enablements)
if statePath, err := state.SaveProcess(a.Uid, cmd, a.runDirPath, a.command, a.enablements); err != nil {
// process already started, shouldn't be fatal
fmt.Println("Error registering process:", err)
} else {
a.exit.SealStatePath(statePath)
}
var r int
if err := cmd.Wait(); err != nil {
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
internal.Fatal("Error running process:", err)
}
}
verbose.Println("Process exited with exit code", r)
internal.BeforeExit()
os.Exit(r)
}
func (a *App) commandBuilderSudo() (args []string) {
args = make([]string, 0, 4+len(a.env)+len(a.command))
// -Hiu $USER
args = append(args, "-Hiu", a.Username)
// -A?
if _, ok := os.LookupEnv(sudoAskPass); ok {
verbose.Printf("%s set, adding askpass flag\n", sudoAskPass)
args = append(args, "-A")
}
// environ
args = append(args, a.env...)
// -- $@
args = append(args, "--")
args = append(args, a.command...)
return
}
func (a *App) commandBuilderBwrap() (args []string) {
// TODO: build bwrap command
internal.Fatal("bwrap")
panic("unreachable")
}
func (a *App) commandBuilderMachineCtl() (args []string) {
args = make([]string, 0, 9+len(a.env))
// shell --uid=$USER
args = append(args, "shell", "--uid="+a.Username)
// --quiet
if !verbose.Get() {
args = append(args, "--quiet")
}
// environ
envQ := make([]string, len(a.env)+1)
for i, e := range a.env {
envQ[i] = "-E" + e
}
envQ[len(a.env)] = "-E" + a.launcherPayloadEnv()
args = append(args, envQ...)
// -- .host
args = append(args, "--", ".host")
// /bin/sh -c
if sh, ok := util.Which("sh"); !ok {
internal.Fatal("Did not find 'sh' in PATH")
} else {
args = append(args, sh, "-c")
}
if len(a.command) == 0 { // execute shell if command is not provided
a.command = []string{"$SHELL"}
}
innerCommand := strings.Builder{}
innerCommand.WriteString("dbus-update-activation-environment --systemd")
for _, e := range a.env {
innerCommand.WriteString(" " + strings.SplitN(e, "=", 2)[0])
}
innerCommand.WriteString("; ")
if executable, err := os.Executable(); err != nil {
internal.Fatal("Error reading executable path:", err)
} else {
if a.enablements.Has(internal.EnableDBus) {
innerCommand.WriteString(dbusSessionBusAddress + "=" + "'" + dbusAddress[0] + "' ")
if dbusSystem {
innerCommand.WriteString(dbusSystemBusAddress + "=" + "'" + dbusAddress[1] + "' ")
}
}
innerCommand.WriteString("exec " + executable + " -V")
}
args = append(args, innerCommand.String())
return
}

View File

@ -1,150 +0,0 @@
package app
import (
"errors"
"fmt"
"os"
"os/user"
"path"
"strconv"
"git.ophivana.moe/cat/fortify/internal"
"git.ophivana.moe/cat/fortify/internal/util"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
const (
xdgRuntimeDir = "XDG_RUNTIME_DIR"
)
type App struct {
uid int // assigned
env []string // modified via AppendEnv
command []string // set on initialisation
exit *internal.ExitState // assigned
launchOptionText string // set on initialisation
launchOption uint8 // assigned
sharePath string // set on initialisation
runtimePath string // assigned
runDirPath string // assigned
toolPath string // assigned
enablements internal.Enablements // set via setEnablement
*user.User // assigned
// absolutely *no* method of this type is thread-safe
// so don't treat it as if it is
}
func (a *App) LaunchOption() uint8 {
return a.launchOption
}
func (a *App) RunDir() string {
return a.runDirPath
}
func (a *App) setEnablement(e internal.Enablement) {
if a.enablements.Has(e) {
panic("enablement " + e.String() + " set twice")
}
a.enablements |= e.Mask()
}
func (a *App) SealExit(exit *internal.ExitState) {
if a.exit != nil {
panic("application exit state sealed twice")
}
a.exit = exit
}
func New(userName string, args []string, launchOptionText string) *App {
a := &App{
command: args,
launchOptionText: launchOptionText,
sharePath: path.Join(os.TempDir(), "fortify."+strconv.Itoa(os.Geteuid())),
}
// runtimePath, runDirPath
if r, ok := os.LookupEnv(xdgRuntimeDir); !ok {
fmt.Println("Env variable", xdgRuntimeDir, "unset")
// too early for fatal
os.Exit(1)
} else {
a.runtimePath = r
a.runDirPath = path.Join(a.runtimePath, "fortify")
verbose.Println("Runtime directory at", a.runDirPath)
}
// *user.User
if u, err := user.Lookup(userName); err != nil {
if errors.As(err, new(user.UnknownUserError)) {
fmt.Println("unknown user", userName)
} else {
// unreachable
panic(err)
}
// too early for fatal
os.Exit(1)
} else {
a.User = u
}
// uid
if u, err := strconv.Atoi(a.Uid); err != nil {
// usually unreachable
panic("uid parse")
} else {
a.uid = u
}
verbose.Println("Running as user", a.Username, "("+a.Uid+"),", "command:", a.command)
if internal.SdBootedV {
verbose.Println("System booted with systemd as init system (PID 1).")
}
// launchOption, toolPath
switch a.launchOptionText {
case "sudo":
a.launchOption = LaunchMethodSudo
if sudoPath, ok := util.Which("sudo"); !ok {
fmt.Println("Did not find 'sudo' in PATH")
os.Exit(1)
} else {
a.toolPath = sudoPath
}
case "bubblewrap":
a.launchOption = LaunchMethodBwrap
if bwrapPath, ok := util.Which("bwrap"); !ok {
fmt.Println("Did not find 'bwrap' in PATH")
os.Exit(1)
} else {
a.toolPath = bwrapPath
}
case "systemd":
a.launchOption = LaunchMethodMachineCtl
if !internal.SdBootedV {
fmt.Println("System has not been booted with systemd as init system (PID 1).")
os.Exit(1)
}
if machineCtlPath, ok := util.Which("machinectl"); !ok {
fmt.Println("Did not find 'machinectl' in PATH")
} else {
a.toolPath = machineCtlPath
}
default:
fmt.Println("invalid launch method")
os.Exit(1)
}
verbose.Println("Determined launch method to be", a.launchOptionText, "with tool at", a.toolPath)
return a
}

View File

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

View File

@ -1,31 +0,0 @@
package app
import (
"fmt"
"os"
"git.ophivana.moe/cat/fortify/internal"
"git.ophivana.moe/cat/fortify/internal/verbose"
"git.ophivana.moe/cat/fortify/xcb"
)
const display = "DISPLAY"
func (a *App) ShareX() {
a.setEnablement(internal.EnableX)
// discovery X11 and grant user permission via the `ChangeHosts` command
if d, ok := os.LookupEnv(display); !ok {
internal.Fatal("X11: DISPLAY not set")
} else {
// add environment variable for new process
a.AppendEnv(display, d)
verbose.Printf("X11: Adding XHost entry SI:localuser:%s to display '%s'\n", a.Username, d)
if err := xcb.ChangeHosts(xcb.HostModeInsert, xcb.FamilyServerInterpreted, "localuser\x00"+a.Username); err != nil {
internal.Fatal(fmt.Sprintf("Error adding XHost entry to '%s':", d), err)
} else {
a.exit.XcbActionComplete()
}
}
}

View File

@ -1,16 +0,0 @@
package internal
import (
"fmt"
"git.ophivana.moe/cat/fortify/internal/util"
)
var SdBootedV = func() bool {
if v, err := util.SdBooted(); err != nil {
fmt.Println("warn: read systemd marker:", err)
return false
} else {
return v
}
}()

View File

@ -1,34 +0,0 @@
package internal
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

@ -1,176 +0,0 @@
package internal
import (
"errors"
"fmt"
"io/fs"
"os"
"os/user"
"git.ophivana.moe/cat/fortify/acl"
"git.ophivana.moe/cat/fortify/dbus"
"git.ophivana.moe/cat/fortify/internal/verbose"
"git.ophivana.moe/cat/fortify/xcb"
)
// ExitState keeps track of various changes fortify made to the system
// as well as other resources that need to be manually released.
// NOT thread safe.
type ExitState struct {
// target fortified user inherited from app.App
user *user.User
// integer UID of targeted user
uid int
// returns amount of launcher states read
launcherStateCount func() (int, error)
// paths to strip ACLs (of target user) from
aclCleanupCandidate []string
// target process capability enablements
enablements *Enablements
// whether the xcb.ChangeHosts action was complete
xcbActionComplete bool
// reference to D-Bus proxy instance, nil if disabled
dbusProxy *dbus.Proxy
// D-Bus wait complete notification
dbusDone *chan struct{}
// path to fortify process state information
statePath string
// prevents cleanup from happening twice
complete bool
}
// RegisterRevertPath registers a path with ACLs added by fortify
func (s *ExitState) RegisterRevertPath(p string) {
s.aclCleanupCandidate = append(s.aclCleanupCandidate, p)
}
// SealEnablements submits the child process enablements
func (s *ExitState) SealEnablements(e Enablements) {
if s.enablements != nil {
panic("enablement exit state set twice")
}
s.enablements = &e
}
// XcbActionComplete submits xcb.ChangeHosts action completion
func (s *ExitState) XcbActionComplete() {
if s.xcbActionComplete {
Fatal("xcb inserted twice")
}
s.xcbActionComplete = true
}
// SealDBus submits the child's D-Bus proxy instance
func (s *ExitState) SealDBus(p *dbus.Proxy, done *chan struct{}) {
if p == nil {
Fatal("unexpected nil dbus proxy exit state submitted")
}
if s.dbusProxy != nil {
Fatal("dbus proxy exit state set twice")
}
s.dbusProxy = p
s.dbusDone = done
}
// SealStatePath submits filesystem path to the fortify process's state file
func (s *ExitState) SealStatePath(v string) {
if s.statePath != "" {
panic("statePath set twice")
}
s.statePath = v
}
// NewExit initialises a new ExitState containing basic, unchanging information
// about the fortify process required during cleanup
func NewExit(u *user.User, uid int, f func() (int, error)) *ExitState {
return &ExitState{
uid: uid,
user: u,
launcherStateCount: f,
}
}
func Fatal(msg ...any) {
fmt.Println(msg...)
BeforeExit()
os.Exit(1)
}
var exitState *ExitState
func SealExit(s *ExitState) {
if exitState != nil {
panic("exit state submitted twice")
}
exitState = s
}
func BeforeExit() {
if exitState == nil {
fmt.Println("warn: cleanup attempted before exit state submission")
return
}
exitState.beforeExit()
}
func (s *ExitState) beforeExit() {
if s.complete {
panic("beforeExit called twice")
}
if s.statePath == "" {
verbose.Println("State path is unset")
} else {
if err := os.Remove(s.statePath); err != nil && !errors.Is(err, fs.ErrNotExist) {
fmt.Println("Error removing state file:", err)
}
}
if count, err := s.launcherStateCount(); err != nil {
fmt.Println("Error reading active launchers:", err)
os.Exit(1)
} else if count > 0 {
// other launchers are still active
verbose.Printf("Found %d active launchers, exiting without cleaning up\n", count)
return
}
verbose.Println("No other launchers active, will clean up")
if s.xcbActionComplete {
verbose.Printf("X11: Removing XHost entry SI:localuser:%s\n", s.user.Username)
if err := xcb.ChangeHosts(xcb.HostModeDelete, xcb.FamilyServerInterpreted, "localuser\x00"+s.user.Username); err != nil {
fmt.Println("Error removing XHost entry:", err)
}
}
for _, candidate := range s.aclCleanupCandidate {
if err := acl.UpdatePerm(candidate, s.uid); err != nil {
fmt.Printf("Error stripping ACL entry from '%s': %s\n", candidate, err)
}
verbose.Printf("Stripped ACL entry for user '%s' from '%s'\n", s.user.Username, candidate)
}
if s.dbusProxy != nil {
verbose.Println("D-Bus proxy registered, cleaning up")
if err := s.dbusProxy.Close(); err != nil {
if errors.Is(err, os.ErrClosed) {
verbose.Println("D-Bus proxy already closed")
} else {
fmt.Println("Error closing D-Bus proxy:", err)
}
}
// wait for Proxy.Wait to return
<-*s.dbusDone
}
}

View File

@ -1,59 +0,0 @@
package state
import (
"encoding/gob"
"os"
"path"
"git.ophivana.moe/cat/fortify/internal"
)
// we unfortunately have to assume there are never races between processes
// this and launcher should eventually be replaced by a server process
type launcherState struct {
PID int
Launcher string
Argv []string
Command []string
Capability internal.Enablements
}
// ReadLaunchers reads all launcher state file entries for the requested user
// and if decode is true decodes these launchers as well.
func ReadLaunchers(runDirPath, uid string, decode bool) ([]*launcherState, error) {
var f *os.File
var r []*launcherState
launcherPrefix := path.Join(runDirPath, 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)
if decode {
return gob.NewDecoder(f).Decode(&s)
} else {
return nil
}
}
}(); err != nil {
return nil, err
}
}
}
return r, nil
}

View File

@ -1,71 +0,0 @@
package state
import (
"fmt"
"os"
"strconv"
"strings"
"text/tabwriter"
"git.ophivana.moe/cat/fortify/internal"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
func MustPrintLauncherStateGlobal(w **tabwriter.Writer, runDirPath string) {
if dirs, err := os.ReadDir(runDirPath); err != nil {
fmt.Println("Error reading runtime directory:", err)
} else {
for _, e := range dirs {
if !e.IsDir() {
verbose.Println("Skipped non-directory entry", e.Name())
continue
}
if _, err = strconv.Atoi(e.Name()); err != nil {
verbose.Println("Skipped non-uid entry", e.Name())
continue
}
MustPrintLauncherState(w, runDirPath, e.Name())
}
}
}
func MustPrintLauncherState(w **tabwriter.Writer, runDirPath, uid string) {
launchers, err := ReadLaunchers(runDirPath, uid, true)
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 !verbose.Get() {
_, _ = fmt.Fprintln(*w, "\tUID\tPID\tEnablements\tLauncher\tCommand")
} else {
_, _ = fmt.Fprintln(*w, "\tUID\tPID\tArgv")
}
}
for _, state := range launchers {
enablementsDescription := strings.Builder{}
for i := internal.Enablement(0); i < internal.EnableLength; i++ {
if state.Capability.Has(i) {
enablementsDescription.WriteString(", " + i.String())
}
}
if enablementsDescription.Len() == 0 {
enablementsDescription.WriteString("none")
}
if !verbose.Get() {
_, _ = 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,41 +0,0 @@
package state
import (
"encoding/gob"
"errors"
"io/fs"
"os"
"os/exec"
"path"
"strconv"
"git.ophivana.moe/cat/fortify/internal"
)
// SaveProcess called after process start, before wait
func SaveProcess(uid string, cmd *exec.Cmd, runDirPath string, command []string, enablements internal.Enablements) (string, error) {
statePath := path.Join(runDirPath, uid, strconv.Itoa(cmd.Process.Pid))
state := launcherState{
PID: cmd.Process.Pid,
Launcher: cmd.Path,
Argv: cmd.Args,
Command: command,
Capability: enablements,
}
if err := os.Mkdir(path.Join(runDirPath, uid), 0700); err != nil && !errors.Is(err, fs.ErrExist) {
return statePath, err
}
if f, err := os.OpenFile(statePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0600); err != nil {
return statePath, err
} else {
defer func() {
if f.Close() != nil {
// unreachable
panic("state file closed prematurely")
}
}()
return statePath, gob.NewEncoder(f).Encode(state)
}
}

View File

@ -1,39 +0,0 @@
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
}

View File

@ -1,24 +0,0 @@
package util
import (
"errors"
"io/fs"
"os"
)
const (
systemdCheckPath = "/run/systemd/system"
)
// SdBooted implements https://www.freedesktop.org/software/systemd/man/sd_booted.html
func SdBooted() (bool, error) {
_, err := os.Stat(systemdCheckPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
err = nil
}
return false, err
}
return true, nil
}

View File

@ -1,15 +0,0 @@
package verbose
import "fmt"
func Println(a ...any) {
if verbose.Load() {
fmt.Println(a...)
}
}
func Printf(format string, a ...any) {
if verbose.Load() {
fmt.Printf(format, a...)
}
}

View File

@ -1,13 +0,0 @@
package verbose
import "sync/atomic"
var verbose = new(atomic.Bool)
func Get() bool {
return verbose.Load()
}
func Set(v bool) {
verbose.Store(v)
}

View File

@ -1,4 +1,4 @@
package app
package main
import (
"bytes"
@ -8,29 +8,18 @@ import (
"os"
"strings"
"syscall"
"git.ophivana.moe/cat/fortify/internal"
"git.ophivana.moe/cat/fortify/internal/util"
)
const launcherPayload = "FORTIFY_LAUNCHER_PAYLOAD"
const (
// hidden path for main to act as a launcher
egoLauncher = "EGO_LAUNCHER"
)
func (a *App) launcherPayloadEnv() string {
r := &bytes.Buffer{}
enc := base64.NewEncoder(base64.StdEncoding, r)
if err := gob.NewEncoder(enc).Encode(a.command); err != nil {
internal.Fatal("Error encoding launcher payload:", err)
}
_ = enc.Close()
return launcherPayload + "=" + r.String()
}
// Early hidden launcher path
func Early(printVersion bool) {
// hidden launcher path
func tryLauncher() {
if printVersion {
if r, ok := os.LookupEnv(launcherPayload); ok {
if r, ok := os.LookupEnv(egoLauncher); ok {
// egoLauncher variable contains launcher payload
dec := base64.NewDecoder(base64.StdEncoding, strings.NewReader(r))
var argv []string
@ -39,7 +28,7 @@ func Early(printVersion bool) {
os.Exit(1)
}
if err := os.Unsetenv(launcherPayload); err != nil {
if err := os.Unsetenv(egoLauncher); err != nil {
fmt.Println("Error unsetting launcher payload:", err)
// not fatal, do not fail
}
@ -47,7 +36,7 @@ func Early(printVersion bool) {
var p string
if len(argv) > 0 {
if p, ok = util.Which(argv[0]); !ok {
if p, ok = which(argv[0]); !ok {
fmt.Printf("Did not find '%s' in PATH\n", argv[0])
os.Exit(1)
}
@ -56,8 +45,6 @@ func Early(printVersion bool) {
fmt.Println("No command was specified and $SHELL was unset")
os.Exit(1)
}
argv = []string{p}
}
if err := syscall.Exec(p, argv, os.Environ()); err != nil {
@ -71,3 +58,15 @@ func Early(printVersion bool) {
}
}
}
func launcherPayloadEnv() string {
r := &bytes.Buffer{}
enc := base64.NewEncoder(base64.StdEncoding, r)
if err := gob.NewEncoder(enc).Encode(command); err != nil {
fatal("Error encoding launcher payload:", err)
}
_ = enc.Close()
return egoLauncher + "=" + r.String()
}

View File

@ -1,26 +0,0 @@
package main
import (
_ "embed"
"flag"
"fmt"
"os"
)
var (
//go:embed LICENSE
license string
printLicense bool
)
func init() {
flag.BoolVar(&printLicense, "license", false, "Print license")
}
func tryLicense() {
if printLicense {
fmt.Println(license)
os.Exit(0)
}
}

400
main.go
View File

@ -1,133 +1,357 @@
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io/fs"
"os"
"os/exec"
"os/user"
"path"
"strconv"
"strings"
"syscall"
"git.ophivana.moe/cat/fortify/dbus"
"git.ophivana.moe/cat/fortify/internal"
"git.ophivana.moe/cat/fortify/internal/app"
"git.ophivana.moe/cat/fortify/internal/state"
"git.ophivana.moe/cat/fortify/internal/verbose"
)
var Version = "impure"
var (
Version = "impure"
a *app.App
s *internal.ExitState
dbusSession *dbus.Config
dbusSystem *dbus.Config
launchOptionText string
ego *user.User
uid int
env []string
command []string
verbose bool
runtime string
runDir string
)
func tryVersion() {
if printVersion {
fmt.Println(Version)
os.Exit(0)
}
}
const (
term = "TERM"
home = "HOME"
sudoAskPass = "SUDO_ASKPASS"
xdgRuntimeDir = "XDG_RUNTIME_DIR"
xdgConfigHome = "XDG_CONFIG_HOME"
display = "DISPLAY"
pulseServer = "PULSE_SERVER"
pulseCookie = "PULSE_COOKIE"
// https://manpages.debian.org/experimental/libwayland-doc/wl_display_connect.3.en.html
waylandDisplay = "WAYLAND_DISPLAY"
)
func main() {
flag.Parse()
verbose.Set(flagVerbose)
tryLauncher()
copyArgs()
// launcher payload early exit
app.Early(printVersion)
// version/license command early exit
tryVersion()
tryLicense()
a = app.New(userName, flag.Args(), launchOptionText)
s = internal.NewExit(a.User, a.UID(), func() (int, error) {
d, err := state.ReadLaunchers(a.RunDir(), a.Uid, false)
return len(d), err
})
a.SealExit(s)
internal.SealExit(s)
// parse D-Bus config file if applicable
if mustDBus {
if dbusConfigSession == "builtin" {
dbusSession = dbus.NewConfig(dbusID, true, mpris)
} else {
if f, err := os.Open(dbusConfigSession); err != nil {
internal.Fatal("Error opening D-Bus proxy config file:", err)
} else {
if err = json.NewDecoder(f).Decode(&dbusSession); err != nil {
internal.Fatal("Error parsing D-Bus proxy config file:", err)
}
}
}
// system bus proxy is optional
if dbusConfigSystem != "nil" {
if f, err := os.Open(dbusConfigSystem); err != nil {
internal.Fatal("Error opening D-Bus proxy config file:", err)
} else {
if err = json.NewDecoder(f).Decode(&dbusSystem); err != nil {
internal.Fatal("Error parsing D-Bus proxy config file:", err)
}
}
}
if u, err := strconv.Atoi(ego.Uid); err != nil {
// usually unreachable
panic("ego uid parse")
} else {
uid = u
}
// ensure RunDir (e.g. `/run/user/%d/fortify`)
a.EnsureRunDir()
if r, ok := os.LookupEnv(xdgRuntimeDir); !ok {
fatal("Env variable", xdgRuntimeDir, "unset")
} else {
runtime = r
runDir = path.Join(runtime, "ego")
}
// state query command early exit
// state query command
tryState()
// ensure Share (e.g. `/tmp/fortify.%d`)
a.EnsureShare()
// warn about target user home directory ownership
if stat, err := os.Stat(a.HomeDir); err != nil {
if verbose.Get() {
// Report warning if user home directory does not exist or has wrong ownership
if stat, err := os.Stat(ego.HomeDir); err != nil {
if verbose {
switch {
case errors.Is(err, fs.ErrPermission):
fmt.Printf("User %s home directory %s is not accessible\n", a.Username, a.HomeDir)
fmt.Printf("User %s home directory %s is not accessible", ego.Username, ego.HomeDir)
case errors.Is(err, fs.ErrNotExist):
fmt.Printf("User %s home directory %s does not exis\n", a.Username, a.HomeDir)
fmt.Printf("User %s home directory %s does not exist", ego.Username, ego.HomeDir)
default:
fmt.Printf("Error stat user %s home directory %s: %s\n", a.Username, a.HomeDir, err)
fmt.Printf("Error stat user %s home directory %s: %s", ego.Username, ego.HomeDir, err)
}
}
return
} else {
// FreeBSD: not cross-platform
if u := strconv.Itoa(int(stat.Sys().(*syscall.Stat_t).Uid)); u != a.Uid {
fmt.Printf("User %s home directory %s has incorrect ownership (expected UID %s, found %s)", a.Username, a.HomeDir, a.Uid, u)
if u := strconv.Itoa(int(stat.Sys().(*syscall.Stat_t).Uid)); u != ego.Uid {
fmt.Printf("User %s home directory %s has incorrect ownership (expected UID %s, found %s)", ego.Username, ego.HomeDir, ego.Uid, u)
}
}
// ensure runtime directory ACL (e.g. `/run/user/%d`)
a.EnsureRuntime()
if mustWayland {
a.ShareWayland()
// Add execute perm to runtime dir, e.g. `/run/user/%d`
if s, err := os.Stat(runtime); err != nil {
if errors.Is(err, fs.ErrNotExist) {
fatal("Runtime directory does not exist")
}
fatal("Error accessing runtime directory:", err)
} else if !s.IsDir() {
fatal(fmt.Sprintf("Path '%s' is not a directory", runtime))
} else {
if err = aclUpdatePerm(runtime, uid, aclExecute); err != nil {
fatal("Error preparing runtime dir:", err)
} else {
registerRevertPath(runtime)
}
if verbose {
fmt.Printf("Runtime data dir '%s' configured\n", runtime)
}
}
if mustX {
a.ShareX()
// Create runtime dir for Ego itself (e.g. `/run/user/%d/ego`) and make it readable for target
if err := os.Mkdir(runDir, 0700); err != nil && !errors.Is(err, fs.ErrExist) {
fatal("Error creating Ego runtime dir:", err)
}
if err := aclUpdatePerm(runDir, uid, aclExecute); err != nil {
fatal("Error preparing Ego runtime dir:", err)
} else {
registerRevertPath(runDir)
}
if mustDBus {
a.ShareDBus(dbusSession, dbusSystem, dbusVerbose)
// Add rwx permissions to Wayland socket (e.g. `/run/user/%d/wayland-0`)
if w, ok := os.LookupEnv(waylandDisplay); !ok {
if verbose {
fmt.Println("Wayland: WAYLAND_DISPLAY not set, skipping")
}
} else {
// add environment variable for new process
env = append(env, waylandDisplay+"="+path.Join(runtime, w))
wp := path.Join(runtime, w)
if err := aclUpdatePerm(wp, uid, aclRead, aclWrite, aclExecute); err != nil {
fatal(fmt.Sprintf("Error preparing Wayland '%s':", w), err)
} else {
registerRevertPath(wp)
}
if verbose {
fmt.Printf("Wayland socket '%s' configured\n", w)
}
}
if mustPulse {
a.SharePulse()
// Detect `DISPLAY` and grant permissions via X11 protocol `ChangeHosts` command
if d, ok := os.LookupEnv(display); !ok {
if verbose {
fmt.Println("X11: DISPLAY not set, skipping")
}
} else {
// add environment variable for new process
env = append(env, display+"="+d)
if verbose {
fmt.Printf("X11: Adding XHost entry SI:localuser:%s to display '%s'\n", ego.Username, d)
}
if err := changeHosts(xcbHostModeInsert, xcbFamilyServerInterpreted, "localuser\x00"+ego.Username); err != nil {
fatal(fmt.Sprintf("Error adding XHost entry to '%s':", d), err)
} else {
xcbActionComplete = true
}
}
a.Run()
// Add execute permissions to PulseAudio directory (e.g. `/run/user/%d/pulse`)
pulse := path.Join(runtime, "pulse")
pulseS := path.Join(pulse, "native")
if s, err := os.Stat(pulse); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
fatal("Error accessing PulseAudio directory:", err)
}
if mustPulse {
fatal("PulseAudio is unavailable")
}
if verbose {
fmt.Printf("PulseAudio dir '%s' not found, skipping\n", pulse)
}
} else {
// add environment variable for new process
env = append(env, pulseServer+"=unix:"+pulseS)
if err = aclUpdatePerm(pulse, uid, aclExecute); err != nil {
fatal("Error preparing PulseAudio:", err)
} else {
registerRevertPath(pulse)
}
// Ensure permissions of PulseAudio socket `/run/user/%d/pulse/native`
if s, err = os.Stat(pulseS); err != nil {
if errors.Is(err, fs.ErrNotExist) {
fatal("PulseAudio directory found but socket does not exist")
}
fatal("Error accessing PulseAudio socket:", err)
} else {
if m := s.Mode(); m&0o006 != 0o006 {
fatal(fmt.Sprintf("Unexpected permissions on '%s':", pulseS), m)
}
}
// Publish current user's pulse-cookie for target user
pulseCookieSource := discoverPulseCookie()
env = append(env, pulseCookie+"="+pulseCookieSource)
pulseCookieFinal := path.Join(runDir, "pulse-cookie")
if verbose {
fmt.Printf("Publishing PulseAudio cookie '%s' to '%s'\n", pulseCookieSource, pulseCookieFinal)
}
if err = copyFile(pulseCookieFinal, pulseCookieSource); err != nil {
fatal("Error copying PulseAudio cookie:", err)
}
if err = aclUpdatePerm(pulseCookieFinal, uid, aclRead); err != nil {
fatal("Error publishing PulseAudio cookie:", err)
} else {
registerRevertPath(pulseCookieFinal)
}
if verbose {
fmt.Printf("PulseAudio dir '%s' configured\n", pulse)
}
}
// pass $TERM to launcher
if t, ok := os.LookupEnv(term); ok {
env = append(env, term+"="+t)
}
f := launchBySudo
m, b := false, false
switch {
case methodFlags[0]: // sudo
case 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 !sdBooted() {
fmt.Println("This system was not booted through systemd")
fmt.Println(sudoFallback)
} else if tp, ok := which("machinectl"); !ok {
fmt.Println("Did not find 'machinectl' in PATH")
fmt.Println(sudoFallback)
} else {
toolPath = tp
f = func() []string { return launchByMachineCtl(b) }
}
} else if tp, ok := which("sudo"); !ok {
fatal("Did not find 'sudo' in PATH")
} else {
toolPath = tp
}
if verbose {
fmt.Printf("Selected launcher '%s' bare=%t\n", toolPath, b)
}
cmd := exec.Command(toolPath, f()...)
cmd.Env = env
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Dir = runDir
if verbose {
fmt.Println("Executing:", cmd)
}
if err := cmd.Start(); err != nil {
fatal("Error starting process:", err)
}
if err := registerProcess(ego.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) {
fatal("Error running process:", err)
}
}
if verbose {
fmt.Println("Process exited with exit code", r)
}
beforeExit()
os.Exit(r)
}
func launchBySudo() (args []string) {
args = make([]string, 0, 4+len(env)+len(command))
// -Hiu $USER
args = append(args, "-Hiu", ego.Username)
// -A?
if _, ok := os.LookupEnv(sudoAskPass); ok {
if verbose {
fmt.Printf("%s set, adding askpass flag\n", sudoAskPass)
}
args = append(args, "-A")
}
// environ
args = append(args, env...)
// -- $@
args = append(args, "--")
args = append(args, command...)
return
}
func launchByMachineCtl(bare bool) (args []string) {
args = make([]string, 0, 9+len(env))
// shell --uid=$USER
args = append(args, "shell", "--uid="+ego.Username)
// --quiet
if !verbose {
args = append(args, "--quiet")
}
// environ
envQ := make([]string, len(env)+1)
for i, e := range env {
envQ[i] = "-E" + e
}
envQ[len(env)] = "-E" + launcherPayloadEnv()
args = append(args, envQ...)
// -- .host
args = append(args, "--", ".host")
// /bin/sh -c
if sh, ok := which("sh"); !ok {
fatal("Did not find 'sh' in PATH")
} else {
args = append(args, sh, "-c")
}
if len(command) == 0 { // execute shell if command is not provided
command = []string{"$SHELL"}
}
innerCommand := strings.Builder{}
if !bare {
innerCommand.WriteString("dbus-update-activation-environment --systemd")
for _, e := range env {
innerCommand.WriteString(" " + strings.SplitN(e, "=", 2)[0])
}
innerCommand.WriteString("; systemctl --user start xdg-desktop-portal-gtk; ")
}
if executable, err := os.Executable(); err != nil {
fatal("Error reading executable path:", err)
} else {
innerCommand.WriteString("exec " + executable + " -V")
}
args = append(args, innerCommand.String())
return
}

305
nixos.nix
View File

@ -1,305 +0,0 @@
{
lib,
pkgs,
config,
...
}:
let
inherit (lib)
types
mkOption
mkEnableOption
mkIf
mapAttrs
mapAttrsToList
foldlAttrs
optional
;
cfg = config.environment.fortify;
in
{
options = {
environment.fortify = {
enable = mkEnableOption "fortify";
target = mkOption {
default = { };
type =
let
inherit (types)
str
enum
bool
package
anything
submodule
listOf
attrsOf
nullOr
;
in
attrsOf (submodule {
options = {
packages = mkOption {
type = listOf package;
default = [ ];
description = ''
List of extra packages to install via home-manager.
'';
};
launchers = mkOption {
type = attrsOf (submodule {
options = {
command = mkOption {
type = nullOr str;
default = null;
description = ''
Command to run as the target user.
Setting this to null will default command to wrapper name.
'';
};
dbus = {
config = mkOption {
type = nullOr anything;
default = null;
description = ''
D-Bus custom configuration.
Setting this to null will enable built-in defaults.
'';
};
configSystem = mkOption {
type = nullOr anything;
default = null;
description = ''
D-Bus system bus custom configuration.
Setting this to null will disable the system bus proxy.
'';
};
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 {
type = bool;
default = true;
description = ''
Whether to share the PulseAudio socket and cookie.
'';
};
};
share = mkOption {
type = nullOr package;
default = null;
description = ''
Package containing share files.
Setting this to null will default package name to wrapper name.
'';
};
method = mkOption {
type = enum [
"simple"
"sudo"
"bubblewrap"
"systemd"
];
default = "systemd";
description = ''
Launch method for the sandboxed program.
'';
};
};
});
default = { };
};
persistence = mkOption {
type = submodule {
options = {
directories = mkOption {
type = listOf anything;
default = [ ];
};
files = mkOption {
type = listOf anything;
default = [ ];
};
};
};
description = ''
Per-user state passed to github:nix-community/impermanence.
'';
};
extraConfig = mkOption {
type = anything;
default = { };
description = "Extra home-manager configuration.";
};
};
});
};
package = mkOption {
type = types.package;
default = pkgs.callPackage ./package.nix { };
description = "Package providing fortify.";
};
user = mkOption {
type = types.str;
description = "Privileged user account.";
};
shell = mkOption {
type = types.str;
description = ''
Shell set up to source home-manager for the privileged user.
Required for setting up the environment of sandboxed programs.
'';
};
stateDir = mkOption {
type = types.str;
description = ''
The path to persistent storage where per-user state should be stored.
'';
};
};
};
config = mkIf cfg.enable {
environment.persistence.${cfg.stateDir}.users = mapAttrs (_: target: target.persistence) cfg.target;
home-manager.users =
mapAttrs (_: target: target.extraConfig // { home.packages = target.packages; }) cfg.target
// {
${cfg.user}.home.packages =
let
wrap =
user: launchers:
mapAttrsToList (
name: launcher:
with launcher.capability;
let
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;
dbusSystem =
if launcher.dbus.configSystem != null then
pkgs.writeText "${name}-dbus-system.json" (builtins.toJSON launcher.dbus.configSystem)
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 ${launcher.dbus.id}" else "")
+ (if dbusConfig != null then " -dbus-config ${dbusConfig}" else "")
+ (if dbusSystem != null then " -dbus-system ${dbusSystem}" else "");
in
pkgs.writeShellScriptBin name (
if launcher.method == "simple" then
''
exec sudo -u ${user} -i ${command} $@
''
else
''
exec fortify${capArgs} -method ${launcher.method} -u ${user} ${cfg.shell} -c "exec ${command} $@"
''
)
) launchers;
in
foldlAttrs (
acc: user: target:
acc
++ (foldlAttrs (
shares: name: launcher:
let
pkg = if launcher.share != null then launcher.share else pkgs.${name};
link = source: "[ -d '${source}' ] && ln -sv '${source}' $out/share || true";
in
shares
++
optional (launcher.method != "simple" && (launcher.capability.wayland || launcher.capability.x11))
(
pkgs.runCommand "${name}-share" { } ''
mkdir -p $out/share
${link "${pkg}/share/applications"}
${link "${pkg}/share/icons"}
${link "${pkg}/share/man"}
''
)
) (wrap user target.launchers) target.launchers)
) [ cfg.package ] cfg.target;
};
security.polkit.extraConfig =
let
allowList = builtins.toJSON (mapAttrsToList (name: _: name) cfg.target);
in
''
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.machine1.host-shell" &&
${allowList}.indexOf(action.lookup("user")) > -1 &&
subject.user == "${cfg.user}") {
return polkit.Result.YES;
}
});
'';
};
}

View File

@ -1,34 +0,0 @@
{
lib,
buildGoModule,
makeBinaryWrapper,
xdg-dbus-proxy,
acl,
xorg,
}:
buildGoModule rec {
pname = "fortify";
version = "0.0.0-beta.3";
src = ./.;
vendorHash = null;
ldflags = [
"-s"
"-w"
"-X"
"main.Version=v${version}"
];
buildInputs = [
acl
xorg.libxcb
];
nativeBuildInputs = [ makeBinaryWrapper ];
postInstall = ''
wrapProgram $out/bin/${pname} --prefix PATH : ${lib.makeBinPath [ xdg-dbus-proxy ]}
'';
}

162
state.go
View File

@ -1,42 +1,162 @@
package main
import (
"encoding/gob"
"errors"
"flag"
"fmt"
"git.ophivana.moe/cat/fortify/internal/state"
"io/fs"
"os"
"text/tabwriter"
"os/exec"
"path"
"strconv"
)
// we unfortunately have to assume there are never races between processes
// this and launcher should eventually be replaced by a server process
var (
stateActionEarly [2]bool
stateActionEarly bool
statePath string
cleanupCandidate []string
xcbActionComplete bool
)
func init() {
flag.BoolVar(&stateActionEarly[0], "state", false, "print state information of active launchers")
flag.BoolVar(&stateActionEarly[1], "state-current", false, "print state information of active launchers for the specified user")
type launcherState struct {
PID int
Launcher string
Argv []string
Command []string
}
// tryState is called after app initialisation
func tryState() {
var w *tabwriter.Writer
func init() {
flag.BoolVar(&stateActionEarly, "state", false, "query state value of current active launchers")
}
switch {
case stateActionEarly[0]:
state.MustPrintLauncherStateGlobal(&w, a.RunDir())
case stateActionEarly[1]:
state.MustPrintLauncherState(&w, a.RunDir(), a.Uid)
default:
func tryState() {
if !stateActionEarly {
return
}
if w != nil {
if err := w.Flush(); err != nil {
fmt.Println("warn: error formatting output:", err)
}
} else {
fmt.Println("No information available")
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)
}
func registerRevertPath(p string) {
cleanupCandidate = append(cleanupCandidate, p)
}
// called after process start, before wait
func registerProcess(uid string, cmd *exec.Cmd) error {
statePath = path.Join(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(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(runDir, ego.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
}
func beforeExit() {
if err := os.Remove(statePath); err != nil && !errors.Is(err, fs.ErrNotExist) {
fmt.Println("Error removing state file:", err)
}
if a, err := readLaunchers(); err != nil {
fmt.Println("Error reading active launchers:", err)
os.Exit(1)
} else if len(a) > 0 {
// other launchers are still active
if verbose {
fmt.Printf("Found %d active launchers, exiting without cleaning up\n", len(a))
}
return
}
if verbose {
fmt.Println("No other launchers active, will clean up")
}
if xcbActionComplete {
if verbose {
fmt.Printf("X11: Removing XHost entry SI:localuser:%s\n", ego.Username)
}
if err := changeHosts(xcbHostModeDelete, xcbFamilyServerInterpreted, "localuser\x00"+ego.Username); err != nil {
fmt.Println("Error removing XHost entry:", err)
}
}
for _, candidate := range cleanupCandidate {
if err := aclUpdatePerm(candidate, uid); err != nil {
fmt.Printf("Error stripping ACL entry from '%s': %s\n", candidate, err)
}
if verbose {
fmt.Printf("Stripped ACL entry for user '%s' from '%s'\n", ego.Username, candidate)
}
}
}
func fatal(msg ...any) {
fmt.Println(msg...)
beforeExit()
os.Exit(1)
}

100
util.go Normal file
View File

@ -0,0 +1,100 @@
package main
import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path"
)
const (
systemdCheckPath = "/run/systemd/system"
)
// https://www.freedesktop.org/software/systemd/man/sd_booted.html
func sdBooted() bool {
_, err := os.Stat(systemdCheckPath)
if err != nil {
if 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
}
// Try various ways 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) {
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) {
fatal("Error accessing PulseAudio cookie:", err)
// unreachable
return p
}
} else if !s.IsDir() {
return p
}
}
fatal(fmt.Sprintf("Cannot locate PulseAudio cookie (tried $%s, $%s/pulse/cookie, $%s/.pulse-cookie)",
pulseCookie, xdgConfigHome, home))
return ""
}
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
}

72
x11.go Normal file
View File

@ -0,0 +1,72 @@
package main
import "C"
import (
"errors"
"unsafe"
)
//#include <stdlib.h>
//#include <xcb/xcb.h>
//#cgo linux LDFLAGS: -lxcb
import "C"
const (
xcbHostModeInsert = C.XCB_HOST_MODE_INSERT
xcbHostModeDelete = C.XCB_HOST_MODE_DELETE
xcbFamilyInternet = C.XCB_FAMILY_INTERNET
xcbFamilyDecnet = C.XCB_FAMILY_DECNET
xcbFamilyChaos = C.XCB_FAMILY_CHAOS
xcbFamilyServerInterpreted = C.XCB_FAMILY_SERVER_INTERPRETED
xcbFamilyInternet6 = 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
}
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
}
}

View File

@ -1,33 +0,0 @@
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
}
}

View File

@ -1,47 +0,0 @@
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
}