S1 — ssh transport: sshd -i over kubectl exec as ProxyCommand#
Verdict: PASS Date: 2026-08-15 (cluster: k3s v1.34, mixed arm64/amd64)
What was tested#
The headline Podbench connection path — a full OpenSSH transport whose only carrier is
kubectl exec, with no port-forward, no Service, and no pod IP reachability:
Host podbench-target
ProxyCommand kubectl -n podbench-s1 exec -i podbench-target -c podbench -- /usr/sbin/sshd -i -e
User root
Everything ran in namespace podbench-s1 against one pod, podbench-target
(debian:bookworm-slim, sleep infinity, nodeSelector: kubernetes.io/arch=amd64,
scheduled to nuc2), with an ephemeral debug container podbench attached via
kubectl debug --target=app. openssh-server 1:9.2p1-2+deb12u10 was apt-get-installed
into the running ephemeral container. Local client: OpenSSH 9.6p1 in the devcontainer.
Tested, in order:
Remote command execution, exit-code propagation.
Interactive PTY session (
ssh -tt), full login banner +/dev/pts/0.Binary-clean transfer:
scp,sftp,ssh 'cat > f'(stdin),ssh 'cat f'(stdout) — 3 MiB random.Concurrency: 3-way and 8-way simultaneous sessions, each over its own
kubectl exec.stdio framing matrix — 8 ProxyCommand variants isolating why
-eis required, plus a sshd-free minimal experiment that identifies the actual mechanism (see Findings §2).Latency (8 cold connections) and throughput (64 MiB each way), plus
ControlMasterreuse.Robustness: 90 s and 600 s idle; hard transport kill; stalled transport with and without
ServerAliveInterval.VS Code Remote-SSH requirements: >1 MiB
bash -cstdout streaming (8 MiB + a 14.9 MiB byte-exact hash check), plus a real end-to-end bootstrap — downloaded and ranvscode-server1.133.0 inside the pod through the ssh transport, reached its HTTP endpoint and completed a WebSocket 101 upgrade overssh -L.ssh -L,ssh -R,ssh -D(SOCKS) and agent forwarding (-A) over the transport.Minimal working
sshd_config;/run/sshd,UsePAM,PidFile,LogLevelbehaviour.sshd -iunder a non-root uid (uid 1000 viasetpriv).
Exact commands that worked#
Namespace, target pod, ephemeral debug container#
kubectl create ns podbench-s1
target.yaml:
apiVersion: v1
kind: Pod
metadata:
name: podbench-target
namespace: podbench-s1
labels: {app: podbench-target}
spec:
nodeSelector:
kubernetes.io/arch: amd64
containers:
- name: app
image: debian:bookworm-slim
imagePullPolicy: IfNotPresent
command: ["/bin/sh","-c","sleep infinity"]
resources:
requests: {cpu: 50m, memory: 64Mi}
limits: {cpu: "2", memory: 1Gi}
kubectl apply -f target.yaml
timeout 180 kubectl -n podbench-s1 wait --for=condition=Ready pod/podbench-target --timeout=170s
# --custom takes a FILE PATH, not inline JSON (see Deviations)
cat > custom-profile.json <<'EOF'
{"securityContext":{"capabilities":{"add":["SYS_PTRACE"]}}}
EOF
timeout 240 kubectl -n podbench-s1 debug podbench-target \
--image=debian:bookworm-slim \
--image-pull-policy=IfNotPresent \
--container=podbench \
--target=app \
--profile=general \
--custom=custom-profile.json \
-- sleep infinity
Provision sshd inside the ephemeral container#
ssh-keygen -t ed25519 -N "" -f ./s1_id_ed25519 -C podbench-s1
timeout 300 kubectl -n podbench-s1 exec podbench-target -c podbench -- sh -c '
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq && apt-get install -y -qq openssh-server'
PUB=$(cat s1_id_ed25519.pub)
timeout 120 kubectl -n podbench-s1 exec podbench-target -c podbench -- sh -c "
ssh-keygen -A
mkdir -p /root/.ssh /run/sshd # /run/sshd is MANDATORY (privsep dir)
chmod 700 /root/.ssh; chmod 0755 /run/sshd
printf '%s\n' '$PUB' > /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys"
Minimal working sshd_config#
kubectl -n podbench-s1 exec -i podbench-target -c podbench -- \
sh -c 'cat > /etc/ssh/sshd_config.minimal' <<'EOF'
HostKey /etc/ssh/ssh_host_ed25519_key
PermitRootLogin prohibit-password
AuthorizedKeysFile /root/.ssh/authorized_keys
UsePAM no
Subsystem sftp /usr/lib/openssh/sftp-server
EOF
kubectl -n podbench-s1 exec podbench-target -c podbench -- \
/usr/sbin/sshd -t -f /etc/ssh/sshd_config.minimal # -> CONFIG_OK
Five lines. No PidFile directive is needed (-i mode never writes one — verified:
/run/sshd.pid absent after many sessions). UsePAM no works and removes the PAM
dependency entirely. Debian’s stock /etc/ssh/sshd_config also works unmodified.
The ssh client config that works#
Host podbench-target
HostName podbench-target
User root
IdentityFile /abs/path/s1_id_ed25519
IdentitiesOnly yes
StrictHostKeyChecking no
UserKnownHostsFile /abs/path/s1_known_hosts
ProxyCommand kubectl -n podbench-s1 exec -i podbench-target -c podbench -- /usr/sbin/sshd -i -e -f /etc/ssh/sshd_config.minimal -o LogLevel=ERROR
(a) remote command, (b) PTY, exit codes#
$ ssh -F s1_cfg_min podbench-target hostname
podbench-target
$ ssh -F s1_cfg_min podbench-target hostname 2>/dev/null | od -c # stdout is clean
0000000 p o d b e n c h - t a r g e t \n
$ printf 'tty\nexit\n' | ssh -tt -F s1_cfg_min podbench-target
... full Debian login banner ...
root@podbench-target:~# tty
/dev/pts/0
root@podbench-target:~# exit
logout
$ ssh -F s1_cfg_e podbench-target 'exit 42'; echo $?
42
(c) binary-clean transfer — all four paths, identical sha256#
head -c 3145728 /dev/urandom > blob.bin # b2df03aa...c87df
scp -F s1_cfg_e blob.bin podbench-target:/tmp/blob.bin
ssh -F s1_cfg_e podbench-target 'sha256sum /tmp/blob.bin' # b2df03aa...c87df
sftp -F s1_cfg_e -b - podbench-target <<'EOF'
get /tmp/blob.bin blob.down.bin
EOF
sha256sum blob.down.bin # b2df03aa...c87df
ssh -F s1_cfg_e podbench-target 'cat > /tmp/blob2.bin' < blob.bin
ssh -F s1_cfg_e podbench-target 'sha256sum /tmp/blob2.bin' # b2df03aa...c87df
ssh -F s1_cfg_e podbench-target 'cat /tmp/blob.bin' > blob3.bin
sha256sum blob3.bin # b2df03aa...c87df
(d) concurrency — 8 simultaneous sessions#
for i in $(seq 1 8); do
( ssh -F s1_cfg_min podbench-target "echo S$i-\$\$; sleep 8; echo E$i" > conc_$i.txt ) &
done
sleep 4
kubectl -n podbench-s1 exec podbench-target -c podbench -- \
sh -c 'ps -eo args --no-headers | grep -c "[s]shd:"' # -> 9 (8 sessions + the probe's own)
wait
# all 8 files contain both their S and E lines: 8/8
Latency and throughput#
for i in $(seq 1 8); do
s=$(date +%s.%N); ssh -F s1_cfg_e podbench-target true; e=$(date +%s.%N)
echo "$e - $s" | bc
done
ControlMaster (6x speedup, one kubectl exec for many sessions)#
ControlMaster auto
ControlPath /tmp/s1cm/%C # MUST be short: sun_path limit is 108 bytes
ControlPersist 120
Robustness probes#
# 90 s and 600 s idle, keepalives explicitly disabled
ssh -F s1_cfg_e -o ServerAliveInterval=0 -o TCPKeepAlive=no podbench-target \
'echo START_$(date +%s); sleep 600; echo AFTER_IDLE600_$(date +%s)'
# hard transport kill: SIGKILL the ProxyCommand mid-session
kill -9 "$(cat /tmp/s1_proxy.pid)"
# stalled transport: SIGSTOP the ProxyCommand mid-session
kill -STOP "$(cat /tmp/s1_proxy.pid)"
ssh -o ServerAliveInterval=5 -o ServerAliveCountMax=3 ...
VS Code Remote-SSH end-to-end (real vscode-server, not a simulation)#
# 1. large stdout through a non-interactive `bash -c` — byte-exact
ssh -F s1_cfg_e podbench-target 'bash -c "seq 1 2000000"' > s1_seq.txt
sha256sum s1_seq.txt ; seq 1 2000000 | sha256sum
# d2d7c0abc3eb76d91b0b5a2702e92a9f2908269c9c1b3604bdfe2521c71d6274 (both) - 14,888,896 bytes
# 2. server bootstrap, piped script over ssh — exactly the Remote-SSH shape
cat > bootstrap.sh <<'EOS'
set -e
export DEBIAN_FRONTEND=noninteractive
command -v curl >/dev/null || apt-get install -y -qq curl ca-certificates >/dev/null 2>&1
mkdir -p /root/.vscode-server/bin && cd /root/.vscode-server
curl -fsSL -o server.tar.gz "https://update.code.visualstudio.com/latest/server-linux-x64/stable"
tar -xzf server.tar.gz
./vscode-server-linux-x64/bin/code-server --version
EOS
ssh -F s1_cfg_min podbench-target 'bash -s' < bootstrap.sh
# -> 224,027,728 byte tarball downloaded from inside the pod; version 1.133.0
# commit a5b500951314efd502d07465bd138dfbd714a960
# 3. start it, detached, on pod loopback
COMMIT=a5b500951314efd502d07465bd138dfbd714a960
ssh -F s1_cfg_min podbench-target "
mv /root/.vscode-server/vscode-server-linux-x64 /root/.vscode-server/bin/$COMMIT
nohup /root/.vscode-server/bin/$COMMIT/bin/code-server \
--accept-server-license-terms --telemetry-level off \
--host 127.0.0.1 --port 9922 --without-connection-token --start-server \
>/tmp/vscode-server.log 2>&1 &
sleep 12; tail -20 /tmp/vscode-server.log"
# -> "Server bound to 127.0.0.1:9922 (IPv4)" / "Extension host agent started."
# and it SURVIVES session close (still listening on a later connection)
# 4. reach it through the ssh tunnel — HTTP and WebSocket
ssh -F s1_cfg_min -N -L 19922:127.0.0.1:9922 podbench-target &
curl -s http://127.0.0.1:19922/version
# a5b500951314efd502d07465bd138dfbd714a960 (HTTP 200)
curl -s -i -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
'http://127.0.0.1:19922/?reconnectionToken=x&reconnection=false&skipWebSocketFrames=false'
# HTTP/1.1 101 Switching Protocols
# Upgrade: websocket
# Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
-R, -D and agent forwarding#
ssh -F s1_cfg_min -N -D 11080 podbench-target &
curl -s --socks5-hostname 127.0.0.1:11080 http://127.0.0.1:9911/ # HELLO_FROM_POD_HTTP
python3 -m http.server 18099 --bind 127.0.0.1 & # on the devcontainer
ssh -F s1_cfg_min -N -R 18100:127.0.0.1:18099 podbench-target &
ssh -F s1_cfg_min podbench-target \
'python3 -c "import urllib.request;print(urllib.request.urlopen(\"http://127.0.0.1:18100/\",timeout=8).status)"'
# 200
eval "$(ssh-agent -s)"; ssh-add s1_id_ed25519
ssh -A -F s1_cfg_min podbench-target 'echo $SSH_AUTH_SOCK; ls -l $SSH_AUTH_SOCK'
# SSH_AUTH_SOCK=/tmp/ssh-RPH4ceNbtL/agent.5365 srwxr-xr-x 1 root root 0 ...
Findings#
1. The headline path works, exactly as the brief writes it#
Every task in the brief passed. Remote commands, PTY sessions, scp/sftp, stdin/stdout
binary pipes, exit codes, 8-way concurrency, -L/-R/-D forwarding, agent forwarding,
ControlMaster multiplexing, and a real vscode-server bootstrap all work over a transport
whose entire carrier is kubectl exec. stdout is byte-exact in both directions
(sha256 verified on 3 MiB random and 14.9 MiB deterministic payloads).
2. -e IS required — and the reason is NOT what the brief implies#
The brief treats -e as “log to stderr so stderr noise does not pollute the SSH stream”.
That framing is wrong in an important way. The measured behaviour:
ProxyCommand |
result |
|---|---|
|
works |
|
works |
|
FAILS, reproducibly |
|
works |
|
FAILS |
|
FAILS |
|
works |
Failure text in every failing case, identical:
command terminated with exit code 255
ssh_dispatch_run_fatal: Connection to UNKNOWN port 65535: Broken pipe
ssh -vv shows the handshake dying at debug1: expecting SSH2_MSG_KEX_ECDH_REPLY — i.e. the
server side vanishes immediately after KEXINIT, before any auth.
Root cause, isolated with a sshd-free experiment. The CRI exec stream is torn down when the exec’d process closes or replaces its stderr:
# A: control — stderr left alone
( echo one; sleep 4; echo two ) | kubectl -n podbench-s1 exec -i podbench-target -c podbench -- sh -c 'exec cat'
one
two # both arrive
# B: stderr replaced with /dev/null (exactly what sshd does without -e/-E)
( echo one; sleep 4; echo two ) | kubectl -n podbench-s1 exec -i podbench-target -c podbench -- sh -c 'exec 2>/dev/null; exec cat'
one # "two" is SILENTLY LOST, rc=0
# C: stderr closed outright
( echo one; sleep 4; echo two ) | kubectl -n podbench-s1 exec -i podbench-target -c podbench -- sh -c 'exec 2>&-; exec cat'
one # same silent truncation, rc=0
Measured: closing/replacing fd 2 in an exec’d process silently kills the exec stream on
this cluster (experiments A/B/C above, plus the full matrix).
Inferred (consistent with every observation, from OpenSSH’s inetd path): sshd’s
server_accept_inetd() calls stdfd_devnull(1, 1, !log_stderr), so without -e/-E it
points fd 2 at /dev/null; the CRI stderr pipe reaches EOF and containerd tears the whole
exec session down, killing stdin/stdout mid-KEX. -E <file> works because sshd’s option
parser falls -E through to -e, also setting log_stderr = 1, so fd 2 survives.
sh -c 'sshd -i' (no exec) works because the parent shell keeps fd 2 open on sshd’s behalf.
Two consequences the brief does not anticipate:
-eis load-bearing for fd-2 ownership, not for log routing. Any future change that “cleans up” the logging (e.g.>/dev/null 2>&1in a wrapper, or a wrapper that closes stderr) will break the transport in a way that looks like a network fault.Case B/C fail silently with rc=0 and no diagnostic. This exact mode — an exec whose stderr gets closed — will silently truncate any
kubectl execstream, not just ssh.
3. What actually pollutes the stream, and what does not#
Merging sshd’s stderr into stdout is fatal (
sh -c 'exec sshd -i -e 2>&1') — log lines land mid-protocol and the client dies with the sameBroken pipe.Pre-banner stdout noise is tolerated, per RFC 4253 §4.2. Measured exactly:
lines printed before sshd starts
result
1 / 10 / 1023
works
1024
banner exchange: Connection to UNKNOWN port 65535: invalid format2000
same
So a debug image whose entrypoint prints a MOTD is fine up to 1023 lines. Anything a wrapper writes to stdout after sshd starts is fatal.
kubectl exec -it(with-t) did not break here — but only by luck. In a non-tty context kubectl printedUnable to use a TTY - input is not a terminal or the right kind of fileon stderr and fell back to non-tty, so the session worked. When a real TTY was forced onto the ProxyCommand (script -q -c "kubectl exec -it ..." /dev/null), the ssh client hung indefinitely and had to be killed at the 2-minute mark.-tmust never appear in the ProxyCommand.With
-e, sshd’s own log lines appear on the ssh client’s stderr (Accepted publickey for root from UNKNOWN port 65535 ...). This is harmless for a human but is noise a Remote-SSH client parses.-e -o LogLevel=ERROR(orQUIET/FATAL) gives zero stderr bytes and still keeps fd 2 open — verified.
4. Latency and throughput are good enough for interactive work#
measurement |
value |
|---|---|
|
0.320 – 0.377 s (median ≈ 0.345 s) |
bare |
0.151 – 0.157 s |
ssh overhead over raw exec |
≈ 0.19 s |
|
0.058 – 0.062 s |
64 MiB pod → client |
2.50 s (≈ 26 MB/s) |
64 MiB client → pod |
4.94 s (≈ 13 MB/s) |
RSS per live session ( |
≈ 10–11 MB |
30 sequential cold connections (churn) |
0/30 failures, mean 0.323 s |
No process leaks: after ~60 sessions (churn + concurrency + forwarding tests) the devcontainer
had zero orphaned kubectl exec processes beyond the ones still intentionally running, and the
pod had exactly one sshd: process (the still-open idle test). Sessions tear down cleanly on
both sides.
ControlMaster is a ~6x win on connection setup and collapses N sessions onto one
kubectl exec, which also removes N-fold apiserver exec load.
5. Robustness#
Idle: a session with
ServerAliveInterval=0andTCPKeepAlive=nosurvived 90 s and 600 s of complete silence and then delivered its trailing output. There is no short idle timeout on the k3s exec path. Observed:START_1786791739 AFTER_IDLE600_1786792339 # exactly 600 s later, rc=0, no reconnect
Hard transport loss (SIGKILL the ProxyCommand): detected instantly (0 s),
client_loop: send disconnect: Broken pipe,rc=255. Clean failure, no hang.Stalled transport (SIGSTOP the ProxyCommand — the pipe stays open but nothing flows, which is what an apiserver/konnectivity hiccup looks like):
without keepalives: ssh hangs forever (killed by an external 100 s timeout,
rc=124).with
ServerAliveInterval=5 ServerAliveCountMax=3: detected in 19 s,Timeout, server podbench-target not responding.,rc=255.
So
ServerAliveIntervalis not optional — it is the only thing that distinguishes a wedged transport from a long-running quiet command.Pod deleted mid-session: detected instantly (0 s) and cleanly —
command terminated with exit code 137from kubectl, thenclient_loop: send disconnect: Broken pipe,rc=255. No hang, no zombie exec.Sessions are fully independent: each one is its own
kubectl execand its ownsshdprocess, so there is no sharedMaxStartups/MaxSessionsceiling and one dead session cannot take out another. (WithControlMasterthis is traded away deliberately — one transport death takes all multiplexed channels.)
6. VS Code Remote-SSH needs more than plain ssh, and it all works#
Beyond a working ssh, Remote-SSH needs (a) a large non-interactive bash -c stdout stream
for the bootstrap script, (b) working local port forwarding to reach the server it starts,
and (c) ControlMaster reuse. All three verified:
8 MiB base64 and a 14.9 MiB
seq 1 2000000both streamed byte-exact throughssh 'bash -c "..."'.The real
vscode-server1.133.0 (224 MB tarball, downloaded by the pod itself over cluster egress) started on pod loopback, survived session close (nohup+&detaches cleanly from the dying exec session), servedGET /version→ 200 throughssh -L, and completed a WebSocket101 Switching Protocolshandshake through the same tunnel. That is the exact channel the VS Code UI uses.ssh -Randssh -Dalso work, so VS Code’s “forward a port from the remote” feature and any SOCKS-based tooling are available too.
7. sshd -i prerequisites in a scratch container#
/run/sshdmust exist. Without it every connection dies withMissing privilege separation directory: /run/sshd(alsosshd -tfails). This is the single most likely image-build omission —apt-get install openssh-servercreates it, but amkdir /runtmpfs remount or a distroless/scratch layout will not.Host keys must exist (
ssh-keygen -A), otherwise there is nothing to offer at KEX.No PidFile is written in
-imode —PidFile noneis unnecessary.UsePAM noworks, so the image needs no PAM stack. Debian’s defaultUsePAM yesalso works when the package’s PAM files are present.PermitRootLogin prohibit-password(Debian’s default) is sufficient for key auth as root.RBAC needed:
create pods/execplusget podsin the namespace. Nothing else — no Services, no NetworkPolicy holes, no pod IP reachability.
8. sshd -i works as a NON-ROOT uid, and then needs no privsep dir at all#
Tested by running sshd under uid 1000 via setpriv (approximating a debug container with
runAsUser: 1000, i.e. a PSA-restricted namespace):
ProxyCommand kubectl -n podbench-s1 exec -i podbench-target -c podbench -- \
setpriv --reuid=1000 --regid=1000 --clear-groups \
/usr/sbin/sshd -i -e -f /home/dev/etc/sshd_config
with a user-owned config:
HostKey /home/dev/etc/ssh_host_ed25519_key
AuthorizedKeysFile /home/dev/.ssh/authorized_keys
UsePAM no
StrictModes no
PidFile none
Subsystem sftp /usr/lib/openssh/sftp-server
Result: remote command (uid=1000(dev) gid=1000(dev), pwd=/home/dev), scp (rc=0) and a
PTY session (/dev/pts/0) all worked — with /run/sshd deleted. sshd skips privilege
separation when it is not root, so the privsep directory is a root-only requirement.
Re-verified in the same breath: with /run/sshd still absent, the root ProxyCommand
failed immediately (Connection closed by UNKNOWN port 65535).
This matters a lot for the product: Podbench does not need a root debug container to provide
the ssh transport. Root is only needed for the debugging capabilities (ptrace, /proc/<pid>/root),
not for the connection path.
Deviations from the brief#
kubectl debug --customtakes a file path, not inline JSON. The brief’s phrasing (”--customprofile JSON{...}”) reads as inline. It is not:error: must pass a container spec json file for custom profile: open {"securityContext":{"capabilities":{"add":["SYS_PTRACE"]}}}: no such file or directory
Podbench’s CLI must write the profile to a temp file (or use
--profile=+ a manifest patch).-eis not about log tidiness — it is about keeping fd 2 open so the CRI exec stream stays alive. See Findings §2.-E <file>is equally valid (it implieslog_stderr=1), and-e -o LogLevel=ERRORgives both a live fd 2 and a silent stderr. Any implementation that wraps sshd and redirects/closes stderr will break the transport with a misleading “Broken pipe”. This is the single most valuable thing this spike found.A wrapper shell that does not
execmasks the bug.sh -c '/usr/sbin/sshd -i'works even without-e, purely because the parent shell holds fd 2. If Podbench ships a wrapper script, this will hide the fd-2 requirement in testing and then surface it the day someone addsexecfor tidiness. Pin the invocation and add a regression test.The brief’s ProxyCommand omits
-n <namespace>and-f <sshd_config>. In practice the generated config needs at least the namespace; and pointing at a dedicatedsshd_config(rather than mutating the distro one) keeps the image’s normal sshd usable.ControlPathlength matters.ControlPathunder a long scratch path failed withControlPath too long ('...' >= 108 bytes)— the AF_UNIXsun_pathlimit. Podbench must put control sockets somewhere short (/tmp/<short>/%C), not next to the kubeconfig or in a workspace directory.kubectl exec -tis a latent footgun, not an obvious error. It “works” from a script (kubectl silently degrades to non-tty) and hangs forever from a terminal. Users hand-editing the generated ssh config will hit this. Podbench should refuse-t.The brief assumes plain ssh is the bar; VS Code additionally needs port forwarding.
-Lis mandatory (that is how the UI reaches the server) and it works — which is what makes the “no port-forward, no pod IP” claim hold: the ssh connection carries the VS Code traffic, sokubectl port-forwardis genuinely unnecessary.Everything here lives in an ephemeral container, which cannot be restarted or removed. The apt-installed sshd, host keys and
authorized_keysare lost if the pod restarts, and the ephemeral container spec is immutable. Fine for a spike; for the product this argues for a purpose-built image rather than runtimeapt-get, and for accepting that a pod restart means re-attaching a new debug container (with new host keys — see below).Host key identity is unsolved.
ssh-keygen -Agenerates fresh keys per attach, and theHostalias is arbitrary, soknown_hostseither warns on every new pod or must be bypassed. This spike usedStrictHostKeyChecking no+ a throwawayUserKnownHostsFile. A real design needs either a per-podHostKeyAliaskeyed on pod UID, or a host key delivered from a Secret so it is stable across re-attaches. Not covered by the brief.
Recommendations for implementation#
Ship this ProxyCommand shape:
ProxyCommand kubectl -n <ns> exec -i <pod> -c podbench -- \ /usr/sbin/sshd -i -e -f /etc/podbench/sshd_config -o LogLevel=ERROR
-iand-eare both mandatory.-o LogLevel=ERRORkeeps the client’s stderr clean for Remote-SSH while preserving the fd-2 lifeline. Never-t. Never redirect sshd’s stderr. Add a unit/e2e test that asserts the transport dies without-e, so the reason is documented in code.Bake into the image, do not
apt-getat runtime:openssh-server,/run/sshd(created at container start, since/runis often tmpfs), a 5-line/etc/podbench/sshd_config, and the sftp subsystem binary (needed forscp/sftp, which VS Code also uses for file upload).Default the generated ssh config to:
ServerAliveInterval 15 ServerAliveCountMax 3 ControlMaster auto ControlPath /tmp/podbench-cm/%C ControlPersist 10m IdentitiesOnly yes
ServerAliveIntervalconverts an invisible hang into a 45 s failure;ControlMastercuts per-command latency from 345 ms to 58 ms and collapses apiserver exec load. Create/tmp/podbench-cm(mode 0700) at config-generation time and keep the path short.Deliver the host key from a Secret, mounted into the debug container, so the pod’s identity is stable across re-attaches; then
StrictHostKeyChecking yesbecomes usable and the tool stops teaching users to disable host verification. Failing that, setHostKeyAliasto the pod UID and manageknown_hostsentries programmatically.Do not expose a Service or port-forward for the VS Code server.
ssh -Lover this transport carries both HTTP and WebSocket correctly, which is the whole security argument of the design; it is empirically sound.Treat “no stderr” as the tripwire. Because a closed CRI stderr silently truncates the stream with
rc=0, add a startup self-check: runkubectl exec -i ... -- sh -c 'exec cat'with a delayed second line and confirm both lines return before declaring the pod ready.Do not require a root debug container for the transport. Findings §8 shows
sshd -iworks fine as uid 1000 with a user-owned host key and config, and then does not need/run/sshdat all. Make the sshd config path and host-key location user-relative ($HOME/.podbench/) so the same image serves both root and PSA-restricteddeployments; gate only the ptrace//procfeatures on root.Follow-up spikes worth running: (a) an actual PSA-
restrictednamespace end-to-end (this namespace carried no PSA labels, sorunAsNonRoot/seccompProfileadmission was never exercised — only the non-root runtime behaviour was); (b) behaviour when the pod is deleted or evicted mid-session and how Remote-SSH’s reconnect handles it; (c) whetherkubectl execthrough konnectivity/an API gateway (rather than this flat k3s path) changes the stall/idle numbers in §5.
What was left behind#
Namespace
podbench-s1— left in place, as instructed. Verified empty:kubectl -n podbench-s1 get all→No resources found. It has no PSA labels.Pod
podbench-target(and its ephemeralpodbenchcontainer) — deleted. It was the only object ever created. Nothing else in the cluster was created, modified or deleted; no other namespace, no node setting, no ArgoCD-managed resource was touched. Only read-onlykubectl get/auth can-i/topandkubectl execinto my own pod were used elsewhere.No leftover local
kubectl execprocesses;/tmp/s1cm(ControlMaster sockets) removed.Local scratch artifacts under
/tmp/claude-0/.../scratchpad/spikes/prefixeds1_(keypairs1_id_ed25519{,.pub}, ssh configss1_cfg_*, logs). Note: this scratchpad is shared with other concurrent spikes and a non-prefixedssh_configwritten early on was clobbered by another agent mid-run — all S1 files were renamed with ans1_prefix afterwards.