diff --git a/cli/cli.go b/cli/cli.go index 1b8ef32..b2edaba 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -2,6 +2,7 @@ package cli import ( "context" + "encoding/json" "fmt" "log" "log/slog" @@ -24,12 +25,14 @@ import ( // Config holds all configuration for the CLI type Config struct { - AllowStrings []string - LogLevel string - LogDir string - ProxyPort int64 - PprofEnabled bool - PprofPort int64 + Config serpent.YAMLConfigPath `yaml:"-"` + AllowListStrings serpent.StringArray `yaml:"allowlist"` // From config file + AllowStrings serpent.StringArray `yaml:"-"` // From CLI flags only + LogLevel serpent.String `yaml:"log_level"` + LogDir serpent.String `yaml:"log_dir"` + ProxyPort serpent.Int64 `yaml:"proxy_port"` + PprofEnabled serpent.Bool `yaml:"pprof_enabled"` + PprofPort serpent.Int64 `yaml:"pprof_port"` } // NewCommand creates and returns the root serpent command @@ -47,6 +50,9 @@ func NewCommand() *serpent.Command { # Monitor all requests to specific domains (allow only those) boundary --allow "domain=github.com path=/api/issues/*" --allow "method=GET,HEAD domain=github.com" -- npm install + # Use allowlist from config file with additional CLI allow rules + boundary --allow "domain=example.com" -- curl https://example.com + # Block everything by default (implicit)` return cmd @@ -58,49 +64,76 @@ func NewCommand() *serpent.Command { func BaseCommand() *serpent.Command { config := Config{} + // Set default config path if file exists - serpent will load it automatically + if home, err := os.UserHomeDir(); err == nil { + defaultPath := filepath.Join(home, ".config", "coder_boundary", "config.yaml") + if _, err := os.Stat(defaultPath); err == nil { + config.Config = serpent.YAMLConfigPath(defaultPath) + } + } + return &serpent.Command{ Use: "boundary", Short: "Network isolation tool for monitoring and restricting HTTP/HTTPS requests", Long: `boundary creates an isolated network environment for target processes, intercepting HTTP/HTTPS traffic through a transparent proxy that enforces user-defined allow rules.`, Options: []serpent.Option{ + { + Flag: "config", + Env: "BOUNDARY_CONFIG", + Description: "Path to YAML config file.", + Value: &config.Config, + YAML: "", + }, { Flag: "allow", Env: "BOUNDARY_ALLOW", - Description: "Allow rule (repeatable). Format: \"pattern\" or \"METHOD[,METHOD] pattern\".", - Value: serpent.StringArrayOf(&config.AllowStrings), + Description: "Allow rule (repeatable). These are merged with allowlist from config file. Format: \"pattern\" or \"METHOD[,METHOD] pattern\".", + Value: &config.AllowStrings, + YAML: "", // CLI only, not loaded from YAML + }, + { + Flag: "", // No CLI flag, YAML only + Description: "Allowlist rules from config file (YAML only).", + Value: &config.AllowListStrings, + YAML: "allowlist", }, { Flag: "log-level", Env: "BOUNDARY_LOG_LEVEL", Description: "Set log level (error, warn, info, debug).", Default: "warn", - Value: serpent.StringOf(&config.LogLevel), + Value: &config.LogLevel, + YAML: "log_level", }, { Flag: "log-dir", Env: "BOUNDARY_LOG_DIR", Description: "Set a directory to write logs to rather than stderr.", - Value: serpent.StringOf(&config.LogDir), + Value: &config.LogDir, + YAML: "log_dir", }, { Flag: "proxy-port", Env: "PROXY_PORT", Description: "Set a port for HTTP proxy.", Default: "8080", - Value: serpent.Int64Of(&config.ProxyPort), + Value: &config.ProxyPort, + YAML: "proxy_port", }, { Flag: "pprof", Env: "BOUNDARY_PPROF", Description: "Enable pprof profiling server.", - Value: serpent.BoolOf(&config.PprofEnabled), + Value: &config.PprofEnabled, + YAML: "pprof_enabled", }, { Flag: "pprof-port", Env: "BOUNDARY_PPROF_PORT", Description: "Set port for pprof profiling server.", Default: "6060", - Value: serpent.Int64Of(&config.PprofPort), + Value: &config.PprofPort, + YAML: "pprof_port", }, }, Handler: func(inv *serpent.Invocation) error { @@ -121,6 +154,12 @@ func Run(ctx context.Context, config Config, args []string) error { return fmt.Errorf("could not set up logging: %v", err) } + configInJSON, err := json.Marshal(config) + if err != nil { + return err + } + logger.Debug("config", "json_config", configInJSON) + if isChild() { logger.Info("boundary CHILD process is started") @@ -158,13 +197,19 @@ func Run(ctx context.Context, config Config, args []string) error { return fmt.Errorf("no command specified") } - // Parse allow list; default to deny-all if none provided - if len(config.AllowStrings) == 0 { + // Merge allowlist from config file with allow from CLI flags + allowListStrings := config.AllowListStrings.Value() + allowStrings := config.AllowStrings.Value() + + // Combine allowlist (config file) with allow (CLI flags) + allAllowStrings := append(allowListStrings, allowStrings...) + + if len(allAllowStrings) == 0 { logger.Warn("No allow rules specified; all network traffic will be denied by default") } // Parse allow rules - allowRules, err := rulesengine.ParseAllowSpecs(config.AllowStrings) + allowRules, err := rulesengine.ParseAllowSpecs(allAllowStrings) if err != nil { logger.Error("Failed to parse allow rules", "error", err) return fmt.Errorf("failed to parse allow rules: %v", err) @@ -197,7 +242,7 @@ func Run(ctx context.Context, config Config, args []string) error { // Create jailer with cert path from TLS setup jailer, err := createJailer(jail.Config{ Logger: logger, - HttpProxyPort: int(config.ProxyPort), + HttpProxyPort: int(config.ProxyPort.Value()), Username: username, Uid: uid, Gid: gid, @@ -216,9 +261,9 @@ func Run(ctx context.Context, config Config, args []string) error { TLSConfig: tlsConfig, Logger: logger, Jailer: jailer, - ProxyPort: int(config.ProxyPort), - PprofEnabled: config.PprofEnabled, - PprofPort: int(config.PprofPort), + ProxyPort: int(config.ProxyPort.Value()), + PprofEnabled: config.PprofEnabled.Value(), + PprofPort: int(config.PprofPort.Value()), }) if err != nil { return fmt.Errorf("failed to create boundary instance: %v", err) @@ -283,7 +328,7 @@ func Run(ctx context.Context, config Config, args []string) error { // setupLogging creates a slog logger with the specified level func setupLogging(config Config) (*slog.Logger, error) { var level slog.Level - switch strings.ToLower(config.LogLevel) { + switch strings.ToLower(config.LogLevel.Value()) { case "error": level = slog.LevelError case "warn": @@ -298,10 +343,11 @@ func setupLogging(config Config) (*slog.Logger, error) { logTarget := os.Stderr - if config.LogDir != "" { + logDir := config.LogDir.Value() + if logDir != "" { // Set up the logging directory if it doesn't exist yet - if err := os.MkdirAll(config.LogDir, 0755); err != nil { - return nil, fmt.Errorf("could not set up log dir %s: %v", config.LogDir, err) + if err := os.MkdirAll(logDir, 0755); err != nil { + return nil, fmt.Errorf("could not set up log dir %s: %v", logDir, err) } // Create a logfile (timestamp and pid to avoid race conditions with multiple boundary calls running) @@ -309,7 +355,7 @@ func setupLogging(config Config) (*slog.Logger, error) { time.Now().Format("2006-01-02_15-04-05"), os.Getpid()) - logFile, err := os.Create(filepath.Join(config.LogDir, logFilePath)) + logFile, err := os.Create(filepath.Join(logDir, logFilePath)) if err != nil { return nil, fmt.Errorf("could not create log file %s: %v", logFilePath, err) } diff --git a/rulesengine/rules.go b/rulesengine/rules.go index 4365734..a8c9f5d 100644 --- a/rulesengine/rules.go +++ b/rulesengine/rules.go @@ -142,14 +142,14 @@ func parseMethodPattern(token string) (string, string, error) { if token == "" { return "", "", errors.New("expected http token, got empty string") } - + // Find the first invalid HTTP token character for i := 0; i < len(token); i++ { if !isHTTPTokenChar(token[i]) { return token[:i], token[i:], nil } } - + // Entire string is a valid HTTP token return token, "", nil }