Status: design validated, attribution core landed (cli/internal/tun/pidroute*.go), not yet wired into the datapath. The single-cluster path is unchanged and stays the proven one until this is validated on two real clusters.
Running two different clusters at once on the same machine.
- Linux is already there. Each
pluglaunch gets a private/etc/resolv.confbind-mounted in its own mount namespace, so two launches never share DNS —plug -p Aandplug -p Bare isolated for free. - macOS / Windows are the hard case. There is no per-process resolver: plug repoints the system resolver (macOS
scutildynamic store, Windows adapter DNS), which is machine-wide. Two clusters would fight over one resolver. That is why 1.1.0 documents "one active cluster at a time" on macOS.
The obvious idea — plug appends a per-cluster suffix so postgres becomes postgres.clusterB — was examined and rejected on macOS:
- To rewrite what the app resolves you must sit inside the app's
getaddrinfo. That is theLD_PRELOAD/DYLDhook the new TUN architecture deliberately removed. - A search domain can't do it either: it is global on macOS (not per-process), and the bare name
postgressucceeds first (default cluster), so the suffix fallback never fires. It also can't tell app A from app B.
Suffixes remain viable on Linux (the private resolv.conf can carry search clusterB), but they are not a cross-platform answer.
Decouple naming from routing:
- One system resolver, one shared fake space. The daemon repoints DNS once and mints a fake IP per name, shared across clusters (
postgres→ one fake IP whichever cluster owns it). No per-cluster DNS. - Disambiguate at
connect(), not at DNS. When the app connects the fake IP, the packet reaches plug's netstack (handleTCP). There plug has both the name (via the fake table) and, crucially, a handle on the caller: the flow's source port. - Attribute the flow to a cluster by ancestry. Source port → owning PID (OS TCP table) → walk the parent chain → the first ancestor that is a registered
plug -p Xlauncher → its cluster. Route the(name, cluster)pair into that cluster's tunnel. plug -p X <cmd>is the entry point (unchanged — you already type it). It doesn't change where DNS goes; it marks the process ancestry so the daemon can attribute connects to cluster X.
Transparent (bare names everywhere), at the cost of one PID lookup per connection (cached PID→cluster).
A process detached from its plug launcher (setsid, reparented to launchd/init) can't be walked back to a cluster. Rather than guess and route to the wrong cluster, plug refuses the connect (hard RST). This is a documented, narrow trade-off — not a blocker for normal plug -p X <cmd> trees.
Landed now (cli/internal/tun/pidroute*.go): the attribution core.
walkToCluster(pid, ppidOf, clusterForPID)— pure, unit-tested ancestry walk (bounded, cycle-safe, refuses on a broken chain).pidRouter/staticRouterbehind aclusterRouterinterface — the single-cluster router is a no-op that always returns its one key, so wiring it later is a zero-regression change.ppidOf: real on Linux (/proc/<pid>/stat) and macOS (ps -o ppid=), stub on Windows.pidForLocalPort: real on Linux (/proc/net/tcp{,6}inode →/proc/<pid>/fdscan), stub on macOS/Windows.
Remaining (needs two real clusters to validate):
- N-tunnel daemon. Today the macOS daemon holds ONE cluster (keyed
host:port, onetr Dialer). Generalize it to hold amap[clusterKey]*tunnel.Transportand one shared netstack. clusterForPIDregistry. Record eachplug -p Xlauncher PID → cluster key as clients register (extendregistry_darwin.go, add the other OSes).- Wire
handleTCP. Replace the singletr Dialerwith aclusterRouter:route(srcPort)→ cluster →dialers[cluster].DialCluster(name:port); refuse (RST) whenok==false. KeepstaticRouterfor the single-cluster case. pidForLocalPorton macOS (lsof -nP -iTCP:<port>parse, ornet.inet.tcp.pcblistsysctl) and Windows (GetExtendedTcpTable,MIB_TCPTABLE_OWNER_PID).ppidOfon Windows (CreateToolhelp32Snapshot→th32ParentProcessID).
- macOS: no cgo (
CGO_ENABLED=0in the image build) → attribution must be cgo-free (ps,lsof, orsysctl), not libproc directly. /proc/<pid>/stat:commcan contain spaces and)— parse from the last).- Perf: cache
PID→cluster(stable for a process), so the per-connection cost is negligible; only new source ports pay a lookup. - Detached processes:
setsidbreaks the chain → refuse, by design.
- N-tunnel daemon skeleton +
clusterForPIDregistry (macOS first — it's the resolver-global case). - Wire
handleTCPbehindclusterRouter(staticRouterdefault → no behaviour change until ≥2 clusters). pidForLocalPort/ppidOffor macOS, then Windows.- e2e on two clusters:
plug -p A pgandplug -p B pgresolvepostgresto different backends concurrently; a detached child is refused.