Skip to content

feat: add an SSH port-forward shadow for air-gapped HPC - #550

Draft
matbun wants to merge 7 commits into
interlink-hq:mainfrom
matbun:feat/ssh-shadow-tunnel
Draft

feat: add an SSH port-forward shadow for air-gapped HPC#550
matbun wants to merge 7 commits into
interlink-hq:mainfrom
matbun:feat/ssh-shadow-tunnel

Conversation

@matbun

@matbun matbun commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

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 customTemplate per 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.

browser -> Ingress/Service -> shadow pod (ssh) -> HPC login node -> compute node

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.

Network:
  EnableTunnel: true
  ShadowMode: ssh
  SSH:
    LoginHost: login.hpc.example.org
    User: alice
    KeySecret: hpc-ssh-key        # or Auth: kerberos + KeytabSecret + Principal

ShadowMode defaults to wstunnel, so existing deployments are unaffected.

Commits

1. refactor: rename wstunnel shadow-pod naming to shadow

The 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:

before after
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 from mesh.go into a new shadow.go.

Pod annotations (interlink.eu/wstunnel-extra-ports, interlink.virtual-kubelet.io/wstunnel-timeout) and the Wstunnel* 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 pods

The 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:

PodStatus.NodeName string `json:"nodeName,omitempty"`

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 HPC

New embedded template templates/shadow-ssh-template.yaml plus a Network.SSH config block. Both auth methods sites actually use are supported: an SSH private key, or a Kerberos keytab with kinit in an init container and a sidecar refreshing the ticket for the life of the tunnel.

A wait-for-node init container polls the node ConfigMap, so a pod whose job is still queued shows Init:0/1 rather than a crash-looping tunnel. Bounded by SSH.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 with interlink.eu/shadow-same-ns.

ShadowMode: ssh + FullMesh: true is 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, since ssh -L is TCP only.

The shadow runs docker/Dockerfile.ssh-tunnel (debian-slim + openssh-client, krb5-user, socat), published as ghcr.io/interlink-hq/interlink/ssh-tunnel:<tag> by the existing release workflow. It contains no interLink code. SSH.Image overrides it; the default is pinned to KubeletVersion so image and VK release together.

4. fix: rebuild the SSH shadow tunnel when the job moves to another node

A 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 -L arguments 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 forwarding

ssh -L needs AllowTcpForwarding on the login node, and a good number of sites do not grant it. SURF's ETP testbed sets AllowTcpForwarding no globally and re-enables it only for the MFA group, so every channel comes back:

channel 1: open failed: administratively prohibited: open failed

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, default nc <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.

    ForwardMode: exec
    ExecConnectCommand: nc     # ncat, or "nc -q 1" on a netcat that does not half-close

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. portforward stays 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 hostname

The node a plugin reports goes into a shell command twice — the -L argument 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:

-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.

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 had

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 shared the configured name was replaced with the HPC key and its contents lost, silently.

Replicated objects now carry interlink.eu/replicated-from and 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 look

Breaking changes

Renaming the rendered resources is a soft break:

  • Shadows created before this change keep their old names until their pods are recreated.
  • Anything hard-coding a shadow Service name — Ingress, Istio VirtualService, admission policies — needs updating.

Security note

SSH.ReplicateCredentials defaults to true. 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 to false and 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.KnownHostsConfigMap the shadow uses StrictHostKeyChecking=accept-new. Pinning host keys is documented and recommended for production.

Testing

go build ./... && go vet ./... && go test ./pkg/... clean. Unit tests cover:

  • NormalizeShadowConfig defaults and every rejection path (unknown mode, unknown forward mode, ssh+mesh, missing required fields, bad duration)
  • both forward modes rendered through the same decoder applyShadowManifests uses, asserting the decoded Deployment — -L per TCP port vs socat listeners plus a control master, UDP skipped, kerberos vs publickey containers/volumes, known_hosts pinning on/off
  • node ConfigMap create/blank/patch, no-write when queued, no-write on repeat, cleanup
  • credential replication, opt-out, and a clear error when the source Secret is missing

End to end, on real hardware

Three virtual kubelets on a shared Kubernetes cluster, one per site, each covering a different combination:

Site Auth Forward mode
SURF ETP (liza.surf.nl) public key exec
CERN (slurmgate06) Kerberos keytab portforward
BSC MareNostrum5 (alogin1.bsc.es) public key portforward

Verified at all three: pod offload (including a GPU pod on SURF), PodStatus.NodeName arriving end to end from the Slurm plugin through the status API into the shadow's ConfigMap, credential replication into the shadow namespace (keytab and krb5.conf for the Kerberos one), and an HTTP service inside the offloaded pod reachable from an ordinary cluster pod through the shadow Service:

$ wget -qO- http://cern-http-interlink-cern-ssh.interlink-cern-ssh-shadow.svc.cluster.local:18080/
hello from hpc-cloud-test001.cern.ch at 2026-07-31T14:04:35+02:00

Also verified:

  • The queued case, which is the normal one. A BSC job sat in PENDING for ~20 minutes while the shadow showed Init:0/1 with an empty ConfigMap, then came up on its own once Slurm allocated a node.
  • The requeue case (commit 4), by republishing a different node into the ConfigMap: the shadow logged compute node changed from ... to ..., rebuilding the tunnel and kept serving, with container restartCount still 0 — so the pod IP already reported for the offloaded pod did not change.
  • A JupyterLab session on every site, driven from an ordinary cluster pod through the shadow Service: /lab returns 200 with <title>JupyterLab</title>, POST /api/kernels starts 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 a nc hop.
  • The node-name check (commit 6), by writing h -oProxyCommand=touch /tmp/pwned into 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

@netlify

netlify Bot commented Jul 29, 2026

Copy link
Copy Markdown

Deploy Preview for interlink-dev canceled.

Name Link
🔨 Latest commit 04020c1
🔍 Latest deploy log https://app.netlify.com/projects/interlink-dev/deploys/6a6cb93afd44fb0008e5c9a4

matbun and others added 7 commits July 31, 2026 17:03
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
matbun force-pushed the feat/ssh-shadow-tunnel branch from bf7d027 to 04020c1 Compare July 31, 2026 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

in-cluster SSH deployment

1 participant