diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a859e6c..22fb82a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,8 +73,12 @@ jobs: - name: Download and verify dependencies run: make deps - - name: Run tests - run: make test + - name: Run unit tests + run: make unit-test + + - name: Run e2e tests + run: make e2e-test + if: matrix.os == 'ubuntu-latest' - name: Check build run: make build \ No newline at end of file diff --git a/Makefile b/Makefile index 6dfb150..efc16de 100644 --- a/Makefile +++ b/Makefile @@ -45,12 +45,25 @@ deps: go mod verify @echo "✓ Dependencies ready!" -# Run tests (needs sudo for E2E tests) -.PHONY: test -test: - @echo "Running tests..." - go test -v -race ./... - @echo "✓ All tests passed!" +# Run unit tests only (no sudo required) +.PHONY: unit-test +unit-test: + @echo "Running unit tests..." + @which go > /dev/null || (echo "Go not found in PATH" && exit 1) + go test -v -race $$(go list ./... | grep -v e2e_tests) + @echo "✓ Unit tests passed!" + +# Run E2E tests (Linux only, needs sudo) +.PHONY: e2e-test +e2e-test: + @echo "Running E2E tests..." + @which go > /dev/null || (echo "Go not found in PATH" && exit 1) + @if [ "$$(uname)" != "Linux" ]; then \ + echo "E2E tests require Linux platform. Current platform: $$(uname)"; \ + exit 1; \ + fi + sudo $(shell which go) test -v -race ./e2e_tests + @echo "✓ E2E tests passed!" # Run tests with coverage (needs sudo for E2E tests) .PHONY: test-coverage diff --git a/cli/cli.go b/cli/cli.go index 9e8b993..2da55df 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -6,9 +6,6 @@ import ( "log/slog" "os" "os/signal" - "os/user" - "path/filepath" - "strconv" "strings" "syscall" @@ -17,6 +14,7 @@ import ( "github.com/coder/boundary/jail" "github.com/coder/boundary/rules" "github.com/coder/boundary/tls" + "github.com/coder/boundary/util" "github.com/coder/serpent" ) @@ -90,7 +88,7 @@ func Run(ctx context.Context, config Config, args []string) error { ctx, cancel := context.WithCancel(ctx) defer cancel() logger := setupLogging(config.LogLevel) - username, uid, gid, homeDir, configDir := getUserInfo() + username, uid, gid, homeDir, configDir := util.GetUserInfo() // Get command arguments if len(args) == 0 { @@ -205,42 +203,6 @@ func Run(ctx context.Context, config Config, args []string) error { return nil } -// getUserInfo returns information about the current user, handling sudo scenarios -func getUserInfo() (string, int, int, string, string) { - // Only consider SUDO_USER if we're actually running with elevated privileges - // In environments like Coder workspaces, SUDO_USER may be set to 'root' - // but we're not actually running under sudo - if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" && os.Geteuid() == 0 && sudoUser != "root" { - // We're actually running under sudo with a non-root original user - user, err := user.Lookup(sudoUser) - if err != nil { - return getCurrentUserInfo() // Fallback to current user - } - - uid, _ := strconv.Atoi(os.Getenv("SUDO_UID")) - gid, _ := strconv.Atoi(os.Getenv("SUDO_GID")) - - // If we couldn't get UID/GID from env, parse from user info - if uid == 0 { - if parsedUID, err := strconv.Atoi(user.Uid); err == nil { - uid = parsedUID - } - } - if gid == 0 { - if parsedGID, err := strconv.Atoi(user.Gid); err == nil { - gid = parsedGID - } - } - - configDir := getConfigDir(user.HomeDir) - - return sudoUser, uid, gid, user.HomeDir, configDir - } - - // Not actually running under sudo, use current user - return getCurrentUserInfo() -} - // setupLogging creates a slog logger with the specified level func setupLogging(logLevel string) *slog.Logger { var level slog.Level @@ -265,31 +227,6 @@ func setupLogging(logLevel string) *slog.Logger { return slog.New(handler) } -// getCurrentUserInfo gets information for the current user -func getCurrentUserInfo() (string, int, int, string, string) { - currentUser, err := user.Current() - if err != nil { - // Fallback with empty values if we can't get user info - return "", 0, 0, "", "" - } - - uid, _ := strconv.Atoi(currentUser.Uid) - gid, _ := strconv.Atoi(currentUser.Gid) - - configDir := getConfigDir(currentUser.HomeDir) - - return currentUser.Username, uid, gid, currentUser.HomeDir, configDir -} - -// getConfigDir determines the config directory based on XDG_CONFIG_HOME or fallback -func getConfigDir(homeDir string) string { - // Use XDG_CONFIG_HOME if set, otherwise fallback to ~/.config/coder_boundary - if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { - return filepath.Join(xdgConfigHome, "coder_boundary") - } - return filepath.Join(homeDir, ".config", "coder_boundary") -} - // createJailer creates a new jail instance for the current platform func createJailer(config jail.Config, unprivileged bool) (jail.Jailer, error) { if unprivileged { diff --git a/e2e_tests/boundary_integration_test.go b/e2e_tests/boundary_integration_test.go new file mode 100644 index 0000000..84ff284 --- /dev/null +++ b/e2e_tests/boundary_integration_test.go @@ -0,0 +1,176 @@ +package e2e_tests + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/coder/boundary/util" + "github.com/stretchr/testify/require" +) + +// findProjectRoot finds the project root by looking for go.mod file +func findProjectRoot(t *testing.T) string { + cwd, err := os.Getwd() + require.NoError(t, err, "Failed to get current working directory") + + // Start from current directory and walk up until we find go.mod + dir := cwd + for { + goModPath := filepath.Join(dir, "go.mod") + if _, err := os.Stat(goModPath); err == nil { + return dir + } + + parent := filepath.Dir(dir) + if parent == dir { + // Reached filesystem root + t.Fatalf("Could not find go.mod file starting from %s", cwd) + } + dir = parent + } +} + +// getNamespaceName gets the single network namespace name +// Fails if there are 0 or multiple namespaces +func getNamespaceName(t *testing.T) string { + cmd := exec.Command("ip", "netns", "list") + output, err := cmd.Output() + require.NoError(t, err, "Failed to list network namespaces") + + lines := strings.Split(string(output), "\n") + var namespaces []string + + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + // Extract namespace name (first field) + parts := strings.Fields(line) + if len(parts) > 0 { + namespaces = append(namespaces, parts[0]) + } + } + } + + require.Len(t, namespaces, 1, "Expected exactly one network namespace, found %d: %v", len(namespaces), namespaces) + return namespaces[0] +} + +func TestBoundaryIntegration(t *testing.T) { + // Find project root by looking for go.mod file + projectRoot := findProjectRoot(t) + + // Build the boundary binary + buildCmd := exec.Command("go", "build", "-o", "/tmp/boundary-test", "./cmd/...") + buildCmd.Dir = projectRoot + err := buildCmd.Run() + require.NoError(t, err, "Failed to build boundary binary") + + // Create context for boundary process + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // Start boundary process with sudo + boundaryCmd := exec.CommandContext(ctx, "/tmp/boundary-test", + "--allow", "dev.coder.com", + "--allow", "jsonplaceholder.typicode.com", + "--log-level", "debug", + "--", "bash", "-c", "sleep 10 && echo 'Test completed'") + + // Suppress output to prevent terminal corruption + boundaryCmd.Stdout = os.Stdout // Let it go to /dev/null + boundaryCmd.Stderr = os.Stderr + + // Start the process + err = boundaryCmd.Start() + require.NoError(t, err, "Failed to start boundary process") + + // Give boundary time to start + time.Sleep(2 * time.Second) + + // Get the namespace name that boundary created + namespaceName := getNamespaceName(t) + + // Test HTTP request through boundary (from inside the jail) + t.Run("HTTPRequestThroughBoundary", func(t *testing.T) { + // Run curl directly in the namespace using ip netns exec + curlCmd := exec.Command("sudo", "ip", "netns", "exec", namespaceName, + "curl", "http://jsonplaceholder.typicode.com/todos/1") + + // Capture stderr separately + var stderr bytes.Buffer + curlCmd.Stderr = &stderr + output, err := curlCmd.Output() + + if err != nil { + t.Fatalf("curl command failed: %v, stderr: %s, output: %s", err, stderr.String(), string(output)) + } + + // Verify response contains expected content + expectedResponse := `{ + "userId": 1, + "id": 1, + "title": "delectus aut autem", + "completed": false +}` + require.Equal(t, expectedResponse, string(output)) + }) + + // Test HTTPS request through boundary (from inside the jail) + t.Run("HTTPSRequestThroughBoundary", func(t *testing.T) { + _, _, _, _, configDir := util.GetUserInfo() + certPath := fmt.Sprintf("%v/ca-cert.pem", configDir) + + // Run curl directly in the namespace using ip netns exec + curlCmd := exec.Command("sudo", "ip", "netns", "exec", namespaceName, + "env", fmt.Sprintf("SSL_CERT_FILE=%v", certPath), "curl", "-s", "https://dev.coder.com/api/v2") + + // Capture stderr separately + var stderr bytes.Buffer + curlCmd.Stderr = &stderr + output, err := curlCmd.Output() + + if err != nil { + t.Fatalf("curl command failed: %v, stderr: %s, output: %s", err, stderr.String(), string(output)) + } + + // Verify response contains expected content + expectedResponse := `{"message":"👋"} +` + require.Equal(t, expectedResponse, string(output)) + }) + + // Test blocked domain (from inside the jail) + t.Run("BlockedDomainTest", func(t *testing.T) { + // Run curl directly in the namespace using ip netns exec + curlCmd := exec.Command("sudo", "ip", "netns", "exec", namespaceName, + "curl", "-s", "http://example.com") + + // Capture stderr separately + var stderr bytes.Buffer + curlCmd.Stderr = &stderr + output, err := curlCmd.Output() + + if err != nil { + t.Fatalf("curl command failed: %v, stderr: %s, output: %s", err, stderr.String(), string(output)) + } + require.Contains(t, string(output), "Request Blocked by Boundary") + }) + + // Clean up + cancel() // This will terminate the boundary process + err = boundaryCmd.Wait() // Wait for process to finish + if err != nil { + t.Logf("Boundary process finished with error: %v", err) + } + + // Clean up binary + err = os.Remove("/tmp/boundary-test") + require.NoError(t, err, "Failed to remove /tmp/boundary-test") +} diff --git a/jail/linux.go b/jail/linux.go index 085e69f..a8c3a5f 100644 --- a/jail/linux.go +++ b/jail/linux.go @@ -43,6 +43,9 @@ func NewLinuxJail(config Config) (*LinuxJail, error) { func (l *LinuxJail) Start() error { l.logger.Debug("Setup called") + e := getEnvs(l.configDir, l.caCertPath) + l.commandEnv = mergeEnvs(e, map[string]string{}) + // Setup DNS configuration BEFORE creating namespace // This ensures the namespace-specific resolv.conf is available when namespace is created err := l.setupDNS() @@ -75,10 +78,10 @@ func (l *LinuxJail) Start() error { func (l *LinuxJail) Command(command []string) *exec.Cmd { l.logger.Debug("Creating command with namespace", "namespace", l.namespace) - cmdArgs := []string{"ip", "netns", "exec", l.namespace} + cmdArgs := []string{"netns", "exec", l.namespace} cmdArgs = append(cmdArgs, command...) - cmd := exec.Command("sudo", cmdArgs...) + cmd := exec.Command("ip", cmdArgs...) cmd.Env = l.commandEnv return cmd @@ -214,6 +217,19 @@ func (l *LinuxJail) setupIptables() error { return fmt.Errorf("failed to add comprehensive TCP redirect rule: %v", err) } + // TODO: clean up this rules + cmd = exec.Command("iptables", "-A", "FORWARD", "-s", "192.168.100.0/24", "-j", "ACCEPT") + err = cmd.Run() + if err != nil { + return err + } + + cmd = exec.Command("iptables", "-A", "FORWARD", "-d", "192.168.100.0/24", "-j", "ACCEPT") + err = cmd.Run() + if err != nil { + return err + } + l.logger.Debug("Comprehensive TCP boundarying enabled", "interface", l.vethHost, "proxy_port", l.httpProxyPort) return nil } diff --git a/tls/tls.go b/tls/tls.go index d3da893..d717dde 100644 --- a/tls/tls.go +++ b/tls/tls.go @@ -86,6 +86,8 @@ func (cm *CertificateManager) loadOrGenerateCA() error { caKeyPath := filepath.Join(cm.configDir, "ca-key.pem") caCertPath := filepath.Join(cm.configDir, "ca-cert.pem") + cm.logger.Debug("paths", "cm.configDir", cm.configDir, "caCertPath", caCertPath) + // Try to load existing CA if cm.loadExistingCA(caKeyPath, caCertPath) { cm.logger.Debug("Loaded existing CA certificate") diff --git a/util/user.go b/util/user.go new file mode 100644 index 0000000..dcb15bb --- /dev/null +++ b/util/user.go @@ -0,0 +1,69 @@ +package util + +import ( + "os" + "os/user" + "path/filepath" + "strconv" +) + +// GetUserInfo returns information about the current user, handling sudo scenarios +func GetUserInfo() (string, int, int, string, string) { + // Only consider SUDO_USER if we're actually running with elevated privileges + // In environments like Coder workspaces, SUDO_USER may be set to 'root' + // but we're not actually running under sudo + if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" && os.Geteuid() == 0 && sudoUser != "root" { + // We're actually running under sudo with a non-root original user + user, err := user.Lookup(sudoUser) + if err != nil { + return getCurrentUserInfo() // Fallback to current user + } + + uid, _ := strconv.Atoi(os.Getenv("SUDO_UID")) + gid, _ := strconv.Atoi(os.Getenv("SUDO_GID")) + + // If we couldn't get UID/GID from env, parse from user info + if uid == 0 { + if parsedUID, err := strconv.Atoi(user.Uid); err == nil { + uid = parsedUID + } + } + if gid == 0 { + if parsedGID, err := strconv.Atoi(user.Gid); err == nil { + gid = parsedGID + } + } + + configDir := getConfigDir(user.HomeDir) + + return sudoUser, uid, gid, user.HomeDir, configDir + } + + // Not actually running under sudo, use current user + return getCurrentUserInfo() +} + +// getCurrentUserInfo gets information for the current user +func getCurrentUserInfo() (string, int, int, string, string) { + currentUser, err := user.Current() + if err != nil { + // Fallback with empty values if we can't get user info + return "", 0, 0, "", "" + } + + uid, _ := strconv.Atoi(currentUser.Uid) + gid, _ := strconv.Atoi(currentUser.Gid) + + configDir := getConfigDir(currentUser.HomeDir) + + return currentUser.Username, uid, gid, currentUser.HomeDir, configDir +} + +// getConfigDir determines the config directory based on XDG_CONFIG_HOME or fallback +func getConfigDir(homeDir string) string { + // Use XDG_CONFIG_HOME if set, otherwise fallback to ~/.config/coder_boundary + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { + return filepath.Join(xdgConfigHome, "coder_boundary") + } + return filepath.Join(homeDir, ".config", "coder_boundary") +}