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.
Coverage matrix
What works where - features × OS. One process run as if it were inside the cluster, via a userspace TUN over an SSH tunnel.
snapshot 2026-08-06 · CI re-proves install → grid → multicluster → reverse path → takeover → crash-recovery on every push, on 3 OSes × 3 cluster families (compose, Swarm, Kubernetes); rows noted bench are runtime-proven locally, not yet in CI
Install & privilege
| Feature | Linux | macOS | Windows | Notes |
|---|---|---|---|---|
| Install from the cluster (one-liner) | works | works | works | install | sh · install-windows | bash -s -- <host> (Git Bash) |
| No sudo/admin to install | works | works | works | binaries + PATH; one UAC only to create the service |
| One-time privilege grant at install | works | works | works | setcap · setuid helper · SCM SYSTEM service (validated on a real box) |
| plug <cmd> without sudo/admin | works | works | works | Windows: a non-elevated launcher starts the SYSTEM service via its ACL (proven, LIMITED token) |
| Child runs as you (privilege drop) | works | works | not applicable | caps don't cross exec · Credential drop · service is SYSTEM |
| Pre-create profile at install | works | works | works | reads host/port off the live ssh |
| amd64 and arm64 | works | works | works | six binaries; Windows arm64 native, no WSL2, and its WinTUN driver is exercised on a windows-11-arm CI runner |
| Uninstall | works | works | works | unix + Windows (remove-service, wipe) covered |
Data path
| Feature | Linux | macOS | Windows | Notes |
|---|---|---|---|---|
| Userspace TUN (IP-layer capture) | works | works | works | /dev/net/tun · utun · WinTUN |
| Cluster-name DNS (real apps) | works | works | works | private resolv.conf · scutil store · WinTUN search-suffix + NRPT |
Single-label name via getaddrinfo |
works | works | works | the real app path; Windows needs the .plug search suffix to issue a DNS query |
| Works under a corporate VPN | partial | works | partial | macOS: GlobalProtect and OpenVPN Connect, on real corporate networks |
| Keeps the VPN's own names resolving (split DNS) | works | works | works | a VPN adds a resolver scoped to its domain, and plug keeps sending that domain to it - macOS reads SupplementalMatchDomains, Windows the NRPT, and on Linux systemd-resolved applies its routing domains behind the stub plug relays to. In CI on all three OSes: the selftest declares a domain of its own for its fake VPN, asserts a name under it resolves through plug, then stops resolving once the domain is withdrawn |
| Follows the resolver when a VPN comes up, drops, or the network changes | works | works | works | the servers are not a startup fact - in CI on all three OSes: the selftest fabricates a VPN (an extra adapter carrying a resolver that knows a name nothing else knows) and asserts that name resolves through plug, then stops when the VPN goes away. Includes what a VPN does not cause: on macOS one network service serves every SSID, so changing Wi-Fi moves the resolvers without moving the service. plug doctor reports where lookups actually go |
| Every runtime (Node/JVM/Py/Go/gRPC) | works | works | works | IP-level capture, socket never touched |
A CONTAINER as a cluster member (--dockerrun) |
works | partial | partial | an unmodified image joins the cluster through a sidecar that holds the datapath - no Dockerfile change. CI cell on all three cluster families (Linux runners); on macOS and Windows the CLI side (docker found in PATH, native host paths, host.docker.internal for volumes, the container's exit status) is tested in CI against a fake docker, and Docker Desktop itself was verified by hand on a mac |
| Native selftest (datapath proof) | works | works | works | green on all three in CI, plus a windows-11-arm leg opening a real arm64 WinTUN device |
| e2e protocol matrix (8 protos × 4 langs) | works | works | works | native over a Tailscale mesh, against all THREE cluster families - compose, Swarm and Kubernetes (kind) - the same by-name path on Linux, macOS and Windows |
| Split-horizon (short→cluster, FQDN→direct) | works | works | works | decided by the shape of the name - no config |
Reverse: serve a local port to the cluster (-s) |
works | works | works | SSH remote-forward - a cluster workload fetches the runner in CI, path self-verified at startup; re-arm after reconnect in CI (the resilience cell restarts the agent mid-session) |
| Reverse: external caller → published gateway → runner (HTTP) | works | works | works | a POST to a PUBLISHED cluster gateway calls a -s name that lands on the runner's local sink; the correlation id AND the full request path round-trip back (root and a deep path) - the API-gateway use case, proven from outside the cluster |
-s name provisioned dynamically (no redeploy) |
works | works | works | CI serves a name declared nowhere, from a linux/mac/win client, on all three backends: docker-sock signpost container (Compose), Swarm-service signpost on a non-attachable overlay (Swarm), Service through the Services-only RBAC (k8s) - created & torn down per session, swept on agent restart |
| Takeover of a deployed name (default) | works | works | works | a deployed workload owning a -s name is parked for the session and restored on exit - all three backends in CI: containers stopped (Compose), Swarm service scaled to 0 & back to its original replica count (the CI target runs 2), k8s Service repointed via annotation receipt (ClusterIP identical through park/restore). Another session's name stays refused. Boot-gc restore after an agent crash AND the re-park on reconnect are in CI (resilience cell) |
| Self-heal (VPN / sleep / agent restart) | works | works | works | keepalive times out a zombie connection then reconnects & re-provisions - in CI on all three OSes: the resilience cell restarts the agent mid-session and traffic re-parks in seconds (boot-gc restores, the reconnect re-arms) |
Daemon / persistence
| Feature | Linux | macOS | Windows | Notes |
|---|---|---|---|---|
| Persistent global datapath | not applicable | works | works | Linux autonomous · macOS daemon · Windows SCM service (validated) |
| Survives process restarts | works | works | works | per-launch · daemon · service + 30 s self-teardown (validated) |
| Graft (multi-process, same cluster) | works | works | works | flock leader/graft · service is the single owner (3 concurrent sessions proven) |
| plug down | not applicable | works | works | stops the daemon (macOS) / service (Windows) |
Multicluster - different clusters at once
| Feature | Linux | macOS | Windows | Notes |
|---|---|---|---|---|
| Simultaneous different clusters | works | works | works | proven simultaneously in CI on all three - mount-ns · daemon · SYSTEM service |
| PID-at-connect attribution | not applicable | works | works | multiDial shared - proven in CI on macOS and Windows (2 live clusters, same name, right backend) |
| ppidOf | works | works | works | /proc · ps · ToolHelp (unit-tested) |
| pidForLocalPort | works | works | works | /proc/net · lsof · GetExtendedTcpTable (unit-tested) |
| procStart (recycle guard) | works | works | works | ps lstart · /proc stat · GetProcessTimes - rejects a reused PID |
| N-tunnel global daemon | not applicable | works | works | one tunnel per cluster in the daemon/service - proven simultaneously in CI (2 live clusters) |
Profiles & CLI
| Feature | Linux | macOS | Windows | Notes |
|---|---|---|---|---|
| Profiles ~/.plug/*.conf | works | works | works | create by naming · ls · rm · rn/mv · test |
| -p profile / --host / --port | works | works | works | flags override the selected profile |
| Launcher versions (per-cluster) | works | works | works | versions · .exe + wintun.dll handled on Windows |
| Host-key TOFU pin | works | works | works | localhost skipped; pin chowned (macOS) |
Agent deployment
| Item | Support | Notes |
|---|---|---|
| Docker Compose / Swarm | works | agent image joins the stack network - both in CI (Swarm: a real single-node swarm, the agent as a Swarm service on a non-attachable overlay) |
| The downloads, as published | works | the snippet (Compose and Swarm) and deploy/plug-stack.yml (Swarm) deployed as a reader deploys them, image and port aside: a session reaches the app by name and the app reaches the name it serves, on every push. The served copies are checked as pinned before the site is published |
| Kubernetes - NodePort | works | deploy/plug-k8s.yaml applied as published (kind) - the CI legs install and run through it on every push |
| Kubernetes - kubectl port-forward | works | zero exposed port, API-server RBAC - the k8s cluster job answers the agent contract through a live port-forward on every push |
| Cross-namespace | works | via FQDN svc.othernamespace |
Design limits (all OS)
| Item | Support | Notes |
|---|---|---|
| UDP / QUIC / ping | not yet | TCP only (SSH tunnel); most clients fall back to TCP |
| IPv6 literal (hard-coded) | not yet | fake IPs are IPv4; by-name cluster service is fine |
What is not proven yet
Two things the grid above cannot claim. Both are marked ! in it, and neither is a gap in what plug does - they are limits of what has been demonstrated.
--no-env=KEY holds a key back to yours, and -c --env-of reads a running workload's without taking it over, and a MOUNTED FILE is projected and read at its path - a k8s secret, a Compose secret, a Swarm config), the LIVE MOUNT of the workload's volume (automatic: the variable naming it repointed at the mount; explicit: --mount; a file written through it is served back by the workload - a Compose volume, a Swarm volume, a PersistentVolumeClaim on kind), the reverse direction (a cluster workload - and an
external caller through a published gateway - reaches a runner-served name) and
launcher/core version compat. See
How it works
and the roadmap.