plecto-server 0.15.2

Plecto's fast path: an L7 reverse proxy / API gateway data plane (HTTP/1.1, HTTP/2, HTTP/3, TLS, routing, load balancing).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//! E2E (tdd-workflow Phase 0) for the M2 fast path (ADR 000013): drive real HTTP/1.1 requests
//! through a running `plecto-server` and assert the client-visible behaviour — routing by host +
//! path prefix, the route's filter chain (continue / short-circuit / response-edit), host-native
//! prefix strip on the forwarded path, and a 404 when no route matches. A fake hyper upstream
//! echoes the path it received so the strip is observable; filter-hello supplies the chain.

use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::Arc;

use bytes::Bytes;
use http_body_util::{BodyExt, Empty, Full};
use hyper::body::Incoming;
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode};
use hyper_util::client::legacy::Client;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::rt::{TokioExecutor, TokioIo};
use tokio::net::TcpListener;

use plecto_control::{Control, Host, Manifest, MemoryStore, ResolvedArtifact};
use plecto_host::test_support::{
    TestSigner, bound_sbom, filter_hello_component, filter_noop_component,
};
use plecto_server::serve;

/// A fake upstream: returns 200 with the path it received (`x-upstream-path`), an `x-from:
/// upstream` marker, an `x-plecto-respedit` header (so the response chain has something to act
/// on), and a fixed body. Lets a test prove the request reached the upstream and on what path.
async fn echo(req: Request<Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
    let path = req
        .uri()
        .path_and_query()
        .map(|p| p.as_str().to_string())
        .unwrap_or_default();
    // Reflect the inbound `traceparent` so a test can prove the proxy continued the caller's trace
    // (review f000005 P1#2) rather than minting a fresh root.
    let traceparent = req
        .headers()
        .get("traceparent")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("")
        .to_string();
    // Reflect the forwarding headers the proxy set, so a test can prove edge-model overwrite
    // (review f000005 P2#3): the upstream sees Plecto's values, not the client's spoof.
    let xff = req
        .headers()
        .get("x-forwarded-for")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("")
        .to_string();
    let xfp = req
        .headers()
        .get("x-forwarded-proto")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("")
        .to_string();
    // `X-Real-IP` is re-issued by Plecto (the de-facto convention); `CF-Connecting-IP` is part of the
    // stripped client-IP family but NOT re-issued — so a test can prove both halves of the edge
    // model (ADR 000022): the re-issued header carries the real peer, the dropped one is empty.
    let xrealip = req
        .headers()
        .get("x-real-ip")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("")
        .to_string();
    let cfip = req
        .headers()
        .get("cf-connecting-ip")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("")
        .to_string();
    Ok(Response::builder()
        .status(200)
        .header("x-upstream-path", path)
        .header("x-upstream-traceparent", traceparent)
        .header("x-upstream-xff", xff)
        .header("x-upstream-xfproto", xfp)
        .header("x-upstream-xrealip", xrealip)
        .header("x-upstream-cfip", cfip)
        .header("x-from", "upstream")
        .header("x-plecto-respedit", "1")
        .body(Full::new(Bytes::from_static(b"upstream-ok")))
        .unwrap())
}

async fn spawn_upstream() -> SocketAddr {
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move {
        loop {
            let (stream, _) = listener.accept().await.unwrap();
            tokio::spawn(async move {
                let _ = hyper::server::conn::http1::Builder::new()
                    .serve_connection(TokioIo::new(stream), service_fn(echo))
                    .await;
            });
        }
    });
    addr
}

async fn spawn_proxy(control: Arc<Control>) -> SocketAddr {
    let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move {
        let _ = serve(control, listener).await;
    });
    addr
}

/// Build a control plane: filter-hello signed + loaded as a trusted filter, a route `/api`
/// (strip `/api`) → that chain → the given upstream address.
fn control_for(upstream_addr: SocketAddr) -> Arc<Control> {
    let component = filter_hello_component();
    let signer = TestSigner::new().unwrap();
    let component_signature = signer.sign(&component).unwrap();
    let sbom = bound_sbom(&component);
    let sbom_signature = signer.sign(&sbom).unwrap();
    let mut store = MemoryStore::new();
    let digest = store.insert(
        "fh",
        ResolvedArtifact {
            component,
            component_signature,
            sbom,
            sbom_signature,
        },
    );
    let toml = format!(
        r#"
[[filter]]
id = "fh"
source = "fh"
digest = "{digest}"
isolation = "trusted"

[[upstream]]
name = "echo"
addresses = ["{upstream_addr}"]
[upstream.health]
path = "/healthz"
interval_ms = 50

[[route]]
filters = ["fh"]
upstream = "echo"
strip_prefix = "/api"
[route.match]
path_prefix = "/api"
"#
    );
    let manifest = Manifest::from_toml(&toml).unwrap();
    let host = Host::new(signer.trust_policy().unwrap()).unwrap();
    Arc::new(Control::load(host, &manifest, Box::new(store)).unwrap())
}

fn client() -> Client<HttpConnector, Empty<Bytes>> {
    Client::builder(TokioExecutor::new()).build_http()
}

/// Send a GET through the proxy; return (status, response headers, body string).
async fn get(
    client: &Client<HttpConnector, Empty<Bytes>>,
    proxy: SocketAddr,
    path: &str,
    headers: &[(&str, &str)],
) -> (StatusCode, hyper::HeaderMap, String) {
    let mut builder = Request::builder()
        .method("GET")
        .uri(format!("http://{proxy}{path}"));
    for (n, v) in headers {
        builder = builder.header(*n, *v);
    }
    let resp = client
        .request(builder.body(Empty::<Bytes>::new()).unwrap())
        .await
        .expect("proxy request");
    let (parts, body) = resp.into_parts();
    let bytes = body.collect().await.unwrap().to_bytes();
    (
        parts.status,
        parts.headers,
        String::from_utf8_lossy(&bytes).into_owned(),
    )
}

/// Wait until the upstream's first health probe lands (ADR 000017): instances start pessimistic, so
/// a forward returns 503 until a probe passes. Poll a forwarding path until it stops being 503.
async fn wait_ready(client: &Client<HttpConnector, Empty<Bytes>>, proxy: SocketAddr) {
    for _ in 0..100 {
        let (status, _, _) = get(client, proxy, "/api/__ready", &[]).await;
        if status != StatusCode::SERVICE_UNAVAILABLE {
            return;
        }
        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    }
    panic!("upstream never became healthy within the readiness window");
}

#[tokio::test]
async fn routes_runs_chain_strips_prefix_and_forwards() {
    let upstream = spawn_upstream().await;
    let proxy = spawn_proxy(control_for(upstream)).await;
    let client = client();
    wait_ready(&client, proxy).await;

    let (status, headers, body) = get(&client, proxy, "/api/hello", &[]).await;

    assert_eq!(status, StatusCode::OK, "an unblocked request forwards 200");
    assert_eq!(body, "upstream-ok", "the upstream body streams through");
    assert_eq!(
        headers.get("x-from").and_then(|v| v.to_str().ok()),
        Some("upstream"),
        "the response came from the upstream (the chain continued)"
    );
    assert_eq!(
        headers.get("x-upstream-path").and_then(|v| v.to_str().ok()),
        Some("/hello"),
        "host-native strip_prefix removed /api before forwarding"
    );
    assert!(
        headers.contains_key("x-plecto-respadded"),
        "the response-side chain ran and applied the filter's response edit"
    );
}

#[tokio::test]
async fn short_circuit_never_reaches_upstream() {
    let upstream = spawn_upstream().await;
    let proxy = spawn_proxy(control_for(upstream)).await;
    let client = client();

    let (status, headers, body) =
        get(&client, proxy, "/api/hello", &[("x-plecto-block", "1")]).await;

    assert_eq!(status, StatusCode::FORBIDDEN, "a blocked request gets 403");
    assert_eq!(
        body, "blocked by filter-hello",
        "the filter synthesised the body"
    );
    assert!(
        !headers.contains_key("x-from"),
        "a short-circuit must not reach the upstream"
    );
}

#[tokio::test]
async fn unmatched_route_is_404() {
    let upstream = spawn_upstream().await;
    let proxy = spawn_proxy(control_for(upstream)).await;
    let client = client();

    let (status, headers, _body) = get(&client, proxy, "/nope", &[]).await;

    assert_eq!(status, StatusCode::NOT_FOUND, "no matching route → 404");
    assert_eq!(
        headers.get("x-plecto-fault").and_then(|v| v.to_str().ok()),
        Some("no-route"),
    );
}

#[tokio::test]
async fn continues_inbound_trace_context() {
    // review f000005 P1#2 / ADR 000009: a request carrying a W3C `traceparent` must have its trace
    // CONTINUED through Plecto — the traceparent forwarded upstream keeps the same trace-id, not a
    // freshly-minted root. (Before the fix `proxy_core` always called `snapshot()` = a new root.)
    let upstream = spawn_upstream().await;
    let proxy = spawn_proxy(control_for(upstream)).await;
    let client = client();
    wait_ready(&client, proxy).await;

    let inbound = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
    let (status, headers, _body) =
        get(&client, proxy, "/api/hello", &[("traceparent", inbound)]).await;

    assert_eq!(status, StatusCode::OK);
    let forwarded = headers
        .get("x-upstream-traceparent")
        .and_then(|v| v.to_str().ok())
        .expect("the upstream received a traceparent");
    let trace_id = forwarded.split('-').nth(1).unwrap_or("");
    assert_eq!(
        trace_id, "4bf92f3577b34da6a3ce929d0e0e4736",
        "the inbound trace-id is preserved (the caller's trace continues, not a new root)"
    );
}

#[tokio::test]
async fn overwrites_spoofed_forwarded_headers_with_the_real_peer() {
    // review f000005 P2#3 / ADR 000018 + 000022: a client cannot forge its source IP. The whole
    // de-facto client-IP family is stripped — `X-Forwarded-For` AND `X-Real-IP` AND CDN headers
    // like `CF-Connecting-IP` — then `X-Forwarded-For` / `X-Real-IP` are re-issued from the real
    // connection peer (loopback in a test) and `X-Forwarded-Proto` from the wire scheme. So the
    // upstream (and any IP-based filter) sees Plecto's authoritative values, never the client's.
    let upstream = spawn_upstream().await;
    let proxy = spawn_proxy(control_for(upstream)).await;
    let client = client();
    wait_ready(&client, proxy).await;

    let (status, headers, _body) = get(
        &client,
        proxy,
        "/api/hello",
        &[
            ("x-forwarded-for", "9.9.9.9"),
            ("x-real-ip", "9.9.9.9"),
            ("cf-connecting-ip", "8.8.8.8"),
        ],
    )
    .await;

    assert_eq!(status, StatusCode::OK);
    assert_eq!(
        headers.get("x-upstream-xff").and_then(|v| v.to_str().ok()),
        Some("127.0.0.1"),
        "the spoofed X-Forwarded-For is replaced by the real loopback peer"
    );
    assert_eq!(
        headers
            .get("x-upstream-xrealip")
            .and_then(|v| v.to_str().ok()),
        Some("127.0.0.1"),
        "the spoofed X-Real-IP is replaced by the real loopback peer"
    );
    assert_eq!(
        headers.get("x-upstream-cfip").and_then(|v| v.to_str().ok()),
        Some(""),
        "a spoofed CF-Connecting-IP is stripped and not re-issued"
    );
    assert_eq!(
        headers
            .get("x-upstream-xfproto")
            .and_then(|v| v.to_str().ok()),
        Some("http"),
        "X-Forwarded-Proto reflects the plaintext connection"
    );
}

fn control_for_noop(upstream_addr: SocketAddr) -> Arc<Control> {
    let component = filter_noop_component();
    let signer = TestSigner::new().unwrap();
    let component_signature = signer.sign(&component).unwrap();
    let sbom = bound_sbom(&component);
    let sbom_signature = signer.sign(&sbom).unwrap();
    let mut store = MemoryStore::new();
    let digest = store.insert(
        "noop",
        ResolvedArtifact {
            component,
            component_signature,
            sbom,
            sbom_signature,
        },
    );
    let toml = format!(
        r#"
[[filter]]
id = "noop"
source = "noop"
digest = "{digest}"
isolation = "trusted"

[[upstream]]
name = "echo"
addresses = ["{upstream_addr}"]
[upstream.health]
path = "/healthz"
interval_ms = 50

[[route]]
filters = ["noop"]
upstream = "echo"
strip_prefix = "/api"
[route.match]
path_prefix = "/api"
"#
    );
    let manifest = Manifest::from_toml(&toml).unwrap();
    let host = Host::new(signer.trust_policy().unwrap()).unwrap();
    Arc::new(Control::load(host, &manifest, Box::new(store)).unwrap())
}

#[tokio::test]
async fn trusted_noop_filter_route_runs_inline_and_forwards() {
    let upstream = spawn_upstream().await;
    let proxy = spawn_proxy(control_for_noop(upstream)).await;
    let client = client();
    wait_ready(&client, proxy).await;

    let (status, headers, body) = get(&client, proxy, "/api/hello", &[]).await;

    assert_eq!(status, StatusCode::OK, "an unblocked request forwards 200");
    assert_eq!(body, "upstream-ok", "the upstream body streams through");
    assert_eq!(
        headers.get("x-from").and_then(|v| v.to_str().ok()),
        Some("upstream"),
        "the response came from the upstream"
    );
    assert_eq!(
        headers.get("x-upstream-path").and_then(|v| v.to_str().ok()),
        Some("/hello"),
        "strip_prefix was applied"
    );
}