fortify: integrate command handler
All checks were successful
Test / Create distribution (push) Successful in 25s
Test / Run NixOS test (push) Successful in 3m24s

Signed-off-by: Ophestra <cat@gensokyo.uk>
This commit is contained in:
Ophestra 2025-02-23 02:35:02 +09:00
parent 89970f5197
commit 7e52463445
Signed by: cat
SSH Key Fingerprint: SHA256:gQ67O0enBZ7UdZypgtspB2FDM1g3GVw8nX0XSdcFw8Q
3 changed files with 205 additions and 245 deletions

428
main.go
View File

@ -3,19 +3,18 @@ package main
import ( import (
"context" "context"
_ "embed" _ "embed"
"flag" "errors"
"fmt" "fmt"
"log" "log"
"os" "os"
"os/signal" "os/signal"
"os/user" "os/user"
"strconv" "strconv"
"strings"
"sync" "sync"
"syscall" "syscall"
"text/tabwriter"
"time" "time"
"git.gensokyo.uk/security/fortify/command"
"git.gensokyo.uk/security/fortify/dbus" "git.gensokyo.uk/security/fortify/dbus"
"git.gensokyo.uk/security/fortify/fst" "git.gensokyo.uk/security/fortify/fst"
"git.gensokyo.uk/security/fortify/helper/seccomp" "git.gensokyo.uk/security/fortify/helper/seccomp"
@ -30,36 +29,16 @@ import (
) )
var ( var (
flagVerbose bool errSuccess = errors.New("success")
flagJSON bool
//go:embed LICENSE //go:embed LICENSE
license string license string
) )
func init() { func init() { fmsg.Prepare("fortify") }
fmsg.Prepare("fortify")
flag.BoolVar(&flagVerbose, "v", false, "Verbose output")
flag.BoolVar(&flagJSON, "json", false, "Format output in JSON when applicable")
}
var std sys.State = new(sys.Std) var std sys.State = new(sys.Std)
type gl []string
func (g *gl) String() string {
if g == nil {
return "<nil>"
}
return strings.Join(*g, " ")
}
func (g *gl) Set(v string) error {
*g = append(*g, v)
return nil
}
func main() { func main() {
// early init argv0 check, skips root check and duplicate PR_SET_DUMPABLE // early init argv0 check, skips root check and duplicate PR_SET_DUMPABLE
init0.TryArgv0() init0.TryArgv0()
@ -73,112 +52,29 @@ func main() {
log.Fatal("this program must not run as root") log.Fatal("this program must not run as root")
} }
flag.CommandLine.Usage = func() { var (
fmt.Println() flagVerbose bool
fmt.Println("Usage:\tfortify [-v] [--json] COMMAND [OPTIONS]") flagJSON bool
fmt.Println() )
fmt.Println("Commands:") c := command.New(os.Stderr, log.Printf, "fortify", func([]string) error { fmsg.Store(flagVerbose); return nil }).
w := tabwriter.NewWriter(os.Stdout, 0, 1, 4, ' ', 0) Flag(&flagVerbose, "v", command.BoolFlag(false), "Print debug messages to the console").
commands := [][2]string{ Flag(&flagJSON, "json", command.BoolFlag(false), "Serialise output as JSON when applicable")
{"app", "Launch app defined by the specified config file"},
{"run", "Configure and start a permissive default sandbox"},
{"show", "Show the contents of an app configuration"},
{"ps", "List active apps and their state"},
{"version", "Show fortify version"},
{"license", "Show full license text"},
{"template", "Produce a config template"},
{"help", "Show this help message"},
}
for _, c := range commands {
_, _ = fmt.Fprintf(w, "\t%s\t%s\n", c[0], c[1])
}
if err := w.Flush(); err != nil {
fmt.Printf("fortify: cannot write command list: %v\n", err)
}
fmt.Println()
}
flag.Parse()
fmsg.Store(flagVerbose)
args := flag.Args() c.Command("app", "Launch app defined by the specified config file", func(args []string) error {
if len(args) == 0 { if len(args) < 1 {
flag.CommandLine.Usage()
internal.Exit(0)
}
switch args[0] {
case "version": // print version string
if v, ok := internal.Check(internal.Version); ok {
fmt.Println(v)
} else {
fmt.Println("impure")
}
internal.Exit(0)
case "license": // print embedded license
fmt.Println(license)
internal.Exit(0)
case "template": // print full template configuration
printJSON(os.Stdout, false, fst.Template())
internal.Exit(0)
case "help": // print help message
flag.CommandLine.Usage()
internal.Exit(0)
case "ps": // print all state info
set := flag.NewFlagSet("ps", flag.ExitOnError)
var short bool
set.BoolVar(&short, "short", false, "Print instance id")
// Ignore errors; set is set for ExitOnError.
_ = set.Parse(args[1:])
printPs(os.Stdout, time.Now().UTC(), state.NewMulti(std.Paths().RunDirPath), short)
internal.Exit(0)
case "show": // pretty-print app info
set := flag.NewFlagSet("show", flag.ExitOnError)
var short bool
set.BoolVar(&short, "short", false, "Omit filesystem information")
// Ignore errors; set is set for ExitOnError.
_ = set.Parse(args[1:])
switch len(set.Args()) {
case 0: // system
printShowSystem(os.Stdout, short)
case 1: // instance
name := set.Args()[0]
config, instance := tryShort(name)
if config == nil {
config = tryPath(name)
}
printShowInstance(os.Stdout, time.Now().UTC(), instance, config, short)
default:
log.Fatal("show requires 1 argument")
}
internal.Exit(0)
case "app": // launch app from configuration file
if len(args) < 2 {
log.Fatal("app requires at least 1 argument") log.Fatal("app requires at least 1 argument")
} }
// config extraArgs... // config extraArgs...
config := tryPath(args[1]) config := tryPath(args[0])
config.Command = append(config.Command, args[2:]...) config.Command = append(config.Command, args[1:]...)
// invoke app // invoke app
runApp(app.MustNew(std), config) runApp(app.MustNew(std), config)
panic("unreachable") panic("unreachable")
})
case "run": // run app in permissive defaults usage pattern {
set := flag.NewFlagSet("run", flag.ExitOnError)
var ( var (
dbusConfigSession string dbusConfigSession string
dbusConfigSystem string dbusConfigSystem string
@ -187,135 +83,211 @@ func main() {
fid string fid string
aid int aid int
groups gl groups command.RepeatableFlag
homeDir string homeDir string
userName string userName string
enablements [system.ELen]bool enablements [system.ELen]bool
) )
set.StringVar(&dbusConfigSession, "dbus-config", "builtin", "Path to D-Bus proxy config file, or \"builtin\" for defaults") c.NewCommand("run", "Configure and start a permissive default sandbox", func(args []string) error {
set.StringVar(&dbusConfigSystem, "dbus-system", "nil", "Path to system D-Bus proxy config file, or \"nil\" to disable") // initialise config from flags
set.BoolVar(&mpris, "mpris", false, "Allow owning MPRIS D-Bus path, has no effect if custom config is available") config := &fst.Config{
set.BoolVar(&dbusVerbose, "dbus-log", false, "Force logging in the D-Bus proxy") ID: fid,
Command: args,
}
set.StringVar(&fid, "id", "", "App ID, leave empty to disable security context app_id") if aid < 0 || aid > 9999 {
set.IntVar(&aid, "a", 0, "Fortify application ID") log.Fatalf("aid %d out of range", aid)
set.Var(&groups, "g", "Groups inherited by the app process") }
set.StringVar(&homeDir, "d", "os", "Application home directory")
set.StringVar(&userName, "u", "chronos", "Passwd name within sandbox")
set.BoolVar(&enablements[system.EWayland], "wayland", false, "Allow Wayland connections")
set.BoolVar(&enablements[system.EX11], "X", false, "Share X11 socket and allow connection")
set.BoolVar(&enablements[system.EDBus], "dbus", false, "Proxy D-Bus connection")
set.BoolVar(&enablements[system.EPulse], "pulse", false, "Share PulseAudio socket and cookie")
// Ignore errors; set is set for ExitOnError. // resolve home/username from os when flag is unset
_ = set.Parse(args[1:]) var (
passwd *user.User
// initialise config from flags passwdOnce sync.Once
config := &fst.Config{ passwdFunc = func() {
ID: fid, var us string
Command: set.Args(), if uid, err := std.Uid(aid); err != nil {
} fmsg.PrintBaseError(err, "cannot obtain uid from fsu:")
os.Exit(1)
if aid < 0 || aid > 9999 { } else {
log.Fatalf("aid %d out of range", aid) us = strconv.Itoa(uid)
} }
// resolve home/username from os when flag is unset if u, err := user.LookupId(us); err != nil {
var ( fmsg.Verbosef("cannot look up uid %s", us)
passwd *user.User passwd = &user.User{
passwdOnce sync.Once Uid: us,
passwdFunc = func() { Gid: us,
var us string Username: "chronos",
if uid, err := std.Uid(aid); err != nil { Name: "Fortify",
fmsg.PrintBaseError(err, "cannot obtain uid from fsu:") HomeDir: "/var/empty",
os.Exit(1) }
} else { } else {
us = strconv.Itoa(uid) passwd = u
}
if u, err := user.LookupId(us); err != nil {
fmsg.Verbosef("cannot look up uid %s", us)
passwd = &user.User{
Uid: us,
Gid: us,
Username: "chronos",
Name: "Fortify",
HomeDir: "/var/empty",
} }
} else {
passwd = u
} }
)
if homeDir == "os" {
passwdOnce.Do(passwdFunc)
homeDir = passwd.HomeDir
} }
)
if homeDir == "os" { if userName == "chronos" {
passwdOnce.Do(passwdFunc) passwdOnce.Do(passwdFunc)
homeDir = passwd.HomeDir userName = passwd.Username
}
if userName == "chronos" {
passwdOnce.Do(passwdFunc)
userName = passwd.Username
}
config.Confinement.AppID = aid
config.Confinement.Groups = groups
config.Confinement.Outer = homeDir
config.Confinement.Username = userName
// enablements from flags
for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ {
if enablements[i] {
config.Confinement.Enablements.Set(i)
} }
}
// parse D-Bus config file from flags if applicable config.Confinement.AppID = aid
if enablements[system.EDBus] { config.Confinement.Groups = groups
if dbusConfigSession == "builtin" { config.Confinement.Outer = homeDir
config.Confinement.SessionBus = dbus.NewConfig(fid, true, mpris) config.Confinement.Username = userName
} else {
if c, err := dbus.NewConfigFromFile(dbusConfigSession); err != nil { // enablements from flags
log.Fatalf("cannot load session bus proxy config from %q: %s", dbusConfigSession, err) for i := system.Enablement(0); i < system.Enablement(system.ELen); i++ {
} else { if enablements[i] {
config.Confinement.SessionBus = c config.Confinement.Enablements.Set(i)
} }
} }
// system bus proxy is optional // parse D-Bus config file from flags if applicable
if dbusConfigSystem != "nil" { if enablements[system.EDBus] {
if c, err := dbus.NewConfigFromFile(dbusConfigSystem); err != nil { if dbusConfigSession == "builtin" {
log.Fatalf("cannot load system bus proxy config from %q: %s", dbusConfigSystem, err) config.Confinement.SessionBus = dbus.NewConfig(fid, true, mpris)
} else { } else {
config.Confinement.SystemBus = c if conf, err := dbus.NewConfigFromFile(dbusConfigSession); err != nil {
log.Fatalf("cannot load session bus proxy config from %q: %s", dbusConfigSession, err)
} else {
config.Confinement.SessionBus = conf
}
}
// system bus proxy is optional
if dbusConfigSystem != "nil" {
if conf, err := dbus.NewConfigFromFile(dbusConfigSystem); err != nil {
log.Fatalf("cannot load system bus proxy config from %q: %s", dbusConfigSystem, err)
} else {
config.Confinement.SystemBus = conf
}
}
// override log from configuration
if dbusVerbose {
config.Confinement.SessionBus.Log = true
config.Confinement.SystemBus.Log = true
} }
} }
// override log from configuration // invoke app
if dbusVerbose { runApp(app.MustNew(std), config)
config.Confinement.SessionBus.Log = true panic("unreachable")
config.Confinement.SystemBus.Log = true }).
} Flag(&dbusConfigSession, "dbus-config", command.StringFlag("builtin"),
} "Path to D-Bus proxy config file, or \"builtin\" for defaults").
Flag(&dbusConfigSystem, "dbus-system", command.StringFlag("nil"),
// invoke app "Path to system D-Bus proxy config file, or \"nil\" to disable").
runApp(app.MustNew(std), config) Flag(&mpris, "mpris", command.BoolFlag(false),
panic("unreachable") "Allow owning MPRIS D-Bus path, has no effect if custom config is available").
Flag(&dbusVerbose, "dbus-log", command.BoolFlag(false),
// internal commands "Force logging in the D-Bus proxy").
case "shim": Flag(&fid, "id", command.StringFlag(""),
shim.Main() "App ID, leave empty to disable security context app_id").
internal.Exit(0) Flag(&aid, "a", command.IntFlag(0),
case "init": "Fortify application ID").
init0.Main() Flag(nil, "g", &groups,
internal.Exit(0) "Groups inherited by the app process").
Flag(&homeDir, "d", command.StringFlag("os"),
default: "Application home directory").
log.Fatalf("%q is not a valid command", args[0]) Flag(&userName, "u", command.StringFlag("chronos"),
"Passwd name within sandbox").
Flag(&enablements[system.EWayland], "wayland", command.BoolFlag(false),
"Allow Wayland connections").
Flag(&enablements[system.EX11], "X", command.BoolFlag(false),
"Share X11 socket and allow connection").
Flag(&enablements[system.EDBus], "dbus", command.BoolFlag(false),
"Proxy D-Bus connection").
Flag(&enablements[system.EPulse], "pulse", command.BoolFlag(false),
"Share PulseAudio socket and cookie")
} }
panic("unreachable") var showFlagShort bool
c.NewCommand("show", "Show the contents of an app configuration", func(args []string) error {
switch len(args) {
case 0: // system
printShowSystem(os.Stdout, showFlagShort, flagJSON)
case 1: // instance
name := args[0]
config, instance := tryShort(name)
if config == nil {
config = tryPath(name)
}
printShowInstance(os.Stdout, time.Now().UTC(), instance, config, showFlagShort, flagJSON)
default:
log.Fatal("show requires 1 argument")
}
return errSuccess
}).Flag(&showFlagShort, "short", command.BoolFlag(false), "Omit filesystem information")
var psFlagShort bool
c.NewCommand("ps", "List active apps and their state", func(args []string) error {
printPs(os.Stdout, time.Now().UTC(), state.NewMulti(std.Paths().RunDirPath), psFlagShort, flagJSON)
return errSuccess
}).Flag(&psFlagShort, "short", command.BoolFlag(false), "Print instance id")
c.Command("version", "Show fortify version", func(args []string) error {
if v, ok := internal.Check(internal.Version); ok {
fmt.Println(v)
} else {
fmt.Println("impure")
}
return errSuccess
})
c.Command("license", "Show full license text", func(args []string) error {
fmt.Println(license)
return errSuccess
})
c.Command("template", "Produce a config template", func(args []string) error {
printJSON(os.Stdout, false, fst.Template())
return errSuccess
})
c.Command("help", "Show this help message", func([]string) error {
c.PrintHelp()
return errSuccess
})
// internal commands
c.Command("shim", command.UsageInternal, func([]string) error { shim.Main(); return errSuccess })
c.Command("init", command.UsageInternal, func([]string) error { init0.Main(); return errSuccess })
err := c.Parse(os.Args[1:])
if errors.Is(err, errSuccess) || errors.Is(err, command.ErrHelp) {
internal.Exit(0)
panic("unreachable")
}
if errors.Is(err, command.ErrNoMatch) || errors.Is(err, command.ErrEmptyTree) {
internal.Exit(1)
panic("unreachable")
}
if err == nil {
log.Fatal("unreachable")
}
var flagError command.FlagError
if !errors.As(err, &flagError) {
log.Printf("command: %v", err)
internal.Exit(1)
panic("unreachable")
}
fmsg.Verbose(flagError.Error())
if flagError.Success() {
internal.Exit(0)
}
internal.Exit(1)
} }
func runApp(a fst.App, config *fst.Config) { func runApp(a fst.App, config *fst.Config) {

View File

@ -18,7 +18,7 @@ import (
"git.gensokyo.uk/security/fortify/internal/state" "git.gensokyo.uk/security/fortify/internal/state"
) )
func printShowSystem(output io.Writer, short bool) { func printShowSystem(output io.Writer, short, flagJSON bool) {
t := newPrinter(output) t := newPrinter(output)
defer t.MustFlush() defer t.MustFlush()
@ -43,7 +43,7 @@ func printShowSystem(output io.Writer, short bool) {
func printShowInstance( func printShowInstance(
output io.Writer, now time.Time, output io.Writer, now time.Time,
instance *state.State, config *fst.Config, instance *state.State, config *fst.Config,
short bool) { short, flagJSON bool) {
if flagJSON { if flagJSON {
if instance != nil { if instance != nil {
printJSON(output, short, instance) printJSON(output, short, instance)
@ -190,7 +190,7 @@ func printShowInstance(
} }
} }
func printPs(output io.Writer, now time.Time, s state.Store, short bool) { func printPs(output io.Writer, now time.Time, s state.Store, short, flagJSON bool) {
var entries state.Entries var entries state.Entries
if e, err := state.Join(s); err != nil { if e, err := state.Join(s); err != nil {
log.Fatalf("cannot join store: %v", err) log.Fatalf("cannot join store: %v", err)

View File

@ -448,14 +448,8 @@ App
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
{
v := flagJSON
t.Cleanup(func() { flagJSON = v })
flagJSON = tc.json
}
output := new(strings.Builder) output := new(strings.Builder)
printShowInstance(output, testTime, tc.instance, tc.config, tc.short) printShowInstance(output, testTime, tc.instance, tc.config, tc.short, tc.json)
if got := output.String(); got != tc.want { if got := output.String(); got != tc.want {
t.Errorf("printShowInstance: got\n%s\nwant\n%s", t.Errorf("printShowInstance: got\n%s\nwant\n%s",
got, tc.want) got, tc.want)
@ -645,14 +639,8 @@ func Test_printPs(t *testing.T) {
for _, tc := range testCases { for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
{
v := flagJSON
t.Cleanup(func() { flagJSON = v })
flagJSON = tc.json
}
output := new(strings.Builder) output := new(strings.Builder)
printPs(output, testTime, stubStore(tc.entries), tc.short) printPs(output, testTime, stubStore(tc.entries), tc.short, tc.json)
if got := output.String(); got != tc.want { if got := output.String(); got != tc.want {
t.Errorf("printPs: got\n%s\nwant\n%s", t.Errorf("printPs: got\n%s\nwant\n%s",
got, tc.want) got, tc.want)