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
601 lines
18 KiB
Go
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
|
|
}
|