diff --git a/cmd/fence/linux_bootstrap.go b/cmd/fence/linux_bootstrap.go new file mode 100644 index 0000000..30460d2 --- /dev/null +++ b/cmd/fence/linux_bootstrap.go @@ -0,0 +1,650 @@ +//go:build linux + +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "os" + "os/exec" + "strings" + "syscall" + "time" + + "github.com/Use-Tusk/fence/internal/config" + "github.com/Use-Tusk/fence/internal/sandbox" + "github.com/spf13/pflag" +) + +const ( + ExitWrapperSetupFailed = 125 // Socket, landlock, or other setup failures + ExitCommandNotFound = 127 // User command not in PATH +) + +type bootstrapOptions struct { + httpSocket string + socksSocket string + reverseBridges []reverseBridgeSpec + command []string + debug bool +} + +// exitError represents an error with an associated exit code +type exitError struct { + code int + err error +} + +func (e *exitError) Error() string { + return e.err.Error() +} + +func (e *exitError) ExitCode() int { + return e.code +} + +// fatalError creates an exitError and returns it +func fatalError(code int, format string, args ...any) error { + msg := fmt.Sprintf(format, args...) + fmt.Fprintf(os.Stderr, "[fence:linux-bootstrap] Error: %s\n", msg) + return &exitError{ + code: code, + err: fmt.Errorf("%s", msg), + } +} + +// fatal calls os.Exit with the appropriate code (kept for backward compatibility) +func fatal(exitCode int, format string, args ...any) { + fmt.Fprintf(os.Stderr, "[fence:linux-bootstrap] Error: "+format+"\n", args...) + os.Exit(exitCode) +} + +func parseFlagsAndArgs() (bootstrapOptions, error) { + flags := pflag.NewFlagSet("linux-bootstrap", pflag.ContinueOnError) + httpSocket := flags.String("http-socket", "", "") + socksSocket := flags.String("socks-socket", "", "") + reverseBridgeSpecs := flags.StringArray("reverse-bridge", nil, "") + debugMode := flags.Bool("debug", false, "") + + if err := flags.Parse(os.Args[2:]); err != nil { + return bootstrapOptions{}, fatalError(ExitWrapperSetupFailed, "%v", err) + } + + var reverseBridges []reverseBridgeSpec + for _, s := range *reverseBridgeSpecs { + spec, err := parseReverseBridge(s) + if err != nil { + return bootstrapOptions{}, fatalError(ExitWrapperSetupFailed, "%v", err) + } + reverseBridges = append(reverseBridges, spec) + } + + command := flags.Args() + if len(command) == 0 { + return bootstrapOptions{}, fatalError(ExitWrapperSetupFailed, "no command specified") + } + + return bootstrapOptions{ + httpSocket: *httpSocket, + socksSocket: *socksSocket, + reverseBridges: reverseBridges, + command: command, + debug: *debugMode, + }, nil +} + +type envGroup struct { + keys []string + value string +} + +func setEnvVars(g envGroup) error { + for _, key := range g.keys { + if err := os.Setenv(key, g.value); err != nil { + return fmt.Errorf("failed to set %s: %w", key, err) + } + } + return nil +} + +func startTCPBridge(ctx context.Context, port int, socketPath, label string) error { + startErrCh := make(chan struct { + port int + err error + }, 1) + go func() { + if _, err := bridgeTCPToUnix(ctx, port, socketPath, startErrCh); err != nil && err != context.Canceled { + fmt.Fprintf(os.Stderr, "[fence:linux-bootstrap] %s bridge error: %v\n", label, err) + } + }() + result := <-startErrCh + return result.err +} + +func startBridgesAndSetEnv(ctx context.Context, opts bootstrapOptions) ([]string, error) { + var socketPaths []string + + if opts.httpSocket != "" { + socketPaths = append(socketPaths, opts.httpSocket) + if err := startTCPBridge(ctx, 3128, opts.httpSocket, "HTTP"); err != nil { + return nil, fatalError(ExitWrapperSetupFailed, "failed to start HTTP bridge: %v", err) + } + if err := setEnvVars(envGroup{ + keys: []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"}, + value: "http://127.0.0.1:3128", + }); err != nil { + return nil, fatalError(ExitWrapperSetupFailed, "failed to set proxy env vars: %v", err) + } + } + + if opts.socksSocket != "" { + socketPaths = append(socketPaths, opts.socksSocket) + if err := startTCPBridge(ctx, 1080, opts.socksSocket, "SOCKS"); err != nil { + return nil, fatalError(ExitWrapperSetupFailed, "failed to start SOCKS bridge: %v", err) + } + if err := setEnvVars(envGroup{ + keys: []string{"ALL_PROXY", "all_proxy"}, + value: "socks5h://127.0.0.1:1080", + }); err != nil { + return nil, fatalError(ExitWrapperSetupFailed, "failed to set proxy env vars: %v", err) + } + } + + if opts.httpSocket != "" || opts.socksSocket != "" { + if err := setEnvVars(envGroup{ + keys: []string{"NO_PROXY", "no_proxy"}, + value: "localhost,127.0.0.1", + }); err != nil { + return nil, fatalError(ExitWrapperSetupFailed, "failed to set no_proxy env vars: %v", err) + } + } + + for _, rb := range opts.reverseBridges { + socketPaths = append(socketPaths, rb.socketPath) + go func(port int, socketPath string) { + if _, err := bridgeUnixToTCP(ctx, socketPath, port); err != nil && err != context.Canceled { + fmt.Fprintf(os.Stderr, "[fence:linux-bootstrap] Reverse bridge error: %v\n", err) + } + }(rb.port, rb.socketPath) + } + + return socketPaths, nil +} + +func applyLandlock(opts bootstrapOptions, socketPaths []string) error { + cfg, err := loadConfigFromEnv() + if err != nil { + return fatalError(ExitWrapperSetupFailed, "%v", err) + } + + // Get current working directory for relative path resolution + cwd, err := os.Getwd() + if err != nil { + return fatalError(ExitWrapperSetupFailed, "failed to get working directory: %v", err) + } + + if opts.debug { + fmt.Fprintf(os.Stderr, "[fence:linux-bootstrap] Applying Landlock restrictions before command execution\n") + } + + // Collect execute paths - we need to allow execution of the shell + // The command[0] is the shell path (e.g., /nix/store/.../bash) + var executePaths []string + if len(opts.command) > 0 { + // Resolve the command path to get the actual executable + execPath, err := exec.LookPath(opts.command[0]) + if err == nil { + // Add the resolved shell binary path for execution + executePaths = append(executePaths, execPath) + if opts.debug { + fmt.Fprintf(os.Stderr, "[fence:linux-bootstrap] Adding execute path: %s\n", execPath) + } + } else if opts.debug { + fmt.Fprintf(os.Stderr, "[fence:linux-bootstrap] Warning: could not resolve command path: %v\n", err) + } + } + + // Apply Landlock restrictions + err = sandbox.ApplyLandlockFromConfigWithExec(cfg, cwd, socketPaths, executePaths, opts.debug) + if err != nil { + if opts.debug { + fmt.Fprintf(os.Stderr, "[fence:linux-bootstrap] Warning: Landlock not applied: %v\n", err) + } + } else if opts.debug { + fmt.Fprintf(os.Stderr, "[fence:linux-bootstrap] Landlock restrictions applied\n") + } + + return nil +} + +func execUserCommand(opts bootstrapOptions) error { + // Use cmd.Run() so that bridge goroutines remain alive + // while the command executes. Landlock restrictions applied above + // are automatically inherited by child processes. + execPath, err := exec.LookPath(opts.command[0]) + if err != nil { + return fatalError(ExitCommandNotFound, "command not found: %s", opts.command[0]) + } + + // Create the command + cmd := exec.Command(execPath, opts.command[1:]...) // #nosec G204 -- execPath is resolved via exec.LookPath + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + // Sanitize environment (strips LD_PRELOAD, etc.) + // FENCE_SANDBOX=1 is injected from outside the sandbox by bwrap via --setenv, + // so it is already present in os.Environ() here. + cmd.Env = sandbox.FilterDangerousEnv(os.Environ()) + + // Run the command; keeping this process alive preserves the bridge goroutines. + err = cmd.Run() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return &exitError{ + code: exitErr.ExitCode(), + err: exitErr, + } + } + // Check if the error is "command not found" + if cmdErr, ok := err.(*exec.Error); ok && cmdErr.Err == exec.ErrNotFound { + return fatalError(ExitCommandNotFound, "command not found: %s", opts.command[0]) + } + return fatalError(ExitWrapperSetupFailed, "run failed: %v", err) + } + + return nil +} + +// runLinuxBootstrapWrapper handles the --linux-bootstrap wrapper mode. +// This runs inside the sandbox and handles: +// 1. Socket bridging (TCP <-> Unix sockets for proxy support) +// 2. Waiting for sockets to be ready +// 3. Applying Landlock restrictions (if configured) +// 4. Running the user command +func runLinuxBootstrapWrapper() { + opts, err := parseFlagsAndArgs() + if err != nil { + handleErrorAndExit(err) + return + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + socketPaths, err := startBridgesAndSetEnv(ctx, opts) + if err != nil { + handleErrorAndExit(err) + return + } + + if len(socketPaths) > 0 { + if err := waitForUnixSockets(ctx, socketPaths, 5*time.Second); err != nil { + handleErrorAndExit(fatalError(ExitWrapperSetupFailed, "%v", err)) + return + } + } + + // Repair runtime environment (TMPDIR, XDG_RUNTIME_DIR) if needed. + // This mirrors the runtime env repair logic from linuxRuntimeEnvScript() + // which is used in the shell script bootstrap path. + // This must happen before applyLandlock since it creates directories. + runtimeCleanup := repairRuntimeEnv() + defer runtimeCleanup() + + if err := applyLandlock(opts, socketPaths); err != nil { + handleErrorAndExit(err) + return + } + + if err := execUserCommand(opts); err != nil { + handleErrorAndExit(err) + return + } +} + +// handleErrorAndExit extracts the exit code from an error and calls os.Exit +func handleErrorAndExit(err error) { + if exitErr, ok := err.(*exitError); ok { + os.Exit(exitErr.ExitCode()) + } + // Fallback for unexpected error types + os.Exit(ExitWrapperSetupFailed) +} + +// reverseBridgeSpec represents a reverse bridge specification (port:socketPath) +type reverseBridgeSpec struct { + port int + socketPath string +} + +// parseReverseBridge parses a reverse bridge spec like "3000:/tmp/fence-rev-3000.sock" +func parseReverseBridge(spec string) (reverseBridgeSpec, error) { + parts := strings.SplitN(spec, ":", 2) + if len(parts) != 2 { + return reverseBridgeSpec{}, fmt.Errorf("invalid reverse-bridge spec %q, expected PORT:PATH format", spec) + } + + var port int + if _, err := fmt.Sscanf(parts[0], "%d", &port); err != nil { + return reverseBridgeSpec{}, fmt.Errorf("invalid port in reverse-bridge spec %q: %v", spec, err) + } + + if port <= 0 || port > 65535 { + return reverseBridgeSpec{}, fmt.Errorf("port %d out of range in reverse-bridge spec", port) + } + + if parts[1] == "" { + return reverseBridgeSpec{}, fmt.Errorf("empty socket path in reverse-bridge spec %q", spec) + } + + return reverseBridgeSpec{ + port: port, + socketPath: parts[1], + }, nil +} + + + +// bridgeTCPToUnix bridges TCP connections on a port to a Unix socket. +// This is used for proxy support (HTTP/SOCKS proxies). +// startErrCh receives the actual port and nil once the listener is ready, +// or -1 and an error if setup fails; it is always sent to exactly once before the function returns. +func bridgeTCPToUnix(ctx context.Context, listenPort int, unixSocketPath string, startErrCh chan<- struct { + port int + err error +}) (int, error) { + lc := net.ListenConfig{ + Control: func(network, address string, c syscall.RawConn) error { + var setsockoptErr error + err := c.Control(func(fd uintptr) { + // Allow reuse of address to avoid "address already in use" errors + setsockoptErr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1) + }) + if err != nil { + return err + } + return setsockoptErr + }, + } + + listenAddr := fmt.Sprintf("127.0.0.1:%d", listenPort) + ln, err := lc.Listen(ctx, "tcp", listenAddr) + if err != nil { + startErrCh <- struct { + port int + err error + }{-1, fmt.Errorf("failed to listen on %s: %w", listenAddr, err)} + return -1, fmt.Errorf("failed to listen on %s: %w", listenAddr, err) + } + + // Get the actual port (important when listenPort is 0) + actualPort := ln.Addr().(*net.TCPAddr).Port + startErrCh <- struct { + port int + err error + }{actualPort, nil} + + // Close listener when context is cancelled + go func() { + <-ctx.Done() + _ = ln.Close() + }() + + for { + select { + case <-ctx.Done(): + return actualPort, ctx.Err() + default: + } + + tcpConn, err := ln.Accept() + if err != nil { + // Check if context was cancelled + select { + case <-ctx.Done(): + return actualPort, ctx.Err() + default: + return actualPort, fmt.Errorf("accept error: %w", err) + } + } + + go handleTCPToUnixConnection(tcpConn, unixSocketPath) + } +} + +// handleTCPToUnixConnection handles a single TCP to Unix socket connection +func handleTCPToUnixConnection(tcpConn net.Conn, unixPath string) { + defer func() { _ = tcpConn.Close() }() + + unixConn, err := net.Dial("unix", unixPath) + if err != nil { + return + } + defer func() { _ = unixConn.Close() }() + + // Bidirectional copy + done := make(chan struct{}, 2) + go func() { + _, _ = io.Copy(tcpConn, unixConn) + done <- struct{}{} + }() + go func() { + _, _ = io.Copy(unixConn, tcpConn) + done <- struct{}{} + }() + + // Wait for both directions to finish + <-done + <-done +} + +// bridgeUnixToTCP bridges a Unix socket to a TCP port (reverse bridge) +// This is used for exposing ports from inside the sandbox +func bridgeUnixToTCP(ctx context.Context, unixSocketPath string, targetPort int) (int, error) { + // Remove socket if it already exists + _ = os.Remove(unixSocketPath) + + // Create Unix socket listener + lc := net.ListenConfig{} + ln, err := lc.Listen(ctx, "unix", unixSocketPath) + if err != nil { + return -1, fmt.Errorf("failed to listen on unix socket %s: %w", unixSocketPath, err) + } + + // Close listener when context is cancelled + go func() { + <-ctx.Done() + _ = ln.Close() + _ = os.Remove(unixSocketPath) + }() + + for { + select { + case <-ctx.Done(): + return targetPort, ctx.Err() + default: + } + + unixConn, err := ln.Accept() + if err != nil { + // Check if context was cancelled + select { + case <-ctx.Done(): + return targetPort, ctx.Err() + default: + return targetPort, fmt.Errorf("accept error: %w", err) + } + } + + go handleUnixToTCPConnection(unixConn, targetPort) + } +} + +// handleUnixToTCPConnection handles a single Unix to TCP socket connection +func handleUnixToTCPConnection(unixConn net.Conn, targetPort int) { + defer func() { _ = unixConn.Close() }() + + tcpConn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", targetPort)) + if err != nil { + return + } + defer func() { _ = tcpConn.Close() }() + + // Bidirectional copy + done := make(chan struct{}, 2) + go func() { + _, _ = io.Copy(unixConn, tcpConn) + done <- struct{}{} + }() + go func() { + _, _ = io.Copy(tcpConn, unixConn) + done <- struct{}{} + }() + + // Wait for one direction to finish + <-done +} + +// waitForUnixSockets waits for all Unix sockets to be ready with a timeout +func waitForUnixSockets(ctx context.Context, socketPaths []string, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + for _, path := range socketPaths { + if err := waitForUnixSocket(ctx, path); err != nil { + return fmt.Errorf("socket %s not ready: %w", path, err) + } + } + + return nil +} + +// waitForUnixSocket waits for a single Unix socket to be ready +func waitForUnixSocket(ctx context.Context, socketPath string) error { + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + // Try to connect to the socket + conn, err := net.Dial("unix", socketPath) + if err == nil { + _ = conn.Close() + return nil + } + } + } +} + +// dirIsUsable checks if a directory exists and is writable. +func dirIsUsable(path string) bool { + if path == "" { + return false + } + + info, err := os.Stat(path) + if err != nil { + return false + } + + if !info.IsDir() { + return false + } + + // Try to create a test file to verify write permissions + testFile := path + "/.fence-write-test-" + fmt.Sprintf("%d", os.Getpid()) + f, err := os.OpenFile(testFile, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return false + } + f.Close() + os.Remove(testFile) + + return true +} + +// preparePrivateRuntimeDir creates a private runtime directory under /tmp. +func preparePrivateRuntimeDir() (dir string, cleanup func(), err error) { + uid := os.Getuid() + pattern := fmt.Sprintf("fence-runtime-%d-XXXXXX", uid) + + dir, err = os.MkdirTemp("/tmp", pattern) + if err != nil { + return "", nil, fmt.Errorf("failed to create temp dir: %w", err) + } + + // Set permissions to 0700 (private) + if err := os.Chmod(dir, 0700); err != nil { + os.RemoveAll(dir) + return "", nil, err + } + + // Verify the directory is usable + if !dirIsUsable(dir) { + os.RemoveAll(dir) + return "", nil, fmt.Errorf("created directory is not usable") + } + + cleanup = func() { + os.RemoveAll(dir) + } + + return dir, cleanup, nil +} + +// repairRuntimeEnv repairs TMPDIR and XDG_RUNTIME_DIR environment variables. +// Returns a cleanup function to remove any created runtime directory. +func repairRuntimeEnv() (cleanup func()) { + cleanup = func() {} // Default no-op cleanup + + // Repair TMPDIR if not usable + tmpdir := os.Getenv("TMPDIR") + if !dirIsUsable(tmpdir) { + os.Setenv("TMPDIR", "/tmp") + } + + // Repair XDG_RUNTIME_DIR if not usable + xdgRuntimeDir := os.Getenv("XDG_RUNTIME_DIR") + var createdRuntimeDir string + + if !dirIsUsable(xdgRuntimeDir) { + // Create a private runtime directory + dir, dirCleanup, err := preparePrivateRuntimeDir() + if err == nil { + os.Setenv("XDG_RUNTIME_DIR", dir) + createdRuntimeDir = dir + cleanup = dirCleanup + } else { + // If we can't create a runtime dir, unset it + os.Unsetenv("XDG_RUNTIME_DIR") + } + } + + // If we created a runtime dir, return a cleanup that removes it + if createdRuntimeDir != "" { + return cleanup + } + + return func() {} // No cleanup needed if we didn't create a directory +} + +// loadConfigFromEnv loads the config from FENCE_CONFIG_JSON environment variable, +// falling back to config.Default() if the variable is absent or invalid. +func loadConfigFromEnv() (*config.Config, error) { + configJSON := os.Getenv("FENCE_CONFIG_JSON") + if configJSON == "" { + return nil, fmt.Errorf("FENCE_CONFIG_JSON is not set") + } + cfg := &config.Config{} + if err := json.Unmarshal([]byte(configJSON), cfg); err != nil { + return nil, fmt.Errorf("failed to parse FENCE_CONFIG_JSON: %w", err) + } + return cfg, nil +} diff --git a/cmd/fence/linux_bootstrap_stub.go b/cmd/fence/linux_bootstrap_stub.go new file mode 100644 index 0000000..221fd40 --- /dev/null +++ b/cmd/fence/linux_bootstrap_stub.go @@ -0,0 +1,15 @@ +//go:build !linux + +package main + +import ( + "fmt" + "os" +) + +// runLinuxBootstrapWrapper is a stub for non-Linux platforms. +// The bootstrap wrapper is only needed inside Linux sandboxes. +func runLinuxBootstrapWrapper() { + fmt.Fprintln(os.Stderr, "[fence] --linux-bootstrap is only supported on Linux") + os.Exit(1) +} diff --git a/cmd/fence/linux_bootstrap_test.go b/cmd/fence/linux_bootstrap_test.go new file mode 100644 index 0000000..8b9fb7f --- /dev/null +++ b/cmd/fence/linux_bootstrap_test.go @@ -0,0 +1,1132 @@ +//go:build linux + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/Use-Tusk/fence/internal/config" +) + +func TestBridgeTCPToUnix(t *testing.T) { + // Create a Unix socket server that echoes data + tmpDir := t.TempDir() + socketPath := tmpDir + "/test.sock" + + // Start Unix socket server + serverReady := make(chan struct{}) + go func() { + ln, err := net.Listen("unix", socketPath) + if err != nil { + t.Errorf("failed to listen on unix socket: %v", err) + return + } + defer func() { _ = ln.Close() }() + + close(serverReady) + + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer func() { _ = c.Close() }() + buf := make([]byte, 1024) + n, err := c.Read(buf) + if err != nil { + return + } + _, _ = c.Write(buf[:n]) + }(conn) + } + }() + + // Wait for server to be ready + <-serverReady + time.Sleep(50 * time.Millisecond) + + // Start TCP to Unix bridge + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + startErrCh := make(chan struct { + port int + err error + }, 1) + go func() { + if _, err := bridgeTCPToUnix(ctx, 0, socketPath, startErrCh); err != nil && err != context.Canceled { + t.Logf("bridge error: %v", err) + } + }() + result := <-startErrCh + if result.err != nil { + t.Fatalf("bridge failed to start: %v", result.err) + } + port := result.port + + // Connect via TCP and send data + conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + if err != nil { + t.Fatalf("failed to connect to bridge: %v", err) + } + defer func() { _ = conn.Close() }() + + testData := "hello world" + _, err = conn.Write([]byte(testData)) + if err != nil { + t.Fatalf("failed to write to connection: %v", err) + } + + buf := make([]byte, 1024) + n, err := conn.Read(buf) + if err != nil { + t.Fatalf("failed to read from connection: %v", err) + } + + if string(buf[:n]) != testData { + t.Errorf("expected %q, got %q", testData, string(buf[:n])) + } +} + +func TestBridgeTCPToUnix_MultipleConnections(t *testing.T) { + // Create a Unix socket server + tmpDir := t.TempDir() + socketPath := tmpDir + "/test.sock" + + serverReady := make(chan struct{}) + go func() { + ln, err := net.Listen("unix", socketPath) + if err != nil { + t.Errorf("failed to listen on unix socket: %v", err) + return + } + defer func() { _ = ln.Close() }() + + close(serverReady) + + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer func() { _ = c.Close() }() + buf := make([]byte, 1024) + n, _ := c.Read(buf) + _, _ = c.Write(buf[:n]) + }(conn) + } + }() + + <-serverReady + time.Sleep(50 * time.Millisecond) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + startErrCh := make(chan struct { + port int + err error + }, 1) + go func() { + if _, err := bridgeTCPToUnix(ctx, 0, socketPath, startErrCh); err != nil && err != context.Canceled { + t.Logf("bridge error: %v", err) + } + }() + result := <-startErrCh + if result.err != nil { + t.Fatalf("bridge failed to start: %v", result.err) + } + port := result.port + + // Test multiple concurrent connections + done := make(chan bool, 3) + for i := 0; i < 3; i++ { + go func(id int) { + conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + if err != nil { + t.Errorf("connection %d failed: %v", id, err) + done <- false + return + } + defer func() { _ = conn.Close() }() + + msg := "test" + _, _ = conn.Write([]byte(msg)) + + buf := make([]byte, 1024) + n, _ := conn.Read(buf) + + if string(buf[:n]) != msg { + t.Errorf("connection %d: expected %q, got %q", id, msg, string(buf[:n])) + } + done <- true + }(i) + } + + // Wait for all connections + for i := 0; i < 3; i++ { + if !<-done { + t.Error("at least one connection failed") + } + } +} + +func TestBridgeTCPToUnix_ContextCancellation(t *testing.T) { + tmpDir := t.TempDir() + socketPath := tmpDir + "/test.sock" + + serverReady := make(chan struct{}) + go func() { + ln, err := net.Listen("unix", socketPath) + if err != nil { + t.Errorf("failed to listen: %v", err) + return + } + defer func() { _ = ln.Close() }() + close(serverReady) + + for { + conn, err := ln.Accept() + if err != nil { + return + } + _ = conn.Close() + } + }() + + <-serverReady + time.Sleep(50 * time.Millisecond) + + ctx, cancel := context.WithCancel(context.Background()) + + startErrCh := make(chan struct { + port int + err error + }, 1) + go func() { + if _, err := bridgeTCPToUnix(ctx, 0, socketPath, startErrCh); err != nil && err != context.Canceled { + t.Logf("bridge error: %v", err) + } + }() + result := <-startErrCh + if result.err != nil { + t.Fatalf("bridge failed to start: %v", result.err) + } + + // Cancel the context + cancel() + + // Verify bridge stops - just wait a bit since we can't easily capture the return value + time.Sleep(100 * time.Millisecond) +} + +func TestBridgeUnixToTCP(t *testing.T) { + // Create a TCP server with dynamic port + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + defer func() { _ = listener.Close() }() + + port := listener.Addr().(*net.TCPAddr).Port + + serverReady := make(chan struct{}) + go func() { + close(serverReady) + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer func() { _ = c.Close() }() + buf := make([]byte, 1024) + n, _ := c.Read(buf) + _, _ = c.Write(buf[:n]) + }(conn) + } + }() + + <-serverReady + + // Start Unix to TCP bridge + tmpDir := t.TempDir() + socketPath := tmpDir + "/reverse.sock" + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + if _, err := bridgeUnixToTCP(ctx, socketPath, port); err != nil && err != context.Canceled { + t.Logf("bridge error: %v", err) + } + }() + + // Wait for socket to appear + time.Sleep(100 * time.Millisecond) + + // Connect via Unix socket + conn, err := net.Dial("unix", socketPath) + if err != nil { + t.Fatalf("failed to connect to unix socket: %v", err) + } + defer func() { _ = conn.Close() }() + + testData := "reverse test" + _, err = conn.Write([]byte(testData)) + if err != nil { + t.Fatalf("failed to write: %v", err) + } + + buf := make([]byte, 1024) + n, err := conn.Read(buf) + if err != nil { + t.Fatalf("failed to read: %v", err) + } + + if string(buf[:n]) != testData { + t.Errorf("expected %q, got %q", testData, string(buf[:n])) + } +} + +func TestWaitForUnixSocket_Timeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + err := waitForUnixSocket(ctx, "/tmp/nonexistent-socket-xyz.sock") + if err == nil { + t.Error("expected timeout error for nonexistent socket") + } +} + +func TestWaitForUnixSocket_Success(t *testing.T) { + tmpDir := t.TempDir() + socketPath := tmpDir + "/wait.sock" + + // Start server after a delay + go func() { + time.Sleep(100 * time.Millisecond) + ln, err := net.Listen("unix", socketPath) + if err != nil { + t.Errorf("failed to listen: %v", err) + return + } + defer func() { _ = ln.Close() }() + + conn, err := ln.Accept() + if err != nil { + return + } + _ = conn.Close() + }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err := waitForUnixSocket(ctx, socketPath) + if err != nil { + t.Errorf("expected socket to become ready, got error: %v", err) + } +} + +func TestWaitForUnixSockets_AllReady(t *testing.T) { + tmpDir := t.TempDir() + + socketPaths := []string{ + tmpDir + "/sock1.sock", + tmpDir + "/sock2.sock", + } + + // Start servers for each socket + for _, path := range socketPaths { + go func(p string) { + time.Sleep(50 * time.Millisecond) + ln, err := net.Listen("unix", p) + if err != nil { + return + } + defer func() { _ = ln.Close() }() + + conn, err := ln.Accept() + if err != nil { + return + } + _ = conn.Close() + }(path) + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + err := waitForUnixSockets(ctx, socketPaths, 2*time.Second) + if err != nil { + t.Errorf("expected all sockets to become ready, got error: %v", err) + } +} + +func TestParseReverseBridge(t *testing.T) { + tests := []struct { + name string + input string + expectedPort int + expectedPath string + expectError bool + }{ + { + name: "valid spec", + input: "3000:/tmp/test.sock", + expectedPort: 3000, + expectedPath: "/tmp/test.sock", + expectError: false, + }, + { + name: "missing path", + input: "3000:", + expectError: true, + }, + { + name: "invalid port", + input: "abc:/tmp/test.sock", + expectError: true, + }, + { + name: "missing colon", + input: "3000", + expectError: true, + }, + { + name: "port out of range", + input: "70000:/tmp/test.sock", + expectError: true, + }, + { + name: "negative port", + input: "-1:/tmp/test.sock", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + spec, err := parseReverseBridge(tt.input) + + if tt.expectError { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if spec.port != tt.expectedPort { + t.Errorf("expected port %d, got %d", tt.expectedPort, spec.port) + } + + if spec.socketPath != tt.expectedPath { + t.Errorf("expected path %q, got %q", tt.expectedPath, spec.socketPath) + } + }) + } +} + +// TestHandleTCPToUnixConnection_WaitsForBothDirections tests that the bridge +// waits for both directions to complete before closing connections. +// This prevents data loss when one direction finishes before the other. +func TestHandleTCPToUnixConnection_WaitsForBothDirections(t *testing.T) { + tmpDir := t.TempDir() + socketPath := tmpDir + "/test.sock" + + // Create a Unix socket server that: + // 1. Reads a small request + // 2. Waits to ensure the request direction finishes first + // 3. Sends a large response + serverReady := make(chan struct{}) + responseSize := 100 * 1024 // 100KB response + go func() { + ln, err := net.Listen("unix", socketPath) + if err != nil { + t.Errorf("failed to listen on unix socket: %v", err) + return + } + defer func() { _ = ln.Close() }() + + close(serverReady) + + conn, err := ln.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + + // Read the small request + buf := make([]byte, 1024) + n, _ := conn.Read(buf) + t.Logf("Server received %d bytes: %q", n, string(buf[:n])) + + // Wait to ensure the request direction finishes first + // This simulates a slow server response + time.Sleep(200 * time.Millisecond) + + // Send a large response + largeResponse := make([]byte, responseSize) + for i := range largeResponse { + largeResponse[i] = 'X' + } + written, err := conn.Write(largeResponse) + if err != nil { + t.Logf("Server write error: %v", err) + } else { + t.Logf("Server wrote %d bytes", written) + } + }() + + <-serverReady + time.Sleep(50 * time.Millisecond) + + // Start TCP to Unix bridge + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + startErrCh := make(chan struct { + port int + err error + }, 1) + go func() { + if _, err := bridgeTCPToUnix(ctx, 0, socketPath, startErrCh); err != nil && err != context.Canceled { + t.Logf("bridge error: %v", err) + } + }() + result := <-startErrCh + if result.err != nil { + t.Fatalf("bridge failed to start: %v", result.err) + } + port := result.port + + // Connect via TCP + conn, err := net.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) + if err != nil { + t.Fatalf("failed to connect to bridge: %v", err) + } + defer func() { _ = conn.Close() }() + + // Send a small request + request := []byte("REQUEST") + _, err = conn.Write(request) + if err != nil { + t.Fatalf("failed to write request: %v", err) + } + t.Logf("Client sent %d bytes", len(request)) + + // Close write side to signal EOF to server + // This causes the request direction (unixConn <- tcpConn) to finish + if tcpConn, ok := conn.(*net.TCPConn); ok { + _ = tcpConn.CloseWrite() + t.Log("Client closed write side") + } + + // Read the large response + // With the bug: connection closes early, read fails or gets partial data + // With the fix: we get the full response + responseBuf := make([]byte, responseSize*2) + totalRead := 0 + deadline := time.After(5 * time.Second) + + for totalRead < responseSize { + select { + case <-deadline: + t.Fatalf("timeout: only received %d of %d bytes - data was truncated", totalRead, responseSize) + default: + } + + n, err := conn.Read(responseBuf[totalRead:]) + if n > 0 { + totalRead += n + t.Logf("Client read %d bytes, total: %d", n, totalRead) + } + if err != nil { + if totalRead < responseSize { + t.Fatalf("connection closed early: got %d of %d bytes: %v", totalRead, responseSize, err) + } + break + } + } + + if totalRead < responseSize { + t.Errorf("expected %d bytes, got %d - response was truncated", responseSize, totalRead) + } else { + t.Logf("SUCCESS: received full response of %d bytes", totalRead) + } +} + +func TestLoadConfigFromEnv(t *testing.T) { + t.Run("empty env", func(t *testing.T) { + _ = os.Unsetenv("FENCE_CONFIG_JSON") + _, err := loadConfigFromEnv() + if err == nil { + t.Error("expected error for empty env, got nil") + } + }) + + t.Run("valid json", func(t *testing.T) { + t.Setenv("FENCE_CONFIG_JSON", `{"allowPty": true}`) + cfg, err := loadConfigFromEnv() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if !cfg.AllowPty { + t.Error("expected AllowPty=true from parsed config") + } + }) + + t.Run("invalid json", func(t *testing.T) { + t.Setenv("FENCE_CONFIG_JSON", `{invalid}`) + _, err := loadConfigFromEnv() + if err == nil { + t.Error("expected error for invalid json, got nil") + } + }) + + t.Run("valid json with network config", func(t *testing.T) { + cfg := &config.Config{ + Network: config.NetworkConfig{ + AllowedDomains: []string{"github.com"}, + DeniedDomains: []string{}, + }, + } + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + t.Setenv("FENCE_CONFIG_JSON", string(data)) + + result, err := loadConfigFromEnv() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if len(result.Network.AllowedDomains) != 1 || result.Network.AllowedDomains[0] != "github.com" { + t.Errorf("expected AllowedDomains=[github.com], got %v", result.Network.AllowedDomains) + } + }) +} + +// TestRepairRuntimeEnv_Integration tests the runtime environment repair +// in realistic scenarios where TMPDIR or XDG_RUNTIME_DIR are problematic. +// This mirrors the shell script logic from linuxRuntimeEnvScript(). +func TestRepairRuntimeEnv_Integration(t *testing.T) { + // Save original values + origTMPDIR := os.Getenv("TMPDIR") + origXDG := os.Getenv("XDG_RUNTIME_DIR") + defer func() { + if origTMPDIR != "" { + os.Setenv("TMPDIR", origTMPDIR) + } else { + os.Unsetenv("TMPDIR") + } + if origXDG != "" { + os.Setenv("XDG_RUNTIME_DIR", origXDG) + } else { + os.Unsetenv("XDG_RUNTIME_DIR") + } + }() + + t.Run("repairs unset TMPDIR", func(t *testing.T) { + os.Unsetenv("TMPDIR") + cleanup := repairRuntimeEnv() + defer cleanup() + + tmpdir := os.Getenv("TMPDIR") + if tmpdir != "/tmp" { + t.Errorf("expected TMPDIR=/tmp for unset TMPDIR, got %q", tmpdir) + } + }) + + t.Run("repairs TMPDIR pointing to nonexistent directory", func(t *testing.T) { + os.Setenv("TMPDIR", "/nonexistent/tmp/dir/xyz123") + cleanup := repairRuntimeEnv() + defer cleanup() + + tmpdir := os.Getenv("TMPDIR") + if tmpdir != "/tmp" { + t.Errorf("expected TMPDIR=/tmp for nonexistent path, got %q", tmpdir) + } + }) + + t.Run("keeps valid TMPDIR", func(t *testing.T) { + validTmpDir := t.TempDir() + os.Setenv("TMPDIR", validTmpDir) + cleanup := repairRuntimeEnv() + defer cleanup() + + tmpdir := os.Getenv("TMPDIR") + if tmpdir != validTmpDir { + t.Errorf("expected TMPDIR to remain %q, got %q", validTmpDir, tmpdir) + } + }) + + t.Run("creates XDG_RUNTIME_DIR when unset", func(t *testing.T) { + os.Unsetenv("XDG_RUNTIME_DIR") + cleanup := repairRuntimeEnv() + defer cleanup() + + xdg := os.Getenv("XDG_RUNTIME_DIR") + if xdg == "" { + t.Fatal("expected XDG_RUNTIME_DIR to be set") + } + + // Verify it's under /tmp with correct prefix + if !strings.HasPrefix(xdg, "/tmp/fence-runtime-") { + t.Errorf("expected XDG_RUNTIME_DIR under /tmp/fence-runtime-, got %q", xdg) + } + + // Verify directory exists and is usable + info, err := os.Stat(xdg) + if err != nil { + t.Fatalf("XDG_RUNTIME_DIR directory does not exist: %v", err) + } + + // Verify permissions are 0700 + if info.Mode().Perm() != 0700 { + t.Errorf("expected XDG_RUNTIME_DIR permissions 0700, got %04o", info.Mode().Perm()) + } + + // Verify we can write to it + testFile := xdg + "/test-write" + if err := os.WriteFile(testFile, []byte("test"), 0600); err != nil { + t.Errorf("cannot write to XDG_RUNTIME_DIR: %v", err) + } + os.Remove(testFile) + }) + + t.Run("repairs XDG_RUNTIME_DIR pointing to nonexistent directory", func(t *testing.T) { + os.Setenv("XDG_RUNTIME_DIR", "/nonexistent/runtime/dir/xyz123") + cleanup := repairRuntimeEnv() + defer cleanup() + + xdg := os.Getenv("XDG_RUNTIME_DIR") + if xdg == "" { + t.Fatal("expected XDG_RUNTIME_DIR to be set") + } + + // Verify it was replaced with a new directory + if !strings.HasPrefix(xdg, "/tmp/fence-runtime-") { + t.Errorf("expected XDG_RUNTIME_DIR under /tmp/fence-runtime-, got %q", xdg) + } + + // Verify the new directory exists + if _, err := os.Stat(xdg); err != nil { + t.Fatalf("XDG_RUNTIME_DIR directory does not exist: %v", err) + } + }) + + t.Run("keeps valid XDG_RUNTIME_DIR", func(t *testing.T) { + validRuntimeDir := t.TempDir() + os.Setenv("XDG_RUNTIME_DIR", validRuntimeDir) + cleanup := repairRuntimeEnv() + defer cleanup() + + xdg := os.Getenv("XDG_RUNTIME_DIR") + if xdg != validRuntimeDir { + t.Errorf("expected XDG_RUNTIME_DIR to remain %q, got %q", validRuntimeDir, xdg) + } + }) + + t.Run("cleanup removes created runtime directory", func(t *testing.T) { + os.Unsetenv("XDG_RUNTIME_DIR") + cleanup := repairRuntimeEnv() + + xdg := os.Getenv("XDG_RUNTIME_DIR") + if xdg == "" { + t.Fatal("XDG_RUNTIME_DIR was not set") + } + + // Verify directory exists + if _, err := os.Stat(xdg); err != nil { + t.Fatalf("directory does not exist: %v", err) + } + + // Call cleanup + cleanup() + + // Verify directory is removed + if _, err := os.Stat(xdg); !os.IsNotExist(err) { + t.Errorf("expected directory to be removed after cleanup, got error: %v", err) + } + }) + + t.Run("cleanup does not remove pre-existing XDG_RUNTIME_DIR", func(t *testing.T) { + existingDir := t.TempDir() + os.Setenv("XDG_RUNTIME_DIR", existingDir) + cleanup := repairRuntimeEnv() + + // Call cleanup + cleanup() + + // Verify original directory still exists + if _, err := os.Stat(existingDir); err != nil { + t.Errorf("expected pre-existing directory to remain after cleanup: %v", err) + } + }) + + t.Run("handles both TMPDIR and XDG_RUNTIME_DIR needing repair", func(t *testing.T) { + os.Unsetenv("TMPDIR") + os.Unsetenv("XDG_RUNTIME_DIR") + cleanup := repairRuntimeEnv() + defer cleanup() + + // Verify both are repaired + tmpdir := os.Getenv("TMPDIR") + if tmpdir != "/tmp" { + t.Errorf("expected TMPDIR=/tmp, got %q", tmpdir) + } + + xdg := os.Getenv("XDG_RUNTIME_DIR") + if xdg == "" { + t.Error("expected XDG_RUNTIME_DIR to be set") + } + }) + + t.Run("handles read-only XDG_RUNTIME_DIR", func(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("test requires non-root user") + } + + // Create a read-only directory + tmpDir := t.TempDir() + readOnlyDir := tmpDir + "/readonly" + if err := os.Mkdir(readOnlyDir, 0555); err != nil { + t.Fatalf("failed to create read-only dir: %v", err) + } + defer os.Chmod(readOnlyDir, 0755) // Clean up + + os.Setenv("XDG_RUNTIME_DIR", readOnlyDir) + cleanup := repairRuntimeEnv() + defer cleanup() + + xdg := os.Getenv("XDG_RUNTIME_DIR") + // Should have created a new directory since the read-only one is unusable + if xdg == readOnlyDir { + t.Error("expected read-only XDG_RUNTIME_DIR to be replaced") + } + + if !strings.HasPrefix(xdg, "/tmp/fence-runtime-") { + t.Errorf("expected new XDG_RUNTIME_DIR under /tmp/fence-runtime-, got %q", xdg) + } + }) +} + +// TestExecUserCommand tests the command execution function. +func TestExecUserCommand(t *testing.T) { + t.Run("command not found", func(t *testing.T) { + opts := bootstrapOptions{ + command: []string{"nonexistent-command-xyz123"}, + } + + err := execUserCommand(opts) + if err == nil { + t.Fatal("expected error for nonexistent command") + } + + var exitErr *exitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected exitError, got %T", err) + } + + if exitErr.ExitCode() != ExitCommandNotFound { + t.Errorf("expected exit code %d, got %d", ExitCommandNotFound, exitErr.ExitCode()) + } + }) + + t.Run("successful command execution", func(t *testing.T) { + opts := bootstrapOptions{ + command: []string{"echo", "hello"}, + } + + err := execUserCommand(opts) + if err != nil { + t.Errorf("expected no error, got: %v", err) + } + }) + + t.Run("command with exit code", func(t *testing.T) { + opts := bootstrapOptions{ + command: []string{"sh", "-c", "exit 42"}, + } + + err := execUserCommand(opts) + if err == nil { + t.Fatal("expected error for non-zero exit code") + } + + var exitErr *exitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected exitError, got %T", err) + } + + if exitErr.ExitCode() != 42 { + t.Errorf("expected exit code 42, got %d", exitErr.ExitCode()) + } + }) +} + +// TestParseFlagsAndArgs tests flag parsing and validation. +func TestParseFlagsAndArgs(t *testing.T) { + // Save original args + origArgs := os.Args + defer func() { os.Args = origArgs }() + + t.Run("no command specified", func(t *testing.T) { + os.Args = []string{"fence", "--linux-bootstrap"} + _, err := parseFlagsAndArgs() + if err == nil { + t.Error("expected error for no command") + } + + var exitErr *exitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected exitError, got %T", err) + } + + if exitErr.ExitCode() != ExitWrapperSetupFailed { + t.Errorf("expected exit code %d, got %d", ExitWrapperSetupFailed, exitErr.ExitCode()) + } + }) + + t.Run("valid command", func(t *testing.T) { + os.Args = []string{"fence", "--linux-bootstrap", "echo", "hello"} + opts, err := parseFlagsAndArgs() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if len(opts.command) != 2 { + t.Errorf("expected 2 command args, got %d", len(opts.command)) + } + if opts.command[0] != "echo" { + t.Errorf("expected command[0]=echo, got %q", opts.command[0]) + } + }) + + t.Run("invalid reverse-bridge spec", func(t *testing.T) { + os.Args = []string{"fence", "--linux-bootstrap", "--reverse-bridge", "invalid", "echo"} + _, err := parseFlagsAndArgs() + if err == nil { + t.Error("expected error for invalid reverse-bridge spec") + } + + var exitErr *exitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected exitError, got %T", err) + } + + if exitErr.ExitCode() != ExitWrapperSetupFailed { + t.Errorf("expected exit code %d, got %d", ExitWrapperSetupFailed, exitErr.ExitCode()) + } + }) + + t.Run("parses reverse-bridge correctly", func(t *testing.T) { + os.Args = []string{"fence", "--linux-bootstrap", "--reverse-bridge", "3000:/tmp/test.sock", "echo"} + opts, err := parseFlagsAndArgs() + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if len(opts.reverseBridges) != 1 { + t.Fatalf("expected 1 reverse bridge, got %d", len(opts.reverseBridges)) + } + if opts.reverseBridges[0].port != 3000 { + t.Errorf("expected port 3000, got %d", opts.reverseBridges[0].port) + } + if opts.reverseBridges[0].socketPath != "/tmp/test.sock" { + t.Errorf("expected socket path /tmp/test.sock, got %q", opts.reverseBridges[0].socketPath) + } + }) +} + +// TestApplyLandlock tests Landlock application. +func TestApplyLandlock(t *testing.T) { + t.Run("missing FENCE_CONFIG_JSON", func(t *testing.T) { + _ = os.Unsetenv("FENCE_CONFIG_JSON") + opts := bootstrapOptions{ + command: []string{"echo"}, + } + + err := applyLandlock(opts, nil) + if err == nil { + t.Error("expected error for missing FENCE_CONFIG_JSON") + } + + var exitErr *exitError + if !errors.As(err, &exitErr) { + t.Fatalf("expected exitError, got %T", err) + } + + if exitErr.ExitCode() != ExitWrapperSetupFailed { + t.Errorf("expected exit code %d, got %d", ExitWrapperSetupFailed, exitErr.ExitCode()) + } + }) + + t.Run("valid config", func(t *testing.T) { + t.Setenv("FENCE_CONFIG_JSON", `{}`) + opts := bootstrapOptions{ + command: []string{"echo"}, + } + + err := applyLandlock(opts, nil) + // Landlock may fail if not supported, but should not return exitError + if err != nil { + var exitErr *exitError + if errors.As(err, &exitErr) { + t.Errorf("unexpected exitError: %v", err) + } + // Non-exit errors are acceptable (e.g., Landlock not supported) + } + }) +} + +// TestExitError tests the exitError type. +func TestExitError(t *testing.T) { + t.Run("Error() returns message", func(t *testing.T) { + err := &exitError{ + code: 42, + err: fmt.Errorf("test error"), + } + + if err.Error() != "test error" { + t.Errorf("expected 'test error', got %q", err.Error()) + } + }) + + t.Run("ExitCode() returns code", func(t *testing.T) { + err := &exitError{ + code: 42, + err: fmt.Errorf("test error"), + } + + if err.ExitCode() != 42 { + t.Errorf("expected exit code 42, got %d", err.ExitCode()) + } + }) + + t.Run("errors.As works", func(t *testing.T) { + err := &exitError{ + code: 42, + err: fmt.Errorf("test error"), + } + + var exitErr *exitError + if !errors.As(err, &exitErr) { + t.Error("expected errors.As to work") + } + + if exitErr.ExitCode() != 42 { + t.Errorf("expected exit code 42, got %d", exitErr.ExitCode()) + } + }) +} + +func TestStartBridgesAndSetEnv_SetsNoProxy(t *testing.T) { + t.Run("sets NO_PROXY and no_proxy when http socket is configured", func(t *testing.T) { + tmpDir := t.TempDir() + httpSocketPath := tmpDir + "/http.sock" + + httpListener, err := net.Listen("unix", httpSocketPath) + if err != nil { + t.Fatalf("failed to listen on http socket: %v", err) + } + defer httpListener.Close() + go func() { + for { + conn, err := httpListener.Accept() + if err != nil { + return + } + conn.Close() + } + }() + + t.Setenv("NO_PROXY", "") + t.Setenv("no_proxy", "") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + opts := bootstrapOptions{ + httpSocket: httpSocketPath, + command: []string{"echo", "hello"}, + } + + _, err = startBridgesAndSetEnv(ctx, opts) + if err != nil { + t.Fatalf("startBridgesAndSetEnv failed: %v", err) + } + + noProxy := os.Getenv("NO_PROXY") + if noProxy != "localhost,127.0.0.1" { + t.Errorf("expected NO_PROXY=localhost,127.0.0.1, got %q", noProxy) + } + noProxyLower := os.Getenv("no_proxy") + if noProxyLower != "localhost,127.0.0.1" { + t.Errorf("expected no_proxy=localhost,127.0.0.1, got %q", noProxyLower) + } + }) + + t.Run("sets NO_PROXY and no_proxy when socks socket is configured", func(t *testing.T) { + tmpDir := t.TempDir() + socksSocketPath := tmpDir + "/socks.sock" + + socksListener, err := net.Listen("unix", socksSocketPath) + if err != nil { + t.Fatalf("failed to listen on socks socket: %v", err) + } + defer socksListener.Close() + go func() { + for { + conn, err := socksListener.Accept() + if err != nil { + return + } + conn.Close() + } + }() + + t.Setenv("NO_PROXY", "") + t.Setenv("no_proxy", "") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + opts := bootstrapOptions{ + socksSocket: socksSocketPath, + command: []string{"echo", "hello"}, + } + + _, err = startBridgesAndSetEnv(ctx, opts) + if err != nil { + t.Fatalf("startBridgesAndSetEnv failed: %v", err) + } + + noProxy := os.Getenv("NO_PROXY") + if noProxy != "localhost,127.0.0.1" { + t.Errorf("expected NO_PROXY=localhost,127.0.0.1, got %q", noProxy) + } + noProxyLower := os.Getenv("no_proxy") + if noProxyLower != "localhost,127.0.0.1" { + t.Errorf("expected no_proxy=localhost,127.0.0.1, got %q", noProxyLower) + } + }) +} diff --git a/cmd/fence/main.go b/cmd/fence/main.go index 836a1fd..6af37b5 100644 --- a/cmd/fence/main.go +++ b/cmd/fence/main.go @@ -32,19 +32,20 @@ var ( ) var ( - debug bool - monitor bool - settingsPath string - templateName string - listTemplates bool - cmdString string - exposePorts []string - shellMode string - shellLogin bool - forceNewSession bool - exitCode int - showVersion bool - linuxFeatures bool + debug bool + monitor bool + settingsPath string + templateName string + listTemplates bool + cmdString string + exposePorts []string + shellMode string + shellLogin bool + forceNewSession bool + exitCode int + showVersion bool + linuxFeatures bool + shellBasedLinuxBootstrap bool // Development-only flag for testing shell script bootstrap ) func main() { @@ -55,6 +56,13 @@ func main() { return } + // Check for --linux-bootstrap wrapper mode + // This must be checked before cobra to avoid flag conflicts + if len(os.Args) >= 2 && os.Args[1] == "--linux-bootstrap" { + runLinuxBootstrapWrapper() + return + } + rootCmd := &cobra.Command{ Use: "fence [flags] -- [command...]", Short: "Run commands in a sandbox with network and filesystem restrictions", @@ -113,6 +121,7 @@ Configuration file format: rootCmd.Flags().BoolVar(&forceNewSession, "force-new-session", false, "Linux only: force bubblewrap --new-session even for interactive PTY sessions") rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "Show version information") rootCmd.Flags().BoolVar(&linuxFeatures, "linux-features", false, "Show available Linux security features and exit") + rootCmd.Flags().BoolVar(&shellBasedLinuxBootstrap, "shell-based-linux-bootstrap", false, "TODO remove before merging: Use shell script bootstrap instead of Go implementation") rootCmd.Flags().SetInterspersed(true) @@ -200,6 +209,7 @@ func runCommand(cmd *cobra.Command, args []string) error { manager := sandbox.NewManager(cfg, debug, monitor) manager.SetExposedPorts(ports) manager.SetShellOptions(shellMode, shellLogin) + manager.SetShellBasedLinuxBootstrap(shellBasedLinuxBootstrap) defer manager.Cleanup() if err := manager.Initialize(); err != nil { @@ -648,6 +658,19 @@ parseCommand: fmt.Fprintf(os.Stderr, "[fence:landlock-wrapper] Applying Landlock restrictions\n") } + // Resolve the executable path once, before applying Landlock restrictions. + // This is important because exec.LookPath may fail under Landlock for non-standard + // paths (e.g., /tmp/fence/bin/shell) where directory traversal is constrained. + var execPath string + if len(command) > 0 { + var err error + execPath, err = exec.LookPath(command[0]) + if err != nil { + fmt.Fprintf(os.Stderr, "[fence:landlock-wrapper] Error: command not found: %s\n", command[0]) + os.Exit(127) + } + } + // Only apply Landlock on Linux if platform.Detect() == platform.Linux { // Load config from environment variable (passed by parent fence process) @@ -668,8 +691,18 @@ parseCommand: // Get current working directory for relative path resolution cwd, _ := os.Getwd() + // Allow execution of the command we're about to exec (e.g. /tmp/fence/bin/shell + // in the shell-based bootstrap, which is a bind-mount into a non-standard path). + var executePaths []string + if execPath != "" { + executePaths = append(executePaths, execPath) + if debugMode { + fmt.Fprintf(os.Stderr, "[fence:landlock-wrapper] Adding execute path: %s\n", execPath) + } + } + // Apply Landlock restrictions - err := sandbox.ApplyLandlockFromConfig(cfg, cwd, nil, debugMode) + err := sandbox.ApplyLandlockFromConfigWithExec(cfg, cwd, nil, executePaths, debugMode) if err != nil { if debugMode { fmt.Fprintf(os.Stderr, "[fence:landlock-wrapper] Warning: Landlock not applied: %v\n", err) @@ -680,13 +713,6 @@ parseCommand: } } - // Find the executable - execPath, err := exec.LookPath(command[0]) - if err != nil { - fmt.Fprintf(os.Stderr, "[fence:landlock-wrapper] Error: command not found: %s\n", command[0]) - os.Exit(127) - } - if debugMode { fmt.Fprintf(os.Stderr, "[fence:landlock-wrapper] Exec: %s %v\n", execPath, command[1:]) } @@ -695,9 +721,9 @@ parseCommand: hardenedEnv := sandbox.FilterDangerousEnv(os.Environ()) // Exec the command (replaces this process) - err = syscall.Exec(execPath, command, hardenedEnv) //nolint:gosec - if err != nil { - fmt.Fprintf(os.Stderr, "[fence:landlock-wrapper] Exec failed: %v\n", err) + execErr := syscall.Exec(execPath, command, hardenedEnv) //nolint:gosec + if execErr != nil { + fmt.Fprintf(os.Stderr, "[fence:landlock-wrapper] Exec failed: %v\n", execErr) os.Exit(1) } } diff --git a/cmd/fence/main_test.go b/cmd/fence/main_test.go index b183944..dd9c807 100644 --- a/cmd/fence/main_test.go +++ b/cmd/fence/main_test.go @@ -1,12 +1,29 @@ package main import ( + "encoding/json" + "os" "os/exec" + "runtime" + "strings" "testing" + "github.com/Use-Tusk/fence/internal/config" "github.com/spf13/cobra" ) +// minimalFenceConfigJSON returns a valid FENCE_CONFIG_JSON value for use in +// bootstrap integration tests. The outer process always sets this variable, so +// tests that invoke the binary directly must provide it too. +func minimalFenceConfigJSON(t *testing.T) string { + t.Helper() + data, err := json.Marshal(config.Default()) + if err != nil { + t.Fatalf("failed to marshal default config: %v", err) + } + return string(data) +} + func TestStartCommandWithSignalProxy_CleanupIsIdempotent(t *testing.T) { execCmd := exec.Command("sh", "-c", "exit 0") cleanup, err := startCommandWithSignalProxy(execCmd) @@ -63,3 +80,141 @@ func TestApplyCLIConfigOverrides_NilConfigWithForceNewSessionFlag(t *testing.T) t.Fatal("expected ForceNewSession override to be applied") } } + +func TestLinuxBootstrapWrapper_SimpleCommand(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("--linux-bootstrap is only supported on Linux") + } + // Build the fence binary first + buildCmd := exec.Command("go", "build", "-o", "/tmp/fence-test", ".") + buildCmd.Dir = "." + if output, err := buildCmd.CombinedOutput(); err != nil { + t.Fatalf("failed to build fence: %v\n%s", err, output) + } + defer func() { _ = os.Remove("/tmp/fence-test") }() + + // Run with --linux-bootstrap -- echo hello + cmd := exec.Command("/tmp/fence-test", "--linux-bootstrap", "--", "echo", "hello") + cmd.Env = append(os.Environ(), "FENCE_CONFIG_JSON="+minimalFenceConfigJSON(t)) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("command failed: %v\n%s", err, output) + } + + if !strings.Contains(string(output), "hello") { + t.Errorf("expected output to contain 'hello', got: %s", output) + } +} + +func TestLinuxBootstrapWrapper_FlagParsing(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("--linux-bootstrap is only supported on Linux") + } + // Build the fence binary first + buildCmd := exec.Command("go", "build", "-o", "/tmp/fence-test", ".") + buildCmd.Dir = "." + if output, err := buildCmd.CombinedOutput(); err != nil { + t.Fatalf("failed to build fence: %v\n%s", err, output) + } + defer func() { _ = os.Remove("/tmp/fence-test") }() + + // Test that flags are parsed correctly and -- separates flags from command + // Note: We don't pass socket paths here since we're just testing flag parsing + cmd := exec.Command("/tmp/fence-test", + "--linux-bootstrap", + "--", "echo", "test") + cmd.Env = append(os.Environ(), "FENCE_CONFIG_JSON="+minimalFenceConfigJSON(t)) + + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("command failed: %v\n%s", err, output) + } + + if !strings.Contains(string(output), "test") { + t.Errorf("expected output to contain 'test', got: %s", output) + } +} + +func TestLinuxBootstrapWrapper_ExitCode(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("--linux-bootstrap is only supported on Linux") + } + // Build the fence binary first + buildCmd := exec.Command("go", "build", "-o", "/tmp/fence-test", ".") + buildCmd.Dir = "." + if output, err := buildCmd.CombinedOutput(); err != nil { + t.Fatalf("failed to build fence: %v\n%s", err, output) + } + defer func() { _ = os.Remove("/tmp/fence-test") }() + + // Test that exit codes are properly propagated + cmd := exec.Command("/tmp/fence-test", "--linux-bootstrap", "--", "sh", "-c", "exit 42") + cmd.Env = append(os.Environ(), "FENCE_CONFIG_JSON="+minimalFenceConfigJSON(t)) + + _ = cmd.Run() + + if cmd.ProcessState == nil { + t.Fatal("ProcessState is nil") + } + + exitCode := cmd.ProcessState.ExitCode() + if exitCode != 42 { + t.Errorf("expected exit code 42, got %d", exitCode) + } +} + +func TestLinuxBootstrapWrapper_CommandNotFound(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("--linux-bootstrap is only supported on Linux") + } + // Build the fence binary first + buildCmd := exec.Command("go", "build", "-o", "/tmp/fence-test", ".") + buildCmd.Dir = "." + if output, err := buildCmd.CombinedOutput(); err != nil { + t.Fatalf("failed to build fence: %v\n%s", err, output) + } + defer func() { _ = os.Remove("/tmp/fence-test") }() + + // Test command not found returns exit code 127 + cmd := exec.Command("/tmp/fence-test", "--linux-bootstrap", "--", "nonexistent-command-xyz") + cmd.Env = append(os.Environ(), "FENCE_CONFIG_JSON="+minimalFenceConfigJSON(t)) + + _ = cmd.Run() + + if cmd.ProcessState == nil { + t.Fatal("ProcessState is nil") + } + + exitCode := cmd.ProcessState.ExitCode() + if exitCode != 127 { + t.Errorf("expected exit code 127 for command not found, got %d", exitCode) + } +} + +func TestLinuxBootstrapWrapper_NoCommand(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("--linux-bootstrap is only supported on Linux") + } + // Build the fence binary first + buildCmd := exec.Command("go", "build", "-o", "/tmp/fence-test", ".") + buildCmd.Dir = "." + if output, err := buildCmd.CombinedOutput(); err != nil { + t.Fatalf("failed to build fence: %v\n%s", err, output) + } + defer func() { _ = os.Remove("/tmp/fence-test") }() + + // Test no command specified returns exit code 125 (ExitWrapperSetupFailed) + cmd := exec.Command("/tmp/fence-test", "--linux-bootstrap") + cmd.Env = append(os.Environ(), "FENCE_CONFIG_JSON="+minimalFenceConfigJSON(t)) + + _ = cmd.Run() + + if cmd.ProcessState == nil { + t.Fatal("ProcessState is nil") + } + + exitCode := cmd.ProcessState.ExitCode() + if exitCode != 125 { + t.Errorf("expected exit code 125 for no command, got %d", exitCode) + } +} diff --git a/internal/sandbox/integration_linux_test.go b/internal/sandbox/integration_linux_test.go index 8bd9ca8..f0b8df8 100644 --- a/internal/sandbox/integration_linux_test.go +++ b/internal/sandbox/integration_linux_test.go @@ -551,6 +551,12 @@ func TestLinux_NetworkBlocksPing(t *testing.T) { skipIfAlreadySandboxed(t) skipIfCommandNotFound(t, "ping") + // Skip if network namespace is not available (ping won't be blocked without it) + features := DetectLinuxFeatures() + if !features.CanUnshareNet { + t.Skip("skipping: network namespace not available in this environment") + } + workspace := createTempWorkspace(t) cfg := testConfigWithWorkspace(workspace) diff --git a/internal/sandbox/linux.go b/internal/sandbox/linux.go index 4fba04f..98f44b7 100644 --- a/internal/sandbox/linux.go +++ b/internal/sandbox/linux.go @@ -52,6 +52,10 @@ type LinuxSandboxOptions struct { ShellMode string // Whether to run shell as login shell. ShellLogin bool + // Use Go-based linux-bootstrap wrapper instead of shell script + UseLinuxBootstrap bool + // Development-only: force shell script bootstrap even when UseLinuxBootstrap is true + ShellBasedLinuxBootstrap bool } const ( @@ -420,26 +424,17 @@ func getMandatoryDenyPaths(cwd string, allowGitConfig bool) []string { return paths } -// WrapCommandLinux wraps a command with Linux bubblewrap sandbox. -// It uses available security features (Landlock, seccomp) with graceful fallback. -func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool) (string, error) { +// WrapCommandLinux wraps a command with configurable shell selection. +func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool, shellMode string, shellLogin bool, shellBasedLinuxBootstrap bool) (string, error) { return WrapCommandLinuxWithOptions(cfg, command, bridge, reverseBridge, LinuxSandboxOptions{ - UseLandlock: true, // Enabled by default, will fall back if not available - UseSeccomp: true, // Enabled by default - UseEBPF: true, // Enabled by default if available - Debug: debug, - }) -} - -// WrapCommandLinuxWithShell wraps a command with configurable shell selection. -func WrapCommandLinuxWithShell(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool, shellMode string, shellLogin bool) (string, error) { - return WrapCommandLinuxWithOptions(cfg, command, bridge, reverseBridge, LinuxSandboxOptions{ - UseLandlock: true, - UseSeccomp: true, - UseEBPF: true, - Debug: debug, - ShellMode: shellMode, - ShellLogin: shellLogin, + UseLandlock: true, + UseSeccomp: true, + UseEBPF: true, + UseLinuxBootstrap: !shellBasedLinuxBootstrap, // Use Go wrapper unless shell-based flag is set + ShellBasedLinuxBootstrap: shellBasedLinuxBootstrap, // Development-only: force shell script + Debug: debug, + ShellMode: shellMode, + ShellLogin: shellLogin, }) } @@ -554,6 +549,63 @@ func appendLinuxBootstrapExecutableMounts(args []string, mounts []linuxBootstrap return args } +func appendLinuxBootstrapWrapperArgs( + bwrapArgs []string, + fenceExePath, shellPath, shellFlag, command string, + bridge *LinuxBridge, + reverseBridge *ReverseBridge, + cfg *config.Config, +) ([]string, error) { + // Use staged bootstrap paths (e.g. /tmp/fence/bin/shell, /tmp/fence/bin/fence) + // instead of binding host paths directly. This mirrors the shell-based bootstrap + // pattern and ensures executables are accessible at known locations inside the sandbox. + // Always mount fence (pass true) since the Go-based bootstrap always needs it + // for the --linux-bootstrap command, regardless of whether Landlock is used. + needsSocat := bridge != nil || (reverseBridge != nil && len(reverseBridge.Ports) > 0) + bootstrapMounts, bootstrapExecs, err := planLinuxBootstrapExecutables( + shellPath, + fenceExePath, + true, + needsSocat, + ) + if err != nil { + return nil, err + } + bwrapArgs = appendLinuxBootstrapExecutableMounts(bwrapArgs, bootstrapMounts) + + // Build the linux-bootstrap command line using staged paths + bootstrapCmd := []string{bootstrapExecs.Fence, "--linux-bootstrap"} + if bridge != nil { + bootstrapCmd = append(bootstrapCmd, + "--http-socket", bridge.HTTPSocketPath, + "--socks-socket", bridge.SOCKSSocketPath, + ) + } + if reverseBridge != nil { + for i, port := range reverseBridge.Ports { + bootstrapCmd = append(bootstrapCmd, + "--reverse-bridge", fmt.Sprintf("%d:%s", port, reverseBridge.SocketPaths[i]), + ) + } + } + bootstrapCmd = append(bootstrapCmd, "--", bootstrapExecs.Shell, shellFlag, command) + + // Set FENCE_SANDBOX=1 from outside the sandbox via bwrap --setenv so it is structurally + // guaranteed to be present regardless of any in-sandbox code paths. + bwrapArgs = append(bwrapArgs, "--setenv", "FENCE_SANDBOX", "1") + + // Pass config via environment variable + if cfg != nil { + configJSON, err := json.Marshal(cfg) + if err == nil { + bwrapArgs = append(bwrapArgs, "--setenv", "FENCE_CONFIG_JSON", string(configJSON)) + } + } + + // Execute the bootstrap command directly (no shell wrapper needed) + return append(bwrapArgs, bootstrapCmd...), nil +} + func buildLinuxBootstrapScript( cfg *config.Config, command string, @@ -620,6 +672,11 @@ trap cleanup EXIT `) script.WriteString(linuxRuntimeEnvScript()) + // NOTE: FENCE_SANDBOX=1 is only exported inside this bridge block, meaning it is absent + // when no network bridge is active. This is likely a bug in the shell-based bootstrap path, + // but we leave it as-is here; the Go-based bootstrap sets it unconditionally from outside + // the sandbox via bwrap --setenv in appendLinuxBootstrapWrapperArgs, which is structurally + // guaranteed regardless of any in-sandbox code paths. if bridge != nil { _, _ = fmt.Fprintf(&script, ` # Start HTTP proxy listener (port 3128 -> Unix socket -> host HTTP proxy) @@ -641,6 +698,7 @@ export NO_PROXY=localhost,127.0.0.1 export no_proxy=localhost,127.0.0.1 export FENCE_SANDBOX=1 + `, ShellQuote([]string{ bootstrapExecs.Socat, @@ -1184,32 +1242,53 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin executableIsFence := strings.Contains(filepath.Base(fenceExePath), "fence") useLandlockWrapper := opts.UseLandlock && features.CanUseLandlock() && fenceExePath != "" && !executableInTmp && executableIsFence + // Use Go-based linux-bootstrap wrapper when: + // 1. Explicitly requested via UseLinuxBootstrap option + // 2. Fence binary is accessible (not in /tmp, is fence binary) + // 3. ShellBasedLinuxBootstrap is NOT set (development-only flag to force shell script) + // This replaces the shell script bootstrap entirely + useLinuxBootstrapWrapper := opts.UseLinuxBootstrap && fenceExePath != "" && !executableInTmp && executableIsFence && !opts.ShellBasedLinuxBootstrap + if opts.Debug && executableInTmp { fmt.Fprintf(os.Stderr, "[fence:linux] Skipping Landlock wrapper (executable in /tmp, likely a test)\n") } if opts.Debug && !executableIsFence { fmt.Fprintf(os.Stderr, "[fence:linux] Skipping Landlock wrapper (running as library, not fence CLI)\n") } - - bootstrapMounts, bootstrapExecs, err := planLinuxBootstrapExecutables( - shellPath, - fenceExePath, - useLandlockWrapper, - bridge != nil || (reverseBridge != nil && len(reverseBridge.Ports) > 0), - ) - if err != nil { - return "", err + if opts.Debug && useLinuxBootstrapWrapper { + fmt.Fprintf(os.Stderr, "[fence:linux] Using Go-based linux-bootstrap wrapper\n") + } + if opts.Debug && opts.ShellBasedLinuxBootstrap && opts.UseLinuxBootstrap { + fmt.Fprintf(os.Stderr, "[fence:linux] Using shell script bootstrap (--shell-based-linux-bootstrap flag set)\n") } - bwrapArgs = appendLinuxBootstrapExecutableMounts(bwrapArgs, bootstrapMounts) - bwrapArgs = append(bwrapArgs, "--", bootstrapExecs.Shell, shellFlag) + if useLinuxBootstrapWrapper { + bwrapArgs, err = appendLinuxBootstrapWrapperArgs(bwrapArgs, fenceExePath, shellPath, shellFlag, command, bridge, reverseBridge, cfg) + if err != nil { + return "", err + } + } else { + // Use the original shell script bootstrap + bootstrapMounts, bootstrapExecs, err := planLinuxBootstrapExecutables( + shellPath, + fenceExePath, + useLandlockWrapper, + bridge != nil || (reverseBridge != nil && len(reverseBridge.Ports) > 0), + ) + if err != nil { + return "", err + } + bwrapArgs = appendLinuxBootstrapExecutableMounts(bwrapArgs, bootstrapMounts) - innerScript, err := buildLinuxBootstrapScript(cfg, command, bridge, reverseBridge, opts, useLandlockWrapper, bootstrapExecs, shellFlag) - if err != nil { - return "", err - } + bwrapArgs = append(bwrapArgs, "--", bootstrapExecs.Shell, shellFlag) + + innerScript, err := buildLinuxBootstrapScript(cfg, command, bridge, reverseBridge, opts, useLandlockWrapper, bootstrapExecs, shellFlag) + if err != nil { + return "", err + } - bwrapArgs = append(bwrapArgs, innerScript) + bwrapArgs = append(bwrapArgs, innerScript) + } if opts.Debug { var featureList []string diff --git a/internal/sandbox/linux_landlock.go b/internal/sandbox/linux_landlock.go index bac9dec..275ba98 100644 --- a/internal/sandbox/linux_landlock.go +++ b/internal/sandbox/linux_landlock.go @@ -19,6 +19,14 @@ import ( // This should be called before exec'ing the sandboxed command. // Returns nil if Landlock is not available (graceful fallback). func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []string, debug bool) error { + return ApplyLandlockFromConfigWithExec(cfg, cwd, socketPaths, nil, debug) +} + +// ApplyLandlockFromConfigWithExec creates and applies Landlock restrictions based on config. +// This should be called before exec'ing the sandboxed command. +// Returns nil if Landlock is not available (graceful fallback). +// executePaths are additional paths that need execute permission. +func ApplyLandlockFromConfigWithExec(cfg *config.Config, cwd string, socketPaths []string, executePaths []string, debug bool) error { features := DetectLinuxFeatures() if !features.CanUseLandlock() { if debug { @@ -71,6 +79,55 @@ func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []strin } } + // System binary paths need execute permission for running commands + systemExecutePaths := []string{ + "/bin", + "/sbin", + "/usr/bin", + "/usr/sbin", + "/usr/local/bin", + "/usr/local/sbin", + "/opt", + } + + // Add additional execute paths (e.g., for shell in non-standard locations) + systemExecutePaths = append(systemExecutePaths, executePaths...) + + for _, p := range systemExecutePaths { + if err := ruleset.AllowExecute(p); err != nil && debug { + // Ignore errors for paths that don't exist + if !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add execute path %s: %v\n", p, err) + } + } + + // For non-directory execute paths (individual binaries), also add read+execute + // access to all parent directories. This is needed to traverse to the executable, + // especially for multicall binaries like coreutils where the binary is in + // /nix/store/.../bin/ but those directories aren't in the default read paths, + // and for binaries in /tmp/fence/bin/ (shell-based bootstrap) where the kernel + // requires execute permission on every directory component of the path. + if info, err := os.Stat(p); err == nil && !info.IsDir() { + // Walk up the directory tree from the binary's parent to root + // and add read+execute access to each directory so the kernel can + // traverse the path and exec the binary. + dir := filepath.Dir(p) + for dir != "/" && dir != "." { + if err := ruleset.AllowExecute(dir); err != nil && debug { + if !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add execute path for executable parent %s: %v\n", dir, err) + } + } + if err := ruleset.AllowRead(dir); err != nil && debug { + if !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add read path for executable parent %s: %v\n", dir, err) + } + } + dir = filepath.Dir(dir) + } + } + } + // If /etc/resolv.conf is a cross-mount symlink (e.g., -> /mnt/wsl/resolv.conf // on WSL), Landlock needs a read rule for the resolved target's parent dir, // otherwise following the symlink hits EACCES. diff --git a/internal/sandbox/linux_landlock_stub.go b/internal/sandbox/linux_landlock_stub.go index 57166d4..dd0cfd6 100644 --- a/internal/sandbox/linux_landlock_stub.go +++ b/internal/sandbox/linux_landlock_stub.go @@ -9,6 +9,11 @@ func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []strin return nil } +// ApplyLandlockFromConfigWithExec is a no-op on non-Linux platforms. +func ApplyLandlockFromConfigWithExec(cfg *config.Config, cwd string, socketPaths []string, executePaths []string, debug bool) error { + return nil +} + // LandlockRuleset is a stub for non-Linux platforms. type LandlockRuleset struct{} diff --git a/internal/sandbox/linux_stub.go b/internal/sandbox/linux_stub.go index 90ce3cb..53e6ca1 100644 --- a/internal/sandbox/linux_stub.go +++ b/internal/sandbox/linux_stub.go @@ -22,13 +22,15 @@ type ReverseBridge struct { // LinuxSandboxOptions is a stub for non-Linux platforms. type LinuxSandboxOptions struct { - UseLandlock bool - UseSeccomp bool - UseEBPF bool - Monitor bool - Debug bool - ShellMode string - ShellLogin bool + UseLandlock bool + UseSeccomp bool + UseEBPF bool + Monitor bool + Debug bool + ShellMode string + ShellLogin bool + UseLinuxBootstrap bool + ShellBasedLinuxBootstrap bool } // NewLinuxBridge returns an error on non-Linux platforms. @@ -48,12 +50,7 @@ func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) { func (b *ReverseBridge) Cleanup() {} // WrapCommandLinux returns an error on non-Linux platforms. -func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool) (string, error) { - return "", fmt.Errorf("Linux sandbox not available on this platform") -} - -// WrapCommandLinuxWithShell returns an error on non-Linux platforms. -func WrapCommandLinuxWithShell(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool, shellMode string, shellLogin bool) (string, error) { +func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool, shellMode string, shellLogin bool, shellBasedLinuxBootstrap bool) (string, error) { return "", fmt.Errorf("Linux sandbox not available on this platform") } diff --git a/internal/sandbox/linux_test.go b/internal/sandbox/linux_test.go index 5ebe4dc..c528dc7 100644 --- a/internal/sandbox/linux_test.go +++ b/internal/sandbox/linux_test.go @@ -3,6 +3,7 @@ package sandbox import ( + "fmt" "os" "os/exec" "path/filepath" @@ -238,6 +239,112 @@ func TestWrapCommandLinuxWithOptions_UsesStagedBootstrapShell(t *testing.T) { } } +// TestAppendLinuxBootstrapWrapperArgs_ResolvesSymlinks verifies that the +// appendLinuxBootstrapWrapperArgs function resolves symlinks for shell and fence +// paths before passing them to --ro-bind. This is critical on usr-merged distros +// where /bin is a symlink to /usr/bin, as bwrap will fail if the destination path +// contains symlink components. +func TestAppendLinuxBootstrapWrapperArgs_ResolvesSymlinks(t *testing.T) { + // Create a temporary directory with a real file and a symlink to it + tmpDir := t.TempDir() + + // Create a real shell binary (just an empty file for testing) + realShell := filepath.Join(tmpDir, "real-shell") + if err := os.WriteFile(realShell, []byte("#!/bin/sh\necho test"), 0o755); err != nil { + t.Fatalf("failed to create real shell: %v", err) + } + + // Create a symlink to the shell + symlinkShell := filepath.Join(tmpDir, "shell-link") + if err := os.Symlink(realShell, symlinkShell); err != nil { + t.Fatalf("failed to create shell symlink: %v", err) + } + + // Create a real fence binary + realFence := filepath.Join(tmpDir, "real-fence") + if err := os.WriteFile(realFence, []byte("#!/bin/sh\necho fence"), 0o755); err != nil { + t.Fatalf("failed to create real fence: %v", err) + } + + // Create a symlink to the fence binary + symlinkFence := filepath.Join(tmpDir, "fence-link") + if err := os.Symlink(realFence, symlinkFence); err != nil { + t.Fatalf("failed to create fence symlink: %v", err) + } + + // Call appendLinuxBootstrapWrapperArgs with symlinked paths + args := []string{"bwrap"} + result, err := appendLinuxBootstrapWrapperArgs(args, symlinkFence, symlinkShell, "-c", "echo test", nil, nil, nil) + if err != nil { + t.Fatalf("appendLinuxBootstrapWrapperArgs returned error: %v", err) + } + + // Verify that the resolved paths are used as --ro-bind sources with staged destinations + // The function should resolve symlinks and mount to staged paths like /tmp/fence/bin/shell + realShellBind := fmt.Sprintf("--ro-bind\x00%s\x00%s", realShell, linuxBootstrapShellPath) + realFenceBind := fmt.Sprintf("--ro-bind\x00%s\x00%s", realFence, linuxBootstrapFencePath) + + // Convert args to a single string for easier checking + argsStr := strings.Join(result, "\x00") + + // Check that the resolved shell path is used as source with staged destination + if !strings.Contains(argsStr, realShellBind) { + // Check if the unresolved symlink path is being used (indicates the bug) + symlinkShellBind := fmt.Sprintf("--ro-bind\x00%s\x00%s", symlinkShell, linuxBootstrapShellPath) + if strings.Contains(argsStr, symlinkShellBind) { + t.Fatalf("BUG: appendLinuxBootstrapWrapperArgs uses unresolved shell symlink %q instead of resolved path %q. This will fail on usr-merged distros.", symlinkShell, realShell) + } + t.Fatalf("Expected --ro-bind with resolved shell path %q -> %q, but it was not found in args: %v", realShell, linuxBootstrapShellPath, result) + } + + // Check that the resolved fence path is used as source with staged destination + if !strings.Contains(argsStr, realFenceBind) { + // Check if the unresolved symlink path is being used (indicates the bug) + symlinkFenceBind := fmt.Sprintf("--ro-bind\x00%s\x00%s", symlinkFence, linuxBootstrapFencePath) + if strings.Contains(argsStr, symlinkFenceBind) { + t.Fatalf("BUG: appendLinuxBootstrapWrapperArgs uses unresolved fence symlink %q instead of resolved path %q. This will fail on usr-merged distros.", symlinkFence, realFence) + } + t.Fatalf("Expected --ro-bind with resolved fence path %q -> %q, but it was not found in args: %v", realFence, linuxBootstrapFencePath, result) + } + + // Verify the bootstrap command uses staged paths, not host paths + // The command should be: /tmp/fence/bin/fence --linux-bootstrap -- /tmp/fence/bin/shell -c + foundFenceStaged := false + foundShellStaged := false + for _, arg := range result { + if arg == linuxBootstrapFencePath { + foundFenceStaged = true + } + if arg == linuxBootstrapShellPath { + foundShellStaged = true + } + } + if !foundFenceStaged { + t.Fatalf("Expected bootstrap command to use staged fence path %q, but it was not found in args: %v", linuxBootstrapFencePath, result) + } + if !foundShellStaged { + t.Fatalf("Expected bootstrap command to use staged shell path %q, but it was not found in args: %v", linuxBootstrapShellPath, result) + } +} + +// TestAppendLinuxBootstrapWrapperArgs_HandlesNonexistentPaths verifies that +// appendLinuxBootstrapWrapperArgs returns an error for nonexistent paths, +// since planLinuxBootstrapExecutables cannot resolve them for mounting. +func TestAppendLinuxBootstrapWrapperArgs_HandlesNonexistentPaths(t *testing.T) { + args := []string{"bwrap"} + + // Call with nonexistent paths - should return an error + nonexistentPath := "/nonexistent/path/to/fence" + nonexistentShell := "/nonexistent/path/to/shell" + + _, err := appendLinuxBootstrapWrapperArgs(args, nonexistentPath, nonexistentShell, "-c", "echo test", nil, nil, nil) + + // Should return an error since the paths cannot be resolved for staging + if err == nil { + t.Fatalf("BUG: appendLinuxBootstrapWrapperArgs did not return an error for nonexistent paths") + } +} + func TestResolveLinuxDeviceMode(t *testing.T) { tests := []struct { name string diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index 1f5b8dc..fa19fed 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -11,19 +11,20 @@ import ( // Manager handles sandbox initialization and command wrapping. type Manager struct { - config *config.Config - httpProxy *proxy.HTTPProxy - socksProxy *proxy.SOCKSProxy - linuxBridge *LinuxBridge - reverseBridge *ReverseBridge - httpPort int - socksPort int - exposedPorts []int - shellMode string - shellLogin bool - debug bool - monitor bool - initialized bool + config *config.Config + httpProxy *proxy.HTTPProxy + socksProxy *proxy.SOCKSProxy + linuxBridge *LinuxBridge + reverseBridge *ReverseBridge + httpPort int + socksPort int + exposedPorts []int + shellMode string + shellLogin bool + shellBasedLinuxBootstrap bool // Development-only: use shell script bootstrap TODO remove before merge + debug bool + monitor bool + initialized bool } // NewManager creates a new sandbox manager. @@ -50,6 +51,11 @@ func (m *Manager) SetShellOptions(mode string, login bool) { m.shellLogin = login } +// SetShellBasedLinuxBootstrap sets whether to use shell script bootstrap (development-only). +func (m *Manager) SetShellBasedLinuxBootstrap(useShell bool) { + m.shellBasedLinuxBootstrap = useShell +} + // Initialize sets up the sandbox infrastructure (proxies, etc.). func (m *Manager) Initialize() error { if m.initialized { @@ -128,7 +134,7 @@ func (m *Manager) WrapCommand(command string) (string, error) { case platform.MacOS: return WrapCommandMacOS(m.config, command, m.httpPort, m.socksPort, m.exposedPorts, m.debug, m.shellMode, m.shellLogin) case platform.Linux: - return WrapCommandLinuxWithShell(m.config, command, m.linuxBridge, m.reverseBridge, m.debug, m.shellMode, m.shellLogin) + return WrapCommandLinux(m.config, command, m.linuxBridge, m.reverseBridge, m.debug, m.shellMode, m.shellLogin, m.shellBasedLinuxBootstrap) default: return "", fmt.Errorf("unsupported platform: %s", plat) }