Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 19 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 2 additions & 65 deletions cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@ import (
"log/slog"
"os"
"os/signal"
"os/user"
"path/filepath"
"strconv"
"strings"
"syscall"

Expand All @@ -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"
)

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
176 changes: 176 additions & 0 deletions e2e_tests/boundary_integration_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
20 changes: 18 additions & 2 deletions jail/linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think some additional documentation explaining this would be really good

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree, I'll do it in a follow-up PR

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
}
Expand Down
2 changes: 2 additions & 0 deletions tls/tls.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading