internal/sandbox: add package
Package sandbox is added to allow the worker to run go tools and
analyses in a sandbox.
This package exports the Run function, which can run any program
with arguments inside a sandbox.
The sandbox is established with gvisor's runsc program.
For more on gvisor, see https://gvisor.dev.
The testdata directory holds a minimal bundle for testing.
Because the test requires some setup, and must be run as root,
there is a Makefile that does all the work. Test this package
using `make`, not `go test`.
Change-Id: I797b711a087acc91932964c1b03e1352500a79e4
Reviewed-on: https://go-review.googlesource.com/c/pkgsite-metrics/+/464620
Reviewed-by: Jonathan Amsterdam <jba@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Julie Qiu <julieqiu@google.com>
Auto-Submit: Julie Qiu <julieqiu@google.com>
Run-TryBot: Julie Qiu <julieqiu@google.com>
diff --git a/internal/sandbox/Makefile b/internal/sandbox/Makefile
new file mode 100644
index 0000000..93e91fc
--- /dev/null
+++ b/internal/sandbox/Makefile
@@ -0,0 +1,41 @@
+# Copyright 2022 The Go Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file.
+
+# Makefile for the sandbox package.
+# `make` will build and install binaries needed for the test, run the test as root,
+# then clean up.
+
+default: test clean
+
+test: /usr/local/bin/runsc testbundle
+ sudo $(shell which go) test -v
+
+
+# Release version must match the one in cmd/worker/Dockerfile.
+RUNSC_URL := https://storage.googleapis.com/gvisor/releases/release/20221107.0/$(shell uname -m)
+
+# This is an edited version of the commands at https://gvisor.dev/docs/user_guide/install.
+/usr/local/bin/runsc:
+ wget $(RUNSC_URL)/runsc $(RUNSC_URL)/runsc.sha512
+ sha512sum -c runsc.sha512
+ rm -f *.sha512
+ chmod a+rx runsc
+ sudo mv runsc /usr/local/bin
+
+testbundle: testdata/bundle/rootfs/runner testdata/bundle/rootfs/printargs
+
+testdata/bundle/rootfs/runner: runner.go
+ go build -o $@ $<
+ chmod o+rx $@
+
+testdata/bundle/rootfs/printargs: testdata/printargs.go
+ go build -o $@ $<
+ chmod o+rx $@
+
+clean:
+ rm testdata/bundle/rootfs/runner
+ rm testdata/bundle/rootfs/printargs
+
+.PHONY: clean testbundle
+
diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go
new file mode 100644
index 0000000..d76b88f
--- /dev/null
+++ b/internal/sandbox/runner.go
@@ -0,0 +1,52 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build ignore
+
+// This program runs another program provided on standard input,
+// prints its standard output, then terminates. It logs to stderr.
+//
+// It first reads all of standard input, then splits it into words on
+// whitespace. It treats the first word as the program path and the rest as
+// arguments.
+package main
+
+import (
+ "bytes"
+ "errors"
+ "io"
+ "log"
+ "os"
+ "os/exec"
+ "strings"
+)
+
+func main() {
+ log.SetOutput(os.Stderr)
+ log.SetPrefix("runner: ")
+ log.Print("starting")
+ in, err := io.ReadAll(os.Stdin)
+ if err != nil {
+ log.Fatal(err)
+ }
+ log.Printf("read %q", in)
+ args := strings.Fields(string(in))
+ if len(args) == 0 {
+ log.Fatal("no args")
+ }
+ cmd := exec.Command(args[0], args[1:]...)
+ out, err := cmd.Output()
+ if err != nil {
+ s := err.Error()
+ var eerr *exec.ExitError
+ if errors.As(err, &eerr) {
+ s += ": " + string(bytes.TrimSpace(eerr.Stderr))
+ }
+ log.Fatalf("%s failed with %s", args[0], s)
+ }
+ if _, err := os.Stdout.Write(out); err != nil {
+ log.Fatal(err)
+ }
+ log.Print("succeeded")
+}
diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go
new file mode 100644
index 0000000..0625d1d
--- /dev/null
+++ b/internal/sandbox/sandbox.go
@@ -0,0 +1,81 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package sandbox runs programs in a secure environment.
+package sandbox
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "os/exec"
+ "strings"
+ "unicode"
+
+ "golang.org/x/pkgsite-metrics/internal/derrors"
+)
+
+// A Sandbox is a restricted execution environment.
+// A Sandbox instance refers to a directory containing an OCI
+// bundle (see https://github.com/opencontainers/runtime-spec/blob/main/bundle.md).
+type Sandbox struct {
+ bundleDir string
+ Runsc string // path to runsc program
+}
+
+// New returns a new Sandbox using the bundle in bundleDir.
+// The bundle must be configured to run the 'runner' program,
+// built from runner.go in this directory.
+// The Sandbox expects the runsc program to be on the path.
+// That can be overridden by setting the Runsc field.
+func New(bundleDir string) *Sandbox {
+ return &Sandbox{
+ bundleDir: bundleDir,
+ Runsc: "runsc",
+ }
+}
+
+// Run runs program with args in a sandbox.
+// The program argument is the absolute path to the program from
+// within the sandbox.
+// It is invoked directly, as with [exec.Command]; no shell
+// interpretation is performed.
+// Its working directory is the bundle filesystem root.
+// The program is passed the given arguments, which must not contain whitespace.
+//
+// If the program succeeds (exits with code 0), its standard output is returned.
+// If it fails, the first return value is empty and the error comes from [exec.Command.Output].
+func (s *Sandbox) Run(ctx context.Context, program string, args ...string) (stdout []byte, err error) {
+ defer derrors.Wrap(&err, "Run(%s, %q)", program, args)
+ for _, a := range args {
+ if strings.IndexFunc(a, unicode.IsSpace) >= 0 {
+ return nil, fmt.Errorf("arg %q contains whitespace", a)
+ }
+ }
+
+ // -ignore-cgroups is needed to avoid this error from runsc:
+ // cannot set up cgroup for root: configuring cgroup: write /sys/fs/cgroup/cgroup.subtree_control: device or resource busy
+ cmd := exec.CommandContext(ctx, s.Runsc, "-ignore-cgroups", "-network=none", "run", "sandbox")
+ cmd.Dir = s.bundleDir
+ stdinPipe, err := cmd.StdinPipe()
+ if err != nil {
+ return nil, err
+ }
+ stdin := program + " " + strings.Join(args, " ")
+ c := make(chan error, 1)
+ go func() {
+ _, err := io.WriteString(stdinPipe, stdin)
+ stdinPipe.Close()
+ c <- err
+ }()
+ out, err := cmd.Output()
+ if err != nil {
+ return nil, err
+ }
+ if err := <-c; err != nil {
+ return nil, fmt.Errorf("writing stdin: %w", err)
+ }
+ return bytes.TrimSpace(out), nil
+}
diff --git a/internal/sandbox/sandbox_test.go b/internal/sandbox/sandbox_test.go
new file mode 100644
index 0000000..d88d652
--- /dev/null
+++ b/internal/sandbox/sandbox_test.go
@@ -0,0 +1,70 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package sandbox
+
+import (
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "strings"
+ "testing"
+
+ "golang.org/x/pkgsite-metrics/internal"
+)
+
+// These tests require a minimal bundle, in testdata/bundle.
+// The Makefile in this directory will build and install
+// the binaries needed for the test.
+
+func Test(t *testing.T) {
+ t.Skip()
+
+ if os.Getenv("USER") != "root" {
+ t.Skip("skipping; must run as root. Run 'make'.")
+ }
+ ctx := context.Background()
+ sb := New("testdata/bundle")
+ sb.Runsc = "/usr/local/bin/runsc" // must match path in Makefile
+
+ t.Run("printargs", func(t *testing.T) {
+ out, err := sb.Run(ctx, "/printargs", "a", "b")
+ if err != nil {
+ t.Fatal(internal.IncludeStderr(err))
+ }
+
+ want := `args:
+0: "a"
+1: "b"`
+ got := string(out)
+ if got != want {
+ t.Fatalf("got\n%q\nwant\n%q", got, want)
+ }
+ })
+
+ t.Run("space in arg", func(t *testing.T) {
+ _, err := sb.Run(ctx, "foo", "a b c")
+ if err == nil {
+ t.Fatal("got nil, want error")
+ }
+ if g, w := err.Error(), "contains whitespace"; !strings.Contains(g, w) {
+ t.Fatalf("got\n%q\nwhich does not contain %q", g, w)
+ }
+ })
+
+ t.Run("no program", func(t *testing.T) {
+ _, err := sb.Run(ctx, "foo")
+ var eerr *exec.ExitError
+ if !errors.As(err, &eerr) {
+ t.Fatalf("got %T, wanted *exec.ExitError", err)
+ }
+ if g, w := eerr.ExitCode(), 1; g != w {
+ t.Fatalf("got exit code %d, wanted %d", g, w)
+ }
+ if g, w := string(eerr.Stderr), "executable file not found"; !strings.Contains(g, w) {
+ t.Fatalf("got\n%q\nwhich does not contain %q", g, w)
+ }
+ })
+}
diff --git a/internal/sandbox/testdata/bundle/config.json b/internal/sandbox/testdata/bundle/config.json
new file mode 100644
index 0000000..1bd2beb
--- /dev/null
+++ b/internal/sandbox/testdata/bundle/config.json
@@ -0,0 +1,99 @@
+{
+ "ociVersion": "1.0.0",
+ "process": {
+ "user": {
+ "uid": 0,
+ "gid": 0
+ },
+ "args": [
+ "/runner"
+ ],
+ "env": [
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+ "TERM=xterm"
+ ],
+ "cwd": "/",
+ "capabilities": {
+ "bounding": [
+ "CAP_AUDIT_WRITE",
+ "CAP_KILL",
+ "CAP_NET_BIND_SERVICE"
+ ],
+ "effective": [
+ "CAP_AUDIT_WRITE",
+ "CAP_KILL",
+ "CAP_NET_BIND_SERVICE"
+ ],
+ "inheritable": [
+ "CAP_AUDIT_WRITE",
+ "CAP_KILL",
+ "CAP_NET_BIND_SERVICE"
+ ],
+ "permitted": [
+ "CAP_AUDIT_WRITE",
+ "CAP_KILL",
+ "CAP_NET_BIND_SERVICE"
+ ],
+ "ambient": [
+ "CAP_AUDIT_WRITE",
+ "CAP_KILL",
+ "CAP_NET_BIND_SERVICE"
+ ]
+ },
+ "rlimits": [
+ {
+ "type": "RLIMIT_NOFILE",
+ "hard": 1024,
+ "soft": 1024
+ }
+ ]
+ },
+ "root": {
+ "path": "rootfs",
+ "readonly": false
+ },
+ "hostname": "runsc",
+ "mounts": [
+ {
+ "destination": "/proc",
+ "type": "proc",
+ "source": "proc"
+ },
+ {
+ "destination": "/dev",
+ "type": "tmpfs",
+ "source": "tmpfs",
+ "options": []
+ },
+ {
+ "destination": "/sys",
+ "type": "sysfs",
+ "source": "sysfs",
+ "options": [
+ "nosuid",
+ "noexec",
+ "nodev",
+ "ro"
+ ]
+ }
+ ],
+ "linux": {
+ "namespaces": [
+ {
+ "type": "pid"
+ },
+ {
+ "type": "network"
+ },
+ {
+ "type": "ipc"
+ },
+ {
+ "type": "uts"
+ },
+ {
+ "type": "mount"
+ }
+ ]
+ }
+}
diff --git a/internal/sandbox/testdata/bundle/rootfs/.dockerenv b/internal/sandbox/testdata/bundle/rootfs/.dockerenv
new file mode 100755
index 0000000..e69de29
--- /dev/null
+++ b/internal/sandbox/testdata/bundle/rootfs/.dockerenv
diff --git a/internal/sandbox/testdata/bundle/rootfs/etc/hostname b/internal/sandbox/testdata/bundle/rootfs/etc/hostname
new file mode 100755
index 0000000..e69de29
--- /dev/null
+++ b/internal/sandbox/testdata/bundle/rootfs/etc/hostname
diff --git a/internal/sandbox/testdata/bundle/rootfs/etc/hosts b/internal/sandbox/testdata/bundle/rootfs/etc/hosts
new file mode 100755
index 0000000..e69de29
--- /dev/null
+++ b/internal/sandbox/testdata/bundle/rootfs/etc/hosts
diff --git a/internal/sandbox/testdata/bundle/rootfs/etc/mtab b/internal/sandbox/testdata/bundle/rootfs/etc/mtab
new file mode 120000
index 0000000..4c0a094
--- /dev/null
+++ b/internal/sandbox/testdata/bundle/rootfs/etc/mtab
@@ -0,0 +1 @@
+/proc/mounts
\ No newline at end of file
diff --git a/internal/sandbox/testdata/bundle/rootfs/etc/resolv.conf b/internal/sandbox/testdata/bundle/rootfs/etc/resolv.conf
new file mode 100755
index 0000000..e69de29
--- /dev/null
+++ b/internal/sandbox/testdata/bundle/rootfs/etc/resolv.conf
diff --git a/internal/sandbox/testdata/printargs.go b/internal/sandbox/testdata/printargs.go
new file mode 100644
index 0000000..2a03b5e
--- /dev/null
+++ b/internal/sandbox/testdata/printargs.go
@@ -0,0 +1,22 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build ignore
+
+// This program prints its arguments and exits.
+// It is used for testing the sandbox package.
+
+package main
+
+import (
+ "fmt"
+ "os"
+)
+
+func main() {
+ fmt.Printf("args:\n")
+ for i, arg := range os.Args[1:] {
+ fmt.Printf("%d: %q\n", i, arg)
+ }
+}
diff --git a/internal/util.go b/internal/util.go
new file mode 100644
index 0000000..15395a6
--- /dev/null
+++ b/internal/util.go
@@ -0,0 +1,22 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package internal
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "os/exec"
+)
+
+// IncludeStderr includes the stderr with an *exec.ExitError.
+// If err is not an *exec.ExitError, it returns err.Error().
+func IncludeStderr(err error) string {
+ var eerr *exec.ExitError
+ if errors.As(err, &eerr) {
+ return fmt.Sprintf("%v: %s", eerr, bytes.TrimSpace(eerr.Stderr))
+ }
+ return err.Error()
+}