use std::net::{IpAddr, SocketAddr};
use std::time::{Duration, Instant};
use anyhow::Context as _;
use nfs_v3::wire::{GETATTR3args, Nfs3Result, nfs_fh3, nfsstat3};
use onc_rpc_client::rpc::RpcClient;
use onc_rpc_client::transport::net::Connector;
use onc_rpc_client::transport::tokio::{TokioConnector, TokioIo};
use onc_xdr::{Pack, Unpack, Void};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use crate::proto::auth::Credential;
use nfs_v3::{PROGRAM as NFS_PROGRAM, VERSION as NFS_VERSION_3};
const NFSPROC3_GETATTR: u32 = 1;
const NFSPROC3_NULL: u32 = 0;
const NFS_DEFAULT_PORT: u16 = 2049;
pub(crate) type NfsIo = TokioIo<TcpStream>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReconnectStrategy {
Persistent,
}
#[derive(Debug)]
pub(crate) struct ConnectionHealth {
pub _created_at: Instant,
pub last_used: Instant,
pub request_count: u64,
pub poisoned: bool,
}
impl ConnectionHealth {
fn new() -> Self {
let now = Instant::now();
Self { _created_at: now, last_used: now, request_count: 0, poisoned: false }
}
}
pub(crate) struct NfsConnection {
rpc: RpcClient<NfsIo>,
pub root: Option<Vec<u8>>,
pub _auth_flavors: Vec<u32>,
pub addr: SocketAddr,
pub export: String,
pub credential: Credential,
_reconnect: ReconnectStrategy,
pub health: ConnectionHealth,
}
impl std::fmt::Debug for NfsConnection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NfsConnection").field("addr", &self.addr).field("export", &self.export).field("request_count", &self.health.request_count).field("poisoned", &self.health.poisoned).finish_non_exhaustive()
}
}
impl NfsConnection {
pub(crate) async fn connect(addr: SocketAddr, export: &str, credential: Credential, reconnect: ReconnectStrategy, proxy: Option<&str>) -> anyhow::Result<Self> {
let mut mount = crate::proto::mount::NfsMountClient::default().with_credential(credential.clone());
let mut portmap = crate::proto::portmap::PortmapClient::default_port();
if let Some(p) = proxy {
mount = mount.with_proxy(p.to_owned());
portmap = portmap.with_proxy(p.to_owned());
}
let mounted = mount.mount(addr, export).await.with_context(|| format!("mount {export} on {addr}"))?;
let nfs_port = match portmap.query_port(addr, NFS_PROGRAM, NFS_VERSION_3).await {
Ok(p) => p,
Err(e) => {
tracing::warn!(%addr, err = %e, port = NFS_DEFAULT_PORT, "portmapper did not answer for NFSv3; assuming the conventional port");
NFS_DEFAULT_PORT
},
};
let nfs_addr = SocketAddr::new(addr.ip(), nfs_port);
let io = Self::open(nfs_addr, proxy).await.with_context(|| format!("NFS connect to {nfs_addr}"))?;
let rpc = RpcClient::new_with_auth(io, credential.to_opaque_auth(), onc_rpc_client::rpc::opaque_auth::default());
Ok(Self { rpc, root: Some(mounted.handle.as_bytes().to_vec()), _auth_flavors: mounted.auth_flavors, addr, export: export.to_owned(), credential, _reconnect: reconnect, health: ConnectionHealth::new() })
}
pub(crate) async fn connect_direct(addr: SocketAddr, nfs_port: u16, credential: Credential, reconnect: ReconnectStrategy, proxy: Option<&str>) -> anyhow::Result<Self> {
let nfs_addr = SocketAddr::new(addr.ip(), nfs_port);
let io = Self::open(nfs_addr, proxy).await.with_context(|| format!("direct NFS connect to {nfs_addr}"))?;
let rpc = RpcClient::new_with_auth(io, credential.to_opaque_auth(), onc_rpc_client::rpc::opaque_auth::default());
Ok(Self { rpc, root: None, _auth_flavors: vec![1], addr, export: format!("__direct__{nfs_port}"), credential, _reconnect: reconnect, health: ConnectionHealth::new() })
}
async fn open(target: SocketAddr, proxy: Option<&str>) -> anyhow::Result<NfsIo> {
if let Some(p) = proxy {
let proxy_addr = parse_proxy_addr(p)?;
let stream = socks5_connect(proxy_addr, target).await?;
Ok(TokioIo::new(stream))
} else {
Ok(connect_privileged_nfs(target).await?)
}
}
pub(crate) async fn call<C, R>(&mut self, program: u32, version: u32, proc: u32, args: &C) -> Result<R, onc_rpc_client::RpcError>
where
C: Pack + Send + Sync,
R: Unpack,
{
self.health.request_count = self.health.request_count.saturating_add(1);
self.health.last_used = Instant::now();
self.rpc.credential = self.credential.to_opaque_auth();
self.rpc.call::<C, R>(program, version, proc, args).await
}
pub(crate) async fn call_as<C, R>(&mut self, cred: onc_rpc_client::rpc::opaque_auth<'static>, program: u32, version: u32, proc: u32, args: &C) -> Result<R, onc_rpc_client::RpcError>
where
C: Pack + Send + Sync,
R: Unpack,
{
self.health.request_count = self.health.request_count.saturating_add(1);
self.health.last_used = Instant::now();
self.rpc.credential = cred;
let result = self.rpc.call::<C, R>(program, version, proc, args).await;
self.rpc.credential = self.credential.to_opaque_auth();
result
}
pub(crate) async fn health_check(&mut self) -> bool {
let Some(root) = self.root.clone() else {
return self.call::<Void, Void>(NFS_PROGRAM, NFS_VERSION_3, NFSPROC3_NULL, &Void).await.is_ok();
};
let args = GETATTR3args { object: nfs_fh3 { data: onc_xdr::Opaque::owned(root) } };
match self.call::<_, nfs_v3::wire::GETATTR3res>(NFS_PROGRAM, NFS_VERSION_3, NFSPROC3_GETATTR, &args).await {
Ok(Nfs3Result::Ok(_)) => true,
Ok(Nfs3Result::Err((status, _))) => matches!(status, nfsstat3::NFS3ERR_ACCES | nfsstat3::NFS3ERR_PERM),
Ok(_) | Err(_) => false,
}
}
pub(crate) const fn poison(&mut self) {
self.health.poisoned = true;
}
pub(crate) fn is_stale(&self, threshold: Duration) -> bool {
self.health.last_used.elapsed() > threshold
}
pub(crate) fn update_credential(&mut self, credential: Credential) {
self.credential = credential;
}
}
pub(crate) async fn socks5_connect(proxy_addr: SocketAddr, target: SocketAddr) -> std::io::Result<TcpStream> {
let mut stream = TcpStream::connect(proxy_addr).await?;
stream.write_all(&[0x05, 0x01, 0x00]).await?;
let mut method_resp = [0u8; 2];
_ = stream.read_exact(&mut method_resp).await?;
if method_resp[0] != 0x05 || method_resp[1] != 0x00 {
return Err(std::io::Error::other(format!("SOCKS5 auth rejected (method byte=0x{:02x})", method_resp[1])));
}
let ip = match target.ip() {
IpAddr::V4(v4) => v4.octets(),
IpAddr::V6(_) => return Err(std::io::Error::other("SOCKS5 proxy: IPv6 target not supported")),
};
let port = target.port().to_be_bytes();
stream.write_all(&[0x05, 0x01, 0x00, 0x01, ip[0], ip[1], ip[2], ip[3], port[0], port[1]]).await?;
let mut head = [0u8; 4];
_ = stream.read_exact(&mut head).await?;
if head[1] != 0x00 {
return Err(std::io::Error::other(format!("SOCKS5 CONNECT failed (REP=0x{:02x})", head[1])));
}
let bnd_len = match head[3] {
0x01 => 4 + 2,
0x04 => 16 + 2,
0x03 => {
let mut len = [0u8; 1];
_ = stream.read_exact(&mut len).await?;
usize::from(len[0]) + 2
},
atyp => return Err(std::io::Error::other(format!("SOCKS5 CONNECT reply has unsupported ATYP=0x{atyp:02x}"))),
};
let mut bnd = vec![0u8; bnd_len];
_ = stream.read_exact(&mut bnd).await?;
Ok(stream)
}
pub(crate) fn parse_proxy_addr(proxy: &str) -> anyhow::Result<SocketAddr> {
let stripped = proxy.strip_prefix("socks5://").unwrap_or(proxy);
stripped.parse::<SocketAddr>().with_context(|| format!("invalid proxy address '{proxy}' (expected host:port or socks5://host:port)"))
}
async fn connect_privileged_nfs(addr: SocketAddr) -> std::io::Result<NfsIo> {
for port in 300_u16..1024 {
match TokioConnector.connect_with_port(addr, port).await {
Ok(io) => return Ok(io),
Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => {},
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::debug!(%addr, %e, "destination connect failed, not retrying other source ports");
break;
},
}
}
tracing::warn!(%addr, "privileged NFS port binding failed, falling back to ephemeral port");
TokioConnector.connect(addr).await
}