Added macos client

This commit is contained in:
Ultradesu
2026-03-11 00:05:13 +00:00
parent b7bbaa2d33
commit 57c9a3f456
5 changed files with 692 additions and 24 deletions

View File

@@ -0,0 +1,125 @@
pub mod nfs;
use clap::Parser;
use furumi_client_core::FurumiClient;
use nfsserve::tcp::{NFSTcp, NFSTcpListener};
use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tracing::info;
#[derive(Parser, Debug)]
#[command(version, about = "Furumi-ng: mount remote filesystem via encrypted gRPC + NFS (macOS)")]
struct Args {
/// Server address (IP:PORT, will use https:// by default unless --no-tls is specified)
#[arg(short, long, env = "FURUMI_SERVER", default_value = "0.0.0.0:50051")]
server: String,
/// Authentication Bearer token (leave empty if auth is disabled on server)
#[arg(short, long, env = "FURUMI_TOKEN", default_value = "")]
token: String,
/// Mount point directory
#[arg(short, long, env = "FURUMI_MOUNT")]
mount: PathBuf,
/// Disable TLS encryption
#[arg(long, default_value_t = false)]
no_tls: bool,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt::init();
let args = Args::parse();
if !args.mount.exists() || !args.mount.is_dir() {
eprintln!(
"Error: Mount point {:?} does not exist or is not a directory",
args.mount
);
std::process::exit(1);
}
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let full_addr = if args.no_tls {
if args.server.starts_with("http://") || args.server.starts_with("https://") {
args.server.clone()
} else {
format!("http://{}", args.server)
}
} else if args.server.starts_with("http://") || args.server.starts_with("https://") {
args.server.clone()
} else {
format!("https://{}", args.server)
};
let client = rt.block_on(async { FurumiClient::connect(&full_addr, &args.token).await })?;
let furumi_nfs = nfs::FurumiNfs::new(client);
let mount_point = args.mount.clone();
let mount_point_umount = args.mount.clone();
// Set up signal handler for graceful shutdown
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
ctrlc::set_handler(move || {
eprintln!("\nReceived signal, unmounting...");
r.store(false, Ordering::SeqCst);
})?;
rt.block_on(async move {
// Bind NFS server to a random available port on localhost
let listener = NFSTcpListener::bind("127.0.0.1:0", furumi_nfs)
.await
.expect("Failed to bind NFS listener");
let port = listener.get_listen_port();
info!("NFS server listening on 127.0.0.1:{}", port);
// Start handling NFS connections BEFORE mounting —
// mount_nfs needs to talk to the server during mount.
let handle = tokio::spawn(async move {
if let Err(e) = listener.handle_forever().await {
eprintln!("NFS server error: {}", e);
}
});
// Mount via macOS native mount_nfs
let mount_path = mount_point.to_string_lossy().to_string();
let opts = format!(
"rdonly,locallocks,noresvport,nfsvers=3,tcp,rsize=1048576,port={},mountport={}",
port, port
);
let status = Command::new("mount_nfs")
.args(["-o", &opts, "127.0.0.1:/", &mount_path])
.status()
.expect("Failed to execute mount_nfs");
if !status.success() {
handle.abort();
eprintln!("Error: mount_nfs failed with status {}", status);
std::process::exit(1);
}
println!("Mounted Furumi-ng to {:?}", mount_path);
// Wait for shutdown signal
while running.load(Ordering::SeqCst) {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
// Unmount
let _ = Command::new("umount").arg(mount_point_umount.to_string_lossy().as_ref()).status();
handle.abort();
println!("Unmounted successfully.");
});
Ok(())
}

View File

@@ -0,0 +1,406 @@
use async_trait::async_trait;
use furumi_client_core::FurumiClient;
use nfsserve::nfs::{
fattr3, fileid3, filename3, ftype3, mode3, nfspath3, nfsstat3, nfstime3, sattr3, specdata3,
};
use nfsserve::vfs::{DirEntry, NFSFileSystem, ReadDirResult, VFSCapabilities};
use std::collections::HashMap;
use std::sync::Mutex;
use tokio_stream::StreamExt;
use tracing::{debug, error};
// ── ID Mapper ──────────────────────────────────────────────────
/// Maps NFS file IDs (u64) to remote virtual filesystem paths and vice versa.
/// ID 1 is permanently assigned to the root directory ("/").
/// ID 0 is reserved by NFS and never used.
struct IdMapper {
id_to_path: HashMap<fileid3, String>,
path_to_id: HashMap<String, fileid3>,
next_id: fileid3,
}
impl IdMapper {
fn new() -> Self {
let mut id_to_path = HashMap::new();
let mut path_to_id = HashMap::new();
// Root is always ID 1 (0 is reserved in NFS)
id_to_path.insert(1, "/".to_string());
path_to_id.insert("/".to_string(), 1);
Self {
id_to_path,
path_to_id,
next_id: 2,
}
}
fn get_or_insert(&mut self, path: &str) -> fileid3 {
if let Some(&id) = self.path_to_id.get(path) {
return id;
}
let id = self.next_id;
self.next_id += 1;
self.id_to_path.insert(id, path.to_string());
self.path_to_id.insert(path.to_string(), id);
id
}
fn get_path(&self, id: fileid3) -> Option<&str> {
self.id_to_path.get(&id).map(|s| s.as_str())
}
}
// ── FurumiNfs ──────────────────────────────────────────────────
pub struct FurumiNfs {
client: FurumiClient,
mapper: Mutex<IdMapper>,
}
impl FurumiNfs {
pub fn new(client: FurumiClient) -> Self {
Self {
client,
mapper: Mutex::new(IdMapper::new()),
}
}
/// Build the full remote path from a parent directory ID and a child filename.
fn resolve_child_path(&self, dirid: fileid3, name: &filename3) -> Result<String, nfsstat3> {
let mapper = self.mapper.lock().unwrap();
let parent = mapper.get_path(dirid).ok_or(nfsstat3::NFS3ERR_STALE)?;
let name_str = String::from_utf8_lossy(&name.0);
let path = if parent == "/" {
format!("/{}", name_str)
} else {
format!("{}/{}", parent, name_str)
};
Ok(path)
}
/// Convert a gRPC AttrResponse into NFS fattr3, assigning/looking up the file ID.
fn make_fattr(
&self,
path: &str,
attr: &furumi_common::proto::AttrResponse,
) -> fattr3 {
let mut mapper = self.mapper.lock().unwrap();
let id = mapper.get_or_insert(path);
let is_dir = attr.mode & (libc::S_IFDIR as u32) != 0;
let ftype = if is_dir {
ftype3::NF3DIR
} else {
ftype3::NF3REG
};
let mtime = nfstime3 {
seconds: attr.mtime as u32,
nseconds: 0,
};
let uid = unsafe { libc::getuid() };
let gid = unsafe { libc::getgid() };
fattr3 {
ftype,
mode: (attr.mode & 0o777) as mode3,
nlink: if is_dir { 2 } else { 1 },
uid,
gid,
size: attr.size,
used: attr.size,
rdev: specdata3::default(),
fsid: 0,
fileid: id,
atime: mtime,
mtime,
ctime: mtime,
}
}
}
#[async_trait]
impl NFSFileSystem for FurumiNfs {
fn capabilities(&self) -> VFSCapabilities {
VFSCapabilities::ReadOnly
}
fn root_dir(&self) -> fileid3 {
1
}
async fn lookup(&self, dirid: fileid3, filename: &filename3) -> Result<fileid3, nfsstat3> {
let name_str = String::from_utf8_lossy(&filename.0);
debug!("lookup: dirid={} name={}", dirid, name_str);
let path = self.resolve_child_path(dirid, filename)?;
let attr = self
.client
.get_attr(&path)
.await
.map_err(|_| nfsstat3::NFS3ERR_NOENT)?;
let mut mapper = self.mapper.lock().unwrap();
let id = mapper.get_or_insert(&path);
// Sanity: verify the attr makes sense (file exists)
let _ = attr;
debug!("lookup: {} -> id={}", path, id);
Ok(id)
}
async fn getattr(&self, id: fileid3) -> Result<fattr3, nfsstat3> {
debug!("getattr: id={}", id);
let path = {
let mapper = self.mapper.lock().unwrap();
mapper
.get_path(id)
.ok_or(nfsstat3::NFS3ERR_STALE)?
.to_string()
};
let attr = self
.client
.get_attr(&path)
.await
.map_err(|_| nfsstat3::NFS3ERR_IO)?;
Ok(self.make_fattr(&path, &attr))
}
async fn setattr(&self, _id: fileid3, _setattr: sattr3) -> Result<fattr3, nfsstat3> {
Err(nfsstat3::NFS3ERR_ROFS)
}
async fn read(
&self,
id: fileid3,
offset: u64,
count: u32,
) -> Result<(Vec<u8>, bool), nfsstat3> {
debug!("read: id={} offset={} count={}", id, offset, count);
let path = {
let mapper = self.mapper.lock().unwrap();
mapper
.get_path(id)
.ok_or(nfsstat3::NFS3ERR_STALE)?
.to_string()
};
let mut stream = self
.client
.read_file(&path, offset, count, count)
.await
.map_err(|e| {
error!("read_file error: {:?}", e);
nfsstat3::NFS3ERR_IO
})?;
let mut data = Vec::new();
while let Some(chunk_res) = stream.next().await {
match chunk_res {
Ok(chunk) => data.extend_from_slice(&chunk.data),
Err(e) => {
error!("read stream error: {:?}", e);
return Err(nfsstat3::NFS3ERR_IO);
}
}
}
// EOF if we got less data than requested
let eof = (data.len() as u32) < count;
Ok((data, eof))
}
async fn write(&self, _id: fileid3, _offset: u64, _data: &[u8]) -> Result<fattr3, nfsstat3> {
Err(nfsstat3::NFS3ERR_ROFS)
}
async fn create(
&self,
_dirid: fileid3,
_filename: &filename3,
_attr: sattr3,
) -> Result<(fileid3, fattr3), nfsstat3> {
Err(nfsstat3::NFS3ERR_ROFS)
}
async fn create_exclusive(
&self,
_dirid: fileid3,
_filename: &filename3,
) -> Result<fileid3, nfsstat3> {
Err(nfsstat3::NFS3ERR_ROFS)
}
async fn mkdir(
&self,
_dirid: fileid3,
_dirname: &filename3,
) -> Result<(fileid3, fattr3), nfsstat3> {
Err(nfsstat3::NFS3ERR_ROFS)
}
async fn remove(&self, _dirid: fileid3, _filename: &filename3) -> Result<(), nfsstat3> {
Err(nfsstat3::NFS3ERR_ROFS)
}
async fn rename(
&self,
_from_dirid: fileid3,
_from_filename: &filename3,
_to_dirid: fileid3,
_to_filename: &filename3,
) -> Result<(), nfsstat3> {
Err(nfsstat3::NFS3ERR_ROFS)
}
async fn readdir(
&self,
dirid: fileid3,
start_after: fileid3,
max_entries: usize,
) -> Result<ReadDirResult, nfsstat3> {
debug!(
"readdir: dirid={} start_after={} max={}",
dirid, start_after, max_entries
);
let path = {
let mapper = self.mapper.lock().unwrap();
mapper
.get_path(dirid)
.ok_or(nfsstat3::NFS3ERR_STALE)?
.to_string()
};
let entries = self.client.read_dir(&path).await.map_err(|e| {
error!("read_dir error: {:?}", e);
nfsstat3::NFS3ERR_IO
})?;
// Build full list with IDs and attributes
let mut nfs_entries: Vec<DirEntry> = Vec::new();
for entry in &entries {
let child_path = if path == "/" {
format!("/{}", entry.name)
} else {
format!("{}/{}", path, entry.name)
};
let is_dir = entry.r#type == 4; // DT_DIR
let child_id = {
let mut mapper = self.mapper.lock().unwrap();
mapper.get_or_insert(&child_path)
};
// Try to get real attributes, fall back to synthetic ones
let attr = match self.client.get_attr(&child_path).await {
Ok(a) => self.make_fattr(&child_path, &a),
Err(_) => {
let mut mapper = self.mapper.lock().unwrap();
let id = mapper.get_or_insert(&child_path);
let ftype = if is_dir {
ftype3::NF3DIR
} else {
ftype3::NF3REG
};
fattr3 {
ftype,
mode: if is_dir { 0o755 } else { 0o644 },
nlink: if is_dir { 2 } else { 1 },
uid: unsafe { libc::getuid() },
gid: unsafe { libc::getgid() },
size: 0,
used: 0,
rdev: specdata3::default(),
fsid: 0,
fileid: id,
atime: nfstime3::default(),
mtime: nfstime3::default(),
ctime: nfstime3::default(),
}
}
};
nfs_entries.push(DirEntry {
fileid: child_id,
name: entry.name.as_bytes().into(),
attr,
});
}
// Handle pagination: skip entries up to and including start_after
let filtered: Vec<DirEntry> = if start_after > 0 {
let skip_pos = nfs_entries
.iter()
.position(|e| e.fileid == start_after)
.map(|p| p + 1)
.unwrap_or(0);
nfs_entries.into_iter().skip(skip_pos).collect()
} else {
nfs_entries
};
let end = filtered.len() <= max_entries;
let entries: Vec<DirEntry> = filtered.into_iter().take(max_entries).collect();
Ok(ReadDirResult { entries, end })
}
async fn symlink(
&self,
_dirid: fileid3,
_linkname: &filename3,
_symlink: &nfspath3,
_attr: &sattr3,
) -> Result<(fileid3, fattr3), nfsstat3> {
Err(nfsstat3::NFS3ERR_ROFS)
}
async fn readlink(&self, _id: fileid3) -> Result<nfspath3, nfsstat3> {
Err(nfsstat3::NFS3ERR_NOTSUPP)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_id_mapper_root() {
let mapper = IdMapper::new();
assert_eq!(mapper.get_path(1).unwrap(), "/");
}
#[test]
fn test_id_mapper_assignment() {
let mut mapper = IdMapper::new();
let id1 = mapper.get_or_insert("/hello.txt");
assert_eq!(id1, 2);
let id2 = mapper.get_or_insert("/dir/world.mkv");
assert_eq!(id2, 3);
assert_eq!(mapper.get_path(2).unwrap(), "/hello.txt");
assert_eq!(mapper.get_path(3).unwrap(), "/dir/world.mkv");
// Same path returns same ID
assert_eq!(mapper.get_or_insert("/hello.txt"), 2);
}
#[test]
fn test_id_mapper_non_existent() {
let mapper = IdMapper::new();
assert!(mapper.get_path(999).is_none());
}
}