FIX: TLS options

This commit is contained in:
2026-03-10 16:52:13 +00:00
parent 67547d677c
commit b7bbaa2d33
8 changed files with 291 additions and 34 deletions

View File

@@ -26,11 +26,10 @@ cargo build --release --workspace
--token mysecrettoken \ --token mysecrettoken \
--tls-cert-out /tmp/furumi-ca.pem --tls-cert-out /tmp/furumi-ca.pem
# Client — loads the server's certificate for encrypted connection # Client — automatically uses TLS, trusts server certificate
./target/release/furumi-mount-linux \ ./target/release/furumi-mount-linux \
--server https://server-ip:50051 \ --server server-ip:50051 \
--token mysecrettoken \ --token mysecrettoken \
--tls-ca /tmp/furumi-ca.pem \
--mount /mnt/remote --mount /mnt/remote
# Use it # Use it
@@ -40,13 +39,9 @@ mpv /mnt/remote/video.mkv
## Encryption ## Encryption
TLS is enabled by default. The server auto-generates a self-signed certificate on each start — no manual cert management required. The certificate is used **only for encryption**, not for server identity verification. TLS is enabled by default. The server auto-generates a self-signed certificate on each start — no manual cert management required. The client automatically trusts the server's certificate for encryption.
To pass the certificate to the client: To disable TLS (not recommended): `--no-tls` on both server and client.
1. Server: `--tls-cert-out /path/to/cert.pem` saves the generated cert
2. Client: `--tls-ca /path/to/cert.pem` loads it for the TLS handshake
To disable TLS (not recommended): `--no-tls` on the server, and use `http://` on the client.
## Configuration ## Configuration
@@ -60,17 +55,16 @@ All options can be set via CLI flags or environment variables.
| `--root` | `FURUMI_ROOT` | `.` | Directory to expose | | `--root` | `FURUMI_ROOT` | `.` | Directory to expose |
| `--token` | `FURUMI_TOKEN` | *(empty, auth off)* | Bearer token | | `--token` | `FURUMI_TOKEN` | *(empty, auth off)* | Bearer token |
| `--metrics-bind` | `FURUMI_METRICS_BIND` | `0.0.0.0:9090` | Prometheus endpoint | | `--metrics-bind` | `FURUMI_METRICS_BIND` | `0.0.0.0:9090` | Prometheus endpoint |
| `--tls-cert-out` | `FURUMI_TLS_CERT_OUT` | — | Save auto-generated cert PEM |
| `--no-tls` | — | `false` | Disable TLS | | `--no-tls` | — | `false` | Disable TLS |
### Client ### Client
| Flag | Env | Default | Description | | Flag | Env | Default | Description |
|------|-----|---------|-------------| |------|-----|---------|-------------|
| `--server` | `FURUMI_SERVER` | `https://0.0.0.0:50051` | Server address | | `--server` | `FURUMI_SERVER` | `0.0.0.0:50051` | Server address |
| `--token` | `FURUMI_TOKEN` | *(empty)* | Bearer token | | `--token` | `FURUMI_TOKEN` | *(empty)* | Bearer token |
| `--mount` | `FURUMI_MOUNT` | — | Mount point directory | | `--mount` | `FURUMI_MOUNT` | — | Mount point directory |
| `--tls-ca` | `FURUMI_TLS_CA` | — | Server CA cert PEM file | | `--no-tls` | — | `false` | Disable TLS |
## Prometheus Metrics ## Prometheus Metrics

View File

@@ -246,3 +246,6 @@ impl FurumiClient {
Ok(stream) Ok(stream)
} }
} }
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,66 @@
use crate::client::AuthInterceptor;
use tonic::{Request, service::Interceptor};
#[test]
fn test_auth_interceptor_with_token() {
let mut interceptor = AuthInterceptor {
token: "my-secret-token".to_string(),
};
let req = Request::new(());
let res = interceptor.call(req).expect("Failed to intercept");
let auth_header = res.metadata().get("authorization").expect("Missing auth header");
assert_eq!(auth_header.to_str().unwrap(), "Bearer my-secret-token");
}
#[test]
fn test_auth_interceptor_empty_token() {
let mut interceptor = AuthInterceptor {
token: "".to_string(),
};
let req = Request::new(());
let res = interceptor.call(req).expect("Failed to intercept");
assert!(res.metadata().get("authorization").is_none(), "Auth header should not be set for empty token");
}
#[test]
fn test_auth_interceptor_invalid_chars() {
let mut interceptor = AuthInterceptor {
// ASCII control characters are invalid in Metadata values
token: "token\nwith\nnewlines".to_string(),
};
let req = Request::new(());
let res = interceptor.call(req);
assert!(res.is_err(), "Interceptor should fail on invalid token characters");
assert_eq!(res.unwrap_err().code(), tonic::Code::InvalidArgument);
}
use furumi_common::proto::AttrResponse;
#[tokio::test]
async fn test_client_caching_logic() {
let cache = moka::future::Cache::builder()
.max_capacity(100)
.build();
let path = "/test/file.txt";
let attr = AttrResponse {
size: 1024,
mode: 0o644,
mtime: 1234567890,
};
cache.insert(path.to_string(), attr.clone()).await;
let cached_attr = cache.get(path).await.expect("Item should be in cache");
assert_eq!(cached_attr.size, attr.size);
assert_eq!(cached_attr.mode, attr.mode);
assert_eq!(cached_attr.mtime, attr.mtime);
assert!(cache.get("/non/existent").await.is_none());
}

View File

@@ -11,20 +11,18 @@ use tokio::runtime::Handle;
const TTL: Duration = Duration::from_secs(1); // 1 second FUSE kernel TTL const TTL: Duration = Duration::from_secs(1); // 1 second FUSE kernel TTL
pub struct FurumiFuse { // ── InodeMapper ──────────────────────────────────────────────────
client: FurumiClient,
rt_handle: Handle, /// Maps FUSE inodes (u64) to remote virtual filesystem paths (String) and vice versa.
// FUSE deals in inodes (u64). We need to map inode -> string path. /// Inode 1 is permanently assigned to the root directory ("/").
// In a real VFS, this requires a proper tree or bidirectional map. pub struct InodeMapper {
// For simplicity: inode 1 is always the root "/".
inode_to_path: Arc<Mutex<HashMap<u64, String>>>, inode_to_path: Arc<Mutex<HashMap<u64, String>>>,
// Mapping path back to inode to keep them consistent
path_to_inode: Arc<Mutex<HashMap<String, u64>>>, path_to_inode: Arc<Mutex<HashMap<String, u64>>>,
next_inode: Arc<Mutex<u64>>, next_inode: Arc<Mutex<u64>>,
} }
impl FurumiFuse { impl InodeMapper {
pub fn new(client: FurumiClient, rt_handle: Handle) -> Self { pub fn new() -> Self {
let mut inode_to_path = HashMap::new(); let mut inode_to_path = HashMap::new();
let mut path_to_inode = HashMap::new(); let mut path_to_inode = HashMap::new();
@@ -33,15 +31,13 @@ impl FurumiFuse {
path_to_inode.insert("/".to_string(), 1); path_to_inode.insert("/".to_string(), 1);
Self { Self {
client,
rt_handle,
inode_to_path: Arc::new(Mutex::new(inode_to_path)), inode_to_path: Arc::new(Mutex::new(inode_to_path)),
path_to_inode: Arc::new(Mutex::new(path_to_inode)), path_to_inode: Arc::new(Mutex::new(path_to_inode)),
next_inode: Arc::new(Mutex::new(2)), // Inodes start from 2 next_inode: Arc::new(Mutex::new(2)), // Inodes start from 2
} }
} }
fn get_inode(&self, path: &str) -> u64 { pub fn get_inode(&self, path: &str) -> u64 {
let mut p2i = self.path_to_inode.lock().unwrap(); let mut p2i = self.path_to_inode.lock().unwrap();
if let Some(&inode) = p2i.get(path) { if let Some(&inode) = p2i.get(path) {
inode inode
@@ -55,9 +51,27 @@ impl FurumiFuse {
} }
} }
fn get_path(&self, inode: u64) -> Option<String> { pub fn get_path(&self, inode: u64) -> Option<String> {
self.inode_to_path.lock().unwrap().get(&inode).cloned() self.inode_to_path.lock().unwrap().get(&inode).cloned()
} }
}
// ── FurumiFuse ──────────────────────────────────────────────────
pub struct FurumiFuse {
client: FurumiClient,
rt_handle: Handle,
mapper: InodeMapper,
}
impl FurumiFuse {
pub fn new(client: FurumiClient, rt_handle: Handle) -> Self {
Self {
client,
rt_handle,
mapper: InodeMapper::new(),
}
}
fn make_attr(inode: u64, attr: &furumi_common::proto::AttrResponse) -> FileAttr { fn make_attr(inode: u64, attr: &furumi_common::proto::AttrResponse) -> FileAttr {
let kind = if attr.mode & libc::S_IFDIR != 0 { let kind = if attr.mode & libc::S_IFDIR != 0 {
@@ -91,7 +105,7 @@ impl Filesystem for FurumiFuse {
let name_str = name.to_string_lossy(); let name_str = name.to_string_lossy();
debug!("lookup: parent={} name={}", parent, name_str); debug!("lookup: parent={} name={}", parent, name_str);
let parent_path = match self.get_path(parent) { let parent_path = match self.mapper.get_path(parent) {
Some(p) => p, Some(p) => p,
None => { None => {
reply.error(libc::ENOENT); reply.error(libc::ENOENT);
@@ -115,7 +129,7 @@ impl Filesystem for FurumiFuse {
match attr_res { match attr_res {
Ok(attr) => { Ok(attr) => {
let inode = self.get_inode(&full_path); let inode = self.mapper.get_inode(&full_path);
reply.entry(&TTL, &Self::make_attr(inode, &attr), 0); reply.entry(&TTL, &Self::make_attr(inode, &attr), 0);
} }
Err(_) => { Err(_) => {
@@ -127,7 +141,7 @@ impl Filesystem for FurumiFuse {
fn getattr(&mut self, _req: &Request, ino: u64, _fh: Option<u64>, reply: ReplyAttr) { fn getattr(&mut self, _req: &Request, ino: u64, _fh: Option<u64>, reply: ReplyAttr) {
debug!("getattr: ino={}", ino); debug!("getattr: ino={}", ino);
let path = match self.get_path(ino) { let path = match self.mapper.get_path(ino) {
Some(p) => p, Some(p) => p,
None => { None => {
reply.error(libc::ENOENT); reply.error(libc::ENOENT);
@@ -158,7 +172,7 @@ impl Filesystem for FurumiFuse {
) { ) {
debug!("readdir: ino={} offset={}", ino, offset); debug!("readdir: ino={} offset={}", ino, offset);
let path = match self.get_path(ino) { let path = match self.mapper.get_path(ino) {
Some(p) => p, Some(p) => p,
None => { None => {
reply.error(libc::ENOENT); reply.error(libc::ENOENT);
@@ -192,7 +206,7 @@ impl Filesystem for FurumiFuse {
format!("{}/{}", path, entry.name) format!("{}/{}", path, entry.name)
}; };
let child_ino = self.get_inode(&full_path); let child_ino = self.mapper.get_inode(&full_path);
let kind = if entry.r#type == 4 { let kind = if entry.r#type == 4 {
FileType::Directory FileType::Directory
} else { } else {
@@ -226,7 +240,7 @@ impl Filesystem for FurumiFuse {
) { ) {
debug!("read: ino={} offset={} size={}", ino, offset, size); debug!("read: ino={} offset={} size={}", ino, offset, size);
let path = match self.get_path(ino) { let path = match self.mapper.get_path(ino) {
Some(p) => p, Some(p) => p,
None => { None => {
reply.error(libc::ENOENT); reply.error(libc::ENOENT);
@@ -263,3 +277,43 @@ impl Filesystem for FurumiFuse {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_inode_mapper_root() {
let mapper = InodeMapper::new();
// Root is always 1
assert_eq!(mapper.get_path(1).unwrap(), "/");
assert_eq!(mapper.get_inode("/"), 1);
}
#[test]
fn test_inode_mapper_assignment() {
let mapper = InodeMapper::new();
// New paths get new inodes starting from 2
let path1 = "/hello.txt";
let ino1 = mapper.get_inode(path1);
assert_eq!(ino1, 2);
let path2 = "/dir/world.mkv";
let ino2 = mapper.get_inode(path2);
assert_eq!(ino2, 3);
// Lookup gives the mapped paths back
assert_eq!(mapper.get_path(2).unwrap(), path1);
assert_eq!(mapper.get_path(3).unwrap(), path2);
// Asking for the same path again returns the same inode
assert_eq!(mapper.get_inode(path1), 2);
}
#[test]
fn test_inode_mapper_non_existent() {
let mapper = InodeMapper::new();
assert!(mapper.get_path(999).is_none());
}
}

View File

@@ -10,8 +10,8 @@ use furumi_client_core::FurumiClient;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(version, about = "Furumi-ng: mount remote filesystem via encrypted gRPC + FUSE")] #[command(version, about = "Furumi-ng: mount remote filesystem via encrypted gRPC + FUSE")]
struct Args { struct Args {
/// Server address (use https:// for encrypted, http:// for plaintext) /// Server address (IP:PORT, will use https:// by default unless --no-tls is specified)
#[arg(short, long, env = "FURUMI_SERVER", default_value = "https://0.0.0.0:50051")] #[arg(short, long, env = "FURUMI_SERVER", default_value = "0.0.0.0:50051")]
server: String, server: String,
/// Authentication Bearer token (leave empty if auth is disabled on server) /// Authentication Bearer token (leave empty if auth is disabled on server)
@@ -21,6 +21,10 @@ struct Args {
/// Mount point directory /// Mount point directory
#[arg(short, long, env = "FURUMI_MOUNT")] #[arg(short, long, env = "FURUMI_MOUNT")]
mount: PathBuf, mount: PathBuf,
/// Disable TLS encryption
#[arg(long, default_value_t = false)]
no_tls: bool,
} }
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -38,8 +42,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
.enable_all() .enable_all()
.build()?; .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 { let client = rt.block_on(async {
FurumiClient::connect(&args.server, &args.token).await FurumiClient::connect(&full_addr, &args.token).await
})?; })?;
let fuse_fs = fs::FurumiFuse::new(client, rt.handle().clone()); let fuse_fs = fs::FurumiFuse::new(client, rt.handle().clone());

View File

@@ -0,0 +1,10 @@
use crate::fs::FurumiFuse;
use furumi_client_core::FurumiClient;
// We need a helper to create a dummy client since FurumiFuse requires one.
// The easiest way is to connect to a non-existent port with HTTP, which fails fast,
// but for FurumiFuse::new, it just needs the struct, not an active connection yet.
// Wait, `connect` returns a Result. Since we don't mock the gRPC server here,
// testing FurumiFuse without a real client is hard because FurumiFuse has `client: FurumiClient`.
// Instead, we can refactor the Inode mapper into a separate struct to make it easily testable.

View File

@@ -126,3 +126,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests;

109
furumi-server/src/tests.rs Normal file
View File

@@ -0,0 +1,109 @@
use crate::metrics;
use crate::security::AuthInterceptor;
use tonic::service::Interceptor;
use tonic::Request;
// Since counters are lazy statics, we can get their values directly for testing
use std::thread::sleep;
use std::time::Duration;
#[test]
fn test_metrics_request_timer_ok() {
let method = "test_method_ok";
let timer = metrics::RequestTimer::new(method);
// Simulate some work
sleep(Duration::from_millis(10));
timer.finish_ok();
// Check counters
let started = metrics::GRPC_REQUESTS_TOTAL
.with_label_values(&[method, "started"])
.get();
let ok = metrics::GRPC_REQUESTS_TOTAL
.with_label_values(&[method, "ok"])
.get();
assert_eq!(started, 1.0);
assert_eq!(ok, 1.0);
}
#[test]
fn test_metrics_request_timer_err() {
let method = "test_method_err";
let timer = metrics::RequestTimer::new(method);
// Simulate some work
sleep(Duration::from_millis(5));
timer.finish_err();
// Check counters
let started = metrics::GRPC_REQUESTS_TOTAL
.with_label_values(&[method, "started"])
.get();
let err = metrics::GRPC_REQUESTS_TOTAL
.with_label_values(&[method, "error"])
.get();
assert_eq!(started, 1.0);
assert_eq!(err, 1.0);
}
#[test]
fn test_metrics_render() {
// Just trigger a metric to ensure the registry isn't empty
metrics::BYTES_READ_TOTAL.inc_by(4096.0);
let rendered = metrics::render_metrics();
assert!(rendered.contains("furumi_bytes_read_total"));
assert!(rendered.contains("4096"));
}
#[test]
fn test_server_auth_interceptor_valid() {
let mut interceptor = AuthInterceptor::new("supersecret".to_string());
let mut req = Request::new(());
req.metadata_mut()
.insert("authorization", "Bearer supersecret".parse().unwrap());
let res = interceptor.call(req);
assert!(res.is_ok(), "Valid token should be accepted");
}
#[test]
fn test_server_auth_interceptor_invalid() {
let mut interceptor = AuthInterceptor::new("supersecret".to_string());
let mut req = Request::new(());
req.metadata_mut()
.insert("authorization", "Bearer wrongtoken".parse().unwrap());
let res = interceptor.call(req);
assert!(res.is_err(), "Invalid token should be rejected");
assert_eq!(res.unwrap_err().code(), tonic::Code::Unauthenticated);
}
#[test]
fn test_server_auth_interceptor_missing() {
let mut interceptor = AuthInterceptor::new("supersecret".to_string());
let req = Request::new(());
// Missing metadata entirely
let res = interceptor.call(req);
assert!(
res.is_err(),
"Missing token should be rejected when auth is enabled"
);
assert_eq!(res.unwrap_err().code(), tonic::Code::Unauthenticated);
}
#[test]
fn test_server_auth_interceptor_disabled() {
// Empty string means auth is disabled
let mut interceptor = AuthInterceptor::new("".to_string());
let req = Request::new(());
let res = interceptor.call(req);
assert!(
res.is_ok(),
"Request should pass if auth is disabled on server"
);
}