Werkdock skeleton: doctor, load, run over the bwrap engine (Go)
The minimal build-capable CLI decided in RFC 0002's outcome: stdlib-only Go module, one static binary. - engine: RunSpec behind the Engine interface (RFC 0001); the Bwrap engine ports Werkator's hardened invocation — uid-0 mapping, read-only rootfs at /, proc/dev/tmp/root before the user binds so binds below them land inside, mountpoint pre-creation in the rootfs including file mountpoints, and a guard against binds escaping the rootfs. --clearenv gives docker-style clean environments (HOME/PATH set explicitly). - store: images under $WERKDOCK_HOME (default ~/.werkdock), load unpacks via the tar CLI into a tmp dir and renames atomically. - cli: docker-shaped run flags (-v/-e/-w/--rm); refused docker flags (-p, --network, --memory, --cpus, --user, -d) fail loudly with the reason; exit codes follow docker (125 CLI errors, child code through). - doctor: port of werkator-build-prerequisites.sh — userns probe with the three signals, tar/zstd, free space and group-quota headroom via testable df/quota parsers, same PASS/FAIL output. - tests: argv golden test, mountpoint and escape tests, flag refusals, store round trip, doctor parsers — plus real-sandbox integration tests that skip where bwrap or userns are unavailable. Also records in step 21: RFC 0002 levels 2/3 deferred; next goal is sandbox builds of Werkator, Werkbaum, and Werkdock itself. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
de79210a7b
commit
e4bfeacf5a
@@ -0,0 +1,199 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Bwrap runs a RunSpec through the bwrap CLI — filesystem isolation
|
||||
// only; network, uid mapping target, /proc, /dev, and /tmp come from
|
||||
// the host by contract.
|
||||
//
|
||||
// The invocation is a port of Werkator's BwrapBuildRunner, including
|
||||
// the parts hardened on a real Hostsharing webspace: bind mountpoints
|
||||
// are pre-created inside the rootfs (a plain host directory), because
|
||||
// bwrap cannot mkdir them against the read-only root bind.
|
||||
type Bwrap struct {
|
||||
// Path of the bwrap binary; empty means "bwrap" via PATH.
|
||||
Path string
|
||||
// Stdio of the sandboxed command; nil fields default to the
|
||||
// werkdock process's own.
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
Stdin io.Reader
|
||||
}
|
||||
|
||||
// DefaultPATH is the PATH inside the sandbox; the environment is
|
||||
// cleared (docker semantics), so a sane default must be set explicitly.
|
||||
const DefaultPATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
// Argv assembles the full bwrap command line for spec.
|
||||
//
|
||||
// Mount order: the rootfs first; then /proc, /dev, and the tmpfs
|
||||
// mounts for /tmp and /root, BEFORE the user binds, so a bind whose
|
||||
// destination lies below them lands inside instead of being shadowed;
|
||||
// then the user binds in the given order.
|
||||
func (b *Bwrap) Argv(spec RunSpec) ([]string, error) {
|
||||
if spec.RootFS == "" {
|
||||
return nil, errors.New("rootfs must be set")
|
||||
}
|
||||
if !filepath.IsAbs(spec.RootFS) {
|
||||
return nil, fmt.Errorf("rootfs must be an absolute path: %s", spec.RootFS)
|
||||
}
|
||||
if len(spec.Command) == 0 {
|
||||
return nil, errors.New("no command specified")
|
||||
}
|
||||
bin := b.Path
|
||||
if bin == "" {
|
||||
bin = "bwrap"
|
||||
}
|
||||
args := []string{
|
||||
bin,
|
||||
"--unshare-user",
|
||||
"--unshare-pid",
|
||||
"--die-with-parent",
|
||||
"--uid", "0",
|
||||
"--gid", "0",
|
||||
"--ro-bind", spec.RootFS, "/",
|
||||
"--proc", "/proc",
|
||||
"--dev", "/dev",
|
||||
"--tmpfs", "/tmp",
|
||||
"--tmpfs", "/root",
|
||||
}
|
||||
for _, bd := range spec.Binds {
|
||||
if !filepath.IsAbs(bd.Dest) {
|
||||
return nil, fmt.Errorf("bind destination must be an absolute path: %s", bd.Dest)
|
||||
}
|
||||
flag := "--bind"
|
||||
if bd.ReadOnly {
|
||||
flag = "--ro-bind"
|
||||
}
|
||||
args = append(args, flag, bd.Source, bd.Dest)
|
||||
}
|
||||
args = append(args,
|
||||
"--clearenv",
|
||||
"--setenv", "HOME", "/root",
|
||||
"--setenv", "PATH", DefaultPATH,
|
||||
)
|
||||
for _, e := range spec.Env {
|
||||
args = append(args, "--setenv", e.Key, e.Value)
|
||||
}
|
||||
workdir := spec.Workdir
|
||||
if workdir == "" {
|
||||
workdir = "/"
|
||||
}
|
||||
args = append(args, "--chdir", workdir, "--")
|
||||
args = append(args, spec.Command...)
|
||||
return args, nil
|
||||
}
|
||||
|
||||
// EnsureMountpoints pre-creates the mountpoints of spec inside the
|
||||
// rootfs directory. bwrap creates mountpoints against the sandbox view,
|
||||
// which is the read-only rootfs bind — every destination missing from
|
||||
// the rootfs fails with "Read-only file system". The rootfs directory
|
||||
// itself is a plain host directory, so the mountpoints are created
|
||||
// there; bwrap then finds them and has nothing left to mkdir.
|
||||
//
|
||||
// Anything that already exists in the rootfs is left alone (e.g.
|
||||
// /etc/resolv.conf is a file many rootfs archives ship). A bind whose
|
||||
// source is a regular file gets a file mountpoint, not a directory.
|
||||
func EnsureMountpoints(spec RunSpec) error {
|
||||
for _, dest := range []string{"/proc", "/dev", "/tmp", "/root"} {
|
||||
if err := ensureDir(spec.RootFS, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, bd := range spec.Binds {
|
||||
target, err := rootfsPath(spec.RootFS, bd.Dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Lstat(target); err == nil {
|
||||
continue
|
||||
}
|
||||
src, err := os.Stat(bd.Source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bind source %s: %w", bd.Source, err)
|
||||
}
|
||||
if src.Mode().IsRegular() {
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureDir(rootfs, dest string) error {
|
||||
target, err := rootfsPath(rootfs, dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, statErr := os.Lstat(target); statErr == nil {
|
||||
return nil
|
||||
}
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
|
||||
// rootfsPath resolves dest inside rootfs and refuses destinations that
|
||||
// escape it — werkdock assembles mounts from user input, so this must
|
||||
// hold even for hostile paths.
|
||||
func rootfsPath(rootfs, dest string) (string, error) {
|
||||
root := filepath.Clean(rootfs)
|
||||
target := filepath.Join(root, dest)
|
||||
prefix := root
|
||||
if !strings.HasSuffix(prefix, string(filepath.Separator)) {
|
||||
prefix += string(filepath.Separator)
|
||||
}
|
||||
if target != root && !strings.HasPrefix(target, prefix) {
|
||||
return "", fmt.Errorf("bind destination escapes the rootfs: %s", dest)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// Run executes spec and returns the command's exit code; bwrap
|
||||
// propagates the child's code, so the caller can pass it through.
|
||||
func (b *Bwrap) Run(spec RunSpec) (int, error) {
|
||||
argv, err := b.Argv(spec)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := EnsureMountpoints(spec); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
cmd.Stdout = b.Stdout
|
||||
if cmd.Stdout == nil {
|
||||
cmd.Stdout = os.Stdout
|
||||
}
|
||||
cmd.Stderr = b.Stderr
|
||||
if cmd.Stderr == nil {
|
||||
cmd.Stderr = os.Stderr
|
||||
}
|
||||
cmd.Stdin = b.Stdin
|
||||
err = cmd.Run()
|
||||
if err == nil {
|
||||
return 0, nil
|
||||
}
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
return exit.ExitCode(), nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArgvAssemblesTheHardenedInvocation(t *testing.T) {
|
||||
b := &Bwrap{}
|
||||
spec := RunSpec{
|
||||
RootFS: "/store/images/buildenv/rootfs",
|
||||
Binds: []Bind{
|
||||
{Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf", ReadOnly: true},
|
||||
{Source: "/repo", Dest: "/repo"},
|
||||
{Source: "/cache", Dest: "/root/.gradle"},
|
||||
},
|
||||
Env: []EnvVar{{Key: "CI", Value: "true"}, {Key: "TERM", Value: "dumb"}},
|
||||
Workdir: "/repo",
|
||||
Command: []string{"/bin/sh", "-c", "./gradlew build"},
|
||||
}
|
||||
argv, err := b.Argv(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{
|
||||
"bwrap",
|
||||
"--unshare-user", "--unshare-pid", "--die-with-parent",
|
||||
"--uid", "0", "--gid", "0",
|
||||
"--ro-bind", "/store/images/buildenv/rootfs", "/",
|
||||
"--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--tmpfs", "/root",
|
||||
"--ro-bind", "/etc/resolv.conf", "/etc/resolv.conf",
|
||||
"--bind", "/repo", "/repo",
|
||||
"--bind", "/cache", "/root/.gradle",
|
||||
"--clearenv",
|
||||
"--setenv", "HOME", "/root",
|
||||
"--setenv", "PATH", DefaultPATH,
|
||||
"--setenv", "CI", "true",
|
||||
"--setenv", "TERM", "dumb",
|
||||
"--chdir", "/repo", "--",
|
||||
"/bin/sh", "-c", "./gradlew build",
|
||||
}
|
||||
if !reflect.DeepEqual(argv, want) {
|
||||
t.Errorf("argv mismatch:\n got %q\nwant %q", argv, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgvValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
spec RunSpec
|
||||
wantErr string
|
||||
}{
|
||||
{"missing rootfs", RunSpec{Command: []string{"true"}}, "rootfs must be set"},
|
||||
{"relative rootfs", RunSpec{RootFS: "rootfs", Command: []string{"true"}}, "absolute"},
|
||||
{"missing command", RunSpec{RootFS: "/r"}, "no command specified"},
|
||||
{
|
||||
"relative bind dest",
|
||||
RunSpec{RootFS: "/r", Binds: []Bind{{Source: "/s", Dest: "work"}}, Command: []string{"true"}},
|
||||
"absolute",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := (&Bwrap{}).Argv(tt.spec)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("got error %v, want it to contain %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgvDefaultsWorkdirToRoot(t *testing.T) {
|
||||
argv, err := (&Bwrap{}).Argv(RunSpec{RootFS: "/r", Command: []string{"true"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := strings.Join(argv, " ")
|
||||
if !strings.Contains(joined, "--chdir / --") {
|
||||
t.Errorf("expected default workdir /, got: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMountpointsCreatesMissingAndSkipsExisting(t *testing.T) {
|
||||
rootfs := t.TempDir()
|
||||
// The rootfs ships /etc/resolv.conf as a file with content — it
|
||||
// must be left alone.
|
||||
if err := os.MkdirAll(filepath.Join(rootfs, "etc"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shipped := filepath.Join(rootfs, "etc", "resolv.conf")
|
||||
if err := os.WriteFile(shipped, []byte("nameserver 127.0.0.53\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srcDir := t.TempDir()
|
||||
srcFile := filepath.Join(srcDir, "hosts")
|
||||
if err := os.WriteFile(srcFile, []byte("127.0.0.1 localhost\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec := RunSpec{
|
||||
RootFS: rootfs,
|
||||
Binds: []Bind{
|
||||
{Source: "/etc", Dest: "/etc/resolv.conf", ReadOnly: true}, // exists: skipped (source type irrelevant)
|
||||
{Source: srcDir, Dest: "/repo/workspace"}, // missing dir mountpoint
|
||||
{Source: srcFile, Dest: "/etc/hosts.werkdock"}, // missing file mountpoint
|
||||
},
|
||||
Command: []string{"true"},
|
||||
}
|
||||
if err := EnsureMountpoints(spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, dir := range []string{"proc", "dev", "tmp", "root", "repo/workspace"} {
|
||||
fi, err := os.Stat(filepath.Join(rootfs, dir))
|
||||
if err != nil || !fi.IsDir() {
|
||||
t.Errorf("expected directory mountpoint %s in the rootfs: %v", dir, err)
|
||||
}
|
||||
}
|
||||
fi, err := os.Stat(filepath.Join(rootfs, "etc", "hosts.werkdock"))
|
||||
if err != nil || !fi.Mode().IsRegular() {
|
||||
t.Errorf("expected file mountpoint etc/hosts.werkdock in the rootfs: %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(shipped)
|
||||
if err != nil || string(content) != "nameserver 127.0.0.53\n" {
|
||||
t.Errorf("shipped rootfs file was modified: %q, %v", content, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureMountpointsRefusesEscapingDestinations(t *testing.T) {
|
||||
spec := RunSpec{
|
||||
RootFS: t.TempDir(),
|
||||
Binds: []Bind{{Source: "/tmp", Dest: "/../outside"}},
|
||||
Command: []string{"true"},
|
||||
}
|
||||
err := EnsureMountpoints(spec)
|
||||
if err == nil || !strings.Contains(err.Error(), "escapes the rootfs") {
|
||||
t.Errorf("got %v, want an escape refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunInsideRealSandbox is the gated integration test: it runs only
|
||||
// where bwrap and unprivileged user namespaces actually work. The host
|
||||
// / serves as the read-only rootfs, so nothing is unpacked and (all
|
||||
// mountpoints existing) nothing is written.
|
||||
func TestRunInsideRealSandbox(t *testing.T) {
|
||||
if _, err := exec.LookPath("bwrap"); err != nil {
|
||||
t.Skip("bwrap not installed")
|
||||
}
|
||||
if err := exec.Command("bwrap", "--unshare-user", "--uid", "0", "--ro-bind", "/", "/", "true").Run(); err != nil {
|
||||
t.Skipf("unprivileged user namespaces not usable here: %v", err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
b := &Bwrap{Stdout: &stdout, Stderr: &stderr}
|
||||
code, err := b.Run(RunSpec{
|
||||
RootFS: "/",
|
||||
Command: []string{"id", "-u"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("run failed: %v (stderr: %s)", err, stderr.String())
|
||||
}
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code %d, stderr: %s", code, stderr.String())
|
||||
}
|
||||
if got := strings.TrimSpace(stdout.String()); got != "0" {
|
||||
t.Errorf("expected uid 0 inside the sandbox, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPassesTheExitCodeThrough(t *testing.T) {
|
||||
if _, err := exec.LookPath("bwrap"); err != nil {
|
||||
t.Skip("bwrap not installed")
|
||||
}
|
||||
if err := exec.Command("bwrap", "--unshare-user", "--uid", "0", "--ro-bind", "/", "/", "true").Run(); err != nil {
|
||||
t.Skipf("unprivileged user namespaces not usable here: %v", err)
|
||||
}
|
||||
b := &Bwrap{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}}
|
||||
code, err := b.Run(RunSpec{RootFS: "/", Command: []string{"sh", "-c", "exit 42"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code != 42 {
|
||||
t.Errorf("expected exit code 42, got %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Package engine executes sandboxed commands. The CLI verbs are thin
|
||||
// frontends over this package, so a later daemon can expose the same
|
||||
// logic without duplicating it (RFC 0002).
|
||||
package engine
|
||||
|
||||
// Bind is one bind mount, applied in order; later mounts shadow earlier
|
||||
// ones at their own path, exactly as bwrap layers them.
|
||||
type Bind struct {
|
||||
Source string
|
||||
Dest string
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
// EnvVar is one environment variable; order is preserved.
|
||||
type EnvVar struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
// RunSpec describes one sandboxed command, independent of the engine
|
||||
// that executes it.
|
||||
type RunSpec struct {
|
||||
// RootFS is the absolute path to the unpacked image rootfs,
|
||||
// bound read-only at /.
|
||||
RootFS string
|
||||
Binds []Bind
|
||||
Env []EnvVar
|
||||
Workdir string
|
||||
Command []string
|
||||
}
|
||||
|
||||
// Engine runs a RunSpec and reports the command's exit code.
|
||||
// bwrap is the first engine; native namespaces may become a second
|
||||
// (RFC 0001).
|
||||
type Engine interface {
|
||||
Run(spec RunSpec) (int, error)
|
||||
}
|
||||
Reference in New Issue
Block a user