@@ -41,8 +41,9 @@ Per pair we then run **one QUIC connection** (quic-go) over the
4141ICE conn, treating ` ice.Conn ` as a ` net.PacketConn ` . QUIC provides
4242loss recovery, congestion control, and stream multiplexing on top of
4343the lossy UDP underlay. Streams come in two flavours: some are
44- long-lived (one per protocol port, carrying length-prefixed UDP
45- datagrams), the rest are ephemeral (one per accepted TCP connection).
44+ long-lived (one per UDP-bearing infra port, carrying length-prefixed
45+ UDP datagrams), the rest are ephemeral (one per accepted TCP
46+ connection).
4647
4748```
4849 ┌── ICE conn / QUIC ──┐
@@ -65,73 +66,107 @@ maintains five of them.
6566> almost immediately under load. QUIC has the loss recovery + flow
6667> control that yamux is forced to assume from below it.
6768
68- ## Layer 2 — identity-port plane
69+ ## Layer 2 — peer-VIP / service-VIP plane
6970
7071This is the trick that makes the overlay invisible to the applications
71- above. Every peer has a unique port for every protocol. mesh-conn
72- binds the ** other** peers' identity ports on ` 127.0.0.1 ` and bridges
73- each one to the right ICE+QUIC peer link, ** preserving source ports**
74- so the destination app sees the packet as coming from
75- ` 127.0.0.1:<sender's identity port> ` — which is exactly what the app
76- uses to identify the sender.
77-
78- So inside any CVM the entire cluster looks like a single loopback
79- host. Eight protocol ports per peer (serf_lan, server_rpc, http_api,
80- grpc, webdemo, sidecar_public, postgres, patroni_rest), spread by
81- ` base + ordinal ` :
72+ above. Two carved-out loopback ` /24 ` s, allocated cluster-wide:
73+
74+ - ** Peer VIPs** in ` 127.50.0.0/24 ` — one per peer (the peer's
75+ ordinal+1 in the example template). Identifies * who* a piece of
76+ infrastructure traffic is for.
77+ - ** Service VIPs** in ` 127.10.0.0/24 ` — one per Connect upstream a
78+ worker consumes (e.g. webdemo, postgres-master, postgres-replica).
79+ Identifies * what service* an app is calling.
80+
81+ mesh-conn binds a small ** static infra-port allowlist** on every
82+ * other* peer's VIP and forwards each accepted connection to the right
83+ remote peer over the QUIC link:
84+
85+ | Port | Used by | Proto |
86+ | -------| -------------------------------------------------------| -------------|
87+ | 21000 | Envoy Connect public mTLS — webdemo sidecar | TCP |
88+ | 21001 | Envoy Connect public mTLS — postgres sidecar | TCP |
89+ | 8300 | Consul server RPC (server-to-server, client-to-server)| TCP |
90+ | 8301 | Consul serf-LAN gossip | UDP + TCP |
91+
92+ The allowlist is static and small by design — mesh-conn knows
93+ ** peers, not services** . Apps never dial peer VIPs; only Envoy and
94+ Consul-agent do, and both speak well-known platform ports. Adding a
95+ port to the allowlist is a code change in ` mesh-conn/main.go ` , not
96+ a runtime catalog watch.
8297
8398```
8499 inside worker-3 CVM (network_mode: host)
85100 ┌───────────────────────────────────────────────────────────────────┐
86- │ local apps bind their OWN identity ports (base + ordinal=3): │
101+ │ local apps + platform processes bind canonical ports on lo: │
87102 │ │
88- │ consul agent ▶ 127.0.0.1:18003 (serf) 18103 (rpc) │
89- │ 18203 (http) 18303 (grpc) │
90- │ webdemo ▶ 127.0.0.1:18503 │
91- │ envoy sidecar ▶ 127.0.0.1:18603 (public mTLS) │
92- │ patroni / pg ▶ 127.0.0.1:18703 (postgres) 18803 (REST) │
103+ │ patroni / pg ▶ 127.0.0.1:5432 (postgres) │
104+ │ 127.0.0.1:8008 (patroni REST) │
105+ │ webdemo ▶ 127.0.0.1:8080 │
106+ │ envoy-webdemo ▶ 127.0.0.1:21000 (sidecar public mTLS) │
107+ │ envoy-postgres ▶ 127.0.0.1:21001 (sidecar public mTLS) │
108+ │ consul agent ▶ 127.0.0.1:8500 (HTTP) │
109+ │ 127.0.0.1:8502 (gRPC, Envoy xDS) │
110+ │ 127.0.0.1:8301 (serf gossip) │
111+ │ 127.0.0.1:8300 (server RPC; coords only) │
93112 │ │
94- │ mesh-conn binds OTHER peers' identity ports on 127.0.0.1: │
113+ │ loopback aliases (provisioned once at sidecar entrypoint): │
95114 │ │
96- │ ports[0..7] + 0 ◄── coord-0 │
97- │ ports[0..7] + 1 ◄── coord-1 │
98- │ ports[0..7] + 2 ◄── coord-2 │
99- │ ports[0..7] + 4 ◄── worker-4 │
100- │ ports[0..7] + 5 ◄── worker-5 │
115+ │ 127.50.0.<vip>/32 for every peer — peer VIPs │
116+ │ 127.10.0.<vip>/32 per declared upstream — service VIPs │
101117 │ │
102- │ all UDP/TCP traffic to those ports is shipped through the │
103- │ matching ICE+QUIC connection to the corresponding peer. │
118+ │ mesh-conn binds the allowlist on OTHER peers' VIPs, e.g. on │
119+ │ worker-3: │
120+ │ │
121+ │ 127.50.0.1:8301,8300,21000,21001 ◄── coord-0 │
122+ │ 127.50.0.2:8301,8300,21000,21001 ◄── coord-1 │
123+ │ 127.50.0.3:8301,8300,21000,21001 ◄── coord-2 │
124+ │ 127.50.0.5:8301,8300,21000,21001 ◄── worker-4 │
125+ │ 127.50.0.6:8301,8300,21000,21001 ◄── worker-5 │
126+ │ │
127+ │ Envoy listens on the service VIPs for declared upstreams: │
128+ │ │
129+ │ 127.10.0.10:8080 → cluster `webdemo` │
130+ │ 127.10.0.20:5432 → cluster `postgres-master` │
131+ │ 127.10.0.21:5432 → cluster `postgres-replica` │
132+ │ │
133+ │ /etc/hosts maps service names → service VIPs, so apps just call │
134+ │ `postgres-master:5432`. │
104135 └───────────────────────────────────────────────────────────────────┘
105136```
106137
107- Every peer has the symmetric layout — own ports bound by apps, other
108- peers' ports bound by mesh-conn.
138+ Every peer has a symmetric layout: own ports bound by apps + Envoy +
139+ Consul, other peers' VIP+allowlist bound by mesh-conn. Self's VIP is
140+ also aliased on ` lo ` (local short-circuit: dialing
141+ ` 127.50.0.<self-vip>:21000 ` routes through the kernel directly to
142+ the local Envoy, no mesh-conn hop).
109143
110144## Layer 3 — apps
111145
112146Consul agents, Envoy sidecars, webdemo, Patroni, anything else. These
113- think they're talking to peers on ` 127.0.0.1 ` . They never see ICE,
114- QUIC, TURN, or the public internet. Stock HashiCorp Consul, stock
115- Envoy, stock Patroni.
147+ think they're talking to peers on ` 127.0.0.1 ` and to services by
148+ name. They never see ICE, QUIC, TURN, or the public internet. Stock
149+ HashiCorp Consul, stock Envoy, stock Patroni.
116150
117151## How a single call traverses all four layers
118152
119- A Connect-style mTLS call from ` worker-3 ` 's webdemo to ` worker-4 ` 's
120- webdemo:
153+ A Connect-mTLS call from ` worker-3 ` 's webdemo to whichever peer 's
154+ webdemo Envoy load-balances onto :
121155
122156```
123157worker-3 webdemo
124- GET http://127.0.0.1:19000 /hello ── Layer 3, app on its
125- │ local sidecar upstream
158+ GET http://webdemo:8080 /hello ── Layer 3, app dialing
159+ │ /etc/hosts → 127.10.0.10 a service name
126160 ▼
127- worker-3 envoy sidecar
128- picks endpoint via Consul-supplied EDS
129- opens mTLS to "127.0.0.1:18604" (worker-4's sidecar via mesh-conn)
161+ worker-3 envoy-webdemo
162+ listener on 127.10.0.10:8080 (cluster `webdemo`)
163+ EDS endpoint pick → e.g. 127.50.0.5:21000 (worker-4's webdemo sidecar)
164+ opens mTLS to "127.50.0.5:21000"
130165 │
131166 ▼
132- worker-3 mesh-conn (TCP listener on 127.0 .0.1:18604 )
167+ worker-3 mesh-conn (TCP listener on 127.50 .0.5:21000 )
133168 reads bytes off the local TCP listener
134- opens a QUIC stream tagged "port=18604 "
169+ opens a QUIC stream tagged "port=21000 "
135170 writes through the worker-3↔worker-4 QUIC connection ── Layer 2 → 1
136171 │ ╱
137172 │ ╱ here Layer 1 (QUIC frames over the ICE conn)
@@ -140,15 +175,15 @@ worker-3 mesh-conn (TCP listener on 127.0.0.1:18604)
140175 │ ╱
141176 ▼
142177worker-4 mesh-conn (QUIC stream accept on the worker-3 ICE conn)
143- reads stream header → "port=18604"
144- dials TCP to 127.0.0.1:18604 (worker-4's actual sidecar)
178+ reads stream header → "port=21000" (allowlist-validated)
179+ dials TCP to 127.0.0.1:21000 (worker-4's webdemo sidecar)
145180 splices stream ↔ TCP conn ── Layer 1 → 2
146181 │
147182 ▼
148- worker-4 envoy sidecar
183+ worker-4 envoy-webdemo
149184 validates origin's mTLS cert against Connect CA
150185 checks intention webdemo → webdemo (allow)
151- forwards to local 127.0.0.1:18504 (LocalServicePort)
186+ forwards to local 127.0.0.1:8080 (LocalServicePort)
152187 │
153188 ▼
154189worker-4 webdemo
@@ -157,6 +192,36 @@ worker-4 webdemo
157192
158193Reply takes the same path in reverse.
159194
195+ ## Patroni replication uses the same path
196+
197+ A Patroni replica following the leader is just another consumer of
198+ the ` postgres-master ` Connect upstream — no special case. The
199+ replica's ` primary_conninfo ` carries ` host=postgres-master port=5432 `
200+ (the same constant string registered as ` postgresql.connect_address `
201+ on every Patroni instance). The local Envoy at ` 127.10.0.20:5432 `
202+ proxies to the leader's ` 127.50.0.<leader-vip>:21001 ` , which lands
203+ on the leader's postgres-sidecar Envoy and gets forwarded to local
204+ ` 127.0.0.1:5432 ` . The Connect mesh stays in the data path for
205+ streaming WAL + ` pg_basebackup ` — the 2× Envoy hop tax is the cost
206+ of keeping mesh-conn workload-agnostic.
207+
208+ Service-resolver subsets pick the right peer:
209+
210+ ``` hcl
211+ Kind = "service-resolver"
212+ Name = "postgres"
213+ Subsets = {
214+ master = { Filter = "Service.Tags contains \"master\"" }
215+ replica = { Filter = "Service.Tags contains \"replica\"" }
216+ }
217+ ```
218+
219+ Patroni auto-registers the parent ` postgres ` service with the
220+ current role as a tag (` master ` on the leader, ` replica ` on
221+ followers). The subset filter strips down EDS endpoints to the right
222+ peer's sidecar, and the ` postgres-master ` / ` postgres-replica `
223+ service-resolvers redirect to the right subset of ` postgres ` .
224+
160225## mesh-conn × QUIC — how they work together
161226
162227The bit that's worth being precise about: mesh-conn is built on top
@@ -177,8 +242,9 @@ TEE layer, not TLS identity) and gives back a `*quic.Conn`.
177242We need to carry multiple logical channels over the unreliable UDP
178243underlay:
179244
180- - One long-lived stream per identity port (8 of these per peer-pair)
181- carrying length-prefixed UDP datagrams.
245+ - One long-lived stream per UDP-bearing allowlist port (currently
246+ one: 8301 for serf gossip), carrying length-prefixed UDP
247+ datagrams.
182248- One ephemeral stream per accepted local TCP connection, opened and
183249 closed on demand.
184250
@@ -222,10 +288,11 @@ When a stream opens, the **first 3 bytes** carry a mesh-conn header:
222288 datagrams.
223289- ` tag = 0x33 ` → ** streamTCP** — per-connection raw TCP byte stream.
224290
225- The 16-bit ` port ` is the ** receiver's own identity port** for the
226- protocol slot this stream serves. The receiver looks it up in
227- ` self.Ports ` , finds the index, and pairs the stream with the right
228- local socket / dial target.
291+ The 16-bit ` port ` is the ** receiver's local port to dial / write
292+ into** . The receiver validates it against the static allowlist
293+ (` {21000, 21001, 8300, 8301} ` ); anything else is rejected. There is
294+ no per-peer port-list lookup — ports are platform-level constants
295+ the same on every CVM.
229296
230297UDP-over-stream uses an explicit 2-byte big-endian length prefix per
231298datagram, since QUIC streams are byte-oriented (like yamux was) and
@@ -255,7 +322,8 @@ format.
255322## Trust boundaries
256323
257324- ** App → Envoy** is plaintext on loopback. Same CVM, same TEE.
258- - ** Envoy → Envoy** is mTLS, certs signed by Consul's Connect CA.
325+ - ** Envoy → Envoy** is mTLS, certs signed by Consul's Connect CA
326+ (built-in CA provider; root in Raft, no external derivation).
259327 End-to-end across the overlay; mesh-conn just sees encrypted bytes.
260328- ** mesh-conn → mesh-conn** rides ICE. Direct UDP between CVMs on the
261329 public internet (or TURN-relayed if hole-punching ever fails).
@@ -265,22 +333,25 @@ format.
265333- …all confidential traffic above it is ** already encrypted by Envoy
266334 mTLS** (Layer 3), so the wire is safe even if someone could see the
267335 UDP datagrams.
268- - ** Consul gossip** is currently unencrypted (we didn't set a gossip
269- key); RPC is plaintext. Both are confined to inside the overlay,
270- but a full setup would set ` -encrypt=... ` and TLS for
271- RPC. See [ ROBUSTNESS.md] ( ROBUSTNESS.md ) .
336+ - ** Consul gossip** is encrypted via ` -encrypt ` (Stage-1 workaround:
337+ key generated in Terraform and broadcast via env; Stage-2
338+ attestation will replace this with TEE-rooted material). RPC is
339+ plaintext. Both are confined to inside the overlay, but a full
340+ setup would also configure TLS for RPC. See [ ROBUSTNESS.md] ( ROBUSTNESS.md ) .
272341
273342## What's nice about this shape
274343
275344- ** Layer 3 has zero awareness of layers below.** Consul, Envoy,
276- webdemo all think they're on a flat loopback. Anything that runs
277- against Consul today (Vault, Nomad, Boundary, custom apps) drops in
278- unchanged.
279- - ** Layer 1 is a single component (mesh-conn, ~ 700 LoC Go including
280- the QUIC adapter) and has zero awareness of Consul.** It just
281- bridges ports. It would equally well move Postgres replication,
282- Redis Sentinel, Kafka, etc. — and in fact this example uses it for
283- Patroni+Postgres replication.
345+ webdemo, Patroni all think they're on a flat loopback talking to
346+ services by name. Anything that runs against Consul today (Vault,
347+ Nomad, Boundary, custom apps) drops in unchanged.
348+ - ** Layer 1 + 2 are a single component (mesh-conn, ~ 600 LoC Go
349+ including the QUIC adapter) and have zero awareness of services.**
350+ mesh-conn forwards bytes between peer VIPs on a static infra-port
351+ allowlist; it does not know what Consul, Patroni, or Envoy are.
352+ It would equally well move Postgres replication, Redis Sentinel,
353+ Kafka, etc. — and in fact this example uses it for both Consul
354+ cluster gossip and Patroni-replication-via-Envoy.
284355- ** Layer 0 is dumb infra.** Just a public IP running coturn + a tiny
285356 broker. Fungible and not in the data path once peers are connected.
286357
0 commit comments