FIX: TLS options
This commit is contained in:
18
README.md
18
README.md
@@ -26,11 +26,10 @@ cargo build --release --workspace
|
||||
--token mysecrettoken \
|
||||
--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 \
|
||||
--server https://server-ip:50051 \
|
||||
--server server-ip:50051 \
|
||||
--token mysecrettoken \
|
||||
--tls-ca /tmp/furumi-ca.pem \
|
||||
--mount /mnt/remote
|
||||
|
||||
# Use it
|
||||
@@ -40,13 +39,9 @@ mpv /mnt/remote/video.mkv
|
||||
|
||||
## 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:
|
||||
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.
|
||||
To disable TLS (not recommended): `--no-tls` on both server and client.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -60,17 +55,16 @@ All options can be set via CLI flags or environment variables.
|
||||
| `--root` | `FURUMI_ROOT` | `.` | Directory to expose |
|
||||
| `--token` | `FURUMI_TOKEN` | *(empty, auth off)* | Bearer token |
|
||||
| `--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 |
|
||||
|
||||
### Client
|
||||
|
||||
| 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 |
|
||||
| `--mount` | `FURUMI_MOUNT` | — | Mount point directory |
|
||||
| `--tls-ca` | `FURUMI_TLS_CA` | — | Server CA cert PEM file |
|
||||
| `--no-tls` | — | `false` | Disable TLS |
|
||||
|
||||
## Prometheus Metrics
|
||||
|
||||
|
||||
@@ -246,3 +246,6 @@ impl FurumiClient {
|
||||
Ok(stream)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
66
furumi-client-core/src/client/tests.rs
Normal file
66
furumi-client-core/src/client/tests.rs
Normal 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());
|
||||
}
|
||||
@@ -11,20 +11,18 @@ use tokio::runtime::Handle;
|
||||
|
||||
const TTL: Duration = Duration::from_secs(1); // 1 second FUSE kernel TTL
|
||||
|
||||
pub struct FurumiFuse {
|
||||
client: FurumiClient,
|
||||
rt_handle: Handle,
|
||||
// FUSE deals in inodes (u64). We need to map inode -> string path.
|
||||
// In a real VFS, this requires a proper tree or bidirectional map.
|
||||
// For simplicity: inode 1 is always the root "/".
|
||||
// ── InodeMapper ──────────────────────────────────────────────────
|
||||
|
||||
/// Maps FUSE inodes (u64) to remote virtual filesystem paths (String) and vice versa.
|
||||
/// Inode 1 is permanently assigned to the root directory ("/").
|
||||
pub struct InodeMapper {
|
||||
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>>>,
|
||||
next_inode: Arc<Mutex<u64>>,
|
||||
}
|
||||
|
||||
impl FurumiFuse {
|
||||
pub fn new(client: FurumiClient, rt_handle: Handle) -> Self {
|
||||
impl InodeMapper {
|
||||
pub fn new() -> Self {
|
||||
let mut inode_to_path = HashMap::new();
|
||||
let mut path_to_inode = HashMap::new();
|
||||
|
||||
@@ -33,15 +31,13 @@ impl FurumiFuse {
|
||||
path_to_inode.insert("/".to_string(), 1);
|
||||
|
||||
Self {
|
||||
client,
|
||||
rt_handle,
|
||||
inode_to_path: Arc::new(Mutex::new(inode_to_path)),
|
||||
path_to_inode: Arc::new(Mutex::new(path_to_inode)),
|
||||
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();
|
||||
if let Some(&inode) = p2i.get(path) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
let kind = if attr.mode & libc::S_IFDIR != 0 {
|
||||
@@ -91,7 +105,7 @@ impl Filesystem for FurumiFuse {
|
||||
let name_str = name.to_string_lossy();
|
||||
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,
|
||||
None => {
|
||||
reply.error(libc::ENOENT);
|
||||
@@ -115,7 +129,7 @@ impl Filesystem for FurumiFuse {
|
||||
|
||||
match attr_res {
|
||||
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);
|
||||
}
|
||||
Err(_) => {
|
||||
@@ -127,7 +141,7 @@ impl Filesystem for FurumiFuse {
|
||||
fn getattr(&mut self, _req: &Request, ino: u64, _fh: Option<u64>, reply: ReplyAttr) {
|
||||
debug!("getattr: ino={}", ino);
|
||||
|
||||
let path = match self.get_path(ino) {
|
||||
let path = match self.mapper.get_path(ino) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
reply.error(libc::ENOENT);
|
||||
@@ -158,7 +172,7 @@ impl Filesystem for FurumiFuse {
|
||||
) {
|
||||
debug!("readdir: ino={} offset={}", ino, offset);
|
||||
|
||||
let path = match self.get_path(ino) {
|
||||
let path = match self.mapper.get_path(ino) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
reply.error(libc::ENOENT);
|
||||
@@ -192,7 +206,7 @@ impl Filesystem for FurumiFuse {
|
||||
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 {
|
||||
FileType::Directory
|
||||
} else {
|
||||
@@ -226,7 +240,7 @@ impl Filesystem for FurumiFuse {
|
||||
) {
|
||||
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,
|
||||
None => {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ use furumi_client_core::FurumiClient;
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about = "Furumi-ng: mount remote filesystem via encrypted gRPC + FUSE")]
|
||||
struct Args {
|
||||
/// Server address (use https:// for encrypted, http:// for plaintext)
|
||||
#[arg(short, long, env = "FURUMI_SERVER", default_value = "https://0.0.0.0:50051")]
|
||||
/// 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)
|
||||
@@ -21,6 +21,10 @@ struct Args {
|
||||
/// 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>> {
|
||||
@@ -38,8 +42,22 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.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(&args.server, &args.token).await
|
||||
FurumiClient::connect(&full_addr, &args.token).await
|
||||
})?;
|
||||
|
||||
let fuse_fs = fs::FurumiFuse::new(client, rt.handle().clone());
|
||||
|
||||
10
furumi-mount-linux/src/tests.rs
Normal file
10
furumi-mount-linux/src/tests.rs
Normal 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.
|
||||
|
||||
@@ -126,3 +126,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
109
furumi-server/src/tests.rs
Normal file
109
furumi-server/src/tests.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user