Skip to main content

fxcp_core/nfs/
mount.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2// Copyright (C) 2025 Joel Wirāmu Pauling <aenertia@aenertia.net>
3//
4// fxcp-core/src/nfs/mount.rs  --  NFS mount discovery and file handle extraction
5
6//! Parses `/proc/self/mountinfo` to identify NFSv4.2 mounts and extracts
7//! opaque NFS file handles via `name_to_handle_at(2)` for use in userspace
8//! compound RPCs.
9
10use std::net::{SocketAddr, ToSocketAddrs};
11use std::path::{Path, PathBuf};
12use tracing::{debug, info, warn};
13
14use crate::constants;
15
16/// NFS authentication/security flavor as reported by `/proc/mounts` `sec=` option.
17///
18/// Maps to RPCSEC_GSS service levels defined in RFC 2203 S5.2.1.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum NfsSecurity {
21    /// AUTH_SYS  --  UID/GID only, no cryptographic authentication. Default when `sec=` absent.
22    #[default]
23    Sys,
24    /// RPCSEC_GSS krb5  --  mutual Kerberos authentication, no data integrity or privacy.
25    Krb5,
26    /// RPCSEC_GSS krb5i  --  Kerberos authentication + MIC per RPC (integrity protection).
27    Krb5i,
28    /// RPCSEC_GSS krb5p  --  Kerberos authentication + MIC + AES encryption (privacy + integrity).
29    Krb5p,
30}
31
32impl NfsSecurity {
33    /// Returns `true` for any Kerberos flavor (krb5, krb5i, krb5p).
34    pub fn requires_kerberos(self) -> bool {
35        !matches!(self, NfsSecurity::Sys)
36    }
37}
38
39impl std::fmt::Display for NfsSecurity {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        match self {
42            NfsSecurity::Sys => write!(f, "sys"),
43            NfsSecurity::Krb5 => write!(f, "krb5"),
44            NfsSecurity::Krb5i => write!(f, "krb5i"),
45            NfsSecurity::Krb5p => write!(f, "krb5p"),
46        }
47    }
48}
49
50/// NFS transport security mode from `xprtsec=` mount option (RFC 9289).
51///
52/// Orthogonal to `NfsSecurity` (the `sec=` authentication flavor).
53/// TLS provides transport-layer encryption; RPCSEC_GSS provides user authentication.
54/// They can be combined: `sec=krb5,xprtsec=tls` gives Kerberos auth over TLS encryption.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
56pub enum NfsTransportSecurity {
57    /// No TLS  --  cleartext TCP transport (default, backward compatible).
58    #[default]
59    None,
60    /// RPC-over-TLS via RFC 9289 STARTTLS upgrade. Mandatory: fail if server
61    /// doesn't support TLS. Detected from `xprtsec=tls` or `xprtsec=mtls`.
62    Tls,
63    /// Try RPC-over-TLS, fall back to cleartext if server rejects STARTTLS.
64    /// Useful for mixed environments during TLS rollout.
65    Opportunistic,
66}
67
68/// Describes an NFS bypass-capable mount point.
69#[derive(Debug, Clone)]
70pub struct NfsBypassInfo {
71    /// NFS server address (IP:port, default port 2049).
72    pub server_addr: SocketAddr,
73    /// NFS server hostname from mount source (e.g., "awa.3d.ae.net.nz").
74    /// Used for Kerberos service principal construction (`nfs/<hostname>@REALM`).
75    pub server_hostname: String,
76    /// NFS export path on the server (e.g., "/vol/data").
77    pub export_path: String,
78    /// Local mount point path.
79    pub mount_point: PathBuf,
80    /// Whether NFSv4.2 was confirmed in mount options.
81    pub v42_confirmed: bool,
82    /// Authentication/security flavor detected from mount options.
83    pub security: NfsSecurity,
84    /// Transport-layer security mode from `xprtsec=` mount option (RFC 9289).
85    /// `None` by default (cleartext TCP); `Tls` when `xprtsec=tls` detected.
86    pub transport_security: NfsTransportSecurity,
87    /// Whether the mount uses RDMA transport (`proto=rdma` in mount options).
88    /// When true, foxing should use NFS/RDMA (`fxcp-core/nfs/rdma_transport.rs`)
89    /// instead of TCP. Always parsed regardless of `rdma` feature flag.
90    pub rdma: bool,
91}
92
93impl Default for NfsBypassInfo {
94    fn default() -> Self {
95        Self {
96            server_addr: SocketAddr::from(([127, 0, 0, 1], 2049)),
97            server_hostname: String::new(),
98            export_path: String::new(),
99            mount_point: PathBuf::new(),
100            v42_confirmed: false,
101            security: NfsSecurity::default(),
102            transport_security: NfsTransportSecurity::default(),
103            rdma: false,
104        }
105    }
106}
107
108/// Probe whether `path` resides on an NFSv4.2 mount eligible for compound RPC bypass.
109///
110/// Parses `/proc/self/mountinfo` to find the NFS mount covering `path`, extracts
111/// the server address and export path, and checks mount options for `vers=4.2`.
112///
113/// Returns `None` if:
114/// - Path is not on an NFS mount
115/// - NFS version is not 4.2
116/// - Kerberos auth is required (`sec=krb5`)
117/// - Server address cannot be resolved
118pub fn probe_nfs_bypass(path: &Path) -> Option<NfsBypassInfo> {
119    let canonical = path.canonicalize().ok()?;
120    let entries = crate::mount_info::list_mounts();
121
122    let best = entries
123        .iter()
124        .filter(|e| e.fs_type == "nfs" || e.fs_type == "nfs4")
125        .filter(|e| canonical.starts_with(&e.mount_point))
126        .max_by_key(|e| e.mount_point.len())?;
127
128    let mount_point = best.mount_point.clone();
129    let source = best.source.clone();
130    let super_opts = best.super_options.clone();
131
132    // Detect NFS security flavor from mount options
133    let security = super_opts
134        .split(',')
135        .find_map(|opt| match opt.trim() {
136            "sec=krb5p" => Some(NfsSecurity::Krb5p),
137            "sec=krb5i" => Some(NfsSecurity::Krb5i),
138            "sec=krb5" => Some(NfsSecurity::Krb5),
139            _ => None,
140        })
141        .unwrap_or(NfsSecurity::Sys);
142
143    // Detect NFS transport security from mount options (RFC 9289 xprtsec= option)
144    let transport_security = super_opts
145        .split(',')
146        .find_map(|opt| match opt.trim() {
147            "xprtsec=tls" | "xprtsec=mtls" => Some(NfsTransportSecurity::Tls),
148            "xprtsec=none" => Some(NfsTransportSecurity::None),
149            _ => None,
150        })
151        .unwrap_or(NfsTransportSecurity::None);
152
153    let rdma = super_opts.split(',').any(|opt| opt.trim() == "proto=rdma");
154
155    #[cfg(not(feature = "tls"))]
156    if transport_security != NfsTransportSecurity::None {
157        info!(
158            "NFS bypass: xprtsec=tls detected on {} but tls feature is disabled  --  \
159             using cleartext compound RPCs. Build with --features tls for RPC-over-TLS.",
160            mount_point
161        );
162        crate::metrics::NFS_BYPASS_TLS_FALLBACK.inc();
163    }
164
165    // Without krb5 feature: Kerberos mounts fall back to kernel VFS path
166    #[cfg(not(feature = "krb5"))]
167    if security.requires_kerberos() {
168        info!(
169            "NFS bypass unavailable for {}: Kerberos auth detected (sec={})  --  \
170             using kernel VFS path. Performance: large files unaffected; \
171             small-file throughput ~2.6x lower than AUTH_SYS bypass. \
172             Build with --features krb5 for RPCSEC_GSS support.",
173            mount_point, security
174        );
175        crate::metrics::NFS_BYPASS_KRB5_FALLBACK.inc();
176        return None;
177    }
178
179    // With krb5 feature: Kerberos mounts proceed with RPCSEC_GSS
180    #[cfg(feature = "krb5")]
181    if security.requires_kerberos() {
182        info!(
183            "NFS bypass: Kerberos auth detected (sec={}) for {}  --  \
184             will use RPCSEC_GSS context for compound RPCs.",
185            security, mount_point
186        );
187    }
188
189    // Check NFS version  --  must be 4.2
190    let v42 = super_opts.contains("vers=4.2") || super_opts.contains("nfsvers=4.2")
191        || super_opts.contains("vers=4,2") || super_opts.contains("minorversion=2");
192    if !v42 {
193        debug!("NFS bypass: mount {} is not NFSv4.2 (opts: {})", mount_point, super_opts);
194        return None;
195    }
196
197    // Parse server:export from source field (format: "server:/export" or "server:/export/path")
198    let (server_str, export_path) = match source.split_once(':') {
199        Some((s, e)) => (s.to_string(), e.to_string()),
200        None => {
201            debug!("NFS bypass: cannot parse source '{}' for {}", source, mount_point);
202            return None;
203        }
204    };
205
206    // Check for port override in mount options
207    let port: u16 = super_opts.split(',')
208        .find_map(|opt| {
209            let (k, v) = opt.split_once('=')?;
210            if k == "port" { v.parse().ok() } else { None }
211        })
212        .unwrap_or(constants::NFS_DEFAULT_PORT);
213
214    // Resolve server address
215    let addr_str = format!("{}:{}", server_str, port);
216    let server_addr = match addr_str.to_socket_addrs() {
217        Ok(mut addrs) => match addrs.next() {
218            Some(addr) => addr,
219            None => {
220                warn!("NFS bypass: no addresses resolved for {}", addr_str);
221                return None;
222            }
223        },
224        Err(e) => {
225            warn!("NFS bypass: cannot resolve {}: {}", addr_str, e);
226            return None;
227        }
228    };
229
230    debug!("NFS bypass: detected v4.2 mount {} -> {}:{} export={}",
231           mount_point, server_addr, port, export_path);
232
233    Some(NfsBypassInfo {
234        server_addr,
235        server_hostname: server_str,
236        export_path,
237        mount_point: PathBuf::from(mount_point),
238        v42_confirmed: v42,
239        security,
240        transport_security,
241        rdma,
242    })
243}
244
245/// Get the mount ID for a path from `/proc/self/mountinfo`.
246///
247/// Each mount gets a unique ID. After lazy unmount + remount, the mount ID
248/// changes even if the device ID stays the same. This detects NFS remounts
249/// that `metadata().dev()` misses.
250pub fn get_mount_id(path: &Path) -> Option<u64> {
251    crate::mount_info::find_mount_for_path(path).map(|e| e.mount_id)
252}
253
254/// Check if a path is mounted in the global mount namespace (`/proc/mounts`).
255///
256/// Uses `/proc/mounts` which reflects the global namespace, NOT the calling
257/// process's namespace. This detects lazy unmounts (`umount -l`) that are
258/// invisible to `/proc/self/mountinfo` when the process holds the mount open.
259///
260/// Returns `true` if any mount point in `/proc/mounts` covers the given path.
261pub fn is_mount_present_global(path: &Path) -> bool {
262    let mounts = match std::fs::read_to_string("/proc/mounts") {
263        Ok(m) => m,
264        Err(_) => return true, // can't read -> assume mounted (safe default)
265    };
266    // Use the raw path (don't canonicalize  --  that goes through VFS which
267    // still sees the old mount after lazy unmount).
268    let path_str = path.to_string_lossy();
269    // Find any mount point in /proc/mounts that is a prefix of path.
270    // Exclude root (/) which covers everything.
271    for line in mounts.lines() {
272        let fields: Vec<&str> = line.split_whitespace().collect();
273        if fields.len() < 3 { continue; }
274        let mount_point = fields[1];
275        if mount_point == "/" { continue; }
276        if path_str.starts_with(mount_point)
277            && (path_str.len() == mount_point.len() || path_str.as_bytes().get(mount_point.len()) == Some(&b'/'))
278        {
279            return true;
280        }
281    }
282    false
283}
284
285/// Maximum NFS file handle size (kernel constant).
286const MAX_HANDLE_SZ: usize = 128;
287
288/// Resolve a directory path to an opaque NFS file handle using `name_to_handle_at(2)`.
289///
290/// The kernel NFS client stores the server-side file handle in its inode cache.
291/// This syscall extracts it without requiring userspace LOOKUP RPCs.
292///
293/// The returned bytes are the raw NFSv4 filehandle suitable for PUTFH operations.
294pub fn resolve_nfs_handle(dir_path: &Path) -> std::io::Result<Vec<u8>> {
295    use std::ffi::CString;
296    use std::os::unix::ffi::OsStrExt;
297
298    let path_cstr = CString::new(dir_path.as_os_str().as_bytes())
299        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
300
301    // struct file_handle layout: u32 handle_bytes, i32 handle_type, u8 f_handle[MAX_HANDLE_SZ]
302    #[repr(C)]
303    struct FileHandleBuf {
304        handle_bytes: u32,
305        handle_type: i32,
306        f_handle: [u8; MAX_HANDLE_SZ],
307    }
308
309    let mut fh = FileHandleBuf {
310        handle_bytes: MAX_HANDLE_SZ as u32,
311        handle_type: 0,
312        f_handle: [0u8; MAX_HANDLE_SZ],
313    };
314    let mut mount_id: libc::c_int = 0;
315
316    // SAFETY: path_cstr is a valid null-terminated C string. fh and mount_id
317    // are valid mutable pointers. The kernel writes the file handle into fh.
318    let ret = unsafe {
319        libc::syscall(
320            libc::SYS_name_to_handle_at,
321            libc::AT_FDCWD,
322            path_cstr.as_ptr(),
323            &mut fh as *mut FileHandleBuf,
324            &mut mount_id as *mut libc::c_int,
325            0 as libc::c_int, // flags
326        )
327    };
328
329    if ret != 0 {
330        return Err(std::io::Error::last_os_error());
331    }
332
333    let handle_len = fh.handle_bytes as usize;
334    if handle_len > MAX_HANDLE_SZ {
335        return Err(std::io::Error::new(
336            std::io::ErrorKind::InvalidData,
337            format!("NFS handle too large: {} > {}", handle_len, MAX_HANDLE_SZ),
338        ));
339    }
340
341    let raw = &fh.f_handle[..handle_len];
342
343    // For NFS CLIENT mounts (handle_type >= 2), the kernel stores:
344    //   [0..4]   fileid (inode, LE u32)
345    //   [4..8]   generation (LE u32)
346    //   [8..14]  internal metadata (size fields, padding)
347    //   [14..14+N] the actual NFS wire protocol filehandle
348    //   [14+N..] padding zeros
349    //
350    // The wire filehandle is what the NFS server gave during mount.
351    // We extract it by finding the non-zero payload after the 14-byte header,
352    // trimming trailing zeros.
353    if fh.handle_type >= 2 && handle_len > 14 {
354        let wire_region = &raw[14..];
355        // Trim trailing zero padding
356        let wire_len = wire_region.iter().rposition(|&b| b != 0)
357            .map(|p| p + 1)
358            .unwrap_or(0);
359        if wire_len > 0 {
360            return Ok(wire_region[..wire_len].to_vec());
361        }
362    }
363
364    // Fallback: return full handle
365    Ok(raw.to_vec())
366}
367
368#[cfg(test)]
369mod tests {
370    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
371    use crate::constants;
372
373    #[test]
374    fn test_parse_nfs_source() {
375        // Test source parsing
376        let source = "server.example.com:/vol/data";
377        let (server, export) = source.split_once(':').unwrap();
378        assert_eq!(server, "server.example.com");
379        assert_eq!(export, "/vol/data");
380    }
381
382    #[test]
383    fn test_kerberos_detection() {
384        let opts = "rw,vers=4.2,sec=krb5p,rsize=1048576";
385        assert!(opts.contains("sec=krb5"));
386    }
387
388    #[test]
389    fn test_nfs_security_parse() {
390        use super::NfsSecurity;
391
392        let parse = |opts: &str| -> NfsSecurity {
393            opts.split(',')
394                .find_map(|opt| match opt.trim() {
395                    "sec=krb5p" => Some(NfsSecurity::Krb5p),
396                    "sec=krb5i" => Some(NfsSecurity::Krb5i),
397                    "sec=krb5" => Some(NfsSecurity::Krb5),
398                    _ => None,
399                })
400                .unwrap_or(NfsSecurity::Sys)
401        };
402
403        assert_eq!(parse("rw,vers=4.2,sec=krb5p,rsize=1048576"), NfsSecurity::Krb5p);
404        assert_eq!(parse("rw,vers=4.2,sec=krb5i"), NfsSecurity::Krb5i);
405        assert_eq!(parse("rw,vers=4.2,sec=krb5"), NfsSecurity::Krb5);
406        assert_eq!(parse("rw,vers=4.2,rsize=1048576"), NfsSecurity::Sys);
407        assert_eq!(parse("rw,vers=4.2,sec=sys"), NfsSecurity::Sys);
408    }
409
410    #[test]
411    fn test_nfs_security_requires_kerberos() {
412        use super::NfsSecurity;
413
414        assert!(!NfsSecurity::Sys.requires_kerberos());
415        assert!(NfsSecurity::Krb5.requires_kerberos());
416        assert!(NfsSecurity::Krb5i.requires_kerberos());
417        assert!(NfsSecurity::Krb5p.requires_kerberos());
418    }
419
420    #[test]
421    fn test_nfs_security_display() {
422        use super::NfsSecurity;
423
424        assert_eq!(format!("{}", NfsSecurity::Sys), "sys");
425        assert_eq!(format!("{}", NfsSecurity::Krb5), "krb5");
426        assert_eq!(format!("{}", NfsSecurity::Krb5i), "krb5i");
427        assert_eq!(format!("{}", NfsSecurity::Krb5p), "krb5p");
428    }
429
430    #[test]
431    fn test_nfs_security_default() {
432        use super::NfsSecurity;
433        assert_eq!(NfsSecurity::default(), NfsSecurity::Sys);
434    }
435
436    #[test]
437    fn test_v42_detection() {
438        assert!("vers=4.2,rsize=1048576".contains("vers=4.2"));
439        assert!("nfsvers=4.2".contains("nfsvers=4.2"));
440        assert!(!"vers=4.1,rsize=1048576".contains("vers=4.2"));
441    }
442
443    #[test]
444    fn test_port_extraction() {
445        let opts = "rw,vers=4.2,port=2050,rsize=1048576";
446        let port: u16 = opts.split(',')
447            .find_map(|opt| {
448                let (k, v) = opt.split_once('=')?;
449                if k == "port" { v.parse().ok() } else { None }
450            })
451            .unwrap_or(constants::NFS_DEFAULT_PORT);
452        assert_eq!(port, 2050);
453
454        let opts_no_port = "rw,vers=4.2,rsize=1048576";
455        let port2: u16 = opts_no_port.split(',')
456            .find_map(|opt| {
457                let (k, v) = opt.split_once('=')?;
458                if k == "port" { v.parse().ok() } else { None }
459            })
460            .unwrap_or(constants::NFS_DEFAULT_PORT);
461        assert_eq!(port2, constants::NFS_DEFAULT_PORT);
462    }
463
464    #[test]
465    fn transport_security_default_is_none() {
466        use super::NfsTransportSecurity;
467        assert_eq!(NfsTransportSecurity::default(), NfsTransportSecurity::None);
468    }
469
470    #[test]
471    fn transport_security_parse_tls() {
472        use super::NfsTransportSecurity;
473        let opts = "rw,sec=krb5,nfsvers=4.2,xprtsec=tls";
474        let result: Option<NfsTransportSecurity> = opts
475            .split(',')
476            .find_map(|opt| match opt.trim() {
477                "xprtsec=tls" | "xprtsec=mtls" => Some(NfsTransportSecurity::Tls),
478                "xprtsec=none" => Some(NfsTransportSecurity::None),
479                _ => None,
480            });
481        assert_eq!(result, Some(NfsTransportSecurity::Tls));
482    }
483
484    #[test]
485    fn transport_security_parse_mtls() {
486        use super::NfsTransportSecurity;
487        let opts = "rw,sec=krb5p,nfsvers=4.2,xprtsec=mtls";
488        let result: Option<NfsTransportSecurity> = opts
489            .split(',')
490            .find_map(|opt| match opt.trim() {
491                "xprtsec=tls" | "xprtsec=mtls" => Some(NfsTransportSecurity::Tls),
492                "xprtsec=none" => Some(NfsTransportSecurity::None),
493                _ => None,
494            });
495        assert_eq!(result, Some(NfsTransportSecurity::Tls));
496    }
497
498    #[test]
499    fn transport_security_absent_defaults_to_none() {
500        use super::NfsTransportSecurity;
501        let opts = "rw,sec=sys,nfsvers=4.2";
502        let result = opts
503            .split(',')
504            .find_map(|opt| match opt.trim() {
505                "xprtsec=tls" | "xprtsec=mtls" => Some(NfsTransportSecurity::Tls),
506                "xprtsec=none" => Some(NfsTransportSecurity::None),
507                _ => None,
508            })
509            .unwrap_or(NfsTransportSecurity::None);
510        assert_eq!(result, NfsTransportSecurity::None);
511    }
512
513    #[test]
514    fn detect_rdma_proto_in_mount_options() {
515        let opts = "rw,vers=4.2,proto=rdma,sec=krb5p,rsize=1048576";
516        let rdma = opts.split(',').any(|opt| opt.trim() == "proto=rdma");
517        assert!(rdma);
518    }
519
520    #[test]
521    fn detect_tcp_proto_as_not_rdma() {
522        let opts = "rw,vers=4.2,proto=tcp,sec=sys,rsize=1048576";
523        let rdma = opts.split(',').any(|opt| opt.trim() == "proto=rdma");
524        assert!(!rdma);
525
526        let opts_no_proto = "rw,vers=4.2,sec=sys";
527        let rdma2 = opts_no_proto.split(',').any(|opt| opt.trim() == "proto=rdma");
528        assert!(!rdma2);
529    }
530}