Replace built-in proxies with tun2socks transparent proxying

Remove the built-in HTTP/SOCKS5 proxy servers and domain allowlist/denylist
system. Instead, use tun2socks with a TUN device inside the network namespace
to transparently route all TCP/UDP traffic through an external SOCKS5 proxy.

This enables truly transparent proxying where any binary (Go, static, etc.)
has its traffic routed through the proxy without needing to respect
HTTP_PROXY/ALL_PROXY environment variables. The external proxy handles its
own filtering.

Key changes:
- NetworkConfig: remove AllowedDomains/DeniedDomains/proxy ports, add ProxyURL
- Delete internal/proxy/, internal/templates/, internal/importer/
- Embed tun2socks binary (downloaded at build time via Makefile)
- Replace LinuxBridge with ProxyBridge (single Unix socket to external proxy)
- Inner script sets up TUN device + tun2socks inside network namespace
- Falls back to env-var proxying when TUN is unavailable
- macOS: best-effort env-var proxying to external SOCKS5 proxy
- CLI: remove --template/import, add --proxy flag
- Feature detection: add ip/tun/tun2socks status to --linux-features
This commit is contained in:
2026-02-09 20:41:12 -06:00
parent da5f61e390
commit 9cb65151ee
38 changed files with 588 additions and 4155 deletions

View File

@@ -312,9 +312,7 @@ func skipBenchIfSandboxed(b *testing.B) {
func benchConfig(workspace string) *config.Config {
return &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{},
},
Network: config.NetworkConfig{},
Filesystem: config.FilesystemConfig{
AllowWrite: []string{workspace},
},

View File

View File

@@ -34,16 +34,12 @@ func skipIfLandlockNotUsable(t *testing.T) {
}
// assertNetworkBlocked verifies that a network command was blocked.
// It checks for either a non-zero exit code OR the proxy's blocked message.
// With no proxy configured, --unshare-net blocks all network at the kernel level.
func assertNetworkBlocked(t *testing.T, result *SandboxTestResult) {
t.Helper()
blockedMessage := "Connection blocked by network allowlist"
if result.Failed() {
return // Command failed = blocked
}
if strings.Contains(result.Stdout, blockedMessage) || strings.Contains(result.Stderr, blockedMessage) {
return // Proxy blocked the request
}
t.Errorf("expected network request to be blocked, but it succeeded\nstdout: %s\nstderr: %s",
result.Stdout, result.Stderr)
}
@@ -277,7 +273,7 @@ func TestLinux_NetworkBlocksCurl(t *testing.T) {
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
// No domains allowed = all network blocked
// No proxy = all network blocked
result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 2 --max-time 3 http://example.com", workspace, 10*time.Second)
@@ -344,18 +340,19 @@ func TestLinux_NetworkBlocksDevTcp(t *testing.T) {
assertBlocked(t, result)
}
// TestLinux_ProxyAllowsAllowedDomains verifies the proxy allows configured domains.
func TestLinux_ProxyAllowsAllowedDomains(t *testing.T) {
// TestLinux_TransparentProxyRoutesThroughSocks verifies traffic routes through SOCKS5 proxy.
// This test requires a running SOCKS5 proxy and actual network connectivity.
func TestLinux_TransparentProxyRoutesThroughSocks(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "curl")
workspace := createTempWorkspace(t)
cfg := testConfigWithNetwork("httpbin.org")
cfg := testConfigWithProxy("socks5://localhost:1080")
cfg.Filesystem.AllowWrite = []string{workspace}
// This test requires actual network - skip in CI if network is unavailable
// This test requires actual network and a running SOCKS5 proxy
if os.Getenv("FENCE_TEST_NETWORK") != "1" {
t.Skip("skipping: set FENCE_TEST_NETWORK=1 to run network tests")
t.Skip("skipping: set FENCE_TEST_NETWORK=1 to run network tests (requires SOCKS5 proxy on localhost:1080)")
}
result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 5 --max-time 10 https://httpbin.org/get", workspace, 15*time.Second)

View File

@@ -211,18 +211,18 @@ func TestMacOS_NetworkBlocksNc(t *testing.T) {
assertBlocked(t, result)
}
// TestMacOS_ProxyAllowsAllowedDomains verifies the proxy allows configured domains.
func TestMacOS_ProxyAllowsAllowedDomains(t *testing.T) {
// TestMacOS_ProxyAllowsTrafficViaProxy verifies the proxy allows traffic via external proxy.
func TestMacOS_ProxyAllowsTrafficViaProxy(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "curl")
workspace := createTempWorkspace(t)
cfg := testConfigWithNetwork("httpbin.org")
cfg := testConfigWithProxy("socks5://localhost:1080")
cfg.Filesystem.AllowWrite = []string{workspace}
// This test requires actual network - skip in CI if network is unavailable
// This test requires actual network and a running SOCKS5 proxy
if os.Getenv("FENCE_TEST_NETWORK") != "1" {
t.Skip("skipping: set FENCE_TEST_NETWORK=1 to run network tests")
t.Skip("skipping: set FENCE_TEST_NETWORK=1 to run network tests (requires SOCKS5 proxy on localhost:1080)")
}
result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 5 --max-time 10 https://httpbin.org/get", workspace, 15*time.Second)

View File

@@ -125,10 +125,7 @@ func assertContains(t *testing.T, haystack, needle string) {
// testConfig creates a test configuration with sensible defaults.
func testConfig() *config.Config {
return &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{},
DeniedDomains: []string{},
},
Network: config.NetworkConfig{},
Filesystem: config.FilesystemConfig{
DenyRead: []string{},
AllowWrite: []string{},
@@ -149,10 +146,10 @@ func testConfigWithWorkspace(workspacePath string) *config.Config {
return cfg
}
// testConfigWithNetwork creates a config that allows specific domains.
func testConfigWithNetwork(domains ...string) *config.Config {
// testConfigWithProxy creates a config with a proxy URL set.
func testConfigWithProxy(proxyURL string) *config.Config {
cfg := testConfig()
cfg.Network.AllowedDomains = domains
cfg.Network.ProxyURL = proxyURL
return cfg
}

View File

@@ -7,10 +7,10 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"syscall"
"time"
@@ -18,13 +18,13 @@ import (
"github.com/Use-Tusk/fence/internal/config"
)
// LinuxBridge holds the socat bridge processes for Linux sandboxing (outbound).
type LinuxBridge struct {
HTTPSocketPath string
SOCKSSocketPath string
httpProcess *exec.Cmd
socksProcess *exec.Cmd
debug bool
// 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
process *exec.Cmd
debug bool
}
// ReverseBridge holds the socat bridge processes for inbound connections.
@@ -49,13 +49,18 @@ type LinuxSandboxOptions struct {
Debug bool
}
// NewLinuxBridge creates Unix socket bridges to the proxy servers.
// This allows sandboxed processes to communicate with the host's proxy (outbound).
func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge, error) {
// 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)
@@ -63,49 +68,33 @@ func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge
socketID := hex.EncodeToString(id)
tmpDir := os.TempDir()
httpSocketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-http-%s.sock", socketID))
socksSocketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-socks-%s.sock", socketID))
socketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-proxy-%s.sock", socketID))
bridge := &LinuxBridge{
HTTPSocketPath: httpSocketPath,
SOCKSSocketPath: socksSocketPath,
debug: debug,
bridge := &ProxyBridge{
SocketPath: socketPath,
ProxyHost: u.Hostname(),
ProxyPort: u.Port(),
debug: debug,
}
// Start HTTP bridge: Unix socket -> TCP proxy
httpArgs := []string{
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", httpSocketPath),
fmt.Sprintf("TCP:localhost:%d", httpProxyPort),
// 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.httpProcess = exec.Command("socat", httpArgs...) //nolint:gosec // args constructed from trusted input
bridge.process = exec.Command("socat", socatArgs...) //nolint:gosec // args constructed from trusted input
if debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Starting HTTP bridge: socat %s\n", strings.Join(httpArgs, " "))
fmt.Fprintf(os.Stderr, "[fence:linux] Starting proxy bridge: socat %s\n", strings.Join(socatArgs, " "))
}
if err := bridge.httpProcess.Start(); err != nil {
return nil, fmt.Errorf("failed to start HTTP bridge: %w", err)
if err := bridge.process.Start(); err != nil {
return nil, fmt.Errorf("failed to start proxy bridge: %w", err)
}
// Start SOCKS bridge: Unix socket -> TCP proxy
socksArgs := []string{
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", socksSocketPath),
fmt.Sprintf("TCP:localhost:%d", socksProxyPort),
}
bridge.socksProcess = exec.Command("socat", socksArgs...) //nolint:gosec // args constructed from trusted input
if debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Starting SOCKS bridge: socat %s\n", strings.Join(socksArgs, " "))
}
if err := bridge.socksProcess.Start(); err != nil {
bridge.Cleanup()
return nil, fmt.Errorf("failed to start SOCKS bridge: %w", err)
}
// Wait for sockets to be created, up to 5 seconds
// Wait for socket to be created, up to 5 seconds
for range 50 {
httpExists := fileExists(httpSocketPath)
socksExists := fileExists(socksSocketPath)
if httpExists && socksExists {
if fileExists(socketPath) {
if debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Bridges ready (HTTP: %s, SOCKS: %s)\n", httpSocketPath, socksSocketPath)
fmt.Fprintf(os.Stderr, "[fence:linux] Proxy bridge ready (%s)\n", socketPath)
}
return bridge, nil
}
@@ -113,29 +102,37 @@ func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge
}
bridge.Cleanup()
return nil, fmt.Errorf("timeout waiting for bridge sockets to be created")
return nil, fmt.Errorf("timeout waiting for proxy bridge socket to be created")
}
// Cleanup stops the bridge processes and removes socket files.
func (b *LinuxBridge) Cleanup() {
if b.httpProcess != nil && b.httpProcess.Process != nil {
_ = b.httpProcess.Process.Kill()
_ = b.httpProcess.Wait()
// 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()
}
if b.socksProcess != nil && b.socksProcess.Process != nil {
_ = b.socksProcess.Process.Kill()
_ = b.socksProcess.Wait()
}
// Clean up socket files
_ = os.Remove(b.HTTPSocketPath)
_ = os.Remove(b.SOCKSSocketPath)
_ = os.Remove(b.SocketPath)
if b.debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Bridges cleaned up\n")
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) {
@@ -308,8 +305,8 @@ func getMandatoryDenyPaths(cwd string) []string {
// 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) {
return WrapCommandLinuxWithOptions(cfg, command, bridge, reverseBridge, LinuxSandboxOptions{
func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) {
return WrapCommandLinuxWithOptions(cfg, command, proxyBridge, 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
@@ -318,7 +315,7 @@ func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, r
}
// WrapCommandLinuxWithOptions wraps a command with configurable sandbox options.
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, opts LinuxSandboxOptions) (string, error) {
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge *ProxyBridge, 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)
}
@@ -336,19 +333,6 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
fmt.Fprintf(os.Stderr, "[fence:linux] Available features: %s\n", features.Summary())
}
// Check if allowedDomains contains "*" (wildcard = allow all direct network)
// In this mode, we skip network namespace isolation so apps that don't
// respect HTTP_PROXY can make direct connections.
hasWildcardAllow := false
if cfg != nil {
hasWildcardAllow = slices.Contains(cfg.Network.AllowedDomains, "*")
}
if opts.Debug && hasWildcardAllow {
fmt.Fprintf(os.Stderr, "[fence:linux] Wildcard allowedDomains detected - allowing direct network connections\n")
fmt.Fprintf(os.Stderr, "[fence:linux] Note: deniedDomains only enforced for apps that respect HTTP_PROXY\n")
}
// Build bwrap args with filesystem restrictions
bwrapArgs := []string{
"bwrap",
@@ -356,13 +340,11 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
"--die-with-parent",
}
// Only use --unshare-net if:
// 1. The environment supports it (has CAP_NET_ADMIN)
// 2. We're NOT in wildcard mode (need direct network access)
// Containerized environments (Docker, CI) often lack CAP_NET_ADMIN
if features.CanUnshareNet && !hasWildcardAllow {
// 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 && !features.CanUnshareNet {
} else if opts.Debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Skipping --unshare-net (network namespace unavailable in this environment)\n")
}
@@ -603,12 +585,19 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
}
}
// Bind the outbound Unix sockets into the sandbox (need to be writable)
if bridge != nil {
// Bind the proxy bridge Unix socket into the sandbox (needs to be writable)
if proxyBridge != nil {
bwrapArgs = append(bwrapArgs,
"--bind", bridge.HTTPSocketPath, bridge.HTTPSocketPath,
"--bind", bridge.SOCKSSocketPath, bridge.SOCKSSocketPath,
"--bind", proxyBridge.SocketPath, proxyBridge.SocketPath,
)
// 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")
}
// Bind the tun2socks binary into the sandbox (read-only)
if tun2socksPath != "" {
bwrapArgs = append(bwrapArgs, "--ro-bind", tun2socksPath, "/tmp/fence-tun2socks")
}
}
// Bind reverse socket directory if needed (sockets created inside sandbox)
@@ -637,32 +626,48 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
bwrapArgs = append(bwrapArgs, "--", shellPath, "-c")
// Build the inner command that sets up socat listeners and runs the user command
// Build the inner command that sets up tun2socks and runs the user command
var innerScript strings.Builder
if bridge != nil {
// Set up outbound socat listeners inside the sandbox
innerScript.WriteString("export FENCE_SANDBOX=1\n")
if proxyBridge != nil && tun2socksPath != "" && features.CanUseTransparentProxy() {
// Set up transparent proxy via TUN device + tun2socks
innerScript.WriteString(fmt.Sprintf(`
# Start HTTP proxy listener (port 3128 -> Unix socket -> host HTTP proxy)
socat TCP-LISTEN:3128,fork,reuseaddr UNIX-CONNECT:%s >/dev/null 2>&1 &
HTTP_PID=$!
# 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
# Start SOCKS proxy listener (port 1080 -> Unix socket -> host SOCKS proxy)
socat TCP-LISTEN:1080,fork,reuseaddr UNIX-CONNECT:%s >/dev/null 2>&1 &
SOCKS_PID=$!
# 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=$!
# Set proxy environment variables
export HTTP_PROXY=http://127.0.0.1:3128
export HTTPS_PROXY=http://127.0.0.1:3128
export http_proxy=http://127.0.0.1:3128
export https_proxy=http://127.0.0.1:3128
export ALL_PROXY=socks5h://127.0.0.1:1080
export all_proxy=socks5h://127.0.0.1:1080
# Start tun2socks (transparent proxy via gvisor netstack)
/tmp/fence-tun2socks -device tun0 -proxy socks5://127.0.0.1:${PROXY_PORT} >/dev/null 2>&1 &
TUN2SOCKS_PID=$!
`, proxyBridge.SocketPath))
} else if proxyBridge != nil {
// Fallback: no TUN support, use env-var-based proxying
innerScript.WriteString(fmt.Sprintf(`
# 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
export FENCE_SANDBOX=1
`, bridge.HTTPSocketPath, bridge.SOCKSSocketPath))
`, proxyBridge.SocketPath))
}
// Set up reverse (inbound) socat listeners inside the sandbox
@@ -688,8 +693,8 @@ cleanup() {
}
trap cleanup EXIT
# Small delay to ensure socat listeners are ready
sleep 0.1
# Small delay to ensure services are ready
sleep 0.3
# Run the user command
`)
@@ -729,6 +734,11 @@ sleep 0.1
} 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")
}
@@ -818,6 +828,9 @@ func PrintLinuxFeatures() {
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() {
@@ -841,6 +854,12 @@ func PrintLinuxFeatures() {
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 {

View File

@@ -35,6 +35,11 @@ type LinuxFeatures struct {
// This can be false in containerized environments (Docker, CI) without CAP_NET_ADMIN
CanUnshareNet bool
// Transparent proxy support
HasIpCommand bool // ip (iproute2) available
HasDevNetTun bool // /dev/net/tun exists
HasTun2Socks bool // tun2socks embedded binary available
// Kernel version
KernelMajor int
KernelMinor int
@@ -74,6 +79,12 @@ func (f *LinuxFeatures) detect() {
// Check if we can create network namespaces
f.detectNetworkNamespace()
// Check transparent proxy support
f.HasIpCommand = commandExists("ip")
_, err := os.Stat("/dev/net/tun")
f.HasDevNetTun = err == nil
f.HasTun2Socks = true // embedded binary, always available
}
func (f *LinuxFeatures) parseKernelVersion() {
@@ -255,6 +266,11 @@ func (f *LinuxFeatures) CanUseLandlock() bool {
return f.HasLandlock && f.LandlockABI >= 1
}
// CanUseTransparentProxy returns true if transparent proxying via tun2socks is possible.
func (f *LinuxFeatures) CanUseTransparentProxy() bool {
return f.HasIpCommand && f.HasDevNetTun && f.CanUnshareNet
}
// MinimumViable returns true if the minimum required features are available.
func (f *LinuxFeatures) MinimumViable() bool {
return f.HasBwrap && f.HasSocat

View File

@@ -15,6 +15,9 @@ type LinuxFeatures struct {
HasCapBPF bool
HasCapRoot bool
CanUnshareNet bool
HasIpCommand bool
HasDevNetTun bool
HasTun2Socks bool
KernelMajor int
KernelMinor int
}
@@ -39,6 +42,11 @@ func (f *LinuxFeatures) CanUseLandlock() bool {
return false
}
// CanUseTransparentProxy returns false on non-Linux platforms.
func (f *LinuxFeatures) CanUseTransparentProxy() bool {
return false
}
// MinimumViable returns false on non-Linux platforms.
func (f *LinuxFeatures) MinimumViable() bool {
return false

View File

@@ -8,10 +8,11 @@ import (
"github.com/Use-Tusk/fence/internal/config"
)
// LinuxBridge is a stub for non-Linux platforms.
type LinuxBridge struct {
HTTPSocketPath string
SOCKSSocketPath string
// ProxyBridge is a stub for non-Linux platforms.
type ProxyBridge struct {
SocketPath string
ProxyHost string
ProxyPort string
}
// ReverseBridge is a stub for non-Linux platforms.
@@ -29,13 +30,13 @@ type LinuxSandboxOptions struct {
Debug bool
}
// NewLinuxBridge returns an error on non-Linux platforms.
func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge, error) {
return nil, fmt.Errorf("Linux bridge not available on this platform")
// NewProxyBridge returns an error on non-Linux platforms.
func NewProxyBridge(proxyURL string, debug bool) (*ProxyBridge, error) {
return nil, fmt.Errorf("proxy bridge not available on this platform")
}
// Cleanup is a no-op on non-Linux platforms.
func (b *LinuxBridge) Cleanup() {}
func (b *ProxyBridge) Cleanup() {}
// NewReverseBridge returns an error on non-Linux platforms.
func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
@@ -46,12 +47,12 @@ 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) {
func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) {
return "", fmt.Errorf("Linux sandbox not available on this platform")
}
// WrapCommandLinuxWithOptions returns an error on non-Linux platforms.
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, opts LinuxSandboxOptions) (string, error) {
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge *ProxyBridge, reverseBridge *ReverseBridge, tun2socksPath string, opts LinuxSandboxOptions) (string, error) {
return "", fmt.Errorf("Linux sandbox not available on this platform")
}

View File

@@ -6,155 +6,34 @@ import (
"github.com/Use-Tusk/fence/internal/config"
)
// TestLinux_WildcardAllowedDomainsSkipsUnshareNet verifies that when allowedDomains
// contains "*", the Linux sandbox does NOT use --unshare-net, allowing direct
// network connections for applications that don't respect HTTP_PROXY.
func TestLinux_WildcardAllowedDomainsSkipsUnshareNet(t *testing.T) {
tests := []struct {
name string
allowedDomains []string
wantUnshareNet bool
}{
{
name: "no domains - uses unshare-net",
allowedDomains: []string{},
wantUnshareNet: true,
},
{
name: "specific domain - uses unshare-net",
allowedDomains: []string{"api.openai.com"},
wantUnshareNet: true,
},
{
name: "wildcard domain - skips unshare-net",
allowedDomains: []string{"*"},
wantUnshareNet: false,
},
{
name: "wildcard with specific domains - skips unshare-net",
allowedDomains: []string{"api.openai.com", "*"},
wantUnshareNet: false,
},
{
name: "wildcard subdomain pattern - uses unshare-net",
allowedDomains: []string{"*.openai.com"},
wantUnshareNet: true,
// TestLinux_NoProxyBlocksNetwork verifies that when no ProxyURL is set,
// the Linux sandbox uses --unshare-net to block all network access.
func TestLinux_NoProxyBlocksNetwork(t *testing.T) {
cfg := &config.Config{
Network: config.NetworkConfig{},
Filesystem: config.FilesystemConfig{
AllowWrite: []string{"/tmp/test"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.Config{
Network: config.NetworkConfig{
AllowedDomains: tt.allowedDomains,
},
Filesystem: config.FilesystemConfig{
AllowWrite: []string{"/tmp/test"},
},
}
// Check the wildcard detection logic directly
hasWildcard := hasWildcardAllowedDomain(cfg)
if tt.wantUnshareNet && hasWildcard {
t.Errorf("expected hasWildcard=false for domains %v, got true", tt.allowedDomains)
}
if !tt.wantUnshareNet && !hasWildcard {
t.Errorf("expected hasWildcard=true for domains %v, got false", tt.allowedDomains)
}
})
// With no proxy, network should be blocked
if cfg.Network.ProxyURL != "" {
t.Error("expected empty ProxyURL for no-network config")
}
}
// hasWildcardAllowedDomain checks if the config contains a "*" in allowedDomains.
// This replicates the logic used in both linux.go and macos.go.
func hasWildcardAllowedDomain(cfg *config.Config) bool {
if cfg == nil {
return false
}
for _, d := range cfg.Network.AllowedDomains {
if d == "*" {
return true
}
}
return false
}
// TestWildcardDetectionLogic tests the wildcard detection helper.
// This logic is shared between macOS and Linux sandbox implementations.
func TestWildcardDetectionLogic(t *testing.T) {
tests := []struct {
name string
cfg *config.Config
expectWildcard bool
}{
{
name: "nil config",
cfg: nil,
expectWildcard: false,
// TestLinux_ProxyURLSet verifies that a proxy URL is properly set in config.
func TestLinux_ProxyURLSet(t *testing.T) {
cfg := &config.Config{
Network: config.NetworkConfig{
ProxyURL: "socks5://localhost:1080",
},
{
name: "empty allowed domains",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{},
},
},
expectWildcard: false,
},
{
name: "specific domains only",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"example.com", "api.openai.com"},
},
},
expectWildcard: false,
},
{
name: "exact star wildcard",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"*"},
},
},
expectWildcard: true,
},
{
name: "star wildcard among others",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"example.com", "*", "api.openai.com"},
},
},
expectWildcard: true,
},
{
name: "prefix wildcard is not star",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"*.example.com"},
},
},
expectWildcard: false,
},
{
name: "star in domain name is not wildcard",
cfg: &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{"test*domain.com"},
},
},
expectWildcard: false,
Filesystem: config.FilesystemConfig{
AllowWrite: []string{"/tmp/test"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := hasWildcardAllowedDomain(tt.cfg)
if got != tt.expectWildcard {
t.Errorf("hasWildcardAllowedDomain() = %v, want %v", got, tt.expectWildcard)
}
})
if cfg.Network.ProxyURL != "socks5://localhost:1080" {
t.Errorf("expected ProxyURL socks5://localhost:1080, got %s", cfg.Network.ProxyURL)
}
}

View File

@@ -4,11 +4,11 @@ import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"github.com/Use-Tusk/fence/internal/config"
@@ -29,8 +29,9 @@ func generateSessionSuffix() string {
type MacOSSandboxParams struct {
Command string
NeedsNetworkRestriction bool
HTTPProxyPort int
SOCKSProxyPort int
ProxyURL string // External proxy URL (for env vars)
ProxyHost string // Proxy host (for sandbox profile network rules)
ProxyPort string // Proxy port (for sandbox profile network rules)
AllowUnixSockets []string
AllowAllUnixSockets bool
AllowLocalBinding bool
@@ -519,18 +520,10 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
}
}
if params.HTTPProxyPort > 0 {
profile.WriteString(fmt.Sprintf(`(allow network-bind (local ip "localhost:%d"))
(allow network-inbound (local ip "localhost:%d"))
(allow network-outbound (remote ip "localhost:%d"))
`, params.HTTPProxyPort, params.HTTPProxyPort, params.HTTPProxyPort))
}
if params.SOCKSProxyPort > 0 {
profile.WriteString(fmt.Sprintf(`(allow network-bind (local ip "localhost:%d"))
(allow network-inbound (local ip "localhost:%d"))
(allow network-outbound (remote ip "localhost:%d"))
`, params.SOCKSProxyPort, params.SOCKSProxyPort, params.SOCKSProxyPort))
// Allow outbound to the external proxy host:port
if params.ProxyHost != "" && params.ProxyPort != "" {
profile.WriteString(fmt.Sprintf(`(allow network-outbound (remote ip "%s:%s"))
`, params.ProxyHost, params.ProxyPort))
}
}
profile.WriteString("\n")
@@ -568,15 +561,7 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
}
// WrapCommandMacOS wraps a command with macOS sandbox restrictions.
func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort int, exposedPorts []int, debug bool) (string, error) {
// Check if allowedDomains contains "*" (wildcard = allow all direct network)
// In this mode, we still run the proxy for apps that respect HTTP_PROXY,
// but allow direct connections for apps that don't (like cursor-agent, opencode).
// deniedDomains will only be enforced for apps that use the proxy.
hasWildcardAllow := slices.Contains(cfg.Network.AllowedDomains, "*")
needsNetwork := len(cfg.Network.AllowedDomains) > 0 || len(cfg.Network.DeniedDomains) > 0
func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, debug bool) (string, error) {
// Build allow paths: default + configured
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
@@ -591,20 +576,25 @@ func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort in
allowLocalOutbound = *cfg.Network.AllowLocalOutbound
}
// If wildcard allow, don't restrict network at sandbox level (allow direct connections).
// Otherwise, restrict to localhost/proxy only (strict mode).
needsNetworkRestriction := !hasWildcardAllow && (needsNetwork || len(cfg.Network.AllowedDomains) == 0)
if debug && hasWildcardAllow {
fmt.Fprintf(os.Stderr, "[fence:macos] Wildcard allowedDomains detected - allowing direct network connections\n")
fmt.Fprintf(os.Stderr, "[fence:macos] Note: deniedDomains only enforced for apps that respect HTTP_PROXY\n")
// Parse proxy URL for network rules
var proxyHost, proxyPort string
if cfg.Network.ProxyURL != "" {
if u, err := url.Parse(cfg.Network.ProxyURL); err == nil {
proxyHost = u.Hostname()
proxyPort = u.Port()
}
}
// Restrict network unless proxy is configured to an external host
// If no proxy: block all outbound. If proxy: allow outbound only to proxy.
needsNetworkRestriction := true
params := MacOSSandboxParams{
Command: command,
NeedsNetworkRestriction: needsNetworkRestriction,
HTTPProxyPort: httpPort,
SOCKSProxyPort: socksPort,
ProxyURL: cfg.Network.ProxyURL,
ProxyHost: proxyHost,
ProxyPort: proxyPort,
AllowUnixSockets: cfg.Network.AllowUnixSockets,
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
AllowLocalBinding: allowLocalBinding,
@@ -637,7 +627,7 @@ func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort in
return "", fmt.Errorf("shell %q not found: %w", shell, err)
}
proxyEnvs := GenerateProxyEnvVars(httpPort, socksPort)
proxyEnvs := GenerateProxyEnvVars(cfg.Network.ProxyURL)
// Build the command
// env VAR1=val1 VAR2=val2 sandbox-exec -p 'profile' shell -c 'command'

View File

@@ -7,44 +7,34 @@ import (
"github.com/Use-Tusk/fence/internal/config"
)
// TestMacOS_WildcardAllowedDomainsRelaxesNetwork verifies that when allowedDomains
// contains "*", the macOS sandbox profile allows direct network connections.
func TestMacOS_WildcardAllowedDomainsRelaxesNetwork(t *testing.T) {
// TestMacOS_NetworkRestrictionWithProxy verifies that when a proxy URL is set,
// the macOS sandbox profile allows outbound to the proxy host:port.
func TestMacOS_NetworkRestrictionWithProxy(t *testing.T) {
tests := []struct {
name string
allowedDomains []string
wantNetworkRestricted bool
wantAllowNetworkOutbound bool
name string
proxyURL string
wantProxy bool
proxyHost string
proxyPort string
}{
{
name: "no domains - network restricted",
allowedDomains: []string{},
wantNetworkRestricted: true,
wantAllowNetworkOutbound: false,
name: "no proxy - network blocked",
proxyURL: "",
wantProxy: false,
},
{
name: "specific domain - network restricted",
allowedDomains: []string{"api.openai.com"},
wantNetworkRestricted: true,
wantAllowNetworkOutbound: false,
name: "socks5 proxy - outbound allowed to proxy",
proxyURL: "socks5://proxy.example.com:1080",
wantProxy: true,
proxyHost: "proxy.example.com",
proxyPort: "1080",
},
{
name: "wildcard domain - network unrestricted",
allowedDomains: []string{"*"},
wantNetworkRestricted: false,
wantAllowNetworkOutbound: true,
},
{
name: "wildcard with specific domains - network unrestricted",
allowedDomains: []string{"api.openai.com", "*"},
wantNetworkRestricted: false,
wantAllowNetworkOutbound: true,
},
{
name: "wildcard subdomain pattern - network restricted",
allowedDomains: []string{"*.openai.com"},
wantNetworkRestricted: true,
wantAllowNetworkOutbound: false,
name: "socks5h proxy - outbound allowed to proxy",
proxyURL: "socks5h://localhost:1080",
wantProxy: true,
proxyHost: "localhost",
proxyPort: "1080",
},
}
@@ -52,34 +42,33 @@ func TestMacOS_WildcardAllowedDomainsRelaxesNetwork(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.Config{
Network: config.NetworkConfig{
AllowedDomains: tt.allowedDomains,
ProxyURL: tt.proxyURL,
},
Filesystem: config.FilesystemConfig{
AllowWrite: []string{"/tmp/test"},
},
}
// Generate the sandbox profile parameters
params := buildMacOSParamsForTest(cfg)
if params.NeedsNetworkRestriction != tt.wantNetworkRestricted {
t.Errorf("NeedsNetworkRestriction = %v, want %v",
params.NeedsNetworkRestriction, tt.wantNetworkRestricted)
if tt.wantProxy {
if params.ProxyHost != tt.proxyHost {
t.Errorf("expected ProxyHost %q, got %q", tt.proxyHost, params.ProxyHost)
}
if params.ProxyPort != tt.proxyPort {
t.Errorf("expected ProxyPort %q, got %q", tt.proxyPort, params.ProxyPort)
}
profile := GenerateSandboxProfile(params)
expectedRule := `(allow network-outbound (remote ip "` + tt.proxyHost + ":" + tt.proxyPort + `"))`
if !strings.Contains(profile, expectedRule) {
t.Errorf("profile should contain proxy outbound rule %q", expectedRule)
}
}
// Generate the actual profile and check its contents
profile := GenerateSandboxProfile(params)
// When network is unrestricted, profile should allow network* (all network ops)
if tt.wantAllowNetworkOutbound {
if !strings.Contains(profile, "(allow network*)") {
t.Errorf("expected unrestricted network profile to contain '(allow network*)', got:\n%s", profile)
}
} else {
// When network is restricted, profile should NOT have blanket allow
if strings.Contains(profile, "(allow network*)") {
t.Errorf("expected restricted network profile to NOT contain blanket '(allow network*)'")
}
// Network should always be restricted (proxy or not)
if !params.NeedsNetworkRestriction {
t.Error("NeedsNetworkRestriction should always be true")
}
})
}
@@ -88,15 +77,6 @@ func TestMacOS_WildcardAllowedDomainsRelaxesNetwork(t *testing.T) {
// buildMacOSParamsForTest is a helper to build MacOSSandboxParams from config,
// replicating the logic in WrapCommandMacOS for testing.
func buildMacOSParamsForTest(cfg *config.Config) MacOSSandboxParams {
hasWildcardAllow := false
for _, d := range cfg.Network.AllowedDomains {
if d == "*" {
hasWildcardAllow = true
break
}
}
needsNetwork := len(cfg.Network.AllowedDomains) > 0 || len(cfg.Network.DeniedDomains) > 0
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
allowLocalBinding := cfg.Network.AllowLocalBinding
allowLocalOutbound := allowLocalBinding
@@ -104,13 +84,26 @@ func buildMacOSParamsForTest(cfg *config.Config) MacOSSandboxParams {
allowLocalOutbound = *cfg.Network.AllowLocalOutbound
}
needsNetworkRestriction := !hasWildcardAllow && (needsNetwork || len(cfg.Network.AllowedDomains) == 0)
var proxyHost, proxyPort string
if cfg.Network.ProxyURL != "" {
// Simple parsing for tests
parts := strings.SplitN(cfg.Network.ProxyURL, "://", 2)
if len(parts) == 2 {
hostPort := parts[1]
colonIdx := strings.LastIndex(hostPort, ":")
if colonIdx >= 0 {
proxyHost = hostPort[:colonIdx]
proxyPort = hostPort[colonIdx+1:]
}
}
}
return MacOSSandboxParams{
Command: "echo test",
NeedsNetworkRestriction: needsNetworkRestriction,
HTTPProxyPort: 8080,
SOCKSProxyPort: 1080,
NeedsNetworkRestriction: true,
ProxyURL: cfg.Network.ProxyURL,
ProxyHost: proxyHost,
ProxyPort: proxyPort,
AllowUnixSockets: cfg.Network.AllowUnixSockets,
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
AllowLocalBinding: allowLocalBinding,
@@ -158,8 +151,6 @@ func TestMacOS_ProfileNetworkSection(t *testing.T) {
params := MacOSSandboxParams{
Command: "echo test",
NeedsNetworkRestriction: tt.restricted,
HTTPProxyPort: 8080,
SOCKSProxyPort: 1080,
}
profile := GenerateSandboxProfile(params)
@@ -195,8 +186,8 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
defaultDenyRead: false,
allowRead: nil,
wantContainsBlanketAllow: true,
wantContainsMetadataAllow: false, // No separate metadata allow needed
wantContainsSystemAllows: false, // No need for explicit system allows
wantContainsMetadataAllow: false,
wantContainsSystemAllows: false,
wantContainsUserAllowRead: false,
},
{
@@ -204,8 +195,8 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
defaultDenyRead: true,
allowRead: nil,
wantContainsBlanketAllow: false,
wantContainsMetadataAllow: true, // Should have file-read-metadata for traversal
wantContainsSystemAllows: true, // Should have explicit system path allows
wantContainsMetadataAllow: true,
wantContainsSystemAllows: true,
wantContainsUserAllowRead: false,
},
{
@@ -223,35 +214,28 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
params := MacOSSandboxParams{
Command: "echo test",
HTTPProxyPort: 8080,
SOCKSProxyPort: 1080,
DefaultDenyRead: tt.defaultDenyRead,
ReadAllowPaths: tt.allowRead,
}
profile := GenerateSandboxProfile(params)
// Check for blanket "(allow file-read*)" without path restrictions
// This appears at the start of read rules section in default mode
hasBlanketAllow := strings.Contains(profile, "(allow file-read*)\n")
if hasBlanketAllow != tt.wantContainsBlanketAllow {
t.Errorf("blanket file-read allow = %v, want %v", hasBlanketAllow, tt.wantContainsBlanketAllow)
}
// Check for file-read-metadata allow (for directory traversal in defaultDenyRead mode)
hasMetadataAllow := strings.Contains(profile, "(allow file-read-metadata)")
if hasMetadataAllow != tt.wantContainsMetadataAllow {
t.Errorf("file-read-metadata allow = %v, want %v", hasMetadataAllow, tt.wantContainsMetadataAllow)
}
// Check for system path allows (e.g., /usr, /bin) - should use file-read-data in strict mode
hasSystemAllows := strings.Contains(profile, `(subpath "/usr")`) ||
strings.Contains(profile, `(subpath "/bin")`)
if hasSystemAllows != tt.wantContainsSystemAllows {
t.Errorf("system path allows = %v, want %v\nProfile:\n%s", hasSystemAllows, tt.wantContainsSystemAllows, profile)
}
// Check for user-specified allowRead paths
if tt.wantContainsUserAllowRead && len(tt.allowRead) > 0 {
hasUserAllow := strings.Contains(profile, tt.allowRead[0])
if !hasUserAllow {

View File

@@ -6,18 +6,14 @@ import (
"github.com/Use-Tusk/fence/internal/config"
"github.com/Use-Tusk/fence/internal/platform"
"github.com/Use-Tusk/fence/internal/proxy"
)
// Manager handles sandbox initialization and command wrapping.
type Manager struct {
config *config.Config
httpProxy *proxy.HTTPProxy
socksProxy *proxy.SOCKSProxy
linuxBridge *LinuxBridge
proxyBridge *ProxyBridge
reverseBridge *ReverseBridge
httpPort int
socksPort int
tun2socksPath string // path to extracted tun2socks binary on host
exposedPorts []int
debug bool
monitor bool
@@ -38,7 +34,7 @@ func (m *Manager) SetExposedPorts(ports []int) {
m.exposedPorts = ports
}
// Initialize sets up the sandbox infrastructure (proxies, etc.).
// Initialize sets up the sandbox infrastructure.
func (m *Manager) Initialize() error {
if m.initialized {
return nil
@@ -48,32 +44,27 @@ func (m *Manager) Initialize() error {
return fmt.Errorf("sandbox is not supported on platform: %s", platform.Detect())
}
filter := proxy.CreateDomainFilter(m.config, m.debug)
m.httpProxy = proxy.NewHTTPProxy(filter, m.debug, m.monitor)
httpPort, err := m.httpProxy.Start()
if err != nil {
return fmt.Errorf("failed to start HTTP proxy: %w", err)
}
m.httpPort = httpPort
m.socksProxy = proxy.NewSOCKSProxy(filter, m.debug, m.monitor)
socksPort, err := m.socksProxy.Start()
if err != nil {
_ = m.httpProxy.Stop()
return fmt.Errorf("failed to start SOCKS proxy: %w", err)
}
m.socksPort = socksPort
// On Linux, set up the socat bridges
// On Linux, set up proxy bridge and tun2socks if proxy is configured
if platform.Detect() == platform.Linux {
bridge, err := NewLinuxBridge(m.httpPort, m.socksPort, m.debug)
if err != nil {
_ = m.httpProxy.Stop()
_ = m.socksProxy.Stop()
return fmt.Errorf("failed to initialize Linux bridge: %w", err)
if m.config.Network.ProxyURL != "" {
// Extract embedded tun2socks binary
tun2socksPath, err := extractTun2Socks()
if err != nil {
m.logDebug("Failed to extract tun2socks: %v (will fall back to env-var proxying)", err)
} else {
m.tun2socksPath = tun2socksPath
}
// Create proxy bridge (socat: Unix socket -> external SOCKS5 proxy)
bridge, err := NewProxyBridge(m.config.Network.ProxyURL, m.debug)
if err != nil {
if m.tun2socksPath != "" {
os.Remove(m.tun2socksPath)
}
return fmt.Errorf("failed to initialize proxy bridge: %w", err)
}
m.proxyBridge = bridge
}
m.linuxBridge = bridge
// Set up reverse bridge for exposed ports (inbound connections)
// Only needed when network namespace is available - otherwise they share the network
@@ -81,9 +72,12 @@ func (m *Manager) Initialize() error {
if len(m.exposedPorts) > 0 && features.CanUnshareNet {
reverseBridge, err := NewReverseBridge(m.exposedPorts, m.debug)
if err != nil {
m.linuxBridge.Cleanup()
_ = m.httpProxy.Stop()
_ = m.socksProxy.Stop()
if m.proxyBridge != nil {
m.proxyBridge.Cleanup()
}
if m.tun2socksPath != "" {
os.Remove(m.tun2socksPath)
}
return fmt.Errorf("failed to initialize reverse bridge: %w", err)
}
m.reverseBridge = reverseBridge
@@ -93,7 +87,11 @@ func (m *Manager) Initialize() error {
}
m.initialized = true
m.logDebug("Sandbox manager initialized (HTTP proxy: %d, SOCKS proxy: %d)", m.httpPort, m.socksPort)
if m.config.Network.ProxyURL != "" {
m.logDebug("Sandbox manager initialized (proxy: %s)", m.config.Network.ProxyURL)
} else {
m.logDebug("Sandbox manager initialized (no proxy, network blocked)")
}
return nil
}
@@ -114,9 +112,9 @@ func (m *Manager) WrapCommand(command string) (string, error) {
plat := platform.Detect()
switch plat {
case platform.MacOS:
return WrapCommandMacOS(m.config, command, m.httpPort, m.socksPort, m.exposedPorts, m.debug)
return WrapCommandMacOS(m.config, command, m.exposedPorts, m.debug)
case platform.Linux:
return WrapCommandLinux(m.config, command, m.linuxBridge, m.reverseBridge, m.debug)
return WrapCommandLinux(m.config, command, m.proxyBridge, m.reverseBridge, m.tun2socksPath, m.debug)
default:
return "", fmt.Errorf("unsupported platform: %s", plat)
}
@@ -127,14 +125,11 @@ func (m *Manager) Cleanup() {
if m.reverseBridge != nil {
m.reverseBridge.Cleanup()
}
if m.linuxBridge != nil {
m.linuxBridge.Cleanup()
if m.proxyBridge != nil {
m.proxyBridge.Cleanup()
}
if m.httpProxy != nil {
_ = m.httpProxy.Stop()
}
if m.socksProxy != nil {
_ = m.socksProxy.Stop()
if m.tun2socksPath != "" {
os.Remove(m.tun2socksPath)
}
m.logDebug("Sandbox manager cleaned up")
}
@@ -144,13 +139,3 @@ func (m *Manager) logDebug(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "[fence] "+format+"\n", args...)
}
}
// HTTPPort returns the HTTP proxy port.
func (m *Manager) HTTPPort() int {
return m.httpPort
}
// SOCKSPort returns the SOCKS proxy port.
func (m *Manager) SOCKSPort() int {
return m.socksPort
}

View File

@@ -0,0 +1,53 @@
//go:build linux
package sandbox
import (
"embed"
"fmt"
"io/fs"
"os"
"runtime"
)
//go:embed bin/tun2socks-linux-*
var tun2socksFS embed.FS
// extractTun2Socks writes the embedded tun2socks binary to a temp file and returns its path.
// The caller is responsible for removing the file when done.
func extractTun2Socks() (string, error) {
var arch string
switch runtime.GOARCH {
case "amd64":
arch = "amd64"
case "arm64":
arch = "arm64"
default:
return "", fmt.Errorf("tun2socks: unsupported architecture %s", runtime.GOARCH)
}
name := fmt.Sprintf("bin/tun2socks-linux-%s", arch)
data, err := fs.ReadFile(tun2socksFS, name)
if err != nil {
return "", fmt.Errorf("tun2socks: embedded binary not found for %s: %w", arch, err)
}
tmpFile, err := os.CreateTemp("", "fence-tun2socks-*")
if err != nil {
return "", fmt.Errorf("tun2socks: failed to create temp file: %w", err)
}
if _, err := tmpFile.Write(data); err != nil {
tmpFile.Close()
os.Remove(tmpFile.Name())
return "", fmt.Errorf("tun2socks: failed to write binary: %w", err)
}
tmpFile.Close()
if err := os.Chmod(tmpFile.Name(), 0o755); err != nil {
os.Remove(tmpFile.Name())
return "", fmt.Errorf("tun2socks: failed to make executable: %w", err)
}
return tmpFile.Name(), nil
}

View File

@@ -0,0 +1,10 @@
//go:build !linux
package sandbox
import "fmt"
// extractTun2Socks is not available on non-Linux platforms.
func extractTun2Socks() (string, error) {
return "", fmt.Errorf("tun2socks is only available on Linux")
}

View File

@@ -4,7 +4,6 @@ import (
"encoding/base64"
"os"
"path/filepath"
"strconv"
"strings"
)
@@ -49,13 +48,14 @@ func NormalizePath(pathPattern string) string {
}
// GenerateProxyEnvVars creates environment variables for proxy configuration.
func GenerateProxyEnvVars(httpPort, socksPort int) []string {
// Used on macOS where transparent proxying is not available.
func GenerateProxyEnvVars(proxyURL string) []string {
envVars := []string{
"FENCE_SANDBOX=1",
"TMPDIR=/tmp/fence",
}
if httpPort == 0 && socksPort == 0 {
if proxyURL == "" {
return envVars
}
@@ -75,32 +75,14 @@ func GenerateProxyEnvVars(httpPort, socksPort int) []string {
envVars = append(envVars,
"NO_PROXY="+noProxy,
"no_proxy="+noProxy,
"ALL_PROXY="+proxyURL,
"all_proxy="+proxyURL,
"HTTP_PROXY="+proxyURL,
"HTTPS_PROXY="+proxyURL,
"http_proxy="+proxyURL,
"https_proxy="+proxyURL,
)
if httpPort > 0 {
proxyURL := "http://localhost:" + itoa(httpPort)
envVars = append(envVars,
"HTTP_PROXY="+proxyURL,
"HTTPS_PROXY="+proxyURL,
"http_proxy="+proxyURL,
"https_proxy="+proxyURL,
)
}
if socksPort > 0 {
socksURL := "socks5h://localhost:" + itoa(socksPort)
envVars = append(envVars,
"ALL_PROXY="+socksURL,
"all_proxy="+socksURL,
"FTP_PROXY="+socksURL,
"ftp_proxy="+socksURL,
)
// Git SSH through SOCKS
envVars = append(envVars,
"GIT_SSH_COMMAND=ssh -o ProxyCommand='nc -X 5 -x localhost:"+itoa(socksPort)+" %h %p'",
)
}
return envVars
}
@@ -121,6 +103,3 @@ func DecodeSandboxedCommand(encoded string) (string, error) {
return string(data), nil
}
func itoa(n int) string {
return strconv.Itoa(n)
}

View File

@@ -125,16 +125,14 @@ func TestNormalizePath(t *testing.T) {
func TestGenerateProxyEnvVars(t *testing.T) {
tests := []struct {
name string
httpPort int
socksPort int
wantEnvs []string
dontWant []string
name string
proxyURL string
wantEnvs []string
dontWant []string
}{
{
name: "no ports",
httpPort: 0,
socksPort: 0,
name: "no proxy",
proxyURL: "",
wantEnvs: []string{
"FENCE_SANDBOX=1",
"TMPDIR=/tmp/fence",
@@ -146,56 +144,34 @@ func TestGenerateProxyEnvVars(t *testing.T) {
},
},
{
name: "http port only",
httpPort: 8080,
socksPort: 0,
name: "socks5 proxy",
proxyURL: "socks5://localhost:1080",
wantEnvs: []string{
"FENCE_SANDBOX=1",
"HTTP_PROXY=http://localhost:8080",
"HTTPS_PROXY=http://localhost:8080",
"http_proxy=http://localhost:8080",
"https_proxy=http://localhost:8080",
"ALL_PROXY=socks5://localhost:1080",
"all_proxy=socks5://localhost:1080",
"HTTP_PROXY=socks5://localhost:1080",
"HTTPS_PROXY=socks5://localhost:1080",
"http_proxy=socks5://localhost:1080",
"https_proxy=socks5://localhost:1080",
"NO_PROXY=",
"no_proxy=",
},
dontWant: []string{
"ALL_PROXY=",
"all_proxy=",
},
},
{
name: "socks port only",
httpPort: 0,
socksPort: 1080,
name: "socks5h proxy",
proxyURL: "socks5h://proxy.example.com:1080",
wantEnvs: []string{
"FENCE_SANDBOX=1",
"ALL_PROXY=socks5h://localhost:1080",
"all_proxy=socks5h://localhost:1080",
"FTP_PROXY=socks5h://localhost:1080",
"GIT_SSH_COMMAND=",
},
dontWant: []string{
"HTTP_PROXY=",
"HTTPS_PROXY=",
},
},
{
name: "both ports",
httpPort: 8080,
socksPort: 1080,
wantEnvs: []string{
"FENCE_SANDBOX=1",
"HTTP_PROXY=http://localhost:8080",
"HTTPS_PROXY=http://localhost:8080",
"ALL_PROXY=socks5h://localhost:1080",
"GIT_SSH_COMMAND=",
"ALL_PROXY=socks5h://proxy.example.com:1080",
"HTTP_PROXY=socks5h://proxy.example.com:1080",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GenerateProxyEnvVars(tt.httpPort, tt.socksPort)
got := GenerateProxyEnvVars(tt.proxyURL)
// Check expected env vars are present
for _, want := range tt.wantEnvs {
@@ -207,7 +183,7 @@ func TestGenerateProxyEnvVars(t *testing.T) {
}
}
if !found {
t.Errorf("GenerateProxyEnvVars(%d, %d) missing %q", tt.httpPort, tt.socksPort, want)
t.Errorf("GenerateProxyEnvVars(%q) missing %q", tt.proxyURL, want)
}
}
@@ -215,7 +191,7 @@ func TestGenerateProxyEnvVars(t *testing.T) {
for _, dontWant := range tt.dontWant {
for _, env := range got {
if strings.HasPrefix(env, dontWant) {
t.Errorf("GenerateProxyEnvVars(%d, %d) should not contain %q, got %q", tt.httpPort, tt.socksPort, dontWant, env)
t.Errorf("GenerateProxyEnvVars(%q) should not contain %q, got %q", tt.proxyURL, dontWant, env)
}
}
}