Skip to content
Merged
96 changes: 71 additions & 25 deletions cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"context"
"encoding/json"
"fmt"
"log"
"log/slog"
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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")

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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":
Expand All @@ -298,18 +343,19 @@ 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)
logFilePath := fmt.Sprintf("boundary-%s-%d.log",
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)
}
Expand Down
4 changes: 2 additions & 2 deletions rulesengine/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading