use std::io::Write as _;
use std::net::SocketAddr;
use std::sync::Arc;
use clap::Parser;
use rustyline::Editor;
use rustyline::error::ReadlineError;
use rustyline::history::DefaultHistory;
use crate::cli::probe::{build_gid_list, make_mount_client};
use crate::cli::target::Source as TargetSource;
use crate::cli::{GlobalOpts, H_BEHAVIOR, H_PERMISSIONS, H_TARGET};
use crate::proto::auth::{AuthSys, Credential};
use crate::proto::circuit::CircuitBreaker;
use crate::proto::conn::ReconnectStrategy;
use crate::proto::nfs3::Nfs3Client;
use crate::proto::nfs3::types::FileHandle;
use crate::proto::pool::{ConnectionPool, PoolKey};
use crate::proto::transport::PooledTransport;
use crate::shell::NfsShell;
use crate::shell::V3_SHELL_COMMANDS;
use crate::shell::complete::ShellCompleter;
use crate::shell::ops::ShellHandle;
use crate::shell::v3::V3Ops;
use crate::util::stealth::StealthConfig;
#[derive(Parser)]
pub(crate) struct ShellArgs {
#[arg(help_heading = H_TARGET)]
pub target: String,
#[arg(short = 'e', long, value_name = "PATH", help_heading = H_TARGET)]
pub export: Option<String>,
#[arg(long, help_heading = H_PERMISSIONS)]
pub allow_write: bool,
#[arg(short = 'c', long, value_name = "CMD", help_heading = H_BEHAVIOR)]
pub command: Option<String>,
#[arg(long, value_name = "HEX", help_heading = H_TARGET)]
pub handle: Option<String>,
#[arg(long, default_value = "3", value_name = "VER", help_heading = H_BEHAVIOR)]
pub nfs_version: u32,
}
pub(crate) async fn run(args: ShellArgs, globals: &GlobalOpts) -> anyhow::Result<()> {
tracing::info!(target = %args.target, "starting NFS shell");
if args.nfs_version == 4 {
return run_nfs4_shell(args, globals).await;
}
if args.nfs_version == 2 {
return run_nfs2_shell(args, globals).await;
}
if args.nfs_version != 3 {
anyhow::bail!("--nfs-version {} is not supported (use 2, 3, or 4)", args.nfs_version);
}
let target = crate::cli::target::parse(&args.target, args.export.as_deref(), args.handle.as_deref(), false)?;
let host = target.host;
let (export, handle_hex_arg): (String, Option<String>) = match &target.source {
TargetSource::Export(p) => (p.clone(), None),
TargetSource::Handle(h) => (String::from("/"), Some(h.clone())),
TargetSource::None => (String::from("/"), None),
};
let uid = globals.uid;
let gid = globals.gid;
let addr = SocketAddr::new(host, 111);
let pool = Arc::new(match &globals.proxy {
Some(p) => ConnectionPool::with_proxy(p.clone()),
None => ConnectionPool::default_config(),
});
let circuit = Arc::new(CircuitBreaker::default_config());
let gids = build_gid_list(gid, &globals.aux_gids);
let cred = Credential::Sys(AuthSys::with_groups(uid, gid, &gids, &globals.hostname));
let (root_fh, pool_key, direct_nfs_port) = if let Some(ref hex) = handle_hex_arg {
let fh = FileHandle::from_hex(hex).map_err(|e| anyhow::anyhow!("invalid --handle: {e}"))?;
eprintln!("{}", crate::output::status_info(&format!("Using raw handle: {hex}")));
let nfs_port = globals.nfs_port.unwrap_or(2049);
eprintln!("{}", crate::output::status_info(&format!("Session via {host}:{nfs_port} (MOUNT bypassed)")));
let key = PoolKey { host: SocketAddr::new(host, nfs_port), export: format!("__handle__{nfs_port}"), uid, gid };
(fh, key, Some(nfs_port))
} else {
let mount_client = make_mount_client(globals);
eprintln!("{}", crate::output::status_info(&format!("Mounting {host}:{export}")));
let (mount_result, via_v1) = match mount_client.mount(addr, &export).await {
Ok(r) => (r, false),
Err(v3_err) => {
tracing::info!("MOUNT v3 failed ({v3_err}); trying MOUNT v1");
let r = mount_client.mount_v1(addr, &export).await.map_err(|v1_err| anyhow::anyhow!("MOUNT v3: {v3_err}; MOUNT v1: {v1_err}"))?;
(r, true)
},
};
let key = PoolKey { host: addr, export: export.clone(), uid, gid };
let direct_port = if via_v1 && globals.nfs_port.is_none() { Some(2049) } else { globals.nfs_port };
(mount_result.handle, key, direct_port)
};
let stealth = StealthConfig::new(globals.delay, globals.jitter);
let nfs3 = if let Some(nfs_port) = direct_nfs_port {
Arc::new(Nfs3Client::new(PooledTransport::new_direct(Arc::clone(&pool), pool_key, Arc::clone(&circuit), stealth, cred, ReconnectStrategy::Persistent, nfs_port)))
} else {
Arc::new(Nfs3Client::new(PooledTransport::new(Arc::clone(&pool), pool_key, Arc::clone(&circuit), stealth, cred, ReconnectStrategy::Persistent)))
};
let ops = V3Ops::new(Arc::clone(&nfs3));
let root_handle = ShellHandle(root_fh.as_bytes().to_vec());
let mut shell = NfsShell::new(ops, root_handle, args.allow_write, globals.hostname.clone(), V3_SHELL_COMMANDS);
shell.refresh_tab_cache().await;
eprintln!("{}", crate::output::status_ok(&format!("Connected to {host} as uid={uid} gid={gid}{} -- type 'help' for commands", if args.allow_write { " [write enabled]" } else { "" })));
eprintln!("# rerun: nfswolf shell {host}:{export} --uid {uid} --gid {gid}");
if let Some(cmd) = args.command {
shell.dispatch(&cmd).await;
crate::cli::emit_replay(globals);
return Ok(());
}
let completer = shell.make_completer();
let mut rl = Editor::<ShellCompleter, DefaultHistory>::new()?;
rl.set_helper(Some(completer));
loop {
let prompt = format!("nfswolf@{host}:{} uid={} gid={}> ", shell.cwd_path(), shell.current_uid(), shell.current_gid());
match rl.readline(&prompt) {
Ok(line) => {
drop(rl.add_history_entry(&line));
let trimmed = line.trim();
if trimmed == "exit" || trimmed == "quit" {
break;
}
shell.dispatch(&line).await;
},
Err(ReadlineError::Interrupted | ReadlineError::Eof) => break,
Err(e) => {
eprintln!("readline error: {e}");
break;
},
}
}
crate::cli::emit_replay(globals);
Ok(())
}
const NFS4_READ_MAX_BYTES: u64 = 256 * 1024 * 1024;
async fn run_nfs4_shell(args: ShellArgs, globals: &GlobalOpts) -> anyhow::Result<()> {
use crate::proto::nfs4::{Nfs4Client as PooledNfs4Client, PooledNfs4};
let target = crate::cli::target::parse(&args.target, args.export.as_deref(), args.handle.as_deref(), false)?;
let host = target.host;
drop(target.source);
let nfs_port = globals.nfs_port.unwrap_or(2049);
let addr = SocketAddr::new(host, nfs_port);
let mut uid = globals.uid;
let mut gid = globals.gid;
let mut hostname = globals.hostname.clone();
eprintln!("{}", crate::output::status_info(&format!("Connecting to {host}:{nfs_port} via NFSv4 (no MOUNT)")));
let pool = Arc::new(match &globals.proxy {
Some(p) => ConnectionPool::with_proxy(p.clone()),
None => ConnectionPool::default_config(),
});
let circuit = Arc::new(CircuitBreaker::default_config());
let stealth = StealthConfig::new(globals.delay, globals.jitter);
let gids = build_gid_list(gid, &globals.aux_gids);
let cred = Credential::Sys(AuthSys::with_groups(uid, gid, &gids, &hostname));
let pool_key = PoolKey { host: addr, export: format!("__nfs4__{nfs_port}"), uid, gid };
let transport = PooledTransport::new_direct(Arc::clone(&pool), pool_key, Arc::clone(&circuit), stealth, cred, ReconnectStrategy::Persistent, nfs_port);
let client = PooledNfs4Client::new(transport);
let root_fh = client.get_root_fh().await.map_err(|e| anyhow::anyhow!("PUTROOTFH failed: {e}"))?;
eprintln!("{}", crate::output::status_ok(&format!("Connected to {host} as uid={uid} gid={gid} hostname={hostname} (NFSv4 shell -- type 'help' for commands)")));
eprintln!("# rerun: nfswolf shell {host} --nfs-version 4 --uid {uid} --gid {gid}");
let mut cwd_fh = root_fh.clone();
let mut cwd_path = "/".to_owned();
if let Some(ref cmd) = args.command {
dispatch_nfs4(&client, cmd, &mut cwd_fh, &mut cwd_path, args.allow_write, &mut uid, &mut gid, &mut hostname).await;
crate::cli::emit_replay(globals);
return Ok(());
}
let client = Arc::new(tokio::sync::Mutex::new(client));
let tab_cache = {
let entries = client.lock().await.list_dir(&cwd_fh).await.unwrap_or_default();
Arc::new(std::sync::Mutex::new(crate::shell::complete::TabCache { cwd: cwd_fh.clone(), entries }))
};
let completer = ShellCompleter::new(Box::new(Nfs4RemoteCompleter { client: Arc::clone(&client) }), root_fh.clone(), Arc::clone(&tab_cache), V4_SHELL_COMMANDS);
let mut rl = Editor::<ShellCompleter, DefaultHistory>::new()?;
rl.set_helper(Some(completer));
loop {
let prompt = format!("nfswolf@{host}:{cwd_path} uid={uid} gid={gid} [v4]> ");
match rl.readline(&prompt) {
Ok(line) => {
drop(rl.add_history_entry(&line));
let trimmed = line.trim();
if trimmed == "exit" || trimmed == "quit" {
break;
}
let mut guard = client.lock().await;
dispatch_nfs4(&guard, trimmed, &mut cwd_fh, &mut cwd_path, args.allow_write, &mut uid, &mut gid, &mut hostname).await;
if guard.uid() != uid || guard.gid() != gid || guard.machinename() != hostname {
let gids_new = build_gid_list(gid, &globals.aux_gids);
let new_cred = Credential::Sys(AuthSys::with_groups(uid, gid, &gids_new, &hostname));
*guard = guard.with_credential(new_cred, uid, gid);
}
if let Ok(entries) = guard.list_dir(&cwd_fh).await
&& let Ok(mut cache) = tab_cache.lock()
{
cache.cwd.clone_from(&cwd_fh);
cache.entries = entries;
}
},
Err(ReadlineError::Interrupted | ReadlineError::Eof) => break,
Err(e) => {
eprintln!("readline error: {e}");
break;
},
}
}
crate::cli::emit_replay(globals);
Ok(())
}
async fn nfs4_cat(client: &crate::proto::nfs4::Nfs4Client, file_fh: &[u8]) {
let mut offset: u64 = 0;
loop {
match client.read_chunk(file_fh, offset, 65536).await {
Ok((data, eof)) => {
if let Err(e) = std::io::stdout().write_all(&data) {
eprintln!("cat: write to stdout: {e}");
break;
}
offset += data.len() as u64;
if eof || data.is_empty() {
break;
}
if offset > NFS4_READ_MAX_BYTES {
eprintln!("cat: aborted at {offset} bytes: exceeds {NFS4_READ_MAX_BYTES}-byte cap (untrusted server returning endless non-EOF data)");
break;
}
},
Err(e) => {
eprintln!("cat: {e}");
break;
},
}
}
drop(std::io::stdout().flush());
}
async fn nfs4_get(client: &crate::proto::nfs4::Nfs4Client, file_fh: &[u8], local_name: &str) {
let mut buf = Vec::new();
let mut offset: u64 = 0;
loop {
match client.read_chunk(file_fh, offset, 65536).await {
Ok((data, eof)) => {
offset += data.len() as u64;
buf.extend_from_slice(&data);
if eof || data.is_empty() {
break;
}
if offset > NFS4_READ_MAX_BYTES {
eprintln!("get: aborted at {offset} bytes: exceeds {NFS4_READ_MAX_BYTES}-byte cap (untrusted server returning endless non-EOF data)");
return;
}
},
Err(e) => {
eprintln!("get: read error: {e}");
return;
},
}
}
match std::fs::write(local_name, &buf) {
Ok(()) => println!("{}", crate::output::status_ok(&format!("saved {} bytes -> {local_name}", buf.len()))),
Err(e) => eprintln!("get: write {local_name}: {e}"),
}
}
async fn dispatch_nfs4(client: &crate::proto::nfs4::Nfs4Client, line: &str, cwd_fh: &mut Vec<u8>, cwd_path: &mut String, allow_write: bool, uid: &mut u32, gid: &mut u32, hostname: &mut String) {
let _ = allow_write;
let mut parts = line.split_whitespace();
let Some(cmd) = parts.next() else { return };
let args: Vec<&str> = parts.collect();
match cmd {
"help" | "?" => {
println!("NFSv4 shell commands:");
println!(" ls list current directory");
println!(" ls <path> list a subdirectory");
println!(" cd <dir> change directory (cd / for root)");
println!(" pwd print current directory");
println!(" cat <file> print file contents");
println!(" get <file> download file to current local directory");
println!(" uid <n> set AUTH_SYS UID (zero-cost credential swap)");
println!(" gid <n> set AUTH_SYS GID (zero-cost credential swap)");
println!(" hostname <name> spoof AUTH_SYS machine name");
println!(" whoami show current uid/gid/hostname");
println!(" handle print current file handle as hex");
println!(" lcd <dir> change local working directory");
println!(" lls [dir] list local directory");
println!(" lpwd print local working directory");
println!(" lmkdir <dir> create local directory");
println!(" exit / quit exit the shell");
},
"whoami" | "id" => println!("uid={uid} gid={gid} hostname={hostname}"),
"uid" => match args.first().and_then(|s| s.parse::<u32>().ok()) {
Some(new_uid) => {
*uid = new_uid;
println!("uid={uid} gid={gid} hostname={hostname}");
},
None => eprintln!("uid: usage: uid <number>"),
},
"gid" => match args.first().and_then(|s| s.parse::<u32>().ok()) {
Some(new_gid) => {
*gid = new_gid;
println!("uid={uid} gid={gid} hostname={hostname}");
},
None => eprintln!("gid: usage: gid <number>"),
},
"hostname" => {
if let Some(new_host) = args.first() {
(*new_host).clone_into(hostname);
println!("hostname={hostname}");
} else {
println!("{hostname}");
}
},
"pwd" => println!("{cwd_path}"),
"ls" | "ll" | "dir" => {
let target_fh = if let Some(subdir) = args.first() {
let components = cwd_path_plus(cwd_path, subdir);
let refs: Vec<&str> = components.iter().map(String::as_str).collect();
match client.lookup_fh(&refs).await {
Ok(fh) => fh,
Err(e) => {
eprintln!("ls: {e}");
return;
},
}
} else {
cwd_fh.clone()
};
match client.list_dir(&target_fh).await {
Ok(names) => {
let mut sorted = names;
sorted.sort();
for name in &sorted {
println!("{name}");
}
},
Err(e) => eprintln!("ls: {e}"),
}
},
"cd" => {
let target = args.first().copied().unwrap_or("/");
let new_path = if target == "/" {
match client.get_root_fh().await {
Ok(fh) => {
*cwd_fh = fh;
"/".to_owned()
},
Err(e) => {
eprintln!("cd /: {e}");
return;
},
}
} else {
let components = cwd_path_plus(cwd_path, target);
let refs: Vec<&str> = components.iter().map(String::as_str).collect();
match client.lookup_fh(&refs).await {
Ok(fh) => {
*cwd_fh = fh;
format!("/{}", components.join("/"))
},
Err(e) => {
eprintln!("cd: {e}");
return;
},
}
};
*cwd_path = new_path;
},
"cat" | "type" => {
let Some(filename) = args.first() else {
eprintln!("usage: cat <file>");
return;
};
let file_components = cwd_path_plus(cwd_path, filename);
let refs: Vec<&str> = file_components.iter().map(String::as_str).collect();
let file_fh = match client.lookup_fh(&refs).await {
Ok(fh) => fh,
Err(e) => {
eprintln!("cat: {e}");
return;
},
};
nfs4_cat(client, &file_fh).await;
},
"get" | "download" => {
let Some(filename) = args.first() else {
eprintln!("usage: get <file>");
return;
};
let file_components = cwd_path_plus(cwd_path, filename);
let refs: Vec<&str> = file_components.iter().map(String::as_str).collect();
let file_fh = match client.lookup_fh(&refs).await {
Ok(fh) => fh,
Err(e) => {
eprintln!("get: {e}");
return;
},
};
let local_name = file_components.last().map_or(*filename, String::as_str);
nfs4_get(client, &file_fh, local_name).await;
},
"handle" => {
let hex = cwd_fh.iter().fold(String::with_capacity(cwd_fh.len() * 2), |mut s, b| {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
s
});
println!("{hex}");
},
"lcd" => {
let dir = args.first().copied().unwrap_or(".");
match std::env::set_current_dir(dir) {
Ok(()) => println!("{}", std::env::current_dir().map_or_else(|_| dir.to_owned(), |p| p.display().to_string())),
Err(e) => eprintln!("lcd: {e}"),
}
},
"lls" => {
let target = args.first().copied().unwrap_or(".");
match std::fs::read_dir(target) {
Ok(iter) => {
let mut names: Vec<String> = iter.filter_map(Result::ok).map(|e| e.file_name().to_string_lossy().into_owned()).collect();
names.sort();
for n in &names {
println!("{n}");
}
},
Err(e) => eprintln!("lls: {e}"),
}
},
"lpwd" => match std::env::current_dir() {
Ok(p) => println!("{}", p.display()),
Err(e) => eprintln!("lpwd: {e}"),
},
"lmkdir" => {
let Some(dir) = args.first() else {
eprintln!("usage: lmkdir <dir>");
return;
};
match std::fs::create_dir_all(dir) {
Ok(()) => println!("created {dir}"),
Err(e) => eprintln!("lmkdir: {e}"),
}
},
"history" => eprintln!("history: use up/down arrow keys (readline) to navigate command history"),
"exit" | "quit" => {}, "put" | "mkdir" | "rm" | "rmdir" | "mv" | "chmod" | "chown" | "symlink" | "link" | "mknod" => {
eprintln!("{cmd}: not supported in NFSv4 mode (requires stateful OPEN/CLOSE with stateid tracking)");
},
_ => eprintln!("unknown command '{cmd}' -- type 'help' for commands"),
}
}
fn cwd_path_plus(cwd_path: &str, target: &str) -> Vec<String> {
let base: Vec<&str> = if target.starts_with('/') {
vec![]
} else {
cwd_path.trim_start_matches('/').split('/').filter(|s| !s.is_empty()).collect()
};
let mut components: Vec<String> = base.iter().map(|s| (*s).to_owned()).collect();
for part in target.trim_start_matches('/').split('/') {
match part {
"" | "." => {},
".." => {
drop(components.pop());
},
other => components.push(other.to_owned()),
}
}
components
}
const V4_SHELL_COMMANDS: &[&str] = &["ls", "ll", "dir", "cd", "pwd", "cat", "type", "get", "download", "uid", "gid", "hostname", "whoami", "id", "handle", "lcd", "lls", "lpwd", "lmkdir", "history", "help", "exit", "quit"];
struct Nfs4RemoteCompleter {
client: Arc<tokio::sync::Mutex<crate::proto::nfs4::Nfs4Client>>,
}
impl crate::shell::complete::RemoteCompleter for Nfs4RemoteCompleter {
fn list_dir_entries(&self, handle: &[u8]) -> Vec<String> {
let client = Arc::clone(&self.client);
let fh = handle.to_vec();
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(async move { client.lock().await.list_dir(&fh).await.unwrap_or_default() }))
}
fn resolve_path(&self, start: &[u8], path: &str) -> Option<Vec<u8>> {
let client = Arc::clone(&self.client);
let fh = start.to_vec();
let components: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(async move { client.lock().await.lookup_from_fh(&fh, &components).await.ok() }))
}
}
async fn run_nfs2_shell(args: ShellArgs, globals: &GlobalOpts) -> anyhow::Result<()> {
use crate::cli::probe::{make_v2_client_with_hostname, parse_addr_with_port};
use crate::cli::target::{Source, parse as parse_target};
use crate::shell::V2_SHELL_COMMANDS;
use crate::shell::v2::V2Ops;
let target = parse_target(&args.target, args.export.as_deref(), args.handle.as_deref(), false)?;
let host = target.host;
let uid = globals.uid;
let gid = globals.gid;
let hostname = globals.hostname.clone();
let (root_fh, export) = match &target.source {
Source::Export(p) => {
let mount_client = make_mount_client(globals);
let addr = SocketAddr::new(host, 111);
eprintln!("{}", crate::output::status_info(&format!("Mounting {host}:{p} (MOUNT v1)")));
let mount_result = mount_client.mount_v1(addr, p).await?;
let fh = nfs_v2::wire::Nfs2FileHandle::from_bytes(mount_result.handle.as_bytes());
(fh, p.clone())
},
Source::Handle(hex) => {
let generic = FileHandle::from_hex(hex).map_err(|e| anyhow::anyhow!("invalid --handle: {e}"))?;
let fh = nfs_v2::wire::Nfs2FileHandle::from_bytes(generic.as_bytes());
eprintln!("{}", crate::output::status_info(&format!("Using raw handle (NFSv2): {hex}")));
(fh, String::from("/"))
},
Source::None => {
let mount_client = make_mount_client(globals);
let addr = SocketAddr::new(host, 111);
let export = "/".to_owned();
eprintln!("{}", crate::output::status_info(&format!("Mounting {host}:/ (MOUNT v1)")));
let mount_result = mount_client.mount_v1(addr, &export).await?;
let fh = nfs_v2::wire::Nfs2FileHandle::from_bytes(mount_result.handle.as_bytes());
(fh, export)
},
};
let addr = parse_addr_with_port(&host.to_string(), globals.nfs_port)?;
let stealth = StealthConfig::new(globals.delay, globals.jitter);
let (_pool, _circuit, client) = make_v2_client_with_hostname(addr, &export, uid, gid, &globals.aux_gids, stealth, globals.proxy.as_deref(), globals.nfs_port, &hostname);
let client = Arc::new(client);
let v2ops = V2Ops::new(client);
let root = ShellHandle(root_fh.0.to_vec());
let mut shell = NfsShell::new(v2ops, root, args.allow_write, hostname, V2_SHELL_COMMANDS);
shell.refresh_tab_cache().await;
eprintln!("{}", crate::output::status_ok(&format!("Connected to {host} as uid={uid} gid={gid} (NFSv2 shell -- type 'help' for commands)")));
eprintln!("# rerun: nfswolf shell {host}:{export} --nfs-version 2 --uid {uid} --gid {gid}");
if let Some(cmd) = args.command {
shell.dispatch(&cmd).await;
crate::cli::emit_replay(globals);
return Ok(());
}
let completer = shell.make_completer();
let mut rl = Editor::<ShellCompleter, DefaultHistory>::new()?;
rl.set_helper(Some(completer));
loop {
let prompt = format!("nfswolf@{host}:{} uid={} gid={} [v2]> ", shell.cwd_path(), shell.current_uid(), shell.current_gid());
match rl.readline(&prompt) {
Ok(line) => {
drop(rl.add_history_entry(&line));
let trimmed = line.trim();
if trimmed == "exit" || trimmed == "quit" {
break;
}
shell.dispatch(&line).await;
},
Err(ReadlineError::Interrupted | ReadlineError::Eof) => break,
Err(e) => {
eprintln!("readline error: {e}");
break;
},
}
}
crate::cli::emit_replay(globals);
Ok(())
}