From e911973f56705dc7002a61b2073153c4d9f00306 Mon Sep 17 00:00:00 2001 From: YEVHENII SHCHERBINA Date: Fri, 12 Dec 2025 16:17:30 +0000 Subject: [PATCH] Refactor before implementing landjail --- app/app.go | 98 ---------- app/parent.go | 168 ------------------ boundary/boundary.go | 101 ----------- cli/cli.go | 56 +++--- config/config.go | 46 +++++ go.mod | 2 + go.sum | 4 + log/log.go | 59 ++++++ {app => nsjail_manager}/child.go | 8 +- nsjail_manager/manager.go | 160 +++++++++++++++++ .../nsjail}/command_runner.go | 2 +- {jail => nsjail_manager/nsjail}/jail.go | 46 ++--- .../nsjail}/local_stub_resolver.go | 2 +- .../nsjail}/networking_host.go | 27 +-- .../nsjail}/networking_ns.go | 2 +- {jail => nsjail_manager/nsjail}/util.go | 2 +- nsjail_manager/parent.go | 79 ++++++++ nsjail_manager/run.go | 25 +++ 18 files changed, 444 insertions(+), 443 deletions(-) delete mode 100644 app/app.go delete mode 100644 app/parent.go delete mode 100644 boundary/boundary.go create mode 100644 config/config.go create mode 100644 log/log.go rename {app => nsjail_manager}/child.go (94%) create mode 100644 nsjail_manager/manager.go rename {jail => nsjail_manager/nsjail}/command_runner.go (98%) rename {jail => nsjail_manager/nsjail}/jail.go (76%) rename {jail => nsjail_manager/nsjail}/local_stub_resolver.go (99%) rename {jail => nsjail_manager/nsjail}/networking_host.go (87%) rename {jail => nsjail_manager/nsjail}/networking_ns.go (99%) rename {jail => nsjail_manager/nsjail}/util.go (98%) create mode 100644 nsjail_manager/parent.go create mode 100644 nsjail_manager/run.go diff --git a/app/app.go b/app/app.go deleted file mode 100644 index 09ef1a9..0000000 --- a/app/app.go +++ /dev/null @@ -1,98 +0,0 @@ -package app - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "os" - "path/filepath" - "strings" - "time" - - "github.com/coder/serpent" -) - -// Config holds all configuration for the CLI -type Config struct { - 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"` - ConfigureDNSForLocalStubResolver serpent.Bool `yaml:"configure_dns_for_local_stub_resolver"` -} - -func isChild() bool { - return os.Getenv("CHILD") == "true" -} - -// Run executes the boundary command with the given configuration and arguments -func Run(ctx context.Context, config Config, args []string) error { - logger, err := setupLogging(config) - if err != nil { - 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() { - return RunChild(logger, args) - } - - return RunParent(ctx, logger, args, config) -} - -// 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.Value()) { - case "error": - level = slog.LevelError - case "warn": - level = slog.LevelWarn - case "info": - level = slog.LevelInfo - case "debug": - level = slog.LevelDebug - default: - level = slog.LevelWarn // Default to warn if invalid level - } - - logTarget := os.Stderr - - logDir := config.LogDir.Value() - if logDir != "" { - // Set up the logging directory if it doesn't exist yet - 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(logDir, logFilePath)) - if err != nil { - return nil, fmt.Errorf("could not create log file %s: %v", logFilePath, err) - } - - // Set the log target to the file rather than stderr. - logTarget = logFile - } - - // Create a standard slog logger with the appropriate level - handler := slog.NewTextHandler(logTarget, &slog.HandlerOptions{ - Level: level, - }) - - return slog.New(handler), nil -} diff --git a/app/parent.go b/app/parent.go deleted file mode 100644 index ce1e34e..0000000 --- a/app/parent.go +++ /dev/null @@ -1,168 +0,0 @@ -package app - -import ( - "context" - "fmt" - "log/slog" - "os" - "os/exec" - "os/signal" - "strings" - "syscall" - - "github.com/coder/boundary/boundary" - "github.com/coder/boundary/audit" - "github.com/coder/boundary/jail" - "github.com/coder/boundary/rulesengine" - "github.com/coder/boundary/tls" - "github.com/coder/boundary/util" -) - -func RunParent(ctx context.Context, logger *slog.Logger, args []string, config Config) error { - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - username, uid, gid, homeDir, configDir := util.GetUserInfo() - - // Get command arguments - if len(args) == 0 { - return fmt.Errorf("no command specified") - } - - // 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(allAllowStrings) - if err != nil { - logger.Error("Failed to parse allow rules", "error", err) - return fmt.Errorf("failed to parse allow rules: %v", err) - } - - // Create rule engine - ruleEngine := rulesengine.NewRuleEngine(allowRules, logger) - - // Create auditor - auditor := audit.NewLogAuditor(logger) - - // Create TLS certificate manager - certManager, err := tls.NewCertificateManager(tls.Config{ - Logger: logger, - ConfigDir: configDir, - Uid: uid, - Gid: gid, - }) - if err != nil { - logger.Error("Failed to create certificate manager", "error", err) - return fmt.Errorf("failed to create certificate manager: %v", err) - } - - // Setup TLS to get cert path for jailer - tlsConfig, caCertPath, configDir, err := certManager.SetupTLSAndWriteCACert() - if err != nil { - return fmt.Errorf("failed to setup TLS and CA certificate: %v", err) - } - - // Create jailer with cert path from TLS setup - jailer, err := jail.NewLinuxJail(jail.Config{ - Logger: logger, - HttpProxyPort: int(config.ProxyPort.Value()), - Username: username, - Uid: uid, - Gid: gid, - HomeDir: homeDir, - ConfigDir: configDir, - CACertPath: caCertPath, - ConfigureDNSForLocalStubResolver: config.ConfigureDNSForLocalStubResolver.Value(), - }) - if err != nil { - return fmt.Errorf("failed to create jailer: %v", err) - } - - // Create boundary instance - boundaryInstance, err := boundary.New(ctx, boundary.Config{ - RuleEngine: ruleEngine, - Auditor: auditor, - TLSConfig: tlsConfig, - Logger: logger, - Jailer: jailer, - 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) - } - - // Setup signal handling BEFORE any setup - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - // Open boundary (starts network namespace and proxy server) - err = boundaryInstance.Start() - if err != nil { - return fmt.Errorf("failed to open boundary: %v", err) - } - defer func() { - logger.Info("Closing boundary...") - err := boundaryInstance.Close() - if err != nil { - logger.Error("Failed to close boundary", "error", err) - } - }() - - // Execute command in boundary - go func() { - defer cancel() - cmd := boundaryInstance.Command(os.Args) - - logger.Debug("Executing command in boundary", "command", strings.Join(os.Args, " ")) - err := cmd.Start() - if err != nil { - logger.Error("Command failed to start", "error", err) - return - } - - err = boundaryInstance.ConfigureAfterCommandExecution(cmd.Process.Pid) - if err != nil { - logger.Error("configuration after command execution failed", "error", err) - return - } - - logger.Debug("waiting on a child process to finish") - err = cmd.Wait() - if err != nil { - // Check if this is a normal exit with non-zero status code - if exitError, ok := err.(*exec.ExitError); ok { - exitCode := exitError.ExitCode() - // Log at debug level for non-zero exits (normal behavior) - logger.Debug("Command exited with non-zero status", "exit_code", exitCode) - } else { - // This is an unexpected error (not just a non-zero exit) - logger.Error("Command execution failed", "error", err) - } - return - } - logger.Debug("Command completed successfully") - }() - - // Wait for signal or context cancellation - select { - case sig := <-sigChan: - logger.Info("Received signal, shutting down...", "signal", sig) - cancel() - case <-ctx.Done(): - // Context cancelled by command completion - logger.Info("Command completed, shutting down...") - } - - return nil -} diff --git a/boundary/boundary.go b/boundary/boundary.go deleted file mode 100644 index 8fb438c..0000000 --- a/boundary/boundary.go +++ /dev/null @@ -1,101 +0,0 @@ -package boundary - -import ( - "context" - "crypto/tls" - "fmt" - "log/slog" - "os/exec" - "time" - - "github.com/coder/boundary/audit" - "github.com/coder/boundary/jail" - "github.com/coder/boundary/proxy" - "github.com/coder/boundary/rulesengine" -) - -type Config struct { - RuleEngine rulesengine.Engine - Auditor audit.Auditor - TLSConfig *tls.Config - Logger *slog.Logger - Jailer jail.Jailer - ProxyPort int - PprofEnabled bool - PprofPort int -} - -type Boundary struct { - config Config - jailer jail.Jailer - proxyServer *proxy.Server - logger *slog.Logger - ctx context.Context - cancel context.CancelFunc -} - -func New(ctx context.Context, config Config) (*Boundary, error) { - // Create proxy server - proxyServer := proxy.NewProxyServer(proxy.Config{ - HTTPPort: config.ProxyPort, - RuleEngine: config.RuleEngine, - Auditor: config.Auditor, - Logger: config.Logger, - TLSConfig: config.TLSConfig, - PprofEnabled: config.PprofEnabled, - PprofPort: config.PprofPort, - }) - - // Create cancellable context for boundary - ctx, cancel := context.WithCancel(ctx) - - return &Boundary{ - config: config, - jailer: config.Jailer, - proxyServer: proxyServer, - logger: config.Logger, - ctx: ctx, - cancel: cancel, - }, nil -} - -func (b *Boundary) Start() error { - // Configure the jailer (network isolation) - err := b.jailer.ConfigureBeforeCommandExecution() - if err != nil { - return fmt.Errorf("failed to start jailer: %v", err) - } - - // Start proxy server in background - err = b.proxyServer.Start() - if err != nil { - b.logger.Error("Proxy server error", "error", err) - return err - } - - // Give proxy time to start - time.Sleep(100 * time.Millisecond) - - return nil -} - -func (b *Boundary) Command(command []string) *exec.Cmd { - return b.jailer.Command(command) -} - -func (b *Boundary) ConfigureAfterCommandExecution(processPID int) error { - return b.jailer.ConfigureAfterCommandExecution(processPID) -} - -func (b *Boundary) Close() error { - // Stop proxy server - if b.proxyServer != nil { - err := b.proxyServer.Stop() - if err != nil { - b.logger.Error("Failed to stop proxy server", "error", err) - } - } - - // Close jailer - return b.jailer.Close() -} diff --git a/cli/cli.go b/cli/cli.go index e103cb4..7d1a20c 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -1,10 +1,14 @@ package cli import ( + "encoding/json" + "fmt" "os" "path/filepath" - "github.com/coder/boundary/app" + "github.com/coder/boundary/config" + "github.com/coder/boundary/log" + "github.com/coder/boundary/nsjail_manager" "github.com/coder/serpent" ) @@ -35,13 +39,13 @@ func NewCommand() *serpent.Command { // *top level* serpent command. We are creating this split to make it easier to integrate into the coder // CLI if needed. func BaseCommand() *serpent.Command { - config := app.Config{} + cliConfig := config.CliConfig{} - // Set default config path if file exists - serpent will load it automatically + // Set default cliConfig 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") + defaultPath := filepath.Join(home, ".cliConfig", "coder_boundary", "cliConfig.yaml") if _, err := os.Stat(defaultPath); err == nil { - config.Config = serpent.YAMLConfigPath(defaultPath) + cliConfig.Config = serpent.YAMLConfigPath(defaultPath) } } @@ -51,23 +55,23 @@ func BaseCommand() *serpent.Command { 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", + Flag: "cliConfig", Env: "BOUNDARY_CONFIG", - Description: "Path to YAML config file.", - Value: &config.Config, + Description: "Path to YAML cliConfig file.", + Value: &cliConfig.Config, YAML: "", }, { Flag: "allow", Env: "BOUNDARY_ALLOW", - Description: "Allow rule (repeatable). These are merged with allowlist from config file. Format: \"pattern\" or \"METHOD[,METHOD] pattern\".", - Value: &config.AllowStrings, + Description: "Allow rule (repeatable). These are merged with allowlist from cliConfig file. Format: \"pattern\" or \"METHOD[,METHOD] pattern\".", + Value: &cliConfig.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, + Description: "Allowlist rules from cliConfig file (YAML only).", + Value: &cliConfig.AllowListStrings, YAML: "allowlist", }, { @@ -75,14 +79,14 @@ func BaseCommand() *serpent.Command { Env: "BOUNDARY_LOG_LEVEL", Description: "Set log level (error, warn, info, debug).", Default: "warn", - Value: &config.LogLevel, + Value: &cliConfig.LogLevel, YAML: "log_level", }, { Flag: "log-dir", Env: "BOUNDARY_LOG_DIR", Description: "Set a directory to write logs to rather than stderr.", - Value: &config.LogDir, + Value: &cliConfig.LogDir, YAML: "log_dir", }, { @@ -90,14 +94,14 @@ func BaseCommand() *serpent.Command { Env: "PROXY_PORT", Description: "Set a port for HTTP proxy.", Default: "8080", - Value: &config.ProxyPort, + Value: &cliConfig.ProxyPort, YAML: "proxy_port", }, { Flag: "pprof", Env: "BOUNDARY_PPROF", Description: "Enable pprof profiling server.", - Value: &config.PprofEnabled, + Value: &cliConfig.PprofEnabled, YAML: "pprof_enabled", }, { @@ -105,20 +109,32 @@ func BaseCommand() *serpent.Command { Env: "BOUNDARY_PPROF_PORT", Description: "Set port for pprof profiling server.", Default: "6060", - Value: &config.PprofPort, + Value: &cliConfig.PprofPort, YAML: "pprof_port", }, { Flag: "configure-dns-for-local-stub-resolver", Env: "BOUNDARY_CONFIGURE_DNS_FOR_LOCAL_STUB_RESOLVER", Description: "Configure DNS for local stub resolver (e.g., systemd-resolved). Only needed when /etc/resolv.conf contains nameserver 127.0.0.53.", - Value: &config.ConfigureDNSForLocalStubResolver, + Value: &cliConfig.ConfigureDNSForLocalStubResolver, YAML: "configure_dns_for_local_stub_resolver", }, }, Handler: func(inv *serpent.Invocation) error { - args := inv.Args - return app.Run(inv.Context(), config, args) + appConfig := config.NewAppConfigFromCliConfig(cliConfig) + + logger, err := log.SetupLogging(appConfig) + if err != nil { + return fmt.Errorf("could not set up logging: %v", err) + } + + appConfigInJSON, err := json.Marshal(appConfig) + if err != nil { + return err + } + logger.Debug("Application config", "config", appConfigInJSON) + + return nsjail_manager.Run(inv.Context(), logger, appConfig, inv.Args) }, } } diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..76176b4 --- /dev/null +++ b/config/config.go @@ -0,0 +1,46 @@ +package config + +import ( + "github.com/coder/serpent" +) + +type CliConfig struct { + 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"` + ConfigureDNSForLocalStubResolver serpent.Bool `yaml:"configure_dns_for_local_stub_resolver"` +} + +type AppConfig struct { + AllowRules []string + LogLevel string + LogDir string + ProxyPort int64 + PprofEnabled bool + PprofPort int64 + ConfigureDNSForLocalStubResolver bool +} + +func NewAppConfigFromCliConfig(cfg CliConfig) AppConfig { + // Merge allowlist from config file with allow from CLI flags + allowListStrings := cfg.AllowListStrings.Value() + allowStrings := cfg.AllowStrings.Value() + + // Combine allowlist (config file) with allow (CLI flags) + allAllowStrings := append(allowListStrings, allowStrings...) + + return AppConfig{ + AllowRules: allAllowStrings, + LogLevel: cfg.LogLevel.Value(), + LogDir: cfg.LogDir.Value(), + ProxyPort: cfg.ProxyPort.Value(), + PprofEnabled: cfg.PprofEnabled.Value(), + PprofPort: cfg.PprofPort.Value(), + ConfigureDNSForLocalStubResolver: cfg.ConfigureDNSForLocalStubResolver.Value(), + } +} diff --git a/go.mod b/go.mod index bcc62e7..7923878 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.0 require ( github.com/cenkalti/backoff/v5 v5.0.3 github.com/coder/serpent v0.10.0 + github.com/landlock-lsm/go-landlock v0.0.0-20251103212306-430f8e5cd97c github.com/stretchr/testify v1.8.4 golang.org/x/sys v0.38.0 ) @@ -34,4 +35,5 @@ require ( golang.org/x/term v0.37.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect ) diff --git a/go.sum b/go.sum index 2cc0b51..5de50b1 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/landlock-lsm/go-landlock v0.0.0-20251103212306-430f8e5cd97c h1:QcKqiunpt7hooa/xIx0iyepA6Cs2BgKexaYOxHvHNCs= +github.com/landlock-lsm/go-landlock v0.0.0-20251103212306-430f8e5cd97c/go.mod h1:stwyhp9tfeEy3A4bRJLdOEvjW/CetRJg/vcijNG8M5A= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -146,3 +148,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 h1:Z06sMOzc0GNCwp6efaVrIrz4ywGJ1v+DP0pjVkOfDuA= +kernel.org/pub/linux/libs/security/libcap/psx v1.2.77/go.mod h1:+l6Ee2F59XiJ2I6WR5ObpC1utCQJZ/VLsEbQCD8RG24= diff --git a/log/log.go b/log/log.go new file mode 100644 index 0000000..88807c3 --- /dev/null +++ b/log/log.go @@ -0,0 +1,59 @@ +package log + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "github.com/coder/boundary/config" +) + +// SetupLogging creates a slog logger with the specified level +func SetupLogging(config config.AppConfig) (*slog.Logger, error) { + var level slog.Level + switch strings.ToLower(config.LogLevel) { + case "error": + level = slog.LevelError + case "warn": + level = slog.LevelWarn + case "info": + level = slog.LevelInfo + case "debug": + level = slog.LevelDebug + default: + level = slog.LevelWarn // Default to warn if invalid level + } + + logTarget := os.Stderr + + logDir := config.LogDir + if logDir != "" { + // Set up the logging directory if it doesn't exist yet + 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(logDir, logFilePath)) + if err != nil { + return nil, fmt.Errorf("could not create log file %s: %v", logFilePath, err) + } + + // Set the log target to the file rather than stderr. + logTarget = logFile + } + + // Create a standard slog logger with the appropriate level + handler := slog.NewTextHandler(logTarget, &slog.HandlerOptions{ + Level: level, + }) + + return slog.New(handler), nil +} diff --git a/app/child.go b/nsjail_manager/child.go similarity index 94% rename from app/child.go rename to nsjail_manager/child.go index c91a380..78f49a7 100644 --- a/app/child.go +++ b/nsjail_manager/child.go @@ -1,4 +1,4 @@ -package app +package nsjail_manager import ( "context" @@ -10,7 +10,7 @@ import ( "time" "github.com/cenkalti/backoff/v5" - "github.com/coder/boundary/jail" + "github.com/coder/boundary/nsjail_manager/nsjail" "golang.org/x/sys/unix" ) @@ -60,14 +60,14 @@ func RunChild(logger *slog.Logger, args []string) error { return fmt.Errorf("failed to wait for interface %s: %w", vethNetJail, err) } - err := jail.SetupChildNetworking(vethNetJail) + err := nsjail.SetupChildNetworking(vethNetJail) if err != nil { return fmt.Errorf("failed to setup child networking: %v", err) } logger.Info("child networking is successfully configured") if os.Getenv("CONFIGURE_DNS_FOR_LOCAL_STUB_RESOLVER") == "true" { - err = jail.ConfigureDNSForLocalStubResolver() + err = nsjail.ConfigureDNSForLocalStubResolver() if err != nil { return fmt.Errorf("failed to configure DNS in namespace: %v", err) } diff --git a/nsjail_manager/manager.go b/nsjail_manager/manager.go new file mode 100644 index 0000000..b0ddfda --- /dev/null +++ b/nsjail_manager/manager.go @@ -0,0 +1,160 @@ +package nsjail_manager + +import ( + "context" + "crypto/tls" + "fmt" + "log/slog" + "os" + "os/exec" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/coder/boundary/audit" + "github.com/coder/boundary/config" + "github.com/coder/boundary/nsjail_manager/nsjail" + "github.com/coder/boundary/proxy" + "github.com/coder/boundary/rulesengine" +) + +type NSJailManager struct { + jailer nsjail.Jailer + proxyServer *proxy.Server + logger *slog.Logger + config config.AppConfig +} + +func NewNSJailManager( + ruleEngine rulesengine.Engine, + auditor audit.Auditor, + tlsConfig *tls.Config, + jailer nsjail.Jailer, + logger *slog.Logger, + config config.AppConfig, +) (*NSJailManager, error) { + // Create proxy server + proxyServer := proxy.NewProxyServer(proxy.Config{ + HTTPPort: int(config.ProxyPort), + RuleEngine: ruleEngine, + Auditor: auditor, + Logger: logger, + TLSConfig: tlsConfig, + PprofEnabled: config.PprofEnabled, + PprofPort: int(config.PprofPort), + }) + + return &NSJailManager{ + config: config, + jailer: jailer, + proxyServer: proxyServer, + logger: logger, + }, nil +} + +func (b *NSJailManager) Run(ctx context.Context) error { + b.logger.Info("Start namespace-jail manager") + err := b.setupHostAndStartProxy() + if err != nil { + return fmt.Errorf("failed to start namespace-jail manager: %v", err) + } + + defer func() { + b.logger.Info("Stop namespace-jail manager") + err := b.stopProxyAndCleanupHost() + if err != nil { + b.logger.Error("Failed to stop namespace-jail manager", "error", err) + } + }() + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + go func() { + defer cancel() + b.RunChildProcess(os.Args) + }() + + // Setup signal handling BEFORE any setup + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + // Wait for signal or context cancellation + select { + case sig := <-sigChan: + b.logger.Info("Received signal, shutting down...", "signal", sig) + cancel() + case <-ctx.Done(): + // Context canceled by command completion + b.logger.Info("Command completed, shutting down...") + } + + return nil +} + +func (b *NSJailManager) RunChildProcess(command []string) { + cmd := b.jailer.Command(command) + + b.logger.Debug("Executing command in boundary", "command", strings.Join(os.Args, " ")) + err := cmd.Start() + if err != nil { + b.logger.Error("Command failed to start", "error", err) + return + } + + err = b.jailer.ConfigureHostNsCommunication(cmd.Process.Pid) + if err != nil { + b.logger.Error("configuration after command execution failed", "error", err) + return + } + + b.logger.Debug("waiting on a child process to finish") + err = cmd.Wait() + if err != nil { + // Check if this is a normal exit with non-zero status code + if exitError, ok := err.(*exec.ExitError); ok { + exitCode := exitError.ExitCode() + // Log at debug level for non-zero exits (normal behavior) + b.logger.Debug("Command exited with non-zero status", "exit_code", exitCode) + } else { + // This is an unexpected error (not just a non-zero exit) + b.logger.Error("Command execution failed", "error", err) + } + return + } + b.logger.Debug("Command completed successfully") +} + +func (b *NSJailManager) setupHostAndStartProxy() error { + // Configure the jailer (network isolation) + err := b.jailer.ConfigureHost() + if err != nil { + return fmt.Errorf("failed to start jailer: %v", err) + } + + // Start proxy server in background + err = b.proxyServer.Start() + if err != nil { + b.logger.Error("Proxy server error", "error", err) + return err + } + + // Give proxy time to start + time.Sleep(100 * time.Millisecond) + + return nil +} + +func (b *NSJailManager) stopProxyAndCleanupHost() error { + // Stop proxy server + if b.proxyServer != nil { + err := b.proxyServer.Stop() + if err != nil { + b.logger.Error("Failed to stop proxy server", "error", err) + } + } + + // Close jailer + return b.jailer.Close() +} diff --git a/jail/command_runner.go b/nsjail_manager/nsjail/command_runner.go similarity index 98% rename from jail/command_runner.go rename to nsjail_manager/nsjail/command_runner.go index 884e74d..df681d3 100644 --- a/jail/command_runner.go +++ b/nsjail_manager/nsjail/command_runner.go @@ -1,4 +1,4 @@ -package jail +package nsjail import ( "fmt" diff --git a/jail/jail.go b/nsjail_manager/nsjail/jail.go similarity index 76% rename from jail/jail.go rename to nsjail_manager/nsjail/jail.go index 86bb6f3..ebb8664 100644 --- a/jail/jail.go +++ b/nsjail_manager/nsjail/jail.go @@ -1,4 +1,4 @@ -package jail +package nsjail import ( "fmt" @@ -11,18 +11,15 @@ import ( ) type Jailer interface { - ConfigureBeforeCommandExecution() error + ConfigureHost() error Command(command []string) *exec.Cmd - ConfigureAfterCommandExecution(processPID int) error + ConfigureHostNsCommunication(processPID int) error Close() error } type Config struct { Logger *slog.Logger HttpProxyPort int - Username string - Uid int - Gid int HomeDir string ConfigDir string CACertPath string @@ -38,10 +35,6 @@ type LinuxJail struct { httpProxyPort int configDir string caCertPath string - homeDir string - username string - uid int - gid int configureDNSForLocalStubResolver bool } @@ -51,10 +44,6 @@ func NewLinuxJail(config Config) (*LinuxJail, error) { httpProxyPort: config.HttpProxyPort, configDir: config.ConfigDir, caCertPath: config.CACertPath, - homeDir: config.HomeDir, - username: config.Username, - uid: config.Uid, - gid: config.Gid, configureDNSForLocalStubResolver: config.ConfigureDNSForLocalStubResolver, }, nil } @@ -63,7 +52,7 @@ func NewLinuxJail(config Config) (*LinuxJail, error) { // process is launched. It sets environment variables, creates the veth pair, and // installs iptables rules on the host. At this stage, the target PID and its netns // are not yet known. -func (l *LinuxJail) ConfigureBeforeCommandExecution() error { +func (l *LinuxJail) ConfigureHost() error { l.commandEnv = getEnvs(l.configDir, l.caCertPath) if err := l.configureHostNetworkBeforeCmdExec(); err != nil { @@ -111,13 +100,26 @@ func (l *LinuxJail) Command(command []string) *exec.Cmd { return cmd } -// ConfigureAfterCommandExecution finalizes setup once the target process starts. -// With the child PID known, it moves the jail-side veth into the child’s network -// namespace. -func (l *LinuxJail) ConfigureAfterCommandExecution(pidInt int) error { - err := l.configureHostNetworkAfterCmdExec(pidInt) - if err != nil { - return fmt.Errorf("failed to configure parent networking: %v", err) +// ConfigureHostNsCommunication finalizes host-side networking after the target +// process has started. It moves the jail-side veth into the target process's network +// namespace using the provided PID. This requires the process to be running so +// its PID (and thus its netns) are available. +func (l *LinuxJail) ConfigureHostNsCommunication(pidInt int) error { + PID := fmt.Sprintf("%v", pidInt) + + runner := newCommandRunner([]*command{ + // Move the jail-side veth interface into the target network namespace. + // This isolates the interface so that it becomes visible only inside the + // jail's netns. From this point on, the jail will configure its end of + // the veth pair (IP address, routes, etc.) independently of the host. + { + "Move jail-side veth into network namespace", + exec.Command("ip", "link", "set", l.vethJailName, "netns", PID), + []uintptr{uintptr(unix.CAP_NET_ADMIN)}, + }, + }) + if err := runner.run(); err != nil { + return err } return nil diff --git a/jail/local_stub_resolver.go b/nsjail_manager/nsjail/local_stub_resolver.go similarity index 99% rename from jail/local_stub_resolver.go rename to nsjail_manager/nsjail/local_stub_resolver.go index a16ac53..f19af07 100644 --- a/jail/local_stub_resolver.go +++ b/nsjail_manager/nsjail/local_stub_resolver.go @@ -1,4 +1,4 @@ -package jail +package nsjail import ( "os/exec" diff --git a/jail/networking_host.go b/nsjail_manager/nsjail/networking_host.go similarity index 87% rename from jail/networking_host.go rename to nsjail_manager/nsjail/networking_host.go index bf0baaa..48c3c8e 100644 --- a/jail/networking_host.go +++ b/nsjail_manager/nsjail/networking_host.go @@ -1,4 +1,4 @@ -package jail +package nsjail import ( "fmt" @@ -55,31 +55,6 @@ func (l *LinuxJail) configureHostNetworkBeforeCmdExec() error { return nil } -// configureHostNetworkAfterCmdExec finalizes host-side networking after the target -// process has started. It moves the jail-side veth into the target process's network -// namespace using the provided PID. This requires the process to be running so -// its PID (and thus its netns) are available. -func (l *LinuxJail) configureHostNetworkAfterCmdExec(pidInt int) error { - PID := fmt.Sprintf("%v", pidInt) - - runner := newCommandRunner([]*command{ - // Move the jail-side veth interface into the target network namespace. - // This isolates the interface so that it becomes visible only inside the - // jail's netns. From this point on, the jail will configure its end of - // the veth pair (IP address, routes, etc.) independently of the host. - { - "Move jail-side veth into network namespace", - exec.Command("ip", "link", "set", l.vethJailName, "netns", PID), - []uintptr{uintptr(unix.CAP_NET_ADMIN)}, - }, - }) - if err := runner.run(); err != nil { - return err - } - - return nil -} - // setupIptables configures iptables rules for comprehensive TCP traffic interception func (l *LinuxJail) configureIptables() error { runner := newCommandRunner([]*command{ diff --git a/jail/networking_ns.go b/nsjail_manager/nsjail/networking_ns.go similarity index 99% rename from jail/networking_ns.go rename to nsjail_manager/nsjail/networking_ns.go index 3d2e1bb..d99e3fd 100644 --- a/jail/networking_ns.go +++ b/nsjail_manager/nsjail/networking_ns.go @@ -1,4 +1,4 @@ -package jail +package nsjail import ( "os/exec" diff --git a/jail/util.go b/nsjail_manager/nsjail/util.go similarity index 98% rename from jail/util.go rename to nsjail_manager/nsjail/util.go index 1861f0b..7c03e15 100644 --- a/jail/util.go +++ b/nsjail_manager/nsjail/util.go @@ -1,4 +1,4 @@ -package jail +package nsjail import ( "os" diff --git a/nsjail_manager/parent.go b/nsjail_manager/parent.go new file mode 100644 index 0000000..a289fdf --- /dev/null +++ b/nsjail_manager/parent.go @@ -0,0 +1,79 @@ +package nsjail_manager + +import ( + "context" + "fmt" + "log/slog" + + "github.com/coder/boundary/audit" + "github.com/coder/boundary/config" + "github.com/coder/boundary/nsjail_manager/nsjail" + "github.com/coder/boundary/rulesengine" + "github.com/coder/boundary/tls" + "github.com/coder/boundary/util" +) + +func RunParent(ctx context.Context, logger *slog.Logger, args []string, config config.AppConfig) error { + _, uid, gid, homeDir, configDir := util.GetUserInfo() + + // Get command arguments + if len(args) == 0 { + return fmt.Errorf("no command specified") + } + + if len(config.AllowRules) == 0 { + logger.Warn("No allow rules specified; all network traffic will be denied by default") + } + + // Parse allow rules + allowRules, err := rulesengine.ParseAllowSpecs(config.AllowRules) + if err != nil { + logger.Error("Failed to parse allow rules", "error", err) + return fmt.Errorf("failed to parse allow rules: %v", err) + } + + // Create rule engine + ruleEngine := rulesengine.NewRuleEngine(allowRules, logger) + + // Create auditor + auditor := audit.NewLogAuditor(logger) + + // Create TLS certificate manager + certManager, err := tls.NewCertificateManager(tls.Config{ + Logger: logger, + ConfigDir: configDir, + Uid: uid, + Gid: gid, + }) + if err != nil { + logger.Error("Failed to create certificate manager", "error", err) + return fmt.Errorf("failed to create certificate manager: %v", err) + } + + // Setup TLS to get cert path for jailer + tlsConfig, caCertPath, configDir, err := certManager.SetupTLSAndWriteCACert() + if err != nil { + return fmt.Errorf("failed to setup TLS and CA certificate: %v", err) + } + + // Create jailer with cert path from TLS setup + jailer, err := nsjail.NewLinuxJail(nsjail.Config{ + Logger: logger, + HttpProxyPort: int(config.ProxyPort), + HomeDir: homeDir, + ConfigDir: configDir, + CACertPath: caCertPath, + ConfigureDNSForLocalStubResolver: config.ConfigureDNSForLocalStubResolver, + }) + if err != nil { + return fmt.Errorf("failed to create jailer: %v", err) + } + + // Create boundary instance + nsJailMgr, err := NewNSJailManager(ruleEngine, auditor, tlsConfig, jailer, logger, config) + if err != nil { + return fmt.Errorf("failed to create boundary instance: %v", err) + } + + return nsJailMgr.Run(ctx) +} diff --git a/nsjail_manager/run.go b/nsjail_manager/run.go new file mode 100644 index 0000000..f8efb8d --- /dev/null +++ b/nsjail_manager/run.go @@ -0,0 +1,25 @@ +package nsjail_manager + +import ( + "context" + "log/slog" + "os" + + "github.com/coder/boundary/config" +) + +func isChild() bool { + return os.Getenv("CHILD") == "true" +} + +// Run is the main entry point that determines whether to execute as a parent or child process. +// If running as a child (CHILD env var is set), it sets up networking in the namespace +// and executes the target command. Otherwise, it runs as the parent process, setting up the jail, +// proxy server, and managing the child process lifecycle. +func Run(ctx context.Context, logger *slog.Logger, config config.AppConfig, args []string) error { + if isChild() { + return RunChild(logger, args) + } + + return RunParent(ctx, logger, args, config) +}