This repository has been archived on 2026-03-13. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
greywall/internal/config/config.go
Mathieu Virbel 481616455a fix: add SOCKS5 auth, DNS bridge, and TUN capability support
Three issues prevented transparent proxying from working end-to-end:

1. bwrap dropped CAP_NET_ADMIN before exec, so ip tuntap/link commands
   failed inside the sandbox. Add --cap-add CAP_NET_ADMIN and
   CAP_NET_BIND_SERVICE when transparent proxy is active.

2. tun2socks only offered SOCKS5 no-auth (method 0x00), but many proxies
   (e.g. gost) require username/password auth (method 0x02). Pass through
   credentials from the proxy URL so tun2socks offers both auth methods.

3. DNS resolution failed because UDP DNS needs SOCKS5 UDP ASSOCIATE which
   most proxies don't support. Add --dns flag and DnsBridge that routes
   DNS queries from the sandbox through a Unix socket to a host-side DNS
   server. Falls back to TCP relay through the tunnel when no --dns is set.

Also brings up loopback interface (ip link set lo up) inside the network
namespace so socat can bind to 127.0.0.1.
2026-02-10 14:57:56 -06:00

500 lines
15 KiB
Go

// Package config defines the configuration types and loading for fence.
package config
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"github.com/tidwall/jsonc"
)
// Config is the main configuration for fence.
type Config struct {
Extends string `json:"extends,omitempty"`
Network NetworkConfig `json:"network"`
Filesystem FilesystemConfig `json:"filesystem"`
Command CommandConfig `json:"command"`
SSH SSHConfig `json:"ssh"`
AllowPty bool `json:"allowPty,omitempty"`
}
// NetworkConfig defines network restrictions.
type NetworkConfig struct {
ProxyURL string `json:"proxyUrl,omitempty"` // External SOCKS5 proxy (e.g. socks5://host:1080)
DnsAddr string `json:"dnsAddr,omitempty"` // DNS server address on host (e.g. localhost:3153)
AllowUnixSockets []string `json:"allowUnixSockets,omitempty"`
AllowAllUnixSockets bool `json:"allowAllUnixSockets,omitempty"`
AllowLocalBinding bool `json:"allowLocalBinding,omitempty"`
AllowLocalOutbound *bool `json:"allowLocalOutbound,omitempty"` // If nil, defaults to AllowLocalBinding value
}
// FilesystemConfig defines filesystem restrictions.
type FilesystemConfig struct {
DefaultDenyRead bool `json:"defaultDenyRead,omitempty"` // If true, deny reads by default except system paths and AllowRead
AllowRead []string `json:"allowRead"` // Paths to allow reading (used when DefaultDenyRead is true)
DenyRead []string `json:"denyRead"`
AllowWrite []string `json:"allowWrite"`
DenyWrite []string `json:"denyWrite"`
AllowGitConfig bool `json:"allowGitConfig,omitempty"`
}
// CommandConfig defines command restrictions.
type CommandConfig struct {
Deny []string `json:"deny"`
Allow []string `json:"allow"`
UseDefaults *bool `json:"useDefaults,omitempty"`
}
// SSHConfig defines SSH command restrictions.
// SSH commands are filtered using an allowlist by default for security.
type SSHConfig struct {
AllowedHosts []string `json:"allowedHosts"` // Host patterns to allow SSH to (supports wildcards like *.example.com)
DeniedHosts []string `json:"deniedHosts"` // Host patterns to deny SSH to (checked before allowed)
AllowedCommands []string `json:"allowedCommands"` // Commands allowed over SSH (allowlist mode)
DeniedCommands []string `json:"deniedCommands"` // Commands denied over SSH (checked before allowed)
AllowAllCommands bool `json:"allowAllCommands,omitempty"` // If true, use denylist mode instead of allowlist
InheritDeny bool `json:"inheritDeny,omitempty"` // If true, also apply global command.deny rules
}
// DefaultDeniedCommands returns commands that are blocked by default.
// These are system-level dangerous commands that are rarely needed by AI agents.
var DefaultDeniedCommands = []string{
// System control - can crash/reboot the machine
"shutdown",
"reboot",
"halt",
"poweroff",
"init 0",
"init 6",
"systemctl poweroff",
"systemctl reboot",
"systemctl halt",
// Kernel/module manipulation
"insmod",
"rmmod",
"modprobe",
"kexec",
// Disk/partition manipulation (including common variants)
"mkfs",
"mkfs.ext2",
"mkfs.ext3",
"mkfs.ext4",
"mkfs.xfs",
"mkfs.btrfs",
"mkfs.vfat",
"mkfs.ntfs",
"fdisk",
"parted",
"dd if=",
// Container escape vectors
"docker run -v /:/",
"docker run --privileged",
// Chroot/namespace escape
"chroot",
"unshare",
"nsenter",
}
// Default returns the default configuration with all network blocked (no proxy = no network).
func Default() *Config {
return &Config{
Network: NetworkConfig{},
Filesystem: FilesystemConfig{
DenyRead: []string{},
AllowWrite: []string{},
DenyWrite: []string{},
},
Command: CommandConfig{
Deny: []string{},
Allow: []string{},
// UseDefaults defaults to true (nil = true)
},
SSH: SSHConfig{
AllowedHosts: []string{},
DeniedHosts: []string{},
AllowedCommands: []string{},
DeniedCommands: []string{},
},
}
}
// DefaultConfigPath returns the default config file path.
// Uses the OS-preferred config directory (XDG on Linux, ~/Library/Application Support on macOS).
// Falls back to ~/.fence.json if the new location doesn't exist but the legacy one does.
func DefaultConfigPath() string {
// Try OS-preferred config directory first
configDir, err := os.UserConfigDir()
if err == nil {
newPath := filepath.Join(configDir, "fence", "fence.json")
if _, err := os.Stat(newPath); err == nil {
return newPath
}
// Check if parent directory exists (user has set up the new location)
// If so, prefer this even if config doesn't exist yet
if _, err := os.Stat(filepath.Dir(newPath)); err == nil {
return newPath
}
}
// Fall back to legacy path if it exists
home, err := os.UserHomeDir()
if err != nil {
return "fence.json"
}
legacyPath := filepath.Join(home, ".fence.json")
if _, err := os.Stat(legacyPath); err == nil {
return legacyPath
}
// Neither exists, prefer new XDG-compliant path
if configDir != "" {
return filepath.Join(configDir, "fence", "fence.json")
}
return filepath.Join(home, ".config", "fence", "fence.json")
}
// Load loads configuration from a file path.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path) //nolint:gosec // user-provided config path - intentional
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read config file: %w", err)
}
// Handle empty file
if len(strings.TrimSpace(string(data))) == 0 {
return nil, nil
}
var cfg Config
if err := json.Unmarshal(jsonc.ToJSON(data), &cfg); err != nil {
return nil, fmt.Errorf("invalid JSON in config file: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
return &cfg, nil
}
// Validate validates the configuration.
func (c *Config) Validate() error {
if c.Network.ProxyURL != "" {
if err := validateProxyURL(c.Network.ProxyURL); err != nil {
return fmt.Errorf("invalid network.proxyUrl %q: %w", c.Network.ProxyURL, err)
}
}
if c.Network.DnsAddr != "" {
if err := validateHostPort(c.Network.DnsAddr); err != nil {
return fmt.Errorf("invalid network.dnsAddr %q: %w", c.Network.DnsAddr, err)
}
}
if slices.Contains(c.Filesystem.AllowRead, "") {
return errors.New("filesystem.allowRead contains empty path")
}
if slices.Contains(c.Filesystem.DenyRead, "") {
return errors.New("filesystem.denyRead contains empty path")
}
if slices.Contains(c.Filesystem.AllowWrite, "") {
return errors.New("filesystem.allowWrite contains empty path")
}
if slices.Contains(c.Filesystem.DenyWrite, "") {
return errors.New("filesystem.denyWrite contains empty path")
}
if slices.Contains(c.Command.Deny, "") {
return errors.New("command.deny contains empty command")
}
if slices.Contains(c.Command.Allow, "") {
return errors.New("command.allow contains empty command")
}
// SSH config
for _, host := range c.SSH.AllowedHosts {
if err := validateHostPattern(host); err != nil {
return fmt.Errorf("invalid ssh.allowedHosts %q: %w", host, err)
}
}
for _, host := range c.SSH.DeniedHosts {
if err := validateHostPattern(host); err != nil {
return fmt.Errorf("invalid ssh.deniedHosts %q: %w", host, err)
}
}
if slices.Contains(c.SSH.AllowedCommands, "") {
return errors.New("ssh.allowedCommands contains empty command")
}
if slices.Contains(c.SSH.DeniedCommands, "") {
return errors.New("ssh.deniedCommands contains empty command")
}
return nil
}
// UseDefaultDeniedCommands returns whether to use the default deny list.
func (c *CommandConfig) UseDefaultDeniedCommands() bool {
return c.UseDefaults == nil || *c.UseDefaults
}
// 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 u.Scheme != "socks5" && u.Scheme != "socks5h" {
return errors.New("proxy URL must use socks5:// or socks5h:// scheme")
}
if u.Hostname() == "" {
return errors.New("proxy URL must include a hostname")
}
if u.Port() == "" {
return errors.New("proxy URL must include a port")
}
return nil
}
// validateHostPort validates a host:port address.
func validateHostPort(addr string) error {
// Must contain a colon separating host and port
host, port, found := strings.Cut(addr, ":")
if !found || host == "" || port == "" {
return errors.New("must be in host:port format (e.g. localhost:3153)")
}
return nil
}
// validateHostPattern validates an SSH host pattern.
// Host patterns are more permissive than domain patterns:
// - Can contain wildcards anywhere (e.g., prod-*.example.com, *.example.com)
// - Can be IP addresses
// - Can be simple hostnames without dots
func validateHostPattern(pattern string) error {
if pattern == "" {
return errors.New("empty host pattern")
}
// Reject patterns with protocol or path
if strings.Contains(pattern, "://") || strings.Contains(pattern, "/") {
return errors.New("host pattern cannot contain protocol or path")
}
// Reject patterns with port (user@host:port style)
// But allow colons for IPv6 addresses
if strings.Contains(pattern, ":") && !strings.Contains(pattern, "::") && !isIPv6Pattern(pattern) {
return errors.New("host pattern cannot contain port; specify port in SSH command instead")
}
// Reject patterns with @ (should be just the host, not user@host)
if strings.Contains(pattern, "@") {
return errors.New("host pattern should not contain username; specify just the host")
}
return nil
}
// isIPv6Pattern checks if a pattern looks like an IPv6 address.
func isIPv6Pattern(pattern string) bool {
// IPv6 addresses contain multiple colons
colonCount := strings.Count(pattern, ":")
return colonCount >= 2
}
// 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 {
hostname = strings.ToLower(hostname)
pattern = strings.ToLower(pattern)
// "*" matches all hosts
if pattern == "*" {
return true
}
// If pattern contains no wildcards, do exact match
if !strings.Contains(pattern, "*") {
return hostname == pattern
}
// Convert glob pattern to a simple matcher
// Split pattern by * and check each part
return matchGlob(hostname, pattern)
}
// matchGlob performs simple glob matching with * wildcards.
func matchGlob(s, pattern string) bool {
// Handle edge cases
if pattern == "*" {
return true
}
if pattern == "" {
return s == ""
}
// Split pattern by * and match parts
parts := strings.Split(pattern, "*")
// Check prefix (before first *)
if !strings.HasPrefix(s, parts[0]) {
return false
}
s = s[len(parts[0]):]
// Check suffix (after last *)
if len(parts) > 1 {
last := parts[len(parts)-1]
if !strings.HasSuffix(s, last) {
return false
}
s = s[:len(s)-len(last)]
}
// Check middle parts (between *s)
for i := 1; i < len(parts)-1; i++ {
part := parts[i]
if part == "" {
continue
}
idx := strings.Index(s, part)
if idx < 0 {
return false
}
s = s[idx+len(part):]
}
return true
}
// Merge combines a base config with an override config.
// Values in override take precedence. Slice fields are appended (base + override).
// The Extends field is cleared in the result since inheritance has been resolved.
func Merge(base, override *Config) *Config {
if base == nil {
if override == nil {
return Default()
}
result := *override
result.Extends = ""
return &result
}
if override == nil {
result := *base
result.Extends = ""
return &result
}
result := &Config{
// AllowPty: true if either config enables it
AllowPty: base.AllowPty || override.AllowPty,
Network: NetworkConfig{
// ProxyURL/DnsAddr: override wins if non-empty
ProxyURL: mergeString(base.Network.ProxyURL, override.Network.ProxyURL),
DnsAddr: mergeString(base.Network.DnsAddr, override.Network.DnsAddr),
// Append slices (base first, then override additions)
AllowUnixSockets: mergeStrings(base.Network.AllowUnixSockets, override.Network.AllowUnixSockets),
// Boolean fields: override wins if set, otherwise base
AllowAllUnixSockets: base.Network.AllowAllUnixSockets || override.Network.AllowAllUnixSockets,
AllowLocalBinding: base.Network.AllowLocalBinding || override.Network.AllowLocalBinding,
// Pointer fields: override wins if set, otherwise base
AllowLocalOutbound: mergeOptionalBool(base.Network.AllowLocalOutbound, override.Network.AllowLocalOutbound),
},
Filesystem: FilesystemConfig{
// Boolean fields: true if either enables it
DefaultDenyRead: base.Filesystem.DefaultDenyRead || override.Filesystem.DefaultDenyRead,
// Append slices
AllowRead: mergeStrings(base.Filesystem.AllowRead, override.Filesystem.AllowRead),
DenyRead: mergeStrings(base.Filesystem.DenyRead, override.Filesystem.DenyRead),
AllowWrite: mergeStrings(base.Filesystem.AllowWrite, override.Filesystem.AllowWrite),
DenyWrite: mergeStrings(base.Filesystem.DenyWrite, override.Filesystem.DenyWrite),
// Boolean fields: override wins if set
AllowGitConfig: base.Filesystem.AllowGitConfig || override.Filesystem.AllowGitConfig,
},
Command: CommandConfig{
// Append slices
Deny: mergeStrings(base.Command.Deny, override.Command.Deny),
Allow: mergeStrings(base.Command.Allow, override.Command.Allow),
// Pointer field: override wins if set
UseDefaults: mergeOptionalBool(base.Command.UseDefaults, override.Command.UseDefaults),
},
SSH: SSHConfig{
// Append slices
AllowedHosts: mergeStrings(base.SSH.AllowedHosts, override.SSH.AllowedHosts),
DeniedHosts: mergeStrings(base.SSH.DeniedHosts, override.SSH.DeniedHosts),
AllowedCommands: mergeStrings(base.SSH.AllowedCommands, override.SSH.AllowedCommands),
DeniedCommands: mergeStrings(base.SSH.DeniedCommands, override.SSH.DeniedCommands),
// Boolean fields: true if either enables it
AllowAllCommands: base.SSH.AllowAllCommands || override.SSH.AllowAllCommands,
InheritDeny: base.SSH.InheritDeny || override.SSH.InheritDeny,
},
}
return result
}
// mergeStrings appends two string slices, removing duplicates.
func mergeStrings(base, override []string) []string {
if len(base) == 0 {
return override
}
if len(override) == 0 {
return base
}
seen := make(map[string]bool, len(base))
result := make([]string, 0, len(base)+len(override))
for _, s := range base {
if !seen[s] {
seen[s] = true
result = append(result, s)
}
}
for _, s := range override {
if !seen[s] {
seen[s] = true
result = append(result, s)
}
}
return result
}
// mergeOptionalBool returns override if non-nil, otherwise base.
func mergeOptionalBool(base, override *bool) *bool {
if override != nil {
return override
}
return base
}
// mergeString returns override if non-empty, otherwise base.
func mergeString(base, override string) string {
if override != "" {
return override
}
return base
}