Replace built-in proxies with tun2socks transparent proxying

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

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

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

View File

@@ -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

View File

@@ -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)
}
})
}
}