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.
1028 lines
35 KiB
Go
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")
|
|
}
|
|
}
|