use std::net::SocketAddr;
use anyhow::Context as _;
use onc_rpc_client::rpc::RpcClient;
use onc_rpc_client::transport::net::Connector as _;
use onc_rpc_client::transport::tokio::{TokioConnector, TokioIo};
use tokio::net::TcpStream;
use crate::proto::nfs4::types::{ArgOp, AttrRequest, CompoundArgs, CompoundRes, NFS4_PROC_COMPOUND, NFS4_PROGRAM, NFS4_VERSION, ResOpData};
use crate::util::stealth::StealthConfig;
pub(crate) struct Nfs4DirectClient {
rpc: RpcClient<TokioIo<TcpStream>>,
addr: SocketAddr,
proxy: Option<String>,
stealth: StealthConfig,
aux_gids: Vec<u32>,
}
impl std::fmt::Debug for Nfs4DirectClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Nfs4DirectClient").field("addr", &self.addr).finish_non_exhaustive()
}
}
impl Nfs4DirectClient {
async fn connect_tcp(addr: SocketAddr, proxy: Option<&str>) -> anyhow::Result<TokioIo<TcpStream>> {
if let Some(p) = proxy {
let proxy_addr = crate::proto::conn::parse_proxy_addr(p)?;
let stream = crate::proto::conn::socks5_connect(proxy_addr, addr).await.with_context(|| format!("SOCKS5 connect to {addr} via {p}"))?;
Ok(TokioIo::new(stream))
} else {
TokioConnector.connect(addr).await.with_context(|| format!("NFSv4 TCP connect to {addr}"))
}
}
pub(crate) async fn connect_proxy(addr: SocketAddr, proxy: Option<&str>) -> anyhow::Result<Self> {
let null_auth = onc_rpc_client::rpc::opaque_auth::default();
let io = Self::connect_tcp(addr, proxy).await?;
let rpc = RpcClient::new_with_auth(io, null_auth.clone(), null_auth);
Ok(Self { rpc, addr, proxy: proxy.map(String::from), stealth: StealthConfig::none(), aux_gids: Vec::new() })
}
pub(crate) async fn connect_with_auth_proxy(addr: SocketAddr, uid: u32, gid: u32, hostname: &str, proxy: Option<&str>) -> anyhow::Result<Self> {
use crate::proto::auth::AuthSys;
let opaque = AuthSys::new(uid, gid, hostname).to_opaque_auth(crate::proto::auth::next_stamp());
let io = Self::connect_tcp(addr, proxy).await?;
let rpc = RpcClient::new_with_auth(io, opaque, onc_rpc_client::rpc::opaque_auth::default());
Ok(Self { rpc, addr, proxy: proxy.map(String::from), stealth: StealthConfig::none(), aux_gids: Vec::new() })
}
#[expect(dead_code, reason = "v4 shell with aux-GID support not yet wired up")]
pub(crate) async fn connect_with_groups_proxy(addr: SocketAddr, uid: u32, gid: u32, aux_gids: &[u32], hostname: &str, proxy: Option<&str>) -> anyhow::Result<Self> {
use crate::proto::auth::AuthSys;
let gids = aux_gids.to_vec();
let opaque = AuthSys::with_groups(uid, gid, &gids, hostname).to_opaque_auth(crate::proto::auth::next_stamp());
let io = Self::connect_tcp(addr, proxy).await?;
let rpc = RpcClient::new_with_auth(io, opaque, onc_rpc_client::rpc::opaque_auth::default());
Ok(Self { rpc, addr, proxy: proxy.map(String::from), stealth: StealthConfig::none(), aux_gids: aux_gids.to_vec() })
}
#[must_use]
pub(crate) const fn with_stealth(mut self, stealth: StealthConfig) -> Self {
self.stealth = stealth;
self
}
#[expect(dead_code, reason = "v4 shell identity-change path not yet wired up")]
pub(crate) async fn reconnect_with_auth(&mut self, uid: u32, gid: u32, hostname: &str) -> anyhow::Result<()> {
use crate::proto::auth::AuthSys;
let opaque = AuthSys::with_groups(uid, gid, &self.aux_gids, hostname).to_opaque_auth(crate::proto::auth::next_stamp());
let io = Self::connect_tcp(self.addr, self.proxy.as_deref()).await?;
self.rpc = RpcClient::new_with_auth(io, opaque, onc_rpc_client::rpc::opaque_auth::default());
Ok(())
}
pub(crate) async fn compound(&mut self, ops: Vec<ArgOp>) -> anyhow::Result<CompoundRes> {
self.stealth.wait().await;
let args = CompoundArgs { tag: String::new(), minorversion: 0, ops };
self.rpc.call::<CompoundArgs, CompoundRes>(NFS4_PROGRAM, NFS4_VERSION, NFS4_PROC_COMPOUND, &args).await.context("NFSv4 COMPOUND")
}
pub(crate) async fn compound_v41(&mut self, ops: Vec<ArgOp>) -> anyhow::Result<CompoundRes> {
self.stealth.wait().await;
let args = CompoundArgs { tag: String::new(), minorversion: 1, ops };
self.rpc.call::<CompoundArgs, CompoundRes>(NFS4_PROGRAM, NFS4_VERSION, NFS4_PROC_COMPOUND, &args).await.context("NFSv4.1 COMPOUND")
}
pub(crate) async fn get_root_fh(&mut self) -> anyhow::Result<Vec<u8>> {
let res = self.compound(vec![ArgOp::Putrootfh, ArgOp::Getfh]).await?;
anyhow::ensure!(res.status == 0, "PUTROOTFH/GETFH failed: NFSv4 status={}", res.status);
match res.results.get(1).map(|op| &op.data) {
Some(ResOpData::Fh(fh)) => Ok(fh.clone()),
_ => anyhow::bail!("GETFH result missing or wrong type"),
}
}
pub(crate) async fn lookup_fh(&mut self, components: &[&str]) -> anyhow::Result<Vec<u8>> {
if components.is_empty() {
return self.get_root_fh().await;
}
let mut ops = Vec::with_capacity(components.len() + 2);
ops.push(ArgOp::Putrootfh);
for &c in components {
ops.push(ArgOp::Lookup(c.to_owned()));
}
ops.push(ArgOp::Getfh);
let res = self.compound(ops).await?;
anyhow::ensure!(res.status == 0, "LOOKUP failed: NFSv4 status={}", res.status);
match res.results.last().map(|op| &op.data) {
Some(ResOpData::Fh(fh)) => Ok(fh.clone()),
_ => anyhow::bail!("GETFH result missing after LOOKUP chain"),
}
}
#[expect(dead_code, reason = "v4 shell LOOKUP-from-cwd path not yet wired up")]
pub(crate) async fn lookup_from_fh(&mut self, start_fh: &[u8], components: &[&str]) -> anyhow::Result<Vec<u8>> {
if components.is_empty() {
return Ok(start_fh.to_vec());
}
let mut ops = Vec::with_capacity(components.len() + 2);
ops.push(ArgOp::Putfh(start_fh.to_vec()));
for &c in components {
ops.push(ArgOp::Lookup(c.to_owned()));
}
ops.push(ArgOp::Getfh);
let res = self.compound(ops).await?;
anyhow::ensure!(res.status == 0, "LOOKUP failed: NFSv4 status={}", res.status);
match res.results.last().map(|op| &op.data) {
Some(ResOpData::Fh(fh)) => Ok(fh.clone()),
_ => anyhow::bail!("GETFH result missing after LOOKUP chain"),
}
}
pub(crate) async fn list_dir(&mut self, dir_fh: &[u8]) -> anyhow::Result<Vec<String>> {
const MAX_READDIR_ENTRIES: usize = 1_000_000;
let mut names = Vec::new();
let mut raw_seen: usize = 0;
let mut cookie: u64 = 0;
let mut cookieverf: u64 = 0;
loop {
let ops = vec![ArgOp::Putfh(dir_fh.to_vec()), ArgOp::Readdir { cookie, cookieverf, dircount: 4096, maxcount: 65536, attr_request: AttrRequest::empty() }];
let res = self.compound(ops).await?;
anyhow::ensure!(res.status == 0, "READDIR failed: NFSv4 status={}", res.status);
let (server_verf, entries, eof) = match res.results.get(1).map(|op| &op.data) {
Some(ResOpData::Readdir { cookieverf, entries, eof }) => (*cookieverf, entries, *eof),
_ => anyhow::bail!("READDIR result missing or wrong type"),
};
cookieverf = u64::from_be_bytes(server_verf);
let Some(last_cookie) = entries.last().map(|e| e.cookie) else { break };
raw_seen = raw_seen.saturating_add(entries.len());
for e in entries {
if e.name != "." && e.name != ".." {
names.push(e.name.clone());
}
}
if eof {
break;
}
if raw_seen >= MAX_READDIR_ENTRIES {
tracing::warn!(count = raw_seen, "NFSv4 READDIR hit entry cap; directory listing truncated");
break;
}
if last_cookie == cookie {
break;
}
cookie = last_cookie;
}
Ok(names)
}
#[expect(dead_code, reason = "v4 shell READ path not yet wired up")]
pub(crate) async fn read_chunk(&mut self, file_fh: &[u8], offset: u64, count: u32) -> anyhow::Result<(Vec<u8>, bool)> {
let stateid = [0u8; 16];
let ops = vec![ArgOp::Putfh(file_fh.to_vec()), ArgOp::Read { stateid, offset, count }];
let res = self.compound(ops).await?;
anyhow::ensure!(res.status == 0, "READ failed: NFSv4 status={}", res.status);
match res.results.get(1).map(|op| &op.data) {
Some(ResOpData::Read { eof, data }) => Ok((data.clone(), *eof)),
_ => anyhow::bail!("READ result missing or wrong type"),
}
}
}