From 9cb65151ee34d4b3727fa4e6f3f0b2d228b0bc74 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Mon, 9 Feb 2026 20:41:12 -0600 Subject: [PATCH] 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 --- .gitignore | 3 + Makefile | 62 ++- cmd/fence/main.go | 220 +------- go.mod | 5 - go.sum | 12 - internal/config/config.go | 106 +--- internal/config/config_test.go | 244 +++----- internal/importer/claude.go | 444 --------------- internal/importer/claude_test.go | 581 ------------------- internal/proxy/http.go | 331 ----------- internal/proxy/http_test.go | 308 ---------- internal/proxy/socks.go | 106 ---- internal/proxy/socks_test.go | 130 ----- internal/sandbox/benchmark_test.go | 4 +- internal/sandbox/bin/.gitkeep | 0 internal/sandbox/integration_linux_test.go | 19 +- internal/sandbox/integration_macos_test.go | 10 +- internal/sandbox/integration_test.go | 11 +- internal/sandbox/linux.go | 229 ++++---- internal/sandbox/linux_features.go | 16 + internal/sandbox/linux_features_stub.go | 8 + internal/sandbox/linux_stub.go | 21 +- internal/sandbox/linux_test.go | 159 +----- internal/sandbox/macos.go | 58 +- internal/sandbox/macos_test.go | 134 ++--- internal/sandbox/manager.go | 93 ++-- internal/sandbox/tun2socks_embed.go | 53 ++ internal/sandbox/tun2socks_embed_stub.go | 10 + internal/sandbox/utils.go | 39 +- internal/sandbox/utils_test.go | 66 +-- internal/templates/code-relaxed.json | 8 - internal/templates/code-strict.json | 29 - internal/templates/code.json | 206 ------- internal/templates/disable-telemetry.json | 119 ---- internal/templates/git-readonly.json | 19 - internal/templates/local-dev-server.json | 9 - internal/templates/templates.go | 253 --------- internal/templates/templates_test.go | 618 --------------------- 38 files changed, 588 insertions(+), 4155 deletions(-) delete mode 100644 internal/importer/claude.go delete mode 100644 internal/importer/claude_test.go delete mode 100644 internal/proxy/http.go delete mode 100644 internal/proxy/http_test.go delete mode 100644 internal/proxy/socks.go delete mode 100644 internal/proxy/socks_test.go create mode 100644 internal/sandbox/bin/.gitkeep create mode 100644 internal/sandbox/tun2socks_embed.go create mode 100644 internal/sandbox/tun2socks_embed_stub.go delete mode 100644 internal/templates/code-relaxed.json delete mode 100644 internal/templates/code-strict.json delete mode 100644 internal/templates/code.json delete mode 100644 internal/templates/disable-telemetry.json delete mode 100644 internal/templates/git-readonly.json delete mode 100644 internal/templates/local-dev-server.json delete mode 100644 internal/templates/templates.go delete mode 100644 internal/templates/templates_test.go diff --git a/.gitignore b/.gitignore index c4cca2f..c09f0e4 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ coverage.out cpu.out mem.out +# Embedded binaries (downloaded at build time) +internal/sandbox/bin/tun2socks-* + diff --git a/Makefile b/Makefile index 36a5071..bc2e86f 100644 --- a/Makefile +++ b/Makefile @@ -5,88 +5,107 @@ GOTEST=$(GOCMD) test GOMOD=$(GOCMD) mod BINARY_NAME=fence BINARY_UNIX=$(BINARY_NAME)_unix +TUN2SOCKS_VERSION=v2.5.2 +TUN2SOCKS_BIN_DIR=internal/sandbox/bin -.PHONY: all build build-ci build-linux test test-ci clean deps install-lint-tools setup setup-ci run fmt lint release release-minor help +.PHONY: all build build-ci build-linux test test-ci clean deps install-lint-tools setup setup-ci run fmt lint release release-minor download-tun2socks help all: build -build: - @echo "๐Ÿ”จ Building $(BINARY_NAME)..." +download-tun2socks: + @echo "Downloading tun2socks $(TUN2SOCKS_VERSION)..." + @mkdir -p $(TUN2SOCKS_BIN_DIR) + @curl -sL "https://github.com/xjasonlyu/tun2socks/releases/download/$(TUN2SOCKS_VERSION)/tun2socks-linux-amd64.zip" -o /tmp/tun2socks-linux-amd64.zip + @unzip -o -q /tmp/tun2socks-linux-amd64.zip -d /tmp/tun2socks-amd64 + @mv /tmp/tun2socks-amd64/tun2socks-linux-amd64 $(TUN2SOCKS_BIN_DIR)/tun2socks-linux-amd64 + @chmod +x $(TUN2SOCKS_BIN_DIR)/tun2socks-linux-amd64 + @rm -rf /tmp/tun2socks-linux-amd64.zip /tmp/tun2socks-amd64 + @curl -sL "https://github.com/xjasonlyu/tun2socks/releases/download/$(TUN2SOCKS_VERSION)/tun2socks-linux-arm64.zip" -o /tmp/tun2socks-linux-arm64.zip + @unzip -o -q /tmp/tun2socks-linux-arm64.zip -d /tmp/tun2socks-arm64 + @mv /tmp/tun2socks-arm64/tun2socks-linux-arm64 $(TUN2SOCKS_BIN_DIR)/tun2socks-linux-arm64 + @chmod +x $(TUN2SOCKS_BIN_DIR)/tun2socks-linux-arm64 + @rm -rf /tmp/tun2socks-linux-arm64.zip /tmp/tun2socks-arm64 + @echo "tun2socks binaries downloaded to $(TUN2SOCKS_BIN_DIR)/" + +build: download-tun2socks + @echo "Building $(BINARY_NAME)..." $(GOBUILD) -o $(BINARY_NAME) -v ./cmd/fence -build-ci: - @echo "๐Ÿ—๏ธ CI: Building $(BINARY_NAME) with version info..." +build-ci: download-tun2socks + @echo "CI: Building $(BINARY_NAME) with version info..." $(eval VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")) $(eval BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')) $(eval GIT_COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")) $(GOBUILD) -ldflags "-s -w -X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME) -X main.gitCommit=$(GIT_COMMIT)" -o $(BINARY_NAME) -v ./cmd/fence test: - @echo "๐Ÿงช Running tests..." + @echo "Running tests..." $(GOTEST) -v ./... test-ci: - @echo "๐Ÿงช CI: Running tests with coverage..." + @echo "CI: Running tests with coverage..." $(GOTEST) -v -race -coverprofile=coverage.out ./... clean: - @echo "๐Ÿงน Cleaning..." + @echo "Cleaning..." $(GOCLEAN) rm -f $(BINARY_NAME) rm -f $(BINARY_UNIX) rm -f coverage.out + rm -f $(TUN2SOCKS_BIN_DIR)/tun2socks-linux-* deps: - @echo "๐Ÿ“ฆ Downloading dependencies..." + @echo "Downloading dependencies..." $(GOMOD) download $(GOMOD) tidy -build-linux: - @echo "๐Ÿง Building for Linux..." +build-linux: download-tun2socks + @echo "Building for Linux..." CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BINARY_UNIX) -v ./cmd/fence build-darwin: - @echo "๐ŸŽ Building for macOS..." + @echo "Building for macOS..." CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BINARY_NAME)_darwin -v ./cmd/fence install-lint-tools: - @echo "๐Ÿ“ฆ Installing linting tools..." + @echo "Installing linting tools..." go install mvdan.cc/gofumpt@latest go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest - @echo "โœ… Linting tools installed" + @echo "Linting tools installed" setup: deps install-lint-tools - @echo "โœ… Development environment ready" + @echo "Development environment ready" setup-ci: deps install-lint-tools - @echo "โœ… CI environment ready" + @echo "CI environment ready" run: build ./$(BINARY_NAME) fmt: - @echo "๐Ÿ“ Formatting code..." + @echo "Formatting code..." gofumpt -w . lint: - @echo "๐Ÿ” Linting code..." + @echo "Linting code..." golangci-lint run --allow-parallel-runners release: - @echo "๐Ÿš€ Creating patch release..." + @echo "Creating patch release..." ./scripts/release.sh patch release-minor: - @echo "๐Ÿš€ Creating minor release..." + @echo "Creating minor release..." ./scripts/release.sh minor help: @echo "Available targets:" @echo " all - build (default)" - @echo " build - Build the binary" + @echo " build - Build the binary (downloads tun2socks if needed)" @echo " build-ci - Build for CI with version info" @echo " build-linux - Build for Linux" @echo " build-darwin - Build for macOS" + @echo " download-tun2socks - Download tun2socks binaries for embedding" @echo " test - Run tests" @echo " test-ci - Run tests for CI with coverage" @echo " clean - Clean build artifacts" @@ -100,4 +119,3 @@ help: @echo " release - Create patch release (v0.0.X)" @echo " release-minor - Create minor release (v0.X.0)" @echo " help - Show this help" - diff --git a/cmd/fence/main.go b/cmd/fence/main.go index 790ad21..6407951 100644 --- a/cmd/fence/main.go +++ b/cmd/fence/main.go @@ -2,22 +2,17 @@ package main import ( - "bufio" "encoding/json" "fmt" "os" "os/exec" "os/signal" - "path/filepath" "strconv" - "strings" "syscall" "github.com/Use-Tusk/fence/internal/config" - "github.com/Use-Tusk/fence/internal/importer" "github.com/Use-Tusk/fence/internal/platform" "github.com/Use-Tusk/fence/internal/sandbox" - "github.com/Use-Tusk/fence/internal/templates" "github.com/spf13/cobra" ) @@ -32,8 +27,7 @@ var ( debug bool monitor bool settingsPath string - templateName string - listTemplates bool + proxyURL string cmdString string exposePorts []string exitCode int @@ -55,25 +49,29 @@ func main() { Long: `fence is a command-line tool that runs commands in a sandboxed environment with network and filesystem restrictions. -By default, all network access is blocked. Configure allowed domains in -~/.config/fence/fence.json (or ~/Library/Application Support/fence/fence.json on macOS) -or pass a settings file with --settings, or use a built-in template with --template. +By default, all network access is blocked. Use --proxy to route traffic through +an external SOCKS5 proxy, or configure a proxy URL in your settings file at +~/.config/fence/fence.json (or ~/Library/Application Support/fence/fence.json on macOS). + +On Linux, fence uses tun2socks for truly transparent proxying: all TCP/UDP traffic +from any binary is captured at the kernel level via a TUN device and forwarded +through the external SOCKS5 proxy. No application awareness needed. + +On macOS, fence uses environment variables (best-effort) to direct traffic +to the proxy. Examples: - fence curl https://example.com # Will be blocked (no domains allowed) - fence -- curl -s https://example.com # Use -- to separate fence flags from command - fence -c "echo hello && ls" # Run with shell expansion + fence -- curl https://example.com # Blocked (no proxy) + fence --proxy socks5://localhost:1080 -- curl https://example.com # Via proxy + fence -- curl -s https://example.com # Use -- to separate flags + fence -c "echo hello && ls" # Run with shell expansion fence --settings config.json npm install - fence -t npm-install npm install # Use built-in npm-install template - fence -t ai-coding-agents -- agent-cmd # Use AI coding agents template - fence -p 3000 -c "npm run dev" # Expose port 3000 for inbound connections - fence --list-templates # Show available built-in templates + fence -p 3000 -c "npm run dev" # Expose port 3000 Configuration file format: { "network": { - "allowedDomains": ["github.com", "*.npmjs.org"], - "deniedDomains": [] + "proxyUrl": "socks5://localhost:1080" }, "filesystem": { "denyRead": [], @@ -91,10 +89,9 @@ Configuration file format: } rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") - rootCmd.Flags().BoolVarP(&monitor, "monitor", "m", false, "Monitor and log sandbox violations (macOS: log stream, all: proxy denials)") + 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().StringVarP(&templateName, "template", "t", "", "Use built-in template (e.g., ai-coding-agents, npm-install)") - rootCmd.Flags().BoolVar(&listTemplates, "list-templates", false, "List available templates") + rootCmd.Flags().StringVar(&proxyURL, "proxy", "", "External SOCKS5 proxy URL (e.g., socks5://localhost:1080)") 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") @@ -102,7 +99,6 @@ Configuration file format: rootCmd.Flags().SetInterspersed(true) - rootCmd.AddCommand(newImportCmd()) rootCmd.AddCommand(newCompletionCmd(rootCmd)) if err := rootCmd.Execute(); err != nil { @@ -126,11 +122,6 @@ func runCommand(cmd *cobra.Command, args []string) error { return nil } - if listTemplates { - printTemplates() - return nil - } - var command string switch { case cmdString != "": @@ -158,29 +149,16 @@ func runCommand(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "[fence] Exposing ports: %v\n", ports) } - // Load config: template > settings file > default path + // Load config: settings file > default path > default config var cfg *config.Config var err error switch { - case templateName != "": - cfg, err = templates.Load(templateName) - if err != nil { - return fmt.Errorf("failed to load template: %w\nUse --list-templates to see available templates", err) - } - if debug { - fmt.Fprintf(os.Stderr, "[fence] Using template: %s\n", templateName) - } case settingsPath != "": cfg, err = config.Load(settingsPath) if err != nil { return fmt.Errorf("failed to load config: %w", err) } - absPath, _ := filepath.Abs(settingsPath) - cfg, err = templates.ResolveExtendsWithBaseDir(cfg, filepath.Dir(absPath)) - if err != nil { - return fmt.Errorf("failed to resolve extends: %w", err) - } default: configPath := config.DefaultConfigPath() cfg, err = config.Load(configPath) @@ -192,14 +170,14 @@ func runCommand(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "[fence] No config found at %s, using default (block all network)\n", configPath) } cfg = config.Default() - } else { - cfg, err = templates.ResolveExtendsWithBaseDir(cfg, filepath.Dir(configPath)) - if err != nil { - return fmt.Errorf("failed to resolve extends: %w", err) - } } } + // CLI --proxy flag overrides config + if proxyURL != "" { + cfg.Network.ProxyURL = proxyURL + } + manager := sandbox.NewManager(cfg, debug, monitor) manager.SetExposedPorts(ports) defer manager.Cleanup() @@ -263,12 +241,6 @@ func runCommand(cmd *cobra.Command, args []string) error { } } - // Note: Landlock is NOT applied here because: - // 1. The sandboxed command is already running (Landlock only affects future children) - // 2. Proper Landlock integration requires applying restrictions inside the sandbox - // For now, filesystem isolation relies on bwrap mount namespaces. - // Landlock code exists for future integration (e.g., via a wrapper binary). - go func() { sigCount := 0 for sig := range sigChan { @@ -298,136 +270,6 @@ func runCommand(cmd *cobra.Command, args []string) error { return nil } -// newImportCmd creates the import subcommand. -func newImportCmd() *cobra.Command { - var ( - claudeMode bool - inputFile string - outputFile string - saveFlag bool - forceFlag bool - extendTmpl string - noExtend bool - ) - - cmd := &cobra.Command{ - Use: "import", - Short: "Import settings from other tools", - Long: `Import permission settings from other tools and convert them to fence config. - -Currently supported sources: - --claude Import from Claude Code settings - -By default, imports extend the "code" template which provides sensible defaults -for network access (npm, GitHub, LLM providers) and filesystem protections. -Use --no-extend for a minimal config, or --extend to choose a different template. - -Examples: - # Preview import (prints JSON to stdout) - fence import --claude - - # Save to the default config path - # Linux: ~/.config/fence/fence.json - # macOS: ~/Library/Application Support/fence/fence.json - fence import --claude --save - - # Save to a specific output file - fence import --claude -o ./fence.json - - # Import from a specific Claude Code settings file - fence import --claude -f ~/.claude/settings.json --save - - # Import without extending any template (minimal config) - fence import --claude --no-extend --save - - # Import and extend a different template - fence import --claude --extend local-dev-server --save`, - RunE: func(cmd *cobra.Command, args []string) error { - if !claudeMode { - return fmt.Errorf("no import source specified. Use --claude to import from Claude Code") - } - - opts := importer.DefaultImportOptions() - if noExtend { - opts.Extends = "" - } else if extendTmpl != "" { - opts.Extends = extendTmpl - } - - result, err := importer.ImportFromClaude(inputFile, opts) - if err != nil { - return fmt.Errorf("failed to import Claude settings: %w", err) - } - - for _, warning := range result.Warnings { - fmt.Fprintf(os.Stderr, "Warning: %s\n", warning) - } - if len(result.Warnings) > 0 { - fmt.Fprintln(os.Stderr) - } - - // Determine output destination - var destPath string - if saveFlag { - destPath = config.DefaultConfigPath() - } else if outputFile != "" { - destPath = outputFile - } - - if destPath != "" { - if !forceFlag { - if _, err := os.Stat(destPath); err == nil { - fmt.Printf("File %q already exists. Overwrite? [y/N] ", destPath) - reader := bufio.NewReader(os.Stdin) - response, _ := reader.ReadString('\n') - response = strings.TrimSpace(strings.ToLower(response)) - if response != "y" && response != "yes" { - fmt.Println("Aborted.") - return nil - } - } - } - - if err := os.MkdirAll(filepath.Dir(destPath), 0o750); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } - - if err := importer.WriteConfig(result.Config, destPath); err != nil { - return err - } - fmt.Printf("Imported %d rules from %s\n", result.RulesImported, result.SourcePath) - fmt.Printf("Written to %q\n", destPath) - } else { - // Print clean JSON to stdout, helpful info to stderr (don't interfere with piping) - data, err := importer.MarshalConfigJSON(result.Config) - if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) - } - fmt.Println(string(data)) - if result.Config.Extends != "" { - fmt.Fprintf(os.Stderr, "\n# Extends %q - inherited rules not shown\n", result.Config.Extends) - } - fmt.Fprintf(os.Stderr, "# Imported %d rules from %s\n", result.RulesImported, result.SourcePath) - fmt.Fprintf(os.Stderr, "# Use --save to write to the default config path\n") - } - - return nil - }, - } - - cmd.Flags().BoolVar(&claudeMode, "claude", false, "Import from Claude Code settings") - cmd.Flags().StringVarP(&inputFile, "file", "f", "", "Path to settings file (default: ~/.claude/settings.json for --claude)") - cmd.Flags().StringVarP(&outputFile, "output", "o", "", "Output file path") - cmd.Flags().BoolVar(&saveFlag, "save", false, "Save to the default config path") - cmd.Flags().BoolVarP(&forceFlag, "force", "y", false, "Overwrite existing file without prompting") - cmd.Flags().StringVar(&extendTmpl, "extend", "", "Template to extend (default: code)") - cmd.Flags().BoolVar(&noExtend, "no-extend", false, "Don't extend any template (minimal config)") - cmd.MarkFlagsMutuallyExclusive("extend", "no-extend") - cmd.MarkFlagsMutuallyExclusive("save", "output") - - return cmd -} - // newCompletionCmd creates the completion subcommand for shell completions. func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command { cmd := &cobra.Command{ @@ -473,18 +315,6 @@ ${fpath[1]}/_fence for zsh, ~/.config/fish/completions/fence.fish for fish). return cmd } -// printTemplates prints all available templates to stdout. -func printTemplates() { - fmt.Println("Available templates:") - fmt.Println() - for _, t := range templates.List() { - fmt.Printf(" %-20s %s\n", t.Name, t.Description) - } - fmt.Println() - fmt.Println("Usage: fence -t