From 473b97c7d6599deefcde4b48f2e64052ca4adec1 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 30 Sep 2026 22:45:00 +0000 Subject: [PATCH] Add the sessionview launcher for per-Session views The daemon re-executes itself into a launcher cloned into new mount, PID and network namespaces. The launcher mounts the world over /dev/fuse, hands the connection to the daemon's world server over SCM_RIGHTS, binds the private directories, overlays and shim onto symlink-free targets, adds /proc and a minimal /dev, moves the world onto /, drops privileges, installs a seccomp filter that denies user namespaces, setns and clone3, and runs the Harness as PID 1's only child. Mount flags alone decide what can be executed. --- apps/daemon/cmd/oac-daemon/main.go | 2 + .../internal/sessionview/build_linux.go | 318 +++++++++ .../internal/sessionview/control_linux.go | 195 ++++++ apps/daemon/internal/sessionview/doc.go | 10 + apps/daemon/internal/sessionview/errors.go | 61 ++ .../internal/sessionview/launcher_linux.go | 222 ++++++ .../internal/sessionview/probe_linux.go | 102 +++ .../internal/sessionview/restrict_linux.go | 45 ++ .../internal/sessionview/seccomp_linux.go | 92 +++ apps/daemon/internal/sessionview/spec.go | 222 ++++++ .../daemon/internal/sessionview/view_linux.go | 352 ++++++++++ .../internal/sessionview/view_linux_test.go | 652 ++++++++++++++++++ .../daemon/internal/sessionview/view_other.go | 28 + go.mod | 1 + go.sum | 6 + 15 files changed, 2308 insertions(+) create mode 100644 apps/daemon/internal/sessionview/build_linux.go create mode 100644 apps/daemon/internal/sessionview/control_linux.go create mode 100644 apps/daemon/internal/sessionview/doc.go create mode 100644 apps/daemon/internal/sessionview/errors.go create mode 100644 apps/daemon/internal/sessionview/launcher_linux.go create mode 100644 apps/daemon/internal/sessionview/probe_linux.go create mode 100644 apps/daemon/internal/sessionview/restrict_linux.go create mode 100644 apps/daemon/internal/sessionview/seccomp_linux.go create mode 100644 apps/daemon/internal/sessionview/spec.go create mode 100644 apps/daemon/internal/sessionview/view_linux.go create mode 100644 apps/daemon/internal/sessionview/view_linux_test.go create mode 100644 apps/daemon/internal/sessionview/view_other.go diff --git a/apps/daemon/cmd/oac-daemon/main.go b/apps/daemon/cmd/oac-daemon/main.go index 368943517..b704d8cc3 100644 --- a/apps/daemon/cmd/oac-daemon/main.go +++ b/apps/daemon/cmd/oac-daemon/main.go @@ -9,9 +9,11 @@ import ( "os" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/cli" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/sessionview" ) func main() { + sessionview.Init() if err := cli.Execute(os.Args[1:]); err != nil { fmt.Fprintf(os.Stderr, "oac-daemon: %v\n", err) os.Exit(1) diff --git a/apps/daemon/internal/sessionview/build_linux.go b/apps/daemon/internal/sessionview/build_linux.go new file mode 100644 index 000000000..c148ee2f3 --- /dev/null +++ b/apps/daemon/internal/sessionview/build_linux.go @@ -0,0 +1,318 @@ +//go:build linux + +package sessionview + +import ( + "errors" + "fmt" + "strings" + + "golang.org/x/sys/unix" +) + +// builder mounts the local pieces onto the world. Every target is resolved beneath its parent mount without following symlinks, so the sandbox cannot redirect a mount. +type builder struct { + root int // the world root, an O_PATH fd + fds []int +} + +// devNodes are bound from the host's /dev into the view's /dev. +var devNodes = []string{"null", "zero", "full", "random", "urandom", "tty"} + +var devLinks = [][2]string{ + {"ptmx", "pts/ptmx"}, + {"fd", "/proc/self/fd"}, + {"stdin", "/proc/self/fd/0"}, + {"stdout", "/proc/self/fd/1"}, + {"stderr", "/proc/self/fd/2"}, +} + +const ( + attrNoSuid = unix.MOUNT_ATTR_NOSUID + attrNoDev = unix.MOUNT_ATTR_NODEV + attrNoExec = unix.MOUNT_ATTR_NOEXEC +) + +func (b *builder) build(spec *launchSpec) error { + root, err := unix.Open(spec.Staging, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + if err != nil { + return &Error{Kind: ErrLauncher, Op: "open", Path: spec.Staging, Err: err} + } + b.root = b.keep(root) + for _, d := range spec.Private { + src, err := b.source(d.HostDir) + if err != nil { + return err + } + if err := b.bind(src, b.root, privateRoot+"/"+d.Name, bindAttr(d.Writable, d.Exec, false)); err != nil { + return err + } + } + shim := -1 + if len(spec.Shim.Names) > 0 || len(spec.Shim.Paths) > 0 { + if shim, err = b.source(spec.Shim.Binary); err != nil { + return err + } + } + if err := b.shimDir(spec.Shim.Names, shim); err != nil { + return err + } + for _, o := range spec.Overlays { + src, err := b.source(o.Source) + if err != nil { + return err + } + if err := b.bind(src, b.root, o.Path, bindAttr(false, o.Exec, false)); err != nil { + return err + } + } + for _, p := range spec.Shim.Paths { + if err := b.bind(shim, b.root, p, bindAttr(false, true, false)); err != nil { + return err + } + } + proc, err := newFS("proc", nil, attrNoSuid|attrNoDev|attrNoExec) + if err != nil { + return err + } + defer unix.Close(proc) + if err := b.attach(proc, b.root, "/proc", true); err != nil { + return err + } + return b.dev() +} + +// shimDir presents the shim at /.oac/bin/ on a read-only tmpfs. +func (b *builder) shimDir(names []string, shim int) error { + dir := privateRoot + "/" + shimName + mnt, err := newFS("tmpfs", [][2]string{{"mode", "0755"}, {"size", "64k"}}, attrNoSuid|attrNoDev|attrNoExec) + if err != nil { + return err + } + defer unix.Close(mnt) + if err := b.attach(mnt, b.root, dir, true); err != nil { + return err + } + for _, n := range names { + if err := createFile(mnt, n, dir+"/"+n); err != nil { + return err + } + if err := b.bindAt(shim, mnt, n, dir+"/"+n, bindAttr(false, true, false)); err != nil { + return err + } + } + return readOnly(mnt, dir) +} + +// dev builds a minimal read-only /dev with host device nodes, a new devpts instance and a noexec /dev/shm. +func (b *builder) dev() error { + mnt, err := newFS("tmpfs", [][2]string{{"mode", "0755"}, {"size", "64k"}}, attrNoSuid|attrNoDev|attrNoExec) + if err != nil { + return err + } + defer unix.Close(mnt) + if err := b.attach(mnt, b.root, "/dev", true); err != nil { + return err + } + for _, n := range devNodes { + src, err := b.source("/dev/" + n) + if err != nil { + return err + } + if err := createFile(mnt, n, "/dev/"+n); err != nil { + return err + } + if err := b.bindAt(src, mnt, n, "/dev/"+n, bindAttr(true, false, true)); err != nil { + return err + } + } + subs := []struct { + name, fstype string + opts [][2]string + attr int + }{ + {"pts", "devpts", [][2]string{{"ptmxmode", "0666"}, {"mode", "0620"}}, attrNoSuid | attrNoExec}, + {"shm", "tmpfs", [][2]string{{"mode", "1777"}}, attrNoSuid | attrNoDev | attrNoExec}, + } + for _, s := range subs { + if err := unix.Mkdirat(mnt, s.name, 0o755); err != nil { + return &Error{Kind: ErrLauncher, Op: "mkdir", Path: "/dev/" + s.name, Err: err} + } + fs, err := newFS(s.fstype, s.opts, s.attr) + if err != nil { + return err + } + err = b.attachAt(fs, mnt, s.name, "/dev/"+s.name, true) + unix.Close(fs) + if err != nil { + return err + } + } + for _, l := range devLinks { + if err := unix.Symlinkat(l[1], mnt, l[0]); err != nil { + return &Error{Kind: ErrLauncher, Op: "symlink", Path: "/dev/" + l[0], Err: err} + } + } + return readOnly(mnt, "/dev") +} + +// source opens a trusted host path. +func (b *builder) source(path string) (int, error) { + fd, err := unix.Open(path, unix.O_PATH|unix.O_CLOEXEC, 0) + if err != nil { + return -1, &Error{Kind: ErrLauncher, Op: "open", Path: path, Err: err} + } + return b.keep(fd), nil +} + +func (b *builder) keep(fd int) int { + b.fds = append(b.fds, fd) + return fd +} + +func (b *builder) close() { + for _, fd := range b.fds { + unix.Close(fd) + } + b.fds, b.root = nil, -1 +} + +// bind mounts a clone of src at the absolute view path under the world root. +func (b *builder) bind(src, root int, view string, attr *unix.MountAttr) error { + return b.bindAt(src, root, strings.TrimPrefix(view, "/"), view, attr) +} + +func (b *builder) bindAt(src, dirfd int, rel, view string, attr *unix.MountAttr) error { + var st unix.Stat_t + if err := unix.Fstat(src, &st); err != nil { + return &Error{Kind: ErrLauncher, Op: "stat source", Path: view, Err: err} + } + mnt, err := unix.OpenTree(src, "", unix.OPEN_TREE_CLONE|unix.OPEN_TREE_CLOEXEC|unix.AT_EMPTY_PATH) + if err != nil { + return mountError("open_tree", view, err) + } + defer unix.Close(mnt) + if err := unix.MountSetattr(mnt, "", unix.AT_EMPTY_PATH, attr); err != nil { + return mountError("mount_setattr", view, err) + } + return b.attachAt(mnt, dirfd, rel, view, st.Mode&unix.S_IFMT == unix.S_IFDIR) +} + +func (b *builder) attach(mnt, root int, view string, dir bool) error { + return b.attachAt(mnt, root, strings.TrimPrefix(view, "/"), view, dir) +} + +// attachAt moves a detached mount onto rel beneath dirfd, which must be a directory when dir is set and a regular file otherwise. +func (b *builder) attachAt(mnt, dirfd int, rel, view string, dir bool) error { + target, err := resolve(dirfd, rel) + if err != nil { + return &Error{Kind: ErrMountTarget, Op: "resolve", Path: view, Err: err} + } + defer unix.Close(target) + var st unix.Stat_t + if err := unix.Fstat(target, &st); err != nil { + return &Error{Kind: ErrMountTarget, Op: "stat", Path: view, Err: err} + } + switch kind := st.Mode & unix.S_IFMT; { + case dir && kind != unix.S_IFDIR: + return &Error{Kind: ErrMountTarget, Op: "check", Path: view, Err: unix.ENOTDIR} + case !dir && kind != unix.S_IFREG: + return &Error{Kind: ErrMountTarget, Op: "check", Path: view, Err: fmt.Errorf("not a regular file")} + } + if err := unix.MoveMount(mnt, "", target, "", unix.MOVE_MOUNT_F_EMPTY_PATH|unix.MOVE_MOUNT_T_EMPTY_PATH); err != nil { + return mountError("move_mount", view, err) + } + return nil +} + +// resolve opens rel beneath dirfd without following symlinks or crossing mounts. +func resolve(dirfd int, rel string) (int, error) { + how := &unix.OpenHow{ + Flags: unix.O_PATH | unix.O_CLOEXEC, + Resolve: unix.RESOLVE_BENEATH | unix.RESOLVE_NO_SYMLINKS | unix.RESOLVE_NO_XDEV, + } + // openat2 reports a concurrent rename with EAGAIN; a sandbox that keeps renaming fails the view. + for try := 0; ; try++ { + fd, err := unix.Openat2(dirfd, rel, how) + if (err == unix.EAGAIN || err == unix.EINTR) && try < 16 { + continue + } + return fd, err + } +} + +func newFS(fstype string, opts [][2]string, attr int) (int, error) { + fs, err := unix.Fsopen(fstype, unix.FSOPEN_CLOEXEC) + if err != nil { + return -1, mountError("fsopen", fstype, err) + } + defer unix.Close(fs) + for _, o := range opts { + if err := unix.FsconfigSetString(fs, o[0], o[1]); err != nil { + return -1, mountError("fsconfig "+o[0], fstype, err) + } + } + if err := unix.FsconfigCreate(fs); err != nil { + return -1, mountError("fsconfig create", fstype, err) + } + mnt, err := unix.Fsmount(fs, unix.FSMOUNT_CLOEXEC, attr) + if err != nil { + return -1, mountError("fsmount", fstype, err) + } + return mnt, nil +} + +func createFile(dirfd int, name, view string) error { + fd, err := unix.Openat(dirfd, name, unix.O_CREAT|unix.O_EXCL|unix.O_WRONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o444) + if err != nil { + return &Error{Kind: ErrLauncher, Op: "create", Path: view, Err: err} + } + return unix.Close(fd) +} + +func readOnly(mnt int, view string) error { + if err := unix.MountSetattr(mnt, "", unix.AT_EMPTY_PATH, &unix.MountAttr{Attr_set: unix.MOUNT_ATTR_RDONLY}); err != nil { + return mountError("mount_setattr", view, err) + } + return nil +} + +func bindAttr(writable, exec, dev bool) *unix.MountAttr { + a := &unix.MountAttr{Attr_set: attrNoSuid} + for _, f := range []struct { + set bool + attr uint64 + }{{!writable, unix.MOUNT_ATTR_RDONLY}, {!exec, attrNoExec}, {!dev, attrNoDev}} { + if f.set { + a.Attr_set |= f.attr + } else { + a.Attr_clr |= f.attr + } + } + return a +} + +// switchRoot makes the world root the process root. pivot_root would also detach the old root, but container seccomp profiles block it. +func switchRoot(root int) error { + if err := unix.Fchdir(root); err != nil { + return &Error{Kind: ErrLauncher, Op: "fchdir", Path: "/", Err: err} + } + if err := unix.Mount(".", "/", "", unix.MS_MOVE, ""); err != nil { + return mountError("move root", "/", err) + } + if err := unix.Chroot("."); err != nil { + return &Error{Kind: ErrLauncher, Op: "chroot", Path: "/", Err: err} + } + if err := unix.Chdir("/"); err != nil { + return &Error{Kind: ErrLauncher, Op: "chdir", Path: "/", Err: err} + } + return nil +} + +func mountError(op, path string, err error) error { + kind := ErrLauncher + if errors.Is(err, unix.EPERM) || errors.Is(err, unix.EACCES) { + kind = ErrMountDenied + } + return &Error{Kind: kind, Op: op, Path: path, Err: err} +} diff --git a/apps/daemon/internal/sessionview/control_linux.go b/apps/daemon/internal/sessionview/control_linux.go new file mode 100644 index 000000000..694be509e --- /dev/null +++ b/apps/daemon/internal/sessionview/control_linux.go @@ -0,0 +1,195 @@ +//go:build linux + +package sessionview + +import ( + "bytes" + "encoding/gob" + "errors" + "fmt" + "io" + "net" + "os" + "sync" + "syscall" + + "golang.org/x/sys/unix" +) + +// Launcher file descriptors. The spec travels over a pipe so that nothing about the view appears in argv or the environment. +const ( + specFD = 3 + controlFD = 4 + stdinFD = 5 + stdoutFD = 6 + stderrFD = 7 +) + +// launchSpec is what the daemon sends the launcher: the Spec without its callbacks and files. +type launchSpec struct { + Staging string + Private []PrivateDir + Overlays []Overlay + Shim Shim + Path string + Args []string + Env []string + Dir string + UID uint32 + GID uint32 + Groups []uint32 +} + +type msgKind uint8 + +const ( + msgMounted msgKind = iota + 1 // launcher: the world is mounted; carries the /dev/fuse and netns fds + msgProceed // daemon: the world serves and the network is set up + msgStarted // launcher: the process runs + msgFailed // launcher: construction failed + msgExited // launcher: the process ended + msgSignal // daemon: signal the process +) + +type message struct { + Kind msgKind + Pid int + Signal syscall.Signal + Exit Exit + Fail failure +} + +// failure carries a launcher *Error across the control socket. +type failure struct { + Kind int + Op string + Path string + Errno syscall.Errno + Text string +} + +func failureOf(err error) failure { + f := failure{Text: err.Error()} + var e *Error + if errors.As(err, &e) { + for i, k := range errorKinds { + if k == e.Kind { + f.Kind = i + } + } + f.Op, f.Path, f.Text = e.Op, e.Path, "" + if e.Err != nil { + f.Text = e.Err.Error() + } + } + if errors.As(err, &f.Errno) { + f.Text = "" + } + return f +} + +func (f failure) err() error { + e := &Error{Kind: ErrLauncher, Op: f.Op, Path: f.Path} + if f.Kind > 0 && f.Kind < len(errorKinds) { + e.Kind = errorKinds[f.Kind] + } + switch { + case f.Errno != 0: + e.Err = f.Errno + case f.Text != "": + e.Err = errors.New(f.Text) + } + return e +} + +// control is one end of the launcher's SOCK_SEQPACKET control socket. Each packet holds one gob-encoded message. +type control struct { + conn *net.UnixConn + mu sync.Mutex +} + +func newControl(f *os.File) (*control, error) { + c, err := net.FileConn(f) + f.Close() + if err != nil { + return nil, err + } + uc, ok := c.(*net.UnixConn) + if !ok { + c.Close() + return nil, fmt.Errorf("control socket is a %T", c) + } + return &control{conn: uc}, nil +} + +func (c *control) send(m message, fds ...int) error { + var buf bytes.Buffer + if err := gob.NewEncoder(&buf).Encode(m); err != nil { + return err + } + var oob []byte + if len(fds) > 0 { + oob = unix.UnixRights(fds...) + } + c.mu.Lock() + defer c.mu.Unlock() + _, _, err := c.conn.WriteMsgUnix(buf.Bytes(), oob, nil) + return err +} + +// recv returns the next message and the files it carries. It returns io.EOF once the peer has closed its end. +func (c *control) recv() (message, []*os.File, error) { + buf := make([]byte, 64<<10) + oob := make([]byte, unix.CmsgSpace(2*4)) + n, oobn, flags, _, err := c.conn.ReadMsgUnix(buf, oob) + if err != nil { + return message{}, nil, err + } + files, err := unixRights(oob[:oobn]) + if err == nil && flags&(unix.MSG_TRUNC|unix.MSG_CTRUNC) != 0 { + err = errors.New("control message truncated") + } + if err == nil && n == 0 { + err = io.EOF + } + var m message + if err == nil { + err = gob.NewDecoder(bytes.NewReader(buf[:n])).Decode(&m) + } + if err != nil { + closeFiles(files) + return message{}, nil, err + } + return m, files, nil +} + +func (c *control) close() error { + return c.conn.Close() +} + +func unixRights(oob []byte) ([]*os.File, error) { + msgs, err := unix.ParseSocketControlMessage(oob) + if err != nil { + return nil, err + } + var files []*os.File + for i := range msgs { + fds, err := unix.ParseUnixRights(&msgs[i]) + if err != nil { + closeFiles(files) + return nil, err + } + for _, fd := range fds { + files = append(files, os.NewFile(uintptr(fd), "sessionview-fd")) + } + } + return files, nil +} + +func closeFiles(files []*os.File) { + for _, f := range files { + if f != nil { + f.Close() + } + } +} diff --git a/apps/daemon/internal/sessionview/doc.go b/apps/daemon/internal/sessionview/doc.go new file mode 100644 index 000000000..448b9bdcb --- /dev/null +++ b/apps/daemon/internal/sessionview/doc.go @@ -0,0 +1,10 @@ +// Package sessionview runs one process inside a per-Session view on the agent host. +// +// A view is a private mount, PID and network namespace whose root is the Session's world: a FUSE file system that the daemon serves over a /dev/fuse connection. The launcher adds the local pieces on top of the world: private directories under /.oac, trusted overlays, the command shim, a fresh /proc and a minimal /dev. The process starts with no capabilities, no_new_privs, a seccomp filter and only stdin, stdout and stderr open. Its network namespace has only loopback up. +// +// The exec guard is narrow. Mount flags alone decide which files can be executed: the world and every writable mount are nosuid and noexec, so executing a file from the file system works only from read-only mounts declared executable, such as the Harness directory, the shim and exec-flagged overlays. The seccomp filter denies creating a user namespace and every setns, so the process cannot create or enter another user namespace. Executing from a memfd and code that an allowed interpreter runs are outside this guard. +// +// The daemon calls [Init] first thing in main. [Start] re-executes the daemon binary as the launcher, which becomes PID 1 of the view: it builds the view, starts the process, forwards signals, reaps orphans and exits with the process status. Its exit tears the view down. +// +// The package works only on Linux. Elsewhere [Start] returns [ErrUnsupported]. +package sessionview diff --git a/apps/daemon/internal/sessionview/errors.go b/apps/daemon/internal/sessionview/errors.go new file mode 100644 index 000000000..6ea36a66c --- /dev/null +++ b/apps/daemon/internal/sessionview/errors.go @@ -0,0 +1,61 @@ +package sessionview + +import ( + "errors" + "fmt" + "strings" +) + +// Error kinds. Every error that Start, Probe, Wait, Signal and Close return matches one of them with errors.Is. +var ( + ErrUnsupported = errors.New("sessionview: unsupported platform") + ErrInvalidSpec = errors.New("sessionview: invalid spec") + ErrCapability = errors.New("sessionview: missing capability") + ErrNoFUSE = errors.New("sessionview: /dev/fuse unavailable") + ErrNoSeccomp = errors.New("sessionview: seccomp filter unavailable") + ErrMountDenied = errors.New("sessionview: mount denied") + ErrMountTarget = errors.New("sessionview: invalid mount target") + ErrWorld = errors.New("sessionview: world server failed") + ErrNetwork = errors.New("sessionview: network setup failed") + ErrRestrict = errors.New("sessionview: privilege drop failed") + ErrExec = errors.New("sessionview: process start failed") + ErrLauncher = errors.New("sessionview: launcher failed") + ErrClosed = errors.New("sessionview: view closed") +) + +// errorKinds fixes the wire code of each kind the launcher reports. +var errorKinds = []error{ErrLauncher, ErrNoFUSE, ErrMountDenied, ErrMountTarget, ErrNetwork, ErrRestrict, ErrExec} + +// Error is a typed sessionview failure. It matches Kind and, when present, Err. +type Error struct { + Kind error + Op string + Path string + Err error +} + +func (e *Error) Error() string { + var b strings.Builder + b.WriteString(e.Kind.Error()) + if e.Op != "" { + b.WriteString(": " + e.Op) + } + if e.Path != "" { + b.WriteString(" " + e.Path) + } + if e.Err != nil { + b.WriteString(": " + e.Err.Error()) + } + return b.String() +} + +func (e *Error) Unwrap() []error { + if e.Err == nil { + return []error{e.Kind} + } + return []error{e.Kind, e.Err} +} + +func invalid(format string, args ...any) error { + return &Error{Kind: ErrInvalidSpec, Err: fmt.Errorf(format, args...)} +} diff --git a/apps/daemon/internal/sessionview/launcher_linux.go b/apps/daemon/internal/sessionview/launcher_linux.go new file mode 100644 index 000000000..64cb22df6 --- /dev/null +++ b/apps/daemon/internal/sessionview/launcher_linux.go @@ -0,0 +1,222 @@ +//go:build linux + +package sessionview + +import ( + "encoding/gob" + "fmt" + "os" + "os/signal" + "runtime" + "strings" + "sync" + "sync/atomic" + "syscall" + + "golang.org/x/sys/unix" +) + +// Init runs the launcher when the process was started as one, and then never returns. The daemon calls it first thing in main. +func Init() { + if len(os.Args) != 1 || os.Args[0] != launcherArg0 { + return + } + // Capability sets, no_new_privs and seccomp filters are per thread: the thread that sets them must be the one that forks the process. + runtime.LockOSThread() + os.Exit(runLauncher()) +} + +// launcher is PID 1 of the view. +type launcher struct { + ctl *control + proceed chan struct{} + proceedOnce sync.Once + pidfd atomic.Int64 +} + +func runLauncher() int { + ctl, err := newControl(os.NewFile(controlFD, "sessionview-control")) + if err != nil { + fmt.Fprintf(os.Stderr, "sessionview launcher: %v\n", err) + return 1 + } + l := &launcher{ctl: ctl, proceed: make(chan struct{})} + l.pidfd.Store(-1) + code, err := l.run() + if err != nil { + _ = ctl.send(message{Kind: msgFailed, Fail: failureOf(err)}) + return 1 + } + return code +} + +func (l *launcher) run() (int, error) { + spec, err := readSpec() + if err != nil { + return 0, err + } + go l.serveControl() + if err := unix.Mount("", "/", "", unix.MS_REC|unix.MS_PRIVATE, ""); err != nil { + return 0, mountError("make-rprivate", "/", err) + } + if err := loopbackUp(); err != nil { + return 0, &Error{Kind: ErrNetwork, Op: "loopback", Err: err} + } + if err := l.mountWorld(spec.Staging); err != nil { + return 0, err + } + <-l.proceed + b := &builder{root: -1} + defer b.close() + if err := b.build(spec); err != nil { + return 0, err + } + if err := switchRoot(b.root); err != nil { + return 0, err + } + if err := restrict(); err != nil { + return 0, err + } + b.close() + pid, err := startProcess(spec) + if err != nil { + return 0, err + } + pidfd, err := unix.PidfdOpen(pid, 0) + if err != nil { + return 0, &Error{Kind: ErrExec, Op: "pidfd_open", Err: err} + } + l.pidfd.Store(int64(pidfd)) + for _, fd := range []int{stdinFD, stdoutFD, stderrFD} { + unix.Close(fd) + } + if err := l.ctl.send(message{Kind: msgStarted, Pid: pid}); err != nil { + return 0, &Error{Kind: ErrLauncher, Op: "report start", Err: err} + } + l.forwardSignals() + return l.reap(pid) +} + +func readSpec() (*launchSpec, error) { + f := os.NewFile(specFD, "sessionview-spec") + defer f.Close() + var s launchSpec + if err := gob.NewDecoder(f).Decode(&s); err != nil { + return nil, &Error{Kind: ErrLauncher, Op: "read spec", Err: err} + } + return &s, nil +} + +// serveControl handles daemon messages. When the daemon goes away the view goes with it. +func (l *launcher) serveControl() { + for { + m, files, err := l.ctl.recv() + closeFiles(files) + if err != nil { + os.Exit(1) + } + switch m.Kind { + case msgProceed: + l.proceedOnce.Do(func() { close(l.proceed) }) + case msgSignal: + l.signal(m.Signal) + } + } +} + +func (l *launcher) signal(sig syscall.Signal) { + if fd := l.pidfd.Load(); fd >= 0 { + _ = unix.PidfdSendSignal(int(fd), sig, nil, 0) + } +} + +func (l *launcher) forwardSignals() { + sigs := make(chan os.Signal, 8) + signal.Notify(sigs, unix.SIGHUP, unix.SIGINT, unix.SIGQUIT, unix.SIGTERM, unix.SIGUSR1, unix.SIGUSR2, unix.SIGWINCH) + go func() { + for s := range sigs { + l.signal(s.(syscall.Signal)) + } + }() +} + +// reap collects every child, since orphans in the view reparent to PID 1, until the process exits. +func (l *launcher) reap(pid int) (int, error) { + for { + var ws unix.WaitStatus + wpid, err := unix.Wait4(-1, &ws, 0, nil) + if err == unix.EINTR { + continue + } + if err != nil { + return 0, &Error{Kind: ErrLauncher, Op: "wait", Err: err} + } + if wpid != pid { + continue + } + var exit Exit + code := ws.ExitStatus() + if ws.Signaled() { + exit.Signal, exit.CoreDumped = ws.Signal(), ws.CoreDump() + code = 128 + int(exit.Signal) + } else { + exit.Code = code + } + _ = l.ctl.send(message{Kind: msgExited, Exit: exit}) + return code, nil + } +} + +func (l *launcher) mountWorld(staging string) error { + dev, err := unix.Open("/dev/fuse", unix.O_RDWR|unix.O_CLOEXEC, 0) + if err != nil { + return &Error{Kind: ErrNoFUSE, Op: "open", Path: "/dev/fuse", Err: err} + } + defer unix.Close(dev) + opts := strings.Join(append([]string{fmt.Sprintf("fd=%d", dev)}, fuseOptions...), ",") + if err := unix.Mount("oac-world", staging, "fuse", fuseMountFlags, opts); err != nil { + return mountError("mount", staging, err) + } + netns, err := unix.Open("/proc/self/ns/net", unix.O_RDONLY|unix.O_CLOEXEC, 0) + if err != nil { + return &Error{Kind: ErrNetwork, Op: "open", Path: "/proc/self/ns/net", Err: err} + } + defer unix.Close(netns) + if err := l.ctl.send(message{Kind: msgMounted}, dev, netns); err != nil { + return &Error{Kind: ErrLauncher, Op: "report mount", Err: err} + } + return nil +} + +func loopbackUp() error { + fd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM|unix.SOCK_CLOEXEC, 0) + if err != nil { + return err + } + defer unix.Close(fd) + ifr, err := unix.NewIfreq("lo") + if err != nil { + return err + } + if err := unix.IoctlIfreq(fd, unix.SIOCGIFFLAGS, ifr); err != nil { + return err + } + ifr.SetUint16(ifr.Uint16() | unix.IFF_UP) + return unix.IoctlIfreq(fd, unix.SIOCSIFFLAGS, ifr) +} + +func startProcess(spec *launchSpec) (int, error) { + pid, err := syscall.ForkExec(spec.Path, spec.Args, &syscall.ProcAttr{ + Dir: spec.Dir, + Env: spec.Env, + Files: []uintptr{stdinFD, stdoutFD, stderrFD}, + Sys: &syscall.SysProcAttr{ + Setsid: true, + Credential: &syscall.Credential{Uid: spec.UID, Gid: spec.GID, Groups: spec.Groups}, + }, + }) + if err != nil { + return 0, &Error{Kind: ErrExec, Op: "exec", Path: spec.Path, Err: err} + } + return pid, nil +} diff --git a/apps/daemon/internal/sessionview/probe_linux.go b/apps/daemon/internal/sessionview/probe_linux.go new file mode 100644 index 000000000..218b0de53 --- /dev/null +++ b/apps/daemon/internal/sessionview/probe_linux.go @@ -0,0 +1,102 @@ +//go:build linux + +package sessionview + +import ( + "fmt" + "os" + "runtime" + "strings" + "sync" + + "golang.org/x/sys/unix" +) + +// requiredCaps are what the launcher uses: namespaces and mounts, chroot, loopback, the process's credentials, dropping the bounding set and signalling the process. +var requiredCaps = []struct { + bit int + name string +}{ + {unix.CAP_SYS_ADMIN, "CAP_SYS_ADMIN"}, + {unix.CAP_SYS_CHROOT, "CAP_SYS_CHROOT"}, + {unix.CAP_NET_ADMIN, "CAP_NET_ADMIN"}, + {unix.CAP_SETUID, "CAP_SETUID"}, + {unix.CAP_SETGID, "CAP_SETGID"}, + {unix.CAP_SETPCAP, "CAP_SETPCAP"}, + {unix.CAP_KILL, "CAP_KILL"}, +} + +var probe = sync.OnceValue(probeHost) + +// Probe checks once that this host can build views and returns the same typed error afterwards. +func Probe() error { + return probe() +} + +func probeHost() error { + hdr := unix.CapUserHeader{Version: unix.LINUX_CAPABILITY_VERSION_3} + var data [2]unix.CapUserData + if err := unix.Capget(&hdr, &data[0]); err != nil { + return &Error{Kind: ErrCapability, Op: "capget", Err: err} + } + effective := uint64(data[1].Effective)<<32 | uint64(data[0].Effective) + var missing []string + for _, c := range requiredCaps { + if effective&(1< 0 { + return &Error{Kind: ErrCapability, Op: "probe", Err: fmt.Errorf("missing %s", strings.Join(missing, ", "))} + } + dev, err := unix.Open("/dev/fuse", unix.O_RDWR|unix.O_CLOEXEC, 0) + if err != nil { + return &Error{Kind: ErrNoFUSE, Op: "open", Path: "/dev/fuse", Err: err} + } + unix.Close(dev) + if err := onThrowawayThread(probeMount); err != nil { + return err + } + return onThrowawayThread(probeSeccomp) +} + +// onThrowawayThread runs f on a dedicated thread that is never unlocked, so the thread exits with the goroutine and takes any namespace or filter f sets up with it. +func onThrowawayThread(f func() error) error { + errc := make(chan error, 1) + go func() { + runtime.LockOSThread() + errc <- f() + }() + return <-errc +} + +// probeMount mounts a tmpfs in a private mount namespace. +func probeMount() error { + if err := unix.Unshare(unix.CLONE_NEWNS); err != nil { + return mountProbeError("unshare", "", err) + } + if err := unix.Mount("", "/", "", unix.MS_REC|unix.MS_PRIVATE, ""); err != nil { + return mountProbeError("make-rprivate", "/", err) + } + dir, err := os.MkdirTemp("", "oac-view-probe-*") + if err != nil { + return &Error{Kind: ErrLauncher, Op: "probe", Err: err} + } + defer os.Remove(dir) + if err := unix.Mount("oac-probe", dir, "tmpfs", fuseMountFlags, "size=4k"); err != nil { + return mountProbeError("mount", dir, err) + } + if err := unix.Unmount(dir, 0); err != nil { + return mountProbeError("umount", dir, err) + } + return nil +} + +// probeSeccomp installs the view's seccomp filter. +func probeSeccomp() error { + return installSeccomp(ErrNoSeccomp) +} + +func mountProbeError(op, path string, err error) error { + return &Error{Kind: ErrMountDenied, Op: op, Path: path, Err: err} +} diff --git a/apps/daemon/internal/sessionview/restrict_linux.go b/apps/daemon/internal/sessionview/restrict_linux.go new file mode 100644 index 000000000..251595b97 --- /dev/null +++ b/apps/daemon/internal/sessionview/restrict_linux.go @@ -0,0 +1,45 @@ +//go:build linux + +package sessionview + +import ( + "math" + + "golang.org/x/sys/unix" +) + +// restrict leaves the calling thread, which forks the process, with only what the process may inherit: no fds beyond 0–2, empty bounding, ambient and inheritable capability sets, no_new_privs and the seccomp filter. The child's setuid clears the remaining effective and permitted sets. +func restrict() error { + if err := unix.CloseRange(3, math.MaxUint32, unix.CLOSE_RANGE_CLOEXEC); err != nil { + return &Error{Kind: ErrRestrict, Op: "close_range", Err: err} + } + if err := dropCapabilities(); err != nil { + return err + } + return installSeccomp(ErrRestrict) +} + +func dropCapabilities() error { + for c := 0; c < 64; c++ { + err := unix.Prctl(unix.PR_CAPBSET_DROP, uintptr(c), 0, 0, 0) + if err == unix.EINVAL { + break + } + if err != nil { + return &Error{Kind: ErrRestrict, Op: "capbset_drop", Err: err} + } + } + if err := unix.Prctl(unix.PR_CAP_AMBIENT, unix.PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0); err != nil { + return &Error{Kind: ErrRestrict, Op: "cap_ambient_clear_all", Err: err} + } + hdr := unix.CapUserHeader{Version: unix.LINUX_CAPABILITY_VERSION_3} + var data [2]unix.CapUserData + if err := unix.Capget(&hdr, &data[0]); err != nil { + return &Error{Kind: ErrRestrict, Op: "capget", Err: err} + } + data[0].Inheritable, data[1].Inheritable = 0, 0 + if err := unix.Capset(&hdr, &data[0]); err != nil { + return &Error{Kind: ErrRestrict, Op: "capset", Err: err} + } + return nil +} diff --git a/apps/daemon/internal/sessionview/seccomp_linux.go b/apps/daemon/internal/sessionview/seccomp_linux.go new file mode 100644 index 000000000..b2537e33b --- /dev/null +++ b/apps/daemon/internal/sessionview/seccomp_linux.go @@ -0,0 +1,92 @@ +//go:build linux + +package sessionview + +import ( + "runtime" + "unsafe" + + "golang.org/x/net/bpf" + "golang.org/x/sys/unix" +) + +// Offsets into struct seccomp_data. On amd64 and arm64, both little-endian, an argument's low word comes first. +const ( + dataNr = 0 + dataArch = 4 + dataArg0Low = 16 + + x32SyscallBit = 0x40000000 +) + +// seccompArch holds one architecture's numbers for the syscalls the filter decides on. They come from the kernel's syscall tables, since the filter covers both architectures whatever the build. +type seccompArch struct { + audit uint32 + clone, unshare, setns, clone3 uint32 + x32 bool +} + +var seccompArches = []seccompArch{ + {audit: unix.AUDIT_ARCH_X86_64, clone: 56, unshare: 272, setns: 308, clone3: 435, x32: true}, + {audit: unix.AUDIT_ARCH_AARCH64, clone: 220, unshare: 97, setns: 268, clone3: 435}, +} + +var ( + retAllow = uint32(unix.SECCOMP_RET_ALLOW) + retKill = uint32(unix.SECCOMP_RET_KILL_PROCESS) + retEPERM = uint32(unix.SECCOMP_RET_ERRNO | unix.EPERM) + retENOSYS = uint32(unix.SECCOMP_RET_ERRNO | unix.ENOSYS) +) + +// seccompProgram denies creating a user namespace and every setns and allows everything else. Classic BPF cannot read clone3's argument struct, so clone3 reports ENOSYS and libc and Go fall back to clone. Any architecture other than amd64 and arm64 kills the process, and amd64 x32 syscalls report ENOSYS. +func seccompProgram() []bpf.Instruction { + prog := []bpf.Instruction{bpf.LoadAbsolute{Off: dataArch, Size: 4}} + for _, a := range seccompArches { + // Every path through a block returns, so a mismatch skips to the next architecture with A still holding arch. + block := a.block() + prog = append(prog, bpf.JumpIf{Cond: bpf.JumpEqual, Val: a.audit, SkipFalse: uint8(len(block))}) + prog = append(prog, block...) + } + return append(prog, bpf.RetConstant{Val: retKill}) +} + +func (a seccompArch) block() []bpf.Instruction { + b := []bpf.Instruction{bpf.LoadAbsolute{Off: dataNr, Size: 4}} + if a.x32 { + b = append(b, bpf.JumpIf{Cond: bpf.JumpGreaterOrEqual, Val: x32SyscallBit, SkipFalse: 1}, bpf.RetConstant{Val: retENOSYS}) + } + return append(b, + bpf.JumpIf{Cond: bpf.JumpEqual, Val: a.clone3, SkipFalse: 1}, + bpf.RetConstant{Val: retENOSYS}, + bpf.JumpIf{Cond: bpf.JumpEqual, Val: a.setns, SkipFalse: 1}, + bpf.RetConstant{Val: retEPERM}, + bpf.JumpIf{Cond: bpf.JumpEqual, Val: a.clone, SkipTrue: 1}, + bpf.JumpIf{Cond: bpf.JumpEqual, Val: a.unshare, SkipFalse: 3}, + bpf.LoadAbsolute{Off: dataArg0Low, Size: 4}, + bpf.JumpIf{Cond: bpf.JumpBitsSet, Val: unix.CLONE_NEWUSER, SkipFalse: 1}, + bpf.RetConstant{Val: retEPERM}, + bpf.RetConstant{Val: retAllow}, + ) +} + +// installSeccomp sets no_new_privs and installs the filter on the calling thread, reporting failures as kind. Children forked from the thread inherit both, and the filter survives exec. +func installSeccomp(kind error) error { + raw, err := bpf.Assemble(seccompProgram()) + if err != nil { + return &Error{Kind: kind, Op: "assemble seccomp filter", Err: err} + } + filter := make([]unix.SockFilter, len(raw)) + for i, r := range raw { + filter[i] = unix.SockFilter{Code: r.Op, Jt: r.Jt, Jf: r.Jf, K: r.K} + } + prog := unix.SockFprog{Len: uint16(len(filter)), Filter: &filter[0]} + if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil { + return &Error{Kind: kind, Op: "no_new_privs", Err: err} + } + _, _, errno := unix.Syscall(unix.SYS_SECCOMP, unix.SECCOMP_SET_MODE_FILTER, 0, uintptr(unsafe.Pointer(&prog))) + runtime.KeepAlive(filter) + if errno != 0 { + return &Error{Kind: kind, Op: "seccomp", Err: errno} + } + return nil +} diff --git a/apps/daemon/internal/sessionview/spec.go b/apps/daemon/internal/sessionview/spec.go new file mode 100644 index 000000000..f8773f53c --- /dev/null +++ b/apps/daemon/internal/sessionview/spec.go @@ -0,0 +1,222 @@ +package sessionview + +import ( + "os" + "path" + "path/filepath" + "strings" + "syscall" +) + +// Spec declares one view and the process it runs. +type Spec struct { + World World + Private []PrivateDir + Overlays []Overlay + Shim Shim + Process Process + Network Network +} + +// World starts serving the view's root file system on dev, a /dev/fuse connection that the launcher has already mounted as mount describes. The server runs in the daemon, outside the view, and never mounts or unmounts anything. sessionview closes dev after Stop returns. +type World func(dev *os.File, mount WorldMount) (WorldServer, error) + +// WorldServer is a running world. +type WorldServer interface { + // Stop returns once serving has ended. sessionview calls it after the view has exited, when the kernel has aborted the connection. + Stop() error +} + +// WorldMount describes how the launcher mounted the world. +type WorldMount struct { + // Options are the FUSE mount options, apart from the launcher's fd number. + Options []string + // Flags are the mount flags. + Flags []string + // Mountpoints are the view paths the launcher mounts over. The world presents each one, and each ancestor as a directory, with no symlinks and a stable identity for the view's lifetime, whether or not the sandbox has the path. A mountpoint that disappears or changes identity detaches what is mounted on it. + Mountpoints []Mountpoint +} + +// Mountpoint is a view path the world presents as a directory or a regular file. +type Mountpoint struct { + Path string + Dir bool +} + +// PrivateDir binds a host directory at /.oac/. A writable directory is never executable. +type PrivateDir struct { + Name string + HostDir string + Writable bool + Exec bool +} + +// Overlay presents a trusted, immutable host file or directory at an absolute view path. +type Overlay struct { + Path string + Source string + Exec bool +} + +// Shim presents a static binary at /.oac/bin/ for each name and binds it over each absolute view path. +type Shim struct { + Binary string + Names []string + Paths []string +} + +// Process is the one process the view runs. +type Process struct { + Path string + Args []string + Env []string + Dir string + UID uint32 + GID uint32 + Groups []uint32 + // A nil Stdin, Stdout or Stderr is a pipe whose other end the View exposes. + Stdin, Stdout, Stderr *os.File +} + +// Network configures the view's network namespace, which has only loopback up. +type Network struct { + // Setup, when set, runs with the view's network namespace before the process starts. sessionview closes netns after Setup returns; what Setup opens in the namespace belongs to the caller. + Setup func(netns *os.File) error +} + +// Exit is how the process ended. +type Exit struct { + Code int + Signal syscall.Signal + CoreDumped bool +} + +const ( + privateRoot = "/.oac" + shimName = "bin" +) + +// reservedTrees are built by the launcher; overlays and shim paths stay out of them. +var reservedTrees = []string{privateRoot, "/proc", "/dev"} + +func (s *Spec) validate() error { + if s.World == nil { + return invalid("world is required") + } + names := map[string]bool{} + for _, d := range s.Private { + if !isComponent(d.Name) || d.Name == shimName || names[d.Name] { + return invalid("private directory name %q", d.Name) + } + names[d.Name] = true + if d.Writable && d.Exec { + return invalid("private directory %q is both writable and executable", d.Name) + } + if info, err := hostSource(d.HostDir); err != nil { + return err + } else if !info.IsDir() { + return invalid("private directory %s is not a directory", d.HostDir) + } + } + var claimed []string + for _, o := range s.Overlays { + if err := viewPath(o.Path); err != nil { + return err + } + if _, err := hostSource(o.Source); err != nil { + return err + } + claimed = append(claimed, o.Path) + } + if len(s.Shim.Names) > 0 || len(s.Shim.Paths) > 0 { + if info, err := hostSource(s.Shim.Binary); err != nil { + return err + } else if info.IsDir() { + return invalid("shim %s is a directory", s.Shim.Binary) + } + } + names = map[string]bool{} + for _, n := range s.Shim.Names { + if !isComponent(n) || names[n] { + return invalid("shim name %q", n) + } + names[n] = true + } + for _, p := range s.Shim.Paths { + if err := viewPath(p); err != nil { + return err + } + claimed = append(claimed, p) + } + for i, a := range claimed { + for _, b := range claimed[i+1:] { + if a == b || within(a, b) || within(b, a) { + return invalid("view paths %s and %s overlap", a, b) + } + } + } + return s.Process.validate() +} + +func (p *Process) validate() error { + if !isViewAbs(p.Path) || !isViewAbs(p.Dir) { + return invalid("process path %q and directory %q must be absolute and clean", p.Path, p.Dir) + } + if len(p.Args) == 0 { + return invalid("process argv is empty") + } + if p.UID == 0 || p.GID == 0 { + return invalid("process must not run as uid or gid 0") + } + for _, a := range p.Args { + if strings.IndexByte(a, 0) >= 0 { + return invalid("process argument contains NUL") + } + } + for _, e := range p.Env { + if strings.IndexByte(e, 0) >= 0 || strings.IndexByte(e, '=') <= 0 { + return invalid("process environment entry %q", e) + } + } + return nil +} + +func viewPath(p string) error { + if !isViewAbs(p) || p == "/" { + return invalid("view path %q must be absolute, clean and not /", p) + } + for _, r := range reservedTrees { + if p == r || within(p, r) { + return invalid("view path %s is inside %s", p, r) + } + } + return nil +} + +// hostSource checks that a trusted host path is a directory or a regular file. +func hostSource(p string) (os.FileInfo, error) { + if !filepath.IsAbs(p) { + return nil, invalid("host path %q must be absolute", p) + } + info, err := os.Stat(p) + if err != nil { + return nil, &Error{Kind: ErrInvalidSpec, Op: "stat", Path: p, Err: err} + } + if !info.IsDir() && !info.Mode().IsRegular() { + return nil, invalid("host path %s is neither a directory nor a regular file", p) + } + return info, nil +} + +func isViewAbs(p string) bool { + return strings.HasPrefix(p, "/") && path.Clean(p) == p && strings.IndexByte(p, 0) < 0 +} + +func isComponent(name string) bool { + return name != "" && name != "." && name != ".." && !strings.ContainsAny(name, "/\x00") +} + +// within reports whether p is strictly beneath dir. +func within(p, dir string) bool { + return strings.HasPrefix(p, dir+"/") +} diff --git a/apps/daemon/internal/sessionview/view_linux.go b/apps/daemon/internal/sessionview/view_linux.go new file mode 100644 index 000000000..1e31c87da --- /dev/null +++ b/apps/daemon/internal/sessionview/view_linux.go @@ -0,0 +1,352 @@ +//go:build linux + +package sessionview + +import ( + "context" + "encoding/gob" + "errors" + "fmt" + "io" + "os" + "os/exec" + "sync" + "sync/atomic" + "syscall" + + "golang.org/x/sys/unix" +) + +// launcherArg0 marks the re-executed daemon binary as a launcher. +const launcherArg0 = "oac-sessionview" + +// The world mount. default_permissions stays off: the world decides access. +var ( + fuseOptions = []string{"rootmode=40000", "user_id=0", "group_id=0", "allow_other"} + fuseFlags = []string{"nosuid", "nodev", "noexec"} +) + +const fuseMountFlags = unix.MS_NOSUID | unix.MS_NODEV | unix.MS_NOEXEC + +// View is a running view. +type View struct { + cmd *exec.Cmd + ctl *control + world WorldServer + dev *os.File + staging string + pipes [3]*os.File + + reapOnce sync.Once + reapErr error + closing atomic.Bool + closeOnce sync.Once + done chan struct{} + exit Exit + err error +} + +// Start builds a view for spec and starts its process. ctx bounds only the construction. +func Start(ctx context.Context, spec Spec) (*View, error) { + if err := spec.validate(); err != nil { + return nil, err + } + if err := Probe(); err != nil { + return nil, err + } + v := &View{done: make(chan struct{})} + if err := v.launch(&spec); err != nil { + v.abort() + return nil, err + } + stop := context.AfterFunc(ctx, func() { _ = v.cmd.Process.Kill() }) + err := v.handshake(&spec) + if !stop() { + err = &Error{Kind: ErrLauncher, Op: "start", Err: ctx.Err()} + } + if err != nil { + v.abort() + return nil, err + } + go v.watch() + return v, nil +} + +func (v *View) launch(spec *Spec) error { + child, err := v.stdio(&spec.Process) + if err != nil { + return &Error{Kind: ErrLauncher, Op: "pipe", Err: err} + } + defer func() { + given := []*os.File{spec.Process.Stdin, spec.Process.Stdout, spec.Process.Stderr} + for i, f := range child { + if f != given[i] { + f.Close() + } + } + }() + v.staging, err = os.MkdirTemp("", "oac-view-*") + if err != nil { + return &Error{Kind: ErrLauncher, Op: "staging", Err: err} + } + specR, specW, err := os.Pipe() + if err != nil { + return &Error{Kind: ErrLauncher, Op: "pipe", Err: err} + } + defer specR.Close() + pair, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_SEQPACKET|unix.SOCK_CLOEXEC, 0) + if err != nil { + specW.Close() + return &Error{Kind: ErrLauncher, Op: "socketpair", Err: err} + } + ctlChild := os.NewFile(uintptr(pair[1]), "sessionview-control") + defer ctlChild.Close() + if v.ctl, err = newControl(os.NewFile(uintptr(pair[0]), "sessionview-control")); err != nil { + specW.Close() + return &Error{Kind: ErrLauncher, Op: "control", Err: err} + } + v.cmd = &exec.Cmd{ + Path: "/proc/self/exe", + Args: []string{launcherArg0}, + Env: []string{}, + Stderr: os.Stderr, + ExtraFiles: []*os.File{specR, ctlChild, child[0], child[1], child[2]}, + // Cloning into the namespaces, rather than unsharing later, puts every runtime thread of the launcher in them and makes it PID 1 of the view. + SysProcAttr: &syscall.SysProcAttr{ + Cloneflags: syscall.CLONE_NEWNS | syscall.CLONE_NEWNET | syscall.CLONE_NEWPID, + Setsid: true, + }, + } + if err := v.cmd.Start(); err != nil { + specW.Close() + v.cmd = nil + return &Error{Kind: ErrLauncher, Op: "start", Err: err} + } + ls := &launchSpec{ + Staging: v.staging, Private: spec.Private, Overlays: spec.Overlays, Shim: spec.Shim, + Path: spec.Process.Path, Args: spec.Process.Args, Env: spec.Process.Env, Dir: spec.Process.Dir, + UID: spec.Process.UID, GID: spec.Process.GID, Groups: spec.Process.Groups, + } + // A launcher that dies early breaks the pipe; the handshake reports that. + go func() { + _ = gob.NewEncoder(specW).Encode(ls) + specW.Close() + }() + return nil +} + +// stdio returns the launcher's ends of the process's stdio, creating pipes where the spec gives no file. +func (v *View) stdio(p *Process) ([3]*os.File, error) { + child := [3]*os.File{p.Stdin, p.Stdout, p.Stderr} + for i := range child { + if child[i] != nil { + continue + } + r, w, err := os.Pipe() + if err != nil { + for j := range i { + if v.pipes[j] != nil { + v.pipes[j].Close() + child[j].Close() + } + } + return child, err + } + if i == 0 { + child[i], v.pipes[i] = r, w + } else { + child[i], v.pipes[i] = w, r + } + } + return child, nil +} + +func (v *View) handshake(spec *Spec) error { + m, files, err := v.ctl.recv() + if err != nil { + return v.lost("mount", err) + } + if m.Kind == msgFailed { + return m.Fail.err() + } + if m.Kind != msgMounted || len(files) != 2 { + closeFiles(files) + return &Error{Kind: ErrLauncher, Op: "mount", Err: fmt.Errorf("unexpected message %d with %d files", m.Kind, len(files))} + } + v.dev = files[0] + netns := files[1] + defer netns.Close() + world, err := spec.World(v.dev, WorldMount{Options: fuseOptions, Flags: fuseFlags, Mountpoints: spec.mountpoints()}) + if err != nil { + return &Error{Kind: ErrWorld, Op: "serve", Err: err} + } + v.world = world + if spec.Network.Setup != nil { + if err := spec.Network.Setup(netns); err != nil { + return &Error{Kind: ErrNetwork, Op: "setup", Err: err} + } + } + if err := v.ctl.send(message{Kind: msgProceed}); err != nil { + return v.lost("proceed", err) + } + m, files, err = v.ctl.recv() + closeFiles(files) + switch { + case err != nil: + return v.lost("start", err) + case m.Kind == msgFailed: + return m.Fail.err() + case m.Kind != msgStarted: + return &Error{Kind: ErrLauncher, Op: "start", Err: fmt.Errorf("unexpected message %d", m.Kind)} + } + return nil +} + +// lost reports a launcher that stopped talking, with its exit status when it has exited. +func (v *View) lost(op string, err error) error { + if errors.Is(err, io.EOF) { + if werr := v.reap(); werr != nil { + err = werr + } + } + return &Error{Kind: ErrLauncher, Op: op, Err: err} +} + +func (v *View) reap() error { + v.reapOnce.Do(func() { v.reapErr = v.cmd.Wait() }) + return v.reapErr +} + +// abort undoes a failed Start. +func (v *View) abort() { + if v.cmd != nil { + _ = v.cmd.Process.Kill() + _ = v.reap() + } + _ = v.teardown() + for _, p := range v.pipes { + if p != nil { + p.Close() + } + } +} + +// teardown releases what the view held once the launcher has exited. +func (v *View) teardown() error { + var err error + if v.world != nil { + if serr := v.world.Stop(); serr != nil { + err = &Error{Kind: ErrWorld, Op: "stop", Err: serr} + } + } + if v.dev != nil { + v.dev.Close() + } + if v.ctl != nil { + v.ctl.close() + } + if v.staging != "" { + os.Remove(v.staging) + } + return err +} + +func (v *View) watch() { + defer close(v.done) + var exit *Exit + var failed error + for { + m, files, err := v.ctl.recv() + closeFiles(files) + if err != nil { + if !errors.Is(err, io.EOF) { + _ = v.cmd.Process.Kill() + } + break + } + switch m.Kind { + case msgExited: + exit = &m.Exit + case msgFailed: + failed = m.Fail.err() + } + } + werr := v.reap() + terr := v.teardown() + switch { + case exit != nil: + v.exit = *exit + case v.closing.Load(): + v.err = ErrClosed + case failed != nil: + v.err = failed + default: + v.err = &Error{Kind: ErrLauncher, Op: "wait", Err: werr} + } + if terr != nil { + v.err = errors.Join(v.err, terr) + } +} + +// Wait returns how the process ended once the view is torn down and the world server has stopped. It returns ErrClosed when Close ended the view first. +func (v *View) Wait() (Exit, error) { + <-v.done + return v.exit, v.err +} + +// Signal delivers sig to the process. +func (v *View) Signal(sig syscall.Signal) error { + select { + case <-v.done: + return ErrClosed + default: + } + if err := v.ctl.send(message{Kind: msgSignal, Signal: sig}); err != nil { + return &Error{Kind: ErrLauncher, Op: "signal", Err: err} + } + return nil +} + +// Close kills the view, waits for its teardown and closes the pipes it created. +func (v *View) Close() error { + v.closeOnce.Do(func() { + v.closing.Store(true) + _ = v.cmd.Process.Kill() + <-v.done + for _, p := range v.pipes { + if p != nil { + p.Close() + } + } + }) + return nil +} + +// Stdin is the write end of the process's stdin pipe, or nil when the spec gave a file. +func (v *View) Stdin() *os.File { return v.pipes[0] } + +// Stdout is the read end of the process's stdout pipe, or nil when the spec gave a file. +func (v *View) Stdout() *os.File { return v.pipes[1] } + +// Stderr is the read end of the process's stderr pipe, or nil when the spec gave a file. +func (v *View) Stderr() *os.File { return v.pipes[2] } + +func (s *Spec) mountpoints() []Mountpoint { + var m []Mountpoint + for _, d := range s.Private { + m = append(m, Mountpoint{Path: privateRoot + "/" + d.Name, Dir: true}) + } + m = append(m, + Mountpoint{Path: privateRoot + "/" + shimName, Dir: true}, + Mountpoint{Path: "/proc", Dir: true}, + Mountpoint{Path: "/dev", Dir: true}, + ) + for _, o := range s.Overlays { + info, err := os.Stat(o.Source) + m = append(m, Mountpoint{Path: o.Path, Dir: err == nil && info.IsDir()}) + } + for _, p := range s.Shim.Paths { + m = append(m, Mountpoint{Path: p}) + } + return m +} diff --git a/apps/daemon/internal/sessionview/view_linux_test.go b/apps/daemon/internal/sessionview/view_linux_test.go new file mode 100644 index 000000000..d1672e27a --- /dev/null +++ b/apps/daemon/internal/sessionview/view_linux_test.go @@ -0,0 +1,652 @@ +//go:build linux + +package sessionview + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "slices" + "strconv" + "strings" + "syscall" + "testing" + "time" + + gofs "github.com/hanwen/go-fuse/v2/fs" + "github.com/hanwen/go-fuse/v2/fuse" + "golang.org/x/net/bpf" + "golang.org/x/sys/unix" +) + +// The view tests need root with CAP_SYS_ADMIN and CAP_NET_ADMIN, /dev/fuse and no AppArmor confinement. Run them in a throwaway container: +// +// CGO_ENABLED=0 go test -c -o /tmp/sessionview.test ./apps/daemon/internal/sessionview +// docker run --rm --cap-add SYS_ADMIN --cap-add NET_ADMIN --device /dev/fuse --security-opt apparmor=unconfined \ +// -e OAC_TEST_SESSIONVIEW=1 -v /tmp/sessionview.test:/t.test:ro debian:bookworm-slim /t.test -test.v +const ( + gateEnv = "OAC_TEST_SESSIONVIEW" + helperEnv = "OAC_VIEW_HELPER" + shimMarker = "oac-test-shim" + viewID = 1000 + brokerAddr = "127.0.0.1:7070" +) + +// The test binary is also the Harness, the shim and the world binary inside the view. +func TestMain(m *testing.M) { + Init() + switch filepath.Base(os.Args[0]) { + case "sh", "env": + fmt.Println(shimMarker) + os.Exit(0) + } + if mode := os.Getenv(helperEnv); mode != "" { + os.Exit(runHelper(mode)) + } + os.Exit(m.Run()) +} + +func TestViewIsolation(t *testing.T) { + requireView(t) + f := newFixture(t) + w := &loopbackWorld{dir: f.world} + spec := f.spec(w, "probe", "OAC_VIEW_HOST_PATH="+f.self) + spec.Network.Setup = serveBroker(t) + v, err := Start(context.Background(), spec) + if err != nil { + t.Fatalf("Start: %v", err) + } + defer v.Close() + out, err := io.ReadAll(v.Stdout()) + if err != nil { + t.Fatal(err) + } + if exit, err := v.Wait(); err != nil || exit != (Exit{}) { + t.Fatalf("Wait = %+v, %v; output %s", exit, err, out) + } + var report map[string]string + if err := json.Unmarshal(out, &report); err != nil { + t.Fatalf("probe output %q: %v", out, err) + } + for _, c := range viewChecks { + if msg, ok := report[c.name]; !ok || msg != "" { + t.Errorf("%s: %q", c.name, msg) + } + } + if got, err := os.ReadFile(filepath.Join(f.world, "data", "out.txt")); err != nil || string(got) != "written in the view" { + t.Errorf("world file written in the view = %q, %v", got, err) + } +} + +func TestViewSignalAndTeardown(t *testing.T) { + requireView(t) + f := newFixture(t) + w := &loopbackWorld{dir: f.world} + token := fmt.Sprintf("oac-grandchild-%d", time.Now().UnixNano()) + v, err := Start(context.Background(), f.spec(w, "wait", "OAC_VIEW_TOKEN="+token)) + if err != nil { + t.Fatalf("Start: %v", err) + } + defer v.Close() + if line, err := bufio.NewReader(v.Stdout()).ReadString('\n'); err != nil || line != "ready\n" { + t.Fatalf("harness said %q, %v", line, err) + } + if n := processesWith(t, token); n != 1 { + t.Fatalf("%d grandchildren before exit, want 1", n) + } + if err := v.Signal(syscall.SIGTERM); err != nil { + t.Fatalf("Signal: %v", err) + } + if exit, err := v.Wait(); err != nil || exit != (Exit{Code: 7}) { + t.Fatalf("Wait = %+v, %v; want exit code 7", exit, err) + } + if n := processesWith(t, token); n != 0 { + t.Errorf("%d grandchildren survived the Harness", n) + } + select { + case <-w.served: + default: + t.Error("world server still serving") + } + if left, _ := filepath.Glob(filepath.Join(os.TempDir(), "oac-view-*")); len(left) != 0 { + t.Errorf("staging directories left: %v", left) + } +} + +// TestViewRefusesSymlinkedMountpoint checks that a sandbox symlink on the way to a mountpoint fails the view instead of redirecting the mount. +func TestViewRefusesSymlinkedMountpoint(t *testing.T) { + requireView(t) + f := newFixture(t) + etc := filepath.Join(f.world, "etc") + if err := os.Rename(etc, etc+".real"); err != nil { + t.Fatal(err) + } + if err := os.Symlink("etc.real", etc); err != nil { + t.Fatal(err) + } + w := &loopbackWorld{dir: f.world} + if _, err := Start(context.Background(), f.spec(w, "noop")); !errors.Is(err, ErrMountTarget) || !errors.Is(err, unix.ELOOP) { + t.Fatalf("Start = %v, want ErrMountTarget with ELOOP", err) + } + select { + case <-w.served: + default: + t.Error("world server still serving") + } +} + +// TestSeccompProgram runs the filter in a BPF interpreter for both architectures. The interpreter loads words big-endian where the kernel loads them in host order, so the input stores each word the filter reads big-endian at its seccomp_data offset. +func TestSeccompProgram(t *testing.T) { + vm, err := bpf.NewVM(seccompProgram()) + if err != nil { + t.Fatal(err) + } + amd64, arm64 := seccompArches[0], seccompArches[1] + cases := []struct { + name string + arch, nr, arg0 uint32 + want uint32 + }{ + {"amd64 getpid", amd64.audit, 39, 0, retAllow}, + {"amd64 clone", amd64.audit, amd64.clone, unix.CLONE_VM | unix.CLONE_VFORK, retAllow}, + {"amd64 clone user namespace", amd64.audit, amd64.clone, unix.CLONE_NEWUSER, retEPERM}, + {"amd64 unshare mount namespace", amd64.audit, amd64.unshare, unix.CLONE_NEWNS, retAllow}, + {"amd64 unshare user namespace", amd64.audit, amd64.unshare, unix.CLONE_NEWNS | unix.CLONE_NEWUSER, retEPERM}, + {"amd64 setns", amd64.audit, amd64.setns, 0, retEPERM}, + {"amd64 clone3", amd64.audit, amd64.clone3, 0, retENOSYS}, + {"amd64 x32 unshare", amd64.audit, x32SyscallBit | amd64.unshare, unix.CLONE_NEWUSER, retENOSYS}, + {"arm64 getpid", arm64.audit, 172, 0, retAllow}, + {"arm64 clone", arm64.audit, arm64.clone, unix.CLONE_VM | unix.CLONE_VFORK, retAllow}, + {"arm64 clone user namespace", arm64.audit, arm64.clone, unix.CLONE_NEWUSER, retEPERM}, + {"arm64 unshare user namespace", arm64.audit, arm64.unshare, unix.CLONE_NEWUSER, retEPERM}, + {"arm64 setns", arm64.audit, arm64.setns, 0, retEPERM}, + {"arm64 clone3", arm64.audit, arm64.clone3, 0, retENOSYS}, + {"i386", unix.AUDIT_ARCH_I386, 1, 0, retKill}, + {"arm", unix.AUDIT_ARCH_ARM, 1, 0, retKill}, + } + for _, c := range cases { + in := make([]byte, 64) + binary.BigEndian.PutUint32(in[dataNr:], c.nr) + binary.BigEndian.PutUint32(in[dataArch:], c.arch) + binary.BigEndian.PutUint32(in[dataArg0Low:], c.arg0) + if got, err := vm.Run(in); err != nil || uint32(got) != c.want { + t.Errorf("%s: %#x, %v; want %#x", c.name, got, err, c.want) + } + } +} + +func TestStartRejectsInvalidSpec(t *testing.T) { + for name, spec := range map[string]Spec{ + "relative overlay": {Overlays: []Overlay{{Path: "etc/resolv.conf", Source: "/etc/hosts"}}}, + "writable executable private": {Private: []PrivateDir{{Name: "home", HostDir: t.TempDir(), Writable: true, Exec: true}}}, + } { + spec.World = (&loopbackWorld{}).serve + spec.Process = Process{Path: "/bin/true", Args: []string{"true"}, Dir: "/", UID: viewID, GID: viewID} + if _, err := Start(context.Background(), spec); !errors.Is(err, ErrInvalidSpec) { + t.Errorf("%s: Start = %v, want ErrInvalidSpec", name, err) + } + } +} + +func requireView(t *testing.T) { + t.Helper() + if os.Getenv(gateEnv) != "1" { + t.Skipf("set %s=1 and run the test binary as root in a privileged container; see the comment at the top of this file", gateEnv) + } + if err := Probe(); err != nil { + t.Fatalf("Probe: %v", err) + } +} + +type fixture struct { + self, world, harness, home, run, overlay string +} + +// newFixture lays out a world with the mountpoints the real world frontend presents synthetically, plus the local sources. +func newFixture(t *testing.T) *fixture { + base := t.TempDir() + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + f := &fixture{ + self: self, + world: filepath.Join(base, "world"), + harness: filepath.Join(base, "harness"), + home: filepath.Join(base, "home"), + run: filepath.Join(base, "run"), + overlay: filepath.Join(base, "overlay"), + } + for _, d := range []string{".oac/harness", ".oac/home", ".oac/run", ".oac/bin", "proc", "dev", "bin", "usr/bin", "data", "etc/oac-overlay"} { + mkdir(t, filepath.Join(f.world, d)) + } + writeFile(t, filepath.Join(f.world, "bin", "sh"), "") + writeFile(t, filepath.Join(f.world, "usr", "bin", "env"), "") + writeFile(t, filepath.Join(f.world, "data", "in.txt"), "hello from the world") + copyFile(t, self, filepath.Join(f.world, "bin", "worldbin")) + mkdir(t, f.harness) + copyFile(t, self, filepath.Join(f.harness, "harness")) + for _, d := range []string{f.home, f.run} { + mkdir(t, d) + if err := os.Chown(d, viewID, viewID); err != nil { + t.Fatal(err) + } + } + mkdir(t, f.overlay) + writeFile(t, filepath.Join(f.overlay, "greeting"), "from the overlay") + return f +} + +func (f *fixture) spec(w *loopbackWorld, mode string, env ...string) Spec { + return Spec{ + World: w.serve, + Private: []PrivateDir{ + {Name: "harness", HostDir: f.harness, Exec: true}, + {Name: "home", HostDir: f.home, Writable: true}, + {Name: "run", HostDir: f.run, Writable: true}, + }, + Overlays: []Overlay{{Path: "/etc/oac-overlay", Source: f.overlay}}, + Shim: Shim{ + Binary: filepath.Join(f.harness, "harness"), + Names: []string{"sh", "env"}, + Paths: []string{"/bin/sh", "/usr/bin/env"}, + }, + Process: Process{ + Path: "/.oac/harness/harness", + Args: []string{"harness"}, + Env: append([]string{helperEnv + "=" + mode}, env...), + Dir: "/data", + UID: viewID, + GID: viewID, + Stderr: os.Stderr, + }, + } +} + +// loopbackWorld serves a directory as the world, the way the world frontend serves a sandbox. +type loopbackWorld struct { + dir string + served chan struct{} +} + +func (w *loopbackWorld) serve(dev *os.File, _ WorldMount) (WorldServer, error) { + fd, err := unix.Dup(int(dev.Fd())) + if err != nil { + return nil, err + } + root, err := gofs.NewLoopbackRoot(w.dir) + if err != nil { + unix.Close(fd) + return nil, err + } + srv, err := fuse.NewServer(gofs.NewNodeFS(root, &gofs.Options{}), fmt.Sprintf("/dev/fd/%d", fd), &fuse.MountOptions{}) + if err != nil { + unix.Close(fd) + return nil, err + } + w.served = make(chan struct{}) + go func() { + srv.Serve() + close(w.served) + }() + return w, nil +} + +func (w *loopbackWorld) Stop() error { + select { + case <-w.served: + return nil + case <-time.After(10 * time.Second): + return errors.New("world still serving 10s after the view ended") + } +} + +// serveBroker listens in the view's network namespace, as the broker does. +func serveBroker(t *testing.T) func(*os.File) error { + return func(netns *os.File) error { + type result struct { + ln net.Listener + err error + } + ch := make(chan result, 1) + go func() { + // Never unlocked: the thread exits with the goroutine instead of returning to the pool inside the view's namespace. + runtime.LockOSThread() + if err := unix.Setns(int(netns.Fd()), unix.CLONE_NEWNET); err != nil { + ch <- result{err: err} + return + } + ln, err := net.Listen("tcp", brokerAddr) + ch <- result{ln, err} + }() + r := <-ch + if r.err != nil { + return r.err + } + t.Cleanup(func() { r.ln.Close() }) + go func() { + for { + c, err := r.ln.Accept() + if err != nil { + return + } + c.Write([]byte("oac-broker\n")) + c.Close() + } + }() + return nil + } +} + +// processesWith counts processes whose command line contains token. +func processesWith(t *testing.T, token string) int { + t.Helper() + cmdlines, err := filepath.Glob("/proc/[0-9]*/cmdline") + if err != nil { + t.Fatal(err) + } + n := 0 + for _, p := range cmdlines { + if b, err := os.ReadFile(p); err == nil && bytes.Contains(b, []byte(token)) { + n++ + } + } + return n +} + +func runHelper(mode string) int { + switch mode { + case "probe": + report := map[string]string{} + for _, c := range viewChecks { + report[c.name] = "" + if err := c.run(); err != nil { + report[c.name] = err.Error() + } + } + json.NewEncoder(os.Stdout).Encode(report) + return 0 + case "wait": + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGTERM) + child := exec.Command("/.oac/harness/harness", os.Getenv("OAC_VIEW_TOKEN")) + child.Env = []string{helperEnv + "=sleep"} + if err := child.Start(); err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + fmt.Println("ready") + <-sigs + return 7 + case "sleep": + time.Sleep(time.Hour) + return 0 + case "noop": + return 0 + } + fmt.Fprintf(os.Stderr, "unknown helper %q\n", mode) + return 2 +} + +// viewChecks run inside the view as the Harness. +var viewChecks = []struct { + name string + run func() error +}{ + {"inherits only stdio", onlyStdio}, + {"runs as the view user", func() error { + if os.Getuid() != viewID || os.Getgid() != viewID { + return fmt.Errorf("uid %d gid %d", os.Getuid(), os.Getgid()) + } + return nil + }}, + {"no capabilities and no_new_privs", noPrivileges}, + {"reads the world", func() error { return fileHas("/data/in.txt", "hello from the world") }}, + {"writes the world", func() error { return os.WriteFile("/data/out.txt", []byte("written in the view"), 0o644) }}, + {"root is the FUSE world", func() error { + var st unix.Statfs_t + if err := unix.Statfs("/", &st); err != nil { + return err + } + if st.Type != unix.FUSE_SUPER_MAGIC { + return fmt.Errorf("root file system type %#x", st.Type) + } + return nil + }}, + {"world binaries do not execute", func() error { return execDenied("/bin/worldbin") }}, + {"/bin/sh runs the shim", func() error { return shimRuns("/bin/sh", "-c", "true") }}, + {"/.oac/bin runs the shim", func() error { return shimRuns("/.oac/bin/env") }}, + {"overlay is presented", func() error { return fileHas("/etc/oac-overlay/greeting", "from the overlay") }}, + {"/.oac/home is writable and not executable", func() error { + if err := os.WriteFile("/.oac/home/tool", []byte("#!/.oac/bin/sh\n"), 0o755); err != nil { + return err + } + return execDenied("/.oac/home/tool") + }}, + {"moves and links across world directories", func() error { return moveAndLink("/data") }}, + {"moves and links across /.oac/home directories", func() error { return moveAndLink("/.oac/home") }}, + {"creates processes and threads", func() error { + child := exec.Command("/.oac/harness/harness") + child.Env = []string{helperEnv + "=noop"} + if err := child.Run(); err != nil { + return fmt.Errorf("os/exec child: %w", err) + } + // Locked goroutines each need a thread of their own. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + tids, release := make(chan int), make(chan struct{}) + defer close(release) + for range 8 { + go func() { + runtime.LockOSThread() + tids <- unix.Gettid() + <-release + }() + } + seen := map[int]bool{unix.Gettid(): true} + for range 8 { + seen[<-tids] = true + } + if len(seen) != 9 { + return fmt.Errorf("%d distinct threads, want 9", len(seen)) + } + return nil + }}, + {"denies user namespaces, setns and clone3", func() error { + var errs []error + // Without the filter, unshare(CLONE_NEWUSER) fails with EINVAL in a multithreaded process, a zero-sized clone3 with EINVAL and setns on a pipe with EINVAL. + if err := unix.Unshare(unix.CLONE_NEWUSER); err != unix.EPERM { + errs = append(errs, fmt.Errorf("unshare(CLONE_NEWUSER): %v", err)) + } + child := exec.Command("/.oac/harness/harness") + child.Env = []string{helperEnv + "=noop"} + child.SysProcAttr = &syscall.SysProcAttr{Cloneflags: unix.CLONE_NEWUSER} + if err := child.Run(); !errors.Is(err, syscall.EPERM) { + errs = append(errs, fmt.Errorf("clone(CLONE_NEWUSER): %v", err)) + } + if _, _, err := unix.RawSyscall(unix.SYS_CLONE3, 0, 0, 0); err != unix.ENOSYS { + errs = append(errs, fmt.Errorf("clone3: %v", err)) + } + if err := unix.Setns(0, 0); err != unix.EPERM { + errs = append(errs, fmt.Errorf("setns: %v", err)) + } + return errors.Join(errs...) + }}, + {"/proc shows only the view", func() error { + entries, err := os.ReadDir("/proc") + if err != nil { + return err + } + var pids []int + for _, e := range entries { + if pid, err := strconv.Atoi(e.Name()); err == nil { + pids = append(pids, pid) + } + } + if !slices.Equal(pids, []int{1, os.Getpid()}) { + return fmt.Errorf("pids %v", pids) + } + return nil + }}, + {"host files are hidden", func() error { + if _, err := os.Stat(os.Getenv("OAC_VIEW_HOST_PATH")); !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("stat host path: %v", err) + } + return nil + }}, + {"only loopback", func() error { + ifs, err := net.Interfaces() + if err != nil { + return err + } + if len(ifs) != 1 || ifs[0].Name != "lo" || ifs[0].Flags&net.FlagUp == 0 { + return fmt.Errorf("interfaces %v", ifs) + } + return nil + }}, + {"reaches the broker on loopback", func() error { + c, err := net.DialTimeout("tcp", brokerAddr, 5*time.Second) + if err != nil { + return err + } + defer c.Close() + line, err := bufio.NewReader(c).ReadString('\n') + if line != "oac-broker\n" { + return fmt.Errorf("broker said %q, %v", line, err) + } + return nil + }}, + {"no route out", func() error { + if _, err := net.DialTimeout("tcp", "192.0.2.1:80", 2*time.Second); !errors.Is(err, syscall.ENETUNREACH) { + return fmt.Errorf("dial: %v", err) + } + return nil + }}, +} + +// onlyStdio finds inherited fds. Package initialisers in this binary open fds of their own, but Go opens every fd close-on-exec, so an fd without FD_CLOEXEC crossed the exec. +func onlyStdio() error { + entries, err := os.ReadDir("/proc/self/fd") + if err != nil { + return err + } + var inherited []string + for _, e := range entries { + fd, _ := strconv.Atoi(e.Name()) + if fd <= 2 { + continue + } + if flags, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); err == nil && flags&unix.FD_CLOEXEC == 0 { + link, _ := os.Readlink("/proc/self/fd/" + e.Name()) + inherited = append(inherited, e.Name()+"="+link) + } + } + if len(inherited) > 0 { + return fmt.Errorf("inherited fds %v", inherited) + } + return nil +} + +func noPrivileges() error { + status, err := os.ReadFile("/proc/self/status") + if err != nil { + return err + } + want := map[string]string{"CapInh": "0000000000000000", "CapPrm": "0000000000000000", "CapEff": "0000000000000000", "CapBnd": "0000000000000000", "CapAmb": "0000000000000000", "NoNewPrivs": "1"} + for _, line := range strings.Split(string(status), "\n") { + k, v, _ := strings.Cut(line, ":") + if w, ok := want[k]; ok { + if strings.TrimSpace(v) != w { + return fmt.Errorf("%s: %s", k, strings.TrimSpace(v)) + } + delete(want, k) + } + } + if len(want) > 0 { + return fmt.Errorf("status lacks %v", want) + } + return nil +} + +// moveAndLink renames a file into a sibling directory and hard-links it back. +func moveAndLink(dir string) error { + a, b := filepath.Join(dir, "mv-a"), filepath.Join(dir, "mv-b") + for _, d := range []string{a, b} { + if err := os.Mkdir(d, 0o755); err != nil { + return err + } + } + if err := os.WriteFile(filepath.Join(a, "f"), []byte("moved"), 0o644); err != nil { + return err + } + if err := os.Rename(filepath.Join(a, "f"), filepath.Join(b, "f")); err != nil { + return err + } + if err := os.Link(filepath.Join(b, "f"), filepath.Join(a, "link")); err != nil { + return err + } + return fileHas(filepath.Join(a, "link"), "moved") +} + +func execDenied(path string) error { + if err := exec.Command(path).Run(); !errors.Is(err, syscall.EACCES) { + return fmt.Errorf("exec %s: %v, want EACCES", path, err) + } + return nil +} + +func shimRuns(path string, args ...string) error { + out, err := exec.Command(path, args...).Output() + if err != nil || strings.TrimSpace(string(out)) != shimMarker { + return fmt.Errorf("%s printed %q, %v", path, out, err) + } + return nil +} + +func fileHas(path, want string) error { + got, err := os.ReadFile(path) + if err != nil { + return err + } + if string(got) != want { + return fmt.Errorf("%s holds %q", path, got) + } + return nil +} + +func mkdir(t *testing.T, dir string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func copyFile(t *testing.T, src, dst string) { + t.Helper() + data, err := os.ReadFile(src) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, data, 0o755); err != nil { + t.Fatal(err) + } +} diff --git a/apps/daemon/internal/sessionview/view_other.go b/apps/daemon/internal/sessionview/view_other.go new file mode 100644 index 000000000..f50c0befa --- /dev/null +++ b/apps/daemon/internal/sessionview/view_other.go @@ -0,0 +1,28 @@ +//go:build !linux + +package sessionview + +import ( + "context" + "os" + "syscall" +) + +// Init does nothing outside Linux. +func Init() {} + +// Probe reports that views need Linux. +func Probe() error { return ErrUnsupported } + +// Start reports that views need Linux. +func Start(context.Context, Spec) (*View, error) { return nil, ErrUnsupported } + +// View is a running view. Outside Linux none exists. +type View struct{} + +func (*View) Wait() (Exit, error) { return Exit{}, ErrUnsupported } +func (*View) Signal(syscall.Signal) error { return ErrUnsupported } +func (*View) Close() error { return nil } +func (*View) Stdin() *os.File { return nil } +func (*View) Stdout() *os.File { return nil } +func (*View) Stderr() *os.File { return nil } diff --git a/go.mod b/go.mod index 9146030d1..7c7e8841a 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/go-chi/chi/v5 v5.3.2 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/hanwen/go-fuse/v2 v2.11.0 github.com/jackc/pgx/v5 v5.10.0 github.com/moby/moby/api v1.56.0 github.com/moby/moby/client v0.6.0 diff --git a/go.sum b/go.sum index 07c9f1f70..e5de9b15d 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hanwen/go-fuse/v2 v2.11.0 h1:CGVkJh9gRz0pTRMADNcqdFl3ec/5QbE/Vx1Gl7ESozM= +github.com/hanwen/go-fuse/v2 v2.11.0/go.mod h1:aU7NkGYZUmuJrZapoI3mEcNve7PZTySUOLBuch/vR6U= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -54,6 +56,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= @@ -64,6 +68,8 @@ github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ= github.com/moby/moby/api v1.56.0/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk= github.com/moby/moby/client v0.6.0 h1:AJjEB21QPbXSXjDsZorFBoDZPhMrfbpaPLgSMAW9Bgs= github.com/moby/moby/client v0.6.0/go.mod h1:OCo00wNRyA3m4lmJ228W3JbyCN4ZNNYjpOXiJydBdcQ= +github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=