From 255b77d91dc40736b7bbd7d96b22500076b098e5 Mon Sep 17 00:00:00 2001 From: Ophestra Date: Wed, 2 Jul 2025 20:59:17 +0900 Subject: cmd/hakurei: move command handlers The hakurei command is a bit ugly since it's also used for validating the command package. This alleviates some of the ugliness. Signed-off-by: Ophestra --- cmd/hakurei/command.go | 258 ++++++++++++++++++++++++++++++++++++++++++++ cmd/hakurei/command_test.go | 81 ++++++++++++++ cmd/hakurei/main.go | 250 +----------------------------------------- cmd/hakurei/main_test.go | 81 -------------- 4 files changed, 340 insertions(+), 330 deletions(-) create mode 100644 cmd/hakurei/command.go create mode 100644 cmd/hakurei/command_test.go delete mode 100644 cmd/hakurei/main_test.go diff --git a/cmd/hakurei/command.go b/cmd/hakurei/command.go new file mode 100644 index 00000000..5e9d5732 --- /dev/null +++ b/cmd/hakurei/command.go @@ -0,0 +1,258 @@ +package main + +import ( + "context" + "fmt" + "io" + "log" + "os" + "os/signal" + "os/user" + "strconv" + "sync" + "syscall" + "time" + + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app" + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app/instance" + "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/state" + "git.gensokyo.uk/security/hakurei/command" + "git.gensokyo.uk/security/hakurei/dbus" + "git.gensokyo.uk/security/hakurei/hst" + "git.gensokyo.uk/security/hakurei/internal" + "git.gensokyo.uk/security/hakurei/internal/hlog" + "git.gensokyo.uk/security/hakurei/system" +) + +func buildCommand(out io.Writer) command.Command { + var ( + flagVerbose bool + flagJSON bool + ) + c := command.New(out, log.Printf, "hakurei", func([]string) error { internal.InstallOutput(flagVerbose); return nil }). + Flag(&flagVerbose, "v", command.BoolFlag(false), "Increase log verbosity"). + Flag(&flagJSON, "json", command.BoolFlag(false), "Serialise output in JSON when applicable") + + c.Command("shim", command.UsageInternal, func([]string) error { instance.ShimMain(); return errSuccess }) + + c.Command("app", "Load app from configuration file", func(args []string) error { + if len(args) < 1 { + log.Fatal("app requires at least 1 argument") + } + + // config extraArgs... + config := tryPath(args[0]) + config.Args = append(config.Args, args[1:]...) + + runApp(config) + panic("unreachable") + }) + + { + var ( + dbusConfigSession string + dbusConfigSystem string + mpris bool + dbusVerbose bool + + fid string + aid int + groups command.RepeatableFlag + homeDir string + userName string + + wayland, x11, dBus, pulse bool + ) + + c.NewCommand("run", "Configure and start a permissive default sandbox", func(args []string) error { + // initialise config from flags + config := &hst.Config{ + ID: fid, + Args: args, + } + + if aid < 0 || aid > 9999 { + log.Fatalf("aid %d out of range", aid) + } + + // resolve home/username from os when flag is unset + var ( + passwd *user.User + passwdOnce sync.Once + passwdFunc = func() { + var us string + if uid, err := std.Uid(aid); err != nil { + hlog.PrintBaseError(err, "cannot obtain uid from setuid wrapper:") + os.Exit(1) + } else { + us = strconv.Itoa(uid) + } + + if u, err := user.LookupId(us); err != nil { + hlog.Verbosef("cannot look up uid %s", us) + passwd = &user.User{ + Uid: us, + Gid: us, + Username: "chronos", + Name: "Hakurei Permissive Default", + HomeDir: "/var/empty", + } + } else { + passwd = u + } + } + ) + + if homeDir == "os" { + passwdOnce.Do(passwdFunc) + homeDir = passwd.HomeDir + } + + if userName == "chronos" { + passwdOnce.Do(passwdFunc) + userName = passwd.Username + } + + config.Identity = aid + config.Groups = groups + config.Data = homeDir + config.Username = userName + + if wayland { + config.Enablements |= system.EWayland + } + if x11 { + config.Enablements |= system.EX11 + } + if dBus { + config.Enablements |= system.EDBus + } + if pulse { + config.Enablements |= system.EPulse + } + + // parse D-Bus config file from flags if applicable + if dBus { + if dbusConfigSession == "builtin" { + config.SessionBus = dbus.NewConfig(fid, true, mpris) + } else { + if conf, err := dbus.NewConfigFromFile(dbusConfigSession); err != nil { + log.Fatalf("cannot load session bus proxy config from %q: %s", dbusConfigSession, err) + } else { + config.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.SystemBus = conf + } + } + + // override log from configuration + if dbusVerbose { + config.SessionBus.Log = true + config.SystemBus.Log = true + } + } + + // invoke app + runApp(config) + panic("unreachable") + }). + Flag(&dbusConfigSession, "dbus-config", command.StringFlag("builtin"), + "Path to session bus proxy config file, or \"builtin\" for defaults"). + Flag(&dbusConfigSystem, "dbus-system", command.StringFlag("nil"), + "Path to system bus proxy config file, or \"nil\" to disable"). + Flag(&mpris, "mpris", command.BoolFlag(false), + "Allow owning MPRIS D-Bus path, has no effect if custom config is available"). + Flag(&dbusVerbose, "dbus-log", command.BoolFlag(false), + "Force buffered logging in the D-Bus proxy"). + Flag(&fid, "id", command.StringFlag(""), + "Reverse-DNS style Application identifier, leave empty to inherit instance identifier"). + Flag(&aid, "a", command.IntFlag(0), + "Application identity"). + Flag(nil, "g", &groups, + "Groups inherited by all container processes"). + Flag(&homeDir, "d", command.StringFlag("os"), + "Container home directory"). + Flag(&userName, "u", command.StringFlag("chronos"), + "Passwd user name within sandbox"). + Flag(&wayland, "wayland", command.BoolFlag(false), + "Enable connection to Wayland via security-context-v1"). + Flag(&x11, "X", command.BoolFlag(false), + "Enable direct connection to X11"). + Flag(&dBus, "dbus", command.BoolFlag(false), + "Enable proxied connection to D-Bus"). + Flag(&pulse, "pulse", command.BoolFlag(false), + "Enable direct connection to PulseAudio") + } + + var showFlagShort bool + c.NewCommand("show", "Show live or local app configuration", func(args []string) error { + switch len(args) { + case 0: // system + printShowSystem(os.Stdout, showFlagShort, flagJSON) + + case 1: // instance + name := args[0] + config, entry := tryShort(name) + if config == nil { + config = tryPath(name) + } + printShowInstance(os.Stdout, time.Now().UTC(), entry, 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 instances", 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", "Display version information", func(args []string) error { + fmt.Println(internal.Version()) + 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, hst.Template()) + return errSuccess + }) + + c.Command("help", "Show this help message", func([]string) error { + c.PrintHelp() + return errSuccess + }) + + return c +} + +func runApp(config *hst.Config) { + ctx, stop := signal.NotifyContext(context.Background(), + syscall.SIGINT, syscall.SIGTERM) + defer stop() // unreachable + a := instance.MustNew(instance.ISetuid, ctx, std) + + rs := new(app.RunState) + if sa, err := a.Seal(config); err != nil { + hlog.PrintBaseError(err, "cannot seal app:") + internal.Exit(1) + } else { + internal.Exit(instance.PrintRunStateErr(instance.ISetuid, rs, sa.Run(rs))) + } + + *(*int)(nil) = 0 // not reached +} diff --git a/cmd/hakurei/command_test.go b/cmd/hakurei/command_test.go new file mode 100644 index 00000000..7e2b792e --- /dev/null +++ b/cmd/hakurei/command_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "bytes" + "errors" + "flag" + "testing" + + "git.gensokyo.uk/security/hakurei/command" +) + +func TestHelp(t *testing.T) { + testCases := []struct { + name string + args []string + want string + }{ + { + "main", []string{}, ` +Usage: hakurei [-h | --help] [-v] [--json] COMMAND [OPTIONS] + +Commands: + app Load app from configuration file + run Configure and start a permissive default sandbox + show Show live or local app configuration + ps List active instances + version Display version information + license Show full license text + template Produce a config template + help Show this help message + +`, + }, + { + "run", []string{"run", "-h"}, ` +Usage: hakurei run [-h | --help] [--dbus-config ] [--dbus-system ] [--mpris] [--dbus-log] [--id ] [-a ] [-g ] [-d ] [-u ] [--wayland] [-X] [--dbus] [--pulse] COMMAND [OPTIONS] + +Flags: + -X Enable direct connection to X11 + -a int + Application identity + -d string + Container home directory (default "os") + -dbus + Enable proxied connection to D-Bus + -dbus-config string + Path to session bus proxy config file, or "builtin" for defaults (default "builtin") + -dbus-log + Force buffered logging in the D-Bus proxy + -dbus-system string + Path to system bus proxy config file, or "nil" to disable (default "nil") + -g value + Groups inherited by all container processes + -id string + Reverse-DNS style Application identifier, leave empty to inherit instance identifier + -mpris + Allow owning MPRIS D-Bus path, has no effect if custom config is available + -pulse + Enable direct connection to PulseAudio + -u string + Passwd user name within sandbox (default "chronos") + -wayland + Enable connection to Wayland via security-context-v1 + +`, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + out := new(bytes.Buffer) + c := buildCommand(out) + if err := c.Parse(tc.args); !errors.Is(err, command.ErrHelp) && !errors.Is(err, flag.ErrHelp) { + t.Errorf("Parse: error = %v; want %v", + err, command.ErrHelp) + } + if got := out.String(); got != tc.want { + t.Errorf("Parse: %s want %s", got, tc.want) + } + }) + } +} diff --git a/cmd/hakurei/main.go b/cmd/hakurei/main.go index 143ce9c2..4cfe7ebd 100644 --- a/cmd/hakurei/main.go +++ b/cmd/hakurei/main.go @@ -4,31 +4,15 @@ package main //go:generate cp ../../LICENSE . import ( - "context" _ "embed" "errors" - "fmt" - "io" "log" "os" - "os/signal" - "os/user" - "strconv" - "sync" - "syscall" - "time" - "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app" - "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/app/instance" - "git.gensokyo.uk/security/hakurei/cmd/hakurei/internal/state" - "git.gensokyo.uk/security/hakurei/command" - "git.gensokyo.uk/security/hakurei/dbus" - "git.gensokyo.uk/security/hakurei/hst" "git.gensokyo.uk/security/hakurei/internal" "git.gensokyo.uk/security/hakurei/internal/hlog" "git.gensokyo.uk/security/hakurei/internal/sys" "git.gensokyo.uk/security/hakurei/sandbox" - "git.gensokyo.uk/security/hakurei/system" ) var ( @@ -61,239 +45,7 @@ func main() { hlog.BeforeExit() os.Exit(0) } + // this catches faulty command handlers that fail to return before this point }) log.Fatal("unreachable") } - -func buildCommand(out io.Writer) command.Command { - var ( - flagVerbose bool - flagJSON bool - ) - c := command.New(out, log.Printf, "hakurei", func([]string) error { internal.InstallOutput(flagVerbose); return nil }). - Flag(&flagVerbose, "v", command.BoolFlag(false), "Increase log verbosity"). - Flag(&flagJSON, "json", command.BoolFlag(false), "Serialise output in JSON when applicable") - - c.Command("shim", command.UsageInternal, func([]string) error { instance.ShimMain(); return errSuccess }) - - c.Command("app", "Load app from configuration file", func(args []string) error { - if len(args) < 1 { - log.Fatal("app requires at least 1 argument") - } - - // config extraArgs... - config := tryPath(args[0]) - config.Args = append(config.Args, args[1:]...) - - runApp(config) - panic("unreachable") - }) - - { - var ( - dbusConfigSession string - dbusConfigSystem string - mpris bool - dbusVerbose bool - - fid string - aid int - groups command.RepeatableFlag - homeDir string - userName string - - wayland, x11, dBus, pulse bool - ) - - c.NewCommand("run", "Configure and start a permissive default sandbox", func(args []string) error { - // initialise config from flags - config := &hst.Config{ - ID: fid, - Args: args, - } - - if aid < 0 || aid > 9999 { - log.Fatalf("aid %d out of range", aid) - } - - // resolve home/username from os when flag is unset - var ( - passwd *user.User - passwdOnce sync.Once - passwdFunc = func() { - var us string - if uid, err := std.Uid(aid); err != nil { - hlog.PrintBaseError(err, "cannot obtain uid from setuid wrapper:") - os.Exit(1) - } else { - us = strconv.Itoa(uid) - } - - if u, err := user.LookupId(us); err != nil { - hlog.Verbosef("cannot look up uid %s", us) - passwd = &user.User{ - Uid: us, - Gid: us, - Username: "chronos", - Name: "Hakurei Permissive Default", - HomeDir: "/var/empty", - } - } else { - passwd = u - } - } - ) - - if homeDir == "os" { - passwdOnce.Do(passwdFunc) - homeDir = passwd.HomeDir - } - - if userName == "chronos" { - passwdOnce.Do(passwdFunc) - userName = passwd.Username - } - - config.Identity = aid - config.Groups = groups - config.Data = homeDir - config.Username = userName - - if wayland { - config.Enablements |= system.EWayland - } - if x11 { - config.Enablements |= system.EX11 - } - if dBus { - config.Enablements |= system.EDBus - } - if pulse { - config.Enablements |= system.EPulse - } - - // parse D-Bus config file from flags if applicable - if dBus { - if dbusConfigSession == "builtin" { - config.SessionBus = dbus.NewConfig(fid, true, mpris) - } else { - if conf, err := dbus.NewConfigFromFile(dbusConfigSession); err != nil { - log.Fatalf("cannot load session bus proxy config from %q: %s", dbusConfigSession, err) - } else { - config.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.SystemBus = conf - } - } - - // override log from configuration - if dbusVerbose { - config.SessionBus.Log = true - config.SystemBus.Log = true - } - } - - // invoke app - runApp(config) - panic("unreachable") - }). - Flag(&dbusConfigSession, "dbus-config", command.StringFlag("builtin"), - "Path to session bus proxy config file, or \"builtin\" for defaults"). - Flag(&dbusConfigSystem, "dbus-system", command.StringFlag("nil"), - "Path to system bus proxy config file, or \"nil\" to disable"). - Flag(&mpris, "mpris", command.BoolFlag(false), - "Allow owning MPRIS D-Bus path, has no effect if custom config is available"). - Flag(&dbusVerbose, "dbus-log", command.BoolFlag(false), - "Force buffered logging in the D-Bus proxy"). - Flag(&fid, "id", command.StringFlag(""), - "Reverse-DNS style Application identifier, leave empty to inherit instance identifier"). - Flag(&aid, "a", command.IntFlag(0), - "Application identity"). - Flag(nil, "g", &groups, - "Groups inherited by all container processes"). - Flag(&homeDir, "d", command.StringFlag("os"), - "Container home directory"). - Flag(&userName, "u", command.StringFlag("chronos"), - "Passwd user name within sandbox"). - Flag(&wayland, "wayland", command.BoolFlag(false), - "Enable connection to Wayland via security-context-v1"). - Flag(&x11, "X", command.BoolFlag(false), - "Enable direct connection to X11"). - Flag(&dBus, "dbus", command.BoolFlag(false), - "Enable proxied connection to D-Bus"). - Flag(&pulse, "pulse", command.BoolFlag(false), - "Enable direct connection to PulseAudio") - } - - var showFlagShort bool - c.NewCommand("show", "Show live or local app configuration", func(args []string) error { - switch len(args) { - case 0: // system - printShowSystem(os.Stdout, showFlagShort, flagJSON) - - case 1: // instance - name := args[0] - config, entry := tryShort(name) - if config == nil { - config = tryPath(name) - } - printShowInstance(os.Stdout, time.Now().UTC(), entry, 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 instances", 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", "Display version information", func(args []string) error { - fmt.Println(internal.Version()) - 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, hst.Template()) - return errSuccess - }) - - c.Command("help", "Show this help message", func([]string) error { - c.PrintHelp() - return errSuccess - }) - - return c -} - -func runApp(config *hst.Config) { - ctx, stop := signal.NotifyContext(context.Background(), - syscall.SIGINT, syscall.SIGTERM) - defer stop() // unreachable - a := instance.MustNew(instance.ISetuid, ctx, std) - - rs := new(app.RunState) - if sa, err := a.Seal(config); err != nil { - hlog.PrintBaseError(err, "cannot seal app:") - internal.Exit(1) - } else { - internal.Exit(instance.PrintRunStateErr(instance.ISetuid, rs, sa.Run(rs))) - } - - *(*int)(nil) = 0 // not reached -} diff --git a/cmd/hakurei/main_test.go b/cmd/hakurei/main_test.go deleted file mode 100644 index 7e2b792e..00000000 --- a/cmd/hakurei/main_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package main - -import ( - "bytes" - "errors" - "flag" - "testing" - - "git.gensokyo.uk/security/hakurei/command" -) - -func TestHelp(t *testing.T) { - testCases := []struct { - name string - args []string - want string - }{ - { - "main", []string{}, ` -Usage: hakurei [-h | --help] [-v] [--json] COMMAND [OPTIONS] - -Commands: - app Load app from configuration file - run Configure and start a permissive default sandbox - show Show live or local app configuration - ps List active instances - version Display version information - license Show full license text - template Produce a config template - help Show this help message - -`, - }, - { - "run", []string{"run", "-h"}, ` -Usage: hakurei run [-h | --help] [--dbus-config ] [--dbus-system ] [--mpris] [--dbus-log] [--id ] [-a ] [-g ] [-d ] [-u ] [--wayland] [-X] [--dbus] [--pulse] COMMAND [OPTIONS] - -Flags: - -X Enable direct connection to X11 - -a int - Application identity - -d string - Container home directory (default "os") - -dbus - Enable proxied connection to D-Bus - -dbus-config string - Path to session bus proxy config file, or "builtin" for defaults (default "builtin") - -dbus-log - Force buffered logging in the D-Bus proxy - -dbus-system string - Path to system bus proxy config file, or "nil" to disable (default "nil") - -g value - Groups inherited by all container processes - -id string - Reverse-DNS style Application identifier, leave empty to inherit instance identifier - -mpris - Allow owning MPRIS D-Bus path, has no effect if custom config is available - -pulse - Enable direct connection to PulseAudio - -u string - Passwd user name within sandbox (default "chronos") - -wayland - Enable connection to Wayland via security-context-v1 - -`, - }, - } - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - out := new(bytes.Buffer) - c := buildCommand(out) - if err := c.Parse(tc.args); !errors.Is(err, command.ErrHelp) && !errors.Is(err, flag.ErrHelp) { - t.Errorf("Parse: error = %v; want %v", - err, command.ErrHelp) - } - if got := out.String(); got != tc.want { - t.Errorf("Parse: %s want %s", got, tc.want) - } - }) - } -} -- cgit v1.3.1