Skip to content

Commit e23958d

Browse files
terion-nameclaude
andcommitted
Document tunnel semantics, TLS, streaming, and observability
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7961d5c commit e23958d

2 files changed

Lines changed: 81 additions & 8 deletions

File tree

README.md

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
# airpc
22

3-
airpc is an outbound-only edge/connector gateway. In this slice it supports:
3+
airpc is an outbound-only edge/connector gateway: expose private RPC services without opening a single inbound port on the private network. It supports:
44

5-
- **HTTP unary** proxying over NATS Core request/reply;
6-
- **TCP** and **gRPC** opaque byte streams over a connector-initiated WebSocket data tunnel; and
7-
- **WebSocket** message relay over the same data tunnel.
5+
- **HTTP** proxying over NATS Core request/reply, with SSE/chunked/large responses streamed over the data tunnel and client-disconnect cancellation propagated to the backend;
6+
- **TCP** and **gRPC** opaque byte streams over a connector-initiated WebSocket data tunnel;
7+
- **WebSocket** message relay (including close-code propagation) over the same tunnel;
8+
- **TLS** termination on public listeners plus optional mTLS between connector and edge; and
9+
- **Prometheus metrics**, including per-method gRPC observability decoded passively from the opaque relay.
810

9-
An **edge** process listens on public HTTP/TCP addresses. A private **connector** process dials NATS, queue-subscribes for configured routes, and opens the outbound data WebSocket to the edge. HTTP requests use MessagePack envelopes on `airpc.v1.route.<route>.unary`. Non-unary routes first use `airpc.v1.route.<route>.open` to select a connector, then relay frames on that connector's active tunnel.
11+
An **edge** process listens on public HTTP/TCP addresses. A private **connector** process dials NATS, queue-subscribes for configured routes, and opens the outbound data WebSocket to the edge. HTTP requests use MessagePack envelopes on `airpc.v1.route.<route>.unary`; cancels are published on `airpc.v1.cancel.<request_id>`. Non-unary routes first use `airpc.v1.route.<route>.open` to select a connector, then relay frames on that connector's active tunnel.
1012

11-
PKI/mTLS, Kubernetes manifests, SDKs, transparent proxying, and protocol-aware gRPC parsing are not implemented in this slice.
13+
SDK adapters, transparent proxying, and protocol-aware gRPC parsing are deliberately post-MVP (see `Known limitations`).
1214

1315
## Run locally
1416

@@ -36,32 +38,66 @@ The same edge process also starts:
3638

3739
WebSocket routes are served on the edge HTTP listener using `public_path` or `public_prefix` and are relayed as WebSocket messages to private `ws://` or `wss://` targets.
3840

39-
First-product `grpc` mode intentionally preserves the raw HTTP/2/TCP stream. It does not yet parse gRPC methods, metrics, statuses, or trailers.
41+
`grpc` mode intentionally preserves the raw HTTP/2/TCP stream — trailers, `*-bin` metadata, deadlines, cancellation, and bidirectional streaming all pass through untouched, because re-terminating gRPC is where proxies break in subtle ways. Protocol awareness is observation-only: see "Observability" below.
42+
43+
### HTTP unary, streaming responses, and cancellation
44+
45+
HTTP request bodies are read fully (bounded by `max_inline_request`) and carried in the NATS request. Responses are inlined in the NATS reply when they fit; when a response is chunked, an `text/event-stream`, or its declared length exceeds `max_inline_response`, the connector instead streams the body through an `http-stream` session on its data tunnel and the edge flushes chunks to the client as they arrive. Streamed bodies are not bound by the route `timeout` (which still bounds request dispatch and response headers). If the connector's tunnel is down, responses that fit inline still work; oversized ones fail.
46+
47+
When the public client disconnects or the route timeout expires before a reply, the edge publishes `airpc.v1.cancel.<request_id>` and the connector aborts the in-flight backend request.
48+
49+
### Data tunnel semantics
50+
51+
One WebSocket tunnel per connector multiplexes all stream sessions as MessagePack frames keyed by `session_id`.
52+
53+
- **Lossless, per-session flow control.** Each side may have at most 32 unacknowledged data frames (~1 MiB at the 32 KiB relay read size) in flight per session; the receiver returns credit with `window` frames as it consumes data. A slow client or backend backpressures only its own session — other sessions on the same tunnel keep flowing.
54+
- **TCP half-close propagation.** A client (or backend) write-side `FIN` is relayed as an `eof` frame and half-closes the other end, so request/half-close/response protocols work; the session ends when both directions have finished.
55+
- **Automatic tunnel reconnect.** The connector redials the edge data WebSocket with exponential backoff (250ms doubling to 5s) for the life of the process. While disconnected, the connector rejects route opens over NATS; the edge retries rejected opens every 50ms until the route timeout, so another connector in the queue group can take the session. Established sessions on a dropped tunnel are closed, not resumed. Edge shutdown closes tunnels explicitly so connectors notice immediately.
56+
- **Idle timeout.** Routes may set `idle_timeout`; a stream session with no relayed data in either direction for that long is closed (default: no idle timeout). WebSocket ping/pong keepalives are answered by each hop but are not relayed and do not count as activity.
57+
- **WebSocket close codes.** A close initiated by either end is relayed with its original status code and text, so clients and backends complete their close handshakes normally.
4058

4159
## Configuration
4260

4361
See [`examples/airpc.yaml`](examples/airpc.yaml). Important fields are:
4462

4563
- `nats.url`: default NATS server URL used by both edge and connector.
4664
- `nats.edge_url` / `nats.connector_url` (optional): role-specific NATS URLs, useful for separate NATS users and permissions in Compose or production.
65+
- `nats.creds_file` (optional): path to a NATS `.creds` file used for authentication. NATS connections reconnect indefinitely after the initial connect succeeds.
4766
- `edge.http_addr`: HTTP listen address for HTTP unary and public WebSocket routes.
4867
- `edge.data_addr`: WebSocket tunnel listen address for connector data sessions.
49-
- `connector.edge_data_url`: connector URL for `edge.data_addr`, normally `ws://<edge-data>/_airpc/data`.
68+
- `edge.metrics_addr` / `connector.metrics_addr` (optional): serve Prometheus metrics at `/metrics`. The endpoint is unauthenticated — bind it to a private interface.
69+
- `edge.tls` (optional): `cert_file`/`key_file` terminate TLS on the HTTP and data listeners; `client_ca_file` additionally requires a verified connector client certificate on the **data listener only** (mTLS). TLS 1.2 minimum.
70+
- `connector.edge_data_url`: connector URL for `edge.data_addr`, normally `ws://<edge-data>/_airpc/data` (`wss://` when `edge.tls` is set).
5071
- `connector.tunnel_token` (optional): shared token required on the data tunnel.
72+
- `connector.tls` (optional, requires a `wss://` data URL): `ca_file` trusts a private CA for the edge certificate; `cert_file`/`key_file` present a client certificate for mTLS; `server_name` overrides SNI.
5173
- `routes[].name`: route token used in NATS subjects.
5274
- `routes[].mode: http`: HTTP unary route. Uses `public_prefix`/`public_path`, URL `target`, inline body limits, `timeout`, and `forwarded_headers`.
5375
- `routes[].mode: websocket`: public WebSocket route. Uses `public_prefix`/`public_path` and private `ws://`/`wss://` `target`.
5476
- `routes[].mode: tcp`: public TCP listener. Uses `listen` and private `host:port` `target`.
5577
- `routes[].mode: grpc`: public TCP listener for opaque gRPC-over-HTTP/2. Uses `listen` and private `host:port` `target`.
78+
- `routes[].idle_timeout` (optional, stream modes): close a session after this long with no relayed data in either direction. Unset or `0` disables the idle check. Do not set it below the application's own keepalive interval — WebSocket ping/pong does not count as activity.
79+
- `routes[].tls` (optional, tcp/grpc only): serve this route's public listener with the `edge.tls` certificate. Leave unset for TLS-passthrough of protocols that bring their own TLS.
5680

5781
Derived NATS names:
5882

5983
- Unary subject: `airpc.v1.route.<route>.unary`
6084
- Open subject for stream routes: `airpc.v1.route.<route>.open`
6185
- Queue group: `airpc.route.<route>.connectors`
86+
- Cancel subject: `airpc.v1.cancel.<request_id>`
6287

6388
The edge strips hop-by-hop HTTP headers and only forwards HTTP request headers listed in `forwarded_headers`. Response hop-by-hop headers are stripped before writing back to the client. The runtime does not log request/response bodies or Authorization values.
6489

90+
## Observability
91+
92+
With `metrics_addr` set, edge and connector expose Prometheus metrics:
93+
94+
- `airpc_http_requests_total{route,status}` and `airpc_http_request_duration_seconds{route}` — HTTP unary at the edge.
95+
- `airpc_stream_sessions_total` / `airpc_stream_sessions_active{route,mode}` and `airpc_stream_bytes_total{route,direction}` — tcp/grpc/websocket sessions.
96+
- `airpc_tunnel_connected{connector_id}` and `airpc_tunnel_dials_total{connector_id,outcome}` — connector data-tunnel health.
97+
- `airpc_grpc_rpcs_total{route,method,code}` and `airpc_grpc_rpc_duration_seconds{route,method}` — per-method gRPC RPCs on `grpc` routes.
98+
99+
The gRPC metrics come from a **passive** HTTP/2 decoder on the relayed bytes: it extracts `:path` and `grpc-status` from frame headers without ever modifying, delaying, or re-terminating the stream. It is deliberately best-effort — if observation cannot keep up or the stream is not parseable plaintext HTTP/2 (e.g. TLS-passthrough where the client encrypts end-to-end), observation goes dark for that connection and traffic is unaffected. Bodies, header values, and message payloads are never recorded.
100+
65101
## Docker Compose bench
66102

67103
The Compose bench in [`deploy/compose.yaml`](deploy/compose.yaml) runs NATS, edge, connector, private HTTP/TCP/WebSocket/gRPC backends, and a public-only test runner on separated `public`, `broker`, and internal `private` networks. Run:
@@ -72,8 +108,28 @@ make e2e
72108

73109
The smoke flow checks normal traffic, public/edge inability to reach private backend addresses directly, connector-down failures, and recovery after restarting the connector.
74110

111+
## Kubernetes
112+
113+
[`deploy/k8s/`](deploy/k8s/) contains plain manifests: a ConfigMap holding `airpc.yaml`, an edge Deployment + Service (readiness on `/_airpc/healthz`), and a connector Deployment whose replicas join the route queue groups using their pod names as connector IDs. Edit the ConfigMap's routes and targets, point `connector.edge_data_url` at the edge Service (or its public address when connectors run in another cluster), and:
114+
115+
```sh
116+
kubectl apply -f deploy/k8s/
117+
```
118+
119+
For production, move `tunnel_token` (or the mTLS key material) into a Secret and expose the edge via LoadBalancer or an Ingress in front of the `http` port.
120+
121+
## Known limitations
122+
123+
These are deliberate post-MVP cuts, in line with the original design's build order:
124+
125+
- **HTTP request bodies are inline-only.** Request bodies are bounded by `max_inline_request`; client-streaming and bidirectional HTTP (e.g. gRPC over the `http` mode) are not supported — use the opaque `grpc`/`tcp` modes for those. Response trailers are not relayed.
126+
- **No gRPC re-termination.** gRPC is relayed opaquely by design; airpc observes methods/statuses for metrics but does not route per-method or rewrite gRPC semantics. Put a gRPC-aware L7 proxy (e.g. Envoy) in front of or behind airpc if per-method routing is required.
127+
- **No SDK adapters, forward/transparent proxy modes, or Kubernetes operator.** Endpoint replacement (URL/host:port/DNS) is the supported integration path; the manifests in `deploy/k8s/` are static.
128+
75129
## Development
76130

77131
```sh
78132
go test ./...
79133
```
134+
135+
`internal/integration` starts an in-process NATS server, edge, and connector, and covers the full surface end-to-end: HTTP unary, response streaming (SSE incrementality, oversized bodies, inline fallback), client-disconnect cancellation, lossless bulk transfer under backpressure, TCP half-close, per-session isolation of a stalled session, idle teardown, tunnel recovery after an edge restart, open retry past a tunnel-down connector, WebSocket close-code propagation, TLS/mTLS (HTTPS unary, TLS TCP route, wss tunnel with client certificates, rejection without one), and Prometheus metrics including gRPC method/status observation of a real grpc-go call through the relay.

examples/airpc.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
11
nats:
22
url: nats://127.0.0.1:4222
3+
# creds_file: /run/secrets/airpc.creds
34

45
edge:
56
http_addr: 127.0.0.1:8080
67
data_addr: 127.0.0.1:8081
8+
# Prometheus metrics at /metrics; unauthenticated, keep it private.
9+
# metrics_addr: 127.0.0.1:9090
10+
# Terminate TLS on the HTTP and data listeners; client_ca_file additionally
11+
# requires connector client certificates on the data listener (mTLS).
12+
# tls:
13+
# cert_file: /certs/edge.crt
14+
# key_file: /certs/edge.key
15+
# client_ca_file: /certs/ca.crt
716

817
connector:
918
edge_data_url: ws://127.0.0.1:8081/_airpc/data
1019
tunnel_token: dev-token
20+
# Required trust/identity for a wss:// edge_data_url with a private CA.
21+
# tls:
22+
# ca_file: /certs/ca.crt
23+
# cert_file: /certs/connector.crt
24+
# key_file: /certs/connector.key
1125

1226
routes:
1327
- name: demo
@@ -25,6 +39,9 @@ routes:
2539
mode: tcp
2640
listen: 127.0.0.1:7000
2741
target: 127.0.0.1:9001
42+
# Close sessions with no traffic in either direction for this long.
43+
# Omit (or 0) to keep idle sessions open indefinitely.
44+
idle_timeout: 10m
2845

2946
# WebSocket routes upgrade on edge.http_addr and relay messages to
3047
# private ws:// or wss:// targets.

0 commit comments

Comments
 (0)