feat: add an SSH port-forward shadow for air-gapped HPC - #550
Draft
matbun wants to merge 7 commits into
Draft
Conversation
✅ Deploy Preview for interlink-dev canceled.
|
This was referenced Jul 29, 2026
Open
matbun
force-pushed
the
feat/ssh-shadow-tunnel
branch
from
July 31, 2026 12:59
079e4cd to
6e72125
Compare
This was referenced Jul 31, 2026
Open
matbun
force-pushed
the
feat/ssh-shadow-tunnel
branch
2 times, most recently
from
July 31, 2026 13:17
20cde27 to
0b433d7
Compare
matbun
force-pushed
the
feat/ssh-shadow-tunnel
branch
2 times, most recently
from
July 31, 2026 14:38
3fc00e1 to
bf7d027
Compare
The per-pod Deployment/Service the VK creates for offloaded pods with exposed ports is a shadow pod, not a wstunnel: wstunnel is only one of the things that can run inside it, and an SSH port-forward shadow runs no wstunnel at all. Rendered resource names change accordingly: Deployment/Service wstunnel-<pod>-<ns> -> shadow-<pod>-<ns> Namespace <ns>-wstunnel -> <ns>-shadow Go identifiers follow (WstunnelTemplateData -> ShadowTemplateData, createDummyPod -> createShadowPod, ...), and the naming/identity helpers move out of mesh.go into shadow.go, where they sit next to the code that uses them rather than next to WireGuard key handling. Pod annotations (interlink.eu/wstunnel-extra-ports, interlink.virtual-kubelet.io/wstunnel-timeout, ...) and the Wstunnel* config keys are deliberately untouched: renaming those breaks existing manifests and deployments for no user-visible gain. Names that genuinely refer to wstunnel - the client command, the embedded wstunnel templates, the download URLs - keep their names. Upgrade note: shadows created before this change keep their old names until their pods are recreated. Anything hard-coding a shadow Service name (Ingress, VirtualService, admission policies) needs updating. Signed-off-by: Matteo Bunino <48362942+matbun@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A shadow tunnel needs to know which remote node the workload landed on,
but the shadow is rendered at CreatePod time - before the job has been
submitted, let alone scheduled. Nothing in the pod spec can carry that
value.
Plugins now report it through the status API:
PodStatus.NodeName // omitempty; empty while the job is queued
and the VK materialises it into a per-shadow ConfigMap, <shadow>-node,
created empty alongside the shadow and patched once the plugin reports a
node. A shadow mounts that ConfigMap and waits for the key to fill in.
A ConfigMap rather than an env var because kubelet refreshes mounted
ConfigMaps in place: changing the pod spec would restart the shadow and
change its pod IP, which the VK has already reported to Kubernetes as the
offloaded pod's IP.
The value is blanked when a shadow is created, so a pod recreated under a
new UID cannot inherit a node whose job is gone, and repeated reports of
an unchanged node are dropped so the status loop does not write on every
poll.
Plugins that do not set NodeName are unaffected: the field is omitempty,
so the wire format is unchanged and shadows that do not consume the
ConfigMap never look at it.
Signed-off-by: Matteo Bunino <48362942+matbun@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The wstunnel shadow assumes two things air-gapped HPC sites do not have:
outbound internet access from the compute nodes, and a publicly reachable
ingress on the Kubernetes cluster. The workload dials out; nothing works
if it cannot.
This inverts the direction. The shadow dials in to the site's SSH login
node and runs `ssh -N -L <port>:<compute node>:<port>`, one -L per
exposed port, so cluster traffic reaches services inside an offloaded
pod - notebooks, dashboards, web UIs - with the login node as the only
required route. The offloaded pod runs nothing on its side: no wstunnel
client, no WireGuard config, no pre-exec injection at all.
Network:
EnableTunnel: true
ShadowMode: ssh
SSH:
LoginHost: login.hpc.example.org
User: alice
KeySecret: hpc-ssh-key # or Kerberos: KeytabSecret + Principal
Selected by Network.ShadowMode, which defaults to "wstunnel", so existing
deployments are untouched. Both authentication methods sites actually use
are supported: an SSH private key, or a Kerberos keytab with kinit run in
an init container and refreshed by a sidecar for the life of the tunnel.
The shadow learns its target from the per-pod node ConfigMap: an init
container waits on it, so a pod whose job is still queued shows Init
rather than a crash-looping tunnel, and the wait is bounded by
SSH.NodeWaitTimeout (default 2h) with an error naming the actual cause.
Credentials are replicated into the shadow's namespace by default, since
shadows follow the offloaded pod into arbitrary per-user namespaces and
would otherwise have to be seeded by hand everywhere. Set
SSH.ReplicateCredentials: false to manage them yourself.
ShadowMode: ssh with FullMesh is rejected at startup: the mesh needs the
compute node to dial the cluster, which is exactly what these sites
cannot do. Mesh over SSH is tracked in interlink-hq#548.
UDP ports are skipped with a warning, since ssh -L is TCP only.
The shadow runs docker/Dockerfile.ssh-tunnel, a stock ssh + krb5 client
image published alongside the other release images. It contains no
interLink code; SSH.Image overrides it.
Signed-off-by: Matteo Bunino <48362942+matbun@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A requeued Slurm job comes back on a different host and the plugin reports the new one, which the virtual kubelet republishes into the shadow's node ConfigMap. The kubelet refreshes the mount, but ssh already had the old hostname baked into its -L arguments, so the tunnel kept pointing at the node the job had left. Supervise ssh instead of exec'ing it: poll the mounted file while it runs and restart it against the new node when the name changes. An ssh exit with the node unchanged still ends the container, so a dead tunnel is restarted by Kubernetes as before. Also name the login-node requirement in the startup line. A site with "AllowTcpForwarding no" only fails once traffic arrives, with a bare "channel N: open failed: administratively prohibited" and nothing pointing at the cause; now the requirement sits directly above it in the same log. Signed-off-by: Matteo Bunino <48362942+matbun@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rding ssh -L needs AllowTcpForwarding on the login node, and a good number of HPC sites do not grant it: SURF's ETP testbed sets "AllowTcpForwarding no" globally and re-enables it only for the MFA group. The shadow there authenticates, starts, reports Ready, and then resets every connection with "administratively prohibited". Nothing about that is fixable from the cluster. Network.SSH.ForwardMode: exec drops the forwarding channel entirely. Each exposed port gets a local socat listener in the shadow, and every accepted connection is relayed by a command run on the login node (Network.SSH.ExecConnectCommand, "nc <compute node> <port>" by default). All it needs is the ability to run a command over SSH, which is the same privilege the shadow already uses to log in. Both modes are interchangeable above the socket: the Service still fronts the same containerPort, and the offloaded workload still just binds 0.0.0.0. Only the transport differs. The relays share one multiplexed SSH connection, established as an explicit control master before the listeners open. Without it each connection would pay a full handshake — ruinous for something like a notebook UI, which opens dozens — and the login node would see a session per connection instead of one. portforward stays the default; it is cheaper and has no dependency on the site having a netcat. Signed-off-by: Matteo Bunino <48362942+matbun@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The node a plugin reports goes straight into a shell command, twice: into the
`-L` argument in portforward mode, and into the relay command that exec mode
sends to the login node. Neither was quoted, and neither was checked.
A name containing whitespace splits into an extra ssh argument, and ssh takes
options anywhere:
-L 0.0.0.0:8888:h:1 -oProxyCommand=touch:8888 user@login
ProxyCommand runs a command in the shadow container. In exec mode a name
containing a quote or `$(` breaks out of the relay command instead, which the
login node's shell then re-parses.
For the Slurm plugin this value is `hostname -f` written to a file in the pod's
job directory on a shared filesystem, so it is only as trustworthy as the
permissions on that directory, and a plugin that computes it some other way could
be wrong in more interesting ways.
Refuse anything that is not an RFC 1123 hostname or an IP literal, before it
reaches the ConfigMap the shadow mounts, and log why. Escaping was the other
option, but there are two shells and two modes to get right and no legitimate
node name needs it.
Quote the node in both modes as well, so a future path that skips the check
cannot reintroduce the same bug quietly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Matteo Bunino <48362942+matbun@users.noreply.github.com>
Credential replication created-or-updated unconditionally. With interlink.eu/shadow-same-ns the shadow lands in the offloaded pod's own namespace, which on a multi-tenant cluster is somebody's personal namespace, so a Secret that merely shares the configured name was replaced with the HPC key and its contents lost. Nothing said so. Mark replicated objects with interlink.eu/replicated-from and only overwrite ones carrying it. A pre-existing object without it fails pod creation with an error naming the collision and the two ways out, which is recoverable; silently destroying a user's Secret is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Matteo Bunino <48362942+matbun@users.noreply.github.com>
matbun
force-pushed
the
feat/ssh-shadow-tunnel
branch
from
July 31, 2026 15:03
bf7d027 to
04020c1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #545. Implements the approach discussed in #535.
Problem
The wstunnel shadow assumes the offloaded pod can dial out to a public ingress. Air-gapped HPC sites have neither: compute nodes have no outbound internet access, and the cluster has no publicly reachable ingress. Today the only way to expose a notebook or dashboard running on such a site is a hand-written ~200-line
customTemplateper deployment, which is what CERN and BSC both ended up with.What this does
Adds an SSH port-forward shadow that inverts the direction: instead of the compute node dialing out, the shadow pod dials in to the site's login node and forwards each exposed port to the compute node the job landed on.
The only requirement is outbound SSH from the cluster to the login node. The offloaded pod runs nothing on its side: no wstunnel client, no WireGuard config, no pre-exec injection.
ShadowModedefaults towstunnel, so existing deployments are unaffected.Commits
1.
refactor: rename wstunnel shadow-pod naming to shadowThe per-pod Deployment/Service is a shadow pod; wstunnel is only one thing that can run inside it, and the SSH shadow runs none. Rendered names change:
wstunnel-<pod>-<ns>shadow-<pod>-<ns><ns>-wstunnel<ns>-shadowGo identifiers follow (
WstunnelTemplateData->ShadowTemplateData,createDummyPod->createShadowPod, ...), and the naming/identity helpers move frommesh.gointo a newshadow.go.Pod annotations (
interlink.eu/wstunnel-extra-ports,interlink.virtual-kubelet.io/wstunnel-timeout) and theWstunnel*config keys are deliberately not renamed — that would break existing manifests for no user-visible gain. Names that genuinely refer to wstunnel keep them.2.
feat: propagate the remote compute node to shadow podsThe shadow is rendered at CreatePod time, before the job is submitted, so nothing in the pod spec can carry the target node. Plugins now report it via a new optional field:
The VK creates a per-shadow ConfigMap
<shadow>-node(empty) alongside the shadow and patches the value in once a plugin reports it. A ConfigMap rather than an env var because kubelet refreshes mounted ConfigMaps in place — changing the pod spec would restart the shadow and change its pod IP, which has already been reported to Kubernetes as the offloaded pod's IP.The value is blanked on shadow creation so a recreated pod can't inherit a dead node, and unchanged values are dropped so the status loop doesn't write on every poll.
omitempty+ plugins that never set it = unchanged wire format.3.
feat: add an SSH port-forward shadow for air-gapped HPCNew embedded template
templates/shadow-ssh-template.yamlplus aNetwork.SSHconfig block. Both auth methods sites actually use are supported: an SSH private key, or a Kerberos keytab withkinitin an init container and a sidecar refreshing the ticket for the life of the tunnel.A
wait-for-nodeinit container polls the node ConfigMap, so a pod whose job is still queued showsInit:0/1rather than a crash-looping tunnel. Bounded bySSH.NodeWaitTimeout(default 2h — queue waits are normal) with an error that names the actual cause.Credentials are replicated into the shadow's namespace by default (
SSH.ReplicateCredentials), since shadows follow the offloaded pod into arbitrary per-user namespaces withinterlink.eu/shadow-same-ns.ShadowMode: ssh+FullMesh: trueis rejected at startup — the mesh needs the compute node to dial the cluster, which is exactly what these sites cannot do. Tracked in #548. UDP ports are skipped with a warning, sincessh -Lis TCP only.The shadow runs
docker/Dockerfile.ssh-tunnel(debian-slim +openssh-client,krb5-user,socat), published asghcr.io/interlink-hq/interlink/ssh-tunnel:<tag>by the existing release workflow. It contains no interLink code.SSH.Imageoverrides it; the default is pinned toKubeletVersionso image and VK release together.4.
fix: rebuild the SSH shadow tunnel when the job moves to another nodeA requeued job comes back on a different host, and the VK republishes it into the node ConfigMap — but ssh already had the old hostname baked into its
-Larguments and kept forwarding to the node the job had left.The container now supervises ssh rather than
execing it: it polls the mounted file and rebuilds the forwarders against the new node, in place. An ssh exit with the node unchanged still ends the container, so a dead tunnel is restarted by Kubernetes as before.5.
feat: add an exec forward mode for login nodes that refuse port forwardingssh -LneedsAllowTcpForwardingon the login node, and a good number of sites do not grant it. SURF's ETP testbed setsAllowTcpForwarding noglobally and re-enables it only for the MFA group, so every channel comes back:Network.SSH.ForwardMode: execdrops the forwarding channel entirely. Each exposed port gets a localsocatlistener in the shadow, and every accepted connection is relayed by a command run on the login node (Network.SSH.ExecConnectCommand, defaultnc <compute node> <port>). All it needs is the ability to run a command over SSH — the same privilege the shadow already uses to log in.The relays share one multiplexed SSH connection, established as an explicit control master before the listeners open. Without it each connection would pay a full handshake — a notebook UI opens dozens — and the login node would see a session per connection.
The two modes are interchangeable above the socket: the Service fronts the same containerPort, and the offloaded workload still just binds
0.0.0.0.portforwardstays the default; it is cheaper and has no dependency on the site having a netcat.6.
fix: refuse a compute node name that is not a hostnameThe node a plugin reports goes into a shell command twice — the
-Largument in portforward mode, and the relay command exec mode sends to the login node — and was neither quoted nor checked. A name containing whitespace splits into an extra ssh argument, and ssh takes options anywhere:ProxyCommandruns a command in the shadow container. In exec mode a name containing a quote or$(breaks out of the relay command instead, which the login node's shell then re-parses. For the Slurm plugin this value ishostname -fwritten to a file in the pod's job directory on a shared filesystem, so it is only as trustworthy as the permissions on that directory.Refuse anything that is not an RFC 1123 hostname or an IP literal before it reaches the ConfigMap the shadow mounts, and quote it in both modes as well.
7.
fix: do not overwrite a credential the shadow namespace already hadCredential replication created-or-updated unconditionally. With
interlink.eu/shadow-same-nsthe shadow lands in the offloaded pod's own namespace, which on a multi-tenant cluster is somebody's personal namespace, so a Secret that merely shared the configured name was replaced with the HPC key and its contents lost, silently.Replicated objects now carry
interlink.eu/replicated-fromand only ones bearing it are overwritten. An unmarked object whose content already matches is adopted rather than refused — that is what a copy made by an earlier version lookBreaking changes
Renaming the rendered resources is a soft break:
Security note
SSH.ReplicateCredentialsdefaults totrue. It copies the SSH key/keytab Secret into each shadow's namespace, which makes it readable by anyone who can read Secrets there. It defaults on because multi-tenant setups (Kubeflow notebooks in per-user namespaces) are unusable otherwise, but it's a real tradeoff — set it tofalseand provision the Secret yourself if that isn't acceptable for your cluster. The Helm chart only grants the VK cluster-wide Secret write when it's enabled.Without
SSH.KnownHostsConfigMapthe shadow usesStrictHostKeyChecking=accept-new. Pinning host keys is documented and recommended for production.Testing
go build ./... && go vet ./... && go test ./pkg/...clean. Unit tests cover:NormalizeShadowConfigdefaults and every rejection path (unknown mode, unknown forward mode, ssh+mesh, missing required fields, bad duration)applyShadowManifestsuses, asserting the decoded Deployment —-Lper TCP port vssocatlisteners plus a control master, UDP skipped, kerberos vs publickey containers/volumes, known_hosts pinning on/offEnd to end, on real hardware
Three virtual kubelets on a shared Kubernetes cluster, one per site, each covering a different combination:
liza.surf.nl)execslurmgate06)portforwardalogin1.bsc.es)portforwardVerified at all three: pod offload (including a GPU pod on SURF),
PodStatus.NodeNamearriving end to end from the Slurm plugin through the status API into the shadow's ConfigMap, credential replication into the shadow namespace (keytab andkrb5.conffor the Kerberos one), and an HTTP service inside the offloaded pod reachable from an ordinary cluster pod through the shadow Service:Also verified:
PENDINGfor ~20 minutes while the shadow showedInit:0/1with an empty ConfigMap, then came up on its own once Slurm allocated a node.compute node changed from ... to ..., rebuilding the tunneland kept serving, with containerrestartCountstill 0 — so the pod IP already reported for the offloaded pod did not change./labreturns 200 with<title>JupyterLab</title>,POST /api/kernelsstarts a Python kernel on the compute node, and a file round-trips through the contents API. 45-106 ms per request, including the exec-mode site where every request crosses an SSH channel and anchop.h -oProxyCommand=touch /tmp/pwnedinto the shared-filesystem file the plugin reads. The virtual kubelet refused it, the ConfigMap kept the real node, the tunnel kept serving, and nothing ran in the shadow container.Related
PodStatus.NodeNamereporting): feat: report the allocated compute node in pod status interlink-slurm-plugin#148 — depends on this being released, needs ago.modbump afterwards