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
Jose B 6be1cf5620
Some checks failed
Build and test / Lint (pull_request) Failing after 1m3s
Build and test / Test (Linux) (pull_request) Failing after 39s
Build and test / Build (pull_request) Successful in 19s
feat: add domain-based outbound filtering with allowedDomains/deniedDomains
Add NetworkConfig.AllowedDomains and DeniedDomains fields for controlling
outbound connections by hostname. Deny rules are checked first (deny wins).
When AllowedDomains is set, only matching domains are permitted. When only
DeniedDomains is set, all domains except denied ones are allowed.

Implement FilteringProxy that wraps gost HTTP proxy with domain enforcement
via AllowConnect callback. Skip GreyHaven proxy/DNS defaults
2026-02-17 11:52:43 -05:00

601 lines
18 KiB
Go

// Package config defines the configuration types and loading for greywall.
package config
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"github.com/tidwall/jsonc"
)
// Config is the main configuration for greywall.
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)
AllowedDomains []string `json:"allowedDomains,omitempty"` // Domains to allow outbound connections to (supports wildcards)
DeniedDomains []string `json:"deniedDomains,omitempty"` // Domains to deny outbound connections to (checked before allowed)
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 nil or true, deny reads by default except system paths, CWD, 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"`
}
// IsDefaultDenyRead returns whether deny-by-default read mode is enabled.
// Defaults to true when not explicitly set (nil).
func (f *FilesystemConfig) IsDefaultDenyRead() bool {
return f.DefaultDenyRead == nil || *f.DefaultDenyRead
}
// 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 ~/.greywall.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, "greywall", "greywall.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 "greywall.json"
}
legacyPath := filepath.Join(home, ".greywall.json")
if _, err := os.Stat(legacyPath); err == nil {
return legacyPath
}
// Neither exists, prefer new XDG-compliant path
if configDir != "" {
return filepath.Join(configDir, "greywall", "greywall.json")
}
return filepath.Join(home, ".config", "greywall", "greywall.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)
}
}
for _, domain := range c.Network.AllowedDomains {
if err := validateDomainPattern(domain); err != nil {
return fmt.Errorf("invalid network.allowedDomains %q: %w", domain, err)
}
}
for _, domain := range c.Network.DeniedDomains {
if err := validateDomainPattern(domain); err != nil {
return fmt.Errorf("invalid network.deniedDomains %q: %w", domain, 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
}
// validateDomainPattern validates a domain pattern for allowedDomains/deniedDomains.
// Rejects patterns with protocol, path, port, or empty strings.
func validateDomainPattern(pattern string) error {
if pattern == "" {
return errors.New("empty domain pattern")
}
// Allow wildcard-all
if pattern == "*" {
return nil
}
// Reject patterns with protocol
if strings.Contains(pattern, "://") {
return errors.New("domain pattern cannot contain protocol (remove http:// or https://)")
}
// Reject patterns with path
if strings.Contains(pattern, "/") {
return errors.New("domain pattern cannot contain path")
}
// Reject patterns with port
if strings.Contains(pattern, ":") {
return errors.New("domain pattern cannot contain port")
}
// Reject patterns with @
if strings.Contains(pattern, "@") {
return errors.New("domain pattern cannot contain username")
}
return nil
}
// IsDomainAllowed checks if a hostname is allowed by the domain filtering rules.
// Strips port from hostname. Deny rules are checked first (deny wins).
// If AllowedDomains is set, the domain must match at least one allowed pattern.
// If only DeniedDomains is set, all domains except denied ones are allowed.
func (n *NetworkConfig) IsDomainAllowed(hostname string) bool {
// Strip port if present
if host, _, found := strings.Cut(hostname, ":"); found {
hostname = host
}
// Check denied domains first (deny wins)
for _, pattern := range n.DeniedDomains {
if MatchesHost(hostname, pattern) {
return false
}
}
// If no allowed domains configured, allow all (only deny list is active)
if len(n.AllowedDomains) == 0 {
return true
}
// Check allowed domains
for _, pattern := range n.AllowedDomains {
if MatchesHost(hostname, pattern) {
return true
}
}
// Not in allow list
return false
}
// HasDomainFiltering returns true when domain filtering is configured.
func (n *NetworkConfig) HasDomainFiltering() bool {
return len(n.AllowedDomains) > 0 || len(n.DeniedDomains) > 0
}
// IsWildcardAllow returns true when AllowedDomains contains "*" (allow all).
func (n *NetworkConfig) IsWildcardAllow() bool {
return slices.Contains(n.AllowedDomains, "*")
}
// 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 domain slices
AllowedDomains: mergeStrings(base.Network.AllowedDomains, override.Network.AllowedDomains),
DeniedDomains: mergeStrings(base.Network.DeniedDomains, override.Network.DeniedDomains),
// 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{
// Pointer field: override wins if set, otherwise base (nil = deny-by-default)
DefaultDenyRead: mergeOptionalBool(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
}