use onc_rpc_client::transport::DirectTransport;
use std::net::SocketAddr;
use anyhow::Context as _;
use nfs_v3::MountClient;
use nfs_v3::wire::mount::{dirpath, export_node};
use onc_xdr::Opaque;
use crate::proto::auth::{AuthFlavor, Credential};
use crate::proto::nfs3::types::FileHandle;
#[derive(Debug, Clone)]
pub(crate) struct MountResult {
pub handle: FileHandle,
pub auth_flavors: Vec<u32>,
pub _parsed_flavors: Vec<AuthFlavor>,
}
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
pub(crate) struct ExportEntry {
pub path: String,
pub allowed_hosts: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub auth_flavors: Vec<u32>,
#[serde(skip_serializing_if = "String::is_empty")]
pub handle_hex: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub(crate) struct MountedClient {
pub hostname: String,
pub directory: String,
}
#[derive(Debug, Clone)]
pub(crate) struct NfsMountClient {
mount_port: Option<u16>,
privileged_required: bool,
proxy: Option<String>,
credential: Credential,
}
impl NfsMountClient {
#[must_use]
pub(crate) const fn new() -> Self {
Self { mount_port: None, privileged_required: false, proxy: None, credential: Credential::None }
}
#[must_use]
pub(crate) const fn with_port(port: u16) -> Self {
Self { mount_port: Some(port), privileged_required: false, proxy: None, credential: Credential::None }
}
#[must_use]
pub(crate) const fn require_privileged(mut self) -> Self {
self.privileged_required = true;
self
}
#[must_use]
pub(crate) fn with_credential(mut self, credential: Credential) -> Self {
self.credential = credential;
self
}
pub(crate) fn with_proxy(mut self, proxy: String) -> Self {
self.proxy = Some(proxy);
self
}
pub(crate) async fn mount(&self, addr: SocketAddr, export: &str) -> anyhow::Result<MountResult> {
match self.mount_once(addr, export).await {
Ok(r) => Ok(r),
Err(e) if self.privileged_required => Err(e),
Err(e) => {
if downcast_mnt_acces(&e) {
tracing::warn!(%addr, %export, "MNT returned ACCES from ephemeral source port; retrying with privileged-only");
let priv_client = Self { mount_port: self.mount_port, privileged_required: true, proxy: self.proxy.clone(), credential: self.credential.clone() };
priv_client.mount_once(addr, export).await.with_context(|| format!("MNT {export} (privileged retry)"))
} else {
Err(e)
}
},
}
}
async fn mount_once(&self, addr: SocketAddr, export: &str) -> anyhow::Result<MountResult> {
let client = self.connect(addr).await?;
let path = dirpath(Opaque::owned(export.as_bytes().to_vec()));
let res = client.v3_mnt(path).await.with_context(|| format!("MNT {export}"))?;
let handle = FileHandle::from_bytes(res.fhandle.0.as_ref());
let parsed_flavors = res.auth_flavors.iter().map(|&f| parse_flavor(f)).collect();
Ok(MountResult { handle, auth_flavors: res.auth_flavors, _parsed_flavors: parsed_flavors })
}
pub(crate) async fn mount_v1(&self, addr: SocketAddr, export: &str) -> anyhow::Result<MountResult> {
match self.mount_v1_once(addr, export).await {
Ok(r) => Ok(r),
Err(e) if self.privileged_required => Err(e),
Err(e) => {
if downcast_mnt_acces(&e) {
tracing::warn!(%addr, %export, "MNT v1 returned ACCES; retrying with privileged-only");
let priv_client = Self { mount_port: self.mount_port, privileged_required: true, proxy: self.proxy.clone(), credential: self.credential.clone() };
priv_client.mount_v1_once(addr, export).await.with_context(|| format!("MNT v1 {export} (privileged retry)"))
} else {
Err(e)
}
},
}
}
async fn mount_v1_once(&self, addr: SocketAddr, export: &str) -> anyhow::Result<MountResult> {
use onc_rpc_client::rpc::RpcClient;
let portmap = match &self.proxy {
Some(p) => crate::proto::portmap::PortmapClient::default_port().with_proxy(p.clone()),
None => crate::proto::portmap::PortmapClient::default_port(),
};
let port = match self.mount_port {
Some(p) => p,
None => portmap.query_port(addr, 100_005, 1).await.with_context(|| "GETPORT for MOUNT v1")?,
};
let mount_addr = SocketAddr::new(addr.ip(), port);
let io = if let Some(ref p) = self.proxy {
let proxy_addr = crate::proto::conn::parse_proxy_addr(p)?;
let stream = crate::proto::conn::socks5_connect(proxy_addr, mount_addr).await.with_context(|| format!("SOCKS5 connect to mountd v1 at {mount_addr}"))?;
onc_rpc_client::transport::tokio::TokioIo::new(stream)
} else if self.privileged_required {
connect_privileged_only(mount_addr).await.with_context(|| format!("connect to mountd v1 at {mount_addr} (privileged-only)"))?
} else {
connect_privileged_or_fallback(mount_addr).await.with_context(|| format!("connect to mountd v1 at {mount_addr}"))?
};
let opaque = self.credential.to_opaque_auth();
let mut rpc = RpcClient::new_with_auth(io, opaque, onc_rpc_client::rpc::opaque_auth::default());
let path = dirpath(Opaque::owned(export.as_bytes().to_vec()));
let result: FhStatus = rpc.call(100_005, 1, 1, &path).await.with_context(|| format!("MNT v1 {export}"))?;
if result.status != 0 {
anyhow::bail!("MNT v1 {export}: status {}", result.status);
}
let handle = FileHandle::from_bytes(&result.fhandle);
Ok(MountResult { handle, auth_flavors: vec![1], _parsed_flavors: vec![AuthFlavor::Sys] })
}
pub(crate) async fn unmount(&self, addr: SocketAddr, export: &str) -> anyhow::Result<()> {
let client = self.connect(addr).await?;
let path = dirpath(Opaque::owned(export.as_bytes().to_vec()));
client.umnt(path).await.with_context(|| format!("UMNT {export}"))
}
pub(crate) async fn list_exports(&self, addr: SocketAddr) -> anyhow::Result<Vec<ExportEntry>> {
let client = self.connect(addr).await?;
let exports = client.export().await.context("MNTPROC_EXPORT v3")?;
Ok(exports.into_inner().into_iter().map(export_entry_from).collect())
}
pub(crate) async fn list_exports_v1(&self, addr: SocketAddr) -> anyhow::Result<Vec<ExportEntry>> {
use nfs_mount::wire::exports;
use onc_rpc_client::rpc::RpcClient;
use onc_xdr::Void;
let portmap = match &self.proxy {
Some(p) => crate::proto::portmap::PortmapClient::default_port().with_proxy(p.clone()),
None => crate::proto::portmap::PortmapClient::default_port(),
};
let port = match self.mount_port {
Some(p) => p,
None => portmap.query_port(addr, 100_005, 1).await.with_context(|| "GETPORT for MOUNT v1")?,
};
let mount_addr = SocketAddr::new(addr.ip(), port);
let io = if let Some(ref p) = self.proxy {
let proxy_addr = crate::proto::conn::parse_proxy_addr(p)?;
let stream = crate::proto::conn::socks5_connect(proxy_addr, mount_addr).await.with_context(|| format!("SOCKS5 connect to mountd v1 at {mount_addr}"))?;
onc_rpc_client::transport::tokio::TokioIo::new(stream)
} else if self.privileged_required {
connect_privileged_only(mount_addr).await.with_context(|| format!("connect to mountd v1 at {mount_addr} (privileged-only)"))?
} else {
connect_privileged_or_fallback(mount_addr).await.with_context(|| format!("connect to mountd v1 at {mount_addr}"))?
};
let mut rpc = RpcClient::new(io);
let result: exports<'_, '_> = rpc.call(100_005, 1, 5, &Void).await.context("MOUNT v1 EXPORT")?;
Ok(result.into_inner().into_iter().map(export_entry_from).collect())
}
pub(crate) async fn dump_clients(&self, addr: SocketAddr) -> anyhow::Result<Vec<MountedClient>> {
let client = self.connect(addr).await?;
let dump = client.dump().await.context("MNTPROC_DUMP")?;
Ok(dump.into_inner().into_iter().map(|b| MountedClient { hostname: bytes_to_string(b.ml_hostname.0.as_ref()), directory: bytes_to_string(b.ml_directory.0.as_ref()) }).collect())
}
async fn connect(&self, addr: SocketAddr) -> anyhow::Result<MountClient<DirectTransport<crate::proto::conn::NfsIo>>> {
let portmap = match &self.proxy {
Some(p) => crate::proto::portmap::PortmapClient::default_port().with_proxy(p.clone()),
None => crate::proto::portmap::PortmapClient::default_port(),
};
let port = match self.mount_port {
Some(p) => p,
None => match portmap.query_port(addr, 100_005, 3).await {
Ok(p) => p,
Err(e) => {
tracing::warn!(%addr, error = %e, "portmapper unavailable, trying mountd on well-known fallback ports");
self.probe_mountd_fallback(addr).await.with_context(|| format!("portmapper unavailable and mountd not found on fallback ports (2049, 20048) -- use --mount-port to specify manually\n portmapper error: {e}"))?
},
},
};
let mount_addr = SocketAddr::new(addr.ip(), port);
let io = if let Some(ref p) = self.proxy {
let proxy_addr = crate::proto::conn::parse_proxy_addr(p)?;
let stream = crate::proto::conn::socks5_connect(proxy_addr, mount_addr).await.with_context(|| format!("SOCKS5 connect to mountd at {mount_addr} via {p}"))?;
onc_rpc_client::transport::tokio::TokioIo::new(stream)
} else if self.privileged_required {
connect_privileged_only(mount_addr).await.with_context(|| format!("connect to mountd at {mount_addr} (privileged-only)"))?
} else {
connect_privileged_or_fallback(mount_addr).await.with_context(|| format!("connect to mountd at {mount_addr}"))?
};
Ok(MountClient::v3(DirectTransport::with_auth(io, self.credential.to_opaque_auth(), onc_rpc_client::rpc::opaque_auth::default())))
}
async fn probe_mountd_fallback(&self, addr: SocketAddr) -> anyhow::Result<u16> {
const FALLBACK_PORTS: &[u16] = &[2049, 20048];
for &port in FALLBACK_PORTS {
let mount_addr = SocketAddr::new(addr.ip(), port);
let io_result = if let Some(ref p) = self.proxy {
let proxy_addr = crate::proto::conn::parse_proxy_addr(p)?;
crate::proto::conn::socks5_connect(proxy_addr, mount_addr).await.map(onc_rpc_client::transport::tokio::TokioIo::new)
} else {
use onc_rpc_client::transport::net::Connector as _;
onc_rpc_client::transport::tokio::TokioConnector.connect(mount_addr).await
};
let Ok(io) = io_result else { continue };
let mc = MountClient::v3(DirectTransport::new(io));
if mc.export().await.is_ok() {
tracing::info!(%addr, port, "mountd found on fallback port");
return Ok(port);
}
}
anyhow::bail!("mountd not reachable on fallback ports {FALLBACK_PORTS:?}")
}
}
impl Default for NfsMountClient {
fn default() -> Self {
Self::new()
}
}
fn downcast_mnt_acces(err: &anyhow::Error) -> bool {
use nfs_mount::MountError;
use nfs_mount::wire::mountstat3;
err.chain().any(|cause| matches!(cause.downcast_ref::<MountError<onc_rpc_client::RpcError>>(), Some(MountError::Status(mountstat3::MNT3ERR_ACCES))))
}
async fn connect_privileged_or_fallback(addr: SocketAddr) -> std::io::Result<crate::proto::conn::NfsIo> {
use onc_rpc_client::transport::net::Connector as _;
use onc_rpc_client::transport::tokio::TokioConnector;
for local_port in 300_u16..1024 {
match TokioConnector.connect_with_port(addr, local_port).await {
Ok(io) => return Ok(io),
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
tracing::debug!(%addr, "no privilege to bind <1024, falling back to ephemeral");
break;
},
Err(e) => {
tracing::trace!(%addr, port = local_port, %e, "privileged mountd connect failed, trying next port");
},
}
}
tracing::warn!(%addr, "privileged port binding failed, falling back to ephemeral port -- server may reject with EACCES");
TokioConnector.connect(addr).await
}
async fn connect_privileged_only(addr: SocketAddr) -> std::io::Result<crate::proto::conn::NfsIo> {
use onc_rpc_client::transport::net::Connector as _;
use onc_rpc_client::transport::tokio::TokioConnector;
let mut last_err: Option<std::io::Error> = None;
for local_port in 300_u16..1024 {
match TokioConnector.connect_with_port(addr, local_port).await {
Ok(io) => return Ok(io),
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => return Err(e),
Err(e) => {
tracing::trace!(%addr, port = local_port, %e, "privileged-only mountd connect failed, trying next port");
last_err = Some(e);
},
}
}
Err(last_err.unwrap_or_else(|| std::io::Error::other("privileged source port range 300-1023 exhausted")))
}
const fn parse_flavor(raw: u32) -> AuthFlavor {
AuthFlavor::from_u32(raw)
}
fn export_entry_from(node: export_node<'_, '_>) -> ExportEntry {
let path = bytes_to_string(node.ex_dir.0.as_ref());
let allowed_hosts = node.ex_groups.into_inner().into_iter().map(|n| bytes_to_string(n.0.as_ref())).collect();
ExportEntry { path, allowed_hosts, auth_flavors: Vec::new(), handle_hex: String::new() }
}
fn bytes_to_string(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
use nfs_mount::wire::FhStatus;