Skip to main content

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}