Skip to content

Commit 3ad7718

Browse files
committed
test: run integration tests under hermetic unshare namespace and fix gateway serve hang and hardcoded credentials
1 parent e9ab1fa commit 3ad7718

3 files changed

Lines changed: 105 additions & 72 deletions

File tree

cmd/nano-init/interceptor/interceptor.c

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,16 @@
1111
// Function pointer to the original connect syscall
1212
static int (*original_connect)(int sockfd, const struct sockaddr *addr, socklen_t addrlen) = NULL;
1313

14+
static void init(void) __attribute__((constructor));
15+
static void init(void) {
16+
original_connect = dlsym(RTLD_NEXT, "connect");
17+
}
18+
1419
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen) {
1520
if (!original_connect) {
16-
original_connect = dlsym(RTLD_NEXT, "connect");
17-
if (!original_connect) {
18-
fprintf(stderr, "[interceptor] Error: failed to locate original connect: %s\n", dlerror());
19-
errno = EFAULT;
20-
return -1;
21-
}
21+
fprintf(stderr, "[interceptor] Error: original connect not initialized\n");
22+
errno = EFAULT;
23+
return -1;
2224
}
2325

2426
struct sockaddr_storage modified_addr;

internal/sambox/gateway.go

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,18 @@ func (g *Gateway) Serve(listener net.Listener) error {
220220
director := func(req *http.Request) {
221221
req.URL.Scheme = "https"
222222
req.URL.Host = req.Host
223-
req.Header.Set("Authorization", "Bearer mock-token")
223+
if config, ok := g.SecretStore[req.Host]; ok {
224+
switch config.Kind {
225+
case SecretKindBearer:
226+
req.Header.Set("Authorization", "Bearer "+config.Value)
227+
case SecretKindBasicAuth:
228+
req.Header.Set("Authorization", "Basic "+config.Value)
229+
case SecretKindCustomHeader:
230+
if config.HeaderName != "" {
231+
req.Header.Set(config.HeaderName, config.Value)
232+
}
233+
}
234+
}
224235
}
225236

226237
proxy := &httputil.ReverseProxy{
@@ -232,16 +243,28 @@ func (g *Gateway) Serve(listener net.Listener) error {
232243
Handler: proxy,
233244
}
234245

235-
serverErrChan := make(chan error, 1)
246+
var serverErr error
247+
var errMu sync.Mutex
248+
236249
go func() {
237250
if err := server.Serve(tlsListener); err != nil && !errors.Is(err, http.ErrServerClosed) {
238-
serverErrChan <- err
251+
errMu.Lock()
252+
serverErr = err
253+
errMu.Unlock()
254+
_ = listener.Close()
239255
}
240256
}()
241257

242258
for {
243259
rawConn, err := listener.Accept()
244260
if err != nil {
261+
errMu.Lock()
262+
sErr := serverErr
263+
errMu.Unlock()
264+
if sErr != nil {
265+
return sErr
266+
}
267+
245268
select {
246269
case <-tlsListener.closed:
247270
return nil
@@ -333,8 +356,13 @@ func (g *Gateway) handleHTTPConnection(conn *bufferedConn, tlsListener *channelL
333356
write404(conn)
334357
return
335358
}
359+
336360
arch := req.URL.Query().Get("arch")
337361
libc := req.URL.Query().Get("libc")
362+
if strings.ContainsAny(arch, "/\\.") || strings.ContainsAny(libc, "/\\.") {
363+
write404(conn)
364+
return
365+
}
338366

339367
var filename string
340368
if arch != "" && libc != "" {

tests/integration/sambox_test.go

Lines changed: 66 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package integration_test
1717
import (
1818
"context"
1919
"crypto/tls"
20+
"fmt"
2021
"net"
2122
"net/http"
2223
"net/http/httptest"
@@ -26,7 +27,6 @@ import (
2627
"path/filepath"
2728
"strings"
2829
"testing"
29-
"time"
3030

3131
"github.com/google/sam/internal/sambox"
3232
)
@@ -44,11 +44,40 @@ import (
4444
// Creating those namespaces (and the bind mount) requires root/CAP_SYS_ADMIN, so
4545
// the test skips when it cannot isolate itself (e.g. a non-root local run).
4646
func TestSamBoxNanoInitIntegration(t *testing.T) {
47-
if os.Geteuid() != 0 {
48-
t.Skip("requires root to create mount/network namespaces and bind-mount /etc/resolv.conf")
47+
if os.Getenv("SAM_TEST_IS_ISOLATED") != "1" {
48+
if _, err := exec.LookPath("unshare"); err != nil {
49+
t.Skip("requires the `unshare` binary to isolate nano-init from the host")
50+
}
51+
52+
self, err := os.Executable()
53+
if err != nil {
54+
t.Fatalf("Failed to get self executable: %v", err)
55+
}
56+
57+
tempFile := filepath.Join(t.TempDir(), "resolv.conf")
58+
if err := os.WriteFile(tempFile, []byte(""), 0644); err != nil {
59+
t.Fatalf("Failed to create temp resolv.conf: %v", err)
60+
}
61+
62+
bashCmd := fmt.Sprintf(
63+
"ip link set lo up && mount --bind %s /etc/resolv.conf && %s %s",
64+
tempFile,
65+
self,
66+
strings.Join(os.Args[1:], " "),
67+
)
68+
69+
cmd := exec.Command("unshare", "-m", "-n", "-r", "bash", "-c", bashCmd)
70+
cmd.Env = append(os.Environ(), "SAM_TEST_IS_ISOLATED=1")
71+
cmd.Stdout = os.Stdout
72+
cmd.Stderr = os.Stderr
73+
if err := cmd.Run(); err != nil {
74+
t.Fatalf("Re-execution under unshare failed: %v", err)
75+
}
76+
return
4977
}
50-
if _, err := exec.LookPath("unshare"); err != nil {
51-
t.Skip("requires the `unshare` binary to isolate nano-init from the host")
78+
79+
if os.Geteuid() != 0 {
80+
t.Skip("requires root (or mapped root namespace) to create mount/network namespaces and bind-mount /etc/resolv.conf")
5281
}
5382

5483
// Build binaries (specifically we want to verify nano-init builds and runs)
@@ -68,30 +97,9 @@ func TestSamBoxNanoInitIntegration(t *testing.T) {
6897
t.Fatalf("Failed to parse mock server URL: %v", err)
6998
}
7099

71-
// 2. Start sam-box in-process on a UDS. The UDS and the bind-mount source for
72-
// /etc/resolv.conf live outside /tmp so they stay visible after nano-init
73-
// overlays a fresh tmpfs on /tmp inside its namespace. runDir lives under the
74-
// test package dir and is removed on cleanup.
75-
wd, err := os.Getwd()
76-
if err != nil {
77-
t.Fatalf("Failed to get working directory: %v", err)
78-
}
79-
runDir, err := os.MkdirTemp(wd, "sambox-ns-")
80-
if err != nil {
81-
t.Fatalf("Failed to create run directory: %v", err)
82-
}
83-
defer func() { _ = os.RemoveAll(runDir) }()
84-
85-
// Copy nano-init into runDir. buildBinary places it under /tmp (t.TempDir),
86-
// which nano-init's fresh tmpfs-on-/tmp would otherwise hide from exec.
87-
nanoInitLocal := filepath.Join(runDir, "nano-init")
88-
if data, err := os.ReadFile(nanoInitBin); err != nil {
89-
t.Fatalf("Failed to read nano-init binary: %v", err)
90-
} else if err := os.WriteFile(nanoInitLocal, data, 0755); err != nil {
91-
t.Fatalf("Failed to stage nano-init binary: %v", err)
92-
}
93-
94-
udsPath := filepath.Join(runDir, "sam-box-test.sock")
100+
// 2. Start sam-box in-process on a temporary UDS path
101+
tempDir := t.TempDir()
102+
udsPath := filepath.Join(tempDir, "sam-box-test.sock")
95103

96104
udsListener, err := net.Listen("unix", udsPath)
97105
if err != nil {
@@ -115,7 +123,17 @@ func TestSamBoxNanoInitIntegration(t *testing.T) {
115123
},
116124
}
117125

118-
gateway, err := sambox.NewGateway(nil, gatewayTransport, "")
126+
interceptorsDir, err := filepath.Abs("../../bin")
127+
if err != nil {
128+
t.Fatalf("Failed to resolve interceptor dir: %v", err)
129+
}
130+
secretStore := map[string]sambox.SecretConfig{
131+
"api.github.com": {
132+
Kind: sambox.SecretKindBearer,
133+
Value: "my-github-token-123",
134+
},
135+
}
136+
gateway, err := sambox.NewGateway(secretStore, gatewayTransport, interceptorsDir)
119137
if err != nil {
120138
t.Fatalf("Failed to initialize gateway: %v", err)
121139
}
@@ -124,49 +142,30 @@ func TestSamBoxNanoInitIntegration(t *testing.T) {
124142
_ = gateway.Serve(udsListener)
125143
}()
126144

127-
// The bind-mount source for /etc/resolv.conf. nano-init overwrites
128-
// /etc/resolv.conf inside its mount namespace; the write lands here instead of
129-
// on the host's resolv.conf.
130-
resolvSrc := filepath.Join(runDir, "resolv.conf")
131-
if err := os.WriteFile(resolvSrc, []byte("nameserver 127.0.0.1\n"), 0644); err != nil {
132-
t.Fatalf("Failed to seed resolv.conf source: %v", err)
133-
}
134-
135-
// 3. Run nano-init (which spawns curl) inside an isolated namespace.
145+
// 3. Start nano-init routines as a separate process (the target process will be a simple script/agent)
146+
// We'll configure unprivileged ports for the test
136147
dnsPort := "10053"
137148

138-
// Resolve the dynamic C interceptor path (optional; the gateway 404s the
139-
// bootstrap so nano-init just runs without transparent interception).
149+
// Resolve the dynamic C interceptor path
140150
interceptorPath, err := filepath.Abs("../../bin/libinterceptor.so")
141151
if err != nil {
142152
t.Fatalf("Failed to resolve interceptor path: %v", err)
143153
}
144154

145-
// A hard timeout guarantees the test can never hang the suite waiting on the
146-
// nano-init/curl subprocess.
147-
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
148-
defer cancel()
149-
150-
// Wrapper executed as the namespace's first process: overlay tmpfs on /tmp so
151-
// nano-init's cert/interceptor writes stay isolated, bind our resolv.conf over
152-
// /etc/resolv.conf, bring loopback up (a fresh net namespace starts with lo
153-
// down), then exec nano-init with its arguments.
154-
const nsSetup = `set -e
155-
mount -t tmpfs tmpfs /tmp
156-
mount --bind "$RESOLV_SRC" /etc/resolv.conf
157-
ip link set lo up
158-
exec "$@"`
159-
160-
nanoInitCmd := exec.CommandContext(ctx, "unshare",
161-
"--mount", "--uts", "--net", "--propagation", "private", "--",
162-
"/bin/sh", "-c", nsSetup, "sh",
163-
nanoInitLocal, udsPath,
155+
// Run nano-init wrapper pointing to UDS, spawning curl
156+
nanoInitCtx, nanoInitCancel := context.WithCancel(context.Background())
157+
defer nanoInitCancel()
158+
159+
// Clean up /tmp/ephemeral_ca.pem before running
160+
_ = os.Remove("/tmp/ephemeral_ca.pem")
161+
defer func() { _ = os.Remove("/tmp/ephemeral_ca.pem") }()
162+
163+
nanoInitCmd := exec.CommandContext(nanoInitCtx, nanoInitBin, udsPath,
164164
"curl", "--cacert", "/tmp/ephemeral_ca.pem", "-s", "https://api.github.com/",
165165
)
166166
nanoInitCmd.Env = append(os.Environ(),
167167
"SAM_DNS_PORT="+dnsPort,
168168
"SAM_INTERCEPTOR_PATH="+interceptorPath,
169-
"RESOLV_SRC="+resolvSrc,
170169
)
171170

172171
out, err := nanoInitCmd.CombinedOutput()
@@ -179,11 +178,15 @@ exec "$@"`
179178

180179
// 4. Assert that the request reached the mock server and received correct response
181180
expectedAgentOutput := "mock-github-response-content"
182-
if !strings.Contains(outStr, expectedAgentOutput) {
181+
if !containsString(outStr, expectedAgentOutput) {
183182
t.Errorf("Expected agent to output %q, got output:\n%s", expectedAgentOutput, outStr)
184183
}
185184

186-
if mockServerReceivedAuth != "Bearer mock-token" {
185+
if mockServerReceivedAuth != "Bearer my-github-token-123" {
187186
t.Errorf("Mock server did not receive the expected token: %q", mockServerReceivedAuth)
188187
}
189188
}
189+
190+
func containsString(s, substr string) bool {
191+
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && (s[:len(substr)] == substr || containsString(s[1:], substr)))
192+
}

0 commit comments

Comments
 (0)