Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ TCP accept (SO_REUSEPORT per worker thread)
| `src/http_proxy.rs` | HTTP/1.1 header framing and rewrite helpers shared by the HTTP listener |
| `src/sni.rs` | MSG_PEEK ClientHello parser; SNI extraction; JA3 fingerprint |
| `src/router.rs` | RouteTable (exact + wildcard HashMap); ArcSwap hot-swap |
| `src/upstream.rs` | UpstreamStream enum (Tcp/Uds); connect(); TCP_NODELAY |
| `src/upstream.rs` | UpstreamStream enum (Tcp/Uds); connect(); TCP_NODELAY; PROXY v1/v2 header encoders; HTTP header injection (XFF, X-JA3/X-JA4) |
| `src/balancer.rs` | UdsBalancer: AtomicU32 least-connections; IP affinity DashMap |
| `src/tls.rs` | rustls ServerConfig builder; SHA-256 deduplication cache |
| `src/mtls.rs` | SymphonyClientVerifier wrapping WebPkiClientVerifier |
Expand All @@ -56,6 +56,21 @@ TCP accept (SO_REUSEPORT per worker thread)
### MSG_PEEK for SNI/JA3
A single `stream.peek(&mut buf[..512])` reads the ClientHello without consuming any bytes. Cost: 1 syscall, 512-byte stack buffer, zero heap allocation. This gives us both the SNI (for routing) and the JA3 fingerprint (for protection) before the TLS handshake begins. The alternative — a custom TLS acceptor that extracts SNI internally — would require modifying rustls internals.

### Source-address & fingerprint forwarding (`upstream.rs` + `proxy_conn.rs`)
Per route, `sourceAddressHeader` picks how the client IP reaches the upstream: PROXY v1 (text),
PROXY **v2** (binary, TLV-capable), `X-Forwarded-For` injection, or none. `forwardFingerprint`
(`ja3`/`ja4`/`none`) additionally forwards the ClientHello fingerprint symphony already computes
in `sni.rs`, so backends can make their own bot/abuse decisions on it. Carrier follows the mode:
a PROXY v2 TLV (custom types `0xE0`=JA3 / `0xE1`=JA4, in HAProxy's `0xE0–0xEF` private range) under
`proxyProtocolV2` — which works even in passthrough since it prefixes the raw TLS bytes — otherwise
an injected `X-JA3`/`X-JA4` header. Header injection is gated on `l7_http1` (terminated TLS *and* a
non-h2 negotiated ALPN); it's a no-op in passthrough or on an h2 upstream, so text is never spliced
into TLS ciphertext or an h2 frame stream. `inject_request_headers` also strips any client-supplied
copy of an injected header so the value is authoritative (anti-spoof). `proxy_conn.rs` threads the
fingerprint + the connection's `local_addr` (the PROXY v2 destination) into `apply_source_header`.
Keep v2 **opt-in**: HAProxy/nginx speak it, but Harper core's own UDS reader currently parses v1 only,
so the UDS default stays v1.

### ArcSwap for RouteTable
`ArcSwap<RouteTable>` gives us a pointer-swap on writes (single atomic store) and a single `load()` on reads — no lock contention on the hot path. With ≤100 routes, rebuilding the full table on `updateConfig` costs ~microseconds (Arc pointer clones only). A partial-update scheme would be more complex without meaningful benefit.

Expand Down
45 changes: 42 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ console.log('proxy listening on :443');
| `maxConnectionsPerSecond` | `number` | — | Route-wide new-connection rate cap (token bucket). Connections are silently dropped when exhausted. |
| `burst` | `number` | `maxConnectionsPerSecond` | Token bucket burst ceiling for the route rate limit |
| `http2` | `boolean` | `false` | Advertise `h2` in ALPN so clients negotiate HTTP/2. Raw H2 frames flow through to the upstream unchanged. Requires `terminateTls: true`. |
| `sourceAddressHeader` | `'proxyProtocol' \| 'xForwardedFor' \| 'none'` | `'proxyProtocol'` for UDS, `'none'` for TCP | How the real client IP is forwarded to the upstream. See [Source address forwarding](#source-address-forwarding). |
| `sourceAddressHeader` | `'proxyProtocol' \| 'proxyProtocolV2' \| 'xForwardedFor' \| 'none'` | `'proxyProtocol'` for UDS, `'none'` for TCP | How the real client IP is forwarded to the upstream. See [Source address forwarding](#source-address-forwarding). |
| `forwardFingerprint` | `'ja3' \| 'ja4' \| 'none'` | `'none'` | Forward the client TLS fingerprint downstream. See [Forwarding the fingerprint](#forwarding-the-fingerprint-downstream). |

### `Upstream`

Expand Down Expand Up @@ -344,11 +345,12 @@ Connections that exceed the limit are silently dropped (TCP RST). This is a glob

## Source address forwarding

Use `sourceAddressHeader` on a route to control how the real client IP is communicated to the upstream. This only applies when `terminateTls: true` (TLS is terminated by the proxy).
Use `sourceAddressHeader` on a route to control how the real client IP is communicated to the upstream. The PROXY protocol carriers work whether or not TLS is terminated (they prefix the connection); `'xForwardedFor'` requires `terminateTls: true` and a plaintext HTTP/1 upstream, since it rewrites the HTTP request.

| Value | Behaviour |
|---|---|
| `'proxyProtocol'` | Sends a PROXY protocol v1 header (`PROXY TCP4 <src-ip> <dst-ip> <src-port> 0\r\n`) before any application data. Default for UDS upstreams. |
| `'proxyProtocol'` | Sends a PROXY protocol v1 (text) header (`PROXY TCP4 <src-ip> <dst-ip> <src-port> 0\r\n`) before any application data. Default for UDS upstreams. |
| `'proxyProtocolV2'` | Sends a PROXY protocol v2 (binary) header before any application data. v2 adds a TLV section — the carrier for `forwardFingerprint` below. Keep it opt-in: the consumer must speak v2 (nginx/HAProxy do; Harper core's own UDS reader currently parses v1 only). |
| `'xForwardedFor'` | Reads the first chunk of the HTTP request, inserts an `X-Forwarded-For` header after the request line, then copies the rest verbatim. No per-request parsing overhead for keep-alive connections. Default for TCP upstreams (disabled). |
| `'none'` | Does not forward source address information. Default for TCP upstreams. |

Expand Down Expand Up @@ -392,6 +394,43 @@ Bun.serve({
});
```

### Forwarding the fingerprint downstream

symphony computes the client's JA3/JA4 fingerprint from the ClientHello (the same value used for `ja3Blocklist`/`ja4Blocklist`). Set `forwardFingerprint` to also hand it to the upstream, so a backend behind symphony can make its own bot/abuse decisions on it.

| `forwardFingerprint` | Value forwarded |
|---|---|
| `'ja3'` | The JA3 MD5 hex (32 chars) |
| `'ja4'` | The JA4 fingerprint |
| `'none'` (default) | Nothing forwarded |

The **carrier depends on `sourceAddressHeader`**:

- With `'proxyProtocolV2'`, the fingerprint rides a PROXY v2 **TLV** — type `0xE0` for JA3, `0xE1` for JA4 (in HAProxy's `0xE0–0xEF` private range). This works even in passthrough (`terminateTls: false`), since the header prefixes the raw TLS bytes.
- Otherwise, symphony injects an **`X-JA3` / `X-JA4` HTTP header**. This requires a plaintext HTTP/1 upstream (`terminateTls: true` and not `http2`); it is skipped for passthrough or HTTP/2 upstreams (use `'proxyProtocolV2'` there). Any client-supplied `X-JA3`/`X-JA4` is stripped so the injected value is authoritative and can't be spoofed.

```typescript
// TLV carrier — works for any upstream that speaks PROXY v2, including passthrough
{
sni: 'app.example.com',
upstreams: [{ kind: 'tcp', host: '10.0.0.5', port: 8080 }],
terminateTls: true,
cert: { certChain, privateKey },
sourceAddressHeader: 'proxyProtocolV2',
forwardFingerprint: 'ja4',
}

// HTTP-header carrier — for HTTP/1 backends that read request headers
{
sni: 'app.example.com',
upstreams: [{ kind: 'uds', path: '/run/app/worker.sock' }],
terminateTls: true,
cert: { certChain, privateKey },
sourceAddressHeader: 'xForwardedFor',
forwardFingerprint: 'ja3', // upstream reads X-JA3 alongside X-Forwarded-For
}
```

---

## Protection
Expand Down
Loading
Loading