netget/server/nfs/guard.rs
1//! A record-marking screen that sits between the network and `nfsserve`.
2//!
3//! # Why this exists
4//!
5//! `nfsserve` 0.10.2 sizes buffers from numbers a peer supplies and never checks them.
6//! `rpcwire::read_fragment` does
7//!
8//! ```notrust
9//! let length = (fragment_header & ((1 << 31) - 1)) as usize;
10//! append_to.resize(append_to.len() + length, 0);
11//! ```
12//!
13//! — a 31-bit length, so one four-byte record marker of `0xFFFF_FFFF` asks for a 2 GiB
14//! zeroed allocation, and `xdr.rs` does the same with a full 32-bit length for every
15//! `dirpath`, `filename`, file handle and WRITE payload. A record whose last-fragment bit is
16//! clear appends into the same buffer indefinitely. All of it is reachable **before any
17//! authentication** — a `MOUNTPROC3_MNT` is about forty bytes on the wire.
18//!
19//! None of that can be fixed in the crate from here, and `NFSTcpListener` owns its own
20//! `accept()` loop, so there is no seam to hook. NetGet therefore binds the public listener
21//! itself, starts `nfsserve` on a loopback-only ephemeral port, and screens the record-marking
22//! headers on the way through. Bytes the screen refuses never reach the crate.
23//!
24//! # What it refuses
25//!
26//! One record at a time, by the numbers the peer *announces* — never by what it managed to
27//! send, because a truncated request is indistinguishable from a complete one to whatever
28//! reads it next:
29//!
30//! - a fragment longer than [`MAX_FRAGMENT_BYTES`],
31//! - a record whose fragments total more than [`MAX_RECORD_BYTES`],
32//! - a record built from more than [`MAX_FRAGMENTS_PER_RECORD`] fragments (a peer can
33//! otherwise hold a connection open forever with zero-length non-last fragments),
34//! - a fragment body that stalls for longer than [`FRAGMENT_BODY_TIMEOUT`] once its length
35//! has been announced.
36//!
37//! A refusal is a real ONC RPC answer, not a silent close: the peer gets an accepted reply
38//! carrying its own xid with `accept_stat = GARBAGE_ARGS` ("procedure can't decode params"),
39//! which is the only thing RFC 5531 can say at the record layer, and the connection is then
40//! closed. It is deliberately *not* a [`crate::utils::WireFailure`] string — the record layer
41//! has no free-text field, and inventing one would be the leak that type exists to prevent.
42//! Every refusal logs a stable `decision=fail_closed_*` tag; see [`Refusal::decision_tag`].
43//!
44//! # What it does not do
45//!
46//! The screen bounds framing, not semantics. Inside an admitted record the crate's XDR
47//! decoder is still the unchecked one — but every length it reads now lives inside a record
48//! the screen already sized, so the worst it can ask for is [`MAX_RECORD_BYTES`].
49
50use std::net::SocketAddr;
51use std::sync::Arc;
52use std::time::Duration;
53
54use tokio::io::{AsyncReadExt, AsyncWriteExt};
55use tokio::net::{TcpListener, TcpStream};
56use tokio::sync::{mpsc, Mutex};
57
58use crate::logging::emit::Log;
59
60/// Largest single RPC fragment the screen will pass through.
61///
62/// `nfsserve`'s default `fsinfo` advertises `rtmax`/`wtmax` of 1 MiB, so a conforming client
63/// may legitimately send a WRITE of that size plus RPC and NFS headers. This is twice that,
64/// which leaves the legitimate case comfortable and still refuses the 2 GiB the wire format
65/// allows.
66pub const MAX_FRAGMENT_BYTES: usize = 2 * 1024 * 1024;
67
68/// Largest complete RPC record (all fragments summed) the screen will pass through.
69pub const MAX_RECORD_BYTES: usize = 2 * 1024 * 1024;
70
71/// Most fragments one record may be split into. Real clients send exactly one.
72pub const MAX_FRAGMENTS_PER_RECORD: usize = 64;
73
74/// How long a peer may stall part-way through a fragment it has already announced.
75pub const FRAGMENT_BODY_TIMEOUT: Duration = Duration::from_secs(30);
76
77/// Most connections screened at once. Each admitted record costs `nfsserve` up to
78/// [`MAX_RECORD_BYTES`], so this is what turns "bounded per connection" into "bounded".
79///
80/// This is where `crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS` came from: it was the
81/// only connection cap in the tree, and the only one anyone had argued for. It now *is* that
82/// constant, so the two can no longer drift apart.
83pub const MAX_CONCURRENT_CONNECTIONS: usize =
84 crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
85
86/// How long to wait for a peer's first RPC record after it has connected.
87///
88/// ONC RPC over TCP is client-speaks-first: the record marker and the call are the first thing
89/// on the wire and the server says nothing before them. A real client sends NULL or MOUNT
90/// immediately. A peer that has connected and sent nothing has made no call at all, which is
91/// the state an unauthenticated flood lives in — and until now nothing bounded it, as
92/// `src/server/nfs/CLAUDE.md` said in as many words.
93const FIRST_RECORD_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
94
95/// How long to wait for a *further* record once one has been answered.
96///
97/// Fifteen minutes, and long on purpose: a mounted NFS filesystem with no I/O is genuinely
98/// silent for long stretches, and that is the normal state of a mount rather than a symptom.
99/// The Linux client reconnects its TCP transport transparently, so a reaped idle connection
100/// costs a mount nothing observable. Crucially this bound is **not** armed while the backend is
101/// answering — see `awaiting_reply` below, and note that in this server one `lookup` is one LLM
102/// round-trip, which may be parked for a human for minutes.
103const IDLE_BETWEEN_RECORDS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(900);
104
105/// A peer over [`MAX_CONCURRENT_CONNECTIONS`] gets a plain close.
106///
107/// Every refusal this screen can express is an accepted RPC reply carrying the *call's own*
108/// xid — that is what makes [`Refusal`] answerable in NFS's own vocabulary at all. A peer being
109/// turned away at accept has sent no call and therefore no xid, so there is nothing well-formed
110/// to write. The refusal is logged with `decision=fail_closed_connection_cap` instead.
111const CONNECTION_CAP_REFUSAL: &[u8] = b"";
112
113/// Deepest path a MOUNT `dirpath` may have.
114///
115/// `NFSFileSystem::path_to_id`'s default implementation splits the dirpath on `/` and calls
116/// `lookup()` once per component — and in this server every `lookup` is one LLM round-trip.
117/// An unauthenticated MOUNT is otherwise an unbounded sequential chain of model calls that
118/// saturates `--llm-max-concurrent` for every other server in the process. Real export paths
119/// are two or three components deep.
120pub const MAX_PATH_COMPONENTS: usize = 32;
121
122/// Bytes read from the peer in one go while streaming an admitted fragment.
123const RELAY_CHUNK_BYTES: usize = 64 * 1024;
124
125/// How long a refused record is given to produce the four bytes of its xid, so the refusal
126/// can be addressed to it. Nothing else of that record is ever read.
127const XID_RECOVERY_TIMEOUT: Duration = Duration::from_secs(2);
128
129/// Why the screen refused a record.
130#[derive(Clone, Copy, Debug, PartialEq, Eq)]
131pub enum Refusal {
132 /// One fragment announced more than [`MAX_FRAGMENT_BYTES`].
133 FragmentTooLarge { announced: usize },
134 /// The fragments of one record summed to more than [`MAX_RECORD_BYTES`].
135 RecordTooLarge { announced_total: usize },
136 /// More than [`MAX_FRAGMENTS_PER_RECORD`] fragments in one record.
137 TooManyFragments,
138 /// The peer announced a fragment and then stopped sending it.
139 BodyStalled,
140}
141
142impl Refusal {
143 /// A stable tag an operator can grep for, in the shape `src/server/radius/` established.
144 pub fn decision_tag(self) -> &'static str {
145 match self {
146 Self::FragmentTooLarge { .. } => "fail_closed_oversize_fragment",
147 Self::RecordTooLarge { .. } => "fail_closed_oversize_record",
148 Self::TooManyFragments => "fail_closed_fragment_flood",
149 Self::BodyStalled => "fail_closed_fragment_stalled",
150 }
151 }
152
153 /// Operator-facing detail. Never reaches the wire.
154 pub fn describe(self) -> String {
155 match self {
156 Self::FragmentTooLarge { announced } => format!(
157 "fragment announced {} bytes, limit {}",
158 announced, MAX_FRAGMENT_BYTES
159 ),
160 Self::RecordTooLarge { announced_total } => format!(
161 "record would reach {} bytes, limit {}",
162 announced_total, MAX_RECORD_BYTES
163 ),
164 Self::TooManyFragments => {
165 format!("record exceeded {} fragments", MAX_FRAGMENTS_PER_RECORD)
166 }
167 Self::BodyStalled => format!(
168 "announced fragment stalled for more than {}s",
169 FRAGMENT_BODY_TIMEOUT.as_secs()
170 ),
171 }
172 }
173}
174
175/// One admitted fragment: how many bytes to relay, and whether it closes the record.
176#[derive(Clone, Copy, Debug, PartialEq, Eq)]
177pub struct Fragment {
178 pub length: usize,
179 pub is_last: bool,
180}
181
182/// Running state for the record currently being relayed on one connection.
183#[derive(Debug, Default)]
184pub struct RecordScreen {
185 bytes_so_far: usize,
186 fragments_so_far: usize,
187}
188
189impl RecordScreen {
190 pub fn new() -> Self {
191 Self::default()
192 }
193
194 /// True when no fragment of the current record has been relayed yet, so the next four
195 /// bytes on the wire are the call's xid.
196 pub fn at_record_start(&self) -> bool {
197 self.fragments_so_far == 0
198 }
199
200 /// Decide a fragment from its four-byte record marker, **before** anything is read or
201 /// allocated for it. RFC 1057 §10: the top bit is "last fragment", the low 31 are the
202 /// length.
203 pub fn admit_fragment(&mut self, marker: u32) -> Result<Fragment, Refusal> {
204 let is_last = marker & 0x8000_0000 != 0;
205 let length = (marker & 0x7FFF_FFFF) as usize;
206
207 if length > MAX_FRAGMENT_BYTES {
208 return Err(Refusal::FragmentTooLarge { announced: length });
209 }
210 if self.fragments_so_far >= MAX_FRAGMENTS_PER_RECORD {
211 return Err(Refusal::TooManyFragments);
212 }
213 let total = self.bytes_so_far.saturating_add(length);
214 if total > MAX_RECORD_BYTES {
215 return Err(Refusal::RecordTooLarge {
216 announced_total: total,
217 });
218 }
219
220 self.bytes_so_far = total;
221 self.fragments_so_far += 1;
222 if is_last {
223 self.bytes_so_far = 0;
224 self.fragments_so_far = 0;
225 }
226 Ok(Fragment { length, is_last })
227 }
228}
229
230/// An ONC RPC accepted reply with `accept_stat = GARBAGE_ARGS`, record marker included.
231///
232/// RFC 5531: `xid`, `msg_type = REPLY (1)`, `reply_stat = MSG_ACCEPTED (0)`, an AUTH_NULL
233/// verifier (flavor 0, length 0), then `accept_stat = GARBAGE_ARGS (4)`. Twenty-four bytes
234/// behind a last-fragment marker.
235pub fn garbage_args_reply(xid: u32) -> [u8; 28] {
236 let mut out = [0u8; 28];
237 out[0..4].copy_from_slice(&(0x8000_0000u32 | 24).to_be_bytes());
238 out[4..8].copy_from_slice(&xid.to_be_bytes());
239 out[8..12].copy_from_slice(&1u32.to_be_bytes()); // REPLY
240 out[12..16].copy_from_slice(&0u32.to_be_bytes()); // MSG_ACCEPTED
241 out[16..20].copy_from_slice(&0u32.to_be_bytes()); // verf.flavor = AUTH_NULL
242 out[20..24].copy_from_slice(&0u32.to_be_bytes()); // verf.length = 0
243 out[24..28].copy_from_slice(&4u32.to_be_bytes()); // accept_stat = GARBAGE_ARGS
244 out
245}
246
247/// Accept on NetGet's own listener and relay each connection to `nfsserve`, screened.
248///
249/// Runs until the task is aborted (`stop_server` does that through
250/// `AppState::register_server_task`).
251pub async fn serve_screened(
252 listener: TcpListener,
253 backend: SocketAddr,
254 app_state: Arc<crate::state::app_state::AppState>,
255 server_id: crate::state::ServerId,
256 status_tx: mpsc::UnboundedSender<String>,
257) {
258 // The hand-rolled semaphore this loop used to keep is now the shared helper every other
259 // accept loop in the tree calls, so the cap, the refusal and the log line are one
260 // implementation rather than this one plus thirty-one absences.
261 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONCURRENT_CONNECTIONS);
262 loop {
263 let (client, peer_addr, permit) = match crate::server::accept_bounded::accept_bounded(
264 &listener,
265 &limiter,
266 CONNECTION_CAP_REFUSAL,
267 "NFS",
268 Some(&status_tx),
269 )
270 .await
271 {
272 Ok(triple) => triple,
273 Err(e) => {
274 Log::new(Some(&status_tx)).error(format!("NFS accept failed: {}", e));
275 tokio::time::sleep(Duration::from_millis(100)).await;
276 continue;
277 }
278 };
279 let peer = peer_addr;
280
281 let status_tx = status_tx.clone();
282 let handle = tokio::spawn(async move {
283 let _permit = permit;
284 screen_connection(client, backend, peer.to_string(), status_tx).await;
285 });
286 // Registering per-connection tasks is what lets `stop_server` cancel traffic that is
287 // already in flight, not just release the listening socket.
288 app_state.register_server_task(server_id, handle).await;
289 }
290}
291
292/// How relaying one admitted fragment ended.
293enum Relayed {
294 /// Every announced byte reached the backend.
295 Complete,
296 /// The connection closed, reset, or the backend went away. Ordinary, not a refusal.
297 PeerGone,
298 /// The peer announced bytes and then stopped sending them.
299 Stalled,
300}
301
302/// Relay one client connection to `nfsserve`, screening every record marker on the way in.
303async fn screen_connection(
304 client: TcpStream,
305 backend: SocketAddr,
306 peer: String,
307 status_tx: mpsc::UnboundedSender<String>,
308) {
309 let log = Log::new(Some(&status_tx));
310 let _ = client.set_nodelay(true);
311
312 let upstream = match TcpStream::connect(backend).await {
313 Ok(s) => s,
314 Err(e) => {
315 log.error(format!(
316 "NFS could not reach its own backend for {}: {}",
317 peer, e
318 ));
319 return;
320 }
321 };
322 let _ = upstream.set_nodelay(true);
323
324 let (mut from_peer, to_peer) = client.into_split();
325 let (mut from_backend, mut to_backend) = upstream.into_split();
326 // Shared because the screen writes its refusal to the same half the downstream relay
327 // uses. The lock is held only for one `write_all`.
328 let to_peer = Arc::new(Mutex::new(to_peer));
329
330 // True from the moment a record reaches the backend until the backend answers it.
331 //
332 // This is what keeps the idle bound below from evicting a live call. In this server one
333 // `lookup` is one LLM round-trip and may be parked for a human at the dashboard
334 // (`src/state/intercepts.rs`, 300s by default); throughout that the peer is silent because
335 // it is *waiting on us*, which is not the same thing as an idle connection, and closing it
336 // would be exactly the live-transfer eviction this project learned about from TFTP.
337 let awaiting_reply = Arc::new(std::sync::atomic::AtomicBool::new(false));
338
339 let downstream_out = Arc::clone(&to_peer);
340 let downstream_awaiting = Arc::clone(&awaiting_reply);
341 let downstream = tokio::spawn(async move {
342 let mut buf = vec![0u8; RELAY_CHUNK_BYTES];
343 loop {
344 match from_backend.read(&mut buf).await {
345 Ok(0) | Err(_) => break,
346 Ok(n) => {
347 let mut out = downstream_out.lock().await;
348 if out.write_all(&buf[..n]).await.is_err() {
349 break;
350 }
351 drop(out);
352 // Answered: the peer now owes us the next record, so the idle bound means
353 // something again.
354 downstream_awaiting.store(false, std::sync::atomic::Ordering::SeqCst);
355 }
356 }
357 }
358 });
359
360 let mut screen = RecordScreen::new();
361 // The xid of the record in flight, captured from the first four bytes of its first
362 // fragment, so a refusal later in the same record is still addressed to the right call.
363 let mut record_xid: u32 = 0;
364 let mut buf = vec![0u8; RELAY_CHUNK_BYTES];
365
366 let mut seen_record = false;
367
368 let outcome: Option<Refusal> = loop {
369 let mut marker = [0u8; 4];
370 // Bounded in time as well as in size. The deadline re-arms rather than closing while a
371 // reply is outstanding, so only genuine silence from the peer ends the connection.
372 let marker_bound = if seen_record {
373 IDLE_BETWEEN_RECORDS_TIMEOUT
374 } else {
375 FIRST_RECORD_READ_TIMEOUT
376 };
377 let marker_read = loop {
378 match tokio::time::timeout(marker_bound, from_peer.read_exact(&mut marker)).await {
379 Ok(read) => break Some(read),
380 Err(_) => {
381 if awaiting_reply.load(std::sync::atomic::Ordering::SeqCst) {
382 continue;
383 }
384 log.info(format!(
385 "NFS peer {} sent nothing for {}s; closing idle connection",
386 peer,
387 marker_bound.as_secs()
388 ));
389 break None;
390 }
391 }
392 };
393 match marker_read {
394 Some(Ok(_)) => {}
395 // A closed or reset connection between records is ordinary; so is the idle close
396 // above, which has already logged its reason.
397 Some(Err(_)) | None => break None,
398 }
399 seen_record = true;
400 let marker = u32::from_be_bytes(marker);
401
402 let at_start = screen.at_record_start();
403 if at_start {
404 record_xid = 0;
405 }
406 let fragment = match screen.admit_fragment(marker) {
407 Ok(f) => f,
408 Err(refusal) => {
409 // Recover the xid so the refusal is addressed to the call that caused it.
410 // Four bytes, briefly: the announced body is never read, only the identifier
411 // that has to appear in the reply for a client to match it to anything.
412 if at_start {
413 let mut xid = [0u8; 4];
414 if let Ok(Ok(_)) =
415 tokio::time::timeout(XID_RECOVERY_TIMEOUT, from_peer.read_exact(&mut xid))
416 .await
417 {
418 record_xid = u32::from_be_bytes(xid);
419 }
420 }
421 break Some(refusal);
422 }
423 };
424
425 // From here the peer is waiting on the backend, so the idle deadline above must not
426 // count the wait. Set before the write, so there is no window in which a reply could
427 // be in flight while the connection still looks idle.
428 awaiting_reply.store(true, std::sync::atomic::Ordering::SeqCst);
429 if to_backend.write_all(&marker.to_be_bytes()).await.is_err() {
430 break None;
431 }
432
433 let mut remaining = fragment.length;
434 let mut captured = 0usize;
435 let relayed = loop {
436 if remaining == 0 {
437 break Relayed::Complete;
438 }
439 let want = remaining.min(buf.len());
440 let read =
441 match tokio::time::timeout(FRAGMENT_BODY_TIMEOUT, from_peer.read(&mut buf[..want]))
442 .await
443 {
444 Ok(Ok(0)) | Ok(Err(_)) => break Relayed::PeerGone,
445 Ok(Ok(n)) => n,
446 Err(_) => break Relayed::Stalled,
447 };
448
449 // Capture the xid out of the first four bytes of the record.
450 if at_start && captured < 4 {
451 let take = read.min(4 - captured);
452 for &b in &buf[..take] {
453 record_xid = (record_xid << 8) | b as u32;
454 captured += 1;
455 }
456 }
457
458 if to_backend.write_all(&buf[..read]).await.is_err() {
459 break Relayed::PeerGone;
460 }
461 remaining -= read;
462 };
463
464 // A first fragment shorter than four bytes leaves the xid partly filled; align what
465 // was captured into the high bytes so the reply at least echoes something derived
466 // from the call rather than a number shifted by an arbitrary amount.
467 if at_start && captured > 0 && captured < 4 {
468 record_xid <<= 8 * (4 - captured) as u32;
469 }
470
471 match relayed {
472 Relayed::Complete => {}
473 Relayed::PeerGone => break None,
474 Relayed::Stalled => break Some(Refusal::BodyStalled),
475 }
476 };
477
478 if let Some(refusal) = outcome {
479 log.error(format!(
480 "NFS record from {} refused decision={} ({}) - answered accept_stat=GARBAGE_ARGS",
481 peer,
482 refusal.decision_tag(),
483 refusal.describe()
484 ));
485 let reply = garbage_args_reply(record_xid);
486 let mut out = to_peer.lock().await;
487 let _ = out.write_all(&reply).await;
488 let _ = out.flush().await;
489 let _ = out.shutdown().await;
490 }
491
492 // Dropping the backend write half closes that direction; the relay ends with it.
493 drop(to_backend);
494 downstream.abort();
495}