From aa454b158f3713b2ee47162837dcea1efdce0ee6 Mon Sep 17 00:00:00 2001 From: Ophestra Date: Wed, 25 Jun 2025 20:44:49 +0900 Subject: cmd/planterette: remove hsu special case Remove special case and invoke hakurei out of process. Signed-off-by: Ophestra --- cmd/fpkg/app.go | 154 --------------- cmd/fpkg/build.nix | 252 ----------------------- cmd/fpkg/main.go | 351 --------------------------------- cmd/fpkg/paths.go | 101 ---------- cmd/fpkg/proc.go | 29 --- cmd/fpkg/test/configuration.nix | 62 ------ cmd/fpkg/test/default.nix | 34 ---- cmd/fpkg/test/foot.nix | 48 ----- cmd/fpkg/test/test.py | 108 ---------- cmd/fpkg/with.go | 114 ----------- cmd/hsu/main.go | 2 +- cmd/hsu/package.nix | 15 +- cmd/hsu/path.go | 1 - cmd/planterette/app.go | 154 +++++++++++++++ cmd/planterette/build.nix | 252 +++++++++++++++++++++++ cmd/planterette/main.go | 333 +++++++++++++++++++++++++++++++ cmd/planterette/paths.go | 101 ++++++++++ cmd/planterette/proc.go | 60 ++++++ cmd/planterette/test/configuration.nix | 62 ++++++ cmd/planterette/test/default.nix | 34 ++++ cmd/planterette/test/foot.nix | 48 +++++ cmd/planterette/test/test.py | 108 ++++++++++ cmd/planterette/with.go | 114 +++++++++++ 23 files changed, 1271 insertions(+), 1266 deletions(-) delete mode 100644 cmd/fpkg/app.go delete mode 100644 cmd/fpkg/build.nix delete mode 100644 cmd/fpkg/main.go delete mode 100644 cmd/fpkg/paths.go delete mode 100644 cmd/fpkg/proc.go delete mode 100644 cmd/fpkg/test/configuration.nix delete mode 100644 cmd/fpkg/test/default.nix delete mode 100644 cmd/fpkg/test/foot.nix delete mode 100644 cmd/fpkg/test/test.py delete mode 100644 cmd/fpkg/with.go create mode 100644 cmd/planterette/app.go create mode 100644 cmd/planterette/build.nix create mode 100644 cmd/planterette/main.go create mode 100644 cmd/planterette/paths.go create mode 100644 cmd/planterette/proc.go create mode 100644 cmd/planterette/test/configuration.nix create mode 100644 cmd/planterette/test/default.nix create mode 100644 cmd/planterette/test/foot.nix create mode 100644 cmd/planterette/test/test.py create mode 100644 cmd/planterette/with.go (limited to 'cmd') diff --git a/cmd/fpkg/app.go b/cmd/fpkg/app.go deleted file mode 100644 index 257956d9..00000000 --- a/cmd/fpkg/app.go +++ /dev/null @@ -1,154 +0,0 @@ -package main - -import ( - "encoding/json" - "log" - "os" - "path" - - "git.gensokyo.uk/security/hakurei/dbus" - "git.gensokyo.uk/security/hakurei/hst" - "git.gensokyo.uk/security/hakurei/sandbox/seccomp" - "git.gensokyo.uk/security/hakurei/system" -) - -type appInfo struct { - Name string `json:"name"` - Version string `json:"version"` - - // passed through to [hst.Config] - ID string `json:"id"` - // passed through to [hst.Config] - Identity int `json:"identity"` - // passed through to [hst.Config] - Groups []string `json:"groups,omitempty"` - // passed through to [hst.Config] - Devel bool `json:"devel,omitempty"` - // passed through to [hst.Config] - Userns bool `json:"userns,omitempty"` - // passed through to [hst.Config] - Net bool `json:"net,omitempty"` - // passed through to [hst.Config] - Device bool `json:"dev,omitempty"` - // passed through to [hst.Config] - Tty bool `json:"tty,omitempty"` - // passed through to [hst.Config] - MapRealUID bool `json:"map_real_uid,omitempty"` - // passed through to [hst.Config] - DirectWayland bool `json:"direct_wayland,omitempty"` - // passed through to [hst.Config] - SystemBus *dbus.Config `json:"system_bus,omitempty"` - // passed through to [hst.Config] - SessionBus *dbus.Config `json:"session_bus,omitempty"` - // passed through to [hst.Config] - Enablements system.Enablement `json:"enablements"` - - // passed through to [hst.Config] - Multiarch bool `json:"multiarch,omitempty"` - // passed through to [hst.Config] - Bluetooth bool `json:"bluetooth,omitempty"` - - // allow gpu access within sandbox - GPU bool `json:"gpu"` - // store path to nixGL mesa wrappers - Mesa string `json:"mesa,omitempty"` - // store path to nixGL source - NixGL string `json:"nix_gl,omitempty"` - // store path to activate-and-exec script - Launcher string `json:"launcher"` - // store path to /run/current-system - CurrentSystem string `json:"current_system"` - // store path to home-manager activation package - ActivationPackage string `json:"activation_package"` -} - -func (app *appInfo) toFst(pathSet *appPathSet, argv []string, flagDropShell bool) *hst.Config { - config := &hst.Config{ - ID: app.ID, - - Path: argv[0], - Args: argv, - - Enablements: app.Enablements, - - SystemBus: app.SystemBus, - SessionBus: app.SessionBus, - DirectWayland: app.DirectWayland, - - Username: "hakurei", - Shell: shellPath, - Data: pathSet.homeDir, - Dir: path.Join("/data/data", app.ID), - - Identity: app.Identity, - Groups: app.Groups, - - Container: &hst.ContainerConfig{ - Hostname: formatHostname(app.Name), - Devel: app.Devel, - Userns: app.Userns, - Net: app.Net, - Device: app.Device, - Tty: app.Tty || flagDropShell, - MapRealUID: app.MapRealUID, - Filesystem: []*hst.FilesystemConfig{ - {Src: path.Join(pathSet.nixPath, "store"), Dst: "/nix/store", Must: true}, - {Src: pathSet.metaPath, Dst: path.Join(hst.Tmp, "app"), Must: true}, - {Src: "/etc/resolv.conf"}, - {Src: "/sys/block"}, - {Src: "/sys/bus"}, - {Src: "/sys/class"}, - {Src: "/sys/dev"}, - {Src: "/sys/devices"}, - }, - Link: [][2]string{ - {app.CurrentSystem, "/run/current-system"}, - {"/run/current-system/sw/bin", "/bin"}, - {"/run/current-system/sw/bin", "/usr/bin"}, - }, - Etc: path.Join(pathSet.cacheDir, "etc"), - AutoEtc: true, - }, - ExtraPerms: []*hst.ExtraPermConfig{ - {Path: dataHome, Execute: true}, - {Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true}, - }, - } - if app.Multiarch { - config.Container.Seccomp |= seccomp.FilterMultiarch - } - if app.Bluetooth { - config.Container.Seccomp |= seccomp.FilterBluetooth - } - return config -} - -func loadAppInfo(name string, beforeFail func()) *appInfo { - bundle := new(appInfo) - if f, err := os.Open(name); err != nil { - beforeFail() - log.Fatalf("cannot open bundle: %v", err) - } else if err = json.NewDecoder(f).Decode(&bundle); err != nil { - beforeFail() - log.Fatalf("cannot parse bundle metadata: %v", err) - } else if err = f.Close(); err != nil { - log.Printf("cannot close bundle metadata: %v", err) - // not fatal - } - - if bundle.ID == "" { - beforeFail() - log.Fatal("application identifier must not be empty") - } - - return bundle -} - -func formatHostname(name string) string { - if h, err := os.Hostname(); err != nil { - log.Printf("cannot get hostname: %v", err) - return "hakurei-" + name - } else { - return h + "-" + name - } -} diff --git a/cmd/fpkg/build.nix b/cmd/fpkg/build.nix deleted file mode 100644 index b5c50801..00000000 --- a/cmd/fpkg/build.nix +++ /dev/null @@ -1,252 +0,0 @@ -{ - nixpkgsFor, - system, - nixpkgs, - home-manager, -}: - -{ - lib, - stdenv, - closureInfo, - writeScript, - runtimeShell, - writeText, - symlinkJoin, - vmTools, - runCommand, - fetchFromGitHub, - - zstd, - nix, - sqlite, - - name ? throw "name is required", - version ? throw "version is required", - pname ? "${name}-${version}", - modules ? [ ], - nixosModules ? [ ], - script ? '' - exec "$SHELL" "$@" - '', - - id ? name, - identity ? throw "identity is required", - groups ? [ ], - userns ? false, - net ? true, - dev ? false, - no_new_session ? false, - map_real_uid ? false, - direct_wayland ? false, - system_bus ? null, - session_bus ? null, - - allow_wayland ? true, - allow_x11 ? false, - allow_dbus ? true, - allow_pulse ? true, - gpu ? allow_wayland || allow_x11, -}: - -let - inherit (lib) optionals; - - homeManagerConfiguration = home-manager.lib.homeManagerConfiguration { - pkgs = nixpkgsFor.${system}; - modules = modules ++ [ - { - home = { - username = "hakurei"; - homeDirectory = "/data/data/${id}"; - stateVersion = "22.11"; - }; - } - ]; - }; - - launcher = writeScript "hakurei-${pname}" '' - #!${runtimeShell} -el - ${script} - ''; - - extraNixOSConfig = - { pkgs, ... }: - { - environment = { - etc.nixpkgs.source = nixpkgs.outPath; - systemPackages = [ pkgs.nix ]; - }; - - imports = nixosModules; - }; - nixos = nixpkgs.lib.nixosSystem { - inherit system; - modules = [ - extraNixOSConfig - { nix.settings.experimental-features = [ "flakes" ]; } - { nix.settings.experimental-features = [ "nix-command" ]; } - { boot.isContainer = true; } - { system.stateVersion = "22.11"; } - ]; - }; - - etc = vmTools.runInLinuxVM ( - runCommand "etc" { } '' - mkdir -p /etc - ${nixos.config.system.build.etcActivationCommands} - - # remove unused files - rm -rf /etc/sudoers - - mkdir -p $out - tar -C /etc -cf "$out/etc.tar" . - '' - ); - - extendSessionDefault = id: ext: { - filter = true; - - talk = [ "org.freedesktop.Notifications" ] ++ ext.talk; - own = - (optionals (id != null) [ - "${id}.*" - "org.mpris.MediaPlayer2.${id}.*" - ]) - ++ ext.own; - - inherit (ext) call broadcast; - }; - - nixGL = fetchFromGitHub { - owner = "nix-community"; - repo = "nixGL"; - rev = "310f8e49a149e4c9ea52f1adf70cdc768ec53f8a"; - hash = "sha256-lnzZQYG0+EXl/6NkGpyIz+FEOc/DSEG57AP1VsdeNrM="; - }; - - mesaWrappers = - let - isIntelX86Platform = system == "x86_64-linux"; - nixGLPackages = import (nixGL + "/default.nix") { - pkgs = nixpkgs.legacyPackages.${system}; - enable32bits = isIntelX86Platform; - enableIntelX86Extensions = isIntelX86Platform; - }; - in - symlinkJoin { - name = "nixGL-mesa"; - paths = with nixGLPackages; [ - nixGLIntel - nixVulkanIntel - ]; - }; - - info = builtins.toJSON { - inherit - name - version - id - identity - launcher - groups - userns - net - dev - no_new_session - map_real_uid - direct_wayland - system_bus - gpu - ; - - session_bus = - if session_bus != null then - (session_bus (extendSessionDefault id)) - else - (extendSessionDefault id { - talk = [ ]; - own = [ ]; - call = { }; - broadcast = { }; - }); - - enablements = (if allow_wayland then 1 else 0) + (if allow_x11 then 2 else 0) + (if allow_dbus then 4 else 0) + (if allow_pulse then 8 else 0); - - mesa = if gpu then mesaWrappers else null; - nix_gl = if gpu then nixGL else null; - current_system = nixos.config.system.build.toplevel; - activation_package = homeManagerConfiguration.activationPackage; - }; -in - -stdenv.mkDerivation { - name = "${pname}.pkg"; - inherit version; - __structuredAttrs = true; - - nativeBuildInputs = [ - zstd - nix - sqlite - ]; - - buildCommand = '' - NIX_ROOT="$(mktemp -d)" - export USER="nobody" - - # create bootstrap store - bootstrapClosureInfo="${ - closureInfo { - rootPaths = [ - nix - nixos.config.system.build.toplevel - ]; - } - }" - echo "copying bootstrap store paths..." - mkdir -p "$NIX_ROOT/nix/store" - xargs -n 1 -a "$bootstrapClosureInfo/store-paths" cp -at "$NIX_ROOT/nix/store/" - NIX_REMOTE="local?root=$NIX_ROOT" nix-store --load-db < "$bootstrapClosureInfo/registration" - NIX_REMOTE="local?root=$NIX_ROOT" nix-store --optimise - sqlite3 "$NIX_ROOT/nix/var/nix/db/db.sqlite" "UPDATE ValidPaths SET registrationTime = ''${SOURCE_DATE_EPOCH}" - chmod -R +r "$NIX_ROOT/nix/var" - - # create binary cache - closureInfo="${ - closureInfo { - rootPaths = - [ - homeManagerConfiguration.activationPackage - launcher - ] - ++ optionals gpu [ - mesaWrappers - nixGL - ]; - } - }" - echo "copying application paths..." - TMP_STORE="$(mktemp -d)" - mkdir -p "$TMP_STORE/nix/store" - xargs -n 1 -a "$closureInfo/store-paths" cp -at "$TMP_STORE/nix/store/" - NIX_REMOTE="local?root=$TMP_STORE" nix-store --load-db < "$closureInfo/registration" - sqlite3 "$TMP_STORE/nix/var/nix/db/db.sqlite" "UPDATE ValidPaths SET registrationTime = ''${SOURCE_DATE_EPOCH}" - NIX_REMOTE="local?root=$TMP_STORE" nix --offline --extra-experimental-features nix-command \ - --verbose --log-format raw-with-logs \ - copy --all --no-check-sigs --to \ - "file://$NIX_ROOT/res?compression=zstd&compression-level=19¶llel-compression=true" - - # package /etc - mkdir -p "$NIX_ROOT/etc" - tar -C "$NIX_ROOT/etc" -xf "${etc}/etc.tar" - - # write metadata - cp "${writeText "bundle.json" info}" "$NIX_ROOT/bundle.json" - - # create an intermediate file to improve zstd performance - INTER="$(mktemp)" - tar -C "$NIX_ROOT" -cf "$INTER" . - zstd -T0 -19 -fo "$out" "$INTER" - ''; -} diff --git a/cmd/fpkg/main.go b/cmd/fpkg/main.go deleted file mode 100644 index 7d44d419..00000000 --- a/cmd/fpkg/main.go +++ /dev/null @@ -1,351 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "log" - "os" - "os/signal" - "path" - "syscall" - - "git.gensokyo.uk/security/hakurei/command" - "git.gensokyo.uk/security/hakurei/hst" - "git.gensokyo.uk/security/hakurei/internal" - "git.gensokyo.uk/security/hakurei/internal/app/instance" - "git.gensokyo.uk/security/hakurei/internal/hlog" - "git.gensokyo.uk/security/hakurei/internal/sys" - "git.gensokyo.uk/security/hakurei/sandbox" -) - -const shellPath = "/run/current-system/sw/bin/bash" - -var ( - errSuccess = errors.New("success") - - std sys.State = new(sys.Std) -) - -func init() { - hlog.Prepare("fpkg") - if err := os.Setenv("SHELL", shellPath); err != nil { - log.Fatalf("cannot set $SHELL: %v", err) - } -} - -func main() { - // early init path, skips root check and duplicate PR_SET_DUMPABLE - sandbox.TryArgv0(hlog.Output{}, hlog.Prepare, internal.InstallFmsg) - - if err := sandbox.SetDumpable(sandbox.SUID_DUMP_DISABLE); err != nil { - log.Printf("cannot set SUID_DUMP_DISABLE: %s", err) - // not fatal: this program runs as the privileged user - } - - if os.Geteuid() == 0 { - log.Fatal("this program must not run as root") - } - - ctx, stop := signal.NotifyContext(context.Background(), - syscall.SIGINT, syscall.SIGTERM) - defer stop() // unreachable - - var ( - flagVerbose bool - flagDropShell bool - ) - c := command.New(os.Stderr, log.Printf, "fpkg", func([]string) error { - internal.InstallFmsg(flagVerbose) - return nil - }). - Flag(&flagVerbose, "v", command.BoolFlag(false), "Print debug messages to the console"). - Flag(&flagDropShell, "s", command.BoolFlag(false), "Drop to a shell in place of next hakurei action") - - c.Command("shim", command.UsageInternal, func([]string) error { instance.ShimMain(); return errSuccess }) - - { - var ( - flagDropShellActivate bool - ) - c.NewCommand("install", "Install an application from its package", func(args []string) error { - if len(args) != 1 { - log.Println("invalid argument") - return syscall.EINVAL - } - pkgPath := args[0] - if !path.IsAbs(pkgPath) { - if dir, err := os.Getwd(); err != nil { - log.Printf("cannot get current directory: %v", err) - return err - } else { - pkgPath = path.Join(dir, pkgPath) - } - } - - /* - Look up paths to programs started by fpkg. - This is done here to ease error handling as cleanup is not yet required. - */ - - var ( - _ = lookPath("zstd") - tar = lookPath("tar") - chmod = lookPath("chmod") - rm = lookPath("rm") - ) - - /* - Extract package and set up for cleanup. - */ - - var workDir string - if p, err := os.MkdirTemp("", "fpkg.*"); err != nil { - log.Printf("cannot create temporary directory: %v", err) - return 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) - - /* - Parse bundle and app metadata, do pre-install checks. - */ - - bundle := loadAppInfo(path.Join(workDir, "bundle.json"), cleanup) - pathSet := pathSetByApp(bundle.ID) - - a := bundle - if s, err := os.Stat(pathSet.metaPath); err != nil { - if !os.IsNotExist(err) { - cleanup() - log.Printf("cannot access %q: %v", pathSet.metaPath, err) - return err - } - // did not modify app, clean installation condition met later - } else if s.IsDir() { - cleanup() - log.Printf("metadata path %q is not a file", pathSet.metaPath) - return syscall.EBADMSG - } else { - a = loadAppInfo(pathSet.metaPath, cleanup) - if a.ID != bundle.ID { - cleanup() - log.Printf("app %q claims to have identifier %q", - bundle.ID, a.ID) - return syscall.EBADE - } - // sec: should verify credentials - } - - if a != bundle { - // do not try to re-install - if a.NixGL == bundle.NixGL && - a.CurrentSystem == bundle.CurrentSystem && - a.Launcher == bundle.Launcher && - a.ActivationPackage == bundle.ActivationPackage { - cleanup() - log.Printf("package %q is identical to local application %q", - pkgPath, a.ID) - return errSuccess - } - - // identity determines uid - if a.Identity != bundle.Identity { - cleanup() - log.Printf("package %q identity %d differs from installed %d", - pkgPath, bundle.Identity, a.Identity) - return syscall.EBADE - } - - // sec: should compare version string - hlog.Verbosef("installing application %q version %q over local %q", - bundle.ID, bundle.Version, a.Version) - } else { - hlog.Verbosef("application %q clean installation", bundle.ID) - // sec: should install credentials - } - - /* - Setup steps for files owned by the target user. - */ - - withCacheDir(ctx, "install", []string{ - // export inner bundle path in the environment - "export BUNDLE=" + hst.Tmp + "/bundle", - // replace inner /etc - "mkdir -p etc", - "chmod -R +w etc", - "rm -rf etc", - "cp -dRf $BUNDLE/etc etc", - // replace inner /nix - "mkdir -p nix", - "chmod -R +w nix", - "rm -rf nix", - "cp -dRf /nix nix", - // copy from binary cache - "nix copy --offline --no-check-sigs --all --from file://$BUNDLE/res --to $PWD", - // deduplicate nix store - "nix store --offline --store $PWD optimise", - // make cache directory world-readable for autoetc - "chmod 0755 .", - }, workDir, bundle, pathSet, flagDropShell, cleanup) - - if bundle.GPU { - withCacheDir(ctx, "mesa-wrappers", []string{ - // link nixGL mesa wrappers - "mkdir -p nix/.nixGL", - "ln -s " + bundle.Mesa + "/bin/nixGLIntel nix/.nixGL/nixGL", - "ln -s " + bundle.Mesa + "/bin/nixVulkanIntel nix/.nixGL/nixVulkan", - }, workDir, bundle, pathSet, false, cleanup) - } - - /* - Activate home-manager generation. - */ - - withNixDaemon(ctx, "activate", []string{ - // clean up broken links - "mkdir -p .local/state/{nix,home-manager}", - "chmod -R +w .local/state/{nix,home-manager}", - "rm -rf .local/state/{nix,home-manager}", - // run activation script - bundle.ActivationPackage + "/activate", - }, false, func(config *hst.Config) *hst.Config { return config }, - bundle, pathSet, flagDropShellActivate, cleanup) - - /* - Installation complete. Write metadata to block re-installs or downgrades. - */ - - // serialise metadata to ensure consistency - if f, err := os.OpenFile(pathSet.metaPath+"~", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644); err != nil { - cleanup() - log.Printf("cannot create metadata file: %v", err) - return err - } else if err = json.NewEncoder(f).Encode(bundle); err != nil { - cleanup() - log.Printf("cannot write metadata: %v", err) - return err - } else if err = f.Close(); err != nil { - log.Printf("cannot close metadata file: %v", err) - // not fatal - } - - if err := os.Rename(pathSet.metaPath+"~", pathSet.metaPath); err != nil { - cleanup() - log.Printf("cannot rename metadata file: %v", err) - return err - } - - cleanup() - return errSuccess - }). - Flag(&flagDropShellActivate, "s", command.BoolFlag(false), "Drop to a shell on activation") - } - - { - var ( - flagDropShellNixGL bool - flagAutoDrivers bool - ) - c.NewCommand("start", "Start an application", func(args []string) error { - if len(args) < 1 { - log.Println("invalid argument") - return syscall.EINVAL - } - - /* - Parse app metadata. - */ - - id := args[0] - pathSet := pathSetByApp(id) - a := loadAppInfo(pathSet.metaPath, func() {}) - if a.ID != id { - log.Printf("app %q claims to have identifier %q", id, a.ID) - return syscall.EBADE - } - - /* - Prepare nixGL. - */ - - if a.GPU && flagAutoDrivers { - withNixDaemon(ctx, "nix-gl", []string{ - "mkdir -p /nix/.nixGL/auto", - "rm -rf /nix/.nixGL/auto", - "export NIXPKGS_ALLOW_UNFREE=1", - "nix build --impure " + - "--out-link /nix/.nixGL/auto/opengl " + - "--override-input nixpkgs path:/etc/nixpkgs " + - "path:" + a.NixGL, - "nix build --impure " + - "--out-link /nix/.nixGL/auto/vulkan " + - "--override-input nixpkgs path:/etc/nixpkgs " + - "path:" + a.NixGL + "#nixVulkanNvidia", - }, true, func(config *hst.Config) *hst.Config { - config.Container.Filesystem = append(config.Container.Filesystem, []*hst.FilesystemConfig{ - {Src: "/etc/resolv.conf"}, - {Src: "/sys/block"}, - {Src: "/sys/bus"}, - {Src: "/sys/class"}, - {Src: "/sys/dev"}, - {Src: "/sys/devices"}, - }...) - appendGPUFilesystem(config) - return config - }, a, pathSet, flagDropShellNixGL, func() {}) - } - - /* - Create app configuration. - */ - - argv := make([]string, 1, len(args)) - if !flagDropShell { - argv[0] = a.Launcher - } else { - argv[0] = shellPath - } - argv = append(argv, args[1:]...) - - config := a.toFst(pathSet, argv, flagDropShell) - - /* - Expose GPU devices. - */ - - if a.GPU { - config.Container.Filesystem = append(config.Container.Filesystem, - &hst.FilesystemConfig{Src: path.Join(pathSet.nixPath, ".nixGL"), Dst: path.Join(hst.Tmp, "nixGL")}) - appendGPUFilesystem(config) - } - - /* - Spawn app. - */ - - mustRunApp(ctx, config, func() {}) - return errSuccess - }). - Flag(&flagDropShellNixGL, "s", command.BoolFlag(false), "Drop to a shell on nixGL build"). - Flag(&flagAutoDrivers, "auto-drivers", command.BoolFlag(false), "Attempt automatic opengl driver detection") - } - - c.MustParse(os.Args[1:], func(err error) { - hlog.Verbosef("command returned %v", err) - if errors.Is(err, errSuccess) { - hlog.BeforeExit() - os.Exit(0) - } - }) - log.Fatal("unreachable") -} diff --git a/cmd/fpkg/paths.go b/cmd/fpkg/paths.go deleted file mode 100644 index aa581c9e..00000000 --- a/cmd/fpkg/paths.go +++ /dev/null @@ -1,101 +0,0 @@ -package main - -import ( - "log" - "os" - "os/exec" - "path" - "strconv" - "sync/atomic" - - "git.gensokyo.uk/security/hakurei/hst" - "git.gensokyo.uk/security/hakurei/internal/hlog" -) - -var ( - dataHome string -) - -func init() { - // dataHome - if p, ok := os.LookupEnv("HAKUREI_DATA_HOME"); ok { - dataHome = p - } else { - dataHome = "/var/lib/hakurei/" + strconv.Itoa(os.Getuid()) - } -} - -func lookPath(file string) string { - if p, err := exec.LookPath(file); err != nil { - log.Fatalf("%s: command not found", file) - return "" - } else { - return p - } -} - -var beforeRunFail = new(atomic.Pointer[func()]) - -func mustRun(name string, arg ...string) { - hlog.Verbosef("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)() - } - log.Fatalf("%s: %v", name, err) - } -} - -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 -} - -func appendGPUFilesystem(config *hst.Config) { - config.Container.Filesystem = append(config.Container.Filesystem, []*hst.FilesystemConfig{ - // flatpak commit 763a686d874dd668f0236f911de00b80766ffe79 - {Src: "/dev/dri", Device: true}, - // mali - {Src: "/dev/mali", Device: true}, - {Src: "/dev/mali0", Device: true}, - {Src: "/dev/umplock", Device: true}, - // nvidia - {Src: "/dev/nvidiactl", Device: true}, - {Src: "/dev/nvidia-modeset", Device: true}, - // nvidia OpenCL/CUDA - {Src: "/dev/nvidia-uvm", Device: true}, - {Src: "/dev/nvidia-uvm-tools", Device: true}, - - // flatpak commit d2dff2875bb3b7e2cd92d8204088d743fd07f3ff - {Src: "/dev/nvidia0", Device: true}, {Src: "/dev/nvidia1", Device: true}, - {Src: "/dev/nvidia2", Device: true}, {Src: "/dev/nvidia3", Device: true}, - {Src: "/dev/nvidia4", Device: true}, {Src: "/dev/nvidia5", Device: true}, - {Src: "/dev/nvidia6", Device: true}, {Src: "/dev/nvidia7", Device: true}, - {Src: "/dev/nvidia8", Device: true}, {Src: "/dev/nvidia9", Device: true}, - {Src: "/dev/nvidia10", Device: true}, {Src: "/dev/nvidia11", Device: true}, - {Src: "/dev/nvidia12", Device: true}, {Src: "/dev/nvidia13", Device: true}, - {Src: "/dev/nvidia14", Device: true}, {Src: "/dev/nvidia15", Device: true}, - {Src: "/dev/nvidia16", Device: true}, {Src: "/dev/nvidia17", Device: true}, - {Src: "/dev/nvidia18", Device: true}, {Src: "/dev/nvidia19", Device: true}, - }...) -} diff --git a/cmd/fpkg/proc.go b/cmd/fpkg/proc.go deleted file mode 100644 index 7862e476..00000000 --- a/cmd/fpkg/proc.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -import ( - "context" - "os" - - "git.gensokyo.uk/security/hakurei/hst" - "git.gensokyo.uk/security/hakurei/internal/app" - "git.gensokyo.uk/security/hakurei/internal/app/instance" - "git.gensokyo.uk/security/hakurei/internal/hlog" -) - -func mustRunApp(ctx context.Context, config *hst.Config, beforeFail func()) { - rs := new(app.RunState) - a := instance.MustNew(instance.ISetuid, ctx, std) - - var code int - if sa, err := a.Seal(config); err != nil { - hlog.PrintBaseError(err, "cannot seal app:") - code = 1 - } else { - code = instance.PrintRunStateErr(instance.ISetuid, rs, sa.Run(rs)) - } - - if code != 0 { - beforeFail() - os.Exit(code) - } -} diff --git a/cmd/fpkg/test/configuration.nix b/cmd/fpkg/test/configuration.nix deleted file mode 100644 index ce74228a..00000000 --- a/cmd/fpkg/test/configuration.nix +++ /dev/null @@ -1,62 +0,0 @@ -{ pkgs, ... }: -{ - users.users = { - alice = { - isNormalUser = true; - description = "Alice Foobar"; - password = "foobar"; - uid = 1000; - }; - }; - - home-manager.users.alice.home.stateVersion = "24.11"; - - # Automatically login on tty1 as a normal user: - services.getty.autologinUser = "alice"; - - environment = { - variables = { - SWAYSOCK = "/tmp/sway-ipc.sock"; - WLR_RENDERER = "pixman"; - }; - }; - - # Automatically configure and start Sway when logging in on tty1: - programs.bash.loginShellInit = '' - if [ "$(tty)" = "/dev/tty1" ]; then - set -e - - mkdir -p ~/.config/sway - (sed s/Mod4/Mod1/ /etc/sway/config && - echo 'output * bg ${pkgs.nixos-artwork.wallpapers.simple-light-gray.gnomeFilePath} fill' && - echo 'output Virtual-1 res 1680x1050') > ~/.config/sway/config - - sway --validate - systemd-cat --identifier=session sway && touch /tmp/sway-exit-ok - fi - ''; - - programs.sway.enable = true; - - virtualisation = { - diskSize = 6 * 1024; - - qemu.options = [ - # Need to switch to a different GPU driver than the default one (-vga std) so that Sway can launch: - "-vga none -device virtio-gpu-pci" - - # Increase zstd performance: - "-smp 8" - ]; - }; - - environment.hakurei = { - enable = true; - stateDir = "/var/lib/hakurei"; - users.alice = 0; - - extraHomeConfig = { - home.stateVersion = "23.05"; - }; - }; -} diff --git a/cmd/fpkg/test/default.nix b/cmd/fpkg/test/default.nix deleted file mode 100644 index dcd2cc66..00000000 --- a/cmd/fpkg/test/default.nix +++ /dev/null @@ -1,34 +0,0 @@ -{ - nixosTest, - callPackage, - - system, - self, -}: -let - buildPackage = self.buildPackage.${system}; -in -nixosTest { - name = "fpkg"; - nodes.machine = { - environment.etc = { - "foot.pkg".source = callPackage ./foot.nix { inherit buildPackage; }; - }; - - imports = [ - ./configuration.nix - - self.nixosModules.hakurei - self.inputs.home-manager.nixosModules.home-manager - ]; - }; - - # adapted from nixos sway integration tests - - # testScriptWithTypes:49: error: Cannot call function of unknown type - # (machine.succeed if succeed else machine.execute)( - # ^ - # Found 1 error in 1 file (checked 1 source file) - skipTypeCheck = true; - testScript = builtins.readFile ./test.py; -} diff --git a/cmd/fpkg/test/foot.nix b/cmd/fpkg/test/foot.nix deleted file mode 100644 index 76b677ac..00000000 --- a/cmd/fpkg/test/foot.nix +++ /dev/null @@ -1,48 +0,0 @@ -{ - lib, - buildPackage, - foot, - wayland-utils, - inconsolata, -}: - -buildPackage { - name = "foot"; - inherit (foot) version; - - identity = 2; - id = "org.codeberg.dnkl.foot"; - - modules = [ - { - home.packages = [ - foot - - # For wayland-info: - wayland-utils - ]; - } - ]; - - nixosModules = [ - { - # To help with OCR: - environment.etc."xdg/foot/foot.ini".text = lib.generators.toINI { } { - main = { - font = "inconsolata:size=14"; - }; - colors = rec { - foreground = "000000"; - background = "ffffff"; - regular2 = foreground; - }; - }; - - fonts.packages = [ inconsolata ]; - } - ]; - - script = '' - exec foot "$@" - ''; -} diff --git a/cmd/fpkg/test/test.py b/cmd/fpkg/test/test.py deleted file mode 100644 index 9b9dfc16..00000000 --- a/cmd/fpkg/test/test.py +++ /dev/null @@ -1,108 +0,0 @@ -import json -import shlex - -q = shlex.quote -NODE_GROUPS = ["nodes", "floating_nodes"] - - -def swaymsg(command: str = "", succeed=True, type="command"): - assert command != "" or type != "command", "Must specify command or type" - shell = q(f"swaymsg -t {q(type)} -- {q(command)}") - with machine.nested( - f"sending swaymsg {shell!r}" + " (allowed to fail)" * (not succeed) - ): - ret = (machine.succeed if succeed else machine.execute)( - f"su - alice -c {shell}" - ) - - # execute also returns a status code, but disregard. - if not succeed: - _, ret = ret - - if not succeed and not ret: - return None - - parsed = json.loads(ret) - return parsed - - -def walk(tree): - yield tree - for group in NODE_GROUPS: - for node in tree.get(group, []): - yield from walk(node) - - -def wait_for_window(pattern): - def func(last_chance): - nodes = (node["name"] for node in walk(swaymsg(type="get_tree"))) - - if last_chance: - nodes = list(nodes) - machine.log(f"Last call! Current list of windows: {nodes}") - - return any(pattern in name for name in nodes) - - retry(func) - - -def collect_state_ui(name): - swaymsg(f"exec hakurei ps > '/tmp/{name}.ps'") - machine.copy_from_vm(f"/tmp/{name}.ps", "") - swaymsg(f"exec hakurei --json ps > '/tmp/{name}.json'") - machine.copy_from_vm(f"/tmp/{name}.json", "") - machine.screenshot(name) - - -def check_state(name, enablements): - instances = json.loads(machine.succeed("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 hakurei --json ps")) - if len(instances) != 1: - raise Exception(f"unexpected state length {len(instances)}") - instance = next(iter(instances.values())) - - config = instance['config'] - - if len(config['args']) != 1 or not (config['args'][0].startswith("/nix/store/")) or f"hakurei-{name}-" not in (config['args'][0]): - raise Exception(f"unexpected args {instance['config']['args']}") - - if config['enablements'] != enablements: - raise Exception(f"unexpected enablements {instance['config']['enablements']}") - - -start_all() -machine.wait_for_unit("multi-user.target") - -# To check hakurei's version: -print(machine.succeed("sudo -u alice -i hakurei version")) - -# Wait for Sway to complete startup: -machine.wait_for_file("/run/user/1000/wayland-1") -machine.wait_for_file("/tmp/sway-ipc.sock") - -# Prepare fpkg directory: -machine.succeed("install -dm 0700 -o alice -g users /var/lib/hakurei/1000") - -# Install fpkg app: -swaymsg("exec fpkg -v install /etc/foot.pkg && touch /tmp/fpkg-install-done") -machine.wait_for_file("/tmp/fpkg-install-done") - -# Start app (foot) with Wayland enablement: -swaymsg("exec fpkg -v start org.codeberg.dnkl.foot") -wait_for_window("hakurei@machine-foot") -machine.send_chars("clear; wayland-info && touch /tmp/success-client\n") -machine.wait_for_file("/tmp/hakurei.1000/tmpdir/2/success-client") -collect_state_ui("app_wayland") -check_state("foot", 13) -# Verify acl on XDG_RUNTIME_DIR: -print(machine.succeed("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000002")) -machine.send_chars("exit\n") -machine.wait_until_fails("pgrep foot") -# Verify acl cleanup on XDG_RUNTIME_DIR: -machine.wait_until_fails("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000002") - -# Exit Sway and verify process exit status 0: -swaymsg("exit", succeed=False) -machine.wait_for_file("/tmp/sway-exit-ok") - -# Print hakurei runDir contents: -print(machine.succeed("find /run/user/1000/hakurei")) \ No newline at end of file diff --git a/cmd/fpkg/with.go b/cmd/fpkg/with.go deleted file mode 100644 index ffacec71..00000000 --- a/cmd/fpkg/with.go +++ /dev/null @@ -1,114 +0,0 @@ -package main - -import ( - "context" - "path" - "strings" - - "git.gensokyo.uk/security/hakurei/hst" - "git.gensokyo.uk/security/hakurei/internal" - "git.gensokyo.uk/security/hakurei/sandbox/seccomp" -) - -func withNixDaemon( - ctx context.Context, - action string, command []string, net bool, updateConfig func(config *hst.Config) *hst.Config, - app *appInfo, pathSet *appPathSet, dropShell bool, beforeFail func(), -) { - mustRunAppDropShell(ctx, updateConfig(&hst.Config{ - ID: app.ID, - - Path: shellPath, - Args: []string{shellPath, "-lc", "rm -f /nix/var/nix/daemon-socket/socket && " + - // start nix-daemon - "nix-daemon --store / & " + - // wait for socket to appear - "(while [ ! -S /nix/var/nix/daemon-socket/socket ]; do sleep 0.01; done) && " + - // create directory so nix stops complaining - "mkdir -p /nix/var/nix/profiles/per-user/root/channels && " + - strings.Join(command, " && ") + - // terminate nix-daemon - " && pkill nix-daemon", - }, - - Username: "hakurei", - Shell: shellPath, - Data: pathSet.homeDir, - Dir: path.Join("/data/data", app.ID), - ExtraPerms: []*hst.ExtraPermConfig{ - {Path: dataHome, Execute: true}, - {Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true}, - }, - - Identity: app.Identity, - - Container: &hst.ContainerConfig{ - Hostname: formatHostname(app.Name) + "-" + action, - Userns: true, // nix sandbox requires userns - Net: net, - Seccomp: seccomp.FilterMultiarch, - Tty: dropShell, - Filesystem: []*hst.FilesystemConfig{ - {Src: pathSet.nixPath, Dst: "/nix", Write: true, Must: true}, - }, - Link: [][2]string{ - {app.CurrentSystem, "/run/current-system"}, - {"/run/current-system/sw/bin", "/bin"}, - {"/run/current-system/sw/bin", "/usr/bin"}, - }, - Etc: path.Join(pathSet.cacheDir, "etc"), - AutoEtc: true, - }, - }), dropShell, beforeFail) -} - -func withCacheDir( - ctx context.Context, - action string, command []string, workDir string, - app *appInfo, pathSet *appPathSet, dropShell bool, beforeFail func()) { - mustRunAppDropShell(ctx, &hst.Config{ - ID: app.ID, - - Path: shellPath, - Args: []string{shellPath, "-lc", strings.Join(command, " && ")}, - - Username: "nixos", - Shell: shellPath, - Data: pathSet.cacheDir, // this also ensures cacheDir via shim - Dir: path.Join("/data/data", app.ID, "cache"), - ExtraPerms: []*hst.ExtraPermConfig{ - {Path: dataHome, Execute: true}, - {Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true}, - {Path: workDir, Execute: true}, - }, - - Identity: app.Identity, - - Container: &hst.ContainerConfig{ - Hostname: formatHostname(app.Name) + "-" + action, - Seccomp: seccomp.FilterMultiarch, - Tty: dropShell, - Filesystem: []*hst.FilesystemConfig{ - {Src: path.Join(workDir, "nix"), Dst: "/nix", Must: true}, - {Src: workDir, Dst: path.Join(hst.Tmp, "bundle"), Must: true}, - }, - Link: [][2]string{ - {app.CurrentSystem, "/run/current-system"}, - {"/run/current-system/sw/bin", "/bin"}, - {"/run/current-system/sw/bin", "/usr/bin"}, - }, - Etc: path.Join(workDir, "etc"), - AutoEtc: true, - }, - }, dropShell, beforeFail) -} - -func mustRunAppDropShell(ctx context.Context, config *hst.Config, dropShell bool, beforeFail func()) { - if dropShell { - config.Args = []string{shellPath, "-l"} - mustRunApp(ctx, config, beforeFail) - beforeFail() - internal.Exit(0) - } - mustRunApp(ctx, config, beforeFail) -} diff --git a/cmd/hsu/main.go b/cmd/hsu/main.go index cefcad48..28571b9b 100644 --- a/cmd/hsu/main.go +++ b/cmd/hsu/main.go @@ -41,7 +41,7 @@ func main() { log.Fatalf("cannot read parent executable path: %v", err) } else if strings.HasSuffix(p, " (deleted)") { log.Fatal("hakurei executable has been deleted") - } else if p != mustCheckPath(hmain) && p != mustCheckPath(fpkg) { + } else if p != mustCheckPath(hmain) { log.Fatal("this program must be started by hakurei") } else { toolPath = p diff --git a/cmd/hsu/package.nix b/cmd/hsu/package.nix index 96ec5630..5fce566b 100644 --- a/cmd/hsu/package.nix +++ b/cmd/hsu/package.nix @@ -16,15 +16,8 @@ buildGoModule { go mod init hsu >& /dev/null ''; - ldflags = - lib.attrsets.foldlAttrs - ( - ldflags: name: value: - ldflags ++ [ "-X main.${name}=${value}" ] - ) - [ "-s -w" ] - { - hmain = "${hakurei}/libexec/hakurei"; - fpkg = "${hakurei}/libexec/fpkg"; - }; + ldflags = lib.attrsets.foldlAttrs ( + ldflags: name: value: + ldflags ++ [ "-X main.${name}=${value}" ] + ) [ "-s -w" ] { hmain = "${hakurei}/libexec/hakurei"; }; } diff --git a/cmd/hsu/path.go b/cmd/hsu/path.go index b4a7756a..53aa9d10 100644 --- a/cmd/hsu/path.go +++ b/cmd/hsu/path.go @@ -9,7 +9,6 @@ const compPoison = "INVALIDINVALIDINVALIDINVALIDINVALID" var ( hmain = compPoison - fpkg = compPoison ) func mustCheckPath(p string) string { diff --git a/cmd/planterette/app.go b/cmd/planterette/app.go new file mode 100644 index 00000000..257956d9 --- /dev/null +++ b/cmd/planterette/app.go @@ -0,0 +1,154 @@ +package main + +import ( + "encoding/json" + "log" + "os" + "path" + + "git.gensokyo.uk/security/hakurei/dbus" + "git.gensokyo.uk/security/hakurei/hst" + "git.gensokyo.uk/security/hakurei/sandbox/seccomp" + "git.gensokyo.uk/security/hakurei/system" +) + +type appInfo struct { + Name string `json:"name"` + Version string `json:"version"` + + // passed through to [hst.Config] + ID string `json:"id"` + // passed through to [hst.Config] + Identity int `json:"identity"` + // passed through to [hst.Config] + Groups []string `json:"groups,omitempty"` + // passed through to [hst.Config] + Devel bool `json:"devel,omitempty"` + // passed through to [hst.Config] + Userns bool `json:"userns,omitempty"` + // passed through to [hst.Config] + Net bool `json:"net,omitempty"` + // passed through to [hst.Config] + Device bool `json:"dev,omitempty"` + // passed through to [hst.Config] + Tty bool `json:"tty,omitempty"` + // passed through to [hst.Config] + MapRealUID bool `json:"map_real_uid,omitempty"` + // passed through to [hst.Config] + DirectWayland bool `json:"direct_wayland,omitempty"` + // passed through to [hst.Config] + SystemBus *dbus.Config `json:"system_bus,omitempty"` + // passed through to [hst.Config] + SessionBus *dbus.Config `json:"session_bus,omitempty"` + // passed through to [hst.Config] + Enablements system.Enablement `json:"enablements"` + + // passed through to [hst.Config] + Multiarch bool `json:"multiarch,omitempty"` + // passed through to [hst.Config] + Bluetooth bool `json:"bluetooth,omitempty"` + + // allow gpu access within sandbox + GPU bool `json:"gpu"` + // store path to nixGL mesa wrappers + Mesa string `json:"mesa,omitempty"` + // store path to nixGL source + NixGL string `json:"nix_gl,omitempty"` + // store path to activate-and-exec script + Launcher string `json:"launcher"` + // store path to /run/current-system + CurrentSystem string `json:"current_system"` + // store path to home-manager activation package + ActivationPackage string `json:"activation_package"` +} + +func (app *appInfo) toFst(pathSet *appPathSet, argv []string, flagDropShell bool) *hst.Config { + config := &hst.Config{ + ID: app.ID, + + Path: argv[0], + Args: argv, + + Enablements: app.Enablements, + + SystemBus: app.SystemBus, + SessionBus: app.SessionBus, + DirectWayland: app.DirectWayland, + + Username: "hakurei", + Shell: shellPath, + Data: pathSet.homeDir, + Dir: path.Join("/data/data", app.ID), + + Identity: app.Identity, + Groups: app.Groups, + + Container: &hst.ContainerConfig{ + Hostname: formatHostname(app.Name), + Devel: app.Devel, + Userns: app.Userns, + Net: app.Net, + Device: app.Device, + Tty: app.Tty || flagDropShell, + MapRealUID: app.MapRealUID, + Filesystem: []*hst.FilesystemConfig{ + {Src: path.Join(pathSet.nixPath, "store"), Dst: "/nix/store", Must: true}, + {Src: pathSet.metaPath, Dst: path.Join(hst.Tmp, "app"), Must: true}, + {Src: "/etc/resolv.conf"}, + {Src: "/sys/block"}, + {Src: "/sys/bus"}, + {Src: "/sys/class"}, + {Src: "/sys/dev"}, + {Src: "/sys/devices"}, + }, + Link: [][2]string{ + {app.CurrentSystem, "/run/current-system"}, + {"/run/current-system/sw/bin", "/bin"}, + {"/run/current-system/sw/bin", "/usr/bin"}, + }, + Etc: path.Join(pathSet.cacheDir, "etc"), + AutoEtc: true, + }, + ExtraPerms: []*hst.ExtraPermConfig{ + {Path: dataHome, Execute: true}, + {Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true}, + }, + } + if app.Multiarch { + config.Container.Seccomp |= seccomp.FilterMultiarch + } + if app.Bluetooth { + config.Container.Seccomp |= seccomp.FilterBluetooth + } + return config +} + +func loadAppInfo(name string, beforeFail func()) *appInfo { + bundle := new(appInfo) + if f, err := os.Open(name); err != nil { + beforeFail() + log.Fatalf("cannot open bundle: %v", err) + } else if err = json.NewDecoder(f).Decode(&bundle); err != nil { + beforeFail() + log.Fatalf("cannot parse bundle metadata: %v", err) + } else if err = f.Close(); err != nil { + log.Printf("cannot close bundle metadata: %v", err) + // not fatal + } + + if bundle.ID == "" { + beforeFail() + log.Fatal("application identifier must not be empty") + } + + return bundle +} + +func formatHostname(name string) string { + if h, err := os.Hostname(); err != nil { + log.Printf("cannot get hostname: %v", err) + return "hakurei-" + name + } else { + return h + "-" + name + } +} diff --git a/cmd/planterette/build.nix b/cmd/planterette/build.nix new file mode 100644 index 00000000..b5c50801 --- /dev/null +++ b/cmd/planterette/build.nix @@ -0,0 +1,252 @@ +{ + nixpkgsFor, + system, + nixpkgs, + home-manager, +}: + +{ + lib, + stdenv, + closureInfo, + writeScript, + runtimeShell, + writeText, + symlinkJoin, + vmTools, + runCommand, + fetchFromGitHub, + + zstd, + nix, + sqlite, + + name ? throw "name is required", + version ? throw "version is required", + pname ? "${name}-${version}", + modules ? [ ], + nixosModules ? [ ], + script ? '' + exec "$SHELL" "$@" + '', + + id ? name, + identity ? throw "identity is required", + groups ? [ ], + userns ? false, + net ? true, + dev ? false, + no_new_session ? false, + map_real_uid ? false, + direct_wayland ? false, + system_bus ? null, + session_bus ? null, + + allow_wayland ? true, + allow_x11 ? false, + allow_dbus ? true, + allow_pulse ? true, + gpu ? allow_wayland || allow_x11, +}: + +let + inherit (lib) optionals; + + homeManagerConfiguration = home-manager.lib.homeManagerConfiguration { + pkgs = nixpkgsFor.${system}; + modules = modules ++ [ + { + home = { + username = "hakurei"; + homeDirectory = "/data/data/${id}"; + stateVersion = "22.11"; + }; + } + ]; + }; + + launcher = writeScript "hakurei-${pname}" '' + #!${runtimeShell} -el + ${script} + ''; + + extraNixOSConfig = + { pkgs, ... }: + { + environment = { + etc.nixpkgs.source = nixpkgs.outPath; + systemPackages = [ pkgs.nix ]; + }; + + imports = nixosModules; + }; + nixos = nixpkgs.lib.nixosSystem { + inherit system; + modules = [ + extraNixOSConfig + { nix.settings.experimental-features = [ "flakes" ]; } + { nix.settings.experimental-features = [ "nix-command" ]; } + { boot.isContainer = true; } + { system.stateVersion = "22.11"; } + ]; + }; + + etc = vmTools.runInLinuxVM ( + runCommand "etc" { } '' + mkdir -p /etc + ${nixos.config.system.build.etcActivationCommands} + + # remove unused files + rm -rf /etc/sudoers + + mkdir -p $out + tar -C /etc -cf "$out/etc.tar" . + '' + ); + + extendSessionDefault = id: ext: { + filter = true; + + talk = [ "org.freedesktop.Notifications" ] ++ ext.talk; + own = + (optionals (id != null) [ + "${id}.*" + "org.mpris.MediaPlayer2.${id}.*" + ]) + ++ ext.own; + + inherit (ext) call broadcast; + }; + + nixGL = fetchFromGitHub { + owner = "nix-community"; + repo = "nixGL"; + rev = "310f8e49a149e4c9ea52f1adf70cdc768ec53f8a"; + hash = "sha256-lnzZQYG0+EXl/6NkGpyIz+FEOc/DSEG57AP1VsdeNrM="; + }; + + mesaWrappers = + let + isIntelX86Platform = system == "x86_64-linux"; + nixGLPackages = import (nixGL + "/default.nix") { + pkgs = nixpkgs.legacyPackages.${system}; + enable32bits = isIntelX86Platform; + enableIntelX86Extensions = isIntelX86Platform; + }; + in + symlinkJoin { + name = "nixGL-mesa"; + paths = with nixGLPackages; [ + nixGLIntel + nixVulkanIntel + ]; + }; + + info = builtins.toJSON { + inherit + name + version + id + identity + launcher + groups + userns + net + dev + no_new_session + map_real_uid + direct_wayland + system_bus + gpu + ; + + session_bus = + if session_bus != null then + (session_bus (extendSessionDefault id)) + else + (extendSessionDefault id { + talk = [ ]; + own = [ ]; + call = { }; + broadcast = { }; + }); + + enablements = (if allow_wayland then 1 else 0) + (if allow_x11 then 2 else 0) + (if allow_dbus then 4 else 0) + (if allow_pulse then 8 else 0); + + mesa = if gpu then mesaWrappers else null; + nix_gl = if gpu then nixGL else null; + current_system = nixos.config.system.build.toplevel; + activation_package = homeManagerConfiguration.activationPackage; + }; +in + +stdenv.mkDerivation { + name = "${pname}.pkg"; + inherit version; + __structuredAttrs = true; + + nativeBuildInputs = [ + zstd + nix + sqlite + ]; + + buildCommand = '' + NIX_ROOT="$(mktemp -d)" + export USER="nobody" + + # create bootstrap store + bootstrapClosureInfo="${ + closureInfo { + rootPaths = [ + nix + nixos.config.system.build.toplevel + ]; + } + }" + echo "copying bootstrap store paths..." + mkdir -p "$NIX_ROOT/nix/store" + xargs -n 1 -a "$bootstrapClosureInfo/store-paths" cp -at "$NIX_ROOT/nix/store/" + NIX_REMOTE="local?root=$NIX_ROOT" nix-store --load-db < "$bootstrapClosureInfo/registration" + NIX_REMOTE="local?root=$NIX_ROOT" nix-store --optimise + sqlite3 "$NIX_ROOT/nix/var/nix/db/db.sqlite" "UPDATE ValidPaths SET registrationTime = ''${SOURCE_DATE_EPOCH}" + chmod -R +r "$NIX_ROOT/nix/var" + + # create binary cache + closureInfo="${ + closureInfo { + rootPaths = + [ + homeManagerConfiguration.activationPackage + launcher + ] + ++ optionals gpu [ + mesaWrappers + nixGL + ]; + } + }" + echo "copying application paths..." + TMP_STORE="$(mktemp -d)" + mkdir -p "$TMP_STORE/nix/store" + xargs -n 1 -a "$closureInfo/store-paths" cp -at "$TMP_STORE/nix/store/" + NIX_REMOTE="local?root=$TMP_STORE" nix-store --load-db < "$closureInfo/registration" + sqlite3 "$TMP_STORE/nix/var/nix/db/db.sqlite" "UPDATE ValidPaths SET registrationTime = ''${SOURCE_DATE_EPOCH}" + NIX_REMOTE="local?root=$TMP_STORE" nix --offline --extra-experimental-features nix-command \ + --verbose --log-format raw-with-logs \ + copy --all --no-check-sigs --to \ + "file://$NIX_ROOT/res?compression=zstd&compression-level=19¶llel-compression=true" + + # package /etc + mkdir -p "$NIX_ROOT/etc" + tar -C "$NIX_ROOT/etc" -xf "${etc}/etc.tar" + + # write metadata + cp "${writeText "bundle.json" info}" "$NIX_ROOT/bundle.json" + + # create an intermediate file to improve zstd performance + INTER="$(mktemp)" + tar -C "$NIX_ROOT" -cf "$INTER" . + zstd -T0 -19 -fo "$out" "$INTER" + ''; +} diff --git a/cmd/planterette/main.go b/cmd/planterette/main.go new file mode 100644 index 00000000..8988d27a --- /dev/null +++ b/cmd/planterette/main.go @@ -0,0 +1,333 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "log" + "os" + "os/signal" + "path" + "syscall" + + "git.gensokyo.uk/security/hakurei/command" + "git.gensokyo.uk/security/hakurei/hst" + "git.gensokyo.uk/security/hakurei/internal" + "git.gensokyo.uk/security/hakurei/internal/hlog" +) + +const shellPath = "/run/current-system/sw/bin/bash" + +var ( + errSuccess = errors.New("success") +) + +func init() { + hlog.Prepare("planterette") + if err := os.Setenv("SHELL", shellPath); err != nil { + log.Fatalf("cannot set $SHELL: %v", err) + } +} + +func main() { + if os.Geteuid() == 0 { + log.Fatal("this program must not run as root") + } + + ctx, stop := signal.NotifyContext(context.Background(), + syscall.SIGINT, syscall.SIGTERM) + defer stop() // unreachable + + var ( + flagVerbose bool + flagDropShell bool + ) + c := command.New(os.Stderr, log.Printf, "planterette", func([]string) error { internal.InstallFmsg(flagVerbose); return nil }). + Flag(&flagVerbose, "v", command.BoolFlag(false), "Print debug messages to the console"). + Flag(&flagDropShell, "s", command.BoolFlag(false), "Drop to a shell in place of next hakurei action") + + { + var ( + flagDropShellActivate bool + ) + c.NewCommand("install", "Install an application from its package", func(args []string) error { + if len(args) != 1 { + log.Println("invalid argument") + return syscall.EINVAL + } + pkgPath := args[0] + if !path.IsAbs(pkgPath) { + if dir, err := os.Getwd(); err != nil { + log.Printf("cannot get current directory: %v", err) + return err + } else { + pkgPath = path.Join(dir, pkgPath) + } + } + + /* + Look up paths to programs started by planterette. + This is done here to ease error handling as cleanup is not yet required. + */ + + var ( + _ = lookPath("zstd") + tar = lookPath("tar") + chmod = lookPath("chmod") + rm = lookPath("rm") + ) + + /* + Extract package and set up for cleanup. + */ + + var workDir string + if p, err := os.MkdirTemp("", "planterette.*"); err != nil { + log.Printf("cannot create temporary directory: %v", err) + return 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) + + /* + Parse bundle and app metadata, do pre-install checks. + */ + + bundle := loadAppInfo(path.Join(workDir, "bundle.json"), cleanup) + pathSet := pathSetByApp(bundle.ID) + + a := bundle + if s, err := os.Stat(pathSet.metaPath); err != nil { + if !os.IsNotExist(err) { + cleanup() + log.Printf("cannot access %q: %v", pathSet.metaPath, err) + return err + } + // did not modify app, clean installation condition met later + } else if s.IsDir() { + cleanup() + log.Printf("metadata path %q is not a file", pathSet.metaPath) + return syscall.EBADMSG + } else { + a = loadAppInfo(pathSet.metaPath, cleanup) + if a.ID != bundle.ID { + cleanup() + log.Printf("app %q claims to have identifier %q", + bundle.ID, a.ID) + return syscall.EBADE + } + // sec: should verify credentials + } + + if a != bundle { + // do not try to re-install + if a.NixGL == bundle.NixGL && + a.CurrentSystem == bundle.CurrentSystem && + a.Launcher == bundle.Launcher && + a.ActivationPackage == bundle.ActivationPackage { + cleanup() + log.Printf("package %q is identical to local application %q", + pkgPath, a.ID) + return errSuccess + } + + // identity determines uid + if a.Identity != bundle.Identity { + cleanup() + log.Printf("package %q identity %d differs from installed %d", + pkgPath, bundle.Identity, a.Identity) + return syscall.EBADE + } + + // sec: should compare version string + hlog.Verbosef("installing application %q version %q over local %q", + bundle.ID, bundle.Version, a.Version) + } else { + hlog.Verbosef("application %q clean installation", bundle.ID) + // sec: should install credentials + } + + /* + Setup steps for files owned by the target user. + */ + + withCacheDir(ctx, "install", []string{ + // export inner bundle path in the environment + "export BUNDLE=" + hst.Tmp + "/bundle", + // replace inner /etc + "mkdir -p etc", + "chmod -R +w etc", + "rm -rf etc", + "cp -dRf $BUNDLE/etc etc", + // replace inner /nix + "mkdir -p nix", + "chmod -R +w nix", + "rm -rf nix", + "cp -dRf /nix nix", + // copy from binary cache + "nix copy --offline --no-check-sigs --all --from file://$BUNDLE/res --to $PWD", + // deduplicate nix store + "nix store --offline --store $PWD optimise", + // make cache directory world-readable for autoetc + "chmod 0755 .", + }, workDir, bundle, pathSet, flagDropShell, cleanup) + + if bundle.GPU { + withCacheDir(ctx, "mesa-wrappers", []string{ + // link nixGL mesa wrappers + "mkdir -p nix/.nixGL", + "ln -s " + bundle.Mesa + "/bin/nixGLIntel nix/.nixGL/nixGL", + "ln -s " + bundle.Mesa + "/bin/nixVulkanIntel nix/.nixGL/nixVulkan", + }, workDir, bundle, pathSet, false, cleanup) + } + + /* + Activate home-manager generation. + */ + + withNixDaemon(ctx, "activate", []string{ + // clean up broken links + "mkdir -p .local/state/{nix,home-manager}", + "chmod -R +w .local/state/{nix,home-manager}", + "rm -rf .local/state/{nix,home-manager}", + // run activation script + bundle.ActivationPackage + "/activate", + }, false, func(config *hst.Config) *hst.Config { return config }, + bundle, pathSet, flagDropShellActivate, cleanup) + + /* + Installation complete. Write metadata to block re-installs or downgrades. + */ + + // serialise metadata to ensure consistency + if f, err := os.OpenFile(pathSet.metaPath+"~", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644); err != nil { + cleanup() + log.Printf("cannot create metadata file: %v", err) + return err + } else if err = json.NewEncoder(f).Encode(bundle); err != nil { + cleanup() + log.Printf("cannot write metadata: %v", err) + return err + } else if err = f.Close(); err != nil { + log.Printf("cannot close metadata file: %v", err) + // not fatal + } + + if err := os.Rename(pathSet.metaPath+"~", pathSet.metaPath); err != nil { + cleanup() + log.Printf("cannot rename metadata file: %v", err) + return err + } + + cleanup() + return errSuccess + }). + Flag(&flagDropShellActivate, "s", command.BoolFlag(false), "Drop to a shell on activation") + } + + { + var ( + flagDropShellNixGL bool + flagAutoDrivers bool + ) + c.NewCommand("start", "Start an application", func(args []string) error { + if len(args) < 1 { + log.Println("invalid argument") + return syscall.EINVAL + } + + /* + Parse app metadata. + */ + + id := args[0] + pathSet := pathSetByApp(id) + a := loadAppInfo(pathSet.metaPath, func() {}) + if a.ID != id { + log.Printf("app %q claims to have identifier %q", id, a.ID) + return syscall.EBADE + } + + /* + Prepare nixGL. + */ + + if a.GPU && flagAutoDrivers { + withNixDaemon(ctx, "nix-gl", []string{ + "mkdir -p /nix/.nixGL/auto", + "rm -rf /nix/.nixGL/auto", + "export NIXPKGS_ALLOW_UNFREE=1", + "nix build --impure " + + "--out-link /nix/.nixGL/auto/opengl " + + "--override-input nixpkgs path:/etc/nixpkgs " + + "path:" + a.NixGL, + "nix build --impure " + + "--out-link /nix/.nixGL/auto/vulkan " + + "--override-input nixpkgs path:/etc/nixpkgs " + + "path:" + a.NixGL + "#nixVulkanNvidia", + }, true, func(config *hst.Config) *hst.Config { + config.Container.Filesystem = append(config.Container.Filesystem, []*hst.FilesystemConfig{ + {Src: "/etc/resolv.conf"}, + {Src: "/sys/block"}, + {Src: "/sys/bus"}, + {Src: "/sys/class"}, + {Src: "/sys/dev"}, + {Src: "/sys/devices"}, + }...) + appendGPUFilesystem(config) + return config + }, a, pathSet, flagDropShellNixGL, func() {}) + } + + /* + Create app configuration. + */ + + argv := make([]string, 1, len(args)) + if !flagDropShell { + argv[0] = a.Launcher + } else { + argv[0] = shellPath + } + argv = append(argv, args[1:]...) + + config := a.toFst(pathSet, argv, flagDropShell) + + /* + Expose GPU devices. + */ + + if a.GPU { + config.Container.Filesystem = append(config.Container.Filesystem, + &hst.FilesystemConfig{Src: path.Join(pathSet.nixPath, ".nixGL"), Dst: path.Join(hst.Tmp, "nixGL")}) + appendGPUFilesystem(config) + } + + /* + Spawn app. + */ + + mustRunApp(ctx, config, func() {}) + return errSuccess + }). + Flag(&flagDropShellNixGL, "s", command.BoolFlag(false), "Drop to a shell on nixGL build"). + Flag(&flagAutoDrivers, "auto-drivers", command.BoolFlag(false), "Attempt automatic opengl driver detection") + } + + c.MustParse(os.Args[1:], func(err error) { + hlog.Verbosef("command returned %v", err) + if errors.Is(err, errSuccess) { + hlog.BeforeExit() + os.Exit(0) + } + }) + log.Fatal("unreachable") +} diff --git a/cmd/planterette/paths.go b/cmd/planterette/paths.go new file mode 100644 index 00000000..aa581c9e --- /dev/null +++ b/cmd/planterette/paths.go @@ -0,0 +1,101 @@ +package main + +import ( + "log" + "os" + "os/exec" + "path" + "strconv" + "sync/atomic" + + "git.gensokyo.uk/security/hakurei/hst" + "git.gensokyo.uk/security/hakurei/internal/hlog" +) + +var ( + dataHome string +) + +func init() { + // dataHome + if p, ok := os.LookupEnv("HAKUREI_DATA_HOME"); ok { + dataHome = p + } else { + dataHome = "/var/lib/hakurei/" + strconv.Itoa(os.Getuid()) + } +} + +func lookPath(file string) string { + if p, err := exec.LookPath(file); err != nil { + log.Fatalf("%s: command not found", file) + return "" + } else { + return p + } +} + +var beforeRunFail = new(atomic.Pointer[func()]) + +func mustRun(name string, arg ...string) { + hlog.Verbosef("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)() + } + log.Fatalf("%s: %v", name, err) + } +} + +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 +} + +func appendGPUFilesystem(config *hst.Config) { + config.Container.Filesystem = append(config.Container.Filesystem, []*hst.FilesystemConfig{ + // flatpak commit 763a686d874dd668f0236f911de00b80766ffe79 + {Src: "/dev/dri", Device: true}, + // mali + {Src: "/dev/mali", Device: true}, + {Src: "/dev/mali0", Device: true}, + {Src: "/dev/umplock", Device: true}, + // nvidia + {Src: "/dev/nvidiactl", Device: true}, + {Src: "/dev/nvidia-modeset", Device: true}, + // nvidia OpenCL/CUDA + {Src: "/dev/nvidia-uvm", Device: true}, + {Src: "/dev/nvidia-uvm-tools", Device: true}, + + // flatpak commit d2dff2875bb3b7e2cd92d8204088d743fd07f3ff + {Src: "/dev/nvidia0", Device: true}, {Src: "/dev/nvidia1", Device: true}, + {Src: "/dev/nvidia2", Device: true}, {Src: "/dev/nvidia3", Device: true}, + {Src: "/dev/nvidia4", Device: true}, {Src: "/dev/nvidia5", Device: true}, + {Src: "/dev/nvidia6", Device: true}, {Src: "/dev/nvidia7", Device: true}, + {Src: "/dev/nvidia8", Device: true}, {Src: "/dev/nvidia9", Device: true}, + {Src: "/dev/nvidia10", Device: true}, {Src: "/dev/nvidia11", Device: true}, + {Src: "/dev/nvidia12", Device: true}, {Src: "/dev/nvidia13", Device: true}, + {Src: "/dev/nvidia14", Device: true}, {Src: "/dev/nvidia15", Device: true}, + {Src: "/dev/nvidia16", Device: true}, {Src: "/dev/nvidia17", Device: true}, + {Src: "/dev/nvidia18", Device: true}, {Src: "/dev/nvidia19", Device: true}, + }...) +} diff --git a/cmd/planterette/proc.go b/cmd/planterette/proc.go new file mode 100644 index 00000000..78677fab --- /dev/null +++ b/cmd/planterette/proc.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "io" + "log" + "os" + "os/exec" + + "git.gensokyo.uk/security/hakurei/hst" + "git.gensokyo.uk/security/hakurei/internal" + "git.gensokyo.uk/security/hakurei/internal/hlog" +) + +var hakureiPath = internal.MustHakureiPath() + +func mustRunApp(ctx context.Context, config *hst.Config, beforeFail func()) { + var ( + cmd *exec.Cmd + st io.WriteCloser + ) + + if r, w, err := os.Pipe(); err != nil { + beforeFail() + log.Fatalf("cannot pipe: %v", err) + } else { + if hlog.Load() { + cmd = exec.CommandContext(ctx, hakureiPath, "-v", "app", "3") + } else { + cmd = exec.CommandContext(ctx, hakureiPath, "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() + log.Fatalf("cannot send configuration: %v", err) + } + }() + + if err := cmd.Start(); err != nil { + beforeFail() + log.Fatalf("cannot start hakurei: %v", err) + } + if err := cmd.Wait(); err != nil { + var exitError *exec.ExitError + if errors.As(err, &exitError) { + beforeFail() + internal.Exit(exitError.ExitCode()) + } else { + beforeFail() + log.Fatalf("cannot wait: %v", err) + } + } +} diff --git a/cmd/planterette/test/configuration.nix b/cmd/planterette/test/configuration.nix new file mode 100644 index 00000000..ce74228a --- /dev/null +++ b/cmd/planterette/test/configuration.nix @@ -0,0 +1,62 @@ +{ pkgs, ... }: +{ + users.users = { + alice = { + isNormalUser = true; + description = "Alice Foobar"; + password = "foobar"; + uid = 1000; + }; + }; + + home-manager.users.alice.home.stateVersion = "24.11"; + + # Automatically login on tty1 as a normal user: + services.getty.autologinUser = "alice"; + + environment = { + variables = { + SWAYSOCK = "/tmp/sway-ipc.sock"; + WLR_RENDERER = "pixman"; + }; + }; + + # Automatically configure and start Sway when logging in on tty1: + programs.bash.loginShellInit = '' + if [ "$(tty)" = "/dev/tty1" ]; then + set -e + + mkdir -p ~/.config/sway + (sed s/Mod4/Mod1/ /etc/sway/config && + echo 'output * bg ${pkgs.nixos-artwork.wallpapers.simple-light-gray.gnomeFilePath} fill' && + echo 'output Virtual-1 res 1680x1050') > ~/.config/sway/config + + sway --validate + systemd-cat --identifier=session sway && touch /tmp/sway-exit-ok + fi + ''; + + programs.sway.enable = true; + + virtualisation = { + diskSize = 6 * 1024; + + qemu.options = [ + # Need to switch to a different GPU driver than the default one (-vga std) so that Sway can launch: + "-vga none -device virtio-gpu-pci" + + # Increase zstd performance: + "-smp 8" + ]; + }; + + environment.hakurei = { + enable = true; + stateDir = "/var/lib/hakurei"; + users.alice = 0; + + extraHomeConfig = { + home.stateVersion = "23.05"; + }; + }; +} diff --git a/cmd/planterette/test/default.nix b/cmd/planterette/test/default.nix new file mode 100644 index 00000000..3ff76fcf --- /dev/null +++ b/cmd/planterette/test/default.nix @@ -0,0 +1,34 @@ +{ + nixosTest, + callPackage, + + system, + self, +}: +let + buildPackage = self.buildPackage.${system}; +in +nixosTest { + name = "planterette"; + nodes.machine = { + environment.etc = { + "foot.pkg".source = callPackage ./foot.nix { inherit buildPackage; }; + }; + + imports = [ + ./configuration.nix + + self.nixosModules.hakurei + self.inputs.home-manager.nixosModules.home-manager + ]; + }; + + # adapted from nixos sway integration tests + + # testScriptWithTypes:49: error: Cannot call function of unknown type + # (machine.succeed if succeed else machine.execute)( + # ^ + # Found 1 error in 1 file (checked 1 source file) + skipTypeCheck = true; + testScript = builtins.readFile ./test.py; +} diff --git a/cmd/planterette/test/foot.nix b/cmd/planterette/test/foot.nix new file mode 100644 index 00000000..76b677ac --- /dev/null +++ b/cmd/planterette/test/foot.nix @@ -0,0 +1,48 @@ +{ + lib, + buildPackage, + foot, + wayland-utils, + inconsolata, +}: + +buildPackage { + name = "foot"; + inherit (foot) version; + + identity = 2; + id = "org.codeberg.dnkl.foot"; + + modules = [ + { + home.packages = [ + foot + + # For wayland-info: + wayland-utils + ]; + } + ]; + + nixosModules = [ + { + # To help with OCR: + environment.etc."xdg/foot/foot.ini".text = lib.generators.toINI { } { + main = { + font = "inconsolata:size=14"; + }; + colors = rec { + foreground = "000000"; + background = "ffffff"; + regular2 = foreground; + }; + }; + + fonts.packages = [ inconsolata ]; + } + ]; + + script = '' + exec foot "$@" + ''; +} diff --git a/cmd/planterette/test/test.py b/cmd/planterette/test/test.py new file mode 100644 index 00000000..b551dc7a --- /dev/null +++ b/cmd/planterette/test/test.py @@ -0,0 +1,108 @@ +import json +import shlex + +q = shlex.quote +NODE_GROUPS = ["nodes", "floating_nodes"] + + +def swaymsg(command: str = "", succeed=True, type="command"): + assert command != "" or type != "command", "Must specify command or type" + shell = q(f"swaymsg -t {q(type)} -- {q(command)}") + with machine.nested( + f"sending swaymsg {shell!r}" + " (allowed to fail)" * (not succeed) + ): + ret = (machine.succeed if succeed else machine.execute)( + f"su - alice -c {shell}" + ) + + # execute also returns a status code, but disregard. + if not succeed: + _, ret = ret + + if not succeed and not ret: + return None + + parsed = json.loads(ret) + return parsed + + +def walk(tree): + yield tree + for group in NODE_GROUPS: + for node in tree.get(group, []): + yield from walk(node) + + +def wait_for_window(pattern): + def func(last_chance): + nodes = (node["name"] for node in walk(swaymsg(type="get_tree"))) + + if last_chance: + nodes = list(nodes) + machine.log(f"Last call! Current list of windows: {nodes}") + + return any(pattern in name for name in nodes) + + retry(func) + + +def collect_state_ui(name): + swaymsg(f"exec hakurei ps > '/tmp/{name}.ps'") + machine.copy_from_vm(f"/tmp/{name}.ps", "") + swaymsg(f"exec hakurei --json ps > '/tmp/{name}.json'") + machine.copy_from_vm(f"/tmp/{name}.json", "") + machine.screenshot(name) + + +def check_state(name, enablements): + instances = json.loads(machine.succeed("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 hakurei --json ps")) + if len(instances) != 1: + raise Exception(f"unexpected state length {len(instances)}") + instance = next(iter(instances.values())) + + config = instance['config'] + + if len(config['args']) != 1 or not (config['args'][0].startswith("/nix/store/")) or f"hakurei-{name}-" not in (config['args'][0]): + raise Exception(f"unexpected args {instance['config']['args']}") + + if config['enablements'] != enablements: + raise Exception(f"unexpected enablements {instance['config']['enablements']}") + + +start_all() +machine.wait_for_unit("multi-user.target") + +# To check hakurei's version: +print(machine.succeed("sudo -u alice -i hakurei version")) + +# Wait for Sway to complete startup: +machine.wait_for_file("/run/user/1000/wayland-1") +machine.wait_for_file("/tmp/sway-ipc.sock") + +# Prepare planterette directory: +machine.succeed("install -dm 0700 -o alice -g users /var/lib/hakurei/1000") + +# Install planterette app: +swaymsg("exec planterette -v install /etc/foot.pkg && touch /tmp/planterette-install-ok") +machine.wait_for_file("/tmp/planterette-install-ok") + +# Start app (foot) with Wayland enablement: +swaymsg("exec planterette -v start org.codeberg.dnkl.foot") +wait_for_window("hakurei@machine-foot") +machine.send_chars("clear; wayland-info && touch /tmp/success-client\n") +machine.wait_for_file("/tmp/hakurei.1000/tmpdir/2/success-client") +collect_state_ui("app_wayland") +check_state("foot", 13) +# Verify acl on XDG_RUNTIME_DIR: +print(machine.succeed("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000002")) +machine.send_chars("exit\n") +machine.wait_until_fails("pgrep foot") +# Verify acl cleanup on XDG_RUNTIME_DIR: +machine.wait_until_fails("getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep 1000002") + +# Exit Sway and verify process exit status 0: +swaymsg("exit", succeed=False) +machine.wait_for_file("/tmp/sway-exit-ok") + +# Print hakurei runDir contents: +print(machine.succeed("find /run/user/1000/hakurei")) \ No newline at end of file diff --git a/cmd/planterette/with.go b/cmd/planterette/with.go new file mode 100644 index 00000000..ffacec71 --- /dev/null +++ b/cmd/planterette/with.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + "path" + "strings" + + "git.gensokyo.uk/security/hakurei/hst" + "git.gensokyo.uk/security/hakurei/internal" + "git.gensokyo.uk/security/hakurei/sandbox/seccomp" +) + +func withNixDaemon( + ctx context.Context, + action string, command []string, net bool, updateConfig func(config *hst.Config) *hst.Config, + app *appInfo, pathSet *appPathSet, dropShell bool, beforeFail func(), +) { + mustRunAppDropShell(ctx, updateConfig(&hst.Config{ + ID: app.ID, + + Path: shellPath, + Args: []string{shellPath, "-lc", "rm -f /nix/var/nix/daemon-socket/socket && " + + // start nix-daemon + "nix-daemon --store / & " + + // wait for socket to appear + "(while [ ! -S /nix/var/nix/daemon-socket/socket ]; do sleep 0.01; done) && " + + // create directory so nix stops complaining + "mkdir -p /nix/var/nix/profiles/per-user/root/channels && " + + strings.Join(command, " && ") + + // terminate nix-daemon + " && pkill nix-daemon", + }, + + Username: "hakurei", + Shell: shellPath, + Data: pathSet.homeDir, + Dir: path.Join("/data/data", app.ID), + ExtraPerms: []*hst.ExtraPermConfig{ + {Path: dataHome, Execute: true}, + {Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true}, + }, + + Identity: app.Identity, + + Container: &hst.ContainerConfig{ + Hostname: formatHostname(app.Name) + "-" + action, + Userns: true, // nix sandbox requires userns + Net: net, + Seccomp: seccomp.FilterMultiarch, + Tty: dropShell, + Filesystem: []*hst.FilesystemConfig{ + {Src: pathSet.nixPath, Dst: "/nix", Write: true, Must: true}, + }, + Link: [][2]string{ + {app.CurrentSystem, "/run/current-system"}, + {"/run/current-system/sw/bin", "/bin"}, + {"/run/current-system/sw/bin", "/usr/bin"}, + }, + Etc: path.Join(pathSet.cacheDir, "etc"), + AutoEtc: true, + }, + }), dropShell, beforeFail) +} + +func withCacheDir( + ctx context.Context, + action string, command []string, workDir string, + app *appInfo, pathSet *appPathSet, dropShell bool, beforeFail func()) { + mustRunAppDropShell(ctx, &hst.Config{ + ID: app.ID, + + Path: shellPath, + Args: []string{shellPath, "-lc", strings.Join(command, " && ")}, + + Username: "nixos", + Shell: shellPath, + Data: pathSet.cacheDir, // this also ensures cacheDir via shim + Dir: path.Join("/data/data", app.ID, "cache"), + ExtraPerms: []*hst.ExtraPermConfig{ + {Path: dataHome, Execute: true}, + {Ensure: true, Path: pathSet.baseDir, Read: true, Write: true, Execute: true}, + {Path: workDir, Execute: true}, + }, + + Identity: app.Identity, + + Container: &hst.ContainerConfig{ + Hostname: formatHostname(app.Name) + "-" + action, + Seccomp: seccomp.FilterMultiarch, + Tty: dropShell, + Filesystem: []*hst.FilesystemConfig{ + {Src: path.Join(workDir, "nix"), Dst: "/nix", Must: true}, + {Src: workDir, Dst: path.Join(hst.Tmp, "bundle"), Must: true}, + }, + Link: [][2]string{ + {app.CurrentSystem, "/run/current-system"}, + {"/run/current-system/sw/bin", "/bin"}, + {"/run/current-system/sw/bin", "/usr/bin"}, + }, + Etc: path.Join(workDir, "etc"), + AutoEtc: true, + }, + }, dropShell, beforeFail) +} + +func mustRunAppDropShell(ctx context.Context, config *hst.Config, dropShell bool, beforeFail func()) { + if dropShell { + config.Args = []string{shellPath, "-l"} + mustRunApp(ctx, config, beforeFail) + beforeFail() + internal.Exit(0) + } + mustRunApp(ctx, config, beforeFail) +} -- cgit v1.3.1