Skip to content

Commit 459dbda

Browse files
test: add e2e-tests for boundary CLI (#60)
1 parent d628bfc commit 459dbda

File tree

7 files changed

+292
-75
lines changed

7 files changed

+292
-75
lines changed

.github/workflows/ci.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,12 @@ jobs:
7373
- name: Download and verify dependencies
7474
run: make deps
7575

76-
- name: Run tests
77-
run: make test
76+
- name: Run unit tests
77+
run: make unit-test
78+
79+
- name: Run e2e tests
80+
run: make e2e-test
81+
if: matrix.os == 'ubuntu-latest'
7882

7983
- name: Check build
8084
run: make build

Makefile

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,25 @@ deps:
4545
go mod verify
4646
@echo "✓ Dependencies ready!"
4747

48-
# Run tests (needs sudo for E2E tests)
49-
.PHONY: test
50-
test:
51-
@echo "Running tests..."
52-
go test -v -race ./...
53-
@echo "✓ All tests passed!"
48+
# Run unit tests only (no sudo required)
49+
.PHONY: unit-test
50+
unit-test:
51+
@echo "Running unit tests..."
52+
@which go > /dev/null || (echo "Go not found in PATH" && exit 1)
53+
go test -v -race $$(go list ./... | grep -v e2e_tests)
54+
@echo "✓ Unit tests passed!"
55+
56+
# Run E2E tests (Linux only, needs sudo)
57+
.PHONY: e2e-test
58+
e2e-test:
59+
@echo "Running E2E tests..."
60+
@which go > /dev/null || (echo "Go not found in PATH" && exit 1)
61+
@if [ "$$(uname)" != "Linux" ]; then \
62+
echo "E2E tests require Linux platform. Current platform: $$(uname)"; \
63+
exit 1; \
64+
fi
65+
sudo $(shell which go) test -v -race ./e2e_tests
66+
@echo "✓ E2E tests passed!"
5467

5568
# Run tests with coverage (needs sudo for E2E tests)
5669
.PHONY: test-coverage

cli/cli.go

Lines changed: 2 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,6 @@ import (
66
"log/slog"
77
"os"
88
"os/signal"
9-
"os/user"
10-
"path/filepath"
11-
"strconv"
129
"strings"
1310
"syscall"
1411

@@ -17,6 +14,7 @@ import (
1714
"github.com/coder/boundary/jail"
1815
"github.com/coder/boundary/rules"
1916
"github.com/coder/boundary/tls"
17+
"github.com/coder/boundary/util"
2018
"github.com/coder/serpent"
2119
)
2220

@@ -90,7 +88,7 @@ func Run(ctx context.Context, config Config, args []string) error {
9088
ctx, cancel := context.WithCancel(ctx)
9189
defer cancel()
9290
logger := setupLogging(config.LogLevel)
93-
username, uid, gid, homeDir, configDir := getUserInfo()
91+
username, uid, gid, homeDir, configDir := util.GetUserInfo()
9492

9593
// Get command arguments
9694
if len(args) == 0 {
@@ -205,42 +203,6 @@ func Run(ctx context.Context, config Config, args []string) error {
205203
return nil
206204
}
207205

208-
// getUserInfo returns information about the current user, handling sudo scenarios
209-
func getUserInfo() (string, int, int, string, string) {
210-
// Only consider SUDO_USER if we're actually running with elevated privileges
211-
// In environments like Coder workspaces, SUDO_USER may be set to 'root'
212-
// but we're not actually running under sudo
213-
if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" && os.Geteuid() == 0 && sudoUser != "root" {
214-
// We're actually running under sudo with a non-root original user
215-
user, err := user.Lookup(sudoUser)
216-
if err != nil {
217-
return getCurrentUserInfo() // Fallback to current user
218-
}
219-
220-
uid, _ := strconv.Atoi(os.Getenv("SUDO_UID"))
221-
gid, _ := strconv.Atoi(os.Getenv("SUDO_GID"))
222-
223-
// If we couldn't get UID/GID from env, parse from user info
224-
if uid == 0 {
225-
if parsedUID, err := strconv.Atoi(user.Uid); err == nil {
226-
uid = parsedUID
227-
}
228-
}
229-
if gid == 0 {
230-
if parsedGID, err := strconv.Atoi(user.Gid); err == nil {
231-
gid = parsedGID
232-
}
233-
}
234-
235-
configDir := getConfigDir(user.HomeDir)
236-
237-
return sudoUser, uid, gid, user.HomeDir, configDir
238-
}
239-
240-
// Not actually running under sudo, use current user
241-
return getCurrentUserInfo()
242-
}
243-
244206
// setupLogging creates a slog logger with the specified level
245207
func setupLogging(logLevel string) *slog.Logger {
246208
var level slog.Level
@@ -265,31 +227,6 @@ func setupLogging(logLevel string) *slog.Logger {
265227
return slog.New(handler)
266228
}
267229

268-
// getCurrentUserInfo gets information for the current user
269-
func getCurrentUserInfo() (string, int, int, string, string) {
270-
currentUser, err := user.Current()
271-
if err != nil {
272-
// Fallback with empty values if we can't get user info
273-
return "", 0, 0, "", ""
274-
}
275-
276-
uid, _ := strconv.Atoi(currentUser.Uid)
277-
gid, _ := strconv.Atoi(currentUser.Gid)
278-
279-
configDir := getConfigDir(currentUser.HomeDir)
280-
281-
return currentUser.Username, uid, gid, currentUser.HomeDir, configDir
282-
}
283-
284-
// getConfigDir determines the config directory based on XDG_CONFIG_HOME or fallback
285-
func getConfigDir(homeDir string) string {
286-
// Use XDG_CONFIG_HOME if set, otherwise fallback to ~/.config/coder_boundary
287-
if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
288-
return filepath.Join(xdgConfigHome, "coder_boundary")
289-
}
290-
return filepath.Join(homeDir, ".config", "coder_boundary")
291-
}
292-
293230
// createJailer creates a new jail instance for the current platform
294231
func createJailer(config jail.Config, unprivileged bool) (jail.Jailer, error) {
295232
if unprivileged {
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package e2e_tests
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"strings"
11+
"testing"
12+
"time"
13+
14+
"github.com/coder/boundary/util"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
// findProjectRoot finds the project root by looking for go.mod file
19+
func findProjectRoot(t *testing.T) string {
20+
cwd, err := os.Getwd()
21+
require.NoError(t, err, "Failed to get current working directory")
22+
23+
// Start from current directory and walk up until we find go.mod
24+
dir := cwd
25+
for {
26+
goModPath := filepath.Join(dir, "go.mod")
27+
if _, err := os.Stat(goModPath); err == nil {
28+
return dir
29+
}
30+
31+
parent := filepath.Dir(dir)
32+
if parent == dir {
33+
// Reached filesystem root
34+
t.Fatalf("Could not find go.mod file starting from %s", cwd)
35+
}
36+
dir = parent
37+
}
38+
}
39+
40+
// getNamespaceName gets the single network namespace name
41+
// Fails if there are 0 or multiple namespaces
42+
func getNamespaceName(t *testing.T) string {
43+
cmd := exec.Command("ip", "netns", "list")
44+
output, err := cmd.Output()
45+
require.NoError(t, err, "Failed to list network namespaces")
46+
47+
lines := strings.Split(string(output), "\n")
48+
var namespaces []string
49+
50+
for _, line := range lines {
51+
line = strings.TrimSpace(line)
52+
if line != "" {
53+
// Extract namespace name (first field)
54+
parts := strings.Fields(line)
55+
if len(parts) > 0 {
56+
namespaces = append(namespaces, parts[0])
57+
}
58+
}
59+
}
60+
61+
require.Len(t, namespaces, 1, "Expected exactly one network namespace, found %d: %v", len(namespaces), namespaces)
62+
return namespaces[0]
63+
}
64+
65+
func TestBoundaryIntegration(t *testing.T) {
66+
// Find project root by looking for go.mod file
67+
projectRoot := findProjectRoot(t)
68+
69+
// Build the boundary binary
70+
buildCmd := exec.Command("go", "build", "-o", "/tmp/boundary-test", "./cmd/...")
71+
buildCmd.Dir = projectRoot
72+
err := buildCmd.Run()
73+
require.NoError(t, err, "Failed to build boundary binary")
74+
75+
// Create context for boundary process
76+
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
77+
defer cancel()
78+
79+
// Start boundary process with sudo
80+
boundaryCmd := exec.CommandContext(ctx, "/tmp/boundary-test",
81+
"--allow", "dev.coder.com",
82+
"--allow", "jsonplaceholder.typicode.com",
83+
"--log-level", "debug",
84+
"--", "bash", "-c", "sleep 10 && echo 'Test completed'")
85+
86+
// Suppress output to prevent terminal corruption
87+
boundaryCmd.Stdout = os.Stdout // Let it go to /dev/null
88+
boundaryCmd.Stderr = os.Stderr
89+
90+
// Start the process
91+
err = boundaryCmd.Start()
92+
require.NoError(t, err, "Failed to start boundary process")
93+
94+
// Give boundary time to start
95+
time.Sleep(2 * time.Second)
96+
97+
// Get the namespace name that boundary created
98+
namespaceName := getNamespaceName(t)
99+
100+
// Test HTTP request through boundary (from inside the jail)
101+
t.Run("HTTPRequestThroughBoundary", func(t *testing.T) {
102+
// Run curl directly in the namespace using ip netns exec
103+
curlCmd := exec.Command("sudo", "ip", "netns", "exec", namespaceName,
104+
"curl", "http://jsonplaceholder.typicode.com/todos/1")
105+
106+
// Capture stderr separately
107+
var stderr bytes.Buffer
108+
curlCmd.Stderr = &stderr
109+
output, err := curlCmd.Output()
110+
111+
if err != nil {
112+
t.Fatalf("curl command failed: %v, stderr: %s, output: %s", err, stderr.String(), string(output))
113+
}
114+
115+
// Verify response contains expected content
116+
expectedResponse := `{
117+
"userId": 1,
118+
"id": 1,
119+
"title": "delectus aut autem",
120+
"completed": false
121+
}`
122+
require.Equal(t, expectedResponse, string(output))
123+
})
124+
125+
// Test HTTPS request through boundary (from inside the jail)
126+
t.Run("HTTPSRequestThroughBoundary", func(t *testing.T) {
127+
_, _, _, _, configDir := util.GetUserInfo()
128+
certPath := fmt.Sprintf("%v/ca-cert.pem", configDir)
129+
130+
// Run curl directly in the namespace using ip netns exec
131+
curlCmd := exec.Command("sudo", "ip", "netns", "exec", namespaceName,
132+
"env", fmt.Sprintf("SSL_CERT_FILE=%v", certPath), "curl", "-s", "https://dev.coder.com/api/v2")
133+
134+
// Capture stderr separately
135+
var stderr bytes.Buffer
136+
curlCmd.Stderr = &stderr
137+
output, err := curlCmd.Output()
138+
139+
if err != nil {
140+
t.Fatalf("curl command failed: %v, stderr: %s, output: %s", err, stderr.String(), string(output))
141+
}
142+
143+
// Verify response contains expected content
144+
expectedResponse := `{"message":"👋"}
145+
`
146+
require.Equal(t, expectedResponse, string(output))
147+
})
148+
149+
// Test blocked domain (from inside the jail)
150+
t.Run("BlockedDomainTest", func(t *testing.T) {
151+
// Run curl directly in the namespace using ip netns exec
152+
curlCmd := exec.Command("sudo", "ip", "netns", "exec", namespaceName,
153+
"curl", "-s", "http://example.com")
154+
155+
// Capture stderr separately
156+
var stderr bytes.Buffer
157+
curlCmd.Stderr = &stderr
158+
output, err := curlCmd.Output()
159+
160+
if err != nil {
161+
t.Fatalf("curl command failed: %v, stderr: %s, output: %s", err, stderr.String(), string(output))
162+
}
163+
require.Contains(t, string(output), "Request Blocked by Boundary")
164+
})
165+
166+
// Clean up
167+
cancel() // This will terminate the boundary process
168+
err = boundaryCmd.Wait() // Wait for process to finish
169+
if err != nil {
170+
t.Logf("Boundary process finished with error: %v", err)
171+
}
172+
173+
// Clean up binary
174+
err = os.Remove("/tmp/boundary-test")
175+
require.NoError(t, err, "Failed to remove /tmp/boundary-test")
176+
}

jail/linux.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ func NewLinuxJail(config Config) (*LinuxJail, error) {
4343
func (l *LinuxJail) Start() error {
4444
l.logger.Debug("Setup called")
4545

46+
e := getEnvs(l.configDir, l.caCertPath)
47+
l.commandEnv = mergeEnvs(e, map[string]string{})
48+
4649
// Setup DNS configuration BEFORE creating namespace
4750
// This ensures the namespace-specific resolv.conf is available when namespace is created
4851
err := l.setupDNS()
@@ -75,10 +78,10 @@ func (l *LinuxJail) Start() error {
7578
func (l *LinuxJail) Command(command []string) *exec.Cmd {
7679
l.logger.Debug("Creating command with namespace", "namespace", l.namespace)
7780

78-
cmdArgs := []string{"ip", "netns", "exec", l.namespace}
81+
cmdArgs := []string{"netns", "exec", l.namespace}
7982
cmdArgs = append(cmdArgs, command...)
8083

81-
cmd := exec.Command("sudo", cmdArgs...)
84+
cmd := exec.Command("ip", cmdArgs...)
8285
cmd.Env = l.commandEnv
8386

8487
return cmd
@@ -214,6 +217,19 @@ func (l *LinuxJail) setupIptables() error {
214217
return fmt.Errorf("failed to add comprehensive TCP redirect rule: %v", err)
215218
}
216219

220+
// TODO: clean up this rules
221+
cmd = exec.Command("iptables", "-A", "FORWARD", "-s", "192.168.100.0/24", "-j", "ACCEPT")
222+
err = cmd.Run()
223+
if err != nil {
224+
return err
225+
}
226+
227+
cmd = exec.Command("iptables", "-A", "FORWARD", "-d", "192.168.100.0/24", "-j", "ACCEPT")
228+
err = cmd.Run()
229+
if err != nil {
230+
return err
231+
}
232+
217233
l.logger.Debug("Comprehensive TCP boundarying enabled", "interface", l.vethHost, "proxy_port", l.httpProxyPort)
218234
return nil
219235
}

tls/tls.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ func (cm *CertificateManager) loadOrGenerateCA() error {
8686
caKeyPath := filepath.Join(cm.configDir, "ca-key.pem")
8787
caCertPath := filepath.Join(cm.configDir, "ca-cert.pem")
8888

89+
cm.logger.Debug("paths", "cm.configDir", cm.configDir, "caCertPath", caCertPath)
90+
8991
// Try to load existing CA
9092
if cm.loadExistingCA(caKeyPath, caCertPath) {
9193
cm.logger.Debug("Loaded existing CA certificate")

0 commit comments

Comments
 (0)