From 713796992c87a5bd1ab854d02340ebf7cf5c3f7a Mon Sep 17 00:00:00 2001 From: Ophestra Date: Thu, 26 Dec 2024 13:21:49 +0900 Subject: [PATCH] cmd/fpkg: app bundle helper This helper program creates fortify configuration for running an application bundle. The activate action wraps a home-manager activation package and ensures each generation gets activated once. Signed-off-by: Ophestra --- cmd/fpkg/activate.go | 57 +++++++++++++++ cmd/fpkg/bundle.go | 68 +++++++++++++++++ cmd/fpkg/install.go | 169 +++++++++++++++++++++++++++++++++++++++++++ cmd/fpkg/main.go | 50 +++++++++++++ cmd/fpkg/paths.go | 71 ++++++++++++++++++ cmd/fpkg/proc.go | 64 ++++++++++++++++ cmd/fpkg/start.go | 114 +++++++++++++++++++++++++++++ dist/install.sh | 1 + 8 files changed, 594 insertions(+) create mode 100644 cmd/fpkg/activate.go create mode 100644 cmd/fpkg/bundle.go create mode 100644 cmd/fpkg/install.go create mode 100644 cmd/fpkg/main.go create mode 100644 cmd/fpkg/paths.go create mode 100644 cmd/fpkg/proc.go create mode 100644 cmd/fpkg/start.go diff --git a/cmd/fpkg/activate.go b/cmd/fpkg/activate.go new file mode 100644 index 0000000..1249903 --- /dev/null +++ b/cmd/fpkg/activate.go @@ -0,0 +1,57 @@ +package main + +import ( + "errors" + "os" + "os/exec" + "path" + + "git.gensokyo.uk/security/fortify/fst" + "git.gensokyo.uk/security/fortify/internal/fmsg" +) + +func actionActivate(args []string) { + // simple check so activate is not attempted outside fortify + if s, err := os.Stat(path.Join(fst.Tmp, "sbin")); err != nil { + fmsg.Fatalf("cannot stat fortify sbin: %v", err) + } else if !s.IsDir() { + fmsg.Fatal("fortify sbin path is not a directory") + } + + home := os.Getenv("HOME") + if !path.IsAbs(home) { + fmsg.Fatalf("path %q is not aboslute", home) + } + marker := path.Join(home, ".hm-activation") + + if len(args) != 1 { + fmsg.Fatalf("invalid argument") + } + activate := path.Join(args[0], "activate") + + var cmd *exec.Cmd + if l, err := os.Readlink(marker); err != nil && !errors.Is(err, os.ErrNotExist) { + fmsg.Fatalf("cannot read activation marker %q: %v", marker, err) + } else if err != nil || l != activate { + cmd = exec.Command(activate) + } + + // marker present and equals to current activation package + if cmd == nil { + fmsg.Exit(0) + panic("unreachable") + } + + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + cmd.Env = os.Environ() + if err := cmd.Run(); err != nil { + fmsg.Fatalf("cannot activate home-manager configuration: %v", err) + } + + if err := os.Remove(marker); err != nil && !errors.Is(err, os.ErrNotExist) { + fmsg.Fatalf("cannot remove existing marker: %v", err) + } + if err := os.Symlink(activate, marker); err != nil { + fmsg.Fatalf("cannot create activation marker: %v", err) + } +} diff --git a/cmd/fpkg/bundle.go b/cmd/fpkg/bundle.go new file mode 100644 index 0000000..1a870e4 --- /dev/null +++ b/cmd/fpkg/bundle.go @@ -0,0 +1,68 @@ +package main + +import ( + "encoding/json" + "os" + + "git.gensokyo.uk/security/fortify/dbus" + "git.gensokyo.uk/security/fortify/internal/fmsg" + "git.gensokyo.uk/security/fortify/internal/system" +) + +type bundleInfo struct { + Name string `json:"name"` + Version string `json:"version"` + + // passed through to [fst.Config] + ID string `json:"id"` + // passed through to [fst.Config] + AppID int `json:"app_id"` + // passed through to [fst.Config] + Groups []string `json:"groups,omitempty"` + // passed through to [fst.Config] + UserNS bool `json:"userns,omitempty"` + // passed through to [fst.Config] + Net bool `json:"net,omitempty"` + // passed through to [fst.Config] + Dev bool `json:"dev,omitempty"` + // passed through to [fst.Config] + NoNewSession bool `json:"no_new_session,omitempty"` + // passed through to [fst.Config] + MapRealUID bool `json:"map_real_uid,omitempty"` + // passed through to [fst.Config] + DirectWayland bool `json:"direct_wayland,omitempty"` + // passed through to [fst.Config] + SystemBus *dbus.Config `json:"system_bus,omitempty"` + // passed through to [fst.Config] + SessionBus *dbus.Config `json:"session_bus,omitempty"` + // passed through to [fst.Config] + Enablements system.Enablements `json:"enablements"` + + // allow gpu access within sandbox + GPU bool `json:"gpu"` + // inner nix store path to activate-and-exec script + Launcher string `json:"launcher"` + // store path to /run/current-system + CurrentSystem string `json:"current_system"` +} + +func loadBundleInfo(name string, beforeFail func()) *bundleInfo { + bundle := new(bundleInfo) + if f, err := os.Open(name); err != nil { + beforeFail() + fmsg.Fatalf("cannot open bundle: %v", err) + } else if err = json.NewDecoder(f).Decode(&bundle); err != nil { + beforeFail() + fmsg.Fatalf("cannot parse bundle metadata: %v", err) + } else if err = f.Close(); err != nil { + fmsg.Printf("cannot close bundle metadata: %v", err) + // not fatal + } + + if bundle.ID == "" { + beforeFail() + fmsg.Fatal("application identifier must not be empty") + } + + return bundle +} diff --git a/cmd/fpkg/install.go b/cmd/fpkg/install.go new file mode 100644 index 0000000..150390f --- /dev/null +++ b/cmd/fpkg/install.go @@ -0,0 +1,169 @@ +package main + +import ( + "encoding/json" + "flag" + "os" + "path" + + "git.gensokyo.uk/security/fortify/fst" + "git.gensokyo.uk/security/fortify/internal/fmsg" +) + +func actionInstall(args []string) { + set := flag.NewFlagSet("install", flag.ExitOnError) + var dropShell bool + set.BoolVar(&dropShell, "s", false, "Drop to a shell on activation") + + // Ignore errors; set is set for ExitOnError. + _ = set.Parse(args) + + args = set.Args() + + if len(args) != 1 { + fmsg.Fatal("invalid argument") + } + pkgPath := args[0] + if !path.IsAbs(pkgPath) { + if dir, err := os.Getwd(); err != nil { + fmsg.Fatalf("cannot get current directory: %v", err) + } else { + pkgPath = path.Join(dir, pkgPath) + } + } + + var ( + _ = lookPath("zstd") + tar = lookPath("tar") + chmod = lookPath("chmod") + rm = lookPath("rm") + ) + + var workDir string + if p, err := os.MkdirTemp("", "fpkg.*"); err != nil { + fmsg.Fatalf("cannot create temporary directory: %v", err) + } else { + workDir = p + } + cleanup := func() { + // should be faster than a native implementation + mustRun(chmod, "-R", "+w", workDir) + mustRun(rm, "-rf", workDir) + } + beforeRunFail.Store(&cleanup) + + mustRun(tar, "-C", workDir, "-xf", pkgPath) + bundle := loadBundleInfo(path.Join(workDir, "bundle.json"), cleanup) + + pathSet := pathSetByApp(bundle.ID) + + app := bundle + if s, err := os.Stat(pathSet.metaPath); err != nil { + if !os.IsNotExist(err) { + cleanup() + fmsg.Fatalf("cannot access %q: %v", pathSet.metaPath, err) + panic("unreachable") + } + // did not modify app, clean installation condition met later + } else if s.IsDir() { + cleanup() + fmsg.Fatalf("metadata path %q is not a file", pathSet.metaPath) + panic("unreachable") + } else { + app = loadBundleInfo(pathSet.metaPath, cleanup) + if app.ID != bundle.ID { + cleanup() + fmsg.Fatalf("app %q claims to have the identifier %q", bundle.ID, app.ID) + } + // sec: should verify credentials + } + + if app != bundle { + if app.Launcher == bundle.Launcher { + cleanup() + fmsg.Printf("package %q is identical to local application %q", pkgPath, app.ID) + fmsg.Exit(0) + } + // sec: should compare version string + fmsg.VPrintf("installing application %q version %q over local %q", bundle.ID, bundle.Version, app.Version) + } else { + fmsg.VPrintf("application %q clean installation", bundle.ID) + // sec: should install credentials + } + + var command []string + if !dropShell { + shellCommand := "export BUNDLE=" + fst.Tmp + "/bundle &&" + // export inner bundle path in the environment + "mkdir -p etc && chmod -R +w etc && rm -rf etc && cp -dRf $BUNDLE/etc etc &&" + // copy inner /etc + "mkdir -p nix && chmod -R +w nix && rm -rf nix &&" + // remove existing inner /nix + "nix copy --all --from file://$BUNDLE/res --to . &&" + // copy binary cache store paths (app) + "nix copy --all --to ." // copy inner /nix store paths (nixos) + command = []string{shell, "-c", shellCommand} + } else { + command = []string{shell} + } + + config := &fst.Config{ + ID: app.ID, + Command: command, + Confinement: fst.ConfinementConfig{ + AppID: app.AppID, + Groups: app.Groups, + Username: "nixos", + Inner: path.Join("/data/data", app.ID, "cache"), + Outer: pathSet.cacheDir, // this also ensures cacheDir via fshim + Sandbox: &fst.SandboxConfig{ + Hostname: formatHostname(app.Name) + "-install", + NoNewSession: dropShell, // nix copy should not need job control + Filesystem: []*fst.FilesystemConfig{ + {Src: path.Join(workDir, "nix"), Dst: "/nix", Must: true}, + {Src: workDir, Dst: path.Join(fst.Tmp, "bundle"), Must: true}, + }, + Link: [][2]string{ + {bundle.CurrentSystem, "/run/current-system"}, + {"/run/current-system/sw/bin", "/bin"}, + {"/run/current-system/sw/bin", "/usr/bin"}, + }, + Etc: path.Join(workDir, "etc"), + AutoEtc: true, + }, + ExtraPerms: []*fst.ExtraPermConfig{ + {Path: dataHome, Execute: true}, + {Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true}, + {Path: workDir, Execute: true}, + }, + SystemBus: app.SystemBus, + SessionBus: app.SessionBus, + Enablements: app.Enablements, + }, + } + + fortifyApp(config, cleanup) + + if dropShell { + cleanup() + fmsg.Exit(0) + } + + // serialise metadata to ensure consistency + if f, err := os.OpenFile(pathSet.metaPath+"~", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755); err != nil { + cleanup() + fmsg.Fatalf("cannot create metadata file: %v", err) + panic("unreachable") + } else if err = json.NewEncoder(f).Encode(bundle); err != nil { + cleanup() + fmsg.Fatalf("cannot write metadata: %v", err) + panic("unreachable") + } else if err = f.Close(); err != nil { + fmsg.Printf("cannot close metadata file: %v", err) + // not fatal + } + + if err := os.Rename(pathSet.metaPath+"~", pathSet.metaPath); err != nil { + cleanup() + fmsg.Fatalf("cannot rename metadata file: %v", err) + panic("unreachable") + } + + cleanup() +} diff --git a/cmd/fpkg/main.go b/cmd/fpkg/main.go new file mode 100644 index 0000000..0bbd923 --- /dev/null +++ b/cmd/fpkg/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "flag" + "os" + + "git.gensokyo.uk/security/fortify/internal/fmsg" +) + +const shell = "/run/current-system/sw/bin/bash" + +func init() { + if err := os.Setenv("SHELL", shell); err != nil { + fmsg.Fatalf("cannot set $SHELL: %v", err) + } +} + +var ( + flagVerbose bool +) + +func init() { + flag.BoolVar(&flagVerbose, "v", false, "Verbose output") +} + +func main() { + fmsg.SetPrefix("fpkg") + + flag.Parse() + fmsg.SetVerbose(flagVerbose) + + args := flag.Args() + if len(args) < 1 { + fmsg.Fatal("invalid argument") + } + + switch args[0] { + case "install": + actionInstall(args[1:]) + case "start": + actionStart(args[1:]) + + case "activate": + actionActivate(args[1:]) + default: + fmsg.Fatal("invalid argument") + } + + fmsg.Exit(0) +} diff --git a/cmd/fpkg/paths.go b/cmd/fpkg/paths.go new file mode 100644 index 0000000..cf36c09 --- /dev/null +++ b/cmd/fpkg/paths.go @@ -0,0 +1,71 @@ +package main + +import ( + "os" + "os/exec" + "path" + "strconv" + "sync/atomic" + + "git.gensokyo.uk/security/fortify/internal/fmsg" +) + +var ( + dataHome string +) + +func init() { + // dataHome + if p, ok := os.LookupEnv("FORTIFY_DATA_HOME"); ok { + dataHome = p + } else { + dataHome = "/var/lib/fortify/" + strconv.Itoa(os.Getuid()) + } +} + +func lookPath(file string) string { + if p, err := exec.LookPath(file); err != nil { + fmsg.Fatalf("%s: command not found", file) + panic("unreachable") + } else { + return p + } +} + +var beforeRunFail = new(atomic.Pointer[func()]) + +func mustRun(name string, arg ...string) { + fmsg.VPrintf("spawning process: %q %q", name, arg) + cmd := exec.Command(name, arg...) + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + if f := beforeRunFail.Swap(nil); f != nil { + (*f)() + } + fmsg.Fatalf("%s: %v", name, err) + panic("unreachable") + } +} + +type appPathSet struct { + // ${dataHome}/${id} + baseDir string + // ${baseDir}/app + metaPath string + // ${baseDir}/files + homeDir string + // ${baseDir}/cache + cacheDir string + // ${baseDir}/cache/nix + nixPath string +} + +func pathSetByApp(id string) *appPathSet { + pathSet := new(appPathSet) + pathSet.baseDir = path.Join(dataHome, id) + pathSet.metaPath = path.Join(pathSet.baseDir, "app") + pathSet.homeDir = path.Join(pathSet.baseDir, "files") + pathSet.cacheDir = path.Join(pathSet.baseDir, "cache") + pathSet.nixPath = path.Join(pathSet.cacheDir, "nix") + return pathSet +} diff --git a/cmd/fpkg/proc.go b/cmd/fpkg/proc.go new file mode 100644 index 0000000..a995c9f --- /dev/null +++ b/cmd/fpkg/proc.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "errors" + "io" + "os" + "os/exec" + + "git.gensokyo.uk/security/fortify/fst" + "git.gensokyo.uk/security/fortify/internal" + "git.gensokyo.uk/security/fortify/internal/fmsg" +) + +func fortifyApp(config *fst.Config, beforeFail func()) { + var ( + cmd *exec.Cmd + st io.WriteCloser + ) + if p, ok := internal.Check(internal.Fortify); !ok { + beforeFail() + fmsg.Fatal("invalid fortify path, this copy of fpkg is not compiled correctly") + panic("unreachable") + } else if r, w, err := os.Pipe(); err != nil { + beforeFail() + fmsg.Fatalf("cannot pipe: %v", err) + panic("unreachable") + } else { + if fmsg.Verbose() { + cmd = exec.Command(p, "-v", "app", "3") + } else { + cmd = exec.Command(p, "app", "3") + } + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + cmd.ExtraFiles = []*os.File{r} + st = w + } + + go func() { + if err := json.NewEncoder(st).Encode(config); err != nil { + beforeFail() + fmsg.Fatalf("cannot send configuration: %v", err) + panic("unreachable") + } + }() + + if err := cmd.Start(); err != nil { + beforeFail() + fmsg.Fatalf("cannot start fortify: %v", err) + panic("unreachable") + } + if err := cmd.Wait(); err != nil { + var exitError *exec.ExitError + if errors.As(err, &exitError) { + beforeFail() + fmsg.Exit(exitError.ExitCode()) + panic("unreachable") + } else { + beforeFail() + fmsg.Fatalf("cannot wait: %v", err) + panic("unreachable") + } + } +} diff --git a/cmd/fpkg/start.go b/cmd/fpkg/start.go new file mode 100644 index 0000000..39cc83b --- /dev/null +++ b/cmd/fpkg/start.go @@ -0,0 +1,114 @@ +package main + +import ( + "encoding/json" + "flag" + "os" + "path" + + "git.gensokyo.uk/security/fortify/fst" + "git.gensokyo.uk/security/fortify/internal/fmsg" +) + +func actionStart(args []string) { + set := flag.NewFlagSet("start", flag.ExitOnError) + var dropShell bool + set.BoolVar(&dropShell, "s", false, "Drop to a shell on activation") + + // Ignore errors; set is set for ExitOnError. + _ = set.Parse(args) + + args = set.Args() + + if len(args) < 1 { + fmsg.Fatal("invalid argument") + } + name := args[0] + if !path.IsAbs(name) { + if dir, err := os.Getwd(); err != nil { + fmsg.Fatalf("cannot get current directory: %v", err) + } else { + name = path.Join(dir, name) + } + } + + bundle := new(bundleInfo) + if f, err := os.Open(path.Join(name, "bundle.json")); err != nil { + fmsg.Fatalf("cannot open bundle: %v", err) + } else if err = json.NewDecoder(f).Decode(&bundle); err != nil { + fmsg.Fatalf("cannot parse bundle metadata: %v", err) + } else if err = f.Close(); err != nil { + fmsg.Printf("cannot close bundle metadata: %v", err) + } + + command := make([]string, 1, len(args)) + if !dropShell { + command[0] = bundle.Launcher + } else { + command[0] = shell + } + command = append(command, args[1:]...) + + baseDir := path.Join(dataHome, bundle.ID) + homeDir := path.Join(baseDir, "files") + config := &fst.Config{ + ID: bundle.ID, + Command: command, + Confinement: fst.ConfinementConfig{ + AppID: bundle.AppID, + Groups: bundle.Groups, + Username: "fortify", + Inner: path.Join("/data/data", bundle.ID), + Outer: homeDir, + Sandbox: &fst.SandboxConfig{ + Hostname: formatHostname(bundle.Name), + UserNS: bundle.UserNS, + Net: bundle.Net, + Dev: bundle.Dev, + NoNewSession: bundle.NoNewSession || dropShell, + MapRealUID: bundle.MapRealUID, + DirectWayland: bundle.DirectWayland, + Filesystem: []*fst.FilesystemConfig{ + {Src: path.Join(name, "nix"), Dst: "/nix", Must: true}, + {Src: "/etc/resolv.conf"}, + {Src: "/sys/block"}, + {Src: "/sys/bus"}, + {Src: "/sys/class"}, + {Src: "/sys/dev"}, + {Src: "/sys/devices"}, + }, + Link: [][2]string{ + {bundle.CurrentSystem, "/run/current-system"}, + {"/run/current-system/sw/bin", "/bin"}, + {"/run/current-system/sw/bin", "/usr/bin"}, + }, + Etc: path.Join(name, "etc"), + AutoEtc: true, + }, + ExtraPerms: []*fst.ExtraPermConfig{ + {Path: dataHome, Execute: true}, + {Ensure: true, Path: baseDir, Read: true, Write: true, Execute: true}, + }, + SystemBus: bundle.SystemBus, + SessionBus: bundle.SessionBus, + Enablements: bundle.Enablements, + }, + } + + if bundle.GPU { + config.Confinement.Sandbox.Filesystem = append(config.Confinement.Sandbox.Filesystem, + &fst.FilesystemConfig{Src: "/dev/dri", Device: true}) + } + + fortifyApp(config, func() {}) + fmsg.Exit(0) +} + +func formatHostname(name string) string { + if h, err := os.Hostname(); err != nil { + fmsg.Printf("cannot get hostname: %v", err) + return "fortify-" + name + } else { + return h + "-" + name + } +} diff --git a/dist/install.sh b/dist/install.sh index 9694969..dcb5dc0 100755 --- a/dist/install.sh +++ b/dist/install.sh @@ -4,6 +4,7 @@ cd "$(dirname -- "$0")" || exit 1 install -vDm0755 "bin/fortify" "${FORTIFY_INSTALL_PREFIX}/usr/bin/fortify" install -vDm0755 "bin/fshim" "${FORTIFY_INSTALL_PREFIX}/usr/libexec/fortify/fshim" install -vDm0755 "bin/finit" "${FORTIFY_INSTALL_PREFIX}/usr/libexec/fortify/finit" +install -vDm0755 "bin/fpkg" "${FORTIFY_INSTALL_PREFIX}/usr/libexec/fortify/fpkg" install -vDm0755 "bin/fuserdb" "${FORTIFY_INSTALL_PREFIX}/usr/libexec/fortify/fuserdb" install -vDm6511 "bin/fsu" "${FORTIFY_INSTALL_PREFIX}/usr/bin/fsu"