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:
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -25,14 +26,11 @@ type Config struct {
|
||||
|
||||
// NetworkConfig defines network restrictions.
|
||||
type NetworkConfig struct {
|
||||
AllowedDomains []string `json:"allowedDomains"`
|
||||
DeniedDomains []string `json:"deniedDomains"`
|
||||
ProxyURL string `json:"proxyUrl,omitempty"` // External SOCKS5 proxy (e.g. socks5://host:1080)
|
||||
AllowUnixSockets []string `json:"allowUnixSockets,omitempty"`
|
||||
AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"`
|
||||
AllowLocalBinding bool `json:"allowLocalBinding,omitempty"`
|
||||
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.
|
||||
@@ -106,13 +104,10 @@ var DefaultDeniedCommands = []string{
|
||||
"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 {
|
||||
return &Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{},
|
||||
DeniedDomains: []string{},
|
||||
},
|
||||
Network: NetworkConfig{},
|
||||
Filesystem: FilesystemConfig{
|
||||
DenyRead: []string{},
|
||||
AllowWrite: []string{},
|
||||
@@ -196,14 +191,9 @@ func Load(path string) (*Config, error) {
|
||||
|
||||
// Validate validates the configuration.
|
||||
func (c *Config) Validate() error {
|
||||
for _, domain := range c.Network.AllowedDomains {
|
||||
if err := validateDomainPattern(domain); err != nil {
|
||||
return fmt.Errorf("invalid allowed domain %q: %w", domain, err)
|
||||
}
|
||||
}
|
||||
for _, domain := range c.Network.DeniedDomains {
|
||||
if err := validateDomainPattern(domain); err != nil {
|
||||
return fmt.Errorf("invalid denied domain %q: %w", domain, err)
|
||||
if c.Network.ProxyURL != "" {
|
||||
if err := validateProxyURL(c.Network.ProxyURL); err != nil {
|
||||
return fmt.Errorf("invalid network.proxyUrl %q: %w", c.Network.ProxyURL, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,46 +243,21 @@ func (c *CommandConfig) UseDefaultDeniedCommands() bool {
|
||||
return c.UseDefaults == nil || *c.UseDefaults
|
||||
}
|
||||
|
||||
func validateDomainPattern(pattern string) error {
|
||||
if pattern == "localhost" {
|
||||
return nil
|
||||
// validateProxyURL validates a SOCKS5 proxy URL.
|
||||
func validateProxyURL(proxyURL string) error {
|
||||
u, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
if strings.Contains(pattern, "://") || strings.Contains(pattern, "/") || strings.Contains(pattern, ":") {
|
||||
return errors.New("domain pattern cannot contain protocol, path, or port")
|
||||
if u.Scheme != "socks5" && u.Scheme != "socks5h" {
|
||||
return errors.New("proxy URL must use socks5:// or socks5h:// scheme")
|
||||
}
|
||||
|
||||
// Handle wildcard patterns
|
||||
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.Hostname() == "" {
|
||||
return errors.New("proxy URL must include a hostname")
|
||||
}
|
||||
|
||||
// Reject other uses of wildcards
|
||||
if strings.Contains(pattern, "*") {
|
||||
return errors.New("only *.domain.com wildcard patterns are allowed")
|
||||
if u.Port() == "" {
|
||||
return errors.New("proxy URL must include a port")
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -332,26 +297,6 @@ func isIPv6Pattern(pattern string) bool {
|
||||
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.
|
||||
// SSH host patterns support wildcards anywhere in the pattern.
|
||||
func MatchesHost(hostname, pattern string) bool {
|
||||
@@ -440,9 +385,10 @@ func Merge(base, override *Config) *Config {
|
||||
AllowPty: base.AllowPty || override.AllowPty,
|
||||
|
||||
Network: NetworkConfig{
|
||||
// ProxyURL: override wins if non-empty
|
||||
ProxyURL: mergeString(base.Network.ProxyURL, override.Network.ProxyURL),
|
||||
|
||||
// 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),
|
||||
|
||||
// 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
|
||||
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{
|
||||
@@ -531,9 +473,9 @@ func mergeOptionalBool(base, override *bool) *bool {
|
||||
return base
|
||||
}
|
||||
|
||||
// mergeInt returns override if non-zero, otherwise base.
|
||||
func mergeInt(base, override int) int {
|
||||
if override != 0 {
|
||||
// mergeString returns override if non-empty, otherwise base.
|
||||
func mergeString(base, override string) string {
|
||||
if override != "" {
|
||||
return override
|
||||
}
|
||||
return base
|
||||
|
||||
@@ -6,72 +6,6 @@ import (
|
||||
"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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -84,29 +18,55 @@ func TestConfigValidate(t *testing.T) {
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid config with domains",
|
||||
name: "valid config with proxy",
|
||||
config: Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"example.com", "*.github.com"},
|
||||
DeniedDomains: []string{"blocked.com"},
|
||||
ProxyURL: "socks5://localhost:1080",
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid allowed domain",
|
||||
name: "valid socks5h proxy",
|
||||
config: Config{
|
||||
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,
|
||||
},
|
||||
{
|
||||
name: "invalid denied domain",
|
||||
name: "invalid proxy - no port",
|
||||
config: Config{
|
||||
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,
|
||||
@@ -164,11 +124,8 @@ func TestDefault(t *testing.T) {
|
||||
if cfg == nil {
|
||||
t.Fatal("Default() returned nil")
|
||||
}
|
||||
if cfg.Network.AllowedDomains == nil {
|
||||
t.Error("AllowedDomains should not be nil")
|
||||
}
|
||||
if cfg.Network.DeniedDomains == nil {
|
||||
t.Error("DeniedDomains should not be nil")
|
||||
if cfg.Network.ProxyURL != "" {
|
||||
t.Error("ProxyURL should be empty by default")
|
||||
}
|
||||
if cfg.Filesystem.DenyRead == nil {
|
||||
t.Error("DenyRead should not be nil")
|
||||
@@ -222,21 +179,18 @@ func TestLoad(t *testing.T) {
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid config",
|
||||
name: "valid config with proxy",
|
||||
setup: func(dir string) string {
|
||||
path := filepath.Join(dir, "valid.json")
|
||||
content := `{"network":{"allowedDomains":["example.com"]}}`
|
||||
content := `{"network":{"proxyUrl":"socks5://localhost:1080"}}`
|
||||
_ = os.WriteFile(path, []byte(content), 0o600)
|
||||
return path
|
||||
},
|
||||
wantNil: false,
|
||||
wantErr: false,
|
||||
checkConfig: func(t *testing.T, cfg *Config) {
|
||||
if len(cfg.Network.AllowedDomains) != 1 {
|
||||
t.Errorf("expected 1 allowed domain, got %d", len(cfg.Network.AllowedDomains))
|
||||
}
|
||||
if cfg.Network.AllowedDomains[0] != "example.com" {
|
||||
t.Errorf("expected example.com, got %s", cfg.Network.AllowedDomains[0])
|
||||
if cfg.Network.ProxyURL != "socks5://localhost:1080" {
|
||||
t.Errorf("expected socks5://localhost:1080, got %s", cfg.Network.ProxyURL)
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -251,10 +205,10 @@ func TestLoad(t *testing.T) {
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid domain in config",
|
||||
name: "invalid proxy URL in config",
|
||||
setup: func(dir string) string {
|
||||
path := filepath.Join(dir, "invalid_domain.json")
|
||||
content := `{"network":{"allowedDomains":["*.com"]}}`
|
||||
path := filepath.Join(dir, "invalid_proxy.json")
|
||||
content := `{"network":{"proxyUrl":"http://localhost:1080"}}`
|
||||
_ = os.WriteFile(path, []byte(content), 0o600)
|
||||
return path
|
||||
},
|
||||
@@ -307,15 +261,15 @@ func TestMerge(t *testing.T) {
|
||||
override := &Config{
|
||||
AllowPty: true,
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"example.com"},
|
||||
ProxyURL: "socks5://localhost:1080",
|
||||
},
|
||||
}
|
||||
result := Merge(nil, override)
|
||||
if !result.AllowPty {
|
||||
t.Error("expected AllowPty to be true")
|
||||
}
|
||||
if len(result.Network.AllowedDomains) != 1 || result.Network.AllowedDomains[0] != "example.com" {
|
||||
t.Error("expected AllowedDomains to be [example.com]")
|
||||
if result.Network.ProxyURL != "socks5://localhost:1080" {
|
||||
t.Error("expected ProxyURL to be socks5://localhost:1080")
|
||||
}
|
||||
if result.Extends != "" {
|
||||
t.Error("expected Extends to be cleared")
|
||||
@@ -326,15 +280,15 @@ func TestMerge(t *testing.T) {
|
||||
base := &Config{
|
||||
AllowPty: true,
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"example.com"},
|
||||
ProxyURL: "socks5://localhost:1080",
|
||||
},
|
||||
}
|
||||
result := Merge(base, nil)
|
||||
if !result.AllowPty {
|
||||
t.Error("expected AllowPty to be true")
|
||||
}
|
||||
if len(result.Network.AllowedDomains) != 1 {
|
||||
t.Error("expected AllowedDomains to be [example.com]")
|
||||
if result.Network.ProxyURL != "socks5://localhost:1080" {
|
||||
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{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"github.com", "api.github.com"},
|
||||
ProxyURL: "socks5://base:1080",
|
||||
},
|
||||
}
|
||||
override := &Config{
|
||||
Extends: "base-template",
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"private-registry.company.com"},
|
||||
ProxyURL: "socks5://override:1080",
|
||||
},
|
||||
}
|
||||
result := Merge(base, override)
|
||||
|
||||
// Should have all three domains
|
||||
if len(result.Network.AllowedDomains) != 3 {
|
||||
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)
|
||||
if result.Network.ProxyURL != "socks5://override:1080" {
|
||||
t.Errorf("expected override ProxyURL, got %s", result.Network.ProxyURL)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deduplicate merged domains", func(t *testing.T) {
|
||||
t.Run("proxy URL base preserved when override empty", func(t *testing.T) {
|
||||
base := &Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"github.com", "example.com"},
|
||||
ProxyURL: "socks5://base:1080",
|
||||
},
|
||||
}
|
||||
override := &Config{
|
||||
Network: NetworkConfig{
|
||||
AllowedDomains: []string{"github.com", "new.com"},
|
||||
},
|
||||
Network: NetworkConfig{},
|
||||
}
|
||||
result := Merge(base, override)
|
||||
|
||||
// Should deduplicate
|
||||
if len(result.Network.AllowedDomains) != 3 {
|
||||
t.Errorf("expected 3 domains (deduped), got %d: %v", len(result.Network.AllowedDomains), result.Network.AllowedDomains)
|
||||
if result.Network.ProxyURL != "socks5://base:1080" {
|
||||
t.Errorf("expected base ProxyURL, got %s", result.Network.ProxyURL)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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.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 {
|
||||
@@ -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 {
|
||||
return &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{},
|
||||
},
|
||||
Network: config.NetworkConfig{},
|
||||
Filesystem: config.FilesystemConfig{
|
||||
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.
|
||||
// It checks for either a non-zero exit code OR the proxy's blocked message.
|
||||
// With no proxy configured, --unshare-net blocks all network at the kernel level.
|
||||
func assertNetworkBlocked(t *testing.T, result *SandboxTestResult) {
|
||||
t.Helper()
|
||||
blockedMessage := "Connection blocked by network allowlist"
|
||||
if result.Failed() {
|
||||
return // Command failed = blocked
|
||||
}
|
||||
if strings.Contains(result.Stdout, blockedMessage) || strings.Contains(result.Stderr, blockedMessage) {
|
||||
return // Proxy blocked the request
|
||||
}
|
||||
t.Errorf("expected network request to be blocked, but it succeeded\nstdout: %s\nstderr: %s",
|
||||
result.Stdout, result.Stderr)
|
||||
}
|
||||
@@ -277,7 +273,7 @@ func TestLinux_NetworkBlocksCurl(t *testing.T) {
|
||||
|
||||
workspace := createTempWorkspace(t)
|
||||
cfg := testConfigWithWorkspace(workspace)
|
||||
// No domains allowed = all network blocked
|
||||
// No proxy = all network blocked
|
||||
|
||||
result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 2 --max-time 3 http://example.com", workspace, 10*time.Second)
|
||||
|
||||
@@ -344,18 +340,19 @@ func TestLinux_NetworkBlocksDevTcp(t *testing.T) {
|
||||
assertBlocked(t, result)
|
||||
}
|
||||
|
||||
// TestLinux_ProxyAllowsAllowedDomains verifies the proxy allows configured domains.
|
||||
func TestLinux_ProxyAllowsAllowedDomains(t *testing.T) {
|
||||
// TestLinux_TransparentProxyRoutesThroughSocks verifies traffic routes through SOCKS5 proxy.
|
||||
// This test requires a running SOCKS5 proxy and actual network connectivity.
|
||||
func TestLinux_TransparentProxyRoutesThroughSocks(t *testing.T) {
|
||||
skipIfAlreadySandboxed(t)
|
||||
skipIfCommandNotFound(t, "curl")
|
||||
|
||||
workspace := createTempWorkspace(t)
|
||||
cfg := testConfigWithNetwork("httpbin.org")
|
||||
cfg := testConfigWithProxy("socks5://localhost:1080")
|
||||
cfg.Filesystem.AllowWrite = []string{workspace}
|
||||
|
||||
// This test requires actual network - skip in CI if network is unavailable
|
||||
// This test requires actual network and a running SOCKS5 proxy
|
||||
if os.Getenv("FENCE_TEST_NETWORK") != "1" {
|
||||
t.Skip("skipping: set FENCE_TEST_NETWORK=1 to run network tests")
|
||||
t.Skip("skipping: set FENCE_TEST_NETWORK=1 to run network tests (requires SOCKS5 proxy on localhost:1080)")
|
||||
}
|
||||
|
||||
result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 5 --max-time 10 https://httpbin.org/get", workspace, 15*time.Second)
|
||||
|
||||
@@ -211,18 +211,18 @@ func TestMacOS_NetworkBlocksNc(t *testing.T) {
|
||||
assertBlocked(t, result)
|
||||
}
|
||||
|
||||
// TestMacOS_ProxyAllowsAllowedDomains verifies the proxy allows configured domains.
|
||||
func TestMacOS_ProxyAllowsAllowedDomains(t *testing.T) {
|
||||
// TestMacOS_ProxyAllowsTrafficViaProxy verifies the proxy allows traffic via external proxy.
|
||||
func TestMacOS_ProxyAllowsTrafficViaProxy(t *testing.T) {
|
||||
skipIfAlreadySandboxed(t)
|
||||
skipIfCommandNotFound(t, "curl")
|
||||
|
||||
workspace := createTempWorkspace(t)
|
||||
cfg := testConfigWithNetwork("httpbin.org")
|
||||
cfg := testConfigWithProxy("socks5://localhost:1080")
|
||||
cfg.Filesystem.AllowWrite = []string{workspace}
|
||||
|
||||
// This test requires actual network - skip in CI if network is unavailable
|
||||
// This test requires actual network and a running SOCKS5 proxy
|
||||
if os.Getenv("FENCE_TEST_NETWORK") != "1" {
|
||||
t.Skip("skipping: set FENCE_TEST_NETWORK=1 to run network tests")
|
||||
t.Skip("skipping: set FENCE_TEST_NETWORK=1 to run network tests (requires SOCKS5 proxy on localhost:1080)")
|
||||
}
|
||||
|
||||
result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 5 --max-time 10 https://httpbin.org/get", workspace, 15*time.Second)
|
||||
|
||||
@@ -125,10 +125,7 @@ func assertContains(t *testing.T, haystack, needle string) {
|
||||
// testConfig creates a test configuration with sensible defaults.
|
||||
func testConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{},
|
||||
DeniedDomains: []string{},
|
||||
},
|
||||
Network: config.NetworkConfig{},
|
||||
Filesystem: config.FilesystemConfig{
|
||||
DenyRead: []string{},
|
||||
AllowWrite: []string{},
|
||||
@@ -149,10 +146,10 @@ func testConfigWithWorkspace(workspacePath string) *config.Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// testConfigWithNetwork creates a config that allows specific domains.
|
||||
func testConfigWithNetwork(domains ...string) *config.Config {
|
||||
// testConfigWithProxy creates a config with a proxy URL set.
|
||||
func testConfigWithProxy(proxyURL string) *config.Config {
|
||||
cfg := testConfig()
|
||||
cfg.Network.AllowedDomains = domains
|
||||
cfg.Network.ProxyURL = proxyURL
|
||||
return cfg
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -18,13 +18,13 @@ import (
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
)
|
||||
|
||||
// LinuxBridge holds the socat bridge processes for Linux sandboxing (outbound).
|
||||
type LinuxBridge struct {
|
||||
HTTPSocketPath string
|
||||
SOCKSSocketPath string
|
||||
httpProcess *exec.Cmd
|
||||
socksProcess *exec.Cmd
|
||||
debug bool
|
||||
// ProxyBridge bridges sandbox to an external SOCKS5 proxy via Unix socket.
|
||||
type ProxyBridge struct {
|
||||
SocketPath string // Unix socket path
|
||||
ProxyHost string // Parsed from ProxyURL
|
||||
ProxyPort string // Parsed from ProxyURL
|
||||
process *exec.Cmd
|
||||
debug bool
|
||||
}
|
||||
|
||||
// ReverseBridge holds the socat bridge processes for inbound connections.
|
||||
@@ -49,13 +49,18 @@ type LinuxSandboxOptions struct {
|
||||
Debug bool
|
||||
}
|
||||
|
||||
// NewLinuxBridge creates Unix socket bridges to the proxy servers.
|
||||
// This allows sandboxed processes to communicate with the host's proxy (outbound).
|
||||
func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge, error) {
|
||||
// NewProxyBridge creates a Unix socket bridge to an external SOCKS5 proxy.
|
||||
// The bridge uses socat to forward from a Unix socket to the external proxy's TCP address.
|
||||
func NewProxyBridge(proxyURL string, debug bool) (*ProxyBridge, error) {
|
||||
if _, err := exec.LookPath("socat"); err != nil {
|
||||
return nil, fmt.Errorf("socat is required on Linux but not found: %w", err)
|
||||
}
|
||||
|
||||
u, err := parseProxyURL(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid proxy URL: %w", err)
|
||||
}
|
||||
|
||||
id := make([]byte, 8)
|
||||
if _, err := rand.Read(id); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate socket ID: %w", err)
|
||||
@@ -63,49 +68,33 @@ func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge
|
||||
socketID := hex.EncodeToString(id)
|
||||
|
||||
tmpDir := os.TempDir()
|
||||
httpSocketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-http-%s.sock", socketID))
|
||||
socksSocketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-socks-%s.sock", socketID))
|
||||
socketPath := filepath.Join(tmpDir, fmt.Sprintf("fence-proxy-%s.sock", socketID))
|
||||
|
||||
bridge := &LinuxBridge{
|
||||
HTTPSocketPath: httpSocketPath,
|
||||
SOCKSSocketPath: socksSocketPath,
|
||||
debug: debug,
|
||||
bridge := &ProxyBridge{
|
||||
SocketPath: socketPath,
|
||||
ProxyHost: u.Hostname(),
|
||||
ProxyPort: u.Port(),
|
||||
debug: debug,
|
||||
}
|
||||
|
||||
// Start HTTP bridge: Unix socket -> TCP proxy
|
||||
httpArgs := []string{
|
||||
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", httpSocketPath),
|
||||
fmt.Sprintf("TCP:localhost:%d", httpProxyPort),
|
||||
// Start bridge: Unix socket -> external SOCKS5 proxy TCP
|
||||
socatArgs := []string{
|
||||
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", socketPath),
|
||||
fmt.Sprintf("TCP:%s:%s", bridge.ProxyHost, bridge.ProxyPort),
|
||||
}
|
||||
bridge.httpProcess = exec.Command("socat", httpArgs...) //nolint:gosec // args constructed from trusted input
|
||||
bridge.process = exec.Command("socat", socatArgs...) //nolint:gosec // args constructed from trusted input
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Starting HTTP bridge: socat %s\n", strings.Join(httpArgs, " "))
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Starting proxy bridge: socat %s\n", strings.Join(socatArgs, " "))
|
||||
}
|
||||
if err := bridge.httpProcess.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start HTTP bridge: %w", err)
|
||||
if err := bridge.process.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start proxy bridge: %w", err)
|
||||
}
|
||||
|
||||
// Start SOCKS bridge: Unix socket -> TCP proxy
|
||||
socksArgs := []string{
|
||||
fmt.Sprintf("UNIX-LISTEN:%s,fork,reuseaddr", socksSocketPath),
|
||||
fmt.Sprintf("TCP:localhost:%d", socksProxyPort),
|
||||
}
|
||||
bridge.socksProcess = exec.Command("socat", socksArgs...) //nolint:gosec // args constructed from trusted input
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Starting SOCKS bridge: socat %s\n", strings.Join(socksArgs, " "))
|
||||
}
|
||||
if err := bridge.socksProcess.Start(); err != nil {
|
||||
bridge.Cleanup()
|
||||
return nil, fmt.Errorf("failed to start SOCKS bridge: %w", err)
|
||||
}
|
||||
|
||||
// Wait for sockets to be created, up to 5 seconds
|
||||
// Wait for socket to be created, up to 5 seconds
|
||||
for range 50 {
|
||||
httpExists := fileExists(httpSocketPath)
|
||||
socksExists := fileExists(socksSocketPath)
|
||||
if httpExists && socksExists {
|
||||
if fileExists(socketPath) {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Bridges ready (HTTP: %s, SOCKS: %s)\n", httpSocketPath, socksSocketPath)
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Proxy bridge ready (%s)\n", socketPath)
|
||||
}
|
||||
return bridge, nil
|
||||
}
|
||||
@@ -113,29 +102,37 @@ func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge
|
||||
}
|
||||
|
||||
bridge.Cleanup()
|
||||
return nil, fmt.Errorf("timeout waiting for bridge sockets to be created")
|
||||
return nil, fmt.Errorf("timeout waiting for proxy bridge socket to be created")
|
||||
}
|
||||
|
||||
// Cleanup stops the bridge processes and removes socket files.
|
||||
func (b *LinuxBridge) Cleanup() {
|
||||
if b.httpProcess != nil && b.httpProcess.Process != nil {
|
||||
_ = b.httpProcess.Process.Kill()
|
||||
_ = b.httpProcess.Wait()
|
||||
// Cleanup stops the bridge process and removes the socket file.
|
||||
func (b *ProxyBridge) Cleanup() {
|
||||
if b.process != nil && b.process.Process != nil {
|
||||
_ = b.process.Process.Kill()
|
||||
_ = b.process.Wait()
|
||||
}
|
||||
if b.socksProcess != nil && b.socksProcess.Process != nil {
|
||||
_ = b.socksProcess.Process.Kill()
|
||||
_ = b.socksProcess.Wait()
|
||||
}
|
||||
|
||||
// Clean up socket files
|
||||
_ = os.Remove(b.HTTPSocketPath)
|
||||
_ = os.Remove(b.SOCKSSocketPath)
|
||||
_ = os.Remove(b.SocketPath)
|
||||
|
||||
if b.debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Bridges cleaned up\n")
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Proxy bridge cleaned up\n")
|
||||
}
|
||||
}
|
||||
|
||||
// parseProxyURL parses a SOCKS5 proxy URL and returns the parsed URL.
|
||||
func parseProxyURL(proxyURL string) (*url.URL, error) {
|
||||
u, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Scheme != "socks5" && u.Scheme != "socks5h" {
|
||||
return nil, fmt.Errorf("proxy URL must use socks5:// or socks5h:// scheme, got %s", u.Scheme)
|
||||
}
|
||||
if u.Hostname() == "" || u.Port() == "" {
|
||||
return nil, fmt.Errorf("proxy URL must include hostname and port")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// NewReverseBridge creates Unix socket bridges for inbound connections.
|
||||
// Host listens on ports, forwards to Unix sockets that go into the sandbox.
|
||||
func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
|
||||
@@ -308,8 +305,8 @@ func getMandatoryDenyPaths(cwd string) []string {
|
||||
|
||||
// WrapCommandLinux wraps a command with Linux bubblewrap sandbox.
|
||||
// It uses available security features (Landlock, seccomp) with graceful fallback.
|
||||
func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool) (string, error) {
|
||||
return WrapCommandLinuxWithOptions(cfg, command, bridge, reverseBridge, LinuxSandboxOptions{
|
||||
func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) {
|
||||
return WrapCommandLinuxWithOptions(cfg, command, proxyBridge, reverseBridge, tun2socksPath, LinuxSandboxOptions{
|
||||
UseLandlock: true, // Enabled by default, will fall back if not available
|
||||
UseSeccomp: true, // Enabled by default
|
||||
UseEBPF: true, // Enabled by default if available
|
||||
@@ -318,7 +315,7 @@ func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, r
|
||||
}
|
||||
|
||||
// WrapCommandLinuxWithOptions wraps a command with configurable sandbox options.
|
||||
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, opts LinuxSandboxOptions) (string, error) {
|
||||
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge *ProxyBridge, reverseBridge *ReverseBridge, tun2socksPath string, opts LinuxSandboxOptions) (string, error) {
|
||||
if _, err := exec.LookPath("bwrap"); err != nil {
|
||||
return "", fmt.Errorf("bubblewrap (bwrap) is required on Linux but not found: %w", err)
|
||||
}
|
||||
@@ -336,19 +333,6 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Available features: %s\n", features.Summary())
|
||||
}
|
||||
|
||||
// Check if allowedDomains contains "*" (wildcard = allow all direct network)
|
||||
// In this mode, we skip network namespace isolation so apps that don't
|
||||
// respect HTTP_PROXY can make direct connections.
|
||||
hasWildcardAllow := false
|
||||
if cfg != nil {
|
||||
hasWildcardAllow = slices.Contains(cfg.Network.AllowedDomains, "*")
|
||||
}
|
||||
|
||||
if opts.Debug && hasWildcardAllow {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Wildcard allowedDomains detected - allowing direct network connections\n")
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Note: deniedDomains only enforced for apps that respect HTTP_PROXY\n")
|
||||
}
|
||||
|
||||
// Build bwrap args with filesystem restrictions
|
||||
bwrapArgs := []string{
|
||||
"bwrap",
|
||||
@@ -356,13 +340,11 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
|
||||
"--die-with-parent",
|
||||
}
|
||||
|
||||
// Only use --unshare-net if:
|
||||
// 1. The environment supports it (has CAP_NET_ADMIN)
|
||||
// 2. We're NOT in wildcard mode (need direct network access)
|
||||
// Containerized environments (Docker, CI) often lack CAP_NET_ADMIN
|
||||
if features.CanUnshareNet && !hasWildcardAllow {
|
||||
// Always use --unshare-net when available (network namespace isolation)
|
||||
// Inside the namespace, tun2socks will provide transparent proxy access
|
||||
if features.CanUnshareNet {
|
||||
bwrapArgs = append(bwrapArgs, "--unshare-net") // Network namespace isolation
|
||||
} else if opts.Debug && !features.CanUnshareNet {
|
||||
} else if opts.Debug {
|
||||
fmt.Fprintf(os.Stderr, "[fence:linux] Skipping --unshare-net (network namespace unavailable in this environment)\n")
|
||||
}
|
||||
|
||||
@@ -603,12 +585,19 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
|
||||
}
|
||||
}
|
||||
|
||||
// Bind the outbound Unix sockets into the sandbox (need to be writable)
|
||||
if bridge != nil {
|
||||
// Bind the proxy bridge Unix socket into the sandbox (needs to be writable)
|
||||
if proxyBridge != nil {
|
||||
bwrapArgs = append(bwrapArgs,
|
||||
"--bind", bridge.HTTPSocketPath, bridge.HTTPSocketPath,
|
||||
"--bind", bridge.SOCKSSocketPath, bridge.SOCKSSocketPath,
|
||||
"--bind", proxyBridge.SocketPath, proxyBridge.SocketPath,
|
||||
)
|
||||
// Bind /dev/net/tun for TUN device creation inside the sandbox
|
||||
if features.HasDevNetTun {
|
||||
bwrapArgs = append(bwrapArgs, "--dev-bind", "/dev/net/tun", "/dev/net/tun")
|
||||
}
|
||||
// Bind the tun2socks binary into the sandbox (read-only)
|
||||
if tun2socksPath != "" {
|
||||
bwrapArgs = append(bwrapArgs, "--ro-bind", tun2socksPath, "/tmp/fence-tun2socks")
|
||||
}
|
||||
}
|
||||
|
||||
// Bind reverse socket directory if needed (sockets created inside sandbox)
|
||||
@@ -637,32 +626,48 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
|
||||
|
||||
bwrapArgs = append(bwrapArgs, "--", shellPath, "-c")
|
||||
|
||||
// Build the inner command that sets up socat listeners and runs the user command
|
||||
// Build the inner command that sets up tun2socks and runs the user command
|
||||
var innerScript strings.Builder
|
||||
|
||||
if bridge != nil {
|
||||
// Set up outbound socat listeners inside the sandbox
|
||||
innerScript.WriteString("export FENCE_SANDBOX=1\n")
|
||||
|
||||
if proxyBridge != nil && tun2socksPath != "" && features.CanUseTransparentProxy() {
|
||||
// Set up transparent proxy via TUN device + tun2socks
|
||||
innerScript.WriteString(fmt.Sprintf(`
|
||||
# Start HTTP proxy listener (port 3128 -> Unix socket -> host HTTP proxy)
|
||||
socat TCP-LISTEN:3128,fork,reuseaddr UNIX-CONNECT:%s >/dev/null 2>&1 &
|
||||
HTTP_PID=$!
|
||||
# Set up TUN device for transparent proxying
|
||||
ip tuntap add dev tun0 mode tun
|
||||
ip addr add 198.18.0.1/15 dev tun0
|
||||
ip link set dev tun0 up
|
||||
ip route add default via 198.18.0.1 dev tun0
|
||||
|
||||
# Start SOCKS proxy listener (port 1080 -> Unix socket -> host SOCKS proxy)
|
||||
socat TCP-LISTEN:1080,fork,reuseaddr UNIX-CONNECT:%s >/dev/null 2>&1 &
|
||||
SOCKS_PID=$!
|
||||
# Bridge: local port -> Unix socket -> host -> external SOCKS5 proxy
|
||||
PROXY_PORT=18321
|
||||
socat TCP-LISTEN:${PROXY_PORT},fork,reuseaddr,bind=127.0.0.1 UNIX-CONNECT:%s >/dev/null 2>&1 &
|
||||
BRIDGE_PID=$!
|
||||
|
||||
# Set proxy environment variables
|
||||
export HTTP_PROXY=http://127.0.0.1:3128
|
||||
export HTTPS_PROXY=http://127.0.0.1:3128
|
||||
export http_proxy=http://127.0.0.1:3128
|
||||
export https_proxy=http://127.0.0.1:3128
|
||||
export ALL_PROXY=socks5h://127.0.0.1:1080
|
||||
export all_proxy=socks5h://127.0.0.1:1080
|
||||
# Start tun2socks (transparent proxy via gvisor netstack)
|
||||
/tmp/fence-tun2socks -device tun0 -proxy socks5://127.0.0.1:${PROXY_PORT} >/dev/null 2>&1 &
|
||||
TUN2SOCKS_PID=$!
|
||||
|
||||
`, proxyBridge.SocketPath))
|
||||
} else if proxyBridge != nil {
|
||||
// Fallback: no TUN support, use env-var-based proxying
|
||||
innerScript.WriteString(fmt.Sprintf(`
|
||||
# Set up SOCKS5 bridge (no TUN available, env-var-based proxying)
|
||||
PROXY_PORT=18321
|
||||
socat TCP-LISTEN:${PROXY_PORT},fork,reuseaddr,bind=127.0.0.1 UNIX-CONNECT:%s >/dev/null 2>&1 &
|
||||
BRIDGE_PID=$!
|
||||
|
||||
export ALL_PROXY=socks5h://127.0.0.1:${PROXY_PORT}
|
||||
export all_proxy=socks5h://127.0.0.1:${PROXY_PORT}
|
||||
export HTTP_PROXY=socks5h://127.0.0.1:${PROXY_PORT}
|
||||
export HTTPS_PROXY=socks5h://127.0.0.1:${PROXY_PORT}
|
||||
export http_proxy=socks5h://127.0.0.1:${PROXY_PORT}
|
||||
export https_proxy=socks5h://127.0.0.1:${PROXY_PORT}
|
||||
export NO_PROXY=localhost,127.0.0.1
|
||||
export no_proxy=localhost,127.0.0.1
|
||||
export FENCE_SANDBOX=1
|
||||
|
||||
`, bridge.HTTPSocketPath, bridge.SOCKSSocketPath))
|
||||
`, proxyBridge.SocketPath))
|
||||
}
|
||||
|
||||
// Set up reverse (inbound) socat listeners inside the sandbox
|
||||
@@ -688,8 +693,8 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Small delay to ensure socat listeners are ready
|
||||
sleep 0.1
|
||||
# Small delay to ensure services are ready
|
||||
sleep 0.3
|
||||
|
||||
# Run the user command
|
||||
`)
|
||||
@@ -729,6 +734,11 @@ sleep 0.1
|
||||
} else {
|
||||
featureList = append(featureList, "bwrap(pid,fs)")
|
||||
}
|
||||
if proxyBridge != nil && features.CanUseTransparentProxy() {
|
||||
featureList = append(featureList, "tun2socks(transparent)")
|
||||
} else if proxyBridge != nil {
|
||||
featureList = append(featureList, "proxy(env-vars)")
|
||||
}
|
||||
if features.HasSeccomp && opts.UseSeccomp && seccompFilterPath != "" {
|
||||
featureList = append(featureList, "seccomp")
|
||||
}
|
||||
@@ -818,6 +828,9 @@ func PrintLinuxFeatures() {
|
||||
fmt.Printf(" Seccomp: %v (log level: %d)\n", features.HasSeccomp, features.SeccompLogLevel)
|
||||
fmt.Printf(" Landlock: %v (ABI v%d)\n", features.HasLandlock, features.LandlockABI)
|
||||
fmt.Printf(" eBPF: %v (CAP_BPF: %v, root: %v)\n", features.HasEBPF, features.HasCapBPF, features.HasCapRoot)
|
||||
fmt.Printf(" ip (iproute2): %v\n", features.HasIpCommand)
|
||||
fmt.Printf(" /dev/net/tun: %v\n", features.HasDevNetTun)
|
||||
fmt.Printf(" tun2socks: %v (embedded)\n", features.HasTun2Socks)
|
||||
|
||||
fmt.Printf("\nFeature Status:\n")
|
||||
if features.MinimumViable() {
|
||||
@@ -841,6 +854,12 @@ func PrintLinuxFeatures() {
|
||||
fmt.Printf(" This is common in Docker, GitHub Actions, and other CI systems.\n")
|
||||
}
|
||||
|
||||
if features.CanUseTransparentProxy() {
|
||||
fmt.Printf(" ✓ Transparent proxy available (tun2socks + TUN device)\n")
|
||||
} else {
|
||||
fmt.Printf(" ○ Transparent proxy not available (needs ip, /dev/net/tun, network namespace)\n")
|
||||
}
|
||||
|
||||
if features.CanUseLandlock() {
|
||||
fmt.Printf(" ✓ Landlock available for enhanced filesystem control\n")
|
||||
} else {
|
||||
|
||||
@@ -35,6 +35,11 @@ type LinuxFeatures struct {
|
||||
// This can be false in containerized environments (Docker, CI) without CAP_NET_ADMIN
|
||||
CanUnshareNet bool
|
||||
|
||||
// Transparent proxy support
|
||||
HasIpCommand bool // ip (iproute2) available
|
||||
HasDevNetTun bool // /dev/net/tun exists
|
||||
HasTun2Socks bool // tun2socks embedded binary available
|
||||
|
||||
// Kernel version
|
||||
KernelMajor int
|
||||
KernelMinor int
|
||||
@@ -74,6 +79,12 @@ func (f *LinuxFeatures) detect() {
|
||||
|
||||
// Check if we can create network namespaces
|
||||
f.detectNetworkNamespace()
|
||||
|
||||
// Check transparent proxy support
|
||||
f.HasIpCommand = commandExists("ip")
|
||||
_, err := os.Stat("/dev/net/tun")
|
||||
f.HasDevNetTun = err == nil
|
||||
f.HasTun2Socks = true // embedded binary, always available
|
||||
}
|
||||
|
||||
func (f *LinuxFeatures) parseKernelVersion() {
|
||||
@@ -255,6 +266,11 @@ func (f *LinuxFeatures) CanUseLandlock() bool {
|
||||
return f.HasLandlock && f.LandlockABI >= 1
|
||||
}
|
||||
|
||||
// CanUseTransparentProxy returns true if transparent proxying via tun2socks is possible.
|
||||
func (f *LinuxFeatures) CanUseTransparentProxy() bool {
|
||||
return f.HasIpCommand && f.HasDevNetTun && f.CanUnshareNet
|
||||
}
|
||||
|
||||
// MinimumViable returns true if the minimum required features are available.
|
||||
func (f *LinuxFeatures) MinimumViable() bool {
|
||||
return f.HasBwrap && f.HasSocat
|
||||
|
||||
@@ -15,6 +15,9 @@ type LinuxFeatures struct {
|
||||
HasCapBPF bool
|
||||
HasCapRoot bool
|
||||
CanUnshareNet bool
|
||||
HasIpCommand bool
|
||||
HasDevNetTun bool
|
||||
HasTun2Socks bool
|
||||
KernelMajor int
|
||||
KernelMinor int
|
||||
}
|
||||
@@ -39,6 +42,11 @@ func (f *LinuxFeatures) CanUseLandlock() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// CanUseTransparentProxy returns false on non-Linux platforms.
|
||||
func (f *LinuxFeatures) CanUseTransparentProxy() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// MinimumViable returns false on non-Linux platforms.
|
||||
func (f *LinuxFeatures) MinimumViable() bool {
|
||||
return false
|
||||
|
||||
@@ -8,10 +8,11 @@ import (
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
)
|
||||
|
||||
// LinuxBridge is a stub for non-Linux platforms.
|
||||
type LinuxBridge struct {
|
||||
HTTPSocketPath string
|
||||
SOCKSSocketPath string
|
||||
// ProxyBridge is a stub for non-Linux platforms.
|
||||
type ProxyBridge struct {
|
||||
SocketPath string
|
||||
ProxyHost string
|
||||
ProxyPort string
|
||||
}
|
||||
|
||||
// ReverseBridge is a stub for non-Linux platforms.
|
||||
@@ -29,13 +30,13 @@ type LinuxSandboxOptions struct {
|
||||
Debug bool
|
||||
}
|
||||
|
||||
// NewLinuxBridge returns an error on non-Linux platforms.
|
||||
func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge, error) {
|
||||
return nil, fmt.Errorf("Linux bridge not available on this platform")
|
||||
// NewProxyBridge returns an error on non-Linux platforms.
|
||||
func NewProxyBridge(proxyURL string, debug bool) (*ProxyBridge, error) {
|
||||
return nil, fmt.Errorf("proxy bridge not available on this platform")
|
||||
}
|
||||
|
||||
// Cleanup is a no-op on non-Linux platforms.
|
||||
func (b *LinuxBridge) Cleanup() {}
|
||||
func (b *ProxyBridge) Cleanup() {}
|
||||
|
||||
// NewReverseBridge returns an error on non-Linux platforms.
|
||||
func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
|
||||
@@ -46,12 +47,12 @@ func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
|
||||
func (b *ReverseBridge) Cleanup() {}
|
||||
|
||||
// WrapCommandLinux returns an error on non-Linux platforms.
|
||||
func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool) (string, error) {
|
||||
func WrapCommandLinux(cfg *config.Config, command string, proxyBridge *ProxyBridge, reverseBridge *ReverseBridge, tun2socksPath string, debug bool) (string, error) {
|
||||
return "", fmt.Errorf("Linux sandbox not available on this platform")
|
||||
}
|
||||
|
||||
// WrapCommandLinuxWithOptions returns an error on non-Linux platforms.
|
||||
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, opts LinuxSandboxOptions) (string, error) {
|
||||
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, proxyBridge *ProxyBridge, reverseBridge *ReverseBridge, tun2socksPath string, opts LinuxSandboxOptions) (string, error) {
|
||||
return "", fmt.Errorf("Linux sandbox not available on this platform")
|
||||
}
|
||||
|
||||
|
||||
@@ -6,155 +6,34 @@ import (
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
)
|
||||
|
||||
// TestLinux_WildcardAllowedDomainsSkipsUnshareNet verifies that when allowedDomains
|
||||
// contains "*", the Linux sandbox does NOT use --unshare-net, allowing direct
|
||||
// network connections for applications that don't respect HTTP_PROXY.
|
||||
func TestLinux_WildcardAllowedDomainsSkipsUnshareNet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
allowedDomains []string
|
||||
wantUnshareNet bool
|
||||
}{
|
||||
{
|
||||
name: "no domains - uses unshare-net",
|
||||
allowedDomains: []string{},
|
||||
wantUnshareNet: true,
|
||||
},
|
||||
{
|
||||
name: "specific domain - uses unshare-net",
|
||||
allowedDomains: []string{"api.openai.com"},
|
||||
wantUnshareNet: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard domain - skips unshare-net",
|
||||
allowedDomains: []string{"*"},
|
||||
wantUnshareNet: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard with specific domains - skips unshare-net",
|
||||
allowedDomains: []string{"api.openai.com", "*"},
|
||||
wantUnshareNet: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard subdomain pattern - uses unshare-net",
|
||||
allowedDomains: []string{"*.openai.com"},
|
||||
wantUnshareNet: true,
|
||||
// TestLinux_NoProxyBlocksNetwork verifies that when no ProxyURL is set,
|
||||
// the Linux sandbox uses --unshare-net to block all network access.
|
||||
func TestLinux_NoProxyBlocksNetwork(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Network: config.NetworkConfig{},
|
||||
Filesystem: config.FilesystemConfig{
|
||||
AllowWrite: []string{"/tmp/test"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: tt.allowedDomains,
|
||||
},
|
||||
Filesystem: config.FilesystemConfig{
|
||||
AllowWrite: []string{"/tmp/test"},
|
||||
},
|
||||
}
|
||||
|
||||
// Check the wildcard detection logic directly
|
||||
hasWildcard := hasWildcardAllowedDomain(cfg)
|
||||
|
||||
if tt.wantUnshareNet && hasWildcard {
|
||||
t.Errorf("expected hasWildcard=false for domains %v, got true", tt.allowedDomains)
|
||||
}
|
||||
if !tt.wantUnshareNet && !hasWildcard {
|
||||
t.Errorf("expected hasWildcard=true for domains %v, got false", tt.allowedDomains)
|
||||
}
|
||||
})
|
||||
// With no proxy, network should be blocked
|
||||
if cfg.Network.ProxyURL != "" {
|
||||
t.Error("expected empty ProxyURL for no-network config")
|
||||
}
|
||||
}
|
||||
|
||||
// hasWildcardAllowedDomain checks if the config contains a "*" in allowedDomains.
|
||||
// This replicates the logic used in both linux.go and macos.go.
|
||||
func hasWildcardAllowedDomain(cfg *config.Config) bool {
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
for _, d := range cfg.Network.AllowedDomains {
|
||||
if d == "*" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TestWildcardDetectionLogic tests the wildcard detection helper.
|
||||
// This logic is shared between macOS and Linux sandbox implementations.
|
||||
func TestWildcardDetectionLogic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *config.Config
|
||||
expectWildcard bool
|
||||
}{
|
||||
{
|
||||
name: "nil config",
|
||||
cfg: nil,
|
||||
expectWildcard: false,
|
||||
// TestLinux_ProxyURLSet verifies that a proxy URL is properly set in config.
|
||||
func TestLinux_ProxyURLSet(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
ProxyURL: "socks5://localhost:1080",
|
||||
},
|
||||
{
|
||||
name: "empty allowed domains",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{},
|
||||
},
|
||||
},
|
||||
expectWildcard: false,
|
||||
},
|
||||
{
|
||||
name: "specific domains only",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"example.com", "api.openai.com"},
|
||||
},
|
||||
},
|
||||
expectWildcard: false,
|
||||
},
|
||||
{
|
||||
name: "exact star wildcard",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"*"},
|
||||
},
|
||||
},
|
||||
expectWildcard: true,
|
||||
},
|
||||
{
|
||||
name: "star wildcard among others",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"example.com", "*", "api.openai.com"},
|
||||
},
|
||||
},
|
||||
expectWildcard: true,
|
||||
},
|
||||
{
|
||||
name: "prefix wildcard is not star",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"*.example.com"},
|
||||
},
|
||||
},
|
||||
expectWildcard: false,
|
||||
},
|
||||
{
|
||||
name: "star in domain name is not wildcard",
|
||||
cfg: &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: []string{"test*domain.com"},
|
||||
},
|
||||
},
|
||||
expectWildcard: false,
|
||||
Filesystem: config.FilesystemConfig{
|
||||
AllowWrite: []string{"/tmp/test"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := hasWildcardAllowedDomain(tt.cfg)
|
||||
if got != tt.expectWildcard {
|
||||
t.Errorf("hasWildcardAllowedDomain() = %v, want %v", got, tt.expectWildcard)
|
||||
}
|
||||
})
|
||||
if cfg.Network.ProxyURL != "socks5://localhost:1080" {
|
||||
t.Errorf("expected ProxyURL socks5://localhost:1080, got %s", cfg.Network.ProxyURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
@@ -29,8 +29,9 @@ func generateSessionSuffix() string {
|
||||
type MacOSSandboxParams struct {
|
||||
Command string
|
||||
NeedsNetworkRestriction bool
|
||||
HTTPProxyPort int
|
||||
SOCKSProxyPort int
|
||||
ProxyURL string // External proxy URL (for env vars)
|
||||
ProxyHost string // Proxy host (for sandbox profile network rules)
|
||||
ProxyPort string // Proxy port (for sandbox profile network rules)
|
||||
AllowUnixSockets []string
|
||||
AllowAllUnixSockets bool
|
||||
AllowLocalBinding bool
|
||||
@@ -519,18 +520,10 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
|
||||
}
|
||||
}
|
||||
|
||||
if params.HTTPProxyPort > 0 {
|
||||
profile.WriteString(fmt.Sprintf(`(allow network-bind (local ip "localhost:%d"))
|
||||
(allow network-inbound (local ip "localhost:%d"))
|
||||
(allow network-outbound (remote ip "localhost:%d"))
|
||||
`, params.HTTPProxyPort, params.HTTPProxyPort, params.HTTPProxyPort))
|
||||
}
|
||||
|
||||
if params.SOCKSProxyPort > 0 {
|
||||
profile.WriteString(fmt.Sprintf(`(allow network-bind (local ip "localhost:%d"))
|
||||
(allow network-inbound (local ip "localhost:%d"))
|
||||
(allow network-outbound (remote ip "localhost:%d"))
|
||||
`, params.SOCKSProxyPort, params.SOCKSProxyPort, params.SOCKSProxyPort))
|
||||
// Allow outbound to the external proxy host:port
|
||||
if params.ProxyHost != "" && params.ProxyPort != "" {
|
||||
profile.WriteString(fmt.Sprintf(`(allow network-outbound (remote ip "%s:%s"))
|
||||
`, params.ProxyHost, params.ProxyPort))
|
||||
}
|
||||
}
|
||||
profile.WriteString("\n")
|
||||
@@ -568,15 +561,7 @@ func GenerateSandboxProfile(params MacOSSandboxParams) string {
|
||||
}
|
||||
|
||||
// WrapCommandMacOS wraps a command with macOS sandbox restrictions.
|
||||
func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort int, exposedPorts []int, debug bool) (string, error) {
|
||||
// Check if allowedDomains contains "*" (wildcard = allow all direct network)
|
||||
// In this mode, we still run the proxy for apps that respect HTTP_PROXY,
|
||||
// but allow direct connections for apps that don't (like cursor-agent, opencode).
|
||||
// deniedDomains will only be enforced for apps that use the proxy.
|
||||
hasWildcardAllow := slices.Contains(cfg.Network.AllowedDomains, "*")
|
||||
|
||||
needsNetwork := len(cfg.Network.AllowedDomains) > 0 || len(cfg.Network.DeniedDomains) > 0
|
||||
|
||||
func WrapCommandMacOS(cfg *config.Config, command string, exposedPorts []int, debug bool) (string, error) {
|
||||
// Build allow paths: default + configured
|
||||
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
|
||||
|
||||
@@ -591,20 +576,25 @@ func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort in
|
||||
allowLocalOutbound = *cfg.Network.AllowLocalOutbound
|
||||
}
|
||||
|
||||
// If wildcard allow, don't restrict network at sandbox level (allow direct connections).
|
||||
// Otherwise, restrict to localhost/proxy only (strict mode).
|
||||
needsNetworkRestriction := !hasWildcardAllow && (needsNetwork || len(cfg.Network.AllowedDomains) == 0)
|
||||
|
||||
if debug && hasWildcardAllow {
|
||||
fmt.Fprintf(os.Stderr, "[fence:macos] Wildcard allowedDomains detected - allowing direct network connections\n")
|
||||
fmt.Fprintf(os.Stderr, "[fence:macos] Note: deniedDomains only enforced for apps that respect HTTP_PROXY\n")
|
||||
// Parse proxy URL for network rules
|
||||
var proxyHost, proxyPort string
|
||||
if cfg.Network.ProxyURL != "" {
|
||||
if u, err := url.Parse(cfg.Network.ProxyURL); err == nil {
|
||||
proxyHost = u.Hostname()
|
||||
proxyPort = u.Port()
|
||||
}
|
||||
}
|
||||
|
||||
// Restrict network unless proxy is configured to an external host
|
||||
// If no proxy: block all outbound. If proxy: allow outbound only to proxy.
|
||||
needsNetworkRestriction := true
|
||||
|
||||
params := MacOSSandboxParams{
|
||||
Command: command,
|
||||
NeedsNetworkRestriction: needsNetworkRestriction,
|
||||
HTTPProxyPort: httpPort,
|
||||
SOCKSProxyPort: socksPort,
|
||||
ProxyURL: cfg.Network.ProxyURL,
|
||||
ProxyHost: proxyHost,
|
||||
ProxyPort: proxyPort,
|
||||
AllowUnixSockets: cfg.Network.AllowUnixSockets,
|
||||
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
||||
AllowLocalBinding: allowLocalBinding,
|
||||
@@ -637,7 +627,7 @@ func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort in
|
||||
return "", fmt.Errorf("shell %q not found: %w", shell, err)
|
||||
}
|
||||
|
||||
proxyEnvs := GenerateProxyEnvVars(httpPort, socksPort)
|
||||
proxyEnvs := GenerateProxyEnvVars(cfg.Network.ProxyURL)
|
||||
|
||||
// Build the command
|
||||
// env VAR1=val1 VAR2=val2 sandbox-exec -p 'profile' shell -c 'command'
|
||||
|
||||
@@ -7,44 +7,34 @@ import (
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
)
|
||||
|
||||
// TestMacOS_WildcardAllowedDomainsRelaxesNetwork verifies that when allowedDomains
|
||||
// contains "*", the macOS sandbox profile allows direct network connections.
|
||||
func TestMacOS_WildcardAllowedDomainsRelaxesNetwork(t *testing.T) {
|
||||
// TestMacOS_NetworkRestrictionWithProxy verifies that when a proxy URL is set,
|
||||
// the macOS sandbox profile allows outbound to the proxy host:port.
|
||||
func TestMacOS_NetworkRestrictionWithProxy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
allowedDomains []string
|
||||
wantNetworkRestricted bool
|
||||
wantAllowNetworkOutbound bool
|
||||
name string
|
||||
proxyURL string
|
||||
wantProxy bool
|
||||
proxyHost string
|
||||
proxyPort string
|
||||
}{
|
||||
{
|
||||
name: "no domains - network restricted",
|
||||
allowedDomains: []string{},
|
||||
wantNetworkRestricted: true,
|
||||
wantAllowNetworkOutbound: false,
|
||||
name: "no proxy - network blocked",
|
||||
proxyURL: "",
|
||||
wantProxy: false,
|
||||
},
|
||||
{
|
||||
name: "specific domain - network restricted",
|
||||
allowedDomains: []string{"api.openai.com"},
|
||||
wantNetworkRestricted: true,
|
||||
wantAllowNetworkOutbound: false,
|
||||
name: "socks5 proxy - outbound allowed to proxy",
|
||||
proxyURL: "socks5://proxy.example.com:1080",
|
||||
wantProxy: true,
|
||||
proxyHost: "proxy.example.com",
|
||||
proxyPort: "1080",
|
||||
},
|
||||
{
|
||||
name: "wildcard domain - network unrestricted",
|
||||
allowedDomains: []string{"*"},
|
||||
wantNetworkRestricted: false,
|
||||
wantAllowNetworkOutbound: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard with specific domains - network unrestricted",
|
||||
allowedDomains: []string{"api.openai.com", "*"},
|
||||
wantNetworkRestricted: false,
|
||||
wantAllowNetworkOutbound: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard subdomain pattern - network restricted",
|
||||
allowedDomains: []string{"*.openai.com"},
|
||||
wantNetworkRestricted: true,
|
||||
wantAllowNetworkOutbound: false,
|
||||
name: "socks5h proxy - outbound allowed to proxy",
|
||||
proxyURL: "socks5h://localhost:1080",
|
||||
wantProxy: true,
|
||||
proxyHost: "localhost",
|
||||
proxyPort: "1080",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -52,34 +42,33 @@ func TestMacOS_WildcardAllowedDomainsRelaxesNetwork(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Network: config.NetworkConfig{
|
||||
AllowedDomains: tt.allowedDomains,
|
||||
ProxyURL: tt.proxyURL,
|
||||
},
|
||||
Filesystem: config.FilesystemConfig{
|
||||
AllowWrite: []string{"/tmp/test"},
|
||||
},
|
||||
}
|
||||
|
||||
// Generate the sandbox profile parameters
|
||||
params := buildMacOSParamsForTest(cfg)
|
||||
|
||||
if params.NeedsNetworkRestriction != tt.wantNetworkRestricted {
|
||||
t.Errorf("NeedsNetworkRestriction = %v, want %v",
|
||||
params.NeedsNetworkRestriction, tt.wantNetworkRestricted)
|
||||
if tt.wantProxy {
|
||||
if params.ProxyHost != tt.proxyHost {
|
||||
t.Errorf("expected ProxyHost %q, got %q", tt.proxyHost, params.ProxyHost)
|
||||
}
|
||||
if params.ProxyPort != tt.proxyPort {
|
||||
t.Errorf("expected ProxyPort %q, got %q", tt.proxyPort, params.ProxyPort)
|
||||
}
|
||||
|
||||
profile := GenerateSandboxProfile(params)
|
||||
expectedRule := `(allow network-outbound (remote ip "` + tt.proxyHost + ":" + tt.proxyPort + `"))`
|
||||
if !strings.Contains(profile, expectedRule) {
|
||||
t.Errorf("profile should contain proxy outbound rule %q", expectedRule)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the actual profile and check its contents
|
||||
profile := GenerateSandboxProfile(params)
|
||||
|
||||
// When network is unrestricted, profile should allow network* (all network ops)
|
||||
if tt.wantAllowNetworkOutbound {
|
||||
if !strings.Contains(profile, "(allow network*)") {
|
||||
t.Errorf("expected unrestricted network profile to contain '(allow network*)', got:\n%s", profile)
|
||||
}
|
||||
} else {
|
||||
// When network is restricted, profile should NOT have blanket allow
|
||||
if strings.Contains(profile, "(allow network*)") {
|
||||
t.Errorf("expected restricted network profile to NOT contain blanket '(allow network*)'")
|
||||
}
|
||||
// Network should always be restricted (proxy or not)
|
||||
if !params.NeedsNetworkRestriction {
|
||||
t.Error("NeedsNetworkRestriction should always be true")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -88,15 +77,6 @@ func TestMacOS_WildcardAllowedDomainsRelaxesNetwork(t *testing.T) {
|
||||
// buildMacOSParamsForTest is a helper to build MacOSSandboxParams from config,
|
||||
// replicating the logic in WrapCommandMacOS for testing.
|
||||
func buildMacOSParamsForTest(cfg *config.Config) MacOSSandboxParams {
|
||||
hasWildcardAllow := false
|
||||
for _, d := range cfg.Network.AllowedDomains {
|
||||
if d == "*" {
|
||||
hasWildcardAllow = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
needsNetwork := len(cfg.Network.AllowedDomains) > 0 || len(cfg.Network.DeniedDomains) > 0
|
||||
allowPaths := append(GetDefaultWritePaths(), cfg.Filesystem.AllowWrite...)
|
||||
allowLocalBinding := cfg.Network.AllowLocalBinding
|
||||
allowLocalOutbound := allowLocalBinding
|
||||
@@ -104,13 +84,26 @@ func buildMacOSParamsForTest(cfg *config.Config) MacOSSandboxParams {
|
||||
allowLocalOutbound = *cfg.Network.AllowLocalOutbound
|
||||
}
|
||||
|
||||
needsNetworkRestriction := !hasWildcardAllow && (needsNetwork || len(cfg.Network.AllowedDomains) == 0)
|
||||
var proxyHost, proxyPort string
|
||||
if cfg.Network.ProxyURL != "" {
|
||||
// Simple parsing for tests
|
||||
parts := strings.SplitN(cfg.Network.ProxyURL, "://", 2)
|
||||
if len(parts) == 2 {
|
||||
hostPort := parts[1]
|
||||
colonIdx := strings.LastIndex(hostPort, ":")
|
||||
if colonIdx >= 0 {
|
||||
proxyHost = hostPort[:colonIdx]
|
||||
proxyPort = hostPort[colonIdx+1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return MacOSSandboxParams{
|
||||
Command: "echo test",
|
||||
NeedsNetworkRestriction: needsNetworkRestriction,
|
||||
HTTPProxyPort: 8080,
|
||||
SOCKSProxyPort: 1080,
|
||||
NeedsNetworkRestriction: true,
|
||||
ProxyURL: cfg.Network.ProxyURL,
|
||||
ProxyHost: proxyHost,
|
||||
ProxyPort: proxyPort,
|
||||
AllowUnixSockets: cfg.Network.AllowUnixSockets,
|
||||
AllowAllUnixSockets: cfg.Network.AllowAllUnixSockets,
|
||||
AllowLocalBinding: allowLocalBinding,
|
||||
@@ -158,8 +151,6 @@ func TestMacOS_ProfileNetworkSection(t *testing.T) {
|
||||
params := MacOSSandboxParams{
|
||||
Command: "echo test",
|
||||
NeedsNetworkRestriction: tt.restricted,
|
||||
HTTPProxyPort: 8080,
|
||||
SOCKSProxyPort: 1080,
|
||||
}
|
||||
|
||||
profile := GenerateSandboxProfile(params)
|
||||
@@ -195,8 +186,8 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
|
||||
defaultDenyRead: false,
|
||||
allowRead: nil,
|
||||
wantContainsBlanketAllow: true,
|
||||
wantContainsMetadataAllow: false, // No separate metadata allow needed
|
||||
wantContainsSystemAllows: false, // No need for explicit system allows
|
||||
wantContainsMetadataAllow: false,
|
||||
wantContainsSystemAllows: false,
|
||||
wantContainsUserAllowRead: false,
|
||||
},
|
||||
{
|
||||
@@ -204,8 +195,8 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
|
||||
defaultDenyRead: true,
|
||||
allowRead: nil,
|
||||
wantContainsBlanketAllow: false,
|
||||
wantContainsMetadataAllow: true, // Should have file-read-metadata for traversal
|
||||
wantContainsSystemAllows: true, // Should have explicit system path allows
|
||||
wantContainsMetadataAllow: true,
|
||||
wantContainsSystemAllows: true,
|
||||
wantContainsUserAllowRead: false,
|
||||
},
|
||||
{
|
||||
@@ -223,35 +214,28 @@ func TestMacOS_DefaultDenyRead(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
params := MacOSSandboxParams{
|
||||
Command: "echo test",
|
||||
HTTPProxyPort: 8080,
|
||||
SOCKSProxyPort: 1080,
|
||||
DefaultDenyRead: tt.defaultDenyRead,
|
||||
ReadAllowPaths: tt.allowRead,
|
||||
}
|
||||
|
||||
profile := GenerateSandboxProfile(params)
|
||||
|
||||
// Check for blanket "(allow file-read*)" without path restrictions
|
||||
// This appears at the start of read rules section in default mode
|
||||
hasBlanketAllow := strings.Contains(profile, "(allow file-read*)\n")
|
||||
if hasBlanketAllow != tt.wantContainsBlanketAllow {
|
||||
t.Errorf("blanket file-read allow = %v, want %v", hasBlanketAllow, tt.wantContainsBlanketAllow)
|
||||
}
|
||||
|
||||
// Check for file-read-metadata allow (for directory traversal in defaultDenyRead mode)
|
||||
hasMetadataAllow := strings.Contains(profile, "(allow file-read-metadata)")
|
||||
if hasMetadataAllow != tt.wantContainsMetadataAllow {
|
||||
t.Errorf("file-read-metadata allow = %v, want %v", hasMetadataAllow, tt.wantContainsMetadataAllow)
|
||||
}
|
||||
|
||||
// Check for system path allows (e.g., /usr, /bin) - should use file-read-data in strict mode
|
||||
hasSystemAllows := strings.Contains(profile, `(subpath "/usr")`) ||
|
||||
strings.Contains(profile, `(subpath "/bin")`)
|
||||
if hasSystemAllows != tt.wantContainsSystemAllows {
|
||||
t.Errorf("system path allows = %v, want %v\nProfile:\n%s", hasSystemAllows, tt.wantContainsSystemAllows, profile)
|
||||
}
|
||||
|
||||
// Check for user-specified allowRead paths
|
||||
if tt.wantContainsUserAllowRead && len(tt.allowRead) > 0 {
|
||||
hasUserAllow := strings.Contains(profile, tt.allowRead[0])
|
||||
if !hasUserAllow {
|
||||
|
||||
@@ -6,18 +6,14 @@ import (
|
||||
|
||||
"github.com/Use-Tusk/fence/internal/config"
|
||||
"github.com/Use-Tusk/fence/internal/platform"
|
||||
"github.com/Use-Tusk/fence/internal/proxy"
|
||||
)
|
||||
|
||||
// Manager handles sandbox initialization and command wrapping.
|
||||
type Manager struct {
|
||||
config *config.Config
|
||||
httpProxy *proxy.HTTPProxy
|
||||
socksProxy *proxy.SOCKSProxy
|
||||
linuxBridge *LinuxBridge
|
||||
proxyBridge *ProxyBridge
|
||||
reverseBridge *ReverseBridge
|
||||
httpPort int
|
||||
socksPort int
|
||||
tun2socksPath string // path to extracted tun2socks binary on host
|
||||
exposedPorts []int
|
||||
debug bool
|
||||
monitor bool
|
||||
@@ -38,7 +34,7 @@ func (m *Manager) SetExposedPorts(ports []int) {
|
||||
m.exposedPorts = ports
|
||||
}
|
||||
|
||||
// Initialize sets up the sandbox infrastructure (proxies, etc.).
|
||||
// Initialize sets up the sandbox infrastructure.
|
||||
func (m *Manager) Initialize() error {
|
||||
if m.initialized {
|
||||
return nil
|
||||
@@ -48,32 +44,27 @@ func (m *Manager) Initialize() error {
|
||||
return fmt.Errorf("sandbox is not supported on platform: %s", platform.Detect())
|
||||
}
|
||||
|
||||
filter := proxy.CreateDomainFilter(m.config, m.debug)
|
||||
|
||||
m.httpProxy = proxy.NewHTTPProxy(filter, m.debug, m.monitor)
|
||||
httpPort, err := m.httpProxy.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start HTTP proxy: %w", err)
|
||||
}
|
||||
m.httpPort = httpPort
|
||||
|
||||
m.socksProxy = proxy.NewSOCKSProxy(filter, m.debug, m.monitor)
|
||||
socksPort, err := m.socksProxy.Start()
|
||||
if err != nil {
|
||||
_ = m.httpProxy.Stop()
|
||||
return fmt.Errorf("failed to start SOCKS proxy: %w", err)
|
||||
}
|
||||
m.socksPort = socksPort
|
||||
|
||||
// On Linux, set up the socat bridges
|
||||
// On Linux, set up proxy bridge and tun2socks if proxy is configured
|
||||
if platform.Detect() == platform.Linux {
|
||||
bridge, err := NewLinuxBridge(m.httpPort, m.socksPort, m.debug)
|
||||
if err != nil {
|
||||
_ = m.httpProxy.Stop()
|
||||
_ = m.socksProxy.Stop()
|
||||
return fmt.Errorf("failed to initialize Linux bridge: %w", err)
|
||||
if m.config.Network.ProxyURL != "" {
|
||||
// Extract embedded tun2socks binary
|
||||
tun2socksPath, err := extractTun2Socks()
|
||||
if err != nil {
|
||||
m.logDebug("Failed to extract tun2socks: %v (will fall back to env-var proxying)", err)
|
||||
} else {
|
||||
m.tun2socksPath = tun2socksPath
|
||||
}
|
||||
|
||||
// Create proxy bridge (socat: Unix socket -> external SOCKS5 proxy)
|
||||
bridge, err := NewProxyBridge(m.config.Network.ProxyURL, m.debug)
|
||||
if err != nil {
|
||||
if m.tun2socksPath != "" {
|
||||
os.Remove(m.tun2socksPath)
|
||||
}
|
||||
return fmt.Errorf("failed to initialize proxy bridge: %w", err)
|
||||
}
|
||||
m.proxyBridge = bridge
|
||||
}
|
||||
m.linuxBridge = bridge
|
||||
|
||||
// Set up reverse bridge for exposed ports (inbound connections)
|
||||
// Only needed when network namespace is available - otherwise they share the network
|
||||
@@ -81,9 +72,12 @@ func (m *Manager) Initialize() error {
|
||||
if len(m.exposedPorts) > 0 && features.CanUnshareNet {
|
||||
reverseBridge, err := NewReverseBridge(m.exposedPorts, m.debug)
|
||||
if err != nil {
|
||||
m.linuxBridge.Cleanup()
|
||||
_ = m.httpProxy.Stop()
|
||||
_ = m.socksProxy.Stop()
|
||||
if m.proxyBridge != nil {
|
||||
m.proxyBridge.Cleanup()
|
||||
}
|
||||
if m.tun2socksPath != "" {
|
||||
os.Remove(m.tun2socksPath)
|
||||
}
|
||||
return fmt.Errorf("failed to initialize reverse bridge: %w", err)
|
||||
}
|
||||
m.reverseBridge = reverseBridge
|
||||
@@ -93,7 +87,11 @@ func (m *Manager) Initialize() error {
|
||||
}
|
||||
|
||||
m.initialized = true
|
||||
m.logDebug("Sandbox manager initialized (HTTP proxy: %d, SOCKS proxy: %d)", m.httpPort, m.socksPort)
|
||||
if m.config.Network.ProxyURL != "" {
|
||||
m.logDebug("Sandbox manager initialized (proxy: %s)", m.config.Network.ProxyURL)
|
||||
} else {
|
||||
m.logDebug("Sandbox manager initialized (no proxy, network blocked)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -114,9 +112,9 @@ func (m *Manager) WrapCommand(command string) (string, error) {
|
||||
plat := platform.Detect()
|
||||
switch plat {
|
||||
case platform.MacOS:
|
||||
return WrapCommandMacOS(m.config, command, m.httpPort, m.socksPort, m.exposedPorts, m.debug)
|
||||
return WrapCommandMacOS(m.config, command, m.exposedPorts, m.debug)
|
||||
case platform.Linux:
|
||||
return WrapCommandLinux(m.config, command, m.linuxBridge, m.reverseBridge, m.debug)
|
||||
return WrapCommandLinux(m.config, command, m.proxyBridge, m.reverseBridge, m.tun2socksPath, m.debug)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported platform: %s", plat)
|
||||
}
|
||||
@@ -127,14 +125,11 @@ func (m *Manager) Cleanup() {
|
||||
if m.reverseBridge != nil {
|
||||
m.reverseBridge.Cleanup()
|
||||
}
|
||||
if m.linuxBridge != nil {
|
||||
m.linuxBridge.Cleanup()
|
||||
if m.proxyBridge != nil {
|
||||
m.proxyBridge.Cleanup()
|
||||
}
|
||||
if m.httpProxy != nil {
|
||||
_ = m.httpProxy.Stop()
|
||||
}
|
||||
if m.socksProxy != nil {
|
||||
_ = m.socksProxy.Stop()
|
||||
if m.tun2socksPath != "" {
|
||||
os.Remove(m.tun2socksPath)
|
||||
}
|
||||
m.logDebug("Sandbox manager cleaned up")
|
||||
}
|
||||
@@ -144,13 +139,3 @@ func (m *Manager) logDebug(format string, args ...interface{}) {
|
||||
fmt.Fprintf(os.Stderr, "[fence] "+format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPPort returns the HTTP proxy port.
|
||||
func (m *Manager) HTTPPort() int {
|
||||
return m.httpPort
|
||||
}
|
||||
|
||||
// SOCKSPort returns the SOCKS proxy port.
|
||||
func (m *Manager) SOCKSPort() int {
|
||||
return m.socksPort
|
||||
}
|
||||
|
||||
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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -49,13 +48,14 @@ func NormalizePath(pathPattern string) string {
|
||||
}
|
||||
|
||||
// GenerateProxyEnvVars creates environment variables for proxy configuration.
|
||||
func GenerateProxyEnvVars(httpPort, socksPort int) []string {
|
||||
// Used on macOS where transparent proxying is not available.
|
||||
func GenerateProxyEnvVars(proxyURL string) []string {
|
||||
envVars := []string{
|
||||
"FENCE_SANDBOX=1",
|
||||
"TMPDIR=/tmp/fence",
|
||||
}
|
||||
|
||||
if httpPort == 0 && socksPort == 0 {
|
||||
if proxyURL == "" {
|
||||
return envVars
|
||||
}
|
||||
|
||||
@@ -75,32 +75,14 @@ func GenerateProxyEnvVars(httpPort, socksPort int) []string {
|
||||
envVars = append(envVars,
|
||||
"NO_PROXY="+noProxy,
|
||||
"no_proxy="+noProxy,
|
||||
"ALL_PROXY="+proxyURL,
|
||||
"all_proxy="+proxyURL,
|
||||
"HTTP_PROXY="+proxyURL,
|
||||
"HTTPS_PROXY="+proxyURL,
|
||||
"http_proxy="+proxyURL,
|
||||
"https_proxy="+proxyURL,
|
||||
)
|
||||
|
||||
if httpPort > 0 {
|
||||
proxyURL := "http://localhost:" + itoa(httpPort)
|
||||
envVars = append(envVars,
|
||||
"HTTP_PROXY="+proxyURL,
|
||||
"HTTPS_PROXY="+proxyURL,
|
||||
"http_proxy="+proxyURL,
|
||||
"https_proxy="+proxyURL,
|
||||
)
|
||||
}
|
||||
|
||||
if socksPort > 0 {
|
||||
socksURL := "socks5h://localhost:" + itoa(socksPort)
|
||||
envVars = append(envVars,
|
||||
"ALL_PROXY="+socksURL,
|
||||
"all_proxy="+socksURL,
|
||||
"FTP_PROXY="+socksURL,
|
||||
"ftp_proxy="+socksURL,
|
||||
)
|
||||
// Git SSH through SOCKS
|
||||
envVars = append(envVars,
|
||||
"GIT_SSH_COMMAND=ssh -o ProxyCommand='nc -X 5 -x localhost:"+itoa(socksPort)+" %h %p'",
|
||||
)
|
||||
}
|
||||
|
||||
return envVars
|
||||
}
|
||||
|
||||
@@ -121,6 +103,3 @@ func DecodeSandboxedCommand(encoded string) (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
@@ -125,16 +125,14 @@ func TestNormalizePath(t *testing.T) {
|
||||
|
||||
func TestGenerateProxyEnvVars(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
httpPort int
|
||||
socksPort int
|
||||
wantEnvs []string
|
||||
dontWant []string
|
||||
name string
|
||||
proxyURL string
|
||||
wantEnvs []string
|
||||
dontWant []string
|
||||
}{
|
||||
{
|
||||
name: "no ports",
|
||||
httpPort: 0,
|
||||
socksPort: 0,
|
||||
name: "no proxy",
|
||||
proxyURL: "",
|
||||
wantEnvs: []string{
|
||||
"FENCE_SANDBOX=1",
|
||||
"TMPDIR=/tmp/fence",
|
||||
@@ -146,56 +144,34 @@ func TestGenerateProxyEnvVars(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "http port only",
|
||||
httpPort: 8080,
|
||||
socksPort: 0,
|
||||
name: "socks5 proxy",
|
||||
proxyURL: "socks5://localhost:1080",
|
||||
wantEnvs: []string{
|
||||
"FENCE_SANDBOX=1",
|
||||
"HTTP_PROXY=http://localhost:8080",
|
||||
"HTTPS_PROXY=http://localhost:8080",
|
||||
"http_proxy=http://localhost:8080",
|
||||
"https_proxy=http://localhost:8080",
|
||||
"ALL_PROXY=socks5://localhost:1080",
|
||||
"all_proxy=socks5://localhost:1080",
|
||||
"HTTP_PROXY=socks5://localhost:1080",
|
||||
"HTTPS_PROXY=socks5://localhost:1080",
|
||||
"http_proxy=socks5://localhost:1080",
|
||||
"https_proxy=socks5://localhost:1080",
|
||||
"NO_PROXY=",
|
||||
"no_proxy=",
|
||||
},
|
||||
dontWant: []string{
|
||||
"ALL_PROXY=",
|
||||
"all_proxy=",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "socks port only",
|
||||
httpPort: 0,
|
||||
socksPort: 1080,
|
||||
name: "socks5h proxy",
|
||||
proxyURL: "socks5h://proxy.example.com:1080",
|
||||
wantEnvs: []string{
|
||||
"FENCE_SANDBOX=1",
|
||||
"ALL_PROXY=socks5h://localhost:1080",
|
||||
"all_proxy=socks5h://localhost:1080",
|
||||
"FTP_PROXY=socks5h://localhost:1080",
|
||||
"GIT_SSH_COMMAND=",
|
||||
},
|
||||
dontWant: []string{
|
||||
"HTTP_PROXY=",
|
||||
"HTTPS_PROXY=",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "both ports",
|
||||
httpPort: 8080,
|
||||
socksPort: 1080,
|
||||
wantEnvs: []string{
|
||||
"FENCE_SANDBOX=1",
|
||||
"HTTP_PROXY=http://localhost:8080",
|
||||
"HTTPS_PROXY=http://localhost:8080",
|
||||
"ALL_PROXY=socks5h://localhost:1080",
|
||||
"GIT_SSH_COMMAND=",
|
||||
"ALL_PROXY=socks5h://proxy.example.com:1080",
|
||||
"HTTP_PROXY=socks5h://proxy.example.com:1080",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := GenerateProxyEnvVars(tt.httpPort, tt.socksPort)
|
||||
got := GenerateProxyEnvVars(tt.proxyURL)
|
||||
|
||||
// Check expected env vars are present
|
||||
for _, want := range tt.wantEnvs {
|
||||
@@ -207,7 +183,7 @@ func TestGenerateProxyEnvVars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("GenerateProxyEnvVars(%d, %d) missing %q", tt.httpPort, tt.socksPort, want)
|
||||
t.Errorf("GenerateProxyEnvVars(%q) missing %q", tt.proxyURL, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +191,7 @@ func TestGenerateProxyEnvVars(t *testing.T) {
|
||||
for _, dontWant := range tt.dontWant {
|
||||
for _, env := range got {
|
||||
if strings.HasPrefix(env, dontWant) {
|
||||
t.Errorf("GenerateProxyEnvVars(%d, %d) should not contain %q, got %q", tt.httpPort, tt.socksPort, dontWant, env)
|
||||
t.Errorf("GenerateProxyEnvVars(%q) should not contain %q, got %q", tt.proxyURL, dontWant, env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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