warpgate/src/rclone/mount.rs
grabbit 5efef83a90 Add multi_thread_streams/cutoff support and Samba performance tuning
- Add multi_thread_streams (default 4) and multi_thread_cutoff (default "50M")
  fields to ReadConfig, wired into rclone mount args
- Expose both fields in Web UI config editor under Read Tuning section
- Add Samba performance options: TCP_NODELAY, large readwrite, max xmit
- Update config.toml.default with new fields and sftp_connections guidance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 14:15:23 +08:00

318 lines
10 KiB
Rust

//! Manage rclone VFS FUSE mount lifecycle.
use std::path::Path;
use anyhow::{Context, Result};
use crate::config::{Config, ShareConfig};
use super::config::RCLONE_CONF_PATH;
/// Build the full `rclone mount` command-line arguments for a single share.
///
/// Each share gets its own rclone mount process with a dedicated RC port.
pub fn build_mount_args(config: &Config, share: &ShareConfig, rc_port: u16) -> Vec<String> {
let mut args = Vec::new();
// Subcommand and source:dest
args.push("mount".into());
args.push(format!("{}:{}", share.connection, share.remote_path));
args.push(share.mount_point.display().to_string());
// Point to our generated rclone.conf
args.push("--config".into());
args.push(RCLONE_CONF_PATH.into());
// VFS cache mode — full enables read-through + write-back
args.push("--vfs-cache-mode".into());
args.push("full".into());
// Write-back delay
args.push("--vfs-write-back".into());
args.push(config.writeback.write_back.clone());
// Cache size limits
args.push("--vfs-cache-max-size".into());
args.push(config.cache.max_size.clone());
args.push("--vfs-cache-max-age".into());
args.push(config.cache.max_age.clone());
// NOTE: --vfs-cache-min-free-space requires rclone 1.65+.
// Ubuntu apt may ship older versions. We detect support at runtime.
if rclone_supports_min_free_space() {
args.push("--vfs-cache-min-free-space".into());
args.push(config.cache.min_free.clone());
}
// Cache directory (SSD path)
args.push("--cache-dir".into());
args.push(config.cache.dir.display().to_string());
// Directory listing cache TTL
args.push("--dir-cache-time".into());
args.push(config.directory_cache.cache_time.clone());
// Read optimization
args.push("--buffer-size".into());
args.push(config.read.buffer_size.clone());
args.push("--vfs-read-chunk-size".into());
args.push(config.read.chunk_size.clone());
args.push("--vfs-read-chunk-size-limit".into());
args.push(config.read.chunk_limit.clone());
args.push("--vfs-read-ahead".into());
args.push(config.read.read_ahead.clone());
// Multi-thread download: splits large files across N parallel SFTP streams
args.push("--multi-thread-streams".into());
args.push(config.read.multi_thread_streams.to_string());
args.push("--multi-thread-cutoff".into());
args.push(config.read.multi_thread_cutoff.clone());
// Concurrent transfers for write-back
args.push("--transfers".into());
args.push(config.writeback.transfers.to_string());
// SFTP connection resilience: increase retries for flaky tunnels (Tailscale/WireGuard)
args.push("--retries".into());
args.push("10".into());
args.push("--low-level-retries".into());
args.push("20".into());
args.push("--retries-sleep".into());
args.push("1s".into());
args.push("--contimeout".into());
args.push("30s".into());
// Bandwidth limits (only add flag if at least one direction is limited)
let bw = format_bwlimit(&config.bandwidth.limit_up, &config.bandwidth.limit_down);
if bw != "0" {
args.push("--bwlimit".into());
args.push(bw);
}
// Enable rclone RC API on per-share port
args.push("--rc".into());
args.push("--rc-addr".into());
args.push(format!("127.0.0.1:{rc_port}"));
// Allow non-root users to access the FUSE mount (requires user_allow_other in /etc/fuse.conf)
args.push("--allow-other".into());
args
}
/// Format the `--bwlimit` value from separate up/down strings.
///
/// rclone accepts `RATE` for symmetric or `UP:DOWN` for asymmetric limits.
/// Returns `"0"` when both directions are unlimited.
fn format_bwlimit(up: &str, down: &str) -> String {
let up_zero = up == "0" || up.is_empty();
let down_zero = down == "0" || down.is_empty();
match (up_zero, down_zero) {
(true, true) => "0".into(),
_ => format!("{up}:{down}"),
}
}
/// Check if rclone supports `--vfs-cache-min-free-space` (added in v1.65).
///
/// Runs `rclone mount --help` and checks for the flag in the output.
/// Returns false if rclone is not found or the flag is absent.
fn rclone_supports_min_free_space() -> bool {
std::process::Command::new("rclone")
.args(["mount", "--help"])
.output()
.map(|o| {
let stdout = String::from_utf8_lossy(&o.stdout);
stdout.contains("--vfs-cache-min-free-space")
})
.unwrap_or(false)
}
/// Build the rclone mount command as a string (for systemd ExecStart).
pub fn build_mount_command(config: &Config, share: &ShareConfig, rc_port: u16) -> String {
let args = build_mount_args(config, share, rc_port);
format!("/usr/bin/rclone {}", args.join(" "))
}
/// Check if a FUSE mount is currently active at the given mount point.
pub fn is_mounted(mount_point: &Path) -> Result<bool> {
let mp_str = mount_point.display().to_string();
let content = std::fs::read_to_string("/proc/mounts")
.with_context(|| "Failed to read /proc/mounts")?;
for line in content.lines() {
// /proc/mounts format: device mountpoint fstype options dump pass
let mut fields = line.split_whitespace();
let _device = fields.next();
if let Some(mp) = fields.next()
&& mp == mp_str {
return Ok(true);
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> Config {
toml::from_str(
r#"
[[connections]]
name = "nas"
nas_host = "10.0.0.1"
nas_user = "admin"
[cache]
dir = "/tmp/cache"
[read]
[bandwidth]
[writeback]
[directory_cache]
[protocols]
[[shares]]
name = "photos"
connection = "nas"
remote_path = "/photos"
mount_point = "/mnt/photos"
"#,
)
.unwrap()
}
#[test]
fn test_format_bwlimit_both_zero() {
assert_eq!(format_bwlimit("0", "0"), "0");
}
#[test]
fn test_format_bwlimit_both_empty() {
assert_eq!(format_bwlimit("", ""), "0");
}
#[test]
fn test_format_bwlimit_mixed_zero_empty() {
assert_eq!(format_bwlimit("0", ""), "0");
assert_eq!(format_bwlimit("", "0"), "0");
}
#[test]
fn test_format_bwlimit_up_only() {
assert_eq!(format_bwlimit("10M", "0"), "10M:0");
}
#[test]
fn test_format_bwlimit_down_only() {
assert_eq!(format_bwlimit("0", "50M"), "0:50M");
}
#[test]
fn test_format_bwlimit_both_set() {
assert_eq!(format_bwlimit("10M", "50M"), "10M:50M");
}
#[test]
fn test_build_mount_args_contains_essentials() {
let config = test_config();
let share = &config.shares[0];
let args = build_mount_args(&config, share, 5572);
assert_eq!(args[0], "mount");
assert_eq!(args[1], "nas:/photos");
assert_eq!(args[2], "/mnt/photos");
assert!(args.contains(&"--config".to_string()));
assert!(args.contains(&RCLONE_CONF_PATH.to_string()));
assert!(args.contains(&"--vfs-cache-mode".to_string()));
assert!(args.contains(&"full".to_string()));
assert!(args.contains(&"--vfs-write-back".to_string()));
assert!(args.contains(&"5s".to_string()));
assert!(args.contains(&"--vfs-cache-max-size".to_string()));
assert!(args.contains(&"200G".to_string()));
assert!(args.contains(&"--vfs-cache-max-age".to_string()));
assert!(args.contains(&"720h".to_string()));
assert!(args.contains(&"--cache-dir".to_string()));
assert!(args.contains(&"/tmp/cache".to_string()));
assert!(args.contains(&"--dir-cache-time".to_string()));
assert!(args.contains(&"1h".to_string()));
assert!(args.contains(&"--buffer-size".to_string()));
assert!(args.contains(&"--multi-thread-streams".to_string()));
assert!(args.contains(&"--multi-thread-cutoff".to_string()));
assert!(args.contains(&"--transfers".to_string()));
assert!(args.contains(&"4".to_string()));
assert!(args.contains(&"--rc".to_string()));
assert!(args.contains(&"--rc-addr".to_string()));
assert!(args.contains(&"127.0.0.1:5572".to_string()));
assert!(args.contains(&"--allow-other".to_string()));
}
#[test]
fn test_build_mount_args_no_bwlimit_when_unlimited() {
let config = test_config();
let share = &config.shares[0];
let args = build_mount_args(&config, share, 5572);
assert!(!args.contains(&"--bwlimit".to_string()));
}
#[test]
fn test_build_mount_args_with_bwlimit() {
let mut config = test_config();
config.bandwidth.limit_up = "10M".into();
config.bandwidth.limit_down = "50M".into();
let share = &config.shares[0];
let args = build_mount_args(&config, share, 5572);
assert!(args.contains(&"--bwlimit".to_string()));
assert!(args.contains(&"10M:50M".to_string()));
}
#[test]
fn test_build_mount_command_format() {
let config = test_config();
let share = &config.shares[0];
let cmd = build_mount_command(&config, share, 5572);
assert!(cmd.starts_with("/usr/bin/rclone mount"));
assert!(cmd.contains("nas:/photos"));
assert!(cmd.contains("/mnt/photos"));
}
#[test]
fn test_build_mount_args_custom_config() {
let mut config = test_config();
config.shares[0].remote_path = "/volume1/media".into();
config.shares[0].mount_point = std::path::PathBuf::from("/mnt/media");
config.cache.dir = std::path::PathBuf::from("/ssd/cache");
config.writeback.transfers = 16;
let share = &config.shares[0];
let args = build_mount_args(&config, share, 5573);
assert_eq!(args[1], "nas:/volume1/media");
assert_eq!(args[2], "/mnt/media");
assert!(args.contains(&"/ssd/cache".to_string()));
assert!(args.contains(&"16".to_string()));
assert!(args.contains(&"127.0.0.1:5573".to_string()));
}
#[test]
fn test_build_mount_args_different_rc_ports() {
let config = test_config();
let share = &config.shares[0];
let args0 = build_mount_args(&config, share, 5572);
assert!(args0.contains(&"127.0.0.1:5572".to_string()));
let args1 = build_mount_args(&config, share, 5573);
assert!(args1.contains(&"127.0.0.1:5573".to_string()));
}
}