door: the peer-pin fix — the UDP front door's same-IP lock + reply-port refresh (OQ-SK2-09 option 2)
- serve_on pins the client's source IP (parsed datagrams only — protocol garbage can never move the pin) and sends replies to the latest-seen port from that IP: a NAT port rebind under a stable IP keeps receiving (the review 003 S-1 residual, now fixed) - the RFC 1928 MUST implemented: datagrams from a source IP other than the pinned one are dropped (previously every parsed datagram was forwarded regardless of source); the spoof primitive shrinks from any-source to same-IP off-path - the residual (a full client IP change mid-association) stays the RFC's expected-peer posture — revisitable per OQ-SK2-09 (the control-conn-derived expected IP is the complete fix, ADR-shaped) - two tests pin the new behavior: the NAT port-rebind follow (the old port stops receiving, the new one takes over) and the foreign-IP drop (no reply to the spoofer, the association healthy) Verification: cargo test --features local (15 suites green, door-udp 38 tests), clippy --all-targets --all-features -D warnings clean, fmt clean.
This commit is contained in:
1 parent
086aeb4135
commit
31fb11cf83
2 files changed
+197
-31
No files matched your search
+56
-31
@@ -831,13 +831,20 @@ impl UdpFrontDoor {
|
||||
/// Serve the association over an existing relay socket (the
|
||||
/// test/assembly seam — the same pump without the bind).
|
||||
///
|
||||
/// Posture (accepted residual, review 003 S-1): the client's
|
||||
/// source address is learned from the first inbound datagram and
|
||||
/// pinned for the association's lifetime — a wildcard-bind
|
||||
/// deployment accepts the off-path-first-datagram shape (the
|
||||
/// RFC's own per-association peer expectation; the loopback bind
|
||||
/// is the documented posture). A client whose source port
|
||||
/// rebinds mid-association (NAT) stops receiving replies.
|
||||
/// Posture (the peer-pin shape, OQ-SK2-09): the client's source
|
||||
/// **IP** is learned from the first inbound datagram and pinned for
|
||||
/// the association's lifetime; replies go to the **latest-seen
|
||||
/// port** from that IP (a NAT port rebind under a stable IP keeps
|
||||
/// receiving). Datagrams from a source IP other than the pinned
|
||||
/// one are dropped — RFC 1928's MUST ("MUST drop any datagrams
|
||||
/// arriving from any source IP address other than the one recorded
|
||||
/// for the particular association"). The residual (a full client
|
||||
/// IP change mid-association — a NAT *address* rebind) stops
|
||||
/// receiving replies by design: the RFC's expected-peer posture,
|
||||
/// revisitable per OQ-SK2-09. The loopback bind is the documented
|
||||
/// posture; a wildcard-bind deployment accepts the same-IP
|
||||
/// off-path-first-datagram shape (a much weaker primitive than the
|
||||
/// pre-fix any-source pin).
|
||||
pub async fn serve_on(
|
||||
self,
|
||||
socket: UdpSocket,
|
||||
@@ -865,18 +872,26 @@ impl UdpFrontDoor {
|
||||
return Ok(());
|
||||
};
|
||||
let socket = Arc::new(socket);
|
||||
// The client's source address — learned from the first inbound
|
||||
// datagram and used for every reply (the relay's single-client
|
||||
// shape; the door serves one association per socket).
|
||||
let client_addr: Arc<std::sync::Mutex<Option<SocketAddr>>> =
|
||||
Arc::new(std::sync::Mutex::new(None));
|
||||
// The peer pin (OQ-SK2-09): the client's source IP — learned
|
||||
// from the first inbound datagram and pinned; the reply port
|
||||
// refreshes to the latest-seen source port from that IP (the
|
||||
// NAT-port-rebind shape). A source IP mismatch is the RFC's
|
||||
// drop case (the datagram is dropped, not forwarded).
|
||||
let client_peer: Arc<std::sync::RwLock<Option<std::net::IpAddr>>> =
|
||||
Arc::new(std::sync::RwLock::new(None));
|
||||
let reply_port: Arc<std::sync::RwLock<Option<u16>>> =
|
||||
Arc::new(std::sync::RwLock::new(None));
|
||||
|
||||
// socket→channel: decode the RFC header (the codec boundary),
|
||||
// refuse FRAG ≠ 0 (drop the datagram — the v1 posture), send
|
||||
// `{addr, data}` in-band. Each datagram resets the idle window.
|
||||
// refuse FRAG ≠ 0 (drop the datagram — the v1 posture), pin
|
||||
// the peer IP + enforce the RFC's same-IP drop rule (the pin
|
||||
// reacts to *parsed* datagrams only — protocol garbage can
|
||||
// never move it), refresh the reply port, send `{addr, data}`
|
||||
// in-band. Each datagram resets the idle window.
|
||||
let s2c = {
|
||||
let socket = Arc::clone(&socket);
|
||||
let client_addr = Arc::clone(&client_addr);
|
||||
let client_peer = Arc::clone(&client_peer);
|
||||
let reply_port = Arc::clone(&reply_port);
|
||||
async move {
|
||||
let mut scratch = vec![0u8; 65535];
|
||||
loop {
|
||||
@@ -900,11 +915,22 @@ impl UdpFrontDoor {
|
||||
continue;
|
||||
}
|
||||
{
|
||||
let mut known = client_addr.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if known.is_none() {
|
||||
*known = Some(from);
|
||||
let mut known = client_peer.write().unwrap_or_else(|e| e.into_inner());
|
||||
match *known {
|
||||
None => *known = Some(from.ip()),
|
||||
Some(ip) if ip != from.ip() => {
|
||||
tracing::debug!(
|
||||
peer = %from,
|
||||
pinned = %ip,
|
||||
"udp front door: datagram from a non-expected \
|
||||
source IP dropped (RFC 1928 peer posture)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
*reply_port.write().unwrap_or_else(|e| e.into_inner()) = Some(from.port());
|
||||
if let Err(err) = send_inband(&mut channel_write, &addr, payload).await {
|
||||
return UdpDoorExit::Session(err.to_string());
|
||||
}
|
||||
@@ -914,15 +940,16 @@ impl UdpFrontDoor {
|
||||
|
||||
// channel→socket: receive the in-band `{addr, data}` frame,
|
||||
// encode the RFC reply header (the codec boundary), send the
|
||||
// RFC-header'd datagram back to the client's source address.
|
||||
// The association's end (the channel EOF) is this pump's clean
|
||||
// exit. The decode state (the incremental reader + the
|
||||
// batched-datagram pending queue) persists across pump
|
||||
// iterations — the bridge owns it for the association's
|
||||
// lifetime.
|
||||
// RFC-header'd datagram back to the pinned peer IP's
|
||||
// latest-seen reply port. The association's end (the channel
|
||||
// EOF) is this pump's clean exit. The decode state (the
|
||||
// incremental reader + the batched-datagram pending queue)
|
||||
// persists across pump iterations — the bridge owns it for the
|
||||
// association's lifetime.
|
||||
let c2s = {
|
||||
let socket = Arc::clone(&socket);
|
||||
let client_addr = Arc::clone(&client_addr);
|
||||
let client_peer = Arc::clone(&client_peer);
|
||||
let reply_port = Arc::clone(&reply_port);
|
||||
async move {
|
||||
let mut bridge = InbandBridge::new();
|
||||
loop {
|
||||
@@ -936,14 +963,12 @@ impl UdpFrontDoor {
|
||||
Err(err) => return UdpDoorExit::Session(err.to_string()),
|
||||
};
|
||||
header.extend_from_slice(&payload);
|
||||
let to = client_addr
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.as_ref()
|
||||
.copied();
|
||||
let Some(to) = to else {
|
||||
let ip = *client_peer.read().unwrap_or_else(|e| e.into_inner());
|
||||
let port = *reply_port.read().unwrap_or_else(|e| e.into_inner());
|
||||
let (Some(ip), Some(port)) = (ip, port) else {
|
||||
continue;
|
||||
};
|
||||
let to = SocketAddr::new(ip, port);
|
||||
if let Err(err) = socket.send_to(&header, to).await {
|
||||
return UdpDoorExit::Io(err);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user