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:
glm-5.3-flash committed 2026-09-28 09:15:05 +00:00
1 parent 086aeb4135
commit 31fb11cf83
2 files changed
+197 -31

No files matched your search

+56 -31
View File
@@ -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);
}