This repository has been archived on 2026-03-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
greywall/internal/sandbox/linux.go
Mathieu Virbel 481616455a fix: add SOCKS5 auth, DNS bridge, and TUN capability support
Three issues prevented transparent proxying from working end-to-end:

1. bwrap dropped CAP_NET_ADMIN before exec, so ip tuntap/link commands
   failed inside the sandbox. Add --cap-add CAP_NET_ADMIN and
   CAP_NET_BIND_SERVICE when transparent proxy is active.

2. tun2socks only offered SOCKS5 no-auth (method 0x00), but many proxies
   (e.g. gost) require username/password auth (method 0x02). Pass through
   credentials from the proxy URL so tun2socks offers both auth methods.

3. DNS resolution failed because UDP DNS needs SOCKS5 UDP ASSOCIATE which
   most proxies don't support. Add --dns flag and DnsBridge that routes
   DNS queries from the sandbox through a Unix socket to a host-side DNS
   server. Falls back to TCP relay through the tunnel when no --dns is set.

Also brings up loopback interface (ip link set lo up) inside the network
namespace so socat can bind to 127.0.0.1.
2026-02-10 14:57:56 -06:00

1028 lines
35 KiB
Go

//go:build linux
package sandbox
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/Use-Tusk/fence/internal/config"
)
// ProxyBridge bridges sandbox to an external SOCKS5 proxy via Unix socket.
type ProxyBridge struct {
SocketPath string // Unix socket path
ProxyHost string // Parsed from ProxyURL
ProxyPort string // Parsed from ProxyURL
ProxyUser string // Username from ProxyURL (if any)
ProxyPass string // Password from ProxyURL (if any)
HasAuth bool // Whether credentials were provided
process *exec.Cmd
debug bool
}
// DnsBridge bridges DNS queries from the sandbox to a host-side DNS server via Unix socket.
// Inside the sandbox, a socat relay converts UDP DNS queries (port 53) to the Unix socket.
// On the host, socat forwards from the Unix socket to the actual DNS server (TCP).
type DnsBridge struct {
SocketPath string // Unix socket path
DnsAddr string // Host-side DNS address (host:port)
process *exec.Cmd
debug bool
}
// NewDnsBridge creates a Unix socket bridge to a host-side DNS server.
func NewDnsBridge(dnsAddr string, debug bool) (*DnsBridge, error) {
if _, err := exec.LookPath("socat"); err != nil {
return nil, fmt.Errorf("socat is required for DNS bridge: %w", err)
}
id := make([]byte, 8)
if _, err := rand.Read(id); err != nil {
return nil, fmt.Errorf("failed to generate socket ID: %w", err)
}
socketID := hex.EncodeToString(id)
tmpDir := os.TempDir()
socketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-dns-%s.sock", socketID))
bridge := &DnsBridge{
SocketPath: socketPath,
DnsAddr: dnsAddr,
debug: debug,
}
// Start bridge: Unix socket -> DNS server TCP
socatArgs := []string{
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", socketPath),
fmt.Sprintf("TCP:%s", dnsAddr),
}
bridge.process = exec.Command("socat", socatArgs...) //nolint:gosec // args constructed from trusted input
if debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Starting DNS bridge: socat %s\n", strings.Join(socatArgs, " "))
}
if err := bridge.process.Start(); err != nil {
return nil, fmt.Errorf("failed to start DNS bridge: %w", err)
}
// Wait for socket to be created
for range 50 {
if fileExists(socketPath) {
if debug {
fmt.Fprintf(os.Stderr, "[fence:linux] DNS bridge ready (%s -> %s)\n", socketPath, dnsAddr)
}
return bridge, nil
}
time.Sleep(100 * time.Millisecond)
}
bridge.Cleanup()
return nil, fmt.Errorf("timeout waiting for DNS bridge socket to be created")
}
// Cleanup stops the DNS bridge and removes the socket file.
func (b *DnsBridge) Cleanup() {
if b.process != nil && b.process.Process != nil {
_ = b.process.Process.Kill()
_ = b.process.Wait()
}
_ = os.Remove(b.SocketPath)
if b.debug {
fmt.Fprintf(os.Stderr, "[fence:linux] DNS bridge cleaned up\n")
}
}
// ReverseBridge holds the socat bridge processes for inbound connections.
type ReverseBridge struct {
Ports []int
SocketPaths []string // Unix socket paths for each port
processes []*exec.Cmd
debug bool
}
// LinuxSandboxOptions contains options for the Linux sandbox.
type LinuxSandboxOptions struct {
// Enable Landlock filesystem restrictions (requires kernel 5.13+)
UseLandlock bool
// Enable seccomp syscall filtering
UseSeccomp bool
// Enable eBPF monitoring (requires CAP_BPF or root)
UseEBPF bool
// Enable violation monitoring
Monitor bool
// Debug mode
Debug bool
}
// NewProxyBridge creates a Unix socket bridge to an external SOCKS5 proxy.
// The bridge uses socat to forward from a Unix socket to the external proxy's TCP address.
func NewProxyBridge(proxyURL string, debug bool) (*ProxyBridge, error) {
if _, err := exec.LookPath("socat"); err != nil {
return nil, fmt.Errorf("socat is required on Linux but not found: %w", err)
}
u, err := parseProxyURL(proxyURL)
if err != nil {
return nil, fmt.Errorf("invalid proxy URL: %w", err)
}
id := make([]byte, 8)
if _, err := rand.Read(id); err != nil {
return nil, fmt.Errorf("failed to generate socket ID: %w", err)
}
socketID := hex.EncodeToString(id)
tmpDir := os.TempDir()
socketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-proxy-%s.sock", socketID))
bridge := &ProxyBridge{
SocketPath: socketPath,
ProxyHost: u.Hostname(),
ProxyPort: u.Port(),
debug: debug,
}
// Capture credentials from the proxy URL (if any)
if u.User != nil {
bridge.HasAuth = true
bridge.ProxyUser = u.User.Username()
bridge.ProxyPass, _ = u.User.Password()
}
// Start bridge: Unix socket -> external SOCKS5 proxy TCP
socatArgs := []string{
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", socketPath),
fmt.Sprintf("TCP:%s:%s", bridge.ProxyHost, bridge.ProxyPort),
}
bridge.process = exec.Command("socat", socatArgs...) //nolint:gosec // args constructed from trusted input
if debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Starting proxy bridge: socat %s\n", strings.Join(socatArgs, " "))
}
if err := bridge.process.Start(); err != nil {
return nil, fmt.Errorf("failed to start proxy bridge: %w", err)
}
// Wait for socket to be created, up to 5 seconds
for range 50 {
if fileExists(socketPath) {
if debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Proxy bridge ready (%s)\n", socketPath)
}
return bridge, nil
}
time.Sleep(100 * time.Millisecond)
}
bridge.Cleanup()
return nil, fmt.Errorf("timeout waiting for proxy bridge socket to be created")
}
// Cleanup stops the bridge process and removes the socket file.
func (b *ProxyBridge) Cleanup() {
if b.process != nil && b.process.Process != nil {
_ = b.process.Process.Kill()
_ = b.process.Wait()
}
_ = os.Remove(b.SocketPath)
if b.debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Proxy bridge cleaned up\n")
}
}
// parseProxyURL parses a SOCKS5 proxy URL and returns the parsed URL.
func parseProxyURL(proxyURL string) (*url.URL, error) {
u, err := url.Parse(proxyURL)
if err != nil {
return nil, err
}
if u.Scheme != "socks5" && u.Scheme != "socks5h" {
return nil, fmt.Errorf("proxy URL must use socks5:// or socks5h:// scheme, got %s", u.Scheme)
}
if u.Hostname() == "" || u.Port() == "" {
return nil, fmt.Errorf("proxy URL must include hostname and port")
}
return u, nil
}
// NewReverseBridge creates Unix socket bridges for inbound connections.
// Host listens on ports, forwards to Unix sockets that go into the sandbox.
func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
if len(ports) == 0 {
return nil, nil
}
if _, err := exec.LookPath("socat"); err != nil {
return nil, fmt.Errorf("socat is required on Linux but not found: %w", err)
}
id := make([]byte, 8)
if _, err := rand.Read(id); err != nil {
return nil, fmt.Errorf("failed to generate socket ID: %w", err)
}
socketID := hex.EncodeToString(id)
tmpDir := os.TempDir()
bridge := &ReverseBridge{
Ports: ports,
debug: debug,
}
for _, port := range ports {
socketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-rev-%d-%s.sock", port, socketID))
bridge.SocketPaths = append(bridge.SocketPaths, socketPath)
// Start reverse bridge: TCP listen on host port -> Unix socket
// The sandbox will create the Unix socket with UNIX-LISTEN
// We use retry to wait for the socket to be created by the sandbox
args := []string{
fmt.Sprintf("TCP-LISTEN:%d,fork,reuseaddr", port),
fmt.Sprintf("UNIX-CONNECT:%s,retry=50,interval=0.1", socketPath),
}
proc := exec.Command("socat", args...) //nolint:gosec // args constructed from trusted input
if debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Starting reverse bridge for port %d: socat %s\n", port, strings.Join(args, " "))
}
if err := proc.Start(); err != nil {
bridge.Cleanup()
return nil, fmt.Errorf("failed to start reverse bridge for port %d: %w", port, err)
}
bridge.processes = append(bridge.processes, proc)
}
if debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Reverse bridges ready for ports: %v\n", ports)
}
return bridge, nil
}
// Cleanup stops the reverse bridge processes and removes socket files.
func (b *ReverseBridge) Cleanup() {
for _, proc := range b.processes {
if proc != nil && proc.Process != nil {
_ = proc.Process.Kill()
_ = proc.Wait()
}
}
// Clean up socket files
for _, socketPath := range b.SocketPaths {
_ = os.Remove(socketPath)
}
if b.debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Reverse bridges cleaned up\n")
}
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// isDirectory returns true if the path exists and is a directory.
func isDirectory(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
return info.IsDir()
}
// isSymlink returns true if the path is a symbolic link.
func isSymlink(path string) bool {
info, err := os.Lstat(path) // Lstat doesn't follow symlinks
if err != nil {
return false
}
return info.Mode()&os.ModeSymlink != 0
}
// canMountOver returns true if bwrap can safely mount over this path.
// Returns false for symlinks (target may not exist in sandbox) and
// other special cases that could cause mount failures.
func canMountOver(path string) bool {
if isSymlink(path) {
return false
}
return fileExists(path)
}
// sameDevice returns true if both paths reside on the same filesystem (device).
func sameDevice(path1, path2 string) bool {
var s1, s2 syscall.Stat_t
if syscall.Stat(path1, &s1) != nil || syscall.Stat(path2, &s2) != nil {
return true // err on the side of caution
}
return s1.Dev == s2.Dev
}
// intermediaryDirs returns the chain of directories between root and targetDir,
// from shallowest to deepest. Used to create --dir entries so bwrap can set up
// mount points inside otherwise-empty mount-point stubs.
//
// Example: intermediaryDirs("/", "/run/systemd/resolve") ->
//
// ["/run", "/run/systemd", "/run/systemd/resolve"]
func intermediaryDirs(root, targetDir string) []string {
rel, err := filepath.Rel(root, targetDir)
if err != nil {
return []string{targetDir}
}
parts := strings.Split(rel, string(filepath.Separator))
dirs := make([]string, 0, len(parts))
current := root
for _, part := range parts {
current = filepath.Join(current, part)
dirs = append(dirs, current)
}
return dirs
}
// getMandatoryDenyPaths returns concrete paths (not globs) that must be protected.
// This expands the glob patterns from GetMandatoryDenyPatterns into real paths.
func getMandatoryDenyPaths(cwd string) []string {
var paths []string
// Dangerous files in cwd
for _, f := range DangerousFiles {
p := filepath.Join(cwd, f)
paths = append(paths, p)
}
// Dangerous directories in cwd
for _, d := range DangerousDirectories {
p := filepath.Join(cwd, d)
paths = append(paths, p)
}
// Git hooks in cwd
paths = append(paths, filepath.Join(cwd, ".git/hooks"))
// Git config in cwd
paths = append(paths, filepath.Join(cwd, ".git/config"))
// Also protect home directory dangerous files
home, err := os.UserHomeDir()
if err == nil {
for _, f := range DangerousFiles {
p := filepath.Join(home, f)
paths = append(paths, p)
}
}
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, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) {
return WrapCommandLinuxWithOptions(cfg, command, proxyBridge, dnsBridge, reverseBridge, tun2socksPath, 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,
})
}
// WrapCommandLinuxWithOptions wraps a command with configurable sandbox options.
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, opts LinuxSandboxOptions) (string, error) {
if _, err := exec.LookPath("bwrap"); err != nil {
return "", fmt.Errorf("bubblewrap (bwrap) is required on Linux but not found: %w", err)
}
shell := "bash"
shellPath, err := exec.LookPath(shell)
if err != nil {
return "", fmt.Errorf("shell %q not found: %w", shell, err)
}
cwd, _ := os.Getwd()
features := DetectLinuxFeatures()
if opts.Debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Available features: %s\n", features.Summary())
}
// Build bwrap args with filesystem restrictions
bwrapArgs := []string{
"bwrap",
"--new-session",
"--die-with-parent",
}
// Always use --unshare-net when available (network namespace isolation)
// Inside the namespace, tun2socks will provide transparent proxy access
if features.CanUnshareNet {
bwrapArgs = append(bwrapArgs, "--unshare-net") // Network namespace isolation
} else if opts.Debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Skipping --unshare-net (network namespace unavailable in this environment)\n")
}
bwrapArgs = append(bwrapArgs, "--unshare-pid") // PID namespace isolation
// Generate seccomp filter if available and requested
var seccompFilterPath string
if opts.UseSeccomp && features.HasSeccomp {
filter := NewSeccompFilter(opts.Debug)
filterPath, err := filter.GenerateBPFFilter()
if err != nil {
if opts.Debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Seccomp filter generation failed: %v\n", err)
}
} else {
seccompFilterPath = filterPath
if opts.Debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Seccomp filter enabled (blocking %d dangerous syscalls)\n", len(DangerousSyscalls))
}
// Add seccomp filter via fd 3 (will be set up via shell redirection)
bwrapArgs = append(bwrapArgs, "--seccomp", "3")
}
}
defaultDenyRead := cfg != nil && cfg.Filesystem.DefaultDenyRead
if defaultDenyRead {
// In defaultDenyRead mode, we only bind essential system paths read-only
// and user-specified allowRead paths. Everything else is inaccessible.
if opts.Debug {
fmt.Fprintf(os.Stderr, "[fence:linux] DefaultDenyRead mode enabled - binding only essential system paths\n")
}
// Bind essential system paths read-only
// Skip /dev, /proc, /tmp as they're mounted with special options below
for _, systemPath := range GetDefaultReadablePaths() {
if systemPath == "/dev" || systemPath == "/proc" || systemPath == "/tmp" ||
systemPath == "/private/tmp" {
continue
}
if fileExists(systemPath) {
bwrapArgs = append(bwrapArgs, "--ro-bind", systemPath, systemPath)
}
}
// Bind user-specified allowRead paths
if cfg != nil && cfg.Filesystem.AllowRead != nil {
boundPaths := make(map[string]bool)
expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowRead)
for _, p := range expandedPaths {
if fileExists(p) && !strings.HasPrefix(p, "/dev/") && !strings.HasPrefix(p, "/proc/") && !boundPaths[p] {
boundPaths[p] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
}
}
// Add non-glob paths
for _, p := range cfg.Filesystem.AllowRead {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) && fileExists(normalized) &&
!strings.HasPrefix(normalized, "/dev/") && !strings.HasPrefix(normalized, "/proc/") && !boundPaths[normalized] {
boundPaths[normalized] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", normalized, normalized)
}
}
}
} else {
// Default mode: bind entire root filesystem read-only
bwrapArgs = append(bwrapArgs, "--ro-bind", "/", "/")
}
// Mount special filesystems
// Use --dev-bind for /dev instead of --dev to preserve host device permissions
// (the --dev minimal devtmpfs has permission issues when bwrap is setuid)
bwrapArgs = append(bwrapArgs, "--dev-bind", "/dev", "/dev")
bwrapArgs = append(bwrapArgs, "--proc", "/proc")
// /tmp needs to be writable for many programs
bwrapArgs = append(bwrapArgs, "--tmpfs", "/tmp")
// Ensure /etc/resolv.conf is readable inside the sandbox.
// On some systems (e.g., WSL), /etc/resolv.conf is a symlink to a path
// on a separate mount point (e.g., /mnt/wsl/resolv.conf) that isn't
// reachable after --ro-bind / / (non-recursive bind). When the target
// is on a different filesystem, we create intermediate directories and
// bind the real file at its original location so the symlink resolves.
if target, err := filepath.EvalSymlinks("/etc/resolv.conf"); err == nil && target != "/etc/resolv.conf" {
// Skip targets under specially-mounted dirs — a --tmpfs there would
// overwrite the --dev-bind or --proc mounts established above.
targetUnderSpecialMount := strings.HasPrefix(target, "/dev/") ||
strings.HasPrefix(target, "/proc/") ||
strings.HasPrefix(target, "/tmp/")
// In defaultDenyRead mode, also skip if the target is under a path
// already individually bound (e.g., /run, /sys) — a --tmpfs would
// overwrite that explicit bind. Targets under unbound paths like
// /mnt/wsl still need the fix.
if defaultDenyRead {
for _, p := range GetDefaultReadablePaths() {
if strings.HasPrefix(target, p+"/") {
targetUnderSpecialMount = true
break
}
}
}
if fileExists(target) && !sameDevice("/", target) && !targetUnderSpecialMount {
// Make the symlink target reachable by creating its parent dirs.
// Walk down from / to the target's parent: skip dirs on the root
// device (they have real content like /mnt/c, /mnt/d on WSL),
// apply --tmpfs at the mount boundary (first dir on a different
// device — an empty mount-point stub safe to replace), then --dir
// for any deeper subdirectories inside the now-writable tmpfs.
targetDir := filepath.Dir(target)
mountBoundaryFound := false
for _, dir := range intermediaryDirs("/", targetDir) {
if !mountBoundaryFound {
if !sameDevice("/", dir) {
bwrapArgs = append(bwrapArgs, "--tmpfs", dir)
mountBoundaryFound = true
}
// skip dirs still on root device
} else {
bwrapArgs = append(bwrapArgs, "--dir", dir)
}
}
if mountBoundaryFound {
bwrapArgs = append(bwrapArgs, "--ro-bind", target, target)
}
if opts.Debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Resolved /etc/resolv.conf symlink -> %s (cross-mount)\n", target)
}
}
}
writablePaths := make(map[string]bool)
// Add default write paths (system paths needed for operation)
for _, p := range GetDefaultWritePaths() {
// Skip /dev paths (handled by --dev) and /tmp paths (handled by --tmpfs)
if strings.HasPrefix(p, "/dev/") || strings.HasPrefix(p, "/tmp/") || strings.HasPrefix(p, "/private/tmp/") {
continue
}
writablePaths[p] = true
}
// Add user-specified allowWrite paths
if cfg != nil && cfg.Filesystem.AllowWrite != nil {
expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowWrite)
for _, p := range expandedPaths {
writablePaths[p] = true
}
// Add non-glob paths
for _, p := range cfg.Filesystem.AllowWrite {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) {
writablePaths[normalized] = true
}
}
}
// Make writable paths actually writable (override read-only root)
for p := range writablePaths {
if fileExists(p) {
bwrapArgs = append(bwrapArgs, "--bind", p, p)
}
}
// Handle denyRead paths - hide them
// For directories: use --tmpfs to replace with empty tmpfs
// For files: use --ro-bind /dev/null to mask with empty file
// Skip symlinks: they may point outside the sandbox and cause mount errors
if cfg != nil && cfg.Filesystem.DenyRead != nil {
expandedDenyRead := ExpandGlobPatterns(cfg.Filesystem.DenyRead)
for _, p := range expandedDenyRead {
if canMountOver(p) {
if isDirectory(p) {
bwrapArgs = append(bwrapArgs, "--tmpfs", p)
} else {
// Mask file with /dev/null (appears as empty, unreadable)
bwrapArgs = append(bwrapArgs, "--ro-bind", "/dev/null", p)
}
}
}
// Add non-glob paths
for _, p := range cfg.Filesystem.DenyRead {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) && canMountOver(normalized) {
if isDirectory(normalized) {
bwrapArgs = append(bwrapArgs, "--tmpfs", normalized)
} else {
bwrapArgs = append(bwrapArgs, "--ro-bind", "/dev/null", normalized)
}
}
}
}
// Apply mandatory deny patterns (make dangerous files/dirs read-only)
// This overrides any writable mounts for these paths
//
// Note: We only use concrete paths from getMandatoryDenyPaths(), NOT glob expansion.
// GetMandatoryDenyPatterns() returns expensive **/pattern globs that require walking
// the entire directory tree - this can hang on large directories (see issue #27).
//
// The concrete paths cover dangerous files in cwd and home directory. Files like
// .bashrc in subdirectories are not protected, but this may be lower-risk since shell
// rc files in project subdirectories are uncommon and not automatically sourced.
//
// TODO: consider depth-limited glob expansion (e.g., max 3 levels) to protect
// subdirectory dangerous files without full tree walks that hang on large dirs.
mandatoryDeny := getMandatoryDenyPaths(cwd)
// Deduplicate
seen := make(map[string]bool)
for _, p := range mandatoryDeny {
if !seen[p] && fileExists(p) {
seen[p] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
}
}
// Handle explicit denyWrite paths (make them read-only)
if cfg != nil && cfg.Filesystem.DenyWrite != nil {
expandedDenyWrite := ExpandGlobPatterns(cfg.Filesystem.DenyWrite)
for _, p := range expandedDenyWrite {
if fileExists(p) && !seen[p] {
seen[p] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
}
}
// Add non-glob paths
for _, p := range cfg.Filesystem.DenyWrite {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) && fileExists(normalized) && !seen[normalized] {
seen[normalized] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", normalized, normalized)
}
}
}
// Bind the proxy bridge Unix socket into the sandbox (needs to be writable)
var dnsRelayResolvConf string // temp file path for custom resolv.conf
if proxyBridge != nil {
bwrapArgs = append(bwrapArgs,
"--bind", proxyBridge.SocketPath, proxyBridge.SocketPath,
)
if tun2socksPath != "" && features.CanUseTransparentProxy() {
// Bind /dev/net/tun for TUN device creation inside the sandbox
if features.HasDevNetTun {
bwrapArgs = append(bwrapArgs, "--dev-bind", "/dev/net/tun", "/dev/net/tun")
}
// Preserve CAP_NET_ADMIN (TUN device + network config) and
// CAP_NET_BIND_SERVICE (DNS relay on port 53) inside the namespace
bwrapArgs = append(bwrapArgs, "--cap-add", "CAP_NET_ADMIN")
bwrapArgs = append(bwrapArgs, "--cap-add", "CAP_NET_BIND_SERVICE")
// Bind the tun2socks binary into the sandbox (read-only)
bwrapArgs = append(bwrapArgs, "--ro-bind", tun2socksPath, "/tmp/fence-tun2socks")
}
// Bind DNS bridge socket if available
if dnsBridge != nil {
bwrapArgs = append(bwrapArgs,
"--bind", dnsBridge.SocketPath, dnsBridge.SocketPath,
)
}
// Override /etc/resolv.conf to point DNS at our local relay (port 53).
// Inside the sandbox, a socat relay on UDP :53 converts queries to the
// DNS bridge (Unix socket -> host DNS server) or to TCP through the tunnel.
if dnsBridge != nil || (tun2socksPath != "" && features.CanUseTransparentProxy()) {
tmpResolv, err := os.CreateTemp("", "fence-resolv-*.conf")
if err == nil {
_, _ = tmpResolv.WriteString("nameserver 127.0.0.1\n")
tmpResolv.Close()
dnsRelayResolvConf = tmpResolv.Name()
bwrapArgs = append(bwrapArgs, "--ro-bind", dnsRelayResolvConf, "/etc/resolv.conf")
if opts.Debug {
if dnsBridge != nil {
fmt.Fprintf(os.Stderr, "[fence:linux] DNS: overriding resolv.conf -> 127.0.0.1 (bridge to %s)\n", dnsBridge.DnsAddr)
} else {
fmt.Fprintf(os.Stderr, "[fence:linux] DNS: overriding resolv.conf -> 127.0.0.1 (TCP relay through tunnel)\n")
}
}
}
}
}
// Bind reverse socket directory if needed (sockets created inside sandbox)
if reverseBridge != nil && len(reverseBridge.SocketPaths) > 0 {
// Get the temp directory containing the reverse sockets
tmpDir := filepath.Dir(reverseBridge.SocketPaths[0])
bwrapArgs = append(bwrapArgs, "--bind", tmpDir, tmpDir)
}
// Get fence executable path for Landlock wrapper
fenceExePath, _ := os.Executable()
// Skip Landlock wrapper if executable is in /tmp (test binaries are built there)
// The wrapper won't work because --tmpfs /tmp hides the test binary
executableInTmp := strings.HasPrefix(fenceExePath, "/tmp/")
// Skip Landlock wrapper if fence is being used as a library (executable is not fence)
// The wrapper re-executes the binary with --landlock-apply, which only fence understands
executableIsFence := strings.Contains(filepath.Base(fenceExePath), "fence")
useLandlockWrapper := opts.UseLandlock && features.CanUseLandlock() && fenceExePath != "" && !executableInTmp && executableIsFence
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")
}
bwrapArgs = append(bwrapArgs, "--", shellPath, "-c")
// Build the inner command that sets up tun2socks and runs the user command
var innerScript strings.Builder
innerScript.WriteString("export FENCE_SANDBOX=1\n")
if proxyBridge != nil && tun2socksPath != "" && features.CanUseTransparentProxy() {
// Build the tun2socks proxy URL with credentials if available
// Many SOCKS5 proxies require the username/password auth flow even
// without real credentials (e.g., gost always selects method 0x02).
// Including userinfo ensures tun2socks offers both auth methods.
tun2socksProxyURL := "socks5://127.0.0.1:${PROXY_PORT}"
if proxyBridge.HasAuth {
userinfo := url.UserPassword(proxyBridge.ProxyUser, proxyBridge.ProxyPass)
tun2socksProxyURL = fmt.Sprintf("socks5://%s@127.0.0.1:${PROXY_PORT}", userinfo.String())
}
// Set up transparent proxy via TUN device + tun2socks
innerScript.WriteString(fmt.Sprintf(`
# Bring up loopback interface (needed for socat to bind on 127.0.0.1)
ip link set lo up
# Set up TUN device for transparent proxying
ip tuntap add dev tun0 mode tun
ip addr add 198.18.0.1/15 dev tun0
ip link set dev tun0 up
ip route add default via 198.18.0.1 dev tun0
# Bridge: local port -> Unix socket -> host -> external SOCKS5 proxy
PROXY_PORT=18321
socat TCP-LISTEN:${PROXY_PORT},fork,reuseaddr,bind=127.0.0.1 UNIX-CONNECT:%s >/dev/null 2>&1 &
BRIDGE_PID=$!
# Start tun2socks (transparent proxy via gvisor netstack)
/tmp/fence-tun2socks -device tun0 -proxy %s >/dev/null 2>&1 &
TUN2SOCKS_PID=$!
`, proxyBridge.SocketPath, tun2socksProxyURL))
// DNS relay: convert UDP DNS queries on port 53 so apps can resolve names.
if dnsBridge != nil {
// Dedicated DNS bridge: UDP :53 -> Unix socket -> host DNS server
innerScript.WriteString(fmt.Sprintf(`# DNS relay: UDP queries -> Unix socket -> host DNS server (%s)
socat UDP4-RECVFROM:53,fork,reuseaddr UNIX-CONNECT:%s >/dev/null 2>&1 &
DNS_RELAY_PID=$!
`, dnsBridge.DnsAddr, dnsBridge.SocketPath))
} else {
// Fallback: UDP :53 -> TCP to public DNS through the tunnel
innerScript.WriteString(`# DNS relay: UDP queries -> TCP 1.1.1.1:53 (through tun2socks tunnel)
socat UDP4-RECVFROM:53,fork,reuseaddr TCP:1.1.1.1:53 >/dev/null 2>&1 &
DNS_RELAY_PID=$!
`)
}
} else if proxyBridge != nil {
// Fallback: no TUN support, use env-var-based proxying
innerScript.WriteString(fmt.Sprintf(`
# Bring up loopback interface (needed for socat to bind on 127.0.0.1)
ip link set lo up 2>/dev/null
# Set up SOCKS5 bridge (no TUN available, env-var-based proxying)
PROXY_PORT=18321
socat TCP-LISTEN:${PROXY_PORT},fork,reuseaddr,bind=127.0.0.1 UNIX-CONNECT:%s >/dev/null 2>&1 &
BRIDGE_PID=$!
export ALL_PROXY=socks5h://127.0.0.1:${PROXY_PORT}
export all_proxy=socks5h://127.0.0.1:${PROXY_PORT}
export HTTP_PROXY=socks5h://127.0.0.1:${PROXY_PORT}
export HTTPS_PROXY=socks5h://127.0.0.1:${PROXY_PORT}
export http_proxy=socks5h://127.0.0.1:${PROXY_PORT}
export https_proxy=socks5h://127.0.0.1:${PROXY_PORT}
export NO_PROXY=localhost,127.0.0.1
export no_proxy=localhost,127.0.0.1
`, proxyBridge.SocketPath))
}
// Set up reverse (inbound) socat listeners inside the sandbox
if reverseBridge != nil && len(reverseBridge.Ports) > 0 {
innerScript.WriteString("\n# Start reverse bridge listeners for inbound connections\n")
for i, port := range reverseBridge.Ports {
socketPath := reverseBridge.SocketPaths[i]
// Listen on Unix socket, forward to localhost:port inside the sandbox
innerScript.WriteString(fmt.Sprintf(
"socat UNIX-LISTEN:%s,fork,reuseaddr TCP:127.0.0.1:%d >/dev/null 2>&1 &\n",
socketPath, port,
))
innerScript.WriteString(fmt.Sprintf("REV_%d_PID=$!\n", port))
}
innerScript.WriteString("\n")
}
// Add cleanup function
innerScript.WriteString(`
# Cleanup function
cleanup() {
jobs -p | xargs -r kill 2>/dev/null
}
trap cleanup EXIT
# Small delay to ensure services are ready
sleep 0.3
# Run the user command
`)
// Use Landlock wrapper if available
if useLandlockWrapper {
// Pass config via environment variable (serialized as JSON)
// This ensures allowWrite/denyWrite rules are properly applied
if cfg != nil {
configJSON, err := json.Marshal(cfg)
if err == nil {
innerScript.WriteString(fmt.Sprintf("export FENCE_CONFIG_JSON=%s\n", ShellQuoteSingle(string(configJSON))))
}
}
// Build wrapper command with proper quoting
// Use bash -c to preserve shell semantics (e.g., "echo hi && ls")
wrapperArgs := []string{fenceExePath, "--landlock-apply"}
if opts.Debug {
wrapperArgs = append(wrapperArgs, "--debug")
}
wrapperArgs = append(wrapperArgs, "--", "bash", "-c", command)
// Use exec to replace bash with the wrapper (which will exec the command)
innerScript.WriteString(fmt.Sprintf("exec %s\n", ShellQuote(wrapperArgs)))
} else {
innerScript.WriteString(command)
innerScript.WriteString("\n")
}
bwrapArgs = append(bwrapArgs, innerScript.String())
if opts.Debug {
var featureList []string
if features.CanUnshareNet {
featureList = append(featureList, "bwrap(network,pid,fs)")
} else {
featureList = append(featureList, "bwrap(pid,fs)")
}
if proxyBridge != nil && features.CanUseTransparentProxy() {
featureList = append(featureList, "tun2socks(transparent)")
} else if proxyBridge != nil {
featureList = append(featureList, "proxy(env-vars)")
}
if features.HasSeccomp && opts.UseSeccomp && seccompFilterPath != "" {
featureList = append(featureList, "seccomp")
}
if useLandlockWrapper {
featureList = append(featureList, fmt.Sprintf("landlock-v%d(wrapper)", features.LandlockABI))
} else if features.CanUseLandlock() && opts.UseLandlock {
featureList = append(featureList, fmt.Sprintf("landlock-v%d(unavailable)", features.LandlockABI))
}
if reverseBridge != nil && len(reverseBridge.Ports) > 0 {
featureList = append(featureList, fmt.Sprintf("inbound:%v", reverseBridge.Ports))
}
fmt.Fprintf(os.Stderr, "[fence:linux] Sandbox: %s\n", strings.Join(featureList, ", "))
}
// Build the final command
bwrapCmd := ShellQuote(bwrapArgs)
// If seccomp filter is enabled, wrap with fd redirection
// bwrap --seccomp expects the filter on the specified fd
if seccompFilterPath != "" {
// Open filter file on fd 3, then run bwrap
// The filter file will be cleaned up after the sandbox exits
return fmt.Sprintf("exec 3<%s; %s", ShellQuoteSingle(seccompFilterPath), bwrapCmd), nil
}
return bwrapCmd, nil
}
// StartLinuxMonitor starts violation monitoring for a Linux sandbox.
// Returns monitors that should be stopped when the sandbox exits.
func StartLinuxMonitor(pid int, opts LinuxSandboxOptions) (*LinuxMonitors, error) {
monitors := &LinuxMonitors{}
features := DetectLinuxFeatures()
// Note: SeccompMonitor is disabled because our seccomp filter uses SECCOMP_RET_ERRNO
// which silently returns EPERM without logging to dmesg/audit.
// To enable seccomp logging, the filter would need to use SECCOMP_RET_LOG (allows syscall)
// or SECCOMP_RET_KILL (logs but kills process) or SECCOMP_RET_USER_NOTIF (complex).
// For now, we rely on the eBPF monitor to detect syscall failures.
if opts.Debug && opts.Monitor && features.SeccompLogLevel >= 1 {
fmt.Fprintf(os.Stderr, "[fence:linux] Note: seccomp violations are blocked but not logged (SECCOMP_RET_ERRNO is silent)\n")
}
// Start eBPF monitor if available and requested
// This monitors syscalls that return EACCES/EPERM for sandbox descendants
if opts.Monitor && opts.UseEBPF && features.HasEBPF {
ebpfMon := NewEBPFMonitor(pid, opts.Debug)
if err := ebpfMon.Start(); err != nil {
if opts.Debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Failed to start eBPF monitor: %v\n", err)
}
} else {
monitors.EBPFMonitor = ebpfMon
if opts.Debug {
fmt.Fprintf(os.Stderr, "[fence:linux] eBPF monitor started for PID %d\n", pid)
}
}
} else if opts.Monitor && opts.Debug {
if !features.HasEBPF {
fmt.Fprintf(os.Stderr, "[fence:linux] eBPF monitoring not available (need CAP_BPF or root)\n")
}
}
return monitors, nil
}
// LinuxMonitors holds all active monitors for a Linux sandbox.
type LinuxMonitors struct {
EBPFMonitor *EBPFMonitor
}
// Stop stops all monitors.
func (m *LinuxMonitors) Stop() {
if m.EBPFMonitor != nil {
m.EBPFMonitor.Stop()
}
}
// PrintLinuxFeatures prints available Linux sandbox features.
func PrintLinuxFeatures() {
features := DetectLinuxFeatures()
fmt.Printf("Linux Sandbox Features:\n")
fmt.Printf(" Kernel: %d.%d\n", features.KernelMajor, features.KernelMinor)
fmt.Printf(" Bubblewrap (bwrap): %v\n", features.HasBwrap)
fmt.Printf(" Socat: %v\n", features.HasSocat)
fmt.Printf(" Network namespace (--unshare-net): %v\n", features.CanUnshareNet)
fmt.Printf(" Seccomp: %v (log level: %d)\n", features.HasSeccomp, features.SeccompLogLevel)
fmt.Printf(" Landlock: %v (ABI v%d)\n", features.HasLandlock, features.LandlockABI)
fmt.Printf(" eBPF: %v (CAP_BPF: %v, root: %v)\n", features.HasEBPF, features.HasCapBPF, features.HasCapRoot)
fmt.Printf(" ip (iproute2): %v\n", features.HasIpCommand)
fmt.Printf(" /dev/net/tun: %v\n", features.HasDevNetTun)
fmt.Printf(" tun2socks: %v (embedded)\n", features.HasTun2Socks)
fmt.Printf("\nFeature Status:\n")
if features.MinimumViable() {
fmt.Printf(" ✓ Minimum requirements met (bwrap + socat)\n")
} else {
fmt.Printf(" ✗ Missing requirements: ")
if !features.HasBwrap {
fmt.Printf("bwrap ")
}
if !features.HasSocat {
fmt.Printf("socat ")
}
fmt.Println()
}
if features.CanUnshareNet {
fmt.Printf(" ✓ Network namespace isolation available\n")
} else if features.HasBwrap {
fmt.Printf(" ⚠ Network namespace unavailable (containerized environment?)\n")
fmt.Printf(" Sandbox will still work but with reduced network isolation.\n")
fmt.Printf(" This is common in Docker, GitHub Actions, and other CI systems.\n")
}
if features.CanUseTransparentProxy() {
fmt.Printf(" ✓ Transparent proxy available (tun2socks + TUN device)\n")
} else {
fmt.Printf(" ○ Transparent proxy not available (needs ip, /dev/net/tun, network namespace)\n")
}
if features.CanUseLandlock() {
fmt.Printf(" ✓ Landlock available for enhanced filesystem control\n")
} else {
fmt.Printf(" ○ Landlock not available (kernel 5.13+ required)\n")
}
if features.CanMonitorViolations() {
fmt.Printf(" ✓ Violation monitoring available\n")
} else {
fmt.Printf(" ○ Violation monitoring limited (kernel 4.14+ for seccomp logging)\n")
}
if features.HasEBPF {
fmt.Printf(" ✓ eBPF monitoring available (enhanced visibility)\n")
} else {
fmt.Printf(" ○ eBPF monitoring not available (needs CAP_BPF or root)\n")
}
}