Compare commits

...

44 Commits

Author SHA1 Message Date
Ophestra Umiker 2763ec730e
release: 0.0.0-beta.3
release / release (push) Successful in 57s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-17 23:17:39 +09:00
Ophestra Umiker 3d963b9f67
nix: include package buildInputs in devShells
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-17 23:15:33 +09:00
Ophestra Umiker 4b7d616862
exit: move final and early code to internal package
Exit cleanup state information is now stored in a dedicated struct and built up using methods of that struct.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-17 13:48:42 +09:00
Ophestra Umiker 6a6f62efa6
release: 0.0.0-beta.2
release / release (push) Successful in 3m2s Details
This project started as a Go implementation of https://github.com/intgr/ego. That is clearly no longer what it is anymore and the tagged releases no longer made sense, so we're going back to v0.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-16 20:41:02 +09:00
Ophestra Umiker 03c24c5122
move acl and xcb binding packages to top level
These packages are reasonably clean and do not interact with other packages.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-16 20:32:07 +09:00
Ophestra Umiker 8bdae74ebe
final: refactor for removal of system package and reduction of interactions to state package
State query command has been moved to main where it belongs, "system" information are now fetched in app.New and stored in *App with accessors for relevant values. Exit (cleanup-related) functions are separated into its dedicated "final" package.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-16 20:31:35 +09:00
Ophestra Umiker d49b97b1d4
nix: pass method string directly
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-13 11:58:45 +09:00
Ophestra Umiker 40d0550ad3
flag: move method flag from main
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-13 11:49:10 +09:00
Ophestra Umiker da6d238d8a
verbose: remove system package interaction
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-12 21:07:05 +09:00
Ophestra Umiker b0aff89166
app: handle launch method in New function
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-12 20:53:33 +09:00
Ophestra Umiker 8223a9ee66
enable filter in README example
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-11 16:35:23 +09:00
Ophestra Umiker 88ac05be6d
nix: fix typo in nixos module implementation previously missed due to lazy eval
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-09 23:29:16 +09:00
Ophestra Umiker 0ef321ad6f
update README document
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-09 23:22:07 +09:00
Ophestra Umiker 52f986559c
app/run: empty launcher environment
The launcher process should not receive the child's environment.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-09 22:24:58 +09:00
Ophestra Umiker 396066de7b
nix: implement dbus-system option in nixos module
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-09 21:26:14 +09:00
Ophestra Umiker 44301cd979
app/dbus: accept system bus config
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-09 21:19:12 +09:00
Ophestra Umiker 20c0e66d8f
dbus/config: seal with session and system bus proxy
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-09 21:13:00 +09:00
Ophestra Umiker e5918ba3b3
dbus/config: fix builtin defaults
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-09 19:20:37 +09:00
Ophestra Umiker 35d040590b
dbus/config: document fields and add --call and --broadcast
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-09 13:27:31 +09:00
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
Ophestra Umiker 58d3a1fbc7
release: 1.0.4
release / release (push) Has been cancelled Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-04 19:57:47 +09:00
Ophestra Umiker 1b5fce5ccb
update README document
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-04 19:54:35 +09:00
Ophestra Umiker 945cce2f5e
nix: implement nixos module
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-04 17:03:21 +09:00
Ophestra Umiker 5c3e7cf664
app/launch: set argv when launching shell
release / release (push) Has been cancelled Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-04 11:04:16 +09:00
Ophestra Umiker 743b6afbbb
workflows: rename binary to fortify
release / release (push) Has been cancelled Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-04 01:27:04 +09:00
Ophestra Umiker d8f76f3b25
rename to fortify and restructure
release / release (push) Has been cancelled Details
More sandbox features will be added and this will no longer track ego's features and behaviour.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-09-04 01:20:12 +09:00
Ophestra Umiker 7e6eb82195
license: embed license in executable
release / release (push) Successful in 54s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-07-16 22:07:40 +09:00
Ophestra Umiker 09507a541b
nix: build directly with buildGoModules
Since we have no dependencies, we don't need a vendor hash, so doing this actually makes sense.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-07-16 21:54:44 +09:00
Ophestra Umiker 1f72c30033
adapt README document
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-07-16 21:33:31 +09:00
Ophestra Umiker e1a96ded34
apply MIT license
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-07-16 20:49:00 +09:00
Ophestra Umiker 18db464bd5
apply X11 license
release / release (push) Successful in 35s Details
Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-07-16 14:20:17 +09:00
Ophestra Umiker a3c2916c1a
state: track launcher states in runDir and clean up before exit
X11 hosts and ACL rules are no longer necessary after all launcher processes exit. This reverts all changes to the system made during setup when no launchers remain. State information is also saved in runDir which can be tracked externally.

Signed-off-by: Ophestra Umiker <cat@ophivana.moe>
2024-07-16 14:19:43 +09:00
42 changed files with 2471 additions and 667 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/ego &&
sha256sum --tag -b bin/ego > bin/ego.sha256"
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"
- 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
/ego
/fortify
# Test binary, built with `go test -c`
*.test

7
LICENSE Normal file
View File

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

182
README.md Normal file
View File

@ -0,0 +1,182 @@
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 main
package acl
import (
"errors"
@ -13,88 +13,11 @@ 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)

86
acl/export.go Normal file
View File

@ -0,0 +1,86 @@
package acl
import "unsafe"
//#include <stdlib.h>
//#include <sys/acl.h>
//#include <acl/libacl.h>
//#cgo linux LDFLAGS: -lacl
import "C"
const (
Read = C.ACL_READ
Write = C.ACL_WRITE
Execute = C.ACL_EXECUTE
TypeDefault = C.ACL_TYPE_DEFAULT
TypeAccess = C.ACL_TYPE_ACCESS
UndefinedTag = C.ACL_UNDEFINED_TAG
UserObj = C.ACL_USER_OBJ
User = C.ACL_USER
GroupObj = C.ACL_GROUP_OBJ
Group = C.ACL_GROUP
Mask = C.ACL_MASK
Other = C.ACL_OTHER
)
func UpdatePerm(path string, uid int, perms ...C.acl_perm_t) error {
// read acl from file
a, err := aclGetFile(path, TypeAccess)
if err != nil {
return err
}
// free acl on return if get is successful
defer a.free()
// remove existing entry
if err = a.removeEntry(User, uid); err != nil {
return err
}
// create new entry if perms are passed
if len(perms) > 0 {
// create new acl entry
var e C.acl_entry_t
if _, err = C.acl_create_entry(&a.val, &e); err != nil {
return err
}
// get perm set of new entry
var p C.acl_permset_t
if _, err = C.acl_get_permset(e, &p); err != nil {
return err
}
// add target perms
for _, perm := range perms {
if _, err = C.acl_add_perm(p, perm); err != nil {
return err
}
}
// set perm set to new entry
if _, err = C.acl_set_permset(e, p); err != nil {
return err
}
// set user tag to new entry
if _, err = C.acl_set_tag_type(e, User); err != nil {
return err
}
// set qualifier (uid) to new entry
if _, err = C.acl_set_qualifier(e, unsafe.Pointer(&uid)); err != nil {
return err
}
}
// calculate mask after update
if _, err = C.acl_calc_mask(&a.val); err != nil {
return err
}
// write acl to file
return a.setFile(path, TypeAccess)
}

51
cli.go
View File

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

103
dbus/config.go Normal file
View File

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

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()
}

84
dbus/setup.go Normal file
View File

@ -0,0 +1,84 @@
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 Normal file
View File

@ -0,0 +1,52 @@
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": 1717179513,
"narHash": "sha256-vboIEwIQojofItm2xGCdZCzW96U85l9nDW3ifMuAIdM=",
"lastModified": 1725361206,
"narHash": "sha256-/HTUg+kMaqBPGrcQBYboAMsQHIWIkuKRDldss/035Hc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "63dacb46bf939521bdc93981b4cbb7ecb58427a0",
"rev": "2830c7c930311397d94c0b86a359c865c081c875",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "24.05",
"ref": "nixos-unstable-small",
"repo": "nixpkgs",
"type": "github"
}

View File

@ -1,36 +1,47 @@
{
description = "ego development environment";
description = "fortify sandbox tool and nixos module";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/24.05";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable-small";
};
outputs = { self, nixpkgs }:
outputs =
{ self, nixpkgs }:
let
supportedSystems = [ "x86_64-linux" ];
forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system);
supportedSystems = [
"aarch64-linux"
"i686-linux"
"x86_64-linux"
];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; });
in
{
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'
'')
];
};
}
);
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
'';
};
});
};
}

2
go.mod
View File

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

13
internal/app/builder.go Normal file
View File

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

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

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

63
internal/app/ensure.go Normal file
View File

@ -0,0 +1,63 @@
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,4 +1,4 @@
package main
package app
import (
"bytes"
@ -8,18 +8,29 @@ import (
"os"
"strings"
"syscall"
"git.ophivana.moe/cat/fortify/internal"
"git.ophivana.moe/cat/fortify/internal/util"
)
const (
// hidden path for main to act as a launcher
egoLauncher = "EGO_LAUNCHER"
)
const launcherPayload = "FORTIFY_LAUNCHER_PAYLOAD"
// hidden launcher path
func tryLauncher() {
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) {
if printVersion {
if r, ok := os.LookupEnv(egoLauncher); ok {
// egoLauncher variable contains launcher payload
if r, ok := os.LookupEnv(launcherPayload); ok {
dec := base64.NewDecoder(base64.StdEncoding, strings.NewReader(r))
var argv []string
@ -28,7 +39,7 @@ func tryLauncher() {
os.Exit(1)
}
if err := os.Unsetenv(egoLauncher); err != nil {
if err := os.Unsetenv(launcherPayload); err != nil {
fmt.Println("Error unsetting launcher payload:", err)
// not fatal, do not fail
}
@ -36,7 +47,7 @@ func tryLauncher() {
var p string
if len(argv) > 0 {
if p, ok = which(argv[0]); !ok {
if p, ok = util.Which(argv[0]); !ok {
fmt.Printf("Did not find '%s' in PATH\n", argv[0])
os.Exit(1)
}
@ -45,6 +56,8 @@ func tryLauncher() {
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 {
@ -58,15 +71,3 @@ func tryLauncher() {
}
}
}
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()
}

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

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

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

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

150
internal/app/setup.go Normal file
View File

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

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

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

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

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

16
internal/early.go Normal file
View File

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

34
internal/enablement.go Normal file
View File

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

176
internal/exit.go Normal file
View File

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

59
internal/state/data.go Normal file
View File

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

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

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

41
internal/state/track.go Normal file
View File

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

39
internal/util/simple.go Normal file
View File

@ -0,0 +1,39 @@
package util
import (
"io"
"os"
"os/exec"
)
func Which(file string) (string, bool) {
p, err := exec.LookPath(file)
return p, err == nil
}
func CopyFile(dst, src string) error {
srcD, err := os.Open(src)
if err != nil {
return err
}
defer func() {
if srcD.Close() != nil {
// unreachable
panic("src file closed prematurely")
}
}()
dstD, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer func() {
if dstD.Close() != nil {
// unreachable
panic("dst file closed prematurely")
}
}()
_, err = io.Copy(dstD, srcD)
return err
}

24
internal/util/std.go Normal file
View File

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

15
internal/verbose/print.go Normal file
View File

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

13
internal/verbose/state.go Normal file
View File

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

26
license.go Normal file
View File

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

398
main.go
View File

@ -1,351 +1,133 @@
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io/fs"
"os"
"os/exec"
"os/user"
"path"
"strconv"
"strings"
"syscall"
)
var Version = "impure"
"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 (
ego *user.User
uid int
env []string
command []string
verbose bool
runtime string
runDir string
Version = "impure"
a *app.App
s *internal.ExitState
dbusSession *dbus.Config
dbusSystem *dbus.Config
launchOptionText string
)
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 tryVersion() {
if printVersion {
fmt.Println(Version)
os.Exit(0)
}
}
func main() {
flag.Parse()
tryLauncher()
copyArgs()
verbose.Set(flagVerbose)
if u, err := strconv.Atoi(ego.Uid); err != nil {
// usually unreachable
panic("ego uid parse")
} else {
uid = u
// 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 r, ok := os.LookupEnv(xdgRuntimeDir); !ok {
fatal("Env variable", xdgRuntimeDir, "unset")
} else {
runtime = r
}
// ensure RunDir (e.g. `/run/user/%d/fortify`)
a.EnsureRunDir()
// Report warning if user home directory does not exist or has wrong ownership
if stat, err := os.Stat(ego.HomeDir); err != nil {
if verbose {
// state query command early exit
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() {
switch {
case errors.Is(err, fs.ErrPermission):
fmt.Printf("User %s home directory %s is not accessible", ego.Username, ego.HomeDir)
fmt.Printf("User %s home directory %s is not accessible\n", a.Username, a.HomeDir)
case errors.Is(err, fs.ErrNotExist):
fmt.Printf("User %s home directory %s does not exist", ego.Username, ego.HomeDir)
fmt.Printf("User %s home directory %s does not exis\n", a.Username, a.HomeDir)
default:
fmt.Printf("Error stat user %s home directory %s: %s", ego.Username, ego.HomeDir, err)
fmt.Printf("Error stat user %s home directory %s: %s\n", a.Username, a.HomeDir, err)
}
}
return
} else {
// FreeBSD: not cross-platform
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)
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)
}
}
// 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 {
// Cleanup: need revert
if err = aclUpdatePerm(runtime, uid, aclExecute); err != nil {
fatal("Error preparing runtime dir:", err)
}
if verbose {
fmt.Printf("Runtime data dir '%s' configured\n", runtime)
}
// ensure runtime directory ACL (e.g. `/run/user/%d`)
a.EnsureRuntime()
if mustWayland {
a.ShareWayland()
}
// Create runtime dir for Ego itself (e.g. `/run/user/%d/ego`) and make it readable for target
runDir = path.Join(runtime, "ego")
if err := os.Mkdir(runDir, 0700); err != nil && !errors.Is(err, fs.ErrExist) {
fatal("Error creating Ego runtime dir:", err)
}
// Cleanup: need revert
if err := aclUpdatePerm(runDir, uid, aclExecute); err != nil {
fatal("Error preparing Ego runtime dir:", err)
}
// Cleanup: need register control PID
// 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))
// Cleanup: need revert
if err := aclUpdatePerm(path.Join(runtime, w), uid, aclRead, aclWrite, aclExecute); err != nil {
fatal(fmt.Sprintf("Error preparing Wayland '%s':", w), err)
}
if verbose {
fmt.Printf("Wayland socket '%s' configured\n", w)
}
if mustX {
a.ShareX()
}
// 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)
// Cleanup: need revert
if err := changeHosts(xcbHostModeInsert, xcbFamilyServerInterpreted, "localuser\x00"+ego.Username); err != nil {
fatal(fmt.Sprintf("Error adding XHost entry to '%s':", d), err)
}
if verbose {
fmt.Printf("X11: Adding XHost entry SI:localuser:%s to display '%s'\n", ego.Username, d)
}
if mustDBus {
a.ShareDBus(dbusSession, dbusSystem, dbusVerbose)
}
// 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)
// Cleanup: need revert
if err = aclUpdatePerm(pulse, uid, aclExecute); err != nil {
fatal("Error preparing PulseAudio:", err)
}
// 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)
}
// Cleanup: need revert
if err = aclUpdatePerm(pulseCookieFinal, uid, aclRead); err != nil {
fatal("Error publishing PulseAudio cookie:", err)
}
if verbose {
fmt.Printf("PulseAudio dir '%s' configured\n", pulse)
}
if mustPulse {
a.SharePulse()
}
// 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)
}
// Cleanup: need register
var r int
if err := cmd.Wait(); err != nil {
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
fatal("Error running process:", err)
}
}
// Cleanup: deregister, call revert
if verbose {
fmt.Println("Process exited with exit code", r)
}
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
}
func fatal(msg ...any) {
// Cleanup: call revert
fmt.Println(msg...)
os.Exit(1)
a.Run()
}

305
nixos.nix Normal file
View File

@ -0,0 +1,305 @@
{
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;
}
});
'';
};
}

34
package.nix Normal file
View File

@ -0,0 +1,34 @@
{
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 ]}
'';
}

42
state.go Normal file
View File

@ -0,0 +1,42 @@
package main
import (
"flag"
"fmt"
"git.ophivana.moe/cat/fortify/internal/state"
"os"
"text/tabwriter"
)
var (
stateActionEarly [2]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")
}
// tryState is called after app initialisation
func tryState() {
var w *tabwriter.Writer
switch {
case stateActionEarly[0]:
state.MustPrintLauncherStateGlobal(&w, a.RunDir())
case stateActionEarly[1]:
state.MustPrintLauncherState(&w, a.RunDir(), a.Uid)
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)
}

100
util.go
View File

@ -1,100 +0,0 @@
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
View File

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

33
xcb/c.go Normal file
View File

@ -0,0 +1,33 @@
package xcb
import (
"errors"
)
//#include <stdlib.h>
//#include <xcb/xcb.h>
//#cgo linux LDFLAGS: -lxcb
import "C"
func xcbHandleConnectionError(c *C.xcb_connection_t) error {
if errno := C.xcb_connection_has_error(c); errno != 0 {
switch errno {
case C.XCB_CONN_ERROR:
return errors.New("connection error")
case C.XCB_CONN_CLOSED_EXT_NOTSUPPORTED:
return errors.New("extension not supported")
case C.XCB_CONN_CLOSED_MEM_INSUFFICIENT:
return errors.New("memory not available")
case C.XCB_CONN_CLOSED_REQ_LEN_EXCEED:
return errors.New("request length exceeded")
case C.XCB_CONN_CLOSED_PARSE_ERR:
return errors.New("invalid display string")
case C.XCB_CONN_CLOSED_INVALID_SCREEN:
return errors.New("server has no screen matching display")
default:
return errors.New("generic X11 failure")
}
} else {
return nil
}
}

47
xcb/export.go Normal file
View File

@ -0,0 +1,47 @@
package xcb
//#include <stdlib.h>
//#include <xcb/xcb.h>
//#cgo linux LDFLAGS: -lxcb
import "C"
import (
"errors"
"unsafe"
)
const (
HostModeInsert = C.XCB_HOST_MODE_INSERT
HostModeDelete = C.XCB_HOST_MODE_DELETE
FamilyInternet = C.XCB_FAMILY_INTERNET
FamilyDecnet = C.XCB_FAMILY_DECNET
FamilyChaos = C.XCB_FAMILY_CHAOS
FamilyServerInterpreted = C.XCB_FAMILY_SERVER_INTERPRETED
FamilyInternet6 = C.XCB_FAMILY_INTERNET_6
)
func ChangeHosts(mode, family C.uint8_t, address string) error {
var c *C.xcb_connection_t
c = C.xcb_connect(nil, nil)
defer C.xcb_disconnect(c)
if err := xcbHandleConnectionError(c); err != nil {
return err
}
addr := C.CString(address)
cookie := C.xcb_change_hosts_checked(c, mode, family, C.ushort(len(address)), (*C.uchar)(unsafe.Pointer(addr)))
C.free(unsafe.Pointer(addr))
if err := xcbHandleConnectionError(c); err != nil {
return err
}
e := C.xcb_request_check(c, cookie)
if e != nil {
defer C.free(unsafe.Pointer(e))
return errors.New("xcb_change_hosts() failed")
}
return nil
}