Skip to content

Commit ce5e5f2

Browse files
committed
feat: US-017 - Add SSH port forwarding / tunneling e2e-docker fixture
1 parent e9fde4c commit ce5e5f2

7 files changed

Lines changed: 168 additions & 3 deletions

File tree

packages/secure-exec/tests/e2e-docker.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
import { createTestNodeRuntime } from "./test-utils.js";
2525
import {
2626
buildImage,
27+
getContainerInternalIp,
2728
skipUnlessDocker,
2829
startContainer,
2930
type Container,
@@ -111,6 +112,9 @@ const skipReason = isCI ? false : skipUnlessDocker();
111112

112113
const activeContainers: Container[] = [];
113114
let services: ServiceConnections = {};
115+
let internalAddresses: Partial<
116+
Record<ServiceName, { host: string; port: number }>
117+
> = {};
114118

115119
/* ------------------------------------------------------------------ */
116120
/* Fixture discovery (runs at module load) */
@@ -144,6 +148,12 @@ describe.skipIf(skipReason)("e2e-docker", () => {
144148
port: Number(process.env.SSH_PORT ?? 2222),
145149
},
146150
};
151+
internalAddresses = {
152+
redis: {
153+
host: process.env.REDIS_INTERNAL_HOST ?? "127.0.0.1",
154+
port: Number(process.env.REDIS_INTERNAL_PORT ?? 6379),
155+
},
156+
};
147157
return;
148158
}
149159

@@ -212,6 +222,15 @@ describe.skipIf(skipReason)("e2e-docker", () => {
212222
redis: { host: redis.host, port: redis.port },
213223
ssh: { host: ssh.host, port: ssh.port },
214224
};
225+
226+
// Internal Docker bridge IPs for tunnel tests (SSH container can reach
227+
// other containers on the same bridge by their internal IP)
228+
internalAddresses = {
229+
redis: {
230+
host: getContainerInternalIp(redis.containerId),
231+
port: 6379,
232+
},
233+
};
215234
}, 180_000);
216235

217236
afterAll(() => {
@@ -289,10 +308,16 @@ function getServiceEnvVars(
289308
env.MYSQL_HOST = conn.host;
290309
env.MYSQL_PORT = String(conn.port);
291310
break;
292-
case "redis":
311+
case "redis": {
293312
env.REDIS_HOST = conn.host;
294313
env.REDIS_PORT = String(conn.port);
314+
const internal = internalAddresses.redis;
315+
if (internal) {
316+
env.REDIS_INTERNAL_HOST = internal.host;
317+
env.REDIS_INTERNAL_PORT = String(internal.port);
318+
}
295319
break;
320+
}
296321
case "ssh":
297322
env.SSH_HOST = conn.host;
298323
env.SSH_PORT = String(conn.port);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"entry": "src/index.js",
3+
"expectation": "pass",
4+
"services": ["ssh", "redis"]
5+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "e2e-docker-ssh2-tunnel",
3+
"private": true,
4+
"type": "commonjs",
5+
"dependencies": {
6+
"ssh2": "1.17.0"
7+
}
8+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
const { Client } = require("ssh2");
2+
3+
async function main() {
4+
const tunnelHost = process.env.REDIS_INTERNAL_HOST;
5+
const tunnelPort = Number(process.env.REDIS_INTERNAL_PORT || "6379");
6+
7+
if (!tunnelHost) {
8+
throw new Error("REDIS_INTERNAL_HOST is required for tunnel test");
9+
}
10+
11+
const result = await new Promise((resolve, reject) => {
12+
const conn = new Client();
13+
let settled = false;
14+
15+
conn.on("ready", () => {
16+
// Open a tunnel through the SSH server to Redis
17+
conn.forwardOut(
18+
"127.0.0.1",
19+
0,
20+
tunnelHost,
21+
tunnelPort,
22+
(err, stream) => {
23+
if (err) {
24+
conn.end();
25+
return reject(err);
26+
}
27+
28+
let response = "";
29+
30+
stream.on("data", (data) => {
31+
response += data.toString();
32+
// Redis PING response is "+PONG\r\n"
33+
if (!settled && response.includes("\r\n")) {
34+
settled = true;
35+
conn.end();
36+
resolve({
37+
tunneled: true,
38+
response: response.trim(),
39+
});
40+
}
41+
});
42+
43+
stream.on("error", (streamErr) => {
44+
if (!settled) {
45+
settled = true;
46+
conn.end();
47+
reject(streamErr);
48+
}
49+
});
50+
51+
// Send Redis PING command (inline format)
52+
stream.write("PING\r\n");
53+
},
54+
);
55+
});
56+
57+
conn.on("error", (err) => {
58+
if (!settled) {
59+
settled = true;
60+
reject(err);
61+
}
62+
});
63+
64+
conn.connect({
65+
host: process.env.SSH_HOST,
66+
port: Number(process.env.SSH_PORT),
67+
username: "testuser",
68+
password: "testpass",
69+
});
70+
});
71+
72+
console.log(JSON.stringify(result));
73+
}
74+
75+
main().catch((err) => {
76+
console.error(err.message);
77+
process.exit(1);
78+
});

packages/secure-exec/tests/utils/docker.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,17 @@ export interface Container {
7878
/**
7979
* Build a Docker image from a Dockerfile and tag it.
8080
*/
81+
/**
82+
* Return a container's internal IP on the Docker bridge network.
83+
*/
84+
export function getContainerInternalIp(containerId: string): string {
85+
return execFileSync(
86+
"docker",
87+
["inspect", containerId, "-f", "{{.NetworkSettings.IPAddress}}"],
88+
{ encoding: "utf-8", timeout: 5_000 },
89+
).trim();
90+
}
91+
8192
export function buildImage(dockerfilePath: string, tag: string): void {
8293
if (!isDockerAvailable()) {
8394
throw new Error("Docker is not available on this host");

progress.txt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ Started: 2026-03-17
33
PRD: ralph/kernel-hardening (46 stories)
44

55
## Codebase Patterns
6+
- Docker containers on default bridge can reach each other by internal IP (docker inspect IPAddress), not by 127.0.0.1 host-mapped ports — use getContainerInternalIp() for cross-container tunnel destinations
67
- process.exit() inside bridge callbacks (childProcessDispatch, timer refs) causes unhandled ProcessExitError — always await a Promise from the callback, then call process.exit() at the top-level await
78
- Bridge process.stdout.write strips trailing newlines — NDJSON capture helpers must join with '\n' to restore event delimiters
89
- Native binary sandbox test pattern: createTestNodeRuntime + createHostCommandExecutor, sandbox code calls require('child_process').spawn(), env vars serialize through bridge JSON to host executor
@@ -2658,3 +2659,40 @@ PRD: ralph/kernel-hardening (46 stories)
26582659
- Claude Code's SDK (sdk.mjs) is not a standalone runner; it always spawns cli.js as a subprocess with native .node dependencies
26592660
- OpenCode is a compiled Bun ELF binary — no JS source to extract
26602661
---
2662+
2663+
## 2026-03-19 - US-016
2664+
- Added SSH key-based authentication e2e-docker fixture
2665+
- Generated test RSA keypair (test_rsa/test_rsa.pub) embedded in fixture and Dockerfile
2666+
- Updated sshd.Dockerfile: enabled PubkeyAuthentication, COPY test_rsa.pub to authorized_keys
2667+
- New fixture ssh2-key-auth connects with privateKey instead of password, runs conn.exec(), verifies parity
2668+
- Files changed:
2669+
- packages/secure-exec/tests/e2e-docker/dockerfiles/sshd.Dockerfile (added pubkey auth + COPY)
2670+
- packages/secure-exec/tests/e2e-docker/dockerfiles/test_rsa.pub (new)
2671+
- packages/secure-exec/tests/e2e-docker/ssh2-key-auth/fixture.json (new)
2672+
- packages/secure-exec/tests/e2e-docker/ssh2-key-auth/package.json (new)
2673+
- packages/secure-exec/tests/e2e-docker/ssh2-key-auth/src/index.js (new)
2674+
- packages/secure-exec/tests/e2e-docker/ssh2-key-auth/keys/test_rsa (new)
2675+
- packages/secure-exec/tests/e2e-docker/ssh2-key-auth/keys/test_rsa.pub (new)
2676+
- **Learnings for future iterations:**
2677+
- buildImage() uses path.dirname(dockerfilePath) as build context — public keys for COPY must be in the dockerfiles/ directory
2678+
- RSA key-based auth works through the sandbox crypto bridge without any additional fixes — sign()/createSign() already handle RSA key parsing
2679+
- Fixture pattern for key auth: read privateKey via fs.readFileSync, pass as Buffer to conn.connect({ privateKey })
2680+
---
2681+
2682+
## 2026-03-19 - US-017
2683+
- Added SSH port forwarding / tunneling e2e-docker fixture
2684+
- Fixture connects via SSH, uses conn.forwardOut() to tunnel to Redis container, sends raw PING, verifies +PONG response
2685+
- Test infra discovers container internal IPs on Docker bridge via `docker inspect` for cross-container tunnel destinations
2686+
- Files changed:
2687+
- packages/secure-exec/tests/e2e-docker/ssh2-tunnel/fixture.json (new)
2688+
- packages/secure-exec/tests/e2e-docker/ssh2-tunnel/package.json (new)
2689+
- packages/secure-exec/tests/e2e-docker/ssh2-tunnel/src/index.js (new)
2690+
- packages/secure-exec/tests/utils/docker.ts (added getContainerInternalIp helper)
2691+
- packages/secure-exec/tests/e2e-docker.test.ts (added internalAddresses, REDIS_INTERNAL_HOST/PORT env injection)
2692+
- **Learnings for future iterations:**
2693+
- SSH forwardOut() works through the sandbox net bridge without any additional fixes — it uses the same TCP stream as the SSH connection (direct-tcpip channel over existing SSH protocol)
2694+
- Docker containers on the default bridge network can reach each other by internal IP (from `docker inspect`), not by 127.0.0.1 host-mapped ports
2695+
- getContainerInternalIp() in docker.ts resolves a container's bridge IP via `docker inspect -f '{{.NetworkSettings.IPAddress}}'`
2696+
- Raw Redis PING protocol: send "PING\r\n" (inline format), response is "+PONG\r\n"
2697+
- For tunnel fixtures, use REDIS_INTERNAL_HOST/PORT (container bridge IP + internal port), not REDIS_HOST/PORT (host-mapped)
2698+
---

scripts/ralph/prd.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,8 +307,8 @@
307307
"Tests pass"
308308
],
309309
"priority": 17,
310-
"passes": false,
311-
"notes": "SSH tunneling (forwardOut/forwardIn) is commonly used for database access through bastion hosts. This exercises nested TCP streams through the sandbox's net bridge — a very different path than direct connections."
310+
"passes": true,
311+
"notes": "Completed. Fixture tunnels through SSH to Redis using forwardOut(). Test infra discovers container internal IPs on the Docker bridge via docker inspect. Sends raw Redis PING through the tunnel, verifies +PONG response. Host and sandbox produce identical output."
312312
},
313313
{
314314
"id": "US-018",

0 commit comments

Comments
 (0)