diff --git a/cli/cli.go b/cli/cli.go index 7d1a20c..1777e60 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -8,7 +8,7 @@ import ( "github.com/coder/boundary/config" "github.com/coder/boundary/log" - "github.com/coder/boundary/nsjail_manager" + "github.com/coder/boundary/run" "github.com/coder/serpent" ) @@ -119,9 +119,25 @@ func BaseCommand() *serpent.Command { Value: &cliConfig.ConfigureDNSForLocalStubResolver, YAML: "configure_dns_for_local_stub_resolver", }, + { + Flag: "jail-type", + Env: "BOUNDARY_JAIL_TYPE", + Description: "Jail type to use for network isolation. Options: nsjail (default), landjail.", + Default: "nsjail", + Value: &cliConfig.JailType, + YAML: "jail_type", + }, }, Handler: func(inv *serpent.Invocation) error { - appConfig := config.NewAppConfigFromCliConfig(cliConfig) + appConfig, err := config.NewAppConfigFromCliConfig(cliConfig, inv.Args) + if err != nil { + return fmt.Errorf("failed to parse cli config file: %v", err) + } + + // Get command arguments + if len(appConfig.TargetCMD) == 0 { + return fmt.Errorf("no command specified") + } logger, err := log.SetupLogging(appConfig) if err != nil { @@ -134,7 +150,7 @@ func BaseCommand() *serpent.Command { } logger.Debug("Application config", "config", appConfigInJSON) - return nsjail_manager.Run(inv.Context(), logger, appConfig, inv.Args) + return run.Run(inv.Context(), logger, appConfig) }, } } diff --git a/config/config.go b/config/config.go index 76176b4..538137e 100644 --- a/config/config.go +++ b/config/config.go @@ -1,9 +1,30 @@ package config import ( + "fmt" + "github.com/coder/serpent" ) +// JailType represents the type of jail to use for network isolation +type JailType string + +const ( + NSJailType JailType = "nsjail" + LandjailType JailType = "landjail" +) + +func NewJailTypeFromString(str string) (JailType, error) { + switch str { + case "nsjail": + return NSJailType, nil + case "landjail": + return LandjailType, nil + default: + return NSJailType, fmt.Errorf("invalid JailType: %s", str) + } +} + type CliConfig struct { Config serpent.YAMLConfigPath `yaml:"-"` AllowListStrings serpent.StringArray `yaml:"allowlist"` // From config file @@ -14,6 +35,7 @@ type CliConfig struct { PprofEnabled serpent.Bool `yaml:"pprof_enabled"` PprofPort serpent.Int64 `yaml:"pprof_port"` ConfigureDNSForLocalStubResolver serpent.Bool `yaml:"configure_dns_for_local_stub_resolver"` + JailType serpent.String `yaml:"jail_type"` } type AppConfig struct { @@ -24,9 +46,11 @@ type AppConfig struct { PprofEnabled bool PprofPort int64 ConfigureDNSForLocalStubResolver bool + JailType JailType + TargetCMD []string } -func NewAppConfigFromCliConfig(cfg CliConfig) AppConfig { +func NewAppConfigFromCliConfig(cfg CliConfig, targetCMD []string) (AppConfig, error) { // Merge allowlist from config file with allow from CLI flags allowListStrings := cfg.AllowListStrings.Value() allowStrings := cfg.AllowStrings.Value() @@ -34,6 +58,11 @@ func NewAppConfigFromCliConfig(cfg CliConfig) AppConfig { // Combine allowlist (config file) with allow (CLI flags) allAllowStrings := append(allowListStrings, allowStrings...) + jailType, err := NewJailTypeFromString(cfg.JailType.Value()) + if err != nil { + return AppConfig{}, err + } + return AppConfig{ AllowRules: allAllowStrings, LogLevel: cfg.LogLevel.Value(), @@ -42,5 +71,7 @@ func NewAppConfigFromCliConfig(cfg CliConfig) AppConfig { PprofEnabled: cfg.PprofEnabled.Value(), PprofPort: cfg.PprofPort.Value(), ConfigureDNSForLocalStubResolver: cfg.ConfigureDNSForLocalStubResolver.Value(), - } + JailType: jailType, + TargetCMD: targetCMD, + }, nil } diff --git a/e2e_tests/boundary_test.go b/e2e_tests/boundary_test.go index 4f6236d..3fc26c5 100644 --- a/e2e_tests/boundary_test.go +++ b/e2e_tests/boundary_test.go @@ -16,7 +16,7 @@ import ( "github.com/stretchr/testify/require" ) -// BoundaryTest is a high-level test framework for boundary e2e tests +// BoundaryTest is a high-level test framework for boundary e2e tests using nsjail type BoundaryTest struct { t *testing.T projectRoot string @@ -100,6 +100,7 @@ func (bt *BoundaryTest) Start(command ...string) *BoundaryTest { // Build command args args := []string{ "--log-level", bt.logLevel, + "--jail-type", "nsjail", } for _, domain := range bt.allowedDomains { args = append(args, "--allow", domain) @@ -202,6 +203,32 @@ func (bt *BoundaryTest) makeRequest(url string) []byte { return output } +// ExpectDenyContains makes an HTTP/HTTPS request and expects it to be denied, checking that response contains the given text +func (bt *BoundaryTest) ExpectDenyContains(url string, containsText string) { + bt.t.Helper() + output := bt.makeRequest(url) + require.Contains(bt.t, string(output), containsText, "Response does not contain expected denial text") +} + +func (bt *BoundaryTest) getNsCurlCmd(url string) *exec.Cmd { + pid := fmt.Sprintf("%v", bt.pid) + _, _, _, _, configDir := util.GetUserInfo() + certPath := fmt.Sprintf("%v/ca-cert.pem", configDir) + + args := []string{"nsenter", "-t", pid, "-n", "--", + "env", fmt.Sprintf("SSL_CERT_FILE=%v", certPath), "curl", "-sS", url} + curlCmd := exec.Command("sudo", args...) + + return curlCmd +} + +func (bt *BoundaryTest) getHostCurlCmd(url string) *exec.Cmd { + args := []string{"-sS", url} + curlCmd := exec.Command("curl", args...) + + return curlCmd +} + // getTargetProcessPID gets the PID of the boundary target process. // Target process is associated with a network namespace, so you can exec into it, using this PID. // pgrep -f boundary-test -n is doing two things: diff --git a/e2e_tests/landjail_test.go b/e2e_tests/landjail_test.go new file mode 100644 index 0000000..bcf6fae --- /dev/null +++ b/e2e_tests/landjail_test.go @@ -0,0 +1,298 @@ +package e2e_tests + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "testing" + "time" + + "github.com/coder/boundary/util" + "github.com/stretchr/testify/require" +) + +// LandjailTest is a high-level test framework for boundary e2e tests using landjail +type LandjailTest struct { + t *testing.T + projectRoot string + binaryPath string + allowedDomains []string + logLevel string + cmd *exec.Cmd + startupDelay time.Duration + // Pipes to communicate with the bash process + bashStdin io.WriteCloser + bashStdout io.ReadCloser + bashStderr io.ReadCloser +} + +// LandjailTestOption is a function that configures LandjailTest +type LandjailTestOption func(*LandjailTest) + +// NewLandjailTest creates a new LandjailTest instance +func NewLandjailTest(t *testing.T, opts ...LandjailTestOption) *LandjailTest { + projectRoot := findProjectRoot(t) + binaryPath := "/tmp/boundary-landjail-test" + + lt := &LandjailTest{ + t: t, + projectRoot: projectRoot, + binaryPath: binaryPath, + allowedDomains: []string{}, + logLevel: "warn", + startupDelay: 2 * time.Second, + } + + // Apply options + for _, opt := range opts { + opt(lt) + } + + return lt +} + +// WithAllowedDomain adds an allowed domain rule +func WithLandjailAllowedDomain(domain string) LandjailTestOption { + return func(lt *LandjailTest) { + lt.allowedDomains = append(lt.allowedDomains, fmt.Sprintf("domain=%s", domain)) + } +} + +// WithAllowedRule adds a full allow rule (e.g., "method=GET domain=example.com path=/api/*") +func WithLandjailAllowedRule(rule string) LandjailTestOption { + return func(lt *LandjailTest) { + lt.allowedDomains = append(lt.allowedDomains, rule) + } +} + +// WithLogLevel sets the log level +func WithLandjailLogLevel(level string) LandjailTestOption { + return func(lt *LandjailTest) { + lt.logLevel = level + } +} + +// WithStartupDelay sets how long to wait after starting boundary before making requests +func WithLandjailStartupDelay(delay time.Duration) LandjailTestOption { + return func(lt *LandjailTest) { + lt.startupDelay = delay + } +} + +// Build builds the boundary binary +func (lt *LandjailTest) Build() *LandjailTest { + buildCmd := exec.Command("go", "build", "-o", lt.binaryPath, "./cmd/...") + buildCmd.Dir = lt.projectRoot + err := buildCmd.Run() + require.NoError(lt.t, err, "Failed to build boundary binary") + return lt +} + +// Start starts the boundary process with a bash process that reads commands from stdin +func (lt *LandjailTest) Start(command ...string) *LandjailTest { + // Build command args + args := []string{ + "--log-level", lt.logLevel, + "--jail-type", "landjail", + } + for _, domain := range lt.allowedDomains { + args = append(args, "--allow", domain) + } + args = append(args, "--") + + // Bash command that reads and executes commands from stdin + // Each command should end with a newline, and we use a marker to detect completion + // Using a unique marker to avoid conflicts with command output + if len(command) == 0 { + command = []string{"/bin/bash", "-c", "while IFS= read -r cmd; do if [ \"$cmd\" = \"exit\" ]; then exit 0; fi; eval \"$cmd\"; echo \"__BOUNDARY_CMD_DONE__\"; done"} + } + args = append(args, command...) + + lt.cmd = exec.Command(lt.binaryPath, args...) + + // Capture pipes for communication with bash + var err error + lt.bashStdin, err = lt.cmd.StdinPipe() + require.NoError(lt.t, err, "Failed to create stdin pipe for landjail") + + lt.bashStdout, err = lt.cmd.StdoutPipe() + require.NoError(lt.t, err, "Failed to create stdout pipe for landjail") + + lt.bashStderr, err = lt.cmd.StderrPipe() + require.NoError(lt.t, err, "Failed to create stderr pipe for landjail") + + // Forward stderr to os.Stderr for debugging + go io.Copy(os.Stderr, lt.bashStderr) //nolint:errcheck + + err = lt.cmd.Start() + require.NoError(lt.t, err, "Failed to start boundary process with landjail") + + // Wait for boundary to start + time.Sleep(lt.startupDelay) + + return lt +} + +// Stop gracefully stops the boundary process +func (lt *LandjailTest) Stop() { + if lt.cmd == nil || lt.cmd.Process == nil { + return + } + + // Send "exit" command to bash, then close stdin + if lt.bashStdin != nil { + _, _ = lt.bashStdin.Write([]byte("exit\n")) + lt.bashStdin.Close() + } + + time.Sleep(1 * time.Second) + + // Wait for process to finish + if lt.cmd != nil { + err := lt.cmd.Wait() + if err != nil { + lt.t.Logf("Boundary process finished with error: %v", err) + } + } + + // Close pipes if they're still open + if lt.bashStdout != nil { + lt.bashStdout.Close() + } + if lt.bashStderr != nil { + lt.bashStderr.Close() + } + + // Clean up binary + err := os.Remove(lt.binaryPath) + if err != nil { + lt.t.Logf("Failed to remove boundary binary: %v", err) + } +} + +// ExpectAllowed makes an HTTP/HTTPS request and expects it to be allowed with the given response body +func (lt *LandjailTest) ExpectAllowed(url string, expectedBody string) { + lt.t.Helper() + output := lt.makeRequest(url) + require.Equal(lt.t, expectedBody, string(output), "Expected response body does not match") +} + +// ExpectAllowedContains makes an HTTP/HTTPS request and expects it to be allowed, checking that response contains the given text +func (lt *LandjailTest) ExpectAllowedContains(url string, containsText string) { + lt.t.Helper() + output := lt.makeRequest(url) + require.Contains(lt.t, string(output), containsText, "Response does not contain expected text") +} + +// ExpectDeny makes an HTTP/HTTPS request and expects it to be denied +func (lt *LandjailTest) ExpectDeny(url string) { + lt.t.Helper() + output := lt.makeRequest(url) + require.Contains(lt.t, string(output), "Request Blocked by Boundary", "Expected request to be blocked") +} + +// ExpectDenyContains makes an HTTP/HTTPS request and expects it to be denied, checking that response contains the given text +func (lt *LandjailTest) ExpectDenyContains(url string, containsText string) { + lt.t.Helper() + output := lt.makeRequest(url) + require.Contains(lt.t, string(output), containsText, "Response does not contain expected denial text") +} + +// makeRequest executes a curl command in the landjail bash process +// Always sets SSL_CERT_FILE for HTTPS support (harmless for HTTP requests) +func (lt *LandjailTest) makeRequest(url string) []byte { + lt.t.Helper() + + if lt.bashStdin == nil || lt.bashStdout == nil { + lt.t.Fatalf("landjail pipes not initialized") + } + + _, _, _, _, configDir := util.GetUserInfo() + certPath := fmt.Sprintf("%v/ca-cert.pem", configDir) + + // Build curl command with SSL_CERT_FILE + curlCmd := fmt.Sprintf("env SSL_CERT_FILE=%s curl -sS %s\n", certPath, url) + + // Write command to stdin + _, err := lt.bashStdin.Write([]byte(curlCmd)) + require.NoError(lt.t, err, "Failed to write command to landjail stdin") + + // Read output until we see the completion marker + var output bytes.Buffer + doneMarker := []byte("__BOUNDARY_CMD_DONE__") + buf := make([]byte, 4096) + + for { + n, err := lt.bashStdout.Read(buf) + if n > 0 { + // Check if we've received the completion marker + data := buf[:n] + if idx := bytes.Index(data, doneMarker); idx != -1 { + // Found the marker, add everything before it to output + output.Write(data[:idx]) + // Remove the marker and newline + remaining := data[idx+len(doneMarker):] + if len(remaining) > 0 && remaining[0] == '\n' { + remaining = remaining[1:] + } + if len(remaining) > 0 { + output.Write(remaining) + } + break + } + output.Write(data) + } + if err == io.EOF { + break + } + if err != nil { + lt.t.Fatalf("Failed to read from landjail stdout: %v", err) + } + } + + return output.Bytes() +} + +func TestLandjail(t *testing.T) { + // Create and configure landjail test + lt := NewLandjailTest(t, + WithLandjailAllowedDomain("dev.coder.com"), + WithLandjailAllowedDomain("jsonplaceholder.typicode.com"), + WithLandjailLogLevel("debug"), + ). + Build(). + Start() + + // Ensure cleanup + defer lt.Stop() + + // Test allowed HTTP request + t.Run("HTTPRequestThroughBoundary", func(t *testing.T) { + expectedResponse := `{ + "userId": 1, + "id": 1, + "title": "delectus aut autem", + "completed": false +}` + lt.ExpectAllowed("http://jsonplaceholder.typicode.com/todos/1", expectedResponse) + }) + + // Test allowed HTTPS request + t.Run("HTTPSRequestThroughBoundary", func(t *testing.T) { + expectedResponse := `{"message":"👋"} +` + lt.ExpectAllowed("https://dev.coder.com/api/v2", expectedResponse) + }) + + //// Test blocked HTTP request + //t.Run("HTTPBlockedDomainTest", func(t *testing.T) { + // lt.ExpectDeny("http://example.com") + //}) + // + //// Test blocked HTTPS request + //t.Run("HTTPSBlockedDomainTest", func(t *testing.T) { + // lt.ExpectDeny("https://example.com") + //}) +} diff --git a/e2e_tests/e2e_boundary_test.go b/e2e_tests/ns_jail_test.go similarity index 96% rename from e2e_tests/e2e_boundary_test.go rename to e2e_tests/ns_jail_test.go index 7d2e6ac..1fabff6 100644 --- a/e2e_tests/e2e_boundary_test.go +++ b/e2e_tests/ns_jail_test.go @@ -2,7 +2,7 @@ package e2e_tests import "testing" -func TestE2EBoundary(t *testing.T) { +func TestNamespaceJail(t *testing.T) { // Create and configure boundary test bt := NewBoundaryTest(t, WithAllowedDomain("dev.coder.com"), diff --git a/landjail/landjail.go b/landjail/landjail.go new file mode 100644 index 0000000..58e9e8b --- /dev/null +++ b/landjail/landjail.go @@ -0,0 +1,75 @@ +package landjail + +import ( + "fmt" + "log" + "os" + "os/exec" + "syscall" + + "github.com/landlock-lsm/go-landlock/landlock" +) + +type Config struct { + //BindTCPPorts []int + ConnectTCPPorts []int +} + +func Apply(cfg Config) error { + // Get the Landlock version which works for Kernel 6.7+ + llCfg := landlock.V4 + + // Collect our rules + var netRules []landlock.Rule + + // Add rules for TCP port binding + //for _, port := range cfg.BindTCPPorts { + // log.Debug("Adding TCP bind port: %d", port) + // net_rules = append(net_rules, landlock.BindTCP(uint16(port))) + //} + + // Add rules for TCP connections + for _, port := range cfg.ConnectTCPPorts { + log.Printf("Adding TCP connect port: %d", port) + netRules = append(netRules, landlock.ConnectTCP(uint16(port))) + } + + err := llCfg.RestrictNet(netRules...) + if err != nil { + return fmt.Errorf("failed to apply Landlock network restrictions: %w", err) + } + + return nil +} + +func Run(args []string, env []string) error { + binary, err := exec.LookPath(args[0]) + if err != nil { + return err + } + + log.Printf("Executing: %v", args) + + // Only pass the explicitly specified environment variables + // If env is empty, no environment variables will be passed + return syscall.Exec(binary, args, env) +} + +func main() { + fmt.Printf("OK\n") + + cfg := Config{ + ConnectTCPPorts: []int{80}, + } + err := Apply(cfg) + if err != nil { + log.Fatalf("failed to apply Landlock network restrictions: %v", err) + } + + log.Printf("os.Args[1:]: %v", os.Args[1:]) + + err = Run(os.Args[1:], os.Environ()) + if err != nil { + log.Fatalf("failed to apply Landlock network restrictions: %v", err) + } +} diff --git a/nsjail_manager/child.go b/nsjail_manager/child.go index 78f49a7..824816f 100644 --- a/nsjail_manager/child.go +++ b/nsjail_manager/child.go @@ -47,7 +47,7 @@ func waitForInterface(interfaceName string, timeout time.Duration) error { return nil } -func RunChild(logger *slog.Logger, args []string) error { +func RunChild(logger *slog.Logger, targetCMD []string) error { logger.Info("boundary CHILD process is started") vethNetJail := os.Getenv("VETH_JAIL_NAME") @@ -75,8 +75,8 @@ func RunChild(logger *slog.Logger, args []string) error { } // Program to run - bin := args[0] - args = args[1:] + bin := targetCMD[0] + args := targetCMD[1:] cmd := exec.Command(bin, args...) cmd.Stdin = os.Stdin diff --git a/nsjail_manager/parent.go b/nsjail_manager/parent.go index a289fdf..533abc7 100644 --- a/nsjail_manager/parent.go +++ b/nsjail_manager/parent.go @@ -13,14 +13,9 @@ import ( "github.com/coder/boundary/util" ) -func RunParent(ctx context.Context, logger *slog.Logger, args []string, config config.AppConfig) error { +func RunParent(ctx context.Context, logger *slog.Logger, 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") } diff --git a/nsjail_manager/run.go b/nsjail_manager/run.go index f8efb8d..e38e431 100644 --- a/nsjail_manager/run.go +++ b/nsjail_manager/run.go @@ -16,10 +16,10 @@ func isChild() bool { // 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 { +func Run(ctx context.Context, logger *slog.Logger, config config.AppConfig) error { if isChild() { - return RunChild(logger, args) + return RunChild(logger, config.TargetCMD) } - return RunParent(ctx, logger, args, config) + return RunParent(ctx, logger, config) } diff --git a/run/run.go b/run/run.go new file mode 100644 index 0000000..7777bb8 --- /dev/null +++ b/run/run.go @@ -0,0 +1,23 @@ +package run + +import ( + "context" + "fmt" + "log/slog" + "os" + + "github.com/coder/boundary/config" + "github.com/coder/boundary/landjail" + "github.com/coder/boundary/nsjail_manager" +) + +func Run(ctx context.Context, logger *slog.Logger, cfg config.AppConfig) error { + switch cfg.JailType { + case config.NSJailType: + return nsjail_manager.Run(ctx, logger, cfg) + case config.LandjailType: + return landjail.Run(cfg.TargetCMD, os.Environ()) + default: + return fmt.Errorf("unknown jail type: %s", cfg.JailType) + } +}