Skip to content

Commit 7f58ae1

Browse files
feat: lowering perms to CAP_NET_ADMIN
1 parent 55a44f2 commit 7f58ae1

File tree

9 files changed

+322
-178
lines changed

9 files changed

+322
-178
lines changed

.github/workflows/ci.yml

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

76+
# Before (default):
77+
# - /etc/resolv.conf -> /run/systemd/resolve/stub-resolv.conf
78+
# - stub-resolv.conf points to 127.0.0.53 (systemd-resolved stub listener)
79+
# - systemd-resolved forwards to the real upstream file:
80+
# /run/systemd/resolve/resolv.conf
81+
# Flow: /etc/resolv.conf -> stub-resolv.conf (127.0.0.53) -> systemd-resolved -> /run/systemd/resolve/resolv.conf
82+
#
83+
# After (bind-mount):
84+
# - /etc/resolv.conf is bind-mounted to /run/systemd/resolve/resolv.conf
85+
# - processes read upstream nameservers directly from /run/systemd/resolve/resolv.conf
86+
# Flow: /etc/resolv.conf -> /run/systemd/resolve/resolv.conf
87+
#
88+
# This makes processes talk directly to the upstream DNS servers and
89+
# bypasses the systemd-resolved *stub listener* (127.0.0.53).
90+
#
91+
# Important nuance: systemd-resolved itself is NOT stopped; it still runs and updates
92+
# /run/systemd/resolve/resolv.conf. Because this is a bind (not a copy), updates to the
93+
# upstream list are visible. Trade-off: we lose the stub’s features (caching,
94+
# split-DNS/VPN per-interface behavior, DNSSEC/DoT/DoH mediation, mDNS/LLMNR).
95+
#
96+
# Reason: network namespaces have their own network stack (interfaces, routes and loopback).
97+
# A process inside a network namespace resolves 127.0.0.53 against that namespace’s loopback, not the host’s,
98+
# and systemd-resolved usually listens on the host loopback. As a result the stub at 127.0.0.53 is often
99+
# unreachable from an isolated namespace and DNS lookups fail.
100+
# Bind-mounting /run/systemd/resolve/resolv.conf over /etc/resolv.conf forces processes to use the upstream
101+
# nameservers directly, avoiding that failure.
102+
- name: Change DNS configuration
103+
if: runner.os == 'Linux'
104+
run: sudo mount --bind /run/systemd/resolve/resolv.conf /etc/resolv.conf
105+
76106
- name: Run unit tests
77107
run: make unit-test
78108

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ e2e-test:
6262
echo "E2E tests require Linux platform. Current platform: $$(uname)"; \
6363
exit 1; \
6464
fi
65-
sudo $(shell which go) test -v -race ./e2e_tests
65+
sudo $(shell which go) test -v -race ./e2e_tests -count=1
6666
@echo "✓ E2E tests passed!"
6767

6868
# Run tests with coverage (needs sudo for E2E tests)

boundary.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ func New(ctx context.Context, config Config) (*Boundary, error) {
5555
}
5656

5757
func (b *Boundary) Start() error {
58-
// Start the jailer (network isolation)
59-
err := b.jailer.Start()
58+
// Configure the jailer (network isolation)
59+
err := b.jailer.ConfigureBeforeCommandExecution()
6060
if err != nil {
6161
return fmt.Errorf("failed to start jailer: %v", err)
6262
}
@@ -78,6 +78,10 @@ func (b *Boundary) Command(command []string) *exec.Cmd {
7878
return b.jailer.Command(command)
7979
}
8080

81+
func (b *Boundary) ConfigureAfterCommandExecution(processPID int) error {
82+
return b.jailer.ConfigureAfterCommandExecution(processPID)
83+
}
84+
8185
func (b *Boundary) Close() error {
8286
// Stop proxy server
8387
if b.proxyServer != nil {

cli/cli.go

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package cli
33
import (
44
"context"
55
"fmt"
6+
"log"
67
"log/slog"
78
"os"
9+
"os/exec"
810
"os/signal"
911
"path/filepath"
1012
"strings"
@@ -92,8 +94,39 @@ func BaseCommand() *serpent.Command {
9294
}
9395
}
9496

97+
func isChild() bool {
98+
return os.Getenv("CHILD") == "true"
99+
}
100+
95101
// Run executes the boundary command with the given configuration and arguments
96102
func Run(ctx context.Context, config Config, args []string) error {
103+
if isChild() {
104+
log.Printf("boundary CHILD process is started")
105+
106+
vethNetJail := os.Getenv("VETH_JAIL_NAME")
107+
err := jail.SetupChildNetworking(vethNetJail)
108+
if err != nil {
109+
return fmt.Errorf("failed to setup child networking: %v", err)
110+
}
111+
log.Printf("child networking is successfully configured")
112+
113+
// Program to run
114+
bin := args[0]
115+
args = args[1:]
116+
117+
cmd := exec.Command(bin, args...)
118+
cmd.Stdin = os.Stdin
119+
cmd.Stdout = os.Stdout
120+
cmd.Stderr = os.Stderr
121+
err = cmd.Run()
122+
if err != nil {
123+
log.Printf("failed to run %s: %v", bin, err)
124+
return err
125+
}
126+
127+
return nil
128+
}
129+
97130
ctx, cancel := context.WithCancel(ctx)
98131
defer cancel()
99132

@@ -191,15 +224,26 @@ func Run(ctx context.Context, config Config, args []string) error {
191224
// Execute command in boundary
192225
go func() {
193226
defer cancel()
194-
cmd := boundaryInstance.Command(args)
195-
cmd.Stderr = os.Stderr
196-
cmd.Stdout = os.Stdout
197-
cmd.Stdin = os.Stdin
227+
cmd := boundaryInstance.Command(os.Args)
228+
229+
logger.Debug("Executing command in boundary", "command", strings.Join(os.Args, " "))
230+
err := cmd.Start()
231+
if err != nil {
232+
logger.Error("Command failed to start", "error", err)
233+
return
234+
}
235+
236+
err = boundaryInstance.ConfigureAfterCommandExecution(cmd.Process.Pid)
237+
if err != nil {
238+
logger.Error("configuration after command execution failed", "error", err)
239+
return
240+
}
198241

199-
logger.Debug("Executing command in boundary", "command", strings.Join(args, " "))
200-
err := cmd.Run()
242+
logger.Debug("waiting on a child process to finish")
243+
err = cmd.Wait()
201244
if err != nil {
202245
logger.Error("Command execution failed", "error", err)
246+
return
203247
}
204248
}()
205249

e2e_tests/boundary_integration_test.go

Lines changed: 23 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"os/exec"
99
"path/filepath"
10+
"strconv"
1011
"strings"
1112
"testing"
1213
"time"
@@ -37,29 +38,15 @@ func findProjectRoot(t *testing.T) string {
3738
}
3839
}
3940

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")
41+
func getChildProcessPID(t *testing.T) int {
42+
cmd := exec.Command("pgrep", "-f", "boundary-test", "-n")
4443
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-
}
44+
require.NoError(t, err)
6045

61-
require.Len(t, namespaces, 1, "Expected exactly one network namespace, found %d: %v", len(namespaces), namespaces)
62-
return namespaces[0]
46+
pidStr := strings.TrimSpace(string(output))
47+
pid, err := strconv.Atoi(pidStr)
48+
require.NoError(t, err)
49+
return pid
6350
}
6451

6552
func TestBoundaryIntegration(t *testing.T) {
@@ -73,18 +60,19 @@ func TestBoundaryIntegration(t *testing.T) {
7360
require.NoError(t, err, "Failed to build boundary binary")
7461

7562
// Create context for boundary process
76-
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
63+
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Second)
7764
defer cancel()
7865

7966
// Start boundary process with sudo
8067
boundaryCmd := exec.CommandContext(ctx, "/tmp/boundary-test",
8168
"--allow", "dev.coder.com",
8269
"--allow", "jsonplaceholder.typicode.com",
8370
"--log-level", "debug",
84-
"--", "bash", "-c", "sleep 10 && echo 'Test completed'")
71+
//"--", "/bin/bash")
72+
"--", "/bin/bash", "-c", "/usr/bin/sleep 12 && /usr/bin/echo 'Test completed'")
8573

86-
// Suppress output to prevent terminal corruption
87-
boundaryCmd.Stdout = os.Stdout // Let it go to /dev/null
74+
boundaryCmd.Stdin = os.Stdin
75+
boundaryCmd.Stdout = os.Stdout
8876
boundaryCmd.Stderr = os.Stderr
8977

9078
// Start the process
@@ -95,12 +83,18 @@ func TestBoundaryIntegration(t *testing.T) {
9583
time.Sleep(2 * time.Second)
9684

9785
// Get the namespace name that boundary created
98-
namespaceName := getNamespaceName(t)
86+
//namespaceName := getNamespaceName(t)
87+
88+
pidInt := getChildProcessPID(t)
89+
pid := fmt.Sprintf("%v", pidInt)
90+
91+
fmt.Printf("pidInt: %v\n", pidInt)
92+
//time.Sleep(200 * time.Second)
9993

10094
// Test HTTP request through boundary (from inside the jail)
10195
t.Run("HTTPRequestThroughBoundary", func(t *testing.T) {
10296
// Run curl directly in the namespace using ip netns exec
103-
curlCmd := exec.Command("sudo", "ip", "netns", "exec", namespaceName,
97+
curlCmd := exec.Command("sudo", "nsenter", "-t", pid, "-n", "--",
10498
"curl", "http://jsonplaceholder.typicode.com/todos/1")
10599

106100
// Capture stderr separately
@@ -128,7 +122,7 @@ func TestBoundaryIntegration(t *testing.T) {
128122
certPath := fmt.Sprintf("%v/ca-cert.pem", configDir)
129123

130124
// Run curl directly in the namespace using ip netns exec
131-
curlCmd := exec.Command("sudo", "ip", "netns", "exec", namespaceName,
125+
curlCmd := exec.Command("sudo", "sudo", "nsenter", "-t", pid, "-n", "--",
132126
"env", fmt.Sprintf("SSL_CERT_FILE=%v", certPath), "curl", "-s", "https://dev.coder.com/api/v2")
133127

134128
// Capture stderr separately
@@ -149,7 +143,7 @@ func TestBoundaryIntegration(t *testing.T) {
149143
// Test blocked domain (from inside the jail)
150144
t.Run("BlockedDomainTest", func(t *testing.T) {
151145
// Run curl directly in the namespace using ip netns exec
152-
curlCmd := exec.Command("sudo", "ip", "netns", "exec", namespaceName,
146+
curlCmd := exec.Command("sudo", "sudo", "nsenter", "-t", pid, "-n", "--",
153147
"curl", "-s", "http://example.com")
154148

155149
// Capture stderr separately

jail/jail.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import (
88
)
99

1010
type Jailer interface {
11-
Start() error
11+
ConfigureBeforeCommandExecution() error
1212
Command(command []string) *exec.Cmd
13+
ConfigureAfterCommandExecution(processPID int) error
1314
Close() error
1415
}
1516

0 commit comments

Comments
 (0)