diff --git a/cmd/fence/main.go b/cmd/fence/main.go index 6407951..12ccaeb 100644 --- a/cmd/fence/main.go +++ b/cmd/fence/main.go @@ -28,6 +28,7 @@ var ( monitor bool settingsPath string proxyURL string + dnsAddr string cmdString string exposePorts []string exitCode int @@ -92,6 +93,7 @@ Configuration file format: rootCmd.Flags().BoolVarP(&monitor, "monitor", "m", false, "Monitor and log sandbox violations") rootCmd.Flags().StringVarP(&settingsPath, "settings", "s", "", "Path to settings file (default: OS config directory)") rootCmd.Flags().StringVar(&proxyURL, "proxy", "", "External SOCKS5 proxy URL (e.g., socks5://localhost:1080)") + rootCmd.Flags().StringVar(&dnsAddr, "dns", "", "DNS server address on host (e.g., localhost:3153)") rootCmd.Flags().StringVarP(&cmdString, "c", "c", "", "Run command string directly (like sh -c)") rootCmd.Flags().StringArrayVarP(&exposePorts, "port", "p", nil, "Expose port for inbound connections (can be used multiple times)") rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "Show version information") @@ -173,10 +175,13 @@ func runCommand(cmd *cobra.Command, args []string) error { } } - // CLI --proxy flag overrides config + // CLI flags override config if proxyURL != "" { cfg.Network.ProxyURL = proxyURL } + if dnsAddr != "" { + cfg.Network.DnsAddr = dnsAddr + } manager := sandbox.NewManager(cfg, debug, monitor) manager.SetExposedPorts(ports) diff --git a/internal/config/config.go b/internal/config/config.go index ea36ce5..7849678 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,6 +27,7 @@ type Config struct { // NetworkConfig defines network restrictions. type NetworkConfig struct { ProxyURL string `json:"proxyUrl,omitempty"` // External SOCKS5 proxy (e.g. socks5://host:1080) + DnsAddr string `json:"dnsAddr,omitempty"` // DNS server address on host (e.g. localhost:3153) AllowUnixSockets []string `json:"allowUnixSockets,omitempty"` AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"` AllowLocalBinding bool `json:"allowLocalBinding,omitempty"` @@ -196,6 +197,11 @@ func (c *Config) Validate() error { return fmt.Errorf("invalid network.proxyUrl %q: %w", c.Network.ProxyURL, err) } } + if c.Network.DnsAddr != "" { + if err := validateHostPort(c.Network.DnsAddr); err != nil { + return fmt.Errorf("invalid network.dnsAddr %q: %w", c.Network.DnsAddr, err) + } + } if slices.Contains(c.Filesystem.AllowRead, "") { return errors.New("filesystem.allowRead contains empty path") @@ -261,6 +267,16 @@ func validateProxyURL(proxyURL string) error { return nil } +// validateHostPort validates a host:port address. +func validateHostPort(addr string) error { + // Must contain a colon separating host and port + host, port, found := strings.Cut(addr, ":") + if !found || host == "" || port == "" { + return errors.New("must be in host:port format (e.g. localhost:3153)") + } + return nil +} + // validateHostPattern validates an SSH host pattern. // Host patterns are more permissive than domain patterns: // - Can contain wildcards anywhere (e.g., prod-*.example.com, *.example.com) @@ -385,8 +401,9 @@ func Merge(base, override *Config) *Config { AllowPty: base.AllowPty || override.AllowPty, Network: NetworkConfig{ - // ProxyURL: override wins if non-empty + // ProxyURL/DnsAddr: override wins if non-empty ProxyURL: mergeString(base.Network.ProxyURL, override.Network.ProxyURL), + DnsAddr: mergeString(base.Network.DnsAddr, override.Network.DnsAddr), // Append slices (base first, then override additions) AllowUnixSockets: mergeStrings(base.Network.AllowUnixSockets, override.Network.AllowUnixSockets), diff --git a/internal/sandbox/linux.go b/internal/sandbox/linux.go index 7a33ab5..55a1ec5 100644 --- a/internal/sandbox/linux.go +++ b/internal/sandbox/linux.go @@ -20,13 +20,88 @@ import ( // 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 + 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 @@ -77,6 +152,13 @@ func NewProxyBridge(proxyURL string, debug bool) (*ProxyBridge, error) { 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), @@ -305,8 +387,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, proxyBridge *ProxyBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) { - return WrapCommandLinuxWithOptions(cfg, command, proxyBridge, reverseBridge, tun2socksPath, LinuxSandboxOptions{ +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 @@ -315,7 +397,7 @@ func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBrid } // WrapCommandLinuxWithOptions wraps a command with configurable sandbox options. -func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge *ProxyBridge, reverseBridge *ReverseBridge, tun2socksPath string, opts LinuxSandboxOptions) (string, error) { +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) } @@ -586,18 +668,50 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge } // 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, ) - // 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 != "" { + 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) @@ -632,8 +746,21 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge 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 @@ -646,13 +773,33 @@ socat TCP-LISTEN:${PROXY_PORT},fork,reuseaddr,bind=127.0.0.1 UNIX-CONNECT:%s >/d BRIDGE_PID=$! # Start tun2socks (transparent proxy via gvisor netstack) -/tmp/fence-tun2socks -device tun0 -proxy socks5://127.0.0.1:${PROXY_PORT} >/dev/null 2>&1 & +/tmp/fence-tun2socks -device tun0 -proxy %s >/dev/null 2>&1 & TUN2SOCKS_PID=$! -`, proxyBridge.SocketPath)) +`, 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 & diff --git a/internal/sandbox/linux_stub.go b/internal/sandbox/linux_stub.go index 91b1103..15a0c6e 100644 --- a/internal/sandbox/linux_stub.go +++ b/internal/sandbox/linux_stub.go @@ -15,6 +15,12 @@ type ProxyBridge struct { ProxyPort string } +// DnsBridge is a stub for non-Linux platforms. +type DnsBridge struct { + SocketPath string + DnsAddr string +} + // ReverseBridge is a stub for non-Linux platforms. type ReverseBridge struct { Ports []int @@ -38,6 +44,14 @@ func NewProxyBridge(proxyURL string, debug bool) (*ProxyBridge, error) { // Cleanup is a no-op on non-Linux platforms. func (b *ProxyBridge) Cleanup() {} +// NewDnsBridge returns an error on non-Linux platforms. +func NewDnsBridge(dnsAddr string, debug bool) (*DnsBridge, error) { + return nil, fmt.Errorf("DNS bridge not available on this platform") +} + +// Cleanup is a no-op on non-Linux platforms. +func (b *DnsBridge) Cleanup() {} + // NewReverseBridge returns an error on non-Linux platforms. func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) { return nil, fmt.Errorf("reverse bridge not available on this platform") @@ -47,12 +61,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, proxyBridge *ProxyBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) { +func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, 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, proxyBridge *ProxyBridge, reverseBridge *ReverseBridge, tun2socksPath string, opts LinuxSandboxOptions) (string, error) { +func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge *ProxyBridge, dnsBridge *DnsBridge, reverseBridge *ReverseBridge, tun2socksPath string, opts LinuxSandboxOptions) (string, error) { return "", fmt.Errorf("Linux sandbox not available on this platform") } diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index f395ae3..86c4985 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -12,6 +12,7 @@ import ( type Manager struct { config *config.Config proxyBridge *ProxyBridge + dnsBridge *DnsBridge reverseBridge *ReverseBridge tun2socksPath string // path to extracted tun2socks binary on host exposedPorts []int @@ -64,6 +65,19 @@ func (m *Manager) Initialize() error { return fmt.Errorf("failed to initialize proxy bridge: %w", err) } m.proxyBridge = bridge + + // Create DNS bridge if a DNS server is configured + if m.config.Network.DnsAddr != "" { + dnsBridge, err := NewDnsBridge(m.config.Network.DnsAddr, m.debug) + if err != nil { + m.proxyBridge.Cleanup() + if m.tun2socksPath != "" { + os.Remove(m.tun2socksPath) + } + return fmt.Errorf("failed to initialize DNS bridge: %w", err) + } + m.dnsBridge = dnsBridge + } } // Set up reverse bridge for exposed ports (inbound connections) @@ -114,7 +128,7 @@ func (m *Manager) WrapCommand(command string) (string, error) { case platform.MacOS: return WrapCommandMacOS(m.config, command, m.exposedPorts, m.debug) case platform.Linux: - return WrapCommandLinux(m.config, command, m.proxyBridge, m.reverseBridge, m.tun2socksPath, m.debug) + return WrapCommandLinux(m.config, command, m.proxyBridge, m.dnsBridge, m.reverseBridge, m.tun2socksPath, m.debug) default: return "", fmt.Errorf("unsupported platform: %s", plat) } @@ -125,6 +139,9 @@ func (m *Manager) Cleanup() { if m.reverseBridge != nil { m.reverseBridge.Cleanup() } + if m.dnsBridge != nil { + m.dnsBridge.Cleanup() + } if m.proxyBridge != nil { m.proxyBridge.Cleanup() }