Skip to main content

fxcp_core/nfs/
mod.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/mod.rs  --  NFSv4.2 userspace compound RPC bypass
5
6//! Userspace NFSv4.2 compound RPC client for bypassing VFS per-file overhead.
7//!
8//! Instead of routing small file writes through the kernel VFS (4-6 NFS round-trips
9//! per file), this module constructs raw XDR compound payloads and sends them over
10//! TCP directly to the NFS server. One compound = one round-trip per file.
11//!
12//! Only active for NFSv4.2 targets with AUTH_SYS, files <=16MB.
13
14pub mod mount;
15pub mod xdr;
16pub mod rpc;
17pub mod client;
18#[cfg(feature = "krb5")]
19pub mod gss;
20#[cfg(feature = "tls")]
21pub(crate) mod tls_transport;
22#[cfg(feature = "rdma")]
23pub(crate) mod rdma_ffi;
24#[cfg(feature = "rdma")]
25pub(crate) mod rdma_msg;
26#[cfg(feature = "rdma")]
27pub(crate) mod rdma_transport;
28
29pub use mount::NfsBypassInfo;
30pub use mount::NfsSecurity;
31pub use mount::NfsTransportSecurity;
32pub use client::NfsCompoundClient;
33pub use client::NfsClientPool;
34
35/// Maximum file size eligible for NFS compound bypass (16MB).
36pub const NFS_BYPASS_MAX_SIZE: u64 = 16 * 1024 * 1024;
37
38/// Maximum file data for compound WRITE ops (1MB). Servers negotiate
39/// ca_maxrequestsize (~2MB on knfsd); compound headers consume ~300B.
40pub const NFS_BYPASS_WRITE_MAX: u64 = 1_048_576;
41
42/// The transport protocol used for an NFS connection.
43///
44/// This is distinct from [`NfsTransportSecurity`] (which describes the
45/// *security layer*  --  AUTH_SYS, krb5, TLS) and from [`NfsTransport`]
46/// (which is the runtime connection enum in `client.rs`).
47///
48/// `NfsTransportType` is a lightweight, copyable tag used by the advtuner,
49/// EventBus, and BatchTuner to make transport-aware decisions without
50/// carrying the full connection state.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub enum NfsTransportType {
53    /// Plain TCP (NFSv4.2 compound RPC bypass, no encryption)
54    Tcp,
55    /// TLS 1.3 via rustls (RFC 9289 STARTTLS upgrade)
56    Tls,
57    /// kTLS  --  TLS promoted to kernel offload via setsockopt(SOL_TLS)
58    Ktls,
59    /// RPC-over-RDMA v1 (RFC 8166) via SoftRoCE or hardware RoCE v2
60    Rdma,
61}
62
63impl NfsTransportType {
64    /// Returns true if this transport uses TLS encryption (Tls or Ktls).
65    pub fn is_tls(&self) -> bool {
66        matches!(self, Self::Tls | Self::Ktls)
67    }
68
69    /// Returns true if this transport uses RDMA (credit-based inflight governor).
70    pub fn is_rdma(&self) -> bool {
71        matches!(self, Self::Rdma)
72    }
73
74    /// Returns a short string label for Prometheus labels and CloudEvents extensions.
75    pub fn as_str(&self) -> &'static str {
76        match self {
77            Self::Tcp => "tcp",
78            Self::Tls => "tls",
79            Self::Ktls => "ktls",
80            Self::Rdma => "rdma",
81        }
82    }
83}
84
85/// NFSv4 identity mapping: uid/gid -> user@domain / group@domain strings.
86///
87/// NFSv4 SETATTR OWNER/OWNER_GROUP attributes require utf8str_cs format
88/// (e.g. "aenertia@3d.ae.net.nz"), not numeric UIDs. The domain suffix
89/// comes from /etc/idmapd.conf [General] Domain, matching rpc.idmapd on
90/// the NFS server.
91pub mod idmap {
92    use std::sync::OnceLock;
93    use tracing::debug;
94
95    static IDMAP_DOMAIN: OnceLock<String> = OnceLock::new();
96
97    fn read_idmap_domain() -> String {
98        if let Ok(content) = std::fs::read_to_string("/etc/idmapd.conf") {
99            for line in content.lines() {
100                let trimmed = line.trim();
101                if let Some(rest) = trimmed.strip_prefix("Domain")
102                    && let Some(val) = rest.trim().strip_prefix('=') {
103                        let domain = val.trim().to_string();
104                        if !domain.is_empty() {
105                            debug!("idmap domain from /etc/idmapd.conf: {}", domain);
106                            return domain;
107                        }
108                    }
109            }
110        }
111        if let Ok(hostname) = std::fs::read_to_string("/proc/sys/kernel/hostname")
112            && let Some(dot) = hostname.trim().find('.') {
113                let domain = hostname.trim()[dot + 1..].to_string();
114                debug!("idmap domain from hostname: {}", domain);
115                return domain;
116            }
117        debug!("idmap domain: fallback to localdomain");
118        "localdomain".to_string()
119    }
120
121    fn domain() -> &'static str {
122        IDMAP_DOMAIN.get_or_init(read_idmap_domain)
123    }
124
125    pub fn uid_to_owner(uid: u32) -> Option<String> {
126        let mut buf = [0u8; crate::constants::NFS_NSS_BUFFER_SIZE];
127        let mut pwd = std::mem::MaybeUninit::<libc::passwd>::uninit();
128        let mut result = std::ptr::null_mut::<libc::passwd>();
129        let ret = unsafe {
130            libc::getpwuid_r(
131                uid,
132                pwd.as_mut_ptr(),
133                buf.as_mut_ptr() as *mut libc::c_char,
134                buf.len(),
135                &mut result,
136            )
137        };
138        if ret == 0 && !result.is_null() {
139            let name = unsafe { std::ffi::CStr::from_ptr((*result).pw_name) };
140            if let Ok(name_str) = name.to_str() {
141                return Some(format!("{}@{}", name_str, domain()));
142            }
143        }
144        None
145    }
146
147    pub fn gid_to_group(gid: u32) -> Option<String> {
148        let mut buf = [0u8; crate::constants::NFS_NSS_BUFFER_SIZE];
149        let mut grp = std::mem::MaybeUninit::<libc::group>::uninit();
150        let mut result = std::ptr::null_mut::<libc::group>();
151        let ret = unsafe {
152            libc::getgrgid_r(
153                gid,
154                grp.as_mut_ptr(),
155                buf.as_mut_ptr() as *mut libc::c_char,
156                buf.len(),
157                &mut result,
158            )
159        };
160        if ret == 0 && !result.is_null() {
161            let name = unsafe { std::ffi::CStr::from_ptr((*result).gr_name) };
162            if let Ok(name_str) = name.to_str() {
163                return Some(format!("{}@{}", name_str, domain()));
164            }
165        }
166        None
167    }
168}
169
170/// NFS-specific errors for the compound RPC bypass.
171#[derive(Debug, thiserror::Error)]
172pub enum NfsError {
173    #[error("TCP connection failed: {0}")]
174    ConnectionFailed(#[from] std::io::Error),
175
176    #[error("Session establishment failed: {0}")]
177    SessionFailed(String),
178
179    #[error("Stale file handle for {path}")]
180    StaleHandle { path: String },
181
182    #[error("NFS4 error {code}: {message}")]
183    Nfs4Error { code: u32, message: String },
184
185    #[error("XDR decode error: {0}")]
186    XdrDecode(String),
187
188    #[error("RPC error: {0}")]
189    RpcError(String),
190
191    #[error("Timeout after {0:?}")]
192    Timeout(std::time::Duration),
193
194    #[error("File too large for bypass: {size} > {max}")]
195    FileTooLarge { size: u64, max: u64 },
196
197    #[error("Kerberos auth required -- bypass unavailable")]
198    KerberosRequired,
199
200    #[error("RPC-over-TLS not supported by server (no STARTTLS response)")]
201    TlsNotSupported,
202
203    #[error("TLS handshake failed: {0}")]
204    TlsHandshakeFailed(String),
205
206    #[cfg(feature = "rdma")]
207    #[error("RDMA transport error: {0}")]
208    Rdma(String),
209}