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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -29,3 +29,6 @@ coverage.out
|
|||||||
cpu.out
|
cpu.out
|
||||||
mem.out
|
mem.out
|
||||||
|
|
||||||
|
# Embedded binaries (downloaded at build time)
|
||||||
|
internal/sandbox/bin/tun2socks-*
|
||||||
|
|
||||||
|
|||||||
62
Makefile
62
Makefile
@@ -5,88 +5,107 @@ GOTEST=$(GOCMD) test
|
|||||||
GOMOD=$(GOCMD) mod
|
GOMOD=$(GOCMD) mod
|
||||||
BINARY_NAME=fence
|
BINARY_NAME=fence
|
||||||
BINARY_UNIX=$(BINARY_NAME)_unix
|
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
|
all: build
|
||||||
|
|
||||||
build:
|
download-tun2socks:
|
||||||
@echo "🔨 Building $(BINARY_NAME)..."
|
@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
|
$(GOBUILD) -o $(BINARY_NAME) -v ./cmd/fence
|
||||||
|
|
||||||
build-ci:
|
build-ci: download-tun2socks
|
||||||
@echo "🏗️ CI: Building $(BINARY_NAME) with version info..."
|
@echo "CI: Building $(BINARY_NAME) with version info..."
|
||||||
$(eval VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev"))
|
$(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 BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ'))
|
||||||
$(eval GIT_COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown"))
|
$(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
|
$(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:
|
test:
|
||||||
@echo "🧪 Running tests..."
|
@echo "Running tests..."
|
||||||
$(GOTEST) -v ./...
|
$(GOTEST) -v ./...
|
||||||
|
|
||||||
test-ci:
|
test-ci:
|
||||||
@echo "🧪 CI: Running tests with coverage..."
|
@echo "CI: Running tests with coverage..."
|
||||||
$(GOTEST) -v -race -coverprofile=coverage.out ./...
|
$(GOTEST) -v -race -coverprofile=coverage.out ./...
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@echo "🧹 Cleaning..."
|
@echo "Cleaning..."
|
||||||
$(GOCLEAN)
|
$(GOCLEAN)
|
||||||
rm -f $(BINARY_NAME)
|
rm -f $(BINARY_NAME)
|
||||||
rm -f $(BINARY_UNIX)
|
rm -f $(BINARY_UNIX)
|
||||||
rm -f coverage.out
|
rm -f coverage.out
|
||||||
|
rm -f $(TUN2SOCKS_BIN_DIR)/tun2socks-linux-*
|
||||||
|
|
||||||
deps:
|
deps:
|
||||||
@echo "📦 Downloading dependencies..."
|
@echo "Downloading dependencies..."
|
||||||
$(GOMOD) download
|
$(GOMOD) download
|
||||||
$(GOMOD) tidy
|
$(GOMOD) tidy
|
||||||
|
|
||||||
build-linux:
|
build-linux: download-tun2socks
|
||||||
@echo "🐧 Building for Linux..."
|
@echo "Building for Linux..."
|
||||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BINARY_UNIX) -v ./cmd/fence
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BINARY_UNIX) -v ./cmd/fence
|
||||||
|
|
||||||
build-darwin:
|
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
|
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BINARY_NAME)_darwin -v ./cmd/fence
|
||||||
|
|
||||||
install-lint-tools:
|
install-lint-tools:
|
||||||
@echo "📦 Installing linting tools..."
|
@echo "Installing linting tools..."
|
||||||
go install mvdan.cc/gofumpt@latest
|
go install mvdan.cc/gofumpt@latest
|
||||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@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
|
setup: deps install-lint-tools
|
||||||
@echo "✅ Development environment ready"
|
@echo "Development environment ready"
|
||||||
|
|
||||||
setup-ci: deps install-lint-tools
|
setup-ci: deps install-lint-tools
|
||||||
@echo "✅ CI environment ready"
|
@echo "CI environment ready"
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
./$(BINARY_NAME)
|
./$(BINARY_NAME)
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
@echo "📝 Formatting code..."
|
@echo "Formatting code..."
|
||||||
gofumpt -w .
|
gofumpt -w .
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@echo "🔍 Linting code..."
|
@echo "Linting code..."
|
||||||
golangci-lint run --allow-parallel-runners
|
golangci-lint run --allow-parallel-runners
|
||||||
|
|
||||||
release:
|
release:
|
||||||
@echo "🚀 Creating patch release..."
|
@echo "Creating patch release..."
|
||||||
./scripts/release.sh patch
|
./scripts/release.sh patch
|
||||||
|
|
||||||
release-minor:
|
release-minor:
|
||||||
@echo "🚀 Creating minor release..."
|
@echo "Creating minor release..."
|
||||||
./scripts/release.sh minor
|
./scripts/release.sh minor
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Available targets:"
|
@echo "Available targets:"
|
||||||
@echo " all - build (default)"
|
@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-ci - Build for CI with version info"
|
||||||
@echo " build-linux - Build for Linux"
|
@echo " build-linux - Build for Linux"
|
||||||
@echo " build-darwin - Build for macOS"
|
@echo " build-darwin - Build for macOS"
|
||||||
|
@echo " download-tun2socks - Download tun2socks binaries for embedding"
|
||||||
@echo " test - Run tests"
|
@echo " test - Run tests"
|
||||||
@echo " test-ci - Run tests for CI with coverage"
|
@echo " test-ci - Run tests for CI with coverage"
|
||||||
@echo " clean - Clean build artifacts"
|
@echo " clean - Clean build artifacts"
|
||||||
@@ -100,4 +119,3 @@ help:
|
|||||||
@echo " release - Create patch release (v0.0.X)"
|
@echo " release - Create patch release (v0.0.X)"
|
||||||
@echo " release-minor - Create minor release (v0.X.0)"
|
@echo " release-minor - Create minor release (v0.X.0)"
|
||||||
@echo " help - Show this help"
|
@echo " help - Show this help"
|
||||||
|
|
||||||
|
|||||||
@@ -2,22 +2,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/Use-Tusk/fence/internal/config"
|
"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/platform"
|
||||||
"github.com/Use-Tusk/fence/internal/sandbox"
|
"github.com/Use-Tusk/fence/internal/sandbox"
|
||||||
"github.com/Use-Tusk/fence/internal/templates"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,8 +27,7 @@ var (
|
|||||||
debug bool
|
debug bool
|
||||||
monitor bool
|
monitor bool
|
||||||
settingsPath string
|
settingsPath string
|
||||||
templateName string
|
proxyURL string
|
||||||
listTemplates bool
|
|
||||||
cmdString string
|
cmdString string
|
||||||
exposePorts []string
|
exposePorts []string
|
||||||
exitCode int
|
exitCode int
|
||||||
@@ -55,25 +49,29 @@ func main() {
|
|||||||
Long: `fence is a command-line tool that runs commands in a sandboxed environment
|
Long: `fence is a command-line tool that runs commands in a sandboxed environment
|
||||||
with network and filesystem restrictions.
|
with network and filesystem restrictions.
|
||||||
|
|
||||||
By default, all network access is blocked. Configure allowed domains in
|
By default, all network access is blocked. Use --proxy to route traffic through
|
||||||
~/.config/fence/fence.json (or ~/Library/Application Support/fence/fence.json on macOS)
|
an external SOCKS5 proxy, or configure a proxy URL in your settings file at
|
||||||
or pass a settings file with --settings, or use a built-in template with --template.
|
~/.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:
|
Examples:
|
||||||
fence curl https://example.com # Will be blocked (no domains allowed)
|
fence -- curl https://example.com # Blocked (no proxy)
|
||||||
fence -- curl -s https://example.com # Use -- to separate fence flags from command
|
fence --proxy socks5://localhost:1080 -- curl https://example.com # Via proxy
|
||||||
fence -c "echo hello && ls" # Run with shell expansion
|
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 --settings config.json npm install
|
||||||
fence -t npm-install npm install # Use built-in npm-install template
|
fence -p 3000 -c "npm run dev" # Expose port 3000
|
||||||
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
|
|
||||||
|
|
||||||
Configuration file format:
|
Configuration file format:
|
||||||
{
|
{
|
||||||
"network": {
|
"network": {
|
||||||
"allowedDomains": ["github.com", "*.npmjs.org"],
|
"proxyUrl": "socks5://localhost:1080"
|
||||||
"deniedDomains": []
|
|
||||||
},
|
},
|
||||||
"filesystem": {
|
"filesystem": {
|
||||||
"denyRead": [],
|
"denyRead": [],
|
||||||
@@ -91,10 +89,9 @@ Configuration file format:
|
|||||||
}
|
}
|
||||||
|
|
||||||
rootCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
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(&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().StringVar(&proxyURL, "proxy", "", "External SOCKS5 proxy URL (e.g., socks5://localhost:1080)")
|
||||||
rootCmd.Flags().BoolVar(&listTemplates, "list-templates", false, "List available templates")
|
|
||||||
rootCmd.Flags().StringVarP(&cmdString, "c", "c", "", "Run command string directly (like sh -c)")
|
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().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")
|
rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "Show version information")
|
||||||
@@ -102,7 +99,6 @@ Configuration file format:
|
|||||||
|
|
||||||
rootCmd.Flags().SetInterspersed(true)
|
rootCmd.Flags().SetInterspersed(true)
|
||||||
|
|
||||||
rootCmd.AddCommand(newImportCmd())
|
|
||||||
rootCmd.AddCommand(newCompletionCmd(rootCmd))
|
rootCmd.AddCommand(newCompletionCmd(rootCmd))
|
||||||
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
@@ -126,11 +122,6 @@ func runCommand(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if listTemplates {
|
|
||||||
printTemplates()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var command string
|
var command string
|
||||||
switch {
|
switch {
|
||||||
case cmdString != "":
|
case cmdString != "":
|
||||||
@@ -158,29 +149,16 @@ func runCommand(cmd *cobra.Command, args []string) error {
|
|||||||
fmt.Fprintf(os.Stderr, "[fence] Exposing ports: %v\n", ports)
|
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 cfg *config.Config
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch {
|
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 != "":
|
case settingsPath != "":
|
||||||
cfg, err = config.Load(settingsPath)
|
cfg, err = config.Load(settingsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load config: %w", err)
|
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:
|
default:
|
||||||
configPath := config.DefaultConfigPath()
|
configPath := config.DefaultConfigPath()
|
||||||
cfg, err = config.Load(configPath)
|
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)
|
fmt.Fprintf(os.Stderr, "[fence] No config found at %s, using default (block all network)\n", configPath)
|
||||||
}
|
}
|
||||||
cfg = config.Default()
|
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 := sandbox.NewManager(cfg, debug, monitor)
|
||||||
manager.SetExposedPorts(ports)
|
manager.SetExposedPorts(ports)
|
||||||
defer manager.Cleanup()
|
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() {
|
go func() {
|
||||||
sigCount := 0
|
sigCount := 0
|
||||||
for sig := range sigChan {
|
for sig := range sigChan {
|
||||||
@@ -298,136 +270,6 @@ func runCommand(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
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.
|
// newCompletionCmd creates the completion subcommand for shell completions.
|
||||||
func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command {
|
func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@@ -473,18 +315,6 @@ ${fpath[1]}/_fence for zsh, ~/.config/fish/completions/fence.fish for fish).
|
|||||||
return cmd
|
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 <template> <command>")
|
|
||||||
fmt.Println("Example: fence -t code -- code")
|
|
||||||
}
|
|
||||||
|
|
||||||
// runLandlockWrapper runs in "wrapper mode" inside the sandbox.
|
// runLandlockWrapper runs in "wrapper mode" inside the sandbox.
|
||||||
// It applies Landlock restrictions and then execs the user command.
|
// It applies Landlock restrictions and then execs the user command.
|
||||||
// Usage: fence --landlock-apply [--debug] -- <command...>
|
// Usage: fence --landlock-apply [--debug] -- <command...>
|
||||||
|
|||||||
5
go.mod
5
go.mod
@@ -5,16 +5,11 @@ go 1.25
|
|||||||
require (
|
require (
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1
|
github.com/bmatcuk/doublestar/v4 v4.9.1
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
github.com/stretchr/testify v1.8.4
|
|
||||||
github.com/things-go/go-socks5 v0.0.5
|
|
||||||
github.com/tidwall/jsonc v0.3.2
|
github.com/tidwall/jsonc v0.3.2
|
||||||
golang.org/x/sys v0.39.0
|
golang.org/x/sys v0.39.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
12
go.sum
12
go.sum
@@ -1,28 +1,16 @@
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
github.com/things-go/go-socks5 v0.0.5 h1:qvKaGcBkfDrUL33SchHN93srAmYGzb4CxSM2DPYufe8=
|
|
||||||
github.com/things-go/go-socks5 v0.0.5/go.mod h1:mtzInf8v5xmsBpHZVbIw2YQYhc4K0jRwzfsH64Uh0IQ=
|
|
||||||
github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc=
|
github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc=
|
||||||
github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE=
|
github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE=
|
||||||
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
|
|
||||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
|
||||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -25,14 +26,11 @@ type Config struct {
|
|||||||
|
|
||||||
// NetworkConfig defines network restrictions.
|
// NetworkConfig defines network restrictions.
|
||||||
type NetworkConfig struct {
|
type NetworkConfig struct {
|
||||||
AllowedDomains []string `json:"allowedDomains"`
|
ProxyURL string `json:"proxyUrl,omitempty"` // External SOCKS5 proxy (e.g. socks5://host:1080)
|
||||||
DeniedDomains []string `json:"deniedDomains"`
|
|
||||||
AllowUnixSockets []string `json:"allowUnixSockets,omitempty"`
|
AllowUnixSockets []string `json:"allowUnixSockets,omitempty"`
|
||||||
AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"`
|
AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"`
|
||||||
AllowLocalBinding bool `json:"allowLocalBinding,omitempty"`
|
AllowLocalBinding bool `json:"allowLocalBinding,omitempty"`
|
||||||
AllowLocalOutbound *bool `json:"allowLocalOutbound,omitempty"` // If nil, defaults to AllowLocalBinding value
|
AllowLocalOutbound *bool `json:"allowLocalOutbound,omitempty"` // If nil, defaults to AllowLocalBinding value
|
||||||
HTTPProxyPort int `json:"httpProxyPort,omitempty"`
|
|
||||||
SOCKSProxyPort int `json:"socksProxyPort,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FilesystemConfig defines filesystem restrictions.
|
// FilesystemConfig defines filesystem restrictions.
|
||||||
@@ -106,13 +104,10 @@ var DefaultDeniedCommands = []string{
|
|||||||
"nsenter",
|
"nsenter",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default returns the default configuration with all network blocked.
|
// Default returns the default configuration with all network blocked (no proxy = no network).
|
||||||
func Default() *Config {
|
func Default() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Network: NetworkConfig{
|
Network: NetworkConfig{},
|
||||||
AllowedDomains: []string{},
|
|
||||||
DeniedDomains: []string{},
|
|
||||||
},
|
|
||||||
Filesystem: FilesystemConfig{
|
Filesystem: FilesystemConfig{
|
||||||
DenyRead: []string{},
|
DenyRead: []string{},
|
||||||
AllowWrite: []string{},
|
AllowWrite: []string{},
|
||||||
@@ -196,14 +191,9 @@ func Load(path string) (*Config, error) {
|
|||||||
|
|
||||||
// Validate validates the configuration.
|
// Validate validates the configuration.
|
||||||
func (c *Config) Validate() error {
|
func (c *Config) Validate() error {
|
||||||
for _, domain := range c.Network.AllowedDomains {
|
if c.Network.ProxyURL != "" {
|
||||||
if err := validateDomainPattern(domain); err != nil {
|
if err := validateProxyURL(c.Network.ProxyURL); err != nil {
|
||||||
return fmt.Errorf("invalid allowed domain %q: %w", domain, err)
|
return fmt.Errorf("invalid network.proxyUrl %q: %w", c.Network.ProxyURL, err)
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, domain := range c.Network.DeniedDomains {
|
|
||||||
if err := validateDomainPattern(domain); err != nil {
|
|
||||||
return fmt.Errorf("invalid denied domain %q: %w", domain, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,46 +243,21 @@ func (c *CommandConfig) UseDefaultDeniedCommands() bool {
|
|||||||
return c.UseDefaults == nil || *c.UseDefaults
|
return c.UseDefaults == nil || *c.UseDefaults
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateDomainPattern(pattern string) error {
|
// validateProxyURL validates a SOCKS5 proxy URL.
|
||||||
if pattern == "localhost" {
|
func validateProxyURL(proxyURL string) error {
|
||||||
return nil
|
u, err := url.Parse(proxyURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid URL: %w", err)
|
||||||
}
|
}
|
||||||
|
if u.Scheme != "socks5" && u.Scheme != "socks5h" {
|
||||||
if strings.Contains(pattern, "://") || strings.Contains(pattern, "/") || strings.Contains(pattern, ":") {
|
return errors.New("proxy URL must use socks5:// or socks5h:// scheme")
|
||||||
return errors.New("domain pattern cannot contain protocol, path, or port")
|
|
||||||
}
|
}
|
||||||
|
if u.Hostname() == "" {
|
||||||
// Handle wildcard patterns
|
return errors.New("proxy URL must include a hostname")
|
||||||
if strings.HasPrefix(pattern, "*.") {
|
|
||||||
domain := pattern[2:]
|
|
||||||
// Must have at least one more dot after the wildcard
|
|
||||||
if !strings.Contains(domain, ".") {
|
|
||||||
return errors.New("wildcard pattern too broad (e.g., *.com not allowed)")
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
|
|
||||||
return errors.New("invalid domain format")
|
|
||||||
}
|
|
||||||
// Check each part has content
|
|
||||||
parts := strings.Split(domain, ".")
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return errors.New("wildcard pattern too broad")
|
|
||||||
}
|
|
||||||
if slices.Contains(parts, "") {
|
|
||||||
return errors.New("invalid domain format")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
if u.Port() == "" {
|
||||||
// Reject other uses of wildcards
|
return errors.New("proxy URL must include a port")
|
||||||
if strings.Contains(pattern, "*") {
|
|
||||||
return errors.New("only *.domain.com wildcard patterns are allowed")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular domains must have at least one dot
|
|
||||||
if !strings.Contains(pattern, ".") || strings.HasPrefix(pattern, ".") || strings.HasSuffix(pattern, ".") {
|
|
||||||
return errors.New("invalid domain format")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,26 +297,6 @@ func isIPv6Pattern(pattern string) bool {
|
|||||||
return colonCount >= 2
|
return colonCount >= 2
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchesDomain checks if a hostname matches a domain pattern.
|
|
||||||
func MatchesDomain(hostname, pattern string) bool {
|
|
||||||
hostname = strings.ToLower(hostname)
|
|
||||||
pattern = strings.ToLower(pattern)
|
|
||||||
|
|
||||||
// "*" matches all domains
|
|
||||||
if pattern == "*" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wildcard pattern like *.example.com
|
|
||||||
if strings.HasPrefix(pattern, "*.") {
|
|
||||||
baseDomain := pattern[2:]
|
|
||||||
return strings.HasSuffix(hostname, "."+baseDomain)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exact match
|
|
||||||
return hostname == pattern
|
|
||||||
}
|
|
||||||
|
|
||||||
// MatchesHost checks if a hostname matches an SSH host pattern.
|
// MatchesHost checks if a hostname matches an SSH host pattern.
|
||||||
// SSH host patterns support wildcards anywhere in the pattern.
|
// SSH host patterns support wildcards anywhere in the pattern.
|
||||||
func MatchesHost(hostname, pattern string) bool {
|
func MatchesHost(hostname, pattern string) bool {
|
||||||
@@ -440,9 +385,10 @@ func Merge(base, override *Config) *Config {
|
|||||||
AllowPty: base.AllowPty || override.AllowPty,
|
AllowPty: base.AllowPty || override.AllowPty,
|
||||||
|
|
||||||
Network: NetworkConfig{
|
Network: NetworkConfig{
|
||||||
|
// ProxyURL: override wins if non-empty
|
||||||
|
ProxyURL: mergeString(base.Network.ProxyURL, override.Network.ProxyURL),
|
||||||
|
|
||||||
// Append slices (base first, then override additions)
|
// Append slices (base first, then override additions)
|
||||||
AllowedDomains: mergeStrings(base.Network.AllowedDomains, override.Network.AllowedDomains),
|
|
||||||
DeniedDomains: mergeStrings(base.Network.DeniedDomains, override.Network.DeniedDomains),
|
|
||||||
AllowUnixSockets: mergeStrings(base.Network.AllowUnixSockets, override.Network.AllowUnixSockets),
|
AllowUnixSockets: mergeStrings(base.Network.AllowUnixSockets, override.Network.AllowUnixSockets),
|
||||||
|
|
||||||
// Boolean fields: override wins if set, otherwise base
|
// Boolean fields: override wins if set, otherwise base
|
||||||
@@ -451,10 +397,6 @@ func Merge(base, override *Config) *Config {
|
|||||||
|
|
||||||
// Pointer fields: override wins if set, otherwise base
|
// Pointer fields: override wins if set, otherwise base
|
||||||
AllowLocalOutbound: mergeOptionalBool(base.Network.AllowLocalOutbound, override.Network.AllowLocalOutbound),
|
AllowLocalOutbound: mergeOptionalBool(base.Network.AllowLocalOutbound, override.Network.AllowLocalOutbound),
|
||||||
|
|
||||||
// Port fields: override wins if non-zero
|
|
||||||
HTTPProxyPort: mergeInt(base.Network.HTTPProxyPort, override.Network.HTTPProxyPort),
|
|
||||||
SOCKSProxyPort: mergeInt(base.Network.SOCKSProxyPort, override.Network.SOCKSProxyPort),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
Filesystem: FilesystemConfig{
|
Filesystem: FilesystemConfig{
|
||||||
@@ -531,9 +473,9 @@ func mergeOptionalBool(base, override *bool) *bool {
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeInt returns override if non-zero, otherwise base.
|
// mergeString returns override if non-empty, otherwise base.
|
||||||
func mergeInt(base, override int) int {
|
func mergeString(base, override string) string {
|
||||||
if override != 0 {
|
if override != "" {
|
||||||
return override
|
return override
|
||||||
}
|
}
|
||||||
return base
|
return base
|
||||||
|
|||||||
@@ -6,72 +6,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestValidateDomainPattern(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
pattern string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
// Valid patterns
|
|
||||||
{"valid domain", "example.com", false},
|
|
||||||
{"valid subdomain", "api.example.com", false},
|
|
||||||
{"valid wildcard", "*.example.com", false},
|
|
||||||
{"valid wildcard subdomain", "*.api.example.com", false},
|
|
||||||
{"localhost", "localhost", false},
|
|
||||||
|
|
||||||
// Invalid patterns
|
|
||||||
{"protocol included", "https://example.com", true},
|
|
||||||
{"path included", "example.com/path", true},
|
|
||||||
{"port included", "example.com:443", true},
|
|
||||||
{"wildcard too broad", "*.com", true},
|
|
||||||
{"invalid wildcard position", "example.*.com", true},
|
|
||||||
{"trailing wildcard", "example.com.*", true},
|
|
||||||
{"leading dot", ".example.com", true},
|
|
||||||
{"trailing dot", "example.com.", true},
|
|
||||||
{"no TLD", "example", true},
|
|
||||||
{"empty wildcard domain part", "*.", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
err := validateDomainPattern(tt.pattern)
|
|
||||||
if (err != nil) != tt.wantErr {
|
|
||||||
t.Errorf("validateDomainPattern(%q) error = %v, wantErr %v", tt.pattern, err, tt.wantErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMatchesDomain(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
hostname string
|
|
||||||
pattern string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
// Exact matches
|
|
||||||
{"exact match", "example.com", "example.com", true},
|
|
||||||
{"exact match case insensitive", "Example.COM", "example.com", true},
|
|
||||||
{"exact no match", "other.com", "example.com", false},
|
|
||||||
|
|
||||||
// Wildcard matches
|
|
||||||
{"wildcard match subdomain", "api.example.com", "*.example.com", true},
|
|
||||||
{"wildcard match deep subdomain", "deep.api.example.com", "*.example.com", true},
|
|
||||||
{"wildcard no match base domain", "example.com", "*.example.com", false},
|
|
||||||
{"wildcard no match different domain", "api.other.com", "*.example.com", false},
|
|
||||||
{"wildcard case insensitive", "API.Example.COM", "*.example.com", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := MatchesDomain(tt.hostname, tt.pattern)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("MatchesDomain(%q, %q) = %v, want %v", tt.hostname, tt.pattern, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConfigValidate(t *testing.T) {
|
func TestConfigValidate(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -84,29 +18,55 @@ func TestConfigValidate(t *testing.T) {
|
|||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid config with domains",
|
name: "valid config with proxy",
|
||||||
config: Config{
|
config: Config{
|
||||||
Network: NetworkConfig{
|
Network: NetworkConfig{
|
||||||
AllowedDomains: []string{"example.com", "*.github.com"},
|
ProxyURL: "socks5://localhost:1080",
|
||||||
DeniedDomains: []string{"blocked.com"},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid allowed domain",
|
name: "valid socks5h proxy",
|
||||||
config: Config{
|
config: Config{
|
||||||
Network: NetworkConfig{
|
Network: NetworkConfig{
|
||||||
AllowedDomains: []string{"https://example.com"},
|
ProxyURL: "socks5h://proxy.example.com:1080",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid proxy - wrong scheme",
|
||||||
|
config: Config{
|
||||||
|
Network: NetworkConfig{
|
||||||
|
ProxyURL: "http://localhost:1080",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid denied domain",
|
name: "invalid proxy - no port",
|
||||||
config: Config{
|
config: Config{
|
||||||
Network: NetworkConfig{
|
Network: NetworkConfig{
|
||||||
DeniedDomains: []string{"*.com"},
|
ProxyURL: "socks5://localhost",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid proxy - no host",
|
||||||
|
config: Config{
|
||||||
|
Network: NetworkConfig{
|
||||||
|
ProxyURL: "socks5://:1080",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid proxy - not a URL",
|
||||||
|
config: Config{
|
||||||
|
Network: NetworkConfig{
|
||||||
|
ProxyURL: "not-a-url",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
@@ -164,11 +124,8 @@ func TestDefault(t *testing.T) {
|
|||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
t.Fatal("Default() returned nil")
|
t.Fatal("Default() returned nil")
|
||||||
}
|
}
|
||||||
if cfg.Network.AllowedDomains == nil {
|
if cfg.Network.ProxyURL != "" {
|
||||||
t.Error("AllowedDomains should not be nil")
|
t.Error("ProxyURL should be empty by default")
|
||||||
}
|
|
||||||
if cfg.Network.DeniedDomains == nil {
|
|
||||||
t.Error("DeniedDomains should not be nil")
|
|
||||||
}
|
}
|
||||||
if cfg.Filesystem.DenyRead == nil {
|
if cfg.Filesystem.DenyRead == nil {
|
||||||
t.Error("DenyRead should not be nil")
|
t.Error("DenyRead should not be nil")
|
||||||
@@ -222,21 +179,18 @@ func TestLoad(t *testing.T) {
|
|||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid config",
|
name: "valid config with proxy",
|
||||||
setup: func(dir string) string {
|
setup: func(dir string) string {
|
||||||
path := filepath.Join(dir, "valid.json")
|
path := filepath.Join(dir, "valid.json")
|
||||||
content := `{"network":{"allowedDomains":["example.com"]}}`
|
content := `{"network":{"proxyUrl":"socks5://localhost:1080"}}`
|
||||||
_ = os.WriteFile(path, []byte(content), 0o600)
|
_ = os.WriteFile(path, []byte(content), 0o600)
|
||||||
return path
|
return path
|
||||||
},
|
},
|
||||||
wantNil: false,
|
wantNil: false,
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
checkConfig: func(t *testing.T, cfg *Config) {
|
checkConfig: func(t *testing.T, cfg *Config) {
|
||||||
if len(cfg.Network.AllowedDomains) != 1 {
|
if cfg.Network.ProxyURL != "socks5://localhost:1080" {
|
||||||
t.Errorf("expected 1 allowed domain, got %d", len(cfg.Network.AllowedDomains))
|
t.Errorf("expected socks5://localhost:1080, got %s", cfg.Network.ProxyURL)
|
||||||
}
|
|
||||||
if cfg.Network.AllowedDomains[0] != "example.com" {
|
|
||||||
t.Errorf("expected example.com, got %s", cfg.Network.AllowedDomains[0])
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -251,10 +205,10 @@ func TestLoad(t *testing.T) {
|
|||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid domain in config",
|
name: "invalid proxy URL in config",
|
||||||
setup: func(dir string) string {
|
setup: func(dir string) string {
|
||||||
path := filepath.Join(dir, "invalid_domain.json")
|
path := filepath.Join(dir, "invalid_proxy.json")
|
||||||
content := `{"network":{"allowedDomains":["*.com"]}}`
|
content := `{"network":{"proxyUrl":"http://localhost:1080"}}`
|
||||||
_ = os.WriteFile(path, []byte(content), 0o600)
|
_ = os.WriteFile(path, []byte(content), 0o600)
|
||||||
return path
|
return path
|
||||||
},
|
},
|
||||||
@@ -307,15 +261,15 @@ func TestMerge(t *testing.T) {
|
|||||||
override := &Config{
|
override := &Config{
|
||||||
AllowPty: true,
|
AllowPty: true,
|
||||||
Network: NetworkConfig{
|
Network: NetworkConfig{
|
||||||
AllowedDomains: []string{"example.com"},
|
ProxyURL: "socks5://localhost:1080",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
result := Merge(nil, override)
|
result := Merge(nil, override)
|
||||||
if !result.AllowPty {
|
if !result.AllowPty {
|
||||||
t.Error("expected AllowPty to be true")
|
t.Error("expected AllowPty to be true")
|
||||||
}
|
}
|
||||||
if len(result.Network.AllowedDomains) != 1 || result.Network.AllowedDomains[0] != "example.com" {
|
if result.Network.ProxyURL != "socks5://localhost:1080" {
|
||||||
t.Error("expected AllowedDomains to be [example.com]")
|
t.Error("expected ProxyURL to be socks5://localhost:1080")
|
||||||
}
|
}
|
||||||
if result.Extends != "" {
|
if result.Extends != "" {
|
||||||
t.Error("expected Extends to be cleared")
|
t.Error("expected Extends to be cleared")
|
||||||
@@ -326,15 +280,15 @@ func TestMerge(t *testing.T) {
|
|||||||
base := &Config{
|
base := &Config{
|
||||||
AllowPty: true,
|
AllowPty: true,
|
||||||
Network: NetworkConfig{
|
Network: NetworkConfig{
|
||||||
AllowedDomains: []string{"example.com"},
|
ProxyURL: "socks5://localhost:1080",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
result := Merge(base, nil)
|
result := Merge(base, nil)
|
||||||
if !result.AllowPty {
|
if !result.AllowPty {
|
||||||
t.Error("expected AllowPty to be true")
|
t.Error("expected AllowPty to be true")
|
||||||
}
|
}
|
||||||
if len(result.Network.AllowedDomains) != 1 {
|
if result.Network.ProxyURL != "socks5://localhost:1080" {
|
||||||
t.Error("expected AllowedDomains to be [example.com]")
|
t.Error("expected ProxyURL to be preserved")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -345,47 +299,37 @@ func TestMerge(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("merge allowed domains", func(t *testing.T) {
|
t.Run("proxy URL override wins", func(t *testing.T) {
|
||||||
base := &Config{
|
base := &Config{
|
||||||
Network: NetworkConfig{
|
Network: NetworkConfig{
|
||||||
AllowedDomains: []string{"github.com", "api.github.com"},
|
ProxyURL: "socks5://base:1080",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
override := &Config{
|
override := &Config{
|
||||||
Extends: "base-template",
|
|
||||||
Network: NetworkConfig{
|
Network: NetworkConfig{
|
||||||
AllowedDomains: []string{"private-registry.company.com"},
|
ProxyURL: "socks5://override:1080",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
result := Merge(base, override)
|
result := Merge(base, override)
|
||||||
|
|
||||||
// Should have all three domains
|
if result.Network.ProxyURL != "socks5://override:1080" {
|
||||||
if len(result.Network.AllowedDomains) != 3 {
|
t.Errorf("expected override ProxyURL, got %s", result.Network.ProxyURL)
|
||||||
t.Errorf("expected 3 allowed domains, got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extends should be cleared
|
|
||||||
if result.Extends != "" {
|
|
||||||
t.Errorf("expected Extends to be cleared, got %q", result.Extends)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("deduplicate merged domains", func(t *testing.T) {
|
t.Run("proxy URL base preserved when override empty", func(t *testing.T) {
|
||||||
base := &Config{
|
base := &Config{
|
||||||
Network: NetworkConfig{
|
Network: NetworkConfig{
|
||||||
AllowedDomains: []string{"github.com", "example.com"},
|
ProxyURL: "socks5://base:1080",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
override := &Config{
|
override := &Config{
|
||||||
Network: NetworkConfig{
|
Network: NetworkConfig{},
|
||||||
AllowedDomains: []string{"github.com", "new.com"},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
result := Merge(base, override)
|
result := Merge(base, override)
|
||||||
|
|
||||||
// Should deduplicate
|
if result.Network.ProxyURL != "socks5://base:1080" {
|
||||||
if len(result.Network.AllowedDomains) != 3 {
|
t.Errorf("expected base ProxyURL, got %s", result.Network.ProxyURL)
|
||||||
t.Errorf("expected 3 domains (deduped), got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -484,51 +428,6 @@ func TestMerge(t *testing.T) {
|
|||||||
t.Errorf("expected 2 allowRead paths, got %d: %v", len(result.Filesystem.AllowRead), result.Filesystem.AllowRead)
|
t.Errorf("expected 2 allowRead paths, got %d: %v", len(result.Filesystem.AllowRead), result.Filesystem.AllowRead)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("merge defaultDenyRead from override", func(t *testing.T) {
|
|
||||||
base := &Config{
|
|
||||||
Filesystem: FilesystemConfig{
|
|
||||||
DefaultDenyRead: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
override := &Config{
|
|
||||||
Filesystem: FilesystemConfig{
|
|
||||||
DefaultDenyRead: true,
|
|
||||||
AllowRead: []string{"/home/user/project"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
result := Merge(base, override)
|
|
||||||
|
|
||||||
if !result.Filesystem.DefaultDenyRead {
|
|
||||||
t.Error("expected DefaultDenyRead to be true (from override)")
|
|
||||||
}
|
|
||||||
if len(result.Filesystem.AllowRead) != 1 {
|
|
||||||
t.Errorf("expected 1 allowRead path, got %d", len(result.Filesystem.AllowRead))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("override ports", func(t *testing.T) {
|
|
||||||
base := &Config{
|
|
||||||
Network: NetworkConfig{
|
|
||||||
HTTPProxyPort: 8080,
|
|
||||||
SOCKSProxyPort: 1080,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
override := &Config{
|
|
||||||
Network: NetworkConfig{
|
|
||||||
HTTPProxyPort: 9090, // override
|
|
||||||
// SOCKSProxyPort not set, should keep base
|
|
||||||
},
|
|
||||||
}
|
|
||||||
result := Merge(base, override)
|
|
||||||
|
|
||||||
if result.Network.HTTPProxyPort != 9090 {
|
|
||||||
t.Errorf("expected HTTPProxyPort 9090, got %d", result.Network.HTTPProxyPort)
|
|
||||||
}
|
|
||||||
if result.Network.SOCKSProxyPort != 1080 {
|
|
||||||
t.Errorf("expected SOCKSProxyPort 1080, got %d", result.Network.SOCKSProxyPort)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func boolPtr(b bool) *bool {
|
func boolPtr(b bool) *bool {
|
||||||
@@ -741,3 +640,30 @@ func TestMergeSSHConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateProxyURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid socks5", "socks5://localhost:1080", false},
|
||||||
|
{"valid socks5h", "socks5h://proxy.example.com:1080", false},
|
||||||
|
{"valid socks5 with ip", "socks5://192.168.1.1:1080", false},
|
||||||
|
{"http scheme", "http://localhost:1080", true},
|
||||||
|
{"https scheme", "https://localhost:1080", true},
|
||||||
|
{"no port", "socks5://localhost", true},
|
||||||
|
{"no host", "socks5://:1080", true},
|
||||||
|
{"not a URL", "not-a-url", true},
|
||||||
|
{"empty", "", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := validateProxyURL(tt.url)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("validateProxyURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,444 +0,0 @@
|
|||||||
// Package importer provides functionality to import settings from other tools.
|
|
||||||
package importer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/Use-Tusk/fence/internal/config"
|
|
||||||
"github.com/tidwall/jsonc"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ClaudeSettings represents the Claude Code settings.json structure.
|
|
||||||
type ClaudeSettings struct {
|
|
||||||
Permissions ClaudePermissions `json:"permissions"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClaudePermissions represents the permissions block in Claude Code settings.
|
|
||||||
type ClaudePermissions struct {
|
|
||||||
Allow []string `json:"allow"`
|
|
||||||
Deny []string `json:"deny"`
|
|
||||||
Ask []string `json:"ask"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClaudeSettingsPaths returns the standard paths where Claude Code stores settings.
|
|
||||||
func ClaudeSettingsPaths() []string {
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
paths := []string{
|
|
||||||
filepath.Join(home, ".claude", "settings.json"),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also check project-level settings in current directory
|
|
||||||
cwd, err := os.Getwd()
|
|
||||||
if err == nil {
|
|
||||||
paths = append(paths,
|
|
||||||
filepath.Join(cwd, ".claude", "settings.json"),
|
|
||||||
filepath.Join(cwd, ".claude", "settings.local.json"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return paths
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultClaudeSettingsPath returns the default user-level Claude settings path.
|
|
||||||
func DefaultClaudeSettingsPath() string {
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return filepath.Join(home, ".claude", "settings.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadClaudeSettings loads Claude Code settings from a file.
|
|
||||||
func LoadClaudeSettings(path string) (*ClaudeSettings, error) {
|
|
||||||
data, err := os.ReadFile(path) //nolint:gosec // user-provided path - intentional
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read Claude settings: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle empty file
|
|
||||||
if len(strings.TrimSpace(string(data))) == 0 {
|
|
||||||
return &ClaudeSettings{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var settings ClaudeSettings
|
|
||||||
if err := json.Unmarshal(jsonc.ToJSON(data), &settings); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid JSON in Claude settings: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &settings, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConvertClaudeToFence converts Claude Code settings to a fence config.
|
|
||||||
func ConvertClaudeToFence(settings *ClaudeSettings) *config.Config {
|
|
||||||
cfg := config.Default()
|
|
||||||
|
|
||||||
// Process allow rules
|
|
||||||
for _, rule := range settings.Permissions.Allow {
|
|
||||||
processClaudeRule(rule, cfg, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process deny rules
|
|
||||||
for _, rule := range settings.Permissions.Deny {
|
|
||||||
processClaudeRule(rule, cfg, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process ask rules (treat as deny for fence, since fence doesn't have interactive prompts)
|
|
||||||
// Users can review and move to allow if needed
|
|
||||||
for _, rule := range settings.Permissions.Ask {
|
|
||||||
processClaudeRule(rule, cfg, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
// bashPattern matches Bash permission rules like "Bash(npm run test:*)" or "Bash(curl:*)"
|
|
||||||
var bashPattern = regexp.MustCompile(`^Bash\((.+)\)$`)
|
|
||||||
|
|
||||||
// readPattern matches Read permission rules like "Read(./.env)" or "Read(./secrets/**)"
|
|
||||||
var readPattern = regexp.MustCompile(`^Read\((.+)\)$`)
|
|
||||||
|
|
||||||
// writePattern matches Write permission rules like "Write(./output/**)"
|
|
||||||
var writePattern = regexp.MustCompile(`^Write\((.+)\)$`)
|
|
||||||
|
|
||||||
// editPattern matches Edit permission rules (similar to Write)
|
|
||||||
var editPattern = regexp.MustCompile(`^Edit\((.+)\)$`)
|
|
||||||
|
|
||||||
// processClaudeRule processes a single Claude permission rule and updates the fence config.
|
|
||||||
func processClaudeRule(rule string, cfg *config.Config, isAllow bool) {
|
|
||||||
rule = strings.TrimSpace(rule)
|
|
||||||
if rule == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Bash(command) rules
|
|
||||||
if matches := bashPattern.FindStringSubmatch(rule); len(matches) == 2 {
|
|
||||||
cmd := normalizeClaudeCommand(matches[1])
|
|
||||||
if cmd != "" {
|
|
||||||
if isAllow {
|
|
||||||
cfg.Command.Allow = appendUnique(cfg.Command.Allow, cmd)
|
|
||||||
} else {
|
|
||||||
cfg.Command.Deny = appendUnique(cfg.Command.Deny, cmd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Read(path) rules
|
|
||||||
if matches := readPattern.FindStringSubmatch(rule); len(matches) == 2 {
|
|
||||||
path := normalizeClaudePath(matches[1])
|
|
||||||
if path != "" {
|
|
||||||
if !isAllow {
|
|
||||||
// Read deny -> filesystem.denyRead
|
|
||||||
cfg.Filesystem.DenyRead = appendUnique(cfg.Filesystem.DenyRead, path)
|
|
||||||
}
|
|
||||||
// Note: fence doesn't have an "allowRead" concept - everything is readable by default
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Write(path) rules
|
|
||||||
if matches := writePattern.FindStringSubmatch(rule); len(matches) == 2 {
|
|
||||||
path := normalizeClaudePath(matches[1])
|
|
||||||
if path != "" {
|
|
||||||
if isAllow {
|
|
||||||
cfg.Filesystem.AllowWrite = appendUnique(cfg.Filesystem.AllowWrite, path)
|
|
||||||
} else {
|
|
||||||
cfg.Filesystem.DenyWrite = appendUnique(cfg.Filesystem.DenyWrite, path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Edit(path) rules (same as Write)
|
|
||||||
if matches := editPattern.FindStringSubmatch(rule); len(matches) == 2 {
|
|
||||||
path := normalizeClaudePath(matches[1])
|
|
||||||
if path != "" {
|
|
||||||
if isAllow {
|
|
||||||
cfg.Filesystem.AllowWrite = appendUnique(cfg.Filesystem.AllowWrite, path)
|
|
||||||
} else {
|
|
||||||
cfg.Filesystem.DenyWrite = appendUnique(cfg.Filesystem.DenyWrite, path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle bare tool names (e.g., "Read", "Write", "Bash")
|
|
||||||
// These are global permissions that don't map directly to fence's path-based model
|
|
||||||
// We skip them as they don't provide actionable path/command restrictions
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeClaudeCommand converts Claude's command format to fence format.
|
|
||||||
// Claude uses "npm:*" style, fence uses "npm" for prefix matching.
|
|
||||||
func normalizeClaudeCommand(cmd string) string {
|
|
||||||
cmd = strings.TrimSpace(cmd)
|
|
||||||
|
|
||||||
// Handle wildcard patterns like "npm:*" -> "npm"
|
|
||||||
// Claude uses ":" as separator, fence uses space-separated commands
|
|
||||||
// Also handles "npm run test:*" -> "npm run test"
|
|
||||||
cmd = strings.TrimSuffix(cmd, ":*")
|
|
||||||
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeClaudePath converts Claude's path format to fence format.
|
|
||||||
func normalizeClaudePath(path string) string {
|
|
||||||
path = strings.TrimSpace(path)
|
|
||||||
|
|
||||||
// Claude uses ./ prefix for relative paths, fence doesn't require it
|
|
||||||
// but fence does support it, so we can keep it
|
|
||||||
|
|
||||||
// Convert ** glob patterns - both Claude and fence support these
|
|
||||||
// No conversion needed
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
// appendUnique appends a value to a slice if it's not already present.
|
|
||||||
func appendUnique(slice []string, value string) []string {
|
|
||||||
for _, v := range slice {
|
|
||||||
if v == value {
|
|
||||||
return slice
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return append(slice, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImportResult contains the result of an import operation.
|
|
||||||
type ImportResult struct {
|
|
||||||
Config *config.Config
|
|
||||||
SourcePath string
|
|
||||||
RulesImported int
|
|
||||||
Warnings []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImportOptions configures the import behavior.
|
|
||||||
type ImportOptions struct {
|
|
||||||
// Extends specifies a template or file to extend. Empty string means no extends.
|
|
||||||
Extends string
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultImportOptions returns the default import options.
|
|
||||||
// By default, imports extend the "code" template for sensible defaults.
|
|
||||||
func DefaultImportOptions() ImportOptions {
|
|
||||||
return ImportOptions{
|
|
||||||
Extends: "code",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImportFromClaude imports settings from Claude Code and returns a fence config.
|
|
||||||
// If path is empty, it tries the default Claude settings path.
|
|
||||||
func ImportFromClaude(path string, opts ImportOptions) (*ImportResult, error) {
|
|
||||||
if path == "" {
|
|
||||||
path = DefaultClaudeSettingsPath()
|
|
||||||
}
|
|
||||||
|
|
||||||
if path == "" {
|
|
||||||
return nil, fmt.Errorf("could not determine Claude settings path")
|
|
||||||
}
|
|
||||||
|
|
||||||
settings, err := LoadClaudeSettings(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := ConvertClaudeToFence(settings)
|
|
||||||
|
|
||||||
// Set extends if specified
|
|
||||||
if opts.Extends != "" {
|
|
||||||
cfg.Extends = opts.Extends
|
|
||||||
}
|
|
||||||
|
|
||||||
result := &ImportResult{
|
|
||||||
Config: cfg,
|
|
||||||
SourcePath: path,
|
|
||||||
RulesImported: len(settings.Permissions.Allow) +
|
|
||||||
len(settings.Permissions.Deny) +
|
|
||||||
len(settings.Permissions.Ask),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add warnings for rules that couldn't be fully converted
|
|
||||||
for _, rule := range settings.Permissions.Allow {
|
|
||||||
if isGlobalToolRule(rule) {
|
|
||||||
result.Warnings = append(result.Warnings,
|
|
||||||
fmt.Sprintf("Global tool permission %q skipped (fence uses path/command-based rules)", rule))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, rule := range settings.Permissions.Deny {
|
|
||||||
if isGlobalToolRule(rule) {
|
|
||||||
result.Warnings = append(result.Warnings,
|
|
||||||
fmt.Sprintf("Global tool permission %q skipped (fence uses path/command-based rules)", rule))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, rule := range settings.Permissions.Ask {
|
|
||||||
if isGlobalToolRule(rule) {
|
|
||||||
result.Warnings = append(result.Warnings,
|
|
||||||
fmt.Sprintf("Global tool permission %q skipped (fence uses path/command-based rules)", rule))
|
|
||||||
} else {
|
|
||||||
result.Warnings = append(result.Warnings,
|
|
||||||
fmt.Sprintf("Ask rule %q converted to deny (fence doesn't support interactive prompts)", rule))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// isGlobalToolRule checks if a rule is a global tool permission (no path/command specified).
|
|
||||||
func isGlobalToolRule(rule string) bool {
|
|
||||||
rule = strings.TrimSpace(rule)
|
|
||||||
// Global rules are bare tool names without parentheses
|
|
||||||
return !strings.Contains(rule, "(")
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanNetworkConfig is used for JSON output with omitempty to skip empty fields.
|
|
||||||
type cleanNetworkConfig struct {
|
|
||||||
AllowedDomains []string `json:"allowedDomains,omitempty"`
|
|
||||||
DeniedDomains []string `json:"deniedDomains,omitempty"`
|
|
||||||
AllowUnixSockets []string `json:"allowUnixSockets,omitempty"`
|
|
||||||
AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"`
|
|
||||||
AllowLocalBinding bool `json:"allowLocalBinding,omitempty"`
|
|
||||||
AllowLocalOutbound *bool `json:"allowLocalOutbound,omitempty"`
|
|
||||||
HTTPProxyPort int `json:"httpProxyPort,omitempty"`
|
|
||||||
SOCKSProxyPort int `json:"socksProxyPort,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanFilesystemConfig is used for JSON output with omitempty to skip empty fields.
|
|
||||||
type cleanFilesystemConfig struct {
|
|
||||||
DenyRead []string `json:"denyRead,omitempty"`
|
|
||||||
AllowWrite []string `json:"allowWrite,omitempty"`
|
|
||||||
DenyWrite []string `json:"denyWrite,omitempty"`
|
|
||||||
AllowGitConfig bool `json:"allowGitConfig,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanCommandConfig is used for JSON output with omitempty to skip empty fields.
|
|
||||||
type cleanCommandConfig struct {
|
|
||||||
Deny []string `json:"deny,omitempty"`
|
|
||||||
Allow []string `json:"allow,omitempty"`
|
|
||||||
UseDefaults *bool `json:"useDefaults,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// cleanConfig is used for JSON output with fields in desired order and omitempty.
|
|
||||||
type cleanConfig struct {
|
|
||||||
Extends string `json:"extends,omitempty"`
|
|
||||||
AllowPty bool `json:"allowPty,omitempty"`
|
|
||||||
Network *cleanNetworkConfig `json:"network,omitempty"`
|
|
||||||
Filesystem *cleanFilesystemConfig `json:"filesystem,omitempty"`
|
|
||||||
Command *cleanCommandConfig `json:"command,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalConfigJSON marshals a fence config to clean JSON, omitting empty arrays
|
|
||||||
// and with fields in a logical order (extends first).
|
|
||||||
func MarshalConfigJSON(cfg *config.Config) ([]byte, error) {
|
|
||||||
clean := cleanConfig{
|
|
||||||
Extends: cfg.Extends,
|
|
||||||
AllowPty: cfg.AllowPty,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Network config - only include if non-empty
|
|
||||||
network := cleanNetworkConfig{
|
|
||||||
AllowedDomains: cfg.Network.AllowedDomains,
|
|
||||||
DeniedDomains: cfg.Network.DeniedDomains,
|
|
||||||
AllowUnixSockets: cfg.Network.AllowUnixSockets,
|
|
||||||
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
|
||||||
AllowLocalBinding: cfg.Network.AllowLocalBinding,
|
|
||||||
AllowLocalOutbound: cfg.Network.AllowLocalOutbound,
|
|
||||||
HTTPProxyPort: cfg.Network.HTTPProxyPort,
|
|
||||||
SOCKSProxyPort: cfg.Network.SOCKSProxyPort,
|
|
||||||
}
|
|
||||||
if !isNetworkEmpty(network) {
|
|
||||||
clean.Network = &network
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filesystem config - only include if non-empty
|
|
||||||
filesystem := cleanFilesystemConfig{
|
|
||||||
DenyRead: cfg.Filesystem.DenyRead,
|
|
||||||
AllowWrite: cfg.Filesystem.AllowWrite,
|
|
||||||
DenyWrite: cfg.Filesystem.DenyWrite,
|
|
||||||
AllowGitConfig: cfg.Filesystem.AllowGitConfig,
|
|
||||||
}
|
|
||||||
if !isFilesystemEmpty(filesystem) {
|
|
||||||
clean.Filesystem = &filesystem
|
|
||||||
}
|
|
||||||
|
|
||||||
// Command config - only include if non-empty
|
|
||||||
command := cleanCommandConfig{
|
|
||||||
Deny: cfg.Command.Deny,
|
|
||||||
Allow: cfg.Command.Allow,
|
|
||||||
UseDefaults: cfg.Command.UseDefaults,
|
|
||||||
}
|
|
||||||
if !isCommandEmpty(command) {
|
|
||||||
clean.Command = &command
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.MarshalIndent(clean, "", " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func isNetworkEmpty(n cleanNetworkConfig) bool {
|
|
||||||
return len(n.AllowedDomains) == 0 &&
|
|
||||||
len(n.DeniedDomains) == 0 &&
|
|
||||||
len(n.AllowUnixSockets) == 0 &&
|
|
||||||
!n.AllowAllUnixSockets &&
|
|
||||||
!n.AllowLocalBinding &&
|
|
||||||
n.AllowLocalOutbound == nil &&
|
|
||||||
n.HTTPProxyPort == 0 &&
|
|
||||||
n.SOCKSProxyPort == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func isFilesystemEmpty(f cleanFilesystemConfig) bool {
|
|
||||||
return len(f.DenyRead) == 0 &&
|
|
||||||
len(f.AllowWrite) == 0 &&
|
|
||||||
len(f.DenyWrite) == 0 &&
|
|
||||||
!f.AllowGitConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
func isCommandEmpty(c cleanCommandConfig) bool {
|
|
||||||
return len(c.Deny) == 0 &&
|
|
||||||
len(c.Allow) == 0 &&
|
|
||||||
c.UseDefaults == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatConfigWithComment returns the config JSON with a comment header
|
|
||||||
// explaining that values are inherited from the extended template.
|
|
||||||
func FormatConfigWithComment(cfg *config.Config) (string, error) {
|
|
||||||
data, err := MarshalConfigJSON(cfg)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var output strings.Builder
|
|
||||||
|
|
||||||
// Add comment about inherited values if extending a template
|
|
||||||
if cfg.Extends != "" {
|
|
||||||
output.WriteString(fmt.Sprintf("// This config extends %q.\n", cfg.Extends))
|
|
||||||
output.WriteString(fmt.Sprintf("// Network, filesystem, and command rules from %q are inherited.\n", cfg.Extends))
|
|
||||||
output.WriteString("// Only your additional rules are shown below.\n")
|
|
||||||
output.WriteString("// Run `fence --list-templates` to see available templates.\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
output.Write(data)
|
|
||||||
output.WriteByte('\n')
|
|
||||||
|
|
||||||
return output.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteConfig writes a fence config to a file.
|
|
||||||
func WriteConfig(cfg *config.Config, path string) error {
|
|
||||||
output, err := FormatConfigWithComment(cfg)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to marshal config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(path, []byte(output), 0o644); err != nil { //nolint:gosec // config file permissions
|
|
||||||
return fmt.Errorf("failed to write config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,581 +0,0 @@
|
|||||||
package importer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/Use-Tusk/fence/internal/config"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestConvertClaudeToFence(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
settings *ClaudeSettings
|
|
||||||
wantCmd struct {
|
|
||||||
allow []string
|
|
||||||
deny []string
|
|
||||||
}
|
|
||||||
wantFS struct {
|
|
||||||
denyRead []string
|
|
||||||
allowWrite []string
|
|
||||||
denyWrite []string
|
|
||||||
}
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty settings",
|
|
||||||
settings: &ClaudeSettings{
|
|
||||||
Permissions: ClaudePermissions{},
|
|
||||||
},
|
|
||||||
wantCmd: struct {
|
|
||||||
allow []string
|
|
||||||
deny []string
|
|
||||||
}{},
|
|
||||||
wantFS: struct {
|
|
||||||
denyRead []string
|
|
||||||
allowWrite []string
|
|
||||||
denyWrite []string
|
|
||||||
}{},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "bash allow rules",
|
|
||||||
settings: &ClaudeSettings{
|
|
||||||
Permissions: ClaudePermissions{
|
|
||||||
Allow: []string{
|
|
||||||
"Bash(npm run lint)",
|
|
||||||
"Bash(npm run test:*)",
|
|
||||||
"Bash(git status)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
wantCmd: struct {
|
|
||||||
allow []string
|
|
||||||
deny []string
|
|
||||||
}{
|
|
||||||
allow: []string{"npm run lint", "npm run test", "git status"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "bash deny rules",
|
|
||||||
settings: &ClaudeSettings{
|
|
||||||
Permissions: ClaudePermissions{
|
|
||||||
Deny: []string{
|
|
||||||
"Bash(curl:*)",
|
|
||||||
"Bash(sudo:*)",
|
|
||||||
"Bash(rm -rf /)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
wantCmd: struct {
|
|
||||||
allow []string
|
|
||||||
deny []string
|
|
||||||
}{
|
|
||||||
deny: []string{"curl", "sudo", "rm -rf /"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "read deny rules",
|
|
||||||
settings: &ClaudeSettings{
|
|
||||||
Permissions: ClaudePermissions{
|
|
||||||
Deny: []string{
|
|
||||||
"Read(./.env)",
|
|
||||||
"Read(./secrets/**)",
|
|
||||||
"Read(~/.ssh/*)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
wantFS: struct {
|
|
||||||
denyRead []string
|
|
||||||
allowWrite []string
|
|
||||||
denyWrite []string
|
|
||||||
}{
|
|
||||||
denyRead: []string{"./.env", "./secrets/**", "~/.ssh/*"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "write allow rules",
|
|
||||||
settings: &ClaudeSettings{
|
|
||||||
Permissions: ClaudePermissions{
|
|
||||||
Allow: []string{
|
|
||||||
"Write(./output/**)",
|
|
||||||
"Write(./build)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
wantFS: struct {
|
|
||||||
denyRead []string
|
|
||||||
allowWrite []string
|
|
||||||
denyWrite []string
|
|
||||||
}{
|
|
||||||
allowWrite: []string{"./output/**", "./build"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "write deny rules",
|
|
||||||
settings: &ClaudeSettings{
|
|
||||||
Permissions: ClaudePermissions{
|
|
||||||
Deny: []string{
|
|
||||||
"Write(./.git/**)",
|
|
||||||
"Edit(./package-lock.json)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
wantFS: struct {
|
|
||||||
denyRead []string
|
|
||||||
allowWrite []string
|
|
||||||
denyWrite []string
|
|
||||||
}{
|
|
||||||
denyWrite: []string{"./.git/**", "./package-lock.json"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ask rules converted to deny",
|
|
||||||
settings: &ClaudeSettings{
|
|
||||||
Permissions: ClaudePermissions{
|
|
||||||
Ask: []string{
|
|
||||||
"Write(./config.json)",
|
|
||||||
"Bash(npm publish)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
wantCmd: struct {
|
|
||||||
allow []string
|
|
||||||
deny []string
|
|
||||||
}{
|
|
||||||
deny: []string{"npm publish"},
|
|
||||||
},
|
|
||||||
wantFS: struct {
|
|
||||||
denyRead []string
|
|
||||||
allowWrite []string
|
|
||||||
denyWrite []string
|
|
||||||
}{
|
|
||||||
denyWrite: []string{"./config.json"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "global tool rules are skipped",
|
|
||||||
settings: &ClaudeSettings{
|
|
||||||
Permissions: ClaudePermissions{
|
|
||||||
Allow: []string{
|
|
||||||
"Read",
|
|
||||||
"Grep",
|
|
||||||
"LS",
|
|
||||||
"Bash(npm run build)", // This should be included
|
|
||||||
},
|
|
||||||
Deny: []string{
|
|
||||||
"Edit",
|
|
||||||
"Bash(sudo:*)", // This should be included
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
wantCmd: struct {
|
|
||||||
allow []string
|
|
||||||
deny []string
|
|
||||||
}{
|
|
||||||
allow: []string{"npm run build"},
|
|
||||||
deny: []string{"sudo"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mixed rules",
|
|
||||||
settings: &ClaudeSettings{
|
|
||||||
Permissions: ClaudePermissions{
|
|
||||||
Allow: []string{
|
|
||||||
"Bash(npm install)",
|
|
||||||
"Bash(npm run:*)",
|
|
||||||
"Write(./dist/**)",
|
|
||||||
},
|
|
||||||
Deny: []string{
|
|
||||||
"Bash(curl:*)",
|
|
||||||
"Read(./.env)",
|
|
||||||
"Write(./.git/**)",
|
|
||||||
},
|
|
||||||
Ask: []string{
|
|
||||||
"Bash(git push)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
wantCmd: struct {
|
|
||||||
allow []string
|
|
||||||
deny []string
|
|
||||||
}{
|
|
||||||
allow: []string{"npm install", "npm run"},
|
|
||||||
deny: []string{"curl", "git push"},
|
|
||||||
},
|
|
||||||
wantFS: struct {
|
|
||||||
denyRead []string
|
|
||||||
allowWrite []string
|
|
||||||
denyWrite []string
|
|
||||||
}{
|
|
||||||
denyRead: []string{"./.env"},
|
|
||||||
allowWrite: []string{"./dist/**"},
|
|
||||||
denyWrite: []string{"./.git/**"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
cfg := ConvertClaudeToFence(tt.settings)
|
|
||||||
|
|
||||||
assert.ElementsMatch(t, tt.wantCmd.allow, cfg.Command.Allow, "command.allow mismatch")
|
|
||||||
assert.ElementsMatch(t, tt.wantCmd.deny, cfg.Command.Deny, "command.deny mismatch")
|
|
||||||
assert.ElementsMatch(t, tt.wantFS.denyRead, cfg.Filesystem.DenyRead, "filesystem.denyRead mismatch")
|
|
||||||
assert.ElementsMatch(t, tt.wantFS.allowWrite, cfg.Filesystem.AllowWrite, "filesystem.allowWrite mismatch")
|
|
||||||
assert.ElementsMatch(t, tt.wantFS.denyWrite, cfg.Filesystem.DenyWrite, "filesystem.denyWrite mismatch")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalizeClaudeCommand(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"npm:*", "npm"},
|
|
||||||
{"curl:*", "curl"},
|
|
||||||
{"npm run test:*", "npm run test"},
|
|
||||||
{"git status", "git status"},
|
|
||||||
{"sudo rm -rf", "sudo rm -rf"},
|
|
||||||
{"", ""},
|
|
||||||
{" npm ", "npm"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.input, func(t *testing.T) {
|
|
||||||
got := normalizeClaudeCommand(tt.input)
|
|
||||||
assert.Equal(t, tt.want, got)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadClaudeSettings(t *testing.T) {
|
|
||||||
t.Run("valid settings", func(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
settingsPath := filepath.Join(tmpDir, "settings.json")
|
|
||||||
|
|
||||||
content := `{
|
|
||||||
"permissions": {
|
|
||||||
"allow": ["Bash(npm install)", "Read"],
|
|
||||||
"deny": ["Bash(sudo:*)"],
|
|
||||||
"ask": ["Write"]
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
settings, err := LoadClaudeSettings(settingsPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, []string{"Bash(npm install)", "Read"}, settings.Permissions.Allow)
|
|
||||||
assert.Equal(t, []string{"Bash(sudo:*)"}, settings.Permissions.Deny)
|
|
||||||
assert.Equal(t, []string{"Write"}, settings.Permissions.Ask)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("settings with comments (JSONC)", func(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
settingsPath := filepath.Join(tmpDir, "settings.json")
|
|
||||||
|
|
||||||
content := `{
|
|
||||||
// This is a comment
|
|
||||||
"permissions": {
|
|
||||||
"allow": ["Bash(npm install)"],
|
|
||||||
"deny": [], // Another comment
|
|
||||||
"ask": []
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
settings, err := LoadClaudeSettings(settingsPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, []string{"Bash(npm install)"}, settings.Permissions.Allow)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("empty file", func(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
settingsPath := filepath.Join(tmpDir, "settings.json")
|
|
||||||
|
|
||||||
err := os.WriteFile(settingsPath, []byte(""), 0o600) //nolint:gosec // test file
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
settings, err := LoadClaudeSettings(settingsPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.NotNil(t, settings)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("file not found", func(t *testing.T) {
|
|
||||||
_, err := LoadClaudeSettings("/nonexistent/path/settings.json")
|
|
||||||
assert.Error(t, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("invalid json", func(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
settingsPath := filepath.Join(tmpDir, "settings.json")
|
|
||||||
|
|
||||||
err := os.WriteFile(settingsPath, []byte("not json"), 0o600) //nolint:gosec // test file
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = LoadClaudeSettings(settingsPath)
|
|
||||||
assert.Error(t, err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestImportFromClaude(t *testing.T) {
|
|
||||||
t.Run("successful import with default extends", func(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
settingsPath := filepath.Join(tmpDir, "settings.json")
|
|
||||||
|
|
||||||
content := `{
|
|
||||||
"permissions": {
|
|
||||||
"allow": ["Bash(npm install)", "Write(./dist/**)"],
|
|
||||||
"deny": ["Bash(curl:*)", "Read(./.env)"],
|
|
||||||
"ask": ["Bash(git push)"]
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
result, err := ImportFromClaude(settingsPath, DefaultImportOptions())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, settingsPath, result.SourcePath)
|
|
||||||
assert.Equal(t, 5, result.RulesImported)
|
|
||||||
assert.Equal(t, "code", result.Config.Extends) // default extends
|
|
||||||
|
|
||||||
// Check converted config
|
|
||||||
assert.Contains(t, result.Config.Command.Allow, "npm install")
|
|
||||||
assert.Contains(t, result.Config.Command.Deny, "curl")
|
|
||||||
assert.Contains(t, result.Config.Command.Deny, "git push") // ask -> deny
|
|
||||||
assert.Contains(t, result.Config.Filesystem.AllowWrite, "./dist/**")
|
|
||||||
assert.Contains(t, result.Config.Filesystem.DenyRead, "./.env")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("import with no extend", func(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
settingsPath := filepath.Join(tmpDir, "settings.json")
|
|
||||||
|
|
||||||
content := `{
|
|
||||||
"permissions": {
|
|
||||||
"allow": ["Bash(npm install)"],
|
|
||||||
"deny": [],
|
|
||||||
"ask": []
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
opts := ImportOptions{Extends: ""}
|
|
||||||
result, err := ImportFromClaude(settingsPath, opts)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, "", result.Config.Extends) // no extends
|
|
||||||
assert.Contains(t, result.Config.Command.Allow, "npm install")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("import with custom extend", func(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
settingsPath := filepath.Join(tmpDir, "settings.json")
|
|
||||||
|
|
||||||
content := `{
|
|
||||||
"permissions": {
|
|
||||||
"allow": ["Bash(npm install)"],
|
|
||||||
"deny": [],
|
|
||||||
"ask": []
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
opts := ImportOptions{Extends: "local-dev-server"}
|
|
||||||
result, err := ImportFromClaude(settingsPath, opts)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, "local-dev-server", result.Config.Extends)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("warnings for global rules", func(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
settingsPath := filepath.Join(tmpDir, "settings.json")
|
|
||||||
|
|
||||||
content := `{
|
|
||||||
"permissions": {
|
|
||||||
"allow": ["Read", "Grep", "Bash(npm install)"],
|
|
||||||
"deny": ["Edit"],
|
|
||||||
"ask": ["Write"]
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
err := os.WriteFile(settingsPath, []byte(content), 0o600) //nolint:gosec // test file
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
result, err := ImportFromClaude(settingsPath, DefaultImportOptions())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Should have warnings for global rules: Read, Grep, Edit, Write (all global)
|
|
||||||
assert.Len(t, result.Warnings, 4)
|
|
||||||
|
|
||||||
// Verify the warnings mention the right rules
|
|
||||||
warningsStr := strings.Join(result.Warnings, " ")
|
|
||||||
assert.Contains(t, warningsStr, "Read")
|
|
||||||
assert.Contains(t, warningsStr, "Grep")
|
|
||||||
assert.Contains(t, warningsStr, "Edit")
|
|
||||||
assert.Contains(t, warningsStr, "Write")
|
|
||||||
assert.Contains(t, warningsStr, "skipped")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteConfig(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
outputPath := filepath.Join(tmpDir, "fence.json")
|
|
||||||
|
|
||||||
cfg := &config.Config{}
|
|
||||||
cfg.Command.Allow = []string{"npm install"}
|
|
||||||
cfg.Command.Deny = []string{"curl"}
|
|
||||||
cfg.Filesystem.DenyRead = []string{"./.env"}
|
|
||||||
|
|
||||||
err := WriteConfig(cfg, outputPath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify the file was written correctly
|
|
||||||
data, err := os.ReadFile(outputPath) //nolint:gosec // test reads file we just wrote
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Contains(t, string(data), `"npm install"`)
|
|
||||||
assert.Contains(t, string(data), `"curl"`)
|
|
||||||
assert.Contains(t, string(data), `"./.env"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMarshalConfigJSON(t *testing.T) {
|
|
||||||
t.Run("omits empty arrays", func(t *testing.T) {
|
|
||||||
cfg := &config.Config{}
|
|
||||||
cfg.Command.Allow = []string{"npm install"}
|
|
||||||
// Leave all other arrays empty
|
|
||||||
|
|
||||||
data, err := MarshalConfigJSON(cfg)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
output := string(data)
|
|
||||||
assert.Contains(t, output, `"npm install"`)
|
|
||||||
assert.NotContains(t, output, `"allowedDomains"`)
|
|
||||||
assert.NotContains(t, output, `"deniedDomains"`)
|
|
||||||
assert.NotContains(t, output, `"denyRead"`)
|
|
||||||
assert.NotContains(t, output, `"allowWrite"`)
|
|
||||||
assert.NotContains(t, output, `"denyWrite"`)
|
|
||||||
assert.NotContains(t, output, `"network"`) // entire network section should be omitted
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("includes extends field", func(t *testing.T) {
|
|
||||||
cfg := &config.Config{}
|
|
||||||
cfg.Extends = "code"
|
|
||||||
cfg.Command.Allow = []string{"npm install"}
|
|
||||||
|
|
||||||
data, err := MarshalConfigJSON(cfg)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
output := string(data)
|
|
||||||
assert.Contains(t, output, `"extends": "code"`)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("includes non-empty arrays", func(t *testing.T) {
|
|
||||||
cfg := &config.Config{}
|
|
||||||
cfg.Network.AllowedDomains = []string{"example.com"}
|
|
||||||
cfg.Filesystem.DenyRead = []string{".env"}
|
|
||||||
cfg.Command.Deny = []string{"sudo"}
|
|
||||||
|
|
||||||
data, err := MarshalConfigJSON(cfg)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
output := string(data)
|
|
||||||
assert.Contains(t, output, `"example.com"`)
|
|
||||||
assert.Contains(t, output, `".env"`)
|
|
||||||
assert.Contains(t, output, `"sudo"`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFormatConfigWithComment(t *testing.T) {
|
|
||||||
t.Run("adds comment when extends is set", func(t *testing.T) {
|
|
||||||
cfg := &config.Config{}
|
|
||||||
cfg.Extends = "code"
|
|
||||||
cfg.Command.Allow = []string{"npm install"}
|
|
||||||
|
|
||||||
output, err := FormatConfigWithComment(cfg)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Contains(t, output, `// This config extends "code".`)
|
|
||||||
assert.Contains(t, output, `// Network, filesystem, and command rules from "code" are inherited.`)
|
|
||||||
assert.Contains(t, output, `"npm install"`)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("no comment when extends is empty", func(t *testing.T) {
|
|
||||||
cfg := &config.Config{}
|
|
||||||
cfg.Command.Allow = []string{"npm install"}
|
|
||||||
|
|
||||||
output, err := FormatConfigWithComment(cfg)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.NotContains(t, output, "//")
|
|
||||||
assert.Contains(t, output, `"npm install"`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsGlobalToolRule(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
rule string
|
|
||||||
expected bool
|
|
||||||
}{
|
|
||||||
{"Read", true},
|
|
||||||
{"Write", true},
|
|
||||||
{"Grep", true},
|
|
||||||
{"LS", true},
|
|
||||||
{"Bash", true},
|
|
||||||
{"Read(./.env)", false},
|
|
||||||
{"Write(./dist/**)", false},
|
|
||||||
{"Bash(npm install)", false},
|
|
||||||
{"Bash(curl:*)", false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.rule, func(t *testing.T) {
|
|
||||||
assert.Equal(t, tt.expected, isGlobalToolRule(tt.rule))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAppendUnique(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
slice []string
|
|
||||||
value string
|
|
||||||
expected []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "append to empty",
|
|
||||||
slice: []string{},
|
|
||||||
value: "a",
|
|
||||||
expected: []string{"a"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "append new value",
|
|
||||||
slice: []string{"a", "b"},
|
|
||||||
value: "c",
|
|
||||||
expected: []string{"a", "b", "c"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "skip duplicate",
|
|
||||||
slice: []string{"a", "b"},
|
|
||||||
value: "a",
|
|
||||||
expected: []string{"a", "b"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := appendUnique(tt.slice, tt.value)
|
|
||||||
assert.Equal(t, tt.expected, result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
// Package proxy provides HTTP and SOCKS5 proxy servers with domain filtering.
|
|
||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/Use-Tusk/fence/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FilterFunc determines if a connection to host:port should be allowed.
|
|
||||||
type FilterFunc func(host string, port int) bool
|
|
||||||
|
|
||||||
// HTTPProxy is an HTTP/HTTPS proxy server with domain filtering.
|
|
||||||
type HTTPProxy struct {
|
|
||||||
server *http.Server
|
|
||||||
listener net.Listener
|
|
||||||
filter FilterFunc
|
|
||||||
debug bool
|
|
||||||
monitor bool
|
|
||||||
mu sync.RWMutex
|
|
||||||
running bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHTTPProxy creates a new HTTP proxy with the given filter.
|
|
||||||
// If monitor is true, only blocked requests are logged.
|
|
||||||
// If debug is true, all requests and filter rules are logged.
|
|
||||||
func NewHTTPProxy(filter FilterFunc, debug, monitor bool) *HTTPProxy {
|
|
||||||
return &HTTPProxy{
|
|
||||||
filter: filter,
|
|
||||||
debug: debug,
|
|
||||||
monitor: monitor,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start starts the HTTP proxy on a random available port.
|
|
||||||
func (p *HTTPProxy) Start() (int, error) {
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to listen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
p.listener = listener
|
|
||||||
p.server = &http.Server{
|
|
||||||
Handler: http.HandlerFunc(p.handleRequest),
|
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
p.mu.Lock()
|
|
||||||
p.running = true
|
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
if err := p.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
|
||||||
p.logDebug("HTTP proxy server error: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
addr := listener.Addr().(*net.TCPAddr)
|
|
||||||
p.logDebug("HTTP proxy listening on localhost:%d", addr.Port)
|
|
||||||
return addr.Port, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop stops the HTTP proxy.
|
|
||||||
func (p *HTTPProxy) Stop() error {
|
|
||||||
p.mu.Lock()
|
|
||||||
p.running = false
|
|
||||||
p.mu.Unlock()
|
|
||||||
|
|
||||||
if p.server != nil {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
return p.server.Shutdown(ctx)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Port returns the port the proxy is listening on.
|
|
||||||
func (p *HTTPProxy) Port() int {
|
|
||||||
if p.listener == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return p.listener.Addr().(*net.TCPAddr).Port
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTPProxy) handleRequest(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method == http.MethodConnect {
|
|
||||||
p.handleConnect(w, r)
|
|
||||||
} else {
|
|
||||||
p.handleHTTP(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleConnect handles HTTPS CONNECT requests (tunnel).
|
|
||||||
func (p *HTTPProxy) handleConnect(w http.ResponseWriter, r *http.Request) {
|
|
||||||
start := time.Now()
|
|
||||||
host, portStr, err := net.SplitHostPort(r.Host)
|
|
||||||
if err != nil {
|
|
||||||
host = r.Host
|
|
||||||
portStr = "443"
|
|
||||||
}
|
|
||||||
|
|
||||||
port := 443
|
|
||||||
if portStr != "" {
|
|
||||||
if p, err := strconv.Atoi(portStr); err == nil {
|
|
||||||
port = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if allowed
|
|
||||||
if !p.filter(host, port) {
|
|
||||||
p.logRequest("CONNECT", fmt.Sprintf("https://%s:%d", host, port), host, 403, "BLOCKED", time.Since(start))
|
|
||||||
http.Error(w, "Connection blocked by network allowlist", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
p.logRequest("CONNECT", fmt.Sprintf("https://%s:%d", host, port), host, 200, "ALLOWED", time.Since(start))
|
|
||||||
|
|
||||||
// Connect to target
|
|
||||||
targetConn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 10*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
p.logDebug("CONNECT dial failed: %s:%d: %v", host, port, err)
|
|
||||||
http.Error(w, "Bad Gateway", http.StatusBadGateway)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer func() { _ = targetConn.Close() }()
|
|
||||||
|
|
||||||
// Hijack the connection
|
|
||||||
hijacker, ok := w.(http.Hijacker)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
clientConn, _, err := hijacker.Hijack()
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Failed to hijack connection", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer func() { _ = clientConn.Close() }()
|
|
||||||
|
|
||||||
if _, err := clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pipe data bidirectionally
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(2)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
_, _ = io.Copy(targetConn, clientConn)
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
_, _ = io.Copy(clientConn, targetConn)
|
|
||||||
}()
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleHTTP handles regular HTTP proxy requests.
|
|
||||||
func (p *HTTPProxy) handleHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
start := time.Now()
|
|
||||||
targetURL, err := url.Parse(r.RequestURI)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
host := targetURL.Hostname()
|
|
||||||
port := 80
|
|
||||||
if targetURL.Port() != "" {
|
|
||||||
if p, err := strconv.Atoi(targetURL.Port()); err == nil {
|
|
||||||
port = p
|
|
||||||
}
|
|
||||||
} else if targetURL.Scheme == "https" {
|
|
||||||
port = 443
|
|
||||||
}
|
|
||||||
|
|
||||||
if !p.filter(host, port) {
|
|
||||||
p.logRequest(r.Method, r.RequestURI, host, 403, "BLOCKED", time.Since(start))
|
|
||||||
http.Error(w, "Connection blocked by network allowlist", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new request and copy headers
|
|
||||||
proxyReq, err := http.NewRequest(r.Method, r.RequestURI, r.Body)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for key, values := range r.Header {
|
|
||||||
for _, value := range values {
|
|
||||||
proxyReq.Header.Add(key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
proxyReq.Host = targetURL.Host
|
|
||||||
|
|
||||||
// Remove hop-by-hop headers
|
|
||||||
proxyReq.Header.Del("Proxy-Connection")
|
|
||||||
proxyReq.Header.Del("Proxy-Authorization")
|
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
||||||
return http.ErrUseLastResponse
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(proxyReq)
|
|
||||||
if err != nil {
|
|
||||||
p.logRequest(r.Method, r.RequestURI, host, 502, "ERROR", time.Since(start))
|
|
||||||
http.Error(w, "Bad Gateway", http.StatusBadGateway)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
// Copy response headers
|
|
||||||
for key, values := range resp.Header {
|
|
||||||
for _, value := range values {
|
|
||||||
w.Header().Add(key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(resp.StatusCode)
|
|
||||||
_, _ = io.Copy(w, resp.Body)
|
|
||||||
|
|
||||||
p.logRequest(r.Method, r.RequestURI, host, resp.StatusCode, "ALLOWED", time.Since(start))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *HTTPProxy) logDebug(format string, args ...interface{}) {
|
|
||||||
if p.debug {
|
|
||||||
fmt.Fprintf(os.Stderr, "[fence:http] "+format+"\n", args...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// logRequest logs a detailed request entry.
|
|
||||||
// In monitor mode (-m), only blocked/error requests are logged.
|
|
||||||
// In debug mode (-d), all requests are logged.
|
|
||||||
func (p *HTTPProxy) logRequest(method, url, host string, status int, action string, duration time.Duration) {
|
|
||||||
isBlocked := action == "BLOCKED" || action == "ERROR"
|
|
||||||
|
|
||||||
if p.monitor && !p.debug && !isBlocked {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !p.debug && !p.monitor {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
timestamp := time.Now().Format("15:04:05")
|
|
||||||
statusIcon := "✓"
|
|
||||||
switch action {
|
|
||||||
case "BLOCKED":
|
|
||||||
statusIcon = "✗"
|
|
||||||
case "ERROR":
|
|
||||||
statusIcon = "!"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "[fence:http] %s %s %-7s %d %s %s (%v)\n", timestamp, statusIcon, method, status, host, truncateURL(url, 60), duration.Round(time.Millisecond))
|
|
||||||
}
|
|
||||||
|
|
||||||
// truncateURL shortens a URL for display.
|
|
||||||
func truncateURL(url string, maxLen int) string {
|
|
||||||
if len(url) <= maxLen {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
return url[:maxLen-3] + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateDomainFilter creates a filter function from a config.
|
|
||||||
// When debug is true, logs filter rule matches to stderr.
|
|
||||||
func CreateDomainFilter(cfg *config.Config, debug bool) FilterFunc {
|
|
||||||
return func(host string, port int) bool {
|
|
||||||
if cfg == nil {
|
|
||||||
// No config = deny all
|
|
||||||
if debug {
|
|
||||||
fmt.Fprintf(os.Stderr, "[fence:filter] No config, denying: %s:%d\n", host, port)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check denied domains first
|
|
||||||
for _, denied := range cfg.Network.DeniedDomains {
|
|
||||||
if config.MatchesDomain(host, denied) {
|
|
||||||
if debug {
|
|
||||||
fmt.Fprintf(os.Stderr, "[fence:filter] Denied by rule: %s:%d (matched %s)\n", host, port, denied)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check allowed domains
|
|
||||||
for _, allowed := range cfg.Network.AllowedDomains {
|
|
||||||
if config.MatchesDomain(host, allowed) {
|
|
||||||
if debug {
|
|
||||||
fmt.Fprintf(os.Stderr, "[fence:filter] Allowed by rule: %s:%d (matched %s)\n", host, port, allowed)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if debug {
|
|
||||||
fmt.Fprintf(os.Stderr, "[fence:filter] No matching rule, denying: %s:%d\n", host, port)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetHostFromRequest extracts the hostname from a request.
|
|
||||||
func GetHostFromRequest(r *http.Request) string {
|
|
||||||
host := r.Host
|
|
||||||
if h := r.URL.Hostname(); h != "" {
|
|
||||||
host = h
|
|
||||||
}
|
|
||||||
// Strip port
|
|
||||||
if idx := strings.LastIndex(host, ":"); idx != -1 {
|
|
||||||
host = host[:idx]
|
|
||||||
}
|
|
||||||
return host
|
|
||||||
}
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/Use-Tusk/fence/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTruncateURL(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
url string
|
|
||||||
maxLen int
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"short url", "https://example.com", 50, "https://example.com"},
|
|
||||||
{"exact length", "https://example.com", 19, "https://example.com"},
|
|
||||||
{"needs truncation", "https://example.com/very/long/path/to/resource", 30, "https://example.com/very/lo..."},
|
|
||||||
{"empty url", "", 50, ""},
|
|
||||||
{"very short max", "https://example.com", 10, "https:/..."},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := truncateURL(tt.url, tt.maxLen)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("truncateURL(%q, %d) = %q, want %q", tt.url, tt.maxLen, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetHostFromRequest(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
host string
|
|
||||||
urlStr string
|
|
||||||
wantHost string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "host header only",
|
|
||||||
host: "example.com",
|
|
||||||
urlStr: "/path",
|
|
||||||
wantHost: "example.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "host header with port",
|
|
||||||
host: "example.com:8080",
|
|
||||||
urlStr: "/path",
|
|
||||||
wantHost: "example.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "full URL overrides host",
|
|
||||||
host: "other.com",
|
|
||||||
urlStr: "http://example.com/path",
|
|
||||||
wantHost: "example.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "url with port",
|
|
||||||
host: "other.com",
|
|
||||||
urlStr: "http://example.com:9000/path",
|
|
||||||
wantHost: "example.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ipv6 host",
|
|
||||||
host: "[::1]:8080",
|
|
||||||
urlStr: "/path",
|
|
||||||
wantHost: "[::1]",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
parsedURL, _ := url.Parse(tt.urlStr)
|
|
||||||
req := &http.Request{
|
|
||||||
Host: tt.host,
|
|
||||||
URL: parsedURL,
|
|
||||||
}
|
|
||||||
|
|
||||||
got := GetHostFromRequest(req)
|
|
||||||
if got != tt.wantHost {
|
|
||||||
t.Errorf("GetHostFromRequest() = %q, want %q", got, tt.wantHost)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateDomainFilter(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
cfg *config.Config
|
|
||||||
host string
|
|
||||||
port int
|
|
||||||
allowed bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "nil config denies all",
|
|
||||||
cfg: nil,
|
|
||||||
host: "example.com",
|
|
||||||
port: 443,
|
|
||||||
allowed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "allowed domain",
|
|
||||||
cfg: &config.Config{
|
|
||||||
Network: config.NetworkConfig{
|
|
||||||
AllowedDomains: []string{"example.com"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
host: "example.com",
|
|
||||||
port: 443,
|
|
||||||
allowed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "denied domain takes precedence",
|
|
||||||
cfg: &config.Config{
|
|
||||||
Network: config.NetworkConfig{
|
|
||||||
AllowedDomains: []string{"example.com"},
|
|
||||||
DeniedDomains: []string{"example.com"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
host: "example.com",
|
|
||||||
port: 443,
|
|
||||||
allowed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard allowed",
|
|
||||||
cfg: &config.Config{
|
|
||||||
Network: config.NetworkConfig{
|
|
||||||
AllowedDomains: []string{"*.example.com"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
host: "api.example.com",
|
|
||||||
port: 443,
|
|
||||||
allowed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "wildcard denied",
|
|
||||||
cfg: &config.Config{
|
|
||||||
Network: config.NetworkConfig{
|
|
||||||
AllowedDomains: []string{"*.example.com"},
|
|
||||||
DeniedDomains: []string{"*.blocked.example.com"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
host: "api.blocked.example.com",
|
|
||||||
port: 443,
|
|
||||||
allowed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unmatched domain denied",
|
|
||||||
cfg: &config.Config{
|
|
||||||
Network: config.NetworkConfig{
|
|
||||||
AllowedDomains: []string{"example.com"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
host: "other.com",
|
|
||||||
port: 443,
|
|
||||||
allowed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty allowed list denies all",
|
|
||||||
cfg: &config.Config{
|
|
||||||
Network: config.NetworkConfig{
|
|
||||||
AllowedDomains: []string{},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
host: "example.com",
|
|
||||||
port: 443,
|
|
||||||
allowed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "star wildcard allows all",
|
|
||||||
cfg: &config.Config{
|
|
||||||
Network: config.NetworkConfig{
|
|
||||||
AllowedDomains: []string{"*"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
host: "any-domain.example.com",
|
|
||||||
port: 443,
|
|
||||||
allowed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "star wildcard with deny list",
|
|
||||||
cfg: &config.Config{
|
|
||||||
Network: config.NetworkConfig{
|
|
||||||
AllowedDomains: []string{"*"},
|
|
||||||
DeniedDomains: []string{"blocked.com"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
host: "blocked.com",
|
|
||||||
port: 443,
|
|
||||||
allowed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "star wildcard allows non-denied",
|
|
||||||
cfg: &config.Config{
|
|
||||||
Network: config.NetworkConfig{
|
|
||||||
AllowedDomains: []string{"*"},
|
|
||||||
DeniedDomains: []string{"blocked.com"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
host: "allowed.com",
|
|
||||||
port: 443,
|
|
||||||
allowed: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
filter := CreateDomainFilter(tt.cfg, false)
|
|
||||||
got := filter(tt.host, tt.port)
|
|
||||||
if got != tt.allowed {
|
|
||||||
t.Errorf("CreateDomainFilter() filter(%q, %d) = %v, want %v", tt.host, tt.port, got, tt.allowed)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateDomainFilterCaseInsensitive(t *testing.T) {
|
|
||||||
cfg := &config.Config{
|
|
||||||
Network: config.NetworkConfig{
|
|
||||||
AllowedDomains: []string{"Example.COM"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
filter := CreateDomainFilter(cfg, false)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
host string
|
|
||||||
allowed bool
|
|
||||||
}{
|
|
||||||
{"example.com", true},
|
|
||||||
{"EXAMPLE.COM", true},
|
|
||||||
{"Example.Com", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.host, func(t *testing.T) {
|
|
||||||
got := filter(tt.host, 443)
|
|
||||||
if got != tt.allowed {
|
|
||||||
t.Errorf("filter(%q) = %v, want %v", tt.host, got, tt.allowed)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewHTTPProxy(t *testing.T) {
|
|
||||||
filter := func(host string, port int) bool { return true }
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
debug bool
|
|
||||||
monitor bool
|
|
||||||
}{
|
|
||||||
{"default", false, false},
|
|
||||||
{"debug mode", true, false},
|
|
||||||
{"monitor mode", false, true},
|
|
||||||
{"both modes", true, true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
proxy := NewHTTPProxy(filter, tt.debug, tt.monitor)
|
|
||||||
if proxy == nil {
|
|
||||||
t.Fatal("NewHTTPProxy() returned nil")
|
|
||||||
}
|
|
||||||
if proxy.debug != tt.debug {
|
|
||||||
t.Errorf("debug = %v, want %v", proxy.debug, tt.debug)
|
|
||||||
}
|
|
||||||
if proxy.monitor != tt.monitor {
|
|
||||||
t.Errorf("monitor = %v, want %v", proxy.monitor, tt.monitor)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHTTPProxyStartStop(t *testing.T) {
|
|
||||||
filter := func(host string, port int) bool { return true }
|
|
||||||
proxy := NewHTTPProxy(filter, false, false)
|
|
||||||
|
|
||||||
port, err := proxy.Start()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Start() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if port <= 0 {
|
|
||||||
t.Errorf("Start() returned invalid port: %d", port)
|
|
||||||
}
|
|
||||||
|
|
||||||
if proxy.Port() != port {
|
|
||||||
t.Errorf("Port() = %d, want %d", proxy.Port(), port)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := proxy.Stop(); err != nil {
|
|
||||||
t.Errorf("Stop() error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHTTPProxyPortBeforeStart(t *testing.T) {
|
|
||||||
filter := func(host string, port int) bool { return true }
|
|
||||||
proxy := NewHTTPProxy(filter, false, false)
|
|
||||||
|
|
||||||
if proxy.Port() != 0 {
|
|
||||||
t.Errorf("Port() before Start() = %d, want 0", proxy.Port())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/things-go/go-socks5"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SOCKSProxy is a SOCKS5 proxy server with domain filtering.
|
|
||||||
type SOCKSProxy struct {
|
|
||||||
server *socks5.Server
|
|
||||||
listener net.Listener
|
|
||||||
filter FilterFunc
|
|
||||||
debug bool
|
|
||||||
monitor bool
|
|
||||||
port int
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSOCKSProxy creates a new SOCKS5 proxy with the given filter.
|
|
||||||
// If monitor is true, only blocked connections are logged.
|
|
||||||
// If debug is true, all connections are logged.
|
|
||||||
func NewSOCKSProxy(filter FilterFunc, debug, monitor bool) *SOCKSProxy {
|
|
||||||
return &SOCKSProxy{
|
|
||||||
filter: filter,
|
|
||||||
debug: debug,
|
|
||||||
monitor: monitor,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fenceRuleSet implements socks5.RuleSet for domain filtering.
|
|
||||||
type fenceRuleSet struct {
|
|
||||||
filter FilterFunc
|
|
||||||
debug bool
|
|
||||||
monitor bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *fenceRuleSet) Allow(ctx context.Context, req *socks5.Request) (context.Context, bool) {
|
|
||||||
host := req.DestAddr.FQDN
|
|
||||||
if host == "" {
|
|
||||||
host = req.DestAddr.IP.String()
|
|
||||||
}
|
|
||||||
port := req.DestAddr.Port
|
|
||||||
|
|
||||||
allowed := r.filter(host, port)
|
|
||||||
|
|
||||||
shouldLog := r.debug || (r.monitor && !allowed)
|
|
||||||
if shouldLog {
|
|
||||||
timestamp := time.Now().Format("15:04:05")
|
|
||||||
if allowed {
|
|
||||||
fmt.Fprintf(os.Stderr, "[fence:socks] %s ✓ CONNECT %s:%d ALLOWED\n", timestamp, host, port)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(os.Stderr, "[fence:socks] %s ✗ CONNECT %s:%d BLOCKED\n", timestamp, host, port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ctx, allowed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start starts the SOCKS5 proxy on a random available port.
|
|
||||||
func (p *SOCKSProxy) Start() (int, error) {
|
|
||||||
// Create listener first to get a random port
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to listen: %w", err)
|
|
||||||
}
|
|
||||||
p.listener = listener
|
|
||||||
p.port = listener.Addr().(*net.TCPAddr).Port
|
|
||||||
|
|
||||||
server := socks5.NewServer(
|
|
||||||
socks5.WithRule(&fenceRuleSet{
|
|
||||||
filter: p.filter,
|
|
||||||
debug: p.debug,
|
|
||||||
monitor: p.monitor,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
p.server = server
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
if err := p.server.Serve(p.listener); err != nil {
|
|
||||||
if p.debug {
|
|
||||||
fmt.Fprintf(os.Stderr, "[fence:socks] Server error: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if p.debug {
|
|
||||||
fmt.Fprintf(os.Stderr, "[fence:socks] SOCKS5 proxy listening on localhost:%d\n", p.port)
|
|
||||||
}
|
|
||||||
return p.port, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop stops the SOCKS5 proxy.
|
|
||||||
func (p *SOCKSProxy) Stop() error {
|
|
||||||
if p.listener != nil {
|
|
||||||
return p.listener.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Port returns the port the proxy is listening on.
|
|
||||||
func (p *SOCKSProxy) Port() int {
|
|
||||||
return p.port
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/things-go/go-socks5"
|
|
||||||
"github.com/things-go/go-socks5/statute"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestFenceRuleSetAllow(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
fqdn string
|
|
||||||
ip net.IP
|
|
||||||
port int
|
|
||||||
allowed bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "allow by FQDN",
|
|
||||||
fqdn: "allowed.com",
|
|
||||||
port: 443,
|
|
||||||
allowed: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "deny by FQDN",
|
|
||||||
fqdn: "blocked.com",
|
|
||||||
port: 443,
|
|
||||||
allowed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "fallback to IP when FQDN empty",
|
|
||||||
fqdn: "",
|
|
||||||
ip: net.ParseIP("1.2.3.4"),
|
|
||||||
port: 80,
|
|
||||||
allowed: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "allow with IP fallback",
|
|
||||||
fqdn: "",
|
|
||||||
ip: net.ParseIP("127.0.0.1"),
|
|
||||||
port: 8080,
|
|
||||||
allowed: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
filter := func(host string, port int) bool {
|
|
||||||
return host == "allowed.com" || host == "127.0.0.1"
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
rs := &fenceRuleSet{filter: filter, debug: false, monitor: false}
|
|
||||||
req := &socks5.Request{
|
|
||||||
DestAddr: &statute.AddrSpec{
|
|
||||||
FQDN: tt.fqdn,
|
|
||||||
IP: tt.ip,
|
|
||||||
Port: tt.port,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, allowed := rs.Allow(context.Background(), req)
|
|
||||||
if allowed != tt.allowed {
|
|
||||||
t.Errorf("Allow() = %v, want %v", allowed, tt.allowed)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewSOCKSProxy(t *testing.T) {
|
|
||||||
filter := func(host string, port int) bool { return true }
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
debug bool
|
|
||||||
monitor bool
|
|
||||||
}{
|
|
||||||
{"default", false, false},
|
|
||||||
{"debug mode", true, false},
|
|
||||||
{"monitor mode", false, true},
|
|
||||||
{"both modes", true, true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
proxy := NewSOCKSProxy(filter, tt.debug, tt.monitor)
|
|
||||||
if proxy == nil {
|
|
||||||
t.Fatal("NewSOCKSProxy() returned nil")
|
|
||||||
}
|
|
||||||
if proxy.debug != tt.debug {
|
|
||||||
t.Errorf("debug = %v, want %v", proxy.debug, tt.debug)
|
|
||||||
}
|
|
||||||
if proxy.monitor != tt.monitor {
|
|
||||||
t.Errorf("monitor = %v, want %v", proxy.monitor, tt.monitor)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSOCKSProxyStartStop(t *testing.T) {
|
|
||||||
filter := func(host string, port int) bool { return true }
|
|
||||||
proxy := NewSOCKSProxy(filter, false, false)
|
|
||||||
|
|
||||||
port, err := proxy.Start()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Start() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if port <= 0 {
|
|
||||||
t.Errorf("Start() returned invalid port: %d", port)
|
|
||||||
}
|
|
||||||
|
|
||||||
if proxy.Port() != port {
|
|
||||||
t.Errorf("Port() = %d, want %d", proxy.Port(), port)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := proxy.Stop(); err != nil {
|
|
||||||
t.Errorf("Stop() error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSOCKSProxyPortBeforeStart(t *testing.T) {
|
|
||||||
filter := func(host string, port int) bool { return true }
|
|
||||||
proxy := NewSOCKSProxy(filter, false, false)
|
|
||||||
|
|
||||||
if proxy.Port() != 0 {
|
|
||||||
t.Errorf("Port() before Start() = %d, want 0", proxy.Port())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -312,9 +312,7 @@ func skipBenchIfSandboxed(b *testing.B) {
|
|||||||
|
|
||||||
func benchConfig(workspace string) *config.Config {
|
func benchConfig(workspace string) *config.Config {
|
||||||
return &config.Config{
|
return &config.Config{
|
||||||
Network: config.NetworkConfig{
|
Network: config.NetworkConfig{},
|
||||||
AllowedDomains: []string{},
|
|
||||||
},
|
|
||||||
Filesystem: config.FilesystemConfig{
|
Filesystem: config.FilesystemConfig{
|
||||||
AllowWrite: []string{workspace},
|
AllowWrite: []string{workspace},
|
||||||
},
|
},
|
||||||
|
|||||||
0
internal/sandbox/bin/.gitkeep
Normal file
0
internal/sandbox/bin/.gitkeep
Normal file
@@ -34,16 +34,12 @@ func skipIfLandlockNotUsable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// assertNetworkBlocked verifies that a network command was blocked.
|
// 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) {
|
func assertNetworkBlocked(t *testing.T, result *SandboxTestResult) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
blockedMessage := "Connection blocked by network allowlist"
|
|
||||||
if result.Failed() {
|
if result.Failed() {
|
||||||
return // Command failed = blocked
|
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",
|
t.Errorf("expected network request to be blocked, but it succeeded\nstdout: %s\nstderr: %s",
|
||||||
result.Stdout, result.Stderr)
|
result.Stdout, result.Stderr)
|
||||||
}
|
}
|
||||||
@@ -277,7 +273,7 @@ func TestLinux_NetworkBlocksCurl(t *testing.T) {
|
|||||||
|
|
||||||
workspace := createTempWorkspace(t)
|
workspace := createTempWorkspace(t)
|
||||||
cfg := testConfigWithWorkspace(workspace)
|
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)
|
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)
|
assertBlocked(t, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLinux_ProxyAllowsAllowedDomains verifies the proxy allows configured domains.
|
// TestLinux_TransparentProxyRoutesThroughSocks verifies traffic routes through SOCKS5 proxy.
|
||||||
func TestLinux_ProxyAllowsAllowedDomains(t *testing.T) {
|
// This test requires a running SOCKS5 proxy and actual network connectivity.
|
||||||
|
func TestLinux_TransparentProxyRoutesThroughSocks(t *testing.T) {
|
||||||
skipIfAlreadySandboxed(t)
|
skipIfAlreadySandboxed(t)
|
||||||
skipIfCommandNotFound(t, "curl")
|
skipIfCommandNotFound(t, "curl")
|
||||||
|
|
||||||
workspace := createTempWorkspace(t)
|
workspace := createTempWorkspace(t)
|
||||||
cfg := testConfigWithNetwork("httpbin.org")
|
cfg := testConfigWithProxy("socks5://localhost:1080")
|
||||||
cfg.Filesystem.AllowWrite = []string{workspace}
|
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" {
|
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)
|
result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 5 --max-time 10 https://httpbin.org/get", workspace, 15*time.Second)
|
||||||
|
|||||||
@@ -211,18 +211,18 @@ func TestMacOS_NetworkBlocksNc(t *testing.T) {
|
|||||||
assertBlocked(t, result)
|
assertBlocked(t, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMacOS_ProxyAllowsAllowedDomains verifies the proxy allows configured domains.
|
// TestMacOS_ProxyAllowsTrafficViaProxy verifies the proxy allows traffic via external proxy.
|
||||||
func TestMacOS_ProxyAllowsAllowedDomains(t *testing.T) {
|
func TestMacOS_ProxyAllowsTrafficViaProxy(t *testing.T) {
|
||||||
skipIfAlreadySandboxed(t)
|
skipIfAlreadySandboxed(t)
|
||||||
skipIfCommandNotFound(t, "curl")
|
skipIfCommandNotFound(t, "curl")
|
||||||
|
|
||||||
workspace := createTempWorkspace(t)
|
workspace := createTempWorkspace(t)
|
||||||
cfg := testConfigWithNetwork("httpbin.org")
|
cfg := testConfigWithProxy("socks5://localhost:1080")
|
||||||
cfg.Filesystem.AllowWrite = []string{workspace}
|
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" {
|
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)
|
result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 5 --max-time 10 https://httpbin.org/get", workspace, 15*time.Second)
|
||||||
|
|||||||
@@ -125,10 +125,7 @@ func assertContains(t *testing.T, haystack, needle string) {
|
|||||||
// testConfig creates a test configuration with sensible defaults.
|
// testConfig creates a test configuration with sensible defaults.
|
||||||
func testConfig() *config.Config {
|
func testConfig() *config.Config {
|
||||||
return &config.Config{
|
return &config.Config{
|
||||||
Network: config.NetworkConfig{
|
Network: config.NetworkConfig{},
|
||||||
AllowedDomains: []string{},
|
|
||||||
DeniedDomains: []string{},
|
|
||||||
},
|
|
||||||
Filesystem: config.FilesystemConfig{
|
Filesystem: config.FilesystemConfig{
|
||||||
DenyRead: []string{},
|
DenyRead: []string{},
|
||||||
AllowWrite: []string{},
|
AllowWrite: []string{},
|
||||||
@@ -149,10 +146,10 @@ func testConfigWithWorkspace(workspacePath string) *config.Config {
|
|||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
// testConfigWithNetwork creates a config that allows specific domains.
|
// testConfigWithProxy creates a config with a proxy URL set.
|
||||||
func testConfigWithNetwork(domains ...string) *config.Config {
|
func testConfigWithProxy(proxyURL string) *config.Config {
|
||||||
cfg := testConfig()
|
cfg := testConfig()
|
||||||
cfg.Network.AllowedDomains = domains
|
cfg.Network.ProxyURL = proxyURL
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,13 +18,13 @@ import (
|
|||||||
"github.com/Use-Tusk/fence/internal/config"
|
"github.com/Use-Tusk/fence/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LinuxBridge holds the socat bridge processes for Linux sandboxing (outbound).
|
// ProxyBridge bridges sandbox to an external SOCKS5 proxy via Unix socket.
|
||||||
type LinuxBridge struct {
|
type ProxyBridge struct {
|
||||||
HTTPSocketPath string
|
SocketPath string // Unix socket path
|
||||||
SOCKSSocketPath string
|
ProxyHost string // Parsed from ProxyURL
|
||||||
httpProcess *exec.Cmd
|
ProxyPort string // Parsed from ProxyURL
|
||||||
socksProcess *exec.Cmd
|
process *exec.Cmd
|
||||||
debug bool
|
debug bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReverseBridge holds the socat bridge processes for inbound connections.
|
// ReverseBridge holds the socat bridge processes for inbound connections.
|
||||||
@@ -49,13 +49,18 @@ type LinuxSandboxOptions struct {
|
|||||||
Debug bool
|
Debug bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLinuxBridge creates Unix socket bridges to the proxy servers.
|
// NewProxyBridge creates a Unix socket bridge to an external SOCKS5 proxy.
|
||||||
// This allows sandboxed processes to communicate with the host's proxy (outbound).
|
// The bridge uses socat to forward from a Unix socket to the external proxy's TCP address.
|
||||||
func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge, error) {
|
func NewProxyBridge(proxyURL string, debug bool) (*ProxyBridge, error) {
|
||||||
if _, err := exec.LookPath("socat"); err != nil {
|
if _, err := exec.LookPath("socat"); err != nil {
|
||||||
return nil, fmt.Errorf("socat is required on Linux but not found: %w", err)
|
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)
|
id := make([]byte, 8)
|
||||||
if _, err := rand.Read(id); err != nil {
|
if _, err := rand.Read(id); err != nil {
|
||||||
return nil, fmt.Errorf("failed to generate socket ID: %w", err)
|
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)
|
socketID := hex.EncodeToString(id)
|
||||||
|
|
||||||
tmpDir := os.TempDir()
|
tmpDir := os.TempDir()
|
||||||
httpSocketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-http-%s.sock", socketID))
|
socketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-proxy-%s.sock", socketID))
|
||||||
socksSocketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-socks-%s.sock", socketID))
|
|
||||||
|
|
||||||
bridge := &LinuxBridge{
|
bridge := &ProxyBridge{
|
||||||
HTTPSocketPath: httpSocketPath,
|
SocketPath: socketPath,
|
||||||
SOCKSSocketPath: socksSocketPath,
|
ProxyHost: u.Hostname(),
|
||||||
debug: debug,
|
ProxyPort: u.Port(),
|
||||||
|
debug: debug,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start HTTP bridge: Unix socket -> TCP proxy
|
// Start bridge: Unix socket -> external SOCKS5 proxy TCP
|
||||||
httpArgs := []string{
|
socatArgs := []string{
|
||||||
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", httpSocketPath),
|
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", socketPath),
|
||||||
fmt.Sprintf("TCP:localhost:%d", httpProxyPort),
|
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 {
|
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 {
|
if err := bridge.process.Start(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to start HTTP bridge: %w", err)
|
return nil, fmt.Errorf("failed to start proxy bridge: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start SOCKS bridge: Unix socket -> TCP proxy
|
// Wait for socket to be created, up to 5 seconds
|
||||||
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
|
|
||||||
for range 50 {
|
for range 50 {
|
||||||
httpExists := fileExists(httpSocketPath)
|
if fileExists(socketPath) {
|
||||||
socksExists := fileExists(socksSocketPath)
|
|
||||||
if httpExists && socksExists {
|
|
||||||
if debug {
|
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
|
return bridge, nil
|
||||||
}
|
}
|
||||||
@@ -113,29 +102,37 @@ func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge
|
|||||||
}
|
}
|
||||||
|
|
||||||
bridge.Cleanup()
|
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.
|
// Cleanup stops the bridge process and removes the socket file.
|
||||||
func (b *LinuxBridge) Cleanup() {
|
func (b *ProxyBridge) Cleanup() {
|
||||||
if b.httpProcess != nil && b.httpProcess.Process != nil {
|
if b.process != nil && b.process.Process != nil {
|
||||||
_ = b.httpProcess.Process.Kill()
|
_ = b.process.Process.Kill()
|
||||||
_ = b.httpProcess.Wait()
|
_ = b.process.Wait()
|
||||||
}
|
}
|
||||||
if b.socksProcess != nil && b.socksProcess.Process != nil {
|
_ = os.Remove(b.SocketPath)
|
||||||
_ = b.socksProcess.Process.Kill()
|
|
||||||
_ = b.socksProcess.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up socket files
|
|
||||||
_ = os.Remove(b.HTTPSocketPath)
|
|
||||||
_ = os.Remove(b.SOCKSSocketPath)
|
|
||||||
|
|
||||||
if b.debug {
|
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.
|
// NewReverseBridge creates Unix socket bridges for inbound connections.
|
||||||
// Host listens on ports, forwards to Unix sockets that go into the sandbox.
|
// Host listens on ports, forwards to Unix sockets that go into the sandbox.
|
||||||
func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
|
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.
|
// WrapCommandLinux wraps a command with Linux bubblewrap sandbox.
|
||||||
// It uses available security features (Landlock, seccomp) with graceful fallback.
|
// 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) {
|
func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) {
|
||||||
return WrapCommandLinuxWithOptions(cfg, command, bridge, reverseBridge, LinuxSandboxOptions{
|
return WrapCommandLinuxWithOptions(cfg, command, proxyBridge, reverseBridge, tun2socksPath, LinuxSandboxOptions{
|
||||||
UseLandlock: true, // Enabled by default, will fall back if not available
|
UseLandlock: true, // Enabled by default, will fall back if not available
|
||||||
UseSeccomp: true, // Enabled by default
|
UseSeccomp: true, // Enabled by default
|
||||||
UseEBPF: true, // Enabled by default if available
|
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.
|
// 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 {
|
if _, err := exec.LookPath("bwrap"); err != nil {
|
||||||
return "", fmt.Errorf("bubblewrap (bwrap) is required on Linux but not found: %w", err)
|
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())
|
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
|
// Build bwrap args with filesystem restrictions
|
||||||
bwrapArgs := []string{
|
bwrapArgs := []string{
|
||||||
"bwrap",
|
"bwrap",
|
||||||
@@ -356,13 +340,11 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
|
|||||||
"--die-with-parent",
|
"--die-with-parent",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only use --unshare-net if:
|
// Always use --unshare-net when available (network namespace isolation)
|
||||||
// 1. The environment supports it (has CAP_NET_ADMIN)
|
// Inside the namespace, tun2socks will provide transparent proxy access
|
||||||
// 2. We're NOT in wildcard mode (need direct network access)
|
if features.CanUnshareNet {
|
||||||
// Containerized environments (Docker, CI) often lack CAP_NET_ADMIN
|
|
||||||
if features.CanUnshareNet && !hasWildcardAllow {
|
|
||||||
bwrapArgs = append(bwrapArgs, "--unshare-net") // Network namespace isolation
|
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")
|
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)
|
// Bind the proxy bridge Unix socket into the sandbox (needs to be writable)
|
||||||
if bridge != nil {
|
if proxyBridge != nil {
|
||||||
bwrapArgs = append(bwrapArgs,
|
bwrapArgs = append(bwrapArgs,
|
||||||
"--bind", bridge.HTTPSocketPath, bridge.HTTPSocketPath,
|
"--bind", proxyBridge.SocketPath, proxyBridge.SocketPath,
|
||||||
"--bind", bridge.SOCKSSocketPath, bridge.SOCKSSocketPath,
|
|
||||||
)
|
)
|
||||||
|
// 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)
|
// 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")
|
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
|
var innerScript strings.Builder
|
||||||
|
|
||||||
if bridge != nil {
|
innerScript.WriteString("export FENCE_SANDBOX=1\n")
|
||||||
// Set up outbound socat listeners inside the sandbox
|
|
||||||
|
if proxyBridge != nil && tun2socksPath != "" && features.CanUseTransparentProxy() {
|
||||||
|
// Set up transparent proxy via TUN device + tun2socks
|
||||||
innerScript.WriteString(fmt.Sprintf(`
|
innerScript.WriteString(fmt.Sprintf(`
|
||||||
# Start HTTP proxy listener (port 3128 -> Unix socket -> host HTTP proxy)
|
# Set up TUN device for transparent proxying
|
||||||
socat TCP-LISTEN:3128,fork,reuseaddr UNIX-CONNECT:%s >/dev/null 2>&1 &
|
ip tuntap add dev tun0 mode tun
|
||||||
HTTP_PID=$!
|
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)
|
# Bridge: local port -> Unix socket -> host -> external SOCKS5 proxy
|
||||||
socat TCP-LISTEN:1080,fork,reuseaddr UNIX-CONNECT:%s >/dev/null 2>&1 &
|
PROXY_PORT=18321
|
||||||
SOCKS_PID=$!
|
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
|
# Start tun2socks (transparent proxy via gvisor netstack)
|
||||||
export HTTP_PROXY=http://127.0.0.1:3128
|
/tmp/fence-tun2socks -device tun0 -proxy socks5://127.0.0.1:${PROXY_PORT} >/dev/null 2>&1 &
|
||||||
export HTTPS_PROXY=http://127.0.0.1:3128
|
TUN2SOCKS_PID=$!
|
||||||
export http_proxy=http://127.0.0.1:3128
|
|
||||||
export https_proxy=http://127.0.0.1:3128
|
`, proxyBridge.SocketPath))
|
||||||
export ALL_PROXY=socks5h://127.0.0.1:1080
|
} else if proxyBridge != nil {
|
||||||
export all_proxy=socks5h://127.0.0.1:1080
|
// 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 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
|
// Set up reverse (inbound) socat listeners inside the sandbox
|
||||||
@@ -688,8 +693,8 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
# Small delay to ensure socat listeners are ready
|
# Small delay to ensure services are ready
|
||||||
sleep 0.1
|
sleep 0.3
|
||||||
|
|
||||||
# Run the user command
|
# Run the user command
|
||||||
`)
|
`)
|
||||||
@@ -729,6 +734,11 @@ sleep 0.1
|
|||||||
} else {
|
} else {
|
||||||
featureList = append(featureList, "bwrap(pid,fs)")
|
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 != "" {
|
if features.HasSeccomp && opts.UseSeccomp && seccompFilterPath != "" {
|
||||||
featureList = append(featureList, "seccomp")
|
featureList = append(featureList, "seccomp")
|
||||||
}
|
}
|
||||||
@@ -818,6 +828,9 @@ func PrintLinuxFeatures() {
|
|||||||
fmt.Printf(" Seccomp: %v (log level: %d)\n", features.HasSeccomp, features.SeccompLogLevel)
|
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(" 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(" 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")
|
fmt.Printf("\nFeature Status:\n")
|
||||||
if features.MinimumViable() {
|
if features.MinimumViable() {
|
||||||
@@ -841,6 +854,12 @@ func PrintLinuxFeatures() {
|
|||||||
fmt.Printf(" This is common in Docker, GitHub Actions, and other CI systems.\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() {
|
if features.CanUseLandlock() {
|
||||||
fmt.Printf(" ✓ Landlock available for enhanced filesystem control\n")
|
fmt.Printf(" ✓ Landlock available for enhanced filesystem control\n")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ type LinuxFeatures struct {
|
|||||||
// This can be false in containerized environments (Docker, CI) without CAP_NET_ADMIN
|
// This can be false in containerized environments (Docker, CI) without CAP_NET_ADMIN
|
||||||
CanUnshareNet bool
|
CanUnshareNet bool
|
||||||
|
|
||||||
|
// Transparent proxy support
|
||||||
|
HasIpCommand bool // ip (iproute2) available
|
||||||
|
HasDevNetTun bool // /dev/net/tun exists
|
||||||
|
HasTun2Socks bool // tun2socks embedded binary available
|
||||||
|
|
||||||
// Kernel version
|
// Kernel version
|
||||||
KernelMajor int
|
KernelMajor int
|
||||||
KernelMinor int
|
KernelMinor int
|
||||||
@@ -74,6 +79,12 @@ func (f *LinuxFeatures) detect() {
|
|||||||
|
|
||||||
// Check if we can create network namespaces
|
// Check if we can create network namespaces
|
||||||
f.detectNetworkNamespace()
|
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() {
|
func (f *LinuxFeatures) parseKernelVersion() {
|
||||||
@@ -255,6 +266,11 @@ func (f *LinuxFeatures) CanUseLandlock() bool {
|
|||||||
return f.HasLandlock && f.LandlockABI >= 1
|
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.
|
// MinimumViable returns true if the minimum required features are available.
|
||||||
func (f *LinuxFeatures) MinimumViable() bool {
|
func (f *LinuxFeatures) MinimumViable() bool {
|
||||||
return f.HasBwrap && f.HasSocat
|
return f.HasBwrap && f.HasSocat
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ type LinuxFeatures struct {
|
|||||||
HasCapBPF bool
|
HasCapBPF bool
|
||||||
HasCapRoot bool
|
HasCapRoot bool
|
||||||
CanUnshareNet bool
|
CanUnshareNet bool
|
||||||
|
HasIpCommand bool
|
||||||
|
HasDevNetTun bool
|
||||||
|
HasTun2Socks bool
|
||||||
KernelMajor int
|
KernelMajor int
|
||||||
KernelMinor int
|
KernelMinor int
|
||||||
}
|
}
|
||||||
@@ -39,6 +42,11 @@ func (f *LinuxFeatures) CanUseLandlock() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanUseTransparentProxy returns false on non-Linux platforms.
|
||||||
|
func (f *LinuxFeatures) CanUseTransparentProxy() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// MinimumViable returns false on non-Linux platforms.
|
// MinimumViable returns false on non-Linux platforms.
|
||||||
func (f *LinuxFeatures) MinimumViable() bool {
|
func (f *LinuxFeatures) MinimumViable() bool {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import (
|
|||||||
"github.com/Use-Tusk/fence/internal/config"
|
"github.com/Use-Tusk/fence/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LinuxBridge is a stub for non-Linux platforms.
|
// ProxyBridge is a stub for non-Linux platforms.
|
||||||
type LinuxBridge struct {
|
type ProxyBridge struct {
|
||||||
HTTPSocketPath string
|
SocketPath string
|
||||||
SOCKSSocketPath string
|
ProxyHost string
|
||||||
|
ProxyPort string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReverseBridge is a stub for non-Linux platforms.
|
// ReverseBridge is a stub for non-Linux platforms.
|
||||||
@@ -29,13 +30,13 @@ type LinuxSandboxOptions struct {
|
|||||||
Debug bool
|
Debug bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLinuxBridge returns an error on non-Linux platforms.
|
// NewProxyBridge returns an error on non-Linux platforms.
|
||||||
func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge, error) {
|
func NewProxyBridge(proxyURL string, debug bool) (*ProxyBridge, error) {
|
||||||
return nil, fmt.Errorf("Linux bridge not available on this platform")
|
return nil, fmt.Errorf("proxy bridge not available on this platform")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup is a no-op on non-Linux platforms.
|
// 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.
|
// NewReverseBridge returns an error on non-Linux platforms.
|
||||||
func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
|
func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
|
||||||
@@ -46,12 +47,12 @@ func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
|
|||||||
func (b *ReverseBridge) Cleanup() {}
|
func (b *ReverseBridge) Cleanup() {}
|
||||||
|
|
||||||
// WrapCommandLinux returns an error on non-Linux platforms.
|
// 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")
|
return "", fmt.Errorf("Linux sandbox not available on this platform")
|
||||||
}
|
}
|
||||||
|
|
||||||
// WrapCommandLinuxWithOptions returns an error on non-Linux platforms.
|
// 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")
|
return "", fmt.Errorf("Linux sandbox not available on this platform")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,155 +6,34 @@ import (
|
|||||||
"github.com/Use-Tusk/fence/internal/config"
|
"github.com/Use-Tusk/fence/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestLinux_WildcardAllowedDomainsSkipsUnshareNet verifies that when allowedDomains
|
// TestLinux_NoProxyBlocksNetwork verifies that when no ProxyURL is set,
|
||||||
// contains "*", the Linux sandbox does NOT use --unshare-net, allowing direct
|
// the Linux sandbox uses --unshare-net to block all network access.
|
||||||
// network connections for applications that don't respect HTTP_PROXY.
|
func TestLinux_NoProxyBlocksNetwork(t *testing.T) {
|
||||||
func TestLinux_WildcardAllowedDomainsSkipsUnshareNet(t *testing.T) {
|
cfg := &config.Config{
|
||||||
tests := []struct {
|
Network: config.NetworkConfig{},
|
||||||
name string
|
Filesystem: config.FilesystemConfig{
|
||||||
allowedDomains []string
|
AllowWrite: []string{"/tmp/test"},
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
// With no proxy, network should be blocked
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
if cfg.Network.ProxyURL != "" {
|
||||||
cfg := &config.Config{
|
t.Error("expected empty ProxyURL for no-network 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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasWildcardAllowedDomain checks if the config contains a "*" in allowedDomains.
|
// TestLinux_ProxyURLSet verifies that a proxy URL is properly set in config.
|
||||||
// This replicates the logic used in both linux.go and macos.go.
|
func TestLinux_ProxyURLSet(t *testing.T) {
|
||||||
func hasWildcardAllowedDomain(cfg *config.Config) bool {
|
cfg := &config.Config{
|
||||||
if cfg == nil {
|
Network: config.NetworkConfig{
|
||||||
return false
|
ProxyURL: "socks5://localhost:1080",
|
||||||
}
|
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
{
|
Filesystem: config.FilesystemConfig{
|
||||||
name: "empty allowed domains",
|
AllowWrite: []string{"/tmp/test"},
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
if cfg.Network.ProxyURL != "socks5://localhost:1080" {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Errorf("expected ProxyURL socks5://localhost:1080, got %s", cfg.Network.ProxyURL)
|
||||||
got := hasWildcardAllowedDomain(tt.cfg)
|
|
||||||
if got != tt.expectWildcard {
|
|
||||||
t.Errorf("hasWildcardAllowedDomain() = %v, want %v", got, tt.expectWildcard)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Use-Tusk/fence/internal/config"
|
"github.com/Use-Tusk/fence/internal/config"
|
||||||
@@ -29,8 +29,9 @@ func generateSessionSuffix() string {
|
|||||||
type MacOSSandboxParams struct {
|
type MacOSSandboxParams struct {
|
||||||
Command string
|
Command string
|
||||||
NeedsNetworkRestriction bool
|
NeedsNetworkRestriction bool
|
||||||
HTTPProxyPort int
|
ProxyURL string // External proxy URL (for env vars)
|
||||||
SOCKSProxyPort int
|
ProxyHost string // Proxy host (for sandbox profile network rules)
|
||||||
|
ProxyPort string // Proxy port (for sandbox profile network rules)
|
||||||
AllowUnixSockets []string
|
AllowUnixSockets []string
|
||||||
AllowAllUnixSockets bool
|
AllowAllUnixSockets bool
|
||||||
AllowLocalBinding bool
|
AllowLocalBinding bool
|
||||||
@@ -519,18 +520,10 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if params.HTTPProxyPort > 0 {
|
// Allow outbound to the external proxy host:port
|
||||||
profile.WriteString(fmt.Sprintf(`(allow network-bind (local ip "localhost:%d"))
|
if params.ProxyHost != "" && params.ProxyPort != "" {
|
||||||
(allow network-inbound (local ip "localhost:%d"))
|
profile.WriteString(fmt.Sprintf(`(allow network-outbound (remote ip "%s:%s"))
|
||||||
(allow network-outbound (remote ip "localhost:%d"))
|
`, params.ProxyHost, params.ProxyPort))
|
||||||
`, 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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
profile.WriteString("\n")
|
profile.WriteString("\n")
|
||||||
@@ -568,15 +561,7 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WrapCommandMacOS wraps a command with macOS sandbox restrictions.
|
// WrapCommandMacOS wraps a command with macOS sandbox restrictions.
|
||||||
func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort int, exposedPorts []int, debug bool) (string, error) {
|
func WrapCommandMacOS(cfg *config.Config, command string, 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
|
|
||||||
|
|
||||||
// Build allow paths: default + configured
|
// Build allow paths: default + configured
|
||||||
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
|
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
|
||||||
|
|
||||||
@@ -591,20 +576,25 @@ func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort in
|
|||||||
allowLocalOutbound = *cfg.Network.AllowLocalOutbound
|
allowLocalOutbound = *cfg.Network.AllowLocalOutbound
|
||||||
}
|
}
|
||||||
|
|
||||||
// If wildcard allow, don't restrict network at sandbox level (allow direct connections).
|
// Parse proxy URL for network rules
|
||||||
// Otherwise, restrict to localhost/proxy only (strict mode).
|
var proxyHost, proxyPort string
|
||||||
needsNetworkRestriction := !hasWildcardAllow && (needsNetwork || len(cfg.Network.AllowedDomains) == 0)
|
if cfg.Network.ProxyURL != "" {
|
||||||
|
if u, err := url.Parse(cfg.Network.ProxyURL); err == nil {
|
||||||
if debug && hasWildcardAllow {
|
proxyHost = u.Hostname()
|
||||||
fmt.Fprintf(os.Stderr, "[fence:macos] Wildcard allowedDomains detected - allowing direct network connections\n")
|
proxyPort = u.Port()
|
||||||
fmt.Fprintf(os.Stderr, "[fence:macos] Note: deniedDomains only enforced for apps that respect HTTP_PROXY\n")
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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{
|
params := MacOSSandboxParams{
|
||||||
Command: command,
|
Command: command,
|
||||||
NeedsNetworkRestriction: needsNetworkRestriction,
|
NeedsNetworkRestriction: needsNetworkRestriction,
|
||||||
HTTPProxyPort: httpPort,
|
ProxyURL: cfg.Network.ProxyURL,
|
||||||
SOCKSProxyPort: socksPort,
|
ProxyHost: proxyHost,
|
||||||
|
ProxyPort: proxyPort,
|
||||||
AllowUnixSockets: cfg.Network.AllowUnixSockets,
|
AllowUnixSockets: cfg.Network.AllowUnixSockets,
|
||||||
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
||||||
AllowLocalBinding: allowLocalBinding,
|
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)
|
return "", fmt.Errorf("shell %q not found: %w", shell, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
proxyEnvs := GenerateProxyEnvVars(httpPort, socksPort)
|
proxyEnvs := GenerateProxyEnvVars(cfg.Network.ProxyURL)
|
||||||
|
|
||||||
// Build the command
|
// Build the command
|
||||||
// env VAR1=val1 VAR2=val2 sandbox-exec -p 'profile' shell -c 'command'
|
// env VAR1=val1 VAR2=val2 sandbox-exec -p 'profile' shell -c 'command'
|
||||||
|
|||||||
@@ -7,44 +7,34 @@ import (
|
|||||||
"github.com/Use-Tusk/fence/internal/config"
|
"github.com/Use-Tusk/fence/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestMacOS_WildcardAllowedDomainsRelaxesNetwork verifies that when allowedDomains
|
// TestMacOS_NetworkRestrictionWithProxy verifies that when a proxy URL is set,
|
||||||
// contains "*", the macOS sandbox profile allows direct network connections.
|
// the macOS sandbox profile allows outbound to the proxy host:port.
|
||||||
func TestMacOS_WildcardAllowedDomainsRelaxesNetwork(t *testing.T) {
|
func TestMacOS_NetworkRestrictionWithProxy(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
allowedDomains []string
|
proxyURL string
|
||||||
wantNetworkRestricted bool
|
wantProxy bool
|
||||||
wantAllowNetworkOutbound bool
|
proxyHost string
|
||||||
|
proxyPort string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "no domains - network restricted",
|
name: "no proxy - network blocked",
|
||||||
allowedDomains: []string{},
|
proxyURL: "",
|
||||||
wantNetworkRestricted: true,
|
wantProxy: false,
|
||||||
wantAllowNetworkOutbound: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "specific domain - network restricted",
|
name: "socks5 proxy - outbound allowed to proxy",
|
||||||
allowedDomains: []string{"api.openai.com"},
|
proxyURL: "socks5://proxy.example.com:1080",
|
||||||
wantNetworkRestricted: true,
|
wantProxy: true,
|
||||||
wantAllowNetworkOutbound: false,
|
proxyHost: "proxy.example.com",
|
||||||
|
proxyPort: "1080",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "wildcard domain - network unrestricted",
|
name: "socks5h proxy - outbound allowed to proxy",
|
||||||
allowedDomains: []string{"*"},
|
proxyURL: "socks5h://localhost:1080",
|
||||||
wantNetworkRestricted: false,
|
wantProxy: true,
|
||||||
wantAllowNetworkOutbound: true,
|
proxyHost: "localhost",
|
||||||
},
|
proxyPort: "1080",
|
||||||
{
|
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,34 +42,33 @@ func TestMacOS_WildcardAllowedDomainsRelaxesNetwork(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
Network: config.NetworkConfig{
|
Network: config.NetworkConfig{
|
||||||
AllowedDomains: tt.allowedDomains,
|
ProxyURL: tt.proxyURL,
|
||||||
},
|
},
|
||||||
Filesystem: config.FilesystemConfig{
|
Filesystem: config.FilesystemConfig{
|
||||||
AllowWrite: []string{"/tmp/test"},
|
AllowWrite: []string{"/tmp/test"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate the sandbox profile parameters
|
|
||||||
params := buildMacOSParamsForTest(cfg)
|
params := buildMacOSParamsForTest(cfg)
|
||||||
|
|
||||||
if params.NeedsNetworkRestriction != tt.wantNetworkRestricted {
|
if tt.wantProxy {
|
||||||
t.Errorf("NeedsNetworkRestriction = %v, want %v",
|
if params.ProxyHost != tt.proxyHost {
|
||||||
params.NeedsNetworkRestriction, tt.wantNetworkRestricted)
|
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
|
// Network should always be restricted (proxy or not)
|
||||||
profile := GenerateSandboxProfile(params)
|
if !params.NeedsNetworkRestriction {
|
||||||
|
t.Error("NeedsNetworkRestriction should always be true")
|
||||||
// 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*)'")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -88,15 +77,6 @@ func TestMacOS_WildcardAllowedDomainsRelaxesNetwork(t *testing.T) {
|
|||||||
// buildMacOSParamsForTest is a helper to build MacOSSandboxParams from config,
|
// buildMacOSParamsForTest is a helper to build MacOSSandboxParams from config,
|
||||||
// replicating the logic in WrapCommandMacOS for testing.
|
// replicating the logic in WrapCommandMacOS for testing.
|
||||||
func buildMacOSParamsForTest(cfg *config.Config) MacOSSandboxParams {
|
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...)
|
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
|
||||||
allowLocalBinding := cfg.Network.AllowLocalBinding
|
allowLocalBinding := cfg.Network.AllowLocalBinding
|
||||||
allowLocalOutbound := allowLocalBinding
|
allowLocalOutbound := allowLocalBinding
|
||||||
@@ -104,13 +84,26 @@ func buildMacOSParamsForTest(cfg *config.Config) MacOSSandboxParams {
|
|||||||
allowLocalOutbound = *cfg.Network.AllowLocalOutbound
|
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{
|
return MacOSSandboxParams{
|
||||||
Command: "echo test",
|
Command: "echo test",
|
||||||
NeedsNetworkRestriction: needsNetworkRestriction,
|
NeedsNetworkRestriction: true,
|
||||||
HTTPProxyPort: 8080,
|
ProxyURL: cfg.Network.ProxyURL,
|
||||||
SOCKSProxyPort: 1080,
|
ProxyHost: proxyHost,
|
||||||
|
ProxyPort: proxyPort,
|
||||||
AllowUnixSockets: cfg.Network.AllowUnixSockets,
|
AllowUnixSockets: cfg.Network.AllowUnixSockets,
|
||||||
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
||||||
AllowLocalBinding: allowLocalBinding,
|
AllowLocalBinding: allowLocalBinding,
|
||||||
@@ -158,8 +151,6 @@ func TestMacOS_ProfileNetworkSection(t *testing.T) {
|
|||||||
params := MacOSSandboxParams{
|
params := MacOSSandboxParams{
|
||||||
Command: "echo test",
|
Command: "echo test",
|
||||||
NeedsNetworkRestriction: tt.restricted,
|
NeedsNetworkRestriction: tt.restricted,
|
||||||
HTTPProxyPort: 8080,
|
|
||||||
SOCKSProxyPort: 1080,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
profile := GenerateSandboxProfile(params)
|
profile := GenerateSandboxProfile(params)
|
||||||
@@ -195,8 +186,8 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
|
|||||||
defaultDenyRead: false,
|
defaultDenyRead: false,
|
||||||
allowRead: nil,
|
allowRead: nil,
|
||||||
wantContainsBlanketAllow: true,
|
wantContainsBlanketAllow: true,
|
||||||
wantContainsMetadataAllow: false, // No separate metadata allow needed
|
wantContainsMetadataAllow: false,
|
||||||
wantContainsSystemAllows: false, // No need for explicit system allows
|
wantContainsSystemAllows: false,
|
||||||
wantContainsUserAllowRead: false,
|
wantContainsUserAllowRead: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -204,8 +195,8 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
|
|||||||
defaultDenyRead: true,
|
defaultDenyRead: true,
|
||||||
allowRead: nil,
|
allowRead: nil,
|
||||||
wantContainsBlanketAllow: false,
|
wantContainsBlanketAllow: false,
|
||||||
wantContainsMetadataAllow: true, // Should have file-read-metadata for traversal
|
wantContainsMetadataAllow: true,
|
||||||
wantContainsSystemAllows: true, // Should have explicit system path allows
|
wantContainsSystemAllows: true,
|
||||||
wantContainsUserAllowRead: false,
|
wantContainsUserAllowRead: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -223,35 +214,28 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
params := MacOSSandboxParams{
|
params := MacOSSandboxParams{
|
||||||
Command: "echo test",
|
Command: "echo test",
|
||||||
HTTPProxyPort: 8080,
|
|
||||||
SOCKSProxyPort: 1080,
|
|
||||||
DefaultDenyRead: tt.defaultDenyRead,
|
DefaultDenyRead: tt.defaultDenyRead,
|
||||||
ReadAllowPaths: tt.allowRead,
|
ReadAllowPaths: tt.allowRead,
|
||||||
}
|
}
|
||||||
|
|
||||||
profile := GenerateSandboxProfile(params)
|
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")
|
hasBlanketAllow := strings.Contains(profile, "(allow file-read*)\n")
|
||||||
if hasBlanketAllow != tt.wantContainsBlanketAllow {
|
if hasBlanketAllow != tt.wantContainsBlanketAllow {
|
||||||
t.Errorf("blanket file-read allow = %v, want %v", 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)")
|
hasMetadataAllow := strings.Contains(profile, "(allow file-read-metadata)")
|
||||||
if hasMetadataAllow != tt.wantContainsMetadataAllow {
|
if hasMetadataAllow != tt.wantContainsMetadataAllow {
|
||||||
t.Errorf("file-read-metadata allow = %v, want %v", 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")`) ||
|
hasSystemAllows := strings.Contains(profile, `(subpath "/usr")`) ||
|
||||||
strings.Contains(profile, `(subpath "/bin")`)
|
strings.Contains(profile, `(subpath "/bin")`)
|
||||||
if hasSystemAllows != tt.wantContainsSystemAllows {
|
if hasSystemAllows != tt.wantContainsSystemAllows {
|
||||||
t.Errorf("system path allows = %v, want %v\nProfile:\n%s", hasSystemAllows, tt.wantContainsSystemAllows, profile)
|
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 {
|
if tt.wantContainsUserAllowRead && len(tt.allowRead) > 0 {
|
||||||
hasUserAllow := strings.Contains(profile, tt.allowRead[0])
|
hasUserAllow := strings.Contains(profile, tt.allowRead[0])
|
||||||
if !hasUserAllow {
|
if !hasUserAllow {
|
||||||
|
|||||||
@@ -6,18 +6,14 @@ import (
|
|||||||
|
|
||||||
"github.com/Use-Tusk/fence/internal/config"
|
"github.com/Use-Tusk/fence/internal/config"
|
||||||
"github.com/Use-Tusk/fence/internal/platform"
|
"github.com/Use-Tusk/fence/internal/platform"
|
||||||
"github.com/Use-Tusk/fence/internal/proxy"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manager handles sandbox initialization and command wrapping.
|
// Manager handles sandbox initialization and command wrapping.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
httpProxy *proxy.HTTPProxy
|
proxyBridge *ProxyBridge
|
||||||
socksProxy *proxy.SOCKSProxy
|
|
||||||
linuxBridge *LinuxBridge
|
|
||||||
reverseBridge *ReverseBridge
|
reverseBridge *ReverseBridge
|
||||||
httpPort int
|
tun2socksPath string // path to extracted tun2socks binary on host
|
||||||
socksPort int
|
|
||||||
exposedPorts []int
|
exposedPorts []int
|
||||||
debug bool
|
debug bool
|
||||||
monitor bool
|
monitor bool
|
||||||
@@ -38,7 +34,7 @@ func (m *Manager) SetExposedPorts(ports []int) {
|
|||||||
m.exposedPorts = ports
|
m.exposedPorts = ports
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize sets up the sandbox infrastructure (proxies, etc.).
|
// Initialize sets up the sandbox infrastructure.
|
||||||
func (m *Manager) Initialize() error {
|
func (m *Manager) Initialize() error {
|
||||||
if m.initialized {
|
if m.initialized {
|
||||||
return nil
|
return nil
|
||||||
@@ -48,32 +44,27 @@ func (m *Manager) Initialize() error {
|
|||||||
return fmt.Errorf("sandbox is not supported on platform: %s", platform.Detect())
|
return fmt.Errorf("sandbox is not supported on platform: %s", platform.Detect())
|
||||||
}
|
}
|
||||||
|
|
||||||
filter := proxy.CreateDomainFilter(m.config, m.debug)
|
// On Linux, set up proxy bridge and tun2socks if proxy is configured
|
||||||
|
|
||||||
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
|
|
||||||
if platform.Detect() == platform.Linux {
|
if platform.Detect() == platform.Linux {
|
||||||
bridge, err := NewLinuxBridge(m.httpPort, m.socksPort, m.debug)
|
if m.config.Network.ProxyURL != "" {
|
||||||
if err != nil {
|
// Extract embedded tun2socks binary
|
||||||
_ = m.httpProxy.Stop()
|
tun2socksPath, err := extractTun2Socks()
|
||||||
_ = m.socksProxy.Stop()
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize Linux bridge: %w", err)
|
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)
|
// Set up reverse bridge for exposed ports (inbound connections)
|
||||||
// Only needed when network namespace is available - otherwise they share the network
|
// 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 {
|
if len(m.exposedPorts) > 0 && features.CanUnshareNet {
|
||||||
reverseBridge, err := NewReverseBridge(m.exposedPorts, m.debug)
|
reverseBridge, err := NewReverseBridge(m.exposedPorts, m.debug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.linuxBridge.Cleanup()
|
if m.proxyBridge != nil {
|
||||||
_ = m.httpProxy.Stop()
|
m.proxyBridge.Cleanup()
|
||||||
_ = m.socksProxy.Stop()
|
}
|
||||||
|
if m.tun2socksPath != "" {
|
||||||
|
os.Remove(m.tun2socksPath)
|
||||||
|
}
|
||||||
return fmt.Errorf("failed to initialize reverse bridge: %w", err)
|
return fmt.Errorf("failed to initialize reverse bridge: %w", err)
|
||||||
}
|
}
|
||||||
m.reverseBridge = reverseBridge
|
m.reverseBridge = reverseBridge
|
||||||
@@ -93,7 +87,11 @@ func (m *Manager) Initialize() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
m.initialized = true
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,9 +112,9 @@ func (m *Manager) WrapCommand(command string) (string, error) {
|
|||||||
plat := platform.Detect()
|
plat := platform.Detect()
|
||||||
switch plat {
|
switch plat {
|
||||||
case platform.MacOS:
|
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:
|
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:
|
default:
|
||||||
return "", fmt.Errorf("unsupported platform: %s", plat)
|
return "", fmt.Errorf("unsupported platform: %s", plat)
|
||||||
}
|
}
|
||||||
@@ -127,14 +125,11 @@ func (m *Manager) Cleanup() {
|
|||||||
if m.reverseBridge != nil {
|
if m.reverseBridge != nil {
|
||||||
m.reverseBridge.Cleanup()
|
m.reverseBridge.Cleanup()
|
||||||
}
|
}
|
||||||
if m.linuxBridge != nil {
|
if m.proxyBridge != nil {
|
||||||
m.linuxBridge.Cleanup()
|
m.proxyBridge.Cleanup()
|
||||||
}
|
}
|
||||||
if m.httpProxy != nil {
|
if m.tun2socksPath != "" {
|
||||||
_ = m.httpProxy.Stop()
|
os.Remove(m.tun2socksPath)
|
||||||
}
|
|
||||||
if m.socksProxy != nil {
|
|
||||||
_ = m.socksProxy.Stop()
|
|
||||||
}
|
}
|
||||||
m.logDebug("Sandbox manager cleaned up")
|
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...)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
53
internal/sandbox/tun2socks_embed.go
Normal file
53
internal/sandbox/tun2socks_embed.go
Normal 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
|
||||||
|
}
|
||||||
10
internal/sandbox/tun2socks_embed_stub.go
Normal file
10
internal/sandbox/tun2socks_embed_stub.go
Normal 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")
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,13 +48,14 @@ func NormalizePath(pathPattern string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GenerateProxyEnvVars creates environment variables for proxy configuration.
|
// 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{
|
envVars := []string{
|
||||||
"FENCE_SANDBOX=1",
|
"FENCE_SANDBOX=1",
|
||||||
"TMPDIR=/tmp/fence",
|
"TMPDIR=/tmp/fence",
|
||||||
}
|
}
|
||||||
|
|
||||||
if httpPort == 0 && socksPort == 0 {
|
if proxyURL == "" {
|
||||||
return envVars
|
return envVars
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,32 +75,14 @@ func GenerateProxyEnvVars(httpPort, socksPort int) []string {
|
|||||||
envVars = append(envVars,
|
envVars = append(envVars,
|
||||||
"NO_PROXY="+noProxy,
|
"NO_PROXY="+noProxy,
|
||||||
"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
|
return envVars
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +103,3 @@ func DecodeSandboxedCommand(encoded string) (string, error) {
|
|||||||
return string(data), nil
|
return string(data), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func itoa(n int) string {
|
|
||||||
return strconv.Itoa(n)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -125,16 +125,14 @@ func TestNormalizePath(t *testing.T) {
|
|||||||
|
|
||||||
func TestGenerateProxyEnvVars(t *testing.T) {
|
func TestGenerateProxyEnvVars(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
httpPort int
|
proxyURL string
|
||||||
socksPort int
|
wantEnvs []string
|
||||||
wantEnvs []string
|
dontWant []string
|
||||||
dontWant []string
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "no ports",
|
name: "no proxy",
|
||||||
httpPort: 0,
|
proxyURL: "",
|
||||||
socksPort: 0,
|
|
||||||
wantEnvs: []string{
|
wantEnvs: []string{
|
||||||
"FENCE_SANDBOX=1",
|
"FENCE_SANDBOX=1",
|
||||||
"TMPDIR=/tmp/fence",
|
"TMPDIR=/tmp/fence",
|
||||||
@@ -146,56 +144,34 @@ func TestGenerateProxyEnvVars(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "http port only",
|
name: "socks5 proxy",
|
||||||
httpPort: 8080,
|
proxyURL: "socks5://localhost:1080",
|
||||||
socksPort: 0,
|
|
||||||
wantEnvs: []string{
|
wantEnvs: []string{
|
||||||
"FENCE_SANDBOX=1",
|
"FENCE_SANDBOX=1",
|
||||||
"HTTP_PROXY=http://localhost:8080",
|
"ALL_PROXY=socks5://localhost:1080",
|
||||||
"HTTPS_PROXY=http://localhost:8080",
|
"all_proxy=socks5://localhost:1080",
|
||||||
"http_proxy=http://localhost:8080",
|
"HTTP_PROXY=socks5://localhost:1080",
|
||||||
"https_proxy=http://localhost:8080",
|
"HTTPS_PROXY=socks5://localhost:1080",
|
||||||
|
"http_proxy=socks5://localhost:1080",
|
||||||
|
"https_proxy=socks5://localhost:1080",
|
||||||
"NO_PROXY=",
|
"NO_PROXY=",
|
||||||
"no_proxy=",
|
"no_proxy=",
|
||||||
},
|
},
|
||||||
dontWant: []string{
|
|
||||||
"ALL_PROXY=",
|
|
||||||
"all_proxy=",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "socks port only",
|
name: "socks5h proxy",
|
||||||
httpPort: 0,
|
proxyURL: "socks5h://proxy.example.com:1080",
|
||||||
socksPort: 1080,
|
|
||||||
wantEnvs: []string{
|
wantEnvs: []string{
|
||||||
"FENCE_SANDBOX=1",
|
"FENCE_SANDBOX=1",
|
||||||
"ALL_PROXY=socks5h://localhost:1080",
|
"ALL_PROXY=socks5h://proxy.example.com:1080",
|
||||||
"all_proxy=socks5h://localhost:1080",
|
"HTTP_PROXY=socks5h://proxy.example.com: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=",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got := GenerateProxyEnvVars(tt.httpPort, tt.socksPort)
|
got := GenerateProxyEnvVars(tt.proxyURL)
|
||||||
|
|
||||||
// Check expected env vars are present
|
// Check expected env vars are present
|
||||||
for _, want := range tt.wantEnvs {
|
for _, want := range tt.wantEnvs {
|
||||||
@@ -207,7 +183,7 @@ func TestGenerateProxyEnvVars(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
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 _, dontWant := range tt.dontWant {
|
||||||
for _, env := range got {
|
for _, env := range got {
|
||||||
if strings.HasPrefix(env, dontWant) {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "code",
|
|
||||||
"network": {
|
|
||||||
// Allow all domains directly (for apps that ignore HTTP_PROXY)
|
|
||||||
// The "*" wildcard bypasses proxy-based domain filtering
|
|
||||||
"allowedDomains": ["*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "code",
|
|
||||||
"filesystem": {
|
|
||||||
// Deny reads by default, only system paths and allowRead are accessible
|
|
||||||
"defaultDenyRead": true,
|
|
||||||
"allowRead": [
|
|
||||||
// Current working directory
|
|
||||||
".",
|
|
||||||
|
|
||||||
// macOS preferences (needed by many apps)
|
|
||||||
"~/Library/Preferences",
|
|
||||||
|
|
||||||
// AI coding tool configs (need to read their own settings)
|
|
||||||
"~/.claude",
|
|
||||||
"~/.claude.json",
|
|
||||||
"~/.codex",
|
|
||||||
"~/.cursor",
|
|
||||||
"~/.opencode",
|
|
||||||
"~/.gemini",
|
|
||||||
"~/.factory",
|
|
||||||
|
|
||||||
// XDG config directory
|
|
||||||
"~/.config",
|
|
||||||
|
|
||||||
// Cache directories (some tools read from cache)
|
|
||||||
"~/.cache"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
{
|
|
||||||
"allowPty": true,
|
|
||||||
"network": {
|
|
||||||
"allowLocalBinding": true,
|
|
||||||
"allowLocalOutbound": true,
|
|
||||||
"allowedDomains": [
|
|
||||||
// LLM API providers
|
|
||||||
"api.openai.com",
|
|
||||||
"*.anthropic.com",
|
|
||||||
"api.githubcopilot.com",
|
|
||||||
"generativelanguage.googleapis.com",
|
|
||||||
"api.mistral.ai",
|
|
||||||
"api.cohere.ai",
|
|
||||||
"api.together.xyz",
|
|
||||||
"openrouter.ai",
|
|
||||||
|
|
||||||
// OpenCode
|
|
||||||
"opencode.ai",
|
|
||||||
"api.opencode.ai",
|
|
||||||
|
|
||||||
// Factory CLI (droid)
|
|
||||||
"*.factory.ai",
|
|
||||||
"api.workos.com",
|
|
||||||
|
|
||||||
// Cursor API
|
|
||||||
"*.cursor.sh",
|
|
||||||
|
|
||||||
// Git hosting
|
|
||||||
"github.com",
|
|
||||||
"api.github.com",
|
|
||||||
"raw.githubusercontent.com",
|
|
||||||
"codeload.github.com",
|
|
||||||
"objects.githubusercontent.com",
|
|
||||||
"release-assets.githubusercontent.com",
|
|
||||||
"gitlab.com",
|
|
||||||
|
|
||||||
// Package registries
|
|
||||||
"registry.npmjs.org",
|
|
||||||
"*.npmjs.org",
|
|
||||||
"registry.yarnpkg.com",
|
|
||||||
"pypi.org",
|
|
||||||
"files.pythonhosted.org",
|
|
||||||
"crates.io",
|
|
||||||
"static.crates.io",
|
|
||||||
"index.crates.io",
|
|
||||||
"proxy.golang.org",
|
|
||||||
"sum.golang.org",
|
|
||||||
|
|
||||||
// Model registry
|
|
||||||
"models.dev"
|
|
||||||
],
|
|
||||||
|
|
||||||
"deniedDomains": [
|
|
||||||
// Cloud metadata APIs (prevent credential theft)
|
|
||||||
"169.254.169.254",
|
|
||||||
"metadata.google.internal",
|
|
||||||
"instance-data.ec2.internal",
|
|
||||||
|
|
||||||
// Telemetry (optional, can be removed if needed)
|
|
||||||
"statsig.anthropic.com",
|
|
||||||
"*.sentry.io"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
"filesystem": {
|
|
||||||
"allowWrite": [
|
|
||||||
".",
|
|
||||||
// Temp files
|
|
||||||
"/tmp",
|
|
||||||
|
|
||||||
// Local cache, needed by tools like `uv`
|
|
||||||
"~/.cache/**",
|
|
||||||
|
|
||||||
// Claude Code
|
|
||||||
"~/.claude*",
|
|
||||||
"~/.claude/**",
|
|
||||||
|
|
||||||
// Codex
|
|
||||||
"~/.codex/**",
|
|
||||||
|
|
||||||
// Cursor
|
|
||||||
"~/.cursor/**",
|
|
||||||
|
|
||||||
// OpenCode
|
|
||||||
"~/.opencode/**",
|
|
||||||
"~/.local/state/**",
|
|
||||||
|
|
||||||
// Gemini CLI
|
|
||||||
"~/.gemini/**",
|
|
||||||
|
|
||||||
// Factory CLI (droid)
|
|
||||||
"~/.factory/**",
|
|
||||||
|
|
||||||
// Package manager caches
|
|
||||||
"~/.npm/_cacache",
|
|
||||||
"~/.cache",
|
|
||||||
"~/.bun/**",
|
|
||||||
|
|
||||||
// Cargo cache (Rust, used by Codex)
|
|
||||||
"~/.cargo/registry/**",
|
|
||||||
"~/.cargo/git/**",
|
|
||||||
"~/.cargo/.package-cache",
|
|
||||||
|
|
||||||
// Shell completion cache
|
|
||||||
"~/.zcompdump*",
|
|
||||||
|
|
||||||
// XDG directories for app configs/data
|
|
||||||
"~/.local/share/**",
|
|
||||||
"~/.config/**"
|
|
||||||
],
|
|
||||||
|
|
||||||
"denyWrite": [
|
|
||||||
// Protect environment files with secrets
|
|
||||||
".env",
|
|
||||||
".env.*",
|
|
||||||
"**/.env",
|
|
||||||
"**/.env.*",
|
|
||||||
|
|
||||||
// Protect key/certificate files
|
|
||||||
"*.key",
|
|
||||||
"*.pem",
|
|
||||||
"*.p12",
|
|
||||||
"*.pfx",
|
|
||||||
"**/*.key",
|
|
||||||
"**/*.pem",
|
|
||||||
"**/*.p12",
|
|
||||||
"**/*.pfx"
|
|
||||||
],
|
|
||||||
|
|
||||||
"denyRead": [
|
|
||||||
// SSH private keys and config
|
|
||||||
"~/.ssh/id_*",
|
|
||||||
"~/.ssh/config",
|
|
||||||
"~/.ssh/*.pem",
|
|
||||||
|
|
||||||
// GPG keys
|
|
||||||
"~/.gnupg/**",
|
|
||||||
|
|
||||||
// Cloud provider credentials
|
|
||||||
"~/.aws/**",
|
|
||||||
"~/.config/gcloud/**",
|
|
||||||
"~/.kube/**",
|
|
||||||
|
|
||||||
// Docker config (may contain registry auth)
|
|
||||||
"~/.docker/**",
|
|
||||||
|
|
||||||
// GitHub CLI auth
|
|
||||||
"~/.config/gh/**",
|
|
||||||
|
|
||||||
// Package manager auth tokens
|
|
||||||
"~/.pypirc",
|
|
||||||
"~/.netrc",
|
|
||||||
"~/.git-credentials",
|
|
||||||
"~/.cargo/credentials",
|
|
||||||
"~/.cargo/credentials.toml"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
"command": {
|
|
||||||
"useDefaults": true,
|
|
||||||
"deny": [
|
|
||||||
// Git commands that modify remote state
|
|
||||||
"git push",
|
|
||||||
"git reset",
|
|
||||||
"git clean",
|
|
||||||
"git checkout --",
|
|
||||||
"git rebase",
|
|
||||||
"git merge",
|
|
||||||
|
|
||||||
// Package publishing commands
|
|
||||||
"npm publish",
|
|
||||||
"pnpm publish",
|
|
||||||
"yarn publish",
|
|
||||||
"cargo publish",
|
|
||||||
"twine upload",
|
|
||||||
"gem push",
|
|
||||||
|
|
||||||
// Privilege escalation
|
|
||||||
"sudo",
|
|
||||||
|
|
||||||
// GitHub CLI commands that modify remote state
|
|
||||||
"gh pr create",
|
|
||||||
"gh pr merge",
|
|
||||||
"gh pr close",
|
|
||||||
"gh pr reopen",
|
|
||||||
"gh pr review",
|
|
||||||
"gh pr comment",
|
|
||||||
"gh release create",
|
|
||||||
"gh release delete",
|
|
||||||
"gh repo create",
|
|
||||||
"gh repo fork",
|
|
||||||
"gh repo delete",
|
|
||||||
"gh issue create",
|
|
||||||
"gh issue close",
|
|
||||||
"gh issue comment",
|
|
||||||
"gh gist create",
|
|
||||||
"gh workflow run",
|
|
||||||
"gh api",
|
|
||||||
"gh auth login",
|
|
||||||
"gh secret set",
|
|
||||||
"gh secret delete",
|
|
||||||
"gh variable set",
|
|
||||||
"gh variable delete"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
{
|
|
||||||
"allowPty": true,
|
|
||||||
"network": {
|
|
||||||
"allowLocalBinding": true,
|
|
||||||
"allowLocalOutbound": true,
|
|
||||||
"allowedDomains": ["*"],
|
|
||||||
|
|
||||||
// Block common analytics, telemetry, and error reporting services
|
|
||||||
"deniedDomains": [
|
|
||||||
// Error reporting
|
|
||||||
"*.sentry.io",
|
|
||||||
"*.ingest.sentry.io",
|
|
||||||
"sentry.io",
|
|
||||||
|
|
||||||
// Product analytics
|
|
||||||
"*.posthog.com",
|
|
||||||
"app.posthog.com",
|
|
||||||
"us.posthog.com",
|
|
||||||
"eu.posthog.com",
|
|
||||||
|
|
||||||
// Feature flags / experimentation
|
|
||||||
"*.statsig.com",
|
|
||||||
"statsig.com",
|
|
||||||
"statsig.anthropic.com",
|
|
||||||
|
|
||||||
// Customer data platforms
|
|
||||||
"*.segment.io",
|
|
||||||
"*.segment.com",
|
|
||||||
"api.segment.io",
|
|
||||||
"cdn.segment.com",
|
|
||||||
|
|
||||||
// Analytics
|
|
||||||
"*.amplitude.com",
|
|
||||||
"api.amplitude.com",
|
|
||||||
"api2.amplitude.com",
|
|
||||||
"*.mixpanel.com",
|
|
||||||
"api.mixpanel.com",
|
|
||||||
|
|
||||||
"*.heap.io",
|
|
||||||
"*.heapanalytics.com",
|
|
||||||
|
|
||||||
// Session recording
|
|
||||||
"*.fullstory.com",
|
|
||||||
"*.hotjar.com",
|
|
||||||
"*.hotjar.io",
|
|
||||||
"*.logrocket.io",
|
|
||||||
"*.logrocket.com",
|
|
||||||
|
|
||||||
// Error tracking
|
|
||||||
"*.bugsnag.com",
|
|
||||||
"notify.bugsnag.com",
|
|
||||||
"*.rollbar.com",
|
|
||||||
"api.rollbar.com",
|
|
||||||
|
|
||||||
// APM / Monitoring
|
|
||||||
"*.datadog.com",
|
|
||||||
"*.datadoghq.com",
|
|
||||||
|
|
||||||
"*.newrelic.com",
|
|
||||||
"*.nr-data.net",
|
|
||||||
|
|
||||||
// Feature flags
|
|
||||||
"*.launchdarkly.com",
|
|
||||||
"*.split.io",
|
|
||||||
|
|
||||||
// Product analytics / user engagement
|
|
||||||
"*.pendo.io",
|
|
||||||
"*.intercom.io",
|
|
||||||
"*.intercom.com",
|
|
||||||
|
|
||||||
// Mobile attribution
|
|
||||||
"*.appsflyer.com",
|
|
||||||
"*.adjust.com",
|
|
||||||
"*.branch.io",
|
|
||||||
|
|
||||||
// Crash reporting
|
|
||||||
"crashlytics.com",
|
|
||||||
"*.crashlytics.com",
|
|
||||||
"firebase-settings.crashlytics.com"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
"filesystem": {
|
|
||||||
"allowWrite": [
|
|
||||||
".",
|
|
||||||
"/tmp",
|
|
||||||
|
|
||||||
// Local cache, needed by tools like `uv`
|
|
||||||
"~/.cache/**",
|
|
||||||
|
|
||||||
// Claude Code state/config
|
|
||||||
"~/.claude*",
|
|
||||||
"~/.claude/**",
|
|
||||||
|
|
||||||
// Codex state/config
|
|
||||||
"~/.codex/**",
|
|
||||||
|
|
||||||
// Package manager caches
|
|
||||||
"~/.npm/_cacache",
|
|
||||||
"~/.cache",
|
|
||||||
"~/.bun/**",
|
|
||||||
|
|
||||||
// Cargo cache (Rust, used by Codex)
|
|
||||||
"~/.cargo/registry/**",
|
|
||||||
"~/.cargo/git/**",
|
|
||||||
"~/.cargo/.package-cache",
|
|
||||||
|
|
||||||
// Shell completion cache
|
|
||||||
"~/.zcompdump*",
|
|
||||||
|
|
||||||
// XDG directories for app configs/data
|
|
||||||
"~/.local/share/**",
|
|
||||||
"~/.config/**",
|
|
||||||
|
|
||||||
// OpenCode state
|
|
||||||
"~/.opencode/**"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"network": {
|
|
||||||
"allowedDomains": []
|
|
||||||
},
|
|
||||||
"filesystem": {
|
|
||||||
"allowWrite": ["."],
|
|
||||||
"denyWrite": [".git"]
|
|
||||||
},
|
|
||||||
"command": {
|
|
||||||
"deny": [
|
|
||||||
"git push",
|
|
||||||
"git reset",
|
|
||||||
"git clean",
|
|
||||||
"git checkout --",
|
|
||||||
"git rebase",
|
|
||||||
"git merge"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"network": {
|
|
||||||
"allowLocalBinding": true,
|
|
||||||
"allowLocalOutbound": true
|
|
||||||
},
|
|
||||||
"filesystem": {
|
|
||||||
"allowWrite": [".", "/tmp"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
// Package templates provides embedded configuration templates for fence.
|
|
||||||
package templates
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/Use-Tusk/fence/internal/config"
|
|
||||||
"github.com/tidwall/jsonc"
|
|
||||||
)
|
|
||||||
|
|
||||||
// maxExtendsDepth limits inheritance chain depth to prevent infinite loops.
|
|
||||||
const maxExtendsDepth = 10
|
|
||||||
|
|
||||||
// isPath returns true if the extends value looks like a file path rather than a template name.
|
|
||||||
// A value is considered a path if it contains a path separator or starts with ".".
|
|
||||||
func isPath(s string) bool {
|
|
||||||
return strings.ContainsAny(s, "/\\") || strings.HasPrefix(s, ".")
|
|
||||||
}
|
|
||||||
|
|
||||||
//go:embed *.json
|
|
||||||
var templatesFS embed.FS
|
|
||||||
|
|
||||||
// Template represents a named configuration template.
|
|
||||||
type Template struct {
|
|
||||||
Name string
|
|
||||||
Description string
|
|
||||||
}
|
|
||||||
|
|
||||||
// AvailableTemplates lists all embedded templates with descriptions.
|
|
||||||
var templateDescriptions = map[string]string{
|
|
||||||
"default-deny": "No network allowlist; no write access (most restrictive)",
|
|
||||||
"disable-telemetry": "Block analytics/error reporting (Sentry, Posthog, Statsig, etc.)",
|
|
||||||
"workspace-write": "Allow writes in the current directory",
|
|
||||||
"npm-install": "Allow npm registry; allow writes to workspace/node_modules/tmp",
|
|
||||||
"pip-install": "Allow PyPI; allow writes to workspace/tmp",
|
|
||||||
"local-dev-server": "Allow binding and localhost outbound; allow writes to workspace/tmp",
|
|
||||||
"git-readonly": "Blocks destructive commands like git push, rm -rf, etc.",
|
|
||||||
"code": "Production-ready config for AI coding agents (Claude Code, Codex, Copilot, etc.)",
|
|
||||||
"code-relaxed": "Like 'code' but allows direct network for apps that ignore HTTP_PROXY (cursor-agent, opencode)",
|
|
||||||
"code-strict": "Like 'code' but denies reads by default; only allows reading the current project directory and essential system paths",
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns all available template names sorted alphabetically.
|
|
||||||
func List() []Template {
|
|
||||||
entries, err := templatesFS.ReadDir(".")
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var templates []Template
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := strings.TrimSuffix(entry.Name(), ".json")
|
|
||||||
desc := templateDescriptions[name]
|
|
||||||
if desc == "" {
|
|
||||||
desc = "No description available"
|
|
||||||
}
|
|
||||||
templates = append(templates, Template{Name: name, Description: desc})
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Slice(templates, func(i, j int) bool {
|
|
||||||
return templates[i].Name < templates[j].Name
|
|
||||||
})
|
|
||||||
|
|
||||||
return templates
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load loads a template by name and returns the parsed config.
|
|
||||||
// If the template uses "extends", the inheritance chain is resolved.
|
|
||||||
func Load(name string) (*config.Config, error) {
|
|
||||||
return loadWithDepth(name, 0, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadWithDepth loads a template with cycle and depth tracking.
|
|
||||||
func loadWithDepth(name string, depth int, seen map[string]bool) (*config.Config, error) {
|
|
||||||
if depth > maxExtendsDepth {
|
|
||||||
return nil, fmt.Errorf("extends chain too deep (max %d)", maxExtendsDepth)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize name (remove .json if present)
|
|
||||||
name = strings.TrimSuffix(name, ".json")
|
|
||||||
|
|
||||||
// Check for cycles
|
|
||||||
if seen == nil {
|
|
||||||
seen = make(map[string]bool)
|
|
||||||
}
|
|
||||||
if seen[name] {
|
|
||||||
return nil, fmt.Errorf("circular extends detected: %q", name)
|
|
||||||
}
|
|
||||||
seen[name] = true
|
|
||||||
|
|
||||||
filename := name + ".json"
|
|
||||||
data, err := templatesFS.ReadFile(filename)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("template %q not found", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
var cfg config.Config
|
|
||||||
if err := json.Unmarshal(jsonc.ToJSON(data), &cfg); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse template %q: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If this template extends another, resolve the chain
|
|
||||||
if cfg.Extends != "" {
|
|
||||||
baseCfg, err := loadWithDepth(cfg.Extends, depth+1, seen)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to load base template %q: %w", cfg.Extends, err)
|
|
||||||
}
|
|
||||||
return config.Merge(baseCfg, &cfg), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exists checks if a template with the given name exists.
|
|
||||||
func Exists(name string) bool {
|
|
||||||
name = strings.TrimSuffix(name, ".json")
|
|
||||||
filename := name + ".json"
|
|
||||||
|
|
||||||
_, err := templatesFS.ReadFile(filename)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPath returns the embedded path for a template (for display purposes).
|
|
||||||
func GetPath(name string) string {
|
|
||||||
name = strings.TrimSuffix(name, ".json")
|
|
||||||
return filepath.Join("internal/templates", name+".json")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveExtends resolves the extends field in a config by loading and merging
|
|
||||||
// the base template or config file. If the config has no extends field, it is returned as-is.
|
|
||||||
// Relative paths are resolved relative to the current working directory.
|
|
||||||
// Use ResolveExtendsWithBaseDir if you need to resolve relative to a specific directory.
|
|
||||||
func ResolveExtends(cfg *config.Config) (*config.Config, error) {
|
|
||||||
return ResolveExtendsWithBaseDir(cfg, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveExtendsWithBaseDir resolves the extends field in a config.
|
|
||||||
// The baseDir is used to resolve relative paths in the extends field.
|
|
||||||
// If baseDir is empty, relative paths will be resolved relative to the current working directory.
|
|
||||||
//
|
|
||||||
// The extends field can be:
|
|
||||||
// - A template name (e.g., "code", "npm-install")
|
|
||||||
// - An absolute path (e.g., "/path/to/base.json")
|
|
||||||
// - A relative path (e.g., "./base.json", "../shared/base.json")
|
|
||||||
//
|
|
||||||
// Paths are detected by the presence of "/" or "\" or a leading ".".
|
|
||||||
func ResolveExtendsWithBaseDir(cfg *config.Config, baseDir string) (*config.Config, error) {
|
|
||||||
if cfg == nil || cfg.Extends == "" {
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolveExtendsWithDepth(cfg, baseDir, 0, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveExtendsWithDepth resolves extends with cycle and depth tracking.
|
|
||||||
func resolveExtendsWithDepth(cfg *config.Config, baseDir string, depth int, seen map[string]bool) (*config.Config, error) {
|
|
||||||
if cfg == nil || cfg.Extends == "" {
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if depth > maxExtendsDepth {
|
|
||||||
return nil, fmt.Errorf("extends chain too deep (max %d)", maxExtendsDepth)
|
|
||||||
}
|
|
||||||
|
|
||||||
if seen == nil {
|
|
||||||
seen = make(map[string]bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
var baseCfg *config.Config
|
|
||||||
var newBaseDir string
|
|
||||||
var err error
|
|
||||||
|
|
||||||
// Handle file path or template name extends
|
|
||||||
if isPath(cfg.Extends) {
|
|
||||||
baseCfg, newBaseDir, err = loadConfigFile(cfg.Extends, baseDir, seen)
|
|
||||||
} else {
|
|
||||||
baseCfg, err = loadWithDepth(cfg.Extends, depth+1, seen)
|
|
||||||
newBaseDir = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the base config also has extends, resolve it recursively
|
|
||||||
if baseCfg.Extends != "" {
|
|
||||||
baseCfg, err = resolveExtendsWithDepth(baseCfg, newBaseDir, depth+1, seen)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return config.Merge(baseCfg, cfg), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadConfigFile loads a config from a file path with cycle detection.
|
|
||||||
// Returns the loaded config, the directory of the loaded file (for resolving nested extends), and any error.
|
|
||||||
func loadConfigFile(path, baseDir string, seen map[string]bool) (*config.Config, string, error) {
|
|
||||||
var resolvedPath string
|
|
||||||
switch {
|
|
||||||
case filepath.IsAbs(path):
|
|
||||||
resolvedPath = path
|
|
||||||
case baseDir != "":
|
|
||||||
resolvedPath = filepath.Join(baseDir, path)
|
|
||||||
default:
|
|
||||||
var err error
|
|
||||||
resolvedPath, err = filepath.Abs(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", fmt.Errorf("failed to resolve path %q: %w", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean and normalize the path for cycle detection
|
|
||||||
resolvedPath = filepath.Clean(resolvedPath)
|
|
||||||
|
|
||||||
if seen[resolvedPath] {
|
|
||||||
return nil, "", fmt.Errorf("circular extends detected: %q", path)
|
|
||||||
}
|
|
||||||
seen[resolvedPath] = true
|
|
||||||
|
|
||||||
data, err := os.ReadFile(resolvedPath) //nolint:gosec // user-provided config path - intentional
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return nil, "", fmt.Errorf("extends file not found: %q", path)
|
|
||||||
}
|
|
||||||
return nil, "", fmt.Errorf("failed to read extends file %q: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle empty file
|
|
||||||
if len(strings.TrimSpace(string(data))) == 0 {
|
|
||||||
return nil, "", fmt.Errorf("extends file is empty: %q", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
var cfg config.Config
|
|
||||||
if err := json.Unmarshal(jsonc.ToJSON(data), &cfg); err != nil {
|
|
||||||
return nil, "", fmt.Errorf("invalid JSON in extends file %q: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cfg.Validate(); err != nil {
|
|
||||||
return nil, "", fmt.Errorf("invalid configuration in extends file %q: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &cfg, filepath.Dir(resolvedPath), nil
|
|
||||||
}
|
|
||||||
@@ -1,618 +0,0 @@
|
|||||||
package templates
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/Use-Tusk/fence/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestList(t *testing.T) {
|
|
||||||
templates := List()
|
|
||||||
if len(templates) == 0 {
|
|
||||||
t.Fatal("expected at least one template")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that code template exists
|
|
||||||
found := false
|
|
||||||
for _, tmpl := range templates {
|
|
||||||
if tmpl.Name == "code" {
|
|
||||||
found = true
|
|
||||||
if tmpl.Description == "" {
|
|
||||||
t.Error("code template should have a description")
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Error("code template not found")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoad(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{"code", false},
|
|
||||||
{"disable-telemetry", false},
|
|
||||||
{"git-readonly", false},
|
|
||||||
{"local-dev-server", false},
|
|
||||||
{"nonexistent", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
cfg, err := Load(tt.name)
|
|
||||||
if tt.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error, got nil")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if cfg == nil {
|
|
||||||
t.Error("expected config, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadWithJsonExtension(t *testing.T) {
|
|
||||||
// Should work with or without .json extension
|
|
||||||
cfg1, err := Load("disable-telemetry")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to load disable-telemetry: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg2, err := Load("disable-telemetry.json")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to load disable-telemetry.json: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both should return valid configs
|
|
||||||
if cfg1 == nil || cfg2 == nil {
|
|
||||||
t.Error("expected both configs to be non-nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExists(t *testing.T) {
|
|
||||||
if !Exists("code") {
|
|
||||||
t.Error("code template should exist")
|
|
||||||
}
|
|
||||||
if Exists("nonexistent") {
|
|
||||||
t.Error("nonexistent should not exist")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCodeTemplate(t *testing.T) {
|
|
||||||
cfg, err := Load("code")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to load code template: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify key settings
|
|
||||||
if !cfg.AllowPty {
|
|
||||||
t.Error("code template should have AllowPty=true")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cfg.Network.AllowedDomains) == 0 {
|
|
||||||
t.Error("code template should have allowed domains")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that *.anthropic.com is in allowed domains
|
|
||||||
found := false
|
|
||||||
for _, domain := range cfg.Network.AllowedDomains {
|
|
||||||
if domain == "*.anthropic.com" {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Error("*.anthropic.com should be in allowed domains")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that cloud metadata domains are denied
|
|
||||||
if len(cfg.Network.DeniedDomains) == 0 {
|
|
||||||
t.Error("code template should have denied domains")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check command deny list
|
|
||||||
if len(cfg.Command.Deny) == 0 {
|
|
||||||
t.Error("code template should have denied commands")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCodeStrictTemplate(t *testing.T) {
|
|
||||||
cfg, err := Load("code-strict")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to load code-strict template: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should inherit AllowPty from code template
|
|
||||||
if !cfg.AllowPty {
|
|
||||||
t.Error("code-strict should inherit AllowPty=true from code")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have defaultDenyRead enabled
|
|
||||||
if !cfg.Filesystem.DefaultDenyRead {
|
|
||||||
t.Error("code-strict should have DefaultDenyRead=true")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have allowRead with current directory
|
|
||||||
if len(cfg.Filesystem.AllowRead) == 0 {
|
|
||||||
t.Error("code-strict should have allowRead paths")
|
|
||||||
}
|
|
||||||
hasCurrentDir := false
|
|
||||||
for _, path := range cfg.Filesystem.AllowRead {
|
|
||||||
if path == "." {
|
|
||||||
hasCurrentDir = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasCurrentDir {
|
|
||||||
t.Error("code-strict should allow reading current directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should inherit allowWrite from code
|
|
||||||
if len(cfg.Filesystem.AllowWrite) == 0 {
|
|
||||||
t.Error("code-strict should inherit allowWrite from code")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should inherit denyWrite from code
|
|
||||||
if len(cfg.Filesystem.DenyWrite) == 0 {
|
|
||||||
t.Error("code-strict should inherit denyWrite from code")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should inherit allowed domains from code
|
|
||||||
if len(cfg.Network.AllowedDomains) == 0 {
|
|
||||||
t.Error("code-strict should inherit allowed domains from code")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should inherit denied commands from code
|
|
||||||
if len(cfg.Command.Deny) == 0 {
|
|
||||||
t.Error("code-strict should inherit denied commands from code")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extends should be cleared after resolution
|
|
||||||
if cfg.Extends != "" {
|
|
||||||
t.Error("extends should be cleared after loading")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCodeRelaxedTemplate(t *testing.T) {
|
|
||||||
cfg, err := Load("code-relaxed")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to load code-relaxed template: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should inherit AllowPty from code template
|
|
||||||
if !cfg.AllowPty {
|
|
||||||
t.Error("code-relaxed should inherit AllowPty=true from code")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have wildcard in allowed domains
|
|
||||||
hasWildcard := false
|
|
||||||
for _, domain := range cfg.Network.AllowedDomains {
|
|
||||||
if domain == "*" {
|
|
||||||
hasWildcard = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasWildcard {
|
|
||||||
t.Error("code-relaxed should have '*' in allowed domains")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should inherit denied domains from code
|
|
||||||
if len(cfg.Network.DeniedDomains) == 0 {
|
|
||||||
t.Error("code-relaxed should inherit denied domains from code")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should inherit filesystem config from code
|
|
||||||
if len(cfg.Filesystem.AllowWrite) == 0 {
|
|
||||||
t.Error("code-relaxed should inherit allowWrite from code")
|
|
||||||
}
|
|
||||||
if len(cfg.Filesystem.DenyRead) == 0 {
|
|
||||||
t.Error("code-relaxed should inherit denyRead from code")
|
|
||||||
}
|
|
||||||
if len(cfg.Filesystem.DenyWrite) == 0 {
|
|
||||||
t.Error("code-relaxed should inherit denyWrite from code")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should inherit command config from code
|
|
||||||
if len(cfg.Command.Deny) == 0 {
|
|
||||||
t.Error("code-relaxed should inherit command deny list from code")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extends should be cleared after resolution
|
|
||||||
if cfg.Extends != "" {
|
|
||||||
t.Error("extends should be cleared after loading")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveExtends(t *testing.T) {
|
|
||||||
t.Run("nil config", func(t *testing.T) {
|
|
||||||
result, err := ResolveExtends(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if result != nil {
|
|
||||||
t.Error("expected nil result for nil input")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("no extends", func(t *testing.T) {
|
|
||||||
cfg := &config.Config{
|
|
||||||
AllowPty: true,
|
|
||||||
Network: config.NetworkConfig{
|
|
||||||
AllowedDomains: []string{"example.com"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
result, err := ResolveExtends(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if result != cfg {
|
|
||||||
t.Error("expected same config when no extends")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("extends code template", func(t *testing.T) {
|
|
||||||
cfg := &config.Config{
|
|
||||||
Extends: "code",
|
|
||||||
Network: config.NetworkConfig{
|
|
||||||
AllowedDomains: []string{"private-registry.company.com"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
result, err := ResolveExtends(cfg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have merged config
|
|
||||||
if result.Extends != "" {
|
|
||||||
t.Error("extends should be cleared after resolution")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have AllowPty from base template
|
|
||||||
if !result.AllowPty {
|
|
||||||
t.Error("should inherit AllowPty from code template")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have domains from both
|
|
||||||
hasPrivateRegistry := false
|
|
||||||
hasAnthropic := false
|
|
||||||
for _, domain := range result.Network.AllowedDomains {
|
|
||||||
if domain == "private-registry.company.com" {
|
|
||||||
hasPrivateRegistry = true
|
|
||||||
}
|
|
||||||
if domain == "*.anthropic.com" {
|
|
||||||
hasAnthropic = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasPrivateRegistry {
|
|
||||||
t.Error("should have private-registry.company.com from override")
|
|
||||||
}
|
|
||||||
if !hasAnthropic {
|
|
||||||
t.Error("should have *.anthropic.com from base template")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("extends nonexistent template", func(t *testing.T) {
|
|
||||||
cfg := &config.Config{
|
|
||||||
Extends: "nonexistent-template",
|
|
||||||
}
|
|
||||||
_, err := ResolveExtends(cfg)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for nonexistent template")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtendsChainDepth(t *testing.T) {
|
|
||||||
// This tests that the maxExtendsDepth limit is respected.
|
|
||||||
// We can't easily create a deep chain with embedded templates,
|
|
||||||
// but we can test that the code template (which has no extends)
|
|
||||||
// loads correctly.
|
|
||||||
cfg, err := Load("code")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if cfg == nil {
|
|
||||||
t.Error("expected non-nil config")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsPath(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
// Template names (not paths)
|
|
||||||
{"code", false},
|
|
||||||
{"npm-install", false},
|
|
||||||
{"my-template", false},
|
|
||||||
|
|
||||||
// Absolute paths
|
|
||||||
{"/path/to/config.json", true},
|
|
||||||
{"/etc/fence/base.json", true},
|
|
||||||
|
|
||||||
// Relative paths
|
|
||||||
{"./base.json", true},
|
|
||||||
{"../shared/base.json", true},
|
|
||||||
{"configs/base.json", true},
|
|
||||||
|
|
||||||
// Windows-style paths
|
|
||||||
{"C:\\path\\to\\config.json", true},
|
|
||||||
{".\\base.json", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.input, func(t *testing.T) {
|
|
||||||
got := isPath(tt.input)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("isPath(%q) = %v, want %v", tt.input, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtendsFilePath(t *testing.T) {
|
|
||||||
// Create temp directory for test files
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
|
|
||||||
t.Run("extends absolute path", func(t *testing.T) {
|
|
||||||
// Create base config file
|
|
||||||
baseContent := `{
|
|
||||||
"network": {
|
|
||||||
"allowedDomains": ["base.example.com"]
|
|
||||||
},
|
|
||||||
"filesystem": {
|
|
||||||
"allowWrite": ["/tmp"]
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
basePath := filepath.Join(tmpDir, "base.json")
|
|
||||||
if err := os.WriteFile(basePath, []byte(baseContent), 0o600); err != nil {
|
|
||||||
t.Fatalf("failed to write base config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config that extends the base via absolute path
|
|
||||||
cfg := &config.Config{
|
|
||||||
Extends: basePath,
|
|
||||||
Network: config.NetworkConfig{
|
|
||||||
AllowedDomains: []string{"override.example.com"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := ResolveExtendsWithBaseDir(cfg, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have merged domains
|
|
||||||
if len(result.Network.AllowedDomains) != 2 {
|
|
||||||
t.Errorf("expected 2 domains, got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have filesystem from base
|
|
||||||
if len(result.Filesystem.AllowWrite) != 1 || result.Filesystem.AllowWrite[0] != "/tmp" {
|
|
||||||
t.Errorf("expected AllowWrite [/tmp], got %v", result.Filesystem.AllowWrite)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("extends relative path", func(t *testing.T) {
|
|
||||||
// Create base config in subdir
|
|
||||||
subDir := filepath.Join(tmpDir, "configs")
|
|
||||||
if err := os.MkdirAll(subDir, 0o750); err != nil {
|
|
||||||
t.Fatalf("failed to create subdir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
baseContent := `{
|
|
||||||
"allowPty": true,
|
|
||||||
"network": {
|
|
||||||
"allowedDomains": ["relative-base.example.com"]
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
basePath := filepath.Join(subDir, "base.json")
|
|
||||||
if err := os.WriteFile(basePath, []byte(baseContent), 0o600); err != nil {
|
|
||||||
t.Fatalf("failed to write base config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config that extends via relative path
|
|
||||||
cfg := &config.Config{
|
|
||||||
Extends: "./configs/base.json",
|
|
||||||
Network: config.NetworkConfig{
|
|
||||||
AllowedDomains: []string{"child.example.com"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := ResolveExtendsWithBaseDir(cfg, tmpDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should inherit AllowPty
|
|
||||||
if !result.AllowPty {
|
|
||||||
t.Error("should inherit AllowPty from base")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have merged domains
|
|
||||||
if len(result.Network.AllowedDomains) != 2 {
|
|
||||||
t.Errorf("expected 2 domains, got %d", len(result.Network.AllowedDomains))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("extends nonexistent file", func(t *testing.T) {
|
|
||||||
cfg := &config.Config{
|
|
||||||
Extends: "/nonexistent/path/config.json",
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := ResolveExtendsWithBaseDir(cfg, "")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for nonexistent file")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("extends invalid JSON file", func(t *testing.T) {
|
|
||||||
invalidPath := filepath.Join(tmpDir, "invalid.json")
|
|
||||||
if err := os.WriteFile(invalidPath, []byte("{invalid json}"), 0o600); err != nil {
|
|
||||||
t.Fatalf("failed to write invalid config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
|
||||||
Extends: invalidPath,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := ResolveExtendsWithBaseDir(cfg, "")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for invalid JSON")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("extends file with invalid config", func(t *testing.T) {
|
|
||||||
// Create config with invalid domain pattern
|
|
||||||
invalidContent := `{
|
|
||||||
"network": {
|
|
||||||
"allowedDomains": ["*.com"]
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
invalidPath := filepath.Join(tmpDir, "invalid-domain.json")
|
|
||||||
if err := os.WriteFile(invalidPath, []byte(invalidContent), 0o600); err != nil {
|
|
||||||
t.Fatalf("failed to write config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
|
||||||
Extends: invalidPath,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := ResolveExtendsWithBaseDir(cfg, "")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for invalid config")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("circular extends via files", func(t *testing.T) {
|
|
||||||
// Create two files that extend each other
|
|
||||||
fileA := filepath.Join(tmpDir, "a.json")
|
|
||||||
fileB := filepath.Join(tmpDir, "b.json")
|
|
||||||
|
|
||||||
contentA := `{"extends": "` + fileB + `"}`
|
|
||||||
contentB := `{"extends": "` + fileA + `"}`
|
|
||||||
|
|
||||||
if err := os.WriteFile(fileA, []byte(contentA), 0o600); err != nil {
|
|
||||||
t.Fatalf("failed to write a.json: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(fileB, []byte(contentB), 0o600); err != nil {
|
|
||||||
t.Fatalf("failed to write b.json: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
|
||||||
Extends: fileA,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := ResolveExtendsWithBaseDir(cfg, "")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for circular extends")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("nested extends chain", func(t *testing.T) {
|
|
||||||
// Create a chain: child -> middle -> base
|
|
||||||
baseContent := `{
|
|
||||||
"network": {
|
|
||||||
"allowedDomains": ["base.com"]
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
basePath := filepath.Join(tmpDir, "chain-base.json")
|
|
||||||
if err := os.WriteFile(basePath, []byte(baseContent), 0o600); err != nil {
|
|
||||||
t.Fatalf("failed to write base: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
middleContent := `{
|
|
||||||
"extends": "` + basePath + `",
|
|
||||||
"network": {
|
|
||||||
"allowedDomains": ["middle.com"]
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
middlePath := filepath.Join(tmpDir, "chain-middle.json")
|
|
||||||
if err := os.WriteFile(middlePath, []byte(middleContent), 0o600); err != nil {
|
|
||||||
t.Fatalf("failed to write middle: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
|
||||||
Extends: middlePath,
|
|
||||||
Network: config.NetworkConfig{
|
|
||||||
AllowedDomains: []string{"child.com"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := ResolveExtendsWithBaseDir(cfg, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have all three domains
|
|
||||||
if len(result.Network.AllowedDomains) != 3 {
|
|
||||||
t.Errorf("expected 3 domains, got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("file extends template", func(t *testing.T) {
|
|
||||||
// Create a file that extends a built-in template
|
|
||||||
fileContent := `{
|
|
||||||
"extends": "code",
|
|
||||||
"network": {
|
|
||||||
"allowedDomains": ["extra.example.com"]
|
|
||||||
}
|
|
||||||
}`
|
|
||||||
filePath := filepath.Join(tmpDir, "extends-template.json")
|
|
||||||
if err := os.WriteFile(filePath, []byte(fileContent), 0o600); err != nil {
|
|
||||||
t.Fatalf("failed to write config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Config that extends this file
|
|
||||||
cfg := &config.Config{
|
|
||||||
Extends: filePath,
|
|
||||||
Network: config.NetworkConfig{
|
|
||||||
AllowedDomains: []string{"top.example.com"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := ResolveExtendsWithBaseDir(cfg, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have AllowPty from code template
|
|
||||||
if !result.AllowPty {
|
|
||||||
t.Error("should inherit AllowPty from code template")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should have domains from all levels
|
|
||||||
hasAnthropic := false
|
|
||||||
hasExtra := false
|
|
||||||
hasTop := false
|
|
||||||
for _, domain := range result.Network.AllowedDomains {
|
|
||||||
switch domain {
|
|
||||||
case "*.anthropic.com":
|
|
||||||
hasAnthropic = true
|
|
||||||
case "extra.example.com":
|
|
||||||
hasExtra = true
|
|
||||||
case "top.example.com":
|
|
||||||
hasTop = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasAnthropic {
|
|
||||||
t.Error("should have *.anthropic.com from code template")
|
|
||||||
}
|
|
||||||
if !hasExtra {
|
|
||||||
t.Error("should have extra.example.com from middle file")
|
|
||||||
}
|
|
||||||
if !hasTop {
|
|
||||||
t.Error("should have top.example.com from top config")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user