Added macos client
This commit is contained in:
125
furumi-mount-macos/src/main.rs
Normal file
125
furumi-mount-macos/src/main.rs
Normal 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(())
|
||||
}
|
||||
406
furumi-mount-macos/src/nfs.rs
Normal file
406
furumi-mount-macos/src/nfs.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user