Skip to content

Commit 3504c8a

Browse files
committed
gw: address Copilot review (port range, async fetcher, pp tests)
Three fixes from review: 1. Treat the wire-format `port: uint32` as out-of-range when it can't fit in u16 (instead of silently truncating to a different valid port). Use `u16::try_from` and skip invalid entries. 2. Move the legacy `Info()` lazy fetch off the connection critical path: - `should_send_pp` is now sync. On a cache hit it returns the declared value; on a miss it enqueues the instance for the background worker and returns `pp = false` immediately, so a slow/missing CVM agent never blocks a proxied connection. - A single background task (`spawn_fetcher`) drains the queue, dedupes in-flight instance ids via a HashSet, applies a configurable timeout (`timeouts.port_attrs_fetch`, default 10s), and writes the result back to WaveKV. 3. Add unit tests in `pp.rs` for the inbound PROXY parser: v1/v2 IPv4 happy paths, no-prefix rejection, v1 missing terminator, v2 over-length cap, and the address synthesis/Display helpers.
1 parent 1e30454 commit 3504c8a

8 files changed

Lines changed: 221 additions & 34 deletions

File tree

gateway/gateway.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ shutdown = "5s"
8585
total = "5h"
8686
# Timeout for proxy protocol header.
8787
pp_header = "5s"
88+
# Timeout for the background lazy fetch of port_attrs from a legacy CVM.
89+
port_attrs_fetch = "10s"
8890

8991
[core.recycle]
9092
enabled = true

gateway/src/config.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ pub struct Timeouts {
152152
/// Timeout for reading the proxy protocol header from inbound connections.
153153
#[serde(with = "serde_duration")]
154154
pub pp_header: Duration,
155+
/// Timeout for the background lazy fetch of `port_attrs` from a legacy CVM
156+
/// agent's `Info()` RPC.
157+
#[serde(with = "serde_duration")]
158+
pub port_attrs_fetch: Duration,
155159
}
156160

157161
#[derive(Debug, Clone, Deserialize, Serialize)]

gateway/src/main_service.rs

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ pub struct ProxyInner {
7575
pub(crate) wavekv_sync: Option<Arc<WaveKvSyncService>>,
7676
/// HTTPS client config for mTLS (used for bootnode peer discovery)
7777
https_config: Option<HttpsClientConfig>,
78+
/// Sender for the background port_attrs lazy-fetch worker. The proxy fast
79+
/// path enqueues unknown instance_ids and immediately returns `pp=false`
80+
/// so a missing cache never blocks a connection.
81+
pub(crate) port_attrs_tx: tokio::sync::mpsc::UnboundedSender<String>,
7882
}
7983

8084
#[derive(Debug, Serialize, Deserialize, Default)]
@@ -103,9 +107,13 @@ pub struct ProxyOptions {
103107

104108
impl Proxy {
105109
pub async fn new(options: ProxyOptions) -> Result<Self> {
106-
Ok(Self {
107-
_inner: Arc::new(ProxyInner::new(options).await?),
108-
})
110+
let (port_attrs_tx, port_attrs_rx) = tokio::sync::mpsc::unbounded_channel();
111+
let inner = ProxyInner::new(options, port_attrs_tx).await?;
112+
let proxy = Self {
113+
_inner: Arc::new(inner),
114+
};
115+
crate::proxy::port_attrs::spawn_fetcher(proxy.clone(), port_attrs_rx);
116+
Ok(proxy)
109117
}
110118
}
111119

@@ -114,7 +122,10 @@ impl ProxyInner {
114122
self.state.lock().or_panic("Failed to lock AppState")
115123
}
116124

117-
pub async fn new(options: ProxyOptions) -> Result<Self> {
125+
pub async fn new(
126+
options: ProxyOptions,
127+
port_attrs_tx: tokio::sync::mpsc::UnboundedSender<String>,
128+
) -> Result<Self> {
118129
let ProxyOptions {
119130
config,
120131
my_app_id,
@@ -270,6 +281,7 @@ impl ProxyInner {
270281
kv_store,
271282
wavekv_sync,
272283
https_config: Some(https_config),
284+
port_attrs_tx,
273285
})
274286
}
275287

@@ -1369,7 +1381,12 @@ impl GatewayRpc for RpcHandler {
13691381
let port_attrs = request.port_attrs.map(|list| {
13701382
list.attrs
13711383
.into_iter()
1372-
.map(|p| (p.port as u16, PortFlags { pp: p.pp }))
1384+
.filter_map(|p| {
1385+
// Wire format is uint32 to avoid varint shenanigans, but valid TCP
1386+
// ports fit in u16. Drop out-of-range entries instead of truncating.
1387+
let port = u16::try_from(p.port).ok()?;
1388+
Some((port, PortFlags { pp: p.pp }))
1389+
})
13731390
.collect::<BTreeMap<u16, PortFlags>>()
13741391
});
13751392
self.state.do_register_cvm(

gateway/src/pp.rs

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,135 @@ where
171171
}
172172
}
173173
if !end_found {
174-
bail!("no valid proxy protocol header detected");
174+
bail!("no valid proxy protocol header detected (v1 terminator not found)");
175175
}
176176

177177
Ok(())
178178
}
179+
180+
#[cfg(test)]
181+
mod tests {
182+
use super::*;
183+
use proxy_protocol::{version1 as v1, version2 as v2, ProxyHeader};
184+
185+
fn extract_v4(header: ProxyHeader) -> (std::net::SocketAddrV4, std::net::SocketAddrV4) {
186+
match header {
187+
ProxyHeader::Version1 {
188+
addresses:
189+
v1::ProxyAddresses::Ipv4 {
190+
source,
191+
destination,
192+
},
193+
..
194+
} => (source, destination),
195+
ProxyHeader::Version2 {
196+
addresses:
197+
v2::ProxyAddresses::Ipv4 {
198+
source,
199+
destination,
200+
},
201+
..
202+
} => (source, destination),
203+
other => panic!("expected ipv4 header, got {other:?}"),
204+
}
205+
}
206+
207+
#[tokio::test]
208+
async fn parses_v1_ipv4() {
209+
// v1 is ASCII: "PROXY TCP4 <src> <dst> <sport> <dport>\r\n"
210+
let header = b"PROXY TCP4 1.2.3.4 5.6.7.8 11111 22222\r\n";
211+
let (_stream, parsed) = read_proxy_header(&header[..]).await.expect("v1 parse");
212+
let (src, dst) = extract_v4(parsed);
213+
assert_eq!(src.ip().octets(), [1, 2, 3, 4]);
214+
assert_eq!(src.port(), 11111);
215+
assert_eq!(dst.ip().octets(), [5, 6, 7, 8]);
216+
assert_eq!(dst.port(), 22222);
217+
}
218+
219+
#[tokio::test]
220+
async fn parses_v2_ipv4() {
221+
// v2 magic + ver/cmd 0x21 + family/proto 0x11 (TCP/IPv4) + len 12
222+
let mut header = Vec::new();
223+
header.extend_from_slice(V2_PROTOCOL_PREFIX);
224+
header.extend_from_slice(&[0x21, 0x11, 0x00, 0x0c]);
225+
header.extend_from_slice(&[1, 2, 3, 4]); // src ip
226+
header.extend_from_slice(&[5, 6, 7, 8]); // dst ip
227+
header.extend_from_slice(&11111u16.to_be_bytes()); // src port
228+
header.extend_from_slice(&22222u16.to_be_bytes()); // dst port
229+
230+
let (_stream, parsed) = read_proxy_header(&header[..]).await.expect("v2 parse");
231+
let (src, dst) = extract_v4(parsed);
232+
assert_eq!(src.ip().octets(), [1, 2, 3, 4]);
233+
assert_eq!(src.port(), 11111);
234+
assert_eq!(dst.ip().octets(), [5, 6, 7, 8]);
235+
assert_eq!(dst.port(), 22222);
236+
}
237+
238+
#[tokio::test]
239+
async fn rejects_no_prefix() {
240+
// Looks neither like v1 ("PROXY") nor v2 magic.
241+
let bytes = b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n";
242+
let err = read_proxy_header(&bytes[..]).await.unwrap_err();
243+
assert!(
244+
format!("{err:#}").contains("no valid proxy protocol header"),
245+
"unexpected error: {err:#}"
246+
);
247+
}
248+
249+
#[tokio::test]
250+
async fn rejects_v1_without_terminator() {
251+
// PROXY prefix matched but no \r\n terminator within V1_MAX_LENGTH bytes.
252+
let bytes = vec![b'P'; V1_MAX_LENGTH + 8]; // all 'P' — never closes
253+
let mut head = b"PROXY".to_vec();
254+
head.extend(std::iter::repeat(b'A').take(V1_MAX_LENGTH));
255+
let err = read_proxy_header(&head[..]).await.unwrap_err();
256+
let msg = format!("{err:#}");
257+
assert!(
258+
msg.contains("v1 terminator not found") || msg.contains("no valid proxy"),
259+
"unexpected error: {msg}"
260+
);
261+
// Sanity: the longer no-terminator buffer would also fail (read past)
262+
let _ = bytes;
263+
}
264+
265+
#[tokio::test]
266+
async fn rejects_v2_oversize_length() {
267+
// v2 prefix with a length field exceeding V2_MAX_LENGTH.
268+
let mut header = Vec::new();
269+
header.extend_from_slice(V2_PROTOCOL_PREFIX);
270+
header.extend_from_slice(&[0x21, 0x11]);
271+
// length = V2_MAX_LENGTH bytes -> total = MIN + that, blows the cap
272+
header.extend_from_slice(&(V2_MAX_LENGTH as u16).to_be_bytes());
273+
let err = read_proxy_header(&header[..]).await.unwrap_err();
274+
assert!(
275+
format!("{err:#}").contains("too long"),
276+
"unexpected error: {err:#}"
277+
);
278+
}
279+
280+
#[test]
281+
fn synthesizes_unspec_when_no_addrs() {
282+
// We can't construct a real TcpStream in a unit test cheaply; just
283+
// assert the helper returns Unspec for the all-None branch by going
284+
// through the public Display impl.
285+
let header = ProxyHeader::Version2 {
286+
command: v2::ProxyCommand::Proxy,
287+
transport_protocol: v2::ProxyTransportProtocol::Stream,
288+
addresses: v2::ProxyAddresses::Unspec,
289+
};
290+
assert_eq!(format!("{}", DisplayAddr(&header)), "<unspec>");
291+
}
292+
293+
#[test]
294+
fn display_v2_ipv4_source() {
295+
let header = ProxyHeader::Version2 {
296+
command: v2::ProxyCommand::Proxy,
297+
transport_protocol: v2::ProxyTransportProtocol::Stream,
298+
addresses: v2::ProxyAddresses::Ipv4 {
299+
source: "9.8.7.6:1234".parse().unwrap(),
300+
destination: "1.2.3.4:80".parse().unwrap(),
301+
},
302+
};
303+
assert_eq!(format!("{}", DisplayAddr(&header)), "9.8.7.6:1234");
304+
}
305+
}

gateway/src/proxy.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ pub(crate) struct AddressInfo {
4242
pub(crate) type AddressGroup = smallvec::SmallVec<[AddressInfo; 4]>;
4343

4444
mod io_bridge;
45-
mod port_attrs;
45+
pub(crate) mod port_attrs;
4646
mod sni;
4747
mod tls_passthough;
4848
mod tls_terminate;

gateway/src/proxy/port_attrs.rs

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,56 +2,93 @@
22
//
33
// SPDX-License-Identifier: Apache-2.0
44

5-
//! Per-port attribute lookup with lazy fetch from legacy CVMs.
5+
//! Per-port attribute lookup with background lazy fetch from legacy CVMs.
6+
//!
7+
//! Two paths:
8+
//!
9+
//! - Fast path (`should_send_pp`): a synchronous, non-blocking lookup. On a
10+
//! cache miss it enqueues the instance for the background worker and
11+
//! optimistically returns `pp = false` so the connection isn't blocked.
12+
//! - Slow path ([`spawn_fetcher`]): a single background task that drains the
13+
//! queue, dedupes in-flight instances, calls the agent's `Info()` RPC with
14+
//! a timeout, and writes the result back to WaveKV.
615
7-
use std::{collections::BTreeMap, net::Ipv4Addr};
16+
use std::collections::{BTreeMap, HashSet};
17+
use std::net::Ipv4Addr;
18+
use std::sync::{Arc, Mutex};
819

920
use anyhow::{Context, Result};
1021
use dstack_guest_agent_rpc::dstack_guest_client::DstackGuestClient;
1122
use dstack_types::AppCompose;
1223
use http_client::prpc::PrpcClient;
24+
use tokio::sync::mpsc::UnboundedReceiver;
1325
use tracing::{debug, warn};
1426

1527
use crate::{kv::PortFlags, main_service::Proxy};
1628

1729
/// Decide whether the gateway should send a PROXY protocol header on the
1830
/// outbound connection to (`instance_id`, `port`).
1931
///
20-
/// Lookup order:
21-
///
22-
/// 1. In-memory `port_attrs` populated at registration (new CVMs).
23-
/// 2. Lazy fetch via the agent's `Info()` RPC (legacy CVMs that didn't report
24-
/// attributes at registration). Result is cached on success.
25-
/// 3. Default `false` on any failure.
26-
pub(crate) async fn should_send_pp(state: &Proxy, instance_id: &str, port: u16) -> bool {
32+
/// Cache hit returns the declared value. Cache miss returns `false` and asks
33+
/// the background worker to populate the cache for the next request — this
34+
/// keeps the data path off the critical Info() RPC.
35+
pub(crate) fn should_send_pp(state: &Proxy, instance_id: &str, port: u16) -> bool {
2736
if let Some(attrs) = state.lock().instance_port_attrs(instance_id) {
2837
return attrs.get(&port).map(|f| f.pp).unwrap_or(false);
2938
}
30-
match lazy_fetch(state, instance_id).await {
31-
Ok(attrs) => attrs.get(&port).map(|f| f.pp).unwrap_or(false),
32-
Err(err) => {
33-
warn!("port_attrs lazy fetch for instance {instance_id} failed: {err:#}");
34-
false
39+
// Best-effort enqueue. If the channel is closed (shutdown) or the worker
40+
// is gone, silently drop — `false` is the conservative default anyway.
41+
let _ = state.port_attrs_tx.send(instance_id.to_string());
42+
false
43+
}
44+
45+
/// Spawn the background lazy-fetch worker. Should be called once at startup.
46+
pub(crate) fn spawn_fetcher(state: Proxy, mut rx: UnboundedReceiver<String>) {
47+
let in_flight: Arc<Mutex<HashSet<String>>> = Default::default();
48+
let timeout = state.config.proxy.timeouts.port_attrs_fetch;
49+
tokio::spawn(async move {
50+
while let Some(instance_id) = rx.recv().await {
51+
// Dedupe: only spawn one fetch per instance at a time. After the
52+
// fetch completes, the entry is removed so a later registration
53+
// (with new compose_hash) can trigger a fresh fetch via the same
54+
// path.
55+
{
56+
let mut in_flight = in_flight.lock().expect("port_attrs in_flight poisoned");
57+
if !in_flight.insert(instance_id.clone()) {
58+
continue;
59+
}
60+
}
61+
let state = state.clone();
62+
let in_flight = in_flight.clone();
63+
let id = instance_id.clone();
64+
tokio::spawn(async move {
65+
match tokio::time::timeout(timeout, fetch_and_store(&state, &id)).await {
66+
Ok(Ok(())) => debug!("port_attrs cached for instance {id}"),
67+
Ok(Err(err)) => {
68+
warn!("port_attrs fetch for instance {id} failed: {err:#}")
69+
}
70+
Err(_) => {
71+
warn!("port_attrs fetch for instance {id} timed out after {timeout:?}")
72+
}
73+
}
74+
in_flight
75+
.lock()
76+
.expect("port_attrs in_flight poisoned")
77+
.remove(&id);
78+
});
3579
}
36-
}
80+
});
3781
}
3882

39-
async fn lazy_fetch(state: &Proxy, instance_id: &str) -> Result<BTreeMap<u16, PortFlags>> {
83+
async fn fetch_and_store(state: &Proxy, instance_id: &str) -> Result<()> {
4084
let (ip, agent_port) = {
4185
let guard = state.lock();
4286
let ip = guard.instance_ip(instance_id).context("unknown instance")?;
4387
(ip, guard.config.proxy.agent_port)
4488
};
45-
4689
let attrs = fetch_port_attrs(ip, agent_port).await?;
47-
state
48-
.lock()
49-
.update_instance_port_attrs(instance_id, attrs.clone());
50-
debug!(
51-
"fetched port_attrs for legacy instance {instance_id} via Info(): {} entries",
52-
attrs.len()
53-
);
54-
Ok(attrs)
90+
state.lock().update_instance_port_attrs(instance_id, attrs);
91+
Ok(())
5592
}
5693

5794
async fn fetch_port_attrs(ip: Ipv4Addr, agent_port: u16) -> Result<BTreeMap<u16, PortFlags>> {

gateway/src/proxy/tls_passthough.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ pub(crate) async fn proxy_to_app(
195195
.await
196196
.with_context(|| format!("connecting timeout to app {app_id}: {addresses:?}:{port}"))?
197197
.with_context(|| format!("failed to connect to app {app_id}: {addresses:?}:{port}"))?;
198-
if should_send_pp(&state, &instance_id, port).await {
198+
if should_send_pp(&state, &instance_id, port) {
199199
let pp_header_bin =
200200
proxy_protocol::encode(pp_header).context("failed to encode pp header")?;
201201
outbound.write_all(&pp_header_bin).await?;

gateway/src/proxy/tls_terminate.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ impl Proxy {
299299
.await
300300
.map_err(|_| anyhow!("connecting timeout"))?
301301
.context("failed to connect to app")?;
302-
if should_send_pp(self, &instance_id, port).await {
302+
if should_send_pp(self, &instance_id, port) {
303303
let pp_header_bin =
304304
proxy_protocol::encode(pp_header).context("failed to encode pp header")?;
305305
outbound.write_all(&pp_header_bin).await?;

0 commit comments

Comments
 (0)