Run a local process as if it were inside your cluster: prefix any command
with plug and it resolves the cluster DNS and reaches the cluster services like
any workload of the stack - no code change, no proxy configuration in your app. One
tiny agent container on the cluster side, one static CLI on yours. Docker, Swarm, Kubernetes
and OpenShift.
Under the hood
How it works says what plug does. This page says how, for the curious and for whoever has to vouch for it.
One mechanism: a userspace TUN, over an SSH tunnel. plug captures your command's cluster traffic at the IP layer and splices it, by name, to a tiny agent (one static Go binary, no shell) running in the cluster. Your app's socket is never touched, and nothing but a private, reserved IP range is ever intercepted.
The data path, step by step
-
Your command connects by name. It asks for
my-service:8080exactly as it would inside the cluster - no proxy variables, no rewritten URL. -
DNS is answered in-stack. plug runs a
gVisor userspace network
stack behind a TUN device (
/dev/net/tunon Linux,utunon macOS, WinTUN on Windows). A single-label name is handed a private stand-in IP from a reserved range (198.18.x.x), and the OS routes that range straight into the TUN. -
plug reads the packet and splices the flow. The
connect()surfaces as a packet in plug's stack; because plug minted the stand-in IP, it knows the real name, terminates the flow, and splices it onto the single SSH tunnel - carrying the name, not an IP. -
The agent dials from inside. The agent opens an SSH
direct-tcpipchannel: it resolves the name with the cluster's own resolver and connects toservice:portfrom inside the cluster. Standard SSH, so any client speaks it - the server is ours, a few hundred lines overgolang.org/x/crypto/ssh, with no shell and no privileged helper behind it.
Why capture at the IP layer
-
Every runtime, no config. The app's socket is never touched, so it covers
Node, the JVM (Spring/Quarkus/Netty), Python, Ruby, PHP, curl - and, crucially,
Go and other statically-linked binaries, and gRPC, the exact cases an
LD_PRELOAD/proxy approach cannot reach. -
Split-horizon by name shape. Single-label names (
my-service,rabbitmq) go to the cluster; dotted FQDNs (api.github.com) andlocalhostresolve and connect directly, so your app keeps normal internet access. - Self-healing transport. A keepalive keeps the SSH tunnel warm and a drop (an idle NAT/VPN timeout, a laptop sleep, an agent restart) is re-dialed transparently, so long sessions never need a restart. The agent's host key is pinned on first use.
The privilege, granted once
Creating the TUN, setting routes and repointing DNS needs privilege - granted
once at install so day-to-day plug <cmd> needs none. Each OS
does it the native way:
-
Linux - file capabilities (
cap_net_admin,cap_sys_admin,cap_net_bind_service). Each launch also gets a private resolver in its own mount namespace, which is what lets several clusters run side by side for free. -
macOS - a setuid-root helper holds the TUN + DNS, then drops your command
back to your user. Because macOS repoints DNS machine-wide, the data path lives in a small
per-cluster daemon that survives each run and restores your DNS ~30 s after
the last
plugof that cluster exits (plug downstops it now). -
Windows - the data path lives in a SYSTEM service installed
once (elevated); every
plugrun is a non-elevated launcher that starts it on demand and delegates to it, so the command itself never needs admin.
Several clusters at once
Two different clusters can run side by side - plug -p a and
plug -p b - with the same service names resolving to the right backend each time.
How plug keeps them apart depends on the OS:
- Linux gives every launch a private resolver in its own mount namespace, so two launches never share DNS - isolation for free.
- Windows repoints the system resolver machine-wide, so the SYSTEM service holds one tunnel per cluster and disambiguates each flow at
connect(): the source port maps to the owning process, whose ancestry is walked back to theplug -p xthat launched it - that is its cluster (PID-at-connect). - macOS - the global daemon holds one tunnel per cluster and attributes each flow by PID at connect, like Windows (proven simultaneously in CI).
One honest limit: if a process fully detaches from the plug that
launched it - a rare case, where it re-parents to the system and the ancestry link is lost -
plug can no longer tell which cluster it belongs to, so it declines that
connection rather than risk routing it to the wrong cluster.
The reverse direction
The transport carries the other way too: plug -s
<name>:<cluster-port>:<local-port> <cmd> opens a
dedicated SSH connection for the session and asks the agent for a standard
remote forward - the agent listens on the
cluster port, and every connection a cluster workload makes rides that connection back to your
local port (dedicated, so the port's lifetime is exactly the session's, even where the forward
datapath lives in a shared daemon). The cluster name that points at it is
provisioned by the agent on the fly: a signpost container carrying the DNS
alias (Docker, via the socket) or a Service whose endpoints name the agent pod holding the
session (Kubernetes, via a Services-and-their-endpoints role) - created on -s, removed when the session ends, and
re-provisioned automatically after a reconnect, with no stack redeploy. plug verifies the whole
loop at startup through the cluster's own DNS. A deployed service owning the
name is parked for the session (containers stopped / Swarm scaled to 0 / k8s
Service repointed) and restored on exit - your process substitutes for it,
and inherits its environment: the parked workload's variables, secrets
included as the pod already had them, are handed to your command, the cluster's value
winning over whatever the shell or a .env left inherited (so the process
behaves as in the cluster), the container's own plumbing (PATH,
KUBERNETES_*) left out, --no-env to opt out (or --no-env A,B
to keep YOUR value for a key), --env-of <name> to take another
workload's instead (a -c script with a service's credentials, a -s
borrowing its neighbour's). Its mounted secret and config files come too - a
CA in PEM, a keystore - read through the same exec, materialised for the session and the
variables that name their path repointed, with no FUSE mount (or mounted at their exact
path under --dockerrun). And its data volumes - a Docker volume,
a bind, a PersistentVolumeClaim - are mounted on your machine, live and
read-write, for the session, by default: the agent starts a helper beside the
workload (its own image, with the volume mounted, serving it over SMB), plug reaches it
through the tunnel like any service and mounts it with the SMB client the OS already has -
nothing to install, on macOS, Linux and Windows (a drive letter there). Each volume lands under the session's directory at its cluster path,
the variables naming it repointed, so the process finds its data where its environment
says; --no-mount opts out, --mount /data puts one at the exact path.
The helper is tied to the session as the name is: reaped by the agent when the session
dies, re-provisioned after a reconnect with the mount left untouched. The peers those variables name the Kubernetes way,
rabbitmq.shop.svc.cluster.local, resolve through plug like the bare name does,
whole, so a Service in another namespace is reached as the pod reaches it. So does the short
form a chart often writes, rabbitmq.shop: plug asks the clusters for it when its
last label cannot be a public domain (shop, monitoring), and leaves
it to your usual DNS otherwise, so a name like github.com or one of your local
network never changes hands. A namespace named after a public top-level domain
(dev, app) is reached by its long name. A name held by another live plug session is refused, and the refusal
names the process holding it. That claim is the agent's,
not the signpost's: it leases the name to the session serving it, so the name stays that
session's even in the moments no signpost exists - right after an agent restart, for
instance. A session is only ever held to be alive while its own forward still answers, so
nothing stays locked after a session dies.
See Swarm and
Kubernetes.
Built with open source
plug stands on the shoulders of these projects - thank you:
| Dependency | Role | License |
|---|---|---|
| golang.org/x/crypto/ssh | The transport, on both ends: the CLI's in-process client, and the agent's server doing the direct-tcpip dials and the remote forwards | BSD |
| wireguard-go + gVisor | The userspace TUN device and network stack that answer DNS and terminate flows in-process | MIT · Apache-2.0 |
| smbserver | The SMB server of a live mount, compiled into the agent: the helper serves a workload's volume with the binary alone, without privilege | Apache-2.0 |
| Go | The CLI - one static binary per platform, no runtime dependencies | BSD |
| distroless | Base of the agent image: no shell, no libc, no package manager - one static Go binary, the binaries it serves, and the root certificates | Apache-2.0 |