Skip to content

Commit ac343af

Browse files
authored
Merge pull request #14 from mwfj/support-upstream-request-forwarding
Adds the core proxy engine
2 parents b255b0b + 54c8518 commit ac343af

46 files changed

Lines changed: 7993 additions & 253 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Makefile

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ HTTP2_SRCS = $(SERVER_DIR)/http2_session.cc $(SERVER_DIR)/http2_stream.cc $(SERV
7171
TLS_SRCS = $(SERVER_DIR)/tls_context.cc $(SERVER_DIR)/tls_connection.cc $(SERVER_DIR)/tls_client_context.cc
7272

7373
# Upstream connection pool sources
74-
UPSTREAM_SRCS = $(SERVER_DIR)/upstream_connection.cc $(SERVER_DIR)/pool_partition.cc $(SERVER_DIR)/upstream_host_pool.cc $(SERVER_DIR)/upstream_manager.cc
74+
UPSTREAM_SRCS = $(SERVER_DIR)/upstream_connection.cc $(SERVER_DIR)/pool_partition.cc $(SERVER_DIR)/upstream_host_pool.cc $(SERVER_DIR)/upstream_manager.cc $(SERVER_DIR)/header_rewriter.cc $(SERVER_DIR)/retry_policy.cc $(SERVER_DIR)/upstream_http_codec.cc $(SERVER_DIR)/http_request_serializer.cc $(SERVER_DIR)/proxy_transaction.cc $(SERVER_DIR)/proxy_handler.cc
7575

7676
# CLI layer sources
7777
CLI_SRCS = $(SERVER_DIR)/cli_parser.cc $(SERVER_DIR)/signal_handler.cc $(SERVER_DIR)/pid_file.cc $(SERVER_DIR)/daemonizer.cc
@@ -137,9 +137,9 @@ HTTP_HEADERS = $(LIB_DIR)/http/http_callbacks.h $(LIB_DIR)/http/http_connection_
137137
HTTP2_HEADERS = $(LIB_DIR)/http2/http2_callbacks.h $(LIB_DIR)/http2/http2_connection_handler.h $(LIB_DIR)/http2/http2_constants.h $(LIB_DIR)/http2/http2_session.h $(LIB_DIR)/http2/http2_stream.h $(LIB_DIR)/http2/protocol_detector.h
138138
WS_HEADERS = $(LIB_DIR)/ws/websocket_connection.h $(LIB_DIR)/ws/websocket_frame.h $(LIB_DIR)/ws/websocket_handshake.h $(LIB_DIR)/ws/websocket_parser.h $(LIB_DIR)/ws/utf8_validate.h
139139
TLS_HEADERS = $(LIB_DIR)/tls/tls_context.h $(LIB_DIR)/tls/tls_connection.h $(LIB_DIR)/tls/tls_client_context.h
140-
UPSTREAM_HEADERS = $(LIB_DIR)/upstream/upstream_manager.h $(LIB_DIR)/upstream/upstream_host_pool.h $(LIB_DIR)/upstream/pool_partition.h $(LIB_DIR)/upstream/upstream_connection.h $(LIB_DIR)/upstream/upstream_lease.h
140+
UPSTREAM_HEADERS = $(LIB_DIR)/upstream/upstream_manager.h $(LIB_DIR)/upstream/upstream_host_pool.h $(LIB_DIR)/upstream/pool_partition.h $(LIB_DIR)/upstream/upstream_connection.h $(LIB_DIR)/upstream/upstream_lease.h $(LIB_DIR)/upstream/upstream_http_codec.h $(LIB_DIR)/upstream/http_request_serializer.h $(LIB_DIR)/upstream/header_rewriter.h $(LIB_DIR)/upstream/retry_policy.h $(LIB_DIR)/upstream/proxy_transaction.h $(LIB_DIR)/upstream/proxy_handler.h $(LIB_DIR)/upstream/upstream_response.h $(LIB_DIR)/upstream/upstream_callbacks.h
141141
CLI_HEADERS = $(LIB_DIR)/cli/cli_parser.h $(LIB_DIR)/cli/signal_handler.h $(LIB_DIR)/cli/pid_file.h $(LIB_DIR)/cli/version.h $(LIB_DIR)/cli/daemonizer.h
142-
TEST_HEADERS = $(TEST_DIR)/test_framework.h $(TEST_DIR)/http_test_client.h $(TEST_DIR)/basic_test.h $(TEST_DIR)/stress_test.h $(TEST_DIR)/race_condition_test.h $(TEST_DIR)/timeout_test.h $(TEST_DIR)/config_test.h $(TEST_DIR)/http_test.h $(TEST_DIR)/websocket_test.h $(TEST_DIR)/tls_test.h $(TEST_DIR)/cli_test.h $(TEST_DIR)/http2_test.h $(TEST_DIR)/route_test.h $(TEST_DIR)/upstream_pool_test.h
142+
TEST_HEADERS = $(TEST_DIR)/test_framework.h $(TEST_DIR)/http_test_client.h $(TEST_DIR)/basic_test.h $(TEST_DIR)/stress_test.h $(TEST_DIR)/race_condition_test.h $(TEST_DIR)/timeout_test.h $(TEST_DIR)/config_test.h $(TEST_DIR)/http_test.h $(TEST_DIR)/websocket_test.h $(TEST_DIR)/tls_test.h $(TEST_DIR)/cli_test.h $(TEST_DIR)/http2_test.h $(TEST_DIR)/route_test.h $(TEST_DIR)/upstream_pool_test.h $(TEST_DIR)/proxy_test.h
143143

144144
# All headers combined
145145
HEADERS = $(CORE_HEADERS) $(CALLBACK_HEADERS) $(REACTOR_HEADERS) $(NETWORK_HEADERS) $(SERVER_HEADERS) $(THREAD_POOL_HEADERS) $(UTIL_HEADERS) $(FOUNDATION_HEADERS) $(HTTP_HEADERS) $(HTTP2_HEADERS) $(WS_HEADERS) $(TLS_HEADERS) $(UPSTREAM_HEADERS) $(CLI_HEADERS) $(TEST_HEADERS)
@@ -224,6 +224,11 @@ test_upstream: $(TARGET)
224224
@echo "Running upstream connection pool tests only..."
225225
./$(TARGET) upstream
226226

227+
# Run only proxy engine tests
228+
test_proxy: $(TARGET)
229+
@echo "Running proxy engine tests only..."
230+
./$(TARGET) proxy
231+
227232
# Display help information
228233
help:
229234
@echo "Reactor Server C++ - Makefile Help"
@@ -304,4 +309,4 @@ help:
304309
# Build only the production server binary
305310
server: $(SERVER_TARGET)
306311

307-
.PHONY: all clean test server test_basic test_stress test_race test_config test_http test_ws test_tls test_cli test_http2 test_upstream help
312+
.PHONY: all clean test server test_basic test_stress test_race test_config test_http test_ws test_tls test_cli test_http2 test_upstream test_proxy help

docs/configuration.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,80 @@ Upstream connection pools are configured via the `upstreams` array in the JSON c
201201

202202
**Note:** Upstream configuration changes require a server restart — pools are built once during `Start()` and cannot be rebuilt at runtime.
203203

204+
### Proxy Route Configuration
205+
206+
Each upstream entry may include an optional `proxy` section to auto-register a proxy route that forwards matching requests to the backend. When `proxy.route_prefix` is non-empty, `HttpServer::Start()` registers the route automatically — no handler code is needed.
207+
208+
```json
209+
{
210+
"upstreams": [
211+
{
212+
"name": "api-backend",
213+
"host": "10.0.1.5",
214+
"port": 8080,
215+
"pool": { "max_connections": 64 },
216+
"proxy": {
217+
"route_prefix": "/api/v1",
218+
"strip_prefix": true,
219+
"response_timeout_ms": 5000,
220+
"methods": ["GET", "POST", "PUT", "DELETE"],
221+
"header_rewrite": {
222+
"set_x_forwarded_for": true,
223+
"set_x_forwarded_proto": true,
224+
"set_via_header": true,
225+
"rewrite_host": true
226+
},
227+
"retry": {
228+
"max_retries": 2,
229+
"retry_on_connect_failure": true,
230+
"retry_on_5xx": false,
231+
"retry_on_timeout": false,
232+
"retry_on_disconnect": true,
233+
"retry_non_idempotent": false
234+
}
235+
}
236+
}
237+
]
238+
}
239+
```
240+
241+
**Proxy fields** (`proxy.*`):
242+
243+
| Field | Default | Description |
244+
|-------|---------|-------------|
245+
| `route_prefix` | "" | Route pattern to match (empty = disabled). Supports full pattern syntax: `/api/v1`, `/api/:version/*path`, `/users/:id([0-9]+)`. Patterns ending in `/*rest` match anything under the prefix. |
246+
| `strip_prefix` | false | When `true`, strip the static portion of `route_prefix` before forwarding. Example: `route_prefix="/api/v1"`, `strip_prefix=true` → client `GET /api/v1/users/123` reaches upstream as `GET /users/123`. |
247+
| `response_timeout_ms` | 30000 | Max time to wait for upstream response headers after the request is fully sent. **Must be `0` or `>= 1000`** (timer scan has 1 s resolution). `0` disables the per-request deadline and lifts the async safety cap for this request only — use with caution, long-running handlers still respect the server-wide `max_async_deferred_sec_`. |
248+
| `methods` | `[]` | Methods to proxy. Empty array means all methods. Methods listed here are auto-registered on the route; conflicts with any user-registered async route on the same `(method, pattern)` are detected at `Start()` and raise `std::invalid_argument`. |
249+
250+
**Proxy header rewrite fields** (`proxy.header_rewrite.*`):
251+
252+
| Field | Default | Description |
253+
|-------|---------|-------------|
254+
| `set_x_forwarded_for` | true | Append the client IP to `X-Forwarded-For` (preserves any upstream chain) |
255+
| `set_x_forwarded_proto` | true | Set `X-Forwarded-Proto` to `http` or `https` based on the client connection |
256+
| `set_via_header` | true | Add the server's `Via` header per RFC 7230 §5.7.1 |
257+
| `rewrite_host` | true | Rewrite the outgoing `Host` header to the upstream's authority (off = forward client's Host verbatim) |
258+
259+
Hop-by-hop headers listed in RFC 7230 §6.1 (`Connection`, `Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `TE`, `Trailers`, `Transfer-Encoding`, `Upgrade`) are always stripped from both the outgoing request and the returned response.
260+
261+
**Proxy retry fields** (`proxy.retry.*`):
262+
263+
| Field | Default | Description |
264+
|-------|---------|-------------|
265+
| `max_retries` | 0 | Max retry attempts (0 = no retries). Backoff is jittered exponential (25 ms base, 250 ms cap). |
266+
| `retry_on_connect_failure` | true | Retry when the pool checkout fails to establish a TCP/TLS connection |
267+
| `retry_on_5xx` | false | Retry when the upstream returns a 5xx response (headers only — once the body starts streaming to the client, retries stop) |
268+
| `retry_on_timeout` | false | Retry when the response deadline fires before headers arrive |
269+
| `retry_on_disconnect` | true | Retry when the upstream closes the connection before any response bytes are sent to the client |
270+
| `retry_non_idempotent` | false | Allow retries on POST/PATCH/DELETE (dangerous — can duplicate side effects; default safe methods only) |
271+
272+
**Notes:**
273+
274+
- Retries never fire after any response bytes have been sent to the downstream client.
275+
- `proxy.route_prefix` conflicts — two upstreams auto-registering the same pattern, or an upstream conflicting with a user-registered async route on the same `(method, pattern)` — are rejected at `Start()` with `std::invalid_argument`.
276+
- The proxy engine is built on the async route framework: per-request deadlines, client abort propagation, and pool checkout cancellation are all handled by `ProxyTransaction::Cancel()`. See [docs/http.md](http.md) for the programmatic API.
277+
204278
### Validation
205279

206280
`ConfigLoader::Validate()` checks:

docs/http.md

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,90 @@ The handler receives a const request reference and a completion callback. Call `
189189
- **Thread safety** — the completion callback MUST be invoked on the dispatcher thread that owns the connection. Upstream pool `CheckoutAsync` naturally routes callbacks to the correct dispatcher.
190190
- **HTTP/2 support** — async routes work identically for H2 streams; the framework binds `SubmitStreamResponse` internally
191191

192+
## Proxy Routes
193+
194+
Proxy routes forward client requests to an upstream backend service. They are built on top of the async-route framework and require a matching `upstreams[]` entry in the server config so the connection pool, TLS client context, and retry/header policies exist. See [docs/configuration.md](configuration.md#proxy-route-configuration) for the full set of config fields.
195+
196+
### Auto-registration from config
197+
198+
The simplest way to use a proxy route is to set `proxy.route_prefix` in the upstream config. `HttpServer::Start()` walks every upstream with a non-empty `route_prefix` and registers the route automatically — no application code required.
199+
200+
```json
201+
{
202+
"upstreams": [
203+
{
204+
"name": "api-backend",
205+
"host": "10.0.1.5",
206+
"port": 8080,
207+
"pool": { "max_connections": 64 },
208+
"proxy": {
209+
"route_prefix": "/api/v1/*rest",
210+
"strip_prefix": true,
211+
"methods": ["GET", "POST", "PUT", "DELETE"]
212+
}
213+
}
214+
]
215+
}
216+
```
217+
218+
Any `GET/POST/PUT/DELETE` under `/api/v1/` is forwarded to `api-backend`, with the `/api/v1` prefix stripped before forwarding (so upstream sees `/users/123` instead of `/api/v1/users/123`).
219+
220+
### Programmatic registration
221+
222+
Applications that construct their own config in code can use `HttpServer::Proxy()`:
223+
224+
```cpp
225+
#include "http/http_server.h"
226+
227+
HttpServer server(config);
228+
229+
// Register a proxy route on an already-configured upstream.
230+
// Reuses the proxy fields (methods, strip_prefix, header_rewrite, retry,
231+
// response_timeout_ms) from config.upstreams[i].proxy — only route_prefix
232+
// is overridden by the first argument.
233+
server.Proxy("/api/v1/*rest", "api-backend");
234+
235+
server.Start();
236+
```
237+
238+
`Proxy()` calls must happen before `Start()`. Calling it afterwards — or naming an upstream that is not in the config — raises `std::invalid_argument`.
239+
240+
### HEAD precedence and companion methods
241+
242+
Proxy registrations interact with the HEAD-fallback rule from [Route Matching](#route-matching) as follows:
243+
244+
- **Paired HEAD + GET on the same registration** (both in `methods`): HEAD goes to the proxy, GET goes to the proxy. No fallback.
245+
- **HEAD only** (no GET in `methods`): HEAD is registered as a proxy *default*. If a user async handler later registers GET on the same pattern, the router uses the user's GET for HEAD fallback and silently drops the proxy HEAD. This prevents accidental conflicts between library-provided proxies and application-defined GETs.
246+
- **Companion methods**: If a proxy registers `OPTIONS` for a pattern that also has a user-registered async GET, the router marks the proxy pattern as a *companion*. At dispatch time, if the companion proxy route wins (e.g. for a non-matching method), it yields to the user handler via a runtime decision rather than a registration-time rejection — because the conflict is method-level and only detectable per-request.
247+
- Per-`(method, pattern)` conflict markers are stored separately so two proxies registering disjoint methods on the same pattern do not contaminate each other's HEAD pairing.
248+
249+
### Request lifecycle and client abort
250+
251+
Each proxy request is handled by a per-request `ProxyTransaction`:
252+
253+
1. `CHECKOUT_PENDING` — wait for an idle pooled connection (or open a new one, subject to `pool.max_connections`)
254+
2. `SENDING_REQUEST` — serialize and write the HTTP/1.1 request, with header rewriting applied
255+
3. `AWAITING_RESPONSE` — wait for response headers (bounded by `proxy.response_timeout_ms`)
256+
4. `RECEIVING_BODY` — stream the body back to the client
257+
5. `COMPLETE` / `FAILED` — return the connection to the pool or discard it
258+
259+
If the client disconnects mid-request, the framework's async-abort hook calls `ProxyTransaction::Cancel()`, which:
260+
261+
- Sets a `cancelled_` flag guarding every callback entry point
262+
- Signals the pool wait-queue via a shared cancel token so `PoolPartition` can purge the dead entry
263+
- Poisons the upstream connection (`MarkClosing()`) if any bytes have already been written — retrying a partially-sent request on a reused connection is unsafe
264+
- Returns the connection to the pool (or destroys it) without further I/O
265+
266+
### Response timeouts and the async safety cap
267+
268+
`proxy.response_timeout_ms` is the hard deadline for receiving response headers after the request is fully sent. Its valid values are:
269+
270+
- **`>= 1000`** — normal case. The deadline is armed when the request is flushed and cleared when headers arrive. If it fires, the transaction retries (if policy allows) or responds with 504.
271+
- **`0`** — disables the per-request deadline *and* disables the server-wide async safety cap (`max_async_deferred_sec_`) for this request only. The `ProxyHandler` sets `request.async_cap_sec_override = 0` before dispatching. Use this only for intentionally long-polling backends; normal requests should keep a bounded timeout.
272+
- **Other positive values below 1000** — rejected at config load (the 1 s floor matches the timer scan resolution).
273+
274+
Retries are bounded by `proxy.retry.max_retries` and never fire after any response bytes have reached the client. See [docs/configuration.md](configuration.md#proxy-route-configuration) for the full retry matrix.
275+
192276
## Middleware
193277
194278
```cpp
@@ -254,8 +338,10 @@ Proxied requests using absolute-form URIs (`GET http://example.com/foo HTTP/1.1`
254338
### Builder Pattern
255339
256340
```cpp
257-
// Chained builder
258-
HttpResponse().Status(200).Header("X-Custom", "value").Json(R"({"ok":true})")
341+
#include "http/http_status.h"
342+
343+
// Chained builder — use HttpStatus::* constants (see include/http/http_status.h)
344+
HttpResponse().Status(HttpStatus::OK).Header("X-Custom", "value").Json(R"({"ok":true})")
259345
260346
// Content type helpers
261347
res.Json(json_string); // Sets Content-Type: application/json
@@ -278,7 +364,9 @@ res.Body(data, "image/png"); // Custom content type
278364
| `PayloadTooLarge()` | 413 | Body exceeds limit |
279365
| `HeaderTooLarge()` | 431 | Headers exceed limit |
280366
| `InternalError(msg)` | 500 | Server error |
367+
| `BadGateway()` | 502 | Upstream unreachable |
281368
| `ServiceUnavailable()` | 503 | Overloaded |
369+
| `GatewayTimeout()` | 504 | Upstream timeout |
282370
| `HttpVersionNotSupported()` | 505 | Non-1.x HTTP version |
283371

284372
### Header Behavior

include/config/server_config.h

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,16 +61,87 @@ struct UpstreamPoolConfig {
6161
bool operator!=(const UpstreamPoolConfig& o) const { return !(*this == o); }
6262
};
6363

64+
struct ProxyHeaderRewriteConfig {
65+
bool set_x_forwarded_for = true; // Append client IP to X-Forwarded-For
66+
bool set_x_forwarded_proto = true; // Set X-Forwarded-Proto
67+
bool set_via_header = true; // Add Via header
68+
bool rewrite_host = true; // Rewrite Host to upstream address
69+
70+
bool operator==(const ProxyHeaderRewriteConfig& o) const {
71+
return set_x_forwarded_for == o.set_x_forwarded_for &&
72+
set_x_forwarded_proto == o.set_x_forwarded_proto &&
73+
set_via_header == o.set_via_header &&
74+
rewrite_host == o.rewrite_host;
75+
}
76+
bool operator!=(const ProxyHeaderRewriteConfig& o) const { return !(*this == o); }
77+
};
78+
79+
struct ProxyRetryConfig {
80+
int max_retries = 0; // 0 = no retries
81+
bool retry_on_connect_failure = true; // Retry when pool checkout connect fails
82+
bool retry_on_5xx = false; // Retry on 5xx response from upstream
83+
bool retry_on_timeout = false; // Retry on response timeout
84+
bool retry_on_disconnect = true; // Retry when upstream closes mid-response
85+
bool retry_non_idempotent = false; // Retry POST/PATCH/DELETE (dangerous)
86+
87+
bool operator==(const ProxyRetryConfig& o) const {
88+
return max_retries == o.max_retries &&
89+
retry_on_connect_failure == o.retry_on_connect_failure &&
90+
retry_on_5xx == o.retry_on_5xx &&
91+
retry_on_timeout == o.retry_on_timeout &&
92+
retry_on_disconnect == o.retry_on_disconnect &&
93+
retry_non_idempotent == o.retry_non_idempotent;
94+
}
95+
bool operator!=(const ProxyRetryConfig& o) const { return !(*this == o); }
96+
};
97+
98+
struct ProxyConfig {
99+
// Response timeout: max time to wait for upstream response headers
100+
// after request is fully sent. 0 = disabled (no deadline). Otherwise
101+
// must be >= 1000 (timer scan has 1s resolution).
102+
int response_timeout_ms = 30000; // 30 seconds
103+
104+
// Route pattern prefix to match (e.g., "/api/users")
105+
// Supports the existing pattern syntax: "/api/:version/users/*path"
106+
std::string route_prefix;
107+
108+
// Strip the route prefix before forwarding to upstream.
109+
// Example: route_prefix="/api/v1", strip_prefix=true
110+
// client: GET /api/v1/users/123 -> upstream: GET /users/123
111+
// When false: upstream sees the full original path.
112+
bool strip_prefix = false;
113+
114+
// Methods to proxy. Empty = all methods.
115+
std::vector<std::string> methods;
116+
117+
// Header rewriting configuration
118+
ProxyHeaderRewriteConfig header_rewrite;
119+
120+
// Retry policy configuration
121+
ProxyRetryConfig retry;
122+
123+
bool operator==(const ProxyConfig& o) const {
124+
return response_timeout_ms == o.response_timeout_ms &&
125+
route_prefix == o.route_prefix &&
126+
strip_prefix == o.strip_prefix &&
127+
methods == o.methods &&
128+
header_rewrite == o.header_rewrite &&
129+
retry == o.retry;
130+
}
131+
bool operator!=(const ProxyConfig& o) const { return !(*this == o); }
132+
};
133+
64134
struct UpstreamConfig {
65135
std::string name;
66136
std::string host;
67137
int port = 80;
68138
UpstreamTlsConfig tls;
69139
UpstreamPoolConfig pool;
140+
ProxyConfig proxy;
70141

71142
bool operator==(const UpstreamConfig& o) const {
72143
return name == o.name && host == o.host && port == o.port &&
73-
tls == o.tls && pool == o.pool;
144+
tls == o.tls && pool == o.pool && proxy == o.proxy;
74145
}
75146
bool operator!=(const UpstreamConfig& o) const { return !(*this == o); }
76147
};

0 commit comments

Comments
 (0)