Skip to content

Commit cc99550

Browse files
test: add e2e-tests for boundary CLI
1 parent d628bfc commit cc99550

File tree

5 files changed

+287
-5
lines changed

5 files changed

+287
-5
lines changed

.github/workflows/ci.yml

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

76+
# - name: Setup tmate session
77+
# uses: mxschmitt/action-tmate@v3
78+
7679
- name: Run tests
7780
run: make test
7881

Makefile

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,17 @@ deps:
4949
.PHONY: test
5050
test:
5151
@echo "Running tests..."
52-
go test -v -race ./...
52+
@which go > /dev/null || (echo "Go not found in PATH" && exit 1)
53+
sudo $(shell which go) test -v -race ./...
5354
@echo "✓ All tests passed!"
5455

5556
# Run tests with coverage (needs sudo for E2E tests)
5657
.PHONY: test-coverage
5758
test-coverage:
5859
@echo "Running tests with coverage..."
59-
go test -v -race -coverprofile=coverage.out ./...
60-
go tool cover -html=coverage.out -o coverage.html
60+
@which go > /dev/null || (echo "Go not found in PATH" && exit 1)
61+
sudo $(shell which go) test -v -race -coverprofile=coverage.out ./...
62+
$(shell which go) tool cover -html=coverage.out -o coverage.html
6163
@echo "✓ Coverage report generated: coverage.html"
6264

6365
# CI checks (deps, test, build)
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
package e2e_tests
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"os"
8+
"os/exec"
9+
"os/user"
10+
"path/filepath"
11+
"strconv"
12+
"strings"
13+
"testing"
14+
"time"
15+
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
// getUserInfo returns information about the current user, handling sudo scenarios
20+
func getUserInfo() (string, int, int, string, string) {
21+
// Only consider SUDO_USER if we're actually running with elevated privileges
22+
// In environments like Coder workspaces, SUDO_USER may be set to 'root'
23+
// but we're not actually running under sudo
24+
if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" && os.Geteuid() == 0 && sudoUser != "root" {
25+
// We're actually running under sudo with a non-root original user
26+
user, err := user.Lookup(sudoUser)
27+
if err != nil {
28+
return getCurrentUserInfo() // Fallback to current user
29+
}
30+
31+
uid, _ := strconv.Atoi(os.Getenv("SUDO_UID"))
32+
gid, _ := strconv.Atoi(os.Getenv("SUDO_GID"))
33+
34+
// If we couldn't get UID/GID from env, parse from user info
35+
if uid == 0 {
36+
if parsedUID, err := strconv.Atoi(user.Uid); err == nil {
37+
uid = parsedUID
38+
}
39+
}
40+
if gid == 0 {
41+
if parsedGID, err := strconv.Atoi(user.Gid); err == nil {
42+
gid = parsedGID
43+
}
44+
}
45+
46+
configDir := getConfigDir(user.HomeDir)
47+
48+
return sudoUser, uid, gid, user.HomeDir, configDir
49+
}
50+
51+
// Not actually running under sudo, use current user
52+
return getCurrentUserInfo()
53+
}
54+
55+
// getCurrentUserInfo gets information for the current user
56+
func getCurrentUserInfo() (string, int, int, string, string) {
57+
currentUser, err := user.Current()
58+
if err != nil {
59+
// Fallback with empty values if we can't get user info
60+
return "", 0, 0, "", ""
61+
}
62+
63+
uid, _ := strconv.Atoi(currentUser.Uid)
64+
gid, _ := strconv.Atoi(currentUser.Gid)
65+
66+
configDir := getConfigDir(currentUser.HomeDir)
67+
68+
return currentUser.Username, uid, gid, currentUser.HomeDir, configDir
69+
}
70+
71+
// getConfigDir determines the config directory based on XDG_CONFIG_HOME or fallback
72+
func getConfigDir(homeDir string) string {
73+
// Use XDG_CONFIG_HOME if set, otherwise fallback to ~/.config/coder_boundary
74+
if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
75+
return filepath.Join(xdgConfigHome, "coder_boundary")
76+
}
77+
return filepath.Join(homeDir, ".config", "coder_boundary")
78+
}
79+
80+
// findProjectRoot finds the project root by looking for go.mod file
81+
func findProjectRoot(t *testing.T) string {
82+
cwd, err := os.Getwd()
83+
require.NoError(t, err, "Failed to get current working directory")
84+
85+
// Start from current directory and walk up until we find go.mod
86+
dir := cwd
87+
for {
88+
goModPath := filepath.Join(dir, "go.mod")
89+
if _, err := os.Stat(goModPath); err == nil {
90+
return dir
91+
}
92+
93+
parent := filepath.Dir(dir)
94+
if parent == dir {
95+
// Reached filesystem root
96+
t.Fatalf("Could not find go.mod file starting from %s", cwd)
97+
}
98+
dir = parent
99+
}
100+
}
101+
102+
// getNamespaceName gets the single network namespace name
103+
// Fails if there are 0 or multiple namespaces
104+
func getNamespaceName(t *testing.T) string {
105+
cmd := exec.Command("ip", "netns", "list")
106+
output, err := cmd.Output()
107+
require.NoError(t, err, "Failed to list network namespaces")
108+
109+
lines := strings.Split(string(output), "\n")
110+
var namespaces []string
111+
112+
for _, line := range lines {
113+
line = strings.TrimSpace(line)
114+
if line != "" {
115+
// Extract namespace name (first field)
116+
parts := strings.Fields(line)
117+
if len(parts) > 0 {
118+
namespaces = append(namespaces, parts[0])
119+
}
120+
}
121+
}
122+
123+
require.Len(t, namespaces, 1, "Expected exactly one network namespace, found %d: %v", len(namespaces), namespaces)
124+
return namespaces[0]
125+
}
126+
127+
func TestBoundaryIntegration(t *testing.T) {
128+
// Find project root by looking for go.mod file
129+
projectRoot := findProjectRoot(t)
130+
131+
// Build the boundary binary
132+
buildCmd := exec.Command("go", "build", "-o", "/tmp/boundary-test", "./cmd/...")
133+
buildCmd.Dir = projectRoot
134+
err := buildCmd.Run()
135+
require.NoError(t, err, "Failed to build boundary binary")
136+
137+
// Create context for boundary process
138+
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
139+
defer cancel()
140+
141+
// Start boundary process with sudo
142+
boundaryCmd := exec.CommandContext(ctx, "/tmp/boundary-test",
143+
"--allow", "dev.coder.com",
144+
"--allow", "jsonplaceholder.typicode.com",
145+
"--log-level", "debug",
146+
"--", "bash", "-c", "sleep 10 && echo 'Test completed'")
147+
148+
// Suppress output to prevent terminal corruption
149+
boundaryCmd.Stdout = os.Stdout // Let it go to /dev/null
150+
boundaryCmd.Stderr = os.Stderr
151+
152+
// Start the process
153+
err = boundaryCmd.Start()
154+
require.NoError(t, err, "Failed to start boundary process")
155+
156+
// Give boundary time to start
157+
time.Sleep(2 * time.Second)
158+
159+
// Get the namespace name that boundary created
160+
namespaceName := getNamespaceName(t)
161+
162+
// Test HTTP request through boundary (from inside the jail)
163+
t.Run("HTTPRequestThroughBoundary", func(t *testing.T) {
164+
// Run curl directly in the namespace using ip netns exec
165+
curlCmd := exec.Command("sudo", "ip", "netns", "exec", namespaceName,
166+
"curl", "http://jsonplaceholder.typicode.com/todos/1")
167+
168+
// Capture stderr separately
169+
var stderr bytes.Buffer
170+
curlCmd.Stderr = &stderr
171+
output, err := curlCmd.Output()
172+
173+
if err != nil {
174+
t.Fatalf("curl command failed: %v, stderr: %s, output: %s", err, stderr.String(), string(output))
175+
}
176+
177+
// Verify response contains expected content
178+
expectedResponse := `{
179+
"userId": 1,
180+
"id": 1,
181+
"title": "delectus aut autem",
182+
"completed": false
183+
}`
184+
require.Equal(t, expectedResponse, string(output))
185+
})
186+
187+
// Test HTTPS request through boundary (from inside the jail)
188+
t.Run("HTTPSRequestThroughBoundary", func(t *testing.T) {
189+
u, err := user.Current()
190+
if err != nil {
191+
panic(err)
192+
}
193+
fmt.Printf("u.Username: %v\n", u.Username)
194+
fmt.Printf("u.HomeDir: %v\n", u.HomeDir)
195+
196+
sudoUser, uid, gid, homeDir, configDir := getUserInfo()
197+
fmt.Printf("sudoUser: %v\n", sudoUser)
198+
fmt.Printf("uid: %v\n", uid)
199+
fmt.Printf("gid: %v\n", gid)
200+
fmt.Printf("homeDir: %v\n", homeDir)
201+
fmt.Printf("configDir: %v\n", configDir)
202+
certPath := fmt.Sprintf("%v/ca-cert.pem", configDir)
203+
204+
// Run curl directly in the namespace using ip netns exec
205+
// TODO(yevhenii): remove env
206+
curlCmd := exec.Command("sudo", "ip", "netns", "exec", namespaceName,
207+
"env", fmt.Sprintf("SSL_CERT_FILE=%v", certPath), "curl", "-s", "https://dev.coder.com/api/v2")
208+
209+
// Capture stderr separately
210+
var stderr bytes.Buffer
211+
curlCmd.Stderr = &stderr
212+
output, err := curlCmd.Output()
213+
214+
if err != nil {
215+
t.Fatalf("curl command failed: %v, stderr: %s, output: %s", err, stderr.String(), string(output))
216+
}
217+
218+
// Verify response contains expected content
219+
expectedResponse := `{"message":"👋"}
220+
`
221+
require.Equal(t, expectedResponse, string(output))
222+
})
223+
224+
// Test blocked domain (from inside the jail)
225+
t.Run("BlockedDomainTest", func(t *testing.T) {
226+
// Run curl directly in the namespace using ip netns exec
227+
curlCmd := exec.Command("sudo", "ip", "netns", "exec", namespaceName,
228+
"curl", "-s", "http://example.com")
229+
230+
// Capture stderr separately
231+
var stderr bytes.Buffer
232+
curlCmd.Stderr = &stderr
233+
output, err := curlCmd.Output()
234+
235+
if err != nil {
236+
t.Fatalf("curl command failed: %v, stderr: %s, output: %s", err, stderr.String(), string(output))
237+
}
238+
require.Contains(t, string(output), "Request Blocked by Boundary")
239+
})
240+
241+
// Clean up
242+
cancel() // This will terminate the boundary process
243+
err = boundaryCmd.Wait() // Wait for process to finish
244+
if err != nil {
245+
t.Logf("Boundary process finished with error: %v", err)
246+
}
247+
248+
// Clean up binary
249+
os.Remove("/tmp/boundary-test")
250+
}

jail/linux.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ 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+
//p := fmt.Sprintf("http://localhost:%d", l.httpProxyPort)
48+
l.commandEnv = mergeEnvs(e, map[string]string{
49+
//"HOME": l.homeDir,
50+
//"USER": l.username,
51+
//"LOGNAME": l.username,
52+
//"HTTP_PROXY": p,
53+
//"HTTPS_PROXY": p,
54+
//"http_proxy": p,
55+
//"https_proxy": p,
56+
})
57+
4658
// Setup DNS configuration BEFORE creating namespace
4759
// This ensures the namespace-specific resolv.conf is available when namespace is created
4860
err := l.setupDNS()
@@ -75,10 +87,10 @@ func (l *LinuxJail) Start() error {
7587
func (l *LinuxJail) Command(command []string) *exec.Cmd {
7688
l.logger.Debug("Creating command with namespace", "namespace", l.namespace)
7789

78-
cmdArgs := []string{"ip", "netns", "exec", l.namespace}
90+
cmdArgs := []string{"netns", "exec", l.namespace}
7991
cmdArgs = append(cmdArgs, command...)
8092

81-
cmd := exec.Command("sudo", cmdArgs...)
93+
cmd := exec.Command("ip", cmdArgs...)
8294
cmd.Env = l.commandEnv
8395

8496
return cmd
@@ -214,6 +226,19 @@ func (l *LinuxJail) setupIptables() error {
214226
return fmt.Errorf("failed to add comprehensive TCP redirect rule: %v", err)
215227
}
216228

229+
// TODO: clean up this rules
230+
cmd = exec.Command("iptables", "-A", "FORWARD", "-s", "192.168.100.0/24", "-j", "ACCEPT")
231+
err = cmd.Run()
232+
if err != nil {
233+
return err
234+
}
235+
236+
cmd = exec.Command("iptables", "-A", "FORWARD", "-d", "192.168.100.0/24", "-j", "ACCEPT")
237+
err = cmd.Run()
238+
if err != nil {
239+
return err
240+
}
241+
217242
l.logger.Debug("Comprehensive TCP boundarying enabled", "interface", l.vethHost, "proxy_port", l.httpProxyPort)
218243
return nil
219244
}

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)