diff options
Diffstat (limited to 'cmd')
47 files changed, 4697 insertions, 52 deletions
diff --git a/cmd/app/app.go b/cmd/app/app.go index 9679413f..ca809334 100644 --- a/cmd/app/app.go +++ b/cmd/app/app.go @@ -14,6 +14,24 @@ import ( "hakurei.app/hst" ) +// base is the root of the persistent state directory. +type base check.Absolute + +// append calls filepath.Join with b as the first element. +func (b *base) append(elem ...string) *check.Absolute { + return (*check.Absolute)(b).Append(elem...) +} + +// initial returns the pathname of the bottom layer. +func (b *base) initial() *check.Absolute { + return b.append("initial") +} + +// state returns the pathname of the home directory for id. +func (b *base) state(id string) *check.Absolute { + return b.append("state", id) +} + // parsePair parses a NUL-delimited quoted paths pair. func parsePair(s string) (source, target *check.Absolute, err error) { var p string @@ -35,7 +53,7 @@ func parsePair(s string) (source, target *check.Absolute, err error) { // corresponding [hst.Config]. func parse( id string, - base *check.Absolute, + base *base, r io.Reader, templateP *string, ) (*hst.Config, error) { @@ -44,7 +62,7 @@ func parse( root := hst.FSOverlay{ Target: fhs.AbsRoot, - Lower: []*check.Absolute{base.Append("initial")}, + Lower: []*check.Absolute{base.initial()}, } c := hst.Config{ ID: id, @@ -65,7 +83,7 @@ func parse( {FilesystemConfig: &root}, {FilesystemConfig: &hst.FSBind{ Target: home, - Source: base.Append("state", id), + Source: base.state(id), Write: true, Ensure: true, }}, @@ -110,7 +128,7 @@ func parse( *templateP = template } c.Identity = v - root.Upper = base.Append("template", template) + root.Upper = base.template(template) } if err := scanOnce(); err != nil { diff --git a/cmd/app/app_merged.go b/cmd/app/app_merged.go new file mode 100644 index 00000000..e0a31f94 --- /dev/null +++ b/cmd/app/app_merged.go @@ -0,0 +1,15 @@ +//go:build merge + +package main + +import "hakurei.app/check" + +// template returns the pathname of an upperdir. +func (b *base) template(template string) *check.Absolute { + return b.append("template", template, "upper") +} + +// work returns the pathname of a workdir. +func (b *base) work(template string) *check.Absolute { + return b.append("template", template, "work") +} diff --git a/cmd/app/app_sep.go b/cmd/app/app_sep.go new file mode 100644 index 00000000..9ec98e41 --- /dev/null +++ b/cmd/app/app_sep.go @@ -0,0 +1,15 @@ +//go:build !merge + +package main + +import "hakurei.app/check" + +// template returns the pathname of an upperdir. +func (b *base) template(template string) *check.Absolute { + return b.append("template", template) +} + +// work returns the pathname of a workdir. +func (b *base) work(template string) *check.Absolute { + return b.append("work", template) +} diff --git a/cmd/app/app_test.go b/cmd/app/app_test.go index 1aab4759..90c0068e 100644 --- a/cmd/app/app_test.go +++ b/cmd/app/app_test.go @@ -13,7 +13,7 @@ import ( func TestParse(t *testing.T) { t.Parallel() - base := fhs.AbsProc.Append("nonexistent") + b := (*base)(fhs.AbsProc.Append("nonexistent")) testCases := []struct { name string data string @@ -74,13 +74,13 @@ talk com.canonical.Unity {FilesystemConfig: &hst.FSOverlay{ Target: fhs.AbsRoot, Lower: []*check.Absolute{ - base.Append("initial"), + b.initial(), }, - Upper: base.Append("template", "nonfree"), + Upper: b.template("nonfree"), }}, {FilesystemConfig: &hst.FSBind{ Target: hst.AbsPrivateTmp.Append("home"), - Source: base.Append("state", "com.discordapp.Discord"), + Source: b.state("com.discordapp.Discord"), Write: true, Ensure: true, }}, @@ -128,7 +128,7 @@ talk com.canonical.Unity got, err := parse( tc.name, - base, + b, strings.NewReader(tc.data), nil, ) diff --git a/cmd/app/doc.go b/cmd/app/doc.go new file mode 100644 index 00000000..8c5397fd --- /dev/null +++ b/cmd/app/doc.go @@ -0,0 +1,362 @@ +/* +The app program is a proof-of-concept frontend for cmd/hakurei. + +This program is not covered by the compatibility promise. The command line +interface and configuration syntax may change at any time. + +# Installation + +Compile cmd/app with these arguments: + + go build -trimpath \ + -ldflags='-s -w + -buildid= + -X hakurei.app/internal/info.hsuPath=/usr/local/bin/hsu' \ + ./cmd/app + +Replace /usr/local/bin/hsu with the hakurei installation's absolute hsu +pathname. + +# Sharing files + +Sharing files between apps is only possible with the permissionless shared +filesystem ([cmd/sharefs]), as apps generally do not share credentials. It can +be built without any special linker flags. Create a symlink at +/usr/bin/mount.fuse.sharefs pointing to the sharefs binary when mounting sharefs +via fstab: + + sharefs /sdcard fuse.sharefs rw,noexec,nosuid,nodev,noatime,allow_other,mkdir,source=/var/lib/sdcard,setuid=1023,setgid=1023 0 0 + +Replace /var/lib/sdcard with the location of the backing directory. User and +group id must be specified in numerical form, it must own the backing directory. +The mount point does not have to be /sdcard. + +# General setup + +The entire directory structure containing persistent app data must be created +manually for now, due to the different ownership requirements being impossible +to set up directly through cmd/hsu. + +The environment variable ROSA_APP_PATH should be set to the absolute pathname of +the persistent state directory. This document will use $ROSA_APP_PATH to refer +to the persistent state directory. The $ROSA_APP_PATH directory should be owned +by the user declared in /etc/hsurc and invoking cmd/app, and must be readable +and executable by all subordinate users. This can be done by making it +world-readable and executable, or by adding a group directive to the common +file. + +The $ROSA_APP_PATH/app directory contains app configuration files, named after +their reverse-DNS style application identifier string. It should be owned by +the user invoking cmd/app. + +The $ROSA_APP_PATH/initial directory contains the read-only base of every +app template. Everything within it, including the directory itself, should be +owned by the reserved user, its user and group id obtained by the command: + + app id + +This document assumes a [Void Linux rootfs tarball] is unpacked here. It is +possible to use other Linux distributions; Void linux is selected here because +its package manager works correctly out of the box in the hakurei container +environment. If the use case does not require glibc, [Alpine Linux] or +[Chimera Linux] might be more suitable. + +Once the tarball is unpacked at $ROSA_APP_PATH/initial, create a directory named +chronos in $ROSA_APP_PATH/initial/home, and directories block, bus, class, dev, +and devices in $ROSA_APP_PATH/initial/sys. Then, recursively change ownerships +of files and directories in $ROSA_APP_PATH/initial to the reserved user/group. + +The $ROSA_APP_PATH/lock directory contains lock files guarding entry into +mutable and app containers of every template. It must be owned by the user +invoking cmd/app. + +The $ROSA_APP_PATH/state directory contains app home directories, named after +their reverse-DNS style application identifier string. The directory itself +should be owned by the user invoking cmd/app; each directory inside must be +owned by the subordinate user/group id of its app, obtained by the command: + + app id "$APPID" + +where "$APPID" is the second component of the first line in the configuration +file of the corresponding app. It is considered good practice, but not required, +for these directory to have the permission bits set to 0700. + +The $ROSA_APP_PATH/template directory contains templates that apps are based +on, which are all derived from the initial layer. The directory itself +should be owned by the user invoking cmd/app, and each directory inside should +be owned by the reserved user/group. Their names are used in the first component +of the first line in the configuration file of each app, to specify that the +file is derived from that template. + +The $ROSA_APP_PATH/work directory contains overlay work directories for mutable +containers. It must contain one manually created, empty directory for each +template, named after the template directory itself, owned by the reserved +user/group. The directory itself and its contents should have the permission +bits set to 0700. + +# Snapshots and cloning + +To reduce disk space used by templates, [ZFS clones] or an equivalent can be +used. In such a setup, where each template occupies a different filesystem, the +"merge" build tag is required, and the work directory can be omitted during +directory creation. Instead, the individual template directories contain both +upper and work directories. + +# Configuring the base template + +This section describes basic setup required for the typical desktop use case. +Generally, multiple templates are created to mitigate the global nature of +conventional package managers. The setup described in this section applies to +all templates. For convenience, a "base" template should be created, containing +this setup, and all future templates should be copied from the base template. +This section assumes the template is named "base". + +Create an empty directory at $ROSA_APP_PATH/template/base, owned by the reserved +user, with permission bits set to 0755. Create its corresponding work directory +at $ROSA_APP_PATH/work/base, also owned by the reserved user, with permission +bits set to 0700. Once this is complete, enter its mutable container with the +command: + + app enter --shell=/bin/sh base + +This command overrides the container configuration to use the shell program at +/bin/sh. Normally, cmd/app uses zsh, which is not yet installed. + +Once in the container, populate /etc/resolv.conf with some well-known DNS +service, for example: + + nameserver 8.8.8.8 + nameserver 8.8.4.4 + +The mutable container does not bind the host /etc/resolv.conf. It is generally +recommended to populate it with a well-known service here to avoid trouble in +future template changes. A later section configures regular app containers to +use host configuration. + +After populating nameserver configuration, install zsh using the package manager +provided by the distribution unpacked earlier. On Void Linux, this is done by +the command: + + xbps-install zsh + +Wait for the package installation to complete, configure zsh if necessary, then +exit from the shell, and re-enter the container without overriding the login +shell: + + app enter base + +Some packages are generally required in the container for the typical desktop +use case: xdg-user-dirs to reconfigure "well known" user directories to point +to the inner sharefs mount point, mesa to interact with the GPU, dconf to +configure GTK, xdg-desktop-portal-gtk to have gtk talk to dconf. Fonts generally +need to be installed as well. Additionally, a text editor can be installed for +editing configuration files later on. On Void Linux, these packages are +installed by the command: + + xbps-install \ + vim \ + mesa \ + mesa-dri \ + mesa-intel-dri \ + mesa-vaapi \ + mesa-vulkan-intel \ + mesa-vulkan-radeon \ + mesa-vulkan-overlay-layer \ + dconf \ + xdg-user-dirs \ + xdg-desktop-portal-gtk \ + noto-fonts-ttf \ + noto-fonts-ttf-variable \ + noto-fonts-ttf-extra \ + noto-fonts-emoji \ + noto-fonts-cjk \ + noto-fonts-cjk-variable \ + noto-fonts-cjk-sans \ + noto-fonts-cjk-sans-variable \ + noto-fonts-cjk-serif \ + noto-fonts-cjk-serif-variable + +Add -32bit variants of mesa packages if multilib support is required. Adjust +the package selection based on use case. + +Edit /etc/xdg/user-dirs.defaults and point directories to the inner sharefs +mount point as required. The resulting file should look like this: + + # Default settings for user directories + # + # The values are relative pathnames from the home directory and + # will be translated on a per-path-element basis into the users locale + DESKTOP=../../sdcard/Desktop + DOWNLOAD=../../sdcard/Download + TEMPLATES=../../sdcard/Templates + PUBLICSHARE=../../sdcard/Public + DOCUMENTS=../../sdcard/Documents + MUSIC=../../sdcard/Music + PICTURES=../../sdcard/Pictures + VIDEOS=../../sdcard/Movies + PROJECTS=../../sdcard/Projects + # Another alternative is: + #MUSIC=Documents/Music + #PICTURES=Documents/Pictures + #VIDEOS=Documents/Videos + +If this is configured, it must be applied to the home directory of each app +individually. This can be done for all apps via the shell expression: + + app run | xargs -n 1 echo app run --command=xdg-user-dirs-update + +This must also be done for every newly created app. + +The /etc/dconf directory can be set up to provide defaults across all apps. To +do so, edit /etc/dconf/profile/user: + + user-db:user + system-db:local + system-db:site + system-db:distro + +Then, place files in /etc/dconf/db/local.d containing dconf configuration. For +example: + + [org/gnome/desktop/interface] + gtk-enable-primary-paste=true + color-scheme='prefer-dark' + gtk-theme='adw-gtk3-dark' + icon-theme='Papirus-Dark' + +Themes must be installed in the container. Change colour-scheme and themes +accordingly. Setting gtk-enable-primary-paste restores clipboard behaviour that +GNOME maintainers decided to break. + +After editing these configuration files, update dconf system databases: + + dconf update + +# Common configuration + +The optional $ROSA_APP_PATH/common file contains common configuration included +after the specific configuration of each app. Some bind mounts are required for +almost every graphical program, and many widely used libraries are configured +through the environment. For most setups, these directives are generally +required: + + ; for libudev + ro "/sys/block" + ro "/sys/bus" + ro "/sys/class" + ro "/sys/dev" + ro "/sys/devices" + + env EDITOR=vim + ; for apps to play nice with xdg-dbus-proxy + ro+ "/etc/machine-id" + ; template must have a /etc/resolv.conf file + ro+ "/etc/resolv.conf" + ; group name of the sharefs group configured on host + group media_rw + ; for sharefs + rw "/sdcard" + ; refer to /usr/share/zoneinfo + env TZ=Asia/Tokyo + + ; must be installed first + env XCURSOR_THEME=volantes_cursors + ; must be generated first if using glibc + env LANG=en_GB.UTF-8 + env LC_COLLATE=C + +# Installing an app + +An app is defined by a configuration file in $ROSA_APP_PATH/app, and a +persistent state directory in $ROSA_APP_PATH/state. Before creating an app, its +identity must be decided. Different apps should generally not share an identity. +The next unused identity can be found using the command: + + app next + +If the -v argument is added before the "next" command, cmd/app will additionally +show apps sharing the same identity. + +Once the identity is decided, the subordinate user/group id can be obtained by +the command: + + app id "$APPID" + +where "$APPID" is the identity of this app. A directory must be created under +$ROSA_APP_PATH/state, owned by this user and group id. Its permission bits +should be set to 0700. A configuration file with the same name, owned by the +user invoking cmd/app, must be placed in $ROSA_APP_PATH/app. Its contents are +described in the next section. The reverse-DNS style application identifier +string is submitted to the Wayland display server, and used as part of the +dbus preset if enabled, so the correct identifier must be obtained. If no such +identifier exist, use the domain of the app home page. + +# Configuring an app + +The configuration file starts with two structural directives, and the remaining +lines are freestanding directives applied in order. + +The first structural directive is a line containing two components separated by +the ':' byte. The first component is the template used by the app. the second +component is the decimal representation of the app identity. The second +structural directive is a shell expression passed to the shell serving as the +initial process of the app. + +After the structural directives, each line contains exactly one directive or +comment. Comment lines begin with a ';' byte: these lines are not interpreted by +cmd/app in any way. + +The following section documents currently available freestanding directives. + +# Directives + + interactive start initial process as an interactive shell + gpu expose GPU devices to the container + system_bus enable system bus in the dbus proxy + + wayland expose a Wayland pathname socket via security-context-v1 + x11 expose the X11 pathname socket + dbus enable the per-container xdg-dbus-proxy daemon + pipewire expose a pipewire pathname socket via SecurityContext + + multiarch unblock system calls required for multiarch to work on + multiarch-enabled targets (amd64, arm64) + devel unblock ptrace and friends + userns unblock userns creation and container setup syscalls + net enable network access + abstract enable access to external abstract unix sockets + tty unblock dangerous terminal I/O (faking input) + mapuid map the target user id to the user id of the user + invoking cmd/app in the container user namespace + device mount /dev/ from the init mount namespace as is in the + container mount namespace + + share_runtime share XDG_RUNTIME_DIR between containers under the same + identity + share_tmpdir share TMPDIR between containers under the same identity + + username <name> set username of the emulated user + hostname <name> set container hostname + env KEY=VALUE set an environment variable for the initial process + + ro "pathname" make a host path available to the container; the string + must be presented in Go string literal syntax, to + specify a different inner pathname, end the outer + pathname with NUL and specify the inner pathname after + the NUL byte + rw "pathname" like ro, but the resulting mount entry is made writable + ro+ "pathname" like ro, but is skipped if pathname does not exist + rw+ "pathname" like ro+, but the resulting mount entry is made writable + + own name add an own policy for the dbus proxy + own_system name like own, but for the system bus if enabled + talk name add a talk policy for the dbus proxy + talk_system name like talk, but for the system bus if enabled + +[cmd/sharefs]: https://pkg.go.dev/hakurei.app/cmd/sharefs +[Void Linux rootfs tarball]: https://voidlinux.org/download +[Alpine Linux]: https://alpinelinux.org +[Chimera Linux]: https://chimera-linux.org +[ZFS clones]: https://openzfs.github.io/openzfs-docs/man/v2.4/8/zfs-clone.8.html +*/ +package main diff --git a/cmd/app/lock.go b/cmd/app/lock.go index 88068381..2a419ea1 100644 --- a/cmd/app/lock.go +++ b/cmd/app/lock.go @@ -8,7 +8,6 @@ import ( "strings" "syscall" - "hakurei.app/check" "hakurei.app/fhs" "hakurei.app/hst" "hakurei.app/internal/env" @@ -24,15 +23,15 @@ func (e MutationConflictError) Error() string { } // informTemplate guards intention of a template or its derivatives. -func informTemplate(base *check.Absolute, name string, mutable bool) (func() error, error) { - mu := lockedfile.MutexAt(base.Append("lock", name).String()) +func informTemplate(b *base, name string, mutable bool) (func() error, error) { + mu := lockedfile.MutexAt(b.append("lock", name).String()) if unlock, err := mu.Lock(); err != nil { return nil, err } else { defer unlock() } - marker := base.Append("lock", "."+name) + marker := b.append("lock", "."+name) if p, err := os.ReadFile(marker.String()); err == nil { if _, err = os.Stat(fhs.AbsProc.Append(string(p)).String()); err == nil { return nil, MutationConflictError(p) @@ -72,8 +71,8 @@ func informTemplate(base *check.Absolute, name string, mutable bool) (func() err if !root.Target.Is(fhs.AbsRoot) || len(root.Lower) != 1 || - !root.Lower[0].Is(base.Append("initial")) || - !root.Upper.Is(base.Append("template", name)) || + !root.Lower[0].Is(b.initial()) || + !root.Upper.Is(b.template(name)) || root.Work != nil { continue } @@ -102,12 +101,12 @@ func informTemplate(base *check.Absolute, name string, mutable bool) (func() err } // acquireTemplate obtains exclusivity of a template. -func acquireTemplate(base *check.Absolute, name string) (remove func() error, err error) { - return informTemplate(base, name, true) +func acquireTemplate(b *base, name string) (remove func() error, err error) { + return informTemplate(b, name, true) } // enterTemplate checks against exclusivity of a template. -func enterTemplate(base *check.Absolute, name string) error { - _, err := informTemplate(base, name, false) +func enterTemplate(b *base, name string) error { + _, err := informTemplate(b, name, false) return err } diff --git a/cmd/app/main.go b/cmd/app/main.go index 00bceb57..9b51d601 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -1,24 +1,25 @@ -// The app program is a proof-of-concept frontend for cmd/hakurei. -// -// This program is not covered by the compatibility promise. The command line -// interface and configuration syntax may change at any time. package main import ( "context" "errors" + "fmt" "io" "log" "os" "os/exec" "os/signal" "path/filepath" + "slices" + "strconv" + "strings" "syscall" "hakurei.app/check" "hakurei.app/command" "hakurei.app/fhs" "hakurei.app/hst" + "hakurei.app/internal/outcome" "hakurei.app/message" ) @@ -36,7 +37,7 @@ func main() { flagBase string flagInsecure bool - base, template, initial *check.Absolute + b *base ) c := command.New(os.Stderr, log.Printf, "app", func([]string) (err error) { msg.SwapVerbose(flagVerbose) @@ -44,14 +45,15 @@ func main() { if flagBase == "" { flagBase = "state" } + + var a *check.Absolute if flagBase, err = filepath.Abs(flagBase); err != nil { return - } else if base, err = check.NewAbs(flagBase); err != nil { + } else if a, err = check.NewAbs(flagBase); err != nil { return } + b = (*base)(a) - template = base.Append("template") - initial = base.Append("initial") return }).Flag( &flagVerbose, @@ -76,7 +78,7 @@ func main() { "enter", "Enter mutable state template", func(args []string) error { if len(args) != 1 { - return list(template, true) + return list(b.append("template"), true) } config := hst.Config{ @@ -86,9 +88,9 @@ func main() { Filesystem: []hst.FilesystemConfigJSON{ {FilesystemConfig: &hst.FSOverlay{ Target: fhs.AbsRoot, - Lower: []*check.Absolute{initial}, - Upper: template.Append(args[0]), - Work: base.Append("work", args[0]), + Lower: []*check.Absolute{b.initial()}, + Upper: b.template(args[0]), + Work: b.work(args[0]), }}, {FilesystemConfig: &hst.FSEphemeral{ Target: fhs.AbsTmp, @@ -122,7 +124,7 @@ func main() { config.Container.Home = a } - remove, err := acquireTemplate(base, args[0]) + remove, err := acquireTemplate(b, args[0]) if err != nil { return err } @@ -148,19 +150,19 @@ func main() { "run", "Start the named application", func(args []string) error { if len(args) < 1 { - return list(base.Append("app"), false) + return list(b.append("app"), false) } var config *hst.Config var r io.Reader - f, err := os.Open(base.Append("app", args[0]).String()) + f, err := os.Open(b.append("app", args[0]).String()) if err != nil { return err } r = f var common *os.File - if common, err = os.Open(base.Append("common").String()); err != nil { + if common, err = os.Open(b.append("common").String()); err != nil { if !errors.Is(err, os.ErrNotExist) { _ = f.Close() return err @@ -170,7 +172,7 @@ func main() { } var name string - config, err = parse(args[0], base, r, &name) + config, err = parse(args[0], b, r, &name) if closeErr := f.Close(); err == nil { err = closeErr } @@ -187,7 +189,7 @@ func main() { config.Container.Args[2] = flagCommand } - if err = enterTemplate(base, name); err != nil { + if err = enterTemplate(b, name); err != nil { return err } return run(ctx, msg, flagInsecure, config, args[1:]...) @@ -200,6 +202,105 @@ func main() { ) } + c.NewCommand( + "id", "Show user/group id of the specified appid", + func(args []string) error { + var appid int + switch len(args) { + case 0: + log.Println("appid not specified, assuming reserved user") + break + + case 1: + var err error + appid, err = strconv.Atoi(args[0]) + if err != nil { + return os.ErrInvalid + } + break + + default: + return errors.New("id requires 1 argument") + } + + fmt.Println(hst.ToUser(outcome.Info().User, appid)) + return nil + }, + ) + + c.NewCommand( + "next", "Find next unused identity", + func([]string) error { + var names []string + if dents, err := os.ReadDir(b.append("app").String()); err != nil { + return err + } else { + names = make([]string, 0, len(dents)) + for _, dent := range dents { + name := dent.Name() + if dent.IsDir() || (len(name) > 0 && name[0] == '.') { + continue + } + names = append(names, name) + } + } + + apps := make([]*hst.Config, len(names)) + for i, name := range names { + r, err := os.Open(b.append("app", name).String()) + if err != nil { + return err + } + + apps[i], err = parse(name, b, r, nil) + if closeErr := r.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + } + + p := make(map[int][]*hst.Config) + for _, config := range apps { + p[config.Identity] = append(p[config.Identity], config) + } + + identities := make([]int, 0, len(p)) + for identity, a := range p { + identities = append(identities, identity) + if msg.IsVerbose() && len(a) != 1 { + ids := make([]string, len(a)) + for i, config := range a { + ids[i] = config.ID + } + slices.Sort(ids) + msg.Verbosef( + "%s shares identity %d", + strings.Join(ids, ", "), identity, + ) + } + } + + if len(identities) == 0 { + // 0 is the reserved identity + fmt.Println(1) + return nil + } + + slices.Sort(identities) + next := identities[0] - 1 + for _, identity := range identities { + if identity != next+1 { + break + } + next = identity + } + fmt.Println(next + 1) + return nil + }, + ) + c.MustParse(os.Args[1:], func(err error) { if e, ok := errors.AsType[*exec.ExitError](err); ok && e != nil { os.Exit(e.ExitCode()) diff --git a/cmd/dist/main.go b/cmd/dist/main.go index 8e1c57c1..1034f282 100644 --- a/cmd/dist/main.go +++ b/cmd/dist/main.go @@ -56,6 +56,7 @@ func main() { verbose := os.Getenv("VERBOSE") != "" runTests := os.Getenv("HAKUREI_DIST_MAKE") == "" + race := os.Getenv("HAKUREI_RACE") != "" version = getenv("HAKUREI_VERSION", strings.TrimSpace(version)) prefix := getenv("PREFIX", "/usr/local") destdir := getenv("DESTDIR", "dist") @@ -64,6 +65,10 @@ func main() { log.Println() } + if race { + version += "-race" + } + if err := os.MkdirAll(destdir, 0755); err != nil { log.Fatal(err) } @@ -95,12 +100,17 @@ func main() { verboseFlag = "-buildvcs=false" } + raceFlag := "-race" + if !race { + raceFlag = "-buildvcs=false" + } + log.Printf("Building hakurei %s for %s/%s.", version, runtime.GOOS, runtime.GOARCH) mustRun(ctx, nil, "go", "generate", "./...") mustRun( ctx, nil, "go", "build", "-trimpath", - verboseFlag, "-o", s, + verboseFlag, raceFlag, "-o", s, "-ldflags=-s -w "+ "-buildid= -linkmode external -extldflags=-static "+ "-X hakurei.app/internal/info.buildVersion="+version+" "+ @@ -125,7 +135,7 @@ func main() { if runTests { log.Println("##### Testing Hakurei.") mustRun( - ctx, nil, "go", "test", + ctx, nil, "go", "test", raceFlag, "-ldflags=-buildid= -linkmode external -extldflags=-static", "./...", ) diff --git a/cmd/hakurei/testsuite/configuration.nix b/cmd/hakurei/testsuite/configuration.nix new file mode 100644 index 00000000..62d8239d --- /dev/null +++ b/cmd/hakurei/testsuite/configuration.nix @@ -0,0 +1,252 @@ +{ + lib, + pkgs, + config, + ... +}: +{ + users.users = { + alice = { + isNormalUser = true; + description = "Alice Foobar"; + password = "foobar"; + uid = 1000; + }; + untrusted = { + isNormalUser = true; + description = "Untrusted user"; + password = "foobar"; + uid = 1001; + + # For deny unmapped uid test: + packages = [ config.environment.hakurei.package ]; + }; + }; + + home-manager.users.alice.home.stateVersion = "24.11"; + + # Automatically login on tty1 as a normal user: + services.getty.autologinUser = "alice"; + + security.pam.loginLimits = [ + { + domain = "@users"; + item = "rtprio"; + type = "-"; + value = 1; + } + ]; + + environment = { + systemPackages = with pkgs; [ + # For D-Bus tests: + mako + libnotify + ]; + + variables = { + SWAYSOCK = "/tmp/sway-ipc.sock"; + WLR_RENDERER = "pixman"; + }; + + # To help with OCR: + etc."xdg/foot/foot.ini".text = lib.generators.toINI { } { + main = { + font = "inconsolata:size=14"; + }; + colors = rec { + foreground = "000000"; + background = "ffffff"; + regular2 = foreground; + }; + }; + }; + + fonts.packages = [ pkgs.inconsolata ]; + + # 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; + + # For PulseAudio tests: + security.rtkit.enable = true; + services.pipewire = { + enable = true; + alsa.enable = true; + alsa.support32Bit = true; + pulse.enable = true; + jack.enable = true; + }; + + virtualisation = { + # Hopefully reduces spurious test failures: + memorySize = if pkgs.stdenv.hostPlatform.is32bit then 2046 else 8192; + + 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 Go test compiler performance: + "-smp 16" + ]; + }; + + # Disk image is too small for some tests: + boot.tmp.useTmpfs = true; + + environment.hakurei = { + enable = true; + stateDir = "/var/lib/hakurei"; + users.alice = 0; + + extraHomeConfig = + { config, ... }: + { + # To test merge deduplication: + options._hakurei.stateVersion = lib.mkOption { type = lib.types.str; }; + + config = { + home = { inherit (config._hakurei) stateVersion; }; + _hakurei.stateVersion = "23.05"; + }; + }; + + commonPaths = [ + { + type = "bind"; + src = "/var/tmp"; + write = true; + } + ]; + + apps = { + "cat.gensokyo.extern.bash.linger-timeout" = { + name = "hakurei-check-linger-timeout"; + identity = 9999; + share = pkgs.bash; + packages = [ pkgs.bash ]; + command = '' + sleep infinity & disown + exit + ''; + wait_delay = 1; + enablements = { + wayland = false; + pipewire = false; + }; + }; + + "cat.gensokyo.extern.foot.noEnablements" = { + name = "ne-foot"; + identity = 1; + shareUid = true; + verbose = true; + share = pkgs.foot; + packages = with pkgs; [ + foot + + # For wayland-info: + wayland-utils + ]; + command = "foot"; + enablements = { + dbus = false; + pipewire = false; + }; + }; + + "cat.gensokyo.extern.foot.noEnablements.immediate" = { + name = "ne-foot-immediate"; + identity = 1; + shareUid = true; + verbose = true; + wait_delay = -1; + share = pkgs.foot; + packages = [ ]; + command = "foot"; + enablements = { + dbus = false; + pipewire = false; + }; + }; + + "cat.gensokyo.extern.foot.pulseaudio" = { + name = "pa-foot"; + identity = 2; + verbose = true; + share = pkgs.foot; + packages = [ pkgs.foot ]; + command = "foot"; + enablements.dbus = false; + }; + + "cat.gensokyo.extern.Alacritty.x11" = { + name = "x11-alacritty"; + identity = 1; + shareUid = true; + verbose = true; + share = pkgs.alacritty; + packages = with pkgs; [ + # For X11 terminal emulator: + alacritty + + # For glinfo: + mesa-demos + ]; + command = "alacritty"; + enablements = { + wayland = false; + x11 = true; + dbus = false; + pipewire = false; + }; + }; + + "cat.gensokyo.extern.foot.directWayland" = { + name = "da-foot"; + identity = 4; + verbose = true; + insecureWayland = true; + share = pkgs.foot; + packages = with pkgs; [ + foot + + # For wayland-info: + wayland-utils + ]; + command = "foot"; + enablements = { + dbus = false; + pipewire = false; + }; + }; + + "cat.gensokyo.extern.strace.wantFail" = { + name = "strace-failure"; + identity = 5; + verbose = true; + share = pkgs.strace; + command = "strace true"; + enablements = { + wayland = false; + x11 = false; + dbus = false; + pipewire = false; + }; + }; + }; + }; +} diff --git a/cmd/hakurei/testsuite/default.nix b/cmd/hakurei/testsuite/default.nix new file mode 100644 index 00000000..81daa0a2 --- /dev/null +++ b/cmd/hakurei/testsuite/default.nix @@ -0,0 +1,81 @@ +{ + lib, + testers, + buildFHSEnv, + writeShellScriptBin, + + system, + self, + withRace ? false, +}: + +testers.nixosTest { + name = "hakurei" + (if withRace then "-race" else ""); + nodes.machine = + { options, pkgs, ... }: + let + fhs = + let + hakurei = options.environment.hakurei.package.default; + in + buildFHSEnv { + pname = "hakurei-fhs"; + inherit (hakurei) version; + targetPkgs = _: hakurei.targetPkgs; + extraOutputsToInstall = [ "dev" ]; + profile = '' + export PKG_CONFIG_PATH="/usr/share/pkgconfig:$PKG_CONFIG_PATH" + ''; + }; + in + { + environment.systemPackages = [ + # For go tests: + (writeShellScriptBin "hakurei-test" '' + # Assert hst CGO_ENABLED=0: ${ + with pkgs; + runCommand "hakurei-hst-cgo" { nativeBuildInputs = [ self.packages.${system}.hakurei.go ]; } '' + cp -r ${options.environment.hakurei.package.default.src} "$out" + chmod -R +w "$out" + cp ${writeText "hst_cgo_test.go" ''package hakurei_test;import("testing";"hakurei.app/hst");func TestTemplate(t *testing.T){hst.Template()}''} "$out/hst_cgo_test.go" + (cd "$out" && HOME="$(mktemp -d)" CGO_ENABLED=0 go test .) + '' + } + + cd ${self.packages.${system}.hakurei.src} + ${fhs}/bin/hakurei-fhs -c \ + 'CC="clang -O3 -Werror" go test --tags=noskip ${if withRace then "-race" else "-count 16"} ./...' \ + &> /tmp/hakurei-test.log && \ + touch /tmp/hakurei-test-ok + touch /tmp/hakurei-test-done + '') + ]; + + # Run with Go race detector: + environment.hakurei = lib.mkIf withRace rec { + # race detector does not support static linking + package = (pkgs.callPackage ./package.nix { }).overrideAttrs (previousAttrs: { + env = previousAttrs.env // { + GOFLAGS = previousAttrs.env.GOFLAGS + " -race"; + }; + }); + hsuPackage = options.environment.hakurei.hsuPackage.default.override { hakurei = package; }; + }; + + 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/hakurei/testsuite/flake.lock b/cmd/hakurei/testsuite/flake.lock new file mode 100644 index 00000000..5537506a --- /dev/null +++ b/cmd/hakurei/testsuite/flake.lock @@ -0,0 +1,49 @@ +{ + "nodes": { + "home-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1787146702, + "narHash": "sha256-YbRcLdU/yK4gWsQg7V8WTKZHfXL33g8+wSFUX3wyevs=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "173b7e8d40fdc8c296a9c99854314f17a3a1704c", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "release-26.05", + "repo": "home-manager", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1787101114, + "narHash": "sha256-gwrPcFf/rDjHPaVflbDZ040ZDmBTRj/7+s8ZmE2SaIM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b18a4b905f8d028dc4476412e6d6891728695379", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "home-manager": "home-manager", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/cmd/hakurei/testsuite/flake.nix b/cmd/hakurei/testsuite/flake.nix new file mode 100644 index 00000000..e1df8990 --- /dev/null +++ b/cmd/hakurei/testsuite/flake.nix @@ -0,0 +1,75 @@ +{ + description = "hakurei container tool and nixos module"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + + home-manager = { + url = "github:nix-community/home-manager/release-26.05"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + { + self, + nixpkgs, + home-manager, + }: + let + supportedSystems = [ "x86_64-linux" ]; + + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; }); + in + { + nixosModules.hakurei = import ./nixos.nix self.packages; + + checks = forAllSystems ( + system: + let + pkgs = nixpkgsFor.${system}; + + inherit (pkgs) callPackage; + in + { + hakurei = callPackage ./. { inherit system self; }; + race = callPackage ./. { + inherit system self; + withRace = true; + }; + } + ); + + packages = forAllSystems ( + system: + let + inherit (self.packages.${system}) hakurei hsu; + pkgs = nixpkgsFor.${system}; + in + { + default = hakurei; + hakurei = pkgs.pkgsStatic.callPackage ./package.nix { + inherit (pkgs) + # passthru.buildInputs + go_1_27 + clang + + # nativeBuildInputs + pkg-config + wayland-scanner + makeBinaryWrapper + + # appPackages + glibc + xdg-dbus-proxy + + # for check + nettools + ; + }; + hsu = pkgs.callPackage ./hsu.nix { inherit (self.packages.${system}) hakurei; }; + } + ); + }; +} diff --git a/cmd/hakurei/testsuite/hsu.nix b/cmd/hakurei/testsuite/hsu.nix new file mode 100644 index 00000000..d1ddcb77 --- /dev/null +++ b/cmd/hakurei/testsuite/hsu.nix @@ -0,0 +1,23 @@ +{ + lib, + buildGoModule, + hakurei ? abort "hakurei package required", +}: + +buildGoModule { + pname = "${hakurei.pname}-hsu"; + inherit (hakurei) version; + + src = ../../hsu; + inherit (hakurei) vendorHash; + env.CGO_ENABLED = 0; + + preBuild = '' + go mod init hsu >& /dev/null + ''; + + ldflags = lib.attrsets.foldlAttrs ( + ldflags: name: value: + ldflags ++ [ "-X main.${name}=${value}" ] + ) [ "-s -w" ] { hakureiPath = "${hakurei}/libexec/hakurei"; }; +} diff --git a/cmd/hakurei/testsuite/nixos.nix b/cmd/hakurei/testsuite/nixos.nix new file mode 100644 index 00000000..49bfffb6 --- /dev/null +++ b/cmd/hakurei/testsuite/nixos.nix @@ -0,0 +1,407 @@ +packages: +{ + lib, + pkgs, + config, + ... +}: + +let + inherit (lib) + lists + attrsets + mkMerge + mkIf + mapAttrs + foldlAttrs + optional + optionals + ; + + cfg = config.environment.hakurei; + + # userid*userOffset + appStart + appid + getsubuid = userid: appid: userid * 100000 + 10000 + appid; + getsubname = userid: appid: "u${toString userid}_a${toString appid}"; + getsubhome = userid: appid: "${cfg.stateDir}/u${toString userid}/a${toString appid}"; + + mountpoints = { + ${cfg.sharefs.name} = mkIf (cfg.sharefs.source != null) { + depends = [ cfg.sharefs.source ]; + device = "sharefs"; + fsType = "fuse.sharefs"; + noCheck = true; + options = [ + "rw" + "noexec" + "nosuid" + "nodev" + "noatime" + "allow_other" + "mkdir" + "source=${cfg.sharefs.source}" + "setuid=${toString config.users.users.${cfg.sharefs.user}.uid}" + "setgid=${toString config.users.groups.${cfg.sharefs.group}.gid}" + ]; + }; + }; +in + +{ + imports = [ (import ./options.nix packages) ]; + + options = { + # Forward declare a dummy option for VM filesystems since the real one won't exist + # unless the VM module is actually imported. + virtualisation.fileSystems = lib.mkOption { }; + }; + + config = mkIf cfg.enable { + assertions = [ + ( + let + conflictingApps = foldlAttrs ( + acc: id: app: + ( + acc + ++ foldlAttrs ( + acc': id': app': + if id == id' || app.shareUid && app'.shareUid || app.identity != app'.identity then acc' else acc' ++ [ id ] + ) [ ] cfg.apps + ) + ) [ ] cfg.apps; + in + { + assertion = (lists.length conflictingApps) == 0; + message = "the following hakurei apps have conflicting identities: " + (builtins.concatStringsSep ", " conflictingApps); + } + ) + ]; + + security.wrappers.hsu = { + source = "${cfg.hsuPackage}/bin/hsu"; + setuid = true; + owner = "root"; + group = "root"; + }; + + environment.etc.hsurc = { + mode = "0400"; + text = foldlAttrs ( + acc: username: fid: + "${toString config.users.users.${username}.uid} ${toString fid}\n" + acc + ) "" cfg.users; + }; + + environment.systemPackages = optional (cfg.sharefs.source != null) cfg.sharefs.package; + fileSystems = mountpoints; + virtualisation.fileSystems = mountpoints; + + home-manager = + let + privPackages = mapAttrs (_: userid: { + home.packages = foldlAttrs ( + acc: id: app: + [ + ( + let + extendDBusDefault = id: ext: { + filter = true; + + talk = [ "org.freedesktop.Notifications" ] ++ ext.talk; + own = [ + "${id}.*" + "org.mpris.MediaPlayer2.${id}.*" + ] + ++ ext.own; + + inherit (ext) call broadcast; + }; + dbusConfig = + let + default = { + talk = [ ]; + own = [ ]; + call = { }; + broadcast = { }; + }; + in + { + session_bus = if app.dbus.session != null then (app.dbus.session (extendDBusDefault id)) else (extendDBusDefault id default); + system_bus = app.dbus.system; + }; + command = if app.command == null then app.name else app.command; + script = if app.script == null then ("exec " + command + " $@") else app.script; + isGraphical = if app.gpu != null then app.gpu else app.enablements.wayland || app.enablements.x11; + + conf = { + inherit id; + inherit (app) identity enablements; + inherit (dbusConfig) session_bus system_bus; + direct_wayland = app.insecureWayland; + sched_policy = app.schedPolicy; + sched_priority = app.schedPriority; + groups = app.groups ++ optional (cfg.sharefs.source != null) cfg.sharefs.group; + + container = { + inherit (app) + wait_delay + devel + userns + device + tty + multiarch + env + ; + map_real_uid = app.mapRealUid; + host_net = app.hostNet; + host_abstract = app.hostAbstract; + share_runtime = app.shareRuntime; + share_tmpdir = app.shareTmpdir; + + filesystem = + let + bind = src: { + type = "bind"; + inherit src; + }; + optBind = src: { + type = "bind"; + inherit src; + optional = true; + }; + optDevBind = src: { + type = "bind"; + inherit src; + dev = true; + optional = true; + }; + in + [ + (bind "/bin") + (bind "/usr/bin") + (bind "/nix/store") + (optBind "/sys/block") + (optBind "/sys/bus") + (optBind "/sys/class") + (optBind "/sys/dev") + (optBind "/sys/devices") + ] + ++ optionals app.nix [ + (bind "/nix/var") + ] + ++ optionals isGraphical [ + (optDevBind "/dev/dri") + (optDevBind "/dev/nvidiactl") + (optDevBind "/dev/nvidia-modeset") + (optDevBind "/dev/nvidia-uvm") + (optDevBind "/dev/nvidia-uvm-tools") + (optDevBind "/dev/nvidia0") + ] + ++ optionals app.useCommonPaths cfg.commonPaths + ++ app.extraPaths + ++ [ + { + type = "bind"; + dst = "/etc/"; + src = "/etc/"; + special = true; + } + { + type = "link"; + dst = "/run/current-system"; + linkname = "/run/current-system"; + dereference = true; + } + ] + ++ optionals (isGraphical && config.hardware.graphics.enable) ( + [ + { + type = "link"; + dst = "/run/opengl-driver"; + linkname = config.systemd.tmpfiles.settings.graphics-driver."/run/opengl-driver"."L+".argument; + } + ] + ++ optionals (app.multiarch && config.hardware.graphics.enable32Bit) [ + { + type = "link"; + dst = "/run/opengl-driver-32"; + linkname = config.systemd.tmpfiles.settings.graphics-driver."/run/opengl-driver-32"."L+".argument; + } + ] + ) + ++ [ + { + type = "bind"; + src = getsubhome userid app.identity; + write = true; + ensure = true; + } + ]; + + username = getsubname userid app.identity; + inherit (cfg) shell; + home = getsubhome userid app.identity; + + path = + if app.path == null then + pkgs.writeScript "${app.name}-start" '' + #!${pkgs.zsh}${pkgs.zsh.shellPath} + ${script} + '' + else + app.path; + args = if app.args == null then [ "${app.name}-start" ] else app.args; + }; + }; + + checkedConfig = + name: value: + let + file = pkgs.writeText name (builtins.toJSON value); + in + pkgs.runCommand "checked-${name}" { nativeBuildInputs = [ cfg.package ]; } '' + ln -vs ${file} "$out" + hakurei show --no-store ${file} + ''; + in + pkgs.writeShellScriptBin app.name '' + exec hakurei${if app.verbose then " -v" else ""}${if app.insecureWayland then " --insecure" else ""} run ${checkedConfig "hakurei-app-${app.name}.json" conf} $@ + '' + ) + ] + ++ ( + let + pkg = if app.share != null then app.share else pkgs.${app.name}; + copy = source: "[ -d '${source}' ] && cp -Lrv '${source}' $out/share || true"; + in + optional (app.enablements.wayland || app.enablements.x11) ( + pkgs.runCommand "${app.name}-share" { } '' + mkdir -p $out/share + ${copy "${pkg}/share/applications"} + ${copy "${pkg}/share/pixmaps"} + ${copy "${pkg}/share/icons"} + ${copy "${pkg}/share/man"} + + if test -d "$out/share/applications"; then + substituteInPlace $out/share/applications/* \ + --replace-warn '${pkg}/bin/' "" \ + --replace-warn '${pkg}/libexec/' "" + fi + '' + ) + ) + ++ acc + ) [ cfg.package ] cfg.apps; + }) cfg.users; + in + { + useUserPackages = false; # prevent users.users entries from being added + + users = + mkMerge + (foldlAttrs + ( + acc: _: fid: + foldlAttrs + ( + acc: _: app: + ( + let + key = getsubname fid app.identity; + in + { + usernames = acc.usernames // { + ${key} = true; + }; + merge = acc.merge ++ [ + { + ${key} = mkMerge ( + [ + app.extraConfig + { home.packages = app.packages; } + ] + ++ lib.optional (!attrsets.hasAttrByPath [ key ] acc.usernames) cfg.extraHomeConfig + ); + } + ]; + } + ) + ) + { + inherit (acc) usernames; + merge = acc.merge ++ [ { ${getsubname fid 0} = cfg.extraHomeConfig; } ]; + } + cfg.apps + ) + { + usernames = { }; + merge = [ privPackages ]; + } + cfg.users + ).merge; + }; + + users = + let + getuser = userid: appid: { + isSystemUser = true; + createHome = true; + description = "Hakurei subordinate user ${toString appid} (u${toString userid})"; + group = getsubname userid appid; + home = getsubhome userid appid; + uid = getsubuid userid appid; + }; + getgroup = userid: appid: { gid = getsubuid userid appid; }; + in + { + users = mkMerge ( + foldlAttrs + ( + acc: username: fid: + acc + ++ + foldlAttrs + ( + acc': _: app: + acc' ++ [ { ${getsubname fid app.identity} = getuser fid app.identity; } ] + ) + [ + { + ${getsubname fid 0} = getuser fid 0; + ${username}.extraGroups = [ cfg.sharefs.group ]; + } + ] + cfg.apps + ) + (optional (cfg.sharefs.source != null) { + ${cfg.sharefs.user} = { + uid = lib.mkDefault 1023; + inherit (cfg.sharefs) group; + isSystemUser = true; + home = cfg.sharefs.source; + }; + }) + cfg.users + ); + + groups = mkMerge ( + foldlAttrs + ( + acc: _: fid: + acc + ++ foldlAttrs ( + acc': _: app: + acc' ++ [ { ${getsubname fid app.identity} = getgroup fid app.identity; } ] + ) [ { ${getsubname fid 0} = getgroup fid 0; } ] cfg.apps + ) + (optional (cfg.sharefs.source != null) { + ${cfg.sharefs.group} = { + gid = lib.mkDefault 1023; + }; + }) + cfg.users + ); + }; + }; +} diff --git a/cmd/hakurei/testsuite/options.nix b/cmd/hakurei/testsuite/options.nix new file mode 100644 index 00000000..f624b6f5 --- /dev/null +++ b/cmd/hakurei/testsuite/options.nix @@ -0,0 +1,364 @@ +packages: +{ + lib, + pkgs, + config, + ... +}: + +let + inherit (lib) types mkOption mkEnableOption; + + cfg = config.environment.hakurei; +in + +{ + options = { + environment.hakurei = { + enable = mkEnableOption "hakurei"; + + package = mkOption { + type = types.package; + default = packages.${pkgs.stdenv.hostPlatform.system}.hakurei; + description = "The hakurei package to use."; + }; + + hsuPackage = mkOption { + type = types.package; + default = packages.${pkgs.stdenv.hostPlatform.system}.hsu; + description = "The hsu package to use."; + }; + + users = mkOption { + type = + let + inherit (types) attrsOf ints; + in + attrsOf (ints.between 0 99); + description = '' + Users allowed to spawn hakurei apps and their corresponding hakurei identity. + ''; + }; + + extraHomeConfig = mkOption { + type = types.anything; + description = '' + Extra home-manager configuration to merge with all target users. + ''; + }; + + sharefs = { + package = mkOption { + type = types.package; + default = pkgs.linkFarm "sharefs" { + "bin/sharefs" = "${cfg.package}/libexec/sharefs"; + "bin/mount.fuse.sharefs" = "${cfg.package}/libexec/sharefs"; + }; + description = "The sharefs package to use."; + }; + + user = mkOption { + type = types.str; + default = "sharefs"; + description = '' + Name of the user to run the sharefs daemon as. + ''; + }; + + group = mkOption { + type = types.str; + default = "sharefs"; + description = '' + Name of the group to run the sharefs daemon as. + ''; + }; + + name = mkOption { + type = types.str; + default = "/sdcard"; + description = '' + Host path to mount sharefs on. + ''; + }; + + source = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Writable backing directory. Setting this to null disables sharefs. + ''; + }; + }; + + apps = mkOption { + type = + let + inherit (types) + int + ints + str + bool + enum + package + anything + submodule + listOf + attrsOf + nullOr + functionTo + ; + in + attrsOf (submodule { + options = { + name = mkOption { + type = str; + description = '' + Name of the app's launcher script. + ''; + }; + + verbose = mkEnableOption "launchers with verbose output"; + + identity = mkOption { + type = ints.between 1 9999; + description = '' + Application identity. Identity 0 is reserved for system services. + ''; + }; + shareUid = mkEnableOption "sharing identity with another application"; + + packages = mkOption { + type = listOf package; + default = [ ]; + description = '' + List of extra packages to install via home-manager. + ''; + }; + + extraConfig = mkOption { + type = anything; + default = { }; + description = '' + Extra home-manager configuration. + ''; + }; + + path = mkOption { + type = nullOr str; + default = null; + description = '' + Custom executable path. + Setting this to null will default to the start script. + ''; + }; + + args = mkOption { + type = nullOr (listOf str); + default = null; + description = '' + Custom args. + Setting this to null will default to script name. + ''; + }; + + script = mkOption { + type = nullOr str; + default = null; + description = '' + Application launch script. + ''; + }; + + command = mkOption { + type = nullOr str; + default = null; + description = '' + Command to run as the target user. + Setting this to null will default command to launcher name. + Has no effect when script is set. + ''; + }; + + groups = mkOption { + type = listOf str; + default = [ ]; + description = '' + List of groups to inherit from the privileged user. + ''; + }; + + shareRuntime = mkEnableOption "sharing of XDG_RUNTIME_DIR between containers under the same identity"; + shareTmpdir = mkEnableOption "sharing of TMPDIR between containers under the same identity"; + + dbus = { + session = mkOption { + type = nullOr (functionTo anything); + default = null; + description = '' + D-Bus session bus custom configuration. + Setting this to null will enable built-in defaults. + ''; + }; + + system = mkOption { + type = nullOr anything; + default = null; + description = '' + D-Bus system bus custom configuration. + Setting this to null will disable the system bus proxy. + ''; + }; + }; + + env = mkOption { + type = nullOr (attrsOf str); + default = null; + description = '' + Environment variables to set for the initial process in the sandbox. + ''; + }; + + wait_delay = mkOption { + type = nullOr int; + default = null; + description = '' + Duration to wait for after interrupting a container's initial process in nanoseconds. + A negative value causes the container to be terminated immediately on cancellation. + Setting this to null defaults to five seconds. + ''; + }; + + devel = mkEnableOption "debugging-related kernel interfaces"; + userns = mkEnableOption "user namespace creation"; + tty = mkEnableOption "access to the controlling terminal"; + multiarch = mkEnableOption "multiarch kernel-level support"; + + hostNet = mkEnableOption "share host net namespace" // { + default = true; + }; + hostAbstract = mkEnableOption "share abstract unix socket scope"; + + schedPolicy = mkOption { + type = nullOr (enum [ + "fifo" + "rr" + "batch" + "idle" + "deadline" + "ext" + ]); + default = null; + description = '' + Scheduling policy to set for the container. + The zero value retains the current scheduling policy. + ''; + }; + schedPriority = mkOption { + type = nullOr (ints.between 1 99); + default = null; + description = '' + Scheduling priority to set for the container. + ''; + }; + + nix = mkEnableOption "nix daemon access"; + mapRealUid = mkEnableOption "mapping to priv-user uid"; + device = mkEnableOption "access to all devices"; + insecureWayland = mkEnableOption "direct access to the Wayland socket"; + + gpu = mkOption { + type = nullOr bool; + default = null; + description = '' + Target process GPU and driver access. + Setting this to null will enable GPU whenever X or Wayland is enabled. + ''; + }; + + useCommonPaths = mkEnableOption "common extra paths" // { + default = true; + }; + + extraPaths = mkOption { + type = listOf (attrsOf anything); + default = [ ]; + description = '' + Extra paths to make available to the container. + ''; + }; + + enablements = { + wayland = mkOption { + type = nullOr bool; + default = true; + description = '' + Whether to share the Wayland server via security-context-v1. + ''; + }; + + x11 = mkOption { + type = nullOr bool; + default = false; + description = '' + Whether to share the X11 socket and allow connection. + ''; + }; + + dbus = mkOption { + type = nullOr bool; + default = true; + description = '' + Whether to proxy D-Bus. + ''; + }; + + pipewire = mkOption { + type = nullOr bool; + default = true; + description = '' + Whether to share the PipeWire server via pipewire-pulse on a SecurityContext socket. + ''; + }; + }; + + share = mkOption { + type = nullOr package; + default = null; + description = '' + Package containing share files. + Setting this to null will default package name to wrapper name. + ''; + }; + }; + }); + default = { }; + description = '' + Declaratively configured hakurei apps. + ''; + }; + + commonPaths = mkOption { + type = types.listOf (types.attrsOf types.anything); + default = [ ]; + description = '' + Common extra paths to make available to the container. + ''; + }; + + shell = mkOption { + type = types.str; + default = "/run/current-system/sw/bin/bash"; + description = '' + Absolute path to preferred shell. + ''; + }; + + stateDir = mkOption { + type = types.str; + description = '' + The state directory where app home directories are stored. + ''; + }; + }; + }; +} diff --git a/cmd/hakurei/testsuite/package.nix b/cmd/hakurei/testsuite/package.nix new file mode 100644 index 00000000..12196cf6 --- /dev/null +++ b/cmd/hakurei/testsuite/package.nix @@ -0,0 +1,145 @@ +{ + lib, + stdenv, + buildGo127Module, + makeBinaryWrapper, + xdg-dbus-proxy, + pkg-config, + libffi, + libseccomp, + acl, + wayland, + wayland-protocols, + wayland-scanner, + + libxcb, + libxau, + libxdmcp, + + # for sharefs + fuse3, + + # for passthru.buildInputs + go_1_27, + clang, + xorgproto, + + # for check + util-linux, + nettools, + + glibc, # for ldd + withStatic ? stdenv.hostPlatform.isStatic, +}: + +buildGo127Module rec { + pname = "hakurei"; + version = with lib.strings; removePrefix "v" (trim (builtins.readFile ../../dist/VERSION)); + + srcFiltered = builtins.path { + name = "${pname}-src"; + path = lib.cleanSource ../../../.; + filter = path: type: !(type == "regular" && (lib.hasSuffix ".nix" path || lib.hasSuffix ".py" path)) && !(type == "directory" && lib.hasSuffix "/test" path) && !(type == "directory" && lib.hasSuffix "/cmd/hsu" path); + }; + vendorHash = null; + + src = stdenv.mkDerivation { + name = "${pname}-src-full"; + inherit version; + enableParallelBuilding = true; + src = srcFiltered; + + buildInputs = [ + wayland + wayland-protocols + ]; + + nativeBuildInputs = [ + go_1_27 + pkg-config + wayland-scanner + ]; + + buildPhase = "GOCACHE=$(mktemp -d) go generate ./..."; + installPhase = "cp -r . $out"; + }; + + ldflags = + lib.attrsets.foldlAttrs + ( + ldflags: name: value: + ldflags ++ [ "-X hakurei.app/internal/info.${name}=${value}" ] + ) + ( + [ "-s -w" ] + ++ lib.optionals withStatic [ + "-linkmode external" + "-extldflags \"-static\"" + ] + ) + { + buildVersion = "v${version}"; + hakureiPath = "${placeholder "out"}/libexec/hakurei"; + hsuPath = "/run/wrappers/bin/hsu"; + }; + + env = { + # use clang instead of gcc + CC = "clang -O3 -Werror"; + }; + + buildInputs = [ + libffi + libseccomp + fuse3 + acl + wayland + util-linux + + libxcb + libxau + libxdmcp + ]; + + nativeBuildInputs = [ + pkg-config + makeBinaryWrapper + + # for container example + nettools + ]; + + postInstall = + let + appPackages = [ + glibc + xdg-dbus-proxy + ]; + in + '' + install -D --target-directory=$out/share/zsh/site-functions cmd/dist/comp/* + + mkdir "$out/libexec" + mv "$out"/bin/* "$out/libexec/" + + makeBinaryWrapper "$out/libexec/hakurei" "$out/bin/hakurei" \ + --inherit-argv0 --prefix PATH : ${lib.makeBinPath appPackages} + ''; + + passthru = { + go = go_1_27; + + targetPkgs = [ + go_1_27 + clang + xorgproto + util-linux + + # for go generate + wayland-protocols + wayland-scanner + ] + ++ buildInputs + ++ nativeBuildInputs; + }; +} diff --git a/cmd/hakurei/testsuite/sandbox/main.go b/cmd/hakurei/testsuite/sandbox/main.go new file mode 100644 index 00000000..820b7e2f --- /dev/null +++ b/cmd/hakurei/testsuite/sandbox/main.go @@ -0,0 +1,409 @@ +//go:build testsuite + +// The sandbox test program runs cmd/hakurei with configurations simulating +// several common workloads and inspects the resulting container states. +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + + "hakurei.app/check" + "hakurei.app/fhs" + "hakurei.app/hst" + "hakurei.app/internal/store" + "hakurei.app/internal/testsuite" + + "hakurei.app/cmd/hakurei/testsuite/sandbox/testdata" +) + +// mustScanFor continuously scans the proc filesystem and calls f for each entry +// visited. +func mustScanFor(f func(ps *testsuite.StatScanner) bool) int { + var ps testsuite.StatScanner + + for ps.Scan() { + if f(&ps) { + break + } + } + if err := ps.Err(); err != nil { + log.Fatal(err) + } + return ps.Stat().PID +} + +// mustStart starts a hakurei container and returns the pid of a process within +// the container. This process must be terminated by the caller. +func mustStart( + ctx context.Context, + serial uint64, + cred *syscall.Credential, + files ...*os.File, +) (pid int, done <-chan error) { + _serial := strconv.FormatUint(serial, 10) + _, done = testsuite.MustStartWith( + ctx, cred, nil, files, + "hakurei", "exec", + "sleep", "infinity", _serial, + ) + + var stat syscall.Stat_t + pid = mustScanFor(func(s *testsuite.StatScanner) bool { + select { + case err := <-done: + if err == nil { + log.Fatalf("test process %d terminated unexpectedly", serial) + } + log.Fatalf("test process %d terminated unexpectedly: %v", serial, err) + default: + break + } + + if s.Stat().Comm != "sleep" { + return false + } + + if args, err := s.Stat().Args(); err != nil { + if testsuite.IsNotExist(err) { + return false + } + log.Fatal(err) + } else if !slices.Equal(args, []string{ + "sleep", + "infinity", + _serial, + }) { + return false + } + + if err := s.Stat().Stat(&stat); err != nil { + if testsuite.IsNotExist(err) { + return false + } + log.Fatal(err) + } + + id := hst.ToUser[uint32](0, 0) + if stat.Uid != id || stat.Gid != id { + return false + } + + return true + }) + return +} + +func main() { + go testsuite.ReceiveSignals() + + // the signal handler does not wait for termination + ctx := context.Background() + + cred := syscall.Credential{Uid: 1000, Gid: 100} + if err := os.MkdirAll("/opt/test-helper/bin", 0755); err != nil { + log.Fatal(err) + } + + var testHelperDone <-chan error + { + cmd := exec.Command( + "go", "build", + "-o", "/opt/test-helper/bin", + "-tags=tester", + "-trimpath", + "./cmd/hakurei/testsuite/sandbox/tester", + ) + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + testHelperDone = testsuite.MustStart(cmd) + } + + var wg sync.WaitGroup + defer wg.Wait() + + var serial atomic.Uint64 + newSerial := func() uint64 { serial.Add(1); return serial.Load() } + + testsuite.MustRun( + &cred, nil, + "hakurei", "exec", "capsh", "--print", + ) + wg.Go(func() { + defer log.Println("validated capabilities/securebits in user namespace") + + testsuite.MustRun( + &cred, nil, + "hakurei", "exec", "capsh", "--has-no-new-privs", + ) + + for _, p := range []byte{'a', 'b', 'i', 'p'} { + testsuite.MustFail( + &cred, nil, + "hakurei", "exec", "capsh", "--has-"+string(p)+"=CAP_SYS_ADMIN", + ) + } + testsuite.MustFail( + &cred, nil, + "hakurei", "exec", "umount", "-R", "/dev", + ) + }) + + wg.Go(func() { + defer log.Println("validated pd seccomp outcome") + + c, cancel := context.WithCancel(ctx) + defer cancel() + + pid, done := mustStart(c, newSerial(), &cred) + testsuite.MustCheckFilter(pid, testdata.SumPD) + if err := testsuite.FilterTerminated(<-done); err != nil { + log.Fatal(err) + } + }) + + wg.Go(func() { + defer log.Println("validated fd leak") + + c, cancel := context.WithCancel(ctx) + defer cancel() + + pid, done := mustStart(c, newSerial(), &cred, os.Stdin, os.Stdout, os.Stderr) + prefix := filepath.Join(fhs.Proc, strconv.Itoa(pid), "fd") + + var fail bool + if entries, err := os.ReadDir(prefix); err != nil { + log.Fatal(err.Error()) + } else { + for _, ent := range entries { + var fd int + if fd, err = strconv.Atoi(ent.Name()); err != nil { + log.Fatal(err.Error()) + } + + // skip standard streams + if fd <= 2 { + continue + } + fail = true + + var d string + if d, err = os.Readlink(filepath.Join( + prefix, + ent.Name(), + )); err != nil { + log.Fatal(err.Error()) + } + log.Printf("extra fd %d -> %s", fd, d) + } + } + if fail { + log.Fatal("file descriptors leaked") + } + + if err := syscall.Kill(pid, syscall.SIGTERM); err != nil { + log.Fatalf("cannot terminate anchor: %v", err) + } else if err = testsuite.FilterTerminated(<-done); err != nil { + log.Fatal(err) + } + }) + + if err := os.MkdirAll(testsuite.XDGRuntimeDir, 0700); err != nil { + log.Fatal(err) + } else if err = os.Chown(testsuite.XDGRuntimeDir, 1000, 1000); err != nil { + log.Fatal(err) + } + + var swg sync.WaitGroup + defer swg.Wait() + dbusEnv := testsuite.MustStartSessionBus(&cred) + testsuite.MustStartSway(&swg, &cred, dbusEnv) + defer testsuite.TerminateSway(&cred) + testsuite.MustStartPipeWire(&cred, dbusEnv) + + if err := <-testHelperDone; err != nil { + log.Fatalf("cannot compile test helper: %v", err) + } + log.Println("created test helper") + + s := store.New(check.MustAbs("/tmp/hakurei.0/state")) + for name, tc := range testdata.All() { + wg.Go(func() { + cmd := exec.Command( + "script", "/dev/null", + "-E", "always", + "-qec", + "hakurei run "+ + "--identifier-fd=5"+ + " 4 1>&3", + ) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Pdeathsig: syscall.SIGTERM, + Credential: &cred, + } + var output bytes.Buffer + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, &output, &output + cmd.Env = []string{ + "PATH=" + os.Getenv("PATH"), + "TERM=xterm", + testsuite.XDGRuntimeEnv, + testsuite.WaylandEnv, + "DISPLAY=:0", + dbusEnv, + } + + var err error + var notify, _notify, _conf, conf, ident, _ident *os.File + if notify, _notify, err = os.Pipe(); err != nil { + log.Fatal(err) + } + cmd.ExtraFiles = append(cmd.ExtraFiles, _notify) + if _conf, conf, err = os.Pipe(); err != nil { + log.Fatal(err) + } + cmd.ExtraFiles = append(cmd.ExtraFiles, _conf) + if ident, _ident, err = os.Pipe(); err != nil { + log.Fatal(err) + } + cmd.ExtraFiles = append(cmd.ExtraFiles, _ident) + + done := testsuite.MustStart(cmd) + wg.Go(func() { + _err := <-done + log.Printf("completed test case %s\n%s", name, output.String()) + if _err != nil { + log.Fatalf("test case %s: %v", name, _err) + } + }) + + if err = json.NewEncoder(conf).Encode(&tc.Hakurei); err != nil { + log.Fatal(err) + } else if err = conf.Close(); err != nil { + log.Fatal(err) + } + + var id hst.ID + if _, err = io.ReadFull(ident, id[:]); err != nil { + log.Fatal(err) + } else if err = ident.Close(); err != nil { + log.Fatal(err) + } + + if _, err = io.ReadFull(notify, make([]byte, 8)); err != nil { + log.Fatal(err) + } else if err = notify.Close(); err != nil { + log.Fatal(err) + } + + var ( + ok bool + p hst.State + ) + entries, copyError := s.All() + for entry := range entries { + if entry.ID == id { + ok = true + if _, err = entry.Load(&p, nil); err != nil { + log.Fatal(err) + } + break + } + } + if err = copyError(); err != nil { + log.Fatal(err) + } + if !ok { + log.Fatalf("instance %s is not present in store", id) + } + + var stat syscall.Stat_t + pid := mustScanFor(func(ps *testsuite.StatScanner) bool { + select { + case err = <-done: + if err == nil { + log.Fatal("test process terminated unexpectedly") + } + log.Fatal(err) + default: + break + } + + if ps.Stat().Comm != "test-helper" { + return false + } + + var args []string + if args, err = ps.Stat().Args(); err != nil { + if testsuite.IsNotExist(err) { + return false + } + log.Fatal(err) + } else if !slices.Equal(args, tc.Hakurei.Container.Args) { + return false + } + + if err = ps.Stat().Stat(&stat); err != nil { + if testsuite.IsNotExist(err) { + return false + } + log.Fatal(err) + } + + uid := hst.ToUser[uint32](0, uint32(tc.Hakurei.Identity)) + if stat.Uid != uid || stat.Gid != uid { + return false + } + + var t []byte + if t, err = os.ReadFile(filepath.Join( + fhs.Proc, + strconv.Itoa(ps.Stat().PPID), + "stat", + )); err != nil { + if testsuite.IsNotExist(err) { + return false + } + log.Fatal(err) + } + + var _stat testsuite.Stat + if err = _stat.UnmarshalText(t); err != nil { + log.Fatal(err) + } + if _stat.PPID != p.ShimPID { + return false + } + + return true + }) + + testsuite.MustCheckFilter( + pid, + tc.Sum, + ) + }) + } + + wg.Wait() + + if dents, err := os.ReadDir("/tmp"); err != nil { + log.Fatal(err) + } else { + for _, dent := range dents { + if name := dent.Name(); strings.HasPrefix(name, ".hakurei-shim-") { + log.Fatalf("leftover shim work dir %q", name) + } + } + } +} diff --git a/cmd/hakurei/testsuite/sandbox/testdata/device.go b/cmd/hakurei/testsuite/sandbox/testdata/device.go new file mode 100644 index 00000000..5de9f550 --- /dev/null +++ b/cmd/hakurei/testsuite/sandbox/testdata/device.go @@ -0,0 +1,134 @@ +//go:build testsuite || tester + +package testdata + +import ( + "os" + "syscall" + + "hakurei.app/fhs" + "hakurei.app/hst" + "hakurei.app/internal/testsuite" + "hakurei.app/internal/testsuite/mountinfo" +) + +var _ = TestCase{ + Hakurei: hst.Config{ + ID: "app.hakurei.sample.device", + Enablements: new(hst.EWayland | hst.EPipeWire | hst.EDBus | hst.EX11), + Identity: 4, + + Container: &hst.ContainerConfig{ + Hostname: "hakurei-sample-device", + + Filesystem: []hst.FilesystemConfigJSON{ + fcLinker, + fcLib, + fcTestHelper, + }, + + Username: "u0_a4", + Shell: fhs.AbsUsrBin.Append("bash"), + Home: hst.AbsPrivateTmp, + Path: absTestHelper, + Args: []string{"tester", "device"}, + + Flags: hst.FDevice | hst.FShareTmpdir, + }, + }, + + // 0, PresetStrict + Sum: sumSimple, + + Env: []string{ + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus", + "DISPLAY=unix:/tmp/.X11-unix/X0", + "HOME=/.hakurei", + "SHELL=/usr/bin/bash", + "TERM=xterm", + "USER=u0_a4", + "WAYLAND_DISPLAY=wayland-0", + "XDG_RUNTIME_DIR=/run/user/65534", + "XDG_SESSION_CLASS=user", + "XDG_SESSION_TYPE=wayland", + "PULSE_SERVER=unix:/run/user/65534/pulse/native", + }, + + FS: &testsuite.FS{Dir: dir{ + ".hakurei": {Mode: os.ModeDir | 0755, Dir: dir{ + "test-helper": {Mode: 0755}, + }}, + + // unstable host dev + "dev": {Mode: os.ModeDir | 0755}, + + "etc": {Mode: os.ModeDir | 0755, Dir: dir{ + "passwd": {Mode: 0600, + Data: new("u0_a4:x:65534:65534:Hakurei:/.hakurei:/usr/bin/bash\n")}, + "group": {Mode: 0600, + Data: new("hakurei:x:65534:\n")}, + }}, + + "lib64": {Mode: os.ModeDir | 0755, Dir: dir{ + "ld-linux-x86-64.so.2": {Mode: os.ModeSymlink | 0777}, + }}, + + "run": {Mode: os.ModeDir | 0755, Dir: dir{ + "user": {Mode: os.ModeDir | 0755, Dir: dir{ + "65534": {Mode: os.ModeDir | 0700, Dir: dir{ + "bus": {Mode: os.ModeSocket | 0775}, + "wayland-0": {Mode: os.ModeSocket | 070}, + "pulse": {Mode: os.ModeDir | 0700, Dir: dir{ + "native": {Mode: os.ModeSocket | 0777}, + }}, + }}, + }}, + }}, + + "tmp": {Mode: os.ModeDir | 0770, Dir: dir{ + ".X11-unix": {Mode: os.ModeDir | 0755, Dir: dir{ + "X0": {Mode: os.ModeSocket | 0775}, + }}, + }}, + + "lib": {Mode: os.ModeDir | 0755}, + "proc": {Mode: os.ModeDir | 0555}, + }}, + + Mount: []*mountinfo.Entry{ + r("/sysroot", "/", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110003,gid=110003,inode64"), + r("/", "/proc", "rw,nosuid,nodev,noexec,relatime", "proc", "proc", "rw"), + r("/", "/.hakurei", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=4k,mode=755,uid=110003,gid=110003,inode64"), + + // host /dev in testing environment + r("/", "/dev", "rw,nosuid", "tmpfs", "tmpfs", ignore), + r("/", "/dev/pts", "rw,nosuid,noexec,relatime", "devpts", "devpts", "rw,gid=100004,mode=620,ptmxmode=666"), + r("/", "/dev/mqueue", "rw,nosuid,nodev,noexec,relatime", "mqueue", "mqueue", "rw"), + r("/kvm", "/dev/kvm", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/fuse", "/dev/fuse", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/", "/dev/shm", "rw,nosuid,nodev,noexec,relatime", "tmpfs", "shm", ignore), + r("/null", "/dev/null", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/zero", "/dev/zero", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/full", "/dev/full", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/tty", "/dev/tty", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/random", "/dev/random", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/urandom", "/dev/urandom", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + + r("/", "/dev/shm", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110003,gid=110003,inode64"), + r("/", "/run/user", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=16384k,mode=755,uid=110003,gid=110003,inode64"), + r("/tmp/hakurei.0/tmpdir/4", "/tmp", "rw,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r(ignore, "/etc/passwd", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110003,gid=110003,inode64"), + r(ignore, "/etc/group", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110003,gid=110003,inode64"), + r(ignore, "/run/user/65534/wayland-0", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r("/tmp/.X11-unix", "/tmp/.X11-unix", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r(ignore, "/run/user/65534/bus", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r("/usr/lib", "/lib", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r("/opt/test-helper/bin/tester", "/.hakurei/test-helper", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r(ignore, "/run/user/65534/pulse/native", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + }, + + Seccomp: true, + + TrySocket: "/tmp/.X11-unix/X0", + ErrnoAbstract: syscall.ECONNREFUSED, +}.register("device") diff --git a/cmd/hakurei/testsuite/sandbox/testdata/mapuid.go b/cmd/hakurei/testsuite/sandbox/testdata/mapuid.go new file mode 100644 index 00000000..218b035d --- /dev/null +++ b/cmd/hakurei/testsuite/sandbox/testdata/mapuid.go @@ -0,0 +1,124 @@ +//go:build testsuite || tester + +package testdata + +import ( + "os" + "syscall" + + "hakurei.app/fhs" + "hakurei.app/hst" + "hakurei.app/internal/testsuite" + "hakurei.app/internal/testsuite/mountinfo" +) + +var _ = TestCase{ + Hakurei: hst.Config{ + ID: "app.hakurei.sample.mapuid", + Enablements: new(hst.EWayland | hst.EPipeWire | hst.EDBus), + Identity: 3, + + Container: &hst.ContainerConfig{ + Hostname: "hakurei-sample-mapuid", + + Filesystem: []hst.FilesystemConfigJSON{ + fcLinker, + fcLib, + fcTestHelper, + }, + + Username: "u0_a3", + Shell: fhs.AbsUsrBin.Append("bash"), + Home: hst.AbsPrivateTmp, + Path: absTestHelper, + Args: []string{"tester", "mapuid"}, + + Flags: hst.FMapRealUID | hst.FShareRuntime | hst.FShareTmpdir, + }, + }, + + // 0, PresetStrict + Sum: sumSimple, + + Env: []string{ + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", + "HOME=/.hakurei", + "SHELL=/usr/bin/bash", + "TERM=xterm", + "USER=u0_a3", + "WAYLAND_DISPLAY=wayland-0", + "XDG_RUNTIME_DIR=/run/user/1000", + "XDG_SESSION_CLASS=user", + "XDG_SESSION_TYPE=wayland", + "PULSE_SERVER=unix:/run/user/1000/pulse/native", + }, + + FS: &testsuite.FS{Dir: dir{ + ".hakurei": {Mode: os.ModeDir | 0755, Dir: dir{ + "test-helper": {Mode: 0755}, + }}, + + // unstable host dev + "dev": {Mode: os.ModeDir | 0755}, + + "etc": {Mode: os.ModeDir | 0755, Dir: dir{ + "passwd": {Mode: 0600, + Data: new("u0_a3:x:1000:100:Hakurei:/.hakurei:/usr/bin/bash\n")}, + "group": {Mode: 0600, + Data: new("hakurei:x:100:\n")}, + }}, + + "lib64": {Mode: os.ModeDir | 0755, Dir: dir{ + "ld-linux-x86-64.so.2": {Mode: os.ModeSymlink | 0777}, + }}, + + "run": {Mode: os.ModeDir | 0755, Dir: dir{ + "user": {Mode: os.ModeDir | 0755, Dir: dir{ + "1000": {Mode: os.ModeDir | 0770, Dir: dir{ + "bus": {Mode: os.ModeSocket | 0775}, + "wayland-0": {Mode: os.ModeSocket | 070}, + "pulse": {Mode: os.ModeDir | 0700, Dir: dir{ + "native": {Mode: os.ModeSocket | 0777}, + }}, + }}, + }}, + }}, + + "tmp": {Mode: os.ModeDir | 0770, Dir: dir{}}, + + "lib": {Mode: os.ModeDir | 0755}, + "proc": {Mode: os.ModeDir | 0555}, + }}, + + Mount: []*mountinfo.Entry{ + r("/sysroot", "/", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110002,gid=110002,inode64"), + r("/", "/proc", "rw,nosuid,nodev,noexec,relatime", "proc", "proc", "rw"), + r("/", "/.hakurei", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=4k,mode=755,uid=110002,gid=110002,inode64"), + r("/", "/dev", "ro,nosuid,nodev,relatime", "tmpfs", "devtmpfs", "rw,mode=755,uid=110002,gid=110002,inode64"), + r("/null", "/dev/null", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/zero", "/dev/zero", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/full", "/dev/full", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/random", "/dev/random", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/urandom", "/dev/urandom", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/tty", "/dev/tty", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/", "/dev/pts", "rw,nosuid,noexec,relatime", "devpts", "devpts", "rw,mode=620,ptmxmode=666"), + r("/", "/dev/mqueue", "rw,nosuid,nodev,noexec,relatime", "mqueue", "mqueue", "rw"), + r("/", "/dev/shm", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110002,gid=110002,inode64"), + r("/", "/run/user", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=16384k,mode=755,uid=110002,gid=110002,inode64"), + r("/tmp/hakurei.0/runtime/3", "/run/user/1000", "rw,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r("/tmp/hakurei.0/tmpdir/3", "/tmp", "rw,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r(ignore, "/etc/passwd", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110002,gid=110002,inode64"), + r(ignore, "/etc/group", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110002,gid=110002,inode64"), + r(ignore, "/run/user/1000/wayland-0", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r(ignore, "/run/user/1000/bus", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r("/usr/lib", "/lib", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r("/opt/test-helper/bin/tester", "/.hakurei/test-helper", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r(ignore, "/run/user/1000/pulse/native", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + }, + + Seccomp: true, + + TrySocket: "/tmp/.X11-unix/X0", + ErrnoAbstract: syscall.ECONNREFUSED, + ErrnoPathname: syscall.ENOENT, +}.register("mapuid") diff --git a/cmd/hakurei/testsuite/sandbox/testdata/pdlike.go b/cmd/hakurei/testsuite/sandbox/testdata/pdlike.go new file mode 100644 index 00000000..5b58ed38 --- /dev/null +++ b/cmd/hakurei/testsuite/sandbox/testdata/pdlike.go @@ -0,0 +1,141 @@ +//go:build testsuite || tester + +package testdata + +import ( + "os" + "syscall" + + "hakurei.app/fhs" + "hakurei.app/hst" + "hakurei.app/internal/testsuite" + "hakurei.app/internal/testsuite/mountinfo" +) + +var _ = TestCase{ + Hakurei: hst.Config{ + ID: "app.hakurei.sample.pdlike", + Enablements: new(hst.EWayland | hst.EPipeWire | hst.EDBus), + Identity: 5, + + Container: &hst.ContainerConfig{ + Hostname: "hakurei-sample-pdlike", + + Filesystem: []hst.FilesystemConfigJSON{ + fcLinker, + fcLib, + fcTestHelper, + }, + + Username: "u0_a5", + Shell: fhs.AbsUsrBin.Append("bash"), + Home: hst.AbsPrivateTmp, + Path: absTestHelper, + Args: []string{"tester", "pdlike"}, + + Flags: hst.FHostNet | hst.FTty | hst.FUserns | hst.FShareRuntime | hst.FShareTmpdir, + }, + }, + + // 0, PresetExt | PresetDenyDevel + Sum: SumPD, + + Env: []string{ + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus", + "HOME=/.hakurei", + "SHELL=/usr/bin/bash", + "TERM=xterm", + "USER=u0_a5", + "WAYLAND_DISPLAY=wayland-0", + "XDG_RUNTIME_DIR=/run/user/65534", + "XDG_SESSION_CLASS=user", + "XDG_SESSION_TYPE=wayland", + "PULSE_SERVER=unix:/run/user/65534/pulse/native", + }, + + FS: &testsuite.FS{Dir: dir{ + ".hakurei": {Mode: os.ModeDir | 0755, Dir: dir{ + "test-helper": {Mode: 0755}, + }}, + + "dev": {Mode: os.ModeDir | 0755, Dir: dir{ + "core": {Mode: os.ModeSymlink | 0777}, + "fd": {Mode: os.ModeSymlink | 0777}, + "full": {Mode: os.ModeDevice | os.ModeCharDevice | 0666}, + "mqueue": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}}, + "null": {Mode: os.ModeDevice | os.ModeCharDevice | 0666, Data: new("")}, + "ptmx": {Mode: os.ModeSymlink | 0777}, + "pts": {Mode: os.ModeDir | 0755, Dir: dir{ + "ptmx": {Mode: os.ModeDevice | os.ModeCharDevice | 0666}, + }}, + "random": {Mode: os.ModeDevice | os.ModeCharDevice | 0666}, + "shm": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}}, + "stderr": {Mode: os.ModeSymlink | 0777}, + "stdin": {Mode: os.ModeSymlink | 0777}, + "stdout": {Mode: os.ModeSymlink | 0777}, + "tty": {Mode: os.ModeDevice | os.ModeCharDevice | 0666}, + "urandom": {Mode: os.ModeDevice | os.ModeCharDevice | 0444}, + "zero": {Mode: os.ModeDevice | os.ModeCharDevice | 0666}, + }}, + + "etc": {Mode: os.ModeDir | 0755, Dir: dir{ + "passwd": {Mode: 0600, + Data: new("u0_a5:x:65534:65534:Hakurei:/.hakurei:/usr/bin/bash\n")}, + "group": {Mode: 0600, + Data: new("hakurei:x:65534:\n")}, + }}, + + "lib64": {Mode: os.ModeDir | 0755, Dir: dir{ + "ld-linux-x86-64.so.2": {Mode: os.ModeSymlink | 0777}, + }}, + + "run": {Mode: os.ModeDir | 0755, Dir: dir{ + "user": {Mode: os.ModeDir | 0755, Dir: dir{ + "65534": {Mode: os.ModeDir | 0770, Dir: dir{ + "bus": {Mode: os.ModeSocket | 0775}, + "wayland-0": {Mode: os.ModeSocket | 070}, + "pulse": {Mode: os.ModeDir | 0700, Dir: dir{ + "native": {Mode: os.ModeSocket | 0777}, + }}, + }}, + }}, + }}, + + "tmp": {Mode: os.ModeDir | 0770, Dir: dir{}}, + + "lib": {Mode: os.ModeDir | 0755}, + "proc": {Mode: os.ModeDir | 0555}, + }}, + + Mount: []*mountinfo.Entry{ + r("/sysroot", "/", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110004,gid=110004,inode64"), + r("/", "/proc", "rw,nosuid,nodev,noexec,relatime", "proc", "proc", "rw"), + r("/", "/.hakurei", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=4k,mode=755,uid=110004,gid=110004,inode64"), + r("/", "/dev", "ro,nosuid,nodev,relatime", "tmpfs", "devtmpfs", "rw,mode=755,uid=110004,gid=110004,inode64"), + r("/null", "/dev/null", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/zero", "/dev/zero", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/full", "/dev/full", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/random", "/dev/random", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/urandom", "/dev/urandom", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/tty", "/dev/tty", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/", "/dev/pts", "rw,nosuid,noexec,relatime", "devpts", "devpts", "rw,mode=620,ptmxmode=666"), + r("/", "/dev/mqueue", "rw,nosuid,nodev,noexec,relatime", "mqueue", "mqueue", "rw"), + r("/", "/dev/shm", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110004,gid=110004,inode64"), + r("/", "/run/user", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=16384k,mode=755,uid=110004,gid=110004,inode64"), + r("/tmp/hakurei.0/runtime/5", "/run/user/65534", "rw,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r("/tmp/hakurei.0/tmpdir/5", "/tmp", "rw,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r(ignore, "/etc/passwd", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110004,gid=110004,inode64"), + r(ignore, "/etc/group", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110004,gid=110004,inode64"), + r(ignore, "/run/user/65534/wayland-0", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r(ignore, "/run/user/65534/bus", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r("/usr/lib", "/lib", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r("/opt/test-helper/bin/tester", "/.hakurei/test-helper", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r(ignore, "/run/user/65534/pulse/native", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + }, + + Seccomp: true, + + TrySocket: "/tmp/.X11-unix/X0", + ErrnoAbstract: syscall.EPERM, + ErrnoPathname: syscall.ENOENT, +}.register("pdlike") diff --git a/cmd/hakurei/testsuite/sandbox/testdata/simple.go b/cmd/hakurei/testsuite/sandbox/testdata/simple.go new file mode 100644 index 00000000..edb7c350 --- /dev/null +++ b/cmd/hakurei/testsuite/sandbox/testdata/simple.go @@ -0,0 +1,140 @@ +//go:build testsuite || tester + +package testdata + +import ( + "os" + "syscall" + + "hakurei.app/fhs" + "hakurei.app/hst" + "hakurei.app/internal/testsuite" + "hakurei.app/internal/testsuite/mountinfo" +) + +var _ = TestCase{ + Hakurei: hst.Config{ + ID: "app.hakurei.sample.simple", + Enablements: new(hst.EWayland | hst.EPipeWire | hst.EDBus), + Identity: 1, + + Container: &hst.ContainerConfig{ + Hostname: "hakurei-sample-simple", + Env: map[string]string{"HAKUREI_SAMPLE": "1"}, + + Filesystem: []hst.FilesystemConfigJSON{ + fcLinker, + fcLib, + fcTestHelper, + }, + + Username: "u0_a1", + Shell: fhs.AbsUsrBin.Append("bash"), + Home: hst.AbsPrivateTmp, + Path: absTestHelper, + Args: []string{"tester", "simple"}, + }, + }, + + // 0, PresetStrict + Sum: sumSimple, + + Env: []string{ + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus", + "HAKUREI_SAMPLE=1", + "HOME=/.hakurei", + "SHELL=/usr/bin/bash", + "TERM=xterm", + "USER=u0_a1", + "WAYLAND_DISPLAY=wayland-0", + "XDG_RUNTIME_DIR=/run/user/65534", + "XDG_SESSION_CLASS=user", + "XDG_SESSION_TYPE=wayland", + "PULSE_SERVER=unix:/run/user/65534/pulse/native", + }, + + FS: &testsuite.FS{Dir: dir{ + ".hakurei": {Mode: os.ModeDir | 0755, Dir: dir{ + "test-helper": {Mode: 0755}, + }}, + + "dev": {Mode: os.ModeDir | 0755, Dir: dir{ + "core": {Mode: os.ModeSymlink | 0777}, + "fd": {Mode: os.ModeSymlink | 0777}, + "full": {Mode: os.ModeDevice | os.ModeCharDevice | 0666}, + "mqueue": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}}, + "null": {Mode: os.ModeDevice | os.ModeCharDevice | 0666, Data: new("")}, + "ptmx": {Mode: os.ModeSymlink | 0777}, + "pts": {Mode: os.ModeDir | 0755, Dir: dir{ + "ptmx": {Mode: os.ModeDevice | os.ModeCharDevice | 0666}, + }}, + "random": {Mode: os.ModeDevice | os.ModeCharDevice | 0666}, + "shm": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}}, + "stderr": {Mode: os.ModeSymlink | 0777}, + "stdin": {Mode: os.ModeSymlink | 0777}, + "stdout": {Mode: os.ModeSymlink | 0777}, + "tty": {Mode: os.ModeDevice | os.ModeCharDevice | 0666}, + "urandom": {Mode: os.ModeDevice | os.ModeCharDevice | 0444}, + "zero": {Mode: os.ModeDevice | os.ModeCharDevice | 0666}, + }}, + + "etc": {Mode: os.ModeDir | 0755, Dir: dir{ + "passwd": {Mode: 0600, + Data: new("u0_a1:x:65534:65534:Hakurei:/.hakurei:/usr/bin/bash\n")}, + "group": {Mode: 0600, + Data: new("hakurei:x:65534:\n")}, + }}, + + "lib64": {Mode: os.ModeDir | 0755, Dir: dir{ + "ld-linux-x86-64.so.2": {Mode: os.ModeSymlink | 0777}, + }}, + + "run": {Mode: os.ModeDir | 0755, Dir: dir{ + "user": {Mode: os.ModeDir | 0755, Dir: dir{ + "65534": {Mode: os.ModeDir | 0700, Dir: dir{ + "bus": {Mode: os.ModeSocket | 0775}, + "wayland-0": {Mode: os.ModeSocket | 070}, + "pulse": {Mode: os.ModeDir | 0700, Dir: dir{ + "native": {Mode: os.ModeSocket | 0777}, + }}, + }}, + }}, + }}, + + "tmp": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}}, + + "lib": {Mode: os.ModeDir | 0755}, + "proc": {Mode: os.ModeDir | 0555}, + }}, + + Mount: []*mountinfo.Entry{ + r("/sysroot", "/", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110000,gid=110000,inode64"), + r("/", "/proc", "rw,nosuid,nodev,noexec,relatime", "proc", "proc", "rw"), + r("/", "/.hakurei", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=4k,mode=755,uid=110000,gid=110000,inode64"), + r("/", "/dev", "ro,nosuid,nodev,relatime", "tmpfs", "devtmpfs", "rw,mode=755,uid=110000,gid=110000,inode64"), + r("/null", "/dev/null", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/zero", "/dev/zero", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/full", "/dev/full", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/random", "/dev/random", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/urandom", "/dev/urandom", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/tty", "/dev/tty", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/", "/dev/pts", "rw,nosuid,noexec,relatime", "devpts", "devpts", "rw,mode=620,ptmxmode=666"), + r("/", "/dev/mqueue", "rw,nosuid,nodev,noexec,relatime", "mqueue", "mqueue", "rw"), + r("/", "/dev/shm", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110000,gid=110000,inode64"), + r("/", "/run/user", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=16384k,mode=755,uid=110000,gid=110000,inode64"), + r("/", "/tmp", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110000,gid=110000,inode64"), + r(ignore, "/etc/passwd", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110000,gid=110000,inode64"), + r(ignore, "/etc/group", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110000,gid=110000,inode64"), + r(ignore, "/run/user/65534/wayland-0", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r(ignore, "/run/user/65534/bus", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r("/usr/lib", "/lib", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r("/opt/test-helper/bin/tester", "/.hakurei/test-helper", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r(ignore, "/run/user/65534/pulse/native", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + }, + + Seccomp: true, + + TrySocket: "/tmp/.X11-unix/X0", + ErrnoAbstract: syscall.ECONNREFUSED, + ErrnoPathname: syscall.ENOENT, +}.register("simple") diff --git a/cmd/hakurei/testsuite/sandbox/testdata/sum.go b/cmd/hakurei/testsuite/sandbox/testdata/sum.go new file mode 100644 index 00000000..e4e8643a --- /dev/null +++ b/cmd/hakurei/testsuite/sandbox/testdata/sum.go @@ -0,0 +1,22 @@ +//go:build testsuite || tester + +package testdata + +import ( + "crypto/sha512" + "encoding/base64" + "strconv" +) + +// sum decodes s as [base64.StdEncoding] and panics if it is invalid or +// unexpectedly sized. +func sum(s string) [sha512.Size]byte { + p, err := base64.StdEncoding.DecodeString(s) + if err != nil { + panic(err) + } + if len(p) != sha512.Size { + panic("unexpected checksum sized " + strconv.Itoa(len(p))) + } + return ([sha512.Size]byte)(p) +} diff --git a/cmd/hakurei/testsuite/sandbox/testdata/sum_amd64.go b/cmd/hakurei/testsuite/sandbox/testdata/sum_amd64.go new file mode 100644 index 00000000..bd751105 --- /dev/null +++ b/cmd/hakurei/testsuite/sandbox/testdata/sum_amd64.go @@ -0,0 +1,9 @@ +//go:build testsuite || tester + +package testdata + +var ( + SumPD = sum("xpiwgf+Vev4XptlDdFN9N/KmP2+d112nVGVCQHqeMkduvaMxK6d4XX9hhUK8+vJ8on3MLd26hSBp0ovP6MrTmg==") + sumSimple = sum("6IApjfK9Z1HQBA/CG8DtTAD5XcDXulBsJE2LjPaGbbqO9KMylvKHtmzMwdeOlwJll/hMx97BVz4UiWD701zXNQ==") + sumTTY = sum("C3YAdHbByeJdv2dMKf32CaFlanAGPkkydlThtTYK09oG4aPjK/gOlhxVFq2D1Lnn6b3odqk3l+J2J9JVXCWFiw==") +) diff --git a/cmd/hakurei/testsuite/sandbox/testdata/sum_arm64.go b/cmd/hakurei/testsuite/sandbox/testdata/sum_arm64.go new file mode 100644 index 00000000..1691828f --- /dev/null +++ b/cmd/hakurei/testsuite/sandbox/testdata/sum_arm64.go @@ -0,0 +1,9 @@ +//go:build testsuite || tester + +package testdata + +var ( + SumPD = sum("QzzpuREoLW3MgCkxn7ebgWtg1aeV7I/JQ0TdAnYU1o8CMWapG7iB+q7u3Sbj2JR04UHlppqX6TuJhMqPFJmZgA==") + sumSimple = sum("eTGFOKPchRMUtr2W8Q1YYayyqn4Ty43gYZ0PanZwnWfwHvP9Z+GVhisC+XEeW3abxNHrT8DfxBpyPInJaKkylw==") + sumTTY = sum("zx9NyHQ2uo7JXSaLZjpjl7sLSlrGTYVX5sxSnYsPb2Xa06krYu0p2F7unG3eEmd1ek0PhgMuikXKG86t+jTPXg==") +) diff --git a/cmd/hakurei/testsuite/sandbox/testdata/testdata.go b/cmd/hakurei/testsuite/sandbox/testdata/testdata.go new file mode 100644 index 00000000..bdb5178e --- /dev/null +++ b/cmd/hakurei/testsuite/sandbox/testdata/testdata.go @@ -0,0 +1,125 @@ +//go:build testsuite || tester + +// Package testdata holds sandbox inspection test cases. +package testdata + +import ( + "crypto/sha512" + "iter" + "log" + "strconv" + "syscall" + + "hakurei.app/check" + "hakurei.app/fhs" + "hakurei.app/hst" + "hakurei.app/internal/testsuite" + "hakurei.app/internal/testsuite/mountinfo" +) + +// A TestCase represents a named test case that may be requested by the caller. +type TestCase struct { + // Configuration of the inspected container. + Hakurei hst.Config + // Checksum of expected seccomp filter program. + Sum [sha512.Size]byte + + // Expected environment. Skipped if nil. + Env []string `json:"env,omitempty"` + // Expected root filesystem. Skipped if nil. + FS *testsuite.FS `json:"fs,omitempty"` + // Expected mountinfo records. Skipped if nil. + Mount []*mountinfo.Entry `json:"mount,omitempty"` + // Whether to run seccomp checks. + Seccomp bool `json:"seccomp,omitempty"` + + // Name of pathname and abstract sockets to attempt. + TrySocket string `json:"try_socket,omitempty"` + // Errno to expect attempting to reach the abstract socket. + ErrnoAbstract syscall.Errno `json:"errno_abstract,omitempty"` + // Errno to expect attempting to reach the pathname socket. + ErrnoPathname syscall.Errno `json:"errno_pathname,omitempty"` +} + +// testCases hold all named test cases. +var testCases map[string]TestCase + +// fc returns c wrapped in its JSON adapter. +func fc(c hst.FilesystemConfig) hst.FilesystemConfigJSON { + return hst.FilesystemConfigJSON{ + FilesystemConfig: c, + } +} + +// ignore is the magic string for a mountinfo field to be ignored. +const ignore = "//ignore" + +type dir = map[string]*testsuite.FS + +// r returns the address of a [mountinfo.Entry]. +func r( + root, target, vfsOptstr string, + fsType, source, fsOptstr string, +) *mountinfo.Entry { + return &mountinfo.Entry{ + ID: -1, + Parent: -1, + Root: root, + Target: target, + VfsOptstr: vfsOptstr, + FsType: fsType, + Source: source, + FsOptstr: fsOptstr, + } +} + +var ( + // fcLinker is the dynamic linker symlink. + fcLinker = fc(&hst.FSLink{ + Target: fhs.AbsRoot.Append("lib64", "ld-linux-x86-64.so.2"), + Linkname: "../lib/x86_64-linux-gnu/ld-linux-x86-64.so.2", + }) + // fcLib is the dynamic library bind mount. + fcLib = fc(&hst.FSBind{Source: fhs.AbsRoot.Append("lib")}) + + // absTestHelper is the absolute pathname of the test helper program. + absTestHelper = hst.AbsPrivateTmp.Append("test-helper") + // fcTestHelper is the test helper bind mount. + fcTestHelper = fc(&hst.FSBind{ + Target: absTestHelper, + Source: check.MustAbs("/opt/test-helper/bin/tester"), + }) +) + +// register adds a test case to testCases. +func (c TestCase) register(name string) (_ struct{}) { + if testCases == nil { + testCases = make(map[string]TestCase) + } + + if _, ok := testCases[name]; ok { + panic("attempting to register " + strconv.Quote(name) + " twice") + } + testCases[name] = c + return +} + +// Get returns the named test case, or terminates the program if name is invalid. +func Get(name string) TestCase { + tc, ok := testCases[name] + if !ok { + log.Fatalf("invalid test case %q", name) + } + return tc +} + +// All returns an iterator over all named test cases. +func All() iter.Seq2[string, TestCase] { + return func(yield func(string, TestCase) bool) { + for name, tc := range testCases { + if !yield(name, tc) { + return + } + } + } +} diff --git a/cmd/hakurei/testsuite/sandbox/testdata/tty.go b/cmd/hakurei/testsuite/sandbox/testdata/tty.go new file mode 100644 index 00000000..2a3ba76e --- /dev/null +++ b/cmd/hakurei/testsuite/sandbox/testdata/tty.go @@ -0,0 +1,145 @@ +//go:build testsuite || tester + +package testdata + +import ( + "os" + + "hakurei.app/fhs" + "hakurei.app/hst" + "hakurei.app/internal/testsuite" + "hakurei.app/internal/testsuite/mountinfo" +) + +var _ = TestCase{ + Hakurei: hst.Config{ + ID: "app.hakurei.sample.tty", + Enablements: new(hst.EWayland | hst.EPipeWire | hst.EDBus | hst.EX11), + Identity: 2, + + Container: &hst.ContainerConfig{ + Hostname: "hakurei-sample-tty", + + Filesystem: []hst.FilesystemConfigJSON{ + fcLinker, + fcLib, + fcTestHelper, + }, + + Username: "u0_a2", + Shell: fhs.AbsUsrBin.Append("bash"), + Home: hst.AbsPrivateTmp, + Path: absTestHelper, + Args: []string{"tester", "tty"}, + + Flags: hst.FHostNet | hst.FHostAbstract | + hst.FTty | hst.FShareRuntime, + }, + }, + + // 0, PresetExt | PresetDenyNS | PresetDenyDevel + Sum: sumTTY, + + Env: []string{ + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/65534/bus", + "DISPLAY=:0", + "HOME=/.hakurei", + "SHELL=/usr/bin/bash", + "TERM=xterm", + "USER=u0_a2", + "WAYLAND_DISPLAY=wayland-0", + "XDG_RUNTIME_DIR=/run/user/65534", + "XDG_SESSION_CLASS=user", + "XDG_SESSION_TYPE=wayland", + "PULSE_SERVER=unix:/run/user/65534/pulse/native", + }, + + FS: &testsuite.FS{Dir: dir{ + ".hakurei": {Mode: os.ModeDir | 0755, Dir: dir{ + "test-helper": {Mode: 0755}, + }}, + + "dev": {Mode: os.ModeDir | 0755, Dir: dir{ + "core": {Mode: os.ModeSymlink | 0777}, + "fd": {Mode: os.ModeSymlink | 0777}, + "full": {Mode: os.ModeDevice | os.ModeCharDevice | 0666}, + "mqueue": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}}, + "null": {Mode: os.ModeDevice | os.ModeCharDevice | 0666, Data: new("")}, + "ptmx": {Mode: os.ModeSymlink | 0777}, + "pts": {Mode: os.ModeDir | 0755, Dir: dir{ + "ptmx": {Mode: os.ModeDevice | os.ModeCharDevice | 0666}, + }}, + "random": {Mode: os.ModeDevice | os.ModeCharDevice | 0666}, + "shm": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{}}, + "stderr": {Mode: os.ModeSymlink | 0777}, + "stdin": {Mode: os.ModeSymlink | 0777}, + "stdout": {Mode: os.ModeSymlink | 0777}, + "tty": {Mode: os.ModeDevice | os.ModeCharDevice | 0666}, + "urandom": {Mode: os.ModeDevice | os.ModeCharDevice | 0444}, + "zero": {Mode: os.ModeDevice | os.ModeCharDevice | 0666}, + }}, + + "etc": {Mode: os.ModeDir | 0755, Dir: dir{ + "passwd": {Mode: 0600, + Data: new("u0_a2:x:65534:65534:Hakurei:/.hakurei:/usr/bin/bash\n")}, + "group": {Mode: 0600, + Data: new("hakurei:x:65534:\n")}, + }}, + + "lib64": {Mode: os.ModeDir | 0755, Dir: dir{ + "ld-linux-x86-64.so.2": {Mode: os.ModeSymlink | 0777}, + }}, + + "run": {Mode: os.ModeDir | 0755, Dir: dir{ + "user": {Mode: os.ModeDir | 0755, Dir: dir{ + "65534": {Mode: os.ModeDir | 0770, Dir: dir{ + "bus": {Mode: os.ModeSocket | 0775}, + "wayland-0": {Mode: os.ModeSocket | 070}, + "pulse": {Mode: os.ModeDir | 0700, Dir: dir{ + "native": {Mode: os.ModeSocket | 0777}, + }}, + }}, + }}, + }}, + + "tmp": {Mode: os.ModeDir | os.ModeSticky | 0777, Dir: dir{ + ".X11-unix": {Mode: os.ModeDir | 0755, Dir: dir{ + "X0": {Mode: os.ModeSocket | 0775}, + }}, + }}, + + "lib": {Mode: os.ModeDir | 0755}, + "proc": {Mode: os.ModeDir | 0555}, + }}, + + Mount: []*mountinfo.Entry{ + r("/sysroot", "/", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110001,gid=110001,inode64"), + r("/", "/proc", "rw,nosuid,nodev,noexec,relatime", "proc", "proc", "rw"), + r("/", "/.hakurei", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=4k,mode=755,uid=110001,gid=110001,inode64"), + r("/", "/dev", "ro,nosuid,nodev,relatime", "tmpfs", "devtmpfs", "rw,mode=755,uid=110001,gid=110001,inode64"), + r("/null", "/dev/null", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/zero", "/dev/zero", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/full", "/dev/full", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/random", "/dev/random", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/urandom", "/dev/urandom", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/tty", "/dev/tty", "rw,nosuid,noexec,relatime", "devtmpfs", "devtmpfs", ignore), + r("/", "/dev/pts", "rw,nosuid,noexec,relatime", "devpts", "devpts", "rw,mode=620,ptmxmode=666"), + r("/", "/dev/mqueue", "rw,nosuid,nodev,noexec,relatime", "mqueue", "mqueue", "rw"), + r("/", "/dev/shm", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110001,gid=110001,inode64"), + r("/", "/run/user", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,size=16384k,mode=755,uid=110001,gid=110001,inode64"), + r("/tmp/hakurei.0/runtime/2", "/run/user/65534", "rw,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r("/", "/tmp", "rw,nosuid,nodev,relatime", "tmpfs", "ephemeral", "rw,uid=110001,gid=110001,inode64"), + r(ignore, "/etc/passwd", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110001,gid=110001,inode64"), + r(ignore, "/etc/group", "ro,nosuid,nodev,relatime", "tmpfs", "rootfs", "rw,uid=110001,gid=110001,inode64"), + r(ignore, "/run/user/65534/wayland-0", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r("/tmp/.X11-unix", "/tmp/.X11-unix", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r(ignore, "/run/user/65534/bus", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r("/usr/lib", "/lib", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r("/opt/test-helper/bin/tester", "/.hakurei/test-helper", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + r(ignore, "/run/user/65534/pulse/native", "ro,nosuid,nodev,relatime", "overlay", "overlay", ignore), + }, + + Seccomp: true, + + TrySocket: "/tmp/.X11-unix/X0", +}.register("tty") diff --git a/cmd/hakurei/testsuite/sandbox/tester/main.go b/cmd/hakurei/testsuite/sandbox/tester/main.go new file mode 100644 index 00000000..452712d9 --- /dev/null +++ b/cmd/hakurei/testsuite/sandbox/tester/main.go @@ -0,0 +1,224 @@ +//go:build tester + +// The sandbox tester runs within a cmd/hakurei container and validates its +// state. Since the test environment is relatively predictable, the tester can +// make various assumptions about the host. +package main + +import ( + "errors" + "log" + "net" + "os" + "os/signal" + "path/filepath" + "syscall" + + "hakurei.app/cmd/hakurei/testsuite/sandbox/testdata" + "hakurei.app/internal/testsuite/mountinfo" +) + +//#include <sys/quota.h> +import "C" + +// mustAbs returns s, or terminates the program if s is not absolute. +func mustAbs(s string) string { + if !filepath.IsAbs(s) { + log.Fatalf("%q is not absolute", s) + } + return s +} + +func main() { + log.SetFlags(0) + log.SetPrefix("tester: ") + + if len(os.Args) != 2 { + log.Fatal("tester requires 1 argument") + } + want := testdata.Get(os.Args[1]) + log.SetPrefix("tester: " + os.Args[1] + " ") + + checkWritableDirPaths := []string{ + "/dev/shm", + "/tmp", + os.Getenv("XDG_RUNTIME_DIR"), + } + for _, a := range checkWritableDirPaths { + pathname := filepath.Join(mustAbs(a), ".hakurei-check") + if err := os.WriteFile(pathname, make([]byte, 1<<8), 0600); err != nil { + log.Fatalf("[FAIL] %s", err) + } else if err = os.Remove(pathname); err != nil { + log.Fatalf("[FAIL] %s", err) + } else { + log.Printf("[ OK ] %s is writable", a) + } + } + + if want.Env != nil { + var ( + fail bool + i int + got string + ) + for i, got = range os.Environ() { + if i == len(want.Env) { + log.Fatalf("got more than %d environment variables", len(want.Env)) + } + if got != want.Env[i] { + fail = true + log.Printf("[FAIL] %s", got) + } else { + log.Printf("[ OK ] %s", got) + } + } + + i++ + if i != len(want.Env) { + log.Fatalf("got %d environment variables, want %d", i, len(want.Env)) + } + + if fail { + log.Fatalf("[FAIL] some environment variables did not match") + } + } else { + log.Printf("[SKIP] skipping environ check") + } + + if want.FS != nil { + if err := want.FS.Compare(log.Printf, ".", os.DirFS("/")); err != nil { + log.Fatalf("%v", err) + } + } else { + log.Printf("[SKIP] skipping fs check") + } + + if want.Mount != nil { + var fail bool + + m, err := mountinfo.Open("") + if err != nil { + log.Fatal(err) + } + + i := 0 + var ent mountinfo.Entry + for m.Next() { + m.Copy(&ent) + + if i == len(want.Mount) { + log.Fatalf("got more than %d entries", i) + } + if !ent.EqualWithIgnore(want.Mount[i], "//ignore") { + fail = true + log.Printf("[FAIL] %s", &ent) + } else { + log.Printf("[ OK ] %s", &ent) + } + + i++ + } + if err = m.Err(); err != nil { + log.Fatalf("%v", err) + } + + if i != len(want.Mount) { + log.Fatalf("got %d entries, want %d", i, len(want.Mount)) + } + + if fail { + log.Fatalf("[FAIL] some mount points did not match") + } + } else { + log.Printf("[SKIP] skipping mounts check") + } + + if want.Seccomp { + const NULL = 0 + + for _, tc := range []struct { + name string + errno syscall.Errno + + trap, a1, a2, a3, a4, a5, a6 uintptr + }{ + {"syslog", syscall.EPERM, syscall.SYS_SYSLOG, 0, NULL, NULL, NULL, NULL, NULL}, + {"acct", syscall.EPERM, syscall.SYS_ACCT, 0, NULL, NULL, NULL, NULL, NULL}, + {"quotactl", syscall.EPERM, syscall.SYS_QUOTACTL, C.Q_GETQUOTA, NULL, uintptr(os.Getuid()), NULL, NULL, NULL}, + {"add_key", syscall.EPERM, syscall.SYS_ADD_KEY, NULL, NULL, NULL, NULL, NULL, NULL}, + {"keyctl", syscall.EPERM, syscall.SYS_KEYCTL, NULL, NULL, NULL, NULL, NULL, NULL}, + {"request_key", syscall.EPERM, syscall.SYS_REQUEST_KEY, NULL, NULL, NULL, NULL, NULL, NULL}, + {"move_pages", syscall.EPERM, syscall.SYS_MOVE_PAGES, uintptr(os.Getpid()), NULL, NULL, NULL, NULL, NULL}, + {"mbind", syscall.EPERM, syscall.SYS_MBIND, NULL, NULL, NULL, NULL, NULL, NULL}, + {"get_mempolicy", syscall.EPERM, syscall.SYS_GET_MEMPOLICY, NULL, NULL, NULL, NULL, NULL, NULL}, + {"set_mempolicy", syscall.EPERM, syscall.SYS_SET_MEMPOLICY, NULL, NULL, NULL, NULL, NULL, NULL}, + {"migrate_pages", syscall.EPERM, syscall.SYS_MIGRATE_PAGES, NULL, NULL, NULL, NULL, NULL, NULL}, + } { + if _, _, errno := syscall.Syscall6(tc.trap, tc.a1, tc.a2, tc.a3, tc.a4, tc.a5, tc.a6); errno != tc.errno { + log.Fatalf("[FAIL] %s: %v, want %v", tc.name, errno, tc.errno) + } + log.Printf("[ OK ] %s: %v", tc.name, tc.errno) + } + } else { + log.Printf("[SKIP] skipping seccomp check") + } + + if want.TrySocket != "" { + retry: + abstractConn, abstractErr := net.Dial("unix", "@"+want.TrySocket) + pathnameConn, pathnameErr := net.Dial("unix", want.TrySocket) + ok := true + + if abstractErr == nil { + if err := abstractConn.Close(); err != nil { + ok = false + log.Printf("Close: %v", err) + } + } + if pathnameErr == nil { + if err := pathnameConn.Close(); err != nil { + ok = false + log.Printf("Close: %v", err) + } + } + + if errors.Is( + abstractErr, + syscall.EAGAIN, + ) || errors.Is( + pathnameErr, + syscall.EAGAIN, + ) { + goto retry + } + + abstractWantErr := error(want.ErrnoAbstract) + pathnameWantErr := error(want.ErrnoPathname) + if want.ErrnoAbstract == 0 { + abstractWantErr = nil + } + if want.ErrnoPathname == 0 { + pathnameWantErr = nil + } + + if !errors.Is(abstractErr, abstractWantErr) { + ok = false + log.Printf("abstractErr: %v, want %v", abstractErr, abstractWantErr) + } + if !errors.Is(pathnameErr, pathnameWantErr) { + ok = false + log.Printf("pathnameErr: %v, want %v", pathnameErr, pathnameWantErr) + } + + if !ok { + os.Exit(1) + } + } + + s := make(chan os.Signal, 1) + signal.Notify(s, syscall.SIGTERM) + if _, err := os.Stdout.Write(make([]byte, 8)); err != nil { + log.Fatalf("cannot notify testsuite: %v", err) + } + <-s +} diff --git a/cmd/hakurei/testsuite/test.py b/cmd/hakurei/testsuite/test.py new file mode 100644 index 00000000..98f08275 --- /dev/null +++ b/cmd/hakurei/testsuite/test.py @@ -0,0 +1,315 @@ +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.wait_for_file(f"/tmp/{name}.ps") + machine.copy_from_vm(f"/tmp/{name}.ps", "") + swaymsg(f"exec hakurei --json ps > '/tmp/{name}.json'") + machine.wait_for_file(f"/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 = instances[0] + + command = f"{name}-start" + if not (instance['container']['path'].startswith("/nix/store/")) or not (instance['container']['path'].endswith(command)): + raise Exception(f"unexpected path {instance['path']}") + + if len(instance['container']['args']) != 1 or instance['container']['args'][0] != command: + raise Exception(f"unexpected args {instance['args']}") + + if instance['enablements'] != enablements: + raise Exception(f"unexpected enablements {instance['enablements']['enablements']}") + + +def hakurei(command): + swaymsg(f"exec hakurei {command}") + + +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") + +# Run hakurei Go tests outside of nix build in the background: +swaymsg("exec hakurei-test") + +# Deny unmapped uid: +denyOutput = machine.fail("sudo -u untrusted -i hakurei exec &>/dev/stdout") +print(denyOutput) +denyOutputVerbose = machine.fail("sudo -u untrusted -i hakurei -v exec &>/dev/stdout") +print(denyOutputVerbose) + +# Direct hsu call: +userid = machine.succeed("sudo -u alice -i hsu") +if userid != "0": + raise Exception(f"unexpected userid: {userid}") + +# Verify hsu fault behaviour: +if denyOutput != "hsu: uid 1001 is not in the hsurc file\n": + raise Exception(f"unexpected deny output:\n{denyOutput}") +if denyOutputVerbose != "hsu: uid 1001 is not in the hsurc file\nhakurei: *cannot retrieve user id from setuid wrapper: current user is not in the hsurc file\n": + raise Exception(f"unexpected deny verbose output:\n{denyOutputVerbose}") + +# Verify timeout behaviour: +machine.succeed('sudo -u alice -i hakurei-check-linger-timeout > /var/tmp/linger-stdout 2> /var/tmp/linger-stderr || (cat /var/tmp/linger-stderr; false)') +linger_stdout = machine.succeed("cat /var/tmp/linger-stdout") +linger_stderr = machine.succeed("cat /var/tmp/linger-stderr") +if linger_stdout != "": + raise Exception(f"unexpected stdout: {linger_stdout}") +if linger_stderr != "init: timeout exceeded waiting for lingering processes\n": + raise Exception(f"unexpected stderr: {linger_stderr}") + +check_offset = 0 + + +def hakurei_identity(offset): + return 1+check_offset+offset + + +# Start hakurei permissive defaults outside Wayland session: +print(machine.succeed("sudo -u alice -i hakurei -v exec -a 0 touch /tmp/pd-bare-ok")) +machine.wait_for_file("/tmp/hakurei.0/tmpdir/0/pd-bare-ok") + +# Verify silent output permissive defaults: +output = machine.succeed("sudo -u alice -i hakurei exec -a 0 true &>/dev/stdout") +if output != "": + raise Exception(f"unexpected output\n{output}") + +# Verify silent output permissive defaults signal: +def silent_output_interrupt(flags): + swaymsg("exec foot") + wait_for_window("alice@machine") + # identity 0 does not have home-manager + machine.send_chars(f"exec hakurei exec {flags}-a 0 sh -c 'export PATH=/run/current-system/sw/bin:$PATH && touch /tmp/pd-silent-ready && sleep infinity' &>/tmp/pd-silent\n") + machine.wait_for_file("/tmp/hakurei.0/tmpdir/0/pd-silent-ready") + machine.succeed("rm /tmp/hakurei.0/tmpdir/0/pd-silent-ready") + machine.send_key("ctrl-c") + machine.wait_until_fails("pgrep foot") + machine.wait_until_fails(f"pgrep -u alice -f 'hakurei exec {flags}-a 0 '") + output = machine.succeed("cat /tmp/pd-silent && rm /tmp/pd-silent") + if output != "": + raise Exception(f"unexpected output\n{output}") + + +silent_output_interrupt("") +silent_output_interrupt("--dbus ") # this one is especially painful as it maintains a helper +silent_output_interrupt("--wayland -X --dbus --pulse ") + +# Verify graceful failure on bad Wayland display name: +print(machine.fail("sudo -u alice -i hakurei -v exec --wayland true")) + +# Start hakurei permissive defaults within Wayland session: +hakurei('-v exec --wayland --dbus --dbus-log notify-send -a "NixOS Tests" "Test notification" "Notification from within sandbox." && touch /tmp/dbus-ok') +machine.wait_for_file("/tmp/dbus-ok") +collect_state_ui("dbus_notify_exited") +# not in pid namespace, verify termination +machine.wait_until_fails("pgrep xdg-dbus-proxy") +machine.succeed("pkill -9 mako") + +# Check revert type selection: +hakurei("-v exec --wayland -X --dbus --pulse -u p0 foot && touch /tmp/p0-exit-ok") +wait_for_window("p0@machine") +print(machine.succeed("getfacl --absolute-names --omit-header --numeric /tmp/hakurei.0/runtime | grep 10000")) +hakurei("-v exec --wayland -X --dbus --pulse -u p1 foot && touch /tmp/p1-exit-ok") +wait_for_window("p1@machine") +print(machine.succeed("getfacl --absolute-names --omit-header --numeric /tmp/hakurei.0/runtime | grep 10000")) +machine.send_chars("exit\n") +machine.wait_for_file("/tmp/p1-exit-ok") +# Verify acl is kept alive: +print(machine.succeed("getfacl --absolute-names --omit-header --numeric /tmp/hakurei.0/runtime | grep 10000")) +machine.send_chars("exit\n") +machine.wait_for_file("/tmp/p0-exit-ok") +machine.fail("getfacl --absolute-names --omit-header --numeric /tmp/hakurei.0/runtime | grep 10000") + +# Check invalid identifier fd behaviour: +machine.fail('echo \'{"container":{"shell":"/proc/nonexistent","home":"/proc/nonexistent","path":"/proc/nonexistent"}}\' | sudo -u alice -i hakurei -v run --identifier-fd 32767 - 2>&1 | tee > /tmp/invalid-identifier-fd') +machine.wait_for_file("/tmp/invalid-identifier-fd") +print(machine.succeed('grep "^hakurei: cannot write identifier: bad file descriptor$" /tmp/invalid-identifier-fd')) + +# Check interrupt shim behaviour: +swaymsg("exec sh -c 'ne-foot; echo -n $? > /tmp/monitor-exit-code'") +wait_for_window(f"u0_a{hakurei_identity(0)}@machine") +machine.succeed("pkill -INT -f 'hakurei -v run '") +machine.wait_until_fails("pgrep foot") +machine.wait_for_file("/tmp/monitor-exit-code") +interrupt_exit_code = int(machine.succeed("cat /tmp/monitor-exit-code")) +if interrupt_exit_code != 230: + raise Exception(f"unexpected exit code {interrupt_exit_code}") + +# Check interrupt shim behaviour immediate termination: +swaymsg("exec sh -c 'ne-foot-immediate; echo -n $? > /tmp/monitor-exit-code'") +wait_for_window(f"u0_a{hakurei_identity(0)}@machine") +machine.succeed("pkill -INT -f 'hakurei -v run '") +machine.wait_until_fails("pgrep foot") +machine.wait_for_file("/tmp/monitor-exit-code") +interrupt_exit_code = int(machine.succeed("cat /tmp/monitor-exit-code")) +if interrupt_exit_code != 254: + raise Exception(f"unexpected exit code {interrupt_exit_code}") + +# Check shim SIGCONT from unexpected process behaviour: +swaymsg("exec sh -c 'ne-foot &> /tmp/shim-cont-unexpected-pid'") +wait_for_window(f"u0_a{hakurei_identity(0)}@machine") +machine.succeed("pkill -CONT -f 'hakurei shim'") +machine.succeed("pkill -INT -f 'hakurei -v run '") +machine.wait_until_fails("pgrep foot") +machine.wait_for_file("/tmp/shim-cont-unexpected-pid") +print(machine.succeed('grep "shim: got SIGCONT from unexpected process$" /tmp/shim-cont-unexpected-pid')) + +# Check setscheduler: +sched_unset = int(machine.succeed("sudo -u alice -i hakurei -v exec cat /proc/self/sched | grep '^policy' | tr -d ' ' | cut -d ':' -f 2")) +if sched_unset != 0: + raise Exception(f"unexpected unset policy: {sched_unset}") +sched_idle = int(machine.succeed("sudo -u alice -i hakurei -v exec --policy=idle cat /proc/self/sched | grep '^policy' | tr -d ' ' | cut -d ':' -f 2")) +if sched_idle != 5: + raise Exception(f"unexpected idle policy: {sched_idle}") +sched_rr = int(machine.succeed("sudo -u alice -i hakurei -v exec --policy=rr cat /proc/self/sched | grep '^policy' | tr -d ' ' | cut -d ':' -f 2")) +if sched_rr != 2: + raise Exception(f"unexpected round-robin policy: {sched_idle}") + +# Start app (foot) with Wayland enablement: +swaymsg("exec ne-foot") +wait_for_window(f"u0_a{hakurei_identity(0)}@machine") +machine.send_chars("clear; wayland-info && touch /var/tmp/client-ok\n") +machine.wait_for_file("/var/tmp/client-ok") +collect_state_ui("foot_wayland") +check_state("ne-foot", {"wayland": True}) +# Verify lack of acl on XDG_RUNTIME_DIR: +machine.fail(f"getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep {hakurei_identity(0) + 10000}") +machine.send_chars("exit\n") +machine.wait_until_fails("pgrep foot") +machine.fail(f"getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep {hakurei_identity(0) + 10000}") + +# Test pipewire-pulse: +swaymsg("exec pa-foot") +wait_for_window(f"u0_a{hakurei_identity(1)}@machine") +machine.send_chars("clear; pactl info && touch /var/tmp/pulse-ok\n") +machine.wait_for_file("/var/tmp/pulse-ok") +collect_state_ui("pulse_wayland") +check_state("pa-foot", {"wayland": True, "pipewire": True}) +machine.fail("find /tmp -maxdepth 1 -type d -name '.hakurei-shim-*' -print -exec false '{}' +") +machine.send_chars("exit\n") +machine.wait_until_fails("pgrep foot") +machine.wait_until_fails("pgrep -x hakurei") +machine.succeed("find /tmp -maxdepth 1 -type d -name '.hakurei-shim-*' -print -exec false '{}' +") +# Test PipeWire SecurityContext: +machine.succeed("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 hakurei -v exec --pulse pactl info") +machine.fail("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 hakurei -v exec --pulse pactl set-sink-mute @DEFAULT_SINK@ toggle") +# Test PipeWire direct access: +machine.succeed("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 pw-dump") +machine.fail("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 hakurei -v exec --pipewire pw-dump") + +# Test XWayland (foot does not support X): +swaymsg("exec x11-alacritty") +wait_for_window(f"u0_a{hakurei_identity(0)}@machine") +machine.send_chars("clear; glinfo && touch /var/tmp/x11-ok\n") +machine.wait_for_file("/var/tmp/x11-ok") +collect_state_ui("alacritty_x11") +check_state("x11-alacritty", {"x11": True}) +machine.send_chars("exit\n") +machine.wait_until_fails("pgrep alacritty") + +# Start app (foot) with direct Wayland access: +swaymsg("exec da-foot") +wait_for_window(f"u0_a{hakurei_identity(3)}@machine") +machine.send_chars("clear; wayland-info && touch /var/tmp/direct-ok\n") +collect_state_ui("foot_direct") +machine.wait_for_file("/var/tmp/direct-ok") +check_state("da-foot", {"wayland": True}) +# Verify acl on XDG_RUNTIME_DIR: +print(machine.succeed(f"getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep {hakurei_identity(3) + 10000}")) +machine.send_chars("exit\n") +machine.wait_until_fails("pgrep foot") +# Verify acl cleanup on XDG_RUNTIME_DIR: +machine.wait_until_fails(f"getfacl --absolute-names --omit-header --numeric /run/user/1000 | grep {hakurei_identity(3) + 10000}") + +# Test syscall filter: +print(machine.fail("sudo -u alice -i XDG_RUNTIME_DIR=/run/user/1000 strace-failure")) + +# Start app (foot) with Wayland enablement from a terminal: +swaymsg("exec foot $SHELL -c '(ne-foot) & disown && exec $SHELL'") +wait_for_window(f"u0_a{hakurei_identity(0)}@machine") +machine.send_chars("clear; wayland-info && touch /var/tmp/term-ok\n") +machine.wait_for_file("/var/tmp/term-ok") +machine.send_key("alt-h") +machine.send_chars("clear; hakurei show $(hakurei ps --short) && touch /tmp/ps-show-ok && exec cat\n") +machine.wait_for_file("/tmp/ps-show-ok") +collect_state_ui("foot_wayland_term") +check_state("ne-foot", {"wayland": True}) +machine.send_key("alt-l") +machine.send_chars("exit\n") +wait_for_window("alice@machine") +machine.send_key("ctrl-c") +machine.wait_until_fails("pgrep foot") + +# Exit Sway and verify process exit status 0: +machine.wait_until_fails("pgrep -x hakurei") +swaymsg("exit", succeed=False) +machine.wait_for_file("/tmp/sway-exit-ok") + +# Print hakurei share and rundir contents: +print(machine.succeed("find /tmp/hakurei.0 " + + "-path '/tmp/hakurei.0/runtime/*/*' -prune -o " + + "-path '/tmp/hakurei.0/tmpdir/*/*' -prune -o " + + "-print")) +print(machine.succeed("find /run/user/1000/hakurei")) +machine.succeed("find /tmp -maxdepth 1 -type d -name '.hakurei-shim-*' -print -exec false '{}' +") + +# Verify go test status: +machine.wait_for_file("/tmp/hakurei-test-done") +print(machine.succeed("cat /tmp/hakurei-test.log")) +machine.wait_for_file("/tmp/hakurei-test-ok") diff --git a/cmd/mbf/cache.go b/cmd/mbf/cache.go index 0e20ca99..166e1c45 100644 --- a/cmd/mbf/cache.go +++ b/cmd/mbf/cache.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "net/http" "os" "path/filepath" @@ -14,6 +15,9 @@ import ( "hakurei.app/pkg" ) +// poisonOpen is whether cache.open is poisoned. This enables running as root. +var _, poisonOpen = os.LookupEnv("MBF_POISON_OPEN") + // cache refers to an instance of [pkg.Cache] that might be open. type cache struct { ctx context.Context @@ -49,6 +53,10 @@ func writeTitle(msg message.Msg, s string) { // open opens the underlying [pkg.Cache]. func (cache *cache) open() (err error) { + if poisonOpen { + return errors.New("attempting to open cache") + } + if cache.c != nil { return os.ErrInvalid } diff --git a/cmd/mbf/info.go b/cmd/mbf/info.go index a4bc7c02..d8691f60 100644 --- a/cmd/mbf/info.go +++ b/cmd/mbf/info.go @@ -56,9 +56,9 @@ func commandInfo( mustPrintln("website : " + strings.TrimSuffix(meta.Website, "/")) } - if len(meta.Dependencies) > 0 { + if len(meta.Runtimes) > 0 { mustPrint("depends on :") - for _, d := range meta.Dependencies { + for _, d := range meta.Runtimes { _meta, _ := rosa.MustLoad(d) s := _meta.Name if _meta.Version != rosa.Unversioned { diff --git a/cmd/mbf/internal/ci/ci.go b/cmd/mbf/internal/ci/ci.go new file mode 100644 index 00000000..b1ee6a8c --- /dev/null +++ b/cmd/mbf/internal/ci/ci.go @@ -0,0 +1,569 @@ +// Package ci implements the CI service and client. +package ci + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/ed25519" + "encoding/binary" + "errors" + "io" + "io/fs" + "log" + "net" + "net/http" + "os" + "path" + "path/filepath" + "sync" + "syscall" + "time" + "unique" + "unsafe" + _ "unsafe" // for go:linkname + + "hakurei.app/internal/rosa" + "hakurei.app/message" + "hakurei.app/pkg" +) + +// setSource is made available here to accept prepared hakurei tarballs. +// +//go:linkname setSource hakurei.app/internal/rosa.(*S).setSource +func setSource(s *rosa.S, p []byte, version string) + +// inotifyInit returns a new inotify instance. +func inotifyInit() (*os.File, error) { + fd, err := syscall.InotifyInit1(syscall.IN_NONBLOCK | syscall.IN_CLOEXEC) + if err != nil { + return nil, os.NewSyscallError("inotify_init1", err) + } + return os.NewFile(uintptr(fd), "inotify"), nil +} + +// inotifyAddWatch adds pathname to in. +func inotifyAddWatch( + in *os.File, + pathname string, + mask uint32, +) (watchdesc int, err error) { + sc, _err := in.SyscallConn() + if _err != nil { + return -1, _err + } + if _err = sc.Control(func(fd uintptr) { + watchdesc, err = syscall.InotifyAddWatch(int(fd), pathname, mask) + }); _err != nil { + return -1, _err + } + return +} + +// Follow reads from the file at pathname and writes its contents and any new +// contents to w. follow returns if a read, write or inotify error occurs, or +// the context is cancelled. +func Follow(ctx context.Context, pathname string, w io.Writer) (err error) { + var in *os.File + if in, err = inotifyInit(); err != nil { + return + } + defer func() { + if _err := in.Close(); err == nil { + err = _err + } + }() + + if _, err = inotifyAddWatch(in, pathname, syscall.IN_MODIFY); err != nil { + return + } + var r *os.File + if r, err = os.Open(pathname); err != nil { + return + } + defer func() { + if _err := r.Close(); err == nil { + err = _err + } + }() + + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-ctx.Done(): + now := time.Now() + _ = in.SetDeadline(now) + return + + case <-done: + return + } + }() + + if _, err = io.Copy(w, r); err != nil { + return + } + + buf := make([]byte, os.Getpagesize()) + for { + if _, err = io.Copy(w, r); err != nil { + return + } + if _, err = in.Read(buf); err != nil { + if errors.Is(err, os.ErrDeadlineExceeded) { + err = nil + } + return + } + } +} + +// archiveTime is the hardcoded time written to every header time field. +var archiveTime = time.Unix(0, 0) + +// Path wraps the [pkg.Cache] pathname. +type Path string + +// String returns the value of p. +func (p Path) String() string { return string(p) } + +// append is [filepath.Join] with p as the first element. +func (p Path) append(elem ...string) string { + return filepath.Join(append([]string{p.String()}, elem...)...) +} + +// New returns a new [Path]. +func New(c *pkg.Cache) Path { return Path(c.Path().String()) } + +// name returns the CI socket pathname. +func (p Path) name() string { return p.append("ci") } + +// client returns the CI http client. +func (p Path) client() *http.Client { + var d net.Dialer + addr := net.UnixAddr{ + Net: "unix", + Name: p.name(), + } + + return &http.Client{Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return d.DialUnix(ctx, "unix", nil, &addr) + }, + }} +} + +// cure writes the identifier of the pending artifact, cures the artifact, and +// writes the cure whence. For an unsuccessful cure, a negative whence is +// written, followed by a user-facing error string. +func cure(c *pkg.Cache, a pkg.Artifact, w http.ResponseWriter) error { + h := w.Header() + h.Set("Content-Type", "application/octet-stream") + h.Set("Cache-Control", "no-cache") + + f, ok := w.(http.Flusher) + if !ok { + _, _ = w.Write([]byte{0}) + return errors.ErrUnsupported + } + + id := c.Ident(a).Value() + if _, err := w.Write(id[:]); err != nil { + return err + } + f.Flush() + + _, _, whence, err := c.CureWhence(a) + if err != nil { + whence = -1 + } + if _, _err := w.Write( + binary.LittleEndian.AppendUint64(nil, uint64(whence)), + ); _err != nil { + return _err + } + + if err != nil { + if _, _err := io.WriteString(w, err.Error()); _err != nil { + return _err + } + } + f.Flush() + return err +} + +// spool holds reusable [rosa.S] instances. +var spool = sync.Pool{New: func() any { return rosa.New() }} + +// getS returns the address of a populated [rosa.S]. Its hakurei-source may be +// clobbered and must be replaced using setSource before use. +func getS() *rosa.S { return spool.Get().(*rosa.S) } + +// putS returns s to spool. +func putS(s *rosa.S) { spool.Put(s) } + +// versionSize is the maximum size of the specified version string, plus its +// deliminator byte. +const versionSize = 8 + 16 + 6 + 2 + +// errBadVersion is returned by readSource if a header does not contain +// the deliminator byte. +var errBadVersion = errors.New("unterminated version string") + +// readSource reads a version string and compressed source tarball from r and +// returns the address of a [rosa.S] with this source tarball. The resulting +// [rosa.S] must be returned via putS. +func readSource(w http.ResponseWriter, r *http.Request) (*rosa.S, error) { + var header [versionSize]byte + _, err := io.ReadFull(r.Body, header[:]) + if err != nil { + _ = r.Body.Close() + http.Error(w, "bad header", http.StatusBadRequest) + return nil, err + } + + var version string + if i := bytes.IndexByte(header[:], 0); i < 0 { + _ = r.Body.Close() + http.Error(w, "unterminated version string", http.StatusBadRequest) + return nil, errBadVersion + } else { + version = unsafe.String(&header[0], i) + } + + var p []byte + if p, err = io.ReadAll(r.Body); err != nil { + _ = r.Body.Close() + http.Error(w, "cannot receive payload", http.StatusInternalServerError) + return nil, err + } + + s := getS() + setSource(s, p, version) + return s, r.Body.Close() +} + +// ErrDaemonError is returned by writeSource generally if cure could not flush +// on the connection to notify completion. +var ErrDaemonError = errors.New("CI service could not process the request") + +// writeSource writes a source tarball to the specified endpoint of the CI +// backend servicing the cache referred to by cm. +func (p Path) writeSource( + ctx context.Context, + w io.Writer, + endpoint, source, version string, +) (*pkg.ID, error) { + if len(version) >= versionSize { + return nil, syscall.ENOMEM + } + var header [versionSize]byte + copy(header[:], version[:]) + + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + if err := filepath.WalkDir(source, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() && d.Name() == ".git" { + return fs.SkipDir + } + + var fi fs.FileInfo + if fi, err = d.Info(); err != nil { + return err + } + + var linkname string + if fi.Mode()&fs.ModeSymlink != 0 { + if linkname, err = os.Readlink(path); err != nil { + return err + } + } + + var h *tar.Header + if h, err = tar.FileInfoHeader(fi, linkname); err != nil { + return err + } + h.AccessTime, h.ChangeTime, h.ModTime = archiveTime, archiveTime, archiveTime + h.Name = path + + var isVersion bool + if dir, file := filepath.Split(path); filepath.Base(dir) == "dist" && + file == "VERSION" && fi.Mode().IsRegular() { + isVersion = true + h.Size = int64(len(version)) + } + + if err = tw.WriteHeader(h); err != nil { + return err + } + + if isVersion { + _, err = io.WriteString(tw, version) + return err + } + + if fi.Mode().IsRegular() { + var f io.ReadCloser + if f, err = os.Open(path); err != nil { + return err + } + + _, err = io.Copy(tw, f) + if _err := f.Close(); err == nil { + err = _err + } + if err != nil { + return err + } + } + return nil + }); err != nil { + return nil, err + } + if err := tw.Close(); err != nil { + return nil, err + } + if err := gw.Close(); err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + "http://"+path.Join("_", endpoint), + io.MultiReader(bytes.NewReader(header[:]), bytes.NewReader(buf.Bytes())), + ) + if err != nil { + return nil, err + } + + var resp *http.Response + resp, err = p.client().Do(req) + if err != nil { + return nil, err + } + + var id pkg.ID + _, err = io.ReadFull(resp.Body, id[:]) + if err != nil { + _ = resp.Body.Close() + if errors.Is(err, io.ErrUnexpectedEOF) { + return nil, ErrDaemonError + } + return nil, err + } + + c, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + var whence int + go func() { + defer cancel() + var wbuf [8]byte + _, _err := io.ReadFull(resp.Body, wbuf[:]) + whence = int(binary.LittleEndian.Uint64(wbuf[:])) + done <- _err + }() + + if w != nil { + retry: + err = Follow(c, filepath.Join(p.String(), "status", pkg.Encode(id)), w) + if err != nil { + if ctx.Err() == nil && errors.Is(err, os.ErrNotExist) { + goto retry + } + + _ = resp.Body.Close() + return nil, err + } + } + + err = <-done + if err != nil { + _ = resp.Body.Close() + return nil, err + } + + if whence < 0 { + var m []byte + if m, err = io.ReadAll(resp.Body); err != nil { + _ = resp.Body.Close() + return nil, err + } else if err = resp.Body.Close(); err != nil { + return nil, err + } + return nil, errors.New(unsafe.String(unsafe.SliceData(m), len(m))) + } + return &id, resp.Body.Close() +} + +// The stubKey is used by the mirror service exposed by serve where +// authentication is unnecessary. +var stubKey = ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) + +// fetch fetches the outcome of id and writes it to the specified directory. +func (p Path) fetch(ctx context.Context, id *pkg.ID, output string) error { + c := p.client() + r, err := rosa.NewRemote( + c, "http://_", + stubKey.Public().(ed25519.PublicKey), + ) + if err != nil { + return err + } + + var sum *pkg.Checksum + if sum, err = r.Artifact(ctx, unique.Make(*id)); err != nil { + return err + } + + var req *http.Request + if req, err = http.NewRequestWithContext( + ctx, + http.MethodGet, + "http://"+path.Join("_", "outcome", pkg.Encode(*sum)), + nil, + ); err != nil { + return err + } + + var resp *http.Response + if resp, err = c.Do(req); err != nil { + return err + } + + err = pkg.Extract(resp.Body, output, nil) + if closeErr := resp.Body.Close(); err == nil { + err = closeErr + } + return err +} + +const ( + // endpointDist accepts a Path.writeSource stream and produces a + // distribution for the submitted source. + endpointDist = "/dist" + + // endpointRace is like endpointDist, but the resulting distribution is + // instrumented with the data race detector. + endpointRace = "/race" +) + +// MakeDist creates a hakurei distribution using the CI service. +func (p Path) MakeDist( + ctx context.Context, + w io.Writer, + output, source, version string, +) error { + id, err := p.writeSource(ctx, w, endpointDist, source, version) + if err != nil { + return err + } + return p.fetch(ctx, id, output) +} + +// MakeDistRace creates a hakurei distribution (race) using the CI service. +func (p Path) MakeDistRace( + ctx context.Context, + w io.Writer, + output, source, version string, +) error { + id, err := p.writeSource(ctx, w, endpointRace, source, version) + if err != nil { + return err + } + return p.fetch(ctx, id, output) +} + +// Serve services CI workload dispatched to c. +func Serve(ctx context.Context, msg message.Msg, c *pkg.Cache) error { + const shutdownTimeout = 15 * time.Second + p := New(c) + addr := net.UnixAddr{ + Net: "unix", + Name: p.name(), + } + + var mux http.ServeMux + mux.HandleFunc("POST "+endpointDist, func(w http.ResponseWriter, r *http.Request) { + s, err := readSource(w, r) + if err != nil { + msg.Verbose(err) + return + } + defer putS(s) + + _, a := s.Std().MustLoad(rosa.H("hakurei-dist")) + if err = cure(c, a, w); err != nil { + msg.Verbose(err) + return + } + if msg.IsVerbose() { + msg.Verbosef( + "satisfied distribution %s", + pkg.Encode(c.Ident(a).Value()), + ) + } + }) + mux.HandleFunc("POST "+endpointRace, func(w http.ResponseWriter, r *http.Request) { + s, err := readSource(w, r) + if err != nil { + msg.Verbose(err) + return + } + defer putS(s) + + _, a := s.Std().MustLoad(rosa.H("hakurei-dist-race")) + if err = cure(c, a, w); err != nil { + msg.Verbose(err) + return + } + if msg.IsVerbose() { + msg.Verbosef( + "satisfied distribution %s (race)", + pkg.Encode(c.Ident(a).Value()), + ) + } + }) + + if r, err := os.OpenRoot(p.String()); err != nil { + return err + } else { + defer func() { + if err = r.Close(); err != nil { + msg.Verbose(err) + } + }() + rosa.NewMirror(msg, r.FS(), stubKey).Register(&mux) + } + + server := http.Server{Handler: &mux} + go func() { + <-ctx.Done() + cc, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if _err := server.Shutdown(cc); _err != nil { + log.Fatal(_err) + } + }() + + ul, err := net.ListenUnix("unix", &addr) + if err != nil { + return err + } + ul.SetUnlinkOnClose(true) + msg.Verbosef("listening on %s", addr.Name) + + err = server.Serve(ul) + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + return err +} diff --git a/cmd/mbf/internal/ci/ci_test.go b/cmd/mbf/internal/ci/ci_test.go new file mode 100644 index 00000000..447c4ab1 --- /dev/null +++ b/cmd/mbf/internal/ci/ci_test.go @@ -0,0 +1,56 @@ +package ci_test + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + "hakurei.app/cmd/mbf/internal/ci" +) + +func TestFollow(t *testing.T) { + t.Parallel() + + pathname := filepath.Join(t.TempDir(), "f") + w, err := os.Create(pathname) + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + ctx, cancel := context.WithCancel(t.Context()) + + var want string + go func() { + defer cancel() + for _, s := range []string{ + "\xde\xad\xbe\xef", + "\xff\xff\xff\xff", + "\x00\x00", + } { + want += s + if _, _err := w.WriteString(s); _err != nil { + panic(_err) + } + } + }() + +retry: + if err = ci.Follow(ctx, pathname, &buf); err != nil { + t.Fatal(err) + } + <-ctx.Done() + + // the inotify event takes time to arrive, and there is no way to + // synchronise for this cleanly + if buf.Len() != len(want) { + buf.Reset() + goto retry + } + + if got := buf.String(); got != want { + t.Fatalf("follow: %q, want %q", got, want) + } +} diff --git a/cmd/mbf/internal/pkgserver/api.go b/cmd/mbf/internal/pkgsite/api.go index 68ccd471..a3094359 100644 --- a/cmd/mbf/internal/pkgserver/api.go +++ b/cmd/mbf/internal/pkgsite/api.go @@ -1,5 +1,5 @@ -// Package pkgserver implements the package metadata service backend. -package pkgserver +// Package pkgsite implements the package metadata website. +package pkgsite import ( "context" diff --git a/cmd/mbf/internal/pkgserver/api_test.go b/cmd/mbf/internal/pkgsite/api_test.go index 1552fec9..d2c21d81 100644 --- a/cmd/mbf/internal/pkgserver/api_test.go +++ b/cmd/mbf/internal/pkgsite/api_test.go @@ -1,4 +1,4 @@ -package pkgserver +package pkgsite import ( "net/http" diff --git a/cmd/mbf/internal/pkgserver/index.go b/cmd/mbf/internal/pkgsite/index.go index 2a9b6de5..760806c0 100644 --- a/cmd/mbf/internal/pkgserver/index.go +++ b/cmd/mbf/internal/pkgsite/index.go @@ -1,4 +1,4 @@ -package pkgserver +package pkgsite import ( "cmp" diff --git a/cmd/mbf/internal/pkgserver/index_test.go b/cmd/mbf/internal/pkgsite/index_test.go index 8f3b5530..abf78876 100644 --- a/cmd/mbf/internal/pkgserver/index_test.go +++ b/cmd/mbf/internal/pkgsite/index_test.go @@ -1,4 +1,4 @@ -package pkgserver +package pkgsite import ( "bytes" diff --git a/cmd/mbf/internal/pkgserver/search.go b/cmd/mbf/internal/pkgsite/search.go index 15947804..ef7cd76f 100644 --- a/cmd/mbf/internal/pkgserver/search.go +++ b/cmd/mbf/internal/pkgsite/search.go @@ -1,4 +1,4 @@ -package pkgserver +package pkgsite import ( "cmp" diff --git a/cmd/mbf/internal/pkgserver/ui/index.html b/cmd/mbf/internal/pkgsite/ui/index.html index 6a5d72b4..6a5d72b4 100644 --- a/cmd/mbf/internal/pkgserver/ui/index.html +++ b/cmd/mbf/internal/pkgsite/ui/index.html diff --git a/cmd/mbf/internal/pkgserver/ui/index.ts b/cmd/mbf/internal/pkgsite/ui/index.ts index 0784b81f..0784b81f 100644 --- a/cmd/mbf/internal/pkgserver/ui/index.ts +++ b/cmd/mbf/internal/pkgsite/ui/index.ts diff --git a/cmd/mbf/internal/pkgserver/ui/style.css b/cmd/mbf/internal/pkgsite/ui/style.css index b4f281ac..b4f281ac 100644 --- a/cmd/mbf/internal/pkgserver/ui/style.css +++ b/cmd/mbf/internal/pkgsite/ui/style.css diff --git a/cmd/mbf/internal/pkgserver/ui/tsconfig.json b/cmd/mbf/internal/pkgsite/ui/tsconfig.json index 24df4936..24df4936 100644 --- a/cmd/mbf/internal/pkgserver/ui/tsconfig.json +++ b/cmd/mbf/internal/pkgsite/ui/tsconfig.json diff --git a/cmd/mbf/internal/pkgserver/ui/ui.go b/cmd/mbf/internal/pkgsite/ui/ui.go index 3fc0d89c..3fc0d89c 100644 --- a/cmd/mbf/internal/pkgserver/ui/ui.go +++ b/cmd/mbf/internal/pkgsite/ui/ui.go diff --git a/cmd/mbf/internal/pkgserver/ui/ui_full.go b/cmd/mbf/internal/pkgsite/ui/ui_full.go index 564787dc..564787dc 100644 --- a/cmd/mbf/internal/pkgserver/ui/ui_full.go +++ b/cmd/mbf/internal/pkgsite/ui/ui_full.go diff --git a/cmd/mbf/internal/pkgserver/ui/ui_stub.go b/cmd/mbf/internal/pkgsite/ui/ui_stub.go index daf010f8..daf010f8 100644 --- a/cmd/mbf/internal/pkgserver/ui/ui_stub.go +++ b/cmd/mbf/internal/pkgsite/ui/ui_stub.go diff --git a/cmd/mbf/main.go b/cmd/mbf/main.go index 4f471d6a..dd5ee624 100644 --- a/cmd/mbf/main.go +++ b/cmd/mbf/main.go @@ -43,8 +43,9 @@ import ( "hakurei.app/message" "hakurei.app/pkg" - "hakurei.app/cmd/mbf/internal/pkgserver" - "hakurei.app/cmd/mbf/internal/pkgserver/ui" + "hakurei.app/cmd/mbf/internal/ci" + "hakurei.app/cmd/mbf/internal/pkgsite" + "hakurei.app/cmd/mbf/internal/pkgsite/ui" ) // builtin contains native and embedded [rosa.Artifact] registrations. @@ -70,7 +71,7 @@ func main() { log.SetPrefix("mbf: ") msg := message.New(log.Default()) - if os.Geteuid() == 0 { + if !poisonOpen && os.Geteuid() == 0 { log.Fatal("this program must not run as root") } @@ -343,7 +344,7 @@ func main() { var mux http.ServeMux ui.Register(&mux) - if err = pkgserver.Register(ctx, &mux, r); err != nil { + if err = pkgsite.Register(ctx, &mux, r); err != nil { return } @@ -504,7 +505,7 @@ func main() { c.NewCommand( "daemon", "Service artifact IR with Rosa OS extensions", - func(args []string) error { + func([]string) error { ul, err := net.ListenUnix("unix", &addr) if err != nil { return err @@ -514,6 +515,63 @@ func main() { }, ) + _ci := c.New("ci", command.UsageInternal) + _ci.NewCommand( + "daemon", + "Service CI workload dispatched through the socket", + func([]string) error { + return cm.Do(func(cache *pkg.Cache) error { + return ci.Serve(ctx, msg, cache) + }) + }, + ) + { + var flagOutput string + _ci.NewCommand( + "dist", + "Request distribution tarball for the specified source directory", + func(args []string) error { + if len(args) != 2 { + return errors.New("dist requires 2 arguments") + } + + return ci.Path(cm.base).MakeDist( + ctx, + os.Stdout, + flagOutput, + args[0], args[1], + ) + }, + ).Flag( + &flagOutput, + "o", command.StringFlag("."), + "Write the resulting distribution to the named directory", + ) + } + { + var flagOutput string + _ci.NewCommand( + "race", + "Request distribution tarball (race) for the specified source directory", + func(args []string) error { + if len(args) != 2 { + return errors.New("race requires 2 arguments") + } + + return ci.Path(cm.base).MakeDistRace( + ctx, + os.Stdout, + flagOutput, + args[0], args[1], + ) + }, + ).Flag( + &flagOutput, + "o", command.StringFlag("."), + "Write the resulting distribution to the named directory", + ) + } + c.NewCommand( "keygen", "Create keypair for local cache", diff --git a/cmd/sharefs/testsuite/main.go b/cmd/sharefs/testsuite/main.go new file mode 100644 index 00000000..167875a1 --- /dev/null +++ b/cmd/sharefs/testsuite/main.go @@ -0,0 +1,119 @@ +//go:build testsuite + +// The sharefs test program checks cli behaviour and exercises the filesystem +// implemented by cmd/sharefs using fs_mark. +package main + +import ( + "errors" + "log" + "os" + "os/exec" + "strings" + "syscall" + + "hakurei.app/internal/testsuite" +) + +// checkBadOpts invokes cmd/sharefs with the specified options and compares +// the resulting error message. +func checkBadOpts(cred *syscall.Credential, opts, want string) { + var buf strings.Builder + buf.Grow(len(want)) + + cmd := exec.Command( + "sharefs", + "-f", + "-o", "source=/etc,"+opts, + "/mnt", + ) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Pdeathsig: syscall.SIGKILL, + Credential: cred, + } + cmd.Stderr = &buf + err := cmd.Run() + if err == nil { + log.Fatalf("opts=%q, unexpected success", opts) + } + if e, ok := errors.AsType[*exec.ExitError](err); !ok { + log.Fatal(err) + } else if !e.Exited() { + log.Fatal(e) + } + + if got := buf.String(); got != want { + log.Fatalf("opts=%q\n\t got:%q\n\twant:%q", opts, got, want) + } +} + +func main() { + go testsuite.ReceiveSignals() + + cred := syscall.Credential{Uid: 1000, Gid: 100} + if err := os.Mkdir("result", 0755); err != nil { + log.Fatal(err) + } + + done := make(chan struct{}) + go func() { + defer close(done) + + testsuite.MustRun( + nil, nil, + "fs_mark", + "-v", + "-d", "/sdcard/fs_mark", + "-l", "result/fs_mark.log", + ) + }() + + log.Println("checking malformed setuid/setgid representation") + checkBadOpts(&cred, "setuid=ff", "sharefs: invalid value for option setuid\n") + checkBadOpts(&cred, "setgid=ff", "sharefs: invalid value for option setgid\n") + + log.Println("checking bounds check for setuid/setgid") + checkBadOpts(&cred, "setuid=0", "sharefs: invalid value for option setuid\n") + checkBadOpts(&cred, "setgid=0", "sharefs: invalid value for option setgid\n") + checkBadOpts(&cred, "setuid=-1", "sharefs: invalid value for option setuid\n") + checkBadOpts(&cred, "setgid=-1", "sharefs: invalid value for option setgid\n") + + log.Println("checking non-root setuid/setgid") + checkBadOpts(&cred, "setuid=1023", "sharefs: setuid and setgid has no effect when not starting as root\n") + checkBadOpts(&cred, "setgid=1023", "sharefs: setuid and setgid has no effect when not starting as root\n") + checkBadOpts(&cred, "setuid=1023,setgid=1023", "sharefs: setuid and setgid has no effect when not starting as root\n") + checkBadOpts(&cred, "mkdir", "sharefs: mkdir has no effect when not starting as root\n") + + log.Println("checking root without setuid/setgid") + checkBadOpts(nil, "allow_other", "sharefs: setuid and setgid must not be 0\n") + checkBadOpts(nil, "setuid=1023", "sharefs: setuid and setgid must not be 0\n") + checkBadOpts(nil, "setgid=1023", "sharefs: setuid and setgid must not be 0\n") + + log.Println("verifying mount point") + if err := os.Remove("/mnt"); err != nil { + log.Fatal(err) + } + + log.Println("checking unprivileged mount/unmount") + testsuite.MustRun(&cred, nil, "mkdir", "/tmp/sdcard", "/tmp/persistent") + testsuite.MustRun(&cred, nil, "sharefs", "-o", "source=/tmp/persistent", "/tmp/sdcard") + testsuite.MustRun(&cred, nil, "touch", "/tmp/sdcard/check") + testsuite.MustRun(&cred, nil, "umount", "/tmp/sdcard") + testsuite.MustRun(&cred, nil, "rm", "/tmp/persistent/check") + testsuite.MustRun(&cred, nil, "rmdir", "/tmp/sdcard", "/tmp/persistent") + + log.Println("waiting for fs_mark to complete") + <-done + + const backingDir = "/var/lib/sdcard" + sharefsCred := syscall.Credential{Uid: 1023, Gid: 1023} + log.Println("checking permissions") + testsuite.MustRun(&sharefsCred, nil, "touch", backingDir+"/fs_mark/.check") + testsuite.MustRun(&sharefsCred, nil, "rm", backingDir+"/fs_mark/.check") + testsuite.MustRun(&cred, nil, "rm", "-rf", "/sdcard/fs_mark") + if _, err := os.ReadDir(backingDir + "/fs_mark"); err == nil { + log.Fatal("fs_mark directory was not removed") + } else if !errors.Is(err, os.ErrNotExist) { + log.Fatal(err) + } +} diff --git a/cmd/sharefs/testsuite/raceattr.go b/cmd/sharefs/testsuite/raceattr.go new file mode 100644 index 00000000..412cb2b3 --- /dev/null +++ b/cmd/sharefs/testsuite/raceattr.go @@ -0,0 +1,122 @@ +//go:build raceattr + +// The raceattr program reproduces vfs inode file attribute race. +// +// Even though libfuse high-level API presents the address of a struct stat +// alongside struct fuse_context, file attributes are actually inherent to the +// inode, instead of the specific call from userspace. The kernel implementation +// in fs/fuse/xattr.c appears to make stale data in the inode (set by a previous +// call) impossible or very unlikely to reach userspace via the stat family of +// syscalls. However, when using default_permissions to have the VFS check +// permissions, this race still happens, despite the resulting struct stat being +// correct when overriding the check via capabilities otherwise. +// +// This program reproduces the failure, but because of its continuous nature, it +// is provided independent of the vm integration test suite. +package main + +import ( + "context" + "flag" + "log" + "os" + "os/signal" + "runtime" + "sync" + "sync/atomic" + "syscall" +) + +func newStatAs( + ctx context.Context, cancel context.CancelFunc, + n *atomic.Uint64, ok *atomic.Bool, + uid uint32, pathname string, + continuous bool, +) func() { + return func() { + runtime.LockOSThread() + defer cancel() + + if _, _, errno := syscall.Syscall( + syscall.SYS_SETUID, uintptr(uid), + 0, 0, + ); errno != 0 { + cancel() + log.Printf("cannot set uid to %d: %s", uid, errno) + } + + var stat syscall.Stat_t + for { + if ctx.Err() != nil { + return + } + + if err := syscall.Lstat(pathname, &stat); err != nil { + // SHAREFS_PERM_DIR not world executable, or + // SHAREFS_PERM_REG not world readable + if !continuous { + cancel() + } + ok.Store(true) + log.Printf("uid %d: %v", uid, err) + } else if stat.Uid != uid { + // appears to be unreachable + if !continuous { + cancel() + } + ok.Store(true) + log.Printf("got uid %d instead of %d", stat.Uid, uid) + } + n.Add(1) + } + } +} + +func main() { + log.SetFlags(0) + log.SetPrefix("raceattr: ") + + p := flag.String("target", "/sdcard/raceattr", "pathname of test file") + u0 := flag.Int("uid0", 1<<10-1, "first uid") + u1 := flag.Int("uid1", 1<<10-2, "second uid") + count := flag.Int("count", 1, "threads per uid") + continuous := flag.Bool("continuous", false, "keep running even after reproduce") + flag.Parse() + + if os.Geteuid() != 0 { + log.Fatal("this program must run as root") + } + + ctx, cancel := signal.NotifyContext( + context.Background(), + syscall.SIGINT, + syscall.SIGTERM, + syscall.SIGHUP, + ) + + if err := os.WriteFile(*p, nil, 0); err != nil { + log.Fatal(err) + } + + var ( + wg sync.WaitGroup + + n atomic.Uint64 + ok atomic.Bool + ) + + if *count < 1 { + *count = 1 + } + for range *count { + wg.Go(newStatAs(ctx, cancel, &n, &ok, uint32(*u0), *p, *continuous)) + if *u1 >= 0 { + wg.Go(newStatAs(ctx, cancel, &n, &ok, uint32(*u1), *p, *continuous)) + } + } + + wg.Wait() + if !*continuous && ok.Load() { + log.Printf("reproduced after %d calls", n.Load()) + } +} |
